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必须为高电平。这是协议规定的,不能搞错。
代码实现很简单,但延迟时间要调好。标准模式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; // 成功
}
你看,整个流程就是:起始 → 发地址 → 发寄存器地址 → 发数据 → 停止。每一步都检查应答,出错就停止并返回错误码。这样调试的时候,看返回值就知道哪一步出了问题。