目录
-
- [铺垫:System V 的三种通信方式](#铺垫:System V 的三种通信方式)
- [一、System V 共享内存](#一、System V 共享内存)
- [二、System V 消息队列](#二、System V 消息队列)
-
- [2.1 消息队列的各个接口](#2.1 消息队列的各个接口)
- [三、System V 信号量](#三、System V 信号量)
-
- [3.1 几个概念](#3.1 几个概念)
- [3.2 什么是信号量?](#3.2 什么是信号量?)
- [3.3 信号量的各个接口](#3.3 信号量的各个接口)
- 四、三者的共性
-
- [4.1 统一的核心数据结构](#4.1 统一的核心数据结构)
- [4.2 内核中的统一管理](#4.2 内核中的统一管理)
- [4.3 System V 内核数据结构详解](#4.3 System V 内核数据结构详解)
- [4.4 共享内存的挂接](#4.4 共享内存的挂接)

个人主页:矢望
个人专栏:C++、Linux、C语言、数据结构、Coze-AI、MySQL
铺垫:System V 的三种通信方式
| 通信方式 | 英文名称 | 主要特点 | 用途 |
|---|---|---|---|
| 共享内存 | Shared Memory |
最快,直接内存访问 | 大数据量、高频通信 |
| 消息队列 | Message Queue |
有边界,支持多类型 | 结构化消息传递 |
| 信号量 | Semaphore |
同步与互斥控制 | 资源管理、进程同步 |
一、System V 共享内存
1.1 原理
将同一块物理内存映射到多个进程的虚拟地址空间,进程直接读写,无需内核拷贝 。

如上图,进行进程间通信首先就需要让不同的进程看到同一份资源。上图中让不同的进程将同一个内存块映射到自己的虚拟地址空间(具体是共享区),每一个进程都得到内存块在自己的虚拟地址空间的起始地址。
所以共享内存的原理是一个简化版本的动态库的映射 。并且共享内存将来在物理内存中会存在多份,所以操作系统就需要通过先描述,再组织对共享内存进行相关的管理,因此共享内存的管理结构体 + 共享内存本身 等于 共享内存。
1.2 共享内存 函数 与 代码测试
创建 与 获取共享内存
shmget函数
shmget 是 System V 共享内存的创建/获取接口,是整个共享内存机制的入口 。

返回值 :成功时返回值是共享内存标识符,失败时返回-1。
| 参数 | 类型 | 说明 |
|---|---|---|
key |
key_t |
共享内存的唯一标识符,让不同进程识别同一块共享内存 |
size |
size_t |
共享内存大小,通常按页4KB对齐,大小必须是4096字节的整数倍 |
shmflg |
int |
标志位,指定权限和操作模式 |
其中 shmflg 由两部分组成:权限标志 + 操作标志。
权限标志 就是创建文件的初始权限,如0666等。
操作标志 常用的用两种IPC_CREAT和IPC_EXCL,后者不能单独使用。
IPC_CREAT:可以单独传递使用, 功能是如果创建的共享内存不存在就创建,如果存在就获取 ;也就是总要获取一个共享内存。
IPC_CREAT | IPC_EXCL:如果创建的共享内存不存在就创建,如果存在就出错返回。也就是只要新的共享内存。
那么我们如何知道共享内存是否存在呢? 所以共享内存一定有自己的唯一值标识。
第一个参数key :共享内存的键值,在内核中标识共享内存的唯一性。既然有这个参数,那么这个键值就需要用户来传递。
可是为什么不让内核来直接生成呢? 如果这个键值由内核来做,那么进程A在创建共享内存中,内核将会生成键值并返回给A,然后和A进程素未蒙面的进程B来了,它要和A通信可是它不知道标识共享内存的键值是多少。难道还要让进程A传递给进程B吗,如果A真的传递给了进程B那它们不就已经能够通信了吗? 这就不成了先有鸡还是先有蛋的问题了吗? 所以这个键值不能由内核生成。
这个键值在通信之前,由中间人用户约定好是什么。将来进程A拿着实现准备好的键值创建共享内存,进程B拿着实现约定好的共享内存找到共享内存,两个进程开始进行通信 。
就好比谍战片里面两个不认识的特工,由一个中间人搭线约定会面地点一样,在出发之前,特工就已经知道在那里会面了。
那么这个键值如何设定呢?这个键值可以随意设定,但不推荐随意设定,这里可以通过ftok函数设定。

它的作用是将文件路径 和项目 ID转换成一个唯一的 key 值,供 System V IPC(共享内存、消息队列、信号量)使用。
返回值 :成功时,返回一个 key_t 类型的键值,通常是一个整数;失败时,返回 -1,并设置 errno。
和上期博客一样,依旧是Client和Server要进行通信,我们依旧使用Shm.hpp管理与共享内存相关的类操作。

首先,两个进程要进行操作,首先就需要创建共享内存。
cpp
const int Size = 128;
// 等价于命名管道那里的文件路径
#define PATH_NAME "/tmp" // 使用存在的路径
#define PROJ_ID 0x666
class Shm
{
public:
Shm()
:_shmid(-1)
,_size(Size)
{}
// 创建共享内存
void Create()
{
key_t k = GetKey();
if(k < 0)
{
std::cerr << "ftok error: " << strerror(errno) << std::endl;
exit(1);
}
printf("key = 0x%x, key = %d\n", k, k);
// _shmid = shmget(k, Size, IPC_CREAT | IPC_EXCL);
}
~Shm()
{}
private:
key_t GetKey()
{
key_t k = ftok(PATH_NAME, PROJ_ID);
return k;
}
private:
int _shmid; // 共享内存标识符
int _size; // 共享内存的大小
};
如上,我们编译运行打印出key值。

接着就是让Client获取共享内存,然后看是否得到的是同一个共享内存。
cpp
// 获取共享内存
void Get()
{
key_t k = GetKey();
if(k < 0)
{
std::cerr << "ftok error: " << strerror(errno) << std::endl;
exit(1);
}
printf("key = 0x%x, key = %d\n", k, k);
// _shmid = shmget(k, Size, IPC_CREAT);
}
运行测试 :

如上,这两个进程能够得到相同的共享内存。它们能够看到同一份资源。
共享内存的结构体:

上面这两个结构体是内核管理共享内存的核心数据结构。
好,接下来,我们就创建共享内存。
cpp
// 创建共享内存
void Create()
{
// 构建键值
key_t k = GetKey();
if(k < 0)
{
std::cerr << "ftok error: " << strerror(errno) << std::endl;
exit(1);
}
// 创建新的共享内存
_shmid = shmget(k, Size, IPC_CREAT | IPC_EXCL);
if(_shmid < 0)
{
std::cerr << "shmget error: " << strerror(errno) << std::endl;
exit(2);
}
printf("key = 0x%x, shmid = %d\n", k, _shmid);
}
运行测试 :

如上,创建出了共享内存,并且shmid是0。
如何查看我们创建出的共享内存呢?
ipcs 命令默认显示所有 IPC 资源,共享内存、消息队列、信号量。添加选项-m:查看所有共享内存;-q:查看所有消息队列;-s:查看所有信号量。
ipcrm 命令是用于删除 System V IPC 资源(共享内存、消息队列、信号量)的命令。它是 ipcs 的搭档命令,用于清理不再需要的 IPC 资源。
后来我将已存在的共享内存删掉之后,再次创建多次:

如上,shmid变成了1,可是再次创建,它就不让我创建了。
我们首先查看共享内存:

如上图,我们看到了共享内存,并且发现我们的./Server进程结束之后,共享内存依旧存在,所以共享内存(包括System V IPC),它的生命周期随内核 。因此只要用户不主动删除ipc资源,ipc资源会和操作系统一样,一直存在,除非重启系统。
删除共享内存
这就意味着我们必须手动删除共享内存,将来删除共享内存的方式有指令和代码两种方式。先来看指令删除ipcrm -m shmid。

我们再次创建。

多次创建时,它说文件已存在。
所以key和shmid之间的区别:key只在内核中标识共享内存的唯一性,当key冲突时,就不能再创建这个共享内存了,而用户使用共享内存时,不通过key使用。shmid只在用户中使用,在自己的代码中通过shmid来访问共享内存。
它们之间的关系类比fd和inode编号,shmid相当于fd,key相当于inode编号。
shmctl函数
接下来来看指令删除共享内存。
shmctl(Shared Memory Control)是用于控制和管理共享内存的系统调用,它可以获取状态、修改权限、删除共享内存等。

| 参数 | 类型 | 说明 |
|---|---|---|
shmid |
int |
共享内存标识符(shmget 的返回值) |
cmd |
int |
控制命令,指定要执行的操作 |
buf |
struct shmid_ds * |
指向共享内存状态结构的指针,用于获取或设置信息 |
返回值 :成功时,返回0,部分指令返回非0,失败时,返回-1。
控制指令:
cmd |
用途 | 返回值 |
|---|---|---|
IPC_STAT |
获取状态信息 | 0(成功),-1(失败) |
IPC_SET |
设置权限/所有者 | 0(成功),-1(失败) |
IPC_RMID |
删除共享内存 | 0(成功),-1(失败) |
IPC_INFO |
获取系统限制 | 内核中最高索引值 |
SHM_INFO |
获取资源使用信息 | 0(成功),-1(失败) |
SHM_STAT |
通过索引获取状态 | shmid(成功),-1(失败) |
cpp
// 删除共享内存
void Delete()
{
int n = shmctl(_shmid, IPC_RMID, nullptr);
(void)n; // 防止编译器告警
}
cpp
int main()
{
Shm shm;
shm.Create();
sleep(5);
shm.Delete();
return 0;
}
运行测试 :

如上,先创建,之后通过代码删除共享内存。
挂接共享内存
shmat函数
现在创建、获取、删除共享都有了,现在的工作就是挂接共享内存了。将共享内存挂接到进程的虚拟地址空间。
shmat(shared memory attach)负责将共享内存段挂接到进程的虚拟地址空间,让进程能够像访问普通内存一样访问共享内存。

返回值 :成功时,返回共享内存挂接到进程虚拟地址空间的起始地址;失败时,返回 (void *)-1,并设置 errno。
所以我们类中还需要一个成员变量存储起始地址。
| 参数 | 类型 | 说明 |
|---|---|---|
shmid |
int |
共享内存标识符(shmget 的返回值) |
shmaddr |
const void * |
指定挂接的虚拟地址(通常为 NULL) |
shmflg |
int |
挂接标志,控制访问权限和映射方式 |
这里不推荐自己指定要挂接的共享内存的虚拟地址,推荐使用NULL,让内核自动选择一个未使用的虚拟地址。
shmflg的常用标志 :
0:默认,以读写方式挂接,需要共享内存本身有读写权限;
SHM_RDONLY:只读挂接,只能读取不能写入;
SHM_RND:与 shmaddr 非 NULL 配合,将地址向下取整到页边界;
SHM_REMAP:如果指定地址已被映射,则重新映射。
挂接共享内存:
cpp
class Shm
{
public:
// 挂接共享内存
void Attach()
{
_start_addr = shmat(_shmid, nullptr, 0);
if(_start_addr == (void*)-1)
exit(3);
}
private:
int _shmid; // 共享内存标识符
int _size; // 共享内存的大小
void* _start_addr; // 共享内存的起始地址
};
cpp
int main()
{
Shm shm;
shm.Create();
sleep(5);
shm.Attach();
sleep(5);
shm.Delete();
return 0;
}
运行测试 :

如上,代码挂接共享内存失败了,我们现在来看看描述共享内存的各个参数。

如上图,前面几个不用多说,其中nattch表示的是挂接数,也就是当前有多少进程将这个共享内存挂接到它的虚拟地址空间。
而perms标识的是这个共享内存的权限。是共享内存的权限控制机制,它决定了哪些进程可以访问共享内存资源。如上图中,权限是0,也就是没有任何权限,表示所有用户,包括所有者,都没有任何权限访问。
也就是我们现在需要设置共享内存的权限,那么如何设置呢?在上文讲创建共享内存时说shmget函数的第三个参数 shmflg 由两部分组成:权限标志 + 操作标志。权限就是在这里设置的。
修改代码:
cpp
// 创建共享内存
void Create()
{
// 构建键值
key_t k = GetKey();
if(k < 0)
{
std::cerr << "ftok error: " << strerror(errno) << std::endl;
exit(1);
}
// 创建新的共享内存
_shmid = shmget(k, Size, IPC_CREAT | IPC_EXCL | 0666);
if(_shmid < 0)
{
std::cerr << "shmget error: " << strerror(errno) << std::endl;
exit(2);
}
printf("key = 0x%x, shmid = %d\n", k, _shmid);
}
再次运行测试 :

如上,这样就挂接成功了,并且我们看到perms变成666了。
扩充 : 我在最开始说过,共享内存的大小必须是4096字节的整数倍,但是细心的兄弟会发现,我是按照128字节申请的,如上图所示。并且实际显示的也的确是128,不是说必须是4096的整数倍吗?原因是共享内存的 shm_segsz 字段存储的是用户实际请求的大小,但内核实际分配的是4096字节大小的整数倍。也就是说虽然你申请的是128字节,但内核实际分配的是4096字节。如果你申请4097字节,那么内核会分配4096*2字节,但是bytes显示的值还是4097字节。这样设计的目的是为了让用户知道自己的共享内存在逻辑上就是自己申请的大小,实际上你可以使用整个页面的空间,但这是未定义行为,当你越界访问(超出你申请的字节),系统依旧给你报错。
去关联共享内存
你现在将共享内存挂接到了你的进程,之后你需要首先去关联,然后再删除它。
shmdt函数

shmdt(shared memory detach)是共享内存操作中与 shmat 配对的函数,用于将共享内存从进程的虚拟地址空间分离。
它的参数就是共享内存在虚拟地址空间的起始地址。
返回值 :成功时,返回0;失败时,返回-1。
cpp
// 去关联共享内存
void Detach()
{
int n = shmdt(_start_addr);
(void)n;
}
cpp
int main()
{
Shm shm;
shm.Create();
sleep(5);
shm.Attach();
sleep(5);
shm.Detach();
sleep(3);
shm.Delete();
return 0;
}
运行测试 :

到现在,我们就完成了共享内存生命周期的代码级管理。
接下来,让Server和Client同时挂接共享内存。
Server.cc:
cpp
#include "Shm.hpp"
int main()
{
Shm shm;
shm.Create();
sleep(2);
shm.Attach();
sleep(5);
shm.Detach();
sleep(5);
shm.Delete();
return 0;
}
Client.cc:
cpp
#include "Shm.hpp"
int main()
{
Shm shm;
sleep(2);
shm.Get();
sleep(2);
shm.Attach();
sleep(5);
shm.Detach();
return 0;
}
运行测试 :

如上,成功让两个进程挂接到同一个共享内存。
到目前为止,还没有进行通信。我们之前所做的所有工作都是让不同进程看到同一份资源。
进行通信
成员变量获取接口:
cpp
void* Addr()
{
return _start_addr;
}
int size()
{
return _size;
}
Client.cc:
cpp
#include "Shm.hpp"
int main()
{
Shm shm;
shm.Get();
shm.Attach();
char* shm_start = (char*)shm.Addr();
int size = shm.size();
int index = 0;
// 写入数据
while(true)
{
std::cout << "输入消息: ";
char ch;
std::cin >> ch;
shm_start[index++] = ch;
index %= size;
}
shm.Detach();
return 0;
}
Server.cc:
cpp
#include "Shm.hpp"
int main()
{
Shm shm;
shm.Create();
shm.Attach();
char* shm_start = (char*)shm.Addr();
int size = shm.size();
// 读取共享内存
while(true)
{
for(int i = 0; i < size; i++)
{
std::cout << shm_start[i] << ' ';
}
std::cout << std::endl;
sleep(1);
}
shm.Detach();
shm.Delete();
return 0;
}
运行测试 :

如上,成功进行了通信,Server端从共享内存中获取了Client端发送的数据。
共享内存的特点 :
优点 :
1、访问共享内存,不需要系统调用,因为共享内存已经映射到进程的用户共享区了 。
2、写端将数据拷贝到共享内存,其它连接该共享内存的进程立马就能看到。共享内存是所有进程间通信方式中速度最快的 。因为它的拷贝次数少,直接映射,不需要系统调用,有了共享内存的起始地址,它甚至可以直接向共享内存写入,例如std::cin >> shm_start;,只要一些入,读端马上就看到了。
cpp
共享内存的数据流动:
写端: 用户缓冲区 ──直接写入──→ 共享内存(物理内存)
读端: 共享内存(物理内存)──直接读取──→ 用户缓冲区
拷贝次数:0 次(直接访问)
管道的拷贝次数:
写端: 用户缓冲区 ──拷贝──→ 内核缓冲区 ──拷贝──→ 读端用户缓冲区
拷贝次数:2 次
缺点 :没有资源的保护机制,没有同步与互斥机制 。当写端停止写的时候,读端并没有阻塞,可以持续读,可能写端还没有写完,读端就读取数据了。因此需要额外保护,这也是为什么共享内存通常与信号量配合使用,共享内存负责数据传输,信号量负责同步协调。
1.3 获取共享内存的属性
获取共享内存属性信息是通过 shmctl 系统调用配合 IPC_STAT 命令来实现的。

cpp
// 获取共享内存属性
void Attr()
{
struct shmid_ds ds;
int n = shmctl(_shmid, IPC_STAT, &ds);
if(n < 0)
{
perror("shmctl");
exit(4);
}
printf("shmid: %d\n", _shmid);
printf("key: 0x%08x\n", ds.shm_perm.__key);
printf("大小: %lu bytes\n", ds.shm_segsz);
printf("附加进程数: %lu\n", ds.shm_nattch);
printf("创建者 PID: %d\n", ds.shm_cpid);
printf("最后操作 PID: %d\n", ds.shm_lpid);
printf("所有者 UID: %d\n", ds.shm_perm.uid);
printf("所有者 GID: %d\n", ds.shm_perm.gid);
printf("创建者 UID: %d\n", ds.shm_perm.cuid);
printf("创建者 GID: %d\n", ds.shm_perm.cgid);
printf("权限: 0%03o\n", ds.shm_perm.mode & 0777);
printf("最后附加时间: %s", ctime(&ds.shm_atime));
printf("最后分离时间: %s", ctime(&ds.shm_dtime));
printf("最后改变时间: %s", ctime(&ds.shm_ctime));
}
运行 :

二、System V 消息队列

消息队列是一种通过队列数据结构实现的进程间通信机制。如上图,基于一个共享的队列,进程之间就可以实现基于有类型的数据块级别的进程间通信,这就叫做消息队列。
消息队列也可能存在多份,所以操作系统需要对消息队列通过先描述,再组织进行管理。另外消息队列一定有一个队列头struct来描述消息队列。
2.1 消息队列的各个接口
- 创建消息队列:

如上就是,创建消息队列的相关函数msgget,可以看到它的第一个参数和创建共享内存的参数相同,都是key,所以用法也是一样的,都可以使用ftok函数生成key值。

所以进程A和进程B也是通过key值来保证自己看到的是同一个消息队列的。
msgget的第二个参数也是标志位(权限控制 + 创建选项),和共享内存的参数的使用方式都是一样的!
通过ipcs -q就可以查看到创建出来的消息队列。

- 删除消息队列
删除它,可以使用ipcrm -q 【msqid】删除,也可以使用函数msgctl来删除。

msgctl 是消息队列的控制接口,它负责对已创建的消息队列进行查询、修改、删除等控制操作。通过IPC_RMID可以删除消息队列,通过IPC_STAT选项可以带出核心数据结构信息。

通过这一系列和共享内存高度相似的接口,我们可以知道System V是一个标准,接口、返回值、参数、数据结构、底层原理都是具有共性的。
- 进行进程间通信
消息队列就需要函数接口了,msgsnd用于发送消息,msgrcv用于接收消息。

msgsnd参数 :msqid,消息队列标识符 ,由msgget()返回;msgp,指向消息缓冲区的指针 ,消息结构必须按照固定格式定义:

cpp
struct msgbuf
{
long mtype; // 消息类型,必须 > 0
char mtext[1]; // 消息数据(可以是任意内容,长度可变)
};
msgsz,消息数据部分的长度 ;msgflg,控制标志 ,常用值0:阻塞模式,如果队列满就等待;IPC_NOWAIT:非阻塞模式,队列满时立即返回错误。
msgrcv参数 :msqid,消息队列标识符 ;
msgp,指向接收缓冲区的指针 ,结构格式与发送时相同;
msgsz,缓冲区数据部分的最大长度 ,如果实际消息数据超过这个长度,取决于msgflg决定是否截断或报错;
msgtyp ,指定要接收的消息类型 ,这是消息队列的核心优势:msgtyp = 0:接收队列中第一个消息(按先进先出),msgtyp > 0:接收队列中类型等于msgtyp的第一个消息,msgtyp < 0:接收队列中类型小于等于|msgtyp|的最小类型值的第一个消息;
msgflg,常用标志 :0:阻塞模式,队列无符合条件消息时等待,IPC_NOWAIT:非阻塞,无消息立即返回ENOMSG,MSG_NOERROR:如果消息数据长度大于msgsz,截断数据而不报错。
三、System V 信号量
3.1 几个概念
进程之间需要进程间通信(IPC),但是进程具有独立性,所以我们就想方法让不同的进程看到同一份资源,这个方法是OS提供的。解决一个问题往往会伴生出新的问题。例如,当一个进程在传输数据途中,另一个进程立刻就来读取数据,这就导致了数据不一致的问题,并发问题。
因此,有这几个新的概念:
1、共享资源 :多个执行流能同时看到并访问的公共资源。
2、互斥 :任何时刻,只能有一个执行流访问公共资源。互斥的本质就是为了保护公共资源。
3、临界资源 :被保护起来的公共资源。
4、临界区 :访问临界资源的代码。访问临界资源是进程在访问,是CPU访问执行到了相关代码。
5、原子性:做一件事,要么不做,要么做完,没有中间状态,这种只有两态的操作就叫做原子性。没有中间操作的情况下,不容易被打扰。
3.2 什么是信号量?
信号量(信号灯),本质是一个计数器!
举个例子,你要去看电影,电影院中一共100个座位。所以人数一定不能超过100。你在买票也就是申请座位资源的时候,只要买到票相应的座位就是你的。因此买票的本质就是对资源的预定机制。
在这个例子中,票就相当于信号量,它是有限的,有一定的数量。信号量的本质是一个计数器,用来描述临界资源中资源数量的多少 。买票就是对资源的预定机制,申请资源,首先就要申请信号量,申请成功就可以访问资源,申请失败就会被阻塞。
申请信号量的本质就是对资源的预定机制。
细节:
1、每个进程都要申请信号量,也就是说每个进程都必须看到同一个信号量,因此信号量本身就是共享资源 。谁来保护信号量的安全呢?OS保证信号量的申请操作(--)是原子的,信号量的释放操作(++)也是原子的。申请操作叫做P操作,释放操作叫做V操作 。
2、如果电影院是超级VIP级别的,里面只有一个座位,也就是信号量为1,那么一次只能进来一个人,这就是互斥!这种只有0/1两种状态的信号量叫做二元信号量,二元信号量就是为了实现互斥的。上面的可以有很多种状态的信号量叫做多元信号量。
如下图,将来对一整个内存块做整体使用,也就是使用二元信号量对资源做管理。而将进程块分成很多小的内存块就是使用多元信号量对资源做管理,允许多个进程访问不同的内存块。

上图中也就相当于左边是超级VIP电影院,右边是普通的电影院。
3、++、--操作是原子的吗?如果信号量是整数sem = 10;那么sem--操作在C代码中是一行代码,但是转成汇编语句之后就是多行代码。
cpp
mov eax, [ebp-4] ; 将 sem 的值加载到 eax 寄存器
sub eax, 1 ; eax = eax - 1
mov [ebp-4], eax ; 将结果写回内存
而CPU对进程的切换与调度是随时可以进行的,有可能刚刚将值加载到寄存器,进程就被切走了,另一个进程到了之后它发现sem还没变化,它也进入到了申请信号量的逻辑,就糟糕了。因此++、--操作不是原子的!最简单粗暴判断一行代码是不是原子就是看它转到汇编语句之后是不是一行代码,如果是一行代码就是原子的。
解决方案:需要硬件原子指令或内核锁保护。
4、如何保证多个进程看到同一个信号量呢? 如果要进行共享资源的保护,就必须先让不同的进程看到同一份资源(计数器资源),所以信号量sem才被归类到进程IPC。信号量是用来保护其他共享资源的工具,但工具本身也必须是共享的,因此信号量被归为IPC机制的一种。
3.3 信号量的各个接口
- 获取信号量
semget 是System V信号量中用于创建或获取信号量集的系统调用。它返回一个标识符,供后续的 semop(P/V操作)和 semctl(控制操作)使用。

可以看到它的第一个参数和创建共享内存的参数相同,都是key,所以用法也是一样的,都可以使用ftok函数生成key值。

所以进程A和进程B也是通过key值来保证自己看到的是同一个信号量的。
semget的第三个参数也是标志位(权限控制 + 创建选项),和共享内存的参数的使用方式都是一样的!
第二个参数nsems是信号量集中信号量的个数,也就是你想要申请几个信号量,这个一次可以申请信号量集。
由于信号量会存在很多个,所以操作系统需要通过先描述,再组织对信号量进行管理。
在内核中,每个信号量集都对应一个 struct semid_ds 结构体,它就像这个集合的档案。
cpp
// 内核中典型的信号量集描述结构
struct semid_ds {
struct ipc_perm sem_perm; // 权限信息(key、所有者等)
struct sem *sem_base; // 指向信号量数组的指针
unsigned short sem_nsems; // 信号量个数(就是nsems参数)
time_t sem_otime; // 最后操作时间
time_t sem_ctime; // 最后修改时间
};
struct sem { // 单个信号量的描述
unsigned short semval; // 当前值(计数器)
pid_t sempid; // 最后操作该信号量的进程ID
unsigned short semncnt; // 等待该信号量值增加的进程数
unsigned short semzcnt; // 等待该信号量值变为0的进程数
};
- 删除信号量
semctl 是 System V 信号量中用于控制信号量集的系统调用。它负责初始化、删除、查询信号量集,以及对单个信号量进行设置等操作。

| 参数 | 含义 |
|---|---|
semid |
信号量集标识符(由 semget 返回) |
semnum |
信号量在信号量集合中的索引(0 表示第一个) |
cmd |
要执行的命令 |
... |
可选参数,取决于 cmd,通常是 union semun |
删除信号量集,IPC_RMID,semctl(semid, 0, IPC_RMID);即可删除,删除的是整个信号量集,而不仅仅是单个信号量。
- 初始化信号量
使用的接口依旧是semctl。
初始化信号,SETVAL。
需要搭配第四个参数进行初始化信号量。

这个联合体不在任何系统头文件中定义,需要程序员自己写!
cpp
// 必须自己定义这个联合体
union semun {
int val; // 用于 SETVAL 命令
struct semid_ds *buf; // 用于 IPC_STAT 和 IPC_SET
unsigned short *array; // 用于 GETALL 和 SETALL
struct seminfo *__buf; // 用于 IPC_INFO(系统信息)
};
// 设置集合中第 0 个信号量的值为 1
union semun arg;
arg.val = 1;
semctl(semid, 0, SETVAL, arg);
/*
* 第1个参数 semid:信号量集标识符,由 semget 返回
* 第2个参数 0:信号量在集合中的索引,0 表示第1个信号量
* 第3个参数 SETVAL:命令,表示设置信号量的值
* 第4个参数 arg:联合体,包含要设置的初始值
*/
- 对信号量进行
P/V操作
semop 是 System V 信号量中用于执行原子操作的系统调用。它实现了信号量的核心功能:P操作(申请资源)和 V操作(释放资源)。

| 参数 | 含义 |
|---|---|
semid |
信号量集标识符(由 semget 返回) |
sops |
指向操作数组的指针 |
nsops |
操作数组中操作的数量 |

cpp
struct sembuf {
unsigned short sem_num; /* 信号量在集合中的索引(0 表示第一个) */
short sem_op; /* 操作值(负数=P操作,正数=V操作,0=等待0) */
short sem_flg; /* 操作标志(0, IPC_NOWAIT, SEM_UNDO) */
};
申请资源:
cpp
struct sembuf op;
op.sem_num = 0; /* 操作第 0 个信号量 */
op.sem_op = -1; /* P操作:申请一个资源 */
op.sem_flg = 0; /* 阻塞模式 */
semop(semid, &op, 1); // 1 表示只执行 1 个操作
释放资源:
cpp
struct sembuf op;
op.sem_num = 0; /* 操作第 0 个信号量 */
op.sem_op = 1; /* V操作:释放一个资源 */
op.sem_flg = 0; /* 阻塞模式 */
semop(semid, &op, 1); // 1 表示只执行 1 个操作
同时操作两个信号量:
cpp
/* 同时操作两个信号量 */
struct sembuf ops[2];
/* 第一个操作:对信号量0执行 P 操作 */
ops[0].sem_num = 0;
ops[0].sem_op = -1;
ops[0].sem_flg = 0;
/* 第二个操作:对信号量1执行 P 操作 */
ops[1].sem_num = 1;
ops[1].sem_op = -1;
ops[1].sem_flg = 0;
/* 执行 2 个操作,原子执行 */
semop(semid, ops, 2);
内核中的信号量管理分为三个层次:
cpp
系统层(全局管理)
struct ipc_ids sem_ids // 管理所有信号量集
↓
信号量集层(每个集合)
struct semid_ds // 描述一个信号量集
↓
单个信号量层(每个信号量)
struct sem // 描述一个信号量


四、三者的共性
System V 共享内存、消息队列、信号量十分相似,IPC资源 = 内核数据结构 + 资源本身(内存块、队列、计数器)。
4.1 统一的核心数据结构
每个 System V IPC 资源都有类似的管理结构:
cpp
// 共享内存描述符
struct shmid_ds {
struct ipc_perm shm_perm; // 权限信息
size_t shm_segsz; // 大小
// ...
};
// 消息队列描述符
struct msqid_ds {
struct ipc_perm msg_perm; // 权限信息
// ...
};
// 信号量描述符
struct semid_ds {
struct ipc_perm sem_perm; // 权限信息
// ...
};
// 统一的权限结构
struct ipc_perm {
key_t __key; // 资源键值
uid_t uid; // 所有者 UID
gid_t gid; // 所有者 GID
uid_t cuid; // 创建者 UID
gid_t cgid; // 创建者 GID
mode_t mode; // 访问权限
// ...
};
4.2 内核中的统一管理
cpp
// 内核中的 IPC 子系统统一管理
struct ipc_ids {
int in_use; // 使用中的 IPC 资源数
int max_id; // 最大 ID
struct kern_ipc_perm *entries[IPCMNI]; // 统一的管理数组
};
// 所有 IPC 资源都继承自 kern_ipc_perm
struct kern_ipc_perm {
spinlock_t lock; // 保护锁
int id; // 资源 ID
key_t key; // 资源键值
// ...
};
接口统一性 :xxxget、xxxctl、xxxop 的三段式命名。
参数一致性 :都使用 key、标志位、权限模式。
数据结构统一性 :都有 ipc_perm 权限结构。
管理工具统一 :ipcs/ipcrm 可以管理所有类型。
4.3 System V 内核数据结构详解
内核数据结构中有很多结构体,其中如下:
共享内存 :

消息队列 :

信号量 :

它们的第一个元素都是kern_ipc_perm结构体类型,它是所有 IPC 资源的基类,也是所有 IPC 资源通用的核心部分。

有了这些的结构体,内核是如何对它们进行管理的呢?
内核通过一个ipc_id_ary的结构体进行管理:

这个结构体的第二个元素是柔性数组,不太了解的转移到这个博客:点击跳转。
以管理消息队列为例,柔性数组中就会存储消息队列对象的地址,然后通过这个地址就可以对消息队列结构体对象中的元素进行访问,由于消息队列结构体的第一个元素就是struct kern_ipc_perm *类型,所以通过p[0]->就可以直接访问kern_ipc_perm结构体对象中的元素,而消息队列其它的元素可以通过(struct msg_queue*)(p[0])->进行访问,如下图。

我们的柔性数组中可以存储一系列的共享内存/消息队列/信号量等的结构体对象的地址,所以我们之前调用的shmget、semget、msgget所返回的整型值可以理解为柔性数组的数组下标。
知道了如何管理IPC资源,那么IPC资源又不止一种,那是如何知道具体是什么IPC资源的呢?
实际上,它是通过另一种叫做ipc_ids的结构体区分开的。

如上图,这个结构体中有一个ipc_id_ary*的指针类型,这个entries就是指向ipc_id_ary结构体对象的!
实际上会存在三个ipc_ids的结构体对象,分别是:
cpp
static struct ipc_ids msg_ids; // 管理所有消息队列
static struct ipc_ids sem_ids; // 管理所有信号量集
static struct ipc_ids shm_ids; // 管理所有共享内存段
所以将来不同的IPC资源对应的系统调用不同,所以将来在内核中一共存在三个共享内存来分别管理共享内存、消息队列和信号量,这样就知道具体是那个资源了。
上面还有一个细节:多态 。
C++的类是 属性 + 方法 ,其中C语言实现方法的多态是结构体中加入函数指针的方式实现;而C语言实现属性的多态就是结构体嵌套的方式实现。
属性的多态:
cpp
// 基类:所有 IPC 资源共有的属性
struct kern_ipc_perm {
spinlock_t lock; /* 保护锁 */
int id; /* 资源 ID */
key_t key; /* 资源键值 */
uid_t uid; /* 所有者 UID */
gid_t gid; /* 所有者 GID */
uid_t cuid; /* 创建者 UID */
gid_t cgid; /* 创建者 GID */
mode_t mode; /* 访问权限 */
unsigned long seq; /* 序列号 */
};
// 子类
// 结构体嵌套,Linux 内核使用这种方式
struct sem_array {
struct kern_ipc_perm sem_perm; /* 嵌套基类:属性继承 */
/* 派生类自己的属性 */
time_t sem_otime;
time_t sem_ctime;
int sem_nsems;
struct sem *sem_base;
};
struct shmid_kernel {
struct kern_ipc_perm shm_perm; /* 嵌套基类:属性继承 */
/* 派生类自己的属性 */
size_t shm_segsz;
unsigned long shm_nattch;
struct file *shm_file;
};
struct msg_queue {
struct kern_ipc_perm q_perm; /* 嵌套基类:属性继承 */
/* 派生类自己的属性 */
unsigned long q_qnum;
unsigned long q_qbytes;
struct list_head q_messages;
};
方法的多态:
cpp
// 基类:所有 IPC 资源共有的属性
struct kern_ipc_perm {
spinlock_t lock; /* 保护锁 */
int id; /* 资源 ID */
key_t key; /* 资源键值 */
uid_t uid; /* 所有者 UID */
gid_t gid; /* 所有者 GID */
uid_t cuid; /* 创建者 UID */
gid_t cgid; /* 创建者 GID */
mode_t mode; /* 访问权限 */
unsigned long seq; /* 序列号 */
};
// 定义操作表(虚函数表)
/* 方法表:相当于 C++ 的虚函数表(vtable) */
struct ipc_ops {
/* 方法指针(相当于虚函数) */
int (*init)(void *obj, ...); /* 构造函数 */
int (*destroy)(void *obj); /* 析构函数 */
void (*print)(void *obj); /* 打印信息 */
int (*operation)(void *obj, int op); /* 具体操作 */
};
/* 全局方法表(每个类共享一份,类似于 C++ 的虚表) */
static struct ipc_ops sem_ops = {
.init = sem_init,
.destroy = sem_destroy,
.print = sem_print,
.operation = sem_operation,
};
static struct ipc_ops shm_ops = {
.init = shm_init,
.destroy = shm_destroy,
.print = shm_print,
.operation = shm_operation,
};
static struct ipc_ops msg_ops = {
.init = msg_init,
.destroy = msg_destroy,
.print = msg_print,
.operation = msg_operation,
};
// 对象结构体(包含虚表指针)
/* 每个对象单独存储方法指针(类似 C++ 对象模型) */
struct ipc_object {
struct kern_ipc_perm perm; /* 属性(基类) */
struct ipc_ops *ops; /* 虚表指针(指向全局的方法表) */
void *private_data; /* 派生类特有数据 */
};
/* 创建信号量对象 */
struct ipc_object *create_semaphore(key_t key) {
struct ipc_object *obj = malloc(sizeof(struct ipc_object));
obj->perm.key = key;
obj->ops = &sem_ops; /* 绑定信号量的方法表 */
obj->private_data = malloc(sizeof(struct sem_private));
return obj;
}
/* 多态调用 */
void print_ipc_object(struct ipc_object *obj) {
obj->ops->print(obj); /* 动态绑定:根据 ops 调用对应方法 */
}
上面结构体中的.是指定初始化器,这是 C99 标准引入的指定初始化器语法,用于在初始化结构体时显式指定要初始化哪个成员,而不必按照结构体成员的顺序。
4.4 共享内存的挂接
共享内存的挂接是 vm_area_struct 和 shmid_kernel 通过 struct file 作为桥梁,建立了进程地址空间与共享内存之间的关联,struct file 是在 shmget 创建共享内存段时由内核创建并关联的。

如果要进行进程间通信的话,会再来一个进程,它们的核心结构体中的struct file*指向同一个struct file结构体对象,这样就挂接到同一个共享内存了。
另外,如果是磁盘上有名称的文件,创建 struct file,并且映射到进程地址空间,这是通过mmap完成的,这个文件可以是库文件,所以库文件就是通过mmap完成加载到共享区的!
所以,通过共享内存挂接的过程,我们就可以理解库文件加载的过程,它们两者本质是相同的。
总结:
以上就是本期博客分享的全部内容啦!如果觉得文章还不错的话可以三连支持一下,你的支持就是我前进最大的动力!
技术的探索永无止境! 道阻且长,行则将至!后续我会给大家带来更多优质博客内容,欢迎关注我的CSDN账号,我们一同成长!
(~ ̄▽ ̄)~