一、MOS中体现“面向对象”思想的两个设计
1. fd.c的文件服务函数接口,实际上包含console file pipe三种外设文件类型
1.1 fd.h 定义外设文件的抽象接口dev
struct Dev {
int dev_id;
char *dev_name;
int (*dev_read)(struct Fd *, void *, u_int, u_int);
int (*dev_write)(struct Fd *, const void *, u_int, u_int);
int (*dev_close)(struct Fd *);
int (*dev_stat)(struct Fd *, struct Stat *);
int (*dev_seek)(struct Fd *, u_int);
};
1.2 实现dev接口的继承类
以下三种外设类型,都给dev结构体填入了自己的id name 和各自的开关、读写文件函数指针。
file.c
struct Dev devfile = {
.dev_id = 'f',
.dev_name = "file",
.dev_read = file_read,
.dev_write = file_write,
.dev_close = file_close,
.dev_stat = file_stat,
};
console.c
struct Dev devcons =
{
.dev_id= 'c',
.dev_name= "cons",
.dev_read= cons_read,
.dev_write= cons_write,
.dev_close= cons_close,
.dev_stat= cons_stat,
};
pipe.c
struct Dev devpipe =
{
.dev_id= 'p',
.dev_name= "pipe",
.dev_read= piperead,
.dev_write= pipewrite,
.dev_close= pipeclose,
.dev_stat= pipestat,
};
1.3 父类方法调用的多态性
在fd.c中调用close()\read()\write()的文件操作函数接口时,使用类似(*dev->dev_close)(fd)
、(*dev->dev_read)(fd, buf, n, fd->fd_offset)
的调用,可以实现类似类方法的多态的效果,不同的子类(外设文件类型)对应不同的文件服务函数效果。
2. 在对shell读入的命令进行split和parse,拆分出命令和参数的过程中,将所有符号和字符串都抽象成token
二、Shell执行流程
2.1 申请新进程的几种方式
- 内核态下,init.c中
加载二进制映像的宏ENV_CREATE()直接调用内核态函数env_create_priotity()
来创建进程 - 需要新进程执行外部命令commands.b时,通过spawn()中的系统调用
syscall_env_alloc()
创建新进程 - shell中通过 fork() 创建新进程进行命令的解析和重定向工作,再调用spwan() 执行外部命令
2.2 shell的基本功能与其对应的函数
1.把shell终端的标准输入输出初始化为控制台输入和屏幕输出
user/init.b中:int opencons(void):把标准输入设置为控制台输入
- fd_alloc(&fd):从小到大遍历fd号,通过检查INDEX2FD(fd) 对应的页表是否PTE_V,判断fd号是否被占用
- 给fd号所对应的fd结构体申请BY2PG,标记为共享页面。
syscall_mem_alloc(0, (u_int)fd, PTE_V|PTE_R|PTE_LIBRARY)
实际上,在Linux的终端概念中,默认每个进程的fd 0,1,2已经被占用。被申请使用的文件描述符fd是从3开始的。Linux默认0是标准输入,从键盘输入;1是标准输出,输出到屏幕;2是标准错误输出。
而在MOS中,不存在标准输入、标准输出的概念,并没有被人为预设好。也就是说,我们的MOS中fd是从0开始申请的。所以,我们要在MOS中模拟类似Linux终端的标准输入输出这一行为。
所以在user/init.b中有以下这段代码:
if ((r = opencons()) < 0) user_panic("opencons: %e", r); if (r != 0) user_panic("first opencons used fd %d", r); if ((r = dup(0, 1)) < 0)
可以看到,因为init.c是我们从内核态进程spwan出来的第一个进程,所以opencons()返回的文件描述符r必然是0。
此时,我们实现了标准输入 fd 0代表控制台的键盘输入。
shell的umain函数是一个死循环,关于用户命令的读入自顶向下调用了以下函数:
readline(buf, sizeof buf) 读取用户输入的命令行,读到回车返回。
read(0, buf + i, 1) :从标准输入中每次读取一个char。这个在fd.c中的用户文件服务函数接口,调取标准输入fd 0代表的控制台文件,从而调用了console.c的cons_read()函数。
cons_read()函数通过系统调用
syscall_cgetc()
读取控制台的字符,返回给上层函数,并把字符writef显示在终端syscall_cgetc() 调用了内核函数lib/getc.S,从控制台获取了输入字符并返回。
小彩蛋:
这是getc.S的函数,这段汇编函数的第一行从地址0x90000000获取字符。
然而,把这个地址换为控制台的外设地址0xB0000000,控制台的效果也完全一致。
LEAF(sys_cgetc) 1: lb t0, 0x90000000 beqz t0, 1b nop move v0,t0 jr ra nop END(sys_cgetc)
user/init.b中:dup(1,0):把标准输出设置为控制台文件
- 目的:把标准输入定向给标准输出,在user/init.b中通过
write(1,"LALA",4);
语句来进行测试。 - 原理:由于fd 1被重定向到控制台文件,write(1) 就是向控制台文件写入,目的是把写入内容显示在屏幕。
- 对于console类型文件,write()调用cons_write()函数,通过user_bcopy和writef直接写在终端。
- 目的:把标准输入定向给标准输出,在user/init.b中通过
2.rumcmd:把用户输入的字符串指令拆分成命令和参数
- readline()读入的用户命令字符串,通过gettoken()函数进行层次化建模与拆分:
- 不论是连接命令和命令的pipe符号’|’,还是表示命令或者文件名的字符串,或者表示重定向的符号’>’、’<’等,在gettoken()中被拆分成出一个元素后,都用一个char作为返回值。(字符串使用’w’,其他符号使用自身)。
- 这样做的优势是,可以使用一个死循环来处理可变长参数。循环内部switch case语句接受char类型的返回值,进行不同的处理。
- 把不同元素抽象成token,实现了句法拆分的层次化处理,把繁琐的字符串处理变的有层次化,扩展性强。 对此部分的扩展性迭代见lab6-challenge的easy任务部分。
- 至此,完成了命令的读入与解析部分。下面为命令如何执行
3.dup:文件、管道、标准输入输出之间的重定向
文件重定向
- 在上述switch case语句中,如果识别到重定向符号,会再次调用gettoken()函数读入文件名字符串,并使用r=open()获取文件描述符。
- 如果是输出重定向,open()的参数除了写O_WONLY,还要加上创建O_CREATE。
- 文件类型的重定向,只需要把标准输入0、或者标准输出1使用dup()重定向为打开的文件描述符r即可。
管道重定向
- 大致流程:识别到’|’,说明目前识别到的argv已经可以执行左边的指令。而shell的原则是申请新进程来执行命令。所以fork(),子进程进入
runit:
的命令执行部分。父进程继续循环,parse右半部分command。
父进程的任务:
fork()前申请管道
pipe(p);
,因为父子进程需要共享pipefork()后:由于父进程要继续识别后续指令,所以它在管道中作为读方,接受左边命令子进程的返回数据。所以
dup(p[0],0)
,把管道的读指针作为父进程后续指令的标准输入。此时结合管道特性分析:dup时,fd2data(0)的页面被映射到了fd2data(p[0]),此时fd2data(p[0])这个物理页所对的虚拟地址包括:fd2data(p[0]),fd2data(p[1]),fd2data(0)。
这使得MOS对于管道的close()时机与linux下gcc中的管道close()时机存在差异。二者都需要父进程或者子进程首先关闭一个不用的读或写指针。
对于各自进程的另一个需要使用的指针的关闭时机,linux下gcc中的管道close()需要在读或者写完成时,才关闭相应指针。参考这段程序
而MOS系统中,由于释放pipe的机制是ref(fd)==ref(fd2data),也就是fd物理页面的引用次数=data物理页面的引用次数,才释放。
- 所以此时,父进程可以关闭读指针和写指针。(子进程在
dup(p[1],1)
后也同样关闭读写指针。)这样此时,fd[0]和fd[1]的页面引用都是0,fd2data那个物理页面由于两次dup()重定向,页面引用是2.所以pipe不会因为两次close关闭,会在后续fd2data页面引用降到0时关闭。 - 父进程继续解析后续指令,
goto again;
子进程的任务
- 子进程用于执行识别到的指令,产生指令结果作为后续指令的输入,所以它在管道中作为写方。所以
dup(p[1],1)
,把管道的写指针作为子进程执行指令的标准输出。 - 子进程执行自身指令,
goto runit;
- 子进程用于执行识别到的指令,产生指令结果作为后续指令的输入,所以它在管道中作为写方。所以
dup(int oldfdnum, int newfdnum)函数的具体实现
- 找到两个fd的fd2data页面的虚拟地址
- 遍历fd2data(oldfdnum)所对应的文件区的虚拟地址的页表项,如果PTE_V,使用系统调用
syscall_mem_map
把fd2data(oldfdnum)文件区中的这一虚拟地址的物理页,映射给fd2data(newfdnum)文件区的相同偏移的虚拟地址。
4.spawn:加载命令的二进制映像,创建子进程执行外部命令
在runcmd()的runit部分,把解析到的命令和参数argv数组传入spawn()函数,执行外部命令。
从文件系统打开2 进制 ELF,在MOS 里是 *.b,申请新的进程描述符
- MOS中open()的文件要在磁盘映像中检索,对应到fs.img文件。而此文件在fs/Makefile中生成。所以我们没有办法打开user/*.b,而是需要在fs/Makefile中给生成磁盘映像fs.img的命令中加入user/*.b的二进制文件,才能在磁盘中烧录对应的文件。
使用系统调用syscall_env_alloc() 生成新进程
为子进程初始化堆、栈空间,并设置栈顶指针,对于栈空间,因为我们的调用可能是有参数的,所以要将参数也安排进用户栈中。
将目标程序加载到子进程的地址空间中,并为它们分配物理页面;
usr_load_elf():
load_icode_mapper()
+ 类似lab4fork.c的pgfault()
中的用户态页面复制二进制文件从text_start位置开始加载。这个位置对于每个*.b是固定的,由user/user.lds指导链入
. = 0x00400000; _text = .; /* Text and read-only data */ .text : { *(.text) *(.fixup) *(.gnu.warning) }
设置子进程的寄存器 (栈寄存器 sp 设置为 esp。程序入口地址 pc 设置为 UTEXT)
- 寄存器指针为进程中上下文寄存器
tf = &(envs[ENVX(child_envid)].env_tf);
。 - 用户进程下的
envs
与env.c中的不同。envs在user/entry.S中定义,.globl envs
,然后被赋值为UENVSenvs: .word UENVS
。此时在用户态,想修改进程env结构体,从UENVS的虚拟地址通过两级页表访问修改env结构体的物理页面。
- 寄存器指针为进程中上下文寄存器
遍历页表,将父进程的共享页面syscall_mem_map() 映射到子进程的地址空间中。
这些都做完后,syscall_set_env_status() 设置子进程可执行。
三、pipe机制
思考题和教程描述的较为清晰。
关于中断导致竞争的解决策略
对于fd页和fd2data页的映射和解映射不能封装成原子操作,所以做了两次修正:
- 一个是顺序的问题,map时先data页再fd页,unmap时先fd页再data页。
- env_runs域要在lab3部分的env.c的env_runs()维护,从而保证可以通过env_runs确保获取的两个pageref是没有被时钟中断分开。
pipe_read()中需要在读取过程中写好结束或者调度条件
while (p->p_rpos >= p->p_wpos) { if (_pipeisclosed(fd, p) || i > 0) return i; syscall_yield(); }
关于MOS的管道与linux下gcc中的管道在close()中的不同:
Thinking 6.1
示例代码中,父进程操作管道的写端,子进程操作管道的读端。如果现在想让父进程作为“读者”,代码应当如何修改?
#include <stdlib.h>
#include <unistd.h>
int fildes[2];
/* buf size is 100 */
char buf[100];
int status;
int main(){
status = pipe(fildes);
if (status == -1 ) {
/* an error occurred */
printf("error\n");
}
switch (fork()) {
case -1: /* Handle error */
break;
case 0: /* Child - reads from pipe */
close(fildes[0]); /* Read end is unused */
write(fildes[1], "Hello world\n", 12); /* Write data on pipe */
close(fildes[1]); /* father will see EOF */
exit(EXIT_SUCCESS);
default: /* Parent - writes to pipe */
close(fildes[1]); /* Write end is unused */
read(fildes[0], buf, 100); /* Get data from pipe */
printf("father-process read:%s",buf); /* Print the data */
close(fildes[0]); /* Finished with pipe */
exit(EXIT_SUCCESS);
}
}
Thinking 6.2
上面这种不同步修改 pp_ref 而导致的进程竞争问题在 user/fd.c 中的dup 函数中也存在。请结合代码模仿上述情景,分析一下我们的 dup 函数中为什么会出现预想之外的情况?
pipe(p);
rightpipe = fork();
if (rightpipe == 0) {
dup(p[1], 0);
read(p[0]);
}
else {
close(p[0]);
write(p[1]);
close(p[1]);
}
- fork 结束后,子进程先执行dup(p[0],newfd).时钟中断产生在下面两条指令之间.
if ((r = syscall_mem_map(0, (u_int)oldfd, 0, (u_int)newfd,
((*vpt)[VPN(oldfd)]) & (PTE_V | PTE_R | PTE_LIBRARY))) < 0) {
goto err;
}
/* break */
if ((* vpd)[PDX(ova)]) {
for (i = 0; i < PDMAP; i += BY2PG) {
pte = (* vpt)[VPN(ova + i)];
if (pte & PTE_V) {
// should be no error here -- pd is already allocated
if ((r = syscall_mem_map(0, ova + i, 0, nva + i,
pte & (PTE_V | PTE_R | PTE_LIBRARY))) < 0) {
goto err;
}
}
}
}
- 子进程 dup(p[1],newfd) 后,newfd增加了对 p[1] 的映射,但还没有来得及增加对 pipe 的映射(map),此时时钟中断产生,父进程接着执行。
- 父进程close(p[0])后,此时各个页的引用情况:pageref(p[0]) = 1, pageref(p[1]) = 3(因为子进程复制了p[1])。此时 pipe 的pageref 是 3(子进程的pageref(pipe)是2,子进程对pipe没有dup增加映射也没有close解除映射;同时父进程中 p[0] 刚解除对 pipe 的映射,所以在父进程中也只有 p[1] 引用了 pipe。
- 父进程执行 write,write 中首先判断读者是否关闭。比较 pageref(pipe) 与 pageref(p[1])之后发现它们都是 3,说明写端已经关闭,于是父进程退出。
Thinking 6.4
仔细阅读上面这段话,并思考下列问题
• 按照上述说法控制 pipeclose 中 fd 和 pipe unmap 的顺序,是否可以解决上述场景的进程竞争问题?给出你的分析过程。
• 我们只分析了 close 时的情形,在 fd.c 中有一个 dup 函数,用于复制文件内容。试想,如果要复制的是一个管道,那么是否会出现与 close 类似的问题?请模仿上述材料写写你的理解。
close()的ummap(fd)后unmap(pipe)可以解决竞争问题。考虑下面的过程:
- fork 结束后,子进程先执行。时钟中断产生在 close(p[1]) 与 read 之间,父进程开
始执行。 - 父进程在 close(p[0]) 中,p[0] 已经解除了对 p[0] 的映射 (unmap),还没有来得及
解除对 pipe 的映射,时钟中断产生,子进程接着执行。 - 此时各个页的引用情况:pageref(p[0]) = 1(父进程解除了对 p[0] 的
映射),而 pageref(p[1]) = 1(因为子进程已经关闭了 p[1])。但注意,此时 pipe 的
pageref 是 3,子进程中 p[0] 和 p[1] 都引用了 pipe,同时父进程中 p[0] 刚解除对 pipe 的映
射,所以在父进程中只有 p[1] 引用了 pipe。 - 子进程执行 read,read 中首先判断写者是否关闭。比较 pageref(pipe) 与 pageref(p[0])
不相等,说明写端没有关闭。
- fork 结束后,子进程先执行。时钟中断产生在 close(p[1]) 与 read 之间,父进程开
由于pageref(fd)的取值为2或1,pageref(pipe)可以是4~2,所以需要满足“大追小”,即分pageref(pipe)和pageref(fd)别从4和2开始,要让他们在1=1的时候相遇,从而避免在2=2的时候意外相遇而终止。所以dup的时候,要先让大的pageref(pipe)先map从而增加一,再让pageref(fd)map增加一,避免因为pageref(fd)map增加的时候导致二者相等的错误情况。具体错误情况可见thinking 6.2。