4. I2C软件模拟:基于GPIO的Bit-Bang驱动实现

说实话,很多嵌入式工程师一听到“软件模拟I2C”,就觉得这是低端活儿。但我在项目中遇到过太多次——芯片的硬件I2C控制器不够用,或者某个引脚被复用了,这时候Bit-Bang就是救命稻草。而且,搞懂软件模拟,你对I2C协议的理解会比只用硬件控制器的人深得多。

这一章,我们就手撸一个基于GPIO的I2C Bit-Bang驱动。核心就三件事:延时控制、时序模拟、多主机仲裁。嗯,咱们一个一个来。

4.1 为什么需要软件模拟?

硬件I2C控制器当然好用,但总有它搞不定的时候:

  • 引脚不够用:硬件I2C引脚被其他外设占了,你又不想改PCB
  • 控制器数量有限:大部分MCU只有1-2个硬件I2C,挂太多设备会出问题
  • 特殊速率要求:有些传感器需要非标速率,硬件控制器不一定支持
  • 调试阶段:软件模拟可以随时调整时序,方便排查问题
我的习惯:量产项目我优先用硬件I2C,但原型验证阶段我经常先用Bit-Bang。原因很简单——改软件比改PCB快多了。

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);
}
我曾经踩过的坑:编译器优化会把空循环直接优化掉!一定要加volatile或者__NOP()。我有一回调了三天,最后发现是-O2优化把延时循环吃了,SCL波形完全不对。

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;
}
关键点:接收字节时,SDA方向切换要注意。发送完地址后,SDA要从输出切到输入。我习惯在每次操作前都明确设置GPIO方向,虽然慢一点,但不会出错。

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 仲裁丢失后的处理流程

一旦检测到仲裁丢失,主机必须:

  1. 立即释放SDA和SCL:设置为输入模式(高阻态)
  2. 停止当前传输:不能再继续发数据
  3. 等待总线空闲:检测到停止信号后,重新尝试
  4. 退避重试:可以加一个随机延时,避免两个主机同时重试又撞上
// 仲裁丢失处理
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驱动的整体架构和关键流程:

I2C Bit-Bang驱动架构图 I2C Bit-Bang 驱动核心架构 硬件抽象层(HAL) GPIO初始化 | SCL/SDA高低控制 | SDA电平读取 | GPIO方向切换 时序控制层 微秒级延时 | 半周期延时 | 速率自适应(100kHz/400kHz) 协议原语层 起始信号 | 停止信号 | 发送字节 | 接收字节 | ACK/NACK检测 高级功能层 多主机仲裁检测 | 仲裁丢失处理 | 总线空闲检测 | 退避重试 💡 每一层都依赖下层提供的接口,上层不需要关心底层GPIO的具体实现

总结一下:Bit-Bang I2C驱动就是四层结构——硬件抽象管GPIO,时序控制管速度,协议原语管通信,高级功能管仲裁。每一层各司其职,上层不需要关心底层怎么实现的。

我个人建议,初学者先把前两层调通,用逻辑分析仪看到正确的波形,再去做仲裁这种高级功能。一步一步来,别想一口吃成胖子。