凌晨两点半开发板的控制台突然刷出一行BUG: scheduling while atomic: ...紧接着整块板子的中断响应全部错乱。我盯着屏幕看了三分钟才缓过来我的中断处理函数里只是顺手调了一个带睡眠的接口结果直接把调度器惹毛了。这不是段子是我调一个 GPIO 按键驱动时真实踩过的坑也是内核里几乎所有新人都会撞上的一道红线中断处理函数里不能 sleep一旦 sleep调度器就会用scheduling while atomic这串单词当场示众。这篇文章我不打算只讲原理我想把那次排查过程完整还原出来报错现场的日志长什么样、每一行该怎么读、内核到底在哪个环节拦截的、最后我是怎么把修复代码写干净的。如果你正在被类似告警折磨或者只是想搞清楚“为什么中断里不能睡”这份逐行拆解应该能帮你少走几天弯路。1. 事故现场一次普通的中断加锁把调度器直接惹毛了1.1 复现过程一个看起来人畜无害的 mutex_lock当时我在写一个带防抖处理的按键驱动硬件上按键接在 GPIO 上触发方式为边沿中断。按键按下后GPIO 中断处理函数需要把一个事件塞进内核缓冲区同时更新一个计数变量方便用户态程序通过read()读取。为了让用户态读写和中断处理不互相撕扯我顺手在中断处理函数里加了一把mutex_lock。代码结构大概是这样的static irqreturn_t btn_irq_handler(int irq, void *dev_id) { struct btn_dev *btn dev_id; /* 自认为安全加锁保护共享的按键计数 */ mutex_lock(btn-lock); btn-press_count; mutex_unlock(btn-lock); return IRQ_HANDLED; }这段代码第一眼看上去没有任何问题mutex_lock是内核里再常见不过的锁保护一个计数器有什么错问题恰恰出在这里——mutex_lock在锁被占用的路径上是“可以睡眠”的而中断处理函数运行在中断上下文中在这个上下文里调用任何可能睡眠的接口都等于直接闯红灯。板子上电后前几次按键都是好的因为锁没被占用mutex_lock走的是快速路径不睡自然不炸。可一旦用户态的读写线程恰好持锁并睡眠中断处理函数再进来的时候mutex_lock就会进入慢速路径尝试睡眠于是控制台开始刷告警。1.2 控制台上出现的“死亡一行”长什么样那次告警不是常见的 oops 直接崩溃而是一行BUG: scheduling while atomic后面拖了一条完整的调用栈。我把它整理成下面这份脱敏版本基本结构一模一样BUG: scheduling while atomic: kworker/0:1/7/0x00010000 Modules linked in: my_button(O) CPU: 0 PID: 7 Comm: kworker/0:1 Not tainted 5.10.100 #2 Hardware name: my-board Call Trace: dump_stack0x6d/0xa0 __schedule_bug0x3e/0x50 __schedule0x5a/0x5e0 schedule0x3a/0xc0 schedule_timeout0x14/0x130 wait_for_completion_timeout0x47/0x100 my_driver_irq_handler0x44/0x80 [my_button] __handle_irq_event_percpu0x4a/0x180 handle_irq_event0x57/0xa0 handle_fasteoi_irq0xa7/0x140 generic_handle_irq0x27/0x40 __handle_domain_irq0x64/0xc0 ...第一行kworker/0:1/7/0x00010000。三段式进程名是kworker/0:1PID 是70x00010000是preempt_count的十六进制值表示当前处于原子上下文。当时看到kworker我愣了半秒我的驱动跟 kworker 有什么关系后面才反应过来中断处理函数是“寄生”在被打断的任务上下文里的current是被中断打断前正在运行的那个任务因此看到的是 kworker不代表问题出在 kworker。1.3 为什么以前没炸这次炸了并发时序才是关键这种问题最坑的地方在于“偶现”。第一次出现时我以为是硬件抖动直到连续刷了两版面才意识到是代码逻辑问题。原因不复杂mutex_lock是否能进入睡眠路径取决于这个锁当时是否被别的任务持有。如果没持有人CPU 上一条指令就加锁解锁完事了完全不会触发告警。只有当你锁被占用、调用者又处于中断上下文时内核才不得不揭竿而起。这也是这类 bug 在开发和早期测试阶段容易被漏掉的原因单次按键测试大概率锁是空闲的一旦运行时某个进程长期持锁按键中断恰好打断该进程问题就爆了。所以遇到scheduling while atomic第一反应不要是“我的锁是不是没初始化”而是“我在什么上下文里调用了可能睡眠的函数”。2. 中断上下文为什么天生“睡不得”preempt_count 与内核的原子契约2.1 睡眠的本质让出 CPU 需要合法的“身份”要理解为什么中断处理函数不能睡眠先要搞清楚“睡眠”在内核里到底做了什么。一个任务从运行态进入睡眠态不只是自己“趴下”那么简单它要完成至少三件事把自己挂到某个等待队列上、标记任务状态为不可运行、调用调度器选择下一个任务并完成上下文切换。被唤醒时再沿着原来的内核栈恢复执行。这里的关键是“上下文切换”。上下文切换需要一整套完整的任务状态用户态寄存器、内核栈、current指针、任务运行队列等信息。这些东西只有普通进程/线程才完整具备。而硬中断处理函数没有自己的任务结构它借用的是被中断任务的栈和current本质上只是一段“插队”执行的特殊代码执行完必须原路返回中断点。睡眠意味着让出 CPU一旦让出中断点那一大堆寄存器、栈状态就没人帮它保存返回时就彻底抓瞎了。生活化类比是这样的进程睡眠相当于一个人想睡觉他有自己的床可以放心躺下到点再起来。中断处理函数则是你正在排队办事时插队进来的一只手它没有一个“身体”可以躺下如果这只手突然不回去了整个队伍后面的人都不知道该怎么办。2.2 preempt_count内核用来记账的“禁止抢占计数器”内核里专门有一个字段记录“当前能不能被抢占/能不能睡眠”这就是preempt_count。它存在于每个任务的任务结构或线程信息中可以简单地理解为一个多层计数器bit0~bit7显式调用preempt_disable()的次数bit8~bit15处于软中断上下文包括 tasklet的层数bit16~bit19处于硬中断上下文的层数bit20 及以上NMI 上下文的层数位宽的具体划分依赖内核版本和架构x86_64 5.10 上大致是各占一段。你不需要死记这些位但一定要形成条件反射preempt_count非零当前就是原子上下文原子上下文里禁止调度、禁止睡眠。当硬件中断触发时内核进入中断处理流程会把这些计数往上加。加完后调度器看到preempt_count非零就知道“这里是一片不可抢占的区域”。这也是为什么中断处理函数里哪怕调schedule()都不会被正常执行——调度器的第一道检查就是看这个计数。相关判断接口也在内核里随处可见preemptible() // 当前是否可被抢占 in_interrupt() // 是否处于中断上下文 in_irq() // 是否处于硬中断上下文 in_softirq() // 是否处于软中断上下文看到scheduling while atomic告警时可以先在内核代码里加一句pr_info(preempt_count0x%x\n, preempt_count())或者直接看日志里的第三个数字快速判断是哪一层计数不为零。2.3 硬中断、软中断、tasklet三种上下文都不许睡很多人以为只有“中断处理函数”才不能睡眠其实范围更广。硬中断 handler 不允许睡眠这已经说过了从硬中断返回时可能触发的软中断softirq同样在原子上下文中也不许睡基于软中断实现的 tasklet 同样继承了这个限制。只有少数几种“延迟执行”机制运行在进程上下文比如workqueue 的工作线程threaded irq 创建的中断线程kernel thread这些机制的执行者是一个“真正的任务”有自己的栈和任务结构可以安心睡眠。所以驱动开发者通常用它们来接手那些必须睡眠的重活。为方便记忆我把常见的上下文分成了三类执行环境能否睡眠典型例子进程上下文可以system call、workqueue、threaded irq handler硬中断上下文不行request_irq 注册的 handler软中断/tasklet不行softirq 回调、tasklet 回调这里有一个我之前也搞混过的点tasklet 虽然看起来像“延迟执行”但它仍然不是进程没有完整的可切换上下文。所以在 tasklet 里调msleep()一样会被内核痛骂。2.4 如果强行睡下去会发生什么“睡下去会怎样”并不是一个威胁式的提问而是实打实的内核状态机破坏。中断处理函数睡眠后有两种典型结局一种是死锁。中断处理等待某个 completion而这个 completion 需要另一个中断或者同一个 CPU 上的任务来触发如果 CPU 正把中断处理函数挂起后续中断又无法进入相同 handler完成信号永远不会来系统直接卡死。另一种是栈错乱。中断处理函数挂起后调度器切换走了当前任务中断栈上残留的数据结构指向一个不存在的执行现场等它被唤醒再返回时恢复的寄存器、栈指针和内核栈内容可能已经完全对不上轻则触发 oops重则直接 panic。还有一个更隐蔽的影响优先级反转。实时系统中中断优先级最高如果中断 handler 睡在一个被低优先级任务持有的锁上高优先级的中断行为反而被低优先级任务卡住整个实时性设计瞬间坍塌。这也是内核把这个行为定为“BUG”而不是“warning”的根本原因——它不是风格问题是正确性问题。3. 逐行拆解那份 scheduling while atomic 报告3.1 第一行BUG 关键字和三个字段怎么读回到上面那份日志。BUG: scheduling while atomic: kworker/0:1/7/0x00010000逐段拆kworker/0:1当前任务的 comm 字段表示当前 CPU 上被打断的任务是哪个。前面说过它不代表中断发生在内核线程里只是中断恰好踩在它头上。7当前任务的 PID当你要去/proc/7/stack或者看调度统计时用得上。0x00010000preempt_count的原始值。在 5.10 x86_64 这个配置里第 16 位对应硬中断计数为 1说明现在正处于一层硬中断处理中。如果这里显示的是0x00000200附近的值则往往对应软中断或 tasklet 上下文。这个十六进制值不是你写代码时要算的东西但了解它非常有助于判断“告警到底是在硬中断里触发的还是在软中断里触发的”。不同上下文排查方向完全不同硬中断里出现问题重点查request_irq注册的 handler软中断/tasklet 里出现问题重点查网络、块设备或者tasklet_schedule相关的回调。3.2 Modules linked in、CPU/PID/Comm、Not tainted 有什么用日志第二行Modules linked in: my_button(O)列出的是当前加载的内核模块后面带上(O)表示有模块代码在本堆栈中被使用。如果你看到自己的模块名出现在这儿那基本可以锁定问题发生在你的驱动里。CPU: 0 PID: 7是 CPU 编号和 PID。多核系统上看到CPU: 3也不奇怪中断可以路由到任意 CPU关键是这一个编号配合后面的Comm能帮你确定现场是不是被某个实时线程或 kworker 占据。Not tainted表示内核没有被污染还没有别的驱动做非法操作如果看到的是Tainted: P之类说明系统之前已经出现过其他异常排查时要先把脏水排掉再看这个栈。从行文上看这里的信息密度不高但它是内核事故现场的“元数据”。我习惯在复现问题前先看有没有Tainted有的话先重启干净内核否则后面抓到的栈可能受过干扰。3.3 Call Trace 的阅读顺序从下往上“追凶”Call Trace 这一段是整个告警的核心但很多新手会从上往下读然后一脸懵。内核打印调用栈时栈顶在屏幕下方栈底在屏幕上方。你要从下往上看才能知道问题真正从哪个函数进入。在刚才那份栈里自下而上是__handle_domain_irq generic_handle_irq handle_fasteoi_irq handle_irq_event __handle_irq_event_percpu my_driver_irq_handler [my_button] wait_for_completion_timeout schedule_timeout schedule __schedule __schedule_bug dump_stack一眼就能定位到my_driver_irq_handler它调用了wait_for_completion_timeout。这个函数名本身就是“等待某个事件完成并附带超时”等待过程中会睡眠最终通过schedule_timeout()进入调度器。调度器一进门就发现preempt_count非零于是打印__schedule_bug然后dump_stack。所以排查这段栈的通用方法很朴素从下往上找看到第一个带你的驱动名字/模块名的函数基本就是案发现场。3.4 为什么栈顶停在了 schedule_timeout 而不是 mutex_lock关于告警出现位置还有一个高频问题“为什么报告在schedule_timeout上而不是在mutex_lock上”原因是内核做了两级检查。mutex_lock内部如果开启了CONFIG_DEBUG_ATOMIC_SLEEP会通过might_sleep()在函数入口做一次检查这时你看到的会是另一条告警BUG: sleeping function called from invalid context。如果没有开启这个调试选项或者睡眠发生在更深层函数里内核就只能等真正执行到schedule()时再暴露问题。mutex_lock的慢速路径会调用schedule但在调用栈上它不一定总出现。因为mutex_lock可能在锁空闲时直接成功返回不进入睡眠路径只有在锁被占用时才走到schedule。这解释了为什么告警里的函数五花八门wait_for_completion_timeout、schedule_timeout、msleep、kmalloc(GFP_KERNEL)等等本质上它们最后都汇集到了调度器这个统一检查点。4. 调度器在哪里设的卡口schedule_debug 与兜底逻辑4.1 schedule() 入口的检查代码内核在kernel/sched/core.c的__schedule()里做了原子性检查具体逻辑大致是这样的static void __sched notrace __schedule(bool preempt) { struct task_struct *prev, *next; prev current; schedule_debug(prev, preempt); ... }schedule_debug()中会判断preempt_count和睡眠条件如果当前任务处于不可调度状态却调用了调度器就调用__schedule_bug()。__schedule_bug()干的事就是我们看到的那几行打印 BUG 头、打印当前进程、打印模块列表、打印调用栈。代码本身并不复杂但设计含义很深调度器负责的是“切人”它必须保证切人的时刻是合法的。就像你把一个 U 盘从电脑上拔下来之前必须先执行安全弹出调度器在切换任务之前也要检查有没有人还霸占着 CPU 的“原子区域”。4.2 为什么错误会在最深处才暴露你可能会想为什么不直接在入口把所有睡眠函数都拦住因为内核里的睡眠函数太多了而且很多函数内部会根据参数、标志、运行时状态选择是否真的睡眠。比如kmalloc(GFP_KERNEL)正常情况下会睡眠等待空闲页但如果内存充足它也可能不用睡。再比如mutex_lock如果锁空闲它也不会睡。因此内核只能在“真正要发生睡眠动作”的最终路口也就是schedule()做一次最终裁决。CONFIG_DEBUG_ATOMIC_SLEEP开启后might_sleep()会在一众“可能睡眠”的函数入口预检可以在更早的位置报错但它本质上是一种“温馨提示”不是强制卡口。真正无可辩驳的现场依然是scheduling while atomic。4.3 WARN 之后内核做了什么清掉原子计数强行继续很多人看到“BUG”两个字以为系统马上要死其实这个阶段内核还不会立刻 panic。它打印完调用栈后会强制把preempt_count恢复成可调度状态然后继续执行调度器。这样做是为了尽可能保留现场让日志能完整落盘。但“强行继续”不等于“安全通过”因为中断处理函数早该返回它却试图睡下去此时内核栈、中断返回路径都已经不符合预期。后续执行大概率会触发更严重的二次异常很多人看到的Unable to handle kernel paging request at virtual address其实都是这个 BUG 的余波。所以排查时必须把目光放在“第一个 BUG”上而不是后面刷出来的一堆 panic。4.4 常见的黑名单与白名单我在代码审查中经常给新人一张速查表今天也整理一下。凡是可能睡眠的接口都不允许在原子上下文调用类型接口示例原因睡眠延时msleep, ssleep, usleep_range等待目标时间实时睡眠锁mutex_lock, mutex_trylock 之外的大部分 mutex 路径慢速路径睡眠完成量wait_for_completion, wait_for_completion_timeout等待信号量直接睡眠分配kmalloc(GFP_KERNEL), kzalloc(GFP_KERNEL)内存紧张时睡眠用户态操作copy_from_user, copy_to_user可能缺页睡眠调度相关schedule, schedule_timeout主动让出 CPU在中断处理函数里可以安全使用的是这些类型接口示例原因原子分配kmalloc(GFP_ATOMIC), devm_kzalloc(..., GFP_ATOMIC)不睡眠但可能失败自旋锁spin_lock, spin_unlock忙等不睡眠关中断local_irq_save, local_irq_restore不睡眠忙碌延时udelay, ndelay忙等不睡眠但中断里要慎用注意udelay虽然技术上可以在原子上下文使用但是中断处理函数里长时间忙等依然会拖垮系统实时性不到万不得已不要用。5. 修复方案把该睡的事请出中断处理函数5.1 方案一workqueue把工作推给内核线程最直观的修复方式是把“需要睡眠”的事情延后到工作队列中执行。工作队列由内核线程在进程上下文中跑可以放心调用mutex_lock、msleep这些接口。修改后伪代码如下struct btn_dev { struct mutex lock; unsigned long press_count; struct work_struct btn_work; }; static void btn_work_handler(struct work_struct *work) { struct btn_dev *btn container_of(work, struct btn_dev, btn_work); mutex_lock(btn-lock); /* 这里可以放心睡眠、做耗时操作 */ btn-press_count; mutex_unlock(btn-lock); } static irqreturn_t btn_irq_handler(int irq, void *dev_id) { struct btn_dev *btn dev_id; /* 中断上下文只做一件事把工作推给 workqueue */ schedule_work(btn-btn_work); return IRQ_HANDLED; }schedule_work()可以在中断上下文调用它内部只是把 work 挂到系统默认工作队列上真正执行者由内核 worker 线程完成。它解决的问题是“把睡眠危险区移出中断”代价是引入了调度延迟按键中断发生到工作函数真正执行中间可能隔几个调度周期。这里有个容易踩的坑如果你在中断里频繁schedule_work工作函数可能被并发多次调用即使 work 本身在大部分内核版本里被设计为“同一时间只在一个 CPU 上执行”你还是需要保证宿主结构体在中断和工作函数同时访问时不会被释放。常见的做法是在驱动 remove 路径上cancel_work_sync()确保工作函数结束后才释放内存。5.2 方案二threaded irq把中断处理函数变成线程如果你既想保留中断触发机制又想让 handler 拥有完整进程上下文threaded irq 是更漂亮的做法。用request_threaded_irq()注册时主 handler 可以传 NULL内核会默认创建一个中断线程把整个处理流程放进线程里执行。static irqreturn_t btn_threaded_handler(int irq, void *dev_id) { struct btn_dev *btn dev_id; mutex_lock(btn-lock); btn-press_count; msleep(5); /* 这里可以睡 */ mutex_unlock(btn-lock); return IRQ_HANDLED; } /* 使用时 */ ret request_threaded_irq(gpio_to_irq(KEY_GPIO), NULL, btn_threaded_handler, IRQF_TRIGGER_RISING | IRQF_ONESHOT, my-button, btn); if (ret) { pr_err(request_threaded_irq failed: %d\n, ret); }两个参数值得解释一下。第一个 handler 传NULL表示主处理函数使用内核默认的irq_default_primary_handler它的唯一任务就是唤醒中断线程。这样比较干净因为你不需要写一个“啥也不干但必须存在”的 stub。IRQF_ONESHOT的作用是让中断在 thread handler 执行期间保持屏蔽状态防止同一个中断反复触发导致线程重入尤其对边沿触发中断很重要。threaded irq 非常适合那些“每次中断都得处理一定量数据但处理过程中又绕不开睡眠函数”的设备比如 I2C/SPI 总线上的触控屏、温湿度传感器等。对比 workqueuethreaded irq 的中断线程往往能获得相对更好的实时性因为它直接和中断号绑定还可以设置线程优先级。5.3 方案三如果只是临界区用原子操作/自旋锁缩小范围有些场景其实根本不需要睡眠只是我们习惯性地用了mutex。比如中断里只是改一个计数、置一个标志位完全可以用原子操作代替。static irqreturn_t btn_irq_handler(int irq, void *dev_id) { struct btn_dev *btn dev_id; atomic_inc(btn-press_count); return IRQ_HANDLED; }如果临界区需要保护多个变量可以用自旋锁。自旋锁在中断上下文合法因为它不会睡眠只是原地等待。要注意的是持有自旋锁的临界区绝对不能调用任何可能睡眠的函数否则就是从“非法睡眠”升级成“自旋锁持锁睡眠”情况更严重。这类方案的优点是延迟最小、代码最简单缺点是它只能解决“不需要睡眠”的场合。你的业务一旦真的需要睡眠比如要等待 DMA 完成、要等 I2C 总线回复那就老老实实用前两种方案。5.4 三种方案怎么选修这类问题没有银弹选型取决于你的需求。我自己总结了一个判断顺序场景推荐方案中断里只是更新变量/标志原子操作或自旋锁需要做较多数据处理必须等待总线/DMA/锁threaded irq处理任务与中断触发频率没有强实时绑定workqueue需要延迟执行但又不希望创建中断线程tasklet 无睡眠处理如果你在写新代码我建议优先考虑 threaded irq语义清晰处理函数拥有独立进程上下文调试时也容易在栈上分辨。老代码想快速止血先用 workqueue 把睡眠函数搬走再仔细规划锁的归属。5.5 用内核调试开关和 ftrace 验证修复效果修完之后不要只看“不报错了”就交差我通常还会做三件事。先把CONFIG_DEBUG_ATOMIC_SLEEP、CONFIG_PROVE_LOCKING、CONFIG_DEBUG_PREEMPT全部打开重新编译内核并跑压力测试。这三个开关组合起来能把“原子上下文睡眠”和“锁依赖错误”在最早发生点暴露出来。如果开锁检测立不住你的修复大概率只是把症状往后推。再用 ftrace 抓一下中断处理耗时确认 handler 不在中断上下文里做重活。可以挂载 debugfs 后开启相关事件cd /sys/kernel/debug/tracing echo 1 events/irq/irq_handler_entry/enable echo 1 events/irq/irq_handler_exit/enable echo 1 tracing_on cat trace观察handler前面的字段如果irq_handler_entry和irq_handler_exit之间隔着几百毫秒说明还有耗时代码留在中断路径里要继续优化。最后用trace-cmd录制一段包含sched_switch、irq_handler_entry和workqueue事件的现场确认用户态读写按键数据时中断线程/工作线程的唤醒顺序符合预期。这一步能帮你发现“虽然不报 scheduling while atomic 了但唤醒延迟变高”的副作用。6. 我在实际项目里的取舍和经验6.1 什么时候选 threaded irq什么时候选 workqueue做过几个项目之后我对这两个方案有了比较固定的偏好。需要和硬件时序强相关、每次中断都必须尽快完成数据采集的场景我用 threaded irq比如触摸屏、姿态传感器。原因是中断线程与中断事件一一对应逻辑上天然是“设备驱动自己的异步任务”不容易出现多个设备共享工作队列互相挤压的情况。任务本身不紧急、只是不想丢事件的场景我用 workqueue比如按键消抖、温度采样、消息上报。这类任务即使延迟几十毫秒用户也感知不到没必要为它专门开一个中断线程。一个值得注意的细节是threaded irq 的线程并不是完全独立于中断线的。如果设备在suspend期间也能触发中断你需要额外处理中断线程的暂停和唤醒否则恢复可能出问题。workqueue 就没有这种烦恼系统休眠时 worker 冻结机制是现成的。6.2 排查时最容易忽略的两件事第一件事是“排查第一个 BUG而不是最后一个 panic”。scheduling while atomic出现后内核会继续执行一段不可预测的流程后面的 oops 很可能是倒霉的连锁反应。你的驱动代码在哪个函数触发了第一次告警看第一个调用栈才最准确。日志开头如果刷了十几屏先翻到最前面锁定第一处BUG。第二件事是“确认自己到底在哪一层上下文”。我见过有人看到scheduling while atomic就把 handler 里的所有代码都搬走结果问题还没解决因为他真正的睡眠发生在设备子系统的resume回调里而不是中断路径。判断的方法是保留一个临时打印打印in_interrupt()、in_irq()、in_atomic()的返回值确认报错点的上下文类型后再动手改。6.3 一个小技巧让告警发生的瞬间可复现这类问题最难受的是复现困难。我的做法是人为拉大概率在锁被持有的情况下触发中断比如在用户态写一个循环持续持有/dev/button的读锁同时用tools/gpio/gpio-event-mon连续按键触发中断。反复跑几十轮后scheduling while atomic基本必现。复现成功后再把调试开关打开抓完整栈就轻松多了。如果你也正被这种告警折腾记住最核心的一条中断处理函数不是普通函数它是一段没有“身体”的临时执行体。凡是可能睡下的动作都不属于它把它该做的事推迟到真正有“身体”的进程上下文里做问题自然迎刃而解。