操作系统实验做到4.3,我第一次意识到“程序不在内存里也能跑”并不是一句拿来凑字数的口号。这个实验要求做两件事:一次是观测并解释进程运行过程中出现的第一次页故障,另一次是用共享内存完成父子进程间的通信。页故障让我真正看清了虚拟内存、页表、按需分页这些概念是怎么在一颗CPU上闭环的;共享内存则是我第一次绕过管道,直接把数据写进一块两个进程都能看到的内存区域。整个过程代码量不大,但背后的机制和坑一点都不少。这篇就用实测记录的方式,把这次实验从原理、代码到踩坑完整拆一遍,适合正在做操作系统实验、或者想补Linux内存管理基础的同学参考。
1. 实验4.3到底要搞懂什么:页故障与共享内存不是两个孤岛
1.1 两个知识点为什么被安排在一起
页故障和共享内存,表面上一个在讲“进程如何拿到物理页”,一个在讲“进程之间如何交换数据”,但它们的核心都落在同一个数据结构上——页表。
页故障解决的是“进程按需访问某个虚拟地址,但页表里没有映射”的情况。CPU访问内存时通过MMU查页表,发现对应页表项不存在,就会触发一次异常,内核在异常处理里补建映射。整个过程对用户进程透明,进程只觉得自己执行慢了一拍。
共享内存解决的是“两个进程怎么高效共享同一块数据”。实现方式也是操作页表:把两个进程的不同虚拟地址,映射到同一块物理页帧。父进程写入,子进程读出来,本质上是让各自的页表指向同一个物理页。
所以这个实验的关键思路是:页故障带你看清单进程地址空间的按需映射机制,共享内存则把这种映射能力扩展到多进程之间。两件事都用页表做文章,放在一个实验里顺理成章。
1.2 实验环境与需要的基础
我这次使用的环境是 Ubuntu 22.04,内核 5.15,gcc 11.4,全程在普通用户下完成,没有动内核源码。文章里所有依赖 /proc 文件系统的操作,在其他主流 Linux 发行版上同样成立。
基础要求其实不高:能读懂 C 语言,知道 fork() 的返回值含义,理解“虚拟地址”和“物理地址”不是一回事,就足够跑完整个实验。如果你还没学过页表结构,我建议先把“页表项里的 Present 位”这个概念刻在脑子里,后面所有内容都从这里展开。
2. 第一次页故障:从MMU发现映射缺失到内核完成补页
2.1 页表项里的关键位和“Present = 0”
x86-64 下内存按 4KB 分页时,地址被拆成多个部分,CPU 通过页目录和页表一级一级索引,最后拿到一个 64 位的页表项(PTE)。页表项里决定“这个页面有没有真实物理页支撑”的,是第 0 位 Present。
Linux 中常见页表项字段如下:
| 位 | 名称 | 作用 |
|---|---|---|
| 0 | Present | 1 表示物理页在内存中,0 表示不在 |
| 1 | RW | 0 表示只读,1 表示可读写 |
| 2 | US | 0 表示仅内核态可访问,1 表示用户态也可访问 |
| 5 | Accessed | 该页是否被访问过,用于页面置换算法 |
| 6 | Dirty | 该页是否被写过,决定回写磁盘的方式 |
| 12~51 | PFN | 物理页帧号,决定映射到哪一块物理内存 |
当 CPU 访问一个虚拟地址,MMU 查页表发现对应页表项 Present 为 0,或者当前访问权限不满足 RW/US 位要求,就会触发页故障。这里要注意,页故障不是一个普通函数调用,而是一次由硬件检测、通过中断描述符表跳转到内核处理程序的完整异常流程。
2.2 缺页后的处理链路:错误码、CR2与do_page_fault
一次页故障的完整链路可以拆成硬件和软件两段:
硬件侧,CPU 在发现页故障后做三件事:把当前执行现场压栈、把错误码也压栈,然后把触发异常的那个线性地址写入 CR2 寄存器,最后跳转到内核注册的页故障处理入口。错误码里每个位都有意义——第 0 位表示页面是否存在,第 1 位表示是读还是写,第 2 位表示是用户态还是内核态触发的。
软件侧,内核入口拿到错误码和 CR2 后,进入 do_page_fault 处理函数。它会根据不同的故障原因走不同分支:
- 页不存在:分配一个物理页,在 PTE 里填上页帧号,置位 Present,然后返回用户态。
- 写保护违规:可能是写时复制(COW)场景,内核复制物理页并重新映射,也可能确实是非法写,那就发给进程 SIGSEGV。
- 非法地址:比如用户态访问内核地址空间,直接判死刑,发 SIGSEGV。
处理完成后,内核通过 iret 指令回到用户态,CPU 会重新执行那条触发缺页的指令。这一次 MMU 再查页表,映射已经存在,指令正常通过。整个过程中用户进程完全无感知,它只看到自己的指令执行稍微慢了一丁点。
这里值得反复强调的是:一次页故障不是一个内核函数“解决”的,而是硬件异常、状态保存、内核处理、重新执行指令这个完整闭环。
2.3 进程生命周期里的各类“第一次缺页”
实验标题里“第一次”两个字很关键。一个程序刚被 exec 加载时,地址空间是全新的,代码段、数据段、BSS 段、堆、栈、动态链接库,几乎没有任何一个页面已经真正映射到物理内存。
程序启动后,各种“第一次访问”会接连触发页故障:
- 执行代码段第一条指令:可执行文件的对应页面需要从磁盘读入,往往是一次 major fault。
- 读取已初始化的全局变量:数据段页面被映射并加载初始值。
- 第一次写未初始化的大数组:BSS 段页面需要分配零页并映射,通常是 minor fault。
- 第一次 malloc 后写堆:malloc 本身不分配物理页,真正第一次写堆地址时才触发缺页。
- 第一次压栈:栈页也是在进程访问时才按需扩展。
所以“页故障”不是异常状态,而是虚拟内存机制下最常规的操作。实验里要求我们做的,就是把这些本来不可见的“常规操作”量化出来。
3. 亲手触发并观测页故障:用minflt数据说话
3.1 三个层级的观测手段
观测页故障不需要写内核模块,Linux 在 /proc 和外部工具里已经留下了足够多的统计数据:
| 观测对象 | 文件/工具 | 字段 |
|---|---|---|
| 全局缺页计数 | /proc/vmstat | pgfault、pgmajfault |
| 单进程缺页计数 | /proc/PID/stat | minflt(第10字段)、majflt(第12字段) |
| 进程运行结束后汇总 | /usr/bin/time -v | Minor page faults、Major page faults |
其中 minflt 是 minor fault,指页面不在进程页表里,但物理页很容易拿到(比如分配零页);majflt 是 major fault,指页面需要从磁盘读入,有真正的 I/O 等待。实验里我们重点关注 minflt。
3.2 写一个能“看见”minflt的C程序
先写一个解析 /proc/self/stat 的小函数。直接用空格分隔字段会出错,因为 stat 第 2 个字段是进程名,被括号包着,而进程名里可以包含空格。标准做法是找到最后一个右括号,再从它后面开始按字段计数。
#include <stdio.h> #include <stdlib.h> #include <string.h> long read_minflt(void) { FILE *fp = fopen("/proc/self/stat", "r"); if (!fp) { perror("fopen"); return -1; } char buf[1024]; size_t n = fread(buf, 1, sizeof(buf) - 1, fp); fclose(fp); if (n <= 0) { return -1; } buf[n] = '\0'; char *rp = strrchr(buf, ')'); if (!rp) { return -1; } char *p = rp + 1; char *saveptr = NULL; char *tok = strtok_r(p, " \t", &saveptr); // 此时是 state,即第3字段 if (!tok) { return -1; } // 继续取第4到第9字段:ppid, pgrp, session, tty_nr, tpgid, flags for (int i = 0; i < 6; i++) { tok = strtok_r(NULL, " \t", &saveptr); if (!tok) { return -1; } } // 下一个字段就是第10字段 minflt tok = strtok_r(NULL, " \t", &saveptr); if (!tok) { return -1; } return atol(tok); } int global_var = 42; // 位于 .data 段 char big_array[4 * 1024 * 1024]; // 位于 .bss 段,4MB volatile int sink; int main(void) { // 提前使用一次栈和全局变量,避免栈页缺页混进统计 sink = 0; long m1 = read_minflt(); sink = global_var; // 第一次访问 .data 段 long m2 = read_minflt(); for (size_t i = 0; i < sizeof(big_array); i++) { big_array[i] = 0; // 第一次触碰 .bss 段 } long m3 = read_minflt(); printf("baseline minflt : %ld\n", m1); printf("after data access : %ld (diff=%ld)\n", m2, m2 - m1); printf("after bss array touch : %ld (diff=%ld)\n", m3, m3 - m2); return 0; }编译时不要加优化,否则编译器可能把循环优化掉,导致观测目标消失:
gcc -O0 -Wall -o pf_demo pf_demo.c ./pf_demo3.3 运行结果怎么解读
我测试机上的输出是:
baseline minflt : 137 after data access : 138 (diff=1) after bss array touch : 1162 (diff=1024)三个数字都非常有信息量。
进程启动时的 baseline 是 137,这一百多次 minor fault 主要来自动态链接器、libc 等共享库首次映射,以及可执行文件本身的代码段和数据段补页。它说明一个结论:即使是最简单的 C 程序,从 exec 到 main 执行完,中间已经发生了上百次缺页。
访问 global_var 后 diff 是 1,因为全局变量 data 段所在页是第一次被读取,内核分配物理页并建立映射,恰好触发一次 minor fault。
访问 4MB 数组后 diff 是 1024,正好等于 4MB 除以 4KB 页大小。这说明 BSS 数组的每一页都是按需分配的,写每一页的第一个字节时触发一次缺页,内核填 PT E 映射后,后续写同一页的其余字节不再触发。
这个结果直观地回答了“按需分页到底节省了什么”:程序申请了 4MB 的 BSS 空间,但如果它从头到尾只碰了一页,那物理内存就只需要给它一页。
统计点之间尽量不放 printf 等 I/O 操作,因为 printf 本身会引入库代码缺页。如果实测 diff 比预期多几个数字,通常是 read_minflt 自身使用的库代码首次加载造成的,不影响定性结论。
4. 父子进程共享内存通信:shmget到shmctl落地
4.1 共享内存为什么是零拷贝通信
之前实验用过管道和消息队列,它们都是“发送方数据先拷贝到内核缓冲区,接收方再从内核缓冲区拷贝到用户空间”的路线。共享内存则完全绕开了内核拷贝:内核把同一块物理页同时映射到两个进程的虚拟地址空间,发送方写自己的地址,接收方在自己的地址里直接就能看到。
所以共享内存常被说是“最快的 IPC 方式”。代价是它不提供任何同步机制。管道有内核帮你串行化,共享内存没有,读写双方必须自己协调顺序,否则就是典型的竞态条件。
4.2 System V共享内存API的参数与用法
实验里我用的是 System V 版共享内存,共四个函数:
| 函数 | 作用 | 关键参数 |
|---|---|---|
| shmget | 创建或获取共享内存段 | key, size, shmflg |
| shmat | 把共享内存段附加到进程地址空间 | shmid, shmaddr, shmflg |
| shmdt | 将共享内存段从进程地址空间分离 | shmaddr |
| shmctl | 控制共享内存段,包括删除 | shmid, cmd, buf |
shmget 的第一个 key 是标识符,第二个 size 是段大小,第三个 shmflg 通常写 IPC_CREAT | 0666,表示不存在就创建,权限是 rw-rw-rw-。它返回的是一个整数 shmid,不是地址。
shmat 把 shmid 对应的物理段挂到当前进程的虚拟地址空间,返回的是可用的虚拟地址指针。shmaddr 传 NULL 时由内核选择合适地址,这最省心。返回 (void *)-1 表示失败,不能只判 NULL。
shmdt 只解除映射,不删除段。删除段要靠 shmctl(shmid, IPC_RMID, NULL)。
4.3 完整示例代码与运行结果
写成父子进程协作:子进程往共享内存写入字符串,父进程等子进程结束后读取并打印。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/ipc.h> #include <sys/shm.h> #include <sys/types.h> #include <sys/wait.h> #define SHM_SIZE 128 int main(void) { int shmid = shmget(IPC_PRIVATE, SHM_SIZE, IPC_CREAT | 0666); if (shmid < 0) { perror("shmget failed"); exit(EXIT_FAILURE); } printf("shmid = %d\n", shmid); pid_t pid = fork(); if (pid < 0) { perror("fork failed"); exit(EXIT_FAILURE); } if (pid == 0) { // 子进程:附加共享内存,写入数据 char *msg = (char *)shmat(shmid, NULL, 0); if (msg == (char *)-1) { perror("child shmat failed"); exit(EXIT_FAILURE); } snprintf(msg, SHM_SIZE, "hello from child (pid %d)", getpid()); shmdt(msg); exit(EXIT_SUCCESS); } else { // 父进程:等待子进程写入完成后附加并读取 wait(NULL); char *msg = (char *)shmat(shmid, NULL, 0); if (msg == (char *)-1) { perror("parent shmat failed"); exit(EXIT_FAILURE); } printf("parent read: %s\n", msg); shmdt(msg); // 删除共享内存段 shmctl(shmid, IPC_RMID, NULL); } return 0; }编译运行:
gcc -Wall -o shm_demo shm_demo.c ./shm_demo我这里的输出是:
shmid = 196610 parent read: hello from child (pid 18472)注意 shmid 每次运行都不一样,这是内核维护的共享内存段标识符,不是固定的。代码里最关键的一行是父进程的 wait(NULL),它保证了子进程写完、分离之后,父进程才附加并读取,避免了读到一个空串。
4.4 fork和shmat的先后顺序怎么选
共享内存的使用时机有两条路线:
第一种是 fork 之前先 shmat,子进程会直接继承父进程已经映射好的地址,父子进程拿到同一个虚拟地址,代码最简洁。适合共享段在创建后马上 fork 的场景。
第二种是 fork 之后父子各自 shmat,像我上面的示例。好处是逻辑更清晰,也方便演示“同一个 shmid 在两个进程中分别映射”的过程。
两者都能完成通信。实验报告里我建议两种都写一遍,然后对比打印出来的地址。你会发现两个进程里 shmat 返回的虚拟地址往往一样,但两个进程的虚拟地址空间是独立的,地址相同不代表它们共享虚拟地址空间,共享的是背后的物理页面。
5. 实验里最容易翻车的三个地方
5.1 父进程不等待,读到的是旧值
共享内存第一次实验最常见的翻车现场:子进程写入,父进程立刻去读,结果读到一截空数据或旧数据。这不是代码写错了,而是共享内存没有同步机制,父子进程并发执行,父进程可能抢在子进程写入之前读取。
现象一般是:
parent read:空串。解决方式取决于场景。如果子进程写完就结束,用 wait() 等待是最简单的。如果两边都要持续读写,就得引入信号量,或者用 pthread 的互斥量配合共享内存。
从这次实验的角度看,用 wait() 同步是合理的,因为父子进程存在天然依赖关系。但这个“合理”要写进实验报告,说明你知道共享内存本身不保证顺序。
5.2 共享内存段被遗忘,ipcs里留下一堆残留
共享内存不像 malloc,进程退出后不会自动回收。如果在程序结尾漏掉 shmctl(shmid, IPC_RMID, NULL),内核里就会残留一个共享内存段。跑几次实验就积累好几个。
查看残留:
ipcs -m手动清理:
ipcrm -m 196610这里有个容易误会的细节:IPC_RMID 是“标记删除”,不是立刻释放。如果还有进程 attach 在这个段上,内核会等所有进程 detach 之后才真正销毁。所以不用担心删了共享内存导致正在使用的进程崩溃,它只会让新进程无法再获得这个段。
我的习惯是:实验代码里凡是创建了共享内存,无论分支里发生什么,父进程收尾时一定删。如果程序中途出 bug 退出,就在终端里用 ipcs 排查、ipcrm 清理。
5.3 选错key、类型不匹配:IPC_PRIVATE与ftok的取舍
shmget 的第一个参数 key 有三种常见写法:IPC_PRIVATE、ftok() 生成的 key、以及自己写死的常量数字。
IPC_PRIVATE 虽然是“私有”的名字,实际含义却是“让内核分配一个全新的共享内存段”。只要 shmid 被传出去,任何进程都可以用它 attach。它最适合父进程创建段后 fork,子进程从 fork 的继承关系中直接拿到 shmid 的场景。好处是实现简单,也避免其他进程猜到你用的固定 key。
如果两个没有亲缘关系的进程要共享内存,就需要它们用同一个 key。推荐 ftok():
key_t key = ftok("/tmp/shmfile", 66); int shmid = shmget(key, SHM_SIZE, IPC_CREAT | 0666);ftok 根据指定文件的 inode 和一个项目 ID 生成 key。但这里有一个坑:那个路径对应的文件必须真实存在,否则 ftok 返回 -1。所以要先 touch 一个参考文件。
共享内存里存数据时,类型使用也有讲究。如果只存字符串,用 snprintf 并控制长度就够了。如果要存结构体,建议定义固定长度字段,不要存指针——不同进程里的虚拟地址虽然可能相同,但依赖这一点写代码很危险。跨平台时还需要注意字节序和结构体对齐,不过实验层面先做到固定长度字符串就足够了。
6. 用“页表”视角把两个实验收束起来
6.1 页故障和共享内存本质上是同一套机制的两面
页故障讲的是“页表映射缺失时,内核如何补建”;共享内存讲的是“多个进程的页表如何指向同一物理页”。一个负责按需建立,一个负责跨进程复用,底层操作都是修改页表项、刷新 TLB。
把这次实验放在一起看,我可以总结这么一条主线:Linux 里进程拿到的每一个地址都是虚拟地址,真正物理页的分配和映射是懒散的,用到才建。页故障处理是这个懒散机制的触发点,共享内存则是这个机制下最高效的跨进程协作手段。
理解到这一层,再回头看“程序不在内存里也能跑”,就会明白它说的是:程序在磁盘上,运行时只把用到的页面映射进物理内存,用不到的留在文件里,物理内存不足时还可以换出。整个系统的内存压力,都被页表这层间接映射化解掉了。
6.2 一个值得自己动手验证的延伸:共享内存的首次访问其实也缺页
做完上面两个实验,可以再做一个小延伸实验,把两个知识点真正连起来:在父进程 shmat 之后、第一次真正读取共享内存之前,记录一次 minflt,读完后再记录一次。
我预期的现象是:父进程第一次访问共享内存地址时,diff 接近 1,也就是触发一次 minor fault。原因在于 shmat 只是创建了虚拟地址区域的相关映射信息,并没有立刻填充页表项。父进程第一次访问共享内存地址时,页表里其实还没有该页的映射,需要内核走一次缺页流程,把页表项补上,指向子进程写好的那一块物理页。
这个实验可以用代码里现成的 read_minflt 移到共享内存程序里验证。如果测出来 diff 不是 1 而是 0,也别慌,不同内核和不同分配路径可能有细微差异,但“共享内存按需建立页表映射”这个方向是对的。
把这两个实验放在同一个窗口里跑一遍,你会对“虚拟内存”这四个字有完全不一样的感觉——它不是一个抽象概念,而是一张张页表项在背后替你扛着所有内存操作。