Musel's blog

BUAA-OS-Lab6

字数统计: 4k阅读时长: 15 min
2022/06/12

一、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流程分析的命令拆分部分

二、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直接写在终端。

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);,因为父子进程需要共享pipe

    • fork()后:由于父进程要继续识别后续指令,所以它在管道中作为读方,接受左边命令子进程的返回数据。所以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() + 类似lab4 fork.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,然后被赋值为UENVS envs: .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()中的不同:

    上述关于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])
      不相等,说明写端没有关闭。
  • 由于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

CATALOG
  1. 1. 一、MOS中体现“面向对象”思想的两个设计
    1. 1.1. 1. fd.c的文件服务函数接口,实际上包含console file pipe三种外设文件类型
    2. 1.2. 1.1 fd.h 定义外设文件的抽象接口dev
      1. 1.2.1. 1.2 实现dev接口的继承类
        1. 1.2.1.1. file.c
        2. 1.2.1.2. console.c
        3. 1.2.1.3. pipe.c
      2. 1.2.2. 1.3 父类方法调用的多态性
    3. 1.3. 2. 在对shell读入的命令进行split和parse,拆分出命令和参数的过程中,将所有符号和字符串都抽象成token
  2. 2. 二、Shell执行流程
    1. 2.1. 2.1 申请新进程的几种方式
    2. 2.2. 2.2 shell的基本功能与其对应的函数
      1. 2.2.1. 1.把shell终端的标准输入输出初始化为控制台输入和屏幕输出
      2. 2.2.2. 2.rumcmd:把用户输入的字符串指令拆分成命令和参数
      3. 2.2.3. 3.dup:文件、管道、标准输入输出之间的重定向
        1. 2.2.3.1. 文件重定向
        2. 2.2.3.2. 管道重定向
        3. 2.2.3.3. dup(int oldfdnum, int newfdnum)函数的具体实现
      4. 2.2.4. 4.spawn:加载命令的二进制映像,创建子进程执行外部命令
  3. 3. 三、pipe机制
    1. 3.1. 关于中断导致竞争的解决策略
    2. 3.2. Thinking 6.1
    3. 3.3. Thinking 6.2
    4. 3.4. Thinking 6.4