Linux (十五)信号机制深度解析:从硬件中断到用户态递送的完整链路

全文核心主线 :硬件中断/异常/系统调用 → 用户态陷入内核 → 内核处理事件并产生信号 → 信号记录到进程未决集 → 内核处理完毕准备返回用户态 → 检查未决信号并修改返回上下文 → 用户态先执行信号处理函数 → sigreturn 系统调用恢复现场 → 继续原执行流

所有知识点严格围绕这条链路展开,每一节对应链路中的一个环节,层层递进。本章在原有完整内容基础上,补充时间片调度机制、TSC 时间戳计数器、高精度定时器 hrtimer 三大块深度内容,所有硬件与调度细节最终都回扣到信号机制本身,不删减原有任何章节,保持全文完整度。

信号(Signal)是 Linux 最经典的异步通知机制。很多开发者只停留在 signal() 函数的表层使用,却不清楚三个最核心的问题:

  1. 为什么信号必须通过内核才能发送?
  2. 为什么进程在纯用户态死循环里,不做任何系统调用,也能收到信号?
  3. 为什么信号里修改的全局变量,必须加 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 主流系统使用两套高精度定时器组合:

  1. HPET(高精度事件定时器)
    • 由南桥芯片提供,是系统级的全局定时器,基准频率通常为 14.31818 MHz 或 100 MHz。
    • 提供多个独立的比较器通道,精度可达纳秒级,支持单次触发和周期触发,用于替代传统 PIT。
  2. 本地 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
  • 64 位系统下 jiffies 几乎不会溢出;32 位系统下 1000Hz 配置约 49 天溢出,内核通过 jiffies 回绕处理逻辑保证正确性。
时间片与进程调度:时钟中断的核心职责

时钟中断最核心的工作之一,就是维护进程的时间片,驱动调度器运转,这也是它和信号机制深度关联的另一个维度。

时间片的基本概念
  • 时间片(Time Slice)是调度器分配给每个进程运行的最长时间,单位通常是 jiffies。
  • 目标:让多个进程在单个 CPU 上「分时复用」,看起来像同时运行。时钟中断每来一次,就扣减当前进程的时间片;时间片耗尽后,就触发调度,让下一个进程运行。
时钟中断中的调度相关操作

每次时钟中断上半部,都会执行以下调度相关逻辑:

  1. 更新进程运行时间 :累加当前进程的 utime(用户态运行时间)和 stime(内核态运行时间),用于统计、计费和虚拟定时器判断。
  2. 扣减时间片:递减当前进程的剩余时间片计数。
  3. 时间片耗尽判断 :如果剩余时间片减到 0,就设置当前进程的 TIF_NEED_RESCHED 标志,请求调度。
  4. 调度时机 :时钟中断处理完毕、返回用户态前,会检查 TIF_NEED_RESCHED 标志,如果置位就调用 schedule() 切换进程。

这里有一个和信号强相关的关键点:调度检查和信号检查发生在同一个返回路径上

时钟中断返回用户态前,内核会依次检查 TIF_NEED_RESCHEDTIF_SIGPENDING。也就是说,每次时间片调度的检查点,同时也是信号递送的检查点。哪怕进程时间片没耗尽、不需要调度,也一定会走一遍信号检查流程,这就保证了信号递送的及时性。

CFS 调度器下的时间片

传统 O(1) 调度器使用固定时间片;现代 Linux 默认的 CFS(完全公平调度器)不再使用固定时间片,但依然依赖时钟中断:

  • CFS 用虚拟运行时间(vruntime)替代固定时间片,每个进程按权重分配 CPU 时间。
  • 时钟中断依然负责累加运行时间、更新 vruntime、检查是否需要调度。
  • 最小粒度(sched_min_granularity)保证每个进程至少运行一小段时间,避免频繁切换,这个粒度也是基于 HZ 计算的。
时钟中断的完整处理流程

时钟中断属于硬中断,处理流程分为上半部(硬中断上下文)下半部(软中断上下文),遵循「上半部快处理、下半部做重活」的中断设计原则:

  1. 硬件触发:定时器计数到期,通过本地 APIC 向当前 CPU 发送中断请求,CPU 自动完成栈切换、压入上下文,陷入内核态。
  2. 上半部处理(硬中断)
    • 应答中断控制器,关闭当前中断线。
    • 更新 jiffies 计数(全局或 per-cpu)。
    • 更新当前进程的运行时间统计(用户态时间、内核态时间)。
    • 扣减时间片 / 更新 vruntime,检查是否需要调度,设置 TIF_NEED_RESCHED 标志。
    • 标记时钟软中断(TIMER_SOFTIRQ),退出硬中断。
  3. 下半部处理(软中断)
    • 遍历内核定时器链表,检查所有定时器是否到期。
    • 对到期的定时器,执行回调函数;如果是 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),到期时触发一次性中断。
  • 应用场景:nanosleepclock_nanosleep、IO 超时、调度器高精度计时等。
  • 和信号的关系:alarm()SIGALRM 传统上基于低分辨率定时器;现代内核也提供 timer_create 接口,可以创建基于 hrtimer 的定时器,到期发送 SIGALRM 或其他信号,精度远高于传统 alarm。
1.3.3 与信号机制的深度绑定

时钟中断不是孤立的硬件机制,它从三个维度深度支撑了信号机制的正常工作:

1. 信号递送的周期性兜底入口

这是时钟中断对信号机制最核心的价值:

  • 如果一个进程处于纯用户态死循环,完全不调用系统调用、不触发异常、也没有其他外设中断,它本可以永远不陷入内核,永远没有机会处理信号。
  • 但时钟中断是强制性的周期性中断,无论进程在做什么,每隔一个节拍(几毫秒)就会强制打断用户态执行,陷入内核态。
  • 每次时钟中断处理完毕、返回用户态前,都会走标准的 exit_to_user_mode_loop 检查流程,检查 TIF_SIGPENDING 标志。
  • 这意味着:只要信号被标记为未决,最晚经过一个节拍的时间,就一定会被递送给进程。这就是死循环里也能收到信号的底层原因。
2. 所有定时器类信号的硬件源头

SIGALRMSIGVTALRMSIGPROF 等定时器类信号,底层完全依赖时钟中断:

  • 用户调用 alarm() / setitimer() 时,内核会创建一个内核定时器节点,挂入全局定时器链表,设置到期时间(以 jiffies 为单位)。
  • 每次时钟中断的下半部,都会扫描定时器链表,检查是否到期。
  • 定时器到期后,内核调用 send_sig() 向进程发送对应信号,并根据配置重置或销毁定时器。
  • 这也决定了传统定时器信号的精度上限就是时钟节拍:1000Hz 配置下精度最高 1ms,250Hz 下是 4ms,不可能做到微秒级精确。
  • 基于 hrtimer 的高精度定时器,精度可以达到微秒级,但本质依然是靠定时器到期触发中断、再产生信号。
3. 可中断睡眠与信号唤醒

进程调用 sleepread 等阻塞函数时,会进入 TASK_INTERRUPTIBLE(可中断睡眠)状态。这种状态下的进程,既可以被信号唤醒,也可以被资源就绪唤醒。

  • 信号到来时,内核会唤醒睡眠的进程,让它进入就绪态,等待调度器调度运行。
  • 进程被调度运行、从内核态返回用户态前,就会走信号递送流程,处理信号。
  • 时钟中断负责调度器的运转,保证被唤醒的进程能及时得到运行机会,进而及时处理信号。

1.4 硬件自动完成的栈切换与上下文压栈

从用户态(Ring 3)切到内核态(Ring 0)时,CPU 硬件会原子性地完成以下操作,全程由硬件保证安全:

  1. 栈切换 :从 TSS(任务状态段)中读取当前 CPU 的内核栈地址(rsp0),把用户栈的 SSRSP 寄存器压入内核栈保存,然后将 RSP 切换为内核栈地址。 用户态和内核态使用完全独立的栈,这是特权隔离的重要保障。
  2. 压入关键上下文 :按固定顺序把 RFLAGS(标志寄存器)、CS + RIP(代码段+指令指针,即被中断的下一条指令地址)压入内核栈。 这一步保存了「程序从哪来」,是后续能原路返回的基础。
  3. 压入错误码(可选):缺页、通用保护等异常会额外压入错误码,说明异常原因。
  4. 查表跳转:根据中断向量号查 IDT(中断描述符表),跳转到内核对应的处理函数入口。

1.5 软件补充:完整上下文 struct pt_regs

硬件只压了少量关键寄存器,但内核处理过程中会修改通用寄存器。因此进入内核处理函数前,汇编代码会把剩下的所有通用寄存器(raxrbxrcxrdxrsirdirbpr8~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 里的 riprsp,让进程返回用户态时「走错路」,先去执行信号处理函数。

承上启下:理解了 pt_regs 和内核态切换,我们就能回答最初的问题------为什么信号不能随时打断用户代码?

内核没有办法在用户态代码执行中途,强行修改用户寄存器、插入一段代码。只有当进程陷入内核、完整上下文保存在内核栈的 pt_regs 里时,内核才能安全地修改返回上下文。这也是信号递送永远发生在「内核态→用户态」边界的根本原因。

1.6 返回用户态:信号递送的唯一合法时机

内核处理完系统调用、异常或中断后,最终会走到返回用户态的出口 exit_to_user_mode_loop。在真正执行 iretq 返回前,内核会循环检查线程标志位,直到全部清零才返回:

  1. TIF_SIGPENDING :有未决信号需要处理 → 调用 do_signal()
  2. TIF_NEED_RESCHED :需要调度 → 调用 schedule() 切换进程
  3. 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

以缺页异常为例,完整的异常→信号转换流程

  1. 进程访问一个无效虚拟地址,CPU 触发 14 号缺页异常,自动陷入内核,切换栈、压入上下文,形成 pt_regs
  2. 内核 do_page_fault 被调用,读取 CR2 寄存器拿到触发异常的虚拟地址。
  3. 查找进程的 VMA(虚拟内存区域),判断地址合法性:
    • 合法但未分配物理页:分配物理页、建立页表映射,返回用户态重试指令,进程无感知。
    • 非法地址/权限不匹配:判定为用户态非法访问。
  4. 调用 force_sig(SIGSEGV) 向当前进程发送段错误信号,把信号标记到进程的未决集里。
  5. 异常处理结束,走到返回用户态路径,检查到 TIF_SIGPENDING,进入 do_signal() 递送信号。

2.2 时钟中断与定时器信号

时钟中断是所有定时器类信号的硬件源头,alarm()setitimer()timer_create() 等定时器,本质都是基于时钟中断的软件计时。

三类定时器信号

每个进程的 task_struct 中维护着三类独立的传统定时器,都由时钟中断驱动检查:

  1. 真实定时器(ITIMER_REAL) :统计墙上真实时间,无论进程在用户态还是内核态、是否睡眠都计时,到期发送 SIGALRM
  2. 虚拟定时器(ITIMER_VIRTUAL) :只统计进程在用户态运行的时间,到期发送 SIGVTALRM
  3. 概况定时器(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() 函数最开头。

  • 内核伪代码逻辑

    c 复制代码
    if (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(),执行步骤如下:

  1. 参数校验,检查信号号合法性、目标进程权限。
  2. 持有 sighand->siglock 自旋锁,防止并发修改处理方式。
  3. 忽略检查 :如果目标信号处理方式为 SIG_IGN 且非强制信号,直接释放锁返回,不做任何记录。
  4. 选择队列:判断是线程信号还是进程组信号,选择私有 pending 或共享 shared_pending
  5. 去重判断:如果是标准信号且对应位图已经置位,直接丢弃新信号(不排队)。
  6. 分配节点:实时信号或首次产生的标准信号,分配 sigqueue 节点,填充 siginfo_t 信息,加入未决链表。
  7. 标记未决:设置未决位图对应位为 1。
  8. 唤醒进程:设置目标进程的 TIF_SIGPENDING 标志位;如果目标进程正处于可中断睡眠状态,则唤醒它。
  9. 释放锁,返回成功。

5.2 阶段二:未决(Pending)

信号产生后、递送前的状态,保存在未决集中。

  • 未被阻塞的信号:等待下一次内核态返回用户态时被递送。
  • 被阻塞的信号:一直停留在未决集,直到解除阻塞。
  • 标准信号:同信号多次产生只保留一个,会丢失。
  • 实时信号:按顺序排队,全部保留。

5.3 阶段三:递送(Delivery)

递送发生在每次内核态返回用户态之前 ,由 do_signal() 函数执行,输入就是保存在内核栈上的 struct pt_regs

5.3.1 查找可递送信号
  1. 检查 TIF_SIGPENDING 标志,若无未决信号则直接返回用户态。
  2. 先查私有未决集,再查共享未决集,从低到高扫描位图,找到第一个不在 blocked 阻塞集中的信号。
  3. 清除对应未决位,取出 siginfo_t 信息,准备处理。
5.3.2 三种处理分支

找到信号后,根据处理方式进入不同分支:

  1. 默认动作(SIG_DFL:根据信号类型执行终止、Core Dump、忽略、停止、继续等默认行为。
  2. 忽略(SIG_IGN:直接清除未决标志,继续查找下一个信号。
  3. 自定义处理函数 :最复杂的分支,通过修改 pt_regs 劫持返回路径。
5.3.3 自定义处理:劫持返回上下文

这是信号机制最精妙的部分。内核不会在内核态执行用户提供的处理函数,而是修改保存在内核栈上的用户态上下文,让进程返回用户态时「自动」先去执行处理函数。

完整步骤:

  1. 选择栈 :默认使用用户栈;设置了 SA_ONSTACK 则用备用信号栈。
  2. 构造信号帧 :在用户栈上压入 rt_sigframe 结构,包含:
    • 原始 pt_regs 的完整副本(被中断时的全部寄存器状态)
    • 信号编号、siginfo_t 详细信息
    • 阻塞掩码快照
    • 返回地址:指向 __restore_rt stub(sigreturn 的入口)
  3. 修改 pt_regs :修改内核栈上保存的用户态上下文:
    • rip(指令指针)设为信号处理函数的入口地址
    • rsp(栈指针)设为压入信号帧后的新栈顶
    • 阻塞掩码更新为 blocked | sa_mask | 当前信号(处理期间自动屏蔽)
  4. 返回用户态iretq 返回用户态。此时 pt_regs 已经被改写,CPU 不会回到原来被中断的地方,而是直接开始执行信号处理函数。

划重点:信号不是「打断」了用户程序,而是内核在返回用户态前,偷偷改了返回地址,让程序先去跑信号处理函数。整个过程对用户程序完全透明。

5.4 阶段四:用户态处理与 sigreturn 恢复

  1. 执行处理函数:返回用户态后,CPU 直接执行信号处理函数的代码,这部分运行在用户态 Ring 3 特权级。
  2. 函数返回 :处理函数执行完 ret 指令,根据栈上的返回地址,跳转到 __restore_rt stub。
  3. 触发 sigreturn__restore_rt 执行 syscall 触发 rt_sigreturn 系统调用,再次陷入内核态。
  4. 恢复上下文 :内核中 sys_rt_sigreturn 从用户栈的信号帧里读取原始 pt_regs,验证合法性后,把内核栈上的返回上下文还原成被中断时的状态。
  5. 继续原执行流 :再次返回用户态时,rip 已经恢复成原来的指令地址,进程从当初被中断的位置继续执行,就像什么都没发生过一样。

承上启下:完整链路已经走通了。接下来我们基于这条链路,深入讲解几个核心特性的底层原理,以及工程中最容易踩的坑------其中最典型的,就是信号异步执行带来的「变量可见性问题」,也就是 volatile 关键字的由来。


6 核心特性深度与工程踩坑

6.1 不可靠信号 vs 可靠信号

特性 不可靠信号(标准信号,1~31) 可靠信号(实时信号,32~64)
底层存储 位图,只记录「有无」 链表,每个信号独立节点
排队能力 不排队,同信号多次到达只递送一次 支持排队,发送几次递送几次
丢失风险 阻塞期间多次产生会丢失 阻塞期间全部保留
附带数据 不支持 支持 siginfo_t,可传整数/指针
递送顺序 按编号从小到大,小信号优先 按入队顺序,先进先出

6.2 信号递送顺序与优先级

内核遍历未决信号时有明确的顺序规则:

  1. 标准信号优先于实时信号:先处理完所有标准信号,再处理实时信号。
  2. 标准信号按编号升序 :编号越小优先级越高,比如 SIGHUP(1)SIGINT(2) 先被处理。
  3. 实时信号按入队顺序:先发送的先处理,遵循 FIFO。

6.3 被中断的系统调用:EINTRSA_RESTART

当进程阻塞在 readwait 等慢系统调用上时收到信号,系统调用会被中断,返回 -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 信号安全与可重入性

信号处理函数是异步插入执行的,如果处理函数里调用了不可重入函数(如 printfmallocstrtok),可能导致数据错乱、死锁、崩溃。

  • 信号安全铁律:只能调用 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 访问之间重排指令顺序

对应三条明确规则:

  1. 读必取内存:每次读取 volatile 变量,必须从内存地址真实加载,不能复用寄存器缓存值。
  2. 写必回内存:每次写入 volatile 变量,必须立刻写回内存,不能暂存寄存器等待批量刷新。
  3. 编译期不重排 :编译器不能把 volatile 变量的读写操作互相穿插颠倒;但它只约束编译器,不约束 CPU 乱序执行 ------这是 C 语言 volatile 和 Java volatile 最核心的区别。

汇编层面的直观对比

  • 不加 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_tvolatile:编译器优化后变量被缓存到寄存器,信号修改了内存也无效。
  • 只有 volatilesig_atomic_t:变量宽度超过机器字长时,读写会被拆成两条指令;信号插在中间会读到半新半旧的错误数据。
6.5.4 关于 volatile 的三个常见误区
  1. 误区1:volatile 能保证原子性

    错误。volatile 只约束编译器优化,和原子性无关。大于机器字长的变量,即使加 volatile,读写依然是非原子的。

  2. 误区2:C 的 volatile 和 Java 一样,能禁止 CPU 重排、保证多核可见性

    错误。Java 的 volatile 会插入内存屏障,约束 CPU 行为;C 标准的 volatile 只约束编译器,完全不管 CPU 乱序和多核缓存。

    • 单核心单线程的信号场景下,没有 CPU 重排问题,volatile 足够用。
    • 多线程多核心场景下,C 的 volatile 不能替代内存屏障和原子操作。
  3. 误区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 是进程异常终止时,内核将进程完整内存镜像转储到磁盘的机制,用于事后调试。

  • 触发信号:SIGQUITSIGILLSIGTRAPSIGABRTSIGFPESIGSEGVSIGBUS 等。
  • 内核流程:检查 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 信号机制是一条从硬件到用户态的完整闭环链路,每一环都有明确的底层支撑:

  1. 硬件层 :晶振提供基准时钟,可编程定时器分频产生周期性时钟中断;TSC 提供高精度计时但不产生中断;中断/异常/系统调用触发特权级切换,CPU 自动完成栈切换与关键上下文保存,内核补充 pt_regs 完整快照,为信号递送提供了可修改的上下文支点。
  2. 调度与计时层:时钟中断驱动时间片轮转与进程调度,维护 jiffies 时间基准,检查定时器到期;调度检查与信号检查共享同一条返回路径,保证每次节拍都能触发信号递送。
  3. 产生层 :内核在处理各类事件(异常、时钟中断、软件事件、系统调用)的过程中生成信号,通过 __send_signal 统一记录到未决集;忽略机制在这一层生效,直接丢弃信号。
  4. 存储层 :信号存储在 task_struct 的信号字段中,位图存标准信号、链表存实时信号,阻塞掩码控制递送开关。
  5. 递送层 :信号严格在内核态返回用户态的边界递送,通过修改 pt_regs 劫持返回地址,构造用户栈帧,透明地注入信号处理函数;sigreturn 完整恢复上下文。
  6. 工程层 :信号的异步执行特性带来了可重入、编译器优化可见性、竞态等问题,volatile 解决编译期可见性,sig_atomic_t 保证读写原子性,sigsuspend 解决竞态。

理解了这条完整链路,你就能从底层解释所有信号行为,写出安全、可靠的信号处理代码。