一、简介

接续上次的讨论,我们已经知道了进程是内存隔离的,它们拥有各自独立的虚拟地址空间。这个设计保证了安全性和稳定性,但也带来了一个新问题:进程之间如何合法地交换数据?

这就是进程间通信 (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) 时,底层发生了什么?

  1. 发起系统调用: 进程A(比如读取者)调用 read(fd[0], ...)。CPU 触发软中断,从用户态切换到内核态

  2. 内核检查: 内核接管后,查看 fd[0] 指向的内核缓冲区。发现缓冲区是空的。

  3. 进入等待: 内核不能空手返回。它将进程A的 PCB(进程控制块)中的状态从 Running(运行)修改为 Waiting(阻塞/睡眠)。

  4. 上下文切换: 内核保存进程A的CPU寄存器(PC, SP等)到其PCB中。

  5. 调度新进程: 内核的调度器 (Scheduler) 启动,选择一个Ready(就绪)状态的进程B,将其PCB中的寄存器加载到CPU,切换页表,然后返回用户态。进程B开始执行。

  6. (另一边)写入数据: 某个时刻,进程C(写入者)调用 write(fd[1], ...),内核将数据放入缓冲区。

  7. 唤醒操作: 内核在写入数据后,会检查是否有进程正在Waiting这个缓冲区。它发现了进程A。

  8. 状态变更: 内核将进程A的PCB状态从 Waiting 修改为 Ready,并将其放入就绪队列

  9. (未来某个时刻)再次调度: 当CPU时钟中断发生,调度器再次运行时,它可能会选择进程A。

  10. 恢复执行: 内核恢复进程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;
}

如何运行:

  1. 编译: gcc fifo_writer.c -o writer gcc fifo_reader.c -o reader

  2. 打开一个终端,运行: ./reader (它会阻塞在 open)

  3. 打开另一个终端,运行: ./writer (它会写入,然后两个程序同时结束)

3、共享内存 (Shared Memory)

这是最高效的IPC机制,因为它完全绕过了内核的数据复制

① 核心思想

我们之前说,进程A和B的虚拟地址空间是隔离的。0x1000 在A中和 0x1000 在B中指向不同的物理内存。

共享内存的“作弊”之处在于:它请求内核**“掰弯”这个规则。它让内核创建一块物理内存**,然后把这块物理内存同时映射 (Map) 到A和B的虚拟地址空间中。

② 底层原理 (CPU/MMU 层面)

  1. 创建共享段: 进程A调用 shmget() 系统调用。内核收到请求,在物理RAM中分配一块内存(例如,4KB)。内核为这块内存建立一个唯一的ID(shmid)。

  2. 映射 (Attach):

    • 进程A调用 shmat(shmid, ...)。这是一个关键的系统调用。CPU切换到内核态,内核执行以下操作:

      • 内核在进程A的页表 (Page Table) 中找到一个空闲的条目。

      • 内核将这个条目指向 shmget() 创建的物理内存地址

      • shmat() 返回这个条目对应的虚拟地址(例如 0xB6F00000)给进程A。

    • 进程B(知道 shmid)也调用 shmat(shmid, ...)

      • 内核在进程B的页表中也找到一个空闲条目。

      • 内核将这个条目指向同一块物理内存地址。

      • shmat() 返回这个条目对应的虚拟地址(例如 0xB6E80000)给进程B。

注意: 虚拟地址在A和B中可能不同,但它们对应的页表项都指向同一块物理RAM

  1. 高速访问:

    • 进程A执行:*(char *)0xB6F00000 = 'X';

    • CPU(用户态)执行 MOV 指令。

    • CPU的MMU(内存管理单元)查询进程A的页表,将虚拟地址 0xB6F00000 翻译为物理地址(比如 0xPHYS_ADDR)。

    • CPU将 'X' 写入物理内存 0xPHYS_ADDR

    • 全程没有系统调用,没有内核介入。

  2. 读取:

    • 进程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;
}

如何运行:

  1. 编译: gcc shm_writer.c -o writer gcc shm_reader.c -o reader

  2. 终端1: ./writer (它会写入并等待)

  3. 终端2: ./reader (它会立刻读到 writer 写入的数据)

  4. 在终端1按回车,writer 退出。

4、信号量 (Semaphores)

信号量本身不传输数据,它是用来同步其他IPC机制(尤其是共享内存)的。

① 核心思想

信号量是一个内核管理的整数计数器。进程只能对它执行两种原子操作 (Atomic Operations):

  1. P (Wait/Down): 尝试将计数器减 1。

    • 如果计数器 > 0,则减 1 成功,进程继续执行。

    • 如果计数器 == 0,则进程阻塞(内核将其PCB置为Waiting),直到有其他进程对该信号量执行 V 操作。

  2. V (Signal/Up): 将计数器加 1。

    • 加 1 后,内核会检查是否有进程阻塞在该信号量上。

    • 如果有,内核唤醒其中一个进程(将其PCB置为Ready)。

② 底层原理 (原子性)

PV 操作的原子性 (Atomicity) 是关键。当一个进程在内核态执行 P 操作(检查值、决定阻塞、减1)时,CPU不允许另一个进程也进入内核态操作同一个信号量。

这在单核CPU上通过关闭中断来实现。在多核CPU上,则需要依赖更底层的硬件原子指令(例如 x86 上的 XCHGLOCK CMPXCHG),它们可以锁定内存总线,确保在"读-改-写"一个内存位置的整个过程中,没有其他CPU核可以访问它。

信号量的阻塞/唤醒原理,与管道的 read/write 阻塞原理完全一致(都是内核调度器在操作PCB)。

③ 示例:用信号量保护共享内存

我们只需在 shm_writer.cshm_reader.c 的基础上稍作修改(使用 semget, semop):

  • 初始化: 创建一个信号量,初始值设为 1 (代表"资源可用")。

  • Writer 写操作:

    1. P(sem) (Wait): 请求资源。计数器 1 -> 0

    2. strcpy(shared_memory, ...): 安全地写入数据(此时Reader无法进入)。

    3. V(sem) (Signal): 释放资源。计数器 0 -> 1

  • Reader 读操作:

    1. P(sem) (Wait): 请求资源。计数器 1 -> 0

    2. printf("%s", shared_memory): 安全地读取数据。

    3. V(sem) (Signal): 释放资源。计数器 0 -> 1

这种值为 1 的信号量,专门用于互斥访问,称为互斥锁 (Mutex)

5、消息队列 (Message Queues)

① 核心思想

你不再是写入一个连续的字节流,而是发送一个个独立的消息包。每个消息包都有两个部分:

  1. 类型 (Type): 一个长整型数(long),你可以用它来给消息分类或设置优先级。

  2. 数据 (Data): 一块二进制数据。

进程可以从队列中按类型“钓鱼”式地读取消息,而不是像管道那样必须按FIFO顺序。

② 底层原理 (内核层面)

  1. 创建/获取 (msgget): 进程A调用 msgget(key, ...)

    • 内核在内核空间中创建一个 msqid_ds 结构体。这个结构体是消息队列的“PCB”。

    • 这个结构体内部包含了指向消息链表头/尾的指针、队列的权限、最大字节数等元数据。

    • 这个链表中的每个节点都是一个内核分配的内存块,用来存放一个完整的消息(类型+数据)。

  2. 发送 (msgsnd): 进程A调用 msgsnd(msqid, &msg_buffer, ...)

    • 系统调用,CPU切换到内核态。

    • 内核分配一块新的内核内存(一个链表节点)。

    • 内核复制 (Copy) 进程A的 msg_buffer(用户空间)到新分配的内核内存中。(第一次复制)

    • 内核将这个新节点挂在消息链表的尾部。

    • 内核检查是否有任何进程正在Waiting(阻塞)在此队列上等待接收消息。

    • 如果有,内核唤醒一个(或多个,取决于接收类型)等待的进程(PCB状态:Waiting -> Ready)。

  3. 接收 (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上下文切换到其他进程。

③ 优缺点

  • 优点:

    1. 解耦: 进程A和B不需要同时运行。A可以发送几条消息然后退出,B稍后再启动来读取它们(消息队列是随内核持续存在的,直到被显式删除或系统重启)。

    2. 类型/优先级: 最大的特性。允许你实现复杂的通信逻辑,如用类型1作普通请求,类型100作紧急插队请求。

  • 缺点:

    1. 两次复制: 性能开销与管道类似(User -> Kernel, Kernel -> User),远慢于共享内存。

    2. 限制: 队列的总字节数和最大消息数都受内核限制。

    3. 开销: 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;
}

运行:

  1. 编译: gcc msg_sender.c -o sender gcc msg_receiver.c -o receiver

  2. 终端1: ./sender

  3. 终端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/writesend/recv 进行**全双工(Full-Duplex)**通信(即双方可以同时读写)。

② 底层原理 (内核层面)

这是对管道的终极魔改。它复用了内核中绝大部分的网络协议栈代码,但把"数据包目的地"从"网卡驱动"改为了"另一个本地进程"。

  1. socket(AF_UNIX, ...)

    • AF_UNIX (Address Family UNIX) 是关键。它告诉内核:“我要用网络API,但通信方在本地。”

    • 内核在内部创建一个 inode(就像管道和FIFO一样),并分配内核数据结构(struct socket),这个结构体里包含了两个缓冲区(一个用于读,一个用于写),从而实现全双工。

  2. bind(fd, "/tmp/my.sock") (Server):

    • 内核在文件系统中创建一个特殊文件 /tmp/my.sock(类型为 s,即socket)。

    • 这个文件inode的作用就是**“路标”**,它指向服务器 listen 的那个 socket 结构体。

  3. listen(fd, ...) (Server):

    • 内核将这个 socket 标记为“被动”(Passive)模式,并为其分配一个“半连接队列”和“全连接队列”,准备接受客户端。

  4. connect(fd_cli, "/tmp/my.sock") (Client):

    • 系统调用。客户端内核通过路径 /tmp/my.sock 找到服务器的“路标”inode,进而找到服务器正在 listensocket 结构体。

    • 内核在内部模拟一次TCP三方握手(这非常快,只是在内核中设置几个状态标志)。

    • 内核创建一个新的 struct socket(包含两个新缓冲区)来代表这个已建立的连接,并将其放入服务器的“全连接队列”中。

    • 内核唤醒正在 accept() 处阻塞的服务器进程。

  5. accept(fd_srv, ...) (Server):

    • 服务器进程被唤醒。

    • accept 系统调用从“全连接队列”中取出这个新连接,并为它分配一个新的文件描述符(例如 fd=5)。

    • accept 返回这个新的 fd

  6. send/recv (通信):

    • 现在,客户端的 fd_cli 和服务器的新 fd=5 指向同一个内核连接对象(它内部有两个缓冲区)。

    • 客户端 send():系统调用,数据从客户端User空间 (复制1) -> 内核的“写缓冲区”。

    • 服务器 recv():系统调用,数据从内核的“写缓冲区” (复制2) -> 服务器User空间。

    • 反之亦然。这个过程与管道的阻塞/唤醒原理完全相同。

③ 优缺点

  • 优点:

    1. API标准: 使用与网络编程完全相同的API。如果你的本地服务未来需要改成网络服务,几乎不需要改代码,只需把 AF_UNIX 改成 AF_INET

    2. 全双工: 双方可以同时收发,比管道灵活。

    3. C/S模型: 天然支持一个服务器对多个客户端的复杂模型。

    4. 传递文件描述符 (SCM_RIGHTS): UDS有一个独门绝技,它可以在进程间传递打开的文件描述符。这是一个高级技巧,内核直接复制进程A的文件描述符表项到进程B,零数据拷贝,极其高效。

  • 缺点:

    1. 两次复制: 同样存在User/Kernel数据拷贝,性能不如共享内存。

    2. 设置复杂: 代码量比管道或消息队列要多。

④ 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;
}

运行:

  1. 编译: gcc uds_server.c -o server gcc uds_client.c -o client

  2. 终端1: ./server (它会阻塞在 accept)

  3. 终端2: ./client (它会连接、发送、接收,然后两个程序会相继退出)

7、IPC 机制对比总结

机制 速度 数据流 亲缘关系 同步/阻塞 底层原理
匿名管道 单向 (Half-Duplex) 必须 (父子) 自带阻塞

内核缓冲区 + FD 继承

命名管道(FIFO) 单向 无需 自带阻塞

内核缓冲区 + 文件系统inode

共享内存 极快 任意 (N/A) 无需 无阻塞 (危险!) 页表映射 (MMU)
信号量 (非数据) 信号 (N/A) 无需 自带阻塞

内核计数器 + 原子操作

(消息队列) 中等 多对多 无需 自带阻塞 内核链表 + 复制
(Unix套接字) 中等 双向 (Full-Duplex) 无需 自带阻塞

内核缓冲区 + 文件系统inode

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐