
1. 为什么AT24C02是I2C入门的最佳练手对象搞STM32的兄弟基本都绕不开I2C这个外设而AT24C02几乎是所有人接触I2C的第一个实战器件。原因很简单它便宜、好买、协议标准、时序宽容度高而且掉电不丢数据这个特性让它在很多小项目里充当参数存储器的角色。你做的那个温控器要记住上次设定的温度、你的小台灯要保存亮度档位、你的数据采集板要存校准系数这些场景AT24C02都能顶上。但很多人第一次调AT24C02的时候都会卡住——要么读出来全是0xFF要么写进去读出来不对要么干脆卡在等待ACK的死循环里。这些问题背后其实都是对I2C时序理解不到位、对AT24C02的页写机制和写周期不了解造成的。我见过太多人直接抄一份代码能用就完事结果换个芯片、换个上拉电阻值就歇菜了。这篇内容我打算把STM32F103驱动AT24C02这件事从头到尾拆开讲。从硬件电路怎么搭、I2C的开漏模式为什么必须配外部上拉、到软件层面用寄存器还是HAL库、页写怎么处理跨页、写周期等待怎么做才不丢数据全部按实际项目里踩过的路子来。不管你是刚上手STM32的新手还是想把这套东西整理成自己代码库的老手应该都能拿到点能直接用的东西。2. 硬件设计别小看那两颗上拉电阻2.1 AT24C02的引脚连接与地址配置AT24C02是8引脚的小芯片I2C接口只占两根线SCL串行时钟和SDA串行数据。剩下的引脚里A0/A1/A2是地址选择脚WP是写保护脚VCC和GND供电。地址这块很多人第一次会懵。AT24C02的7位从机地址是1010加上A2 A1 A0三位所以A2A1A07位地址8位写地址8位读地址0000x500xA00xA10010x510xA20xA30100x520xA40xA51110x570xAE0xAF实际用的时候如果总线上只挂一片AT24C02把A0/A1/A2全接地就行地址就是0x50。注意这里说的是7位地址HAL库函数里传的参数需要左移一位变成8位形式这个后面代码部分会细说。WP写保护脚要特别注意。接高电平时芯片进入只读状态你写什么都写不进去而且不会报错就是默默失败。调试阶段建议直接接地等确认读写都正常了再考虑要不要用写保护功能。我遇到过有人WP悬空导致写入时好时坏的情况因为悬空电平不确定芯片可能在保护和非保护之间乱跳。2.2 上拉电阻选型4.7k不是万能答案I2C总线是开漏结构这意味着SCL和SDA线只能被拉低不能被主动拉高。所以必须外接上拉电阻否则总线永远起不来。STM32F103的I2C引脚配置成开漏复用模式后内部弱上拉根本不够用必须外部加。上拉电阻的取值是个权衡阻值太大比如10k以上上升沿变缓高速通信时波形还没到高电平就被拉低了导致通信失败。示波器上看就是上升沿是个圆弧而不是陡峭的直角。阻值太小比如1k以下灌电流太大器件可能扛不住。AT24C02的SDA引脚最大灌电流是3mA3.3V供电时理论上最小电阻是1.1k。实际项目里4.7k是最常用的值对应3.3V系统、100kHz标准模式、总线电容不超过200pF的场景。但如果你的总线走线长、挂了多个器件、或者跑400kHz快速模式4.7k可能就偏大了。这时候可以降到2.2k甚至1.8k。有个经验公式可以估算上升时间tr ≈ 0.847 × R × CI2C标准模式要求上升时间小于1000ns快速模式小于300ns。假设总线电容100pF标准模式下R最大约11.8k快速模式下R最大约3.5k。所以跑400kHz的时候4.7k其实是超标的虽然很多时候能凑合工作但余量不够。实操建议调试阶段先用4.7k如果示波器看到上升沿明显变圆或者通信不稳定换成2.2k试试。手头没有合适阻值的时候可以用两个10k并联得到5k效果接近。2.3 电平匹配问题3.3V MCU和5V EEPROM能直连吗AT24C02有不同电压版本常见的有2.5V、3.3V、5V供电的型号。如果你用的是5V供电的AT24C02而STM32F103是3.3V系统这里就有电平匹配问题。好消息是I2C的开漏结构天然适合电平转换。因为总线上的高电平是由上拉电阻决定的不是由器件推出来的。所以只要把上拉电阻接到3.3V5V的AT24C02和3.3V的STM32就能正常通信——AT24C02的SDA/SCL引脚在5V供电时输入高电平门限大约是0.7×VCC3.5V3.3V可能刚好卡在边缘。稳妥的做法是上拉电阻接到3.3V同时确认AT24C02的VIH参数。查AT24C02手册VIH最小值是0.7×VCC5V供电时就是3.5V3.3V确实不够。这时候要么换3.3V版本的AT24C02要么加电平转换电路。最简单的电平转换方案是用一个N沟道MOS管加两个上拉电阻但更省事的办法是直接用3.3V供电的AT24C02现在市面上3.3V版本已经很常见了没必要给自己找麻烦。3. STM32F103的I2C外设配置寄存器还是HAL库3.1 开漏模式与推挽模式的本质区别STM32的GPIO有推挽和开漏两种输出模式I2C必须用开漏。这个点很多人知道结论但说不清原因。推挽输出是上下两个MOS管交替导通输出高电平时上管导通、下管截止引脚被主动拉到VCC输出低电平时下管导通、上管截止引脚被拉到GND。这种模式下如果两个器件同时输出一个输出高一个输出低就会形成VCC到GND的直接通路大电流烧毁引脚。开漏输出只有下管没有上管。输出低电平时下管导通拉低总线输出高电平时下管截止引脚处于高阻态靠外部上拉电阻把电平拉高。这样多个器件的开漏输出接在一起任何一个拉低总线都是低电平全部释放才是高电平这就是I2C的线与逻辑也是总线仲裁的基础。配置的时候STM32F103的I2C引脚要设成复用开漏模式GPIO_Mode_AF_OD速度根据通信速率选100kHz选2MHz就够了400kHz建议选50MHz。3.2 硬件I2C vs 软件模拟I2CSTM32F103的硬件I2C外设有个众所周知的毛病在某些情况下会死锁尤其是总线受到干扰或者从机没有正常响应的时候。这个问题的根源是硬件I2C状态机在异常情况下可能卡在某个状态出不来需要复位整个I2C外设才能恢复。所以实际项目里很多人宁愿用软件模拟I2C。软件模拟的好处是时序完全可控出问题容易排查不会死锁超时了直接返回错误就行移植方便换个MCU改改GPIO操作就行可以灵活处理各种非标准时序坏处是占用CPU时间高速通信时CPU开销大。不过AT24C02这种器件100kHz的速率软件模拟完全够用CPU占用率也不高。我的建议是如果是学习或者对可靠性要求高的场景用软件模拟如果是产品开发且硬件I2C调通了用硬件I2C省CPU。下面两种方式我都会讲。3.3 软件模拟I2C的时序实现要点软件模拟I2C的核心就是精确控制SCL和SDA的时序。先定义几个基本操作// 引脚定义 #define I2C_SCL_PIN GPIO_Pin_6 #define I2C_SDA_PIN GPIO_Pin_7 #define I2C_GPIO GPIOB // SCL和SDA操作宏 #define SCL_H() GPIO_SetBits(I2C_GPIO, I2C_SCL_PIN) #define SCL_L() GPIO_ResetBits(I2C_GPIO, I2C_SCL_PIN) #define SDA_H() GPIO_SetBits(I2C_GPIO, I2C_SDA_PIN) #define SDA_L() GPIO_ResetBits(I2C_GPIO, I2C_SDA_PIN) #define SDA_READ() GPIO_ReadInputDataBit(I2C_GPIO, I2C_SDA_PIN)起始条件SCL为高时SDA从高变低。void I2C_Start(void) { SDA_H(); SCL_H(); delay_us(4); SDA_L(); delay_us(4); SCL_L(); delay_us(4); }停止条件SCL为高时SDA从低变高。void I2C_Stop(void) { SDA_L(); SCL_H(); delay_us(4); SDA_H(); delay_us(4); }发送一个字节从最高位开始每bit在SCL低电平时准备好SDA然后SCL拉高从机在SCL高电平期间采样。void I2C_SendByte(uint8_t byte) { for (int i 0; i 8; i) { SCL_L(); delay_us(2); if (byte 0x80) SDA_H(); else SDA_L(); byte 1; delay_us(2); SCL_H(); delay_us(4); } SCL_L(); delay_us(2); }接收ACK发送完8bit后主机释放SDA从机拉低表示应答。uint8_t I2C_WaitAck(void) { uint8_t ack; SDA_H(); // 释放SDA delay_us(2); SCL_H(); delay_us(4); ack SDA_READ(); // 0表示有ACK SCL_L(); delay_us(2); return ack; }这里的delay_us很关键。100kHz的I2C每个时钟周期10us高电平低电平各5us左右。上面的延时加起来大概符合这个节奏。但要注意delay_us的实现要准确用SysTick或者定时器都行别用for循环空跑那个时间不准。踩坑记录我最早写软件I2C的时候delay时间给太短示波器上看SCL频率到了300多kHzAT24C02直接不响应。后来把延时调大降到100kHz左右就正常了。AT24C02标准模式最高100kHz别超。4. AT24C02读写全流程拆解4.1 字节写从起始到ACK的完整时序AT24C02的字节写流程是这样的主机发送起始条件主机发送设备地址写方向0xA0等待从机ACK主机发送要写入的内存地址0x00~0xFF等待从机ACK主机发送要写入的数据字节等待从机ACK主机发送停止条件等待写周期完成约5ms代码实现uint8_t AT24C02_WriteByte(uint8_t addr, uint8_t data) { I2C_Start(); I2C_SendByte(0xA0); // 设备地址写 if (I2C_WaitAck()) { I2C_Stop(); return 1; // 错误 } I2C_SendByte(addr); // 内存地址 if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte(data); // 数据 if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_Stop(); delay_ms(5); // 等待写周期 return 0; }这里最后那个delay_ms(5)非常关键。AT24C02在收到停止条件后内部开始擦写EEPROM单元这个过程需要时间典型值5ms最大可能到10ms。在这段时间内芯片不会响应任何I2C命令。如果你紧接着发下一个写命令从机不会回ACK你的程序就会卡在等待ACK的地方。4.2 页写一次写8字节的正确姿势AT24C02的页大小是8字节。页写就是一次连续写入最多8个字节地址在页内自动递增。但要注意如果起始地址不是页对齐的写到页边界后会回卷到本页开头覆盖之前的数据。比如从地址0x05开始写8个字节实际会写到0x05、0x06、0x07、0x00、0x01、0x02、0x03、0x04。0x00~0x04的数据被覆盖了这不是你想要的。所以页写的时候要么保证起始地址页对齐0x00、0x08、0x10...要么在跨页的时候拆成两次写。uint8_t AT24C02_WritePage(uint8_t addr, uint8_t *data, uint8_t len) { if (len 8) return 1; // 超过一页 if ((addr / 8) ! ((addr len - 1) / 8)) return 1; // 跨页 I2C_Start(); I2C_SendByte(0xA0); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte(addr); if (I2C_WaitAck()) { I2C_Stop(); return 1; } for (uint8_t i 0; i len; i) { I2C_SendByte(data[i]); if (I2C_WaitAck()) { I2C_Stop(); return 1; } } I2C_Stop(); delay_ms(5); return 0; }如果要写超过8字节的数据需要自己封装一个跨页写函数按页拆分uint8_t AT24C02_WriteBuffer(uint8_t addr, uint8_t *data, uint16_t len) { while (len 0) { uint8_t page_remain 8 - (addr % 8); // 当前页剩余空间 uint8_t write_len (len page_remain) ? len : page_remain; if (AT24C02_WritePage(addr, data, write_len)) return 1; addr write_len; data write_len; len - write_len; } return 0; }4.3 随机读先写地址再读数据AT24C02的读操作分两种当前地址读和随机读。当前地址读是读内部地址指针指向的位置地址指针在每次读写后自动加1。随机读是先发送要读的地址然后重新起始再发送读命令。随机读的流程主机发送起始条件主机发送设备地址写方向0xA0等待ACK主机发送要读的内存地址等待ACK主机重新发送起始条件主机发送设备地址读方向0xA1等待ACK主机读取一个字节发送NACK表示只读一个主机发送停止条件uint8_t AT24C02_ReadByte(uint8_t addr, uint8_t *data) { I2C_Start(); I2C_SendByte(0xA0); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte(addr); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_Start(); // 重新起始 I2C_SendByte(0xA1); // 设备地址读 if (I2C_WaitAck()) { I2C_Stop(); return 1; } *data I2C_ReadByte(); // 读一个字节 I2C_SendNack(); // 发送NACK I2C_Stop(); return 0; }读多个字节的时候前面每个字节读完后主机发送ACK最后一个字节发送NACK然后停止。uint8_t AT24C02_ReadBuffer(uint8_t addr, uint8_t *buf, uint16_t len) { I2C_Start(); I2C_SendByte(0xA0); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte(addr); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_Start(); I2C_SendByte(0xA1); if (I2C_WaitAck()) { I2C_Stop(); return 1; } for (uint16_t i 0; i len; i) { buf[i] I2C_ReadByte(); if (i len - 1) I2C_SendAck(); else I2C_SendNack(); } I2C_Stop(); return 0; }4.4 写周期等待轮询ACK比死等延时更靠谱前面代码里用delay_ms(5)等待写周期简单但效率低。更好的做法是轮询发一个起始条件设备地址如果从机回ACK说明写周期结束了如果没回ACK就继续等。void AT24C02_WaitReady(void) { uint32_t timeout 0; do { I2C_Start(); I2C_SendByte(0xA0); if (I2C_WaitAck() 0) { I2C_Stop(); return; } I2C_Stop(); delay_us(100); timeout; } while (timeout 1000); // 最多等100ms }这种方式比固定延时灵活写周期短的时候能更快进行下一步写周期长的时候也不会丢数据。实际测试AT24C02的写周期通常在3~5ms之间用轮询方式平均能省2ms左右。5. 常见问题排查与避坑指南5.1 读出来全是0xFF是怎么回事这是最常见的问题原因通常有这几个从机没应答。用示波器看SDA线如果发送地址后SDA一直是高电平说明从机根本没响应。检查设备地址对不对0xA0还是0xA1、WP脚是不是被拉高了、供电是否正常、上拉电阻有没有焊。地址搞错了。AT24C02的7位地址是0x50但HAL库的I2C函数需要传8位地址也就是0xA0。如果你传了0x50实际发送的是0x50左移一位还是0x50从机地址就错了。软件模拟的时候直接发0xA0就行。写周期没等。写完一个字节立刻读从机还在忙内部擦写不会响应读出来就是0xFF。加延时或者轮询ACK。页写回卷。从非页对齐地址开始写多个字节数据被覆盖了。检查写入地址和长度。5.2 通信不稳定、时好时坏上拉电阻偏大。示波器看SCL/SDA上升沿如果上升时间超过1us换小一点的电阻。总线电容太大。走线太长、挂了太多器件、PCB布局不好都会增加总线电容。I2C标准规定总线电容不超过400pF超了就要想办法减小。电源干扰。AT24C02的VCC引脚旁边加一个0.1uF的去耦电容越近越好。地线没接好。MCU和AT24C02必须共地否则电平参考不一致通信肯定出问题。5.3 硬件I2C死锁怎么恢复STM32F103的硬件I2C死锁后SDA可能被从机拉低不放SCL也动不了。恢复方法是把I2C引脚临时配置成普通GPIO开漏输出手动发送9个SCL脉冲让从机把剩余的数据位发完释放SDA然后发送停止条件再重新初始化I2C外设。void I2C_BusRecovery(void) { GPIO_InitTypeDef gpio; // 配置SCL和SDA为普通开漏输出 gpio.GPIO_Pin I2C_SCL_PIN | I2C_SDA_PIN; gpio.GPIO_Mode GPIO_Mode_Out_OD; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(I2C_GPIO, gpio); SDA_H(); for (int i 0; i 9; i) { SCL_L(); delay_us(5); SCL_H(); delay_us(5); } // 发送停止条件 SDA_L(); delay_us(5); SCL_H(); delay_us(5); SDA_H(); delay_us(5); // 重新初始化I2C I2C_Init(); }5.4 常见问题速查表现象可能原因排查方法解决措施读全0xFF从机无应答示波器看ACK位检查地址、WP脚、供电写入后读不对写周期未等待加长延时测试用轮询ACK替代固定延时通信时好时坏上拉电阻偏大看上升沿时间换2.2k~4.7k电阻页写数据错乱跨页回卷检查地址和长度按页拆分写入硬件I2C卡死状态机死锁看SDA是否被拉低总线恢复重新初始化只能读不能写WP脚被拉高测WP脚电平WP接地高速通信失败总线电容大测上升时间减小上拉、缩短走线独家经验调试I2C的时候手边一定要有示波器或者逻辑分析仪。光靠串口打印很难定位问题因为I2C的问题大多出在时序和电气层面。一个几十块的逻辑分析仪配合开源软件能把起始、地址、ACK、数据全部解码出来比猜效率高十倍。6. 从AT24C02延伸出去的几个实用技巧6.1 用结构体封装读写接口实际项目里不会一个字节一个字节地读写通常是把参数打包成结构体整体存取。但要注意结构体的字节对齐问题不同编译器对齐方式可能不一样存进去读出来可能错位。稳妥的做法是手动序列化typedef struct { uint16_t temperature; uint8_t brightness; uint8_t mode; uint32_t counter; } DeviceConfig; void SaveConfig(DeviceConfig *cfg) { uint8_t buf[8]; buf[0] cfg-temperature 8; buf[1] cfg-temperature 0xFF; buf[2] cfg-brightness; buf[3] cfg-mode; buf[4] (cfg-counter 24) 0xFF; buf[5] (cfg-counter 16) 0xFF; buf[6] (cfg-counter 8) 0xFF; buf[7] cfg-counter 0xFF; AT24C02_WriteBuffer(0x00, buf, 8); }这样不管编译器怎么对齐存进去的字节顺序都是确定的。6.2 数据校验别让坏数据坑了你EEPROM不是绝对可靠的写入过程中断电、芯片老化都可能导致数据损坏。重要的参数建议加校验简单点用累加和复杂点用CRC16。uint8_t CalcChecksum(uint8_t *data, uint8_t len) { uint8_t sum 0; for (uint8_t i 0; i len; i) sum data[i]; return sum; }存储的时候把校验和放在最后读取的时候算一遍对比不对就恢复默认值。6.3 磨损均衡AT24C02也有寿命AT24C02的擦写寿命是100万次单看数字很大但如果你的程序每秒写一次不到12天就写废了。对于频繁写入的场景可以做简单的磨损均衡把数据轮流写到不同的地址块每次写之前记录当前块号。比如把256字节分成16块每块16字节轮流写。这样寿命就变成了1600万次够用了。6.4 用HAL库的I2C函数实现如果你用STM32CubeMX生成了HAL库工程I2C的读写可以用HAL库函数// 写一个字节 HAL_I2C_Mem_Write(hi2c1, 0xA0, addr, I2C_MEMADD_SIZE_8BIT, data, 1, 100); // 读一个字节 HAL_I2C_Mem_Read(hi2c1, 0xA0, addr, I2C_MEMADD_SIZE_8BIT, data, 1, 100); // 写多个字节 HAL_I2C_Mem_Write(hi2c1, 0xA0, addr, I2C_MEMADD_SIZE_8BIT, buf, len, 100); // 读多个字节 HAL_I2C_Mem_Read(hi2c1, 0xA0, addr, I2C_MEMADD_SIZE_8BIT, buf, len, 100);注意HAL库的HAL_I2C_Mem_Write内部已经处理了写周期等待但它的等待方式是固定延时不是轮询ACK。如果写周期比较长可能需要手动加延时。另外HAL库的I2C函数在通信失败时会返回错误码可以根据返回值判断问题HAL_OK成功HAL_ERROR总线错误HAL_BUSY总线忙HAL_TIMEOUT超时调试的时候把返回值打印出来能快速定位问题。6.5 逻辑分析仪抓包实战最后说下怎么用逻辑分析仪抓I2C波形。把分析仪的CH0接SCLCH1接SDAGND接板子GND采样率设1MHz以上协议选I2C。正常的写时序应该是这样的Start | 0xA0 | ACK | 0x00 | ACK | 0x55 | ACK | Stop如果看到某个ACK位是高电平说明从机没应答问题就出在那一步。如果Start条件都出不来说明总线被拉死了需要做总线恢复。逻辑分析仪的好处是能把整个通信过程可视化比示波器看单个信号更直观。特别是调试多字节读写的时候能清楚看到每个字节的传输和应答情况。最后分享一个小技巧如果手头没有逻辑分析仪可以用另一块STM32的I2C从机模式来监听总线把收到的数据通过串口打印出来。虽然麻烦点但应急够用。这套AT24C02的读写流程我前后调过不下十次每次换芯片、换板子、换速率都会遇到新问题。但核心的东西就那些开漏上拉、地址正确、时序匹配、写周期等待。把这四点吃透I2C这块基本就通了。后面换其他I2C器件比如OLED屏、温湿度传感器、EEPROM套路都是一样的。