Linux信号机制详解:从内核递送到sigaction工程实践
2026/9/18 0:51:40 网站建设 项目流程

简介:面向操作系统学习者与系统开发调试人员,这份 PDF 围绕 Linux 信号机制做系统性梳理。内容涵盖信号作为异步通知方式的基本定位,进程终止、异常事件、系统调用失败、终端交互、进程交互与调试跟踪等信号分类,以及忽略、默认处理和自定义处理函数三种响应方式;同时说明内核、kill/raise 系统调用与用户输入等产生途径,可靠与不可靠信号的差异、信号命名标准化,并涉及 signal_struct、sigpending、sigset_t 等内核数据结构与相关系统调用。读者可借此理解信号如何影响进程的运行、睡眠、停止与僵尸状态,为异常处理、进程生命周期管理和并发控制打下基础,也适合作为课程学习与专业指导的参考文献。资源包含 1 个 PDF 文件,压缩包约 24KB,便于随时查阅;目前已积累 124 人学习下载。

1. 杀不掉的进程背后,是信号没被正确处理

线上巡检时遇到过一台机器,kill -9打下去进程纹丝不动,ps显示状态是D,运维只能等 IO 超时。也遇到过另一类情况:服务收到SIGTERM后本该优雅退出,结果日志戛然而止、连接被硬断,因为业务代码在信号处理函数里调了printfmalloc。这两个现象都指向同一件事——Linux 信号机制不是一条kill命令那么简单,它牵涉内核的未决位图、进程的阻塞掩码、用户态处理函数的执行时机,以及异步信号安全的边界。

Linux 信号机制的分析与研究,落点在于把「信号怎么产生、内核怎么记账、进程怎么响应、工程上怎么安全地用」这条链路走通。它服务于排查进程杀不掉、守护进程被误杀、嵌入式 Linux 设备上异常重启、父子进程僵尸泄漏这类问题,也几乎是 Linux 面试题里绕不开的一环。下面从内核结构讲到可复现的代码,再讲到strace/proc层面的验证手段。

2. 信号的产生、阻塞与递送:内核里的三条链路

要理解信号,先把它拆成三个阶段:产生(generation)、递送(delivery)、处理(handling)。中间还夹着一个容易被忽略的状态——未决(pending)。很多「信号没生效」的问题,卡在未决到递送这一步。

2.1 信号从哪来:四类发送源与 kill 系列命令

信号的来源可以归成四类,理解分类有助于判断问题出在哪个方向:

  • 终端驱动:Ctrl+CSIGINTCtrl+\SIGQUITCtrl+ZSIGTSTP
  • 内核异常与硬件事件:非法内存访问发SIGSEGV,除零发SIGFPE,管道写端关闭后写发SIGPIPE,定时器到期发SIGALRM
  • 显式系统调用:kill(2)tgkill(2)raise(3)pthread_kill(3)alarm(2)
  • 作业控制与子进程状态变化:SIGCHLDSIGCONTSIGSTOP

日常调试用得最多的还是命令行工具,这些属于 Linux 常用命令里高频被问到的部分:

# 列出全部信号编号与名称,前 31 个是标准信号,34 起是实时信号 kill -l # 发送指定信号,默认是 SIGTERM(15) kill -TERM 12345 # 按名字批量杀,-e 精确匹配进程名,避免误杀同名脚本 pkill -TERM -x nginx # 给进程组发信号,负号后面跟 PGID kill -HUP -6789 # 查看某进程的信号掩码、未决信号、已捕获信号 grep -E 'Sig(Pnd|Blk|Ign|Cgt)' /proc/12345/status

kill -l输出的编号是后续所有操作的索引。kill -15kill -9的差别不只是「优雅」和「暴力」,SIGKILLSIGSTOP在内核里不可被阻塞、不可被捕获、不可被忽略,这是kill -9打下去没反应时,问题必然在进程状态(如D态)而非信号本身的原因。

2.2 未决、阻塞与递送:task_struct 上的三个关键位图

内核为每个进程维护信号相关的几个集合,挂在进程描述符上。抛开具体结构体名,可以把它抽象成三张 64 位图:

集合含义对应 /proc 字段
pending已产生但还没递送给进程的信号SigPnd
blocked被进程主动屏蔽、暂不递送的信号SigBlk
handler每个信号当前的处理方式(默认/忽略/捕获)SigIgn+SigCgt

产生信号的流程是:发送方调用kill系列,内核校验权限(CAP_KILL或同 UID 同会话等规则),然后把信号位写进目标的 pending 集合。递送发生在目标进程即将从内核态返回用户态的那一刻——内核检查 pending 与 blocked 的交集,若某信号未阻塞且未被忽略,就把它从 pending 清掉,改写到用户栈上准备执行处理函数;若被阻塞,就继续挂在 pending 里等掩码放开。

这里有两个反复被踩的点。第一,标准信号不排队:同一个标准信号在 pending 位图里只占一个 bit,连续kill -10五次,进程可能只处理一次。第二,sigprocmask加在父进程上的掩码会被fork继承、被exec保留,所以守护进程在exec前必须把掩码清干净,否则子进程一出生就带着「信号免疫」。

2.3 实时信号与标准信号的核心差别

编号 1 到 31 是标准信号,34 到 64 是实时信号。差异集中在三点:实时信号可以排队,同一信号产生多次就会递送多次;实时信号带一个siginfo_t里的整型或指针负载,接收方能取到发送方 PID、UID 和数据;编号小的实时信号优先级更高。

维度标准信号(1–31)实时信号(34–64)
排队不排队,合并为一个 bit按优先级排队,同一信号可累计
负载仅信号编号附带sigval联合体
默认动作各自不同,多为终止默认为终止
常见用途退出、重载、超时线程间定向通知、带数据事件

做嵌入式 Linux 项目时,实时信号常被用来在多个线程间传递带句柄的事件,省掉一层队列。但如果只是想让进程退出、重载配置,标准信号足够,用SIGRTMIN反而让代码可读性变差。

3. 用 sigaction 写一个能复现的信号处理程序

把内核侧的概念落到代码,最小闭环是:注册处理函数、运行、用外部命令触发、观察行为。这套流程在 Linux 面试题里也常被要求手写。

3.1 signal 与 sigaction 的选型理由

signal(2)是老接口,语义在不同 libc 和不同 Unix 上不一致:有的实现处理一次后自动重置为默认动作,有的默认带SA_RESTART语义。sigaction(2)是 POSIX 标准接口,行为确定,且能拿到siginfo_t、能精细控制掩码和标志位。选型结论很直接:

对比项signalsigaction
标准性历史接口,语义因实现而异POSIX 明确
是否自动重置视实现而定SA_RESETHAND显式控制
处理期间掩码不明确sa_mask精确指定
获取发送方信息不支持支持SA_SIGINFO
推荐场景一次性小脚本生产代码、守护进程

生产代码里我一般统一用sigaction,避免「换一个内核行为就变」的隐患。

3.2 最小可运行示例:捕获 SIGINT 与 SIGTERM

下面这段程序可以完整复现「注册—触发—退出」的链路。关键点是用volatile sig_atomic_t传递退出标志,处理函数里只做赋值。

#include <signal.h> #include <stdio.h> #include <string.h> #include <unistd.h> /* 只能声明为 volatile sig_atomic_t,保证赋值是原子的、 且编译器不会把它优化进寄存器导致主循环读不到变化 */ static volatile sig_atomic_t g_stop = 0; static void on_term(int signo, siginfo_t *info, void *ctx) { (void)ctx; /* 处理函数内只做异步信号安全操作,此处仅赋值 */ g_stop = (signo == SIGINT) ? 1 : 2; /* 通过 SA_SIGINFO 可以取到发送方 PID,便于审计日志 */ (void)info; } int main(void) { struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_sigaction = on_term; sa.sa_flags = SA_SIGINFO | SA_RESTART; /* 被信号打断的慢系统调用自动重启 */ sigemptyset(&sa.sa_mask); sigaddset(&sa.sa_mask, SIGTERM); /* 处理 SIGINT 期间屏蔽 SIGTERM,避免嵌套 */ if (sigaction(SIGINT, &sa, NULL) < 0 || sigaction(SIGTERM, &sa, NULL) < 0) { perror("sigaction"); return 1; } /* 主循环周期性检查标志,sleep 被打断也能正常退出 */ while (!g_stop) { pause(); } printf("exit by signal, code=%d\n", (int)g_stop); return g_stop == 1 ? 0 : 1; }

逻辑说明:sigactionon_term注册为SIGINTSIGTERM的处理函数,sa_mask里放SIGTERM表示执行on_term期间屏蔽SIGTERM,防止两个信号嵌套执行同一个函数造成状态混乱。sa_flags里的SA_SIGINFO让处理函数拿到siginfo_t,可取info->si_pidSA_RESTART让被信号中断的readaccept等慢系统调用自动重启,不加就得手动处理EINTR

参数说明:g_stop必须是volatile sig_atomic_t,这是 C 标准保证在信号处理函数与主流程之间可安全访问的唯一整型。pause()让进程挂起直到有任何信号到来,避免主循环空转占 CPU。

3.3 编译、运行与验证步骤

# -O2 下继续验证 volatile 的必要性,去掉 volatile 可能死循环 gcc -O2 -Wall -o sigdemo sigdemo.c # 终端 1 运行,记录 PID ./sigdemo & echo $! # 假设输出 20481 # 终端 2 发送 SIGINT,程序应打印 exit by signal, code=1 kill -INT 20481 # 再跑一次,这次发 SIGTERM,退出码应为 1(g_stop==2) ./sigdemo & kill -TERM $! # 观察进程对信号的实际处置:Ign 位图表示忽略,Cgt 位图表示已捕获 grep -E 'Sig(Blk|Ign|Cgt)' /proc/20481/status

SigCgt字段是一个十六进制掩码,第n位为 1 表示信号n+1被捕获。想确认SIGINT(编号 2)是否被注册,可以看SigCgt的最低位是否为1(第 1 位对应编号 2)。这套验证方式比只看代码可靠,因为它读的是内核记账的真实状态。

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

这是最容易出事的一节。信号处理函数的执行是异步插入的,它可能在主流程刚进入malloc、正在改堆链表中间指针时被调用。此时在处理函数里再调mallocprintf,堆或 stdio 锁就处于不一致状态,表现为卡死或野指针。

POSIX 列出的异步信号安全函数里,常用的有writereadopenclose_exitkillsigaction。不安全的高频函数包括printfmallocfreesyslogstrtok。正确做法是在处理函数里只置标志或write一个字节到管道,真正的日志和资源释放放到主循环做,这也是第 4 章 self-pipe 的由来。

4. 工程落地:self-pipe、signalfd 与 SIGCHLD 回收

内核只管把信号递送过来,怎么把它接进事件循环、怎么和epoll配合,是工程层面的问题。

4.1 self-pipe trick:把信号变成可 epoll 的事件

思路是:处理函数里往一个管道写 1 字节(write是异步信号安全的),主线程把这个管道读端加进epoll。信号到来与 IO 事件就走同一条路径,处理函数本身不做任何复杂逻辑。

static int g_pipefd[2]; static void on_sig(int signo) { unsigned char c = (unsigned char)signo; /* write 是异步信号安全的;写满丢弃即可,不阻塞 */ ssize_t n = write(g_pipefd[1], &c, 1); (void)n; } /* 初始化:注册前把管道设成非阻塞 */ static void setup(void) { pipe(g_pipefd); fcntl(g_pipefd[0], F_SETFL, O_NONBLOCK); fcntl(g_pipefd[1], F_SETFL, O_NONBLOCK); struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_handler = on_sig; sa.sa_flags = SA_RESTART; sigemptyset(&sa.sa_mask); sigaction(SIGTERM, &sa, NULL); sigaction(SIGHUP, &sa, NULL); }

逻辑说明:管道两端都设非阻塞,避免信号密集时write阻塞在信号处理函数里——这会直接把进程卡死。主循环从g_pipefd[0]读到的字节就是信号编号,据此决定是重载配置还是退出。这样做还顺带解决了「同一信号合并」的问题:即使内核只递送一次SIGHUP,管道里也能按实际写入次数累积。

参数说明:管道容量默认 64KB,超过后write返回EAGAIN。若信号可能极密集,要么加长管道,要么在处理函数里加一个自增计数器并接受合并语义。

4.2 signalfd:把信号并入事件循环

Linux 特有的signalfd把信号直接变成一个文件描述符,信号到来时该 fd 可读,读出来的结构体含信号编号和siginfo。它省掉了 self-pipe 的手工管道,但代价是需要先阻塞信号,否则信号仍会走默认递送路径。

sigset_t mask; sigemptyset(&mask); sigaddset(&mask, SIGTERM); sigaddset(&mask, SIGINT); /* 必须先阻塞,signalfd 才能截获,否则信号按原路径递送 */ sigprocmask(SIG_BLOCK, &mask, NULL); int sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC); /* 之后把 sfd 加入 epoll,可读时 read 出 struct signalfd_siginfo */

三种方案的选择可以归纳成一张表:

方案依赖是否需要阻塞信号适用场景
直接 sigactionPOSIX逻辑极简的退出标志
self-pipePOSIX跨平台、已有 epoll 循环
signalfdLinux纯 Linux 服务、信号种类多

4.3 SIGCHLD 与僵尸进程的回收

SIGCHLD的坑是:多个子进程近乎同时退出时,内核可能只递送一次信号。如果在处理函数里wait一次就返回,剩下的子进程会变成僵尸,ps里显示Z态并持续占用 PID。正确写法是循环回收直到waitpid返回 0 或 -1。

static void on_chld(int signo) { (void)signo; int saved = errno; /* waitpid 会改 errno,先存后还原 */ int status; pid_t pid; /* WNOHANG 保证没有可回收子进程时立即返回,不阻塞主流程 */ while ((pid = waitpid(-1, &status, WNOHANG)) > 0) { /* 只记录,不做重活 */ } errno = saved; }

注册时建议加上SA_NOCLDSTOP,这样子进程被暂停或恢复时不会触发SIGCHLD,减少无谓唤醒。若服务里用了线程池且主线程和子线程都可能fork,还要注意子进程会继承父进程的信号掩码和SIGCHLD处理方式。

4.4 多线程场景下信号该给谁处理

进程级信号在内核里是投递给「进程」的,但最终由某一个不阻塞该信号的线程来执行处理函数——具体是哪个线程不确定。常见做法有两种。一是把所有信号在主线程之外的线程里统一屏蔽,只留一个专用线程sigwait阻塞等待,信号处理逻辑全部串行化。二是用pthread_kill定向发给特定线程,但这种信号是线程私有的,别的线程屏蔽与否不影响它。

/* 工作线程启动时统一屏蔽,事件线程用 sigwait 收口 */ sigset_t set; sigemptyset(&set); sigaddset(&set, SIGTERM); pthread_sigmask(SIG_BLOCK, &set, NULL); int sig; while (1) { if (sigwait(&set, &sig) == 0) { if (sig == SIGTERM) break; } }

sigwait与信号处理函数不能混用同一信号,否则行为不确定。混合使用时优先保证同一信号只有一条消费路径。

5. 用 /proc、strace 与信号掩码定位真实问题

信号类问题排查的难点在于「现象」和「原因」隔了好几层。比较有效的路径是先用/proc/<pid>/status看内核记账,再用strace看系统调用层,最后回到代码确认处理函数是否安全。

第一层,读/proc/<pid>/status的四个字段:

字段含义典型异常
SigPnd线程组未决信号长期非 0 说明信号被阻塞没递送
SigBlk当前阻塞掩码SIGTERM说明进程对它免疫
SigIgn被忽略的信号SIGPIPE常被业务忽略,属正常
SigCgt被捕获的信号和代码里注册的集合对不上就是没生效

第二层,用strace观察信号流向:

# 跟踪信号与信号相关系统调用,-f 跟进子进程 strace -f -e trace=signal -p 12345 # 只看目标进程收到的信号,不跟系统调用 strace -e trace=signal -o sig.log ./sigdemo # 结合 /proc 定时采样,观察掩码是否被中途改动 watch -n1 "grep -E 'Sig(Blk|Pnd)' /proc/12345/status"

strace输出里--- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=...} ---表示信号已递送到用户态;如果只看到rt_sigprocmask把某信号加进阻塞集,却始终看不到递送记录,问题就坐实在掩码上。

第三个技巧是处理函数里的打印。把printf换成write(2, ...)到已打开的日志 fd,可以避开 stdio 锁,同时不破坏异步信号安全。若必须知道errno,在进入处理函数时先保存、退出前还原,因为处理函数返回后主流程可能依赖进入前的errno值。

第四点,遇到kill -9无效时别在信号层纠缠。SIGKILL不可被阻塞或捕获,没反应只能说明进程没机会返回用户态,检查/proc/<pid>/stackwchan看它卡在哪个内核路径,常见是等待不可中断 IO。反过来,SIGTERM无效就该查SigBlkSigIgn,这两者的排查顺序几乎能覆盖九成的「信号不生效」工单。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询