☰
OpenHarmony I2C驱动开发与排障实战:从设备树到HDF框架
2026/9/27 10:36:17 网站建设 项目流程

1. 从一根线说起:I2C 到底解决了什么问题

搞 OpenHarmony 系统开发的朋友,尤其是做驱动适配的,迟早会跟 I2C 打交道。你拿到一块 RK3568 的开发板,上面挂着触摸屏、加速度计、EEPROM、温湿度传感器,这些器件大多通过两根线跟主控通信——一根 SCL 时钟线,一根 SDA 数据线。这就是 I2C 总线,全称 Inter-Integrated Circuit,中文叫集成电路总线。

它最直接的价值就一个字:省。省引脚、省走线、省成本。你想想,如果每个传感器都走 SPI,四个器件就要四根片选线加三根共用线,一共七根;换成 I2C,两根线全搞定,器件靠地址区分。对于引脚资源紧张的 SoC 来说,这是刚需。但省是有代价的——I2C 是半双工、主从架构、开漏输出,速度从 100kHz 到 3.4MHz 不等,抗干扰能力也比差分总线弱。所以用 I2C 的核心矛盾就一句话:用最少的线,换最稳的通信。

在 OpenHarmony 里,I2C 的使用分两个层面。上层是 HDF(Hardware Driver Foundation)驱动框架,你写一个 I2C 设备驱动,注册到 HDF 的 I2C 控制器上;下层是内核里的 I2C 子系统,负责时序生成、总线仲裁、设备匹配。很多刚接触 OpenHarmony 驱动开发的人会卡在中间——设备树写了,驱动编了,但/dev/i2c-x就是出不来,或者出来了读写全是 0xFF。这不是你代码写得不对,而是对 I2C 的排障逻辑没建立起来。

这篇文章面向的是正在 OpenHarmony 上做 I2C 设备适配的驱动工程师、系统移植人员,以及想搞懂 I2C 排障套路的嵌入式开发者。我会从总线原理讲到设备树配置,从 HDF 驱动注册讲到用逻辑分析仪抓波形,最后给出一套可复用的排障流程。你不需要有很深的 Linux 内核基础,但至少要能看懂 C 代码和 DTS 语法。

2. I2C 总线核心机制拆解:为什么你的时序总是不对

2.1 开漏输出与上拉电阻:I2C 的物理层真相

I2C 的 SCL 和 SDA 都是开漏(Open-Drain)结构,这意味着器件只能把线拉低,不能主动拉高。线要变高,靠的是上拉电阻。这个设计的好处是天然支持多设备共享总线——任何一个器件拉低,总线就是低;所有器件都释放,总线才被上拉电阻拉高。这就是“线与”逻辑。

上拉电阻的取值不是随便选的。典型值 4.7kΩ 到 10kΩ,但具体要看总线电容和速度。总线电容包括 PCB 走线电容、器件引脚电容、连接器电容,一般每根线控制在 400pF 以内。上升时间 Tr 和上拉电阻 Rp、总线电容 Cb 的关系是:

Tr ≈ 0.847 × Rp × Cb

以标准模式 100kHz 为例,上升时间要求小于 1000ns。如果 Cb 是 200pF,那么 Rp 最大约 5.9kΩ。如果你用了 10kΩ 上拉,上升沿就会太慢,波形变成“圆肩”,高速下直接通信失败。反过来,电阻太小,低电平灌电流太大,器件可能扛不住。3.3V 系统下,4.7kΩ 对应灌电流约 0.7mA,大部分器件都能接受。

我在 RK3568 上遇到过一个问题:I2C3 接了一个 GT911 触摸屏,扫描地址时有时无。用示波器一看,SCL 上升沿接近 1.5μs,明显超标。板子上用的是 10kΩ 上拉,总线走了很长一段排线,电容偏大。换成 2.2kΩ 后,上升沿降到 300ns 以内,触摸屏识别稳定。所以排障第一步,先看波形,别急着改代码。

2.2 起始、停止与应答:I2C 的协议骨架

I2C 通信的原子单元是“起始条件-地址帧-数据帧-停止条件”。起始条件(Start)是 SCL 高时 SDA 由高变低;停止条件(Stop)是 SCL 高时 SDA 由低变高。这两个条件必须由主机产生。

地址帧是 7 位地址加 1 位读写位。比如 GT911 的地址是 0x5D,写操作就是 0xBA(0x5D<<1 | 0),读操作是 0xBB。很多新手在这里翻车: datasheet 上写的是 7 位地址,但代码里填的是 8 位,结果怎么都不通。记住一个原则:Linux 和 OpenHarmony 的 I2C 设备树里填 7 位地址,内核会自动左移。

每传输一个字节,接收方要回一个应答位(ACK)。ACK 是接收方把 SDA 拉低,NACK 是释放 SDA。如果主机发完地址后收到 NACK,说明从机没响应——要么地址错了,要么从机没上电,要么从机被复位了。如果主机读数据时从机回 NACK,通常表示数据读完了。

时钟同步和仲裁是 I2C 的两个高级特性。时钟同步靠 SCL 线的线与逻辑:多个主机同时发时钟,低电平周期由最长的那个决定,高电平周期由最短的那个决定。仲裁发生在 SDA 线上:主机发数据时同时回读 SDA,如果自己发的是高但读回来是低,说明有别的设备在拉低,自己就退出。这些机制保证了多主机场景下的可靠性,但在实际嵌入式开发中,绝大多数场景是单主机,你只需要关注时序和地址。

2.3 标准模式、快速模式与高速模式:速度怎么选

I2C 规范定义了五种速度等级:

模式最高速率典型上拉适用场景
标准模式100 kHz4.7kΩEEPROM、低速传感器
快速模式400 kHz2.2kΩ触摸屏、加速度计
快速模式+1 MHz1kΩ高速传感器
高速模式3.4 MHz主动上拉特殊高速器件
超快速模式5 MHz推挽单向传输,极少用

选速度的原则是:够用就好,别贪高。速度越高,对总线电容、上拉电阻、PCB 走线的要求越苛刻。一个 400kHz 的触摸屏,你非要用 1MHz 去读,可能反而因为信号完整性问题导致误码。在 OpenHarmony 的设备树里,clock-frequency属性就是设这个的。RK3568 的 I2C 控制器支持 100k、400k、1M 等档位,但具体能跑多少,取决于你的板级设计。

我一般建议:新板子调试阶段先用 100kHz,确保通信正常后再逐步提高。如果提高到某个频率开始丢包,就退回上一档。别小看这个步骤,很多“偶发性通信失败”就是速度设太高导致的。

3. OpenHarmony 下的 I2C 设备树配置:从原理到实操

3.1 设备树里的 I2C 控制器节点

在 OpenHarmony 的 RK3568 方案里,I2C 控制器的设备树节点通常在kernel/linux/linux-5.10/arch/arm64/boot/dts/rockchip/rk3568.dtsi里定义。以 I2C3 为例,核心属性包括:

i2c3: i2c@fe5c0000 { compatible = "rockchip,rk3568-i2c", "rockchip,rk3399-i2c"; reg = <0x0 0xfe5c0000 0x0 0x1000>; interrupts = <GIC_SPI 53 IRQ_TYPE_LEVEL_HIGH>; clocks = <&cru CLK_I2C3>, <&cru PCLK_I2C3>; clock-names = "i2c", "pclk"; pinctrl-names = "default"; pinctrl-0 = <&i2c3m0_xfer>; #address-cells = <1>; #size-cells = <0>; status = "disabled"; };

compatible是驱动匹配的关键,内核里的 I2C 控制器驱动会拿这个字符串去匹配。reg是寄存器基地址和长度。clocks和clock-names是时钟源,I2C 控制器需要工作时钟和 APB 总线时钟。pinctrl指定引脚复用,RK3568 的 I2C3 可以复用到不同的 GPIO 组,i2c3m0_xfer表示 m0 组的 SCL/SDA。

status = "disabled"表示默认不使能,你需要在板级 DTS 里覆盖它。比如在rk3568-evb.dtsi或者你自己的板级 DTS 里:

&i2c3 { status = "okay"; clock-frequency = <100000>; pinctrl-names = "default"; pinctrl-0 = <&i2c3m0_xfer>; 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>; status = "okay"; }; };

这里clock-frequency设成 100000,即 100kHz。gt911节点的reg = <0x5d>就是 7 位地址。interrupt-parent和interrupts指定中断引脚,reset-gpios和irq-gpios是 GPIO 控制引脚。这些属性不是 I2C 控制器本身的,而是从机设备的,但必须放在 I2C 控制器节点下面,内核才会把它当作该总线上的设备。

3.2 地址冲突与多设备挂载

一条 I2C 总线上可以挂多个设备,但地址不能冲突。7 位地址空间是 0x08 到 0x77,其中有些地址是保留的。你挂设备前,先列一个地址表:

设备7位地址8位写地址8位读地址
GT9110x5D0xBA0xBB
AT24C020x500xA00xA1
MPU60500x680xD00xD1
SHT300x440x880x89

如果两个设备地址一样,你有几个办法:一是换器件,选地址可配置的版本;二是用 I2C 多路复用器,比如 TCA9548A,它本身是个 I2C 设备,地址比如 0x70,下面挂 8 个通道,每个通道可以挂地址相同的设备。在设备树里,多路复用器下面的设备要放在对应的子节点里:

i2c3 { tca9548: mux@70 { compatible = "nxp,pca9548"; reg = <0x70>; #address-cells = <1>; #size-cells = <0>; channel0: i2c@0 { #address-cells = <1>; #size-cells = <0>; reg = <0>; gt911: touchscreen@5d { compatible = "goodix,gt911"; reg = <0x5d>; }; }; }; };

这种嵌套结构在内核里会被解析成一条虚拟总线,访问时先写多路复用器的通道选择寄存器,再发目标设备地址。OpenHarmony 的 HDF I2C 框架也支持这种模式,但需要你在驱动里正确处理总线切换。

3.3 引脚复用与电气特性配置

RK3568 的 I2C 引脚复用通过 pinctrl 配置。你需要在rk3568-pinctrl.dtsi里找到对应的引脚组,确认rockchip,pins属性里的 GPIO 编号和功能号。比如:

i2c3m0_xfer: i2c3m0-xfer { rockchip,pins = <1 RK_PA1 1 &pcfg_pull_none_smt>, <1 RK_PA0 1 &pcfg_pull_none_smt>; };

这里<1 RK_PA1 1 ...>表示 GPIO1_A1,功能号 1 是 I2C3_SCL。pcfg_pull_none_smt表示不使能内部上拉,使能施密特触发器。注意:I2C 总线必须用外部上拉电阻,不能依赖内部上拉。内部上拉通常几十 kΩ,太弱了,高速下根本拉不起来。很多开发板已经焊了外部上拉,你只需要确认阻值合适。

如果你在设备树里把引脚配错了,比如配成了 GPIO 功能,那 I2C 控制器就发不出波形。排障时可以用cat /sys/kernel/debug/pinctrl/pinctrl-rockchip-pinctrl/pinmux-pins查看引脚当前功能。如果显示的不是 I2C,那就是 pinctrl 没配对。

4. HDF 驱动框架下的 I2C 设备驱动开发

4.1 HDF I2C 驱动模型概览

OpenHarmony 的 HDF 驱动框架把设备驱动分成三层:HDF 核心层、主机层、设备层。对于 I2C,主机层是hdf_i2c_controller,负责控制器的注册和管理;设备层是你写的具体设备驱动,比如gt911触摸屏驱动。设备驱动通过HdfI2cCntlrGet()获取控制器句柄,然后调用HdfI2cTransfer()发起读写。

一个典型的 I2C 设备驱动入口长这样:

static int32_t Gt911Bind(struct HdfDeviceObject *device) { struct Gt911Dev *dev = NULL; dev = (struct Gt911Dev *)OsalMemCalloc(sizeof(*dev)); if (dev == NULL) { return HDF_ERR_MALLOC_FAIL; } dev->device = device; device->priv = dev; return HDF_SUCCESS; } static int32_t Gt911Init(struct HdfDeviceObject *device) { struct Gt911Dev *dev = (struct Gt911Dev *)device->priv; dev->i2cHandle = HdfI2cCntlrGet(device); if (dev->i2cHandle == NULL) { HDF_LOGE("get i2c cntlr failed"); return HDF_FAILURE; } return HDF_SUCCESS; } static void Gt911Release(struct HdfDeviceObject *device) { struct Gt911Dev *dev = (struct Gt911Dev *)device->priv; if (dev != NULL) { OsalMemFree(dev); } } struct HdfDriverEntry g_gt911DriverEntry = { .moduleVersion = 1, .moduleName = "gt911", .Bind = Gt911Bind, .Init = Gt911Init, .Release = Gt911Release, }; HDF_INIT(g_gt911DriverEntry);

HdfI2cCntlrGet()会根据设备树里的bus属性或者 HDF 配置里的busId找到对应的 I2C 控制器。你需要在device_info.hcs里配置:

device_i2c :: device { device0 :: deviceNode { policy = 2; priority = 50; permission = 0664; moduleName = "HDF_I2C_MANAGER"; serviceName = "HDF_I2C_MANAGER"; }; }; device_gt911 :: device { device0 :: deviceNode { policy = 2; priority = 50; permission = 0664; moduleName = "gt911"; serviceName = "gt911"; deviceMatchAttr = "gt911_config"; }; };

deviceMatchAttr对应hdf_dev_gt911_config.hcs里的配置,里面可以指定busId、regAddr、regWidth等参数。

4.2 I2C 读写接口与寄存器操作

HDF 提供了HdfI2cTransfer()接口,参数是一个I2cMsg数组。每个I2cMsg包含从机地址、读写标志、数据缓冲区、长度。比如读 GT911 的寄存器:

static int32_t Gt911ReadReg(struct Gt911Dev *dev, uint16_t reg, uint8_t *buf, uint16_t len) { int32_t ret; uint8_t regBuf[2]; struct I2cMsg msg[2]; regBuf[0] = (reg >> 8) & 0xFF; regBuf[1] = reg & 0xFF; msg[0].addr = dev->i2cAddr; msg[0].flags = 0; msg[0].buf = regBuf; msg[0].len = 2; msg[1].addr = dev->i2cAddr; msg[1].flags = I2C_FLAG_READ; msg[1].buf = buf; msg[1].len = len; ret = HdfI2cTransfer(dev->i2cHandle, msg, 2); if (ret != HDF_SUCCESS) { HDF_LOGE("i2c transfer failed, ret=%d", ret); } return ret; }

这里用了两个I2cMsg:第一个写寄存器地址,第二个读数据。中间没有停止条件,这是 I2C 的“重复起始”机制。如果你把两个消息分开调用,中间会插入停止条件,有些器件就不认了。所以读写寄存器一定要用消息数组,不要分两次调用。

写寄存器类似:

static int32_t Gt911WriteReg(struct Gt911Dev *dev, uint16_t reg, uint8_t *buf, uint16_t len) { int32_t ret; uint8_t *sendBuf = NULL; struct I2cMsg msg; sendBuf = (uint8_t *)OsalMemCalloc(len + 2); if (sendBuf == NULL) { return HDF_ERR_MALLOC_FAIL; } sendBuf[0] = (reg >> 8) & 0xFF; sendBuf[1] = reg & 0xFF; if (memcpy_s(sendBuf + 2, len, buf, len) != EOK) { OsalMemFree(sendBuf); return HDF_FAILURE; } msg.addr = dev->i2cAddr; msg.flags = 0; msg.buf = sendBuf; msg.len = len + 2; ret = HdfI2cTransfer(dev->i2cHandle, &msg, 1); OsalMemFree(sendBuf); return ret; }

注意memcpy_s是 OpenHarmony 的安全函数,比标准memcpy多一个目标缓冲区大小参数,防止溢出。这是 OpenHarmony 代码规范的要求,别用错了。

4.3 中断与轮询的取舍

GT911 这类触摸屏用中断通知主机有触摸事件,主机在中断处理里读坐标。但 I2C 读写本身是同步的,HdfI2cTransfer()会阻塞直到传输完成。在中断上下文里不能睡眠,所以你不能直接在中断处理函数里调HdfI2cTransfer()。正确做法是用工作队列或者线程化中断,把 I2C 读写放到进程上下文。

static irqreturn_t Gt911IrqHandler(int irq, void *data) { struct Gt911Dev *dev = (struct Gt911Dev *)data; OsalWorkQueueSchedule(dev->workQueue, &dev->work); return IRQ_HANDLED; } static void Gt911WorkHandler(void *data) { struct Gt911Dev *dev = (struct Gt911Dev *)data; uint8_t touchData[4]; Gt911ReadReg(dev, 0x814E, touchData, sizeof(touchData)); // 处理触摸数据 }

轮询模式适合那些没有中断引脚的器件,比如 EEPROM。你可以在定时器里周期性读取,但要注意频率别太高,否则占满 I2C 总线。我一般建议:能中断就中断,中断引脚不够用再考虑轮询,轮询周期至少 10ms 以上。

5. I2C 排障实战:从波形到日志的完整链路

5.1 先看硬件:上电、上拉、地址

排障第一步永远是硬件。我见过太多人代码查了半天,最后发现是器件没上电,或者上拉电阻没焊。检查清单如下:

  • 用万用表量 SCL 和 SDA 对地电压。空闲时应该是高电平,接近 VCC。如果只有 0.7V 左右,说明上拉电阻没焊或者阻值太大。
  • 量器件 VCC 引脚,确认供电正常。有些器件需要 1.8V 和 3.3V 双电源,漏接一个就不工作。
  • 确认地址引脚。很多器件的地址由 ADDR 引脚电平决定,比如 AT24C02 的 A0/A1/A2,接地是 0x50,接 VCC 是 0x57。你设备树里写的地址必须和硬件一致。
  • 检查 SCL/SDA 有没有接反。PCB 上丝印有时候会标错,用万用表蜂鸣档从主控引脚追到器件引脚。

如果硬件没问题,下一步上逻辑分析仪。逻辑分析仪比示波器更适合看 I2C,因为它能直接解码协议。抓一段波形,看起始条件、地址帧、ACK 位。如果地址帧后没有 ACK,说明从机没响应。这时候你可以在总线上只挂一个从机,排除地址冲突。

5.2 再看设备树:status、pinctrl、clock-frequency

设备树的问题通常表现为:/dev/i2c-3不存在,或者存在但读写返回-ENODEV。排查步骤:

  1. 确认status = "okay"。很多板级 DTS 默认是disabled,你忘了覆盖。
  2. 确认 pinctrl 正确。用cat /sys/kernel/debug/pinctrl/pinctrl-rockchip-pinctrl/pinmux-pins | grep i2c3查看引脚功能。
  3. 确认clock-frequency合理。设成 0 或者超过控制器支持的最大值,驱动会报错。
  4. 确认从机节点在控制器节点内部,reg属性是 7 位地址。
  5. 确认compatible字符串和驱动匹配。用ls /sys/bus/i2c/devices/看设备有没有注册成功。如果显示3-005d,说明地址 0x5d 的设备已经挂在总线 3 上了。

如果设备注册了但驱动没绑定,检查compatible是否和驱动里的of_device_id表匹配。OpenHarmony 的 HDF 驱动还需要在device_info.hcs里配置moduleName和serviceName,漏了任何一个都起不来。

5.3 后看驱动:日志、返回值、时序

驱动层的问题最隐蔽。我一般用以下手段定位:

  • 在HdfI2cTransfer()前后加日志,打印返回值。HDF_SUCCESS是 0,HDF_ERR_TIMEOUT是超时,HDF_ERR_IO是总线错误。
  • 用i2c-tools在 shell 里手动读写。OpenHarmony 的 shell 可能没有i2cget/i2cset,但你可以自己写一个测试程序,调用HdfI2cTransfer()读已知寄存器。比如读 GT911 的0x8140寄存器,应该返回厂商 ID。
  • 如果手动读写正常但驱动不正常,那就是驱动逻辑问题。检查消息数组的flags是否正确,读操作必须设I2C_FLAG_READ。
  • 如果读写偶尔失败,检查是否有并发访问。多个线程同时操作同一个 I2C 控制器,需要加互斥锁。HDF 的 I2C 控制器本身有锁,但你的设备驱动里如果有多个操作序列,最好自己再加一层。

5.4 常见问题速查表

现象可能原因排查方法解决措施
/dev/i2c-3不存在控制器 status 为 disabled查设备树设status = "okay"
扫描不到设备地址器件没上电或地址错量电压、查 datasheet修正供电或地址
地址有 ACK 但读写失败寄存器地址宽度错查器件手册改regWidth为 1 或 2
读写偶发失败上拉太弱或速度太高看波形上升沿减小上拉、降速
中断不触发GPIO 配错或中断类型错查 pinctrl 和中断标志改IRQ_TYPE_EDGE_FALLING
驱动加载失败HDF 配置缺失查device_info.hcs补moduleName和serviceName
数据全是 0xFF从机没驱动 SDA查从机供电和复位检查复位引脚时序

6. 几个容易踩的坑和我的实操心得

第一个坑:设备树里reg属性写成 8 位地址。比如 GT911 的 7 位地址是 0x5D,你写成 0xBA,内核会把它当成 7 位地址 0xBA 去寻址,肯定找不到设备。记住:设备树里永远填 7 位地址。

第二个坑:I2C 消息数组里读操作没设I2C_FLAG_READ。这样内核会当成写操作,从机收到的是读命令但主机在写数据,时序全乱。读操作必须显式设置标志位。

第三个坑:在中断上下文调 I2C 读写。I2C 传输可能睡眠,中断上下文不能睡眠,会导致内核崩溃或者警告。用工作队列或者线程化中断。

第四个坑:忽略重复起始条件。读写寄存器时,写寄存器地址和读数据之间不能有停止条件。用两个I2cMsg组成一个传输,而不是两次HdfI2cTransfer()。

第五个坑:上拉电阻照搬参考设计。参考设计用的是 4.7kΩ,但你的板子走线更长、器件更多,总线电容更大,4.7kΩ 可能就不够了。用示波器看上升沿,超过 1μs 就减小电阻。

我个人的经验是:I2C 排障,七成问题在硬件,两成在设备树,一成在驱动。所以每次遇到问题,先量电压、看波形,别一上来就改代码。逻辑分析仪是必备工具,几百块的那种就够用,能解码 I2C 就行。另外,OpenHarmony 的 HDF 框架比标准 Linux 多了一层配置,device_info.hcs和hdf_dev_xxx_config.hcs要配合着看,漏一个参数都跑不起来。

最后分享一个小技巧:如果你不确定器件地址,可以用一个简单的扫描程序,从 0x08 到 0x77 逐个发地址帧,看哪个地址有 ACK。但注意,有些器件在地址不匹配时会拉低 SDA 导致总线挂死,所以扫描前最好确认总线上没有会挂死的器件。如果总线挂了,给 SCL 发 9 个时钟脉冲,大部分器件会释放 SDA。这个技巧在调试 EEPROM 时特别有用。

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

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

立即咨询