ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RK3568 Linux驱动开发:从内核模块、设备树到I2C/CAN子系统全链路解析

RK3568 Linux驱动开发:从内核模块、设备树到I2C/CAN子系统全链路解析 上个月帮一位从单片机转过来的同事看RK3568工控板的问题I2C接了温湿度传感器读回来全是0xffCAN这边两板对测根本没反应can0起来之后dmesg里全是bus-off。我第一句话就问他设备树里把节点加了吗他愣了半天反问我设备树不是编译内核的时候自动生成的吗这个反应其实很典型。很多从裸机、MCU转Linux驱动开发的人刚接触这套东西的时候最痛苦的不是C语言也不是内核API而是搞不清内核模块、设备树、I2C/CAN子系统这三层东西是怎么串成一条链路的。市面上的教程要么只讲模块编程要么只讲设备树语法要么上来就丢一个i2c_driver结构体让你抄很少有人把模块怎么变成驱动、设备树怎么变成硬件描述、子系统怎么把两者捏在一起这件事从头到尾讲透。这篇文章我就以RK3568平台为主线完整走一遍从内核模块到设备树再到I2C/CAN子系统的路径。适合刚入门Linux驱动、或者做了一段时间但只知道抄代码不太清楚底层逻辑的开发者。中间会穿插我实际踩过的坑尤其是设备树复位时序、I2C地址移位、CAN总线仲裁这些容易出问题的地方。1. 为什么我建议从整条链路入手而不是先去啃函数API很多初学者拿到《Linux设备驱动开发详解》这种书第一反应是从头翻到字符设备那一章把register_chrdev、file_operations这些API背下来。然后发现真正到了板子上还是不知道怎么让一个I2C设备工作起来。因为现代Linux内核的设备模型早就不是写个驱动直接操作寄存器那个玩法了。1.1 设备模型的三层分工现代Linux驱动开发实际上是一个三层协作的框架可以用一个不算太严谨但很好理解的类比来记忆设备树是硬件说明书描述板子上有什么设备、挂在哪条总线上、用什么地址、需要什么时序。内核模块是员工包含驱动逻辑但员工得知道自己要去服务哪台设备。内核设备模型总线、驱动、设备是人事系统负责把员工和说明书里的设备做配对配上了就调用probe。这个类比不是随便打的。实际开发中你写一个i2c_driver本质上就是在人事系统里登记一个会修I2C设备的技术员并且在of_match_table里声明我能修solomon,ssd1306这种设备。而设备树里的那个节点就是一台贴了标签solomon,ssd1306地址0x3C的设备。内核启动时扫到这台设备再看到你登记的技术员两者匹配成功就执行你的probe函数。1.2 链路中的关键流转节点从模块加载到应用层真正能read/open中间经过的关键节点包括内核启动时解析DTB设备树二进制把dts描述的节点注册成platform_device或i2c_client等具体设备实例。驱动模块通过module_init加载注册struct xxx_driver到对应总线。总线驱动I2C core、SPI core、platform bus执行match比较compatible或者name。匹配成功内核调用driver的probe函数。probe里初始化硬件、申请资源、注册中断、创建字符设备或注册网络设备等。应用层通过设备节点/dev/xxx或socket接口访问。任何一个环节断了表现出来就是板子没反应。而排查问题的高手脑子里都有这张链路图能快速定位断点在设备树、在总线match、在probe初始化还是在上层应用。1.3 环境准备这次用的是RK3568平台内核版本5.10交叉编译工具链是aarch64-linux-gnu-。实际开发建议提前把内核源码单独编一遍生成内核镜像和dtb方便改设备树后单独编译烧录。我习惯在板端Linux里用modprobe/insmod直接加载模块调试能省掉反复烧录的时间。具体环境因板子而异不展开但记住两个原则一是内核源码版本必须和板子运行的镜像一致否则模块加载会报version magic错误二是设备树编译用dtc工具改动后要生成新的dtb并确认它真的被bootloader加载了这点后面会专门讲。2. 内核模块每次加载和卸载背后到底发生了什么如果你是从裸机转过来的模块机制是你遇到的第一个反直觉的东西。裸机里所有代码都编译进固件上电就跑内核模块则是一段可以在系统运行中动态插入内核地址空间的代码。这带来一个很大的优势驱动可以先编成.ko文件在板子上调好了再决定编进内核开发效率高很多。2.1 从hello world看模块生命周期最基础的内核模块代码长这样#include linux/module.h #include linux/kernel.h #include linux/init.h static int __init hello_init(void) { printk(KERN_INFO hello driver loaded\n); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO hello driver unloaded\n); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE(GPL);编译出hello.ko后insmod hello.ko时内核做三件事把代码段和数据段拷贝到内核空间解析符号引用然后调用module_init指定的hello_init。rmmod时调用hello_exit。注意这里有个关键点printk打印的不是终端而是内核日志缓冲区要用dmesg查看。实际项目中模块不一定非要用insmod/rmmod手动加载也可以配置成开机自动加载或者在设备树里通过 compatible 匹配后由内核自动调用。但理解手动加载的机制对你理解驱动生命周期非常有帮助特别是调试阶段手动加载能快速验证你的probe有没有被调用。2.2 字符设备让应用层能open、read、write的关键如果模块只是printk那和跑马灯没区别。驱动最终要服务应用层字符设备就是最常见的一种接口。以miscdevice为例这是Linux提供的一个简化版字符设备注册方式适合主设备号动态分配的小型设备#include linux/miscdevice.h #include linux/fs.h static long demo_ioctl(struct file *file, unsigned int cmd, unsigned long arg) { return 0; } static const struct file_operations demo_fops { .owner THIS_MODULE, .unlocked_ioctl demo_ioctl, }; static struct miscdevice demo_dev { .minor MISC_DYNAMIC_MINOR, .name demo, .fops demo_fops, }; static int __init demo_init(void) { return misc_register(demo_dev); } module_init(demo_init);注册成功后系统会自动在/dev下创建demo节点。应用层open(/dev/demo)会触发内核根据设备号找到这个miscdevice然后回调demo_fops里对应的函数。我自己带新人时经常强调file_operations不是你实现几个函数交差而是内核暴露给你的一套钩子。你希望应用层打开设备时做什么、读数据时做什么、写命令时做什么全在这张表里定义。比如很多商业软件里的读写审计、数据过滤本质就是在file_operations里对read/write做一层封装拦截再转发给底层实际设备。这类机制在很多场景都会用到属于非常基础也很有用的设计思路。2.3 从手动注册到自动匹配platform_driver的由来老式驱动会在init里直接ioremap寄存器地址、注册字符设备硬件地址写死在代码里。这在设备单一的结构下没问题但ARM SoC外设繁杂换个板子地址变了就要改代码重新编译维护成本很高。platform_driver机制就是为了解决这个问题。它把驱动逻辑和硬件资源拆开。硬件资源描述放在设备树里驱动只负责声明自己支持什么设备然后等内核把资源传递进来。一个最简单的platform_driver长这样static const struct of_device_id demo_of_match[] { { .compatible vendor,demo-device }, { } }; MODULE_DEVICE_TABLE(of, demo_of_match); static struct platform_driver demo_platform_driver { .probe demo_probe, .remove demo_remove, .driver { .name demo, .of_match_table demo_of_match, }, }; module_platform_driver(demo_platform_driver);这个模式是理解I2C/CAN驱动的基础。I2C有i2c_driverSPI有spi_driver本质上都是同一套路数声明设备匹配表注册驱动probe里干活。后面讲I2C/CAN你会发现结构非常相似。3. 设备树硬件说明书没写清楚的细节都会在这里变成bug设备树Device Tree是一种描述硬件信息的数据结构以树形节点组织用dts源文件编写编译成dtb后由bootloader传给内核。内核启动时解析DTB动态生成设备列表再和驱动程序做匹配。3.1 节点、属性、compatible设备树的三板斧设备树的核心语法不复杂一个节点就是一台设备节点名通常写成名称地址的形式属性用键值对表示。最重要的属性有三个compatible设备类型标识格式一般是厂商,型号。驱动靠它来寻找自己能服务的设备也是match的核心。reg设备在总线上的地址I2C设备就是I2C地址SPI设备就是片选号。interrupts设备使用的中断线。以RK3568为例I2C3上挂一个SSD1306 OLED屏设备树片段如下i2c3 { status okay; pinctrl-names default; pinctrl-0 i2c3_xfer; ssd1306: ssd13063c { compatible solomon,ssd1306; reg 0x3c; reset-gpios gpio4 RK_PA2 GPIO_ACTIVE_HIGH; reset-delay-ms 10; }; };这里i2c3表示在i2c3这个父节点下追加子节点。pinctrl是引脚复用配置pinctrl-0指向I2C3的scl/sda引脚mux。子节点ssd13063c表示I2C地址是0x3c。3.2 CAN控制器在设备树中的两种典型形态CAN控制器的设备树配置取决于它是SoC内部集成还是外挂芯片。RK3568内部集成了CAN控制器MCAN设备树里只需要在can节点下使能并配置时钟can0 { status okay; assigned-clocks cru CLK_CAN0; assigned-clock-rates 200000000; pinctrl-names default; pinctrl-0 can0m0_pins; };如果你是外挂MCP2515SPI接口的CAN控制器那么要在SPI总线下挂子节点spi2 { status okay; mcp2515: can0 { compatible microchip,mcp2515; reg 0; clocks cru CLK_MCP2515; interrupt-parent gpio1; interrupts RK_PB2 IRQ_TYPE_LEVEL_LOW; spi-max-frequency 10000000; }; };注意外挂芯片的interrupt-parent和interrupts要仔细对照原理图这个中断脚要是错了CAN驱动会在probe阶段卡死或者收发完全没反应。3.3 很多人忽略的复位时序配置回到开头热搜词里的linux 设备树设置复位信号时间这个是设备树开发里非常典型的一个隐性坑。很多I2C/SPI外设比如OLED屏、触摸屏、传感器都有一个复位脚。硬件上电后驱动必须在特定的时间内给复位脚一个符合规格的脉冲设备才能正常初始化。以SSD1306为例数据手册要求复位引脚保持低电平至少10us然后拉高之后等待至少100ms才能发初始化命令。如果设备树里没配置reset-gpios驱动probe时跳过了复位步骤常常表现为设备无应答、屏幕黑屏或者读出数据全错。在驱动里可以通过devm_gpiod_get_optional获取复位GPIO然后利用gpiod_set_value和udelay/msleep组合控制时序。有的驱动还会读取reset-delay-ms之类的自定义属性来决定延时。实际项目中这类属性没配全导致设备不工作的案例特别多排查的时候第一反应应该是拿设备树和原理图逐项核对而不是去改驱动的逻辑。3.4 设备树调试三板斧设备树调试的资料不多很多人改了dts后不知道到底生效没有。这里分享三个我常用的手段/proc/device-tree内核解析DTB后导出的设备树视图直接cat或ls就可以看到实际生效的节点。/sys/firmware/devicetree/base另一个设备树导出路径和/proc/device-tree基本等价。内核日志dmesg里会有OF: fdt: xxx的打印可以看到设备树解析过程。改设备树最坑的情况是你改了dts编译了dtb烧录了但内核跑的还是旧的。因为有些bootloader会优先加载它自己分区里的dtb而不是你烧录的位置。遇到这种情况先ls /proc/device-tree/看看节点有没有变化没有就检查烧录和启动流程别一上来就怀疑驱动。4. I2C子系统从设备树节点到一个能用的OLED屏I2C是最常见的低速板级总线一条SDA一条SCL挂一堆传感器/屏幕/触摸芯片。Linux的I2C子系统已经非常成熟你真正需要做的事情其实比想象中少写一个i2c_driver声明匹配规则在probe里读设备树拿资源然后用i2c_transfer收发数据。4.1 设备树节点如何变成i2c_client当我们写了上面的设备树节点ssd13063c后内核的I2C core在扫描I2C总线时会为这个节点创建一个struct i2c_client。这个client包含地址0x3c、namessd1306、适配器即I2C控制器等关键信息。这个结构体是I2C设备的身份证后面所有操作都通过它进行。4.2 i2c_driver注册、probe匹配和资源获取I2C驱动的核心结构是i2c_driverstatic 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_i2c_driver展开后就是一个module_init加module_exit自动处理了i2c_add_driver和i2c_del_driver。在probe里可以通过devm_gpiod_get_optional这些API获取设备树里定义的复位GPIO通过i2c_client结构体得到设备地址然后给硬件发初始化序列。这里有个细节i2c_driver的probe参数是struct i2c_client指针你要的I2C地址、适配器操作函数都从这里取。和platform_driver一样资源都是由内核自动传入的驱动不需要自己iormap。4.3 实际发数据i2c_transfer是怎么工作的I2C驱动最终都是通过i2c_transfer或i2c_smbus_*系列函数收发数据。i2c_transfer接收一个struct i2c_msg数组每条msg可以是一个写操作或读操作struct i2c_msg msgs[2]; u8 write_buf[2] {0x00, 0xAF}; // 命令开启显示 u8 read_buf[1] {0}; msgs[0].addr client-addr; msgs[0].flags 0; // 写 msgs[0].len sizeof(write_buf); msgs[0].buf write_buf; msgs[1].addr client-addr; msgs[1].flags I2C_M_RD; // 读 msgs[1].len sizeof(read_buf); msgs[1].buf read_buf; i2c_transfer(client-adapter, msgs, 2);I2C core会根据msg中addr和flags自动生成开始/停止信号、读写位。很多初学者会纠结那SCL高低电平翻转这些时序呢——这些都在适配器的transfer函数里已经实现了SoC的I2C控制器驱动会直接操作硬件寄存器生成时序应用层和你的外设驱动都不需要关心。4.4 I2C调试的常见坑和排查套路I2C调试是最能拉开经验差距的部分。我总结几个高频问题地址不对I2C设备地址有7位和8位两种说法。设备树里写0x3ci2cdetect显示0x3c但很多芯片手册写的是0x78——因为手册把读写位加进去了。驱动里你看到的是7位地址0x3c没问题别被手册绕晕。无应答现象是i2c_transfer返回负值dmesg里报NACK。先检查上电、上拉电阻、地址、引脚mux用示波器抓SDA/SCL波形立判生死。时钟拉伸某些传感器会拉低SCL做时钟拉伸如果控制器不支持会一直超时。翻数据手册确认必要时调低I2C速率。i2cdetect使用技巧i2cdetect -l列出所有总线i2cdetect -y 3扫描总线3i2cdetect -y -r 3用SMBus read byte扫描。扫描不到目标设备时大概率是硬件链路问题先别继续往下调驱动。我做的SSD1306项目里最大的坑就是复位时序。驱动里GPIO获取成功、I2C通信也正常但屏幕死活不亮。后来用示波器量复位脚发现probe里没有拉低复位的时序芯片停在上电随机状态。加上复位脉冲后屏幕立刻正常。5. CAN子系统设备树配置、驱动框架和报文收发实战CAN在Linux里被实现成网络设备这在架构上很妙。你别把它想成char设备它的接口是SocketCAN应用层通过socket(AF_CAN)操作这和大家熟悉的socket网络编程非常相似。驱动层面CAN驱动最终是注册一个struct net_device对象收发报文走netif_rx和ndo_start_xmit。5.1 从设备树到CAN设备驱动不管是内部MCAN还是外挂MCP2515驱动框架都类似。以MCP2515为例内核里有一个现成的mcp251x驱动只要设备树里compatible写对资源配齐驱动会自动加载。你自己写CAN驱动的时候主要的probe工作包括从设备树获取中断号、SPI设备或platform资源初始化CAN控制器参数位时序、模式分配并注册net_device注册中断处理函数中断里读取CAN控制器接收到的帧并交给网络子系统CAN驱动把报文封装成struct can_frame通过can_put_echo_skb发送、can_rx_offload_thread等机制接收这些细节在新驱动的can_dev框架下基本都有标准实现。5.2 一批起不来CAN的启动配置CAN设备启动比I2C复杂一点因为它不是简单的读写而是要设置波特率和过滤规则。启动CAN0的标准命令sudo ip link set can0 up type can bitrate 500000这条命令背后驱动会重新初始化CAN控制器并设置位时序。如果bitrate没设很多驱动会用默认波特率往往是125k或者250k和你对端设备不一致就会产生总线错误。要查看CAN设备状态ip -details link show can0输出里有state: ACTIVE表示已经正常工作。5.3 收发验证cansend和candumpcan-utils是CAN调试的标准工具测试时先开一个终端收再开另一个终端发# 终端A candump can0 -n 10 # 终端B cansend can0 123#DEADBEEF如果你有两块板子一边candump一边cansend能收到就是基本通了。测试脚本里还可以用循环发送#!/bin/bash count0 while [ $count -lt 100 ]; do cansend can0 123#$(printf %08x $count) count$((count1)) sleep 0.1 done这套脚本跑起来能快速判断链路稳定性。5.4 CAN调试里那些看着像代码问题其实是硬件问题的情况CAN调试比I2C更依赖物理层我遇到过的坑按频率排序bus-off如果dmesg刷CAN device driver did not manifest或者网络状态变成BUS-OFF通常是总线短路、对端波特率不匹配、没有终端电阻或者物理层有干扰。BUS-OFF状态下CAN控制器会自动离线需要用ip link set can0 down再up恢复。波特率不一致两边看起来都能up但一收发就是错误帧。用ip -details link show can0比较两边的bitrate和采样点最好用同一套配置。终端电阻CAN总线两端必须各接一个120欧姆终端电阻。调试时我经常只接了一端短距离看不出问题线一长就疯狂报错。引脚mux没配置内部CAN控制器最常见的问题不是驱动而是pinctrl没配对导致CAN_TX/CAN_RX根本没连通。先核对设备树pinctrl-0再查datasheet确认引脚功能。CAN协议本身的仲裁机制也会出现在调试里多节点同时发送时ID小的帧优先。如果你发现低优先级节点一直发不出去那不是bug是仲裁机制在正常工作。6. 走完这条路径之后还能往哪些方向深入当你把内核模块-设备树-I2C/CAN子系统这条路径走通一遍Linux设备驱动的大门基本就打开了。后面的路基本是沿着两个方向展开横向扩展子系统知识面纵向往性能与稳定性深挖。6.1 横向扩展SPI、PCIe、USB、MDIOI2C和CAN画出的这条路径在SPI、PCIe、USB、MDIO等子系统里完全复用。SPI有spi_driver和spi_transfer机制PCIe有pci_driverUSB有usb_driver连以太网PHY都走mii_bus/phy_driver这套模型。理解了driver和device通过match配对probe里获取资源再注册成应用层可见接口这个套路你在任何子系统里都不会迷路。比如SPI设备树里常见的spidev节点、AD9361这类射频芯片的寄存器配置本质上和你刚才配SSD1306是同一套逻辑。你会发现自己看新子系统代码的速度变快了因为到处都熟悉。6.2 纵向深入系统裁剪、性能和实时性优化做完功能就该考虑产品化的问题了。热门词里提到的系统裁剪优化就是这一步。裁剪包括内核配置裁剪掉用不到的驱动和子系统减少内核镜像大小设备树裁剪去掉用不到的节点加快启动应用层用buildroot或yocto定制文件系统去掉无用组件。性能调优方面常见手段包括实时线程优先级、中断线程化、CPU隔离和内存优化。CAN场景里如果要求高实时性可能要把CAN中断绑定到某一个核并关掉该核的调度负载。I2C高频读取则要考虑把读取频率分散避免长时间占用总线。算法部署方面NPU相关的驱动和运行时优化又是另一个深坑但这套设备模型的基础依然适用。6.3 排查问题的一套经验性套路最后分享一个我反复使用的排查流程也是带新人时必讲的确认设备树实际生效ls /proc/device-tree/看节点在不在。确认驱动有没有match上dmesg看驱动probe调用、/sys/bus/i2c/drivers下有没有绑定。确认硬件链路示波器量时序和波形确认电源、复位、中断引脚。确认应用层接口cat /dev/xxx、ip link show can0看设备节点是否创建、状态是否正确。逐层向上验证驱动-设备节点-应用哪层不通就停在哪层深挖。这个顺序特别适合嵌入式Linux因为它可以从上往下快速缩小排查范围。很多新手一上来就怀疑是自己的代码结果最后发现是bootloader加载的dtb太旧方向反了白调一晚上。我个人体会最深的还是那句话Linux驱动开发七分靠设备树透不透明三分靠会不会写C代码。把设备树和执行链路的逻辑吃透比死记硬背几百个内核API管用得多。调完这套I2C/CAN的流程后你会对整个系统有一种所有设备都在一张网上按规则匹配的通透感。这种通透了后面做SPI做USB做PCIe都是顺水推舟的事。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进