☰
操作系统实验从fork到内核模块:进程、同步与内存管理实战指南
2026/10/9 21:45:40 网站建设 项目流程

简介:山东大学操作系统实验课程与实践资料包,专门面向操作系统课程学习者,聚焦进程控制、进程同步与管道通信等核心实验。压缩包内共416个文件,以C与C++源码(约七十个C文件、九个C++文件)、CMake配置脚本、Makefile构建文件以及编译生成的中间产物和可执行文件为主,同时包含Markdown与文本说明文档,便于对照代码与实验步骤。全部文件仅727KB,却覆盖系统调用、进程调度、虚拟存储、死锁处理等经典主题。从内容预览可见,其中包含管道读写、生产者消费者、消息队列、共享内存、线程控制等示例源码及静态库文件,可直观演示进程间通信与线程控制的具体实现。此外,还提供了实验辅助脚本与构建过程日志,有助于理解实验环境的搭建和调试思路。已有192人学习下载,适合需要参考完整实验代码与构建流程的操作系统课程实践者。

1. 操作系统实验课到底在训练什么:把原理编译成能跑的代码

很多同学第一次拿到某高校的操作系统实验课文档时,第一反应不是兴奋,而是发怵:调度、内存、文件系统这些概念在书上都能背,一落到 Linux 终端里,连一个 fork 下去到底跑了几条路径都说不清楚。这门课真正的价值就在这儿——它逼你把“进程是资源分配的基本单位”这种黑话,翻译成能观察到打印顺序、能数出缺页次数、能通过 strace 看到系统调用序列的真实代码。它一般会从进程管理与同步互斥起步,逐步走到内存管理、文件系统模拟,再往上就是内核模块与系统调用。

这篇文章写给三类人:正在被实验报告追赶的在校生、想把这个课程项目填进简历的自学者、以及准备接手带实验的助教。我会按一线实操的路子,把这套实验怎么拆、环境怎么搭、核心代码怎么写、参数怎么调、坑在哪里一次讲清楚。你不需要先学完整本操作系统教材,但要有基本的 C 语言能力,剩下的事情就是照着步骤复现,再从复现走向变式。

2. 实验模块与开发环境:先对课程动手顺序做减法,再搭一个不容易翻车的 Linux 环境

2.1 实验模块长什么样:进程、并发、内存、文件系统与系统调用

这套课程实验通常不会让你直接去改一个完整内核,而是分成几个“可以单独跑、结果可以验证”的模块。理解每个模块的验证方式,比盲目写代码更重要。下面这张表是我习惯用来给新手做减法用的:

模块常见实验题目代码形态主要验证方式
进程管理fork、exec、wait 的进程树用户态 C 程序打印顺序、退出码
线程与同步生产者消费者、哲学家就餐pthread 多线程是否死锁、吞吐量
内存管理页面置换算法、分配器模拟用户态模拟器缺页次数、内存利用率
文件系统inode、磁盘块分配、目录树用户态模拟目录一致性
系统调用读写文件、进程复制用户态/内核态strace、返回码

前三个模块是绝大多数报告的得分主战场,因为它们现象明显:进程顺序乱不乱、线程卡不卡、缺页多不多,一眼就能看出结果。文件系统模拟偏重数据结构设计,代码量通常更大,但算法本身的查错难度反而不如并发问题高。系统调用实验往往作为进阶题,有些班次会把它做成内核模块,后面我会单独说。

2.2 环境选择:虚拟机、Linux 子系统还是物理机

环境选择的第一原则不是“哪个更酷”,而是“哪个出问题时你还能救命”。我见过不少同学在物理机上直接装 Linux,结果显卡驱动翻车,课没上先折腾了一天系统。反过来,也有人用虚拟机软甲怕性能不够。我的建议是:

  • 日常写代码和跑实验:用虚拟机软件里的某个长期支持版 Linux 发行版即可。性能对课程实验完全够,而且快照功能是后悔药,内核模块把系统搞崩了直接回滚。
  • 只做用户态 C 实验:用 Linux 子系统也够,但要注意子系统里的内核版本和真实发行版不一定一致,后面做内核模块时容易踩“头文件对不上”的坑。
  • 真想长期搞内核方向:再考虑物理机,但不要为了这一门课去赌硬件兼容性。

环境搭好之后,第一件事不是写代码,是确认工具链和内核版本一致:

# 先看内核版本,再看 gcc、make、gdb 是否存在 uname -r gcc --version make --version gdb --version # 缺什么装什么,以 Debian 系为例 sudo apt update sudo apt install -y build-essential gdb

代码里每个命令都有它的用途。uname -r输出的内核版本号必须记住,因为内核模块实验编译时要靠它去找头文件目录;gcc --version确认编译器存在,避免你把时间浪费在“明明写了代码却找不到 gcc”这种环境问题上;build-essential包含 gcc、make、头文件等一组基础包,能省掉后续一大堆“缺文件”的报错。

2.3 建议的实验顺序与目录命名规范

实验顺序不要按 PPT 章节来,要按“相互依赖关系”来。我的习惯是:先做进程管理,再做并发同步,然后做内存模拟,最后碰文件系统和内核模块。原因是进程实验让你理解“程序被谁执行”,并发实验建立在多执行流之上,内存实验又需要你掌握缺页中断的宏观过程,顺序乱了,你会在同步实验里被莫名其妙的时序问题反复折磨。

工作目录的规范也值得在一开始就定好。不要在一个文件夹里堆满 main.c、main_copy.c、最终版.c 这种名字,后面报告和复查都会崩溃。我一般用这种结构:

os_lab/ ├── 01_process/ ├── 02_sync/ ├── 03_memory/ ├── 04_fs/ └── 05_syscall/

每个实验目录里放一份 Makefile,保证在目录里敲make就能编译出可执行文件。这里给一个最通用的模板:

CC = gcc CFLAGS = -Wall -Wextra -g -O0 -pthread TARGET = demo SRC = demo.c all: $(TARGET) $(TARGET): $(SRC) $(CC) $(CFLAGS) -o $@ $^ clean: rm -f $(TARGET)

-Wall -Wextra是打开编译警告,课程实验里警告通常意味着逻辑隐患,别关;-g是为了让 gdb 能看到源码行号;-O0是关闭优化,防止调试时变量被优化掉,变量明明存在却打印不出来;-pthread是编译多线程程序必需的,链接时要用到 pthread 库。新手最容易犯的错误是把-pthread只加在某个文件上,结果多文件编译时留下“未定义的 pthread_create”这类报错。

3. 动手实现三个核心实验:fork、生产者消费者与 LRU 页面置换

3.1 进程创建与回收:fork/wait 的最小可运行程序

进程实验的第一步不是写一个复杂的 shell,而是把一个 fork 的行为彻底看清楚。下面这个程序是很多实验的起点,同时也是理解“什么是进程”的最短路径:

#include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> int main(void) { pid_t pid = fork(); if (pid < 0) { perror("fork"); exit(1); } if (pid == 0) { // 子进程 printf("[child] pid=%d, parent=%d\n", getpid(), getppid()); _exit(0); } else { // 父进程,pid 是子进程 id int status; waitpid(pid, &status, 0); printf("[parent] child %d exited, status=%d\n", pid, WEXITSTATUS(status)); } return 0; }

fork()一次调用两次返回:在父进程中返回子进程 PID,在子进程中返回 0。所以代码里pid == 0的分支是子进程执行区,pid > 0的分支是父进程执行区。我在子进程里用_exit(0)而不是exit(0),这是刻意为之——exit会刷新 stdio 缓冲区,而 fork 时父进程的缓冲区已经被复制到子进程里,如果父进程之前在 stdout 里存了内容,子进程退出时可能把同一段缓冲区内容打印两次,产生“重复输出”的经典怪象。waitpid的作用是让父进程阻塞等待子进程结束,拿到它的退出状态,否则子进程会变成僵尸进程。WEXITSTATUS(status)是把 waitpid 拿到的状态码里真正的退出码提取出来。

改参数是这一步的乐趣所在。你可以把waitpid去掉再跑一次,会观察到父进程先打印,子进程变僵尸;也可以在 fork 之前printf一个不带换行的字符串,体会缓冲区复制带来的输出混乱。这些现象就是实验报告里最值钱的“问题与改进”素材,比直接抄代码有意义得多。

3.2 生产者消费者:条件变量版本为什么比裸锁更可靠

并发实验里最常考的是生产者消费者。很多同学一开始用“互斥锁 + 忙等”写,结果 CPU 占用率飙到 100%,程序还时好时坏。更稳的方案是用条件变量,它能让线程在条件不满足时睡眠,而不是空转。下面是一个精炼但不失完整性的实现:

#include <pthread.h> #include <stdio.h> #include <stdlib.h> #define CAP 8 int buf[CAP]; int head = 0, tail = 0, count = 0; pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t not_empty = PTHREAD_COND_INITIALIZER; pthread_cond_t not_full = PTHREAD_COND_INITIALIZER; void produce(int v) { pthread_mutex_lock(&mtx); while (count == CAP) { pthread_cond_wait(&not_full, &mtx); } buf[tail] = v; tail = (tail + 1) % CAP; count++; pthread_cond_signal(&not_empty); pthread_mutex_unlock(&mtx); } int consume(void) { pthread_mutex_lock(&mtx); while (count == 0) { pthread_cond_wait(&not_empty, &mtx); } int v = buf[head]; head = (head + 1) % CAP; count--; pthread_cond_signal(&not_full); pthread_mutex_unlock(&mtx); return v; }

这段代码的关键不是锁本身,而是两个细节。第一,条件必须放在while里而不是if里。条件变量存在“虚假唤醒”,也就是说线程可能在没有被 signal 的情况下醒来;用while会在唤醒后重新检查条件,不满足就继续等,而if只在第一次检查条件,醒来后直接往下走,缓冲区满的时候就会出现越界写入。第二,pthread_cond_wait必须传入已加锁的互斥锁,它会原子地释放锁并睡眠,被唤醒后再重新拿锁,这个机制保证了 count 的检查与修改是临界区操作。

信号量版本思路类似,但需要注意“资源计数”和“缓冲区位置”必须放在同一把锁里保护。我见过一个经典翻车:代码用两个信号量分别表示空位和满位,结果缓冲区下标 head/tail 的更新没有加锁,两个生产者同时写 tail 导致数据覆盖。信号量解决的是“能不能操作”的计数问题,而“操作本身”仍然离不开互斥。

3.3 页面置换模拟器:LRU 的参数设计与结果验证

内存管理实验很少直接让你改内核的分页模块,更多是写一个页面置换算法的模拟器。LRU 是必考项,但很多实现跑出来的缺页次数和理论对不上,问题往往出在“时间戳”的初始化上。下面这个版本专门处理了空页框先填满的情况:

#include <stdio.h> #include <string.h> #define PAGES 4 #define REFS 12 int frames[PAGES]; int timestamps[PAGES]; int find_victim(void) { int oldest = 0; for (int i = 1; i < PAGES; i++) { if (timestamps[i] < timestamps[oldest]) { oldest = i; } } return oldest; } int main(void) { int refs[REFS] = {1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5}; memset(frames, -1, sizeof(frames)); memset(timestamps, 0, sizeof(timestamps)); int faults = 0; int clock = 0; for (int i = 0; i < REFS; i++) { int hit = 0; for (int j = 0; j < PAGES; j++) { if (frames[j] == refs[i]) { hit = 1; timestamps[j] = clock++; break; } } if (hit) continue; // 先找空页框,没有空位才按 LRU 淘汰 int pos = -1; for (int j = 0; j < PAGES; j++) { if (frames[j] == -1) { pos = j; break; } } if (pos == -1) { pos = find_victim(); } frames[pos] = refs[i]; timestamps[pos] = clock++; faults++; } printf("page faults: %d\n", faults); return 0; }

页面置换模拟器的核心参数是PAGES和REFS。PAGES是页框数,决定了能同时驻留多少页面;REFS是访问序列,模拟进程的访存轨迹。这个代码里find_victim()找的是 timestamps 最小的页,也就是“最久没有被访问”的页。clock是个只增不减的逻辑时钟,每次命中或装入页面都递增,用它来记录“最近访问”的顺序关系。我特意用一个pos变量先把空位找出来,再用 LRU 淘汰,是因为如果把空页框也当成“最旧页面”,前四个页面装入时会互相覆盖,缺页次数直接失真。

在我这个固定访问序列下,PAGES=4跑出来是 7 次缺页,改成 3 后变成 10 次。这个结果不是玄学,而是 LRU 的典型行为:页框减少后,局部性再好的序列也扛不住频繁淘汰。你在实验报告里应该画一张“页框数-缺页次数”的变化表,而不是只贴一段代码。另外,访问序列可以从 rand 生成,但一定要固定随机种子,否则换台机器、换个版本库,结果就完全对不上,没法复核。

4. 验证与避坑:常见问题排查和高分实验报告的自查清单

4.1 评分点拆解:不要只对着功能点写报告

实验报告不是代码注释的搬运工。我看了大量拿不到高分的报告,普遍问题是:原理抄了三四页,核心代码贴了一大段,但“为什么这么设计”“参数改了会怎样”“遇到过什么问题”几乎没有。真正能拿分的报告有固定套路:

  • 实验目的与实际验证方法:你做了什么,用什么手段证明你做对了
  • 数据结构与关键流程:别贴完整代码,只贴核心函数,配合注释说明
  • 测试数据与现象:不同输入、不同参数下的输出,最好有表格
  • 问题与改进:至少两条真实的踩坑记录

这四块里,最值钱的是“问题与改进”。就算你的代码最后没有全部通过,只要你能准确描述“现象→原因→解决”,就能证明你真的在动手,而不是从别处复制了一段能编译的代码。

4.2 五个高频踩坑记录:现象、原因、解决

我在辅导过程中,反复遇到下面几类问题,每条都按踩坑现场、根因和解决方法来写:

坑一:子进程输出重复。现象是 fork 之前 printf 的内容在父进程和子进程里各打印了一次。原因是stdout的缓冲区在 fork 时被完整复制,子进程退出时又调用exit把缓冲区再刷一次。解决方式是子进程里用_exit,或者在 fork 前fflush(NULL)把所有缓冲区清空。

坑二:生产者消费者程序偶发卡死。现象是跑十次有三次整个进程挂起不动。原因是生产者 cond_wait 返回后没有重新检查缓冲区容量,把 if 写成了if (count == CAP)而不是while。解决方法是无条件用while包住条件判断,并且确认 signal 和 wait 使用的是同一个互斥锁。

坑三:页面置换模拟器的缺页次数和手算不一致。现象是代码逻辑看着没问题,但输出永远多几次缺页。原因是空页框没有被优先处理,LRU 把 -1 当成了 victim。解决方式是先找空位,空位填满后才走淘汰逻辑。

坑四:内核模块 insmod 报 Invalid module format。现象是编译成功,但加载时模块格式错误。原因是模块编译用的内核头文件版本与当前运行的内核不一致,常见于用户换了内核之后没有重新编译。解决方式是先uname -r查版本,再用/lib/modules/$(uname -r)/build作为内核编译目录,确保头文件完全匹配。

坑五:测试用例太弱,代码一运行就退出,无法证明正确性。现象是程序只能跑一条 happy path,参数一变就崩。原因是只在 main 里写死了一组输入。解决方式是写一个循环跑多组输入,并用断言检查结果,比如生产者消费者程序可以统计生产总数和消费总数,不一致就直接报错。

4.3 回归验证脚本:用日志和退出码证明程序没“炸”

课程实验不要求你写自动化测试,但一份简单脚本能让你的报告可信度立刻上一个台阶。我给每个实验目录里都放一个run_tests.sh,它的作用不是测试你的算法性能,而是防止你改一个模块把另一个模块改崩了。

#!/usr/bin/env bash set -e echo "== test 01: fork scenario ==" ./01_process/demo echo "exit=$?" echo "== test 02: producer-consumer ==" timeout 5 ./02_sync/demo 1000 echo "exit=$?" echo "== test 03: memory simulator ==" ./03_memory/demo echo "exit=$?"

timeout 5是个很好的习惯,它能在程序死锁时强制结束,避免你的验证卡死在没有输出的状态。set -e让脚本在第一个失败处停止,避免后面一堆错误输出掩盖真正的问题。每个实验的可执行文件都用退出码来传达结果:0 是成功,非 0 是失败。把这份脚本连同运行结果截图放进实验报告,助教能直接看出你验证过什么,而不是只看到一个“编译通过”。

5. 进阶路线:从用户态实验走到内核模块和系统调用

5.1 最轻量的系统调用实验:用 syscall() 直接触发内核接口

系统调用实验最容易劝退人的点是“自己写一个系统调用”,因为那需要修改内核源码、重新编译内核,流程长而且失败后难排查。作为课程进阶,我更推荐先从“直接调用系统调用”做起,看看用户态到内核态的门到底长什么样。

#include <stdio.h> #include <sys/syscall.h> #include <unistd.h> int main(void) { long pid = syscall(SYS_getpid); printf("syscall(SYS_getpid) = %ld\n", pid); printf("getpid() = %d\n", getpid()); return 0; }

syscall()是标准 C 库提供的一个通用入口,第一个参数是系统调用号,后面的参数会原样传给内核。SYS_getpid是 Linux 里 getpid 这个系统调用对应的编号。你不需要去记编号,头文件里都定义好了。这个实验的核心内容不是打印结果,而是明白你调用的每一个库函数底层都会走一次syscall,比如fork实际调用的是SYS_fork或SYS_clone。把这两行输出对比一下,哪个是库函数封装、哪个是真实系统调用,一目了然。

5.2 从内核模块开始:编译、插入、观察日志的完整闭环

想真正碰内核,不建议一上来就改系统调用表,先做一个能加载和卸载的内核模块更稳。下面这个模块不做任何危险操作,只在内核日志里记录一句加载信息:

#include <linux/init.h> #include <linux/module.h> #include <linux/printk.h> static int __init demo_init(void) { printk(KERN_INFO "demo: module loaded\n"); return 0; } static void __exit demo_exit(void) { printk(KERN_INFO "demo: module unloaded\n"); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL");

__init和__exit是内核模块的标记宏,表示这段代码只在初始化或退出阶段使用,用完内核可以释放。MODULE_LICENSE("GPL")不是可选的,很多内核版本在没有 GPL 声明时会拒绝加载模块。编译它需要一套独立的 Makefile,而不是 gcc 直编:

obj-m := demo.o KERNEL_DIR := /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean

这个 Makefile 的逻辑是:进入内核源码目录,用内核自己的构建系统来编译当前目录里的模块。-C切换目录,M=$(PWD)告诉内核构建系统“外部模块的源码目录在这里”。编译后执行:

make sudo insmod demo.ko sudo rmmod demo dmesg | tail -n 10

insmod把模块插入内核,rmmod卸载,dmesg查看内核日志,验证模块的 init 和 exit 是否真的执行了。这一步最容易翻车的不是代码,而是头文件版本不一致,也就是之前踩坑记录里说的 Invalid module format。看到这个报错,别去怀疑代码,先检查uname -r和/lib/modules/里是否存在对应目录,不存在就先装内核头文件包。

5.3 把课程实验包装成能放进作品集的小工程

同样是做实验,有人交一个压缩包,有人交一个带 README、Makefile 和测试脚本的工程目录,后者在面试或推免材料里的分量完全不同。包装不需要花哨,需要的是让别人能无痛复现。README 只写三块:项目结构、运行方式、每道实验的验证结果。

一个能用的 README 模板是:

## 结构 01_process: fork 与 wait 的进程树演示 02_sync: 基于 pthread 条件变量的生产者消费者 03_memory: LRU 页面置换模拟器 ## 运行 cd 01_process && make && ./demo ## 结果 - 01: 父进程回收子进程,退出码 0 - 02: 生产 1000 次,消费 1000 次,无死锁 - 03: PAGES=4 时缺页 7 次 ## 环境 Linux 内核版本 x.y.z,gcc 版本 a.b.c

我在多个同学的作品集里看到同一个问题:README 写得像代码清单,没有“结果”这一栏。结果栏才是亮点,它告诉对方你不仅写过代码,还验证过它。把这套目录放到个人主页或者交给课程验收时,它能直接说明“这个方向我深入过”。

6. 最后一个技巧:用 strace 和 gdb 验证你的实验结论

进程实验做完后,不要只盯着程序自己的输出,我习惯用两把外部工具交叉验证。第一把是strace,它能把程序发起的每个系统调用都列出来;第二把是gdb,它能让你在 fork 之后分别追踪父进程和子进程。这条组合拳能解决很多“程序看起来跑对了但总觉得不对劲”的悬案。

# 统计系统调用的次数和耗时 strace -c -f ./01_process/demo # 让 gdb 跟随新 fork 出来的子进程,查看它的调用栈 gdb -batch \ -ex "set follow-fork-mode child" \ -ex "break main" \ -ex "run" \ -ex "info inferiors" \ --args ./01_process/demo

strace的-c参数会输出每个系统调用被调用了多少次,-f是跟踪子进程,没有-f你只能看到父进程的行为,子进程的系统调用会全部漏掉。gdb的set follow-fork-mode child是程序员自己的后悔药——默认情况下 gdb 只跟着父进程走,你没法看到子进程里的崩溃点;加上它,调试器就会在 fork 后切换到子进程,方便你在子进程里打bt看调用栈。

一个能立刻见效的检查方法:跑一段 fork 程序,如果strace -c里看到超过预期数量的clone调用,说明你的代码在某个地方偷偷多创建了进程;如果在生产者消费者程序里看到某个线程长时间没有futex唤醒,说明同步条件可能有问题。用自己的逻辑对照系统调用序列,比盯着 printf 猜输出可靠得多。

那次带 A 同学做这套实验,最后的 bug 就藏在strace里:程序表面输出完全正常,但系统调用统计里多了一倍的openat,顺藤摸瓜才发现是配置文件被反复打开了三次。那之后我养成了“先跑 strace,再谈代码对错”的习惯。希望这个技巧能成为你下一次实验的最后一根救命稻草。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询