从内核模块到设备树、I2C/CAN:嵌入式Linux驱动开发核心路径解析
2026/9/14 1:47:54 网站建设 项目流程

1. 从一次“驱动水土不服”说起:为什么要理清整条系统路径

我最早接触嵌入式Linux驱动时,最大的困惑不是“怎么写代码”,而是“代码写好了,硬件为什么不动”。辛辛苦苦写完一个字符设备驱动,insmod之后/dev下什么也没有;费尽心思把设备树节点补上,probe函数却压根不被调用;改了几个寄存器地址,系统启动直接panic。当时花了一整周排查,最后才发现问题是设备树里的compatible字段和驱动里的of_match_table写岔了一个字母。

后来回头看,这类问题几乎每个做嵌入式Linux开发的人都会遇到。原因很简单:在Linux里写设备驱动,不是在“写一个控制硬件的程序”,而是在“把一块硬件正确地挂接进一套早已设计好的抽象体系里”。这套体系的路径就是从内核模块开始,经过设备模型、设备树描述,最终落到I2C、CAN这类具体总线子系统的通信上。这个路径不梳理清楚,驱动开发永远是“对着register瞎填,跑起来靠运气”。

标题里这条“从内核模块到设备树、I2C/CAN的系统路径”,恰恰点出了Linux驱动开发最核心的主线。这篇文章我想把这条路从头到尾走一遍:先讲清楚每个环节管什么、为什么要这么设计,再用实际案例把从模块加载到总线通信的完整链路串起来。适合刚入门的嵌入式Linux工程师、从单片机转Linux开发的同行,以及那种“驱动能跑但不知道为啥能跑”的朋友。

2. 驱动开发的地基:内核模块与设备驱动模型

2.1 模块加载的完整生命周期

内核模块是Linux驱动最常见的载体,原因很朴素:Linux内核是宏内核,如果每加一个驱动都要重新编译整个内核,开发效率会低到无法接受。模块机制允许把驱动编译成独立的.ko文件,运行时用insmod装进去,rmmod卸掉,调试和迭代都快得多。

一个最简模块的骨架是这样的:

#include <linux/init.h> #include <linux/module.h> #include <linux/kernel.h> static int __init demo_init(void) { printk(KERN_INFO "demo module loaded\n"); return 0; } static void __exit demo_exit(void) { printk(KERN_INFO "demo module unloaded\n"); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("A simple demo module");

代码本身不复杂,但我建议每个初学者都去搞清楚模块加载时的完整生命周期,因为很多诡异问题就出在这些看似“约定俗成”的细节里。

加载一个模块时,内核实际做的事情远不止调用module_init这个函数。首先是文件系统层的处理:insmod把.ko文件读入内存,调用init_module系统调用,内核会先做模块依赖解析(如果有依赖的符号需要从别的模块导入)、版本校验(modversions机制)、重定位(类似用户态动态链接库的符号解析),然后才执行模块的初始化函数。如果模块里用了export_symbol导出的符号,而提供这个符号的模块还没加载,insmod会直接报unknown symbol错误。

卸载流程也很考验功夫:rmmod会先调用module_exit,然后做资源清理和符号回收。如果模块在exit函数里忘了释放某个资源,或者有进程还在使用这个模块创建的设备节点,卸载就会失败或者留下悬空指针。这里有两个很实用的排查经验:内核日志里出现rmmod: module is in use,先检查是不是有进程占用了设备文件;出现slab erroruse-after-free,基本可以断定是exit路径上资源释放顺序写错了。

2.2 设备驱动模型:kobject、设备与驱动是怎么对上的

很多初学者写驱动时,心里装的全是register_chrdev、ioremap、copy_to_user这些API,但对设备驱动模型本身没有概念,导致遇到“probe为什么不执行”这类问题时完全无从下手。实际上,理解设备驱动模型才是理解整个Linux驱动体系的钥匙。

Linux的设备驱动模型以kobject为地基,向上构建了三层核心对象:设备(device)、驱动(driver)、总线(bus)。这三者的关系可以类比成生活中的插座体系:设备是电器,驱动是适配电器规格的插座设计图纸,总线是墙里的电线。电器要工作,必须插到符合自己规格的插座上,也就是设备必须挂在匹配的总线上。

内核里的device和driver通过总线匹配起来,匹配成功后触发driver的probe回调。平台设备(platform device)挂在platform总线上,I2C设备挂在I2C总线上,SPI设备挂在SPI总线上。每种总线都有一套自己的匹配规则,但大原则是一样的:设备端声明“我是什么”,驱动端声明“我支持什么”,两边对上了就握手成功。

理解这个模型最大的价值在于:遇到probe不执行,至少知道沿着“设备树节点有没有正确创建device→device有没有注册到对应总线→driver有没有注册到同一条总线→compatible/fdt匹配规则有没有命中”这条链路去排查,而不是在代码里瞎打printk。

3. 设备树:硬件拓扑的“说明书”与驱动解耦的关键

3.1 设备树到底在解决什么问题

在没有设备树的时代,板级文件(board file)里每加一个新硬件,都要改arch/arm/mach-xxx/目录下的C代码,把设备信息硬编码进去。换一块板子,硬件资源不同,就要重新编译内核。设备树(Device Tree,简称DT)本质上就是把“硬件长什么样”从内核代码里剥离出来,变成一份独立的描述文件,内核启动时解析这份文件,动态创建设备。

用大白话说:设备树是一张硬件“配置单”,告诉内核“我这个板子上有哪些设备、它们挂在哪个控制器上、寄存器地址在哪里、中断号是多少、复位脚是哪一个”。设备树出现后,同一份内核镜像可以跑在多种板子上,只需要更换对应的.dtb文件。

我在实际项目中最常用到的设备树场景有两类:一类是修改现有节点属性(比如把某个UART的时钟频率改掉、把某个GPIO的中断触发方式从上升沿改成下降沿),另一类是新增自定义设备节点(比如外挂一个传感器、一个LCD屏控制器)。前者考验你对现有dtsi(设备树包含文件)结构的熟悉程度,后者考验你对of(Open Firmware)接口的理解。

3.2 从dtsi到dtb的编译与追加节点技巧

设备树源文件以.dts和.dtsi为扩展名,dtsi是公共头文件,dts是具体板级文件。编译流程是:dtc编译器把dts/dtsi编译成dtb二进制,内核启动时由bootloader加载dtb到内存,把地址传给内核。

一个最容易被忽略的点是:改完dts文件后,必须重新编译dtb,并且确认bootloader加载的是新编译出来的dtb,而不是缓存的旧文件。我在RK3568平台上踩过这个坑,改了设备树想启用CAN,结果系统起来后死活看不到can0节点,折腾半天发现bootloader从固定分区读的是老dtb。这种问题跟驱动代码没关系,纯粹是编译产物没更新到位。

设备树文件有几个高频操作,值得展开说说。

新增一个I2C设备的节点,模板如下:

&i2c1 { status = "okay"; clock-frequency = <100000>; ssd1306: ssd1306@3c { compatible = "solomon,ssd1306"; reg = <0x3c>; reset-gpios = <&gpio4 5 GPIO_ACTIVE_LOW>; width = <128>; height = <64>; }; };

这里compatible字段将来要和驱动里的of_match_table匹配,reg是I2C从设备地址。reset-gpios引用了GPIO控制器下的具体引脚。width和height这种自定义属性是给驱动用的,用of_property_read_u32就可以读出来。

节点追加和修改的语法要熟练:用&label { ... };的方式可以给已存在的节点追加内容或修改属性,用delete-nodedelete-property可以删除。比如要禁用某块板子用不到的CAN控制器:

&can1 { status = "disabled"; };

很多新手最懵的是status属性。设备树里设备节点默认能使能也可能默认禁用,具体看SoC厂商的dtsi怎么写。改完status之后还要确认时钟、引脚复用(pinctrl)有没有配置好。CAN控制器在RK3568上需要把对应引脚mux成can功能,这个过程是pinctrl子系统在驱动probe时自动完成的,但它依赖设备树里的pinctrl-0属性:

&can1 { status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&can1m0_pins>; };

3.3 设备树里的复位时序与GPIO操作:一个真实案例

热词里有人搜“linux 设备树设置复位信号时间”,这个点很典型,恰好可以展开说。

很多外设芯片上电后需要复位脚拉低、保持一段时间、再拉高,然后才能正常工作。设备树里通常只描述了“复位脚是哪个GPIO”,具体时序是驱动里用gpiod_set_value配合udelay或usleep_range实现的。但有些芯片对时序要求极严,或者驱动写得不严谨,就会出问题。

以SSD1306 OLED驱动为例,正常情况下复位时序是:拉低→延时10ms以上→拉高→延时100ms以上。如果延时不够,屏幕可能初始化失败或者显示花屏。设备树里能做的,是通过reset-gpios属性告诉驱动复位脚是哪个,剩下的时序逻辑必须在驱动代码里处理。

如果你遇到“复位信号时间不对导致设备不稳定”的问题,排查重点不是设备树,而是驱动里的延时逻辑。这里有个容易踩的坑:udelay在原子上下文(比如spinlock保护区域)里是忙等,没问题;但在正常进程上下文里,超过1ms的延时应该用usleep_range,不然会白白占用CPU。很多花屏、通信失败的bug,最后查下来就是延时函数用错了。

3.4 设备树调试三板斧

设备树写得好不好,直接影响驱动开发的进度。我总结了一套调试三板斧,可以节省大量时间。

先看设备树有没有被正确解析。内核启动日志里搜索of_unittest或直接查看/proc/device-tree目录,如果节点正常,该目录下应该有对应的子目录和属性文件。没有的话,dtb没加载对或者节点被禁用了。

再看具体设备有没有被创建。挂到总线上的设备在/sys/bus/platform/devices//sys/bus/i2c/devices/这些目录下能看到符号链接。看不到设备文件,说明设备树解析阶段就出了问题。

最后看probe有没有执行。如果设备存在、驱动也加载了,probe还是不执行,检查compatible是否匹配。用下面的命令可以查看设备的compatible信息:

cat /proc/device-tree/i2c1/ssd1306@3c/compatible

如果输出和驱动of_match_table里的字符串不一致,probe永远不会执行。这个错误看起来低级,但确实常见,因为很多人是从别处复制设备树节点,没注意型号差异就换了主控平台。

4. I2C子系统实例:从probe到用户态测试

4.1 I2C核心、总线适配器与从设备驱动

I2C总线大概是嵌入式Linux里最简单也最常用的总线了。它的架构分三层:I2C核心(i2c-core)、I2C适配器(adapter,即I2C控制器驱动)、I2C从设备驱动(client driver)。

I2C核心提供注册接口、数据传输helper函数和sysfs属性管理。适配器抽象了物理I2C控制器的读写能力,它向上表现为一个i2c_adapter实例,注册到内核后,所有挂在这条总线上的client才能通过它发起传输。从设备驱动则是我们一般写的驱动,它要做的是:声明自己支持的compatible和I2C设备ID,在probe里初始化设备,在remove里释放资源,通过i2c_transfer或i2c_smbus系列函数和设备通信。

很多初学者搞不清i2c_add_driver、i2c_register_driver、i2c_new_device、i2c_new_client_device这些API的差别。简单说:i2c_add_driver是驱动侧注册,i2c_new_device是总线侧创建设备。设备树被解析后,内核会为每个带compatible的I2C子节点创建i2c_client,然后在总线上做匹配。

4.2 手写一个SSD1306 OLED设备驱动:完整代码与关键解析

SSD1306是一个很经典的I2C接口OLED控制芯片,作为驱动开发练手项目非常合适,因为它寄存器简单、读写逻辑直观,而且能直接看到驱动效果。

完整的驱动代码框架如下:

#include <linux/module.h> #include <linux/kernel.h> #include <linux/i2c.h> #include <linux/delay.h> #include <linux/gpio/consumer.h> #include <linux/of_device.h> #define SSD1306_I2C_ADDR 0x3C #define SSD1306_CMD_MODE 0x00 #define SSD1306_DATA_MODE 0x40 struct ssd1306_dev { struct i2c_client *client; struct gpio_desc *reset_gpio; u32 width; u32 height; }; static int ssd1306_write_cmd(struct ssd1306_dev *ssd, u8 cmd) { u8 buf[2] = { SSD1306_CMD_MODE, cmd }; return i2c_master_send(ssd->client, buf, 2); } static int ssd1306_write_data(struct ssd1306_dev *ssd, u8 *data, size_t len) { u8 *buf; int ret; buf = kmalloc(len + 1, GFP_KERNEL); if (!buf) return -ENOMEM; buf[0] = SSD1306_DATA_MODE; memcpy(buf + 1, data, len); ret = i2c_master_send(ssd->client, buf, len + 1); kfree(buf); return ret; } static int ssd1306_init(struct ssd1306_dev *ssd) { if (ssd->reset_gpio) { gpiod_set_value_cansleep(ssd->reset_gpio, 0); usleep_range(10000, 15000); gpiod_set_value_cansleep(ssd->reset_gpio, 1); usleep_range(100000, 120000); } ssd1306_write_cmd(ssd, 0xAE); ssd1306_write_cmd(ssd, 0x20); ssd1306_write_cmd(ssd, 0x00); ssd1306_write_cmd(ssd, 0x8D); ssd1306_write_cmd(ssd, 0x14); ssd1306_write_cmd(ssd, 0xAF); return 0; } static int ssd1306_probe(struct i2c_client *client) { struct device *dev = &client->dev; struct ssd1306_dev *ssd; int ret; ssd = devm_kzalloc(dev, sizeof(*ssd), GFP_KERNEL); if (!ssd) return -ENOMEM; ssd->client = client; ssd->reset_gpio = devm_gpiod_get_optional(dev, "reset", GPIOD_OUT_HIGH); of_property_read_u32(dev->of_node, "width", &ssd->width); of_property_read_u32(dev->of_node, "height", &ssd->height); ret = ssd1306_init(ssd); if (ret) return ret; i2c_set_clientdata(client, ssd); dev_info(dev, "ssd1306 initialized, %ux%u\n", ssd->width, ssd->height); return 0; } static void ssd1306_remove(struct i2c_client *client) { struct ssd1306_dev *ssd = i2c_get_clientdata(client); ssd1306_write_cmd(ssd, 0xAE); } 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_LICENSE("GPL");

代码核心要点如下:

probe里的devm_系列函数是managed device resource机制,好处是资源不手动释放也不会泄漏,remove阶段内核会自动回收,省掉了很多麻烦。devm_gpiod_get_optional中的optional意味着设备树里没有reset-gpios节点时,驱动也能正常跑,不会因为缺少可选资源而报错。

i2c_master_send会自动处理I2C起始条件、地址、ACK等协议细节,驱动只需要关注数据本身。这里地址没有额外左移一位是因为i2c-master框架会自动做地址转换,你传0x3C就是7位地址0x3C。

module_i2c_driver宏把i2c_add_driver和i2c_del_driver都封装好了,比手动调用省事且不容易漏。

4.3 I2C开发中常见的5个坑

接触I2C驱动这段时间,踩过的坑和看别人踩的坑加起来,最典型的是下面这些。

第一个坑是7位地址和8位地址混淆。SSD1306的7位地址是0x3C,左移一位后的写地址是0x78。在设备树里写reg=<0x3C>,在裸机代码里直接用0x78,两边对不上就会一直通信失败。注意检查你手上的参考代码是Linux驱动还是裸机程序。

第二个坑是I2C总线时钟频率不匹配。设备树clock-frequency属性控制总线速率,标准模式100kHz,快速模式400kHz。如果外设芯片最高只支持100kHz,你把总线配成400kHz,偶发通信错误会非常难查,因为不是必现,往往和信号质量、线路长度都有关系。

第三个坑是ACK错误和NACK错误分不清。i2c_transfer返回负的错误码,-EIO通常是总线问题,-ENXIO通常是从设备无应答。检查思路完全不一样:前者查线路、电平、上拉电阻;后者查地址、供电、芯片是否处于异常状态。

第四个坑是probe函数里做太多阻塞操作。SSD1306这种简单设备还好,如果probe里有几十毫秒甚至上百毫秒的延时,会影响系统启动速度。更危险的是如果probe里调用了会睡眠的函数(比如i2c_transfer本身会睡眠),但在原子上下文(比如某些回调里)调用,就会触发内核警告甚至死锁。

第五个坑是调试时不知道该看哪个日志。I2C子系统在内核配置里打开CONFIG_I2C_CHARDEV和CONFIG_I2C_DEBUG_BUS后,能看到很多控制器层面的传输日志。配合i2cdetect、i2cget、i2cset这些用户态工具,调试效率会高很多。

5. CAN子系统:从设备树配置到收发链路

5.1 CAN和I2C的本质差异

CAN(Controller Area Network)总线和I2C虽然都叫总线,但设计哲学完全不同。I2C是同步串行总线,主机主动发起通信,从机被动响应,典型的“中心化”模型;CAN是异步半双工总线,任何节点都能主动发消息,靠报文ID的优先级做仲裁,典型的“去中心化”模型。

这个差异决定了驱动开发的关注点完全不同:I2C驱动关心怎么发起一次传输、地址对不对、时序是否满足;CAN驱动关心怎么配置波特率、怎么处理错误帧、怎么实现过滤、怎样保证实时性。CAN还天生带错误检测机制,有错误主动、错误被动、总线关闭几种状态,驱动里可以选择注册错误中断来感知总线健康状况。

在Linux里,CAN设备被抽象成网络设备(netdevice),用SocketCAN接口操作。用户态通过socket(AF_CAN, SOCK_RAW, CAN_RAW)就能收发报文,驱动层核心工作是管理CAN控制器硬件,实现网络设备操作集(ndo_open、ndo_stop、ndo_start_xmit)。

5.2 MCP2515在SPI下的设备树配置

很多主控没有内置CAN控制器,或者CAN控制器数量不够用,这时常见做法是外挂MCP2515这种SPI转CAN芯片。这类芯片的设备树配置需要同时处理SPI子系统和CAN子系统两个层面。

在Linux内核里,MCP2515驱动已经在内核源码里了(drivers/net/can/spi/mcp251x.c),我们主要工作是写设备树节点。以RK3568平台为例:

&spi1 { status = "okay"; mcp2515: mcp2515@0 { compatible = "microchip,mcp2515"; reg = <0>; spi-max-frequency = <10000000>; clocks = <&cru CLK_MCP2515>; interrupt-parent = <&gpio3>; interrupts = <6 IRQ_TYPE_EDGE_FALLING>; pinctrl-names = "default"; pinctrl-0 = <&mcp2515_irq_pin>; }; };

几个关键属性要仔细解释:reg=<0>表示这是SPI控制器下的第几个片选(chip select),如果硬件上接了CS0就填0。spi-max-frequency取决于MCP2515的晶振和SPI控制器的能力,一般10MHz比较安全,太高可能出现SPI传输错误。interrupt-parent和interrupts描述芯片的INT引脚接到主控的哪个GPIO、什么触发方式。clocks属性用来提供外部晶振频率,某些平台还要单独在驱动里配置晶振频率参数。

MCP2515驱动里还有一个经常需要调的地方:晶振晶振频率和波特率。比如8MHz晶振下,要得到500kbps的CAN波特率,驱动会根据控制器寄存器取值自动计算分频和位时序。

5.3 SocketCAN用户态操作与实测

设备树配置好、驱动加载成功后,在用户态操作CAN比很多人想象中简单:

# 启用CAN设备 sudo ip link set can0 up type can bitrate 500000 # 查看CAN设备状态 ip -details link show can0 # 接收CAN报文(candump) candump can0 # 发送CAN报文(cansend) cansend can0 123#DEADBEEF

用ip命令初始化时要注意一点:bitrate必须和设备树里MCP2515外接晶振的搭配能整除出整数分频系数,否则ip命令会报错或者通信不稳定。如果板子上用的是16MHz晶振,配置bitrate 500000通常没问题;如果晶振频率非常规(比如12MHz),你可能需要选用特定的采样点参数。

判断CAN链路是否正常有个非常实用的命令组合:

ip -details link show can0

看输出里的state,如果显示ERROR-ACTIVE,说明硬件链路正常;如果显示BUS-OFF,说明总线错误太多,控制器已经退出总线了。配合candump -e can0能看到错误帧信息,错误帧数量突增往往意味着总线两端波特率不一致或者物理层存在问题。

5.4 CAN驱动的调试心得

CAN驱动的坑主要集中在三个方面。

一是波特率配置。同一总线上的所有节点波特率必须完全一致,包括采样点位置(sample point)也有讲究。采样点决定了一个位时间里在哪个位置采样电平,布线复杂或节点多的总线,采样点设在75%-87.5%比较稳。SocketCAN在ip命令里支持通过-S选项指定采样点,但前提是驱动实现了相关接口。

二是中断处理。MCP2515的中断引脚是边沿触发的,如果驱动里中断处理函数做了过多阻塞操作(比如通过SPI同步读取多个寄存器),在高负载下会丢中断。调试时如果发现偶发收不到报文,可以先用candump -t a看时间戳,判断是接收链路问题还是应用层问题。

三是错误注入与恢复。CAN驱动开发中最难受的场景是总线关闭后无法自动恢复。驱动层面要确保can_restart机制开启(can_restart相关配置由netlink接口设置),同时建议外设芯片的中断引脚必须配置正确,因为bus-off恢复通常依赖中断唤醒。

6. 常见问题与排查技巧实录

写驱动这几年,复盘下来真正见效的排查套路其实很固定。整理成表格方便查阅:

问题现象可能原因排查手段
insmod报unknown symbol依赖模块未加载或符号未导出检查模块依赖,用modinfo查看依赖
/dev下没有设备节点设备号注册失败或udev规则缺失dmesg看注册日志,检查主设备号
probe函数不执行compatible不匹配或设备未创建查/proc/device-tree下的compatible
I2C传输返回-ENXIO从设备无应答,地址错误或芯片异常i2cdetect扫描总线地址
I2C传输返回-EIO总线仲裁失败或线路问题查上拉电阻、总线电容
CAN链路BUS-OFF波特率不一致或物理层短路/断路对比各节点波特率,查终端电阻
模块卸载崩溃资源释放顺序错误或并发访问未处理code review配KASAN/KCSAN检测

除了表格里的场景,还有几个独家心得值得分享。

第一个心得是“让设备树背锅之前,先确认它真的被加载了”。很多设备树问题,其实是bootloader没把dtb传给内核,或者传给的是旧版本。我的排查顺序永远是:启动日志看DTS版本号 → /proc/device-tree确认节点 → /sys/bus/xxx/devices确认设备 → 最后才怀疑驱动代码。

第二个心得是“善用devicetree的overlay机制做快速验证”。如果不想每次改dts都重新编译dtb、烧写分区,可以尝试用设备树overlay(叠加层)在运行时动态加载设备配置。这在内核配置里打开CONFIG_OF_OVERLAY之后,配合configfs就能用,对原型验证和调试非常有用。

第三个心得是“驱动出问题先查内核配置”。设备树节点写得再好,驱动代码写得再对,如果内核没编译对应的驱动模块或没打开对应的总线子系统,一切都是白搭。RK3568上调试CAN,忘了在menuconfig里选中MCP2515驱动,设备树配置再正确,CAN设备也起不来。每次开始调试前,先花五分钟确认相关配置:

zcat /proc/config.gz | grep MCP2515 zcat /proc/config.gz | grep I2C

第四个心得是“重视GPIO复用和pinctrl”。嵌入式平台上最常见的一类“驱动没反应”问题,其实是引脚mux错了:外设确实注册成功了,probe也执行了,但引脚被复用成了别的功能,信号根本出不去。排查这类问题时,用下面的命令查看引脚当前状态:

cat /sys/kernel/debug/pinctrl/pinctrl-handles cat /sys/kernel/debug/gpio

如果发现引脚状态和预期不符,检查设备树里的pinctrl-0配置是否覆盖到了对应引脚。

7. 从这条路径中得到的核心经验

回头再看这篇文章的标题,“从内核模块到设备树、I2C/CAN的系统路径”,其实不只是技术路线,更是一种解决问题的思维方式。

我自己的经验是:写Linux驱动不能只盯着内核API和寄存器,必须先建立整机视角。模块机制管的是“驱动代码怎么被内核接纳”,设备模型管的是“设备和驱动怎么握手”,设备树管的是“硬件资源怎么被描述”,I2C/CAN子系统管的是“具体业务数据怎么和设备交换”。每一层解决一个问题,每一层也都有自己的一套调试工具和排查方法论。出问题时,顺着这条路径逐层排错,远比在单点里死磕高效。

还有一个很深的感受:设备树和驱动代码的边界,往往就是业务稳定性的边界。设备树描述改动频繁,一定要做好版本管理;驱动代码影响范围大,合入前要谨慎测试。在嵌入式Linux团队里,设备树review不严谨导致的硬件配置事故,比驱动代码bug导致的问题多得多。

这篇文章基于我在RK3568等主流平台上的实际开发经验写成,部分内容和代码示例是常见实践的组合与总结。Linux驱动开发的路还很长,设备树、I2C、CAN只是其中最基础的一段,后面还有PCIe、USB、网络驱动、中断子系统、DMA框架等着去啃。希望这篇文章能帮你把这条系统路径上的每一个节点都走通,少踩几个我踩过的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询