ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从内核模块到设备树、I2C/CAN:嵌入式Linux驱动开发核心路径解析

从内核模块到设备树、I2C/CAN:嵌入式Linux驱动开发核心路径解析 1. 从一次“驱动水土不服”说起为什么要理清整条系统路径我最早接触嵌入式Linux驱动时最大的困惑不是“怎么写代码”而是“代码写好了硬件为什么不动”。辛辛苦苦写完一个字符设备驱动insmod之后/dev下什么也没有费尽心思把设备树节点补上probe函数却压根不被调用改了几个寄存器地址系统启动直接panic。当时花了一整周排查最后才发现问题是设备树里的compatible字段和驱动里的of_match_table写岔了一个字母。后来回头看这类问题几乎每个做嵌入式Linux开发的人都会遇到。原因很简单在Linux里写设备驱动不是在“写一个控制硬件的程序”而是在“把一块硬件正确地挂接进一套早已设计好的抽象体系里”。这套体系的路径就是从内核模块开始经过设备模型、设备树描述最终落到I2C、CAN这类具体总线子系统的通信上。这个路径不梳理清楚驱动开发永远是“对着register瞎填跑起来靠运气”。标题里这条“从内核模块到设备树、I2C/CAN的系统路径”恰恰点出了Linux驱动开发最核心的主线。这篇文章我想把这条路从头到尾走一遍先讲清楚每个环节管什么、为什么要这么设计再用实际案例把从模块加载到总线通信的完整链路串起来。适合刚入门的嵌入式Linux工程师、从单片机转Linux开发的同行以及那种“驱动能跑但不知道为啥能跑”的朋友。2. 驱动开发的地基内核模块与设备驱动模型2.1 模块加载的完整生命周期内核模块是Linux驱动最常见的载体原因很朴素Linux内核是宏内核如果每加一个驱动都要重新编译整个内核开发效率会低到无法接受。模块机制允许把驱动编译成独立的.ko文件运行时用insmod装进去rmmod卸掉调试和迭代都快得多。一个最简模块的骨架是这样的#include linux/init.h #include linux/module.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 simple demo module);代码本身不复杂但我建议每个初学者都去搞清楚模块加载时的完整生命周期因为很多诡异问题就出在这些看似“约定俗成”的细节里。加载一个模块时内核实际做的事情远不止调用module_init这个函数。首先是文件系统层的处理insmod把.ko文件读入内存调用init_module系统调用内核会先做模块依赖解析如果有依赖的符号需要从别的模块导入、版本校验modversions机制、重定位类似用户态动态链接库的符号解析然后才执行模块的初始化函数。如果模块里用了export_symbol导出的符号而提供这个符号的模块还没加载insmod会直接报unknown symbol错误。卸载流程也很考验功夫rmmod会先调用module_exit然后做资源清理和符号回收。如果模块在exit函数里忘了释放某个资源或者有进程还在使用这个模块创建的设备节点卸载就会失败或者留下悬空指针。这里有两个很实用的排查经验内核日志里出现rmmod: module is in use先检查是不是有进程占用了设备文件出现slab error或use-after-free基本可以断定是exit路径上资源释放顺序写错了。2.2 设备驱动模型kobject、设备与驱动是怎么对上的很多初学者写驱动时心里装的全是register_chrdev、ioremap、copy_to_user这些API但对设备驱动模型本身没有概念导致遇到“probe为什么不执行”这类问题时完全无从下手。实际上理解设备驱动模型才是理解整个Linux驱动体系的钥匙。Linux的设备驱动模型以kobject为地基向上构建了三层核心对象设备device、驱动driver、总线bus。这三者的关系可以类比成生活中的插座体系设备是电器驱动是适配电器规格的插座设计图纸总线是墙里的电线。电器要工作必须插到符合自己规格的插座上也就是设备必须挂在匹配的总线上。内核里的device和driver通过总线匹配起来匹配成功后触发driver的probe回调。平台设备platform device挂在platform总线上I2C设备挂在I2C总线上SPI设备挂在SPI总线上。每种总线都有一套自己的匹配规则但大原则是一样的设备端声明“我是什么”驱动端声明“我支持什么”两边对上了就握手成功。理解这个模型最大的价值在于遇到probe不执行至少知道沿着“设备树节点有没有正确创建device→device有没有注册到对应总线→driver有没有注册到同一条总线→compatible/fdt匹配规则有没有命中”这条链路去排查而不是在代码里瞎打printk。3. 设备树硬件拓扑的“说明书”与驱动解耦的关键3.1 设备树到底在解决什么问题在没有设备树的时代板级文件board file里每加一个新硬件都要改arch/arm/mach-xxx/目录下的C代码把设备信息硬编码进去。换一块板子硬件资源不同就要重新编译内核。设备树Device Tree简称DT本质上就是把“硬件长什么样”从内核代码里剥离出来变成一份独立的描述文件内核启动时解析这份文件动态创建设备。用大白话说设备树是一张硬件“配置单”告诉内核“我这个板子上有哪些设备、它们挂在哪个控制器上、寄存器地址在哪里、中断号是多少、复位脚是哪一个”。设备树出现后同一份内核镜像可以跑在多种板子上只需要更换对应的.dtb文件。我在实际项目中最常用到的设备树场景有两类一类是修改现有节点属性比如把某个UART的时钟频率改掉、把某个GPIO的中断触发方式从上升沿改成下降沿另一类是新增自定义设备节点比如外挂一个传感器、一个LCD屏控制器。前者考验你对现有dtsi设备树包含文件结构的熟悉程度后者考验你对ofOpen Firmware接口的理解。3.2 从dtsi到dtb的编译与追加节点技巧设备树源文件以.dts和.dtsi为扩展名dtsi是公共头文件dts是具体板级文件。编译流程是dtc编译器把dts/dtsi编译成dtb二进制内核启动时由bootloader加载dtb到内存把地址传给内核。一个最容易被忽略的点是改完dts文件后必须重新编译dtb并且确认bootloader加载的是新编译出来的dtb而不是缓存的旧文件。我在RK3568平台上踩过这个坑改了设备树想启用CAN结果系统起来后死活看不到can0节点折腾半天发现bootloader从固定分区读的是老dtb。这种问题跟驱动代码没关系纯粹是编译产物没更新到位。设备树文件有几个高频操作值得展开说说。新增一个I2C设备的节点模板如下i2c1 { status okay; clock-frequency 100000; ssd1306: ssd13063c { compatible solomon,ssd1306; reg 0x3c; reset-gpios gpio4 5 GPIO_ACTIVE_LOW; width 128; height 64; }; };这里compatible字段将来要和驱动里的of_match_table匹配reg是I2C从设备地址。reset-gpios引用了GPIO控制器下的具体引脚。width和height这种自定义属性是给驱动用的用of_property_read_u32就可以读出来。节点追加和修改的语法要熟练用label { ... };的方式可以给已存在的节点追加内容或修改属性用delete-node或delete-property可以删除。比如要禁用某块板子用不到的CAN控制器can1 { status disabled; };很多新手最懵的是status属性。设备树里设备节点默认能使能也可能默认禁用具体看SoC厂商的dtsi怎么写。改完status之后还要确认时钟、引脚复用pinctrl有没有配置好。CAN控制器在RK3568上需要把对应引脚mux成can功能这个过程是pinctrl子系统在驱动probe时自动完成的但它依赖设备树里的pinctrl-0属性can1 { status okay; pinctrl-names default; pinctrl-0 can1m0_pins; };3.3 设备树里的复位时序与GPIO操作一个真实案例热词里有人搜“linux 设备树设置复位信号时间”这个点很典型恰好可以展开说。很多外设芯片上电后需要复位脚拉低、保持一段时间、再拉高然后才能正常工作。设备树里通常只描述了“复位脚是哪个GPIO”具体时序是驱动里用gpiod_set_value配合udelay或usleep_range实现的。但有些芯片对时序要求极严或者驱动写得不严谨就会出问题。以SSD1306 OLED驱动为例正常情况下复位时序是拉低→延时10ms以上→拉高→延时100ms以上。如果延时不够屏幕可能初始化失败或者显示花屏。设备树里能做的是通过reset-gpios属性告诉驱动复位脚是哪个剩下的时序逻辑必须在驱动代码里处理。如果你遇到“复位信号时间不对导致设备不稳定”的问题排查重点不是设备树而是驱动里的延时逻辑。这里有个容易踩的坑udelay在原子上下文比如spinlock保护区域里是忙等没问题但在正常进程上下文里超过1ms的延时应该用usleep_range不然会白白占用CPU。很多花屏、通信失败的bug最后查下来就是延时函数用错了。3.4 设备树调试三板斧设备树写得好不好直接影响驱动开发的进度。我总结了一套调试三板斧可以节省大量时间。先看设备树有没有被正确解析。内核启动日志里搜索of_unittest或直接查看/proc/device-tree目录如果节点正常该目录下应该有对应的子目录和属性文件。没有的话dtb没加载对或者节点被禁用了。再看具体设备有没有被创建。挂到总线上的设备在/sys/bus/platform/devices/、/sys/bus/i2c/devices/这些目录下能看到符号链接。看不到设备文件说明设备树解析阶段就出了问题。最后看probe有没有执行。如果设备存在、驱动也加载了probe还是不执行检查compatible是否匹配。用下面的命令可以查看设备的compatible信息cat /proc/device-tree/i2c1/ssd13063c/compatible如果输出和驱动of_match_table里的字符串不一致probe永远不会执行。这个错误看起来低级但确实常见因为很多人是从别处复制设备树节点没注意型号差异就换了主控平台。4. I2C子系统实例从probe到用户态测试4.1 I2C核心、总线适配器与从设备驱动I2C总线大概是嵌入式Linux里最简单也最常用的总线了。它的架构分三层I2C核心i2c-core、I2C适配器adapter即I2C控制器驱动、I2C从设备驱动client driver。I2C核心提供注册接口、数据传输helper函数和sysfs属性管理。适配器抽象了物理I2C控制器的读写能力它向上表现为一个i2c_adapter实例注册到内核后所有挂在这条总线上的client才能通过它发起传输。从设备驱动则是我们一般写的驱动它要做的是声明自己支持的compatible和I2C设备ID在probe里初始化设备在remove里释放资源通过i2c_transfer或i2c_smbus系列函数和设备通信。很多初学者搞不清i2c_add_driver、i2c_register_driver、i2c_new_device、i2c_new_client_device这些API的差别。简单说i2c_add_driver是驱动侧注册i2c_new_device是总线侧创建设备。设备树被解析后内核会为每个带compatible的I2C子节点创建i2c_client然后在总线上做匹配。4.2 手写一个SSD1306 OLED设备驱动完整代码与关键解析SSD1306是一个很经典的I2C接口OLED控制芯片作为驱动开发练手项目非常合适因为它寄存器简单、读写逻辑直观而且能直接看到驱动效果。完整的驱动代码框架如下#include linux/module.h #include linux/kernel.h #include linux/i2c.h #include linux/delay.h #include linux/gpio/consumer.h #include linux/of_device.h #define SSD1306_I2C_ADDR 0x3C #define SSD1306_CMD_MODE 0x00 #define SSD1306_DATA_MODE 0x40 struct ssd1306_dev { struct i2c_client *client; struct gpio_desc *reset_gpio; u32 width; u32 height; }; static int ssd1306_write_cmd(struct ssd1306_dev *ssd, u8 cmd) { u8 buf[2] { SSD1306_CMD_MODE, cmd }; return i2c_master_send(ssd-client, buf, 2); } static int ssd1306_write_data(struct ssd1306_dev *ssd, u8 *data, size_t len) { u8 *buf; int ret; buf kmalloc(len 1, GFP_KERNEL); if (!buf) return -ENOMEM; buf[0] SSD1306_DATA_MODE; memcpy(buf 1, data, len); ret i2c_master_send(ssd-client, buf, len 1); kfree(buf); return ret; } static int ssd1306_init(struct ssd1306_dev *ssd) { if (ssd-reset_gpio) { gpiod_set_value_cansleep(ssd-reset_gpio, 0); usleep_range(10000, 15000); gpiod_set_value_cansleep(ssd-reset_gpio, 1); usleep_range(100000, 120000); } ssd1306_write_cmd(ssd, 0xAE); ssd1306_write_cmd(ssd, 0x20); ssd1306_write_cmd(ssd, 0x00); ssd1306_write_cmd(ssd, 0x8D); ssd1306_write_cmd(ssd, 0x14); ssd1306_write_cmd(ssd, 0xAF); return 0; } static int ssd1306_probe(struct i2c_client *client) { struct device *dev client-dev; struct ssd1306_dev *ssd; int ret; ssd devm_kzalloc(dev, sizeof(*ssd), GFP_KERNEL); if (!ssd) return -ENOMEM; ssd-client client; ssd-reset_gpio devm_gpiod_get_optional(dev, reset, GPIOD_OUT_HIGH); of_property_read_u32(dev-of_node, width, ssd-width); of_property_read_u32(dev-of_node, height, ssd-height); ret ssd1306_init(ssd); if (ret) return ret; i2c_set_clientdata(client, ssd); dev_info(dev, ssd1306 initialized, %ux%u\n, ssd-width, ssd-height); return 0; } static void ssd1306_remove(struct i2c_client *client) { struct ssd1306_dev *ssd i2c_get_clientdata(client); ssd1306_write_cmd(ssd, 0xAE); } static const struct of_device_id ssd1306_of_match[] { { .compatible solomon,ssd1306 }, { } }; MODULE_DEVICE_TABLE(of, ssd1306_of_match); static struct i2c_driver ssd1306_driver { .driver { .name ssd1306, .of_match_table ssd1306_of_match, }, .probe ssd1306_probe, .remove ssd1306_remove, }; module_i2c_driver(ssd1306_driver); MODULE_LICENSE(GPL);代码核心要点如下probe里的devm_系列函数是managed device resource机制好处是资源不手动释放也不会泄漏remove阶段内核会自动回收省掉了很多麻烦。devm_gpiod_get_optional中的optional意味着设备树里没有reset-gpios节点时驱动也能正常跑不会因为缺少可选资源而报错。i2c_master_send会自动处理I2C起始条件、地址、ACK等协议细节驱动只需要关注数据本身。这里地址没有额外左移一位是因为i2c-master框架会自动做地址转换你传0x3C就是7位地址0x3C。module_i2c_driver宏把i2c_add_driver和i2c_del_driver都封装好了比手动调用省事且不容易漏。4.3 I2C开发中常见的5个坑接触I2C驱动这段时间踩过的坑和看别人踩的坑加起来最典型的是下面这些。第一个坑是7位地址和8位地址混淆。SSD1306的7位地址是0x3C左移一位后的写地址是0x78。在设备树里写reg0x3C在裸机代码里直接用0x78两边对不上就会一直通信失败。注意检查你手上的参考代码是Linux驱动还是裸机程序。第二个坑是I2C总线时钟频率不匹配。设备树clock-frequency属性控制总线速率标准模式100kHz快速模式400kHz。如果外设芯片最高只支持100kHz你把总线配成400kHz偶发通信错误会非常难查因为不是必现往往和信号质量、线路长度都有关系。第三个坑是ACK错误和NACK错误分不清。i2c_transfer返回负的错误码-EIO通常是总线问题-ENXIO通常是从设备无应答。检查思路完全不一样前者查线路、电平、上拉电阻后者查地址、供电、芯片是否处于异常状态。第四个坑是probe函数里做太多阻塞操作。SSD1306这种简单设备还好如果probe里有几十毫秒甚至上百毫秒的延时会影响系统启动速度。更危险的是如果probe里调用了会睡眠的函数比如i2c_transfer本身会睡眠但在原子上下文比如某些回调里调用就会触发内核警告甚至死锁。第五个坑是调试时不知道该看哪个日志。I2C子系统在内核配置里打开CONFIG_I2C_CHARDEV和CONFIG_I2C_DEBUG_BUS后能看到很多控制器层面的传输日志。配合i2cdetect、i2cget、i2cset这些用户态工具调试效率会高很多。5. CAN子系统从设备树配置到收发链路5.1 CAN和I2C的本质差异CANController Area Network总线和I2C虽然都叫总线但设计哲学完全不同。I2C是同步串行总线主机主动发起通信从机被动响应典型的“中心化”模型CAN是异步半双工总线任何节点都能主动发消息靠报文ID的优先级做仲裁典型的“去中心化”模型。这个差异决定了驱动开发的关注点完全不同I2C驱动关心怎么发起一次传输、地址对不对、时序是否满足CAN驱动关心怎么配置波特率、怎么处理错误帧、怎么实现过滤、怎样保证实时性。CAN还天生带错误检测机制有错误主动、错误被动、总线关闭几种状态驱动里可以选择注册错误中断来感知总线健康状况。在Linux里CAN设备被抽象成网络设备netdevice用SocketCAN接口操作。用户态通过socket(AF_CAN, SOCK_RAW, CAN_RAW)就能收发报文驱动层核心工作是管理CAN控制器硬件实现网络设备操作集ndo_open、ndo_stop、ndo_start_xmit。5.2 MCP2515在SPI下的设备树配置很多主控没有内置CAN控制器或者CAN控制器数量不够用这时常见做法是外挂MCP2515这种SPI转CAN芯片。这类芯片的设备树配置需要同时处理SPI子系统和CAN子系统两个层面。在Linux内核里MCP2515驱动已经在内核源码里了drivers/net/can/spi/mcp251x.c我们主要工作是写设备树节点。以RK3568平台为例spi1 { status okay; mcp2515: mcp25150 { compatible microchip,mcp2515; reg 0; spi-max-frequency 10000000; clocks cru CLK_MCP2515; interrupt-parent gpio3; interrupts 6 IRQ_TYPE_EDGE_FALLING; pinctrl-names default; pinctrl-0 mcp2515_irq_pin; }; };几个关键属性要仔细解释reg0表示这是SPI控制器下的第几个片选chip select如果硬件上接了CS0就填0。spi-max-frequency取决于MCP2515的晶振和SPI控制器的能力一般10MHz比较安全太高可能出现SPI传输错误。interrupt-parent和interrupts描述芯片的INT引脚接到主控的哪个GPIO、什么触发方式。clocks属性用来提供外部晶振频率某些平台还要单独在驱动里配置晶振频率参数。MCP2515驱动里还有一个经常需要调的地方晶振晶振频率和波特率。比如8MHz晶振下要得到500kbps的CAN波特率驱动会根据控制器寄存器取值自动计算分频和位时序。5.3 SocketCAN用户态操作与实测设备树配置好、驱动加载成功后在用户态操作CAN比很多人想象中简单# 启用CAN设备 sudo ip link set can0 up type can bitrate 500000 # 查看CAN设备状态 ip -details link show can0 # 接收CAN报文candump candump can0 # 发送CAN报文cansend cansend can0 123#DEADBEEF用ip命令初始化时要注意一点bitrate必须和设备树里MCP2515外接晶振的搭配能整除出整数分频系数否则ip命令会报错或者通信不稳定。如果板子上用的是16MHz晶振配置bitrate 500000通常没问题如果晶振频率非常规比如12MHz你可能需要选用特定的采样点参数。判断CAN链路是否正常有个非常实用的命令组合ip -details link show can0看输出里的state如果显示ERROR-ACTIVE说明硬件链路正常如果显示BUS-OFF说明总线错误太多控制器已经退出总线了。配合candump -e can0能看到错误帧信息错误帧数量突增往往意味着总线两端波特率不一致或者物理层存在问题。5.4 CAN驱动的调试心得CAN驱动的坑主要集中在三个方面。一是波特率配置。同一总线上的所有节点波特率必须完全一致包括采样点位置sample point也有讲究。采样点决定了一个位时间里在哪个位置采样电平布线复杂或节点多的总线采样点设在75%-87.5%比较稳。SocketCAN在ip命令里支持通过-S选项指定采样点但前提是驱动实现了相关接口。二是中断处理。MCP2515的中断引脚是边沿触发的如果驱动里中断处理函数做了过多阻塞操作比如通过SPI同步读取多个寄存器在高负载下会丢中断。调试时如果发现偶发收不到报文可以先用candump -t a看时间戳判断是接收链路问题还是应用层问题。三是错误注入与恢复。CAN驱动开发中最难受的场景是总线关闭后无法自动恢复。驱动层面要确保can_restart机制开启can_restart相关配置由netlink接口设置同时建议外设芯片的中断引脚必须配置正确因为bus-off恢复通常依赖中断唤醒。6. 常见问题与排查技巧实录写驱动这几年复盘下来真正见效的排查套路其实很固定。整理成表格方便查阅问题现象可能原因排查手段insmod报unknown symbol依赖模块未加载或符号未导出检查模块依赖用modinfo查看依赖/dev下没有设备节点设备号注册失败或udev规则缺失dmesg看注册日志检查主设备号probe函数不执行compatible不匹配或设备未创建查/proc/device-tree下的compatibleI2C传输返回-ENXIO从设备无应答地址错误或芯片异常i2cdetect扫描总线地址I2C传输返回-EIO总线仲裁失败或线路问题查上拉电阻、总线电容CAN链路BUS-OFF波特率不一致或物理层短路/断路对比各节点波特率查终端电阻模块卸载崩溃资源释放顺序错误或并发访问未处理code review配KASAN/KCSAN检测除了表格里的场景还有几个独家心得值得分享。第一个心得是“让设备树背锅之前先确认它真的被加载了”。很多设备树问题其实是bootloader没把dtb传给内核或者传给的是旧版本。我的排查顺序永远是启动日志看DTS版本号 → /proc/device-tree确认节点 → /sys/bus/xxx/devices确认设备 → 最后才怀疑驱动代码。第二个心得是“善用devicetree的overlay机制做快速验证”。如果不想每次改dts都重新编译dtb、烧写分区可以尝试用设备树overlay叠加层在运行时动态加载设备配置。这在内核配置里打开CONFIG_OF_OVERLAY之后配合configfs就能用对原型验证和调试非常有用。第三个心得是“驱动出问题先查内核配置”。设备树节点写得再好驱动代码写得再对如果内核没编译对应的驱动模块或没打开对应的总线子系统一切都是白搭。RK3568上调试CAN忘了在menuconfig里选中MCP2515驱动设备树配置再正确CAN设备也起不来。每次开始调试前先花五分钟确认相关配置zcat /proc/config.gz | grep MCP2515 zcat /proc/config.gz | grep I2C第四个心得是“重视GPIO复用和pinctrl”。嵌入式平台上最常见的一类“驱动没反应”问题其实是引脚mux错了外设确实注册成功了probe也执行了但引脚被复用成了别的功能信号根本出不去。排查这类问题时用下面的命令查看引脚当前状态cat /sys/kernel/debug/pinctrl/pinctrl-handles cat /sys/kernel/debug/gpio如果发现引脚状态和预期不符检查设备树里的pinctrl-0配置是否覆盖到了对应引脚。7. 从这条路径中得到的核心经验回头再看这篇文章的标题“从内核模块到设备树、I2C/CAN的系统路径”其实不只是技术路线更是一种解决问题的思维方式。我自己的经验是写Linux驱动不能只盯着内核API和寄存器必须先建立整机视角。模块机制管的是“驱动代码怎么被内核接纳”设备模型管的是“设备和驱动怎么握手”设备树管的是“硬件资源怎么被描述”I2C/CAN子系统管的是“具体业务数据怎么和设备交换”。每一层解决一个问题每一层也都有自己的一套调试工具和排查方法论。出问题时顺着这条路径逐层排错远比在单点里死磕高效。还有一个很深的感受设备树和驱动代码的边界往往就是业务稳定性的边界。设备树描述改动频繁一定要做好版本管理驱动代码影响范围大合入前要谨慎测试。在嵌入式Linux团队里设备树review不严谨导致的硬件配置事故比驱动代码bug导致的问题多得多。这篇文章基于我在RK3568等主流平台上的实际开发经验写成部分内容和代码示例是常见实践的组合与总结。Linux驱动开发的路还很长设备树、I2C、CAN只是其中最基础的一段后面还有PCIe、USB、网络驱动、中断子系统、DMA框架等着去啃。希望这篇文章能帮你把这条系统路径上的每一个节点都走通少踩几个我踩过的坑。
返回列表