lab6实验报告

思考题

Thinking 6.1

题面

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

回答

只需要将父子进程中的操作流程反过来即可,也就是在子进程中关闭读端close(fildes[0]),然后进行写操作,并且在写入结束后关闭写端;再父进程中关闭写端close(fildes[1]),然后进行读操作,并且在读取结束后,关闭读端即可。

Thinking 6.2

题面

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

回答

dup函数代码如下:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/* Overview:
* Duplicate the file descriptor.
* Post-Condition:
* Return 'newfdnum' on success.
* Return the corresponding error code on error.
* Hint:
* Use 'fd_lookup' or 'INDEX2FD' to get 'fd' to 'fdnum'.
* Use 'fd2data' to get the data address to 'fd'.
* Use 'syscall_mem_map' to share the data pages.
*/
int dup(int oldfdnum, int newfdnum) {
int i, r;
void *ova, *nva;
u_int pte;
struct Fd *oldfd, *newfd;
/* Step 1: Check if 'oldnum' is valid. if not, return an error code, or get 'fd'. */
if ((r = fd_lookup(oldfdnum, &oldfd)) < 0) {
return r;
}
/* Step 2: Close 'newfdnum' to reset content. */
close(newfdnum);
/* Step 3: Get 'newfd' reffered by 'newfdnum'. */
newfd = (struct Fd *)INDEX2FD(newfdnum);
/* Step 4: Get data address. */
ova = fd2data(oldfd);
nva = fd2data(newfd);
/* Step 5: Dunplicate the data and 'fd' self from old to new. */
if ((r = syscall_mem_map(0, oldfd, 0, newfd, vpt[VPN(oldfd)] & (PTE_D | PTE_LIBRARY))) <
0) {
goto err;
}
if (vpd[PDX(ova)]) {
for (i = 0; i < PDMAP; i += PTMAP) {
pte = vpt[VPN(ova + i)];
if (pte & PTE_V) {
// should be no error here -- pd is already allocated
if ((r = syscall_mem_map(0, (void *)(ova + i), 0, (void *)(nva + i),
pte & (PTE_D | PTE_LIBRARY))) < 0) {
goto err;
}
}
}
}
return newfdnum;
err:
/* If error occurs, cancel all map operations. */
panic_on(syscall_mem_unmap(0, newfd));
for (i = 0; i < PDMAP; i += PTMAP) {
panic_on(syscall_mem_unmap(0, (void *)(nva + i)));
}
return r;
}

dup函数大致作用就是将oldfdnum所表示的文件描述符的数据完全赋值给newfdnum,总共包含两个过程:1.将newfdnum对应得newfd所在虚拟页映射到oldfdnum对应的oldfd所在物理页上;2.将newfd的数据所在的虚拟页映射到oldfd的数据所在物理页上。
模仿指导书的例子,给出如下场景:

1
2
3
4
5
6
7
8
9
pipe(p);
if (fork() == 0) {
close(p[1]);
read(p[0], buf, sizeof buf);
}
else {
dup(p[0], newfd);
write(p[1], "Hello", 5);
}

fork结束后,子进程先执行,时钟中断在close(p[1])read之间,父进程执行。
父进程在dup(p[0], newfd)过程中,已经增加了newfd对于p[0]的映射,但是对pipe的还并未成功,假如此时时钟中断产生,进程调度后子进程运行。
此时各页的引用情况如下:pageref(p[0]) = 3(除了父子进程的,还有新复制的文件描述符newfd的映射),pageref(p[1]) = 1(只有父进程的映射),而pageref(p) = 3,此时子进程中p[0]引用了pipe,父进程中p[0]p[1]都引用了pipe,而新的文件描述符由于被打断了并未进行映射。
因此此时再回到子进程,执行read函数,判断到pageref(p[0]) == pageref(p),表明了写端关闭,则子进程退出,出现了意料之外的事。

Thinking 6.3

题面

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

回答

如果系统调用不是原子操作,那么在抢占式进程调度和多进程竞争的情况下,很多本来应该”一气呵成”完成的操作会被破碎开,导致出现因为调度不同的运行结果不同的意料之外的情况出现。
具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.section .text.exc_gen_entry
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) #清除Status寄存器值中的UM、EXL、IE位,使CPU保持内核态、关中断并允许嵌套异常
mtc0 t0, CP0_STATUS #写入Status寄存器中
mfc0 t0, CP0_CAUSE
andi t0, 0x7c #取Cause寄存器中的2-6位,即异常的类型码
lw t0, exception_handlers(t0) #以异常码为索引在exception_handlers数组里找对应中断处理函数
jr t0 #跳转到相应的中断处理程序处

该代码是对于异常的初步处理,其中and t0, t0, ~(STATUS_UM | STATUS_EXL | STATUS_IE)并连结mtc0 t0, CP0_STATUS,写入Status寄存器中,实现了关中断,避免了在此后的中断处理程序运行过程中出现被时钟中断打断的情况。而这里当然包含了系统调用。

Thinking 6.4

题面

话:

1
2
3
4
5
_pipe_is_closed 函数返回正确结果的条件其实只是:
• 写端关闭当且仅当 pageref(p[0]) == pageref(pipe);
• 读端关闭当且仅当 pageref(p[1]) == pageref(pipe);
比如说第一个条件,写端关闭时,当然有 pageref(p[0]) == pageref(pipe)。但是由于进程切换的存在,我们无法确保 当 pageref(p[0]) == pageref(pipe) 时,写端关闭。正面如果不好解决问题,我们可以考虑从其逆否命题着手,即我们要确保:当写端没有关闭的时候,pageref(p[0]) != pageref(pipe)。
我们考虑之前那个预想之外的情景,它出现的最关键原因在于:pipe 的引用次数总比 fd要高。当管道的 close 进行到一半时,若先解除 pipe 的映射,再解除 fd 的映射,就会使得 pipe 引用次数的 -1 先于 fd。这就导致在两个 unmap 的间隙,会出现 pageref(pipe) ==pageref(fd) 的情况。若调换 fd 和 pipeclose 中的 unmap 顺序,使得 fd 引用次数的 -1先于 pipe。在两个 unmap 的间隙,pageref(pipe) > pageref(fd) 仍成立,即使此时发生中断,也不会影响判断管道是否关闭的正确性。
1
2
3
仔细阅读上面这段话,并思考下列问题
• 按照上述说法控制 pipe_close 中 fd 和 pipe unmap 的顺序,是否可以解决上述场景的进程竞争问题?给出你的分析过程。
• 我们只分析了 close 时的情形,在 fd.c 中有一个 dup 函数,用于复制文件描述符。试想,如果要复制的文件描述符指向一个管道,那么是否会出现与 close 类似的问题?请模仿上述材料写写你的理解。

回答

  1. 我认为可以解决。正如该话所述,此前进程竞争发生的原因在于:pipe的引用次数总比fd要高且close时先解除pipe再解除fd。这样使得高引用次数有了与低引用次数相同的可乘之机,导致写端提前关闭的假象,但是假如调换解除顺序,先解除引用次数更少的fd的映射,再是pipe,使前者引用次数先-1。那么由于引用次数本身的大小关系,在两个unmap间,pageref(pipe) > pageref(fd)就将永远成立,而此时即使发生时钟中断,也不会影响_pipe_is_closed函数的返回值正确与否。
  2. dup函数同样也会出现类似close函数的类似问题,同样是pipe的引用次数比fd高,而dup函数的逻辑是先映射fd,再映射pipe,这样同样是使引用次数少的向多的靠拢,导致可能出现引用次数相同的情况。类似的,只要调换映射顺序,先映射pipe,再fd,先使引用次数多的更多,再使少的次数增加,这样在两个map期间,pipe的引用次数始终大于fd

Thinking 6.5

题面

1
2
3
4
5
6
思考以下三个问题。
• 认真回看 Lab5 文件系统相关代码,弄清打开文件的过程。
• 回顾 Lab1 与 Lab3,思考如何读取并加载 ELF 文件。
• 在 Lab1 中我们介绍了 data text bss 段及它们的含义,data 段存放初始化过的全局变量,bss 段存放未初始化的全局变量。关于 memsize 和 filesize ,我们在 Note1.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() 的实现,思考这一点是如何实现的?下面给出一些对于上述问题的提示,以便大家更好地把握加载内核进程和加载用户进程的区别与联系,类比完成 spawn 函数。
关于第一个问题,在 Lab3 中我们创建进程,并且通过 ENV_CREATE(...) 在内核态加载了初始进程,而我们的 spawn 函数则是通过和文件系统交互,取得文件描述块,进而找到 ELF 在“硬盘”中的位置,进而读取。
关于第二个问题,各位已经在 Lab3 中填写了 load_icode 函数,实现了 ELF 可执行文件中读取数据并加载到内存空间,其中通过调用 elf_load_seg 函数来加载各个程序段。在 Lab3 中我们要填写 load_icode_mapper 回调函数,在内核态下加载 ELF 数据到内存空间;相应地,在 Lab6 中 spawn 函数也需要在用户态下使用系统调用为 ELF 数据分配空间。

回答

  1. 打开文件流程:1).调用file.c中的int open(const char *path, int mode)函数。2).该函数通过fd_alloc获得一个新的文件描述符fd,并利用fsipc_open()函数通过IPC机制同fs文件服务进程通信获得相应的打开文件的文件描述符。3).fs进程的serve()函数将该请求分配给serve_open()函数,由后者完成相应服务并再次通过IPC机制与用户进程通信,将此前等回复而阻塞的用户进程获得相应的打开文件的文件描述符。4).将新获得的文件描述符的内容利用fsipc_map完成映射,进而使获得的文件描述符可以访问到相应的文件数据。
  2. 通过 ENV_CREATE(…) 我们在内核态加载了初始进程,而 spawn 函数则是通过和文件系统交互,取得文件描述块,进而找到 ELF 在“硬盘”中的位置,进而读取。而加载ELF则通过env.c中的load_icode()函数来实现,其中通过给elf_load_seg函数设置回调函数load_icode_mapper并运行实现了ELF的加载,因此具体的加载过程实现主要在elf_load_seg函数中,其中先从ELF文件中读取段头信息,包括段的虚拟地址(p_vaddr)、文件中的偏移量(p_offset)、段在文件中的大小(p_filesz)以及段在内存中的大小(p_memsz)。
    此后根据p_memsz在虚存中分配相应空间。此后将文件中的内容(大小为 p_filesz)复制到内存中相应的地址上。对于.bss段,由于 p_filesz 为 0,不会复制任何数据。如果 p_memsz 大于 p_filesz,则将多出的部分(即 p_memsz - p_filesz)初始化为 0。而这部分对应的就是.bss段。
    具体来说,对于.bss段,p_filesz 为 0,而 p_memsz.bss段需要占据的空间。函数会检测这种情况,并确保在内存中为.bss段分配空间,并将其初始化为 0。
    load_icode_mapper()函数负责将 ELF 文件映射到内存中,它会调用 elf_load_seg() 来处理具体的段加载。通过迭代 ELF 文件中的段表,回调函数load_icode_mapper() 将每一个段(包括.bss段)加载到内存中。
  3. 先从ELF文件中读取文件头信息,确定段表的位置和大小。此后遍历段表中每个段,调用elf_load_seg()通过回调函数load_icode_mapper()函数加载到内存中,对每个段,若包含有.bss段(p_filesz<p_memsz),则会由elf_load_seg()函数来保证其在内存中分配的空间上的值都初始化为0.

Thinking 6.6

题面

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

回答

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//user/init.c
// stdin should be 0, because no file descriptors are open yet
// 由于此前并没有文件描述符被生成,
// 因此,此处opencons()函数中通过fd_alloc()获取的fd就是第0号fd,其将作为标准输入存在
if ((r = opencons()) != 0) {
user_panic("opencons: %d", r);
}
// stdout
// 通过dup复制的方式将0号fd复制给1号fd
// 这样一来1号fd就承担了标准输出的责任
// 如此0和1就这样被"安排"好了
if ((r = dup(0, 1)) < 0) {
user_panic("dup: %d", r);
}

Thinking 6.7

题面

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

回答

  1. 具体实现代码如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    for (;;) {
    if (interactive) {
    printf("\n$ ");
    }
    readline(buf, sizeof buf);
    if (buf[0] == '#') {
    continue;
    } //注释指令不予理会
    if (echocmds) {
    printf("# %s\n", buf);
    } //echo命令直接打印
    if ((r = fork()) < 0) {
    user_panic("fork: %d", r);
    } //此外的命令都需要单开一个子进程来完成
    if (r == 0) {
    runcmd(buf);
    exit();
    } else {
    wait(r);
    }
    }
    根据以上代码可知,MOS中我们用到的shell命令除了echo命令是内置命令,其余都是外部命令。
  2. 我认为大致有以下几点原因:1.首先cd可能涉及到多级目录,对于多级目录如果每一级都要fork一个进程,那么开销会极为巨大;2.此外,可能是由于上述的原因,Linux提供了chdir()系统调用,使得不需要另开进程,既可以完成cd命令的任务。

Thinking 6.8

题面

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

回答

ls.b | cat.b > motd在我的shell的输出结果如下(以下是在spawn()函数添加了输出调试的结果):

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
27
28
29
30
31
$ ls.b | cat.b > motd
[00002803] pipecreate
father:00002803 call child:00003004 by fork()
father:00002803 call child:00003805 by spawn()
father:00003004 call child:00004006 by spawn()
[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 ...
```
根据输出可知:
1. 一共观察到两次`spawn`,分别对应于`00002803`创建`00003805`和`00003004`创建出`00004006`。其中`00003004`是由`00002803`在`parsecmd()`函数中通过`fork()`创建出来的子进程。
2. 一共有4个进程被销毁,也就是全部被创建出来的进程。
其中:
`00002803`:主`shell`进程通过`fork`创建出来的解析并执行当前命令的子进程;
`00003805`:由`00002803`在`runcmd()`函数中通过`spawn()`创建出来的执行管道左侧命令的子进程;
`00003004`:由`00002803`在`parsecmd()`函数中通过`fork()`创建出来的解析并执行管道右侧命令的子进程;
`00004006`:由`00003004`在`runcmd()`函数中通过`spawn()`创建出来的执行管道右侧命令的子进程。

## lab6课下

### Exercise 6.1
#### 题面

根据上述提示与代码中的注释,填写 user/lib/pipe.c 中的 pipe_read、pipe_write、_pipe_is_closed 函数并通过 testpipe 的测试。

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118

#### 回答
代码如下:
```c
static int _pipe_is_closed(struct Fd *fd, struct Pipe *p) {
// The 'pageref(p)' is the total number of readers and writers.
// The 'pageref(fd)' is the number of envs with 'fd' open
// (readers if fd is a reader, writers if fd is a writer).
//
// Check if the pipe is closed using 'pageref(fd)' and 'pageref(p)'.
// If they're the same, the pipe is closed.
// Otherwise, the pipe isn't closed.

int fd_ref, pipe_ref, runs;
// Use 'pageref' to get the reference counts for 'fd' and 'p', then
// save them to 'fd_ref' and 'pipe_ref'.
// Keep retrying until 'env->env_runs' is unchanged before and after
// reading the reference counts.
/* Exercise 6.1: Your code here. (1/3) */
runs = env->env_runs;
fd_ref = pageref(fd);
pipe_ref = pageref(p);
while (runs != env->env_runs) {
runs = env->env_runs;
fd_ref = pageref(fd);
pipe_ref = pageref(p);
}
return fd_ref == pipe_ref;
}

/* Overview:
* Read at most 'n' bytes from the pipe referred by 'fd' into 'vbuf'.
*
* Post-Condition:
* Return the number of bytes read from the pipe.
* The return value must be greater than 0, unless the pipe is closed and nothing
* has been written since the last read.
*
* Hint:
* Use 'fd2data' to get the 'Pipe' referred by 'fd'.
* Use '_pipe_is_closed' to check if the pipe is closed.
* The parameter 'offset' isn't used here.
*/
static int pipe_read(struct Fd *fd, void *vbuf, u_int n, u_int offset) {
int i;
struct Pipe *p;
char *rbuf;

// Use 'fd2data' to get the 'Pipe' referred by 'fd'.
// Write a loop that transfers one byte in each iteration.
// Check if the pipe is closed by '_pipe_is_closed'.
// When the pipe buffer is empty:
// - If at least 1 byte is read, or the pipe is closed, just return the number
// of bytes read so far.
// - Otherwise, keep yielding until the buffer isn't empty or the pipe is closed.
/* Exercise 6.1: Your code here. (2/3) */
p = (struct Pipe *)fd2data(fd);
i = 0;
rbuf = (char *)vbuf;
while (i < n) {
while (p->p_rpos >= p->p_wpos) {
if (i >= 1 || _pipe_is_closed(fd, p)) {
return i;
}
syscall_yield();
}
*rbuf = p->p_buf[(p->p_rpos++) % PIPE_SIZE];
rbuf++;
i++;
}
return n;
user_panic("pipe_read not implemented");
}

/* Overview:
* Write 'n' bytes from 'vbuf' to the pipe referred by 'fd'.
*
* Post-Condition:
* Return the number of bytes written into the pipe.
*
* Hint:
* Use 'fd2data' to get the 'Pipe' referred by 'fd'.
* Use '_pipe_is_closed' to judge if the pipe is closed.
* The parameter 'offset' isn't used here.
*/
static int pipe_write(struct Fd *fd, const void *vbuf, u_int n, u_int offset) {
int i;
struct Pipe *p;
char *wbuf;

// Use 'fd2data' to get the 'Pipe' referred by 'fd'.
// Write a loop that transfers one byte in each iteration.
// If the bytes of the pipe used equals to 'PIPE_SIZE', the pipe is regarded as full.
// Check if the pipe is closed by '_pipe_is_closed'.
// When the pipe buffer is full:
// - If the pipe is closed, just return the number of bytes written so far.
// - If the pipe isn't closed, keep yielding until the buffer isn't full or the
// pipe is closed.
/* Exercise 6.1: Your code here. (3/3) */
p = (struct Pipe *)fd2data(fd);
i = 0;
wbuf = (char *)vbuf;
while (i < n) {
while (p->p_wpos - p->p_rpos >= PIPE_SIZE) {
if (_pipe_is_closed(fd, p)) {
return i;
}
syscall_yield();
}
p->p_buf[(p->p_wpos++) % PIPE_SIZE] = *wbuf;
wbuf++;
i++;
}

//user_panic("pipe_write not implemented");

return n;
}

Exercise 6.2

题面

1
2
修改 user/lib/pipe.c 中 pipe_close 函数中的 unmap 顺序以避免上述情景中的进程竞争情况。
提示:当前函数中的 unmap 操作会使 pipe/fd 的引用次数 -1 。

回答

代码如下:

1
2
3
4
5
6
static int pipe_close(struct Fd *fd) {
// Unmap 'fd' and the referred Pipe.
syscall_mem_unmap(0, fd);
syscall_mem_unmap(0, (void *)fd2data(fd)); //上述两个系统调用替换位置即可
return 0;
}

Exercise 6.3

题面

1
2
修改 user/lib/fd.c 中 dup 函数中的 map 顺序以避免上述情景中的进程竞争情况。
提示:当前函数中的 map 操作会使 fd/pipe 的引用次数 +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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
int dup(int oldfdnum, int newfdnum) {
int i, r;
void *ova, *nva;
u_int pte;
struct Fd *oldfd, *newfd;
/* Step 1: Check if 'oldnum' is valid. if not, return an error code, or get 'fd'. */
if ((r = fd_lookup(oldfdnum, &oldfd)) < 0) {
return r;
}
/* Step 2: Close 'newfdnum' to reset content. */
close(newfdnum);
/* Step 3: Get 'newfd' reffered by 'newfdnum'. */
newfd = (struct Fd *)INDEX2FD(newfdnum);
/* Step 4: Get data address. */
ova = fd2data(oldfd);
nva = fd2data(newfd);
/* Step 5: Dunplicate the data and 'fd' self from old to new. */
// if ((r = syscall_mem_map(0, oldfd, 0, newfd, vpt[VPN(oldfd)] & (PTE_D | PTE_LIBRARY))) <
// 0) {
// goto err;
// }
if (vpd[PDX(ova)]) {
for (i = 0; i < PDMAP; i += PTMAP) {
pte = vpt[VPN(ova + i)];
if (pte & PTE_V) {
// should be no error here -- pd is already allocated
if ((r = syscall_mem_map(0, (void *)(ova + i), 0, (void *)(nva + i),
pte & (PTE_D | PTE_LIBRARY))) < 0) {
goto err;
}
}
}
}
if ((r = syscall_mem_map(0, oldfd, 0, newfd, vpt[VPN(oldfd)] & (PTE_D | PTE_LIBRARY))) <
0) {
goto err;
}
//将对于fd的映射增加替换到对pipe的映射增加之后即可
return newfdnum;
err:
/* If error occurs, cancel all map operations. */
panic_on(syscall_mem_unmap(0, newfd));
for (i = 0; i < PDMAP; i += PTMAP) {
panic_on(syscall_mem_unmap(0, (void *)(nva + i)));
}
return r;
}

Exercise 6.4

题面

1
2
根据以上描述以及注释,补充完成 user/lib/spawn.c 中的 int spawn(char
*prog, char **argv)。

回答

代码如下:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
int spawn(char *prog, char **argv) {
// Step 1: Open the file 'prog' (the path of the program).
// Return the error if 'open' fails.
int fd;
if ((fd = open(prog, O_RDONLY)) < 0) {
return fd;
}
// Step 2: Read the ELF header (of type 'Elf32_Ehdr') from the file into 'elfbuf' using
// 'readn()'.
// If that fails (where 'readn' returns a different size than expected),
// set 'r' and 'goto err' to close the file and return the error.
int r;
u_char elfbuf[512];
/* Exercise 6.4: Your code here. (1/6) */
//此处从fd代表的文件中读取sizeof(Elf32_Ehdr)个字节数据到elfbuf中,
//若不能读取正常数量的数据,则跳转到错误处理err
r = readn(fd, (void *) elfbuf, sizeof(Elf32_Ehdr));
if (r != sizeof(Elf32_Ehdr)) {
goto err;
}
const Elf32_Ehdr *ehdr = elf_from(elfbuf, sizeof(Elf32_Ehdr));
if (!ehdr) {
r = -E_NOT_EXEC;
goto err;
}
u_long entrypoint = ehdr->e_entry;
// Step 3: Create a child using 'syscall_exofork()' and store its envid in 'child'.
// If the syscall fails, set 'r' and 'goto err'.
u_int child;
/* Exercise 6.4: Your code here. (2/6) */
//此处调用系统调用syscall_excfork(),调用fork,返回相应的进程号
child = syscall_exofork();
if (child < 0) {
r = child;
goto err;
}
// Step 4: Use 'init_stack(child, argv, &sp)' to initialize the stack of the child.
// 'goto err1' if that fails.
u_int sp;
/* Exercise 6.4: Your code here. (3/6) */
//使用init_stack初始化子进程,如果失败跳转到错误处理err1
if ((r = init_stack(child, argv, &sp)) < 0) {
goto err1;
}
// Step 5: Load the ELF segments in the file into the child's memory.
// This is similar to 'load_icode()' in the kernel.
size_t ph_off;
ELF_FOREACH_PHDR_OFF (ph_off, ehdr) {
// Read the program header in the file with offset 'ph_off' and length
// 'ehdr->e_phentsize' into 'elfbuf'.
// 'goto err1' on failure.
// You may want to use 'seek' and 'readn'.
/* Exercise 6.4: Your code here. (4/6) */
//通过seek定位偏移,再通过readn读取ehdr->e_phentsize个字节数据从fd对应文件中读入elfbuf中
//期间发生失败即跳转到err1错误处理
if ((r = seek(fd, ph_off)) < 0) {
goto err1;
}
if ((r = readn(fd, (void *)elfbuf, ehdr->e_phentsize)) != ehdr->e_phentsize) {
goto err1;
}
Elf32_Phdr *ph = (Elf32_Phdr *)elfbuf;
if (ph->p_type == PT_LOAD) {
void *bin;
// Read and map the ELF data in the file at 'ph->p_offset' into our memory
// using 'read_map()'.
// 'goto err1' if that fails.
/* Exercise 6.4: Your code here. (5/6) */
//如下两个根据注释提示完成即可
if ((r = read_map(fd, ph->p_offset, &bin)) < 0) {
goto err1;
}
// Load the segment 'ph' into the child's memory using 'elf_load_seg()'.
// Use 'spawn_mapper' as the callback, and '&child' as its data.
// 'goto err1' if that fails.
/* Exercise 6.4: Your code here. (6/6) */
if ((r = elf_load_seg(ph, bin, spawn_mapper, &child)) < 0) {
goto err1;
}
}
}
close(fd);

struct Trapframe tf = envs[ENVX(child)].env_tf;
tf.cp0_epc = entrypoint;
tf.regs[29] = sp;
if ((r = syscall_set_trapframe(child, &tf)) != 0) {
goto err2;
}
// Pages with 'PTE_LIBRARY' set are shared between the parent and the child.
for (u_int pdeno = 0; pdeno <= PDX(USTACKTOP); pdeno++) {
if (!(vpd[pdeno] & PTE_V)) {
continue;
}
for (u_int pteno = 0; pteno <= PTX(~0); pteno++) {
u_int pn = (pdeno << 10) + pteno;
u_int perm = vpt[pn] & ((1 << PGSHIFT) - 1);
if ((perm & PTE_V) && (perm & PTE_LIBRARY)) {
void *va = (void *)(pn << PGSHIFT);

if ((r = syscall_mem_map(0, va, child, va, perm)) < 0) {
debugf("spawn: syscall_mem_map %x %x: %d\n", va, child, r);
goto err2;
}
}
}
}
if ((r = syscall_set_env_status(child, ENV_RUNNABLE)) < 0) {
debugf("spawn: syscall_set_env_status %x: %d\n", child, r);
goto err2;
}
return child;
err2:
syscall_env_destroy(child);
return r;
err1:
syscall_env_destroy(child);
err:
close(fd);
return r;
}

Exercise 6.5

题面

1
2
根据以上描述,补充完成 user/sh.c 中的 void parsecmd(char **argv,int *rightpipe),实现对重定向和管道的解释功能。
提示:注意考虑重定向失败的情况(当文件不能正确打开时,重定向符号前的命令不应执行)。

回答

代码如下:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
int parsecmd(char **argv, int *rightpipe) {
int argc = 0;
while (1) {
char *t;
int fd, r;
int c = gettoken(0, &t);
switch (c) {
case 0:
return argc;
case 'w':
if (argc >= MAXARGS) {
debugf("too many arguments\n");
exit();
}
argv[argc++] = t;
break;
case '<':
if (gettoken(0, &t) != 'w') {
debugf("syntax error: < not followed by word\n");
exit();
}
// Open 't' for reading, dup it onto fd 0, and then close the original fd.
// If the 'open' function encounters an error,
// utilize 'debugf' to print relevant messages,
// and subsequently terminate the process using 'exit'.
/* Exercise 6.5: Your code here. (1/3) */
//以O_RDONLY模式打开t代表路径下的文件,并进行复制fd到标准输入并关闭fd的操作
if ((fd = open(t, O_RDONLY)) < 0) {
debugf("failed to open %s\n", t);
exit();
}
if ((r = dup(fd, 0)) < 0) {
debugf("dup error, type of error is %d\n", r);
exit();
}
close(fd);
break;
case '>':
if (gettoken(0, &t) != 'w') {
debugf("syntax error: > not followed by word\n");
exit();
}
// Open 't' for writing, create it if not exist and trunc it if exist, dup
// it onto fd 1, and then close the original fd.
// If the 'open' function encounters an error,
// utilize 'debugf' to print relevant messages,
// and subsequently terminate the process using 'exit'.
/* Exercise 6.5: Your code here. (2/3) */
//注意这里的模式不只有O_WRONLY,由于需要支持无文件创建文件,截断文件,还要有O_CREAT和O_TRUNC两个模式
//其余类似'<'
if ((fd = open(t, O_WRONLY | O_CREAT | O_TRUNC)) < 0) {
debugf("failed to open %s\n", t);
exit();
}
if ((r = dup(fd, 1)) < 0) {
debugf("dup error, type of error is %d\n", r);
exit();
}
close(fd);
break;
case '|':;
/* First, allocate a pipe.
* Then fork, set '*rightpipe' to the returned child envid or zero.
* The child runs the right side of the pipe:
* - dup the read end of the pipe onto 0
* - close the read end of the pipe
* - close the write end of the pipe
* - and 'return parsecmd(argv, rightpipe)' again, to parse the rest of the
* command line.
* The parent runs the left side of the pipe:
* - dup the write end of the pipe onto 1
* - close the write end of the pipe
* - close the read end of the pipe
* - and 'return argc', to execute the left of the pipeline.
*/
int p[2];
/* Exercise 6.5: Your code here. (3/3) */
//根据注释填写即可,注意父子进程返回值不同
if ((r = pipe(p)) < 0) {
debugf("the open of pipe error, the type of error is %d\n", r);
exit();
}
int pid = fork();
if (pid >= 0) {
*rightpipe = pid;
}
else {
//error
debugf("fork error\n");
exit();
}
if (pid == 0) {
//child
dup(p[0], 0);
close(p[0]);
close(p[1]);
return parsecmd(argv, rightpipe);
}
else if (pid > 0) {
//father
dup(p[1], 1);
close(p[0]);
close(p[1]);
return argc;
}
break;
}
}
return argc;
}

lab6课下难点

管道

作为一种典型的进程间单向通信方式,管道分为有名和无名两种,无名的常用于父子进程间。Unix系统中,管道由int pipe(int fd[2])函数创建,成功创建返回0,fd用于保存读写端的文件描述符,fd[0]是读端,另一个则是写端。
本质上,管道是一种只存在于内存中的文件,父进程调用pipe打开的两个文件描述符:一读、一写,映射到同一片内存区域fork()后,子进程复制两个文件描述符,从而在父子进程间形成了四个指向同一片内存区域的文件描述符,父子进程可根据需要关掉不用的一个,进而实现父子进程间的单向通信管道

实验中的pipe

使用逻辑同Unix中的,可能有不同的是通过置共享位(PTE_LIBRARY)的方式,保持该页共享可写的状态,使父子进程对修改结果相互可见。
pipe结构体定义如下:

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
struct Pipe {
u_int p_rpos; // read position,可以理解成读指针,只有读者可读
u_int p_wpos; // write position,可理解为写指针,只有写者可写
u_char p_buf[PIPE_SIZE]; // data buffer,PIPE_SIZE值为32,这个缓冲区实际上是一个类似环形缓冲区,因此下一个要读或写的位置i实际上为i%PIPE_SIZE
};
```
读者从管道读数据时,将`p_buf[p_rpos%PIPE_SIZE]`中数据拷贝走,此后读指针自增1,但需要注意,**`buf`可能还未填入数据,因此若管道数据为空,即`p_rpos>=p_wpos`时,应进程切换到写者。**
类似的,写者将数据写入`p_buf[p_wpos%PIPE_SIZE]`中后写指针自增1,但是也要注意缓冲区**溢满**情况,即写者只能在`p_wpos-p_rpos<PIPE_SIZE`时运行,否则只能挂起,等读者进程运行。
此外,为了避免由于不知道另一端进程是否关闭导致的死循环问题,读写者进程都需要对另一端的状态进行判断:**当出现buf空或满时,根据另一端是否关闭了来判断是否返回,关闭则返回0;否则切换进程运行**。
`MOS`中,使用`static int _pipe_is_closed(struct Fd *fd, struct Pipe *p)`判断管道另一端是否已关闭。
实际上,对一个匿名管道来说,分配了三页的空间:一页读数据的文件描述符`rfd`,一页写文件符`wfd`,剩下一页被两个`fd`共享的缓冲区`pipe`。显然,`pipe`将被读写者引用2次,因此有`pageref(rfd)+pageref(wfd)=pageref(pipe)`成立。
通过上述恒等式即可知晓另一端是否已关闭,因为当另一端关闭了,其对应页的`pp_ref`将是0,也就是`pageref(xfd)`将为0.因此就有`pageref(rfd) = pageref(pipe)`(对读者来说)成立,写者同理。
由于抢占式进程管理,当多个进程共享同一变量时,不同执行顺序可能产生完全不同的结果,导致结果的不确定性,因此对于管道的共享性质,需要十分警惕,确保诸如`_pipe_is_closed`用于判断管道另一端是否关闭的返回结果是否正确。
进程通过`pipe_close`关闭管道端口,函数实质为通过两次`unmap`解除文件描述符`fd`和缓冲区`pipe`之间的映射。不过由于进程切换,并不能确保两次`unmap`可在同一进程时间片内执行完,因为这之间很可能由于切换而被打断,因此,`fd`和对`pipe`的`pp_ref`不能保证同步写入,将影响判断`_pipe_is_closed`返回正确性。
举个例子:
```c
pipe(p);
if (fork() == 0) {
close(p[1]);
read(p[0], buf, sizeof buf);
}
else {
close(p[0]);
write(p[1], "Hello", 5);
}

fork结束后,子进程先运行。时钟中断在close(p[1])read间后,父进程开始执行。
父进程在close(p[0])过程中,删除了p[0]pipe的映射,但未解除对p[0]的映射,此时若时钟中断产生,进程调度子进程运行。
此时各页引用情况:pageref(p[0]) = 2(父进程未解除对p[0]的映射,父子各一个),pageref(p[1]) = 1(子进程已关闭了p[1],但父进程还有),而pageref(pipe) = 2(子进程中p[0]引用了,父进程中p[0]解除了映射,但是还有p[1]引用了)。
此后,子进程运行read,先判断写者是否关闭,发现pageref(pipe) == pageref(p[0]),写端关闭,但是其实都还没开始,则子进程退出,出现未写入的情况。
让我们仔细研究_pipe_is_closed的函数逻辑:

  1. 写端关闭<=>pageref(p[0]) == pageref(pipe)
  2. 读端关闭<=>pageref(p[1]) == pageref(pipe)
    对于第一个条件,写端关闭时,显然有pageref(p[0]) == pageref(pipe)成立,但由于存在进程切换,无法保证上述等式成立时,写端关闭。显然其逆否命题对于我们来说更好处理:当写端未关闭时,保证pageref(p[0]) != pageref(pipe)
    此前的预想之外的情况出现的关键在于:pipe的引用次数总比fd要高。当close到一半时,若
    先解除pipe的映射,后解除fd的映射
    ,就会使得引用次数更高的pipe的引用次数与fd相同,导致出现了写端关闭的假象。但是假如调换解除顺序,先解除引用次数更少的fd的映射,再是pipe,使前者引用次数先-1。那么由于引用次数本身的大小关系,两个unmap间,pageref(pipe) > pageref(fd)就将永远成立,而此时即使发生时钟中断,也不会影响_pipe_is_closed函数的返回值正确与否。
    除了上述问题,或许你也会有这样的疑问:对于pageref的调用一定正确的吗?也就是pp_ref读取同步问题。
    举个例子:
        pipe(p);
        if (fork() == 0) {
                close(p[1]);
                read(p[0], buf, sizeof buf);
        } 
        else {
                close(p[0]);
                write(p[1], "Hello", 5);
        }

假设fork结束后,子进程先执行,执行完close(p[1])后,接着read,由于此时pipebuf为空,因此read函数要判断父进程的写端是否关闭,进入到_pipe_is_closed函数,pageref(fd)为2(父子进程都打开了p[0]),此时时钟中断发生。
父进程运行,先close(p[0]),再向buf写数据,假设由于数据较多,写一半发生了中断,切换到子进程。
此时子进程获得的pageref(pipe)值为2(父进程开了p[1],子进程开了p[0]),引用值相等,误认为父进程写端已关闭,子进程错误退出。
fd作为共享变量,在子进程中获得pageref(fd)的值后并没有由于父进程对fd的修改而同步,成了”脏数据”。为了保证读的同步,在父进程修改后,应该重新读取引用次数,确认两次读取间进程并没有切换后,才能返回正确结果
在实验中,通过env_runs变量来实现,其记录了一个进程env_run的次数,这样就可以通过判断某个操作前后env_runs的次数是否改变来确定在这个操作是否是在同一次进程调用中被执行的,进而实现了同步读。

相关函数

创建管道函数

int pipe(int pfd[2]).
步骤大致如下:

  1. 创建并分配两个fdfd0fd1,并分配相应的文件描述符空间。
  2. fd0对应数据区域分配一页空间,将fd1对应数据区域映射到该物理页上,该页的内容为pipe结构体。
  3. 将读端fd0的权限设置为只读fd1只写,并通过函数传入参数将这两个文件描述符的编号返回。
    注意此处的权限位都需要包含不会触发父子进程间修改页面内容导致的写时复制机制的PTE_LIBRARY位,便于父子进程间传递信息。
两个查询管道是否关闭的函数

static int _pipe_is_closed(struct Fd *fd, struct Pipe *p)int pipe_is_closed(int fdnum),其中判断的主要逻辑在前者,后者是对于前者的封装,其具体实施逻辑在此前已经讲述过了。此处不再赘述,不过由于可能的中断,因此需要通过判断获取引用次数操作前后的env->env_run是否相等来判断是否是在统一进程调度下进行的。

读管道函数

static int pipe_read(struct Fd *fd, void *vbuf, u_int n,u_int offset),其从fd对应的管道文件数据buf中读至多nB到vbuf对应虚地址中,并返回读到的字节数。
读取过程可能遇到:

  1. buf不为空:按顺序读取buf内容,直到buf为空或达到nB。
  2. buf为空:使用 _pipe_is_closed 函数查询管道的写端是否已经关闭,若已关闭,说明读入完成,函数返回 0;若没有关闭,则使用 syscall_yield() 进行等待,直到管道关闭或缓冲区不为空。
写管道函数

static int pipe_write(struct Fd *fd, const void *vbuf, u_int n, u_int offset)
与读类似,不过此处的n是需要写入恰好nB,非buf上限,因此完成全部nB写入前,若buf满了,则用syscall_yield()等待,并非返回,直到buf不满或pipe关闭。

关闭管道函数

static int pipe_close(struct Fd *fd)关闭管道的一端。

shell

shell分为两大类:

  1. 图形界面 shell(Graphical User Interface shell 即 GUI shell)。例如:应用最为广泛的是微软 Windows 系列操作系统的 Windows Explorer,也包括广为人知的 Linux 操作系统的XWindow Manager(BlackBox 和 FluxBox),以及功能更强大的 CDE、GNOME、KDE 和 XFCE等。
  2. 命令行式 shell(Command Line Interface shell ,即 CLI shell),也就是我们 MOS 操作系统最后即将实现的 shell 模式。

解释shell命令

user目录下提供了ls.ccat.c等用户程序,而Shell程序sh.c调用上面此前提及的spawn函数,其读取相应的可执行文件,并加载到新进程中运行。
下探讨如何实现管道和重定向机制:

  1. 对于重定向符号’<’和’>’,读下一个单词,打开其代表的文件后将其赋值给标准输入或标准输出。
  2. 对于管道符号’|’,先建立pipe,后fork:父进程将管道写者复制给标准输出,后关闭父进程读写者,执行|左边命令输出到管道写端;子进程将管道读者复制给标准输入,从管道读取数据,关闭进程读写者,继续读下一个单词,并执行其代表的命令。
    进程空间中,文件、管道、控制台和文件描述符都以共享页面的形式存在。
    具体的shell启动执行过程如下:
    内核启动的进程user/icode.b调用spawn创建init.b进程。init.b先打开控制台作为0和1号文件描述符,即进程的标准输入和标准输出,然后调用spawn创建sh.b进程,即shell。通过共享页面机制,forkspawn创建的子进程继承了父进程的文件描述符,因此shell进程以及其此后为了执行命令而fork出的子进程都仍可以通过标准输入输出操作控制台,与用户交互。

    shell进程创建的子shell进程负责解析命令行命令并通过spawn生成可执行程序进程(对应的*.b文件)。解析命令行命令时,子shell将重定向的文件和管道等文件通过dup复制到子shell的标准输入或输出,然后spawn将标准输入和输出通过共享内存映射给可执行程序,从而使可执行程序(*.b)可从控制台、文件和管道等位置输入和输出数据。

相关函数

初始化栈空间函数

int init_stack(u_int child, char **argv, u_int *init_esp)初始化子进程栈空间,达到向子进程主函数传递参数的目的。由于父进程不能直接操控子进程栈空间,因此要先将参数填充到当前进程的TMPPAGE,然后将其映射到子进程栈空间中。

spawn函数

int spawn(char *prog, char **argv),类似于fork都是产生一个子进程,但不同的是spawn产生的是不再执行与父进程相同的程序而是装载新的ELF文件、执行新程序的子进程。
大致流程如下:

  1. 使用open打开即将装载ELF文件的prog
  2. 使用syscall_exofork为子进程申请一个PCB
  3. 使用init_stack为子进程初始化栈空间,将要传递的参数argv传入子进程。
  4. 使用elf_load_segELF各段加载进子进程。
  5. 设置子进程运行现场寄存器,tf->cp0_epc设为程序入口点,tf->regs[29]设为装载参数后的栈顶指针,使子进程唤醒时以正确状态运行。
  6. 将父进程共享页面映射给子进程,与fork不同,这里只映射共享页面。
  7. 使用syscall_set_env_status唤醒子进程。
sh.c中的main

int main(int argc, char **argv)shell进程的主函数,是启动shell进程的第一步进入的函数,主体为死循环:

  1. 调用readline读入用户输入命令。
  2. fork出一个子进程。
  3. 子进程执行用户命令,执行结束后子进程结束,父进程等待。
  4. 父进程等待子进程结束,返回循环开头,读入下一个命令。
命令读入函数

void readline(char *buf, u_int n)从标准输入读入用户命令,保存在buf中。

命令解析函数

int _gettoken(char *s, char **p1, char **p2)int gettoken(char *s, char **p1)
gettoken接收readline读入的命令字符串作为传入参数s。此两者负责s分割,提取命令中基本单元——特殊符号或单词,过滤空白字符。gettoken是对_gettoken的封装。

cmd命令解析与执行函数

void parsecmd(char **argv, int *rightpipe) 和 user/sh.c 中的 void runcmd(char *s)
前一个函数解析用户输入命令,通过调用gettoken将命令解析成若干词法单元,保存在argv中,并在遇到<>|等时特殊处理。解析完一条命令包含所有词法单元后,结束该函数,运行runcmd。需要注意:命令中包含|时,需创建pipefork一个子shell,子进程调用parsecmd解析pipe右侧命令,父进程返回runcmd执行左侧命令,此时rightpipe记录子进程进程号即解析并执行pipe右侧命令的进程号,用于pipe左侧命令进程等待管道右侧命令进程执行完毕后释放
runcmd执行解析后的每一条命令,通过argv判断命令种类,调用spawn产生子进程并装载命令对应的ELF文件,子进程执行命令,父进程等待子进程结束后,结束进程。

shell相关函数调用关系

此处给出指导书中提供的:

心得体会

lab6可谓是极为宏大,要彻底理解得需要融会贯通lab1-lab5的所有知识,管道的机制实现讲解解决了此前我在使用|命令的疑惑,其中精妙的同步控制方式更是令我叹为观止,初步的shell解释,更是让我窥探到命令行的奥秘,使我对于shell的理解更加深入。
当然由于注释已经给的很详细了,因此甚至不用理解全部代码的意思,单看注释就可以完成代码填写,导致我对于部分函数的功能与实现的理解浅(动)尝(力)辄(不)止(足),最终实现了小型shell交互的那一刻,还是很有成就感的。
总之,OS之旅收获很多,不仅一窥操作系统内核,还在实践中提升了代码编写和debug的能力,为今后的学习工作奠定基石!!!