Appearance
第二章:受限执行
运行 ./worker input.txt output.txt 时,我们希望它的大多数指令直接在 CPU 上执行,但它不能修改内核页表、关闭中断或跳进任意内核函数。操作系统不可能在软件中检查每条指令;限制必须由处理器在执行指令时强制实施。
本章先沿 xv6 的一次 write() 系统调用追踪完整路径:
text
user write()
→ ecall
→ RISC-V trap hardware
→ uservec
→ trapframe
→ usertrap
→ syscall
→ sys_write
→ prepare_return / userret
→ sret读完这条路径后,再讨论 Linux/x86-64 为什么需要 Ring、GDT、IDT、TSS 和 SYSCALL MSR。
RISC-V 特权级
RISC-V 定义多个 privilege mode。xv6 主要使用:
| 执行环境 | 特权级 |
|---|---|
| 用户程序 | U-mode |
| xv6 内核 | S-mode |
| OpenSBI 等固件 | M-mode |
当前 privilege mode 是处理器状态,不是 xv6 在内存中维护的布尔变量。CPU 在执行指令和访问 CSR(control and status register)时直接检查它。U-mode 不能写 satp 更换页表,不能写 stvec 更换 trap 入口,也不能执行 sret 任意制造一次特权返回。
xv6 所说的 user mode 和 kernel mode,是内核对 U-mode 与 S-mode 的使用。RISC-V 硬件不知道哪个 ELF 文件叫“内核”,也不理解 PID、UID 或文件权限。
受保护状态
xv6 依赖几组 S-mode CSR:
| CSR | 作用 |
|---|---|
satp | 当前页表根和地址空间标识 |
stvec | trap 入口地址 |
sepc | trap 前的程序计数器 |
scause | trap 原因 |
stval | 与异常有关的地址或附加值 |
sstatus | 中断使能和 trap 前特权级等状态 |
sscratch | trap 入口可使用的暂存值 |
只有 S-mode 才能配置这些状态。内核先把规则写入 CSR,之后 CPU 在 fast path 上执行规则。因此“内核控制用户程序”更准确地说是:内核配置保护状态,处理器逐条强制保护状态。
页表权限
RISC-V Sv39 页表项包含 V/R/W/X/U/A/D 等位。U 位决定页面能否由 U-mode 访问;R/W/X 分别控制读取、写入和取指。xv6 在 uvmalloc() 中给用户页设置 PTE_U,而内核页不设置。
用户程序执行 load、store 或取指时,硬件从 satp 指定的页表开始翻译并检查权限。失败会产生 page-fault trap。CPU 只报告“这次访问不满足页表规则”;按需分配、COW 或杀死进程,是内核随后作出的决定。第四章再完整讨论 Sv39 和 xv6 的 walk()、mappages()。
Trap
xv6 把三类事件统称为 trap:
- system call:用户程序主动执行
ecall; - exception:当前指令产生非法操作或 page fault;
- interrupt:时钟或设备在当前指令之外发出通知。
系统调用和异常是同步事件;中断是异步事件。它们进入内核后的前半段可以共用机制,但来源和返回语义不同。
RISC-V 的 trap 硬件只做最少工作。发生从 U-mode 到 S-mode 的 trap 时,CPU:
- 把当前 PC 写入
sepc; - 在
scause写入原因; - 必要时在
stval写入出错地址; - 在
sstatus.SPP记录原特权级,并更新中断使能位; - 把 privilege mode 改为 S-mode;
- 把 PC 设为
stvec指定的入口。
CPU 不会自动切换页表,不会自动切换内核栈,也不会保存所有通用寄存器。刚进入 stvec 时仍使用用户页表,sp 仍是用户栈指针。剩余工作必须由入口汇编完成。
系统调用 ABI
xv6 的用户态 stub 由 user/usys.pl 生成。每个系统调用都只有三条核心指令:
asm
write:
li a7, SYS_write
ecall
retRISC-V C ABI 已把 write(fd, buf, n) 的参数放入 a0、a1、a2。stub 只需把系统调用号放入 a7,再执行 ecall。返回后,a0 保存返回值。
这一步说明系统调用不是普通函数调用:普通 call 的目标地址来自指令,仍在 U-mode;ecall 没有用户指定的内核目标,CPU 只能跳到内核预先写入 stvec 的入口。
Trampoline
xv6 把 stvec 指向 trampoline 页中的 uservec。这页代码映射在每个用户页表和内核页表的同一虚拟地址上,因为 trap 刚发生时 CPU 尚未切换页表。
uservec 的顺序可以逐行核对:
- 用
csrw sscratch, a0暂存用户a0; - 令
a0 = TRAPFRAME; - 用一系列
sd把用户通用寄存器保存到 trapframe; - 从 trapframe 读出
kernel_sp、kernel_hartid、kernel_trap和kernel_satp; - 把
sp切到当前进程的内核栈; - 写
satp切到内核页表,并执行sfence.vma; - 跳到 C 函数
usertrap()。
这里软硬件边界非常清楚:
| 动作 | 完成者 |
|---|---|
保存 sepc/scause/sstatus | RISC-V 硬件 |
提高到 S-mode 并跳到 stvec | RISC-V 硬件 |
| 保存通用寄存器 | uservec |
| 切换内核栈 | uservec |
| 切换内核页表 | uservec |
| 判断 trap 原因 | usertrap |
Trapframe
每个 xv6 进程都有一页 struct trapframe。它保存用户寄存器,也保存入口汇编无法凭空知道的内核状态:
c
struct trapframe {
uint64 kernel_satp;
uint64 kernel_sp;
uint64 kernel_trap;
uint64 epc;
uint64 kernel_hartid;
uint64 ra;
uint64 sp;
// ... 其余用户寄存器
uint64 a0;
// ...
uint64 a7;
};trapframe 是 xv6 定义的内存布局,不是 RISC-V 自动生成的结构。uservec 按固定 offset 写入它,C 代码再按字段读取。入口汇编和 C struct 的布局必须一致。
Trap 分派
usertrap() 首先确认 trap 确实来自 U-mode,然后把 stvec 改为内核 trap 入口。接着读取 scause:
c
if (r_scause() == 8) {
p->trapframe->epc += 4;
intr_on();
syscall();
} else if ((which_dev = devintr()) != 0) {
// device interrupt
} else if ((r_scause() == 13 || r_scause() == 15)
&& vmfault(p->pagetable, r_stval(),
r_scause() == 13) != 0) {
// lazily allocate a user page
} else {
setkilled(p);
}scause == 8 表示来自 U-mode 的 environment call。sepc 最初指向 ecall 本身,所以 xv6 把保存的 epc 加 4;否则返回后会再次执行同一条 ecall。
syscall() 从 trapframe 的 a7 取系统调用号,用它索引函数表,再把返回值写回 a0。以 write 为例,最终进入 sys_write(),读取 FD、用户地址和长度,再调用文件层。
用户不能借 a7 选择任意内核函数。a7 只索引内核编译好的 syscall table,越界号码直接返回错误。
用户内存
进入 S-mode 不等于参数可信。用户可把 buf 指向未映射页面、内核地址或跨页区间。xv6 不直接解引用这类地址,而是用 copyin()、copyout() 和 copyinstr() 沿用户页表翻译地址并复制。
系统调用还要验证:
- FD 是否位于当前进程的 open-file table 中;
- 长度和地址计算是否溢出;
- 页表项是否存在并带
PTE_U; - 对象类型是否支持相应操作。
硬件防止 U-mode 直接访问内核;内核代码防止不可信参数诱使 S-mode 代码访问错误对象。这是两层不同的检查。
Trap 返回
返回路径从 prepare_return() 开始。它关闭中断,重新令 stvec 指向 uservec,向 trapframe 写入下一次入口所需的 kernel_satp/kernel_sp/kernel_trap,并设置:
sstatus.SPP = 0:sret返回 U-mode;sstatus.SPIE = 1:回到用户态后允许中断;sepc = trapframe->epc:恢复用户 PC。
随后 trampoline 中的 userret 切回用户页表,从 trapframe 恢复通用寄存器,最后执行 sret。只有 sret 依据 sstatus 真正降低特权级并跳回用户 PC。
用户程序不能自己执行同样的 sret 获得 S-mode,因为它在 U-mode 执行 sret 本身就是非法指令。
时钟中断
若只有 ecall 才能回到内核,死循环程序可以永久占用 CPU。xv6 的 timer 通过 RISC-V supervisor timer interrupt 让内核周期性获得控制。
devintr() 识别时钟后调用 clockintr() 更新 ticks。usertrap() 在确认这是 timer interrupt 后调用 yield();调度器才可能改运行另一个进程。因此:
text
timer interrupt
→ trap 进入内核
→ clockintr 更新时钟
→ yield 进入调度器
→ 可能切换进程trap 是重新获得控制权的硬件机制,调度是内核随后作出的选择。第五章将沿 yield()、sched() 和 swtch() 继续追踪。
初始化
这条路径成立之前,xv6 必须配置每个 hart:
kvminithart()写satp,启用内核页表;trapinithart()写stvec,安装内核 trap vector;plicinithart()配置外部设备中断;timerinit()配置 timer;- 第一个进程由
forkret()调用prepare_return(),再跳到 trampoline 中的userret。
保护不是“处理器天然知道该运行 xv6”。内核启动代码先建立页表和入口,硬件之后才有规则可执行。
x86-64 扩展
xv6 的主线已经给出受限执行所需的全部逻辑部件。x86-64 Linux 使用不同硬件对象实现同一结构,并增加兼容性和安全机制。
x86 特权级
x86 当前特权级 CPL 来自 CS selector 的低两位。Linux 内核运行在 Ring 0,用户程序运行在 Ring 3;root 进程仍是 Ring 3。selector 指向 GDT 或 LDT 中的 descriptor,CPU 比较 CPL、RPL 和 DPL。
在 32 位保护模式中,segment descriptor 的 base/limit 可以限制地址范围,LDT 可提供任务局部段表。到了 x86-64 long mode,普通代码段和数据段的 base/limit 大多被忽略,FS/GS 基址、CS 特权语义和 GDT 中的 TSS descriptor 仍然有效;逐页内存隔离主要依靠页表 U/S、R/W 和 NX 位。
x86 中断入口
x86 异常和外部中断通过 IDT vector 选择 gate。跨特权级时,CPU 可从 TSS 取得 Ring 0 栈;IST 还能为 NMI、double fault 等入口选择专用栈。CPU 建立 SS/RSP/RFLAGS/CS/RIP 等返回现场,入口汇编再补全 struct pt_regs。
这比 RISC-V stvec 更复杂,但角色对应:
| xv6/RISC-V | Linux/x86-64 |
|---|---|
| U/S mode | Ring 3/0 |
satp | CR3 |
stvec | IDT 或 IA32_LSTAR |
sepc/scause | exception frame、vector、error code |
| trapframe | struct pt_regs |
sret | iretq 或 sysretq |
SYSCALL
x86-64 Linux 的系统调用号位于 RAX,参数位于 RDI/RSI/RDX/R10/R8/R9。寄存器约定见 syscall(2),号码见 syscall_64.tbl。
内核启动时配置 IA32_STAR/LSTAR/FMASK。执行 syscall 后,硬件把用户 RIP 保存到 RCX、RFLAGS 保存到 R11,装载内核 CS/SS/RIP;它不通过普通 IDT gate,也不自动切换内核栈。
Linux 的 entry_SYSCALL_64 执行 swapgs、暂存用户 RSP、必要时切换 CR3、切到线程内核栈并构造 pt_regs。这与 xv6 uservec 承担的是同类软件责任,只是 x86 的硬件入口约定不同。
用户指针
Linux 用 copy_from_user()、copy_to_user() 配合页表、SMAP 和 exception table 访问用户内存。它还要处理多线程地址空间变化、缺页、完整凭证和更复杂的对象权限。复杂度增加了,但基本边界与 xv6 copyin/copyout 相同。
实验
在 xv6 中选择一个最短系统调用,例如 getpid(),沿下面的路径设置断点并单步执行:
text
user stub
→ uservec
→ usertrap
→ syscall
→ sys_getpid
→ prepare_return
→ userret分别记录 a7、sepc、scause、sstatus.SPP、sp 和 satp,注明每个值在哪一步由硬件或软件改变。随后把 a7 改成越界号码,观察 syscall() 如何拒绝它。
练习
- RISC-V trap 硬件保存哪些状态?哪些寄存器由
uservec保存? - 为什么 trampoline 必须同时映射在用户页表和内核页表中?
ecall后为什么要把trapframe->epc加 4?- trapframe 为什么既保存用户寄存器,又保存
kernel_sp和kernel_satp? sret如何确定返回 U-mode 还是 S-mode?- timer interrupt、
yield()和进程切换分别由谁触发? - x86
syscall与普通 IDT gate 在内核栈切换上有何差异? - GDT/LDT 在 32 位保护模式与 64 位 long mode 中的作用为何不同?