云计算百科
云计算领域专业知识百科平台

Linux系统篇25——通信(三):System V 共享内存:从页表映射到 shmget/shmat 一文搞懂

这一篇讲透 Linux 下 System V 共享内存:它为什么是"最快的 IPC"、底层凭什么能"让两个进程看到同一块内存"、以及 ftok / shmget / shmat / shmdt / shmctl 这套接口到底怎么用、怎么删、怎么保证两个进程挂到同一块上。代码沿用课件里的 comm.h / comm.c / server.c / client.c 多文件结构,方便你直接对照课程。


目录

目录

​编辑

目录

一、一个问题:管道传大块数据,够快吗?

二、一句话结论:什么是 System V 共享内存

三、System V 是什么:标准还是 Linux 专属?

四、底层原理:两块虚拟地址,如何指向同一块物理内存

4.1 先回忆:进程之间是"隔离"的

4.2 共享内存的"魔法":让页表指向同一块物理内存

4.3 为什么快?"不经过内核"指的是什么

五、内核怎么管理共享内存:shmid_ds 结构

六、五个接口逐个拆:ftok / shmget / shmat / shmdt / shmctl

6.1 ftok —— 在用户层构造一个 key

6.2 shmget —— 创建或获取共享内存

6.3 shmat —— 挂接到进程地址空间

6.4 shmdt —— 与当前进程"去关联"

6.5 shmctl —— 控制 / 删除共享内存

七、IPC_CREAT 与 IPC_EXCL:到底几个意思

八、key 的本质:用户空间"约定"出来的名字

九、多文件版完整 demo

comm.h

comm.c

server.c —— 创建者,循环读出 client 写的内容

client.c —— 参与者,往共享内存里写 26 个字母

运行与排坑

十、共享内存的同步问题

十一、与前面文章的联系 / 面试常考串联

11.1 对比《通信(一):什么是进程间通信?》

11.2 对比《通信(二):匿名管道 vs 命名管道》

11.3 对比《进程(四):虚拟地址空间 / 页表 / 进程隔离》

十二、面试官追问

十三、小结



一、一个问题:管道传大块数据,够快吗?

在 《Linux系统篇24——通信(二):匿名管道 vs 命名管道》 里我们把"管道"讲透了:内核缓冲区 + 两个 fd,先进先出。但它有个绕不开的代价——

每一次通信都要"用户态 → 内核态 → 用户态"拷贝两次。

进程 A 把数据从自己的用户空间 write 进内核缓冲区,进程 B 再从内核缓冲区 read 到自己的用户空间。数据在用户态和内核态之间兜了一圈。传几 KB 没感觉,传几百 MB 的大块数据,这个拷贝开销就非常明显。

于是有了 IPC 家族里速度天花板——共享内存(Shared Memory)。它不走内核中转,而是让多个进程直接读写同一块物理内存。下面把它拆开。


二、一句话结论:什么是 System V 共享内存

共享内存 = 同一块物理内存,被映射到多个进程的虚拟地址空间;进程之间不再经过内核拷贝即可交换数据。但它的代价是"不带同步互斥",必须自己配信号量/管道来控制访问顺序。

三、System V 是什么:标准还是 Linux 专属?

System V 是一套标准,Linux 内核支持并实现了一套 IPC 通信模块。

展开说:

  • System V(System Five)是早期的 Unix 标准之一,它定义了进程间通信的一组接口规范。
  • Linux 内核实现了这套规范里的三种 IPC:消息队列、共享内存、信号量。所以你会看到它们都被命名为 msg* / shm* / sem*,API 形态高度一致(都要先用 key 去"找/建",再用一个 id 去"操作")。
  • 它和 POSIX IPC(同样是消息队列、共享内存、信号量,但接口命名是 mq_* / shm_open 那种)是并列的两套体系。大厂面试常问两者区别,这里先记住:我们这篇讲的是 System V 这套。

四、底层原理:两块虚拟地址,如何指向同一块物理内存

4.1 先回忆:进程之间是"隔离"的

在 《Linux系统篇(十五)——进程(四):一文吃透 Linux 虚拟地址空间》 里我们建立了一个关键认知:

  • 每个进程有独立的虚拟地址空间;
  • CPU 访问变量时,靠 MMU + 页表 把虚拟地址翻译成物理地址;
  • 页表里存的是"虚拟页号 → 物理页号"。

所以两个进程的 0x1234 虽然虚拟地址相同,但页表不同,最终落到两块不同的物理内存。这正是进程隔离的基石,也是"为什么全局变量在进程间是假共享"的根本原因。

4.2 共享内存的"魔法":让页表指向同一块物理内存

共享内存干的事,恰恰是把这套规则反过来用——

4.3 为什么快?"不经过内核"指的是什么

管道每次传递数据都要陷入内核("进入内核的系统调用来传递彼此的数据")。共享内存之所以快,是因为数据一旦映射好了,进程后续读写就只是读写自己的虚拟地址,不需要再调用 read/write 让内核搬运——内核只在"建立映射"那一刻介入,之后就是两个进程对一块内存的直接访问。这就是课件说的"数据传递不再涉及到内核"。

但同时要记住一句硬话:"共享内存没有进行同步与互斥!共享内存缺乏访问控制!会带来并发问题。" 所以它快,但"不安全",后面第十节点一下怎么补。

五、内核怎么管理共享内存:shmid_ds 结构

既然"多个进程可能使用不同的共享内存",内核必须有一套结构体来描述和组织每一个共享内存对象。

struct shmid_ds {
struct ipc_perm shm_perm; /* 操作权限 */
int shm_segsz; /* 共享内存段大小 (bytes) */
__kernel_time_t shm_atime; /* 最后 attach 时间 */
__kernel_time_t shm_dtime; /* 最后 detach 时间 */
__kernel_time_t shm_ctime; /* 最后改动时间 */
__kernel_ipc_pid_t shm_cpid; /* 创建者 pid */
__kernel_ipc_pid_t shm_lpid; /* 最后操作者 pid */
unsigned short shm_nattch; /* 当前挂接数 (attach 的进程数) */
unsigned short shm_unused; /* 兼容性字段 */
void *shm_unused2; /* 旧版 DIPC 用 */
void *shm_unused3; /* 未使用 */
};

几个面试常问的成员:

  • shm_perm:权限,和文件权限类似,由 shmget 的 shmflg 里的 0666 这种位决定。
  • shm_segsz:这段共享内存多大。
  • shm_nattch:当前有几个进程挂接在上面。

六、五个接口逐个拆:ftok / shmget / shmat / shmdt / shmctl

6.1 ftok —— 在用户层构造一个 key

#include <sys/ipc.h>
key_t ftok(const char *pathname, int proj_id);

  • 作用:把"一个真实存在的路径 + 一个项目 ID"映射成一个 key_t 整数。
  • 关键点(图片里强调了):key 是由用户空间生成的,再传给 OS;它不是内核直接分配给你的。两个进程只要传相同的 pathname 和 proj_id,就能算出同一个 key,进而找到同一块共享内存。
  • 坑:pathname 必须真实存在,它只是被拿来"取某些位"参与运算,路径指向的文件内容和大小都无所谓。

6.2 shmget —— 创建或获取共享内存

#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);

参数含义(来自课件)
key 这个共享内存段的"名字",由 ftok 生成
size 共享内存大小(建议是页大小 4096 的整数倍)
shmflg 由九个权限标志构成,用法和创建文件时的 mode 模式标志一样(如 0666)

返回值:成功返回该共享内存段的标识码(非负整数,叫 shmid);失败返回 -1。

shmflg 最重要的两个位:

  • IPC_CREAT:共享内存不存在 → 创建并返回;已存在 → 获取并返回。
  • IPC_CREAT | IPC_EXCL:共享内存不存在 → 创建并返回;已存在 → 出错返回 -1(用来确保"我是创建者")。

6.3 shmat —— 挂接到进程地址空间

void *shmat(int shmid, const void *shmaddr, int shmflg);

  • 作用:把标识为 shmid 的共享内存,映射到当前进程的虚拟地址空间。
  • 参数:
    • shmid:shmget 返回的标识码;
    • shmaddr:指定连接地址,通常传 NULL 让内核自动选一个地址;
    • shmflg:常用 SHM_RND(地址向下对齐)、SHM_RDONLY(只读)。
  • 返回值:成功返回指向共享内存第一个字节的指针;失败返回 (void*)-1。

6.4 shmdt —— 与当前进程"去关联"

int shmdt(const void *shmaddr);

  • 作用:把共享内存段与当前进程脱离(解除映射)。
  • 参数:shmaddr 就是 shmat 返回的指针。
  • 返回值:成功 0;失败 -1。
  • ⚠️ 关键坑:"将共享内存段与当前进程脱离不等于删除共享内存段。" 它只是让当前进程不再能访问这段地址,物理内存和内核对象还在。

6.5 shmctl —— 控制 / 删除共享内存

int shmctl(int shmid, int cmd, struct shmid_ds *buf);

  • 作用:用于控制共享内存(查状态、设权限、删除)。
  • cmd 的三个主要取值:
    • IPC_RMID:标记删除。即便还有进程挂着,也照样删(内核会等最后一个进程 detach 后真正释放)。
    • IPC_STAT:把当前状态拷进 buf。
    • IPC_SET:用 buf 更新权限。
  • 💡 生命周期提醒:System V 的 IPC 资源生命周期随内核——进程退出不会自动删,除非重启或手动 shmctl(IPC_RMID) / ipcrm -m <shmid>。所以"谁来删"必须是进程该做的事,别指望系统替你收尾

七、IPC_CREAT 与 IPC_EXCL:到底几个意思

组合共享内存不存在时共享内存已存在时典型使用者
IPC_CREAT 创建并返回 获取并返回 参与者(client)
IPC_CREAT IPC_EXCL` 创建并返回 失败返回 -1
0(只用权限位或 0) 失败返回 -1 获取并返回 参与者另一种写法

为什么发起者要用 IPC_CREAT | IPC_EXCL? 它等价于"我要一个全新的、没人用过的共享内存",如果拿到的是别人已经建好的,说明 key 冲突或资源残留在内核里——直接报错更稳妥,避免偷偷挂到一个不对的内存上。 去获取。

八、key 的本质:用户空间"约定"出来的名字

key 由用户空间生成,传给 OS。很多人第一次用共享内存会懵:两个进程凭什么保证挂到同一块内存?

答案就在 key:

  • shmget(key, …) 在内核里是用 key 作为查找主键的——同一个 key 找到的就是同一块共享内存对象。
  • 而 key 来自 ftok(pathname, proj_id),它本质是"路径 + 项目ID"的哈希值。
  • 所以双方只要约定好同一个路径、同一个 proj_id,各自 ftok 出来的 key 就相同,自然能找到同一块内存。
  • 这跟命名管道"认名字"是一个思想:靠一个双方都认可的名字来接头。区别在于,命名管道的名字是文件系统里的 FIFO 文件,共享内存的"名字"是内存里的一个整数 key。

    顺带区分两个容易混的 ID:

    • key:跨进程"约定"出来的名字,用户态决定;
    • shmid:内核返回给你的"本地句柄",只在你这个进程上下文里用,用来调用 shmat/shmdt/shmctl。

    问题:System‑V共享内存为什么key需要用户生成?

    shmid是进程私有句柄,别的进程拿不到。key相当于全局暗号,用来让多个独立进程找到同一块共享内存,内核无法自动生成并通知其他进程该标识,所以要用户约定生成;如果传入IPC_PRIVATE由内核自动生成key,则仅fork的父子进程可以使用,不适合无关进程通信。

    九、多文件版完整 demo

    comm.h

    #ifndef _COMM_H_
    #define _COMM_H_

    #include <stdio.h>
    #include <sys/types.h>
    #include <sys/ipc.h>
    #include <sys/shm.h>

    #define PATHNAME "." // ftok 用到的真实路径,双方必须一致
    #define PROJ_ID 0x6666 // 项目 ID,双方必须一致

    int createShm(int size); // 创建者:IPC_CREAT|IPC_EXCL
    int destroyShm(int shmid);// 删除
    int getShm(int size); // 参与者:IPC_CREAT

    #endif

    comm.c

    #include "comm.h"

    static int commShm(int size, int flags)
    {
    key_t key = ftok(PATHNAME, PROJ_ID); // ① 用户态生成 key
    if (key < 0) {
    perror("ftok");
    return -1;
    }
    int shmid = 0;
    if ((shmid = shmget(key, size, flags)) < 0) { // ② 用 key 创建/获取
    perror("shmget");
    return -2;
    }
    return shmid;
    }

    int destroyShm(int shmid)
    {
    if (shmctl(shmid, IPC_RMID, NULL) < 0) { // ⑤ 标记删除
    perror("shmctl");
    return -1;
    }
    return 0;
    }

    int createShm(int size)
    {
    return commShm(size, IPC_CREAT | IPC_EXCL | 0666); // 发起者:必须全新
    }

    int getShm(int size)
    {
    return commShm(size, IPC_CREAT); // 参与者:获取即可
    }

    server.c —— 创建者,循环读出 client 写的内容

    #include "comm.h"

    int main()
    {
    int shmid = createShm(4096); // ① 创建全新共享内存
    char *addr = shmat(shmid, NULL, 0); // ③ 挂接到自己地址空间
    sleep(2);

    int i = 0;
    while (i++ < 26) {
    printf("client# %s\\n", addr); // ④ 直接读,不经过内核
    sleep(1);
    }

    shmdt(addr); // ⑥ 去关联(不是删除)
    sleep(2);
    destroyShm(shmid); // ⑦ 真正删除
    return 0;
    }

    client.c —— 参与者,往共享内存里写 26 个字母

    #include "comm.h"

    int main()
    {
    int shmid = getShm(4096); // ① 获取已存在的共享内存
    sleep(1);
    char *addr = shmat(shmid, NULL, 0); // ③ 挂接

    sleep(2);
    int i = 0;
    while (i < 26) {
    addr[i] = 'A' + i; // ④ 直接写,不经过内核
    i++;
    addr[i] = 0; // 末尾置 0,方便 printf
    sleep(1);
    }

    shmdt(addr); // ⑥ 去关联
    sleep(2);
    return 0;
    }

    运行与排坑

    先跑 ./server,它会 IPC_CREAT|IPC_EXCL 建一块新的;此时若你 Ctrl+C 杀掉 server,共享内存不会被删除(生命周期随内核),重跑就会报错:

    # ./server
    shmget: File exists
    # ipcs -m # 查看残留的共享内存
    —— Shared Memory Segments ——–
    key shmid owner perms bytes nattch status
    0x66026a25 688145 root 666 4096 0
    # ipcrm -m 688145 # 手动删掉残留(演示用,正常应由进程删)

    删 IPC 资源是"进程该做的事",这里 ipcrm 只为演示命令,正常场景千万别依赖手动清理。

    十、共享内存的同步问题

    共享内存没有进行同步与互斥!共享内存缺乏访问控制!会带来并发问题。

    也就是说,server 读的时候,client 可能还没写完——读到的就是半成品。怎么补?两条路:

  • 配信号量(Semaphore):最正统的"共享内存 + 信号量"组合,下一篇会展开。
  • 借一根管道做"通知":课件实例2 给了思路——用命名管道充当"轮次信号",client 写完发一个字节唤醒 server。本质是用管道的阻塞特性,给共享内存加一层简单的顺序控制(课件也称它"仅仅是让同学们理解顺序性",有 bug 仅作演示)。
  • 记住主线结论就够了:共享内存管"数据",同步管"秩序",两者一定要搭配使用。 细节留到下一篇信号量。

    十一、与前面文章的联系 / 面试常考串联

    11.1 对比《通信(一):什么是进程间通信?》

    那篇把 IPC 全家福摆过一遍,其中对共享内存的定位是:"把同一块物理内存映射到多个进程,大家直接读写,零拷贝、性能最高。但它不负责同步,必须配信号量使用。" 本篇就是把那一句"零拷贝"背后的页表映射原理和API 实战补齐。

    11.2 对比《通信(二):匿名管道 vs 命名管道》

    维度管道(那篇讲的)共享内存(本篇)
    数据路径 用户 → 内核缓冲 → 用户(两次拷贝) 进程直接读写物理内存(零拷贝)
    边界/同步 自带 FIFO、阻塞、EOF 无边界、无同步,要自己配
    生命周期 匿名随进程、命名随文件 随内核,必须手动删

    一句话:管道是"内核帮你搬",共享内存是"大家直接改同一块",所以共享内存快,但麻烦也多。

    11.3 对比《进程(四):虚拟地址空间 / 页表 / 进程隔离》

    那篇的核心是"每个进程一套页表,虚拟地址互不干扰"。本篇恰恰是它的反向应用:

    • 普通变量:父子进程靠 COW(写时复制)各自一份物理页;
    • 共享内存:内核特意让两个进程的页表项指向同一片物理页,于是读写互相可见。

    这个结论把"进程隔离"和"进程共享"统一在了同一个页表机制下——面试被问"共享内存为什么能让两个进程看到同一份数据",答"页表指向同一物理页"就点到根上了。

    十二、面试官追问

    追问答案
    共享内存为什么最快? 映射建立后,进程读写只是访问自己的虚拟地址,不再调用 read/write 让内核搬运;数据传递不经过内核拷贝。
    shmdt 和 shmctl(IPC_RMID) 区别? shmdt 只是"当前进程"解除映射,内核对象还在;shmctl(IPC_RMID) 才是标记删除(等所有进程 detach 后真正释放物理内存)。
    两个进程怎么保证挂到同一块? 用同一个 key,而 key 来自 ftok 的"相同 pathname + 相同 proj_id"。内核按 key 查找共享内存对象。
    IPC_EXCL 什么场景用? 保证本进程新建 IPC 对象,对象已存在则报错,避免重复创建
    生命周期随内核是什么意思? 进程退出不会自动删,除非重启或手动 shmctl(IPC_RMID)/ipcrm;不删会内核资源泄漏。
    共享内存没有同步怎么办? 搭配信号量做互斥/秩序,或用管道做轻量通知;实际工程中两者组合最常见。

    十三、小结

    用三句话收尾:

  • 原理:共享内存让多个进程的页表指向同一块物理内存,读写零拷贝、最快;
  • 接口:ftok 建 key → shmget 建/取 → shmat 挂 → shmdt 摘 → shmctl(IPC_RMID) 删;
  • 坑:shmdt ≠ 删除、生命周期随内核、没有同步互斥、大小最好按页对齐。

  • 💬 看完如果有一句没懂,或者你手上的课件图跟我这里解读对不上,评论区直接说,我补图补例子。也欢迎把本篇和你正在学的《通信(一)(二)》串起来复习。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Linux系统篇25——通信(三):System V 共享内存:从页表映射到 shmget/shmat 一文搞懂
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!