I2C 总线在嵌入式开发里算是"老熟人"了,但真到动手把 AT24C02 这类 EEPROM 跑通,很多人还是会卡在时序、地址、应答这些细节上。STM32F103 的硬件 I2C 外设功能齐全,可一旦配置不对,总线直接锁死、读回全 0xFF 的情况太常见了。这篇内容就围绕 STM32F103 驱动 AT24C02 的完整读写流程展开,从硬件连接、I2C 协议原理,到寄存器配置、字节读写、页写、时序分析,再到实际调试中踩过的坑,一步步拆开讲。不管你是刚接触 I2C 的新手,还是想把这套流程整理成可复用代码的老手,都能从中找到能直接抄作业的部分。
1. 为什么选 AT24C02 和 STM32F103 这套组合
1.1 AT24C02 的定位与典型应用场景
AT24C02 是一颗 2Kbit(256 字节)的 I2C 接口 EEPROM,属于 Atmel(现 Microchip)AT24C 系列里容量最小的一档。别看它容量小,在嵌入式系统里出场率极高,原因很实在:价格便宜、接口简单、掉电不丢数据、支持百万次擦写。典型用途包括保存设备校准参数、记录开机次数、存储用户配置、掉电前的状态快照等。
它的存储结构是 32 页 × 8 字节/页,总共 256 字节。这个"页"的概念很关键,后面讲页写的时候会重点说。器件地址由 A0/A1/A2 三个引脚决定,7 位地址的高 4 位固定为 1010,低 3 位由引脚电平决定,所以一条总线上最多挂 8 片 AT24C02,地址范围 0x50 到 0x57。写保护引脚 WP 拉高时整个存储区只读,拉低才允许写入,这个引脚在调试阶段建议直接接地,省得排查半天发现是写保护没放开。
1.2 STM32F103 硬件 I2C 外设的能力边界
STM32F103 系列(比如常见的 C8T6、RCT6)一般带 1 到 2 个 I2C 外设,支持标准模式 100kHz 和快速模式 400kHz,兼容 SMBus 和 PMBus。硬件 I2C 的好处是时序由外设自动生成,CPU 负担小,支持中断和 DMA。但它也有"脾气":状态机复杂,一旦某个事件没按预期清除,总线容易卡死;错误标志处理不当会导致后续所有传输失败。
很多人被硬件 I2C 坑过之后转投软件模拟 I2C,其实没必要。硬件 I2C 只要把事件顺序和超时处理做扎实,稳定性完全够用。我个人的做法是:初始化时把时钟频率设成 100kHz 起步,跑通后再往上调;每个等待事件的地方都加超时计数,避免死循环。
1.3 硬件连接与上拉电阻的关键细节
接线本身不复杂:STM32 的 SCL、SDA 分别接 AT24C02 的 SCL、SDA,VCC 接 3.3V,GND 共地,A0/A1/A2 接地(地址 0x50),WP 接地。真正容易出问题的是上拉电阻。
I2C 是开漏输出,总线必须靠上拉电阻拉到高电平。阻值选择有讲究:太大,上升沿变缓,高速通信时波形爬不上去;太小,灌电流过大,可能超过器件的驱动能力。经验值是 4.7kΩ 在 100kHz 下很稳,400kHz 时可以用 2.2kΩ 到 4.7kΩ。如果总线上挂了多个从机,或者走线较长,阻值要适当减小。
注意:STM32F103 的 I2C 引脚必须配置为复用开漏模式(AF_OD),不能配成推挽。推挽模式下两个设备同时输出会直接短路,这是新手最容易犯的错之一。
2. I2C 协议里那些必须搞清楚的时序概念
2.1 起始、停止与应答信号的本质
I2C 只有两根线,所有信息都靠电平变化来编码。起始条件(START)是 SCL 为高时 SDA 由高变低;停止条件(STOP)是 SCL 为高时 SDA 由低变高。数据位在 SCL 低电平期间改变,在 SCL 高电平期间保持稳定,这样接收方才能在时钟高电平采样到有效数据。
每传输 8 位数据后,接收方要拉低 SDA 一个时钟周期作为应答(ACK),表示"收到了"。如果接收方不拉低(保持高),就是非应答(NACK)。主机读数据时,读完最后一个字节要主动发 NACK,然后发 STOP,告诉从机"我不再需要了"。这个细节在写读时序时特别容易漏,漏了就会多读一个字节或者总线状态异常。
2.2 AT24C02 的器件地址与读写位
AT24C02 的 7 位地址是 1010 + A2A1A0。以 A0/A1/A2 全接地为例,7 位地址是 1010000,即 0x50。实际传输时是 8 位:高 7 位地址 + 1 位读写方向位。写操作方向位为 0,所以写地址字节是 0xA0;读操作方向位为 1,读地址字节是 0xA1。
这里有个常见误区:有人直接把 0x50 当写地址用,结果从机不应答。记住,0x50 是 7 位地址,左移一位再加读写位才是总线上真正发送的字节。STM32 的硬件 I2C 在发送地址时,库函数通常要求传入已经左移好的 8 位地址,这点要看具体用的是标准库还是 HAL 库。
2.3 字节写、页写与读操作的时序差异
AT24C02 支持三种基本操作:字节写、页写、当前地址读、随机读、顺序读。字节写是发起始、写地址、字地址、数据、停止,从机在收到停止后启动内部写周期,大约 5ms,这期间不响应任何命令。页写是一次性写最多 8 个字节,但有个硬性限制:不能跨页。如果起始地址是 0x06,写 8 个字节会写到 0x06 到 0x0D,但 0x08 是下一页的起点,实际会回卷到 0x00,把前面的数据覆盖掉。这个"页回卷"是页写最坑的地方。
读操作分两种:当前地址读是直接发读地址然后读数据;随机读是先发写地址+字地址(伪写),再发起始+读地址,然后读数据。随机读的"伪写"步骤很多人不理解,其实就是为了把内部地址指针定位到指定位置。
3. STM32F103 硬件 I2C 的寄存器级配置思路
3.1 时钟使能与 GPIO 复用配置
以 I2C1 为例,它挂在 APB1 总线上。第一步是开时钟:RCC_APB1ENR 的 I2C1EN 置位,同时 RCC_APB2ENR 的 GPIOBEN 置位(假设用 PB6/PB7)。然后配置 GPIO:PB6 为 SCL,PB7 为 SDA,模式设为复用开漏,输出速度可以设 50MHz。
标准库里的写法是 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD。HAL 库则是通过 HAL_I2C_Init 内部处理,但 GPIO 的复用功能要单独用 HAL_GPIO_Init 配好。这一步如果配成普通开漏输出(GPIO_Mode_Out_OD),硬件 I2C 外设根本接管不了引脚,波形出不来。
3.2 I2C 时钟控制寄存器 CCR 与 TRISE 的计算
I2C 的通信速率由 CCR 寄存器决定。标准模式 100kHz 下,CCR = PCLK1 / (2 × 100000)。假设 PCLK1 是 36MHz,CCR = 36000000 / 200000 = 180。快速模式 400kHz 下,如果占空比是 2:1,CCR = PCLK1 / (3 × 400000) = 30。
TRISE 寄存器限制 SCL 上升沿的最大时间。标准模式最大允许 1000ns,快速模式 300ns。计算公式是 TRISE = (最大上升时间 / PCLK1 周期) + 1。36MHz 下周期约 27.8ns,标准模式 TRISE = 1000/27.8 + 1 ≈ 37。这个值设不对,通信可能在高频下出错,但 100kHz 下影响不明显,所以很多人忽略了它。
3.3 事件检测与超时保护机制
硬件 I2C 的发送流程是一串事件驱动的:先发 START,等 SB 标志;发地址,等 ADDR 标志;发数据,等 TXE 标志;发 STOP,等 BTF 标志。每个等待都要有超时,否则一旦从机没接好,程序就死等在那里。
我习惯写一个通用的等待函数,传入事件标志和超时计数,超时后返回错误码并复位 I2C 外设。复位的方法是置位 CR1 的 SWRST 位,然后重新初始化。这个"看门狗式"的保护在实际产品里非常必要,因为现场环境复杂,总线被拉死的情况并不罕见。
4. 从零实现 AT24C02 字节读写与页写
4.1 单字节写的完整流程与代码
单字节写的步骤:发 START → 发写地址 0xA0 → 等 ACK → 发字地址(0x00 到 0xFF)→ 等 ACK → 发数据 → 等 ACK → 发 STOP → 延时 5ms 等内部写完成。
用标准库写出来大概是这样:
uint8_t AT24C02_WriteByte(uint8_t addr, uint8_t data) { I2C_GenerateSTART(I2C1, ENABLE); if (!I2C_WaitEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT, TIMEOUT)) return 1; I2C_Send7bitAddress(I2C1, 0xA0, I2C_Direction_Transmitter); if (!I2C_WaitEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED, TIMEOUT)) return 2; I2C_SendData(I2C1, addr); if (!I2C_WaitEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTING, TIMEOUT)) return 3; I2C_SendData(I2C1, data); if (!I2C_WaitEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED, TIMEOUT)) return 4; I2C_GenerateSTOP(I2C1, ENABLE); Delay_ms(5); return 0; }注意最后那个 5ms 延时不能省。AT24C02 在 STOP 之后进入内部写周期,这期间它不响应总线。如果紧接着发下一个写命令,从机会 NACK,导致失败。有些方案用"应答查询"代替固定延时,就是反复发写地址直到收到 ACK,效率更高,但实现稍复杂。
4.2 页写的边界处理与回卷陷阱
页写的代码结构和字节写类似,只是连续发多个数据字节。关键在于发送前要计算这一页还能写几个字节。假设起始地址是 addr,页内偏移是 addr % 8,这一页剩余空间是 8 - (addr % 8)。如果要写的数据长度超过剩余空间,必须分多次写,每次不超过页边界。
我见过有人图省事,直接循环调用字节写来写连续数据,这样虽然不会出错,但每个字节都要等 5ms,写 256 字节要 1.28 秒,太慢了。正确做法是用页写,256 字节最多 32 次页写,每次 5ms,总共 160ms,快了一个数量级。
提示:页写时如果数据长度正好填满一页,从机地址指针会自动翻到下一页,这是正常的。但如果起始地址不在页首,且数据长度超过剩余空间,就会回卷覆盖,务必在代码里做边界判断。
4.3 随机读与顺序读的实现细节
随机读的流程:发 START → 发写地址 0xA0 → 等 ACK → 发字地址 → 等 ACK → 再发 START(重复起始)→ 发读地址 0xA1 → 等 ACK → 读数据 → 发 NACK → 发 STOP。
这里的关键是"重复起始"(Restart),它不发 STOP 直接再发 START,保持总线控制权。如果用 STOP 再 START,虽然也能工作,但中间总线可能被其他主机抢占(多主机场景),所以标准做法是用重复起始。
顺序读是在随机读的基础上,读完一个字节后主机发 ACK 而不是 NACK,从机就会继续输出下一个地址的数据,直到主机发 NACK 才停止。这个特性适合连续读取一块数据,比如一次读出整个配置区。
5. 调试过程中最容易被忽略的几个坑
5.1 总线锁死的成因与恢复方法
总线锁死通常表现为 SCL 或 SDA 被某个设备一直拉低,主机发 START 也没反应。常见原因是:从机在传输过程中被复位,导致它还在等时钟,而主机已经放弃;或者上拉电阻没接,总线浮空。
恢复方法有两种。一是硬件复位:给从机断电再上电。二是软件模拟时钟:把 SCL 配成普通 GPIO,手动发 9 个时钟脉冲,让从机把剩余数据位发完,然后发 STOP。这个方法在不能断电的场合很实用。STM32 的 I2C 外设本身有 SWRST 位可以复位,但如果引脚被从机拉死,复位外设也没用,必须先把总线解开。
5.2 写周期延时不足导致的偶发失败
前面提到写操作后要延时 5ms,但实际测试中,有些批次的 AT24C02 在极端温度下写周期可能接近 10ms。如果产品要在宽温范围工作,建议延时留足余量,或者改用应答查询。应答查询的写法是:发 START → 发写地址 → 如果收到 ACK 说明写完成,如果 NACK 就延时一小段再试,直到成功或超时。
我个人的经验是,常温调试用固定 5ms 没问题,但量产固件里最好用应答查询,既快又稳。应答查询的平均等待时间通常只有 2 到 3ms,比固定延时效率高。
5.3 多字节读写时的地址自增与跨页问题
AT24C02 的地址指针在每次读写后自动加一,到达 0xFF 后回卷到 0x00。这个特性在读操作里很方便,可以一次读出整个存储区。但在写操作里要小心:如果连续写超过 256 字节,会从头覆盖。虽然 AT24C02 只有 256 字节,写满就结束了,但如果代码是通用的,换成 AT24C256 这类大容量芯片时,跨页和地址回卷的逻辑就要重新考虑。
另外,读操作没有页的概念,可以连续读任意长度,只要主机一直发 ACK。所以读大块数据时不用像写那样分页,直接顺序读即可。
6. 把读写封装成可复用模块的实践建议
6.1 接口设计:分层与错误码
我习惯把 EEPROM 操作分成两层:底层是 I2C 读写原语,负责起始、停止、字节收发;上层是 AT24C02 的业务接口,比如 WriteByte、ReadByte、WriteBuffer、ReadBuffer。上层接口返回统一的错误码,0 表示成功,非 0 表示具体错误类型(超时、NACK、参数越界等)。
这样分层的好处是,换 MCU 或换 I2C 实现时,只需要改底层,上层业务代码不动。错误码统一后,上层可以决定是重试还是上报,逻辑清晰。
6.2 参数校验与边界保护
WriteBuffer 和 ReadBuffer 必须校验地址范围。AT24C02 的合法地址是 0x00 到 0xFF,长度不能超过 256 减去起始地址。如果传入 addr=0xF0、len=32,就会越界。虽然 AT24C02 会回卷,但这是隐式行为,容易掩盖 bug。显式校验并返回错误,能让问题在开发阶段就暴露出来。
另外,页写时要自动分页。上层调用 WriteBuffer(0x06, buf, 10),底层应该自动拆成 0x06 写 2 字节、0x08 写 8 字节两次页写,而不是让调用者自己去算。这个封装能大幅降低使用门槛。
6.3 实测性能数据与优化方向
在 100kHz 下实测:单字节写约 5.2ms(含延时),页写 8 字节约 5.3ms,随机读约 0.3ms,顺序读 256 字节约 23ms。如果把速率提到 400kHz,读操作能快 4 倍左右,写操作受限于内部写周期,提升不明显。
优化方向主要是减少写延时。除了应答查询,还可以在两次写之间插入其他任务,用状态机的方式管理,避免 CPU 空等。在 RTOS 环境下,可以把写操作放到低优先级任务里,延时期间让出 CPU。
7. 几个实际项目中的经验补充
7.1 电源波动对 EEPROM 写入的影响
EEPROM 写入时需要稳定的电源。如果系统在写入过程中掉电,可能导致数据写坏,甚至锁死芯片。我在一个电池供电的项目里遇到过:电池电量低时写 EEPROM,偶尔会失败。后来在写操作前检测电压,低于阈值就拒绝写入,问题解决。
另外,AT24C02 的 VCC 范围是 1.8V 到 5.5V,STM32F103 是 3.3V,直接对接没问题。但如果系统里有 5V 器件,要注意电平匹配。I2C 是开漏总线,只要上拉电阻接到 3.3V,5V 器件也能通信,因为开漏输出不会主动拉高,不存在电平冲突。但如果 5V 器件的 I2C 引脚有内部上拉,就要小心了。
7.2 用逻辑分析仪抓波形的正确姿势
调试 I2C 最有效的工具是逻辑分析仪。抓波形时要注意采样率,至少是通信速率的 10 倍以上,100kHz 的 I2C 建议用 1MHz 以上采样率。触发条件设成 SDA 下降沿(起始条件),这样能抓到完整的传输过程。
看波形时重点看几个地方:起始条件是否干净、地址字节是否正确、每个字节后的 ACK 是否拉低、停止条件是否正常。如果 ACK 没拉低,说明从机没响应,先查地址和接线。如果波形上升沿很缓,说明上拉电阻太大。
7.3 从 AT24C02 迁移到其他 EEPROM 的注意事项
如果项目后期需要更大容量,比如换成 AT24C256(32K 字节),地址结构会变。AT24C256 的字地址是 16 位,需要发两个字节,而 AT24C02 只发一个字节。器件地址的低 3 位含义也不同,AT24C256 只用 A0/A1 两位做地址,A2 用于页地址高位。这些差异在迁移时都要改。
不过,如果底层接口封装得好,上层业务代码基本不用动,只需要改底层对字地址的处理。这也是我强调分层设计的原因。
8. 写在最后的一点个人体会
I2C 和 EEPROM 这套东西,原理不复杂,但细节特别多。我刚开始做的时候,被总线锁死折腾了整整两天,最后发现是上拉电阻没焊。后来养成了习惯:每次新板子回来,先用逻辑分析仪抓一遍波形,确认时序正常再写代码。这个习惯帮我省了大量调试时间。
另外,硬件 I2C 虽然方便,但不要迷信它。遇到诡异问题时,用软件模拟 I2C 交叉验证一下,能快速定位是硬件问题还是配置问题。两种方式各有适用场景,关键是理解背后的时序原理,这样不管用哪种都能心里有数。