1. 为什么还要折腾 AT24C02 这颗老古董
1.1 从一次掉电丢参数的事故说起
前阵子帮朋友调一个工业采集板,主控是 STM32F103C8T6,跑得好好的,结果现场一断电再上电,标定参数全没了,设备直接罢工。他一开始想用 Flash 模拟 EEPROM 存参数,代码写了一半发现擦写寿命和页管理太麻烦,最后还是老老实实焊了一颗 AT24C02 上去。这事让我觉得,虽然现在动不动就是 W25Q 系列、FRAM、甚至直接上文件系统,但 AT24C02 这种 2Kbit 的小容量 EEPROM 在中小项目里依然是性价比之王——便宜、接口简单、掉电不丢、按字节写,特别适合存配置参数、校准系数、设备序列号这类小数据。
这篇文章我就把 STM32F103 通过 I2C 读写 AT24C02 的完整流程拆开讲,从硬件接线、I2C 时序原理、寄存器配置,到单字节读写、页写、连续读,再到实际调试中踩过的坑,全部按我自己的实操顺序来。适合刚接触 I2C 的嵌入式新手,也适合那些用 HAL 库但没搞明白底层时序的老哥。你看完至少能做到:自己写一套不依赖 HAL 的 I2C 驱动,或者用 HAL 库但知道每个参数为什么这么填。
1.2 AT24C02 到底是个什么东西
AT24C02 是 Atmel(现已被 Microchip 收购)出的 I2C 接口 EEPROM,容量 2Kbit,也就是 256 字节。注意单位,2Kbit 除以 8 等于 256 字节,很多人第一次用会以为是 2048 字节,结果地址写到 256 就翻车。它的内部组织是 32 页,每页 8 字节,这个页结构非常关键,后面讲页写的时候会重点说。
它的 I2C 从机地址是 7 位,高 4 位固定为 1010,低 3 位由 A2、A1、A0 三个引脚决定。所以一条 I2C 总线上最多挂 8 颗 AT24C02,地址范围从 0x50 到 0x57。写操作时地址字节是 0xA0 到 0xAE(偶地址),读操作是 0xA1 到 0xAF(奇地址)。这个 0x50 是 7 位地址,HAL 库函数里要左移一位,很多新手就是在这里把地址搞错,读出来全是 0xFF。
工作电压范围 1.8V 到 5.5V,兼容 3.3V 和 5V 系统,和 STM32F103 的 3.3V 直接对接没问题。I2C 速率支持标准模式 100kHz 和快速模式 400kHz,部分型号支持 1MHz,但一般我们用 100kHz 或 400kHz 就够了。写周期典型值 5ms,最大 10ms,这个参数决定了你连续写的时候必须加延时或者用应答轮询,不然数据会丢。
1.3 为什么选 I2C 而不是 SPI
有人会问,SPI 更快,为什么不用 SPI 的 EEPROM?我的经验是,看引脚资源和总线复用情况。I2C 只用两根线,SCL 和 SDA,可以挂多个从机,AT24C02、OLED 屏、温湿度传感器、RTC 都能挂在同一条总线上,节省 MCU 引脚。SPI 虽然快,但每个从机要一根 CS,挂多了引脚吃不消。对于存参数这种低频、小数据量的场景,I2C 的 100kHz 完全够用,写 256 字节也就几十毫秒的事。
另外 I2C 是开漏输出,需要上拉电阻,这个后面硬件部分会详细说。SPI 是推挽,一般不需要外部上拉。从抗干扰角度,短距离板内通信两者差别不大,长距离 I2C 反而因为开漏和上拉更容易受容性负载影响。所以我的建议是:板内、低速、多从机,选 I2C;高速、大数据量、点对点,选 SPI。
2. 硬件连接与上拉电阻的坑
2.1 最小系统接线清单
先列一下我这次用的硬件:
- 主控:STM32F103C8T6 最小系统板
- 存储:AT24C02 DIP8 封装
- 上拉电阻:4.7kΩ 两个
- 电源:3.3V
- 调试器:ST-Link V2
- 串口:CH340 模块,用来打印调试信息
接线如下表:
| AT24C02 引脚 | 名称 | 接到 STM32 | 说明 |
|---|---|---|---|
| 1 | A0 | GND | 地址位,接地 |
| 2 | A1 | GND | 地址位,接地 |
| 3 | A2 | GND | 地址位,接地 |
| 4 | GND | GND | 电源地 |
| 5 | SDA | PB7 | I2C1_SDA |
| 6 | SCL | PB6 | I2C1_SCL |
| 7 | WP | GND | 写保护,接地允许写 |
| 8 | VCC | 3.3V | 电源正 |
这里 WP 引脚要特别注意,接高电平会禁止写操作,只能读。我第一次用的时候没注意,WP 悬空,结果写进去读出来还是旧数据,查了半天以为是时序问题,最后发现是 WP 被内部弱上拉拉高了。所以要么接 GND,要么用 MCU 的一个 IO 控制,需要写的时候拉低。
A0、A1、A2 三个地址引脚我全部接地,所以器件 7 位地址是 1010000,即 0x50。如果你要挂多颗,就按二进制组合改这三个脚的电平。
2.2 上拉电阻到底选多大
I2C 是开漏输出,SCL 和 SDA 都必须接上拉电阻,否则总线永远是低电平,通信直接失败。上拉电阻的取值不是随便拍脑袋,它和总线电容、通信速率、电源电压都有关系。
上拉电阻最小值由灌电流决定。I2C 标准规定,标准模式和快速模式下,器件拉低时灌电流最大 3mA,所以 R_min = (VDD - VOL) / 3mA。3.3V 系统,VOL 一般 0.4V,算下来 R_min ≈ 967Ω。也就是说电阻不能小于 1kΩ,否则灌电流超标,可能损坏器件。
上拉电阻最大值由上升时间决定。总线电容 Cb 包括引脚电容、走线电容、器件电容,一般板内短走线按 100pF 估算。标准模式 100kHz,上升时间 tr 最大 1000ns,根据 RC 充电公式,tr ≈ 0.8473 × R × Cb,反推 R_max = tr / (0.8473 × Cb) ≈ 1000ns / (0.8473 × 100pF) ≈ 11.8kΩ。快速模式 400kHz,tr 最大 300ns,R_max ≈ 3.5kΩ。
所以 3.3V、100kHz、短走线的情况下,上拉电阻在 1kΩ 到 10kΩ 之间都合理,我一般选 4.7kΩ,兼顾上升沿和功耗。如果总线挂了很多器件或者走线很长,电容增大,就要减小电阻,比如 2.2kΩ。如果发现波形上升沿太缓,示波器一看是个圆弧,那就是电阻太大了,换小一点。
注意:有些开发板自带了 10kΩ 上拉,你再外接 4.7kΩ,并联后等效 3.2kΩ,一般也没问题,但最好确认一下板子原理图,避免重复上拉导致电阻过小。
2.3 电源去耦和走线建议
AT24C02 的 VCC 和 GND 之间建议加一个 0.1uF 的陶瓷电容,靠近芯片引脚放置,滤高频噪声。虽然 EEPROM 本身功耗很低,但 I2C 总线翻转时会有瞬态电流,去耦电容能保证电源稳定。
走线方面,SCL 和 SDA 尽量平行走,长度尽量短,避免和电机、继电器、DC-DC 开关电源的走线平行。如果实在避不开,中间用地线隔离。我遇到过一块板子,EEPROM 放在 DC-DC 旁边,写数据十次错三次,后来把 EEPROM 挪远并加了屏蔽地,问题消失。所以布局阶段就要考虑,不要等调试时才发现。
3. I2C 时序原理与 STM32 寄存器配置
3.1 用生活场景理解 I2C 时序
I2C 的时序看起来复杂,其实用快递柜取件来类比就很好懂。SCL 是时钟,相当于快递柜的开门节奏;SDA 是数据,相当于你输入取件码。起始条件(START)是快递柜系统被唤醒,停止条件(STOP)是操作结束关门。每个数据位在 SCL 高电平期间必须稳定,SCL 低电平期间才能变化,这就是数据有效性规则。
应答(ACK)是收件方告诉发件方“我收到了”,非应答(NACK)是“我不收了”或者“我读完了”。主机发地址后,从机拉低 SDA 表示 ACK;主机读最后一个字节后,要发 NACK 再发 STOP,告诉从机别再发了。
AT24C02 的写操作流程是:START → 发写地址(0xA0)→ 等 ACK → 发内存地址 → 等 ACK → 发数据 → 等 ACK → STOP。读操作稍微绕一点:START → 发写地址(0xA0)→ 等 ACK → 发内存地址 → 等 ACK → 再发 START(重复起始)→ 发读地址(0xA1)→ 等 ACK → 读数据 → 发 NACK → STOP。这个“写地址+内存地址+重复起始+读地址”的组合,是 EEPROM 读操作的固定套路,很多人第一次写会漏掉重复起始,结果读出来不对。
3.2 STM32F103 的 I2C 外设寄存器
STM32F103 的 I2C 外设寄存器不多,核心就几个:
- CR1:控制寄存器 1,使能 I2C、起始、停止、ACK 使能、时钟频率配置
- CR2:控制寄存器 2,中断使能、DMA 使能、外设时钟频率
- OAR1/OAR2:自身地址寄存器,做从机时才用
- DR:数据寄存器,读写数据都走这里
- SR1:状态寄存器 1,存放 SB、ADDR、BTF、RXNE、TXE、AF、BERR 等标志
- SR2:状态寄存器 2,存放 MSL、BUSY、TRA、GENCALL 等
- CCR:时钟控制寄存器,配置 SCL 频率
- TRISE:上升时间寄存器,配置最大上升时间
配置 100kHz 的步骤大致是:
- 使能 GPIOB 和 I2C1 时钟
- 配置 PB6、PB7 为复用开漏输出
- 复位 I2C1
- 配置 CR2 的 FREQ 字段为 APB1 时钟频率(36MHz)
- 配置 CCR 为 180,得到 100kHz(36MHz / (2 × 180) = 100kHz)
- 配置 TRISE 为 37(36MHz × 1000ns + 1)
- 配置 CR1 的 ACK 位,使能应答
- 配置 CR1 的 PE 位,使能 I2C
这里 FREQ 字段很多人忽略,它告诉 I2C 外设 APB1 的时钟频率,外设内部用它来计算时序。如果填错,SCL 频率就不对。STM32F103 的 APB1 默认是 36MHz,如果你改了时钟树,这里也要跟着改。
CCR 的计算公式是:标准模式下 CCR = F_APB1 / (2 × F_SCL),快速模式下 CCR = F_APB1 / (3 × F_SCL),同时要设置 CCR 的 DUTY 位。100kHz 时,CCR = 36M / (2 × 100k) = 180。TRISE 标准模式下最大上升时间 1000ns,TRISE = 36 × 1000 / 1000 + 1 = 37。
3.3 用 HAL 库还是寄存器
我个人的习惯是:学习阶段用寄存器,把时序搞明白;项目里用 HAL 库,开发效率高。HAL 库把 I2C 的起始、地址、数据、停止都封装成了函数,比如HAL_I2C_Mem_Write和HAL_I2C_Mem_Read,直接填器件地址、内存地址、数据指针、长度就行。
但 HAL 库有个坑,它的超时机制如果设置不当,总线卡死时会一直阻塞。我一般把超时设成 100ms 左右,并且在调用前后检查返回值。另外 HAL 库的 I2C 状态机在总线出错后需要手动复位,HAL_I2C_DeInit再HAL_I2C_Init,或者用__HAL_I2C_DISABLE再__HAL_I2C_ENABLE。
如果你用寄存器写,核心函数就几个:等待 SB 置位、发送地址、等待 ADDR 置位、清 ADDR、发送数据、等待 BTF、发送 STOP。下面给一个单字节写的寄存器版本核心逻辑:
void I2C_WriteByte(uint8_t devAddr, uint8_t memAddr, uint8_t data) { // 等待总线空闲 while(I2C1->SR2 & I2C_SR2_BUSY); // 发送起始 I2C1->CR1 |= I2C_CR1_START; while(!(I2C1->SR1 & I2C_SR1_SB)); // 发送写地址 I2C1->DR = devAddr << 1; while(!(I2C1->SR1 & I2C_SR1_ADDR)); (void)I2C1->SR2; // 清 ADDR // 发送内存地址 while(!(I2C1->SR1 & I2C_SR1_TXE)); I2C1->DR = memAddr; while(!(I2C1->SR1 & I2C_SR1_TXE)); // 发送数据 I2C1->DR = data; while(!(I2C1->SR1 & I2C_SR1_BTF)); // 发送停止 I2C1->CR1 |= I2C_CR1_STOP; }这段代码里,每次写 DR 之前都要等 TXE,每次状态变化都要等对应标志。清 ADDR 标志的方法是先读 SR1 再读 SR2,这是 STM32 的固定套路,漏了 SR2 读取,ADDR 不会清,后面就卡死。
4. 单字节读写与页写实操
4.1 单字节写:最简单也最容易出错
单字节写就是上面那段代码,流程是 START、写地址、内存地址、数据、STOP。写完之后必须等 5ms 左右的写周期,才能进行下一次操作。如果你不等,紧接着发下一个 START,AT24C02 不会应答,因为它在忙内部写。
我一般用两种方式处理写周期:一是死延时 5ms,简单粗暴,适合不赶时间的场景;二是应答轮询(ACK Polling),发写地址后如果收到 ACK 说明写完了,收到 NACK 说明还在忙,就循环重试。应答轮询效率高,但代码稍复杂。下面给一个应答轮询的实现:
void AT24C02_WaitReady(uint8_t devAddr) { uint32_t timeout = 100000; while(timeout--) { I2C1->CR1 |= I2C_CR1_START; while(!(I2C1->SR1 & I2C_SR1_SB)); I2C1->DR = devAddr << 1; // 等待 ADDR 或 AF while(!(I2C1->SR1 & (I2C_SR1_ADDR | I2C_SR1_AF))); if(I2C1->SR1 & I2C_SR1_ADDR) { (void)I2C1->SR2; I2C1->CR1 |= I2C_CR1_STOP; return; // 器件就绪 } else { I2C1->SR1 &= ~I2C_SR1_AF; I2C1->CR1 |= I2C_CR1_STOP; } } }这段代码里,AF 是应答失败标志,收到 NACK 时置位,需要写 0 清除。每次重试都要发 START 和地址,直到收到 ACK。
4.2 单字节读:重复起始是关键
单字节读的流程是:START、写地址、内存地址、重复 START、读地址、读数据、NACK、STOP。注意最后要发 NACK,告诉从机数据够了,然后发 STOP。
uint8_t I2C_ReadByte(uint8_t devAddr, uint8_t memAddr) { uint8_t data; while(I2C1->SR2 & I2C_SR2_BUSY); // 第一次起始,写地址 I2C1->CR1 |= I2C_CR1_START; while(!(I2C1->SR1 & I2C_SR1_SB)); I2C1->DR = devAddr << 1; while(!(I2C1->SR1 & I2C_SR1_ADDR)); (void)I2C1->SR2; // 发内存地址 while(!(I2C1->SR1 & I2C_SR1_TXE)); I2C1->DR = memAddr; while(!(I2C1->SR1 & I2C_SR1_BTF)); // 重复起始 I2C1->CR1 |= I2C_CR1_START; while(!(I2C1->SR1 & I2C_SR1_SB)); I2C1->DR = (devAddr << 1) | 1; while(!(I2C1->SR1 & I2C_SR1_ADDR)); (void)I2C1->SR2; // 准备 NACK 和 STOP I2C1->CR1 &= ~I2C_CR1_ACK; I2C1->CR1 |= I2C_CR1_STOP; // 读数据 while(!(I2C1->SR1 & I2C_SR1_RXNE)); data = I2C1->DR; I2C1->CR1 |= I2C_CR1_ACK; // 恢复 ACK return data; }这里有个细节,NACK 和 STOP 要在读 DR 之前设置,因为一旦读走 DR,RXNE 清了,再设 STOP 可能来不及。我试过先读再设 STOP,偶尔会多读一个字节或者总线状态异常,所以顺序很重要。
4.3 页写:8 字节一页,跨页会回卷
AT24C02 支持页写,一次最多写 8 字节,而且不能跨页。什么叫跨页?比如你从地址 0x06 开始写 4 个字节,写到 0x09,这没问题,因为 0x06 到 0x09 都在第 0 页(0x00 到 0x07)?不对,0x06 到 0x09 跨了第 0 页和第 1 页,因为第 0 页是 0x00 到 0x07,第 1 页是 0x08 到 0x0F。所以从 0x06 写 4 个字节,写到 0x08 时,地址会回卷到第 0 页的 0x00,而不是继续到 0x08。这是 AT24C02 的硬件行为,不是 bug,但如果你不知道,数据就会写错位置。
页写的流程和单字节写类似,只是连续发多个数据,每发一个等 TXE,最后一个等 BTF,然后 STOP。写完之后同样要等 5ms 写周期。
void AT24C02_PageWrite(uint8_t devAddr, uint8_t memAddr, uint8_t *data, uint8_t len) { // len 最大 8,且不能跨页 while(I2C1->SR2 & I2C_SR2_BUSY); I2C1->CR1 |= I2C_CR1_START; while(!(I2C1->SR1 & I2C_SR1_SB)); I2C1->DR = devAddr << 1; while(!(I2C1->SR1 & I2C_SR1_ADDR)); (void)I2C1->SR2; while(!(I2C1->SR1 & I2C_SR1_TXE)); I2C1->DR = memAddr; for(uint8_t i = 0; i < len; i++) { while(!(I2C1->SR1 & I2C_SR1_TXE)); I2C1->DR = data[i]; } while(!(I2C1->SR1 & I2C_SR1_BTF)); I2C1->CR1 |= I2C_CR1_STOP; }实际项目中,如果要写超过 8 字节的数据,就要自己分包,每包不超过页边界。我的做法是先算当前地址到页尾还剩多少字节,取剩余和待写长度的最小值作为本包长度,写完等 5ms,再写下一包。
4.4 连续读:一次读多个字节
连续读和单字节读类似,只是读完一个字节后发 ACK,继续读下一个,最后一个字节发 NACK 再 STOP。AT24C02 的地址会自动递增,读到 0xFF 后回卷到 0x00。
void AT24C02_SequentialRead(uint8_t devAddr, uint8_t memAddr, uint8_t *buf, uint16_t len) { while(I2C1->SR2 & I2C_SR2_BUSY); I2C1->CR1 |= I2C_CR1_START; while(!(I2C1->SR1 & I2C_SR1_SB)); I2C1->DR = devAddr << 1; while(!(I2C1->SR1 & I2C_SR1_ADDR)); (void)I2C1->SR2; while(!(I2C1->SR1 & I2C_SR1_TXE)); I2C1->DR = memAddr; while(!(I2C1->SR1 & I2C_SR1_BTF)); I2C1->CR1 |= I2C_CR1_START; while(!(I2C1->SR1 & I2C_SR1_SB)); I2C1->DR = (devAddr << 1) | 1; while(!(I2C1->SR1 & I2C_SR1_ADDR)); (void)I2C1->SR2; I2C1->CR1 |= I2C_CR1_ACK; for(uint16_t i = 0; i < len; i++) { if(i == len - 1) { I2C1->CR1 &= ~I2C_CR1_ACK; I2C1->CR1 |= I2C_CR1_STOP; } while(!(I2C1->SR1 & I2C_SR1_RXNE)); buf[i] = I2C1->DR; } I2C1->CR1 |= I2C_CR1_ACK; }这段代码里,最后一个字节前要清 ACK 并设 STOP,然后读 DR。读完之后恢复 ACK,方便下次通信。
5. 调试中遇到的典型问题与排查
5.1 读出来全是 0xFF 或 0x00
这是最常见的问题,原因通常有三个:器件地址错、上拉电阻没接、WP 引脚被拉高。
器件地址错的情况最多。AT24C02 的 7 位地址是 0x50,但 HAL 库的HAL_I2C_Mem_Write要求传入左移一位后的地址,也就是 0xA0。如果你传 0x50,HAL 库会把它当成 7 位地址再左移,变成 0xA0,其实也对;但如果你传 0xA0,HAL 库再左移就变成 0x140,超出 8 位,就错了。所以用 HAL 库时,传 0xA0 还是 0x50 要看具体函数实现,我一般直接传 0xA0,因为 HAL 库内部不再左移。
上拉电阻没接,用万用表量 SCL 和 SDA 对 VCC 的电阻,应该是 4.7kΩ 左右。如果无穷大,说明没接上拉,总线永远是低,通信失败。
WP 引脚悬空或接高,写操作被禁止,读出来是旧数据或者 0xFF。用万用表量 WP 对地电压,应该是 0V。
5.2 写进去读出来不对,偶尔错一两个字节
这种偶发错误通常是写周期没等够。AT24C02 的写周期最大 10ms,如果你只延时 1ms 就进行下一次写,器件还在忙,数据没写进去。解决方法是把延时加到 5ms 以上,或者用应答轮询。
另一个原因是电源不稳。如果 MCU 和 EEPROM 共用电源,MCU 跑起来电流波动大,EEPROM 写的时候电压跌落,数据就错了。解决方法是在 EEPROM 的 VCC 和 GND 之间加 0.1uF 和 10uF 电容,靠近芯片放置。
还有一种情况是 I2C 总线被干扰,波形上有毛刺。用示波器看 SCL 和 SDA,如果上升沿有振铃或者下降沿有回沟,说明走线太长或者上拉太小。可以试着减小上拉电阻到 2.2kΩ,或者在总线串联 22Ω 电阻抑制振铃。
5.3 总线卡死,SCL 或 SDA 被拉低
总线卡死是 I2C 最头疼的问题,通常发生在从机正在发数据时主机复位,从机把 SDA 拉低不放。这时候主机再发 START,从机不响应,总线就死了。
解决方法有两种:一是主机复位后,手动发送 9 个 SCL 脉冲,让从机把剩下的数据位发完,然后发 STOP,释放总线。二是用 GPIO 模拟 I2C 的复位时序,先把 SCL 和 SDA 配置成普通 IO,拉低 SCL 几个周期,再拉高,最后发 STOP。
下面给一个软件复位总线的函数:
void I2C_BusRecovery(void) { GPIO_InitTypeDef gpio; // 配置 SCL 和 SDA 为普通开漏输出 gpio.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; gpio.GPIO_Mode = GPIO_Mode_Out_OD; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &gpio); // 发送 9 个时钟脉冲 for(int i = 0; i < 9; i++) { GPIO_ResetBits(GPIOB, GPIO_Pin_6); delay_us(5); GPIO_SetBits(GPIOB, GPIO_Pin_6); delay_us(5); } // 发送 STOP 条件 GPIO_ResetBits(GPIOB, GPIO_Pin_7); delay_us(5); GPIO_SetBits(GPIOB, GPIO_Pin_6); delay_us(5); GPIO_SetBits(GPIOB, GPIO_Pin_7); delay_us(5); // 重新配置为复用开漏 gpio.GPIO_Mode = GPIO_Mode_AF_OD; GPIO_Init(GPIOB, &gpio); // 重新初始化 I2C I2C_DeInit(); I2C_Init(); }这个函数我放在 I2C 初始化之前调用,每次上电先复位总线,能解决大部分卡死问题。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| 读全 0xFF | 地址错、上拉没接、WP 高 | 量上拉电阻、量 WP 电压、确认地址 | 改地址、接上拉、WP 接地 |
| 读全 0x00 | SDA 被拉低、器件没供电 | 量 SDA 电压、量 VCC | 复位总线、检查供电 |
| 偶发错字节 | 写周期不够、电源不稳 | 加大延时、看电源纹波 | 延时 5ms、加去耦电容 |
| 总线卡死 | 从机拉低 SDA | 量 SCL/SDA 电平 | 软件复位总线 |
| 写不进 | WP 高、器件坏 | 量 WP、换器件 | WP 接地、换新芯片 |
| 页写跨页错 | 地址回卷 | 检查起始地址和长度 | 分包写,不跨页 |
6. 项目封装与实战建议
6.1 把驱动封装成独立模块
在实际项目里,我不会把 I2C 读写代码散落在 main 函数里,而是封装成at24c02.c和at24c02.h,对外提供几个接口:
AT24C02_Init():初始化 I2C 和总线复位AT24C02_WriteByte(addr, data):写单字节AT24C02_ReadByte(addr):读单字节AT24C02_WriteBuffer(addr, buf, len):写任意长度,内部自动分包AT24C02_ReadBuffer(addr, buf, len):读任意长度AT24C02_WriteWord(addr, word):写 16 位或 32 位数据,内部拆字节AT24C02_ReadWord(addr):读 16 位或 32 位数据
这样上层应用不用关心 I2C 时序和页边界,直接调接口就行。AT24C02_WriteBuffer内部实现分包逻辑:
void AT24C02_WriteBuffer(uint8_t memAddr, uint8_t *buf, uint16_t len) { uint16_t written = 0; while(written < len) { uint8_t pageRemain = 8 - (memAddr % 8); uint8_t chunk = (len - written) < pageRemain ? (len - written) : pageRemain; AT24C02_PageWrite(0xA0, memAddr, buf + written, chunk); AT24C02_WaitReady(0xA0); memAddr += chunk; written += chunk; } }这段代码先算当前地址到页尾还剩多少字节,取剩余和待写长度的最小值,保证不跨页。写完一包等器件就绪,再写下一包。
6.2 数据校验与备份策略
EEPROM 虽然掉电不丢,但也不是绝对可靠。强干扰、电源异常、器件老化都可能导致数据损坏。我的做法是每个参数存两份,一份原值,一份取反,读的时候对比,如果不一致就用备份恢复。或者加 CRC 校验,存数据时算 CRC 一起写入,读的时候校验。
对于关键参数,我还会在 EEPROM 里存一个版本号和写入次数,方便追溯。版本号用于固件升级后判断参数格式是否兼容,写入次数用于评估器件寿命。AT24C02 的擦写寿命是 100 万次,按每天写 100 次算,能用 27 年,一般项目根本用不完,但如果是高频写场景,就要考虑磨损均衡或者换 FRAM。
6.3 几个我踩过的坑和心得
第一个坑是地址回卷。我第一次写页写的时候,从地址 0x07 写 8 个字节,以为会写到 0x0E,结果写到 0x08 时回卷到 0x00,把前面的数据覆盖了。后来才知道 AT24C02 页写不能跨页,必须分包。
第二个坑是 HAL 库的超时。HAL 库的HAL_I2C_Mem_Write默认超时是 1000ms,如果总线卡死,程序会阻塞 1 秒,实时性很差。我一般改成 100ms,并且在调用后检查返回值,如果是 HAL_TIMEOUT 或 HAL_ERROR,就调用总线复位。
第三个坑是 WP 引脚。有一次焊板子忘了接 WP,悬空状态下内部弱上拉把它拉高,写操作全部失败,读出来是旧数据。查了两天才发现是 WP 的问题。所以 WP 要么接 GND,要么用 IO 控制,不要悬空。
第四个坑是上拉电阻。有一次用了一款开发板,板载 10kΩ 上拉,我又外接 10kΩ,并联后 5kΩ,100kHz 下勉强能用,但 400kHz 时上升沿太缓,通信不稳定。后来把外接电阻去掉,只用板载 10kΩ,反而好了。所以上拉电阻不是越小越好,要匹配速率和电容。
6.4 后续可以扩展的方向
如果你已经把 AT24C02 玩明白了,可以试试这些扩展:
- 换成 AT24C256 或 AT24C512,容量更大,地址是 16 位,需要发两个地址字节
- 用 I2C 总线挂多个 EEPROM,通过 A0、A1、A2 区分地址
- 用 DMA 传输 I2C 数据,减少 CPU 占用
- 用软件模拟 I2C,不依赖硬件外设,方便移植到任意 GPIO
- 加文件系统,把 EEPROM 当成小容量存储,管理多个配置文件
我个人在实际操作中的体会是,I2C 和 EEPROM 这套东西,原理不难,难在细节。地址、上拉、写周期、页边界、总线复位,每一个点都可能让你调半天。但只要把时序图看懂,用示波器抓几次波形,把上面这些坑踩一遍,以后遇到任何 I2C 器件都能快速上手。最后再分享一个小技巧:调试 I2C 时,先写一个扫描总线的函数,把所有从机地址打印出来,确认器件在线,再调读写,能省很多时间。