ARTICLE DETAIL

资讯详情

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

彻底搞懂preempt_count与in_interrupt等上下文判断宏

彻底搞懂preempt_count与in_interrupt等上下文判断宏 写内核驱动尤其是处理中断、锁、延时这些路径时我几乎每天都会跟preempt_count()、in_interrupt()这一组函数宏打交道。它们看起来就是几个小判断但用错一个轻则逻辑跑偏重则直接触发 “sleeping function called from invalid context” 或者死锁。这篇文章就把这组上下文判断函数和宏彻底讲透包括位域布局、计数增减规则、每个宏的真实含义以及驱动里怎么拿它做“能不能睡眠、能不能分配内存”的判断。适合内核入门刚接触驱动代码的读者也适合写过一段时间但对preempt_count位域还有些模糊的人。内核里有一段代码可能在进程上下文、软中断、硬中断、NMI 里都能跑到。不同的执行环境对睡眠、锁、延迟函数的要求完全不同。内核没有给每个函数单独传“我现在在哪”的参数而是把当前状态压缩到一个整型字段里这个字段就是preempt_count()。理解了它in_interrupt()等宏就只是几个位运算的查表问题。1. 上下文判断到底在判断什么先说清楚“我在哪”1.1 为什么驱动代码必须回答这个问题驱动里的回调函数往往是“环境不挑食”的。一个被read()调用的辅助函数可能在用户进程直接调用也可能被某个内核线程调用还可能被挂在中断处理器里执行。在这些地方能做的事情差异很大进程上下文里可以睡眠、可以等锁、可以用GFP_KERNEL分配内存中断上下文里这些问题全都反过来睡眠可能直接导致系统挂死因为调度器根本不会在那个 CPU 上运行新任务你睡下去就没机会醒。这里有个很直观的类比进程上下文像你坐在家里等快递想睡一会儿没问题快递员会叫醒你中断上下文像你在高速公路上开车时接了一通重要电话你既不能停车睡觉也不能慢慢处理别的事只能把当前这趟行程最核心的事快速办完。所以代码在决定用GFP_KERNEL还是GFP_ATOMIC、能不能调用msleep()之前先得知道自己在哪个环境。这组判断宏就是内核给开发者提供的“环境自检工具”。它们不是凭空算出来的而是基于preempt_count这个字段的位域做位运算。所以看这些宏之前必须先看preempt_count的内部结构。1.2 preempt_count内核里那张“状态速查表”preempt_count早期放在当前线程的thread_info里现在多数架构把它移到了task_struct的嵌入式thread_info中但接口不变你依然可以像读普通变量一样读它。它的核心设计思想是用一个 32 位整数同时记录几个独立的嵌套计数每个字段占固定几位。之所以这样做而不是用几个独立的 bool 变量一是因为内核热路径上读取当前状态是极高频率操作一次取一个整型再按位判断比读五个变量要快二是这些状态经常同时出现比如硬中断里关闭抢占、软中断嵌套等位域天然支持多状态叠加。理解这一点你就知道为什么in_atomic()只需要判断preempt_count() ! 0因为任何一个计数位非零都代表当前处于不可随意切换的状态。字段的大致分配是低 8 位是抢占嵌套计数接着 8 位是软中断计数接下来 4 位是硬中断计数再接下来 1 位是 NMI 标记再往上 1 位是PREEMPT_ACTIVE标记表示当前正在执行抢占调度流程。各架构可以通过配置调整位数但主流 x86/arm/arm64 使用的就是这套默认分布。内核里这些位由PREEMPT_MASK、SOFTIRQ_MASK、HARDIRQ_MASK、NMI_MASK等宏来访问对应的偏移还有一套SHIFT宏。日常代码其实不需要记住具体数值但理解分布能帮你快速读懂调试信息里打印出来的十六进制preempt_count。1.3 位域布局对照表一眼看懂返回值我把默认 32 位布局整理成一张表方便你对照排查位段偏移掩码十六进制值含义PREEMPT_BITS00x000000FF低 8 位抢占嵌套计数每次 preempt_disable 加 1SOFTIRQ_BITS80x0000FF008-15 位软中断相关计数HARDIRQ_BITS160x000F000016-19 位硬中断嵌套计数NMI_BITS200x0010000020 位NMI 标记PREEMPT_ACTIVE_BITS210x0020000021 位抢占调度流程进行中的标记注意掩码不是总数它们是位掩码。比如hardirq_count()返回的是preempt_count() HARDIRQ_MASK的原始位值最大值时值是 0x000F0000 而不是 15。你要看嵌套层数需要右移HARDIRQ_SHIFT。很多初学朋友直接打印preempt_count()然后看低字节结果半天对不上就是因为没有按MASK去拆。2. 计数是怎么加进去的中断、软中断、抢占的入口出口2.1 硬中断入口出口的计数变化硬件中断发生时内核入口代码会调用irq_enter()这个函数里会对preempt_count增加HARDIRQ_OFFSET。中断处理结束irq_exit()再把它减掉。如果在同一个 CPU 上出现了中断嵌套硬中断计数就会变成 2、3对应HARDIRQ_MASK里相应层数。所以你在硬中断服务函数里看到preempt_count()的 bit16-19 非零是正常现象。NMI 有点特殊。它也是一种异步中断但入口走的是nmi_enter()多数实现里一次计入NMI_MASK和HARDIRQ_OFFSET。这也解释了为什么in_interrupt()在 NMI 下通常也会返回真虽然它的经典定义只检查硬中断位和软中断位。如果要精确判断当前是不是 NMI应该用in_nmi()只看NMI_MASK。这里要留意的是中断处理函数里调用的代码in_interrupt()为真in_atomic()也为真因为它们都基于位域。但两者虽有交集并不是一回事。我在第三节详细讲。2.2 softirq 与 local_bh_disable 的计数差别软中断的计数比硬中断有意思。内核用SOFTIRQ_OFFSET表示“正在执行软中断回调服务”这一状态但关闭 bottom half 用的是SOFTIRQ_DISABLE_OFFSET它的值是2 * SOFTIRQ_OFFSET。为什么要两倍因为这样设计可以区分两种情况当前只是调用了local_bh_disable()关闭了软中断还是正在do_softirq()里执行某个回调。这两种情况对调用者来说差别很大前者你只是临时关了下半部仍然在进程上下文后者你已经在软中断上下文睡眠是绝对不允许的。如果都用同一个位就没法区分了。内核用in_softirq()和in_serving_softirq()两个宏来分别表达这两层含义。in_softirq()判断的是软中断相关位整体非零所以local_bh_disable()临界区内它也为真in_serving_softirq()只精确到正在执行软中断处理一般建议用它来判断“我是否在 softirq 回调里”。很多人的翻车现场就是在local_bh_disable()之后看到in_softirq()为真误以为自己在软中断上下文进而做出错误决策。2.3 解读一个真实的 preempt_count 值假设某个调试点打印出preempt_count 0x00010101按位拆开就是 0x00010000 加 0x00000100 加 0x00000001分别对应硬中断嵌套 1 层、软中断计数 1 层、抢占关闭 1 层。如果再叠加PREEMPT_ACTIVE会看到 bit21 被置位。实际调试时比起直接看十六进制我更喜欢在内核代码里直接打印各组成部分例如pr_info(preempt_count0x%08x preempt%u hardirq%u softirq%u nmi%d\n, preempt_count(), preempt_count() PREEMPT_MASK, preempt_count() HARDIRQ_MASK, preempt_count() SOFTIRQ_MASK, in_nmi());这样能一眼看出哪个位段不为零。注意上面hardirq和softirq打印的还是原始掩码值不是嵌套次数如果需要层数就右移对应SHIFT。我见过不少同事在这里直接把掩码值当层数用结果报 0x10000 层硬中断闹了笑话。3. 常用判断宏逐个拆开定义、含义、坑位3.1 in_interrupt() 和 in_atomic()两个最常用的“问路石”in_interrupt()的经典定义是(hardirq_count() || softirq_count())它回答的问题是“我现在是不是在执行中断处理相关工作”包括硬中断和软中断。tasklet、softirq 回调、hardirq handler 里它都返回真。早期内核里还能看到in_irq()语义基本等同现在的in_hardirq()只是改名了。in_atomic()的定义更简单(preempt_count() ! 0)。只要抢占关闭、持锁临界区、软中断、硬中断、NMI、PREEMPT_ACTIVE 任何一个状态存在它就返回真。所以它回答的问题是“我现在是不是在一个不可被抢占、不适合睡眠的原子上下文里”。这两个宏最容易搞混的地方是in_interrupt()本质上可以看成in_atomic()的一个子集场景但反过来in_atomic()为真的地方in_interrupt()可能为假。典型例子就是spin_lock()保护的临界区自旋锁的实现会关抢占preempt_count()非零in_atomic()为真但你不是在处理中断in_interrupt()是假。所以想判断“能不能睡眠”只看in_interrupt()会漏掉一多半危险场景。3.2 in_softirq() 与 in_serving_softirq()容易误用的兄弟这两个宏我在前面已经提到这里把定义和边界说清楚。in_softirq()检查的是softirq_count()也就是preempt_count()的 8-15 位整体非零。于是下面的场景它的返回值得你注意在local_bh_disable()/local_bh_enable()包围的代码里softirq_count()非零in_softirq()返回真但你其实还在进程上下文。在真正的do_softirq()执行 softirq 回调时in_softirq()也是真。在硬中断里软中断位一般是 0in_softirq()返回假。in_serving_softirq()的实现是(softirq_count() SOFTIRQ_OFFSET)只认SOFTIRQ_OFFSET这一个位也就是真正进入 softirq 处理时由softirq_enter()加的位。这个宏能回答“我现在正在跑一个 softirq/tasklet 回调吗”是判断能否睡眠的更精确工具。所以我建议当你只想判断“我是否在软中断处理过程中”用in_serving_softirq()当你想判断“softirq 相关计数是否非零”比如排查关闭 bottom half 带来的影响用in_softirq()。别互换。3.3 in_hardirq()、in_nmi() 与 preemptible()剩下的三兄弟in_hardirq()就是判断硬中断计数是否非零用来识别硬件中断上下文。in_nmi()看NMI_MASK是最精确的非可屏蔽中断标记。preemptible()则有点特别它判断的是“当前是否处于可以被抢占的状态”经典实现是(preempt_count() 0 !irqs_disabled())。注意preemptible()只有在开启CONFIG_PREEMPTION时才有实际意义在没有配置抢占的内核里它直接返回 0。这个宏适合用在内核工具里判断某个路径是否允许主动让出 CPU驱动代码中并不常用但它能用来补足“可睡眠”的语义能抢占不意味着能睡眠指针还持有自旋锁时抢占也是关闭的所以睡眠判断通常还要和锁状态一起看。把这些宏放一起看它们其实是在同一个preempt_count上进行了不同角度的投影。核心矛盾只有一个当前这段代码是否可以安全地停下来等待。这个判断做对了锁和睡眠的使用就成功了一大半。4. 落到驱动代码怎么判断“能不能睡眠、能不能分配内存”4.1 一个万能判断公式我用了很多年受限于各种历史原因和调用路径很多驱动里的公共函数根本不知道自己会被谁调用。写一个被多处复用的辅助函数时我习惯用这样一行判断static bool can_sleep(void) { return !in_atomic() !irqs_disabled(); }in_atomic()覆盖了所有preempt_count非零的场景包括持锁、关抢占、中断软中断等irqs_disabled()单独补上“本地中断被关但 preempt_count 没有体现”的情况。两层都通过了才敢放心走可能睡眠的分支。为什么必须加irqs_disabled()因为local_irq_disable()只是屏蔽了 CPU 的硬件中断标志并不会改变preempt_count。你在中断关闭状态下调用msleep()中断永远开不起来照样挂死。内核没有把“中断关闭”和“原子状态”合并到一个字段所以判断睡眠合法性时两个条件都得检查。4.2 中断上下文里分配内存GFP_ATOMIC 使用边界最典型的场景是按标志位选择内存分配方式。新手常写这样if (in_interrupt()) buf kmalloc(size, GFP_ATOMIC); else buf kmalloc(size, GFP_KERNEL);这个写法在中断 handler 里是对的可一旦同一个函数被持自旋锁的代码路径调用in_interrupt()就返回假代码走GFP_KERNEL于是kmalloc尝试睡眠触发调度器警告。正确做法应该用原子性判断gfp_t gfp GFP_KERNEL; if (in_atomic() || irqs_disabled()) gfp GFP_ATOMIC; buf kmalloc(size, gfp);这是GFP_ATOMIC的真正使用边界它不只是给中断上下文用的所有不能睡眠的地方包括自旋锁临界区、RCU 读锁临界区、关闭抢占的代码段都应该考虑用原子分配。in_interrupt()没有覆盖到这些场景所以才需要一套更完整的判断。4.3 配合锁使用的几个纪律这些判断宏只能帮你识别环境不能替代锁的正确性。我踩过几次坑之后给自己定了三条纪律写在这里供你参考。第一不要在持有自旋锁的路径里临时睡眠即使preempt_count看起来只是加了 1。因为自旋锁就是靠关闭抢占来防止持有者被换走的你一旦睡眠其他 CPU 上等锁的人会一直自旋系统表现为“一动不动的死锁”。第二不要手工修改preempt_count。内核提供了preempt_disable()、local_bh_disable()、local_irq_disable()等对称接口配套的还有调试检查和调度触发逻辑。直接对计数做位操作相位会乱调度器行为会变得不可预测。第三might_sleep()是比你自己判断更可靠的断言。它不会改变行为只会在不该睡眠的地方打印警告。在可被多种上下文调用的函数里放一个might_sleep()等于给未来所有调用点都加了一道自动安检。我经常在分配内存、等待队列、加信号量的函数入口处放它。5. 常见问题与排查实录5.1 为什么在 spin_lock 临界区内 in_interrupt() 是 false这是群里被问得最多的问题。原因是spin_lock()虽然关闭了抢占但它并没有进中断处理流程HARDIRQ_MASK和SOFTIRQ_MASK都没有被置位in_interrupt()自然为假。in_interrupt()只回答“是否处理中断”不回答“是否原子”。所以如果你看到某段代码在自旋锁里调用了一个使用in_interrupt()做分支的公共函数那这个公共函数的分支判断很可能是错的。正确思路是公共函数统一用原子性判断或者把“是否原子”作为参数由调用方显式传下去。5.2 “sleeping function called from invalid context”是这样追的这个报错长这样BUG: sleeping function called from invalid context at mm/slub.c:200 in_atomic(): 1, irqs_disabled(): 1, non_block: 0, pid: 1234, name: kworker/u8:2解读方法很简单看in_atomic()和irqs_disabled()两个字段。很多警告里会跟着一段dump_stack()顺着调用链往回找几乎每次都能定位到某个函数在原子路径里分配了GFP_KERNEL内存或者调用了会睡眠的锁。我实际排查时发现超过一半的情况是持锁后调用了一个公共函数而这个公共函数内部无条件kmalloc(..., GFP_KERNEL)或者调用了mutex_lock()。排查步骤可以固定为先看报错中in_atomic()的数值非零就说明 atomic context再看调用链中第一个可疑的公共函数然后把该函数里的GFP_KERNEL改成GFP_ATOMIC或者把调用方式改成先取数据再持锁。启用CONFIG_DEBUG_ATOMIC_SLEEP和CONFIG_PROVE_LOCKING能让这类问题早期暴露。5.3 用 preempt_count 调试内核路径的小技巧最后分享几个调试习惯。第一怀疑自己进了中断上下文时直接在代码里打印preempt_count()配合CALLER_ADDR0或者dump_stack()比看少得可怜的注释靠谱得多。第二使用 ftrace 的preemptirqsofftracer 记录关抢占和关中断的时间能找出那些“无意中关了抢占很久”的坏代码。第三内核版本演进过程中这部分位域虽然整体稳定但细节一直在调整新版内核里PREEMPT_ACTIVE的处理方式和老版本不太一样不同发行版内核如果行为对不上先查自己手里的include/linux/preempt.h别拿旧笔记硬套。写完这段我自己最大的感受是这些宏看着简单但它们背后的preempt_count是一个把调度、中断、锁三套机制集中到一个字段里的精巧设计不懂位域判断就是瞎猜。你在自己的代码里用这些宏做分支前先打印一次当前值看看每个位段的真实分布再决定依赖哪个判断这是最不容易出错的办法。
返回列表