首页 / 资讯中心 / 文章详情

xv6-rev5内核源码实战:从环境搭建到自定义系统调用

xv6-rev5内核源码实战:从环境搭建到自定义系统调用 ★ FEATURED ARTICLE
简介Xv6是麻省理工学院为操作系统课程设计的类UNIX教学系统这套xv6-rev5.tar.gz源码包是第五修订版的完整代码适合需要从源码层面理解OS原理的学生、考研者及底层开发人员。包内共85个文件以44个C源码和19个头文件为骨架覆盖内核初始化、进程调度、内存管理、文件系统、设备驱动等模块另有8个汇编文件、Makefile及少量辅助脚本和文档整个压缩包仅78KB轻量精简适合逐行精读。通过分析这些代码可以系统掌握进程创建与调度、页式虚拟内存与页面替换、基于inode的文件系统、自旋锁与信号量、常见系统调用实现、启动引导流程以及中断和异常处理等核心概念。读者既可对照xv6官方课程实验修改调度算法、内存分配策略也可自行扩展网络栈或驱动从而加深对硬件接口和并发控制的理解。该资源已有1460人学习下载是操作系统自学与教学的高质量参考资料。1. 为什么还要拆一份 xv6-rev5教学内核比你想的更接近实战准备读操作系统源码的人大多会先打开现代内核结果被几十个子目录和几百万行代码劝退。反过来xv6-rev5 这套只有几千行 C 的源码包却能让你在几小时内把进程、系统调用、页表、文件系统全部跑通它源自某高校的操作系统课程按早期 Unix 的设计思路重新实现运行在 x86 32 位环境下配合模拟器就能当一台真实机器用。它能解决的是把课本里抽象的概念落成可断点、可单步、可改代码并立刻看到后果的环境。适合正在上 OS 课的学生也适合想快速补内核基础但没时间啃大项目的从业者。下载一份源码包等于把配套的实验环境一起拿到手。2. 源码结构第一课几千行 C 代码先分清内核、用户程序与 Makefile拿到 xv6-rev5.tar.gz 之后第一件事不是急着 make而是把文件摊开来看一遍。这个目录非常扁平没有现代内核那种 driver/mm/fs/ipc 分层所有东西就摆在顶层。这个设计对初学者极其友好你不需要在仓库里来回跳转几分钟就能建立全局认知。2.1 目录树速览哪些文件决定内核行为哪些只是工具程序解压后ls一眼扫完文件能分成四组。第一组是内核启动路径涉及bootblock.S、bootmain.c、entry.S、main.c。第二组是内核核心子系统包括proc.c、vm.c、kalloc.c、trap.c、syscall.c、fs.c、bio.c、log.c等等。第三组是用户态程序比如sh.c、cat.c、ls.c、grep.c、wc.c、forktest.c。第四组是构建工具重点是Makefile和mkfs.c。分组代表文件作用启动链路bootblock.S、bootmain.c、entry.S、main.c从 BIOS 加载到内核初始化核心内核proc.c、vm.c、kalloc.c、trap.c进程/内存/中断文件系统fs.c、bio.c、log.c、file.c、pipe.cblock、inode、日志、管道用户程序sh.c、cat.c、ls.c、grep.cshell 和常用命令构建工具Makefile、mkfs.c编译内核、生成 fs.img第一次读别按文件名字母顺序读要按启动顺序读。先看bootblock.S和bootmain.c明白内核镜像怎么被加载接着看entry.S怎么开分页再进main.c看初始化顺序最后顺着proc.c看第一个进程怎么被拉起来。这个顺序和现代内核的启动流程同构读一遍就能举一反三。另外注意mkfs.c。它是用户态程序在编译时运行用来生成文件系统镜像fs.img。往 xv6 里加文件、加程序时改的不是这个文件而是Makefile里的 UPROGS 列表mkfs会按列表把编译好的可执行文件写进镜像。2.2 启动链路从 bootblock 到 main.c 的第一行日志x86 开启电源后BIOS 把 512 字节的 boot block 加载到物理地址0x7c00然后跳进去执行。xv6 的bootblock.S做的事就是写着0x7c00这个地址把整个内核读进来。读完以后bootmain.c负责解析 ELF 格式的内核镜像按段加载到内存再跳到entry标号。entry.S是内核真正开始执行的入口它做了两件关键事按页对齐设置栈指针然后调用一个简单的页表初始化把虚拟地址映射到物理地址。这一步做完CPU 就在分页模式下运行了此后代码里的地址全是虚拟地址不再直接操作物理内存。接下来跳进main.cint main(void) { lapic_init(); consinit(); // 控制台先能输出 kinit(); // 物理内存分配器 vminit(); // 内核页表 picinit(); // 8259A 中断控制器 ioapicinit(); // IOAPIC uartinit(); // 串口 pinit(); // 进程表 inodeinit(); // inode 缓存 fileinit(); // 文件表 userinit(); // 第一个用户进程 return 0; }main.c的顺序是有讲究的先用consinit让控制台能输出之后再用panic报错你才能看到字先初始化物理内存分配器后续所有内核数据结构才能kalloc拿页最后userinit创建第一个进程 init整个系统才算活过来。调试时如果在内核早期崩溃多半是这几行的初始化顺序被你改乱了。2.3 内核镜像如何被链接出来地址与符号表xv6-rev5 的Makefile里并没有额外写 ld script而是用最直接的方式kernel: $(OBJS) $(LD) $(LDFLAGS) -Ttext 0x100000 -e entry -o kernel $(OBJS)-Ttext 0x100000表示代码段起始在物理地址1MB处这是 x86 内核的传统加载位置BIOS 和 bootblock 占用的低地址空间已经腾出来了。-e entry把入口点设成entry这个全局符号和entry.S对应。整个镜像是一个扁平的可执行文件没有现代内核那种段权限分离的复杂度但这个简化反而让 gdb 调试更直观。nm kernel | grep main$ # 输出类似 00102d00 T main这就是 main 函数在镜像里的物理地址高版本 gcc 编译出的内核符号会带 32 位地址但如果你在 64 位宿主上编译需要保证链接时用的是 32 位模式也就是-m32。用file kernel看一眼如果显示ELF 32-bit LSB executable就对了如果你看到64-bit说明Makefile里的 CFLAGS 没生效后面会连 bootblock 一起翻车。3. 在本地跑起来qemu 环境搭建与 Makefile 参数实战读代码永远不如跑起来有感觉。xv6-rev5 是 2006 年左右的教学版本面向 32 位 x86工具链版本的兼容性比现代 Linux 上的项目要敏感得多。这一章给你一套我在两个发行版上反复用过的流程照着做能一次跑通。3.1 环境准备32 位工具链、qemu 与常见版本坑我建议在 Debian 系发行版上操作包管理最省事。需要的核心包是build-essential、gcc-multilib、gdb、qemu-system-i386。重点是这个gcc-multilib它提供 32 位库和 32 位链接支持没有它你会遇到一堆找不到头文件的错。sudo apt update sudo apt install build-essential gcc-multilib gdb qemu-system-i386如果你是非 Debian 系或者 macOSx86 的 32 位工具链会难办一些直接考虑在虚拟机里装个 Debian 系再继续省得在宿主环境上折腾半小时。QEMU 版本推荐 2.0 以上、7.0 以下太老不支持-serial mon:stdio的参数写法太新偶尔对老内核的设备模型有差异。实际用下来5.x 系列的 QEMU 跑 xv6-rev5 最稳。3.2 编译三步走make clean 到 mach qemu我一般按这个顺序来保证用的是干净产物tar xzf xv6-rev5.tar.gz cd xv6-rev5 make clean makemake clean在换编译器、换 Makefile 之后尤其重要。xv6-rev5 的Makefile没有现代内核那么完善的依赖追踪某个头文件变了对应的.o可能不会自动重建手动make clean是唯一的后悔药。编译输出会显示 gcc 编译每个.c文件最后 ld 链接生成kernel再调用mkfs生成fs.img。注意看Makefile里这一段CFLAGS -fno-pic -static -fno-builtin -fno-strict-aliasing -fvar-tracking -fno-omit-frame-pointer -ggdb -m32 -g -Wno-error-m32指定生成 32 位代码-fno-pic避免位置无关代码产生的地址混淆-fno-builtin防止 gcc 把memcpy这类调用优化成内建函数导致链接符号缺失。-ggdb生成完整调试信息后面你用 gdb 看内核变量就靠它。如果你用的是 gcc 8 以上建议在 CFLAGS 里补一个-fcommon不然链接阶段可能报一堆 symbol 重定义具体原因见第 5 章。3.3 运行、挂载文件系统与常用命编译完成后启动make qemumake qemu对外层执行的是qemu-system-i386加一串参数。实际展开大概是这样qemu-system-i386 \ -serial mon:stdio \ -drive filefs.img,index0,mediadisk,formatraw \ -smp 1 \-serial mon:stdio的意思是把串口输出重定向到当前终端同时开启 monitor 复用同一个终端。这样 xv6 的uartinit初始化完成后你就能看到init: starting sh的提示直接进入 shell。如果没有这一行内核输出会跑到 QEMU 的图形窗口里在无桌面的服务器上就什么都看不到。进入 shell 以后可以试试ls查看目录cat README如果镜像里有echo helloforktest跑一个进程创建测试usertests完整测试这要跑一会儿退出 QEMU 是CtrlA然后按X不是CtrlC。很多人第一次在这里卡住猛按CtrlC只会把 QEMU 进程杀掉文件系统镜像可能没有正常关闭。xv6 的日志系统能恢复但尽量不要养成直接杀进程的习惯。4. 核心机制阅读路径进程管理、系统调用与内存布局跑起来以后再回头看代码观感完全不同。这一章按一条主线来读从进程被创建、到用户态发系统调用、到内核切页表执行最后回到用户态。4.1 proc 结构体与进程生命周期proc.h里的struct proc是整个系统最核心的数据结构占用专门的内存区域。每个进程对应一个struct proc关键字段如下struct proc { char *mem; // 用户程序起始内存 uint sz; // 用户程序占用的字节数 char *kstack; // 内核栈底 enum procstate state; // UNUSED/EMBRYO/SLEEPING/RUNNABLE/RUNNING/ZOMBIE int pid; struct proc *parent; struct trapframe *tf; // 保存中断/异常时的寄存器现场 struct context *context; // 用于切换的寄存器上下文 struct file *ofile[NOFILE]; // 打开的文件表 struct inode *cwd; // 当前工作目录 };mem和sz描述用户地址空间kstack是内核栈执行系统调用时进程在内核态就用它。context只保存被调用方保存的寄存器callee-saved因为调度切换和函数返回类似不需要保存所有寄存器。tf指向一个trapframe系统调用入口会把用户态的大部分寄存器压进去syscall返回后恢复现场用。进程从UNUSED到RUNNABLE再到ZOMBIE的生命周期迁移里最关键的是allocproc和scheduler。allocproc从ptable.proc数组里找一个空槽分配 pid初始化内核栈和 trapframe。scheduler是一个死循环不断扫ptable找到一个RUNNABLE进程后调用swtch把 CPU 上下文切过去。4.2 系统调用完整链路从 int 指令到 syscall 表用户态程序执行fork()时实际调用的是usys.S里的一段汇编T_SYSCALL 0x40 ; 64 号中断 .globl fork fork: movl $1, %eax ; 系统调用号 int $0x40 ; 触发中断 ret每个系统调用在usys.S里就是三行汇编把调用号放进eax触发int 0x40然后ret。内核侧入口在trap.c的trap函数里首先根据中断号判断如果是T_SYSCALL就调用syscall()否则就走设备中断或错误处理。syscall.c里维护着一张函数指针表static int (*syscalls[])(void) { [SYS_fork] sys_fork, [SYS_exit] sys_exit, [SYS_wait] sys_wait, [SYS_write] sys_write, ... }; void syscall(void) { int num proc-tf-eax; if (num 0 num NELEM(syscalls)) proc-tf-eax syscalls[num](); }这里有个设计点值得注意eax在调用前放的是系统调用号在内核处理完以后又被改写成返回值。所以用户态的fork()汇编代码在int返回后不需要再动寄存器eax里就是返回值。这个结构你在加自己的系统调用时会直接用上。4.3 虚拟内存从 kalloc 到 uvmalloc 的映射关系kalloc.c实现的是物理页分配器kalloc()返回一个物理页的地址内部就是维护一个空闲页链表。而上层vm.c负责页表。entry.S最开始用的是一个内部临时页表main.c里调用vminit()建立起内核本身的页表。之后userinit创建第一个用户进程时走的是uvmcreate、uvmalloc这条路径pde_t* uvmcreate(void) { pde_t *pgdir kalloc(); memset(pgdir, 0, PGSIZE); return pgdir; } int uvmalloc(pde_t *pgdir, char *oldsz, char *newsz) { char *a oldsz; for (; a newsz; a PGSIZE) mappages(pgdir, a, PGSIZE, kalloc(), PTE_W|PTE_U); return newsz; }uvmalloc的逻辑很简单把用户程序要用的虚拟地址区间按页对齐每页申请一块物理内存再映射进去。所以 xv6 的用户地址空间中没有独立堆段和栈段的概念统一被当作连续扩展的段来管理sz字段就是这个段的边界。你读到这里时可以和现代 os 的 brk、mmap 对比一下体会教学内核的取舍简单但够用。5. 避坑常见问题排查编译、运行、调试的五个真实场景这套源码我前后在不同机器上装过五六次每次踩的坑都高度重复。下面五条按出现频率排序每一条都是实际遇到过、能直接照着解决的。5.1 编译报错 undefined reference高版本 gcc 的默认开关变化现象make到链接阶段报一堆undefined reference to main或者符号重定义的错但你明明看到main.c编译成功了。原因gcc 10 以后默认开了-fno-common多个编译单元里有同名弱符号时直接当作重定义错误。xv6-rev5 的代码写于 gcc 4.x 时代部分文件之间有隐式的外部符号依赖在旧规则下没问题新规则下就爆了。解决打开Makefile在 CFLAGS 里加一个-fcommon然后make clean make。我一开始不知道这个原因还以为是代码缺了头文件白白查了半小时。5.2 32 位库缺失stdint.h/stdio.h 找不到现象编译时报fatal error: stdio.h: No such file or directory或者链接错cannot find crt1.o。原因现代发行版默认只带 64 位运行库32 位编译需要额外的 multilib 支持。单独装gcc不够没有 32 位版本的 glibc 头文件和启动文件。解决Debian 系执行sudo apt install gcc-multilib。装完再编译stdio.h这类头文件就能找到了。如果你用的是最小化安装的容器可能还需要libc6-dev-i386建议两个一起装。5.3 qemu 黑屏或无串口输出现象make qemu执行后弹出一个图形窗口但里面是黑的终端也没有任何 shell 输出。原因老版本 xv6 的 Makefile 默认启动图形输出而新 qemu 对老显卡模型的渲染有问题或者你跑在无桌面的 SSH 环境里图形窗口本身就是无效的。解决把Makefile里的QEMUOPTS里的图形选项去掉改用-nographic或者-serial mon:stdio。我最常用的启动参数是qemu-system-i386 -serial mon:stdio -drive filefs.img,index0,mediadisk,formatraw加上-nographic可以避免图形窗口占用键盘焦点。如果你还需要 monitor 以CtrlA切换。5.4 改完代码不生效未 make clean 导致的陈旧对象现象你改了个.c文件里的一个变量make qemu后看到的还是旧行为甚至编译日志显示gcc根本没重新执行。原因xv6-rev5 的 Makefile 依赖规则比较原始对头文件的变化不敏感某些情况下.o没有因为.c的 mtime 变化而重建比如系统时间戳异常。解决养成习惯修改任何头文件之后都执行make clean make不要偷懒只按make。从那次在proc.h里加字段后一切看起来没变浪费了半小时排查之后我规定自己每次改动都强制走一遍 clean。这个习惯一直保留到现在。5.5 gdb 断点全 miss内核符号地址与调试参数对不上现象gdb打断点在maincontinue之后没有命中程序跑飞。原因最常见的是两个来源混淆一是 QEMU 的-smp多核启动导致入口执行次数和你预期不同二是 gdb 默认开启了地址随机化或者你没有加载正确的符号文件。解决调试时直接启动内核不接外部引导gdb kernel (gdb) target remote localhost:1234 (gdb) set disable-randomization on (gdb) b main (gdb) continue如果断点还是不中用nm kernel | grep main拿实际地址人肉对比一下 gdb 报的地址是否一致。多数情况下问题出在你连的 QEMU 实例不是同一个内核镜像。6. 进阶玩法给它加一个系统调用再全程 gdb 追踪阅读到这一步你对 xv6-rev5 的结构已经有全局手感了。这个教学内核最大的优势是可以随便改、随便断电、随便加功能。下面这个加系统调用的操作你把五个文件走一遍等于把整个内核的调用链焊进记忆里。6.1 从 0 加到 1自定义系统调用的五个文件修改点假设我要加一个hello()系统调用功能是往控制台输出一行字。需要改五处第一处syscall.h加编号#define SYS_hello 22 // 接在现有最后一个编号后面第二处sysproc.c加实现int sys_hello(void) { cprintf(hello from kernel\n); return 0; }第三处syscall.c加声明和表项注意 extern 别漏extern int sys_hello(void); static int (*syscalls[])(void) { ... [SYS_hello] sys_hello, };第四处usys.S加用户态入口.globl hello hello: movl $22, %eax int $0x40 ret第五处写一个调用它的用户程序放进程镜像里。hello.c里直接hello()然后Makefile的 UPROGS 加一行_hello。重新make clean make qemu进 shell 输入hello就能看到内核返回的那行输出。这一步里最容易翻车的是第三处的 extern 声明忘记写链接时符号表少一个入口报错在 link 阶段。以后每次加调用先在这几个文件上花两分钟走一遍序效率高很多。6.2 用 qemugdb 单步看一遍上下文切换上下文切换是内核里最容易被一张图带过、不看实际执行就永远理解不了的部分。用 gdb 跟踪一次fork出来的子进程调度qemu-system-i386 -s -S -serial mon:stdio \ -drive filefs.img,index0,mediadisk,formatraw另一个终端进 gdbtarget remote localhost:1234在scheduler和swtch各下断点。swtch实际就是几条寄存器保存和恢复指令把esp切换到新进程的内核栈上。单步到ret指令时你会看到eip跳到子进程的 fork 返回处那个瞬间就是进程切换最本质的物理动作。追踪完整个流程后再来回读proc.c会通透得多。6.3 写一个用户程序挂进 xv6 并作为 init 启动把自定义程序直接作为 init 启动是验证整套工具链最快的方式。你只需要在Makefile里把UPROGS里的_init临时替换成你自己的程序名再重新make。启动后看到 shell 提示符之前你的程序先跑起来说明内核加载、页表映射、系统调用三件事都正常。这套玩法很适合做课程实验我后来做调度算法对比都是在这个基础上改的省掉了很多重新造轮子的时间。从那以后我每拿到一套老内核代码第一件事就是make clean第二件事就是跑一遍usertests把底摸清楚改动之前先把 gdb 断点验证一遍。这几乎是所有内核实验的通用开工仪式。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站