操作系统实验做到4.3这个阶段说明你已经不是第一次被虚拟内存、进程调度这些概念折磨了。这次的实验标题“第一次页故障 父子进程间的共享内存通信实现”第一眼看上去像是两个完全独立的任务一个在讲内存管理一个在讲进程间通信。可真正动手做下来你会发现它其实是用一次实验把虚拟内存、缺页异常、进程地址空间和IPC四条线串在了一起。我的实验是在 Ubuntu 20.04.6 LTS 上完成的内核 5.4.0gcc 9.4.0全程C语言没用任何第三方库。这篇内容适合两种人看一是操作系统课正在做类似实验的同学建议别直接抄代码抄思路和排查方法更有价值二是想真正理解 mmap 缺页和共享内存到底怎么回事的 Linux C 开发者。1. 实验到底要解决什么问题1.1 一个标题拆成两个知识点先把这个标题拆开看。“第一次页故障”讲的是虚拟内存管理的核心机制进程第一次访问某个虚拟地址时CPU 去查页表发现页表项无效于是触发缺页异常内核在异常处理里补上物理页框再让 CPU 重新执行刚才那条指令。教材里管这个过程叫按需调页Linux 内核文档里常叫 demand paging。这个过程对性能的影响非常大一次缺页的开销可能是普通内存访问的几千倍甚至上万倍这也是为什么现代操作系统宁可牺牲一点首次访问的速度也要用懒加载的方式分配物理内存。“父子进程间的共享内存通信”则属于进程间通信的范畴。父进程用 mmap 把同一块物理内存映射到自己和子进程的地址空间里两个进程读写的是同一个物理页通信成本几乎为零。这和管道、消息队列那种要经过内核缓冲区拷贝的方式完全不同属于真正的零拷贝通信。但这里有一个容易被忽略的细节共享内存对象通过 mmap 映射进进程地址空间之后物理页真的会立刻分配好吗答案是并不会。mmap 只是建立了虚拟地址到虚拟内存区域的映射关系真正第一次读写那块区域时才会触发缺页由内核把物理页补上。所以这个实验的巧妙之处就在于它让你在实现共享内存通信的过程中亲眼看到“第一次页故障”到底发生了什么。两个看似不相关的知识点其实是被同一个底层机制联系在一起的。1.2 实验环境与工具链准备我的实验环境是 Ubuntu 20.04.6 LTS内核 5.4.0gcc 9.4.0。如果你用的是更新的 Ubuntu 22.04 或 24.04内核版本高一些但实验原理完全一致。环境检查命令一般是这四个uname -a gcc --version free -h ls -ld /dev/shm最后一条命令比较关键。Linux 的 POSIX 共享内存对象默认创建在 /dev/shm 目录下它是个 tmpfs 文件系统大小默认是物理内存的一半。如果共享内存开得太大或者实验中途崩溃导致对象没被清理很容易把 /dev/shm 占满后面所有用共享内存的程序都会莫名其妙报错。这个实验我用到的调试工具主要有四个strace、gdb、perf 和 /usr/bin/time。它们各自干的活后面会细说。这里先提一句strace 是理解系统调用行为的神器比如我想知道 mmap 到底传了什么参数、内核返回了什么地址直接 strace 就能看到perf 可以用来数缺页次数精确到 minor fault 和 major fault 分开统计/usr/bin/time -v 则是最快的验证缺页次数的办法一条命令就能把进程运行期间的所有缺页信息打出来。2. 第一次页故障你真的理解缺页吗2.1 从虚拟内存区域到物理页框要理解第一次页故障得先搞清楚进程地址空间和物理内存之间到底是什么关系。你可以把进程的虚拟地址空间想象成一张城市地图mmap 或 malloc 只是在地图上圈了一块地写上“这里以后会盖楼”但实际上这块地还是空的。内核在进程内部用一个叫 VMA 的结构记录这块地的位置、大小和权限而页表里的对应条目是无效的或者干脆没有分配。当你真正去读写这块区域的第一个字节时CPU 拿着虚拟地址去查页表发现没有有效的页表项就会触发 Page Fault 异常。CPU 会保存好当前的执行现场跳转到内核的缺页异常处理入口。内核先判断这个地址是不是合法如果它落在某个 VMA 范围内就说明是正常的按需分配内核分配一页物理内存把页表项补齐然后返回用户态让 CPU 重新执行触发缺页的那条指令如果这个地址根本不在任何 VMA 范围内或者访问权限不对内核就会给进程发一个 SIGSEGV也就是段错误。这里要区分三类缺页实验报告里经常会考缺页类型触发场景代价minor fault物理页已经在内存里只是页表还没建立比如共享内存第一次访问、代码段第一次加载较低major fault需要从磁盘或 swap 换入比如第一次读文件映射很高涉及磁盘 I/Oinvalid/protection fault地址未映射或者权限不足内核直接发 SIGSEGV实验里所谓的“第一次页故障”大概率是 minor fault。为什么强调“第一次”因为同一页第二次访问时页表项已经有效了CPU 直接走 TLB 或者页表就能完成地址转换不会再陷入内核。所以缺页这件事只会发生一次后面的访问都是普通内存访问。这一点对性能影响极大也是操作系统课程里老生常谈的“局部性原理”的一个现实注脚。2.2 用一段C代码把缺页过程抓出来很多人以为观察缺页必须写内核模块其实用户态就能做。最直接的方式是用 mmap 申请一大块匿名内存然后在写入第一个字节前后分别读取 getrusage 统计的 minor fault 数量。#include stdio.h #include stdlib.h #include sys/mman.h #include sys/resource.h #include unistd.h int main(void) { struct rusage before, after; long size 1L 30; // 1GB 虚拟地址空间 char *p mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (p MAP_FAILED) { perror(mmap); exit(1); } getrusage(RUSAGE_SELF, before); p[0] A; // 第一次触碰这个页触发缺页 getrusage(RUSAGE_SELF, after); printf(before: minflt%ld majflt%ld\n, before.ru_minflt, before.ru_majflt); printf(after: minflt%ld majflt%ld\n, after.ru_minflt, after.ru_majflt); printf(delta: minflt%ld\n, after.ru_minflt - before.ru_minflt); munmap(p, size); return 0; }编译运行以后你会发现 delta minflt 至少是 1。这 1 次缺页就是因为 p[0] 这个地址对应的物理页还没有分配。如果你把循环写成 for (int i 0; i size; i 4096) p[i] 1;那 delta 基本就等于你触碰的页数因为每 4KB 就要触发一次缺页Linux 默认物理页大小就是 4096 字节。如果想进一步“抓住”第一次页故障发生的瞬间可以给这块内存设置只读权限然后注册一个 SIGSEGV 信号处理函数。当进程第一次写这块内存时本来应该触发缺页但因为页表项权限不够内核会把 SIGSEGV 发给进程。在信号处理函数里通过 siginfo_t 结构体里的 si_addr 字段你能拿到触发异常的那个虚拟地址。这种模拟方式虽然和真正的按需调页处理路径不完全一样但能让你极其直观地感受到“CPU 在访问一个还没准备好的地址时会经历一次异常处理流程”。2.3 从系统和内核视角确认缺页发生了除了 getrusage还有几个非常实用的命令和文件可以观测缺页行为第一个是 /usr/bin/time -v。注意一定要写全路径因为 shell 内置的 time 没有 -v 选项。运行之后会输出一大堆进程资源信息其中有两行Minor (reclaiming a frame) page faults: N Major (requiring I/O) page faults: NN 就是进程运行期间发生过的缺页次数。这个数字在实验报告里直接可以当作观测数据比你自己口算靠谱得多。第二个是 perf。命令是perf stat -e minor-faults,major-faults ./fault_demo输出里会明确列出 minor-faults 和 major-faults 的数量。perf 的优势是精确到事件级别适合做性能对比实验比如比较提前 memset 和没提前 memset 两种情况下共享内存首次访问的缺页次数差异。第三个是 /proc/PID/statm。这个文件记录了进程的内存占用信息第二列是 RSS也就是实际占用的物理页数。mmap 之后如果不碰那块内存RSS 不会涨一旦触发缺页RSS 就会增加。通过对比多次读取 statm 的 RSS 变化可以验证缺页到底分配了多少物理页。这里说一个生活化类比按需调页就像图书馆不会把一本书的每一页都复印好放在你桌上而是你借到书以后某一天翻开某一页管理员才临时去后台把这一页调出来给你。malloc 申请 1GB 内存时如果操作系统立刻分配 1GB 物理页那么服务器上同时跑几十个吃内存的进程物理内存瞬间就会耗尽。懒加载的本质就是用“首次访问慢一点”换取“平常运行省内存”。3. 父子进程共享内存通信从mmap到进程间同步3.1 为什么选共享内存而不是管道或消息队列做个对比你就明白了。匿名管道和命名管道数据从进程 A 写到内核缓冲区进程 B 再从内核缓冲区读走中间至少经历两次拷贝System V 消息队列类似也是数据先拷到内核消息队列再拷到接收进程。Socket 通信更重要处理协议头和连接状态编码复杂度也高。共享内存的做法是把同一块物理页同时映射到两个进程的地址空间进程 A 直接往这块地址写进程 B 直接读中间没有任何内核缓冲拷贝。从通信效率上说共享内存是最快的。代价是系统不会帮你做同步两个进程同时读写同一块内存到底谁先谁后、怎么保证数据一致性都得你自己控制。对于操作系统实验来说共享内存还有一个独特优势它直接展示了“页表映射”这个抽象概念。父子进程各自拥有一份虚拟地址空间但某些虚拟页通过页表映射到了同一个物理页这就把进程隔离和进程通信这两个看似矛盾的概念统一起来了。3.2 POSIX共享内存通信的完整实现Linux 上有两套共享内存 API一套是 System V 的 shmget/shmat/shmdt另一套是 POSIX 的 shm_open/mmap/shm_unlink。实验我推荐用 POSIX 这套一方面它更简洁和文件操作系统的概念一致另一方面它和 mmap 天然配合方便观察缺页过程。实现步骤分成五步用 shm_open 创建或打开一个共享内存对象名字必须以/开头比如/lab43_shm。用 ftruncate 设置这个对象的大小单位是字节。用 mmap 把对象映射到当前进程地址空间。注意 flags 里必须带MAP_SHARED否则父子进程各写各的。调用 fork 创建子进程。子进程会继承父进程已经建立好的映射关系虚拟地址相同指向的物理页也相同。父进程负责写入数据子进程负责读取。通信结束后用 munmap 解除映射用 shm_unlink 删除共享内存对象。完整代码我贴一个最小可运行的版本#include fcntl.h #include sys/mman.h #include sys/stat.h #include sys/wait.h #include unistd.h #include stdio.h #include string.h #include stdlib.h #define SHM_NAME /lab43_shm #define SHM_SIZE 256 int main(void) { int fd shm_open(SHM_NAME, O_CREAT | O_RDWR, 0666); if (fd 0) { perror(shm_open); exit(1); } if (ftruncate(fd, SHM_SIZE) 0) { perror(ftruncate); exit(1); } char *buf mmap(NULL, SHM_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (buf MAP_FAILED) { perror(mmap); exit(1); } pid_t pid fork(); if (pid 0) { perror(fork); exit(1); } if (pid 0) { while (buf[0] 0) usleep(1000); printf([child] read: %s\n, buf 1); munmap(buf, SHM_SIZE); close(fd); exit(0); } else { strcpy(buf 1, hello from parent); buf[0] 1; wait(NULL); munmap(buf, SHM_SIZE); close(fd); shm_unlink(SHM_NAME); } return 0; }编译命令gcc -o shm_demo shm_demo.c -Wall ./shm_demo输出是[child] read: hello from parent这段代码里有几个细节值得注意。第一个是 shm_open 的 flags 用了O_CREAT | O_RDWR意思是如果对象不存在就创建权限 0666。第二个是 ftruncate 必须在 mmap 之前调用否则映射区域长度为 0访问必然段错误。第三个是 fork 之后父进程和子进程都拿到了 buf 这个虚拟地址并且都指向同一个物理页。这就是共享内存通信的本质。3.3 同步机制没有锁的共享内存就是一个定时炸弹上面 demo 里子进程用while (buf[0] 0) usleep(1000);轮询等待父进程写入。这在课程实验里能跑但你心里要清楚它并不是一个合格的同步方案。问题出在两个方面。第一编译器可能把这个循环优化成永远只读一次 buf[0]然后就死循环。因为 C 语言标准里buf[0] 不是 volatile也没有被任何原子操作修饰编译器有权假定这块内存不会被其他人修改。第二即使编译器没优化单靠轮询也容易浪费 CPU而且无法处理“两个进程同时写”的互斥问题。正规的做法是用信号量。POSIX 有名信号量特别适合父子进程场景因为它在 fork 之前创建子进程会继承已经打开的信号量句柄。改造思路是这样sem_t *sem sem_open(/lab43_sem, O_CREAT, 0666, 0); // fork 之后 // 父进程 strcpy(buf 1, hello from parent); sem_post(sem); // 子进程 sem_wait(sem); printf([child] read: %s\n, buf 1);sem_wait 会阻塞直到信号量值大于 0sem_post 会把信号量值加 1。这样父子进程就形成了“先写后读”的顺序关系比轮询可靠得多。还有一点如果在共享内存里放的是结构体指针或者链表指针一定要记住指针是虚拟地址父子进程的虚拟地址空间大部分区域是相同布局的所以继承过来的指针在子进程里通常还能用。但如果是线程库的 pthread_mutex_t千万不要直接用于共享内存因为 pthread 互斥锁默认是针对线程的fork 之后不保证可用。要用就用进程间同步原语比如 sem_t、文件锁或者 futex。4. 把两个实验合体在共享内存场景下观察缺页4.1 为什么共享内存第一次访问也会缺页前面 2.1 讲了mmap 只是建立虚拟地址到 VMA 的映射物理页要等第一次访问才分配。这个规律对共享内存同样成立。shm_open 创建共享内存对象后ftruncate 只是把对象大小设置好mmap 只是在当前进程的页表里留下一条待补全的记录并没有真的把物理页准备好。等到 fork 之后子进程继承了父进程的页表关系但物理页依然没有分配。所以如果实验要求观察“第一次页故障”最好的场景就是让子进程去读共享内存的第一个字节并且在此之前父进程不要碰这块内存。这样子进程的第一次读必然触发一次 minor fault。这个设计把“共享内存通信”和“缺页异常”两个知识点完美地结合在了同一个实验现象上。这里有一个特别容易踩的坑如果你在父进程里执行了memset(buf, 0, SHM_SIZE)来初始化共享内存那么物理页已经在父进程里被分配好了。fork 之后子进程继承的页表项是有效的子进程再去读共享内存就不会触发缺页或者说触发的缺页计数会少很多。很多同学实验做出来 minflt delta 是 0一脸懵十有八九就是提前初始化了。4.2 合体代码统计子进程第一次读共享内存的缺页增量#include fcntl.h #include sys/mman.h #include sys/stat.h #include sys/resource.h #include sys/wait.h #include unistd.h #include stdio.h #include stdlib.h #define SHM_NAME /lab43_obs #define SHM_SIZE 4096 int main(void) { int fd shm_open(SHM_NAME, O_CREAT | O_RDWR, 0666); if (fd 0) { perror(shm_open); exit(1); } ftruncate(fd, SHM_SIZE); char *buf mmap(NULL, SHM_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (buf MAP_FAILED) { perror(mmap); exit(1); } pid_t pid fork(); if (pid 0) { perror(fork); exit(1); } if (pid 0) { struct rusage before, after; getrusage(RUSAGE_SELF, before); volatile char c buf[0]; // 第一次读共享内存 getrusage(RUSAGE_SELF, after); printf([child] first read char %d\n, c); printf([child] minflt delta %ld\n, after.ru_minflt - before.ru_minflt); exit(0); } else { usleep(50000); // 给子进程一点时间完成统计 buf[0] 42; wait(NULL); munmap(buf, SHM_SIZE); close(fd); shm_unlink(SHM_NAME); } return 0; }运行结果通常是[child] first read char 0 [child] minflt delta 1minflt delta 等于 1说明子进程访问 buf[0] 的那一瞬间内核缺页处理程序补齐了一个物理页。这个物理页正是父进程和子进程后续通信要用的那一页。如果父进程在 fork 之前先执行过buf[0] 0;或者其他写操作那子进程的 delta 很可能变成 0因为物理页已经存在缺页不再发生。你也可以用 strace 观察整个系统调用序列strace -f -e traceshm_open,ftruncate,mmap,fork,wait4 ./combined通过 trace 输出能看到 shm_open 返回的 fd、ftruncate 设置的长度、mmap 返回的虚拟地址以及 fork 之后子进程调用 getrusage 的位置。这种“从用户态到内核态再到页表分配”的完整路径是实验报告里非常有说服力的证据链。4.3 进阶玩法把共享内存开到 1MB观察每一页的缺页如果只读一个字节minflt delta 永远是 1效果并不震撼。把 SHM_SIZE 改成 1MB然后让子进程循环访问 buf[0] 到 buf[1048575]统计前后的 minflt 增量你会看到 delta 大约等于 256因为 1MB 除以 4KB 等于 256 个物理页。这个实验能直观证明缺页是按页触发的不是一次性把整个共享内存对象全部加载进来。这个现象推论到现实世界就是为什么有些程序启动时看起来“卡一下”之后运行就流畅了——启动阶段密集访问代码段和数据段每次访问新页都缺页一次等热页都摸了一遍后续就快了。5. 常见问题与排查技巧实录5.1 段错误不是玄学学会看信号与寄存器做页故障实验段错误几乎是必踩的坑。我见过最多的三种情况第一种mmap 直接返回了 MAP_FAILED但代码没检查就直接解引用 buf。mmap 失败的原因可能是共享内存大小超限也可能是 fd 非法或者 flags 冲突。解决办法永远是在 mmap 之后立刻判断返回值并且用 perror 输出错误信息。第二种访问越界。比如共享内存对象大小是 100 字节但代码里写了 buf[200] 1内核发现这个地址不在 VMA 的范围内直接发 SIGSEGV。第三种mprotect 把页设成只读但信号处理函数里没有正确修改页权限导致程序反复触发 SIGSEGV最后进程被干掉。排查工具按顺序来先用 dmesg 看内核日志比如dmesg | tail -20能看到“segfault at 0x7f... ip 0x... sp 0x...”这样的信息0x7f... 开头是用户态地址ip 是触发异常的指令地址再用 gdb 编译时加 -g运行后输入 run段错误后输入 bt能直接列出调用栈如果觉得 gdb 太重可以先 strace 看看系统调用链路确认 mmap、shm_open 这些调用有没有成功。5.2 共享内存残留与清理POSIX 共享内存对象不像普通文件一样自动删除进程退出后它会残留在 /dev/shm 目录下。如果实验反复崩溃你会看到一堆以 lab43_ 开头的文件。ls -lh /dev/shm rm -f /dev/shm/lab43_shm但如果是在代码里创建的共享内存更规范的做法是让父进程调用 shm_unlink。注意 shm_unlink 只是删除名字已经映射的内存不会立即消失要等所有进程都 munmap 之后才会真正释放。所以最佳实践是父进程在 wait 到子进程退出后先 munmap再 close最后 shm_unlink。还有一个实用技巧实验代码开头先 unlink 一次再加 O_CREAT 创建这样即使上一次实验异常退出也能避免因为对象已存在导致 ftruncate 或 mmap 行为异常。5.3 fork 继承、写时复制和 MAP_SHARED 的坑这个实验最常见的翻车点就是 MAP_SHARED 写漏了。如果 mmap 时用 MAP_PRIVATE表面上父子进程都能读写 buf但实际上子进程一旦写数据内核就开始写时复制给子进程单独复制一个物理页。父子进程的虚拟地址一样物理页却不是同一份通信自然失败。判断方式很简单让子进程往共享内存写一个特殊值然后父进程读看读到的值是不是子进程写的那个。如果读到的是旧值多半是 MAP_PRIVATE 或者两个进程各自创建了共享内存对象。还有一个容易混淆的概念fork 之后子进程继承的映射是“继承页表”不是“继承锁”。如果父进程在创建共享内存后打开了一个信号量的确会继承但如果是调用 pthread_mutex_lock 之前就持有锁而子进程在 fork 时把锁状态继承下来那子进程可能永远拿不到锁因为锁还处于被父进程持有的状态。解决方案就是前面说的进程间同步用 sem_t 或者 futex别用线程锁。5.4 观测数据与理论对不上怎么办有同学会遇到 minflt delta 不是预期值的情况。比如预期是 1结果多了几。原因往往不是共享内存本身而是子进程第一次调用 printf 或 getrusage 时自己的标准库代码段页也要缺页。这些缺页会计入子进程的 minflt。解决办法是让观测窗口更“干净”在统计 before 之前先让子进程自旋 sleep 一小段时间把库函数和页面补齐或者统计时只看和共享内存相关的那部分用两个 getrusage 之间除了读共享内存不做任何别的事把 printf 放到 after 统计之后。如果还是对不上用 /usr/bin/time -v 单独跑一个只访问共享内存一次的子进程看全局的 minor faults 数量逻辑会更清楚。6. 实验报告怎么写才有区分度6.1 报告不是代码堆砌是观测记录评分老师最不想看到的是一大段贴代码最后来一句“运行成功”。真正能拿高分的实验报告应该有清晰的观测数据表和现象解释。比如你可以建一张表列三组实验对比场景操作子进程 minflt deltaRSS 变化共享内存不提前初始化fork 后子进程直接读1增加 4KB共享内存提前 memsetfork 前父进程写满0无变化继承已分配页共享内存 1MB 全部访问子进程循环读写整块区域约 256增加 1MB这张表本身就能说明按需调页的核心结论。再配上 strace 截图或者 text 记录很有说服力。6.2 记录一次完整的验证过程写报告时建议包含这样一条验证路径先给出程序总体结构画出父子进程各自的地址空间如何通过页表指向同一物理页再用 strace 抓系统调用链展示 shm_open、ftruncate、mmap、fork 的调用顺序与返回值接着用 getrusage 或 /usr/bin/time -v 统计缺页说明第一次读共享内存触发的缺页类型和次数最后解释如果去掉 MAP_SHARED、或者提前 memset、或者不调用 shm_unlink实验现象会分别发生什么变化。能把这几个分支讲清楚说明知识点是真的通了而不是碰巧跑通。我个人在实际操作里的一个小习惯是把 strace 的输出和 perf 的结果直接贴进报告作为附录但正文只保留整理后的表格和结论。这样既显得数据扎实又不会让报告变成系统调用流水账。另外如果你做完这个实验还有余力可以把共享内存里的数据结构换成结构体数组再配合多个子进程读写观察多进程并发场景下缺页次数的变化这个进阶方向对后续学文件系统映射和数据库缓冲池都很有帮助。
阅读完成 · 觉得有帮助?