提到MIT 6.S081这个实验系统,绕不开lab2 系统调用的创建。上一个lab1让人觉得“还好,不过是几个用户程序”,等到lab2开始往内核里塞新系统调用时,很多人会发现以前没仔细看的usys.S、trapframe、syscall分发表突然全变成了拦路虎。我第一次做这个实验前,自以为已经理解了系统调用的大致流程,结果连续改错三四个地方,每次编译后进qemu看到的都是“unknown sys call”,来回折腾了很久才把问题理清。这个lab会让你在xv6里补上trace和sysinfo两个系统调用:前者能按进程跟踪某类系统调用并打印日志,后者让用户程序读取内核当前的空闲内存量和活跃进程数。做完这个实验,你不光能知道怎么“新增”一个系统调用,更能把用户态到内核态这条路上每一道关卡都串起来。
我见过不少人去网上找现成的patch,提交完make grade通过就当结束了。这样倒也能过,但很可惜,因为lab2真正值钱的是那个“接线”的过程:为什么用户态一个函数调用最终会变成内核函数的执行?为什么新增一个系统调用要改七八个文件?这些点一旦看明白,后面lab3页表、lab4 trap、lab5 lazy alloc都会轻松不少。下面我按自己从读题到实现的顺序,把完整链路、核心代码、调试踩坑全部摊开讲,尽量让看完的人能少走几个小时的弯路。
1. 从echo到内核:xv6系统调用的完整链路与新调用接入点
1.1 用户态stub、a7寄存器和ecall是如何把write送进内核的
很多第一次做lab2的人会问,用户程序里直接写write(1, "hello", 5),这个write到底是谁实现的?xv6没有完整的libc,用户态看到的write、read、fork这些函数,本质上是一段由Makefile自动生成的汇编stub。这些stub最初写在user/usys.pl脚本里,编译时会生成user/usys.S,里面每个系统调用对应这几条指令:
asm复制write:
li a7, SYS_write
ecall
ret
也就是说,用户程序调用write时,真正执行的动作是:把系统调用号SYS_write放进a7寄存器,然后执行ecall指令陷入内核。RISC-V里ecall会让CPU从用户态切换到内核态,并跳转到stvec寄存器指向的入口,也就是trampoline代码,再由trampoline转到内核的usertrap函数。usertrap根据中断原因判断这是一次系统调用后,会调用syscall.c里的syscall函数。
做过Java JNI或者写过跨语言绑定的朋友,对这个过程应该很有画面感:Java侧声明一个native方法,JVM在native侧注册对应函数,调用时JVM负责跨语言边界的参数传递和异常转换。xv6这里的系统调用也一样,只不过它跨的不只是语言边界,而是用户态和内核态的特权级边界,参数传递靠寄存器而不是JVM栈,地址空间隔离靠页表而不是虚拟机内存管理。所以每次新增系统调用,都需要在用户侧、内核侧同步留下“接口登记信息”,缺一层,整个链路就断掉。
1.2 内核侧syscall分发表如何把一个数字变成一个函数
当ecall进入内核后,syscall函数会从当前进程的trapframe里取出a7寄存器,这个值就是系统调用号。接下来它要做一次查表,找到对应的内核函数。xv6在kernel/syscall.c里维护了这样一个数组:
c复制static uint64 (*syscalls[])(void) = {
[SYS_fork] sys_fork,
[SYS_exit] sys_exit,
[SYS_wait] sys_wait,
[SYS_pipe] sys_pipe,
[SYS_read] sys_read,
...
};
注意这个数组的写法是C99的指定初始化器:数组下标就是系统调用号。sys_fork这些函数的类型统一是uint64(void),返回值会被写回当前进程trapframe的a0寄存器,这样用户态stub执行ecall之后的ret,用户程序拿到的返回值自然就是a0里的内容。
理解了这条链路,就能明白一个系统调用要接入xv6,至少需要这几步:
- 在kernel/syscall.h中定义系统调用号,比如
#define SYS_trace 22 - 在user/usys.pl中添加entry,生成用户态的汇编stub
- 在user/user.h中声明用户态可见的函数原型
- 在kernel/syscall.c中加上对sys_trace的extern声明,并注册到syscalls数组里
- 在内核某个文件里真正实现sys_trace函数
这个结构不是xv6自己拍脑袋设计的,而是典型的系统调用“全链路注册”模型。用户态编译只需要知道stub和函数声明,内核只认系统调用号,两边通过syscall.h里的编号对上。后面trace实现里还会涉及进程结构体字段、fork继承、syscall日志打印,那些是在这条主干道上加业务逻辑。主干道没接通之前,任何业务逻辑都是空中楼阁。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. trace和sysinfo两个题目到底想考什么
2.1 trace:一个掩码参数与“进程属性继承”的语义
lab2对trace的要求是:用户调用trace(mask)之后,当前进程后续发起的每一个系统调用,只要它的系统调用号对应的二进制位在mask中被置位,就必须在内核里打印一行日志,格式类似:
code复制3: syscall read -> 1023
其中3是进程pid,read是系统调用名,1023是返回值。很多人一看就想到“在syscall函数里加打印”,这方向没错。但真正容易忽略的是题目隐含的进程语义:这个跟踪设置是要影响当前进程之后fork出来的子进程的。也就是说,你不能只把mask存到一个全局变量里,而要挂在struct proc结构体上,并且在fork时把它复制给子进程。为什么是struct proc而不是全局变量?因为系统调用跟踪通常是粒度到进程的,一个进程设置跟踪,不应该影响系统中其他无关进程。如果你用一个全局mask,那其他进程的系统调用也会被打出来,sysinfotest或者trace测试很可能失败。
exec之前调用trace的是用户程序trace.c,它会先执行一次trace系统调用设置mask,然后用exec执行被跟踪的目标程序。进程执行exec后,用户内存映像会被替换成新程序,但struct proc里的tracemask不是用户内存,它不受exec影响,所以跟踪状态会自然保留到新程序里。题目没有明确说exec后是否保留,但从行为预期看,它必须保留,否则trace 32 grep hello README这类命令就没法工作。
2.2 sysinfo:空闲内存与进程数量的统计,背后全是数据结构
sysinfo的需求更直接:实现一个系统调用,接收用户态传入的struct sysinfo*指针,内核负责填入两个字段:freemem表示空闲内存字节数,nproc表示状态不是UNUSED的进程数量。和trace比,它完全不需要新增进程字段,也没有继承语义,但它要求你真正去读xv6的两个核心数据结构。
第一个是内核物理内存分配器kalloc。xv6用kmem维护一个空闲物理页链表,每一页的空闲内存头部存一个struct run指针,指向下一页。统计空闲内存,就是遍历这个链表,每遇到一个节点加一个PGSIZE,也就是一个空闲页4096字节。注意是字节数,不是页数,这是测试里最容易看错的地方。第二个数据结构是进程表struct proc proc[NPROC],这是全局数组,遍历它统计非UNUSED的进程个数即可。
sysinfo比trace多了一个跨地址空间拷贝的问题。用户程序传进来的是一个用户态虚拟地址,内核不能直接往这个地址写数据,因为内核页表里根本没有对应的映射,直接写就会触发页错误。xv6提供copyout函数,把内核内存中的数据复制到指定进程的用户虚拟地址空间去。这也是题目隐藏的考点:系统调用不仅要从用户态取参数进来,还要能把结果安全送回去。整个过程就像你在一个隔离区里面,想给外面的人递东西,你只能通过专门的窗口操作员copyout,而不是隔墙扔出去。
| 新系统调用 | 用户侧原型 | 内核核心逻辑 | 真正的考点 |
|---|---|---|---|
| trace | int trace(int mask) | 设置proc的tracemask并在syscall中打印 | fork继承、exec保留、掩码判断 |
| sysinfo | int sysinfo(struct sysinfo*) | 统计freemem和nproc并copyout | 空闲页链表、进程表遍历、用户指针写入 |
对比着看其实很有意思:trace考的是“一个进程级配置如何在内核各流程中流转”,sysinfo考的是“怎么读取内核全局状态并安全地返回给用户”。一个偏生命周期,一个偏数据结构,两者结合刚好把系统调用的两个侧面都覆盖了。
3. 动手实现:trace与sysinfo的接线顺序和关键代码
3.1 四件套:user.h、usys.pl、syscall.h、syscall.c必须同步改
先说trace。如果你完全不看vx6现有代码,直接往里面加东西,大概率会在“到底哪些文件需要改”上晕头转向。我自己推荐按用户态到内核态的次序来改:先让用户程序能编译,再让内核能识别,最后再写业务逻辑。
先改user/user.h,在文件末尾加上函数原型。由于sysinfo的参数是指向结构体的指针,你可以在声明里用不完整类型:
c复制int trace(int mask);
struct sysinfo;
int sysinfo(struct sysinfo *info);
使用不完整类型的目的就是为了避免在user.h里包含一大堆头文件,反正这里只需要知道它是一个指针。然后在user/usys.pl中加入两条entry:
perl复制entry("trace");
entry("sysinfo");
make的时候脚本会重新生成usys.S,为两个新系统调用生成“li a7,编号; ecall; ret”的汇编stub。
接着改内核侧编号。打开kernel/syscall.h,在已有的SYS_close后面追加:
c复制#define SYS_trace 22
#define SYS_sysinfo 23
最后改kernel/syscall.c。这里有两个地方必须同步:一是在文件前面加两个extern声明,二是在syscalls数组里注册。最
