简介:西南交通大学操作系统实验报告,是一份获得95分(A)的高分课程成果,面向操作系统课程学习者、Linux初学者及需要撰写实验报告的高校学生。报告基于2020-2021学年软件工程专业教学要求,完整记录多个核心实验:熟悉Linux目录结构与Shell常用命令(cd、ls、ps、chmod)及重定向、管道机制;使用vi/gedit与gcc/gdb完成C程序编写、编译与调试;通过/proc虚拟文件系统观测cpuinfo、version、stat、uptime、meminfo、loadavg、interrupts等系统状态信息;并涉及软中断通信、进程调度(FCFS与RR策略)和线程同步实验,每项均含实验目的、步骤与总结,方便对照实践。资源包为docx单文件,约1.48MB,排版清晰可直接阅读。目前已有401人学习浏览,适合需要系统梳理Linux实验要点、提升内核观测与C调试能力的读者参考。
1. 为什么操作系统实验要当成一套「Linux 全链路打通」来做
操作系统实验能拿到 95 分,往往不是最后一个代码能跑,而是整条链路能不能被讲清楚。我见过不少同学在截止前几天把七个实验的 C 程序一次性凑齐,代码也确实出结果,但验收时被追问一个底层原理就直接卡壳。这套来自某高校软件工程专业的操作系统实验方案,把 Linux 环境搭建、Shell 命令、gcc/gdb 调试、软中断通信、进程调度、线程同步、内存管理、系统调用与缺页统计七个实验串成了一条可复现的实践链路。对正在做操作系统课设或毕设的学生来说,它可以当作搭建自己方案的底稿;对想快速回顾 Linux 进程管理与 /proc 机制的从业者,也是一份合适的参考材料。
2. Linux 环境搭建与调试工作流:目录结构、Shell 命令与 gcc/gdb
2.1 目录结构与 Shell 命令:先弄清 Linux 的“家底”
第一次进 Linux 终端,最直观的感受是所有东西都挂在一个根目录/下面,路径用正斜杠分隔,这和 Windows 的盘符加反斜杠完全不同。很多同学实验一卡住,就是因为连目录结构都摸不清,源码随手乱放,最后编译时怎么也找不到路径。这套实验的第一步,就是先把目录体系记下来。
实验报告里重点列出的目录足够覆盖全部实验场景:/bin存放最常用命令,/home是用户主目录,/media挂载外接设备,/opt放额外安装的软件,/lib是 C 编译器的库目录,/tmp放临时文件。而/proc是进程信息的虚拟文件系统,观察系统状态时每天都会用到,但千万不要往里面写入内容。实验源码统一放在/home下的用户目录,编译产生的临时文件放到/tmp,系统目录一律只读,这三条习惯能避免大半环境问题。
Shell 命令方面,重点掌握cd、ls、ps、chmod,以及重定向 redirect 与管道 pipe 的组合用法。单个命令单独用理解不了系统全貌,组合起来才有效率:
# 编译并保留错误日志,避免刷屏导致第一条报错被淹没 gcc -o myprog myprog.c 2> build.log # 只查看编译日志的最后 30 行,快速定位第一个错误位置 tail -30 build.log # 过滤进程输出,确认 fork 出来的子进程是否还在运行 ps -ef | grep myprog管道把ps和grep串起来,是实验期间排查进程的固定手段。重定向时2>只捕获标准错误输出,这样 stdout 和 stderr 分离,调试阶段定位问题会快很多。
2.2 用 Vim + gcc + gdb 建立调试闭环
第一次在没有图形 IDE 的 Linux 下写 C 程序确实不适应,但 vim 作为编辑器、gcc 作为编译器、gdb 作为调试器,三者组合完全够用,而且比 IDE 更能看清编译和链接的细节。vim 只需要记住几个操作:按i进入编辑模式,写代码;按ESC退回一般模式;输入:wq保存退出。需要另存文件时,:w 新文件名就能搞定,不用退回 Shell 再用mv。
gcc 编译这步最容易掉链子。很多同学不带-g就编译,真到 gdb 调试时发现没有符号信息,只能回头重新编译。建议每次编译都带上完整参数:
gcc -Wall -g -o myprog myprog.c-Wall让所有可能的警告都显现,-g生成调试信息。gdb 打开可执行文件后,先用break main在入口下断点,run启动,next单步,print查看变量值。对于 fork 多进程的调试,gdb 还需要根据需求设置 follow-fork-mode,否则只能跟踪到父进程或子进程其中一方。软中断通信实验里的wait_mark全局变量,用print一下就能确认信号处理函数是否被正确调用,比打日志定位快得多。
2.3 环境问题排查:网络连接与文件传输的踩坑记录
实验环境搭建中,网络问题排在第一位。常见现象是虚拟机配置向导全部走完,系统正常启动,但终端里 ping 外网持续失败。这个现象多半是虚拟机的网络模式选错了。
桥接模式下虚拟机和宿主机处于同一网段,互相能访问,但局域网内设备一多就容易 IP 冲突;NAT 模式允许虚拟机访问外网,外部机器却无法反向访问虚拟机,相当于虚拟机通过宿主机代理上网;主机模式则将虚拟机和宿主机网络完全隔离,只能本机连通,访问不了外网。实验环境采用 NAT 模式最合适:既能安装软件包和拉取依赖,又天然避免 IP 冲突。
文件传输也存在一个隐藏的坑。如果代码先在 Windows 编辑器里写好,再通过 xftp 传到 Linux,需要特别注意代码中是否出现了fork()、signal()这类 POSIX 接口。Windows 本地运行时,这些函数没有对应实现,IDE 会直接报错,但 Linux 下不一定有问题。解决方式是不要依赖 Windows 环境做编译验证,统一传到 Linux 内用 gcc 重新编译一次,所有报错以 Linux 下的结果为准。
3. /proc 文件系统观测:六个内核状态接口的读取与编程实现
3.1 先理解 /proc 是什么
/proc 不是硬盘上真实存在的文件,而是内核运行期间动态生成的虚拟文件系统。在 /proc 目录下能看到两类条目:一类是纯数字的子目录,每个数字对应当前正在运行的一个进程号,目录内保存该进程的运行信息;另一类是系统级的文件,如 cpuinfo、meminfo、version、stat、uptime 等,分别对应 CPU 信息、内存信息、内核版本、系统状态与运行时长。
实验的目的在于,通过读取这些文件理解 Linux 内核向用户态暴露状态的方式。以下六个文件是课设中重点观测对象:/proc/cpuinfo读取 CPU 型号与特性,/proc/version读取内核版本号,/proc/stat读取进程状态与 CPU 使用统计,/proc/uptime读取系统运行时长,/proc/meminfo读取内存与交换分区使用量,/proc/loadavg读取系统平均负载。/proc/interrupts则用于查看中断使用情况。
注意:读取 /proc 不需要 root 权限,但写入虚拟文件会直接影响内核行为。实验一律只做读取,不做写入。
3.2 读取 cpuinfo、version、stat、uptime
最直接的获取方式是使用 cat 命令查看:
cat /proc/cpuinfo cat /proc/version cat /proc/stat cat /proc/uptime/proc/version 输出中的内核版本号遵循一套规律——主版本号的第二位为偶数代表稳定版本,奇数代表开发版本。这个细节常被导师在验收时追问,属于必须掌握的常识。例如 3.10.0-1160 这类版本号,写入报告时只需要说明版本解析规则,不需要标新立异。
/proc/uptime 会输出两个数字:第一个是系统总运行秒数,第二个是系统空闲秒数。两者之差除以总时间就是系统忙时比例,报告里用一行计算就能解读负载状态。实验要求对比 uptime 和 cpuinfo 的输出,本质上是让你从 CPU 属性和实际运行时长两个维度交叉验证系统状态。
3.3 编程实现对 meminfo、loadavg、interrupts 的观测
用 cat 看文件只是基础,编程读取才是实验的核心。我习惯把读取逻辑单独封装成函数,通过命令行参数决定查哪一项,这样三个观测点共用一套框架:
#include <stdio.h> #include <stdlib.h> #include <string.h> void read_meminfo() { FILE *fp = fopen("/proc/meminfo", "r"); char line[256]; if (fp == NULL) { perror("open meminfo failed"); exit(1); } while (fgets(line, sizeof(line), fp) != NULL) { // 只输出前四行关键字段,避免整文件刷屏 if (strncmp(line, "MemTotal", 8) == 0 || strncmp(line, "MemFree", 7) == 0 || strncmp(line, "Buffers", 7) == 0 || strncmp(line, "Cached", 6) == 0) { printf("%s", line); } } fclose(fp); } void read_loadavg() { FILE *fp = fopen("/proc/loadavg", "r"); double run_1, run_5, run_15; int running, total, last_pid; if (fp == NULL) { perror("open loadavg failed"); exit(1); } // 依次解析三个平均负载字段、运行进程数与总进程数、最近进程号 fscanf(fp, "%lf %lf %lf %d/%d %d", &run_1, &run_5, &run_15, &running, &total, &last_pid); printf("loadavg: %.2f %.2f %.2f, running %d/%d, last_pid %d\n", run_1, run_5, run_15, running, total, last_pid); fclose(fp); } int main(int argc, char **argv) { if (argc < 2) { fprintf(stderr, "Usage: %s [meminfo|loadavg|interrupts]\n", argv[0]); return 1; } if (strcmp(argv[1], "meminfo") == 0) { read_meminfo(); } else if (strcmp(argv[1], "loadavg") == 0) { read_loadavg(); } else { fprintf(stderr, "unsupported argument\n"); return 1; } return 0; }loadavg 的输出里,三个小数分别表示 1 分钟、5 分钟和 15 分钟内的平均负载,running/total 是当前运行进程数与总进程数,last_pid 是最近创建的进程号。平均负载高于 CPU 核心数时,说明系统已经过载,这是一项可以结合硬件配置来叙述的分析点。interrupts 的读取逻辑与 meminfo 完全一致,用 fopen 打开文件后按固定格式逐行解析即可,不需要额外引入复杂的数据结构。
4. 软中断通信实验:从 fork 到信号的进程控制全流程
4.1 信号机制与实验目标
软中断通信是操作系统课程里最能说明异步机制的一组实验。目标分为两层:第一层理解信号,明白信号是操作系统与进程之间的一种软中断手段;第二层用自定义信号在父子进程之间实现按序终止。整个实验通过fork()创建进程树,通过signal()注册信号处理函数,通过kill()向指定进程发送信号。
实验有两个递进场景。场景一:父进程创建两个子进程,终端输入 Ctrl+\ 后,父进程收到 SIGQUIT,再依次终止两个子进程并退出。场景二:父进程创建子进程,子进程再创建孙进程,Ctrl+\ 后信号从父传到子、从子传到孙,终止顺序为孙、子、父。两个场景共享同一套基本代码框架,区别只在于进程树的形状。完成场景一再改场景二,工作量会小很多。
4.2 场景一:一个父进程创建两个子进程
核心代码可以浓缩为以下结构,省略了 lockf 在终端输出时的加锁保护:
#include <stdio.h> #include <stdlib.h> #include <signal.h> #include <unistd.h> int wait_mark = 1; // 信号处理函数:将等待标志置 0,waiting() 因此退出循环 void stop() { wait_mark = 0; } void waiting() { while (wait_mark != 0) ; } int main() { int p1, p2; // 创建 P1,fork 返回 -1 时持续重试 while ((p1 = fork()) == -1) ; sleep(1); if (p1 > 0) { // 父进程分支:继续创建 P2 while ((p2 = fork()) == -1) ; if (p2 > 0) { wait_mark = 1; signal(SIGQUIT, stop); waiting(); // 向两个子进程发送自定义信号,通知它们退出 kill(p1, 16); kill(p2, 17); wait(0); wait(0); printf("parent process %d is killed!\n", getpid()); exit(0); } else { // P2 分支:忽略 SIGQUIT,只等待 17 号信号 wait_mark = 1; signal(SIGQUIT, SIG_IGN); signal(17, stop); waiting(); printf("child P2 process %d is killed by parent %d\n", getpid(), getppid()); exit(0); } } else { // P1 分支:忽略 SIGQUIT,只等待 16 号信号 wait_mark = 1; signal(SIGQUIT, SIG_IGN); signal(16, stop); waiting(); printf("child P1 process %d is killed by parent %d\n", getpid(), getppid()); exit(0); } }wait_mark 是信号响应开关的关键。初始为 1,waiting() 做空转;stop() 被信号处理器调用后把 wait_mark 置 0,waiting() 退出,进程继续执行。两个子进程都用SIG_IGN忽略 SIGQUIT,是为了保证 Ctrl+\ 只触发父进程的信号处理,否则终端会把信号发给前台进程组中的所有进程,逐级终止的观察效果就不存在了。
kill(p1, 16)这里的 16 是自定义信号编号,并非固定标准。不同 Linux 发行版上某些编号可能被占用,如果实验环境特殊,最好先通过kill -l查看本机可用信号编号,避免信号被系统拦截后不起作用。
4.3 场景二:祖孙三代进程的终止顺序
场景二在代码结构上改成链式传递:父进程创建子进程,子进程再创建孙进程。父进程收到 SIGQUIT 后向子进程发送 16 号信号;子进程收到后先向孙进程发送 16 号信号,等孙进程退出后再退出,最后父进程退出。
int main() { int p1; // 第一次 fork:创建子进程 while ((p1 = fork()) == -1) ; if (p1 > 0) { wait_mark = 1; signal(SIGQUIT, stop); waiting(); kill(p1, 16); wait(0); printf("parent process is killed\n"); exit(0); } else { // 子进程:继续 fork 创建孙进程 while ((p1 = fork()) == -1) ; if (p1 > 0) { wait_mark = 1; signal(SIGQUIT, SIG_IGN); signal(16, stop); waiting(); kill(p1, 16); wait(0); printf("child P1 process %d is killed by parent %d\n", getpid(), getppid()); exit(0); } else { // 孙进程:等待信号,打印后退出 wait_mark = 1; signal(SIGQUIT, SIG_IGN); signal(16, stop); waiting(); printf("grandson process %d is killed by parent %d\n", getpid(), getppid()); exit(0); } } }这个场景里最容易搞错的是各分支的退出顺序。正确设计里,孙进程不要调用 wait(),它只需要等信号、打印退出;子进程在向孙进程发完信号后调用 wait(0) 回收;父进程对子进程做同样处理。如果子进程提前调用 exit() 或者父进程少写了一个 wait(0),终端输出就会缺少孙进程被终止的记录,整个链路断在半截。
4.4 实验结果解读与验证步骤
软中断实验的验证建议按四步走:第一步编译源码并启动程序;第二步另开终端查看进程树;第三步回到程序终端按 Ctrl+\;第四步对比退出顺序,并把日志写入实验报告。直接跑代码很容易,但报告里必须写出“子进程先退出,父进程最后退出”的完整顺序。
执行顺序在场景一中存在随机性。由于 fork 之后两个子进程的调度顺序不完全确定,sleep(1) 只能稳定父进程先与子进程打印信息,P1、P2 谁先被终止仍要看当时的调度结果。实验报告可以如实记录这个现象并说明原因,反而比刻意调整时序更有说服力。
5. 进程调度、线程同步、内存管理与缺页统计:联合落地与常见问题排查
5.1 进程调度实验:FCFS 与 RR 策略的验证
进程调度实验要求在创建的工程中分别验证 FCFS(先来先服务)和 RR(时间片轮转)两种策略。FCFS 的设计逻辑很直白:维护一个按到达时间排序的队列,进程按到达顺序依次占用 CPU 直到结束;RR 则在就绪队列中循环分配 CPU,每个进程运行一个时间片,时间片结束后回到队尾继续排队。
模拟调度时通常会定义一个进程控制块结构体,里面放入 pid、到达时间、服务时间和剩余时间:
typedef struct { int pid; int arrival; int burst; int remaining; } proc_t;FCFS 的验证重点是平均周转时间:进程从进入到结束的总时间之和再除以进程数。RR 的验证重点则是时间片大小对平均周转时间的影响——时间片过大会接近 FCFS,时间片过小会增加上下文切换开销,课堂演示中通常给出一个适中值。报告里应当把两种策略的运行结果放在一起做数值对比,而不是只贴两张截图。
5.2 线程同步实验:锁与信号量的作用
线程同步实验通常用共享计数器或共享缓冲区来展示数据竞争。多个线程同时执行 counter 自增,由于线程切换可能发生在自增指令之间,最终计数值常常小于预期。加锁之后的计数值与理论值一致,这就是同步存在的意义。
核心代码只需展示锁的用法:
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; int counter = 0; void *worker(void *arg) { for (int i = 0; i < 1000; i++) { pthread_mutex_lock(&lock); counter += 1; pthread_mutex_unlock(&lock); } return NULL; }实验报告中需要写清楚一个细节:互斥锁只是让临界区互斥,并不会改变线程被调度的顺序。如果作业要求的同步目标是按特定顺序执行,比如生产者必须先于消费者生产,那就得用信号量或条件变量,互斥锁不够用。答辩常问“锁和信号量有什么区别”,从“互斥 vs 排序”两个角度回答就能讲透。
5.3 内存管理与系统调用实验:从接口到机制
内存管理实验的核心是认识虚拟地址到物理地址的映射关系,以及进程如何向内核申请和释放内存。常见的落地方式是调用 getpagesize() 查看本机页大小,再通过读取 /proc/self/stat 或 sysinfo 获取进程内存使用情况。页大小决定了分页机制下的最小分配单位,做完这组实验再去做缺页统计会顺畅得多。
系统调用实验的重点是观察用户态到内核态的切换过程。以最常用的获取进程号为例,正常编程用库函数 getpid(),但内部实际是通过 syscall 指令陷入内核完成的:
#include <sys/syscall.h> #include <unistd.h> // 直接通过系统调用号调用,绕过库函数封装 long pid = syscall(SYS_getpid);SYS_getpid 是内核为 getpid 分配的系统调用号,用户态程序把系统调用号和参数放进寄存器,触发 syscall 指令后由内核完成真正的执行。实验报告写到这一层,就已经从“会用函数”上升到了“理解机制”。
5.4 缺页次数统计实验:从统计字段到结论解读
缺页次数统计实验的数据来源在 /proc/self/stat,其中 minflt 表示次要缺页次数,majflt 表示主要缺页次数。次要缺页是指所需页面已在内存中但不在当前进程页表中,主要缺页则需要从磁盘换入页面,性能开销完全不同。
读取这两个字段的 C 代码可以这样写:
FILE *fp = fopen("/proc/self/stat", "r"); int pid; char comm[256]; unsigned long minflt, majflt; // 跳过前面多个字段,第 10 和第 11 个字段分别是 minflt 与 majflt fscanf(fp, "%d %s %*s %*d %*d %*d %*d %*d %*d %*d %*d %*d %lu %lu", &pid, comm, &minflt, &majflt); printf("PID %d, minflt=%lu, majflt=%lu\n", pid, minflt, majflt); fclose(fp);%*s表示跳过字符串字段,%*d跳过整型字段,%lu读取无符号长整型。这段代码先统计一次当前进程的缺页数,再访问一个较大数组触发缺页,然后再读取一次并计算差值,就是实验要求的缺页统计过程。结果解读时要注意:缺页本身是正常的内存管理机制,只有总是出现 majflt,才说明物理内存真的不够用了。
5.5 最容易翻车的三个点
现象一:代码扔到 Linux 下,gcc 报出“未定义引用”错误。
原因:代码中调用了 fork()、signal() 等 POSIX 接口,但编译环境缺失对应的头文件路径或链接库。
解决:编译命令中显式携带 -Wall,并确认头文件已经 include;优先使用 gcc 在 Linux 内编译验证,不要只在 Windows IDE 上看到编译通过就认为没有问题。
现象二:按 Ctrl+\ 后所有进程同时退出,完全看不到逐级终止。
原因:子进程没有忽略 SIGQUIT,终端把信号发送给前台进程组中的所有进程,父进程和所有子进程同时响应。
解决:在每个子进程分支中调用 signal(SIGQUIT, SIG_IGN),保证只有父进程处理该信号,再通过 kill() 分发自定义信号。
现象三:读取 /proc/self/stat 时数值解析错乱,minflt 和 majflt 得到巨大异常值。
原因:/proc 文件字段顺序复杂,前面的进程名可能因为带空格或括号而影响格式化解析。
解决:先用 cat 命令查看该文件的真实格式,再对照字段顺序写 fscanf,必要时以 %*s 显式跳过不受控字段。
6. 验收与答辩:把实验方案讲成一套能落地的故事
答辩能不能过关,取决于三个准备动作:结构梳理、代码重点、结果解读。整个验收环节的核心不是“代码能不能跑”,而是“每个实验的设计意图能不能说清楚”。
结构上,每份实验报告都按照目的、内容、步骤、结果、总结五段来组织。最容易跳过的“实验结果解读”,恰好是评分时拉开差距的地方。不要只贴运行截图,要有对比和原因分析,例如软中断实验中输出顺序存在随机性,根本原因是 fork 后的调度时序不确定;FCFS 和 RR 的平均周转时间差异,来源于是否抢占。
代码重点上,每段代码准备两个讲解点:一个关键逻辑,一个易错点。以软中断通信实验为例,先说 wait_mark 是什么,再说如果子进程没有忽略 SIGQUIT 程序会怎么翻车。能说清楚这两点,基本就能证明代码不是从别处直接复制来的。
答辩问答环节可以提前准备一张自查表:
- 问题:/proc 是真实文件系统吗?
回答:不是,它是内核动态生成的虚拟文件系统,用于暴露系统状态。 - 问题:FCFS 与 RR 的核心区别?
回答:FCFS 没有抢占,调度到结束;RR 用时间片轮转,可以抢占。 - 问题:缺页统计中 minflt 和 majflt 的含义?
回答:minflt 是页面未映射但已在物理内存,majflt 需要磁盘 I/O 补充页面。
从那以后,我每次做实验报告都强制自己走一遍“结构、代码、解读”的顺序,宁可少写一点功能代码,也要把每条关键步骤的原因写清楚。希望这套方案也能帮到你。
本文还有配套的精品资源,点击获取