尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Linux内核模块从入门到实战:Hello World的编写、编译与加载卸载

Linux内核模块从入门到实战:Hello World的编写、编译与加载卸载 1. 内核模块到底是什么先搞懂这层皮1.1 内核态与用户态的区别为什么要“进内核”写代码我们平时写的C程序、Python脚本跑起来之后都属于用户态进程。用户态程序能做的事情非常有限读写文件、收发网络包、打印到终端这些操作统统要经过操作系统内核的“审核”。内核就像一家银行的柜员你递进去一张取款单系统调用柜员核对身份、检查余额最后才把现金递给你。这套流程的好处是安全一个程序出了问题最多把自己搞崩不会影响整台机器。但有些场景用户态这套“审核流程”就成了瓶颈。比如你想对每个网络数据包做深度过滤、想监控某个文件的每一次读写、想在内核里新增一种文件系统这些操作如果走系统调用一来一回的性能损耗是不可接受的而且有些东西根本不在用户态的能力范围内。这个时候就需要把代码直接跑在内核态也就是让代码成为内核的一部分。内核态代码拥有最高的CPU特权级可以执行特权指令、直接访问内存和管理硬件。换句话说它不再是“客户”而是“柜员本人”。这带来了巨大的能力也带来了巨大的责任一不小心写崩了直接就是系统死机或者内核崩溃Kernel Panic连个报错弹窗都不给你。也正因为如此我们不会随随便便改内核源码然后重新编译整个内核这代价太高了于是就有了内核模块。1.2 内核模块的工作方式不重编内核也能动态加载内核模块Kernel Module是一种可以在运行时动态加载进内核、也可以随时卸载的代码单元。最经典的例子就是各种驱动程序你把一个USB无线网卡插上系统自动加载对应的驱动模块设备拨掉模块自动卸载。整个过程不用重启机器不用重新编译内核。这背后的机制有点像现在很多软件做的“插件体系”主程序保持稳定不变需要什么功能就动态加载对应的插件。内核模块和普通内核代码的区别在于链接方式。普通内核代码是静态编译进内核镜像的启动时就常驻内存而内核模块是独立的.ko文件通过insmod命令加载时内核会把这个文件里的代码动态链接进内核空间注册对应的初始化函数执行完毕后它在内核里就“安了家”。卸载时rmmod命令会触发退出函数把资源释放干净然后从内核里“物理移除”。这种设计让内核保持精简也让驱动和文件系统的开发调试周期大大缩短。而 Hello World毫无疑问是理解这一切的最好起点。它虽然不做什么实事但麻雀虽小五脏俱全涵盖了模块的入口、出口、编译、加载、日志输出、卸载这整个完整闭环。把这一套流程跑通你就掌握了所有更复杂内核模块的骨架。2. 准备开发环境这是最容易被新手卡住的一步2.1 环境选型真机、虚拟机还是 WSL2写内核模块第一个问题不是“怎么写”而是“在哪写”。我见过太多新手一上来就在自己的主力机器上折腾结果把宿主机搞崩了心态直接爆炸。我的建议很有分寸无论如何别在物理真机上拿刚写出来的模块做试验。如果你本机就装的是Linux建议装个虚拟机VirtualBox或VMware都行装个Ubuntu Server/Debian系统分配2个CPU和2GB内存就足够编译内核模块了。虚拟机最大的好处是快照功能模块把内核搞崩了回滚快照三秒满血复活。如果追求方便Windows用户也可以用WSL2Windows Subsystem for Linux来搭环境。WSL2本身就是一个轻量级虚拟机内核版本也很新编译和加载内核模块完全没问题。不过有一点需要留意WSL2的内核是微软自定义签名的有时候加载第三方模块会碰到签名校验不通过的问题而且WSL2下并不能真正测试与硬件强相关的驱动模块。所以如果只是学习代码和机制WSL2完全够用如果要写真实的硬件驱动还是建议用虚拟机里的标准发行版。2.2 安装必要的工具链和内核头文件无论你选哪种环境前提是装上编译内核模块所需的工具。以 Ubuntu/Debian 系为例只需要执行sudo apt update sudo apt install build-essential linux-headers-$(uname -r)这里面有两个关键点需要解释一下。第一build-essential包含了 gcc、make 等基础编译工具。第二linux-headers-$(uname -r)这个包非常关键它的路径会对应你当前正在运行的内核版本里面装的是内核源码的头文件、Makefile 和一些配置脚本。内核模块在编译时需要依赖这些文件来解析内核的接口和数据结构。这里有个高频坑如果你从软件源安装头文件没问题但如果你是自己手动编译过内核或者系统里有多套内核文件那么linux-headers-$(uname -r)这套命令就必须严格匹配当前运行的uname -r输出。比如你当前内核是5.15.0-91-generic那么装的头文件也必须是这个版本。装错了后面编译出来的模块大概率加载不进去。2.3 验证环境是否就绪的快速检查清单装好工具后不要急着写代码先花半分钟确认环境是否靠谱。执行以下三条命令uname -r ls /lib/modules/$(uname -r)/build which gcc gcc --versionuname -r输出当前内核版本号ls /lib/modules/$(uname -r)/build确认头文件目录存在如果提示没有这个目录说明头文件没装好gcc --version确认编译器可用。这三条全绿就可以进入下一步了。这个清单看起来简陋但真的能拦下一大半的环境问题特别是很多人到了make报错才回头看结果浪费大量时间。3. 写一个内核版的 Hello World逐行拆解3.1 完整代码hello.c环境搞定后新建一个工作目录例如~/hello_module然后在里面创建hello.c#include linux/init.h #include linux/module.h #include linux/kernel.h static int __init hello_init(void) { printk(KERN_INFO Hello, World from kernel module!\n); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO Goodbye, kernel module unloaded!\n); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name youexample.com); MODULE_DESCRIPTION(A simple Hello World kernel module);这套代码就是内核模块的最简骨架整体结构非常清晰一个入口函数、一个出口函数、两个注册宏、三段模块描述信息。任何内核模块哪怕复杂到网卡驱动、文件系统驱动骨架都是这样的形态只是里面做的事变多了。3.2 每一个字段的含义Lincense、init、exit、printk我们先从第一梯队说起也就是两个注册宏。module_init(hello_init)和module_exit(hello_exit)是内核模块的“身份证”。module_init声明这个模块加载时调用哪个函数作为入口module_exit声明模块卸载时调用哪个函数作为出口。这两个宏本质上是把对应的函数指针放到内核预定义的.initcall和.exitcall段里加载器和卸载器会去这些段里找函数。理解了这一点你就知道为什么模块必须要有且仅有一个module_init—— 没有入口函数内核根本不知道怎么启动你的模块。static int __init hello_init(void)里有两个细节值得注意。static把函数作用域限制在当前文件因为在模块内部这两个函数不需要被外部文件引用内核模块的符号表默认不会导出所以没必要用全局符号。__init是一个内核宏它告诉内核这个函数只在初始化阶段使用加载完成后这片内存会被释放掉可以用来节省内存。这对一个几十行的Hello World没什么意义但对真正的大型驱动模块能省下好几KB到几MB的内存。同样__exit标记的退出函数在模块被编译进内核而不是作为模块加载的时候会被直接丢弃。然后是printk。它经常被直接理解为“内核里的 printf”这个类比大体没错但差异很关键。printk打印的不是标准输出而是内核的日志缓冲区ring buffer日志最终通过dmesg命令查看或者写到/var/log/kern.log。另外一个区别是printk带日志级别KERN_INFO就是其中一种表示这是一条普通信息。如果不写日志级别不同内核版本下会根据默认级别决定是否显示到控制台非常容易造成“我明明打印了怎么屏幕上什么也没有”的经典困惑这点后面第5节会详细展开。最后是MODULE_LICENSE、MODULE_AUTHOR、MODULE_DESCRIPTION这三个宏。它们不产生业务逻辑但并不是可有可无的装饰品。特别提一下MODULE_LICENSE(GPL)这行很重要因为它和符号导出有关如果模块不是声明 GPL 协议那么它就无权使用内核里标记为EXPORT_SYMBOL_GPL的符号也就是 GPL-only 的导出符号。很多核心的、敏感的接口都是 GPL-only 的所以不规范写 License可能在编译时没问题但加载时直接报错或者运行时某些功能不可用。我的习惯是写任何模块第一行需要放的就是这个。3.3 Makefile 怎么写以及为什么要这样写光有.c文件还不够还需要一个 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) clean这个 Makefile 的核心逻辑只有一行$(MAKE) -C $(KDIR) M$(PWD) modules。拆开来看-C $(KDIR)的意思是切换到内核源码目录去执行 make因为内核模块的构建规则都在那个目录里的 Kbuild 系统里M$(PWD)告诉 Kbuild你要编译的外部模块源码在我当前目录modules是编译目标表示生成内核模块。obj-m : hello.o这一行是 Kbuild 约定的写法它说明当前目录下的hello.o要作为可加载模块来构建。Kbuild 会根据hello.o自动去找同名的hello.c源文件所以文件名必须严格对应否则编译报错“No rule to make target”。有一个特别常见的误区需要说明很多人以为编译内核模块就是在当前目录下执行gcc -c hello.c直接生成.o文件甚至有人手动去链接生成.ko。这是完全错误的。内核模块的编译必须借助内核的 Kbuild 系统因为模块需要和内核的符号表、段定义、编译选项对齐。手动编译出的.o文件没有.modinfo段也没有正确的重定位信息硬加载必然失败。所以老老实实用 Makefile 和 Kbuild 才是正道。3.4 亲手编译make 命令的执行过程在hello_module目录下执行make你会看到类似下面的输出make -C /lib/modules/5.15.0-91-generic/build M/home/user/hello_module modules make[1]: Entering directory /usr/src/linux-headers-5.15.0-91-generic CC [M] /home/user/hello_module/hello.o MODPOST /home/user/hello_module/Module.symvers CC [M] /home/user/hello_module/hello.mod.o LD [M] /home/user/hello_module/hello.ko make[1]: Leaving directory /usr/src/linux-headers-5.15.0-91-generic这里有一个值得留意的文件名hello.mod.o。这个文件不是从你的源码直接编译的而是 Kbuild 在MODPOST阶段自动生成的它里面包含了模块的依赖关系信息和 .modinfo 段。最终链接生成hello.ko时会把hello.o和hello.mod.o合并在一起。这也是为什么手动编译模块行不通因为hello.mod.o里的元数据是不可或缺的一部分。编译成功后当前目录下会出现一堆文件hello.ko、hello.o、hello.mod.o、hello.mod.c、Module.symvers、modules.order。我们真正需要的是hello.ko。用file hello.ko或者modinfo hello.ko可以查看模块信息modinfo输出里能看到之前声明的描述、作者、License 等元数据filename: /home/user/hello_module/hello.ko license: GPL author: Your Name youexample.com description: A simple Hello World kernel module vermagic: 5.15.0-91-generic SMP mod_unload modversions注意vermagic这一行它记录了编译这个模块时的内核版本及配置。加载时内核会校验这个字符串是否匹配不匹配就会报错这也是后续常见问题的根源之一。4. 插入与卸载让“字”真正出现在内核日志里4.1 insmod 与 modprobe 的区别编译出了hello.ko接下来就是加载。加载模块的命令是insmodsudo insmod hello.koinsmod命令的作用很直接把指定的.ko文件加载进内核并调用模块的初始化函数。它不会去解析依赖关系你给它什么文件它就加载什么文件。如果模块还依赖其他模块你得自己先把依赖的模块加载好。日常系统管理中有另一个命令叫modprobe。它和insmod最大的区别是modprobe会解决依赖关系还会在标准模块搜索路径/lib/modules/$(uname -r)/里查找模块。但是我们自己编译的模块通常在用户目录下不在标准路径里所以如果要让modprobe能找到需要手动拷贝到/lib/modules/$(uname -r)/extra/再执行depmod -a更新依赖。对于开发调试阶段insmod仍然是最高效、最直接的选择。4.2 查看内核日志dmesg 的几种玩法加载完模块后printk的输出并不会直接出现在终端上它进了内核的环形缓冲区。查看这个缓冲区最经典的命令是dmesg但直接跑dmesg会把开机以来所有的内核日志全部刷出来内容非常多。如果想只看自己模块产生的日志用管道过滤dmesg | tail -n 20或者更精准地过滤关键字dmesg | grep Hello如果内核日志输出太快被覆盖了还可以配合time或先dmesg -c清空缓冲区再加载模块这样后面的输出就干净很多。dmesg -c会清空缓冲区且打印清空前的日志调试时很好用但生产环境慎用。另外一个非常实用的命令是tail -f /var/log/kern.log很多发行版会把内核日志持久化到这个文件。用这个命令可以实时看到模块加载和卸载时的日志输出比来回敲dmesg顺畅不少。4.3 卸载模块rmmod 与清理流程卸载模块的命令是sudo rmmod hello这里需要注意rmmod后面跟的是模块名也就是hello而不是文件名hello.ko。模块名是obj-m : hello.o里定义的简单说就是.ko文件名去掉后缀。模块卸载时内核会调用hello_exit函数所以日志里会打印 Goodbye 这一行用dmesg | tail就能看到。实际开发中还有一些排查模块状态的命令。比如lsmod可以列出当前所有已加载模块显示模块大小、被哪些模块依赖等信息modinfo hello.ko可以查看某个.ko文件的元信息。模块加载不成功或卸载不掉时通常还要结合rmmod的报错和内核日志中的详细原因来定位。4.4 一个完整演练从编译到卸载的一次干净流程我建议你养成一个习惯每次测试都走一遍干净流程避免环境残留干扰判断。下面是一个完整示例# 1. 编译 make clean make # 2. 查看模块信息验证编译没问题 modinfo hello.ko # 3. 清空历史内核日志便于只看本次实验的输出 sudo dmesg -c # 4. 加载模块 sudo insmod hello.ko # 5. 确认模块已加载 lsmod | grep hello # 6. 查看本次日志 dmesg # 7. 卸载模块 sudo rmmod hello # 8. 再次查看日志确认入口和出口函数都被执行了 dmesg | tail -n 10如果你在第6步看到了 “Hello, World from kernel module!”在第8步看到了 “Goodbye, kernel module unloaded!”恭喜你的内核模块处女作完整跑通了。到这一步你已经掌握了内核模块开发的整个闭环编写、编译、加载、验证、卸载。这个闭环是所有更复杂内核开发工作的基础。5. 常见问题与排错实录实战避坑5.1 问题一make 报错“没有规则可创建目标”现象执行make时报错类似make[1]: *** No rule to make target hello.c, needed by hello.o. Stop.原因分析这个报错一般有几种可能。一是hello.c文件名和obj-m : hello.o中的模块名不一致比如你把源码文件起名叫test.c但 Makefile 里写的还是hello.oKbuild 找不到对源文件二是当前目录下根本没有.c文件或者路径不对导致 Kbuild 在M$(PWD)目录里找不到源文件。解法检查文件名是否严格匹配检查是否在正确的目录下执行 make建议执行一下ls -l *.c确认源文件在位。还有一种不太常见的情况是你的 Makefile 里用了PWD : $(shell pwd)但通过 sudo 执行 make 时环境变量变化导致路径异常这种情况建议在 Makefile 里把路径写死或者先cd到源码目录再执行 make。5.2 问题二insmod 提示 “Invalid module format”现象执行sudo insmod hello.ko时报错通常是insmod: ERROR: could not insert module hello.ko: Invalid module format原因分析这是内核模块开发里最经典的报错之一。根本原因是模块的vermagic和当前运行内核的版本、配置不匹配。比如你编译模块时用的是/lib/modules/5.15.0-91-generic/build的源码树但当前运行内核却是5.15.0-89-generic或者系统更新内核后没有重启都会导致这种情况。还有一种可能是编译模块时用的编译器版本或配置和内核本身不一致。解法先用uname -r查看当前内核版本再检查头文件是否匹配ls /lib/modules/$(uname -r)/build确认无误后就make clean再重新编译一次。如果还不行可以对比modinfo hello.ko里的vermagic和/lib/modules/$(uname -r)/build里的内核配置是否一致。在自定义内核的场景下还需要确认内核源码的.config里CONFIG_MODVERSIONS等选项是否和模块编译时一致。5.3 问题三printk 的日志去哪了、为什么看不见现象模块加载成功了lsmod里也能看到但dmesg里就是看不到Hello, World!那行打印。原因分析这个问题的可能性比较多。最常见的是printk的日志级别比内核控制台的日志级别低导致输出被过滤掉了。内核里printk的日志级别从 0紧急到 7调试数值越小越重要。KERN_INFO是级别 6如果当前内核控制台的loglevel是 4那么级别 6 的信息不会打印到控制台但它仍然会写进内核的日志缓冲区理论上dmesg里还是能看到。如果dmesg里也没有那可能是你用了自定义printk格式或者日志缓冲区被大量信息刷掉了。解法用dmesg | tail -n 30看最新的日志确认有没有被其他信息淹没如果看不到就检查是不是printk的字符串里有特殊字符被截断。还有一个好习惯在每个模块的打印前加上独特的关键字比如printk(KERN_INFO mymod: hello\n)这样过滤时dmesg | grep mymod一下就能找到。5.4 问题四加载模块时权限报错和“Operation not permitted”现象执行insmod时报Operation not permitted即使加了sudo也一样。原因分析这个报错在较新的发行版上越来越常见。一是 Secure Boot 开启后内核会校验模块的数字签名未签名的模块无法加载二是内核编译时开启了模块签名强制校验功能CONFIG_MODULE_SIG_FORCE。在这两种情况下自己编译的模块都没有有效签名于是被拒绝加载。解法开发机上最简单的处理是关闭 Secure BootBIOS 里操作或者在内核启动参数里加上module.sig_enforce0。虚拟机场景直接关闭 Secure Boot 最省事。另外如果你用的是自己定制的内核需要重新配置内核选项关掉强制签名校验。这个问题在 WSL2 上也会遇到因为微软签名了 WSL 内核第三方模块加载可能被拒这也是我前面建议学习阶段优先用虚拟机的原因之一。5.5 避坑心得我自己的几个习惯写内核模块这几年我总结了几条特别朴素的习惯写在这里供参考。第一在虚拟机里调试。这是一条铁律。内核模块不像普通程序段错误顶多就是进程退出模块写崩了就是整机崩溃。VMware 或 VirtualBox 的快照功能是我最后的救命稻草每次试验前打一个快照出了问题直接回滚效率高得不是一点半点。第二日志先行。写代码之前先把日志钩子设计好每个函数入口、出口、关键路径都要有唯一的日志标签。尤其是涉及错误分支时日志里一定要包含具体信息。很多时候你以为代码不对实际是日志看少了模块里发生了什么dmesg基本都能告诉你。第三版本对齐强迫症。内核模块对内核版本极其敏感建议每次编译前都执行uname -r确认环境不要在多个内核版本之间反复横跳。如果你需要为多个版本编译模块那就要用 DKMS 这类工具来管理而不是手动维护一堆.ko文件。第四先写骨架再填逻辑。开发一个模块功能前我习惯先把module_init、module_exit空壳写好让模块能编、能装、能卸再逐步往里加功能。这样可以保证任何时刻模块都处在一个可回退的状态排查问题时也能快速定位是新加的哪一块出了问题。最后再分享一个小技巧在这个 Hello World 的基础上有一个很实用的扩展方向给hello_init增加参数支持。内核模块可以像命令行程序一样接收参数声明方式非常简单static int count 1; module_param(count, int, 0644); MODULE_PARM_DESC(count, Number of times to print hello);然后在hello_init里用一个循环根据count打印多次。编译后用insmod hello.ko count5加载就能看到输出5次。同时参数会出现在/sys/module/hello/parameters/count文件里并且因为权限是 0644运行时可修改、实时生效。这个小功能能让你更直观地感受到内核模块和普通程序在“运行参数”处理上的差异也能顺带提前熟悉sysfs接口的雏形——而sysfs是几乎所有真实驱动都会用到的东西。从一个最朴素的Hello World出发逐步往里面加参数、加/proc或/sys接口、加并发处理你会发现内核编程的乐趣是一层层铺开来的。希望这篇分享能帮你迈过这第一道门槛。
返回列表