很多人拿到一块新板子,第一反应是打开芯片手册,然后一头扎进内核源码。折腾几天,helloworld模块能加载了,板子却没按预期工作——LED不闪、I2C设备读不到、CAN报文发不出去。问题往往不是代码写错,而是脑子里缺了一条完整路径:从最基础的内核模块写起,中间经过设备树把硬件描述清楚,再进入I2C/CAN这类具体总线协议,每一步都有它特有的知识点和坑。这篇文章不谈那种“照着例程改”的速成方法,而是站在驱动开发者视角,把这条路径完整捋一遍,包括我实际开发中遇到过的典型问题和排查思路,希望能帮你少走弯路。
1. 从一段字符驱动开始:把内核模块这层窗户纸捅破
设备驱动在Linux里并不是一个多么神秘的东西。它说到底就是在内核空间里注册一组操作函数,让用户态程序可以通过open、read、write、ioctl这些接口访问硬件。而这组操作函数对应的载体,通常就是内核模块。
1.1 一个最小模块的完整生命周期
你随便翻开一本内核编程的书,第一个例子大概率是类似这样的:
#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 kernel module");编译通过后,insmod demo.ko加载,rmmod demo卸载,模块的一生就结束了。但我要提醒你,别觉得这东西太简单就跳过。这个模块背后有两个很重要的点:一个是__init和__exit宏,它们不只是给代码打标记,还会让内核把这个函数的代码段放在特殊的init段里,加载完释放掉,节省内存;另一个是MODULE_LICENSE,如果你不声明“GPL”或兼容许可证,很多内核符号不会导出给你用,这在写真实驱动时非常致命。
1.2 从模块到字符设备
嵌入式Linux里最常用的设备形态就是字符设备。把上面的模块扩展成字符设备,需要做三件事:分配一个设备号、注册一组回调函数、创建设备节点。传统写法是用register_chrdev_region或者alloc_chrdev_region,然后再手动class_create配合device_create来生成/dev/xxx。
不过现在的内核我建议直接用miscdevice,它是个轻量级的封装,内核会替你处理设备号和类的问题,适合我们用来理解结构:
#include <linux/fs.h> #include <linux/miscdevice.h> #include <linux/uaccess.h> static ssize_t demo_read(struct file *file, char __user *buf, size_t count, loff_t *pos) { const char msg[] = "hello from demo driver\n"; return simple_read_from_buffer(buf, count, pos, msg, sizeof(msg) - 1); } static const struct file_operations demo_fops = { .owner = THIS_MODULE, .read = demo_read, }; static struct miscdevice demo_misc = { .minor = MISC_DYNAMIC_MINOR, .name = "demo_dev", .fops = &demo_fops, }; static int __init demo_init(void) { return misc_register(&demo_misc); } static void __exit demo_exit(void) { misc_deregister(&demo_misc); }加载模块后,你可以直接在终端执行cat /dev/demo_dev,看到“hello from demo driver”。驱动开发的第一个核心认知就在这里:用户态访问硬件,本质上是访问内核里的文件接口,而文件接口背后是你注册的file_operations。
1.3 为什么不能只在模块里操作寄存器
很多人写到这里就会开始问,那我申请一块内存,然后ioremap物理地址,直接在read回调里读寄存器不就行了吗?当然行,很多早期内核就是这么干的。但从产品化的角度,这种方式存在两个明显问题:第一,硬件连接方式变了,比如GPIO从PA1换到PB3,你得改源代码重新编译;第二,一个设备可能挂在不同总线上,比如同一个传感器既支持I2C也支持SPI,你用固定寄存器地址写出来的驱动,换一种总线就得推倒重来。
这就引出了设备树。设备树的作用是把“硬件长什么样”从“驱动怎么操作硬件”中剥离出来,让同一份驱动源码可以适配多种板卡配置。下一章我们仔细展开。
2. 设备树到底解决了什么:硬件描述的来龙去脉
如果你在嵌入式Linux里提到硬件配置,绕不开设备树(Device Tree)。它本质上是一个描述硬件拓扑的数据结构,从引导程序加载到内核,内核根据它来匹配驱动、初始化设备、管理资源。
2.1 dts、dtsi、dtb的关系
你可能会看到板级目录下有.dts和.dtsi文件,前者是板子的主描述文件,后者是芯片级或者通用模块的描述文件,通过#include包含进来。.dts经dtc编译后生成二进制.dtb,引导程序把.dtb的物理地址传给内核,内核再从内存里解析出设备树。我习惯把.dtsi类比成C语言里的头文件,把公共的硬件描述抽出来复用,把板级差异留在.dts里,这样产品线多了非常方便。
比如瑞芯微RK3568这样的芯片,它的SDK里通常会有rk3568.dtsi,里面定义了CPU、中断控制器、I2C控制器、CAN控制器这些芯片内部资源;而具体到某个开发板的rk3568-evb.dts,只会写这块板子接了什么外设、使用了哪些引脚。开发时改板级文件就够,芯片级文件尽量不动。
2.2 设备树节点怎么描述硬件
一个设备树节点的骨架是这样的:
&i2c0 { status = "okay"; clock-frequency = <100000>; eeprom@50 { compatible = "atmel,24c02"; reg = <0x50>; pagesize = <8>; }; };这里的compatible是驱动与设备匹配的关键字符串,reg是设备在总线上的地址,&i2c0表示向i2c0节点追加信息。当你写一个I2C驱动时,of_match_table里的.compatible = "atmel,24c02"要和设备树里的字符串精确一致,一个字母都不能差,否则内核认为没有设备驱动支持这个硬件。
我还想强调一下status属性。很多时候外设不工作,不是驱动问题,而是设备树里对应节点status = "disabled"没有改成okay。SDK默认会关掉很多外设来降低功耗或避免引脚冲突,新板子调设备树第一步往往就是检查这一条。
2.3 驱动如何拿到设备树里的参数
你写了reg = <0x50>,驱动怎么知道这个值?I2C设备驱动通过i2c_client结构体里的addr拿地址,平台设备驱动则通过platform_get_resource、device_property_read_u32这些API拿reg、interrupts、clock-frequency等属性。
上面那个eeprom的读写,驱动里常见这样取参数:
struct i2c_client *client = to_i2c_client(dev); u32 pagesize; device_property_read_u32(&client->dev, "pagesize", &pagesize);这种方式的好处显而易见:同一个eeprom驱动可以用于多个板子,只要设备树里的pagesize属性填不同值,驱动行为就跟着变化,源码完全不用动。
2.4 设备树解析失败怎么排查
在实际项目中,设备树出问题通常有几个特征:驱动probe不调用、/sys/firmware/devicetree/base里看不到对应节点、或者系统启动日志里报Failed to create device node。
我的排查习惯是三步走:
- 先检查
.dtb里是不是真的有这个节点。 - 在开发板上执行
ls /proc/device-tree/,这其实是/sys/firmware/devicetree/base的符号链接,能直接看到内核解析后的结果;如果节点不存在,多半是dts内容没编进去或者status不对。 - 查看
compatible是否和驱动完全匹配,包括大小写、逗号位置。这里有个容易忽略的细节:设备树里compatible = "atmel,24c02",驱动里写的却是"atmel,24c32",虽然都是eeprom,内核可不会帮你”智能识别“。
3. I2C设备驱动:不只是一次i2c_transfer
I2C是嵌入式系统里用的最多的一种板级总线,挂传感器、EEPROM、显示屏、触摸IC,到处都是。Linux的I2C子系统也做了非常清晰的层次划分,理解了它的层次,写驱动就成了一件“填空”的事。
3.1 I2C子系统三层架构
Linux的I2C子系统从上到下可以分成三层:I2C设备驱动、I2C核心层、I2C控制器驱动(适配器)。我们写驱动时关注的是最上层,也就是I2C设备驱动,它负责跟挂在总线上的具体外设打交道;而I2C控制器驱动处理的是硬件时序问题,一般由芯片厂商提供。
为了让你有个直观感受,我贴一个I2C设备驱动的基本结构:
#include <linux/i2c.h> #include <linux/module.h> #include <linux/init.h> static int myi2c_probe(struct i2c_client *client) { // client->addr 是设备地址 // 这里可以读取设备树属性、初始化硬件 return 0; } static void myi2c_remove(struct i2c_client *client) { } static const struct of_device_id myi2c_of_match[] = { { .compatible = "demo,my-sensor" }, { } }; MODULE_DEVICE_TABLE(of, myi2c_of_match); static struct i2c_driver myi2c_driver = { .probe = myi2c_probe, .remove = myi2c_remove, .id_table = myi2c_id, .driver = { .name = "myi2c", .of_match_table = myi2c_of_match, }, }; module_i2c_driver(myi2c_driver); MODULE_LICENSE("GPL");内核通过设备树里的compatible找到这个驱动后,会调用probe。到这里,驱动开发已经从“全局文件操作”转型为“总线设备驱动模型”了,这是嵌入式Linux驱动很关键的分水岭。
3.2 I2C通信的基本动作:读、写、读写复合
I2C协议本身很简单:起始信号、设备地址、读/写位、寄存器地址、数据、应答位、停止信号。驱动层看到的就是一组i2c_msg。
往一个EEPROM的某个寄存器地址写一字节数据,通常是这样:
static int eeprom_write_byte(struct i2c_client *client, u8 reg, u8 val) { u8 buf[2] = { reg, val }; struct i2c_msg msg = { .addr = client->addr, .flags = 0, // 写 .len = 2, .buf = buf, }; return i2c_transfer(client->adapter, &msg, 1); }从一个传感器的某个寄存器读一字节,需要先写寄存器地址,再发起读操作。如果用两条独立消息分别发写和读,房间里隔着一条总线的控制器是不会自动帮你连续完成的,外部设备通常也不会买账。正确做法是用一个带I2C_M_RD标志的复合消息:
static int sensor_read_reg(struct i2c_client *client, u8 reg, u8 *val) { struct i2c_msg msgs[2]; msgs[0].addr = client->addr; msgs[0].flags = 0; msgs[0].len = 1; msgs[0].buf = ® msgs[1].addr = client->addr; msgs[1].flags = I2C_M_RD; msgs[1].len = 1; msgs[1].buf = val; return i2c_transfer(client->adapter, msgs, 2); }这里有个新手常犯的错:如果两次调用i2c_transfer,中间就可能被其他设备抢占总线,读出的数据不在同一个事务里。而复合消息在大多数I2C控制器上会被处理成“重复起始位”(Restart),这是完全符合时序要求的。
3.3 为什么设备地址经常“对不上”
I2C设备地址分成7位和8位两种说法。数据手册上给的是7位地址如0x50,你在设备树里就写reg = <0x50>;但如果你自己用用户态程序直接发地址字节,往往要把地址左移一位,并在最低位补上读写标志,也就是8位地址0xA0。如果你在i2cdetect里看到设备出现在0x50,在代码里却用了0xA0,那就要警惕是不是尺寸写错了。
另外,I2C地址还分静态地址和动态地址。像一些PMIC芯片可以通过硬件引脚改变地址的低位,两个板子可能设备树里写的reg不同。调试时就一定要用i2cdetect -y -r 0扫描一下总线,看看设备实际出现在哪个位置,再回头改设备树或驱动。
3.4 用户态I2C调试三板斧
没有逻辑分析仪之前,我调试I2C主要靠三个工具:
i2cdetect -l:列出系统的I2C总线。i2cdetect -y <bus>:扫描总线上的设备地址。i2cget/i2cset:直接读写设备的某个寄存器。
比如你怀疑一个温度传感器挂了,可以直接:
i2cdetect -y 2 i2cget -y 2 0x48 0x00如果i2cget返回错误,先不要怀疑传感器坏了,先确认地址、上拉电阻、以及设备树里这个总线的clock-frequency是否超了。很多传感器不支持400kHz快模式,你把它挂在只有100kHz能力的外设旁边,就会出现偶发无应答。
3.5 I2C时序的怀疑与验证
当I2C通信不稳定时,我个人首先会怀疑的问题按概率排序是这样的:
- 设备树里
clock-frequency是不是超过从设备上限; - 总线引脚的内部上拉没有配置,靠外部上拉是不是阻值太大;
- 从设备地址冲突,总线上有两个相同地址的设备;
- 地线电位差过大,这在长排线连接时特别明显。
你检查完这些还是要怀疑硬件时序,那就用逻辑分析仪抓SCL和SDA。正常读一次的时序应该是:SDA先拉低,SCL产生8个时钟,然后应答位,再继续。如果波形里坑坑洼洼或者只有SCL没有SDA,大概率是地址不对或者设备没上电。
4. CAN设备:从SocketCAN框架看一条报文的前半生
CAN总线在车载、工控和机器人领域都大量使用,它的实时性、抗干扰能力和多主通信特性是其他板级总线替代不了的。在Linux里,CAN设备的驱动模型和I2C差异比较大,它不是“设备—总线—驱动”三件套这么简单,而是走网络设备(net_device)这一套框架。也就是驱动层向内核注册的是一个网络接口,比如can0、can1,而用户态是把它当网卡一样操作的。
4.1 CAN控制器驱动的骨架
Linux的CAN驱动通常基于struct net_device和struct can_priv实现。我们以常见的MCU内部CAN控制器为例,驱动需要完成这样几件事:
- 分配并注册一个
net_device; - 实现
open、stop、start_xmit等回调; - 配置位时序、滤波器、中断;
- 接收中断里把硬件报文封装成
struct can_frame上报给对方。
注册网卡的极简骨架大概是:
#include <linux/can/dev.h> struct mycan_priv { struct can_priv can; void __iomem *base; int irq; }; static netdev_tx_t mycan_start_xmit(struct sk_buff *skb, struct net_device *ndev) { // 把 skb 里的 can_frame 寄存器化,写入硬件 return NETDEV_TX_OK; } static int mycan_open(struct net_device *ndev) { // 打开中断、配置波特率、启动CAN控制器 return open_candev(ndev); } static int mycan_stop(struct net_device *ndev) { close_candev(ndev); return 0; } static const struct net_device_ops mycan_netdev_ops = { .ndo_open = mycan_open, .ndo_stop = mycan_stop, .ndo_start_xmit = mycan_start_xmit, };对于小白来说,最容易卡住的是“为什么CAN驱动要搞成网卡模型”。其实很简单,CAN报文的收发天然是异步的、不确定长度的,而且可能有多个节点同时发数据,用一个“数据包”模型来抽象非常自然。内核里已经有了SocketCAN这套协议栈,能很好支持CAN报文过滤、协议族、错误管理,驱动只需要把它当一块网卡来实现就行了。
4.2 设备树里的CAN节点
CAN控制器也属于平台设备,所以设备树里同样需要描述。下面是一个典型例子:
&can0 { status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&can0_pins>; clock-frequency = <200000000>; // 有些芯片叫 clocks };不同厂家的属性命名会有差别,有的用clocks = <&cru CLK_CAN0>,有的用clock-frequency,但思路都一样:告诉驱动CAN控制器的时钟来源,以便计算波特率。如果这里写错,你配置500k波特率时控制器实际跑的可能是完全另一个频率,发出去的报文别人全都收不到,排查起来相当隐蔽。
pinctrl-0也很关键,它描述了CAN收发引脚复用到哪个引脚组。嵌入式芯片的引脚复用极其灵活,不配置pinctrl,即使控制器已经注册成功,TXD/RXD也不会从芯片内部映射到外部引脚上,总线自然没有任何波形。
4.3 CAN用户态操作
驱动注册成功后,你会看到一个can0接口。使用前需要做些网络配置:
sudo ip link set can0 type can bitrate 500000 sudo ip link set can0 up candump can0 & cansend can0 123#DEADBEEFip link set can0 type can bitrate 500000这一步就是设置波特率,它最终会调到底层驱动里的位时序计算方法。两个节点通信,波特率必须一致,并且两端都要有终端电阻(如果线缆长的话)。
实际测试时我一般先做回环测试:
sudo ip link set can0 type can bitrate 500000 loopback on sudo ip link set can0 up cansend can0 123#55667788 candump can0loopback模式下报文发送后会被控制器自己收回来,这个测试不依赖外部节点,可以快速验证驱动、中断、SocketCAN链路是否正常。确认没问题后,再关掉loopback,接上真实总线和其他节点联调。
4.4 常见的CAN驱动错误处理
调试CAN驱动时,我印象最深的两个问题:
第一个是CAN错误中断风暴。硬件上如果总线没有终端电阻,或者只有一端接了地,控制器会不断报总线错误,中断服务函数被频繁触发,CPU占用率直接飙升,系统看起来像卡死了一样。这时候先断开外部总线,做纯内部回环,如果问题消失,问题基本就在外部电路。
第二个是没有配置CAN控制器的验收滤波器,导致驱动收到了大量不关心的报文,CPU被无意义中断拖垮。大部分CAN控制器都有滤波器,可以在不改变CPU的情况下只接收特定ID范围的报文。设备树里或者驱动里一定要把滤波器设置好。
5. 真实调试现场:设备树失败、I2C卡死、CAN丢帧
说了这么多框架和例子,真正让一个驱动从“能编译”变成“能稳定跑”的,是调试能力。我挑几个自己碰到过的高频问题,把排查链路完整写出来。
5.1 设备树节点明明有,驱动probe就是不调用
有一次我移植一个新板子的I2C触摸屏,设备树里&i2c3下加了触摸屏节点,status = "okay"也写了,启动后却看不到触摸屏设备。
我在开发板上执行ls /sys/firmware/devicetree/base/i2c3/touchscreen,节点存在。再看compatible,也跟驱动里一致。最后怎么查出来的呢?发现i2c3这个节点本身被上层&i2c3 { status = "disabled"; };在后面重新覆盖了。设备树里同名的节点属性覆盖关系很隐蔽,尤其是外部扩展板和主dts对同一个i2c控制器都有配置时,后面的会覆盖前面的。
排查方法很简单:用dtc -I fs -O dts -o dump.dts /proc/device-tree把内核实际使用的设备树反编译出来,直接看最终生效的内容。记住,永远以/proc/device-tree为准,而不是源码里的.dts文件。编译器做节点合并时经常有意想不到的效果。
5.2 I2C read偶尔返回,但数据全是0xFF
这种现象通常不是内核驱动问题,而是硬件线路问题。如果设备没上电或者SDA引脚虚焊,主控读回来的数据是0xFF,因为你读到的是被上拉电阻拉高的总线电平。更加迷惑的是它偶尔能出一次正常数据,往往就是接触不良,冷焊点导致电阻忽大忽小。
这时候我会先用示波器测SDA和SCL的波形,看应答位有没有正常拉低。如果始终没有ACK,再查地址。这里有个坑:如果总线上有多个设备,i2cdetect能扫出来,但实际写入时某个设备即使地址不匹配也可能不拉低总线,导致共存问题。多设备系统里我建议先把I2C外设一个个摘掉测试,减少变量。
5.3 内核打印你关掉了,怎么定位?
内核里面printk被日志级别过滤是非常常见的情况。驱动里写了dev_info或者dev_dbg却不打印,很多人开始怀疑代码没跑。其实你执行dmesg -n 8或者echo 8 > /proc/sys/kernel/printk就能打开大部分日志。dev_dbg这种级别在默认内核配置下可能被编译掉,这时你需要确认CONFIG_DYNAMIC_DEBUG是否打开,打开后可以用:
echo "file drivers/i2c/busses/i2c-imx.c +p" > /sys/kernel/debug/dynamic_debug/control这就能单独打开某个文件的调试日志。内核日志系统对驱动调试来说是最基本也是最重要的手段,不要嫌它笨,关键时候比任何调试器都管用。
5.4 CAN丢帧但不报错,先查环和仲裁
CAN总线本身的CSMA/CA机制就是有优先级仲裁概念的,多个节点同时发数据时优先级低的报文会被自动推迟,这不代表驱动丢帧。真实项目里出现“我发了10帧,对方只收到8帧”,我先看总线上是不是有另一个节点持续发高优先级报文,再查波特率偏差和终端电阻。
波特率偏差很容易被忽视。CAN允许的位时间误差很小,如果两端晶振精度差异大或者CAN控制器输入时钟频率计算有误,就会造成长时间运行后偶发错误帧。遇到这种问题,用ip -details link show can0查看驱动上报的波特率参数,确保和预期一致。
5.5 驱动调试的核心武器:debugfs
我强烈建议你在自己的驱动里顺手加一个debugfs目录,用来暴露内部寄存器值、统计信息、甚至直接触发测试逻辑。比如I2C驱动可以加一个regs文件,读出来就是当前控制器寄存器快照。这种方式比反复改代码加printk高效多了。
一个简单的debugfs入口:
#include <linux/debugfs.h> static struct dentry *dbg_dir; static int dbg_show(struct seq_file *m, void *v) { struct my_device *dev = m->private; seq_printf(m, "irq: %d\n", dev->irq); seq_printf(m, "last_error: 0x%08x\n", readl(dev->base + REG_STATUS)); return 0; } static int dbg_open(struct inode *inode, struct file *file) { return single_open(file, dbg_show, inode->i_private); } static const struct file_operations dbg_fops = { .owner = THIS_MODULE, .open = dbg_open, .read = seq_read, }; dbg_dir = debugfs_create_dir("mydev", NULL); debugfs_create_file("status", 0444, dbg_dir, dev, &dbg_fops);有了这个基础,配合trace-cmd或者ftrace,你基本可以看清probe调用链、中断触发频率、收发函数进出时机。遇到“莫名失灵”的bug,先开kernel function trace,往往能找到线索。
写在最后
从最小的内核模块,到字符设备,再到设备树解析、I2C设备驱动、CAN网络设备框架,这一路走下来,其实覆盖了Linux驱动开发很重要的几条主线:模块化的内核编程、设备驱动模型、总线抽象、数据包抽象。设备树解决的是“硬件如何描述”,I2C解决的是“板级外设如何通信”,CAN解决的是“多节点数据如何交换”。这三块知识放在一起,就是一块嵌入式Linux产品开发板上从Boot到用户态链路里最常被问到的驱动技术区域。
真正能让你进步的不是反复看文档,而是上手改代码、烧板子、抓波形,然后遇到问题回去查内核源码。驱动的调试从来不是一次性的,需要你从设备树反编译、内核日志、总线扫描工具、示波器逻辑分析仪几个维度反复交叉验证。希望这篇文章能把这条路径给你串清楚,让你在写下一个驱动的时候,脑子里先有地图,而不是从前只看到一棵树。