xv6的ecall怎么回来?从a0的第一次交换走到sret
发布时间:2026/11/2 15:00:17 锦皓数字建站

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并清SIEsret再按规定恢复。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_sp1\n, pid, (int)result); exit(0); }probe_ecall、probe_after_ecall两个标号方便 GDB 精确停在指令前后。调用结果先存进result再调用普通的getpid()对照不能在第二次调用后还把固定寄存器变量当作第一次调用的结果。构建时只打入init、sh和测试程序内核源码不改make TOOLPREFIXriscv64-linux-gnu- \ UPROGSuser/_init user/_sh user/_trapprobe \ CFLAGS-Wall -Werror -Wno-errorinfinite-recursion -O -fno-omit-frame-pointer -ggdb -MD -mcmodelmedany -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,pmpfalse \ -bios none -kernel kernel/kernel -m 128M -smp 1 -nographic \ -global virtio-mmio.force-legacytrue \ -drive filefs.img,ifnone,formatraw,idx0 \ -device virtio-blk-device,drivex0,busvirtio-mmio-bus.0这里有几项历史版本兼容条件不能省了不说。GCC 15 会对旧版 shell 报无限递归警告命令只把这一项降为警告没有改 shell。这个历史内核启动路径与新 QEMU 默认配置不匹配因此本实验关闭模拟 CPU的 PMP并强制 legacy virtio仅用于隔离教学虚拟机不能拿去当安全部署配置。构建还可能提示历史_entry符号问题本次最终启动结果如下不能只看链接警告就声称系统已运行。进入 xv6 的$提示符后输入trapprobe$ trapprobe TRAP_PROBE_OK pid3 result3 same_sp1进程号可以不同需要一致的是两次结果以及返回前后的用户栈。退出 QEMU 使用Ctrl-a后按x。怎么验证中间过程而不只看最后一行最后打印成功只能说明程序返回了、结果相同、两次读取的栈指针相同不能单凭它证明“CPU 进来时没换页表”。所以我们另外用 GDB 记录中间状态。若手工观察在上面的 QEMU 命令追加-S -gdb unix:/tmp/xv6-trap-lab.gdb,serveron,waitoff先确认这是你本次实验独占的套接字路径。另一终端在源码目录执行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。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。