1. 先搞清楚两条落地路径:模块动态加载 vs 内核编译内置
Linux 驱动程序塞进系统的办法只有两条:一条是编成.ko文件,用insmod/modprobe在运行时塞进去;另一条是在编译内核的时候,直接把驱动代码链接进vmlinux,开机就跟着内核一起起来。这两条路走的其实是同一份 C 代码,只是链接方式和生命周期管理完全不同。很多人第一次接触驱动开发,卡住的地方并不在"驱动怎么写",而在于"我该用哪种方式放进去""为什么我的驱动在别人机器上加载不了"。
这篇内容围绕的就是这两条路径的完整实操。从最小的模块骨架、Makefile 怎么跟内核构建树对接,到把驱动正式塞进内核源码树、写 Kconfig、改 Makefile、重新编译验证,一路走到一个能真正读写的字符设备。适合已经会写 C、懂一点 Linux 命令行、但没正经写过内核驱动的朋友;也适合手上有现成驱动代码、想把它从模块改成内置(或者反过来)的同行参考。
提醒一句:所有涉及内核的操作,务必在虚拟机或者可以随时重装的实验环境里做。内核崩溃不是进程崩溃,它不会给你留任何"保存一下"的机会。
1.1 同一份代码,两种完全不同的活法
模块(.ko)本质上是一个格式特殊的目标文件,内核用init_module系统调用把它映射进内核空间,做重定位、解析符号、执行入口函数。它的优点是改一行代码重新编一次,rmmod再insmod就生效,不用重启。开发阶段这个效率差是几十倍量级的——重启一次机器到进桌面可能两三分钟,编译一个单文件模块通常不超过十秒。
内置驱动则是在内核构建阶段被链接进vmlinux,最终打包成bzImage。它的初始化函数通过 initcall 机制在内核启动过程中被调用,从头到尾都属于内核镜像的一部分。优点是开机即用、不依赖/lib/modules目录、不会被卸载、启动早期就能工作。比如存储控制器、电源管理、根文件系统相关的驱动,基本都必须内置,因为模块加载本身需要文件系统已经挂载好,这事有先有后。
真正上手你会发现,两者的差异远不止"加载方式"这一点。模块编译只需要内核头文件和一个准备好的构建树;内置驱动需要你把源码放进内核源码树,还得改 Kconfig 和 Makefile,然后重新编译整个内核(或者至少编译到那个目录)。前者是分钟级迭代,后者是十几分钟到一小时级别的迭代。所以业内的通行做法是:开发调试期一律用模块,功能稳定、验证充分之后,再决定要不要转内置。
1.2 选型判断:什么场景该走哪条路
下面这张表是我自己遇到过的场景归纳,可以直接对着看:
| 场景特征 | 推荐方式 | 理由 |
|---|---|---|
| 功能还在反复调试 | 模块 | 改完重新加载即可,不用重启 |
| 驱动要适配多台设备、多个内核版本 | 模块 | 同一份源码针对不同版本各编一次即可 |
| 根文件系统所在存储的驱动 | 内置 | 模块依赖文件系统,先有鸡还是先有蛋 |
| 开机极早期就要工作(控制台、时钟源) | 内置 | 模块加载时机太晚 |
| 需要按设备型号选择性加载 | 模块 | 配合modprobe配置和 udev 规则 |
| 发行版内核、不允许用户改内核 | 模块 | 只能外部编译加载 |
| 产品固件、追求最小攻击面 | 内置 | 减少可动态加载代码的入口 |
| 代码里有必须绝对保密的算法 | 内置 | 内置代码不落地成独立文件(但这不构成真正的安全边界) |
有一个常见误区值得单独说:很多人以为内置驱动"性能更好"。其实一个已经在内存里的驱动,无论它是链接进内核还是动态加载的,运行时的调用路径、函数指针跳转、内存布局都没有本质差别。内置唯一省掉的是加载那一刻的重定位开销,通常是毫秒级的事。真正的差别在生命周期和部署形态,不在运行性能。
反过来,模块也并非"低人一等"。Linux 内核里绝大多数驱动现在都是以模块形式存在的,lsmod一敲能看到几十上百个。模块机制的成熟度足够高,只要注意版本匹配和符号导出,稳定性完全够用。
2. 从零写一个能加载的内核模块
内核模块和普通 C 程序最根本的差别在于:它没有main,没有 libc,运行在内核地址空间,用的是内核栈。你不能printf,不能用malloc,不能用浮点运算,甚至不能声明一个几 MB 的局部数组(x86_64 上内核栈默认只有 16KB)。这些限制听起来很吓人,但写起来其实有固定的套路。
2.1 最小骨架与逐行说明
一个能编译、能加载、能卸载的最小模块长这样:
#include <linux/init.h> #include <linux/module.h> #include <linux/kernel.h> MODULE_LICENSE("GPL"); MODULE_AUTHOR("demo"); MODULE_DESCRIPTION("minimal loadable module"); MODULE_VERSION("0.1"); static int __init demo_init(void) { pr_info("demo: module loaded, kernel %s\n", utsname()->release); return 0; } static void __exit demo_exit(void) { pr_info("demo: module unloaded\n"); } module_init(demo_init); module_exit(demo_exit);几个点必须掰开说。__init这个宏告诉链接器:这个函数只在初始化阶段用一次,用完就把这段内存还给系统。在模块里它的作用相对温和(模块卸载时整块内存都释放),但在内置驱动里这个标记极其重要,用好了能省下可观的常驻内存。
pr_info是printk的封装,输出会进内核日志缓冲区,用dmesg看,不会出现在你的终端上(除非你从串口或者 tty 控制台看)。输出级别KERN_INFO意味着它是信息级,某些系统默认的dmesg显示级别可能会把它过滤掉,遇到"我明明加了打印但 dmesg 什么都没有"的情况,先看/proc/sys/kernel/printk里的控制台日志级别。
MODULE_LICENSE("GPL")不是可有可无的装饰。内核里有一部分符号是用EXPORT_SYMBOL_GPL导出的,只有声明了 GPL 兼容许可证的模块才能用。不写这行,模块加载后虽然能跑,但内核会被标记为tainted(污染),dmesg里会出现一行module verification failed: signature and/or required key missing或者loading out-of-tree module taints kernel之类的提示。很多企业环境会因为 taint 标记拒绝提供支持,所以别省这一行。
末尾的module_init/module_exit是两个宏,模块模式下它们把函数登记为模块的入口和出口;内置模式下module_init会展开成 initcall 机制,让函数在内核启动的某个阶段被执行。同一行代码,两种展开——这正是"一份代码两种打包方式"能成立的技术基础。
2.2 Makefile 怎么跟内核构建树对上
内核模块不能像普通程序那样直接gcc -c,它必须用一个已经配置好的内核构建树来编译,因为需要拿到内核的include路径、编译选项、符号版本信息等。标准的 Makefile 是这样:
obj-m := demo.o KERNELDIR ?= /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) clean install: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules_installobj-m的意思是"把这个目标编成模块"。注意这不是普通的 make 语法,它是内核构建系统约定的一种写法,-C $(KERNELDIR)把工作目录切到内核构建树,读那里的顶层 Makefile,然后M=$(PWD)告诉它"要编的外部模块在这个目录"。内核顶层 Makefile 会心跳般地往下走一遍,把.ko生成出来。
/lib/modules/$(uname -r)/build通常是个软链接,指向真正准备好的内核源码树或头文件包。很多新手卡在这里:这个路径不存在,或者指向的源码树没跑过make prepare/make modules_prepare,编译就会报No rule to make target 'modules'或者一堆找不到头文件的错误。
2.3 编译—加载—验证—卸载的完整链路
实操一遍,把每一步的真实反馈记下来,以后出问题好对照:
# 1. 确认构建树存在 ls -l /lib/modules/$(uname -r)/build # 2. 编译 make # 3. 看产物 modinfo ./demo.ko # 4. 加载 sudo insmod ./demo.ko # 5. 确认已加载 lsmod | grep demo # 6. 看打印 sudo dmesg | tail -5 # 7. 卸载 sudo rmmod demo第 3 步的modinfo值得养成习惯,它会打印出filename、license、description、vermagic(版本魔数)、depends(依赖的其他模块)等信息。vermagic那一行是关键:它长这样5.15.0-91-generic SMP mod_unload modversions,包含了内核版本、是否 SMP、是否开启了模块版本校验。如果你拿到的.ko的vermagic跟当前运行内核不一致,insmod会直接拒绝,报Invalid module format。
第 4 步如果报错,dmesg里通常会有更详细的解释。这里有个细节:insmod的错误信息在终端,真正的原因往往在内核日志里。养成insmod失败立刻dmesg | tail的习惯,能省掉大量猜测。
另外提一下modprobe和insmod的区别。insmod就是赤裸裸地把指定路径的文件塞进去,不管依赖。modprobe走的是/lib/modules/$(uname -r)/下的模块索引,会按depends自动先加载依赖,还支持按模块名而不是完整路径加载。要用modprobe,你的模块得先make modules_install装进去,再跑一次depmod -a生成索引,否则会报Module xxx not found in directory。
2.4 模块参数与符号导出:让模块之间能对话
模块可以从外部接收参数,这在实际项目里用得非常多,比如"这个驱动的缓冲区大小、调试开关、目标设备号"都可以做成参数,这样同一份.ko不用重编就能适配不同部署环境:
static int buf_size = 1024; module_param(buf_size, int, 0644); MODULE_PARM_DESC(buf_size, "buffer size in bytes"); static bool debug_en = false; module_param(debug_en, bool, 0644);0644是/sys/module/<modname>/parameters/下该参数文件的权限。设成0644意味着运行时有写权限,改完立刻生效(只要你的驱动在用到参数时是读数读的,而不是只在 init 里读一次)。这一招对线上排查特别有用——不用重新加载模块,改个参数就能打开更细的日志。
符号导出则是模块之间协作的基础。如果你的模块 A 提供了一些函数,模块 B 想调用,那 A 必须把这些函数用EXPORT_SYMBOL(或EXPORT_SYMBOL_GPL)导出:
void demo_helper(int x) { /* ... */ } EXPORT_SYMBOL(demo_helper);B 调用时会记录一条依赖关系,lsmod的Used by列会体现出来。这个依赖关系是强约束:B 卸载前 A 不能卸载,否则 B 引用的地址就悬空了。曾经见过一个开发者在容器里反复rmmod A失败,报Module A is in use,排查半天发现是另一个模块引用了他导出的符号,lsmod的最后一列清清楚楚写着。养成看Used by的习惯。
还有一类经验:尽量不要在模块之间建立复杂的符号依赖链。一旦模块数量多起来,加载顺序、卸载顺序、循环依赖都会变成噩梦。能用干净接口的,就把接口做窄,宁可多传一点参数,也不要互相持有对方的数据结构指针。
3. 把驱动编译进内核:Kconfig 与 Makefile 双改造
走到内置这条路,动作就多了:源码要放进内核源码树、要在某个 Kconfig 里加条目、要在对应的 Makefile 里加规则、要跑menuconfig选中、最后重新编译内核。每一步出问题都会导致"配置里找不到我的选项"或者"编了但没生效"。
3.1 源码放哪儿,为什么不能乱放
内核源码树下的目录分工是有讲究的:drivers/char/放字符设备、drivers/block/放块设备、drivers/net/放网络、drivers/misc/放那些各种都沾一点又不值得单独立目录的东西、drivers/platform/放平台设备相关。放错位置不会导致编译失败,但会让后来维护的人骂街,而且可能影响某些全局配置的生效范围。
如果只是自己实验用,drivers/misc/或者drivers/char/是最省事的落点——这两个目录的 Makefile 和 Kconfig 都是维护良好、结构清晰的模板,照抄即可。假设我们给驱动起名叫demo_chr,就在drivers/char/下新建demo_chr.c,然后把配套的 Kconfig 和 Makefile 改动做进去。
一定要先在实验目录或者内核源码的拷贝副本里做这些改动。内核源码树动过之后,git status会显示一堆未跟踪文件,如果是发行版的内核源码包,可能连 git 都没有。稳妥的做法是用git clone一份内核源码,或者在内核源码树之外建一个目录,用M=的方式编译模块,等到要验证内置时,再在源码树里做改动。
3.2 Kconfig 怎么写:tristate 是两种模式共存的开关
在drivers/char/Kconfig里找一个合适的插入位置(比如某个类似的字符设备条目之后),加上这么一段:
config DEMO_CHR tristate "Demo character device driver" depends on MMU default n help A demonstration character device driver used to explain the difference between built-in and modular integration. If unsure, say N.关键在于tristate而不是bool。tristate允许三种状态:Y(编译进内核)、M(编译成模块)、N(不编译)。bool只有Y和N,只能内置。这一个词就决定了同一份配置能否同时支撑两条路径。depends on MMU是常见的依赖限制,具体写什么取决于你的驱动实际需要什么基础设施。
help后面的文字是给人看的,编写规范是缩进两个空格、每行不超过 70 个字符左右。这块写清楚,将来别人(包括三个月后的你自己)在 menuconfig 里看到才知道是什么。
还有一个容易踩的坑:改了 Kconfig 但 menuconfig 里找不到新条目。原因通常是上一层目录的 Kconfig 没有source你这个文件。比如你改的是drivers/char/Kconfig,而drivers/Kconfig里如果没有source "drivers/char/Kconfig",你的改动就不会被读到。检查方式很简单:在 menuconfig 里按/输入配置名搜索,如果搜不到,就是没被 source 进来。
3.3 Makefile 三种写法,对应三种编译结果
在drivers/char/Makefile里加一行,写法有三种,效果完全不同:
# 写法一:永远编译进内核,不受配置影响 obj-y += demo_chr.o # 写法二:永远编译成模块,不受配置影响 obj-m += demo_chr.o # 写法三:由 Kconfig 里的 DEMO_CHR 决定 Y/M/N obj-$(CONFIG_DEMO_CHR) += demo_chr.o写法三是唯一正确的方式。它的展开逻辑是:如果CONFIG_DEMO_CHR=y,这一行就变成obj-y += demo_chr.o,代码被链进vmlinux;如果是m,就变成obj-m,生成独立的.ko;如果是n,整行变成obj- += demo_chr.o,实际上被忽略。
写法一和写法二只在开发早期用来快速验证,用在正式代码里就是给自己挖坑——前者会让所有构建都带上你的驱动,后者会让"内置"这个选项彻底失效。
如果驱动由多个源文件组成,写法是:
demo_chr-y := main.o devops.o obj-$(CONFIG_DEMO_CHR) += demo_chr.o这里demo_chr-y是"当 demo_chr 被编译成内置时,由这几个 .o 链接而成",demo_chr-objs是老式写法,新代码里用-y后缀更主流,因为同一组规则可以同时兼容 Y 和 M。
3.4 配置、编译、验证的完整流程
改动做完之后,走这么一遍:
# 1. 进入内核源码树 cd /path/to/linux-source # 2. 以当前运行配置为基线(推荐,能省掉大量无关选项的调整) cp /boot/config-$(uname -r) .config make olddefconfig # 3. 图形化选择你的驱动 make menuconfig # 路径:Device Drivers -> Character devices -> Demo character device driver # 用 M 选中,或者用 Y 选中,实测两种 # 4. 确认配置生效 grep DEMO_CHR .config # 应该看到 CONFIG_DEMO_CHR=m 或 =y # 5. 编译 make -j$(nproc) # 6. 只编模块(如果选了 M) make modules_install # 7. 安装内核并更新引导 make install # 具体命令取决于发行版,可能还需要 update-initramfs -u 或 dracut -f # 8. 重启 reboot第 4 步那个grep千万别跳过。menuconfig有时会因为依赖不满足自动把你的选项打回n,你在界面上明明按了Y,保存退出后.config里却是CONFIG_DEMO_CHR is not set,这时候继续编译就是白等。这种情况十有八九是depends on里的某个条件不满足。
验证内置是否真的生效,方法有几个层次:
# 模块方式加载的,lsmod 能看到 lsmod | grep demo_chr # 内置的,lsmod 看不到,但可以看 sysfs ls /sys/module/ | grep demo_chr # 直接看内核符号表 grep demo_chr /proc/kallsyms # 看内核启动日志里的初始化打印 dmesg | grep demo内置驱动不会出现在lsmod里,这是最容易让人误判"没编进去"的地方。看/sys/module/或者/proc/kallsyms更靠谱。/proc/kallsyms里的符号地址如果全是 0,是因为内核开启了kptr_restrict,普通用户看不到真实地址,这是正常的安全策略,用 root 或者调整该参数即可。
3.5 内置模式下必须调整的代码细节
同一份代码,从 M 切到 Y,有几个地方必须留意。
__init和__exit的语义变了。内置时__init修饰的函数在内核初始化完成后,其占用的内存会被释放掉。这意味着:不能在__init函数里初始化一个指针,然后运行时再去调用它指向的函数——函数体已经被回收了,调用就是跳到一个可能已经分配给别人的地址。同理,__initdata修饰的全局变量在初始化后也会被释放,运行时读它就是读垃圾数据。
__exit更彻底:内置模式下退出函数根本不会被调用(内核不会"卸载自己"),链接器甚至会把这一段代码丢掉。所以资源的释放逻辑不能只写在退出函数里,出错路径上的清理必须在 init 函数内部用goto逆序完成。这个要求在模块模式下也一样,只是内置模式下更容易被忽略,因为你在开发时用模块跑得好好的,一转到内置就显露出问题了。
还有一个隐蔽的点:内置驱动的初始化失败不会阻止系统启动,只会让你的设备不可用。所以如果开机后设备不工作、dmesg里也看不出明显错误,先确认 init 函数有没有返回非零值。可以在 init 的每个关键步骤后加pr_info,把失败点和错误码打出来。
4. 一个能跑通的字符设备驱动:从设备号到 /dev 节点
讲完打包方式,得有一个真能读写的例子来落地。下面这个字符设备驱动足够小,但包含了设备号申请、cdev 注册、文件操作集、用户态数据拷贝、自动创建/dev节点这几件事的完整链路。
4.1 设备号的申请与注册
Linux 里每个设备由"主设备号 + 次设备号"唯一标识。主设备号标识驱动,次设备号标识该驱动管理的具体设备实例。申请方式有两种:register_chrdev_region(静态指定,需要你知道哪个号没被占用)和alloc_chrdev_region(让内核动态分配,推荐)。
static dev_t demo_devno; static struct cdev demo_cdev; static struct class *demo_class; #define DEV_NAME "demo_chr" static int __init demo_init(void) { int ret; ret = alloc_chrdev_region(&demo_devno, 0, 1, DEV_NAME); if (ret < 0) { pr_err("demo_chr: alloc_chrdev_region failed, ret=%d\n", ret); return ret; } cdev_init(&demo_cdev, &demo_fops); demo_cdev.owner = THIS_MODULE; ret = cdev_add(&demo_cdev, demo_devno, 1); if (ret) { pr_err("demo_chr: cdev_add failed, ret=%d\n", ret); goto err_cdev; } pr_info("demo_chr: major=%d minor=%d\n", MAJOR(demo_devno), MINOR(demo_devno)); return 0; err_cdev: unregister_chrdev_region(demo_devno, 1); return ret; }alloc_chrdev_region的第二个参数是起始次设备号,第三个是申请的数量。如果只管理一个设备,就是 0 和 1。分配成功后主设备号是动态的,每次加载可能不一样,所以不能把设备号写死在用户态的脚本里,得靠device_create自动建节点,或者从dmesg里读出来再mknod。
cdev_init把文件操作集绑上去,cdev_add才是真正把设备注册进内核。这两个顺序不能反。demo_cdev.owner = THIS_MODULE这行设置引用计数归属,作用是:设备被打开期间,模块不能被卸载(rmmod会报is in use)。别省略这一行,否则你能在用户态还开着设备文件的情况下把模块卸掉,接下来任何读写操作都是野指针,直接内核 panic。
4.2 file_operations 与用户态数据拷贝
static ssize_t demo_read(struct file *file, char __user *ubuf, size_t count, loff_t *ppos) { int len; if (*ppos >= demo_len) return 0; len = min((int)count, demo_len - (int)*ppos); if (copy_to_user(ubuf + *ppos, demo_buf + *ppos, len)) return -EFAULT; *ppos += len; return len; } static ssize_t demo_write(struct file *file, const char __user *ubuf, size_t count, loff_t *ppos) { int len; if (*ppos >= BUF_LEN) return 0; len = min((int)count, BUF_LEN - (int)*ppos); if (copy_from_user(demo_buf + *ppos, ubuf + *ppos, len)) return -EFAULT; *ppos += len; demo_len = *ppos; return len; }copy_to_user/copy_from_user是绝对不能绕过的一步。内核空间和用户空间地址不能直接解引用,用户给你的指针可能是非法的、可能指向尚未映射的页,直接memcpy会直接触发缺页异常,结果是内核 Oops。这两个函数会做必要的地址校验,返回值非零表示有字节没拷成功,必须返回-EFAULT。
min那几行是在处理边界。用户态read和write的count可能是任意大的值,也可能因为信号中断而偏小,驱动的缓冲区只有 256 字节,必须截断。文件操作集里的*ppos是文件偏移,read返回 0 是"文件结束"的约定,cat命令就是靠它判断该停了。
定义文件操作集:
static const struct file_operations demo_fops = { .owner = THIS_MODULE, .open = demo_open, .release = demo_release, .read = demo_read, .write = demo_write, };open里可以做权限检查、引用计数、硬件上电之类的事,release里做对应的收尾。即使什么都不做,也建议留一个空的open,方便以后加调试打印。
4.3 自动创建 /dev 节点:class 与 device
有了设备号还得有/dev/xxx这个文件,用户态才能open。手动mknod当然可以,但主设备号是动态分配的时候就没法写死脚本了,所以正规做法是通过class_create+device_create让内核通知 udev/mdev 自动建节点。
demo_class = class_create(DEV_NAME); if (IS_ERR(demo_class)) { ret = PTR_ERR(demo_class); goto err_class; } if (IS_ERR(device_create(demo_class, NULL, demo_devno, NULL, DEV_NAME))) { ret = -ENODEV; goto err_dev; }这里有个版本差异必须提醒:class_create在较早的内核里是两个参数class_create(owner, name),在较新的内核里简化成了一个参数class_create(name)。如果你照抄网上的代码编译报too many arguments to function 'class_create',就是撞上这个改动。稳妥的写法是用版本宏判断:
#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 4, 0) demo_class = class_create(DEV_NAME); #else demo_class = class_create(THIS_MODULE, DEV_NAME); #endif这个细节说明一件事:内核内部 API 会变,网上抄来的代码必须核对版本。判断标准不是"编译过不过",而是"你的目标内核版本里这个函数的签名是什么"。
对应的清理路径要全部逆序写全:
static void __exit demo_exit(void) { device_destroy(demo_class, demo_devno); class_destroy(demo_class); cdev_del(&demo_cdev); unregister_chrdev_region(demo_devno, 1); }device_destroy→class_destroy→cdev_del→unregister_chrdev_region,顺序跟创建时完全相反。这个"逆序释放"的模式在内核代码里随处可见,因为资源之间往往有依赖:device依赖class,class依赖已经存在的设备号。顺序错了,内核会在某个地方给你个 Oops 或者WARNING。
4.4 用户态验证:三条命令测完
加载之后,验证不需要写专门的测试程序,直接用 shell 就够:
sudo insmod demo_chr.ko sudo dmesg | tail -3 # 预期看到: demo_chr: major=237 minor=0 # 以及自动建节点的信息 ls -l /dev/demo_chr # 预期: crw------- 1 root root 237, 0 ... # 写 echo "hello kernel" | sudo tee /dev/demo_chr # 读 sudo cat /dev/demo_chr # 卸载 sudo rmmod demo_chr如果cat出来是空、或者卡住不动,多半是read函数里的*ppos处理有问题——比如忘了更新*ppos,cat会一直以为没读到东西,反复调用直到 buffer 满。如果echo之后cat内容截断了,检查demo_len有没有在write里更新。
cat卡死还有一种可能是返回了正数但没推进*ppos,内核的vfs_read检测到这种"读了但位置没动"的情况会返回-EINVAL,某些版本的cat会表现为报错退出。总之,读写函数的返回值语义要严格符合约定:返回读写的字节数,出错返回负的 errno,读到末尾返回 0。
5. 常见问题与排查技巧实录
这一段是整篇里最值钱的部分,全都是我在真实环境里撞过的墙。按发生阶段分类,方便对号入座。
5.1 编译期问题速查
| 报错信息 | 根因 | 处理方式 |
|---|---|---|
No rule to make target 'modules' | 构建树路径错、或没跑过make modules_prepare | 确认/lib/modules/$(uname -r)/build存在且完整;在构建树里跑一次make modules_prepare |
fatal error: linux/module.h: No such file | 缺内核头文件包 | 安装与运行内核版本严格对应的开发包 |
too many arguments to function 'class_create' | 内核版本与示例代码不匹配 | 用uname -r和头文件里的声明核对,加版本宏分支 |
undefined symbol: xxx(内置编译时) | 引用了未导出的符号,或链接顺序问题 | 检查符号是否EXPORT_SYMBOL;检查 Makefile 里-y/-objs的源文件列表 |
implicit declaration of function 'copy_from_user' | 漏了#include <linux/uaccess.h> | 补头文件,别靠隐式声明 |
WARNING: modpost: missing MODULE_LICENSE() | 没写许可证宏 | 加上MODULE_LICENSE("GPL") |
error: 'struct file_operations' has no member named 'ioctl' | 新版内核把ioctl改名成unlocked_ioctl | 用unlocked_ioctl,并理解它在调用时已经持有大内核锁(不再需要手动加 BKL) |
modpost警告值得单独说。它发生在"编译已经过了,正在给模块做后处理"的阶段,很多人看到警告就忽略过去了,但modpost报出的未定义符号、许可证缺失,往往就是加载失败的根因。养成编译完扫一眼有没有WARNING: modpost:的习惯。
5.2 加载期问题速查
| 现象 | 根因 | 处理方式 |
|---|---|---|
insmod: Invalid module format | vermagic不匹配 | modinfo对比.ko和当前内核;用目标内核的构建树重编 |
insmod: Unknown symbol in module | 依赖的符号没导出,或依赖模块未加载 | dmesg看具体符号名;检查EXPORT_SYMBOL;先加载依赖模块 |
insmod: File exists | 模块已加载 | lsmod确认;rmmod后再试 |
rmmod: Module is in use | 引用计数非零 | 关闭所有打开的设备文件;lsmod看Used by列 |
dmesg出现taints kernel | 非 GPL 许可证或非上游代码 | 加MODULE_LICENSE("GPL");接受 taint 或改为上游代码 |
modprobe: FATAL: Module not found | 没装进/lib/modules,或没跑depmod | make modules_install后depmod -a |
Invalid module format这个错误还有个变种:disagrees about version of symbol module_layout。这不是版本号对不上,而是内核开启了CONFIG_MODVERSIONS,每个导出符号都带一个基于结构布局算出来的 CRC 值。你编译时用的头文件里的结构定义跟运行内核的不一致,CRC 就不同。唯一的解法是拿运行内核真正对应的源码树来编译,不能凑合。
5.3 运行期崩溃的现场还原思路
内核崩溃(Oops / panic)的特点是:屏幕刷一堆寄存器、调用栈、RIP地址,然后要么挂住,要么重启。信息量很大,但重点看几行:
sudo dmesg | head -40 # 关注: # BUG: unable to handle kernel paging request at ffff... # RIP: 0010:demo_read+0x3a/0x60 # Call Trace: # demo_read # vfs_read # ksys_readRIP那一行直接告诉你崩在哪个函数、偏移多少。Call Trace告诉你调用链。如果是空指针解引用,前面那句unable to handle kernel paging request at后面跟的地址通常是00000000或很小的值,说明解引用了一个 NULL 或者几乎为 NULL 的指针。
有几个我踩过的高频原因:其一,__init函数里初始化了某个函数指针,初始化完成后那部分内存被回收,运行时再调用就是跳到无效地址,这种崩溃的RIP值往往很怪;其二,kmalloc之后没检查返回值就用了,内存紧张时直接 NULL 解引用;其三,并发访问没有加锁,两个线程同时进write把环形缓冲区的读写指针搞乱,越界写坏相邻内存。
抓现场的实用技巧:开发阶段用netconsole把内核日志通过网络实时发到另一台机器,或者开pstore/ramoops把崩溃日志保存到一段保留内存里,重启后从/sys/fs/pstore/读出来。虚拟机的话,把串口重定向到文件最简单,配置好之后所有内核输出都自动落盘,崩溃了也不丢。这套现场保留机制一定要在开始写驱动之前就配好,等到崩了两次才发现什么都抓不到,返工成本很高。
5.4 内置驱动的隐蔽坑
内置模式下有些问题是模块模式下永远不会出现的,得单独列出来。
第一个是配置被依赖关系打回。你在 menuconfig 里按了Y,保存退出,grep一下.config却是# CONFIG_DEMO_CHR is not set。九成是因为某个depends on条件不满足。menuconfig 界面里其实有提示——如果某个选项是灰的,按?能看到"Depends on: ..."和哪些条件没满足。这个?键是排查配置问题最有效的工具,可惜知道的人不多。
第二个是改了源码但没重新编到。Linux 的内核构建系统会做增量编译,正常情况下改了.c就会重编对应的.o。但如果你改了 Kconfig 或者动了 Makefile 里的目标名,增量判断可能失效,make跑得飞快却什么都没干。配置相关的改动之后,一律先make clean或者至少make prepare再编,别相信增量。
第三个是内置驱动打印的时间点太早。如果驱动在subsys_initcall甚至更早的阶段初始化,那时printk的日志缓冲区可能还没完全准备好,dmesg里会看到日志顺序错乱甚至丢失。排查这类问题时,dmesg -t去掉时间戳,或者看/var/log/kern.log这种在用户态落盘的日志,会靠谱一些。
第四个是设备号在内置和模块下不一样。内置驱动在启动的时候申请设备号,那时候动态分配是从一个比较小的起始值往上找;模块加载时,内核里已经有一堆设备占了号,分到的值会完全不同。如果你的用户态脚本写了mknod /dev/demo c 237 0,切成内置之后这个 237 大概率是错的,节点打开会报No such device or address。别写死设备号,靠device_create自动建,或者从dmesg里解析。
6. 最后再说几句实操里的体感
关于"到底该写模块还是内置",我的经验是:除非有明确的启动时序要求或者部署形态限制,一律先做模块。驱动开发的特点是你永远不知道下一行代码会不会让机器崩,模块崩了最多重启一次,内置驱动写错了可能导致机器起不来,得进恢复模式甚至重新刷系统。那种"改一行、编内核、装内核、重启、发现挂不住、再进恢复模式"的循环,一天能跑三轮就算效率高了。
真要转内置,我一般会按这个节奏走:先在模块模式下把功能跑通、把并发路径跑几百遍、把错误路径都触发一遍(故意让kmalloc失败、故意传非法指针、故意在并发下卸载),确认不会崩了,再改 Kconfig 转内置。转的时候只改三处:源码放进源码树、加 Kconfig 条目、加 Makefile 规则,代码本身一行不动——如果你的代码在转内置时还需要改逻辑,那说明前面的模块阶段没做实。
另外一个我自己养成的习惯:任何一个新驱动,第一版永远只做init+exit+ 一行pr_info,加载成功、dmesg能看到打印、卸载干净,这一步过了再往里加设备号、加文件操作、加数据处理。每次只加一层,每层都验证。听起来慢,实际上比"一次写完然后面对一屏 Oops 猜哪里错了"快得多。内核开发没有断点调试的便利(虽然kgdb能用,但配置成本不低),pr_info才是你最常用的调试器,把它用在刀刃上。
最后一个细节,dmesg -w这个命令值得绑定到快捷键上。它会持续监听内核日志,你这边加载模块、那边打印就冒出来,不用来回敲dmesg | tail。配合dmesg -T显示可读时间戳,排查时序相关的问题会舒服很多。