首页 / 资讯中心 / 文章详情

重跑95分Linux操作系统实验:/proc、fork与缺页统计避坑指南

重跑95分Linux操作系统实验:/proc、fork与缺页统计避坑指南 ★ FEATURED ARTICLE
简介这份西南交通大学操作系统实验报告胡晓鹏老师课程评分95分面向软件工程等专业学生用于完成Linux环境入门、C程序编译调试与内核状态观测等实验任务也可作为实验报告撰写与期末复习的参考模板。压缩包内仅有1个docx文档约1.48MB按实验一至实验四依次组织Linux界面与目录结构、Shell常用命令及重定向与管道机制、gcc与gdb编译调试流程、/proc下cpuinfo、meminfo、loadavg、interrupts等内核信息观测以及软中断通信、FCFS与RR进程调度、线程同步等实验每部分含实验目的、实验内容、实验步骤与总结目录层级清晰便于按实验逐项查阅。目前已有403人学习。读者可对照实验要求搭建环境、理解报告结构与数据分析写法减少调试和排版上的弯路。1. 一份 95 分的 Linux 操作系统实验报告值不值得拆开重跑一遍拿到这份 Linux 操作系统课程实验报告时我先翻的不是分数是目录八组实验从目录结构、/proc 观测一路排到软中断通信、进程调度、线程同步、内存管理和缺页统计。评分 95 说明结论站得住但报告本身以代码片段和截图为主不亲自跑一遍等于只看了个热闹。它适合谁手上有 Linux 课程设计、要交实验报告、又被 fork 和 /proc 卡住的人也适合以前写过、但周转时间和缺页数算不明白的人。下面按可复现的顺序重走一遍把每步的命令、代码、参数和翻车点摊开讲跑完这一轮报告里的每个数字你自己也能复算出来。2. Linux 环境与 /proc 观测从目录坐标到 cpuinfo 的第一条命令链2.1 先建立目录与权限的坐标系很多人一上来就敲gcc报错之后连头文件在哪儿都找不到。Linux 的目录组织有一套固定分工先把这套分工记牢后面所有实验都会省力。根目录/下不是分盘符而是按用途切分/bin和/usr/bin放常用命令/etc放配置/lib放动态库和编译器的库文件/home是用户主目录/proc是内核和进程的虚拟文件系统/tmp放临时产物/lostfound是非法关机后的残留碎片。实验报告里把/proc写成进程的映像这个说法半对半错/proc/N才是进程映像/proc根下的文件是内核导出的状态快照读写它们等于在跟内核对话。后面 cpuinfo、meminfo、loadavg、stat、interrupts 全在这一层。路径存什么实验里什么时候用到/bin/usr/bin常用命令ls、gcc、ps、kill/etc系统与服务的配置改文件、查用户与权限/lib动态库、编译器库链接报错时定位/proc内核状态与进程状态观测实验的全部数据源/home用户主目录放源码和可执行文件/tmp临时文件编译中间产物权限这一块必须动手验证一次光看chmod的文档记不住。ls -lпервой 列十位字符是类型位加三组 rwx分别对应属主、属组、其他人chmod 755是把三组权限用八进制写死chmod ux是按符号改。编译出来的a.out默认带可执行位自己用vim :w存出来的文件不一定带./a.out报 Permission denied 时先看这一位。2.2 终端、vim 与 gcc 的最小闭环实验一的目的是把编辑—编译—运行—调试四步串起来。我一般会在家目录下先建四个子目录把后面几组实验的源码分开避免a.out互相覆盖mkdir -p ~/oslab/{proc,sig,sched,mem} cd ~/oslab/proc vim proc_view.c # i 进入插入模式Esc 退回普通模式:wq 保存退出 gcc -g -O0 -Wall -o proc_view proc_view.c ./proc_view cpuinfo-g保留调试符号后面gdb才能把行号和源码对上-O0关闭优化避免变量被优化掉、断点跳行-Wall打开告警像隐式声明函数这类问题不打开就只是警告跑起来才崩。-o指定输出名不写就固定生成a.out四个实验同时做的时候很容易搞混。调试环节用gdb走一遍断点、单步、看变量gdb ./proc_view # (gdb) break dump 在读取函数入口下断点 # (gdb) run meminfo 带参数启动 # (gdb) next 单步不进入被调函数 # (gdb) print path 查看拼出来的 /proc 路径 # (gdb) continue 继续到下一个断点或结束这套流程的价值不在命令本身而在于出问题时你能停下来看状态。实验报告里提到用mv改文件名在 vim 里:w new_filename另存这类小技巧看着琐碎实际排错时很救命——比如源码改名后Makefile里的目标没跟着改编译出来的是旧文件现象就是改了代码没效果血泪经验大多来自这种地方。2.3 用一段 C 程序把 /proc 读出来实验二要求观测 cpuinfo、version、stat、uptime并编程实现 meminfo、loadavg、interrupts、filesystem 的读取。这四个文件格式不同但读取方式完全一致按行读、按字段切。先写一个通用 dump把文件内容原样打出来确认字段位置再针对性解析。#include stdio.h #include stdlib.h /* 按行读取 /proc 下的只读文件最多输出 max_lines 行避免刷屏 */ static int dump(const char *path, int max_lines) { FILE *fp fopen(path, r); if (!fp) { /* 文件不存在或无权限时给出明确提示 */ perror(path); return -1; } char buf[512]; int n 0; while (n max_lines fgets(buf, sizeof(buf), fp)) { fputs(buf, stdout); /* 原样输出保留字段分隔方便肉眼核对 */ n; } fclose(fp); /* /proc 文件必须关闭否则句柄泄漏 */ return n; } int main(int argc, char **argv) { if (argc 2) { fprintf(stderr, usage: %s cpuinfo|meminfo|loadavg|uptime|stat\n, argv[0]); return 1; } char path[128]; snprintf(path, sizeof(path), /proc/%s, argv[1]); /* 拼路径防止手写错 */ return dump(path, 20) 0 ? 1 : 0; }buf取 512 字节够用/proc/meminfo单行最长不到 100 字节max_lines控制输出行数/proc/cpuinfo在多核机器上可能几百行不限制会淹掉终端。snprintf比sprintf多一个长度参数是防缓冲区溢出的默认写法。编译运行后重点看这几项文件关注字段含义/proc/cpuinfomodel name、cpu cores、siblings型号、物理核数、逻辑核数/proc/meminfoMemTotal、MemAvailable、Cached总内存、可用内存、页缓存/proc/loadavg三个浮点 running/total1/5/15 分钟平均负载/proc/uptime两个浮点开机秒数、空闲累计秒数/proc/statcpu行user/system/idle 等时间片累计/proc/version整行内核版本与编译信息MemAvailable比MemFree更值得看后者只统计完全空闲的页前者把可回收的缓存算进去了判断内存压力用前者。/proc/uptime的单位是秒且第二个值是所有 CPU 空闲时间的累加多核机器上它会明显大于第一个值第一次看容易以为是脏数据。平均负载那三个数要和核数一起读8 核机器上 1 分钟负载 8.0 表示跑满2 核机器上同样的 8.0 意味着严重排队。实验报告里考察的jiffies就是/proc/stat里那些累计值的底层计数单位内核每个节拍加一用户态看到的是换算后的秒数。3. 软中断通信与进程调度fork 三次、信号两个、时间片一个3.1 fork 链与信号链谁是父、谁是孙软中断实验的核心不是signal本身而是进程树的形状。两组要求父进程创建两个子进程Ctrl\触发后两个子进程先死、父进程后死以及父进程创建子进程、子进程再创建孙进程信号从父传子、子传孙逐级退出。关键设计有三点子进程必须忽略SIGQUIT否则你在终端按Ctrl\时整个前台进程组一起收到信号子进程先于父进程退出父进程后面的kill直接返回ESRCH现象就是父进程打印了结束信息但子进程的结束信息没出现。第二点是父子各认一个自定义信号号16、17用编号区分终止指令来源。第三点是输出必须加锁否则多进程往同一个终端写会串行错乱。#include stdio.h #include stdlib.h #include signal.h #include unistd.h #include sys/wait.h static volatile sig_atomic_t wait_mark 1; /* 信号处理函数只改这一位 */ static void stop(int signo) { (void)signo; wait_mark 0; } int main(void) { pid_t p1 fork(); if (p1 0) { perror(fork p1); exit(1); } if (p1 0) { /* 子进程 P1 */ signal(SIGQUIT, SIG_IGN); /* 屏蔽终端 Ctrl\只认父进程的 16 */ signal(16, stop); while (wait_mark) pause(); /* 挂起等待不占 CPU */ lockf(1, 1, 0); /* 对 stdout 加锁保证整行输出 */ printf(child P1 %d killed by %d\n, getpid(), getppid()); lockf(1, 0, 0); exit(0); } pid_t p2 fork(); if (p2 0) { perror(fork p2); exit(1); } if (p2 0) { /* 子进程 P2 */ signal(SIGQUIT, SIG_IGN); signal(17, stop); /* P2 认 17 号 */ while (wait_mark) pause(); lockf(1, 1, 0); printf(child P2 %d killed by %d\n, getpid(), getppid()); lockf(1, 0, 0); exit(0); } signal(SIGQUIT, stop); /* 父进程接收终端 Ctrl\ */ while (wait_mark) pause(); kill(p1, 16); /* 先发 P1 */ kill(p2, 17); /* 再发 P2 */ wait(NULL); /* 回收两个子进程防僵尸 */ wait(NULL); printf(parent %d exit\n, getpid()); return 0; }wait_mark必须声明为volatile sig_atomic_t普通int在开优化后可能被缓存进寄存器信号处理函数改完主循环读不到程序就卡在那儿不动了——这是最典型的一类玄学问题。pause()让进程真正睡眠比while(1);空转省 CPU被信号打断后自动返回。lockf(1, 1, 0)的三个参数是文件描述符 1 代表标准输出、1 表示加锁、0 表示从当前位置到文件尾解锁用lockf(1, 0, 0)。三级版本的结构一样只是子进程里再fork一次孙进程认 16 号信号子进程收到信号后先杀孙进程、wait回收再自己退出。这里有个顺序坑子进程如果不wait就直接exit孙进程会被过继给 init父进程的wait(NULL)只能回收子进程输出顺序看着对进程树其实已经散了。3.2 输出互斥为什么 printf 会串行错乱printf内部对FILE结构加锁但那把锁只保证单个printf调用不被打断多个进程之间不共享锁。两个进程同时写终端输出就是交替插入的字符流报告里那张结果分析的截图如果顺序看着很整齐多半是因为进程退出间隔足够大运气好而已。lockf和flock都行前者是 POSIX 记录锁后者是 BSD 文件锁标准输出是同一个文件描述符时两者效果接近。要注意的是加锁范围要覆盖整段输出别只锁printf那一行——中间夹了getpid()这类调用时取到的值可能在输出前就变了。3.3 FCFS 与 RR 的量化对比进程调度实验如果只在验证界面上勾选策略、看动画学不到东西。我一般写个几十行的模拟器把周转时间和带权周转时间算出来对比参数一改就能看出差别。#include stdio.h typedef struct { char name[8]; int arrive; /* 到达时间 */ int burst; /* 需要运行的时间片数 */ int left; /* 剩余时间RR 用 */ int finish; /* 完成时刻 */ } PCB; static void report(PCB p[], int n, const char *tag) { double sum_turn 0, sum_wturn 0; for (int i 0; i n; i) { int turn p[i].finish - p[i].arrive; /* 周转 完成 - 到达 */ sum_turn turn; sum_wturn (double)turn / p[i].burst; /* 带权周转 周转 / 服务时间 */ } printf(%s: avg_turn%.2f avg_wturn%.2f\n, tag, sum_turn / n, sum_wturn / n); } int main(void) { PCB a[3] { {P1,0,5,5,0}, {P2,1,3,3,0}, {P3,2,1,1,0} }; PCB b[3]; for (int i 0; i 3; i) b[i] a[i]; int t 0; /* FCFS按到达顺序串行执行 */ for (int i 0; i 3; i) { if (t a[i].arrive) t a[i].arrive; /* CPU 空闲到下一个到达 */ t a[i].burst; a[i].finish t; } report(a, 3, FCFS); const int q 2; /* RR 时间片取 2 */ t 0; int done 0; while (done 3) { int moved 0; for (int i 0; i 3; i) { if (b[i].left 0) continue; int run b[i].left q ? b[i].left : q; t run; b[i].left - run; moved 1; if (b[i].left 0) { b[i].finish t; done; } } if (!moved) t; /* 全部未到达时推进时钟 */ } report(b, 3, RR); return 0; }q就是时间片长度单位跟burst一致。取太大退化成 FCFS取太小上下文切换开销占比飙升常见做法是让q略大于一次交互所需的计算量。上面这组数据FCFS 的平均带权周转明显高于 RR原因是长作业 P1 堵在前面短作业 P2、P3 得干等——这正是调度策略要解决的问题不用背结论改两个数跑一遍就记住了。report里的除零风险要留意burst为 0 的进程会让带权周转变成inf真实系统里这类进程会被当作已完成直接跳过。4. 线程同步、内存管理与缺页统计三个实验共用一套观测手段4.1 互斥量和条件变量生产者与消费者线程同步实验一般是生产者—消费者模型考察互斥量和条件变量的配合。最容易错的两个地方判断条件用while而不是if以及pthread_cond_wait之前必须持锁。#include pthread.h #include stdio.h #define CAP 8 static int buf[CAP], cnt 0, head 0, tail 0; static pthread_mutex_t mtx PTHREAD_MUTEX_INITIALIZER; static pthread_cond_t not_full PTHREAD_COND_INITIALIZER; static pthread_cond_t not_empty PTHREAD_COND_INITIALIZER; static void *producer(void *arg) { for (int i 0; i 20; i) { pthread_mutex_lock(mtx); while (cnt CAP) /* 必须用 while防虚假唤醒 */ pthread_cond_wait(not_full, mtx); /* 等待时自动释放锁 */ buf[tail] i; tail (tail 1) % CAP; cnt; pthread_cond_signal(not_empty); /* 唤醒一个消费者 */ pthread_mutex_unlock(mtx); } return NULL; } static void *consumer(void *arg) { for (int i 0; i 20; i) { pthread_mutex_lock(mtx); while (cnt 0) pthread_cond_wait(not_empty, mtx); int v buf[head]; head (head 1) % CAP; cnt--; pthread_cond_signal(not_full); pthread_mutex_unlock(mtx); printf(consumed %d\n, v); } return NULL; } int main(void) { pthread_t p, c; pthread_create(p, NULL, producer, NULL); pthread_create(c, NULL, consumer, NULL); pthread_join(p, NULL); pthread_join(c, NULL); return 0; }编译时必须加-pthread而且放在命令末尾gcc -g -o pc pc.c -pthread。少了它链接阶段报undefined reference to pthread_create有些发行版还会把它当成普通编译选项忽略掉现象更迷惑。pthread_cond_wait干三件事释放互斥量、阻塞等待、被唤醒后重新加锁。所以它必须在持锁状态下调用不持锁就调用是未定义行为。while循环的作用是醒来后重新检查条件因为可能存在虚假唤醒if只判断一次就会越界。4.2 brk、mmap 与 /proc/self/maps内存管理实验要求观察进程地址空间。两个最直接的手段是sbrk调堆顶和读/proc/self/maps#include stdio.h #include unistd.h #include sys/mman.h int main(void) { void *heap0 sbrk(0); /* 取当前堆顶不改变位置 */ printf(heap start: %p\n, heap0); sbrk(4096 * 4); /* 向上扩 4 页一页通常 4KB */ printf(heap grown: %p\n, sbrk(0)); sbrk(-4096 * 4); /* 归还避免影响后续观测 */ void *m mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); /* 匿名映射一页 */ if (m MAP_FAILED) { perror(mmap); return 1; } printf(mmap at %p\n, m); munmap(m, 4096); return 0; }MAP_ANONYMOUS表示不绑定文件-1和0是文件描述符与偏移的占位。运行前先cat /proc/self/maps记一下基线运行后对比堆段和mmap段的地址范围变化。页大小用getpagesize()取别写死 4096跨架构时会错。4.3 缺页次数getrusage 与 /proc/self/stat 双路核对缺页统计是最容易做出假结果的实验。两种取数方式两条路互相验证#include stdio.h #include string.h #include sys/resource.h int main(void) { struct rusage ru; getrusage(RUSAGE_SELF, ru); printf(before: minflt%ld majflt%ld\n, ru.ru_minflt, ru.ru_majflt); static char big[4 * 1024 * 1024]; /* 4MB 静态区 */ memset(big, 1, sizeof(big)); /* 逐页写入触发大量次缺页 */ getrusage(RUSAGE_SELF, ru); printf(after : minflt%ld majflt%ld\n, ru.ru_minflt, ru.ru_majflt); return 0; }ru_minflt是次缺页页在内存里只缺页表映射ru_majflt是主缺页需要读磁盘后者代价高几个数量级。上面这段代码两个值都会变但主缺页通常只有启动时那几次。交叉验证读/proc/self/stat的第 10 和 12 个字段。这里有个坑stat的第二字段是带括号的进程名可能含空格按空格切分会整体错位。稳妥做法是先定位右括号#include stdio.h #include string.h int main(void) { char buf[1024]; FILE *fp fopen(/proc/self/stat, r); if (!fp) return 1; fgets(buf, sizeof(buf), fp); fclose(fp); char *p strrchr(buf, )); /* 进程名结束位置 */ unsigned long minflt 0, majflt 0; /* ) 后依次是 state、ppid、pgrp、session、tty、tpgid、flags、minflt、cminflt、majflt */ sscanf(p 2, %*c %*d %*d %*d %*d %*d %*u %lu %*u %lu, minflt, majflt); printf(stat: minflt%lu majflt%lu\n, minflt, majflt); return 0; }%*c、%*d表示读入但丢弃%*u对应无符号字段。p 2跳过)和其后的空格。两路结果应该一致若差几个数通常是两次采样之间程序又产生了新的缺页采样点要贴近。实验报告里结果解读部分把内存分配和缺页挂钩这个思路是对的malloc大块内存时并不立刻分配物理页真正产生缺页是在首次写入时。所以测缺页要看写操作只看malloc返回指针的话数字几乎不动很多人据此得出分配不产生缺页的错误结论。5. 避坑与排查八个实验里最容易翻车的五个点5.1 按了 Ctrl\ 子进程先死父进程 kill 返回 ESRCH现象终端里父进程打印了结束信息子进程的结束信息完全没出现kill不报错但也没效果。原因Ctrl\发出的SIGQUIT发给整个前台进程组子进程和父进程一起收到。子进程默认动作是终止于是先于父进程退出父进程再杀一个已死进程kill返回 -1errno是ESRCH但代码里没检查返回值。解决子进程fork后立刻signal(SIGQUIT, SIG_IGN)只保留对父进程自定义信号的响应同时在每次kill后检查返回值失败就打印errno。排查时用ps -ef --forest看进程树比盯着截图猜有用。5.2 Windows 编辑器里写 fork 代码编译报未定义现象在自己的 IDE 里写得好好的拷进 Linux 编译报implicit declaration of function fork或链接失败。原因fork、signal、pause、lockf都属于 POSIX 接口Windows 的运行库没有。解决源码直接在 Linux 里写或者在 Linux 侧建一个共享目录编辑。头文件要齐unistd.h给fork/pausesignal.h给signal/killsys/wait.h给wait。缺哪个补哪个不要靠猜。5.3 线程代码编译过了链接报 pthread 未定义现象gcc pc.c -o pc报一堆undefined reference to pthread_create。原因-pthread没加或者加在了源文件前面。这个选项既要影响编译定义宏也要影响链接拉库位置放错就只生效一半。解决写成gcc -g -Wall -o pc pc.c -pthread放末尾。另外注意用-pthread而不是-lpthread前者还会设置线程安全的编译宏。5.4 RR 模拟里时间片取 0程序死循环现象跑 RR 那组终端卡住不返回CPU 占用 100%。原因q小于等于 0 时每轮循环里left不减done永远到不了 nwhile出不来。解决入口处校验q 1并给循环加一个最大轮次保护比如int guard 0; while (done n guard 10000)。调试时用gdb挂上去CtrlC打断bt看栈比无头绪地改代码快。5.5 /proc/self/stat 解析错位minflt 读成了 utime现象自己算的缺页数和getrusage对不上差了几个数量级。原因stat第二字段的进程名带括号有的程序名里还有空格按空格strtok切分会让所有字段整体偏移第 10 个字段取到的其实是时间值。解决先用strrchr(buf, ))定位进程名结尾从右括号之后重新按格式串读。或者干脆用getrusage做主数据源用stat做交叉验证两者差距大就说明解析写错了。提示这五个问题里有四个的根因都是默认行为——默认信号动作、默认链接选项、默认边界值、默认字段格式。写的时候每处都显式声明一次能省掉大半排查时间。6. 进阶把一次观测做成可复现的对照实验单次跑通只能证明代码能跑要做成有说服力的结论得设计对照。缺页统计这块最简单的对照是分配但不写和分配并写满两条路径的次缺页数量差一个数量级跑一遍就能把惰性分配这件事讲清楚操作分配 4MB写入 4MBru_minflt增量个位数约 10244MB / 4KBru_majflt增量0102耗时量级微秒毫秒我一般会写一个循环把写入粒度从 1 字节、1 页、4 页各跑一次记录三次ru_minflt增量。粒度小于页时增量相同这说明缺页是按页触发的与写入次数无关跨页写入时增量按页数线性增长。这组数据比任何文字描述都直观。进程调度那部分同理。固定三个作业的到达时间只改时间片q从 1 扫到 8把平均带权周转打印成一行就能看到曲线先降后升太小切换开销大太大退化成 FCFS。这个拐点位置和作业的服务时间分布有关换一组作业数据拐点就移所以我每次都要重扫一遍不套用别人的结论。软中断实验也可以加对照一次让子进程忽略SIGQUIT一次不忽略对比kill的返回值和进程退出的先后顺序把进程组信号分发这件事从文字变成可观测的现象。工具层面strace -f -e traceclone,fork,vfork,wait4,kill能看到真实的系统调用序列比printf打点可靠得多。第一次用会看到大量意料之外的调用别慌按进程号过滤一遍就干净了。perf stat -e page-faults也能直接给出缺页计数和getrusage对照着看哪个数不对立刻能发现。从那以后我每次重跑这类实验都强制先跑一遍基线、再改一个参数、最后把两次输出并排贴在一起不做对照的数字一律不往报告里写。这习惯看着慢实际上把返工次数压下来了。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站