【STM32】STM32实战笔记-软件模拟I2C驱动EEPROM(32)
1. 软件模拟I2C的必要性在STM32开发中我们通常会遇到两种I2C实现方式硬件I2C和软件模拟I2C。硬件I2C虽然效率高但由于STM32的硬件I2C外设存在一些历史遗留问题比如时钟拉伸bug很多开发者更倾向于使用GPIO模拟I2C时序。这种方式虽然会占用更多CPU资源但胜在稳定可控移植性强。我曾在多个项目中使用软件模拟I2C驱动AT24C02 EEPROM实测下来发现这种方案特别适合对实时性要求不高的场景。比如记录设备运行日志、存储校准参数等。AT24C02是经典的I2C接口EEPROM容量2Kbit256字节支持页写入和随机读取工作电压范围宽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(i0; i8; 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(i0; i8; 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-读例如当A2A1A00时写地址0xA0读地址0xA14.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 i0; ilen; 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(i0; i8; 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(i0; i7; i) { read_data[i] I2C_ReadByte(0); // 发送ACK } read_data[7] I2C_ReadByte(1); // 最后一个字节发送NACK I2C_Stop(); // 校验数据 for(i0; i8; 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但对于大多数应用已经足够。