从零开始学习嵌入式P30----进程间的通信

从零开始学习嵌入式P30----进程间的通信

上一篇我们学会了用互斥锁和信号量协调同一进程内的多个线程,最后留下一个问题:线程共享同一份内存,通信零成本;可进程之间彼此隔离 ------每个进程都有自己独立的虚拟地址空间,进程 A 的全局变量,进程 B 根本看不见。那进程之间怎么交换数据?这就是本篇的主题:进程间通信(IPC) 。我们将依次学会五种机制:管道 (无名管道和有名管道)、信号消息队列共享内存信号量集。它们各有分工:管道像一根水管,信号像一声门铃,消息队列像邮箱,共享内存像一块公共黑板,信号量则是给黑板上锁的钥匙。

本篇目标

学完这一篇,我们应该能够:

  • 说清进程间通信的五种常用方式,以及各自适合什么场景;
  • pipe 在父子进程之间传递数据,说清无名管道的使用限制;
  • mkfifo + open / read / write 让两个毫不相干的进程互相通信;
  • signal 注册信号处理函数,用 kill / alarm 发信号,用 pause 挂起等待;
  • 说清 IPC 对象的 key 是怎么来的(ftok),会用 ipcs / ipcrm 查看和删除;
  • msgget / msgsnd / msgrcv / msgctl 收发带类型的消息;
  • shmget / shmat / shmdt / shmctl 共享一块内存,并说清它为什么最快;
  • semget / semctl / semop 在进程间做互斥与同步。

一、进程间通信的方式

进程和线程最大的区别在于:进程拥有独立的地址空间。线程间写一个全局变量另一个立刻就能看到,进程间却不行------变量名相同也没用,它们住在各自的房子里。所以操作系统专门提供了一批"跨进程搬运数据"的机制:

方式 一句话理解 特点
无名管道 一根单向水管 只能用于有亲缘关系的进程(如父子进程)
有名管道 有名字的水管 任意两个进程都能用
信号 门铃 只传"事件发生了",不传数据
消息队列 邮箱 按类型收发消息,有格式
共享内存 公共黑板 最快,但需要自己配合同步
信号量集 黑板旁的钥匙 本身不传数据,负责给共享资源立规矩

下面挨个拆开看。


二、无名管道

1. 什么是管道

管道是内核里的一块缓冲区,行为像一根单向的水管:一端进水(写),另一端出水(读)。无名管道没有文件名,看不见摸不着,只能在创建它的进程和它的后代之间使用。

2. 函数接口

c 复制代码
int pipe(int pipefd[2]);
  • 功能:创建无名管道,通过参数带出两个文件描述符;
  • 参数
    • pipefd[0]读端
    • pipefd[1]写端
  • 返回值 :成功返回 0,失败返回 -1

记忆口诀:0 读 1 写------刚好和 stdin(0)、stdout(1)的"0 入 1 出"同构。

3. 使用套路与限制

无名管道的经典用法是配合 forkpipefork,管道随文件描述符表一起被复制到子进程,父子各留一端、关掉另一端,就搭好了一条单向通道:

c 复制代码
#include "head.h"

char tmpbuff[4096] = {0};

int main(void)
{
    pid_t pid;
    int pipefd[2];
    ssize_t nret = 0;

    if (-1 == pipe(pipefd))
    {
        perror("fail to pipe");
        return -1;
    }

    pid = fork();
    if (-1 == pid)
    {
        perror("fail to fork");
        return -1;
    }
    if (0 == pid)
    {
        close(pipefd[0]);                       // 子进程只写,关掉读端
        strcpy(tmpbuff, "hello world");
        write(pipefd[1], tmpbuff, strlen(tmpbuff) + 1);
        close(pipefd[1]);
    }
    else
    {
        close(pipefd[1]);                       // 父进程只读,关掉写端
        nret = read(pipefd[0], tmpbuff, sizeof(tmpbuff));
        printf("接收到 %ld 字节数据, tmpbuff = %s\n", nret, tmpbuff);
        close(pipefd[0]);
    }

    return 0;
}

无名管道的几个硬限制,必须记牢:

  1. 只能用于有亲缘关系的进程(父子、兄弟),因为它没有名字,外人拿不到它的文件描述符;
  2. 半双工 :数据只能朝一个方向流,pipefd[1] 进、pipefd[0] 出,不能反着来;
  3. 内核缓冲区大小有限(默认 64K),写满后 write 会阻塞;
  4. 读端没有数据时 read 阻塞 ;写端全部关闭后 read 返回 0(相当于读到 EOF)。

如果两个进程八竿子打不着(比如两个独立启动的程序),无名管道就无能为力了------这时要用有名管道。


三、有名管道

1. 函数接口

有名管道(FIFO)在文件系统里有一个路径名 ,任何进程只要知道这个名字就能打开它,因此可以用于任意两个进程之间

c 复制代码
int mkfifo(const char *pathname, mode_t mode);
  • 功能 :创建一个有名管道文件(文件类型为 p);
  • 参数
    • pathname:管道文件路径;
    • mode:权限,如 0664
  • 返回值 :成功返回 0,失败返回 -1(管道已存在时也会返回失败)。

创建之后,对它的操作和普通文件完全一样:open 打开、read / write 读写、close 关闭。要注意一个特性:以只读方式 open 会阻塞,直到有进程以写方式打开它 ;反之亦然。也就是说 open 本身就承担了"等对方上线"的握手功能。

2. 两个独立进程互相喊话

读端程序负责创建管道并读数据:

c 复制代码
#include "head.h"

int main(void)
{
    int fd = 0;
    char tmpbuff[4096] = {0};

    mkfifo("/tmp/myfifo", 0664);

    fd = open("/tmp/myfifo", O_RDONLY);
    if (-1 == fd)
    {
        perror("fail to open");
        return -1;
    }
    printf("open fifo for reading success\n");

    read(fd, tmpbuff, sizeof(tmpbuff));
    printf("tmpbuff = %s\n", tmpbuff);

    close(fd);

    return 0;
}

写端程序打开同一个管道写数据:

c 复制代码
#include "head.h"

int main(void)
{
    int fd = 0;
    char tmpbuff[4096] = {0};

    mkfifo("/tmp/myfifo", 0664);

    fd = open("/tmp/myfifo", O_WRONLY);
    if (-1 == fd)
    {
        perror("fail to open");
        return -1;
    }
    printf("open fifo for writing success\n");

    gets(tmpbuff);
    write(fd, tmpbuff, strlen(tmpbuff));

    close(fd);

    return 0;
}

两边都调用 mkfifo 是因为无法保证谁先运行------已存在时 mkfifo 失败无妨,open 照样能用。先运行的一方会阻塞在 open 上,直到另一方也打开管道,双方"握手"成功后数据才流动。

3. 练习:用两条管道实现双向聊天

一条管道是单向的,要实现"你一句我一句"的双向聊天,就用两条管道ATOB 负责 A 发给 B,BTOA 负责 B 发给 A。

A 端:先写后读------

c 复制代码
#include "head.h"

int main(void)
{
    int fatob = 0;
    int fbtoa = 0;
    char tmpbuff[1024] = {0};

    mkfifo("/tmp/ATOB", 0664);
    mkfifo("/tmp/BTOA", 0664);

    fatob = open("/tmp/ATOB", O_WRONLY);
    fbtoa = open("/tmp/BTOA", O_RDONLY);

    while (1)
    {
        memset(tmpbuff, 0, sizeof(tmpbuff));
        gets(tmpbuff);
        write(fatob, tmpbuff, strlen(tmpbuff));

        memset(tmpbuff, 0, sizeof(tmpbuff));
        read(fbtoa, tmpbuff, sizeof(tmpbuff));
        printf("RECV:%s\n", tmpbuff);
    }

    close(fatob);
    close(fbtoa);

    return 0;
}

B 端把读写方向整个反过来:先读后写------

c 复制代码
#include "head.h"

int main(void)
{
    int fatob = 0;
    int fbtoa = 0;
    char tmpbuff[1024] = {0};

    mkfifo("/tmp/ATOB", 0664);
    mkfifo("/tmp/BTOA", 0664);

    fatob = open("/tmp/ATOB", O_RDONLY);
    fbtoa = open("/tmp/BTOA", O_WRONLY);

    while (1)
    {
        memset(tmpbuff, 0, sizeof(tmpbuff));
        read(fatob, tmpbuff, sizeof(tmpbuff));
        printf("RECV:%s\n", tmpbuff);

        memset(tmpbuff, 0, sizeof(tmpbuff));
        gets(tmpbuff);
        write(fbtoa, tmpbuff, strlen(tmpbuff));
    }

    close(fatob);
    close(fbtoa);

    return 0;
}

注意两端 open 的顺序也是镜像的:A 先开 ATOB 写端(会阻塞),B 先开 ATOB 读端------两边正好解开彼此的阻塞。如果两端 open 顺序写成一样,就会互相等对方先开,卡死在 open,这是有名管道最容易踩的坑。


四、信号

1. 什么是信号

管道能传数据,信号则只负责"通知":信号是软件层的异步事件,就像门铃------它只告诉你"有人来了",不带包裹。进程收到信号后,会打断当前的执行流,转去执行预设的处理动作,处理完再回到原来的地方继续。

常见的信号(kill -l 可以看到全部):

信号 触发方式 默认动作
SIGINT 终端按 Ctrl + C 终止进程
SIGQUIT 终端按 Ctrl + \ 终止进程
SIGTSTP 终端按 Ctrl + Z 暂停进程
SIGKILL kill -9 PID 终止进程(不可捕获、不可忽略
SIGSTOP --- 暂停进程(不可捕获、不可忽略
SIGSEGV 段错误(非法内存访问) 终止进程
SIGPIPE 往读端已关闭的管道写数据 终止进程
SIGALRM alarm 定时器到期 终止进程
SIGCHLD 子进程状态改变 忽略
SIGUSR1 / SIGUSR2 用户自定义 终止进程

每个信号有三种处理方式:执行默认动作忽略捕获 (执行自己写的处理函数)。SIGKILLSIGSTOP 是特权信号,只能执行默认动作------系统必须留一条能强行干掉任何进程的后路。

2. 注册信号处理函数:signal

c 复制代码
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
  • 功能:为某个信号注册处理方式;
  • 参数
    • signum:信号编号(如 SIGINT);
    • handler:处理方式,可以是自定义函数、SIG_IGN(忽略)或 SIG_DFL(恢复默认);
  • 返回值 :成功返回之前的处理方式,失败返回 SIG_ERR

下面这个程序注册了自己的 SIGINT 处理函数,从此按 Ctrl + C 杀不掉它,只会打印一句话(想退出可以按 Ctrl + \SIGQUIT):

c 复制代码
#include "head.h"

void handler(int signo)
{
    printf("信号处理函数被执行\n");

    return;
}

int main(void)
{
    signal(SIGINT, handler);

    while (1)
    {
        printf("正在执行\n");
        sleep(1);
    }

    return 0;
}

一个处理函数也可以同时服务多个信号,靠参数 signo 区分来的是谁:

c 复制代码
#include "head.h"

void handler(int signo)
{
    if (SIGINT == signo)
    {
        printf("SIGINT信号来了!\n");
    }
    else if (SIGQUIT == signo)
    {
        printf("SIGQUIT信号来了!\n");
    }
    else if (SIGTSTP == signo)
    {
        printf("SIGTSTP信号来了!\n");
    }

    return;
}

int main(void)
{
    signal(SIGINT, handler);
    signal(SIGQUIT, handler);
    signal(SIGTSTP, handler);

    while (1)
    {

    }

    return 0;
}

3. pause:挂起进程等信号

c 复制代码
int pause(void);
  • 功能 :让进程挂起(睡眠) ,直到收到任意一个信号才会返回。进程挂起时不占用 CPU,这是它比 while(1) 空转高明的地方。
c 复制代码
#include "head.h"

void handler(int signo)
{
    return;
}

int main(void)
{
    signal(SIGINT, handler);

    printf("pause上面\n");
    pause();
    printf("pause下面\n");

    return 0;
}

运行后停在 pause 上一动不动,按一次 Ctrl + C,处理函数执行完,pause 返回,才打印出"pause下面"。注意:如果信号的处理方式是默认动作(终止进程),进程直接就死了,根本没机会从 pause 返回------所以 pause 常和自定义处理函数搭配使用。

4. alarm:给自己定闹钟

c 复制代码
unsigned int alarm(unsigned int seconds);
  • 功能seconds 秒之后,内核向本进程发送一个 SIGALRM 信号。alarm(0) 表示取消之前定的闹钟。

利用它可以在处理函数里再定一次闹钟,实现"每 5 秒干一次活":

c 复制代码
#include "head.h"

void handler(int signo)
{
    printf("SIGALRM come\n");

    alarm(5);                       // 再定下一次

    return;
}

int main(void)
{
    signal(SIGALRM, handler);

    alarm(5);

    while (1)
    {

    }

    return 0;
}

5. kill:向指定进程发信号

c 复制代码
int kill(pid_t pid, int sig);
  • 功能 :向进程号为 pid 的进程发送 sig 信号(命令行里的 kill 命令就是它的外壳)。

下面这个程序用 fork 造出父子进程,让它们互相发信号通信:子进程收到 Ctrl + \SIGQUIT)时给父进程发 SIGUSR1;父进程收到 Ctrl + CSIGINT)时给子进程发 SIGUSR2。两边各自把"发给自己用的按键信号"忽略掉,只响应对方发来的自定义信号:

c 复制代码
#include "head.h"

pid_t pid;

void handler_child(int signo)
{
    if (SIGQUIT == signo)
    {
        kill(getppid(), SIGUSR1);
    }
    else if (SIGUSR2 == signo)
    {
        printf("收到父进程信号了!\n");
    }

    return;
}

void handler_parent(int signo)
{
    if (SIGINT == signo)
    {
        kill(pid, SIGUSR2);
    }
    else if (SIGUSR1 == signo)
    {
        printf("收到子进程信号了!\n");
    }

    return;
}

int main(void)
{
    pid = fork();
    if (-1 == pid)
    {
        perror("fail to fork");
        return -1;
    }
    if (0 == pid)
    {
        signal(SIGUSR2, handler_child);
        signal(SIGQUIT, handler_child);
        signal(SIGINT, SIG_IGN);
    }
    else if (pid > 0)
    {
        signal(SIGUSR1, handler_parent);
        signal(SIGINT, handler_parent);
        signal(SIGQUIT, SIG_IGN);
    }

    while (1)
    {

    }

    return 0;
}

fork 之后 pid 在父进程里是子进程的 PID,子进程里则用 getppid() 拿父进程 PID------两个方向的路都通,信号就能一来一回。

6. 练习:用 alarm + pause 手写一个 MySleep

把上面几件兵器组合起来,就能实现一个自己的 sleep:定个闹钟,然后挂起等它响,响完把信号处理方式恢复原样:

c 复制代码
#include "head.h"

void handler(int signo)
{
    return;
}

void MySleep(int seconds)
{
    signal(SIGALRM, handler);

    alarm(seconds);
    pause();

    signal(SIGALRM, SIG_DFL);

    return;
}

int main(void)
{
    printf("sleep上面\n");
    MySleep(5);
    printf("sleep下面\n");

    return 0;
}

逻辑非常直白:alarm(5) 让内核 5 秒后送一个 SIGALRMpause() 挂起等它;信号到了,处理函数空跑一圈,pause 返回;最后把 SIGALRM 恢复成默认处理,避免影响调用者。这个小程序是理解"信号 + 挂起等待"配合的最佳标本。


五、System V IPC 对象:key 与管理命令

从消息队列开始,后面的三种机制(消息队列、共享内存、信号量集)有一个共同的身份:System V IPC 对象 。它们由内核统一管理,不属于任何进程------创建它的进程退出了,对象还在,直到显式删除。

1. 查看与删除:ipcs / ipcrm

  • ipcs -q / -m / -s:分别查看消息队列、共享内存、信号量;
  • ipcrm -Q key / -M key / -S key:按 key 删除对应 IPC 对象;
  • ipcrm -q id / -m id / -s id:按 ID 删除。

程序崩溃没来得及清理时,就靠这两条命令"擦屁股"。

2. ftok:两个进程怎么找到同一个对象

无名管道靠继承,有名管道靠路径名,IPC 对象靠 key ------两个进程必须算出同一个 key ,才能拿到同一个消息队列/共享内存/信号量。ftok 就是约定的"暗号生成器":

c 复制代码
key_t ftok(const char *pathname, int proj_id);
  • 功能:由一个已存在的文件路径和一个 8 位的项目 ID,算出一个 IPC key;
  • 参数
    • pathname:一个真实存在的文件路径(两边必须写同一个);
    • proj_id:项目 ID(低 8 位有效,两边必须写同一个);
  • 返回值 :成功返回 key,失败返回 -1

只要两个进程用同样的 (pathname,proj_id)ftok,算出的 key 必然相同------这就是它们接头的方式。


六、消息队列

1. 什么是消息队列

消息队列是内核里的一条消息链表 ,像邮箱:发送方把消息打包、贴上"类型标签"扔进去,接收方按标签取自己关心的消息,不必按先来后到的顺序。相比管道,它有类型、有边界、可以双向,也不用管打开关闭那一套。

使用套路固定四步半:ftok 生成 key → msgget 创建/获取队列 → msgsnd 发 / msgrcv 收 → msgctl 销毁

2. 函数接口

c 复制代码
int msgget(key_t key, int msgflg);
  • 功能:按 key 创建(或获取已存在的)消息队列,返回队列 ID;
  • 参数msgflgIPC_CREAT | 0664 表示"不存在就创建,权限 0664";加上 IPC_EXCL 则表示"已存在就报错";
  • 返回值 :成功返回队列 ID,失败返回 -1
c 复制代码
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
  • 功能:向队列发送一条消息;
  • 参数
    • msgp:消息结构体地址,第一个成员必须是 long mtype(消息类型,必须 > 0)
    • msgsz:消息正文 的字节数(不含 mtype);
    • msgflg0 表示队列满时阻塞;
  • 返回值 :成功返回 0,失败返回 -1
c 复制代码
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
  • 功能:从队列接收消息;
  • 参数msgtyp 指定收哪种类型:0 收队列中第一条,> 0 收该类型的第一条;
  • 返回值 :成功返回收到的正文字节数 ,失败返回 -1
c 复制代码
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
  • 功能 :控制消息队列,cmdIPC_RMID 表示删除队列,bufNULL

3. 发一条再收回来

c 复制代码
#include "head.h"

typedef struct msgbuf {
    long mtype;                 // 消息类型,必须 > 0
    char mtext[256];            // 消息正文
} msgbuf_t;

int main(void)
{
    key_t key;
    int msgid = 0;
    msgbuf_t sendmsg;
    msgbuf_t recvmsg;
    ssize_t nret = 0;

    key = ftok("/tmp", 'a');
    if (-1 == key)
    {
        perror("fail to ftok");
        return -1;
    }

    msgid = msgget(key, IPC_CREAT | 0664);
    if (-1 == msgid)
    {
        perror("fail to msgget");
        return -1;
    }

    sendmsg.mtype = 100L;
    strcpy(sendmsg.mtext, "hello world");
    if (-1 == msgsnd(msgid, &sendmsg, sizeof(sendmsg.mtext), 0))
    {
        perror("fail to msgsnd");
        return -1;
    }

    nret = msgrcv(msgid, &recvmsg, sizeof(recvmsg.mtext), 100L, 0);
    if (-1 == nret)
    {
        perror("fail to msgrcv");
        return -1;
    }
    printf("接收%ld字节, 内容:%s\n", nret, recvmsg.mtext);

    msgctl(msgid, IPC_RMID, NULL);

    return 0;
}

同一个程序里先发后收,只是为了看清流程;实际使用中发送和接收在两个进程里。注意 msgsnd 的第三个参数是 sizeof(sendmsg.mtext)------只算正文,不能把 mtype 也算进去

4. 练习:两个进程一收一发

把收发拆成两个独立程序。发送端从键盘读一行,打包成 100 号类型发出:

c 复制代码
#include "head.h"

typedef struct msg {
    long mtype;
    char mtext[256];
} msg_t;

int main(void)
{
    key_t key;
    int msgid = 0;
    msg_t sendmsg;

    key = ftok("/tmp", 'a');
    if (-1 == key)
    {
        perror("fail to ftok");
        return -1;
    }

    msgid = msgget(key, IPC_CREAT | 0664);
    if (-1 == msgid)
    {
        perror("fail to msgget");
        return -1;
    }

    sendmsg.mtype = 100L;
    gets(sendmsg.mtext);
    if (-1 == msgsnd(msgid, &sendmsg, sizeof(sendmsg.mtext), 0))
    {
        perror("fail to msgsnd");
        return -1;
    }

    return 0;
}

接收端按 100 号类型取消息,取完顺手销毁队列:

c 复制代码
#include "head.h"

typedef struct msg {
    long mtype;
    char mtext[256];
} msg_t;

int main(void)
{
    key_t key;
    int msgid = 0;
    msg_t recvmsg;
    ssize_t nret = 0;

    key = ftok("/tmp", 'a');
    if (-1 == key)
    {
        perror("fail to ftok");
        return -1;
    }

    msgid = msgget(key, IPC_CREAT | 0664);
    if (-1 == msgid)
    {
        perror("fail to msgget");
        return -1;
    }

    nret = msgrcv(msgid, &recvmsg, sizeof(recvmsg.mtext), 100L, 0);
    if (-1 == nret)
    {
        perror("fail to msgrcv");
        return -1;
    }
    printf("RECV:%s\n", recvmsg.mtext);

    msgctl(msgid, IPC_RMID, NULL);

    return 0;
}

先跑接收端再跑发送端:msgrcv 会阻塞在队列前等消息,发送端一扔进来,接收端立刻打印。两边 ftok 参数一致、mtype 一致,就接上了头。


七、共享内存

1. 什么是共享内存

消息队列每次收发都要在用户态和内核态之间拷贝数据。共享内存则干脆得多:内核划出一块物理内存,同时映射到多个进程的地址空间 ,进程像访问自己的数组一样直接读写它------数据不需要拷贝,因此它是最快的 IPC 方式

但快是有代价的:共享内存本身没有任何同步机制,一个进程在写、另一个进程同时在读,就会读到写了一半的数据。所以共享内存常和信号量搭配使用(下一节的主角)。

使用套路:ftokshmget 创建 → shmat 映射 → 读写 → shmdt 解除映射 → shmctl 销毁

2. 函数接口

c 复制代码
int shmget(key_t key, size_t size, int shmflg);
  • 功能 :按 key 创建(或获取)共享内存,大小为 size 字节;
  • 参数shmflgIPC_CREAT | 0664
  • 返回值 :成功返回共享内存 ID,失败返回 -1
c 复制代码
void *shmat(int shmid, const void *shmaddr, int shmflg);
  • 功能 :把共享内存映射到本进程的地址空间(attach);
  • 参数shmaddrNULL 让内核挑地址,shmflg0(读写);
  • 返回值 :成功返回映射后的首地址,失败返回 (void *)-1
c 复制代码
int shmdt(const void *shmaddr);
  • 功能 :解除映射(detach)。注意:解除映射不等于删除,内存块还在。
c 复制代码
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
  • 功能 :控制共享内存,IPC_RMID 表示删除,bufNULL

3. 进程间直接读写同一块内存

写端程序:创建、映射、不断把键盘输入写到共享内存里,输入 .quit 退出:

c 复制代码
#include "head.h"

int main(void)
{
    key_t key;
    int shmid = 0;
    char *pshmaddr = NULL;

    key = ftok("/tmp", 'a');
    if (-1 == key)
    {
        perror("fail to ftok");
        return -1;
    }

    shmid = shmget(key, 4096, IPC_CREAT | 0664);
    if (-1 == shmid)
    {
        perror("fail to shmget");
        return -1;
    }

    pshmaddr = shmat(shmid, NULL, 0);
    if (NULL == pshmaddr)
    {
        perror("fail to shmat");
        return -1;
    }

    while (1)
    {
        gets(pshmaddr);
        if (0 == strcmp(pshmaddr, ".quit"))
        {
            break;
        }
    }

    shmdt(pshmaddr);
    shmctl(shmid, IPC_RMID, NULL);

    return 0;
}

读端程序:映射同一块内存,循环打印里面的内容,看到 .quit 退出:

c 复制代码
#include "head.h"

int main(void)
{
    key_t key;
    int shmid = 0;
    char *pshmaddr = NULL;

    key = ftok("/tmp", 'a');
    if (-1 == key)
    {
        perror("fail to ftok");
        return -1;
    }

    shmid = shmget(key, 4096, IPC_CREAT | 0664);
    if (-1 == shmid)
    {
        perror("fail to shmget");
        return -1;
    }

    pshmaddr = shmat(shmid, NULL, 0);
    if (NULL == pshmaddr)
    {
        perror("fail to shmat");
        return -1;
    }

    while (1)
    {
        if (0 == strcmp(pshmaddr, ".quit"))
        {
            break;
        }
        printf("SHMADDR:%s\n", pshmaddr);
    }

    shmdt(pshmaddr);
    shmctl(shmid, IPC_RMID, NULL);

    return 0;
}

两个进程没有任何收/发调用,写端一改 pshmaddr 指向的内容,读端立刻就能读到------零拷贝,这就是共享内存快的原因。同时也能直观看到它的缺点:读端靠死循环反复打印,内容没变化时也在刷屏,完全没有"对方写完了吗"的协调机制------这正是信号量要补的位。


八、信号量集

1. 从线程信号量到进程信号量

上一篇学的 sem_init / sem_wait / sem_post 是 POSIX 信号量,服务于同一进程内的线程。System V 也提供了一套进程间可用的信号量集 :一次可以创建一组信号量(信号灯),申请和释放通过"操作结构体"描述。

先记住三个接口的分工:

  • semget:创建信号量集;
  • semctl:控制------给某个信号量赋初值、删除整个集合;
  • semop申请(-1)和释放(+1) ------相当于 sem_waitsem_post 的合体。

2. 函数接口

c 复制代码
int semget(key_t key, int nsems, int semflg);
  • 功能 :按 key 创建一个包含 nsems 个信号量的集合;
  • 返回值 :成功返回信号量集 ID,失败返回 -1
c 复制代码
int semctl(int semid, int semnum, int cmd, ...);
  • 功能 :控制信号量集中编号为 semnum(从 0 开始)的信号量;
  • 参数cmd 常用 SETVAL(设置初值,通过第四个参数传入)和 IPC_RMID(删除集合);
  • 返回值 :失败返回 -1

第四个参数的类型 union semun 需要自己定义(内核头文件不提供):

c 复制代码
union semun {
    int              val;    /* SETVAL 用的值 */
    struct semid_ds *buf;    /* IPC_STAT / IPC_SET 用 */
    unsigned short  *array;  /* GETALL / SETALL 用 */
};
c 复制代码
int semop(int semid, struct sembuf *sops, size_t nsops);
  • 功能 :对信号量集做一组操作,nsops 是操作个数;
  • 参数sops 指向 struct sembuf 数组,每个元素描述一次操作:
c 复制代码
struct sembuf {
    unsigned short sem_num;   /* 操作第几个信号量 */
    short          sem_op;    /* -1 申请(P 操作),+1 释放(V 操作) */
    short          sem_flg;   /* 通常用 SEM_UNDO:进程退出时自动撤销操作 */
};

sem_op 为正/负的含义和上一篇信号量的 +1/-1 完全对应:-1 是申请,申请不到就阻塞;+1 是释放,唤醒等待者

3. 走一遍完整的申请释放流程

创建一个包含 2 个信号量的集合:0 号当"读信号量"(初值 0),1 号当"写信号量"(初值 1),然后演示申请写、释放读、申请读的过程:

c 复制代码
#include "head.h"

union semun {
    int              val;
    struct semid_ds *buf;
    unsigned short  *array;
};

int main(void)
{
    key_t key;
    int semid = 0;
    union semun myun;
    struct sembuf mybuf;

    key = ftok("/tmp", 'a');
    if (-1 == key)
    {
        perror("fail to ftok");
        return -1;
    }

    semid = semget(key, 2, IPC_CREAT | 0664);
    if (-1 == semid)
    {
        perror("fail to semget");
        return -1;
    }

    myun.val = 0;
    semctl(semid, 0, SETVAL, myun);     // 读信号量初值 0

    myun.val = 1;
    semctl(semid, 1, SETVAL, myun);     // 写信号量初值 1

    // 申请写信号量:1 号,-1
    mybuf.sem_num = 1;
    mybuf.sem_op = -1;
    mybuf.sem_flg = SEM_UNDO;
    if (-1 == semop(semid, &mybuf, 1))
    {
        perror("fail to semop");
        return -1;
    }
    printf("申请到写信号量了!\n");

    // 释放读信号量:0 号,+1
    mybuf.sem_num = 0;
    mybuf.sem_op = +1;
    mybuf.sem_flg = SEM_UNDO;
    if (-1 == semop(semid, &mybuf, 1))
    {
        perror("fail to semop");
        return -1;
    }
    printf("释放读信号量了!\n");

    // 申请读信号量:0 号,-1(刚释放过,能申请到)
    mybuf.sem_num = 0;
    mybuf.sem_op = -1;
    mybuf.sem_flg = SEM_UNDO;
    if (-1 == semop(semid, &mybuf, 1))
    {
        perror("fail to semop");
        return -1;
    }
    printf("申请到读信号量了!\n");

    return 0;
}

如果把第二次"申请读信号量"再复制一份接着调用,进程就会阻塞 在那里------读信号量的计数已经归 0,没有资源可申请。把"申请读"想象成上一篇 sem_wait,把"释放读"想象成 sem_post,整套语义完全平移,只是换了一套更啰嗦的接口。

实战中,信号量集最常见的岗位就是站在共享内存旁边:写进程"申请写 → 写共享内存 → 释放读",读进程"申请读 → 读共享内存 → 释放写",初值一 1 一 0,两个进程严格交替------这和上一篇双信号量收发练习的骨架一模一样,只是从线程搬到了进程。


九、常见错误

1. 无名管道父子两端方向搞反

c 复制代码
// 父进程想读,却关掉了读端
close(pipefd[0]);
read(pipefd[0], buf, sizeof(buf));   // 永远读不到

pipefd[0] 是读端、pipefd[1] 是写端,各留一端、关掉另一端之前先想清楚"我这个进程是读还是写"。两端都关成同一个方向,管道就成了摆设。

2. 有名管道两端 open 顺序写成一样

c 复制代码
// A: open(ATOB, O_WRONLY); open(BTOA, O_RDONLY);
// B: open(ATOB, O_WRONLY); open(BTOA, O_RDONLY);   // 双方都等对方先开,死锁

双向通信时,两端的 open 顺序必须是镜像的:一个先开 1 号管道的写、另一个就先开 1 号管道的读,互相解开对方的阻塞。

3. 以为 signal 注册后能拦截 SIGKILL

SIGKILLSIGSTOP 不可捕获、不可忽略signal(SIGKILL, handler) 不会报错但也绝不会生效。想"优雅退出",应该捕获 SIGTERMSIGINT,在处理函数里做清理。

4. msgsndmtype 也算进了消息长度

c 复制代码
msgsnd(msgid, &msg, sizeof(msg), 0);        // 错:多算了一个 long
msgsnd(msgid, &msg, sizeof(msg.mtext), 0);  // 对:只算正文

msgsz 只描述正文 长度,且消息结构体的第一个成员必须是大于 0 的 long mtype

5. 两个进程 ftok 参数不一致,永远接不上头

ftok("/tmp", 'a')ftok("/tmp", 'b') 算出的是两个不同的 key,双方各自创建各自的队列,互相看不见。路径和项目 ID 是"接头暗号",两端必须一字不差

6. 只 shmdtshmctl(IPC_RMID),共享内存泄漏

shmdt 只是"我不再访问了",内存块还在内核里。进程都退出了它也不会自动消失,得用 shmctl(..., IPC_RMID, NULL) 显式删除,或者事后用 ipcrm -m 清理。消息队列、信号量集同理。

7. 共享内存不配合同步机制,读到"半句话"

共享内存没有阻塞、没有通知,写进程写到一半时读进程就能读到中间状态。只要读写可能并发,就必须配信号量(或互斥锁)保护。


十、进程间通信速查

机制 接口 要点
无名管道 pipe(pipefd) pipefd[0] 读、pipefd[1] 写;仅限亲缘进程;先 pipefork
有名管道 mkfifo(path, 0664) + open/read/write 任意进程可用;open 会阻塞到对端打开;双工需两条管道
注册信号 signal(SIGINT, handler) handler 可换 SIG_IGN / SIG_DFLSIGKILLSIGSTOP 不可捕获
挂起等信号 pause() 睡到任意信号到来,不占 CPU
定时信号 alarm(秒) 到时向自己发 SIGALRM
发信号 kill(pid, sig) 需要知道对方 PID
生成 key ftok("/tmp", 'a') 两端参数一致才算出同一个 key
消息队列 msgget / msgsnd / msgrcv / msgctl 首成员必须 long mtype > 0;长度只算正文;按类型收发
共享内存 shmget / shmat / shmdt / shmctl 最快、零拷贝;无同步机制;shmdt 只是解除映射
信号量集 semget / semctl(SETVAL) / semop sem_op 为 -1 申请、+1 释放;SEM_UNDO 防进程暴毙残留
IPC 管理 ipcs -q/m/sipcrm -q/m/s id 程序崩溃后的清理手段
易混淆点 结论
无名管道 vs 有名管道 无名管道靠 fork 继承,限亲缘进程;有名管道靠路径名,任意进程可用
信号 vs 其他 IPC 信号只通知"事件",不携带数据;要传数据用管道/消息队列/共享内存
shmdt vs IPC_RMID 前者解除本进程映射,后者真正删除内核对象
共享内存为什么快 直接映射到进程地址空间,读写不经过内核拷贝
POSIX 信号量 vs 信号量集 sem_* 服务线程;semget/semop 服务进程,一次管理一组

小结

这一篇我们走出了进程的院墙。进程地址空间相互隔离,所以操作系统提供了一整套 IPC 机制:无名管道pipe 创建、pipefd[0]pipefd[1] 写,但只能服务有亲缘关系的进程;有名管道mkfifo 在文件系统里挂个名字,任意两个进程 open 之后就能通信,双向聊天就开两条管道。信号 是异步通知:signal 注册处理函数、kill 发信号、pause 挂起等待、alarm 定时,组合起来还能手写 MySleep。随后登场的三种 System V IPC 对象 由内核统一管理,两个进程靠 ftok 算出同一个 key 来接头:消息队列 按类型收发带格式的消息,共享内存 把同一块物理内存映射进多个进程、速度最快但没有同步,信号量集semop 的 -1/+1 做进程间的申请与释放,恰好补上共享内存的同步短板。还要记住:这些内核对象不会随进程退出而消失,用 IPC_RMIDipcrm 清理干净是每一段的收尾动作。

掌握了五种进程间通信方式,本地多进程协作的工具箱就齐了。下一步如果两台机器上的程序要通信------比如开发板把采集到的数据传给上位机------就要走出单机,进入**网络编程(socket)**的世界。

相关推荐
日拱一卒的小田17 分钟前
ZYNQ学习笔记3-ZYNQ的IIC控制器3
笔记·学习
boxiansheng16322 分钟前
通讯录系统报错(二)
c语言·数据结构
山甫aa1 小时前
【从零开始的 Web 后端学习】文件上传一篇搞定(MultipartFile + 本地存储 + 阿里云 OSS 保姆级教程)
前端·学习·阿里云
高亦真1 小时前
今天是学习嵌入式的第32天
linux·学习·算法
Bruce_Liuxiaowei9 小时前
从基础理论开始学习人工智能(三):盲目搜索——从状态空间图到生成-测试范式
人工智能·学习
luj_17689 小时前
大律师考核应重能力与科技素养
c语言·开发语言·c++·经验分享·算法
有梦想的骇客10 小时前
PyTorch学习笔记
pytorch·笔记·学习
刃神太酷啦10 小时前
Linux 系统 MySQL 完整安装配置教程:从卸载 MariaDB 到优化 my.cnf----《Hello MySQL!》(1)
android·linux·c语言·c++·mysql·leetcode·mariadb
陈年老古董10 小时前
深度学习入门笔记:从神经元到深度神经网络
人工智能·笔记·深度学习·神经网络·学习