对于Linux:进程间通信IPC(共享内存)的解析

开篇介绍:

hello 大家,那么上篇博客我们一起学习了实现两个不相关之间进程的通信的一个方法:命名管道,那么我们知道,方法肯定不止一种,所以本篇博客,我们来学习另一个方法------共享内存。

这个难度会上升一些,但是本质上还是不难,OK,话不多说,我们开始!~~~

在 Linux 进程间通信(IPC)的 "工具箱" 里,System V 共享内存绝对是 "效率天花板"------ 它的通信速度远超匿名管道、命名管道,甚至比消息队列、信号量快一个量级。之前我们学的管道类 IPC,本质是 "内核中转" 模式,数据要在用户态和内核态之间来回拷贝,而共享内存直接让进程 "共享物理内存",跳过内核这个 "中间商",把效率拉满。

但共享内存也不是 "完美工具",它的致命缺陷(无同步互斥)和复杂的底层原理,让很多初学者望而却步。今天,我们就从 "基础概念→底层原理→生命周期→核心特性→实战注意点 + 代码示例",用最生活化的例子、最细致的拆解,把共享内存讲得明明白白,代码示例仅作辅助理解,不堆砌复杂逻辑,让你看完不仅 "会用",更能 "懂原理"。

一、为什么需要共享内存?

在学共享内存之前,我们已经掌握了匿名管道、命名管道这两种 IPC 方式。它们的核心逻辑是:两个进程通过 "内核缓冲区" 传递数据 ------ 进程 A 把数据写到内核缓冲区,进程 B 从内核缓冲区读数据,内核全程负责数据的中转和同步。

但这种 "中转模式" 有两个致命痛点:

  1. 数据拷贝开销大:数据要经历 "进程 A 用户态→内核态→进程 B 用户态" 两次拷贝,大数据量传输时(比如视频流、日志批量传输),拷贝耗时会占满 CPU;
  2. 系统调用开销大 :每次读写都要调用read/write函数,触发 "用户态 - 内核态切换"------ 这种切换看似简单,实则涉及权限检查、上下文保存 / 恢复,单次切换耗时就比内存读写高几个数量级。

举个直观的例子:用管道传输 1GB 数据,可能需要几秒甚至十几秒;而用共享内存,可能只需要几百毫秒 ------ 差距全在 "是否需要内核中转"。

共享内存的出现,就是为了解决这两个痛点:它让进程直接访问同一块物理内存,数据无需拷贝,通信时无需系统调用,效率接近 "进程内读写内存" 的速度。

核心结论:共享内存不是 "管道的替代品",而是 "高频、大数据量通信场景的最优解"------ 管道适合小数据量、简单通信,共享内存适合大数据量、低延迟通信。

二、共享内存到底是什么?

很多人觉得共享内存抽象,其实用一个生活化的场景就能秒懂:

场景设定:

  • 假设你和同事在同一个办公室(操作系统),你们各自有一本私人笔记本(进程的虚拟地址空间)------ 你们只能在自己的笔记本上写字、看书,不能直接碰对方的笔记本(进程隔离);
  • 办公室墙上有一块公共白板(物理内存中的共享区域)------ 所有人都能在上面写字、擦字、看书;
  • 管理员(操作系统)给你和同事各贴了一张 "映射贴纸"(页表),上面写着:"你的笔记本第 10 页(虚拟地址),对应墙上白板的第 5 块区域(物理地址)";
  • 你在自己笔记本第 10 页写 "下午 3 点开会",本质是在白板第 5 块区域写字;同事翻自己笔记本第 10 页,看到的就是你写的内容 ------ 这就是 "共享内存通信"。

对应到计算机中的概念:

生活化场景 计算机概念 作用
私人笔记本 进程的虚拟地址空间 进程专属,隔离其他进程,避免内存冲突
公共白板 物理内存中的共享区域 所有挂载的进程都能访问,是数据共享的载体
映射贴纸 页表(Page Table) 记录虚拟地址与物理地址的对应关系,让进程 "以为在操作自己的内存"
写字 / 看书 进程读写共享内存 直接操作物理内存,无需第三方中转
管理员 操作系统(内核) 负责开辟共享区域、维护页表、管理共享内存的生命周期

核心本质:

共享内存 = 一块 "公共物理内存" + 多个进程的 "页表映射"------ 进程通过虚拟地址访问共享内存,本质是通过页表翻译后,直接操作物理内存。

注意点 1:共享内存是 "物理内存",不是 "文件"------ 它不存储在硬盘上,断电后数据丢失(和管道的内核缓冲区一样),但管道是 "文件系统中的标识",共享内存是 "物理内存中的区域",这是两者的核心区别。

注意点 2:共享内存的 "共享" 是 "逻辑上的共享"------ 进程看到的虚拟地址可能不同,但对应的物理地址一定相同。比如你看到的虚拟地址是 0x70000000,同事看到的是 0x80000000,但都对应物理地址 0x10000000,写的内容会互相覆盖(通信的基础)。

三、为什么共享内存是最快的 IPC?

要理解共享内存的 "快",我们必须把管道和共享内存的通信流程拆到最细,对比每个步骤的开销,才能直观感受到差距。

先明确:计算机中的 "耗时排名"(从高到低)

  1. 磁盘 IO(比如读写文件);
  2. 用户态 - 内核态切换(比如调用read/write);
  3. 内存拷贝(比如从用户态内存拷贝到内核态内存);
  4. 内存读写(直接操作物理内存或虚拟内存)。

管道的通信流程刚好包含了 "高耗时步骤",而共享内存避开了这些步骤。

1. 管道的通信流程(以命名管道为例):

假设进程 A 给进程 B 发 "hello" 字符串(5 字节),步骤如下:

  1. 进程 A 调用write(fd, "hello", 5)------ 触发用户态→内核态切换(耗时高);
  2. 内核把进程 A 用户态内存中的 "hello",拷贝到内核态的管道缓冲区(内存拷贝,耗时中);
  3. 进程 B 调用read(fd, buf, 1024)------ 触发用户态→内核态切换(耗时高);
  4. 内核把管道缓冲区的 "hello",拷贝到进程 B 的用户态内存(内存拷贝,耗时中);
  5. 进程 B 从内核态→用户态切换(耗时高),才能使用buf中的数据。

总开销:3 次用户态 - 内核态切换 + 2 次内存拷贝 + 2 次系统调用。

2. 共享内存的通信流程:

同样是进程 A 给进程 B 发 "hello",步骤如下:

  1. (初始化步骤)操作系统开辟共享内存(1 次系统调用,仅执行 1 次);
  2. (初始化步骤)进程 A、B 通过shmat映射共享内存(各 1 次系统调用,仅执行 1 次);
  3. 进程 A 直接往自己的虚拟地址写 "hello"------ 本质是写物理内存,无切换、无拷贝(耗时低);
  4. 进程 B 直接从自己的虚拟地址读 "hello"------ 本质是读物理内存,无切换、无拷贝(耗时低);
  5. (收尾步骤)进程 A、B 解除映射(各 1 次系统调用),删除共享内存(1 次系统调用)。

总开销:初始化和收尾共 5 次系统调用(仅执行 1 次),通信过程 0 次切换、0 次拷贝、0 次系统调用。

效率差距的核心原因:

  • 管道的 "通信过程"(步骤 1-5)每次都要承担高耗时开销,而共享内存的 "高耗时步骤" 只在初始化和收尾执行 1 次,通信过程几乎无开销;
  • 内存读写的速度是纳秒级(1 纳秒 = 10^-9 秒),而用户态 - 内核态切换是微秒级(1 微秒 = 10^-6 秒),差距达 1000 倍。

注意点 3:共享内存的 "快" 是 "通信过程快",初始化和收尾有额外开销 ------ 如果只是传输 1 次小数据(比如 1 字节),管道可能比共享内存快(因为共享内存的初始化开销超过通信收益);但如果是多次、大数据量传输,共享内存的优势会无限放大。

注意点 4:不要误以为 "共享内存一定比管道快"------ 要看通信场景:小数据、单次传输用管道,大数据、多次传输用共享内存。

四、进程如何 "共享" 同一块内存?------ 从虚拟地址到物理内存的完整链路

这是共享内存最核心、最容易理解错的部分。要搞懂这个问题,我们必须先铺垫 3 个基础概念:虚拟地址空间、物理内存、页表。如果跳过这些基础,直接讲共享内存的映射,只会越听越懵。

铺垫 1:为什么需要虚拟地址空间?------ 进程隔离的核心

每个进程都有自己独立的 "虚拟地址空间"(比如 32 位系统是 0~4GB,64 位系统是 0~2^64-1),这个地址空间是 "虚拟的",不是真实的物理内存地址。

为什么要有虚拟地址?

  • 隔离进程:如果进程直接使用物理地址,进程 A 写物理地址 0x10000000,可能会覆盖进程 B 的数据,导致进程崩溃(比如你直接在同事的笔记本上写字);
  • 地址连续:进程申请内存时(比如malloc(1024)),虚拟地址是连续的,但对应的物理地址可以是分散的(操作系统通过页表拼接),方便内存管理;
  • 安全:虚拟地址到物理地址的翻译由 CPU 和内核控制,进程无法访问没有权限的物理地址(比如内核的物理内存)。

注意点 5:进程编程时使用的所有地址(比如指针地址),都是虚拟地址 ------ 你永远不知道它对应的真实物理地址,也不需要知道(操作系统会处理)。

铺垫 2:物理内存的管理单位 ------ 页(Page)

物理内存不是按 "字节" 管理的,而是按 "页" 管理的。Linux 系统中,页大小默认是 4KB(可以通过getconf PAGE_SIZE命令查看),也就是说,物理内存被分成了一个个 4KB 的 "页框"(Page Frame),每个页框有一个唯一的物理地址。

为什么按页管理?

  • 减少内存碎片:如果按字节管理,频繁分配和释放小内存会产生大量碎片(比如 1 字节、2 字节的空闲空间,无法分配给需要 1KB 的进程);按页管理,碎片最小是 4KB,利用率更高;
  • 简化页表映射:页表只需要记录 "虚拟页" 和 "物理页框" 的对应关系,不需要记录每个字节的映射,减少页表的存储空间。

注意点 6:共享内存的大小必须是 "页大小的整数倍"------ 即使你申请 1 字节,操作系统也会分配 1 个页(4KB);申请 5KB,会分配 2 个页(8KB),这就是 "向上取整"。比如你要在白板上写 1 个字,管理员也会给你分配一整块 4KB 的区域,不会只给你 1 个字节的空间。

铺垫 3:页表 ------ 虚拟地址到物理地址的 "翻译官"

页表是操作系统给每个进程维护的 "映射表",本质是一个数组,数组的索引是 "虚拟页号",数组的值是 "物理页框号"。

比如:

  • 虚拟地址 0x70000000~0x70000FFF(4KB,1 个虚拟页),对应的物理页框号是 0x100(物理地址 0x10000000~0x10000FFF);
  • 当进程访问虚拟地址 0x70000001 时,CPU 会先把虚拟地址拆成 "虚拟页号 + 页内偏移"(0x70000 + 0x0001),然后通过页表找到物理页框号 0x100,最后计算出物理地址 0x10000001,去物理内存中读写数据。

这个 "翻译" 过程是由 CPU 的 "内存管理单元(MMU)" 硬件自动完成的,进程完全感知不到 ------ 进程以为自己在操作虚拟地址,实则在操作物理地址。

注意点 7:页表是 "进程私有" 的 ------ 每个进程有自己的页表,所以不同进程的相同虚拟地址,可以对应不同的物理地址(隔离);也可以对应相同的物理地址(共享内存的核心)。

共享内存的 "映射魔法":让两个进程的页表指向同一块物理页框

当我们创建共享内存并映射到进程地址空间时,操作系统会做 3 件事:

  1. 在物理内存中分配一个或多个连续的页框(比如 2 个页框,8KB),记录这些页框的物理地址(比如 0x10000000~0x10001FFF);
  2. 在进程 A 的页表中,添加一条映射记录:"虚拟页 X → 物理页框 0x100、0x101"(虚拟地址范围比如 0x70000000~0x70001FFF);
  3. 在进程 B 的页表中,添加一条映射记录:"虚拟页 Y → 物理页框 0x100、0x101"(虚拟地址范围比如 0x80000000~0x80001FFF)。

此时,进程 A 写虚拟地址 0x70000000,本质是写物理地址 0x10000000;进程 B 读虚拟地址 0x80000000,本质是读物理地址 0x10000000------ 两个进程就这样共享了同一块物理内存。

注意点 8:映射时的虚拟地址必须是 "页对齐" 的 ------ 比如页大小是 4KB,虚拟地址必须是 4KB 的整数倍(0x70000000 是 4KB 的整数倍,0x70000001 不是)。如果传shmaddr时指定的地址不是页对齐,且设置了SHM_RND标志,操作系统会自动向下调整为最近的页对齐地址(比如 0x70000001 调整为 0x70000000)。

注意点 9:映射成功后,共享内存的虚拟地址是void*类型 ------ 因为操作系统不知道你要存储什么类型的数据(char、int、结构体),所以需要你手动转换成具体类型(比如char*int*)才能操作。这就像管理员给你分配了一块白板,你需要明确是用来写文字(char)还是画表格(结构体),才能正确使用。

五、共享内存的完整生命周期(shm系列函数):

共享内存的生命周期是 "申请→映射→通信→解除映射→删除",每个步骤都有很多容易踩的坑,我们逐一拆解

步骤 1:申请物理内存(对应shmget函数)

作用:让操作系统在物理内存中开辟一块共享区域,并分配唯一的 "身份证号"(shmid,共享内存标识符)。

bash 复制代码
int shmget(key_t key, size_t size, int shmflg);

核心细节:

  • key 值:共享内存的 "全局唯一标识":
    • key 是让多个进程找到同一块共享内存的 "钥匙"------ 进程 A 用 key=0x66000000 创建共享内存,进程 B 必须用同一个 key 才能挂载并访问。

    • key 通常通过ftok函数生成:

      bash 复制代码
      key_t ftok(const char *pathname, int proj_id)。
      • pathname:必须是一个存在且可访问的文件路径(比如".""/home/user/test.txt");
      • proj_id:一个 1~255 的整数(比如0x66);
      • 原理:ftok会根据文件的 inode 号和 proj_id,生成一个唯一的 key 值(比如文件 inode=12345,proj_id=0x66,生成 key=0x66003039)。

注意点 10:ftok生成的 key 可能重复(哈希冲突)------ 不同的文件路径 + proj_id 组合,可能生成相同的 key。

解决方法:

  1. 用项目专属的文件路径(比如"/home/user/myproject/shm.key"),避免和其他文件冲突;

  2. 生成 key 后,用ipcs -m查看系统中已有的 key,确认没有重复。

注意点 11:key 可以手动指定(不用ftok)------ 比如直接设key=0x12345678,但不推荐:如果系统中已有其他共享内存用这个 key,会导致创建失败。

  • size:共享内存的大小:
    • 单位是字节,必须是页大小的整数倍(操作系统会自动向上取整);
    • 建议设置为页大小的整数倍(比如 4KB、8KB),避免浪费(比如申请 5KB,实际分配 8KB,浪费 3KB)。

注意点 12:不要申请过大的共享内存 ------ 比如申请 1GB,即使你只用到 1KB,也会占用 1GB 的物理内存,导致资源浪费。申请原则:"够用就好",根据实际需求设置大小。

  • shmflg:申请标志 + 权限:
    • 申请标志(常用组合):
      • IPC_CREAT:如果共享内存不存在,创建;如果已存在,直接返回其 shmid(使用者用);
      • IPC_CREAT | IPC_EXCL:如果共享内存不存在,创建;如果已存在,报错(创建者用)------ 确保创建的是 "全新" 的共享内存,避免使用旧的共享内存(可能残留数据);
    • 权限位:和文件权限一样,用八进制数表示(比如06660600),表示谁能访问共享内存:
      • 0666:所有者、组用户、其他用户都能读写(测试用);
      • 0600:仅所有者能读写(生产用,安全);
    • 权限位必须和申请标志组合使用(比如IPC_CREAT | 0666)。

注意点 13:权限位会被umask屏蔽 ------ 比如设置0666,但系统umask=0002,实际权限是0666 & ~0002 = 0664(其他用户无写权限)。如果想让权限位完全生效,创建前执行umask(0)(清除当前进程的权限掩码)。

注意点 14:IPC_EXCL必须和IPC_CREAT一起使用 ------ 单独使用IPC_EXCL没有意义,会报错。

注意点 15:申请失败的常见原因:

  1. key 已存在,且用了IPC_CREAT | IPC_EXCL(创建者重复创建);
  2. 权限不足(比如要创建0600的共享内存,但当前用户不是所有者);
  3. 共享内存大小超过系统限制(比如系统最大允许 4MB,申请 8MB);
  4. 物理内存不足(系统没有足够的页框分配)。

代码示例:生成 key + 申请共享内存

复制代码
#include <sys/ipc.h>
#include <sys/shm.h>
#include <stdio.h>
#include <stdlib.h>

int main() {
    // 1. 生成唯一key(路径".",proj_id=0x66)
    key_t key = ftok(".", 0x66);
    if (key == -1) {
        perror("ftok failed"); // 生成key失败(比如路径不存在)
        exit(1);
    }
    printf("生成key成功:0x%x\n", key);

    // 2. 清除umask,确保权限生效
    umask(0);

    // 3. 申请共享内存(大小4KB,创建者模式:不存在则创建,存在则报错)
    int shmid = shmget(key, 4096, IPC_CREAT | IPC_EXCL | 0666);
    if (shmid == -1) {
        perror("shmget failed"); // 申请失败(比如key已存在)
        exit(1);
    }
    printf("申请共享内存成功,shmid:%d\n", shmid);

    return 0;
}

步骤 2:映射到进程地址空间(对应shmat函数)

作用:让操作系统给进程的页表添加 "虚拟地址→共享内存物理地址" 的映射,返回进程访问共享内存的 "入口"(虚拟地址)。

bash 复制代码
void *shmat(int shmid, const void *shmaddr, int shmflg);

核心细节:

  • shmid:共享内存的身份证号:

    • shmget返回,必须是有效的 shmid(否则映射失败)。
  • shmaddr:指定映射的虚拟地址:

    • nullptr(推荐):让操作系统自动分配一个空闲的、页对齐的虚拟地址(避免手动指定地址导致冲突);
    • 传具体地址(不推荐):比如(void*)0x70000000,此时:
      • 如果地址不是页对齐,且没设置SHM_RND:映射失败;
      • 如果地址已被进程占用(比如已映射其他内存):映射失败;
    • 仅在特殊场景下手动指定地址(比如嵌入式系统,内存布局固定)。
  • shmflg:映射标志:

    • 0:默认,可读可写;
    • SHM_RDONLY:只读模式,进程只能读共享内存,不能写(保护数据不被修改);
    • SHM_RND:页对齐调整,仅当shmaddrnullptr时有效 ------ 把shmaddr向下调整为最近的页对齐地址(比如shmaddr=0x70000001,调整为0x70000000)。
  • 返回值:要是shmat函数失败的话,它的返回值是(void*)-1

注意点 16:映射成功后,返回的虚拟地址是void*------ 必须转换成具体类型(char*int*、结构体指针)才能操作,否则会编译报错(不能直接解引用void*)。

注意点 17:不要修改映射后的虚拟地址 ------ 比如把char* addr = (char*)shmat(...)改成addr += 1024,然后用shmdt解除映射时,必须传入原始的虚拟地址(shmat返回的地址),否则解除映射失败。

注意点 18:一个进程可以多次映射同一个共享内存 ------ 比如进程 A 映射两次,会得到两个不同的虚拟地址,但都对应同一块物理内存(写其中一个,另一个也能读到)。但不推荐这样做,会浪费虚拟地址空间。

注意点 19:映射失败的常见原因:

  1. shmid 无效(比如已被删除);
  2. 权限不足(比如共享内存权限是0600,当前用户不是所有者);
  3. 手动指定的 shmaddr 不是页对齐,且没设置SHM_RND
  4. 进程的虚拟地址空间不足(没有空闲的虚拟地址可映射)。

代码示例:将共享内存映射到进程地址空间

复制代码
#include <sys/ipc.h>
#include <sys/shm.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main() {
    // 假设已通过ftok生成key=0x66003039,shmid=12345(实际需替换为自己的shmid)
    key_t key = 0x66003039;
    int shmid = 12345;

    // 映射共享内存(自动分配虚拟地址,可读可写)
    void* shm_addr = shmat(shmid, nullptr, 0);
    if (shm_addr == (void*)-1) {
        perror("shmat failed"); // 映射失败(比如shmid无效)
        exit(1);
    }
    printf("映射成功,虚拟地址:%p\n", shm_addr);

    // 转换为char*类型(后续用于存储字符串)
    char* data = (char*)shm_addr;
    printf("虚拟地址转换完成,可作为char数组操作\n");

    return 0;
}

步骤 3:进程通信(核心环节)

作用:进程通过映射后的虚拟地址,直接读写共享内存,实现数据传递。

核心细节:

  • 通信的本质:操作虚拟地址 → MMU 翻译为物理地址 → 读写共享内存的物理页框。
  • 操作方式:把虚拟地址转换成具体类型后,像操作数组或结构体一样读写。
    • 例子 1:存储字符串(char*);
    • 例子 2:存储整数(int*);
    • 例子 3:存储结构体。

注意点 20:两个进程的类型转换必须一致 ------ 进程 A 转成char*,进程 B 也必须转成char*;如果 A 转char*,B 转int*,会导致数据解析错误(比如 "hello" 转成 int 会是乱码)。这就像两个人用白板通信,一个写中文,一个按英文解读,肯定看不懂。

注意点 21:共享内存中存储字符串时,必须手动添加'\0'终止符 ------ 共享内存是 "裸内存",不会自动添加字符串终止符,不添加的话,读进程会读到垃圾数据(直到遇到'\0')。

注意点 22:共享内存中存储结构体时,要注意内存对齐 ------ 不同编译器的结构体对齐规则可能不同,导致进程 A 写的结构体,进程 B 读的时候字段偏移不对(比如 A 中的id在偏移 0,B 中的id在偏移 4)。解决方法:1. 用__attribute__((packed))强制取消对齐(不推荐,可能影响性能);2. 手动添加填充字段,确保对齐一致。

注意点 23:不要越界访问共享内存 ------ 比如共享内存大小是 4KB(1024 个int),进程写addr[1024](第 1025 个 int),会访问到其他进程的物理内存或内核内存,导致进程崩溃(段错误)。

代码示例:读写不同类型的数据

复制代码
#include <sys/ipc.h>
#include <sys/shm.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// 示例3:定义结构体(用于存储复杂数据)
struct User {
    int id;
    char name[20];
    int age;
} __attribute__((packed)); // 强制取消对齐(避免不同编译器差异)

int main() {
    // 假设已完成映射,shm_addr是映射后的虚拟地址
    void* shm_addr = (void*)0x7f8000000000; // 示例虚拟地址,实际替换为shmat返回值

    // 1. 读写字符串(char*)
    char* str_data = (char*)shm_addr;
    strcpy(str_data, "Hello, Shared Memory!"); // 写字符串(手动添加'\0')
    printf("写字符串:%s\n", str_data);
    printf("读字符串:%s\n", str_data);

    // 2. 读写整数(int*)
    int* int_data = (int*)(shm_addr + 100); // 偏移100字节,避免和字符串冲突
    int_data[0] = 100; // 写第1个整数
    int_data[1] = 200; // 写第2个整数
    printf("写整数:%d, %d\n", int_data[0], int_data[1]);
    printf("读整数:%d, %d\n", int_data[0], int_data[1]);

    // 3. 读写结构体(struct User*)
    struct User* user_data = (struct User*)(shm_addr + 200); // 偏移200字节
    user_data->id = 1;
    strcpy(user_data->name, "Zhang San");
    user_data->age = 25;
    printf("写结构体:id=%d, name=%s, age=%d\n", user_data->id, user_data->name, user_data->age);
    printf("读结构体:id=%d, name=%s, age=%d\n", user_data->id, user_data->name, user_data->age);

    return 0;
}

步骤 4:解除映射(对应shmdt函数)

作用:让操作系统删除进程页表中对应的映射记录,进程不再能访问该共享内存。

bash 复制代码
int shmdt(const void *shmaddr);

核心细节:

  • 参数:仅需传入shmat返回的虚拟地址(原始地址,不能是修改后的地址)。
  • 返回值:返回值小于0就代表失败
  • 本质:删除 "虚拟地址→物理地址" 的映射,不是删除共享内存本身 ------ 共享内存的物理页框仍然存在,其他进程还能访问。

注意点 24:解除映射后,不要再次访问该虚拟地址 ------ 映射已删除,虚拟地址对应的物理地址可能已被分配给其他进程,访问会导致段错误。

注意点 25:进程退出时,内核会自动解除所有映射 ------ 即使进程崩溃(没调用shmdt),内核也会清理页表,不会导致虚拟地址泄漏。但建议显式调用shmdt(良好的编程习惯)。

注意点 26:解除映射失败的常见原因:

  1. 传入的虚拟地址不是shmat返回的原始地址;
  2. 该虚拟地址没有对应的共享内存映射(比如已解除过映射)。

代码示例:解除共享内存映射

复制代码
#include <sys/shm.h>
#include <stdio.h>
#include <stdlib.h>

int main() {
    // 假设已完成映射,shm_addr是shmat返回的原始虚拟地址
    void* shm_addr = (void*)0x7f8000000000;

    // 解除映射
    int ret = shmdt(shm_addr);
    if (ret == -1) {
        perror("shmdt failed"); // 解除失败(比如地址无效)
        exit(1);
    }
    printf("解除映射成功\n");

    return 0;
}

步骤 5:删除共享内存(对应shmctl函数,cmd=IPC_RMID

作用:让操作系统释放共享内存的物理页框,删除共享内存的shmid_ds结构体,彻底销毁共享内存。

bash 复制代码
int shmctl(int shmid, int cmd, struct shmid_ds *buf);

核心细节:

  • 参数:
    • shmid:要删除的共享内存的身份证号;
    • cmd=IPC_RMID:删除命令;
    • buf=nullptr:删除时不需要传入shmid_ds结构体。
  • 删除规则:
    • 如果当前有进程挂载共享内存(shmid_ds.shm_nattch > 0),执行IPC_RMID后,共享内存不会立即删除,而是标记为 "待删除"(状态dest),直到所有进程都解除映射(shm_nattch=0),内核才会释放物理内存;
    • 如果当前没有进程挂载(shm_nattch=0),执行IPC_RMID后,共享内存会立即删除。

注意点 27:谁创建的共享内存,谁负责删除 ------ 避免多个进程同时执行IPC_RMID(虽然不会报错,但可能导致共享内存提前被删除)。

注意点 28:删除共享内存后,已挂载的进程仍能访问 ------ 直到进程解除映射,共享内存的物理页框才会被释放。但不推荐这样做(共享内存已标记为删除,后续可能出现异常)。

注意点 29:不要忘记删除共享内存 ------ 这是最常见的坑!如果忘记删除,共享内存会一直占用物理内存,直到系统重启(内存泄漏)。解决方法:1. 程序退出前显式调用shmctl(IPC_RMID);2. 用atexit注册清理函数(确保异常退出也能删除);3. 定期用ipcs -m查看,手动删除无用的共享内存。

注意点 30:删除失败的常见原因:

  1. shmid 无效(比如已被删除);
  2. 权限不足(比如不是共享内存的创建者,也不是 root 用户)。

代码示例:删除共享内存

复制代码
#include <sys/shm.h>
#include <stdio.h>
#include <stdlib.h>

// 清理函数:进程退出时自动删除共享内存
void cleanup(int shmid) {
    int ret = shmctl(shmid, IPC_RMID, nullptr);
    if (ret == -1) {
        perror("shmctl(IPC_RMID) failed");
        exit(1);
    }
    printf("删除共享内存成功,shmid:%d\n", shmid);
}

int main() {
    // 假设shmid=12345(实际替换为自己的shmid)
    int shmid = 12345;

    // 注册清理函数,确保进程退出(正常/异常)时删除共享内存
    atexit(() { cleanup(shmid); });

    // ... 中间执行通信逻辑 ...

    printf("程序执行完成,退出时会自动删除共享内存\n");
    return 0;
}

六、内核如何管理共享内存?------ shmid_ds结构体全字段解析

操作系统为每一块共享内存维护了一个shmid_ds结构体,记录共享内存的所有信息(相当于 "身份证 + 说明书")。我们不需要记忆结构体的代码,但需要理解每个字段的作用(排查问题时会用到)。

复制代码
struct shmid_ds {
    struct ipc_perm shm_perm;  // 权限控制结构体
    int shm_segsz;             // 共享内存大小(字节,页大小的整数倍)
    __kernel_time_t shm_atime; // 最后一次映射(shmat)的时间戳
    __kernel_time_t shm_dtime; // 最后一次解除映射(shmdt)的时间戳
    __kernel_time_t shm_ctime; // 最后一次修改的时间戳(创建、权限修改、删除)
    __kernel_ipc_pid_t shm_cpid; // 创建者的PID
    __kernel_ipc_pid_t shm_lpid; // 最后一次操作(shmat/shmdt/读写)的PID
    unsigned short shm_nattch; // 当前挂载的进程数
    unsigned short shm_unused; // 未使用(兼容旧版本)
    void shm_unused2;          // 未使用
    void shm_unused3;          // 未使用
};

关键字段详解:

  1. shm_perm:权限控制

    • 包含共享内存的所有者 ID、组 ID、其他用户的权限(和文件权限一致);
    • ipcs -m查看时,perms字段的值就来自这里(比如666)。
  2. shm_segsz:共享内存大小

    • 实际分配的大小(向上取整后的页大小整数倍),比如申请 5KB,这里的值是 8192(8KB)。
  3. shm_atime/shm_dtime/shm_ctime:时间戳

    • 用于调试:比如shm_atime很久没更新,说明没有进程挂载;shm_dtime最近更新,说明有进程刚解除映射。
  4. shm_cpid/shm_lpid:PID 相关

    • shm_cpid:找到共享内存的创建者(比如用ps -ef | grep 1234查看 PID=1234 的进程);
    • shm_lpid:找到最后操作共享内存的进程(排查谁在占用共享内存)。
  5. shm_nattch:当前挂载数

    • 核心字段!判断共享内存是否还在被使用:
      • shm_nattch > 0:有进程在使用,删除后会标记为dest
      • shm_nattch = 0:无进程使用,删除后立即释放。

注意点 31 :用ipcs -m -i shmid可以查看shmid_ds的所有字段 ------ 比如ipcs -m -i 12345,会输出该共享内存的详细信息,方便排查问题(比如为什么删除不了,谁在占用)。

七、共享内存的 "致命缺陷":没有同步与互斥 ------ 为什么需要搭配其他工具?

共享内存的最大优点是 "快",但最大缺陷是 "没有同步与互斥机制"------ 操作系统完全不干预进程对共享内存的访问,导致数据安全问题。

什么是同步与互斥?

  • 互斥:同一时间,只能有一个进程写共享内存(避免两个进程同时写,数据覆盖);
  • 同步:写进程写完数据后,读进程才能读(避免读进程读一半数据)。

管道为什么没有这个问题?

  • 管道的内核缓冲区有 "读写阻塞机制":
    • 读进程没数据时,read会阻塞(等写进程写);
    • 写进程缓冲区满时,write会阻塞(等读进程读);
    • 写进程关闭后,读进程read返回 0(知道数据读完了)。

共享内存为什么有这个问题?

  • 共享内存是 "裸内存",没有任何阻塞机制:
    • 读进程可以在写进程没写完时就开始读(读到残缺数据);
    • 两个写进程可以同时写(数据覆盖);
    • 读进程可以一直读旧数据(不知道写进程已经更新)。

具体冲突例子:

例子 1:读进程读一半数据

  • 写进程要写 "hello world"(11 字节),先写了 "hello"(5 字节),还没写 " world";
  • 读进程此时开始读,只能读到 "hello"+ 垃圾数据(直到遇到'\0')。

例子 2:两个写进程同时写

  • 进程 A 写 "abcdef"(6 字节),进程 B 写 "123456"(6 字节);
  • 最终共享内存中的数据可能是 "a1c3e5"(数据交叉覆盖),完全混乱。

例子 3:读进程一直读旧数据

  • 写进程写了 "hello",读进程读了一次;
  • 写进程更新为 "world",但读进程不知道,还在反复读 "hello"。

解决方法:共享内存 + 同步 / 互斥工具

共享内存负责 "高效传数据",其他工具负责 "有序传数据",常见组合:

组合 1:共享内存 + 命名管道

  • 原理:用命名管道传递 "控制信号",共享内存传递 "数据";
  • 流程:
    1. 写进程写完数据后,往命名管道写一个字符(比如'1'),表示 "数据已写好";
    2. 读进程先从命名管道读(阻塞),读到'1'后,再从共享内存读数据;
    3. 读进程读完后,往命名管道写一个字符(比如'2'),表示 "数据已读完";
    4. 写进程收到'2'后,再写下一批数据。
  1. 写进程代码(writer.c

负责写共享内存数据,并通过命名管道发送 "数据已写好" 的控制信号,等待 "数据已读完" 的反馈。

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

#define FIFO_PATH "/tmp/my_sync_fifo"  // 命名管道路径
#define SHM_KEY 0x123456              // 共享内存唯一标识
#define SHM_SIZE 1024                 // 共享内存大小(字节)

int main() {
    // ========== 步骤1:创建命名管道 ==========
    if (mkfifo(FIFO_PATH, 0666) == -1) {
        perror("mkfifo failed");
        exit(1);
    }

    // ========== 步骤2:创建并映射共享内存 ==========
    // 创建共享内存(IPC_CREAT表示不存在则创建,0666是权限)
    int shmid = shmget(SHM_KEY, SHM_SIZE, IPC_CREAT | 0666);
    if (shmid == -1) {
        perror("shmget failed");
        exit(1);
    }
    // 映射共享内存到进程地址空间
    char *shm_ptr = (char*)shmat(shmid, NULL, 0);
    if (shm_ptr == (char*)-1) {
        perror("shmat failed");
        exit(1);
    }

    // ========== 步骤3:打开命名管道(写端) ==========
    int fifo_fd = open(FIFO_PATH, O_WRONLY);
    if (fifo_fd == -1) {
        perror("open fifo for write failed");
        exit(1);
    }

    // ========== 步骤4:循环写数据 + 发控制信号 ==========
    int count = 0;
    while (1) {
        // 写数据到共享内存
        sprintf(shm_ptr, "Hello from Writer, Message %d", ++count);
        printf("Writer wrote: %s\n", shm_ptr);

        // 往命名管道写 '1',表示"数据已写好"
        write(fifo_fd, "1", 1);

        // 等待命名管道的 '2',表示"数据已读完"
        char ack;
        read(fifo_fd, &ack, 1);
        if (ack == '2') {
            printf("Writer got ack, ready for next message\n");
            sleep(1);  // 模拟数据生成间隔
        }

        // 演示用:写5条数据后退出
        if (count == 5) break;
    }

    // ========== 步骤5:清理资源 ==========
    shmdt(shm_ptr);  // 解除共享内存映射
    close(fifo_fd);  // 关闭命名管道

    return 0;
}
  1. 读进程代码(reader.c

负责通过命名管道接收 "数据已写好" 的信号,读取共享内存数据,再发送 "数据已读完" 的反馈。

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

#define FIFO_PATH "/tmp/my_sync_fifo"  // 与writer一致的命名管道路径
#define SHM_KEY 0x123456              // 与writer一致的共享内存key
#define SHM_SIZE 1024                 // 与writer一致的共享内存大小

int main() {
    // ========== 步骤1:获取并映射共享内存 ==========
    // 获取共享内存(假设writer已创建)
    int shmid = shmget(SHM_KEY, SHM_SIZE, 0666);
    if (shmid == -1) {
        perror("shmget failed");
        exit(1);
    }
    // 映射共享内存到进程地址空间
    char *shm_ptr = (char*)shmat(shmid, NULL, 0);
    if (shm_ptr == (char*)-1) {
        perror("shmat failed");
        exit(1);
    }

    // ========== 步骤2:打开命名管道(读端) ==========
    int fifo_fd = open(FIFO_PATH, O_RDONLY);
    if (fifo_fd == -1) {
        perror("open fifo for read failed");
        exit(1);
    }

    // ========== 步骤3:循环读控制信号 + 读共享内存 ==========
    int count = 0;
    while (1) {
        // 读命名管道的 '1',阻塞直到收到(表示"数据已写好")
        char sig;
        read(fifo_fd, &sig, 1);
        if (sig == '1') {
            // 读共享内存数据
            printf("Reader read: %s\n", shm_ptr);

            // 往命名管道写 '2',表示"数据已读完"
            write(fifo_fd, "2", 1);
            count++;
        }

        // 演示用:读5条数据后退出,并清理资源
        if (count == 5) {
            shmdt(shm_ptr);   // 解除共享内存映射
            close(fifo_fd);   // 关闭命名管道
            shmctl(shmid, IPC_RMID, NULL);  // 删除共享内存
            unlink(FIFO_PATH); // 删除命名管道
            break;
        }
    }

    return 0;
}
  1. 编译与运行步骤

  2. 编译两个程序:

    复制代码
    gcc writer.c -o writer
    gcc reader.c -o reader
  3. 先运行写进程writer),再运行读进程reader):

    • 终端 1:./writer
    • 终端 2:./reader
  4. 观察输出,你会看到writer写数据后阻塞,直到reader读完并反馈,再继续写下一条,实现了同步通信

代码说明

  • 命名管道 :用于传递控制信号('1'表示 "数据已写好",'2'表示 "数据已读完"),利用其 "阻塞读写" 的特性实现进程同步。
  • 共享内存:用于传递实际数据(字符串),利用其 "高效读写" 的特性提升通信效率。
  • 资源清理reader在退出前会删除共享内存和命名管道,避免资源泄漏。

注意点 32:命名管道的阻塞机制是核心 ------ 确保读进程等写进程,写进程等读进程,解决同步问题;同时,命名管道同一时间只能有一个进程写,解决互斥问题。

组合 2:共享内存 + System V 信号量

  • 原理:信号量是专门用于同步互斥的 IPC 工具,通过 "P 操作"(申请资源)和 "V 操作"(释放资源)控制进程访问顺序;
  • 流程:
    1. 初始化一个信号量,值为 1(表示同一时间只能有一个进程访问共享内存);
    2. 写进程要写数据时,先执行 P 操作(信号量值减 1,变成 0,其他进程再 P 操作会阻塞);
    3. 写进程写完后,执行 V 操作(信号量值加 1,变成 1,唤醒阻塞的进程);
    4. 读进程要读数据时,同样先 P 操作,读完后 V 操作。

注意点 33:信号量本身也有缺陷 ------ 信号量的操作不是原子的(需要搭配semop函数保证原子性),且生命周期和共享内存一样,需要手动删除,否则会泄漏。

核心结论:共享内存不能单独使用(除非你能保证只有一个进程写、一个进程读,且读写顺序固定),必须搭配同步 / 互斥工具,才能保证数据安全。

八、实用命令:管理共享内存

学习共享内存,必须掌握几个常用命令,用于查看、删除、调试共享内存,这是避免内存泄漏的关键。

1. 查看系统中的所有共享内存

复制代码
ipcs -m

输出示例:

复制代码
------ Shared Memory Segments --------
key        shmid      owner      perms      bytes      nattch     status
0x66000000 12345      user       666        4096       2          dest
0x12345678 67890      root       600        8192       0

字段解释:

  • key:共享内存的全局标识;
  • shmid:共享内存的身份证号(删除时要用);
  • owner:创建者用户名;
  • perms:权限(666 = 可读可写,600 = 仅所有者可读可写);
  • bytes:共享内存大小(字节);
  • nattch:当前挂载的进程数(2 表示有 2 个进程在使用);
  • status:状态(dest表示已标记删除,等待所有进程解除映射)。

注意点 34:status=dest的共享内存无法再被新进程挂载 ------ 只能等待现有进程解除映射后,自动删除。

2. 查看指定共享内存的详细信息

复制代码
ipcs -m -i shmid

示例(查看 shmid=12345 的详细信息):

复制代码
ipcs -m -i 12345

输出示例:

复制代码
Shared memory Segment shmid=12345
uid=1000  gid=1000  cuid=1000  cgid=1000
mode=0666 access_perms=0666
bytes=4096  lpid=7890  cpid=4567  nattch=2
att_time=Wed Nov 20 14:30:00 2024
det_time=Wed Nov 20 14:35:00 2024
change_time=Wed Nov 20 14:25:00 2024

字段解释:

  • uid/gid:当前所有者 / 组 ID;
  • cuid/cgid:创建者 / 组 ID;
  • mode/access_perms:权限;
  • lpid:最后一次操作的进程 PID;
  • cpid:创建者 PID;
  • att_time/det_time/change_time:最后映射 / 解除映射 / 修改时间。

注意点 35:通过cpid可以找到创建共享内存的进程 ------ 比如ps -ef | grep 4567,查看 PID=4567 的进程是什么。

3. 删除指定的共享内存

复制代码
ipcrm -m shmid

示例(删除 shmid=12345 的共享内存):

复制代码
ipcrm -m 12345

注意点 36:删除时必须用shmid,不能用key------ 可以先用ipcs -m找到对应的 shmid,再删除。

注意点 37:如果nattch>0,删除后共享内存状态变成dest------ 此时用ipcs -m还能看到它,直到所有进程解除映射后才会消失。

4. 查看共享内存的系统限制

复制代码
cat /proc/sys/kernel/shmmax  # 单个共享内存的最大大小(字节)
cat /proc/sys/kernel/shmall  # 系统中所有共享内存的总大小(页数)
cat /proc/sys/kernel/shmmni  # 系统中最多能创建的共享内存数量

注意点 38:修改系统限制(临时生效)------ 比如把单个共享内存的最大大小改成 10MB:

复制代码
echo 10485760 > /proc/sys/kernel/shmmax  # 10MB=10*1024*1024=10485760字节

注意点 39:永久修改系统限制 ------ 需要编辑/etc/sysctl.conf文件,添加以下内容,然后执行sysctl -p生效:

复制代码
kernel.shmmax = 10485760
kernel.shmall = 2560
kernel.shmmni = 4096

5. 排查共享内存泄漏

如果系统中出现大量nattch=0且没有dest状态的共享内存,说明存在内存泄漏(创建后没删除)。解决方法:

  1. ipcs -m列出所有共享内存;
  2. ipcrm -m shmid删除无用的共享内存(nattch=0且不是正在使用的);
  3. 检查创建共享内存的程序,确保退出时调用shmctl(IPC_RMID)

九、共享内存的适用场景与最佳实践

适用场景:

  1. 高频、大数据量通信:比如视频流传输、实时传感器数据采集、日志批量传输(多次传输 GB 级数据);
  2. 低延迟要求:比如工业控制、游戏服务器、金融交易系统(要求通信延迟在毫秒级以下);
  3. 进程间共享大量只读数据 :比如配置文件(多个进程读取同一个配置,无需修改,用SHM_RDONLY模式映射,安全高效)。

不适用场景:

  1. 小数据量、单次通信:比如进程间传递一个整数(管道更简单,无需初始化共享内存);
  2. 无同步互斥工具的场景:比如多个进程同时写,且没有管道、信号量搭配;
  3. 需要持久化数据的场景:共享内存断电后数据丢失,需要持久化的话用文件或数据库。

最佳实践:

  1. 谁创建,谁删除 :创建共享内存的进程,负责在退出前删除它(用atexit注册清理函数);
  2. 权限最小化 :生产环境中用06000644权限,避免其他用户篡改数据;
  3. 大小按需分配:根据实际需求设置共享内存大小,避免浪费物理内存;
  4. 显式映射 / 解除映射 :虽然内核会自动清理,但显式调用shmatshmdt是良好的编程习惯;
  5. 搭配同步互斥工具:除非是单写单读且顺序固定,否则一定要搭配管道或信号量;
  6. 定期清理 :在服务器环境中,定期执行ipcs -m | grep -v nattch | xargs ipcrm -m(删除 nattch=0 的共享内存),避免泄漏。

十、总结:共享内存的核心价值与学习感悟

System V 共享内存是 Linux IPC 中 "效率最高" 的工具,它的核心价值在于 "跳过内核中转,直接操作物理内存",但它不是 "银弹"------ 必须搭配同步互斥工具才能安全使用。

学习共享内存的过程,本质是学习 "进程地址空间、虚拟内存、物理内存、页表" 这些底层概念的过程。这些概念看似抽象,但只要用生活化的例子拆解,用具体的地址例子辅助,再结合简单的代码示例,就能慢慢理解。

最后,记住几个核心结论:

  1. 共享内存的 "快" 源于 "无内核中转、无数据拷贝";
  2. 共享内存的 "坑" 源于 "无同步互斥、生命周期独立于进程";
  3. 共享内存不能单独使用,必须搭配管道或信号量;
  4. 管理共享内存的关键是 "记住删除",避免内存泄漏。

示例代码:

老样子,我依旧给大家提供一个示例的代码,其是实现两个进程的联系,使用共享内存的方法哦。

Comman.hpp:

cpp 复制代码
#pragma once
#include <cstdio>
#include <cstdlib>

// 定义一个宏负责处理一些打开失败的情况
#define ERR_EXIT(m)                         \
    do                                      \
    {                                       \
        perror(m);                          \
        exit(2);                            \
    } while (0)

Shm.hpp:

cpp 复制代码
#pragma once
#include <cstdio>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstdlib>
#include <cstring>
#include <string>
#include <iostream>
#include <functional>

#include "Comman.hpp"

//接下来要学习的一种IPC方式就是system5 共享内存,叫做shared memory
//这个是最快的一种IPC方式,但是它的缺陷就是对数据没有保护性,即不能一个发,另一个等着发才能读
//但是它的最快速,确实是毋庸置疑的,那么为什么会是最快的呢?其实就是如他的名字一样
//我们知道,想要实现进程间的通信,那么就要先让两个进程看到同一份资源(内存)
//那么我们前面学的命名管道是通过两个进程一起看到命名管道这个文件,从而实现两个不相关的进程的通信
//那么命名管道是文件级别的,会慢一些,所以,共享内存就是在内存级别让两个进程看到同一份内存
//怎么实现的呢,其实也比较简单,
//那就是先在物理内存里面申请一块空间,大小为4096字节,也就是4KB,那么这里有个注意点
//是要我们去调用系统接口让物理内容开辟一块空间,然后也是由我们指定这个空间的大小
//但是虽然我们可以随意指定,但是系统无论如何都是开辟4096字节空间,或者是它的整数倍
//对于我们指定的空间大小,是采取向上取整
//那么这块空间叫作什么呢?不错,它就是共享内存!!!!!!!
//那么共享内存共享内存,如其名,它就是让两个不相关的进程都能访问到这一块共享内存
//那么怎么实现让两个不相关的进程都能访问到这一块共享内存呢?
//其实就是通过物理地址转化为虚拟地址(通过页表进行联系)
//然后这么一来,两个进程内的虚拟地址都能找到物理内存中的那一块共享内存
//由此一来,两个进程就能都访问到物理内存中的那一块共享内存,也就达到了让两个进程看到同一份资源(内存)
//所以两个进程间,也就能进行通信了,那么具体是怎么让两个进程进行联系呢?
//其实就是一个进程往它的与物理内存中的共享内存进行联系的虚拟地址发送消息,然后这个虚拟地址自然会找到物理内存中的那一块共享内存
//所以就是相当于这个进程给那个物理内存中的共享内存输入数据,
//而另一个进程怎么读数据呢?其实也是访问它的与物理内存中的共享内存进行联系的虚拟地址
//就是相当于这个进程访问到物理内存中的共享内存,这里注意,两个进程肯定是要访问到同一块共享内存才行
//当然这个怎么实现我们用到系统接口的时候会再说
//那么关于进程写数据和读数据,还是要有讲究的
//因为地址空间是void*类型的,虽然可以接收任意类型指针,但是我们不能直接对void*进行访问和使用
//所以我们想要传入数据或者读取数据,
//就得去把一个进程它的与物理内存中的共享内存进行联系的虚拟地址从void*转换为char*或者int*等等数据指针类型
//然后这里也是有一个注意点,对于进行通信之间的两个进程,虽然一个负责读,一个负责写
//但是它们两个也都是需要去将它的与物理内存中的共享内存进行联系的虚拟地址从void*转换为char*或者int*等等数据指针类型
//这样子才能写入数据和准确的读取到数据(不可能一个强制转换为char*,另一个则强制转换为int*,这样子会牛头不对马嘴)
//然后同样的,我们要借助死循环去对对应的虚拟地址进行写入数据和读取数据,这个也是老生常谈的操作了
//其实这么干本质上就是一个进程对一块空间咔咔写入数据,另一个进程对那同一块空间嘎嘎读取数据
//那么这个是不需要使用什么read、write函数的。
//怎么说嘞,当我们将一个进程它的与物理内存中的共享内存进行联系的虚拟地址从void*转换为char*或者int*等等数据指针类型
//其实此时那一块空间就相当于是一个数组了,是的,char/int数组,我们知道,char数组的类型不就是char*,
//所以我们就可以当作数组来进行访问,平时我们怎么对数组进行写入数据和怎么读取数组内容,
//就怎么对待共享内存读取数据,这一点很重要,very important!!!
//不过也正是因为这个特性,所以,才导致共享内存这种进程间的通信对数据没有保护性,即不能一个发,另一个等着发才能读
//因为就相当于是对一个数组进行写入和读取,那么系统才不管你什么同步和互斥嘞
//你写就写,我什么时候想读就读,哪怕你什么东西都还没写,但是我要读,诶我就是咔咔读,管你的
//所以,这个缺陷就比较鸡肋了,但是也有解决方法,虽然解决方法不怎么样
//一个是用命名管道将两个进程联系起来,那么当一个进程通过命名管道给另一个进程传入某个数据的时候
//那个接收数据的进程才会开始从共享内存那里开始读取数据
//不难就搁那罢工,啥也不干,类似堵塞,这是一种解决方法。、
//还有一个就是system5 信号量,这个也有缺陷,所以也不怎么样,然后它的使用方法和共享内存也差不多
//所以后续有需要,再去使用。
//那么如上就是借助共享内存进行进程间通信的大概,还是比较简单的
//向物理内存申请共享内存并获取共享内存id的系统接口:shmget
//将进程的虚拟地址和共享内存划上联系的系统接口:shmat(shm attach)
//将进程的虚拟地址和共享内存取消联系的系统接口:shmdt(shm deatch)
//在代码内部删除共享内存或者或取共享内存信息的系统接口:shmctl
//查看共享内存的命令:ipcs -m
//删除指定共享内存的命令:ipcrm -m shmid

//接下来我们就可以封装出一个开辟以及操作共享内存的类来
const int SHMSIZE=4096;//共享内存空间大小
const int PROJID=0x66;//用于ftok函数生成key
#define PATH "."
const int shmmode=0666;//共享内存权限码
#define USER "user"//使用共享内存的使用者
#define CREATOR "creator"//创建共享内存的创造者,只有它能去把共享内存删掉,谁创建谁删除

class Shm
{
private:
    //创建共享内存,获取共享内存id,删除共享内存,链接进程和共享内存,断开共享内存和进程的函数
    void CreatHelper(int shmflg)//创建共享内存的帮手,我们创建共享内存,获取共享内存id的使用方法差不多,所以可以直接封装为一个函数
    {
        //向物理内存申请共享内存并获取共享内存id的系统接口:shmget
        //int shmget(key_t key, size_t size, int shmflg);
        //shmget函数的返回值就是shmid,即共享内存的编号,这个在后续删除共享内存和链接共享内存、进程要用到
        //要是创建共享内存失败了,就会返回-1
        //shmget函数的第一个参数是key值,作为共享内存在系统内部的唯一标识符,就靠它找到同一个共享内存
        //我们可以自己随意指定,但是没什么必要,系统提供了ftok函数帮助我们生成key值
        //key_t ftok(const char *pathname, int proj_id);
        //这个函数的返回值便是key,我们就是拿它的返回值当做可以
        //那么这个函数的第一个参数就是制定路径,我们可以直接传"."即可,或者其他的都行
        //然后第二个参数是传入一个整型,那么同样的,也可以是我们自己指定的
        //爱是多少就多少,我这里使用0x66,那么使用了之后就会得到key
        //后面我们想要新的key,只需要改变传入的参数即可
        //然后ftok函数我们就放在构造函数中使用
        //那么关于shmget函数的第二个参数,则是要创建的共享内存的空间大小,一般是4096字节,也可以大于
        //系统采用向上取整,这个上面有说。
        //而shmget的第三个参数就是有讲究的了,它是传入标志,它们的用法和创建文件时使用的mode模式标志是一样的
        //取值为IPC_CREAT:共享内存不存在,创建并返回;共享内存已存在,获取并返回。
        //那么我们的使用者,即使用共享内存的,只需要用一个IPC_CREAT就行
        //通过它获取到已经创建的共享内存的shmid,用于后续删除共享内存和链接共享内存、进程
        //取值为IPC_CREAT | IPC_EXCL:共享内存不存在,创建并返回;共享内存已存在,则出错返回。
        //而我们的创建者,即创建共享内存的,就是用IPC_CREAT | IPC_EXCL,表明要创建一个全新的共享内存
        //那么系统怎么知道某个共享内存有没有已经存在呢?很显然,就是依靠key值
        //key值,作为共享内存在系统内部的唯一标识符,就靠它找到同一个共享内存
        //那么除了这个,还不够,因为如果创建了新的共享内存,那么它是需要权限的
        //因为Linux下一切皆文件,所以我们也得给这个共享内存设置权限码,那么一般就是0666
        //而0666放在哪里呢?其实就是再|0666就行,也是放在第三个参数上
        //至此,这个shmget函数就差不多了
        //这里要重点强调一下IPC_CREAT、IPC_EXCL等等,本质上都是宏定义的整型数字哦
        //这一点一定要清楚
        
        //那么我们本函数的核心功能就是使用shmget函数
        //但是我们不直接指定shmget函数的第三个参数,而是让外界函数指定
        //因为我们不知道是创建者使用,还是使用者使用
        //但是由于这两个的使用,都是一样的,key一样,共享内存一样
        //只有第三个参数不一样,所以我们封装出这个函数来进行使用
        //便捷又美观

        int ret_shmget_shmid=shmget(_key,_shmsize,shmflg);//最后一个参数就使用外界传来的
        if(ret_shmget_shmid<0)//处理失败情况
        {
            ERR_EXIT("shmget failed: ");
        }
        //得到shmid值
        _shmid=ret_shmget_shmid;//传给成员变量
        std::cout<<"shmid is: "<<_shmid<<std::endl;
    }
    //创建者使用shmget函数
    void CreatShm()
    {
        //直接给CreatHelper传入IPC_CREAT|IPC_EXCL|0666即可
        CreatHelper(IPC_CREAT|IPC_EXCL|0666);
    }
    //使用者使用shmget函数
    void UserGetShmid()
    {
        //直接给CreatHelper传入IPC_CREAT即可
        CreatHelper(IPC_CREAT);
        //简简单单
    }
    //将共享内存与进程虚拟空间地址挂上联系
    void AttachShmAndVirtual()
    {
        //这个就得使用shmat函数:
        //void *shmat(int shmid, const void *shmaddr, int shmflg);
        //返回值很简单,就是一个进程它的与物理内存中的共享内存进行联系的虚拟地址
        //我们后面本质写入和读取,也就是把这个指针变量强制转换为char*等等,然后像数组一样对其进行操作
        //那么要是shmat函数失败的话,它的返回值(void*)-1
        //我们可以借助这一个去进行判断共享内存与进程虚拟空间地址是否成功挂上联系
        //那么shmat的第一个参数就是要和进程挂起联系的共享内存的shmid,即shmget函数的返回值
        //这个不必多说,前面也有说过,还是很简单的说实话
        //那么它的第二个参数是指针类型,
        //其实是可以让我们用户传入说要在该进程的哪个虚拟地址开始和共享内存挂起联系
        //但是我们怎么知道该进程哪个位置可以放置呢,所以一般是传入nullptr,让系统自己找位置放置
        //第三个参数的话,shmflg:它的两个可能取值是SHM_RND和SHM_RDONLY
        //1. SHM_RDONLY
        //含义:表示对共享内存段只读访问。
        //作用:设置该标志后,进程只能读取共享内存中的数据,无法写入,用于保障数据安全性(防止意外修改)。
        //2. SHM_RND
        //含义:表示 "页对齐"("RND" 是 "round" 的缩写,意为取整、对齐)。
        //作用:当通过 shmget 指定共享内存的 key 时,若 key 不是系统页大小的整数倍,
        //设置 SHM_RND 后会自动将 key 调整为最近的页对齐值,确保共享内存的地址符合系统页对齐要求。
        //但是我们一般直接传入0就行,
        //若 shmflg=0,表示 "可读可写" 且 "不强制地址对齐"(系统自动分配地址时无需此标志,会默认对齐)。
        //就是这么的简单,哈哈

        void* ret_shmat_viraddress=shmat(_shmid,nullptr,0);
        if(ret_shmat_viraddress==(void*)-1)//在64位系统中,void*是8字节,int是4字节,所以我们使用long long int
        {
            ERR_EXIT("shmat failed: ");
        }
        //建立联系成功
        _viraddress=ret_shmat_viraddress;//传入成员变量
        std::cout<<"virtualaddress is: "<<_viraddress<<std::endl;
    }
    //将共享内存与进程虚拟空间地址的联系断开
    void DeatchShmAndVirtual()
    {
        //那么想要将共享内存与进程虚拟空间地址的联系断开
        //就得使用shmdt函数:
        //int shmdt(const void *shmaddr);
        //这个就很简单了,返回值小于0就代表失败
        //而传入参数也很简单,
        //就是一个进程它的与物理内存中的共享内存进行联系的虚拟地址
        //就是这么简单
        int ret_shmdt=shmdt(_viraddress);
        if(ret_shmdt<0)
        {
            ERR_EXIT("shmdt failed: ");
        }
        std::cout<<"DeatchShmAndVirtual successed!"<<std::endl;
    }
    //将共享内存删除掉,仅限创建者使用
    void RemoveShm()
    {
        //想要删除共享内存,就得使用shmctl函数
        //那么这个函数就有讲究了:
        //int shmctl(int shmid, int cmd, struct shmid_ds *buf);
        //返回值:成功返回0;失败返回-1
        //那么第一个参数依旧是指定共享内存的id,很简单
        //主要的关键是在第二个参数上,
        //IPC_STAT:获取共享内存的当前状态,将共享内存的关联数据(如权限、大小等)填充到 shmid_ds 结构中。
        //那么第三个参数的struct shmid_ds *结构体指针就是用于这个
        //IPC_SET:在进程权限足够时,修改共享内存的属性,将 shmid_ds 结构中的值设置为共享内存的当前关联值。
        //那么第三个参数的struct shmid_ds *结构体指针也是用于这个
        //IPC_RMID:删除指定的共享内存段,释放对应的内存资源。
        //那么第二个参数用了这个后,第三个参数就直接传入nullptr就行了
        //然后就能删除指定shmid的共享内存了
        
        int ret_shmctl=shmctl(_shmid,IPC_RMID,0);
        if(ret_shmctl<0)
        {
            ERR_EXIT("shmctl failed: ");
        }
        //就是这么简单
        std::cout<<"RemoveShm successed!"<<std::endl;
    }
public:
    //构造函数
    Shm(const char* path=PATH,int projid=PROJID,const char* usertype=CREATOR)//给上缺省值
    :_usertype(usertype)
    ,_shmsize(SHMSIZE)
    {
        //构造函数就是负责创建key值,以及判断是什么用户使用
        //创建者就是创建共享内存,同时获取到共享内存id,并将创建者进程与共享内存挂上联系
        //使用者就是获取到共享内存id,并将使用者进程与共享内存挂上联系
        //所以我们得使用if语句进行判断
        //但是它们两个都得进行将进程与共享内存挂上联系
        int ret_ftok_key=ftok(path,projid);
        if(ret_ftok_key<0)//处理失败情况
        {
            ERR_EXIT("ftok failed: ");
        }
        //得到key值
        _key=ret_ftok_key;//传给成员变量
        //判断用户类型
        //我们要牢记,创建者和使用者,都会各自创建一个Shm类变量哦
        if(_usertype==CREATOR)//string变量可以直接使用==,因为有==运算符重载函数
        {
            //创建者就是创建共享内存,同时获取到共享内存id,并将创建者进程与共享内存挂上联系
            CreatShm();
        }
        else if(_usertype==USER)
        {
            //使用者就是获取到共享内存id,并将使用者进程与共享内存挂上联系
            UserGetShmid();
        }
        //它们两个都得进行将进程与共享内存挂上联系
        AttachShmAndVirtual();
    }
    //析构函数
    ~Shm()
    {
        //无论是创建者还是使用者都得断开联系
        //但是创建者还需要进行共享内存的删除
        //而使用者就不需要
        
        //先断开联系
        DeatchShmAndVirtual();
        if(_usertype==CREATOR)//创建者还需要进行共享内存的删除
        {
            RemoveShm();
        }
        std::cout<<"~Shm successed!"<<std::endl;
    }
    //获取共享内存空间大小
    size_t GetShmSize()
    {
        return _shmsize;
    }
    //获取一个进程它的与物理内存中的共享内存进行联系的虚拟地址
    void* GetVirtualAddress()
    {
        return _viraddress;
    }
    //获取key值
    int GetKey()
    {
        return _key;
    }

private:
    size_t _shmsize;//共享内存空间大小
    void* _viraddress;//一个进程它的与物理内存中的共享内存进行联系的虚拟地址
    int _shmid;//共享内存id
    int _key;//key值,作为共享内存在系统内部的唯一标识符,就靠它找到同一个共享内存
    std::string _usertype;//用户类型,是创建共享内存的创造者还是使用共享内存的使用者
};

Client.cc

cpp 复制代码
//客户端,也是使用共享内存的进程
#include "Shm.hpp"

int main()
{
    Shm s(PATH, PROJID, "user");

    //获取于共享内存建立联系的虚拟地址指针
    //并且直接强制转换为字符数组类型
    char* virtualaddress=(char*)s.GetVirtualAddress();


    // 像对待字符数组一样去对其进行数据的写入
    // 我们读写共享内存,有没有使用系统调用??没有!!
    int index = 0;
    for (char c = 'A'; c <= 'B'; c++, index += 2)
    {
        // 向共享内存写入
        // 像对待字符数组一样去对其进行数据的写入
        sleep(1);
        virtualaddress[index] = c;
        virtualaddress[index + 1] = c;
        sleep(1);
        virtualaddress[index+2] = 0;
    }

    //最后终止发送,让读取数据的那一个进程能够知道
    //写入结束标记
    strcpy(virtualaddress, "END"); 
    return 0;
}

Server.cc:

cpp 复制代码
//服务端,也是创建共享内存的进程
#include "Shm.hpp"

int main()
{
    //创建Shm类变量
    Shm s(PATH, PROJID,"creator");

    //获取于共享内存建立联系的虚拟地址指针
    //并且直接强制转换为字符数组类型
    char* virtualaddress=(char*)s.GetVirtualAddress();

    //死循环发送和数据
    while(true)
    {
        //打印获取到的数据(字符串)
        printf("%s\n",virtualaddress);
        if(strcmp(virtualaddress,"END")==0)
        {
            break;
        }
    }
    return 0;
}

Makefile:

bash 复制代码
.PHONY:all
all:Client Server
Client:Client.cc
	g++ $^ -o $@ -std=c++11

Server:Server.cc
	g++ $^ -o $@ -std=c++11

.PHONY:clean
clean:
	rm -f Client Server fifo

其实,还是很简单滴。

结语:

结语:从 "看见内存" 到 "驾驭内存",IPC 学习的进阶之路

敲完最后一行代码,看着终端里 Server 和 Client 通过共享内存顺畅通信的输出,忽然想起刚开始学进程间通信时的手足无措 ------ 那时对着匿名管道的readwrite函数反复琢磨:"为什么进程 A 写的字节,进程 B 能读到?内核到底在中间做了什么?" 而现在,当我们能用共享内存让两个进程直接 "触碰" 同一块物理内存时,那些曾经抽象的 "虚拟地址""页表""物理内存",终于变成了可以亲手操作的具体存在。

这一路的学习,其实是一场 "打破隔阂" 的探索。从命名管道依赖 "文件系统标识" 实现通信,到共享内存通过 "页表映射" 让进程共享物理内存,我们触摸到的不仅是 API 的用法,更是操作系统设计的底层逻辑:所有进程间通信的本质,都是让不同的进程 "看见同一份资源"。只不过这份资源可以是文件(管道)、内存(共享内存)、内核消息队列,甚至是网络套接字 ------ 而共享内存的 "高效",恰恰源于它跳过了 "资源中转" 的环节,让进程直接与物理内存对话。

回想共享内存的学习过程,最容易卡壳的地方往往不是 API 的调用,而是对 "虚拟地址空间" 的理解。刚开始总疑惑:"为什么两个进程的虚拟地址不同,却能操作同一块物理内存?" 直到用 "私人笔记本与公共白板" 的例子类比,才突然明白:进程的虚拟地址空间就像一本带 "映射贴纸" 的笔记本,贴纸(页表)上写着 "第几页对应白板第几块区域",而共享内存就是让两本笔记本的贴纸指向同一块白板。这个比喻背后,藏着计算机内存管理最核心的智慧 ------用虚拟地址的 "隔离" 保障安全,用页表的 "映射" 实现共享

而共享内存的 "缺陷"------ 缺乏同步与互斥,反而成了理解 "通信安全" 的最佳切入点。刚开始写示例代码时,总觉得 "两个进程直接读写内存多方便",直到出现 "读进程读到一半数据""写进程互相覆盖" 的混乱,才真正理解管道为什么要设计 "阻塞机制"。原来 "效率" 和 "安全" 从来都是取舍:管道用 "内核中转" 的开销换来了天然的同步,共享内存用 "直接读写" 的高效把同步的责任交给了程序员。这也正是技术选择的本质:没有完美的工具,只有适合场景的方案

学习共享内存时,那些容易被忽略的 "细节" 其实最有价值。比如shmget申请的大小必须是页的整数倍,背后是操作系统 "按页管理内存" 的逻辑;shmat返回的虚拟地址必须强制类型转换才能使用,提醒我们 "内存本身没有类型,类型是程序员赋予的";shmctl删除共享内存时 "标记为 dest 而非立即删除",体现了操作系统 "不打扰正在使用的进程" 的设计哲学。这些细节像散落的拼图,拼起来就是完整的 "操作系统如何管理内存" 的图景。

实战环节的代码封装,更让我们体会到 "从理解到应用" 的跨越。当把shmget"shmat""shmdt""shmctl" 封装成Shm类,用构造函数处理创建与映射,用析构函数负责清理,忽然发现:好的封装不是隐藏细节,而是让使用者能聚焦核心逻辑 。就像我们在ClientServer中,只需关心 "往共享内存写什么""从共享内存读什么",而不必反复思考 "如何申请内存""如何解除映射"------ 这正是编程抽象的意义。

当然,学习的过程总少不了 "踩坑"。比如第一次忘记用ipcrm删除共享内存,导致下次运行shmget时因为 "key 已存在" 而报错;比如手动指定shmaddr时没注意页对齐,导致shmat返回失败;比如读写进程对共享内存的类型转换不一致,读到一堆乱码。但正是这些错误,让我们记住了 "共享内存的生命周期独立于进程""虚拟地址必须页对齐""类型转换要一致" 这些关键规则 ------错误从来不是学习的终点,而是理解的起点

往更深了说,共享内存的学习其实是在培养一种 "底层思维"。当我们知道 "进程操作的地址是虚拟的""内存访问要经过页表翻译",再看指针、数组、动态内存分配时,视角会完全不同。比如malloc申请的内存为什么是连续的虚拟地址却可能对应离散的物理页?free释放内存时为什么有时不会立即归还给操作系统?这些问题的答案,其实都藏在我们学习共享内存时接触的 "页表""物理页框" 概念里。技术的底层逻辑是相通的,掌握了核心原理,就能一通百通

从命名管道到共享内存,我们走过的不仅是 "两种 IPC 机制" 的学习,更是 "从简单到复杂""从抽象到具体" 的认知升级。命名管道像 "寄信",把数据交给内核(邮局)中转,简单但低效;共享内存像 "共用水板",进程直接读写,高效但需要自己维护秩序。而真实的工程场景中,很少有单一技术能解决所有问题:视频监控系统可能用共享内存传输实时帧,用信号量控制读写顺序,用命名管道传递控制指令 ------技术的价值,在于组合使用的智慧

最后想对你说:如果现在对共享内存的某些细节还没完全吃透,没关系。学习操作系统相关的知识,就像剥洋葱 ------ 第一层是 API 的用法,第二层是机制的原理,第三层是设计的哲学,每剥一层都需要时间和实践。重要的是保持 "追问" 的习惯:"为什么shmget需要 key?""shmat的虚拟地址是怎么分配的?""内核如何知道有多少进程在使用共享内存?" 这些追问会像指南针,指引你穿过 "会用" 的表象,抵达 "懂原理" 的深处。

当你下次在项目中需要设计进程间通信方案时,希望你能想起今天学的共享内存:它的高效让你在传输大数据时毫不犹豫,它的同步问题提醒你做好安全防护,它的生命周期管理让你记得 "谁创建谁清理"。而更重要的是,希望你能想起这段 "从困惑到清晰" 的学习过程 ------技术的魅力从来不止于 "能用",更在于理解它 "为何如此设计" 的思考中

进程间通信的学习还没结束,信号量、消息队列、UNIX 域套接字还在前方等待探索。但只要带着今天这份 "打破砂锅问到底" 的好奇心,带着 "理论结合实践" 的行动力,你会发现:那些看似高深的操作系统知识,终将变成你手中可以驾驭的工具。毕竟,所有复杂的技术,本质上都是为了解决简单的问题 ------ 让不同的进程,顺畅地 "说上话"。

继续往前走吧,下一段学习之路,依然值得期待。

相关推荐
代码中介商1 小时前
Linux 进程间通信:共享内存与消息队列完全指南
linux·运维·服务器
计算机安禾1 小时前
【Linux从入门到精通】第27篇:文本处理三剑客(上)——grep 正则表达式实战
linux·运维·正则表达式
码到成功>_<1 小时前
Linux中grep命令使用说明
linux
minji...1 小时前
Linux 网络套接字编程(六)TCP的通信是全双工的,自定义协议的定制,序列化和反序列化
linux·运维·服务器·网络·c++
小王C语言1 小时前
【linux进程信号】————产生信号:signal自定义信号处理动作(自定义捕捉)、前后台进程、产生信号的方式(函数、软条件、硬件异常)....等等
运维·服务器·前端
Gauss松鼠会2 小时前
效率起飞!GaussDB 管理平台(TPOPS)升级指南
服务器·数据库·性能优化·gaussdb·经验总结
晚风予卿云月2 小时前
【linux】僵尸进程与孤儿进程
linux·运维·服务器
逻辑驱动的ken2 小时前
Java高频面试考点场景题16
java·开发语言·面试·职场和发展·求职招聘
故事还在继续吗2 小时前
Linux cgroup 使用指南:从原理到实践
linux·运维·服务器