思考题

Thinking 4.1 思考并回答下面的问题:

•内核在保存现场的时候是如何避免破坏通用寄存器的?

•系统陷入内核调用后可以直接从当时的$a0-$a3 参数寄存器中得到用户调用 msyscall 留下的信息吗?

•我们是怎么做到让 sys 开头的函数“认为”我们提供了和用户调用 msyscall 时同样的参数的?

•内核处理系统调用的过程对 Trapframe 做了哪些更改?这种修改对应的用户态的变化是什么?

  • 内核使用宏函数 SAVE_ALL 来保存现场,在该函数的代码实现里,只使用了 k0 和 k1 两个通用寄存器来进行操作,从而保证其他通用寄存器的值都不会被改变。
  • 可以。因为内核在陷入内核、保存现场的过程中,寄存器$a0-$a3 中的值都没有被破坏。
  • 用户在调用 msyscall 时,传入的参数会被保存在$a0-$a3 寄存器和堆栈中。当陷入内核时,$a0-$a3 寄存器不会被破坏,而且用户栈中的内容会被原封不动地被拷贝到内核栈中。因此,sys_* 函数可以从寄存器和内核栈获得”用户调用 msyscall 时传入的参数值”。
  • 在处理过程中,将 Trapframecp0_epc 的值加了 4,同时将 sys_* 函数的返回值存入 Trapframe 中的 v0 寄存器中。这种修改保证在进入用户态时,用户程序能够从正确的位置正确运行,同时也使得用户程序从 v0 寄存器中获得系统调用的返回值

Thinking 4.2 思考 envid2env 函数: 为什么 envid2env 中需要判断 e->env_id != envid 的情况?如果没有这步判断会发生什么情况?

mkenvid 函数的返回值可知,同一个进程控制块每次申请时获取到的 env_id 是不一样的。因此需要判断当前的进程控制块是否是指定 env_id 的进程控制块。若没有该判断,则可能会因进程控制块重用而导致访问的进程控制块不匹配,从而产生错误。

Thinking 4.3 思考下面的问题,并对这个问题谈谈你的理解:请回顾 kern/env.c 文件 中 mkenvid() 函数的实现,该函数不会返回 0,请结合系统调用和 IPC 部分的实现与 envid2env() 函数的行为进行解释。

envid2env() 函数中,可以看到若 envid 值为 0,则将当前进程控制块作为值传递给参数:

1
2
3
4
5
6
7
8
9
10
int envid2env(u_int envid, struct Env **penv, int checkperm) {
struct Env *e;
// ...
if (envid == 0) {
*penv = curenv;
return 0;
}
e = &envs[ENVX(envid)];
// ...
}

因此,在部分系统调用和 IPC 实现中,可以通过值为 0 的 env_id 来直接获取当前进程的进程控制块,方便程序直接访问。

Thinking 4.4 关于 fork 函数的两个返回值,下面说法正确的是:

A、fork 在父进程中被调用两次,产生两个返回值

B、fork 在两个进程中分别被调用一次,产生两个不同的返回值

C、fork 只在父进程中被调用了一次,在两个进程中各产生一个返回值

D、fork 只在子进程中被调用了一次,在两个进程中各产生一个返回值

C

Thinking 4.5 我们并不应该对所有的用户空间页都使用 duppage 进行映射。那么究竟哪些用户空间页应该映射,哪些不应该呢?请结合 kern/env.c 中 env_init 函数进行的页面映射、include/mmu.h 里的内存布局图以及本章的后续描述进行思考。

UTOP/UENVS/UXSTACKTOPULIM 这部分空间在创建进程 env_alloc 时已经由内核进行映射(模板页表等),因此无需再次映射。

USTACKTOPUXSTACKTOP 之间是异常处理栈和无效内存,父子进程无需共享这部分内存,因此也无需映射。

因此,最终被映射的页面只有 USTACKTOP 之下的部分。

Thinking 4.6 在遍历地址空间存取页表项时你需要使用到 vpd 和 vpt 这两个指针,请参考 user/include/lib.h 中的相关定义,思考并回答这几个问题:

•vpt 和 vpd 的作用是什么?怎样使用它们?

•从实现的角度谈一下为什么进程能够通过这种方式来存取自身的页表?

•它们是如何体现自映射设计的?

•进程能够通过这种方式来修改自己的页表项吗?

  • vptvpd 分别指向用户页表和用户页目录。通过虚拟页号 vpn 可以获取对应页的页表项 vpt[VPN(va)],通过 va 高 10 位可以获取对应页表在页目录中对应的页目录项 vpd[PDX(va)]

  • use/include/lib.h 中,vptvpd 指向了内存中固定的位置,分别是用户页表空间的首地址以及用户页目录的首地址,因此可以通过这种方式访问:

    1
    2
    #define vpt ((const volatile Pte *)UVPT)
    #define vpd ((const volatile Pde *)(UVPT + (PDX(UVPT) << PGSHIFT)))
  • 4KB 页目录映射的空间大小为 4MB,刚好是 4MB 的页表空间。因此页目录位于第 PDX(UVPT) 个页表,基地址为 UVPT + (PDX(UVPT) << PGSHIFT),正好是 vpd 指向的地址。

  • 不能,页表是内核态程序维护的,用户进程只能对页表项其进行访问,而不能对其进行修改。

Thinking 4.7 在 do_tlb_mod 函数中,你可能注意到了一个向异常处理栈复制 Trapframe 运行现场的过程,请思考并回答这几个问题:

• 这里实现了一个支持类似于“异常重入”的机制,而在什么时候会出现这种“异常重入”?

• 内核为什么需要将异常的现场 Trapframe 复制到用户空间?

  • 由对栈指针的设置(tf->regs[29] -= sizeof(struct Trapframe);)可知支持异常重入。若在异常处理时再次对 COW 的页面进行写入,就会再次发生页写入异常,重新进入 do_tlb_mod 函数。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    void do_tlb_mod(struct Trapframe *tf) {
    struct Trapframe tmp_tf = *tf;

    if (tf->regs[29] < USTACKTOP || tf->regs[29] >= UXSTACKTOP) {
    tf->regs[29] = UXSTACKTOP;
    }
    tf->regs[29] -= sizeof(struct Trapframe);
    *(struct Trapframe *)tf->regs[29] = tmp_tf;
    // ...
    }
  • 因为页写入异常处理函数是在用户态进行的,而用户态无法访问内核空间,因此需要将异常现场复制到到用户空间。

Thinking 4.8 在用户态处理页写入异常,相比于在内核态处理有什么优势?

  • 尽量减少内核出现错误的可能,即使程序崩溃,也不会影响系统的稳定。
  • 用户态程序可根据具体需求实现定制化的处理逻辑。同时微内核的模式下,用户态进行新页面的分配映射也更加灵活方便。

Thinking 4.9 请思考并回答以下几个问题:

• 为什么需要将 syscall_set_tlb_mod_entry 的调用放置在 syscall_exofork 之前?

• 如果放置在写时复制保护机制完成之后会有怎样的效果?

  • 节省子进程的运行时间
  • 如果放置在写时复制保护机制完成之后,那么子进程中的全局变量 env_tlb_mod_entry 就不会被赋值,进而页写入异常处理机制也就无法被建立起来。

难点分析

  1. 系统调用流程

    系统调用流程图.drawio

  2. fork 流程 & 页写入异常处理流程

实验体会

  • 要区分用户态和内核态的操作。syscall_* 是在用户态发起的系统调用函数,由 msyscall 发起系统调用,从而转入内核态,并由 sys_* 系统调用函数进行处理。
  • 要厘清 fork 操作的流程,掌握在 fork 过程中父进程与子进程的变化与联系。
  • 掌握页写入异常的处理操作。

原创说明

部分思考题参考「BUAA-OS」 Lab4:系统调用和 fork | Hyggge’s Blog