简介:面向软件工程、计算机专业学生及操作系统课程学习者,这份西南交通大学操作系统实验报告(胡晓鹏老师课程,评分A/95分)提供了一份可对照复现的实验范例,适合在Linux环境下完成课程实验、期末复盘或准备实验答辩时参考。全文以一份docx文档交付,压缩包约1.48MB,共1个文件,内容按实验模块组织,便于直接查阅与整理。报告覆盖Linux用户界面、目录结构、Shell常用命令与重定向/管道机制,C语言程序的编辑、gcc编译与gdb调试,以及通过/proc观察cpuinfo、version、stat、uptime、meminfo、loadavg、interrupts、filesystem等内核状态信息;后续还涉及软中断通信、进程调度(FCFS与RR策略验证)和线程同步实验。已有403人学习,读者可从中获取实验目的、内容、步骤与总结的完整写作框架,并借鉴评分较高的报告组织方式与调试排错思路。
1. 一份 95 分的 Linux 操作系统实验报告,能复用什么
学期结束就吃灰的 Linux 操作系统实验报告,多数人只记得自己敲过 fork,具体写了什么、为什么这么写、哪里卡了半天,全忘了。这份材料的价值不在分数,而在于它把七次实验的完整链路都留下来了:从目录结构和 Shell 命令起步,到/proc内核状态观测、软中断通信、进程调度、线程同步、内存管理,最后落到自定义系统调用和缺页次数统计。每一步都有实验目的、操作步骤、运行现象和核心代码。
它适合三类人:正在上操作系统实验课、需要一份能对得上号的实现参考;写过 fork 和 signal 但说不清收尸顺序和信号竞态的;以及想把实验记录整理成可复现材料的人。下面不复述报告内容,而是按我自己重做一遍的顺序,把每一块拆到能跑、能改参数、能看出错在哪。
2. 环境打底:目录分工、Shell 管道与 gcc/gdb 调试链
第一个实验看着最没技术含量,实际是整个学期后面所有实验的地基。报告里花了不少篇幅记 Linux 目录结构,当时可能觉得是凑字数,但后面读/proc、找动态库、定位头文件,全靠这份目录感。
2.1 先认清目录分工,命令才有落点
Linux 没有盘符,所有东西挂在根/下,路径用正斜杠。几个高频目录的分工是这样的:/bin、/usr/bin放常用命令,/etc放配置,/home是用户主目录,/proc是内核暴露出来的虚拟文件系统,/tmp放临时文件,/lib、/usr/lib放动态库和 C 运行时,/var放日志和可变数据。/lost+found是文件系统自检后捡回来的碎片,正常机器上基本是空的。
# 一次看清根目录下每个挂载点/目录的类型和容量 df -hT | column -t # 看 /bin 下到底有哪些可执行命令,顺便统计数量 ls /bin | wc -l ls -l /bin/ls # 命令本身也是一个文件,看它的权限位 # 找出一个头文件到底装在哪,编译报 "No such file" 时很有用 find /usr/include -name "signal.h" 2>/dev/nulldf -hT的-T是显示文件系统类型,能看到/proc是proc类型、/tmp有时是tmpfs,这解释了为什么往/tmp写大文件不会真的吃掉磁盘。find那条命令加2>/dev/null是为了吞掉权限不足的报错,否则输出会被Permission denied淹没。编译时遇到找不到头文件,先跑这条确认路径,比盲目改-I参数快。
2.2 重定向、管道和命令组合,才是 Shell 的真正用法
报告里提了redirect和pipe,这两个机制在后面的实验里反复出现:抓/proc快照、过滤进程、把编译日志单独存一份。核心就三条:>覆盖写、>>追加写、2>单独接标准错误,|把前一条命令的标准输出变成后一条的标准输入。
# 把标准输出和标准错误合流到同一个文件,注意顺序不能反 gcc -Wall -o demo demo.c > build.log 2>&1 # 列出所有进程,过滤出自己写的那个,再数一行 ps -ef | grep -c "[d]emo" # 管道 + 重定向组合:每隔一秒抓一次负载,连抓五次存盘 for i in $(seq 5); do cat /proc/loadavg >> load.log sleep 1 done2>&1必须写在>后面,写成2>&1 > build.log就只把标准错误接到终端、标准输出进文件,顺序反了行为完全不同,这是新手最常见的翻车点之一。grep -c "[d]emo"里的方括号是技巧,让grep自己的进程名不匹配到结果里,否则永远多算一行。抓/proc快照循环里的sleep 1是采样间隔,想看出趋势就改成 0.5 或 2,配合uptime一起看更直观。
2.3 从 vim 到 gcc 再到 gdb,三段式调试链
报告里的流程是vim编辑、gcc编译、运行看结果,中间缺了 gdb 这一环,后面一旦出现段错误就只能靠 printf。补上这一环,实验效率会有明显差别。
# 编辑源码:i 进入插入模式,Esc 回普通模式,:wq 保存退出 vim argc.c # 编译:-g 生成调试符号,-Wall 打开常用警告,-O0 关掉优化方便单步 gcc -g -O0 -Wall -Wextra -o argc argc.c#include <stdio.h> int main(int argc, char *argv[]) { int sum = 0; for (int i = 1; i < argc; i++) { printf("argv[%d] = %s\n", i, argv[i]); sum += i; /* 故意留个可观察的中间变量 */ } printf("sum = %d\n", sum); return 0; }编译参数里-g决定 gdb 能不能把地址映射回源码行,-O0决定单步执行时变量值会不会被优化掉,-Wall -Wextra是给自己上的保险,-o后面跟输出文件名。调试时用下面这组命令:
gdb ./argc # (gdb) break main 在 main 入口下断点 # (gdb) run a b c 带三个参数启动 # (gdb) next 单步,不进入函数 # (gdb) print sum 打印变量当前值 # (gdb) backtrace 段错误后看调用栈 # (gdb) quitbreak main断在入口,run a b c把参数传进去,next逐行推进,print sum观察累加过程,backtrace在程序崩溃后打印调用栈,比 printf 打一排日志快得多。报告里提到把argc.c改名用mv,或者在 vim 里:w new_name另存,这两种方式都不影响已编译的产物,但改名后记得重新gcc,可执行文件不会跟着改名。
提示:源码扩展名必须是
.c。写成.v或.cpp再交给 gcc,前者会被当成未知格式直接报file format not recognized,后者会切到 C++ 编译器,链接行为可能和预期不一致。
3. /proc 观测:用 C 读取内核状态变量的通用骨架
第二个实验是整份报告里信息密度最高的一块。/proc是内核给自己开的窗口,把 CPU、内存、中断、文件系统这些运行时状态以文本形式暴露出来,实验要求既会用给定程序看,也要自己写程序读。
3.1 /proc 是虚拟文件系统,读一次现场生成一次
/proc下的文件大小基本都是 0,内容不在磁盘上,而是每次read时由内核的对应处理函数现算出来。所以你看/proc/uptime两次,数值一定不一样;/proc/<pid>/下的目录,进程一退出就消失。理解这一点很重要:它解释了三件事——为什么不能vim编辑/proc/meminfo、为什么cat和stat拿不到真实大小、为什么读它的开销取决于内核那一侧的计算量。
具体到几个实验点:/proc/cpuinfo是每个逻辑核一段,processor字段是核编号;/proc/version一行给内核版本和 GCC 版本,版本号规则是次版本号为偶数是稳定版、奇数是开发版;/proc/stat的cpu行依次是 user、nice、system、idle、iowait、irq、softirq、steal,单位是 jiffies;/proc/uptime两个浮点数,第一个是开机秒数,第二个是所有 CPU 空闲秒数之和。
3.2 一个通用读函数吃掉 meminfo/loadavg/interrupts
实验要求观测meminfo、loadavg、interrupts、filesystems。这四个文件的格式不一样,但读取动作完全一样,抽一个通用函数出来,后面加新文件只改一行调用。
#include <stdio.h> #include <string.h> /* 逐行读 /proc 下的文本文件,命中前缀就原样打印 */ static int grep_proc(const char *path, const char *key) { FILE *fp = fopen(path, "r"); if (!fp) { /* 文件不存在或权限不足,打印原因并返回 */ perror(path); return -1; } char line[256]; while (fgets(line, sizeof(line), fp)) { if (strncmp(line, key, strlen(key)) == 0) fputs(line, stdout); } fclose(fp); return 0; } int main(void) { grep_proc("/proc/meminfo", "MemTotal"); /* 物理内存总量,单位 kB */ grep_proc("/proc/meminfo", "MemFree"); /* 完全空闲的内存 */ grep_proc("/proc/meminfo", "MemAvailable"); /* 估算的可用量,比 MemFree 准 */ grep_proc("/proc/meminfo", "SwapTotal"); /* 交换区总量,为 0 说明没开 swap */ grep_proc("/proc/loadavg", ""); /* 整行输出:1/5/15 分钟负载 + 运行实体数 + 最近 pid */ grep_proc("/proc/interrupts", " 0:"); /* 只看 0 号中断,通常是本地定时器 */ grep_proc("/proc/filesystems", "ext"); /* 内核已注册的 file system 类型 */ return 0; }编译用gcc -Wall -O2 -o procscan procscan.c。fgets的缓冲区给 256 字节够用,/proc/meminfo单行最长的HugePages相关行也就几十字节;如果读/proc/interrupts在核多的机器上,那一行会非常长,fgets会截断,想完整取就把缓冲区放到 4096。strncmp用前缀匹配是因为meminfo里MemTotal和MemTotalXXX这类键不会冲突,但换成stat时要注意cpu会同时匹配cpu0、cpu1,得再加判断。
/proc/loadavg那一行的五个字段值得单独说:前三个是 1 分钟、5 分钟、15 分钟的平均负载,可以粗略理解成“就绪队列长度”;第四个是“正在运行/总进程数”,用斜杠分隔;第五个是最近创建的进程 PID。我在自己机器上跑出0.52 0.58 0.59 2/386 12345,说明负载不高、总共 386 个进程里有 2 个在跑。
3.3 uptime、stat 与 jiffies 对账,缺页统计从这里切进去
报告末尾提到 jiffies,说它是内核的全局节拍计数器,开机取一个基准时间,每过一个节拍加一。把这句话和/proc/uptime对照着写代码,抽象概念立刻落地。
#include <stdio.h> #include <unistd.h> #include <sys/resource.h> int main(void) { double up = 0, idle = 0; FILE *fp = fopen("/proc/uptime", "r"); if (fp) { fscanf(fp, "%lf %lf", &up, &idle); fclose(fp); } long hz = sysconf(_SC_CLK_TCK); /* 内核每秒钟的节拍数,x86 上通常 100 */ printf("uptime=%.2fs idle=%.2fs\n", up, idle); printf("HZ=%ld 推算 jiffies≈%.0f\n", hz, up * hz); struct rusage ru; getrusage(RUSAGE_SELF, &ru); printf("minflt=%ld majflt=%ld\n", ru.ru_minflt, ru.ru_majflt); return 0; }sysconf(_SC_CLK_TCK)拿到的就是HZ,乘以 uptime 秒数得到的是开机以来的节拍估算值,和/proc/stat里btime加上各 CPU 时间片的总和能对上,差个几秒属于正常的采样误差。getrusage(RUSAGE_SELF)的ru_minflt是次要缺页次数(页面在内存里但没建立映射,比如首次访问堆内存),ru_majflt是主要缺页(要从磁盘读回来),这两个字段就是缺页统计实验的核心数据源。想对比父子进程,就把RUSAGE_SELF换成RUSAGE_CHILDREN,注意它统计的是已回收子进程的累计值,子进程没wait之前读不到。
提示:
/proc/stat的数值是累计量,算某个时间段的 CPU 使用率要采两次做差,直接拿单次读数去除没有意义。
4. 软中断通信:fork + signal + kill 的收尸顺序
第三个实验是整份报告里代码量最大的一块:父进程创建两个子进程,或者父—子—孙三层,按Ctrl+\发SIGQUIT,然后逐级终止。代码不长,但信号注册时机、等待方式、回收顺序三处都能翻车。
4.1 信号注册时机与 wait_mark 忙等
fork()调一次返回两次:父进程拿到子进程 PID,子进程拿到 0,出错返回 -1。所以判断分支永远写成if (pid > 0)是父、else if (pid == 0)是子、else是出错。这个判断没搞清,后面全是乱的。
signal(SIGQUIT, handler)把某个信号绑到处理函数上,kill(pid, sig)给指定进程发信号,wait(NULL)阻塞等任意一个子进程退出并回收僵尸。报告里用的是while (wait_mark != 0);这种忙等轮询标志位,简单但有两个隐患:一是标志位不加volatile会被-O2优化成死循环,因为编译器看不出信号处理函数会改它;二是忙等白占 CPU。写作时用volatile sig_atomic_t修饰标志位,等待改成pause()或sigsuspend()更稳。
4.2 父—子—孙三级终止,逐段拆开
下面是三层结构的最小可用版本,逻辑和报告一致,但把几个容易踩的点补上了。
#include <stdio.h> #include <stdlib.h> #include <signal.h> #include <unistd.h> static volatile sig_atomic_t wait_mark = 1; /* 必须 volatile,否则可能被优化 */ static void on_stop(int sig) { (void)sig; wait_mark = 0; } int main(void) { signal(SIGQUIT, SIG_IGN); /* 子进程会继承处理方式,先置忽略 */ pid_t p1; while ((p1 = fork()) == -1) ; /* fork 失败就重试,资源紧张时兜底 */ if (p1 > 0) { /* 父进程 */ signal(SIGQUIT, on_stop); /* 注册必须在等待之前 */ printf("parent %d\n", getpid()); while (wait_mark) pause(); /* 收到 Ctrl+\ 后被唤醒 */ kill(p1, SIGUSR1); /* 通知子进程去收孙子 */ wait(NULL); /* 回收子进程,避免僵尸 */ printf("parent %d exit\n", getpid()); } else { /* 子进程 */ signal(SIGUSR1, on_stop); pid_t g; while ((g = fork()) == -1) ; if (g == 0) { /* 孙进程 */ signal(SIGUSR1, on_stop); printf("grandson %d\n", getpid()); while (wait_mark) pause(); printf("grandson %d exit\n", getpid()); _exit(0); /* 子进程用 _exit,别刷父进程的缓冲区 */ } printf("child %d, grandson %d\n", getpid(), g); while (wait_mark) pause(); kill(g, SIGUSR1); /* 先杀孙子 */ wait(NULL); printf("child %d exit\n", getpid()); } return 0; }编译gcc -Wall -O2 -o tree tree.c,运行后按Ctrl+\(终端会把它翻译成SIGQUIT发给前台进程组)。因为子进程一开始把SIGQUIT设成SIG_IGN,按一次只会叫醒父进程,父进程再发SIGUSR1逐级往下传,输出顺序必然是孙 → 子 → 父。
几个参数和写法的理由:SIG_IGN而不是默认处理,是因为默认动作是终止进程,信号会同时发给整个进程组,子进程会先死,父进程再kill就杀了个空;SIGUSR1和SIGUSR2是留给用户自定义的,报告里用 16、17 这两个数字也行,但SIGUSR1在大多数平台上就是 10,跨平台可读性差;孙进程用_exit(0)而不是exit(0),因为exit会 flush 继承来的 stdio 缓冲区,可能把父进程没来得及输出的内容重复打印一遍。pause()有个经典竞态:信号如果在while判断和pause()之间到达,进程会永远挂住。严格写法是先sigprocmask屏蔽,再用sigsuspend原子地解除并等待。
4.3 lockf 串行化输出,顺带验证 FCFS 与 RR
多进程同时printf,输出会交错成一行半句话,报告里用lockf(1, 1, 0)解决。第一个参数是文件描述符,1 是标准输出;第二个参数F_LOCK加锁、F_ULOCK解锁;第三个 0 表示从当前位置锁到文件末尾。
#include <stdio.h> #include <unistd.h> static void print_locked(const char *msg) { lockf(fileno(stdout), F_LOCK, 0); /* 对 stdout 加排他锁 */ fputs(msg, stdout); fflush(stdout); /* 必须刷,否则内容还在缓冲区里 */ lockf(fileno(stdout), F_ULOCK, 0); }fflush不能省,缓冲区没刷出去就解锁,锁等于白加。这种“先锁再写再刷再解锁”的套路,和后面线程同步实验里pthread_mutex_lock/unlock是一个思路,只是换了个粒度。
同一章里的进程调度实验(FCFS 与 RR)我建议用一个几十行的模拟程序手算核对,比在纸上推公式靠谱:
#include <stdio.h> typedef struct { int pid, arrive, burst, left, finish; } PCB; int main(void) { PCB p[] = { {1, 0, 5, 5, 0}, {2, 1, 3, 3, 0}, {3, 2, 1, 1, 0} }; int n = sizeof(p) / sizeof(p[0]); int q = 2, t = 0, done = 0; /* q 是时间片,改成 1 或 3 看结果变化 */ while (done < n) { int ran = 0; for (int i = 0; i < n; i++) { if (p[i].arrive <= t && p[i].left > 0) { int slice = p[i].left < q ? p[i].left : q; t += slice; p[i].left -= slice; ran = 1; if (p[i].left == 0) { p[i].finish = t; done++; } } } if (!ran) t++; /* 没有就绪进程,时间照走 */ } double sum = 0; for (int i = 0; i < n; i++) { int tat = p[i].finish - p[i].arrive; /* 周转时间 = 完成 - 到达 */ sum += tat; printf("P%d finish=%d turnaround=%d\n", p[i].pid, p[i].finish, tat); } printf("avg turnaround=%.2f\n", sum / n); return 0; }数组里arrive是到达时间、burst是服务时间、left是剩余时间,q是时间片。把q改成比最大服务时间还大,RR 就退化成 FCFS,一次就能看出两种策略的差别。这个模型按数组顺序轮询,不是严格的“时间片用完排到队尾”,但便于手算验证,真要严格实现就加一个就绪队列。想算带权周转时间,再除以各自的burst累加平均即可。
5. 避坑与排查:五条血泪记录
这一节按“现象 → 原因 → 解决”写,每条都是我重做时真实撞过或见别人撞过的,顺序按撞到的概率排。
5.1 信号类:kill 发出去了,对面没反应
现象:按下Ctrl+\,父进程退出了,子进程还挂在那里,ps里能看到僵尸。
原因有两个。一是信号注册晚于信号到达,fork之后先printf再signal,中间那几微秒里信号到了就走默认动作。二是标志位没加volatile,-O2下while (wait_mark)被编译成无条件死循环,信号处理函数改了内存也没用。
解决:signal注册放到fork之前或紧接着fork之后的第一个语句;标志位声明成volatile sig_atomic_t;验证时用strace -f -e trace=kill,wait4 ./tree看系统调用有没有真的发出去,如果kill返回 0 但对面没退出,问题一定在处理函数或标志位。
5.2 编译类:gcc 报 “file format not recognized”
现象:gcc argc.v -o argc直接报错,或者把.cpp文件按 C++ 编译后链接报符号重复。
原因:gcc 靠扩展名判断语言,.v它不认识,会当成链接器输入;.cpp会切到 C++ 前端,fork、signal这些 POSIX 接口在 C++ 里没声明就会报未定义。
解决:源文件统一.c;已经写成别的名字,用gcc -x c file.ext强制指定语言。另外在 Windows 的 IDE 里写fork()报LNK2019,不是代码问题,fork是 POSIX 接口,Windows 运行时根本没有,得在 Linux 环境里写和编,或者用虚拟机的共享目录把文件传过去再编。
5.3 并发类:线程同步实验里的死锁和丢失唤醒
现象:程序跑着跑着卡死不退,gdb挂上去bt一看,所有线程都停在pthread_mutex_lock或pthread_cond_wait。
原因常见三种:同一线程对普通互斥锁重复加锁;某条分支return前忘了unlock;条件变量用if判断谓词而不是while,被唤醒后条件其实还不成立,或者唤醒发生在wait之前直接丢了。
解决:加锁解锁配对写,用pthread_mutex_trylock排查重入;pthread_cond_wait永远包在while (!ready) pthread_cond_wait(...)里;跑一遍valgrind --tool=helgrind ./sync,它能直接指出锁顺序和未加锁访问。这段代码我一般会先写单线程版本验证逻辑,再加锁,比一上来就多线程好排错。
5.4 内核类:缺页数和系统调用号对不上
现象:缺页统计程序跑出来minflt是 0 或者大得离谱;自定义系统调用编译通过但一调用就返回 -1。
原因:getrusage(RUSAGE_CHILDREN)在子进程还没wait时读不到数据;缺页统计放在fork之后读,把子进程的账算到了自己头上。系统调用号方面,自己加的系统调用如果复用了已有编号,或者只编了模块没更新系统调用表,syscall()就返回ENOSYS。
解决:统计自身缺页固定用RUSAGE_SELF,并且在fork之前读一次做基线;自定义系统调用改完内核源码后要完整编译并安装新内核,只make modules_install不重启等于没生效;验证时用dmesg | tail看自己加的printk有没有打出来,比猜快。
5.5 内存类:free 之后还能读到数据,统计全乱
现象:内存管理实验里free(p)之后printf("%d", *p)还能打出原值,程序不崩,导致误判“释放没生效”。
原因:free只是把这块内存还给分配器,不保证清零,也不保证立刻归还操作系统;内容还在原地,读到的是脏数据。这是未定义行为,换台机器、换个编译选项就可能段错误。
解决:释放后把指针置NULL,访问前判空;用valgrind --leak-check=full ./mem跑一遍,能同时看到泄漏和非法访问;想看真实的内存归还情况,读/proc/<pid>/status里的VmRSS,而不是看free命令输出的那个free列(那一列包含缓存,容易误读)。
注意:
/proc下的数字都是瞬时快照,任何“跑一次记一个数就下结论”的做法都不可靠,至少采三次看波动范围。
6. 把七次实验串成一条验证链:脚本化复现与记录技巧
七次实验分开跑,每次手动gcc、手动./a.out、手动截图,重复劳动很多,而且过两周就记不清哪个输出对应哪次编译。我后来的做法是写一个跑批脚本,把所有源码统一编译、限时运行、把输出和当时的/proc快照一起归档,文件名带时间戳。
#!/usr/bin/env bash set -euo pipefail OUT="run_$(date +%Y%m%d_%H%M%S)" mkdir -p "$OUT" for src in lab*.c; do name="${src%.c}" # -g 保留符号,-pthread 兼容线程实验,编译日志单独存 gcc -g -O0 -Wall -Wextra -pthread -o "$OUT/$name" "$src" \ 2> "$OUT/$name.build.log" || { echo "$name BUILD FAIL" >> "$OUT/_status.log"; continue; } # 限时 20 秒,防止信号实验等不到 Ctrl+\ 时挂死 timeout 20 "./$OUT/$name" > "$OUT/$name.out" 2>&1 \ || echo "$name exit=$?" >> "$OUT/_status.log" # 顺手留一份运行时快照,方便回头对账 { cat /proc/loadavg; grep -E 'MemTotal|MemAvailable' /proc/meminfo; } \ > "$OUT/$name.snapshot" doneset -euo pipefail让脚本遇到错误立刻停、变量未定义报错、管道中任一环失败都算失败,避免 silently 跑完一堆空文件。timeout 20是关键,信号实验等着人工按键,不设上限会一直挂着。_status.log收集所有失败项,跑完先看这个文件,比逐个翻输出快。归档目录按时间戳命名,同一份代码改完再跑一次不会覆盖上次结果,对比两次差异就能定位是哪次改动引入的问题。
进阶一点,用strace验证进程和信号行为,比看printf可靠:
# 只看进程创建、信号发送和回收三类系统调用 strace -f -e trace=clone,fork,kill,wait4 ./tree 2>&1 | grep -E 'clone|kill|wait4'输出里能看到每次fork返回的父子 PID、kill的目标和信号号、wait4回收的是哪个 PID。如果kill的目标 PID 和ps里看到的对不上,说明你的变量被覆盖了;如果wait4一直不返回,说明子进程没退出,问题在信号处理那侧,不在回收这侧。
记录方式上,我习惯每条实验结论配一行“命令 + 关键输出片段 + 一句解释”,比如strace显示三次kill依次打向孙、子、父,就写“信号按层级传递,未出现跨级终止”。这种记录隔半年再看还能复现,比贴一张全屏截图有用得多。从那以后我每次重做这类实验,都强制先跑一遍脚本、把_status.log和strace输出留档再动手改代码,省下来的排查时间远超写脚本的那二十分钟。希望帮到你。
本文还有配套的精品资源,点击获取