资讯动态

STM32 I2C读写AT24C02 EEPROM:从物理层到代码实战

发布时间:2026/10/25 1:02:44 来源:尧图企业网站定制
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后默认引脚是PB6SCL和PB7SDA时钟源来自APB1总线最高36MHz。工程里我习惯把系统时钟配到72MHzAPB1分频到36MHzI2C选Standard Mode、100kHzCubeMX会自动算出合适的分频系数。对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所以完整的首字节是写操作0xA010100000读操作0xA110100001。这个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而不是STOPSTART是为了保证这个读操作是原子性的避免总线上其他主机插进来抢先操作。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选Standard100kHz其他保持默认。时钟树里确保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一边拉到5VMOS管导通时把低电平传过去高电平靠各自上拉恢复。这个转换电路不是可选项而是必须项直接通过电阻分压把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能立刻定位到是哪个环节开始出错这个习惯帮我省了大量排查时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑