Linux驱动开发实战:从内核模块到设备树,再到I2C/CAN总线

Linux驱动开发实战:从内核模块到设备树,再到I2C/CAN总线 Linux设备驱动开发这条路实操过的人都知道难的不是语法而是不知道从哪下手。很多人一上来就翻内核源码对着设备树文件一脸懵真正要用I2C和CAN总线驱动外部设备时更是手足无措。其实驱动开发有一套特别清晰的路径先写内核模块搞懂驱动的装载和卸载再用设备树把硬件信息和驱动代码拆开最后进到I2C、CAN这类具体总线的子系统里按规范读写寄存器、收发数据帧就行了。这篇文章我就围绕这套路径把从内核模块到设备树、再到I2C和CAN总线驱动的完整过程展开说清楚。不管你是刚入门的嵌入式新人还是已经写过几个字符设备驱动但还没碰过总线类驱动的工程师都可以照着这个思路去搭自己的驱动知识体系。我尽量用实际项目中能直接抄的代码、配置和命令来讲少说空话。1. 从内核模块开始理解Linux驱动的最小单元1.1 为什么内核模块是驱动开发的第一课在Linux体系里驱动本质上是运行在内核态的一段代码用来管理特定硬件。内核模块Kernel Module是其中非常特殊的一种形式它不用整体编译进内核而是以 .ko 文件存在系统运行时用 insmod 动态加载用 rmmod 动态卸载。对开发调试来说这太关键了。改一段驱动代码重新编译一个 .ko 文件加载进去就能测不需要反复烧写整个内核镜像开发周期会短非常多。我见过不少初学者第一步就去移植内核、裁剪系统结果一个配置项没选对整个内核起不来折腾一下午还没碰到驱动代码。正确顺序是先把内核模块这一套流程跑通写代码、编 .ko、加载、看日志、卸载把驱动是怎么被系统接管的这个感觉找到后面学设备树和总线子系统会特别顺。类比一下内核是公司的正式员工体系模块就相当于外聘专家。专家不用参与公司全部日常流程来了就能干活干完活就可以走。设备驱动大多数情况下就是用这种外聘的方式来管理硬件资源的。1.2 一个最小内核模块的骨架与编译一个最基础的内核模块代码量很小核心就三句加载函数、卸载函数、两个注册宏。下面这个例子我经常拿来当模板不管后面驱动多复杂骨架都是它。#include linux/module.h #include linux/init.h #include linux/kernel.h static int __init demo_init(void) { printk(KERN_INFO demo: module loaded\n); return 0; } static void __exit demo_exit(void) { printk(KERN_INFO demo: module unloaded\n); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A demo kernel module);配套的Makefile也很有讲究关键在于要使用内核源码树目录来做编译。以Ubuntu开发机为例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执行 make 之后如果没报错目录下会生成 demo.ko。加载和验证流程如下sudo insmod demo.ko dmesg | tail -5 sudo rmmod demo dmesg | tail -5insmod 之后在 dmesg 里能看到 demo: module loadedrmmod 之后能看到 demo: module unloaded说明整个模块生命周期跑通了。这里有几个必须注意的细节__init 和 __exit 不是装饰性写法。它们让内核在模块加载/卸载以后释放这段代码占用的内存减少驻留内存的开销正规驱动都这么写。MODULE_LICENSE(GPL) 非常关键。如果没有声明GPL一些内核导出的符号EXPORT_SYMBOL_GPL 导出的你是无法使用的比如很多内核 API 和辅助函数。写 printk 时要用 KERN_INFO 或更高优先级否则有些内核配置下 dmesg 里看不到还容易被 level 过滤掉。1.3 内核模块的生命周期与资源管理模块加载后内核会给它维护一个引用计数refcnt。如果模块中提供了文件操作接口并且有进程正在使用这些接口引用计数就不为0这时 rmmod 会返回 EBUSY模块卸不掉。手动处理时可以用 lsmod 查看模块被谁引用也可以用 modprobe -r 去按依赖顺序卸载。另一个容易踩坑的是模块的返回值语义。demo_init 返回 0 代表加载成功返回负数比如 -ENODEV、-EINVAL内核会认为加载失败并打印对应的错误码。很多新手习惯在 init 里做一堆资源申请比如注册字符设备、申请GPIO、请求中断只要有一步失败都要记得把之前申请的资源全部释放掉再 return 负数否则就会出现资源泄漏下次 insmod 时可能报 Device or resource busy。模块层面还有一个点就是符号导出。如果 A 模块需要调用 B 模块里的函数需要用 EXPORT_SYMBOL 或 EXPORT_SYMBOL_GPL 显式导出同时 B 模块要先加载。这属于多模块协作的场景后面写总线驱动时经常要拆分多个 .ko提前知道这个机制能少走弯路。2. 设备树从硬编码到配置化的关键一跃2.1 设备树解决了什么问题在没有设备树的时代ARM Linux 里每换一块板子都要在 mach-xxx.c 这类板级文件里注册一堆 platform_device 结构体把片内外设地址、中断号、GPIO 全部硬编码在 C 代码中。问题在于上游内核每新增一种板子就得多塞几百行板级代码时间一长文件非常多而且不同开发板之间的代码耦合严重想复用内核版本特别痛苦。设备树Device Tree把硬件长什么样从内核代码里抽离出来变成一份独立的描述文件。内核编译时或启动时解析这份文件根据节点和属性去匹配对应的驱动。这样同一份内核镜像在 A 板子上启动时加载的是 A 的设备树在 B 板子上加载的是 B 的设备树硬件差异全在 .dts 文件里体现代码层面一次编译、到处跑。对驱动开发者来说设备树带来的最大变化是驱动代码里不再写死某块板子上的寄存器地址和中断号而是通过标准 API 去解析设备树节点拿到这些信息后再操作硬件。这个解耦思路一旦建立后面做系统裁剪优化、平台移植都会轻松很多。2.2 DTS/DTC/DTB看懂设备树的三层结构设备树的源文件是 .dts公共部分通常放在 .dtsi 中用 #include 方式引入。比如一个完整的板级 .dts 往往会包含 SoC 厂商提供的 .dtsi里面定义了 CPU、内存控制器、中断控制器、各种总线节点板级文件里只要覆盖override掉需要修改的节点即可。DTC 是设备树编译器把 .dts 编译成二进制的 .dtb。内核启动时由 bootloader 加载 .dtb 文件然后在启动早期进行解压展开unflatten变成内核内部能检索的设备树结构体。所以设备树的三层结构可以理解为DTS 是源码DTB 是编译产物内核解析后形成的内存树就是运行时使用的最终形态。在开发机上手工编译设备树的命令dtc -I dts -O dtb -o myboard.dtb myboard.dts反编译已有 DTB 也非常实用dtc -I dtb -O dts -o output.dts myboard.dtb驱动代码里最常用的是这几个 APIstruct device_node *np dev-of_node; of_property_read_u32(np, clock-frequency, freq); of_property_read_string(np, compatible, compatible); of_get_named_gpio(np, enable-gpios, 0);这些 API 返回 -EINVAL 或 -ENODATA 时说明节点里缺属性或类型不对要回头查 .dts 里的写法。2.3 I2C和CAN设备节点的编写实例设备树的节点写法对 I2C 从设备和 CAN 控制器最典型。I2C 从设备节点挂在对应 I2C 控制器节点下面关键是 compatible 和 reg 两个属性。compatible 用于驱动匹配reg 就是从设备的 7 位 I2C 地址i2c2 { status okay; clock-frequency 400000; eeprom50 { compatible atmel,24c02; reg 0x50; pagesize 16; }; temperature48 { compatible ti,tmp117; reg 0x48; }; };这里的 clock-frequency 是 I2C 控制器启动时配置总线速率用的400000 就是快速模式 400kHz。内部子节点的 reg 直接决定了内核里 i2c_client 的 addr 字段如果这个值写错驱动用 i2c_transfer 发地址时永远匹配不上从设备。CAN 控制器节点一般放在 SoC 片内外设区域下需要配置 pinctrl 引脚复用、中断等信息。以常见的 RK3568 平台为例设备树里往往是这样can0 { pinctrl-names default; pinctrl-0 can0m0_pins; status okay; }; can1 { pinctrl-names default; pinctrl-0 can1m0_pins; status okay; };pinctrl 的意思是告诉内核CAN0 控制器的引脚复用被配置成 CAN 功能而不是 GPIO 功能。这个配置如果和实际原理图不一致板上总线怎么都拉不高电平排查时特别容易忽略。2.4 设备树与驱动匹配的完整链路设备树驱动匹配走的是 platform_driver 机制。驱动里声明一个 of_match_table里面放好 compatible 字符串内核在启动阶段和设备热插拔时都会拿着设备树节点的 compatible 属性与驱动表中的项做比较匹配成功就调用驱动的 probe 函数。static const struct of_device_id demo_of_match[] { { .compatible vendor,tmp117 }, { } }; MODULE_DEVICE_TABLE(of, demo_of_match); static struct platform_driver demo_driver { .probe demo_probe, .remove demo_remove, .driver { .name demo, .of_match_table demo_of_match, }, }; module_platform_driver(demo_driver);probe 函数里做的事情本质就是把设备树里解析到的信息拿出来初始化硬件并注册设备。整个链路可以这样理解DTS 里的 compatible 相当于身份证号驱动的 of_match_table 相当于公安局的户籍系统两者对上号了probe 就被触发硬件设备就算正式接管了。我在实际项目中经常用 /sys/firmware/devicetree/base/ 目录来快速确认设备树是否生效。比如cat /sys/firmware/devicetree/base/i2c2/eeprom50/compatible如果输出 atmel,24c02说明节点已经进入内核的设备树了。再配合 /sys/bus/i2c/devices/ 下的设备列表就能定位是设备树问题还是驱动匹配问题。3. I2C子系统从协议到驱动的全链路实践3.1 I2C协议的核心要点I2C 总线只有两根线SCL时钟线和 SDA数据线。主机控制 SCL 产生时钟SDA 用来传数据。通信开始前主机先发出起始条件——SCL 为高电平期间 SDA 从高拉低结束前发送停止条件——SCL 为高电平期间 SDA 从低拉高。这个起始和停止条件特别重要很多从设备在识别到起始条件后才会重置内部状态机。数据传输按字节进行每字节8位高位在前。每个字节之后接收方需要回一个 ACK 位SDA 拉低表示收到否则就是 NACK。地址帧也是一样7位从设备地址左移一位最低位为0表示写为1表示读。比如 EEPROM 地址 0x50写操作时字节是 0xA0读操作时字节是 0xA1。从设备如果处理不过来可以拉低 SCL 进行时钟延展clock stretching主机需要等从设备释放 SCL 才能继续。不过很多小设备不支持这个特性驱动里一般不会去处理但总线丢 ACK 时心里要清楚有这种可能。上拉电阻是 I2C 硬件设计的关键。SCL 和 SDA 都是开漏输出必须通过上拉电阻接到电源。电阻太小会导致上升沿过快、功耗大电阻太大则会导致上升沿过慢、通信频率上不去。常见经验值在 1kΩ 到 10kΩ 之间400kHz 速率一般用 2.2kΩ 或 4.7kΩ。3.2 Linux I2C子系统架构Linux 的 I2C 子系统分为三层适配器adapter、核心层core和设备驱动client driver。adapter代表一条 I2C 总线控制器里面包含 algorithm收发函数实现和资源信息。例如 SoC 自带的 I2C2、I2C3 控制器内核里就是一个个 i2c_adapter。client代表挂在某条总线上设备包含地址和设备树信息。设备树解析后每个 I2C 子节点都会生成一个 i2c_client。driveri2c_driver 是驱动本身通过 id_table 或者 of_match_table 与 client 匹配。可以这么类比adapter 是墙上的插座client 是插头driver 是电器说明书。匹配成功以后probe 被调用驱动就能通过 adapter 向 client 地址发起读写请求了。内核提供的核心 API 有几个需要记牢int i2c_master_send(struct i2c_client *client, const char *buf, int count); int i2c_master_recv(struct i2c_client *client, char *buf, int count); int i2c_transfer(struct i2c_adapter *adap, struct i2c_msg *msgs, int num);i2c_master_send/recv 适合简单读写i2c_transfer 适合组合操作。比如读 EEPROM 某个地址的数据需要先写地址再读数据两条消息要放同一个 i2c_msg 数组里用 i2c_transfer 一次完成中间是连续的重复起始条件repeated start这样就不会被其他主机打断。3.3 I2C设备驱动编写实战拿一个真实的温度传感器驱动来举例从设备地址 0x48内部寄存器 0x00 存储温度值。驱动核心是 i2c_driver 的注册和读写函数。#include linux/i2c.h #include linux/module.h #include linux/kernel.h #define TMP117_TEMP_REG 0x00 static int tmp117_read_temperature(struct i2c_client *client, int *temp) { struct i2c_msg msgs[2]; unsigned char reg TMP117_TEMP_REG; unsigned char data[2]; int ret; msgs[0].addr client-addr; msgs[0].flags 0; msgs[0].len 1; msgs[0].buf reg; msgs[1].addr client-addr; msgs[1].flags I2C_M_RD; msgs[1].len 2; msgs[1].buf data; ret i2c_transfer(client-adapter, msgs, 2); if (ret 0) return ret; *temp (data[0] 8) | data[1]; return 0; } static int tmp117_probe(struct i2c_client *client) { int temp; dev_info(client-dev, tmp117 probed\n); if (tmp117_read_temperature(client, temp) 0) dev_info(client-dev, temp raw 0x%04x\n, temp); return 0; } static void tmp117_remove(struct i2c_client *client) { dev_info(client-dev, tmp117 removed\n); } static const struct of_device_id tmp117_of_match[] { { .compatible ti,tmp117 }, { } }; MODULE_DEVICE_TABLE(of, tmp117_of_match); static const struct i2c_device_id tmp117_id[] { { tmp117, 0 }, { } }; MODULE_DEVICE_TABLE(i2c, tmp117_id); static struct i2c_driver tmp117_driver { .driver { .name tmp117, .of_match_table tmp117_of_match, }, .probe tmp117_probe, .remove tmp117_remove, .id_table tmp117_id, }; module_i2c_driver(tmp117_driver); MODULE_LICENSE(GPL);注意几个容易忽略的地方msgs[0].flags 不设置表示写msgs[1].flags 设置 I2C_M_RD表示读。msg.len 是指字节数不是寄存器地址位数。寄存器地址是1字节len就是1返回数据是2字节len就是2。i2c_transfer 返回成功传输的消息条数如果返回2表示两条消息都完成了返回0或负数都说明传输失败。编译和加载后在 /sys/bus/i2c/devices/2-0048/ 下能看到这个设备节点说明 client 和 driver 已经成对绑定了。3.4 I2C调试与常见问题排查I2C 调试工具我用得非常勤在 i2c-tools 包里几个命令分别是 i2cdetect、i2cdump、i2cget、i2cset、i2ctransfer。先扫描总线上的设备i2cdetect -y 2这条命令会列出总线2上所有能 ACK 的7位地址。如果你确认设备挂在 I2C2但这里扫不到那基本可以断定是硬件层面的问题排查方向是设备供电是否正确、SDA/SCL 有没有接反、上拉电阻有没有贴、设备地址是不是真如手册所写。之前遇到过一块板子原理图上地址是0x50实际芯片的地址引脚被拉高变成0x51i2cdetect 显示0x51就是对不上的典型。如果 i2cdetect 能扫到地址但驱动读写异常重点检查这两点字节序问题。很多传感器寄存器是16位高字节在前还是低字节在前要严格按 datasheet 来。我见过有工程师在小端处理器上把高低字节拼反了温度读出直接变成一个不合理的负数。ACK 异常和时钟延展。用示波器一边抓 SCL、SDA一边跑驱动。起始条件、地址字节、ACK 位每个都要核对。如果发现从设备一直 NACK优先怀疑设备地址错误或设备处于复位状态。还有一类问题特别隐蔽I2C 总线被某个设备锁住SDA 一直为低。原因可能是某次通信在停止条件前被打断从设备状态机卡死。解决办法是给从设备断电复位或者用 GPIO 模拟多个时钟周期让它恢复。做产品时我习惯在硬件上给 I2C 设备的电源单独加软开关遇到总线锁死可以通过 GPIO 复位设备而不是只能整板断电。4. CAN总线面向工业现场的驱动开发4.1 CAN协议与Linux SocketCANCAN 总线是工业控制和汽车领域最常见的现场总线。它用两根差分信号线CANH、CANL传输抗干扰能力比普通UART强得多。通信时任何节点都可以主动发送多个节点同时发送时通过 ID 仲裁机制决定谁优先ID 数值越小优先级越高发送时显性电平可以覆盖隐性电平实现非破坏性仲裁。协议层面标准帧的 ID 是 11 位扩展帧是 29 位。数据段最多8字节CAN FD 可以到64字节但这里先按经典CAN讲。每帧还有 CRC、ACK 等字段出错了节点会发出错误帧网络里的节点可以根据错误计数TEC/REC判断自己是 error-active 还是 bus-off 状态。Linux 内核把 CAN 设备抽象成了网络接口也就是 SocketCAN接口名是 can0、can1 这种。操作起来和网卡非常像配置波特率、启停、收发数据都通过 socket 或 ip 命令完成。启动一个 CAN 接口的完整流程sudo ip link set can0 down sudo ip link set can0 up type can bitrate 500000查看状态ip -details link show can0发送和接收数据用 can-utils 里的命令cansend can0 123#DEADBEEF candump can0其中 123 是 11 位 IDDEADBEEF 是 4 字节数据。用 cangen 可以随机发数据测试总线压力用 candump 加上 -e 参数可以同时显示错误帧。4.2 CAN设备驱动框架与中断处理CAN 控制器在 Linux 里的驱动框架和普通网卡驱动有几分相似。核心数据结构有两个net_device 描述网络接口can_priv 描述CAN控制器私有属性里面保存波特率、时钟、错误状态等。驱动注册流程大致是probe 函数里分配 net_device初始化 can_priv设置 netdev_ops申请中断最后 register_candev。中断处理函数里做接收和错误统计。接收时可以用 NAPI 或者 can_rx_offload 来减轻高负载下的 CPU 压力。发送路径一般是应用层调用 socket 发送 CAN 帧内核协议栈最终调用驱动实现的 .ndo_start_xmit。驱动把帧写入控制器发送缓冲区然后等待发送完成中断。这个过程需要处理好临界区防止在发送中又来了新数据导致覆盖。错误处理是 CAN 驱动里很容易被忽视的部分。总线短路、节点掉线、波特率不匹配都会导致错误帧。驱动里要监听控制器状态寄存器如果是 bus-off 状态通常需要按协议要求进行 11 个连续隐性位的总线恢复bus-off recovery这时也可以选择直接把接口 down/up 来手动复位。我实际调试中常用的一个窍门是丢掉逻辑分析仪之前先看 can0 设备状态的 error 计数。如果一直在涨说明物理层有问题如果计数稳定但是收发不到数据再去查帧ID和滤波配置。4.3 基于设备树的CAN节点配置实例CAN 控制器节点在设备树里的配置和I2C节点风格一致重点也是 pinctrl、中断、时钟。以 RK3568 平台为例设备树中 CAN0 和 CAN1 节点通常这样使能can0 { pinctrl-names default; pinctrl-0 can0m0_pins; status okay; }; can1 { pinctrl-names default; pinctrl-0 can1m0_pins; status okay; };pinctrl 的作用前面说过是把芯片引脚从 GPIO 模式切换成 CAN 功能。RK3568 的 CAN0 可能有 m0/m1 等多组引脚可选设备树里选哪组必须和实际 PCB 原理图对应。如果 pinctrl 配置错误CAN 控制器虽然注册成功了但引脚完全没有信号示波器量 CANH/CANL 都是平的新手在这上面容易卡好久。如果使用的是 SPI 转 CAN 的控制器比如 MCP2515设备树节点就会放在 SPI 总线下写法类似spi0 { mcp2515: can0 { compatible microchip,mcp2515; reg 0; spi-max-frequency 10000000; interrupt-parent gpio3; interrupts 10 IRQ_TYPE_LEVEL_LOW; clocks cru CLK_MCP2515; vdd-supply vcc3v3; xceiver-supply vcc3v3; }; };需要注意的是MCP2515 这种控制器需要额外的晶振时钟而且通常有一颗 CAN 收发器芯片Transceiver把控制器输出的逻辑信号变换成 CANH/CANL 差分信号。xceiver-supply 属性就是给收发器供电用的设备树里不配probe 阶段可能直接失败。4.4 CAN调试工具与问题排查CAN 调试中我最常用的三个工具是 cansend、candump 和 canfdtest前两个发数据和监听后一个做 CAN FD 或环回压力测试。排查双节点通信失败的顺序我一般固定这样走第一步查物理层。用万用表量 CANH 与 CANL 之间的静态电压正常情况下隐性状态二者电压都接近 2.5V差值接近 0发送显性帧时差值会达到 2V 左右。然后再确认总线两端是否都有 120Ω 终端电阻用万用表量总线两端之间的电阻应该是 60Ω 左右两个120Ω并联如果量到120Ω说明有一端终端电阻没焊或者断了。第二步查波特率。两个节点必须用完全一样的波特率。ip link set can0 up type can bitrate 500000 这条命令里的 bitrate 必须一致。很多隐性故障都是这边 500K、那边 250K双方都能起来但一收发就全是错误帧。第三步抓错误帧。candump 加 -e 参数可以显示所有错误帧看到 error passive 或 bus off 时回头查物理层和波特率。candump can0 -e如果手头有另一块正常工作的设备可以用它做对比测试。我曾经遇到过一块板子 CAN 通信时好时坏最后量出来是 CANL 线到连接器之间虚焊仅靠排针的机械接触维持摇晃一下线束就开始报错。这类问题光看协议栈日志是定位不了的必须回到硬件排查。位定时参数BS1、BS2、SJW也是 CAN 驱动的重点之一。IP 命令里可以用 tq、prop-seg、phase-seg1、phase-seg2、sjw 这些参数微调位定时。BS1 和 BS2 的长短直接决定采样点位置工业现场一般推荐采样点放在 75% 到 87.5% 左右具体数值要看总线长度和节点数。如果链路长、节点多可以适当增大 SJW 来增强对时钟偏差的容忍度。5. 写在最后的一些实际经验把这套路径走完你会发现在 Linux 下做驱动开发本质就是按子系统规则办事情。内核模块教会你资源是怎么申请和释放的设备树教会你硬件信息是怎么传递到驱动里的I2C 和 CAN 子系统则教你在具体总线上怎么收发数据、怎么处理中断和错误。掌握了这三层再去碰 SPI、UART、USB、PCIe 驱动大框架都是通的。我自己带人的时候有个习惯新同学先写内核模块然后给他一块传感器小板子做 I2C 驱动再给他装一套双节点 CAN 收发环境。这三步走完他对驱动到底在干什么会有一个非常直观的认知。代码量并不大过程却很锻炼人尤其是出问题的时候逼着他去读芯片手册、看时序图、量波形这些能力后面用在哪都值钱。调 I2C 和 CAN 的时候有几个小习惯建议你从第一天就养成。第一设备树改动一定用 git 管理每个节点被谁改过、为什么改全留记录第二调试信息先全部打开内核的 dynamic debug 和网络的 dev_err、dev_info 都写上别偷懒第三每次做硬件改动以后先跑一遍环回测试再上网络免得把总线上其他节点带崩。踩过几次坑之后你会发现这些不起眼的习惯才是项目不烂尾的真正保障。