简介:面向软件工程、计算机等专业学生的操作系统课程实验报告文档,来自西南交通大学,由胡晓鹏老师指导,获95分评价。内容围绕Linux系统组织与行为展开,覆盖Shell常用命令与重定向、管道机制,vi或gedit编辑与gcc编译、gdb调试,以及/proc下cpuinfo、meminfo、loadavg、interrupts等内核信息的观测;后续实验还涉及软中断通信、FCFS与RR进程调度验证和线程同步。文档保留实验目的、内容、步骤与总结的完整结构,可对照复现实验过程、梳理报告写法并查漏补缺。包内共1个docx文件,约1.48MB,目录清晰,便于按实验模块查阅。已有404人学习,适合课程实验参考与期末复习使用。
1. 一份 95 分的操作系统实验报告,能省掉你哪几小时的试错
Linux 操作系统的实验报告,最容易写成的样子是“能跑但说不清”:进程确实被终止了,调度也确实轮转了,缺页数也确实打印了,可换个参数就复现不出来,连自己都讲不明白输出为什么是这一行。这份标注 95 分的课程实验报告,把八个实验串成了一条线:从 Linux 环境与/proc观测、软中断通信,到 FCFS 与 RR 进程调度、线程同步、内存管理,再到系统调用与缺页次数统计,每个实验都配上了命令、代码、运行输出和结果解读。它最适合三类人:正在赶操作系统实验课、需要一份结构参照的同学;想补 Linux 进程与内存基本功的转岗开发者;以及带实验的人,想看看“每一步都能对应到具体命令和输出”的报告长什么样。
2. Linux 环境与 /proc 观测:把内核变量从黑匣子里拽出来
操作系统实验的第一个卡点从来不是算法,而是环境和观测手段。你在 Windows 上写惯了#include <stdio.h>,切到 Linux 之后连目录树怎么走、进程状态去哪儿看、内存还剩多少该读哪个文件都要重新建立肌肉记忆。这份报告的前两个实验就在干这件事:先用一整套 Shell 命令把系统跑起来,再通过/proc这个虚拟文件系统把内核里的一堆计数器读成人类可读的数字。顺序不能反——不先把命令用熟,后面读/proc的每一行都会变成猜测。
/proc值得单独拎出来说。它不占磁盘,里面每个文件都是内核现场生成的一个“窗口”,你cat一次就相当于向内核发起一次查询。理解这一点之后,后面缺页统计、负载观测、进程状态追踪全都顺了,因为它们本质上是同一件事:找到正确的文件,读正确的字段,然后确认单位。
2.1 目录结构与 Shell 命令的最小可用集合
Linux 只有一个根,所有东西挂在/下面,路径用正斜杠分隔。这条规则听起来废话,但它是后面所有路径错误的源头——写C:\lab\a.c这种路径在 Linux 下必然失败。几个必须建立直觉的目录:/bin放常用命令,/home放用户主目录,/proc是内核状态窗口,/tmp放临时文件,/lib放库,/media是外接设备自动挂载点。做实验时源文件一般放/home/你的用户名/下,不要放/tmp,因为重启会清。
命令这块不用背全书,把下面这一组练到不看笔记就能敲,实验就够用了。
| 命令 | 典型用法 | 容易翻车的点 |
|---|---|---|
cd | cd /proc、cd ~ | cd ..与cd -混用,回不到上一个目录 |
ls | ls -l、ls -d /proc/[0-9]* | 不加-d会把目录内容全展开 |
ps | ps -ef、ps -eo pid,ppid,stat,cmd | 只看ps看不到别的终端进程 |
chmod | chmod 644 a.c、chmod +x run.sh | 用chmod 777一把梭,后面权限实验没法做 |
mv | mv argc.c oslab1.c | 目标已存在时会被静默覆盖 |
grep | grep -E 'MemTotal|MemFree' /proc/meminfo | 忘了-E导致|不生效 |
重定向和管道是把这些命令组合起来的关键。>覆盖写、>>追加写、2>&1把错误流并进标准输出、|把前一个命令的 stdout 喂给后一个。下面这段可以直接抄进终端跑,跑完你会对“命令可以拼装”有实感:
# 列出当前所有进程的 PID 目录,只取前 5 个 ls -d /proc/[0-9]* | head -5 # 抓内存三行,同时打印到屏幕并写入文件 grep -E 'MemTotal|MemFree|MemAvailable' /proc/meminfo | tee mem_snapshot.txt # 看当前 shell 的进程树,排除 grep 自身 ps -eo pid,ppid,stat,cmd | grep -v grep | grep bash第一行里[0-9]*是通配符,匹配所有以数字开头的项,正好对应“以 PID 命名的目录”。第二行tee的作用是分流,屏幕和文件各拿一份,比>更适合边看边存档。第三行grep -v grep是血泪经验:不过滤掉的话,grep自己也会出现在结果里,看报告的人会以为多了一个进程。
提示:
/proc下以数字命名的目录是进程目录,数字就是 PID,进程退出后目录消失。用它验证“子进程是否真的退出了”比看printf可靠。
2.2 /proc 里那几个必须会读的文件
实验要求观察cpuinfo、version、stat、uptime,并编程读取meminfo、loadavg、interrupts、filesystem。这几个文件的信息密度差别很大,读之前先知道每个文件的字段含义和单位,否则数字拿在手里也不知道对不对。
| 文件 | 关键字段 | 单位/含义 |
|---|---|---|
/proc/cpuinfo | model name、cpu cores、flags | 型号字符串、核数、指令集标志 |
/proc/version | 整行 | 内核版本、编译工具链、编译时间 |
/proc/stat | cpu行前四列、ctxt、btime | 用户态/内核态节拍数、上下文切换次数、启动时间戳 |
/proc/uptime | 两个浮点数 | 系统运行秒数、累计空闲秒数 |
/proc/meminfo | MemTotal、MemAvailable、SwapTotal | 单位是 kB |
/proc/loadavg | 前三列 | 1、5、15 分钟平均负载,第四列是运行中/总进程数 |
/proc/interrupts | 每行一个中断号 | 各 CPU 上的中断触发计数 |
/proc/filesystems | 每行一个名字 | 前面带nodev的是不挂设备的文件系统 |
两个高频误读。第一,MemFree不等于“可用内存”,Linux 会主动把空闲内存拿去做缓存,真正的可用量看MemAvailable,报告里写MemFree偏低就断定内存不够是典型误判。第二,/proc/stat里的 CPU 时间是节拍数,不是秒,换算要除以sysconf(_SC_CLK_TCK),一般是 100;直接当成秒写进报告,数量级会差两个。这也是为什么两次采样同一文件的差值才有意义,绝对值几乎没法用。
2.3 用 C 把 /proc 数据读成结构化输出
命令能看,但实验要求“编程实现观测”,目的是让你写出可复用的采集函数。核心就三步:fopen打开、按行或按字段解析、fclose关闭。下面这个程序读内存、负载和运行时长,用到了fscanf和按 key 前缀匹配两种手法:
#define _GNU_SOURCE #include <stdio.h> #include <string.h> #include <stdlib.h> /* 在 /proc/meminfo 中按 key 找一行,返回其 kB 数值,找不到返回 -1 */ static long meminfo_kb(const char *key) { FILE *fp = fopen("/proc/meminfo", "r"); if (!fp) return -1; char line[256]; long val = -1; size_t klen = strlen(key); while (fgets(line, sizeof(line), fp)) { /* 前缀匹配 "MemTotal:" 这类 key,避免误中 MemTotalFoo */ if (strncmp(line, key, klen) == 0 && line[klen] == ':') { sscanf(line + klen + 1, "%ld", &val); /* 跳过冒号再取数字 */ break; } } fclose(fp); return val; } int main(void) { /* 1. 内存:单位 kB */ long total = meminfo_kb("MemTotal"); long avail = meminfo_kb("MemAvailable"); printf("MemTotal = %ld kB (%.2f MB)\n", total, total / 1024.0); printf("MemAvailable = %ld kB (%.2f MB)\n", avail, avail / 1024.0); printf("MemUsed = %.2f %%\n", 100.0 * (total - avail) / total); /* 2. 负载:三个浮点,分别对应 1/5/15 分钟 */ FILE *fp = fopen("/proc/loadavg", "r"); if (fp) { double l1, l5, l15; if (fscanf(fp, "%lf %lf %lf", &l1, &l5, &l15) == 3) printf("loadavg = %.2f %.2f %.2f\n", l1, l5, l15); fclose(fp); } /* 3. 运行时长:第一个数是系统启动至今的秒数 */ fp = fopen("/proc/uptime", "r"); if (fp) { double up, idle; if (fscanf(fp, "%lf %lf", &up, &idle) == 2) { printf("uptime = %.0f s (%d h %d min)\n", up, (int)(up / 3600), (int)((long)up % 3600 / 60)); printf("idle total = %.0f s\n", idle); } fclose(fp); } return 0; }编译和运行用两行命令,-Wall别省,%ld和long对不上这类问题它会直接告诉你:
gcc -Wall -O2 -o procstat procstat.c ./procstat代码里的关键点在于meminfo_kb的匹配方式。/proc/meminfo的行格式固定是Key: value kB,所以先用strncmp比前缀,再检查下一字符是不是冒号——只比前缀的话,MemTotal和MemTotalXXX会混淆,虽然实际文件里没有这种键,但养成习惯能省掉后面解析/proc/self/stat时的麻烦。sscanf的起点是line + klen + 1,即跳过 key 和冒号,直接从空格后面的数字开始解析,%ld会自动跳过前导空白。
参数层面,fgets给了 256 字节缓冲,/proc/meminfo单行最长不超过 100 字节,够用。如果你要读/proc/interrupts,注意它是变长的:第一行 CPU 列表,之后每行的列数等于 CPU 核数,写死解析格式换台机器就崩,正确做法是先按空格切第一行数出 CPU 个数,再逐行按列取。
跑完把输出和free -m、uptime对一遍,数字能对上,这个实验的观测部分就算立住了。对不上就去查单位,九成是 kB 和 MB、节拍和秒搞混。
3. 软中断通信:fork、signal、kill 组成的三层进程族谱
软中断这个实验的名字容易把人带偏,它跟内核里的软中断机制不是一回事,实际考的是用户态进程间用信号通信:父进程创建子进程,子进程阻塞等待某个信号,父进程收到终端组合键产生的信号之后,再向子进程发信号把它们按顺序终止。整条链路的三个角色必须分清——fork造进程,signal注册处理函数,kill发信号,任一环节理解错了,输出顺序就会乱给你看。
这个实验的价值不在代码本身,而在于它逼你亲手确认三件事:信号处理函数在哪个进程的上下文里执行、父子进程的退出顺序靠什么保证、多个进程同时写终端为什么输出会粘连。这三件事在后面做线程同步和系统调用时会反复出现。
3.1 从组合键到 signal:链路到底怎么走的
终端里按下Ctrl+\,终端驱动会给前台进程组所有进程发送SIGQUIT(编号 3),默认动作是终止并产生 core。而Ctrl+C发的是SIGINT(编号 2),默认动作只是终止。这两个别记混,报告里写“按 Ctrl+C 触发 SIGQUIT”是硬伤。
父进程用signal(SIGQUIT, stop)把默认动作换成自己的处理函数,函数里只做一件事:把全局标志wait_mark从 1 改成 0。主流程则卡在while (wait_mark != 0);这个空转循环里。信号到达时,内核中断主流程、跳到stop执行,返回后循环条件不再成立,程序继续往下走。这个模型叫“忙等 + 标志位”,优点是简单,缺点是白烧 CPU,而且wait_mark必须是volatile sig_atomic_t才能保证编译器不去优化它——报告里的代码用的是普通int,-O2编译后能不能正常退出属于玄学,写正式代码时要改。
信号编号的选择有个真实的边界。代码里用 16 和 17 当自定义“终止信号”,在 x86 Linux 上 16 是SIGSTKFLT,17 是SIGCHLD——后者是内核在子进程状态变化时自动发给父进程的。也就是说,子进程一退出,父进程就可能收到一个SIGCHLD,跟你的自定义信号撞号。常见做法是改用SIGUSR1(10)和SIGUSR2(12),或者用SIGRTMIN+n实时信号,从根上避开冲突。这条不影响实验跑通,但答辩时被问到会加分。
3.2 两个子进程版本:代码逐段拆
第一个场景:父进程创建 p1、p2 两个子进程,按Ctrl+\后父进程依次向两个子进程发信号,最后自己退出。骨架如下,相比原始版本补上了exit(0)和volatile两处:
#include <stdio.h> #include <stdlib.h> #include <signal.h> #include <unistd.h> static volatile sig_atomic_t wait_mark = 1; /* 必须 volatile,否则可能被优化掉 */ static void stop(int signo) { (void)signo; wait_mark = 0; } int main(void) { pid_t p1, p2; while ((p1 = fork()) == -1) ; /* fork 失败就重试,EAGAIN 时有用 */ if (p1 == 0) { /* ---- 子进程 P1 ---- */ printf("child P1 pid=%d ppid=%d\n", getpid(), getppid()); signal(SIGQUIT, SIG_IGN); /* 忽略终端组合键,只认父进程的信号 */ signal(SIGUSR1, stop); /* 等父进程发 10 号信号 */ while (wait_mark) pause(); /* pause 比空转省 CPU,收到信号才醒 */ printf("child P1 pid=%d is killed\n", getpid()); exit(0); /* 必须显式退出,避免走回父进程分支 */ } while ((p2 = fork()) == -1) ; if (p2 == 0) { /* ---- 子进程 P2 ---- */ printf("child P2 pid=%d ppid=%d\n", getpid(), getppid()); signal(SIGQUIT, SIG_IGN); signal(SIGUSR2, stop); while (wait_mark) pause(); printf("child P2 pid=%d is killed\n", getpid()); exit(0); } /* ---- 父进程 ---- */ printf("parent pid=%d\n", getpid()); signal(SIGQUIT, stop); while (wait_mark) pause(); /* 等 Ctrl+\ */ kill(p1, SIGUSR1); /* 先通知 P1 */ kill(p2, SIGUSR2); /* 再通知 P2 */ wait(NULL); /* 回收两个子进程,防止僵尸 */ wait(NULL); printf("parent pid=%d is killed\n", getpid()); return 0; }编译运行:
gcc -Wall -O2 -o softirq softirq.c ./softirq # 另开一个终端执行 kill -QUIT <上一步打印的父进程 pid>,效果等同按 Ctrl+\这份代码有几处比原版更稳,值得说明。while (wait_mark) pause();替代空转循环,pause()会挂起进程直到有信号到达,CPU 占用从 100% 降到 0,同时它天然处理了“信号比pause先到”的问题——严格讲标准pause仍有竞态窗口,工程上会用sigsuspend配信号屏蔽字,实验里用pause足够。signal(SIGQUIT, SIG_IGN)放在子进程开头是必须的,否则Ctrl+\会同时打到父子三个进程,子进程直接按默认动作死掉,你根本观察不到“父进程杀掉子进程”这个过程。wait(NULL)回收子进程同样不能省,少了它子进程会变僵尸,ps里状态显示Z。
输出顺序取决于两个kill的先后和子进程被调度到的时刻。报告里写“P2 先于 P1 结束”,原因是代码里先kill(p1),但 P1 被唤醒后还要抢 CPU,实际执行顺序并不严格保证。如果想强制固定顺序,可在两个kill之间加一次waitpid(p1, NULL, 0),等 P1 真正退出再通知 P2,这样输出就稳定了。
3.3 孙进程版本:三层结构怎么保证倒序退出
第二个场景把结构加深一层:父进程创建子进程,子进程再创建孙进程,Ctrl+\之后按孙、子、父的顺序依次终止。难点在于子进程同时扮演两个角色——对上是儿子,对下是父亲,所以它既要能被父进程的信号唤醒,又要在退出前先杀掉自己的儿子。
/* 只保留与前一个版本不同的核心分支 */ if ((p1 = fork()) == -1) ; if (p1 > 0) { /* ---- 祖父进程 ---- */ signal(SIGQUIT, stop); while (wait_mark) pause(); kill(p1, SIGUSR1); /* 只通知子进程,其余交给它自己处理 */ wait(NULL); printf("parent pid=%d is killed\n", getpid()); exit(0); } else { if ((p2 = fork()) == -1) ; if (p2 > 0) { /* ---- 子进程:先等祖父,再杀孙进程 ---- */ printf("child pid=%d ppid=%d\n", getpid(), getppid()); signal(SIGQUIT, SIG_IGN); signal(SIGUSR1, stop); while (wait_mark) pause(); kill(p2, SIGUSR2); /* 通知孙进程先退 */ waitpid(p2, NULL, 0); /* 等它真的退完再往下走 */ printf("child pid=%d is killed\n", getpid()); exit(0); } else { /* ---- 孙进程 ---- */ printf("grandson pid=%d ppid=%d\n", getpid(), getppid()); signal(SIGQUIT, SIG_IGN); signal(SIGUSR2, stop); while (wait_mark) pause(); printf("grandson pid=%d is killed\n", getpid()); exit(0); } }关键在waitpid(p2, NULL, 0)这一句。它把“通知孙进程”和“自己退出”之间强行串行化,保证输出里孙进程一定排在子进程前面。如果换成wait(NULL),在有其他子进程存在时可能回收错对象;waitpid指定 PID,语义明确。另外注意,孙进程的getppid()返回的是子进程的 PID,不是祖父的——这正好可以用来在报告里证明三层结构真实存在,比画图有说服力。
3.4 跑起来之后看什么
运行结果不能只贴截图,要能解读出三件事:父子 PID 的对应关系是否与fork的返回值一致、终止顺序是否与信号发送顺序匹配、退出后ps里有没有留下Z状态进程。
./softirq & # 输出形如: # parent pid=3120 # child pid=3121 ppid=3120 # grandson pid=3122 ppid=3121 kill -QUIT 3120 # 等价于在该终端按 Ctrl+\ ps -eo pid,ppid,stat,cmd | grep -E 'softirq|defunct'ppid从 3120 到 3121 再到 3122 逐级递增,说明族谱成立。ps那一行如果出现Z或defunct,说明该回收的进程没有回收,回去补wait。如果按了组合键毫无反应,先在另一个终端用kill -QUIT <pid>手动发,能退就说明是终端键绑定问题,不能退就是信号被SIG_IGN覆盖了——这是最常见的两种翻车原因,用二分法一分钟能定位。
4. 进程调度与线程同步:FCFS、RR 的指标和时间片怎么定
调度和同步是这门课里最容易被“结果截图”糊弄过去的两块。FCFS 和 RR 的代码几十行就能写完,但报告里真正要回答的是:周转时间怎么算、时间片为什么取这个值、RR 的结果和 FCFS 一样说明了什么。线程同步那边同理,能跑出交替输出不代表没有竞态,得看共享变量的最终值和期望值是否一致。
这一章把三块内容串起来:先用统一的任务结构体把 FCFS 和 RR 写在同一份代码里,避免两套数据结构对不上;再把时间片当成可调参数扫一遍,看指标怎么变;最后用一个固定容量的共享缓冲区做同步,把互斥量和条件变量的配合讲清楚。内存管理实验的落点放在“结果解读”上,它考的不是写代码,而是你能不能把一串地址分配结果翻译成分区状态。
4.1 用同一个任务结构体装下 FCFS 与 RR
先定数据结构,这是整章的骨架。五个字段够用:到达时间、需要运行时间、剩余时间、首次开始时间、完成时间。有了这五个,周转时间、带权周转时间、平均等待时间全都能推出来。
#include <stdio.h> #include <string.h> #define N 5 typedef struct { int pid; /* 任务编号 */ int arrive; /* 到达时间,单位:时间片 */ int burst; /* 需要运行的总时间 */ int remain; /* 剩余时间,RR 每轮减一 */ int start; /* 首次被调度到的时刻,-1 表示还没跑过 */ int finish; /* 完成时刻 */ } Task; /* 周转时间 = 完成 - 到达;带权周转 = 周转 / 服务时间 */ static void report(Task t[], int n, const char *name) { double sum_turn = 0, sum_wturn = 0; printf("==== %s ====\n", name); printf("pid arrive burst finish turn wturn\n"); for (int i = 0; i < n; i++) { int turn = t[i].finish - t[i].arrive; double wt = (double)turn / t[i].burst; sum_turn += turn; sum_wturn += wt; printf("%3d %6d %5d %6d %4d %5.2f\n", t[i].pid, t[i].arrive, t[i].burst, t[i].finish, turn, wt); } printf("avg turn = %.2f, avg wturn = %.2f\n\n", sum_turn / n, sum_wturn / n); }report函数被两个算法共用,这样对比时格式完全一致,不会出现“FCFS 的表少一列”的尴尬。注意wturn用double算,整除会把小数截掉,报告里带权周转时间全是整数就是这个问题。
FCFS 的实现简单到不像算法:按到达时间排序,依次累加执行。
static void fcfs(Task src[], int n) { Task t[N]; memcpy(t, src, sizeof(Task) * n); /* 按到达时间升序,冒泡足够,n 小的时候别上快排 */ for (int i = 0; i < n - 1; i++) for (int j = 0; j < n - 1 - i; j++) if (t[j].arrive > t[j + 1].arrive) { Task tmp = t[j]; t[j] = t[j + 1]; t[j + 1] = tmp; } int now = 0; for (int i = 0; i < n; i++) { if (now < t[i].arrive) now = t[i].arrive; /* CPU 空闲要等,不能直接累加 */ t[i].start = now; now += t[i].burst; t[i].finish = now; } report(t, n, "FCFS"); }if (now < t[i].arrive) now = t[i].arrive;这行是分水岭。少了它,CPU 会在任务还没到达时就开始“执行”,所有完成时间集体前移,指标全部失真。这个错误在小数据量下不容易发现,因为差值恰好等于空闲时间。
RR 用环形队列,时间片q作为参数传进来,remain归零才算完成:
static void rr(Task src[], int n, int q) { Task t[N]; memcpy(t, src, sizeof(Task) * n); for (int i = 0; i < n; i++) t[i].remain = t[i].burst; int queue[256], head = 0, tail = 0; int now = 0, done = 0, added = 0; /* 按到达时间把当前已到达的任务入队,added 记录入队进度 */ while (done < n) { while (added < n && t[added].arrive <= now) queue[tail++ % 256] = added++; if (head == tail) { /* 队列空,CPU 空转到下一个到达时刻 */ now = t[added].arrive; continue; } int i = queue[head++ % 256]; if (t[i].start == -1) t[i].start = now; /* 记录首次执行时刻 */ int run = (t[i].remain < q) ? t[i].remain : q; now += run; t[i].remain -= run; if (t[i].remain == 0) { /* 用完才算出队完成 */ t[i].finish = now; done++; } else { queue[tail++ % 256] = i; /* 没跑完,回队尾 */ } } report(t, n, "RR"); }队列用固定数组加取模,容量 256 对实验规模足够;head和tail只增不减,靠% 256回绕,所以判空用head == tail是成立的,前提是总入队次数不超过 256。如果数据量大,就老老实实用链表,别在这个点上冒险。
4.2 时间片到底取多少:扫一遍参数比争一句结论有用
时间片是 RR 唯一的可调参数,也是报告里最容易写空话的地方。写“时间片过大退化为 FCFS,过小切换开销大”没有信息量,做一次参数扫描,把数字摆出来,结论自然成立。
| 时间片 q | 平均周转时间 | 平均带权周转 | 现象 |
|---|---|---|---|
| 1 | 最小 | 最小 | 切换最频繁,任务交替明显 |
| 2 | 略大 | 略大 | 长任务会被拆成多段 |
| 4 | 接近 FCFS | 接近 FCFS | 短任务优势开始消失 |
| ≥ 最大 burst | 与 FCFS 完全相同 | 与 FCFS 完全相同 | 每个任务一轮跑完,没有轮转 |
最后一行就是那个经典疑问的答案:如果 RR 跑出来的结果跟 FCFS 一模一样,先查时间片是不是大于等于最长burst。这是参数问题,不是代码问题。反过来,如果 q=1 时输出里同一个任务连续出现两次,说明你的入队逻辑写错了——没跑完的任务应该回队尾,而不是继续留在队首。
用一组有区分度的数据能看出差别:到达时间{0,1,2,3,4}、burst{8,4,9,5,2}。这组数据里长短任务混在一起,FCFS 下短任务会被长任务堵住,RR 的带权周转时间会明显更小。如果换成一堆 burst 都等于 3 的任务,两种算法几乎没差别,拿这种数据写对比等于白做。
注意:
now的单位是“时间单位”,不是秒。报告里写“完成时间为 23 秒”和你的数据定义对不上,答辩时会被追问。要么统一说时间单位,要么在表格里标注换算关系。
4.3 线程同步:互斥量加条件变量的最小闭环
同步实验用生产者消费者最合适,因为它同时需要互斥(保护缓冲区)和顺序(空不能取、满不能放)。缓冲区定成 4 个槽,生产者放 10 个数据,消费者取 10 个,最后校验取出的和是否等于放入的和。
#include <pthread.h> #include <stdio.h> #define CAP 4 #define ITEMS 10 static int buf[CAP], in = 0, out = 0, count = 0; static long sum_put = 0, sum_get = 0; static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; static pthread_cond_t not_full = PTHREAD_COND_INITIALIZER; static pthread_cond_t not_empty = PTHREAD_COND_INITIALIZER; static void *producer(void *arg) { (void)arg; for (int i = 1; i <= ITEMS; i++) { pthread_mutex_lock(&mtx); while (count == CAP) /* 必须 while,if 会被虚假唤醒骗 */ pthread_cond_wait(¬_full, &mtx); /* wait 内部先解锁,被唤醒后重新加锁 */ buf[in] = i; in = (in + 1) % CAP; count++; sum_put += i; printf("put %2d, count=%d\n", i, count); pthread_cond_signal(¬_empty); /* 通知一个消费者 */ pthread_mutex_unlock(&mtx); } return NULL; } static void *consumer(void *arg) { (void)arg; for (int k = 0; k < ITEMS; k++) { pthread_mutex_lock(&mtx); while (count == 0) pthread_cond_wait(¬_empty, &mtx); int v = buf[out]; out = (out + 1) % CAP; count--; sum_get += v; printf("get %2d, count=%d\n", v, count); pthread_cond_signal(¬_full); pthread_mutex_unlock(&mtx); } return NULL; }编译要加-pthread,链接顺序放最后:
gcc -Wall -O2 -pthread -o pc producer_consumer.c ./pc | tail -3 # 期望输出:sum_put == sum_get == 55(1 到 10 求和)三个细节决定这个实验能不能过。while (count == CAP)不能用if替换,pthread_cond_wait存在虚假唤醒,返回时不保证条件成立,用if会读到脏数据。pthread_cond_wait的第二个参数必须是当前持有的互斥量,它在内部先解锁再睡眠,被唤醒时重新加锁,写错会导致死锁或数据竞争。sum_put和sum_get的累加在锁内完成,如果挪到锁外,即使打印顺序看着正常,最终求和也可能对不上——这种 bug 出现概率低,但一旦出现在你答辩那次,就是当场翻车。
验证方法很直接:跑一百次,每次都检查sum_put == sum_get。对不上就说明临界区有漏。另外把PTHREAD_MUTEX_INITIALIZER换成pthread_mutex_init配合PTHREAD_MUTEX_ERRORCHECK属性,锁重入会直接返回错误码,比死锁后干瞪眼强。
4.4 内存管理实验:结果解读比代码更长
内存管理这块各校差别很大,有的做动态分区分配(首次适应、最佳适应、最坏适应),有的做页面置换(FIFO、LRU)。不管哪种,回答结构是一样的:输入序列、分配或置换过程、最终内存布局、指标对比。
以动态分区为例,给定空闲分区链[100K, 500K, 200K, 300K, 600K]和请求序列[212K, 417K, 112K, 426K],首次适应和最佳适应的结果会分叉。这类实验用表格记录比用截图强得多:
| 请求 | 首次适应选中 | 剩余 | 最佳适应选中 | 剩余 |
|---|---|---|---|---|
| 212K | 500K 块 | 288K | 300K 块 | 88K |
| 417K | 600K 块 | 183K | 600K 块 | 183K |
| 112K | 288K 块 | 176K | 183K 块 | 71K |
| 426K | 无可用(失败) | — | 无可用(失败) | — |
表格填完,结论自己就出来了:最佳适应留下的都是碎片,后续大请求更容易失败;首次适应分配快但低地址区碎片多。报告里如果能补一句“把空闲块按地址排序后首次适应等价于下次适应”,说明你确实读懂了,而不是抄了一段定义。
页面置换那边同理,核心是缺页次数和缺页率两个数。给一个访问串比如7,0,1,2,0,3,0,4,2,3,物理块 3 个,FIFO 和 LRU 分别模拟一遍,把每次置换的页面写进表格。这一步手算比写代码快,写完代码再用手算结果对,两边一致才算过。LRU 的实现要点是每次访问都要更新“最近使用”顺序,很多人只在缺页时才更新,导致命中时顺序错乱,最终缺页次数偏大。
5. 避坑与排查:五个让我重跑一遍实验的现场记录
下面这五条都是实际卡住过、并且排查过程有明确路径的问题。每一条按“现象、原因、解决”写,遇到相似症状可以直接对号入座,不用从头猜。
5.1 虚拟机连不上网,ifconfig里没有 IPv4 地址
现象:虚拟机能启动、能进桌面,但ping任何地址都不通,ifconfig只有lo有地址,网卡那一项没有inet。
原因基本锁定在网络模式。桥接模式要求虚拟网卡和物理网卡处在同一网段,宿舍或办公室网络如果开了端口隔离或 IP 绑定,虚拟机就拿不到地址;NAT 模式由虚拟机软件自己做地址转换,只要宿主机能上网,虚拟机就能上网,也不容易和同网段其他设备冲突。
解决:把虚拟机网络适配器改成 NAT,然后在系统里重启网络服务——systemctl restart NetworkManager或者ifdown/ifup对应网卡。改完用ip addr确认拿到192.168.x.x这类地址,再ping一个公网地址验证。如果 NAT 也不通,检查宿主机上虚拟机软件的相关服务是否在运行,这一条比改配置更常见。
提示:三种模式的区别记一句话就够——桥接像“同一间教室里多了一台机器”,NAT 像“通过宿主机转接出去”,仅主机模式则是“完全隔离的内部网络”。实验环境优先 NAT。
5.2 在 Windows 里写好.c拷进 Linux,编译报undefined reference to 'fork'
现象:代码在 Windows 的 IDE 里看着没红线,拷到 Linux 上gcc一编就报一堆未定义符号,fork、sleep、kill全找不到。
原因不是代码写错,是头文件根本不存在。fork、signal、unistd.h这些是 POSIX 接口,Windows 的编译器(不管 MinGW 还是 MSVC)没有对应实现。你在 Windows 上“编译通过”只是 IDE 没做完整的链接检查,或者它悄悄用了另一套头文件。
解决:带fork、signal、pthread的代码一律在 Linux 里写、在 Linux 里编。文件可以通过共享目录传,但编译动作必须在 Linux 侧完成。传过去之后先跑一遍gcc -Wall -c xxx.c只做编译不做链接,头文件缺失会立刻暴露,比等到链接阶段再排查快得多。
5.3 按Ctrl+\程序毫无反应,只能Ctrl+C强杀
现象:程序正常打印了父子进程 PID,但按组合键后一直卡着,最后只能开另一个终端kill -9。
原因有两个方向。一是终端把组合键截走了,某些终端模拟器对Ctrl+\有自己的绑定,或者当前焦点不在终端窗口里;二是子进程里signal(SIGQUIT, SIG_IGN)写在了父进程注册处理函数之前,把本该处理的信号也一起忽略了。
解决:先在另一个终端用kill -QUIT <父进程pid>手动发信号。能正常退出说明信号逻辑没问题,是终端键绑定的事,换一个终端或者直接用kill命令;不能退出就检查signal的注册顺序,确保父进程先注册SIGQUIT的处理函数,子进程再忽略。另外确认程序跑在前台进程组里,放到后台跑的话终端信号发不过去。
5.4 多进程输出粘在一起,printf的顺序和预期对不上
现象:本该三行独立的输出变成child P1 pid=3121 ppid=3120child P2 pid=3122,中间没有换行,或者某个进程的输出整体晚了一截才出现。
原因是stdout在重定向或非交互环境下是全缓冲的,printf写进的是进程私有的缓冲区,fork会把父进程的缓冲区复制给子进程,两边各刷一次,内容就重复或错位。多进程同时写同一个文件描述符,谁先谁后也没有保证。
解决:三条路,按场景选。调试阶段在每个printf后面加fflush(stdout),简单粗暴;要保证整行原子输出,用lockf(1, 1, 0)和lockf(1, 0, 0)把写操作锁起来,实验报告里用的就是这个手法;最彻底的是用write(1, buf, len)替代printf,系统调用无缓冲,而且一次写入不超过PIPE_BUF时是原子的。
5.5 RR 跑出来的指标和 FCFS 一模一样
现象:两个算法的输出表格逐行相同,平均周转时间也相同,怎么看都不像“轮转”。
原因九成是时间片设置。如果q大于等于所有任务的最大burst,每个任务第一轮就跑到完成,队列里永远只有一个任务,RR 自然退化成 FCFS。第二种可能是入队逻辑写错,任务没跑完却被当成完成直接出队,效果也等同于 FCFS。
解决:先把q调到 1 或 2,再看输出。正常情况下能观察到同一任务被拆成多段执行,finish时间比burst累加值大。如果q=1还是和 FCFS 一样,打印每一轮的now、当前任务pid和remain,逐轮对,问题一定在remain的减法或者入队分支上。另外检查初始数据:如果所有任务burst都是 1,那什么算法都一样,换一组有区分度的数据再测。
6. 缺页统计与系统调用:让最后两个数字对得上账
缺页次数这个实验最容易糊弄也最容易露馅:程序打印了一个数,你把它抄进报告,但它到底对应物理页缺失还是主缺页、统计的是进程自己还是子进程,一问就穿帮。真正稳的做法是两个独立来源交叉验证,对得上再往报告里写。
Linux 上取缺页有两处。一是/proc/self/stat里的第 10 和第 12 个字段,分别是minflt(次缺页,不需要磁盘 I/O)和majflt(主缺页,需要读盘);二是getrusage(RUSAGE_SELF, &ru)返回的ru_minflt和ru_majflt。两者应该一致,不一致说明中间有 fork 或统计口径不同。
| 字段位 | /proc/[pid]/stat含义 | getrusage对应 |
|---|---|---|
| 第 10 | minflt次缺页次数 | ru_minflt |
| 第 11 | cminflt已回收子进程的次缺页 | ru_nvcsw之外另有语义 |
| 第 12 | majflt主缺页次数 | ru_majflt |
| 第 13 | cmajflt已回收子进程的主缺页 | — |
解析/proc/self/stat有个坑:第 2 个字段是进程名,可能包含空格和括号,按空格切分会整体错位。正确做法是先找最后一个),从它后面开始解析剩余字段。
#include <stdio.h> #include <string.h> #include <stdlib.h> #include <sys/resource.h> #include <sys/mman.h> #include <unistd.h> /* 从 /proc/self/stat 取 minflt 与 majflt */ static void stat_faults(unsigned long *min, unsigned long *maj) { char buf[1024]; FILE *fp = fopen("/proc/self/stat", "r"); if (!fp) { *min = *maj = 0; return; } fgets(buf, sizeof buf, fp); fclose(fp); char *p = strrchr(buf, ')'); /* 跳过可能含空格的进程名 */ if (!p) { *min = *maj = 0; return; } p++; char state; int ppid, pgrp, session, tty, tpgid; unsigned flags; unsigned long cmin, cmaj; sscanf(p, " %c %d %d %d %d %d %u %lu %lu %lu %lu", &state, &ppid, &pgrp, &session, &tty, &tpgid, &flags, min, &cmin, maj, &cmaj); } int main(void) { unsigned long m1, M1, m2, M2; struct rusage ru1, ru2; stat_faults(&m1, &M1); getrusage(RUSAGE_SELF, &ru1); /* 映射 64MB 匿名内存,逐页写入,强制触发次缺页 */ size_t len = 64UL << 20; char *p = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (p == MAP_FAILED) { perror("mmap"); return 1; } for (size_t i = 0; i < len; i += 4096) p[i] = 1; stat_faults(&m2, &M2); getrusage(RUSAGE_SELF, &ru2); printf("stat : minflt +%lu, majflt +%lu\n", m2 - m1, M2 - M1); printf("rusage : minflt +%ld, majflt +%ld\n", ru2.ru_minflt - ru1.ru_minflt, ru2.ru_majflt - ru1.ru_majflt); printf("expect : minflt ≈ %zu (每 4KB 一页)\n", len / 4096); return 0; }编译时加-O0更稳,mmap的写入循环在-O2下可能被部分优化掉,导致缺页数偏少。期望值64MB / 4KB = 16384,实际数字通常略大于它(程序自身栈、库加载也有少量缺页),量级对上就说明统计路径没问题。两个来源的差值应该在个位数以内,差出几百就说明中间有子进程或并发写入干扰。
至于“添加一个系统调用”,链路是固定的:在内核源码的 syscall 表里登记一个
本文还有配套的精品资源,点击获取