☰
中断上下文睡眠引发的Kernel panic:从scheduling while atomic到Aiee
2026/10/14 14:57:17 网站建设 项目流程

做Linux内核驱动好些年,最让我心里一颤的字符组合之一就是这行话:Kernel panic - not syncing: Aiee, killing interrupt handler。尤其当它前面或者后面再跟上一句BUG: scheduling while atomic的时候,基本可以判定你在一个“不允许睡觉”的上下文里强行睡了觉,而且睡得非常不体面。这篇文章就是针对这两个报错做的一次完整的crash复盘,拆一拆它们之间的因果链条,也把定位手法和修复思路全部摊开来讲,适合正在写中断控制器驱动、GPIO驱动、外设中断上报逻辑的兄弟参考。

1. 现场轮廓:两个报错信息分别泄露了什么

绝大多数人第一次看到这两个报错是同一屏打印:先是BUG: scheduling while atomic,带出一大段调用栈,紧接着就是Kernel panic - not syncing: Aiee, killing interrupt handler,然后系统要么死机要么进入panic后的重启流程。好多人只记住了后半句的“Aiee”,觉得这是内核发疯时的胡言乱语,其实两个报错各有信息量,分开看才能摸清问题本质。

1.1 第一行“Kernel panic - not syncing: Aiee, killing interrupt handler”

这句话字面意思是:内核发生了不可同步的panic,并且正在杀死“中断处理程序”。注意里面有个非常特殊的词:Aiee。这是内核开发者留在panic路径里的一个非常古老的拟声词,没有实际业务含义,纯粹就是一种“这已经没法收场了”的感叹。它出现在中断上下文中触发panic的场景,代码路径会认为:既然连中断处理程序都在执行过程中发生了无法恢复的错误,那么再去同步磁盘、刷文件系统、等待其他CPU都没有意义,直接进入死循环或者重启。

在实际项目中,这一行往往是“最后判决书”,而不是“病因单”。它的作用是告诉我们:崩在中断处理过程里,且panic之前内核已经没法继续维护正常运行了。很多工程师看到Aiee就慌,其实更应该做的是往上看,找到前面那一段BUG打印或者完整的调用栈,那里才有真正的线索。

1.2 “BUG: scheduling while atomic”不只是一句警告

再看第二行BUG: scheduling while atomic,这个打印通常长这样:

BUG: scheduling while atomic: swapper/3/0/0x00000200 Modules linked in: CPU: 3 PID: 0 Comm: swapper/3 Not tainted 4.19.25+ Call Trace: ...

这里最关键的字段是被调度进程名后面那个十六进制数字,例如0x00000200。它是当前preempt_count的快照,也就是“禁止抢占/嵌套深度计数器”。只要这个值不为0,调度器就认为自己正处在一个原子上下文里,在这里调用schedule()就属于违规。后半段调用栈则是内核留下的“犯罪现场”,会列出从调度点到中断处理函数的完整路径。

注意,scheduling while atomic本身不一定立刻panic,如果内核开了panic_on_oops它可能会直接转成panic,否则可能只是打印后继续运行,但系统已经处于不健康状态,后续死锁、数据破坏、挂起随时可能出现。拿我们这次的案例来说,它和后面的Aiee是紧密绑定的两级事故:先检测到原子违规,再在中断上下文里进一步恶化,最终触发panic。

1.3 它们为什么经常一起出现

从因果上看,这两个报错往往是一条链上的。典型剧情是:某个外设中断来了,驱动注册的中断处理函数(hardirq handler)想顺手完成一点“收尾工作”,但它调用了一个会让当前任务睡眠的函数,比如kmalloc(..., GFP_KERNEL)、mutex_lock()、down_interruptible()等。睡眠就意味着要发生进程切换,而进程切换必须经由调度器;调度器一看当前preempt_count非零,立刻认定这是非法调度,于是打出BUG: scheduling while atomic。

如果这个违规发生在中断上下文里,那么问题就非常严重了,因为中断处理程序不是一个正常进程,它没有可被安全切换的完整上下文。一旦继续执行到某些更深层的路径,触发了panic(),内核就会带着Aiee, killing interrupt handler彻底趴窝。所以你看这两个报错,一个是“检测到违规”,一个是“违规后无法补救”,本质是同一个病因的两个阶段。

2. 根因:中断上下文凭什么被内核当作“不能睡”的原子区

很多刚接触内核驱动的人会有一个疑惑:我在中断里多干点活怎么了?又不是故意搞破坏,只是调用了平时常用的内存分配和加锁,为什么内核反应这么大?要回答这个问题,需要先搞清楚“原子上下文”在内核里到底是什么地位。

2.1 从preempt_count看“原子”的定义

内核中的“原子”并不是说这段代码一定不会被打断,而是说这段代码内部不允许主动让出CPU。实现这一约束的核心机制就是preempt_count。这个字段里记录了好几类状态:当前是否被禁止抢占、当前是否处于硬中断处理、当前是否处于软中断处理、是否持有自旋锁等等。只要这些状态有一项被置位,调度器就知道现在不是切换进程的好时机。

拿硬中断来举例:当一个中断到达,CPU会跳转到该中断对应的handler执行,此时preempt_count中硬中断相关的计数就会增加。在这个状态下,CPU不属于任何一个正常进程,它是在替硬件“临时办事”。你可以理解成正在路口处理事故的交警:他已经站在马路中间了,这时候突然决定“我先跑旁边超市买个水再回来处理”,那整个路口立刻就乱了。硬中断handler就是那个站在路口的人,他必须快速处理完、撤离,把路口交还给正常的车流。

2.2 硬中断里发生了调度会怎样

如果在硬中断handler里调用schedule()或者任何会触发调度的函数,会发生一连串的问题。第一,中断处理程序使用的栈通常是专门的中断栈,或者与被打断进程共用内核栈,但它本身并没有一个完整的“进程上下文”可供调度器保存和恢复。调度器的核心假设是:每个正在运行的任务都隶属于某个进程结构,可中断上下文里这个假设不成立。

第二,如果该进程此时正好持有自旋锁,问题就更麻烦了。自旋锁的语义就是让等待者原地打转等待持有者释放,如果持有者在持有期间把CPU让出去,那么其他CPU上等待这个锁的任务就会一直死等,轻则长时间卡住,重则形成死锁。调度器在检测到这种调用时选择报警,本质上是在说:“兄弟,你破坏了系统最基本的运行规则,再这样下去所有CPU都会陷入混乱。”

第三,中断返回路径本身有自己约定好的流程,它要恢复被打断任务的寄存器、状态、栈指针,而不是去恢复一个“新任务”。如果在中断里强行发生了调度,返回路径就会踩到一堆无效状态,最终多半是panic收场。所以内核才会把中断上下文、自旋锁持有区间、RCU读侧临界区这几种场景统统归类为“原子上下文”,在这个范围内禁止一切睡眠操作。

2.3 为什么很多人会在这里栽倒(看起来无害的调用)

从实际踩坑经验看,几乎没有人是故意在中断里写调度代码的。都是觉得“这里只是顺手加个锁”“分配一小块内存而已”,结果就把整个系统搭进去了。比如我最近在定位的问题,项目里用的是一块某信号采集设备的中断控制器驱动,中断到来后要解析一个状态寄存器,然后需要把采集到的事件上报给用户态程序。

最初版本的中断handler长这样:

static irqreturn_t bad_gpio_handler(int irq, void *dev_id) { struct gpio_dev *dev = dev_id; char *buf; unsigned long flags; spin_lock_irqsave(&dev->lock, flags); dev->last_type = readl(dev->base + REG_STATUS); spin_unlock_irqrestore(&dev->lock, flags); /* 这里开始踩雷 */ buf = kmalloc(PAGE_SIZE, GFP_KERNEL); if (!buf) return IRQ_HANDLED; mutex_lock(&dev->state_mutex); dev->read_buf = buf; mutex_unlock(&dev->state_mutex); return IRQ_HANDLED; }

一眼看过去逻辑很顺:读状态、分配内存、加锁、保存指针。但问题就出在kmalloc(..., GFP_KERNEL)和mutex_lock()上。GFP_KERNEL允许内存回收时睡眠等待,mutex_lock本身就是睡眠锁,这两者在硬中断handler里都等同于在中环路上突然熄火停车。系统最终崩溃时的打印,就是本文标题里那两个经典报错。

3. 排查链路:一个真实CASE的反向追踪

很多驱动工程师遇到这种crash,第一反应是去代码里“找看起来不对的地方”,但这样效率太低。正确的做法是像刑侦一样,先把现场固定住,再顺着调用栈一路逆向追到违规点。下面是我在这次问题中实际走的排查链路,每一环都有明确目的。

3.1 先保住现场再说

遇到内核crash,第一动作不是改代码,而是尽可能完整地保留现场。最基本的要求是:内核必须开了CONFIG_KALLSYMS和CONFIG_KALLSYMS_ALL,否则打印出来的调用栈全是地址,无法对应函数名。其次建议打开CONFIG_DEBUG_ATOMIC_SLEEP,这个选项是专门用来检测“原子上下文里调用可睡眠函数”的,打开了它,很多本会让系统挂死的场景会在打印BUG后提前暴露。

另外还要设置好串口或netconsole,保证panic前的最后一段dmesg能够完整保留。如果不方便接串口,可以提前配置kdump,用crash工具在vmcore里复盘。这次我们项目是设备裸机部署,没有接调试器,我就把console=ttyS0,115200和nmi_watchdog=panic一起加到内核启动参数里,这样崩溃日志能完整落到串口记录里,不至于断在半路。

3.2 从栈回溯把违规点拉出来

拿到日志后,重点看从BUG: scheduling while atomic开始的那段调用栈。这次日志里的栈大约长这样:

BUG: scheduling while atomic: swapper/3/0/0x00000200 Call Trace: __schedule_bug schedule schedule_preempt_disabled mutex_lock bad_gpio_handler gpio_irq_handler ...

别看前面又是__schedule_bug又是schedule,真正要盯住的是再往下的函数名:bad_gpio_handler。它就是中断处理函数,而它调用的mutex_lock就是罪魁祸首。这里面有个排查习惯非常关键:不要只看栈顶,栈顶只是调度器报警的地方,真正的违规点通常在栈的中间靠下位置,也就是“谁调了那个不该调的函数”。把这条调用链展开后,很快就能定位到具体驱动文件。

为了拿到更细的栈,我还给内核加上了CONFIG_DEBUG_INFO和CONFIG_FRAME_POINTER,重新编译后栈回溯的准确性会好很多。之前遇到过栈回溯不完整的情况,完全靠猜。开启这些选项后,问题从“大概知道是驱动”缩小到了“具体到某一行调用”,速度提升非常明显。

3.3 CONFIG_DEBUG_ATOMIC_SLEEP:让“睡意”第一时间暴露

这里要专门说一下CONFIG_DEBUG_ATOMIC_SLEEP为什么重要。没开这个选项的时候,内核并不总会在原子上下文调用睡眠函数的瞬间拦截,有些函数会一路执行到真正的schedule()才被发现,而有些路径根本不会触发打印,系统只是莫名其妙地卡死。开了它之后,内核会在涉及睡眠的关键函数入口处增加检查,一旦发现preempt_count不为零,立刻打印类似这样的信息:

BUG: sleeping function called from invalid context at mm/slub.c:XXXX

这句话和scheduling while atomic是姊妹报错,都是原子上下文睡眠问题的不同表现。前者更精准,能指明是在哪个内核函数里检测到非法睡眠。排查时看到这行输出,就等于吃了一颗定心丸:根因方向八九不离十了。我建议所有开发板、调试内核都默认把这个选项打开,虽然会增加一点点运行时开销,但换来的是把“神秘挂死”变成“明确报错”,这笔买卖非常划算。

3.4 用ftrace把嫌疑函数锁定

有时候调用栈会因为编译器优化、inline展开等原因变得不完整,光看栈不一定能锁定最后一行“真凶”。这时候我习惯用ftrace再补一刀。核心思路是在关键函数上挂tracepoint,专门抓到schedule()之前是被谁调过来的。

实际操作上,可以在/sys/kernel/debug/tracing/下操作,启用function跟踪器并设置过滤器:

echo bad_gpio_handler > /sys/kernel/debug/tracing/set_ftrace_filter echo function > /sys/kernel/debug/tracing/current_tracer echo 1 > /sys/kernel/debug/tracing/tracing_on

然后复现一次中断触发。完成后关闭跟踪,取出trace文件,里面会显示中断到来后从bad_gpio_handler开始的函数调用序列。如果日志里能看到mutex_lock后面跟着schedule,而schedule之后就是sched_tick或者finish_task_switch这类的调度路径,那基本就能确认:中断上下文里执行到了调度逻辑,违规点清晰可见。

ftrace还有个好处是能复现“偶发问题”。有时候问题不是每次中断都触发,而是跟内存水位、锁竞争有关。用ftrace把统一复现条件记录下来,对比多次触发之间的差异,往往能找到隐藏的竞态条件。

4. 修复实践:把中断路径改成能安稳“睡觉”的跑道

定位到问题之后,修复思路其实非常清晰:要么让中断处理程序根本不调用会睡眠的东西,要么把需要睡眠的工作挪到允许睡眠的上下文里去执行。下面这个环节我做了三种方案的对比,最终根据场景选定了threaded irq。

4.1 方案对比:workqueue、threaded irq与纯原子处理如何选

当时团队里有人提议直接把handler里的内存分配和加锁删掉,中断只置一个标志位,然后交由主循环去处理。这个方案确实最“原子”,但缺点也很明显:事件上报的实时性变差,而且如果中断频繁到来,主循环可能永远在处理旧事件,导致事件堆积。最后没有采用。

后来又对比了workqueue方案:handler里用schedule_work()挂一个工作项,由内核工作线程在进程上下文执行具体的分配、加锁、上报动作。这个方案可行,但在高频率中断场景下,每来一次中断就调度一次work,工作项可能重叠,需要自己做好去重和状态保护,代码复杂度会上来。

最终使用的是threaded irq方案。它本质上是把中断处理拆成两半:上半部只做必要的硬件响应和置位,下半部运行在一个独立的内核线程里,天然允许睡眠。用request_threaded_irq()注册时,可以挂一个thread_fn,这个函数运行在进程上下文,想怎么kmalloc、怎么mutex_lock都没问题。对这次这种“中断要处理一定量数据上报”的场景,threaded irq是味道最对冲的选择。

做个简单的对比表,大家按场景选:

方案执行上下文是否允许睡眠典型适用场景
常规hardirq handler原子上下文不允许只做读状态、清中断、置标志
tasklet / softirq软中断上下文不允许睡眠延迟要求高但工作量小的处理
workqueue内核线程(进程上下文)允许较重的处理,但需要自己管理去重
threaded irq独立内核线程(进程上下文)允许中断下半部整体较重,且逻辑清晰
纯原子处理原子上下文不允许用无锁数据结构和原子变量临时缓存

4.2 核心改动示例:从request_irq改造到request_threaded_irq

这次的具体改动,是把原来的request_irq那段替换成threaded irq。关键代码大致如下。

中断上半部只保留最简单的硬件动作:

static irqreturn_t bad_gpio_handler(int irq, void *dev_id) { struct gpio_dev *dev = dev_id; unsigned long flags; spin_lock_irqsave(&dev->lock, flags); dev->last_type = readl(dev->base + REG_STATUS); writel(REG_CLEAR, dev->base + REG_STATUS); spin_unlock_irqrestore(&dev->lock, flags); return IRQ_WAKE_THREAD; }

紧接着是线程化处理函数,所有涉及睡眠的操作都放在这里:

static irqreturn_t bad_gpio_thread(int irq, void *dev_id) { struct gpio_dev *dev = dev_id; char *buf; unsigned int type; type = dev->last_type; buf = kmalloc(PAGE_SIZE, GFP_KERNEL); if (!buf) return IRQ_HANDLED; mutex_lock(&dev->state_mutex); dev->read_buf = buf; dev->read_type = type; mutex_unlock(&dev->state_mutex); return IRQ_HANDLED; }

注册的调用改成:

ret = request_threaded_irq(irq, bad_gpio_handler, bad_gpio_thread, IRQF_TRIGGER_RISING | IRQF_ONESHOT, "gpio_capture", dev);

这里IRQF_ONESHOT比较关键,它会保证在thread函数执行完毕之前,该中断源保持屏蔽状态,不会出现同一个中断反复进入上半部的情况。对这次场景来说,这个标志让逻辑简洁了很多,不用自己维护中断屏蔽状态。

4.3 修复后的验证:怎么知道自己真的好了

修复完不是跑一次就行,还要做比较严谨的验证。我习惯分三步走。

第一步是静态检查。用checkpatch.pl和smatch扫一遍改动代码,重点关注是否有在spin_lock_irqsave和spin_unlock_irqrestore之间调用睡眠函数的情况。虽然工具扫不出所有问题,但能挡住大部分低级错误。

第二步是动态压测。设备连续跑一段时间的中断触发,日志里持续观察是否有新的BUG: scheduling while atomic或者BUG: sleeping function called from invalid context。同时观察/proc/interrupts,确认中断次数正常增长,thread函数确实被调用。

第三步是故意做一次破坏性测试:把CONFIG_DEBUG_ATOMIC_SLEEP打开,在巨大的中断压力下跑内存回收、大量并发任务调度,看看是否会有隐藏的原子违规。这一步最能暴露竞态。最终我们改造后的代码在持续48小时的压测里,日志干净,中断响应正常,问题算是彻底闭环了。

5. 延伸:原子上下文违规者的常见行为清单

这次定位只是解决了一个中断里的睡眠问题。但“原子上下文里不能睡眠”这条规则覆盖的场景远不止中断handler,很多驱动代码会在各种边角料路径里踩到类似的坑。我整理了一份平时review代码时会对照的清单,分享给大家。

5.1 这些函数在中断里几乎都踩雷

函数/宏为什么危险
kmalloc(..., GFP_KERNEL)可能进入内存回收和直接回收路径,发生睡眠
vmalloc()分配页表、映射内存时可能睡眠
mutex_lock()标准睡眠锁,持有者不释放就不会返回
down_interruptible()/down()信号量获取路径允许睡眠
copy_to_user()/copy_from_user()访问用户内存可能触发缺页,睡眠等待
synchronize_rcu()需要等待RCU读侧临界区结束,属于阻塞操作
usleep_range()/msleep()显式睡眠,直接触发调度

注意udelay()和mdelay()是忙等待,不是睡眠,理论上可以在原子上下文里使用,但它们会空转CPU,如果用时太长,一样会把系统拖垮。所以我的习惯是:中断handler里能不用就不用。

还有一个容易被忽略的点:printk()要不要列入黑名单?在普通硬中断上下文里,printk通常是可用的,但需要关注日志输出速率,如果中断频率很高且每条都打印,日志子系统内部也要加锁、可能要阻塞等待console设备写完,极端情况下会影响实时性。如果是在NMI上下文里,printk就更不能乱用了。稳妥起见,中断处理路径上尽量只打印极少量的关键信息,或者用tracepoint代替。

5.2 几个特别容易误判的点

第一,自旋锁保护区间内也不能调度,但很多人只记住了“这是锁”,没意识到自旋锁在持有期间等价于关闭了抢占,preempt_count非零。所以在spin_lock到spin_unlock之间调kmalloc(GFP_KERNEL)同样是违规的。

第二,tasklet和softirq看起来好像能“排队执行”,但它们的执行上下文依然是原子性的。在tasklet里调用msleep,不会像普通进程一样乖乖休眠,而是会直接触发调度器报警。这一点比硬中断里的坑隐蔽得多,因为代码不是那种一眼可见的中断handler,很多工程师会忽视。

第三,workqueue不一定永远安全。虽然workqueue是进程上下文可以睡眠,但如果你在workqueue函数里又关闭了抢占、拿了自旋锁,然后再调用会睡眠的函数,同样会触发scheduling while atomic。上下文是否原子,取决于你当前在哪里,而不是当前线程是谁。

5.3 通用防御措施与团队评审建议

经过这次踩坑,后来我在代码评审里会特别注意几个点。第一,所有中断handler的函数签名旁标记“原子上下文”的注释,并在评审时逐行检查有没有调用可睡眠API。第二,内核配置里统一开启CONFIG_DEBUG_ATOMIC_SLEEP和CONFIG_PROVE_LOCKING,新驱动合入主线前必须无报错跑完一遍基础测试。第三,凡是中断处理里涉及内存分配、锁、唤醒用户态操作的,一律优先考虑threaded irq或者workqueue,不要在hardirq里逞强。

还有一个预防性设计:把硬中断handler做成“薄片”。它只负责读寄存器、清中断标志、把关键数据写入一个无锁缓冲区或者原子变量,然后调用IRQ_WAKE_THREAD唤醒线程。所有需要花时间处理的事都搬到thread里。这个设计不是万能的,但能规避绝大多数原子上下文违规问题。

我在实际项目中还有一个小习惯值得一提:保留几份经典的错误日志在团队的wiki里,新同学遇到类似报错时先去对照“症状库”,而不是直接上手改代码。像Aiee, killing interrupt handler这种报错,只要看过一次,下次再遇到基本就能立刻联想到中断上下文的睡眠问题。排查crash这件事,很多时候拼的就是“见过多少种死法”的经验积累。

这次从定位到修复,整个过程不算复杂,但教训很深:中断处理函数不是普通函数,它对执行环境有非常严格的要求。写驱动时,宁可把逻辑拆得碎一点,也不要为了图省事把睡眠操作塞进原子上下文。毕竟运行在真机上的内核,可不会因为你的代码“看起来没问题”就网开一面。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询