STM32F103通过I2C读写AT24C02:时序详解与实战避坑指南
动手玩STM32F103的朋友,迟早会撞上I2C。作为一个刚把AT24C02读写调通的过来人,想把这几天踩的坑和验证过的代码整理成文。这一篇不聊虚的,就说怎么用STM32F103通过I2C总线把AT24C02的256字节存储空间读写顺溜,以及那些数据手册和参考手册里不会明说的细节。不管你是刚拿到开发板的新手,还是主用HAL库但想回头看看寄存器到底干了啥的工程师,这篇文章应该都能让你少走几圈弯路。AT24C02这种芯片容量不大,但它在I2C时序教学上的价值堪比教科书,把它的读写流程吃透,后续再去点OLED、读温度传感器、挂姿态传感器,基本都是同一套路子。
1. 先把I2C和AT24C02这事儿的底摸清
1.1 为什么STM32F103配AT24C02是经典组合
STM32F103系列大概是国内嵌入式入门最普遍的主控之一,Cortex-M3内核、72MHz主频、丰富的串口和定时器资源,加上生态成熟的中文资料,新手用起来非常顺手。而AT24C02是一颗极其经典的串行EEPROM芯片,容量256字节,走I2C接口,只需要SDA和SCL两根线,接线简单到可以忽略不计。这两者搭配在一起,刚好把I2C协议里最核心的几类操作——起始条件、停止条件、单字节写、页写、随机读、顺序读——全部串了起来,成为嵌入式学习链路上非常关键的一环。
说实话,AT24C02的256字节容量放到今天不少项目里确实不大,但它真正的价值是:你在它身上搞明白的I2C时序,拿到任何I2C器件上都通用。后面去接OLED屏幕、驱动温湿度传感器、读取陀螺仪数据,底层都是同一个总线读写逻辑。所以我一直建议新人不要跳过EEPROM这个环节,直接用OLED例程去学I2C,那样容易只知其然不知其所以然。用AT24C02入门,成本低、逻辑清晰、出错也好排查,是性价比极高的一课。
1.2 I2C协议核心要点回顾
I2C全称Inter-Integrated Circuit,是飞利浦(现在的恩智浦)搞出来的一种半双工同步串行总线。它只有两根线:SDA负责传输数据,SCL负责提供时钟。这个总线最大的特点是多主机、多从机,每个从机有独立的7位地址(可扩展到10位地址),理论上一条总线上最多能挂128个设备。
几个必须刻进脑子里的时序概念:
- 起始条件:SCL保持高电平时,SDA发生一次高到低的跳变,表示总线开始传输。
- 停止条件:SCL保持高电平时,SDA发生一次低到高的跳变,表示传输结束。
- 数据有效性:SCL高电平期间,SDA上的数据必须保持稳定;SCL低电平期间,SDA才允许变化。这个规则决定了你写代码时改变SDA电平的时机。
- 应答ACK:每发送8位数据后,接收方需要在第9个时钟周期把SDA拉低,表示收到了。如果是接收方不想再收了,就拉高SDA(NACK),发送方看到NACK就知道该停了。
AT24C02的设备地址是7位,格式是1010 A2 A1 A0。当A0、A1、A2引脚都接地时,设备地址就是0x50(加上读/写位后,写地址是0xA0,读地址是0xA1)。这就是为什么你看到别人代码里I2C地址有的写0xA0有的写0x50,其实说的是同一颗芯片,前者是8位完整地址,后者是7位原始地址。很多新手在这一步就懵了,稍不注意就会把地址搞错。
2. 硬件层面的几个关键设计点
2.1 上拉电阻选型与计算
I2C总线是开漏输出结构,SDA和SCL本身不能主动输出高电平,必须依靠外部上拉电阻把电平拉高。这个上拉电阻的取值很有讲究,不是随手焊一个10k就完事。
电阻太大,总线上升沿会变缓,在高速通信时容易产生误采样。电阻太小,总线下拉驱动能力不够,端口可能无法把电平拉到逻辑低。AT24C02本身支持400kHz的快速模式,在400kHz下,上升沿时间通常要求不超过300ns。对于常见的I2C总线电容约50pF到100pF的情况:
- 4.7kΩ是比较通用、稳妥的起步值,适合大多数100kHz场景。
- 2.2kΩ到3.3kΩ在400kHz快速模式下更可靠,尤其是多个从机并联时总线电容偏大的情况。
- 10kΩ在低速、短走线、单从机的场合也能工作,但不建议在400kHz下使用。
我实测下来,STM32F103的I2C1跑400kHz,配3.3kΩ上拉电阻,加上一颗AT24C02和一段约10cm的杜邦线,波形稳定,没有出现上升沿过缓的问题。如果你是飞线搭的电路,走线长、环境干扰又大,建议直接上2.2kΩ,别犹豫。
2.2 开漏模式与推挽模式的取舍
STM32的GPIO配置成复用功能时,I2C的SCL和SDA引脚必须选择开漏输出模式,不是推挽输出。这一点我必须反复强调,因为论坛里隔三差五就有人问"为什么我的I2C波形不对"。
推挽模式下,引脚既能主动输出高电平也能主动输出低电平,这看起来似乎更"有力",但在I2C总线上是禁忌。想一想:如果总线上挂了多个设备,一个设备尝试拉低SDA,而另一个设备(比如你的MCU)在推挽模式下主动输出高电平,两者会直接短路打架,轻则电平错乱,重则损坏器件。
开漏模式则是"只能拉低,不能主动拉高",高电平完全交给外部上拉电阻。这正好匹配I2C的线与逻辑:任何一个设备拉低总线,总线就是低电平;所有设备都释放总线,总线才被上拉为高电平。这是I2C协议能支持多设备共存的基础,不允许投机取巧。
在STM32F103标准库里,配置代码就两行:
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);注意这里的复用,不是普通的开漏输出,是复用开漏。PB6和PB7默认就能复用为I2C1的SCL和SDA,不需要自己用普通GPIO去模拟翻转。
2.3 5V和3.3V器件混接时的电平匹配
AT24C02属于宽压供电芯片,工作电压范围是1.8V到5.5V,所以这个场景在实际项目中非常常见:STM32F103用的是3.3V供电,而AT24C02可能直接挂在5V的电源轨上,甚至有些模块板上直接带了电平转换电路。
这里的关键是:I2C总线的电平是以VDD为参考的。如果你的MCU是3.3V,EEPROM是5V,直接相连的时候,EEPROM的SCL/SDA高电平就是5V,超出了STM32引脚的耐压范围(STM32F103 GPIO绝对最大额定值是VDD+0.3V,也就是3.6V左右)。长期这么干,引脚容易出问题。
解决的方案通常有两种:
- 统一供电:把AT24C02模块上的跳线帽选择成3.3V供电,让两边的逻辑电平都是3.3V。这是最简单也最推荐的做法,大多数市场上的AT24C02模块板都支持电压选择。
- 电平转换电路:用MOSFET搭建双向电平转换电路,或者用现成的电平转换芯片。I2C是开漏结构,非常适合用MOSFET做电平转换,原理也不复杂。
检查一下你的模块:如果上面有3.3V和5V的跳线选择,直接选3.3V,STM32这边就很省心。如果没有跳线,又是5V供电的,那就老老实实加个电平转换,不要图省事直接接。
3. 软件实现:从初始化到读写全流程
3.1 用标准库配置GPIO和I2C外设
我用的是STM32标准外设库,虽然现在HAL库才是主流,但标准库的代码逻辑更贴近寄存器操作,适合用来理解I2C外设的工作机制。HAL库最大的问题是封装层级多,出问题之后你很难定位到底卡在哪个标志位上,所以我个人建议新手先用标准库或寄存器版本把寄存器流程走通,再回头看HAL。
初始化代码主要分两步。第一步是GPIO,把PB6和PB7配置为复用开漏输出;第二步是I2C外设。
void I2C1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; I2C_InitTypeDef I2C_InitStructure; // 使能GPIOB和I2C1的时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); // PB6 -> SCL, PB7 -> SDA 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); // I2C1配置 I2C_InitStructure.I2C_Mode = I2C_Mode_I2C; I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2; // 快速模式下时钟占空比16:9 I2C_InitStructure.I2C_OwnAddr2 = 0; I2C_InitStructure.I2C_Ack = I2C_Ack_Enable; // 应答使能 I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit; I2C_InitStructure.I2C_ClockSpeed = 400000; // 400kHz快速模式 I2C_Init(I2C1, &I2C_InitStructure); I2C_Cmd(I2C1, ENABLE); }关于时钟配置有一个容易忽略的点:I2C1挂载在APB1总线上,默认APB1时钟是36MHz(72MHz主频二分频)。I2C_ClockSpeed虽然设定的值是400000,但外设内部会根据APB1时钟去计算CCR寄存器的值。如果你改变了APB1的分频系数,这里的实际总线速率也会跟着变,调试时如果发现波形频率不对,要先检查APB1时钟。
3.2 写操作:单字节写入与页写入的实现细节
AT24C02的写入分两种:单字节写和页写。单字节写就是给一个地址、给一个数据,写进去完事。页写是连续往同一页内写多个字节,AT24C02的页大小是8字节。
单字节写流程:
void AT24C02_WriteByte(uint8_t addr, uint8_t data) { // 1. 起始信号 I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); // 2. 发送设备地址(写方向) I2C_Send7bitAddress(I2C1, AT24C02_ADDR, I2C_Direction_Transmitter); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); // 3. 发送存储地址(寄存器地址) I2C_SendData(I2C1, addr); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTING)); // 4. 发送数据 I2C_SendData(I2C1, data); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); // 5. 停止信号 I2C_GenerateSTOP(I2C1, ENABLE); // 6. 等待内部写周期完成 delay_ms(5); }步骤6的延时,对应的是AT24C02数据手册里那个写入周期时间tWR,典型值是5ms。也就是说,芯片在收到数据和停止条件之后,还需要大约5ms的时间把数据真正烧录到存储阵列里。在这个时间内,芯片不会响应任何外部命令,如果你紧接着去读,读回来的往往是0xFF或者上一次的内容。
页写和单字节写的流程几乎一样,区别在于发送数据那段是从缓冲区循环往外发:
void AT24C02_PageWrite(uint8_t addr, uint8_t *buf, uint8_t len) { I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, AT24C02_ADDR, I2C_Direction_Transmitter); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); I2C_SendData(I2C1, addr); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTING)); for(uint8_t i = 0; i < len; i++) { I2C_SendData(I2C1, buf[i]); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); } I2C_GenerateSTOP(I2C1, ENABLE); delay_ms(5); }这里有个大坑:页写不能跨页。AT24C02的每个页是8字节,从一个页的第6字节开始写4个字节,第7、8字节落在本页内没问题,第9、10字节就会回绕到这一页的起始地址,而不是下一页的开头。也就是说地址0x06开始写4个字节,实际写入的是0x06、0x07、0x00、0x01,后面的数据会把页头的数据覆盖掉。这个问题在写参数数组时非常容易出,解决方法是写之前判断剩余页空间,超过页边界就手动拆分写入。
3.3 读操作:随机读与顺序读的实现细节
读EEPROM比写要复杂一点点,因为需要先在写方向发送目标地址,然后重启总线切换为读方向,读取数据。这个过程叫"随机读",本质上是先假装写、再从指定地址读。
uint8_t AT24C02_ReadByte(uint8_t addr) { uint8_t data = 0; // 1. 起始信号 I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); // 2. 发送设备地址(写方向),先定位地址 I2C_Send7bitAddress(I2C1, AT24C02_ADDR, I2C_Direction_Transmitter); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); // 3. 发送存储地址 I2C_SendData(I2C1, addr); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); // 4. 重启总线(不是停止再启动,是重复起始) I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); // 5. 发送设备地址(读方向) I2C_Send7bitAddress(I2C1, AT24C02_ADDR, I2C_Direction_Receiver); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED)); // 6. 读取一个字节(读完最后一个字节前需要关闭ACK) while(I2C_GetFlagStatus(I2C1, I2C_FLAG_RXNE) == RESET); data = I2C_ReceiveData(I2C1); // 7. 停止信号 I2C_GenerateSTOP(I2C1, ENABLE); return data; }步骤6里的逻辑要展开讲一下。I2C外设在使能应答(I2C_Ack_Enable)的情况下,会在接收到每个字节后自动发送ACK。如果你要读N个字节,在接收第N-1个字节之后,就应该关闭ACK,让芯片知道"这是最后一个了,别发了",然后再接收最后一个字节,最后发停止信号。
但上面的代码读单个字节时,有一个细节:标准库的I2C_ReceiveData不带自动管理ACK的功能,你需要自己控制。对读单字节来说,可以在读之前暂时禁用ACK,或者更简单的方式是从RXNE标志置位时直接取值,然后用停止信号结束通信。实际操作中,最稳妥的做法是在读完倒数第二个字节后关闭ACK,最后一个字节读到后停止。这也是很多新手在连续读数据时会多出一个字节的根因。
顺序读(也称连续读)则是只要从设备响应了读地址,就会从当前地址开始自增地输出后续的数据,主控每次收到一个字节就回应一个ACK,直到想停止时回NACK,再发停止条件。它特别适合读取EEPROM块数据,效率比逐字节随机读高得多。
3.4 ACK轮询:替代固定延时的更优雅方案
前面说写一次要等5ms,马上想到一个优化:既然芯片在写周期内不发ACK,那我完全可以利用这个特性去做ACK轮询,代替无脑延时。具体做法是发起一次空操作(只发设备地址不操作数据),检测芯片是否返回ACK:返回了,说明内部写周期结束了;没返回,就继续等。
这个技巧在批量写数据时能明显提升效率,尤其是在写入几百字节数据的时候,固定延时10次就是50ms,而ACK轮询通常会更快。而且它比延时更安全,因为延时时间可能因为芯片批次、温度而波动,但ACK是芯片自己给的应答信号,是实时状态。
标准库下做ACK轮询稍微有点别扭,因为EV5、EV6事件状态位在总线出错时需要谨慎处理。我一般写成这样:
void AT24C02_WaitIdle(void) { // 循环发START + 设备地址(写方向),直到收到ACK while(1) { I2C_GenerateSTART(I2C1, ENABLE); // 等待EV5 while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, AT24C02_ADDR, I2C_Direction_Transmitter); // 判断ADDR标志位是否置位,置位说明收到ACK // 如果NACK,芯片会释放总线,需要先处理 if(I2C_GetFlagStatus(I2C1, I2C_FLAG_ADDR)) { // 清标志位,发停止条件,跳出循环 I2C_GenerateSTOP(I2C1, ENABLE); break; } else { I2C_GenerateSTOP(I2C1, ENABLE); } } }这种方式需要小心处理事件标志,否则容易卡死。我的建议是:先做固定延时版本,确认整个读写流程没问题之后,再优化成ACK轮询,否则排错时变量太多,你会很痛苦。
4. 踩坑实录与常见问题排查
4.1 写进去又读出来是0xFF,问题出在哪
这大概是AT24C02最常见的灵异事件之一。排查思路其实很清晰,按顺序来:
- 地址是不是不对:确认AT24C02的A0、A1、A2引脚电平,如果你的模块上这三个跳线默认接高电平,设备地址就不是0x50。用示波器或逻辑分析仪看看挂在SDA上的地址字节是不是1010A2A1A0。
- 写周期没等够:写完立刻读,芯片还没完成内部写入,读出来的是旧数据或0xFF。在读写之间加5ms以上延时再试。
- WP引脚被拉高了:AT24C02的WP写保护引脚如果接高电平,芯片会拒绝写入。很多模块上WP默认通过电阻上拉,你需要在软件或硬件上把它拉低。这部分是特别容易被忽略的,因为模块正常工作的时候你不会想到是写保护的问题。
- 页面回绕:页写的时候如果跨越页边界,数据会写进错误的地址,读的时候以为自己写了,其实数据被覆盖回滚了。
书本里常说把WP接地,但模块上的WP往往是一根排针,不接默认就是悬空,可能被内部上拉或者感应到高电平,导致写保护开启。所以我测试时习惯性用杜邦线把WP针脚接到GND上,一了百了。
4.2 硬件I2C卡死在BSY标志上
STM32F103的硬件I2C有个著名毛病:总线忙(BUSY)标志位卡死。上电后I2C外设检测到SDA或SCL为低电平,就会认为总线忙,拒绝发出START信号,代码就卡在等待EV5的死循环里。这个问题在热插拔器件、外部干扰、或者前一次通信没正常结束时尤其容易触发。
常见的解决办法有:
- 复位I2C外设:把I2C外设关闭再重新打开,大概率能清掉BSY。
- GPIO电平复位:手动把SCL引脚配置为普通推挽输出,翻转9个时钟脉冲,让从机释放总线,然后再把引脚恢复为复用开漏,重新初始化I2C。
- 检查外部上拉:如果SDA或SCL被其他设备异常拉低,总线会一直忙。这个时候先断开其他设备,单独接EEPROM测试。
从这里也解释了一个现象:为什么那么多人最终选择了软件模拟I2C。软件模拟I2C没有BSY标志这个概念,只要GPIO操作不失误,总线状态完全可控。代码虽然多了几十行,但这种可控性对于调试和后期维护来说非常宝贵。
4.3 读取时多出数据或要NACK没收好
还有一个常见的时序错误,就是在读最后一个字节之前没有关闭ACK。很多人写多字节读取时,在循环里每收一个字节就自动回复ACK,结果最后一个字节发完之后,因为主控还在ACK状态,EEPROM会继续输出下一个字节,导致多读了一个数据,或者后续总线状态异常。
标准库的正确做法是读第N-1个字节前调用I2C_AcknowledgeConfig(I2C1, DISABLE),让最后一个字节的ACK变成NACK,EEPROM收到NACK就知道停止输出,然后主控发停止条件。对于那些调用了HAL_I2C_Master_Receive的HAL库用户,这个细节被封装掉了,但用软件模拟或者标准库时务必重视。
4.4 上电时序与模块供电问题
有些朋友硬件没接错、代码也写得对,但就是读写不稳定,偶尔成功偶尔失败。这时候要考虑供电和上电时序。AT24C02上电后需要一个短暂的时间让内部电压稳定,如果STM32复位后立刻访问EEPROM,可能撞上芯片内部的启动延迟。很多例程没有处理这个,实际使用中可以在I2C初始化前加一个100ms左右的上电延时,别小看这100ms,它能解决很多"写十次有一次莫名失败"的疑难杂症。
5. 软件模拟I2C的备选方案
5.1 为什么不少人宁可放弃硬件外设
前面提过硬件I2C的BSY问题,这是很多人转投软件模拟的核心原因。但还有一个原因,是调试上的便利性。硬件I2C的状态机是自动运转的,中途出现问题你很难打断它看状态;软件模拟I2C则完全不同,每一根电平的跳变都是代码里手动执行的,你可以断点停下来观察总线状态,逻辑异常时一眼就能看出来。
软件模拟I2C还有另一个隐性优势:引脚的灵活性。硬件I2C的引脚被固定在复用功能上,而软件模拟可以用任意两个GPIO。这在PCB布局紧张、引脚资源有限的项目里非常实用。哪怕是I2C2和I2C3硬件外设没引出来的老片子,也能用纯GPIO跑起I2C。
软件模拟的关键是严格保证时序:起始条件要等延时,每个数据位要等半个时钟周期,SCL拉高后要保证SDA足够稳定后才采样。对于AT24C02这种最大400kHz的设备,用72MHz主频的STM32F103做软件模拟几乎不费吹灰之力,GPIO翻转频率轻松超出要求。
5.2 模拟代码的生成要点
一个基础软件模拟I2C需要实现的原语有五类:起始信号、停止信号、发送一个字节、接收一个字节、发送应答/非应答。基于这些原语再封装读写操作。
关键点是延时时间的设定。通常采用SCL高电平的一半作为基础延时,400kHz下半个周期约1.25us,72MHz的STM32做几个空循环或者调用delay即可。需要注意的是,写EEPROM时的5ms内部写周期,即便软件模拟同样不可省略,这与总线方式无关。
如果只是想验证硬件是否正常,从GPIO直接翻转做一次"伪I2C"通信,比上来就调硬件外设更容易定位问题。我拿到一块新的AT24C02模块,最先干的事往往不是初始化复杂外设,而是写一小段GPIO软件模拟代码,把整条读写链路先摸通,再决定用硬件外设还是继续用模拟方式。
5.3 虚拟示波器和逻辑分析仪排查
调试I2C的波形问题,逻辑分析仪几乎是必备品。几十块钱、采样率24MHz的那种小工具就足够捕捉400kHz的I2C信号了。很多逻辑分析仪软件自带I2C协议解码功能,直接就能解出SDA线上跑的地址和数据类型,省去手动数脉冲的麻烦。
我第一次调I2C的时候,因为没有逻辑分析仪,只能靠串口打印猜测问题,花了一整天才定位到是地址写法错误。后来买了逻辑分析仪,五分钟就看到波形SDA上跑的是0xA2还是0xA0,问题一目了然。如果你手头没有,建议至少用示波器看一下两个引脚的上升沿是否正常、低电平是否能拉到位,这对排查上拉电阻选型是否正确非常有帮助。
6. 完整例程验证与串口打印
6.1 读写验证思路
写完了初始化代码和读写函数,验证思路其实很简单:往指定的地址写入一组已知数据,延时后从一个固定地址读回来,用串口打印出来对比。如果完全一致,说明整条链路通的;如果不一样,再根据差异的位置缩小排查范围。
我推荐做一个这样的验证序列,顺序读:
// 测试思路:先写后读对比 uint8_t test_data[] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}; uint8_t read_buffer[8] = {0}; uint8_t base_addr = 0x10; // 避开EEPROM开头的不能用区间 // 先把8字节页写进去 AT24C02_PageWrite(base_addr, test_data, 8); delay_ms(5); // 等待写周期 // 再逐一随机读回 for(uint8_t i = 0; i < 8; i++) { read_buffer[i] = AT24C02_ReadByte(base_addr + i); } // 逐字节比较并通过串口输出 for(uint8_t i = 0; i < 8; i++) { printf("addr 0x%02X: write=0x%02X read=0x%02X %s\r\n", base_addr + i, test_data[i], read_buffer[i], (test_data[i] == read_buffer[i]) ? "OK" : "MISMATCH"); }AT24C02的地址空间是256字节,但实际使用中建议把容易踩到WP或特殊用途的边界区域留出来。数据手册并没有定义哪些具体地址不能用,但自己定的协议头、校验区域、数据区域要有一个清晰的划分,避免覆盖升级固件参数。串口打印建议用标准重定向printf,注意不要让printf里嵌入过多耗时操作,否则会影响I2C时序。
6.2 主要坑位速查表
| 现象 | 最可能原因 | 解决思路 |
|---|---|---|
| 写完读全是0xFF | WP引脚被拉高 | WP接GND,或代码里确认写保护关闭 |
| 写完读旧数据 | 写周期未等够 | 读写之间加5ms以上延时 |
| 页写数据错乱 | 跨页边界回绕 | 判断剩余页空间,按页拆分写入 |
| 卡在EV5死循环 | 硬件I2C BSY标志 | 复位外设、SCL翻转9次,或换软件模拟 |
| 读多出一个字节 | 最后字节ACK没收好 | 最后一个字节前关闭ACK |
| 偶发性通信失败 | 上电时序或供电不稳 | 初始化前加100ms延时,查电源纹波 |
6.3 几个适合后续上手的方向
AT24C02读写这关过了之后,可以顺手做几个小扩展。第一,把页写和顺序读封装成通用的块读写接口,做一个简单的参数存储协议:开头放魔数、长度、校验值,之后是业务数据,断电重启后通过校验判断参数是否有效。这是嵌入式产品里常见的"掉电存储"方案雏形。
第二,可以把I2C协议解码逻辑接上逻辑分析仪,自己分析一遍总线上的时序帧,尤其注意起始条件、停止条件、NACK/ACK这些关键位置,加深对协议的直觉理解。第三,试试把同一颗AT24C02挂到一条有多个I2C从机的总线上,学习怎么处理总线冲突和设备轮询。这些内容都从今天这篇的基础延伸出去,能让你把I2C这条知识点真正吃透。
最后说点个人体会。我从一开始照着别人的代码抄了一遍AT24C02的读写,自认为懂了I2C;直到后来被BSY标志卡了整整两天,又把页写跨边界的事情搞砸了一次,才明白所谓的"懂",不是能跑通例程,而是明白总线上每一个电平的变化是怎么来的、出了问题该从哪个方向去查。AT24C02这个256字节的小芯片,确实能教会你这件大事。