信号用户态编程 · API 速查——从 signal 到 sigwait/signalfd 的全家桶
这是 signals-user(用户态编程总纲)的拆分篇之一,专门讲信号 API 全家桶:注册(
sigaction)、发送(kill/raise/killpg/sigqueue)、屏蔽(sigprocmask/pthread_sigmask)、同步等待(sigwait族)、信号转 fd(signalfd)、备用栈(sigaltstack)、原子等待(sigsuspend)、以及sa_flags速查表。配套拆分篇:十大关键坑 · 实战 Demo。
一句话认知(来自总纲):信号难在 handler 在任意时刻"插入"你的正常执行流——handler 只能用 async-signal-safe 函数(否则死锁)、共享变量要
volatile sig_atomic_t(否则数据竞争)、系统调用可能返回 EINTR(必须处理)。本篇只答"有哪些 API、各自怎么用",坑看坑篇,例子看 Demo 篇。
破题:API 又多又杂,先记住一条主线
信号 API 看着一大堆,其实按"信号生命周期"只有四类:注册处置(handler 怎么定)→ 屏蔽(哪些先挡着)→ 发送(谁来触发)→ 等待/投递(怎么收到并处理)。每类 1-2 个主力 API,其余是变体。记住这条主线,速查就不会乱。
| 生命周期环节 | 主力 API | 变体/补充 |
|---|---|---|
| 注册处置 | sigaction() | signal()(历史遗留,别用) |
| 屏蔽 | sigprocmask() / pthread_sigmask() | sigemptyset/sigaddset 等信号集操作 |
| 发送 | kill() | raise()(发给自己)、killpg()(发给进程组)、sigqueue()(带数据) |
| 等待/投递 | sigwait() 族(同步) | signalfd()(转 fd)、sigsuspend()/pause()(原子等待)、sigaltstack()(备用栈) |
一、注册:signal() 与 sigaction()
1.1 signal() —— 历史遗留,避免使用
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
// signum: 信号编号
// handler: SIG_DFL / SIG_IGN / 函数指针
// 返回值: 旧 handler 地址, 失败返回 SIG_ERR不要用 signal()! POSIX 已将其列为 obsolete:行为不可移植(各 Unix 变体对重启/屏蔽语义实现不同);System V 下 handler 执行一次后自动恢复 SIG_DFL;无法设 SA_SIGINFO 拿发送者 PID/故障地址。请用 sigaction()——你只在十年前的代码里见到它。
1.2 sigaction() —— 标准信号注册方式
sigaction() 是注册信号 handler 的唯一推荐方式。签名:
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
// signum: 信号编号 (不能是 SIGKILL=9 或 SIGSTOP=19)
// act: NULL = 只查询, 非空 = 设置新处置
// oldact: NULL = 不关心旧值, 非空 = 输出旧处置
// 返回值: 0 成功, -1 失败 (errno: EINVAL/EINTR/EFAULT)struct sigaction 关键字段:
| 字段 | 含义 | 注意事项 |
|---|---|---|
sa_handler | 简单 handler:void (*)(int) | 不需要 siginfo 时用 |
sa_sigaction | 扩展 handler:void (*)(int, siginfo_t*, void*) | 需要 si_pid/si_addr/si_value 时用 |
sa_mask | handler 执行期间自动加入屏蔽字的额外信号集 | 当前信号已自动屏蔽(若无 SA_NODEFER),这里是追加 |
sa_flags | 行为标志位 | 见 §1.10 速查表 |
sa_restorer | 已废弃,不要填 | — |
sa_handler和sa_sigaction是 union,通过sa_flags & SA_SIGINFO区分用哪一个。设了SA_SIGINFO就必须填sa_sigaction,反之填sa_handler。
// 基础用法: 注册 SIGTERM handler
void demo_sigaction_basic(void)
{
struct sigaction sa;
sa.sa_handler = my_term_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGTERM, &sa, NULL);
}
// 扩展用法: 注册带 siginfo 的 handler (能拿到谁发的/附带数据)
void demo_sigaction_siginfo(void)
{
struct sigaction sa;
sa.sa_sigaction = my_siginfo_handler; // ★ 注意字段名是 sa_sigaction
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_SIGINFO | SA_RESTART; // ★ 必须设 SA_SIGINFO!
sigaction(SIGUSR1, &sa, NULL);
}什么场景用 sa_sigaction(而不是 sa_handler)?
sa_handler 只有一个 int sig,你只知道"来了个信号"。sa_sigaction 额外给 siginfo_t *info(谁发的、怎么来的、带了什么)和 void *ucontext(寄存器现场),下面四个是实际用得上的场景:
| 场景 | 用 info 里的什么 | 为什么需要 |
|---|---|---|
| 定位 SIGSEGV 崩溃地址 | info->si_addr | 拿到引发段错误的访存地址,判断空指针还是野指针;JDK hs_err_pid.log 的 si_addr: 0x... 就是它 |
| sigqueue 带内数据传递 | info->si_value(union sigval) | payload 跟在信号里,不用走共享内存;适合极轻量一对一通知(如"缓冲区就绪,地址 0x...") |
| 区分信号来源做权限判断 | info->si_code + info->si_pid | SI_USER(可能恶意 kill)、SI_QUEUE(可信内部通道)、SI_KERNEL(内核生成)——按来源决定响应或忽略 |
| 崩溃时拿完整寄存器现场 | ucontext → uc_mcontext.gregs | SIGSEGV handler 里拿 RIP/RSP/所有通用寄存器,自己写 mini-coredump 或精细诊断——Breakpad/Chromium 的信号 handler 就这么干的 |
三参数 handler void handler(int sig, siginfo_t *info, void *ucontext) 里 info 的常用字段:
| 字段 | 典型读取内容 |
|---|---|
info->si_signo | 信号编号(同 sig) |
info->si_code | 信号来源:SI_USER(kill)、SI_QUEUE(sigqueue)、SI_TKILL、SI_KERNEL 等 |
info->si_pid | 发送者 PID |
info->si_uid | 发送者 UID |
info->si_value | sigqueue() 传来的附带数据(union sigval) |
info->si_addr | SIGSEGV/SIGBUS 的故障地址 |
ucontext | 完整寄存器状态(同内核 pt_regs 的用户态副本) |
什么场景用 sa_mask?
sa_mask 是 handler 执行期间追加屏蔽的信号集——当前信号本身已经自动屏蔽(SA_NODEFER 不开时),sa_mask 是"再额外屏蔽这些":
| 场景 | 屏蔽什么 | 为什么 |
|---|---|---|
| 优雅退出流程不可被打断 | SIGTERM handler 里 sa_mask 加上 SIGINT、SIGHUP | 退出流程在写 checkpoint/刷缓冲/通知下游,被嵌套打断可能只写完一半数据 |
| 保护 handler 操作的全局状态 | 所有会操作同一块共享数据的信号的 handler,互相加 sa_mask | 两个 handler 改同一共享变量,嵌套产生竞态,用 sa_mask 串行化 |
| 防止信号风暴递归 | 与 SIGUSR1 协作的外部信号,全加入 SIGUSR1 的 sa_mask | SIGUSR1 要一次性处理队列,期间不希望被协作信号搅乱状态机 |
| 确保 handler 原子性 | handler 中操作非重入函数(如 malloc 内部状态)时,屏蔽所有可能触发 handler 的信号 | 严格说不应在 handler 调 malloc,实在避不开时用 sa_mask 挡掉罪魁信号,降低崩溃概率 |
1.2.1 sigaction() 背后发生了什么 —— 系统调用 + 内核注册流程
sigaction() 不是纯用户态函数,它是一次完整的系统调用。glibc 只是"薄封装",真正的注册工作全部在内核态完成。
调用链(用户态 → 内核态)
用户态 内核态
─────── ──────
sigaction(sig, act, oldact)
│
└─ glibc: __sigaction()
│
└─ INLINE_SYSCALL(rt_sigaction, ...)
│
syscall 指令 ──────────────┐
▼
sys_rt_sigaction()
│
├─ copy_from_user() ← 拷入用户态 sa
├─ do_sigaction()
│ ├─ spin_lock(&siglock) ← 拿自旋锁
│ ├─ 读 action[sig-1] ← 旧值
│ ├─ 校验 SA_IMMUTABLE ← SIGKILL/SIGSTOP 不可改
│ ├─ 写 action[sig-1] ← 新 handler+flags+mask
│ └─ spin_unlock
└─ copy_to_user() ← 拷出旧值到 oldact
iret/sysret ───────────────→ 返回用户态- glibc 包装层:
sigaction()在 glibc 中实际调用的是__sigaction(),它把 POSIX 的struct sigaction转成内核的struct kernel_sigaction(主要是处理sa_restorer字段差异,该字段已废弃),然后走syscall(__NR_rt_sigaction, ...)。 syscall指令:触发 CPU 从 ring3(用户态)切换到 ring0(内核态),这是进入内核的唯一入口。之后 CPU 执行内核的entry_SYSCALL_64→ 查系统调用表 → 跳转到__x64_sys_rt_sigaction。copy_from_user():内核不能直接解引用用户态指针(安全原因:用户可能传非法地址;缺页原因:用户页可能被换出)。必须通过copy_from_user把act安全地拷到内核栈上。do_sigaction()—— 核心逻辑:
| 步骤 | 操作 | 说明 |
|---|---|---|
| 拿锁 | spin_lock(&sighand->siglock) | 这是信号系统的"根锁"——发信号(send_signal)、改 handler(do_sigaction)、取信号(get_signal) 全部先拿这把锁。同一时刻一个进程组最多一个线程在执行信号路径 |
| 读旧值 | k = &sighand->action[sig - 1] | 取出旧 handler,后面通过 copy_to_user 写回 oldact |
| 校验 | k->sa.sa_flags & SA_IMMUTABLE | SIGKILL(9) 和 SIGSTOP(19) 在内核启动时就被标为 SA_IMMUTABLE——任何修改它们的尝试直接返回 -EINVAL。这就是为什么你永远不能捕获 SIGKILL |
| 写新值 | 写入 handler 函数指针 + sa_flags + sa_mask | 如果 act == SIG_IGN(1) 或 SIG_DFL(0),写入内核特殊标记值(非真实函数指针)。否则写入用户态传进来的 handler 地址 |
| 特殊处理 | 若 SIGCHLD 且设了 SA_NOCLDWAIT | 内核标记为自动回收僵尸子进程,不再需要 waitpid |
| 放锁 | spin_unlock(&sighand->siglock) | — |
- 返回:
copy_to_user(oldact, &old, ...)把旧 handler 拷回用户态,然后iret/sysret切回 ring3。
本质:在锁保护下写一个数组元素:
task_struct sighand_struct (全线程共享,CLONE_SIGHAND)
┌──────────────┐ ┌──────────────────────────────┐
│ sighand ────┼────────────→│ count (refcount) │
│ signal ────┼──→ ... │ siglock (spinlock) │
│ pending │ │ action[0] : SIG1 的 handler │
│ blocked │ │ action[1] : SIG2 的 handler │
└──────────────┘ │ ... │
│ action[8] : SIGKILL (锁死!) │
│ action[sig-1] ← sigaction() │
│ 写入的就是这里! │
│ ... │
│ action[63] │
└──────────────────────────────┘- 全进程所有线程共享同一个
sighand_struct(CLONE_SIGHAND),所以线程 A 调用sigaction()改了 handler,线程 B 收信号也走同一个 handler。不存在"每线程独立 handler"。 - 操作非常轻量——本质上就是在锁保护下写一个数组元素,没有遍历、没有内存分配、没有复杂逻辑。真正的开销发生在"信号被投递时"(内核需要压
sigframe到用户栈、设置返回地址为__kernel_rt_sigreturn),但那不属于sigaction()调用的范围。
总结:
sigaction()= 系统调用 → 进内核 → 拿锁 → 写action[]数组 → 放锁 → 返回。全程在内核态完成,用户态只负责传递参数结构体。
二、发送:kill() / raise() / killpg() / sigqueue()
#include <signal.h>
int kill(pid_t pid, int sig);
int raise(int sig);
int killpg(pid_t pgrp, int sig);kill() —— 给进程/进程组发信号。pid 参数的语义:
pid 值 | 目标 |
|---|---|
> 0 | 发给 tgid == pid 的那个进程 |
== 0 | 发给与调用者同进程组的所有进程 |
== -1 | 发给有权限的所有进程(除 init 和自身) |
< -1 | 发给进程组 PGID == -pid 的所有进程 |
sig == 0 时不发送信号,只检查进程是否存在(权限检查仍生效)。返回值:0 成功,-1 失败(ESRCH 目标不存在、EPERM 无权限、EINVAL 信号号无效)。
raise(sig) 等价于 kill(getpid(), sig),给调用进程自身发信号(实际发给当前线程)。
killpg(pgrp, sig) 等价于 kill(-pgrp, sig)。
sigqueue() —— 带数据发送 RT 信号:
#include <signal.h>
int sigqueue(pid_t pid, int sig, const union sigval value);
// pid: 目标进程 tgid
// sig: 信号编号 —— ★ 必须是 SIGRTMIN..SIGRTMAX 之一
// value: 附带数据 (sival_int 或 sival_ptr)
// 返回值: 0 成功, -1 失败接收方 handler 通过 info->si_value.sival_int / info->si_value.sival_ptr 拿到附带数据。和 kill() 的关键区别:
kill(SIGUSR1) | sigqueue(SIGRTMIN+0, value) | |
|---|---|---|
| 排队 | 不排队——连续 3 次可能只投递 1 次 | RT 信号排队——连续 3 次依次投递 |
| 附带数据 | 无 (si_value 为空) | 携带 union sigval |
| 容量限制 | 无(只计数一次) | 受 RLIMIT_SIGPENDING 限制(每个 user 最大挂起信号数) |
三、屏蔽:sigprocmask() / pthread_sigmask()
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
int pthread_sigmask(int how, const sigset_t *set, sigset_t *oldset);
// 返回值: 0 成功, -1 失败how操作类型:SIG_BLOCK(追加屏蔽set |= mask)、SIG_UNBLOCK(解除屏蔽set &= ~mask)、SIG_SETMASK(直接覆盖set = mask)。set:新的屏蔽字,传NULL表示只查询不修改。oldset:输出旧屏蔽字,传NULL表示不关心。
这两个函数只影响当前线程的 blocked 屏蔽字,不影响其他线程。在 Linux 上它们走同一条系统调用 sys_rt_sigprocmask,但 POSIX 规定多线程程序必须用 pthread_sigmask。
// 示例: 临界区保护——临时屏蔽 SIGINT 和 SIGTERM
void critical_section_with_signal_block(void)
{
sigset_t block_set, old_set;
sigemptyset(&block_set);
sigaddset(&block_set, SIGINT);
sigaddset(&block_set, SIGTERM);
pthread_sigmask(SIG_BLOCK, &block_set, &old_set);
// ... 临界区代码 (不会被 SIGINT/SIGTERM 打断) ...
pthread_sigmask(SIG_SETMASK, &old_set, NULL); // 恢复
}四、同步等待:sigwait() / sigwaitinfo() / sigtimedwait()
#include <signal.h>
int sigwait(const sigset_t *set, int *sig);
// 返回值: 0 成功, >0 失败 (errno: EINVAL/EINTR)
int sigwaitinfo(const sigset_t *set, siginfo_t *info);
int sigtimedwait(const sigset_t *set, siginfo_t *info,
const struct timespec *timeout);
// 返回值: >0 = 信号编号, -1 = 出错/超时 (errno: EINTR/EAGAIN/EINVAL)
// timeout: NULL = 无限等待这是多线程下处理信号最干净的方式。 不像 handler 那样"异步打断"——sigwait 同步阻塞等待,返回后可调用任何函数(没有 async-signal-safe 限制)。
使用步骤:
- 主线程在创建其他线程之前,
pthread_sigmask(SIG_BLOCK)屏蔽目标信号。 - 子线程继承屏蔽字(或显式屏蔽)。
- 专用线程执行
sigwait(set, &sig),同步等待。 - 信号到达后
sigwait返回,线程根据信号执行业务逻辑。
关键前提:目标信号必须在所有线程中被屏蔽,否则信号会走 handler 而非
sigwait。
sigwaitinfo() 比 sigwait 多了 siginfo_t 输出(能拿 si_pid/si_value);sigtimedwait() 再加超时。
五、信号转 fd:signalfd()
#include <sys/signalfd.h>
int signalfd(int fd, const sigset_t *mask, int flags);
// fd: -1 = 创建新 fd, 已有 fd = 修改监听的信号集
// mask: 要监听的信号集 (这些信号必须被 block, 否则走 handler)
// flags: SFD_CLOEXEC / SFD_NONBLOCK / 0
// 返回值: >=0 = 新 fd, -1 = 失败为什么用它:信号变为普通 fd 可读 → epoll 唤醒 → read() 读出信号 → 在主循环里同步处理。没有 handler、没有 EINTR 重试、没有 async-signal-safe 限制、和 IO 事件统一调度。
read(fd, &fdsi, sizeof(fdsi)) 读出 struct signalfd_siginfo,其中的 ssi_signo(信号编号)、ssi_pid(发送者 PID)、ssi_uid(发送者 UID)、ssi_code(来源)等价于 siginfo_t 的对应字段。为什么 signalfd 比传统信号处理快约一个量级、适合哪些场景,见 signals-alternatives。
六、备用栈:sigaltstack()
#include <signal.h>
int sigaltstack(const stack_t *ss, stack_t *oss);
// ss: 新栈配置 (NULL=只查询), oss: 旧栈配置输出 (NULL=不关心)
// 返回值: 0 成功, -1 失败stack_t 结构:ss_sp(栈基址)、ss_size(大小,推荐 SIGSTKSZ=8KB)、ss_flags(0/SS_DISABLE 禁用/SS_ONSTACK 当前在用)。
什么时候需要:程序可能栈溢出 → SIGSEGV,默认栈不够容纳 sigframe + handler;或者想在 handler 中安全打印 backtrace。设置备用栈后,sigaction 必须加 SA_ONSTACK flag。
// 使用示例
void setup_altstack(void)
{
static char altstack[SIGSTKSZ] __attribute__((aligned(16)));
stack_t ss = { .ss_sp = altstack, .ss_size = SIGSTKSZ, .ss_flags = 0 };
sigaltstack(&ss, NULL);
}
void register_segv_with_altstack(void)
{
struct sigaction sa;
sa.sa_sigaction = segv_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_SIGINFO | SA_ONSTACK; // ★ SA_ONSTACK!
sigaction(SIGSEGV, &sa, NULL);
}七、原子等待:sigsuspend() / pause()
#include <signal.h>
int sigsuspend(const sigset_t *mask);
int pause(void);
// sigsuspend 返回: 永远返回 -1, errno=EINTR (这是正常行为!)sigsuspend(mask) 原子地完成三件事:① 用 mask 替换当前线程的屏蔽字 → ② 挂起直到收到未被 mask 屏蔽的信号 → ③ handler 返回后恢复原来的屏蔽字。
这解决了经典的 TOCTOU 竞态:如果分两步做——先 sigprocmask(SIG_UNBLOCK) 再 pause()——信号可能在两者之间到达并永远丢失。sigsuspend 把"解除屏蔽 + 等待"合为原子操作,消除窗口。
八、sa_flags 速查表(设错了坑很多)
| flag | 含义 | 何时设 | 不设的后果 |
|---|---|---|---|
SA_SIGINFO | handler 用 sa_sigaction(三参数版) | 需要 info->si_pid/si_addr/si_value 时 | handler 只能用 sa_handler(单参数),丢失来源信息 |
SA_RESTART | 可中断系统调用被信号打断后自动重启 | 不希望 read/write 因为信号返回 EINTR 时 | read(fd,...) 可能返回 -1, errno=EINTR,需手动重试 |
SA_NODEFER | handler 执行中不自动屏蔽当前信号 | handler 需要可重入(当前信号可以再次进入) | 当前信号自动屏蔽,同种信号排队等 handler 执行完 |
SA_NOCLDSTOP | SIGCHLD 只在子进程终止时产生,暂停/恢复时不产生 | 不关心子进程 STOP/CONT 状态 | SIGCHLD 在子进程 STOP/CONT 时也触发,干扰 waitpid |
SA_NOCLDWAIT | 子进程终止后不变成僵尸(自动回收) | 不想 waitpid 且不关心子进程退出状态 | 子进程变僵尸,需显式 waitpid |
SA_ONSTACK | handler 在 sigaltstack 设置的备用栈上执行 | 主栈可能溢出(SIGSEGV handler/深递归栈) | handler 使用主栈——主栈若已满则 handler 无法执行 |
SA_RESETHAND | handler 执行一次后恢复为 SIG_DFL(System V 兼容) | 兼容老代码 | handler 不会被清除,每次信号都调你的 handler |
SA_NODEFER | handler 执行中屏蔽字不追加当前信号 | 同种信号可嵌套 | 同种信号 handler 期间被阻塞,防止栈无限嵌套 |
九、一句话总结
信号 API 按生命周期分四类:注册处置用
sigaction(永远别用signal——行为不可移植且 System V 下 handler 执行一次就失效;sigaction本质是一次系统调用,内核在锁保护下写sighand->action[]数组一个元素,全线程共享 handler);发送用kill/raise/killpg,要带数据排队就sigqueue(必须用 RT 信号);屏蔽用pthread_sigmask(多线程必须用这个,只影响当前线程);等待/投递是分水岭——要么 handler 异步打断(受限多),要么sigwait族/signalfd 同步处理(无 async-signal-safe 限制)。sa_flags里SA_SIGINFO/SA_RESTART/SA_ONSTACK是三个最常用的,设错坑很多,细节见坑篇与 Demo 篇。