资讯动态

操作系统实验从fork到内核模块:进程、同步与内存管理实战指南

发布时间:2026/10/9 21:45:58 来源:尧图企业网站定制
简介山东大学操作系统实验课程与实践资料包专门面向操作系统课程学习者聚焦进程控制、进程同步与管道通信等核心实验。压缩包内共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 淘汰是因为如果把空页框也当成“最旧页面”前四个页面装入时会互相覆盖缺页次数直接失真。在我这个固定访问序列下PAGES4跑出来是 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 10insmod把模块插入内核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: PAGES4 时缺页 7 次 ## 环境 Linux 内核版本 x.y.zgcc 版本 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/demostrace的-c参数会输出每个系统调用被调用了多少次-f是跟踪子进程没有-f你只能看到父进程的行为子进程的系统调用会全部漏掉。gdb的set follow-fork-mode child是程序员自己的后悔药——默认情况下 gdb 只跟着父进程走你没法看到子进程里的崩溃点加上它调试器就会在 fork 后切换到子进程方便你在子进程里打bt看调用栈。一个能立刻见效的检查方法跑一段 fork 程序如果strace -c里看到超过预期数量的clone调用说明你的代码在某个地方偷偷多创建了进程如果在生产者消费者程序里看到某个线程长时间没有futex唤醒说明同步条件可能有问题。用自己的逻辑对照系统调用序列比盯着 printf 猜输出可靠得多。那次带 A 同学做这套实验最后的 bug 就藏在strace里程序表面输出完全正常但系统调用统计里多了一倍的openat顺藤摸瓜才发现是配置文件被反复打开了三次。那之后我养成了“先跑 strace再谈代码对错”的习惯。希望这个技巧能成为你下一次实验的最后一根救命稻草。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑