☰
STM32F103硬件I2C驱动AT24C02 EEPROM读写实战指南
2026/10/21 10:28:12 网站建设 项目流程

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

搞嵌入式开发的朋友大概率都碰过这样一个场景:板子跑起来了,传感器数据也读到了,但一断电,之前辛辛苦苦标定的参数全没了,下次上电又得重新来一遍。这时候就需要一颗能掉电保存数据的小芯片——EEPROM。而在众多EEPROM型号里,AT24C02几乎是每个嵌入式工程师的“启蒙老师”,它便宜、好买、接口简单,配合STM32F103的硬件I2C外设,几根线就能搭起一套完整的非易失存储方案。

这个项目的核心目标很明确:用STM32F103的硬件I2C接口,完成对AT24C02 EEPROM的字节读写、页写、连续读等全套操作,并且把整个流程拆到能直接复现的程度。适合谁看?刚接触I2C协议的初学者、想从软件模拟I2C切换到硬件I2C的开发者、以及需要快速在项目里集成EEPROM存储功能的工程师。不管你用的是标准库还是HAL库,底层的时序逻辑和踩坑点都是相通的。

为什么选硬件I2C而不是软件模拟?早期STM32的硬件I2C确实有过一些“翻车”传闻,导致很多人宁愿用GPIO模拟。但F103这一代的硬件I2C在正确配置下是相当稳定的,而且它能解放CPU、支持DMA、中断处理更规范。软件模拟虽然灵活,但占用CPU时间、时序靠延时循环撑着,一旦系统里有中断干扰就容易出问题。所以这个项目选择硬件I2C作为主线,同时在排查环节会对比软件模拟的差异,方便你判断自己的场景该用哪种。

整体方案分四层:最底层是I2C物理层时序,中间是STM32的I2C外设寄存器配置,上面是AT24C02的器件协议层(设备地址、字地址、页边界),最顶层是应用层的读写接口封装。很多人调不通,往往不是某一层完全不会,而是层与层之间的衔接出了问题——比如设备地址左移没左移、页写跨页没处理、ACK/NACK时序没对上。这个项目就是要把这些衔接点一个个拆开讲透。

2. 硬件连接与I2C基础原理拆解

2.1 硬件接线与上拉电阻的选择

AT24C02是8引脚封装,核心引脚就四个:VCC、GND、SDA、SCL,剩下A0/A1/A2是地址选择脚,WP是写保护脚。接线本身不复杂,但有几个细节直接决定你能不能通信成功。

SDA和SCL必须接上拉电阻,这是I2C总线的硬性要求。因为I2C的引脚是开漏输出,器件只能把线拉低,不能主动拉高,高电平全靠上拉电阻把线“拽”上去。阻值怎么选?常见的是4.7kΩ和10kΩ。总线速率100kHz时,4.7kΩ是万能选择;如果速率拉到400kHz,可以降到2.2kΩ左右加快上升沿。阻值太大,上升沿变缓,高速下波形还没到高电平就被拉低了,通信直接失败;阻值太小,灌电流增大,器件可能扛不住。我实测下来,3.3V供电配4.7kΩ在100kHz下波形最干净。

A0/A1/A2三个脚决定器件地址的低三位。全部接地时,7位设备地址是1010000,也就是0x50。注意这里说的是7位地址,实际发送时要左移一位再或上读写位,变成0xA0(写)和0xA1(读)。这个左移操作是新手最容易漏的一步,后面会专门讲。

WP写保护脚接地时允许正常读写,接VCC时芯片进入只读模式,只能读不能写。调试阶段建议先接地,确认读写都通了再考虑要不要加写保护逻辑。

引脚连接说明
VCC3.3V供电,1.8V~5.5V均可
GND地共地
SDAPB7(示例)接4.7kΩ上拉到3.3V
SCLPB6(示例)接4.7kΩ上拉到3.3V
A0/A1/A2GND设备地址0x50
WPGND关闭写保护

2.2 I2C时序的四个关键信号

I2C协议说到底就是四种时序的组合:起始条件、停止条件、数据位传输、应答位。把这四个吃透,任何I2C器件都能调通。

起始条件(START):SCL为高电平时,SDA从高变低。这个“高电平期间拉低数据线”的动作是I2C独有的,用来告诉总线上所有器件“注意,要开始通信了”。

停止条件(STOP):SCL为高电平时,SDA从低变高。和起始相反,表示通信结束。

数据位传输:SCL为低电平时改变SDA,SCL为高电平时SDA必须保持稳定。也就是说,数据在时钟低电平期间准备好,在高电平期间被采样。这个规则保证了接收方能在时钟高电平的中间时刻稳定读到数据。

应答位(ACK/NACK):每传输完8位数据,接收方要在第9个时钟周期把SDA拉低表示应答(ACK),保持高电平表示非应答(NACK)。发送方通过检测这个位判断对方有没有收到。读操作时,主机读完最后一个字节要发NACK,告诉从机“我读完了,别再发了”。

注意:很多人调不通I2C,用示波器一看,起始条件就不对——SDA和SCL的先后顺序反了。记住口诀:起始是“先高SCL,再拉低SDA”,停止是“先高SCL,再拉高SDA”。

2.3 AT24C02的器件地址与字地址

AT24C02的7位设备地址固定前四位是1010,后三位由A2/A1/A0决定。全部接地就是1010000,即0x50。发送时左移一位:写操作是0xA0,读操作是0xA1。

字地址就是芯片内部存储单元的地址,AT24C02有256个字节,地址范围0x00~0xFF,所以字地址是8位,一个字节就能表示。这一点和更大容量的EEPROM不同,比如AT24C32需要16位字地址,要发两个字节。AT24C02只发一个字节,这是它简单的地方,也是容易搞混的地方——别把大容量芯片的时序套上来。

写操作流程:START → 发设备地址+写位(0xA0)→ 等ACK → 发字地址 → 等ACK → 发数据 → 等ACK → STOP。

读操作流程稍微绕一点:先做一个“伪写”操作,START → 发设备地址+写位(0xA0)→ 等ACK → 发字地址 → 等ACK → 再发START(重复起始)→ 发设备地址+读位(0xA1)→ 等ACK → 读数据 → 发NACK → STOP。这个“伪写”的目的是把内部地址指针设置到你想读的位置,然后重新以读模式启动。

3. STM32F103硬件I2C配置全流程

3.1 时钟与GPIO初始化

STM32F103的I2C1挂载在APB1总线上,PB6是SCL,PB7是SDA。初始化顺序不能乱:先开时钟,再配GPIO,最后配I2C外设。

// 使能时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); // 配置GPIO为复用开漏 GPIO_InitTypeDef GPIO_InitStructure; 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);

这里的关键是GPIO_Mode_AF_OD,必须是复用开漏模式。如果错配成推挽输出,I2C总线会出现两个器件同时驱动的情况,轻则通信失败,重则烧引脚。开漏模式配合外部上拉电阻,才能实现“线与”逻辑。

3.2 I2C外设参数配置

I2C的时钟频率由APB1时钟和CCR寄存器决定。假设系统时钟72MHz,APB1分频后是36MHz,要得到100kHz的SCL频率,计算过程如下:

  • 标准模式(100kHz):CCR = APB1时钟 / (2 × 目标频率) = 36MHz / (2 × 100kHz) = 180
  • 快速模式(400kHz):CCR = 36MHz / (2 × 400kHz) = 45
I2C_InitTypeDef I2C_InitStructure; I2C_InitStructure.I2C_Mode = I2C_Mode_I2C; I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2; // 占空比2:1 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);

I2C_OwnAddress1是主机自己的地址,作为主机时这个值其实用不上,但必须填一个合法的7位地址。I2C_Ack设为Enable,这样接收数据时硬件自动回ACK,读最后一个字节前再手动关掉。

3.3 超时机制的必要性

硬件I2C最怕的就是总线卡死——某个器件没响应,SCL被拉低不放,程序死在while循环里。所以每个等待标志位的循环都要加超时计数。

#define I2C_TIMEOUT 10000 static uint8_t I2C_WaitEvent(uint32_t event) { uint32_t timeout = I2C_TIMEOUT; while (!I2C_CheckEvent(I2C1, event)) { if (--timeout == 0) return 1; // 超时返回错误 } return 0; }

超时值设多少合适?100kHz下传输一个字节大约90微秒,10000次循环在72MHz主频下大概几毫秒,足够覆盖正常传输。如果频繁超时,先查硬件接线,再查上拉电阻,最后查设备地址。

实操心得:调试I2C时,如果程序卡在等待ADDR标志,八成是设备地址错了或者从机没上电。如果卡在等待BTF(字节传输完成),可能是从机没回ACK,检查从机是否正常工作。

4. AT24C02读写接口的完整实现

4.1 字节写操作的实现与等待

字节写是最基础的操作,但有一个坑:EEPROM写完一个字节后需要内部擦写时间,典型5ms,最大10ms。在这段时间内,芯片不会响应任何I2C命令。所以写完必须延时或者用“应答轮询”等待。

uint8_t AT24C02_WriteByte(uint8_t addr, uint8_t data) { // 发送起始条件 I2C_GenerateSTART(I2C1, ENABLE); if (I2C_WaitEvent(I2C_EVENT_MASTER_MODE_SELECT)) return 1; // 发送设备地址+写 I2C_Send7bitAddress(I2C1, 0xA0, I2C_Direction_Transmitter); if (I2C_WaitEvent(I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)) return 2; // 发送字地址 I2C_SendData(I2C1, addr); if (I2C_WaitEvent(I2C_EVENT_MASTER_BYTE_TRANSMITTED)) return 3; // 发送数据 I2C_SendData(I2C1, data); if (I2C_WaitEvent(I2C_EVENT_MASTER_BYTE_TRANSMITTED)) return 4; // 发送停止条件 I2C_GenerateSTOP(I2C1, ENABLE); // 等待EEPROM内部擦写完成 Delay_ms(10); return 0; }

这里的Delay_ms(10)是保守做法。更优雅的方式是应答轮询:发START + 设备地址,如果收到ACK说明芯片忙完了,如果NACK就继续等。这样能省掉不必要的固定延时,提高写入效率。

4.2 页写操作的边界处理

AT24C02的页大小是8字节。页写允许一次连续写最多8个字节,但有一个硬性限制:不能跨页。也就是说,如果起始地址是0x06,你最多只能写2个字节(0x06和0x07),写到0x08就翻到下一页了,芯片会从页首0x00重新开始覆盖,导致数据错乱。

uint8_t AT24C02_WritePage(uint8_t addr, uint8_t *buf, uint8_t len) { // 计算当前页剩余空间 uint8_t pageRemain = 8 - (addr % 8); if (len > pageRemain) len = pageRemain; // 截断,不跨页 I2C_GenerateSTART(I2C1, ENABLE); if (I2C_WaitEvent(I2C_EVENT_MASTER_MODE_SELECT)) return 1; I2C_Send7bitAddress(I2C1, 0xA0, I2C_Direction_Transmitter); if (I2C_WaitEvent(I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)) return 2; I2C_SendData(I2C1, addr); if (I2C_WaitEvent(I2C_EVENT_MASTER_BYTE_TRANSMITTED)) return 3; for (uint8_t i = 0; i < len; i++) { I2C_SendData(I2C1, buf[i]); if (I2C_WaitEvent(I2C_EVENT_MASTER_BYTE_TRANSMITTED)) return 4; } I2C_GenerateSTOP(I2C1, ENABLE); Delay_ms(10); return 0; }

如果要写超过一页的数据,应用层需要自己拆包,分多次页写完成。我一般封装一个AT24C02_WriteBuffer函数,内部循环调用页写,自动处理跨页拆分。

4.3 连续读操作的实现

连续读可以一次读出整个芯片的数据,地址指针会自动递增,到0xFF后回卷到0x00。读操作的时序比写多了一个“重复起始”步骤。

uint8_t AT24C02_ReadBuffer(uint8_t addr, uint8_t *buf, uint16_t len) { // 伪写:设置地址指针 I2C_GenerateSTART(I2C1, ENABLE); if (I2C_WaitEvent(I2C_EVENT_MASTER_MODE_SELECT)) return 1; I2C_Send7bitAddress(I2C1, 0xA0, I2C_Direction_Transmitter); if (I2C_WaitEvent(I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)) return 2; I2C_SendData(I2C1, addr); if (I2C_WaitEvent(I2C_EVENT_MASTER_BYTE_TRANSMITTED)) return 3; // 重复起始,切换为读模式 I2C_GenerateSTART(I2C1, ENABLE); if (I2C_WaitEvent(I2C_EVENT_MASTER_MODE_SELECT)) return 4; I2C_Send7bitAddress(I2C1, 0xA1, I2C_Direction_Receiver); if (I2C_WaitEvent(I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED)) return 5; for (uint16_t i = 0; i < len; i++) { if (i == len - 1) { // 最后一个字节前发NACK I2C_AcknowledgeConfig(I2C1, DISABLE); I2C_GenerateSTOP(I2C1, ENABLE); } if (I2C_WaitEvent(I2C_EVENT_MASTER_BYTE_RECEIVED)) return 6; buf[i] = I2C_ReceiveData(I2C1); } I2C_AcknowledgeConfig(I2C1, ENABLE); // 恢复ACK return 0; }

关键点在最后一个字节:必须先关ACK再读数据,然后发STOP。顺序反了会导致多读一个字节或者总线状态异常。

5. 常见问题排查与避坑经验实录

5.1 通信失败问题速查表

现象可能原因排查方法
卡在等待START标志总线被拉低,SCL/SDA短路万用表测电压,正常空闲时都是高
卡在等待ADDR标志设备地址错误或从机未响应确认地址左移,示波器看第9位ACK
写进去读出来不对页写跨页覆盖检查起始地址和长度是否跨8字节边界
偶尔成功偶尔失败上拉电阻过大或走线过长换4.7kΩ,缩短接线
第一次写成功后续失败没等擦写完成加10ms延时或应答轮询
读数据全FF从机没应答,读的是总线空闲电平检查WP脚是否误接VCC

5.2 应答轮询替代固定延时

固定延时10ms虽然简单,但每次写都等10ms,写256字节要2.5秒,效率太低。应答轮询的思路是:发START + 设备地址,如果芯片还在擦写,它不会回ACK,主机收到NACK后发STOP,过一会儿再试,直到收到ACK为止。

void AT24C02_WaitReady(void) { do { I2C_GenerateSTART(I2C1, ENABLE); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, 0xA0, I2C_Direction_Transmitter); // 等待ADDR或AF(应答失败) uint32_t timeout = I2C_TIMEOUT; while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED) && !I2C_GetFlagStatus(I2C1, I2C_FLAG_AF)) { if (--timeout == 0) break; } I2C_GenerateSTOP(I2C1, ENABLE); I2C_ClearFlag(I2C1, I2C_FLAG_AF); } while (I2C_GetFlagStatus(I2C1, I2C_FLAG_AF)); }

这样平均等待时间只有几毫秒,而且不用死等,效率提升明显。

5.3 数据校验与磨损均衡

EEPROM有擦写寿命限制,AT24C02标称100万次。如果某个地址频繁写入,比如每秒存一次传感器数据,不到两周就写废了。两个应对策略:

一是加校验。每个数据块后面跟一个CRC或者简单的累加和,读出来先校验再使用,防止读到半写状态的数据。

二是磨损均衡。不要每次都写同一个地址,可以在多个页之间轮换写入,记录一个写指针,每次写到下一个位置。读的时候扫描所有位置找最新的有效数据。这个策略对AT24C02这种小容量芯片尤其重要,256字节分成32页,轮换写入能把寿命延长32倍。

踩过的坑:有一次做数据记录仪,每秒往0x00地址写一次,跑了大概十天,那个字节就写坏了,读出来永远是0xFF。后来改成轮换写入加校验,同样的芯片跑了半年都没事。

5.4 软件模拟I2C的对比与选择

硬件I2C调不通的时候,很多人会退回软件模拟。软件模拟的好处是引脚随便选、时序完全可控、不受硬件外设的奇怪状态影响。但代价是占用CPU、速率上不去、中断多了容易乱。

我的建议是:如果硬件I2C配置正确,优先用硬件,省CPU而且稳定。如果项目里I2C器件多、总线冲突频繁、或者需要热插拔,软件模拟的容错性反而更好。两者不是替代关系,而是场景选择。实际项目中我经常两个都留着,通过宏切换,调试时用软件模拟确认硬件没问题,量产时切硬件I2C。

6. 完整测试用例与验证方法

6.1 基础读写测试

先写一个字节再读回来,确认基本通路。测试地址选0x00和0xFF两个边界值,再选一个中间值0x55。

void Test_ByteReadWrite(void) { uint8_t testAddr[] = {0x00, 0x55, 0xFF}; uint8_t writeData = 0xA5; for (int i = 0; i < 3; i++) { AT24C02_WriteByte(testAddr[i], writeData); uint8_t readData = AT24C02_ReadByte(testAddr[i]); if (readData == writeData) printf("Addr 0x%02X: PASS\n", testAddr[i]); else printf("Addr 0x%02X: FAIL (wrote 0x%02X, read 0x%02X)\n", testAddr[i], writeData, readData); } }

6.2 页边界测试

专门测试跨页场景:从0x06写4个字节,预期只有前2个字节写入0x06和0x07,后2个字节被丢弃或覆盖。验证芯片行为是否符合预期,同时验证自己的页写函数是否正确截断。

void Test_PageBoundary(void) { uint8_t buf[4] = {0x11, 0x22, 0x33, 0x44}; AT24C02_WritePage(0x06, buf, 4); // 应该只写2个字节 // 读回验证 uint8_t readBuf[4]; AT24C02_ReadBuffer(0x06, readBuf, 4); // 预期:0x06=0x11, 0x07=0x22, 0x08=原值, 0x09=原值 }

6.3 全片读写与数据完整性

写256字节的递增序列,再全部读回比对,验证连续读的地址回卷和整体稳定性。

void Test_FullChip(void) { uint8_t wBuf[256], rBuf[256]; for (int i = 0; i < 256; i++) wBuf[i] = i; // 分页写入 for (int i = 0; i < 256; i += 8) AT24C02_WritePage(i, &wBuf[i], 8); // 连续读出 AT24C02_ReadBuffer(0x00, rBuf, 256); // 比对 uint16_t errors = 0; for (int i = 0; i < 256; i++) if (rBuf[i] != wBuf[i]) errors++; printf("Full chip test: %d errors\n", errors); }

这个测试跑通,基本可以确认硬件连接、时序配置、页写拆分、连续读逻辑全部正确。

6.4 掉电保持验证

最后一步是拔电测试:写入一组数据,断电等待几分钟,重新上电读取,确认数据还在。这一步能验证EEPROM的擦写是否真正完成,以及有没有因为延时不够导致数据没写进去。

实操心得:掉电测试建议至少等5分钟,有些劣质芯片或者供电不稳的情况下,刚写完就断电可能数据丢失。如果项目对可靠性要求高,写完关键数据后可以读回校验一次,确认无误再继续。

7. 性能优化与进阶思路

7.1 用DMA减轻CPU负担

连续读256字节时,CPU要一直轮询RXNE标志,效率不高。F103的I2C支持DMA请求,可以配置DMA通道自动搬运数据,CPU只需要在传输完成后处理一次中断。

配置要点:I2C的RXNE请求映射到DMA1通道7,TXE映射到通道6。使能I2C的DMA使能位(I2C_DMALastTransferCmd和I2C_DMACmd),设置DMA传输长度,启动后CPU就可以去干别的事了。

7.2 中断方式与状态机

如果不想用DMA,也可以用中断方式。把I2C事件中断打开,在中断服务函数里根据当前状态机切换下一步操作。这种方式比轮询灵活,但状态机设计要清晰,否则容易在中断里绕晕。

我一般用三个状态:IDLE、TX、RX。每次事件中断触发后,根据状态和事件类型决定发数据、收数据还是发STOP。状态机的好处是主循环完全不用管I2C,适合多任务场景。

7.3 封装成通用存储接口

项目做多了会发现,今天用AT24C02,明天可能换AT24C16,后天可能用Flash模拟EEPROM。所以最好把读写接口抽象成统一的存储层:Init、Read、Write、Erase四个函数指针,底层具体是哪种芯片对上层透明。这样换芯片只需要改底层驱动,应用代码一行不动。

这个思路在量产项目里特别有用,因为采购经常换料,今天这个型号缺货明天换另一个,有抽象层就能快速适配。

8. 写在最后的实操体会

调I2C这件事,说难不难,说简单也不简单。我见过太多人卡在“设备地址没左移”或者“忘了加上拉电阻”这种小问题上,一调就是一整天。其实只要把时序图对着示波器看一遍,起始、地址、ACK、数据、停止,每个环节都对上,问题自然就暴露了。

AT24C02这颗芯片虽然简单,但它是理解I2C协议最好的载体。把它调通之后,再去搞OLED屏、温湿度传感器、气压计,底层逻辑完全一样,只是设备地址和寄存器地址不同而已。所以别嫌它简单,认真把这一颗芯片吃透,后面所有I2C器件都是举一反三的事。

最后分享一个小技巧:调试阶段在每步操作后加一句printf,把返回的错误码打出来。比如返回1是START超时,返回2是ADDR超时,返回3是数据发送超时。这样一眼就能定位到卡在哪一步,比盲目猜效率高得多。等调通了再把printf去掉,不影响最终性能。

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

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

立即咨询