Lec 6 系统调用入口/出口
今天的目标是学习从用户空间到达内核空间,以及从内核空间返回回用户空间的整个过程。系统调用、异常、设备中断进入内核都是以相同的方式,里面涉及到很多细致的设计和重要的细节,对于隔离性(安全性)和性能都非常重要。
要点
- 系统调用需要做什么
- 陷入——ecall
- 保存用户寄存器
- 设置内核页表
- 内核C代码执行
- 恢复执行
系统调用概述
有三种事件会导致CPU暂停正常的指令执行,并强制将控制权转移到处理该事件的特殊代码。
- 系统调用(System Call),当用户执行
ecall指令请求内核执行某些操作时。 - 异常(exception),一条指令(不管是用户还是内核的)执行了非法操作
- 设备中断
很多书会把这些情况统称为Trap(陷入)。在trap发生时,执行的代码需要在稍后恢复运行,也就是说,我们希望trap对被中断的代码来说是透明的。
期望的trap处理流程是:
trap强制将控制权转移到内核,也就是说,切换到管理员模式
保存32个用户寄存器和PC
切换到内核页表
切换到内核栈
跳转到内核C代码
恢复运行
额外的目标是:
- 对用户代码透明——程序“感觉不到”这件事发生过
- 不要让用户代码干扰到从用户态到内核态的转化,例如,即不要用管理员模式执行用户代码
示例: Write
preview:
write() write() returns
ecall User
----------------------------------------------------------------
sret Kernel
uservec in trampoline.S userret in trampoline.S
usertrap() in trap.c usertrapret() in trap.c
syscall() in syscall.c ^
sys_write() in sysfile.c ---|在高层次看,调用链为左边自上而下,右边自下而上的顺序。
我们来看下xv6系统调用如何进入/离开内核的。xv6 shell 会在终端显示写入一个$字符,这是一种设备中断。在Xv6代码具体步骤为:
- user/sh.c line 137:
write(2, "$ ", 2); - user/usys.S line 29
- 这是个write()函数,仍然在用户空间
- a7寄存器告诉内核它想要哪个系统调用——
SYS_write= 16 ecall被调用 —— 执行用户/内核空间的转换。
我们尝试在ecall加入断点,在user/sh.asm,搜索write,能找到write函数地址入口是0xe24,

于是我们打上断点
(gdb) b *0xe24
(gdb) c
(gdb) delete 1
(gdb) info reg # 可以看到a1-a3为传入的参数,a7为代号
(gdb) x/2c $a1 # x表示检查内存 /2表示显示2条内容,
#c解释为 ASCII 字符
#i代表汇编指令
#x代表hex
#a代表地址
#后面接$p寄存器或者0x地址; 结果可以看到"$"
(gdb) p/a $satp # 打印satp寄存器的值,并用地址形式展示
# 打印寄存器信息
(gdb) p $pc
$2 = (void (*)()) 0xe16
(gdb) p $sp
# $pc 和 $sp都是低地址的, 用户内存从0开始
(gdb) p/3i 0xe14
0xe14: li a7,16
=> 0xe16: ecall
0xe1a: ret
接下来我们执行ecall
(gdb) stepi
0x0000003ffffff000 in ?? ()
=> 0x0000003ffffff000: 73 10 05 14 csrw sscratch,a0
可以看出PC现在指向高地址(虚拟地址)
检查QEMU的监控器,发现这个地址是跳板页trampoline开头,记得那张图吗?映射到了低物理地址
(gdb) x/6i 0x0000003ffffff000
=> 0x3ffffff000: csrw sscratch,a0
0x3ffffff004: lui a0,0x2000
0x3ffffff008: addiw a0,a0,-1
0x3ffffff00a: slli a0,a0,0xd
0x3ffffff00c: sd ra,40(a0)
0x3ffffff010: sd sp,48(a0)
(gdb) p/x $pc
$4 = 0x3ffffff000
这是内核的trampoline trap handling代码
csrw sscratch,a0 是将两个寄存器的值交换
(gdb) p/x $sepc
$5 = 0xe16为什么$pc会跳到trampoline页呢?
Solution: 这是因为,当陷入(trap)发生时,寄存器$stvec(Supervisor Trap Vector Register)指定了特权模式下用来处理陷入的入口地址(处理程序的起始地址,通常指向内核函数)和模式(模式是最低两位,指定了陷入向量模式)
ecall 到底做了哪些事情?
Solution:
- 将用户模式切换为管理员模式
- 将$pc 保存到 $sepc
- 跳转到
$stvec(即将$pc设置为$stvec) - 关闭中断
NOTE
【ecall的作用补充】
ecall调用让用户代码从用户模式切换到了内核模式,而且设置$pc =$stvec,因此内核可以立马获得控制权,又因为也只有ecall才能设置$stvec,因此用户程序不能通过管理员模式来执行。
NOTE
【技巧: Qemu提供查看页表功能】
Trip: 在QEMU模拟器里面,我们可以进入QEMU监视器来获取页表,具体操作是
ctrl + a c ,然后info mem
trampoline 的作用


trampoline 是一种特殊的内存区域,用于处理从用户态到内核态的过渡。在 RISC-V 系统中,ecall 指令会触发陷入,trampoline 包含了陷入后执行的最初几条指令,完成模式切换、堆栈设置等工作,然后跳转到内核的 trap 处理逻辑。
为什么
trampoline必须在用户页表中?
Solution:
- 当用户态执行
ecall时,satp(管理页表基地址的寄存器)不会改变,意味着 CPU 使用的仍然是用户页表 - 为了确保陷入后能找到并执行
trampoline中的指令 ▯
一个保护机制:通过去掉 PTE_U(用户权限位),即使用户代码尝试访问,也会触发访问异常,保证内核代码的安全性。
为什么
trampoline放在虚拟地址空间顶部?
Solution:用户程序的虚拟地址空间通常从低地址开始增长。如果把 trampoline 放在中间,会在用户地址空间中造成“空洞”(未分配的区域),浪费地址空间。 ▯
我们至今还没执行内核C代码。还需要做的事情:
- 保存32个用户寄存器的值(为了后面透明恢复执行)
- 切换到内核页表
- 并将栈指针指向内核栈
- 为执行内核C代码的设置栈
- 跳转到内核C代码——usertrap() ▯
为什么ecall不帮我们做完上面这些工作?
Solution:这是为了给OS设计者提供优化系统调用,异常处理和中断处理的自由空间,从而实现非常快速的处理。具体来说:
- OS可能可以处理某些陷入而无需切换页表
- OS可以同时映射用户态和内核态到同一页表
- 可能有些寄存器不需要保存
- 可能不需要堆栈处理简单的系统调用 ▯
2. 保存用户寄存器
我们是否可以将32个寄存器的值写入物理内存中的某个方便的地址?
Solution: 不行,因为即使在管理员模式下,仍然会受到页表的约束,而当前页表是用户页表,而不是内核页表。 ▯
能否首先将
satp设置为内核页表?
Solution:管理员模式下确实可以设置satp,但是这点上,我们还不知道内核页表的地址 ▯
那怎么办?
思路: 从32个通用寄存器中挑出的一个,来保存一个地址,这个地址指向我们将保存32个用户寄存器值的内存位置。但是,这32个寄存器中都保存着用户的值,我们必须保留这些值以便最终返回给用户。
保存32个用户寄存器的值解决方案有两个部分:
Xv6将一个额外的内核页映射到用户页表中,称为trapframe
- 映射在用户页表中已知的虚拟地址上,始终是同一地址:
0x3fffffe000 - trapframe有空间来存储被保存的寄存器值
- 内核为每个进程分配一个不同的trapframe页
- 可以查看kernel/proc.h中的
struct trapframe
(尽管如此,我们仍然需要一个寄存器来保存trapframe的地址)
- 映射在用户页表中已知的虚拟地址上,始终是同一地址:
RISV-V 提供了
sscratch寄存器- 管理员模式下代码可以使用该寄存器作为临时存储
- 用户态无法使用,因此不需要保存值
为什么寄存器的值要在trapframe保存,而不在用户栈中?
Solution: 因为我们不知道用户代码中是否有栈,我们作为内核不能限制用户层使用什么编程语言,有些甚至没有用到栈,栈指针指向0,或者有栈,但是格式很不一样,可能是一块特殊区域作为栈,内核无法理解。因此,内核不能对用户内存做出任何假设, 为了能够透明 恢复执行,我们需要将这些寄存器值放到内核中。 ▯

我们可以看到有个tp寄存器,线程指针,xv6 用它来维护处理器核的hartid(core号),是cpus[]的索引
3. 设置内核页表
在前面trampoline.S的uservec的handling处理过程,分为两个部分,上半部分是保存用户寄存器,下半部分就是设置内核栈,并跳转需要执行的内核代码了。

- 该图是下半部分的处理函数
一个有趣的现象,现在页表已经换成了内核页表,为什么PC还能够按照顺序继续执行,没有发生crash?
因为trampoline是内核和用户页表都有映射,并映射到同一块区域。直到jr t0挑出trampoline.
4. 内核C代码执行

t0指向的地址trap.c的usertrap的函数入口。我们来看它干了什么事:
- 检查$sstatus寄存器是否来自用户模式
- sstatus:supervisor status register
sstatus中的SIE位控制设备中断是否被启用。如果内核清除了SIE位,RISC-V将推迟设备中断,直到内核重新设置SIE位SPP位表示trap来自用户模式还是管理模式,并控制sret返回到哪个模式。
- sstatus:supervisor status register
- 将$stvec从用户模式下陷入handling处理函数更改为内核模式的handling处理函数
- 为了不被在进程切换时将之前保存用户PC的$sepc寄存器给污染,需要将其保存到用户内存上
- 检查$scausej寄存器判断是什么原因导致。
- 当保存了这些信息后,开启中断,之前是通过ecall由硬件关闭的
- 执行系统调用
后面的syscall()的执行过程就不展开了, 因为重点是介绍系统调用的入口和出口
5. 恢复执行
usertrap() 最后会调用 usertrapret(),此时就开始处理返回给用户程序的处理过程了,我们需要做如下变动:
- 关闭中断
- 恢复 stvec = uservec,为能够顺利下一次ecall
- trapfram stap = 内核页表,为了下一次uservec能找到
- trapframe sp = 内核栈顶
- trapframe trap = usertrap
- trapframe hartid = hartid(in tp)

在最后,trampoline用到了RISV-V 的sret指令,为了能让该指令使用,需要准备一些寄存器
- sstatus: 其"privious mode"字段 设置 为0,意味着在用户模式
- sepc: 保存着用户程序的PC(能够返回陷入的入口)
我们还需要将切换到用户页表,在usertrapret()没有搞定,因为这里面没有用户的页表,需要在一个页表,它被映射到用户和内核页表——trampoline,因此会跳到trampoline.S的userret;
此时,我们知道寄存器a0持有者返回值,csrw satp指令切换到了用户地址空间,然后加载32个用户寄存器,我们跳过它。最后的最后,调用sret
sret的作用
是一个硬件硬件操作
- 将sepc复制到pc
- 将模式切换为用户模式
- 重新打开中断(将SPIE 复制到 SIE)
- 继续在新的PC执行指令
源码精读:trap 入口/出口的汇编与 C
前面用截图讲了流程,这里贴出三份参考源码(
trampoline.S/trap.c/riscv.h)的真实代码逐行拆解。代码取自 xv6-riscv(rev5)。⚠️ 命名提示:本讲早期讲义里的返回函数叫
usertrapret(),在 rev5 源码里已拆分/改名为prepare_return()(负责出口前的寄存器/控制位准备),下文以源码为准。
0. 关键寄存器速查(riscv.h 视角)
| 寄存器 | 作用 | 谁能写 |
|---|---|---|
satp | 当前页表的物理基址(+模式) | 仅 S 模式 |
stvec | trap 入口地址(CPU 进入 S 模式时跳到这) | 仅 S 模式 |
sepc | trap 时自动保存的用户 PC,sret 据此返回 | 由 ecall/硬件设置 |
sscratch | S 模式临时寄存器,用户态用不了 | 仅 S 模式 |
sstatus | SPP=来自哪个模式 / SIE=中断使能 / SPIE=之前的中断使能 | 仅 S 模式 |
scause | trap 原因(8=系统调用,13/15=页错误…) | 硬件设置 |
stval | 出错地址(页错误时) | 硬件设置 |
1. trampoline.S:uservec —— 入口(汇编)
ecall 后 CPU 跳到 stvec(= 这里),此时仍在用户页表、已在 S 模式:
uservec:
# 把用户 a0 暂存到 sscratch,腾出 a0 当指针用
csrw sscratch, a0
# 每个进程的 trapframe 都映射在同一个已知虚拟地址 TRAPFRAME(=0x3fffffe000)
li a0, TRAPFRAME
# 把 31 个用户寄存器存进 trapframe(注意此处还没存 a0)
sd ra, 40(a0)
sd sp, 48(a0)
...
sd t6, 280(a0)
# 用户 a0 此刻在 sscratch 里,取出来存进 trapframe->a0
csrr t0, sscratch
sd t0, 112(a0)
# 从 trapframe 取出内核侧上下文:
ld sp, 8(a0) # kernel_sp → 切到内核栈
ld tp, 32(a0) # kernel_hartid → tp 保存核号
ld t0, 16(a0) # kernel_trap → usertrap() 地址
ld t1, 0(a0) # kernel_satp → 内核页表
sfence.vma zero, zero # 等之前的访存在用户页表下完成
csrw satp, t1 # ★ 切换到内核页表
sfence.vma zero, zero # 刷掉 TLB 里旧的用户映射
jalr t0 # 跳进 usertrap()- 为什么先
csrw sscratch,a0?保存 32 个寄存器需要一个「指针寄存器」指向 trapframe,但所有寄存器都装着用户值。于是借sscratch(用户态碰不到、无需保存)把 a0 暂存,腾出 a0 当指针;最后再从 sscratch 取回用户 a0 存好。注意它是「写入」不是「交换」。 - 为什么切了
satp还能继续顺序执行不崩?因为 trampoline 页在用户页表和内核页表里都映射在同一个虚拟地址 TRAMPOLINE,所以换页表后 PC 指向的下一条指令仍然有效——这正是 trampoline「双映射」的意义。 - 偏移量(40/48/…/280)对应
struct trapframe里各寄存器的位置;前几项(0/8/16/32)是内核预先填好的 kernel_satp/kernel_sp/kernel_trap/kernel_hartid。
2. trap.c:usertrap() —— 分派(C)
uint64
usertrap(void)
{
int which_dev = 0;
if ((r_sstatus() & SSTATUS_SPP) != 0)
panic("usertrap: not from user mode"); // SPP 必须=0,确认来自用户态
w_stvec((uint64)kernelvec); // ★ 既然进了内核,后续 trap 改走 kernelvec(内核态处理)
struct proc *p = myproc();
p->trapframe->epc = r_sepc(); // 保存用户 PC(防被进程切换污染 sepc)
if (r_scause() == 8) {
// —— 系统调用 ——
if (killed(p)) kexit(-1);
p->trapframe->epc += 4; // ecall 是 4 字节,返回时要落在它的下一条
intr_on(); // 保存好关键寄存器后再开中断(ecall 进来时硬件已关)
syscall();
} else if ((which_dev = devintr()) != 0) {
// —— 设备中断 ——
} else if ((r_scause() == 15 || r_scause() == 13) &&
vmfault(p->pagetable, r_stval(), (r_scause()==13)?1:0) != 0) {
// —— 页错误(惰性分配等)——
} else {
printk("usertrap(): unexpected scause ...\n");
setkilled(p); // 无法处理 → 杀进程
}
if (killed(p)) kexit(-1);
if (which_dev == 2) yield(); // 定时器中断 → 让出 CPU
prepare_return(); // ★ 准备返回用户态
return MAKE_SATP(p->pagetable);// 把用户页表 satp 返回给 trampoline.S(在 a0 里)
}要点:
- 第一件事就是
w_stvec(kernelvec):进入内核后,如果再发生中断/异常,应该走「内核态 trap 处理(kernelvec)」而不是 uservec。返回用户前会在prepare_return里改回 uservec。 p->trapframe->epc += 4:sepc指向触发的ecall指令本身,但系统调用返回后要执行它的下一条,所以 +4。(页错误则不加,因为要重跑出错指令。)intr_on()的时机:必须等sepc/scause等被读出并保存后才能开中断,否则新中断会覆盖这些寄存器。
3. trap.c:prepare_return() —— 出口准备(C)
void
prepare_return(void)
{
struct proc *p = myproc();
intr_off(); // 即将把 trap 目标切回 uservec;此刻若来中断会跳进 usertrap 造成灾难,故先关中断
// 下次 trap 走 trampoline 里的 uservec
uint64 trampoline_uservec = TRAMPOLINE + (uservec - trampoline);
w_stvec(trampoline_uservec);
// 填好 trapframe,供下次 uservec 使用
p->trapframe->kernel_satp = r_satp(); // 内核页表
p->trapframe->kernel_sp = p->kstack + PGSIZE;// 内核栈顶
p->trapframe->kernel_trap = (uint64)usertrap; // 下次的 C 处理入口
p->trapframe->kernel_hartid = r_tp(); // 核号
// 准备 sret 要用的控制位
unsigned long x = r_sstatus();
x &= ~SSTATUS_SPP; // SPP=0 → sret 回到用户模式
x |= SSTATUS_SPIE; // SPIE=1 → 回用户态后重新开中断
w_sstatus(x);
w_sepc(p->trapframe->epc); // sret 的目标 PC = 之前保存的用户 PC
}对照原笔记「恢复执行」一节列的 6 步:
stvec→uservec、填kernel_satp/sp/trap/hartid、设SPP/SPIE、设sepc——一一对应。注意它只做准备,真正切回用户页表 + 恢复 32 个寄存器 +sret是在userret里完成的,因为这些得在「两个页表都映射的 trampoline」里干。
4. trampoline.S:userret —— 出口(汇编)
userret:
# usertrap() 返回值(用户页表 satp)在 a0
sfence.vma zero, zero
csrw satp, a0 # ★ 切回用户页表
sfence.vma zero, zero
li a0, TRAPFRAME # a0 重新指向 trapframe
# 恢复除 a0 外的 31 个用户寄存器
ld ra, 40(a0)
...
ld t6, 280(a0)
ld a0, 112(a0) # 最后恢复用户 a0(里面是系统调用返回值)
sret # ★ 回用户态:见下sret 做了什么(硬件一条指令)
- 把
sepc复制到pc(回到陷入点 / 下一条指令) - 根据
sstatus.SPP切回用户模式(前面已设 SPP=0) - 把
SPIE复制回SIE(重新开中断) - 从新的 PC 继续执行
5. 状态变化全景
| 阶段 | 模式 | 页表 | PC 所在 | 中断 |
|---|---|---|---|---|
用户执行 ecall 前 | U | 用户 | 用户低地址 | 开 |
ecall 后 → uservec | S | 仍用户 | trampoline(高址) | 关 |
csrw satp 后 → usertrap | S | 内核 | trampoline→内核 | 关→(保存后)开 |
prepare_return | S | 内核 | 内核 | 关 |
userret 切 satp 后 | S | 用户 | trampoline | 关 |
sret 后 | U | 用户 | 用户(回 sepc) | 开 |
一句话串起来:
ecall只切模式 + 跳stvec(最小化)→uservec借sscratch存寄存器、换内核页表 →usertrap认scause分派 →prepare_return备好返回控制位 →userret换回用户页表、恢复寄存器 →sret切回用户态。 全程靠「双映射的 trampoline + 已知地址的 trapframe + S 模式专属的 sscratch」三件套,既透明又隔离。
参考资料
- lec
- 阅读xv6的第4章
- 研读 xv6 源码
kernel/riscv.hkernel/trampoline.Skernel/trap.c