
从出租车到无人车老司机手把手带你走通嵌入式Linux驱动这条主线写这篇文章的起因其实挺简单——最近连续收到几个私信都在问同一个问题嵌入式Linux驱动到底该怎么入门有人翻了好几周的《Linux设备驱动开发详解》PDF越看越懵因为书里讲的是2.6内核时代的写法和现在实际项目里的设备树、I2C、CAN完全是两套思路还有人照着网上的教程复制了helloworld模块编译倒是通过了加载也成功了但真到了调一块传感器的驱动就不知道下一步该怎么走。这个困惑我很理解。我自己刚入行时也在这个坎上卡了很久。后来带过几届新人、维护过几套量产固件、从内核模块一路改到设备树再到I2C/CAN总线才慢慢把整条脉络理清楚。这行当的实际情况是你真正需要的不是某一本教材而是一条从内核模块到设备树、再到I2C/CAN总线的完整路径以及这条路径上每一步为什么要这么走的逻辑。这篇文章就是专门来讲这条路径的适合正在入门嵌入式Linux驱动开发、或者已经写了一些模块但想往更深层次走的同学。1. 内核模块驱动开发的地基1.1 为什么非要先写一个hello模块很多初学者会问现在的驱动不都是写在设备树里的吗为什么还要从内核模块开始这个问法本身就有问题。设备树负责描述硬件的存在和连接方式真正的行为逻辑仍然是要靠内核代码来执行的。设备树只是给驱动提供了一个入场券和参数表驱动里怎么实现读、写、中断、DMA这些操作仍然完全是由C代码决定的。所以说内核模块是驱动开发的地基。它决定了你能不能跑通编译-加载-运行-卸载这套基本流程也决定了你对内核运行时的理解程度。连模块都写不明白后面不管去调I2C还是CAN都会有一种脚下没根的感觉。先看一段最基础的内核模块代码#include linux/init.h #include linux/module.h #include linux/kernel.h static int __init hello_init(void) { pr_info(hello module loaded!\n); return 0; } static void __exit hello_exit(void) { pr_info(hello module unloaded!\n); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple hello module);这段代码的每个要素背后都有讲究__init和__exit宏__init标记的函数在加载完成后会被释放掉因为再也不需要它了。内核是常驻内存的能省一点是一点这是嵌入式系统裁剪优化的基本思路在模块层面的体现。module_init和module_exit这是内核模块的入口和出口注册机制。加载时执行init指向的函数卸载时执行exit指向的函数这是一个模块能活着的基本契约。MODULE_LICENSE(GPL)没有这行模块可以编译但不会自动加载会提示与内核许可证不匹配。实际项目里如果用了非GPL许可的第三方库这一行经常是法律和工程的双重敏感点。很多人以为模块写完了就万事大吉其实编译这一步的水可比想象中深。1.2 编译Makefile的正确姿势内核模块的编译和平常的应用程序完全不一样。它不能在宿主机的glibc环境下直接编译必须用内核构建系统来编译而且必须使用与目标内核版本、交叉编译工具链完全匹配的配置。obj-m : hello.o KERNELDIR ? /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KERNELDIR) M$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M$(PWD) clean这套Makefile在PC上验证hello模块完全够用但拿到嵌入式平台上就要改两个地方KERNELDIR要指向你交叉编译的那份内核源码树比如~/rk3568/kernel还需要指定交叉编译工具链前缀比如ARCHarm64 CROSS_COMPILEaarch64-linux-gnu-。我在帮一个同事排查的时候遇到过这样的事他不小心用了PC上的内核头文件编译I2C驱动结果加载到板子上爆了一堆version magic错误。这个错误看起来吓人本质上就是你在PC上编的东西和板子上跑的kernel不是同一个。遇到这个问题先别慌它是新手最常踩的坑之一解决办法也很标准把KERNELDIR指对重新编译。1.3 加载和调试的常规套路模块编译出来后常规的加载和调试步骤是insmod hello.ko加载模块执行init函数。rmmod hello卸载模块执行exit函数。lsmod查看已加载的模块列表。dmesg | tail查看内核打印信息模块的pr_info输出都是去这里看的。这里有一个实际工作中很容易踩的坑printk的日志级别。调试时在驱动里写printk或者pr_info如果dmesg里看不到任何输出不代表你的代码没走可能是console loglevel设置太高把INFO级别给过滤了。可以临时用echo 8 /proc/sys/kernel/printk把日志级别拉满再看。2. 设备树让内核知道你接了什么2.1 设备树到底在解决什么问题设备树Device TreeDT在嵌入式Linux里是一种描述硬件的机制。它的出现和ARM Linux的演进历史紧密相关以前的内核里每一块板卡都堆了一大堆arch/arm/mach-xxx下的板级代码谁接了什么样子的硬件全靠写死的C宏和板文件来描述。换一块核心板哪怕是同一个SoC板文件可能都要重写。设备树把硬件是什么从内核代码里剥离开来变成一份独立的数据文件。内核在启动时读取这份文件按照里面的描述去匹配驱动、注册平台设备和各类总线设备。用个不太精确但好理解的类比设备树是硬件的户籍档案每个外设的地址、中断号、时钟、引脚这些关键信息都登记在这个档案里。驱动不需要关心档案是怎么填的它只负责在档案里找到自己对应的那一条记录然后照着做事情。2.2 设备树的基本语法和关键节点设备树文件是一棵树形结构根节点是/下面挂各种子节点。最常见的几个节点结构大概是这样的/dts-v1/; / { model MyEmbedded Board; compatible myvendor,myboard; chosen { stdout-path uart0; }; leds { compatible gpio-leds; led0 { label power; gpios gpio4 20 GPIO_ACTIVE_HIGH; }; }; i2c0: i2cff190000 { compatible snps,designware-i2c; reg 0x0 0xff190000 0x0 0x1000; interrupts 0 28 4; clocks cru PCLK_I2C0; clock-names pclk; #address-cells 1; #size-cells 0; ssd1306: oled3c { compatible solomon,ssd1306; reg 0x3c; }; }; };这段dts虽然简短但已经包含了设备树几乎所有核心属性compatible这是内核匹配驱动的关键字符串。匹配规则是子串匹配驱动侧只要注册了含有同一个字符串的of_match_table条目就能被这个节点唤醒。这是我们做驱动移植时最常检查的一个字段。reg描述节点的寄存器或者设备地址。对于平台设备来说它就是寄存器区的起始地址和长度。interrupts中断号、触发类型。不同SoC对中断号的编码不太一样通常是中断类型 中断号 触发方式这样的结构。clocks和clock-names描述设备需要使用的外部时钟。这个东西非常容易漏漏了之后驱动会挂在时钟请求上。设备树里还有一个经常被问到的细节#address-cells和#size-cells。它们是用来描述子节点里reg属性占用几个cell的。i2c子节点的reg 0x3c只占一个cell所以#size-cells 0地址单元一个cell没有长度字段。这个如果不匹配地址解析就会出错这也是很隐蔽的低级错误。2.3 设备树编译与加载设备树源文件.dts在构建系统里会通过dtcDevice Tree Compiler编译成二进制的.dtb文件。编译之后就烧写到boot分区或者独立的dtb分区里。调试时如果不想每次烧写固件U-Boot也支持把dtb放到SD卡或内存的特定位置加载。在实际项目中我发现一个比较常见的流程是改dts → 单独编译dtb → 通过U-Boot的tftp或者load mmc命令加载新的dtb →boot。这种迭代方式比整个烧固件快得多尤其是在做设备树调试的时候特别重要。因为改一个引脚复用、改一个I2C使能动不动就要反复试验如果每次都全量烧写固件一天根本改不了几次。2.4 设备树相关的坑引脚复用的问题。很多人搞了半天驱动不工作最后发现是引脚复用没配置对。SoC里同一组引脚往往有好几个功能可复用设备树里需要用pinctrl-0来指定当前这个场景下要启用哪个功能。比如你搞I2C驱动但实际上这组引脚的复用配置还在UART模式I2C控制器根本没法正常工作。复位信号时间的问题。热搜词里有人问linux设备树设置复位信号时间这个我猜多半是在调reset引脚。设备树里申请reset GPIO然后拉高拉低驱动里控制时序的方式一般是reset-gpios gpio3 10 GPIO_ACTIVE_LOW; reset-deassert-us 10000;驱动通过devm_gpiod_getgpiod_set_value来控制复位reset-deassert-us是描述复位释放后需要延时多久才能访问设备。不同器件对这个时间的要求差异很大有的几微妙有的要几毫秒实际调起来就是一个字——试。靠谱的办法是先看器件手册上的Reset Timing那一页再在驱动里加延时验证。2.5 拿RK3568设备树举个实际例子现在很多项目用的是瑞芯微RK3568这颗SoC它设备树里的引脚复用信息写得比较规范也很适合拿来学习。比如把触摸从竖屏改成横屏这样的需求很多人会去搜RK3568触摸竖屏改横屏设备树修改实际上就是改动触摸控制器节点里的touchscreen-inverted-x、touchscreen-inverted-y、touchscreen-swapped-x-y这几个属性。这类问题的本质是设备树的属性值决定了驱动对坐标轴方向的解释。触控面板硬件怎么贴、屏的扫描方向怎么走、驱动拿到原始坐标后要不要交换X和Y轴、要不要反转方向的逻辑全部体现在这几个布尔属性上。遇到这种需求我的建议是先查触摸IC的驱动源码确认它对应解析哪些设备树属性再去改设备树。不要凭感觉猜。网上很多改法写得很玄学实际上往往是驱动版本不同支持的属性名不一样。3. I2C总线系统和驱动实现3.1 I2C协议到底是怎么回事I2C是一种两线制的串行通信协议只有时钟线SCL和数据线SDA两根线。和UART不一样I2C是同步的有明确的时钟信号和SPI不一样I2C只需要两根线就能挂多个从设备。它靠地址寻址每个挂在总线上的从设备有一个7位或者10位地址。好多做嵌入式Linux驱动开发的同学一上来就想直接读内核源码、写驱动结果连I2C时序图都没看过。这就相当于没学过开车就直接上路早晚出事。I2C通信的基本过程其实不复杂拆开来看就这几个关键动作起始条件SCL为高电平时SDA从高跳到低。地址字节主机发出7位从设备地址加一个读写方向位0表示写1表示读。从设备应答从设备在第9个时钟周期把SDA拉低。数据字节每次8位高位在前发送方在第9个时钟周期释放SDA给接收方应答。停止条件SCL为高电平时SDA从低跳到高。热搜词里有人问i2c读写多个字节的完整时序其实在Linux内核驱动里这个一般是不需要你关心时序的实现细节的I2C控制器硬件已经完成了这些底层工作。你只需要调用内核的I2C传输API传入消息结构体让控制器硬件按照时序去操作就可以了。真正的时序细节更多是在你阅读示波器波形、排查通信失败时才会有用。3.2 应用层访问方式i2c-dev和i2c-tools在Linux下调试I2C设备最常用的是i2c-tools这套工具。i2cdetect -l可以列出总线上所有I2C适配器i2cdetect -y 0可以扫描0号总线上所有的从设备地址。这里有一个非常多新手踩的坑i2cdetect扫描出来的是7位地址但你查数据手册的时候有些手册写的是8位地址把读写位也算进去了。比如OLED的SSD1306手册上写地址0x78那是8位写地址实际上7位地址是0x3C。Linux的reg 0x3c用的是7位地址。如果你在设备树里写了0x78内核在遍历总线设备时是匹配不上的。另外如果你想快速验证I2C设备工作是否正常可以直接用i2cget、i2cset和i2cdump来读写设备的寄存器。不需要先写一个完整的驱动。这一步在驱动开发前做探路非常好用能帮你先确认硬件通路是不是通的。3.3 从i2c-dev到内核I2C驱动在应用层通过/dev/i2c-N访问设备只能算应急手段正式产品里都要写内核驱动。Linux的I2C驱动框架是一个典型的总线-设备-驱动模型I2C控制器驱动通常由SoC厂商提供负责I2C适配器本身的工作比如DesignWare I2C控制器。I2C设备驱动由你自己编写负责与挂载在总线上的具体设备交互。一个典型的I2C设备驱动的大致结构是这样的#include linux/i2c.h #include linux/module.h #include linux/delay.h static struct i2c_client *this_client; static int ssd1306_probe(struct i2c_client *client) { this_client client; pr_info(ssd1306 probe success, addr0x%02x\n, client-addr); return 0; } static void ssd1306_remove(struct i2c_client *client) { pr_info(ssd1306 removed\n); } static const struct i2c_device_id ssd1306_id[] { { ssd1306, 0 }, { } }; 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, .id_table ssd1306_id, }; module_i2c_driver(ssd1306_driver); MODULE_LICENSE(GPL);这里的核心联系在于设备树里那个compatible solomon,ssd1306节点会在内核I2C子系统总线匹配的时候来找这个驱动里注册的of_match_table。两者字符串一致内核就会自动调用probe函数把这个节点的信息包括地址等作为参数传入struct i2c_client。probe函数里最常见的动作就是往设备里写初始化命令。写寄存器、读寄存器用的API是static int ssd1306_write_cmd(struct i2c_client *client, u8 cmd) { u8 buf[2] { 0x00, cmd }; // 0x00表示后面的字节是命令 struct i2c_msg msg { .addr client-addr, .flags 0, // 0表示写 .len 2, .buf buf, }; return i2c_transfer(client-adapter, msg, 1); }如果你对i2c_master_write_byte这种细粒度的API不熟悉也不要紧。内核I2C子系统的传输封装了一个完整消息的机制直接操作消息结构体才是核心。实践中最常用的有两个层次i2c_smbus_read_byte_data/i2c_smbus_write_byte_data读写单字节寄存器最常用、最方便。i2c_transfer灵活地组织一组消息支持重复起始条件适合做复合操作。I2C读写失败常见的表现是i2c_transfer返回负数。排查时先拿示波器看SCL和SDA波形能直接确认起始条件、地址字节、ACK位是否都有。如果没有波形检查你是否真的i2c_master_send调用了传输或者是总线被别的设备给拉死了。3.4 I2C与UART、SPI怎么选型很多初学者一直搞不清UART、I2C、SPI这三者之间的区别。这个问题其实一句话可以概括为UART是异步的点对点两根线I2C是同步的两根线多设备靠地址区分速度一般几MHz以内SPI是同步的四根线MISO、MOSI、SCLK、CS速度快一主多从靠片选信号。选型时的经验是从这几个方面考虑需要挂很多从设备、且引脚紧张、速度要求不高选I2C。速度要求高比如读写SD卡、Flash、音频编解码器选SPI。设备离得远、走线长、对时钟同步不敏感选UART。嵌入式工程师最常见的调试件——0.96寸OLED屏幕如SSD1306就同时有I2C和SPI两种接口这种只占一两个引脚、不怎么需要速度的确实用I2C更方便省引脚、可以并行挂多个。4. CAN总线从协议到内核驱动4.1 CAN协议的精髓CANController Area Network总线是汽车电子和工业控制领域使用最广泛的现场总线之一。它与I2C、SPI这些芯片间总线最大的不同在于CAN的前身是应对汽车这种电磁干扰很强的环境而设计的所以它的物理层天然就更强调抗干扰能力使用差分信号逻辑上也比I2C复杂得多。CAN报文ID是很多人一开始就卡住的地方。一个标准CAN数据帧CAN 2.0A的报文格式大概是这样的帧起始位1 bit。仲裁字段11位标识符标准帧或29位标识符扩展帧。控制字段IDE位、DLC数据长度代码标识后面数据的字节数0-8。数据字段最多8字节用户数据。CRC和应答字段。帧结束。那个 ID号 在CAN里有两个作用一是标识报文的身份二是总线仲裁的依据。当两个节点同时往总线上发报文时谁发的ID小谁就优先。这就是can总线仲裁的本质——不是靠一个独立的仲裁信号而是依靠在发送过程中每个节点持续回读总线电平如果发现实际回读的电平和自己要发的电平不一致做线与处理显性电平0会覆盖隐性电平1就认为优先级比自己高的节点正在发送自动退出发送。ID越小在仲裁时越容易赢。所以CAN报文ID一定不能理解成设备的地址。它更像是消息的类型从编号同一个设备可以收发一堆不同ID的报文。你完全可以在一根总线上挂20个ECU每个ECU都在发不同ID的报文。4.2 CAN FD和CAN有什么区别热搜词里有人问CAN FD和CAN的区别这确实是近几年的高频话题。CAN FDCAN with Flexible Data-rate是CAN 2.0的升级版主要有两个变化数据长度扩大了单帧可以最多64字节而传统CAN最多8字节。速率提高了仲裁段和传统CAN一样用保守的波特率但数据段可以切到更高的波特率。这就像一条老公路限速路段仲裁段大家都得守规矩但过去收费站之后有一段不设限的高速路段数据段可以跑得更快。CAN FD不能和传统CAN的节点在一起通信它们物理层一样但协议帧格式不同不同帧结构是无法互相解析的。所以在做ECU选型时如果总线上有老节点只能用CAN 2.0而新节点自然选CAN FD。4.3 Linux下的CAN驱动框架和SocketCANLinux对CAN的支持和I2C/SPI的思路完全不同。它把CAN抽象成了网络接口这样你写CAN应用的时候就像在操作一个网口这一套机制叫SocketCAN。CAN驱动更多的工作是仿照net_device的接口来实现的。实际工作中其实很少有人需要亲自去从零写一个CAN控制器驱动大多数情况下使用的是芯片自带的CAN控制器比如STM32的bxCAN或者在Linux下用SPI对接MCP2515这样的外置CAN控制器芯片。内核中已经有mcp251x驱动设备树里正确配置好SPI接口和中断就能直接用起来。设备树节点大致长这样spi0 { status okay; mcp2515: mcp25150 { compatible microchip,mcp2515; reg 0; spi-max-frequency 10000000; interrupt-parent gpio1; interrupts 7 IRQ_TYPE_LEVEL_LOW; clocks mcp251x_clk; vdd-supply vcc_3v3; }; };时钟在这里很关键。MCP2515需要外部晶振设备树里要给它一个clock节点否则驱动在获取时钟时会失败。这个错误的典型表现是模块加载成功但网络接口一直起不来ip link set can0 up报错。应用层用SocketCAN操作CAN总线的基本流程# 1. 初始化CAN接口 ip link set can0 type can bitrate 500000 # 2. 打开接口 ip link set can0 up # 3. 查看总线上有没有错误 ip -details -statistics link show can0C程序里收发报文的套路是创建socket、绑定接口、填充struct can_frame结构体、read/write。这个结构体正好能直观看到CAN报文的基本组成struct can_frame { canid_t can_id; /* 32 bit CAN_ID EFF/RTR/ERR flags */ __u8 can_dlc; /* 数据长度最低4位 */ __u8 __pad; __u8 __res0; __u8 __res1; __u8 data[8] __attribute__((aligned(8))); };这里有个很容易忽略的细节数据字节的大端小端问题。你从can_frame里直接读data[0]对应在总线上就是第一个字节不涉及大小端翻转。但如果你的数据是一个uint16或者uint32的数值比如车速、电压值源节点是按照大端还是小端发的目标节点必须严格对应。不同厂商的车型协议在这个问题上真是五花八门最常见的坑就是两边都以为对方用的是自己的字节序。4.4 CAN总线看不到ACK时怎么办用CAN调试时出现的经典错误路径是这样的你用cansend can0 123#DEADBEEF发了一帧报文返回正常但用candump can0什么都收不到或者发的时候直接报错no ACK received一个一帧都没有。这里面最核心的一句经验是CAN总线上至少要有一个节点在接收ACK环节发送才能成功。CAN协议在帧的应答字段要求接收节点在总线拉低电平给确认的。如果总线上只有你一台设备在发没有任何接收者发送节点会觉得没有收到ACK于是反复重发甚至报错。破解办法很简单在总线上再挂一个CAN节点做监听角色比如另一块开发板、一个USBCAN分析仪甚至一台跑着Linux的树莓派加一根MCP2515模块。挂在同一个网络里后再发报文就不会有no ACK的问题了。4.5 大小端问题、总线仲裁问题和ID分配问题做多节点系统时ID分配是个很讲究的活儿。前面说过ID小的优先级高所以必须把最紧急、最需要低延时的报文比如安全相关的状态帧分配到小ID。同时不同节点的接收过滤通常基于ID掩码来实现ID规划不合理会导致某个节点收了一堆不相干的报文白白增加CPU负载。大小端问题再次强调它不只是CPU架构带来的更多是协议定义带来的。同一条总线上有不同架构的MCUARM、RISC-V、老式都是小端但有些DSP用大端协议必须明确每个多字节字段的字节序。写驱动和应用层代码时该用htons/ntohs或者显式地(data[0] 8) | data[1]不要偷懒用memcpy结构体强制转换——那样在不同平台上结果完全不一样。5. 实操链路总结从一个外设需求的完整流程说起5.1 一个假想的完整实战过程假设产品经理跟你说我们要在这块RK3568板子上加一个挂在I2C上的光照传感器外加一路CAN总线和车机通信。如果你靠搜索零散答案怕是会乱成一团但按这条系统路径走会清晰很多先看原理图确认光照传感器挂在哪个I2C控制器上、地址是多少CAN收发器对应的CAN控制器是哪个。在内核设备树里使能对应的I2C节点和CAN节点把引脚复用改到硬件实际连的脚上。编译新dtb单独加载验证匹配是否成功。用i2c-tools里的i2cdetect扫描总线确认能扫描到传感器的地址。确认无误后看传感器数据手册是否为I2C设备写驱动注册i2c_driver实现probe/remove对接of_match_table。CAN这边通过SocketCAN直接操作接口调试时用cansend/candump验证收发。最后做系统裁剪优化去掉依赖的内核模块默认打开的调试打印、去掉不要的驱动编译选项、调整DMA和中断配置、做性能调优。这种路径的好处是每一步都有明确的验证手段从硬件到内核到应用都是清晰的边界。而不是一头扎进代码改个不停最后不知道是哪一环出了问题。5.2 从algo到driver把经验沉淀成代码还有一个小建议在调试I2C和CAN的时候建议把流程打包成一套自己的调试工具脚本。比如加载dtb的脚本。清理dmesg、按模块名过滤log的脚本。用i2cget循环读取传感器寄存器验证bus稳定性的脚本。用candump过滤指定ID的脚本。这些脚本平时看似很低级但在现场排障时比什么大工具都好使。我到现在还保留着几个最简单的shell函数项目一开会就source进来用。5.3 常见问题速查表把我在实际项目中踩过、以及帮别人排过的坑整理成一张速查表现象可能原因解决思路模块加载报Invalid module format内核版本或符号版本不一致重新用目标内核源码树编译设备端驱动probe没被调用设备树compatible和驱动of_match_table不一致检查两者字符串是否完全匹配i2cdetect扫不到设备地址错了、引脚复用错了、设备没上电用示波器看波形查原理图设备树改了不生效dtb没重新编译或没加载新版单独编译dtb并确认boot命令行I2C传输出错总线被拉死、时钟过快、上拉电阻不合适降速、检查外部上拉、检查总线占用SocketCANno ACK总线上没有其他节点应答挂第二个节点做接收者CAN收发正常但数据乱波特率不匹配、大小端处理错检查bitrate参数、整理字节序触摸方向不对设备树坐标翻转属性配错查看驱动解析哪些属性再改dts这些问题的共性在于大部分都不是代码难写而是链路没打通。所以排查的顺序一定是硬件链路 → 设备树描述 → 驱动匹配 → 总线通信 → 数据解析。不要跳过步骤直接猜。6. 最后分享一点个人体会从内核模块到设备树再到I2C和CAN这条路看着长但每一步都在为下一步打基础。模块开发教会你和内核对话的方式设备树教会你描述硬件的方法I2C和CAN带你进入真正的工业总线世界。我在瑞芯微平台和STM32MP1平台上都走过完整的这条路最深的一个体会是多试、多备份、多留日志。改dts前先备份改驱动前先加日志。很多问题看着神秘日志一开就现原形了。另外瑞芯微RK3568这类平台的学习资料相对丰富厂商提供的SDK里通常有完整的设备树示例和驱动参考代码是很好的学习素材。如果你想快速建立整体概念可以把官方的dtsi文件通读一遍看它一共描述了哪些外设每个外设用了哪些属性再去和原理图对照。这套流程走完再复杂的平台也基本能看懂七七八八。最后分享一个小技巧调试I2C从设备时如果总是复位不成功可以先查设备树里有没有配置复位引脚还有复位信号的时间参数。这类器件比如显示屏、触摸控制器在电源上电后经常会因为复位时序不合规而处于不确定状态表现就是I2C地址扫描不到或者通信超时。调整reset-deassert-us或者在上电时序上增加延时往往就能解决问题。这算是设备树配置里最容易忽略、也最容易见效的一个点。