
简介面向高校计算机专业操作系统课程与Linux实践教学适合正在学习进程管理、存储管理、设备管理、文件管理等模块的学生以及需要动手验证基础理论的考研与自学读者。压缩包共32个文件20个头文件.h提供数据结构与函数接口声明11个C源文件.c承载各实验的主体实现另有1个C源文件.cpp整体15.42MB代码组织清晰、便于按章节对应查阅。内容覆盖进程的软中断通信与管道通信、Linux存储器管理、字符设备驱动程序、一级文件系统设计实现等典型实验每个模块都配有可直接阅读或编译调试的完整源码读者可由此观察进程调度、地址映射、设备驱动注册与inode管理机制的实际运作过程。已有1063人学习下载既可作为课程实验参考、期末复习和备赛训练的有力助手也便于实验报告撰写时对照源码复盘。1. 计算机操作系统实验指导第3版一门靠敲代码才能学会的操作系统课很多人把操作系统课学成了“背概念课”进程、死锁、页面置换背得滚瓜烂熟一到实验课就露馅。操作系统实验指导第3版这类书的定位很直接——让你在 Linux 上把理论句子变成可运行的程序用 fork 造出父子进程用信号量解决生产者消费者手写页面置换模拟器再用银行家算法验证安全序列。它能解决的问题是把“操作系统”从教材名词变成你亲手调出来的行为。适合正在上操作系统实验课的高校学生也适合想补实验环节的自学从业者。下面按我带队做实验的完整链路来写环境怎么搭、三个必做实验怎么做、参数怎么调、坑在哪。2. 搭一个能跑通全部实验的 Linux 环境虚拟机配置、发行版选择与最小安装2.1 为什么实验必须跑在 Linux 上Windows 不是不能跑而是语义对不上实验指导第3版里的代码几乎都以 POSIX 接口为准fork 在 Windows 上没有对应实现信号量参数不一样系统调用的名字都对不上。硬在 Windows 上改造成本比装虚拟机还高。常用做法是直接装一个 Linux 发行版。发行版选择上Ubuntu 是最大众的排错资料最多想贴近国产环境银河麒麟、统信、欧拉也都是能跑这些实验的发行版区别只在包管理器习惯实验代码本身不用改。配套教材最常见的是汤小丹主编的《计算机操作系统》实验指导里的实验顺序基本按它的知识点推进。那虚拟机还是 WSL我一般推荐虚拟机。WSL2 启动快、文件互通方便但它隐藏了系统启动细节后面做引导、中断、设备管理这类实验会够不到底层虚拟机的好处是随时打快照把内核折腾坏了回滚就是这是做实验最值钱的“后悔药”。如果你的实验刚好涉及加内核模块或改启动参数虚拟机几乎是唯一选择。2.2 虚拟机配置参数CPU、内存、磁盘给多少才够虚拟机软件用 VMware Workstation 或 VirtualBox 都行核心是把虚拟化引擎打开Intel 机器开 VT-xAMD 机器开 SVM装完系统后 CPU 占用异常高多半就是没开这个。下面是常见配置参数配置项推荐值理由处理器2 核实验程序以单进程为主双核编译也够快内存4 GB桌面版 Ubuntu 空闲约 1.5G剩余 2.5G 足够 gcc 和模拟器虚拟磁盘40 GB 动态分配实验代码不到 100MB但 gdb、内核头文件和安全更新会吃空间网络NAT能出网装软件包又不需要处理局域网细节内存不用一步到位给 8G实验指导第3版里的程序没有吃内存的大模型给多了反而让虚拟机切走宿主机资源。磁盘选“动态分配”文件会按需增长一开始只占几个 GB。2.3 最小化 Ubuntu 安装与换源操作下载 Ubuntu 22.04 LTS 镜像安装时选“最小安装”省掉 LibreOffice 和游戏。装完系统先做三件事换源、更新、装编译工具。换源这一步是新手最容易卡住的地方源地址写错或替换不完整apt update 就会报错。sudo cp /etc/apt/sources.list /etc/apt/sources.list.bak sudo sed -i s|http://archive.ubuntu.com/ubuntu|https://mirrors.aliyun.com/ubuntu|g /etc/apt/sources.list sudo sed -i s|http://security.ubuntu.com/ubuntu|https://mirrors.aliyun.com/ubuntu|g /etc/apt/sources.list sudo apt update sudo apt upgrade -y sudo apt install build-essential gdb git -y第一行先备份源文件换源翻车了还能恢复。sed 的替换规则把官方源地址全部替换为阿里云镜像22.04 的源文件里有两类地址普通更新源和安全更新源所以两条 sed 都要执行。之后的 update 同步镜像索引upgrade 把内核和库更新到当前版本。最后安装的 build-essential 包含 gcc、g、makegdb 用来打断点看变量git 给实验代码做版本管理。装完验证一下gcc --version uname -r能看到 gcc 版本号和 5.15 以上的内核版本环境就算就绪。注意不要手痒升级到最新非 LTS 内核实验代码不依赖新内核特性稳定优先。2.4 安装后的两个常见问题第一个是虚拟机里没网。现象是 apt update 一直超时原因多半是虚拟机网络模式选成了“仅主机”只能和宿主机通信。解决把网络切到 NAT 或桥接然后重启网络服务。第二个是编译时报缺头文件。现象是 gcc 提示stdio.h: No such file or directory原因是最小安装没有装 libc 开发包build-essential 没装上。解决回查 apt install 命令是否成功执行必要时重跑一次。环境就绪后建议立刻打一个快照命名“base-after-setup”。后面所有实验都在这个快照之上做内核模块写崩了直接回滚不用重装系统。这一步花 5 分钟能省后面 5 小时。3. 进程与同步实验用 fork 造出父子进程再用信号量解决生产者消费者进程与同步是实验指导第3版里最不可跳过的部分跳过它后面线程、并发题都会发虚。这个实验把“进程是资源分配单位”这句话变成一个能在终端观察到的行为。3.1 fork 的返回值与写时复制边界先写一个最小 fork 程序理解两种返回值的分裂#include stdio.h #include unistd.h #include sys/wait.h int main() { pid_t pid fork(); if (pid 0) { perror(fork); return 1; } else if (pid 0) { printf([child] pid%d ppid%d\n, getpid(), getppid()); return 0; // 子进程必须在这里结束 } else { wait(NULL); printf([parent] child%d pid%d\n, pid, getpid()); } return 0; }fork 一次调用返回两次父进程里返回子进程 PID子进程里返回 0。判断返回值是写 fork 程序的第一步。子进程分支里必须写 return 0 或 exit(0)否则它会继续执行 if-else 之外的代码出现“父子都跑完主逻辑又重复打印”的翻车现场。wait(NULL) 让父进程阻塞等待子进程回收不 wait 会留下僵尸进程实验报告的 ps 输出里会出现一堆defunct条目。写时复制值得单独说fork 后父子进程共享物理内存页只在某一方写入时才真正复制所以 fork 的开销来自页表复制和中断不是把整个内存拷贝一遍。实验里验证写时复制的典型做法是fork 前声明一个全局变量子进程修改它再看父进程是否受影响。结论是互不影响因为子进程的修改发生在复制后的私有页上。3.2 信号量初始化的两个关键参数多线程同步实验里信号量初始化是最容易出玄学问题的地方。接口长这样#include semaphore.h sem_t empty, full; sem_init(empty, 0, BUFFER_SIZE); // 第2个参数0线程共享第3个参数初值 sem_init(full, 0, 0);sem_init 的第二个参数 pshared 只能填 0本实验是线程同步不跨进程。填 1 时信号量要放在多进程共享内存里报错或行为怪异先查它。第三个参数是初值empty 代表空槽位数量所以是 BUFFER_SIZEfull 代表满槽位数量所以是 0。这两个初值就是整个生产者消费者正确性的前提。还有一个很多人不知道的坑在 macOS 上 sem_init 已废弃运行时报段错误或未定义符号是常见结果Linux 上没这个问题。实验指导第3版坚持在 Linux 环境跑是有理由的换到 macOS 得改用 sem_open代码就和书对不上了。实验里还建议检查 sem_init 的返回值pshared 写错时不会立刻暴露返回值能帮你定位。3.3 生产者消费者完整实验代码信号量加锁临界区越小越好下面是一份能直接编译运行的生产者消费者#include stdio.h #include pthread.h #include semaphore.h #define BUFFER_SIZE 8 #define LOOP_COUNT 100 sem_t empty, full; pthread_mutex_t mutex; int buffer[BUFFER_SIZE]; int in 0, out 0; void *producer(void *arg) { for (int i 0; i LOOP_COUNT; i) { sem_wait(empty); // 有空槽才继续 pthread_mutex_lock(mutex); // 临界区保护 buffer 和 in buffer[in] i; printf([P] put %d at slot %d\n, i, in); in (in 1) % BUFFER_SIZE; pthread_mutex_unlock(mutex); sem_post(full); // 满槽数量 1 } return NULL; } void *consumer(void *arg) { for (int i 0; i LOOP_COUNT; i) { sem_wait(full); // 有满槽才继续 pthread_mutex_lock(mutex); // 临界区保护 buffer 和 out int item buffer[out]; printf([C] get %d from slot %d\n, item, out); out (out 1) % BUFFER_SIZE; pthread_mutex_unlock(mutex); sem_post(empty); // 空槽数量 1 } return NULL; } int main() { pthread_t p, c; sem_init(empty, 0, BUFFER_SIZE); sem_init(full, 0, 0); pthread_mutex_init(mutex, NULL); pthread_create(p, NULL, producer, NULL); pthread_create(c, NULL, consumer, NULL); pthread_join(p, NULL); pthread_join(c, NULL); sem_destroy(empty); sem_destroy(full); pthread_mutex_destroy(mutex); printf(done, produced%d consumed%d\n, LOOP_COUNT, LOOP_COUNT); return 0; }编译和运行gcc -o pc pc.c -pthread ./pc逻辑说明producer 先 sem_wait(empty) 申请空槽拿到后才进临界区写 bufferconsumer 对称地申请满槽。mutex 只保护 buffer 数组和 in/out 指针不保护信号量本身——信号量的原子操作由内核保证再把锁包在 sem_wait 外面反而会引入双重保护问题。信号量负责“有没有资源”锁负责“缓冲区互斥”两个维度的事分开做。参数说明BUFFER_SIZE 取 8 是常见选择太小会增加线程切换开销太大暴露不了满槽和空槽的处理逻辑8 到 16 之间都合适。LOOP_COUNT100 能稳定观察交替输出如果只跑 1 次看不到任何同步效果。建议把 BUFFER_SIZE 改成 1等价于互相锁定的握手和改成 100近似无限缓冲区对比三种参数下 P/C 的输出节奏才算真正把同步语义看懂。3.4 进程实验最常见的三个翻车点第一个子进程不 return主线代码执行两遍。现象是输出看起来重复原因就是 fork 返回值处理后没在子进程分支末尾退出。解决每个分支末尾都写 return 0 或 exit(0)。第二个sem_wait 永久阻塞。现象是程序打印几行后卡死。原因大多是信号量初值反了如果 full 初值被写成 BUFFER_SIZEconsumer 第一次就拿到槽位但 buffer 还是空的读旧数据甚至打印垃圾。解决先打印两个信号量的当前值回查 sem_init 的第三个参数。第三个printf 输出交错乱序。现象是 P 和 C 的输出行被切开。原因是 printf 放在了锁外面。printf 本身带缓冲且不保证原子性在并发环境里需要把 printf 移进临界区或单独加一把输出锁。这是高并发实验里的常见处理不算过度设计。4. 内存管理实验手写页面置换模拟器把页框数和命中率调出规律内存管理实验经常被学生做成“抄书模拟器”拿 FIFO 和 LRU 的流程图直接改个变量名就交。实验指导第3版这部分的难点不是写算法而是用参数让结果能验证理论结论。4.1 为什么要自己写模拟器而不是改内核真实系统的页表由内核管理用户态程序根本碰不到。改内核的代价很高——编译一个内核几十分钟稍错一步系统起不来。实验指导书的做法是提供访问序列也就是一串页面号用数组模拟页框在用户态统计缺页次数。这样碰不到内核但能验证结论。常见做法分三层访问序列生成器、置换算法实现、统计器。三者分开的好处是改算法时不用重跑序列生成排查问题时能单独调测一层。4.2 FIFO、LRU、Clock 三算法的实现框架用 Python 写这类模拟器最快改参数最方便。三个算法的核心逻辑如下from collections import deque def fifo(seq, frame_num): frames deque(maxlenframe_num) faults 0 for p in seq: if p not in frames: if len(frames) frame_num: frames.popleft() # 淘汰最先进来的 frames.append(p) faults 1 return faults def lru(seq, frame_num): frames [] faults 0 for p in seq: if p in frames: frames.remove(p) # 命中提到最近使用的一端 frames.append(p) else: if len(frames) frame_num: frames.pop(0) # 淘汰最久未用的 frames.append(p) faults 1 return faults def clock(seq, frame_num): frames [None] * frame_num use [0] * frame_num ptr 0 faults 0 for p in seq: if p in frames: use[frames.index(p)] 1 # 命中置引用位 else: while use[ptr] 1: # 引用位为1则清零继续扫 use[ptr] 0 ptr (ptr 1) % frame_num frames[ptr] p # 找到引用位为0的槽位换入 use[ptr] 1 ptr (ptr 1) % frame_num faults 1 return faults逻辑说明FIFO 用 deque(maxlen) 天然实现先进先出popleft 弹出最早进入的页面。LRU 用 list 模拟访问栈命中就 remove 再 append相当于把该页提到“最近使用”端淘汰时弹列表头。页面序列上万时 list.remove 会慢但实验规模下无所谓不要为了性能把代码搞复杂。Clock 用 use 数组模拟引用位缺页时顺着时钟指针扫引用位为 1 就改成 0 继续找到 0 才替换。它用一位硬件位换掉了 LRU 的精确时间戳缺页略多但硬件可实现。参数说明三个函数的入参 seq 是访问序列frame_num 是页框数返回 faults 缺页次数。实验最有价值的输出不是 faults 绝对值而是“页框数从 4 增到 16 时缺页如何下降”以及“FIFO 在某种序列下页框更多反而缺页更多”。4.3 参数怎么设置访问序列、页框数与冷启动访问序列是实验的灵魂。直接写 0,1,2,3 重复循环三个算法几乎都 100% 命中结果没有区分度。最常见做法是带局部性的随机序列大概 80% 概率访问前 20% 的页面剩下 20% 概率随机挑其他页。这模拟真实程序的时间局部性是算法对比的公平场地。页框数选 4、8、16 三档。太少时三个算法都频繁缺页太多时都命中看不出差异4 到 16 区间内能明显看出 LRU 优于 FIFO、Clock 接近 LRU。序列长度取 1000 次以上太短会被冷启动噪音淹没。实验时把前 10% 的访问当作热身丢弃只统计之后的命中率避免冷启动期扭曲结果。本地跑 1000 次局部性序列的典型结果方向页框数FIFOLRUClock4缺页最高缺页最低接近 LRU8明显落后 LRU继续领先与 LRU 差几个百分点16三算法接近接近接近不同序列的具体数值差异很大但方向稳定LRU 每档都占优Clock 用 1 位引用位追到 LRU 的九成水平FIFO 在页框少时最差而且可能出现 Belady 异常。4.4 结果不规律时怎么排查先确认是不是 Belady 异常。现象FIFO 增加页框后缺页反而变多。这是理论课一直在讲的结论真在实验里跑出来很多人反而不信。原因FIFO 不区分页面是否活跃可能换出去一个马上要用的页。解决不用改代码把这一现象和处理过程写进实验报告比 100% 命中更有价值。第二个常见问题三个算法结果一模一样。原因几乎总是访问序列太短或太规则。解决改用局部性随机序列并用固定随机种子保证同组同学能互相对照。模拟器要提供打印命中率和缺页功能的开关排错时才看得到中间过程。5. 死锁与调度实验银行家算法过一遍再谈优先级反转与四个排查记录银行家算法是实验指导第3版里最像“考试题”的一个实验代码量不大但矩阵逻辑一旦写错结果错得很冤。这个实验的目的不是证明“银行家很科学”而是让你亲手确认安全状态、不安全状态和死锁状态三个概念的区别。5.1 银行家算法的安全序列判定步骤算法判定一个状态是否安全核心是反复尝试找一个进程完成顺序。下面是一个标准状态进程已分配(A B C)最大需求(A B C)还需(A B C)P00 1 07 5 37 4 3P12 0 03 2 21 2 2P23 0 29 0 26 0 0P32 1 12 2 20 1 1P40 0 24 3 34 3 1可用资源 available (3, 3, 2)。手动判定步骤把每个进程的“还需”向量和 available 比较能满足的进程标记为可完成找到一个可完成进程后把它已分配的资源归还给 available继续下一轮重复直到所有进程都进安全序列则状态安全若某一轮没有任何进程能满足直接判定不安全。上述状态第一轮能放行 P1还需 1 2 2 小于 3 3 2归还后 available 变 (5, 3, 2)接着放行 P3、P4、P0 或 P2 的合适顺序属于安全状态。代码实现时维护一个 finish 数组每轮扫进程直到某轮没有进程能完成就退出。5.2 优先级反转现象、原因与优先级继承死锁实验里容易顺带翻车的是优先级反转它不造成永久阻塞但会造成“高优先级线程被低优先级线程拖住”的假死现象。现象一个实时任务迟迟得不到执行反而是后台任务一直占着 CPU。原因低优先级线程持有一把锁中优先级线程抢占 CPU 并长时间运行高优先级线程在等锁低优先级线程却根本没有机会释放锁——锁的等待链被中优先级打断了。常见解决是 mutex 的优先级继承当高优先级线程等待低优先级线程持有的锁时内核临时把持锁线程的优先级提到高线程的水平让它快速运行完并释放锁。这就是 pthread 里 PTHREAD_PRIO_INHERIT 选项的用途。实验里验证它需要配合 SCHED_FIFO 实时调度策略和不同优先级的线程如果指导书没预留可以自己写三个线程模拟。5.3 四个排查记录现象、原因、解决第一条安全序列检测函数返回“不安全”但手算明明是安全的。现象是 available 比手算结果少或多一类资源。原因大多是最大需求矩阵和已分配矩阵的行列维度弄混或 available 写的是初始值而不是归还后的累加值。解决先打印三个矩阵和 available手工核对一遍再跑。第二条模拟器能跑完但显示“剩余进程一直不能完成”。现象是循环卡死或输出死循环。原因归还资源写成了覆盖而不是累加写成avail[j] alloc[i][j]而不是avail[j] alloc[i][j]。解决检查归还那行代码。第三条调度实验里低优先级线程“抢跑”了高优先级线程。现象是日志里的执行顺序和理论不符。原因默认调度策略是 SCHED_OTHER 的时间片轮转静态优先级不起决定作用。解决用 pthread_setschedparam 把线程切到 SCHED_FIFO再观察优先级继承效果。第四条加 -g 编译后文件变大十几倍。现象是 a.out 从 20K 变 250K担心影响性能。原因-g 追加调试符号表体积变大是预期行为。解决调试时保留 -g最终验收时去掉重新编译一次不要把这个现象写进实验报告当“优化点”。6. 实验报告怎么写出可信度用 strace 和 vmstat 验证再养成复盘习惯很多人的实验收尾是“代码跑通截图写完报告”。其实还差两步验证和复盘。验证第一步是确认程序的行为确实触碰了操作系统而不只是用户态自嗨。比如 fork 实验跑完用 strace 看系统调用strace -f -e traceclone,futex,write ./fork_democlone 是 Linux 里 fork 的真正入口futex 是信号量和锁在内核的底座write 对应 printf 的输出。看到这三类调用说明实验确实触达了内核。第二步是看系统负荷变化跑生产者消费者时另开一个终端执行vmstat 1 10重点看 r运行队列和 cs上下文切换两列并发实验跑起来时 cs 会明显抬升这就是上下文切换开销的数字证据。实验报告里贴这些数据比贴一百行代码截图更有说服力。复盘习惯我现在坚持这样做每个实验留一个 README记三件事——改过哪几个参数、输出有什么变化、踩了什么坑。下一轮做同类实验直接翻这个文件比翻书快。我自己的血泪经验是早年交实验报告只贴代码和运行截图答辩时被问“为什么把页框数设成 8”直接愣住从那以后才养成了参数记录的习惯。当前行业里总在讨论 AI 操作系统这类方向但真要理解它绕不开这些基础实验里的参数和边界。操作系统实验的意义不是背结论而是能解释每个参数为什么存在希望帮到你。本文还有配套的精品资源点击获取