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时就被这个坑过,调了半天才发现地址写错了。

注意: 7位地址和8位地址的转换。手册里给的地址值,一定要确认是左移前的还是左移后的。建议在HAL层统一用7位地址,驱动内部再左移。

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。

经验之谈: 时钟延展的超时时间设多少合适?我一般设5ms。太短容易误报,太长影响响应。如果遇到特殊从机,可以在设备驱动里单独配置。

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时的核心流程。从发起事务到最终完成,每一步都有检查点。

I2C主模式事务处理流程 发起I2C事务 检查总线空闲(超时?) 发送Start + 从机地址 等待ACK(含时钟延展) ACK? 发送/接收数据字节 ACK? 还有数据? 循环 发送Stop,事务完成 地址NACK → 发送Stop 返回 I2C_ERR_NACK_ADDR 数据NACK → 发送Stop 返回 I2C_ERR_NACK_DATA 错误处理路径

这张图里,我特别强调了两个判断点:地址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表示放弃。这样上层可以控制延展的最大时间。

一个小技巧: 从机模式下,收到Stop信号后,一定要清空内部状态。我曾经遇到一个bug,从机收到Stop后没清状态,下一次Start来的时候,还以为在传输中,数据全乱了。

4.8 避坑指南:我踩过的那些I2C的坑

最后,分享几个我实际项目中遇到的I2C问题,希望能帮你少走弯路。

  • 上拉电阻选不对:有一次总线速率上不去,查了半天,发现上拉电阻用了10kΩ,总线电容又大,信号上升沿太慢。换成2.2kΩ就好了。400kHz高速模式建议用1.5kΩ~2.2kΩ。
  • 中断里做I2C传输:千万别在中断里做完整的I2C传输。I2C的时钟延展会导致中断时间不可控。我一般用DMA+状态机的方式,中断里只处理完成标志。
  • 复位从机后不释放总线:有些从机复位后,SDA或SCL会被拉低。这时候主机要先发9个时钟脉冲,让从机释放总线。这叫“总线恢复”操作。
  • 多主机冲突处理不当:仲裁丢失后,要等总线空闲再重试。不能立即重试,否则大概率又冲突。我一般等一个完整的I2C帧时间(约1ms)再重试。

嗯,I2C看起来简单,但要做好、做可靠,细节不少。这一章的内容,基本覆盖了我在实际项目中遇到的大部分I2C问题。把这些机制设计好,你的HAL层就能应对绝大多数场景了。