☰
xv6的ecall怎么回来?从a0的第一次交换走到sret
2026/11/2 14:58:14 网站建设 项目流程

xv6的ecall怎么回来?从a0的第一次交换走到sret

读到系统调用时,很容易先记住这么一句话:用户程序执行ecall,进入内核,内核处理完再回来。

但沿着代码看,第一步就有个问题。用户程序的a0里面可能装着参数,sp也还指向用户栈。现在内核要保存这些寄存器,总得先找个寄存器装保存地址吧?拿谁装,谁原来的值不就被覆盖了吗?

先盯住这个问题。我们把一次getpid跟到底,看看它究竟怎么进去,又怎么回来。

旧笔记以 MIT 的 xv6 教材第四章为学习来源。这篇沿用问题,重新按一次运行展开。为和旧稿中的csrrw a0, sscratch, a0对上,源码固定为 MIT xv6-riscv 的 e23d53f 提交,不混用新版入口。教材作者为 Russ Cox、Frans Kaashoek、Robert Morris,见 2020 年版教材。下文寄存器变化来自这个固定版本在 QEMU 中的实际跟踪。

先给a0放一个认得出的数

平时调用getpid()不需要参数。这里特意在a0放入0x1234,只是为了认出它;getpid不会使用这个数。再把系统调用号放到a7,执行ecall。

这个版本的SYS_getpid是 11。我们执行前看到的是:

还在用户程序里 pc = 0x18 下一条是ecall a0 = 0x1234 人为放的标记 a7 = 11 要做getpid sp = 0x2fb0 用户栈的位置

这些地址只对应本次编译。换编译器、改测试程序,都可能变化;不要把0x18当系统调用的固定地址。

执行ecall以后,CPU 会先记录原因和返回位置,再跳到事先设置好的入口。在这个 xv6 中,来自用户态的这次调用被交给 S 模式处理,入口指向 trampoline 中的uservec。

此时有几个名字够用就行:sepc记住发生异常的指令地址,scause说明原因,stvec指定处理入口;satp则决定当前用哪张页表翻译地址。

在uservec第一条指令还没执行的位置停下,我们看到:

已经在S模式,到了uservec pc = 0x3ffffff000 sepc = 0x18 刚才的ecall scause = 8 来自U模式的ecall a0 = 0x1234 没动 sp = 0x2fb0 也没动 satp 还是刚才的用户页表

所以,“已经进内核”并不等于所有环境都准备好了。CPU 做了特权级和入口的切换,但没有替 xv6 把用户寄存器全部存起来,也没有替它换栈、换页表。入口汇编还得接着做。

第一个保存地址,从哪里拿?

先看 xv6 事前的准备。每个进程有一块trapframe,用来保存它的寄存器等状态。在这个进程的用户页表中,xv6 把它映射到固定地址TRAPFRAME,也就是这里的0x3fffffe000。

“放在用户页表里”不代表用户程序能访问。这项映射没有用户访问权限,是让刚进入 S 模式、还没换页表的入口代码能够使用。

保存区域有了,但当前a0还是0x1234。如果直接把地址写进a0,原值仍会丢。

这里用上sscratch。它是 S 模式的一个暂存寄存器;xv6 在返回用户态时,已经把TRAPFRAME放进去了。现在执行第一条指令:

csrrw a0, sscratch, a0

这条指令交换两边的值。我们把它前后画在一起:

执行前 a0 = 0x1234 sscratch = TRAPFRAME 交换 ↓ 执行后 a0 = TRAPFRAME sscratch = 0x1234

现在a0可以当保存区域的地址了,原来的0x1234也没丢,而是暂时挪到了sscratch。

接下来大量的sd就容易读了。比如:

sd ra, 40(a0) sd sp, 48(a0)

它们把用户的返回地址、栈指针写进trapframe对应位置。48不是另一个栈的地址,是这个版本结构体中sp字段的偏移,能和kernel/proc.h对照。

那原来的a0呢?它刚才被交换走了,不能直接保存当前a0。所以这里另取一次:

csrr t0, sscratch sd t0, 112(a0)

到这里,标记从a0到sscratch,再进trapframe->a0,没有凭空消失。

保存好了,才轮到换栈、换页表

寄存器有备份后,入口代码就可以使用它们来搭建内核环境了。trapframe前面几个字段不是用户寄存器,而是内核事先填好的入口信息:内核栈顶、内核页表、usertrap的地址等。

例如换栈这一句:

ld sp, 8(a0)

它读出kernel_sp。刚才已经执行过sd sp, 48(a0),因此现在覆盖sp不会丢掉用户栈位置。顺序颠倒就不行:先换栈再保存,保存下来的就不是用户的sp了。

本次运行,在到达 C 函数usertrap的入口时:

trapframe里留着: sp = 0x2fb0 a0 = 0x1234 CPU当前用的是: sp = 0x3fffffa000 satp = 内核页表的值

接下来有个容易略过的问题:页表换掉后,CPU 下一条指令去哪儿取?

假如同一个虚拟地址在新页表里找不到,或者翻译成另一段无关代码,切换动作本身就把后面的路断了。

xv6 因此把这小段 trampoline 代码,在两张页表中映射到相同虚拟地址、相同物理代码页:

用户页表 TRAMPOLINE ──→ 同一页入口代码 ↑ 内核页表 │ TRAMPOLINE ──────────┘

csrw satp, t1换页表,后面的sfence.vma同步地址翻译,再接着跳到usertrap。过渡代码不会因切换页表突然落到别处。

这里需要共享的是这段过渡代码的映射,不是把整个内核都开放给用户。trapframe的固定高地址也不要想当然地认为在两张页表里都能继续使用;换到内核页表后,C 代码通过进程结构里的指针访问它。

进了C函数,为什么还要把返回位置加4?

现在环境准备好了,可以读kernel/trap.c的usertrap。

它先保存sepc到p->trapframe->epc。这时值是0x18,也就是那条ecall。

如果处理完,仍然回到0x18呢?程序又执行一次ecall,就重复调用了。我们真正想接上的是它后面那条指令,因此系统调用分支执行:

p->trapframe->epc += 4;

本次就从0x18改成0x1c。ecall是 4 字节指令;这不是“所有中断异常都把 PC 加 4”的规则。外部中断、缺页等情况要按各自处理语义判断,不能抄这个加法。

随后调用syscall()。这个函数根据保存下来的a7找处理函数,并把返回值放进保存区:

num = p->trapframe->a7; // 检查编号后,调用对应函数。 p->trapframe->a0 = syscalls[num]();

这里摘出两句关键操作,中间的编号检查并没有省略执行。我们这次得到进程号3,所以trapframe->a0被改成了3。

看到这儿,得把“恢复寄存器”这句话理解具体一点。系统调用返回时,a0 不能恢复成最初的参数值,它得装返回值。保存区既留住用户状态,也给内核一个修改返回结果的位置。

刚保存进来: trapframe->a0 = 0x1234 trapframe->epc = 0x18 处理系统调用后: trapframe->a0 = 3 trapframe->epc = 0x1c

回去的时候,最后一个a0怎么恢复?

usertrapret开始准备返回。它设置下次用户陷阱的入口,补好下次进入内核要用的kernel_sp等字段,并把返回目标sepc设为刚才保存的0x1c。

还有sstatus:清掉SPP,表明这次sret要返回 U 模式;设置SPIE,准备返回时的中断状态。切换入口期间先关闭 S 模式中断,以免环境只准备了一半就又发生中断。

SIE/SPIE是特权级中断状态,不要简单理解为“用户程序的普通开关”。进入 S 模式陷阱时,硬件把旧SIE存到SPIE并清SIE;sret再按规定恢复。U 模式下 S 级中断是否可响应,也不能仅用当前SIE的值判断,细则见 RISC-V Supervisor 规范。

然后跳到 trampoline 里的userret,先换回用户页表,再恢复保存的寄存器。刚才共享的那一小页代码,在回程也有用。

此时a0又暂时拿来装TRAPFRAME地址。若先把它恢复成3,后面就不能再用a0找其他寄存器了。于是 xv6 把返回值先放到sscratch,恢复其他寄存器,最后再交换一次:

其他寄存器已经恢复,最后交换前: a0 = TRAPFRAME sscratch = 3 csrrw a0, sscratch, a0 ↓ a0 = 3 sscratch = TRAPFRAME

这样返回值到了用户的a0,同时sscratch又备好了下一次进入内核所需的地址。开头那次交换为什么有地址可拿,到这里也就接上了。

最后执行sret。它根据准备好的特权级状态和sepc,回到用户程序。它本身不会替 xv6 把所有通用寄存器恢复;那些是前面的汇编做的。

实际停在用户程序下一条指令时,我们得到:

模式 = U pc = 0x1c 没再执行ecall a0 = 3 getpid的结果 sp = 0x2fb0 回到了原用户栈 satp 回到了原用户页表 sscratch = TRAPFRAME

到这里,这次调用才算走完。入口先给原值找临时位置,再保存用户环境;回程则先把环境接回去,最后交出返回值。看懂这两次a0/sscratch的交换,比先背一串函数名更容易把整条路串起来。

自己跑:只加一个用户测试程序

使用 Linux/WSL,准备make、RISC-V GCC/binutils、支持 RISC-V 的 GDB,以及qemu-system-riscv64。下面在新建实验目录里操作,不切换现有项目的提交。复现测试环境为 GCC 15.1.0、QEMU 11.0.2、GDB 17.2。

git clone https://github.com/mit-pdos/xv6-riscv.git xv6-trap-lab cd xv6-trap-lab git checkout --detach e23d53f27f6e187732a92a2b4705639d1c260f05

新建user/trapprobe.c,内容如下。这段内联汇编面向该实验的 RISC-V GCC,不是可移植 C 写法。

#include "kernel/types.h" #include "kernel/stat.h" #include "kernel/syscall.h" #include "user/user.h" int main(void) { uint64 before, after; asm volatile("mv %0, sp" : "=r"(before)); register uint64 argument asm("a0") = 0x1234; register uint64 number asm("a7") = SYS_getpid; asm volatile(".global probe_ecall\nprobe_ecall:\necall\n" ".global probe_after_ecall\nprobe_after_ecall:" : "+r"(argument) : "r"(number) : "memory"); uint64 result; asm volatile("mv %0, a0" : "=r"(result)); asm volatile("mv %0, sp" : "=r"(after)); int pid = getpid(); if(result != pid || before != after){ printf("TRAP_PROBE_FAILED\n"); exit(1); } printf("TRAP_PROBE_OK pid=%d result=%d same_sp=1\n", pid, (int)result); exit(0); }

probe_ecall、probe_after_ecall两个标号方便 GDB 精确停在指令前后。调用结果先存进result,再调用普通的getpid()对照;不能在第二次调用后还把固定寄存器变量当作第一次调用的结果。

构建时只打入init、sh和测试程序,内核源码不改:

make TOOLPREFIX=riscv64-linux-gnu- \ 'UPROGS=user/_init user/_sh user/_trapprobe' \ 'CFLAGS=-Wall -Werror -Wno-error=infinite-recursion -O -fno-omit-frame-pointer -ggdb -MD -mcmodel=medany -ffreestanding -fno-common -nostdlib -mno-relax -I. -fno-stack-protector -fno-pie -no-pie' \ kernel/kernel fs.img qemu-system-riscv64 -machine virt -cpu rv64,pmp=false \ -bios none -kernel kernel/kernel -m 128M -smp 1 -nographic \ -global virtio-mmio.force-legacy=true \ -drive file=fs.img,if=none,format=raw,id=x0 \ -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0

这里有几项历史版本兼容条件,不能省了不说。GCC 15 会对旧版 shell 报无限递归警告,命令只把这一项降为警告,没有改 shell。这个历史内核启动路径与新 QEMU 默认配置不匹配,因此本实验关闭模拟 CPU的 PMP,并强制 legacy virtio;仅用于隔离教学虚拟机,不能拿去当安全部署配置。构建还可能提示历史_entry符号问题;本次最终启动结果如下,不能只看链接警告就声称系统已运行。

进入 xv6 的$提示符后输入trapprobe:

$ trapprobe TRAP_PROBE_OK pid=3 result=3 same_sp=1

进程号可以不同;需要一致的是两次结果,以及返回前后的用户栈。退出 QEMU 使用Ctrl-a后按x。

怎么验证中间过程,而不只看最后一行?

最后打印成功,只能说明程序返回了、结果相同、两次读取的栈指针相同,不能单凭它证明“CPU 进来时没换页表”。所以我们另外用 GDB 记录中间状态。

若手工观察,在上面的 QEMU 命令追加-S -gdb unix:/tmp/xv6-trap-lab.gdb,server=on,wait=off,先确认这是你本次实验独占的套接字路径。另一终端在源码目录执行gdb -q kernel/kernel,连接后设置断点:

set architecture riscv:rv64 target remote /tmp/xv6-trap-lab.gdb add-symbol-file user/_trapprobe 0 hbreak *probe_ecall condition 1 $a7 == 11 && $a0 == 0x1234 continue

切回 xv6 终端输入trapprobe,便会停在ecall前。使用全新 GDB 会话,上面的断点编号才是1;已有断点时用 GDB 实际给出的编号。

记录p/x $sp、p/x $satp、p/x $a0,然后给入口地址设临时断点,不要指望 GDB 的一次si一定停在陷阱的第一条指令:

thbreak *0x3ffffff000 condition 2 $scause == 8 && $a0 == 0x1234 && $a7 == 11 continue info registers pc sp a0 satp sscratch sepc scause thbreak *0x3ffffff004 continue info registers a0 sscratch sp satp

后面可以在usertrap、syscall、usertrapret和probe_after_ecall设断点。进usertrap时先对照用户栈备份和当前内核栈;进syscall时看保存的 PC 已经前进;进usertrapret时看保存的a0已是返回值;最后到用户指令再对照原栈和原页表。单核实验可从cpus[0].proc->trapframe读取字段;不要把这个取法直接推广到多核任意时刻。

本次自动验证也是按这七个位置设置条件断点,把状态逐个断言,再等用户程序的输出。寄存器统一按 64 位无符号值比较,避免 GDB 的有符号显示把同一个satp误判成不同值。每次运行使用单独的文件系统镜像副本,完成后退出自己的 GDB/QEMU,不接触宿主磁盘。

我们测的是这一个版本、这一次 U 模式getpid的来回路径,没有拿它代替设备中断、内核嵌套异常、调度切换的验证。以后看这些分支时,可以接着问:这次从哪里来,哪些状态还不能覆盖,处理完应该回到哪条指令?

源码摘录来自上述 MIT xv6-riscv 仓库,原代码采用 MIT License,版权属于其作者。本文保留出处;若下载源码后再分发,应一并保留仓库中的LICENSE。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询