4. I2C软件模拟:基于GPIO的Bit-Bang驱动实现
说实话,很多嵌入式工程师一听到“软件模拟I2C”,就觉得这是低端活儿。但我在项目中遇到过太多次——芯片的硬件I2C控制器不够用,或者某个引脚被复用了,这时候Bit-Bang就是救命稻草。而且,搞懂软件模拟,你对I2C协议的理解会比只用硬件控制器的人深得多。
这一章,我们就手撸一个基于GPIO的I2C Bit-Bang驱动。核心就三件事:延时控制、时序模拟、多主机仲裁。嗯,咱们一个一个来。
4.1 为什么需要软件模拟?
硬件I2C控制器当然好用,但总有它搞不定的时候:
- 引脚不够用:硬件I2C引脚被其他外设占了,你又不想改PCB
- 控制器数量有限:大部分MCU只有1-2个硬件I2C,挂太多设备会出问题
- 特殊速率要求:有些传感器需要非标速率,硬件控制器不一定支持
- 调试阶段:软件模拟可以随时调整时序,方便排查问题
4.2 Bit-Bang的核心:延时控制
I2C的时序说白了就是“什么时候拉高,什么时候拉低,等多久”。延时控制是Bit-Bang的命根子。标准模式100kHz,一个时钟周期是10μs,高低电平各5μs。快速模式400kHz,周期2.5μs,各1.25μs。
但问题来了——MCU的主频不一样,指令执行时间也不一样。你不能写死一个delay函数就完事。
4.2.1 延时函数的实现
我个人习惯用循环计数加校准的方式。先写一个基础延时函数,然后用示波器或者逻辑分析仪去校准。
// 基础延时函数(需要根据实际MCU频率校准)
static void i2c_delay_us(uint32_t us)
{
// 假设MCU主频72MHz,一个空循环大约4个时钟周期
// 1μs ≈ 18个循环(这个值需要实测校准)
uint32_t count = us * 18;
while (count--) {
__NOP(); // 空操作,防止编译器优化掉
}
}
// 半周期延时(用于SCL高低电平)
static void i2c_half_delay(uint32_t speed_hz)
{
// speed_hz: 100000(标准) 或 400000(快速)
uint32_t half_period_us = (1000000 / speed_hz) / 2;
i2c_delay_us(half_period_us);
}
4.2.2 自适应延时策略
不同MCU主频不同,你不可能每次都手动改。我建议用宏定义+编译时计算的方式:
// 根据主频自动计算延时计数
#define SYSCLK_HZ 72000000UL
#define CYCLES_PER_US (SYSCLK_HZ / 1000000)
// 假设4个时钟周期完成一次循环
#define DELAY_LOOPS_PER_US (CYCLES_PER_US / 4)
static void i2c_delay_us(uint32_t us)
{
volatile uint32_t count = us * DELAY_LOOPS_PER_US;
while (count--);
}
这样换MCU的时候,只需要改SYSCLK_HZ就行。当然,最好还是用逻辑分析仪确认一下实际波形。
4.3 GPIO模拟I2C时序
I2C的时序其实就几个基本操作:起始信号、停止信号、发送字节、接收字节、应答检测。我们一个一个来实现。
4.3.1 基本GPIO操作封装
// 假设我们已经定义了SDA和SCL的GPIO操作宏
#define I2C_SCL_HIGH() GPIO_SetBits(GPIOB, GPIO_Pin_6)
#define I2C_SCL_LOW() GPIO_ResetBits(GPIOB, GPIO_Pin_6)
#define I2C_SDA_HIGH() GPIO_SetBits(GPIOB, GPIO_Pin_7)
#define I2C_SDA_LOW() GPIO_ResetBits(GPIOB, GPIO_Pin_7)
#define I2C_SDA_READ() GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_7)
4.3.2 起始信号和停止信号
// 起始信号:SCL高电平时,SDA从高变低
static void i2c_start(void)
{
I2C_SDA_HIGH();
I2C_SCL_HIGH();
i2c_half_delay(I2C_SPEED);
I2C_SDA_LOW(); // SDA先拉低
i2c_half_delay(I2C_SPEED);
I2C_SCL_LOW(); // 然后SCL拉低,准备传输数据
}
// 停止信号:SCL高电平时,SDA从低变高
static void i2c_stop(void)
{
I2C_SDA_LOW();
I2C_SCL_HIGH();
i2c_half_delay(I2C_SPEED);
I2C_SDA_HIGH(); // SDA拉高
i2c_half_delay(I2C_SPEED);
}
4.3.3 发送一个字节
// 发送一个字节,返回从机的ACK应答
// 返回0: 收到ACK;返回1: 收到NACK
static uint8_t i2c_send_byte(uint8_t data)
{
uint8_t i;
uint8_t ack;
// 先发送高位(MSB first)
for (i = 0; i < 8; i++) {
if (data & 0x80) {
I2C_SDA_HIGH();
} else {
I2C_SDA_LOW();
}
data <<= 1; // 左移,准备下一位
// SCL拉高,从机采样
I2C_SCL_HIGH();
i2c_half_delay(I2C_SPEED);
I2C_SCL_LOW();
i2c_half_delay(I2C_SPEED);
}
// 第9个时钟:释放SDA,读取ACK
I2C_SDA_HIGH(); // 释放SDA,让从机控制
I2C_SCL_HIGH();
i2c_half_delay(I2C_SPEED);
ack = I2C_SDA_READ(); // 读取ACK位
I2C_SCL_LOW();
i2c_half_delay(I2C_SPEED);
return ack; // 0=ACK, 1=NACK
}
4.3.4 接收一个字节
// 接收一个字节,参数ack表示是否发送ACK给从机
// ack=0: 发送ACK(还想继续读)
// ack=1: 发送NACK(读完这个就不读了)
static uint8_t i2c_recv_byte(uint8_t ack)
{
uint8_t i;
uint8_t data = 0;
// 释放SDA,让从机控制
I2C_SDA_HIGH();
for (i = 0; i < 8; i++) {
data <<= 1; // 先左移,空出低位
I2C_SCL_HIGH();
i2c_half_delay(I2C_SPEED);
if (I2C_SDA_READ()) {
data |= 0x01; // 读取SDA电平
}
I2C_SCL_LOW();
i2c_half_delay(I2C_SPEED);
}
// 第9个时钟:主机发送ACK/NACK
if (ack) {
I2C_SDA_HIGH(); // NACK:不再读取
} else {
I2C_SDA_LOW(); // ACK:继续读取
}
I2C_SCL_HIGH();
i2c_half_delay(I2C_SPEED);
I2C_SCL_LOW();
i2c_half_delay(I2C_SPEED);
// 释放SDA
I2C_SDA_HIGH();
return data;
}
4.4 多主机仲裁处理
多主机仲裁是I2C协议里比较高级的话题。说白了就是:两个主机同时抢总线,谁先拉低SDA谁赢。
Bit-Bang模式下,仲裁检测其实不复杂——你在发送数据的时候,同时读一下SDA的电平。如果你发的是高电平,但读到的是低电平,说明有其他主机在拉低总线,你就输了。
4.4.1 仲裁丢失检测
// 带仲裁检测的字节发送
// 返回值:0=发送成功,1=仲裁丢失
static uint8_t i2c_send_byte_arbitration(uint8_t data)
{
uint8_t i;
for (i = 0; i < 8; i++) {
if (data & 0x80) {
I2C_SDA_HIGH();
} else {
I2C_SDA_LOW();
}
data <<= 1;
I2C_SCL_HIGH();
i2c_half_delay(I2C_SPEED);
// 仲裁检测:如果SDA电平与发送的不一致
if ((data & 0x80) != I2C_SDA_READ()) {
// 仲裁丢失!立即释放总线
I2C_SDA_HIGH();
I2C_SCL_LOW();
return 1; // 返回仲裁丢失
}
I2C_SCL_LOW();
i2c_half_delay(I2C_SPEED);
}
// ACK检测(略,同上)
return 0;
}
4.4.2 仲裁丢失后的处理流程
一旦检测到仲裁丢失,主机必须:
- 立即释放SDA和SCL:设置为输入模式(高阻态)
- 停止当前传输:不能再继续发数据
- 等待总线空闲:检测到停止信号后,重新尝试
- 退避重试:可以加一个随机延时,避免两个主机同时重试又撞上
// 仲裁丢失处理
static void i2c_arbitration_lost(void)
{
// 释放总线
I2C_SDA_INPUT(); // 设置为输入(高阻)
I2C_SCL_INPUT();
// 等待总线空闲(检测到停止信号)
while (!i2c_bus_idle()) {
// 可以加个超时,防止死等
}
// 随机退避(简单实现)
volatile uint32_t delay = rand() % 100;
while (delay--);
}
4.5 完整驱动框架
把上面的函数组合起来,就是一个完整的Bit-Bang I2C驱动:
// I2C读寄存器:先写地址,再读数据
uint8_t i2c_read_reg(uint8_t dev_addr, uint8_t reg_addr)
{
uint8_t data;
i2c_start();
// 发送设备地址+写位
if (i2c_send_byte(dev_addr << 1 | 0)) {
i2c_stop();
return 0xFF; // 设备无应答
}
// 发送寄存器地址
i2c_send_byte(reg_addr);
// 重复起始信号
i2c_start();
// 发送设备地址+读位
i2c_send_byte(dev_addr << 1 | 1);
// 读取数据,发送NACK
data = i2c_recv_byte(1);
i2c_stop();
return data;
}
4.6 本章核心知识图谱
下面这张图总结了Bit-Bang I2C驱动的整体架构和关键流程:
总结一下:Bit-Bang I2C驱动就是四层结构——硬件抽象管GPIO,时序控制管速度,协议原语管通信,高级功能管仲裁。每一层各司其职,上层不需要关心底层怎么实现的。
我个人建议,初学者先把前两层调通,用逻辑分析仪看到正确的波形,再去做仲裁这种高级功能。一步一步来,别想一口吃成胖子。