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

Linux设备驱动开发实战:从内核模块到设备树与I2C/CAN驱动 1. 设备驱动的三层认知为什么从输出一句话开始很多人一提到 Linux 设备驱动开发第一反应就是我要操作某个硬件芯片我要点亮一块屏幕我要打通一条 I2C 总线。这种目标驱动的学习方式没有错但很容易在一开始就陷入困境——因为设备驱动开发并不是单纯地写代码控制硬件它背后是一整套软件工程思想内核态与用户态的边界、硬件资源的抽象、设备与驱动的匹配机制、总线-设备-驱动三层模型的协作方式。我的经验是入门阶段必须建立三个层次的认识。第一层驱动是内核的一部分。驱动代码不是普通的应用程序它运行在内核态拥有完全不同的运行环境——没有内存保护、没有标准 C 库可以直接调用、出错就可能 panic 整个系统。这意味着你在写驱动时要彻底改变思维方式不是一个程序在运行而是一段代码被内核调度和调用。第二层驱动是描述与匹配的结果。在现代化内核中驱动不是通过注册一个名字然后自己去找硬件来工作的。内核维护着一条链路硬件信息被描述在设备树中或者通过 ACPI、PCI 枚举等方式描述驱动通过 compatible 字符串、设备 ID 表等方式声明我支持哪些设备内核再根据匹配关系把两者绑定。理解这条链路你就理解了为什么设备树那么重要。第三层驱动是数据的搬运工。不管驱动多复杂最终目的都是完成数据传输——把用户空间的请求翻译成硬件操作再把硬件状态反馈给用户程序。I2C 驱动就是读写寄存器CAN 驱动就是把内核的 sk_buff 转换成 CAN 帧、把 CAN 帧上传成网络包。想清楚数据流的方向和格式写代码时就会目标明确。这三层认知建立起来之后再去看具体代码完全就是降维打击。而建立认知最好的方式就是从一个最小内核模块开始——哪怕它只是hello world但加载它、卸载它你就走完了一遍内核模块的生命周期这是整条设备驱动路径的起跑线。2. 手写一个最小内核模块加载、卸载与树外编译2.1 为什么先写模块而不是直接写设备驱动设备驱动本质上就是一个内核模块加上设备匹配逻辑、文件操作接口和硬件访问代码。如果一上来就碰这些东西你会被大量细节淹没——platform driver 的结构体、probe 函数的时机、文件操作的实现、内存映射的陷阱——最后很可能啥也没学会。先写一个最基本的内核模块你只需要关注两件事模块的加载函数和卸载函数。这些代码量不超过 50 行编译命令也不复杂但它能让你一次性搞清楚内核模块的开发环境怎么搭内核头文件和构建系统如何工作模块的加载、卸载生命周期如何流转printk输出去哪里看这些基础扎实了后面加设备、加总线、加中断都是在同一套框架里做增量。2.2 最小模块代码与 Makefile先看代码。文件名就叫hello_mod.c#include linux/init.h #include linux/module.h #include linux/kernel.h static int __init hello_init(void) { printk(KERN_INFO hello_mod: module loaded\n); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO hello_mod: module unloaded\n); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A minimal Linux kernel module); MODULE_VERSION(1.0);这段代码里有两个关键函数hello_init和hello_exit。module_init和module_exit宏把它们注册为模块的加载/卸载入口。__init和__exit是内核的初始化/退出标记宏它们有一个很实用的特性加载函数在模块加载完成后会被释放掉不再占用内核内存——这在嵌入式设备上节约内存的小细节很多教程都不会提。编译它需要配套一个 Makefileobj-m : hello_mod.o KDIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean这里KDIR指向当前内核的构建目录它依赖内核头文件包。大多数 Linux 发行版需要单独安装比如 Ubuntu 上用apt install linux-headers-$(uname -r)嵌入式开发中则是 SDK 自带的交叉编译工具链和内核源码目录。2.3 树外编译与树内编译的区别我上面写的 Makefile 是典型的树外编译模式也就是模块源码不在内核源码目录里而是通过M$(PWD)让内核构建系统去指定目录编译模块。这种方式特别适合开发阶段模块代码独立维护不污染内核树新增一个源文件只需要在obj-m上追加。树内编译则不同它是把模块源码放进内核源码目录的某个子目录修改对应 Kconfig 和 Makefile让模块随内核一起编译。这种方式适合正式产品化——模块最终要合入内核版本管理或者需要依赖内核内部的一些编译选项。对于刚开始做驱动的场景我非常推荐树外编译。原因很简单出错了不会把整个内核搞挂重编模块比重编内核快几个数量级。2.4 加载、卸载与查看日志的完整链路编译完成后你会得到hello_mod.ko文件。操作流程如下# 加载模块 sudo insmod hello_mod.ko # 查看模块是否加载成功 lsmod | grep hello_mod # 查看模块信息 modinfo hello_mod.ko # 卸载模块 sudo rmmod hello_mod # 查看内核日志 dmesg | tail -20加载成功后/sys/module/hello_mod/目录会出现模块对应的 sysfs 节点。通过/sys/module/hello_mod/refcnt可以查看模块被引用的次数——如果模块被其他模块依赖或者被设备占用这个数值不会归零你也无法卸载它。printk输出不会直接打到终端而是进入内核日志缓冲区用dmesg查看。KERN_INFO是日志级别生产环境中可以根据日志级别控制打印信息的详略程度。我在实际开发中踩过最典型的坑是插入了模块dmesg里啥都没有以为是代码问题。结果是因为日志级别被kernel.printk配置过滤掉了。查一下当前生效的打印级别一切正常。这个坑看起来很傻但真的会浪费你半小时。2.5 模块传参驱动初始化的第一步模块支持命令行传参这个能力在驱动调试中极其有用。比如模块里定义一个参数加载时指定static int debug_level 0; module_param(debug_level, int, 0644); MODULE_PARM_DESC(debug_level, Debug level (0-3)); static int hello_init(void) { printk(KERN_INFO hello_mod: debug_level%d\n, debug_level); return 0; }加载时sudo insmod hello_mod.ko debug_level3然后查看/sys/module/hello_mod/parameters/debug_level这个文件你会看到当前值。0644权限意味着 root 可以写入运行时就能动态调整参数。这个机制在驱动开发中太常用了——比如调试 I2C 设备时通过模块参数控制 CRC 校验开关、延时时间等不用重新编译直接改 sysfs 节点极大提升调试效率。3. 设备树到底树在哪从 dts 到 dtb 的编译与匹配逻辑3.1 设备树解决的是硬件描述问题在设备树出现之前ARM Linux 内核里充满了各种board-*.c文件每换一款开发板就要往内核里塞一段机器码相关的板级初始化代码。今天你加一个 GPIO明天你改一个中断号整个内核代码被大量硬件配置撑得越来越臃肿而且这些代码改完必须重新编译内核。设备树的思路是把硬件的拓扑和配置信息从内核代码里抽离出来变成一份独立的数据结构——dts源文件编译成dtb二进制后由 bootloader 加载并传递给内核。内核在启动时解析这棵树并根据节点信息去匹配驱动。打个比方设备树就是一份硬件户口本每个设备节点就是户口本上的一条记录写清楚这个设备的地址、中断、时钟、GPIO 连接等信息驱动则是认领人它通过 compatible 字段声明我认领这类设备内核负责把户口本上的信息和认领人匹配上。3.2 dts、dtsi 与 dtb 的三角关系一个嵌入式项目里你通常接触三类文件.dtsiSoC 级和设备公共资源描述文件一般由芯片原厂提供.dts具体板级描述文件包含本板子独有的外设配置.dtbdts文件编译后的二进制产物被 bootloader 加载比如瑞芯微 RK3568 的开发板SDK 里通常有rk3568.dtsiSoC 公共资源和rk3568-evb.dts开发板配置。dtsi通过#include被dts引用这种方式既遵循了 C 预处理的语法也实现了多文件的复用。在最顶层的dts文件头经常能看到/dts-v1/; #include rk3568.dtsi #include rk3568-evb.ddr.dtsi/dts-v1/;是版本声明表示这是新版设备树语法。后续的#include会把 SoC 级的定义全部展开然后在当前文件里继续做板级覆盖或追加。编译设备树通常不需要手动调用命令SDK 的编译系统会自动完成。但在调试阶段有时需要手动编译# 先把 dts 预处理为纯 dts 文本 cpp -nostdinc -I include -undef -x assembler-with-cpp \ rk3568-evb.dts -o rk3568-evb.dts.pre # 再编译为 dtb dtc -I dts -O dtb -o rk3568-evb.dtb rk3568-evb.dts.precpp处理完之后dtcdevicetree compiler才是真正的语法编译器。反过来从dtb反编译成可读文本用dtc -I dtb -O dts。3.3 root 节点和 compatible匹配驱动的第一把钥匙设备树的最顶层是根节点/ { model Rockchip RK3568 EVB Board; compatible rockchip,rk3568-evb, rockchip,rk3568; #address-cells 1; #size-cells 1; };model是人可读的板卡名称compatible则是内核匹配板级驱动的关键字符串。内核启动早期会使用根节点的compatible与内核里注册的DT_MACHINE_START做匹配決定使用哪个板级兼容代码。到了具体设备节点compatible的匹配逻辑变成设备模型的一部分。例如一个 I2C 触摸屏节点i2c0 { status okay; clock-frequency 100000; touchscreen38 { compatible goodix,gt911; reg 0x38; interrupt-parent gpio3; interrupts RK_PA5 IRQ_TYPE_LEVEL_LOW; reset-gpios gpio3 RK_PA4 GPIO_ACTIVE_LOW; }; };驱动里的of_device_id表只要包含goodix,gt911内核就会在该 I2C 总线上找到地址为0x38的设备并触发驱动的 probe 函数。这里有个关键优先级需要注意设备树中的 compatible 字符串是唯一匹配标准驱动必须以完全相同的字符串声明支持该设备。任何一边写错一个字符match 就会失败设备静默不可用。3.4 中断、GPIO、时钟设备树中的资源描述语法中断在设备树中通常用interrupt-parent和interrupts描述。interrupt-parent指明中断控制器的 phandle 引用interrupts的格式由中断控制器定义。比如 ARM GIC 一般描述为0 45 4——第一个 0 表示 SPI 中断45 是硬件中断号4 表示触发类型。GPIO 描述有两种常见写法老式写法使用gpios或xxx-gpios新式写法配合gpio-keys、gpio-leds等框架使用。上面触摸屏例子里的reset-gpios gpio3 RK_PA4 GPIO_ACTIVE_LOW表示 GPIOC4RK3568 的 GPIO0-4 分组作为复位脚低电平有效。时钟资源描述一般使用clocks和clock-namesclocks cru CLK_I2C0; clock-names i2c;驱动里可以通过devm_clk_get(pdev-dev, i2c)获取时钟句柄使能或调整频率。在调试设备树配置时我会建议始终确认以下几件事节点status是否为okay而不是disabledreg寄存器地址是否与硬件原理图一致中断号是否对应正确、触发类型是否匹配GPIO 编号是否落在正确的 gpio controller 上这些看起来是细节但实际项目中 80% 的设备不工作问题都出在这里。设备树写错驱动写得再正确也白搭。3.5 设备树节点的运行期验证方法我常用的验证手段是在/proc/device-tree/下查看运行时解析出来的设备树。这个目录以树形结构呈现设备树节点例如ls /proc/device-tree/ cat /proc/device-tree/compatible或者使用/sys/firmware/devicetree/base/两者本质相同。看到compatible字符串正确、status是okay至少说明你的dtb被正确加载了。如果你发现自己修改了dts、重新编译了内核镜像烧录后设备树没变化先检查烧录的镜像里是否真的包含了新编译的dtb。有些平台把dtb放在独立分区有些则打包进了 boot 镜像搞错位置是家常便饭。4. I2C 子系统从设备树节点到 regmap 的数据通路4.1 为什么 I2C 是驱动开发练习的最佳场景I2C 是一种低速、双线的串行总线协议广泛用于连接触控屏、传感器、音频 Codec 等设备。它不像 PCI/网络那样复杂又比纯粹的 GPIO 操作更有协议深度非常适合作为学习驱动开发的第二站——掌握了 I2C 驱动你对总线-设备-驱动模型的理解会从感性认识上升到理性认知。I2C 子系统在内核里是分层的。最底层是 I2C 控制器驱动通常是 SoC 原厂实现它负责硬件寄存器的操作中间层是 I2C 核心drivers/i2c/i2c-core.c它管理总线、设备和驱动三者的注册与匹配最上层是 I2C 客户端驱动也就是我们需要实现的部分。4.2 一个 I2C 客户端驱动的标准骨架写 I2C 驱动先大致了解几个核心结构体struct i2c_client描述一个 I2C 设备实例包含地址、适配器编号等struct i2c_driver描述驱动本身包含 id 表和 probe/remove 回调struct i2c_adapter描述 I2C 控制器一般由平台代码管理struct i2c_msg描述一次读写事务一个最简单的 I2C 驱动框架如下#include linux/i2c.h #include linux/module.h #include linux/of.h static int my_sensor_probe(struct i2c_client *client) { struct i2c_adapter *adapter client-adapter; if (!i2c_check_functionality(adapter, I2C_FUNC_SMBUS_READ_BYTE_DATA)) return -EOPNOTSUPP; dev_info(client-dev, my_sensor: detected at 0x%02x\n, client-addr); return 0; } static void my_sensor_remove(struct i2c_client *client) { dev_info(client-dev, my_sensor: removed\n); } static const struct of_device_id my_sensor_of_match[] { { .compatible vendor,my-sensor }, { } }; MODULE_DEVICE_TABLE(of, my_sensor_of_match); static struct i2c_driver my_sensor_driver { .driver { .name my_sensor, .of_match_table my_sensor_of_match, }, .probe my_sensor_probe, .remove my_sensor_remove, }; module_i2c_driver(my_sensor_driver); MODULE_LICENSE(GPL);probe函数在设备树中匹配到该节点时被调用。注意i2c_check_functionality的检查——它确认控制器支持所需的事务类型这是个好习惯避免在能力不达标的控制器上执行非法操作。4.3 为什么把设备树节点放在 i2c 总线节点下面设备树中I2C 设备节点必须挂在对应的 I2C 控制器节点下例如i2c2 { status okay; my_sensor48 { compatible vendor,my-sensor; reg 0x48; pinctrl-names default; pinctrl-0 i2c2_xfer; }; };这里48是设备的 I2C 从机地址reg指定该地址。为什么必须挂在控制器节点下因为设备树解析时I2C 核心会根据节点所在总线的控制器来创建i2c_client。挂在别的位置设备根本不会被识别。pinctrl-names和pinctrl-0是引脚复用配置它把 I2C 控制器的 SDA/SCL 引脚复用为 I2C 功能。很多设备树配置失败的场景都是因为供应商默认把引脚配成了 GPIO 模式I2C 控制器无法通讯。4.4 读寄存器操作i2c_transfer 与 SMBus 调用实际操作中读设备寄存器有两种常见方式。第一种是直接用i2c_transfer构造事务static int read_reg(struct i2c_client *client, u8 reg, u8 *val) { struct i2c_msg msgs[2] { { .addr client-addr, .flags 0, .len 1, .buf reg }, { .addr client-addr, .flags I2C_M_RD, .len 1, .buf val }, }; if (i2c_transfer(client-adapter, msgs, 2) ! 2) { dev_err(client-dev, read reg 0x%02x failed\n, reg); return -EIO; } return 0; }这实际上是标准的 I2C 复合事务先写寄存器地址随后repeated START读取数据。对于支持 SMBus 的设备可以使用更简洁的 SMBus 接口int val i2c_smbus_read_byte_data(client, reg); int rc i2c_smbus_write_byte_data(client, reg, value);SMBus 是 I2C 的一个子集协议在 x86 平台和许多嵌入式平台上广泛支持。它封装了地址、指令长度等细节代码更简洁错误处理也相对齐整。但对于一些不支持 SMBus 提醒条件的设备还是要退回i2c_transfer方式以原始的 I2C 事务来沟通。4.5 regmap 抽象把寄存器读写变成 API如果你的 I2C 设备是一个典型的寄存器型设备比如触摸屏控制芯片、音频 Codec建议直接使用内核的 regmap 框架。它把 I2C、SPI、MMIO 等不同总线统一成一套寄存器缓存管理机制让读写寄存器变得像读内存一样自然。static const struct regmap_config my_sensor_regmap_cfg { .reg_bits 8, .val_bits 8, .max_register 0xFF, .cache_type REGCACHE_RBTREE, }; static int my_sensor_probe(struct i2c_client *client) { struct regmap *regmap; regmap devm_regmap_init_i2c(client, my_sensor_regmap_cfg); if (IS_ERR(regmap)) { dev_err(client-dev, regmap init failed: %ld\n, PTR_ERR(regmap)); return PTR_ERR(regmap); } regmap_write(regmap, 0x10, 0x01); ... }使用 regmap 的好处非常明显自动处理缓存一致性性能更好代码量减少可读性高使用regmap_read/write时无需关心底层是 I2C 还是 SPI方便驱动移植但要注意一点regmap 的缓存机制在中断环境里读写时需要小心cache_only与cache_bypass的设置否则可能读到陈旧数据。实际项目中我一般会在 probe 阶段打开缓存在运行时保持跟踪若有特殊寄存器需要每次直接访问会对它单独做 bypass。4.6 设备树节点里的电压、频率等配置怎么传给驱动驱动需要获取一些板级参数比如传感器的内部采样频率或 IO 电压这些通常从设备树属性读取。在 probe 函数中通过of_property_read_u32等接口获取u32 vcc_supply_mv; if (!of_property_read_u32(client-dev.of_node, vcc-supply-mv, vcc_supply_mv)) dev_info(client-dev, vcc set to %d mV\n, vcc_supply_mv);注意属性命名要规范和芯片手册对齐避免不同板卡之间参数歧义。另外凡是设备树提供的资源如果驱动 start 时缺失应当优雅处理——要么返回错误码要么使用默认值千万别假设用户一定会填全。5. CAN 总线驱动的接入姿势SocketCAN 与设备树协同5.1 CAN 驱动在内核里的定位CANController Area Network总线是汽车电子、工业控制等领域最常见的高速串行总线之一。它在 Linux 内核中不是以字符设备的形式实现的而是以网络设备的形式存在——使用 SocketCAN 框架。你通过socket(AF_CAN, SOCK_RAW, CAN_RAW)这样的 API 收发 CAN 消息就像用 socket 收发普通网络包一样略加封装就能切换到与其他机器通信的模式。这套设计的深意是CAN 设备本质上就是一个低速以太网口内核的网络协议栈为 CAN 提供了统一的抽象层。驱动开发者不需要关心应用层怎么收包、发包只需要关注 CAN 控制器的寄存器操作、中断处理和收发队列管理。5.2 设备树里的 CAN 节点配置设备树中 CAN 控制器节点描述方式因 SoC 而异。常见的形式长这样can1 { status okay; pinctrl-names default; pinctrl-0 can1_m0_pins; /* 可选的波特率配置 */ /* 外部收发器可能还需要 en/gpio 控制 */ };如果你用的某颗 SoC 上 CAN 与 I2C、SPI 等复用同一组引脚比如 RK3568 的 CAN 从 0 到 2有的引脚需要与 UART 做 muxpinctrl配置就很关键。如果把引脚复用错CAN 控制器本身的启动可能会正常但总线完全没有 ACK收发都会失败。有的方案板上带独立 CAN 收发器如 TJA1043需要电源控制 GPIO、待机模式 GPIO 等。这些也要在设备树里描述can1 { status okay; pinctrl-names default; pinctrl-0 can1_m0_pins; /* 假设收发器的 STB 引脚连到 GPIO */ standby-gpios gpio1 RK_PB0 GPIO_ACTIVE_LOW; };驱动里可以用devm_gpiod_get获取这些描述probe 时拉低让收发器进入正常工作模式。5.3 一个 CAN 控制器的驱动框架CAN 控制器驱动一般要实现struct net_device和struct can_priv。核心框架如下struct my_can_priv { struct can_priv can; /* MUST be first */ void __iomem *base; struct clk *clk; ... }; static const struct net_device_ops my_can_netdev_ops { .ndo_open my_can_open, .ndo_stop my_can_close, .ndo_start_xmit my_can_start_xmit, .ndo_change_mtu can_change_mtu, }; static int my_can_probe(struct platform_device *pdev) { struct net_device *ndev; struct my_can_priv *priv; int err; ndev alloc_candev(sizeof(*priv), TX_ECHO_SKB_MAX); if (!ndev) return -ENOMEM; priv netdev_priv(ndev); priv-can.clock.freq clk_get_rate(priv-clk); priv-can.bittiming my_can_bittiming_const; priv-can.do_set_bittiming my_can_set_bittiming; priv-can.do_set_mode my_can_set_mode; ... ndev-netdev_ops my_can_netdev_ops; err register_candev(ndev); ... }alloc_candev申请net_device结构参数是私有数据大小和回声缓冲的数量。之后需要设置can_priv的时钟频率它决定波特率计算的基准。netdev_ops中指定 open、stop、xmit 等回调。硬件初始化主要是使能时钟和电源请求中断设置 CAN 控制器为复位模式配置位时序prop_seg、phase_seg1、phase_seg2、sjw使能控制器进入工作模式调用netif_start_queue或让核心层管理队列ndo_start_xmit是发送入口。内核把待发的sk_buff交给你你需要做以下事情static netdev_tx_t my_can_start_xmit(struct sk_buff *skb, struct net_device *ndev) { struct my_can_priv *priv netdev_priv(ndev); struct can_frame *cf (struct can_frame *)skb-data; /* 填充硬件发送寄存器 */ writel(cf-can_id, priv-base CAN_TX_ID); writel(cf-len, priv-base CAN_TX_DLC); for (int i 0; i cf-len; i) writel(cf-data[i], priv-base CAN_TX_DATA(i)); /* 触发发送 */ writel(1, priv-base CAN_TX_REQ); /* 不要忘了 can_put_echo_skb 和 netif_trans_update */ if (priv-can.ctrlmode CAN_CTRLMODE_LOOPBACK) can_put_echo_skb(skb, ndev, 0); return NETDEV_TX_OK; }记住一个关键点要调can_put_echo_skb记录待确认的帧用于完成 TX 回显。否则有些应用层依赖 TX completion 的回显会直接阻塞等待。中断处理例程读取状态寄存器判断是发送完成还是接收中断然后调用netif_wake_queue、napi_schedule等机制。接收路径调用netif_rx_ni或 NAPI 上送。5.4 SocketCAN 应用层验证链路是否打通驱动写完后验证链路最常用的工具是cansend和candump。它们来自can-utils包。# 配置 CAN 接口波特率 500k sudo ip link set can0 type can bitrate 500000 # 打开接口 sudo ip link set can0 up # 发送一帧数据 cansend can0 123#DEADBEEF # 监听模式 candump can0如果cansend返回NOACK说明总线上没有其他节点响应 ACK可以检查接线是否正确、波特率是否一致、收发器是否正常工作。如果 CRC 错误频繁大概率是波特率配置或终端电阻问题。5.5 CAN 驱动开发绕不开的坑位时序与仲裁CAN 链路能否稳定工作直接取决于位时序。can_priv里的bittiming_const定义了该控制器支持的位时序范围do_set_bittiming回调据当前波特率计算并写入硬件寄存器。位时序公式本质上是整数规划问题——根据 TQTime Quantum、Prop_Seg、Phase_Seg1、Phase_Seg2、SJW 的组合找到误差最低的一组。常用的ip link set can0 type can bitrate 500000会自动调用内核的位时序优化算法但这不一定最优。对某些精度要求高的场合可以手动指定ip link set can0 up type can tq 125 prop-seg 6 phase-seg1 7 phase-seg2 2 sjw 1我在实践中最大的体感是位时序参数看似简单实际上每个控制器的 TQ 最小值、采样率限制都不同。数据手册里的表格和内核算法并不总是一致的必须反复验证是否符合采样点要求。特别是在批量量产的设备上如果采样点和总线其他节点偏移太多总线错误率就会显著上升。6. 一条完整的系统路径从模块初始化到应用层读数据6.1 把前面的节点串成一条线我上面分别讲了模块、设备树、I2C、CAN 四个点这一节把它们串成一个完整的系统路径以一个 I2C 压力传感器 数据上报到应用层为例展开。系统真实启动的过程如下bootloader 加载内核镜像和 dtb内核启动解析设备树I2C 控制器驱动 probe注册 i2c adapterI2C 核心扫描总线设备树子节点创建 i2c_client匹配 my_sensor 驱动probe 被调用驱动初始化 regmap通过 sysfs 或字符设备接口暴露数据用户态程序读取数据或驱动主动上报中断每一步依赖前一步的正确完成。设备树路径、注册路径、匹配路径、数据路径任何一环断了设备就不工作。6.2 驱动暴露数据到用户态的三条路径字符设备接口注册cdev实现read/write/ioctl。优点控制力强缺点模板代码多。适合纯控制型设备。sysfs 属性文件在 driver 目录下创建show/store属性。开发调试最方便cat /sys/.../value就能读数据。缺点是不适合高吞吐。输入子系统/网络子系统按键用input_report_keyCAN 用netif_rx。本质上是把驱动接入内核已经定义好的抽象层应用层直接用一套通用 API。对于 I2C 传感器我一般先用 sysfs 提供快速 debug 接口后续产品化时再补字符设备或用 input 子系统。sysfs 的方式让你在验证硬件通路阶段花费最少的时间。一个 sysfs 属性的最小实现static ssize_t value_show(struct device *dev, struct device_attribute *attr, char *buf) { struct i2c_client *client to_i2c_client(dev); u8 high, low; high i2c_smbus_read_byte_data(client, 0x01); low i2c_smbus_read_byte_data(client, 0x02); return sprintf(buf, %d\n, (high 8) | low); } static DEVICE_ATTR_RO(value); static struct attribute *my_sensor_attrs[] { dev_attr_value.attr, NULL, }; ATTRIBUTE_GROUPS(my_sensor); static const struct i2c_driver my_sensor_driver { .driver { .name my_sensor, .of_match_table my_sensor_of_match, .dev_groups my_sensor_groups, }, ... };加载后在/sys/bus/i2c/devices/2-0048/目录下就有value文件。这是调试阶段最省事的验证手段。6.3 中断、延迟与并发驱动正确性的三道门槛耦合到真实硬件之后驱动开发最大的挑战不是 API 使用而是并发与延迟。中断上下文。外设中断服务程序里不能调用可能睡眠的函数比如kmalloc要带GFP_ATOMIC不能直接i2c_smbus_read_byte_data因为它可能睡眠。解决办法是中断里只做最小必要操作把耗时的读写放到 workqueue、tasklet 或 threaded irq 里处理。static irqreturn_t my_sensor_irq_handler(int irq, void *dev_id) { struct my_sensor_data *data dev_id; /* 延迟实际操作到进程上下文 */ schedule_work(data-work); return IRQ_HANDLED; }并发访问。多个用户进程可能同时打开设备、读取数据驱动需要保护共享资源。最常用的方式是 mutex 或 spinlock。选择依据是临界区是否会睡眠——如果临界区里有寄存器读写、GPIO 操作等可能睡眠的行为使用 mutex如果只是修改一个标志位用 spinlock。资源的释放顺序。probe 函数里申请的资源remove 时一定要按逆序释放。用devm_开头的资源管理 APIdevm_kzalloc、devm_gpiod_get、devm_regmap_init_i2c能在设备移除时自动释放大部分资源。这个习惯我从写第二版驱动时就开始强制自己遵守极大减少了内存泄漏和驱动卸载 panic 的问题。6.4 一个真实的调试案例I2C 探测超时有一次调一个 I2C 触摸屏现象是驱动 probe 成功但首次读寄存器时i2c_transfer返回超时。我排查的过程如下先看 dmesg确认 i2c adapter 的时钟频率设置是否有问题用 i2c-tools 在命令行直接探测设备地址i2cdetect -y 2发现设备能响应——说明硬件通路基本没问题怀疑驱动里对某个特定寄存器的读操作触发了设备异常。对照芯片手册发现读 0x00 寄存器会触发软复位设备在复位期间不应答而我 probe 里一上来就读了它改成先读设备 ID 寄存器0x01确认设备在线再执行后续初始化问题消失这类问题的手段不复杂但特别能说明一个道理硬件调试的第一前提是确认物理通路正确。如果命令行工具能正常访问设备问题基本就回到驱动逻辑或设备树配置上。自己不要直接在驱动里死磕验证链路要逐层排查。7. 工具链与开发环境GPIO、i2c-tools、配合设备树调试的效率配方7.1 开发环境的基本配置做 Linux 设备驱动开发嵌入式环境下通常使用交叉编译工具链。以 RK3568 为例SDK 里一般有aarch64-linux-gnu-开头的工具链。编译模块时 Makefile 需要指定架构和工具链前缀ARCH ? arm64 CROSS_COMPILE ? aarch64-linux-gnu- KDIR : /path/to/kernel-source如果你是 x86 平台上的驱动开发直接用本机内核头文件就行。但我仍然建议把交叉编译环境搭好因为模块最终要运行在目标设备上如果一个模块在本机编译、目标环境是 ARM那么加载时会发生明显错误——这几乎是每个初学者的必修课。7.2 i2c-tools验证硬件通路的黄金工具i2c-tools是一组命令行工具在驱动开发和调试中是神兵利器i2cdetect -l列出所有 I2C adapteri2cdetect -y bus扫描总线上的设备地址i2cget -y bus addr reg读寄存器i2cset -y bus addr reg value写寄存器i2cdump -y bus addr批量打印寄存器设备树配置完成后先用 i2c-tools 确认设备在总线上能响应再做驱动开发。这样可以把设备树配置问题和驱动代码问题分隔开减少变量。7.3 GPIO 调试与 sysfs 配合GPIO 调试同样是设备树配置后的重要环节。现代内核使用gpiodAPI配合 libgpiod 工具# 查看 GPIO 信息 gpiodetect gpioinfo # 读引脚电平 gpioget gpiochip0 4 # 设置引脚输出 gpioset gpiochip0 41设备树配置好后从用户态操作 GPIO 进行操作验证然后才进驱动。这么做省掉的 debug 时间非常可观。7.4 日志与 traceprintk 之外的第二层武器驱动运行期间dmesg是最基础的信息来源。但依赖 printk 打点太繁琐可读性也差。在内核中调试我常用这几类手段dev_info/dev_err/dev_dbg对设备节点标准封装会自动附带设备名和节点信息ftrace跟踪函数调用适合查看驱动某个路径是否被调用perf probe对内核函数动态打点CONFIG_DYNAMIC_DEBUG运行期通过 debugfs 动态开关某个文件的调试输出调试的时候不要无脑加打印。先想清楚当前想验证什么问题再选择合适手段。比如确认设备树节点是否被匹配优先看dmesg | grep -i i2c确认中断是否触发就查/proc/interrupts确认是否有死锁或调度问题再用 lockdep 和 ftrace。7.5 常见的内核配置项让调试特性开起来调试依赖内核配置下面这些选项在开发板上建议开启CONFIG_DEVTMPFS_MOUNT启动时自动挂载 /devCONFIG_DEBUG_FS支持 debugfsCONFIG_DYNAMIC_DEBUG动态调试开关CONFIG_LOCKDEP锁依赖检查CONFIG_FTRACE函数追踪没有这些配置很多调试工具就是有工具但不能用。你都看到功能调不了了却还不知道是因为内核没开选项这种挫败感我体会过太多次。所以拿到一块新板子第一件事就是确认这些调试相关配置是打开的。8. 系统裁剪优化模块怎么放进最终产物、启动时怎么加载8.1 驱动编译方式的选型编译进内核还是作为模块打通驱动以后回归到产品化阶段就要考虑驱动应该编译进内核还是以模块方式存在。编译进内核的优缺点启动后驱动立即生效不依赖根文件系统不需要处理模块签名和加载顺序缺点每次改驱动都要重新烧录内核镜像编译为模块的优缺点驱动与内核镜像解耦升级驱动不用重烧内核方便调试、热插拔缺点依赖 modules 加载机制如果根文件系统损坏、模块加载失败设备功能就不可用我的原则是核心启动链路比如 bootloader 要用的关键外设编译进内核产品功能型驱动比如触摸屏、传感器、CAN 等编译为模块放在根文件系统里由启动脚本按需加载。8.2 模块的自动加载modules-load.d、udev 与 depmod模块编译完成后要安装到目标根文件系统的/lib/modules/$(uname -r)/目录下并运行 depmod 生成依赖信息make ARCHarm64 CROSS_COMPILEaarch64-linux-gnu- modules_install INSTALL_MOD_PATH/path/to/rootfs depmod -b /path/to/rootfs系统启动时模块的加载方式主要有三种默认modules目录按照 depmod 生成的 softdep 依赖自动处理依赖模块udev根据设备热插拔事件自动加载匹配驱动用户在/etc/modules-load.d/下手动添加需要开机加载的模块在内嵌 Linux 裁剪优化工作中模块裁剪的核心是清理/lib/modules/$(uname -r)/kernel/下多余的驱动。很多人直接拷贝整个 modules 目录到根文件系统导致 rootfs 膨胀数倍。正确做法是按需拷贝用 depmod 生成最小依赖。裁剪之后务必验证boot阶段并确认没有加载失败记录。我见过一个项目为了省空间把必需的 fs-verity 模块误删了结果 rootfs 无法验证、系统直接无法启动。裁剪要严谨删模块前先用modinfo和depend信息确认模块之间的依赖关系宁多勿少留足 buffer。8.3 启动日志里的 module 验证设备启动完成后通过以下命令快速确认核心外部模块是否加载lsmod cat /proc/modules如果想更深一层确认模块参数是否生效可以查看/sys/module/name/parameters/下的文件。设备树中节点状态和驱动绑定的确认用如下命令# 查看设备树节点的当前状态 cat /proc/device-tree/xxx/status ls /sys/bus/i2c/devices/这套设备树 → 模块 → 绑定 → 功能的排查链条在量产阶段特别高效。8.4 裁剪带来的隐患你删掉的模块可能早就被硬编码依赖了系统裁剪优化有一个非常隐蔽的问题有些系统服务或核心进程会直接调用/sbin/modprobe加载某个模块而你在裁剪时如果把这个模块删了服务就会静默失败。排查手段是看多次出现modprobe: FATAL: Module xxx not found的日志。出现这种日志请仔细评估这个调用是否在某个功能路径上而不是单纯删掉日志了事。所以裁剪优化的正确姿势是先做一个标准 rootfs记录系统启动时自动加载的所有模块清单测试出每个模块的功能作用然后再定制最小集。千万别从 zero 开始删先查清楚再动作。9. 最后再分享几点实践体会做了这么久的 Linux 驱动开发有几个项目复盘后的体会一直刻在我脑子里。设备树配置导致的疑难杂症占比出奇的高。很多看起来像是驱动代码的问题追到最终都是设备树属性写错、中断号不对、引脚复用冲突。所以拿到一个新的开发板第一步永远是用店里给你的参考设备树对比你的硬件原理图逐项核对而不是直接从零写设备树。驱动代码能用和健壮之间隔着一个生产线。早期我写的驱动在自家开发板上跑得行云流水一到量产阶段就各种问题喘不过气来设备初始化时序不匹配、不同批次的芯片寄存器默认值不同、总线上多设备时的确产生竞争。后来把 probe 的失败清理机制、运行时错误恢复、回退路径都写完整产品才真的稳下来。驱动开发的度量标准不只是功能而是长时间、多设备、高负载下不崩溃。工具链和调试能力是隐性生产力。同样一个问题熟练使用 ftrace、perf、debugfs 的人可能比只用 printk 的人快十倍。设备树、i2c-tools、libgpiod、iproute2 和 Tcpdump都是驱动开发者的日常武器每个都值得花时间用好。调试的心态比技巧更重要。驱动调试有个特点现象经常是不定时发生而你手动复现时却一次都不敢发生。遇到这种问题不要急着改代码分析日志、逐步排查链路整理出可重现的条件后再定位。靠瞎猜和不停试错运气好可能碰上运气不好会浪费一个星期。Linux 设备驱动开发这条路从最小的模块到设备树节点的一个逗号再到总线上的一个 ACK 时序每一环都不难但合起来就是一门细节做功、体系吃饭的手艺。把路径走一遍再回头审视出问题的系统你会突然发现之前的很多困惑只是没看到全貌。