1. 基本概念
System V IPC 是早期 AT&T System V 版本 Unix 提出的一套独立的进程间通信标准 ,和传统 IPC、POSIX IPC 并列,属于单机内多进程通信 方案,Linux 完全兼容。它包含三类对象:System V 消息队列、System V 信号量、System V 共享内存。
我们前面提到的管道、匿名和命名管道等,都属于是传统进程间通信,也就是传统 IPC 的体系,而 System V 和 POSIX IPC 是另外两种体系,定位不一样、年代不一样、用法不一样。
2. System V 共享内存
2.1 基本概念

当多个进程同时打开时,由操作系统内核在物理内存中开辟一段专属内存段,多个进程可通过系统调用将该物理内存映射到各自独立的虚拟地址空间中的共享区,之后,进程直接读写自身虚拟地址即可访问共享物理内存,无需经过内核缓冲区拷贝,因此通信速度极快。该内存段具备内核持久化特性,进程退出后不会自动释放,主要适用于无关进程间大批量数据交互,通常需搭配 System V 信号量实现访问同步与互斥。
创建或获取 System V 共享内存段的函数是 shmget :
cpp
#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);
创建成功返回共享内存 ID;失败返回 -1。shmget 创建的 System V 共享内存,本质就是内核在物理内存里划分出来的一块物理内存块。
key_t key:IPC 键值,用来标识共享内存的唯一性。
size_t size:共享内存大小,字节为单位,通常为页大小整数倍,即 4096 的整数倍,如果不是整数倍,操作系统会自动执行向上取整的操作。
int shmflg:创建标志 + 权限
创建标志: IPC_CREAT:不存在则创建,存在则打开; IPC_CREAT | IPC_EXCL:不存在则创建,存在则报错。要注意的是:IPC_EXCL这个标志不能单独使用。

要注意的是,用来标识共享内存的唯一性的 key 值,是用户手动调用 shmget 时传输的,而不是操作系统生成的。而传输的这个值一般会用 ftok () 这个函数去生成:
cpp
#include <sys/types.h>
#include <sys/ipc.h>
key_t ftok(const char *pathname, int proj_id);
它的作用是:让不同进程,通过同一个路径 + ID,生成同一个 key,从而找到同一个 IPC 对象。
其中的参数是:pathname 一个真实存在的文件路径 (可以是普通文件、目录,不能不存在)。 内核会根据这个文件的索引节点 inode 生成 key。proj_id 项目 ID,只使用低 8 位 (通常填 1~255 之间的数字),该参数作用:让同一个文件可以生成不同的 key。
如果生成成功:返回一个 key_t 类型的 IPC 键值 ,如果失败:返回 -1。其实这个key就像是命名管道里的文件路径加文件名,本质都是保证多进程看到同一份资源。
2.2 代码样例
2.2.1 逻辑梳理
我们想要实现通过共享内存去完成信息传输,那首先要梳理一下逻辑:首先我们一定需要一块共享内存区域,接着要将共享内存挂接到对应进程的虚拟地址空间内,如果共享空间使用完之后,还需要将其解除挂接,最后是删除共享内存区域。至于原理下面即将展示:
我们需要三个文件,分别是封装的共享空间类、服务端、客户端,对应 Shm.hpp 、Server.cc 、Client.cc 。Server 负责不断打印共享内存中的内容,Client 负责不断往共享内存里写入用户输入的字符,两者通过同一块共享内存区域交换数据。Shm 类就是对共享内存创建、挂接、脱离、删除等系统调用的封装:

在给 Shm 这个类初始化时,我们先定义几个成员变量:

这里的 _size 表示共享内存的空间大小,而 *_start_addr 表示共享内存映射到虚拟地址空间里的起始地址。 _shmid 表示操作句柄,大家要理解 key 和 shmid 的区别:key 是进程间约定好的编号 ,用来找到 同一块共享内存。shmid 是操作系统内核返回的编号 ,用来操作这块共享内存。
Shm 类的构造函数里,把共享内存标识符 _shmid 初始化为 -1,大小 _size 固定为全局常量 gsize 即 128 字节,起始地址指针 _start_addr 置空。

2.2.2 创建和获取
我们要明确一个事情,共享内存区首先一定是由服务端去创建,而客户端只需要获取该共享内存的 key 值和 shmid 值,那么对于创建和获取这两块内容来说,实际上是差不多的,本质都是围绕 key 和 shmid 值,所以我们先封装一个主体函数 GetHelper :

私有的 GetKey 函数通过 ftok 将一个存在的路径 "/tmp" 和一个项目 ID 0x66 转换成一个键值 key,ftok 失败则直接退出程序。
GetHelper 先调用 GetKey 拿到键值,然后以这个键值、类内记录的大小 _size 和传入的标志 shmflag 调用 shmget。如果 shmget 返回负值就报错退出,否则把返回的共享内存标识符保存到 _shmid 中并打印出键值和 shmid。
这里用两个不同的公有接口来区分使用场景:Create 调用 GetHelper 时传入 IPC_CREAT | IPC_EXCL | 0666,也就是如果共享内存不存在就创建,如果已存在则失败,所以 Server 端用 Create 来保证自己一定是共享内存的创建者;Get 调用 GetHelper 时只传 IPC_CREAT,表示如果共享内存不存在则创建,存在则直接返回已有的标识符,这就给 Client 端用来获取 Server 已经创建好的那块共享内存。所以谁先启动 Server 谁就负责创建,Client 后启动就获取同一块内存。

2.2.3 易错点 1
大家要注意 Server.cc 在调用 Create 函数时,对于 shmflag 这个参数的传参,加上了权限。之所以这样做,是因为 System V 共享内存、消息队列、信号量,在内核里都叫:IPC 对象,它们的权限模型和文件一模一样!所以创建必须给权限,访问必须检查权限,而这里的读写权限和下面即将要做的共享内存和虚拟地址空间的挂接有关系。因为挂接是要把共享内存映射到进程地址空间,涉及到读写权限,所以没有权限就会直接拒绝。
2.2.4 挂接
有了共享内存标识符之后,Attach 函数调用 shmat 把共享内存挂接到当前进程的地址空间,返回值就是这块内存在本进程中的起始地址,保存到 _start_addr 里,这样进程就能像操作普通内存一样访问共享内存了。

这里要用到 shmat 函数,这里的 at = attach(附加、连接)
cpp
#include <sys/types.h>
#include <sys/shm.h>
void *shmat(int shmid, const void *shmaddr, int shmflg);
它的作用就是:把创建好的共享内存,挂接到当前进程的虚拟地址空间, 挂接成功后,进程就可以像读写内存一样读写共享内存。因此,简单说: shmget 是拿到共享内存的编号 shmat 是让进程能直接用这块内存。
其中的参数,shmid 由 shmget 得到的共享内存 ID。shmaddr 是指你想指定挂接到哪个虚拟地址,我们直接填 0 /nullptr ,这代表让系统自动选择最合适的地址。shmflg代表挂接属性,我们直接填 0(表示读写都可以)。
如果挂接成功:返回 void* 类型的指针, 该指针指向共享内存起始地址。如果失败:返回 (void*) -1。
2.2.5 易错点 2
另外在创建共享内存中还有一个易错点,大家如果尝试运行两次创建共享内存,就会发现有一句这样的报错:

这是因为共享内存不会随着进程结束而消失,System V 共享内存属于内核资源,进程退出不会自动释放,只有手动删除或操作系统重启才会销毁。
并且底层的本质是 key 冲突了,因为shmget(k ,_size , mode)这个函数执行不了,才会返回报错。如果想要继续创建共享内存,就需要先把前面创建的共享内存删除:

其中 ipcs -m 指令是查看当前有哪些共享内存, ipcrm -m [选项] 是指删除清理共享内存。
其中查看共享内存时,nattch表达的是,当前有多少个几次呢把该共享内存挂接到了对应的虚拟地址空间。 bytes 表示共享内存的空间大小。 perms标识权限。
2.2.6 信息交互
当挂接完成之后,我们就可以尝试进行通过共享内存来进行信息交互:
Addr 和 Size 两个函数分别把挂接后的起始地址和共享内存大小暴露出去,供外部读写。Detach 调用 shmdt 取消挂接,Delete 则通过 shmctl 加 IPC_RMID 彻底删除共享内存,这个删除操作只在 Server 端最后调用。

Server 端用 Addr 拿到 char* 类型的起始地址 shm_start,Size 拿到 128 的大小。随后进入一个无限循环,每次循环都会遍历整个共享内存区域,把从 shm_start[0] 到 shm_start[size-1] 的每个字符依次打印出来,字符之间用空格隔开,打印完一行后刷新输出,再 sleep 1 秒,如此反复。
Client 端 main 函数调用的是 Get,获取 Server 已创建的那块共享内存,之后 Attach 挂接,同样得到 shm_start 和 size。接着用一个 index 变量记录写入位置,初始为 0。在无限循环里,提示用户输入一个字符,用 cin 读取到变量 ch 中,然后把 ch 写入 shm_start[index] 处,接着 index 自增并对 size 取模,这样写入位置就会在 0 到 127 之间循环,最后 sleep 1 秒。也就是说,用户每输入一个字符,就会被放到共享内存的下一个格子中,Server 每隔一秒打印整个 128 字节的缓冲区内容,从而看到 Client 最新写入的字符出现在对应下标处,原先没有被覆盖的位置可能显示未初始化的值或之前残留的字符。
2.2.7 取消挂接和删除
因为我们前面说了,共享内存的生命周期是随操作系统的,所以我们需要手动的取消共享内存与虚拟地址空间的挂接和删除共享内存:

Detach 调用 shmdt 取消挂接,Delete 则通过 shmctl 加 IPC_RMID 彻底删除共享内存,这个删除操作只在 Server 端最后调用。
这里要介绍两个函数:
首先是 shmdt 函数:
cpp
#include <sys/shm.h>
int shmdt(const void *shmaddr);
shmdt(shared memory detach,共享内存解挂 )是 System V 共享内存的系统调用,用于断开当前进程虚拟地址空间与共享内存段的映射关系,即解除进程对该共享内存的挂接。
它的唯一一个参数:*shmaddr 表示函数返回的共享内存起始虚拟地址 ,必须是合法的、已成功挂接的共享内存地址。如果该函数执行成功:返回 0;如果失败:返回 -1,并设置全局错误码 errno。
要注意这个函数:仅仅只是接触挂接状态,而不删除共享内存, shmdt 仅作用于当前进程 ,不会销毁内核中的共享内存段 ,其他已挂接的进程仍可正常访问;同时,内核为每个共享内存维护挂接进程计数 ,每调用一次 shmdt,引用计数减 1,计数为 0 不代表内存销毁,仅表示无进程挂载。
这里我们要发出一句疑问:为什么shmdt不需要shmid这个参数?这是因为, shmdt 本质上是给进程用的,操作的是虚拟地址,内核通过虚拟地址就能找到对应的共享内存,根本不需要 shmid。只需要找到映射到虚拟地址空间中的地址,就能实现解除挂载的关系。
接着是 shmctl 函数:
cpp
#include <sys/shm.h>
#include <sys/ipc.h>
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
该函数的参数:shmid 由 shmget 创建成功返回的 共享内存 ID 。cmd 就相当于 shmflag,大家只需要重点记这个:IPC_RMID :删除共享内存 (最常用,学习必考),另外还有两个:IPC_STAT:获取共享内存属性 和 IPC_SET:设置共享内存属性。 第三个参数 buf 共享内存属性结构体,删除时传 nullptr 或 0 即可。
如果删除成功:返回 0,如果失败:返回 -1
另外要注意的是,这里在删除共享内存的时候,第一个参数一定要是 shmid ,因为 key 只是属于内核操作的标识值,用来标识共享内存的唯一性,而用户使用共享内存都是用 shmid 。
至此该共享内存的代码逻辑就结束了,不过当前的共享内存还有很多缺陷,像这种简单的轮询读写没有任何同步机制,Server 读取时 Client 可能正在写入,所以会存在数据竞争,到后面我们讲解信号量时会解决这一问题。
本文到此结束,感谢各位读者的阅读,如果有讲解的不到位或者错误的地方,欢迎各位读者批评或指正。