一、IPC 基础概念
1. IPC 是什么
IPC(Inter-Process Communication)即进程间通信。不同进程拥有相互独立的虚拟地址空间,通常不能直接访问对方的普通变量,因此需要操作系统提供通信机制。
常见 POSIX IPC:
- 无名管道(pipe)
- 有名管道(FIFO)
- 共享内存(POSIX shared memory)
- 消息队列(POSIX message queue)
- 信号量(POSIX semaphore)
线程同步常用机制:
- 互斥锁(pthread mutex)
- 条件变量(pthread condition variable)
注意:
- 通信:解决数据如何从一个执行实体传到另一个执行实体。
- 同步:解决多个执行实体何时访问共享资源、谁先执行等问题。
- 共享内存负责共享数据,但本身不负责互斥和通知,通常需要配合信号量或互斥锁。
- POSIX IPC 与 System V IPC 是两套接口体系,Linux 通常都支持。
二、无名管道 pipe
1. 基本特点
- 半双工通信,通常用于具有亲缘关系的进程,例如父子进程。
- 通过文件描述符进行读写。
- 本质是内核中的字节流缓冲区。
- 不保留应用层消息边界:一次 write() 写入的数据,不保证一次 read() 就完整读出。
2. 创建管道
函数:
#include <unistd.h> int pipe(int pipefd[2]);成功返回 0,失败返回 -1。
两个文件描述符:
- pipefd[0]:读端。
- pipefd[1]:写端。
基本使用示例:
int pipefd[2]; if (pipe(pipefd) == -1) { perror("pipe"); return -1; } pid_t pid = fork(); if (pid == 0) { // 子进程只读 close(pipefd[1]); char buf[128]; ssize_t n = read(pipefd[0], buf, sizeof(buf)); if (n > 0) { write(STDOUT_FILENO, buf, n); } close(pipefd[0]); } else if (pid > 0) { // 父进程只写 close(pipefd[0]); const char msg[] = "hello from parent"; write(pipefd[1], msg, sizeof(msg)); close(pipefd[1]); }3. 为什么 fork 后要关闭不使用的端口
fork() 会复制文件描述符表,父子进程都能访问管道两端。
如果某个进程不使用写端,却没有关闭它,那么读端可能一直检测不到 EOF。因为只有所有指向管道写端的文件描述符都关闭,并且管道中剩余数据被读完后,read() 才会返回 0。
因此:
- 只读的进程关闭写端。
- 只写的进程关闭读端。
- 使用完毕及时 close()。
4. 管道的重要行为
- 管道为空时,阻塞模式下 read() 通常会等待数据。
- 管道写端全部关闭且数据读完后,read() 返回 0,表示EOF。
- 管道没有读端时写入,可能触发SIGPIPE;如果信号被忽略或处理,write() 会失败并返回EPIPE。
- read()/write() 可能发生短读、短写,不能假设一次调用一定传输全部数据。
- 多个进程同时写入时,单次写入不超过PIPE_BUF的数据具有原子性保证;超过 PIPE_BUF 不应假设不会交错。
5. pipe 与 socketpair 的区别
pipe 通常是单向字节流;如果同一对进程需要双向通信,可以创建两条管道,或者考虑socketpair()。
三、有名管道 FIFO
1. 基本特点
- FIFO 是一种特殊文件,也叫有名管道。
- 在文件系统中有路径名。
- 不相关的进程也可以通过相同路径打开 FIFO 通信。
- 数据仍然通过内核管道缓冲区传递,本质仍是字节流,不保留消息边界。
- FIFO 不负责把数据永久保存到普通磁盘文件中。
2. 创建 FIFO
函数:
#include <sys/types.h> #include <sys/stat.h> int mkfifo(const char *pathname, mode_t mode);示例:
if (mkfifo("/tmp/myfifo", 0666) == -1) { if (errno != EEXIST) { perror("mkfifo"); return -1; } }3. 打开与读写
int fd = open("/tmp/myfifo", O_WRONLY); write(fd, "hello", 5); close(fd);另一个进程可以:
int fd = open("/tmp/myfifo", O_RDONLY); char buf[128]; ssize_t n = read(fd, buf, sizeof(buf)); close(fd);默认阻塞语义下:
- 只读方式打开 FIFO,通常会等待写端打开。
- 只写方式打开 FIFO,通常会等待读端打开。
- 使用O_NONBLOCK可以改变部分打开和读写行为;具体要处理ENXIO、EAGAIN等错误。
4. FIFO 生命周期
- mkfifo() 创建目录项。
- open() 打开 FIFO。
- read()/write() 进行通信。
- close() 关闭文件描述符。
- unlink() 删除 FIFO 路径名。
删除路径名不等于强行终止已经打开该 FIFO 的所有使用者。
5. pipe 与 FIFO 对比
无名管道:
- 没有文件系统路径。
- 常见于父子等有亲缘关系进程。
- 通过 pipe() 创建。
FIFO:
- 有文件系统路径。
- 可用于无亲缘关系进程。
- 通过 mkfifo() 创建。
两者都属于字节流,均需要自行设计应用层数据格式和消息边界。
四、POSIX 共享内存
1. 基本思想
共享内存允许多个进程把同一块物理内存区域映射到各自的虚拟地址空间。
进程 A 写入共享内存后,进程 B 可以读取同一块共享区域,避免像管道那样必须把数据从一个进程复制到内核缓冲区、再复制到另一个进程。
特点:
- 通常是高性能 IPC方式。
- 适合传输大量数据,例如图像帧、音视频数据。
- 不自动提供互斥、通知或数据一致性管理。
- 必须设计好共享数据结构和同步机制。
2. 常见 API
#include <sys/mman.h> #include <sys/stat.h> #include <fcntl.h>创建或打开共享内存对象:
int shm_open(const char *name, int oflag, mode_t mode);设置共享内存对象大小:
int ftruncate(int fd, off_t length);映射到进程地址空间:
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);解除映射:
int munmap(void *addr, size_t length);关闭描述符:
int close(int fd);删除共享内存名称:
int shm_unlink(const char *name);3. 基本流程
int fd = shm_open("/my_shm", O_CREAT | O_RDWR, 0666); if (fd == -1) { perror("shm_open"); return -1; } size_t size = 4096; if (ftruncate(fd, size) == -1) { perror("ftruncate"); close(fd); return -1; } void *ptr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (ptr == MAP_FAILED) { perror("mmap"); close(fd); return -1; } // 通过 ptr 读写共享内存 // 例如:memcpy(ptr, data, data_len); munmap(ptr, size); close(fd); shm_unlink("/my_shm");4. 关键细节
- shm_open() 的 name 通常以斜杠开头,例如/my_shm。
- 新建对象后通常要用 ftruncate() 设置大小。
- 使用MAP_SHARED,修改才能对其他映射该对象的进程可见。
- mmap() 成功后返回映射地址;失败返回MAP_FAILED,不是 NULL。
- close(fd) 只关闭文件描述符,不会自动解除 mmap 映射。
- munmap() 解除当前进程的映射。
- shm_unlink() 删除名称;如果仍有进程打开或映射该对象,对象的实际释放会延后到相关引用消失。
- 多个进程映射到的虚拟地址不一定相同,因此共享内存结构中不要直接保存普通进程指针。可以保存相对于共享内存起点的偏移量。
- 创建者和使用者必须约定结构体布局、大小、版本、对齐方式和同步规则。
5. 为什么共享内存需要同步
假设生产者正在写入一帧图像,消费者同时读取:
- 消费者可能读到一半新数据、一半旧数据。
- 生产者可能覆盖消费者尚未处理的数据。
- 多个消费者可能同时修改状态。
因此需要使用进程间信号量、进程共享互斥锁等机制,协调读写顺序和缓冲区状态。
五、POSIX 消息队列
1. 基本特点
- 消息队列以一条条消息为单位保存数据。
- 与管道不同,消息队列保留消息边界。
- 每条消息可以带有优先级。
- 适合传递命令、状态、事件和较小的数据块。
- 队列容量有限,队列满时发送可能阻塞。
2. 常见 API
#include <mqueue.h>创建或打开:
mqd_t mq_open(const char *name, int oflag, ...);发送:
int mq_send(mqd_t mqdes, const char *msg_ptr, size_t msg_len, unsigned int msg_prio);接收:
ssize_t mq_receive(mqd_t mqdes, char *msg_ptr, size_t msg_len, unsigned int *msg_prio);属性查询:
int mq_getattr(mqd_t mqdes, struct mq_attr *attr);关闭:
int mq_close(mqd_t mqdes);删除队列名称:
int mq_unlink(const char *name);3. 创建示例
struct mq_attr attr; attr.mq_flags = 0; attr.mq_maxmsg = 10; attr.mq_msgsize = 128; attr.mq_curmsgs = 0; mqd_t mq = mq_open("/my_queue", O_CREAT | O_RDWR, 0666, &attr);发送:
const char msg[] = "START"; if (mq_send(mq, msg, sizeof(msg), 5) == -1) { perror("mq_send"); }接收:
char buf[128]; unsigned int priority; ssize_t n = mq_receive(mq, buf, sizeof(buf), &priority); if (n >= 0) { // buf 中是收到的消息,n 是消息长度 }4. 优先级规则
- 优先级数值越大,优先级越高。
- 接收时优先取出优先级最高的消息。
- 相同优先级的消息通常按先进先出顺序接收。
5. 必须注意的细节
- mq_receive() 提供的接收缓冲区长度必须至少为队列配置的mq_msgsize,否则可能失败并报EMSGSIZE。
- 队列为空时,阻塞模式下接收会等待。
- 队列满时,阻塞模式下发送会等待。
- 使用 O_NONBLOCK 后,队列空或满时可能返回EAGAIN。
- mq_send() 的 msg_len 不能超过队列允许的消息大小。
- mq_close() 关闭当前进程的队列描述符;mq_unlink() 删除队列名称。
- POSIX 消息队列名称通常以斜杠开头,例如/my_queue。
6. 适用场景
适合:
- 进程间发送控制命令。
- 传递设备状态和任务通知。
- 传递较小的结构化消息。
不适合直接频繁传输大量高清视频帧:队列容量和消息大小有限,通常更适合用共享内存传输大块数据,再用消息队列传递帧索引、状态或控制命令。
六、POSIX 信号量 semaphore
1. 基本概念
信号量是一个带计数值的同步对象,用于控制多个执行实体对资源的访问,或协调执行顺序。
常见用途:
- 限制同时访问某类资源的数量。
- 生产者通知消费者有数据可取。
- 控制共享缓冲区中空闲槽位和已占用槽位数量。
信号量不是普通的数据传输通道。
2. 两类 POSIX 信号量
命名信号量:
- 通过名称访问。
- 常用于无亲缘关系进程间同步。
- API:sem_open()、sem_close()、sem_unlink()。
无名信号量:
- 直接存放在内存对象中。
- 可用于线程间同步。
- 如果放在共享内存中,并正确设置 pshared,也可用于进程间同步。
- API:sem_init()、sem_destroy()。
3. 命名信号量 API
#include <semaphore.h>创建或打开:
sem_t *sem_open(const char *name, int oflag, mode_t mode, unsigned int value);等待,计数减一:
int sem_wait(sem_t *sem);尝试等待,不阻塞:
int sem_trywait(sem_t *sem);定时等待:
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);释放,计数加一:
int sem_post(sem_t *sem);关闭:
int sem_close(sem_t *sem);删除名称:
int sem_unlink(const char *name);4. 无名信号量 API
int sem_init(sem_t *sem, int pshared, unsigned int value); int sem_destroy(sem_t *sem);pshared 参数:
- pshared = 0:用于同一进程内的线程之间。
- pshared != 0:用于不同进程之间;信号量对象必须位于这些进程都能访问的共享内存中。
5. 生产者消费者模型(可以细看一下)
下面的内容要重点搞清楚为什么要设置两个信号量
这个模型的设计是为了防止消费者进程先拿到锁。然后导致直接卡死
假设环形缓冲区容量为 N:
sem_t empty; // 空槽位数量,初始为 N sem_t full; // 已有数据数量,初始为 0 pthread_mutex_t mutex;生产者逻辑:
sem_wait(&empty); // 等待空槽位 pthread_mutex_lock(&mutex); // 保护缓冲区 // 把数据放入缓冲区 pthread_mutex_unlock(&mutex); sem_post(&full); // 通知消费者有数据消费者逻辑:
sem_wait(&full); // 等待已有数据 pthread_mutex_lock(&mutex); // 从缓冲区取出数据 pthread_mutex_unlock(&mutex); sem_post(&empty); // 归还空槽位这里:
- empty控制空槽位数量。
- full控制已填充槽位数量。
- mutex保护对缓冲区及其索引的临界区访问。
6. 注意事项
- sem_wait() 可能被信号中断,返回 -1 且 errno 为EINTR;需要根据程序逻辑决定是否重试。
- sem_trywait() 在资源不可用时通常以EAGAIN失败。
- 不要随意对已经存在的命名信号量重复初始化或重置计数。
- sem_close() 与 sem_unlink() 作用不同:前者关闭当前进程的引用,后者移除名称。
- sem_post() 用于增加计数;不要把它当作普通互斥锁的 unlock 来理解。
七、互斥锁 pthread mutex
1. 基本作用
互斥锁用于保护临界区,确保同一时刻只有一个线程进入受保护的代码区域。
例如多个线程同时修改一个全局计数器,如果没有同步,可能出现数据竞争和结果错误。
2. 常见 API
#include <pthread.h>初始化:
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);加锁:
int pthread_mutex_lock(pthread_mutex_t *mutex);尝试加锁:
int pthread_mutex_trylock(pthread_mutex_t *mutex);定时加锁:
int pthread_mutex_timedlock( pthread_mutex_t *mutex, const struct timespec *abs_timeout);解锁:
int pthread_mutex_unlock(pthread_mutex_t *mutex);销毁:
int pthread_mutex_destroy(pthread_mutex_t *mutex);3. 基本使用
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; int count = 0; void *worker(void *arg) { pthread_mutex_lock(&mutex); count++; pthread_mutex_unlock(&mutex); return NULL; }4. 进程间使用互斥锁
普通 pthread mutex 默认用于同一进程内线程同步。
若要用于不同进程:
- mutex 对象必须放在共享内存等双方都能访问的位置。
- 初始化 mutex 属性。
- 设置PTHREAD_PROCESS_SHARED。
- 再用该属性初始化互斥锁。
示意:
pthread_mutexattr_t attr; pthread_mutexattr_init(&attr); pthread_mutexattr_setpshared( &attr, PTHREAD_PROCESS_SHARED); pthread_mutex_init(shared_mutex, &attr); pthread_mutexattr_destroy(&attr);5. 互斥锁注意事项
- 加锁后必须在合适路径解锁,避免死锁。
- 不要在持锁期间执行很长时间的阻塞操作。
- 多把锁要尽量使用统一的加锁顺序,降低死锁风险。
- pthread_mutex_* 函数通常直接返回错误码,不是统一通过 errno 判断;错误处理应检查函数返回值。
- 互斥锁具有所有权语义,通常由获得锁的线程负责解锁。
- 可以了解robust mutex:当持锁线程异常终止时,后续线程可能收到EOWNERDEAD,需要检查并修复共享状态,再调用 pthread_mutex_consistent()。
八、条件变量 pthread condition variable
1. 基本作用
条件变量用于让线程等待某个条件成立,而不是不断循环检查条件(忙等待)。
条件变量通常与互斥锁配合使用:
- mutex 保护共享状态。
- condition variable 用于等待状态变化的通知。
2. 常见 API
pthread_cond_init() pthread_cond_wait() pthread_cond_signal() pthread_cond_broadcast() pthread_cond_timedwait() pthread_cond_destroy()3. 典型生产者消费者示例
消费者:
pthread_mutex_lock(&mutex); while (queue_empty()) { pthread_cond_wait(&cond, &mutex); } // 条件成立,取出数据 pthread_mutex_unlock(&mutex);生产者:
pthread_mutex_lock(&mutex); // 放入数据 pthread_cond_signal(&cond); pthread_mutex_unlock(&mutex);4. 为什么必须使用 while,不能只使用 if
条件变量可能发生虚假唤醒;另外,线程被唤醒并重新获得 mutex 时,等待的条件也可能已经被其他线程改变。
因此正确逻辑是:
while (条件不成立) { pthread_cond_wait(&cond, &mutex); }不能简单写成:
if (条件不成立) { pthread_cond_wait(&cond, &mutex); }5. pthread_cond_wait 做了什么
pthread_cond_wait(&cond, &mutex) 会:
- 原子地释放 mutex并进入等待。
- 收到通知后重新竞争 mutex。
- 返回前重新获得 mutex。
因此调用者返回后仍持有 mutex,需要在适当位置解锁。
6. 进程间条件变量
默认条件变量用于同一进程内线程同步。跨进程使用时:
- 条件变量对象放在共享内存中。
- 设置条件变量属性PTHREAD_PROCESS_SHARED。
- 配合进程共享互斥锁使用。
九、几种机制的区别
1. 管道与 FIFO
- pipe:无名,常用于有亲缘关系的进程。
- FIFO:有路径名,可用于无亲缘关系进程。
- 二者都是字节流,不保留消息边界。
2. 共享内存与消息队列
共享内存:
- 多进程映射同一块内存。
- 适合大量数据和高吞吐场景。
- 需要额外同步机制。
- 适合传输图像帧、音视频数据块。
消息队列:
- 以消息为单位传递。
- 保留消息边界,支持优先级。
- 适合命令、状态、事件等较小消息。
- 队列容量和消息大小有限。
3. 信号量与互斥锁
信号量:
- 维护计数值。
- 可以表示多个可用资源。
- 常用于通知和资源数量控制。
- 不强调线程所有权。
互斥锁:
- 保护临界区。
- 通常同一时刻只允许一个线程持锁。
- 具有所有权语义。
- 适合保护共享数据结构。
4. 条件变量与信号量
条件变量:
- 等待某个共享状态满足条件。
- 通常必须与 mutex 配合。
- 通知本身不代表条件一定仍然成立。
信号量:
- 内部有计数值。
- post 会增加计数,wait 在计数可用时消耗一个计数。
- 可以积累通知或表示资源数量。
十、嵌入式 Linux 项目中的组合使用
以RK3568 多路视频监控程序为例:
进程划分:
- 采集进程:通过 V4L2 获取摄像头数据。
- 编码进程:通过硬件编码接口或软件编码处理图像。
- 推流进程:通过 RTSP/RTP 等协议发送码流。
- 管理进程:接收配置、启动停止命令和运行状态。
一种 IPC 设计:
- 共享内存:存放图像帧或较大的数据块。
- 消息队列:传递启动、停止、切换码流、错误状态等控制消息。
- 信号量:通知消费者有新数据,或管理缓冲区空闲槽位。
- 进程共享互斥锁:保护共享内存中的队列索引和状态。
- 条件变量:在线程模型中等待缓冲区状态变化。
注意:
- 不要让生产者无限覆盖消费者尚未处理的数据。
- 设计有界缓冲区,明确满队列时的策略:阻塞、丢弃旧帧或丢弃新帧。
- 视频实时性通常更重视延迟上限;必要时可以丢弃过期帧。
- 共享内存结构中尽量使用固定宽度整数和偏移量,避免直接保存进程私有指针。
- 对共享数据定义状态机,例如FREE、WRITING、READY、READING。
- 设计进程异常退出后的资源恢复和清理机制。
十一、面试常见问题
1. 管道和 FIFO 有什么区别?
管道通常没有文件系统路径,常用于具有亲缘关系的进程;FIFO 是有名管道,在文件系统中有路径,因此不相关的进程也可以通过同一路径打开通信。二者都是字节流,不保留消息边界。
2. 为什么共享内存速度快?
多个进程把同一块内存映射到各自地址空间,可以直接访问共享数据,避免通过管道或消息队列传输大块数据时产生的额外数据复制开销。但共享内存需要自行处理同步和数据一致性。
3. 共享内存为什么还需要信号量或锁?
共享内存只解决数据共享,不保证一个进程写入时另一个进程不会同时读取,也不负责通知消费者数据已经准备好。因此需要信号量、进程共享互斥锁等同步机制。
4. 消息队列和管道有什么区别?
管道传输字节流,不保留消息边界;消息队列按消息收发,保留消息边界,并支持消息优先级。消息队列适合控制消息,管道适合简单的流式通信。
5. 信号量和互斥锁有什么区别?
信号量有计数值,可以表示多个资源或通知次数;互斥锁主要用于保护临界区,通常只允许一个线程持有,并具有所有权语义。
6. 条件变量为什么要配合互斥锁?
共享条件通常由多个线程共同访问,需要 mutex 保护条件判断和状态修改。条件变量负责让线程在条件不成立时睡眠,并在状态变化时被唤醒。等待时必须使用 while 重新检查条件。
7. read() 一次能读完管道中的一条消息吗?
不能保证。管道是字节流,没有消息边界。read() 可能读到部分数据,也可能一次读到多段数据。应用层需要自行设计固定长度、长度字段、分隔符等协议格式。
8. pthread 函数的错误码如何处理?
许多 pthread 函数直接返回错误码,成功返回 0,失败返回非零错误码。不能一概使用 errno 判断,应检查函数返回值。
9. sem_wait() 被信号中断怎么办?
如果返回 -1 且 errno 为 EINTR,说明等待被信号中断。可以根据业务逻辑重试,或退出等待流程。
10. close、munmap、shm_unlink 有什么区别?
- close:关闭当前进程持有的文件描述符。
- munmap:解除当前进程的内存映射。
- shm_unlink:删除共享内存对象的名称。
它们不是同一个操作,资源清理时需要根据创建和使用流程分别处理。
十二、编译与移植注意事项
常见编译参数:
gcc main.c -o app -pthread部分较旧的 Linux/glibc 环境中,POSIX 消息队列或共享内存相关接口可能需要额外链接 librt:
gcc main.c -o app -pthread -lrt具体是否需要-lrt,取决于目标系统的 libc/glibc 版本和工具链配置。
交叉编译时重点检查:
- 使用的编译器是否为目标架构工具链。
- sysroot 是否指向目标系统头文件和库。
- 目标根文件系统是否包含所需运行时库。
- 目标内核和 C 库是否支持所使用的接口。
- 编译成功不代表目标板运行时一定具备对应功能。
十三、学习顺序建议
建议按下面顺序学习和动手验证:
- pipe:理解文件描述符、fork、EOF、阻塞读写。
- FIFO:理解无亲缘关系进程如何通过路径通信。
- 共享内存:理解 shm_open、ftruncate、mmap 和资源生命周期。
- 消息队列:理解消息边界、优先级和阻塞行为。
- 信号量:实现生产者消费者模型。
- 互斥锁:解决多线程共享数据竞争。
- 条件变量:实现线程等待队列非空、缓冲区非满等条件。
- 综合练习:使用共享内存传输数据,使用信号量或锁进行同步。
最终要掌握的不是只会背 API,而是能回答:
- 数据放在哪里?
- 谁负责读写?
- 如何通知对方数据准备好了?
- 多个执行实体同时访问时如何避免竞争?
- 队列满了或进程异常退出怎么办?
- 资源由谁创建、谁关闭、谁删除?