思考题

Thinking 4.1 系统调用现场与 Trapframe

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

用户执行 syscall 后,CPU 陷入内核,进入异常入口。内核不能直接随便使用用户寄存器,因为这些寄存器中保存着用户态运行现场,比如系统调用号、参数、返回地址等。

MOS 通过 SAVE_ALL 宏把用户态通用寄存器、cp0_epccp0_statuscp0_cause 等保存到内核栈上的 Trapframe 中。这样之后内核即使使用寄存器,也不会丢失用户原来的现场。

也就是说,保护现场的关键是:

1
2
3
用户寄存器状态
↓ SAVE_ALL
内核栈上的 Trapframe

之后 C 函数 do_syscall(struct Trapframe *tf) 操作的是 Trapframe,而不是直接依赖原始寄存器。

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

不能直接依赖当前 CPU 寄存器中的 $a0-$a3

原因是陷入内核后,异常入口、保存现场、调用 C 函数的过程中,寄存器可能已经被内核代码使用或覆盖。指导书也说明,陷入内核后不是普通函数跳转,内核会先把用户现场保存到内核空间,随后通过 Trapframe * 获取用户态参数。

所以应该从 tf->regs[] 里取参数,例如:

1
2
3
4
sysno = tf->regs[4]; // 用户态 $a0
arg1 = tf->regs[5]; // 用户态 $a1
arg2 = tf->regs[6]; // 用户态 $a2
arg3 = tf->regs[7]; // 用户态 $a3

对于更多参数,还需要按照调用约定从用户栈或保存现场中取出。

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

do_syscall 进行“参数转发”。

用户态 msyscall 按约定把系统调用号和参数放到寄存器里,然后 syscall 陷入内核。内核保存现场后,do_syscallTrapframe 中取出这些寄存器值,再调用对应的 sys_* 函数。

例如:

1
2
3
4
5
int sysno = tf->regs[4];
func = sys_table[sysno];

ret = func(arg1, arg2, arg3, arg4, arg5);
tf->regs[2] = ret;

于是对于 sys_mem_alloc(envid, va, perm) 来说,它收到的参数就和用户态传给 msyscall 的参数保持一致。

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

主要改两个地方。

第一,修改 cp0_epc

1
tf->cp0_epc += 4;

这是为了让系统调用返回后,从 syscall 指令的下一条指令继续执行。如果不加 4,返回用户态后会再次执行同一条 syscall,导致无限陷入内核。

第二,修改返回值寄存器 $v0

1
tf->regs[2] = ret;

用户态恢复后,$v0 就是系统调用返回值。因此用户程序看到的是:

1
r = syscall_xxx(...);

就像普通函数返回一样。


Thinking 4.2 为什么 envid2env 需要判断 e->env_id != envid

envid2env 会通过 ENVX(envid) 得到 envs[] 数组下标:

1
e = &envs[ENVX(envid)];

但这个下标只能说明“这个 envid 对应哪个槽位”,不能说明“这个槽位现在的进程就是原来的那个进程”。

因为 Env 数组中的槽位会被反复复用。假设:

1
2
3
envs[3] 原来是进程 A,env_id = A_id
A 退出后,envs[3] 被释放
后来 envs[3] 分配给进程 B,env_id = B_id

如果没有判断:

1
e->env_id != envid

那么别人拿着旧的 A_id,仍然能通过 ENVX(A_id) 找到 envs[3],结果错误地访问到新进程 B。

所以这一步的作用是防止“旧进程 ID 误命中新进程控制块”。

简洁地说:

1
2
ENVX(envid) 只能检查数组位置;
e->env_id == envid 才能检查这个位置上的进程是不是目标进程。

没有这步判断,会导致已经失效的旧 envid 仍然可能操作新进程,破坏进程隔离和系统安全。


Thinking 4.3 为什么 mkenvid() 不会返回 0?结合系统调用、IPC 和 envid2env() 解释

mkenvid() 不返回 0,是因为在 MOS 中 envid == 0 被赋予了特殊含义:表示“当前进程”。

例如很多系统调用中会传入 envid

1
2
3
sys_mem_alloc(0, va, perm);
sys_mem_map(0, srcva, dstid, dstva, perm);
sys_ipc_recv(dstva);

其中 0 常常表示“不需要显式指定进程,就使用当前进程 curenv”。

envid2env 中也会处理这种特殊情况:

1
2
3
4
if (envid == 0) {
*env_store = curenv;
return 0;
}

所以如果 mkenvid() 真的生成了 0,就会造成严重歧义:

1
2
3
envid == 0
既可能表示真正 ID 为 0 的进程
又可能表示当前进程

在 IPC 中也类似。发送方、接收方会通过 envid 标识对方。如果某个真实进程 ID 是 0,那么系统就无法区分“目标是 0 号进程”还是“目标是当前进程”。

因此 mkenvid() 不返回 0 的根本原因是:

1
2
0 被保留为特殊进程标识,表示 curenv;
真实进程 ID 必须非 0,避免和系统调用约定冲突。

Thinking 4.4 fork 两个返回值,哪个说法正确?

题目选项:

1
2
3
4
A、fork 在父进程中被调用两次,产生两个返回值
B、fork 在两个进程中分别被调用一次,产生两个不同的返回值
C、fork 只在父进程中被调用了一次,在两个进程中各产生一个返回值
D、fork 只在子进程中被调用了一次,在两个进程中各产生一个返回值

正确答案是:

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

原因是:调用 fork() 时,最初只有父进程存在,因此 fork 只可能是父进程主动调用的。执行过程中,父进程通过 sys_exofork 创建子进程,并复制父进程的运行现场给子进程。

之后父子进程都会从“类似 fork 返回的位置”继续执行。但这不是因为 fork 被调用了两次,而是因为子进程继承了父进程在 fork 附近的运行现场。

父进程得到:

1
fork() 返回 child_envid;

子进程得到:

1
fork() 返回 0;

这两个不同返回值是通过修改子进程 Trapframe 中的 $v0 实现的。


Thinking 4.5 哪些用户空间页应该 duppage,哪些不应该?

不能对所有用户空间页都调用 duppage。应该根据内存布局和页面用途分类处理。

应该 duppage 的页

主要是父进程用户地址空间中已经映射、且需要被子进程继承的普通用户页:

1
2
3
4
5
代码段页面
数据段页面
堆页面
普通用户栈页面
已经映射的共享库页面

其中:

1
2
3
4
只读页:直接只读共享;
可写页:父子都改成 COW;
PTE_COW 页:继续 COW;
PTE_LIBRARY 页:保持共享可写。

不应该 duppage 的页

第一,未映射页面不处理。

如果 vpdvpt 显示对应页不存在,就不能映射。

第二,内核空间不能处理。

用户进程只能处理 UTOP 以下的用户空间,不能复制内核映射。

第三,用户异常栈 UXSTACK 不应该 duppage

异常栈用于处理页写入异常。如果异常栈也设成 COW,那么处理 COW 异常时需要写异常栈,又会触发新的 COW 异常,可能导致递归异常。

所以异常栈应该给子进程单独分配一页:

1
syscall_mem_alloc(child, UXSTACKTOP - BY2PG, PTE_V | PTE_R | PTE_D);

总结

1
2
3
4
普通用户页:duppage
未映射页:跳过
内核空间:跳过
用户异常栈:单独分配,不 duppage

Thinking 4.6 vptvpd 的作用、自映射,以及能否修改页表项

1. vptvpd 的作用是什么?怎样使用?

vpt 用来查看当前进程的页表项 PTE。

例如:

1
vpt[VPN(va)]

可以得到虚拟地址 va 对应的页表项,从而判断:

1
2
3
4
该页是否存在 PTE_V
该页是否可写 PTE_D
该页是否是 COW PTE_COW
该页是否是共享页 PTE_LIBRARY

vpd 用来查看页目录项 PDE。

例如:

1
vpd[PDX(va)]

可以判断某个 4MB 区域对应的页表是否存在。

遍历地址空间时通常先看 vpd,再看 vpt

1
2
3
4
5
if (vpd[PDX(va)] & PTE_V) {
if (vpt[VPN(va)] & PTE_V) {
// va 对应页面存在
}
}

2. 为什么进程能够通过这种方式存取自身页表?

因为 MOS 使用了页目录自映射。

页目录中有一个 PDE 指向页目录自身所在的物理页。这样,页目录和页表这套结构也被映射进了当前进程的虚拟地址空间。

于是用户进程可以通过固定虚拟地址区域读取自己的页目录和页表。

3. 它们如何体现自映射设计?

正常情况下,页表是内核维护的数据结构,用户进程不能直接知道它们在哪个物理地址。

但自映射后:

1
2
页目录本身被当成一个页表页映射进虚拟地址空间;
所有页表页也通过固定虚拟地址窗口可见。

因此 vpdvpt 本质上就是这个自映射窗口的两个视角:

1
2
vpd:查看页目录项
vpt:查看页表项

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

不能。

用户进程只能通过 vptvpd 读取页表信息,不能直接修改。原因是这片自映射区域对用户态是只读的。

如果用户能直接写页表项,就可以绕过内核权限检查,例如把只读页改成可写、把非法物理页映射进来,这会破坏系统安全。

所以修改页表必须通过系统调用:

1
2
3
syscall_mem_alloc
syscall_mem_map
syscall_mem_unmap

由内核检查合法性后再修改。


Thinking 4.7 do_tlb_mod 中异常重入与复制 Trapframe 到用户空间

1. 什么时候会出现类似“异常重入”?

异常重入指:正在处理一个异常时,又触发了新的同类或相关异常。

在 Lab4 中典型场景是:用户程序写 COW 页面,触发 TLB Mod 异常,进入 cow_entry。但 cow_entry 本身在用户态执行,执行过程中也可能访问或写入某些页面。如果这些页面也触发异常,就会出现“处理异常时再次异常”。

特别是如果异常处理过程中使用的栈也发生页写入异常,就会导致嵌套异常。

因此 MOS 使用单独的用户异常栈 UXSTACK,并且在 do_tlb_mod 中要考虑当前是否已经在异常栈上。

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

因为 MOS 把页写入异常的主要处理逻辑放在用户态 cow_entry 中,而用户态处理函数需要知道异常发生时的完整现场,例如:

1
2
3
4
出错地址 cp0_badvaddr
原来的 EPC
原来的 sp
通用寄存器状态

这些信息原本保存在内核栈上的 Trapframe 中,用户态不能直接访问内核栈。所以内核要把 Trapframe 复制到用户异常栈上。

这样 cow_entry(struct Trapframe *tf) 才能通过参数 tf 读取异常现场,完成 COW 后再调用:

1
syscall_set_trapframe(0, tf);

恢复原来的执行现场。

总结:

1
2
复制 Trapframe 到用户空间
= 让用户态异常处理函数能读取和恢复异常前现场。

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

用户态处理页写入异常的优势主要是“机制与策略分离”。

内核只负责:

1
2
3
识别 TLB Mod 异常;
保存现场;
切换到用户注册的异常处理函数。

用户态负责:

1
2
3
4
5
判断是否 COW;
分配新页;
复制原页面;
重新映射;
恢复现场。

这样有几个好处。

第一,减小内核复杂度。

COW 的具体策略不需要写死在内核里,内核只提供异常分发和内存映射系统调用即可。

第二,更符合微内核思想。

MOS 后续文件系统、IPC 等都倾向于把复杂功能放到用户态完成,内核只保留必要机制。

第三,灵活性更高。

不同用户程序理论上可以注册不同的页写入异常处理函数,实现不同的用户态异常策略。

第四,安全边界仍然清晰。

用户态虽然处理异常,但真正修改页表、分配页面仍然要通过系统调用,由内核检查权限。

所以简洁地说:

1
用户态处理 COW:内核更小,策略更灵活,仍由系统调用保证安全。

Thinking 4.9 为什么设置 COW 之前要先设置父进程页写入异常处理函数?

因为一旦开始设置写时复制保护,父进程自己的某些可写页也会被改成:

1
2
PTE_COW = 1
PTE_D = 0

此后父进程如果写这些页面,就会触发 TLB Mod 异常。

如果此时还没有通过:

1
syscall_set_tlb_mod_entry(0, cow_entry);

设置父进程的页写入异常处理函数,那么异常发生后内核不知道应该返回到哪个用户态处理入口,COW 无法完成,程序可能直接崩溃或 panic。

所以顺序必须是:

1
2
3
4
先注册父进程 cow_entry
再 duppage 设置 COW
再注册子进程 cow_entry
最后设置子进程 runnable

如果反过来,在写时复制保护完成之后才设置处理函数,那么中间会有危险窗口:

1
2
页面已经 COW
但异常处理函数还没注册

这时只要父进程发生一次写操作,就会触发无法处理的 TLB Mod 异常。

因此答案是:

1
2
设置 COW 会让父进程自身也可能触发页写入异常;
所以必须先设置父进程的 TLB Mod 异常处理入口。

这也是 fork 中先调用 syscall_set_tlb_mod_entry,再进行地址空间复制和 COW 标记的原因。

难点分析

指导书中 Lab4 的目标包括掌握系统调用流程、实现 IPC、实现 fork、掌握页写入异常处理流程。 去年上机题也正是围绕“新增系统调用”和“修改 fork/duppage 对特定权限页的处理”展开:一部分要求增加 sys_get_ppid,另一部分要求增加 PTE_PROTECT,使带该标志的页在 fork 时不复制、不映射给子进程。

一、系统调用流程的理解与实现

Lab4 的第一个难点是理解系统调用并不是普通的函数调用,而是一次从用户态进入内核态的受控切换。用户进程不能直接访问内核空间,也不能直接调用内核函数,因此需要通过 syscall 指令触发异常,进入内核后再由内核根据系统调用号调用对应的 sys_* 函数。

在实现过程中,需要理清用户态和内核态之间的参数传递关系。用户态的 msyscall 将系统调用号和参数放入指定寄存器,执行 syscall 后进入异常处理流程。内核保存现场到 Trapframe 中,再由 do_syscallTrapframe 中取出系统调用号和参数,分发到对应的内核系统调用函数。返回时,内核还要将返回值写回 Trapframe 中的返回值寄存器,使用户态恢复执行后能够像普通函数调用一样获得返回值。

这一部分的难点不在代码量,而在于理解“用户态函数声明—汇编系统调用入口—内核分发函数—具体系统调用实现”之间的对应关系。去年上机题中新增 sys_get_ppid 正好考察了这一点:需要同时在 include/syscall.h 中增加系统调用号,在 kern/syscall_all.c 中实现内核函数并加入系统调用表,在用户态头文件和库函数中增加封装接口。这个题目说明,新增系统调用本质上不是只写一个函数,而是要打通用户态到内核态的完整调用链。

二、envid2env 与系统调用权限检查

Lab4 中大量系统调用都需要根据用户传入的 envid 找到对应进程,例如内存映射、进程状态修改、IPC 发送等。因此 envid2env 是连接用户参数和内核数据结构的重要函数。

这一部分的难点主要体现在两个方面。第一,不能只通过 ENVX(envid) 找到 envs[] 数组下标后就直接使用对应的 Env,还必须检查 e->env_id == envid。因为进程控制块数组中的槽位会被复用,如果不检查完整的 env_id,旧的进程号可能错误地访问到后来复用该槽位的新进程。

第二,系统调用必须进行权限检查。用户传入的 envid、虚拟地址、权限位都不可信,内核需要检查目标进程是否存在、调用者是否有权访问目标进程、虚拟地址是否低于 UTOP、地址是否页对齐、权限位是否合法等。通过这一部分可以体会到,系统调用虽然为用户态提供了内核能力,但必须通过严格检查保证系统安全。

三、内存相关系统调用与页表操作的衔接

sys_mem_allocsys_mem_mapsys_mem_unmap 是 Lab4 中非常关键的一组系统调用。它们把 Lab2 中实现的页表管理能力开放给用户态,使用户程序可以通过系统调用间接完成页面分配、页面映射和解除映射。

这一部分的难点在于:表面上是在写系统调用,实际上调用的是 Lab2 中的 page_allocpage_insertpage_removepage_lookup 等页表操作函数。因此必须重新理解页表项权限位的含义。指导书中说明,PTE_V 表示有效位,PTE_D 表示可写位,PTE_COW 用于 Lab4 的写时复制,PTE_LIBRARY 后续用于共享页面。

四、IPC 机制的实现

IPC 部分的难点在于理解它并不是简单的函数调用,而是两个进程之间通过内核进行同步和数据传递。接收方调用 sys_ipc_recv 后,需要设置自己的接收状态、接收地址,并进入不可运行状态;发送方调用 sys_ipc_try_send 时,内核检查目标进程是否正在等待接收,如果可以发送,就填写接收方的 IPC 相关字段,并把接收方重新设置为可运行。

IPC 传递的内容包括两类:一类是普通整数值,另一类是可选的页面映射。如果发送方提供了合法的 srcva,接收方也提供了合法的 dstva,内核就需要把发送方的物理页面映射到接收方地址空间中。也就是说,IPC 同时复用了进程管理和页表管理机制。

五、fork 与写时复制机制

Lab4 的核心难点是 forkfork 不是单个系统调用完成的,而是由用户态函数通过多个系统调用组合实现的。sys_exofork 只负责创建子进程并复制父进程的寄存器现场,子进程的地址空间复制则由用户态 fork 通过遍历页表并调用 duppage 完成。

为了提高效率,Lab4 使用写时复制机制。普通可写页面在 fork 时不立即复制物理页,而是将父子进程的页表项都改为只读并标记 PTE_COW。当父进程或子进程真正写入该页时,才触发页写入异常,由异常处理函数分配新页并复制原内容。

这一部分最容易出错的是 duppage 中对不同页面权限的分类处理。只读页可以直接共享;可写页需要父子都改为 COW;已经是 COW 的页要继续以 COW 方式共享;用户异常栈不能 COW,而要为子进程单独分配;带特殊标志的页面还要按照其语义单独处理。去年上机题中的 PTE_PROTECT 就是这一类特殊情况:如果当前页带有 PTE_PROTECT,则 duppage 应该提前结束,不继续映射给子进程。子进程之后访问该地址时,会因为没有映射而触发被动分配,形成与父进程无关的新页面。这个考点实际上是在考察对 duppage 分类逻辑的理解,而不是单纯记忆 COW 模板。

六、页写入异常处理流程

写时复制能够成立,依赖于页写入异常处理。COW 页被设置为不可写,当进程尝试写入时,会触发 TLB Mod 异常。内核侧的 do_tlb_mod 判断异常是否来自合法的 COW 页面,然后将异常现场保存到用户异常栈,并跳转到用户态注册的异常处理函数 cow_entry

cow_entry 的任务是真正完成页面复制:根据异常地址找到页边界,分配临时页面,将原页面内容复制过去,解除旧映射,再将新页面以可写权限映射回原地址,最后恢复异常前现场继续执行。

这一部分的难点在于理解内核和用户态的分工。内核并不直接完成 COW 复制,而是提供异常分发、页分配、页映射等机制;用户态负责具体的写时复制策略。这体现了 MOS 中“内核提供机制,用户态实现策略”的设计思想。

实验体会

1. Lab4

通过 Lab4,我对操作系统中用户态和内核态之间的关系有了更清晰的认识。之前的 Lab2 和 Lab3 更多是在内核内部实现页表、进程和异常机制,而 Lab4 则把这些机制封装成用户态可以使用的系统调用接口。也就是说,Lab4 是前面实验内容的一次综合应用:页表管理、进程控制、异常处理都通过系统调用机制连接起来,最终支撑 IPC 和 fork 的实现。

本次实验让我体会最深的是,操作系统中的很多功能并不是孤立存在的。例如,fork 表面上是进程创建函数,但它实际依赖 sys_exofork 创建进程,依赖 sys_mem_map 建立地址空间映射,依赖 sys_mem_alloc 分配异常栈,依赖 sys_set_env_status 启动子进程,依赖页写入异常处理完成 COW。只有把这些模块串起来,才能真正理解 Lab4 的整体结构。

Lab4 让我对写时复制有了更直观的理解。COW 的关键思想不是简单地“不复制”,而是“延迟复制”:fork 时通过共享页面降低开销,真正写入时再借助页写入异常完成复制。这个机制把页表权限、异常处理和用户态页面管理结合在一起,是整个实验中最能体现操作系统设计思想的一部分。通过完成 Lab4,我更加理解了操作系统如何在安全性、效率和灵活性之间进行权衡。

2. Lab4-pre-exam

很简单感觉,按部就班地弄就行了,就是得了解一下env.henv结构体的定义。

3. Lab4-pre-extra

也是逻辑上很清晰,但是要注意pp_ref的增减,合理使用page_insert()``page_remove()``page_decref这三个函数即可。

4. Lab4-exam

和去年的题很像,注意page_lookup()的使用,别的没什么难的,注意合法地址的判断不能直接用is_illegal_va

5. Lab4-extra

量大管饱了属于是。
总共19个空,还有一堆别的要添加的东西。
整体上除了第19空我不太确定dirty_remove()赋值到哪里之外别的全都很顺利。
但是因为第19空的问题我写完之后编译没有过,交上去喜提0分。
后来已经改好了但是还在冷却时间,时间到了,也没法提交了。
很遗憾但是不是特别伤心。至少说明问题不是很大()
最后上机当天不要听歌()不然全神贯注的脑子里突然冒出来一句歌词直接思考暂停()

课下又做了一下,也发现确实有一些点理解有误写得有问题:

  1. 幼稚的错误:
    1. PTE_DPTE_V不分,设置权限的时候写反了().
    2. sys_start_dirty_log中间设置log_enabled那一步直接没看到().
  2. 确实有问题的地方:
    1. 检查 ptr 指向的虚拟地址的合法性:实际上检查的还是ptr,我一看到指向就写的检查*ptr,但实际上后续*ptr又被赋了一个新的值,显然这个写法下该检查毫无意义.
    2. 如果entry=0则取消设置:暗示的其实是判断条件entry != 0 && is_illegal_va(entry)时返回-E_INVAL,取消设置的意思不是把log_enabled设置为0.
    3. 第19空,hh这个当时真没理解写入什么意思,感觉是没看见第13步…
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      // 12. 在异常栈上分配保存脏页记录的空间
      // HINT: 我们总是假定用户异常栈上有足够的空间来容纳陷阱帧和所有脏页记录
      // HINT: 你可以参考“10. 在异常栈上分配保存陷阱帧的空间”的方式进行分配,注意分配的空间应当能保存下所有脏页
      // HINT: 脏页队列的容量为 ENV_MAX_DIRTY_LOG_COUNT (dirtyqueue.h)
      // HINT: 将脏页记录按照一维数组的形式复制到用户异常栈,每个元素的大小为 sizeof(u_long)
      // Lab4-Extra: Your code here. (18 / 19)
      tf->regs[29] -= sizeof(u_long) * ENV_MAX_DIRTY_LOG_COUNT;

      // 13. 记录脏页记录的起始地址
      u_long user_log_addr = tf->regs[29];

      // 14. 逐一将脏页记录出队,并写入异常栈上分配的空间中
      // HINT: 代码执行到此处,前提条件是脏页队列已满
      // HINT: 脏页队列的容量为 ENV_MAX_DIRTY_LOG_COUNT (dirtyqueue.h)
      // Lab4-Extra: Your code here. (19 / 19)
      u_long *user_log_ptr = (u_long *)user_log_addr;
      for (int i = 0; i < ENV_MAX_DIRTY_LOG_COUNT; i++) {
      user_log_ptr[i] = dirty_remove(&curenv->log_queue);
      }

原创说明

参考了往届三位学长学姐的博客。感谢他们的精心整理和付出。
https://hyggge.github.io
https://yanna-zy.github.io
https://demiurge-zby.github.io