☰
OpenHarmony I2C驱动开发与排障实战:从协议原理到设备树配置
2026/9/29 2:53:33 网站建设 项目流程

1. I2C 总线到底是个什么东西

I2C 这玩意儿,搞嵌入式的基本绕不开。你可以把它想象成一条只有两根线的“小区内部道路”:一根是 SCL 时钟线,负责打节拍;另一根是 SDA 数据线,负责传消息。所有挂在总线上的设备——传感器、EEPROM、OLED 屏、触摸芯片——都像小区里的住户,共用这两根线,靠“门牌号”(设备地址)来区分谁在说话。

它跟 SPI、UART 最大的区别在于:I2C 是多主多从、地址寻址、带应答机制的总线。SPI 通常靠片选线一根一根拉低来选设备,线多但速度快;UART 是点对点,两个设备之间直连。I2C 只用两根线就能挂几十个设备,代价是速率相对低(标准模式 100kHz,快速模式 400kHz,高速模式 3.4MHz),而且总线上任何一个设备拉死 SDA 都会导致整条总线瘫痪。

在 OpenHarmony 系统里,I2C 是 HDF(Hardware Driver Foundation)驱动框架重点支持的总线类型之一。你会在//drivers/hdf_core/framework/model/i2c这类路径下看到核心实现,而具体 SoC 的 I2C 控制器驱动则放在//device/soc/<厂商>/<芯片>/hdf/i2c下面。应用层通过 HDF 提供的统一接口去读写 I2C 设备,不用关心底层是瑞芯微 RK3568 还是其他芯片。

这篇文章适合谁看?如果你正在 OpenHarmony 上接传感器、调触摸屏、读 EEPROM,或者被“I2C 通信失败”“设备找不到”这类问题卡住过,那接下来的内容就是给你准备的。我会从总线原理讲到设备树配置,再讲到实际排障,尽量把踩过的坑都摊开说。

2. I2C 通信协议的核心机制拆解

2.1 起始、停止与应答:三件事撑起整个协议

I2C 的通信过程看起来复杂,其实核心就三件事:起始条件(Start)、停止条件(Stop)、应答(ACK/NACK)。

起始条件是在 SCL 保持高电平的时候,SDA 从高变低。停止条件反过来,SCL 高电平时 SDA 从低变高。这两个信号是总线上的“标点符号”,告诉所有设备“我要开始说话了”和“我说完了”。

应答机制是 I2C 可靠性的关键。主机每发送 8 位数据(1 字节)后,会释放 SDA 线,然后在第 9 个时钟周期读取 SDA 电平。如果从机把 SDA 拉低,就是 ACK,表示“收到了”;如果保持高,就是 NACK,表示“没收到”或者“别再发了”。很多排障场景里,用逻辑分析仪抓到的“第 9 个时钟没有拉低”,基本就是从机没响应,原因可能是地址不对、从机没上电、或者从机被复位了。

注意:起始和停止条件必须由主机产生,从机不能主动发起。如果从机想“主动更新主机寄存器”,在标准 I2C 里是做不到的,只能通过额外的中断线通知主机来读。

2.2 数据帧格式:地址、读写位、数据、应答

一次典型的 I2C 传输帧格式是这样的:

  1. 起始条件
  2. 7 位从机地址 + 1 位读写标志(0 写,1 读)
  3. 从机应答(ACK)
  4. 数据字节(一个或多个)
  5. 每个数据字节后跟一个应答位
  6. 停止条件

举个例子,你要往地址为0x50的 EEPROM 写一个字节到内部地址0x00,时序是:Start → 0x50+W → ACK → 0x00(内部地址)→ ACK → 数据 → ACK → Stop。读的时候要先写内部地址,再 Restart → 0x50+R → 读数据 → NACK → Stop。这个“写地址再读”的套路,是很多 I2C 设备的标准操作,新手容易在这里漏掉 Restart 或者搞错读写位。

2.3 时钟同步与仲裁:多主机场景下的隐形规则

当总线上有多个主机时,I2C 靠时钟同步和总线仲裁来避免冲突。时钟同步是指所有主机的 SCL 线是“线与”关系,谁的低电平时间长,总线就按谁的节奏走。仲裁是指多个主机同时发数据时,谁先发出低电平谁就赢,输的那个自动退出。

实际项目中,多主机场景很少见,但如果你在 OpenHarmony 上做双核通信或者多主冗余,就得注意:仲裁失败的主机需要重新发起传输,而且仲裁过程中数据不会丢失。这个机制在硬件 I2C 控制器里通常是自动处理的,但软件模拟 I2C 就得自己实现,复杂度不低。

2.4 I2C 与 SMBus、PMBus 的区别

很多人会把 I2C 和 SMBus、PMBus 混在一起。简单说,SMBus 是 I2C 的一个子集,加了超时机制和更严格的电气规范,主要用于电源管理。PMBus 又是 SMBus 的扩展,专门用于电源管理设备。在 OpenHarmony 里,如果你接的是 PMIC 或者电源管理芯片,设备树里可能会看到smbus或pmbus的兼容字符串,但底层还是 I2C 时序。区别在于超时时间:SMBus 要求 35ms 超时,I2C 没有这个硬性要求。如果你用 I2C 控制器去驱动 SMBus 设备,可能会因为超时机制不匹配而通信失败。

3. OpenHarmony 下 I2C 的设备树配置与驱动框架

3.1 设备树里 I2C 节点怎么写

在 OpenHarmony 的 HDF 驱动框架里,设备树(Device Tree)是描述硬件连接关系的关键文件。以瑞芯微 RK3568 为例,I2C 控制器的节点通常在kernel/linux/patches/linux-5.10/arch/arm64/boot/dts/rockchip/rk3568.dtsi里定义,而具体板级的 I2C 设备挂载则在rk3568-evb.dts这类文件里。

一个典型的 I2C 控制器节点长这样:

i2c1: i2c@fe5a0000 { compatible = "rockchip,rk3568-i2c"; reg = <0x0 0xfe5a0000 0x0 0x1000>; interrupts = <GIC_SPI 51 IRQ_TYPE_LEVEL_HIGH>; clocks = <&cru CLK_I2C1>, <&cru PCLK_I2C1>; clock-names = "i2c", "pclk"; pinctrl-names = "default"; pinctrl-0 = <&i2c1_xfer>; #address-cells = <1>; #size-cells = <0>; status = "okay"; };

然后在板级文件里挂设备:

&i2c1 { status = "okay"; clock-frequency = <100000>; gt911: touchscreen@5d { compatible = "goodix,gt911"; reg = <0x5d>; interrupt-parent = <&gpio0>; interrupts = <RK_PB5 IRQ_TYPE_EDGE_FALLING>; reset-gpios = <&gpio0 RK_PB6 GPIO_ACTIVE_LOW>; irq-gpios = <&gpio0 RK_PB5 GPIO_ACTIVE_HIGH>; }; };

这里有几个关键点:reg属性就是 I2C 从机地址,clock-frequency是总线速率,interrupts和reset-gpios是触摸芯片的中断和复位引脚。如果这些配错了,设备根本不会出现在/dev下面,或者驱动 probe 直接失败。

3.2 HDF 驱动框架里的 I2C 接口

OpenHarmony 的 HDF 框架给 I2C 提供了统一的接口,核心头文件在//drivers/hdf_core/framework/include/platform/i2c_if.h。常用的几个函数:

  • I2cOpen(int16_t busNum):打开指定编号的 I2C 总线,返回句柄。
  • I2cClose(int16_t busNum):关闭总线。
  • I2cTransfer(int16_t busNum, struct I2cMsg *msgs, int16_t count):执行一次传输,支持多个消息连续发送。
  • I2cTransferWithLock:带锁的传输,多线程场景下用。

struct I2cMsg的结构大概是:

struct I2cMsg { uint16_t addr; // 从机地址 uint16_t flags; // 读写标志,0 写,1 读 uint16_t len; // 数据长度 uint8_t *buf; // 数据缓冲区 };

实际写驱动的时候,你通常会先I2cOpen,然后构造I2cMsg数组,调用I2cTransfer,最后I2cClose。如果是连续读写(比如先写寄存器地址再读数据),可以把两个I2cMsg放在一个数组里,一次I2cTransfer完成,中间会自动产生 Restart 而不是 Stop。

3.3 设备树与驱动匹配的底层逻辑

设备树里的compatible字符串和驱动里的of_match_table是一一对应的。比如"goodix,gt911"会匹配到 GT911 触摸驱动。如果匹配不上,驱动不会 probe,设备也不会注册到 HDF 设备管理里。

在 OpenHarmony 里,I2C 设备的驱动通常分为两层:平台驱动(I2C 控制器驱动)和器件驱动(具体传感器/触摸芯片驱动)。平台驱动负责初始化控制器、配置时钟和引脚;器件驱动负责通过I2cTransfer读写寄存器。两层通过 HDF 的设备节点关联起来。

实操心得:如果你在设备树里改了 I2C 节点,但驱动没反应,先检查status是不是"okay",再检查compatible是否和驱动匹配,最后看reg地址是否和硬件原理图一致。这三步能解决 80% 的“设备不识别”问题。

4. I2C 排障实战:从现象到根因的完整链路

4.1 常见故障现象与初步定位

I2C 出问题,现象通常就几种:设备不识别、读写超时、数据错乱、总线死锁。下面这张表是我在实际项目中整理出来的速查表:

现象可能原因排查手段
设备不识别地址错误、上电时序不对、设备树未使能逻辑分析仪抓起始帧,看地址是否匹配
读写超时从机没应答、SCL 被拉低、时钟频率过高示波器看 SCL/SDA 波形,降低速率测试
数据错乱时序抖动、上拉电阻过大、干扰检查上拉电阻(通常 4.7kΩ),缩短走线
总线死锁从机拉死 SDA、主机复位不完整手动发送 9 个时钟脉冲解锁
概率性失败电源纹波、地弹、EMI加滤波电容,检查地平面

逻辑分析仪是排障利器。我通常用 Saleae 或者便宜的逻辑分析仪抓 I2C 波形,重点看:起始条件是否干净、地址帧是否匹配、第 9 个时钟是否有 ACK、停止条件是否完整。如果地址帧发出去没有 ACK,基本就是从机没响应。

4.2 用逻辑分析仪抓 I2C 波形的实操步骤

第一步,把逻辑分析仪的通道 0 接 SCL,通道 1 接 SDA,地线接板子地。第二步,在软件里设置 I2C 解码,选择正确的时钟通道和数据通道。第三步,触发方式设为 SDA 下降沿(起始条件),采样率至少 1MHz 以上,最好 4MHz。第四步,让程序发起一次 I2C 传输,抓取波形。

抓到的波形里,你可以直接看到地址、读写位、数据、ACK/NACK。如果地址是0x5D,但波形里显示0x5C,那就是地址左移了一位或者右移了一位的问题。I2C 的 7 位地址在传输时是左移一位,最低位是读写位,所以0x5D写操作实际发送的是0x5A,读操作是0x5B。这个细节很容易搞错。

注意:有些逻辑分析仪的 I2C 解码器会把 7 位地址和读写位合并显示,有些会分开显示。看波形的时候先确认软件的解码设置,别被显示格式误导。

4.3 总线死锁的解锁方法

总线死锁是 I2C 最恶心的问题之一。现象是 SDA 被某个从机拉低,主机发不了起始条件,整个总线瘫痪。原因通常是从机在传输过程中被复位,或者电源抖动导致状态机卡死。

解锁方法有两种。硬件方法是给从机断电重启,简单粗暴但有效。软件方法是在 SCL 上手动发送 9 个时钟脉冲,让从机把剩余的数据位发完,然后发一个停止条件。在 OpenHarmony 里,你可以通过 GPIO 模拟 SCL 来实现:

// 伪代码:手动发送 9 个时钟脉冲 gpio_set_direction(SCL_PIN, OUTPUT); gpio_set_direction(SDA_PIN, INPUT); for (int i = 0; i < 9; i++) { gpio_set_value(SCL_PIN, 0); udelay(5); gpio_set_value(SCL_PIN, 1); udelay(5); } // 然后发送停止条件 gpio_set_direction(SDA_PIN, OUTPUT); gpio_set_value(SDA_PIN, 0); udelay(5); gpio_set_value(SCL_PIN, 1); udelay(5); gpio_set_value(SDA_PIN, 1);

这个方法我实测过多次,对大多数从机有效。但如果从机彻底挂死,还是得断电。

4.4 设备树配置错误的典型表现

设备树配错,表现往往很隐蔽。比如clock-frequency设成了 400kHz,但从机只支持 100kHz,结果就是概率性读写失败。或者reg地址写成了 8 位地址(比如0x5D写成了0xBA),驱动 probe 时读不到设备 ID,直接返回失败。

还有一种情况是引脚复用没配对。RK3568 的 I2C 引脚可能和 GPIO、UART 复用,如果pinctrl-0没指向正确的引脚组,SCL/SDA 根本没有波形。这时候用示波器看引脚,会发现一直是高电平或者低电平,没有时钟信号。

实操心得:改设备树之后,一定要重新编译 dtb 并确认烧录成功。我遇到过好几次改了 dts 但没重新打包 boot.img,结果调试半天发现用的还是旧设备树。确认方法是在系统起来后,去/proc/device-tree下面看对应节点,或者用dmesg | grep i2c看内核打印。

5. I2C 读写 EEPROM 的完整代码示例与参数计算

5.1 硬件连接与上拉电阻选择

以 AT24C02 为例,容量 2Kbit(256 字节),7 位地址是1010xxx,其中 xxx 由 A0/A1/A2 引脚决定。如果全部接地,地址就是0x50。SCL 和 SDA 各接一个 4.7kΩ 上拉电阻到 3.3V。

上拉电阻的选择有讲究。阻值太大,上升沿变缓,高速通信时波形失真;阻值太小,功耗增加,从机可能拉不低。标准模式 100kHz 下,4.7kΩ 是经验值;快速模式 400kHz 下,可以用 2.2kΩ 到 4.7kΩ。计算公式是R = t_r / (0.8473 * C),其中t_r是允许的上升时间(标准模式 1000ns,快速模式 300ns),C是总线电容(包括引脚电容和走线电容,通常 100pF 到 200pF)。按 100kHz、200pF 算,R = 1000ns / (0.8473 * 200pF) ≈ 5.9kΩ,所以 4.7kΩ 是合理的。

5.2 写 EEPROM 的时序与代码

写 AT24C02 一个字节的流程:Start → 0x50+W → ACK → 内部地址 → ACK → 数据 → ACK → Stop。写完之后需要等待 5ms 左右的内部写周期,期间从机不响应任何请求。

在 OpenHarmony 的 HDF 框架下,代码大概是这样:

int32_t EepromWriteByte(int16_t busNum, uint8_t innerAddr, uint8_t data) { int32_t ret; uint8_t buf[2] = {innerAddr, data}; struct I2cMsg msg = { .addr = 0x50, .flags = 0, // 写 .len = 2, .buf = buf, }; ret = I2cTransfer(busNum, &msg, 1); if (ret != 1) { HDF_LOGE("eeprom write failed, ret=%d", ret); return HDF_FAILURE; } OsalMSleep(10); // 等待内部写周期 return HDF_SUCCESS; }

注意I2cTransfer的返回值是成功传输的消息数量,返回 1 表示成功,返回负数表示失败。OsalMSleep(10)是保险起见等 10ms,实际 AT24C02 的写周期最大 5ms。

5.3 读 EEPROM 的时序与代码

读操作稍微复杂一点,要先写内部地址,再 Restart 读数据:

int32_t EepromReadByte(int16_t busNum, uint8_t innerAddr, uint8_t *data) { int32_t ret; uint8_t addrBuf = innerAddr; uint8_t dataBuf = 0; struct I2cMsg msg[2] = { { .addr = 0x50, .flags = 0, // 写 .len = 1, .buf = &addrBuf, }, { .addr = 0x50, .flags = 1, // 读 .len = 1, .buf = &dataBuf, }, }; ret = I2cTransfer(busNum, msg, 2); if (ret != 2) { HDF_LOGE("eeprom read failed, ret=%d", ret); return HDF_FAILURE; } *data = dataBuf; return HDF_SUCCESS; }

两个I2cMsg放在一个数组里,I2cTransfer会自动在第一个消息后产生 Restart,而不是 Stop。这是 I2C 协议里“复合传输”的标准做法,很多新手会分成两次I2cTransfer调用,中间产生 Stop,导致读出来的数据不对。

5.4 页写与连续读的边界处理

AT24C02 支持页写,一页 8 字节。如果你写超过 8 字节,地址会回卷到页首,覆盖之前的数据。所以写多字节时要分页处理:

int32_t EepromWritePage(int16_t busNum, uint8_t innerAddr, uint8_t *data, uint16_t len) { uint16_t written = 0; while (written < len) { uint8_t pageOffset = innerAddr % 8; uint8_t pageRemain = 8 - pageOffset; uint8_t chunk = (len - written) < pageRemain ? (len - written) : pageRemain; // 构造 buf,写入 chunk 字节 // ... written += chunk; innerAddr += chunk; OsalMSleep(10); } return HDF_SUCCESS; }

连续读没有页边界问题,因为读操作是顺序递增地址的,读完最后一个字节发 NACK 再 Stop 就行。

6. 那些年我踩过的 I2C 坑与独家避坑技巧

6.1 地址左移一位的经典错误

I2C 的 7 位地址在传输时是左移一位,最低位是读写位。比如0x5D写操作,实际发送的是0x5A;读操作是0x5B。很多驱动代码里直接写addr = 0x5D,然后flags = 0,底层会自动左移。但如果你用软件模拟 I2C,就得自己处理这个移位。我见过有人把0x5D直接当 8 位地址发,结果从机根本不响应。

避坑技巧:在逻辑分析仪里看地址时,先确认软件显示的是 7 位地址还是 8 位地址。如果是 8 位,最低位就是读写位,去掉最低位再右移一位才是真正的 7 位地址。

6.2 上拉电阻缺失导致波形畸变

有些开发板为了省成本,I2C 引脚没有焊上拉电阻,靠芯片内部弱上拉。内部上拉通常几十 kΩ,上升沿非常缓,100kHz 下可能勉强能用,400kHz 直接失败。现象是逻辑分析仪抓到的波形上升沿是圆弧形,不是方波。

解决办法是在 SCL 和 SDA 上各焊一个 4.7kΩ 电阻到 3.3V。如果板子已经打样了,可以在排线中间接一个电阻,或者用飞线。我试过在 SDA 上飞一个 2.2kΩ,波形立刻变干净。

6.3 电源时序不对导致设备不响应

有些传感器要求 I2C 上电之前,VDD 必须先稳定。如果 VDD 和 I2C 同时上电,传感器内部状态机可能没初始化完,导致前几次通信失败。GT911 触摸芯片就有这个问题,复位引脚和 I2C 上电时序有严格要求。

解决办法是在设备树里配置reset-gpios,驱动 probe 时先拉低复位,延时 10ms,再拉高,再延时 50ms,然后才开始 I2C 通信。这个时序在 GT911 的数据手册里有明确要求,但很多人不看手册,直接上电就通信,结果概率性失败。

6.4 多设备挂载时的地址冲突

I2C 总线上每个设备地址必须唯一。如果你挂了两个相同型号的传感器,而它们的地址引脚都接地,就会冲突。解决办法是通过地址引脚配置不同地址,或者用 I2C 多路复用器(如 TCA9548A)扩展总线。

TCA9548A 是一个 8 通道 I2C 多路复用器,主机通过写它的寄存器来选择哪个通道导通。在 OpenHarmony 里,你需要先写 TCA9548A 的地址(通常是0x70),选择通道,然后再和通道上的设备通信。这个芯片解决了我很多“多个相同传感器”的场景。

6.5 中断与轮询的取舍

很多 I2C 设备支持中断输出,比如触摸芯片、加速度计。用中断还是轮询,取决于实时性要求和功耗。中断方式下,设备有数据时拉低中断线,主机在中断处理里读 I2C。轮询方式下,主机定时读设备寄存器。

在 OpenHarmony 里,中断方式需要配置interrupt-parent和interrupts,驱动里注册中断处理函数。轮询方式简单,但功耗高。我一般建议:触摸屏用中断,传感器用轮询(除非低功耗要求极高)。

实操心得:中断方式下,中断处理函数里不要做太耗时的 I2C 读写,最好用工作队列或者线程去处理。I2C 传输本身可能睡眠,在中断上下文里直接调用会出问题。

7. 从 I2C 到其他总线的扩展思考

I2C 只是嵌入式总线家族的一员。SPI 速度更快,适合 OLED 屏、Flash 存储;UART 简单直接,适合调试串口、GPS 模块;CAN 总线抗干扰强,适合汽车电子;I2S 专门传音频。选型的时候,速率、距离、设备数量、功耗都是考量因素。

在 OpenHarmony 的 HDF 框架里,这些总线都有对应的统一接口。你学会了 I2C 的设备树配置和 HDF 调用方式,再去看 SPI 或者 UART,会发现套路是一样的:设备树里配节点,驱动里用SpiTransfer或UartWrite。底层逻辑相通,只是时序和协议不同。

我个人在实际操作中的体会是:I2C 排障最核心的能力不是看代码,而是看波形。逻辑分析仪抓一次波形,比读十遍数据手册都管用。另外,设备树配置一定要和硬件原理图对照,地址、引脚、上拉电阻,一个都不能错。最后再分享一个小技巧:如果你怀疑是 I2C 总线本身的问题,可以先把所有从机断开,只留一个,用最简单的读写测试。如果单个设备能通,再逐个加设备,这样能快速定位是哪个设备拉死了总线。

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

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

立即咨询