xv6 专题总结与实践项目 / xv6 Summary And Practice Projects
1. 这篇文章解决什么问题
前面的文章已经走过 xv6 的主要路径。
我们从 C 和指针开始。
再进入 RISC-V 基础。
再看启动流程。
再看内存布局和页表。
再看 trap、中断和系统调用。
再看进程结构。
再看调度。
再看 sleep/wakeup、wait/exit。
再看锁。
再看文件描述符、inode、日志、设备、用户程序和 shell。
最后用 GDB 验证模型。
这一篇做两件事。
第一,总结整个专题的核心模型。
第二,给出实践项目。
实践项目不是刷题。
它们是把源码从“读过”变成“能改”的桥。
本篇基于本地可读的 E:\Github\xv6-riscv。
如果你使用标准 MIT xv6-riscv,也可以直接参考。
1.1 本文基础词小注释
实践项目 project:这里的项目不是“大工程”,而是一个能迫使你读懂、修改、验证某条内核路径的小任务。目标是把被动阅读变成主动掌握。
实验 experiment:实验要有明确问题、修改点、预期现象和观察方法。比如“给 fork 加日志”比“随便改 proc.c”更像实验。
假设 hypothesis:动手前先写下你认为会发生什么。比如“如果 timer interrupt 调用 yield(),那么运行中的进程会周期性让出 CPU”。之后用 GDB 或日志验证。
观察 observation:运行后看到的事实。它可能支持你的假设,也可能推翻你的假设。学习内核时,被推翻很正常,关键是回到源码解释原因。
question
|
v
hypothesis
|
v
small code change
|
v
run / debug
|
v
observation
|
v
revise model回归 regression:原来能工作的行为被你改坏了,就叫回归。做 xv6 实验时,即使只是学习,也要养成改完跑测试或跑基本命令的习惯。
心智模型 mental model:你脑中对系统如何工作的简化图。它不要求一开始完全精确,但必须能被源码、日志、GDB 和实验不断修正。
2. 总体心智模型
xv6 是一个小型 Unix-like 教学内核。
Unix-like 表示它采用了 Unix 风格的进程、文件、系统调用和 shell 模型。
它不是玩具到毫无价值。
它是把真实操作系统骨架压缩到可读范围。
可以把 xv6 看成四层:
+------------------------------------------------+
| user programs: init, sh, cat, echo, tests |
+------------------------------------------------+
| syscall boundary: usys, ecall, trap, syscall |
+------------------------------------------------+
| kernel services: proc, vm, fs, file, pipe |
+------------------------------------------------+
| hardware layer: RISC-V, timer, UART, virtio |
+------------------------------------------------+读 xv6 最重要的是跨层理解。
只读用户程序会看不到内核边界。
只读内核函数会看不到谁触发它们。
只读汇编会看不到设计目的。
只读概念会缺少证据。
3. 本篇涉及的 xv6 源码文件总览
kernel/entry.S 处理早期入口。
kernel/start.c 进入机器启动准备。
kernel/main.c 初始化内核子系统。
kernel/proc.h 定义进程、CPU、trapframe、context。
kernel/proc.c 实现进程生命周期和调度。
kernel/swtch.S 实现上下文切换。
kernel/trap.c 处理用户 trap、内核 trap、设备中断。
kernel/trampoline.S 保存恢复用户寄存器并切换页表。
kernel/syscall.c 分派系统调用。
kernel/sysproc.c 实现进程类系统调用。
kernel/sysfile.c 实现文件类系统调用。
kernel/vm.c 实现页表和地址空间操作。
kernel/kalloc.c 实现物理页分配。
kernel/bio.c 实现 buffer cache。
kernel/fs.c 实现 inode、目录、块映射。
kernel/file.c 实现 file 对象和 fd 相关逻辑。
kernel/pipe.c 实现管道。
kernel/log.c 实现文件系统日志。
kernel/virtio_disk.c 实现虚拟磁盘驱动。
kernel/console.c、kernel/uart.c 实现控制台输入输出。
user/init.c 启动 shell 并回收孤儿。
user/sh.c 实现命令解释器。
user/usys.pl 生成用户态系统调用汇编桩。
Makefile 串联编译、链接、镜像和 QEMU 启动。
4. 进程模型总结
进程是运行中程序的内核表示。
程序是磁盘上的文件。
进程是加载后正在被管理的执行实体。
xv6 用 struct proc 表示进程。
它保存状态。
它保存 pid。
它保存父进程。
它保存用户页表。
它保存 trapframe。
它保存内核上下文。
它保存打开文件。
它保存当前目录。
进程状态形成状态机:
UNUSED -> USED -> RUNNABLE -> RUNNING
^ |
| v
SLEEPING <- yield/sleep
|
v
ZOMBIE -> UNUSED理解进程要抓住三组关系。
第一,用户地址空间和内核数据结构的关系。
第二,进程状态和调度器的关系。
第三,父子关系和 wait/exit 的关系。
5. 系统调用模型总结
系统调用是用户态请求内核服务的接口。
接口表示双方都遵守的约定。
用户程序不能直接调用内核函数。
它通过用户态 stub 执行 ecall。
内核通过 trap 入口接管。
RISC-V 寄存器传递系统调用号和参数。
xv6 再用 syscalls[] 分派到具体实现。
总路径:
user code
|
v
usys stub
|
v
ecall
|
v
trampoline uservec
|
v
usertrap
|
v
syscall
|
v
sys_* wrapper
|
v
kernel service这个模型能解释新增 syscall 需要改哪些文件。
它也能解释为什么用户指针需要 copyin 和 copyout。
6. 内存模型总结
xv6 使用虚拟内存。
虚拟内存让每个进程看到自己的地址空间。
页表负责虚拟地址到物理地址的映射。
内核有内核页表。
每个用户进程有用户页表。
TRAMPOLINE 被映射在高地址。
TRAPFRAME 位于 trampoline 下方。
用户页表中既有用户代码数据,也有 trap 相关映射。
内存路径:
virtual address
|
v
page table walk
|
v
PTE permission check
|
v
physical addressPTE 是页表项。
它包含物理页号。
它包含有效位。
它包含读写执行权限。
它包含用户位。
页表实验最容易验证虚拟内存模型。
7. 调度模型总结
调度器决定哪个可运行进程占用 CPU。
xv6 的调度器很朴素。
它扫描进程表。
它寻找 RUNNABLE。
它切换到进程上下文。
进程通过 yield、sleep、exit 等路径离开 CPU。
上下文切换保存的是内核执行上下文。
它不是保存整个用户地址空间。
进程用户寄存器在 trapframe。
内核调度上下文在 context。
区分这两个结构很重要。
trapframe
|
+-- user registers saved on trap
context
|
+-- kernel registers saved by swtch调度实验可以帮助你看懂状态变化。
8. sleep/wakeup 模型总结
sleep/wakeup 是等待事件的机制。
sleep 把当前进程变成 SLEEPING。
wakeup 把等待某个通道的进程变成 RUNNABLE。
chan 是等待通道。
它通常是某个内核对象地址。
chan 只是比较用的标签。
不是数据队列。
sleep 必须和锁配合。
否则会出现 lost wakeup。
lost wakeup 表示唤醒发生在睡眠之前,导致等待者永远睡下去。
正确模型:
hold condition lock
|
v
check condition
|
v
atomically prepare sleep and release lock
|
v
wakeup changes SLEEPING to RUNNABLE这个模型在 pipe、wait、console、ticks 中都会出现。
9. 文件系统模型总结
xv6 把一切尽量统一到文件接口。
文件描述符是进程看到的小整数。
file 是内核中的打开文件对象。
inode 是文件系统中的文件元数据。
buffer 是磁盘块在内存中的缓存。
磁盘驱动负责实际 I/O。
层次如下:
fd
|
v
struct file
|
v
struct inode / pipe / device
|
v
block mapping
|
v
buffer cache
|
v
virtio disk常见误区是把 fd、file、inode 混为一谈。
实践时要刻意区分。
dup 复制 fd 到同一 file。
open 创建或引用 file。
link 操作 inode 的目录引用。
unlink 可能减少链接数但不立刻释放打开文件。
10. shell 模型总结
shell 是普通用户程序。
它不是内核。
它通过系统调用组合出命令语义。
普通命令使用 fork、exec、wait。
重定向使用 close、open、dup。
管道使用 pipe、fork、close、dup、wait。
后台命令使用 fork 但不等待。
cd 必须在父 shell 中执行。
因为工作目录是进程属性。
shell 的核心图:
parse command line
|
v
build command tree
|
v
fork child environment
|
v
adjust fd if needed
|
v
exec target program掌握 shell 能反过来理解系统调用为什么长这样。
11. 调试模型总结
调试不是碰运气。
调试是验证模型。
先写出假设。
再设置断点。
再观察结构体和寄存器。
再判断是否符合预期。
GDB 最值得看的对象:
myproc()。
*myproc()。
myproc()->trapframe。
myproc()->pagetable。
r_sepc()。
r_scause()。
r_stval()。
syscalls[]。
proc[]。
把 GDB 当作证明工具,而不是猜谜工具。
12. 项目一:新增 syscall
项目目标:新增一个简单系统调用。
建议名称:tracepid 或 hello。
功能可以很小。
例如返回当前 pid 的两倍。
或者打印当前进程名。
重点不是功能。
重点是走完整条系统调用链。
涉及文件:
kernel/syscall.h 添加系统调用号。
kernel/syscall.c 添加 extern 声明和分派表项。
kernel/sysproc.c 或新文件中实现 sys_*。
user/user.h 添加用户态声明。
user/usys.pl 添加 entry。
user/ 下添加测试程序。
Makefile 添加用户程序到 UPROGS。
验收标准:
用户程序能调用新 syscall。
返回值正确。
未知 syscall 不受影响。
构建生成新的 usys.S。
能用 GDB 在 syscall() 看到新系统调用号。
13. 项目一的实验步骤
第一步,选一个未使用的系统调用号。
第二步,在 kernel/syscall.h 定义 SYS_hello。
第三步,在 user/usys.pl 添加 entry("hello")。
第四步,在 user/user.h 声明 int hello(void);。
第五步,在 kernel/sysproc.c 实现 sys_hello。
第六步,在 kernel/syscall.c 注册 sys_hello。
第七步,写 user/hellotest.c。
第八步,在 Makefile 添加 _hellotest。
第九步,运行 xv6。
第十步,在 shell 中执行 hellotest。
建议观察:
系统调用号是否进入 a7。
返回值是否写到 a0。
用户程序是否能看到返回值。
失败时优先检查 usys.pl 和分派表。
14. 项目二:打印 proc 生命周期
项目目标:观察进程从创建到回收的状态变化。
涉及文件:
kernel/proc.c。
kernel/proc.h。
可选涉及 kernel/sysproc.c。
观察点:
allocproc。
userinit。
kfork。
scheduler。
sleep。
wakeup。
kexit。
kwait。
freeproc。
建议输出格式:
proc pid=3 name=sh event=fork state=RUNNABLE parent=2输出要克制。
不要在极高频路径打印太多。
否则 QEMU 输出会淹没真正信息。
验收标准:
运行 echo hi 能看见 fork、exec、exit、wait。
运行管道能看见两个子进程。
运行后台命令能看见父 shell 不 wait。
15. 项目二的分析问题
问题一:第一个用户进程是谁创建的?
问题二:shell 是谁 fork 出来的?
问题三:普通命令的父进程是谁?
问题四:exec 后 pid 是否改变?
问题五:exit 后为何不是立刻 UNUSED?
问题六:wait 在哪个进程里执行?
问题七:孤儿进程最后被谁回收?
问题八:状态变更是否都持有必要锁?
这些问题比打印本身更重要。
打印只是证据来源。
16. 项目三:页表实验
项目目标:打印并解释用户进程页表。
涉及文件:
kernel/vm.c。
kernel/defs.h。
kernel/exec.c。
kernel/proc.c。
kernel/riscv.h。
建议实现一个 vmprint。
输出页表层级。
输出 PTE 地址。
输出权限位。
输出物理地址。
关注区域:
用户代码和数据。
用户栈。
guard page。
trapframe。
trampoline。
验收标准:
执行某个用户程序时能打印其页表。
能解释每个高地址映射的作用。
能说明哪些 PTE 有 PTE_U。
能说明为什么 trampoline 需要固定映射。
17. 项目三的扩展:页错误
扩展目标:观察非法访问如何杀死进程。
写一个用户程序访问非法地址。
断在 usertrap。
观察 scause。
观察 stval。
观察进程被设置 killed。
再观察退出路径。
专业名词:
page fault 是页错误。
它表示地址访问无法被当前页表和权限满足。
缺页不一定是错误。
如果系统实现懒分配,缺页可能触发分配。
非法访问则会终止进程。
验收标准:
能区分合法懒分配缺页和非法访问。
能说清 stval 中地址的意义。
18. 项目四:调度实验
项目目标:修改调度策略并观察结果。
起步实验:统计每个进程被调度次数。
涉及文件:
kernel/proc.h 添加计数字段。
kernel/proc.c 在调度点更新计数。
kernel/sysproc.c 可添加查询 syscall。
user/ 添加测试程序。
可选策略:
保持原始轮询,只打印统计。
增加简单优先级字段。
实现简化时间片。
实现 lottery scheduling。
lottery scheduling 是彩票调度。
它用随机抽签按票数选择进程。
验收标准:
多个 CPU 密集进程同时运行时,统计结果可解释。
修改策略后结果发生符合预期的变化。
sleeping 进程不会被错误调度。
ZOMBIE 进程不会被调度。
19. 项目四的注意点
不要在调度器里随便 sleep。
不要破坏 p->lock 的持锁约定。
不要让一个进程在两个 CPU 上运行。
不要只看一次输出就下结论。
调度结果有时受时钟中断和运行时序影响。
建议先用 CPUS=1 实验。
再用多 CPU 对比。
如果系统卡死,优先检查锁。
如果输出极多,优先减少打印频率。
如果进程永远不退出,检查 wait/exit 是否被破坏。
20. 项目五:文件系统小实验
项目目标:理解 fd、file、inode、buffer 的关系。
实验 A:观察 open 和 close。
实验 B:观察 dup 后共享 offset。
实验 C:观察 unlink 已打开文件。
实验 D:观察小文件写入涉及的磁盘块。
实验 E:观察目录项创建。
涉及文件:
kernel/sysfile.c。
kernel/file.c。
kernel/fs.c。
kernel/bio.c。
kernel/log.c。
user/ 测试程序。
验收标准:
能解释 fd 指向 file。
能解释 file 指向 inode。
能解释 inode 如何映射到数据块。
能解释 bread/bwrite 的作用。
能解释日志为什么包住文件系统修改。
21. 项目五:dup 共享 offset
写用户程序:
打开一个文件。
写入一段内容。
调用 dup(fd)。
通过原 fd 读一部分。
通过 dup fd 再读一部分。
观察 offset 是否共享。
原因:
dup 复制文件描述符表项。
两个 fd 指向同一个 struct file。
offset 在 struct file 中。
所以 offset 被共享。
图:
fd 3 ----+
|
v
struct file ----> inode
^
|
fd 4 ----+这个实验能直接区分 fd 和 file。
22. 项目五:unlink 已打开文件
写用户程序:
创建文件。
打开文件。
unlink 文件名。
继续通过 fd 读写。
关闭 fd。
观察文件何时真正不可达。
专业名词:
link count 是链接计数。
它表示有多少目录项指向 inode。
reference count 是引用计数。
它表示内核中还有多少对象正在引用它。
unlink 减少链接计数。
close 减少打开文件引用。
文件释放通常需要两类计数都归零或满足释放条件。
23. 项目六:shell 小改造
项目目标:在 user/sh.c 上做小功能。
可选功能一:新增 pwd 内建命令。
可选功能二:改进错误提示。
可选功能三:记录上一条命令退出状态。
可选功能四:支持简单环境变量样式替换。
可选功能五:支持命令别名。
建议从 pwd 或退出状态开始。
不要一上来实现完整 Bash。
涉及文件:
user/sh.c。
可能需要 user/user.h。
可能需要新增 syscall。
可能需要新增用户测试程序。
验收标准:
普通命令仍可执行。
重定向仍可用。
管道仍可用。
cd 仍在父 shell 中执行。
错误路径不会让 shell 退出。
24. shell 改造的关键提醒
shell 改造容易误伤 fork/exec/wait 模型。
添加内建命令前先判断:
它是否必须改变父 shell 状态?
如果是,就在父 shell 执行。
如果不是,可以作为普通用户程序。
例如 cd 必须内建。
例如 exit 通常需要内建。
例如 echo 不需要内建。
例如 pwd 可以内建,也可以做用户程序。
如果命令涉及重定向,要考虑是否应在子进程中调整 fd。
不要让父 shell 的标准输入输出被永久改掉。
25. 项目七:系统调用追踪器
项目目标:实现简化 syscall trace。
功能:让进程开启追踪后,内核打印它调用的 syscall。
涉及文件:
kernel/proc.h 添加 trace mask。
kernel/sysproc.c 添加 trace syscall。
kernel/syscall.c 在分派后打印。
user/usys.pl 添加入口。
user/user.h 添加声明。
user/trace.c 添加用户命令。
验收标准:
trace 32 echo hi 能只打印匹配系统调用。
子进程是否继承 trace mask 要有明确设计。
输出包含 pid、syscall 名、返回值。
不会破坏正常返回值。
这个项目很适合综合系统调用、proc、fork、exec。
26. 项目八:进程树命令
项目目标:实现一个类似简化 pstree 的功能。
方案一:内核新增 syscall 导出进程信息。
方案二:在内核中提供调试打印。
方案三:只实现 procdump 扩展。
推荐方案一。
它能练习 copyout。
copyout 是从内核复制数据到用户地址空间。
要设计一个用户可见结构体。
结构体应包含 pid、ppid、state、name。
不要把内核指针暴露给用户。
验收标准:
用户程序能打印进程列表。
不会泄漏内核地址。
不会因为进程表变化导致崩溃。
能解释需要哪些锁。
27. 项目九:buffer cache 统计
项目目标:观察缓存命中率。
在 bio.c 中统计:
查找次数。
命中次数。
未命中次数。
回收次数。
写回次数。
提供 syscall 或控制台打印。
运行:
ls。
cat README。
重复运行同一命令。
观察第二次是否更多命中。
专业名词:
cache hit 是缓存命中。
cache miss 是缓存未命中。
命中表示请求的数据已在缓存中。
未命中表示需要从下层设备读取。
验收标准:
统计数随文件操作变化。
重复读文件命中率提升。
不会破坏 buffer 引用计数。
28. 项目十:日志边界观察
项目目标:理解文件系统日志。
在 begin_op 和 end_op 打印。
在 log_write 打印块号。
运行创建文件、写文件、unlink。
观察一次系统调用涉及哪些块。
日志是保证文件系统一致性的机制。
一致性表示崩溃后磁盘结构不会停在半更新状态。
xv6 日志是简化事务日志。
事务表示一组更新要么整体可见,要么整体不生效。
验收标准:
能说清哪些系统调用需要事务。
能解释为什么普通读不一定需要日志事务。
能说明 begin_op 可能等待的原因。
29. 实践项目的工作方法
每个项目都按同一节奏做。
先写目标。
再列涉及文件。
再画路径图。
再决定最小改动。
再写测试程序。
再用 GDB 验证。
再记录观察结果。
模板:
Hypothesis:
what I think happens
Change:
smallest source modification
Observation:
print/GDB/test output
Conclusion:
model confirmed or corrected这种方法比“改到能跑”更能积累能力。
30. 推荐项目顺序
第一,新增 syscall。
第二,系统调用追踪器。
第三,打印 proc 生命周期。
第四,页表打印。
第五,shell 小改造。
第六,文件系统 fd/file/inode 实验。
第七,buffer cache 统计。
第八,调度实验。
第九,日志边界观察。
这个顺序从边界清晰到内部复杂。
先做 syscall 是因为反馈最快。
再做 proc 是因为能理解运行时。
再做页表是因为需要更多结构理解。
最后做调度和文件系统是因为更容易引入隐蔽错误。
31. 常见误解总表
误解一:能读懂代码就等于会操作系统。
不够。
还要能预测运行行为。
误解二:能改代码编译通过就等于正确。
不够。
还要用测试和 GDB 验证。
误解三:系统调用只是加一个函数。
不是。
它跨越用户头文件、stub、号表、分派表、内核实现、测试程序。
误解四:页表只是地址转换表。
不只是。
它还表达权限和隔离。
误解五:调度器只要选一个进程就行。
不够。
它必须维护状态不变量。
误解六:文件系统只是读写磁盘。
不够。
它还要处理命名、缓存、一致性、引用计数。
32. 最小验证清单
做 syscall 项目时,验证用户态能调用。
做 syscall 项目时,验证未知调用仍报错。
做 proc 项目时,验证 pid 和父子关系。
做 proc 项目时,验证 ZOMBIE 到 UNUSED。
做页表项目时,验证 trampoline 和 trapframe 映射。
做页表项目时,验证权限位解释。
做调度项目时,验证只有 RUNNABLE 被选中。
做调度项目时,验证多进程统计符合预期。
做文件系统项目时,验证 fd、file、inode 分层。
做文件系统项目时,验证崩溃敏感路径有日志保护。
做 shell 项目时,验证父 shell 不被错误替换。
做 shell 项目时,验证管道 EOF 不被破坏。
33. 如何写学习笔记
每次实验后写三段。
第一段写现象。
第二段写原因。
第三段写源码位置。
例如:
Phenomenon:
exec does not change pid.
Reason:
exec replaces user address space of current proc.
Source:
kernel/exec.c updates trapframe and pagetable.笔记不要只贴代码。
代码会忘。
路径和不变量更耐久。
34. 继续学习路线
如果想继续操作系统,下一步可以读 Linux 0.11。
读 Linux 0.11 时用 xv6 做参照。
如果想继续现代内核,可以读现代 Linux 的 VFS、调度、内存管理专题。
但不要急着跳太远。
现代 Linux 抽象很多。
没有 xv6 和 Linux 0.11 的桥,容易只剩函数名。
如果想偏实践,可以做 MIT 6.S081 labs。
这些实验覆盖 syscall、页表、copy-on-write、线程、文件系统等主题。
copy-on-write 是写时复制。
它是 fork 性能优化的核心技术。
如果想偏性能,可以分析锁竞争和 buffer cache。
如果想偏调试,可以系统练习 GDB 和 QEMU。
35. 本篇总结
xv6 专题最终要留下的不是零散函数名。
而是一套能迁移的操作系统问题框架。
进程回答“谁在运行”。
页表回答“它能访问哪里”。
trap 回答“如何进入内核”。
系统调用回答“用户如何请求服务”。
调度回答“谁获得 CPU”。
sleep/wakeup 回答“如何等待事件”。
锁回答“如何保护共享不变量”。
文件系统回答“名字如何变成数据块”。
shell 回答“用户如何组合内核机制”。
最后用一句话收束:
xv6 is small enough to hold in your head,
but real enough to teach the shape of an operating system.接下来最好的学习方式,是选一个项目动手。
不要只读下一篇。
去改一条路径。
去打一个断点。
去验证一个模型。
这样 xv6 才会从书上的系统,变成你真正理解过的系统。