
简介哈工大操作系统实验配套资料面向正在学习操作系统原理、需要完成李治军教授实验课程的学生。压缩包共245个文件约21.89MB以c源文件、h头文件、o目标文件及makefile构建脚本为主含内核编译所需的静态库与汇编启动文件其中c与h为实验源码makefile与a为构建脚本和库文件o为中间产物可支撑实验环境的整体构建。已有3463人学习下载是广受认可的实践参考。内容覆盖进程管理与同步、虚拟内存页面替换、文件系统模拟、磁盘调度、CPU调度以及死锁避免等实验要点具体涉及生产者消费者问题的信号量实现、LRU与FIFO页面置换、FCFS/SJF/RR调度策略、银行家算法等典型场景可帮助读者对照实验手册逐步完成代码调试在动手过程中加深对操作系统工作机制和设计思想的理解。1. 这套操作系统实验到底在练什么从裸机到内核的主线如果你手头拿到了某高校的操作系统实验系列你大概率会先被它的“硬核”吓一跳不是写几个系统调用就算完事而是从一块什么都没有的裸机开始自己写 boot、自己建中断描述符表、自己写第一个进程调度器。这套操作系统实验的目标非常直接——让你用 C 和汇编在一个模拟的 x86 机器上把“操作系统”这三个字重新造一遍。它解决的痛点是普通课程只让你看到操作系统长什么样而这套实验逼你亲手把它造出来。适合谁两类人。一类是想把进程、内存、文件系统从“背书”变成“有手感”的学生另一类是工作后想补内核底子的开发者——哪怕你日常写业务代码这套实验也能让你真正理解“程序是怎么被加载、切换、保护起来的”。我见过太多人刷完这套实验后回头再看系统调用、并发、内存泄漏视角完全不一样。这套实验最大的价值在于“主线完整”不是孤立地做几个 lab而是从开机第一行汇编到文件系统挂载你写的每一段代码都在为下一个实验铺路。所以别把它当作业刷把它当一个 mini 内核的连续剧来追。2. 实验环境搭建与总体框架QEMU 与交叉编译的最小闭环2.1 环境选型为什么是 QEMU 而不是真机或虚拟机做这套实验第一件事是解决“在哪跑”。常见选择有三个真机、通用虚拟机、模拟器。真机方案最硬核但也最折腾断电一次可能就要重新烧盘通用虚拟机比如桌面虚拟化软件能跑但调试手段弱断点一停整个 GUI 都卡住我一般会用 QEMU——它是纯软件模拟启动快、可重复、支持 GDB 远程调试还能随时 dump 内存和磁盘镜像。这套实验的绝大部分代码是 C 和 32 位汇编编译目标是 x86 裸机环境所以不能直接用系统默认的 gcc 链出普通可执行文件而要用gcc -m32 -fno-pie -fno-stack-protector这类参数配合自定义链接脚本把内核放到指定的内存地址上。工具链在 Ubuntu 下一条命令就能装齐sudo apt install qemu-system-x86 gcc-multilib gdb make binutils装完验证一下版本是不是都能用qemu-system-i386 --version和gcc -m32 --version。如果 gcc 报找不到头文件说明缺gcc-multilib补上就好。2.2 第一行汇编从 BIOS 到保护模式的跳转实验的第一个里程碑不是打印 hello world而是让 CPU 进入 32 位保护模式。如果你跳过这一步直接写 C 内核会发现所有指针访问都是错的。原因是 x86 上电后处于实模式只有 1MB 寻址空间而我们的内核想放在 1MB 以上的位置所以必须先把控制寄存器 CR0 的保护位打开。.section .text .code16 .global _start _start: cli # 关中断避免切换过程被打断 movw %cs, %ax movw %ax, %ds movw %ax, %ss movw $0x7c00, %sp # boot 段临时栈 lgdt gdt_desc # 加载全局描述符表 movl %cr0, %eax orl $1, %eax movl %eax, %cr0 # CR0 最低位置 1进入保护模式 ljmp $0x08, $protected_mode # 刷新流水线跳到 32 位代码段 .code32 protected_mode: movw $0x10, %ax movw %ax, %ds movw %ax, %es movw %ax, %ss movl $0x100000, %esp # 内核栈放到 1MB 处 call kernel_main halt: hlt jmp halt这段汇编有几个容易错的地方。cli必须放在最前否则切换过程中一个中断进来CPU 状态会乱掉。lgdt gdt_desc加载的是 GDT 表的位置和长度GDT 表本身要在数据段里定义好段选择子 0x08 对应内核代码段0x10 对应数据段。ljmp是远跳转强制 CPU 用新的段选择子重新加载 CS这一步不能省不然保护模式后的地址翻译还是会出错。2.3 链接脚本与反汇编验证你写的代码到底被放到了哪里C 内核编译出来之后链接地址必须和 boot 跳转的目标地址一致。这套实验默认把内核放在 0x100000也就是 1MB 的位置。如果你用默认链接参数链接器会把代码放在 0x08048000 之类的位置加载进去必挂。解决办法是写一个链接脚本把虚拟地址和加载地址都指到 0x100000OUTPUT_FORMAT(elf32-i386) ENTRY(_start) SECTIONS { . 0x100000; .text : { *(.text) } .data : { *(.data) } .bss : { *(.bss) } }编译命令建议写成 Makefile核心是这三条gcc -m32 -fno-pie -fno-stack-protector -c kernel.c -o kernel.o ld -m elf_i386 -T linker.ld kernel.o -o kernel.elf objcopy -O binary kernel.elf kernel.binobjcopy这一步很多人会漏。生成纯二进制是为了让 boot 直接加载到内存而 ELF 格式带文件头不能直接当裸机镜像用。每次编译完用objdump -d kernel.elf看一眼反汇编确认入口地址是 0x100000 而不是别的值——这一步能帮你排除一大半“代码看着对但跑不起来”的问题。3. 时钟中断与进程调度让内核从“跑起来”到“转起来”3.1 为什么第二个核心实验是时钟中断Boot 跑通之后下一个自然的问题是操作系统凭什么能“同时”跑多个程序答案不是靠 CPU 真的并行而是靠时钟中断不断打断 CPU让它轮流执行每个任务。这套实验把时钟中断放在进程调度之前意图非常明确先把“打断”机制做好再谈“切换”。时钟中断的硬件基础是 Intel 8253/8254 可编程定时器它每隔一个固定周期向 CPU 发一次中断。操作系统在中断处理函数里保存当前任务现场、挑选下一个任务、恢复那个任务的现场——这就是调度器的全部原理。没有时钟中断调度器永远没有机会执行进程切换就是空谈。3.2 实现时钟中断处理的最小代码常见做法是写一个timer.c初始化定时器频率然后在 IDT中断描述符表里挂上处理函数。下面是最小实现#include timer.h #include idt.h #include io.h #define PIT_FREQ 1193180 // 8253 的输入频率 #define TIMER_IRQ 0 // 时钟中断对应 IRQ0 // 初始化 PIT 和 IDT 表项 void timer_init(uint32_t frequency) { uint32_t divisor PIT_FREQ / frequency; // 向 8253 发送控制字和分频值 outb(0x43, 0x36); // 通道 0先低后高方波模式 outb(0x40, divisor 0xFF); outb(0x40, (divisor 8) 0xFF); // 在 IDT 的 32 号中断IRQ0挂上处理函数 idt_set_gate(32, (uint32_t)timer_handler, 0x08, 0x8E); } // 中断处理函数C 层入口 void timer_handler(void) { // 通知 8259A 中断控制器处理完毕 outb(0x20, 0x20); // 这里将来自动被调度器使用 scheduler_tick(); }outb(0x20, 0x20)是向主 8259A 发送 EOIEnd of Interrupt命令。这一步漏了中断标志会一直被占用后续所有中断都会被屏蔽表现为系统“卡死”。idt_set_gate的第三个参数 0x08 是段选择子第四个参数 0x8E 是中断门属性表示特权级 0。这几个参数看着简单错一个就是黑屏或三重故障。3.3 调度器设计时间片轮转的边界参数中断能响了接下来就是调度器。这套实验通常要求实现时间片轮转每个任务运行一段时间后被时钟中断强制让出 CPU。调度器核心结构是一个任务控制块数组里面保存了任务的上下文和状态typedef struct task { uint32_t esp; // 内核栈指针 uint32_t eip; // 下一条指令地址 uint32_t state; // 0空闲, 1运行, 2就绪 uint32_t time_count; // 剩余时间片 uint32_t time_slice; // 时间片长度 } task_t; // 在时钟中断中调用 void scheduler_tick(void) { current-time_count--; if (current-time_count 0) { current-time_count current-time_slice; schedule(); // 换下一个任务运行 } }时间片的取值是个经典参数问题。设得太短任务频繁切换系统 70% 的时间都在保存和恢复现场设得太长交互体验差用户按一下键半天没反应。常见做法是把时间片设成 10ms 到 30ms 之间对应 100Hz 到 33Hz 的时钟频率。我一般用 100Hz 配合 3 个时间片的轮转兼顾响应速度和切换开销。切换本质是“让出 CPU”和“重新上 CPU”两个动作核心代码是保存当前任务的栈指针和指令地址再加载新任务的void switch_to(task_t *next) { // 保存当前任务的 ESP 和 EIP asm volatile(movl %%esp, %0 : r(current-esp)); asm volatile(movl $1f, %0 : r(current-eip)); // 加载新任务的 ESP 和 EIP current next; asm volatile(movl %0, %%esp :: r(next-esp)); asm volatile(movl %0, %%eax :: r(next-eip)); asm volatile(pushl %eax); asm volatile(ret); 1: // 新任务恢复后从这里继续 }这个写法有个隐含前提每个任务必须有自己独立的内核栈。如果你把所有任务共用一个栈僵局就是新任务第一次切换时发现自己把自己压栈覆盖了。还有一个细节是1f标签它保证中断返回后能回到正确的位置而不是跳到任意地方。任务创建的时候要提前在任务的内核栈里伪造好初始现场栈顶放一个iret返回地址再放初始的标志寄存器、CS、EIP这样第一次调度时就能“假装”任务是从一个中断返回开始执行的。这一步是新手最容易困惑的地方——不是真的执行了任务而是通过伪造返回现场让 CPU“以为”任务早就运行过了。4. 内存管理与文件系统把地址空间和磁盘盘块管起来4.1 内存管理实验页表建立与缺页处理调度器能让多个任务轮流跑但如果每个任务都能直接读写物理内存那任务 A 既能踩掉任务 B 的数据也能破坏内核自己的代码。这个实验的核心是给每个任务私有的地址空间用页表把虚拟地址映射到物理页。x86 保护模式下用的是两级页表页目录表PDT和页表PT。每个任务都有一个页目录表基址CR3 寄存器指向当前任务的页目录表任务切换时 CR3 一换整个地址空间就换了。页表项的格式是 32 位低 12 位是标志位高 20 位是物理页号#define PAGE_PRESENT 0x1 #define PAGE_WRITE 0x2 #define PAGE_USER 0x4 // 建立从虚拟地址 va 到物理地址 pa 的映射 void map_page(uint32_t *pd, uint32_t *pt, uint32_t va, uint32_t pa) { uint32_t pd_idx va 22; uint32_t pt_idx (va 12) 0x3FF; // 页目录表项不存在时先分配一个页表 if (!(pd[pd_idx] PAGE_PRESENT)) { uint32_t new_pt alloc_page(); pd[pd_idx] new_pt | PAGE_PRESENT | PAGE_WRITE; } uint32_t pt_base pd[pd_idx] 0xFFFFF000; uint32_t *pt_entry (uint32_t *)pt_base; pt_entry[pt_idx] (pa 0xFFFFF000) | PAGE_PRESENT | PAGE_WRITE | PAGE_USER; // 刷新 TLB否则旧映射可能还缓存着 asm volatile(invlpg (%0) :: r(va)); }pd_idx的高 10 位和pt_idx的中 10 位一拼就是完整的虚拟地址拆分。如果任务访问了一个没有建立映射的地址CPU 会触发缺页异常中断号 14你需要写一个page_fault_handler在里面判断这个地址是不是合法——比如任务栈增长需要的页面就分配物理页并建立映射是非法访问就杀掉任务。初学者最容易漏的是 TLB 刷新invlpg只刷一个页面的缓存如果你图省事用mov cr3, eax全刷性能会很难看。4.2 文件系统实验磁盘块分配与目录项的读写内存管好了下一个实验是文件系统。这套实验里文件系统通常不复杂不需要日志和事务但核心结构必须有超级块、块位图、索引节点inode和目录项。磁盘镜像是一个普通二进制文件QEMU 把它当硬盘传给模拟器你的内核通过读写端口访问它。块分配是这个实验的关键点。磁盘按固定大小的块常见是 512 字节或 1024 字节划分位图里每个 bit 表示一个块是否空闲。分配逻辑就是扫描位图找空闲块#define BLOCK_SIZE 512 #define BITMAP_BLOCK 1 // 位图放在第 1 块 #define INODE_BLOCK 2 // inode 区从第 2 块开始 // 分配一个物理块返回块号 uint32_t alloc_block(uint32_t *bitmap) { for (uint32_t i 0; i BLOCK_SIZE * 8; i) { uint32_t byte i / 8; uint32_t bit i % 8; if (!(bitmap[byte] (1 bit))) { bitmap[byte] | (1 bit); return i; } } return -1; // 磁盘已满 }目录项的设计也有讲究。每个目录项保存文件名和 inode 号最简单的格式是定长的#define FILENAME_LEN 28 typedef struct dirent { uint32_t inode; char name[FILENAME_LEN]; } dirent_t; // 查找目录下的某个文件 uint32_t find_inode(dirent_t *dir, char *name) { for (int i 0; i MAX_DIR_ENTRIES; i) { if (dir[i].inode ! 0 strcmp(dir[i].name, name) 0) { return dir[i].inode; } } return 0; }我强烈建议把目录项设计成定长结构不要用链表或变长否则一个目录块里能放多少文件、翻页怎么处理都会变成噩梦。这套实验的文件系统本质是“能读写就行”不要为了炫技引入 B 树。工作量已经不小了保证读写正确比追求 fancy 的数据结构更重要。4.3 两个实验的联动什么情况下文件系统依赖内存管理文件系统和内存管理看起来是两座孤岛但有几个点必须联动。第一磁盘镜像的读写不是直接的你要在内存里维护一个磁盘缓存比如把位图块、inode 块读入内存再修改最后统一写回。你操作的是内存缓冲区不是磁盘本身——这就依赖内存管理模块提供临时缓冲区。第二进程打开文件时文件描述符表、inode 表、dentry 缓存都要占用内核内存如果alloc_page写得不够好开几个文件系统就崩了。常见的坑是打开文件时分配了一堆内存关闭文件时忘了释放导致内核内存越用越少。调试这类问题不需要复杂的工具在alloc_page里加一个全局计数器每次分配 1 释放 -1跑完一轮操作后打印计数——如果只增不减就是泄漏点。这个小技巧能省你一整晚的排查时间。5. 操作系统实验避坑指南5 个让你排查到深夜的经典坑5.1 现象QEMU 启动后黑屏没有任何输出原因链接脚本里的地址和 boot 跳转地址不一致或者objcopy转二进制时把 ELF 头带进去了。这是这套实验出现频率最高的翻车现场而且报错信息几乎没有。解决先objdump -d kernel.elf看入口是不是 0x100000再看 boot 的远跳转目标是不是同一个地址。如果 boot 跳的是 0x200000而链接脚本用的是 0x100000CPU 跳过去执行的是内存里的随机数据表现就是黑屏。把链接脚本的. 0x100000;和 boot 里的ljmp目标地址对齐再试。5.2 现象时钟中断一触发系统立刻进入死循环原因没有向 8259A 发送 EOI。中断处理函数执行完毕后8259A 还在等待你告诉它“处理完了”如果没有这条outb(0x20, 0x20)同级别甚至更低级别的中断都会被屏蔽。另一个常见原因是 IDT 表项的类型属性写错比如把中断门写成了陷阱门标志位的差异会导致 CPU 对 IF 标志的处理不同。解决在timer_handler的第一行就发送 EOI不要放在最后——如果中间某行代码出问题EOI 根本没机会执行。另外检查 IDT 表项属性是不是 0x8E不是的话中断触发不了或者触发后直接 triple fault。5.3 现象调度器切换几次后一个任务崩溃另一个任务数据被污染原因任务上下文保存不完整。切换时只保存了 ESP 和 EIP但通用寄存器、段寄存器、标志寄存器都没保存。CPU 的原生寄存器是共享资源任务切换必须把全部寄存器都保存到 TCB 里。解决扩展 TCB把 EAX、EBX、ECX、EDX、ESI、EDI、EBP 全部加进去。切换代码改成先压栈全部寄存器再切换栈然后弹栈全部寄存器。哪怕你引入一个新任务看起来能跑几十毫秒寄存器不完整总会是一个随机炸弹。5.4 现象文件系统写入了但重启后数据全没原因文件系统的写操作只改了内存缓冲区没及时刷回磁盘镜像。QEMU 的磁盘只是一个镜像文件如果内核在退出时没有把你所有修改写回关机即丢失。解决在文件系统的sync接口里把位图块、inode 区和数据块全部写回。写回时机有两个选择每个操作完成后立即写简单但慢或者维护一个脏块标记在退出时统一写回。建议先做第一种正确性优先——你的实验不差那几百毫秒的写入时间。5.5 现象make run 正常但用 GDB 调试时代码行和源码对不上原因编译时带了-O2甚至-O3优化。优化器会把代码重排、内联、合并导致断点打不到预期位置单步执行时跳来跳去。解决调试期间编译选项统一用-O0 -g -fno-omit-frame-pointer。-O0关闭优化-g生成调试信息-fno-omit-frame-pointer保留帧指针这样 GDB 的bt才能打出正确的调用栈。框架代码里的 Makefile 通常会默认开优化你需要自己改掉这套实验填坑的第一件事就是和优化选项斗争。6. 验证与进阶用 trace 和内核日志把实验做扎实框架给你跑通了但怎么确定你的调度器真的在正确工作建议你一上来就建立一套“可观测性”机制。我的习惯是写一个printk风格的日志函数带等级和模块名然后在调度切换、内存分配、文件读写三个关键路径上打点。日志放到环形缓冲区里崩溃后还能 dump 出来看最后发生了什么事——这比盯着黑屏猜原因强一百倍。验证清单按这个顺序做。第一步启动日志确认进入保护模式、加载 IDT、初始化内存管理、挂载文件系统每一步都有日志输出。第二步调度验证创建两个任务每个任务打印自己的 ID 和运行计数跑十几秒检查次数是否大致均衡——如果不均衡不是时间片设置有问题就是任务状态管理有 bug。第三步内存验证写一个小程序反复申请和释放内存打印分配前后的空闲页数确保没有泄漏。第四步文件系统验证创建文件、写入一段数据、关镜像、重新启动、再读出来比对内容。这四步做完整你的实验基本就是结实的。进阶方向有两个值得投入。第一个是把 shell 和文件系统打通让用户能在 shell 里输入命令比如ls列出目录、cat读文件、exec加载一个程序。这能让你的内核真正“可用”而不只是一堆硬件的驱动拼盘。第二个是给调度器加优先级实现一个以优先级为基础的多级反馈队列让交互型任务优先运行、计算型任务自动降级。这套逻辑放到现在的面试里也是个能讲半小时的话题。我自己的体会是这套实验最难的不是某一章而是坚持在每一步都做验证。有个同学每次都“攒着一起调试”结果三四个功能叠加出问题根本定位不了。反而是每加一个功能就跑一遍日志看着一行行输出从无到有那种掌控感是这门课最宝贵的东西。希望这篇笔记能帮你少走些弯路把这套实验做得更透。本文还有配套的精品资源点击获取