全文核心主线 :硬件中断/异常/系统调用 → 用户态陷入内核 → 内核处理事件并产生信号 → 信号记录到进程未决集 → 内核处理完毕准备返回用户态 → 检查未决信号并修改返回上下文 → 用户态先执行信号处理函数 →
sigreturn系统调用恢复现场 → 继续原执行流所有知识点严格围绕这条链路展开,每一节对应链路中的一个环节,层层递进。本章在原有完整内容基础上,补充时间片调度机制、TSC 时间戳计数器、高精度定时器 hrtimer 三大块深度内容,所有硬件与调度细节最终都回扣到信号机制本身,不删减原有任何章节,保持全文完整度。
信号(Signal)是 Linux 最经典的异步通知机制。很多开发者只停留在 signal() 函数的表层使用,却不清楚三个最核心的问题:
- 为什么信号必须通过内核才能发送?
- 为什么进程在纯用户态死循环里,不做任何系统调用,也能收到信号?
- 为什么信号里修改的全局变量,必须加
volatile才生效?
这三个问题的答案,全部藏在「用户态-内核态」的特权级切换、时钟中断的周期性切入、时间片调度机制、上下文保存与返回劫持的完整链路里。本文从 CPU 硬件机制出发,沿着信号的生命周期一路走到用户态代码,把所有底层原理和工程实践串联起来。
1 硬件基础:特权级切换与上下文保存
【对应链路环节】陷入内核:这是整个信号机制的入口前提。所有信号的产生都在内核态完成,所有信号的递送都依赖内核态返回用户态的路径。
用户态程序不能直接访问内核数据、不能直接给其他进程发信号,所有和信号相关的内核操作,都必须先通过中断/异常/系统调用陷入内核态才能执行。理解特权级切换和上下文保存,是理解信号产生时机、递送时机的根本前提。
1.1 为什么要有用户态和内核态
x86 架构提供 4 个特权级(Ring 0 ~ Ring 3),Linux 只使用了两级:
- Ring 0(内核态):最高特权,可以执行所有 CPU 指令,访问全部内存与硬件资源,操作系统内核运行在这一级。
- Ring 3(用户态):最低特权,只能执行非特权指令,只能访问自身虚拟地址空间,所有应用程序运行在这一级。
做特权隔离的核心目的是安全与稳定:如果所有程序都跑在最高特权级,恶意代码或有 bug 的程序可以直接修改内核、格式化磁盘,系统的稳定性完全无法保障。用户态程序要做任何危险操作,都必须通过系统调用委托内核完成,由内核做权限校验和安全管控。
1.2 陷入内核的三条唯一路径
用户态不能直接跳转到内核代码,必须通过 CPU 规定的「门」进入。Linux 下只有三条路径能从用户态进入内核态,所有信号的产生与递送,都和这三条路径深度绑定:
| 路径类型 | 触发方式 | 同步/异步 | 典型场景 |
|---|---|---|---|
| 系统调用 | 用户态执行 syscall 指令主动触发 |
同步 | kill 发信号、sigaction 安装处理函数 |
| 异常 | 执行指令出错,CPU 自动触发 | 同步 | 除零、缺页、非法指令,内核会转成对应信号 |
| 硬件中断 | 外设通过 APIC 发送中断请求 | 异步 | 时钟中断、键盘中断,终端 Ctrl+C 由此产生 |
在所有硬件中断里,时钟中断(Timer Interrupt)是信号机制最特殊、最关键的一个触发源------它是周期性的、强制性的,是纯用户态进程能够收到信号的唯一兜底保障,同时也是进程时间片调度、系统计时的共同基础。下面我们从物理晶振开始,完整拆解时钟中断的全链路。
1.3 深度拆解:时钟中断从硬件到内核的完整链路
时钟中断不是凭空产生的,它的源头是主板上的物理晶振,经过可编程定时器分频、中断控制器路由,最终送达 CPU 核心,触发内核的节拍处理逻辑。它既是系统时间的基准,也是进程调度、定时器信号、信号递送兜底的共同基础。
1.3.1 硬件层:从晶振到可编程定时器
所有计时中断的物理源头,都是主板上的晶体振荡器(简称晶振)。
基准时钟源:晶体振荡器
- 晶振是一种利用压电效应产生固定频率方波信号的电子元件,是整个计算机系统的「心跳基准」。
- PC 主板通常有两个核心晶振:
- 系统晶振:频率为 14.31818 MHz,是传统定时器、南桥芯片的基准时钟。
- 实时时钟(RTC)晶振:频率为 32.768 kHz,用于关机后维持系统日期时间,精度较低,不产生周期性时钟中断。
- 晶振输出的原始频率很高,不能直接作为中断触发源,需要通过可编程定时器进行分频,得到我们需要的中断节拍频率。
传统定时器:8253/8254 PIT(可编程间隔定时器)
早期 x86 系统使用 8253/8254 PIT 作为时钟中断源,至今仍被内核兼容支持:
- 输入时钟:由系统晶振分频得到 1.19318 MHz 的基准输入(14.31818 MHz ÷ 12)。
- 工作原理:内核通过端口向 PIT 写入分频系数,定时器就会从初始值开始递减计数,计数到 0 时输出一个脉冲,触发 IRQ0 中断。
- 分频计算:中断频率 = 输入频率 ÷ 分频系数。例如要得到 100Hz 的中断,分频系数 = 1193180 ÷ 100 ≈ 11932。
- 局限:PIT 是全局单定时器,精度低(毫秒级),不支持多核独立计时,现代系统已不再作为主时钟源。
现代定时器:HPET 与本地 APIC 定时器
x86_64 主流系统使用两套高精度定时器组合:
- HPET(高精度事件定时器)
- 由南桥芯片提供,是系统级的全局定时器,基准频率通常为 14.31818 MHz 或 100 MHz。
- 提供多个独立的比较器通道,精度可达纳秒级,支持单次触发和周期触发,用于替代传统 PIT。
- 本地 APIC 定时器(Local APIC Timer)
- 每个 CPU 核心的本地 APIC 都自带一个独立定时器,是现代 Linux 的主时钟源。
- 时钟源来自总线时钟或 CPU 核心时钟,每个核心可以独立配置中断频率,完美适配 SMP 多核架构。
- 支持一次性定时和周期定时,精度远高于传统 PIT。
TSC:时间戳计数器(只计时,不产生中断)
除了能触发中断的定时器,CPU 还提供了一套纯计时用的高速计数器------时间戳计数器(Time Stamp Counter, TSC)。
- 硬件本质:每个 CPU 核心内置一个 64 位寄存器,随 CPU 核心时钟周期递增,每条指令周期递增一次。
- 读取方式:通过
rdtsc指令直接读取,用户态也可以执行,读取延迟只有几个时钟周期,速度极快。 - 精度:取决于 CPU 主频,3GHz 主频下精度约 0.33 纳秒,是系统中精度最高的计时手段。
- 核心特点:只做时间测量,不产生任何中断。它只能用来算时间差,不能用来触发定时事件,因此不能直接驱动信号递送。
- 演进与问题:早期多核 CPU 不同核心的 TSC 不同步、变频时频率变化会导致计时不准;现代 CPU 基本都实现了常量 TSC(Constant TSC),保证全核同步、主频变化时频率不变,成为内核高精度计时的基础。
关键区分:本地 APIC 定时器、HPET、PIT 是「中断型定时器」,能产生中断、触发事件、驱动信号递送;TSC 是「测量型计时器」,精度高、读取快,但不产生中断,只用于时间计算。
1.3.2 内核层:节拍率 HZ、jiffies 与时间片调度
硬件定时器只是提供了可编程的中断触发能力,具体每秒触发多少次中断、每次中断做什么,由内核的时钟子系统定义。
HZ:内核节拍频率
HZ是内核编译时配置的全局参数,代表每秒时钟中断的次数,单位是 Hz。- x86 平台常见配置:
HZ=100:每秒 100 次中断,节拍间隔 10ms,开销低,适合服务器低负载场景。HZ=250:每秒 250 次中断,节拍间隔 4ms,是多数发行版的默认配置,平衡开销与响应性。HZ=1000:每秒 1000 次中断,节拍间隔 1ms,响应性高,适合桌面、低延迟场景,中断开销略高。
- 内核源码中通过
CONFIG_HZ配置项选择,用户无法运行时修改。
jiffies:全局节拍计数器
jiffies是内核的全局 64 位变量,每发生一次时钟中断,jiffies 就加 1。- 它是内核最基础的时间基准,所有超时、定时、进程时间统计都以 jiffies 为单位。
- 真实时间与 jiffies 的转换:
- 秒数转 jiffies:
jiffies = seconds * HZ - jiffies 转毫秒:
ms = jiffies * 1000 / HZ
- 秒数转 jiffies:
- 64 位系统下 jiffies 几乎不会溢出;32 位系统下 1000Hz 配置约 49 天溢出,内核通过 jiffies 回绕处理逻辑保证正确性。
时间片与进程调度:时钟中断的核心职责
时钟中断最核心的工作之一,就是维护进程的时间片,驱动调度器运转,这也是它和信号机制深度关联的另一个维度。
时间片的基本概念
- 时间片(Time Slice)是调度器分配给每个进程运行的最长时间,单位通常是 jiffies。
- 目标:让多个进程在单个 CPU 上「分时复用」,看起来像同时运行。时钟中断每来一次,就扣减当前进程的时间片;时间片耗尽后,就触发调度,让下一个进程运行。
时钟中断中的调度相关操作
每次时钟中断上半部,都会执行以下调度相关逻辑:
- 更新进程运行时间 :累加当前进程的
utime(用户态运行时间)和stime(内核态运行时间),用于统计、计费和虚拟定时器判断。 - 扣减时间片:递减当前进程的剩余时间片计数。
- 时间片耗尽判断 :如果剩余时间片减到 0,就设置当前进程的
TIF_NEED_RESCHED标志,请求调度。 - 调度时机 :时钟中断处理完毕、返回用户态前,会检查
TIF_NEED_RESCHED标志,如果置位就调用schedule()切换进程。
这里有一个和信号强相关的关键点:调度检查和信号检查发生在同一个返回路径上 。
时钟中断返回用户态前,内核会依次检查
TIF_NEED_RESCHED和TIF_SIGPENDING。也就是说,每次时间片调度的检查点,同时也是信号递送的检查点。哪怕进程时间片没耗尽、不需要调度,也一定会走一遍信号检查流程,这就保证了信号递送的及时性。
CFS 调度器下的时间片
传统 O(1) 调度器使用固定时间片;现代 Linux 默认的 CFS(完全公平调度器)不再使用固定时间片,但依然依赖时钟中断:
- CFS 用虚拟运行时间(vruntime)替代固定时间片,每个进程按权重分配 CPU 时间。
- 时钟中断依然负责累加运行时间、更新 vruntime、检查是否需要调度。
- 最小粒度(sched_min_granularity)保证每个进程至少运行一小段时间,避免频繁切换,这个粒度也是基于 HZ 计算的。
时钟中断的完整处理流程
时钟中断属于硬中断,处理流程分为上半部(硬中断上下文)和下半部(软中断上下文),遵循「上半部快处理、下半部做重活」的中断设计原则:
- 硬件触发:定时器计数到期,通过本地 APIC 向当前 CPU 发送中断请求,CPU 自动完成栈切换、压入上下文,陷入内核态。
- 上半部处理(硬中断)
- 应答中断控制器,关闭当前中断线。
- 更新
jiffies计数(全局或 per-cpu)。 - 更新当前进程的运行时间统计(用户态时间、内核态时间)。
- 扣减时间片 / 更新 vruntime,检查是否需要调度,设置
TIF_NEED_RESCHED标志。 - 标记时钟软中断(
TIMER_SOFTIRQ),退出硬中断。
- 下半部处理(软中断)
- 遍历内核定时器链表,检查所有定时器是否到期。
- 对到期的定时器,执行回调函数;如果是
alarm/setitimer定时器,则调用send_sig()向进程发送对应信号。 - 更新系统负载、统计信息、调度器周期计算等。
动态时钟(NO_HZ / Tickless)
传统周期性时钟无论系统是否空闲,都会按 HZ 频率持续触发中断,浪费电力和 CPU 资源。现代 Linux 默认开启动态时钟(CONFIG_NO_HZ):
- CPU 上只有一个运行队列时,会停止周期性时钟中断,改为根据下一个定时器的到期时间,设置一次性高精度定时。
- 没有任务时 CPU 可以进入深度 idle 状态,大幅降低功耗。
- 即便开启动态时钟,只要有进程在运行,依然会保证节拍级的中断切入,不会影响信号递送的兜底机制。
高精度定时器 hrtimer
传统定时器基于 jiffies,精度受限于 HZ。内核 2.6 之后引入了高精度定时器(hrtimer),基于 TSC 和 HPET 实现,精度可达纳秒级。
- 组织方式:用红黑树管理所有定时器,按到期时间排序,每次取最早到期的一个。
- 时钟源:底层使用高精度硬件时钟源(TSC、HPET),到期时触发一次性中断。
- 应用场景:
nanosleep、clock_nanosleep、IO 超时、调度器高精度计时等。 - 和信号的关系:
alarm()和SIGALRM传统上基于低分辨率定时器;现代内核也提供timer_create接口,可以创建基于 hrtimer 的定时器,到期发送SIGALRM或其他信号,精度远高于传统 alarm。
1.3.3 与信号机制的深度绑定
时钟中断不是孤立的硬件机制,它从三个维度深度支撑了信号机制的正常工作:
1. 信号递送的周期性兜底入口
这是时钟中断对信号机制最核心的价值:
- 如果一个进程处于纯用户态死循环,完全不调用系统调用、不触发异常、也没有其他外设中断,它本可以永远不陷入内核,永远没有机会处理信号。
- 但时钟中断是强制性的周期性中断,无论进程在做什么,每隔一个节拍(几毫秒)就会强制打断用户态执行,陷入内核态。
- 每次时钟中断处理完毕、返回用户态前,都会走标准的
exit_to_user_mode_loop检查流程,检查TIF_SIGPENDING标志。 - 这意味着:只要信号被标记为未决,最晚经过一个节拍的时间,就一定会被递送给进程。这就是死循环里也能收到信号的底层原因。
2. 所有定时器类信号的硬件源头
SIGALRM、SIGVTALRM、SIGPROF 等定时器类信号,底层完全依赖时钟中断:
- 用户调用
alarm()/setitimer()时,内核会创建一个内核定时器节点,挂入全局定时器链表,设置到期时间(以 jiffies 为单位)。 - 每次时钟中断的下半部,都会扫描定时器链表,检查是否到期。
- 定时器到期后,内核调用
send_sig()向进程发送对应信号,并根据配置重置或销毁定时器。 - 这也决定了传统定时器信号的精度上限就是时钟节拍:1000Hz 配置下精度最高 1ms,250Hz 下是 4ms,不可能做到微秒级精确。
- 基于 hrtimer 的高精度定时器,精度可以达到微秒级,但本质依然是靠定时器到期触发中断、再产生信号。
3. 可中断睡眠与信号唤醒
进程调用 sleep、read 等阻塞函数时,会进入 TASK_INTERRUPTIBLE(可中断睡眠)状态。这种状态下的进程,既可以被信号唤醒,也可以被资源就绪唤醒。
- 信号到来时,内核会唤醒睡眠的进程,让它进入就绪态,等待调度器调度运行。
- 进程被调度运行、从内核态返回用户态前,就会走信号递送流程,处理信号。
- 时钟中断负责调度器的运转,保证被唤醒的进程能及时得到运行机会,进而及时处理信号。
1.4 硬件自动完成的栈切换与上下文压栈
从用户态(Ring 3)切到内核态(Ring 0)时,CPU 硬件会原子性地完成以下操作,全程由硬件保证安全:
- 栈切换 :从 TSS(任务状态段)中读取当前 CPU 的内核栈地址(
rsp0),把用户栈的SS、RSP寄存器压入内核栈保存,然后将RSP切换为内核栈地址。 用户态和内核态使用完全独立的栈,这是特权隔离的重要保障。 - 压入关键上下文 :按固定顺序把
RFLAGS(标志寄存器)、CS+RIP(代码段+指令指针,即被中断的下一条指令地址)压入内核栈。 这一步保存了「程序从哪来」,是后续能原路返回的基础。 - 压入错误码(可选):缺页、通用保护等异常会额外压入错误码,说明异常原因。
- 查表跳转:根据中断向量号查 IDT(中断描述符表),跳转到内核对应的处理函数入口。
1.5 软件补充:完整上下文 struct pt_regs
硬件只压了少量关键寄存器,但内核处理过程中会修改通用寄存器。因此进入内核处理函数前,汇编代码会把剩下的所有通用寄存器(rax、rbx、rcx、rdx、rsi、rdi、rbp、r8~r15 等)全部压入栈,最终形成一个完整的 struct pt_regs 结构体。
c
// x86_64 下 pt_regs 核心结构(简化)
struct pt_regs {
unsigned long r15; unsigned long r14;
unsigned long r13; unsigned long r12;
unsigned long rbp; unsigned long rbx;
unsigned long r11; unsigned long r10;
unsigned long r9; unsigned long r8;
unsigned long rax; unsigned long rcx;
unsigned long rdx; unsigned long rsi;
unsigned long rdi; unsigned long orig_rax;
unsigned long rip; // 用户态下一条指令地址
unsigned long cs;
unsigned long eflags;
unsigned long rsp; // 用户态栈指针
unsigned long ss;
};
这个结构体是信号机制的核心支点:
- 它是用户态执行现场的完整快照,内核处理完所有事情后,只要把
pt_regs里的值还原回寄存器,就能让进程毫发无损地回到用户态。 - 信号能够「异步插入」用户态执行,本质就是内核偷偷修改了
pt_regs里的rip和rsp,让进程返回用户态时「走错路」,先去执行信号处理函数。
承上启下:理解了
pt_regs和内核态切换,我们就能回答最初的问题------为什么信号不能随时打断用户代码?内核没有办法在用户态代码执行中途,强行修改用户寄存器、插入一段代码。只有当进程陷入内核、完整上下文保存在内核栈的
pt_regs里时,内核才能安全地修改返回上下文。这也是信号递送永远发生在「内核态→用户态」边界的根本原因。
1.6 返回用户态:信号递送的唯一合法时机
内核处理完系统调用、异常或中断后,最终会走到返回用户态的出口 exit_to_user_mode_loop。在真正执行 iretq 返回前,内核会循环检查线程标志位,直到全部清零才返回:
TIF_SIGPENDING:有未决信号需要处理 → 调用do_signal()TIF_NEED_RESCHED:需要调度 → 调用schedule()切换进程TIF_NOTIFY_RESUME:其他待通知事件
检查是循环的:处理完调度后还要再查信号,处理完信号后还要再查调度,确保返回用户态前所有待办事件都处理完毕。
划重点:无论是系统调用返回、异常处理结束,还是时钟中断、键盘中断返回,最终都会走到这个检查点。所有信号,最终都是在返回用户态前的
do_signal()里完成处理的,这是信号递送的唯一出口。
2 信号的来源:内核处理事件时生成信号
【对应链路环节】产生信号:内核在处理中断、异常、系统调用的过程中,判定需要通知用户进程,于是生成信号并记录到目标进程。
上一章讲了进程怎么进入内核态。本章承接上一章:内核在处理这些陷入事件的过程中,如果发现事件需要通知用户态进程,就会生成对应信号,发送给目标进程。
2.1 硬件异常转信号
内核处理 CPU 异常时,如果异常是用户态程序非法操作导致、且无法修复,就会把异常转换为对应信号,发送给当前进程。
| 异常向量 | 异常名称 | 对应信号 | 典型触发场景 | 内核处理入口 |
|---|---|---|---|---|
| 0 | 除零错误 | SIGFPE |
整数除法除数为 0、浮点溢出 | divide_error |
| 6 | 非法指令 | SIGILL |
执行未定义的机器码 | invalid_op |
| 14 | 缺页异常 | SIGSEGV |
访问非法地址、写只读内存 | do_page_fault |
| 1 | 调试异常 | SIGTRAP |
单步执行、硬件断点 | debug |
以缺页异常为例,完整的异常→信号转换流程:
- 进程访问一个无效虚拟地址,CPU 触发 14 号缺页异常,自动陷入内核,切换栈、压入上下文,形成
pt_regs。 - 内核
do_page_fault被调用,读取 CR2 寄存器拿到触发异常的虚拟地址。 - 查找进程的 VMA(虚拟内存区域),判断地址合法性:
- 合法但未分配物理页:分配物理页、建立页表映射,返回用户态重试指令,进程无感知。
- 非法地址/权限不匹配:判定为用户态非法访问。
- 调用
force_sig(SIGSEGV)向当前进程发送段错误信号,把信号标记到进程的未决集里。 - 异常处理结束,走到返回用户态路径,检查到
TIF_SIGPENDING,进入do_signal()递送信号。
2.2 时钟中断与定时器信号
时钟中断是所有定时器类信号的硬件源头,alarm()、setitimer()、timer_create() 等定时器,本质都是基于时钟中断的软件计时。
三类定时器信号
每个进程的 task_struct 中维护着三类独立的传统定时器,都由时钟中断驱动检查:
- 真实定时器(ITIMER_REAL) :统计墙上真实时间,无论进程在用户态还是内核态、是否睡眠都计时,到期发送
SIGALRM。 - 虚拟定时器(ITIMER_VIRTUAL) :只统计进程在用户态运行的时间,到期发送
SIGVTALRM。 - 概况定时器(ITIMER_PROF) :统计用户态+内核态的总运行时间,到期发送
SIGPROF,常用于性能采样。
与时钟中断的对应关系
- 每次时钟中断的上半部,都会更新当前进程的用户态/内核态运行时间,对应虚拟定时器和概况定时器的计数。
- 每次时钟中断的下半部,都会扫描全局定时器链表,检查真实定时器是否到期。
- 定时器到期后,调用
send_sig()向进程发送对应信号;如果是周期性定时器,则重置到期时间重新入队。 - 精度限制:所有传统定时器的检查都在时钟中断中进行,因此精度不可能高于系统节拍(HZ),这也是
alarm()只能做到秒级精度的底层原因。
高精度定时器与信号
基于 hrtimer 的定时器(如 timer_create 创建的 POSIX 定时器),可以做到微秒级精度:
- 底层使用 TSC/HPET 等高精度时钟源,到期时触发一次性高精度中断。
- 中断处理中调用
send_sig()发送信号,延迟远低于周期性时钟节拍。 - 但本质依然是「中断→内核→信号」的链路,信号递送还是发生在中断返回用户态的边界上。
2.3 内核软件事件
很多内核逻辑在特定条件下,会主动向进程发信号,属于操作系统的原生事件通知:
- 终端交互 :用户按下
Ctrl+C时,键盘中断触发终端驱动处理,最终通过kill_pgrp向前台进程组发送SIGINT。 - 子进程状态变化 :子进程终止时,
do_exit中调用do_notify_parent向父进程发送SIGCHLD。 - 管道破裂 :
pipe_write检测到管道所有读端都已关闭时,向当前写进程发送SIGPIPE。
2.4 用户态显式发送
用户程序通过系统调用主动发信号,本质也是陷入内核后,由内核完成信号的记录:
kill(pid, sig):向指定进程/进程组发送信号raise(sig):向当前进程自身发送信号sigqueue(pid, sig, value):发送实时信号并附带数据
2.5 统一出口:__send_signal()
无论信号来源是硬件异常、时钟中断、软件事件还是用户调用,最终都会汇聚到内核函数 __send_signal(),由它统一完成信号的未决记录。这是信号产生阶段的唯一入口,后续所有判断(忽略、排队、唤醒)都在这里完成。
承上启下:信号已经产生了,那它被存在哪里?答案是进程控制块
task_struct中专门的信号相关字段。下一节我们就拆解这些内核数据结构,讲清信号的存储形式。
3 内核数据结构:信号在进程中的存储
【对应链路环节】存储未决:信号产生后,记录在进程的信号相关字段中,等待递送时机到来。
所有和信号相关的信息,全部保存在进程控制块 struct task_struct 里,核心分为四大块:处理函数表、未决信号集、阻塞信号集、进程组共享信息。这四大结构分别对应「信号该怎么处理」「有哪些信号待处理」「哪些信号暂时不处理」「进程组共享信号」四个维度。
3.1 整体结构总览
c
struct task_struct {
struct sighand_struct *sighand; // 信号处理函数表(线程组共享)
struct sigpending pending; // 当前线程私有未决信号队列
sigset_t blocked; // 当前线程阻塞信号掩码(位图)
struct signal_struct *signal; // 进程组共享信号信息
unsigned long sas_ss_sp; // 备用信号栈基址
size_t sas_ss_size; // 备用信号栈大小
};
线程私有 vs 进程共享
- 线程私有 :每个线程有独立的
pending(私有未决集)和blocked(阻塞掩码),发给特定线程的信号只存在这里。 - 进程共享 :
sighand(处理函数表)和signal->shared_pending(共享未决集)为整个线程组共有,发给进程的信号放在共享队列,内核选择一个未阻塞的线程递送。
3.2 信号处理表:sighand_struct
描述每个信号的处理方式,同一个线程组内的所有线程共享同一份,通过引用计数管理生命周期。
c
struct sighand_struct {
atomic_t count; // 引用计数
struct k_sigaction action[_NSIG]; // 数组,下标为信号编号-1
spinlock_t siglock; // 保护处理表的自旋锁
};
struct sigaction 核心字段:
c
struct sigaction {
void (*sa_handler)(int); // 简单处理函数指针
void (*sa_sigaction)(int, siginfo_t *, void *); // 带扩展信息的处理函数
sigset_t sa_mask; // 处理函数执行期间额外屏蔽的信号
int sa_flags; // 行为标志位
};
两个特殊处理指针:
SIG_DFL(值为 0):执行信号的默认动作SIG_IGN(值为 1):直接忽略该信号
3.3 未决信号集:sigpending
已经产生但尚未递送的信号,称为「未决信号」,存储在这里。
c
struct sigpending {
struct list_head list; // 实时信号队列,每个信号一个节点,支持排队
sigset_t signal; // 标准信号位图,1位对应1个信号,只记录有无,不记录次数
};
- 标准信号(1~31):用位图存储,不支持排队。同一个信号连续到达多次,位图也只标记一次,最终只会递送一次,这就是「不可靠信号」的底层根源。
- 实时信号(32~64) :用链表存储,每次发送都会分配一个
sigqueue节点加入队列,发送多少次就递送多少次,这就是「可靠信号」的底层根源。
3.4 阻塞信号集:blocked
也是一个 sigset_t 位图,记录当前进程主动屏蔽的信号。
- 被阻塞的信号产生后,会正常记录到未决集中,但不会被递送。
- 解除阻塞后,如果对应信号处于未决状态,会立即被递送。
- 通过
sigprocmask()系统调用修改。
承上启下:有了存储结构,我们就能深入辨析两个最容易混淆的概念:忽略和阻塞。它们分别作用在信号生命周期的不同阶段,一个在产生时丢弃,一个在递送时延迟,本质完全不同。
4 核心辨析:信号的忽略 vs 阻塞
【对应链路环节】产生/递送过滤:忽略作用在「产生阶段」,阻塞作用在「递送阶段」,分别在两个不同节点控制信号是否生效。
很多人把忽略和阻塞混为一谈,其实它们生效的时机、位置、行为完全不同,分别对应信号生命周期的两个不同节点。
4.1 忽略(SIG_IGN):产生阶段直接丢弃
当进程把某个信号的处理方式设为 SIG_IGN,意味着这个信号会在产生阶段就被内核直接丢弃,全程不会进入未决集。
-
生效位置 :
__send_signal()函数最开头。 -
内核伪代码逻辑 :
cif (handler == SIG_IGN && !sig_kernel_only(sig)) { // 直接丢弃,不设未决位,不分配节点,不唤醒进程 return 0; }其中
sig_kernel_only()用于判断是否为SIGKILL/SIGSTOP------这两个信号即使被设为忽略,内核也会强制生效,保证系统总能终止/停止进程。
一句话总结:被忽略的信号,从一开始就没被记录下来,后续也不会有任何递送动作。
4.2 阻塞(sigprocmask):递送阶段延迟处理
阻塞是通过修改 blocked 位图,暂时推迟信号的递送。信号依然会被正常记录到未决集中,只是不触发处理。
- 生效位置 :
do_signal()遍历未决信号时。 - 执行逻辑 :
__send_signal()正常设置未决位、唤醒进程;但do_signal()遍历未决信号时,会跳过所有在blocked中的信号。 - 解除阻塞时 :
sigprocmask修改完掩码后会重新检查未决集,如果有刚解除阻塞的未决信号,立刻标记并递送。
一句话总结:被阻塞的信号,一直保存在未决集里,只是暂时不处理,解除阻塞后立即递送。
4.3 核心区别对比表
| 对比维度 | 忽略(SIG_IGN) |
阻塞(sigprocmask) |
|---|---|---|
| 作用阶段 | 信号产生时生效 | 信号递送时生效 |
| 未决记录 | 不记录,直接丢弃 | 正常记录,保留在未决集 |
sigpending 检测 |
检测不到 | 可以检测到未决状态 |
| 解除后行为 | 信号已丢失,不会补发 | 解除后立即递送未决信号 |
| 语义本质 | 永久丢弃,不关心该事件 | 延迟处理,稍后再响应 |
| 典型用途 | 永久屏蔽不需要的信号 | 临界区临时屏蔽信号 |
4.4 特殊规则:不可忽略、不可阻塞的信号
有两个信号拥有特殊地位:SIGKILL(9号)和 SIGSTOP(19号)
- 既不能被设置为忽略,也不能被阻塞,也不能被用户自定义捕获。
- 底层原因:它们是系统保证进程可被终止、可被停止的最后手段。如果允许被忽略/阻塞,恶意进程就可以永远不被系统杀死,破坏操作系统的管控能力。
- 内核中所有信号处理逻辑入口,都会先判断
sig_kernel_only(sig),命中则跳过用户设置,强制执行默认动作。
4.5 信号属性继承总表
| 信号属性 | fork() 后子进程 | exec() 后进程 |
|---|---|---|
| 处理方式(sighand) | 完全继承(共享同一份) | 自定义处理重置为 SIG_DFL,IGN/DFL 保留 |
| 阻塞掩码(blocked) | 完全继承 | 完全继承 |
| 未决信号(pending) | 不继承,子进程未决集清空 | 保留未决状态 |
| 备用信号栈配置 | 完全继承 | 完全继承 |
承上启下:讲完了产生、存储、过滤,下一章我们就把整个链路串起来,完整走一遍信号从产生到最终被用户态处理的全流程,重点讲清内核怎么通过修改
pt_regs,把信号处理函数「注入」到用户态执行流里。
5 信号的完整生命周期:从产生到用户态处理
【对应链路环节】全链路串联:产生 → 未决 → 递送 → 用户态处理 → sigreturn 恢复,完整走通一遍。
本章把前面所有知识点串起来,完整还原一个信号从产生到处理的全部步骤,重点讲清「内核怎么让用户态去执行信号处理函数」这个最核心的秘密。

5.1 阶段一:产生(Generation)
所有信号源最终调用 __send_signal(),执行步骤如下:
- 参数校验,检查信号号合法性、目标进程权限。
- 持有
sighand->siglock自旋锁,防止并发修改处理方式。 - 忽略检查 :如果目标信号处理方式为
SIG_IGN且非强制信号,直接释放锁返回,不做任何记录。 - 选择队列:判断是线程信号还是进程组信号,选择私有
pending或共享shared_pending。 - 去重判断:如果是标准信号且对应位图已经置位,直接丢弃新信号(不排队)。
- 分配节点:实时信号或首次产生的标准信号,分配
sigqueue节点,填充siginfo_t信息,加入未决链表。 - 标记未决:设置未决位图对应位为 1。
- 唤醒进程:设置目标进程的
TIF_SIGPENDING标志位;如果目标进程正处于可中断睡眠状态,则唤醒它。 - 释放锁,返回成功。
5.2 阶段二:未决(Pending)
信号产生后、递送前的状态,保存在未决集中。
- 未被阻塞的信号:等待下一次内核态返回用户态时被递送。
- 被阻塞的信号:一直停留在未决集,直到解除阻塞。
- 标准信号:同信号多次产生只保留一个,会丢失。
- 实时信号:按顺序排队,全部保留。
5.3 阶段三:递送(Delivery)
递送发生在每次内核态返回用户态之前 ,由 do_signal() 函数执行,输入就是保存在内核栈上的 struct pt_regs。
5.3.1 查找可递送信号
- 检查
TIF_SIGPENDING标志,若无未决信号则直接返回用户态。 - 先查私有未决集,再查共享未决集,从低到高扫描位图,找到第一个不在
blocked阻塞集中的信号。 - 清除对应未决位,取出
siginfo_t信息,准备处理。
5.3.2 三种处理分支
找到信号后,根据处理方式进入不同分支:
- 默认动作(
SIG_DFL):根据信号类型执行终止、Core Dump、忽略、停止、继续等默认行为。 - 忽略(
SIG_IGN):直接清除未决标志,继续查找下一个信号。 - 自定义处理函数 :最复杂的分支,通过修改
pt_regs劫持返回路径。
5.3.3 自定义处理:劫持返回上下文
这是信号机制最精妙的部分。内核不会在内核态执行用户提供的处理函数,而是修改保存在内核栈上的用户态上下文,让进程返回用户态时「自动」先去执行处理函数。
完整步骤:
- 选择栈 :默认使用用户栈;设置了
SA_ONSTACK则用备用信号栈。 - 构造信号帧 :在用户栈上压入
rt_sigframe结构,包含:- 原始
pt_regs的完整副本(被中断时的全部寄存器状态) - 信号编号、
siginfo_t详细信息 - 阻塞掩码快照
- 返回地址:指向
__restore_rtstub(sigreturn的入口)
- 原始
- 修改
pt_regs:修改内核栈上保存的用户态上下文:rip(指令指针)设为信号处理函数的入口地址rsp(栈指针)设为压入信号帧后的新栈顶- 阻塞掩码更新为
blocked | sa_mask | 当前信号(处理期间自动屏蔽)
- 返回用户态 :
iretq返回用户态。此时pt_regs已经被改写,CPU 不会回到原来被中断的地方,而是直接开始执行信号处理函数。
划重点:信号不是「打断」了用户程序,而是内核在返回用户态前,偷偷改了返回地址,让程序先去跑信号处理函数。整个过程对用户程序完全透明。
5.4 阶段四:用户态处理与 sigreturn 恢复
- 执行处理函数:返回用户态后,CPU 直接执行信号处理函数的代码,这部分运行在用户态 Ring 3 特权级。
- 函数返回 :处理函数执行完
ret指令,根据栈上的返回地址,跳转到__restore_rtstub。 - 触发 sigreturn :
__restore_rt执行syscall触发rt_sigreturn系统调用,再次陷入内核态。 - 恢复上下文 :内核中
sys_rt_sigreturn从用户栈的信号帧里读取原始pt_regs,验证合法性后,把内核栈上的返回上下文还原成被中断时的状态。 - 继续原执行流 :再次返回用户态时,
rip已经恢复成原来的指令地址,进程从当初被中断的位置继续执行,就像什么都没发生过一样。
承上启下:完整链路已经走通了。接下来我们基于这条链路,深入讲解几个核心特性的底层原理,以及工程中最容易踩的坑------其中最典型的,就是信号异步执行带来的「变量可见性问题」,也就是
volatile关键字的由来。
6 核心特性深度与工程踩坑
6.1 不可靠信号 vs 可靠信号
| 特性 | 不可靠信号(标准信号,1~31) | 可靠信号(实时信号,32~64) |
|---|---|---|
| 底层存储 | 位图,只记录「有无」 | 链表,每个信号独立节点 |
| 排队能力 | 不排队,同信号多次到达只递送一次 | 支持排队,发送几次递送几次 |
| 丢失风险 | 阻塞期间多次产生会丢失 | 阻塞期间全部保留 |
| 附带数据 | 不支持 | 支持 siginfo_t,可传整数/指针 |
| 递送顺序 | 按编号从小到大,小信号优先 | 按入队顺序,先进先出 |
6.2 信号递送顺序与优先级
内核遍历未决信号时有明确的顺序规则:
- 标准信号优先于实时信号:先处理完所有标准信号,再处理实时信号。
- 标准信号按编号升序 :编号越小优先级越高,比如
SIGHUP(1)比SIGINT(2)先被处理。 - 实时信号按入队顺序:先发送的先处理,遵循 FIFO。
6.3 被中断的系统调用:EINTR 与 SA_RESTART
当进程阻塞在 read、wait 等慢系统调用上时收到信号,系统调用会被中断,返回 -1,errno = EINTR。
- 底层原因:系统调用阻塞时进程处于
TASK_INTERRUPTIBLE可中断睡眠,信号到来会唤醒进程,系统调用提前返回。 SA_RESTART实现:信号处理完后,内核把pt_regs里的rip往回退一条指令,回到syscall指令处,让进程重新执行系统调用。
工程最佳实践是自己封装重试逻辑,不依赖 SA_RESTART:
c
ssize_t safe_read(int fd, void *buf, size_t len) {
ssize_t n;
do {
n = read(fd, buf, len);
} while (n == -1 && errno == EINTR);
return n;
}
6.4 信号安全与可重入性
信号处理函数是异步插入执行的,如果处理函数里调用了不可重入函数(如 printf、malloc、strtok),可能导致数据错乱、死锁、崩溃。
- 信号安全铁律:只能调用 POSIX 规定的异步信号安全函数;共享变量必须用
volatile sig_atomic_t;最佳实践是只修改标志位,业务逻辑放在主循环处理。
6.5 深度延伸:从信号场景理解 C 语言 volatile 关键字
这是信号异步特性最典型的工程坑,也是 volatile 关键字最经典的适用场景。它的本质,是编译器优化和信号异步执行之间的冲突。
6.5.1 问题起源:编译器优化导致信号修改不可见
我们先看一段看似正确、但开 O2 优化后必然失效的信号代码:
c
int g_running = 1;
void handle_sigint(int sig) {
g_running = 0; // 信号处理函数中修改全局变量
}
int main() {
signal(SIGINT, handle_sigint);
while (g_running) {
// 主循环执行业务逻辑
}
return 0;
}
- 关闭优化(-O0):按下 Ctrl+C 后,
g_running被置 0,循环正常退出。 - 开启 O2 优化:程序永远卡住,按 Ctrl+C 毫无反应。
底层原因和信号的异步本质深度绑定 :
编译器做静态代码分析时,只能看到主执行流的代码路径,看不到由内核注入的、异步执行的信号处理函数路径 。因此编译器会认为:主循环里没有任何代码修改 g_running,这个变量是个常量。
于是编译器做了「寄存器缓存」优化:把 g_running 一次性加载到通用寄存器里,后续每次循环直接读寄存器,不再访问内存。
- 信号处理函数修改的,是内存里的
g_running。 - 主循环读的,是寄存器里缓存的旧值,永远看不到更新。
这就是 volatile 要解决的核心问题:告诉编译器,这个变量的值可能在当前执行流之外被异步修改,不要优化掉它的读写操作。而信号,就是最典型的「当前执行流之外的异步修改」。
6.5.2 volatile 的标准语义与编译原理
C 语言标准对 volatile 的定义是:对 volatile 限定的对象的访问,属于可观测的副作用,编译器不得优化掉对 volatile 对象的读写,也不得在两个 volatile 访问之间重排指令顺序。
对应三条明确规则:
- 读必取内存:每次读取 volatile 变量,必须从内存地址真实加载,不能复用寄存器缓存值。
- 写必回内存:每次写入 volatile 变量,必须立刻写回内存,不能暂存寄存器等待批量刷新。
- 编译期不重排 :编译器不能把 volatile 变量的读写操作互相穿插颠倒;但它只约束编译器,不约束 CPU 乱序执行 ------这是 C 语言
volatile和 Javavolatile最核心的区别。
汇编层面的直观对比:
- 不加 volatile(O2):
while(g_running)编译为「加载到 eax → 循环判断 eax」,循环体内不再访存。 - 加 volatile(O2):每次循环都会执行
mov eax, [g_running],真实从内存读取;信号修改内存后,下一次循环立刻可见。
6.5.3 信号场景下:volatile + sig_atomic_t 缺一不可
二者解决完全不同的问题,信号场景下必须同时使用,缺一不可。
| 关键字/类型 | 解决的核心问题 | 保障的特性 | 作用层面 |
|---|---|---|---|
sig_atomic_t |
保证变量读写是单指令原子的,不会读到写入一半的残缺值 | 原子性(数据完整性) | 数据宽度、指令级 |
volatile |
保证每次读写都访问真实内存,不被编译器优化,让异步修改对主执行流可见 | 可见性(编译期不缓存) | 编译器优化、内存访问 |
- 只有
sig_atomic_t没volatile:编译器优化后变量被缓存到寄存器,信号修改了内存也无效。 - 只有
volatile没sig_atomic_t:变量宽度超过机器字长时,读写会被拆成两条指令;信号插在中间会读到半新半旧的错误数据。
6.5.4 关于 volatile 的三个常见误区
-
误区1:volatile 能保证原子性
错误。volatile 只约束编译器优化,和原子性无关。大于机器字长的变量,即使加 volatile,读写依然是非原子的。
-
误区2:C 的 volatile 和 Java 一样,能禁止 CPU 重排、保证多核可见性
错误。Java 的
volatile会插入内存屏障,约束 CPU 行为;C 标准的volatile只约束编译器,完全不管 CPU 乱序和多核缓存。- 单核心单线程的信号场景下,没有 CPU 重排问题,volatile 足够用。
- 多线程多核心场景下,C 的
volatile不能替代内存屏障和原子操作。
-
误区3:volatile 变量读写没有竞态
错误。哪怕是
sig_atomic_t,也只能保证单次读写原子;「读-改-写」操作(如g_count++)依然存在竞态,需要额外同步。
6.6 竞态问题与 sigsuspend
经典竞态场景
「解除信号阻塞 + 挂起等待信号」如果分两步写,会存在窗口期:如果信号在解除阻塞后、pause 前到达,pause 就会永远等不到信号。
c
sigprocmask(SIG_UNBLOCK, &mask, NULL);
pause(); // 可能永远挂起
解决方案:sigsuspend
原子地完成「设置新阻塞掩码 + 挂起等待信号」,中间没有窗口期:
c
// 原子地解除阻塞并等待
while (sigsuspend(&oldmask) == -1 && errno == EINTR);
6.7 备用信号栈 sigaltstack
默认情况下,信号处理函数在用户栈上执行。如果栈溢出(比如无限递归)导致 SIGSEGV,此时用户栈已经不可用,信号处理函数也无法执行。
- 通过
sigaltstack()可以分配一块独立的内存作为备用信号栈。 - 配合
SA_ONSTACK标志,指定信号处理函数在备用栈上运行。 - 典型用途:栈溢出检测、极端场景下的错误日志输出。
6.8 多线程下的信号模型
- 每个线程有独立的阻塞掩码、私有未决集;整个进程共享处理方式、共享未决集。
- 发给进程的信号,内核会选择任意一个未阻塞该信号的线程递送。
- 多线程最佳实践:所有工作线程阻塞全部信号,单独开一个线程用
sigwait同步处理,避免异步处理的竞态。
7 系统调用接口与工程实战
7.1 信号集操作函数
sigset_t 是位图结构,必须通过专用函数操作:
c
int sigemptyset(sigset_t *set); // 清空
int sigfillset(sigset_t *set); // 全部置位
int sigaddset(sigset_t *set, int signum); // 添加信号
int sigdelset(sigset_t *set, int signum); // 删除信号
int sigismember(const sigset_t *set, int signum); // 判断是否存在
7.2 信号安装:sigaction(推荐)
功能完整、行为标准,是生产环境唯一选择:
c
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
7.3 所有 SA_* 标志详解
| 标志 | 作用 |
|---|---|
SA_RESTART |
被信号中断的慢系统调用自动重启 |
SA_SIGINFO |
使用三参数处理函数,携带完整 siginfo_t 信息 |
SA_NODEFER |
处理函数执行期间不自动屏蔽当前信号,允许信号嵌套 |
SA_RESETHAND |
信号处理一次后,自动重置为 SIG_DFL |
SA_ONSTACK |
在备用信号栈上执行处理函数 |
SA_NOCLDSTOP |
针对 SIGCHLD,子进程停止时不发信号,只有退出时才发 |
SA_NOCLDWAIT |
针对 SIGCHLD,子进程退出时内核自动回收,不产生僵尸 |
SA_INTERRUPT |
不自动重启系统调用,与 SA_RESTART 相反 |
7.4 经典工程场景
优雅回收子进程
c
void sigchld_handler(int sig) {
int saved_errno = errno;
while (waitpid(-1, NULL, WNOHANG) > 0); // 循环非阻塞回收
errno = saved_errno;
}
管道破裂处理
c
signal(SIGPIPE, SIG_IGN); // 忽略 SIGPIPE,通过 write 返回值判断断开
程序优雅退出
c
volatile sig_atomic_t g_running = 1;
void handle_quit(int sig) {
g_running = 0;
}
7.5 Core Dump 深度解析
Core Dump 是进程异常终止时,内核将进程完整内存镜像转储到磁盘的机制,用于事后调试。
- 触发信号:
SIGQUIT、SIGILL、SIGTRAP、SIGABRT、SIGFPE、SIGSEGV、SIGBUS等。 - 内核流程:检查
RLIMIT_CORE限制 → 根据core_pattern创建文件 → 写入 ELF 头与内存镜像 → 进程终止。 - 常用配置:
ulimit -c unlimited开启,修改/proc/sys/kernel/core_pattern设置命名规则。
7.6 常见信号默认行为速查
| 信号 | 编号 | 默认动作 | 触发场景 |
|---|---|---|---|
SIGHUP |
1 | 终止 | 终端断开、守护进程重载配置 |
SIGINT |
2 | 终止 | Ctrl+C |
SIGQUIT |
3 | 终止+Core | Ctrl+\ |
SIGILL |
4 | 终止+Core | 非法指令 |
SIGSEGV |
11 | 终止+Core | 段错误、非法内存访问 |
SIGPIPE |
13 | 终止 | 管道/套接字对端关闭 |
SIGALRM |
14 | 终止 | 定时器到期(时钟中断驱动) |
SIGCHLD |
17 | 忽略 | 子进程状态变化 |
SIGKILL |
9 | 终止 | 强制杀死,不可捕获/忽略 |
SIGSTOP |
19 | 停止 | 暂停进程,不可捕获/忽略 |
8 全文总结
Linux 信号机制是一条从硬件到用户态的完整闭环链路,每一环都有明确的底层支撑:
- 硬件层 :晶振提供基准时钟,可编程定时器分频产生周期性时钟中断;TSC 提供高精度计时但不产生中断;中断/异常/系统调用触发特权级切换,CPU 自动完成栈切换与关键上下文保存,内核补充
pt_regs完整快照,为信号递送提供了可修改的上下文支点。 - 调度与计时层:时钟中断驱动时间片轮转与进程调度,维护 jiffies 时间基准,检查定时器到期;调度检查与信号检查共享同一条返回路径,保证每次节拍都能触发信号递送。
- 产生层 :内核在处理各类事件(异常、时钟中断、软件事件、系统调用)的过程中生成信号,通过
__send_signal统一记录到未决集;忽略机制在这一层生效,直接丢弃信号。 - 存储层 :信号存储在
task_struct的信号字段中,位图存标准信号、链表存实时信号,阻塞掩码控制递送开关。 - 递送层 :信号严格在内核态返回用户态的边界递送,通过修改
pt_regs劫持返回地址,构造用户栈帧,透明地注入信号处理函数;sigreturn 完整恢复上下文。 - 工程层 :信号的异步执行特性带来了可重入、编译器优化可见性、竞态等问题,
volatile解决编译期可见性,sig_atomic_t保证读写原子性,sigsuspend解决竞态。
理解了这条完整链路,你就能从底层解释所有信号行为,写出安全、可靠的信号处理代码。