资讯动态

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

发布时间:2026/9/15 3:43:48 来源:尧图企业网站定制
很多人拿到一块新板子第一反应是打开芯片手册然后一头扎进内核源码。折腾几天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; eeprom50 { 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_transferI2C是嵌入式系统里用的最多的一种板级总线挂传感器、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 reg; 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到用户态链路里最常被问到的驱动技术区域。真正能让你进步的不是反复看文档而是上手改代码、烧板子、抓波形然后遇到问题回去查内核源码。驱动的调试从来不是一次性的需要你从设备树反编译、内核日志、总线扫描工具、示波器逻辑分析仪几个维度反复交叉验证。希望这篇文章能把这条路径给你串清楚让你在写下一个驱动的时候脑子里先有地图而不是从前只看到一棵树。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价