第二章:特权级与系统调用
用户程序要用内核的服务,但不能被信任。RISC-V 的答案是:硬件按特权级限制它能执行什么,越权和主动请求都通过 Trap 交回内核。本章用这套机制实现系统调用与异常处理。
1. 本章要解决的问题
Section titled “1. 本章要解决的问题”系统调用:用户程序需要输出字符、需要退出,这些操作都要碰硬件。怎么让它把请求交给内核,拿到结果后原样继续执行?
异常处理:用户程序执行了非法指令或越界访存,内核怎么拿回控制权,并决定终止它还是让它继续?
两者共用同一套机制,所以放在一章:硬件用特权级限制用户程序能做什么,用 Trap 把请求和越权统一交回内核,再由内核判断该做什么。
本章明确不解决的问题:
| 不解决 | 原因与去向 |
|---|---|
| 地址隔离 | 还没有页表,用户程序发出的就是物理地址,照样能读写内核数据段;要靠地址空间与页表 |
| 时钟中断与抢占 | 本章遇到中断直接 panic;要靠定时器与任务调度 |
| 多任务并存 | 一次只运行一个程序,它结束后才加载下一个 |
换句话说,本章建立的是”执行权限”的边界,不是”内存范围”的边界。硬件真正拦住的只有特权指令和 CSR 访问。
2. 异常与中断
Section titled “2. 异常与中断”Trap 是控制流从当前程序强制切换到内核的统一机制。异常由当前这条指令同步引发,中断由外部事件异步引发。
两者进入内核的路径完全相同,结局却可以截然不同:
差别在内核的决策,不在硬件动作。左边内核不再恢复上下文,直接换下一个程序;右边按原样恢复,程序察觉不到自己被打断过——除了 sepc 被向前推了一条指令。
RISC-V 用 scause 区分两者:最高位为 1 是中断,为 0 是异常,其余位是编号。本章会遇到的几种:
scause | 类别 | 含义 | 本章的处理 |
|---|---|---|---|
2 | 异常 | 非法指令 | 终止当前程序 |
5 | 异常 | load 访问错误 | 终止当前程序 |
7 | 异常 | store/AMO 访问错误 | 终止当前程序 |
8 | 异常 | 来自 U 态的环境调用 | 当作系统调用处理 |
| 最高位为 1 | 中断 | 时钟中断、外设中断等 | 暂不支持,直接 panic |
两类的关键区别是能不能重试。异常由某条具体指令产生,sepc 指向的就是它,处理完要么重新执行、要么跳过、要么放弃整个程序。中断和当前指令无关,sepc 指向的是还没执行的下一条,处理完直接回去继续。
3. 特权级
Section titled “3. 特权级”如果用户程序和内核拥有完全相同的指令执行能力,内核写再多检查也没用——用户程序可以直接改写 Trap 入口、关掉中断、操作设备。软件约定约束不了一个拥有全部权限的程序。
所以判权必须下沉到硬件。处理器维护一个”当前特权级”,规定一部分指令和 CSR 只有在足够高的特权级下才能执行;低特权级代码碰到它们,硬件不执行,而是抛出非法指令异常。这条判定在译码阶段完成,内核只负责事先配置规则,再处理硬件报上来的违规。
RISC-V 定义三个特权级:
| 特权级 | 编码 | 典型软件 | 是否必须实现 |
|---|---|---|---|
| U,用户态 | 00 | 应用程序 | 否 |
| S,内核态 | 01 | 操作系统内核 | 否 |
| M,机器态 | 11 | 固件与 SBI 实现 | 是 |
编码 10 保留未用。虚拟化那一层来自可选的 H 扩展:启用后 S 态变成 HS 态,其下再派生 VS 和 VU 两级给客户机;它和 Linux 上的 KVM 是两回事,本项目不涉及。
M 态是唯一必须实现的,合法组合只有三种:只有 M;M 加 U;M 加 S 加 U。本项目内核跑在 S 态,用户程序跑在 U 态,M 态留给 SBI 实现。
4. 异常处理的设计与实现
Section titled “4. 异常处理的设计与实现”4.1 要解决什么问题
Section titled “4.1 要解决什么问题”- 用户程序触发异常后,如何进入内核态执行处理程序,处理完又如何回到用户态?
- 用户程序的执行现场如何完整保存、如何原样恢复?
第二个问题决定第一个能不能做到”透明”。系统调用返回后,用户程序必须看到和 ecall 之前完全一致的寄存器状态(除了存放返回值的 a0),否则后面的代码就会算错。
4.2 硬件提供了什么
Section titled “4.2 硬件提供了什么”硬件只记录三件事:去哪儿、为什么来、原来在哪儿。
| CSR | 谁写入 | 作用 |
|---|---|---|
stvec | 内核 | Trap 入口地址,以及 Direct / Vectored 模式选择 |
sepc | 硬件写入,内核可改 | Trap 发生时的指令地址,也是 sret 的返回目标 |
scause | 硬件 | Trap 的类别与编号 |
stval | 硬件 | 附加信息,例如出错的地址 |
sstatus.SPP | 硬件 | 进入 Trap 之前的特权级:0 为 U,1 为 S |
sstatus.SPIE | 硬件 | 进入 Trap 之前的 SIE |
stvec 低两位是模式位:0 是 Direct,所有 Trap 跳到同一个入口;1 是 Vectored,中断按编号散开到 入口 + 4 × 编号。本章用 Direct。
进入 S 态 Trap 时,硬件一次性完成六件事:
sepc← 触发 Trap 的指令地址;scause← 原因编号,stval← 附加信息;sstatus.SPP← 当前特权级;sstatus.SPIE←sstatus.SIE,随后SIE← 0,内核默认在关中断下运行;- 特权级切换为 S;
pc←stvec指向的入口。
sret 是逆操作:特权级恢复为 SPP,SIE ← SPIE(是恢复,不是无条件开中断),SPP 复位为 U,最后 pc ← sepc。
硬件到此为止,它不保存任何通用寄存器。32 个整数寄存器此刻还留着用户程序的值,而内核一开始执行就会覆盖它们——保存现场是内核自己的责任。
4.3 三个栈与 TrapContext
Section titled “4.3 三个栈与 TrapContext”现场要存进用户程序碰不到、内核也不会自己覆盖的地方。本章一共三个栈:
三点容易混:
- 内核启动栈和内核栈不是一回事。前者由
entry.asm静态分配,内核初始化、加载程序、打印输出都跑在它上面,和 Trap 无关;后者专供 Trap,进入内核后先在它上面压出 TrapContext,处理函数的栈帧接在下面。 - 不能拿用户栈存上下文。用户栈指针本身就是要保存的内容之一,而且用户程序可以把
sp指向任意地址;内核一旦信任它,等于把自己的写入位置交给用户程序摆布。 - 本章一次只运行一个程序,一个内核栈够用。等到多个任务并存,每个任务都要有自己的内核栈,否则切换时上一个任务的 TrapContext 会被覆盖。
TrapContext 就是”要保存的全部现场”,34 个 usize:
#[repr(C)]pub struct TrapContext { pub x: [usize; 32], pub sstatus: Sstatus, pub sepc: usize,}#[repr(C)] 不能省:汇编按固定偏移读写这块内存(0*8 是 x[0],33*8 是 sepc),而 Rust 默认布局不保证字段顺序。
x[0] 恒为 0,存它是为了让偏移和寄存器编号对齐;x[2] 是用户栈指针,切换到内核栈之前就被换走了,必须单独存回来。sstatus 的 SPP 决定 sret 回哪个特权级,sepc 决定回哪条指令,两个都要存。scause 和 stval 不用存——它们只在判断原因时读一次,不参与恢复。
4.4 内核内部的分工
Section titled “4.4 内核内部的分工”内核这一侧分成两件互不干涉的事:
- 上下文保存与恢复不做任何判断,只搬寄存器。进来时把 34 项写进内核栈,把 TrapContext 的地址交出去;出去时按相同偏移读回,执行
sret。这一段必须用汇编写——它运行时寄存器还是用户程序的值,编译器生成的任何指令都可能破坏它们。 - Trap 处理做全部决策,但不碰寄存器。它读
scause判断发生了什么:系统调用交给syscall,致命异常交给run_next_app。 - TrapContext 是两者之间唯一的接口。Trap 处理改的是内存里的副本,修改要等到恢复上下文把它读回寄存器时才生效。
分发逻辑就是本章内核策略的全部:
#[no_mangle]pub fn trap_handler(cx: &mut TrapContext) -> &mut TrapContext { match scause::read().cause() { Trap::Exception(Exception::UserEnvCall) => { cx.sepc += 4; cx.x[10] = syscall(cx.x[17], [cx.x[10], cx.x[11], cx.x[12]]) as usize; } Trap::Exception(Exception::IllegalInstruction) => { println!("[kernel] 非法指令,终止当前程序"); run_next_app(); } Trap::Exception(Exception::StoreFault) | Trap::Exception(Exception::LoadFault) => { println!("[kernel] 访存错误,终止当前程序"); run_next_app(); } cause => panic!("暂不支持的 Trap:{:?}", cause), } cx}cx.sepc += 4 必须有。ecall 触发异常时硬件把 sepc 指向 ecall 自己,不推进的话 sret 之后会再次执行同一条 ecall,程序原地死循环。加 4 是因为 ecall 固定 4 字节编码,即使开了压缩指令扩展也没有它的 16 位形式。
cx.x[17] 和 cx.x[10] 就是 ABI 里的 a7 和 a0:a7 传系统调用号,a0 起传参数,返回值写回 a0。
终止程序的两个分支不会返回:加载下一个程序会直接跳过去执行,这次 Trap 的 TrapContext 随之作废,内核栈复位。