上个月帮一位从单片机转过来的同事看RK3568工控板的问题,I2C接了温湿度传感器读回来全是0xff,CAN这边两板对测根本没反应,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_driver,SPI有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: ssd1306@3c { 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。子节点ssd1306@3c表示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>; };如果你是外挂MCP2515(SPI接口的CAN控制器),那么要在SPI总线下挂子节点:
&spi2 { status = "okay"; mcp2515: can@0 { 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
当我们写了上面的设备树节点ssd1306@3c后,内核的I2C core在扫描I2C总线时,会为这个节点创建一个struct i2c_client。这个client包含地址(0x3c)、name(ssd1306)、适配器(即I2C控制器)等关键信息。这个结构体是I2C设备的"身份证",后面所有操作都通过它进行。
4.2 i2c_driver注册、probe匹配和资源获取
I2C驱动的核心结构是i2c_driver:
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_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位两种说法。设备树里写0x3c,i2cdetect显示0x3c,但很多芯片手册写的是0x78——因为手册把读写位加进去了。驱动里你看到的是7位地址0x3c,没问题,别被手册绕晕。
- 无应答:现象是i2c_transfer返回负值,dmesg里报NACK。先检查上电、上拉电阻、地址、引脚mux,用示波器抓SDA/SCL波形立判生死。
- 时钟拉伸:某些传感器会拉低SCL做时钟拉伸,如果控制器不支持,会一直超时。翻数据手册确认,必要时调低I2C速率。
- i2cdetect使用技巧:i2cdetect -l列出所有总线,i2cdetect -y 3扫描总线3,i2cdetect -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和candump
can-utils是CAN调试的标准工具,测试时先开一个终端收,再开另一个终端发:
# 终端A candump can0 -n 10 # 终端B cansend can0 123#DEADBEEF如果你有两块板子,一边candump一边cansend,能收到就是基本通了。测试脚本里还可以用循环发送:
#!/bin/bash count=0 while [ $count -lt 100 ]; do cansend can0 123#$(printf "%08x" $count) count=$((count+1)) 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、MDIO
I2C和CAN画出的这条路径,在SPI、PCIe、USB、MDIO等子系统里完全复用。SPI有spi_driver和spi_transfer机制,PCIe有pci_driver,USB有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,都是顺水推舟的事。