STM32 I2C实战:AT24C02 EEPROM读写避坑指南
1. 为什么AT24C02是I2C入门的最佳陪练
搞STM32的人几乎都绕不开I2C,而AT24C02这颗2Kbit的EEPROM,基本就是I2C实战的"标准靶子"。原因很实在:它便宜、好买、时序标准、寄存器逻辑简单,而且掉电不丢数据这个特性,让它在参数存储、设备序列号、校准数据这些场景里一直有存在感。你拿它练手,踩的坑都是I2C的通用坑,学会了换任何I2C从机都能套用。
但我要先说一个很多人忽略的事实:AT24C02的读写流程,难点根本不在"写代码",而在"理解时序边界"。我见过太多人用HAL库两行代码把数据写进去了,就以为自己会I2C了,结果换个从机、换个速率、加个上拉电阻变化,立刻翻车。所以这篇我不打算只给你一份能跑的代码,而是把整个链路拆开——从硬件上拉电阻怎么算,到CubeMX怎么配,到HAL库函数背后到底发了什么,再到页写跨页这个经典陷阱怎么处理。
先明确适用对象:如果你已经会点GPIO和串口,想系统搞懂I2C;或者你代码能跑但总在"偶尔读写失败"里挣扎,这篇就是给你写的。全程基于STM32F103C8T6最小系统 + AT24C02,用STM32CubeMX生成HAL库工程,Keil MDK编译,ST-Link下载调试。这套组合是市面上资料最多、最容易复现的,你照着做基本不会卡在环境上。
在动手之前,先把一个核心认知立住:I2C是"主从+应答"的协议,主机发的每一个字节,从机都要回一个ACK,主机必须检查这个ACK。很多人的代码"能跑",其实是从机根本没应答,只是主机自顾自发完了事,数据压根没写进去。这个认知贯穿全文,后面每个坑都跟它有关。
2. 硬件层:上拉电阻、地址位与最小系统接线
2.1 上拉电阻到底该选多大
I2C是开漏输出,SDA和SCL两条线必须靠外部上拉电阻拉到高电平,否则总线永远是低,通信直接死。这是硬件层第一个必须搞对的东西。
上拉电阻的取值不是随便拍脑袋。它受两个因素夹击:上升时间和功耗/驱动能力。STM32F103的I2C标准模式100kHz、快速模式400kHz,对上升时间有要求(标准模式1000ns,快速模式300ns)。电阻太大,上升沿变缓,高速下波形还没到高电平就被拉低了,通信失败;电阻太小,灌电流大,从机可能拉不低。
工程上的经验取值:
| 总线速率 | 常用上拉电阻 | 说明 |
|---|---|---|
| 100kHz | 4.7kΩ ~ 10kΩ | 最稳妥,绝大多数模块自带4.7k |
| 400kHz | 2.2kΩ ~ 4.7kΩ | 上升时间要求更严,阻值要小 |
| 长走线/多从机 | 适当减小 | 总线电容大,需要更强上拉 |
我实测下来,3.3V系统、100kHz、短线,4.7kΩ是万能解。市面上卖的AT24C02模块板载基本都焊了4.7k,你直接用就行。但如果你是自己画板或者从零搭,千万别忘了这两个电阻——我见过有人调了一下午,最后发现是SDA没上拉,这种坑最气人。
注意:如果模块已经带了上拉电阻,你MCU这边就不要再并一个,否则等效阻值变小,灌电流超标。两个4.7k并联变成2.35k,短时间没事,长期对从机不友好。
2.2 AT24C02的7位地址怎么算
AT24C02的7位从机地址是1010 A2 A1 A0,高四位固定1010,低三位由芯片的A0/A1/A2引脚决定。模块上这三个脚通常默认接地,所以地址就是1010000= 0x50。
这里有个新手最容易懵的点:HAL库函数要的是"左移一位后的8位地址"。也就是说,你传进去的应该是0x50 << 1 = 0xA0。为什么?因为I2C协议里,地址字节是7位地址+1位读写方向位拼成的8位。HAL库把这个方向位留给你在函数内部处理,但地址本身它要的是已经左移好的形式。
#define AT24C02_ADDR (0x50 << 1) // = 0xA0如果你直接把0x50传进去,HAL库会当成0x50这个8位地址去发,实际发出去的是0101000x,从机根本不认,全程无应答。这个坑我踩过,而且它不会报错,只会静默失败,非常隐蔽。
2.3 最小系统接线清单
接线本身不复杂,但顺序和细节要注意:
- VCC:AT24C02接3.3V(它支持1.8V~5.5V,3.3V完全没问题)
- GND:共地,必须的
- SDA:接STM32F103的I2C数据脚,比如PB7(I2C1_SDA)
- SCL:接PB6(I2C1_SCL)
- A0/A1/A2:接地(决定地址0x50)
- WP:写保护脚,接地才能写,接高电平只能读
WP这个脚特别容易被忽略。有些模块把它引出来但默认悬空,悬空状态下电平不确定,可能导致"有时候能写有时候不能写"。稳妥做法是直接接GND,需要写保护时再拉高。
STM32F103的I2C1默认引脚是PB6/PB7,I2C2是PB10/PB11。用CubeMX配置时它会自动帮你分配,但你要知道这两个脚同时也是普通GPIO,配置成I2C复用功能后就不能再当普通IO用了。
3. CubeMX配置:那些默认值背后的取舍
3.1 I2C外设参数逐项拆解
打开CubeMX,选好STM32F103C8T6,进Connectivity → I2C1,把模式设成I2C。然后右边参数栏有几个关键项:
I2C Speed Mode:Standard Mode(100kHz)还是Fast Mode(400kHz)。AT24C02支持400kHz,但如果你上拉电阻偏大或者走线长,建议先用100kHz跑通,再往上提。我一般调试阶段用100kHz,稳定后再评估要不要提速。
Clock Speed:这个值CubeMX会根据你的APB1时钟自动算分频。STM32F103的I2C挂在APB1上,默认36MHz。你填100000,它自动算CCR寄存器。这里不用手动干预,但你要知道如果APB1时钟配错了,I2C速率会跟着错,进而导致时序不满足从机要求。
Duty Cycle:Fast Mode下才有意义,Tlow/Thigh的比例,一般选2:1。标准模式忽略。
Analog Filter / Digital Filter:模拟滤波默认开,数字滤波默认关。一般场景保持默认即可。如果你的总线干扰大,可以开数字滤波,但会增加一点延迟。
Own Address:主机自己的地址,随便填,主机模式下用不到,填0也行。
3.2 时钟树别配错
STM32F103最常见的配置是外部8MHz晶振,PLL倍频到72MHz,APB1分频后36MHz。I2C挂在APB1上,所以I2C的时钟源是36MHz。这个值直接决定CCR的计算结果。
如果你用的是内部HSI(8MHz)或者晶振频率不对,整个时钟树都会偏,I2C实际速率就不是你设的100kHz了。我建议调试I2C前先用示波器或者逻辑分析仪量一下SCL的实际频率,确认是100kHz左右,再往下走。没有逻辑分析仪的话,至少确认系统时钟配置正确。
3.3 生成工程时的几个选项
Project Manager里,Toolchain选MDK-ARM,Code Generator里勾上"Generate peripheral initialization as a pair of .c/.h files",这样I2C的初始化代码会单独放在i2c.c里,工程结构清晰。
生成之后,Keil里编译一下确认没报错,然后就可以开始写读写函数了。这里我不建议一上来就写完整功能,而是先写一个最简单的"发送地址+检查应答"的测试,确认硬件链路通了,再往上叠功能。这个习惯能帮你把问题隔离在最小范围。
4. HAL库读写函数:从字节写到页写的完整实现
4.1 单字节写:最基础也最容易出错
AT24C02的单字节写时序是:起始条件 → 发送设备地址+写方向 → 等ACK → 发送字地址(要写哪个字节位置)→ 等ACK → 发送数据 → 等ACK → 停止条件。然后EEPROM内部会启动一个写周期,大约5ms,这期间它不响应任何请求。
用HAL库实现:
uint8_t AT24C02_WriteByte(uint16_t addr, uint8_t data) { return HAL_I2C_Mem_Write(&hi2c1, AT24C02_ADDR, addr, I2C_MEMADD_SIZE_8BIT, &data, 1, 100); }HAL_I2C_Mem_Write这个函数把"发设备地址→发内存地址→发数据"这一串都封装好了。参数里I2C_MEMADD_SIZE_8BIT表示内存地址是8位(AT24C02容量2Kbit=256字节,地址范围0x00~0xFF,正好8位)。
关键点:写完必须延时。EEPROM的写周期典型5ms,最大可能到10ms。如果你写完立刻读,从机还在忙,不会应答,读回来就是错的。
HAL_I2C_Mem_Write(&hi2c1, AT24C02_ADDR, addr, I2C_MEMADD_SIZE_8BIT, &data, 1, 100); HAL_Delay(5); // 等写周期完成这个5ms是很多人偷懒省掉的,然后抱怨"读出来不对"。其实不是读的问题,是写还没完成。
4.2 单字节读:先写地址再读数据
读操作稍微绕一点。I2C的读不是直接读,而是先发一次"写"把要读的内存地址告诉从机,然后再发一次"读"把数据取回来。这个"伪写"步骤是I2C读的标准套路,HAL库的HAL_I2C_Mem_Read帮你做了:
uint8_t AT24C02_ReadByte(uint16_t addr) { uint8_t data = 0; HAL_I2C_Mem_Read(&hi2c1, AT24C02_ADDR, addr, I2C_MEMADD_SIZE_8BIT, &data, 1, 100); return data; }它内部的动作是:起始 → 设备地址+写 → 内存地址 → 重启 → 设备地址+读 → 收数据 → 发NACK → 停止。这个"重启"(Repeated Start)是读操作的关键,它保证在切换读写方向时不释放总线。
4.3 页写:AT24C02的性能关键
单字节写每次都要等5ms,写256字节就是1.28秒,太慢了。AT24C02支持页写,一页8字节,一次可以连续写8个字节,只等一个写周期。这是它提速的核心手段。
但页写有个硬约束:不能跨页。AT24C02每页8字节,页边界是0x00-0x07、0x08-0x0F……如果你从地址0x06开始写8个字节,写到0x07就到页尾了,第8个字节会"回卷"到本页开头0x00,把原来的数据覆盖掉。这是AT24C02最经典的坑。
正确的页写实现要处理跨页:
void AT24C02_WritePage(uint16_t addr, uint8_t *buf, uint16_t len) { uint16_t pageRemain = 8 - (addr % 8); // 当前页剩余空间 uint16_t writeLen = (len < pageRemain) ? len : pageRemain; HAL_I2C_Mem_Write(&hi2c1, AT24C02_ADDR, addr, I2C_MEMADD_SIZE_8BIT, buf, writeLen, 100); HAL_Delay(5); if (len > writeLen) { AT24C02_WritePage(addr + writeLen, buf + writeLen, len - writeLen); } }这段逻辑的核心是:先算当前页还能写几个,写满一页就延时,剩下的递归处理。这样无论你从哪个地址开始、写多长,都不会跨页出错。
提示:页写虽然快,但每次写周期还是5ms。写256字节按页写是32个写周期,约160ms,比单字节写的1.28秒快了一个数量级。数据量大的场景一定要用页写。
4.4 连续读:一次读多个字节
读操作没有页的概念,可以连续读整个地址空间。AT24C02收到读命令后会从指定地址开始,每读一个字节地址自动加1,直到主机发NACK+停止。
void AT24C02_ReadBuffer(uint16_t addr, uint8_t *buf, uint16_t len) { HAL_I2C_Mem_Read(&hi2c1, AT24C02_ADDR, addr, I2C_MEMADD_SIZE_8BIT, buf, len, 100); }连续读比逐字节读效率高得多,因为省掉了每次的起始、地址发送、重启这些开销。读整个256字节,连续读一次就搞定。
5. 实测踩坑:那些让你怀疑人生的失败场景
5.1 全程无应答:地址左移和上拉电阻
我第一次调AT24C02,代码写完烧进去,读出来全是0xFF。查了半天,两个问题叠在一起:
第一个是地址没左移。我传的是0x50,HAL库发出去从机不认,全程NACK。改成0x50 << 1立刻有反应。
第二个是上拉电阻。我用的模块板载4.7k,但杜邦线太长,波形上升沿很缓。用逻辑分析仪一看,SCL高电平还没到3.3V就被拉下来了。换了短一点的线,问题消失。
排查顺序建议:先确认地址左移,再用逻辑分析仪看波形,最后查硬件连接。没有逻辑分析仪的话,用HAL库的返回值判断——HAL_I2C_Mem_Write返回HAL_ERROR基本就是无应答。
5.2 写进去读出来不对:写周期没等够
这个坑我踩过不止一次。代码逻辑没问题,地址也对,但读出来的数据就是不对,或者时对时不对。原因就是写完没等够5ms。
EEPROM的写周期是内部电荷泵在干活,这期间它不响应总线。你如果写完立刻读,从机还在忙,读操作会失败或者读到旧数据。HAL_Delay(5)是最低要求,保险起见可以给到10ms。
更稳妥的做法是用应答轮询(ACK Polling):写完不固定延时,而是反复发设备地址,直到从机回ACK,说明写周期结束。这样既不会等太久,也不会等不够。
void AT24C02_WaitReady(void) { while (HAL_I2C_IsDeviceReady(&hi2c1, AT24C02_ADDR, 3, 100) != HAL_OK); }HAL_I2C_IsDeviceReady就是干这个的,它发地址看有没有应答。这个方式比死等5ms更优雅,尤其在写大量数据时能省不少时间。
5.3 页写跨页:数据被静默覆盖
这个坑最阴险,因为它不报错,数据也"写进去了",只是位置不对。你从0x06写8个字节,以为写到了0x06~0x0D,实际0x08~0x0D被写到了0x00~0x05,把原来的数据覆盖了。
我当时的场景是存一个结构体,结构体大小12字节,从地址0x00开始写。前8字节正常,后4字节回卷到了0x00,把结构体开头覆盖了。读出来一看,前4字节是结构体后半部分的内容,整个人都懵了。
避免方法:要么按页对齐写(每次写都从8的倍数地址开始),要么用上面那个递归处理跨页的函数。我现在的习惯是所有写操作都走页写函数,不管写几个字节,让它自己处理边界。
5.4 上电第一次读写失败
有些朋友会遇到上电后第一次读写失败,后面就正常了。这通常是上电时序问题:EEPROM和MCU的上电速度不一致,MCU已经开始发命令了,EEPROM还没准备好。
解决办法是在初始化后加一个短延时,或者第一次操作前用HAL_I2C_IsDeviceReady轮询确认从机在线。这个延时不用长,10ms~50ms足够。
5.5 多从机地址冲突
如果你总线上挂了多个I2C设备,要确认地址不冲突。AT24C02通过A0/A1/A2可以配8个地址(0x50~0x57),但如果你还挂了OLED(常见0x3C或0x78)、其他传感器,要统一规划地址。地址冲突的表现是"两个设备都时好时坏",因为它们在抢总线应答。
6. 数据可靠性:校验、磨损与掉电保护
6.1 为什么建议加校验
EEPROM不是绝对可靠的,尤其在写周期中途掉电,可能写入半截数据。工业场景里,关键参数存储一般都会加校验。最简单的方案是每个数据块后面跟一个CRC或者校验和。
typedef struct { uint8_t data[16]; uint8_t checksum; // 前16字节的异或和 } ParamBlock;写入时算好checksum一起写,读取时重新算一遍对比。不一致就说明数据损坏,走默认值或者重写。这个成本很低,但能挡住大部分静默错误。
6.2 写平衡:别盯着一个地址猛写
AT24C02的擦写寿命是100万次。听起来很多,但如果你有个参数每秒写一次,一天就是86400次,十几天就写废了。频繁写的场景一定要做写平衡。
简单做法是:把存储区分成多个槽位,轮流写,每次写下一个槽,读的时候读最新的那个。这样寿命就摊薄到多个地址上。复杂一点的用环形缓冲区+序号标记。
我做过一个项目,设备运行状态每10秒存一次,用8个槽位轮转,理论寿命从几个月拉到了好几年。这个思路值得记住。
6.3 掉电保护的实际做法
掉电保护的核心是在电源掉下去之前把关键数据写完。硬件上可以加大电容储能,软件上检测掉电中断,在电容放电的几十毫秒内完成写操作。
但AT24C02一个写周期5ms,能写的量有限。所以实际做法是:平时就把数据维护在RAM里,只在掉电中断里写最关键的那几个字节。别指望掉电时写一大堆,来不及。
7. 从AT24C02迁移到其他I2C器件的通用思路
把AT24C02玩明白之后,你会发现I2C这套东西是通用的。换任何从机,无非是改三样:设备地址、寄存器/内存地址宽度、读写时序的特殊要求。
比如换成AT24C256(32KB),内存地址变成16位,I2C_MEMADD_SIZE_8BIT要改成I2C_MEMADD_SIZE_16BIT,页大小从8字节变成64字节,其他逻辑基本不变。换成OLED(SSD1306),它没有"内存地址"概念,而是命令/数据切换,这时候用HAL_I2C_Master_Transmit直接发字节流更合适。
我建议你在调通AT24C02之后,刻意去读一遍它的数据手册时序图,对照HAL库函数看每一步对应哪个动作。这个习惯一旦养成,以后遇到任何I2C器件,你都能自己啃手册搞定,而不是到处找例程。
最后分享一个我自己的调试习惯:每次改完I2C代码,先用逻辑分析仪抓一次完整波形,确认起始、地址、ACK、数据、停止都对,再去看功能。波形对了功能不一定对,但波形不对功能一定不对。这个顺序能帮你省掉大量瞎猜的时间。