北航操作系统Lab6实验报告

2025-06-01

本次实验主要实现了MOS操作系统的shell命令行

Thinking 6.1

示例代码中,父进程操作管道的写端,子进程操作管道的读端。如果现在想让父进程作为“读者”,代码应当如何修改?

switch (fork()) {
    case -1:
        break;   
    case 0:
        close(fildes[0]);
        write(fildes[1], "Hello world\n", 12);
        close(fildes[1]);
        exit(EXIT_SUCCESS);
    default:
        close(fildes[1]);
        read(fildes[0], buf, 100); 
        printf("father-process read:%s",buf);
        close(fildes[0]);
        exit(EXIT_SUCCESS);
}

Thinking 6.2

上面这种不同步修改 pp_ref 而导致的进程竞争问题在 user/lib/fd.c 中 的 dup 函数中也存在。请结合代码模仿上述情景,分析一下我们的 dup 函数中为什么会出现预想之外的情况?

此前的 dup 函数先映射文件描述符,再映射文件内容,这样就会出现pageref(fd) 比 pageref(pipe) 先进行更新的情况,在两次map中发生进程切换,转换到其他进程运行,而其他进程调用了 pipe_is_closed(p[0]) ,而此时的确满足 pageref(p[1]) == pageref(pipe),就会得出管道已经关闭的错误结论

Thinking 6.3

阅读上述材料并思考:为什么系统调用一定是原子操作呢?如果你觉得不是所有的系统调用都是原子操作,请给出反例。希望能结合相关代码进行分析说明。

系统调用是由 user/lib/syscall_wrap.S 中msyscall 函数中的 syscall 汇编指令触发的,通过 syscall 陷入内核态处理系统调用时,已经关闭了时钟中断,所以系统调用一定是原子操作

LEAF(msyscall)
	// Just use 'syscall' instruction and return.
	syscall
	jr      ra
END(msyscall)

Thinking 6.4

仔细阅读上面这段话,并思考下列问题:

  • 按照上述说法控制 pipe_close 中 fd 和 pipe unmap的顺序,是否可以解决上述场景的进程竞争问题?给出你的分析过程。
  • 我们只分析了 close时的情形,在 fd.c中有一个 dup函数,用于复制文件描述符。 试想,如果要复制的文件描述符指向一个管道,那么是否会出现与 close 类似的问题?请模仿上述材料写写你的理解。

  • 可以解决,如下设置 unmap 操作的顺序,在两次 unmap 中间对 pipe_close 进行中断,这样必然有 page_ref(fd) < page_ref(pipe) 成立,因此不会在这个过程中对管道的开关过程发生误判
static int pipe_close(struct Fd *fd) {
    // 解除文件描述符的内存映射
    syscall_mem_unmap(0, fd);
    // 解除管道结构体的内存映射
    syscall_mem_unmap(0, (void *)fd2data(fd));
    return 0;  // 关闭成功
}
  • 会出现同样的问题,先映射文件描述符,再映射文件内容,会出现 pageref(fd) 比 pageref(pipe) 先进行更新的情况,在进程切换时可能导致 page_ref(fd) == page_ref(pipe),得出管道已经关闭的错误结论

Thinking 6.5

思考以下三个问题:

  • 认真回看Lab5文件系统相关代码,弄清打开文件的过程。
  • 回顾Lab1与Lab3,思考如何读取并加载ELF文件。
  • 在Lab1 中我们介绍了 data text bss 段及它们的含义,data 段存放初始化过的全局变量,bss段存放未初始化的全局变量。关于memsize和filesize,我们在Note 1.3.4中也解释了它们的含义与特点。关于Note1.3.4,注意其中关于“bss段并不在文件中占数据”表述的含义。回顾Lab3并思考:elf_load_seg()和load_icode_mapper() 函数是如何确保加载ELF文件时,bss段数据被正确加载进虚拟内存空间。bss段 在ELF中并不占空间,但ELF加载进内存后,bss段的数据占据了空间,并且初始值都是0。请回顾elf_load_seg() 和 load_icode_mapper() 的实现,思考这一点 是如何实现的?

下面给出一些对于上述问题的提示,以便大家更好地把握加载内核进程和加载用户进程的 区别与联系,类比完成 spawn函数。

关于第一个问题,在Lab3中我们创建进程,并且通过 ENV_CREATE(…) 在内核态加载了初始进程,而我们的 spawn函数则是通过和文件系统交互,取得文件描述块,进而找到ELF 在“硬盘”中的位置,进而读取。

关于第二个问题,各位已经在Lab3中填写了load_icode 函数,实现了ELF 可执行文件中读取数据并加载到内存空间,其中通过调用elf_load_seg 函数来加载各个程序段。 在Lab3 中我们要填写 load_icode_mapper 回调函数,在内核态下加载 ELF 数据到内存 空间;相应地,在Lab6中spawn函数也需要在用户态下使用系统调用为ELF数据分配空间。

  • ELF文件中的段信息是通过 Elf32_Phdr 结构体数组来描述的,其中包含了各个段的类型、在文件中的偏移、在内存中的虚拟地址、大小等信息。当处理到 .bss 段时,elf_load_seg 函数会调用 map_page 把相应的虚拟地址映射到物理页上,但不从源数据中复制任何内容;同时,在 map_page 的内部会调用 load_icode_mapper 函数,该函数调用 page_insert 函数,将页面进行映射并指定权限,并初始化页面为 0,实现将.bss 段的变量全部置为 0 的效果

Thinking 6.6

通过阅读代码空白段的注释我们知道,将标准输入或输出定向到文件,需要我们将其dup到0或1号文件描述符(fd)。那么问题来了:在哪步,0和1被“安排”为标准输入和标准输出?请分析代码执行流程,给出答案。

// user/init.c 的 main() 函数中
// stdin should be 0, because no file descriptors are open yet
if ((r = opencons()) != 0) {
    user_panic("opencons: %d", r);
}
// stdout
if ((r = dup(0, 1)) < 0) {
    user_panic("dup: %d", r);
}

Thinking 6.7

在 shell 中执行的命令分为内置命令和外部命令。在执行内置命令时shell不需要fork一个子 shell,如 Linux 系统中的 cd 命令。在执行外部命令时 shell 需要 fork 一个子shell,然后子 shell 去执行这条命令。 据此判断,在 MOS 中我们用到的 shell 命令是内置命令还是外部命令?请思考为什么 Linux 的 cd 命令是内部命令而不是外部命令?

  • MOS 中用到的 shell 命令中,echocmds 和注释为内置命令,不需要 fork 子进程便可以执行;其他命令每次执行时都会通过 fork 创建一个子进程,是外部命令

  • Linux 中的 cd 命令设计为内部命令,原因是 cd 命令使用频率较高,若设置为外部指令必然会在 cd 的时候多次调用 fork 生成子进程,这是低效的,将其设置为内部指令可以提高我们操作系统的效率

Thinking 6.8

在你的 shell 中输入命令 ls.b cat.b > motd。
  • 请问你可以在你的shell 中观察到几次spawn?分别对应哪个进程?
  • 请问你可以在你的shell 中观察到几次进程销毁?分别对应哪个进程?
[00002803] pipecreate 
[00003805] destroying 00003805
[00003805] free env 00003805
i am killed ... 
[00004006] destroying 00004006
[00004006] free env 00004006
i am killed ... 
[00003004] destroying 00003004
[00003004] free env 00003004
i am killed ... 
[00002803] destroying 00002803
[00002803] free env 00002803
i am killed ... 
  • shell 中进行了 2次 spawn,主shell进程 fork出来的 2803 进程spawn出了3805进程,2803进程 fork 出来的 3004 进程 spawn 出了 4006 进程
  • shell 中创建并运行了4个进程,因此进行了4次进程销毁:
    • 2803进程:由主shell进程 fork 出来的子 shell 进程,用于解析并执行整个命令
    • 3805进程:由2803进程 spawn 出来的子进程,用于执行3004进程管道左边的命令 ls.b
    • 3004进程:由2803进程 fork 出来的子进程,用于解析并执行管道右端的命令 cat.b > motd
    • 4006进程:由3004进程spawn出来的子进程,用于执行管道右边的命令 cat.b > motd

实验难点

  1. 进程同步问题:管道操作中,时钟中断可能在函数执行中途发生,导致进程竞争问题,这是一大难点。在 pipe_close 和 dup 函数里,pp_ref 的修改顺序至关重要。若先操作文件描述符的映射或解除映射,再操作管道,可能出现引用计数不一致的情况,可能误判读写进程是否关闭。
  2. Spawn 函数实现:spawn 函数需要综合运用多个实验的知识,实现起来较为复杂。它涉及 ELF 文件的加载,要在用户态通过系统调用分配内存空间,并将文件内容映射到子进程的虚拟地址空间,这与 Lab3 内核态加载 ELF 的流程有相似之处。
  3. Shell 解析与执行:Shell 需要解析用户输入的命令,处理管道、重定向等复杂场景。例如,执行复杂命令时,要创建管道,fork 子进程,子进程可能还要 spawn 进程,要求对进程创建、销毁流程有清晰的理解,否则容易出现进程管理混乱的问题。

实验感想

  1. 综合性与系统性的实践:Lab6 是对前序实验的综合运用,从底层的内存管理、进程调度,到文件系统、系统调用,再到用户态的 Shell 和管道机制,形成了一个完整的操作系统雏形。通过实现管道和 Shell,切实体会到操作系统各模块之间的协同工作,例如管道依赖内存映射和进程间通信,Shell 依赖文件系统和进程管理,这种综合性实践让知识体系更加连贯。
  2. 从抽象到实用的跨越:相比之前单纯操作内核代码、输出内容的实验,本次实验实现的 Shell 可以与用户交互,执行实际命令,如文件列表查看、内容输出等,让操作系统从抽象的概念变得具体可感。看着自己实现的系统能响应输入、处理复杂命令,成就感十足,也加深了对操作系统实用性的理解。
  3. 知识巩固与未来展望:实验过程中,对 ELF 格式、进程虚拟内存、系统调用流程等知识进行了复习和深化,弥补了之前理解的不足,同时,Shell 的设计将命令解析与执行分离,体现了模块化思想,为后续扩展(如支持更多命令)奠定了基础。Lab6 作为 OS 实验的终点,不仅是对课程的总结,更是深入探索操作系统内核的新起点,激发了对操作系统底层技术的持续兴趣。