先说两个我经常遇到的“程序莫名其妙死了”的现场。
第一个,一个 C 程序里写了个x / y,y从配置读进来,某天配成了 0,进程当场崩掉,日志里只有一句Floating point exception (core dumped)。第二个,脚本里tail -f app.log | head -n 20,head 读完 20 行退出,过一会儿发现 tail 也没了,终端只剩一句Broken pipe。这两个场景,一个对应SIGFPE,一个对应SIGPIPE,看起来八竿子打不着,但本质都指向同一个问题:信号到底是谁发的、怎么发的、为什么进程接一下就死。
很多做应用开发的朋友对 Linux 信号的理解停留在“kill 命令给进程发信号”,真到了自己程序收到SIGPIPE、SIGFPE、SIGSEGV的时候,就只会上网搜“怎么忽略某个信号”。这篇文章我想顺着“除 0”和“SIGPIPE”这两个具体例子,把 Linux 信号真正的来源、投递路径、以及实际操作中怎么排查和利用它们,完完整整捋一遍。适合刚接触 Linux 服务端开发的读者,也适合那些写过不少代码但一直没把信号机制搞明白的同学。
1. 问题现场:除 0 和 SIGPIPE 到底是怎么冒出来的
1.1 除 0 为什么是 SIGFPE 而不是 SIGSEGV
先看最经典的除 0 崩溃。
在 x86 平台上,CPU 执行除法指令时会做一次硬件检查,如果发现除数是 0,会触发一个编号为#DE的异常(Divide Error Exception),CPU 自动跳到内核预先设置好的异常处理入口。内核拿到这个异常之后,并不认识你的业务逻辑,它只知道“当前这个进程正在执行的指令触发了硬件异常”,于是它做两件事:把异常对应的信号编号记到这个进程的task_struct里,然后在合适的时候把这个信号“递送”给进程。
这里有个容易混淆的点:为什么除 0 崩溃报的是SIGFPE,全称是 Floating Point Exception,翻译过来是“浮点异常”?因为 x86 的#DE异常不只包含整数除 0,还包含浮点运算产生的各种异常,内核统一映射成了SIGFPE。实际上细分的话,SIGFPE的si_code可以是FPE_INTDIV(整数除 0)、FPE_FLTDIV(浮点除 0)、FPE_FLTOVF(浮点溢出)等等,用sigaction注册处理函数时可以在siginfo_t里看到具体原因。所以“整数除 0 崩掉”本质是 CPU 硬件异常被内核转成了信号,而不是某个进程主动 kill 了它。
那为什么不是SIGSEGV?SIGSEGV对应的是 CPU 的页错误(Page Fault)且内核判定为非法地址访问,属于“你碰了不该碰的内存”;SIGFPE属于“你执行了不该执行的算术指令”。两者都由 CPU 异常触发,但异常类型不同,映射的信号自然不同。理解这一点,后面看dmesg或者 core dump 时就不会把SIGFPE当成段错误去查了。
1.2 管道断掉时,谁给进程发了 SIGPIPE
再看tail -f app.log | head -n 20这个场景。
shell 建立管道后,head 从管道读端读数据,tail 往管道写端写数据。head 读完 20 行就正常退出,管道读端随之关闭。此时 tail 再往管道里写,会发现什么?内核的pipe_write逻辑会检查这个管道还有没有读端进程,如果读端已经关闭,继续写没有任何意义,数据永远没人读,于是内核直接返回一个-EPIPE错误,同时给当前写进程投递一个SIGPIPE。SIGPIPE的默认动作是终止进程,所以 tail 或者 grep 或者任何正在往这个管道写数据的进程都会默默死掉。
注意这里“投递信号”的动作发生在系统调用write()的内部,并不是哪个进程用自己的意志发出来的。你可以说它是“内核在检查到管道写不进去时,顺手给你记了一笔”。实际上 Linux 内核里大量这种逻辑:某个系统调用执行到一半,发现条件不满足,返回一个错误码,如果这个错误码对应一个“需要让进程知道”的严重情况,内核就会额外生成一个信号。SIGPIPE是指定场景下的标准产物。
经验之谈:遇到“写管道时进程消失”,先怀疑 SIGPIPE,不要怀疑 OOM、不要怀疑死锁。这是管道编程里最经典的一个坑。
2. 信号来源的四个层级:谁触发、谁投递
2.1 用户态显式发送:kill、raise、tgkill
最直白的一类信号来源是用户通过系统调用显式发送。kill(pid, sig)、raise(sig)、pthread_kill(tid, sig)都是这一类。这里的核心逻辑是:调用者只是在“请求”内核给目标进程投递信号,真正完成“投递”的永远是内核。
内核在kill()系统调用里要做几件事:检查调用者有没有权限给目标进程发信号(通常要求同一用户或具备CAP_KILL权限),找到目标进程/线程组,把信号挂到目标的信号队列上。如果目标进程正在运行,可能还需要通过resched_curr/ 唤醒机制让它在合适的时机处理信号。
很多初学者有个误解,以为 A 进程给 B 进程发信号,是 A 直接把什么东西扔给了 B。实际上,A 只是通过系统调用进入了内核,由内核修改 B 的进程控制块。信号本身是内核维护的一种“事件登记”,不是进程之间直接通信的数据包。这个差别在权限控制上体现得非常明显:普通用户无权给 root 进程发送SIGKILL,因为内核在kill_pid_info里做了 UID 检查,而不是“消息能不能送到”的问题。
2.2 CPU 硬件异常触发的同步信号
第二类来源是 CPU 硬件异常。这类信号严格同步于当前指令流,也就是“正在执行的这条指令出问题了”。
SIGFPE:除 0、浮点异常,对应#DE异常。SIGSEGV:非法内存访问,对应页错误且访问权限/映射不合法。SIGBUS:总线错误,常见于 mmap 文件映射区域的物理 I/O 错误或未对齐访问。SIGILL:非法指令,CPU 执行了无法解码的指令。SIGTRAP:调试器断点,int3指令触发。SIGEMT之类更少见,多数架构上很少遇到。
这类同步信号有个特点:它是在进程自己的执行上下文里产生的,跟其他线程没关系。比如主线程除 0 崩了,触发的是主线程的SIGFPE;如果线程 A 访问了非法内存,内核把SIGSEGV投递到线程 A,而不是整个进程。当然,如果信号默认动作是终止进程,那整个进程还是会死,但如果用sigaction注册了处理函数且不设SA_RESETHAND,你会发现只有出错的线程会去执行处理函数。
内核处理硬件异常的路径大致是:CPU 异常 -> 异常向量表入口 ->do_error_trap/do_page_fault之类的 C 函数 -> 判断错误类型 -> 调用force_sig_fault或者force_sigsegv之类的函数给当前进程记录信号。force_sig_fault不只是发个信号,还会带上详细的异常地址和si_code,这些信息在调试时非常有用。
2.3 内核在系统调用或内核路径中主动发送
第三类是前面说的SIGPIPE这种,内核在代表进程执行系统调用时,发现条件不满足、产生一个错误,并且这个错误需要以信号的形式同步给进程。类似的还有:
SIGPIPE:写管道/写 socket 时对端已关闭。SIGCHLD:子进程退出,内核在进程退出流程里给父进程发通知。SIGXCPU:进程 CPU 时间超过资源限制(RLIMIT_CPU),内核的定时器或调度器检查到超限。SIGXFSZ:写入的文件大小超过RLIMIT_FSIZE。SIGPOLL/SIGIO:异步 I/O 事件到达。SIGSYS:进程发起了内核不认识的系统调用(常见于 seccomp 拦截)。
这一类的共同点是:信号并不是由某个外部实体“主动”发起的,而是内核对进程行为进行评估后,判定“你有异常状况”,于是给你记上一笔。其中SIGCHLD特别有意思,它严格说不是“错误”,而是“通知”,子进程退出是一个再正常不过的事件,但内核依然用信号机制告诉父进程,父进程可以选择处理也可以忽略。这也是为什么很多服务端程序要用waitpid配合SIGCHLD来回收子进程资源,如果忽略了这个信号,子进程会变成僵尸进程占着进程表。
2.4 tty、会话、定时器与资源限制等隐式来源
第四类来源更隐蔽,很多应用开发者甚至想不到。
终端驱动信号:SIGINT(Ctrl+C)、SIGQUIT(Ctrl+\)、SIGTSTP(Ctrl+Z)、SIGHUP(终端挂断)。这些信号不是哪个进程发给你的,而是内核的 tty 线路规程(line discipline)在收到终端字符时,识别出“这是一个控制字符”,于是向当前前台进程组广播信号。比如你按 Ctrl+C,内核的n_tty_receive_char流程会调用isig(),找到这个终端的前台进程组,向组内每个进程发送SIGINT。
定时器信号:setitimer、alarm、POSIX 定时器到期后,内核的定时器软中断路径会向进程投递SIGALRM。这类信号是“时间到了”,跟进程当前做什么没关系,完全异步。
资源限制信号:除了SIGXCPU、SIGXFSZ,还有 cgroup 的 OOM 机制触发的进程终止,虽然它不是通过传统信号逐个投递,但行为上类似一个“来自内核的强制终止”。
会话管理信号:SIGHUP不只是终端挂断,还在很多场景里充当“重新加载配置”的约定信号(比如 nginx、sshd reload),但内核发送它的原始场景是会话控制:控制终端关闭,内核向会话首进程发SIGHUP,再由会话首进程决定是否通知组内其他进程。
把这些来源按“是否与当前指令同步”的标准划分,可以分成两大阵营:同步信号(硬件异常、系统调用返回时的错误)和异步信号(kill、定时器、终端字符、子进程退出)。同步信号的来源极其明确,就是“当前这条指令”;异步信号的来源就比较复杂,需要结合具体场景去排查。
3. 信号从生成到送达进程的完整路径
3.1 pending、blocked、action 三层数据结构
信号生成了,不等于进程马上就要处理它。内核在进程控制块里维护着几个关键要素:信号是否被阻塞(blocked)、信号是否已挂起未处理(pending)、以及进程对这个信号设置的处理动作(action)。
用户空间用sigprocmask/pthread_sigmask修改的是 blocked 集合,表示“这个信号暂时别递送给我”。内核在生成信号时,如果发现目标进程当前阻塞了该信号,并不会丢弃(绝大多数情况),而是把信号加入 pending 集合,等进程解除阻塞后再递送。pending 集合本质上是一组位图加上一个队列。
这里有个非常重要的细节:普通信号是不排队的。一个进程在阻塞期内收到 10 次SIGUSR1,解除阻塞后只会处理一次,因为普通信号在 pending 位图里只占一个 bit。而实时信号(SIGRTMIN到SIGRTMAX)是有独立队列的,每次投递都会排队,不会合并。这就是为什么要区分“标准信号”和“实时信号”,在跨线程传数据时也能解释很多诡异现象。
我从实际开发中经常看到的问题:有人想用SIGUSR1做事件通知,发信号的人和接收方约定“来一次信号就扫描一次文件”,结果信号被阻塞期间来了多次,合并成一次,业务事件直接丢失。这时候要么换实时信号,要么就别依赖信号的“次数”,只把信号当“唤醒条件”,醒来后自己去查细节。
3.2 递送时机:为什么进程必须“回到用户态”才能跑信号处理函数
信号处理函数是用户空间的代码,内核不能直接调用。那内核是怎么让进程去执行处理函数的?
关键点在于:信号的递送(delivery)发生点在“从内核态返回用户态”的路上。
进程在用户态执行,无论是系统调用、硬件中断还是异常,一旦进入内核,返回用户态之前会检查这个进程的TIF_SIGPENDING标志。如果 pending 集合里有未被阻塞的信号,内核会调用do_signal,根据信号的动作类型决定怎么做。如果是自定义处理函数,内核会在用户态栈上构造一个信号帧(signal frame),把当前被打断的上下文保存进去,然后修改返回地址为处理函数入口;处理函数执行完后,再通过rt_sigreturn系统调用恢复之前的上下文。
这就解释了一个非常经典的面试题:为什么一个纯while(1)死循环、没有任何系统调用的程序,能被SIGINT打断?因为进程在用户态循环时不断被调度器切走,每次被切回时都要经过内核态中断入口(比如时钟中断)或者调度器路径,在这些路径上返回用户态时同样会检查TIF_SIGPENDING。换句话说,只要有中断、有调度,就有机会处理信号。
对于单线程进程,这个路径比较简单;多线程进程里,信号递送的目标选择稍微复杂一些:同步信号一定投递到触发线程;异步信号则可能投递到进程内任意一个未阻塞该信号的线程,这也导致多线程程序里用信号做线程间通信非常不靠谱,需要用pthread_kill指定线程。
3.3 默认动作分类与特权信号
信号递送到进程之后,如果进程没有注册任何处理函数,内核会执行默认动作。Linux 把默认动作分成几类:
| 类别 | 常见信号 | 默认结果 |
|---|---|---|
| Term | SIGTERM、SIGPIPE、SIGUSR1、SIGALRM | 终止进程 |
| Core | SIGSEGV、SIGFPE、SIGILL、SIGABRT | 终止进程并生成 core dump |
| Stop | SIGSTOP、SIGTSTP | 暂停进程 |
| Cont | SIGCONT | 继续运行 |
| Ign | SIGCHLD、SIGURG、SIGWINCH | 默认忽略 |
有两个信号是任何处理函数都拦不住的:SIGKILL和SIGSTOP。内核在投递路径里对这些信号做了特殊处理,不允许用户覆盖动作,也不允许阻塞。SIGKILL让进程无条件终止,SIGSTOP让进程无条件暂停。这也是为什么系统管理员应急时总是kill -9,因为-9就是SIGKILL,任何程序都没法忽略或拦截。
这里我想插一个很多人混淆的点:SIGTERM和SIGKILL的区别。SIGTERM(默认编号 15)是“请优雅退出”,进程可以捕获并做清理;SIGKILL(编号 9)是“立刻死”,内核直接强制执行do_exit。你写的服务如果没注册SIGTERM处理函数,kill 默认发 15 号信号也能终止它,但没法保证释放资源、关闭数据库连接等清理工作。生产环境里的安全关机脚本,通常先发SIGTERM,等一段时间再发SIGKILL。
4. 实操心得:怎么用信号来保护和排查自己的程序
4.1 处理 SIGPIPE 的几种正确姿势
先解决最日常的问题:怎么避免自己的程序因为 SIGPIPE 莫名其妙退出。
第一种方式,直接忽略它。注册signal(SIGPIPE, SIG_IGN)或sigaction(SIGPIPE, &act, NULL)后,写管道或写 socket 时如果对端关闭,write()/send()会返回-1,错误码EPIPE,你可以在代码里判断这个错误并决定要不要重试、清理资源、或者退出。
C 语言示例:
#include <signal.h> #include <errno.h> #include <unistd.h> int main(void) { struct sigaction sa; sa.sa_handler = SIG_IGN; sigemptyset(&sa.sa_mask); sa.sa_flags = 0; sigaction(SIGPIPE, &sa, NULL); char buf[] = "hello"; ssize_t n = write(1, buf, sizeof(buf)); if (n < 0 && errno == EPIPE) { /* 对端已关闭,正常处理即可 */ } return 0; }第二种方式,用send()的MSG_NOSIGNAL标志。对于 socket 编程,send(fd, buf, len, MSG_NOSIGNAL)在连接断开时既不会触发SIGPIPE,也会返回EPIPE,比全局忽略信号更精准、更安全。全局忽略信号影响面太大,可能掩盖其他路径上的异常;而MSG_NOSIGNAL只影响这次send调用。
我在做高并发网络转发服务的时候,遇到进程频繁“消失”的坑,最后定位就是SIGPIPE:客户端断开,服务端往一个已经关闭的 socket 写数据,内核直接给进程发了SIGPIPE,默认动作就是终止进程。后来的所有 socket 收发代码统一走了MSG_NOSIGNAL,进程稳定性提升非常明显。
还要提醒一下,shell 管道的场景里,有时候我们希望“下游退出时上游别死”,但上游程序不在我们手里。比如grep pattern hugefile.log | head -1,head 读到一行就退出,grep 继续读文件并往管道写时就会收到SIGPIPE终止。这个行为本身是合理的:上游继续计算下去没有意义。但如果上游是关键任务、不希望被 head 连坐,就需要在上游程序里显式处理SIGPIPE。你改变不了 grep 的行为,但可以改变自己写的程序的行为。
4.2 用 strace、gdb 和 core dump 定位信号来源
排查信号问题,我始终推荐三步走:strace看系统调用、gdb看现场、core dump 看细节。
信号挂了的进程,现场往往没有日志,怎么办?先把程序的崩溃现场抓下来。
第一步,用 strace 跟踪信号:
strace -e trace=signal,write,read -p 12345如果进程是被信号终止的,strace 会打印类似--- SIGPIPE {si_signo=SIGPIPE, si_code=SI_USER, si_pid=...} ---的信息。看到SIGPIPE出现在哪一次write()后面,基本就定位到了。
第二步,用 gdb 捕获信号:
gdb ./your_program core (gdb) catch signal SIGFPE (gdb) run捕获到信号后,bt看一下调用栈,info registers看一下现场寄存器。除 0 崩溃时,栈顶通常能直接看到触发除法的函数和行号,比看任何日志都直观。
第三步,如果是“崩溃但没留现场”的情况,先确认 core dump 开关:
ulimit -c unlimited生产环境一般默认不开 core dump,或者只开在特定目录。调试阶段建议开起来,配合systemd-coredump或apport直接能看到崩溃时的完整内存。gdb加载 core 文件后,输入info signals能看到每个信号当前的处置方式,输入signal SIGFPE能手动触发一次处理流程。
这里有一个我踩过的坑:加了signal(SIGPIPE, SIG_IGN)之后,再用 strace 看,发现进程确实没被信号终止了,但write()返回EPIPE后程序直接走了错误分支退出。这说明忽略信号只是第一步,错误处理必须完整,否则程序照样死,只是死法从“被信号杀死”变成了“自己检查到错误后退出”。排查的时候要同时盯信号和相关系统调用的返回值。
4.3 信号排查常见误区速查表
| 误区 | 实际情况 |
|---|---|
kill -9发的是SIGKILL,程序能拦截 | SIGKILL不可捕获、不可阻塞、不可忽略 |
| 只有显式调用 kill 才会有信号 | 硬件异常、内核错误路径、终端控制字符、定时器到期都会产生信号 |
| 收到信号处理函数执行完就没事了 | 处理函数返回后,如果信号是致命错误(如 SIGSEGV),再次触发同一错误会无限循环,直到栈溢出 |
| 信号会排队,发多少次处理多少次 | 普通信号不排队、同类型合并,实时信号才排队 |
| 多线程里任何线程都能处理信号 | 同步信号只发给触发线程,异步信号发给任意不阻塞的线程,用pthread_kill才能指定线程 |
| 忽略 SIGPIPE 就万事大吉 | 还要处理write()/send()返回的EPIPE,不然逻辑依然会出错 |
4.4 主动利用信号的几个技巧
信号不只是“崩溃通知”,也有很多正向用法。
- 优雅退出:服务端程序注册
SIGTERM处理函数,在函数里关监听、保存状态、退数据库连接,再exit(0)。我见过不少直接把SIGTERM忽略掉的服务,最后只能kill -9,数据一致性风险极高。 - 配置热加载:用
SIGHUP作为“重新读取配置文件”的提示,nginx、sshd 都这么干。自己的守护进程也可以照抄这个约定,运维同学会感觉很亲切。 - 父子进程协作:父进程用
SIGCHLD回收子进程,同时配合waitpid(-1, &status, WNOHANG)避免僵尸进程。 - 超时控制:
alarm()/setitimer()发SIGALRM,在阻塞读操作外层做一个“到点就中断”的开关。现代 Linux 里select/poll/epoll都支持超时参数,但老代码里确实还有很多依赖SIGALRM的写法。
5. 再往内核深处看一眼:信号是“检查”出来的,不是“扔”出来的
5.1 每个信号都对应一个内核检查点
如果你把标题里那个问题——Linux 信号的真正来源——简化成一句话,我会说:信号是内核对某些条件进行“检查”后产生的结果,而不是游离在网络里的数据包。
除 0 产生的SIGFPE,检查点是 CPU 的指令执行单元;SIGPIPE的检查点是pipe_write或 socket 发送路径里的“对端是否还活着”;SIGSEGV的检查点是 CPU 页错误处理;SIGCHLD的检查点是进程退出路径的do_notify_parent逻辑;SIGINT的检查点是 tty 线路规程对输入字符的解析。每一个信号,背后都有一段内核代码在做条件判断。
这个认知对排查问题有多大帮助?我举个例子:SIGBUS通常出现在 mmap 映射的文件被 truncate 之后,进程访问超出新文件大小的映射区域。你如果只知道“SIGBUS 是总线错误”,会去查硬件;如果知道它对应“mmap 映射的文件页 I/O 失败”,就能直接联想到“有没有人改小了文件”。同样是崩溃,排查方向完全不同。
5.2 现代机制正在改变“信号消费”的方式
传统信号处理有两个痛点:一是处理函数在任意时刻打断用户代码,要小心异步安全;二是普通信号不排队,容易丢事件。Linux 后续引入了一些新机制来降低这些痛点。
signalfd把信号变成了一个文件描述符,进程可以用read()来消费信号,跟普通事件循环统一起来。这在多线程事件驱动的服务里非常有用,不需要注册传统的信号处理函数,避免了“在 handler 里调用非异步安全函数”的隐患。
pidfd(进程文件描述符)允许你用poll()监听某个进程是否退出,替代“子进程死了靠 SIGCHLD 通知”的传统方式。waitid配合WNOWAIT在很多场景里都比信号处理更可控。
io_uring和异步接口的出现,也让很多事件通知不再依赖信号。但不管怎么演进,内核底层对“条件检查”的机制没有变,只是把通知方式从“打断执行”变成了“我可以等,你来读”。
我个人在实际项目里的体会是:能不用信号处理函数就不用,优先用signalfd+poll/epoll事件循环;不能不处理信号时,优先忽略 + 检查返回值;实在要注册处理函数,handler 里只许做有限的事:设置一个 volatile 标志、写一个self-pipe、或者干脆什么都不做只唤醒主循环。
最后分享一个排查小技巧:在怀疑信号问题的时候,先跑一条命令看进程当前对每个信号的处置状态:
cat /proc/<pid>/status | grep -E "Sig|Blk|Ign"SigPnd是挂起的信号,SigBlk是被阻塞的信号,SigIgn是被忽略的信号,SigCgt是被捕获的信号。看到SigIgn里如果只有SIGPIPE那样的位置对不上号,基本就能确定问题方向。比起反复改代码打日志,这个文件能让你少走非常多的弯路。