从零构建可靠存储:STM32标准库V3.5实现I2C读写EEPROM实战解析
你有没有遇到过这样的场景?设备运行了半年,用户突然发现上次设置的参数“凭空消失”;或者产品返修时,工程师想读取故障日志,却发现数据根本没保存下来。这类问题的背后,往往不是代码逻辑错误,而是非易失性存储设计的缺失或不当。
在嵌入式系统中,Flash、SRAM和EEPROM各有其用武之地。MCU自带的Flash虽然能存程序,但擦写寿命短(约1万次),且不支持字节级修改;SRAM速度快,却一断电就清零。那么,频繁更新的小量配置数据——比如Wi-Fi密码、校准系数、运行计数器——该往哪儿放?
答案是:外接I²C EEPROM。今天,我们就以经典的STM32F1系列 + 标准外设库V3.5 + AT24C02组合为例,手把手带你实现一套稳定可靠的EEPROM读写方案。即使你现在用的是HAL库,理解这套经典实现,也能让你在调试I²C通信故障时,一眼看出问题所在。
为什么选I²C?两根线如何撑起整个传感器生态?
先别急着写代码,咱们得搞明白:为什么是I²C,而不是SPI或UART?
想象一下你的PCB板引脚紧张,又要接RTC、温度传感器、加速度计、存储芯片……如果每个都用SPI(至少4根线),光IO就耗尽了。而I²C只需SCL(时钟)和SDA(数据)两根线,所有设备并联在这条总线上,靠地址“点名”通信。
它像一条共享的对讲通道:
- 主机喊:“AT24C02,出来!”(发送地址)
- 对应的EEPROM拉低SDA表示“到!”(ACK应答)
- 然后主机发命令或收数据
- 完事后说“解散!”(Stop条件)
这种机制让I²C成为低速外设互联的事实标准。更重要的是,它的协议简单到可以用GPIO“模拟”(Bit-Banging),即使MCU没有硬件I²C模块也能实现。
STM32的I²C外设正是为此而生——它自动处理起始/停止信号、地址匹配、ACK反馈和CRC校验,你只需要告诉它“发什么”和“收多少”。
STM32上如何初始化I²C1?
以下这段代码,是几乎所有基于STM32F1的项目都会用到的I²C初始化模板:
void I2C_EEPROM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; I2C_InitTypeDef I2C_InitStructure; // 使能时钟:I2C1在APB1,GPIOB在APB2 RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB | RCC_APB2Periph_AFIO, ENABLE); // 配置PB6(SCL)和PB7(SDA)为复用开漏输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD; // 复用开漏 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); // I2C模块配置 I2C_InitStructure.I2C_Mode = I2C_Mode_I2C; I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2; // 标准占空比 I2C_InitStructure.I2C_OwnAddress1 = 0x00; // 主机无地址 I2C_InitStructure.I2C_Ack = I2C_Ack_Enable; // 启用应答 I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit; I2C_InitStructure.I2C_ClockSpeed = 100000; // 100kHz I2C_Init(I2C1, &I2C_InitStructure); I2C_Cmd(I2C1, ENABLE); // 开启I2C1外设 }几个关键点你必须吃透:
- 开漏输出(AF_OD):这是I²C电气特性的硬要求。SCL和SDA内部只有下拉能力,靠外部4.7kΩ上拉电阻把电平拉高。这样多个设备才能“线与”工作,避免冲突。
- 100kHz时钟:大多数EEPROM支持最高400kHz,但保守起见先用标准模式。若需提速,注意检查器件手册是否支持快速模式。
- ACK使能:一定要打开!否则你无法通过应答判断从设备是否存在或忙状态。
⚠️ 常见坑点:忘记开启RCC_APB1PeriphClockCmd,结果I²C模块没电,怎么调试都没波形。
EEPROM怎么写?别被“写周期”坑了!
很多人第一次写EEPROM,都会犯同一个错:写完立刻读,结果读出来的还是旧值。原因就在于——EEPROM不是RAM,写操作需要时间。
以AT24C02为例,单字节写入后,芯片内部要完成电荷注入,这个过程叫“写周期(Write Cycle)”,典型持续5ms。在此期间,它不会响应任何I²C请求。
所以,你以为的“写入”其实是两个阶段:
1.传输阶段:主机把数据送到EEPROM;
2.执行阶段:EEPROM自己慢慢写,主机只能等。
来看经典单字节写函数:
uint32_t I2C_EEPROM_ByteWrite(uint8_t dev_addr, uint8_t mem_addr, uint8_t data) { while(I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY)); // 等待总线空闲 I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, dev_addr, I2C_Direction_Transmitter); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); I2C_SendData(I2C1, mem_addr); // 指定存储位置 while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); I2C_SendData(I2C1, data); // 发送实际数据 while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); I2C_GenerateSTOP(I2C1, ENABLE); Delay_ms(5); // 等待写周期完成 ← 关键! return 0; }这里用了最简单的固定延时法。好处是代码清晰,坏处是浪费时间——万一芯片早就写完了呢?
更高效的做法是轮询应答(Polling ACK):
void EEPROM_WaitForWriteComplete(uint8_t dev_addr) { while (1) { I2C_GenerateSTART(I2C1, ENABLE); if (I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)) { I2C_Send7bitAddress(I2C1, dev_addr, I2C_Direction_Transmitter); if (I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)) { // 收到ACK,说明EEPROM已就绪 I2C_GenerateSTOP(I2C1, ENABLE); break; } I2C_GenerateSTOP(I2C1, ENABLE); } Delay_us(100); // 小延时避免死循环 } }这种方法看似复杂,实则更精准。你可以把它封装成通用函数,在每次写操作后调用。
怎么读?两次启动的秘密
读操作比写多一步:你得先告诉EEPROM“我要读哪个地址”,然后再发起一次读请求。这叫做“重复起始(Repeated Start)”。
很多初学者在这里卡住:为什么不直接发“读命令+地址”?因为I²C协议规定,地址后的第一个数据永远是“内存指针”,不能跳过。
正确的流程如下:
uint32_t I2C_EEPROM_BufferRead(uint8_t dev_addr, uint8_t mem_addr, uint8_t* pBuffer, uint16_t NumByteToRead) { if (NumByteToRead == 0) return 1; while(I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY)); // 第一次启动:写模式,设置地址指针 I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, dev_addr, I2C_Direction_Transmitter); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); I2C_SendData(I2C1, mem_addr); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); // 重复起始:切换为读模式 I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, dev_addr + 1, I2C_Direction_Receiver); // 读地址 while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED)); // 连续接收数据 while (NumByteToRead > 1) { while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_RECEIVED)); *pBuffer++ = I2C_ReceiveData(I2C1); NumByteToRead--; } // 最后一个字节:关闭ACK,准备STOP I2C_AcknowledgeConfig(I2C1, DISABLE); I2C_GenerateSTOP(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_RECEIVED)); *pBuffer = I2C_ReceiveData(I2C1); // 恢复ACK,不影响后续通信 I2C_AcknowledgeConfig(I2C1, ENABLE); return 0; }重点来了:
-最后一个字节前必须禁用ACK,否则EEPROM会继续等待下一个字节;
- STOP信号要在发出NACK之后、接收完成之前产生;
- 别忘了最后恢复ACK,否则下次通信可能失败。
工程实践中的那些“潜规则”
纸上谈兵终觉浅。真正做产品时,你还得考虑这些:
✅ 地址别冲突
AT24C02的设备地址是1010 A2 A1 A0 R/W。如果你板子上有两片EEPROM,必须通过焊接改变A0~A2引脚电平,否则它们会“抢答”,导致通信混乱。
✅ 能页写就别单字节
虽然支持字节写,但AT24C02每页16字节。如果你连续写16个字节,应该用页写(Page Write)一次性送完,效率更高。跨页写入会导致地址回绕(写第15字节后再写会回到第0字节)。
✅ 减少物理写入次数
哪怕EEPROM号称百万次寿命,也不能滥用。建议:
- 所有参数先缓存在RAM;
- 只有用户点击“保存”或关机时才刷入EEPROM;
- 使用“脏标志(dirty flag)”机制,避免无意义写入。
✅ 上拉电阻别省
4.7kΩ是经验值。总线越长、设备越多,可适当减小阻值(如2.2kΩ),但太小会增加功耗。必要时在SCL/SDA线上加TVS管防静电。
✅ 软件容错不可少
实际环境中可能遭遇干扰导致通信失败。建议在读写函数外层加重试机制:
uint32_t Safe_EEPROM_Write(uint8_t addr, uint8_t data) { for (int i = 0; i < 3; i++) { if (I2C_EEPROM_ByteWrite(EEPROM_ADDR, addr, data) == 0) { EEPROM_WaitForWriteComplete(EEPROM_ADDR); return 0; } } return 1; // 连续失败 }写在最后:老技术的新价值
也许你会说:“现在都用STM32CubeMX生成HAL代码了,还看V3.5干嘛?”
但事实是,理解底层,才能驾驭高层。当你用HAL库遇到HAL_I2C_Master_Transmit()返回HAL_TIMEOUT时,如果没有看过上面那些while(!I2C_CheckEvent())的轮询逻辑,你怎么知道是时钟没起来、地址错了,还是从设备没应答?
而且,全球仍有数亿台基于STM32F1的老设备在运行。维护它们,是你我可能都要面对的任务。
更重要的是,这套代码体现了一种工程思维:
用最稳定的协议,连接最关键的存储,确保每一字节都不丢失。
下次当你设计一款智能水表、一台医疗监护仪,或一个工业控制器时,请记得:
再炫酷的功能,也抵不过一次掉电丢配置。而一颗几毛钱的AT24C02,加上十几行扎实的I²C代码,就是系统可靠性的最后一道防线。
如果你正在学习嵌入式存储,不妨动手接一块EEPROM,跑一遍上面的代码。当你成功读写出第一个字节时,那种“我真正掌控了硬件”的感觉,值得拥有。
欢迎在评论区分享你的I²C踩坑经历,我们一起排雷。