1. 为什么选AT24C02:从掉电保存需求到I2C物理层
1.1 参数存储的硬需求,EEPROM比Flash省心在哪
前段时间调一块板子,MCU用的是STM32F103最小系统,外挂几个传感器和一颗AT24C02,用来存设备序列号、校准参数和运行次数。我一开始觉得I2C读写EEPROM是最没技术含量的环节,结果硬是折腾了两天,先后碰到总线锁死、写完掉电数据全丢、连续写16字节乱套三个问题,几乎把I2C和AT24C02的坑全踩了一遍。这篇文章就把STM32F103通过I2C读写AT24C02 EEPROM的完整流程拆开讲,从物理层电气特性、器件时序,到HAL库代码封装,再到实测中的排查思路。适合刚接触STM32和I2C的新手,也适合那些"代码能跑但就是不稳定"的项目参考。
先回答一个基础问题:既然STM32F103内部有Flash,为什么还要外挂EEPROM?答案很简单——Flash必须按页擦除,最小擦除单位一般是1KB左右,你改一个字节参数都得先把整页读出来、擦掉、再写回去,流程麻烦不说,频繁擦写对Flash寿命也是负担。AT24C02这类串行EEPROM就不一样,它支持字节级读写,擦写次数标称100万次,数据保存能力也比较可靠,一颗芯片几毛钱,占用两个IO口就能换来256字节的非易失存储,项目里存参数、存序列号、存标定数据都非常合适。
1.2 I2C的电气真相:开漏、上拉和两个电阻
I2C和UART、SPI最大的区别在于物理层。SCL和SDA这两根线,所有设备都是开漏输出,也就是说每个设备只能主动把线拉低,不能主动把线推高。高电平完全靠外部上拉电阻提供,这就是I2C能一主多从、任意设备挂在同一条总线上的根基。
我刚学I2C时一直不理解为什么不能用推挽输出,后来踩了坑才想明白。推挽模式下,GPIO既能强推高也能强拉低,如果一个设备输出高、另一个设备输出低,两根线直接短接,轻则通信失败,重则烧IO口。开漏模式配合上拉电阻后,任何一个设备拉低,总线就是低;所有设备都释放,总线就被电阻拉回高。这个"线与"逻辑,正好实现了I2C的仲裁和ACK机制。
上拉电阻取值没有固定答案,工程上最常见的做法是100kHz标准模式下用4.7kΩ,400kHz快速模式下用2.2kΩ左右,还要看总线上挂了多少设备、走线多长。原理是RC充电时间常数:上升沿由电阻和总线寄生电容决定,RC越大上升沿越慢,太快模式就容易出错。按100pF左右的总线电容估算,4.7kΩ的乘积约470ns,满足100kHz的1μs上升沿要求;但如果走线长、设备多,电容变大,就要换2.2kΩ甚至1kΩ。注意也不要太小,否则低电平灌电流太大,协议允许的单节点灌电流有限,一般不低于1kΩ都比较安全。
2. STM32F103的I2C外设:先绕过硬件I2C的坑
2.1 CubeMX配置:引脚、速率和时钟树
用STM32CubeMX配置I2C非常快,选I2C1后默认引脚是PB6(SCL)和PB7(SDA),时钟源来自APB1总线,最高36MHz。工程里我习惯把系统时钟配到72MHz,APB1分频到36MHz,I2C选Standard Mode、100kHz,CubeMX会自动算出合适的分频系数。对AT24C02来说,官方支持400kHz,但新手阶段我强烈建议先用100kHz跑通,等逻辑分析仪确认波形没问题再提速,因为400kHz对布线、上拉电阻、寄生电容都更敏感。
有个细节容易忽略:CubeMX生成的GPIO初始化会自动把PB6、PB7配置成开漏复用模式(AF_OD),这就是正确姿势。有些人不放心,手动改成推挽,反而破坏了I2C物理层要求,通信表现时好时坏,最后还怪芯片有问题。如果你是自己写寄存器初始化,GPIO配置也要记得是AF_OD,不是GPIO_Mode_Out_PP。
2.2 硬件I2C的"玄学问题"和正规复位手段
网上一搜,STM32F1的硬件I2C被喷得挺惨,什么"硬件I2C有bug,只能软件模拟"之类。我自己的实测结论是:F1的硬件I2C确实矫情,但绝大多数问题出在配置和复位处理上,不是芯片设计缺陷。真正典型的问题是总线忙标志位卡住:设备刚上电,或者主机在传输过程中被复位(比如调试器打断),I2C外设可能检测到总线处于忙状态,这时你发起的传输会一直返回HAL_BUSY。
遇到这种情况,常规做法是DeInit再Init外设,必要时配合GPIO手段释放总线。我封装了一个函数,专门在I2C初始化前检查SDA线电平,如果发现被拉低,就把SCL和SDA全部切成普通开漏GPIO,手动翻转SCL九个脉冲,让总线上可能处于中间状态的从设备复位状态机,然后再重新初始化I2C外设。别小看这一步,后面第5章会具体讲,很多"上电后I2C死掉"的问题就是这么救回来的。
2.3 硬件I2C还是软件模拟I2C
针对AT24C02这种低速小容量EEPROM,软件模拟I2C完全够用,时序可控、调试直观,出了问题看代码就能推断。缺点是占用CPU时间、代码量稍大,如果你还挂了多个I2C传感器而且都走中断通信,软件模拟就不太合适。硬件I2C的优势是状态机自动处理启动、停止、应答、时钟拉低这些细节,配合中断或者DMA能省不少CPU。
我个人在这个项目里的选择是硬件I2C,主要原因是后续还要外挂其他I2C传感器,硬件外设的扩展性好。如果你只是想赶紧存几个参数,不打算折腾,那用GPIO模拟两个函数也足够稳定。两类方案我都写过,代码逻辑差不多,核心都是把时序搞清楚。
3. 设备地址、页写入和ACK轮询:AT24C02时序拆解
3.1 设备地址怎么拼:再说一遍0xA0和0x50的区别
AT24C02的I2C从机地址是固定的,前四位是1010,后三位由芯片引脚A2、A1、A0的电平决定,常见接法全部接地,于是7位从机地址就是1010000,也就是二进制0b1010000,十六进制0x50。真正在总线上传输的时候,主机发出的第一个字节是7位地址左移一位、最低位补R/W,所以完整的首字节是:写操作0xA0(10100000),读操作0xA1(10100001)。
这个"0xA0还是0x50"的问题,几乎每个初学的人都会懵一次。原因在于不同软件层对地址的定义不一样:在寄存器手册和协议层面,你看到的是完整首字节0xA0;在很多HAL库API里,地址参数只填7位0x50,库内部自动左移并拼读写位。比如STM32CubeMX生成的HAL库,I2C传输函数地址参数传0x50,通讯时库会在底层处理成0xA0。如果你在网上看到旧例程直接传0xA0,要确认它是基于寄存器操作还是另一种地址表示,混用就会导致通信失败。我一般统一用7位地址0x50,写代码时再注释清楚完整首字节,方便后续移植。
3.2 四种基本操作:单字节写、页写、当前地址读、随机读
AT24C02的操作可以分成四类,前两类是写,后两类是读:
单字节写的流程是:START,发送设备地址(写),发送内部字地址(8位,对应0~255),发送一个字节数据,STOP。这是最基础的操作,HAL库中用HAL_I2C_Mem_Write一步就能完成。
页写和单字节写的区别在于,设备地址和字地址发送完之后,主机可以连续发送最多8个字节,不需要中途STOP。AT24C02一页是8个字节,内部地址计数器只能在当前页内递增,写到页尾会自动回绕到页首,这就埋下了一个大坑:如果页写跨越页边界,超出的数据不会写到下一页,而是覆盖本页开头的旧数据。下面专门说这个问题。
当前地址读:主机发送设备地址(读)后,EEPROM会把内部地址计数器指向的当前地址的数据发出来。这个操作通常用于"接着上次位置继续读"的场景,但对坐落在任意位置的参数读取不友好,因为要先写字地址改变内部指针。
随机读是实际最常用的读取方式:先以写模式发送设备地址,再发送目标字地址,紧接着发一个重复的START信号,再以读模式发送设备地址,然后读取数据。注意中间那个重复START而不是STOP+START,是为了保证这个读操作是原子性的,避免总线上其他主机插进来抢先操作。HAL库的HAL_I2C_Mem_Read封装的就是随机读逻辑,内部地址8位。
3.3 页写为什么不能跨页,跨页怎么处理
页写是效率最高的写入方式,但前提是你要保证所有数据落在同一页内。AT24C02页大小是8字节,假设要从0x06这个地址连续写5个字节,0x06和0x07属于第一页的最后两个位置,剩下3个字节如果继续发,会被写回页首0x00~0x02,而不是写到0x08开始。这个现象在实际调试中很有迷惑性,因为单个字节读出来都是正常的,连续几个字节就出现排列错乱。
解决思路很直接:写数据前先算一下剩余页空间,能写多少就写多少,写完了等待写周期结束,再挪到下一页继续写。代码实现通常是这样:本次可写长度 = min(剩余长度, 8 - (起始地址 % 8))。比如从0x06写5字节,第一次最多写2字节,地址跳到0x08,第二次再写剩下3字节。这个逻辑看起来简单,却是"写多字节乱套"最常见的坑。
3.4 写周期与ACK轮询:省时间的关键
AT24C02每次写操作结束之后,内部要花一段时间把数据真正烧录进存储单元,手册上写的典型值是2ms,最大值5ms。在这段时间里,EEPROM不响应任何I2C命令——包括最简单的设备地址ACK。于是就有两种等待方式:
第一种是死等延时,写完过5ms再操作下一次。代码简单,但每次都固定等最长时限,批量写多页数据时白白浪费不少时间。第二种是ACK轮询,发送START和设备地址(写模式),如果芯片返回ACK说明内部写周期已经结束,可以做下一次操作;如果返回NACK说明还在烧录,继续重试。这种方式平均等待时间接近真实值,批量写入时效率高不少。HAL库里没有直接封装ACK轮询,但对AT24C02来说等价操作就是HAL_I2C_IsDeviceReady,这个函数会尝试与设备通信,看能否收到ACK。我在每次写完一页后就用它来等待。
4. 读写全流程代码实现:从CubeMX到HAL库封装
4.1 CubeMX工程配置步骤
说一下完整配置路径,方便直接照着抄。在CubeMX里新建芯片型号为STM32F103C8T6的工程,打开Categories里的Connectivity,勾选I2C1,默认引脚PB6/PB7。Configure进入参数页,Master Mode打开,Speed Mode选Standard(100kHz),其他保持默认。时钟树里确保APB1时钟是36MHz,这个值会影响I2C分频。最后生成工程,记得选对应的IDE框架,我习惯生成MDK-ARM。
初始化代码你不需要手写,CubeMX生成的MX_I2C1_Init已经包括GPIO开漏复用配置和I2C外设参数。唯一要补的就是第2章提到的总线释放函数,在main里初始化GPIO之后、初始化I2C之前调用一下,保险起见总会先检查一次SDA状态。
4.2 单字节读写和多字节页写封装
直接给出我封装好的驱动,这是完整可用的版本,基于HAL库:
#include "main.h" #define AT24C02_DEV_ADDR 0x50 /* 7bit address, A2/A1/A0 = 0 */ #define AT24C02_PAGE_SIZE 8 #define I2C_TIMEOUT 100 typedef enum { AT24C02_OK = 0, AT24C02_ERR } AT24C02_Status; extern I2C_HandleTypeDef hi2c1; /* 单字节写 */ AT24C02_Status AT24C02_WriteByte(uint8_t memAddr, uint8_t data) { if (HAL_I2C_Mem_Write(&hi2c1, AT24C02_DEV_ADDR, memAddr, I2C_MEMADD_SIZE_8BIT, &data, 1, I2C_TIMEOUT) != HAL_OK) return AT24C02_ERR; wait_eeprom_ready(); return AT24C02_OK; } /* 多字节写,自动分页 */ AT24C02_Status AT24C02_WriteBytes(uint8_t memAddr, const uint8_t *data, uint8_t len) { uint8_t written = 0; while (written < len) { uint8_t remain = len - written; uint8_t maxWrite = AT24C02_PAGE_SIZE - (memAddr % AT24C02_PAGE_SIZE); if (remain > maxWrite) remain = maxWrite; if (HAL_I2C_Mem_Write(&hi2c1, AT24C02_DEV_ADDR, memAddr, I2C_MEMADD_SIZE_8BIT, (uint8_t *)data + written, remain, I2C_TIMEOUT) != HAL_OK) return AT24C02_ERR; wait_eeprom_ready(); memAddr += remain; written += remain; } return AT24C02_OK; } /* 单字节读 */ uint8_t AT24C02_ReadByte(uint8_t memAddr) { uint8_t data = 0; if (HAL_I2C_Mem_Read(&hi2c1, AT24C02_DEV_ADDR, memAddr, I2C_MEMADD_SIZE_8BIT, &data, 1, I2C_TIMEOUT) != HAL_OK) return 0xFF; return data; } /* 多字节读 */ AT24C02_Status AT24C02_ReadBytes(uint8_t memAddr, uint8_t *data, uint8_t len) { if (HAL_I2C_Mem_Read(&hi2c1, AT24C02_DEV_ADDR, memAddr, I2C_MEMADD_SIZE_8BIT, data, len, I2C_TIMEOUT) != HAL_OK) return AT24C02_ERR; return AT24C02_OK; } /* 等待内部写周期结束,本质是ACK轮询 */ static void wait_eeprom_ready(void) { while (HAL_I2C_IsDeviceReady(&hi2c1, AT24C02_DEV_ADDR, 1000, I2C_TIMEOUT) != HAL_OK) { } }有几点需要解释。HAL_I2C_Mem_Write和HAL_I2C_Mem_Read这类函数是专门给带内部寄存器/字地址的设备准备的,它会负责拼装START、设备地址、内存地址、数据、STOP这些完整流程,所以代码比操作寄存器清爽很多。I2C_MEMADD_SIZE_8BIT表示内部字地址是8位,AT24C02有256字节,一个字地址刚好覆盖。如果换成AT24C04以上容量,内部地址会变成9位或更多,接口上通常要用16位字地址模式,移植时记得改。
4.3 写后回读校验,防止"假成功"
EEPROM写操作在I2C协议层面很容易"成功"——数据送到了总线、从机也回了ACK,但实际有没有正确写进存储阵列,只有等写周期结束后回读才能确认。我在驱动里加了一个校验流程:写完一整段数据后,按同样的地址全部读回,逐个字节对比,不一致就上报错误。这个校验函数在量产测试阶段价值很大,能提前暴露焊接不良、电源跌落、A0~A2引脚虚焊这类硬件问题。
AT24C02_Status AT24C02_WriteAndVerify(uint8_t memAddr, const uint8_t *data, uint8_t len) { AT24C02_Status st = AT24C02_WriteBytes(memAddr, data, len); if (st != AT24C02_OK) return st; uint8_t buf[32]; if (len > 32) len = 32; st = AT24C02_ReadBytes(memAddr, buf, len); if (st != AT24C02_OK) return st; for (uint8_t i = 0; i < len; i++) { if (buf[i] != data[i]) return AT24C02_ERR; } return AT24C02_OK; }注意我限定了一段最大长度32字节,原因是单片AT24C02只有256字节,校验缓冲不宜开太大,你可以按实际需求调整。
5. 实测中的坑:总线锁死、掉电丢数据和电平关系
5.1 复位后SDA被拉低,总线锁死的完整排查链路
这是整个调试过程中最折磨人的一个问题。现象是:程序上电第一次I2C访问就超时,用示波器看SDA线一直是低电平,SCL翻转了也没有任何ACK。我把AT24C02拆掉,SDA立刻恢复高电平,说明问题出在从设备侧。
排查链路是这样的:先用逻辑分析仪抓上电后的总线波形,发现主机在发送设备地址后没有任何应答,再从机一直在等待SCL时钟。顺着这个思路往下查,定位到根因是上一次通信过程中主机被调试器复位打断——比如你正在单步跟踪一行HAL_I2C_Mem_Write,代码执行一半强制停止,I2C外设和EEPROM可能停在"主机发完START、从机已经接收了几位数据"的中间状态。重新复位后,外设状态机乱掉,EEPROM还在傻等,总线自然锁死。
解决办法分成两类:软件层面,在初始化I2C之前先检查SDA电平,如果SDA低,就按第2章写的那样用GPIO输出九个SCL脉冲,把从机的状态机踢回空闲态,再重新初始化I2C。硬件层面,给AT24C02的WP写保护引脚串联一个下拉电阻,保证默认不写保护;同时确认A0~A2引脚不是悬空或者虚焊,地址引脚电平不稳定会导致设备不在期望地址上应答。九个脉冲这个数字来源是SMBus规范里的超时释放机制,对I2C从设备同样有效,多见于各类总线上"解锁"的通用手法。
5.2 写完掉电数据全部丢失,问题不在通信
另一个让我印象深刻的坑是:主机写完数据返回成功,过了几秒断电,重新上电读出来的全是0xFF。看起来像EEPROM没写进去,其实问题出在写周期没有完成。AT24C02上电后电压降到某个阈值以下,写周期就被打断,真正烧录进存储阵列的动作没完成。主机侧即便收到了ACK也不代表数据落地,ACK只代表字节在总线传输成功。
排查时我先看逻辑分析仪波形,确认I2C通信完全正常、ACK也正常,再深入发现问题在软件流程。我原来的代码在写完数据后没有等待写周期就立刻返回了,MCU随即执行下一行代码,马上进入掉电保存流程,断电瞬间数据根本没写完。修复方案是:所有写操作结束都调用wait_eeprom_ready()做ACK轮询,同时在电源设计上预留一点余量——如果掉电保存靠外部中断触发,进中断后第一件事应该是把数据搬到EEPROM,然后等写周期结束再允许系统彻底断电,这个过程需要电源电容撑住几十毫秒。一个简单的储能电容(比如100uF并联在3.3V上)通常就够用了。
5.3 3.3V和5V混用:I2C电平转换到底要不要做
AT24C02工作电压范围是1.8V到5.5V,很多老开发板直接给它供5V。但STM32F103是3.3V系统,GPIO的引脚耐压不是所有IO都能容忍5V,如果I2C总线上拉到5V,高电平直接灌到引脚上,长时间运行存在风险。
I2C是开漏总线,高电平由外部上拉电压决定,所以最简单的方案是让AT24C02的VCC也用3.3V,上拉电阻也接到3.3V,这样总线上所有高电平都是3.3V,完全不需要电平转换。如果硬件上AT24C02必须要工作在5V(比如它的内部上电阈值或者你需要兼容5V器件),SCL、SDA两条线上就要加双向电平转换电路。低成本方案用两个NMOS管做两路转换,或者直接用集成的I2C电平转换芯片,原理都是一样的:一边拉到3.3V,一边拉到5V,MOS管导通时把低电平传过去,高电平靠各自上拉恢复。这个转换电路不是可选项,而是必须项,直接通过电阻分压把3.3V转换成5V高电平是行不通的,因为上拉电阻和分压电阻混在一起会改变总线时序和电平阈值。
6. 几个可以直接用的扩展想法
6.1 I2C地址扫描器,排查挂载设备冲突
如果总线上同时挂了AT24C02、温湿度传感器、陀螺仪等多个I2C设备,最烦人的就是地址冲突。我习惯在调试阶段写一个I2C扫描函数,遍历0x08到0x77范围内的所有7位地址,逐个调用HAL_I2C_IsDeviceReady,把所有有ACK的设备地址打印到串口。这样能快速确认每个芯片的地址是否符合预期,还能发现哪些引脚虚焊导致设备根本没上总线。扫描速度很快,几百个地址毫秒级就完成,不会影响正常业务逻辑。
6.2 参数区的规范划分和磨损均衡
256字节看着小,实际存参数也够用。我常用的规划是:前16字节放设备标识和版本号,中间放标定参数,末尾留一块做运行计数。运行计数这种会频繁写入的数据要注意磨损问题,AT24C02标称100万次擦写,如果你每次开关机都写一次,几年后可能达到寿命上限。简单做法是把运行计数分散到几个地址循环写,或者只在特定事件发生时写一次,避免高频写入。虽然AT24C02内部没有硬件磨损均衡,但软件层面用"多个槽位轮转"就能大幅延长实际寿命。
6.3 写保护引脚的正确用法
AT24C02的WP引脚悬空或者接低电平,主控可以自由读写;如果WP接高电平,芯片会被硬件写保护,所有写操作都被忽略。这个特性在正式产品里很有用:出厂前把校准参数写进去,之后把WP拉高,防止运行时误写导致设备参数损坏。调试阶段则建议让WP固定接低,免得排查半天发现写不进去是因为硬件写保护。我在工程里单独留了一个测试函数,可以切换WP状态,验证读保护和写保护逻辑都没问题后再发布固件。
6.4 从AT24C02到其他容量的移植思路
AT24C02是入门型号,实际项目里可能用AT24C04、AT24C16等更大容量型号。不同容量的区别主要集中在两点:一是页大小可能从8字节变成16字节甚至更大,驱动里的AT24C02_PAGE_SIZE宏要按手册改;二是内部字地址位数不够8位时,要把I2C_MEMADD_SIZE_8BIT替换成I2C_MEMADD_SIZE_16BIT,并相应修改字地址字节数。如果能从写代码的第一天起就把页大小和地址宽度都抽象成宏,后面换型号基本就是改改宏和初始化参数,不用重写驱动。
我自己调试了这两天后最大的体会是,I2C和EEPROM读写看着简单,实际上每个环节都藏着一层"为什么"。物理层决定了开漏和上拉,器件手册决定了时序和页边界,HAL库只是把底层打包了,但你不懂原理,出了问题就只能盲目改参数。建议遇到不稳定问题先从波形入手,逻辑分析仪几十块钱就能买到,比对着代码猜半天高效得多。最后再分享一个实用小技巧:调试阶段在每次读写后把返回状态打印出来,一旦出现HAL_BUSY或NACK,能立刻定位到是哪个环节开始出错,这个习惯帮我省了大量排查时间。