
简介面向高校操作系统课程学习与实验复盘场景这份西南交通大学软件工程专业操作系统实验报告以评分A95分的完整文档呈现适合正在完成Linux上机实验、需要参考报告结构与实验思路的学生。内容围绕Linux环境与C语言程序调试展开覆盖目录结构与Shell常用命令、重定向和管道、gcc编译与gdb调试并延伸到/proc下cpuinfo、meminfo、loadavg等内核状态观测以及软中断通信、FCFS与RR进程调度、线程同步等实验模块。报告按个人声明、实验目的、实验内容、实验步骤和总结组织便于对照梳理操作过程、记录运行结果并分析进程调度策略与线程同步方法。包内共1个docx文件约1.48MB章节层次清晰检索和整理较为直接。已有403人学习可作为课程实验报告撰写、知识点查漏补缺和实验过程复盘的参考材料。1. 从一份 95 分的操作系统实验报告说起它到底能帮你跑通什么去年秋天一个学弟拿着这份评分 95 的操作系统实验报告来问为什么他照着抄进程调度那部分的输出跟报告里完全对不上。翻两页就明白了——fork之后父子进程的判断他抄反了if (p1 0)变成了if (p1 0)父进程直接钻进子进程分支里跑输出当然乱套。这份资料真正值钱的地方不是那些截图而是它把 Linux 环境与 C 程序调试、/proc 内核观测、软中断通信、进程调度、线程同步、内存管理、系统调用、缺页次数统计这七八个实验串成了一条从 shell 命令到内核接口的可复现链路。它适合正在上操作系统课、既要交实验报告又想真把程序跑通的软件工程专业学生也适合想把 Linux 进程与内存机制重新过一遍的后端开发。2. 报告骨架与实验依赖链八次实验怎么排成一条能复现的线拿到一份实验报告第一件事不是看代码是看目录。目录能告诉你作者的编排逻辑也决定了你复现时的顺序。这份报告的实验编号里有个明显的排版失误——实验 1 Linux 环境及 C 语言程序调试和实验 1 观察 Linux用了同一个编号后面才开始 2、3、4。这不影响内容但你在自己整理成工程目录时要顺手改掉否则lab01到底对应哪个实验半年后自己都说不清。2.1 目录结构拆解从环境准备到内核接口的四层递进八个实验不是平铺的是有依赖的。第一层是环境层只碰 shell 和编译器第二层是只读观测层用 C 程序去读 /proc不修改任何内核状态第三层是进程与并发层开始用fork、signal、pthread这些会真正改变系统状态的东西第四层是内核层涉及系统调用表和缺页统计需要你对内核编译或性能计数器有基本概念。实验依赖的前置能力主要触达的接口Linux 环境与 C 调试无shell、vim、gcc、gdb观察 Linux/procC 文件读写/proc/cpuinfo、meminfo、loadavg、uptime软中断通信进程创建与回收fork、signal、kill、wait进程调度进程状态理解调度策略模拟、PCB 数据结构线程同步并发与临界区概念pthread、lockf、信号量内存管理地址空间与指针brk/mmap、/proc/pid/maps系统调用内核编译基础sys_call_table、sys_* 函数缺页次数统计内存管理 性能计数minflt/majflt、/proc/self/stat这张表的用法很直接如果你卡在缺页统计上先回头确认内存管理那个实验你跑通了没有如果软中断那部分信号发出去没反应问题多半在更早的fork和wait回收上。按层推进比按页码推进省时间得多血泪经验是别跳着做——第三层里任何一个fork写错第四层的统计数字全是脏的。2.2 单份实验的固定五段式骨架评分点其实集中在两处八份实验的内部结构高度一致实验目的、实验内容、实验环境、实验步骤与结果、核心代码与心得记录。这个骨架不是形式主义它对应的是评分老师看报告时的检索路径——先看目的写了什么再看步骤里有没有真命令最后落到结果图能不能和代码对上。实验目的别抄教材。报告里有一处写得挺实「了解 Linux 的用户界面目录结构组织运行程序的基本方式」然后正文真的把 /bin、/home、/lostfound、/media、/opt、/lib、/proc、/tmp 逐个解释了一遍。这种目的—正文对得上的写法比空喊掌握操作系统原理要可信。你在改写时照这个思路走目的写三条每条都能在后面的步骤里找到落点。实验步骤是第二个评分点也是最容易糊弄也最容易翻车的地方。报告里明确要求「不能粘贴运行结果」意思是结果图必须是自己机器上跑出来的因为老师会看进程号、时间戳、路径这些东西对不对得上。一个细节这份报告开篇写了一条排版约定——「本人实验报告使用到的命令一并使用粗体加下划线方便阅览」。听着不起眼但一份几十页的报告里命令和正文混在一起阅读体验会崩这个小约定值得抄。2.3 把报告当工程管命名、截图与代码的取舍散装文档最大的问题是复现成本。你在 A 机器上写完换台机器想重跑某个实验得先从头翻到第 35 页找那段代码。我一般会把整份报告拆成目录oslab/ ├── lab01_env/ # Linux 环境与 C 调试 │ ├── src/argc.c │ └── notes.md # 命令清单与截图说明 ├── lab02_proc/ # /proc 观测 │ ├── src/observe.c │ └── result.csv # 采样结果替代截图里的数字 ├── lab03_signal/ # 软中断通信 ├── lab04_sched/ # 进程调度 ├── lab05_thread/ # 线程同步 ├── lab06_mem/ # 内存管理 ├── lab07_syscall/ # 系统调用 └── lab08_pagefault/ # 缺页统计截图保留但不要让它成为唯一证据。像MemTotal、uptime这类每次开机都会变的数值截图三个月后就没法自证改成把输出重定向进result.csv报告里放截图、repo 里放原始数据两边互为佐证。代码也别整段贴进正文贴关键函数其余放 src 目录正文里写清楚这段在哪个文件、负责哪一步比一次性糊 200 行上去专业得多。3. 环境与内核观测从 vim/gcc 到 /proc 与系统调用这一层是整个报告的基座。工具链不通后面全是空谈。报告里记录的环境是 Red Hat 系内核 3.10.0-1160gcc 4.8.5跑在虚拟机里。这个组合比较老但实验内容对版本不敏感你在 Ubuntu 或更新的发行版上跑同样成立唯一要注意的是内核头文件和编译参数的差异。3.1 编译调试闭环vim gcc gdb 三件套的最小验证先写一个带参数的小程序把编辑、编译、运行、调试四步走一遍确认工具链完整。别小看这步很多人卡在命令敲了但没生成可执行文件根因就是扩展名或编译参数。# 1. 编辑进入 vim 后按 i 进入插入模式写完 Esc 再 :wq 保存 vim argc.c # 2. 编译-g 保留调试符号给 gdb 用-Wall 打开常用告警 gcc -g -Wall -o argc argc.c # 3. 运行传三个参数观察 argc 与 argv 的实际取值 ./argc one two three-g的作用是把行号、变量名这些符号信息编进可执行文件没有它 gdb 里只能看到地址断点都设不了。-Wall不是必需的但它会把隐式声明函数变量未使用这类问题提前暴露出来——这份报告里就有编译时对fork隐式声明报错的记录本质是漏了#include unistd.h。接下来进 gdb 验证断点和单步gdb ./argc # 以下为 gdb 交互命令 break main # 在 main 入口下断点 run one two # 带参数启动 print argc # 期望输出 3 next # 单步执行一行 quit调试的目标不是把程序跑完是验证我能控制它停在哪、能看到什么。这套动作在后面的信号实验里会反复用到fork之后分不清父子进程时在fork下一行打print getpid()比printf加日志靠谱得多。3.2 /proc 观测程序读文件就是在读内核状态/proc 是虚拟文件系统里面的文件不占磁盘内容由内核在读取时现场生成。所以观测程序的核心不是解析而是打开—读一行—匹配前缀。#include stdio.h #include string.h /* 在 /proc 的某个文件里查找以 key 开头的行并输出。 * path: /proc 下的文件路径 * key: 行前缀传空串则输出第一行适合 loadavg 这种单行文件 * 返回 0 表示命中-1 表示未找到或文件打不开 */ int show_line(const char *path, const char *key) { FILE *fp fopen(path, r); if (!fp) { perror(path); return -1; } /* 打不开通常是路径写错或权限不足 */ char buf[256]; size_t len strlen(key); while (fgets(buf, sizeof(buf), fp)) { /* 逐行读/proc 文件是流式生成的 */ if (strncmp(buf, key, len) 0) { fputs(buf, stdout); fclose(fp); return 0; } } fclose(fp); return -1; } int main(void) { show_line(/proc/meminfo, MemTotal); show_line(/proc/meminfo, MemAvailable); show_line(/proc/loadavg, ); /* 单行文件整行输出 */ show_line(/proc/uptime, ); show_line(/proc/cpuinfo, model name); return 0; }fgets的第二个参数是缓冲区大小给 256 足够覆盖 /proc 里绝大多数行strncmp只比前len个字符所以传 MemTotal 就能命中 MemTotal: 16384000 kB 这种整行。perror在打开失败时会把errno翻译成人话比printf(error)有用。这套写法比一次性把整个文件读进内存再 split更省事也更符合 /proc 的流式特性——内核是按读取位置分段生成的你读多少它生成多少。要注意/proc/loadavg和/proc/uptime是单行文件传空串时strncmp(buf, , 0)恒等于 0第一行直接命中正好符合需求。这个技巧在写通用采集脚本时很好用。3.3 uptime 与时间基准jiffies 到底怎么换算报告里有一段写得挺深作者拿 /proc/uptime 和 /proc/stat 里的数值去反推时间单位最后落到 jiffies 上——内核开机时从 RTC 读一个基准时间之后每过一个节拍tickjiffies 加 1任意时刻的时间都可以由 jiffies 和 HZ 换算出来秒数 jiffies / HZHZ 是编译内核时定的常数常见值 100、250、1000。所以 /proc/uptime 的第一个数是系统启动至今的秒数含小数第二个数是所有 CPU 处于空闲状态的时间总和。这两个数在多核机器上会出现空闲时间远大于墙钟时间不是 bug是每个核各算各的。你在报告里解释这一点比单纯贴一张 uptime 截图要有说服力得多。同一层里还有系统调用实验。思路是在内核的系统调用表里加一个自己的入口让用户态能通过syscall(编号, ...)调到。要做的事就那么几件在内核源码里加一个SYSCALL_DEFINE函数、在系统调用表里按框架要求登记、补上头文件声明、重新编译安装内核。这中间最容易翻车的是编号冲突和忘记更新头文件现象是编译过但用户态链接时找不到。具体步骤依赖你用的内核版本跟着发行版文档走不要照抄别人的行号。4. 进程与内存软中断通信、调度策略、缺页统计这一层是整份报告里技术含量最高的部分也是最容易看起来跑通了其实没跑对的部分。信号、调度、同步、缺页四个主题都要求你能读懂输出光看到打印信息不算完。4.1 fork/signal 三级进程链路信号怎么从父传到孙报告里两个通信实验第一个是父进程建两个子进程Ctrl\ 触发后依次终止第二个是父、子、孙三级信号逐级向下传递。核心是三条signal注册处理函数、while循环等待标志位、kill向指定 pid 发信号。#include stdio.h #include signal.h #include unistd.h #include sys/wait.h static volatile int wait_mark 1; /* 全局标志处理函数里清 0 */ static void stop(int signo) { (void)signo; wait_mark 0; } int main(void) { pid_t p1 fork(); if (p1 0) { /* 父进程 */ printf(parent %d\n, getpid()); signal(SIGQUIT, stop); /* Ctrl\ 触发置 wait_mark 0 */ while (wait_mark) pause(); /* 阻塞等待不空转 CPU */ kill(p1, SIGUSR1); /* 通知子进程收尾 */ wait(NULL); /* 回收子进程避免僵尸 */ printf(parent %d exit\n, getpid()); } else if (p1 0) { /* 子进程 */ pid_t p2 fork(); if (p2 0) { /* 孙进程 */ signal(SIGUSR1, stop); while (wait_mark) pause(); printf(grandchild %d exit\n, getpid()); _exit(0); } signal(SIGUSR1, stop); while (wait_mark) pause(); kill(p2, SIGUSR1); /* 先杀孙再自己退 */ wait(NULL); printf(child %d exit\n, getpid()); _exit(0); } return 0; }跟原报告比这里改了两处。一是把while (wait_mark);换成while (wait_mark) pause();前者是忙等CPU 占用 100%后者让进程挂起直到信号到达是正常写法。二是把信号编号从写死的 16 换成宏SIGUSR1编号依赖体系结构写数字在不同机器上行为不一样后面避坑章节会细说。执行顺序值得在报告里写清楚Ctrl\ 只发给前台进程组父进程收到 SIGQUIT 后清标志、退出等待然后主动kill子进程子进程收到 SIGUSR1 后再kill孙进程。所以输出顺序是孙、子、父这个顺序不是代码的书写顺序决定的是信号的传递顺序决定的——报告里那句「首先孙进程被杀死然后子进程最后父进程」就是在描述这个因果链比单纯贴一张输出图有价值。lockf(1, 1, 0)/lockf(1, 0, 0)这对调用是给标准输出加锁防止多个进程的printf交错成乱码。第一个参数 1 是文件描述符stdout第二个 1 表示加锁、0 表示解锁第三个 0 表示从当前位置锁到文件末尾。在单核、输出很短的情况下不加锁也看不出问题一旦输出变长就会翻车加上更稳。4.2 FCFS 与 RR 的验证调度策略不是背定义是算周转时间进程调度实验的产出应该是一张表不是一段描述。你需要定义 PCB模拟 FCFS先来先服务和时间片轮转RR然后拿同一组测试数据跑两遍比较平均周转时间和平均等待时间。typedef struct pcb { int pid; int arrive; /* 到达时间 */ int service; /* 需要 CPU 的总时间 */ int remain; /* 剩余时间RR 用 */ int finish; /* 完成时刻用于算周转时间 */ } PCB;RR 的核心循环大概是这样/* time_slice 为时间片q 为就绪队列初始按到达时间入队 */ int t 0; while (!empty(q)) { PCB *p pop(q); if (p-remain time_slice) { p-remain - time_slice; t time_slice; push_back(q, p); /* 时间片用完排到队尾 */ } else { t p-remain; p-remain 0; p-finish t; /* 记录完成时刻 */ } push_arrived(q, t); /* 把 t 时刻前到达的进程补进队列 */ }两个参数决定结果对不对time_slice越小RR 的响应越及时但切换开销越大push_arrived的调用时机如果放在出队之前会导致新到达的进程插队顺序错乱平均周转时间算出来偏小——这是个很隐蔽的错误输出看着合理一核对数字就露馅。测试用例我一般构造三组全部同时到达、到达时间递增、服务时间差异极大比如 1 和 100。第三组能把 FCFS 的长作业阻塞短作业暴露得最明显报告里配上这组数据的对比表说服力比任何文字描述都强。4.3 线程同步与临界区从 lockf 到信号量线程同步实验的目标是复现竞态。先写一个不加保护的计数器多线程各加 N 次跑几次看看结果是不是小于 N×线程数再加上保护结果稳定。#include pthread.h #include semaphore.h static sem_t sem; static int counter 0; void *worker(void *arg) { for (int i 0; i 100000; i) { sem_wait(sem); /* P 操作计数减一为 0 时阻塞 */ counter; /* 临界区只允许一个线程进入 */ sem_post(sem); /* V 操作计数加一唤醒等待者 */ } return NULL; }sem_init(sem, 0, 1)的第二个参数 0 表示线程间共享非进程间第三个参数 1 是初始值等价于互斥锁。这个实验的关键是先看到错误结果把sem_wait/sem_post注释掉跑一遍大概率能得到一个比 100000×线程数小的数字截图存下来作为问题现场再恢复保护跑一遍结果稳定。报告里两组结果并排放因果链就完整了。如果加锁后结果依然不稳定检查是不是有线程没走sem_post就直接return了或者pthread_join漏了导致主线程提前退出。4.4 内存管理与缺页次数采集字段偏移是个坑内存管理实验通常让你观察进程的地址空间分布看堆、栈、代码段各自落在哪。最简单的方式是读/proc/self/maps或者直接打印几个变量的地址做对比int global_var; /* .bss 段 */ int main(void) { int stack_var; /* 栈地址通常最大 */ int *heap_var malloc(1024); /* 堆地址介于两者之间 */ printf(stack: %p\nheap : %p\nglobal: %p\n, stack_var, heap_var, global_var); free(heap_var); return 0; }缺页统计要读/proc/self/stat的 minflt次缺页不需磁盘 IO和 majflt主缺页需要读盘。这两个字段在 stat 文件里是第 10 和第 12 个但不能简单按空格切分——第 2 个字段是进程名可能含空格和括号/* /proc/self/stat 的 comm 字段可能含空格从最后一个 ) 之后开始数更稳 */ long get_minflt(void) { FILE *fp fopen(/proc/self/stat, r); if (!fp) return -1; char buf[1024]; fgets(buf, sizeof(buf), fp); fclose(fp); char *p strrchr(buf, )); /* 定位到 comm 字段的右括号 */ if (!p) return -1; p; /* 跳过 ) 和空格 */ long fields[24]; for (int i 0; i 24 p; i) { fields[i] strtol(p, p, 10); if (*p) p; } return fields[7]; /* 从 state 起算minflt 是第 8 个 */ }触发缺页的方法很直接申请一块比物理内存大的数组按页跨度比如每 4096 字节写一次。写入时内核才真正分配物理页缺页计数随之上涨。另一个思路是用getrusage(RUSAGE_SELF, ru)读ru_minflt和ru_majflt比手工解析 stat 稳得多报告里两种方式都给能体现你知道取舍。5. 避坑与排查五条把实验卡住的现场记录下面这五条都是我在复现这类实验时真实撞过的每条按现象—原因—解决写。别指望一次跑通卡住的时候先来这里对号。第一条编译报 file format not recognized或者干脆没生成可执行文件。现象是gcc对着一个文件报链接器错误提示无法识别格式。原因往往很朴素——文件名手滑写成了argc.v而不是argc.cgcc 不认识.v后缀把它当成待链接的目标文件。报告里就出现过gcc argc.v这一行。解决办法先用file argc.v确认类型改回.c后缀实在要保留怪后缀就加-x c强制按 C 源码处理。养成习惯编译前ls一眼扩展名比看报错快。第二条虚拟机网络连不上ping 不通外网xftp 传不了文件。现象是宿主机能上网虚拟机不行或者反过来宿主机访问不到虚拟机的 SSH。根因是网络模式选错桥接模式下虚拟机和宿主机在同一网段教室或宿舍这种设备密集的环境容易撞 IPNAT 模式下虚拟机借宿主机上网不会冲突但外部访问不进来仅主机模式干脆和外界隔离。最稳的做法是日常用 NAT需要从宿主机传文件时改用共享文件夹或端口转发。排查顺序ip addr看有没有拿到地址 →ip route看默认网关 →ping 网关。改完模式记得重启网络服务只改配置不重启等于没改。第三条fork 之后同一句 printf 打印了两遍。现象是父进程和子进程各自输出了本应只出现一次的内容而且是在 fork 之前写的那句。原因是标准输出默认带缓冲fork 会把用户态缓冲区整个复制一份父子进程退出时各刷一次。解决办法有三个fork 之前fflush(stdout)或者用write(1, buf, len)直接走系统调用绕过缓冲或者在 main 开头setbuf(stdout, NULL)关掉缓冲。第三种最省事但输出会变多适合调试期用。第四条信号发了没反应或者子进程死活不退出。现象是 Ctrl\ 按下去程序没打印预期的结束信息ps一看进程还在。两个常见原因一是信号编号写死成数字比如signal(16, stop)、kill(p1, 17)不同体系结构下编号含义不同在某些平台上 17 恰好是 SIGCHLD捕获它反而干扰了子进程回收代码就卡在wait上二是signal注册之前信号就到达了默认动作直接终止进程。解决办法一律用SIGUSR1、SIGUSR2、SIGQUIT这些宏不要写数字注册处理函数的代码放在fork之前或者紧接fork之后的第一行。第五条程序跑完ps 里一堆 僵尸。现象是父进程已经结束但子进程还在ps aux里显示为 Z 状态。原因是子进程退出后父进程没调wait/waitpid内核保留退出状态等父进程读取读不到就一直挂着。报告里的三级进程代码每一层都写了wait(0)这不是装饰。解决办法父进程对每个子进程显式wait或者在启动时signal(SIGCHLD, SIG_IGN)让内核自动回收。后者写起来简单但如果后面还要用waitpid拿退出码就会被自己挖的坑绊住——两种方式别混用。6. 二次开发把一份报告改造成能反复跑的 OSLab 工程报告是一次性交付物工程是能反复运行的资产。改造的第一刀落在构建上把散落在正文里的gcc xxx.c全部收进 Makefile一条make编译全部实验一条make clean清干净。这样半年后再回来改哪个实验不用回忆当初敲了什么。CC gcc CFLAGS -g -Wall -Wextra -stdgnu99 TARGETS lab02_proc lab03_signal lab04_sched lab05_thread lab06_mem lab08_pagefault all: $(TARGETS) # 每个 .c 独立编译成同名可执行文件 %: %.c $(CC) $(CFLAGS) -o $ $ check: all ./lab02_proc lab02_proc/result.csv ./lab04_sched lab04_sched/sched.out clean: rm -f $(TARGETS) lab02_proc/result.csv lab04_sched/sched.out-Wextra比-Wall多检查一轮能揪出未使用的参数和符号比较问题——信号处理函数的int signo参数没用上就是被它提醒才顺手加了(void)signo。-stdgnu99是为了让for (int i 0; ...)这种写法在旧版 gcc 上也能过。第二刀落在验证上。报告里的数字是死的工程里的数字应该是可复算的。进程调度实验的产出别只贴一张表让它直接打印成 CSVpid,arrive,service,finish,turnaround,weighted每行一个进程末行打平均值。这样你改一次时间片参数make check跑完就能用别的工具一眼看出平均周转时间的变化趋势。缺页实验同理把minflt采样写进 CSV横轴是访问页数、纵轴是缺页次数斜率能直观告诉你每页跨度的缺页增长是否线性。第三刀是参数化。把写死的time_slice、进程数量、数组大小提到命令行参数里用getopt解析/* ./lab04_sched -n 5 -q 4 表示 5 个进程、时间片 4 */ while ((opt getopt(argc, argv, n:q:)) ! -1) { switch (opt) { case n: proc_count atoi(optarg); break; case q: time_slice atoi(optarg); break; default: fprintf(stderr, usage: %s -n N -q Q\n, argv[0]); return 1; } }有了这层验证就从看输出对不对变成了扫一组参数看趋势对不对。比如时间片从 1 扫到 16平均周转时间应该是先降后升的 U 形如果你的曲线单调说明push_arrived的时机或者剩余时间的处理有问题。这种用参数扫描反推实现的验证方式比盯着单次输出找 bug 效率高一个量级。最后一刀也是我踩过最多次的别在同一份代码里既当实验又当模板。原本lab04_sched里我图省事把PCB定义和调度逻辑写在一个文件后来想拿调度器去测缺页实验的进程开销发现根本拆不开只能复制粘贴。从那以后我每次建新实验目录都强制先写头文件把结构体和函数原型定死实现文件只负责填空。这个习惯看着慢实际省的是返工。希望帮到你。本文还有配套的精品资源点击获取