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);
}
从模式驱动的设计要点
从模式比主模式麻烦一些。为什么?因为从机是被动的,你不知道主机什么时候会来读你。
我记得有一次做电池管理芯片的从机驱动,主机每隔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通信都失败。
/* 总线恢复 - 发送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、仲裁丢失、超时,这些错误必须分类处理。我一般会返回错误码,让上层决定是重试还是复位总线。
核心总结:
- I2C驱动分三层:硬件抽象层、传输层、应用接口层。层与层之间用结构体指针传递,不要用全局变量。
- 从机驱动用双缓冲,避免中断里做耗时操作。
- 多设备管理用设备表,统一管理地址、超时和函数指针。
- 总线锁死时,用9个时钟脉冲恢复。这个代码建议放在驱动初始化里,每次上电先跑一遍。
好了,这一章的内容就到这儿。下一章我们聊聊SPI驱动的封装,那个比I2C简单一些,但全双工的特性又带来新的挑战。到时候见。