ecall 精简设计
ecall 只做最少工作(切模式、存 PC、跳转),给 OS 优化空间。
MIT 6.1810 · 第 6 课
用户程序如何通过 ecall 进入内核?内核又如何安全地返回用户态?本课通过 GDB 单步跟踪 xv6 的 write() 系统调用,深入理解用户/内核切换的每一个细节。
当用户程序调用 write(2, "$ ", 2) 时,CPU 当前处于用户态:
需要完成:
| 寄存器/指令 | 作用 |
|---|---|
| satp | 当前页表的物理地址 |
| stvec | trap 入口点(ecall 跳转的目标) |
| sepc | 保存发生 trap 时的 PC |
| scause | trap 原因(系统调用/异常/中断) |
| sscratch | scratch 寄存器,supervisor 可自由使用 |
| sstatus | SPP(前一模式)、SPIE(前一中断使能)、SIE(中断使能) |
| ecall | 切换到 supervisor,保存 PC 到 sepc,跳转到 stvec,禁用中断 |
| sret | 切换到保存的模式,恢复中断,跳转到 sepc |
write() write() returns
ecall sret
↓ ↓
uservec (trampoline.S) userret (trampoline.S)
↓ ↑
usertrap() (trap.c) usertrapret() (trap.c)
↓ ↑
syscall() (syscall.c) --|
↓
sys_write() (sysfile.c) --|
$ make CPUS=1 qemu-gdb
$ gdb
(gdb) b *0xca8 # write() 中的 ecall 指令
(gdb) c
(gdb) x/3i 0xca6 # 查看 ecall 附近的指令
(gdb) p $pc # 低地址 → 用户空间
(gdb) p $sp # 用户栈指针
(gdb) p $a0 # fd = 2
(gdb) p/x $a1 # buf 地址
(gdb) x/s $a1 # "$ "
(gdb) p $a2 # n = 2
(gdb) p/x $stvec # 内核设置的 trap 入口地址
(gdb) stepi # 执行 ecall
# 现在 PC 在高地址 → Trampoline 页
(gdb) p $pc # 0x3ffffff...
(gdb) x/6i $pc # uservec 的第一条指令
(gdb) p $sp # 仍然是用户栈
(gdb) p/x $sepc # 保存的用户 PC
(gdb) p/x $sstatus # SIE=0, SPIE=1
ecall 做了什么:
RISC-V 设计师有意让 ecall 做最少的工作,给 OS 设计师优化的空间——某些 trap 可以不切换页表、不保存所有寄存器、不切换栈。
0x3fffffe000,用于保存寄存器和内核入口信息。# kernel/trampoline.S
uservec:
csrw sscratch, a0 # 临时保存 a0 到 sscratch
li a0, TRAPFRAME # a0 = trapframe 地址
# 保存 32 个用户寄存器到 trapframe
sd ra, 40(a0)
sd sp, 48(a0)
sd gp, 56(a0)
...
sd t0, 0*8(a0) # 注意:这里保存的是 sscratch 的旧值(即原 a0)
...
# 恢复 a0(从 sscratch 中)
csrr a0, sscratch
sd a0, 11*8(a0) # 保存到 trapframe->a0
# 加载内核栈指针
ld sp, 8(a0)
# 加载 hart ID
csrr tp, sscratch
# 加载 usertrap 地址
ld t0, 16(a0)
# 加载内核页表地址并切换
ld t1, 0(a0)
csrw satp, t1
sfence.vma
# 跳转到 usertrap()
jr t0
Trampoline 页在用户页表和内核页表中映射到相同的虚拟地址。切换页表后,PC 仍指向 Trampoline,可以继续执行。
// kernel/trap.c
void usertrap(void) {
// 检查 trap 原因
if ((r_sstatus() & SSTATUS_SPP) != 0)
panic("usertrap: not from user mode");
// 中断向量 → kernelvec
w_stvec((uint64)kernelvec);
struct proc *p = myproc();
p->trapframe->epc = r_sepc(); // 保存用户 PC
if (r_scause() == 8) {
// 系统调用(Environment call from U-mode)
if (killed(p))
exit(-1);
p->trapframe->epc += 4; // 跳过 ecall 指令
intr_on();
syscall(); // → syscall.c
}
// ...
// kernel/syscall.c
void syscall(void) {
struct proc *p = myproc();
int num = p->trapframe->a7; // 系统调用号
if (num > 0 && num < NELEM(syscalls) && syscalls[num]) {
// 调用对应的实现函数
p->trapframe->a0 = syscalls[num]();
} else {
p->trapframe->a0 = -1; // 错误的系统调用号
}
}
因为 syscall() 被调用时,a7 可能已经被 C 函数调用约定修改(用作临时寄存器)。trapframe 保存的是用户态的原始值。
// kernel/trap.c
void usertrapret(void) {
struct proc *p = myproc();
intr_off(); // 关闭中断
// 为下一次 trap 做准备
w_stvec((uint64)kernelvec); // ← 注意:这里应该是 uservec!
p->trapframe->kernel_satp = r_satp();
p->trapframe->kernel_sp = p->kstack + PGSIZE;
p->trapframe->kernel_trap = (uint64)usertrap;
p->trapframe->kernel_hartid = r_tp();
// 设置 sret 需要的寄存器
w_sepc(p->trapframe->epc); # 用户 PC
w_sstatus(r_sstatus() & ~SSTATUS_SPP); # SPP = 0 (user mode)
// 切换到用户页表
uint64 satp = MAKE_SATP(p->pagetable);
// 跳转到 userret(在 trampoline.S 中)
uint64 fn = TRAMPFOLINE + (userret - trampoline);
((void (*)(uint64))fn)(satp);
}
# kernel/trampoline.S
userret:
# a0 = 用户页表地址
csrw satp, a0
sfence.vma
# 恢复所有用户寄存器
ld ra, 40(a0)
ld sp, 48(a0)
...
ld a0, 11*8(a0) # 最后恢复 a0
sret # → sepc → PC, 模式 → user, 中断恢复
sret 做了什么:
Q1:RISC-V 的 ecall 指令做了什么?
Q2:为什么 xv6 需要 Trampoline 页?
Q3:sscratch 寄存器在 uservec 中的作用是什么?
Q4:sret 指令做了什么?
Q5:为什么 syscall() 从 trapframe 读取 a7 而不是直接读 a7 寄存器?
ecall 只做最少工作(切模式、存 PC、跳转),给 OS 优化空间。
Trampoline 页映射在用户/内核页表相同 VA,使用户→内核切换可行。
32 个用户寄存器保存在 trapframe,用于透明恢复。
sret 恢复 PC、模式、中断——回到用户程序继续执行。