一、问题的起点:为什么大家都说“中断上下文不能睡眠”?
在 Linux 内核开发中,有一句几乎所有人都听过的“金科玉律”:
中断上下文不能睡眠(Interrupt context must not sleep)
但很多工程师对这句话的理解,仍然停留在“这是个规定”“这是个经验”“这么做会 BUG”这种层面,而没有真正理解:
为什么不能?
如果睡眠了,内核到底会发生什么?
哪些 API 本质上会导致睡眠?
哪些锁能用,哪些锁绝对不能用?
如果业务逻辑就是需要等待/延时,正确的设计方式是什么?
本文档试图从CPU 执行模型、内核调度器、上下文语义等底层逻辑出发,彻底讲清楚这些问题。
二、什么是“中断上下文”?
我们需要确定正确的认知!
2.1 中断不是“函数调用”
在用户态或内核线程中,我们习惯把函数调用理解为:
当前函数 → 保存返回地址 → 执行 → 返回但中断并不是这种调用模型。
当硬件中断发生时,CPU 会:
立刻暂停当前正在执行的指令流
保存极少量的 CPU 现场(PC / Flags / 部分寄存器)
跳转到中断向量表指定的入口
开始执行中断处理函数
此时有一个极其关键的事实:
CPU 并没有“切换到另一个进程或线程”,进程被“暂停”了,但它并没有被“切走”
2.2 中断上下文的本质特征
中断上下文具备以下几个核心特性:
不属于任何进程
没有 task_struct 语义
没有独立的调度实体
不能被调度器切走
使用当前 CPU 的内核栈
这意味着:中断上下文是“不可调度”的执行状态
调度器只认一种东西:task_struct
而中断上下文:
没有自己的 task_struct
没有调度状态
没有时间片
没有优先级
没有唤醒路径
所以从调度器角度看:
“我不知道你是谁,也不知道以后怎么把你调回来”
三、底层核心原理
首先要研究中断上下文不能睡眠,我们需要讲清楚几个比较重要的基础知识点:
3.1 arm64的中断是从哪里进入内核的?
3.1.1 EL1异常向量表:arch/arm64/kernel/entry.S
arm64 把异常(包括 IRQ)统一走异常向量表。发生 IRQ 时,CPU 会从当前状态跳到 EL1 的 IRQ vector 对应入口(比如 “el1_irq” 这一类标签/入口)。
入口阶段做的关键事情(概念上):
保存现场(寄存器、PSTATE 等)形成 pt_regs
切换到合适的栈
进入 C 语言层的异常处理通道
你会看到入口最终会跳到类似以下层次的 C 处理函数:
el1_irq()/handle_arch_irq()/do_interrupt_handler()
这部分详细可查看我写的博文:
https://www.iliuqi.com/archives/aarch64-exception-handle
留个小作业:
请问中断触发时是如何保留现场的?
3.1.2 GIC 中断控制器的分发:drivers/irqchip/irq-gic-v3.c(常见平台)
arm64 常见是 GICv3,IRQ 入口最终会走到架构注册的 handle_arch_irq,然后从 GIC 读 IAR/ACK,拿到 hwirq,再分发到 generic IRQ framework。
3.2 内核是如何定义:正处于硬中断上下文的?
Linux 用一个非常统一的机制表达“当前上下文不能被调度/不能睡眠”:preempt_count。
相关入口/宏/位域通常在:
include/linux/preempt.hinclude/linux/thread_info.h(或相关架构 thread_info 定义)include/linux/irqflags.h、include/linux/interrupt.h
preempt_count 里不仅表示“禁止抢占”,还包含:
HARDIRQ(硬中断层级)
SOFTIRQ(软中断层级)
PREEMPT(抢占计数)
NMI(某些架构/情形)
这也是为什么判断“是否在中断上下文”会落到这些宏:
in_irq():看 HARDIRQ 位in_softirq():看 SOFTIRQ 位in_interrupt():综合(hardirq/softirq 等)
它们最终都绕不开:preempt_count() 的某些位是否非 0。
这里以in_irq为例,剖析源码
#define in_irq() (hardirq_count())
#define hardirq_count() (preempt_count() & HARDIRQ_MASK)3.3 进入中断上下文的标志
irq_enter() / irq_exit()
在 arm64 的实际处理路径中,你会看到一种非常经典的结构:
进入 IRQ 分发之前:调用
irq_enter()处理完:调用
irq_exit()
这些函数一般在:
kernel/softirq.c(软中断触发/退出相关)kernel/irq/(中断框架相关;具体文件视版本)
irq_enter() 做的最重要的事就是:
增加 hardirq 相关计数(体现到 preempt_count/irq_count)
更新一些统计/RCU/时钟相关 bookkeeping(版本差异较大)
直观理解:
从
irq_enter()开始,内核就正式宣布:“当前 CPU 正在硬中断上下文中执行” 这会让in_irq()变为 true,也会让preempt_count()非 0。
void irq_enter(void){
rcu_irq_enter();
irq_enter_rcu(); ////这儿
}
void irq_enter_rcu(void){
__irq_enter_raw(); ///这个就是标记进入中断上下文
if (is_idle_task(current) && (irq_count() == HARDIRQ_OFFSET))
tick_irq_enter();
account_hardirq_enter(current);
}
#define __irq_enter_raw() \
do { \
preempt_count_add(HARDIRQ_OFFSET); \
lockdep_hardirq_enter(); \
} while (0)3.4 抛开内核的保护机制分析中断中调度的过程
这一章节们完全抛开内核的所有保护机制,纯粹从理论上分析:如果在中断处理函数中直接执行了调度操作(比如调用了schedule()),会发生什么?后果有多严重?
假设场景
我们假设一个极其简陋的内核,或者我们手动写了一段“坏代码”,绕过了所有安全检查:
void dangerous_irq_handler(void)
{
// 1. 中断发生,保存了当前进程A的现场到它的内核栈
// 2. 现在我们处于中断上下文,但没有任何保护标志
// 3. 我们做了一些处理...
handle_hardware();
// 4. 然后我们直接调用调度器!
schedule(); // 假设schedule()没有任何检查
}3.4.1 第一阶段:调度发生
当schedule()被调用时:
调度器选择新进程
调度器认为当前进程(中断上下文!)可以被换出,选择另一个就绪的进程B来运行。
上下文切换开始
// schedule()内部会: // a) 保存当前"进程"的上下文 // b) 恢复进程B的上下文 // c) 切换到进程B运行
这里出现了第一个严重问题:调度器认为当前是在"进程A的上下文"中,但实际上我们在中断处理程序的栈帧中!
3.4.2 第二阶段:灾难展开
3.4.2.1 问题1:栈混乱
内存布局(简化):
+-------------------+ <-- 高地址
| 进程A用户栈 |
+-------------------+
| 进程A内核栈 |
| ... |
| A的保存现场 | <-- 中断发生时保存的A的寄存器
| 中断处理栈帧 | <-- dangerous_irq_handler的栈帧 ← 我们在这里!
+-------------------+ <-- 当前栈指针SP
| 进程B内核栈 |
| ... |
+-------------------+当调度器执行context_switch():
它会把当前状态(中断处理中的寄存器)保存到进程A的任务结构中
然后加载进程B的状态
切换到进程B执行
但:保存的状态是中断处理函数中的状态,不是进程A被中断时的状态!
还有一点比较重要的是:中断上下文使用独立的中断栈。如果中断处理函数睡眠,栈空间可能被其他进程使用,导致栈数据破坏。
PS: 这里就不细说了,中断栈又是另一个概念,感兴趣的自行百度/google/AI查阅
3.4.2.2 问题2:中断状态未恢复
中断处理被强制中断了:
设备可能处于不一致状态(比如DMA传输到一半)
中断控制器认为这个中断还在处理中
可能屏蔽了同级或低级中断
3.4.2.3 问题3:返回地址地狱
假设中断处理函数最终以某种方式"返回"了:
// 原本的中断返回应该是:
eret // 返回到进程A被中断的位置
// 但现在中断处理被schedule()打断,可能:
// 1. 永远无法执行到eret
// 2. 或者eret时寄存器状态完全错误3.4.3 第三阶段:连锁灾难
情况A:进程B继续运行
进程B完全不知道发生了什么
设备中断状态异常,可能导致:
硬件锁死(比如等待ACK永远不来)
数据丢失(部分处理的DMA数据)
中断风暴(中断不断重发)
情况B:稍后再次调度到"被中断的进程A"
当进程A再次被调度时:
恢复的寄存器状态是中断处理函数中的状态,不是原始状态
程序计数器可能指向中断处理函数中的某个地址
栈指针可能指向混乱的位置
进程A恢复执行时:
+------------------------+
| 想象中的流程 | | 实际发生的流程
+------------------------+ +------------------------
| 恢复寄存器 | ---> | 恢复的是中断处理中的寄存器
| 跳转到被中断的代码 | ---> | 跳转到中断处理函数中部
| 继续正常执行 | ---> | 栈混乱,立即崩溃
+------------------------+ +------------------------3.4.4 第四阶段:具体崩溃场景
3.4.4.1 场景1:立即页错误
# 假设中断处理时保存的PC指向这里:
ldr x0, [x1] # 从x1指向的地址加载
# 但恢复时:
# x1 = 中断处理时的某个值(可能是设备寄存器地址)
# 尝试访问这个地址 → 可能是非法地址 → 页错误3.4.4.2 场景2:栈溢出/下溢
中断处理栈:
+----------------+
| 局部变量1 | ← 中断处理函数的栈帧
| 返回地址 |
| 保存的x29 |
| 保存的x30 |
+----------------+
恢复时当作进程A栈:
+----------------+
| 进程A的局部变量| ← 但这里实际上是中断栈!
| 进程A返回地址 |
| ... |
+----------------+
结果:读写完全错误的内存区域3.4.4.3 场景3:特权级别混乱
# 中断处理在EL1(内核模式)
# 但恢复时可能错误地设置了PSTATE
# 可能以内核特权执行用户代码,或以用户特权执行内核代码3.4.5 时间线推演
T0: 进程A在用户模式执行
T1: 中断发生,保存A的现场到A的内核栈
T2: 执行dangerous_irq_handler
T3: 在handler中调用schedule() ← 灾难开始!
T4: 调度器保存"当前上下文"(实际是handler状态)到A的任务结构
T5: 恢复进程B的上下文,运行B
↓
(一段时间后...)
↓
T6: 再次调度到进程A
T7: 恢复错误的上下文到寄存器
T8: 立即崩溃:页错误、非法指令、栈错误等
T9: 可能触发另一个异常,但异常处理也可能混乱
T10: 系统完全死锁或重启3.4.6 更糟糕的情况:嵌套中断
如果允许在中断中调度,那么:
进程A → IRQ1(开始) → schedule() → 进程B
进程B → IRQ2(嵌套) → schedule() → 进程C
...形成中断上下文的无限分身,每个进程的任务结构中都保存了部分中断状态,但没有一个是完整的。
所以在中断中调度是非常危险的!那么在内核中有什么保护机制呢?
3.5 调度器在源码里怎么拦的
你在 IRQ handler 里一旦走到可能睡眠路径,会发生什么?
所谓“睡眠”,本质是 进入调度器 schedule() / __schedule(),把当前执行体挂起,未来再恢复。
但在硬中断上下文里,调度器不能这么做,因为:
中断不是一个“可调度实体”(没有自己的 task_struct 身份)
当前 CPU 只是“临时插入执行了一段 handler”
handler 不存在“以后再回来继续跑”的合法语义
好像又提了一遍!那我们看一下schedule函数做了哪些动作?
static void __sched notrace __schedule(unsigned int sched_mode){
//..
///判断是否是atomic上下文(硬件中断上下文,软件中断上下文),若是则报dbug
schedule_debug(prev, !!sched_mode);
//...
}static inline void schedule_debug(struct task_struct *prev, bool preempt)
{
// 栈溢出检查
#ifdef CONFIG_SCHED_STACK_END_CHECK
if (task_stack_end_corrupted(prev))
panic("corrupted stack end detected inside scheduler\n");
if (task_scs_end_corrupted(prev))
panic("corrupted shadow stack detected inside scheduler\n");
#endif
// 原子上下文休眠检查
#ifdef CONFIG_DEBUG_ATOMIC_SLEEP
if (!preempt && READ_ONCE(prev->__state) && prev->non_block_count) {
printk(KERN_ERR "BUG: scheduling in a non-blocking section: %s/%d/%i\n",
prev->comm, prev->pid, prev->non_block_count);
dump_stack();
add_taint(TAINT_WARN, LOCKDEP_STILL_OK);
}
#endif
if (unlikely(in_atomic_preempt_off())) {
__schedule_bug(prev);
preempt_count_set(PREEMPT_DISABLED);
}
rcu_sleep_check();
SCHED_WARN_ON(ct_state() == CONTEXT_USER);
profile_hit(SCHED_PROFILING, __builtin_return_address(0));
schedstat_inc(this_rq()->sched_count);
}第22行~25行
#define in_atomic_preempt_off() (preempt_count() != PREEMPT_DISABLE_OFFSET)
// 检查是否处于中断处理中/软中断处理中
if (unlikely(in_atomic_preempt_off())) {
__schedule_bug(prev); /// 如果处于中断处理调用
preempt_count_set(PREEMPT_DISABLED); //强制设置PREEMPT_DISABLED防止进一步调度
}static noinline void __schedule_bug(struct task_struct *prev)
{
/* Save this before calling printk(), since that will clobber it */
unsigned long preempt_disable_ip = get_preempt_disable_ip(current);
if (oops_in_progress)
return;
// 打印熟悉的日志
printk(KERN_ERR "BUG: scheduling while atomic: %s/%d/0x%08x\n",
prev->comm, prev->pid, preempt_count());
debug_show_held_locks(prev);
print_modules();
if (irqs_disabled())
print_irqtrace_events(prev);
if (IS_ENABLED(CONFIG_DEBUG_PREEMPT)
&& in_atomic_preempt_off()) {
pr_err("Preemption disabled at:");
print_ip_sym(KERN_ERR, preempt_disable_ip);
}
// 触发panic
if (panic_on_warn)
panic("scheduling while atomic\n");
// 打印栈
dump_stack();
add_taint(TAINT_WARN, LOCKDEP_STILL_OK);
}所以到这边就到了很熟悉的一句日志,
BUG: scheduling while atomic:把它串起来就是: IRQ 入口
irq_enter()把preempt_count的 hardirq 位加上 → 任何会走到schedule()的 API 最终进入__schedule()→__schedule()发现preempt_count != 0→ 报错/BUG。
这就是“从源码证明中断不能睡眠”的闭环。
3.6 一个典型的“错误堆栈”长什么样?
我这边也没有一个现成的例子,也没有去模拟一个这样是案例,所以下面这个是一个理论模拟的结果
BUG: scheduling while atomic: ...
__schedule(kernel/sched/core.c)
schedule
某个阻塞 API(比如 mutex 竞争、wait_event、msleep、内存回收路径等)
你的驱动中断处理函数(xxx_irq_handler)
handle_irq_event / __handle_irq_event_percpu(kernel/irq/handle.c 一类)
generic_handle_irq / handle_domain_irq
gic_handle_irq(drivers/irqchip/irq-gic-*.c)
el1_irq 入口链路(arch/arm64/kernel/entry.S)看到 __schedule + handle_irq_event + gic_handle_irq + 驱动 handler,这基本就是“在硬中断里睡了”的铁证组合。
四、中断上下文可以使用的API
4.1 内存分配:可以但有限制
// 可以:使用GFP_ATOMIC标志,从不睡眠
void *ptr = kmalloc(size, GFP_ATOMIC);
// 可以:使用kmem_cache_alloc的原子版本
void *ptr = kmem_cache_alloc(cachep, GFP_ATOMIC);
// 不可以:使用可能睡眠的标志
void *ptr = kmalloc(size, GFP_KERNEL); // 可能睡眠!4.2 可用的锁机制
// 可以:自旋锁(spinlock)
DEFINE_SPINLOCK(my_lock);
unsigned long flags;
spin_lock_irqsave(&my_lock, flags);
// 临界区
spin_unlock_irqrestore(&my_lock, flags);
// 可以:读-写自旋锁
DEFINE_RWLOCK(my_rwlock);
read_lock(&my_rwlock);
// 读取临界区
read_unlock(&my_rwlock);
// 不可以:互斥锁(mutex)
// mutex_lock(&my_mutex); // 可能睡眠!
// 不可以:信号量(semaphore)
// down(&my_sem); // 可能睡眠!4.3 延时操作:只能忙等待
// 可以:忙等待(不睡眠)
udelay(10); // 微秒级延时(忙等待)
ndelay(1000); // 纳秒级延时(忙等待)
// 不可以:睡眠延时
// msleep(10); // 毫秒级睡眠
// ssleep(1); // 秒级睡眠4.4 其他可用API
printk:内核打印函数(但注意不要过多使用,原因是另一个知识点,后面应该有同学讲)
ktime_get:获取时间
atomic操作:原子递增、递减等
位操作:set_bit、clear_bit等
列表操作:list_add、list_del等(但需要自旋锁保护)
五、中断上下文禁止使用的常见API
六、中断底半部机制
顶半部(Top Half)
在中断上下文中执行
只做最紧急、必要的工作
保存硬件状态
确认中断
调度底半部处理
底半部(Bottom Half)
在进程上下文中执行
完成耗时操作
可以睡眠
可以使用所有内核API
底半部机制的选择
Linux内核提供了多种底半部机制:
工作队列(Work Queue)
// 最简单通用的方式
struct work_struct my_work;
INIT_WORK(&my_work, my_work_function);
schedule_work(&my_work); // 调度到系统工作队列软中断(Softirq)
// 高性能但固定类型
// 内核预定义了几种软中断类型
// 如:网络接收、块设备、定时器等任务队列(Tasklet)
// 基于软中断,但更易用
struct tasklet_struct my_tasklet;
void my_tasklet_function(unsigned long data) {
// 处理函数
}
tasklet_init(&my_tasklet, my_tasklet_function, 0);
tasklet_schedule(&my_tasklet); // 调度任务线程化中断(Threaded IRQ)
// 中断处理直接在内核线程中运行
int request_threaded_irq(unsigned int irq,
irq_handler_t handler,
irq_handler_t thread_fn,
unsigned long flags,
const char *name,
void *dev);七、总结
中断上下文不能睡眠是Linux内核设计的基本原则,源于中断执行环境的特殊性。违反这一原则会导致系统不稳定甚至崩溃。
✅ 保持中断处理函数尽可能短
✅ 使用顶半部/底半部分离设计
✅ 在中断上下文中只使用GFP_ATOMIC分配内存
✅ 使用自旋锁保护共享数据
✅ 避免在中断处理中打印过多日志
✅ 考虑使用线程化中断处理复杂设备
✅ 测量和监控中断处理时间
❌ 绝不在中断上下文中睡眠
❌ 避免在中断上下文中调用复杂API
❌ 不要阻塞其他中断过长时间