Linux操作系统实验报告:/proc、fork与缺页统计全链路复现
简介:面向软件工程专业操作系统课程学习者的西南交通大学操作系统实验报告,可作为Linux实验课记录、实验报告撰写与期末复盘的参考材料。压缩包内为1个docx文档,约1.48MB,内容按实验项目组织,便于直接查阅实验目的、内容、步骤与总结。报告覆盖用户界面、目录结构、Shell常用命令及重定向与管道机制,包含C语言程序编辑、gcc编译和gdb调试,并围绕/proc文件系统观测cpuinfo、meminfo、loadavg、interrupts与filesystem等内核状态信息;后半部分还涉及软中断通信、FCFS与RR进程调度验证及线程同步实验,含工程创建、策略验证和实验步骤记录。已有404人学习,评分A、95分。对于需要提交课程实验报告的学生,可参考其目录层级、实验数据记录方式、问题分析与结论组织,也可据此检查Linux命令使用、/proc信息读取、进程调度结果验证和线程同步实现是否完整。
1. 一份能拿去对照跑通的 Linux 操作系统实验报告长什么样
操作系统课设最尴尬的场面不是不会写,而是代码能跑、结果贴上去、老师一问「为什么是这个输出」就哑了。这份 Linux 操作系统实验报告把八个实验串成一条完整的链路:从 Linux 环境与 C 程序调试起步,接着是 /proc 内核变量观测、软中断通信、进程调度、线程同步、内存管理、系统调用,最后落到缺页次数统计。每个实验都带实验目的、操作步骤、核心代码和结果解读,评分 95 分。它适合两类人:正在上操作系统实验课、需要一份能照着复现的对照材料的同学,以及工作几年后想回头补一遍 fork、signal、调度、缺页这些基本功的开发者。代码本身不算难,真正值钱的是那些写着「遇到的问题」的段落,别人已经替你把坑踩过一遍。
2. Linux 环境与 /proc:先把观测工具搭起来
2.1 命令基线与调试链路
实验一开始并没有直接上内核,而是老老实实把 shell 用熟。这一步很多人觉得浪费时间,实际上后面所有实验都靠它:你要在终端里编译、运行、看进程、抓输出,命令不熟,后面每一步都在跟自己较劲。
报告里列出的常驻命令可以整理成一张最小基线表:
| 命令 | 用途 | 实验里的典型场景 |
|---|---|---|
cd/ls | 目录切换与列举 | 进入实验目录、确认源文件 |
vim | 编辑源码 | 写 C 文件,:wq保存退出 |
gcc | 编译 | gcc -o test test.c |
gdb | 调试 | 断点、单步、查看变量 |
ps | 查看进程 | 确认 fork 出来的子进程还在不在 |
chmod | 改权限 | 给脚本加可执行位 |
>/ ` | ` | 重定向与管道 |
vim 的三种模式是第一个小门槛:一般模式下按i、o、a进编辑模式,写完按Esc回到一般模式,再输入:wq保存退出。想另存成别的文件名,用:w 新文件名,这个技巧在改作业文件名的时候特别顺手。编译这条链路是vim 编辑 → gcc 编译 → ./a.out 运行,中间报错就回到编辑模式改,别在编译错误上硬猜。
提示:报告里记录的运行环境是 3.10 系列内核配 gcc 4.8,字段名和路径在各类发行版上基本一致,但个别字段的排列顺序会随内核版本变化,读之前先
cat一眼确认。
2.2 用 C 程序读 /proc:一个可复用的观测器
/proc 是内核暴露给用户态的一层窗口,里面的文件不是真存在磁盘上,读的时候才由内核现场生成。实验要求观察cpuinfo、version、stat、uptime,再编程实现meminfo、loadavg、interrupts、filesystems的读取。与其为每个文件写一遍 fopen/fgets,不如写一个通用取值函数,后面八个实验都能复用。
#define _GNU_SOURCE #include <stdio.h> #include <string.h> #include <stdlib.h> /* path: /proc 下的文件路径 * key : 需要匹配的行首关键字,传 NULL 表示输出全文 */ static void dump_proc(const char *path, const char *key) { FILE *fp = fopen(path, "r"); char line[512]; if (fp == NULL) { perror(path); /* 文件不存在或权限不足时给出明确提示 */ return; } while (fgets(line, sizeof(line), fp) != NULL) { if (key == NULL || strncmp(line, key, strlen(key)) == 0) { fputs(line, stdout); if (key != NULL) /* 指定关键字时命中即停,避免刷屏 */ break; } } fclose(fp); } int main(void) { dump_proc("/proc/version", NULL); dump_proc("/proc/cpuinfo", "model name"); dump_proc("/proc/meminfo", "MemTotal"); dump_proc("/proc/meminfo", "MemAvailable"); dump_proc("/proc/loadavg", NULL); dump_proc("/proc/uptime", NULL); dump_proc("/proc/filesystems", NULL); return 0; }这段代码的关键在dump_proc的两个参数。path决定读哪个内核文件,key决定要不要按行首前缀过滤——传 NULL 就整篇打印,传"MemTotal"这类关键字就只捞那一行。用strncmp而不是strstr,是因为 /proc 的格式是「字段名 + 冒号 + 值」,按行首匹配不会误命中值里出现的同名子串。fgets的缓冲区给到 512 字节,/proc/cpuinfo单行可能很长,长度不够会被截断,读出来是半截字符串,这种错误比读不到更隐蔽。
编译和运行:
gcc -Wall -o procview procview.c ./procview | head -20-Wall建议常开,perror之所以能打印出可读信息,靠的是 errno 被正确设置,编译器警告能帮你提前发现漏掉的头文件和返回值未处理。如果输出里出现No such file or directory,先确认是不是在容器里跑的,部分精简镜像会裁掉某些 /proc 子项。
2.3 关键字段怎么读:cpuinfo、stat、uptime 与 meminfo
光把文件打出来没用,得知道哪一行代表什么。报告里重点观察的几项可以这样理解:
/proc/cpuinfo里model name是 CPU 型号,processor的条数对应逻辑核数量,cpu MHz是当前频率。虚拟机里这个值往往是宿主机透传的,看到和物理机不一致不用慌。
/proc/version一行里依次是内核版本、编译者、gcc 版本、编译时间。原文记录的是 3.10.0-1160 配 gcc 4.8.5,版本号的第二位是偶数代表稳定版、奇数代表开发版,这个规律在讲内核版本演进时经常被提到。
/proc/stat是实验里信息密度最高的一个文件。第一行cpu后面跟的是 user、nice、system、idle、iowait、irq、softirq 等时间片累计值,单位是 jiffies——内核的节拍计数。jiffies 在开机时取一个基准值,之后每过一个节拍加一,所以任意时刻的系统运行时长都能用它换算出来。后面以intr、ctxt、btime、processes开头的行分别对应中断总数、上下文切换次数、开机时间戳、已创建进程数。
/proc/uptime给两个浮点数:系统已运行秒数、所有 CPU 空闲秒数之和。第二个数除以核数,就能粗略算出平均空闲率。
/proc/meminfo里MemTotal是总内存,MemAvailable是可分配给新进程的估算值,Buffers和Cached是缓存占用。注意MemFree不等于可用内存,把 free 和 available 混为一谈是新手最常见的误读。
/proc/loadavg三个数分别是 1、5、15 分钟平均负载,第四个字段是「运行队列线程数/总线程数/最近创建的进程 PID」。负载高不等于 CPU 忙,等 I/O 的进程也算进运行队列,这是后面进程调度实验里要区分的概念。
对着代码跑一遍,把输出和你top、free看到的值对上,这一步才算真的把 /proc 用起来了。
3. 软中断通信:fork、signal、kill 的时序拆解
3.1 信号机制与实验拓扑
软中断在用户态的表现就是信号。进程收到信号后有三种处理方式:默认动作、忽略、自定义处理函数。实验要求做两个拓扑:父进程创建两个子进程,键盘按Ctrl+\向父进程发 SIGQUIT,父进程再给两个子进程分别发信号 16 和 17;第二个拓扑是父进程创建子进程、子进程再创建孙进程,按Ctrl+\后依次终止孙进程、子进程、父进程。
先明确几个概念。fork()调用一次返回两次,父进程拿到子进程 PID,子进程拿到 0,返回 -1 表示创建失败。signal(信号编号, 处理函数)用来注册处理函数,信号编号 16 和 17 在用户态属于可自定义的实时信号区间之外的普通信号,可以用kill(pid, 16)定向发送。SIGQUIT 的默认动作是终止进程并产生 core dump,实验里把它改成自定义函数,就是为了让父进程在收到键盘中断时不直接死掉,而是走「通知子进程 → 等子进程退出 → 自己退出」的清理流程。
拓扑图不用画,脑子里记住一句话:所有信号都是异步的,你无法确定处理函数在哪个时刻被调用,只能保证「注册之后、收到信号时会执行」。这个不确定性正是后面时序分析的来源。
3.2 两进程版本:从注册到回收的完整链路
先看父进程 + 两个子进程的版本,核心逻辑是父进程收到 SIGQUIT 后依次给两个子进程发信号,子进程收到信号后把等待标记清零,跳出忙等循环,打印退出信息。
#include <stdio.h> #include <stdlib.h> #include <signal.h> #include <unistd.h> static volatile sig_atomic_t wait_mark = 1; static void stop_handler(int sig) { (void)sig; wait_mark = 0; /* 处理函数只做最小的动作:改标记 */ } static void waiting(void) { while (wait_mark != 0) ; /* 忙等,靠信号打断循环条件 */ } int main(void) { pid_t p1, p2; while ((p1 = fork()) == -1) ; /* fork 失败就重试,实际工程里应加次数上限 */ if (p1 == 0) { /* 子进程 P1 */ signal(SIGQUIT, SIG_IGN); /* 忽略键盘中断,只认父进程发的信号 */ signal(16, stop_handler); waiting(); printf("child P1 %d killed by parent %d\n", getpid(), getppid()); exit(0); } while ((p2 = fork()) == -1) ; if (p2 == 0) { /* 子进程 P2 */ signal(SIGQUIT, SIG_IGN); signal(17, stop_handler); waiting(); printf("child P2 %d killed by parent %d\n", getpid(), getppid()); exit(0); } /* 父进程 */ printf("parent %d\n", getpid()); signal(SIGQUIT, stop_handler); waiting(); kill(p1, 16); kill(p2, 17); wait(NULL); /* 回收两个子进程,避免僵尸 */ wait(NULL); printf("parent %d exit\n", getpid()); return 0; }参数和逻辑要逐项说清楚。wait_mark声明为volatile sig_atomic_t,而不是普通int,是因为它在信号处理函数里被修改、在主循环里被读取,编译器优化可能把它缓存在寄存器里导致循环永不退出,volatile强制每次都从内存读,sig_atomic_t保证读写不会被信号打断成半个值。signal(SIGQUIT, SIG_IGN)让子进程对键盘中断免疫,否则你按一次Ctrl+\,三个进程可能同时收到信号,顺序就乱了。
父进程的wait(NULL)两次对应两个子进程,少写一次就会留下僵尸进程,ps里看到<defunct>就是它。子进程退出前打印信息,输出顺序取决于父进程发信号的先后和调度器什么时候切过去,所以「P2 先死还是 P1 先死」不是代码写死的,而是时序决定的。
3.3 三进程版本:孙进程的回收顺序
第二个拓扑的难点在于:父进程只知道子进程的 PID,不知道孙进程的 PID,所以清理动作必须沿调用链逐层传下去。父进程发信号 16 给子进程,子进程收到后再发信号 16 给自己的子进程(也就是孙进程),等孙进程退出后再退出自己。
int main(void) { pid_t p; while ((p = fork()) == -1) ; if (p > 0) { /* 父进程 */ printf("parent %d\n", getpid()); signal(SIGQUIT, stop_handler); waiting(); kill(p, 16); /* 只通知直接子进程 */ wait(NULL); printf("parent exit\n"); exit(0); } /* 子进程继续 fork 出孙进程 */ pid_t g; while ((g = fork()) == -1) ; if (g > 0) { /* 子进程 */ signal(SIGQUIT, SIG_IGN); signal(16, stop_handler); waiting(); kill(g, 16); /* 把信号接力给孙进程 */ wait(NULL); printf("child %d exit\n", getpid()); exit(0); } /* 孙进程 */ signal(SIGQUIT, SIG_IGN); signal(16, stop_handler); waiting(); printf("grandson %d exit\n", getpid()); exit(0); }这里有个容易被忽略的细节:子进程里再次fork之后,父进程和子进程的p值含义不同。父进程里p > 0是新子进程 PID,子进程里g == 0才是孙进程。如果两处判断写混,会出现「孙进程被当成子进程」的分支错误,程序可能一个进程都不退出,卡在忙等里。
时序验证的办法很朴素:在每次printf里带上getpid()和getppid(),运行后把输出按行记录下来,对着 /proc 里/proc/[pid]/stat的第 4 个字段(父 PID)核对关系。报告里记录的那次运行,父进程 3657,子进程 3658,孙进程 3659,依次被杀,父子关系完全对得上。你自己跑出来的 PID 一定不同,但父子差值和退出顺序应该一致——不一致就说明信号没传到,或者处理函数没注册成功。
4. 进程调度与线程同步:FCFS、RR 与互斥量的对照实验
4.1 用数组模拟 FCFS 与 RR
进程调度实验的重点不是真的让内核换调度器,而是用用户态代码把策略跑一遍,观察平均周转时间和平均等待时间怎么变。报告里建了工程分别验证 FCFS 和时间片轮转,这里给一个能直接跑的骨架。
#include <stdio.h> #include <string.h> #define MAX 8 typedef struct { int pid; int arrive; /* 到达时间 */ int burst; /* 需要运行的总时间 */ int remain; /* 剩余时间,RR 用 */ int finish; /* 完成时刻 */ } Task; static void fcfs(Task t[], int n) { int now = 0; double total_turn = 0, total_wait = 0; for (int i = 0; i < n; i++) { if (now < t[i].arrive) now = t[i].arrive; /* CPU 空闲则等到下一个到达 */ now += t[i].burst; t[i].finish = now; total_turn += t[i].finish - t[i].arrive; total_wait += t[i].finish - t[i].arrive - t[i].burst; } printf("FCFS avg turnaround=%.2f avg wait=%.2f\n", total_turn / n, total_wait / n); } static void rr(Task t[], int n, int quantum) { int now = 0, done = 0; double total_turn = 0, total_wait = 0; for (int i = 0; i < n; i++) t[i].remain = t[i].burst; while (done < n) { int progressed = 0; for (int i = 0; i < n; i++) { if (t[i].remain <= 0 || t[i].arrive > now) continue; int slice = t[i].remain < quantum ? t[i].remain : quantum; now += slice; t[i].remain -= slice; progressed = 1; if (t[i].remain == 0) { t[i].finish = now; total_turn += t[i].finish - t[i].arrive; total_wait += t[i].finish - t[i].arrive - t[i].burst; done++; } } if (!progressed) { /* 没有就绪进程就推进时间 */ now++; } } printf("RR(q=%d) avg turnaround=%.2f avg wait=%.2f\n", quantum, total_turn / n, total_wait / n); }fcfs函数只用一次遍历,因为先来先服务不需要回退,now单调递增。rr函数的外层while每轮扫一遍任务表,slice取剩余时间和时间片的较小值,这是防止最后一个时间片超发的关键——如果直接减 quantum,剩余时间会变成负数,完成时刻算出来是错的。progressed标记那一轮有没有推进,没有就绪任务时把时间推一格,否则在到达时间比较分散的数据集上会死循环。
4.2 时间片取值对结果的影响
把同一组数据分别喂给两个函数,时间片取 1、2、4 各跑一次,你会看到一条很典型的曲线:时间片越大,RR 越接近 FCFS;时间片越小,平均等待时间先降后升,切换开销在真实系统里会吃掉收益。
| 调度策略 | 平均周转时间 | 平均等待时间 | 观察点 |
|---|---|---|---|
| FCFS | 偏高 | 偏高 | 长作业在前面就把后面的全堵住 |
| RR(q=1) | 较低 | 较低 | 切换次数最多,用户态模拟里看不出开销 |
| RR(q=4) | 接近 FCFS | 接近 FCFS | 时间片大到等于平均运行时间就退化了 |
调参数时注意arrive的取值。所有任务arrive = 0时,FCFS 的等待时间完全由输入顺序决定,把最长作业放第一个和放最后一个,结果能差一倍。这种「同一份数据换个顺序就是另一个结论」的现象,在实验报告里最好写清楚,否则评阅的人会以为你算错了。
4.3 用 pthread 验证临界区竞争
线程同步实验的核心是让「不加锁会错」这件事真实发生一次。最简单的验证对象是一个全局计数器,多个线程各加固定次数,最后比对期望值。
#include <pthread.h> #include <stdio.h> #define THREADS 4 #define LOOPS 1000000 static long counter = 0; static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; static void *worker(void *arg) { (void)arg; for (long i = 0; i < LOOPS; i++) { pthread_mutex_lock(&lock); /* 注释掉这两行就是无锁版本 */ counter++; pthread_mutex_unlock(&lock); } return NULL; } int main(void) { pthread_t th[THREADS]; for (int i = 0; i < THREADS; i++) pthread_create(&th[i], NULL, worker, NULL); for (int i = 0; i < THREADS; i++) pthread_join(th[i], NULL); printf("expect=%d actual=%ld\n", THREADS * LOOPS, counter); return 0; }编译必须带-pthread,不能只写-lpthread:前者会同时设置编译宏和链接选项,后者只影响链接阶段,在某些环境下_REENTRANT没定义,头文件里的可重入声明就不生效。运行结果分两次看:把 lock/unlock 注掉,actual通常小于 4000000,差值就是丢失的更新;把锁加回去,值稳定等于期望值。
counter++在汇编层面是「读-改-写」三步,线程在这三步之间被切走,另一个线程读到旧值,两次自增只生效一次。互斥量保证同一时刻只有一个线程处于临界区。如果换成信号量,初值设 1 也能达到同样效果,区别在于信号量可以被非持有者释放,互斥量不行——这是选型时唯一需要记住的差别。
5. 常见问题排查:五个把人卡住的坑
5.1 fork 之后输出重复
现象:程序里只写了一次printf("parent"),运行后终端里出现两遍甚至多遍。
原因:printf的输出先进入标准 I/O 缓冲区,fork会把整个用户态内存复制一份,缓冲区里的未刷新内容也跟着复制,父子进程各自在退出时刷新一次,就打印了两遍。
解决:在fork之前调用fflush(stdout),或者给输出加上\n让它走行缓冲,再或者直接用write(1, ...)走不带缓冲的系统调用。实验报告里那几段带lockf(1, 1, 0)的代码,本质也是在对标准输出做串行化,避免多进程输出交错成半行。
5.2 信号注册了却没生效
现象:按Ctrl+\之后父进程直接退出,子进程变成孤儿,ps里能看到它们还挂着。
原因有两个常见来源。一是signal的注册位置在fork之后,子进程继承了旧的处置方式;二是忙等循环里的标记变量没加volatile,编译器把while (wait_mark)优化成了「读一次、死循环」,信号处理函数里改的值根本没被看见。
解决:把signal调用放在fork之前或者子进程分支的最前面,标记变量统一声明成volatile sig_atomic_t。判断是否注册成功,可以在处理函数里加一行写日志到文件,跑完看文件里有没有记录。
5.3 多进程输出互相截断
现象:两个子进程几乎同时退出,终端上打印出来的两行文字粘在一起,前半句属于 A,后半句属于 B。
原因:printf到终端是行缓冲,一行没写完就被调度切走,另一个进程接着写,字节流就交叉了。
解决:用lockf(1, 1, 0)给文件描述符 1 加锁,打印完再lockf(1, 0, 0)解锁。注意锁的作用范围是文件描述符,标准输出被重定向到文件时同样有效。更稳的做法是把整行先拼进缓冲区,一次write写完,减少临界区长度。
5.4 编译找不到 pthread 相关符号
现象:undefined reference to pthread_create,代码看着没问题。
原因:编译时用了gcc test.c -o test,没有链接线程库。新版本 glibc 把 pthread 合并进了 libc,老环境仍然需要显式链接。
解决:统一用gcc -pthread test.c -o test,加在编译命令的任意位置都行。如果用的是 Makefile,把它写进CFLAGS和LDFLAGS两处,只写一处在某些构建系统里会漏。
5.5 缺页统计读错字段
现象:程序读/proc/self/stat取缺页次数,得到的数字明显不对,或者干脆解析崩溃。
原因:/proc/[pid]/stat的第 2 个字段是进程名,被一对括号包着,进程名里可能包含空格甚至右括号。如果直接用空格切分,字段位置会整体错位,第 10 个字段(minflt)取到的其实是别的值。
解决:先找到最后一个)的位置,从它后面开始按空格切分,此时第一个 token 是第 3 个字段(state),往后数 7 个就是 minflt(第 10 字段),再往后 2 个是 majflt(第 12 字段)。解析前先打印原始行核对一遍,比对着 man 手册数下标可靠得多。
6. 收尾:怎么判断一份实验报告是真跑过的
判断标准其实很朴素:结果能不能复现。拿到这份报告之后,我一般不看它写得多漂亮,而是挑三个点做交叉验证。
第一个点是对照输出里的 PID 关系。软中断实验的输出里同时有getpid()和getppid(),把它们抄下来,去/proc/[pid]/stat里核对第 4 字段,父子链能对上,说明这段输出不是编的。第二个点是缺页统计的前后差值:在程序里malloc一大块内存并逐页写入,再读一次minflt,两次的差值应该接近页数;只malloc不写,差值接近零,这个对比做过的人一眼就能看出来。第三个点是用strace跟一遍系统调用,strace -f -e trace=clone,execve,mmap ./a.out能看到 fork 变成 clone、动态库变成 mmap,报告里描述的行为和系统调用序列能对上,可信度就成立了。
这里放一段可直接用的缺页对比代码骨架:
#include <stdio.h> #include <stdlib.h> #include <string.h> /* 从 /proc/self/stat 中取 minflt 与 majflt */ static void read_faults(long *minflt, long *majflt) { FILE *fp = fopen("/proc/self/stat", "r"); char buf[1024]; char *p; if (fp == NULL || fgets(buf, sizeof(buf), fp) == NULL) return; fclose(fp); p = strrchr(buf, ')'); /* 跳过可能含空格的进程名 */ if (p == NULL) return; /* p 之后依次是 state(3)、ppid(4) ... minflt(10)、majflt(12) */ for (int field = 3; field <= 12 && *p; field++) { while (*p == ' ' || *p == ')') p++; char *end = p; while (*end && *end != ' ') end++; if (field == 10) *minflt = strtol(p, NULL, 10); if (field == 12) *majflt = strtol(p, NULL, 10); p = end; } } int main(void) { long m1, M1, m2, M2; const size_t pages = 4096; /* 按页大小估算,16KB 量级 */ read_faults(&m1, &M1); char *buf = malloc(pages * 4096); if (buf == NULL) return 1; memset(buf, 1, pages * 4096); /* 逐页写入才会真正触发缺页 */ read_faults(&m2, &M2); printf("minor delta=%ld major delta=%ld\n", m2 - m1, M2 - M1); free(buf); return 0; }read_faults里用strrchr从右往左找最后一个右括号,而不是strchr,就是为了绕开进程名里带括号的情况。字段计数从 3 开始往后数,第 10 个是 minflt、第 12 个是 majflt,这个下标和 man 手册的字段表一一对应。memset那一行不能省,malloc只分配虚拟地址空间,不写就不缺页,统计出来的差值会是零,这一点是整段代码里最容易翻车的地方。
血泪经验是:第一次做这个实验的时候,我把memset写成了memset(buf, 0, 0),结果前后两次读数完全一样,还以为是内核统计有问题,折腾了半天。从那以后我每次做内存相关的对比实验,都强制先把「有没有真正触碰页面」这一条单独验证一遍,再去相信后面的数字。希望帮到你。
报告原文和配套代码打包在一起,按上面这个顺序从环境搭到缺页统计跑一遍,比对着教材抄十遍都实在。
本文还有配套的精品资源,点击获取