进程间的通信
为什么需要进程间通信?
操作系统中的进程隔离机制确保了各个进程在独立的内存空间中运行,并通过严格的机制防止进程间的非法访问。然而,在某些场景下,进程间的通信(Inter-process Communication, IPC)是必要的。例如,Windows操作系统中的剪贴板功能可以让用户轻松地从一个程序中复制信息到另一个程序,即使两个程序彼此独立;或当一个应用程序的不同组件运行在多个进程中时,这些进程之间可能需要通信。因此,IPC机制的出现是为了满足此类需求。
进程间通信的目的
进程间通信的目的主要包括以下几点:
- 数据传输:一个进程需要将其数据发送给另一个进程。
- 资源共享:多个进程需要共享某些资源。
- 事件通知:一个进程需要向其他进程发送消息,通知某些事件的发生(例如,当进程终止时,通知父进程)。
- 进程控制:某些进程需要控制其他进程的执行,例如调试进程需要拦截目标进程的异常并跟踪其状态变化。
IPC的范围
IPC机制既可用于同一台机器上的进程间通信,也可用于跨设备进程的通信(例如远程过程调用,RPC)。跨机器通信通常依赖网络连接实现。
值得注意的是,进程间的通信不仅限于数据交换,共享资源(如文件或内存块)同样是通信的重要方式。共享资源提供了进程间的协作基础,使得彼此可以通过统一的资源实现间接通信。
IPC机制的分类
在不同的操作系统中,IPC机制的实现方式各不相同。以下是常见的Linux和Android系统中的IPC机制分类:
-
Linux
- 管道(Pipe):用于在父子进程之间传递数据的单向通信机制。
- 共享内存(Shared Memory):不同进程可以访问同一块内存区域,实现高速数据交换。
- 消息队列(Message Queue):通过消息队列实现进程间的消息传递,通常用于非同步数据交换。
- Socket:支持网络通信的通用接口,既可用于同一主机内进程间通信,也可用于跨网络进程通信。
-
Android
- Intent:主要用于应用组件间传递数据,允许不同组件进行数据交换和触发操作。
- Binder:Android特有的高效IPC机制,支持不同进程之间的方法调用。
- AIDL(Android Interface Definition Language):用于定义进程间接口,使不同进程可以通过Binder调用同一个服务。
- ContentProvider:通过统一接口提供数据共享服务,允许不同应用程序间访问数据。
此外,在Linux中还有一些用于进程间同步的机制,例如信号量(Semaphore)和文件锁(File Lock)。在Android系统中,除了以上列出的IPC机制外,还包括 Messenger 和 Broadcast 等用于消息传递的方式。
接下来,我们将基于Android中的Linux内核(版本5.15)的代码,介绍几种主要的IPC机制。可以在 common/include/linux/syscalls.h
文件中查看支持的IPC机制,以下是部分代码示例:
/* ipc/mqueue.c */
... // POSIX 消息队列相关函数
/* ipc/msg.c */
... // System V 消息队列相关函数
/* ipc/sem.c */
... // System V 信号量相关函数
/* ipc/shm.c */
... // System V 共享内存相关函数
/* net/socket.c */
... // Socket 通信相关函数
管道
推荐阅读:
- 一文让你明白,什么是管道(pipe)?
- 【Linux】进程间通信(学习复习兼顾)
什么是管道通信?
- 管道是一种最基本的进程间通信机制。它通过数据流将一个进程的输出连接到另一个进程的输入。
- 管道本质上是内核的一块缓冲区,操作系统将对管道的读写操作转化为对该缓冲区的内存操作。这种方式避免了磁盘I/O带来的性能开销,因此更加高效。
- 管道的使用方式类似文件操作,符合“Linux一切皆文件”的设计理念。
管道通信示例
在shell中,管道符号|
用于将一个命令的输出作为下一个命令的输入。例如:
ls -l | grep string //grep是抓取指令
这里,ls命令列出目录内容,通过管道将输出传递给grep命令,后者筛选包含”string”的行并输出。
管道的特点
1. 亲缘关系:管道通常用于具有亲缘关系(如父子、兄弟关系)的进程间通信。
2. 字节流通信:管道以字节流形式通信,适合两进程间数据交换,不具备消息边界。
3. 数据不保存:管道数据一旦读取便被清除。
4. 单向通信:管道为单向结构,需创建双管道以实现双向通信。
5. 文件接口:管道使用 read、write、close 等文件操作接口,同时属于特殊文件系统(pipefs)。
6. 同步机制:管道自带同步机制。写入操作会在缓冲区满时阻塞,读取操作会在缓冲区空时阻塞。
7. 自动释放:进程退出时,管道资源自动释放。
8. 流式服务:管道支持灵活的数据读写,数据块大小可变。
互斥与同步机制
内核确保管道操作的原子性,避免读取未完成的数据。
- 若缓冲区满,则写操作阻塞;
- 若缓冲区空,则读操作阻塞。
- 单次写入数据不超过 PIPE_BUF(系统管道容量)时,写入具有原子性;
- 超出 PIPE_BUF 则不保证原子性。
匿名管道
常见的管道为匿名管道,其API如下:
int pipe(int fd[2])
{
fd[0] = open(read);
fd[1] = open(write);
...
}
-
参数:
- fd:文件描述符数组,其中fd[0]表示读端, fd[1]表示写端
- 返回值:成功返回0,失败返回错误代码
-
匿名管道的输出型参数:
一般我们会自己创建一个int fd[2],然后使用pipe(fd)
使得fd的 0 下标和 1 下标保存 open 的文件描述符,这样外部就可以使用close(fd[0])
操作读段。也就是看似传入了参数 fd,实际上是输出了两个文件描述符供外部代码调用,这就叫做输出型参数。 -
从这里也可以看出,之所以管道只适合用于亲缘进程,是因为匿名管道的操作需要获取pipe 创建的读端 fd[0] 和写端 fd[1],子进程会继承父进程的文件描述符。
使用示例:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main() {
int fd[2];
pid_t pid;
char buffer[1024];
const char *message = "Hello from parent";
// 创建管道
if (pipe(fd) == -1) {
perror("pipe");
return 1;
}
// fork一个子进程
pid = fork();
if (pid < 0) {
perror("fork");
return 1;
} else if (pid == 0) {
// 子进程 - 关闭写端
close(fd[1]);
read(fd[0], buffer, sizeof(buffer));
printf("Child process received: %s\n", buffer);
close(fd[0]);
} else {
// 父进程 - 关闭读端
close(fd[0]);
write(fd[1], message, strlen(message) + 1);
close(fd[1]);
}
return 0;
}
管道读写规则
- 无数据可读时:
- O_NONBLOCK 关闭:read调用阻塞,等待数据。
- O_NONBLOCK 打开:read 返回 -1,errno 设置为 EAGAIN。
- 缓冲区已满时:
- O_NONBLOCK 关闭:write 调用阻塞,等待空间。
- O_NONBLOCK 打开:write 返回 -1,errno 设置为 EAGAIN。
- 关闭写端时:若所有写端关闭,read 返回0。
- 关闭读端时:若所有读端关闭,write 会引发 SIGPIPE 信号,导致进程退出。
- 原子性:写入量不大于 PIPE_BUF 时,操作具备原子性;否则不保证原子性。
题外话:父子进程间的通信可以使用全局变量实现吗?
不行!因为创建子进程的方式基于 fork()
,而为了提高效率,fork() 使用“写时复制”机制, 进程在写入共享内存前只共享只读的内存地址,修改后各自保持独立的内存副本,因此全局变量无法在父子进程之间实现通信。
命名管道
命名管道也称为FIFO(First In, First Out),遵循先进先出的原则。匿名管道的数据缓存在内存中,进程退出后自动清除,而命名管道则在文件系统中创建文件,通过路径标识和访问。命名管道不同于匿名管道的地方在于它提供一个路径名,可以在文件系统中永久存储。
特点:
- FIFO特性:命名管道按照先进先出的顺序工作,最先写入的数据会最先读出。
- 支持非亲缘关系进程通信:只要访问路径的权限满足条件,任意进程都可以通过命名管道通信。
- 文件操作特性:命名管道作为文件存在,可以使用文件的操作接口来处理,如
open
、read
和write
。 - 阻塞机制:
- 当进程以只读模式打开管道文件时,若没有进程写入数据,该进程将阻塞直到有数据可读。
- 当进程以只写模式打开管道文件时,若没有进程在读取,该进程也会阻塞,直到有进程打开读端。
- 若同时以读写模式(
O_RDWR
)打开命名管道,不会发生阻塞。
命名管道的使用
命名管道的操作方式类似于普通管道,但需要先用mkfifo
命令或mkfifo()
函数创建管道文件。与匿名管道相比,命名管道存在于硬盘上,因此需要先调用open()
打开。使用open()
时,可能出现以下情况:
- 以读写模式(
O_RDWR
)打开命名管道时不会阻塞。 - 以只读模式(
O_RDONLY
)打开时会阻塞,直到有进程以写模式打开管道。 - 以只写模式(
O_WRONLY
)打开时会阻塞,直到有进程以读模式打开管道。
之所以有这几条阻塞机制,是因为命名管道的实现会将数据缓存在内存中,只有在有进程连接到读端时才会将数据写入磁盘。换句话说,当没有读端连接的时候,系统中管道即将要写入的文件里面是空的。
示例代码
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <string.h>
int main() {
const char *fifoPath = "/tmp/myfifo";
char buffer[1024];
const char *message = "Hello from writer";
// 创建命名管道
if (mkfifo(fifoPath, 0666) == -1) {
perror("mkfifo");
return 1;
}
// 父进程写入数据
if (fork() == 0) {
int fd = open(fifoPath, O_WRONLY);
if (fd == -1) {
perror("open write");
return 1;
}
write(fd, message, strlen(message) + 1);
close(fd);
} else {
// 子进程读取数据
int fd = open(fifoPath, O_RDONLY);
if (fd == -1) {
perror("open read");
return 1;
}
read(fd, buffer, sizeof(buffer));
printf("Received message: %s\n", buffer);
close(fd);
}
// 删除命名管道文件
unlink(fifoPath);
return 0;
注意事项
- 阻塞问题:使用命名管道时应注意阻塞规则,避免死锁。确保在读进程和写进程之间进行适当的同步。
- 文件清理:命名管道在文件系统中创建,因此在不再需要时应调用unlink()删除,以释放资源。
命名管道是一种灵活的IPC机制,适用于需要非亲缘进程间通信的场景。其特点使它既能提供文件操作的便捷性,又具有内存缓冲的高效性。
共享内存
共享内存是指多个进程可以同时访问同一块内存区域的机制。多个进程可以把同一块内存映射到它们自己的地址空间中,并且可以直接读写这块内存,就好像它们都拥有这块内存一样。
共享内存实现进程间通信是进程间通信最快的。(相比较管道共享内存不需要两次拷贝)
共享内存的工作原理
-
虚拟地址空间与共享区:每个进程都有独立的虚拟地址空间,其中在栈区和堆区之间留有一块较大的空内存区域,称为共享区。在该区域,进程可以映射共享内存。
-
共享内存的分配和映射:当需要申请共享内存时,操作系统会在物理内存中分配一块区域,然后通过页表将该物理内存映射到各进程的共享区中。不同进程通过映射到同一物理内存区域,从而实现对该内存块的共享访问。
-
内核空间中的共享内存:共享的内存实际属于内核空间,但在用户空间中,进程会通过虚拟地址间接访问该内存块。内核会控制和管理共享内存的分配和权限,确保进程之间的隔离和共享。
-
共享内存的标识:共享内存由一个唯一的
key
标识。多个进程只需约定使用同一个key
,即可访问同一块共享内存区域。
对比:
- 匿名管道通过约定一个文件来进行通信。
- 共享内存通过约定一个唯一的
key
来实现通信。
共享内存的步骤
注意:以下代码基于内核版本5.15
Step1. 创建内存共享区
进程 1 首先通过操作系统提供的 API 向内存申请一块共享区域。在 Linux 环境中,可以使用 shmget
函数来完成这一操作。生成的共享内存段将与特定的 key
(即 shmget
的第一个参数)绑定。
asmlinkage long sys_shmget(key_t key, size_t size, int flag);
- key:共享内存的唯一标识符。用户可使用 ftok(const char *pathname, int proj_id) 函数生成一个唯一的 key 值。
- size:共享内存的大小(字节)。建议设置为页大小的整数倍(通常为 4KB),以便内存对齐。
- shmflg:共享内存的选项和权限。
- IPC_CREAT:若共享内存已存在,则返回共享内存标识符;若不存在,则创建新的共享内存段。
- IPC_EXCL:必须与 IPC_CREAT 一起使用。若指定的共享内存段已存在,则返回错误。
- 可以通过按位或运算符(|)来设置共享内存权限(如 0666 表示可读写权限)。
Step2. 映射内存共享区
共享内存创建后,需要将它映射到进程的地址空间中才能进行访问。在 Linux 中,这一步使用 shmat 函数实现。
asmlinkage long sys_shmat(int shmid, char __user *shmaddr, int shmflg);
Step3.访问内存共享区
进程 1 创建了共享内存区后,其他进程可以通过相同的 key 值来访问该内存区域。具体来说,进程 2 通过 shmget 函数,传入与进程 1 相同的 key 值即可获得共享内存标识符,然后执行 shmat 函数将共享内存映射到它的地址空间。
Step4. 进程间通信
在成功创建和映射共享内存后,进程之间可以直接通过该区域进行信息交换。然而,匿名共享内存并不提供同步机制,因此进程间通信时需自行实现同步,如使用信号量或互斥锁来确保访问顺序,避免数据竞争。
Step5. 撤销内存映射区
进程完成共享内存访问后,应撤销映射以释放进程的地址空间。可以使用 shmdt 函数完成这一操作。
asmlinkage long sys_shmdt(char __user *shmaddr);
- shmaddr:共享内存映射的地址,即 shmat 返回的地址指针。
Step6. 删除内存共享区
当所有进程都完成共享内存使用后,应删除共享内存区以释放系统内存。可以使用 shmctl 函数来删除共享内存。
asmlinkage long sys_shmctl(int shmid, int cmd, struct shmid_ds __user *buf);
- shmid:共享内存标识符,通过 shmget 获取。
- cmd:控制命令。删除共享内存时使用 IPC_RMID。
- buf:指向 shmid_ds 结构体的指针(可为 NULL)。
示例代码:
以下是使用 System V 接口创建和删除共享内存的示例代码:
#include <stdio.h>
#include <stdlib.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
#include <string.h>
int main() {
// 创建唯一的 key
key_t key = ftok("shmfile", 65);
// 创建共享内存段
int shmid = shmget(key, 4096, 0666 | IPC_CREAT);
if (shmid == -1) {
perror("shmget");
exit(1);
}
// 映射共享内存
char *data = (char*) shmat(shmid, NULL, 0);
// 写入数据
strcpy(data, "Hello, shared memory!");
// 打印数据
printf("Data in shared memory: %s\n", data);
// 撤销映射
shmdt(data);
// 删除共享内存
shmctl(shmid, IPC_RMID, NULL);
return 0;
}
共享内存的数据结构
在系统当中可能会有大量的进程在进行通信,因此系统当中就可能存在大量的共享内存,那么操作系统必然要对其进行管理,所以共享内存除了在内存当中真正开辟空间之外,为了维护管理共享内存,系统一定要"描述"共享内存
下面是在内核中表示一块共享内存的数据结构,在common/ipc/shm.c
中
struct shmid_kernel /* private to the kernel */
{
struct kern_ipc_perm shm_perm; /* 用于管理共享内存段的权限结构体,包含访问权限和所有者信息。 */
struct file *shm_file; /* 指向共享内存段的文件对象的指针,用于文件系统中的共享内存对象管理。 */
unsigned long shm_nattch; /* 附加计数器,表示当前有多少个进程附加(attached)到该共享内存段。 */
unsigned long shm_segsz; /* 共享内存段的大小(以字节为单位),表示分配的共享内存大小。 */
time64_t shm_atim; /* 上次访问共享内存的时间戳,用于跟踪最近的访问时间。 */
time64_t shm_dtim; /* 上次分离共享内存的时间戳,用于跟踪最近的分离时间。 */
time64_t shm_ctim; /* 最后一次修改共享内存控制结构的时间戳。 */
struct pid *shm_cprid; /* 创建共享内存段的进程 ID(PID),用于记录创建者进程。 */
struct pid *shm_lprid; /* 最后一次附加或分离共享内存段的进程 ID。 */
struct ucounts *mlock_ucounts; /* 计数指针,记录当前进程锁定共享内存段的次数,用于资源限制。 */
/*
* 创建共享内存对象的任务结构体指针,使用 task_lock(shp->shm_creator)
* 可以同步对共享内存创建者信息的访问。
*/
struct task_struct *shm_creator; /* 共享内存段的创建者任务结构体指针,用于跟踪哪个任务创建了共享内存段。 */
/*
* 按创建者组织的共享内存对象链表,用于快速定位某个任务创建的共享内存段。
* 读/写该列表时需要获取 shm_creator 的 task_lock。
* 如果 list_empty() 返回 true,则表明创建者任务已不再活动。
*/
struct list_head shm_clist; /* 链表节点,用于按创建者组织的共享内存段链表。 */
struct ipc_namespace *ns; /* 指向共享内存段所在的 IPC 命名空间,用于隔离不同命名空间的 IPC 对象。 */
} __randomize_layout;
描述共享内存的数据结构里保存了一个ipc_params结构体,这个结构体保存了IPC(进程将通信)的关键信息
common/include/linux/ipc.h
/* used by in-kernel data structures */
struct kern_ipc_perm {
spinlock_t lock; /* 自旋锁,用于保护结构体的访问,以避免多核环境下的竞态条件 */
bool deleted; /* 标识 IPC 资源是否被标记为删除。删除后,其他进程无法访问该资源 */
int id; /* IPC 资源的标识符,用于唯一标识资源(例如共享内存段的 ID) */
key_t key; /* 资源的键值,由用户进程指定,用于查找和访问特定的 IPC 对象 */
kuid_t uid; /* 拥有该 IPC 资源的用户 ID,用于权限检查 */
kgid_t gid; /* 拥有该 IPC 资源的组 ID,用于权限检查 */
kuid_t cuid; /* 创建该 IPC 资源的用户 ID,用于记录资源的初始创建者 */
kgid_t cgid; /* 创建该 IPC 资源的组 ID,用于记录资源的初始创建组 */
umode_t mode; /* IPC 资源的访问模式,定义用户和组的读、写、执行权限 */
unsigned long seq; /* 序列号,用于唯一标识 IPC 资源。每次创建新资源时递增,用于避免 ID 重复 */
void *security; /* 安全模块(例如 SELinux)使用的指针,用于扩展安全功能 */
struct rhash_head khtnode; /* 哈希链表节点,用于将 IPC 资源插入到内核哈希表中,便于快速查找 */
struct rcu_head rcu; /* RCU(读取-复制-更新)头,用于资源在销毁时的同步管理 */
refcount_t refcount; /* 引用计数,用于跟踪对该 IPC 资源的引用数量,以便在没有引用时销毁资源 */
} ____cacheline_aligned_in_smp __randomize_layout;
共享内存的几个函数
想要在安卓中了解这几个函数的源码可以参考:common/include/linux/syscalls.h
这几个函数的声明都在这里面。
/* ipc/shm.c */
asmlinkage long sys_shmget(key_t key, size_t size, int flag);
asmlinkage long sys_old_shmctl(int shmid, int cmd, struct shmid_ds __user *buf);
asmlinkage long sys_shmctl(int shmid, int cmd, struct shmid_ds __user *buf);
asmlinkage long sys_shmat(int shmid, char __user *shmaddr, int shmflg);
asmlinkage long sys_shmdt(char __user *shmaddr);
具体的实现在common/ipc/shm.c
shmget函数
/*
* ksys_shmget - 创建或获取一个共享内存段。
* @key: 用于标识共享内存段的键值(用户指定)。
* @size: 共享内存段的大小(以字节为单位)。
* @shmflg: 标志位,用于指定创建模式和权限(例如 IPC_CREAT 表示创建新段)。
*
* 该函数使用指定的键值、大小和标志,通过调用通用的 IPC 函数 ipcget() 创建或获取
* 一个共享内存段。成功时返回共享内存段的标识符,否则返回错误代码。
*/
long ksys_shmget(key_t key, size_t size, int shmflg)
{
struct ipc_namespace *ns; /* 当前进程的 IPC 命名空间 */
static const struct ipc_ops shm_ops = { /* IPC 操作的回调函数 */
.getnew = newseg, /* 创建新的共享内存段 */
.associate = security_shm_associate,/* 安全检查和关联 */
.more_checks = shm_more_checks, /* 额外的权限检查 */
};
struct ipc_params shm_params; /* 存储共享内存的参数 */
ns = current->nsproxy->ipc_ns; /* 获取当前进程的 IPC 命名空间 */
/* 初始化共享内存参数 */
shm_params.key = key; /* 设置共享内存的键值 */
shm_params.flg = shmflg; /* 设置共享内存的标志 */
shm_params.u.size = size; /* 设置共享内存的大小 */
/* 调用通用 IPC 函数 ipcget,传递命名空间、共享内存 ID、操作回调和参数 */
return ipcget(ns, &shm_ids(ns), &shm_ops, &shm_params);
}
/*
* SYSCALL_DEFINE3 - 定义 shmget 系统调用
* @shmget: 系统调用名
* @key: 键值,用于标识或创建共享内存段
* @size: 共享内存段的大小
* @shmflg: 创建和权限标志
*
* 通过 ksys_shmget() 实现的实际共享内存分配操作,SYSCALL_DEFINE3 定义了用户空间的
* shmget 系统调用接口,将参数传递给 ksys_shmget 进行处理。
*/
SYSCALL_DEFINE3(shmget, key_t, key, size_t, size, int, shmflg)
{
return ksys_shmget(key, size, shmflg); /* 调用内部实现 */
}
- ksys_shmget 是 shmget 系统调用的核心实现。这个函数接收三个参数:key(共享内存段的唯一标识符)、size(共享内存段的大小)和 shmflg(共享内存段的标志位)。
- 函数的主要任务是将这些参数封装到
ipc_params
结构中,传递给 ipcget 函数,以便后者进行共享内存段的实际创建。 - 而且你这里可以看到这里通过宏定义SYSCALL_DEFINE3 来实际调用ksys_shmget,下面的 shmat 也是一样的处理方式。这个宏帮助内核开发者将系统调用的入口函数与用户空间的调用接口绑定起来。它自动处理参数的获取、类型检查和内核空间的上下文切换,使开发者可以更专注于系统调用的实现逻辑。
ipc_params
结构体:
/*
* struct ipc_params - 用于存储 IPC 操作所需的参数
* @key: IPC 键值,用于唯一标识特定 IPC 对象(如共享内存段或信号量)。
* @flg: 标志位,指定创建或访问模式(例如 IPC_CREAT 表示创建新对象)。
* @u: 存储特定 IPC 类型的参数。
* - size: 用于共享内存的大小(以字节为单位)。
* - nsems: 用于信号量的计数,即信号量集中信号量的数量。
*
* 该结构体包含了 IPC 创建或查找操作所需的基本参数。`u` 联合体用于根据
* IPC 类型来存储特定的参数:当用于共享内存时,存储 `size`;当用于信号量时,存储 `nsems`。
* 这个结构在调用 `ipcget` 函数等 IPC 操作时传递相关信息。
*/
struct ipc_params {
key_t key; /* IPC 键值 */
int flg; /* 创建/访问标志 */
union {
size_t size; /* 共享内存的大小 */
int nsems; /* 信号量的数量 */
} u; /* 存储特定 IPC 类型参数 */
};
namespace
的作用
我们可以看到ksys_shmget 中有一个struct ipc_namespace *ns;
,这个命名空间用于将不同的 IPC 资源(如共享内存、消息队列和信号量)隔离在不同的命名空间中,使得每个命名空间中的进程可以独立访问自己的 IPC 资源,而不会影响到其他命名空间中的进程。简单来说就是让某一组进程只能看到自己这边的共享内存,看不到别人的。
shmat函数
该函数的内容比较多
/*
* do_shmat - 处理进程对共享内存的附加请求
* @shmid: 共享内存段的 ID
* @shmaddr: 共享内存的期望附加地址(用户空间指针)
* @shmflg: 附加的标志,指定权限和附加方式
* @raddr: 返回的附加地址(指向共享内存的地址)
* @shmlba: 附加地址的对齐限制
*
* 该函数用于将共享内存段附加到进程的地址空间。它负责检查访问权限,选择附加地址,
* 并将共享内存段映射到用户空间。
*
* 注意:尽管函数名称包含“shmat”,但它并不是直接的系统调用入口。传入的“raddr”指向
* 内核空间,因此必须有一个封装器函数来处理用户空间到内核空间的转换。
*/
long do_shmat(int shmid, char __user *shmaddr, int shmflg,
ulong *raddr, unsigned long shmlba) {
struct shmid_kernel *shp; /* 指向共享内存段的内核结构 */
unsigned long addr = (unsigned long)shmaddr; /* 用户指定的附加地址 */
unsigned long size; /* 共享内存段的大小 */
struct file *file, *base; /* 文件描述符指针 */
int err; /* 错误代码 */
unsigned long flags = MAP_SHARED; /* mmap 标志 */
unsigned long prot; /* 内存权限 */
int acc_mode; /* 访问模式 */
struct ipc_namespace *ns; /* IPC 命名空间 */
struct shm_file_data *sfd; /* 共享内存文件数据 */
int f_flags; /* 文件访问标志 */
unsigned long populate = 0; /* 内存填充标志 */
err = -EINVAL;
if (shmid < 0) /* 检查共享内存 ID 的合法性 */
goto out;
/* 检查地址对齐情况 */
if (addr) {
if (addr & (shmlba - 1)) { /* 检查是否满足 LBA 对齐要求 */
if (shmflg & SHM_RND) {
addr &= ~(shmlba - 1); /* 按 LBA 对齐 */
if (!addr && (shmflg & SHM_REMAP))
goto out; /* 确保非零地址 */
} else
#ifndef __ARCH_FORCE_SHMLBA
if (addr & ~PAGE_MASK)
#endif
goto out;
}
flags |= MAP_FIXED;
} else if ((shmflg & SHM_REMAP))
goto out;
/* 设置读写权限 */
if (shmflg & SHM_RDONLY) {
prot = PROT_READ;
acc_mode = S_IRUGO;
f_flags = O_RDONLY;
} else {
prot = PROT_READ | PROT_WRITE;
acc_mode = S_IRUGO | S_IWUGO;
f_flags = O_RDWR;
}
if (shmflg & SHM_EXEC) {
prot |= PROT_EXEC;
acc_mode |= S_IXUGO;
}
/* 获取 IPC 命名空间 */
ns = current->nsproxy->ipc_ns;
rcu_read_lock();
shp = shm_obtain_object_check(ns, shmid); /* 获取共享内存段结构 */
if (IS_ERR(shp)) {
err = PTR_ERR(shp);
goto out_unlock;
}
/* 检查访问权限 */
err = -EACCES;
if (ipcperms(ns, &shp->shm_perm, acc_mode))
goto out_unlock;
/* 安全检查 */
err = security_shm_shmat(&shp->shm_perm, shmaddr, shmflg);
if (err)
goto out_unlock;
ipc_lock_object(&shp->shm_perm);
/* 检查共享内存段是否正在被销毁 */
if (!ipc_valid_object(&shp->shm_perm)) {
ipc_unlock_object(&shp->shm_perm);
err = -EIDRM;
goto out_unlock;
}
/* 获取共享内存文件并防止文件指针失效 */
base = get_file(shp->shm_file);
shp->shm_nattch++; /* 共享内存段附加计数增一 */
size = i_size_read(file_inode(base));
ipc_unlock_object(&shp->shm_perm);
rcu_read_unlock();
/* 分配内存并初始化共享内存文件数据结构 */
err = -ENOMEM;
sfd = kzalloc(sizeof(*sfd), GFP_KERNEL);
if (!sfd) {
fput(base);
goto out_nattch;
}
/* 为共享内存段分配文件结构 */
file = alloc_file_clone(base, f_flags,
is_file_hugepages(base) ?
&shm_file_operations_huge :
&shm_file_operations);
err = PTR_ERR(file);
if (IS_ERR(file)) {
kfree(sfd);
fput(base);
goto out_nattch;
}
/* 设置共享内存文件数据 */
sfd->id = shp->shm_perm.id;
sfd->ns = get_ipc_ns(ns);
sfd->file = base;
sfd->vm_ops = NULL;
file->private_data = sfd;
/* 检查 mmap 安全性 */
err = security_mmap_file(file, prot, flags);
if (err)
goto out_fput;
/* 上锁,避免中断 */
if (mmap_write_lock_killable(current->mm)) {
err = -EINTR;
goto out_fput;
}
/* 检查是否可以成功附加到指定地址 */
if (addr && !(shmflg & SHM_REMAP)) {
err = -EINVAL;
if (addr + size < addr)
goto invalid;
if (find_vma_intersection(current->mm, addr, addr + size))
goto invalid;
}
/* 执行内存映射 */
addr = do_mmap(file, addr, size, prot, flags, 0, &populate, NULL);
*raddr = addr;
err = 0;
if (IS_ERR_VALUE(addr))
err = (long)addr;
invalid:
mmap_write_unlock(current->mm);
if (populate)
mm_populate(addr, populate);
out_fput:
fput(file);
out_nattch:
/* 锁定并更新附加计数 */
down_write(&shm_ids(ns).rwsem);
shp = shm_lock(ns, shmid);
shp->shm_nattch--;
/* 判断是否可以销毁共享内存段 */
if (shm_may_destroy(shp))
shm_destroy(ns, shp);
else
shm_unlock(shp);
up_write(&shm_ids(ns).rwsem);
return err;
out_unlock:
rcu_read_unlock();
out:
return err;
}
我们主要关注它做了以下几件事:
- 参数校验:
- 检查 shmid 是否为负数,如果是,则返回 EINVAL 错误。
- 根据 shmaddr 和标志 shmflg 的组合,确定映射地址 addr。如果地址 shmaddr 不为空且未对齐,需要按 SHM_RND 标志处理对齐方式。
- 设置权限和标志:
- 根据 shmflg 设置内存保护标志 prot,访问权限 acc_mode 和文件标志 f_flags。如果设置了 SHM_RDONLY,则权限设为只读,否则设为读写。同时支持 SHM_EXEC 标志,以允许执行权限。
- 获取共享内存对象:
- 通过 shm_obtain_object_check 从当前 IPC 命名空间中获取共享内存段对象 shp,并使用 ipcperms 和 security_shm_shmat 进行权限检查。
- 增加文件引用计数:
- 锁定共享内存对象 shp,增加 shm_nattch 计数,表示共享内存段被附加的次数。获取共享内存段的文件对象 base 并解锁共享内存对象。
- 创建并初始化 shm_file_data 结构:
- 为新的共享内存附加描述符分配内存。若失败,则释放文件对象 base 并返回。
- 通过 alloc_file_clone 创建共享内存文件对象 file。初始化 shm_file_data 结构,将 shm_perm.id、IPC 命名空间 ns 和 base 文件指针存储在 sfd 中。
- 映射共享内存到进程地址空间:
- 检查地址冲突。调用 do_mmap 函数完成共享内存段的实际映射,并返回映射后的地址给 raddr。
- 如果 populate 被设置,则调用 mm_populate 进行内存预分配,以确保页面已载入。
- 清理工作:
- 减少共享内存对象的 shm_nattch 计数,并在适当条件下调用 shm_destroy 以销毁共享内存对象。
- 返回操作结果。
shm_nattch 计数:
shm_nattch表示共享内存段当前被附加(attached)的次数。每个进程将共享内存附加到自己的地址空间时,shm_nattch会增加,表示有新的进程正在使用该共享内存段。相应地,当进程分离共享内存时,shm_nattch会减少。当 shm_nattch 减为 0 时,系统可以安全地释放该共享内存段,避免内存泄漏或非法访问。
shmdt函数
/*
* 分离共享内存段并在标记为销毁时进行释放。
* 释放共享内存段的实际工作是在 shm_close 中完成的。
*/
long ksys_shmdt(char __user *shmaddr)
{
struct mm_struct *mm = current->mm; // 获取当前进程的内存描述符
struct vm_area_struct *vma; // 虚拟内存区域结构体指针
unsigned long addr = (unsigned long)shmaddr; // 将用户提供的地址转换为无符号长整型
int retval = -EINVAL; // 默认返回值:-EINVAL 表示无效参数
#ifdef CONFIG_MMU
loff_t size = 0; // 用于存储共享内存段的大小
struct file *file; // 指向共享内存文件的文件指针
struct vm_area_struct *next; // 指向下一个虚拟内存区域的指针
#endif
// 确保地址对齐,地址必须按页对齐才能正确解除映射
if (addr & ~PAGE_MASK)
return retval;
// 尝试获取内存映射的写锁;如果被中断则返回错误
if (mmap_write_lock_killable(mm))
return -EINTR;
/*
* 定位共享内存段的虚拟内存区域 (VMA) 并解除映射:
* 1. 首先找到从 `shmaddr` 开始的共享内存段 VMA,并确定其大小。
* 2. 然后在指定范围内解除与此共享内存段相关的所有 VMA。
*/
vma = find_vma(mm, addr); // 查找包含或紧邻 `addr` 的 VMA
#ifdef CONFIG_MMU
while (vma) { // 从找到的 VMA 开始迭代
next = vma->vm_next; // 获取下一个 VMA
/*
* 检查此 VMA 是否是通过 mprotect() 或 munmap() 创建的共享内存段
* 或者它是否确实从 `addr` 开始。
*/
if ((vma->vm_ops == &shm_vm_ops) && // 确保这是共享内存段
(vma->vm_start - addr) / PAGE_SIZE == vma->vm_pgoff) {
/*
* 记录与此共享内存段相关的文件。
* 由于重映射可能会导致多个 VMA 指向具有相同偏移的不同文件,
* 我们需要确认这是要解除映射的正确文件。
*/
file = vma->vm_file; // 保存此段的文件指针
size = i_size_read(file_inode(vma->vm_file)); // 获取段的大小
do_munmap(mm, vma->vm_start, vma->vm_end - vma->vm_start, NULL); // 解除此段的映射
retval = 0; // 操作成功
vma = next; // 移动到下一个 VMA
break;
}
vma = next; // 条件不满足时,继续处理下一个 VMA
}
/*
* 在确定段大小后,解除地址范围内与此段匹配的所有 VMA。
* 当到达段的最大可能地址时停止。
*/
size = PAGE_ALIGN(size); // 将大小对齐到页边界
while (vma && (loff_t)(vma->vm_end - addr) <= size) {
next = vma->vm_next;
// 如果 VMA 满足段条件,则解除映射
if ((vma->vm_ops == &shm_vm_ops) &&
((vma->vm_start - addr) / PAGE_SIZE == vma->vm_pgoff) &&
(vma->vm_file == file))
do_munmap(mm, vma->vm_start, vma->vm_end - vma->vm_start, NULL);
vma = next; // 移动到下一个 VMA
}
#else /* CONFIG_MMU */
/*
* 在没有 MMU 的系统中,必须提供共享内存段的确切地址才能将其分离。
*/
if (vma && vma->vm_start == addr && vma->vm_ops == &shm_vm_ops) {
do_munmap(mm, vma->vm_start, vma->vm_end - vma->vm_start, NULL);
retval = 0; // 操作成功
}
#endif
mmap_write_unlock(mm); // 释放内存映射的写锁
return retval; // 返回分离操作的结果
}
该函数用于分离指定的共享内存段并在必要时释放。它会找到传入的shmaddr对应的虚拟内存数据结构vma,检查它的地址是不是正确的,然后调用do_munmap()函数断开对共享内存的连接。注意此操作并不会销毁共享内存,即使没有进程连接到它也不会,只有手动调用shmctl(id, IPC_RMID, NULL)才能销毁。
shmctl函数
switch (cmd) {
case IPC_INFO: {
// 获取系统范围的共享内存配置信息
struct shminfo64 shminfo;
err = shmctl_ipc_info(ns, &shminfo);
if (err < 0)
return err;
if (copy_shminfo_to_user(buf, &shminfo, version))
err = -EFAULT;
return err;
}
case SHM_INFO: {
// 获取当前命名空间下的共享内存状态信息
struct shm_info shm_info;
err = shmctl_shm_info(ns, &shm_info);
if (err < 0)
return err;
if (copy_to_user(buf, &shm_info, sizeof(shm_info)))
err = -EFAULT;
return err;
}
case SHM_STAT:
case SHM_STAT_ANY:
case IPC_STAT: {
// 获取特定共享内存段的信息
err = shmctl_stat(ns, shmid, cmd, &sem64);
if (err < 0)
return err;
if (copy_shmid_to_user(buf, &sem64, version))
err = -EFAULT;
return err;
}
case IPC_SET:
// 修改共享内存段的属性
if (copy_shmid_from_user(&sem64, buf, version))
return -EFAULT;
fallthrough;
case IPC_RMID:
// 删除共享内存段
return shmctl_down(ns, shmid, cmd, &sem64);
case SHM_LOCK:
case SHM_UNLOCK:
// 锁定或解锁共享内存段
return shmctl_do_lock(ns, shmid, cmd);
default:
return -EINVAL;
}
shmctl()总体就是一个switch语句,大多数做的是读取信息的或者设置标志位的工作
消息队列
还是一样的,我们首先现在kernel 5.15中common/ipc/shm.c
里面定义的一些重要函数,这里我们以POSIX 消息队列相关函数为例
// 打开一个命名消息队列
asmlinkage long sys_mq_open(const char __user *name, int oflag, umode_t mode, struct mq_attr __user *attr);
// 删除一个命名消息队列
asmlinkage long sys_mq_unlink(const char __user *name);
// 向消息队列发送带有超时时间的消息
asmlinkage long sys_mq_timedsend(mqd_t mqdes, const char __user *msg_ptr, size_t msg_len, unsigned int msg_prio, const struct __kernel_timespec __user *abs_timeout);
// 从消息队列接收带有超时时间的消息
asmlinkage long sys_mq_timedreceive(mqd_t mqdes, char __user *msg_ptr, size_t msg_len, unsigned int __user *msg_prio, const struct __kernel_timespec __user *abs_timeout);
// 注册一个通知,当消息队列有新消息时触发通知
asmlinkage long sys_mq_notify(mqd_t mqdes, const struct sigevent __user *notification);
// 获取或设置消息队列的属性
asmlinkage long sys_mq_getsetattr(mqd_t mqdes, const struct mq_attr __user *mqstat, struct mq_attr __user *omqstat);
// 32位系统中从消息队列接收带有超时时间的消息(用于时间结构的兼容性)
asmlinkage long sys_mq_timedreceive_time32(mqd_t mqdes,
char __user *u_msg_ptr,
unsigned int msg_len, unsigned int __user *u_msg_prio,
const struct old_timespec32 __user *u_abs_timeout);
// 32位系统中向消息队列发送带有超时时间的消息(用于时间结构的兼容性)
asmlinkage long sys_mq_timedsend_time32(mqd_t mqdes,
const char __user *u_msg_ptr,
unsigned int msg_len, unsigned int msg_prio,
const struct old_timespec32 __user *u_abs_timeout);
关于消息队列,更经典的实现是在安卓的 Handler 中,后续我们会提到。
消息队列的原理
消息队列是一种用于进程间通信的机制,通过内核中的消息链表实现数据的有序存储与传递,允许多个进程并发读写。相比其他进程间通信方式,消息队列具备以下特点与优点:
- 消息队列的标识与结构:
- 每个消息队列由一个唯一的消息队列标识符表示,便于多个进程定位与访问。
- 消息队列中存储的消息是按顺序排列的,形成一个链表。
- 通信特点:
- 允许多个进程同时访问,可支持多对多的进程通信。
- 消息发送方与接收方需要事先约定好消息的格式,包括数据类型与大小,以确保数据被正确解读和处理。
- 优于其他通信方式:
- 相比信号:信号的承载信息量有限,仅适合发送简单的通知,而消息队列可以传递更丰富的数据信息。
- 相比管道:管道只能处理无格式的字节流,无法按数据类型进行组织;消息队列则支持结构化的消息传递,便于解析与处理。
- 数据传输过程:
- 消息队列实现数据传输需进行两次数据复制:
- 从发送进程(进程 A)拷贝数据到消息队列。
- 从消息队列将数据拷贝到接收进程(进程 B)。
Socket
首先要说的是这里所说的进城间通信的Socket不是传统意义上学习TCP/IP协议接触到的Socket。网络通信领域的叫做Network Socket,对于运行在同一机器内的进程间通信,Network Socket也完全能够胜任,只不过执行效率未必让人满意。
UNIX Domain Socket(UDS)是专门针对单机内的进程间通信提出来的,有时也被称为IPC Socket。两者虽然在使用方法上类似,但内部实现原理却有着很大区别。大家所熟识的Network Socket是以TCP/IP协议栈为基础的,需要分包、重组等一系列操作。而UDS因为是
本机内的“安全可靠操作”,实现机制上并不依赖于这些协议。
Android中使用最多的一种IPC机制是Binder,其次就是UDS。
/* net/socket.c */
// 创建一个套接字
asmlinkage long sys_socket(int domain, int type, int protocol);
// 创建一对无名的、相互连接的套接字
asmlinkage long sys_socketpair(int domain, int type, int protocol, int __user *sv);
// 绑定套接字到指定的地址
asmlinkage long sys_bind(int sockfd, struct sockaddr __user *addr, int addrlen);
// 将套接字设置为被动模式,准备接受连接
asmlinkage long sys_listen(int sockfd, int backlog);
// 接受连接请求并返回新的套接字描述符
asmlinkage long sys_accept(int sockfd, struct sockaddr __user *addr, int __user *addrlen);
// 连接到指定地址的套接字
asmlinkage long sys_connect(int sockfd, struct sockaddr __user *addr, int addrlen);
// 获取套接字的本地地址
asmlinkage long sys_getsockname(int sockfd, struct sockaddr __user *addr, int __user *addrlen);
// 获取套接字的远程地址
asmlinkage long sys_getpeername(int sockfd, struct sockaddr __user *addr, int __user *addrlen);
// 发送数据到指定地址
asmlinkage long sys_sendto(int sockfd, void __user *buf, size_t len, unsigned flags,
struct sockaddr __user *dest_addr, int addrlen);
// 从指定地址接收数据
asmlinkage long sys_recvfrom(int sockfd, void __user *buf, size_t len, unsigned flags,
struct sockaddr __user *src_addr, int __user *addrlen);
// 设置套接字的选项
asmlinkage long sys_setsockopt(int sockfd, int level, int optname,
char __user *optval, int optlen);
// 获取套接字的选项
asmlinkage long sys_getsockopt(int sockfd, int level, int optname,
char __user *optval, int __user *optlen);
// 关闭套接字的部分或全部连接
asmlinkage long sys_shutdown(int sockfd, int how);
// 发送消息到套接字
asmlinkage long sys_sendmsg(int sockfd, struct user_msghdr __user *msg, unsigned flags);
// 从套接字接收消息
asmlinkage long sys_recvmsg(int sockfd, struct user_msghdr __user *msg, unsigned flags);
RPC(Remote Procedure Calls)
RPC是指远程过程调用,也就是说两台服务器A,B,一个应用部署在A服务器上,想要调用B服务器上应用提供的函数/方法,由于不在一个内存空间,不能直接调用,需要通过网络来表达调用的语义和传达调用的数据。
RPC 常用在远程控制软件中。
一个完整地RPC需要经历那些过程?
- 远程服务之间建立通讯协议
- 寻址:服务器(如主机或IP地址)以及特定的端口,方法的名称名称是什么
- 通过序列化和反序列化进行数据传递
- 将传递过来的数据通过java反射原理定位接口方法和参数
- 暴露服务:用map将寻址的信息暴露给远方服务(提供一个endpoint URI或者一个前端展示页面)
- 多线程并发请求业务
在这些的基础上,可以对其进行简化,不必关注其中复杂的操作,如调用方的序列化,网络传输调用,反序列化。可以像Spring的 AOP 一样,采用动态代理的技术,通过字节码增强对方法进行拦截增强,以便于增加需要的额外处理逻辑,上述操作都可以通过动态代理来解决。
由服务提供者给出业务接口声明,在调用方的程序里面,RPC 框架根据调用的服务接口提前生成动态代理实现类,并通过依赖注入等技术注入到声明了该接口的相关业务逻辑里面。该代理实现类会拦截所有的方法调用,在提供的方法处理逻辑里面完成一整套的远程调用,并把远程调用结果返回给调用方,这样调用方在调用远程方法的时候就获得了像调用本地接口一样的体验。
可以看以下博客了解更多:
https://juejin.cn/post/7243263236622155834
https://juejin.cn/post/6844903928975327246