
Linux驱动基础(一)模块机制的设计与实现搞Linux驱动开发绕不开的第一道坎就是模块机制。很多新手一开始接触insmod、rmmod、modprobe这几个命令时觉得不就是加载和卸载嘛背下来就行了。但等你真正被Unknown symbol、module_layout版本不匹配、依赖顺序乱七八糟这类问题折磨过几轮之后就会明白模块机制不是加载/卸载这么简单它背后藏着一整套内核设计思想和工程化的妥协方案。我在刚开始写驱动那会儿踩过最经典的一个坑自己编译了一个hello.ko高高兴兴insmod进去dmesg一看啥都没有。当时还以为是打印函数写错了折腾半天才发现是printk的日志级别没对上消息根本没刷到控制台。后来在嵌入式设备上调试串口驱动又遇到模块加载顺序的问题两个驱动互相依赖insmod一个报Unknown symbol另一个又得等前者就绪那时候才真正静下心来把模块机制从头到尾捋了一遍。这篇文章是针对Linux驱动入门的第一篇重点拆解模块机制这个地基。我会把模块的本质、加载卸载的完整流程、Makefile的写法、多文件编译、参数传递、依赖关系这些核心内容全部过一遍并穿插实际调试中积累的排错经验。不管你是准备入门Linux驱动还是已经在写驱动但没系统研究过模块机制这篇文章都能帮你把底子打牢。1. 模块机制到底解决了什么问题1.1 没有模块机制的时代一切都要编进内核要理解模块机制得先知道没有它的时候日子有多难过。早期的Linux内核或者说现在很多精简的嵌入式方案驱动程序是直接编译进内核镜像的。也就是说你每改一次驱动代码哪怕只是改了一个GPIO的编号都需要重新编译整个内核再重新烧录镜像然后重启设备才能验证效果。这个流程在开发阶段是极其痛苦的。以我自己的经历为例早期在ARM板子上调试LED驱动每次改完代码交叉编译内核大概要几分钟到十几分钟再加上烧录、重启、串口输出日志的时间一轮下来半小时就没了。如果一天调几十轮绝大部分时间都耗在编译和烧录上了真正的调试时间少得可怜。模块机制的出现本质上就是把驱动开发从内核开发中解耦出来。驱动被编译成一个独立的.ko文件Kernel Object运行时动态插入内核地址空间不需要的时候再卸载。这就让驱动开发有了接近用户态程序的迭代速度改代码、编译ko、insmod、测试、rmmod、再改整个循环通常在几十秒内完成。1.2 模块机制带来的不仅仅是开发效率模块机制的第二个价值是内存的按需加载。嵌入式设备的内存非常宝贵如果把所有驱动都编进内核不管外设接没接驱动代码都常驻内存。而模块机制允许系统启动时只加载必要的核心驱动其他外设驱动等到设备插入时再通过热插拔机制动态加载用完了还能卸载释放内存。第三个价值是驱动的二进制分发。芯片厂商比如WiFi模组、蓝牙模组、USB转串口芯片的厂商通常只提供编译好的.ko文件给客户而不是把源码交付出去。这在商业上很重要因为驱动程序可能包含厂商的算法调优和寄存器配置细节属于核心知识产权。模块机制天然支持这种二进制分发模式用户拿到.ko文件只要内核版本匹配就能直接加载使用。还有一个容易被忽略的价值驱动的隔离和降级。如果某个驱动加载后导致系统崩溃或行为异常在模块机制下可以卸载这个模块系统还能继续运行。而编译进内核的驱动出现问题基本只能重启解决。在生产环境的服务器上这一点尤其关键——毕竟谁也不想因为一个不常用的外设驱动导致整台机器宕机。1.3 模块机制的基本工作模型模块机制的工作模型可以概括为内核提供运行时服务模块提供具体实现两者通过符号表建立连接。具体来说内核在启动时维护一张全局的符号表include/linux/export.h中定义的EXPORT_SYMBOL系列宏就是干这个的把一些核心功能以符号的形式导出。驱动模块在编译时会标记自己需要哪些外部符号在加载时内核的模块加载器会把这些符号引用解析到内核已导出的符号地址上。如果解析失败加载就会中止并报告Unknown symbol错误。同时模块自身也会导出一些符号通过EXPORT_SYMBOL供其他模块使用。这就构成了模块间的依赖关系——机制上有点像用户态的动态链接库但又比动态链接库更底层、更谨慎因为模块运行在内核态一个指针错误就可能直接导致整个系统崩溃连段错误的机会都没有。2. 从源码到.ko模块的基本骨架与编译体系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 Goodbye, 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);这里面有几个关键点需要说明。__init和__exit这两个宏很值得关注。__init会让初始化函数在加载完成后其占用的内存被释放回系统。所以你会看到dmesg里有时候会提示某个initcall在什么地址但用/proc/kallsyms查的时候又找不到就是这个原因。__exit也是类似的逻辑如果模块被编译进内核而不是作为可加载模块__exit函数会被直接丢弃因为内核不允许卸载自己。module_init和module_exit是两个宏它们把初始化函数和退出函数登记到一个特殊的ELF段里。内核在加载模块时就是从这些段里找到入口函数的。如果你把函数名写错insmod会报Invalid module format或者直接提示找不到入口。MODULE_LICENSE这个东西建议一定要写而且建议写GPL。原因很实际如果不声明GPL某些内核符号是不会被导出给你的典型的比如EXPORT_SYMBOL_GPL修饰的符号。加载时会报Unknown symbol用modprobe还会提示disagrees about version of symbol。这个坑我在写一个网络驱动时踩过后来老老实实加了GPL声明问题立刻消失。2.2 Makefile的写法不要自己写编译命令很多新手第一次编译模块的时候会尝试直接用gcc去编译比如gcc -c hello.c -o hello.o这个做法是错的。内核模块不能直接用系统的gcc编译必须使用内核提供的Kbuild构建系统并且必须指定内核源码树的路径。标准的模块Makefile长这样# Makefile obj-m hello.o KERN_DIR ? /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KERN_DIR) M$(PWD) modules clean: $(MAKE) -C $(KERN_DIR) M$(PWD) clean逐行解释一下这个Makefile的逻辑obj-m hello.o告诉Kbuild我们想要构建一个可加载模块目标文件是hello.o模块名是hello.ko。这是Kbuild的规范写法不能随意改成别的形式。KERN_DIR指定内核源码树的路径。对于Ubuntu这些发行版/lib/modules/$(uname -r)/build这个路径通常是一个符号链接指向实际安装的内核头文件目录。对于嵌入式开发这个路径要改成你交叉编译内核的源码目录。$(MAKE) -C $(KERN_DIR)的意思是切换到内核源码目录执行make让Kbuild体系来处理模块编译。M$(PWD)告诉Kbuild我们的模块源码在当前目录编译出来的中间文件和.ko文件也放到当前目录。编译出来的产物包括hello.ko、hello.mod.c、hello.mod.o、modules.order、Module.symvers等。其中hello.mod.c是自动生成的里面包含了模块的元信息比如依赖关系、模块名等你可以打开看看能加深理解。对于嵌入式开发还需要指定交叉编译工具链Makefile变成这样obj-m hello.o ARCH ? arm CROSS_COMPILE ? arm-linux-gnueabihf- KERN_DIR ? /path/to/kernel/source all: $(MAKE) -C $(KERN_DIR) ARCH$(ARCH) CROSS_COMPILE$(CROSS_COMPILE) M$(PWD) modules clean: $(MAKE) -C $(KERN_DIR) ARCH$(ARCH) CROSS_COMPILE$(CROSS_COMPILE) M$(PWD) cleanARCH和CROSS_COMPILE这两个变量看起来很基础但特别容易出错。尤其是CROSS_COMPILE必须保证你的交叉编译器在PATH环境变量中而且前缀要和实际编译器名字完全一致。如果你用的编译器叫arm-buildroot-linux-gnueabihf-gcc那CROSS_COMPILE就必须是arm-buildroot-linux-gnueabihf-一个字符都不能差。2.3 模块中的打印printk的等级机制模块里的打印不能直接用printf因为模块运行在内核态没有用户态的libc支持。内核提供的打印函数是printk它的使用方式和printf类似但多了日志等级的概念。常见的日志等级有宏值含义KERN_EMERG0紧急事件系统崩溃KERN_ALERT1必须立即处理KERN_CRIT2严重错误KERN_ERR3错误KERN_WARNING4警告KERN_NOTICE5普通但值得注意KERN_INFO6信息KERN_DEBUG7调试信息使用方法是在格式串前拼接等级宏printk(KERN_INFO Hello, module loaded!\n);这个printk的日志等级会决定消息出现在哪里。内核有一个/proc/sys/kernel/printk文件里面保存着四个数值分别表示控制台日志级别、默认日志级别、最小日志级别、最大日志级别。简单说如果消息的日志级别数值小于控制台日志级别消息就会打印到控制台上比如串口、显示器否则只进入内核的环形缓冲区用dmesg才能看到。开发阶段我建议直接用KERN_DEBUG或者干脆不写级别不写的话默认按KERN_WARNING处理然后统一通过dmesg查看输出。这样能避免调试信息刷屏导致看不清重要日志。这里分享一个实际经验如果你在某个驱动里用printk输出了大量调试信息在高频率中断路径上千万不要无脑printk因为printk本身有锁和IO开销可能会显著降低系统性能甚至导致中断处理超时。我在调试一个SPI设备的轮询状态时在while循环里加了printk结果系统直接卡死了。后来改成用trace_printk或者只在状态变化时打印才把问题定位清。3. 加载与卸载insmod、rmmod背后的完整过程3.1 insmod到底做了什么insmod命令虽然看起来简单但它背后走了一套完整的内核模块加载流程。大致可以拆成以下几步读取文件insmod从文件系统中读取.ko文件的全部内容到内存。系统调用insmod通过init_module或finit_module系统调用把模块文件的内容传递给内核。finit_module是在较新的内核版本中加入的它接受一个文件描述符避免了从用户空间拷贝大块数据的问题。ELF解析内核的模块加载器对模块的ELF文件进行解析确认它的目标架构和当前内核一致检查模块的许可证声明确认模块的版本信息。重定位这是最关键的一步。因为模块在编译时并不知道自己会被加载到内核地址空间的哪里所以模块里的符号引用实际上都是未解析的。内核需要扫描模块的重定位表把模块里引用到的外部符号比如printk、kmalloc这些内核函数替换成实际的运行时地址。符号解析在重定位过程中如果遇到一个Unknown symbol加载就会失败并报出具体的符号名。这也是模块加载失败最常见的错误类型。调用init函数所有重定位和符号解析完成后内核调用模块的module_init函数执行初始化逻辑。注册模块初始化成功后模块被正式添加到内核的模块链表中通过lsmod就能看到了。rmmod的流程则相反先调用module_exit函数然后从内核的模块链表摘除模块释放模块占用的内存最后更新模块的依赖计数。3.2 模块依赖与modprobe的差异insmod和modprobe都能加载模块但它们的定位不同。insmod只是一个简单的加载工具只会把指定的.ko文件加载进内核不会处理依赖关系。modprobe则是一个更高层的工具它会去读取/lib/modules/$(uname -r)/modules.dep文件这个文件里记录了所有模块之间的依赖关系加载时会先把所有依赖的模块都加载好然后再加载目标模块。举个例子。你的串口驱动依赖一个底层的GPIO控制器驱动直接insmod你的串口驱动很可能会报Unknown symbol因为底层GPIO驱动还没加载。而modprobe会自动查找并先加载GPIO控制器驱动再加载串口驱动。所以在实际开发中如果模块存在依赖关系就不要用insmod一个个手动加载了直接modprobe更靠谱。但要注意modprobe默认只会去标准模块目录下找.ko文件如果你把模块放到了其他路径需要在/etc/modules.conf或/etc/modprobe.d/下配置搜索路径或者直接用insmod指定完整路径加载。3.3 模块的引用计数与强制卸载用lsmod查看模块时第三列的数字就是模块的引用计数。这个计数表示有多少其他地方正在使用这个模块。比如你的字符设备驱动被一个进程打开了文件描述符模块的引用计数就会大于0。此时rmmod会失败提示Module is in use。引用计数的增减在代码里是通过try_module_get和module_put实现的。但现代内核通常在驱动框架层面已经自动处理了比如字符设备在open时自动增加引用计数release时减少。所以写驱动时除非你的驱动框架没有自动管理引用计数否则不需要自己手动调用。有时候模块因异常导致引用计数卡住或者被D state不可中断睡眠的进程占用rmmod会一直失败。这时候可以尝试rmmod -f强制卸载。但这招只有在模块本身没在内核线程/中断上下文中驻留的情况下才管用。强制卸载一个正在被中断处理程序使用的模块几乎必然导致系统崩溃。所以我的建议是rmmod -f只是开发调试的兜底手段不要在生产环境使用。3.4 开机自动加载模块配置的工程化开发阶段手动insmod没问题但产品发布时驱动需要在系统启动时自动加载。常见的做法有三种把模块放到/lib/modules/$(uname -r)/extra/或/lib/modules/$(uname -r)/updates/目录执行depmod -a更新依赖关系然后在/etc/modules文件中添加模块名。使用udev规则根据设备的热插拔事件自动加载对应的驱动模块。这是最推荐的方式尤其适合USB设备、SDIO设备这类支持热插拔的外设。在系统启动脚本比如init.d脚本、systemd service中手动执行modprobe。对于嵌入式系统如果内核把某些核心功能编译成了模块还需要注意根文件系统的挂载顺序。如果根文件系统本身依赖的驱动是模块那启动过程中就需要先加载这个模块这又涉及initramfs/initrd的机制。这块展开讲会非常复杂我这里只提醒一点在制作initramfs时一定要把根文件系统相关的存储驱动包含进去否则会出现Kernel panic - not syncing: VFS: Unable to mount root fs这个经典启动错误。4. 多文件模块与外部符号工程化的第一步4.1 多个源文件如何编译成一个模块实际项目里的驱动基本不可能只有一个源文件比如一个字符设备驱动可能包含主文件、平台设备定义、电源管理相关代码等。把多个源文件编译成一个.ko的Makefile写法如下# Makefile obj-m mydriver.o mydriver-objs : main.o platform.o power.o KERN_DIR ? /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KERN_DIR) M$(PWD) modules clean: $(MAKE) -C $(KERN_DIR) M$(PWD) clean注意mydriver-objs这一行的命名规则模块名-objs : 组成模块的所有目标文件。这行的意思是mydriver.ko由main.o、platform.o、power.o这三个目标文件链接而成。obj-m mydriver.o是入口配置告诉Kbuild我们要构建一个叫mydriver的模块。这里有一个常见错误如果mydriver-objs写错成mydriver-y或mydriver-objKbuild不会报错但生成的.ko不会包含对应的目标文件加载时可能出现符号缺失的错误。严格来说旧内核支持模块名-y这种写法但新内核建议统一用模块名-objs。另外提醒一下如果多个源文件之间有循环依赖比如main.c引用power.c的函数power.c也引用main.c的函数编译时会报undefined reference”。这种情况下需要重新审视代码结构把公共部分抽到单独的文件或者用一个头文件声明并调整依赖方向。模块内部尽量不要制造循环依赖不仅链接麻烦后续维护也容易出问题。4.2 EXPORT_SYMBOL模块间共享函数如果你的驱动拆成了多个模块其中一个模块的函数需要被另一个模块调用就需要使用EXPORT_SYMBOL来导出符号。// module_a.c #include linux/module.h int my_shared_function(int arg) { return arg * 2; } EXPORT_SYMBOL(my_shared_function);// module_b.c extern int my_shared_function(int arg); static int __init module_b_init(void) { int result my_shared_function(21); printk(KERN_INFO result %d\n, result); return 0; }这里有几个容易忽略的点导出符号的模块必须已经加载成功导入符号的模块才能解析到那个符号。加载顺序反了就会报Unknown symbol。extern int my_shared_function(int arg);这段声明不要手动到处复制容易不一致。最好放到一个专门的头文件里两个模块都包含这个头文件保证声明一致。如果两个模块属于同一个项目建议在Makefile里把它们编译成两个独立的.ko然后用modprobe统一管理依赖关系而不是试图编译成一个.ko。EXPORT_SYMBOL_GPL也是常见的导出方式它要求使用方声明GPL许可证否则无法使用该符号。如果你的驱动是闭源的又会用到EXPORT_SYMBOL_GPL的符号那加载时必然报Unknown symbol。这是很常见的闭源驱动加载失败的原因之一。针对这个问题有些厂商的做法是提供GPL wrapper的补丁但这类做法涉及法律问题我不展开讨论只提醒大家注意这个许可限制的存在。4.3 module_param运行时传递参数模块支持在加载时传递参数这意味着同一个.ko文件可以根据参数不同呈现不同的行为。比如一个GPIO驱动可以接收gpio_num和active_level两个参数根据不同硬件设计灵活配置。// gpio_demo.c #include linux/module.h #include linux/moduleparam.h static int gpio_num 100; static int active_level 1; module_param(gpio_num, int, 0644); MODULE_PARM_DESC(gpio_num, GPIO number to control); module_param(active_level, int, 0644); MODULE_PARM_DESC(active_level, Active level: 1 for high, 0 for low); static int __init gpio_demo_init(void) { printk(KERN_INFO gpio_demo: gpio_num%d, active_level%d\n, gpio_num, active_level); return 0; } static void __exit gpio_demo_exit(void) { printk(KERN_INFO gpio_demo: module unloaded\n); } module_init(gpio_demo_init); module_exit(gpio_demo_exit); MODULE_LICENSE(GPL);加载方式insmod gpio_demo.ko gpio_num23 active_level0module_param的第三个参数是权限位。如果设置为0模块加载后参数不可见如果设置为0444或0644模块加载后会在/sys/module/gpio_demo/parameters/下生成对应的参数文件允许运行时查看甚至修改取决于写权限。运行时修改参数的功能非常适合在设备调试阶段使用——不需要卸载重载模块直接echo新值到sysfs文件就能改变驱动行为。参数类型除了int还支持charp字符串指针、bool、long、ushort等。字符串参数的声明方式稍有不同static char *device_name default; module_param(device_name, charp, 0644);注意字符串参数如果运行时被改短要小心不要越界写charp参数传递的是指针内核不会帮你维护字符串缓冲区的大小。5. 开发调试中的高频问题排查5.1 模块加载失败的五种常见错误我在调试驱动的过程中总结了模块加载失败的几类高频原因基本上可以用一张表说清楚错误现象根本原因排查方向Invalid module formatELF结构不匹配、版本魔法串不一致确认内核源码版本和运行内核一致检查uname -rUnknown symbol引用的符号不存在、未导出、或加载顺序不对用grep 符号名 /proc/kallsyms确认符号状态No space left on device内核模块内存区域耗尽减少模块数量检查内核配置MODULES_VADDR相关选项Exec format error架构不一致比如x86上加载ARM的ko用file hello.ko查看文件架构Operation not permitted权限不足或Secure Boot限制用root执行检查Secure Boot和模块签名其中最让人头疼的是Invalid module format。这个错误很多时候是因为内核源码树和当前运行内核不一致。比如你升级了内核但/lib/modules/$(uname -r)/build还指向旧的内核源码目录或者根本不存在。判断方法很简单uname -r ls -l /lib/modules/$(uname -r)/build如果build链接指向的目录不存在或者指向的目录里的Makefile和运行内核不匹配编出来的模块几乎必然加载失败。还有一种隐蔽情况你没有先编译内核源码树就尝试编译模块。某些内核头文件在源码树没有配置或编译过的情况下生成的autoconf.h缺失编译出来的模块会有问题。所以如果你从零编译内核先确保make modules_prepare执行过。5.2 用modinfo和dmesg定位问题modinfo命令可以查看模块的元信息包括作者、描述、依赖、许可协议等。用modinfo hello.ko能看到模块声称的vermagic也就是内核版本号这个值必须和当前运行内核的版本严格匹配除非开启了CONFIG_MODVERSIONS并用modversions机制做了符号级兼容。如果vermagic不匹配insmod会直接拒绝加载。当模块加载失败时dmesg末尾几条往往是定位问题的第一手信息。注意dmesg输出的时间戳要关注最近1-2秒的日志而不要被系统启动时的大量历史日志淹没。你可以用dmesg | tail -n 20看到关键错误后再把具体的符号名或地址记录下来配合源码和/proc/kallsyms分析。这里有一个很有用的经验/proc/kallsyms里能看到内核符号表。模块加载后模块内部的非static符号也会出现在这个表里。如果你怀疑模块加载后某些符号没有正确注册可以去这个文件里查一下。但要注意从内核4.x开始非root用户看到的符号地址都被置为0了需要root权限才能拿到真实地址。5.3 内存问题模块比用户态程序更致命内核模块运行在内核态没有独立的地址空间保护一个野指针或越界写直接破坏的是内核内存。轻则Oops重则整个系统直接panic重启。最常见的几种内存错误包括在初始化函数里使用未初始化的指针在中断上下文里调用会睡眠的函数比如kmalloc带GFP_KERNEL标志缓冲区越界写覆盖了相邻的内核结构退出函数没有释放init函数里申请的资源或者释放了两次。排查这些问题常用的手段是开启KASANKernel Address Sanitizer和KMSANKernel Memory Sanitizer。这两个工具在编译内核时开启能检测到越界访问、使用未初始化内存等错误并把详细的调用栈打印出来。代价是系统性能和内存占用会有明显下降所以一般只在开发调试阶段开启生产环境要关闭。我自己的调试习惯是开发板或者虚拟机上编译一个带KASAN的内核专门用来做驱动开发验证。等驱动逻辑稳定之后再切换到生产内核里测试。这样能在驱动开发的早期就把很多内存问题暴露出来省去后面在生产环境里排查的极大痛苦。5.4 版本魔法与符号版本控制模块和内核版本的匹配问题本质上是ABI兼容性问题。内核社区给出的解决方案是vermagic和CONFIG_MODVERSIONS。vermagic就是嵌在模块里的一串版本信息字符串包含内核版本号和几个关键配置选项比如是否开启SMP、是否抢占。加载时内核会比对vermagic不一致就拒绝加载。CONFIG_MODVERSIONS则提供了更细粒度的符号级版本控制。每个导出符号会被计算一个CRC校验值模块编译时把用到的符号的CRC值记录下来加载时与内核实际符号的CRC值比对。如果CRC不一致说明符号的ABI可能发生了变化加载就会失败。对开发者来说这个机制带来的实际影响是如果你在内核源码外编译模块必须保证编译环境的内核源码和运行内核完全一致。最稳妥的方式是使用与运行内核相同的源码版本编译模块前先编译并安装内核至少运行make modules_prepare。对于发行版内核一定要安装对应的linux-headers包而不是随便下载一个内核源码包就开始编译。6. 容器和嵌入式场景下的模块管理6.1 为什么容器里加载模块总失败很多人在Docker容器里执行insmod失败疑惑为什么权限明明够还是报Operation not permitted。原因很简单模块加载是全局性的内核操作不是进程隔离级别的。即使你在容器里有CAP_SYS_MODULE权限能触发的也只是宿主机的模块加载路径。如果宿主机内核版本或配置不允许或者模块与宿主机内核版本不匹配加载就会失败。容器的网络、存储等底层功能依赖的驱动模块必须在宿主机上加载。容器内部不需要也不应该加载模块——这是容器隔离设计的初衷之一。所以遇到容器里模块加载失败第一反应应该是把模块放到宿主机加载或者通过宿主机上的udev规则统一管理。6.2 嵌入式设备上模块目录的组织嵌入式系统上的模块目录通常不会像桌面发行版那么完整一般只有驱动开发所需的少量模块。合理的模块目录结构大致长这样/lib/modules/5.10.100/ ├── modules.dep ├── modules.alias ├── modules.symbols └── extra/ ├── gpio_demo.ko ├── spi_master.ko └── sensor_driver.komodules.dep、modules.alias、modules.symbols这些文件都是通过depmod命令生成的。每次往模块目录里添加或删除模块后都要在目标设备上重新执行depmod -a否则modprobe可能找不到新添加的模块或者按旧的依赖关系加载模块导致失败。这里我特别想强调一个在嵌入式新产品调试时反复出现的坑更新模块后忘了重新执行depmod -a结果测试时用的还是旧模块浪费了整整大半天排查代码改了为什么不生效。后来我养成了一个习惯每次烧录新模块到设备后第一条命令就是depmod -a sync确保设备上的模块依赖信息是最新的。6.3 模块卸不掉时怎么办模块卸载失败是嵌入式开发中的高频问题常见场景和解决办法如果rmmod提示Module is in use说明还有进程引用这个模块。先用lsmod看引用计数再用fuser -v /dev/设备节点或者lsof找到具体是哪个进程占用了设备文件让进程退出后再卸载。如果提示Module xxx is used by yyy说明模块yyy依赖xxx。先把yyy卸载再卸载xxx。如果yyy就是卸载不掉还得回到第一步排查进程占用。如果模块进入了D state不可中断睡眠导致卸载卡死基本上只能重启设备没有太温和的解决办法。这种状态通常意味着内核线程卡在某个设备IO等待上比如你在驱动里错误地加了一个等待队列却没人唤醒它。6.4 模块参数与设备树的配合在现代内核尤其是嵌入式Linux中驱动参数越来越多地倾向于从设备树Device Tree获取而不是用module_param。设备树描述硬件的拓扑和配置驱动通过匹配设备树节点获得硬件信息这比硬编码模块参数规范得多。但模块参数并没有被淘汰。在一些不依赖设备树的场景下比如纯虚拟设备、测试驱动、或者设备树不便于描述的一些运行时行为开关模块参数依然是最简单直接的手段。实际项目中通常是两者并用硬件相关的信息放设备树调试相关的行为开关用模块参数。举个例子我在调一个视频采集驱动时硬件管脚映射和数据格式通过设备树节点描述而测试用的帧率、调试日志开关则通过模块参数控制。这样同一个驱动既能部署到不同硬件上又能在开发阶段灵活调整行为不用反复改设备树重新编译。7. 从模块机制出发向内核更深处探索写驱动和写普通应用程序的思维方式完全不同。应用程序崩溃了最多是段错误进程退出驱动模块写错了轻则Oops日志刷屏重则整个内核panic。模块机制本身提供了相对灵活的加载卸载能力但这不意味着我们可以随意对待模块的代码质量。回顾我自己从零开始写驱动的经历模块机制是我认为最值得先掌握的基础设施。它不是简单的一条insmod命令而是涉及ELF重定位、符号解析、生命周期管理、权限控制、依赖管理的一整套体系。把这条链路彻底理解透彻再去学字符设备驱动、平台总线模型、中断子系统都会顺很多。因为这些更上层的Linux驱动框架本质上都是运行在模块机制之上的一套套约定——它们把复杂硬件抽象成标准接口再通过模块机制暴露给内核。接下来在这个系列里我计划继续写字符设备驱动框架、platform总线模型、设备树与驱动匹配、中断处理与底半部机制等主题。每篇文章我都尽量把原理和实操结合起来把我实际调试时踩过的坑和排错的思路都写清楚而不是只给一个简单的API示例。如果你在实践过程中遇到了我文章里没覆盖到的怪问题建议先沉住气用dmesg把信息抓全再用/proc/kallsyms和/sys/module逐步定位。模块机制这块没有捷径多编译几次、多加载几次、多崩溃几次就慢慢摸到门道了。