
1. 我为什么坚持按“模块→字符设备→设备树→I2C/CAN”这个顺序带人入门先说个背景。这几年我带过不少新人做嵌入式Linux驱动也帮朋友的公司做过内训发现一个普遍现象很多人一上来就盯着RK3568、i.MX8M这类平台的BSP包死磕设备树或者照着手册把I2C驱动、CAN驱动的代码抄一遍板子上能跑通了就以为自己会了。结果一旦换平台、换内核版本、换一颗不熟悉的Sensor芯片立刻卡死。问题出在哪儿不是不够努力而是学习路径错了——你跳过了驱动开发最底层的“地基”直接去够最上层的“天花板”。所谓“从内核模块到设备树、I2C/CAN的系统路径”说白了就是要回答四个递进的问题内核怎么把一个驱动代码片段加载进来驱动怎么把设备能力暴露给用户态程序硬件信息怎么从“写死在代码里”变成“由外部描述文件动态描述”复杂的总线协议设备I2C/CAN又是怎么挂到这套框架下的这四个问题恰好对应Linux驱动开发的四个台阶内核模块、字符设备框架、设备树、总线驱动。我见过太多人死在第二级和第三级之间——字符设备还没吃透就去折腾设备树导致后面看I2C的probe回调、看CAN的net_device注册时云里雾里。这篇文章就是按这条路径写的适合刚接触驱动开发、或者已经能改设备树但想补系统知识的工程师也适合准备Linux驱动相关面试的人。你不需要手里有一块真实板卡大部分知识点都能在QEMU模拟的virt平台或者自己的x86开发机上复现。这篇文章里出现的内核代码我尽量按Linux 5.15/LTS的API来讲因为这是目前各大厂商BSP里最主流的分支。不同内核版本API有细微差别我会在关键位置标注出来。2. 内核模块驱动的最小生存单元远不止一个hello world2.1 一个模块到底长什么样内核模块Loadable Kernel ModuleLKM是驱动开发的第一步也是最容易被低估的一步。很多人写个hello world就过了觉得“不过如此”。但如果你真的把模块机制的本质吃透后面理解驱动模型会顺非常多。我见过的最精简、但五脏俱全的模块代码大概是这个样子的#include linux/init.h #include linux/module.h #include linux/kernel.h static int __init demo_init(void) { pr_info(demo module loaded\n); return 0; } static void __exit demo_exit(void) { pr_info(demo module unloaded\n); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A minimal demo driver); MODULE_VERSION(1.0);这段代码虽然是入门级的但它涵盖了模块的四个基本要素初始化函数、退出函数、module_init/module_exit两个宏的注册动作、以及MODULE_系列元信息声明。注意__init和__exit这两个宏它们不只是装饰性的——__init告诉内核这个函数只在初始化阶段使用内存用完可以释放掉。这是一般模块代码里不讲的细节。编译模块用的Makefile很多人抄了一辈子也没弄明白为什么长这样obj-m demo.o KDIR : /lib/modules/$(shell uname -r)/build all: make -C $(KDIR) M$(PWD) modules clean: make -C $(KDIR) M$(PWD) cleanKDIR指向内核源码树或者内核头文件包M$(PWD)告诉kbuild系统“你要编译的模块源文件在我当前目录”。如果你是在板子上交叉编译需要额外传入ARCHarm64 CROSS_COMPILEaarch64-linux-gnu-并且KDIR要指向你实际使用的内核源码目录而不是uname -r对应的目录。这是我见过新手翻车率最高的地方——在x86主机上编译出的.ko拿到ARM板子上insmod直接报invalid module format。2.2 模块加载过程的实际链路经常有人问我insmod一条命令下去内核到底做了什么这个问题看似基础但延伸出去能解释很多疑难杂症。insmod demo.ko做完finit_module()系统调用后内核会做这些事检查模块ELF文件的格式和版本信息vermagic解析符号表并解决外部符号依赖然后把模块的.text、.data等段搬到内核地址空间最后调用你注册的module_init函数。如果初始化函数返回非0值模块会被自动卸载这就是为什么init函数里一旦某个子步骤失败要做好资源清理——要么用goto错误处理链要么用内核提供的devm_系列托管函数。老手和新手的区别从init函数是“一口气往下写”还是“每步留后路”就能看出来。模块和模块之间还有依赖关系日常调试中很常见的是你insmod一个依赖其他模块的驱动内核报Unknown symbol。解决这个问题有两个途径一是按依赖顺序逐个insmod底层模块二是把依赖关系写进depmod生成的modules.dep文件然后用modprobe一键加载。modprobe本质就是insmod的智能封装会解析.ko文件里的MODULE_SOFTDEP、depends字段自动处理依赖。这也是为什么产品发布时驱动安装脚本里几乎都是modprobe而不是insmod。2.3 模块参数驱动和用户态最原始的交互方式模块开发里还有一个常被低估的能力module_param。它允许你在加载模块时像传命令行参数一样传递配置static int debug_level 0; static char *server_addr 192.168.1.100; module_param(debug_level, int, 0644); module_param(server_addr, charp, 0644); MODULE_PARM_DESC(debug_level, Debug level (0-3));加载时这样用insmod demo.ko debug_level2 server_addr192.168.1.50第三个参数0644是sysfs权限位这意味着模块运行期间你还能通过/sys/module/demo/parameters/debug_level这个文件动态修改参数值。这个机制在调试驱动时非常好用保留一个debug_level开关现场不用重新编译直接往sysfs里写数字就能打开/关闭驱动里的调试打印。我在做现场支持时这一招帮我省了不知道多少回“重新编译来回折腾”的尴尬。提示在正式代码里关键路径的调试打印建议用pr_debug()/dev_dbg()而不是pr_info()因为前者在编译时可以通过DEBUG宏被优化掉而且可以通过dynamic_debug机制在运行时按文件、函数、行号精确开关。这是老内核开发者非常依赖、但新人几乎不知道的机制。3. 字符设备驱动框架内核态和用户态对话的第一条正经通道3.1 为什么字符设备是驱动开发的核心骨架内核模块这一步相当于你写了一支可以插进内核的队伍但它只能“自己跟自己玩”。用户态的App完全感知不到你的存在。要让用户态能访问你的硬件最常见的手段就是注册一个字符设备它通过/dev/xxx节点暴露给用户态App对这个节点做open/read/write/ioctl内核就回调到你驱动里对应的file_operations函数。之所以说字符设备是所有驱动的地基是因为连I2C、SPI、CAN这类复杂总线设备最终也脱离不了字符设备的思想I2C设备驱动的/dev/i2c-N就是字符设备CAN的SocketCAN本质也依赖网络设备框架而网络设备框架在Linux里也拥有和字符设备类似的设备模型根基。你把字符设备搞明白了后面所有东西都是在这个框架上做加法。裸写一个字符设备驱动大约需要四步分配设备号、初始化cdev结构体并添加到内核、创建设备类、创建设备节点。3.2 一个可用的字符设备骨架直接照抄下面这个模块是我在培训时最常用的模板它没有对应任何真实硬件但把字符设备的完整骨架搭了出来#include linux/module.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #include linux/slab.h #define DEV_NAME chardemo static dev_t demo_dev_num; static struct cdev demo_cdev; static struct class *demo_class; static char *demo_buf; static int demo_open(struct inode *inode, struct file *filp) { filp-private_data (void *)demo_buf; return 0; } static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *offset) { size_t len strlen((char *)filp-private_data); if (*offset len) return 0; if (count len - *offset) count len - *offset; if (copy_to_user(buf, (char *)filp-private_data *offset, count)) return -EFAULT; *offset count; return count; } static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *offset) { if (count PAGE_SIZE) count PAGE_SIZE; if (copy_from_user((char *)filp-private_data, buf, count)) return -EFAULT; ((char *)filp-private_data)[count] \0; return count; } static long demo_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { return 0; } static const struct file_operations demo_fops { .owner THIS_MODULE, .open demo_open, .read demo_read, .write demo_write, .unlocked_ioctl demo_ioctl, }; static int __init demo_init(void) { int ret; ret alloc_chrdev_region(demo_dev_num, 0, 1, DEV_NAME); if (ret) return ret; cdev_init(demo_cdev, demo_fops); ret cdev_add(demo_cdev, demo_dev_num, 1); if (ret) goto err_cdev_add; demo_class class_create(THIS_MODULE, DEV_NAME); if (IS_ERR(demo_class)) { ret PTR_ERR(demo_class); goto err_class_create; } device_create(demo_class, NULL, demo_dev_num, NULL, DEV_NAME); demo_buf kzalloc(PAGE_SIZE, GFP_KERNEL); if (!demo_buf) { ret -ENOMEM; goto err_kzalloc; } strcpy(demo_buf, hello from kernel\n); pr_info(chardemo: registered, major%d, minor%d\n, MAJOR(demo_dev_num), MINOR(demo_dev_num)); return 0; err_kzalloc: device_destroy(demo_class, demo_dev_num); class_destroy(demo_class); err_class_create: cdev_del(demo_cdev); err_cdev_add: unregister_chrdev_region(demo_dev_num, 1); return ret; } static void __exit demo_exit(void) { kfree(demo_buf); device_destroy(demo_class, demo_dev_num); class_destroy(demo_class); cdev_del(demo_cdev); unregister_chrdev_region(demo_dev_num, 1); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL);这段代码里有几个值得细品的地方alloc_chrdev_region()是“动态分配”设备号如果你要固定设备号用register_chrdev_region()。动态分配的好处是不冲突但设备号不确定需要靠udev根据/sys/class/chardemo/的信息自动创建节点。cdev_add()执行完之后这个设备立刻“活了”。所以它必须在所有资源就绪之后、而且device_create()之前完成否则用户态可能打开一个有file_operations但没有数据缓冲区的设备。class_create和device_create这两步是“锦上添花但极其关键”的。没有这两步你必须手动mknod /dev/chardemo c 240 0去创建设备节点而且App根本不知道设备号是多少。有了device_create用户态就可以看到/sys/class/chardemo/udev通过这个目录自动生成/dev/chardemo。用户态的测试代码就很简单了#include stdio.h #include fcntl.h #include unistd.h #include string.h int main(void) { char buf[64] {0}; int fd open(/dev/chardemo, O_RDWR); if (fd 0) { perror(open); return -1; } read(fd, buf, sizeof(buf)); printf(read: %s\n, buf); write(fd, hello user space, strlen(hello user space) 1); lseek(fd, 0, SEEK_SET); memset(buf, 0, sizeof(buf)); read(fd, buf, sizeof(buf)); printf(read again: %s\n, buf); close(fd); return 0; }3.3 file_operations里的两个容易被忽略的细节第一是read/write必须用copy_to_user/copy_from_user绝对不能用memcpy直接操作用户态指针。原因有两层一是安全性——用户态传进来的指针可能是非法地址直接操作会导致内核崩溃二是功能需要——这两个API内部会做地址合法性校验、段越界检查、缺页处理。你需要关心的*offset移动也是驱动自己的职责如果read里忘了递增*offsetApp就只能永远读到第一段数据这个bug我见过无数次。第二是unlocked_ioctl的问题。老内核里还有个ioctl字段2.6.36之后统一改成了unlocked_ioctl。如果你写驱动时没定义它App的ioctl()调用会直接返回ENOTTY。很多从老代码抄驱动的新人移植到新内核后发现ioctl失效多半就是把这个字段名字写错了。4. 设备树驱动与硬件解耦的关键Reset信号只是其中一个细节4.1 为什么说设备树是驱动开发的“翻译层”在设备树普及之前驱动里想要知道硬件信息比如寄存器基地址、中断号、GPIO引脚最常见做法是在驱动源码里直接硬编码。这种做法做单板可以做产品就完蛋——你给A厂商的板子写的驱动换到B厂商板子上就废了。内核开发者后来引入了Platform Device模型把一个设备的“资源信息”抽象成struct resource再通过platform_device注册到内核驱动侧用platform_get_resource()去拿这比硬编码好得多但平台设备的注册仍然写在板级C文件里代码耦合度还是高。设备树Device Tree的突破在于把硬件的描述完全从内核源码里挪到一份独立文本文件.dts或.dtsi里。驱动代码只负责描述“我怎么操作这一类设备”不关心它的物理位置和设备号。具体某个设备在哪个总线上、挂在哪个时钟域、用哪个GPIO做复位全部由设备树描述。这个“驱动即方案、设备树即配置”的分离思想是整个现代嵌入式Linux驱动开发的灵魂。设备树的源代码是.dts文件通过dtc编译器编译成.dtb二进制引导时由Bootloader加载给内核。在运行时你可以通过/sys/firmware/devicetree/base/查看设备树展开后的内容这个目录对调试非常有用。比如你改了设备树之后到底生效没有直接ls /proc/device-tree/或/sys/firmware/devicetree/base/一眼就能看到。4.2 看懂一个真实的设备树节点并亲自动手写一个以常见的SPI NOR Flash为例设备树节点长这样spi0 { status okay; pinctrl-names default; pinctrl-0 spi0_default; cs-gpios gpio4 3 GPIO_ACTIVE_LOW; flash0 { compatible jedec,spi-nor; reg 0; spi-max-frequency 50000000; spi-tx-bus-width 1; spi-rx-bus-width 1; }; };这里compatible jedec,spi-nor就是设备树和驱动之间的“握手信号”。内核里SPI NOR驱动的of_match_table里如果包含这个字符串驱动就会被匹配并probe。还有更重要的一点compatible的命名规则是“厂商,型号”顺序上先写特定型号、再写通用型号这是Linux社区的约定匹配时用的是“最前面匹配成功即停止”的逻辑。如果你要为自己的I2C触摸屏、GPIO控制的Sensor写一个节点基本上就是仿照这个结构。一个真实的GPIO控制复位信号的例子如下sensor48 { compatible somevendor,pressure-sensor; reg 0x48; interrupt-parent gpio1; interrupts 13 IRQ_TYPE_EDGE_FALLING; reset-gpios gpio4 20 GPIO_ACTIVE_LOW; vdd-supply reg_3v3; };这里interrupt-parent和interrupts定义了这颗Sensor的中断脚接在主控的哪个GPIO上、什么边沿触发reset-gpios就是热搜词里提到的“复位信号”——很多Sensor上电后需要拉低再拉高一次才能完成硬件复位这个拉低保持的时间有些芯片要求最低10us你可以在驱动里用gpiod_set_value()配合usleep_range()控制。设备树本身不负责时序它只告诉驱动“这个复位脚是哪个GPIO、低有效”时序是驱动代码里的活。4.3 驱动侧怎么“接住”设备树platform_driver与of_match_table设备树节点只是“需求描述”真正干活的还是驱动。挂在CPU片内外设总线上的设备在Linux里统一抽象成platform_device对应的驱动就是platform_driver。看一段典型的平台驱动注册代码static const struct of_device_id my_sensor_of_match[] { { .compatible somevendor,pressure-sensor }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, my_sensor_of_match); static int my_sensor_probe(struct platform_device *pdev) { struct device *dev pdev-dev; struct gpio_desc *reset_gpio; reset_gpio devm_gpiod_get(dev, reset, GPIOD_OUT_LOW); if (IS_ERR(reset_gpio)) return PTR_ERR(reset_gpio); /* 拉高完成一次硬件复位 */ gpiod_set_value(reset_gpio, 1); usleep_range(1000, 2000); gpiod_set_value(reset_gpio, 0); return 0; } static struct platform_driver my_sensor_driver { .probe my_sensor_probe, .remove my_sensor_remove, .driver { .name my_sensor, .of_match_table my_sensor_of_match, }, }; module_platform_driver(my_sensor_driver);module_platform_driver()这个宏是module_init module_exit platform_driver_register/unregister的语法糖几乎所有总线驱动最后都是这么写的。probe函数一旦被调用说明设备树里有一个compatible与of_match_table匹配的节点存在了。这里推荐使用devm_managed device resourceAPI比如上面代码里的devm_gpiod_get()。原因是如果后续某个资源获取失败devm框架会自动帮你在probe返回前释放已经申请成功的资源错误处理代码可以写得非常干净。这套API在整个现代Linux驱动里几乎是标配凡是你看内核里比较新的驱动几乎看不到裸gpio_request()不配gpio_free()的写法。4.4 设备树调试里最容易踩的几个坑设备树写错是日常调试设备树需要方法论。我按频率排一下我踩过的坑第一status disabled。节点写得好好的驱动就是probe不了最后发现是SoC自带的dtsi里把某个控制器默认禁用了。这时你要在板级dts里显式改成status okay。这个坑最隐蔽因为你看dtsi和dts里都有这个节点很难注意到一个状态位。第二reset-gpios和xxx-gpios这类属性的GPIO号写错。GPIO控制器有多个bank不同SoC描述方式不一样我们常在设备树里看到gpio4 20 GPIO_ACTIVE_LOW这样的写法——其中gpio4是节点引用20是bank内偏移GPIO_ACTIVE_LOW是低有效。如果你不确定引脚号优先在板卡原理图上把芯片引脚追到SoC的GPIO bank和pin index再换算成设备树写法。别指望靠猜我见过太多人把GPIO_ACTIVE_HIGH和GPIO_ACTIVE_LOW搞反导致设备永远处于复位状态。第三reg属性与地址空间。I2C设备节点里的reg 0x48是7位从机地址注意不是8位写地址。很多人把数据手册里的“0x90写地址”直接填进来驱动永远probe不到设备。这个坑在I2C驱动相关的热搜词里反复出现我下一章会重点拆。第四想确认设备树是否生效最快的方法是在内核里开CONFIG_OF相关选项、并在内核启动cmdline里加of_dev_dbg,或者直接在驱动probe函数里加dev_info(dev, of_node name: %pOF\n, dev-of_node)内核会打印设备树节点路径一眼就知道这个驱动是匹配到哪个节点了。比翻log快得多。5. I2C设备驱动一块Sensor芯片从设备树节点到probe回调的完整旅程5.1 I2C子系统的三层结构I2C子系统在Linux里分三层控制器驱动adapter、核心层I2C core、设备驱动client driver。控制器驱动负责操作SoC上的I2C外设硬件寄存器完成时序设备驱动负责处理具体I2C从设备比如读Sensor的寄存器、写DAC的配置。核心层负责两者之间的匹配和事务转发。这个分层的理解至关重要你在设备树里写的i2cxxx节点下的子节点驱动侧匹配到的就是i2c_driver而你的i2c_driver在probe时拿到的参数是struct i2c_client *它代表的就是设备树里的I2C从设备。你用i2c_transfer()发起的读写最终会经由core调度到对应adapter的master_xfer()回调里由它驱动硬件完成时序。在设备树普及前的老代码里你还会看到用i2c_board_info直接注册I2C设备的方式比如static struct i2c_board_info i2c_devs[] __initdata { { I2C_BOARD_INFO(pressure_sensor, 0x48) }, }; i2c_register_board_info(0, i2c_devs, ARRAY_SIZE(i2c_devs));这个方式在设备树平台已经被替代了但你在维护老内核BSP时会遇到需要能看懂。5.2 从零写一个I2C客户端驱动假设我手上有一颗假的温度传感器芯片faketempI2C从机地址0x48内部寄存器0x00是温度值16位0x01是配置寄存器。驱动代码核心部分如下#include linux/i2c.h #include linux/module.h #include linux/err.h #define FAKETEMP_TEMP_REG 0x00 #define FAKETEMP_CFG_REG 0x01 struct faketemp_data { struct i2c_client *client; }; static int faketemp_read_temp(struct i2c_client *client, s16 *temp) { u8 reg FAKETEMP_TEMP_REG; u8 data[2] {0}; int ret; struct i2c_msg msgs[2] { { .addr client-addr, .flags 0, .len 1, .buf reg, }, { .addr client-addr, .flags I2C_M_RD, .len 2, .buf data, }, }; ret i2c_transfer(client-adapter, msgs, 2); if (ret ! 2) { dev_err(client-dev, i2c_transfer failed, ret%d\n, ret); return -EIO; } *temp (s16)((data[0] 8) | data[1]); return 0; } static int faketemp_probe(struct i2c_client *client) { s16 temp; int ret; dev_info(client-dev, faketemp probed, addr0x%02x\n, client-addr); /* 读一次温度顺便验证通信链路 */ ret faketemp_read_temp(client, temp); if (ret) { dev_err(client-dev, failed to read temp\n); return ret; } dev_info(client-dev, temperature: %d.%02d\n, temp 8, temp 0xff); return 0; } static const struct i2c_device_id faketemp_id[] { { faketemp, 0 }, { } }; MODULE_DEVICE_TABLE(i2c, faketemp_id); static const struct of_device_id faketemp_of_match[] { { .compatible fake,faketemp }, { } }; MODULE_DEVICE_TABLE(of, faketemp_of_match); static struct i2c_driver faketemp_driver { .driver { .name faketemp, .of_match_table faketemp_of_match, }, .probe faketemp_probe, .id_table faketemp_id, }; module_i2c_driver(faketemp_driver); MODULE_LICENSE(GPL);注意两点i2c_transfer里我用了“write-then-read”的两段式消息数组。这是I2C读寄存器的标准做法——先写寄存器的地址然后带I2C_M_RD标志再读。很多新手图省事直接用i2c_smbus_read_word_data()那个函数读16位数据时字节序的坑很多不同传感器微妙地不一样我现在写新驱动都倾向于显式用i2c_msg数组控制每个字节。设备树侧对应节点i2c2 { status okay; clock-frequency 400000; faketemp48 { compatible fake,faketemp; reg 0x48; }; };需要注意的是clock-frequency这个属性是挂在I2C控制器节点上的表示总线速率常见值是100000标准模式或400000快速模式。但它只是尽量去配置实际能否达到400k取决于硬件pull-up电阻和走线不是设备树写了就一定跑得动。5.3 关于0x48与0x907位地址和8位地址的世纪混淆这是I2C调试里最经典的坑。芯片数据手册上经常写“写地址0x90、读地址0x91”但你的设备树reg属性值必须是0x48——因为0x90是把7位地址0x48左移一位再加上读写位得到的8位形式。Linux I2C子系统的client-addr保存的是7位地址新内核也支持10位地址但那是另一个话题i2c_transfer里框架会根据读写标志自动帮你移位、附加读写位。我见过不止一个工程师在这里卡一整天设备树填0x90用i2cdetect却能在0x48扫到设备驱动probe不到两者对不上。所以看到数据手册上的地址大于等于0x80时先做一次“除以2”运算得到的才是reg属性该填的值。这个经验我在培训里讲了至少五十次依然有新人反复踩。5.4 I2C驱动的调试手段i2cdetect、i2cdump、i2cget设备树写好了驱动probe不到怎么排查我的第一反应永远是先用i2cdetect扫总线i2cdetect -l i2cdetect -y 2-l列出所有I2C适配器-y 2扫总线2上的设备。如果0x48这个地址出现在扫描结果里说明硬件通路通着问题大概率出在设备树匹配或者驱动注册上如果扫描不出来说明是硬件接线、电压、上拉电阻、地址线设置的问题你花多少时间在软件上都是白费。确认设备存在后i2cget可以手动读寄存器i2cget -y 2 0x48 0x00这个命令直接用系统I2C框架发起一次读操作能把寄存器值裸读出来。如果这条命令能读出来、驱动却工作不正常说明问题在驱动的读写时序/缓存处理上如果这里都失败就是硬件链路的问题。这种“先排除物理层再定位逻辑层”的思路能让I2C调试效率翻倍。6. CAN设备驱动从字符设备思维切换到网络接口思维的十字路口6.1 为什么把CAN单独拿出来讲很多人学驱动学完I2C之后顺手就去搞SPI、搞PCIe然后突然在一个地方卡住了——CAN。因为CAN在Linux里的实现思路跟前面那些字符设备完全不一样CAN压根不是字符设备而是网络设备。它的驱动框架挂在net_device之下用户态用socket去访问而不是open/read/write。这是一个思维模式的转换点同样是总线设备I2C设备的用户接口是/dev/i2c-N而CAN设备的用户接口是can0这样的网络接口并且遵循SocketCAN协议族。SocketCAN是Linux内核里一套完整的CAN实现包括协议栈CAN_RAW、CAN_BCM、CAN_ISOTP、网络设备层和底层控制器驱动。从驱动开发现场角度来说这就意味着你写CAN驱动时不是在实现struct file_operations而是在实现struct net_device_ops的ndo_open、ndo_close、ndo_start_xmit这些回调。套接字发出的一帧CAN数据会经过网络协议栈、到达你的驱动接口最后由你控制CAN控制器硬件把它发到总线上。6.2 驱动侧的核心骨架net_device can_privLinux内核为CAN设备驱动准备了专门的辅助框架struct can_priv定义在include/linux/can/dev.h封装了波特率、时钟、状态、中断控制等通用逻辑你只需要实现具体的控制器操作函数。驱动骨架大概是这样的#include linux/can.h #include linux/can/dev.h #include linux/netdevice.h struct fakecan_priv { struct can_priv can; void __iomem *base; struct napi_struct napi; struct net_device *dev; }; static netdev_tx_t fakecan_start_xmit(struct sk_buff *skb, struct net_device *dev) { struct fakecan_priv *priv netdev_priv(dev); struct can_frame *frame (struct can_frame *)skb-data; int i; /* 把frame数据写入硬件控制器的发送FIFO */ for (i 0; i frame-can_dlc; i) { writeb(frame-data[i], priv-base FAKECAN_TX_BUF i); } /* 触发发送 */ writel(1, priv-base FAKECAN_TX_CTRL); /* 网络设备层需要统计和管理skb生命周期 */ dev-stats.tx_packets; dev-stats.tx_bytes frame-can_dlc; /* SocketCAN要求驱动把skb交给上层释放不加NETDEV_TX_BUSY就成功 */ dev_kfree_skb(skb); return NETDEV_TX_OK; } static int fakecan_open(struct net_device *dev) { /* 设置波特率、申请中断、启动控制器 */ return open_candev(dev); } static int fakecan_stop(struct net_device *dev) { /* 关闭控制器、释放中断 */ return close_candev(dev); } static const struct net_device_ops fakecan_netdev_ops { .ndo_open fakecan_open, .ndo_stop fakecan_stop, .ndo_start_xmit fakecan_start_xmit, }; static int fakecan_probe(struct platform_device *pdev) { struct net_device *dev; struct fakecan_priv *priv; void __iomem *base; base devm_platform_ioremap_resource(pdev, 0); if (IS_ERR(base)) return PTR_ERR(base); dev alloc_candev(sizeof(struct fakecan_priv), 1); if (!dev) return -ENOMEM; dev-netdev_ops fakecan_netdev_ops; priv netdev_priv(dev); priv-base base; priv-dev dev; /* 时钟频率硬件控制器输入时钟 */ priv-can.clock.freq 50000000; platform_set_drvdata(pdev, dev); return register_candev(dev); } static struct platform_driver fakecan_platform_driver { .probe fakecan_probe, .remove fakecan_remove, .driver { .name fakecan, .of_match_table fakecan_of_match, }, }; module_platform_driver(fakecan_platform_driver);alloc_candev和register_candev这两个API把CAN控制器的生命周期管理打包好了。实际上你在具体SoC的驱动里看到的代码会比这个复杂得多比如FlexCAN控制器有邮箱Mailbox机制MCP2515是把CAN控制器封装在SPI后面的外置芯片底层变成SPI读写但上层的net_device骨架是统一的。你在drivers/net/can/下面翻驱动时会发现无论什么控制器切换到net_device_ops的思路都是一样的。6.3 设备树侧的CAN节点还是以最常见的RK3568平台为例它内部集成的是FlexCAN继承自NXP IP设备树节点大概长这样can1 { status okay; assigned-clocks cru CLK_CAN1; assigned-clock-rates 50000000; };如果主控没有内置CAN控制器用的是MCP2515这类外置SPI转CAN芯片在设备树里是这样写的spi1 { status okay; mcp2515: can0 { compatible microchip,mcp2515; reg 0; spi-max-frequency 10000000; clocks mcp251x_osc; interrupt-parent gpio4; interrupts 20 IRQ_TYPE_EDGE_FALLING; }; };clocks属性指向的是一个振荡器节点MCP2515需要外接晶振波特率精度很大程度依赖这个晶振的精度interrupts是控制器中断脚内核收中断后从它的接收FIFO里把报文搬出来再交给SocketCAN协议栈。如果你配置完发现ip link set can0 up时报failed to bring up can0有一半概率是clocks没给对另一半概率是中断号出了偏差。6.4 用户态怎么验证CAN驱动调通了设备树配好、驱动加载成功之后验证手段比普通字符设备直观得多。# 打开can0接口设置500kbps波特率启动 ip link set can0 up type can bitrate 500000 # 查看接口状态应该出现NOARP、UP、RUNNING字样 ip -details link show can0 # 监听总线上所有报文 candump can0 # 发送一帧标准帧ID0x123数据为0x11 0x22 0x33 0x44 cansend can0 123#11223344 # 统计错误帧和总线错误 ip -details -statistics link show can0如果ip link set can0 up ...成功说明驱动open回调走通了再用cansend主动发一帧、candump能收到自己发的帧基本就说明发送路径OK。但要注意总线没有其他节点应答时CAN控制器通常会报ACK错误这是正常现象——CAN协议要求发送方必须收到至少一个其他节点的ACK位。所以自己一个人调CAN时回环测试ip link set can0 up type can bitrate 500000 loopback on才是真正验证驱动的方式。等整个链路通了再把它切回正常模式接真实总线。这个细节对那种“发送总报错但驱动明明没问题”的场景是决定性的。7. 调试Linux驱动最容易卡住的几个瞬间附我的排查路径7.1 内核崩溃后如何快速定位是不是自己的驱动干的驱动开发最刺激的瞬间就是insmod下去终端刷了一屏Oops内核崩溃然后系统完全卡死。很多新人的第一反应是慌甚至直接断电重启这其实完全没必要——Oops信息本身就是最好的调试数据。只要你能把终端上滚动的信息抄下来大概率能定位问题。拿到Oops信息后先看三件事第一看“Unable to handle kernel NULL pointer dereference”这类描述它告诉你崩溃类型。第二看PC指针PC is at demo_write0x10/0x50这个表达式里的demo_write0x10意思是崩溃发生在demo_write函数偏移0x10字节处后面的/0x50是函数总大小。如果你编译时开了CONFIG_DEBUG_INFO用addr2line反查一下就能准确定位到源码行号。第三看调用栈Call trace它告诉你这个函数是被谁调进来的——大多数驱动崩溃都是因为一个空指针、野指针被传到了下一层。提示我调试时一定会确保开发板用的是编译时开启了CONFIG_DEBUG_INFO和CONFIG_KALLSYMS的内核否则Oops信息里的符号名全变成十六进制地址定位效率下降一大半。这个选项在你编译内核时开一次用到天荒地老。7.2 内核日志里什么都没有驱动就是没反应insmod成功但dmesg里看不到驱动自己的打印比报错还让人抓狂。我遇到过的情况主要分四种一是你的打印用了pr_debug()而内核没有开DEBUG宏这些打印被编译期优化掉了。二是dmesg的缓冲区被刷掉或者控制台的loglevel太低pr_info()都没有显示到串口上。三是驱动确实没被加载用lsmod检查一下别靠猜。四是驱动被加载了但probe函数没被调用原因通常是设备树节点没匹配上。排查顺序我一般这样走先lsmod | grep your_dev确认模块在不在再看/sys/bus/platform/devices/下有没有对应设备最后在驱动的.of_match_table里临时加一个非常宽松的匹配项用dev_info()打印“probe called”。从外到内一层层排除比盯着代码发呆有效得多。关于printk控制台级别很多人调试时发现串口只有部分打印这其实是/proc/sys/kernel/printk四个数字在起作用。它代表控制台日志级别、默认消息日志级别、最小控制台级别、默认控制台级别。调试时把第一项改成8能放行几乎所有printkecho 8 4 1 7 /proc/sys/kernel/printk7.3 驱动能编译但加载时提示版本magic不匹配insmod报错version magic 5.15.0-91-generic SMP mod_unload modversions should be 5.15.0-91-generic SMP mod_unload——这种问题解决路径很清楚模块编译环境的内核头文件版本和运行内核版本不一致。要么把/lib/modules/$(uname -r)/build这个链接指到对的内核源码树要么重新跑到板子对应的内核源码目录下编译。内核模块对版本匹配极其严格因为它要防止二进制接口不兼容的模块被强行加载进内核。在给量产产品做驱动时一定要用最终固件同版本的内核源码来编模块否则就是给自己埋雷。7.4 GPIO请求失败设备树里写的是GPIO驱动里却请求不到这是设备树驱动的另一个高频坑。用了devm_gpiod_get()结果返回-EPROBE_DEFER。-EPROBE_DEFER是一个特殊错误码它的含义是我依赖的资源还没准备好了请内核过一段时间再调用我的probe一次。这个机制在设备树驱动里非常重要——如果节点的GPIO控制器驱动还没加载devm_gpiod_get就会返回-EPROBE_DEFER内核会在依赖的驱动加载完成后自动重新probe。所以遇到-EPROBE_DEFER不是错误而是正常流程你只需要确认两件事一是GPIO引脚的pinctrl配置是否正确它有没有被其他外设占用二是gpio-controller节点的#gpio-cells属性是否与你的gpio4 20 GPIO_ACTIVE_LOW的写法匹配。很多SoC的引脚是mux复用功能同一个引脚可能被I2C、SPI、GPIO多个功能占用设备树里没有配置pinctrl或者pinctrl配置了两个外设共用同一引脚都会导致GPIO申请失败。7.5 中断处理函数里不能做的事写驱动一定会碰到中断。我见过最普遍的中断问题是在中断上下文里调用msleep()、mutex_lock()甚至printk()用多了。中断上下文是原子上下文不能睡眠这是Linux内核开发最基本的红线。实际操作中如果遇到必须在中断里去等待硬件完成某个操作的情况解决方案是采用底半部bottom half机制用tasklet、workqueue、threaded IRQ把耗时操作挪到进程上下文执行。现代驱动里最推荐的是request_threaded_irq()它能直接把中断处理放到一个内核线程上下文里函数里能用mutex也能做更多事情代码可读性也更好。还有一个细节中断服务函数里记录时间后要尽快返回不要在中断里做I2C读写——I2C时序是阻塞的在atomic上下文里你去搞I2C轻则timeout重则挂死总线。真要读数据就disable_irq之后把工作交给workqueue在workqueue里再访问I2C处理完重新enable_irq。7.6 从Sysfs到Debugfs驱动调试的几个“免费”侦察工具驱动开发不止是写代码更是一个信息获取的过程。/sys、/proc、/sys/kernel/debug、/dev这些虚拟文件系统就是你的侦察工具。我最常用的是/sys/kernel/debug/下的内核调试接口比如/sys/kernel/debug/gpio能看到所有GPIO的状态和占用情况/sys/kernel/debug/clk能看到时钟树/sys/kernel/debug/pinctrl看看引脚mux状态。这些比拿万用表捅引脚快得多。/proc/interrupts能看每个中断号触发次数驱动装上之后第一件事就是确认中断有没有在触发。如果中断没触发多半是设备树中断配置有问题或者设备根本没工作。perf和tracepoint在定位驱动性能瓶颈时特别好用。比如查CAN报文接收是不是出现了丢包可以用perf trace或者trace-cmd跟踪net:netif_receive_skb、can:can_rx_skb相关事件。这些工具的使用经验一般不是语法问题而是“要先想清楚自己要看什么事件”的思维问题。7.7 一次现场排查的完整复盘CAN驱动在量产工位间歇性丢帧最后分享一个我这几年印象最深的案例它几乎把所有模块踩坑点串到了一起。客户产品是个带CAN总线的运动控制器量产工位反馈偶尔会出现控制器掉线但重新上电又好了。我去现场时先candump挂着抓帧抓到几帧后发现报文在时间戳上有明显的间隙有一瞬间卡了20ms没帧。这20ms足够让我怀疑驱动侧的问题。排查思路分两步先看驱动在哪一步丢了时间再看是什么占了这20ms。把中断号对应的触发次数打到板卡上发现CAN接收中断触发正常但在另一个GPIO中断触发时CAN中断响应被明显拖慢了——两个中断共享同一个中断控制器GPIO中断处理函数里有一段msleep(5)循环了几次把中断处理拖到了20ms。更糟的是那个中断里还做了I2C读写I2C总线正好又被同一颗SoC的另一个功能占着时序冲突。定位到之后修复方案是把GPIO中断改成request_threaded_irq把耗时操作挪到线程上下文中断服务函数只做数据搬运。改完再测CAN报文间隙从20ms降到了0.5ms以内。这个案例最典型的教训是中断上下文里任何可能阻塞的操作都是隐形的系统级地雷——它不只影响你那颗中断还会拖累整个板子上其他外设。8. 最后送你一条驱动开发的“系统路径”心法这篇文章从内核模块讲到字符设备、设备树、I2C、CAN一路铺下来可能信息量不小。但你回头再看会发现它们不是五个孤立的知识点而是一条完整的能力链理解模块机制 → 掌握设备注册与文件操作 → 学会用设备树描述硬件 → 在具体总线上落地。每往前一步都对前一步提出了新的要求。我带人的实际经验是按这个顺序把每一层都吃透一个零基础的人大概需要三到四个月能够独立接一个简单的I2C或GPIO驱动开发任务。如果跳过层级直接去抄CAN驱动哪怕当时跑通了后面一旦遇到硬件改版、内核升级、驱动并发问题你会发现自己根本无从下手。还有一个很重要的习惯一定要自己动手把每个模块从空文件开始敲一遍哪怕和教程完全一样也要亲手敲。因为驱动开发的很多坑不在阅读代码阶段暴露而是在编译、加载、运行、调试的每一个环节里。我写了这么多年驱动依然会时不时在insmod这步翻车但那不就是这一行的乐趣吗——每次翻车都能多攒一个“这个坑我下次绝对绕过去”的经验。