为什么 Atomic Context 不能睡眠——中断使用被中断任务栈时的通用分析
本文只讨论通用的 Linux/类 Unix 内核原理,不绑定某个芯片、某个内核版本或某一套入口汇编。
讨论场景是:硬件中断发生时,系统没有为 C 级 IRQ handler 准备独立的 per-CPU IRQ stack,而是把中断入口帧和 handler 的函数栈帧放在“被中断任务的内核栈”上。问题是:既然栈属于一个真实任务,为什么中断处理函数仍不能调用会睡眠的函数?应该如何分析?
目录
- 先给最终答案
- 两个维度:栈在哪里与当前是什么上下文
- 什么是任务栈、IRQ 栈和入口栈
- 没有独立 IRQ Stack 的三种情况
- 使用任务栈的中断入口通用时间线
- 为什么栈正确仍然是 Atomic Context
- 睡眠的本质:把一个任务变成可恢复的等待者
- 中断中调用 schedule 的完整后果
- 常见会睡眠的函数为什么都不能直接调用
- 任务栈模型额外带来的风险
- 没有独立 IRQ Stack 时的通用分析步骤
- 用一段抽象代码走完整判断流程
- 哪些事情可以在硬中断中做
- 正确的下半部和线程化方案
- 调试和验证方法
- 最容易出现的错误结论
- 总结
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
内核线程函数的调用帧
上下文切换时需要保存的寄存器现场
它的两个重要属性是:
- 归属于一个任务。 task_A 的栈不会被 task_B 正常地当作自己的栈使用。
- 可以在任务切换时整体挂起。 当 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 half | hard IRQ | 不能 |
| softirq/tasklet | softirq | 不能 |
| 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 栈帧复用了被中断任务的内核栈;它没有把硬中断变成一个可以睡眠的任务。决定能否睡眠的是上下文语义、调度许可和资源约束,而不是栈的归属。