AI智能摘要
“中断上下文不能睡眠”是Linux内核开发的底层铁律,源于中断执行期间CPU未切换进程,无法被调度器感知和管理。文章通过深入解析CPU执行模型、调度机制以及arm64中断流程,阐明中断上下文不具备调度实体特征、没有task_struct、不能被调度,也无法安全使用睡眠相关API和某些锁。这样设计避免
此摘要由AI分析文章内容生成,仅供参考。

一、问题的起点:为什么大家都说“中断上下文不能睡眠”?

在 Linux 内核开发中,有一句几乎所有人都听过的“金科玉律”:

中断上下文不能睡眠(Interrupt context must not sleep)

但很多工程师对这句话的理解,仍然停留在“这是个规定”“这是个经验”“这么做会 BUG”这种层面,而没有真正理解:

  • 为什么不能?

  • 如果睡眠了,内核到底会发生什么?

  • 哪些 API 本质上会导致睡眠?

  • 哪些锁能用,哪些锁绝对不能用?

  • 如果业务逻辑就是需要等待/延时,正确的设计方式是什么?

本文档试图从CPU 执行模型、内核调度器、上下文语义等底层逻辑出发,彻底讲清楚这些问题。

二、什么是“中断上下文”?

我们需要确定正确的认知!

2.1 中断不是“函数调用”

在用户态或内核线程中,我们习惯把函数调用理解为:

当前函数 → 保存返回地址 → 执行 → 返回

中断并不是这种调用模型

当硬件中断发生时,CPU 会:

  1. 立刻暂停当前正在执行的指令流

  2. 保存极少量的 CPU 现场(PC / Flags / 部分寄存器)

  3. 跳转到中断向量表指定的入口

  4. 开始执行中断处理函数

此时有一个极其关键的事实:

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.h

  • include/linux/thread_info.h(或相关架构 thread_info 定义)

  • include/linux/irqflags.hinclude/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() 做的最重要的事就是:

  1. 增加 hardirq 相关计数(体现到 preempt_count/irq_count)

  2. 更新一些统计/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()被调用时:

  1. 调度器选择新进程

调度器认为当前进程(中断上下文!)可以被换出,选择另一个就绪的进程B来运行。

  1. 上下文切换开始

    // 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()

  1. 它会把当前状态(中断处理中的寄存器)保存到进程A的任务结构中

  2. 然后加载进程B的状态

  3. 切换到进程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继续运行
  1. 进程B完全不知道发生了什么

  2. 设备中断状态异常,可能导致:

    1. 硬件锁死(比如等待ACK永远不来)

    2. 数据丢失(部分处理的DMA数据)

    3. 中断风暴(中断不断重发)

情况B:稍后再次调度到"被中断的进程A"

当进程A再次被调度时:

  1. 恢复的寄存器状态是中断处理函数中的状态,不是原始状态

  2. 程序计数器可能指向中断处理函数中的某个地址

  3. 栈指针可能指向混乱的位置

进程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

  1. printk:内核打印函数(但注意不要过多使用,原因是另一个知识点,后面应该有同学讲)

  2. ktime_get:获取时间

  3. atomic操作:原子递增、递减等

  4. 位操作:set_bit、clear_bit等

  5. 列表操作:list_add、list_del等(但需要自旋锁保护)

五、中断上下文禁止使用的常见API

API类别

禁止使用的函数

原因

内存分配

kmalloc(size, GFP_KERNEL)

可能触发直接内存回收导致睡眠

锁机制

mutex_lock(), down()

锁不可用时可能睡眠

延时

msleep(), ssleep(), schedule()

显式睡眠

用户内存

copy_from_user(), copy_to_user()

可能触发页错误导致睡眠

文件操作

file_operations中的大部分函数

可能睡眠

网络栈

大部分网络API

可能睡眠

工作队列

schedule_work()

本身可用,但工作函数在进程上下文执行

六、中断底半部机制

  1. 顶半部(Top Half)

    1. 在中断上下文中执行

    2. 只做最紧急、必要的工作

    3. 保存硬件状态

    4. 确认中断

    5. 调度底半部处理

  2. 底半部(Bottom Half)

    1. 在进程上下文中执行

    2. 完成耗时操作

    3. 可以睡眠

    4. 可以使用所有内核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内核设计的基本原则,源于中断执行环境的特殊性。违反这一原则会导致系统不稳定甚至崩溃。

  1. ✅ 保持中断处理函数尽可能短

  2. ✅ 使用顶半部/底半部分离设计

  3. ✅ 在中断上下文中只使用GFP_ATOMIC分配内存

  4. ✅ 使用自旋锁保护共享数据

  5. ✅ 避免在中断处理中打印过多日志

  6. ✅ 考虑使用线程化中断处理复杂设备

  7. ✅ 测量和监控中断处理时间

  8. ❌ 绝不在中断上下文中睡眠

  9. ❌ 避免在中断上下文中调用复杂API

  10. ❌ 不要阻塞其他中断过长时间