嵌入式开发学习:多进程之间的通信
多进程编程通信办法
一、简介
接续上次的讨论,我们已经知道了进程是内存隔离的,它们拥有各自独立的虚拟地址空间。这个设计保证了安全性和稳定性,但也带来了一个新问题:进程之间如何合法地交换数据?
这就是进程间通信 (Inter-Process Communication, IPC) 的全部意义。
所有IPC机制的共同底层原理是:必须借助操作系统内核(Kernel)。
因为只有内核有权限访问所有物理内存,也只有内核能充当这个“可信的中间人”。用户态的进程A无法直接操作进程B的内存,它只能向内核发起系统调用,请求内核将数据从A的地址空间复制到B的地址空间。
唯一的例外是共享内存,我们稍后会讲,它是一种“作弊”的、最高效的方式。
让我们来深入探讨几种最核心的IPC机制。
二、正文
1、匿名管道 (Anonymous Pipes)
这是最简单、最经典的IPC形式,通常只用于具有亲缘关系的进程之间(例如父子进程)。
①核心思想
pipe() 是一个系统调用,它请求内核在内核空间中开辟一块内存缓冲区(大小通常为4KB或64KB)。这个缓冲区不占用磁盘,纯粹在内存中。
②文件描述符
内核返回两个文件描述符(File Descriptor, FD)给调用它的进程:
-
fd[0]:管道的读取端 -
fd[1]:管道的写入端
③fork() 的魔法
当进程调用 fork() 时,子进程会完整继承父进程的文件描述符表。这意味着,父子进程现在同时拥有指向同一个内核缓冲区的 fd[0] 和 fd[1]。
④通信流
-
为了实现单向通信(例如父写、子读),父进程会关闭它的读取端
close(fd[0]),子进程会关闭它的写入端close(fd[1])。 -
父进程调用
write(fd[1], ...)。这是一个系统调用,CPU切换到内核态,内核将数据从父进程的用户空间复制到内核的管道缓冲区。 -
如果缓冲区满了,
write()调用会阻塞(进程状态变为Waiting),直到子进程读取数据腾出空间。 -
子进程调用
read(fd[0], ...)。这也是一个系统调用。内核将数据从管道缓冲区复制到子进程的用户空间。 -
如果缓冲区是空的,
read()调用会阻塞,直到父进程写入数据。
⑤ 底层阻塞/唤醒原理
当我们说 read() 或 write() 阻塞 (Block) 时,底层发生了什么?
-
发起系统调用: 进程A(比如读取者)调用
read(fd[0], ...)。CPU 触发软中断,从用户态切换到内核态。 -
内核检查: 内核接管后,查看
fd[0]指向的内核缓冲区。发现缓冲区是空的。 -
进入等待: 内核不能空手返回。它将进程A的 PCB(进程控制块)中的状态从 Running(运行)修改为 Waiting(阻塞/睡眠)。
-
上下文切换: 内核保存进程A的CPU寄存器(PC, SP等)到其PCB中。
-
调度新进程: 内核的调度器 (Scheduler) 启动,选择一个Ready(就绪)状态的进程B,将其PCB中的寄存器加载到CPU,切换页表,然后返回用户态。进程B开始执行。
-
(另一边)写入数据: 某个时刻,进程C(写入者)调用
write(fd[1], ...),内核将数据放入缓冲区。 -
唤醒操作: 内核在写入数据后,会检查是否有进程正在Waiting这个缓冲区。它发现了进程A。
-
状态变更: 内核将进程A的PCB状态从 Waiting 修改为 Ready,并将其放入就绪队列。
-
(未来某个时刻)再次调度: 当CPU时钟中断发生,调度器再次运行时,它可能会选择进程A。
-
恢复执行: 内核恢复进程A的CPU寄存器,切换回进程A的页表。进程A从内核态返回用户态,
read()调用此时才返回,带着它读到的数据。
总结: "阻塞"的本质是内核改变进程的PCB状态并执行一次上下文切换。进程完全“冻结”,不消耗任何CPU时间,直到另一个进程的动作触发内核将其唤醒。
⑥ 匿名管道代码示例
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>
int main() {
int pipe_fd[2]; // pipe_fd[0] 是读取端, pipe_fd[1] 是写入端
pid_t pid;
char buffer[128];
// 1. 创建管道
// 必须在 fork() 之前创建,这样子进程才能继承文件描述符
if (pipe(pipe_fd) < 0) {
perror("pipe create error");
exit(1);
}
pid = fork();
if (pid < 0) {
perror("fork error");
exit(1);
}
else if (pid == 0) {
// --- 子进程 (读取者) ---
printf("[子进程] (PID: %d) 启动...\n", getpid());
// 关键:关闭不需要的写入端
close(pipe_fd[1]);
printf("[子进程] 正在等待父进程的消息...\n");
// read 会阻塞,直到管道中有数据
ssize_t bytes_read = read(pipe_fd[0], buffer, sizeof(buffer));
if (bytes_read > 0) {
buffer[bytes_read] = '\0'; // 确保字符串结束
printf("[子进程] 收到消息: '%s'\n", buffer);
} else {
perror("child read error");
}
// 关闭读取端
close(pipe_fd[0]);
printf("[子进程] 退出。\n");
exit(0);
}
else {
// --- 父进程 (写入者) ---
printf("[父进程] (PID: %d) 启动...\n", getpid());
// 关键:关闭不需要的读取端
close(pipe_fd[0]);
const char *message = "Hello, my child!";
printf("[父进程] 准备发送消息: '%s'\n", message);
sleep(1); // 模拟一些工作,确保子进程先阻塞在read上
// write 是一个系统调用,将数据复制到内核缓冲区
write(pipe_fd[1], message, strlen(message));
// 关闭写入端。这很重要!
// 当所有写入端都关闭时,读取端 read() 会返回 0 (EOF)
close(pipe_fd[1]);
// 等待子进程结束
wait(NULL);
printf("[父进程] 退出。\n");
}
return 0;
}
2、命名管道 (FIFO)
匿名管道的缺点是它没有名字,只能被 fork() 继承,因此只能用于有亲缘关系的进程。如果两个毫不相干的进程想通信怎么办?
① 核心思想
命名管道 (FIFO, First-In-First-Out) 是匿名管道的升级版。它在文件系统中拥有一个路径名(就像一个真实文件)。
② 底层原理
你使用 mkfifo("my_pipe", ...) 创建它时,内核会在文件系统中创建一个特殊类型的文件 (inode)。
-
这个inode不指向任何磁盘上的数据块。
-
它指向的其实就是和匿名管道完全相同的内核内存缓冲区。
这样,任何两个进程,只要它们知道这个文件的路径名并且有权限,就可以通过 open() 来打开它。
-
进程A:
int fd_write = open("my_pipe", O_WRONLY);(会阻塞,直到有人打开读取端) -
进程B:
int fd_read = open("my_pipe", O_RDONLY);
一旦双方都打开,它们就获得了指向同一个内核缓冲区的文件描述符,通信原理(read/write的阻塞、唤醒)与匿名管道完全一致。
③ 命名管道代码示例
你需要编译两个独立的程序:
fifo_writer.c (写入者)
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#define FIFO_PATH "/tmp/my_fifo"
int main() {
// 1. 创建 FIFO (如果它不存在)
// mkfifo 是一个系统调用
if (mkfifo(FIFO_PATH, 0666) < 0) {
perror("mkfifo error (可能已存在)");
// EEXIST (File exists) 不是一个致命错误
}
printf("[Writer] 正在打开 FIFO (O_WRONLY)...\n");
// 2. 打开 FIFO
// open 会阻塞,直到另一个进程以 O_RDONLY 打开它
int fd = open(FIFO_PATH, O_WRONLY);
if (fd < 0) {
perror("writer open error");
exit(1);
}
printf("[Writer] FIFO 已打开,准备写入...\n");
const char *msg = "Hello from FIFO Writer!";
write(fd, msg, strlen(msg));
close(fd);
printf("[Writer] 消息已发送,关闭 FIFO。\n");
// (可选) 删除 FIFO
// unlink(FIFO_PATH);
return 0;
}
fifo_reader.c (读取者)
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#define FIFO_PATH "/tmp/my_fifo"
int main() {
char buffer[128];
// 注意:读取者通常不创建 FIFO,它假设写入者已创建
// (在真实世界中,谁先启动不确定,所以双方都尝试创建并忽略 EEXIST 错误是常见做法)
printf("[Reader] 正在打开 FIFO (O_RDONLY)...\n");
// 2. 打开 FIFO
// open 会阻塞,直到另一个进程以 O_WRONLY 打开它
int fd = open(FIFO_PATH, O_RDONLY);
if (fd < 0) {
perror("reader open error");
exit(1);
}
printf("[Reader] FIFO 已打开,等待数据...\n");
// 3. 读取数据
// read 会阻塞,直到 FIFO 中有数据
ssize_t bytes_read = read(fd, buffer, sizeof(buffer));
if (bytes_read > 0) {
buffer[bytes_read] = '\0';
printf("[Reader] 收到消息: '%s'\n", buffer);
}
close(fd);
printf("[Reader] 关闭 FIFO。\n");
// 读取者通常负责清理 FIFO
unlink(FIFO_PATH);
return 0;
}
如何运行:
-
编译:
gcc fifo_writer.c -o writergcc fifo_reader.c -o reader -
打开一个终端,运行:
./reader(它会阻塞在open) -
打开另一个终端,运行:
./writer(它会写入,然后两个程序同时结束)
3、共享内存 (Shared Memory)
这是最高效的IPC机制,因为它完全绕过了内核的数据复制。
① 核心思想
我们之前说,进程A和B的虚拟地址空间是隔离的。0x1000 在A中和 0x1000 在B中指向不同的物理内存。
共享内存的“作弊”之处在于:它请求内核**“掰弯”这个规则。它让内核创建一块物理内存**,然后把这块物理内存同时映射 (Map) 到A和B的虚拟地址空间中。
② 底层原理 (CPU/MMU 层面)
-
创建共享段: 进程A调用
shmget()系统调用。内核收到请求,在物理RAM中分配一块内存(例如,4KB)。内核为这块内存建立一个唯一的ID(shmid)。 -
映射 (Attach):
-
进程A调用
shmat(shmid, ...)。这是一个关键的系统调用。CPU切换到内核态,内核执行以下操作:-
内核在进程A的页表 (Page Table) 中找到一个空闲的条目。
-
内核将这个条目指向
shmget()创建的物理内存地址。 -
shmat()返回这个条目对应的虚拟地址(例如0xB6F00000)给进程A。
-
-
进程B(知道
shmid)也调用shmat(shmid, ...)。-
内核在进程B的页表中也找到一个空闲条目。
-
内核将这个条目指向同一块物理内存地址。
-
shmat()返回这个条目对应的虚拟地址(例如0xB6E80000)给进程B。
-
-
注意: 虚拟地址在A和B中可能不同,但它们对应的页表项都指向同一块物理RAM。
-
高速访问:
-
进程A执行:
*(char *)0xB6F00000 = 'X'; -
CPU(用户态)执行
MOV指令。 -
CPU的MMU(内存管理单元)查询进程A的页表,将虚拟地址
0xB6F00000翻译为物理地址(比如0xPHYS_ADDR)。 -
CPU将 'X' 写入物理内存
0xPHYS_ADDR。 -
全程没有系统调用,没有内核介入。
-
-
读取:
-
进程B执行:
char c = *(char *)0xB6E80000; -
CPU的MMU查询进程B的页表,将虚拟地址
0xB6E80000翻译为同一个物理地址0xPHYS_ADDR。 -
CPU从物理内存
0xPHYS_ADDR中读取 'X'。
-
代价:
-
0次数据复制: 数据不需要在 "用户A -> 内核 -> 用户B" 之间复制。它就在那里。
-
无阻塞:
read/write只是MOV指令,它们不会阻塞。 -
同步问题: 这是最大的代价。如果A正在写入数据(比如一个复杂的结构体),而B中途开始读取,B会读到“一半新一半旧”的垃圾数据。共享内存必须配合同步机制(如信号量)使用。
③ 共享内存代码示例
(注意:为了简化,本示例没有加锁/信号量,这在生产中是极其危险的!)
shm_writer.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <string.h>
#define SHM_KEY 0x1234 // 共享内存的Key (一个"名字")
int main() {
int shmid;
char *shared_memory;
// 1. shmget: 创建或获取共享内存段
// IPC_CREAT: 如果不存在则创建
// 0666: 权限
shmid = shmget(SHM_KEY, 1024, 0666 | IPC_CREAT);
if (shmid < 0) {
perror("shmget error");
exit(1);
}
printf("[Writer] 共享内存段已创建/获取 (ID: %d)\n", shmid);
// 2. shmat: 将共享内存 "附加" 到本进程的虚拟地址空间
// (void *)0 表示让内核自动选择一个空闲的虚拟地址
shared_memory = (char *)shmat(shmid, (void *)0, 0);
if (shared_memory == (char *)-1) {
perror("shmat error");
exit(1);
}
printf("[Writer] 共享内存附加到虚拟地址: %p\n", shared_memory);
// 3. 写入数据 (这就是一个普通的内存指针操作!)
// CPU直接执行 MOV 指令,没有系统调用
const char *msg = "Hello from Shared Memory!";
strcpy(shared_memory, msg);
printf("[Writer] 已向共享内存写入: '%s'\n", msg);
// (为了演示,让写入者等待读取者)
printf("[Writer] 按任意键分离并退出...\n");
getchar();
// 4. shmdt: 分离 (Detach) 共享内存
if (shmdt(shared_memory) < 0) {
perror("shmdt error");
exit(1);
}
printf("[Writer] 共享内存已分离。\n");
return 0;
}
shm_reader.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <string.h>
#define SHM_KEY 0x1234 // 必须和 Writer 使用相同的 Key
int main() {
int shmid;
char *shared_memory;
// 1. shmget: 获取共享内存段 (不创建)
shmid = shmget(SHM_KEY, 1024, 0666);
if (shmid < 0) {
perror("shmget error (请先运行 writer)");
exit(1);
}
printf("[Reader] 共享内存段已获取 (ID: %d)\n", shmid);
// 2. shmat: 附加到本进程的虚拟地址空间
shared_memory = (char *)shmat(shmid, (void *)0, 0);
if (shared_memory == (char *)-1) {
perror("shmat error");
exit(1);
}
printf("[Reader] 共享内存附加到虚拟地址: %p\n", shared_memory);
// 3. 读取数据 (普通的指针操作)
printf("[Reader] 从共享内存读取到: '%s'\n", shared_memory);
// 4. shmdt: 分离
if (shmdt(shared_memory) < 0) {
perror("shmdt error");
exit(1);
}
printf("[Reader] 共享内存已分离。\n");
// 5. shmctl: (可选) 由最后一个进程删除共享内存段
// IPC_RMID: 标记为删除。当附加(attach)计数为0时,内核会释放这块物理内存
if (shmctl(shmid, IPC_RMID, NULL) < 0) {
perror("shmctl(IPC_RMID) error");
}
printf("[Reader] 共享内存段已标记为删除。\n");
return 0;
}
如何运行:
-
编译:
gcc shm_writer.c -o writergcc shm_reader.c -o reader -
终端1:
./writer(它会写入并等待) -
终端2:
./reader(它会立刻读到 writer 写入的数据) -
在终端1按回车,writer 退出。
4、信号量 (Semaphores)
信号量本身不传输数据,它是用来同步其他IPC机制(尤其是共享内存)的。
① 核心思想
信号量是一个内核管理的整数计数器。进程只能对它执行两种原子操作 (Atomic Operations):
-
P (Wait/Down): 尝试将计数器减 1。
-
如果计数器 > 0,则减 1 成功,进程继续执行。
-
如果计数器 == 0,则进程阻塞(内核将其PCB置为Waiting),直到有其他进程对该信号量执行 V 操作。
-
-
V (Signal/Up): 将计数器加 1。
-
加 1 后,内核会检查是否有进程阻塞在该信号量上。
-
如果有,内核唤醒其中一个进程(将其PCB置为Ready)。
-
② 底层原理 (原子性)
P 和 V 操作的原子性 (Atomicity) 是关键。当一个进程在内核态执行 P 操作(检查值、决定阻塞、减1)时,CPU不允许另一个进程也进入内核态操作同一个信号量。
这在单核CPU上通过关闭中断来实现。在多核CPU上,则需要依赖更底层的硬件原子指令(例如 x86 上的 XCHG 或 LOCK CMPXCHG),它们可以锁定内存总线,确保在"读-改-写"一个内存位置的整个过程中,没有其他CPU核可以访问它。
信号量的阻塞/唤醒原理,与管道的 read/write 阻塞原理完全一致(都是内核调度器在操作PCB)。
③ 示例:用信号量保护共享内存
我们只需在 shm_writer.c 和 shm_reader.c 的基础上稍作修改(使用 semget, semop):
-
初始化: 创建一个信号量,初始值设为
1(代表"资源可用")。 -
Writer 写操作:
-
P(sem)(Wait): 请求资源。计数器1 -> 0。 -
strcpy(shared_memory, ...): 安全地写入数据(此时Reader无法进入)。 -
V(sem)(Signal): 释放资源。计数器0 -> 1。
-
-
Reader 读操作:
-
P(sem)(Wait): 请求资源。计数器1 -> 0。 -
printf("%s", shared_memory): 安全地读取数据。 -
V(sem)(Signal): 释放资源。计数器0 -> 1。
-
这种值为 1 的信号量,专门用于互斥访问,称为互斥锁 (Mutex)。
5、消息队列 (Message Queues)
① 核心思想
你不再是写入一个连续的字节流,而是发送一个个独立的消息包。每个消息包都有两个部分:
-
类型 (Type): 一个长整型数(
long),你可以用它来给消息分类或设置优先级。 -
数据 (Data): 一块二进制数据。
进程可以从队列中按类型“钓鱼”式地读取消息,而不是像管道那样必须按FIFO顺序。
② 底层原理 (内核层面)
-
创建/获取 (
msgget): 进程A调用msgget(key, ...)。-
内核在内核空间中创建一个
msqid_ds结构体。这个结构体是消息队列的“PCB”。 -
这个结构体内部包含了指向消息链表头/尾的指针、队列的权限、最大字节数等元数据。
-
这个链表中的每个节点都是一个内核分配的内存块,用来存放一个完整的消息(类型+数据)。
-
-
发送 (
msgsnd): 进程A调用msgsnd(msqid, &msg_buffer, ...)。-
系统调用,CPU切换到内核态。
-
内核分配一块新的内核内存(一个链表节点)。
-
内核复制 (Copy) 进程A的
msg_buffer(用户空间)到新分配的内核内存中。(第一次复制) -
内核将这个新节点挂在消息链表的尾部。
-
内核检查是否有任何进程正在Waiting(阻塞)在此队列上等待接收消息。
-
如果有,内核唤醒一个(或多个,取决于接收类型)等待的进程(PCB状态:Waiting -> Ready)。
-
-
接收 (
msgrcv): 进程B调用msgrcv(msqid, &rcv_buffer, ...)。-
系统调用,CPU切换到内核态。
-
内核根据
msgrcv传入的类型参数(msgtyp)去遍历消息链表:-
msgtyp == 0:读取队列中的第一个消息(FIFO)。 -
msgtyp > 0:读取队列中第一个类型匹配的消息。 -
msgtyp < 0:读取队列中类型小于等于abs(msgtyp)的第一个消息。
-
-
如果找到匹配的消息:
-
内核复制 (Copy) 该内核消息节点中的数据到进程B的
rcv_buffer(用户空间)中。(第二次复制) -
内核将该消息节点从链表中摘除并释放。
-
msgrcv带着数据返回用户态。
-
-
如果未找到匹配消息:
-
进程B阻塞。内核将其PCB状态设为 Waiting,并记录它在等待哪种类型的消息。
-
CPU上下文切换到其他进程。
-
-
③ 优缺点
-
优点:
-
解耦: 进程A和B不需要同时运行。A可以发送几条消息然后退出,B稍后再启动来读取它们(消息队列是随内核持续存在的,直到被显式删除或系统重启)。
-
类型/优先级: 最大的特性。允许你实现复杂的通信逻辑,如用类型1作普通请求,类型100作紧急插队请求。
-
-
缺点:
-
两次复制: 性能开销与管道类似(User -> Kernel, Kernel -> User),远慢于共享内存。
-
限制: 队列的总字节数和最大消息数都受内核限制。
-
开销:
msgrcv按类型查找可能需要遍历链表,(理论上)比管道的直接FIFO读要慢一点。
-
④ 消息队列代码示例
common.h (公共头文件)
#ifndef COMMON_H
#define COMMON_H
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/msg.h>
// 消息队列的 Key
#define MSG_KEY 0x4321
// 消息结构体
// 必须以一个 long 类型的 mtype 成员开头
struct msg_buffer {
long mtype; // 消息类型,必须是第一个成员
char mtext[100]; // 消息内容
};
#endif // COMMON_H
msg_sender.c (发送者)
#include "common.h"
int main() {
int msqid;
struct msg_buffer message;
// 1. msgget: 获取 (或创建) 消息队列
msqid = msgget(MSG_KEY, 0666 | IPC_CREAT);
if (msqid < 0) {
perror("msgget error");
exit(1);
}
printf("[Sender] (PID: %d) 消息队列已连接 (ID: %d)\n", getpid(), msqid);
// 准备发送两条消息
// 消息 1: 类型为 1 (普通)
message.mtype = 1;
strcpy(message.mtext, "这是第一条普通消息。");
// 2. msgsnd: 发送消息
if (msgsnd(msqid, &message, sizeof(message.mtext), 0) < 0) {
perror("msgsnd type 1 error");
} else {
printf("[Sender] 发送了类型为 1 的消息。\n");
}
sleep(1);
// 消息 2: 类型为 100 (紧急)
message.mtype = 100;
strcpy(message.mtext, "!!这是第二条紧急消息!!");
if (msgsnd(msqid, &message, sizeof(message.mtext), 0) < 0) {
perror("msgsnd type 100 error");
} else {
printf("[Sender] 发送了类型为 100 的消息。\n");
}
printf("[Sender] 退出。\n");
return 0;
}
msg_receiver.c (接收者)
#include "common.h"
int main() {
int msqid;
struct msg_buffer message;
// 1. msgget: 获取消息队列 (不创建)
msqid = msgget(MSG_KEY, 0666);
if (msqid < 0) {
perror("msgget error (请先运行 sender)");
exit(1);
}
printf("[Receiver] (PID: %d) 消息队列已连接 (ID: %d)\n", getpid(), msqid);
// 2. msgrcv: 尝试接收 "紧急" 消息 (类型 100)
// 注意第 4 个参数 (msgtyp)
printf("[Receiver] 正在尝试优先接收类型为 100 的消息...\n");
// msgrcv 会阻塞,直到队列中出现类型为 100 的消息
if (msgrcv(msqid, &message, sizeof(message.mtext), 100, 0) < 0) {
perror("msgrcv type 100 error");
} else {
printf("[Receiver] [优先收到] -> 类型: %ld, 内容: '%s'\n", message.mtype, message.mtext);
}
// 3. msgrcv: 接收 "任意" 消息 (FIFO)
// 注意第 4 个参数 (msgtyp) 为 0,表示按顺序接收
printf("[Receiver] 正在接收下一条消息 (FIFO)...\n");
if (msgrcv(msqid, &message, sizeof(message.mtext), 0, 0) < 0) {
perror("msgrcv type 0 error");
} else {
printf("[Receiver] [按序收到] -> 类型: %ld, 内容: '%s'\n", message.mtype, message.mtext);
}
// 4. msgctl: 最后一个进程负责删除消息队列
if (msgctl(msqid, IPC_RMID, NULL) < 0) {
perror("msgctl(IPC_RMID) error");
} else {
printf("[Receiver] 消息队列已删除。\n");
}
return 0;
}
运行:
-
编译:
gcc msg_sender.c -o sendergcc msg_receiver.c -o receiver -
终端1:
./sender -
终端2:
./receiver(你会看到它先收到类型100的消息,再收到类型1的)
6、Unix 域套接字 (Unix Domain Sockets, UDS)
这是目前为止功能最强大、最灵活的IPC机制。它本质上是网络编程(Socket)API 的一个特例,它不通过网卡和IP协议栈出去,而是直接在内核中进行本地通信。
① 核心思想
它模拟了网络通信的客户端/服务器(C/S)模型。
-
服务器 (Server): 创建一个
socket,将其bind到文件系统中的一个路径(例如/tmp/my.sock),然后listen监听连接,最后accept接受客户端连接。 -
客户端 (Client): 创建一个
socket,然后connect到服务器绑定的那个路径(/tmp/my.sock)。
一旦连接建立(accept 返回一个新的 fd),服务器和客户端就可以像使用网络TCP连接一样,通过 read/write 或 send/recv 进行**全双工(Full-Duplex)**通信(即双方可以同时读写)。
② 底层原理 (内核层面)
这是对管道的终极魔改。它复用了内核中绝大部分的网络协议栈代码,但把"数据包目的地"从"网卡驱动"改为了"另一个本地进程"。
-
socket(AF_UNIX, ...):-
AF_UNIX(Address Family UNIX) 是关键。它告诉内核:“我要用网络API,但通信方在本地。” -
内核在内部创建一个
inode(就像管道和FIFO一样),并分配内核数据结构(struct socket),这个结构体里包含了两个缓冲区(一个用于读,一个用于写),从而实现全双工。
-
-
bind(fd, "/tmp/my.sock")(Server):-
内核在文件系统中创建一个特殊文件
/tmp/my.sock(类型为s,即socket)。 -
这个文件inode的作用就是**“路标”**,它指向服务器
listen的那个socket结构体。
-
-
listen(fd, ...)(Server):-
内核将这个
socket标记为“被动”(Passive)模式,并为其分配一个“半连接队列”和“全连接队列”,准备接受客户端。
-
-
connect(fd_cli, "/tmp/my.sock")(Client):-
系统调用。客户端内核通过路径
/tmp/my.sock找到服务器的“路标”inode,进而找到服务器正在listen的socket结构体。 -
内核在内部模拟一次TCP三方握手(这非常快,只是在内核中设置几个状态标志)。
-
内核创建一个新的
struct socket(包含两个新缓冲区)来代表这个已建立的连接,并将其放入服务器的“全连接队列”中。 -
内核唤醒正在
accept()处阻塞的服务器进程。
-
-
accept(fd_srv, ...)(Server):-
服务器进程被唤醒。
-
accept系统调用从“全连接队列”中取出这个新连接,并为它分配一个新的文件描述符(例如fd=5)。 -
accept返回这个新的fd。
-
-
send/recv(通信):-
现在,客户端的
fd_cli和服务器的新fd=5指向同一个内核连接对象(它内部有两个缓冲区)。 -
客户端
send():系统调用,数据从客户端User空间 (复制1) -> 内核的“写缓冲区”。 -
服务器
recv():系统调用,数据从内核的“写缓冲区” (复制2) -> 服务器User空间。 -
反之亦然。这个过程与管道的阻塞/唤醒原理完全相同。
-
③ 优缺点
-
优点:
-
API标准: 使用与网络编程完全相同的API。如果你的本地服务未来需要改成网络服务,几乎不需要改代码,只需把
AF_UNIX改成AF_INET。 -
全双工: 双方可以同时收发,比管道灵活。
-
C/S模型: 天然支持一个服务器对多个客户端的复杂模型。
-
传递文件描述符 (SCM_RIGHTS): UDS有一个独门绝技,它可以在进程间传递打开的文件描述符。这是一个高级技巧,内核直接复制进程A的文件描述符表项到进程B,零数据拷贝,极其高效。
-
-
缺点:
-
两次复制: 同样存在User/Kernel数据拷贝,性能不如共享内存。
-
设置复杂: 代码量比管道或消息队列要多。
-
④ UDS 代码示例 (流式套接字 SOCK_STREAM)
uds_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/un.h> // Unix Domain Sockets 的头文件
#define SOCKET_PATH "/tmp/my_uds_socket"
int main() {
int server_fd, client_fd;
struct sockaddr_un server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);
char buffer[256];
// 1. 创建 UDS (AF_UNIX)
server_fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket error");
exit(1);
}
// 2. 绑定到文件系统路径
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sun_family = AF_UNIX;
strncpy(server_addr.sun_path, SOCKET_PATH, sizeof(server_addr.sun_path) - 1);
// 删除可能已存在的 socket 文件
unlink(SOCKET_PATH);
if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
perror("bind error");
close(server_fd);
exit(1);
}
// 3. 监听连接
if (listen(server_fd, 5) < 0) { // 5 是 backlog 队列大小
perror("listen error");
close(server_fd);
exit(1);
}
printf("[Server] 正在监听 %s ...\n", SOCKET_PATH);
// 4. 接受连接
// accept() 会阻塞,直到有客户端 connect()
client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
if (client_fd < 0) {
perror("accept error");
close(server_fd);
exit(1);
}
printf("[Server] 客户端已连接!\n");
// 5. 接收数据
ssize_t bytes_read = recv(client_fd, buffer, sizeof(buffer), 0);
if (bytes_read > 0) {
buffer[bytes_read] = '\0';
printf("[Server] 收到消息: '%s'\n", buffer);
// 6. 发送回执
send(client_fd, "消息已收到!", 13, 0);
}
close(client_fd);
close(server_fd);
unlink(SOCKET_PATH); // 清理
return 0;
}
uds_client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/un.h>
#define SOCKET_PATH "/tmp/my_uds_socket"
int main() {
int client_fd;
struct sockaddr_un server_addr;
char buffer[256];
// 1. 创建 UDS
client_fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (client_fd < 0) {
perror("socket error");
exit(1);
}
// 2. 准备服务器地址
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sun_family = AF_UNIX;
strncpy(server_addr.sun_path, SOCKET_PATH, sizeof(server_addr.sun_path) - 1);
// 3. 连接服务器
// connect() 会阻塞,直到服务器 accept()
if (connect(client_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
perror("connect error (请先运行 server)");
close(client_fd);
exit(1);
}
printf("[Client] 已连接到服务器。\n");
// 4. 发送数据
const char *msg = "Hello, Server! (from Client)";
send(client_fd, msg, strlen(msg), 0);
printf("[Client] 消息已发送。\n");
// 5. 接收回执
ssize_t bytes_read = recv(client_fd, buffer, sizeof(buffer), 0);
if (bytes_read > 0) {
buffer[bytes_read] = '\0';
printf("[Client] 收到回执: '%s'\n", buffer);
}
close(client_fd);
return 0;
}
运行:
-
编译:
gcc uds_server.c -o servergcc uds_client.c -o client -
终端1:
./server(它会阻塞在accept) -
终端2:
./client(它会连接、发送、接收,然后两个程序会相继退出)
7、IPC 机制对比总结
| 机制 | 速度 | 数据流 | 亲缘关系 | 同步/阻塞 | 底层原理 |
| 匿名管道 | 慢 | 单向 (Half-Duplex) | 必须 (父子) | 自带阻塞 |
内核缓冲区 + FD 继承 |
| 命名管道(FIFO) | 慢 | 单向 | 无需 | 自带阻塞 |
内核缓冲区 + 文件系统inode |
| 共享内存 | 极快 | 任意 (N/A) | 无需 | 无阻塞 (危险!) | 页表映射 (MMU) |
| 信号量 | (非数据) | 信号 (N/A) | 无需 | 自带阻塞 |
内核计数器 + 原子操作 |
| (消息队列) | 中等 | 多对多 | 无需 | 自带阻塞 | 内核链表 + 复制 |
| (Unix套接字) | 中等 | 双向 (Full-Duplex) | 无需 | 自带阻塞 |
内核缓冲区 + 文件系统inode |
更多推荐
所有评论(0)