三藏签名
< Back to projectsMIT6.S081:实现操作系统的关键模块

MIT6.S081:实现操作系统的关键模块

System

从底层理解操作系统的基本原理,实现页表、系统调用,trap机制、写实复制、线程切换、锁等关键机制

总览:11 个 Lab 在训练什么

6.S081 这 11 个 Lab 其实对应的是 11 组非常典型的内核能力:

  1. util:用户态程序、文件描述符、fork/exec/pipe

  2. syscall:系统调用入口、参数抽取、内核态统计

  3. pgtbl:页表遍历、用户页表/内核页表、硬件访问位

  4. traps:trap frame、回溯、用户级 alarm

  5. lazy:缺页异常驱动的按需分配

  6. cow:写时复制、引用计数、只读共享页

  7. thread:用户级线程、上下文切换、并发同步练习

  8. lock:降低锁竞争、per-CPU allocator、分桶缓存

  9. fs:双重间接块、符号链接

  10. mmap:文件映射、VMA、按需载入与回写

  11. net:网卡驱动、协议栈、socket 风格接口

从课程编排看,它几乎就是一条完整的“操作系统能力树”:从用户程序到 trap,从虚存到并发,从文件系统到网络。

Lab 1:util

官方要求实现五个用户态程序:sleeppingpongprimesfindxargs。这个实验的重点不在内核,而在于熟悉 xv6 的用户态 API、shell 环境和最基本的 UNIX 编程模型。

这份实现全部放在 user/ 下:

  • user/sleep.c

  • user/pingpong.c

  • user/primes.c

  • user/find.c

  • user/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.c

  • kernel/sysproc.c

  • kernel/proc.h

  • kernel/sysinfo.h

  • user/trace.c

实现思路:

  • struct proc 中新增 tracemask

  • sys_trace() 把用户传入的掩码写进当前进程结构。

  • syscall() 在真正执行完系统调用后,根据 tracemask 判断是否打印日志,日志内容是 pid + syscall name + return value

  • sys_sysinfo() 则构造一个 struct sysinfo,通过 copyout() 把结果写回用户态指针。

这个实验第一次把“系统调用分发”拆成了完整链路:

  • 用户态 stub 把参数放进寄存器

  • ecall

  • trap -> syscall()

  • sys_xxx

  • 返回值写回 trapframe->a0

如果前一题是“使用系统调用”,这一题就是“定义系统调用”。

Lab 3:pgtbl

官方 page table lab 的主线一般包括:

  • 打印页表

  • 在用户空间映射一个只读共享页,优化简单系统调用

  • 读出页表访问位

这份实现还有一个更激进的扩展:做了“每进程内核页表”。

核心文件:

  • kernel/proc.c

  • kernel/proc.h

  • kernel/vm.c

  • kernel/vmcopyin.c

  • kernel/stats.c

  • user/stats.c

关键点有三层。

第一层是用户页表的显式构造与观察。proc_pagetable() 负责为每个进程创建根页表,并映射 TRAMPOLINETRAPFRAME。这让你清楚看到:用户地址空间不是抽象概念,而是一棵 Sv39 页表树。

第二层是共享页/统计页。分支里新增了 stats 设备接口和对应用户程序,等价于把某些“只读内核信息”映射到用户页表中,减少频繁陷入内核的成本。这是典型的“用映射替代系统调用”的思路。

第三层是每进程内核页表。struct proc 中新增了 kpagetable,调度时显式切换到 p->kpagetable。这比原始 xv6 的“全局唯一内核页表”更进一步,也让 copyin/copyinstr 的优化路径更自然。代价是:内核态地址空间管理变复杂了,用户页增长/回收时必须同步维护内核可见映射。

这一题是整个课程里第一次真正开始“站在 MMU 的视角看内核”。

Lab 4:traps

官方目标包括两部分:

  • backtrace()

  • sigalarm/sigreturn

这份实现的核心文件:

  • kernel/trap.c

  • kernel/sysproc.c

  • kernel/proc.h

backtrace 的思路是沿着 frame pointer 向上回溯,打印返回地址。这个功能通常接在某个内核路径里做验证,这份代码直接在 sys_sleep() 中调用 backtrace(),用于测试回溯链是否正确。

sigalarm 则更像一个微型用户级中断框架:

  • struct proc 中记录 intervalhandler、计时器状态、备份 trapframe。

  • usertrap() 在时钟中断路径里递增进程的 currtime

  • 当到达设定间隔且当前不在 handler 中,就备份整个 trapframe,然后把 epc 改成用户注册的处理函数地址。

  • sigreturn() 再把备份好的 trapframe 恢复回来。

这套实现的本质是“劫持返回用户态时的控制流”:

  • 用户态本来要回到原来的 epc

  • 内核改写它,让它先跑 handler

  • handler 最后通过 sigreturn() 取回原现场

这是理解 trapframe 价值的最佳练习:它不只是“保存寄存器”,而是“重写控制流的抓手”。

Lab 5:lazy

官方要求把 sbrk() 从“立即分配物理页”改成“只扩张地址空间大小,等访问时再分配”。

这份实现的关键文件:

  • kernel/trap.c

  • kernel/vm.c

实现主线很清楚:

  • sys_sbrk() 仍然只增长 p->sz

  • 真正访问尚未映射的页时,触发 page fault

  • usertrap() 检查 scause == 13/15

  • 如果 fault 地址在合法用户区间内,就 kalloc() 一页并 mappages()

与原始 xv6 相比,这要求内核把“缺页”从 fatal error 变成一种正常控制流。

这份实现还做了两处典型修补:

  • uvmunmap() 遇到不存在的映射时选择 continue,而不是一律 panic

  • uvmcopy() 遇到 lazy 区间没有实际 PTE 时也允许跳过

这两点很重要,因为 lazy allocation 的世界里,“虚拟地址合法但当前没有物理页”已经变成了正常状态。

Lab 6:cow

官方要求实现 copy-on-write fork:

  • fork 后父子页表共享物理页

  • 清除写权限

  • 真正写入时再复制

这份实现的关键文件:

  • kernel/kalloc.c

  • kernel/vm.c

  • kernel/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.c

  • user/uthread_switch.S

  • notxv6/ph.c

  • notxv6/barrier.c

uthread 的核心是自己造一个“线程上下文”:

  • ra

  • sp

  • s0-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.c

  • kernel/bio.c

  • kernel/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.c

  • kernel/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

官方要求为用户程序增加文件映射能力,并处理:

  • mmap

  • munmap

  • page fault on demand

  • MAP_SHARED 写回

这份实现的核心文件:

  • kernel/proc.h

  • kernel/sysfile.c

  • kernel/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.c

  • kernel/net.c

  • kernel/sysnet.c

  • kernel/pci.c

网卡驱动层:

  • e1000_init() 配置 TX/RX descriptor ring

  • e1000_transmit()mbuf 绑定到发送描述符,推进 TDT

  • e1000_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 rxq

  • sockwrite() 从用户空间 copyin() 数据,封装成 UDP 包发出

  • sockrecvudp() 根据四元组找到 socket,把收到的 mbuf 放进对应接收队列

  • sockread() 再把队列里的数据 copyout() 给用户进程

这个实验把“设备驱动 -> 协议解析 -> 进程接口”三层串成了一条完整链路,也让前面学的中断、DMA、缓冲区、sleep/wakeup 全都重新出现了一遍。

这份仓库实现的几个共性

把 11 个实验放在一起看,这份仓库有几个很明显的风格。

第一,整体思路是“先跑通功能,再逐步修补边角”。像 trapslazy 分支都能看到提交后继续补 bug 的痕迹。这很真实,也很像内核开发本身。

第二,很多实验的实现都偏“直给”:

  • lazyusertrap() 里直接补页

  • cow 直接用 PTE_RSW 标记 COW

  • mmap 直接维护固定大小 vmas[16]

这类写法的好处是:非常适合教学和理解机制。

第三,后半程实验开始明显出现“横向复用”:

  • trap 路径在 lazycowmmap 都承担缺页处理职责

  • copyout()cow 中也必须懂页错误语义

  • sleep/wakeup 机制在 threadlocknet 都以不同形式重现

这正是 xv6 实验最有价值的地方:不是 11 个互相孤立的小作业,而是一套不断叠加的系统设计训练。

结语

如果把这 11 个 Lab 全部做完,再回头看 xv6,会有一个很大的认知变化:

  • 一开始看到的是一堆分散的 C 文件和汇编入口

  • 后来看到的是进程、页表、trap、锁、文件系统、网络栈这些机制如何串起来

这份仓库里的实现并不都算“工业级最优解”,有些地方甚至保留了学生式的朴素写法和修补痕迹;但这恰恰让它非常适合拿来写技术博客。因为教学实验最重要的不是炫技,而是把机制讲透,把取舍讲清楚。

Comments

Discuss this project

Emoji supported. Comments appear immediately.

No comments yet.