简介:这份资源是面向高校操作系统课程学习者与实验实践者的HNU_ostep实验合集,围绕进程管理、内存管理、文件系统与I/O系统等核心模块展开,帮助读者在动手编码中理解操作系统原理与实现机制。压缩包共收录2000个文件,以910个C源文件与760个头文件为主体,辅以Python脚本、Shell脚本、CMake与Make构建文件、Markdown与TXT说明文档,以及少量PDF、DOCX讲义资料,整体约42.51MB,目录结构便于按实验模块检索。资源中附带的代码蓝本与指导文档,可支撑读者完成进程创建与调度、虚拟内存与页面置换、文件存储与权限控制、设备管理与磁盘调度等实验任务,并通过修改调度算法等代码直观观察不同策略对执行效率的影响。目前已有165人学习,适合希望将理论知识与工程实践结合、为系统软件开发打基础的读者参考使用。
1. 操作系统实验的知识地图:从系统调用到并发控制的完整拼图
操作系统实验这门课,很多人第一反应是“玄学”——代码明明照着讲义敲了,make qemu一跑就是 triple fault,连个报错都看不懂。其实梳理下来会发现,它并不是一堆零散实验的拼盘,而是一条从“让内核跑起来”到“让多个任务安全地跑起来”的完整链路。整张地图大致分四层:最底层是启动与中断机制,往上是内存管理与进程抽象,再往上是文件系统与设备驱动,最顶层是并发与同步。每一层都依赖下一层提供的接口,跳着做实验必然翻车。这份笔记适合正在系统学操作系统、或者期末复习想快速查漏补缺的人,把散落在各个 lab 里的知识点串成一条线,而不是孤立地背每个实验的代码。
2. 启动与中断:内核是怎么“活”过来的
2.1 从加电到第一个 C 函数
操作系统实验的第一步,几乎都是让内核在模拟器里跑起来并打印一行字。这背后涉及的是启动流程:机器加电后先执行固件代码,把内核镜像加载到内存的约定地址,然后跳转到入口点。入口点通常是一段汇编,负责设置栈指针、清零 BSS 段、建立最初的页表,最后才调用 C 语言的main。梳理下来会发现,最容易忽略的是栈指针的设置——如果栈没设好,第一个函数调用就会写飞,表现为莫名其妙的死机。
以常见的 RISC-V 实验环境为例,启动汇编大致长这样:
# entry.S 片段:设置栈并跳转到 C 入口 la sp, stack_top # 把栈顶地址加载到 sp call main # 跳转到 C 语言主函数这里的stack_top是链接脚本里定义的一个符号,指向一块预留的内存区域。参数含义很直接:sp是栈指针寄存器,栈从高地址向低地址增长,所以stack_top取的是这块区域的最高地址。如果链接脚本里没预留这块空间,或者符号名写错,链接阶段就会报未定义符号,这是比较友好的失败;更麻烦的是地址重叠,编译能过但运行就崩。
2.2 中断与异常:内核的“黑匣子”入口
中断和异常是操作系统从被动执行到主动响应的转折点。外部设备通过中断线通知 CPU,程序执行出错则触发异常,两者最终都汇入同一个入口,由内核决定怎么处理。实验里通常要求实现一个中断向量表,把不同类型的异常分派到不同的处理函数。
重点在于区分“中断上下文”和“进程上下文”。中断处理程序运行时,它不属于任何一个进程,不能睡眠、不能调用可能阻塞的函数。很多同学在这里踩坑:在中断处理里直接调用printf或者申请内存,结果系统随机崩溃。正确的做法是只做最紧急的事,把剩下的工作推迟到下半部或者软中断里。
提示:调试中断问题时,先在入口处打印异常号,确认是哪种异常,再去看对应的处理逻辑,比盲目读代码快得多。
3. 内存管理:页表、映射与地址空间
3.1 页表机制与地址翻译
内存管理实验的核心是页表。虚拟地址到物理地址的翻译由硬件查页表完成,操作系统负责建立和维护页表。多级页表的设计是为了节省空间:一级页表覆盖整个地址空间,但大部分条目是空的,只有真正用到的区域才逐级展开。
梳理下来会发现,页表实验最容易错的地方是权限位。页表项里除了物理页号,还有有效位、读写位、用户态可访问位等。如果忘了设置有效位,访问该地址就会触发页错误;如果用户态程序访问了没设用户权限的页,同样会触发异常。这些异常本身不是 bug,而是机制的一部分,关键看内核怎么响应。
// 建立映射的简化逻辑 void map_page(uint64_t *pagetable, uint64_t va, uint64_t pa, int perm) { uint64_t *pte = walk(pagetable, va, 1); // 找到或创建页表项 *pte = (pa >> 12) << 10 | perm | PTE_V; // 填入物理页号、权限、有效位 }walk函数负责逐级查找页表,如果中间某级不存在就分配一个新页。perm是权限组合,PTE_V表示有效。参数里va和pa都按页对齐,低 12 位是页内偏移,不参与翻译。
3.2 地址空间与进程隔离
每个进程看到的是独立的虚拟地址空间,这是操作系统提供隔离性的基础。实验里通常要求为每个进程维护一套页表,在进程切换时切换页表基址寄存器。重点在于理解“内核映射”和“用户映射”的区别:内核代码和数据通常映射在所有进程的地址空间里,但只有在内核态才能访问;用户程序只能访问标记了用户权限的页。
最容易忽略的是页表的释放。进程退出时,如果不逐级释放页表占用的物理页,就会造成内存泄漏。实验里可以用引用计数或者直接遍历释放,但要注意别释放了共享的页。翻车现场往往是:释放完页表后,系统还能跑一会儿,然后突然崩溃,因为某个后台进程还在用被释放的页。
4. 进程与调度:从抽象到上下文切换
4.1 进程控制块与状态机
进程是操作系统里最核心的抽象。实验里通常要求实现进程控制块(PCB),记录进程的标识、状态、页表、内核栈、上下文等信息。进程的状态至少包括就绪、运行、阻塞三种,状态之间的迁移由调度器和系统调用触发。
梳理下来会发现,进程实验的难点不在数据结构定义,而在状态迁移的完整性。比如一个进程在等待 I/O 时应该进入阻塞态,I/O 完成后被唤醒回到就绪态;如果忘了唤醒,进程就永远卡住。血泪经验是:每实现一个可能阻塞的操作,都要问自己“谁负责唤醒它”。
4.2 上下文切换的时机与代价
上下文切换是调度的具体动作,保存当前进程的寄存器现场,恢复下一个进程的现场。切换的时机通常有三类:进程主动让出(如yield系统调用)、时间片用完、被更高优先级进程抢占。实验里一般用时钟中断来触发时间片轮转。
重点在于理解切换的代价:保存和恢复寄存器本身很快,但切换页表会导致 TLB 失效,缓存局部性被破坏,这才是大头。所以调度策略要在公平和效率之间权衡。常见的调度算法有轮转、优先级、多级反馈队列,实验里可能要求实现其中一种并对比效果。
// 上下文切换的简化骨架 void swtch(struct context *old, struct context *new) { // 保存当前寄存器到 old // 从 new 恢复寄存器 }这个函数通常用汇编实现,因为要精确控制寄存器的保存顺序。参数old和new是指向上下文结构的指针,结构里按固定顺序存放被调用者保存寄存器、栈指针、返回地址。调用swtch后,执行流就跑到另一个进程里去了,直到它再次被切换回来。
5. 文件系统与设备驱动:持久化与 I/O 路径
5.1 文件系统的分层结构
文件系统实验通常从最简单的“内存文件系统”开始,逐步加入磁盘布局、inode、目录项、块分配等。分层思路是:上层提供文件接口(打开、读、写、关闭),中层管理 inode 和目录,底层负责块的分配和回收。
梳理下来会发现,最容易出错的是并发访问。多个进程可能同时读写同一个文件,如果不加锁,inode 的引用计数、块位图就会错乱。实验里一般用睡眠锁或者自旋锁保护关键区,但要注意锁的粒度:锁太粗性能差,锁太细容易死锁。
5.2 设备驱动与中断处理
设备驱动是操作系统和硬件之间的翻译层。实验里常见的设备包括磁盘、网卡、串口。驱动的基本流程是:初始化设备、注册中断处理程序、提供读写接口。以磁盘为例,读一个块需要向磁盘控制器发送命令,然后等待中断,中断处理程序再把数据拷贝到内存。
重点在于理解“同步 I/O”和“异步 I/O”的区别。同步 I/O 是发起请求后睡眠等待,直到中断唤醒;异步 I/O 是发起请求后立即返回,用回调或者轮询检查完成状态。实验里通常用同步方式,因为逻辑简单,但性能差。如果要做异步,就要小心竞态条件。
注意:写驱动时,中断处理程序里不要做耗时操作,否则会阻塞其他中断,导致系统响应变慢甚至丢中断。
6. 并发与同步:锁、条件变量与经典陷阱
6.1 锁的实现与使用场景
并发控制是操作系统实验里最容易翻车的部分。锁的基本语义是:同一时刻只有一个执行流能进入临界区。实验里可能要求实现自旋锁、睡眠锁、读写锁。自旋锁忙等,适合临界区极短的场景;睡眠锁在拿不到锁时让出 CPU,适合可能长时间持有的场景。
梳理下来会发现,锁的使用比实现更容易出错。常见的错误包括:忘记释放锁、在持锁时睡眠、锁的顺序不一致导致死锁。血泪经验是:任何可能阻塞的操作,都要确认当前是否持有锁;如果持有,要么先释放,要么换用不会阻塞的版本。
6.2 条件变量与生产者消费者
条件变量用来解决“等待某个条件成立”的问题。典型模式是:在条件不满足时调用wait睡眠,条件满足时调用signal唤醒。生产者消费者问题是经典案例:生产者往缓冲区放数据,消费者从缓冲区取数据,缓冲区满时生产者等待,缓冲区空时消费者等待。
重点在于理解wait的原子性:它必须原子地释放锁并睡眠,否则会丢失唤醒。实验里如果自己实现条件变量,要确保这两步不可分割。翻车现场往往是:生产者放了数据、发了信号,但消费者还没睡下去,信号就丢了,消费者永远等不到。
// 条件变量等待的典型模式 lock(&mutex); while (buffer_empty()) { cond_wait(&cond, &mutex); // 原子释放锁并睡眠 } // 取数据 unlock(&mutex);while而不是if是为了防止虚假唤醒。cond_wait返回时重新持有锁,所以循环里再检查一次条件。参数cond是条件变量,mutex是配套的互斥锁。
7. 易漏点与易错点:那些实验报告里不会写的坑
7.1 易漏点一:中断关闭与开启的配对
为什么漏:在实现临界区保护时,关中断是最简单粗暴的方式,但很多同学关了之后忘了开,或者在不同路径上开的不一致。
怎么补:把关中断和开中断封装成一对函数,用栈式管理或者引用计数,确保嵌套调用也能正确恢复。每次关中断时问自己:这条路径的所有出口都开了吗?
7.2 易漏点二:页表释放时的共享页
为什么漏:多个进程可能共享同一块物理页,比如内核代码段或者共享内存。释放页表时如果直接释放所有映射的物理页,就会把别人还在用的页释放掉。
怎么补:对物理页做引用计数,释放时减一,减到零才真正回收。或者明确区分“独占页”和“共享页”,只释放独占的。
7.3 易漏点三:调度器里的锁顺序
为什么漏:调度器需要访问进程队列、就绪队列、等待队列,如果加锁顺序不一致,两个 CPU 同时调度就可能死锁。
怎么补:给所有相关的锁定义一个全局顺序,任何代码路径都按这个顺序加锁。实验里可以先用一把大锁保护整个调度器,确认逻辑正确后再细化。
7.4 易漏点四:文件系统的崩溃一致性
为什么漏:写文件时如果先更新了 inode 再写数据块,中间断电就会导致 inode 指向垃圾数据。实验里通常不要求处理断电,但逻辑上要理解顺序的重要性。
怎么补:先写数据块,确认落盘后再更新 inode。如果实验环境支持,可以用日志或者写时复制来保证原子性。
7.5 易漏点五:系统调用的返回值检查
为什么漏:用户态程序调用系统调用时,习惯性地不检查返回值,内核里出错返回负值,用户态却当成有效值继续用。
怎么补:所有系统调用返回前统一检查错误码,用户态库函数里把负值转成errno并返回 -1。实验里可以在系统调用入口加断言,确认参数合法。
8. 复习清单:从查漏到补缺的实操路径
复习操作系统实验,最有效的方式不是重读讲义,而是拿一张白纸,把每个 lab 的核心机制默写出来,然后对照代码找差距。下面这份清单按模块组织,每一条都是一个自检问题,能答上来才算过关。
| 模块 | 自检问题 | 常见失分点 |
|---|---|---|
| 启动 | 内核入口到 main 之间做了哪几件事? | 栈指针未设置、BSS 未清零 |
| 中断 | 中断向量表怎么分派?中断上下文能睡眠吗? | 在中断里调用阻塞函数 |
| 页表 | 多级页表怎么走?权限位有哪些? | 忘记设有效位、释放共享页 |
| 进程 | PCB 里存了什么?状态怎么迁移? | 阻塞后忘记唤醒 |
| 调度 | 切换时保存哪些寄存器?代价在哪? | 页表切换后 TLB 未刷新 |
| 文件系统 | inode 和目录项的关系?块怎么分配? | 并发访问未加锁 |
| 并发 | 自旋锁和睡眠锁的区别?条件变量怎么用? | 丢失唤醒、死锁 |
复习时建议按这个顺序走:先过一遍启动和中断,确认内核能正常跑;再重点攻页表和进程,这两块是实验的大头;然后看文件系统和驱动,理解 I/O 路径;最后集中刷并发和同步的经典问题。每过完一个模块,把对应的代码重新跑一遍,用调试器单步跟一次关键路径,比看十遍代码都管用。
从那以后我每次做实验前,都强制走一遍这个清单:先确认启动流程没问题,再检查中断和异常处理,然后逐级往上验证内存、进程、文件系统。这套习惯帮我省下了大量瞎调试的时间。希望这份笔记能帮到你,少踩几个坑,把操作系统实验真正学明白。
本文还有配套的精品资源,点击获取