Lab6 实验报告

Part1. 思考题

Thinking 6.1 父进程为读者

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <stdlib.h>
#include <unistd.h>
int fildes[2];
char buf[100];
int status;
int main(){
status = pipe(fildes);
if(status ==-1) {
printf("error\n");
}
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("parent-processread: %s",buf); /*打印读到的数据*/
close(fildes[0]); /*读取结束,关闭读端*/
exit(EXIT_SUCCESS);
}
}

Thinking 6.2 dup 中的进程竞争

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

dup 实现里,复制一个文件描述符 oldfd 到 newfd ,总共分两步调用 syscall_mem_map 来完成:

  1. 映射文件描述符(struct Fd)
  2. 映射文件内容对应的内存页(page mapping)

这两步会分别对引用计数 pp_ref 做一次“+1”操作。如果在这两次映射之间发生了进程切换,就可能出现下面这种情况:
假设有一个 pipe,引用次数:pp_ref=2,fd[0]=1,fd[1]=1。进程 A 在执行 dup(oldfd, newfd)

  1. 第一条 syscall_mem_map(… oldfd → newfd …) 已经执行完毕,fd[0]引用计数被+1,此时 fd[0]=2,
  2. 紧接着要执行第二条映射(内容页映射),此时pp_ref 还未+1,但刚好被打断,调度到进程 B。

此时进程 B 中如果执行 _pipe_is_closed(fd, p) 则会根据 pp_ref == fd[0] == 2 错误地判断写端关闭,导致错误行为。

Thinking 6.3 原子操作

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

系统调用会陷入内核态,在内核态下,CPU 会屏蔽中断,这样就可以保证系统调用的原子性。也就是说,在执行系统调用期间,CPU 不会被时钟中断打断,因此不会发生上下文切换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// in entry.S
exc_gen_entry:
SAVE_ALL
/*
* Note: When EXL is set or UM is unset, the processor is in kernel mode.
* When EXL is set, the value of EPC is not updated when a new exception occurs.
* To keep the processor in kernel mode and enable exception reentrancy,
* we unset UM and EXL, and unset IE to globally disable interrupts.
*/
mfc0 t0, CP0_STATUS
and t0, t0, ~(STATUS_UM | STATUS_EXL | STATUS_IE)
mtc0 t0, CP0_STATUS
/* Exercise 3.9: Your code here. */
mfc0 t0, CP0_CAUSE
andi t0, 0x7c
lw t0, exception_handlers(t0)
jr t0

Thinking 6.4 解决进程竞争

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

  • pipe_close 函数中,按照上述说法控制 fdpipeunmap 的顺序,可以解决进程竞争问题。具体来说,我们可以先关闭文件描述符fd,然后再解除管道的映射pipe。这样做的好处是,始终保证了pageref(pipe) > pageref(fd) ,从而避免了发生中断时出现二者相等的情况。
  • dup 函数中会出现与 close 类似的问题。因为在 dup 函数中,复制文件描述符时也会涉及到对管道的引用计数和映射操作。如果在复制过程中发生了进程切换,就可能导致类似的竞争条件。因此,在实现 dup 函数时,也需要首先让 pipe->pp_ref++,从而确保pageref(pipe) > pageref(fd),避免在进程切换时出现二者相等的情况。

Thinking 6.5 如何加载 bss 段

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

  • 打开文件的过程主要涉及到文件系统的用户接口和内核实现。用户进程通过调用 user/lib/file.c 中的 open() 请求打开一个文件,函数接着调用 fsipc 模块的 fsipc_open() 函数,向文件系统发送 IPC 请求。文件服务进程接收到请求后,通过 serve 函数调用 file_open() 来处理打开文件的逻辑。
  • 加载 ELF 文件的过程主要由 kern/env.c 中的 load_icode() 函数实现。该函数首先通过 elf_from 读取 ELF 文件头,然后根据 ELF 文件头中的段信息,调用 elf_load_seg() 函数来加载各个段到虚拟内存中:映射文件中真实数据到 [p_vaddr, p_vaddr + p_filesz) 的虚拟地址空间,并将未初始化的 bss 段映射到 [p_vaddr + p_filesz, p_vaddr + p_memsz) 的虚拟地址空间,将 bss 段的初始值设置为 0。
  • elf_load_seg() 处理每个段时,会通过调用 load_icode_mapper() 函数来映射文件到对应的虚拟内存地址空间。对于 bss 段,虽然在 ELF 文件中并不占用实际数据,但在加载时会根据 p_memsz 的值为 bss 段分配足够的虚拟内存空间,并将其初始化为 0。这是通过为 bss 段不断分配页面并 在 load_icode_mapper() 中将 bss 段的内存区域置零来实现的。

Thinking 6.6 内置命令和外部命令

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

1
2
3
4
5
6
7
8
9
// in user/init.c
// 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 命令是内部命令而不是外部命令?

查看 user/sh.c 可知,MOS 中所有的 shell 命令都是外部命令。因为每个命令都需要通过 fork() 创建一个子进程来执行,子进程会调用 runcmd() 函数来处理命令。
Linux 的 cd 命令是内部命令而不是外部命令,因为它直接修改了当前 shell 进程的工作目录。执行 cd 命令时,shell 需要更新其内部状态,而不是创建一个新的子进程来执行该操作。这样可以避免不必要的资源开销和上下文切换,提高效率。

Thinking 6.8 解释命令执行的现象

在你的 shell 中输入命令 ls.b | cat.b > motd。
• 请问你可以在你的shell 中观察到几次spawn?分别对应哪个进程?
• 请问你可以在你的shell 中观察到几次进程销毁?分别对应哪个进程?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ ls.b | cat.b > motd
[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 ...
  • spawn 2次,分别是 00002803 进程spawn 00003805 进程和 00003004 进程 spawn 00004006 进程。

  • 进程销毁 4 次,分别对应:

    • 00003805 进程(00002803 fork 出的子进程)解析并执行ls.b完毕后销毁。
    • 00004006 进程(00003004 fork 出的子进程)执行管道右侧命令cat.b完毕后销毁。
    • 00003004 进程(00002803 fork 出的子进程)解析并执行cat.b > motd完毕后销毁。
    • 00002803 进程(主shell 的子进程)解析并执行ls.b | cat > motd完毕后销毁。

Part 2. 难点分析

Lab6 的实验设计主要围绕Shell的实现以及管道通信展开,实验的难点主要体现在以下几个方面:

  • Shell 的启动与执行:需要理解进程的创建、执行和销毁过程,以及如何解析用户输入的命令,清楚Shell的函数调用流程。
  • 管道通信的实现:需要掌握如何在进程间建立管道,如何通过管道传递数据,以及如何处理管道的读写端。
  • 文件描述符的管理:需要加深Lab5中文件描述符的概念,如何通过 dup 函数复制文件描述符,以及如何在进程间共享文件描述符。

Part 3. 实验体会

Lab6 是一个综合性较强的实验,通过实现Shell和管道通信深入理解操作系统中进程管理、文件描述符和进程间通信的机制,也完成了整个MOS系统的内容,我们也从头体验自己动手完成一个小操作系统的乐趣,收获颇丰。