
MIT6.S081:实现操作系统的关键模块
从底层理解操作系统的基本原理,实现页表、系统调用,trap机制、写实复制、线程切换、锁等关键机制
总览:11 个 Lab 在训练什么
6.S081 这 11 个 Lab 其实对应的是 11 组非常典型的内核能力:
util:用户态程序、文件描述符、fork/exec/pipesyscall:系统调用入口、参数抽取、内核态统计pgtbl:页表遍历、用户页表/内核页表、硬件访问位traps:trap frame、回溯、用户级 alarmlazy:缺页异常驱动的按需分配cow:写时复制、引用计数、只读共享页thread:用户级线程、上下文切换、并发同步练习lock:降低锁竞争、per-CPU allocator、分桶缓存fs:双重间接块、符号链接mmap:文件映射、VMA、按需载入与回写net:网卡驱动、协议栈、socket 风格接口
从课程编排看,它几乎就是一条完整的“操作系统能力树”:从用户程序到 trap,从虚存到并发,从文件系统到网络。
Lab 1:util
官方要求实现五个用户态程序:sleep、pingpong、primes、find、xargs。这个实验的重点不在内核,而在于熟悉 xv6 的用户态 API、shell 环境和最基本的 UNIX 编程模型。
这份实现全部放在 user/ 下:
user/sleep.cuser/pingpong.cuser/primes.cuser/find.cuser/xargs.c
实现特点:
sleep很直接,只做参数解析后调用内核sleep系统调用。pingpong使用两根管道来实现双向同步,展示了“父子进程 + pipe”这一经典模型。primes用递归函数receive_and_send()搭出过滤流水线,每发现一个新的素数就fork出新的右邻过滤器。这是整个 util 里最有 UNIX 味道的一题。find借鉴ls的目录遍历方式,通过递归下降目录树来匹配文件名。xargs的实现偏朴素:一次性从标准输入读进缓冲区,再做参数切分,随后直接exec。它能通过基础测试,但没有实现更完整的“逐行 fork/exec”风格,也没有实现真正的多行处理优化。
这一组练习真正有价值的地方是:你第一次感受到“文件描述符就是进程资源”,“一切 I/O 都能组合到 read/write/pipe/fork/exec 上”。
Lab 2:syscall
官方要求实现两个功能:
trace(mask):跟踪指定系统调用sysinfo(struct sysinfo *):返回空闲内存和进程数
这份实现的核心改动在:
kernel/syscall.ckernel/sysproc.ckernel/proc.hkernel/sysinfo.huser/trace.c
实现思路:
在
struct proc中新增tracemask。sys_trace()把用户传入的掩码写进当前进程结构。syscall()在真正执行完系统调用后,根据tracemask判断是否打印日志,日志内容是pid + syscall name + return value。sys_sysinfo()则构造一个struct sysinfo,通过copyout()把结果写回用户态指针。
这个实验第一次把“系统调用分发”拆成了完整链路:
用户态 stub 把参数放进寄存器
ecalltrap -> syscall()sys_xxx返回值写回
trapframe->a0
如果前一题是“使用系统调用”,这一题就是“定义系统调用”。
Lab 3:pgtbl
官方 page table lab 的主线一般包括:
打印页表
在用户空间映射一个只读共享页,优化简单系统调用
读出页表访问位
这份实现还有一个更激进的扩展:做了“每进程内核页表”。
核心文件:
kernel/proc.ckernel/proc.hkernel/vm.ckernel/vmcopyin.ckernel/stats.cuser/stats.c
关键点有三层。
第一层是用户页表的显式构造与观察。proc_pagetable() 负责为每个进程创建根页表,并映射 TRAMPOLINE 和 TRAPFRAME。这让你清楚看到:用户地址空间不是抽象概念,而是一棵 Sv39 页表树。
第二层是共享页/统计页。分支里新增了 stats 设备接口和对应用户程序,等价于把某些“只读内核信息”映射到用户页表中,减少频繁陷入内核的成本。这是典型的“用映射替代系统调用”的思路。
第三层是每进程内核页表。struct proc 中新增了 kpagetable,调度时显式切换到 p->kpagetable。这比原始 xv6 的“全局唯一内核页表”更进一步,也让 copyin/copyinstr 的优化路径更自然。代价是:内核态地址空间管理变复杂了,用户页增长/回收时必须同步维护内核可见映射。
这一题是整个课程里第一次真正开始“站在 MMU 的视角看内核”。
Lab 4:traps
官方目标包括两部分:
backtrace()sigalarm/sigreturn
这份实现的核心文件:
kernel/trap.ckernel/sysproc.ckernel/proc.h
backtrace 的思路是沿着 frame pointer 向上回溯,打印返回地址。这个功能通常接在某个内核路径里做验证,这份代码直接在 sys_sleep() 中调用 backtrace(),用于测试回溯链是否正确。
sigalarm 则更像一个微型用户级中断框架:
在
struct proc中记录interval、handler、计时器状态、备份 trapframe。usertrap()在时钟中断路径里递增进程的currtime。当到达设定间隔且当前不在 handler 中,就备份整个
trapframe,然后把epc改成用户注册的处理函数地址。sigreturn()再把备份好的 trapframe 恢复回来。
这套实现的本质是“劫持返回用户态时的控制流”:
用户态本来要回到原来的
epc内核改写它,让它先跑 handler
handler 最后通过
sigreturn()取回原现场
这是理解 trapframe 价值的最佳练习:它不只是“保存寄存器”,而是“重写控制流的抓手”。
Lab 5:lazy
官方要求把 sbrk() 从“立即分配物理页”改成“只扩张地址空间大小,等访问时再分配”。
这份实现的关键文件:
kernel/trap.ckernel/vm.c
实现主线很清楚:
sys_sbrk()仍然只增长p->sz真正访问尚未映射的页时,触发
page faultusertrap()检查scause == 13/15如果 fault 地址在合法用户区间内,就
kalloc()一页并mappages()
与原始 xv6 相比,这要求内核把“缺页”从 fatal error 变成一种正常控制流。
这份实现还做了两处典型修补:
uvmunmap()遇到不存在的映射时选择continue,而不是一律 panicuvmcopy()遇到 lazy 区间没有实际 PTE 时也允许跳过
这两点很重要,因为 lazy allocation 的世界里,“虚拟地址合法但当前没有物理页”已经变成了正常状态。
Lab 6:cow
官方要求实现 copy-on-write fork:
fork后父子页表共享物理页清除写权限
真正写入时再复制
这份实现的关键文件:
kernel/kalloc.ckernel/vm.ckernel/trap.c
实现核心分三部分。
第一部分是物理页引用计数。kalloc.c 中增加了按物理页索引的 rcount[],kfree() 不再直接释放,而是先减计数;只有减到 0 才真正回到 freelist。
第二部分是 fork 时的共享映射。uvmcopy() 不再为子进程分配并复制新页,而是直接把父页映射进子页表,并增加引用计数。配合清除 PTE_W、设置软件自定义位 PTE_RSW,可以标记“这是 COW 页”。
第三部分是写 fault 处理。无论是在 usertrap() 中的 store page fault,还是在 copyout() 这种“内核替用户写用户页”的路径里,都要识别:
该页是只读
且标记了 COW
然后分配新页、拷贝旧内容、改回可写映射、减少旧页引用。
这就是操作系统里最经典的“用异常换性能”的优化:fork 成本被推迟到真正写入那一刻。
Lab 7:thread
官方 thread lab 有三部分:
用户级线程
uthread并发哈希表
ph屏障同步
barrier
这份实现分别落在:
user/uthread.cuser/uthread_switch.Snotxv6/ph.cnotxv6/barrier.c
uthread 的核心是自己造一个“线程上下文”:
rasps0-s11
thread_switch 汇编负责保存旧线程上下文并恢复新线程上下文,最后用 ret 跳到新线程恢复出来的 ra。对于第一次运行的新线程,thread_create() 人工设置:
context.sp = 线程私有栈顶context.ra = 线程入口函数
于是第一次切换进去时,ret 就直接落到线程函数。
ph 则展示了“按 bucket 分锁”的基本思路,每个哈希桶一个 pthread_mutex_t。
barrier 使用 pthread_mutex_t + pthread_cond_t 实现“所有线程到齐再继续”的同步屏障。这和内存屏障不是一个概念,它是执行进度上的 barrier。
这一题的意义在于:你会第一次清楚地区分“线程切换”和“进程切换”,以及“上下文保存”在 ABI 层面到底保存什么。
Lab 8:lock
官方要求优化两块高竞争代码:
物理页分配器
buffer cache
这份实现的关键文件:
kernel/kalloc.ckernel/bio.ckernel/spinlock.c
页分配器的优化思路是标准 per-CPU freelist:
kmem.lock[NCPU]kmem.freelists[NCPU]
当前 CPU 优先从本地 freelist 分配;如果本地空了,再从别的 CPU “偷”一个页。这样 kalloc/kfree 的高频路径不再都撞同一把大锁。
buffer cache 的优化更有意思。实现里引入了:
全局分配锁
bcache.lock每个 bucket 的锁
bucketlocks[NBUCKET]每个
buf自身的sleeplock
哈希函数把 (dev, blockno) 分配到不同 bucket,命中路径只需要拿单个 bucket 锁;只有 miss 或跨 bucket 复用时才需要额外拿全局锁。这就是“把元数据冲突范围从全局缩小到局部”的典型做法。
spinlock.c 还统计了锁竞争信息,便于和 kalloctest / bcachetest 对照看效果。
这道题的本质是:不要只会“加锁”,还要会“拆锁”。
Lab 9:fs
官方 fs lab 的主线一般是:
扩大文件支持范围
实现符号链接
这份实现的关键文件:
kernel/fs.ckernel/sysfile.c
大文件支持落在 bmap() 和 itrunc():
在直接块
NDIRECT后,先处理一级间接块再加入双重间接块
这要求 inode 的地址数组重新规划,也要求回收逻辑能递归释放双重间接块指向的所有数据块。
符号链接则主要体现在 sys_open() 的解析逻辑中:
如果
ip->type == T_SYMLINK且没有O_NOFOLLOW就读取链接目标路径
递归调用
recurvsivereadlink()直到解析到真实 inode 或检测出异常
配合 symlinktest,这部分还要求处理:
dangling symlink
symlink cycle
O_NOFOLLOW
这是第一次把“文件系统命名层”和“文件内容层”明显分离开来:symlink 本身只是一个保存路径字符串的 inode。
Lab 10:mmap
官方要求为用户程序增加文件映射能力,并处理:
mmapmunmappage fault on demand
MAP_SHARED写回
这份实现的核心文件:
kernel/proc.hkernel/sysfile.ckernel/trap.c
设计上最重要的是引入 struct vma:
映射起始地址
长度
prot
flags
当前剩余长度
关联文件
每个进程维护一个 vmas[16] 数组,sys_mmap() 做的不是立刻把文件整段读进内存,而是:
选择一个虚拟地址区间
记录 VMA
filedup()增加
p->sz
真正访问时,usertrap() 里的缺页处理再判断 fault 地址是否落在某个 VMA 上:
如果落在 VMA 内,就
kalloc()一页从文件相应 offset
readi()到这页按
PROT_READ/WRITE/EXEC生成 PTE 权限mappages()
sys_munmap() 则负责:
找到对应 VMA
对
MAP_SHARED的页写回文件uvmunmap()解除映射必要时
fileclose/ 清理 VMA
这题的难点在于:它把 lazy allocation 从匿名内存推广到了“文件后备页”。
Lab 11:net
官方 networking lab 包含三层工作:
E1000 网卡驱动收发
简化协议栈
socket 风格的系统调用接口
这份实现的核心文件:
kernel/e1000.ckernel/net.ckernel/sysnet.ckernel/pci.c
网卡驱动层:
e1000_init()配置 TX/RX descriptor ringe1000_transmit()把mbuf绑定到发送描述符,推进TDTe1000_recv()从已完成的接收描述符中取包,交给net_rx(),然后补一个新的mbuf回接收环
协议栈层:
net_tx_udp()逐层压入 UDP 头、IP 头、Ethernet 头net_rx()先按 Ethernet type 分发net_rx_ip()验证 IPv4 头和校验和net_rx_udp()验证 UDP 长度并把 payload 交给 socket 层net_rx_arp()处理最基本的 ARP request/reply
socket 层:
struct sock维护(raddr, lport, rport)和一个mbufq rxqsockwrite()从用户空间copyin()数据,封装成 UDP 包发出sockrecvudp()根据四元组找到 socket,把收到的mbuf放进对应接收队列sockread()再把队列里的数据copyout()给用户进程
这个实验把“设备驱动 -> 协议解析 -> 进程接口”三层串成了一条完整链路,也让前面学的中断、DMA、缓冲区、sleep/wakeup 全都重新出现了一遍。
这份仓库实现的几个共性
把 11 个实验放在一起看,这份仓库有几个很明显的风格。
第一,整体思路是“先跑通功能,再逐步修补边角”。像 traps、lazy 分支都能看到提交后继续补 bug 的痕迹。这很真实,也很像内核开发本身。
第二,很多实验的实现都偏“直给”:
lazy在usertrap()里直接补页cow直接用PTE_RSW标记 COWmmap直接维护固定大小vmas[16]
这类写法的好处是:非常适合教学和理解机制。
第三,后半程实验开始明显出现“横向复用”:
trap 路径在
lazy、cow、mmap都承担缺页处理职责copyout()在cow中也必须懂页错误语义sleep/wakeup机制在thread、lock、net都以不同形式重现
这正是 xv6 实验最有价值的地方:不是 11 个互相孤立的小作业,而是一套不断叠加的系统设计训练。
结语
如果把这 11 个 Lab 全部做完,再回头看 xv6,会有一个很大的认知变化:
一开始看到的是一堆分散的 C 文件和汇编入口
后来看到的是进程、页表、trap、锁、文件系统、网络栈这些机制如何串起来
这份仓库里的实现并不都算“工业级最优解”,有些地方甚至保留了学生式的朴素写法和修补痕迹;但这恰恰让它非常适合拿来写技术博客。因为教学实验最重要的不是炫技,而是把机制讲透,把取舍讲清楚。

Comments
Discuss this project
Emoji supported. Comments appear immediately.