☰
Linux内核观测与进程控制:从/proc、jiffies到调度信号实验
2026/11/2 4:52:35 网站建设 项目流程

简介:这份西南交通大学操作系统实验报告文档面向软件工程等专业学习Linux与操作系统课程的学生,提供可直接参考的实验报告范例,并附有评分A(95分)的完成质量参照。内容围绕Linux环境与C语言程序调试展开,涵盖Shell常用命令、重定向与管道机制,以及gcc编译、gdb调试等基础操作;随后延伸到/proc文件系统观测,包括cpuinfo、version、stat、uptime以及meminfo、loadavg、interrupts、filesystem等内核状态信息,并设置软中断通信、进程调度(FCFS与RR策略验证)和线程同步等实验模块。压缩包内共1个docx文件,大小约1.48MB,报告按个人声明、目录、分实验章节与实验总结组织,便于对照课程进度查阅。已有404人学习,适合需要梳理实验流程、理解报告写法、对照实验目的与步骤或查漏补缺的读者参考。

1. 从“看不到”的崩溃说起:Linux 操作系统的真相藏在哪

Linux 操作系统这门课最反直觉的一点是:代码跑成什么样,答案往往不在代码里,而在内核替你维护的那堆计数器里。某高校软件工程专业的一份实验报告,把七个实验串成了一条链:先用/proc学会“看”,再用信号学会“打断”,用调度学会“排队”,用缺页统计学会“记账”。评分能到 95,靠的不是截图漂亮,而是每个现象都能追到一个内核变量上。这份东西值得翻,是因为它把黑匣子拆成了可复现的观测点,并非一份把命令抄一遍的作业。

2. procfs 与 jiffies:内核变量是怎么被“伪装”成文件的

在终端敲cat /proc/uptime,会得到两个浮点数,比如12345.67 98765.43。多数人看完就过去了,但这两个数其实分别来自一个全局变量和一个开机时读到的硬件时钟,中间隔着一整套虚拟文件系统的分发逻辑。搞清这条链路,后面所有“观测类”实验都只是换个文件读而已。

2.1 一次 open("/proc/uptime") 背后发生了什么

/proc不在任何磁盘上,它是 procfs 挂载出来的内存文件系统。内核启动时用一套注册接口把“文件名 + 读回调”挂进 procfs 的目录树,用户态open时内核只是创建了一个inode,带上一组指向内核函数的操作表;真正干活是在read的那一刻——内核现场拼字符串,把当前值格式化进你给的缓冲区。所以每次cat出来的数字都不一样,不是缓存刷新,是每次都重新算。

常见的实现思路是两层结构:一层负责目录和条目的生命周期,另一层(seq_file)负责把可能很长的输出分片吐出来,避免一次性占用太多栈。前者回答“这个文件叫什么、谁能看”,后者回答“内容怎么一段段写出去”。理解了这一层,就能明白为什么/proc/meminfo十几 KB 的内容读起来毫无压力,也可以明白为什么读它的时候不能假设“两次 read 之间值不变”。

2.2 jiffies 与开机基准时间:uptime 的第一个数是怎么来的

内核里有个全局变量jiffies,每个时钟节拍加一。节拍频率由编译期的HZ决定,常见配置下HZ=250或1000,也就是说每 4 毫秒或 1 毫秒加一。开机时内核从 RTC 硬件读一个时间作为基准,把这个基准换算成 jiffies 的初值。

换算方式很朴素:拿基准时间减去 1970-01-01 00:00:00 UTC,得到秒数,再乘HZ。这里有个容易被忽略的细节——不少版本会给jiffies一个负的初始偏移量(比如相当于开机前 5 分钟),目的是让回绕比较逻辑在启动阶段也能正常工作,避免刚开机就撞上无符号数溢出判断。这是个典型的“用一点小别扭换全局一致性”的设计。

于是uptime的第一个数就是(jiffies - 开机时的 jiffies) / HZ,第二个数是所有 CPU 累计空闲的时间,单位秒。当年做算法实验时习惯用滴答计数,换成 Linux 其实是同一套思路,只是单位从“一次循环”变成了“一个节拍”,而节拍长度由内核编译时定死,用户态改不了。

2.3 用 C 读 meminfo:从 fopen 到解析的完整闭环

报告里要求编程观测meminfo、loadavg、interrupts、filesystem这几项,本质都是同一件事:打开、按行读、按字段切、转数字。真正容易翻车的是字段匹配。

#include <stdio.h> #include <string.h> #include <stdlib.h> /* 从 /proc/meminfo 里取出指定字段的 kB 值;找不到或打不开返回 -1 */ static long read_meminfo_kb(const char *key) { FILE *fp = fopen("/proc/meminfo", "r"); if (!fp) return -1; /* procfs 未挂载,或权限不足 */ char line[256]; size_t klen = strlen(key); long val = -1; while (fgets(line, sizeof(line), fp)) { /* 行的形态是 "MemTotal: 16384000 kB" */ if (strncmp(line, key, klen) == 0 && line[klen] == ':') { /* 跳过冒号和空白,只取十进制数字,单位 kB 直接忽略 */ val = strtol(line + klen + 1, NULL, 10); break; } } fclose(fp); return val; } int main(void) { long total = read_meminfo_kb("MemTotal"); long avail = read_meminfo_kb("MemAvailable"); long freeb = read_meminfo_kb("MemFree"); if (total <= 0) { perror("/proc/meminfo"); return 1; } /* 已用取 总量-可用,比 总量-空闲 更接近真实占用,后者会把缓存算成已用 */ printf("total=%ld kB available=%ld kB free=%ld kB used=%ld kB\n", total, avail, freeb, total - avail); printf("used ratio = %.2f%%\n", 100.0 * (total - avail) / total); return 0; }

代码里的关键点有三个。strncmp只比前缀,所以必须再判line[klen] == ':',否则查Cached会先撞上SwapCached,拿到一个完全不相干的值——这是最隐蔽的一类错误,程序不报错,数字就是错的。strtol从冒号后一位开始扫,会自动跳过空白,遇到kB停下,不用手工sscanf。MemAvailable是内核估算的“还能给新进程用多少”,包含可回收的页缓存,比MemFree有意义得多,很多老教程还在用后者算占用率,结果一台机器看起来永远 90% 满。

参数上,key传的是不带冒号的字段名;返回值单位是 kB(历史上 procfs 就这么定的,没改成字节);返回-1表示没找到或打开失败,调用方要区分这两种情况只能再查errno。

2.4 缺页统计:pgfault 与 pgmajfault 到底数的是什么

/proc/vmstat里有两行值得盯:pgfault是累计缺页次数,pgmajfault是需要真实 IO 的那部分。用户态访问一个还没建立映射的虚拟地址,CPU 抛页错误异常,内核进handle_mm_fault:如果页框里已经有内容(比如共享库、页缓存命中),只改页表,算一次次缺页;如果得去磁盘捞,算一次主缺页。两个计数器就是在这条路径上加的。

统计差值比统计绝对值有用,因为这两个数从开机起只增不减:

#!/bin/bash # 取 /proc/vmstat 中某一列的数值,$1 是字段名 get() { awk -v k="$1" '$1==k {print $2}' /proc/vmstat; } f1=$(get pgfault); m1=$(get pgmajfault) sleep 5 f2=$(get pgfault); m2=$(get pgmajfault) # 次缺页 = 总缺页 - 主缺页,两个采样点之间 echo "minor: $((f2 - f1 - (m2 - m1))) major: $((m2 - m1))"

这里awk用的是$1==k精确匹配,不能图省事写grep pgfault。vmstat 的字段名是完整单词、没有前缀包含关系,但换到别的 proc 文件里,grep就会把pgfault和pgmajfault一起捞出来,差值算出来是负数。

3. 软中断与信号:Ctrl+\ 为什么能让三个进程按顺序倒下

按下Ctrl+\,程序并没有“读到这个按键”。终端驱动把它翻译成一个SIGQUIT,投递给当前前台进程组里的每个进程,内核在下次从系统调用或中断返回用户态之前检查待处理信号,命中就调你注册的处理函数。这条链上没有任何“中断向量表”的参与,所谓“软中断通信”,软在它是纯软件投递,中断在它的执行时机是打断正常控制流。

3.1 signal() 注册的不是中断向量,是进程描述符里的一个位

signal(SIGQUIT, stop)干的事,是把stop这个用户态函数的地址写进当前进程的信号处理表。内核维护每个进程的待处理信号位图,投递时置位,返回用户态前统一处理。所以处理函数运行在用户态、运行在该进程的上下文里、可以访问该进程的全局变量——这一点决定了后面所有写法。

SIGQUIT的默认动作是终止并转储核心,SIGKILL和SIGSTOP不可被捕获或忽略,其余大部分信号都能改。报告里选了 16 号信号做“自定义通知”,在常见架构上它对应一个用户可用的编号,语义完全由程序自己约定,内核不管。

3.2 wait_mark 自旋:kill 发出后子进程怎么知道

来看父子孙三层终止的核心写法,这一版把三层的逻辑摊平了:

#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <signal.h> static volatile int wait_mark = 1; /* 处理函数会改它,volatile 不能省 */ static void stop(int sig) { wait_mark = 0; } int main(void) { pid_t p1; while ((p1 = fork()) == -1) /* fork 失败(如进程数满)就重试 */ ; if (p1 > 0) { /* 父进程 */ printf("parent pid=%d\n", getpid()); signal(SIGQUIT, stop); /* Ctrl+\ 来了只置标志位,不做别的 */ while (wait_mark) /* 自旋等标志,不睡眠 */ ; kill(p1, 16); /* 通知子进程收尾 */ wait(NULL); /* 收割子进程,避免僵尸 */ printf("parent %d exit\n", getpid()); exit(0); } while ((p1 = fork()) == -1) /* 子进程再 fork 一次,造出孙进程 */ ; if (p1 > 0) { /* 子进程 */ printf("child pid=%d ppid=%d\n", getpid(), getppid()); signal(SIGQUIT, SIG_IGN); /* 忽略 Ctrl+\,只听父进程的 16 */ signal(16, stop); while (wait_mark) ; kill(p1, 16); /* 先让孙进程走 */ wait(NULL); printf("child %d exit\n", getpid()); exit(0); } else { /* 孙进程 */ printf("grand pid=%d ppid=%d\n", getpid(), getppid()); signal(SIGQUIT, SIG_IGN); signal(16, stop); while (wait_mark) ; printf("grand %d exit\n", getpid()); exit(0); } }

wait_mark前面那个volatile不是为了“线程安全”,是为了让编译器别把它优化进寄存器。自旋循环while (wait_mark);在优化开启时会被编译器判定为“循环体不改内存”,直接优化成死循环或者干脆删掉,现象就是按下组合键程序毫无反应。这个坑在没有-O2的课堂上不容易暴露,一换编译选项就翻车。

signal(SIGQUIT, SIG_IGN)这一行是整段代码的调度开关。如果子进程也响应SIGQUIT,终端一次投递会把三层同时打掉,终止顺序变成竞态,谁也说不准日志长什么样。忽略掉它、只认父进程发的 16 号信号,顺序才由代码决定。

wait(NULL)参数是NULL表示不关心子进程退出码,只阻塞到任意一个子进程结束。它的作用是把子进程残留在进程表里的表项收走,否则子进程退出后变成僵尸,父进程一直不wait,僵尸就一直挂着。

3.3 终止顺序:先杀孙子靠的不是信号优先级

从结果看,孙进程先打印结束,然后子进程,最后父进程。这不是因为 16 号信号有什么优先级,而是因为调用链是串的:父进程在kill(p1, 16)之后紧接着wait(NULL),在子进程退出前它不会往下走;子进程收到 16 后同样先kill再wait,孙进程不退出它也不走。三个进程被wait串成了一条单向的等待链,顺序是被阻塞关系强制出来的,和信号排队没关系。

这也解释了为什么报告里第一版“父进程创建两个子进程”的实验,输出顺序是 P2 先死、P1 后死:父进程依次调了两次kill,两次之间没有同步,谁先收到谁先跑,顺序取决于内核调度,属于“看运气”。想让它稳定,就得改成先发一个、wait一个、再发下一个。

3.4 输出竞态:lockf(1, 1, 0) 保护的是 stdout 这一行

三个进程往同一个终端写,printf里的字符串可能被切开交错,出现半行半行拼在一起的情况。报告里用lockf把输出包起来:

lockf(1, 1, 0); /* 对 fd=1(stdout)加排他锁,第三个参数 0 表示锁到文件尾 */ printf("\n child %d killed by %d", getpid(), getppid()); lockf(1, 0, 0); /* 解锁 */

参数含义:第一个是文件描述符,第二个1是加锁、0是解锁、2是测试能否加锁,第三个是锁定区间长度,0表示从当前位置到文件末尾。锁加在文件本身(这里是终端设备)上,所以同一终端上的多个进程会互斥。

但只用对了一半还是会乱。printf到终端是行缓冲,遇到换行才刷;重定向到文件时变成全缓冲,四 KB 才刷一次。锁只保证“写这一行的时候别人插不进来”,如果缓冲区里的内容在解锁之后才真正落盘,两个进程的缓冲顺序照样可能颠倒。稳妥写法是在解锁前补一句fflush(stdout),把缓冲强制推出去。

4. 进程调度与线程同步:FCFS 和 RR 的差距到底藏在哪个变量上

调度实验最容易做成“填表格”:给几组到达时间和服务时间,手工算周转时间,跟程序输出对一对,过了。但真正需要理解的是:FCFS 和 RR 的差别并不体现在平均周转时间这个最终数字上,它藏在就绪队列的入队时机里;一旦代码的入队顺序错了,输出会“看起来像对的”,只是数值对不上。

4.1 就绪队列、时间片、上下文切换三件套

把调度器拆开,只有三样东西在动。进程控制块记录每个进程的到达时间、还需运行时间、已等待时间;就绪队列按某种规则排队;时钟中断每个节拍进来一次,把当前进程的剩余时间减一,减到零就触发一次切换。

FCFS 是非抢占的,进程一旦上 CPU 就跑到底,队列退化成先进先出,实现简单但没有交互性——一个长任务会把后面所有短任务堵死。RR 就是给 FCFS 加了个“最多跑多久”的上限,时间片用完强制放回队尾。时间片取值很讲究:取太大退化成 FCFS,取太小上下文切换的开销占比飙升,实际系统里通常取几十毫秒量级,让一次切换的开销摊薄到可以忽略。

4.2 周转时间与等待时间:公式背后在测什么

指标定义反映的问题
周转时间完成时刻 − 到达时刻用户从提交到看到结果的总耗时
带权周转时间周转时间 ÷ 服务时间短任务被拖累的程度,越大越不公平
等待时间周转时间 − 服务时间纯排队损耗,不含真正执行

三个指标一起看才有意义。RR 通常能把平均等待时间压下来,但上下文切换变多;带权周转时间对短任务特别敏感,一个 1 毫秒的任务如果等了 100 毫秒,带权周转就是 101,平均值会被这一个数拉爆,所以单看平均数容易误判,得看分布。

4.3 用一段可跑的 RR 模拟验证入队顺序

下面这段模拟的重点不在算法,在注释标出的那一行:

from collections import deque def rr(procs, quantum): """procs: [(pid, arrive, burst)];返回 {pid: 完成时刻}""" procs = sorted(procs, key=lambda p: p[1]) # 按到达时间排,决定初始入队顺序 q, done = deque(), {} remain = {p[0]: p[2] for p in procs} time, i = 0, 0 while i < len(procs) or q: # 把 time 时刻之前已到达的进程全部入队 while i < len(procs) and procs[i][1] <= time: q.append(procs[i][0]); i += 1 if not q: # CPU 空闲,直接快进到下一个到达点 time = procs[i][1] continue pid = q.popleft() run = min(quantum, remain[pid]) # 时间片够不够跑完 time += run remain[pid] -= run # 关键:新到达的进程必须赶在当前进程重新入队之前进来,否则 RR 退化成 FCFS while i < len(procs) and procs[i][1] <= time: q.append(procs[i][0]); i += 1 if remain[pid] > 0: q.append(pid) else: done[pid] = time return done

那段“新到达先入队”的顺序,就是很多人结果对不上的原因。如果先把自己放回队尾、再收新到达的进程,那么在当前进程用完时间片的这段时间里到达的任务,会排到它后面,等价于给它让了一次路——这在时间片较大时和 FCFS 的输出几乎一致,你调参数怎么调都觉得“RR 没效果”。

quantum改大改小可以直接观察平均等待时间的变化曲线;remain单独存一份剩余时间,不要直接改原列表里的burst,否则要算带权周转时间时原始服务时间已经被破坏了。

4.4 线程同步:锁保护的到底是数据还是顺序

多线程实验里最常见的误解,是把互斥锁当成“让代码按顺序执行”的工具。锁真正保护的是共享数据的不变式:一段代码读-改-写某个变量,中间不能被打断,否则两个线程可能读到同一个旧值、各自加一、覆盖回去,最后只加了一次。锁把这段临界区变成原子的,至于哪个线程先进入临界区,取决于调度,锁并不保证。

生产者消费者这类结构里,光有互斥锁不够,还得有条件变量:缓冲区满时生产者要能睡着、被消费者唤醒,而不是拿着锁空转。lockf那套文件锁和线程互斥锁解决的是同一个抽象问题,只是作用域从进程内扩大到了进程间,代价是每次都要陷入内核。

5. 排查:七个实验里最容易翻车的五个现场

这些坑的共同特点是:程序不报错,结果就是不对。没有断点可打,只能靠对着现象倒推哪一层出了问题。

5.1 vi 里想改文件名,结果发现根本没有“重命名”

现象:在编辑器里折腾半天想换个文件名,保存完发现原文件还在,多出来一个新文件。

原因:编辑器管的是内容,不管目录项。:w newname的语义是“把缓冲区内容写到另一个文件”,原文件完好无损,等于复制而非改名。文件名是目录里的一个条目,改它需要动目录,那是mv的活。

解决:退出编辑器,mv old.c new.c。想少打字就mv old.c !$:r.c之类的 shell 展开,别在编辑器里找不存在的功能。

5.2 网络连不上:三种虚拟网络模式的可见性是单向的

现象:虚拟机配置看着没问题,就是连不通,改成某种模式后突然好了。

原因:桥接让虚拟机和物理主机处在同一网段,双向可访问,代价是占用真实网段的地址,设备一多容易冲突;NAT 模式下虚拟机可以向外访问,但外部无法主动连进来,出站流量经主机做地址转换;仅主机模式两边隔离,只能和主机通信。这三种模式的差别在于“谁能主动发起连接”,并不在于“能不能上网”。

解决:需要虚拟机访问外网就用 NAT,需要局域网内其他机器访问虚拟机才用桥接,纯本地调试用仅主机。改完模式记得看虚拟机内部拿到的地址是不是变了,很多“改了没用”的情况是地址没重新获取。

5.3 在 Windows 的 IDE 里写 fork(),一编译就报错

现象:本机 IDE 里写好的代码,编译直接报找不到fork、signal、unistd.h。

原因:这些是 POSIX 接口,Windows 的 C 运行库不提供。这并非代码写错,根本原因是这些接口所在的那套系统调用根本不存在于另一个平台上。想在本地编辑再传进虚拟机,编译器就没法给出真实的错误反馈,等于放弃了调试能力。

解决:要么直接在目标环境里编辑编译,要么用远程文件同步把源码推过去、在目标环境里编译。同步工具只解决传输,不解决编译,别指望在错的平台上验证对的代码。

5.4 /proc 的数字每次读都不一样,是不是程序写错了

现象:同一个字段连读两次,值不同,怀疑解析逻辑有 bug。

原因:procfs 的内容是read时现场生成的,进程数、内存、负载每时每刻都在变。你读到的是“那一刻的快照”,不是缓存文件。

解决:需要比较就采两个时间点算差值;需要稳定输出就把关心的字段一次性读完,别分多次读再拼。另外别把/proc/stat里的累计值当瞬时值用,那里面是从开机起的累计计数,得前后相减。

5.5 printf 加了锁还是交错

现象:lockf也加了,输出还是偶尔串行。

原因:终端是行缓冲,重定向到文件是全缓冲,printf返回时内容可能还在用户态缓冲区里,锁保护的是“写系统调用”,缓冲区什么时候刷出去不由锁决定。

解决:在解锁前fflush(stdout),或者干脆用write(1, buf, len)绕开缓冲,自己拼好一整行再一次性写出去。

6. 一切皆文件的代价:procfs 这套设计的取舍在哪

把内核状态做成文件,收益是巨大的:任何语言、任何工具,只要能读文件就能观测内核,不需要专门的库,不需要协议栈,cat、awk、grep就能凑出一套监控。这是 Unix 哲学的经典落地——用统一的接口掩盖差异巨大的实现,用户态看到的是一堆文本,背后是完全不同的子系统。

代价同样明显。第一,格式是纯文本且非结构化,解析靠字符串匹配,字段顺序、空格数量、单位都可能随内核版本变化,写死的解析代码跨版本就崩。第二,每次读都要现场格式化,读得频繁会有可观的内核开销,尤其是大文件。第三,写操作语义模糊,往/proc里echo一个值,成不成功、生效范围多大,全看具体实现,缺乏统一的错误语义。

机制数据形态适合场景主要短板
procfs文本人工观测、脚本快速取值格式不稳定、解析脆弱
sysfs一文件一值设备与驱动属性层次深、不适合批量
netlink二进制消息高频事件、结构化数据需要专门库、调试成本高

所以后来才出现了 sysfs 和 netlink 这类分工:要稳定接口就上结构化通道,要人眼看就留文本。这不是谁替代谁,而是分层——底层要效率,上层要便利。

想验证这套机制到底怎么跑,最直接的办法是别只看/proc。找一份内核源码,从注册条目的那个函数开始,顺着读回调一路看到格式化输出的地方,然后对着自己机器上cat出来的内容比对字段顺序。这个过程一次就够了,之后再看任何/proc文件,脑子里自动浮现的是那一串函数调用,而不是一堆神秘的数字。

我自己的教训是:凡是涉及内核态数据的解析,一律先打印原始行再写解析逻辑,绝不先按文档假设格式。因为文档写的是某个版本的样子,你机器上跑的不一定是那个版本。从那以后我每次处理这类文本接口都强制走一遍“先看原样、再写匹配、最后加边界”,省下来的排查时间远比多写的那几行值。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询