OSTEP实验包实战:xv6内核系统调用、调度与页表改造指南 简介这份HNU_ostep操作系统实验资料包面向高校操作系统课程学习者与实验指导教师围绕进程管理、内存管理、文件系统及I/O系统等核心模块提供可动手实践的实验素材帮助读者把调度、虚拟内存、页面置换、IPC与设备管理等抽象原理落到代码层面。压缩包共2000个文件约42.51MB以910个C源文件和760个头文件为主体辅以Python脚本、Shell命令、CMake与Make构建文件以及Markdown、TXT、PDF、DOCX等说明与讲义兼顾源码研读、编译运行和实验指导。已有166人学习。包内源码目录可作为实验实现蓝本配合实验说明中的总览、环境搭建与任务要求读者能够对照修改调度算法、观察不同策略对进程执行效率的影响并借助附书资料理解操作系统内部机制。整体目录结构清晰适合按进程、内存、文件系统和I/O模块逐项练习也便于教师组织课程实验与学生查漏补缺。1. 从一道 fork 判断题说起这份 OSTEP 实验包到底在解决什么第一次打开这份操作系统实验包时我以为它只是把教材《Operating Systems: Three Easy Pieces》的课后题整理了一下。翻完目录才发现真正占篇幅的是内核侧的动手部分进程切换、页表遍历、系统调用注入、文件系统扩容每一项都要你改真实内核代码再用脚本判分。它面向的读者很明确——上完理论课能背出 PCB 字段但没亲手让一个进程从 RUNNABLE 走到 RUNNING 的人。这份实验包的定位是把 OSTEP 三大主题虚拟化、并发、持久化从纸面拉到可运行状态你在教材里看到的调度算法、页表项标志位、日志式文件系统在这里都有对应的代码位置和一个能跑起来的 QEMU 实例。新手可以照着走通一条链路熟手则能拿它当二次开发的脚手架。2. 实验体系与技术栈从 QEMU 到内核的完整运行模型先说明这套实验的技术底座。它沿用的是教学内核的经典形态一个精简的类 Unix 内核跑在 RISC-V 上通过 QEMU 模拟整机用交叉编译工具链产出内核镜像。这和你在真实服务器上写内核模块完全不同——没有 insmod没有 dmesg 可看所有调试都靠串口输出和 GDB 远程连接。这种选择的原因很直接从零实现一个能跑用户程序的系统比自己造一套模拟器或依赖某个大型内核要可控得多。整个内核大概一万行 C 加少量汇编一天能通读完主路径一学期能改透。代价是抽象层次低写错一个release()就死锁没有任何运行时保护替你兜底。2.1 目录结构内核源码、用户程序、判分脚本三件套拿到包先看目录它决定了后续所有操作的位置。. ├── Makefile # 顶层构建入口qemu / gdb / grade 都挂在这里 ├── kernel/ # 内核本体所有内核实验改这里 │ ├── main.c # 第一个 CPU 的启动流程 │ ├── proc.c # 进程表、调度器、fork/exec/exit │ ├── vm.c # 页表建立、walk、copyin/copyout │ ├── trap.c # 陷阱入口系统调用和中断在这里分岔 │ ├── syscall.c # 系统调用分发表 │ ├── sysproc.c # 进程相关系统调用的具体实现 │ ├── fs.c # 文件系统调用层 │ ├── log.c # 日志事务崩溃一致性靠它 │ └── spinlock.c # 自旋锁与 sleep/wakeup ├── user/ # 运行在用户态的程序 │ ├── usys.pl # 生成系统调用桩代码的 Perl 脚本 │ ├── user.h # 用户态可见的系统调用声明 │ └── sh.c # shell跑通后你看到的第一个界面 ├── mkfs/ # 把用户程序打包进文件系统镜像 └── grade-lab-*/ # 每个实验对应的自动判分目录kernel/是按功能切的不是按对象切的所以读代码时经常要横跳追一次read()要从sysfile.c跳到file.c再到log.c。这是教学内核的常态换来的是每个文件都很短vm.c不到八百行。判分脚本目录grade-lab-*每套实验一份里面通常有个gradelib.py负责跑 QEMU、抓串口输出、正则匹配期望结果。很多人跑不通判分不是内核写错了是 Python 依赖或 QEMU 参数变了这个后面专门讲。2.2 技术栈RISC-V 交叉工具链加 QEMU 模拟器工具链是三件套缺一不可。下面的安装方式我在 Debian/Ubuntu 系上验证过多次# 交叉编译器在 x86 主机上产出 riscv64 目标代码 sudo apt install gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu # 模拟器带 5.1 版本以上的 QEMU 才支持 rv64 的 virt 机器 sudo apt install qemu-system-misc # 调试器必须是支持 riscv 的多架构版本 sudo apt install gdb-multiarch三个包的版本要能对上。血泪经验QEMU 太老会在加载内核时直接停在booting...不动且没有任何报错看起来像内核挂死实际是模拟器不认镜像格式。判断方法很简单手动跑一次 QEMU 看它能不能识别-machine virtqemu-system-riscv64 -machine virt -bios none -kernel kernel/kernel -m 128M -nographic能打出 OpenSBI 之类的启动信息说明模拟器没问题问题在内核。什么都不打或者报 unknown machine回去升 QEMU。这条命令也是后面裸调试的底子make qemu本质上就是给它加了一堆参数。关掉 QEMU 的方式是Ctrl-A松开再按X不是Ctrl-C。第一次用的人往往在终端里按半天Ctrl-C出不来以为卡死了。2.3 每个 lab 的输入输出契约make grade 在测什么理解了判分机制写代码才有方向。判分脚本的工作流是固定的三步用实验专用的make qemu启动内核把内建的用户程序列表塞进文件系统镜像等 shell 起来后通过标准输入逐条灌入测试命令抓串口全部输出按预设的正则匹配匹配上给分。这意味着两件事。第一你的输出格式必须严格对齐实验文档多一个空格、少一个换行都可能判不过。第二判分脚本超时时间通常只有几十秒如果你的实现里有死循环或死锁表现是整轮判分挂起而不是某一条失败。先跑一次空白判分看基线长什么样比上来就改代码高效得多make grade # 或指定某个实验的判分入口 python3 grade-lab-util/maketest.py输出里每条测试会有 ok / FAIL / Timeout 三种状态。看到全部 FAIL 先别改内核先确认make qemu单独能跑通、shell 能交互。这一步能挡掉一半以上的环境问题。3. 环境搭建与第一次跑通从编译到在 gdb 里打断点环境这一步没什么技巧就是把工具链凑齐然后跑make。但第一次编译大概率不会一次过所以这一章按「编译 → 启动 → 调试」三段走每段都给出失败时的判断依据。3.1 依赖安装与 make 目标速查顶层 Makefile 是唯一入口常用目标就四个先记住目标作用什么时候用make编译内核和全部用户程序产出kernel/kernel和fs.img改完代码第一步make qemu编译并启动 QEMU进入 shell手动验证功能make qemu-gdb启动 QEMU 但暂停等 GDB 连上来需要单步调试make clean清掉所有产物怀疑残留文件干扰时编译过程本身分两段先编译user/下的程序再用mkfs把它们打包成fs.img然后编译kernel/下的目标文件按kernel/kernel.ld链接成镜像。链接脚本决定了内核被加载到0x80000000这是 RISC-V virt 机器的固定入口地址改错这个地址的表现是 QEMU 启动后立刻复位。第一次编译报错八成是这两类# 报错riscv64-unknown-elf-gcc: command not found # 原因Makefile 默认用 unknown-elf 前缀而 apt 装的是 linux-gnu 前缀 # 解决要么装对应前缀的工具链要么改 Makefile 顶部这一行 TOOLPREFIX riscv64-linux-gnu-第二类是undefined reference to memcpy之类的链接错误通常是编译器内置函数和内核自己实现的string.c打架加-fno-builtin就能压住Makefile 里一般已经带了被自己改坏才会出现。3.2 make qemu 的启动链路从 kernel.ld 到第一个 shell跑通之后看到的东西很朴素——几行启动日志然后一个$提示符。但中间发生的事值得捋一遍因为后面所有实验都挂在这条链路上。make qemu # qemu-system-riscv64 -machine virt -bios none -kernel kernel/kernel \ # -m 128M -smp 3 -nographic -drive filefs.img,ifnone,formatraw,idx0 \ # -device virtio-blk-device,drivex0,busvirtio-mmio-bus.0参数逐个看-m 128M给了 128MB 物理内存这个值在kernel/memlayout.h里有对应常量改内存大小要两边一起改-smp 3是三个 CPU 核心多核实验比如 per-CPU 锁必须保证这个数大于 1否则测不出竞态-nographic把串口直接接到终端这是你能看到输出的唯一通道内核里的printf最终都走 UART。启动时第一个跑起来的是kernel/start.c里的机器态代码做完基本配置后mret掉到main()。main()在kernel/main.c里流程是固定的// kernel/main.c每个 CPU 都会进来一次 void main() { if(cpuid() 0){ consoleinit(); // 串口之后 printf 才有输出 printfinit(); printf(\n); printf(xv6 kernel is booting\n); kinit(); // 物理页分配器 kvminit(); // 内核页表 kvminithart(); // 打开分页 procinit(); // 进程表NPROC 个槽位 trapinit(); // 陷阱向量 trapinithart(); plicinit(); // 中断控制器 binit(); // 块缓存 iinit(); // inode 表 fileinit(); virtio_disk_init(); userinit(); // 造出第一个进程执行 init } else { // 其他 CPU 只做最小初始化然后进 scheduler } scheduler(); }顺序不能乱kinit必须在kvminit之前因为建页表要分配物理页userinit必须在procinit之后plicinit要在开中断之前。改启动流程是最容易翻车的地方加一行printf到consoleinit之前输出直接消失看起来像什么都没执行。userinit()造出的第一个进程执行/initinit再 fork 出sh你看到的$就是它。这条链跑通说明页表、进程、文件系统、驱动四条线都活着可以开始做实验了。3.3 用 gdb 单步让黑匣子开口内核调试没有 print 之外的捷径时GDB 是唯一能看真相的工具。开两个终端# 终端 A启动 QEMU 并停在第一条指令 make qemu-gdb # 输出里会打印 target remote localhost:26000 # 终端 B连上去 gdb-multiarch -ex set architecture riscv:rv64 \ -ex target remote localhost:26000 \ kernel/kernel连上之后layout src打开源码视图b scheduler打断点c继续执行。切进程的时候会自动停在调度器里这时候p *p能看进程结构体的全部字段比在内核里 print 强太多——print 改代码要重新编译重启GDB 不用。几个高频命令记一下info registers看当前寄存器x/10i $pc反汇编当前位置p/x $satp看当前页表基址bt看调用栈。排查 page fault 时最有用的是bt配合p/x $sepcsepc是出错的那条指令地址用addr2line反查就能定位到源码行riscv64-linux-gnu-addr2line -e kernel/kernel 0x80001234这条命令能省掉大量到底哪行崩了的猜测。注意make qemu-gdb会额外生成.gdbinit里面已经配好了架构和断点直接用gdb-multiarch不加参数也能连但仓库里如果有多份.gdbinit会互相覆盖看到断点行为诡异先检查这个文件。4. 核心模块拆解系统调用、调度器、页表三条主线实验改来改去落点永远是这三处。看懂了它们的代码形状加功能就是填空。4.1 系统调用链路从 ecall 到 sys_xxx 的完整路径用户程序调一个函数最终怎么进内核这条链路每个实验都要用。以加一个sys_trace为例六处改动缺一不可// 1) kernel/syscall.h —— 分配一个系统调用号 #define SYS_trace 22 // 2) kernel/syscall.c —— 声明实现并挂进分发表 extern uint64 sys_trace(void); static uint64 (*syscalls[])(void) { [SYS_trace] sys_trace, }; // 3) kernel/syscall.c 的分发函数里把返回值存进 a0 void syscall(void) { int num; struct proc *p myproc(); num p-trapframe-a7; // 调用号放在 a7 寄存器 if(num 0 num NELEM(syscalls) syscalls[num]) { p-trapframe-a0 syscalls[num](); // 返回值写回 a0 } else { printf(%d %s: unknown sys call %d\n, p-pid, p-name, num); p-trapframe-a0 -1; } }剩下三处是kernel/sysproc.c里写实现体、user/user.h加用户态声明、user/usys.pl加一行entry(trace)。usys.pl在构建时生成usys.S内容就是每个系统调用的汇编桩把调用号放进a7然后ecall。// kernel/sysproc.c —— 实现体参数用 argint/argaddr/argstr 取 uint64 sys_trace(void) { int mask; if(argint(0, mask) 0) // 从用户态栈上取第 0 个参数 return -1; myproc()-tracemask mask; // 存进 PCB后面 fork 时记得继承 return 0; }参数为什么不能直接解引用因为argint取到的只是用户态的虚拟地址空间里的数值而寄存器里的指针值在你切到内核页表后仍然有效内核做了直接映射但用户传的字符串、结构体指针不能直接读——那是页表切换后的另一个地址空间。所以必须走copyin/copyout这条规则后面第 4.3 节细讲。这里最常犯的错是漏掉fork里的继承。新加的 PCB 字段在fork()里不复制测试单进程能过一到 fork 场景就丢。检查方法很粗暴全局搜一遍p-tracemask所有出现的地方都对一遍。4.2 调度器一个进程如何从 RUNNABLE 走到 RUNNING调度是 OSTEP 前半本书的主线代码在这// kernel/proc.c void scheduler(void) { struct proc *p; struct cpu *c mycpu(); c-proc 0; for(;;){ intr_on(); // 开中断否则时钟进不来时间片轮不起来 int found 0; for(p proc; p proc[NPROC]; p){ acquire(p-lock); // 每个进程一把锁防并发抢 if(p-state RUNNABLE){ p-state RUNNING; c-proc p; swtch(c-context, p-context); // 上下文切换返回时已换过 CPU c-proc 0; found 1; } release(p-lock); } if(found 0){ intr_on(); asm volatile(wfi); // 没活干就睡等中断唤醒 } } }swtch是汇编写的只保存被调用者保存寄存器ra、sp、s0-s11把当前 CPU 的上下文指针和进程的上下文指针换一下。函数返回时CPU 已经在跑另一个进程了——这是协程式的切换不是中断现场。真正的中断上下文在trapframe里两者别搞混trapframe是用户态寄存器快照context是内核态调用栈快照。改调度策略比如加优先级的位置就在这个for循环里。把线性扫描换成按优先级挑最小改起来只要十几行但要注意两点一是acquire的顺序不能乱遍历时逐把拿逐把放不要同时持有两把进程锁二是挑选过程中不能睡眠scheduler跑在中断关闭临界区内任何可能导致睡眠的调用都是死锁起点。多核场景还有一个坑mycpu()返回的是当前 CPU 的结构体靠tp寄存器里的 hartid 索引。如果你在scheduler里做全局统计必须加锁否则三个 CPU 同时加减计数会丢更新。测试用例里一般会开-smp 3专门打这个。4.3 页表与 copyout用户指针为什么不能直接解引用页表这条线是实验里改动最深的。先看核心的两个函数// kernel/vm.c —— 沿三级页表走到最末级返回 PTE 指针 pte_t * walk(pagetable_t pagetable, uint64 va, int alloc) { if(va MAXVA) panic(walk); for(int level 2; level 0; level--){ pte_t *pte pagetable[PX(level, va)]; if(*pte PTE_V){ pagetable (pagetable_t)PTE2PA(*pte); } else { if(!alloc || (pagetable (pde_t*)kalloc()) 0) return 0; // 没分配就返回空调用方决定怎么办 memset(pagetable, 0, PGSIZE); *pte PA2PTE(pagetable) | PTE_V; } } return pagetable[PX(0, va)]; } // kernel/vm.c —— 内核往用户页写数据唯一合法通道 int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len) { uint64 n, va0, pa0; while(len 0){ va0 PGROUNDDOWN(dstva); // 按页对齐 pa0 walkaddr(pagetable, va0); // 查物理地址 if(pa0 0) return -1; // 用户页不存在返回错误而不是 panic n PGSIZE - (dstva - va0); // 本页还能拷多少 if(n len) n len; memmove((void *)(pa0 (dstva - va0)), src, n); len - n; src n; dstva va0 PGSIZE; } return 0; }逐段看。walk里PX(level, va)是取虚拟地址对应层级的 9 位索引三级页表就是 9/9/9 加 12 位页内偏移。alloc参数决定遇到缺页时是建页还是返回空——这个参数是做 lazy allocation 实验的着力点把alloc传 0 然后在上层捕获0返回值去补页就是懒分配的骨架。copyout的循环处理跨页拷贝每轮算本页剩余空间n拷完再前进。if(pa0 0) return -1这行是错误处理的样板用户传个野指针进来内核返回 -1 让用户程序自己崩绝不能跟着崩。对照着看很多人在实现自己的系统调用时图省事直接*(char*)addr一旦用户传非法地址整个内核 panic这就是翻车的典型现场。walkaddr比walk多一层判断查到 PTE 后要检查PTE_V和PTE_U用户态不能访问没打PTE_U的页。这也是为什么内核往用户态写数据必须走copyout——不是因为地址空间不同内核做了直接映射而是因为只有走这条路径才会做权限和存在性校验。做 COW写时复制实验时改动集中在这两个函数和usertrap的 page fault 分支copyout里遇到PTE_COW标志要先复制物理页再写walkaddr不能直接把只读页返回给写路径。判断依据写错的表现是判分脚本里 fork 相关测试全挂但简单的 echo 能过——遇到这种部分通过先看PTE_U、PTE_W、PTE_COW三个标志位的组合。5. 避坑与排查编译、panic、判分三类高频现场这一章全是踩过的坑按「现象 → 原因 → 解决」写遇到相似的直接对照。5.1 编译期工具链与构建残留现象make报recipe for target user/_cat failed错误信息里混着一堆No such file or directory但user/cat.c明明存在。原因user/_cat这类带下划线的目标是mkfs打包时生成的中间文件make clean没清干净或者上次编译中断留下半截产物。也可能是UPROGS里新加的程序名拼错mkfs找不到源文件。解决make clean后重编。如果还报进user/目录单独跑make cat看真实错误。新加用户程序要同时改三处user/下建.c文件、Makefile的UPROGS里加$U/_xxx、user/user.h里加声明如果它要调系统调用。现象改了kernel/proc.h的结构体编译只重编了一部分文件跑起来行为诡异字段值明显错位。原因Makefile 的依赖规则不完整头文件改动没触发所有包含它的.o重编。教学内核的 Makefile 一般用-MMD -MP生成依赖文件但如果你手动改过规则或者用了并行编译make -j可能踩到。解决改结构体、改宏定义这类影响面大的操作一律make clean make。别省这几十秒。5.2 运行期panic 与页错误定位现象启动直接panic: kvmmake或者卡在xv6 kernel is booting之后再无输出。原因main()里的初始化顺序被打乱最常见的是在kvminit()之前调用了需要访问内核页表的函数或者kinit()没跑就kalloc()。另一个可能是物理内存大小和PHYSTOP常量对不上。解决panic会打印文件名和行号直接跳过去看。如果是自己新加的初始化代码往前挪到procinit()之后因为进程表初始化依赖kalloc。现象跑某个测试时panic: uvmunmap: not mapped或panic: uvmunmap: not a leaf。原因这两个 panic 都出自uvmunmap是进程退出时释放页表的过程。前者是 PTE 标记为有效但walk查不到物理页典型是 lazy allocation 实验没改完后者是最后一级 PTE 缺少PTE_V通常出现在你手动改过walk的返回值却没同步改uvmunmap的容错分支。解决定位到kernel/vm.c的uvmunmap看它遍历时对空 PTE 的处理。做懒分配实验时必须在这里加if(!(*pte PTE_V)) continue;否则进程一退出就崩。这个坑几乎每个做懒分配的人都会踩一次。现象多核测试偶发panic: acquire或直接死锁单核跑一切正常。原因自旋锁重复获取或者持锁期间调用了可能睡眠的函数比如sleep、copyout触发磁盘 I/O。-smp 1时锁竞争几乎不发生问题被掩盖。解决把CPUS加到 3 以上复现然后在acquire里加一段打印lk-name和cpuid()的调试代码看是谁在持锁。原则是持锁区间尽量短锁内不调用任何可能让出 CPU 的函数。5.3 判分期脚本不过但手动能跑现象手动敲命令输出完全正确make grade却报 Timeout 或正则不匹配。原因三个高频来源。一是输出里混了额外的调试printf正则匹配的是整行多几个字符就废二是测试脚本等的是特定提示符你改了 shell 的提示符格式三是 Python 版本差异老脚本用了 Python 2 的print语法。解决先看判分脚本里期望的正则是什么拿grep搜expected关键字。然后对照自己的输出逐字节比特别注意行尾空格和\r\n。调试输出在提交前一定清掉——这是最容易忽略的失分点。现象判分卡住不动也不报错等几分钟才退出。原因内核里死循环或者等一个永远不会来的中断。常见于改了调度器之后某个进程永远 RUNNABLE 却抢不到 CPU或者sleep之后没有对应的wakeup。解决手动跑同样的命令序列看卡在哪一步。如果手动也卡用make qemu-gdb加Ctrl-CGDB 里的中断bt看当前栈。如果是判分才卡把gradelib.py里的超时时间临时调大看它到底在等什么输出。6. 二次开发加一个系统调用并替换调度策略的完整路径如果要在这套实验上做点自己的东西我建议从一个最小闭环开始加一个能读进程运行时的系统调用再用它改调度。这条路径把三条主线全串了一遍改完基本就摸清了整个内核的交互方式。具体做法分五步。第一步在kernel/proc.h的struct proc里加uint64 runtime;记录累计 CPU 时间。第二步在kernel/trap.c的usertrap里时钟中断分支which_dev 2那条路给当前进程加计数// kernel/trap.c时钟中断里累加运行时间 if(which_dev 2){ struct proc *p myproc(); p-runtime 1; // 每个 tick 加一粒度取决于时钟频率 if(p-timeslice TIMESLICE_TICKS){ p-timeslice 0; yield(); // 时间片用完主动让出 } }第三步把scheduler()里的线性扫描换成按runtime挑最小值实现最简单的短作业优先。第四步加两个系统调用getruntime和setpriority走第 4.1 节那六处改动。第五步写个用户程序循环打印自己的运行时验证计数和调度都生效。改造过程有三条验证习惯值得固定下来。其一是每改一处就make一次不要一口气改五个文件再编报错根本分不清是谁的锅。其二是改动前后各跑一次make grade存基线用diff比对新引入的失败一目了然。其三是判分脚本和手动验证必须都过只过判分说明你猜对了正则只过手动说明你还没对齐输出格式两个都过才算真通过。改造点涉及文件验证方式加 PCB 字段kernel/proc.h、kernel/proc.c的allocproc/forkfork 后打印字段确认继承系统调用注册syscall.h、syscall.c、sysproc.c、user.h、usys.pl用户程序调用后看返回值改调度策略kernel/proc.c的scheduler()-smp 1先跑通再上多核时钟计数kernel/trap.c的usertrap打印计数与 wall time 粗略比对有一点必须提醒改调度器时yield()的调用位置很关键。放在时钟中断里是抢占式的放在系统调用返回路径上是协作式的两者语义完全不同。我见过有人把yield放进usertrap的最外层结果每次陷阱返回都切一次进程fork都跑不完就超时。判断方式很简单跑一个纯计算程序如果它被频繁打断且总时间暴涨说明切得太碎了。从那以后我每次动内核结构体或者调度路径都强制走一遍「make clean全量编译 → 单核手动跑三条命令 → 三核跑判分」这套流程一次都不省。教学内核没有运行时保护任何侥幸都会被多核场景放大成随机 panic。上面这些链路和坑点对着源码过一遍再动手改会顺很多希望帮到你。本文还有配套的精品资源点击获取