Linux应用 2026年9月13日 76 分钟

为什么Atomic-Context不能睡眠-任务栈模型极度详细版

为什么 Atomic Context 不能睡眠——中断使用被中断任务栈时的通用分析 本文只讨论通用的 Linux/类 U...
本文目录 展开目录

为什么 Atomic Context 不能睡眠——中断使用被中断任务栈时的通用分析

本文只讨论通用的 Linux/类 Unix 内核原理,不绑定某个芯片、某个内核版本或某一套入口汇编。

讨论场景是:硬件中断发生时,系统没有为 C 级 IRQ handler 准备独立的 per-CPU IRQ stack,而是把中断入口帧和 handler 的函数栈帧放在“被中断任务的内核栈”上。问题是:既然栈属于一个真实任务,为什么中断处理函数仍不能调用会睡眠的函数?应该如何分析?


目录

  1. 先给最终答案
  2. 两个维度:栈在哪里与当前是什么上下文
  3. 什么是任务栈、IRQ 栈和入口栈
  4. 没有独立 IRQ Stack 的三种情况
  5. 使用任务栈的中断入口通用时间线
  6. 为什么栈正确仍然是 Atomic Context
  7. 睡眠的本质:把一个任务变成可恢复的等待者
  8. 中断中调用 schedule 的完整后果
  9. 常见会睡眠的函数为什么都不能直接调用
  10. 任务栈模型额外带来的风险
  11. 没有独立 IRQ Stack 时的通用分析步骤
  12. 用一段抽象代码走完整判断流程
  13. 哪些事情可以在硬中断中做
  14. 正确的下半部和线程化方案
  15. 调试和验证方法
  16. 最容易出现的错误结论
  17. 总结

1. 先给最终答案

1.1 一句话答案

中断能不能睡眠,取决于当前执行路径是否属于可调度的进程上下文,而不取决于当前 SP 是否落在某个任务栈范围内。

所以,在“中断使用被中断任务栈”的模型中,正确结论仍然是:

硬中断 handler 不能睡眠

1.2 为什么“任务栈”没有改变结论

任务栈只回答一个存储问题:

当前函数的局部变量、返回地址和保存寄存器放在哪里?

而“能否睡眠”至少还要回答以下问题:

当前是不是硬中断路径?
中断嵌套深度是否大于零?
抢占是否被禁止?
本地中断是否关闭?
是否持有自旋锁或其他不可阻塞的临界区?
当前代码是否必须完成一套异常返回协议?
当前执行流能否作为一个独立 task 被调度器挂起和恢复?

即使这些函数栈帧位于任务 A 的内核栈中,答案仍可能是:

current = task_A
SP      = task_A 的内核栈
但是    当前执行语义 = hard IRQ
所以    不能阻塞和睡眠

1.3 最重要的区分

任务栈       = 一块内存,以及它与某个任务的归属关系
进程上下文   = 一种允许当前任务阻塞、调度和恢复的执行语义
硬中断上下文 = 一种必须快速完成并返回原执行流的执行语义

它们不是同义词。

1.4 “没有独立 IRQ 栈”只改变了风险形态

使用任务栈时,确实少了一类风险:

不会因为把普通函数帧放到一块不属于任务的 IRQ 栈,
就直接得到一个错误的任务栈指针。

但是下面这些问题全部保留:

硬中断不是独立的调度实体
中断深度/原子深度仍然有效
本地 IRQ 通常仍然关闭
异常返回帧仍然必须完整保留
中断可能打断持锁代码
同一个任务栈可能被嵌套使用并溢出

因此,“任务栈地址看起来正确”最多只能排除一个局部的内存布局问题,不能把 Atomic Context 变成可睡眠上下文。


2. 两个维度:栈在哪里与当前是什么上下文

2.1 存储维度

存储维度描述 CPU 当前把执行现场放在哪里:

SP 当前值
普通 C 调用帧
异常入口保存的寄存器
pt_regs 或等价结构
函数局部变量

可能的存储位置包括:

被中断任务的内核栈
每 CPU 的硬中断栈
每 CPU 的软中断栈
异常入口的临时区域
架构定义的 banked stack

它回答的是:

数据在哪里?

2.2 语义维度

语义维度描述当前执行路径具有什么约束:

是否从硬件 IRQ 入口进入
是否正在处理 softirq/tasklet
是否关闭抢占
是否持有自旋锁
是否关闭本地中断
是否处于 NMI 或不可嵌套异常
是否承担一套尚未完成的异常返回协议

它回答的是:

当前代码能做什么?不能做什么?

2.3 两个维度可以任意组合

下面是一个概念矩阵:

栈位置执行语义是否通常可睡眠
任务内核栈普通进程/内核线程上下文,未持锁、可抢占可以
任务内核栈hard IRQ context不可以
任务内核栈softirq/tasklet context不可以
任务内核栈preempt_disable() 区间不可以
任务内核栈持有自旋锁不可以
每 CPU IRQ 栈hard IRQ context不可以
每 CPU IRQ 栈某些架构的特殊可调度线程入口取决于线程语义,不取决于栈地址

表中最关键的一行就是第二行:任务栈 + hard IRQ,仍然不可睡眠。

2.4 反例:同一块任务栈可以承载不同语义

任务 A 的内核栈可能先后承载:

系统调用 read()
    ↓
驱动普通内核函数
    ↓
硬件 IRQ 插入
    ↓
IRQ handler
    ↓
返回系统调用

栈区域可能一直都是 task_A 的内核栈,但四个阶段的语义不同:

系统调用内部某段路径       可能可睡眠
持有 spinlock 的临界区      不可睡眠
硬 IRQ handler              不可睡眠
返回后的普通进程路径        可能可睡眠

如果只看 SP,就无法区分这些阶段。


3. 什么是任务栈、IRQ 栈和入口栈

3.1 任务内核栈

任务内核栈是每个可调度任务拥有的一段内核内存。它通常保存:

系统调用的内核调用帧
异常处理的 pt_regs
内核线程函数的调用帧
上下文切换时需要保存的寄存器现场

它的两个重要属性是:

  1. 归属于一个任务。 task_A 的栈不会被 task_B 正常地当作自己的栈使用。
  2. 可以在任务切换时整体挂起。 当 task_A 主动阻塞并被切出时,调度器可以保存 task_A 的栈指针和寄存器,未来恢复 task_A。

第二点容易被错误地推广成“在这个栈上的任何函数都可以睡眠”。实际上,只有符合调度器协议的任务执行路径才能利用它。

3.2 独立 IRQ 栈

独立 IRQ 栈一般是每 CPU 的固定栈空间,用于承载硬中断 C handler 的调用帧:

task_A 被打断
    ↓
保存最小现场
    ↓
SP 切到 CPU0 的 IRQ stack
    ↓
运行 handler
    ↓
恢复 task_A 的现场

它的主要价值是:

隔离任务栈,避免中断嵌套把任务栈压得过深
让中断栈使用量更容易统计和限制
避免在深层内核调用栈上再次放入大型 handler 栈帧

它不是“禁止睡眠”的根本原因。

3.3 入口临时栈

有些处理器在异常模式中有 banked SP;有些入口代码会准备极小的临时区域,只保存:

原始寄存器
异常返回地址
原始状态寄存器

然后把执行转移到真正的内核栈上。这个区域只能称为入口暂存区,不应与完整 IRQ stack 混为一谈。

3.4 用户态中断时为什么不能直接用用户栈

如果中断发生在用户态,SP 可能指向用户空间。内核不能把任意 C handler 的内核调用帧直接压到用户栈上,原因包括:

用户地址可能不可写
用户可以修改栈内容
可能触发缺页和权限检查
内核返回现场不能依赖不可信的用户内存

所以“没有独立 IRQ 栈”通常仍意味着入口会使用某种受保护的内核栈:可能是当前任务的内核栈,也可能是入口栈再切换到任务栈。本文讨论的是进入 C handler 后使用任务内核栈的情况,而不是直接使用用户栈。


4. 没有独立 IRQ Stack 的三种情况

4.1 情况一:内核已经在任务内核栈上

这是最直观的情况:

task_A 正在执行内核代码
SP = task_A 内核栈
硬件 IRQ 到达
入口在同一任务栈上保存更多现场
handler 继续使用 task_A 内核栈

此时确实没有明显的“切换到另一块 C 级 IRQ 栈”的动作,但语义仍然是 hard IRQ。

4.2 情况二:从用户态进入后切到当前任务的内核栈

用户态 task_A
    ↓ IRQ
入口保存用户现场
    ↓
切到 task_A 的内核栈
    ↓
建立内核异常帧
    ↓
调用 IRQ handler

C handler 仍然在任务栈上,但它是异常入口的一部分,不是 task_A 主动调用的普通函数。

4.3 情况三:架构完全复用当前栈

在抽象教学模型中,可以假设异常入口直接在当前内核栈上压入现场:

旧 SP
    ↓
压入硬件现场
    ↓
压入内核入口现场
    ↓
调用 handler

只要该栈确实是受保护的内核栈,模型仍然可以正常工作;但“能否睡眠”的结论完全不变。这个模型最适合用来说明:栈复用和上下文约束是正交的。

4.4 三种情况的共同点

handler 不是由 task_A 通过普通 call 主动调用的
handler 需要在返回后恢复被中断的指令流
handler 期间存在 IRQ/原子深度记账
handler 不能把当前任务任意变成等待状态

5. 使用任务栈的中断入口通用时间线

5.1 统一抽象图

                被中断任务 A
                     │
                     │ 正在运行
                     ▼
              硬件 IRQ 到达
                     │
                     ▼
          ┌────────────────────┐
          │ 保存最小异常现场     │
          │ 关闭/屏蔽 IRQ        │
          └─────────┬──────────┘
                    │
                    ▼
          ┌────────────────────┐
          │ 使用 task_A 内核栈   │
          │ 建立 pt_regs/栈帧    │
          └─────────┬──────────┘
                    │
                    ▼
          ┌────────────────────┐
          │ 进入 hard IRQ 记账   │
          │ interrupt_depth++   │
          │ preempt_disable     │
          └─────────┬──────────┘
                    │
                    ▼
          ┌────────────────────┐
          │ 顶半部 handler       │
          │ 只能做不可阻塞工作    │
          └─────────┬──────────┘
                    │
                    ▼
          ┌────────────────────┐
          │ 退出 IRQ 记账        │
          │ interrupt_depth--   │
          └─────────┬──────────┘
                    │
                    ▼
          恢复 task_A 或到安全点调度

5.2 T0:普通任务运行

current = task_A
SP      = task_A 内核栈(如果在内核态)
IRQ depth = 0
preemptible = true(假设没有锁和禁止抢占)

此时 task_A 在普通进程上下文中,某些阻塞接口可以合法使用。

5.3 T1:硬件异步插入

硬件通常至少完成:

保存返回地址
保存进入前的状态
切换异常/特权模式,或进入架构定义的异常入口
屏蔽同级中断或建立嵌套级别

这些动作的共同语义是:

当前代码不是 task_A 主动调用的普通函数

5.4 T2:在任务栈上建立异常帧

如果采用任务栈模型,入口代码在 task_A 内核栈上预留空间:

高地址
┌──────────────────────────────┐
│ 进入 IRQ 前的 task_A 栈帧      │
├──────────────────────────────┤
│ 被中断寄存器现场               │
│ 返回 PC、状态、原始 SP         │
├──────────────────────────────┤
│ IRQ 入口的保存寄存器           │
├──────────────────────────────┤ ← handler 当前 SP
│ handler 局部变量               │
│ handler 调用其他函数的栈帧      │
└──────────────────────────────┘
低地址

这个布局说明“恢复返回现场”可以通过同一任务栈完成,但没有说明 handler 可以调度。

5.5 T3:进入硬中断记账

Linux 类内核通常会增加硬中断计数或原子深度:

interrupt_depth = interrupt_depth + 1
preempt_count   = preempt_count + HARDIRQ_OFFSET

同时可能:

关闭本地 IRQ
通知 RCU/lockdep/trace 系统进入 IRQ
记录中断时间

5.6 T4:handler 的职责

合理的顶半部通常只做:

读取设备状态
确认中断来源
清除/屏蔽设备中断
读取少量数据
更新原子变量或无阻塞环形缓冲区
唤醒等待线程或安排下半部
快速返回

5.7 T5:退出和真正调度

handler 返回后才可以:

减少 IRQ/原子深度
处理必要的 softirq
恢复中断前寄存器和状态
回到可抢占的内核/用户返回点
根据 NEED_RESCHED 决定是否切换任务

“中断唤醒了一个更高优先级任务”与“中断函数自己睡眠”是两件不同的事。前者是在安全返回点延迟调度;后者是把调度器插入一套尚未完成的异常协议。


6. 为什么栈正确仍然是 Atomic Context

6.1 Atomic Context 的真正含义

Atomic Context 不是“使用某一块特殊栈”,而是:

当前代码不能被普通的阻塞式调度打断,
必须在保持当前执行约束的条件下完成或转交工作。

常见来源包括:

硬中断
软中断/tasklet
关闭抢占
持有自旋锁
关闭本地 IRQ
NMI/不可屏蔽异常
某些 RCU 和底层锁临界区

同一个函数可能在不同调用者下处于不同上下文,因此不能只看函数名或栈地址。

6.2 硬中断是“插入片段”,不是“独立任务”

把 CPU 执行流画出来:

普通任务 A:  A0 ─── A1 ─── A2 ─── A3
                         ▲
                         │ IRQ 异步插入
                         │
硬中断:                H0 ─ H1 ─ H2 ─ 返回 A2

H0-H2 必须完成后回到 A2。它没有自己的:

task_struct
等待状态
调度优先级
独立唤醒条件
独立的系统调用返回链

current 仍可能等于 A,但 H 不是 A 的普通 C 调用者。

6.3 使用 task_A 栈不能创建 IRQ task

即使局部变量位于 task_A 栈上,系统中仍然只有:

task_A 的 task_struct

不存在:

task_irq_0 的 task_struct

如果 handler 调用等待接口,等待接口只能把 current(task_A)设为等待状态。它无法表达“只挂起 IRQ handler,但让 task_A 原来的代码继续运行”。

6.4 中断深度和抢占计数不因栈复用而清零

任务栈上的 thread_info 或等价元数据通常仍包含当前任务的原子/抢占状态。进入 IRQ 后:

原来的 preempt_count
        + 硬中断嵌套偏移
        + 可能的本地 IRQ/softirq 约束

退出时必须逐层恢复。睡眠函数若改变任务状态、调用调度器,会使这个嵌套计数与实际返回层级不匹配。

6.5 “本地 IRQ 关闭”会独立禁止睡眠

许多内核的阻塞检查至少要求:

本地 IRQ 已开启
抢占深度允许
不在中断或 NMI

即使某个简化内核没有完整的 preempt_count,在本地 IRQ 关闭的临界区中阻塞也可能导致:

等待者无法被唤醒
定时器中断无法到达
设备完成中断无法处理
自旋锁或 runqueue 锁无法释放

因此,任务栈和 IRQ 开关是两个独立维度。


7. 睡眠的本质:把一个任务变成可恢复的等待者

7.1 阻塞接口通常做了什么

以等待队列为例,抽象流程是:

1. 检查条件
2. 条件不满足时把 current 加入等待队列
3. 设置 current->state
4. 释放配套锁
5. 调用 schedule()
6. 以后由另一个执行实体唤醒 current
7. current 从保存的调度点恢复
8. 重新检查条件

整个过程的主语是 current,也就是一个真实可调度任务。

7.2 任务切换保存的是什么

任务切换通常需要保存:

内核 SP
被调用者保存寄存器
返回地址
地址空间或线程相关状态
调度器所需的任务元数据

任务栈之所以有用,是因为这些状态属于 task_A,可以整体挂起,未来在 task_A 的内核栈上恢复。

硬中断中还有额外的状态:

设备中断是否已确认/清除
中断控制器是否已结束本次处理
IRQ 深度是否已增加
异常返回寄存器是否尚未恢复
本地 IRQ 是否仍需保持关闭
被中断指令的返回地址和状态

这些状态不是普通任务阻塞协议的一部分。

7.3 为什么不能“只暂停 handler”

有人会提出:

handler 也在 task_A 栈上,那就把 task_A 切走,
以后回来继续 handler,不就行了吗?

这个想法缺少一个调度对象。切走的是 current,也就是 task_A;并不是一个可单独命名的 handler。这样做会把下面两个概念混成一个:

task_A 原本被中断的执行流
task_A 栈上暂时插入的 IRQ 执行流

当 task_A 被置为睡眠状态后,原本应该负责唤醒它的条件、锁和设备中断,可能正依赖 IRQ handler 完成;而 handler 又被“挂起”在 task_A 自己身上,形成循环依赖。

7.4 为什么“这次没有真正睡眠”也不安全

阻塞接口有条件路径:

锁空闲时立即返回
内存有空闲页时立即分配
条件已满足时不调用 schedule
用户页已经在内存时不触发缺页

但下一次可能出现:

另一个线程持有锁
内存回收需要等待 I/O
用户页未映射
设备 FIFO 尚未有数据
完成事件尚未到达

内核接口的“可能睡眠”属性要求调用者始终处于可阻塞上下文,不能依赖某一次运行恰好走快路径。


8. 中断中调用 schedule 的完整后果

下面不把问题简化为“栈地址错了”,而是按执行协议逐项分析。

8.1 后果一:改变的是被中断任务的状态

set_current_state(TASK_INTERRUPTIBLE);
schedule();

在硬 IRQ 中执行时,current 仍然通常是被中断任务 A,于是实际改变的是:

task_A->state

而不是“IRQ handler 的状态”。task_A 原本可能只是暂时执行用户代码,不能因为被 IRQ 打断就变成等待者。

8.2 后果二:IRQ 深度没有正常退出

正常路径是:

irq_enter()
    handler()
irq_exit()

如果 handler 中途睡眠:

irq_enter()
    handler()
        schedule()  ← 还没有 irq_exit
    ...
irq_exit()

调度发生时硬 IRQ 深度仍然存在。任务切换后,当前 CPU 可能执行另一个任务;另一个任务的原子计数、IRQ 状态和锁上下文并不对应这个未完成的 handler。

8.3 后果三:返回地址看似在任务栈,返回协议仍未完成

任务栈确实能够保存 handler 的 C 调用帧,但硬中断返回还需要恢复:

异常前的 PC
异常前的 CPSR/状态
异常前的 SP/模式
通用寄存器
中断屏蔽状态
入口临时寄存器

调度器保存了一个任务的可恢复寄存器集,并不会替你完成“退出 IRQ、结束中断、恢复异常状态”这些步骤。两套协议被交错执行,结果属于未定义/不受支持行为。

8.4 后果四:设备或中断控制器状态可能被卡住

许多设备中断必须按顺序处理:

读取状态
读取数据
清除 pending 位
向控制器发送 EOI
恢复屏蔽状态

如果在中间阻塞:

设备可能持续保持中断请求
同一个 IRQ 可能不断重入或被屏蔽
其他设备共享 IRQ 时无法及时处理
中断控制器可能一直等待结束确认

任务栈是否正确与这些硬件协议没有关系。

8.5 后果五:锁死被中断任务持有的锁

场景:

task_A 正在执行:
    spin_lock(&L);
    ...
    // 尚未 spin_unlock

IRQ 到达,进入 handler
handler 又尝试获取 L,或等待某个由 A 释放的资源

结果:

handler 等待 L
task_A 必须从 handler 返回后才能继续
task_A 又无法继续到 spin_unlock

这不是独立 IRQ 栈特有的问题。使用 task_A 栈反而让“中断打断持锁代码”更容易被误认为是普通嵌套调用。

8.6 后果六:允许中断或抢占会产生更深的嵌套

强行调度可能改变本地 IRQ 状态,让其他 IRQ、softirq 或定时器在原 handler 尚未结束时运行。任务栈上的布局会变成:

task_A 原内核帧
    └─ IRQ_1 帧
         └─ 试图睡眠/调度
              └─ task_B 执行
                   └─ 另一个 IRQ
                        └─ 更多帧

这会放大栈深度、锁顺序、设备状态和返回路径问题。

8.7 后果七:任务栈溢出更容易发生

独立 IRQ 栈把中断深度与任务栈隔离;任务栈模型把它们叠加:

任务原有深度
    + 系统调用深度
    + 异常入口帧
    + IRQ handler 深度
    + 可能的嵌套异常/软中断

因此,即使所有函数都不睡眠,复杂 handler 也可能越过任务栈边界。栈溢出是任务栈模型的额外工程风险,但不是“不能睡眠”的逻辑根因。

8.8 后果八:调度器可能直接报告错误

Linux 类内核常见报错包括:

scheduling while atomic
sleeping function called from invalid context
BUG: sleeping function called from invalid context

这些诊断通常来自:

preempt_count/interrupt_depth 非零
本地 IRQ 关闭
持有自旋锁或 lockdep 记录的不可睡眠锁

它们不需要检查“当前 SP 是否在任务栈”就可以成立。


9. 常见会睡眠的函数为什么都不能直接调用

“可能睡眠”是调用约束,不表示每次调用都必然睡眠。下面按机制分类。

9.1 显式等待类

schedule();
schedule_timeout(timeout);
msleep(ms);
usleep_range(min, max);
wait_event(wq, condition);
wait_event_interruptible(wq, condition);
wait_for_completion(&done);

这些接口明确要求当前是可调度任务上下文,硬中断直接禁止。

9.2 互斥锁和信号量类

mutex_lock(&m);
down(&sem);
down_interruptible(&sem);
rwsem_down_read(&sem);

当资源被占用时,它们必须把当前任务放入等待队列。中断 handler 没有独立等待身份,因此不能调用。即使使用 mutex_trylock() 这种立即返回接口,也要确认它的语义、锁类型和调用环境;失败时只能走无阻塞分支。

9.3 内存分配类

kmalloc(size, GFP_KERNEL);
kmem_cache_alloc(cache, GFP_KERNEL);
vmalloc(size);
alloc_pages(..., GFP_KERNEL);

GFP_KERNEL 允许内存回收、等待写回、等待锁等行为。硬中断通常使用预分配对象或 GFP_ATOMIC,但 GFP_ATOMIC 也不是“任何复杂分配都安全”:它可能失败,容量有限,不能当作睡眠替代品。

9.4 用户空间访问类

copy_from_user(...);
copy_to_user(...);
get_user(...);
put_user(...);

用户页可能尚未映射,访问可能触发缺页异常。硬中断不能等待缺页处理,也不应该在 IRQ handler 中直接处理用户指针。正确做法是先在进程上下文保存/验证数据,或把工作下沉到线程。

9.5 设备总线和同步封装类

下面这些接口是否睡眠取决于实现,但在驱动中通常应按“可能睡眠”处理:

i2c_transfer();
spi_sync();
regmap_read();
某些 GPIO/Pin 控制接口
某些时钟、电源、DMA 映射接口
某些文件系统、固件加载和设备模型操作

原因是它们可能等待总线完成、获取 mutex、分配内存、等待硬件中断或调用调度器。除非接口文档明确标注 IRQ-safe,否则不能凭函数名猜测。

9.6 间接睡眠

最容易漏掉的是间接路径:

IRQ handler
  └─ helper_A()
       └─ helper_B()
            └─ mutex_lock()

或:

IRQ handler
  └─ log/trace/debug helper
       └─ 分配内存或获取锁

判断时必须沿调用链继续追踪,不能只审查顶层函数名。

9.7 唤醒和安排工作通常是安全方向

以下操作的设计目的就是把工作交给以后执行:

wake_up_interruptible(&wq);
schedule_work(&work);
queue_work(wq, &work);
tasklet_schedule(&tasklet);

但要区分:

安排工作/唤醒任务      = 当前 handler 不睡眠
等待工作完成            = 可能睡眠,不能在硬 IRQ 中做

10. 任务栈模型额外带来的风险

这些风险不能替代“Atomic Context 不能睡眠”的核心解释,但在实际设计中必须同时考虑。

10.1 栈深度叠加

独立 IRQ 栈模型大致是:

任务栈:任务原有调用帧
IRQ 栈:handler 调用帧

任务栈模型则是:

同一任务栈:
    任务原有调用帧
    + 异常保存帧
    + handler 调用帧
    + 可能的嵌套异常帧

深层系统调用、文件系统路径、网络协议路径再叠加 IRQ handler 后,栈剩余空间可能很小。

10.2 当前任务不同,栈所有者不同

硬 IRQ 在 CPU0 到达时使用“当时正在 CPU0 上运行的任务”的内核栈。如果 handler 触发非法调度,CPU 可能切换到 task_B,之后的 C 调用又会使用 task_B 的栈。此时一个未完成的 handler 逻辑和另一个任务的栈/状态交错,极难维护。

正常设计要求:

handler 在同一个 IRQ 上下文内完成
再退出 irq_enter/irq_exit 对
再回到原任务或合法的调度点

10.3 中断嵌套和重入

即便普通 IRQ 默认不嵌套,仍可能出现:

更高优先级异常
FIQ/NMI
错误处理路径
显式重新开启中断后的嵌套
软中断在退出阶段运行

每层都会在任务栈上新增现场和局部变量。handler 应尽量短小、少用大数组、少调用深层 helper。

10.4 栈上的对象生命周期

如果把任务栈上的局部变量地址交给异步下半部:

irqreturn_t bad_irq(int irq, void *arg)
{
    struct event event;
    queue_work(wq, &event.work);    /* event 位于当前任务栈,错误 */
    return IRQ_HANDLED;
}

handler 返回后,这个栈帧就不再有效。应使用预分配的长期对象、环形缓冲区或专门的工作项。这个错误与睡眠问题不同,但两者都源于没有区分“任务栈上的同步局部变量”和“异步执行需要的持久对象”。

10.5 大对象和可变长数组

以下写法在任务栈模型中尤其危险:

char big_buffer[4096];
struct huge_state state;

中断 handler 的栈预算应包括:

入口保存帧
handler 自身局部变量
所有 helper 的最大栈深度
可能的异常嵌套
编译器保存寄存器和对齐填充

11. 没有独立 IRQ Stack 时的通用分析步骤

这是本文最实用的部分。遇到一个“中断里能不能调用 X”的问题,按以下顺序分析。

11.1 第一步:确认入口类型

先问:这段代码到底由谁调用?

入口来源默认上下文默认能否睡眠
硬件 IRQ top halfhard IRQ不能
softirq/taskletsoftirq不能
threaded IRQ 的线程函数内核线程/进程上下文通常可以
workqueue 回调内核工作线程通常可以
普通系统调用路径进程上下文视锁和抢占状态而定
定时器回调softirq 或等价原子上下文不能
NMI/FIQ更强原子约束不能

不要因为某个函数的名字叫 xxx_handler 就直接判断;同一个 helper 可能被 top half 和线程函数共同调用。

11.2 第二步:确认当前 SP 的存储位置

记录:

当前 SP 是否在任务内核栈?
是否在 per-CPU IRQ/softirq 栈?
是否仍在用户栈?
是否有入口临时帧?

这一步只用于分析现场保存、栈溢出和返回地址,不直接决定睡眠许可。

11.3 第三步:确认 current 和任务身份

记录:

current 指向哪个 task_struct?
当前栈是否确实属于 current?
是否为 idle task?
是否为内核线程?

然后明确写下:

current 是被中断任务,不等于当前正在执行普通进程代码。

11.4 第四步:检查原子/抢占状态

在 Linux 类内核中重点观察:

in_irq();
in_softirq();
in_interrupt();
in_atomic();
preempt_count();
preemptible();
irqs_disabled();

不要只看 in_atomic():某些内核配置下它不能完整识别所有持有的自旋锁,文档也通常建议不要把它当作驱动中唯一的睡眠判定。应把中断状态、抢占状态和锁状态一起看。

11.5 第五步:检查锁和临界区

列出从入口到目标函数之间的所有:

spin_lock/spin_lock_irqsave
raw_spin_lock
local_irq_disable
preempt_disable
local_bh_disable
rcu_read_lock(取决于实现和路径)

任意一个未退出的不可睡眠临界区,都足以禁止阻塞。

11.6 第六步:沿调用链寻找真正的阻塞点

不要停在“函数名看起来只是读取寄存器”。继续追踪:

目标函数
  ├─ 是否获取 mutex/semaphore/completion?
  ├─ 是否调用 schedule 或 wait_event?
  ├─ 是否使用 GFP_KERNEL/vmalloc?
  ├─ 是否访问用户内存?
  ├─ 是否等待总线/设备完成?
  └─ 是否通过 helper 间接调用上述接口?

11.7 第七步:检查返回协议

确认 handler 返回前是否必须完成:

读取/清除设备状态
确认中断来源
向控制器发送 EOI/ack
恢复寄存器和异常栈帧
减少 IRQ/softirq 记账

如果函数可能在其中途阻塞,说明不仅“睡眠不允许”,设备和异常返回协议也会被中断。

11.8 第八步:检查栈预算

在任务栈模型中计算:

进入 handler 时已经使用的栈空间
+ pt_regs/入口帧
+ handler 栈帧
+ 最大 helper 深度
+ 中断嵌套和异常处理空间

这一步回答“会不会栈溢出”,不要拿它替代前面关于睡眠的上下文判断。

11.9 第九步:决定是否需要下沉

如果目标函数可能阻塞,设计选择通常是:

硬 IRQ:采集最小数据、清中断、安排下半部
线程/工作队列:执行会睡眠的复杂操作
进程 read/ioctl:由用户请求路径等待数据

12. 用一段抽象代码走完整判断流程

12.1 错误示例

irqreturn_t device_irq(int irq, void *arg)
{
    struct device_state *dev = arg;
    unsigned int status;

    status = device_read_status(dev);       /* 假设不阻塞 */
    device_clear_irq(dev, status);          /* 假设不阻塞 */

    mutex_lock(&dev->data_mutex);           /* 可能阻塞,错误 */
    if (status & DATA_READY)
        dev->data = device_read_data(dev);  /* 可能等待总线,错误 */
    mutex_unlock(&dev->data_mutex);

    copy_to_user(dev->user_buf, &dev->data,
                 sizeof(dev->data));       /* 可能缺页,错误 */
    return IRQ_HANDLED;
}

12.2 按九步法分析

入口:硬件 IRQ top half
栈:任务内核栈(题设)
current:被中断任务 A
IRQ depth:大于 0
抢占:通常关闭或受限
本地 IRQ:通常关闭

因此从第一步已经得到:

不能调用任何可能睡眠的函数

继续追踪只是为了找出具体风险:

mutex_lock       → 竞争时把 current 放入等待队列
device_read_data → 可能等待总线/设备完成
copy_to_user     → 可能触发缺页

这三个错误与 handler 的栈是不是 task_A 栈没有关系。

12.3 改造后的结构

irqreturn_t device_irq(int irq, void *arg)
{
    struct device_state *dev = arg;
    unsigned int status;
    unsigned char value;

    status = device_read_status(dev);
    if (!device_irq_is_ours(status))
        return IRQ_NONE;

    device_clear_irq(dev, status);

    if (status & DATA_READY) {
        value = device_read_data_atomic(dev);
        ring_put(&dev->ring, value);
    }

    wake_up_interruptible(&dev->read_wait);
    schedule_work(&dev->work);
    return IRQ_HANDLED;
}

复杂处理放到工作线程:

static void device_work(struct work_struct *work)
{
    struct device_state *dev = work_to_device(work);

    mutex_lock(&dev->data_mutex);
    device_sync_or_configure(dev);          /* 可以睡眠 */
    mutex_unlock(&dev->data_mutex);
}

用户的 read() 在进程上下文中等待:

ssize_t device_read(...)
{
    wait_event_interruptible(dev->read_wait,
                             !ring_empty(&dev->ring));
    return copy_to_user(...);
}

12.4 如果必须使用同一套业务逻辑

把业务逻辑拆成两层:

irq_safe 部分:只读写寄存器、原子变量、预分配内存
sleepable 部分:mutex、I/O、用户内存、复杂协议和内存分配
static void process_event_sleepable(struct device_state *dev)
{
    mutex_lock(&dev->data_mutex);
    /* 复杂且可能阻塞的业务逻辑 */
    mutex_unlock(&dev->data_mutex);
}

static irqreturn_t device_irq(int irq, void *arg)
{
    struct device_state *dev = arg;

    device_collect_event_atomic(dev);
    queue_work(dev->wq, &dev->work);
    return IRQ_HANDLED;
}

不要让一个既可能在硬 IRQ、又可能在进程上下文被调用的 helper 隐藏睡眠行为;如果必须复用,至少显式区分 *_atomic() 和 *_sleepable() 两条接口。


13. 哪些事情可以在硬中断中做

13.1 通常可做的操作

在满足设备和架构要求的前提下,顶半部通常可以:

读取/写入已经映射的设备寄存器
确认和清除中断状态
操作原子变量和位图
使用自旋锁(选择正确的 irq-safe 变体)
写入预分配的环形缓冲区
使用 GFP_ATOMIC(仍需处理失败)
递增计数器和记录时间戳
唤醒等待队列
安排 tasklet/softirq/workqueue

13.2 “快”不是唯一标准

顶半部设计常说“要快”,但真正的约束有两条:

不能阻塞/睡眠
不能破坏 IRQ 返回和设备确认协议

短小有助于降低延迟和栈深度,但“执行时间短”不能把一个有阻塞语义的函数变成 IRQ-safe。

13.3 自旋锁的边界

硬中断中可以使用自旋锁,但需要考虑中断是否可能打断持有同一把锁的进程代码:

进程上下文:spin_lock_irqsave(&L)
硬 IRQ:      spin_lock(&L) 或使用专门设计的锁顺序

锁的具体变体取决于锁是否被同一 CPU 的 IRQ 路径访问。原则是:

自旋等待而不睡眠
确保不会因“IRQ 打断持锁者”形成同 CPU 死锁

13.4 预分配比临时分配更稳妥

如果中断频率高、内存压力大或不能接受分配失败,应在进程上下文中预分配:

事件对象池
DMA 缓冲区
环形队列
工作项
统计结构

handler 只从池中取对象,满了就丢弃、计数或触发流控,不要在 IRQ 中等待内存。


14. 正确的下半部和线程化方案

14.1 顶半部 + 工作队列

硬 IRQ top half
    ├─ 读状态/清中断
    ├─ 保存最小数据
    └─ queue_work()
             ↓
工作线程
    ├─ 获取 mutex
    ├─ 等待设备/总线
    ├─ 分配 GFP_KERNEL 内存
    └─ 完成复杂处理

工作队列回调运行在内核工作线程上下文,通常可以睡眠,但仍要遵守工作队列自身的锁和并发规则。

14.2 顶半部 + tasklet/softirq

适合延后但仍不可阻塞的工作:

硬 IRQ → tasklet/softirq

要特别注意:

softirq 不是进程上下文
tasklet 不是可睡眠线程
softirq 中同样不能 mutex_lock()/wait_event()/msleep()

如果下半部需要睡眠,使用 workqueue 或线程化 IRQ,而不是 tasklet。

14.3 线程化 IRQ

线程化 IRQ 将处理拆为:

硬 IRQ 部分:确认来源、屏蔽/清状态、唤醒 IRQ 线程
IRQ 线程:    运行完整 handler,可使用 mutex、等待队列和可睡眠内存

必须区分两个函数:

top half / primary handler  → 仍是 atomic context
thread_fn                   → 任务上下文,通常可睡眠

不能因为二者属于同一个设备 IRQ,就把睡眠代码放进 primary handler。

14.4 进程 read/ioctl 主动等待

设备驱动最常见的设计是:

IRQ:写 ring buffer + wake_up
read:wait_event + copy_to_user

这样等待发生在真正拥有 task_struct、可以被调度的进程上下文中。

14.5 什么时候使用内核线程

如果工作需要:

持续轮询
复杂状态机
较长时间等待
顺序化设备操作
专门的优先级或 CPU 亲和性

可以使用专用内核线程。IRQ 只负责把事件交给线程,不要让硬中断承担线程职责。


15. 调试和验证方法

15.1 在开发阶段显式检查上下文

可以在怀疑路径中加入:

WARN_ON_ONCE(in_interrupt());
WARN_ON_ONCE(irqs_disabled());
WARN_ON_ONCE(!preemptible());
might_sleep();

这些检查的用途不同:

in_interrupt()  检查 IRQ/softirq/NMI 语义
irqs_disabled()  检查本地 IRQ 状态
preemptible()    检查是否允许抢占
might_sleep()    标记该位置必须具备可睡眠上下文

不要在一个复杂条件中只调用 in_atomic() 就下结论。

15.2 打开内核诊断选项

开发内核可以考虑:

CONFIG_DEBUG_ATOMIC_SLEEP
CONFIG_DEBUG_PREEMPT
CONFIG_PROVE_LOCKING
CONFIG_LOCKDEP
CONFIG_DEBUG_SPINLOCK
CONFIG_DEBUG_LIST
栈保护和栈溢出检测选项

这样可以更早看到:

sleeping function called from invalid context
scheduling while atomic
锁顺序反转
栈越界或栈破坏

15.3 观察上下文信息

调试日志中建议同时记录:

printk("pid=%d comm=%s irq=%d softirq=%d atomic=%d preempt=%d irqoff=%d\n",
       current->pid,
       current->comm,
       in_irq(),
       in_softirq(),
       in_atomic(),
       preempt_count(),
       irqs_disabled());

不同内核版本的接口和输出格式可能不同,原则不变:一次同时观察任务身份、IRQ 深度、抢占深度、IRQ 开关和锁状态。

15.4 用调用栈定位真正的睡眠点

收到告警后不要只看最后一行函数名,按下面顺序回溯:

哪个 IRQ 触发了 handler?
handler 的哪一条路径调用了目标函数?
目标函数是直接睡眠,还是间接获取锁/分配/缺页?
调用时是否仍在 top half,还是已经下沉到线程/工作队列?

15.5 用静态规则做初筛

可以对硬 IRQ 入口的调用图做初步搜索:

搜索 schedule、wait_event、mutex_lock、down、msleep
搜索 GFP_KERNEL、vmalloc、copy_to_user、copy_from_user
搜索 I2C/SPI/Regmap/firmware 等可能等待的接口

静态搜索不能代替阅读实现,因为函数是否睡眠可能由参数、锁竞争和配置决定;但它能快速发现明显违规路径。

15.6 测试高压力而不是只测快路径

要让“可能睡眠”的分支真正暴露出来,需要制造:

内存压力
锁竞争
设备延迟
高频中断
中断嵌套
用户页未预取
工作队列拥塞

一次“没有打印告警”的低压力测试不能证明 IRQ handler 可以阻塞。


16. 最容易出现的错误结论

16.1 错误结论一:SP 在任务栈,所以就是进程上下文

纠正:

SP 是存储位置
进程上下文是调度语义
二者不能互相替代

16.2 错误结论二:current 是 task_A,所以 handler 可以让 task_A 睡眠

纠正:

current 只是被中断时的当前任务
IRQ handler 是异步插入片段
它没有独立的等待身份

16.3 错误结论三:没有独立 IRQ 栈就没有“中断上下文”

纠正:

中断上下文由入口类型、IRQ 记账、抢占状态和返回协议定义
不是由栈是否切换定义

16.4 错误结论四:只要这次 mutex 没竞争就可以

纠正:

mutex_lock 的接口语义允许阻塞
调用者必须始终满足可睡眠前提
不能用一次快路径运行结果替代 API 契约

16.5 错误结论五:wake_up 会调度,所以 wake_up 也不能用

纠正:

wake_up 通常只改变等待者和调度标志
当前 IRQ handler 不会在唤醒调用处睡眠
真正调度延后到合法返回点

仍需查阅具体接口是否有额外锁或内存分配要求。

16.6 错误结论六:tasklet/softirq 是“半个线程”,可以睡眠

纠正:

tasklet/softirq 仍属于原子执行路径
需要睡眠时使用 workqueue 或线程化 IRQ

16.7 错误结论七:不能睡眠只是因为 IRQ 栈太小

纠正:

栈大小影响栈溢出风险
不能睡眠的根因是不可调度的执行语义

即使给每个 IRQ handler 分配无限大的独立栈,也不会因此得到可睡眠权限。

16.8 错误结论八:handler 很短,所以可以调用阻塞 API

纠正:

执行时间短 ≠ 不会阻塞
是否阻塞取决于最坏路径,而不是平均耗时

17. 总结

17.1 三层结论

第一层,关于栈:

中断 handler 可以使用被中断任务的内核栈。
这是一种合法的栈组织方式,但只解决现场保存和空间复用问题。

第二层,关于上下文:

硬件 IRQ 进入后,当前执行路径仍有 IRQ 深度、抢占和返回协议约束。
即使 current 和 SP 都指向任务 A,也仍然是 hard IRQ context。

第三层,关于睡眠:

睡眠需要一个可以被调度器独立挂起、唤醒和恢复的任务。
IRQ handler 不是这样的调度实体,因此不能调用可能睡眠的函数。

17.2 一个可反复使用的判断公式

是否能睡眠
    = 入口是否为可睡眠任务上下文
    && IRQ/softirq/NMI 深度为零
    && 抢占已允许
    && 本地 IRQ 状态满足要求
    && 没有持有不可睡眠的锁
    && 目标调用链的最坏路径不会阻塞

而不是:

SP 是否落在任务栈

17.3 针对“中断使用任务栈”的最终分析模板

以后遇到类似问题,可以直接按下面的模板写:

1. 先确认:当前是硬 IRQ、softirq、线程化 IRQ,还是普通进程路径?
2. 再确认:handler 的栈帧放在任务栈、IRQ 栈还是入口栈?
3. 再确认:current 指向谁,但不要把 current 等同于普通进程代码。
4. 检查:IRQ 深度、preempt_count、irqs_disabled、锁状态。
5. 沿调用链查找:schedule、等待、可睡眠锁、GFP_KERNEL、缺页和 I/O。
6. 单独分析:设备 ack/EOI、异常返回和栈深度。
7. 发现可能阻塞后:把工作下沉到工作队列、线程化 IRQ 或进程 read/ioctl。

17.4 最终一句话

没有独立 IRQ Stack,只意味着中断的 C 栈帧复用了被中断任务的内核栈;它没有把硬中断变成一个可以睡眠的任务。决定能否睡眠的是上下文语义、调度许可和资源约束,而不是栈的归属。

上一篇 驱动模块编译进内核的完整方法

将驱动模块编译进内核的完整方法 目录 方法一:外部模块编译(最灵活) 驱动代码放在内核源码外部,单独 make 编译,生...

下一篇 中断使用任务栈时为什么仍然不能睡眠-详细分析

中断使用任务栈时为什么仍然不能睡眠 – 详细分析 目录 1. 前言与背景 1.1 问题的由来 在文档《为什么...