
在嵌入式Linux这行待久了你会发现一个挺有意思的现象同一个硬件驱动不同的团队、不同的项目阶段落地方式往往完全不同。有人习惯把它编译成独立的**.ko 文件**需要的时候 insmod 一把加载进去有人则直接把驱动塞进内核源码树改 Kconfig、改 Makefile编译内核时一起烧进去。这就是 Linux 驱动程序最核心的两条落地路径——模块加载驱动程序和内核编译时添加驱动。这两个词听起来像是某种工具链细节实际上它决定了你后续调试效率、版本管理方式、甚至产品出问题时的应急手段。我见过太多新手在第一种方式上卡了半个月也见过量产项目因为第二种方式改错一个配置项导致整机启动失败。这篇文章就把这两条路径从头到尾捋一遍从思路选型、代码骨架、编译配置到实际踩过的坑和排查手法尽量写成能直接照着抄的形态适合刚接触内核驱动不久、或者一直只敢用其中一种方式的朋友参考。1. 为什么同一个驱动会有两条加载路径1.1 一次现场调试引出的分歧先说个比较典型的场景。某嵌入式团队在调试一块自制的采集板驱动负责读一个 I2C 扩展芯片的寄存器。开发阶段他们用模块方式改一次代码、make 一次、scp 拷到板子上、rmmod 再 insmod整个过程不到三十秒非常顺手。等代码稳定了准备量产硬件同事提了个要求设备启动后十秒内必须开始采数据越早越好。这时候模块方式就有点尴尬了——虽然是 systemd 或 init 脚本自动加载但毕竟多了一步用户态动作而且根文件系统如果挂载出问题模块就加载不了驱动等于不存在。于是他们把驱动编进了内核。结果第一次编译就报错第二次编过了但启动没反应第三次才发现设备树里的节点名和驱动里 of_match_table 的名字对不上。这个过程前后折腾了两天才稳定下来。这个故事的核心信息是模块方式和内置方式不是高级与低级的关系而是两种不同的部署形态各自有明确的适用边界。用错了地方不是不能用而是要付出额外的调试成本和维护成本。从内核的视角来看驱动程序本质上就是一段符合内核编程模型的 C 代码它注册回调、申请资源、响应中断。这段代码是在运行时被动态链接进来还是在编译期静态链接进去内核本身并不关心——它只关心最终能不能在struct device_driver或者file_operations里找到对应的函数指针。理解这一点很重要因为它意味着同一份驱动代码理论上是可以在两种方式之间切换的你不需要写两套代码只需要改构建配置。这个性质在我们后面切换策略的时候会反复用到。1.2 链接时机决定了一切模块与内置的本质差异要理解两条路径的差异关键抓住一个词链接时机。模块方式下驱动在运行期才被链接进内核。内核必须提供一套完整的机制来支持这件事这就是insmod、rmmod、modprobe这一整套工具链存在的原因。内核里有一张用struct module描述的模块清单加载时要做三件事一是把 .ko 文件里的代码段、数据段映射到内核地址空间二是做符号重定位因为驱动里调用的printk、kmalloc、i2c_transfer这些函数在编译时并没有真实地址需要内核查表补齐三是执行模块的初始化函数。其中符号重定位是最容易出问题的环节一旦你调用的某个内核符号没有导出没有EXPORT_SYMBOL加载就会直接失败报 Unknown symbol。内置方式下驱动在编译期就和内核其他代码一起链接成一个整体镜像。没有重定位这一步也就不存在符号导出的问题——只要是内核里的函数理论上都能调用因为大家本来就在同一个二进制里。代价是任何一个驱动出问题都可能影响整个内核镜像的大小和启动流程而且改一行驱动代码就要重新编译整个内核或者至少重新链接 vmlinuz迭代速度慢得多。我用一个生活化的类比来解释这个差别。模块方式像是给一台已经开着的电脑插一块外置声卡插上驱动就加载拔掉就卸载但前提是主板上的接口内核导出的符号得对得上。内置方式像是把声卡芯片直接焊在主板上整体性更好、启动更早、不依赖用户态但想换一块就得把主板拆下来重新焊。还有一个容易被忽略的差异是内存占用。模块的代码段可以按页映射理论上支持卸载后释放而内置驱动占用的是内核镜像的固定空间永远不会被释放。对于内存紧张的嵌入式设备这个差别在驱动数量多的时候会变得很可观。1.3 选型对照什么场景走哪条路我把这两条路径的关键维度整理成一张表实际项目中我基本就是照着这张表做第一轮判断的对比维度模块加载方式内核编译时内置链接时机运行期动态链接编译期静态链接迭代速度秒级改完重新 make 即可分钟到小时级取决于内核规模依赖用户态需要 insmod/modprobe 或 init 脚本完全不依赖启动时机根文件系统就绪之后内核启动早期即可 probe符号限制只能用 EXPORT_SYMBOL 导出的符号内核内符号基本都可用镜像体积独立文件不需要时可不部署计入内核镜像永久占用卸载能力支持 rmmod 热卸载不支持调试友好度高可以反复加载卸载试错低出问题往往要重启适合阶段开发、调试、小批量验证量产、早期初始化、关键路径补充一点表格里没体现的判断依据如果驱动参与了启动早期的关键流程比如要在根文件系统挂载之前就初始化某个存储控制器、时钟源或者电源管理芯片那基本没得选只能内置。因为模块加载依赖用户态工具而用户态要等根文件系统挂载完成这个先后顺序是硬约束绕不过去。反过来如果是外设类驱动、功能相对独立、调试频繁那模块方式几乎是唯一合理的选择。我在做某类传感器驱动的时候一天要改十几次阈值算法全靠模块热加载撑着真要每次都编内核一天连两轮都跑不完。提示不要在项目一开始就急着决定用哪种方式。比较稳妥的策略是开发期一律用模块功能验证完毕、稳定运行一段时间之后再把关键驱动转成内置。这样既不牺牲开发效率也满足了量产的启动时序要求。2. 驱动模块的最小骨架与内核接口约定2.1 一个能跑起来的最小模块长什么样不管后面走哪条路代码骨架是共通的。先看一个最小可加载模块这是所有驱动的地基#include linux/module.h #include linux/init.h #include linux/kernel.h static int __init demo_init(void) { pr_info(demo: module init, version %s\n, 1.0); return 0; } static void __exit demo_exit(void) { pr_info(demo: module exit\n); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(demo); MODULE_DESCRIPTION(minimal demo module); MODULE_VERSION(1.0);这里有几个细节值得单独说。__init和__exit这两个宏不是装饰性的它们会把函数放到位特殊的段里.init.text和.exit.text。内核在初始化完成后会把.init.text这一段内存整个释放掉——对内置驱动来说这能省下实打实的内存对模块来说加载完成后这部分内存同样会被回收。__exit段的函数在内置情况下会被直接丢弃因为内置驱动没法卸载留着也是浪费空间。pr_info是printk(KERN_INFO ...)的封装比直接写 printk 少了宏拼接的麻烦。输出会进内核环形缓冲区用dmesg就能看到。我在调试驱动的时候基本就是靠这个加dmesg -w实时盯着比任何调试器都快。2.2 入口函数的返回值与失败回滚demo_init返回 0 表示成功返回负数表示失败这个约定必须严格遵守。很多人写代码时习惯返回-1但内核里有一套标准的错误码-ENOMEM、-EINVAL、-ENODEV、-EBUSY用标准错误码的好处是上层和日志都能直接识别出失败原因用-1等于把信息丢了。更关键的是失败回滚。初始化函数里往往会做一连串资源申请申请设备号、注册字符设备、创建 class、申请中断、映射寄存器。中间任何一步失败前面已经申请的资源必须按逆序释放否则就会出现资源泄漏。对于模块来说泄漏的后果比内置更严重因为你可以反复加载卸载泄漏会不断累积。我见过一个比较典型的错误写法static int __init demo_init(void) { int ret; ret alloc_chrdev_region(devno, 0, 1, demo); if (ret) return ret; ret register_chrdev_region_something(); if (ret) return ret; /* 错误前面申请的设备号没释放 */ return 0; }正确写法应该是用 goto 链式回滚这是内核里最常见的错误处理范式static int __init demo_init(void) { int ret; ret alloc_chrdev_region(devno, 0, 1, demo); if (ret) return ret; ret register_something(); if (ret) goto err_chrdev; ret create_something(); if (ret) goto err_register; return 0; err_register: unregister_something(); err_chrdev: unregister_chrdev_region(devno, 1); return ret; }这种写法一开始看着有点丑但出问题时能救命。我现在写任何 init 函数第一件事就是把 goto 标签的骨架先敲出来再往里填内容。2.3 模块元信息不是摆设MODULE_LICENSE这一行绝对不是可选项。如果你的驱动调用了任何标了EXPORT_SYMBOL_GPL的内核符号比如大量的内核内部接口而没有声明 GPL 许可内核会拒绝加载日志里会出现 module license unspecified taints kernel 这样的提示或者直接加载失败。这个机制在内核里是硬性的不是建议。MODULE_ALIAS和MODULE_DEVICE_TABLE则决定了模块能不能被自动加载。以 I2C 驱动为例加了MODULE_DEVICE_TABLE(i2c, demo_id_table)之后depmod会把这个表的信息写进/lib/modules/$(uname -r)/modules.alias当内核发现一个匹配的设备时就会通过modprobe自动把对应模块拉起来。少了这一行设备插上了也没人管只能手动 insmod。我在一个项目里就吃过这个亏驱动功能完全正常手动 insmod 之后一切工作但就是不会自动加载。查了半天才发现是MODULE_DEVICE_TABLE漏了导致 alias 表里没有记录。补上之后重新depmod -a问题立刻消失。2.4 字符设备注册的关键几步对于大部分要暴露给用户态访问的驱动字符设备是最常见的形态。完整的注册流程大概是四步申请设备号、初始化 cdev、添加 cdev、创建设备节点。每一步都有坑。static dev_t demo_devno; static struct cdev demo_cdev; static struct class *demo_class; static struct device *demo_device; static const struct file_operations demo_fops { .owner THIS_MODULE, .open demo_open, .release demo_release, .read demo_read, .write demo_write, .unlocked_ioctl demo_ioctl, };.owner THIS_MODULE这一行在模块方式下非常重要它会增加模块的引用计数防止在文件还被打开的时候模块被卸掉。缺了这一行rmmod有可能把一个正在被使用的模块强行卸载后续访问就是空指针。设备号的计算也值得说一下。alloc_chrdev_region是动态申请内核分配什么就是什么你没法预先知道主设备号另一种是register_chrdev_region指定固定的主设备号。动态申请的好处是不会冲突坏处是每次重启可能变固定的好处是可以预先在根文件系统里写好设备节点坏处是容易和别的驱动撞号。我的习惯是开发期用动态申请配合 udev 自动创建设备节点量产且根文件系统固定时再考虑固定设备号。至于设备节点早期是用mknod手动创建现在基本都是通过class_create加device_create让 udev 或 mdev 自动在/dev下生成节点。嵌入式环境里如果没有 udev就得靠 mdev 配合hotplug或者干脆在启动脚本里 mknod。这块因平台而异得看具体的根文件系统是怎么搭的。3. 模块方式实操从Makefile到insmod全流程3.1 外部编译Makefile怎么写才不出错模块可以放在内核源码树里编译也可以放在外面用外部模块编译的方式。实际开发中我强烈推荐后者因为驱动代码和内核代码分离版本管理清楚得多也方便用 git 单独管理。标准的外部编译 Makefile 长这样obj-m demo.o # 如果驱动由多个源文件组成 # demo-objs : demo_main.o demo_ops.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 install: $(MAKE) -C $(KDIR) M$(PWD) modules_install几个容易出错的点。第一KDIR指向的必须是已经编译过的内核源码树或者内核头文件包目录里要有Makefile、.config、include/。如果只是安装了linux-headers包但没做过make modules_prepare编译时会报缺少Module.symvers或者各种 generated 头文件。第二命令前的缩进必须是真正的 Tab 字符空格会让 make 报 missing separator这个错新手几乎人人踩过。第三M$(PWD)中的 PWD 要用$(shell pwd)拿绝对路径不能直接用$(PWD)因为后者在某些 shell 环境下可能是空值。如果驱动由多个 .c 文件组成别写多个obj-m要用demo-objs这种聚合写法。写成多个 obj-m 会生成多个独立的 .ko反而加载不上。3.2 加载、查看、卸载的完整动作链编译成功后会得到demo.ko。接下来的动作链是固定的# 加载 sudo insmod demo.ko # 看是否加载成功 lsmod | grep demo dmesg | tail -20 # 查看模块信息 modinfo demo.ko # 卸载 sudo rmmod demoinsmod和modprobe的区别要搞清楚。insmod直接给文件路径不做依赖解析modprobe按模块名在内核模块目录里找会自动先加载依赖的模块。所以对于有依赖的驱动必须用modprobe而用insmod加载一个依赖未满足的模块会直接报 Unknown symbol in module。modinfo能看到的字段很有用filename、license、depends、vermagic、parm。其中vermagic是加载时的版本校验依据它包含内核版本号、SMP 标志、抢占标志、模块版本化标志等。只要有一项对不上加载就会被拒绝报 version magic ... should be ...这个错误在后面排查章节还会详细讲。加载之后一定要看dmesg。很多问题是加载成功了但没干活比如设备树匹配失败、probe 函数没被调用这些从 lsmod 是看不出来的只能从日志里看。我一般是开一个终端跑dmesg -w另一边操作 insmod/rmmod实时观察输出。3.3 模块传参把可调项从代码里搬出来调试期间改代码 → 重新编译 → 重新拷贝 → 重新加载这个循环虽然快但也能省则省。模块参数就是干这个用的把常用的可调项暴露成参数加载时通过命令行传进去不用重新编译。static int buf_size 4096; static char *dev_name demo0; static bool verbose false; module_param(buf_size, int, 0644); MODULE_PARM_DESC(buf_size, Ring buffer size in bytes, default 4096); module_param(dev_name, charp, 0644); MODULE_PARM_DESC(dev_name, Device node name); module_param(verbose, bool, 0644); MODULE_PARM_DESC(verbose, Enable verbose logging);加载时这样传sudo insmod demo.ko buf_size16384 dev_namedemo1 verbose1第三个参数 0644 是 sysfs 里的权限位设成 0644 意味着加载后还能在/sys/module/demo/parameters/下面直接读写这些参数注意要在驱动里实现对应的 set 回调否则写进去也不会生效。设成 0444 就是只读。这个技巧在调参的时候特别好用比如动态调整日志级别不用重启设备。关于参数的取值有个经验值得分享缓冲区大小的选择不是拍脑袋定的。假设你的驱动要缓存 100ms 的采样数据采样率是 1kHz每个样本 4 字节那么最小缓冲就是 100 × 1000 × 4 400000 字节。实际要给两到三倍余量应对突发所以取 1MB 左右比较合适。如果按默认的 4KB那就是几百毫秒就溢出了表现出来就是丢数据。这种计算一定要在写参数默认值之前做一遍。3.4 依赖关系与模块自动加载机制模块之间的依赖在内核里是显式记录的。当你 insmod 一个模块时内核会检查它引用的外部符号如果这些符号由另一个模块提供且那个模块还没加载加载就会失败。modprobe通过读/lib/modules/$(uname -r)/modules.dep来处理这件事而这个文件是depmod生成的。所以完整流程应该是make modules_install把 .ko 拷到/lib/modules/.../extra/或kernel/drivers/...下然后depmod -a重建依赖表之后modprobe demo才能正确工作。如果设备树里描述的硬件匹配上了驱动的of_match_table内核会通过 uevent 通知用户态用户态的 udev 或 mdev 再去调用modprobe加载对应模块。这条链路上任何一环断了都不行设备树节点名不匹配、MODULE_DEVICE_TABLE没加、depmod没跑、用户态没有 udev任何一处出问题结果都是模块不会自动加载。排查这条链路的方法是从后往前捋先确认modprobe demo手动能成功再确认/sys/bus/.../devices/.../uevent里有没有对应的 MODALIAS再看modules.alias里有没有这条记录。三步走下来基本能定位到具体哪一环。4. 内核编译时内置驱动Kconfig与Makefile联动4.1 源码放进内核树的哪个位置要把驱动内置进内核第一步是把它放到源码树的合适位置。内核源码树是有明确目录约定的目录用途drivers/char/传统字符设备驱动drivers/misc/不好归类的小型杂项驱动drivers/i2c/I2C 从设备驱动drivers/spi/SPI 从设备驱动drivers/gpio/GPIO 控制器驱动drivers/net/网络设备驱动drivers/media/摄像头、视频类驱动选对目录不只是看着整齐的问题它直接影响 Kconfig 的菜单归属、编译顺序和依赖关系。比如你写的是一个 I2C 温度传感器驱动放在drivers/i2c/下那它天然就能依赖I2C这个配置项Kconfig 里写depends on I2C就能生效如果你随手扔到drivers/misc/下虽然也能编但依赖关系就得自己小心处理。我一般的做法是在目标目录下建一个子目录比如drivers/misc/demo_drv/里面放demo_drv.c、Kconfig、Makefile三个文件。然后在父目录的Kconfig里source这个子目录的 Kconfig在父 Makefile 里obj-$(CONFIG_DEMO_DRV) demo_drv/。这样结构清楚将来要摘掉也方便。4.2 Kconfig条目写法与菜单归属Kconfig 是内核配置系统的描述语言它决定了make menuconfig里能看到什么选项、选项之间有什么依赖。一个典型的条目config DEMO_DRV tristate Demo character device driver depends on I2C GPIOLIB select CRC32 default n help Say Y here if you want to build the demo driver into the kernel. Say M to build it as a loadable module instead. If unsure, say N.几个关键字的含义必须搞清楚。tristate表示这个选项有三种状态y编进内核、m编成模块、n不编译。如果你希望这个驱动只能内置、不能模块化就用bool只有 y 和 n 两种。很多新手在转内置的时候忘了这一层以为把 m 改成 y 就行结果 Kconfig 里写的是 bool根本看不到 m 选项白白折腾半天。depends on是硬依赖条件不满足时这个选项在 menuconfig 里根本不会出现或者变成灰色。select是反向依赖选中本项时强制把另一项也选上。select要慎用因为它会无视被选中项的依赖关系强行打开有时候会造成配置冲突内核社区对滥用 select 是有明确批评的。能用depends on就别用select。default n是明确表达默认不启用的意图虽然不写也是默认 n但显式写出来可读性更好也方便后续维护的人理解。4.3 Makefile里obj-y与obj-m的差别这是整个内置流程里最关键、也最容易混淆的一行。内核驱动的 Makefile 里通常这样写obj-$(CONFIG_DEMO_DRV) demo_drv.o这一行会随着配置变化展开成三种形态配置值展开结果含义yobj-y demo_drv.o编进内核镜像 vmlinuzmobj-m demo_drv.o编成独立 .ko 模块nobj- demo_drv.o不编译实际为空操作这就是内核构建系统最巧妙的地方一份 Makefile 同时支持模块和内置两种形态具体走哪条由.config里的配置值决定。所以前面说的同一份驱动代码可以在两种方式之间切换落到构建层面就是这一行的功劳。但要注意如果驱动由多个源文件组成写法是obj-$(CONFIG_DEMO_DRV) demo_drv.o demo_drv-objs : demo_main.o demo_ops.o demo_irq.o这里demo_drv.o是一个逻辑目标实际由后面三个 .o 链接而成两种情况下都能工作。还有一个隐藏的坑如果你在 Makefile 里手写obj-y xxx.o而不是用obj-$(CONFIG_XXX)那么这个驱动会无条件编进内核Kconfig 里的配置形同虚设。这在临时实验时没什么但要是进了正式代码后面想关都关不掉只能改 Makefile。4.4 配置、编译、验证的完整闭环配置环节有几条路可以走。交互式的是make menuconfig适合第一次调整脚本化的是scripts/config命令适合自动化最直接的是手工改.config文件但不推荐因为手工改容易破坏依赖关系。# 方式一菜单配置 make menuconfig # 在 Device Drivers - Demo 里把 DEMO_DRV 设为 * # 方式二命令行直接改 ./scripts/config --enable DEMO_DRV make olddefconfig # 方式三基于现有配置扩展 make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- demo_defconfigmake olddefconfig这一步很重要它会在保留现有配置的基础上把新增的、未设置的选项填上默认值并修正依赖冲突。很多改了 .config 但编译没生效的问题都是因为依赖关系被破坏后没有重新整理。编译的时候先单独编一下驱动所在的目录能快速验证语法make Mdrivers/misc/demo_drv/ modules确认没问题再整体编译make -j$(nproc) ARCHarm CROSS_COMPILEarm-linux-gnueabihf- zImage modules dtbs编译完之后怎么验证驱动真的进去了有三个办法。第一看.config里CONFIG_DEMO_DRVy是否成立。第二在编译输出里找CC drivers/misc/demo_drv/demo_drv.o这一行说明确实被编译了。第三也是最可靠的编译完成后检查System.map或者 vmlinux 的符号表看有没有demo_init这个符号grep demo_init System.map # 输出类似c0a4b3c0 t __initcall_demo_init6如果这一行存在说明初始化函数已经被链接进内核会在启动时被调用。这个检查方法在排查编进去了但没生效的时候特别有效因为它能区分没编进去和编进去但 probe 失败了这两种完全不同的情况。烧写之后如果驱动是内置的lsmod里是看不到的因为它不是模块。验证方式只能是看dmesg里有没有初始化日志以及/dev下有没有生成对应的设备节点。5. 常见问题排查实录与避坑清单5.1 加载失败报错速查表模块加载失败是最常见的场景我把这些年遇到的报错整理成一张表基本能覆盖八成情况报错信息根本原因解决方向Unknown symbol in module依赖模块未加载或符号未导出用 modprobe 替代 insmod检查 EXPORT_SYMBOLversion magic ... should be ...内核版本或编译配置不匹配用目标内核的头文件重编Invalid module format架构不匹配或内核版本差异过大确认交叉编译工具链与目标一致module license unspecified taints kernel缺少 MODULE_LICENSE补上 GPL 声明Permission denied未用 root 或安全策略限制加 sudo检查内核模块签名File exists同模块已加载先 rmmodDevice or resource busy模块被引用无法卸载检查是否有进程占用设备节点关于Device or resource busy补充一个排查技巧用lsmod看第三列的Used by计数如果非 0说明还有引用再用lsof /dev/demo0找出具体是哪个进程占着。实在找不到fuser -v /dev/demo0也能定位。5.2 vermagic与内核版本错配这个坑在交叉编译环境里出现频率极高。现象是在开发机上编译出来的 .ko拷到目标板上 insmod报 version magic 不匹配提示里会同时列出期望值和实际值对比一下就能看出差异在哪。差异通常来自三个方面。一是内核版本号不同比如开发机上是 5.10.0-8目标板是 5.10.0-13小版本号变了 vermagic 就变了。二是SMP/抢占配置不同目标内核开了CONFIG_SMP或者CONFIG_PREEMPT而你编译时用的头文件对应的是没开的版本。三是模块版本化CONFIG_MODVERSIONS未开启导致的符号 CRC 不匹配。解决办法只有一个用目标板正在运行的内核对应的头文件来编译模块。最稳妥的路径是从目标板的/lib/modules/$(uname -r)/build拿如果板子上没有就从编译出那个内核的源码树里拿并且确保.config完全一致。千万别图省事拿开发机的系统内核头文件去编那是另一个内核。注意uname -r里如果带表示 git 仓库有未提交改动说明内核是在开发树上直接编译的这种情况下必须用完全相同的源码树和配置任何一点差异都会导致 vermagic 不一致。5.3 编译期找不到头文件与符号未定义外部模块编译最常见的两个编译错误找不到linux/module.h或者找不到Module.symvers。前者的原因是KDIR指错了或者指向的内核树没做过make modules_prepare。后者更隐蔽一些Module.symvers记录了内核导出的所有符号及其 CRC外部模块编译必须依赖它做符号解析。如果内核树是先make modules_prepare而不是完整编译过这个文件可能不存在。处理方式cd /path/to/kernel/source make modules_prepare # 如果提示 Module.symvers 缺失 make modules # 完整编一遍模块生成 Module.symvers还有一种情况是编译能过但加载报 disagrees about version of symbol这通常是CONFIG_MODVERSIONS打开时符号的 CRC 校验失败根本原因还是内核树和运行内核不是同一份。5.4 内置驱动编译进去了却没反应这是转内置之后最容易遇到的困境因为缺少模块那种加载成功/失败的明确反馈。排查要按启动链路的顺序来。第一步确认真的编进去了grep demo_init System.map有__initcall_demo_init就说明会被调用。第二步确认初始化函数执行了在 init 函数最开头加一句pr_info开机看dmesg。如果连这句都没有说明__initcall那段没被执行可能是链接顺序或者__init段被错误丢弃了。第三步确认 probe 被调用了。设备树驱动的初始化函数通常只做注册真正的硬件操作在 probe 里。如果 init 日志有但 probe 没有那就是设备树匹配失败。这时候要检查三处设备树节点的compatible字符串、驱动里of_match_table的compatible字符串、两者是否逐字符完全一致包括大小写和连字符。第四步probe 里返回值是否为 0。probe 返回负数会导致设备注销日志里通常会有 probe failed with error -22 这类信息-22 是-EINVAL往往是参数校验没过。我个人的经验是内置驱动的问题有七成出在设备树匹配上两成出在资源申请失败时钟没使能、管脚被占用、regulator 没准备好剩下一成才是代码逻辑本身。所以排查顺序永远是先确认匹配再确认资源最后才怀疑代码。5.5 资源释放与并发安全的老坑模块方式下反复加载卸载会把资源泄漏问题放大得特别明显。这些资源包括设备号、cdev、class、device、中断、DMA 缓冲区、io 映射、kthread、timer、workqueue。卸载函数里必须一一释放顺序和申请时相反。一个特别容易被忽略的是misc 设备和 class 的释放顺序。device_destroy必须在class_destroy之前调用cdev_del必须在unregister_chrdev_region之前调用。顺序错了轻则报 warning重则内核 oops。并发安全方面模块方式因为支持热卸载rmmod的时候如果还有另一个 CPU 在执行模块里的代码就会直接崩。所以.owner THIS_MODULE那一行是保命的它会让rmmod在模块引用计数非零时拒绝卸载。但引用计数只能防止文件被打开时卸载防不了你驱动内部起的 kthread 或者 timer这些要在 exit 函数里主动停掉。static void __exit demo_exit(void) { del_timer_sync(demo_timer); /* 同步删除定时器 */ kthread_stop(demo_thread); /* 停止内核线程 */ cancel_work_sync(demo_work); /* 取消工作队列 */ device_destroy(demo_class, demo_devno); class_destroy(demo_class); cdev_del(demo_cdev); unregister_chrdev_region(demo_devno, 1); pr_info(demo: exit done\n); }del_timer_sync、cancel_work_sync这几个带 sync 的接口作用是确保回调函数执行完了才返回普通版本做不到这一点可能导致卸载之后回调还在跑访问已经释放的内存。这个坑我踩过一次表现是 rmmod 时随机崩溃查了整整两天才定位到定时器没同步删除。6. 我在两种方式之间来回切换的几条经验6.1 开发期与量产期的策略切换前面说过最稳妥的策略是开发期用模块、量产期转内置。但真要操作起来有几个细节需要提前准备否则切换的时候会手忙脚乱。第一从写第一行代码开始就按两种形态都能编译的标准来写。这意味着 Makefile 里用obj-$(CONFIG_XXX)这种写法即使当前只在外面用 make -C 编。这样将来把代码挪进内核树只需要加 Kconfig 和一层父级引用代码本身一行不用改。第二不要在代码里硬编码只有模块才有的东西。比如THIS_MODULE在内置编译时会展开成 NULL如果代码里对它做了非空判断然后走不同分支内置时行为就变了。同理module_param在内置构型下会变成module_param_named的等价形式参数可以在/sys/module/下访问这一点是兼容的但前提是你在 Kconfig 里真的开了对应的配置。第三设备树的兼容性要提前验证。内置之后 probe 时间点变早很多在模块模式下反正也能跑的时序假设会失效。比如驱动里依赖某个 regulator 已经使能模块模式下它是在用户态之后加载的肯定已经就绪内置之后 probe 可能发生在 regulator 初始化之前就会返回-EPROBE_DEFER。这时候驱动必须正确返回这个错误码让内核延后重试而不是当成真正的失败返回。关于-EPROBE_DEFER这里展开说一句它是内核的延迟探测机制当驱动发现自己需要的某个资源时钟、regulator、pinctrl还没就绪时返回这个错误码内核会把它排到队列后面稍后重试。很多新手不知道这个机制遇到 probe 失败就直接返回-ENODEV结果设备永远不会被初始化。判断依据很简单如果失败原因是依赖的东西还没准备好而不是真的没有这个设备就该返回-EPROBE_DEFER。6.2 版本管理与可复现构建模块方式有一个隐藏风险代码版本和内核版本容易脱节。你今天在 A 内核上编了个 .ko明天换了 B 内核忘了重新编译拷过去加载失败然后开始怀疑代码。这种情况我遇到过好几次最后的解决办法是把模块编译纳入统一的构建脚本每次发布都记录内核版本、模块 vermagic、编译时间戳。内置方式的风险则相反内核树被改得面目全非下次想复现构建环境都难。我一般的做法是驱动代码用独立 git 仓库管理内核树里的只是一个软链接或者每次同步过去的副本同时用savedefconfig保存一份精简配置make savedefconfig cp defconfig arch/arm/configs/demo_defconfig这样下次拿到一个新环境make demo_defconfig就能还原配置再配合scripts/config脚本打开需要的驱动选项保证每次构建的结果一致。还有个小技巧值得分享内置驱动的调试可以用CONFIG_DYNAMIC_DEBUG配合dyndbg参数在不重新编译的情况下动态打开某个文件的调试输出。启动参数里加dyndbgfile demo_drv.c p或者运行时往/sys/kernel/debug/dynamic_debug/control写规则都能生效。这个手段在内置驱动上尤其珍贵因为它绕开了改一行就要重编内核的困境。至于什么时候该把驱动从内置改回模块我的判断很朴素只要调试过程中发现需要反复改这个驱动的代码就改回模块。内置适合的是那种写完就基本不动的稳定代码一旦进入频繁修改期内置带来的每一次完整编译都是浪费。我现在的习惯是量产出货版本用内置但只要发现某个内置驱动出了问题需要修第一件事就是先把它临时改成m编译在设备上用模块方式定位问题确认修好之后再改回y出正式版本。这个来回切换看着麻烦实际比死磕内置镜像要快得多尤其是在编译一次内核要十几分钟的老平台上这笔账算得很清楚。