Linux内核模块使用计数:从rmmod拒绝到引用计数排查

发布时间:2026/10/13 7:18:22
Linux内核模块使用计数:从rmmod拒绝到引用计数排查 写驱动的人几乎都遇到过这种对话# insmod demo.ko # 测试... # rmmod demo ERROR: Module demo is in use刚把自己写的字符设备加载进内核测完功能准备卸载结果内核板着脸拒绝。很多人第一反应是“谁打开了我是不是lsof能看到”于是在用户态翻来覆去找进程最后发现谁也没打开文件可模块就是卸不掉。真正回答这个问题的是内核模块机制里一个非常基础、却经常被误解的字段——模块使用计数module usage count / refcnt。模块使用计数是内核判断“这段代码还能不能安全卸载”的核心依据。我见过不少驱动开发新手在这里栽跟头有的在 open 里乱调 module_put有的用 try_module_get 给自己“续命”防卸载最后把模块变成烫手山芋。这篇文章想把它讲透——计数是怎么加的、谁在加、计数非零时怎么定位、以及模块作者最容易踩的坑。1. “Module is in use”背后的计数逻辑内核到底在查什么1.1 一次 rmmod 拒绝背后的判断链路当你执行rmmod demo.ko时用户态工具最终会发起一个delete_module()系统调用。内核在这个调用里做了好几件事检查模块当前状态、拿一个临时引用防止模块在被卸载的瞬间又被谁拉起来、然后检查模块引用计数。如果引用计数不是 0直接返回-EBUSYrmmod 就把错误翻译成那句经典提示“Module demo is in use”。这里的“in use”并不是内核在跟你打哑谜它只知道一件事当前还有 N 条路径在用这块模块的代码或数据现在卸载会导致这些路径变成悬空指针。至于占用者是谁内核默认不告诉你因为从设计上说它只负责“保护代码别在有人用的时候被换掉”剩下的排查工作要我们自己来做。顺带一提如果引用计数检查通过了模块会先进入GOING状态此时任何新的try_module_get都会失败。这个细节很重要后面讲竞态的时候会再碰到。1.2 使用计数不是进程数是引用路径数一个常见的思维误区是“我明明没有打开设备节点为什么计数不为 0”因为引用计数统计的并不是“有几个进程在用”而是“有几条引用路径还活着”。举几个例子一个进程打开同一个设备节点两次就会有 2 个struct file对象引用计数就是 2一个内核线程在设备驱动里注册的回调函数里工作这也是一条引用路径另一个内核模块使用了当前模块导出的符号这也可能让当前模块的引用计数加 1文件系统被挂载、网络设备被 UP同样会在对应子系统里形成引用路径。打个比方引用计数就像自习室的门禁卡。每个想使用自习室的人先刷卡再进门出门时再刷一次卡。管理员只关心“还有多少张卡没还”而不会关心里面坐着的是张三还是李四。所以哪怕自习室里空无一人只要有人把卡忘在了桌上管理员就不能关门——模块里那部分代码就是“自习室”忘掉的卡就是泄漏的引用。1.3 谁来增加、谁来减少三个层面的维护者引用计数的增减不完全由驱动作者手动控制它一共有三个层面的“记账人”驱动代码自己当你明确知道某段逻辑需要长期存活、且可能跨越模块卸载时机时需要手动try_module_get用完再module_put。这是最需要小心的部分。内核框架自动维护最典型的是文件操作接口file_operations。字符设备被打开、文件被读写、甚至某些子系统注册回调内核会在约定好的入口自动给你添上引用计数在出口自动归还。模块间依赖维护模块 A 使用模块 B 导出的符号内核在加载 A 时通常会记录依赖关系通过symbol_get这类接口跨模块取符号时同样会关联到模块 B 的引用计数上。理解了这三个层面你基本就明白排查方向了先看是不是自己代码里多了个 get 没 put再看是不是框架自动计数没释放最后看模块间的符号依赖。2. try_module_get 与 module_put最常用的两条生命线2.1 三件套两个函数、一个宏模块代码里最常用的引用计数操作就是下面这三样#include linux/module.h bool ok try_module_get(THIS_MODULE); if (!ok) return -EBUSY; /* 在这里安全地使用模块的代码和全局资源 */ module_put(THIS_MODULE);try_module_get(struct module *mod)尝试增加模块引用计数。成功返回 true失败返回 false。失败意味着模块很可能正在卸载已经进入GOING状态。module_put(struct module *mod)释放一条引用计数。THIS_MODULE是当前模块的struct module *指针写驱动时基本都会用到它。2.2 try 的语义为什么重要很多人第一次看到try_module_get时觉得奇怪为什么不叫module_get因为这里面的“try”不是客气而是真的会失败。考虑这个错误写法if (module_is_live(module)) { try_module_get(module); }在module_is_live判断通过之后、try_module_get执行之前可能有另一个 CPU 正在执行rmmod模块已经切到GOING状态。这时你的try_module_get就会失败。所以正确做法是直接把判断和获取合成一步用try_module_get的返回值判断而不是先查状态再取引用。内核里大量并发 bug 就出在这种“先检查后使用”的窗口期。引用计数接口在这里已经帮你把两步合并了没必要自己拆开再踩一遍。2.3 底层实现从单一计数器到 per-CPU 聚合如果好奇过底层实现不同内核版本差异还蛮大。早期版本里模块引用计数就是一个简单的原子计数器atomic_t所有 CPU 都在同一个变量上做原子加减高并发场景下缓存行颠簸比较明显。现代内核则把模块引用计数改成了per-CPU 计数器。每个 CPU 维护自己的一份计数module_refcount()会把所有 CPU 上的数值聚合起来返回总和。这样try_module_get在绝大多数路径上只是操作本 CPU 的计数避免了全局原子锁竞争。这个演进对驱动作者的影响是不要直接访问struct module内部的 refcnt 字段它可能不再是单一变量不同版本布局完全不同。想拿当前计数用module_refcount(THIS_MODULE)这个宏/函数就好。另外在高并发下看到计数数值跳动很大是正常的聚合值本身有波动关键是看趋势和对称性。2.4 别碰的“捷径”__module_get 与 symbol_get内核里还存在一些更底层的接口比如__module_get()。它和try_module_get的区别是不检查模块状态无脑增加计数。这看起来省事但要求调用者能百分之百保证模块此刻不可能正在卸载。对绝大多数驱动代码来说你并没有这个保证所以别贪图方便用它。symbol_get/symbol_put是跨模块获取导出符号的接口它会顺带把对应模块的引用计数管理起来。如果你在自己的模块里把函数用EXPORT_SYMBOL导出去给别人用就要意识到对方可能通过符号依赖持有你的模块引用这也是holders目录能看到占用者的重要来源。3. 不用你手动调的引用fops-owner 与文件生命周期3.1 fops 里的 owner 字段绝大多数驱动作者第一次接触引用计数不是通过try_module_get而是因为写了这么一段代码static const struct file_operations demo_fops { .owner THIS_MODULE, .open demo_open, .release demo_release, .read demo_read, .ioctl demo_ioctl, };这个.owner字段就是给内核框架看的“这个文件操作属于哪个模块”。当用户态进程打开对应设备节点时VFS 层在do_dentry_open()里会对fops-owner执行一次try_module_get当文件对象最终被释放、走完release回调后VFS 会对fops-owner执行一次module_put。这意味着什么你只要把.owner填对了读写、ioctl 这些操作根本不会反复增减计数。模块引用从 open 成功那一刻起被持有到文件对象销毁时释放整个生命周期是连续的。3.2 open 失败时系统其实已经替你“收卡”了有个坑在这里藏了很久当open回调返回非 0 错误码时VFS 同样会执行module_put(fops-owner)。因为此时文件对象尚未正式建立内核需要把自己在入口处坐的引用还回去。于是很多老式驱动里会出现这种重复释放static int demo_open(struct inode *inode, struct file *filp) { if (某个条件不满足) { module_put(THIS_MODULE); // 画蛇添足会造成双扣 return -EBUSY; } return 0; }VFS 在demo_open返回-EBUSY之后已经做了一次module_put驱动自己又做了一次引用计数就会从原本正确的 0 变成 -1或者在内核字段上显示成一个巨大的无符号值。后果就是模块明明没有被任何文件引用rmmod却永远提示 “Module in use”。同理release回调执行的时候VFS 还没有执行module_put所以你在 release 里再调module_put(THIS_MODULE)一样是双扣。早期内核版本驱动里常见MOD_DEC_USE_COUNT这类手动计数宏那个时代确实需要手动释放现代内核的 fops 路径已经全部自动化了别把老代码习惯直接搬进来。3.3 一旦替换 f_op自动计数就出现盲区很多驱动并不满足于简单的文件操作它们在open回调里会做一件“危险动作”替换filp-f_op。比如根据打开模式选择不同的 ioctl 表或者封装一层私有的操作集合。当你执行filp-f_op inner_fops;VFS 的自动计数管不到这一下。如果inner_fops和原来的 fops 属于同一个模块两者 owner 相同计数大体上还能平衡如果inner_fops属于另一个模块麻烦就来了——旧模块在 open 时被系统持有的引用没有名字新模块又从未在 open 入口被try_module_get过。等到文件关闭时VFS 只对最终的inner_fops-owner做 put旧模块的引用就永远泄漏了。所以一旦你的驱动需要替换f_op请务必自己管理好引用计数对新的 fops owner 执行try_module_get并检查返回值对旧的 fops owner 执行module_put。不少子系统内部就是这么干的但那是框架代码在负责到了你自己的驱动里责任就得自己扛。3.4 其他子系统也有同样的“自动持有”思路字符设备只是最典型的一个。TTY 子系统、块设备、网络设备、声卡等都有自己的生命周期绑定逻辑本质上是同一个模式入口处获取引用出口处释放引用。比如 TTY 驱动在设备被打开时会引用对应模块网络设备在注册和 UP 时会关联模块的 owner 字段。你不一定需要记住每个子系统内部的具体函数但可以建立一个判断习惯当某个设备节点、网络接口或内核对象能被用户态或内核态触达时背后几乎一定有条引用链路在保护模块。莫名其妙卸载不掉先往“对象还活着”的方向想而不是只盯进程列表。4. usage count 不为零三条线索定位占用者4.1 第一眼三处状态文件遇到rmmod拒绝第一件事不要瞎猜先把下面三个数据拉出来看# cat /proc/modules | grep demo demo 24576 3 - Live 0xffffffffc0000000 (O) # cat /sys/module/demo/refcnt 3 # ls /sys/module/demo/holders/proc/modules第三列是当前引用计数。/sys/module/demo/refcnt是同一个数据的 sysfs 形态更精确一些。/sys/module/demo/holders/目录下列出的是占用了该模块的内核模块每个名字对应一个符号依赖或显式持有关系。如果refcnt是 3而holders目录是空的说明占用方向不来自其他内核模块而是来自外部使用者打开的设备文件、挂载的文件系统、注册的内核线程或者某个子系统内部悄悄持有的引用。4.2 “Used by”列别急着信很多人习惯lsmod一下看到Used by列是-就判断模块没人用。这是个经典误区。lsmod展示的Used by列内核态来源其实是/sys/module/*/holders它只反映模块到模块的符号依赖关系。用户态进程打开设备文件、文件系统被挂载、内核线程占用了模块代码这些都不会在holders里体现。所以Used by为空不代表引用计数为 0反过来才是成立的——Used by有名字说明确实有别的模块在顶上挂着。正解是看/proc/modules或/sys/module/name/refcnt。只有这两个地方才反映真实的模块引用总值。4.3 用 tracepoint 抓到究竟是哪条代码路径在 get/put如果计数非零但 holders 为空你已经知道是“外部使用者在占用”但具体是谁这时候可以上内核的模块 tracepoint。内核本身就为模块生命周期埋了事件点module:module_get和module:module_put用 ftrace 就能抓# 先挂载 tracefs路径因内核版本可能是 /sys/kernel/tracing 或 /sys/kernel/debug/tracing mount -t tracefs tracefs /sys/kernel/tracing echo 0 /sys/kernel/tracing/tracing_on echo module:module_get /sys/kernel/tracing/set_event echo module:module_put /sys/kernel/tracing/set_event echo 1 /sys/kernel/tracing/tracing_on # 复现你怀疑导致占用的问题操作比如打开一次设备节点 cat /sys/kernel/tracing/trace抓到的输出大致长这样cat-5678 [001] .... 314.159: module_get: demo call_sitedemo_open0x0/0x100 bash-1234 [002] .... 315.000: module_put: demo call_site__fput0x1a0/0x2c0看到call_site是demo_open0x0说明是 open 路径加的计数如果只看到 get 没有对应的 put那么问题就出在这条路径的释放环节。这个方法在绝大多数场景下比翻代码快得多而且能直接定位到具体函数偏移。注意使用 ftrace 需要 root 权限而且要在内核开启了相应 tracepoint 的前提下。多数发行版内核默认可用嵌入式环境如果裁剪了 tracefs 就得先确认一下。4.4 三个案例复盘案例一open 失败路径双扣导致计数异常。驱动在返回-EBUSY前手动调了一次module_putVFS 又补了一次计数变成负数。现象是模块永远卸不掉holders 为空tracepoint 能明显看到 open 失败路径调用了两次 put。修复方式很直接删掉错误路径里的手动module_put。案例二init 里给自己“续命”。有人为了让模块不被卸载在module_init里写了static int __init demo_init(void) { try_module_get(THIS_MODULE); return 0; }这下计数永远至少是 1rmmod 永远报 in use而且没有任何合理的释放时机。相当于自己给自己上了把锁钥匙还扔了。想卸载只能重启。这种“防卸载”思路是毒药正确做法应该是让真正需要模块存活的对象持有引用而不是模块自己抱住自己。案例三内核线程占着模块代码。驱动创建了一个内核线程线程回调里调用了模块内的函数但创建线程时没有拿模块引用于是模块卸载成功后线程还在跑最终访问已释放的代码直接 oops。正确写法是线程创建前try_module_get(THIS_MODULE)线程退出后module_put(THIS_MODULE)同时模块exit里必须先停止线程、等待线程退出模块卸载才不会踩空。5. 模块作者最容易写歪的引用计数用法5.1 不要把引用计数当“防卸载锁”我见过不止一次有人在模块里写这种代码if (something_bad_happens) { try_module_get(THIS_MODULE); return -EAGAIN; }意图是“让模块在出问题时占住自己防止被卸掉”听起来像是安全保护实际上是把系统推向不可卸载的状态。模块引用计数的本意是反映真实使用路径不是用来挡 rmmod 的工具。你挡住了一次卸载下次系统升级要替换这个模块时就会发现它变成一块真正的“铁疙瘩”。正确的做法永远是谁在真实地使用模块代码谁就负责持有引用使用结束引用必须释放。5.2 中断上下文与并发路径上的对称性问题try_module_get本身可以在原子上下文调用它不需要睡眠不需要锁所以中断处理函数里拿引用是没问题的。但真正的坑在于对称性拿引用的路径在中断里释放引用的路径却可能在进程上下文中间如果隔了很大一段逻辑很容易出现某条异常分支漏掉module_put。一旦泄漏模块卸载就会间歇性失败而且是高并发时才出现特别难复现。我的习惯是把“获取引用”和“释放引用”尽量放在同一个函数或同一层抽象里不要在中断入口 get、却在一个深层调用栈的某个角落 put。中间隔着太多逻辑早晚会漏。5.3 常驻回调必须被彻底注销注册了 timer、workqueue、notifier、sysfs 属性等常驻回调而回调代码引用了模块的函数这就等于有一条隐藏引用路径。哪怕你忘了在回调注册时try_module_get只要模块卸载时这些回调还活着代码照样可能被释放然后回调再触发就崩了。正确顺序是模块exit先del_timer_sync/cancel_work_sync/unregister_notifier把所有可能触达模块代码的外部入口关掉再考虑让引用计数归零。反过来如果先释放了计数再注销回调中间就存在窗口期。5.4 引用计数与对象生命周期别混为一谈模块引用计数保护的是“代码段不要在我执行时被卸载”。模块内部创建的动态对象比如链表节点、缓冲区、kobject它们之间谁引用谁靠的是kref、kobject、refcount_t这类对象级引用计数机制维护。两者职责不同但很多驱动把两件事混着用在模块里给每个对象try_module_get(THIS_MODULE)想让对象跟模块绑定。结果对象离开后模块计数被带着走卸载逻辑变得极其绕。一般来说模块自身计数应该保持“粗糙但真实”对象内部的生命周期交给对象自己的计数机制别让一个模块的卸载条件依赖一堆内部对象的销毁顺序。5.5 卸载顺序口诀先关门、再同步、后释放把大量驱动代码里常见的正确卸载顺序总结成一句话先注销对外入口再同步等待所有在途访问结束然后释放自建内核对象最后让模块引用计数自然归零。以字符设备为例module_exit里应该先cdev_del/misc_deregister/device_destroy让新的 open 立刻失败然后del_timer_sync、cancel_work_sync、flush_work之类把在途任务清掉最后释放内部数据结构退出函数返回。只要顺序对了即使某个路径漏拿引用问题往往也会在同步阶段暴露出来而不是在卸载后随机 oops。6. 把引用计数做成可观测的我的调试习惯6.1 在模块里留一个“引用审计”入口开发期我习惯给模块加一个 debugfs 文件读的时候打印当前模块引用计数、已打开文件数、内核线程数、定时器是否 pending。rmmod 失败时先看这个小文件能少走很多弯路。static int demo_refcnt_show(struct seq_file *m, void *v) { seq_printf(m, refcnt: %lld\n, module_refcount(THIS_MODULE)); return 0; }注意较新内核里module_refcount()返回的是s64打印格式用%lld。老内核可能返回int但用%lld在不同架构下也都能兼容到。等到模块稳定后这个调试文件可以去掉但开发期内它价值很大尤其是处理“高并发下偶发无法卸载”这类问题。6.2 周期检查泄漏别等到 rmmod 才后悔在长时间运行的驱动里可以安排一个低频率的周期任务检查模块在非业务期的引用计数是否落在预期区间。比如字符设备驱动在没有打开文件、没有挂载、没有内核线程时预期计数应该是 0如果发现变成了 3多半是有条路径漏了 put。这种检查在线上的价值比 debugfs 更大因为很多泄漏只在高负载下才发生手动复现很困难。记得加打印频率限制别用printk疯狂刷日志。6.3 两分钟检查单最后把排查过程整理成一个可以照着做的检查单cat /proc/modules | grep module看真实引用计数cat /sys/module/module/refcnt确认计数ls /sys/module/module/holders看是否有模块持有者启用module:module_get和module:module_puttracepoint复现操作看 get/put 是否对称结合模块里的 debugfs 审计文件对照预期值找差异。按这个顺序排查大多数“Module in use”的问题都能在几分钟内定位到具体调用路径。最后再分享一个体会模块使用计数是内核里少数你既能看到、又能操作、还能观测的生命周期机制。它真正的用法不是让你写防御代码而是把代码里真实的引用路径交给系统记账。我见过太多把try_module_get当锁用、把module_put当“解锁”用的驱动最终都在卸载时还了债而规规矩矩在入口 get、出口 put 的模块即使业务再复杂排查起来也清爽得多。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询