ARTICLE DETAIL

资讯详情

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

Linux内核模块机制详解:从insmod到rmmod的加载卸载原理与驱动开发避坑指南

Linux内核模块机制详解:从insmod到rmmod的加载卸载原理与驱动开发避坑指南 做Linux驱动开发第一课基本都是模块机制。你自己写一个最简单的字符设备驱动编译出来就是一个.ko文件用insmod加载、用rmmod卸载这个过程背后就是内核模块机制。很多新人刚开始只学会了怎么编译怎么加载但真到调试的时候遇到Invalid module format、符号找不到、模块卸载不掉就一脸懵。这篇文章我把模块机制从设计思路、最小驱动实现、加载卸载的内核原理到模块参数、符号导出、依赖关系再到常见问题的排查方法一次讲透。内容适合刚接触Linux驱动的初学者也适合做过一段时间驱动开发但对模块机制细节还不太清楚的工程师。1. 为什么Linux内核需要一套模块机制1.1 宏内核的痛点一次改动就要重新编译内核Linux一直是一个宏内核Monolithic Kernel。所谓宏内核就是把进程管理、内存管理、文件系统、网络协议栈、各类设备驱动全部糅合在一个大内核镜像里所有功能都运行在内核态、共享同一个地址空间。这套设计最大的好处是性能好函数之间直接调用不需要像微内核那样频繁做进程间通信、地址空间切换加上Linux本身在调度和内存分配上的优化让它在服务器和高性能场景下非常能打。但它的缺点同样明显。最难受的一点是扩展性差。假设你想给某个网卡写个驱动以纯静态方式集成你得把它编进内核镜像然后重新编译整个内核、重新烧录、重启系统整个过程动辄十几分钟甚至更久。这还只是编译时间如果驱动代码有bug每改一次都要重复一遍开发效率低到让人怀疑人生。而且在实际生产环境中一个正在跑业务的内核镜像是不允许随便更换的驱动升级、硬件更换都会变成麻烦事。模块机制就是冲着这个痛点来的。它的核心思路很简单内核镜像只保留最基本的功能其他功能——尤其是设备驱动——编译成独立的模块文件运行时按需动态加载进内核。这样驱动开发者不用每次改代码都重新编译整个内核只要编译一个几十KB的.ko文件insmod进去就能测试系统管理员也能在不重启的情况下加载新驱动。这个可插拔的设计实际上是把宏内核的性能优势和微内核的扩展灵活性做了一次折中。1.2 模块机制带来的收益与代价模块机制带来的收益直观来说有这么几点。第一开发迭代快。写驱动时大部分时间都在改代码—编译模块—加载测试—卸载—再改这个循环里整个过程只需要几秒钟到几十秒而不是烧录整个内核。第二按需加载节省内存。机器上插什么设备就加载对应的驱动模块不用的模块不占内存系统启动速度也更快。第三支持热插拔。USB设备插上去udev根据设备ID自动找到对应的驱动模块并加载拔下来自动卸载用户无感知。第四厂商可以二进制方式分发驱动不需要把源码丢给客户虽然这个问题一直有争议但从商业角度它确实让很多闭源驱动成为可能。代价也是有的而且搞驱动的人必须清楚。模块机制增加了加载阶段的开销模块要经过符号解析、重定位、版本校验等步骤加载速度比编译进内核的代码直接调用来得慢。更重要的是模块运行在内核空间和内核共享地址空间模块一旦写出非法指针访问、缓冲区溢出这种问题大多数情况下直接就是内核崩溃panic你连个报错窗口都不一定有不像用户态程序崩了只是core dump。再有就是内核版本绑定问题模块和内核之间必须严格匹配后面我会专门讲这个。1.3 模块机制的整体工作流程我把模块机制拆开看它其实是一个完整的内核插件系统分三层。最底层是内核态的支持。内核维护了一张模块链表记录当前加载了哪些模块、每个模块的引用计数、依赖关系。内核提供init_module和finit_module两个系统调用用户态把模块文件内容传进来后内核负责完成ELF解析、符号重定位、调用模块的构造函数和初始化函数以及把模块信息挂到链表上。中间层是一些配套的模块文件。编译出来的.ko文件不是普通可执行程序它本质上是ELF目标文件格式的变体里面除了代码段、数据段还额外放了很多结构化信息模块名、作者、license、依赖的符号列表、vermagic版本字符串、可选的模块参数描述等。这些信息既供内核对模块做校验也供用户态工具展示。最上层是用户态管理工具。insmod、rmmod是最基本的加载卸载命令modprobe更智能能自动处理依赖depmod负责扫描模块目录生成依赖数据库lsmod和modinfo用来查看信息。整个流程从命令下发到内核执行完毕才算完成一次模块的加载。2. 从零动手写一个最小内核模块并跑起来2.1 最小模块的骨架代码理论讲再多不动手都是空的。我们先写一个最小的内核模块。找一个空目录创建hello.c。#include linux/init.h #include linux/module.h #include linux/kernel.h static int __init hello_init(void) { printk(KERN_INFO hello module loaded\n); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO hello module unloaded\n); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple hello world module); MODULE_VERSION(1.0);这段代码信息量不少。module_init和module_exit是两个宏它们把hello_init和hello_exit放到ELF文件的特定section里。内核加载模块时会去找这两个section确定模块的入口和出口函数。init函数返回0表示初始化成功返回负数表示失败内核会根据返回值决定是否继续保留这个模块。exit函数负责把init阶段分配的资源全部释放。printk是内核空间的打印函数和用户态的printf完全不同。它不往终端输出除非配置了控制台而是往内核日志缓冲区写用dmesg命令查看。KERN_INFO是日志级别常见的还有KERN_ERR、KERN_DEBUG等。MODULE_LICENSE(GPL)这一行很多人顺手写上但它的作用比想象中大。它告诉内核这个模块采用了GPL兼容许可如果声明了GPL就可以使用EXPORT_SYMBOL_GPL导出的内核符号否则只能用普通EXPORT_SYMBOL导出的符号。另外声明GPL还会影响内核是否把该模块视为tainted状态。2.2 Makefile与Kbuild编译体系模块不能像普通程序那样直接gcc编译必须用内核的Kbuild构建体系。在源码目录下创建Makefileobj-m : hello.o KDIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) cleanobj-m表示把hello.c编译成外部模块hello.ko。如果你想让某些代码编译进内核镜像用obj-y不过那是在完整内核源码树里的用法外部模块只会用obj-m。KDIR指向当前运行内核的构建目录。/lib/modules/$(uname -r)/build通常是一个软链接指向实际的内核源码树或者内核头文件包。这里有一个关键点这个目录里必须包含编译模块所需的内核头文件、生成的头文件比如autoconf.h、以及Module.symvers等否则编译会失败或者编译出来的模块和当前内核不匹配。M$(PWD)表示执行的是外部模块编译Kbuild会进入当前目录根据obj-m变量找到要编译的源文件。整个构建过程的输出大致是这样的make -C /lib/modules/5.15.0-91-generic/build M/home/user/hello modules make[1]: Entering directory /usr/src/linux-headers-5.15.0-91-generic CC [M] /home/user/hello/hello.o MODPOST /home/user/hello/Module.symvers CC [M] /home/user/hello/hello.mod.o LD [M] /home/user/hello/hello.ko make[1]: Leaving directory /usr/src/linux-headers-5.15.0-91-generic编译完当前目录会多出一堆文件关键的有hello.ko真正的模块文件、hello.o、hello.mod.o、hello.mod.c、Module.symvers。hello.mod.c很有意思它是构建系统自动生成的里面记录了模块的vermagic字符串、依赖符号的CRC值等信息这些最终都会被编进hello.ko。2.3 加载、查看与卸载的完整实操编译完成后先把模块加载进去试试。sudo insmod hello.ko dmesg | tail正常情况下dmesg里能看到hello module loaded。注意如果看不到先确认printk级别和dmesg的过滤配置。接着用lsmod查看模块状态lsmod | grep hello输出大概是hello 16384 0三列分别表示模块名、模块占用的内存大小单位字节、被多少个其他模块引用。再看一下/proc/modules内容基本一致它其实是内核模块链表在procfs上的投影。把模块卸载掉sudo rmmod hello dmesg | tail能看到hello module unloaded。到这里你已经完成了一个完整的内核模块生命周期。这一步跑通之后后面所有驱动开发都建立在这个基础流程上——扩展成一个字符设备、平台驱动、USB驱动无非是在模块的init/exit函数里做更复杂的事情。3. insmod背后内核到底做了什么3.1 从insmod到init_module系统调用很多资料一句话带过insmod加载模块但如果你要做真正的驱动开发必须理解这个命令背后发生的事情。insmod本身是个用户态小程序它把hello.ko整个读进内存然后调用init_module现代内核也可以走finit_module它接受文件描述符系统调用把模块内容传给内核。真正加载模块的工作全部在内核里完成。内核的加载流程大致是这样的首先从ELF格式中解析出各个section做一次初步的合法性检查包括文件格式、架构、位深度等。接着做符号解析和重定位。模块代码里引用了很多内核符号比如printk这些符号在编译时只是记录了一个未定义引用加载时内核需要在内核符号表里找到printk的实际地址然后回填到模块代码里。同时模块自己导出的符号如果有也会被记录到内核符号表供其他模块解析。之后内核会检查vermagic字符串和符号CRC。vermagic不对直接拒绝加载报Invalid module format符号CRC对不上也会拒绝表示模块和内核提供的接口不兼容。这些检查全部通过后内核才开始真正运行这个模块。3.2 init函数与exit函数的特殊性质内核在完成符号重定位和校验后调用模块的构造函数然后调用module_init注册的那个函数——也就是我们写的hello_init。这个函数执行完模块才正式进入存活状态。如果init返回0模块状态变成live如果返回非0内核会立刻把模块卸载掉资源和代码段会被清理。这里有个很多人忽略的细节。我们给init函数加了个__init修饰符它的作用是把函数代码放到一个特殊的section里。内核在模块初始化完成后这个section占用的内存可以被释放。为什么能释放因为init函数只在加载时执行一次之后再也没有人调用它留着纯属浪费。模块加载完你可以观察一下内存占用有些模块的init内存会被标记为不可用的freed memory。exit函数用__exit修饰同样有类似作用。但要注意如果模块被编译进内核而不是作为模块加载__exit标记的函数会被整个丢弃因为内核镜像的代码不会卸载。所以写驱动时不要指望exit函数在模块编进内核时还能被调用。3.3 模块引用计数与卸载条件rmmod卸载模块时内核会检查一个关键数据——模块的引用计数。只有当引用计数为0时内核才允许卸载模块否则直接拒绝报Module xxx is in use。为什么要有引用计数因为模块向内核注册了一堆函数指针比如文件操作表、中断处理函数、procfs接口等。如果正在使用这些接口时模块被卸载了内核调用函数指针就会跳到已经被释放的代码地址立刻系统崩溃。引用计数就是这个场景下的安全阀。引用计数不是模块自己维护的而是内核通过try_module_get和module_put两个接口管理的。比如一个进程打开了某个设备节点设备的open回调函数里通常会自动拿走模块引用close时再释放。你可以在/sys/module/hello/refcnt里看到实时引用计数值。驱动开发时如果你的模块卸载总是失败先看refcnt是几再查是谁没有正确释放引用。4. 模块之间如何协作参数、符号导出与依赖4.1 模块参数的设计与传入方式写驱动时经常要支持一些可配置项比如缓冲区大小、中断号、设备数量、调试开关。模块机制提供了模块参数module parameter机制让这些配置可以在加载时传入甚至运行时修改。先看代码#include linux/moduleparam.h static int buffer_size 1024; module_param(buffer_size, int, 0644); MODULE_PARM_DESC(buffer_size, Size of the buffer, default 1024); static char *device_name mydrv; module_param(device_name, charp, 0644); static int irqs[4] {11, 12, 13, 14}; module_param_array(irqs, int, NULL, 0444);加载时这样传参sudo insmod mydrv.ko buffer_size4096 device_namehello_drvmodule_param的第三个参数是权限位。0644表示该参数在/sys/module/mydrv/parameters/目录下会生成对应的sysfs文件root可以查看和修改其他用户只读。如果传0表示不创建sysfs文件运行时无法查看和修改只在加载时生效。运行中修改参数用echo 8192 /sys/module/mydrv/parameters/buffer_size参数类型支持byte、short、ushort、int、uint、long、ulong、charp、bool、invbool等数组用module_param_array。这些类型在内核里都有对应的parse函数做字符串到数值的转换。charp类型要注意如果传的是内核模块参数它指向的字符串在处理时不会自动复制最好在init函数里用kstrdup保存一份避免后续使用悬垂指针。4.2 EXPORT_SYMBOL模块间共享函数模块不是孤岛。一个常见的场景是A模块是底层硬件驱动提供一组操作函数B模块是上层实现需要调用A的函数。B模块在编译时引用A模块导出的符号运行时由内核负责把B中的未定义符号解析到A的内存地址上。导出符号的方式很简单// 在A模块中 int my_driver_read(struct my_dev *dev, char *buf, int len); EXPORT_SYMBOL(my_driver_read);如果想限制只有声明了GPL的模块才能用EXPORT_SYMBOL_GPL(my_driver_read);B模块里直接extern声明就能调用。但整个编译加载链路上有几个坑。编译A模块后它的构建目录里会生成Module.symvers里面记录了导出的符号和CRC。如果B模块要链接A模块的符号编译B模块时最好把A模块的Module.symvers拷贝过来或者在B的Makefile里声明。否则B能编译出.ko但加载时会报Unknown symbol。加载时用insmod先加载A再加载B如果用modprobe它会根据依赖关系自动处理。查看模块导出和未定义符号用nm工具nm hello.ko | grep -E [Tt] | [Uu] T表示模块导出的函数符号U表示模块引用的外部未定义符号。加载时这些U符号必须被内核符号表解析到。4.3 modprobe与模块依赖的自动处理insmod加载模块时你必须手动保证所有依赖的模块都已经加载。这是非常痛苦的事情尤其在真实系统里一个蓝牙驱动可能依赖十几个其他模块靠手动维护顺序纯属灾难。modprobe就是解决这个问题的。modprobe工作的依据是depmod生成的依赖文件。内核模块安装到/lib/modules/$(uname -r)/下之后运行depmod它会扫描这个目录下所有.ko文件分析各自的符号引用和导出关系生成modules.dep文件。modules.dep里每一行都列出了某个模块依赖的模块列表。当你执行modprobe mydrv它会先查modules.dep按拓扑顺序把所有依赖模块都加载好最后加载mydrv。如果有模块加载失败modprobe会回滚已经加载的模块尽量保持系统回到初始状态。依赖关系图里有环的话depmod会直接报错提示你检查设计。另外modprobe还支持模块别名。模块可以通过MODULE_ALIAS声明自己支持的设备ID比如USB网卡驱动声明MODULE_ALIAS(usb:v1234p5678)。当udev在系统中发现一个USB设备会从设备信息生成modalias字符串然后和所有模块的别名比对自动加载匹配的驱动。这就是为什么你插上U盘或USB转串口设备驱动能自己加载的原因。涉及ch340、cp2102这类USB转串口芯片时这个机制特别能体现价值。4.4 模块的vermagic到底在校验什么前面反复提到vermagic这里展开讲。每个内核模块文件里都有一段vermagic字符串它记录了编译这个模块时所用内核的一些关键信息包括内核版本号、是否支持模块、编译器版本、是否支持SMP、是否抢占、架构类型等。模块加载时内核会把它自己的vermagic和模块的vermagic做比较。只要有一个关键项不一致加载就会被拒绝。为什么会这么严格因为内核的很多数据结构、函数实现都受配置选项影响。比如开启SMP后spinlock结构体内部会多几个字段如果你用一个非SMP内核编译的模块加载到SMP内核里模块里分配的内核对象大小就不对运行起来必然出问题。用modinfo对比一下你的模块和系统modinfo hello.ko | grep vermagic cat /proc/version sudo cat /sys/module/hello/vermagic # 如果模块已经加载实践中有个笨办法但很有效编译模块用的内核构建目录必须和运行内核严格对应。最容易出问题的情况是系统昨天升级了内核重启后uname -r变了但你的Makefile里KDIR还在指向旧版本的构建目录。5. 模块开发避坑指南问题排查与调试5.1 Invalid module format的完整排查思路这个报错可能是模块开发中遇到概率最高的错误了。dmesg里通常会跟着详细的拒绝原因所以第一步永远是先看dmesgdmesg | tail -n 20常见的原因有这么几类。最常见的是内核构建目录和运行内核不匹配。解决方法是重新确认KDIR指向的路径或者干脆重新编译一次内核头文件包。其次是交叉编译时架构没选对比如在x86主机上给ARM开发板编译模块Makefile里忘了指定ARCHarm和CROSS_COMPILE。再次是模块和内核某些配置选项不一致比如CONFIG_MODVERSIONS、CONFIG_PREEMPT等开关的差异。最后还有一种忽略的情况模块编译时和加载时的编译器版本差异过大导致ABI兼容问题。如果只是临时调试可以用insmod -f跳过强制校验。但注意这非常危险只是因为vermagic检查被跳过了如果实际配置确实不兼容模块加载后大概率会在运行时崩溃。我的建议是只在虚拟机里做这种实验生产环境绝对不要用。5.2 符号未定义Unknown symbol in module这个错误通常是依赖没满足。可能有两种情况第一种被依赖的模块还没加载先手动加载依赖模块或者改用modprobe第二种被依赖的模块虽然加载了但符号没有被导出——在模块里定义函数默认是不导出的必须显式加EXPORT_SYMBOL或EXPORT_SYMBOL_GPL。遇到这种情况排查步骤我一般是这样先用modinfo看模块依赖是什么确认依赖模块都加载了再用nm -u看这个模块未定义符号列表挑一个符号名然后用grep symbol /proc/kallsyms检查这个符号在内核符号表里存不存在。如果不存在回到源码检查是不是漏了EXPORT_SYMBOL如果存在但加载仍然失败检查CRC也就是编译时Module.symvers是否同步。5.3 模块卸载失败Module xxx is in usermmod报这个错说明引用计数不是0。我的排查习惯是先看引用计数是多少cat /sys/module/hello/refcnt cat /proc/modules | grep hello然后想清楚谁可能拿了引用。最常见的场景是设备文件被进程打开。比如你的模块注册了字符设备某个进程open了/dev/xxx但没close模块引用计数就不会归零。用lsof或者fuser查一下设备文件占用情况把进程停掉再卸载。还有一类情况是自找麻烦你在模块自己导出的函数里调用了try_module_get但释放路径上漏了对应的module_put。这种引用泄漏很难发现因为每次打开设备都会累加引用计数。遇到这种问题除了仔细检查代码路径还可以临时在模块卸载测试前手动调用module_put来验证假设或者写个小工具通过sysfs触发释放路径配合refcnt观察变化。5.4 内核崩溃与日志定位内核模块出问题表现往往不是优雅的报错而是莫名其妙地panic、重启、或者整个系统无响应。遇到这种情况不要慌以下是我实践中的操作思路。第一加载模块前先在另一个终端开一个dmesg -w实时观察这样内核打印的最后几行日志大概率就是崩溃前的线索。第二如果系统直接黑了利用串口控制台或者内核的pstore功能把内核日志保存下来。第三用虚拟机做驱动调试比如QEMU/KVM里起一个客户机宿主机上gdb直接连上去调试效率比真机高太多。我在开发新驱动时九成的问题都是在虚拟机里先暴露出来的。还有一个经验内核崩溃时屏幕上的Oops信息别急着当乱码它其实包含完整的内核栈回溯和寄存器现场定位模块代码中哪一行出错非常关键。去看Oops里的指令地址或者函数名再结合内核符号表基本能定位到具体函数。配合addr2line工具可以直接从出错地址换算回源码行号。5.5 printk调试的分级与开关技巧调试模块最朴素但最有效的工具还是printk。它有几个级别KERN_EMERG到KERN_DEBUG数字越小优先级越高。控制台默认会打印一定级别以上的日志而dmesg能看到全部。驱动开发时我习惯的做法是关键路径用KERN_ERR调试信息用KERN_DEBUG并且所有调试打印用一个全局开关控制。这样产品阶段把开关关掉日志干净调试阶段打开开关信息丰富。内核还支持动态调试dynamic debug对使用dev_dbg、pr_debug这类宏的打印可以通过debugfs在运行时动态开启某个文件或函数的打印非常强大建议学会了就用起来比改代码重新编译调试省太多时间。另外提一句printk在高频路径上开销不小千万不能在中断处理函数或者热路径上无脑打印。我看到过有驱动在中断里打了太多日志直接让系统卡死的案例。调试打印在定位问题时打印定位完一定要清理或者加开关。最后分享两个实操习惯写了这么多年驱动我养成了两个习惯对提高模块开发效率帮助很大。第一个习惯给每个模块准备一个干净的测试环境。在开发机的QEMU虚拟机里专门追平开发内核版本一旦模块加载把虚拟机搞崩宿主机完全不受影响重启虚拟机几秒钟就能继续调试。真机测试只放在最后做兼容性验证绝不拿开发环境冒险。第二个习惯加载新模块前先看一眼模块信息和符号完整性。用modinfo确认vermagic用nm -u确认未定义符号在不在/proc/kallsyms里。这套快速自检帮我规避了大量低级错误也省了很多来回试错的时间。模块机制是Linux驱动开发的基石搞懂它你后面学字符设备框架、平台驱动、设备树匹配这些内容都会轻松很多。这个系列后面也会继续展开下一篇我会从字符设备驱动框架入手把cdev注册、file_operations、设备节点创建这些硬核内容讲透。
返回列表