跳转到内容

第二章:特权级与系统调用

用户程序要用内核的服务,但不能被信任。RISC-V 的答案是:硬件按特权级限制它能执行什么,越权和主动请求都通过 Trap 交回内核。本章用这套机制实现系统调用与异常处理。

系统调用:用户程序需要输出字符、需要退出,这些操作都要碰硬件。怎么让它把请求交给内核,拿到结果后原样继续执行?

异常处理:用户程序执行了非法指令或越界访存,内核怎么拿回控制权,并决定终止它还是让它继续?

两者共用同一套机制,所以放在一章:硬件用特权级限制用户程序能做什么,用 Trap 把请求和越权统一交回内核,再由内核判断该做什么。

本章明确不解决的问题:

不解决原因与去向
地址隔离还没有页表,用户程序发出的就是物理地址,照样能读写内核数据段;要靠地址空间与页表
时钟中断与抢占本章遇到中断直接 panic;要靠定时器与任务调度
多任务并存一次只运行一个程序,它结束后才加载下一个

换句话说,本章建立的是”执行权限”的边界,不是”内存范围”的边界。硬件真正拦住的只有特权指令和 CSR 访问。

Trap 是控制流从当前程序强制切换到内核的统一机制。异常由当前这条指令同步引发,中断由外部事件异步引发。

两者进入内核的路径完全相同,结局却可以截然不同:

Trap 的两种结局

差别在内核的决策,不在硬件动作。左边内核不再恢复上下文,直接换下一个程序;右边按原样恢复,程序察觉不到自己被打断过——除了 sepc 被向前推了一条指令。

RISC-V 用 scause 区分两者:最高位为 1 是中断,为 0 是异常,其余位是编号。本章会遇到的几种:

scause类别含义本章的处理
2异常非法指令终止当前程序
5异常load 访问错误终止当前程序
7异常store/AMO 访问错误终止当前程序
8异常来自 U 态的环境调用当作系统调用处理
最高位为 1中断时钟中断、外设中断等暂不支持,直接 panic

两类的关键区别是能不能重试。异常由某条具体指令产生,sepc 指向的就是它,处理完要么重新执行、要么跳过、要么放弃整个程序。中断和当前指令无关,sepc 指向的是还没执行的下一条,处理完直接回去继续。

如果用户程序和内核拥有完全相同的指令执行能力,内核写再多检查也没用——用户程序可以直接改写 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 实现。

  1. 用户程序触发异常后,如何进入内核态执行处理程序,处理完又如何回到用户态?
  2. 用户程序的执行现场如何完整保存、如何原样恢复?

第二个问题决定第一个能不能做到”透明”。系统调用返回后,用户程序必须看到和 ecall 之前完全一致的寄存器状态(除了存放返回值的 a0),否则后面的代码就会算错。

硬件只记录三件事:去哪儿、为什么来、原来在哪儿。

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 时,硬件一次性完成六件事:

  1. sepc ← 触发 Trap 的指令地址;
  2. scause ← 原因编号,stval ← 附加信息;
  3. sstatus.SPP ← 当前特权级;
  4. sstatus.SPIE ← sstatus.SIE,随后 SIE ← 0,内核默认在关中断下运行;
  5. 特权级切换为 S;
  6. pc ← stvec 指向的入口。

sret 是逆操作:特权级恢复为 SPP,SIE ← SPIE(是恢复,不是无条件开中断),SPP 复位为 U,最后 pc ← sepc。

硬件到此为止,它不保存任何通用寄存器。32 个整数寄存器此刻还留着用户程序的值,而内核一开始执行就会覆盖它们——保存现场是内核自己的责任。

现场要存进用户程序碰不到、内核也不会自己覆盖的地方。本章一共三个栈:

三个栈的分工与 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 不用存——它们只在判断原因时读一次,不参与恢复。

Trap 处理的结构

内核这一侧分成两件互不干涉的事:

  • 上下文保存与恢复不做任何判断,只搬寄存器。进来时把 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 随之作废,内核栈复位。