Linux 信号机制
一、 概述
在 Linux/Unix 系统中,信号(Signal)是最古老、最基础的进程间通信(IPC)机制之一。它本质上是一种异步事件通知机制。
- 异步性:信号的产生与进程的正常执行流程是并行的,进程无法预知信号何时到达。
- 轻量级:每个信号仅用一个整数标识,不携带复杂的数据结构。
- 核心作用:用于通知进程某个特定事件已经发生(如异常、终止、中断等),协调系统的正常运行。
二、 信号的分类与常见信号
Linux 系统定义了多达 64 种信号,主要分为两大类:
1. 标准信号(1-31号)
传统 UNIX 信号,也称为"不可靠信号"。它们没有优先级,不支持排队,多次发送相同信号可能会被合并(只保留一个)。
- SIGINT (2) :键盘中断(
Ctrl+C),温和地请求终止进程。 - SIGQUIT (3) :键盘退出(
Ctrl+\),强烈请求终止并生成 Core Dump。 - SIGKILL (9) :强制终止进程。不可捕获、不可忽略。
- SIGSEGV (11):段错误(非法内存访问/野指针),通常由硬件异常触发。
- SIGCHLD (17):子进程状态改变(退出或停止)时通知父进程。
- SIGSTOP (19) :强制停止进程。不可捕获、不可忽略。
2. 实时信号(34-64号)
POSIX 标准引入的"可靠信号"(范围从 SIGRTMIN 到 SIGRTMAX)。
- 特点 :支持排队、可携带数据(通过
sigqueue发送),且严格按照发送顺序递达,不会丢失。适用于高实时性 IPC 场景。
三、 信号的生命周期
信号从产生到处理,经历三个核心阶段:
- 产生(Generation):当事件发生时,内核为目标进程生成信号,并将其添加到进程的**未决信号集(Pending Signal Set)**中。
- 阻塞/屏蔽(Blocking/Masking) :进程可以通过设置**信号屏蔽字(Signal Mask)**来阻塞某些信号。被阻塞的信号会保持未决状态,直到解除阻塞。注意:阻塞不等于忽略,忽略是直接丢弃,而阻塞是暂存排队。
- 递达(Delivery):当进程从内核态返回用户态时,内核检查未决信号集。如果存在未被阻塞的信号,则执行相应的处理动作,并将其从 Pending 集合中移除。
四、 信号的处理方式
当信号递达时,进程可以选择以下三种处理方式:
- 执行默认动作(SIG_DFL):由内核决定,如终止进程、停止进程或生成 Core Dump。
- 忽略信号(SIG_IGN):当它没发生过,直接丢弃。
- 捕获信号(Custom Handler):注册自定义处理函数,由内核在信号递达时调用。
五、 信号捕捉的底层机制:三次穿越
信号处理函数(Handler)不是被 main 函数调用的,而是通过内核劫持用户态执行流来实现的。这是一个涉及用户态与内核态多次切换的复杂过程:
- 陷入内核 :进程正在
main函数中执行,发生中断或异常切换到内核态。 - 检查与决策 :中断处理完毕返回用户态前,内核检查发现有信号递达。内核决定不恢复
main的上下文,而是修改指令指针(EIP/RIP)指向sighandler。 - 执行 Handler :CPU 切换回用户态,执行
sighandler。此时sighandler和main使用不同的堆栈空间,是独立控制流程。 - sigreturn :
sighandler返回后,自动执行特殊的系统调用sigreturn再次进入内核态。 - 恢复现场 :内核恢复
main函数的上下文,再次返回用户态继续执行。
六、 核心 API:sigaction 与自动屏蔽
相比于老旧的 signal() 函数,sigaction 提供了更精细、更安全的控制能力。
自动屏蔽机制(防重入保护)
当某个信号的处理函数被调用时,内核会自动将当前信号加入进程的信号屏蔽字。当处理函数返回时,自动恢复原来的屏蔽字。
- 作用:防止同一个信号在处理过程中再次打断自己(避免递归调用导致栈溢出)。
- 额外屏蔽 :通过
sa_mask字段,还可以在处理当前信号时,顺便屏蔽其他指定的信号。
七、 可重入函数与并发安全
由于 Handler 是在用户空间异步执行的,如果处理不当,极易引发灾难。
什么是可重入(Reentrant)?
一个函数在被执行的过程中被中断,并在处理完中断后再次进入该函数,依然能够正确运行,不破坏任何共享数据。
为什么 Handler 里不能随便调 printf/malloc?
- 不可重入的危险 :像
malloc()内部维护着全局的空闲内存链表,printf()内部有全局缓冲区。如果主程序正在修改这些全局状态时被 Handler 打断,且 Handler 也调用了这些函数,会导致数据结构被破坏、内存泄漏甚至段错误。 - 最佳实践 :在 Handler 中,只设置一个全局的
volatile sig_atomic_t类型的标志位。把复杂的业务逻辑留给主循环去处理。
八、 volatile 关键字的底层意义
在信号处理中,标志变量必须用 volatile 修饰。
- 编译器的盲区 :编译器在分析
main函数的控制流时,是"线性"的。它看不见未来某个时刻内核会通过中断强行插入执行handler。 - 防止致命优化 :如果不加
volatile,编译器可能会将while(!flag)优化为只读取一次flag放入寄存器,导致即使 Handler 修改了内存中的flag,主循环也永远看不到,陷入死循环。volatile强制编译器每次都从内存中读取该变量。
九、 SIGCHLD 与僵尸进程防范
只要编写服务端程序,就必须处理子进程退出产生的僵尸进程。现代编程中有两个最佳实践:
- 不关心退出状态 :在
fork()前执行signal(SIGCHLD, SIG_IGN);,内核会自动回收子进程。 - 需要监控状态 :注册 Handler,内部必须使用
while(waitpid(-1, NULL, WNOHANG) > 0);。因为多个子进程同时退出时,信号可能会合并丢失,必须用循环一次性清理干净。
十、 中断、信号与系统调用
信号机制并非孤立存在,它是整个操作系统"事件驱动"架构的一环:
- 硬件中断:外设主动敲门(如键盘按下),触发 CPU 暂停当前工作,执行内核驱动。
- 软件信号:内核在进程从内核态返回用户态时,强行劫持执行流,让进程去跑 Handler。
- 系统调用 :用户程序主动触发(通过
syscall指令和寄存器传参),请求内核办事,办完直接返回。
这三者共同构成了计算机系统底层通信与调度的基石。
文章里提到的进程间通信(IPC)还没展开,要不要我接着帮你整理一份?