资讯动态

手写Linux 0.11内核:从引导到文件系统的八大实验实战指南

发布时间:2026/9/8 10:59:15 来源:尧图企业网站定制
简介哈工大操作系统实验李治军教授配套资源包覆盖进程管理、内存管理、文件系统、设备管理、进程调度与死锁预防等核心实验理论与实践结合适合正在学习操作系统原理并希望动手实践的高校学生、考研复试者及自学者。资源共245个文件既包含88个C源码、45个头文件和61个目标文件也配有makefile、汇编、文本等辅助说明整体压缩包约21.89MB便于按实验模块逐一查阅与复现。已有3447人学习使用。实验内容从P/V操作解决生产者消费者等经典并发问题到实现虚拟内存、LRU/FIFO页面替换与文件系统模拟再到模拟磁盘调度和FCFS、SJF、时间片轮转等CPU调度算法并通过银行家算法处理死锁预防最终综合实现一个简单系统内核能够帮助学习者深入理解操作系统底层机制显著提升编码、调试与系统分析能力。 很多人学操作系统是靠背教材把进程、调度、虚拟内存这些概念背得滚瓜烂熟但合上书之后对操作系统依然没有那种“这东西是我亲手做出来的”的实感。如果你也有这种感觉或者你正在准备考研复试、校招面试想找一个能真正拉开差距的实战项目李治军老师这套哈工大操作系统实验大概率是目前国内高校里最值得投入时间的一套内核实战训练。它不是让你用Python模拟一个假操作系统而是让你直接面对Linux 0.11的真实内核源码从开机引导那一刻开始自己动手改内核、加系统调用、实现进程切换、写信号量、做调度、搞文件系统。整套实验跑下来你对操作系统的理解深度和只看书完全是两个维度。这套实验对应的慕课《操作系统》在多个平台都能找到配套的实验文档和源码也是公开的。适合的人群很明确正在学操作系统的本科生、准备考研复试的考生、想补内核知识的后端开发、以及对计算机底层机制有好奇心的任何开发者。零基础能不能做能做但需要一点C语言和汇编的基础后面我会详细说哪些坎最容易被卡住。1. 李治军这套实验的含金量在哪里课程结构与实验总览1.1 为什么偏偏选Linux 0.11这个“古董”内核现在让你去读Linux 6.x的内核源码别说改代码了光是理解sys_call_table的间接调用、RCU锁、CFS调度器的红黑树就足够让新手劝退。但Linux 0.11不一样它是1991年Linus写出的早期内核全部代码量只有两万行左右而且结构非常干净没有现代内核里那些为了性能引入的复杂机制。它保留了操作系统最核心的骨架进程管理、内存管理、文件系统、设备驱动每一项都能单独拎出来阅读。更重要的是李治军老师的实验设计不是让你读代码而是让你在理解之后改动它。比如实验一要求你修改bootsect.s引导程序实验二要求你往内核里添加系统调用实验三要求你跟踪进程状态变化实验四要求你把TSS进程切换改成内核栈切换实验五要求你自己实现信号量实验六要求你实现新的调度算法实验七做地址映射实验实验八做文件系统。每一组实验都在逼迫你回答一个问题这个机制到底是怎么工作的如果你不知道怎么让它工作你就没法改。1.2 八个实验的整体脉络实验编号主题核心工作难度评估实验0环境搭建安装Bochs编译内核跑通Linux 0.11低实验1系统引导修改bootsect.s理解启动流程中等实验2系统调用新增两个系统调用走通用户态-内核态切换中等实验3进程轨迹跟踪修改内核打印进程状态变化日志中等实验4进程切换用内核栈切换替换TSS切换难实验5信号量实现信号量机制解决生产者消费者问题中等偏难实验6进程调度实现优先级调度算法中等实验7地址映射理解页表映射与共享内存中等偏难实验8文件系统实现文件读写相关功能难很多人被实验4劝退因为它涉及汇编级别的上下文切换需要你真正理解内核栈和用户栈的切换时机。但反过来讲把实验4啃下来的人对进程切换的理解会达到一个相当深的程度面试聊到上下文切换时你能画出完整的栈帧变化图这种深度是从博客和面经里学不到的。2. 实验环境准备Bochs与Linux 0.11编译链那些年踩过的坑2.1 用Bochs而不是QEMU的理由哈工大这套实验官方推荐的是Bochs虚拟机而不是QEMU。很多人不理解QEMU性能更好、调试工具也更现代为什么实验文档偏偏选了Bochs原因在于Bochs内置的调试器对实模式和保护模式下的内存查看支持非常细粒度而且它的指令级调试方式更适合教学场景。你在Bochs里可以单步执行引导扇区代码实时查看寄存器、内存和页表的变化这对理解系统引导阶段在做什么帮助极大。装Bochs的步骤不复杂Linux发行版直接装包就行sudo apt-get install bochs bochs-x bochs-sdl装完之后需要写一个配置文件指定内存大小、启动盘镜像、显示模式。实验文档里有一个标准的bochsrc文件直接复制过来改下路径就能跑。需要注意的一点是Bochs的SDL显示模式如果出现问题可以尝试用x11模式启动二者在跑实验时没有本质区别。2.2 编译Linux 0.11的最大坑工具链版本这是整套实验里最先出现的、也最让人崩溃的问题。Linux 0.11是1991年的代码它对编译器的要求是gcc 1.40左右的老版本而现代系统上装的是gcc 11、12甚至13。直接用新版gcc编译Linux 0.11会爆出一大堆错误主要是内联汇编语法不兼容、隐式声明规则改变、以及一些老式的C语言写法被新标准拒绝。正确的做法是用课程提供好的交叉编译环境我记得实验文档里提供了一个build目录里面带了老版本的gcc和binutils。如果你拿到的是新版本的实验包通常已经预置了这些工具直接执行make就开始编译。如果自己从网上找源码编译最稳妥的方式是在Docker容器里装一个Ubuntu 16.04或CentOS 6等老系统然后用系统自带的gcc来编。还有一个很容易忽略的点编译Linux 0.11必须用32位的工具链。在64位宿主系统上编译需要安装gcc-multilib之类的32位支持包否则链接阶段会提示cannot find -lgcc或类似的错误。2.3 跑通第一个内核的验证方法编译成功之后生成的是一个Image文件这是整个内核的镜像。实验文档提供的标准做法是用一个工具脚本把Image写入软盘镜像然后用Bochs从这个软盘镜像启动dd ifImage ofboot.img bs512 count2880启动之后如果一切正常Bochs窗口里会出现Linux 0.11的启动日志最后停在login提示符。看到这个画面你的实验环境就算搭好了。我当时的做法是先在Linux 0.11里随便敲几条命令比如ls、pwd、cat /etc/rc感受一下这个最原始的系统。这种“自己编译的内核成功开机”的成就感是支撑你继续做后面几个实验的重要动力。3. 系统调用与进程切换最值得死磕的两个实验3.1 实验2一个系统调用从用户态到内核态的完整旅程实验2要求你添加两个系统调用核心目的是让你理解操作系统到底是怎么响应一个系统调用的。实现步骤并不复杂在include/unistd.h中定义系统调用号在include/linux/sys.h中声明sys_iam和sys_whoami函数并加入到sys_call_table数组在kernel/system_call.s中调整系统调用总数最后在kernel/下新建一个文件实现这两个函数。但真正有价值的是理解这条链路上每一步发生了什么。当用户程序调用syscall(2, ...)或包装好的_syscall2宏展开后实质上是把系统调用号放入eax寄存器执行int 0x80触发软中断CPU自动从用户态切换到内核态根据IDT表找到system_call入口内核根据eax中的系统调用号索引sys_call_table找到对应函数执行sys_iam或sys_whoami返回值放入eax执行iret返回用户态这个过程中你最需要关注的是参数怎么传递的。比如iam系统调用需要传入一个字符串指针这个指针是用户态地址空间中的有效地址。内核态虽然也能访问这个地址但初学者很容易在这里栽跟头直接在sys_iam里使用用户态指针做strcpy可能刚好能跑通但一旦用户态传入的字符串跨越了页边界就可能触发页错误。Linux 0.11里没有完整的copy_from_user机制实验也不要求你做到这一步但理解这个隐患是学习内存管理的重要铺垫。一个实操建议写sys_iam和sys_whoami时建议在内核里用一个静态缓冲区暂存字符串。对缓冲区长度做显式检查防止溢出。虽然这是实验但写内核代码的安全意识应该从第一天就开始养成。3.2 实验4为什么现代内核抛弃了TSS切换实验4是整个系列的分水岭。Linux 0.11原本使用硬件TSS任务状态段来做进程切换CPU在执行ljmp指令时会自动把当前进程的所有寄存器保存到原TSS然后从新TSS恢复寄存器。这套机制看起来方便但性能很差因为保存和恢复的寄存器中有很多是进程切换时根本用不到的而且tss切换也无法充分利用高速缓存的局部性。实验要求你实现基于内核栈的切换也就是把switch_to从依赖TSS改成手动保存和恢复现场。核心思路是将当前的esp0内核栈指针保存到当前进程PCB中把目标进程的内核栈地址加载到esp切换内核栈手动压栈/弹栈保存和恢复相关寄存器最后通过iret返回用户态实现上最绕的地方在于进程切换发生在一个进程的内核栈上你手动构造一个栈帧然后通过iret指令“假装”从中断返回从而跳转到下一个进程的用户态。这个过程中你要确保栈里压入的CS、EIP、EFLAGS、SS、ESP都是正确的。任何一处压栈顺序错误程序就会立刻崩溃而且大概率是在你完全想不到的地方崩溃。调试这类问题我建议你在switch_to里加调试输出打印切换前后的当前进程PID、esp、ebp的值通过这些日志来判断栈指针是否按预期变化。Bochs本身也支持在内存访问异常时暂停可以配合使用。4. 并发与内存实验信号量、调度和页表的实战案例4.1 实验5信号量实现中容易被忽略的原子性实验5要求用信号量解决生产者消费者问题代码层面其实不难。Linux 0.11的调度器是不可抢占的进程只在两种情况下主动让出CPU调用schedule()或者从中断/系统调用返回时检查need_resched标志。所以实现信号量的sem_wait和sem_up时你不需要像现代内核那样用自旋锁保护信号量内部的数据结构只需要保证在检查信号量值和当前进程加入等待队列这两个操作之间不会因为中断而产生竞态。实操时最稳妥的办法是在修改信号量内部状态时关中断void sem_wait(sem_t *sem) { cli(); // 关中断 sem-value--; if (sem-value 0) { // 当前进程加入等待队列然后调度 do_block(sem-queue); } sti(); // 开中断 }这里的cli/sti非常关键。如果不关中断有可能出现这种情况一个进程在sem_wait中刚检查完value为正数还没执行value--就被时钟中断打断CPU切换到另一个进程去执行。另一个进程也进入了sem_wait此时两个进程都认为资源可用信号量的互斥就失效了。另外实现do_block时要注意进程在加入等待队列后需要调用schedule()主动让出CPU但这个schedule()会保存当前进程的现场等到将来被唤醒后进程会从schedule()返回继续执行。你的代码要保证进程被唤醒后能正确执行sem_wait的剩余部分也就是把开中断的sti()放在schedule()返回之后。很多人在这里写反导致进程被唤醒后中断一直关闭系统卡死。4.2 实验6调度算法的替换不是只改一个函数实验6要求实现时间片轮转或优先级调度替换默认的调度算法。大部分人的第一反应是改schedule()函数里的选择逻辑就行。没错但这里有个隐蔽的坑Linux 0.11里进程的counter字段并不仅仅是时间片余额它同时承担了“优先级动态变化”的功能。默认情况下counter在被时钟中断处理函数递减减到0后进程被标记为need_resched。而schedule()在选择新进程时会遍历所有进程选取counter最大的那个。如果一个进程的时间片用完了counter已经变为0那它在schedule()的某些实现里依然有机会被选上这种“用时间片剩余量近似优先级”的设计是理解整个调度实验的关键。如果你想实现严格的优先级调度需要自定义一个优先级字段比如在task_struct里加一个priority然后在进程创建时设定初始值在调度函数里按照priority排序、同时让counter被功耗尽后自动转换。工业级的调度器还要考虑饥饿问题、批处理任务与交互式任务的权重分配你不需要做到那么复杂但在设计文档里把这个考量写清楚会让实验报告上一个档次。4.3 实验7地址映射实验教会我的页表思维实验7的题目是“地址映射与共享内存”有两个层次的理解。第一个层次是理解虚拟地址到物理地址的映射过程Linux 0.11用的是两级页表线性地址的高10位索引页目录中间10位索引页表低12位是页内偏移。你需要写程序或在内核里打印某个虚拟地址对应的页目录项和页表项然后手工换算成物理地址。第二个层次是实现共享内存的系统调用比如shmget和shmat。这一步的难点在于不同进程的页表是完全独立的要让两个进程共享同一块物理内存必须在这两个进程的页表里让不同的虚拟地址最终映射到同一个物理页帧。实现时你需要搞清楚怎么把一个新的物理页挂到一个进程的页表上同时还要处理引用计数和回收问题避免进程退出后共享内存没有被释放。做这个实验时我强烈建议你把实验4的进程切换知识连起来用。在共享内存里放一个变量然后让两个进程交替读写这个变量观察它们各自的虚拟地址不同、却能看到相同的数据。这种直观的感受比背十遍“进程地址空间隔离”的记忆要深刻得多。5. 文件系统实验与内核调试三板斧5.1 实验8在一个两万行的小内核里实现对文件系统的改造实验8的方向有好几个版本有的年份要求实现文件的拷入拷出有的要求扩展inode结构或实现新的文件操作。无论具体题目怎么变核心都是让你和文件系统的四个基础组件打交道超级块、inode、目录项和数据块。以最常出现的“实现文件复制”为例你要处理的无非是找到源inode对应的数据块读取数据然后创建目标inode分配新的数据块写入数据。但真正的难点往往体现在边界条件的处理上文件大小不是块大小的整数倍怎么办磁盘块用完怎么办目录项占满怎么办这些细节在教材里不会展开只有亲手写代码才能体会到。我给准备做这个实验的同学一个建议先用Linux 0.11里已有的函数比如sys_open、sys_read、sys_write梳理一遍完整的读写链路搞清楚一个read系统调用从用户态发出后是怎么走到ll_rw_block、最终通过块设备驱动读磁盘的。把这个调用链整理成一张脑图再动手写实验代码效率会高很多。5.2 没有IDE的内核开发怎么调试printk、Bochs断点与反汇编做内核实验和平时写应用开发的最大区别是你几乎不能用gdb打断点因为内核在Bochs里运行gdb的介入方式很受限。我最常用的调试手段是printk但它有个明显的缺陷printk输出需要经过内核的显示驱动而如果你的bug导致显示系统崩溃printk也会失灵。所以我的调试三板斧是在关键位置加printk输出进程PID、当前函数名和关键变量的值用Bochs内置调试器设物理断点b 0x7c00 c这行命令在引导扇区入口处打断点常用于实验1。查内存和反汇编x /10ub 0x90000 u /20 0x8048一条命令看内存一条命令反汇编用来排查栈异常和指令跳转错误。另外一个经验内核panic时的报错信息往往指向某个地址你可以在Bochs里查看eip、cr2、esp这些寄存器的值结合反汇编来定位问题。我第一次做实验4时内核每次启动到一半就crash了后来通过查cr2寄存器发现是空指针访问一路追查下去发现是switch_to里少了一次弹栈操作esp没有恢复到正确的位置。这种排查过程非常培养人每次解决的成就感也不是普通应用开发能比的。6. 做完整套实验之后我的实际体会说实话做完整套哈工大操作系统实验之后最直观的变化不是“我懂了很多名词”而是看代码的方式变了。以前看一个开源项目看到进程创建、上下文切换、文件系统这些地方可能一扫而过觉得“大概就是这个意思”。但亲手改过内核之后你会不自觉地想这里为什么用链表而不是数组这个切换为什么要保存eflagssys_read的buffer为什么需要用户态传过来的指针这些问题背后是一个具体的行为逻辑而不是抽象的概念。这套实验对面试的帮助也很直接。操作系统是后端开发面试的核心考点之一而绝大多数候选人只停留在“背八股”的层面。你如果在面试里能说出“我在Linux 0.11里用内核栈切换替代了TSS切换具体做法是把当前esp存入task_struct再恢复目标进程的esp最后通过手工构造的栈帧iret返回用户态”面试官基本会跳过操作系统的基础问题直接跟你聊项目深挖。这种深度的沟通靠背是背不出来的。最后再分享一个小技巧做实验时不要只追求“跑出正确结果”而是每次改代码前先在纸上写出你期望的流程和数据结构变化。比如做进程切换前先画出两个进程的内核栈内容、PCB指针、esp的变化图。画错了就改画对了再动手写代码。这套方法让我少踩了至少一半的坑也让我在写实验报告时思路非常清晰。这门课值得你花一个学期的时间慢慢啃认真做和赶作业完全是两种收获。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价