news 2026/9/29 17:11:21

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
彻底搞懂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 加 1
SOFTIRQ_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_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是一个把调度、中断、锁三套机制集中到一个字段里的精巧设计,不懂位域,判断就是瞎猜。你在自己的代码里用这些宏做分支前,先打印一次当前值,看看每个位段的真实分布,再决定依赖哪个判断,这是最不容易出错的办法。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 17:11:11

SCI投稿总被拒?从选刊到返修的投稿全流程拆解与实战方法论

“投了8个月,拒了4次”,这句话我实在太熟了。上个月还有位师弟跑来问我:师兄,我是不是就是传说中的“天选拒稿人”?打开投稿系统一看,第四篇稿子刚被编辑退回,连外审意见都没给。他整个人已经处…

作者头像 李华
网站建设 2026/9/29 17:10:58

敏感词检索前端组件实战:树形过滤与多维分析

做内容安全平台前端三年,最近把“敏感词智能检索”模块整个重构了一遍,沉淀了一个可复用的前端组件。这组件做的事情说起来不复杂:在审核后台里,运营同学要快速找到哪些内容命中了敏感词、命中在哪个词、分布在哪些业务线、风险等…

作者头像 李华
网站建设 2026/9/29 17:10:47

稀疏帧音频驱动视频生成:关键帧+插值让数字人口播告别逐帧渲染

前阵子做数字人短视频,遇到一个很实际的问题:音频驱动视频生成的方案不少,但要么嘴型粘合度差,要么一跑起来就是几分钟一帧,想做个长对话根本扛不住。后来我调到一个叫 InfiniteTalk 的项目思路,名字里就带…

作者头像 李华
网站建设 2026/9/29 17:10:46

Agent多轮对话记忆缺失怎么办?Memory分层设计与工程落地实践

我在做客服类 Agent 的时候,碰到过一个印象特别深的场景:用户第一轮说“我住在杭州,孩子上小学三年级”,第五轮问“这周末带娃去哪儿玩合适”,Agent 直接给出一个“推荐您飞往三亚度假”的方案。产品经理当场反问&…

作者头像 李华
网站建设 2026/9/29 17:10:35

VS2013 + C++ 集成 jsoncpp:源码编译、配置与解析实战

简介:一套面向VS2013使用者的C JSON解析入门资源,基于jsoncpp库实现JSON文件的读取与解析,适合需要在Visual Studio中处理JSON数据的开发者参考学习。压缩包内含完整VS2013工程,共33个文件,涵盖jsoncpp的11个头文件、编…

作者头像 李华
网站建设 2026/9/29 17:10:18

InfiniteTalk:用稀疏帧采样实现低成本长时程数字人视频生成

做AI视频生成的人应该都有同感:生成一段几秒钟的短片容易,真正要做成能长时间对话的数字人视频,难点根本不在“生成”这一步,而在所有你以为理所当然的细节——音频和画面的对齐、长时程的稳定性、计算资源的合理分配。我最近把一…

作者头像 李华