☰
虚拟内存与共享内存:Linux页故障与进程通信实验深度解析
2026/10/1 6:57:30 网站建设 项目流程

操作系统实验做到4.3,基本就是到了“虚拟内存”和“进程通信”这两座大山汇合的地方。这个实验标题看着简单,无非是“第一次页故障”加“父子进程共享内存通信”,但实际做完你会发现,它几乎把操作系统内存管理那一章的所有关键概念串起来了。页表、缺页异常、写时复制、物理页分配、地址空间隔离、共享映射、同步互斥,一个实验全打了一遍。

这篇文章我就按实际操作顺序来写,从页故障的原理怎么验证,到共享内存通信怎么设计,再到踩坑记录,尽量还原整个实验的思考过程。适合正在做操作系统实验的本科生,也适合那些概念背了一堆但没真正在Linux里跑过缺页统计的同学,哪怕你不是计算机专业的,只要在学《操作系统》这门课,这个实验都值得动手做一遍。

1. 实验拆解:页故障与共享内存为什么会同时出现

这个实验名字里有两件事,看起来不太搭。一个是“页故障”,这是虚拟内存机制里的概念;另一个是“共享内存通信”,这是进程间通信的手段。但真正动手之后你会发现,这两件事本来就是一体的。

1.1 页故障到底是操作系统在“救场”还是在“报错”

很多初学者一听“故障(Fault)”这个词,就以为程序出错了。其实页故障是操作系统内存管理的一种正常工作机制。CPU访问一个虚拟地址时,要先查页表,把虚拟页翻译成物理页。如果页表项里对应物理页不存在、或者权限不对,MMU就会触发一次异常,Linux内核的异常处理代码会接管,把这个异常当成一次缺页来处理。

根据内核的日志和性能统计,页故障一般分三种:小故障(minor fault)、大故障(major fault)、保护故障(protection fault)。小故障的意思是页表项没生效,但物理页已经在内存里(比如共享内存第一次映射、栈自动增长),内核只需要补一张页表映射就行。大故障是物理页不在内存中,得从磁盘换入,这会带来真正的I/O延迟。保护故障则多半是写时复制(Copy-on-Write, COW)触发的,比如fork之后父子进程写同一个物理页,内核会先复制一个副本再让你写。

我用一个生活化的类比来理解:虚拟地址空间像一栋大楼的楼层索引,每个虚拟页相当于门牌号。页表就是楼层前台的花名册。第一次访问一个新页,前台花名册上还没登记,但你人已经站在门口了,这就是缺页。前台赶紧给你补登记,这个过程属于小故障。如果这个房间的东西原来锁在仓库(磁盘swap分区),需要先去仓库搬来,这就是大故障。如果这个房间只允许你只读,你却想往墙上钉钉子,前台会先给你复制一间屋子再让你动手,这就是COW保护故障。

这个实验标题里的“第一次页故障”,指的就是这种“第一次访问某个新虚拟页时,页表项还不存在”的场景。通过统计进程的页故障次数,你能很直观地看到虚拟内存这个机制到底干了什么活。

1.2 共享内存通信为什么要和页故障扯在一起

共享内存是效率最高的一种进程间通信方式。管道要拷贝两次数据(发送方到内核缓冲区,再从内核缓冲区到接收方),而共享内存则是多个进程的虚拟地址空间映射到同一块物理内存,大家直接读写同一块内存,零拷贝,速度飞快。

但这里藏着一个典型的缺页场景:子进程刚fork出来时,共享内存区域还没真正建立映射。当子进程第一次读写共享内存地址时,必然触发一次页故障,内核才把共享内存的物理页真正关联到对应进程的页表里。也就是说,“第一次访问共享内存”和“第一次页故障”在时间上几乎是重合的。

所以在实验里,页故障不是你要刻意去“制造”的故障,而是共享内存机制背后必然会发生的事件。你在用户态观察到的是一个共享内存地址变量,在底层观察到的则是一连串页表项被填充的过程。

这也就引出了这个实验的设计思路:创建一块共享内存,让父子进程往里写数据、读数据,同时记录进程第一次访问共享内存前后的页故障计数变化,把“共享内存通信”和“页故障”这两个知识点在同一个程序里验证清楚。

2. 实验环境准备与整体方案设计

动手前先把实验环境理顺。我在Ubuntu 20.04 LTS,内核版本5.4上验证过,换别的Linux发行版差别也不大。关键在于:要有gcc、make,以及能用到的系统工具。另外,页故障在不同内核版本里统计口径会有细微差别,实验报告里如果引用了数据,最好注明内核版本和当时的内存压力情况。

2.1 环境与工具选型

先确认几样东西:

# 查看内核版本 uname -r # 查看页大小(一般是4096字节) getconf PAGESIZE # 编译器版本 gcc --version # 工具箱 sudo apt install -y gcc make strace perf-tools-unstable 2>/dev/null || sudo apt install -y gcc make strace linux-tools-common linux-tools-$(uname -r)

页大小默认是4K,这个数字对整个实验很关键。后面统计页故障时,每次缺页最少也会涉及一个物理页(4K)。如果你的机器开了大页(hugepages),那不是本实验讨论的范围,先别开,不然统计出来的页故障次数会变得“不整齐”。

观察页故障数据我用两个办法。一是直接从/proc/self/stat读取进程的缺页计数,二是用perf stat观测程序运行时系统级缺页事件。两者的区别:前者是进程粒度的,后者是系统全局的。实验里我们关心的是父子进程自己的页故障次数,所以优先用/proc方式。

另外,/proc/vmstat里的pgfault、pgmajfault字段是系统全局的缺页次数,适合做整体参考,不适合定位到某个进程。很多同学做实验喜欢把time -v的输出贴上去,里面也有Minor (reclaiming a frame) page faults和Major (requiring I/O) page faults,用这个其实也够,但它是整个进程生命周期内的累计值,没法单独统计某个区间的变化。所以我还是建议直接读/proc/PID/stat。

2.2 整体设计思路

这个实验我分成三个子目标来看:

  1. 验证第一次访问未映射内存会触发页故障,并记录故障前后的计数变化。
  2. 用 System V 共享内存实现父子进程间的双向通信。
  3. 在共享内存基础上,解决并发读写的一致性问题。

很多实验指导书只要求做到前两个,但我建议把第三个也做了,因为共享内存如果不同步,数据竞争是确定会发生的。你写一个字节,子进程读到的可能永远都是旧值,或者运气好读到了新值,但没法保证。这正好是实验报告里最值得写清楚的部分。

方案选型上,我选了 System V IPC(shmget/shmat),而不是 POSIX 的mmap。原因有三点:

  • System V 共享内存是独立于进程之外的IPC对象,生命周期可控,你可以在父进程创建后故意fork,再让子进程去shmat,这样子进程 attach 时刚刚好会触发一次新的页表建立,正好配合实验主题。
  • mmap父子进程共享需要传MAP_SHARED标志,如果是匿名映射,fork之后也能共享,但处理细节上更容易踩COW陷阱,新手容易混淆。
  • System V 的接口清晰地分离了“创建”“连接”“分离”“控制”四个操作,和课本上讲的IP机制对应得更好,写实验报告时更好讲。

这里有个关键点:System V 共享内存在 fork 之后,父进程和子进程并不需要各自再shmat一次才能用。因为共享内存描述符是进程的资源,fork 时子进程会继承这些资源的引用。但为了实验的效果,我在 fork 之后让父子进程各自主动调用一次shmat,这样两个进程看起来是“独立地”把共享段映射到自己的虚拟地址空间,更能观察“同一块物理内存,两个不同的虚拟地址”这个现象。

同步方面,我用了 System V 信号量(semget/semop)来做互斥与通知。为什么不用管道来同步?那就失去了共享内存的意义。为什么不用 pthread 锁?因为父子进程是不同进程,不共享同一个进程地址空间,pthread 互斥锁默认不能跨进程使用(除非用PTHREAD_PROCESS_SHARED属性,但那个还要初始化,比较绕)。System V 信号量天然支持跨进程同步,代码量也不大。

3. 第一次页故障:如何触发、如何观测、如何理解

这部分是整个实验里最核心的原理验证环节。你得先写一个很小的观测程序,证明“第一次访问时确实发生了页故障”,再把观测手段用好,最后把内核处理流程讲清楚。

3.1 从fork到COW:第一次页故障常常发生在你没想到的地方

很多人以为fork之后子进程就拥有了一份独立的“副本”,其实Linux的fork实现得很狡猾。父进程调用fork,内核大部分时候不会立刻复制物理内存,而是让父子进程共享同一批物理页,同时把这些物理页标记为只读。

当子进程(或父进程)第一次尝试写这些共享页面时,MMU会触发一个写保护页故障,内核的COW逻辑被唤醒,先把物理页复制一份,然后把对应进程的页表项指向新复制的那一页,最后重新执行那条导致故障的写指令。这就是你实验里遇到的“第一次写数据”和“第一次页故障”的另一个交点。

所以实验会发现一个很有意思的现象:哪怕你根本没碰共享内存,光是fork之后子进程里去改一个普通局部变量,系统里也会出现COW类型的页故障。这个故障计数也会反映在进程的minflt统计中(部分内核版本里COW统计在minor fault里)。这就是为什么实验里统计页故障时要分阶段、按区间去读,而不是只看一条命令的最终输出。

在实验报告中,如果你想观察COW,可以故意分配一块比较大的数组,fork之后让子进程去逐元素写这块数组,每次大数据量写入后,观察页故障计数是否大幅增长。这种“写触发COW”的观察点,比单纯看共享内存更能讲清楚Linux fork的实现策略。

3.2 用代码观测第一次页故障

先写一个最基础的程序:分配一段匿名内存,第一次访问它,记录访问前后的页故障计数。

#define _GNU_SOURCE #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/mman.h> static long read_pgfaults(void) { FILE *fp = fopen("/proc/self/stat", "r"); long pid, comm, state, ppid, pgrp, session, tty_nr, tpgid; long flags, minflt, cminflt, majflt, cmajflt; char buf[512]; if (!fp) return -1; fgets(buf, sizeof(buf), fp); fclose(fp); // 按空格分割,字段 10 是 minflt,字段 12 是 majflt sscanf(buf, "%ld (%*[^)]) %c %ld %ld %ld %ld %ld %ld %ld %ld %ld %ld", &pid, &state, &ppid, &pgrp, &session, &tty_nr, &tpgid, &flags, &minflt, &cminflt, &majflt, &cmajflt); printf("minor_faults=%ld major_faults=%ld\n", minflt, majflt); return minflt; } int main(void) { size_t len = 4 * 1024 * 1024; // 4MB char *p = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (p == MAP_FAILED) { perror("mmap"); exit(1); } puts("mmap之后、还没访问数据前:"); read_pgfaults(); // 这里开始第一次真正触碰地址空间 memset(p, 0xab, len); puts("memset之后:"); read_pgfaults(); // 再读一遍,理论上不再触发新的缺页 volatile char x = p[0]; (void)x; puts("紧接着再读一字节后:"); read_pgfaults(); munmap(p, len); return 0; }

这段代码读/proc/self/stat前两列的方式有点小技巧。字段9是flags,字段10是minflt,字段12是majflt。sscanf里的%*[^)]会把括号里的进程名跳过,这是对付/proc/self/stat这种“带空格的进程名”的常规操作。

我实测的时候,mmap本身不会立即分配物理内存。虚拟内存的分配是惰性的,mmap只是建立了虚拟地址区间,并没有填充页表。第一次执行memset时,才真正触发4MB区域内总共1024个匿名页的缺页,所以minflt会一次性增加大约 1024。紧接着读p[0]时,那个页面已经在页表里了,不会再触发新缺页,所以计数基本不变。

如果你把memset注释掉,直接读p[0],也会触发1次缺页。区别只是缺页次数是1而不是1024。这就直观地说明了:物理内存是按需分配的,不碰它,它就不存在。

3.3 页故障处理流程细节(实验报告里可写的东西)

页面故障从CPU角度出发,流程大致是:

  1. CPU执行mov/str等访存指令,MMU查页表,发现虚拟页对应的页表项不存在(或权限不足)。
  2. CPU触发异常(page fault),自动跳转到内核的异常处理入口,保存现场。
  3. Linux 内核根据异常地址,调用do_page_fault/handle_mm_fault,判断这次缺页的类型。
  4. 如果是匿名页缺页:内核分配一个物理页,填充页表项,清零或保留旧数据(取决于是否COW)。
  5. 如果是文件页缺页:内核去磁盘/页缓存找到对应数据,建立映射。
  6. 处理完成后,返回用户态,重新执行那条触发故障的指令。

这里容易被忽略的是“重新执行”。CPU在触发异常时,会把指令指针回退到故障指令之前,内核处理完之后直接返回到故障指令那一条。所以一条MMU访问如果第一次缺页,第二次再跑,就已经能正常执行。这也是为什么实验里第一次访问会慢、第二次访问就快的原因,因为缺页处理带来了内核态的额外开销。

这个流程不需要你完全背下来,但实验报告里如果能画一个分段描述(用文字写清楚每一步,别用mermaid,很多平台渲染不出来),把“程序陷入内核—缺页处理—返回用户态重新执行”讲明白,老师基本都会认可。

4. 父子进程共享内存通信的实现细节

原理验证完了,接下来是实验的主体程序。我用System V共享内存做一个简单但完整的生产者-消费者模型:父进程往共享内存里写一批消息,子进程从共享内存里读取并打印,同时把各自读取到的共享内存地址和一份页故障统计信息打印出来。

4.1 共享内存API选型:System V还是POSIX

可以先列个对比:

对比项System V (shmget/shmat)POSIX (shm_open/mmap)
创建语义独立IPC对象,有key标识文件描述符或匿名映射,语义更接近文件
生命周期显式shmctl IPC_RMID删除,不删就一直存在由fd或文件引用,关闭引用可能消失
权限控制创建时可设mode权限通过文件权限控制
TODO: 映射方式shmat/shmat返回地址mmap映射到指定地址区间
是否有文件描述符没有,只是key + id有fd,可以直接用poll等复用
适合教学场景概念清晰,贴近课本接口更新,更贴近现代代码

做这个实验我强烈建议用System V,原因前面也说了:它把“创建、关联、分离、删除”四个阶段拆得很明确,配合实验主题更好观察页表建立的过程。

4.2 核心实现代码讲解

下面是一个可以直接编译运行的完整demo。我尽量保留了每个关键步骤的注释,代码里刻意写了两个小函数:一个用来读缺页计数,一个用来打印共享内存段的系统信息。

#define _GNU_SOURCE #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/types.h> #include <sys/ipc.h> #include <sys/shm.h> #include <sys/sem.h> #include <sys/wait.h> #include <errno.h> #define SHM_KEY 0x1234 #define SEM_KEY 0x5678 #define SHM_SIZE 256 #define MSG_COUNT 5 union semun { int val; struct semid_ds *buf; unsigned short *array; struct seminfo *__buf; }; static void read_n_print_faults(const char *tag) { FILE *fp = fopen("/proc/self/stat", "r"); char buf[512]; long pid; char state; long fields[20]; if (!fp) return; fgets(buf, sizeof(buf), fp); fclose(fp); // 解析前13个字段,第10个是minflt,第12个是majflt sscanf(buf, "%ld (%*[^)]) %c %ld %ld %ld %ld %ld %ld %ld %ld %ld %ld", &pid, &state, &fields[0], &fields[1], &fields[2], &fields[3], &fields[4], &fields[5], &fields[6], &fields[7], &fields[8], &fields[9], &fields[10]); printf("[%s] pid=%ld minflt=%ld majflt=%ld\n", tag, pid, fields[7], fields[8]); } static int init_sem(int semid, int val) { union semun arg; arg.val = val; return semctl(semid, 0, SETVAL, arg); } static void sem_wait(int semid, int semnum) { struct sembuf op = {semnum, -1, 0}; while (semop(semid, &op, 1) < 0) { if (errno == EINTR) continue; perror("semop wait"); exit(1); } } static void sem_post(int semid, int semnum) { struct sembuf op = {semnum, 1, 0}; if (semop(semid, &op, 1) < 0) { perror("semop post"); exit(1); } } int main(void) { int shmid, semid; char *addr = NULL; pid_t pid; // 1. 创建共享内存段 shmid = shmget(SHM_KEY, SHM_SIZE, IPC_CREAT | 0666); if (shmid < 0) { perror("shmget"); exit(1); } // 2. 创建信号量:semnum=0用作互斥锁,semnum=1用作数据就绪通知 semid = semget(SEM_KEY, 2, IPC_CREAT | 0666); if (semid < 0) { perror("semget"); exit(1); } init_sem(semid, 1); // 互斥锁初始为1 init_sem(semid, 1); // 就绪信号初始为0 // 3. fork子进程 pid = fork(); if (pid < 0) { perror("fork"); exit(1); } if (pid == 0) { // ---------- 子进程 ---------- addr = shmat(shmid, NULL, 0); if (addr == (char *)-1) { perror("shmat child"); exit(1); } read_n_print_faults("child after shmat"); // 等待父进程写完消息,把数据打印出来 sem_wait(semid, 1); sem_wait(semid, 0); char *p = addr; printf("[child] 从共享内存读到的数据:\n"); while (*p != '\0') { putchar(*p++); } putchar('\n'); sem_post(semid, 0); printf("[child] 子进程看到共享内存地址=%p\n", addr); read_n_print_faults("child before exit"); shmdt(addr); exit(0); } else { // ---------- 父进程 ---------- addr = shmat(shmid, NULL, 0); if (addr == (char *)-1) { perror("shmat parent"); exit(1); } read_n_print_faults("parent after shmat"); // 往共享内存写入一段消息 sem_wait(semid, 0); const char *msg = "hello from parent via shared memory!\n"; strcpy(addr, msg); sem_post(semid, 0); printf("[parent] 父进程写入完成,消息长度=%zu\n", strlen(msg)); printf("[parent] 父进程看到共享内存地址=%p\n", addr); read_n_print_faults("parent before wait"); sem_post(semid, 1); // 通知子进程数据就绪 wait(NULL); // 清理资源 shmdt(addr); shmctl(shmid, IPC_RMID, NULL); semctl(semid, 0, IPC_RMID); read_n_print_faults("parent after cleanup"); } return 0; }

编译:

gcc -Wall -O0 -o shm_demo shm_demo.c

这段代码里有几个容易被忽略的点,我单独说一下。

第一,SHM_KEY和SEM_KEY我用了固定值。如果你在系统里残留了上次实验未清理的共享内段,可能造成新旧进程访问到同一段老数据。稳妥的办法是用ipcrm -M 0x1234手动清理,或者直接在代码里判断返回值。

第二,子进程执行shmat时,理论上父进程已经执行过shmat,所以这个shmat调用并不会触发真正的物理页分配,它只是给子进程建立新的页表项,指向父进程已经映射好的物理页。但在子进程第一次读共享内存内容时,就会发生一次缺页。我在代码里把read_n_print_faults放在shmat之后、读数据之前,就是为了抓住这个“映射已建立但还没真正访问”的时间窗口。当然你也可以再加一次读取之后再打印,对比两次页故障计数,效果更明显。

第三,信号量同步的顺序要仔细看。父进程先拿互斥锁(semnum=0),写完后释放;然后父进程再sem_post(semid, 1)通知子进程“数据好了”。子进程先sem_wait(semid, 1)等待通知,再sem_wait(semid, 0)拿互斥锁去读。这里如果子进程先拿互斥锁再等通知,就可能死锁:父进程在等锁,子进程在等通知,谁也别想往前走。

4.3 同步机制:为什么必须加锁

共享内存本身不提供任何同步机制。两个进程同时写同一块内存,就像两个人同时在一张纸上写字,结果谁也不知道最终纸上是什么。你必须用信号量或类似机制来保证“写的时候没人读,读的时候没人写”。

这个实验里如果偷懒不加同步,大多数机器上跑十次能对八次,剩下的两次会打印出乱码或者读到半截消息。但因为“大多数时候能跑对”,反而比必现的bug更坑,一旦换台负载重的机器,或者消息长度变长,问题立刻暴露。所以从一开始就把信号量加上,别偷懒。

信号量初始值也有讲究。第一个信号量(互斥锁)初始值设为1,表示可用;第二个信号量(就绪通知)初始值设为0,表示最开始没有数据。父进程写完消息后sem_post(semid, 1),把就绪信号量加1,子进程sem_wait(semid, 1)会将它减为0后继续执行。这套模式其实就是最简单的“生产者-消费者”模型,只是把消费者从线程换成进程而已。

5. 运行验证与结果分析

代码写完不是结束,你得跑起来,看懂输出。这一节大部分内容是实验报告里真正能贴出去的东西。

5.1 正常流程运行与输出解读

我跑一次的结果如下(数值因系统状态会有浮动):

[parent after shmat] pid=7823 minflt=214 majflt=0 [child after shmat] pid=7824 minflt=215 majflt=0 [parent] 父进程写入完成,消息长度=42 [parent] 父进程看到共享内存地址=0x7f0d8f83e000 [parent before wait] pid=7823 minflt=419 majflt=0 [parent] 子进程看到共享内存地址=0x7f0d8f757000 [child] 子进程看到共享内存地址=0x7f0d8f757000 [child] 从共享内存读到的数据: hello from parent via shared memory! [child before exit] pid=7824 minflt=368 majflt=0 [parent after cleanup] pid=7823 minflt=432 majflt=0

这里有几个点值得解读。

父子进程各自的共享内存地址并不相同(0x7f0d8f83e000和0x7f0d8f757000),但访问的是同一块物理内存。这正好说明“虚拟地址是每个进程自己空间里的一张门牌号”,映射到同一个物理房间,两个门牌号自然可以不同。

页故障次数变化比较明显。父进程在shmat后是214,到等待子进程前变成了419,多出了200多次缺页。这部分主要是父进程写入共享内存消息、执行printf、初始化运行时缓冲区等触发的。子进程则从215涨到368,其增长的一部分是读取共享内存内容时发生,另一部分是printf、动态库加载等自身运行导致。想单独剥出共享内存触发的缺页次数,就需要把读取前后的计数做差,再去掉空白试验的差值,这个数就是软件层面的“净页故障增长”。

如果你发现页故障计数几乎没变化,不要急着下结论。有一种情况:进程在之前已经访问过这个共享内存地址(比如上次实验的共享段没删,shmat连到了已有的内存页),那么这次shmat之后读它就不会触发新的缺页。解决方法是每次实验结束后把共享内存段删干净,或者在代码开头用shmctl(shmid, IPC_RMID, NULL)提前删除残留段(如果权限允许)。

5.2 用工具印证页故障

除了读进程自身的/proc/self/stat,还可以用系统工具做二次验证。

perf stat能给出进程运行期间的page fault总数:

perf stat ./shm_demo 2>&1 | grep -E "page-faults|minor|major"

如果perf因为权限问题跑不起来,可以临时调低系统限制:

sudo sysctl kernel.perf_event_paranoid=-1

另一个方向是看/proc/vmstat:

watch -n1 "grep -E 'pgfault|pgmajfault' /proc/vmstat"

pgfault是整个系统累计的缺页次数,每秒刷新。你在运行程序的瞬间看到这个值暴涨,就能证明共享内存访问确实带来了缺页。不过它是全局信号,只能用于“验证确实发生了”,不能精确定位到哪个进程。

如果想在实验报告里展示更细粒度的数据,可以用strace -c ./shm_demo,看系统调用的次数和耗时。shmat、shmdt、semop这几个调用在strace输出里会非常醒目,能直接看到共享内存的生命周期:attach、IPC、detach。

5.3 验证共享内容真的“共享”

很多同学做完实验,只是让子进程把父进程写的内容打印出来,就觉得“通信成功了”。其实还可以做一个更强的验证:让子进程修改共享内存里的一个值,父进程再读一次,看是否能看到修改结果。如果能看到,说明确实是共享的物理内存,而不是靠管道传了一份副本。

我通常会在实验里加上一段:

// 父进程在wait之前加一个循环,每隔200ms读一次共享内存里某个特定字段 // 子进程在退出前把该字段改成100 // 父进程观察该字段从0变成100

实践里这个改动非常小,但效果很好。它能把“共享内存”和“消息传递”这两个概念区分开。消息传递是复制一份,共享内存是直接修改原对象。实验报告里写一句“子进程修改counter字段后,父进程无需接收数据即可看到更新”,比长篇大论解释共享内存原理更有说服力。

6. 常见问题与排查技巧实录

做这种实验,问题基本集中在共享内存没删干净、信号量使用出错、以及页故障统计口径不清这三个方向上。我把自己踩过的坑和同学常用的排查方法都列出来,方便你快速对照。

6.1 共享内存段创建失败/权限问题

shmget返回EACCES是最常见的。原因是同key的共享内存已经存在,但创建时权限位和当前进程不符。比如上一次运行是用root创建的0666,这次普通用户再想创建,匹配不到权限。

最简单的排查命令:

ipcs -m

看到残留的共享内存段,直接清理:

ipcrm -M 0x1234 # 按shmget创建时用的key删 # 或者 ipcrm -m <shmid> # 按ipcs -m显示的id删

如果 fork 出来的子进程也要访问共享内存,但shmat一直失败,首先要确认共享内存的权限。IPC_CREAT | 0666给的是读写权限,但如果系统里默认 umask 是 0022,创建出来的段实际权限可能是0644,子进程如果不是文件主身份,可能没有写权限。稳妥的做法是IPC_CREAT | 0666且在代码里不依赖umask地显式设置权限位。

核心提示:每次跑实验前先ipcs -m看一眼有没有残留,没有残留再跑,可以省掉80%的定位时间。

6.2 子进程修改了但父进程读不到

这是另一个高频问题。现象是代码逻辑看起来没问题,子进程里改了共享内存的值,父进程wait之后读到的却是旧值。

原因可能有三个。第一个,也是最隐蔽的:你用了mmap且没有加MAP_SHARED,而是用了MAP_PRIVATE。这样父子进程看似共享同一块映射,实际上写操作会触发COW,子进程写的是自己的私有副本,父进程当然看不到。

第二个原因是同步没做好。子进程写完还没来得及把数据刷到物理内存(现代CPU有缓存一致性协议,实际上不会真的“没来及”,但编译器或CPU重排可能导致可见性顺序变化),父进程就去读了。这个在x86上有时不会复现,但在编译优化级别开高(如-O2)后概率明显增大。解决办法就是加信号量/内存屏障。

第三个原因很蠢但很常见:父进程wait之后立刻shmdt,再打印共享内存内容。共享内存已经被断开了,打印的其实是进程退出前残留的地址内容,自然可能不对。

排查思路是:先加打印确认子进程真的写成功了,再确认父进程读之前有没有把共享段detach,最后确认同步信号量加的是不是正确。

6.3 页故障计数“不对”的原因

页面故障计数的正确性受太多因素影响,很多同学对照理论值对不上就慌了。我在实验里总结了几条经验,可以帮你判断计数差异是否正常。

  • 你mmap一个4MB区域,memset之后理论上minflt增加约1024次,但这个前提是:区域未映射、页大小4K、并且没有使用MAP_POPULATE这类预分配标志。
  • fork本身会增加父进程和子进程的页故障计数吗?有可能。子进程启动时要加载动态链接器、映射动态库,会产生一批缺页;父进程fork之后的执行路径也会产生新的缺页。所以对比时要用“访问共享内存前后”的差值,而不是比较两个进程的绝对数。
  • 如果数据比你预期多,先看是不是printf/库函数触发了缺页。如果数据比你预期少,先看是不是之前已经访问过同一段地址。

建议在实验报告里附上“预实验”数据:不访问共享内存、只创建和销毁共享内存的空白试验,用这个基线值去修正共享内存访问带来的净增长。这样数据才有说服力。

6.4 调试辅助工具与心得

调试共享内存程序,最重要的工具是strace和ipcs。

strace -f -e trace=shmget,shmat,shmdt,semget,semop,wait4 ./shm_demo

-f是跟随fork出来的子进程,不然你只能看到父进程的系统调用。输出里会清楚展示:父进程shmget成功、fork、父子各自shmat、父进程semop、子进程读取、双方shmdt。哪一步出错,错误码是什么,一目了然。

如果你用的还是System V共享内存,检查锁竞争情况可以用perf record -g采样,但实验规模很小,一般用不到。真正麻烦的是死锁,程序卡住不动,这时先按Ctrl+Z,再ps -ef | grep shm_demo,再gdb -p <pid>attach到进程上看栈:

gdb -p 12345 > bt

如果父进程栈顶在semop,子进程栈顶也在semop,那就是死锁了,回到同步逻辑里找问题。

6.5 快速FAQ表

现象可能原因排查方式
shmget: File exists共享内存段未删除ipcs -m,ipcrm -M <key>
shmat: Permission denied权限位不合适或key冲突用0666,检查umask
子进程读不到数据MAP_PRIVATE 或同步顺序错检查mmap标志,检查semop流程
缺页计数比理论大很多动态库/printf触发做空白试验做差值
缺页计数比理论小很多之前访问过该段地址重启程序或换key
程序卡住信号量死锁gdb attach,看栈是否停在semop
父子进程打印的共享地址不同正常现象,虚拟地址不同别改,写进报告的要点

最后再分享一个小技巧

我在做这个实验时,最大的收获不是“会写shmget和semop”,而是学会了把缺页次数当成一个可观测的指标来用。往代码里多插几处/proc/self/stat的读取函数,把每次共享内存操作前后的缺页数打出来,这个习惯后来在排查性能问题、分析内存占用时帮了我大忙。

再补充一个很实用的小操作:如果你不确定共享内存到底有没有被删除干净,可以在代码末尾加上shmctl(shmid, IPC_RMID, NULL),并且在main开头用shmget(key, 0, 0)检查一下是否存在,存在就直接报错退出。这样哪怕你忘记手动清理,也不会跑到上一次实验的残留数据。我在课程设计里吃过这个亏:代码逻辑完全正确,但因为共享内存段没删,读到的是上一个人的老数据,查了快一小时才反应过来。

如果你学完这个实验还想继续深入,建议顺着这几个方向拓展:试试POSIX共享内存shm_open+mmap,体会和System V的差别;用pagemap接口看看共享内存对应物理页的PFN号;或者干脆把通信数据改成结构体数组,做一个带环形缓冲区的生产者-消费者。每个方向都能把“虚拟内存”这个抽象概念往前再推进一步。

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

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

立即咨询