Lab6-Report

Lab6 Report

思考题

Thinking 6.1

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    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);

    运行结果
    father-process read:Hello world
    }

Thinking 6.2

  • 上面这种不同步修改 pp_ref 而导致的进程竞争问题在 user/lib/fd.c 中的 dup 函数中也存在。请结合代码模仿上述情景,分析一下我们的 dup 函数中为什么会出现预想之外的情况?
    • 如果先复制了 fd 描述符再复制内容,完成了描述符而未完成对应的文件内容映射时被打断切换,另一个进程访问这个 newfd 的内容,此时描述符复制完毕但映射仍是 oldfd 对应的文件内容,访问到旧文件内容,出现错误。
    • 先复制文件内容再复制 fd 时,如果被打断,fd 尚未复制无法访问,不会出现错误。

Thinking 6.3

  • 阅读上述材料并思考:为什么系统调用一定是原子操作呢?如果你觉得不是所有的系统调用都是原子操作,请给出反例。希望能结合相关代码进行分析说明。
    • 系统调用一定是原子操作,因为在通过系统调用陷入内核态时汇编代码关闭了时钟中断。
      • R3000 中 SR 寄存器的低六位中,一组数值表示一种 CPU 的运行状态,其中 KU 位表示是否位于用户模式下,为 1 表示位于用户模式下;IE 位表示中断是否开启,为 1 表示开启,否则不开启,而 KUc 和 IEc 则为 CPU 当前实际的运行状态。陷入内核态处理系统调用时,通过对 IEc 置 0 已关闭所有中断。
    • 系统调用过程一般比较复杂而且非常常用,如果它是可被打断的,那么会给用户程序带来很大不确定性,需要讨论设计更多同步互斥情况。
    • 系统调用中包括设备的读写,如果这些操作不是原子的,可能被打断覆盖了将要读或写的数据或读到尚未写完毕的数据,出现类似 dup 中的情况。
    • 有些系统调用直接和上下文打交道。比如 syscall_set_trapframe(envid, tf); 我们执行这个语句后显然期望接下来 envid 在新的 tf 夏运行,但如果这个被切换了,恰好切换到 envid 进程,就可能在原 tf 错误地继续执行。

Thinking 6.4

  • 仔细阅读上面这段话,并思考下列问题
    • 按照上述说法控制 pipe_close 中 fd 和 pipe unmap 的顺序,是否可以解决上述场景的进程竞争问题?给出你的分析过程。
      • 可以。判断 pipe_is_closed 的条件是 pageref(fd)==pageref(pipe),且一定有 pageref(fd)<=pageref(pipe)。
      • 先 unmap 掉 fd,先把 pageref(fd)减小,使 pipe_close 中 pageref(pipe) > pageref(fd)永远成立,即可以不会出现 pipe_is_closed 误判
    • 我们只分析了 close 时的情形,在 fd.c 中有一个 dup 函数,用于复制文件描述符。试想,如果要复制的文件描述符指向一个管道,那么是否会出现与 close 类似的问题?请模仿上述材料写写你的理解。
      • 会出现,如果先对 fd 进行 map 后对 page 进行 map 的话,会诱发 page_ref(fd) == page_ref(pipe)这样的情况。假如在执行完 syscall_mem_map(0, oldfd, 0, newfd, vpt[VPN(oldfd)] & (PTE_D | PTE_LIBRARY))语句之后立刻切换到该管道的另一个同为读或写的进程执行,此时该进程通过 pageref(pipe)得到 pipe 页面的引用次数为 2,又因为 pageref(oldfd)也变成了 2,两者相等,该进程调用_pipe_is_closed 后得出管道另一侧关闭的结论,从而造成程序运行错误。

Thinking 6.5

  • 思考以下三个问题。

  • 认真回看 Lab5 文件系统相关代码,弄清打开文件的过程。

    • user/lib/file.c 中 open 函数先 fd_alloc 分配一个新文件的 fd,然后用 user/lib/fsipc.c 中 fsipc_open 打开
    • fsipc_open 调用 fsipc(FSREQ_OPEN, req, fd, &perm)并返回结果,fsipc 则通过 ipc_send 发送请求给文件系统服务程序 serv 进程,ipc_recv 接收返回值
    • serv 进程中 serve 接受 env->env_ipc_value 返回值到 req,发送的 FSREQ_OPEN 和接收的 case FSREQ_OPEN 对应,转入调用 serve_open
    • serve_open 中调用 open_alloc 分配出一个 open 结构体,准备保存打开文件内容方式,再调用 file_open 通过文件路径找到文件,之后完善 open 结构体,发送链接后的 Filefd 到 envid
    • 最后在 open 中获取该 Filefd,根据其中的文件信息调用 fsipc_map,将文件全部内容映射到内存
  • 回顾 Lab1 与 Lab3,思考如何读取并加载 ELF 文件。

    • 先用 open 打开二进制文件,将所有内容就都加载进内存
    • 由 kern/env.c 文件中的 load_icode 得到 ELF 文件的的 elf 头,获取段头表、段头数量,文件大小等信息,然后使用 ELF_FOREACH_PHDR_OFF 对于其中每一段用 elf_load_seg 映射到由段头规定的虚地址开头的连续虚拟内存中
    • 最后把 cp0_epc 设置为 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() 的实现,思考这一点是如何实现的?

    • elf_load_seg()中:size_t bin_size = ph->p_filesz; size_t sgsize = ph->p_memsz; 如果磁盘实际使用的大小 bin_size 小于段的大小 sgsize,就将 map_page 中的 src 设为 NULL,相应空间会加载到内存,但其中回调函数 load_icode_mapper 中不进行 memcpy,所以数据保持初始值 0

Thinking 6.6

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

      1
      2
      3
      4
      5
      6
      7
      8
      // 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 命令是外部命令,在 user/fs.c 的 main 的循环中,readline 得到的命令全都 fork()出了子 shell 去执行

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
          for (;;) {
      if (interactive) {
      printf("\n$ ");
      }
      readline(buf, sizeof buf);

      if (buf[0] == '#') {
      continue;
      }
      if (echocmds) {
      printf("# %s\n", buf);
      }
      if ((r = fork()) < 0) {
      user_panic("fork: %d", r);
      }
      if (r == 0) { // 子进程
      runcmd(buf);
      exit();
      } else {
      wait(r);
      }
      }
    • linux 的 cd 命令之所以为内部命令,若设置为外部指令,在 cd 的时候多次调用 fork 生成子进程,过于低效。将其作为 shell 的一个内置函数实现,在解析到 cd 命令时,只需要调用该函数即可,提高系统的运行效率。

Thinking 6.8

  • 在你的 shell 中输入命令 ls.b | cat.b > motd。
    • 请问你可以在你的 shell 中观察到几次 spawn ?分别对应哪个进程?
      • 可以观察到 2 次 spawn,分别对应 3805 和 4006 进程,也就是 ls.b 命令和 cat.b 命令的两个进程。
      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
              $ 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 ...

      - 请问你可以在你的 shell 中观察到几次进程销毁?分别对应哪个进程?
      - 可以观察到 4 次进程销毁,分别对应 3805、4006、3004、2803 四个进程,即 ls.b 命令进程、cat.b 命令进程、管道 fork 出的子 shell 进程以及开始 main 函数中 fork 出的子 shell 进程。

      ## 实验难点

      ### 管道

      - Pipe 流程
      - 用户进程创建 pipe
      - 分配两个文件描述符 fd
      - 将两个文件描述符的数据对应的虚拟地址空间映射到同一个物理页
      - 设置两个文件描述符的属性
      - 返回 fd[0] 和 fd[1]
      - 用户进程使用 pipe
      - 读者
      - 当读指针落后于写指针时,从循环缓冲区中读取 1 字节,将读指针后移
      - 当读指针追上写指针时
      - 若写者已经关闭或已经读取了部分内容,返回当前读取的字节数
      - 否则,阻塞等待(写者没有关闭且未读取任何内容)
      - 写者
      - 当写指针领先于读指针不超过 BY2PIPE 时,将 1 字节写入循环缓冲区,将写指针后移
      - 当写指针领先于读指针等于 BY2PIPE 时
      - 若读者已经关闭,成功返回
      - 否则,阻塞等待(读者没有关闭且未写完所有内容

      - 因为对 pipe 的操作不是原子操作,所以会出现不同步修改导致的进程竞争问题。在判断 pipe 是否关闭时,可通过调整关闭管道解除文件映射时 unmap、map 的顺序解决

      - _pipe_is_closed 中要读取 pageref(fd) 和 pageref(pipe),但这个用户态函数随时可能被打断,数据可能被改变,判断错误。这里的处理是两个 pageref 之前存下 env_runs, 读之后和之后 env_runs 比较,如果相同就代表过程中没有被切换,结果是可靠,否则重读。

      ```c
      do {
      runs = env->env_runs;
      fd_ref = pageref(fd);
      pipe_ref = pageref(p);
      }while(runs != env->env_runs);

Shell

Shell 整体流程
  • spawn :调用文件系统中的可执行文件并执行,产生的子进程装载新的 ELF 文件
    • 使用文件系统提供的 open 函数打开即将装载的 ELF 文件 prog
    • 使用系统调用 syscall_exofork 函数为子进程申请一个进程控制块。
    • 使用 init_stack 函数为子进程初始化栈空间,将需要传递的参数 argv 传入子进程。
    • 使用 elf_load_seg 将 ELF 文件的各个段加载进子进程。(这里与 lab3 不同的是,当前我们处于用户态,具体的 mapper 需要系统调用)
    • 设置子进程的运行现场寄存器,将 tf->cp0_epc 设置为程序入口点,tf->regs[29] 设置为装载参数后的栈顶指针,从而在子进程被唤醒时以正确的状态开始运行。
    • 将父进程的共享页面映射给子进程,与 fork 不同的是,这里只映射共享页面。
    • 使用系统调用 syscall_set_env_status 唤醒子进程。

实验感想

  • 对于稍大的系统工程,理解能力还是有待提高。
  • 对于 mos 的整个系统的理解以及一些部分的细节逻辑处理还不够熟悉,一些流程和补全内容还有些不理解,在之后的挑战性任务中还需要继续阅读代码理解。