简介:这是一份面向高校软件工程与计算机专业学生的操作系统实验报告文档,来自西南交通大学课程实践,获评分A(95分),适合正在学习Linux操作系统、需要完成实验报告或复盘课程实验的读者参考。文档以单个docx文件交付,压缩包约1.48MB,内容按实验组织,覆盖Linux用户界面与目录结构、Shell常用命令及重定向与管道机制、C语言程序编辑编译与gdb调试、/proc文件系统下cpuinfo、meminfo、loadavg、interrupts等内核状态观测,并延伸至软中断通信、进程调度与线程同步实验,实验链路较完整。报告结构完整,含个人声明、目录、实验目的、实验内容、步骤与总结,便于对照课程要求梳理实验流程、理解内核变量观测方法、复盘进程调度策略的验证思路。目前已有404人学习,适合需要完成课程实验报告、补充Linux实践记录或准备相关实验考核的读者参考,也可作为理解操作系统实验组织方式的样例材料。
1. 知识地图:八次实验其实只讲四件事
把 linux 操作系统这套实验按「内核向外暴露了什么信息」重新排列,八次实验会变成四条主线:命令行与/proc是读取内核信息的入口,fork与signal是管理进程的入口,FCFS/RR 与互斥量是调度和并发的入口,内存分配、系统调用、缺页计数是资源管理的入口。四条线看起来独立,却共用同一套工具——gcc编译、gdb断点、cat /proc/xxx看实时数值。这份材料适合两类人:正在跟课做实验、需要一份能照着改的骨架;以及期末想把零散实验整理成体系、答题时能说清「为什么这样写」的人。真正拉开分数的从来不是代码能不能跑通,而是你能不能解释进程树长什么样、缺页为什么是这个量级。
2. Linux 环境与 /proc:命令、目录与内核状态的读取方式
2.1 目录结构:一棵树而不是一堆盘符
Linux 只有一个根/,所有分区、设备、网络文件系统都是「挂载」到某个目录上的,而不是先有 C:、D: 再往里塞东西。这个差别影响后面每一次路径书写:绝对路径从/起算,相对路径从当前工作目录起算,分隔符永远是正斜杠/,反斜杠在 Shell 里是转义符。把常用目录整理成一张表,比死记更能应付提问:
| 目录 | 存放内容 | 复习时要能答出的点 |
|---|---|---|
/bin | 最常用的用户命令 | 与/sbin的分工:后者面向系统管理 |
/home | 普通用户主目录 | 登录后的默认工作目录就是~ |
/lost+found | 非正常关机后找回的碎片文件 | 只有 ext 系列文件系统才有这个目录 |
/media | 自动挂载的外接设备 | 与/mnt这个手动挂载点区分开 |
/opt | 额外安装的第三方软件 | 与/usr/local的取舍要能讲一句 |
/lib | 共享库与内核模块 | 编译时-l找的库、ldd看的依赖都在这一带 |
/proc | 内核与进程的运行时信息 | 虚拟文件系统,不占磁盘,读它等于问内核 |
/tmp | 临时文件 | 可能被清理,别把要交的东西放这儿 |
整理下来会发现,只有/proc是个「假目录」:里面看不到真实扇区,读一次文件就触发一次内核现场计算。理解了这一点,后面观察cpuinfo、meminfo才不会把它们当成静态配置文件。
2.2 终端四件事:切换、查看、进程、权限
cd、ls、ps、chmod这四个命令必须能说清语义,而不只是会用。ls -l输出的十位串,第一位是文件类型(-普通文件、d目录、l符号链接),后九位按「属主、属组、其他」三组各三位rwx排布;chmod 644 file与chmod u+rw,go+r file是同一件事的两种写法,前者是八进制位权,后者是符号法。ps -ef与ps aux都能列进程,前者偏重 UID、PID、CMD,后者多出%CPU、%MEM,做资源观察时用后者更顺手。cd -回到上一个目录这个小技巧,在深路径里来回跳时能省很多时间。
重定向和管道是把单条命令拼成流水线的关键:>覆盖写、>>追加、2>单独接错误流、&>合并两路、|把前一条的 stdout 交给后一条的 stdin。举个实验里天天用的组合:
# 列出当前目录所有 .c 文件,逐个搜含 fork 的行,正常结果写文件,报错单独留档 ls *.c | xargs grep -n "fork" > fork_lines.txt 2> err.log这里的xargs负责把文件名列表转成grep的参数,>只接收标准输出,2>把「文件不存在」这类错误信息隔离到另一个文件——如果不分开,错误信息会混进结果文件,后面排查时就分不清哪行是真结果。管道只传 stdout,这一点是笔试常考点,也是很多人第一次写脚本时踩的坑。
2.3 vim、gcc、gdb:从源码到可执行文件的四步
编辑器、编译器、调试器是一条固定链路,缺一环都跑不起来:
vim argc.c # i/o/a 进插入模式,Esc 回普通模式,:wq 保存退出 gcc -g -Wall -o argc argc.c # -g 保留调试符号给 gdb 用,-Wall 打开常用告警,-o 指定输出名 chmod +x argc # 权限不足时补一步,或直接用 ./argc 运行 gdb ./argc # b main 下断点,r 运行,n 单步,p 变量 打印几个参数值得记住:不带-g编译出来的程序在gdb里看不到源码行,只能看汇编;-Wall会提示「变量声明了没用」「函数没有返回值」这类低级错误,实验报告里那些莫名其妙的段错误,一半能靠它提前发现。想在 vim 里另存为新文件名,用:w newname.c;已经退出编辑器后改名,用mv argc.c oslab1.c。这两种改名方式经常被混着问。
2.4 /proc:把内核变量当文件读
/proc下有两类内容:一类是以数字命名的子目录,每个数字是一个正在运行的进程号,里面记录该进程的status、maps、fd等信息;另一类是固定名字的全局文件,每个文件对应一组内核变量。
| 文件 | 能读到什么 | 常见用途 |
|---|---|---|
/proc/cpuinfo | 处理器型号、核数、缓存、频率 | 判断机器规格、看是否开了超线程 |
/proc/version | 内核版本与编译信息 | 版本号偶数一般是稳定版,奇数是开发版 |
/proc/stat | CPU 各态时间、中断、上下文切换 | 算 CPU 使用率的原始数据 |
/proc/uptime | 系统已运行秒数与空闲秒数 | 配合 jiffies 理解计时机制 |
/proc/meminfo | 总内存、空闲、缓存、交换 | 观察内存压力,单位是 kB |
/proc/loadavg | 1、5、15 分钟平均负载 | 判断系统是否过载 |
/proc/interrupts | 每个 CPU 上各中断号的触发次数 | 看设备是否在正常发中断 |
/proc/filesystems | 当前内核支持的文件系统类型 | 挂载前的可行性检查 |
uptime背后是 jiffies:内核里一个全局变量,每个时钟节拍加一,开机时用 RTC 读到一个基准时间,换算成距 1970-01-01 00:00:00 UTC 的节拍数存进去,之后系统时间就是「基准节拍 + 当前 jiffies」。这套换算在讲进程调度和缺页统计时还会再出现,因为内核里几乎所有「时间」都是用节拍表示的。
用 C 程序读这些文件并不复杂,关键是把「打开—逐行读—关闭」这条固定套路写对:
/* 只取 meminfo 里的两个关键字段,观察单位与口径 */ #include <stdio.h> #include <string.h> int main(void) { FILE *fp = fopen("/proc/meminfo", "r"); /* 虚拟文件系统,open 时内核才生成数据 */ char line[256]; if (!fp) { perror("fopen"); return 1; } while (fgets(line, sizeof(line), fp)) { /* 每行格式固定为 "键: 数值 kB" */ if (strncmp(line, "MemTotal", 8) == 0 || strncmp(line, "MemFree", 7) == 0) fputs(line, stdout); } fclose(fp); /* 关闭句柄后数据失效,下次读要重新 open */ return 0; }fgets按行读比fscanf稳妥,因为/proc里字段数量随内核配置变化,按行切再自己解析更抗变化。这里最容易忽略的是单位:meminfo里除少数几个字段外,数值单位都是 kB,直接当字节用会差三个数量级。
2.5 虚拟机网络三模式:为什么实验环境选 NAT
做实验绕不开虚拟机联网,三种模式的连通方向完全不同,理解错了会浪费一整晚在 ping 不通上。
| 模式 | 虚拟机能否访问外网 | 宿主机能否访问虚拟机 | IP 来源 | 典型问题 |
|---|---|---|---|---|
| 桥接 | 能 | 能 | 与宿主机同网段的独立地址 | 同网段设备多时容易地址冲突 |
| NAT | 能 | 视端口转发配置而定 | 由虚拟网络分配,可不同网段 | 外部设备主动连不进来 |
| 仅主机 | 不能 | 能 | 内部网段 | 与真实网络隔离,装包会失败 |
桥接相当于把物理网卡变成一台交换机,虚拟机是挂在交换机上的另一台机器,地址同段、互相可见,代价是占地址、易冲突。NAT 让虚拟机借宿主机出口访问外部,出口地址是宿主机的,冲突概率低,代价是外部无法主动发起连接。仅主机模式把两者彻底隔离,适合做纯本地测试。实验环境一般选 NAT:既能装编译工具链,又不用管地址规划。这套划分答简答题时写清「连通方向 + 地址来源」两点就够。
3. 进程主线:fork、信号、调度与互斥
3.1 fork 的返回值决定你站在哪条分支
fork()调用一次返回两次,这是进程实验里最反直觉的一点。父进程拿到子进程的 pid(大于 0),子进程拿到 0,出错返回 -1。判断分支时先判> 0还是先判== 0都不影响正确性,但会影响你对进程树的直觉:代码里同一段文字被两个进程各执行一遍,如果不加区分地printf,屏幕上就会出现两份输出。
创建进程树时还要注意回收。父进程不wait(),子进程退出后会留下僵尸条目,ps里状态列显示Z。实验里常见写法是父进程发完信号后连续两次wait(0),对应它创建的两个子进程;如果子进程数量不确定,用while (wait(NULL) > 0);更稳。
3.2 信号:用名字而不是裸数字控制进程树
软中断通信实验的核心是「父进程捕获终端按键 → 转化为信号 → 通知子进程退出」,流程可以压缩成一段能跑的骨架:
#include <stdio.h> #include <signal.h> #include <unistd.h> #include <stdlib.h> volatile sig_atomic_t wait_mark = 1; /* 处理函数里只改标志位,别做打印等不可重入操作 */ void stop(int sig) { wait_mark = 0; } /* 信号一到就把循环条件打掉 */ int main(void) { pid_t p1 = fork(); /* 父进程得 pid,子进程得 0,失败得 -1 */ if (p1 < 0) { perror("fork"); return 1; } if (p1 > 0) { /* 父进程分支 */ signal(SIGQUIT, stop); /* Ctrl+\ 发 SIGQUIT,父进程先挂上处理函数 */ while (wait_mark) pause(); /* pause 让出 CPU 等信号,比空转省资源 */ kill(p1, SIGUSR1); /* 用有名字的信号,而不是 16、17 这种裸数字 */ wait(NULL); /* 回收子进程,避免留下僵尸 */ printf("parent %d exit\n", getpid()); } else { /* 子进程分支 */ signal(SIGQUIT, SIG_IGN); /* 忽略 Ctrl+\,保证退出顺序由父进程掌控 */ signal(SIGUSR1, stop); while (wait_mark) pause(); printf("child %d exit\n", getpid()); } return 0; }几个点值得说透。signal(SIGQUIT, SIG_IGN)的作用是让子进程对终端按键免疫,否则在终端按一次 Ctrl+\,整个进程组一起死,就看不到「依次终止」的顺序了。忙等while (wait_mark);与pause()的区别在于后者会把 CPU 让出去,实验报告里如果写「用 while 循环等待」,答辩时很可能被追问为什么不 sleep。孙进程结构只是把fork再嵌一层,父创建子、子创建孙,退出顺序反过来:先杀最深的那个,再逐层往上。
这里有个血泪经验:原报告里用16、17当自定义信号编号。在常见的 x86 Linux 上,17 恰好对应SIGCHLD,子进程一退出就可能触发,导致「子进程还没收到父进程的命令就自己结束了」这种玄学现象。规范写法是SIGUSR1、SIGUSR2,编号由系统头文件给,不同架构都不出错。
3.3 输出交错与 lockf:临界区不止线程才有
多进程同时往标准输出写,字符会交错成半行半行的乱码,因为printf内部是「格式化到缓冲区 + 写 fd」两段,中间可能被切走。lockf(1, 1, 0)就是给文件描述符 1(stdout)加锁,三个参数分别是描述符、加锁/解锁标志、加锁长度(0 表示从当前偏移到文件末尾),解锁写lockf(1, 0, 0)。这一段在实验里往往只占两行,但它解释了为什么结果图里有的行完整、有的行被撕开。
3.4 FCFS 与 RR:调度指标到底怎么算
调度实验一般是给定一组进程的到达时间与服务时间,手工算出完成时间、周转时间、带权周转时间,再用程序验证。以三个进程为例:P1 到达 0 服务 5,P2 到达 1 服务 3,P3 到达 2 服务 1。
先看先来先服务(FCFS),按到达顺序执行:P1 在 0–5,P2 在 5–8,P3 在 8–9。
| 进程 | 到达 | 服务 | 完成 | 周转=完成−到达 | 带权周转=周转/服务 |
|---|---|---|---|---|---|
| P1 | 0 | 5 | 5 | 5 | 1.00 |
| P2 | 1 | 3 | 8 | 7 | 2.33 |
| P3 | 2 | 1 | 9 | 7 | 7.00 |
平均周转时间约 6.33,平均带权周转约 3.44。再看时间片轮转(RR,q=1):t=0 只有 P1,跑 1 个时间片剩 4;t=1 时 P2 到达,队列为 P1、P2;P2 跑完一片剩 2,t=2 时 P3 到达,队列变为 P1、P3、P2;P3 在 3–4 跑完,完成时间 4;接着 P2 在 4–5、P1 在 5–6、P2 在 6–7 结束、P1 在 7–9 结束。
| 进程 | 完成 | 周转 | 带权周转 |
|---|---|---|---|
| P1 | 9 | 9 | 1.80 |
| P2 | 7 | 6 | 2.00 |
| P3 | 4 | 2 | 2.00 |
平均周转约 5.67,平均带权约 1.93。对比下来,RR 的平均周转只略好一点,但带权周转明显更优,原因是短作业 P3 没被长作业堵在后面。这就是「响应比」的直观意义,也是简答题里最常被要求解释的一句话。
3.5 线程同步:互斥量保护的是「读—改—写」三步
线程实验里最经典的翻车场景是多个线程对同一个计数器累加,结果比预期小。原因不在加法本身,而在counter++被编译成「取数—加一—写回」三条指令,中间可能被切换。互斥量保护的正是这三步的原子性:
#include <pthread.h> pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER; int counter = 0; void *worker(void *arg) { for (int i = 0; i < 100000; i++) { pthread_mutex_lock(&m); /* 进入临界区,其他线程在此阻塞 */ counter++; /* 读-改-写三步必须整体保护 */ pthread_mutex_unlock(&m); /* 离开临界区,唤醒等待者 */ } return NULL; }需要强调的是粒度和配对:锁住的范围太大退化成串行,太小则保护不到;lock与unlock必须成对,中间return或异常退出就会死锁。条件变量配合互斥量实现生产者—消费者时,判断条件要用while而不是if,防止虚假唤醒后直接往下跑。
4. 内存主线:分配、系统调用与缺页计数
4.1 从 malloc 到页框:三层结构要能分开讲
内存管理实验一般做两件事:模拟分配算法的数据结构与回收规则,观察真实系统的页行为。用户态看到的malloc/free是库函数,小片内存走堆区、通过brk扩展,大块直接用mmap映射;再往下是页表把虚拟地址翻译成物理页框;最底层才是物理内存。三个层次对应三套术语,答题时混着用就是丢分点。
模拟分配时常用的三种算法对比如下:
| 算法 | 查找方式 | 优点 | 缺点 |
|---|---|---|---|
| 首次适应 | 从头找第一个够大的空闲块 | 快,低地址优先保留大块 | 低地址碎片累积 |
| 最佳适应 | 找能满足的最小空闲块 | 剩余碎片尽量小 | 产生大量难以利用的小碎片 |
| 最坏适应 | 找最大的空闲块切分 | 切完仍可用 | 大块很快被消耗光 |
回收时的关键是合并:释放的块如果与前后空闲块地址相邻,必须合并成一个更大的块,否则空闲表里会堆满碎片,后续大请求全部失败。这一步在代码里往往只有几行判断,但它是「内存分配是否可用」的分水岭。
4.2 系统调用:从用户态陷入到内核函数
新增一个系统调用的流程是固定的五步:在内核源码里实现功能函数;在系统调用表中按编号登记函数指针;在公开头文件里补上编号宏;重新编译并安装内核;用户态用syscall()或封装好的库函数验证。用户态发起调用的写法是这样:
#include <unistd.h> #include <sys/syscall.h> long pid = syscall(__NR_getpid); /* 编号来自 sys/syscall.h,参数按 ABI 顺序跟在后面 */syscall会把编号放进约定寄存器,然后触发陷入指令切到内核态,内核按编号查表跳到对应函数,返回值经寄存器带回;失败时返回 -1 并设置errno。理解这条链路,就能解释为什么系统调用的开销明显大于普通函数调用:模式切换、参数校验、可能的数据拷贝,每一步都要花钱。
4.3 缺页统计:两种口径不要混用
缺页次数有两条获取路径。第一条是读/proc/vmstat里的pgfault与pgmajfault,这是整机自开机以来的累计值;第二条是用getrusage取当前进程的ru_minflt与ru_majflt,这是单进程维度的累计值。
#include <stdio.h> #include <stdlib.h> #include <sys/resource.h> int main(void) { struct rusage before, after; getrusage(RUSAGE_SELF, &before); /* 先记基准,后面算增量才有意义 */ size_t n = 1 << 20; /* 约 4MB 的 int 数组 */ int *p = malloc(n * sizeof(int)); for (size_t i = 0; i < n; i++) p[i] = i; /* 首次写每一页都会触发一次缺页 */ getrusage(RUSAGE_SELF, &after); printf("minor=%ld major=%ld\n", after.ru_minflt - before.ru_minflt, /* 轻微缺页:页已在内存,补映射即可 */ after.ru_majflt - before.ru_majflt);/* 重大缺页:需要读磁盘或换页 */ free(p); return 0; }两个字段的区别必须说清:轻微缺页只是页表项缺失,物理页可以立刻给(比如零页、写时复制、页缓存命中);重大缺页要等磁盘 I/O,数量级完全不同。实验里如果只报一个「缺页次数」而不区分两者,结论基本没法用。另外,测增量一定先取基准值,直接打印累计值会把进程启动时的开销也算进去,数字看着大,其实跟你的循环没关系。
4.4 三个实验怎么串成一条线
系统调用、内存分配、缺页统计放在一起看才有意义:malloc只承诺虚拟地址空间,真正占物理页是在第一次写的时候,那一次写触发轻微缺页,内核补上页表项;如果内存紧张触发了换出,再访问就是重大缺页,代价是磁盘往返。系统调用则是你主动进入内核的入口,brk、mmap都是它的具体形态。把这条链画清楚,三个实验的结论就能互相印证,而不是三份互不相干的报告。
5. 易漏点清单:五处最容易丢分的地方
5.1 fork 之后的输出重复
易漏点:父进程创建子进程后,缓冲区里的内容被复制一份,父子各刷一次,屏幕上出现重复行。为什么漏:printf不是立即写设备,而是先进用户态缓冲区,fork复制的是整个地址空间,缓冲区也在其中。怎么补:在fork之前fflush(stdout),或者每条输出后加\n并确认终端是行缓冲;判断分支时用if (pid > 0) {...} else if (pid == 0) {...}明确分隔。
5.2 信号用裸数字注册
易漏点:signal(17, handler)这类写法在换机器或换架构后行为不一致。为什么漏:编号与信号的对应关系由平台头文件决定,不同架构上同一个数字的含义可能不同;17 在常见 x86 上对应子进程状态变化信号,很容易被子进程退出误触。怎么补:一律用SIGUSR1、SIGUSR2这类有名字的常量,需要自定义语义时在注释里写明用途;测试时先用kill -l确认编号映射。
5.3 把 /proc 里的数当成静态值
易漏点:连续两次读meminfo得到不同的值,就以为程序写错了。为什么漏:/proc是虚拟文件系统,每次open都由内核现场生成内容,数值本身就是瞬时快照;同时MemFree不等于「可用内存」,MemAvailable才更接近实际可分配量。怎么补:报告里注明采样时刻与采样次数,需要趋势就连采多次取区间;单位统一按 kB 换算,写结论时带上单位。
5.4 缺页计数口径混用
易漏点:把轻微缺页和重大缺页加在一起报一个总数,或者拿整机/proc/vmstat的累计值当成本次实验的增量。为什么漏:两个数据源一个按进程、一个按整机,一个是累计、一个需要作差,字段名又很像。怎么补:实验前先getrusage取基准,结束后作差;结论里分开写minflt与majflt,并说明本次访问模式为什么会让两者比例失衡。
5.5 调度指标算错起点
易漏点:周转时间用「完成时间减开始执行时间」算,带权周转忘了除服务时间。为什么漏:手算时容易盯着甘特图上的执行区间,忽略进程可能在到达后等了很久;带权周转是比值,量纲不同,忘记除就会出现「带权周转比周转还小」的反常结论。怎么补:固定写三列——到达、完成、服务,周转一律用完成 − 到达,带权一律用周转 / 服务;算完检查一次「带权周转 ≥ 1」是否全部成立,不成立就是算错了。
6. 复习清单:把八次实验压成一张可自查的表
复习到后期,最有用的不是再读一遍报告,而是拿一张表逐项自问「这个函数我能不能默写、这个数字我能不能解释」。下面这张表是我自己按实验顺序整理的,用来做最后一轮查漏补缺:
| 实验 | 必须能默写的核心 | 必须能解释的一句话 |
|---|---|---|
| 环境与调试 | gcc -g -Wall -o、gdb的b/r/n/p | 不加-g为什么看不到源码行 |
| 目录与命令 | ls -l十位串、chmod两种写法 | 重定向与管道分别传什么流 |
| /proc 观察 | 逐行读文件的固定套路 | 为什么/proc不占磁盘 |
| 软中断通信 | fork、signal、kill、wait | 子进程为什么要忽略 Ctrl+\ |
| 进程调度 | FCFS 与 RR 的周转、带权周转 | 为什么 RR 的带权周转更小 |
| 线程同步 | pthread_mutex_lock/unlock配对 | 为什么循环判断要用while |
| 内存管理 | 首次/最佳/最坏适应的差异 | 回收时为什么必须合并相邻块 |
| 系统调用 | 五步流程与syscall()写法 | 系统调用比函数调用贵在哪 |
| 缺页统计 | getrusage取增量 | minflt与majflt差在哪 |
具体怎么用这张表:先合上资料,对着「必须能默写」那列在纸上写代码骨架,写不出来的立刻标红;再对着「必须能解释」那列出声讲一遍,讲到卡壳的地方就是真正的漏洞。第二轮只攻标红项,第三轮把九行连起来讲成一条完整链路——从敲命令读/proc,到fork出进程树,到调度算法决定谁先跑,到内存分配触发缺页,最后用系统调用收尾。
还有两个自查小技巧。一是画图:进程树用手画,标上 pid 和信号流向;内存分配画空闲块链表,标出每次分配后碎片的位置。图能画对,代码基本不会写错。二是对数:跑完程序别急着截图,先看/proc里的数字量级对不对——4MB 数组首次写入,轻微缺页数量应该在千次量级;如果报出几十万次,八成是把累计值当增量了。这种「先核对再下结论」的习惯,比多刷两套题管用。
那份实验报告我是当索引用的:卡在某个实验的某个函数上,就翻到对应章节看它的代码骨架和结果分析,对着改参数、改进程数,看输出怎么变,比从头读一遍效率高得多。需要的话可以拿去对着自己的实验逐条比对。
从那以后我每次做系统实验,都强制先画一张进程树或内存流程图再动手写代码,跑完先不看输出、先去/proc里核对一眼数字量级,翻车的次数少了不止一半。希望帮到你。
本文还有配套的精品资源,点击获取