从内核模块到设备树:Linux I2C/CAN驱动开发完整实战指南 如果只写过几个hello_world内核模块就和别人说“我会Linux驱动开发”那大概率会被面试官一句话问住你的字符设备是怎么和硬件对应上的设备树节点又是怎么跟驱动绑定的I2C设备为什么probe没进来CAN接口为什么没生成can0这条从内核模块到设备树、再到I2C/CAN总线的完整路径才是工程师真正干活时面对的日常。这篇文章是写给刚入行嵌入式Linux、以及在野板子上被设备树折磨过的朋友的。我会结合自己在RK3568平台和几块STM32MP1板卡上的实际项目经验把“从模块代码到真实设备驱动”的完整链路串起来先讲透内核模块的局限再讲设备树如何描述硬件然后分别拆解I2C和CAN两条总线驱动的开发套路最后给一批我踩过坑、也帮你填过坑的调试细节。全程有代码、有命令、有配置直接可以跟着思路去改你的板子。1. 内核模块只是起点从hello world到真实驱动的认知转折先泼一盆冷水内核模块只是Linux驱动开发最小的一个入口它解决的是“代码能跑在核心态”的问题但解决不了“驱动怎么找到硬件、硬件怎么被抽象成设备”的问题。很多人写了几个月模块依然搞不懂为什么板子上的设备会自动生成/dev/i2c-0也解释不了probe函数到底被谁调用。这很正常因为真实驱动早就不依赖手动指定设备号了而是靠设备模型和总线自动匹配。1.1 模块的加载卸载与参数传递只是基本功内核模块最基础的结构大家应该都熟悉但细节往往被忽略。看一段我在调试时经常用的最小框架#include linux/module.h #include linux/kernel.h #include linux/init.h static int irq_num 10; module_param(irq_num, int, 0644); MODULE_PARM_DESC(irq_num, irq number for demo); static int __init demo_init(void) { pr_info(demo loaded, irq%d\n, irq_num); return 0; } static void __exit demo_exit(void) { pr_info(demo removed\n); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name);这里有两个容易被新手忽略的点。第一__init和__exit不只是装饰它们告诉内核这些函数在不使用后可以释放内存顺便把函数放到特定段里。第二module_param的权限位0644决定了这个参数在/sys/module/demo/parameters/irq_num下是否可见可写。真实驱动里这个机制常用来在加载时指定地址或配置比如触摸屏的镜像坐标参数。但模块本身并不能告诉内核“我对应哪个硬件”。你手动insmod demo.ko内核只知道有个模块被加载了不会平白无故调用你的demo_init去探测一堆硬件。要挂钩硬件必须进入Linux设备模型。1.2 为什么现代驱动依赖设备模型而不是手动mknod老式的字符设备驱动需要你指定主设备号然后mknod创建设备节点之后open(/dev/mydev)才能找到驱动。这种方式在嵌入式Linux上非常被动板子上硬件型号一变设备节点就乱了还得写脚本去创建。现代驱动走的是bus/device/driver模型。简单说device描述“有什么硬件”比如一个I2C触摸屏挂在I2C0总线上地址0x38。driver描述“怎么驱动这类硬件”比如某个驱动可以支持compatible为goodix,gt911的触摸屏。bus负责匹配这两者匹配成功后调用driver-probe(device)。所以你写的驱动不再负责创建设备节点。它会通过class_create和device_create在/dev下自动生成节点而设备信息来源就是设备树。这套模型让你写驱动时只需要关注“匹配成功之后做什么”匹配过程交给内核。设备树就是告诉你硬件长什么样的“履历表”。1.3 设备模型框架下的platform_driver在设备树普及之前很多平台用platform_device静态注册硬件信息。现在设备树时代常见的做法是把platform_driver与设备树节点绑定。看一个最典型的GPIO按键驱动骨架static const struct of_device_id demo_of_match[] { { .compatible vendor,key-ctrl, }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, demo_of_match); static struct platform_driver demo_driver { .probe demo_probe, .remove demo_remove, .driver { .name demo, .of_match_table demo_of_match, }, }; module_platform_driver(demo_driver);这里的of_match_table就是设备树和驱动之间的“签名”。当设备树里某个节点compatible vendor,key-ctrl时内核会把这个节点转成platform_device然后用这个compatible去匹配驱动的of_match_table。匹配成功就进入probe。你可以在probe里拿到设备树节点的struct device_node *np pdev-dev.of_node然后用device_property_read_u32等API去读属性。认知转折就在这写驱动的主要工作不是写read/write那套接口而是描述清楚驱动支持什么、硬件需要什么参数、以及probe之后怎么初始化硬件。搞懂了这套设备树的大门才算真正打开。2. 设备树用节点语言跟内核描述硬件设备树(DT)不是Linux发明的但Linux把它的价值发挥到了极致。它让“硬件描述”和“驱动代码”解耦同样的内核换上不同的设备树就能适配不同硬件。这也是为什么瑞芯微、全志这些平台几乎每个板子都有一堆.dts后缀的文件。2.1 dts/dtsi/dtb的关系以及编译反编译先理清几个概念.dts: 单个板级设备树源码对应一块具体开发板。.dtsi: 被公用的设备树头文件SoC厂商一般把CPU、中断控制器、串口、I2C控制器等片内外设放在.dtsi里板级.dts通过#include引入。.dtb: 编译后的二进制设备树bootloader(如U-Boot)启动时会读入并传递给内核。overlays: 设备树插件用于在不修改主dts的情况下动态添加设备常见于BeagleBone、Raspberry Pi场景。内核提供make dtbs来编译也提供了dtc工具进行反编译排查。我调试时最常用的是# 反编译dtb为可读的dts dtc -I dtb -O dts -o dumped.dts board.dtb # 单独编译某个dts make ARCHarm64 CROSS_COMPILEaarch64-linux-gnu- rk3568-evb.dtb如果dtc报语法错误一般能在生成日志里看到具体节点行号比如“Error: Label not found”。这种问题多半是引用了不存在的i2c0标签或者删除了某个节点但没有处理引用。2.2 外设节点的基本属性实例拆解设备树里每个设备节点就是一棵树上的叶子它需要告诉内核四件事设备是什么compatible、设备在哪reg、设备怎么用interrupts/pinctrl/clocks/reset等、设备有什么附加参数私有属性。以我常用的一颗I2C电容触摸屏为例假设它挂在I2C0上设备地址0x38使用中断引脚GPIO3_A5节点如下i2c0 { status okay; clock-frequency 100000; touchscreen38 { compatible goodix,gt911; reg 0x38; interrupt-parent gpio3; interrupts 5 IRQ_TYPE_LEVEL_LOW; irq-gpios gpio3 5 GPIO_ACTIVE_LOW; reset-gpios gpio0 8 GPIO_ACTIVE_LOW; touchscreen-size-x 1024; touchscreen-size-y 768; }; };这里逐个拆解compatible: 匹配驱动的关键格式一般是“厂商,型号”。reg: 设备地址对I2C从设备来说就是从机地址。注意这里的0x38和节点名字里的38只是“助记”关系真正驱动里读的是reg属性。interrupt-parent/interrupts: 中断控制器和中断号。RK3568里gpio3控制器作为中断控制器5表示GPIO3_A5对应的硬件中断源IRQ_TYPE_LEVEL_LOW是触发方式。irq-gpios/reset-gpios: 这里虽然不是标准内核属性但驱动会用devm_gpiod_get去读取名为irq和reset的GPIO。私有属性如touchscreen-size-x: 驱动通过device_property_read_u32读取。不是所有属性都必须在设备树里写死。比如clock-frequency在I2C控制器节点中配置子节点一般不需要。这需要参考内核文档Documentation/devicetree/bindings/下的对应绑定说明那里会列出每个属性是required还是optional。2.3 设备树中的pinctrl和时钟配置很多初学者在设备树里配置GPIO复用时会犯错。RK3568这类SoC每个引脚可以复用为GPIO、I2C、UART、PWM等这个复用关系靠pinctrl子系统描述。以I2C0为例SoC的dtsi里通常会定义i2c0_pins: i2c0-pins { rockchip,pins /* m0 scl */ 3 RK_PB4 1 pcfg_pull_up, /* m0 sda */ 3 RK_PB5 1 pcfg_pull_up; };然后在I2C控制器节点里pinctrl-names default; pinctrl-0 i2c0_pins;系统启动时pinctrl驱动会把GPIO3_B4和GPIO3_B5脚复用成I2C0功能并带上拉。如果你发现I2C总线没反应第一步应该是检查pinctrl-0引用的pin配置是否正确而不是马上怀疑驱动代码。时钟也是同样。I2C控制器工作时需要clkdtsi里一般已经配好clocks cru CLK_I2C0,cru PCLK_I2C0驱动里用devm_clk_get获取。如果忘记开启时钟读写寄存器时可能直接硬件总线错误。2.4 常见设备树错误自查清单我在调试时总结过几个高频错误列出来供自查现象可能原因no matching nodecompatible字符串拼写不一致或节点status不是okayprobe未调用of_match_table没写MODULE_DEVICE_TABLE或没有编译进内核GPIO申请失败pinctrl把引脚复用了或GPIO被其他驱动占用中断不触发interrupts属性类型不对或interrupt-parent指定错误时钟获取失败dtsi中node的clocks属性被覆盖或时钟名与驱动不符dtb编译报label not founddtsi里没有对应标签或在未包含头文件时直接用引用这些坑在后面的实战章节还会反复出现。3. I2C驱动开发路线从设备树节点到i2c_driver绑定设备树只是把硬件描述清楚了真正干活的是驱动。I2C驱动是嵌入式Linux里最常见的驱动类型触摸屏、温度传感器、eeprom、OLED屏清一色I2C。掌握I2C驱动的编写套路基本能覆盖半壁江山。3.1 I2C子系统的三个角色adapter/client/driverI2C子系统里有三层角色i2c_adapter: I2C控制器就是SoC里的I2C硬件模块对应/dev/i2c-X或/sys/bus/i2c/devices/i2c-X。i2c_client: 挂在某个adapter上的从设备可以理解为“一个挂在I2C总线上的物理芯片”。i2c_driver: 驱动负责和某个i2c_client匹配。当设备树里定义了i2c0 { touchscreen38 { compatible goodix,gt911; reg 0x38; } };I2C核心层会扫描I2C0总线下的子节点为每个子节点创建一个i2c_client并把它的compatible、reg等信息填好。然后总线匹配i2c_driver的id_table或of_match_table匹配成功即调用probe。这里有个关键设备树里的reg决定了硬件I2C地址驱动里不能再通过模块参数改地址只能读取client-addr。如果板子上的芯片地址被跳线改到了0x39那就去改设备树而不是改驱动代码。3.2 从零写一个I2C电容触摸驱动骨架以GT911为例驱动核心结构如下#include linux/i2c.h #include linux/interrupt.h #include linux/input.h #include linux/of_gpio.h static const struct i2c_device_id gt911_id[] { { gt911, 0 }, { } }; MODULE_DEVICE_TABLE(i2c, gt911_id); static const struct of_device_id gt911_of_match[] { { .compatible goodix,gt911 }, { } }; MODULE_DEVICE_TABLE(of, gt911_of_match); static int gt911_probe(struct i2c_client *client) { struct device *dev client-dev; int ret; /* 读取设备树配置 */ u32 max_x, max_y; device_property_read_u32(dev, touchscreen-size-x, max_x); device_property_read_u32(dev, touchscreen-size-y, max_y); /* 检查中断GPIO */ if (client-irq 0) { dev_err(dev, no irq configured\n); return -EINVAL; } /* 申请复位GPIO */ struct gpio_desc *reset; reset devm_gpiod_get(dev, reset, GPIOD_OUT_LOW); if (IS_ERR(reset)) { dev_err(dev, failed to get reset gpio\n); return PTR_ERR(reset); } /* 复位时序 */ gpiod_set_value_cansleep(reset, 1); msleep(20); gpiod_set_value_cansleep(reset, 0); msleep(20); /* 注册input设备 */ ret devm_request_threaded_irq(dev, client-irq, NULL, gt911_irq_handler, IRQF_TRIGGER_FALLING, gt911, client); if (ret) { dev_err(dev, failed to request irq\n); return ret; } return 0; } static struct i2c_driver gt911_driver { .driver { .name gt911, .of_match_table gt911_of_match, }, .probe_new gt911_probe, .id_table gt911_id, }; module_i2c_driver(gt911_driver); MODULE_LICENSE(GPL);注意几个细节devm_系列接口devm_gpiod_get、devm_request_threaded_irq会在probe失败或设备移除时自动释放资源避免手动free遗漏。probe_new是i2c_driver新版probe只接收struct i2c_client *不再传i2c_device_id更简单。真正与硬件通信通常用i2c_transfer或者i2c_smbus_read/i2c_smbus_write。GT911这类寄存器较多的设备需要先写寄存器地址再读数据我一般构造两个i2c_msgstatic int gt911_i2c_read(struct i2c_client *client, u16 reg, u8 *buf, size_t len) { u8 addr[2] { reg 8, reg 0xff }; struct i2c_msg msgs[2] { { .addr client-addr, .flags 0, .len 2, .buf addr, }, { .addr client-addr, .flags I2C_M_RD, .len len, .buf buf, } }; return i2c_transfer(client-adapter, msgs, 2); }这里为什么要拆成两个msg因为GT911这类芯片需要先发送16位寄存器地址再读回数据连续读取时需要自动增加地址。i2c_transfer的两个msg合并成一次START——写地址——数据的交互流程。如果拆成两次i2c_master_send和i2c_master_recv中间可能产生STOP芯片的地址指针就乱掉了。3.3 调试I2C设备的实用手段和坑写I2C驱动最怕的是probe成功了但读写数据不对。我推荐一套调试流程先确认总线上有没有这个设备i2cdetect -y 0扫描I2C0。如果0x38出现在列表里说明设备在物理上OK。如果没扫描到检查I2C地址是否被地址线拉到其他地址以及上拉电阻是否焊上。如果扫描到了但驱动probe没进来重点查compatible是否一致。如果probe进来但读不到正确ID用i2cdump -y 0 0x38看寄存器原始值确认设备和内核之间的读写通路。有个坑要提有些I2C从设备有地址分页机制比如SSD1306 OLED寄存器地址超过页面范围时需要通过命令切换page。你说“我明明读对了寄存器地址但数据一直不对”多半忘了先发送switch page命令。内核驱动里可以通过封装的helper函数处理避免每个函数都重复写分页逻辑。设备树方面还有一个坑是I2C控制器节点status没有设为okay默认是disabled。在SoC的dtsi里很多外设节点默认status disabled板级dts里不开总线就不会创建。我见过好几次“i2cdetect找不到设备”的原因就是没加status okay。4. CAN驱动为什么它是网络设备而不是字符设备很多人的第一反应是CAN总线驱动应该是个字符设备操作方式像串口。但如果真这么做就绕过了Linux现成的CAN协议栈。实际上Linux的SocketCAN框架把CAN控制器抽象成了网络设备用户态用socket就可以收发CAN报文。这也是CAN驱动开发里最容易懵的地方你不只是写一个驱动你是在把一个CAN控制器接入Linux网络子系统。4.1 SocketCAN框架与CAN设备树节点SocketCAN的优势是应用层可以直接用PF_CAN套接字与TCP/UDP一样的编程模型。内核中负责管理CAN控制器的驱动则是实现net_device_ops结构体并调用CAN核心层提供的一系列接口来注册设备。设备树里CAN控制器节点的写法类似其他总线节点。以rk3568的MCAN为例简化版如下can0 { status okay; assigned-clocks cru CLK_CAN0; assigned-clock-rates 200000000; pinctrl-names default; pinctrl-0 can0m0_pins; };其中assigned-clocks和assigned-clock-rates用来在驱动加载前设置CAN控制器的时钟频率。这个频率不是波特率而是控制器工作时钟。很多新人在设备树里只设置了波特率却不配时钟结果CAN外设一直报bit timing not yet defined。4.2 CAN控制器的驱动注册流程CAN控制器驱动通常依赖通用linux/can/platform.h或linux/can/dev.h我们以经典M_CAN控制器为例驱动注册大致理解成这几步通过platform_driver匹配设备树节点获取寄存器基地址和中断。完成硬件时钟使能、复位、设置时钟频率。分配struct net_devicealloc_candev(sizeof(priv), echo_skb_max)并把私有数据关联到netdev_priv(dev)。填充netdev_ops包括ndo_open、ndo_stop、ndo_start_xmit。设置CAN核心提供的接口如struct can_priv里的bittiming、state等信息。register_candev(dev)告诉网络子系统之后就会出现can0这样的接口。一个简化的ndo_open函数核心要做的是调用open_candev(dev)然后启动硬件netif_start_queue(dev)。ndo_start_xmit的职责是从skb中取出CAN帧写入控制器的发送FIFO。发送完成后要调用can_put_echo_skb、can_get_echo_skb等处理回声帧这样用户态recv才能收到自己发送的CAN报文。这些内容初看复杂但只要理解了“网络设备”这个概念代码并不神秘。内核里大量的can相关文档都在Documentation/networking/can.rst遇到疑问先翻文档。4.3 用户态can-utils与实测操作流程驱动写完并编译进内核后验证流程如下# 启动can0设备设置500kbps ip link set can0 up type can bitrate 500000 # 查看can0状态 ip -details link show can0 # 接收报文 candump can0 # 发送一帧报文 cansend can0 123#DEADBEEF注意设置bitrate之后最好用ip link set can0 down再up重新生效。有些控制器还支持triple-sampling、sample-point等参数比如ip link set can0 up type can bitrate 500000 sample-point 0.75采样点太早或太晚都会影响总线通信特别是多节点时容易出现偶发错误帧。还有个常见的坑是终端电阻。很多开发板上的CAN收发器自带120欧终端电阻但也有的需要你外接。如果板上只有单节点通信可能看着没问题一旦挂在多节点总线上没有终端电阻会导致波形反射出现大量error passive和bus off。这不是驱动问题是先检查硬件再检查代码。调试CAN时最常用的算是candump -L加上时间戳可以精确定位报文延迟。如果怀疑中断风暴用cat /proc/interrupts | grep mcan看中断次数。如果怀疑丢帧用ip -s link show can0查看TX errors和RX overruns。5. 从RK3568到量产设备树定制、时序与常见坑的实战记录前面是单项技术最后把视角拉回真实项目。RK3568这类平台开发时很多人最后卡住的地方不是怎么写驱动而是设备树某一项配置没调对。我挑几个高频又典型的实战问题来说。5.1 触摸屏横竖屏切换不只是改显示参数热搜里有个词“rk3568 触摸竖屏改为横屏设备树修改”这个问题很典型显示panel从竖屏改成横屏屏幕的framebuffer分辨率变了但触摸屏坐标没有跟着转导致点击位置映射错乱。正常的做法分两步第一步改显示设备的dts举例dsi0 { status okay; panel0 { compatible foo,lcd-1080x1920; rotate 90; // 示意某些面板驱动支持 }; };第二步改触摸屏的axis翻转。在input设备里可以用absinfo或设备树属性描述。很多触摸驱动支持touchscreen-inverted-x、touchscreen-swapped-x-y这类标准属性touchscreen-inverted-x 1; touchscreen-inverted-y 1; touchscreen-swapped-x-y 1;这里并不是所有驱动都内置支持你需要看具体驱动是否调用touchscreen_parse_properties这个helper。如果没有实现就只能在驱动里手动翻转坐标或者在用户态通过libinput的CalibrationMatrix做映射。关键教训触摸坐标旋转和显示旋转是两个层面的问题必须同时处理。改完dts后记得sync并确认内核配置里打开了CONFIG_TOUCHSCREEN_PROPERTIES。5.2 复位信号时序设备树里怎么给GPIO控制延时热搜里“linux 设备树设置复位信号时间”对应的是很多外设的reset引脚需要一定脉宽才能可靠复位。比如上面GT911例子中复位脚拉低后要等20ms再拉高然后再等20ms才能开始I2C通信。有人简单粗暴地把udelay写在probe里结果睡眠函数在原子上下文里调用msleep直接报BUG: scheduling while atomic。正确的做法是如果设备树里有reset-gpios驱动优先用devm_gpiod_get然后用gpiod_set_value_cansleep控制电平。在probe的非原子上下文中msleep没问题。如果某些驱动不是probe时序问题而是上电时就必须由外部硬件保证reset时序那可以考虑使用gpio-export和用户态脚本控制但不推荐在量产里这么干。更“设备树”的做法是用gpio的enable-active-high/gpio-keep-in-low之类的属性控制初始状态然后由驱动在需要复位时改变电平。比如有些DTS里直接定义reset-gpios gpio1 4 GPIO_ACTIVE_LOW;驱动里读到该GPIO后自然可以控制时序。问题是很多rk平台的复位脚在pinctrl里被复用成别的功能导致gpiod_get失败。遇到这种情况确保pinctrl-0里包含reset脚对应的GPIO配置而不是让SoC默认把它当PWM用。5.3 设备树移植把现有工程的外设节点搬进新工程搜索热词里还有“如何将ad9361原有设备树移到新建petalinux工程里”这类射频设备尤其容易踩坑。设备树移植不是单纯的复制粘贴CPU节点和驱动节点还需要同步确认clk引用的clkc ...标签是否在新工程里有相同定义。interrupt-parent引用的中断控制器是否一致。pinctrl的物理引脚编号是否相同。同一个SoC不同板级设计可能把SPI引脚换到了另一组引脚旧工程里的pinctrl配置直接搬过来会导致功能脚错位。涉及DMA的属性如dmas是否对应正确的DMA控制器。我建议移植设备树时先做一次“依赖扫描”把旧dts里所有引用的标签列出来和新的dtsi中的标签比对。用grep -r label arch/arm64/boot/dts/rockchip/就能找到定义。不要怕麻烦这一步能省下后面大半周的调试时间。同样的逻辑也适用于spidev设备的配置。很多开发板用spidev来驱动用户态的SPI设备需要先在dts里找到SPI控制器然后添加spi1 { status okay; spidev0 { compatible rohm,dh2228fv; reg 0; spi-max-frequency 1000000; }; };之后就可以在用户态通过/dev/spidev1.0通信。这里给个提示compatible不能用spidev给内核自带的spidev驱动匹配旧内核可以新内核限制了必须使用实际设备的compatible。选一个平台上能匹配的字符串比如rohm,dh2228fv是通用做法。5.4 内核裁剪与刷机验证的注意点驱动和设备树都改好之后进入量产验证阶段还要注意“裁剪”导致的问题。比如内核用menuconfig裁剪时如果不小心去掉了CONFIG_OF设备树解析直接无法工作去掉CONFIG_I2C那么所有I2C子系统代码都没了去掉CONFIG_CANSocketCAN也没了。每次裁剪后我都建议做一次全量make dtbs再检查/boot/config-$(uname -r)里关键配置是否被禁用。如果你是手动刷写镜像务必在uboot环境里确认dtb路径比如setenv fdtfile rk3568-evb.dtb。曾经有人内核和rootfs都刷对了结果bootloader还在加载旧的dtb最后设备树改动怎么都不生效。检查顺序是bootloader env - dtb文件是否存在于boot分区 - 内核是否包含对应config。刷机验证模块时能编成.ko就尽量编成.ko方便反复insmod/rmmod不用每次刷整个内核。设备树改动则一般需要重新刷dtb并重启。在开发阶段我习惯用网络挂载的方式tftp加载dtbNFS根文件系统改完立即重启测试效率远高于烧卡。最后再说一点心里话很多人问Linux驱动开发到底难在哪难的不是语法也不是BUG而是你始终要同时面对“代码逻辑”和“硬件电气特性”两层现实。设备树就是一个把两者粘起来的胶水层理解它之后I2C、CAN、SPI这些总线驱动其实都遵循同一个套路先看硬件原理图再写设备树节点然后套对应的驱动框架。我在实际项目里最大的体会是驱动代码通常花不了太多时间真正占时间的是定位“为什么probe没进”、“为什么波形不对”、“为什么寄存器读出来是全FF”。这些排查能力没有捷径只能靠摸板子、看文档、多踩坑积累。如果你手头正好在调RK3568或者其他带CAN的SoC建议先花半小时读一下内核Documentation/devicetree/bindings/net/can/目录下的绑定文档再回来改dts踩坑概率至少小一半。设备树写错了内核不怕怕的是你不看文档就乱引用标签。把这一条路径走通基本就到了能独立接驱动开发任务的阶段了。