1. 软件模拟I2C的必要性
在STM32开发中,我们通常会遇到两种I2C实现方式:硬件I2C和软件模拟I2C。硬件I2C虽然效率高,但由于STM32的硬件I2C外设存在一些历史遗留问题(比如时钟拉伸bug),很多开发者更倾向于使用GPIO模拟I2C时序。这种方式虽然会占用更多CPU资源,但胜在稳定可控,移植性强。
我曾在多个项目中使用软件模拟I2C驱动AT24C02 EEPROM,实测下来发现这种方案特别适合对实时性要求不高的场景。比如记录设备运行日志、存储校准参数等。AT24C02是经典的I2C接口EEPROM,容量2Kbit(256字节),支持页写入和随机读取,工作电压范围宽(1.8V-5.5V),是嵌入式系统中常用的非易失性存储器。
2. 硬件电路设计要点
2.1 GPIO配置
软件模拟I2C需要两个GPIO分别作为SCL(时钟线)和SDA(数据线)。这两个引脚必须配置为开漏输出模式(GPIO_Mode_Out_OD),原因有三:
- I2C协议允许总线上的多个设备同时驱动信号线
- 开漏输出可以避免总线竞争导致的短路
- 方便实现"线与"逻辑
以STM32F103为例,我们可以选择PB6和PB7作为I2C引脚:
GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); // SCL引脚配置 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); // SDA引脚配置 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_7; GPIO_Init(GPIOB, &GPIO_InitStructure);2.2 上拉电阻选择
I2C总线需要上拉电阻将信号线拉至高电平。电阻值的选择很关键:
- 阻值太小:增加功耗,可能超出GPIO驱动能力
- 阻值太大:上升沿变缓,影响通信速率
根据经验,常用4.7kΩ的上拉电阻。如果总线电容较大(比如线缆较长、设备较多),可以适当减小阻值,但不要低于1kΩ。我曾在一个长距离(约1米)的I2C总线中使用2.2kΩ上拉电阻,通信依然稳定。
3. I2C时序模拟实现
3.1 基础时序函数
软件模拟I2C的核心是精确控制GPIO电平变化。首先需要实现几个基本时序单元:
// 微秒级延时函数 void I2C_Delay(void) { uint16_t i = 10; // 根据主频调整 while(i--); } // 产生起始信号 void I2C_Start(void) { SDA_HIGH(); SCL_HIGH(); I2C_Delay(); SDA_LOW(); // SCL高电平期间SDA下降沿 I2C_Delay(); SCL_LOW(); } // 产生停止信号 void I2C_Stop(void) { SDA_LOW(); SCL_LOW(); I2C_Delay(); SCL_HIGH(); I2C_Delay(); SDA_HIGH(); // SCL高电平期间SDA上升沿 I2C_Delay(); }3.2 字节收发函数
发送一个字节时,需要从高位开始依次发送。每个bit在SCL低电平时准备,高电平时稳定:
// 发送一个字节 uint8_t I2C_SendByte(uint8_t byte) { uint8_t i, ack; for(i=0; i<8; i++) { if(byte & 0x80) SDA_HIGH(); else SDA_LOW(); byte <<= 1; I2C_Delay(); SCL_HIGH(); I2C_Delay(); SCL_LOW(); } // 读取ACK SDA_HIGH(); // 释放SDA I2C_Delay(); SCL_HIGH(); ack = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_7); // 读取SDA状态 SCL_LOW(); return ack; // 0-应答, 1-非应答 } // 接收一个字节 uint8_t I2C_ReadByte(uint8_t ack) { uint8_t i, byte = 0; SDA_HIGH(); // 释放SDA for(i=0; i<8; i++) { byte <<= 1; SCL_HIGH(); if(GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_7)) byte |= 0x01; SCL_LOW(); I2C_Delay(); } // 发送ACK/NACK if(ack) SDA_HIGH(); else SDA_LOW(); I2C_Delay(); SCL_HIGH(); I2C_Delay(); SCL_LOW(); SDA_HIGH(); // 释放SDA return byte; }4. AT24C02驱动实现
4.1 器件地址
AT24C02的7位设备地址由三部分组成:
- 高4位固定为1010
- 中间3位由A2,A1,A0引脚电平决定
- 最低位是读写方向位(0-写,1-读)
例如,当A2=A1=A0=0时:
- 写地址:0xA0
- 读地址:0xA1
4.2 页写入操作
AT24C02支持页写入(Page Write),每页8字节。写入时需要先发送目标地址,再发送数据:
void EEPROM_PageWrite(uint8_t addr, uint8_t *data, uint8_t len) { I2C_Start(); I2C_SendByte(0xA0); // 器件地址+写 I2C_SendByte(addr); // 内存地址 for(uint8_t i=0; i<len; i++) { I2C_SendByte(data[i]); } I2C_Stop(); Delay_ms(5); // 等待写入完成 }注意:跨页写入需要分多次操作。我曾经踩过一个坑,试图一次性写入16字节数据,结果只有前8字节被正确写入。
4.3 随机读取操作
随机读取需要先发送目标地址(伪写入),然后重新启动总线进行读取:
uint8_t EEPROM_RandomRead(uint8_t addr) { uint8_t data; I2C_Start(); I2C_SendByte(0xA0); // 器件地址+写 I2C_SendByte(addr); // 内存地址 I2C_Start(); I2C_SendByte(0xA1); // 器件地址+读 data = I2C_ReadByte(1); // 读取后发送NACK I2C_Stop(); return data; }5. 完整测试例程
下面是一个完整的读写测试程序,包含写入和验证过程:
#define EEPROM_ADDR 0xA0 void EEPROM_Test(void) { uint8_t write_data[8] = {0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0}; uint8_t read_data[8]; uint8_t i, error = 0; // 页写入测试 I2C_Start(); I2C_SendByte(EEPROM_ADDR); I2C_SendByte(0x00); // 起始地址 for(i=0; i<8; i++) { I2C_SendByte(write_data[i]); } I2C_Stop(); Delay_ms(10); // 等待写入完成 // 顺序读取验证 I2C_Start(); I2C_SendByte(EEPROM_ADDR); I2C_SendByte(0x00); // 起始地址 I2C_Start(); I2C_SendByte(EEPROM_ADDR | 0x01); for(i=0; i<7; i++) { read_data[i] = I2C_ReadByte(0); // 发送ACK } read_data[7] = I2C_ReadByte(1); // 最后一个字节发送NACK I2C_Stop(); // 校验数据 for(i=0; i<8; i++) { if(read_data[i] != write_data[i]) { error = 1; break; } } if(error) { printf("EEPROM test failed!\r\n"); } else { printf("EEPROM test passed!\r\n"); } }6. 常见问题排查
在实际项目中,我遇到过各种I2C通信问题,总结几个典型问题及解决方法:
无应答(NACK)
- 检查器件地址是否正确
- 确认上拉电阻已连接
- 测量SCL/SDA波形是否正常
数据错误
- 降低通信速率(增加延时)
- 检查电源稳定性
- 避免在中断中操作I2C
写入失败
- 确保写入周期(5ms)足够
- 页写入不要跨页
- 检查写保护引脚状态
记得有一次调试时,I2C始终无法正常工作,最后发现是PCB设计问题——SCL和SDA走线太长且平行走线,导致串扰严重。重新布线后问题解决。这个教训告诉我,硬件设计同样重要。
7. 性能优化建议
虽然软件模拟I2C简单可靠,但在高速应用场景下仍需优化:
精确延时控制
- 使用定时器产生精确延时
- 根据主频动态调整延时参数
中断优化
- 避免在中断服务程序中长时间操作I2C
- 使用DMA传输大数据块
错误恢复机制
- 添加超时检测
- 实现总线复位功能
我曾经在一个需要频繁读写EEPROM的项目中,将I2C时钟频率优化到约100kHz(标准模式上限),实测传输一个字节约100us。虽然比不上硬件I2C的400kHz,但对于大多数应用已经足够。