拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux内核中断处理函数为什么不能睡眠?scheduling while atomic排查与修复

Linux内核中断处理函数为什么不能睡眠?scheduling while atomic排查与修复

凌晨两点半,开发板的控制台突然刷出一行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_stack+0x6d/0xa0 __schedule_bug+0x3e/0x50 __schedule+0x5a/0x5e0 schedule+0x3a/0xc0 schedule_timeout+0x14/0x130 wait_for_completion_timeout+0x47/0x100 my_driver_irq_handler+0x44/0x80 [my_button] __handle_irq_event_percpu+0x4a/0x180 handle_irq_event+0x57/0xa0 handle_fasteoi_irq+0xa7/0x140 generic_handle_irq+0x27/0x40 __handle_domain_irq+0x64/0xc0 ...

第一行:kworker/0:1/7/0x00010000。三段式:进程名是kworker/0:1,PID 是7,0x00010000是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_count=0x%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或者看调度统计时用得上。
  • 0x00010000:preempt_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 总线上的触控屏、温湿度传感器等。对比 workqueue,threaded 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基本必现。复现成功后再把调试开关打开,抓完整栈就轻松多了。

如果你也正被这种告警折腾,记住最核心的一条:中断处理函数不是普通函数,它是一段没有“身体”的临时执行体。凡是可能睡下的动作,都不属于它;把它该做的事推迟到真正有“身体”的进程上下文里做,问题自然迎刃而解。

返回列表