【Linux 内核专栏 17】进程间通信 IPC(上)— 管道·信号·System V IPC·mmap·文件锁

📚 Linux 内核专栏 · 持续更新中

本系列从底层原理到源码实战,逐篇拆解内核机制。

👉 想系统追更,戳这里查看完整专栏:

【Linux内核系列】

👉 其他技术系列专栏,欢迎关注、交流、共同成长:

【I2C 技术系列】 【串口技术系列】【SPI 技术系列】【RISC-V 架构系列】


本篇定位 :Linux 内核专题 L2 补充篇 (进程间通信 IPC 上半部)。【linux内核专栏 03】进程管理与调度 讲了进程"是什么"、【linux内核专栏 07】同步与并发 讲了"内核态怎么加锁"------但两个进程怎么交换数据、怎么通知对方 ,这套机制叫 IPC(Inter-Process Communication) 。IPC 分两篇:本篇(上)讲用户态进程间的经典通信------管道(pipe/FIFO)、信号(signal)、System V IPC 三件套(消息队列/共享内存/信号量)、mmap 共享内存、文件锁;下篇(18)讲 Socket/Netlink/fd 三件套/futex/线程同步 + 内核↔用户态通信。读完你能选对 IPC、能讲清每种机制内核怎么实现、能对照 FreeRTOS 队列/任务通知。
嵌入式视角:IPC 是你从 RTOS 转过来最容易"会用不会选"的一块

FreeRTOS 通信就两招:队列 (任务间传数据)+ 任务通知 (轻量事件)。到了 Linux,IPC 突然冒出十几种:管道、信号、消息队列、共享内存、信号量、socket、eventfd......选哪个 是 BSP 工程师天天要拍的板。

核心心法:

① 流式传数据 → 管道/socket;

② 事件通知 → 信号/eventfd;

③ 大数据共享 → 共享内存(mmap/shm);

④ 同步 → 信号量/mutex;

⑤ 内核↔用户 → Netlink/ioctl/procfs(下篇)。

本篇先吃透用户态间通信,下篇吃透内核交互。


目录

  • [一、IPC 全景与选择决策树](#一、IPC 全景与选择决策树)
    • [1.1 为什么需要 IPC](#1.1 为什么需要 IPC)
    • [1.2 IPC 机制全景](#1.2 IPC 机制全景)
    • [1.3 五大维度分类](#1.3 五大维度分类)
    • [1.4 选择决策树](#1.4 选择决策树)
  • [二、管道 pipe 与 FIFO](#二、管道 pipe 与 FIFO)
    • [2.1 匿名管道 pipe](#2.1 匿名管道 pipe)
    • [2.2 内核实现:pipe_buffer](#2.2 内核实现:pipe_buffer)
    • [2.3 命名管道 FIFO](#2.3 命名管道 FIFO)
  • [三、信号 signal](#三、信号 signal)
  • [四、System V IPC 三件套](#四、System V IPC 三件套)
    • [4.1 共性:key / 对象 / ipcs](#4.1 共性:key / 对象 / ipcs)
    • [4.2 消息队列 msgq](#4.2 消息队列 msgq)
    • [4.3 共享内存 shm(最快 IPC)](#4.3 共享内存 shm(最快 IPC))
    • [4.4 信号量 sem(SysV,跨进程同步)](#4.4 信号量 sem(SysV,跨进程同步))
    • [4.5 System V IPC 内核实现共性](#4.5 System V IPC 内核实现共性)
  • [五、POSIX IPC](#五、POSIX IPC)
  • [六、mmap 共享内存](#六、mmap 共享内存)
    • [6.1 mmap 做 IPC 的两种方式](#6.1 mmap 做 IPC 的两种方式)
    • [6.2 MAP_SHARED vs MAP_PRIVATE](#6.2 MAP_SHARED vs MAP_PRIVATE)
    • [6.3 共享内存的同步(必配)](#6.3 共享内存的同步(必配))
  • [七、文件锁 flock 与 fcntl](#七、文件锁 flock 与 fcntl)
    • [7.1 flock(建议锁,整文件)](#7.1 flock(建议锁,整文件))
    • [7.2 fcntl(记录锁/租约锁,按字节区段)](#7.2 fcntl(记录锁/租约锁,按字节区段))
  • [八、对照 FreeRTOS / RISC-V](#八、对照 FreeRTOS / RISC-V)
    • [8.1 IPC 机制对照](#8.1 IPC 机制对照)
    • [8.2 本质差异](#8.2 本质差异)
    • [8.3 RISC-V 视角](#8.3 RISC-V 视角)
  • [九、AEGIX BSP 实战](#九、AEGIX BSP 实战)
    • [9.1 守护进程信号处理(SIGHUP 重载 / SIGCHLD 收尸)](#9.1 守护进程信号处理(SIGHUP 重载 / SIGCHLD 收尸))
    • [9.2 共享内存 + 信号量:多进程配置共享](#9.2 共享内存 + 信号量:多进程配置共享)
    • [9.3 管道拉起子进程读输出](#9.3 管道拉起子进程读输出)
    • [9.4 选择实战(本篇机制)](#9.4 选择实战(本篇机制))
  • 十、小结速查与决策树
    • [10.1 机制速查](#10.1 机制速查)
    • [10.2 选择决策树(精简)](#10.2 选择决策树(精简))
    • [10.3 陷阱速查](#10.3 陷阱速查)
    • [10.4 内核对象速查](#10.4 内核对象速查)
  • 十一、相关笔记

一、IPC 全景与选择决策树

1.1 为什么需要 IPC

进程的根本隔离 :每个进程有独立虚拟地址空间(【linux内核专栏 04】内存管理 讲过 mm_struct),进程 A 的 0x4000 和进程 B 的 0x4000 是两块物理内存,默认谁也碰不到谁。这是保护,但也意味着"想交换数据必须经内核搭桥"------这个桥就是 IPC。

进程隔离 vs FreeRTOS 任务共享

FreeRTOS 所有任务共享一个地址空间 ,任务间传数据直接读写全局变量(配临界区)即可,根本不需要 IPC 。Linux 进程隔离,共享必须经内核------这是"多进程"对"单地址空间多任务"的核心代价。线程则共享地址空间(同进程的线程直接读写全局),所以"线程间通信"基本就是"共享变量 + 同步原语",不需要 IPC 机制本身(见下篇 pthread)。

1.2 IPC 机制全景

机制 通信模式 范围 内核对象 典型用途
管道 pipe 字节流,单向,FIFO 父子进程 pipe_inode shell `
FIFO 字节流,单向,有名 任意进程 文件系统 inode mkfifo 命名管道
信号 signal 事件通知(1 个信号号) 任意进程 signal_struct kill/Ctrl+C/SIGCHLD
消息队列 msgq 消息(带类型),有序 任意进程(SysV key) msqid_ds 按类型分发消息
共享内存 shm 共享物理页,最快 任意进程(SysV key) shmid_kernel 大数据零拷贝共享
信号量 sem(SysV) 同步(资源计数) 任意进程 sem_array 跨进程同步(非传输)
mmap 共享 共享物理页(文件/匿名) 任意(有名)/亲缘(匿名) 文件 mapping/匿名 大数据共享,替代 shm
文件锁 flock/fcntl 文件同步 同文件进程 file_lock 多进程写同一文件
Unix socket(下篇) 流/数据报,双向 同主机 socket 本机高效双向通信
Netlink(下篇) 内核↔用户 内核↔用户 socket 驱动事件/路由/配置
eventfd/signalfd/timerfd(下篇) 事件 fd 任意进程 eventfd_ctx fd 化通知,配 epoll

1.3 五大维度分类

复制代码
按"传什么"分:
  ① 数据流     → 管道/FIFO/socket(字节流,边界消失)
  ② 消息(带边界)→ 消息队列/socket 数据报(保留消息边界)
  ③ 共享内存   → mmap/shm(直接共享页,最快,需自己同步)
  ④ 事件       → 信号/eventfd(只通知,不传数据)
  ⑤ 同步       → 信号量/mutex/文件锁(协调访问顺序)

按"有无边界"分:
  ① 流式(字节边界消失)→ 管道/FIFO/socket 流
  ② 消息(保留边界)    → 消息队列/socket 数据报

按"内核是否拷贝"分:
  ① 拷贝(内核中转)  → 管道/消息队列/socket(写者→内核缓冲→读者)
  ② 零拷贝(共享页)  → mmap/shm(映射同一物理页,无拷贝,最快)

按"进程关系"分:
  ① 仅亲缘(父子)   → 匿名 pipe/匿名 mmap
  ② 任意进程         → FIFO/SysV IPC/有名 mmap/socket

1.4 选择决策树

复制代码
要传数据还是只通知?
├─ 只通知(无数据/极少)
│   ├─ 事件简单 → signal(kill, 1 个信号号)
│   └─ 要配 epoll → eventfd(下篇,推荐)
│
├─ 传数据
│   ├─ 数据量?
│   │   ├─ 小(<几百KB)/中等 → 管道 or 消息队列 or socket
│   │   │   ├─ 父子 + 单向流 → pipe
│   │   │   ├─ 任意进程 + 单向流 → FIFO
│   │   │   ├─ 要消息边界/按类型 → 消息队列(SysV msgq)
│   │   │   ├─ 双向/本机高效 → Unix socket(下篇)
│   │   │   └─ 跨主机 → INET socket(14 网络篇)
│   │   └─ 大(几MB+/持续共享)→ 共享内存
│   │       ├─ 简单 → mmap(MAP_SHARED,推荐现代写法)
│   │       └─ 兼容老代码 → SysV shm
│   └─ 需同步(协调访问)→ 信号量 / 文件锁
│       ├─ 跨进程资源计数 → SysV sem / POSIX sem
│       ├─ 同文件多进程 → flock/fcntl
│       └─ 同进程线程 → pthread mutex(下篇,不用 IPC)
│
└─ 内核 ↔ 用户态(驱动/内核事件)→ 下篇(Netlink/ioctl/procfs/sysfs)

现代选型倾向:fd 化 + epoll

老机制(管道/信号/SysV)各有坑:信号不可靠、SysV API 笨重、管道单向。现代 Linux 倾向一切皆 fd ------Unix socket / eventfd / signalfd / timerfd 都是 fd,能统一塞进 epoll 事件循环(下篇详讲)。新代码优先 fd-based IPC;SysV IPC 维护老代码/简单脚本用。


二、管道 pipe 与 FIFO

2.1 匿名管道 pipe

特点 :

① 单向(一端写、一端读);

② 无名字 (靠 fd 继承传递);

③ 只能亲缘进程 (父子,因为只有 fork 才能传 fd);

④ 字节流(无消息边界,write 两次的内容 read 可能一次读出);

⑤ 内核缓冲(默认 64KB,可调)。

c 复制代码
#include <unistd.h>
int pipe(int fd[2]);   // fd[0]=读端, fd[1]=写端

// 典型:父进程 pipe,fork,各关一端,形成单向通道
int fd[2];
pipe(fd);                 // 父先建管道
if (fork() == 0) {        // 子
    close(fd[0]);         // 子关读端(子只写)
    write(fd[1], "hello", 5);
    close(fd[1]);
    _exit(0);
}
close(fd[1]);             // 父关写端(父只读)
char buf[16];
int n = read(fd[0], buf, sizeof(buf));  // n=5, "hello"
close(fd[0]);

关键行为:

情况 行为
读端读,所有写端关闭 read 返回 0(EOF)------这是判断对方写完的标准
写端写,所有读端关闭 write 触发 SIGPIPE (默认终止进程);忽略信号后返回 EPIPE
缓冲区满(默认 64KB) write 阻塞,等读者读走
缓冲区空 read 阻塞,等写者写

"关掉不用的端"是管道正确性的关键

父子各持两端时,父想"只读"必须 close(fd[1]),否则父自己还持有写端,子退出后 read 永远不返回 0(EOF) ------因为还有一个写端(父自己)开着。管道 EOF = 所有写端关闭,漏关一个就死等。这是管道最经典的坑。

2.2 内核实现:pipe_buffer

管道在内核里是一个 struct pipe_inode_info(挂在 inode 上),含一组 struct pipe_buffer(环形缓冲页):

c 复制代码
// 简化:内核 struct pipe_inode_info
struct pipe_inode_info {
    struct mutex mutex;             // 保护管道
    unsigned int nrbufs;            // 当前缓冲页数
    unsigned int buffers;           // 最大页数(默认 16 页 = 64KB)
    unsigned int curbuf;            // 读指针(环形)
    struct pipe_buffer *bufs;       // 环形缓冲页数组
    ...
};

// 每个 pipe_buffer 指向一页(page),记录 offset/len
struct pipe_buffer {
    struct page *page;
    unsigned int offset, len;       // 数据在这页的偏移和长度
    const struct pipe_buf_operations *ops;
};
  • 写者 write:在 bufs 末尾分配页,copy_from_user 写入,len++;
  • 读者 read:从 curbuf 读,copy_to_user,读完释放页,curbuf 前移;
  • 环形:16 页轮转;
  • 容量 = buffers × PAGE_SIZE(默认 16×4KB = 64KB),fcntl(F_SETPIPE_SZ) 可调,最大 /proc/sys/fs/pipe-max-size(默认 1MB);
  • 零拷贝优化 :splice() / tee() 能在管道和文件/socket 间直接搬 pipe_buffer 的页指针,不拷数据(对接 【Linux 内核专栏 13】快IO层 / 【Linux内核专栏 14】网络协议栈)。

2.3 命名管道 FIFO

特点:① 有名字(文件系统里一个 inode);② 任意进程可 open(不必亲缘);③ 其余同 pipe(单向、字节流、内核缓冲)。

bash 复制代码
mkfifo /tmp/myfifo          # 创建命名管道(文件类型 p)
# 终端 A 写
echo "hi" > /tmp/myfifo     # 阻塞,直到有读者
# 终端 B 读
cat < /tmp/myfifo           # 读到 "hi"
c 复制代码
// C 代码
mkfifo("/tmp/myfifo", 0666);
int fd = open("/tmp/myfifo", O_RDWR);   // 读写都开(避免阻塞)
write(fd, "hi", 2);
char buf[16]; read(fd, buf, 2);
  • open 默认阻塞 :只读 open 会等到有写者;只写 open 等到有读者;O_NONBLOCK 不等;
  • FIFO 在 VFS 里就是个特殊文件(12 VFS 篇的 inode),i_op 指向 pipe 的实现;
  • shell 的 | 就是匿名 pipe;mkfifo 是命名版本(可跨任意进程)。

嵌入式视角:管道是"流式日志/命令管道"的命脉

写守护进程,子进程拉起工具:popen("lsof", "r") 读其输出 = 管道;syslog 管道重定向;/dev/xconsole 是 FIFO。凡"一条命令的输出喂给另一条",底层都是 pipe。


三、信号 signal

3.1 信号是什么

信号 = 内核投递给进程的"软件中断" 。内核在进程从内核态返回用户态前 ,检查是否有待处理信号,有则跳到进程注册的信号处理函数执行,完事再回原流程。这是"事件通知"型 IPC------只传一个信号号(1~31 标准 + 34~64 实时),不传数据(实时信号可带少量 siginfo)。

标准信号一览(常用):

信号 号 默认动作 触发场景
SIGHUP 1 终止 终端挂起/守护进程重载配置
SIGINT 2 终止 Ctrl+C
SIGQUIT 3 终止+core Ctrl+\
SIGKILL 9 终止(不可捕/不可忽略) kill -9 强杀
SIGSEGV 11 终止+core 访问非法地址(你 06b MMU 缺页异常)
SIGPIPE 13 终止 写已关闭的管道/socket
SIGALRM 14 终止 alarm() 超时
SIGTERM 15 终止 kill 默认(优雅退出)
SIGCHLD 17 忽略 子进程退出/暂停(父 wait 用)
SIGSTOP 19 暂停(不可捕) Ctrl+Z
SIGCONT 18 继续 恢复暂停进程

3.2 产生信号

c 复制代码
#include <signal.h>
kill(pid, SIGTERM);        // 给指定进程发
raise(SIGTERM);            // 给自己发
killpg(pgid, SIGTERM);     // 给进程组发
alarm(5);                  // 5 秒后给自己发 SIGALRM(一次)
// sigqueue(pid, sig, siginfo)  // 实时信号,可带数据

内核侧:kill(pid, sig) → 找到 task_struct → 在其 pending 队列挂一个 sigqueue → 设置 TIF_SIGPENDING → 进程从内核返回用户态时 do_signal() 处理。

3.3 捕获与处理:sigaction

c 复制代码
#include <signal.h>
void handler(int sig) {
    // 信号处理函数(异步上下文,只能调异步信号安全函数!)
    write(1, "got SIGINT\n", 11);   // write 是信号安全的
}

int main(void) {
    struct sigaction sa;
    sa.sa_handler = handler;
    sigemptyset(&sa.sa_mask);       // 处理时不额外屏蔽别的信号
    sa.sa_flags = SA_RESTART;       // 被中断的 syscall 自动重启(见 3.5)
    sigaction(SIGINT, &sa, NULL);   // 捕获 SIGINT

    // 忽略:sa.sa_handler = SIG_IGN
    // 默认:sa.sa_handler = SIG_DFL
    for (;;) pause();               // 等信号
}
  • SIGKILL(9) / SIGSTOP(19) 不可捕获、不可忽略------内核直接处理,这是"最后的杀手锏",保证总能杀掉失控进程;
  • 处理函数执行期间,该信号被自动屏蔽(防递归),处理完再解除;
  • sa_mask 额外屏蔽的信号,处理期间也屏蔽。

3.4 信号掩码(阻塞)

c 复制代码
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigprocmask(SIG_BLOCK, &set, NULL);    // 屏蔽 SIGINT(不投递,但 pending)
// ... 临界区 ...
sigprocmask(SIG_UNBLOCK, &set, NULL);  // 解除,期间若来了 SIGINT 现在投递
  • "屏蔽(pending)"≠"忽略(丢弃)":屏蔽期间信号挂起,解除后投递;
  • 标准信号(1~31)多次合并 :同一信号屏蔽期间来 5 次,解除后只投递 1 次(标准信号不排队);
  • 实时信号(34~64, SIGRTMIN~SIGRTMAX)不合并 :排队投递 ,可带 siginfo(用 sigqueue 发)。

3.5 信号中断系统调用:EINTR

慢系统调用(read/write/wait/recv 等可能阻塞的)被信号打断时,返回 -1,errno=EINTR:

c 复制代码
// 不加 SA_RESTART:
int n = read(fd, buf, 100);
if (n < 0 && errno == EINTR) {
    // 被信号打断,要手动重试
    n = read(fd, buf, 100);
}

// 加 SA_RESTART:内核自动重启,不返回 EINTR(部分 syscall 重启,如 read;有些不重启,如 select)

信号处理函数里只能调"异步信号安全"函数

信号是异步的------可能打断 malloc 持锁的中间状态。在 handler 里调 printf/malloc/fopen 等非信号安全函数 = 死锁/数据破坏。只能调 write/_exit/read/waitpid/信号安全函数 。要打印就用 write(2, ...)。需要复杂处理:handler 只置标志位 volatile sig_atomic_t flag=1,主循环检查 flag 再处理(自陷模式)。

3.6 内核信号结构(对接 【linux内核专栏 03】进程管理与调度 task_struct)

c 复制代码
struct task_struct {
    ...
    struct signal_struct *signal;    // 进程级(共享):共享 pending、信号处理表
    struct sighand_struct *sighand;  // 信号处理表(k_sigaction 数组)
    sigset_t blocked;                // 本线程屏蔽
    struct sigpending pending;        // 本线程私有 pending 队列
    struct sigpending *signal_pending; // 进程共享 pending
    ...
};
  • 信号可进程级 也可线程级 :定向某线程用 tgkill/pthread_kill;
  • 内核投递点:每次从内核态返回用户态 (中断/异常/syscall 返回)检查 TIF_SIGPENDING;
  • 投递 = 跳到用户态 handler(设栈帧 rt_sigframe),handler 末尾 sigreturn syscall 回内核,内核恢复原上下文。

对照 FreeRTOS:信号 vs 任务通知

FreeRTOS 任务通知 (xTaskNotifyGive/ulTaskNotifyTake)是"轻量事件通知",类似信号------但 FreeRTOS 通知可带 32 位值、可计数、直接唤醒任务,比信号干净。Linux 信号古老且坑多(异步不安全、合并、EINTR),现代代码倾向 eventfd/signalfd 把信号 fd 化(下篇)。守护进程的 SIGHUP 重载配置、SIGCHLD 收尸子进程------这两类信号仍主流。


四、System V IPC 三件套

System V IPC 是 Unix SysV 加的一套 IPC:消息队列、共享内存、信号量。

三者共性 :

① 用 key_t(通常 ftok() 生成)标识;

② ipcs 查看、ipcrm 删除;

③ 内核持久(进程退出后对象还在,除非显式删);

④ API 笨重(老接口)。

4.1 共性:key / 对象 / ipcs

c 复制代码
key_t key = ftok("/etc/profile", 'A');   // 路径+项目号生成 key(同机约定)
int id = msgget(key, IPC_CREAT | 0666);  // 创建/打开对象,返回 id
// ... 用 id 操作 ...
// ipcs -q  查消息队列; ipcs -m 查共享内存; ipcs -s 查信号量
// ipcrm -q <id>  删

SysV IPC 的坑:不随进程退出自动删

SysV IPC 对象内核持久 ------进程 crash 也不删,泄漏会一直占内存/ID。必须显式 IPC_RMID 或 ipcrm。生产环境用要封装好生命周期管理。新代码优先 POSIX IPC 或 mmap。

4.2 消息队列 msgq

特点 :

① 消息带边界 (每条消息独立,不像管道字节流);

② 消息带类型 (long type),读者可按类型取;

③ 内核缓冲,有上限。

c 复制代码
struct msgbuf {
    long mtype;          // 类型(>0,用户定)
    char mtext[100];     // 数据
};

int qid = msgget(key, IPC_CREAT | 0666);

// 写(发送)
struct msgbuf msg = {.mtype = 1};
strcpy(msg.mtext, "hello");
msgsnd(qid, &msg, strlen(msg.mtext), 0);   // 阻塞;IPC_NOWAIT 不阻塞

// 读(接收)
struct msgbuf rcv;
msgrcv(qid, &rcv, 100, 1, 0);    // 收类型=1 的;0=任意类型;负值=类型<=|n| 最小
  • mtype 让你"按类型分发":不同类型给不同消费者,一个队列多路复用;
  • 上限:/proc/sys/kernel/msgmni(队列数)/msgmax(单条最大)/msgmnb(队列总字节),默认队列总 16KB;
  • 内核对象 struct msg_queue,挂 msg_msg 链表。

4.3 共享内存 shm(最快 IPC)

特点 :

① 多个进程映射同一物理页 到各自虚拟地址;

② 无内核中转拷贝 (读写直达),最快;

③ 但不带同步(共享了你要自己加信号量/mutex 保护)。

c 复制代码
int shmid = shmget(key, 4096, IPC_CREAT | 0666);  // 创建 4KB 共享段
char *addr = shmat(shmid, NULL, 0);    // 映射到本进程地址,返回虚拟地址
// 现在 addr 指向共享内存,读写直达物理页
addr[0] = 'X';                          // 另一进程 shmat 后能看到
shmdt(addr);                            // 解除映射
shmctl(shmid, IPC_RMID, NULL);          // 删除(标记,所有人 dt 后才释放)
  • shmat 返回的虚拟地址每进程不同(各自 VMA 分配),但物理页同一;
  • 写入不立即同步 到对方------同主机、同架构、cache 一致性硬件保证可见性,但仍可能需内存屏障 (【linux内核专栏 07】同步与并发 smp_mb)保证顺序;
  • 同步必须自己做:配 SysV 信号量(4.4)或 pthread 进程间 mutex(下篇);
  • 内核对象 struct shmid_kernel,实际映射靠 shmem_file_setup(基于 tmpfs,对接 【linux内核专栏 04】内存管理 mmap)。

共享内存 = 最快 + 最危险

最快(零拷贝),但没有同步 ------两个进程同时写同一地址 = 数据破坏,且没有原子保证(你得自己加锁或用原子操作)。共享内存配同步原语是标配:shm(数据)+ sem(同步)。这也是"为什么 SysV 把 shm 和 sem 配成一对"。

4.4 信号量 sem(SysV,跨进程同步)

注意 :这是 SysV IPC 信号量 ,和 【linux内核专栏 07】同步与并发 篇的内核态 struct semaphore 不是一回事------07 那个是内核内同步,这个是用户态跨进程同步(经 syscall 进内核操作)。

特点 :

① 不是"传数据",是"同步/资源计数";

② 一个 sem array 可含多个信号量 (集合);

③ 操作是 semop 数组(可一次原子操作多个);

④ 永远是 IPC_NOWAIT / 阻塞 / SEM_UNDO(进程退出自动回滚)。

c 复制代码
// 创建含 1 个信号量的集合,初值 1(二值互斥)
int semid = semget(key, 1, IPC_CREAT | 0666);
union semun { int val; struct semid_ds *buf; unsigned short *array; } arg;
arg.val = 1;
semctl(semid, 0, SETVAL, arg);          // 0 号信号量设为 1

// P 操作(等/减)
struct sembuf p = {.sem_num = 0, .sem_op = -1, .sem_flg = 0};
semop(semid, &p, 1);

// 临界区(跨进程共享)

// V 操作(加/释放)
struct sembuf v = {.sem_num = 0, .sem_op = +1, .sem_flg = 0};
semop(semid, &v, 1);
  • sem_op 负=P(减,不够则阻塞),正=V(加,唤醒等待者),0=等信号量变 0;
  • SEM_UNDO:进程 crash 自动回滚其操作,防"持锁进程死了导致死锁";
  • 一次 semop 操作多个信号量原子------要么全成功要么全不变,这是 SysV sem 的核心能力(可用来实现复杂的资源分配协议)。

4.5 System V IPC 内核实现共性

三类 SysV IPC 在内核里都是 ipc_ids 管理,对象类型:

机制 内核结构 关键操作
消息队列 struct msg_queue msgsnd/msgrcv(挂 msg_msg 链)
共享内存 struct shmid_kernel shmat/shmdt(建 shmem mapping)
信号量 struct sem_array semop(改 sem 值,挂等待队列)
  • 都有 ipc_perm(权限 key/uid/gid/mode);
  • ipcs 命令读 /proc/sysvipc/{msg,shm,sem};
  • 限制在 /proc/sys/kernel/{msg*,shm*,sem*}。

五、POSIX IPC

POSIX IPC 是 SysV IPC 的现代替代(IEEE Std 1003.1b),API 更干净:

① 用名字 而非 key;

② 引用计数随进程(fd 或 mmap);

③ 有超时/定时;

④ 一般更易用。

SysV POSIX 说明
msgget/msgsnd/msgrcv mq_open/mq_send/mq_receive 消息队列,带优先级
shmget/shmat/shmdt shm_open/mmap/munmap/shm_unlink 共享内存(本质是 mmap 共享文件)
semget/semop/semctl sem_open/sem_wait/sem_post/sem_unlink 命名信号量
c 复制代码
// POSIX 消息队列
#include <mqueue.h>
mqd_t mq = mq_open("/myqueue", O_CREAT | O_RDWR, 0666, NULL);
mq_send(mq, "hi", 2, 0);            // 优先级 0
char buf[16]; unsigned prio;
mq_receive(mq, buf, 16, &prio);

// POSIX 共享内存(本质:开一个 tmpfs 文件,mmap)
int fd = shm_open("/myshm", O_CREAT | O_RDWR, 0666);
ftruncate(fd, 4096);
char *p = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
p[0] = 'X';
munmap(p, 4096); shm_unlink("/myshm");

// POSIX 命名信号量
sem_t *s = sem_open("/mysem", O_CREAT, 0666, 1);
sem_wait(s);    // P
// 临界区
sem_post(s);    // V
sem_unlink("/mysem");
  • POSIX 消息队列支持 mq_notify(信号或新线程通知,异步)、优先级;
  • POSIX 共享内存就是 mmap 一个 /dev/shm 下的 tmpfs 文件 (Linux 实现)------所以"POSIX shm"和"mmap 共享文件"本质同源(见 【linux内核专栏 06】系统调用);
  • 编译加 -lrt(POSIX 实时扩展库);
  • 新代码优先 POSIX IPC,API 干净且更易集成(命名、fd 化)。

六、mmap 共享内存

6.1 mmap 做 IPC 的两种方式

【linux内核专栏 04】内存管理 篇讲 mmap 是内存映射(文件/匿名),这里聚焦 IPC 视角 :用 MAP_SHARED 让多进程映射同一物理页。

c 复制代码
void *mmap(void *addr, size_t len, int prot, int flags, int fd, off_t off);
// flags 关键:MAP_SHARED(共享,写可见)/ MAP_PRIVATE(私写时复制)
//          MAP_ANONYMOUS(匿名,无文件)/ 有 fd(文件映射)

方式 A:匿名共享(亲缘进程)

c 复制代码
char *p = mmap(NULL, 4096, PROT_READ|PROT_WRITE,
               MAP_SHARED | MAP_ANONYMOUS, -1, 0);
if (fork() == 0) {
    p[0] = 'X';        // 子写
    _exit(0);
}
wait(NULL);
printf("%c\n", p[0]);  // 父读到 'X'(共享)
munmap(p, 4096);
  • MAP_SHARED | MAP_ANONYMOUS:内核建一个匿名 shmem 对象,fork 后父子继承同一物理页;
  • 只能亲缘(父子),因为匿名对象没法给"无关进程"找名字。

方式 B:文件共享(任意进程)

c 复制代码
// 进程 A
int fd = open("/tmp/share", O_RDWR|O_CREAT, 0666);
ftruncate(fd, 4096);
char *p = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
p[0] = 'X';

// 进程 B(独立)
int fd = open("/tmp/share", O_RDWR);
char *p = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
printf("%c\n", p[0]);  // 'X'
  • 同一文件 + MAP_SHARED → 映射同一物理页(经 page cache);
  • 任意进程(只要能 open 该文件)即可共享;
  • 这本质就是 POSIX shm(/dev/shm 下的 tmpfs 文件);
  • 写入会(按内核策略)回写 page cache → 文件(可见到其他映射者);msync 强制刷盘。

6.2 MAP_SHARED vs MAP_PRIVATE

flag 写入效果 用途
MAP_SHARED 写入写回文件/page cache,其他映射者可见 IPC 共享(本节)/共享文件
MAP_PRIVATE 写入触发写时复制(COW),只影响本进程 私有映射(如加载 ELF .data,对接 02 启动)

mmap 共享 = 现代 IPC 共享内存首选

mmap 文件共享 比 SysV shmget 更灵活:

① 有文件名(可 open,自然命名);

② 配 page cache(对接 【linux内核专栏 04】内存管理);

③ 和 POSIX shm 同源;

④ 可 msync 控制刷盘。

新代码用 mmap,不用 SysV shm。SysV shm 只在维护老代码时见。

6.3 共享内存的同步(必配)

共享内存本身没同步,必须配:

c 复制代码
// 进程间互斥:用 pthread 进程间 mutex(下篇详),放在共享内存首部
pthread_mutex_t *mtx = (void *)p;     // p 是 mmap 共享内存
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED);  // 关键:进程间共享
pthread_mutex_init(mtx, &attr);

pthread_mutex_lock(mtx);
// 临界区(操作 p + sizeof(mutex) 之后的数据)
pthread_mutex_unlock(mtx);
  • PTHREAD_PROCESS_SHARED 让 mutex 可跨进程用(mutex 在共享内存里);
  • 或用 SysV 信号量(4.4)/ POSIX 信号量(5);
  • 或用原子操作(atomic_t 的用户态版 __atomic)+ 自定义协议;
  • 没有同步的共享内存 = 定时炸弹。

七、文件锁 flock 与 fcntl

多进程访问同一文件时的协调,有两套锁:

7.1 flock(建议锁,整文件)

c 复制代码
#include <sys/file.h>
int fd = open("/tmp/data", O_RDWR);
flock(fd, LOCK_EX);      // 排他锁(写)
// 临界区
flock(fd, LOCK_UN);      // 解锁
// LOCK_SH 共享锁(读);LOCK_NB 非阻塞
  • 整文件级(不分区段);
  • 建议锁 (advisory)------只有大家都 flock 才生效,不拦不锁的直接 write;
  • 只关联 fd (open file description),同进程内 dup 的 fd 共享锁,close 最后一个 fd 释放;
  • 内核 struct file_lock,挂 inode 的锁列表。

7.2 fcntl(记录锁/租约锁,按字节区段)

c 复制代码
struct flock fl = {
    .l_type = F_WRLCK,       // F_RDLCK/F_WRLCK/F_UNLCK
    .l_whence = SEEK_SET,
    .l_start = 0,            // 起始偏移
    .l_len = 100,            // 锁 0~99 字节(区段)
};
fcntl(fd, F_SETLK, &fl);    // 设锁(非阻塞);F_SETLKW 阻塞
// F_GETLK 查询是否被锁
  • 按区段(字节范围),可锁文件的一部分;
  • 关联 进程(PID),同进程重复加锁不冲突;
  • Linux 上也是建议锁(强制锁需挂载 mand,基本废弃);
  • 租约锁(F_SETLEASE):给文件加租约,别人要打开/写时内核先发信号给你(F_LEASE),用于 Samba/NFS 之类。
flock fcntl
粒度 整文件 字节区段
关联 open file(fd) 进程(PID)
类型 建议锁 建议(强制基本废弃)
典型 shell 脚本互斥 数据库按行锁

嵌入式视角:文件锁用得少,但知道有

嵌入式场景多进程写同一配置/日志文件时用 flock 做互斥(如多个采集进程写同一日志)。BSP 写驱动基本不碰文件锁,但用户态守护进程会。


八、对照 FreeRTOS / RISC-V

8.1 IPC 机制对照

通信需求 FreeRTOS Linux 用户态 IPC
任务间传数据 队列(xQueueSend/Receive) 管道/消息队列/socket
轻量事件通知 任务通知(xTaskNotifyGive) 信号/eventfd(下篇)
共享大数据 共享全局数组(同地址空间) mmap/shm(共享内存)
跨任务同步 二值/计数信号量、mutex SysV/POSIX 信号量、pthread mutex(下篇)
多任务同步点 事件组(xEventGroup) eventfd/futex(下篇)
中断→任务通知 xxxFromISR + 任务通知 内核↔用户:Netlink/kill(下篇)

8.2 本质差异

FreeRTOS Linux
地址空间 所有任务共享一个 每进程独立(需 IPC 桥)
默认通信 全局变量 + 队列 管道/IPC 机制(经内核)
同步 信号量/mutex(单核,关中断) 信号量/mutex(多核,futex/自旋)
事件 任务通知/事件组 信号/eventfd
零拷贝 自然(共享地址) 共享内存(mmap/shm)

8.3 RISC-V 视角

  • 信号投递:RISC-V 上信号处理函数的栈帧 rt_sigframe、sigreturn 由内核 setup,经 ecall(06 syscall)进入;
  • 共享内存可见性:RISC-V RVWMO 弱内存模型(06b),多核共享内存可能需 fence rw,rw(对应 smp_mb)保证顺序;
  • mmap:RISC-V Sv39/Sv48 页表(04/06b),MAP_SHARED 映射靠页表项共享同一物理页;
  • futex(下篇):RISC-V 上 futex 等待用内核 futex 系统调用,唤醒靠 futex_wake,底层是内核等待队列(非原子指令忙等)。

九、AEGIX BSP 实战

9.1 守护进程信号处理(SIGHUP 重载 / SIGCHLD 收尸)

AEGIX 守护进程标准信号用法:

c 复制代码
#include <signal.h>
#include <sys/wait.h>

static volatile sig_atomic_t reload_flag = 0;
static volatile sig_atomic_t child_died = 0;

void sighup_handler(int sig)  { reload_flag = 1; }   // 只置标志
void sigchld_handler(int sig) { child_died = 1; }

int main(void) {
    struct sigaction sa = {0};
    sa.sa_handler = sighup_handler; sa.sa_flags = SA_RESTART;
    sigaction(SIGHUP, &sa, NULL);
    sa.sa_handler = sigchld_handler;
    sigaction(SIGCHLD, &sa, NULL);
    signal(SIGPIPE, SIG_IGN);          // 写断管道不崩(返回 EPIPE)

    for (;;) {
        if (reload_flag) { reload_flag = 0; reload_config(); }
        if (child_died)  { child_died = 0;
            while (waitpid(-1, NULL, WNOHANG) > 0) ;   // 收尸所有子进程
        }
        // ... 主循环 ...
        pause();
    }
}
  • SIGHUP:守护进程约定"重载配置"(因守护进程无终端,HUP 复用为重载);
  • SIGCHLD :子进程退出时内核发,父 waitpid(WNOHANG) 循环收尸,防僵尸;
  • SIGPIPE 忽略 :写断开的管道/socket 不致崩,改检查 EPIPE;
  • handler 只置 sig_atomic_t 标志,主循环处理(自陷模式,避免信号不安全)。

9.2 共享内存 + 信号量:多进程配置共享

多采集进程共享一份传感器配置(避免各读各的):

c 复制代码
// 用 mmap 文件共享 + pthread 进程间 mutex(放共享内存头)
typedef struct {
    pthread_mutex_t lock;          // 进程间 mutex,放共享内存首部
    int sensor_count;
    float values[64];
} shared_cfg_t;

shared_cfg_t *init_shared(const char *path) {
    int fd = open(path, O_RDWR|O_CREAT, 0666);
    ftruncate(fd, sizeof(shared_cfg_t));
    shared_cfg_t *s = mmap(NULL, sizeof(shared_cfg_t),
                           PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
    static int inited = 0;
    if (!inited) {                  // 第一个映射者初始化 mutex
        pthread_mutexattr_t a;
        pthread_mutexattr_init(&a);
        pthread_mutexattr_setpshared(&a, PTHREAD_PROCESS_SHARED);
        pthread_mutex_init(&s->lock, &a);
        inited = 1;
    }
    return s;
}

// 任意进程
shared_cfg_t *cfg = init_shared("/dev/shm/aegix_cfg");
pthread_mutex_lock(&cfg->lock);
cfg->values[0] = 23.5;              // 写共享
float v = cfg->values[1];           // 读其他进程写的
pthread_mutex_unlock(&cfg->lock);
  • mmap /dev/shm(tmpfs,纯内存,不落盘)+ PTHREAD_PROCESS_SHARED mutex;
  • 比 SysV shmget + semop 更现代,API 更干净;
  • 注意 mutex 初始化只做一次(用文件大小判断是否首个)。

9.3 管道拉起子进程读输出

守护进程拉起 lsof 看打开文件:

c 复制代码
FILE *fp = popen("lsof -p $(pidof aegix_daemon)", "r");   // popen 内部 pipe+fork+exec
char line[256];
while (fgets(line, sizeof(line), fp)) {
    syslog(LOG_INFO, "%s", line);     // 记到日志
}
pclose(fp);
  • popen = pipe + fork + exec + fdopen,封装好;
  • 等价手写:pipe → fork → 子 dup2(fd1,1) → exec → 父 read fd0。

9.4 选择实战(本篇机制)

AEGIX 场景 选什么 理由
守护进程重载配置 SIGHUP 约定,kill -HUP 触发
收子进程尸体 SIGCHLD + waitpid 防僵尸
多进程共享传感器配置 mmap /dev/shm + pthread mutex 大数据 + 零拷贝 + 现代同步
拉起工具读输出 popen(pipe) 流式,父子单向
多进程写同一日志 flock 整文件互斥
简单消息分发 SysV 消息队列(老代码)/ Unix socket(新,下篇) 按类型/双向

十、小结速查与决策树

10.1 机制速查

机制 传什么 边界 拷贝 范围 典型
pipe 字节流 无 内核中转 父子 shell `
FIFO 字节流 无 内核中转 任意 命名管道
signal 信号号 --- --- 任意 kill/Ctrl+C
msgq 消息 有 内核中转 任意 按类型分发
shm 共享页 --- 零拷贝 任意 大数据共享
SysV sem 同步 --- --- 任意 跨进程资源计数
mmap 共享 共享页 --- 零拷贝 亲缘/任意 现代共享内存
flock/fcntl 文件锁 --- --- 同文件 文件互斥

10.2 选择决策树(精简)

复制代码
传数据?
├─ 否(只通知)→ signal / eventfd(下篇)
├─ 小/流式 → pipe(父子)/FIFO(任意)/socket(下篇)
├─ 消息带边界/按类型 → 消息队列(SysV)/socket 数据报(下篇)
├─ 大数据共享 → mmap(MAP_SHARED,首选)/SysV shm(老)
└─ 同步 → 信号量(SysV/POSIX)/文件锁(flock)/pthread mutex(下篇,进程间)

10.3 陷阱速查

陷阱 说明
管道漏关写端 read 永不 EOF
写断管道 SIGPIPE 默认终止 → 先 signal(SIGPIPE, SIG_IGN)
信号 handler 调不安全函数 printf/malloc 死锁 → 只调信号安全函数 + 置标志
标准信号合并 同信号多次只投 1 次 → 用实时信号(SIGRTMIN+)
共享内存无同步 数据破坏 → 必配 mutex/信号量
SysV IPC 不自动删 泄漏 → 显式 IPC_RMID / ipcrm
被信号打断 syscall EINTR → SA_RESTART 或手动重试

10.4 内核对象速查

机制 内核结构
pipe/FIFO struct pipe_inode_info + pipe_buffer
signal struct signal_struct/sighand_struct/sigpending
SysV msgq struct msg_queue + msg_msg
SysV shm struct shmid_kernel(基于 shmem/tmpfs)
SysV sem struct sem_array + sem
mmap 共享 VMA(vm_area_struct)+ page cache

十一、相关笔记


【Linux 内核专栏 00】总目录 · 上一篇:【Linux内核专栏 16】调试与性能 · 下一篇:\[18-进程间通信IPC下-Socket-Netlink-fd-futex-线程同步] · L2 补充篇(IPC 上)


✨ 感谢你的阅读 ✨

技术之路漫漫,分享是为了更好地交流。

如果本文的内容对你有启发,希望能得到你的支持:

👍 点赞 | ⭐ 收藏 | 👀 关注

如果你在调试过程中遇到了其他问题,

欢迎在 评论区 💬 留言,我们一起探讨。

关注我,一起交流底层开发的那些事儿!🚀


相关推荐
小能喵41 分钟前
kismet -h 帮助信息 中文版
linux·服务器·网络
seuzhouchenglong42 分钟前
服务器通过本地电脑连接本地开发板
运维·服务器
AR-26710-1 小时前
Linux Day21——systemctl操作
linux·运维
Zw-awa1 小时前
PID 用顺了,然后呢:增强手术与前馈
stm32
臻选云栗子1 小时前
PCDN晚高峰掉量原因分析及优化建议
服务器·网络·安全·网络安全·阿里云·云计算·信息与通信
YumiProxy1 小时前
流媒体区域内容访问:代理IP的技术原理、检测机制与稳定性优化
服务器·网络·网络协议·tcp/ip·ip
额额额对了1 小时前
Linux 驱动开发:基于 platform 总线 + misc 设备的 DS18B20 单总线温度驱动
linux·c语言·开发语言·驱动开发·嵌入式硬件
木雷坞1 小时前
Docker Compose部署Metabase:应用库隔离与日期筛选看板
运维·docker·容器
zcmodeltech1 小时前
沙盘模型控制系统STM32和PLC方案对比:选型逻辑与代码实现
stm32·嵌入式硬件·自动化