上周调试一块自研板卡,同事往USB口插了一颗CH340串口芯片,然后问我:“为什么Windows下一插就出COM口,Linux下去 /dev 里找半天什么都没有?是不是驱动没装?”我让他先lsmod | grep ch341,他一脸懵。后来折腾了二十分钟,发现驱动模块根本没有被加载,手动modprobe ch341之后 ttyUSB0 立刻出现了。问题是,下次重启还得手动敲一遍,或者得看udev脸色吃饭。
这就是Linux驱动开发里最容易忽略、但工程上又躲不过去的一关:驱动自动加载。很多刚入门的朋友,写一个字符设备驱动,insmod能跑、mknod能建节点,就觉得大功告成。可一旦到了真实产品、量产设备、嵌入式根文件系统,你会发现“自动加载”这四个字才是分水岭。模块能不能开机自启,能不能在设备插入瞬间被系统识别,能不能自动创建带正确权限的设备节点,决定了你的驱动能不能交付给别人用,而不只是自己在开发机上玩。
这篇东西适合正在学Linux驱动、做过一两个简单字符设备却发现离“产品化”还有距离的同学,也适合做嵌入式BSP、偶尔要往板子上塞新外设的工程人员。我会把自动加载涉及的模块信息、modprobe/depmod/udev、设备树匹配、USB串口实战这些点串起来讲,不是抄内核文档,就是踩坑记录。
1. 先说结论:自动加载不是内核单方面的事
1.1 一个让很多初学者懵掉的现场
先还原一个典型场景。你写了个驱动foo.ko,放到板子上,insmod foo.ko,然后手动mknod /dev/foo c 240 0,读写测试都正常。你很高兴,退出终端,重新上电,打开熟悉的终端准备继续测试,结果cat /dev/foo直接说不存在,lsmod里面也没有foo。
这不是内核把你的驱动吃了,而是你从一开始就用错了加载方式。insmod是个非常“手工”的命令:它只负责把当前这个.ko模块文件塞进内核,不关心依赖、不检查别名、不写入任何开机加载信息。系统重启后,内核不会记得曾经有过这个模块。
更麻烦的是,如果你依赖mknod手动创建节点,那么重启后不仅要重新加载模块,还得重新算主设备号、重新建节点。这套流程在开发阶段还能忍,真到现场部署,没人会愿意登录到设备上去敲这一串命令。所以“自动加载”至少要解决两件事:模块本身被内核/用户态服务自动装载,并且设备节点在模块加载后自动出现,权限和命名都可用。
1.2 内核、modprobe、udev各管哪一段
自动加载是一个跨内核态和用户态的协作过程,不是内核里某个开关负责的。简单理解,三方的分工大概是这样:
| 角色 | 所处空间 | 负责的事情 |
|---|---|---|
| 内核 | 内核态 | 总线和设备驱动匹配,设备插入时上报uevent |
| modprobe | 用户态 | 读取模块依赖和别名信息,自动加载形影的.ko文件 |
| udev | 用户态 | 监听uevent,根据规则创建设备节点、设置权限、触发后续动作 |
设备插入时,内核里的总线驱动(比如USB core)会发现这个新设备,然后去枚举设备、拿到VID/PID等信息,接着在已经注册的驱动列表里找匹配项。如果匹配的驱动是一个尚未加载的模块,内核会通过request_module机制触发用户态的modprobe,让modprobe根据模块别名把对应的.ko加载进来。
这一步我当年理解了很久才转过弯:内核不是直接把磁盘上所有.ko都扫一遍,它只是把“我需要某个类型的驱动”这个需求告诉modprobe,真正去翻/lib/modules/$(uname -r)目录的是用户态程序。所以如果模块没有安装到正确路径,或者modules.alias文件里没有对应别名,自动加载就静默失败。节点创建的事则归udev管,内核在设备注册成功后会发uevent,udev收到之后按规则执行mknod、chmod、建立软链接等操作。
搞清楚了分工,再去看驱动自动加载,思路就清晰了:你的驱动模块要把自己的“身份”声明清楚,让modprobe找得到;你的设备需要出现在总线上,让内核匹配得到;你的节点规则需要写好,让udev知道用什么权限、什么名字。
2. 驱动自动加载的底层机制:MODULE_ALIAS、depmod和bus_match
2.1 MODULE_ALIAS:给驱动贴上“能被认出来”的标签
自动加载最核心的入口是“模块别名”。很多人只听说过MODULE_LICENSE、MODULE_AUTHOR,但不知道MODULE_ALIAS才是让模块能被“点名加载”的关键。
MODULE_ALIAS本质上是一个字符串,它描述“这个驱动支持什么类型的设备”。比如一个USB串口芯片的驱动,会在源码里写类似这样的内容:
MODULE_ALIAS("usb:v1A86p7523d*dc*dsc*dp*ic*isc*ip*");也可能通过MODULE_DEVICE_TABLE带出来。编译完驱动后,这个别名会以__mod_alias符号的形式放在.ko文件的.modinfo段里。modinfo foo.ko可以直接看到:
$ modinfo foo.ko filename: /home/user/foo.ko alias: usb:v1A86p7523d*dc*dsc*dp*ic*isc*ip*当USB core拿到一个设备的VID=1A86、PID=7523时,它想要一个能支持该设备的驱动,于是构造出类似usb:v1A86p7523的请求,交给modprobe。modprobe到/lib/modules/$(uname -r)/modules.alias里面去查,发现别名匹配,就把foo.ko加载进来。
所以,没有合适的别名,内核有请求也找不到驱动。哪怕你的驱动代码写得再完美,设备插上来也会加载失败。
2.2 depmod和modules.dep:内核怎么知道加载哪个.ko
模块装到系统里不是把.ko往/lib/modules/$(uname -r)一扔就完事。Linux用depmod扫描内核模块目录,生成几个重要索引文件,其中最常见的是:
modules.dep:记录模块之间的依赖关系,比如A依赖B。modules.alias:记录模块别名和设备匹配字符串的映射关系。modules.symbols:记录模块导出的符号与模块名的对应关系。
这也解释了为什么很多教程里会写,执行make modules_install之后不要忘记运行depmod -a。如果漏了这一步,modprobe foo很可能提示modprobe: FATAL: Module foo not found in directory /lib/modules/...,虽然ls能看到文件。
手动加载时,modprobe会读modules.dep,遇到依赖会自动先把依赖模块加载上;而insmod不会做这种事。比如你依赖kernel/drivers/usb/serial/usbserial.ko,用insmod加载自己的驱动就会报Unknown symbol一类错误,用modprobe就正常,差别就在这。
一个正常的安装流程是:
sudo make modules_install sudo depmod -a执行完之后,把模块名字交给modprobe foo,它就能自动找到文件、检查依赖、把需要的模块都加载起来。整个过程不需要你手工指定.ko的路径。
2.3 设备树和platform bus的自动匹配
除了USB这类动态热插拔总线,嵌入式开发里碰得更多的是platform bus,配合设备树(Device Tree)工作。设备树里一个节点写:
&i2c1 { status = "okay"; foo_device: foo@28 { compatible = "vendor,foo-device"; reg = <0x28>; }; };驱动里用of_match_table声明自己能匹配哪些设备:
static const struct of_device_id foo_of_match[] = { { .compatible = "vendor,foo-device" }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, foo_of_match); static struct platform_driver foo_driver = { .probe = foo_probe, .remove = foo_remove, .driver = { .name = "foo", .of_match_table = foo_of_match, }, }; module_platform_driver(foo_driver);如果这个驱动以模块方式编译,虽然设备树节点在系统启动早期就存在,但platform总线和模块加载之间同样依赖别名机制。MODULE_DEVICE_TABLE(of, foo_of_match)编译时会生成of:vendor,foo-device这类别名。设备树节点被内核识别后,platform bus会尝试匹配,必要时触发modprobe加载模块。
这里有一个很常见的误区:以为设备树里有compatible,驱动就一定会自动加载。实际上驱动模块如果没有随根文件系统打包安装,或者modules.alias没有刷新,设备树节点匹配了也是空等。很多嵌入式板子第一次烧完系统发现I2C设备不在,跑一遍depmod -a就正常了,就是这个原因。
3. 从零写一个支持自动加载的字符设备驱动
3.1 字符设备驱动框架的最小骨架
先把一个最基本的字符设备驱动摆出来。这块没什么花头,主要确认驱动框架是完整的,然后再往上加自动加载能力。
#include <linux/module.h> #include <linux/cdev.h> #include <linux/device.h> #include <linux/fs.h> #include <linux/uaccess.h> #define FOO_MAJOR 0 // 0表示动态分配主设备号 #define FOO_MINOR_BASE 0 #define FOO_MINOR_COUNT 1 static dev_t foo_dev_num; static struct cdev foo_cdev; static struct class *foo_class; static int foo_open(struct inode *inode, struct file *filp) { return 0; } static ssize_t foo_read(struct file *filp, char __user *buf, size_t count, loff_t *pos) { return 0; } static const struct file_operations foo_fops = { .owner = THIS_MODULE, .open = foo_open, .read = foo_read, }; static int __init foo_init(void) { int ret; ret = alloc_chrdev_region(&foo_dev_num, FOO_MINOR_BASE, FOO_MINOR_COUNT, "foo"); if (ret < 0) return ret; cdev_init(&foo_cdev, &foo_fops); ret = cdev_add(&foo_cdev, foo_dev_num, FOO_MINOR_COUNT); if (ret < 0) goto err_cdev_add; foo_class = class_create(THIS_MODULE, "foo-class"); if (IS_ERR(foo_class)) { ret = PTR_ERR(foo_class); goto err_class_create; } device_create(foo_class, NULL, foo_dev_num, NULL, "foo"); return 0; err_class_create: cdev_del(&foo_cdev); err_cdev_add: unregister_chrdev_region(foo_dev_num, FOO_MINOR_COUNT); return ret; } static void __exit foo_exit(void) { device_destroy(foo_class, foo_dev_num); class_destroy(foo_class); cdev_del(&foo_cdev); unregister_chrdev_region(foo_dev_num, FOO_MINOR_COUNT); } module_init(foo_init); module_exit(foo_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("A minimal char driver with auto node creation");这段代码里已经有class_create和device_create,好处是当驱动加载成功,内核会自动创建/dev/foo节点,不需要再手动mknod。但这个“自动创建”是在驱动已经被加载之后才发生的,解决不了“设备插入时自动加载模块”的问题。
3.2 让设备表生效:MODULE_DEVICE_TABLE
要真正实现设备出场自动加载,需要给驱动挂上一个匹配表。如果是USB设备,通常会有一个usb_device_id表,里面列出一组VID/PID:
#include <linux/usb.h> #include <linux/usb/serial.h> static const struct usb_device_id foo_id_table[] = { { USB_DEVICE(0x1234, 0x5678) }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(usb, foo_id_table);如果是挂到platform bus上,则通过of_device_id声明和MODULE_DEVICE_TABLE(of, foo_of_match),前面设备树一节已经展示过。这两种方式编译后都会在.ko里生成相应的alias。可以用下述命令确认:
modinfo foo.ko比如你看到输出里有:
alias: usb:v1234p5678d*dc*dsc*dp*ic*isc*ip*那就说明这个模块在设备树或USB总线匹配时可以被modprobe识别。
还有一点容易被忽略:如果你驱动里的逻辑依赖于某个主设备号和设备节点,那么注册接口里最好使用动态主设备号,搭配alloc_chrdev_region,而不是像老式驱动那样硬编码一个主设备号。因为自动加载场景下,多个模块谁先加载完全不可控,硬编码主设备号一旦冲突,整个驱动加载就会失败。
3.3 自动创建设备节点的udev规则
驱动里的device_create负责让内核去生成设备节点,但设备节点权限默认比较受限,命名也可能是默认的。实际量产时往往需要额外写udev规则,比如串口设备要放开权限,或者给特定设备建立固定软链接。一个典型规则文件/etc/udev/rules.d/91-foo.rules可以这么写:
# 按内核名称匹配,这里只是示例 KERNEL=="foo", MODE="0666" # 按USB VID/PID匹配,插入即重命名设备节点 SUBSYSTEM=="usb", ATTRS{idVendor}=="1234", ATTRS{idProduct}=="5678", MODE="0664", GROUP="dialout", SYMLINK+="foo_device"写完规则要重载:
sudo udevadm control --reload-rules sudo udevadm trigger需要注意的是,device_create和udev规则不是互斥关系。device_create是“创建节点”的执行者,udev规则更多是“在节点创建前后做策略调整”的地方。设备节点的最终权限由udev基于规则设定,如果一条规则都不写,很多发行版默认会让普通用户对设备节点只有只读权限,甚至没有权限。
4. 实战:CH340/CP2102/ST-Link这类USB串口是怎么自动加载的
4.1 USB驱动的匹配逻辑
CH340、CP2102、FT232、PL2303这类USB转串口芯片,在Linux里都有现成驱动。它们的匹配不依赖用户态主动加载,而是USB core在枚举设备时发起的。以CH340为例,设备插入后会先被识别成一个USB设备,lsusb可以看到类似:
Bus 001 Device 002: ID 1a86:7523 QinHeng Electronics CH340 serial converter内核拿1a86:7523去对比已注册的USB驱动。如果ch341驱动编译成了模块,USB core会通过request_module("usb:v1A86p7523d...")让modprobe自动把ch341.ko加载到内核。整个过程用户看到的就是:插上USB线,/dev/ttyUSB0自己冒出来。
ST-Link、J-Link这类调试器稍微复杂一点,通常不止一个USB接口功能。ST-Link/V2在Linux下可能同时出现HID接口、mass storage接口和调试接口,枚举时每个接口都可能触发不同的驱动匹配。普通用户不需要手动装驱动,但如果系统里缺少相关模块,就会出现调试器“能识别但无法下载”的问题。
4.2 手动生成并测试VID/PID别名
如果你正在写一个自研USB设备驱动,想验证自动加载是否生效,最直接的办法是主动生成别名,再查看modinfo和modules.alias。假设设备VID是0x1234,PID是0x5678,在驱动里写:
static const struct usb_device_id foo_id_table[] = { { USB_DEVICE(0x1234, 0x5678) }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(usb, foo_id_table); static struct usb_driver foo_usb_driver = { .name = "foo", .probe = foo_probe, .disconnect = foo_disconnect, .id_table = foo_id_table, }; module_usb_driver(foo_usb_driver);编译安装后,用下面几个命令检查:
modinfo foo.ko lsmod | grep foo grep 1234 /lib/modules/$(uname -r)/modules.alias如果modules.alias里能看到类似alias=usb:v1234p5678d*dc*dsc*dp*ic*isc*ip*的条目,说明自动加载链路已经具备。接着插上设备,dmesg会显示usb 1-1: new full-speed USB device这样的日志,同时也能在dmesg尾部看到foo 1-1:1.0: probe success,说明驱动确实被自动加载并完成了probe。
4.3 常见USB串口“装上却找不到/dev/ttyUSB0”的原因
这些年我给不同板卡排查过很多次串口不见的问题,总结下来基本就是以下几类:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
lsusb能看到设备,但/dev下没有ttyUSB/ttyACM | 驱动模块没加载 | modprobe ch341,然后查dmesg |
modprobe提示模块不存在 | 模块没安装或内核版本变了 | make modules_install && depmod -a |
设备出现,但打开/dev/ttyUSB0报Permission denied | 权限不足 | 添加udev规则,将设备加入dialout组 |
设备名变成了/dev/ttyACM0 | 芯片走的是CDC ACM协议,比如CP2102在部分内核下会显示为ttyACM | 使用固定软链接来稳定设备路径 |
| 插上后设备节点闪一下然后消失 | 驱动probe失败,可能有其他模块抢占或资源冲突 | 查看dmesg,卸载冲突模块后重试 |
这里特别提醒一点:CH340不一定总是映射成ttyUSB0,不同内核版本、不同USB Hub端口下,名字可能变成ttyUSB1、ttyUSB2。项目里如果依赖固定路径,不要硬写ttyUSB0,最好写udev规则,根据设备的序列号、VID/PID生成一个固定的/dev/my_uart这种软链接。
5. 自动加载失败后的排查链路
5.1 从dmesg和modprobe的输出看问题
排查自动加载问题,最忌拍脑袋。我比较固定的做法是,拿到一台“驱动加载不正常”的设备,先开两个终端:一个跑dmesg -w,一个跑udevadm monitor --property,然后插入设备,观察内核和udev角度的完整事件流。
dmesg主要看USB枚举、驱动匹配、probe失败的堆栈;udevadm monitor则能看到内核uevent里的各项属性,比如PRODUCT=1a86/7523/...、ACTION=add等。如果事件到了用户态但没有后续动作,说明udev规则没接住;如果事件都没送到用户态,问题大概率在内核匹配或驱动加载环节。
modprobe的排查也有小技巧:
modprobe -v ch341 # 显示详细加载过程 modprobe -n -v ch341 # 只打印准备执行的步骤,不实际加载-n参数常用于看“系统会怎么处理这个模块”,不会改变内核状态,非常适合定位依赖问题。
5.2 modinfo与modules.alias对照法
自动加载失败的一个重要分支,是模块存在但modprobe就是不加载。这时候要把矛头对准别名索引。很多开发者写了MODULE_DEVICE_TABLE,也执行了depmod -a,但还是失败,那就需要人工对照一下modinfo和modules.alias里的内容。
比如,我们的自研USB驱动模块名是foo.ko,先看模块里实际包含哪些别名:
modinfo foo再看系统索引里是不是有对应记录:
grep -i 1234 /lib/modules/$(uname -r)/modules.alias如果modinfo有,但modules.alias里查不到,多半是安装的模块文件不是当前内核目录下的那一份,或者执行depmod的时机不对、内核版本号不匹配。还有可能是交叉编译时直接拷贝.ko到目标板,忘了在目标板上跑depmod -a。嵌入式设备上很多文件系统是只读的,这一步特别容易漏。
5.3 三个最容易踩的坑
第一个坑:用insmod测试通过就以为万事大吉。insmod跳过所有依赖解析和别名查询,它只能证明你的模块语法正确、init回调能跑。一旦把模块换成modprobe自动加载,那些靠insmod掩盖的问题就会全部暴露出来。所以开发阶段建议直接养成用modprobe的习惯,测试自动加载就用modprobe,别用insmod给自己埋雷。
第二个坑:改了驱动之后没有重新生成modules.alias。不少新手把新编译的.ko直接覆盖到/lib/modules/...,然后插上设备发现没有任何反应,还以为是设备坏了。实际上系统索引文件仍然是旧的,modprobe根本不知道这个模块支持新设备。安装模块后跑一次depmod -a,这是成本最低、收益最大的一个习惯。
第三个坑:设备树里的compatible和驱动里的of_match_table不一致。字符哪怕差一个逗号、差一个大写字母都匹配不上。排查办法是启动后在/sys/firmware/devicetree/base/对应节点下查compatible属性,直接cat出来和代码里的字符串做二进制级别对比,别“肉眼认为它们一样”。
6. 工程落地中的额外建议
6.1 initramfs阶段要处理驱动加载顺序
自动加载这件事,在系统完全启动后看起来很“自动”,可如果你做的是嵌入式设备,要面对的是initramfs阶段的问题。根文件系统如果放在SD卡、USB存储或者某个需要驱动才能访问的介质上,那么内核在挂载真正的根文件系统之前,必须先把对应的存储控制器驱动、USB驱动、SD/MMC驱动加载起来。
这时候光靠/lib/modules里的模块是不行的,因为根文件系统还没挂载,内核模块在哪都不一定找得到。一般做法是在构建initramfs时把必要模块放进临时根文件系统,再通过modules-load.d或者init脚本里的modprobe按顺序加载。
如果用的是标准发行版,比如Ubuntu/Debian,可以通过更新initramfs来加入驱动:
echo "ch341" | sudo tee /etc/modules-load.d/ch341.conf sudo update-initramfs -u重启后ch341会在initramfs阶段被加载,早期用户态如果需要访问对应串口也来得及。嵌入式的buildroot/Yocto里,则是在内核配置或rootfs overlay里预先指定模块列表,这一步无法完全照搬PC上的经验,必须在目标板子上反复验证启动日志。
6.2 DKMS和驱动版本管理
如果你的驱动不在主线内核里,或者升级内核后经常要重新编译,不要再手工去make modules_install了。DKMS可以在内核更新后自动重新编译并安装模块,对自研驱动和第三方闭源驱动尤其有用。基本用法是这样:
sudo dkms add ./foo_driver sudo dkms build -m foo_driver -v 1.0 sudo dkms install -m foo_driver -v 1.0DKMS会以模块源码的形式注册到系统里,之后每次安装新内核,它都会尝试为新内核重新编译。配合自动加载,你只需要确保驱动源码里把MODULE_DEVICE_TABLE和MODULE_ALIAS写正确,新内核起来后插上设备就能加载。
一个容易踩的坑是DKMS安装模块后,depmod由DKMS自动执行,但如果源码目录里有多个.ko互相依赖,需要确认DKMS的配置文件里指定了正确的模块顺序。模块依赖没解决好,照样会出现“装上了但加载失败”。
6.3 平台差异:国产Linux发行版、嵌入式rootfs要单独验证
最后聊一个工程上经常被忽略的点:同一套驱动源码,在x86的桌面发行版上自动加载没问题,不代表在嵌入式rootfs、或者国内基于Debian/Ubuntu的定制发行版上也一定没问题。原因主要在于内核配置、模块安装路径、udev版本和固件加载方式的差异。
比如PC发行版通常会把模块放在/lib/modules/$(uname -r)/kernel/drivers/...,而嵌入式rootfs可能放在/usr/lib/modules/$(uname -r),三种路径都存在。如果驱动里还有request_firmware加载固件的逻辑,还需要确认固件文件放在/lib/firmware下且文件名与驱动要求完全一致。固件缺失时,驱动加载不会报“文件不存在”,而是会在dmesg里报一个Direct firmware load for xxx.bin failed,接着probe失败。自动加载链路里,固件这个因素非常容易被忽略,排查时要多看一眼。
另外,不同发行版对普通用户加入dialout组、串口节点权限的默认策略不完全一样。同一个udev规则,在A发行版有效,在B发行版可能因为规则文件加载顺序有变动而不生效。项目只要涉及多个Linux环境,一定要把“插拔设备-节点名-权限-可用性”这四个步骤在目标环境里过一遍,别只在自己电脑上测一次就交付。
我自己平时接新设备有个固定动作,先lsusb看设备,再lsmod看驱动,然后modinfo看别名,接着udevadm monitor看事件,最后dmesg收尾。这套流程看似简单,但能解决我遇到的九成自动加载问题。驱动自动加载不是什么玄学,说到底就是把模块身份、系统索引、总线匹配、udev规则这几样东西理清楚,再难的问题也能顺着链路一步步揪出来。