4、硬件抽象层(HAL)设计:I2C总线的抽象与多主机驱动兼容设计
I2C总线,搞嵌入式的没人不知道。但说实话,真正把它抽象好、做到多主机兼容的项目,我见过的不多。
很多工程师写I2C驱动,就是对着芯片手册撸寄存器。换一个MCU,全部重写。换一个从机设备,又要改一堆。这哪是写驱动?这是在给自己挖坑。
我个人习惯是,从一开始就把I2C当成一个「通信通道」来抽象。不管你是ST、NXP还是GD32,不管主机是哪个,从机是谁,上层只管发数据、收数据。底层怎么折腾,那是HAL的事。
4.1 为什么I2C需要硬件抽象层?
你想想看,I2C协议本身是标准的:起始条件、从机地址、读写位、应答、数据字节、停止条件。但不同MCU的外设实现千差万别。
| 差异点 | MCU A(如STM32) | MCU B(如GD32) | MCU C(如NXP LPC) |
|---|---|---|---|
| 寄存器结构 | CR1/CR2/SR1/SR2 | CTL0/CTL1/STAT | CONSET/CONCLR/STAT |
| 中断处理 | 事件中断+错误中断 | 合并中断标志 | 状态机轮询 |
| 时钟配置 | APB时钟分频+CCR | CLK分频+上升时间 | SCL_HIGH+SCL_LOW |
| 多主机支持 | 硬件仲裁+状态机 | 有限硬件支持 | 纯软件仲裁 |
我在项目中遇到过最头疼的情况:一个板子上用了两颗不同厂家的MCU做I2C通信,从机驱动写得一模一样,结果一个跑得飞起,另一个死活不通。查了三天,发现是时钟延展的时序差异。从那以后,我下定决心要把I2C抽象层做干净。
4.2 分层架构:从底层到应用
我习惯把I2C软件栈分成四层。每一层只做自己的事,不越界。
I2C软件分层架构(自上而下)
- 应用层:传感器读写、EEPROM存取、显示屏配置。只调用抽象接口。
- 设备抽象层:封装具体从机设备的操作序列。比如「读温度」= 发命令+读2字节。
- 总线抽象层(HAL):提供统一的收发接口,屏蔽MCU差异。这是本章重点。
- MCU驱动层:直接操作寄存器,处理中断、DMA、时序。每个MCU一套。
说白了,总线抽象层就是给上层一个「干净」的接口,让上层不用关心底层是轮询还是中断,是硬件I2C还是GPIO模拟。
4.3 核心接口设计
接口设计要「够用」且「不冗余」。我见过有人把I2C抽象出十几个接口,结果一半没人用。我个人习惯只暴露三个核心接口,再加一个配置接口。
/* ============================================================
* i2c_hal.h — I2C总线抽象层接口
* ============================================================ */
#ifndef __I2C_HAL_H__
#define __I2C_HAL_H__
#include <stdint.h>
/* I2C传输状态枚举 */
typedef enum {
I2C_OK = 0,
I2C_BUSY = 1,
I2C_TIMEOUT = 2,
I2C_NACK = 3,
I2C_ERROR = 4
} i2c_status_t;
/* I2C配置结构体 */
typedef struct {
uint32_t clock_hz; /* I2C时钟频率,如100000、400000 */
uint8_t own_address; /* 本机地址(多主机模式用) */
uint8_t addr_mode; /* 0:7位地址, 1:10位地址 */
uint16_t timeout_ms; /* 超时时间,毫秒 */
void (*scl_pin_ctrl)(uint8_t level); /* GPIO模拟时用 */
void (*sda_pin_ctrl)(uint8_t level);
uint8_t (*sda_pin_read)(void);
} i2c_config_t;
/* ========== 核心接口 ========== */
/**
* @brief 初始化I2C总线
* @param bus_id 总线编号(多I2C场景)
* @param cfg 配置参数
* @return 状态码
*/
i2c_status_t i2c_hal_init(uint8_t bus_id, const i2c_config_t *cfg);
/**
* @brief 向从机写入数据
* @param bus_id 总线编号
* @param slave_addr 从机地址(7位左对齐)
* @param data 数据缓冲区
* @param len 数据长度
* @return 状态码
*/
i2c_status_t i2c_hal_write(uint8_t bus_id, uint8_t slave_addr,
const uint8_t *data, uint16_t len);
/**
* @brief 从从机读取数据
* @param bus_id 总线编号
* @param slave_addr 从机地址
* @param data 数据缓冲区
* @param len 期望读取长度
* @return 状态码
*/
i2c_status_t i2c_hal_read(uint8_t bus_id, uint8_t slave_addr,
uint8_t *data, uint16_t len);
/**
* @brief 组合传输(写+读,带重复起始条件)
* @param bus_id 总线编号
* @param slave_addr 从机地址
* @param wdata 写入数据
* @param wlen 写入长度
* @param rdata 读取缓冲区
* @param rlen 期望读取长度
* @return 状态码
*/
i2c_status_t i2c_hal_transfer(uint8_t bus_id, uint8_t slave_addr,
const uint8_t *wdata, uint16_t wlen,
uint8_t *rdata, uint16_t rlen);
#endif /* __I2C_HAL_H__ */
设计要点
bus_id参数支持多I2C总线场景,一个MCU可能有2-3个I2C外设。i2c_hal_transfer是关键接口,很多从机设备需要先写命令再读数据,重复起始条件必须支持。- 配置结构体中的
scl_pin_ctrl等函数指针,是为了兼容GPIO模拟I2C的场景。硬件I2C可以设为NULL。
4.4 多主机驱动兼容设计
多主机场景,说白了就是总线上有多个设备都能发起通信。I2C协议本身支持多主机仲裁,但很多MCU的硬件外设不支持,或者支持得很勉强。
我曾经在一个项目里,需要两个MCU通过I2C共享一组传感器数据。两个MCU都是主机,谁先抢到总线谁发。结果发现MCU A的硬件I2C在仲裁失败后直接挂死,连总线都不释放了。嗯,这就是典型的硬件兼容问题。
我的解决方案是:在HAL层增加一个「多主机兼容模式」。核心思路是——用软件状态机来管理总线所有权。
4.4.1 多主机状态机
/* 多主机状态机 */
typedef enum {
I2C_MASTER_IDLE, /* 空闲,可以发起传输 */
I2C_MASTER_WAITING, /* 等待总线空闲 */
I2C_MASTER_TRANSMITTING,/* 正在传输 */
I2C_MASTER_ARB_LOST, /* 仲裁丢失,退让 */
I2C_MASTER_ERROR /* 错误状态 */
} i2c_master_state_t;
/* 多主机控制块 */
typedef struct {
i2c_master_state_t state;
uint32_t backoff_us; /* 退让时间,微秒 */
uint8_t retry_count; /* 重试次数 */
uint8_t max_retry; /* 最大重试次数 */
} i2c_multi_master_t;
4.4.2 仲裁退让策略
当检测到仲裁丢失时,不能立即重试。我见过有人直接原地重试,结果两个主机反复碰撞,总线直接瘫痪。
| 策略 | 说明 | 适用场景 |
|---|---|---|
| 固定退让 | 仲裁丢失后等待固定时间(如100μs) | 主机数量固定,负载较轻 |
| 随机退让 | 在[0, max_backoff]范围内随机等待 | 多主机竞争激烈,减少碰撞概率 |
| 指数退让 | 每次重试加倍等待时间,直到成功或超时 | 高负载、多主机场景,最推荐 |
| 优先级退让 | 根据设备优先级设定不同退让基数 | 有主从关系的多主机系统 |
避坑指南
我曾经在一个项目里用了固定退让,两个主机都是100μs。结果每次仲裁丢失后同时重试,又同时丢失,陷入死循环。后来改成随机退让,问题解决。记住:多主机场景,随机化是好朋友。
4.5 超时与错误处理
I2C通信最怕什么?死等。从机拉低时钟延展,主机傻傻等着,整个系统卡死。
我的做法是:所有阻塞操作都必须带超时。超时时间从配置结构体传入,默认100ms。
/* 超时等待示例 */
static i2c_status_t wait_for_flag(volatile uint32_t *reg,
uint32_t mask,
uint32_t timeout_ms)
{
uint32_t tick_start = get_system_tick_ms();
while (!(*reg & mask)) {
if ((get_system_tick_ms() - tick_start) > timeout_ms) {
return I2C_TIMEOUT; /* 超时退出,不卡死 */
}
/* 可在此插入看门狗喂狗或任务切换 */
}
return I2C_OK;
}
错误处理策略
- NACK:从机无应答。重试3次,仍失败则返回错误,由上层决定是否复位总线。
- 超时:大概率是从机死机或总线被拉死。建议先发9个时钟脉冲恢复总线,再重新初始化。
- 仲裁丢失:多主机正常现象。按退让策略重试,不要立即报错。
- 总线错误:起始或停止条件异常。直接复位I2C外设,重新初始化。
4.6 实战:从STM32到GD32的驱动迁移
说个真实案例。我们有个产品原用STM32F103,I2C驱动直接操作寄存器。后来因为成本原因换成GD32F103。GD32的I2C外设和STM32不完全兼容,寄存器地址不同,标志位含义也有差异。
如果当初没有HAL层,整个应用层的传感器代码全要改。但因为我们有i2c_hal_init、i2c_hal_write、i2c_hal_read这套抽象接口,只需要重写MCU驱动层的三个函数,上层一行代码没动。
迁移工作量对比:
| 方案 | 修改文件数 | 修改代码行数 | 测试周期 |
|---|---|---|---|
| 无HAL层(直接操作寄存器) | 12个 | 约800行 | 2周 |
| 有HAL层(只改驱动层) | 2个 | 约150行 | 3天 |
这就是抽象的价值。你前期多花一天设计接口,后期能省下一周甚至一个月的时间。
4.7 小结
I2C总线的硬件抽象,核心就三件事:
- 接口统一:不管底层是什么MCU,上层看到的都是
read/write/transfer。 - 多主机兼容:用软件状态机+退让策略,弥补硬件差异。
- 超时兜底:所有阻塞操作必须带超时,系统不能死等。
做到这三点,你的I2C驱动就能在各种MCU之间自由迁移。下次换芯片,你只需要改驱动层那几百行代码,上层应用稳如泰山。