1. 为什么AT24C02这颗"老古董"至今还在大量项目里出现
如果你手头有一块STM32F103最小系统板,想找个外设练手I2C,十有八九第一个被推荐的就是AT24C02。这颗2Kbit(256字节)的EEPROM芯片从上世纪活到现在,价格便宜到按毛算,SOP-8封装焊起来也不费劲,几乎成了I2C入门的"标准教具"。但很多人照着例程跑通之后,换一个页地址、换一种写入长度,数据就开始乱,读出来的全是0xFF或者错位——问题往往不在芯片,而在对I2C时序和EEPROM内部存储结构的理解上。
这篇内容我打算把STM32F103通过硬件I2C读写AT24C02的完整链路拆开讲。从最小系统的供电细节、I2C总线上拉电阻的取值、AT24C02的器件地址构成,到单字节写、页写、随机读、连续读四种时序的差异,再到HAL库和寄存器两种实现方式的取舍,最后把实际调试中那些"例程不会告诉你"的坑一个个摆出来。适合刚接触STM32外设的初学者,也适合已经能跑通例程但想搞清楚底层逻辑的开发者。
需要先明确一点:AT24C02是2Kbit = 256字节的容量,不是2KB。这个换算关系搞错,后面地址计算全盘皆输。它的页大小是8字节,跨页写入会回卷覆盖,这是新手最容易翻车的地方。下面所有内容都围绕这些硬性约束展开。
2. 硬件层:最小系统、上拉电阻与器件地址的确定
2.1 STM32F103最小系统板的供电与电平匹配
市面上常见的STM32F103C8T6最小系统板,核心电压是3.3V,I2C引脚(通常用PB6/PB7或PB10/PB11)的IO电平也是3.3V。AT24C02的工作电压范围一般是1.8V到5.5V,所以3.3V供电完全没问题,不需要额外的电平转换电路。但如果你用的是5V供电的AT24C02模块,而STM32的IO是3.3V,就要注意:AT24C02的SDA引脚在开漏模式下,只要上拉电阻接到3.3V,总线高电平就是3.3V,芯片能识别为高,这种情况下可以直接连,不需要5V转3.3V电路。真正需要电平转换的场景是总线上同时挂了5V器件和3.3V器件,且上拉到了5V,这时3.3V的IO长期承受5V会有损伤风险。
实际接线就四根:VCC接3.3V,GND共地,SCL接PB6,SDA接PB7。AT24C02的WP引脚(写保护)接地表示允许写入,接VCC则整个芯片只读。A0、A1、A2三个地址引脚决定了器件地址的低三位,全部接地时地址最低位是000。
2.2 上拉电阻取值:4.7K不是随便定的
I2C是开漏总线,SCL和SDA都必须接上拉电阻才能输出高电平。很多模块自带上拉,但如果你是自己焊的芯片,一定要加。取值怎么算?标准模式100kHz下,上拉电阻典型值4.7K;快速模式400kHz下,常用2.2K到4.7K。取值太大会导致上升沿变缓,波形变成"圆角",高速下通信失败;取值太小则灌电流过大,超过器件的驱动能力。
粗略估算:总线电容一般在100pF到200pF量级,上升时间tr ≈ 0.847 × R × C。取R=4.7K、C=150pF,tr约0.6微秒,远小于100kHz下1微秒的要求,所以4.7K在大多数场景下都稳。如果你挂了很多器件或者走线很长,电容增大,就要适当减小电阻。我实测过用10K上拉在400kHz下跑,示波器上看上升沿明显变缓,偶尔出现NACK,换成4.7K后问题消失。
2.3 器件地址的7位构成与读写位
AT24C02的7位器件地址是固定的:高四位是1010,接着三位由A2、A1、A0引脚决定。全部接地时,7位地址是1010000,即0x50。注意这是7位地址,实际发送到总线上时,要左移一位并加上读写位:写操作是10100000即0xA0,读操作是10100001即0xA1。
HAL库的API用的是左移后的8位地址,所以调用时传0xA0。而有些底层驱动库用的是7位地址0x50,内部自己移位。这两种写法混用是初学者常见的错误来源,读不出数据先检查这里。
| 参数 | 取值 | 说明 |
|---|---|---|
| 7位器件地址 | 0x50 | A2A1A0全接地 |
| 写地址(8位) | 0xA0 | 0x50左移1位,最低位0 |
| 读地址(8位) | 0xA1 | 0x50左移1位,最低位1 |
| 页大小 | 8字节 | 跨页写入会回卷 |
| 总容量 | 256字节 | 地址范围0x00到0xFF |
3. I2C时序在AT24C02上的四种操作形态
3.1 单字节写:起始、地址、数据、停止
单字节写的完整时序是:START → 发送写地址0xA0 → 等待ACK → 发送字节地址(0x00到0xFF)→ 等待ACK → 发送数据 → 等待ACK → STOP。之后EEPROM内部会启动一个写周期,典型时间5毫秒,最大不超过10毫秒。在这段时间内,芯片不会响应任何总线请求,也就是"忙"状态。
关键点在于:每次写操作之后必须延时等待写周期完成,否则下一次操作会失败。很多例程用HAL_Delay(5),但严格来说应该用"应答查询"(Acknowledge Polling)来判断:反复发送起始条件和写地址,如果收到ACK说明写周期结束,可以继续操作。这种方式比固定延时更高效,尤其在连续写多个字节时能省下大量时间。
3.2 页写:8字节边界是硬约束
页写允许一次连续写入最多8个字节,但有一个铁律:不能跨越8字节页边界。AT24C02内部地址的低3位在页写时不会进位,写满8字节后会自动回卷到本页起始地址,覆盖之前的数据。
举个例子:从地址0x03开始写8个字节,实际写入位置是0x03、0x04、0x05、0x06、0x07,然后回卷到0x00、0x01、0x02。你以为写到了0x0A,实际上把0x00到0x02的旧数据覆盖了。这个坑我在早期项目里踩过,当时写一段配置数据,长度12字节,从地址0x00开始,结果后4个字节把前4个字节覆盖了,读出来全是错位的数据。
正确的做法是:写入前先计算当前地址到页边界的剩余空间,如果数据长度超过剩余空间,就拆分成多次页写,每次不超过页边界。下面是一个拆分逻辑的伪代码思路:
// 假设addr是起始地址,len是数据长度,page_size=8 while (len > 0) { uint8_t page_offset = addr % 8; // 当前页内偏移 uint8_t space_in_page = 8 - page_offset; // 本页剩余空间 uint8_t write_len = (len < space_in_page) ? len : space_in_page; // 执行一次页写,写入write_len个字节 AT24C02_PageWrite(addr, data, write_len); addr += write_len; data += write_len; len -= write_len; // 等待写周期完成 AT24C02_WaitReady(); }3.3 随机读:先写地址再重启读
读操作比写操作多一个步骤。AT24C02没有"读地址指针"的独立命令,要读某个地址的数据,必须先执行一个"伪写"操作把内部地址指针设置好:START → 发送写地址0xA0 → 等待ACK → 发送目标字节地址 → 等待ACK → 再次发送START(Restart)→ 发送读地址0xA1 → 等待ACK → 读取数据 → 发送NACK → STOP。
注意最后主机要发送NACK而不是ACK,告诉从机"我读完了",然后发STOP。如果发ACK,从机会继续输出下一个地址的数据,导致总线状态混乱。这个细节在HAL库的HAL_I2C_Mem_Read里已经封装好了,但如果你用寄存器或模拟I2C,必须自己控制。
3.4 连续读:地址自动递增与边界回卷
连续读是在随机读的基础上,主机每读完一个字节发ACK,从机地址指针自动加一,继续输出下一个字节。直到主机发NACK加STOP才结束。AT24C02的地址指针到0xFF后会回卷到0x00,所以连续读256字节会读完整颗芯片然后从头开始。
实际使用中,连续读适合读取一整块连续数据,比如一次读出一页8字节。但要注意:如果起始地址加上读取长度超过了0xFF,数据会从0x00重新开始,读出来的内容不是你预期的。所以读取前要确保地址范围合法。
| 操作类型 | 起始条件 | 地址阶段 | 数据阶段 | 结束条件 | 写周期等待 |
|---|---|---|---|---|---|
| 单字节写 | 1次START | 写地址+字节地址 | 1字节 | STOP | 需要 |
| 页写 | 1次START | 写地址+字节地址 | 最多8字节 | STOP | 需要 |
| 随机读 | START+Restart | 写地址+字节地址+读地址 | 1字节 | NACK+STOP | 不需要 |
| 连续读 | START+Restart | 写地址+字节地址+读地址 | N字节 | NACK+STOP | 不需要 |
4. HAL库实现:从CubeMX配置到可复用的读写函数
4.1 CubeMX里I2C参数的取舍
在CubeMX里配置I2C1,模式选I2C,速度模式一般选Standard Mode(100kHz)或Fast Mode(400kHz)。AT24C02支持400kHz,但如果你用的是模拟I2C或者走线质量一般,建议先用100kHz跑通再提速。时钟配置里注意I2C的时钟源来自APB1,STM32F103的APB1最高36MHz,CubeMX会自动计算分频系数。
有一个容易被忽略的选项:Clock No Stretch Mode。默认是关闭的,表示允许从机拉低SCL进行时钟拉伸。AT24C02在写周期内不响应总线,但在读操作时一般不做时钟拉伸,所以这个选项保持默认即可。如果你的总线上有其他会拉伸时钟的器件,才需要关注。
另外,GPIO模式CubeMX会自动配成开漏复用输出,不需要手动改。但如果你用的是模拟I2C,就要把SCL和SDA都配成开漏输出,并且外部加上拉电阻。
4.2 三个核心函数的封装与参数说明
HAL库提供了三个针对存储器类器件的API,直接对应AT24C02的操作:
// 写数据:设备地址0xA0,内存地址MemAddress,数据长度Size,超时1000ms HAL_StatusTypeDef HAL_I2C_Mem_Write(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t *pData, uint16_t Size, uint32_t Timeout); // 读数据:参数含义同上 HAL_StatusTypeDef HAL_I2C_Mem_Read(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t *pData, uint16_t Size, uint32_t Timeout); // 判断器件是否就绪(用于应答查询) HAL_StatusTypeDef HAL_I2C_IsDeviceReady(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint32_t Trials, uint32_t Timeout);MemAddSize对于AT24C02是I2C_MEMADD_SIZE_8BIT,因为它的字节地址是8位。如果你用的是AT24C32或更大容量的EEPROM,地址是16位,就要改成I2C_MEMADD_SIZE_16BIT。这个参数搞错,地址会错位,读出来的数据完全不对。
DevAddress传0xA0,HAL库内部会自动处理读写位的切换。Timeout建议给1000毫秒以上,因为写周期最长10毫秒,加上总线仲裁等开销,给太小容易超时返回HAL_TIMEOUT。
4.3 写周期等待:HAL_Delay与应答查询的实测对比
最简单的写法是每次写完之后HAL_Delay(10),保证写周期一定完成。10毫秒是数据手册给出的最大写周期时间,实际芯片通常在3到5毫秒就完成了。用固定延时的问题是浪费时间,如果你要写256字节,按8字节一页写,需要32次页写,每次延时10毫秒就是320毫秒,对于实时性要求高的场景不可接受。
应答查询的做法是:写完之后立即调用HAL_I2C_IsDeviceReady(&hi2c1, 0xA0, 1, 100),如果返回HAL_OK说明器件就绪,可以继续下一次写。如果返回HAL_BUSY或HAL_ERROR,就短暂延时后重试。实测下来,大多数情况下3到4毫秒就能就绪,比固定10毫秒快了一倍多。
HAL_StatusTypeDef AT24C02_WaitReady(void) { uint32_t retry = 0; while (HAL_I2C_IsDeviceReady(&hi2c1, 0xA0, 1, 100) != HAL_OK) { retry++; if (retry > 100) return HAL_ERROR; // 超时保护 HAL_Delay(1); } return HAL_OK; }注意:
HAL_I2C_IsDeviceReady的Trials参数表示尝试次数,给1就行,外层自己控制重试。Timeout给100毫秒,因为单次探测很快。
4.4 跨页写入的拆分函数完整实现
把前面的拆分逻辑和HAL库API结合起来,就是一个可复用的页写函数:
#define AT24C02_ADDR 0xA0 #define AT24C02_PAGE 8 HAL_StatusTypeDef AT24C02_Write(uint16_t addr, uint8_t *data, uint16_t len) { while (len > 0) { uint8_t page_offset = addr % AT24C02_PAGE; uint8_t space = AT24C02_PAGE - page_offset; uint8_t chunk = (len < space) ? len : space; if (HAL_I2C_Mem_Write(&hi2c1, AT24C02_ADDR, addr, I2C_MEMADD_SIZE_8BIT, data, chunk, 1000) != HAL_OK) { return HAL_ERROR; } if (AT24C02_WaitReady() != HAL_OK) { return HAL_ERROR; } addr += chunk; data += chunk; len -= chunk; } return HAL_OK; }读函数就简单得多,不需要拆分,因为读操作没有页限制,一次可以读完256字节:
HAL_StatusTypeDef AT24C02_Read(uint16_t addr, uint8_t *buf, uint16_t len) { return HAL_I2C_Mem_Read(&hi2c1, AT24C02_ADDR, addr, I2C_MEMADD_SIZE_8BIT, buf, len, 1000); }5. 调试现场:那些例程不会告诉你的翻车点
5.1 读出来全是0xFF的三种可能
0xFF是EEPROM擦除后的默认值,读出来全是0xFF说明数据根本没写进去。排查顺序如下:
第一,检查写保护引脚WP。如果WP接了VCC,芯片处于只读状态,写操作会被静默忽略,不返回错误。很多人焊板子时把WP悬空,悬空状态下电平不确定,可能被内部拉高导致写保护。正确做法是WP直接接地。
第二,检查器件地址。用逻辑分析仪抓波形,看发送的地址是不是0xA0。如果A0、A1、A2有引脚接了VCC,地址就不是0x50了。比如A0接VCC,地址变成0x51,写地址是0xA2。
第三,检查写周期等待。如果写完立即读,芯片还在忙,读操作会失败。用应答查询确认器件就绪后再读。
5.2 数据错位:页回卷的典型症状
数据错位的表现是:写入一段数据后读出来,前面几个字节对,后面几个字节是旧数据或者顺序乱了。这几乎可以确定是跨页写入导致的回卷。比如从地址0x06开始写4个字节,实际只写入了0x06、0x07,然后回卷到0x00、0x01,0x08和0x09根本没被写入。
解决办法就是前面说的拆分逻辑。每次写入前计算页内剩余空间,确保单次写入不跨页。这个逻辑不复杂,但如果不写,调试时会很痛苦,因为数据错位不是每次都出现,取决于起始地址和长度。
5.3 总线锁死与HAL_BUSY的恢复
有时候程序跑着跑着,I2C操作一直返回HAL_BUSY,总线像是卡死了。原因通常是上一次通信中途被打断(比如中断优先级问题),从机还在等待时钟,而主机已经复位了状态机。SDA被从机拉低,主机无法发出START条件。
恢复方法是:把SCL配置成普通GPIO,手动发送9个时钟脉冲,让从机把剩余的数据位发完,释放SDA。然后再重新初始化I2C外设。HAL库没有直接提供这个函数,需要自己写:
void I2C_BusRecover(void) { GPIO_InitTypeDef gpio = {0}; // 切换SCL和SDA为普通开漏输出 gpio.Pin = GPIO_PIN_6 | GPIO_PIN_7; gpio.Mode = GPIO_MODE_OUTPUT_OD; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, &gpio); // 发送9个时钟脉冲 for (int i = 0; i < 9; i++) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET); HAL_Delay(1); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET); HAL_Delay(1); } // 发送STOP条件:SDA低->SCL高->SDA高 HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_RESET); HAL_Delay(1); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET); HAL_Delay(1); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_SET); HAL_Delay(1); // 重新初始化I2C外设 MX_I2C1_Init(); }这个恢复函数我放在错误处理里,每次I2C操作返回HAL_BUSY就调用一次,实测能解决大部分总线锁死问题。
5.4 用逻辑分析仪抓波形的正确姿势
调试I2C最有效的工具是逻辑分析仪,几十块钱的8通道USB分析仪就够用。接线时把通道0接SCL,通道1接SDA,采样率至少1MHz,最好4MHz以上。解码协议选I2C,它会自动解析出START、地址、ACK/NACK、数据和STOP。
看波形时重点关注三个地方:地址字节是否匹配、每个字节后的ACK位是否被从机拉低、STOP条件是否完整。如果地址后没有ACK,说明从机没响应,检查器件地址和供电。如果数据字节后没有ACK,可能是从机忙或者写保护。如果STOP条件不完整(SDA在SCL高电平时没有从低变高),可能是时序参数不满足。
我习惯在每次写操作后抓一次波形,确认写周期等待是否生效。如果看到STOP之后总线上还有活动,说明从机还在忙,下一次操作就会失败。
6. 寄存器级实现与模拟I2C的取舍
6.1 什么时候该放弃HAL库
HAL库的HAL_I2C_Mem_Write用起来方便,但有几个场景不适合:一是中断优先级复杂时,HAL库的阻塞式操作会占用CPU;二是需要精确控制每个时钟边沿的特殊时序;三是芯片的I2C外设有硬件bug(STM32F103早期的I2C外设确实有一些已知问题,比如在特定条件下会卡在BUSY状态)。
如果你遇到HAL库反复返回HAL_BUSY且恢复函数也救不回来,可以考虑用模拟I2C。模拟I2C用两个普通GPIO,通过软件控制SCL和SDA的高低电平,完全掌控时序。缺点是速度慢,100kHz下CPU占用率高,但胜在稳定可控。
6.2 模拟I2C的微秒级延时怎么定
模拟I2C的核心是延时函数。100kHz下,一个时钟周期10微秒,高电平和低电平各5微秒。用HAL_Delay不行,最小单位是毫秒。要用__NOP()或者DWT计数器做微秒级延时。
简单做法是用SysTick或者一个定时器做微秒延时:
void delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - start) < ticks); }DWT是Cortex-M3内核自带的调试计数器,需要先使能。这种方式精度高,不受中断影响。如果没有DWT,用定时器也可以,但配置麻烦一些。
模拟I2C的起始条件:SCL高时SDA从高变低。停止条件:SCL高时SDA从低变高。发送一个字节:先发最高位,每发一位拉低SCL,设置SDA,再拉高SCL,从机在SCL高时采样。接收ACK:主机释放SDA,拉高SCL,读SDA电平,低表示ACK。
6.3 硬件I2C与模拟I2C的实测对比
我在同一块板子上分别用硬件I2C和模拟I2C读写256字节,做了个简单对比:
| 对比项 | 硬件I2C(100kHz) | 模拟I2C(100kHz) |
|---|---|---|
| 写256字节耗时 | 约45ms | 约52ms |
| 读256字节耗时 | 约28ms | 约30ms |
| CPU占用 | 低(DMA可进一步降低) | 高(全程忙等) |
| 稳定性 | 偶发BUSY锁死 | 稳定,时序完全可控 |
| 代码复杂度 | 低(HAL封装) | 中(需自己实现时序) |
写操作耗时主要花在写周期等待上,两者差别不大。读操作硬件I2C略快。稳定性方面,模拟I2C因为完全由软件控制,不会出现硬件状态机卡死的问题,但前提是延时准确。如果你的项目对稳定性要求极高,且I2C速率要求不高,模拟I2C是更稳妥的选择。
7. 把AT24C02用在实际项目里的几个经验
7.1 存储结构规划:不要从0x00开始随便写
AT24C02只有256字节,如果项目里要存多种数据(比如配置参数、校准系数、运行日志),最好提前规划地址空间。我的习惯是:0x00到0x0F存设备信息(序列号、版本号),0x10到0x7F存配置参数,0x80到0xFF存运行数据。每块区域之间留几个字节的间隔,方便以后扩展。
另外,重要数据不要只存一份。比如校准系数,可以在两个不同地址各存一份,读取时对比,不一致就报错。AT24C02的写次数寿命是100万次,读次数无限,所以频繁写入的数据要考虑磨损均衡,不要每次都写同一个地址。
7.2 数据校验:CRC还是简单校验和
EEPROM的数据可能因为电源波动、总线干扰等原因出错。读取时加校验能及时发现。最简单的校验和(所有字节相加取低8位)占用1字节,能发现大部分单字节错误。CRC8更可靠,但计算稍复杂。对于配置参数这种不常变的数据,CRC8值得用;对于运行日志,校验和就够了。
存储格式建议:数据 + 校验字节。读取时先算校验,对比存储的校验值,不一致就使用默认值或者报错。这样即使EEPROM里的数据坏了,系统也能正常运行。
7.3 初始化时的默认值写入策略
第一次上电时EEPROM是空的(全0xFF),需要写入默认值。判断依据可以是:读取某个标志字节,如果是0xFF说明未初始化,写入默认值;否则跳过。但要注意,如果标志字节本身被写坏了,可能导致重复初始化。更稳妥的做法是在两个地址存互为反码的标志,比如0x00存0x5A,0x01存0xA5,只有两个都匹配才认为已初始化。
#define INIT_FLAG_ADDR 0x00 #define INIT_FLAG_VAL 0x5A #define INIT_FLAG_INV 0xA5 void AT24C02_InitDefaults(void) { uint8_t flag1, flag2; AT24C02_Read(INIT_FLAG_ADDR, &flag1, 1); AT24C02_Read(INIT_FLAG_ADDR + 1, &flag2, 1); if (flag1 != INIT_FLAG_VAL || flag2 != INIT_FLAG_INV) { // 写入默认参数 AT24C02_Write(0x10, default_params, sizeof(default_params)); // 写入初始化标志 uint8_t flags[2] = {INIT_FLAG_VAL, INIT_FLAG_INV}; AT24C02_Write(INIT_FLAG_ADDR, flags, 2); } }7.4 写操作对系统实时性的影响与规避
前面提到,每次写操作后要等待5到10毫秒的写周期。如果在一个实时控制循环里直接调用写函数,这10毫秒的阻塞可能导致控制周期抖动。规避方法有两种:一是把EEPROM写操作放到低优先级任务或者主循环的空闲时段;二是用状态机方式,写完之后不等待,记录一个时间戳,下次循环再检查是否就绪。
对于STM32F103这种没有RTOS的裸机项目,我通常在主循环里维护一个简单的任务调度,EEPROM写操作标记为"待完成",每次循环检查一次应答,就绪后再执行下一步。这样不会阻塞其他任务。
7.5 从AT24C02迁移到更大容量EEPROM的注意事项
项目做大了,256字节不够用,要换成AT24C32(4KB)或AT24C256(32KB)。迁移时注意三点:第一,器件地址变了,AT24C32的地址是0x50到0x57,但页大小还是8字节(AT24C32)或32字节(AT24C256),跨页逻辑要改;第二,字节地址从8位变成16位,MemAddSize要改成I2C_MEMADD_SIZE_16BIT;第三,写周期时间可能不同,AT24C256的最大写周期是5毫秒,比AT24C02的10毫秒还快,但页写字节数多了,单次写入时间更长。
迁移时最稳妥的做法是把读写函数抽象成统一的接口,底层根据芯片型号选择不同的参数。这样换芯片时只需要改配置,上层业务代码不动。
8. 几个我踩过的具体坑和最终解法
第一个坑是WP引脚悬空。早期焊板子时觉得WP不重要,悬空处理,结果写数据时好时坏。后来用万用表量WP引脚电压,发现悬空时在1.5V左右浮动,有时被内部电路拉高导致写保护。直接接地后问题消失。这个坑的教训是:所有配置引脚都要有明确电平,不能悬空。
第二个坑是连续写多个字节时没有拆分页。当时写一个12字节的结构体,从地址0x00开始,以为AT24C02会自动处理跨页,结果后4个字节回卷覆盖了前4个字节。读出来数据错位,排查了半天才想到页边界问题。后来写了拆分函数,再也没出过这个问题。
第三个坑是I2C总线锁死。有一次在中断里调用了EEPROM写函数,结果中断嵌套导致I2C状态机混乱,总线卡死。后来把EEPROM操作全部移到主循环,中断里只置标志位,问题解决。这个坑的教训是:阻塞式I2C操作不要放在中断里,尤其是高优先级中断。
第四个坑是上拉电阻太大。有一次手头没有4.7K电阻,用了10K,100kHz下勉强能跑,但偶尔出现NACK。换成4.7K后稳定。后来用示波器看波形,10K上拉时上升沿明显变缓,在SCL高电平期间SDA还没稳定,导致采样错误。
第五个坑是器件地址搞错。有一次用了A0接VCC的模块,但代码里地址还是0xA0,结果一直读不到数据。后来用逻辑分析仪抓波形,发现发送的地址是0xA0,但从机实际地址是0xA2,没有ACK。改成0xA2后正常。这个坑的教训是:拿到模块先确认A0、A1、A2的接法,不要想当然。
这些坑说到底都是细节问题,但每一个都能让调试卡住几个小时。AT24C02本身不复杂,复杂的是把I2C时序、EEPROM内部结构、HAL库封装这三层关系理清楚。理清之后,读写EEPROM就是一件很确定的事情,不会再出现"时好时坏"的玄学问题。