如何阅读 xv6 源码 / How To Read xv6 Source
1. 为什么需要专门讲“怎么读”
xv6 的源码不大。
但“不大”不等于“容易读”。
操作系统源码和普通应用源码最大的区别是:控制流经常不是普通函数调用。
你会遇到:
- 启动阶段从汇编进入 C。
- 用户态通过
ecall进入内核。 - 中断在任意时刻打断执行。
- 调度器通过
swtch.S切换上下文。 - 页表决定同一个地址在不同上下文的含义。
- 文件系统通过多层对象间接访问磁盘块。
如果你按普通 C 项目方式从一个 main() 一路点进去,很快就会断。
所以读 xv6 要有方法。
这篇文章的目标是建立一套稳定阅读流程。
read repository map
|
v
run xv6
|
v
find one execution path
|
v
identify involved files
|
v
draw structures and pointers
|
v
read key functions
|
v
verify by experiment这套流程会贯穿整个专题。
每次学一个机制,都按照这个方法走。
1.1 本文基础词小注释
这篇讲“怎么读源码”,会频繁提到这些词。
目录
|
+-- folder
+-- 用来组织源码文件
+-- kernel、user、mkfs 是 xv6 的关键目录
Makefile
|
+-- 构建脚本
+-- 告诉 make 如何编译内核、用户程序和文件系统镜像
QEMU
|
+-- 虚拟机/模拟器
+-- xv6-riscv 通常跑在 QEMU 模拟的 RISC-V 机器里
GDB
|
+-- 调试器
+-- 可以打断点、单步执行、看变量和寄存器
调用路径
|
+-- 一次操作经过哪些函数
+-- 例如 getpid 经过 user wrapper、trap、syscall、sys_getpid读源码时不要只问“这一行是什么意思”。
先问:
我现在在哪个目录?
这个文件属于内核还是用户程序?
这段代码位于哪条执行路径上?2. 项目目录先看成三层
标准 xv6-riscv 大致可以分成三层。
xv6-riscv/
|
+-- kernel/
| |
| +-- operating system kernel
| +-- runs in supervisor mode
| +-- manages processes, memory, files, devices
|
+-- user/
| |
| +-- user programs
| +-- runs in user mode
| +-- calls kernel through syscalls
|
+-- mkfs/
|
+-- host-side filesystem image builder
+-- runs on your development machine再加上几个顶层文件:
Makefile
|
+-- build kernel
+-- build user programs
+-- build filesystem image
+-- run QEMU
+-- support GDB
README / LICENSE
|
+-- project notes and license先不要急着读每个文件。
先把这三层分清楚。
kernel/ 是内核。
user/ 是运行在 xv6 里的用户程序。
mkfs/ 是在宿主机上运行的工具,用来制作 xv6 的文件系统镜像。
这三个层次的执行环境不同。
host machine
|
+-- compiler
+-- make
+-- mkfs tool
+-- qemu
|
v
xv6 virtual machine
|
+-- kernel code
+-- user programs初学者常把 mkfs 和 xv6 内核混在一起。
mkfs 不是 xv6 内核的一部分。
它是在你的开发环境里运行,用来生成 fs.img。
3. 内核源码应该按机制分组
kernel/ 目录里文件很多。
按文件名顺序读通常不合适。
应该按机制分组。
+---------------------+----------------------------------------------+
| Group | Files |
+---------------------+----------------------------------------------+
| Boot | entry.S, start.c, main.c, kernel.ld |
| Hardware interface | riscv.h, memlayout.h |
| Process | proc.h, proc.c, swtch.S |
| Trap and syscall | trampoline.S, kernelvec.S, trap.c |
| Syscall dispatch | syscall.h, syscall.c, sysproc.c, sysfile.c |
| Memory | kalloc.c, vm.c, exec.c, elf.h |
| File abstraction | file.h, file.c, fcntl.h, stat.h |
| Filesystem | fs.h, fs.c, log.c, bio.c, buf.h |
| Pipe | pipe.c |
| Devices | console.c, uart.c, plic.c, virtio_disk.c |
| Locks | spinlock.h, spinlock.c, sleeplock.h |
| Utilities | defs.h, types.h, param.h, printf.c, string.c |
+---------------------+----------------------------------------------+这张表不是为了背。
它的作用是帮助你定位。
例如你看到 read() 系统调用。
你就知道它可能跨越:
sysfile.c
|
v
file.c
|
v
fs.c or pipe.c
|
v
bio.c
|
v
virtio_disk.c看到 fork()。
你就知道它可能跨越:
sysproc.c
|
v
proc.c
|
+-- vm.c
+-- file.c
+-- fs.c读源码时,先问“这属于哪组机制”。
再问“主文件在哪里”。
4. 用户程序和内核代码的边界
xv6 里有用户程序。
例如:
user/init.c
user/sh.c
user/cat.c
user/echo.c
user/grep.c这些程序运行在用户态。
它们不能直接访问内核内部函数。
用户程序需要通过系统调用请求内核服务。
边界图:
user program
|
| calls read(), write(), fork(), exec()
v
user syscall wrapper
|
| ecall
v
kernel trap path
|
v
syscall dispatch
|
v
kernel implementation用户态头文件:
user/user.h
|
+-- user-visible function declarations
+-- includes syscall wrappers and libc-like helpers内核态头文件:
kernel/defs.h
|
+-- kernel internal function declarations不要把这两个混起来。
用户程序包含的是用户态接口。
内核文件包含的是内核内部接口。
系统调用正是它们之间的桥。
5. Makefile 要读什么
初学者容易跳过 Makefile。
但 xv6 的 Makefile 很重要。
它告诉你:
- 哪些文件会编译进内核。
- 哪些文件是用户程序。
- 用户系统调用汇编如何生成。
- 文件系统镜像如何生成。
- QEMU 如何启动。
- GDB 如何连接。
读 Makefile 不需要精通 Make。
先找这些信息。
kernel object list
|
+-- which kernel .c/.S files are compiled
user program list
|
+-- which user programs are built
usys generation
|
+-- user/usys.pl -> user/usys.S
fs image rule
|
+-- mkfs creates fs.img
qemu rule
|
+-- runs kernel and disk image
gdb rule
|
+-- starts QEMU with debug options构建关系可以画成:
kernel/*.c + kernel/*.S
|
v
kernel object files
|
v
kernel/kernel
user/*.c + user/usys.S
|
v
user programs
|
v
mkfs
|
v
fs.img
kernel/kernel + fs.img
|
v
QEMU runs xv6这张图能帮助你理解:
内核镜像和文件系统镜像是两个东西。
用户程序不是直接“放进内核”。
它们被编译后放入文件系统镜像。
xv6 启动后,从文件系统中执行用户程序。
6. 第一次运行 xv6 时看什么
第一次运行 xv6,不要只看到 shell 出来就结束。
你应该观察三件事。
第一,启动输出。
它说明内核已经初始化并进入用户空间。
第二,shell。
它说明第一个用户进程已经启动,并且可以执行用户程序。
第三,简单命令。
例如:
ls
echo hello
cat README这些命令会走文件系统和系统调用。
运行结构:
QEMU
|
v
xv6 kernel boots
|
v
init process starts
|
v
shell starts
|
v
user commands call syscalls第一次运行的学习目标不是马上调试。
而是确认:
- 内核能启动。
- 用户进程能运行。
- 系统调用能工作。
- 文件系统镜像中有用户程序。
这是一切源码阅读的基础。
7. 如何选择第一条阅读路径
读源码最怕没有路径。
建议第一条路径选 getpid()。
因为它最短。
user program calls getpid
|
v
user syscall wrapper
|
v
ecall
|
v
trap
|
v
syscall dispatch
|
v
sys_getpid
|
v
myproc
|
v
return pid涉及文件:
user/user.h
user/usys.pl
kernel/syscall.h
kernel/syscall.c
kernel/sysproc.c
kernel/trap.c
kernel/trampoline.S
kernel/proc.c
kernel/proc.h为什么选 getpid()?
因为它不涉及复杂文件系统,不涉及大量数据复制。
它只需要找到当前进程并返回 pid。
但它完整经过系统调用边界。
第二条路径可以选 fork()。
它开始接触进程复制。
第三条路径可以选 read() 或 write()。
它开始接触 fd、file、inode、pipe、console。
推荐顺序:
getpid
|
v
fork
|
v
exit/wait
|
v
write to console
|
v
read from file这比一开始读 scheduler() 更友好。
8. 如何读一个结构体
读 xv6 不能只追函数。
结构体更重要。
每个核心机制都有核心结构体。
process
|
+-- struct proc
+-- struct cpu
+-- struct context
+-- struct trapframe
file
|
+-- struct file
+-- struct inode
+-- struct pipe
filesystem
|
+-- struct superblock
+-- struct dinode
+-- struct dirent
+-- struct buf
lock
|
+-- struct spinlock
+-- struct sleeplock读结构体用固定模板。
structure name
|
+-- what object does it represent?
|
+-- where is it allocated?
|
+-- who owns it?
|
+-- what fields are pointers?
|
+-- what fields are state?
|
+-- what lock protects it?
|
+-- what functions create/change/destroy it?例如 struct proc:
represents: process
allocated in: proc[NPROC]
owned by: process subsystem
protected by: p->lock
state field: state
memory fields: pagetable, trapframe, kstack, sz
resource fields: ofile[], cwd
lifecycle functions: allocproc, fork, exit, wait, freeproc如果一开始就逐行看 proc.c,很容易迷路。
先把 struct proc 读成对象地图,会顺很多。
9. 如何读一个函数
读函数也要有固定模板。
不要只看每一行做什么。
先看这个函数在机制里扮演什么角色。
function
|
+-- who calls it?
|
+-- when is it called?
|
+-- what object does it operate on?
|
+-- what locks must be held?
|
+-- what state does it change?
|
+-- what errors can happen?
|
+-- what does it return?以 fork() 为例。
fork()
|
+-- called by sys_fork()
|
+-- allocates a new proc
|
+-- copies user memory
|
+-- copies trapframe
|
+-- duplicates open files
|
+-- duplicates cwd
|
+-- sets child runnable
|
+-- returns child pid to parent这比直接从第一行读到最后一行更清楚。
再看 exit()。
exit()
|
+-- closes files
+-- releases cwd
+-- reparents children
+-- records exit status
+-- becomes ZOMBIE
+-- wakes parent
+-- calls sched函数阅读要结合状态图。
state before
|
v
function body
|
v
state after操作系统代码的重点常常不是计算结果。
而是状态变化。
10. 如何画调用路径
每学一个机制,都画调用路径。
调用路径不要一开始画得过细。
先画主干。
例如系统调用:
user wrapper
|
v
ecall
|
v
usertrap
|
v
syscall
|
v
sys_* implementation再补文件:
user/usys.S
|
v
kernel/trampoline.S
|
v
kernel/trap.c
|
v
kernel/syscall.c
|
v
kernel/sysproc.c or kernel/sysfile.c最后补结构体:
trapframe
|
+-- a7 syscall number
+-- a0 return value这就是三层图。
mechanism path
|
v
file path
|
v
data structure path如果只画函数名,容易忽略数据。
如果只画结构体,容易忽略控制流。
两者要结合。
11. 如何使用搜索
读源码离不开搜索。
推荐用 rg。
常见搜索策略:
find definition
|
+-- rg "struct proc"
+-- rg "enum procstate"
+-- rg "void scheduler"
find callers
|
+-- rg "fork\\("
+-- rg "wakeup\\("
+-- rg "copyout\\("
find constants
|
+-- rg "NPROC"
+-- rg "TRAMPOLINE"
+-- rg "SYS_getpid"搜索时要注意函数名可能出现在声明、定义、调用、注释中。
所以看结果要分类。
search result
|
+-- declaration
+-- definition
+-- caller
+-- comment
+-- macro例如搜索 fork。
你会看到:
- 用户态声明。
- 系统调用号。
- 系统调用 wrapper。
sys_fork()。fork()。- 用户程序调用。
不要被结果数量吓到。
把它们按层次摆开。
user layer
|
+-- user.h
+-- usys.S
+-- user programs
syscall layer
|
+-- syscall.h
+-- syscall.c
+-- sysproc.c
kernel core
|
+-- proc.c12. 如何用打印调试
打印是最简单的观察手段。
但在内核里,打印也要小心。
因为有些路径频率很高。
例如:
- 调度器。
- 时钟中断。
- 文件读写。
- 锁获取释放。
随便打印可能让输出爆炸。
推荐原则:
print only at lifecycle boundary
|
+-- process created
+-- process exits
+-- syscall enters
+-- file opens/closes
+-- page table created/freed打印内容要短。
例如:
fork: parent=2 child=3
exit: pid=3 status=0
wait: parent=2 reaped=3
syscall: pid=2 num=11不要一开始打印整个结构体。
先打印关键字段。
object address
|
+-- p pointer
identity
|
+-- pid
state
|
+-- state
relation
|
+-- parent pid打印要带阶段名。
否则多进程输出混在一起时看不懂。
13. 如何用 GDB
GDB 是读 xv6 的重要工具。
但初学者不要一开始追求复杂命令。
先会这几件事:
- 设置断点。
- 单步。
- 查看变量。
- 查看结构体字段。
- 查看寄存器。
- 继续运行。
GDB 阅读目标:
break at function
|
v
observe current process
|
v
inspect fields
|
v
step to next function
|
v
compare with diagram适合断点:
main
usertrap
syscall
sys_getpid
fork
allocproc
exit
wait
scheduler
swtch
copyin
copyout系统调用路径调试:
break syscall
run user command
inspect syscall number
inspect current proc
continue进程路径调试:
break fork
break allocproc
break exit
break wait页表路径调试:
break uvmcopy
break mappages
break walk
break copyoutGDB 不只是为了找 bug。
它是为了验证你的脑内模型。
14. 如何记录学习笔记
每次读源码都应该有固定笔记格式。
不要只摘抄代码。
建议模板:
Topic:
Question:
Source files:
Core structures:
Call path:
State changes:
Pointer relations:
Locks:
Experiment:
What I misunderstood:
Summary:例如读 fork():
Topic:
fork creates child process
Question:
What is copied from parent to child?
Source files:
sysproc.c, proc.c, vm.c, file.c, fs.c
Core structures:
struct proc, trapframe, pagetable, struct file, inode
Call path:
sys_fork -> fork -> allocproc -> uvmcopy -> filedup -> idup
State changes:
UNUSED -> USED -> RUNNABLE
Pointer relations:
child parent points to parent
child ofile entries point to same file objects with increased ref
Locks:
proc locks around state changes
Experiment:
print parent pid and child pid这种笔记会直接变成后续文章素材。
15. 阅读顺序建议
第一次完整学习 xv6,建议不要按文件名排序。
按路径排序。
第一组:全局地图。
Makefile
kernel/main.c
kernel/defs.h
kernel/param.h
kernel/types.h第二组:第一个用户进程。
kernel/proc.h
kernel/proc.c
user/init.c
user/sh.c第三组:系统调用。
user/user.h
user/usys.pl
kernel/syscall.h
kernel/syscall.c
kernel/sysproc.c
kernel/trap.c
kernel/trampoline.S第四组:进程生命周期。
kernel/proc.h
kernel/proc.c
kernel/swtch.S第五组:虚拟内存。
kernel/memlayout.h
kernel/riscv.h
kernel/kalloc.c
kernel/vm.c
kernel/exec.c第六组:文件系统。
kernel/file.h
kernel/file.c
kernel/sysfile.c
kernel/fs.h
kernel/fs.c
kernel/log.c
kernel/bio.c
kernel/virtio_disk.c第七组:设备和并发。
kernel/console.c
kernel/uart.c
kernel/plic.c
kernel/spinlock.c
kernel/sleeplock.c
kernel/pipe.c这个顺序的好处是:
从最短路径开始。
逐步增加对象关系。
不会一上来被文件系统或调度细节压垮。
16. 不同机制的推荐入口函数
每个机制都有推荐入口。
不要随便打开最大文件。
Boot
|
+-- main()
Syscall
|
+-- syscall()
+-- usertrap()
Process
|
+-- allocproc()
+-- fork()
+-- exit()
+-- wait()
Scheduling
|
+-- scheduler()
+-- sched()
+-- yield()
Memory
|
+-- kalloc()
+-- mappages()
+-- walk()
+-- uvmcopy()
+-- copyin()
+-- copyout()
File
|
+-- sys_open()
+-- sys_read()
+-- filealloc()
+-- fileread()
Filesystem
|
+-- namei()
+-- readi()
+-- writei()
+-- bread()
Device
|
+-- devintr()
+-- consoleintr()
+-- virtio_disk_rw()这些入口函数就像城市地铁站。
从它们出发,容易连接到周围机制。
17. 读源码时如何处理“不懂”
不懂很正常。
关键是判断“不懂属于哪一层”。
I do not understand this line
|
+-- C syntax problem?
|
+-- data structure problem?
|
+-- hardware/RISC-V problem?
|
+-- OS concept problem?
|
+-- concurrency problem?
|
+-- build/tool problem?例如看到:
p->trapframe->a0 = p->pid;如果不懂,可能有三层问题。
第一层 C 语法:
p->trapframe->a0 是连续通过指针访问字段。
第二层结构体:
p 是 struct proc *。
trapframe 是 struct trapframe *。
a0 是保存的用户寄存器字段。
第三层系统调用语义:
返回值要放在用户寄存器 a0 中。
所以要写进 trapframe。
把不懂的问题分层后,就知道该补哪里。
18. 如何避免“看懂了但不会用”
看懂源码注释不等于理解。
你需要做小实验。
每个机制至少做一个实验。
Boot
|
+-- print init sequence
Syscall
|
+-- add simple syscall
Process
|
+-- trace fork/exit/wait
Scheduling
|
+-- trace RUNNABLE/RUNNING changes
Memory
|
+-- print pagetable address
+-- trigger invalid memory access
File
|
+-- trace open/read/write path
Pipe
|
+-- trace sleep/wakeup
Device
|
+-- trace console input interrupt实验不一定复杂。
核心是验证关系。
例如:
Hypothesis:
fork creates child process with copied trapframe.
Experiment:
print parent and child trapframe addresses.
Expected:
child has separate trapframe object.这比只说“fork 复制进程”更扎实。
19. 如何写自己的源码注释
建议不要直接在源码里大面积写注释。
容易污染代码。
更好的方式是在知识库文章里写。
如果确实要在本地 xv6 源码中临时注释,建议只写短注释。
并且用实验分支。
笔记里可以更详细。
注释格式可以这样:
Function:
allocproc
Role:
Find an UNUSED proc slot and prepare kernel resources.
Key state transition:
UNUSED -> USED
Important pointers:
p->trapframe
p->pagetable
p->context这种方式比逐行中文翻译更有效。
逐行翻译容易让你以为自己懂了。
但真正重要的是角色、状态和对象关系。
20. 如何把 xv6 文章和源码同步
本知识库里的 xv6 文章应该遵守一条规则:
文章必须服务源码阅读。
每篇文章至少包含:
- 本篇问题。
- 涉及源码文件。
- ASCII 架构图。
- 关键结构体。
- 关键函数路径。
- C 指针或地址边界说明。
- 常见误解。
- 小实验。
文章和源码关系:
xv6 source code
|
v
extract mechanisms
|
v
draw object/call/state diagrams
|
v
write article
|
v
experiment verifies article不要写成:
concept article
|
v
random source snippets而应该写成:
source path
|
v
concept extracted from source这才是这个专题的风格。
21. 读 proc.c 前的准备清单
很多人想直接读 proc.c。
可以。
但先检查:
Before proc.c
|
+-- know struct proc fields
+-- know p->field syntax
+-- know process states
+-- know what pagetable means
+-- know what trapframe means
+-- know what context means
+-- know basic syscall path
+-- know scheduler exists per CPU如果这些不清楚,读 proc.c 会变成硬啃。
proc.c 可以分段读。
initialization
|
+-- procinit
+-- userinit
allocation
|
+-- allocproc
+-- freeproc
lifecycle
|
+-- fork
+-- exit
+-- wait
scheduling
|
+-- scheduler
+-- sched
+-- yield
sleep/wakeup
|
+-- sleep
+-- wakeup
+-- kill
helpers
|
+-- mycpu
+-- myproc
+-- either_copyin/out这样读比从第一行滑到最后一行更清楚。
22. 读 vm.c 前的准备清单
虚拟内存也不能硬读。
先准备:
Before vm.c
|
+-- know virtual address vs physical address
+-- know page size
+-- know PTE flags
+-- know pagetable_t is pointer-like
+-- know satp selects page table
+-- know user address cannot be trustedvm.c 分段读:
kernel page table setup
|
+-- kvmmake
+-- kvminit
+-- kvminithart
page table walking
|
+-- walk
+-- walkaddr
mapping
|
+-- mappages
+-- uvmunmap
user memory lifecycle
|
+-- uvmcreate
+-- uvmfirst
+-- uvmalloc
+-- uvmdealloc
+-- uvmcopy
+-- uvmfree
user/kernel copy
|
+-- copyin
+-- copyout
+-- copyinstr关键问题:
Which page table?
Which virtual address?
Which physical address?
Which permission bits?
Who owns the page?23. 读文件系统前的准备清单
文件系统要慢慢来。
先区分层次。
fd
|
v
struct file
|
v
struct inode
|
v
disk block
|
v
virtio disk准备清单:
Before filesystem
|
+-- know proc.ofile[]
+-- know struct file
+-- know inode is not file descriptor
+-- know buffer cache
+-- know log is filesystem transaction log
+-- know disk block abstraction推荐阅读顺序:
sysfile.c
|
v
file.c
|
v
fs.h
|
v
fs.c
|
v
bio.c
|
v
log.c
|
v
virtio_disk.c先读 read() 路径。
再读 write() 路径。
再读 open() 路径。
不要一开始读所有 inode 操作。
24. 初学者常见误解
误解一:源码应该按文件顺序读。
不应该。
应该按机制路径读。
误解二:main.c 看完就等于知道启动。
不够。
还要知道 entry.S、start.c、kernel.ld。
误解三:用户程序和内核函数可以直接互调。
不可以。
它们通过系统调用边界连接。
误解四:能运行 xv6 就等于环境理解完成。
不是。
还要知道内核镜像、用户程序、文件系统镜像之间的关系。
误解五:GDB 只在程序出 bug 时用。
不是。
GDB 是验证源码理解的工具。
误解六:打印越多越好。
不是。
内核高频路径打印太多会干扰系统。
误解七:读源码就是逐行翻译。
不是。
读源码要提取对象、状态、路径和不变量。
25. 本篇实践任务
任务一:画出目录地图。
xv6-riscv
|
+-- kernel
+-- user
+-- mkfs
+-- Makefile每个目录写一句职责。
任务二:画出构建地图。
kernel source -> kernel image
user source -> user programs -> fs.img
kernel image + fs.img -> QEMU任务三:追踪 getpid()。
列出它经过的文件。
不要先逐行读。
先画路径。
任务四:阅读 struct proc。
按字段分类:
identity
state
memory
scheduling
resources
family relation任务五:选择一个函数写阅读卡片。
推荐 fork()。
模板:
Function:
Caller:
Objects:
State changes:
Pointer relations:
Locks:
Return value:任务六:做一个最小打印实验。
例如在 sys_getpid() 打印当前 pid。
观察用户程序调用和内核输出之间的关系。
26. 本篇总结
读 xv6 要有方法。
核心不是一次读完所有代码。
核心是不断建立这四张图:
directory map
|
v
build map
|
v
call path map
|
v
object relation map每个机制都要问:
- 源码在哪些文件。
- 入口函数是什么。
- 核心结构体是什么。
- 指针关系是什么。
- 状态怎么变化。
- 什么锁保护状态。
- 如何实验验证。
下一篇进入:
05-boot-and-kernel-entry.md那篇会沿着 entry.S -> start.c -> main.c -> userinit -> scheduler 这条路径,仔细解释 xv6 内核如何从硬件启动走到第一个用户进程。