嵌入式Linux系统通常运行多个进程协同工作,这些进程之间需要交换数据、同步动作,选错IPC机制轻则效率低下,重则数据丢失、死锁。
IPC机制全景
Linux提供了多种IPC机制,各自有不同的设计目标和适用场景。没有"最好的"IPC,只有"最合适的"。
管道
管道是最古老的IPC机制,分匿名管道和命名管道(FIFO)两种。
匿名管道
匿名管道只能用于有亲缘关系的进程间(通常是父子进程),半双工通信。
#include<unistd.h>#include<stdio.h>#include<string.h>// 父子进程通过管道通信intmain(void){int pipefd[2];pid_t pid;char buf[128];if (pipe(pipefd) < 0) { perror("pipe");return-1; } pid = fork();if (pid < 0) { perror("fork");return-1; }if (pid == 0) {// 子进程:关闭写端,只读 close(pipefd[1]); read(pipefd[0], buf, sizeof(buf));printf("child recv: %s\n", buf); close(pipefd[0]); } else {// 父进程:关闭读端,只写 close(pipefd[0]); write(pipefd[1], "hello from parent", 18); close(pipefd[1]); wait(NULL); }return0;}
管道的读写行为有几个特点值得注意。写端全部关闭后,读端read()返回0(EOF),而不是阻塞。读端全部关闭后,写端write()触发SIGPIPE信号,默认终止进程。管道容量有限(Linux默认64KB),写满后write()阻塞直到读端消费数据。
命名管道(FIFO)
FIFO在文件系统中存在路径名,任何有权限的进程都能打开它。
#include<sys/stat.h>#include<fcntl.h>#include<unistd.h>// 进程A:创建FIFO并写入voidprocess_a(void){ mkfifo("/tmp/myfifo", 0666);int fd = open("/tmp/myfifo", O_WRONLY); write(fd, "sensor_data:25.6", 16); close(fd);}// 进程B:读取FIFOvoidprocess_b(void){int fd = open("/tmp/myfifo", O_RDONLY);char buf[128]; read(fd, buf, sizeof(buf)); close(fd);}
FIFO的open()默认阻塞,直到另一端也打开。这个特性在做进程同步时有用,但在不需要同步的场景下会导致进程卡住——用O_NONBLOCK标志打开可以避免阻塞。
管道适合简单的数据流场景:一端生产、一端消费,数据有先后顺序。不适合需要双向通信、多进程同时读写、或需要随机访问数据的场景。
共享内存
共享内存是最快的IPC机制。多个进程把同一块物理内存映射到各自的虚拟地址空间,读写操作不需要内核中转,没有数据拷贝开销。代价是需要手动同步。
#include<sys/shm.h>#include<sys/ipc.h>#include<string.h>#include<stdio.h>// 共享内存结构定义typedefstruct {int write_idx; // 写位置int read_idx; // 读位置int data[256]; // 环形缓冲区} shm_ring_t;// 进程A:创建共享内存并写入intmain(void){key_t key = ftok("/tmp/shmfile", 1);int shmid = shmget(key, sizeof(shm_ring_t), IPC_CREAT | 0666);if (shmid < 0) { perror("shmget");return-1; }shm_ring_t *ring = (shm_ring_t *)shmat(shmid, NULL, 0);memset(ring, 0, sizeof(shm_ring_t));// 写入数据(实际使用需要信号量保护)int val = 100;while (1) {int next = (ring->write_idx + 1) % 256;if (next != ring->read_idx) { ring->data[ring->write_idx] = val++; ring->write_idx = next; } usleep(10000); } shmdt(ring);return0;}
共享内存本身不提供任何同步机制。上面的代码省略了信号量保护,在实际使用中这是必须的,否则两个进程同时读写write_idx会产生数据竞争。
配合信号量同步
#include<sys/sem.h>// 二值信号量操作unionsemun {int val;structsemid_ds *buf;unsignedshort *array;};// P操作(获取)voidsem_lock(int semid){structsembufop = { .sem_num = 0, .sem_op = -1, // 减1 .sem_flg = SEM_UNDO }; semop(semid, &op, 1);}// V操作(释放)voidsem_unlock(int semid){structsembufop = { .sem_num = 0, .sem_op = 1, // 加1 .sem_flg = SEM_UNDO }; semop(semid, &op, 1);}// 创建信号量并初始化为1intcreate_semaphore(key_t key){int semid = semget(key, 1, IPC_CREAT | 0666);unionsemunarg; arg.val = 1; semctl(semid, 0, SETVAL, arg);return semid;}
SEM_UNDO标志保证进程异常退出时自动释放信号量,避免死锁。如果进程在持有信号量时崩溃,内核会回滚信号量值。不加这个标志的话,一个进程崩溃后其他等待该信号量的进程将永久阻塞。
共享内存适合大数据量、高频率的场景:视频帧传输、传感器数据流、进程间共享配置。不适合小数据量偶尔通信的场景——映射开销和同步复杂度不划算。
mmap共享内存
System V共享内存(shmget/shmat)是老接口。更现代的做法是用mmap配合shm_open:
#include<sys/mman.h>#include<fcntl.h>#include<unistd.h>// 进程A:创建共享内存对象int fd = shm_open("/my_shm", O_CREAT | O_RDWR, 0666);ftruncate(fd, sizeof(shm_ring_t));shm_ring_t *ring = mmap(NULL, sizeof(shm_ring_t), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);close(fd); // 映射后可以关闭fd// 进程B:打开已有的共享内存对象int fd = shm_open("/my_shm", O_RDWR, 0666);shm_ring_t *ring = mmap(NULL, sizeof(shm_ring_t), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);close(fd);// 使用完毕后解除映射munmap(ring, sizeof(shm_ring_t));// 删除共享内存对象shm_unlink("/my_shm");
shm_open创建的共享内存对象在/dev/shm/下,受tmpfs大小限制。这种方式比System V接口更简洁,也更容易管理——用ls /dev/shm/就能看到所有共享内存对象。
消息队列
消息队列提供了带类型的消息传递。每条消息有优先级,接收端可以按优先级选择性接收。
#include<sys/msg.h>#include<string.h>// 消息结构typedefstruct {long mtype; // 消息类型(必须 > 0)char mtext[256]; // 消息内容} msg_t;// 发送进程voidsender(void){key_t key = ftok("/tmp/msgfile", 1);int msqid = msgget(key, IPC_CREAT | 0666);msg_t msg; msg.mtype = 1; // 类型1:普通数据strcpy(msg.mtext, "temperature:25.6"); msgsnd(msqid, &msg, strlen(msg.mtext), 0); msg.mtype = 2; // 类型2:告警数据strcpy(msg.mtext, "OVERTEMP_ALARM"); msgsnd(msqid, &msg, strlen(msg.mtext), 0);}// 接收进程:优先接收告警消息voidreceiver(void){key_t key = ftok("/tmp/msgfile", 1);int msqid = msgget(key, IPC_CREAT | 0666);msg_t msg;// mtype = -2 表示接收类型 <= 2 的消息中优先级最高的// 即先收类型2,再收类型1 msgrcv(msqid, &msg, sizeof(msg.mtext), -2, 0);printf("type=%ld, text=%s\n", msg.mtype, msg.mtext);}
msgrcv的mtype参数决定了接收行为:
- •
mtype = 0:接收队列中的第一条消息(忽略类型) - •
mtype < 0:接收类型值 <= |mtype| 的消息中类型值最大的
消息队列的一个问题是每个消息有大小上限(默认8KB),且系统范围内消息队列的总数和总大小都有上限。传大数据时不适合用消息队列。
POSIX消息队列(mq_open/mq_send/mq_receive)是替代方案,接口更清晰,支持通知机制:
#include<mqueue.h>// 创建POSIX消息队列mqd_t mqd = mq_open("/alert_queue", O_CREAT | O_RDWR, 0666, NULL);// 设置通知:当队列从空变为非空时发信号structsigeventsev;sev.sigev_notify = SIGEV_SIGNAL;sev.sigev_signo = SIGUSR1;mq_notify(mqd, &sev);// 信号处理函数中接收消息voidhandler(int sig){char buf[256];unsigned prio;ssize_t n = mq_receive(mqd, buf, sizeof(buf), &prio);// 处理消息...}
mq_notify让接收端不必轮询——队列空变非空时内核主动通知。但通知是一次性的,每次通知后需要重新注册。
信号
信号是异步通知机制,适合传递简单的事件信号,不适合传数据。
#include<signal.h>#include<stdio.h>volatilesig_atomic_t alarm_flag = 0;voidalarm_handler(int sig){ alarm_flag = 1;}intmain(void){structsigactionsa; sa.sa_handler = alarm_handler; sigemptyset(&sa.sa_mask); sa.sa_flags = 0; sigaction(SIGALRM, &sa, NULL);// 设置5秒定时器 alarm(5);while (!alarm_flag) {// 主循环做其他工作 pause(); }printf("alarm triggered\n");return0;}
信号处理有几个陷阱。信号可能在任意时刻打断主程序执行,处理函数中不能调用非异步信号安全(async-signal-safe)的函数。printf、malloc都不是信号安全的——在处理函数中只能设置标志位,在主循环中检查标志位再执行实际逻辑。
sig_atomic_t保证读写操作的原子性,用于信号处理函数和主程序之间的标志位通信。用普通int在理论上可能在某些架构上出问题,虽然x86/ARM上32位对齐读写本身是原子的,但用sig_atomic_t是正确的编程习惯。
如果需要传递少量数据,可以使用sigqueue和SA_SIGINFO:
voidinfo_handler(int sig, siginfo_t *info, void *ctx){// info->si_value.sival_int 携带了一个整数值printf("received signal with data: %d\n", info->si_value.sival_int);}// 注册带数据的信号处理structsigactionsa;sa.sa_sigaction = info_handler;sigemptyset(&sa.sa_mask);sa.sa_flags = SA_SIGINFO;sigaction(SIGUSR1, &sa, NULL);// 发送带数据的信号unionsigvalval;val.sival_int = 42;sigqueue(target_pid, SIGUSR1, val);
Unix域套接字
Unix域套接字提供了全双工、面向流或数据报的通信方式。它在本地进程间通信的效率远高于网络套接字,因为没有协议栈开销。
#include<sys/socket.h>#include<sys/un.h>#include<string.h>#include<stdio.h>#include<unistd.h>// 服务端intserver_main(void){int sock = socket(AF_UNIX, SOCK_STREAM, 0);structsockaddr_unaddr;memset(&addr, 0, sizeof(addr)); addr.sun_family = AF_UNIX;strncpy(addr.sun_path, "/tmp/daemon.sock", sizeof(addr.sun_path) - 1); unlink("/tmp/daemon.sock"); // 清理残留 bind(sock, (struct sockaddr *)&addr, sizeof(addr)); listen(sock, 5);while (1) {int client = accept(sock, NULL, NULL);char buf[256];int n = read(client, buf, sizeof(buf)); buf[n] = '\0';printf("request: %s\n", buf); write(client, "OK", 2); close(client); }}// 客户端intclient_main(void){int sock = socket(AF_UNIX, SOCK_STREAM, 0);structsockaddr_unaddr;memset(&addr, 0, sizeof(addr)); addr.sun_family = AF_UNIX;strncpy(addr.sun_path, "/tmp/daemon.sock", sizeof(addr.sun_path) - 1); connect(sock, (struct sockaddr *)&addr, sizeof(addr)); write(sock, "get_status", 10);char buf[256];int n = read(sock, buf, sizeof(buf)); buf[n] = '\0';printf("response: %s\n", buf); close(sock);}
Unix域套接字支持传递文件描述符(SCM_RIGHTS),这是它独有的能力。一个进程可以打开文件或设备,把文件描述符通过Unix域套接字传给另一个进程,后者直接操作同一个文件描述符——不需要知道文件路径,也不需要文件权限。
#include<sys/uio.h>// 发送文件描述符voidsend_fd(int sock, int fd){structmsghdrmsg;structioveciov;char buf[1] = {0}; iov.iov_base = buf; iov.iov_len = 1; msg.msg_iov = &iov; msg.msg_iovlen = 1; msg.msg_name = NULL; msg.msg_namelen = 0;char cmsgbuf[CMSG_SPACE(sizeof(int))]; msg.msg_control = cmsgbuf; msg.msg_controllen = sizeof(cmsgbuf);structcmsghdr *cmsg = CMSG_FIRSTHDR(&msg); cmsg->cmsg_level = SOL_SOCKET; cmsg->cmsg_type = SCM_RIGHTS; cmsg->cmsg_len = CMSG_LEN(sizeof(int)); *(int *)CMSG_DATA(cmsg) = fd; sendmsg(sock, &msg, 0);}// 接收文件描述符intrecv_fd(int sock){structmsghdrmsg;structioveciov;char buf[1]; iov.iov_base = buf; iov.iov_len = 1; msg.msg_iov = &iov; msg.msg_iovlen = 1; msg.msg_name = NULL; msg.msg_namelen = 0;char cmsgbuf[CMSG_SPACE(sizeof(int))]; msg.msg_control = cmsgbuf; msg.msg_controllen = sizeof(cmsgbuf); recvmsg(sock, &msg, 0);structcmsghdr *cmsg = CMSG_FIRSTHDR(&msg);if (cmsg && cmsg->cmsg_type == SCM_RIGHTS) {return *(int *)CMSG_DATA(cmsg); }return-1;}
这个能力在特权分离设计中很有用。一个root权限的服务进程打开/dev/mem或GPIO设备,把文件描述符传给非特权工作进程,后者就能操作硬件而不需要root权限。
各机制对比
选择IPC机制的核心依据是通信模式。如果是一对一的数据流,管道或FIFO足够。如果是大量数据高频交换,共享内存配合信号量。如果是多生产者多消费者的命令/事件分发,消息队列。如果只是偶尔的通知,信号。如果需要双向通信、灵活的协议、或者传递文件描述符,Unix域套接字。
实际项目中经常组合使用多种IPC。采集进程通过共享内存把传感器数据写给处理进程,处理进程通过消息队列把控制命令发给执行进程,监控进程通过信号通知各进程优雅退出。理解每种机制的边界,才能做出合理选择。