I2C驱动封装实战:主从模式与多设备总线管理

说实话,I2C这个协议,看着简单,用起来坑不少。

我刚入行那会儿,觉得I2C不就是两根线嘛,SDA和SCL,拉来拉去就完事了。结果呢?第一次做多设备项目,三个传感器挂一条总线上,死活读不到数据。查了两天,最后发现是地址冲突加上时序没处理好。嗯,从那以后,我再也不敢小看I2C的驱动封装了。

为什么I2C驱动需要封装?

你想想看,一个项目里可能挂着温度传感器、加速度计、EEPROM,甚至还有OLED屏幕。它们都挂在同一条I2C总线上。如果每个外设都自己写一套I2C读写逻辑,代码会变成什么样?

  • 重复的起始信号、停止信号代码散落在各处
  • 应答位处理方式不统一,有的用轮询,有的用中断
  • 总线冲突时,排查起来像大海捞针

说白了,I2C驱动封装的核心目的就两个:统一接口隔离硬件。我个人的习惯是,不管底层用哪个MCU,I2C的API长相必须一样。这样换芯片时,上层应用代码一行都不用改。

主模式驱动的分层设计

我一般把I2C主模式驱动分成三层。这个分层结构,我在好几个项目里验证过,挺稳的。

层级 职责 示例函数
硬件抽象层 直接操作寄存器,产生时序 i2c_hal_start(), i2c_hal_stop()
传输层 封装单字节/多字节读写,处理应答 i2c_master_write_byte(), i2c_master_read_bytes()
应用接口层 提供设备级别的读写API i2c_dev_read_reg(), i2c_dev_write_reg()

这里有个关键点:硬件抽象层必须做到平台无关。我在做STM32和GD32移植时,发现它们的I2C寄存器结构几乎一样,但位定义有差异。所以我在硬件抽象层里用宏做了隔离:

/* 硬件抽象层 - 平台无关的宏定义 */
#if defined(MCU_STM32)
  #define I2C_CR1_START   (1 << 8)
  #define I2C_CR1_STOP    (1 << 9)
#elif defined(MCU_GD32)
  #define I2C_CR1_START   (1 << 12)
  #define I2C_CR1_STOP    (1 << 13)
#endif

void i2c_hal_start(I2C_TypeDef *i2c) {
    i2c->CR1 |= I2C_CR1_START;
    /* 等待硬件清除START位,表示起始信号已发出 */
    while (i2c->CR1 & I2C_CR1_START);
}
我的经验:硬件抽象层不要做任何超时判断。超时处理放在传输层。这样硬件层可以保持最精简,方便后续做DMA或中断模式扩展。

从模式驱动的设计要点

从模式比主模式麻烦一些。为什么?因为从机是被动的,你不知道主机什么时候会来读你。

我记得有一次做电池管理芯片的从机驱动,主机每隔10ms来读一次电压数据。如果我在从机中断里做ADC转换,那肯定来不及。后来我改成双缓冲结构:

/* 从机数据缓冲区 */
static uint8_t slave_tx_buf[2][8];  /* 双缓冲 */
static uint8_t active_buf = 0;

/* 在应用层定期更新非活跃缓冲区 */
void slave_update_data(uint8_t *data, uint8_t len) {
    uint8_t next_buf = active_buf ^ 1;
    memcpy(slave_tx_buf[next_buf], data, len);
}

/* I2C从机中断回调 - 主机读请求 */
void i2c_slave_read_callback(void) {
    /* 直接返回当前活跃缓冲区数据 */
    for (int i = 0; i < 8; i++) {
        i2c_hal_send_byte(slave_tx_buf[active_buf][i]);
    }
    active_buf ^= 1;  /* 切换缓冲区 */
}

这个设计的好处是:中断服务函数里不做任何耗时操作,数据准备好了才切换缓冲区。嗯,这里要注意,缓冲区切换必须用原子操作,不然主机读到一半数据被换了,那就乱套了。

多设备总线管理的核心问题

一条I2C总线上挂多个设备,最怕什么?地址冲突总线锁死

地址冲突:很多I2C器件有多个可选地址,比如通过拉高拉低某个引脚来改变。我建议在驱动初始化时,做一个设备探测流程:

/* 探测总线上所有设备 */
void i2c_bus_scan(void) {
    uint8_t devices[128];
    uint8_t count = 0;
    
    for (uint8_t addr = 0x08; addr < 0x78; addr++) {
        if (i2c_master_probe(addr) == I2C_OK) {
            devices[count++] = addr;
        }
    }
    printf("Found %d devices: ", count);
    for (uint8_t i = 0; i < count; i++) {
        printf("0x%02X ", devices[i]);
    }
}

总线锁死:这是I2C最头疼的问题。从机拉低SCL不放,或者SDA被拉死,整个总线就废了。我曾经在一个产品中遇到过,EEPROM在写操作时突然掉电,SDA被拉低,导致后续所有I2C通信都失败。

避坑指南:我后来在驱动里加了一个总线恢复机制。每次通信前,先检查总线状态。如果发现SDA被拉低超过一定时间,就主动发送9个SCL时钟脉冲,让从机释放总线。这个技巧救了我好几次。
/* 总线恢复 - 发送9个时钟脉冲 */
void i2c_bus_recover(void) {
    /* 先拉高SDA,然后发送9个SCL脉冲 */
    i2c_hal_set_sda(1);
    for (int i = 0; i < 9; i++) {
        i2c_hal_set_scl(0);
        delay_us(5);
        i2c_hal_set_scl(1);
        delay_us(5);
    }
    /* 最后发一个STOP信号 */
    i2c_hal_stop();
}

设备管理表的设计

多设备管理,我习惯用一张设备表来统一管理。每个设备有一个ID、地址、以及对应的读写函数指针。

typedef struct {
    uint8_t  dev_id;       /* 设备ID */
    uint8_t  slave_addr;   /* 7位从机地址 */
    uint32_t timeout_ms;   /* 超时时间 */
    int32_t (*init)(void);           /* 初始化函数指针 */
    int32_t (*read)(uint8_t reg, uint8_t *buf, uint16_t len);
    int32_t (*write)(uint8_t reg, uint8_t *buf, uint16_t len);
} i2c_device_t;

/* 设备表 */
static i2c_device_t g_i2c_devices[] = {
    { .dev_id = 0, .slave_addr = 0x48, .timeout_ms = 100, 
      .init = temp_sensor_init, .read = temp_sensor_read, .write = NULL },
    { .dev_id = 1, .slave_addr = 0x50, .timeout_ms = 10, 
      .init = eeprom_init, .read = eeprom_read, .write = eeprom_write },
    /* 可以继续添加 */
};

这样做的好处是:上层应用只需要知道设备ID,不需要关心具体地址和底层实现。换设备时,改一下设备表就行。

实际项目中的注意事项

  • 时钟延展:有些从机处理慢,会拉低SCL。主机的硬件I2C外设一般会自动处理,但软件模拟I2C时,一定要在发送每个字节后检查SCL是否被拉低。
  • 重复起始条件:读寄存器时,先写寄存器地址,再读数据。中间用重复起始条件,不要发停止信号。不然有些从机会退出传输。
  • 错误处理:NACK、仲裁丢失、超时,这些错误必须分类处理。我一般会返回错误码,让上层决定是重试还是复位总线。

核心总结:

  1. I2C驱动分三层:硬件抽象层、传输层、应用接口层。层与层之间用结构体指针传递,不要用全局变量。
  2. 从机驱动用双缓冲,避免中断里做耗时操作。
  3. 多设备管理用设备表,统一管理地址、超时和函数指针。
  4. 总线锁死时,用9个时钟脉冲恢复。这个代码建议放在驱动初始化里,每次上电先跑一遍。

好了,这一章的内容就到这儿。下一章我们聊聊SPI驱动的封装,那个比I2C简单一些,但全双工的特性又带来新的挑战。到时候见。