
两年前我第一次在真实硬件上调Linux驱动一条insmod命令把刚编译好的内核模块加载进系统结果直接panic。从那一刻起我才真正明白内核模块编程和用户态程序完全是两套思维方式。本文想带你走完编写、编译、加载第一个Linux驱动的全过程把我踩过的坑、花了一整天才想通的原理一次性讲清楚。它适合两类人一类是刚接触Linux驱动、被各种术语劝退的新手另一类是写过一些模块但只知其然、不知其所以然想系统搞懂加载机制的人。跟着走一遍你会理解为什么模块必须用GPL声明、为什么Makefile要那样写、为什么insmod有时候会莫名其妙失败。1. 为什么要和模块打交道Linux驱动形态的本质逻辑1.1 模块化设计的初衷从重新编译内核到动态插拔Linux早期并不是现在这种形态。你要给某个硬件写驱动最原始的做法是把驱动代码直接编进内核源码树然后重新配置、编译整个内核最后重启系统才能生效。如果你只是调一个字符设备驱动每改一行代码就重新编译一次完整内核那种痛苦几乎是劝退级的一次完整编译动辄十几分钟到半小时期间还不能出任何差错。内核模块机制的出现就是冲着解决这个问题来的。驱动被编译成一个独立的.ko文件系统运行的时候可以用insmod把它插进内核不需要的时候用rmmod再拔出来。就好比一台打印机你不必为了偶尔用一次扫描功能就把整个机器重装一遍只需要插上扫描模块就能用用完摘掉也不影响主机运行。这个思路让驱动的开发和调试效率提升了不止一个数量级。当然模块化不是没有代价。当你把代码编进内核时编译器能做全程序优化函数调用更高效而模块机制涉及动态链接性能上会有一点损耗。此外模块加载时还可能面临版本不一致这类问题——我在第6章会展开讲。1.2 内核态和用户态模块运行在哪儿凭什么危险理解内核模块之前你得先建立一个清晰的图景Linux系统分成内核态和用户态两个世界。平时你运行的ls、bash、gcc这些程序都活用户态它们通过系统调用向内核请求服务而内核模块不同它加载后直接运行在内核态跟内核本身共享同一个地址空间拥有最高的权限级别。这意味着什么**用户态程序段错误顶多自己崩掉内核不受影响但是模块里一个野指针可能直接让整个系统死机。**这也是我第一次加载驱动就panic的根本原因——我在模块里访问了一个未映射的内存地址。所以做内核模块编程最基本的心理准备是你写的代码不再受用户态保护机制约束任何内存访问错误都会变成系统级事故。这不是危言耸听是每一个驱动开发者都必须接受的现实。后面讲__init和__exit标记时你还会看到内核如何想尽办法在模块生命周期结束后把资源回收干净。1.3 一套代码两种身份模块与内建驱动的编译差异同样的驱动代码既可以被编译成模块.ko文件也可以直接编进内核镜像built-in。选择哪种取决于你就是需求模块适合开发和调试期灵活、替换成本低built-in适合最终产品启动时驱动就在不需要额外的加载流程也不需要担心initramfs里少放了模块。从代码角度看这两者差了什么呢核心是一个宏module_init()。当一个驱动被编进内核镜像时module_init()宏展开成的是把初始化函数放进一个特殊的初始化段内核启动到一定阶段会去调用它而当这个驱动被编译成模块时module_init()展开成另外一套逻辑让insmod加载时能找到入口函数。你要做的只是保证初始化函数签名正确剩下的事情Kbuild系统会自动处理。理解这一点很重要因为你在网上搜资料时会看到各种写法有人写int init_module(void)有人写static int __init hello_init(void)配module_init(hello_init)。这两种其实等效但后者是现代内核推荐的方式因为__init标记能让内核在启动完成后释放这段内存省一点算一点。2. 开工前夜内核头文件版本对齐与开发工具准备2.1 先确认你踩的到底是哪个内核很多人写第一个内核模块最大的误区是随便找个教程抄一遍Makefile就开编编译通过了也不管它是针对哪个内核的结果insmod的时候直接被拒绝。我见过太多人卡在这一步。内核模块的本质是一段寄生在目标内核上的代码它必须和当前运行的内核严格匹配。所以开工第一件事不是写代码而是弄清楚你当前系统跑的是什么内核版本。命令行执行uname -r在我现在的Ubuntu系统上输出是5.15.0-91-generic记住这个版本号。接下来所有准备工作都围绕它进行。如果你的开发机器是虚拟机内核版本可能经常因为系统更新而变化每次更新后都要重新确认不要认为装过一次头文件就一劳永逸。如果你是在开发板上做交叉编译那就不是uname -r能解决的你需要告诉编译系统目标平台的架构和内核源码路径。那是另一套玩法本文先聚焦在本地编译这个最典型场景。2.2 安装内核头文件并验证build目录可用编译模块并不需要完整的内核源码只需要内核头文件和一份编译配置。大多数发行版都提供了单独的头文件包名字通常是linux-headers-$(uname -r)。Debian/Ubuntu系用sudo apt update sudo apt install linux-headers-$(uname -r)RHEL/CentOS系用sudo yum install kernel-devel装完之后最关键的一个验证步骤是检查这个路径是否存在ls -l /lib/modules/$(uname -r)/build这一步几乎可以筛掉一半的环境问题。正常情况你会看到类似输出lrwxrwxrwx 1 root root 44 Jun 20 10:23 /lib/modules/5.15.0-91-generic/build - /usr/src/linux-headers-5.15.0-91-genericbuild是一个软链接指向真正存放头文件的目录。如果这个目录不存在说明头文件没装好或者当前内核版本对应的头文件包不在软件源里。老内核常遇到这种情况解决办法通常是先更新系统或者手动下载对应版本的头文件包。2.3 编译工具链的最小集合编译内核模块本质上是在一个已经配置好的内核工程里编译一个目标文件所以编译器、链接器这些基础工具肯定要有。最省事的做法是安装整套编译工具sudo apt install build-essential sudo apt install make gcc为了验证工具链可用你可以先做个最简单的小测试用gcc编译一个hello.c确认能生成可执行文件。这能帮你区分工具链本身坏了和内核模块编译环境有问题两类故障。顺带提一个前提如果你是在自己的电脑上做实验优先选一台刚装完系统、没改过内核的机器。自己从源码编译过内核的机器头文件路径可能指向自定义内核源码Makefile写法和发行版默认路径不一致容易踩额外的坑。3. 第一个模块代码逐行拆解Hello Linux Kernel3.1 模块的入口与出口module_init和module_exit的约定先看完整代码这是本文最核心的一小段建议手动敲一遍而不是直接复制#include linux/init.h #include linux/module.h #include linux/moduleparam.h #include linux/kernel.h static int hello_count 1; module_param(hello_count, int, 0644); MODULE_PARM_DESC(hello_count, Number of times to print hello); static int __init hello_module_init(void) { int i; for (i 0; i hello_count; i) { printk(KERN_INFO Hello, Linux kernel module!\n); } return 0; } static void __exit hello_module_exit(void) { printk(KERN_INFO Goodbye, kernel module unloaded.\n); } module_init(hello_module_init); module_exit(hello_module_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple hello world kernel module); MODULE_VERSION(0.1);module_init和module_exit这两个宏是模块的注册函。module_init(hello_module_init)告诉内核这个模块的初始化函数是hello_module_init你加载模块时调用它。module_exit同理告诉内核卸载时该调用谁。你完全可以不叫hello_module_init这个名字叫什么都可以只要通过宏注册进去就行。3.2 为什么用printk而不是printf以及日志级别刚接触内核模块的人第一反应往往是这代码怎么也不像C语言连printf都没有答案是printf是C标准库的函数而内核模块运行在内核态它不链接任何用户态C库甚至不链接传统的libc。用户态程序依赖的stdio、malloc这些基础设施在内核态统统不存在。内核提供的输出函数是printk它把消息写到内核环形缓冲区而不是标准输出。你在终端里看不到得用dmesg命令才能翻出来。再看printk(KERN_INFO Hello, Linux kernel module!\n);这行。KERN_INFO是日志级别它告诉内核这条消息的重要程度。常见级别从高到低有KERN_EMERG、KERN_ALERT、KERN_CRIT、KERN_ERR、KERN_WARNING、KERN_NOTICE、KERN_INFO、KERN_DEBUG。驱动开发调试期最常用的是KERN_INFO和KERN_ERR。提示printk的日志级别不只是个摆设。系统会根据日志级别决定消息是否输出到控制台。调高控制台日志级别比如echo 8 /proc/sys/kernel/printk可以使低级别消息也能直接显示在终端上这在调试驱动时很实用。3.3 许可证和元信息声明这不是走形式代码末尾的几行宏经常被初学者忽略但它们决定了你的模块能不能被Linux内核社区接纳甚至影响加载行为。MODULE_LICENSE(GPL)声明的是许可证。这个声明除了法律意义还有一层技术含义Linux内核内部很多符号函数、变量是只对GPL许可的模块可见的如果你声明私有许可证那些符号在你模块里被引用时链接阶段就会报错Unknown symbol。所以如果你想让自己的驱动在内核社区顺畅流通MODULE_LICENSE(GPL)基本上是必选项。MODULE_AUTHOR、MODULE_DESCRIPTION、MODULE_VERSION这些是元信息它们不影响代码逻辑但是会被记录到模块信息里你可以用modinfo hello_module.ko查看。养成写完整元信息的习惯对后续问题排查很有帮助。我在代码里还加了一个module_param这是为了演示模块参数传递。加载模块时你可以这样sudo insmod hello_module.ko hello_count5那么hello_count会被赋值为5打印5次。这个机制在你需要给驱动传配置项时非常有用而且它会在/sys/module/hello_module/parameters/下生成对应的sysfs节点运行状态下改这个参数文件也能动态调整。4. 编译环节Makefile与Kbuild的协作逻辑4.1 obj-m到底是什么Kbuild如何决定谁被编译成模块在Linux内核里模块的编译不是靠你自己写一个gcc命令去完成而是通过内核源码树里的Kbuild系统。Kbuild会自动处理依赖关系、编译选项、头文件路径你只需要告诉它我想要哪个文件被编译成模块。这个告诉的方式就是obj-m变量。它是一个特殊的内核构建变量obj-y编译并链接进内核镜像obj-m编译成独立的可加载模块.ko文件所以obj-m : hello_module.o的意思是把hello_module.c编译成内核模块。这里的.o后缀有点误导其实它最终会生成.ko文件。Kbuild看到obj-m指向hello_module.o就会去对应的源码目录找hello_module.c。如果你有多个源文件组成一个模块写法会有变化需要用到模块名-objs这种形式。新手阶段不需要但先了解一下没坏处obj-m : mydriver.o mydriver-objs : main.o helper.o4.2 最简单的Makefile长啥样每行都是什么意思这是我目前最推荐新手使用的Makefile简单、稳定、不容易出错obj-m : hello_module.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逐行拆解一下obj-m : hello_module.o告诉Kbuild把hello_module.c编译成模块。KDIR指向当前运行内核的构建目录。前面我们已经确认了/lib/modules/$(uname -r)/build存在这里就是用它。PWD记录你当前所在的源码目录。因为编译动作实际上发生在内核源码目录里Kbuild需要靠M$(PWD)知道外部模块的源码在哪里。all目标调用内核Makefile传入M参数执行modules目标。这段逻辑是Linux内核为外部模块编译预留的标准接口。clean目标调用同样的入口执行清理。-C $(KDIR)的意思是让make先切换到内核目录读取那里的顶层Makefile再去处理modules目标。M$(PWD)则是告诉Kbuild我这是个外部模块源码在M指定的目录。 这两者的配合是整个编译机制的核心。4.3 正常编译输出长什么样从报错中辨认有效信息在这个目录下执行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_module.o MODPOST /home/user/hello_module/Module.symvers CC [M] /home/user/hello_module/hello_module.mod.o LD [M] /home/user/hello_module/hello_module.ko make[1]: Leaving directory /usr/src/linux-headers-5.15.0-91-generic如果IDE那种红底白字的报错铺满屏幕不要慌定位规则和用户态编译是一样的先找error:开头的行看它指向哪个文件哪一行然后往前看几行context。最常见的模式是头文件找不到、语法错误、宏未定义这三类八成是环境问题不是代码本身的问题。编译成功后会生成一个hello_module.ko文件同时还会出现一堆中间产物.o、.mod.c、.mod.o、Module.symvers、modules.order等。.ko文件就是最终要加载的模块文件。可以用file hello_module.ko查看它的格式内核模块其实是ELF格式的可重定位目标文件只是有了一些特殊的section。5. 加载、验证与卸载第一次和内核握手5.1 insmod加载成功之后发生了什么执行加载命令需要root权限sudo insmod hello_module.ko如果命令没有任何输出默默地返回了那基本就是成功了。这跟用户态程序不同不是没有消息就是好消息的普通情况在内核模块里没有报错就是天大的好消息。insmod内部实现是通过init_module这个系统调用把模块文件的内容提交给内核。内核会先做一系列检查模块文件格式、签名如果有、版本兼容性、依赖符号等。检查通过后内核为模块分配内存把代码段、数据段、.modinfo等section加载到内核地址空间然后调用module_init注册的初始化函数。我强烈建议每完成一次加载立刻就检查日志dmesg | tail -n 20正常你会看到类似输出[ 456.789012] Hello, Linux kernel module!这个时间戳是内核启动以来的秒数不是我们熟悉的日期格式。如果你的模块里用了hello_count参数加载时指定了不同值输出次数会相应变化。5.2 lsmod、dmesg、rmmod一组命令完成闭环加载完成后用lsmod检查模块列表。它会读取/proc/modules显示当前加载了哪些模块lsmod | grep hello_module输出hello_module 16384 0三列含义分别是模块名、占用内存大小字节、被引用次数。如果reference count不是0说明有其他模块或内核组件正在使用它此时rmmod会失败报Module is in use。接下来测试卸载sudo rmmod hello_module再执行dmesg | tail -n 20应该能看到卸载日志[ 789.012345] Goodbye, kernel module unloaded.从这段输出你其实能看到完整生命周期加载时init函数执行卸载时exit函数执行。这比任何文档都能直观地说明模块机制。5.3 模块加载失败时的通用排查顺序第一次加载失败太正常了不要气馁。我总结了一套排查顺序按这个走90%的问题能在两分钟内定位先看dmesg | tail -n 30的报错信息它是最直接的内核回应。检查模块文件是否和当前内核匹配报错里会有version magic字样。检查模块文件是否真的是用当前头文件编译的必要时重新make clean make。确认你有没有用root权限执行insmod。如果报Operation not permitted考虑Secure Boot或模块签名问题后面细说。如果加载成功但你的设备没反应别急着怀疑驱动逻辑先确认设备树或设备ID是否匹配。这条链路我做过无数次相信我它救过很多个深夜。6. 那几类反复出现的报错和我的排查路径6.1 version magic不匹配头文件版本对不上这是我见过最多的报错几乎每一个新手都会踩。典型报错长这样[ 123.456789] hello_module: version magic 5.15.0-91-generic SMP mod_unload modversions should be 5.15.0-88-generic SMP mod_unload modversions翻译成人话就是当前内核是5.15.0-88而你编译模块用的头文件是5.15.0-91的两者不匹配。这个问题几乎都是因为你用apt upgrade更新过系统内核升级到了新版本但头文件包没有同步更新或者编译时用的KDIR路径指向了旧头文件。解决办法也很简单重新确认uname -r把/lib/modules/$(uname -r)/build路径指对然后make clean make重新编译。如果你需要使用旧内核可以重启时在GRUB菜单选择旧内核进入但那只能作为临时手段长期来看还是建议跟随系统版本更新。6.2 权限问题insmod无法加载和Secure BootPermission denied或Operation not permitted这类报错优先级排在第二位。先说一个最简单的情况执行insmod需要root权限如果你用普通用户执行会直接报Operation not permitted。加上sudo即可。但如果你已经用了sudo仍然报Operation not permitted这时候就要考虑Secure Boot了。一些新装机的PC默认开启了UEFI Secure Boot它会阻止未签名的内核模块被加载。解决办法有两条路一种是在BIOS设置里关闭Secure Boot这对个人开发机是最省事的方案。另一种是给模块签名。签名流程涉及mokutil和openssl生成密钥、注册Machine Owner Key等步骤稍显繁琐适合需要保持Secure Boot开启的正式环境。开发初期我建议直接关掉把精力花在驱动逻辑上。6.3 符号找不到、内核被污染隐藏的坑如果你在模块里引用了某些内核符号函数或变量而这些符号没有导出给模块使用链接阶段就会报错hello_module: Unknown symbol xxx (err -2)这时你需要确认这个符号是否在内核的Module.symvers文件中。Module.symvers记录了当前内核导出了哪些符号。如果符号确实没有导出你有两个选择换一个已导出的等价函数或者修改内核的导出列表后重新编译内核。后者工程量大新手一般不推荐。还有一个看起来很吓人但影响不大的提示[ 456.789012] hello_module: module verification failed: signature and/or required key missing - tainting kernel这行日志的意思是模块没有有效签名内核把内核被污染的状态标记了下来。它不影响模块运行只是Linus Torvalds和一些维护者认为未签名或非GPL模块可能引入不稳定因素所以特意在日志里标出来。看到这个不用慌开发阶段是正常现象正式发布产品时你需要配置好签名流程。7. 从helloworld到能干活下一步该写什么7.1 从printk到操作真实硬件字符设备的骨架如果你以为写驱动就是printk几句话那还差得远。真正的驱动是给用户态程序提供访问硬件或内核功能的接口。最常见的形态是字符设备它以/dev/xxx这样的文件形式暴露给用户态用户态程序用open、read、write、ioctl这些熟悉的系统调用来操作它。一个最小字符设备骨架大概长这样#include linux/fs.h #include linux/cdev.h #include linux/device.h static int hello_open(struct inode *inode, struct file *filp) { printk(KERN_INFO hello device opened\n); return 0; } static struct file_operations hello_fops { .owner THIS_MODULE, .open hello_open, }; static dev_t hello_devno; static struct cdev hello_cdev; static struct class *hello_class; static int __init hello_init(void) { alloc_chrdev_region(hello_devno, 0, 1, hello_dev); cdev_init(hello_cdev, hello_fops); cdev_add(hello_cdev, hello_devno, 1); hello_class class_create(THIS_MODULE, hello_class); device_create(hello_class, NULL, hello_devno, NULL, hello_node); return 0; }这里引入了三个新概念file_operations定义设备支持哪些操作、cdev字符设备的内核对象、class和device负责在/sys下创建设备节点。这段代码的价值在于它把模块和用户态可操作的文件连接起来第一次让你的驱动有了实际功能。7.2 设备和模块的关系驱动模型的现实很多新手会困惑驱动到底是模块还是设备两者其实是不同维度的概念。模块是代码的载体设备是硬件或虚拟对象的抽象。一个模块可以支持多个设备一个设备也可以由多个模块协作驱动。现代Linux驱动模型通过device和driver两个核心结构把它们组织成一条清晰的链路。下一阶段你应该去了解platform_driver和device_tree这两样东西。前者是Linux对不可枚举设备的标准抽象很多嵌入式设备I2C、SPI设备等都基于它编写后者则是描述硬件配置的标准方式。简单说platform_driver提供了一个标准框架把设备匹配、probe、remove这些生命周期函数规范化了你写驱动时只需要填充这些回调函数。等到你能独立写出一个字符设备并且能配合设备树让它在真实硬件上工作基本上就算入门了。这个过程可能比你想的要长但每一步都值得。我个人的建议是别急着学一堆热门的驱动框架。先把helloworld这个模块的加载、卸载、传参、日志这些基本功做到闭着眼都能操作然后再去碰字符设备和平台驱动。内核对模块的加载机制、符号解析机制、版本检查机制都是整个驱动开发的地基地基没打牢后面学得越快摔得越痛。我自己就是在直冲设备树之后又回来补模块加载原理的走了一段弯路。你如果能把这篇里的每一条命令、每一个宏都亲手验证一遍后续写驱动会顺畅很多。