Linux内核模块开发入门:从insmod到rmmod的完整实践
第一次接触Linux内核模块的时候我一直在想一个问题内核本身是一个编译好之后几乎固化在内存里的单体大程序每次加个驱动、改个功能都要重新编译一次内核实在难受。后来真正用上insmod和rmmod这条链路才意识到内核模块机制其实解决了一个特别现实的痛点给内核动态补功能却不用重新编译、不用重启机器。Linux内核模块就是一段可以被动态加载进内核地址空间的代码常见载体是.ko文件最常见的用途是设备驱动、文件系统、网络过滤这类需要深入内核态才能干活的功能。这篇文章我尽量把你需要的脉络一次讲清楚模块机制解决什么问题、一个最小模块由哪些部分组成、从零编译加载一个模块的完整过程、我踩过的坑以及平时调试模块时常用的手段。适合刚开始接触驱动开发、准备分析内核源码、或者纯粹想搞明白insmod到底做了什么的人。1. 为什么需要内核模块先搞懂它解决的痛点1.1 单体内核的僵硬带来的麻烦Linux采用宏内核架构核心的操作系统功能全部在一个大的内核映像里启动时一次性装载进内存。好处是函数调用开销小、数据共享直接坏处也显而易见功能一旦定下来后面想改动就得重新编译整个内核。一个真实的场景是设备管理器里多了一款网卡驱动没编进去传统做法是重新配置内核、编译、再重启。内核对大多数开发者来说编译一次半小时起步服务器上重启一次还有业务中断风险这显然不是理想的更新方式。模块机制把僵硬的单体内核变成了可以在运行期拼装的形态。设备驱动、文件系统、网络协议等组件从内核主映像中拆出来按需编译成独立文件。启动时只需要基础内核需要哪个组件就把对应文件加载进去不需要了可以卸载内存也跟着释放。整个设计很像给系统加装外部设备不用把整台机器拆开重做插上即用拔掉即走。1.2 模块机制的本质一段可以在运行期插拔的内核代码从实现上讲一个内核模块本质上还是一段ELF格式的目标文件但它不参与内核链接生成vmlinux而是以可重定位对象的形式保存文件后缀是.ko。加载时insmod或modprobe会读取这个文件通过内核提供的一系列系统调用把代码段和数据段复制进内核地址空间再进行符号解析和重定位。这一步做完模块的代码真正跑在内核态拥有内核权限可以访问所有内存和硬件资源。加载过程中还有依赖链的问题。模块A可能调用了模块B导出的一些函数如果B还没有加载A就没办法独立工作。这也是为什么命令insmod和modprobe有区别insmod只负责把指定文件硬塞进内核依赖关系处理得很简单modprobe会先查模块依赖表把需要的依赖模块全部加载完再去加载目标模块。日常工作里我习惯用modprobe省心。1.3 什么场景适合用模块什么场景不适合适合用模块的场景很多各类外围设备驱动、文件系统驱动、网络过滤钩子、安全审计模块、自定义内核协议等。模块机制把这些功能拆成了独立的开发单元编译快、调试隔离、上线和回滚都灵活。不适合用模块的场景也有。模块加载后会有一部分额外的链接和跳转开销虽然大多数情况下可以忽略但在最极端的内核热路径上比如网络收包的每包处理逻辑多一次间接跳转也会被较真的性能测试放大。另外如果某个功能从开机启动阶段就必不可少那把它编进内核主映像反而更可靠避免启动时还要从磁盘加载模块的过程。做个简单的取舍追求灵活性和开发效率用模块追求极致的启动确定性就编进内核。2. 模块的骨架结构、宏与生命周期2.1 一个最小模块长什么样业内流传的Hello World模块版本很多我给一个最小但完整的版本适合做所有实验的起点。#include linux/init.h #include linux/module.h static int __init demo_init(void) { printk(KERN_INFO demo module loaded\n); return 0; } static void __exit demo_exit(void) { printk(KERN_INFO demo module unloaded\n); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(A Developer); MODULE_DESCRIPTION(A minimal demo module); MODULE_VERSION(1.0);每个部分都有它存在的必要。module_init和module_exit是两个入口绑定宏分别告诉内核这个模块加载时执行哪个函数、卸载时执行哪个函数。函数本身加__init和__exit标记是为了提示内核这些函数只在初始化和卸载阶段有效调用完可以释放对应内存这是内核模块节省资源的一个小手段。MODULE_LICENSE声明许可证这不仅是规范问题还会影响内核是否允许你使用某些未导出的GPL符号实际工作中经常遇到因为许可证不匹配导致模块加载失败的情况。MODULE_AUTHOR、MODULE_DESCRIPTION、MODULE_VERSION是元信息用来给工具展示模块的来源和用途的。2.2 模块的退出点干净的卸载比鲁莽的加载更重要很多初学者都容易忽视卸载路径。写模块时分配了内存、注册了接口、创建了proc节点卸载段就必须把对应资源全部释放。内核态不像用户态进程退出时操作系统会替你回收资源内核模块一旦漏掉释放那部分资源就永久丢失了只能靠重启来恢复。一个比较稳妥的卸载顺序是先停止对外提供的服务再注销接口最后释放动态分配的内存。这个顺序反过来会让模块在被外部引用的情况下突然失效轻则内核报错重则直接触发Oops。我自己早期写模块时吃过这个亏注册了proc接口忘了删后来在系统日志里看到一堆访问空指针的报错才意识到清理动作必须和加载动作一一对应。2.3 模块的引用计数与依赖关系内核通过模块的引用计数来判断一个模块是否还能卸载。每次有进程打开某个设备节点、某个功能模块注册了回调内核都会通过try_module_get递增模块的使用计数释放时再module_put减回去。如果在使用计数不为零的情况下执行rmmod内核会直接拒绝卸载提示模块正被占用。查看占用情况lsmod输出里的Used by列就是引用计数的直观展示。一个模块也可能被另一个模块引用比如某网络过滤框架被各种具体实现模块依赖不卸载掉上层模块底层模块就永远退不出来。排查这类问题必须顺着依赖关系先把引用方清干净再做卸载。3. 从零实操编写、编译、加载、卸载一个模块3.1 环境准备先确认内核版本和头文件写模块和写用户态程序不一样模块必须对着正在运行的那颗内核来编译。第一步永远是确认当前内核版本uname -r拿到版本号后检查对应的内核头文件是否存在。模块编译需要内核源码或至少全套头文件以及Kbuild构建系统文件。大多数发行版默认不会装这些需要自己安装。包名通常是linux-headers-内核版本号在你自己用的发行版里搜一下装好即可。安装完成后验证一下目录是否可用ls /lib/modules/$(uname -r)/build能正常进入目录并且里面有Makefile文件就说明编译环境基本就绪了。另外还可以确认一下内核是否开启了模块支持运行grep CONFIG_MODULES /boot/config-$(uname -r)输出为CONFIG_MODULESy说明当前内核支持模块加载这个配置在绝大多数标准内核里都是开启的。3.2 编写代码和Makefile模块专用的构建方式模块不会像普通程序那样走gcc直接链接出可执行文件而是靠内核的Kbuild系统来编译。原因是模块要放进内核的符号环境里需要内核自己提供一系列编译选项、头文件和链接规则。我建议你直接复制下面的Makefile模板KDIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) obj-m : demo.o all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) cleanobj-m : demo.o这行声明了要生成一个名为demo.ko的内核模块对应的源码是demo.c。KDIR指向当前内核的构建目录-C让make先切到内核构建系统里读取它的顶层MakefileM$(PWD)告诉内核构建系统源码在哪个目录。执行make后内核构建系统会依次完成预处理、编译、符号处理最终生成demo.ko。我第一次看到这个Makefile时也觉得绕后来理解了一个关键点模块编译不是在“用户程序的编译环境”里完成的而是要借用“内核自身的构建体系”来完成所以必须通过-C参数把构建控制权交给内核目录下的Makefile。3.3 编译过程与产物确认在源码目录直接执行make片刻之后目录下会出现多个文件包括demo.ko、demo.o、demo.mod.o等。用file命令可以检查产物类型file demo.ko正常的输出会带有ELF 64-bit LSB relocatable字样。relocatable意味着这是一个可重定位文件还需要内核完成地址重定位才能运行这跟普通可执行文件有着本质差异。用modinfo查看模块的元信息modinfo demo.ko可以看到我之前在代码里声明的描述、作者、许可证信息等。3.4 加载、验证、卸载完整的生命周期演练清理掉之前的demo模块后如果有的话先把一段空日志看一下当前内核的消息尾部在哪里方便之后观察输出dmesg | tail然后加载模块sudo insmod demo.ko加载没有任何输出是正常的查看模块是否加载成功用lsmodlsmod | grep demo也可以用modinfo直接查看已加载模块的信息modinfo demo看到模块已经在列表里后再看内核日志dmesg | tail能明显看到加载时打印的那条demo module loaded。这时候再做一次卸载sudo rmmod demo再用dmesg查看会看到卸载时打印的demo module unloaded。整个流程走完模块的核心生命周期也就演示完了加载、验证、查看日志、卸载、再验证。3.5 给模块传递参数实际驱动里模块经常要接收参数比如设备号、IO地址、中断号。这些参数是在insmod时从命令行传进去的。先改一下代码在模块里声明一个参数static int demo_param 0; module_param(demo_param, int, 0644); MODULE_PARM_DESC(demo_param, An example integer parameter);module_param宏的三个参数分别是变量名、类型、权限位。0644表示这个参数不仅可以在加载时传入在运行时还能通过sysfs文件接口查看甚至修改。加载命令变为sudo insmod demo.ko demo_param42模块里可以用printk把这个参数打出来验证。sysfs接口则会出现在/sys/module/demo/parameters/demo_param也就是说系统里多了一个虚拟文件读它能拿到值有权限的话写它还能改值。注意并不是所有参数都允许运行时修改具体要看模块内部是否做了对应的处理逻辑外部权限位只是系统层面的一个开关。4. 踩坑实录常见问题与排查技巧4.1 版本不匹配导致的“Module version mismatch”最常见的问题没有之一。你用A版本内核头文件编译出的模块想在B版本内核上加载系统会报类似“disagrees about version of symbol”的错误。原因在于内核模块会记录一份vermagic字符串里面包含内核版本号、是否开启抢占、是否SMP等关键编译选项加载时内核会严格比对这份字符串。遇到这种问题没有捷径重新用当前内核对应的头文件编译模块。4.2 Unknown symbol错误模块加载时报告unknown symbol通常是两种情况该符号没有被内核或其他模块导出或者该符号所在依赖模块尚未加载。模块间共享函数需要显式用EXPORT_SYMBOL导出如果编译的是第三方模块源码里没有导出声明外部模块就链接不上。排查方法分两步。先看完整报错信息里给出的符号名再到已经加载的模块里找找grep 符号名 /proc/kallsyms如果这个符号有名无值或者根本不在列表里说明内核里没人导出它。如果是某个依赖模块没加载用modprobe先加载依赖模块再加载目标模块。4.3 printk打印的内容看不到模块加载后dmesg里能看到信息但控制台屏幕上看不到这是printk的日志级别在起作用。printk消息带优先级KERN_INFO级别是6内核只会把低于console_loglevel的消息打到物理控制台。默认级别往往是3或4所以KERN_INFO级别的消息被过滤掉了。想立即在控制台看到可以把printk改成KERN_ERR或更高级别也可以动态调整控制台日志级别echo 8 /proc/sys/kernel/printk调试时更推荐的做法是开一个dmesg -w监听日志别依赖物理控制台。4.4 编译时报找不到内核源码或头文件典型错误是“Cannot find kernel build directory”或者一堆linux/xxx.h文件找不到。原因就是没装内核头文件包。记住一个检验原则/lib/modules/$(uname -r)/build目录必须存在并且内容完整。装完头文件后先ls确认目录再make。4.5 卸载报模块正忙rmmod时提示类似“Module is in use”直接原因是引用计数不为零。lsmod的Used by列会显示占用模块列表。如果不是其他模块引用那大概率是有进程正在使用模块创建的设备节点或者回调接口。先终止相关进程释放文件句柄再做卸载。实在找不出占用方可以用以下方式查看内核日志里模块最后一次使用痕迹dmesg | grep demo通常能找到线索。4.6 加载后系统直接出Oops甚至死机模块刚加载就触发内核Oops最常见原因是空指针解引用、越界访问、使用了错误的内存映射方式。内核Oops的核心信息包括报错地址、寄存器现场、栈回溯。第一条建议是别慌先看dmesg里Oops附近的完整日志尤其是带有RIP标记的指令地址再把那条指令所在函数反汇编出来对比分析。这里我要反复强调一件事模块实验一定在虚拟机里做。我接触过的驱动初学者里有不少人习惯在自己的主力开发机上直接玩模块加载一个带野指针的模块接着就是系统卡死只能强制重启。虚拟机里随便折腾快照一恢复就回到初始状态效率高而且安全。我把常见问题整理成一份速查表方便直接对照现象常见原因排查方向模块版本不匹配报错头文件与当前内核版本不一致用uname -r确认版本重装对应头文件unknown symbol符号未导出或依赖模块未加载查/proc/kallsyms先加载依赖模块控制台无printk输出日志级别过滤dmesg查看调console_loglevel编译找不到内核头文件未安装linux-headers包安装当前内核版本的头文件卸载提示in use引用计数不为0lsmod看Used by释放占用进程加载触发Oops空指针、越界访问看dmesg栈回溯用虚拟机调试5. 调试手段与实验环境建议5.1 日志printk的分级使用printk是内核模块调试的基础设施它的第一个参数是日志级别。简单记一下常用级别KERN_EMERG级别0、KERN_ALERT 1、KERN_CRIT 2、KERN_ERR 3、KERN_WARNING 4、KERN_NOTICE 5、KERN_INFO 6、KERN_DEBUG 7。调试期可以用KERN_INFO甚至KERN_DEBUG随代码逐渐稳定再逐步把日志降级或者删掉。dmesg默认会带时间戳和消息来源配合cute的过滤用法dmesg | grep demo注意printk输出中出现的乱码或者截断往往意味着缓冲区溢出或者格式化字符串写错了优先检查参类型和格式串是否匹配。5.2 通过文件接口暴露模块内部状态很多驱动模块调试起来麻烦因为内核态没有用户态那样的调试器交互体验。一个朴素的思路是让模块自己开一个proc文件或sysfs属性把内部状态实时导出。有类似这样一个简化的proc文件创建流程struct proc_dir_entry *proc_entry; proc_entry proc_create(demo_status, 0444, NULL, proc_ops);然后在read回调里返回当前统计值。用户态用cat就能读取模块内部信息。这个思路在调试阶段非常实用能看到的效果比单纯printk直观得多。5.3 虚拟机和快照模块实验的安全区强烈建议在虚拟机里做所有模块实验。虚拟机的内存隔离能力有限但从快速恢复角度讲快照功能是无可替代的。一次危险的模块测试前先做一次快照。如果系统被模块搞崩了直接恢复快照比手动排查省下大量时间。如果手头有硬件相关的需求比如真实网卡的驱动调试那就只能在真实硬件环境做。这种情况下至少把测试机和业务机分开不要拿生产环境跑未经充分验证的模块。5.4 工具全家桶从insmod到depmod除了insmod、rmmod、lsmod、modinfo之外还有几个容易被忽视但实际很重要的命令。modprobe的依赖管理依赖depmod生成的modules.dep文件。每次安装了新模块运行depmod重新生成依赖索引modprobe才能正确解析依赖关系sudo depmod -amodprobe -r是卸载命令它比rmmod聪明的地方是会按依赖顺序处理不会像rmmod那样直接报错退出。内核模块相关的/var/lib/modules或者/proc/modules字段都值得看一眼模块加载状态和参数信息在/proc/modules里能看到完整列表。写在最后的几点体会模块开发和用户态程序开发心态完全不一样。内核态没有稳健的进程隔离和保护机制每一行代码出错都可能直接拖垮整个系统。所以我的习惯是先虚拟机里跑通再看真实机器每次只改一个变量加载前想好卸载路径。用printk把关键路径的入参与返回值打出来配合dmesg反复对照虽然枯燥但排查问题最可靠。还有一个比较实用的经验分享给正在学习的你模块参数这个能力一定要善用。调试阶段把阈值、开关、缓冲区大小都做成参数重启加载时方便调整不用每次改代码重新编译。等你把参数定下来、逻辑稳定了再考虑把这些参数写入sysfs暴露给用户态整个开发节奏会顺畅很多。内核模块这条路刚跨过门槛时觉得难真正走一遍之后很多所谓的“内核神秘感”其实都是被不熟悉的环境和不规范的实验流程放大的。