写内核驱动,尤其是处理中断、锁、延时这些路径时,我几乎每天都会跟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_BITS | 0 | 0x000000FF | 低 8 位 | 抢占嵌套计数,每次 preempt_disable 加 1 |
| SOFTIRQ_BITS | 8 | 0x0000FF00 | 8-15 位 | 软中断相关计数 |
| HARDIRQ_BITS | 16 | 0x000F0000 | 16-19 位 | 硬中断嵌套计数 |
| NMI_BITS | 20 | 0x00100000 | 20 位 | NMI 标记 |
| PREEMPT_ACTIVE_BITS | 21 | 0x00200000 | 21 位 | 抢占调度流程进行中的标记 |
注意,掩码不是总数,它们是位掩码。比如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_count=0x%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()也是真。 - 在硬中断里,软中断位一般是 0,
in_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是一个把调度、中断、锁三套机制集中到一个字段里的精巧设计,不懂位域,判断就是瞎猜。你在自己的代码里用这些宏做分支前,先打印一次当前值,看看每个位段的真实分布,再决定依赖哪个判断,这是最不容易出错的办法。