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

Linux /proc观测、软中断与调度缺页实验解析

Linux /proc观测、软中断与调度缺页实验解析 ★ FEATURED ARTICLE
简介这份西南交通大学操作系统实验报告评分A95分面向软件工程等专业修读操作系统课程的学生以及需要完成Linux环境实践、系统调用观测和进程管理实验的读者可作为课程实验的参考范例与复盘材料。资源包为1个docx文档约1.48MB按实验编号组织包含实验目的、实验内容、操作步骤与总结等完整记录。内容从Linux用户界面、目录结构和Shell常用命令入手涉及重定向与管道机制并记录C语言程序的编辑、gcc编译及gdb调试过程随后围绕/proc文件系统观察cpuinfo、version、stat、uptime并编程读取meminfo、loadavg、interrupts、filesystem等信息。后半部分还覆盖软中断通信、进程调度中FCFS与RR策略验证以及线程同步实验便于读者对照实验流程、理解内核状态信息获取方式并借鉴报告撰写结构与调试排错思路。已有403人学习适合用于课程实验前的预习、实验后的查漏补缺以及报告格式参考。1. 一个 size 恒为 0 的文件却能读出实时内存ls -l /proc/meminfo会告诉你这个文件大小是 0 字节可cat它却能刷出一屏内存数字再cat一次数字还变了。这不是玄学而是 Linux 操作系统把内核状态「投影」成文件的那层设计——虚拟文件系统 /proc。搞懂它你就明白为什么内核观测、进程调试、性能采样几乎都从读文件开始也明白这份操作系统实验报告为什么把「观察 /proc」排在所有实验的最前面后面的软中断、进程调度、缺页统计全靠它把看不见的内核行为取出来。2. /proc 虚拟文件系统读文件为什么能问出内核的秘密Linux 里「一切皆文件」这句话是靠 VFS 撑起来的。VFS 只认 inode 和file_operations这套接口至于这个 inode 背后是真磁盘块、是网络套接字还是内核内存里的一段实时计算它并不关心。/proc被挂载时注册了自己的文件系统类型路径解析到/proc/meminfo的那一刻内核现场造出一个 proc inode把read绑到内核里的一个回调函数上。所以ls -l看到的 0 字节是真的它没有数据块size字段对这类文件没有意义你每cat一次都是内核重新算一遍再交给你。这也解释了一个常见困惑——为什么cp /proc/meminfo /tmp/x得到的是那一瞬间的快照而不是一个会持续更新的镜像。2.1 从 open 到 read一次「假文件」的真实调用链如果只是「open 时生成内容」实现起来会很简单早期的read_proc_t就是这么干的准备好一个不超过一页4KB的缓冲区把字符串填进去返回长度。问题是/proc/meminfo随内核版本越来越长/proc/cpuinfo在几十核的机器上早就超了一页一页装不下就截断用户看到的数字是残缺的——这是血泪经验级别的设计缺陷。后来内核把大部分 proc 文件迁到了 seq_file 机制上。它把「取数据」拆成两件事一组迭代器start / next / stop复杂在数据结构上走位一个show负责把当前元素格式化进缓冲区seq_read在缓冲区被用户读空后再填下一批。这样输出多长都无所谓页边界被彻底藏起来了。常见的实现思路是首次read触发一次填充返回已填好的部分用户继续read时再续填直到迭代器走到stop为止。所以 proc 文件天然支持流式读取但天然不支持随机访问——lseek到中间再读结果没有保证。这套机制带来一个直接的工程约束读 proc 文件必须顺序读到 EOF不能用fseek定位、不能用stat的大小去预分配缓冲区。文件数据来源关键字段能回答什么问题/proc/cpuinfoCPU 拓扑与特性processor、model name、flags几个核、支持哪些指令集/proc/meminfo内存 zone 统计MemTotal、MemAvailable、SwapTotal真实可用内存还剩多少/proc/loadavg调度器负载采样1/5/15 分钟三个值、运行/总进程数系统是忙还是卡在 IO 上/proc/uptimejiffies 换算开机秒数、累计空闲秒数机器开了多久、闲了多久/proc/[pid]/stat进程 task_structminflt、majflt、utime、stime这个进程缺页几次、吃了多少 CPU/proc/interrupts中断控制器统计每个 CPU 每根 IRQ 的计数中断打在哪个核上、是否失衡2.2 三个文件背后的计算meminfo、loadavg、uptime 分别是谁在算/proc/meminfo的数字不是「剩余物理内存」这么简单。MemFree是彻底没被用的页MemAvailable才是内核对「新程序还能申请到多少」的估算它把可回收的页缓存、可回收的 slab 都算了进去。所以判断内存是否吃紧看MemAvailable比看MemFree靠谱得多只盯MemFree的人常常在一台缓存用得很足的机器上误判成内存告急。/proc/loadavg更容易被误读。很多人以为它是「过去一分钟的 CPU 使用率」其实它是「处于可运行状态 R 和不可中断睡眠状态 D 的进程数的指数衰减平均」每 5 秒更新一次用定点数做平滑。这意味着一个卡在磁盘 IO 上的进程同样会把 load 顶上去CPU 却闲得很——负载高但 CPU 空闲八成是 IO 在拖。这个认知差异恰恰是 /proc 实验里最值得亲手验证的一点。/proc/uptime则直接暴露了内核的时间基准。开机时内核读 RTC 拿到墙钟作为基准之后靠jiffies这个全局变量每过一个 tick 加一往前推。HZ决定一秒有多少个 tick常见是 250 或 1000。所以 uptime 的秒级精度来自jiffies / HZ它和墙上时钟的关系是「开机那一刻对齐、之后各走各的」。#include stdio.h /* 从 /proc/meminfo 提取 MemTotal / MemAvailable单位 kB */ static int read_meminfo(unsigned long *total_kb, unsigned long *avail_kb) { FILE *fp fopen(/proc/meminfo, r); char line[256]; if (!fp) return -1; while (fgets(line, sizeof(line), fp)) { if (sscanf(line, MemTotal: %lu kB, total_kb) 1) continue; /* 命中后继续找下一项 */ if (sscanf(line, MemAvailable: %lu kB, avail_kb) 1) break; /* 两项都拿到就收工 */ } fclose(fp); return 0; } int main(void) { unsigned long total 0, avail 0; double load[3] {0.0}; FILE *fp; if (read_meminfo(total, avail) ! 0) return 1; fp fopen(/proc/loadavg, r); if (fp) { fscanf(fp, %lf %lf %lf, load[0], load[1], load[2]); /* 只取前三个字段 */ fclose(fp); } printf(total%lu kB avail%lu kB used%.1f%%\n, total, avail, total ? (1.0 - (double)avail / total) * 100.0 : 0.0); printf(loadavg %.2f %.2f %.2f\n, load[0], load[1], load[2]); return 0; }这段代码的关键点不在语法而在读法。fgets逐行扫描而不是一次性把整个文件读进内存是因为前面说的「size 不可信」%lu必须和unsigned long对齐在 64 位机器上写成%u会截断高位MemAvailable在较老的内核上不存在那时只能用MemFree Buffers Cached粗略估算。判断读没读成功看返回值而不是看缓冲区里有没有字符——proc 文件读取失败时可能只返回半行。编译时加-O2也不影响因为这些值都是从内核实时取的不存在缓存问题。3. 软中断信号一次 Ctrl\ 是怎样把进程拆掉的「软中断」这个说法容易让人误会成软件模拟的硬件中断其实它和硬件中断的路径完全不同。硬件中断是打断 CPU 当前指令流软中断信号signal只是内核在进程的task_struct里把一个 pending 位图上的位置 1然后等这个进程从内核态返回用户态的那一刻才把执行流拐到处理函数上去。它不会被「立即执行」只会「在下一次回到用户态时执行」。理解这一点很多奇怪的现象就顺了一个正在死循环里跑的进程照样能被信号打断因为它迟早会因为时钟中断回到内核再返回而一个卡在不可中断睡眠D 状态的进程信号会被挂起很久都不生效。3.1 从终端按键到信号帧SIGQUIT 的完整旅程按下 Ctrl\ 时键盘字符先进入 tty 层的线路规程。若termios的ISIG标志打开特殊字符VQUIT默认就是 Ctrl\会被识别出来转成SIGQUIT发给这个终端的前台进程组而不是某一个进程。这就是为什么实验中父进程一按 Ctrl\两个子进程理论上也会收到——除非子进程自己signal(SIGQUIT, SIG_IGN)把它忽略掉。实验代码里子进程全都忽略了 SIGQUIT只让父进程接手退出节奏才完全由父进程掌控这个设计不是随手写的。信号投递之后内核在返回用户态的路径上检查 pending 位图命中就为用户态栈上构造一个信号帧把返回地址改成处理函数入口同时把原现场保存在sigcontext里。处理函数执行完返回时会自动跳进一段内核提供的 trampoline 去调sigreturn把现场恢复回来。所以处理函数是被「插」进正常执行流里的一段代码它跑在用户态却对主流程完全异步。3.2 父子进程的交接kill、wait 与僵尸态kill(pid, sig)的名字有误导性它只负责投递信号能不能让目标结束取决于目标有没有给这个信号装处理函数。SIGQUIT 的默认动作是终止并产生 core所以不装处理函数的进程收到它就死了装了处理函数的进程收到它只是执行一段代码。子进程调用exit后并不会立刻从系统里消失它的task_struct还留着退出码状态变成 Z僵尸等父进程wait来收尸。父进程如果不wait就自己退出这个僵尸会被 init 或者 systemd 收养并清理但如果父进程长期运行又不wait僵尸就会越堆越多ps里一片defunct。这份实验报告里父进程在kill之后紧跟两次wait(0)回收的正是这个。#include stdio.h #include signal.h #include unistd.h #include sys/wait.h static volatile sig_atomic_t wait_mark 1; /* 处理函数只碰这个标志 */ static void on_stop(int sig) { (void)sig; wait_mark 0; } int main(void) { pid_t p1 fork(); if (p1 0) return 1; if (p1 0) { /* 子进程 */ signal(SIGQUIT, SIG_IGN); /* 忽略终端的 Ctrl\退出节奏交给父进程 */ signal(16, on_stop); /* 自定义信号号收到即置标志 */ while (wait_mark) pause(); /* 阻塞等待不烧 CPU */ printf(child %d exit\n, getpid()); _exit(0); /* 子进程用 _exit避免重复刷新父进程缓冲 */ } signal(SIGQUIT, on_stop); /* 父进程接手终端的 SIGQUIT */ while (wait_mark) pause(); kill(p1, 16); /* 唤醒子进程的处理函数 */ wait(NULL); /* 回收僵尸取退出码 */ printf(parent %d exit\n, getpid()); return 0; }pause()会让进程睡眠直到任意信号到达比while (wait_mark) ;的忙等省 CPU但引入了一个经典竞态如果信号恰好在while判断完、pause执行前到达处理函数把wait_mark置 0接着进程才进入pause而此后不会再有信号来唤醒它于是永久挂起。正确姿势是先用sigprocmask屏蔽目标信号判断完标志后调sigsuspend它在挂起的同时原子地解除屏蔽把「判断」和「睡眠」缝成一个不可分割的动作。至于信号号为什么写 16那是早期教材沿用下来的写法不同体系结构上 16 对应的具体信号并不相同工程代码里应该写SIGUSR1这类宏而不是裸数字。3.3 为什么处理函数里只能置标志位处理函数被插进主流程的任意位置执行它可能正打断malloc内部的链表操作也可能正打断printf的缓冲区刷新。此时再去调printf、malloc这类非异步信号安全的函数就是在一把已经持有的锁上再抢一次轻则输出交错乱码重则直接死锁卡住。所以处理函数的正确写法永远是「置一个volatile sig_atomic_t标志或者往管道里write一个字节剩下的交给主循环处理」。这条规则在实验里体现得特别明显所有处理器都只做wait_mark 0这一件事。4. 调度与同步FCFS 和 RR 的差距到底落在哪个数字上验证调度策略时「RR 更公平」「FCFS 有护航效应」这类话谁都会说但要让人信服得拿出具体数字。调度效果有三个常用度量口径周转时间等于完成时刻减到达时刻衡量一个任务从进系统到离开总共花了多久带权周转时间等于周转时间除以服务时间把长短任务拉到同一尺度上比较短任务被拖一点带权值就爆表响应时间则是从到达到第一次被调度的时间交互场景更关心它。4.1 用一组数据把两种策略算清楚假定三个任务J1 在 0 时刻到达需要 7 个时间单位J2 在 2 时刻到达需要 4 个J3 在 4 时刻到达需要 1 个。FCFS 按到达顺序跑完J1 独占前 7 个时间片J3 只有 1 个单位的工作却要等到第 11 个片才开始这就是护航效应RR 取时间片 2三个任务轮流上 CPU。策略抢占平均周转时间平均带权周转时间典型问题FCFS否8.003.75长任务堵住短任务队列RRq2是7.002.11时间片过小则切换开销吃掉收益RRq 极大是同 FCFS同 FCFS退化成先来先服务RRq1是略优略优上下文切换次数暴涨from collections import deque # jobs: (名称, 到达时间, 服务时间) def fcfs(jobs): t, out 0, [] for name, arrive, burst in sorted(jobs, keylambda j: j[1]): t max(t, arrive) burst # 空闲也要等到任务到达 turn t - arrive out.append((name, turn, turn / burst)) return out def rr(jobs, q): arrive {n: a for n, a, _ in jobs} remain {n: b for n, _, b in jobs} order [n for n, _, _ in jobs] t, cur, done, out 0, 0, [], [] ready deque() while len(out) len(jobs): # 把所有已到达任务放进就绪队列 for n in order: if arrive[n] t: ready.append(n) if not ready: # 队列空则时间跳到达下一个到达点 t min(a for a in arrive.values() if a t and remain[[k for k, v in arrive.items() if v a][0]] 0) \ if any(remain[n] 0 for n in order) else t 1 continue cur ready.popleft() step min(q, remain[cur]) t step remain[cur] - step for n in order: # 本片执行期间到达的任务入队 if arrive[n] t - step and arrive[n] t and remain[n] 0 and n not in ready: ready.append(n) if remain[cur] 0: ready.append(cur) # 没跑完放回队尾 else: turn t - arrive[cur] out.append((cur, turn, turn / [b for n, _, b in jobs if n cur][0])) return out if __name__ __main__: J [(J1, 0, 7), (J2, 2, 4), (J3, 4, 1)] for label, res in ((FCFS, fcfs(J)), (RR q2, rr(J, 2))): avg_t sum(r[1] for r in res) / len(res) avg_w sum(r[2] for r in res) / len(res) print(f{label:8s} 平均周转{avg_t:.2f} 平均带权周转{avg_w:.2f})这段代码里的q就是时间片长度直接决定轮转的粒度。把q调到大于最长服务时间rr的输出会和fcfs完全一致这就是「RR 退化」的可验证证据把q调到 1切换次数从 3 次涨到 12 次虽然平均带权周转略好但每次切换都要保存和恢复寄存器、刷 TLB、动缓存真实系统里开销很快就吃掉收益。工程上时间片一般取 10 到 100 毫秒这个量级本质是在「响应足够快」和「切换别太频繁」之间找平衡点。4.2 竞态是怎么发生的i 根本不是一条指令调度解决的是「谁上 CPU」同步解决的是「多个执行流同时碰共享数据会不会出事」。i在源码里是一行编译出来是 load、add、store 三条指令。如果时间片正好切在 load 之后 add 之前另一个线程也读了同一个旧值两个人都写回其中一次的增量就凭空蒸发了。这类 bug 的可怕之处在于它可以几千次运行都不复现压力一上来必现典型的玄学问题。临界区要满足三条互斥、前进、有限等待。实现手段从弱到强大致是文件锁lockf只能锁住「同一个文件描述符上的协作进程」语义粗但跨进程可用信号量 PV 操作能控制的不只是互斥还有数量互斥量pthread_mutex是线程级通常配合条件变量做生产者消费者。这份实验报告里用lockf(1, 1, 0)锁住标准输出再打印、打完lockf(1, 0, 0)解锁锁的其实不是数据而是终端目的是让多个进程的printf不要交错成半行——这是最原始但最直观的互斥演示。要注意lockf的锁是跟着进程走的fork之后子进程不继承这把锁的所有权父进程持有锁时fork子进程再去加锁就会卡在自己身上。5. 避坑排查五个让实验结果对不上的地方实验环境里大部分「结果不对」事后看都不是内核行为异常而是写法或读法踩了坑。下面五条按「现象、原因、解决」记录都是在这类实验里反复出现的。5.1 与信号、进程有关的三条第一条加了优化之后程序卡住不退出。现象是while (wait_mark) ;这种循环编译器一旦开启-O2就再也不看内存里的wait_mark了信号处理函数改了也没用程序死等。原因是变量没加volatile编译器把它缓存进寄存器循环体里没有副作用优化成死循环完全合法。解决是把标志声明成volatile sig_atomic_t既阻止缓存又保证读写是单条指令不会读到半个值。调试阶段的习惯是先-O0跑通再逐步开优化一旦行为变化第一个怀疑对象就是这类没有volatile的共享变量。第二条信号处理函数里printf输出乱序甚至整程序挂死。现象是多进程场景下输出半行夹半行、或者按下 Ctrl\ 之后没有任何输出直接僵住。原因是处理函数打断了主流程主流程可能正持有 stdio 的内部锁处理函数里再调printf就是同一把锁二次加锁。解决只有一条路处理函数里只置标志位或者write一个字节所有格式化输出全部挪回主循环。第三条fork之后文件锁卡死。现象是父子两个进程都调lockf锁同一个文件子进程永远阻塞。原因是fork出来的子进程不继承父进程持有的锁但父进程那把锁还在子进程再去加就冲突如果父进程在等子进程结束就成了互相等待。解决是锁的区域尽量收窄到fork之后或者改用信号量并配合pthread_atfork做父子进程间的状态清理。5.2 与 /proc 解析、运行环境有关的两条第四条用fseek定位或者用stat的大小分配缓冲区读出来是空的。现象是st_size为 0按这个长度malloc出来再read一个字节都没有。原因是 proc 文件的 size 字段没有语义内容在read时才由内核现场生成。解决是顺序读到 EOF用fgets循环或者read返回 0 作为结束条件缓冲区固定给 4KB 到 8KB别指望一次读完。第五条按空格切/proc/[pid]/stat取缺页次数取到的数字明显不对。现象是算出来的缺页数比实际小几个数量级或者干脆是进程名的一部分。原因是 stat 的第二个字段是带括号的进程名(comm)进程名里允许出现空格和括号按空格切必然错位。稳妥的解法是从右往左数或者先把第一个左括号到最后一个右括号整段切掉再按空格切也可以直接换/proc/[pid]/statm或getrusage拿这些计数省掉解析的麻烦。注意验证 proc 文件内容时最好在两次读取之间插一段会明显改变状态的操作比如分配大块内存、触发一次 IO否则数字的小幅波动很容易被当成解析错误。6. 缺页统计的取舍为什么不一次把程序全读进内存程序启动时内核并不会把可执行文件的全部内容读进物理内存只建立虚拟地址到文件的映射真正的页在被访问时才由缺页异常拉进来。这个设计直接带来两类计数minflt是「不需要 IO 就能补上的缺页」比如访问一块已经在页缓存里的匿名页majflt是需要读磁盘的缺页。前者反映的是地址空间铺开的规模后者才反映真实的 IO 压力。6.1 怎么亲眼验证按需调页getrusage拿的是当前进程的累计值适合在代码里打点/proc/self/stat的第 10、12 个字段分别对应minflt和majflt适合外部观察。最直观的验证是mmap一个大文件然后逐页读一个字节并打印计数你会看到majflt一页一页往上涨而不是在mmap那一行就涨完。#include stdio.h #include sys/resource.h /* 打印当前进程的缺页计数可放在 mmap 前后各调一次做差 */ static void show_faults(const char *tag) { struct rusage ru; getrusage(RUSAGE_SELF, ru); printf(%-12s minor%ld major%ld\n, tag, ru.ru_minflt, ru.ru_majflt); }6.2 按需调页背后的取舍这个机制的取舍其实很直白一次性全读进来启动快、后续无缺页但内存浪费在可能永远不会执行的代码段上多进程共享同一个可执行文件时的复用也无从谈起完全按需内存利用率最高代价是每次首次访问都要陷一次内核遇到冷启动或者大范围扫描缺页抖动会把延迟拉得很难看。工程上的折中是预读缺页时顺便把相邻几页一起读进来用一次 IO 摊薄多次缺页同时页面置换用 LRU 的近似实现控制驻留集大小。所以看到majflt高不一定是程序写错了也可能是工作集超出了可用内存这时候该动的是内存规格或者访问局部性而不是去改代码里的循环。这份实验报告从环境熟悉、proc 观测、软中断、调度、线程同步一路做到缺页统计代码和结果分析都留在里面配上上面这几层机制去读比单纯抄一遍代码收获大得多。从那以后我每次写带信号处理的程序都强制走一遍三件事标志位加volatile sig_atomic_t、处理函数里绝不调printf、wait一个不落。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站