4. I2C总线抽象:I2C主从模式HAL设计,多设备寻址、时钟延展、错误处理与超时机制
I2C总线,说白了就是嵌入式系统里的“电话线”。两根线——SCL和SDA,挂一堆设备,大家轮流说话。我做了这么多年嵌入式,几乎每个项目都离不开它。但越是常用的东西,坑越多。
这一章,咱们就聊聊怎么把I2C的HAL层设计得既通用又可靠。重点解决三个问题:多设备怎么不打架、从机拖时间怎么办、出错了怎么兜底。
4.1 I2C总线基础与HAL分层思路
先简单回顾一下I2C的物理层。两根线,上拉电阻,开漏输出。这意味着什么?任何设备都可以拉低总线。这是I2C灵活性的根源,也是很多问题的根源。
我习惯把I2C的HAL分成三层:
- 传输层:管最底层的Start/Stop/ACK/NACK时序,直接操作寄存器或GPIO。
- 消息层:封装成“发地址+发数据”或“发地址+收数据”这样的完整消息。
- 事务层:处理多消息组合,比如EEPROM的“先写地址再读数据”这种复合操作。
分层的好处很明显。底层换了MCU,只改传输层;上层业务逻辑不变。我在一个项目里把STM32的硬件I2C换成模拟I2C,只改了不到200行代码,就是因为分层做得好。
4.2 多设备寻址:7位地址与10位地址
I2C用地址区分设备。最常见的是7位地址,理论上支持128个设备,但实际可用的大概就100个左右(0x00保留给广播,0x01~0x07保留)。
10位地址用得少,但有些传感器会用。处理起来也不复杂,就是发两个字节的地址头。
我给出一个典型的地址枚举定义:
typedef enum {
I2C_ADDR_7BIT_MIN = 0x08,
I2C_ADDR_7BIT_MAX = 0x77,
I2C_ADDR_10BIT_FLAG = 0xF0, // 10位地址头标志
I2C_ADDR_GENERAL_CALL = 0x00 // 广播地址
} i2c_addr_type_t;
这里有个坑。很多芯片的地址是7位的,但数据手册里写的是8位值(左移了一位)。比如手册写0xA0,实际7位地址是0x50。我刚开始做I2C时就被这个坑过,调了半天才发现地址写错了。
4.3 时钟延展:从机的“慢一点”请求
时钟延展(Clock Stretching)是I2C里一个容易被忽略的特性。从机可以拉低SCL,告诉主机“我还没准备好,你等会儿”。
为什么会这样?因为从机处理数据需要时间。比如一个温度传感器,你发指令让它采集,它需要几十毫秒才能准备好数据。这时候它就会拉低SCL,让你等着。
我遇到过最夸张的情况,是一个老款的触摸屏控制器,时钟延展能持续50ms。如果主机没有超时机制,整个总线就卡死了。
处理时钟延展,核心就一句话:在SCL释放后,等待它被拉高,如果超时就报错。
代码实现大概是这样的:
static int i2c_wait_scl_high(i2c_bus_t *bus, uint32_t timeout_us) {
uint32_t tick = 0;
while (timeout_us--) {
if (gpio_read(bus->scl_pin) == 1) {
return 0; // SCL已释放,成功
}
delay_1us();
}
return -I2C_ERR_TIMEOUT; // 超时
}
注意,这个等待要在每个SCL时钟周期都做。不能只在开始等一次,后面就不管了。从机可能在任何时候拉低SCL。
4.4 错误处理:NACK、仲裁丢失与总线冲突
I2C的错误类型不多,但每种都得认真处理。我列个表:
| 错误类型 | 触发条件 | 典型原因 | 处理方式 |
|---|---|---|---|
| NACK(地址) | 发送从机地址后无ACK | 设备不存在、地址错误、设备忙 | 发送Stop,返回错误码 |
| NACK(数据) | 发送数据后无ACK | 从机无法接收、写保护、缓冲区满 | 发送Stop,返回错误码 |
| 仲裁丢失 | 多主机同时发送,自己输了 | 多主机冲突 | 释放总线,重试 |
| 时钟延展超时 | SCL被拉低超过设定时间 | 从机死锁、硬件故障 | 发送Stop,复位总线 |
| 总线空闲超时 | 总线长时间无活动 | 总线被拉死、上拉电阻失效 | 复位总线,重新初始化 |
错误码定义要清晰。我习惯用负数表示错误,0表示成功:
#define I2C_OK 0
#define I2C_ERR_NACK_ADDR -1
#define I2C_ERR_NACK_DATA -2
#define I2C_ERR_ARB_LOST -3
#define I2C_ERR_TIMEOUT -4
#define I2C_ERR_BUS_BUSY -5
这里有个细节。仲裁丢失发生后,硬件会自动释放总线。但软件层面,你需要重新发起整个事务,不能接着发。我见过有人只重发当前字节,结果数据错位了。
4.5 超时机制:别让系统死等
超时是I2C HAL里最重要的安全机制。没有超时,一个从机卡住,整个系统就挂了。
超时分两种:
- 字节级超时:每个字节传输的最大等待时间。主要应对时钟延展。
- 事务级超时:整个I2C事务的最大执行时间。防止从机一直不响应。
我一般这样配置:
typedef struct {
uint32_t byte_timeout_us; // 字节超时,默认5000us
uint32_t transaction_timeout_ms; // 事务超时,默认100ms
uint32_t retry_count; // 失败重试次数,默认3次
bool enable_clock_stretch; // 是否支持时钟延展
} i2c_timeout_config_t;
超时时间的设置要灵活。不同的从机,速度不一样。比如一个高速传感器,字节超时可以设短一点;一个慢速的EEPROM,就要设长一点。
我建议在设备驱动初始化时,调用HAL的接口单独配置超时参数:
i2c_timeout_config_t cfg = {
.byte_timeout_us = 10000, // 10ms,给慢速从机留余量
.transaction_timeout_ms = 200, // 200ms
.retry_count = 3,
.enable_clock_stretch = true
};
i2c_set_timeout(&bus, &cfg);
4.6 整体架构与流程图
下面这张图,是我设计I2C HAL时的核心流程。从发起事务到最终完成,每一步都有检查点。
这张图里,我特别强调了两个判断点:地址ACK和数据ACK。很多初学者只检查地址ACK,忽略了数据ACK。结果数据没发出去,还以为成功了。
4.7 从机模式HAL设计要点
从机模式比主机模式复杂一些。因为从机是被动的,你不知道主机什么时候会来访问你。
从机HAL的核心是状态机:
typedef enum {
I2C_SLAVE_IDLE, // 空闲,等待Start
I2C_SLAVE_ADDR, // 收到地址,正在匹配
I2C_SLAVE_RX, // 主机写数据(从机接收)
I2C_SLAVE_TX, // 主机读数据(从机发送)
I2C_SLAVE_STOP // 收到Stop,结束
} i2c_slave_state_t;
从机模式里,时钟延展是主动行为。当从机还没准备好数据时,可以拉低SCL,让主机等着。我建议在从机HAL里提供一个回调函数,让上层应用决定是否延展:
typedef bool (*i2c_slave_stretch_cb_t)(i2c_slave_t *slave, uint32_t elapsed_us);
回调返回true表示继续延展,false表示放弃。这样上层可以控制延展的最大时间。
4.8 避坑指南:我踩过的那些I2C的坑
最后,分享几个我实际项目中遇到的I2C问题,希望能帮你少走弯路。
- 上拉电阻选不对:有一次总线速率上不去,查了半天,发现上拉电阻用了10kΩ,总线电容又大,信号上升沿太慢。换成2.2kΩ就好了。400kHz高速模式建议用1.5kΩ~2.2kΩ。
- 中断里做I2C传输:千万别在中断里做完整的I2C传输。I2C的时钟延展会导致中断时间不可控。我一般用DMA+状态机的方式,中断里只处理完成标志。
- 复位从机后不释放总线:有些从机复位后,SDA或SCL会被拉低。这时候主机要先发9个时钟脉冲,让从机释放总线。这叫“总线恢复”操作。
- 多主机冲突处理不当:仲裁丢失后,要等总线空闲再重试。不能立即重试,否则大概率又冲突。我一般等一个完整的I2C帧时间(约1ms)再重试。
嗯,I2C看起来简单,但要做好、做可靠,细节不少。这一章的内容,基本覆盖了我在实际项目中遇到的大部分I2C问题。把这些机制设计好,你的HAL层就能应对绝大多数场景了。