哈喽,编程搭子们!😜 又到了沉浸式敲代码的快乐时间~把生活调成「代码模式」,带着满满的热爱钻进编程的奇妙世界——今天也要敲出超酷的代码,冲鸭!🚀

在这里插入图片描述

✨ 我的博客主页:喜欢吃燃面
📚 我的专栏(持续更新ing):
《C语言》 |
《C语言之数据结构》 |
《C++》 |
《Linux学习笔记》

💖 超感谢你点开这篇博客!真心希望这些内容能帮到正在打怪升级的你~如果有任何想法、疑问,或者想交流学习心得,都欢迎留言/私信,咱们一起在编程路上互相陪伴、共同进步呀!

引言:中断与信号的内在联系

操作系统内核中,中断与信号是核心异步通知机制:中断是硬件/软件向CPU的异步通知,触发中断处理程序;信号是内核向进程的异步通知,本质是中断在用户空间的延伸与抽象,分别处理硬件与进程层面的异步事件。

理解信号保存机制需先明确中断本质:各类中断触发内核态处理程序,可能需通知用户进程,但进程执行上下文(用户态、系统调用等)与中断上下文不匹配,导致信号无法同步执行,必须保存等待合适时机交付。本文将从中断视角,剖析Linux内核中中断事件转进程未决信号的保存机制、数据结构及状态转换。

一、中断的分类与信号产生的源头

1.硬件中断与异常的区分

在 x86 架构中,中断和异常通过中断描述符表(IDT)统一处理,但从产生源头和语义上可以分为以下几类:

外部硬件中断(IRQ)
由外部设备通过中断控制器(如 APIC、8259A PIC)向 CPU 发送。这类中断是异步的,与 CPU 当前执行的指令流无关。常见的外部中断包括:

  • 时钟中断(Timer IRQ):由可编程间隔定时器(PIT)或本地 APIC 定时器产生,用于调度、定时器到期通知
  • 键盘中断(Keyboard IRQ):用户按下 Ctrl+C 时,键盘控制器产生中断,驱动程序将其转换为 SIGINT
  • 磁盘中断(Disk IRQ):I/O 完成时产生的中断
  • 网络中断(Network IRQ):数据包到达时网卡产生的中断

异常(Exception/Trap)
由 CPU 在执行指令过程中检测到错误或特殊条件而产生。异常是同步的,与当前指令流直接相关:

  • 故障(Fault):如页错误(Page Fault)、段错误(Segmentation Fault),通常可以恢复
  • 陷阱(Trap):如调试断点(INT 3)、系统调用(INT 0x80/SYSCALL),是有意产生的
  • 中止(Abort):如机器检查异常(Machine Check),严重错误无法恢复

软件中断(Software Interrupt)
通过 INT 指令或特殊指令(如 SYSCALL/SYSENTER)产生,用于实现系统调用。

中断/异常源分类与信号映射

软件中断/系统调用

CPU异常 (Exception)

外部硬件中断 (IRQ)

时钟中断 (IRQ0)

更新jiffies
检查定时器到期
发送SIGALRM/SIGVTALRM

键盘中断 (IRQ1)

读取扫描码
检测Ctrl+C
发送SIGINT到前台进程组

磁盘中断 (IRQ14/15)

I/O完成唤醒
可能发送SIGIO

网卡中断

数据包到达
可能发送SIGURG

除零错误 (DE)

force_sig(SIGFPE)

页错误 (PF)

处理缺页或
force_sig(SIGSEGV)

非法指令 (UD)

force_sig(SIGILL)

断点 (BP)

SIGTRAP (调试器)

kill()系统调用

sys_kill()
直接设置pending

sigqueue()

sys_rt_sigqueueinfo()
分配sigqueue

alarm()/setitimer()

设置内核定时器
到期时发送信号

信号保存到pending队列

2. 中断上下文 vs 进程上下文

中断处理程序运行在一个特殊的**中断上下文(Interrupt Context)**中,这与进程上下文有本质区别:

中断上下文的特点

  • 不可睡眠:中断上下文没有对应的进程描述符,不能调用可能阻塞的函数(如 kmalloc(GFP_KERNEL) 可能睡眠,必须使用 GFP_ATOMIC
  • 无用户空间映射:中断上下文不属于任何特定进程,无法直接访问用户空间内存
  • 执行时间受限:中断处理应当快速完成,复杂工作通常推迟到软中断(Softirq)或任务队列(Workqueue)
  • 中断嵌套:硬件中断可能被更高优先级的中断嵌套

进程上下文的特点

  • 可睡眠:进程可以主动放弃 CPU,进入睡眠状态等待资源
  • 完整的地址空间:拥有独立的用户空间和内核空间映射
  • 信号处理在用户态:信号的处理函数(Signal Handler)必须在用户态执行

这种上下文的割裂,是信号必须被保存的根本原因。当中断处理程序决定向某个进程发送信号时,它不能立即调用用户态的处理函数,而只能将信号标记为"未决"(Pending),等待进程从内核态返回用户态时再进行交付。

用户进程 进程控制块 内核中断处理 CPU/IDT 硬件设备 用户进程 进程控制块 内核中断处理 CPU/IDT 硬件设备 运行在中断上下文 不可睡眠/无进程关联 alt [Ctrl+C 检测] [普通按键] 系统调用返回/中断返回时 检查TIF_SIGPENDING 产生中断信号 (如IRQ1) 保存当前上下文 (寄存器/EFLAGS/CS:EIP) 通过IDT跳转到中断处理程序 读取设备状态 (键盘扫描码) 确定前台进程组 kill_pgrp() ->> __send_signal() 设置 shared_pending.signal 位图 标记 TIF_SIGPENDING signal_wake_up() (唤醒或标记) 放入tty缓冲区 发送EOI (End of Interrupt) 恢复之前上下文 (iret) 返回用户态继续执行 发现未决信号 调用do_signal() 交付信号 执行用户态handler

二、中断处理到信号保存的内核路径

1.时钟中断与定时器信号

时钟中断是操作系统的心跳,也是信号保存机制最频繁的触发源之一。以 x86 架构为例,时钟中断的处理流程如下:

硬件层面

  • PIT(可编程间隔定时器)或 HPET(高精度事件定时器)按设定频率(通常为 1000Hz)产生中断
  • 中断通过 IO APIC 路由到某个 CPU 核心
  • CPU 收到中断后,通过 IDT 跳转到 timer_interrupt 处理函数

内核处理

// 简化的时钟中断处理路径
static irqreturn_t timer_interrupt(int irq, void *dev_id)
{
    // 1. 更新全局时间 (jiffies)
    tick_periodic();
    
    // 2. 检查并更新进程时间片 (调度相关)
    update_process_times();
    
    // 3. 检查高精度定时器 (hrtimers)
    hrtimer_interrupt();
    
    return IRQ_HANDLED;
}

void update_process_times(void)
{
    // 更新当前进程的用户态/内核态 CPU 时间
    account_process_tick();
    
    // 触发 TIMER_SOFTIRQ 处理其他定时器
    run_local_timers();
    
    // 调度器检查是否需要重新调度
    scheduler_tick();
}

定时器到期与信号发送
run_local_timers() 执行时,会检查到期的内核定时器。如果某个定时器是为进程设置的(如通过 alarm()setitimer()),定时器回调函数会调用 send_sig_info()send_group_sig_info() 将信号保存到目标进程的 pending 队列:

时钟中断到信号保存的完整路径

ITIMER_REAL

ITIMER_VIRTUAL

ITIMER_PROF

PIT/APIC 产生中断

CPU 通过 IDT 进入

timer_interrupt()

tick_periodic()

update_process_times()

run_local_timers()

检查到期 hrtimers

定时器类型?

alarm_timer_cb()

更新进程时间
不直接发信号

更新统计时间

send_sig_info(SIGALRM)

__send_signal()

设置 task->pending.signal

设置 TIF_SIGPENDING

signal_wake_up()

2. 键盘中断与终端信号

键盘中断是用户与系统交互最直接的信号来源。当用户按下 Ctrl+C 时:

硬件层面

  • 键盘控制器(8042 芯片或 USB HID)检测到按键事件
  • 通过中断线向 CPU 发送 IRQ1
  • CPU 进入键盘中断处理程序

内核处理

// 简化的键盘中断处理路径
static irqreturn_t keyboard_interrupt(int irq, void *dev_id)
{
    unsigned char scancode;
    
    // 读取键盘扫描码
    scancode = inb(KBD_DATA_REG);
    
    // 转换为键值
    handle_scancode(scancode);
    
    return IRQ_HANDLED;
}

void handle_scancode(unsigned char scancode)
{
    // 解析特殊组合键
    if (ctrl_pressed && c_key_pressed) {
        // Ctrl+C -> SIGINT
        // 获取当前终端的前台进程组
        struct pid *pid = tty->pgrp;
        // 向整个进程组发送信号
        kill_pgrp(pid, SIGINT, 1);
    }
    // ... 其他按键处理
}

信号保存
kill_pgrp() 最终会调用 __send_signal(),将 SIGINT 保存到进程组中每个进程的 shared_pending 队列。由于这是从中断上下文发出的信号,必须使用 GFP_ATOMIC 分配内存(如果需要分配 sigqueue 结构):

信号保存 (原子操作)

信号分发 (仍在中断上下文)

键盘中断上下文

IRQ1 触发

inb(0x60) 读扫描码

Ctrl+C ?

tty->pgrp 获取前台进程组

放入输入缓冲区

kill_pgrp(pid, SIGINT)

遍历进程组每个进程

__send_signal(SIGINT)

标准信号?

set_bit(pending.signal)

GFP_ATOMIC 分配 sigqueue

list_add_tail(pending.list)

设置 TIF_SIGPENDING

signal_wake_up()

3. 异常处理与同步信号

与硬件中断不同,异常是同步的,与当前执行的指令直接相关。异常处理程序虽然也通过 IDT 进入,但它在进程上下文中执行(因为异常是由当前进程的指令触发的)。

除零异常的处理

force_sig_info() do_divide_error() 中断描述符表 CPU执行单元 用户进程 force_sig_info() do_divide_error() 中断描述符表 CPU执行单元 用户进程 在进程上下文中执行 current 指向当前进程 返回用户态前检查 TIF_SIGPENDING 执行 DIV 指令,除数为0 检测到除零错误 查询向量0 (DE - Divide Error) 调用 do_divide_error() 准备 siginfo_t (si_code = FPE_INTDIV) force_sig_info(SIGFPE, &info, current) __send_signal(SIGFPE) 设置 current->>pending.signal iret 返回用户态 do_signal() 处理 SIGFPE 执行用户态 handler 或默认动作 (Core Dump)

force_sig_info() 的特殊性
对于某些致命异常(如 SIGSEGV、SIGILL),内核使用 force_sig_info() 而非普通的 send_sig_info()。这是因为:

  1. 进程可能阻塞或忽略了这些信号,但异常导致的信号不可被忽略
  2. force_sig_info() 会强制解除对这些信号的阻塞,并覆盖忽略设置
  3. 如果进程没有注册处理函数,默认动作通常是终止进程并生成 Core Dump
int force_sig_info(int sig, struct kernel_siginfo *info, struct task_struct *t)
{
    // 强制解除信号阻塞
    sigdelset(&t->blocked, sig);
    // 递归解除:如果信号在处理期间被阻塞,也解除
    sigdelset(&t->real_blocked, sig);
    
    // 发送信号
    return send_sig_info(sig, info, t);
}

三、中断上下文中的信号保存限制

1. 为什么中断上下文不能直接处理信号

中断上下文不能直接交付信号给用户进程,存在多重限制:

中断上下文不能直接交付信号的原因

地址空间隔离

中断不属于特定进程
无法确定用户态地址空间
无法访问用户栈

执行状态不确定

进程可能在系统调用中
可能持有锁
可能处于原子操作

睡眠限制

中断上下文不可睡眠
信号处理可能需要分配内存
用户栈扩展可能触发缺页

递归风险

信号处理可能触发新中断
可能导致无限递归
栈溢出风险

调度限制

中断期间调度被延迟
信号处理涉及上下文切换
必须在安全点进行

解决方案: 延迟交付

保存到 pending 队列

设置 TIF_SIGPENDING 标志

在返回用户态时检查

安全交付信号

2. GFP_ATOMIC 与内存分配

从中断上下文保存信号时,如果需要分配 sigqueue 结构(实时信号),必须使用 GFP_ATOMIC 标志:

内存分配策略对比

中断上下文 (GFP_ATOMIC)

进程上下文 (GFP_KERNEL)

可以睡眠等待内存

允许内存回收/交换

可能触发I/O操作

分配成功率较高

不可睡眠,立即返回

仅从空闲内存分配

不触发任何I/O

可能失败 (返回NULL)

kmalloc(size, GFP_KERNEL)

kmalloc(size, GFP_ATOMIC)

返回内存指针

分配成功?

返回内存指针

信号丢失/返回错误

// 实时信号队列节点的原子分配
static struct sigqueue *__sigqueue_alloc(int sig, struct task_struct *t, gfp_t flags)
{
    struct sigqueue *q;
    
    // 从中断上下文调用时,flags 必须为 GFP_ATOMIC
    q = kmem_cache_alloc(sigqueue_cachep, flags);
    if (q) {
        // 初始化并关联到发送者用户(用于资源限制计数)
        q->user = get_uid(__task_cred(t)->user);
        // 增加用户信号计数
        atomic_inc(&q->user->sigpending);
    }
    return q;
}

3. 软中断与信号保存的协作

由于硬中断处理应当快速完成,复杂的信号处理工作有时会推迟到软中断(Softirq)或任务队列(Workqueue)中执行:

Linux 中断分层处理模型

硬件中断发生

硬中断处理 (Top Half)

快速关键操作

需要更多工作?

标记软中断/任务

直接结束

软中断 (Softirq)

任务队列 (Workqueue)

线程化 IRQ

TIMER_SOFTIRQ
处理定时器到期

工作线程上下文
可睡眠,可发信号

专用内核线程
处理中断后续

检查到期信号定时器

发送信号到进程

保存到 pending 队列

例如,高精度定时器(hrtimer)的回调函数可以直接在中断上下文执行,但某些复杂的定时器处理(如 POSIX 定时器)可能通过 TIMER_SOFTIRQ 或工作队列延迟执行,在这些上下文中发送信号时可以使用 GFP_KERNEL 进行内存分配。

四、从中断返回到信号交付的状态转换

1. 中断返回路径与信号检查

当中断处理程序完成时,CPU 通过 iret 指令返回之前的执行上下文。在 Linux 内核中,这一返回路径是信号交付的关键检查点:

硬件中断/异常发生

如有标记软中断

local_bh_enable()

无软中断

检查 TIF_SIGPENDING

有未决信号且未被阻塞

无信号或信号被阻塞

有自定义handler

无handler (SIG_DFL)

信号被忽略 (SIG_IGN)

SIGKILL/SIGSEGV等

默认动作为Core Dump

SIGSTOP/SIGTSTP等

SIGCONT

handler返回后
sigreturn()

仍有未决信号

所有信号处理完毕

进入TASK_STOPPED

返回用户态继续执行

被SIGCONT唤醒时

中断处理

软中断处理

准备返回

检查信号

信号处理

用户态handler

默认动作

忽略信号

终止进程

生成Core

暂停进程

继续进程

检查更多信号

恢复执行

等待唤醒

2. 系统调用返回时的信号检查

系统调用是用户空间进入内核空间的主要途径,也是信号交付最常见的时机:

用户态handler do_signal() 系统调用返回 (exit_to_usermode_loop) 具体系统调用 系统调用入口 (entry_SYSCALL_64) 用户态代码 用户态handler do_signal() 系统调用返回 (exit_to_usermode_loop) 具体系统调用 系统调用入口 (entry_SYSCALL_64) 用户态代码 执行内核逻辑 可能设置 TIF_SIGPENDING 在用户栈构建 sigframe 保存 pt_regs 和 old_blocked 从 sigframe 恢复上下文 恢复 old_blocked 清除已处理信号 alt [有未决信号] [无未决信号] loop [信号处理循环] syscall 指令 保存用户态上下文 (pt_regs) 调用 sys_xxx() 准备返回用户态 检查 TIF_SIGPENDING 如有信号,调用 do_signal() get_signal() 获取信号 handle_signal() 修改返回地址为 handler iret 到用户态 handler 执行用户信号处理函数 sigreturn() 系统调用 直接返回 恢复用户态上下文 (iret)

3. 可中断睡眠与信号唤醒

进程在等待资源时可能进入可中断睡眠状态(TASK_INTERRUPTIBLE)。如果此时中断处理程序向该进程发送信号,内核需要唤醒进程并保存信号:

唤醒后的信号处理

信号到达 (中断上下文)

进程睡眠状态

TASK_INTERRUPTIBLE

TASK_UNINTERRUPTIBLE

TASK_RUNNING

TASK_RUNNING

调用 schedule()

等待资源?

设置状态为 TASK_INTERRUPTIBLE

加入等待队列

调用 schedule() 放弃CPU

中断处理程序

send_signal()

设置 TIF_SIGPENDING

signal_wake_up()

进程状态?

try_to_wake_up()

不唤醒 (信号延迟)

设置 NEED_RESCHED

设置状态为 TASK_RUNNING

加入运行队列

系统调用返回时

检查 TIF_SIGPENDING

返回 -EINTR 或处理信号

下次调度时处理

// 信号唤醒机制
void signal_wake_up(struct task_struct *t, bool resume)
{
    // 设置线程标志,表示有待处理信号
    set_tsk_thread_flag(t, TIF_SIGPENDING);
    
    // 如果进程在可中断睡眠中,唤醒它
    if (resume || task_is_stopped_or_traced(t))
        wake_up_state(t, TASK_WAKEKILL | TASK_INTERRUPTIBLE);
}

五、中断嵌套与信号保存的并发安全

1. 中断嵌套与自旋锁保护

在多核系统和中断嵌套场景下,信号保存操作必须是原子的。内核使用自旋锁(Spinlock)保护 pending 队列:

自旋锁保护 (sighand->siglock)

并发访问场景

中断安全考虑

硬中断中: 本地中断已禁用

软中断中: 可能需禁用本地中断

进程上下文: 直接获取锁

CPU0: 时钟中断

发送 SIGALRM

CPU1: 键盘中断

发送 SIGINT

CPU0: 当前进程

sigprocmask() 修改 blocked

获取 siglock

操作 pending 队列

释放 siglock

// __send_signal 中的锁保护
static int __send_signal(int sig, struct kernel_siginfo *info,
                         struct task_struct *t, enum pid_type type, bool force)
{
    struct sigpending *pending;
    struct sigqueue *q;
    int override_rlimit;
    
    // 获取信号处理锁
    spin_lock(&t->sighand->siglock);
    
    // ... 信号保存逻辑 ...
    
    // 释放锁
    spin_unlock(&t->sighand->siglock);
    
    // 唤醒进程(在锁外执行,避免死锁)
    signal_wake_up(t, type == PIDTYPE_PID);
    
    return 0;
}

2. 本地中断禁用与内存屏障

在中断处理程序中,某些操作需要禁用本地中断以防止嵌套中断导致的竞态条件:

内存屏障保证可见性

本地中断禁用临界区

local_irq_disable()

操作 per-CPU 变量

local_irq_enable()

smp_wmb()

写操作排序

smp_rmb()

读操作排序

smp_mb()

全内存屏障

六、特殊中断场景下的信号保存

1. NMI(不可屏蔽中断)与信号

NMI 是最高优先级的中断,通常用于处理严重硬件错误(如内存校验错误)。NMI 处理程序运行在一个极其受限的上下文中:

NMI 处理流程

检测到 MCE

nmi_handle()

记录错误日志

是否致命?

设置 panic 标志

唤醒 mcelog 守护进程

后续 panic 发送信号

用户态进程处理

NMI 特性

不可屏蔽

不能被 IF 位禁用

最高优先级

抢占任何其他中断

极简处理

通常仅记录错误

无信号发送

避免复杂操作

NMI 处理程序通常不会直接发送信号,而是设置标志,由后续的常规中断或进程上下文处理。

2. 中断线程化与信号

Linux 支持中断线程化(Threaded IRQ),将中断处理程序作为内核线程运行:

线程化中断 (Threaded IRQ)

硬中断上下文

极简处理: 唤醒线程

中断内核线程

完整中断处理

可直接发送信号

使用 GFP_KERNEL 分配

传统中断处理

硬中断上下文

快速处理

软中断/任务队列

延迟处理复杂工作

线程化中断的优势在于处理程序运行在内核线程上下文,可以睡眠,可以使用 GFP_KERNEL 分配内存,从而降低了信号保存失败的风险。

结语

从中断视角看Linux信号保存机制,可见操作系统分层设计:硬件中断经内核IDT和处理程序转为内核态事件,再通过信号抽象为进程级异步通知。因中断上下文(不可睡眠、无地址空间)与进程上下文差异,信号需存在pending队列,待进程从内核态安全返回用户态再交付。

这种延迟交付平衡了效率、安全性与灵活性,既保证中断快速响应,也让进程可控处理异步事件。信号保存机制从Unix不可靠信号演进至Linux实时信号,体现对可靠性、实时性的追求。理解中断到信号的完整路径,是掌握Linux内核原理的关键,也为理解系统级架构奠定基础。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐