4. 软件模拟I2C实现:GPIO位带操作模拟时序

讲完了I2C协议的理论,咱们得动真格的了。这一章,我带你手写一个软件模拟的I2C驱动。说白了,就是用GPIO引脚,通过程序精确控制高低电平,把I2C的时序“比划”出来。

为什么要软件模拟?硬件I2C外设不好用吗?好用的,但不够灵活。我在项目里遇到过好几次,芯片的I2C外设引脚被其他功能占用了,或者I2C总线需要接到任意两个GPIO上。这时候,软件模拟就是唯一的出路。你想想看,能兼容任意GPIO引脚,这多自由。

4.1 核心思想:用GPIO“演”出I2C协议

软件模拟I2C,核心就一句话:用程序控制GPIO的输出和输入,严格按照I2C时序图来操作。我们需要实现四个基本动作:起始信号、停止信号、发送一个字节、接收一个字节。有了这四个函数,剩下的读写操作就是排列组合了。

我个人习惯把SCL和SDA的宏定义放在头文件里,方便移植。比如这样:

#define I2C_SCL_PIN      GPIO_PIN_6
#define I2C_SDA_PIN      GPIO_PIN_7
#define I2C_GPIO_PORT    GPIOB

#define SCL_HIGH()       HAL_GPIO_WritePin(I2C_GPIO_PORT, I2C_SCL_PIN, GPIO_PIN_SET)
#define SCL_LOW()        HAL_GPIO_WritePin(I2C_GPIO_PORT, I2C_SCL_PIN, GPIO_PIN_RESET)
#define SDA_HIGH()       HAL_GPIO_WritePin(I2C_GPIO_PORT, I2C_SDA_PIN, GPIO_PIN_SET)
#define SDA_LOW()        HAL_GPIO_WritePin(I2C_GPIO_PORT, I2C_SDA_PIN, GPIO_PIN_RESET)
#define SDA_READ()       HAL_GPIO_ReadPin(I2C_GPIO_PORT, I2C_SDA_PIN)

嗯,这里要注意,宏定义里不要加分号,否则在代码里容易写出两个分号,编译报错还不好找。

4.2 起始信号与停止信号

这两个信号是I2C通信的“开关”。起始信号:SCL高电平时,SDA从高变低。停止信号:SCL高电平时,SDA从低变高。时序图我画在下面了,你对照着看。

关键点:起始和停止信号发生时,SCL必须为高电平。这是协议规定的,不能搞错。

I2C 起始与停止信号时序 SCL SDA 起始 SDA下降沿 停止 SDA上升沿 SCL高电平时,SDA变化才有效

代码实现很简单,但延迟时间要调好。标准模式100kHz,一个周期10μs,高低电平各5μs。快速模式400kHz,周期2.5μs。我一般用delay_us()函数,精度到微秒级。

void I2C_Start(void)
{
    SDA_HIGH();
    SCL_HIGH();
    delay_us(5);    // 保持稳定
    SDA_LOW();      // SCL高电平时,SDA下降沿
    delay_us(5);
    SCL_LOW();      // 拉低SCL,准备传输数据
}

void I2C_Stop(void)
{
    SDA_LOW();
    SCL_HIGH();
    delay_us(5);
    SDA_HIGH();     // SCL高电平时,SDA上升沿
    delay_us(5);
}

小技巧:起始信号后,SCL一定要拉低。因为后续的数据传输都是在SCL低电平时改变SDA,高电平时采样。如果起始后SCL还保持高,下一个数据位就会出错。

4.3 发送一个字节

发送字节是I2C通信里最频繁的操作。流程是:从高位到低位,逐位发送。每发送一位,SCL都要产生一个脉冲。发送完8位后,还要释放SDA,等待从机的应答。

我曾经在一个项目里,因为应答检测没做超时处理,结果从机死机了,主机就一直卡在等待应答的循环里。从那以后,我写的每个I2C驱动,应答检测都加了超时机制。

uint8_t I2C_SendByte(uint8_t data)
{
    uint8_t i;
    for(i = 0; i < 8; i++)
    {
        if(data & 0x80)  // 先发高位
            SDA_HIGH();
        else
            SDA_LOW();
        data <<= 1;      // 左移,准备下一位
        SCL_HIGH();      // 拉高SCL,从机采样
        delay_us(5);
        SCL_LOW();       // 拉低SCL,允许SDA变化
        delay_us(5);
    }
    
    // 释放SDA,等待应答
    SDA_HIGH();
    SCL_HIGH();
    delay_us(5);
    uint8_t ack = SDA_READ();  // 读取应答位
    SCL_LOW();
    delay_us(5);
    
    return ack;  // 0: 应答(ACK), 1: 非应答(NACK)
}

避坑指南:我曾经在发送完字节后,忘记释放SDA(即把SDA设置为输入或输出高),结果SDA一直被拉低,从机无法发送应答。检查了半天才发现是GPIO模式没切换。记住:发送完数据后,SDA要释放,让从机控制。

4.4 接收一个字节

接收字节和发送类似,但方向相反。主机释放SDA,从机控制SDA,主机在SCL高电平时读取SDA的电平。接收完8位后,主机要发送应答或非应答。

uint8_t I2C_RecvByte(uint8_t ack)
{
    uint8_t i, data = 0;
    
    // 释放SDA,让从机控制
    SDA_HIGH();
    
    for(i = 0; i < 8; i++)
    {
        data <<= 1;      // 左移,为接收位腾出空间
        SCL_HIGH();      // 拉高SCL,从机输出数据
        delay_us(5);
        if(SDA_READ())
            data |= 0x01;  // 读取SDA电平
        SCL_LOW();       // 拉低SCL,允许从机改变SDA
        delay_us(5);
    }
    
    // 发送应答/非应答
    if(ack)
        SDA_HIGH();      // 非应答(NACK)
    else
        SDA_LOW();       // 应答(ACK)
    SCL_HIGH();
    delay_us(5);
    SCL_LOW();
    delay_us(5);
    SDA_HIGH();          // 释放SDA
    
    return data;
}

这里有个细节:接收数据时,SDA要先拉高,确保GPIO处于输入模式(或者开漏输出+上拉)。如果GPIO配置成推挽输出且输出低电平,那从机怎么拉都拉不高,通信就失败了。

4.5 兼容任意GPIO引脚的实现

前面写的代码都是固定引脚。要兼容任意GPIO,我们需要把引脚信息作为参数传递。我习惯用一个结构体来封装:

typedef struct {
    GPIO_TypeDef* port;
    uint16_t scl_pin;
    uint16_t sda_pin;
} I2C_Soft_Handle;

// 初始化时传入引脚信息
void I2C_Soft_Init(I2C_Soft_Handle* handle, 
                   GPIO_TypeDef* port, 
                   uint16_t scl, 
                   uint16_t sda)
{
    handle->port = port;
    handle->scl_pin = scl;
    handle->sda_pin = sda;
    
    // 配置GPIO为开漏输出
    // ... GPIO初始化代码 ...
}

然后所有操作函数都接收这个句柄指针。这样,同一个驱动代码,可以同时驱动多路I2C总线,每路引脚不同。我在一个项目里同时用了3路软件I2C,分别接传感器、EEPROM和LCD,全靠这个结构体来区分。

4.6 时序参数调整与抗干扰

软件模拟I2C的时序,说白了就是延迟时间。延迟太短,信号不稳定;延迟太长,通信速度慢。我一般这样调:

模式 SCL频率 半周期延迟 适用场景
标准模式 100 kHz 5 μs 大多数传感器、EEPROM
快速模式 400 kHz 1.25 μs 高速设备,如某些ADC
低速模式 10-50 kHz 10-50 μs 长线传输、抗干扰要求高

实战经验:如果总线长度超过20cm,或者环境中有电机、继电器等干扰源,我建议把延迟时间加倍。速度慢一点,但通信可靠。我曾经在一条1米长的I2C总线上,用10kHz的速率跑了半年,一次都没出过错。

另外,SCL和SDA的GPIO要配置成开漏输出,外部加上拉电阻(4.7kΩ典型值)。开漏输出模式下,多个设备可以共用总线,不会产生电平冲突。这个我在第2章讲电气特性时提过,这里再强调一遍。

4.7 完整的读写操作封装

有了上面的基础函数,封装读写操作就水到渠成了。以写一个字节到从机为例:

uint8_t I2C_WriteByte(I2C_Soft_Handle* handle, 
                      uint8_t dev_addr, 
                      uint8_t reg_addr, 
                      uint8_t data)
{
    I2C_Start(handle);
    // 发送设备地址 + 写标志(0)
    if(I2C_SendByte(handle, (dev_addr << 1) | 0))
    {
        I2C_Stop(handle);
        return 1;  // 无应答
    }
    // 发送寄存器地址
    if(I2C_SendByte(handle, reg_addr))
    {
        I2C_Stop(handle);
        return 2;
    }
    // 发送数据
    if(I2C_SendByte(handle, data))
    {
        I2C_Stop(handle);
        return 3;
    }
    I2C_Stop(handle);
    return 0;  // 成功
}

你看,整个流程就是:起始 → 发地址 → 发寄存器地址 → 发数据 → 停止。每一步都检查应答,出错就停止并返回错误码。这样调试的时候,看返回值就知道哪一步出了问题。