1. 从一根线说起:I2C 到底解决了什么问题
搞嵌入式开发的人,迟早会跟 I2C 打交道。不管你是在 OpenHarmony 上接一颗温湿度传感器,还是在 RK3568 上调试一颗触摸屏,I2C 几乎无处不在。但很多人对它的理解停留在“两根线,一根 SCL 一根 SDA,挂一堆设备”这个层面,真到了设备不通、数据读不出来的时候,就抓瞎了。
这篇内容,我打算把 I2C 从协议原理到 OpenHarmony 上的实战用法,再到排障思路,完整地捋一遍。适合谁看?如果你正在做 OpenHarmony 驱动开发,或者刚接触嵌入式外设调试,又或者你已经在用 I2C 但遇到问题不知道怎么下手,那这篇内容应该能帮到你。我会尽量用大白话把时序、设备树配置、HDI 接口这些容易让人懵的东西讲清楚,同时给出可以直接参考的操作步骤和排查方法。
先说结论:I2C 的核心价值在于用最少的引脚挂最多的设备。两根线,理论上可以挂 112 个设备(7 位地址去掉保留地址),每个设备有自己的地址,主设备通过地址来区分跟谁说话。这在引脚资源紧张的嵌入式场景里,简直是救命稻草。但代价是什么?代价是它比 SPI 慢,比 UART 复杂,而且一旦总线上某个设备抽风,可能把整条总线拉死。所以,会用 I2C 只是第一步,会排障才是真正的分水岭。
2. I2C 协议核心机制拆解:别被时序图吓到
2.1 两根线背后的电气逻辑
I2C 只有两根信号线:SCL(串行时钟线)和SDA(串行数据线)。这两根线都是开漏输出,什么意思呢?就是每个设备只能把线拉低,不能主动拉高。线要变高,靠的是上拉电阻。这就好比一个会议室里所有人只能举手反对,不能举手赞成,默认状态是“赞成”,谁反对谁举手把线拉低。
这个设计带来的好处是:多设备可以同时挂在一根线上,不会因为某个设备输出高电平而跟另一个输出低电平的设备打架。因为谁都不能输出高电平,只能拉低或者释放,释放后由上拉电阻把线拉高。
上拉电阻选多大?这是第一个容易踩坑的地方。典型值是 4.7kΩ,但这不是固定的。电阻越小,上升沿越陡,能跑的速度越高,但功耗越大;电阻越大,功耗越低,但上升沿变缓,高速通信时可能还没到高电平就被拉低了。经验做法是:标准模式(100kHz)用 4.7kΩ 到 10kΩ,快速模式(400kHz)用 2.2kΩ 到 4.7kΩ,快速模式+(1MHz)用 1kΩ 左右。如果你总线上挂了比较多设备,电容负载增大,上拉电阻也要相应减小。
注意:有些模块自带上拉电阻,你再去主板上加一对,并联之后阻值变小,可能导致上升沿过冲。调试时先用示波器看一眼波形,比盲目换电阻靠谱得多。
2.2 起始、停止、应答:三个必须刻在脑子里的动作
I2C 通信的每一个字节传输,都围绕三个基本动作展开:
起始条件(START):SCL 保持高电平的时候,SDA 从高变低。这个动作只能由主设备发起,意思是“我要开始说话了,大家注意听”。
停止条件(STOP):SCL 保持高电平的时候,SDA 从低变高。意思是“我说完了,总线释放”。
应答(ACK/NACK):每传输完 8 位数据,接收方要在第 9 个时钟周期把 SDA 拉低,表示“收到了”。如果接收方不拉低,就是 NACK,表示“没收到”或者“不要再发了”。
这三个动作是 I2C 排障时最直接的观察点。用逻辑分析仪抓波形,第一眼就看起始条件有没有、地址发出去之后有没有 ACK。如果地址阶段就 NACK,说明从设备根本没响应,要么地址错了,要么设备没上电,要么线没接好。如果数据阶段 NACK,可能是从设备忙不过来,或者寄存器地址不合法。
2.3 7 位地址和 10 位地址:实际用哪个
I2C 支持 7 位和 10 位两种地址格式。7 位地址是主流,一个字节里高 7 位是地址,最低位是读写标志位(0 写,1 读)。10 位地址用得少,一般只在 7 位地址不够分配的时候才用。
实际开发中,你拿到的传感器手册上写的地址,通常是 7 位地址。但有些手册给的是 8 位地址(已经把读写位算进去了),这时候你要自己右移一位。比如手册写 0x92,实际 7 位地址是 0x49。这个坑我踩过不止一次,地址对不上,怎么调都不通,最后发现是手册给的是 8 位格式。
2.4 时钟拉伸:从设备也会“拖堂”
I2C 有一个很人性化的机制叫时钟拉伸(Clock Stretching)。从设备如果处理不过来,可以在接收完一个字节后把 SCL 拉低,强制主设备等待。等从设备准备好了,再释放 SCL,通信继续。
这个机制在调试低速传感器时经常遇到。比如某些温湿度传感器转换一次需要几十毫秒,它就会在转换期间拉低 SCL。如果你用的主控不支持时钟拉伸,或者驱动里没处理这个情况,就会读出错数据。排查时如果发现 SCL 被长时间拉低,先别急着怀疑硬件坏了,看看是不是从设备在忙。
3. OpenHarmony 下的 I2C 驱动框架:HDI 接口怎么用
3.1 从 HDF 到 HDI:OpenHarmony 的驱动分层
OpenHarmony 的驱动框架叫 HDF(Hardware Driver Foundation),它把驱动分成内核态和用户态两部分。I2C 作为一种标准总线,在 HDF 里有对应的抽象层。而 HDI(Hardware Device Interface)是给上层应用提供的统一接口,让应用不需要关心底层是哪个 SoC、哪个 I2C 控制器。
具体来说,OpenHarmony 的 I2C 驱动栈大概是这样:
- 最底层是 SoC 的 I2C 控制器驱动,负责操作寄存器、产生时序。
- 中间是 HDF 的 I2C 核心层,提供统一的 I2C 设备管理。
- 上层是 HDI 接口,暴露给应用层调用。
你在应用层调用的I2cOpen、I2cTransfer这些接口,最终会走到内核里的控制器驱动,完成实际的波形收发。
3.2 设备树配置:I2C 设备怎么“挂上去”
在 OpenHarmony 里,I2C 设备的配置通常写在设备树(Device Tree)里。设备树的作用是告诉内核:哪个 I2C 控制器使能了、总线上挂了哪些设备、每个设备的地址是多少、用哪个驱动。
一个典型的 I2C 设备树节点长这样:
&i2c1 { status = "okay"; clock-frequency = <400000>; sensor@48 { compatible = "vendor,tmp102"; reg = <0x48>; status = "okay"; }; };这里几个关键点:
status = "okay"表示这个 I2C 控制器使能了。如果写成disabled,整条总线都不工作。clock-frequency是总线时钟频率,单位 Hz。400000 就是 400kHz。reg = <0x48>是从设备的 7 位地址。compatible是驱动匹配字符串,内核靠它找到对应的驱动。
常见坑点:地址写错、频率设太高导致通信不稳定、status忘了改。我遇到过好几次,设备树里status默认是disabled,改完忘了编译进内核,结果怎么调都不通。
3.3 HDI 接口调用流程
在 OpenHarmony 应用层或 HAL 层,通过 HDI 接口操作 I2C 的大致流程是:
I2cOpen(busNum):打开指定编号的 I2C 控制器,拿到一个句柄。- 构造
I2cMsg数组:每个 Msg 包含从设备地址、读写标志、数据缓冲区、数据长度。 I2cTransfer(handle, msgs, count):执行传输。I2cClose(handle):关闭句柄。
一个读寄存器的典型操作是两次传输:先写寄存器地址,再读数据。有些驱动支持组合传输(Repeated START),可以在一次 Transfer 里完成。
I2cMsg msgs[2]; msgs[0].addr = 0x48; msgs[0].flags = 0; // 写 msgs[0].buf = ®Addr; msgs[0].len = 1; msgs[1].addr = 0x48; msgs[1].flags = I2C_FLAG_READ; msgs[1].buf = readBuf; msgs[1].len = 2; I2cTransfer(handle, msgs, 2);提示:组合传输时,两次 Msg 之间会产生 Repeated START,而不是 STOP 再 START。这个区别在有些从设备上是致命的,必须用 Repeated START 才能正确读取。
4. 实操:在 RK3568 上点亮一颗 I2C 传感器
4.1 硬件准备和接线检查
我拿 RK3568 开发板接一颗 TMP102 温度传感器来演示。接线很简单:
- VCC 接 3.3V
- GND 接 GND
- SCL 接 I2C1_SCL
- SDA 接 I2C1_SDA
- ADD0 接地(决定地址是 0x48)
接完线,第一件事不是上电写代码,而是用万用表测一下 SCL 和 SDA 对地的电阻。如果阻值接近 0,说明线短路了,先查线。正常应该有上拉电阻的阻值,比如 4.7kΩ 左右。
然后上电,用示波器或者逻辑分析仪看 SCL 和 SDA 的静态电平。正常应该是高电平。如果是低电平,说明总线被某个设备拉死了,可能是设备坏了或者地址冲突。
4.2 设备树修改和内核编译
在 RK3568 的 SDK 里找到对应的设备树文件,通常是kernel/arch/arm64/boot/dts/rockchip/rk3568-xxx.dts。找到&i2c1节点,改成:
&i2c1 { status = "okay"; clock-frequency = <100000>; tmp102@48 { compatible = "ti,tmp102"; reg = <0x48>; status = "okay"; }; };这里我故意把频率设成 100kHz,因为第一次调试,低速更稳。等通了再往上提。
编译内核和设备树,烧录,重启。然后进系统,用i2cdetect工具扫一下总线:
i2cdetect -y 1如果看到地址 0x48 处显示48,说明设备被识别到了。如果显示--,说明没识别到,回去查接线和设备树。
4.3 用 HDI 接口读取温度数据
设备识别到之后,写一个简单的测试程序,通过 HDI 接口读温度寄存器(TMP102 的温度寄存器地址是 0x00):
int16_t read_temperature(int handle) { uint8_t reg = 0x00; uint8_t buf[2] = {0}; I2cMsg msgs[2]; msgs[0].addr = 0x48; msgs[0].flags = 0; msgs[0].buf = ® msgs[0].len = 1; msgs[1].addr = 0x48; msgs[1].flags = I2C_FLAG_READ; msgs[1].buf = buf; msgs[1].len = 2; if (I2cTransfer(handle, msgs, 2) != 0) { return -1; } int16_t raw = (buf[0] << 8) | buf[1]; return raw >> 4; // TMP102 是 12 位数据,右移 4 位 }读出来的 raw 值乘以 0.0625 就是摄氏度。比如 raw 是 400,温度就是 25°C。
4.4 逻辑分析仪抓波形验证
代码跑通之后,我习惯用逻辑分析仪抓一段波形,确认时序没问题。重点看几个地方:
- 起始条件是否干净
- 地址 0x48 写操作后是否有 ACK
- 寄存器地址 0x00 写完后是否有 Repeated START
- 读操作的两个字节后,主设备是否发了 NACK 再 STOP
如果这些都对,说明通信完全正常。如果哪里不对,波形会直接告诉你问题出在哪个阶段。
5. I2C 排障实战:从现象到根因的排查路径
5.1 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 扫描不到设备 | 接线错误、设备没上电、地址不对 | 万用表测电压、示波器看波形、确认地址格式 |
| 地址阶段 NACK | 从设备地址错误、设备未就绪 | 核对手册地址、检查上电时序 |
| 数据阶段 NACK | 寄存器地址不合法、设备忙 | 查手册确认寄存器、增加延时 |
| 读数据全 0 或全 FF | 时序问题、时钟拉伸未处理 | 逻辑分析仪抓波形、降低时钟频率 |
| 总线被拉死 | 某个设备故障、地址冲突 | 逐个断开设备、测静态电平 |
| 通信偶尔出错 | 上拉电阻不合适、线太长 | 调整上拉电阻、缩短走线 |
5.2 典型排障案例:GT911 触摸屏 I2C 通信失败
GT911 是一颗常见的触摸屏控制器,用 I2C 通信。我遇到过好几次 GT911 通信失败的情况,总结下来大概有几种原因:
第一种:上电时序不对。GT911 对复位和上电的顺序有要求,如果复位引脚和电源的上电顺序错了,芯片可能不响应 I2C。解决方法是严格按照手册的时序图,先给电再释放复位,中间加足够的延时。
第二种:地址冲突。GT911 的 I2C 地址可以通过 INT 引脚在上电时决定,是 0x5D 还是 0x14。如果 INT 引脚状态不对,地址就错了。排查时先用i2cdetect扫一遍,看看到底出现在哪个地址。
第三种:中断引脚配置错误。GT911 用中断引脚通知主控有触摸事件,如果中断引脚没配置好,虽然 I2C 能通,但读不到数据。这时候要检查设备树里中断引脚的配置。
5.3 总线死锁:最头疼的情况
I2C 总线死锁是嵌入式开发里最让人头疼的问题之一。现象是 SCL 或 SDA 被某个设备一直拉低,主设备无法发起新的通信。
造成死锁的常见原因:主设备在从设备还没释放 SDA 的时候就发了 STOP,或者从设备在传输过程中复位了,导致它一直拉着 SDA 不放。
恢复方法:给从设备发 9 个时钟脉冲,让它在第 9 个脉冲后释放 SDA,然后发一个 STOP 条件。很多 SoC 的 I2C 控制器支持总线恢复功能,可以在驱动里配置。
实操心得:如果总线上挂了多个设备,建议每个设备的电源单独控制,出问题时可以逐个断电排查。另外,在 SDA 和 SCL 上预留测试点,方便接逻辑分析仪。
5.4 时钟频率和上拉电阻的联合调试
很多时候通信不稳定,不是代码问题,而是时钟频率和上拉电阻不匹配。频率越高,对上升沿的要求越陡,上拉电阻就要越小。但电阻太小,功耗又上去了。
我的经验做法是:先用 100kHz 和 4.7kΩ 跑通,然后逐步提高频率,同时用示波器观察上升沿。如果上升沿变缓,就减小上拉电阻。最终找到一个稳定工作的组合。
另外,总线电容也是影响因素。线越长、挂的设备越多,电容越大,上升沿越缓。如果总线电容超过 400pF,标准模式都可能跑不稳。这时候要么缩短线,要么用 I2C 缓冲器/中继器。
6. 进阶话题:I2C 扩展和多路复用
6.1 地址冲突怎么办:I2C 多路复用器
当你需要挂多个相同型号的传感器时,地址冲突就来了。比如你要接 4 颗同样的温度传感器,它们的地址都是 0x48,没法直接挂在一起。
解决方案是用I2C 多路复用器,比如 TCA9548A。它本身是一个 I2C 从设备,有 8 个下游通道。你通过写它的寄存器来选择哪个通道导通,这样每个通道上挂一个 0x48 的传感器,互不干扰。
在 OpenHarmony 里使用多路复用器,需要在设备树里把多路复用器作为 I2C 设备配好,然后在驱动里先写多路复用器选择通道,再操作下游设备。
6.2 软件 I2C vs 硬件 I2C
有些场景下硬件 I2C 控制器不够用,或者引脚被占用了,就需要用 GPIO 模拟 I2C,也就是软件 I2C。
软件 I2C 的优点是灵活,任意 GPIO 都能用;缺点是占用 CPU,速度慢,时序精度不如硬件。在 OpenHarmony 里,如果要用软件 I2C,需要自己实现时序控制,或者用内核提供的i2c-gpio驱动。
我的建议是:能用硬件 I2C 就用硬件,软件 I2C 只作为备选方案。特别是高速通信场景,软件 I2C 很容易出问题。
6.3 I2C 和 SMBus 的区别
SMBus 是基于 I2C 的一个子集,主要用于电源管理和系统监控。它比 I2C 多了超时机制,时钟频率固定在 10kHz 到 100kHz 之间。
实际开发中,很多电源管理芯片用的是 SMBus,但物理层跟 I2C 兼容。如果你用 I2C 驱动去操作 SMBus 设备,大部分情况下能通,但要注意超时和协议细节的差异。
7. 几个容易忽略的细节和实操建议
7.1 地址格式的坑
再强调一遍:手册给的地址可能是 8 位格式,实际用的时候要右移一位。我见过太多人在这上面浪费时间。拿到地址后,先确认是 7 位还是 8 位,不确定就用i2cdetect扫一遍,看实际出现在哪个地址。
7.2 上电顺序和复位时序
很多 I2C 设备对电源和复位的顺序有要求。比如某些传感器要求先给电,等电源稳定后再释放复位,中间要延时几毫秒。如果顺序错了,设备可能不响应 I2C。
排查时,先用示波器看电源和复位引脚的波形,确认时序符合手册要求。
7.3 中断引脚不要忘
很多 I2C 设备有中断引脚,用来通知主控数据准备好了。如果你只配了 I2C 没配中断,虽然能轮询读数据,但效率低,而且可能错过快速变化的事件。设备树里记得把中断引脚也配好。
7.4 逻辑分析仪是必备工具
调试 I2C,逻辑分析仪比万用表有用得多。它能直接告诉你起始条件、地址、ACK、数据、停止条件,一眼就能看出问题在哪。便宜的逻辑分析仪几十块钱,但能省你几个小时甚至几天的调试时间。
7.5 设备树编译要确认
改完设备树,一定要确认编译进了内核。有时候改了 dts 但没重新编译,或者编译了但烧录的不是新的,都会导致配置不生效。我习惯改完之后用fdtdump或者/proc/device-tree确认一下实际生效的配置。
8. 写在最后:一些个人体会
I2C 这个东西,入门容易,精通难。协议本身不复杂,但实际调试中遇到的问题千奇百怪。我的经验是:遇到问题先别改代码,先用工具看波形。波形不会骗人,它会直接告诉你问题出在哪个阶段。
另外,设备树配置和硬件接线是两大高频问题源。很多时候代码没问题,就是设备树里某个参数写错了,或者接线松了。养成“先查硬件再查软件”的习惯,能省很多时间。
最后分享一个小技巧:如果你在 OpenHarmony 上调试 I2C 设备,可以先用 Linux 下的i2c-tools验证硬件通路,确认设备能扫到、能读写,再去写 HDI 代码。这样能把硬件问题和软件问题分开,排查起来更有条理。