ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

操作系统实验:从页故障到共享内存,理解Linux虚拟内存机制

操作系统实验:从页故障到共享内存,理解Linux虚拟内存机制 操作系统实验做到4.3我第一次意识到“程序不在内存里也能跑”并不是一句拿来凑字数的口号。这个实验要求做两件事一次是观测并解释进程运行过程中出现的第一次页故障另一次是用共享内存完成父子进程间的通信。页故障让我真正看清了虚拟内存、页表、按需分页这些概念是怎么在一颗CPU上闭环的共享内存则是我第一次绕过管道直接把数据写进一块两个进程都能看到的内存区域。整个过程代码量不大但背后的机制和坑一点都不少。这篇就用实测记录的方式把这次实验从原理、代码到踩坑完整拆一遍适合正在做操作系统实验、或者想补Linux内存管理基础的同学参考。1. 实验4.3到底要搞懂什么页故障与共享内存不是两个孤岛1.1 两个知识点为什么被安排在一起页故障和共享内存表面上一个在讲“进程如何拿到物理页”一个在讲“进程之间如何交换数据”但它们的核心都落在同一个数据结构上——页表。页故障解决的是“进程按需访问某个虚拟地址但页表里没有映射”的情况。CPU访问内存时通过MMU查页表发现对应页表项不存在就会触发一次异常内核在异常处理里补建映射。整个过程对用户进程透明进程只觉得自己执行慢了一拍。共享内存解决的是“两个进程怎么高效共享同一块数据”。实现方式也是操作页表把两个进程的不同虚拟地址映射到同一块物理页帧。父进程写入子进程读出来本质上是让各自的页表指向同一个物理页。所以这个实验的关键思路是页故障带你看清单进程地址空间的按需映射机制共享内存则把这种映射能力扩展到多进程之间。两件事都用页表做文章放在一个实验里顺理成章。1.2 实验环境与需要的基础我这次使用的环境是 Ubuntu 22.04内核 5.15gcc 11.4全程在普通用户下完成没有动内核源码。文章里所有依赖 /proc 文件系统的操作在其他主流 Linux 发行版上同样成立。基础要求其实不高能读懂 C 语言知道 fork() 的返回值含义理解“虚拟地址”和“物理地址”不是一回事就足够跑完整个实验。如果你还没学过页表结构我建议先把“页表项里的 Present 位”这个概念刻在脑子里后面所有内容都从这里展开。2. 第一次页故障从MMU发现映射缺失到内核完成补页2.1 页表项里的关键位和“Present 0”x86-64 下内存按 4KB 分页时地址被拆成多个部分CPU 通过页目录和页表一级一级索引最后拿到一个 64 位的页表项PTE。页表项里决定“这个页面有没有真实物理页支撑”的是第 0 位 Present。Linux 中常见页表项字段如下位名称作用0Present1 表示物理页在内存中0 表示不在1RW0 表示只读1 表示可读写2US0 表示仅内核态可访问1 表示用户态也可访问5Accessed该页是否被访问过用于页面置换算法6Dirty该页是否被写过决定回写磁盘的方式12~51PFN物理页帧号决定映射到哪一块物理内存当 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/vmstatpgfault、pgmajfault单进程缺页计数/proc/PID/statminflt第10字段、majflt第12字段进程运行结束后汇总/usr/bin/time -vMinor 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 (diff1) after bss array touch : 1162 (diff1024)三个数字都非常有信息量。进程启动时的 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, shmflgshmat把共享内存段附加到进程地址空间shmid, shmaddr, shmflgshmdt将共享内存段从进程地址空间分离shmaddrshmctl控制共享内存段包括删除shmid, cmd, bufshmget 的第一个 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也别慌不同内核和不同分配路径可能有细微差异但“共享内存按需建立页表映射”这个方向是对的。把这两个实验放在同一个窗口里跑一遍你会对“虚拟内存”这四个字有完全不一样的感觉——它不是一个抽象概念而是一张张页表项在背后替你扛着所有内存操作。
返回列表