Skip to content

第二章:受限执行

运行 ./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当前页表根和地址空间标识
stvectrap 入口地址
sepctrap 前的程序计数器
scausetrap 原因
stval与异常有关的地址或附加值
sstatus中断使能和 trap 前特权级等状态
sscratchtrap 入口可使用的暂存值

只有 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:

  1. 把当前 PC 写入 sepc
  2. scause 写入原因;
  3. 必要时在 stval 写入出错地址;
  4. sstatus.SPP 记录原特权级,并更新中断使能位;
  5. 把 privilege mode 改为 S-mode;
  6. 把 PC 设为 stvec 指定的入口。

CPU 不会自动切换页表,不会自动切换内核栈,也不会保存所有通用寄存器。刚进入 stvec 时仍使用用户页表,sp 仍是用户栈指针。剩余工作必须由入口汇编完成。

系统调用 ABI

xv6 的用户态 stub 由 user/usys.pl 生成。每个系统调用都只有三条核心指令:

asm
write:
    li a7, SYS_write
    ecall
    ret

RISC-V C ABI 已把 write(fd, buf, n) 的参数放入 a0a1a2。stub 只需把系统调用号放入 a7,再执行 ecall。返回后,a0 保存返回值。

这一步说明系统调用不是普通函数调用:普通 call 的目标地址来自指令,仍在 U-mode;ecall 没有用户指定的内核目标,CPU 只能跳到内核预先写入 stvec 的入口。

Trampoline

xv6 把 stvec 指向 trampoline 页中的 uservec。这页代码映射在每个用户页表和内核页表的同一虚拟地址上,因为 trap 刚发生时 CPU 尚未切换页表。

uservec 的顺序可以逐行核对:

  1. csrw sscratch, a0 暂存用户 a0
  2. a0 = TRAPFRAME
  3. 用一系列 sd 把用户通用寄存器保存到 trapframe;
  4. 从 trapframe 读出 kernel_spkernel_hartidkernel_trapkernel_satp
  5. sp 切到当前进程的内核栈;
  6. satp 切到内核页表,并执行 sfence.vma
  7. 跳到 C 函数 usertrap()

这里软硬件边界非常清楚:

动作完成者
保存 sepc/scause/sstatusRISC-V 硬件
提高到 S-mode 并跳到 stvecRISC-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 = 0sret 返回 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:

  1. kvminithart()satp,启用内核页表;
  2. trapinithart()stvec,安装内核 trap vector;
  3. plicinithart() 配置外部设备中断;
  4. timerinit() 配置 timer;
  5. 第一个进程由 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-VLinux/x86-64
U/S modeRing 3/0
satpCR3
stvecIDT 或 IA32_LSTAR
sepc/scauseexception frame、vector、error code
trapframestruct pt_regs
sretiretqsysretq

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

分别记录 a7sepcscausesstatus.SPPspsatp,注明每个值在哪一步由硬件或软件改变。随后把 a7 改成越界号码,观察 syscall() 如何拒绝它。

练习

  1. RISC-V trap 硬件保存哪些状态?哪些寄存器由 uservec 保存?
  2. 为什么 trampoline 必须同时映射在用户页表和内核页表中?
  3. ecall 后为什么要把 trapframe->epc 加 4?
  4. trapframe 为什么既保存用户寄存器,又保存 kernel_spkernel_satp
  5. sret 如何确定返回 U-mode 还是 S-mode?
  6. timer interrupt、yield() 和进程切换分别由谁触发?
  7. x86 syscall 与普通 IDT gate 在内核栈切换上有何差异?
  8. GDT/LDT 在 32 位保护模式与 64 位 long mode 中的作用为何不同?

参考

上一章:操作系统概览 · 下一章:进程模型 →