xv6 Lab2 System calls - MIT 6.1810 Fall 2025 Operating System
本文最后更新于 2026年7月26日 晚上
阅读 xv6 book
操作系统主要解决三个要求:
- multiplexing:多个程序共享 CPU、内存等资源;
- isolation:一个程序不能破坏内核或其他程序;
- interaction:程序仍然需要通过管道等机制进行受控通信。
因此出现了这些抽象:
| 物理资源 | 提供给程序的抽象 |
|---|---|
| CPU | 进程、线程 |
| 物理内存 | 地址空间 |
| 磁盘 | 文件系统 |
| 进程间通信 | 管道、文件描述符 |
RISC-V 的特权级:
- machine mode:启动阶段使用,权限最高;
- supervisor mode:xv6 内核运行于此;
- user mode:普通应用运行于此。
需要内核服务时,在 user mode 执行 ecall 陷入内核态。
宏内核:
- 文件系统、进程管理、内存管理、驱动等都运行在内核态。
- 优点是调用直接、模块协作方便、性能较好;缺点是内核庞大,任何严重 bug 都可能让整个系统崩溃。
- xv6 是宏内核,所有核心模块都在
kernel/中直接互相调用。
微内核:
- 内核只保留调度、地址空间、IPC 等基本机制;文件系统等作为用户态服务器运行,通过消息通信
- 优点是内核更小、更容易隔离和验证;缺点是跨进程通信会增加实现和性能开销。
xv6 源码的组织:
| 领域 | 主要文件 |
|---|---|
| 启动 | entry.S、start.c、main.c |
| 进程与调度 | proc.c、swtch.S |
| 系统调用 | syscall.c、sysproc.c、sysfile.c |
| trap | trampoline.S、kernelvec.S、trap.c |
| 虚拟内存 | vm.c |
| 物理内存 | kalloc.c |
| 文件系统 | fs.c、file.c、log.c、bio.c |
| 设备 | console.c、uart.c、virtio_disk.c |
xv6 中的进程:
每个进程在内核中有一个 struct proc,其中重要内容包括:
pagetable:用户地址空间;kstack:进入内核后使用的内核栈;trapframe:保存用户寄存器;context:内核线程上下文;state:运行、可运行、睡眠等状态。
进程有两套栈:
- 用户态运行:使用 user stack
- 进入内核:使用该进程自己的 kernel stack
用户栈即使损坏,也不应该影响内核栈。xv6 中一个进程只有一个线程,现代系统通常允许一个进程拥有多个线程。
调用一个系统调用的过程的例子:
调用 write(2, "$", 2);:
用户 C 代码调用 write()
↓
user/usys.S 中的 write 标签 (这一步是通过链接实现的)
↓
a7 = SYS_write
↓
ecall
↓
uservec → usertrap → syscall()
↓
syscalls[SYS_write]
↓
sys_write()
↓
返回值写入 trapframe->a0
↓
恢复用户寄存器,回到 write() 后面RISC-V 调用约定中:
a0、a1、a2……保存函数参数;a7保存系统调用编号;a0同时用来保存返回值。
系统调用中汇编代码的联系方式:
user.h 中的声明 + usys.S 中的汇编包装函数 + 链接器将两者对应起来
xv6 的启动
这一块是我写 lab 3 的时候感觉没弄懂的,所以回来补一下。
参考的资料:
- 3.9 XV6 启动过程 | MIT6.S081
- MIT6.s081/6.828 lectrue02:OS design 以及 Lab2 心得 - 逆风的大船的文章 - 知乎
- 重读 xv6(I) | Anri’s blog
QEMU
首先我们需要理解什么是 QEMU。
应该把 QEMU 想象为一个真正的,基于 RISC-V 的计算机对待。
但是,直观上来看,QEMU 是一个开源 C 语言程序,内部的核心就是一个 while(1) 循环,执行:
- 读取 RISC-V 指令
- 解析,找到对应的 op code
- 在软件中执行相应的指令。
xv6 启动流程
RISC-V 有三种 CPU 执行指令的模式:机器模式、内核模式、用户模式。
启动流程:
- 机器启动,将 kernel 加载到内存
- 运行
entry.S,为每个 CPU 设置运行栈,此时为 machine mode - 运行
start,进行一些初始配置,切换为 kernel mode - 运行
main,初始化各个模块,创建首个进程,切换为 user mode - 运行
init,设置标准 I/O,启动shell,开始提供服务
start :
- 时钟中断,用于虚拟化 CPU,切换进程
- 页表映射,用于虚拟化内存地址
main :
- 内存池、分页,用于分配用户空间,虚拟化内存
- trap 中断,用于响应异常,实现系统调用
- 进程管理,用于分配资源,管理进程
- 文件系统,用于控制文件读写
- PLIC、设备驱动、磁盘
实际远比这些复杂,之后会单开一个 blog 文章解析一下此过程,TODO。
- 找到一个很不错的 b 站视频:【04(1)-xv6操作系统-系统启动-1(bootblock)】 ,之后评鉴一下。
Using gdb
一系列练习使用 gdb 的回答练习。
这一段说实话有点无聊,因为我之前没有很好的,关于使用 gdb debug 的经历。所以我大部分是让 LLM 指导我做的。

答题:
代码
Looking at the backtrace output, which function called syscall?
usertrap() at kernel/trap.c:68
What is the value of p->trapframe->a7 and what does that value represent? (Hint: look at user/init.c, the first user program xv6 starts, and its compiled assembly user/init.asm.)
$4 = 0xf, 当前进程 p 进入内核时,保存下来的用户态寄存器 a7 的值。0xf = 15,而 SYS_OPEN = 15,所以代表 open 系统调用。
What was the previous mode that the CPU was in?
用户态
Write down the assembly instruction the kernel is panicing at. Which register corresponds to the variable num?
得到:scause=0xd sepc=0x80001cf8 stval=0x0
panic: kerneltrap
再得到:
4410: 80001cf8: 00002683 lw a3,0(zero) # 0 <_entry-0x80000000>
a3 对应 num
Why does the kernel crash? Hint: look at figure 3-3 in the text; is address 0 mapped in the kernel address space? Is that confirmed by the value in scause above?
代码 *(int *)0 要求 CPU 从虚拟地址 0 读取数据,但内核页表没有映射地址 0,于是发生 page fault。
What is the name of the process that was running when the kernel paniced? What is its process id (pid)?
(gdb)p p->name
$1 "init",\000'<repeats 11 times>
(gdb)p/d p->pid
$2=1一个疑惑:
引用
scause=0xd sepc=0x80001cf8 stval=0x0 panic: kerneltrap 这个分别代表什么,是怎么知道的?
把这三项理解成一次异常的“现场报告”:
scause = 为什么出错 sepc = 哪条指令出错 stval = 访问什么地址时出错你这里是:
scause=0xd sepc=0x80001cf8 stval=0x0 panic: kerneltrap
scause = 0xd
scause表示 trap 的原因。0xd = 十进制 13 = Load page fault也就是:
CPU 在读取内存时发生了页错误。
这与故意加入的代码完全对应:
num = *(int *)0;它试图从内存地址
0读取一个int。RISC-V 规定异常编号 13 表示 load page fault。(RISC-V Documentation)
stval = 0x0对于 page fault,
stval通常记录导致错误的虚拟地址。这里是:
stval = 0x0所以可以确定:
出错的内存访问目标是虚拟地址
0。也就是代码中的:
*(int *)0 ^内核页表没有映射地址
0,因此读取失败。Lab 正是让你通过scause=0xd和stval=0判断这是空地址读取造成的 page fault。(MIT CSAIL)
sepc = 0x80001cf8
sepc保存发生异常时正在执行的指令地址。这里表示:
位于内核地址
0x80001cf8的那条机器指令触发了异常。因此 Lab 让你拿这个地址去查编译后的汇编:
grep -n -B 4 -A 4 "80001cf8" kernel/kernel.asm你应该会找到类似:
80001cf8: ... lw a?,0(zero)其中:
lw a?, 0(zero)意思是从地址:
zero + 0 = 0读取数据。Lab 明确要求用打印出的
sepc在kernel/kernel.asm中定位故障指令。(MIT CSAIL)也可以在 GDB 中直接停在这里:
b *0x80001cf8 c x/i $pc
panic: kerneltrap异常发生时,CPU 正在执行内核代码,所以它进入:
kerneltrap()
kerneltrap()会尝试判断是不是正常的设备或时钟中断。这个 page fault 不是可正常处理的设备中断,于是 xv6 打印三个寄存器并执行:panic("kerneltrap");所以整个推理过程是:
scause = 0xd → 是读取内存产生的 page fault stval = 0 → 读取的是虚拟地址 0 sepc = 0x80001cf8 → 去 kernel/kernel.asm 找这个地址的指令 最终定位 → num = *(int *)0 导致内核读取未映射地址 0你在答案文件中可以写:
scause 0xd indicates a load page fault. stval 0x0 shows that the faulting virtual address was zero. sepc 0x80001cf8 is the address of the faulting instruction, which can be located in kernel/kernel.asm. The kernel crashes because it tries to load data from unmapped virtual address 0.
Sandbox a Command
这感觉主要就是建立认知和 insight,但是感觉文档给的引导不足,或者是我太菜了(大概是这样。)
- 二遍:其实 xv6 book 里面写的很清楚,但是我读书不仔细。不过也无所谓,写完也清楚机制了。
一个问题:
引用
- struct proc 是什么?sysproc.c 是干嘛的?
- kfork kexit 这些都是啥?和 fork 系统调用区别是什么?话说回来这就是fork的实现的地方吗?那是如何指向的?感觉我脑子里一团乱
Answer:
struct proc是什么?
struct proc定义在kernel/proc.h,表示内核眼中的一个进程。可以把它理解为:进程对应的一份内核记录
里面保存这个进程的各种信息,例如:
struct proc { enum procstate state; // 进程状态 int pid; // 进程号 struct proc *parent; // 父进程 pagetable_t pagetable; // 用户页表 struct trapframe *trapframe; // 用户寄存器 struct context context; // 内核调度所需的寄存器 struct file *ofile[NOFILE]; // 打开的文件 char name[16]; // 进程名称 };因此:
struct proc *p = myproc();意思是:取得当前正在运行进程的 struct proc
之后:
p->pid p->trapframe p->state就是读取当前进程的信息。
struct proc只是数据结构。对进程进行创建、调度、退出等操作的代码,主要在kernel/proc.c。
sysproc.c是干什么的?
kernel/sysproc.c放的是与进程有关的系统调用入口,例如:sys_fork() sys_wait() sys_getpid() sys_kill()这些
sys_xxx()函数负责接收系统调用,然后调用内核内部的实现。例如:
uint64 sys_fork(void) { return kfork(); }这里:
sys_fork():系统调用入口kfork():真正创建进程的内核函数
sysproc.c可以理解为系统调用层和进程管理代码之间的连接层。
fork()和kfork()有什么区别?用户程序调用的是:
fork();内核真正实现进程创建的是:
kfork();完整路径是:
用户程序 fork() ↓ user/usys.S 中的 fork 汇编入口 ↓ a7 = SYS_fork ecall ↓ usertrap() ↓ syscall() ↓ syscalls[SYS_fork] ↓ sys_fork() ↓ kfork()
fork()如何指向sys_fork()?用户态的
fork()汇编入口大致是:fork: li a7, SYS_fork ecall ret它没有直接调用
sys_fork(),而是:把系统调用编号 SYS_fork 放进 a7 然后执行 ecall 进入内核内核中的
syscall()读取这个编号:num = p->trapframe->a7;然后查询系统调用表:
static uint64 (*syscalls[])(void) = { [SYS_fork] sys_fork, [SYS_exit] sys_exit, [SYS_wait] sys_wait, };这一项:
[SYS_fork] sys_fork建立了
SYS_fork这个编号 →sys_fork()函数之后:
p->trapframe->a0 = syscalls[num]();对于
fork来说,相当于:p->trapframe->a0 = sys_fork();
kfork()是不是fork真正实现的地方?是。
kfork()位于:kernel/proc.c它主要完成:
- 分配一个新的 struct proc
- 复制父进程的用户内存
- 复制父进程的 trapframe
- 复制打开的文件
- 设置父子关系
- 将子进程设置为 RUNNABLE
简化后的结构是:
代码
int kfork(void) { struct proc *p = myproc(); struct proc *np; np = allocproc(); uvmcopy(p->pagetable, np->pagetable, p->sz); *(np->trapframe) = *(p->trapframe); np->trapframe->a0 = 0; np->parent = p; np->state = RUNNABLE; return np->pid; }因此最需要记住的是:
fork() → ecall → syscall() → sys_fork() → kfork()相关文件关系:
代码
user/user.h fork() 的 C 声明 user/usys.pl 生成 fork 的用户态汇编入口 kernel/syscall.h 定义 SYS_fork 编号 kernel/syscall.c SYS_fork → sys_fork kernel/sysproc.c sys_fork() 调用 kfork() kernel/proc.c kfork() 真正创建进程 kernel/proc.h 定义 struct proc
照着 hint 一步一步实现,然后有一步需要注意:
if (p->syscall_mask & (1 << num)) {
p->trapframe->a0 = -1;
} else {
p->trapframe->a0 = syscalls[num]();
}我开始写的是:
if (p->syscall_mask & (1 << num)) {
prinf(...) // 一段错误日志
}系统调用返回用户态时,用户程序会把 a0 当作返回值。此时 a0 原本保存的通常还是系统调用的第一个参数。如果只打印日志,不修改 a0,返回用户态后,open() 可能把 "README" 的地址当作返回值,而不是得到失败的 -1。
总结一下,跟着 hint 一步一步走,我们究竟实现了什么:
第一步
Add
$U/_sandboxto UPROGS in Makefile 将$U/_sandbox添加到 Makefile 中的 UPROGS 中
没什么好说的,这相当于告知哪些用户程序需要被编译,并放进 xv6 的文件系统。
第二步
引用
Run make qemu and you will see that the compiler cannot compile
user/sandbox.c, because the user-space stubs for theinterposesystem call don't exist yet: add a prototype forinterposetouser/user.h, a stub touser/usys.pl, and a syscall number tokernel/syscall.h. The Makefile invokes the perl scriptuser/usys.pl, which producesuser/usys.S, the actual system call stubs, which use the RISC-Vecallinstruction to transition to the kernel. Once you fix the compilation issues, run sandbox 32768 - cat README in the xv6 shell; it will fail because you haven't implemented the system call in the kernel yet. 运行 make qemu ,你会发现编译器无法编译user/sandbox.c,因为interpose系统调用的用户空间存根尚不存在:在user/user.h中添加interpose的原型,在user/usys.pl中添加存根,并在kernel/syscall.h中添加系统调用号。Makefile 调用 Perl 脚本user/usys.pl,它会生成user/usys.S,即实际的系统调用存根,这些存根使用 RISC-V 的ecall指令来转换到内核。一旦你修复了编译问题,在 xv6 shell 中运行 sandbox 32768 - cat README ;它会失败,因为你尚未在内核中实现该系统调用。
在 user/user.h 里提供一个用户的接口,只specify函数原型签名即可:
int interpose(int, const char *);在 kernel/syscall.h 里面添加系统调用号:
# kernel/syscall.h
#define SYS_interpose 22然后,修改 user/usys.pl:
这里的 pl 后缀是 Perl 脚本,类似于这样:
# user/usys.pl
...省略...
entry("getpid");
entry("sbrk");
entry("pause");
entry("uptime");
entry("interpose");这究竟有什么用呢?make 会生成如这样的 stub:
.global interpose
interpose:
li a7, SYS_interpose
ecall
retstub 在这里可以理解成:一小段“中转代码”或“占位入口”,自己不完成真正功能,只负责把调用转交到别处。
在 xv6 的系统调用里,fork、sbrk、open 这些用户态函数就是 syscall stub。
例如 sbrk 的 stub 大致是:
sbrk:
li a7, SYS_sbrk
ecall
ret它做的事情很少:
把系统调用编号 SYS_sbrk 放进 a7
执行 ecall 进入内核
内核处理完后返回它自己并不负责分配内存。真正的工作由内核中的:
sys_sbrk()以及更底层的:
growproc()
uvmalloc()
kalloc()完成。
所以这条路径可以写成:
用户程序调用 sbrk()
↓
用户态 syscall stub
↓
ecall
↓
内核 syscall()
↓
sys_sbrk()为什么叫 stub,而不直接叫“实现”?
因为它非常薄,只是一个接口外壳:
stub:负责转交请求
implementation:负责真正完成工作在 xv6 中:
user/user.h 中的声明
告诉 C 编译器函数怎么调用
user/usys.pl 生成的 stub
把调用转换成 ecall
kernel/sys_xxx()
真正接收和处理系统调用因此文档中的 user-space stubs,就是位于用户态、负责将普通函数调用转换成系统调用的一小段汇编入口。
第三步
Add a
sys_interpose()function inkernel/sysproc.cthat implements the new system call by recording the mask argument in a new field in theprocstructure (seekernel/proc.h). The functions to retrieve system call arguments from user space are inkernel/syscall.c, and you can see examples of their use inkernel/sysproc.c. Add your newsys_interposeto thesyscallsarray inkernel/syscall.c. 在kernel/sysproc.c中添加一个sys_interpose()函数,通过将掩码参数记录在proc结构(见kernel/proc.h)的新字段中,来实现新的系统调用。从用户空间检索系统调用参数的函数位于kernel/syscall.c中,你可以在kernel/sysproc.c中看到它们的使用示例。将新的sys_interpose添加到kernel/syscall.c的syscalls数组中。
修改 struct proc:
容易见到这个限制的掩码是一个每进程变量,因此要放在 struct proc 里面。
关于这个结构体是什么,回去看 read xv6 book 部分的「xv6 中的进程」。
于是,进入 kernel/proc.h,加入一个 int syscall_mask 字段。
修改 kernel/syscall.c:
首先在其他系统调用的声明附近加上
extern uint64 sys_interpose(void);然后找到系统调用表,把 sys_interpose 加入:
// An array mapping syscall numbers from syscall.h
// to the function that handles the system call.
static uint64 (*syscalls[])(void) = {
// ... 省略
[SYS_close] sys_close,
[SYS_interpose] sys_interpose,
};之后写 sys_call() 函数的时候,会理解这一点。
然后,实现 sys_interpose:
在 kernel/sysproc.c 里面:
关于这个文件是什么,参考 xv6 book:
uint64
sys_interpose(void)
{
int mask; // // 在内核栈中准备一个变量
argint(0, &mask); // // 取得用户传入的第 0 个参数,存入 mask
myproc()->syscall_mask = mask; // 把掩码保存到当前进程
return 0;
}可能会疑惑,这看着根本没实现任何东西啊?我的理解是,这里只是起到了一个从用户态传参的作用。
第四步
Modify
kfork()(seekernel/proc.c) to copy the mask from the parent to the child process. 修改kfork()(见kernel/proc.c),将掩码从父进程复制到子进程。
因为子进程的掩码要继承父进程的。
也很简单,在 kfork() 函数里面加入一个这个语句:
np->syscall_mask = p->syscall_mask;即可。
第五步
Modify the
syscall()function inkernel/syscall.cto check if the system call must be rejected. 修改kernel/syscall.c中的syscall()函数,以检查系统调用是否必须被拒绝。
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
if(p->syscall_mask & (1 << num)) {
p->trapframe->a0 = -1;
} else {
p->trapframe->a0 = syscalls[num]();
}
} else {
printk("%d %s: unknown sys call %d\n",
p->pid, p->name, num);
p->trapframe->a0 = -1;
}这里才是真正实现 interpose 逻辑的地方。
其实我觉得这样的耦合,怪怪的。因为实现某个机制,居然要把它实现在一个调用 syscall 的通用函数里面耦合起来,感觉不太好,不过也没想到什么更好的实现机制了。
从头到尾捋一遍思路
用户态调用 interpose:
- 用户调用我们实现的系统调用接口
int interpose(int, const char*) - 这个接口的实现由 perl 脚本生成的汇编语言实现,把
SYS_interpose的代号放入a7寄存器,由 ecall 硬件支持用户态转入内核态,用户态的函数栈帧存入myproc()->trapframe(xv6 book 4.2 中详细讲解了) - 控制转入 xv6 提供的通用接口
void syscall(void),通过int num; num = p->trapframe->a7;来获得需要调用的 syscall,然后从uint64 (*syscalls[])(void)这个函数指针的数组跳转到具体的函数调用,返回值放入a0寄存器 - 从 ecall 中退出,ret 使得用户侧返回。
Sandbox with allowed pathnames
添加一个对于 open 和 exec 检查路径的功能。
阅读 hint,有这两步:
Some hints:
- Modify
sys_interpose()to remember the allowed pathname.argstrwill be handy to retrieve the pathname. You can declare a buffer of sizeMAXPATHin theprocstruct.- If
openorexecare masked, check if the pathname matches the allowed pathname. If so, allow the execution of those system calls.
第一个很简单,照着做就行了。因为和上一题里面做的东西基本一样。
第二个,我遇到了一些困难:
我问语言模型:
引用
我在完成这个 Sandbox with allowed pathnames,
我卡在了这个,如何判断 open 和 exec 的路径是否符合这一步。我阅读源码,读不出来,比如我尝试阅读 exec 的实现:
代码
uint64 sys_exec(void) { char path[MAXPATH], *argv[MAXARG]; int i; uint64 uargv, uarg; argaddr(1, &uargv); if(argstr(0, path, MAXPATH) < 0) { return -1; } memset(argv, 0, sizeof(argv)); for(i=0;; i++){ if(i >= NELEM(argv)){ goto bad; } if(fetchaddr(uargv+sizeof(uint64)*i, (uint64*)&uarg) < 0){ goto bad; } if(uarg == 0){ argv[i] = 0; break; } argv[i] = kalloc(); if(argv[i] == 0) goto bad; if(fetchstr(uarg, argv[i], PGSIZE) < 0) goto bad; } int ret = kexec(path, argv); for(i = 0; i < NELEM(argv) && argv[i] != 0; i++) kfree(argv[i]); return ret; bad: for(i = 0; i < NELEM(argv) && argv[i] != 0; i++) kfree(argv[i]); return -1; }然后我发现,我找不到类似于实现 interpose 的时候那样的,具体的如何获得参数的部分。具体而言,当我阅读到这一行的时候:
if((n = argstr(0, path, MAXPATH)) < 0)我不是很清楚这个0是什么,我觉得是 stdin?我尝试一步一步读代码,但是好像有点太多了,读不过来。总之,我希望你能简明扼要给我串起来,回答不必过长
得到的答案,总结一下:
-
这里的
0不是 stdin,而是:读取系统调用的第 0 个参数,也就是第一个参数。 -
a0, a1, ... 分别对应系统调用的第0,第1...个参数。
-
而这两个系统调用的函数形式(看源码就可以知道了,
user.h,都是:open(path, mode); exec(path, argv); -
因此,在
syscall()里面,使用argstr(0, pathm MAXPATH)就可以获得参数了。

Attack xv6
背景是,uvmalloc 和 kalloc 里面,省略了 memset 的几个语句,导致新分配的内存保留了其先前使用的内容。
然后运行这么一个程序:
代码
#define DATASIZE (8*4096)
char data[DATASIZE];
int
main(int argc, char *argv[])
{
if(argc != 2){
printf("Usage: secret the-secret\n");
exit(1);
}
strcpy(data, "This may help.");
strcpy(data + 16, argv[1]);
exit(0);
}编写 attack.c 以得到 secret.c 的这个 secret
具体做法需要用到一个系统调用 sbrk:向内核申请扩大当前进程的用户内存,返回值是指向新增加的这段内存的起始位置。
在 xv6 中通常声明为:
char *sbrk(int n);那么解决方案就很简单了:
代码
int
main(int argc, char *argv[])
{
// Your code here.
char *p = sbrk(8*4096);
if (p == (char*)-1) {
exit(1);
}
for (int i = 0; i < 8*4096 - 16; i++) {
if (p[i] =='T') {
char tmp[15];
memcpy(tmp, p + i, 15);
if (!strcmp(tmp, "This may help.")) {
for (char *c = p + i + 16; *c; c++) {
printf("%c", *c);
}
printf("\n");
}
}
}
exit(1);
}总结和碎碎念

xv6 book 找不到比较新版的汉化,所以基本是直接丢给 LLM 让他总结一下我随便看看就上手写 lab 了。结果发现,写 lab 的时候遇到的基本上所有问题,在书里面都有解答,但是我开始写的时候就是一头雾水,有点难绷。
但是仔细想想,感觉我就算仔细读,也读不进去,反倒是我现在从开始的一头雾水,到一边写一边弄懂,可能这样对我而言效果更好一点。或许,现在可以去重新读一下这一部分的书?不过我现在有点懒,所以作罢吧。