☰
STM32硬件I2C驱动AT24C02 EEPROM实战:从字节读写到页写与掉电恢复
2026/10/7 7:49:31 网站建设 项目流程

1. 项目缘起与整体设计思路

搞嵌入式开发的朋友大概率都绕不开一个场景:板子跑起来了,数据算出来了,结果一断电全没了。这时候就需要一颗能掉电保存的存储芯片,而AT24C02几乎是每个STM32入门者都会碰到的第一颗EEPROM。它便宜、好买、电路简单,I2C两根线就能挂上去,容量2Kbit(256字节),存点配置参数、校准系数、开机次数绰绰有余。

我这次做的项目,就是拿STM32F103C8T6最小系统板,通过硬件I2C外设驱动AT24C02,实现字节读写、页写、连续读,并且把整个链路调通到稳定可靠。为什么选硬件I2C而不是软件模拟?原因很直接:F103的硬件I2C虽然早年有“卡死”的江湖传闻,但只要时钟配置正确、状态机处理规范,它的效率远高于软件翻转GPIO,而且不占用CPU时间。软件模拟I2C更适合引脚受限或者时序要求特殊的场合,日常用硬件外设完全够。

整体方案分三层:底层是I2C通信协议层,负责起始、停止、应答、收发字节;中间是AT24C02设备驱动层,封装字节写、页写、随机读、连续读;上层是应用层,做数据校验和掉电恢复测试。这样分层的好处是,以后换24C16、24C64,只需要改设备地址和页大小,底层不用动。

注意:AT24C02的写周期典型值5ms,最大不超过10ms。每次写操作后必须等待这个时间才能发起下一次通信,否则芯片内部还在烧写,不会应答。

2. 硬件连接与I2C基础扫盲

2.1 最小系统与EEPROM的接线

STM32F103C8T6最小系统板通常引出PB6(SCL)和PB7(SDA),这两个引脚正好是I2C1的默认复用引脚。AT24C02的SDA和SCL分别接PB7和PB6,VCC接3.3V,GND共地。A0、A1、A2三个地址引脚全部接地,所以器件7位地址是1010000,即0x50。写操作地址是0xA0,读操作地址是0xA1。

上拉电阻是必须的。I2C总线是开漏结构,引脚只能拉低不能拉高,所以SCL和SDA各需要一只4.7kΩ上拉到3.3V。我试过用10kΩ,在100kHz下勉强能跑,但波形上升沿明显变缓;换成4.7kΩ后波形干净很多。如果总线上挂了多个从机,可以适当减小到2.2kΩ,但不要低于1kΩ,否则灌电流太大。

提示:有些AT24C02模块自带10kΩ上拉,如果你用的是这种模块,就不要再外接上拉了,否则并联后阻值变小,可能导致低电平拉不下去。

2.2 硬件I2C的时钟配置

STM32F103的I2C挂在APB1总线上,最高36MHz。标准模式100kHz,快速模式400kHz。AT24C02支持400kHz,但为了稳定,我一般先用100kHz调通,再尝试提速。时钟配置公式:CCR = PCLK1 / (2 * 目标频率)。假设PCLK1=36MHz,目标100kHz,则CCR=180。如果目标400kHz,CCR=45。同时要设置TRISE,标准模式下等于PCLK1周期加1,即TRISE = 36 + 1 = 37;快速模式下TRISE = (300ns / 周期) + 1,36MHz周期约27.8ns,所以约11。

这些参数在标准库和HAL库里都有对应宏,但理解计算过程有助于排查时钟不对导致的通信失败。

2.3 开漏模式与推挽模式的取舍

I2C的SCL和SDA必须配置为开漏复用输出,不能配推挽。推挽模式下引脚会主动输出高电平,如果从机此时也在拉低总线,就会形成电源到地的低阻通路,轻则波形异常,重则烧引脚。开漏模式下,引脚只能拉低,高电平靠外部上拉电阻实现,这样多设备共享总线才不会冲突。

我见过有人把SDA配成推挽,结果读数据时永远读到0xFF,因为主机推挽输出高电平,从机根本拉不低。这个坑很隐蔽,因为写操作可能看起来正常,读操作就出问题。

3. AT24C02驱动核心细节拆解

3.1 器件地址与读写位

AT24C02的7位地址由固定部分1010和A2、A1、A0引脚决定。全部接地时,7位地址是1010000,即0x50。I2C通信时,主机先发一个字节,高7位是地址,最低位是方向位:0表示写,1表示读。所以写地址字节是0xA0,读地址字节是0xA1。

这里有个容易混淆的点:很多资料直接说“AT24C02地址是0xA0”,其实那是写地址字节,不是7位器件地址。在HAL库中,HAL_I2C_Mem_Write函数的DevAddress参数要求传入的是左移后的8位地址,即0xA0。如果你传0x50,HAL库会把它当成7位地址再左移,结果变成0xA0,反而对了;但如果你传0xA0,它再左移就变成0x140溢出,通信必失败。所以用HAL库时,统一传0xA0作为写地址、0xA1作为读地址,或者传0x50<<1。

3.2 字节写时序与5ms等待

字节写流程:起始条件 → 发送写地址0xA0 → 等待应答 → 发送内存地址(0x00~0xFF)→ 等待应答 → 发送数据字节 → 等待应答 → 停止条件 →等待5ms以上。

最后这个等待是新手最容易忽略的。AT24C02收到停止条件后,内部开始烧写EEPROM单元,这段时间它不会响应任何I2C通信。如果你紧接着发下一次起始条件,从机不会应答,主机就会报总线错误。我一般用HAL_Delay(5),保险起见用10ms。如果频繁写,可以考虑用“应答轮询”方式:反复发起起始条件并发送写地址,直到收到应答为止,这样比固定延时更高效。

3.3 页写与跨页问题

AT24C02的页大小是8字节。页写允许一次连续写入最多8字节,但有一个硬性限制:不能跨页。也就是说,如果你从地址0x07开始写8字节,会写到0x07、0x08...0x0E,但0x08已经是下一页的起始,芯片内部地址指针会在页边界回卷到0x00,导致数据写错位置。

正确做法是:计算当前地址到页边界的剩余空间,如果剩余空间小于要写入的长度,就分两次写。比如从0x06写10字节,先写0x06、0x07两字节(到页边界0x07结束),等待5ms,再从0x08写剩下8字节。这个逻辑必须封装在驱动里,应用层不应该关心。

3.4 随机读与连续读

随机读流程:起始 → 发送写地址0xA0 → 应答 → 发送内存地址 → 应答 →重新起始→ 发送读地址0xA1 → 应答 → 读取数据 → 发送非应答(NACK)→ 停止。注意最后主机要发NACK,告诉从机“我读够了”,然后发停止条件。

连续读则是在读到每个字节后主机发ACK,从机继续输出下一字节,直到主机发NACK并停止。AT24C02内部地址指针会自动递增,但同样会在页边界回卷,所以连续读跨页时数据会重复。如果读整个256字节,需要手动处理页边界,或者干脆分页读。

4. 完整实操流程与代码实现

4.1 工程搭建与I2C初始化

我用的是Keil MDK加标准库,也可以用CubeMX生成HAL库工程。这里以标准库为例,初始化步骤如下:

void I2C_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; I2C_InitTypeDef I2C_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); 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_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; I2C_Init(I2C1, &I2C_InitStructure); I2C_Cmd(I2C1, ENABLE); }

关键点:GPIO_Mode_AF_OD必须是开漏复用,I2C_Ack_Enable使能应答,I2C_ClockSpeed设100kHz。

4.2 字节写函数实现

void AT24C02_WriteByte(uint8_t addr, uint8_t data) { I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, 0xA0, I2C_Direction_Transmitter); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); I2C_SendData(I2C1, 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); HAL_Delay(5); }

每个while都在等事件标志,这是标准库的典型写法。如果卡在某个while里,说明硬件连接或时钟配置有问题。

4.3 页写函数与跨页处理

void AT24C02_WritePage(uint8_t addr, uint8_t *buf, uint8_t len) { uint8_t pageRemain = 8 - (addr % 8); if(len <= pageRemain) { AT24C02_WriteBuffer(addr, buf, len); } else { AT24C02_WriteBuffer(addr, buf, pageRemain); HAL_Delay(5); AT24C02_WriteBuffer(addr + pageRemain, buf + pageRemain, len - pageRemain); } }

AT24C02_WriteBuffer内部就是起始、发地址、循环发数据、停止、延时。这样封装后,应用层调用页写不用关心跨页。

4.4 随机读函数实现

void AT24C02_ReadByte(uint8_t addr, uint8_t *data) { I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, 0xA0, I2C_Direction_Transmitter); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); I2C_SendData(I2C1, 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, 0xA1, I2C_Direction_Receiver); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED)); I2C_AcknowledgeConfig(I2C1, DISABLE); I2C_GenerateSTOP(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_RECEIVED)); *data = I2C_ReceiveData(I2C1); }

注意读最后一个字节前要关闭应答并发送停止条件,顺序不能反。

4.5 数据校验与掉电测试

写完数据后,我习惯做一次回读校验:把写入的256字节全部读出来,和源数据逐字节比较。如果全部一致,说明读写链路稳定。然后断电10秒再上电,重新读取,确认数据还在。这个测试能暴露写周期等待不足、页边界处理错误等问题。

5. 常见问题与排查技巧实录

5.1 总线卡死与恢复

硬件I2C最让人头疼的就是卡死。现象是程序停在某个while里出不来,SCL被从机拉低不放。原因通常是通信过程中被打断,从机在输出数据时主机发了停止条件,导致从机状态机错乱。

恢复方法:把SCL配置为普通推挽输出,手动翻转9个时钟脉冲,让从机把剩余数据发完,然后发一个停止条件。具体操作:拉低SCL,延时,拉高SCL,重复9次,最后按正常时序发停止。恢复后再重新初始化I2C外设。

提示:在I2C读写函数里加超时计数,不要用死循环while,否则一旦卡死整个系统就挂了。

5.2 读写失败排查速查表

现象可能原因解决方法
起始条件后无应答器件地址错误确认A0-A2接地,地址0xA0
写数据后读回0xFF写周期等待不足增加延时到10ms
页写数据错位跨页未处理按页边界分段写
读数据全0x00SDA被拉低检查上拉电阻和焊接
通信偶尔失败上拉电阻过大换成4.7kΩ
高速下不稳定时钟配置错误降速到100kHz测试

5.3 实操心得与避坑经验

第一,先调通单字节读写,再搞页写和连续读。很多人一上来就写完整驱动,结果出问题不知道是哪一层。我一般先用字节写、字节读验证硬件和基础时序,确认无误后再加页写逻辑。

第二,示波器或逻辑分析仪是必备的。I2C只有两根线,用逻辑分析仪抓一次波形,起始、地址、应答、数据、停止一目了然。没有分析仪的话,可以用另一块STM32做从机模拟,但效率低很多。

第三,AT24C02的写次数有限,典型100万次。如果应用需要频繁写,比如每秒写一次,芯片很快就报废。这时候要么加缓存减少写入频率,要么换FRAM或MRAM。我做过一个项目,开机次数存在EEPROM里,每次开机写一次,用了三年也没坏,因为一天才写几次。

第四,电源电压要匹配。AT24C02有2.7V~5.5V版本和1.8V~5.5V版本,STM32F103是3.3V,所以用3.3V供电完全没问题。但如果你的板子上有5V器件,I2C电平转换就要考虑。简单方法是加MOS管做双向电平转换,或者用专用电平转换芯片。

6. 性能优化与扩展思路

6.1 提高写入效率的几种手段

固定延时5ms虽然简单,但浪费CPU时间。如果系统实时性要求高,可以用应答轮询:写完后立即发起起始条件,发送写地址,如果收到应答说明芯片空闲,可以继续下一次写;如果没应答就重试。这样平均等待时间可能只有2~3ms。

另一个手段是合并写入。如果应用需要频繁更新多个参数,可以先把参数缓存在RAM里,攒够一页再一次性写入,减少写周期次数。但要注意掉电时RAM数据会丢,所以关键数据还是要及时写。

6.2 从AT24C02扩展到其他EEPROM

AT24C02的驱动框架稍作修改就能支持24C04、24C08、24C16。这些芯片的页大小不同:24C04/08/16页大小是16字节,地址位数也不同。24C02是8位地址,24C04是9位地址(A0引脚变成地址高位),24C08是10位,24C16是11位。扩展时主要改地址发送逻辑和页大小宏定义。

再往上,24C32到24C512是16位地址,需要发两个地址字节。这时候AT24C02_WriteByte里的地址发送要改成先发高8位再发低8位。封装得好,上层应用几乎不用改。

6.3 数据安全与磨损均衡

EEPROM的擦写寿命有限,如果某个地址频繁写,会先坏。简单做法是做磨损均衡:把数据轮流写到不同页,用版本号或计数器标记最新数据。比如用256字节存一个参数,可以分成8页,每页32字节,轮流写,读的时候找版本号最大的。这样寿命提升8倍。

另外,重要数据可以存两份,加CRC校验。读的时候如果一份CRC错误,就用另一份。这个策略在工业现场很实用,因为EEPROM偶尔会出现位翻转。

6.4 用CubeMX和HAL库的注意事项

如果习惯用CubeMX生成HAL库工程,I2C的配置更简单,但有几个坑。第一,HAL库的HAL_I2C_Mem_Write函数内部已经处理了地址发送和停止条件,但写周期等待还是要自己加。第二,HAL库的超时参数要设合理,默认的HAL_MAX_DELAY是死等,建议改成100ms。第三,HAL库的I2C状态机在错误后需要调用HAL_I2C_DeInit和HAL_I2C_Init重新初始化,否则后续通信一直失败。

我个人的习惯是,底层用标准库自己写,因为可控性强,出问题好排查;上层应用用HAL库的抽象,开发效率高。两者混用也没问题,只要不冲突。

6.5 实际项目中的经验补充

最后分享一个真实踩坑经历。有一次板子批量生产,发现部分板子EEPROM读写不稳定,换了芯片也不行。后来用逻辑分析仪抓波形,发现SCL上升沿有振铃,原因是上拉电阻离芯片太远,走线太长。把上拉电阻移到靠近AT24C02引脚的位置,问题解决。所以PCB布局时,上拉电阻要尽量靠近从机,走线尽量短。

还有一次,程序在调试器下运行正常,脱机运行就失败。查了半天发现是调试器复位时I2C引脚状态不确定,导致从机进入错误状态。解决方法是在初始化I2C之前,先把SCL和SDA配置为普通推挽输出,拉高一段时间,再发一个停止条件,确保总线空闲。这个“总线预处理”步骤后来成了我所有I2C项目的标配。

这些经验在数据手册里不会写,但实际项目中经常遇到。嵌入式开发就是这样,理论通了只是第一步,真正的功夫在调试和排错上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询