STM32F103和AT24C02这套组合只要是做嵌入式开发的基本都绕不开。I2C协议本身看着就两根线挺简单的但等你真上手去读写AT24C02就会发现时序、应答、器件地址这些细节全是要命的地方。这篇文章就把整个流程完整拆开讲一遍从I2C协议底层逻辑到STM32F103的GPIO配置再到AT24C02的字节写、页写、随机读、顺序读最后附上我实际调试时踩过的几个坑。适合正在学STM32通信外设的开发者也适合那些想用EEPROM做掉电保存参数、但因为时序问题迟迟调不通的人。看完基本能做到一次点亮顺利把数据写入并读回。1. 项目认知:为什么入门I2C首选STM32F103和AT24C02先说为什么这套组合是经典。STM32F103是Cortex-M3内核主频72MHz片上资源丰富价格亲民是无数人入门32位单片机的第一颗芯片。AT24C02则是2Kbit容量的EEPROM换算过来就是256字节虽然不大但用来存设备编号、校准参数、开机次数这类小数据绰绰有余。两样东西通过I2C总线连接既练了协议又解决了一个非常实际的工程需求:掉电后数据不丢失。EEPROM和Flash的区别很多人容易混淆。Flash是块擦除最小擦除单位是扇区动辄几KB甚至几十KB而且擦写次数通常在一万次量级。EEPROM则支持字节级读写擦写寿命一般在十万次到一百万次灵活性高出不少。实际产品里如果只需要频繁更新几个配置字节用EEPROM远比用Flash合适。AT24C02就是这类应用里最常见的器件之一。这个项目的核心价值在于I2C协议是嵌入式领域使用频率极高的通信方式EEPROM是最标准的I2C从设备而AT24C02又是最简单的EEPROM寄存器少、时序清晰、手册资料铺天盖地。把这条链路打通之后再去驱动传感器、音频解码、显示模块等I2C设备思路基本都是相通的。可以用一句话概括:搞懂AT24C02就等于拿到了一把打开I2C世界的钥匙。写这篇文章之前我重新翻了一遍代码和时序图发现有一些细节即使做过几年的人也会疏忽比如页写跨边界、应答位判断、WP引脚处理这些。下文会结合实际代码逐一拆解。2. I2C协议底层拆解:时序、地址与应答2.1 两条线的规矩:SCL与SDA的时序约定I2C总线只有两条线串行时钟线SCL和串行数据线SDA。空闲状态下两条线都是高电平这是因为总线上的设备输出级都是开漏结构靠上拉电阻把电平拉高任何设备都可以主动把线拉低。这个设计的好处是支持多主机仲裁但初学者往往忽略背后的电平逻辑导致调试时遇到信号异常。数据传输的规则可以总结成三条核心约定。第一SCL高电平期间SDA上的数据必须保持稳定;第二SCL低电平期间SDA上允许变化;第三数据按字节传输每个字节8位高位在前。这意味着发送方必须在SCL拉低之后才能改变SDA的状态在SCL拉高期间保持数据不动。很多时序问题都是因为SCL和SDA变化时机不对引起的看到波形乱跳基本都是违反了这三条。起始条件和停止条件是整个通信的开始与结束标志特殊在SDA的变化发生在SCL高电平期间。起始条件是SCL保持高电平的同时SDA从高变低;停止条件是SCL保持高电平的同时SDA从低变高。这两个条件都只能在SCL为高时产生因为正常情况下SCL高电平期间SDA不允许变化所以这种变化对总线设备来说是一个醒目的“事件信号”。实际写代码时我习惯先让SCL和SDA都拉高然后拉低SDA再把SCL拉低这样保证最稳妥。2.2 七位地址与器件寻址:一帧数据怎么定位到AT24C02I2C寻址用的是7位地址加1位读写标志组合起来正好一个字节。AT24C02的7位地址前4位是固定的1010后3位来自硬件引脚A2、A1、A0的电平状态。这3个引脚在芯片内部有下拉电阻悬空时为0这在小封装特别是贴片封装里很常见省去了外部连线。把A2、A1、A0都接GND7位地址就是1010000。发送时这7位左移一位最低位填读写标志所以写操作地址是0xA0读操作地址是0xA1。如果A0接了高电平地址就变成0xA2和0xA3。一块I2C总线上最多可以挂8片AT24C02通过A2、A1、A0不同组合来区分。这里有个常见的误操作:有人直接向0x50写地址这是把7位地址当成了8位地址漏掉了左移那一步。2.3 应答机制:总线上的握手信号主机每发送完成一个字节第9个SCL时钟周期就是应答位。发送方在此时释放SDA接收方如果拉低SDA表示ACK;如果保持高电平表示NACK。ACK说明设备收到了且准备好继续NACK则代表设备没响应、数据错误或者不想继续收发。读AT24C02时主机读完最后一个想要的数据字节后需要回一个NACK再产生停止条件。这是很多人困惑的地方:为什么读的时候最后不回答会“不礼貌”实际恰恰相反NACK正是告诉从机“我读够了别再发了”。如果最后回了ACK从机会继续发下一个字节导致总线状态混乱。主机在接收完倒数第二个字节时回ACK最后一个字节前回NACK这是一个很重要的习惯。还有一个细节:AT24C02内部处于写周期时对总线上任何命令都不响应表现为发送器件地址后收不到ACK。很多人在写完数据后马上读发现第一个字节就卡住其实是忽略了写周期的存在。后文会重点讲这个坑怎么处理。3. 软件I2C与硬件I2C之争:我的选型思路3.1 硬件I2C为什么口碑分化STM32F103内部自带两个I2C外设理论上用硬件方式实现I2C通信比软件模拟更省CPU代码也简洁。但实际用过的工程师都知道它的硬件I2C模块在工程圈子里口碑不算好问题集中在事件标志处理繁琐、总线锁死恢复困难、时钟控制逻辑复杂。很多人在学习阶段被硬件I2C折腾得够呛最后干脆全部改用软件模拟。其实STM32F103的硬件I2C并非不能用掌握了事件序列和错误处理流程配合超时机制也能稳定工作但门槛确实比想象中高。比如启动信号发送后要等待EV5事件、地址发送后要等EV6、数据处理时要处理EV8和EV9这些事件在不同的库版本里表述还有差异。一旦从机异常导致总线卡在中间状态复位外设、重新初始化GPIO都未必能救回来。我自己的观点是:如果是学习I2C协议原理或者驱动AT24C02这种低速器件软件模拟完全够用可控性和可观测性反而更好。如果是产品里需要高速、大批量地读写多个I2C从机再考虑深入啃硬件I2C或者换用带成熟硬件I2C实现的芯片。3.2 软件模拟I2C的可靠性与灵活性软件模拟I2C的本质就是通过GPIO的置高置低和输入读取严格按照时序图去控制SCL和SDA。它的优势有三个:第一不依赖特定引脚任意两个GPIO都能组I2C布线更自由;第二代码直观每一行对应一个时序动作出了问题看波形一眼就能定位;第三不受硬件外设状态机束缚控制逻辑完全在自己手上。代价是CPU占用比硬件方式高一些但对100kHz标准速率、每次读写几十个字节的场景CPU占用率几乎可以忽略。AT24C02的写入周期最长有5ms这个等待时间无论硬件还是软件I2C都无法回避。软件模拟I2C有两种常见实现方式一种是普通延时法在翻转电平之间插入延时函数;另一种是状态机法适合在RTOS环境下避免阻塞延时。学习阶段用延时法就够了代码简单直接可读性强。我用的是标准外设库配合简单延时后面给出的代码都是这个思路。维度硬件I2C软件模拟I2C引脚选择固定映射引脚任意GPIO代码复杂度事件标志多细节深直观易理解调试便捷性黑盒状态难追踪每步可控日志易加CPU占用低高一些但低速场景可忽略总线异常恢复需复杂处理重新初始化即可学习友好度一般高4. 全流程代码实现:从初始化到数据落盘4.1 GPIO开漏配置与上拉电阻选择硬件准备就绪后第一步是配置GPIO。软件模拟I2C的SCL和SDA用PB6和PB7不占用硬件I2C外设完全复用普通GPIO。关键点是输出模式必须选择开漏输出因为I2C总线的电气规范要求设备只能拉低总线不能主动拉高高电平靠外部上拉电阻实现。我习惯使用4.7kΩ上拉电阻这个数值在100kHz标准速率和400kHz快速速率下都通用。如果总线过长或者挂载设备过多可以适当降到2.2kΩ增强驱动能力但也不能太小否则低电平灌电流太大。学习板上通常已经焊好上拉电阻自己打板时千万别漏。初始化代码用标准外设库写先开GPIOB的时钟再把PB6和PB7配置为开漏输出SDA引脚在读取时需要切换成输入模式可以直接读取开漏输出引脚的电平因为开漏输出下高电平完全由上拉电阻决定此时读输入寄存器得到的结果就是真实总线电平。为了方便宏定义里用SDA_READ直接读取IDR寄存器。#define SCL_L() GPIO_ResetBits(GPIOB, GPIO_Pin_6) #define SCL_H() GPIO_SetBits(GPIOB, GPIO_Pin_6) #define SDA_L() GPIO_ResetBits(GPIOB, GPIO_Pin_7) #define SDA_H() GPIO_SetBits(GPIOB, GPIO_Pin_7) #define SDA_READ() GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_7) void I2C_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure); SCL_H(); SDA_H(); }注意:务必确保SCL和SDA配置为开漏输出。如果误配为推挽输出在电平拉高时如果外部从机同时拉低总线就会形成直接短路长期运行可能损坏引脚或从机。4.2 核心时序函数:Start、Stop、SendByte、RecvByte初始化完成之后接下来就是最核心的四个时序函数。虽然只是几十行代码但每一行都对应I2C手册里的时序要求建议先对着时序图读代码而不是直接复制。起始函数的动作是:SCL保持高电平SDA从高变低随后SCL拉低。注意顺序不能反如果先拉低SCL再拉低SDA从机就无法识别起始条件。void I2C_Start(void) { SDA_H(); SCL_H(); I2C_Delay(); SDA_L(); I2C_Delay(); SCL_L(); I2C_Delay(); } void I2C_Stop(void) { SDA_L(); SCL_H(); I2C_Delay(); SDA_H(); I2C_Delay(); }发送一个字节需要从最高位开始逐位发送。在SCL拉低期间设置SDA数据然后拉高SCL保持一个时钟周期再拉低SCL继续发送下一位。整个循环执行8次后释放SDA然后在第9个时钟周期读取从机的应答。这里的应答检测是很多新手容易忽略的点如果从机没应答后面的所有操作都是无效的。uint8_t I2C_SendByte(uint8_t dat) { uint8_t i, ack; for(i 0; i 8; i) { if(dat 0x80) SDA_H(); else SDA_L(); dat 1; I2C_Delay(); SCL_H(); I2C_Delay(); SCL_L(); I2C_Delay(); } SDA_H(); I2C_Delay(); SCL_H(); I2C_Delay(); ack SDA_READ(); SCL_L(); I2C_Delay(); return ack; }接收字节的流程相反主机在SCL高电平时读取SDA状态逐位拼装成一个字节。需要特别留意的是读字节函数的最后一个参数主机要在第9个时钟周期决定回ACK还是NACK。回ACK让从机继续发下一个字节回NACK则表示接收完毕。uint8_t I2C_RecvByte(uint8_t ack) { uint8_t i, dat 0; SDA_H(); for(i 0; i 8; i) { I2C_Delay(); SCL_H(); I2C_Delay(); dat 1; if(SDA_READ()) dat | 0x01; SCL_L(); I2C_Delay(); } if(ack) SDA_L(); else SDA_H(); I2C_Delay(); SCL_H(); I2C_Delay(); SCL_L(); I2C_Delay(); SDA_H(); return dat; }4.3 字节写与页写:注意8字节页边界AT24C02的写操作分为字节写和页写。字节写流程是:起始条件、发送器件地址写(0xA0)、发送字地址、发送数据、停止条件。每完成一次字节写AT24C02内部地址会自动加1超过255后会回到0。页写则一次最多写入8个字节。这个8不是随便定的因为AT24C02的256字节被分成32页每页8字节。执行页写时字地址的低3位是页内偏移发送第9个字节时低3位回卷导致数据写到页开头把之前的数据覆盖。跨页写入时更危险你本意是连续写16字节结果第二页的数据全部写错了位置。处理办法有两种:一是写前判断目标地址是否跨越页边界如果需要跨页就拆分成多次页写;二是干脆用单字节写简单可靠代价是每写一个字节都要等一次写周期。在参数保存这种低频场景下我通常直接逐字节写逻辑简单的多。如果想用页写推荐记住一条规则:起始地址加上本次字节数必须保持在同一个8字节页内。void AT24C02_WriteByte(uint8_t addr, uint8_t dat) { I2C_Start(); I2C_SendByte(0xA0); I2C_SendByte(addr); I2C_SendByte(dat); I2C_Stop(); AT24C02_WaitForWriteComplete(); }写周期等待逻辑后文会专门说先按下不提。页写示例看下面这个函数。void AT24C02_WritePage(uint8_t addr, uint8_t *buf, uint8_t len) { uint8_t i; I2C_Start(); I2C_SendByte(0xA0); I2C_SendByte(addr); for(i 0; i len; i) { I2C_SendByte(buf[i]); } I2C_Stop(); AT24C02_WaitForWriteComplete(); }如果确实需要跨页连续写必须手动拆分。比如从地址7开始写4字节第一页只能写1字节到地址7剩下3字节从地址8开始写。不拆就是数据覆盖代码层面还不会报错排查起来很隐蔽。4.4 随机读与顺序读:把数据读回来读操作比写操作多一个“假写”步骤:先发送器件地址写(0xA0)再发送要读的字地址然后重新发送起始条件最后发送器件地址读(0xA1)。这个过程叫随机读目的是让芯片内部的地址指针指向目标位置。很多新手不理解为什么读之前要先写一下本质上是借写操作来设置读地址。uint8_t AT24C02_ReadByte(uint8_t addr) { uint8_t dat; I2C_Start(); I2C_SendByte(0xA0); I2C_SendByte(addr); I2C_Start(); I2C_SendByte(0xA1); dat I2C_RecvByte(0); I2C_Stop(); return dat; }读单个字节时主机接收完数据后回NACK再产生停止条件这个在上面代码里已经体现。如果连续读多个字节倒数第二个字节回ACK最后一个字节回NACK再接停止。顺序读则是在随机读的基础上读完当前字节后不停止继续读下一个字节芯片内部地址自动加1。只要不停止理论上256字节可以一次全读出来。顺序读常用于把整片EEPROM的数据备份到RAM中或者启动时把参数块一次性加载。地址到达255后如果继续读会回卷到0读取逻辑需要自行判断边界。void AT24C02_ReadSequential(uint8_t addr, uint8_t *buf, uint16_t len) { uint16_t i; I2C_Start(); I2C_SendByte(0xA0); I2C_SendByte(addr); I2C_Start(); I2C_SendByte(0xA1); for(i 0; i len - 1; i) { buf[i] I2C_RecvByte(1); } buf[len - 1] I2C_RecvByte(0); I2C_Stop(); }5. 写保护与写周期:最容易踩的两个深坑5.1 WP引脚与写保护机制AT24C02的WP引脚是写保护使能端。WP接高电平时整个存储阵列的写操作被禁止只有读操作正常。WP接低电平或者悬空时才允许写入。这个功能在产品设计中很实用出厂前把关键数据写好然后拉高WP防止运行时被程序意外篡改。但调试阶段如果WP没有明确设置数据写不进去读出来全是0xFF就很让人抓狂。我自己遇到过两次这种问题一次是芯片型号封装不同引脚顺序弄错一次是上拉电阻把WP意外拉高。建议无论调试还是量产WP引脚都要明确接GND或者通过一个10kΩ电阻接地不要悬空靠内部下拉内部下拉是弱下拉抗干扰能力有限。还有一点:WP引脚不是电平触发后立即生效手册标注的是“写保护启用后整页或字节写指令被忽略”真正受影响的是写操作期间因此调试时先用万用表确认WP电平是最省时间的做法。5.2 5ms写周期与轮询等待策略每完成一次字节写或者页写AT24C02内部需要时间把数据写入非易失存储单元这个时间最长是5ms。在写周期内芯片对I2C总线的任何命令都不响应相当于处于“忙”状态。如果写完立即读或者连续写多个字节时第二个字节的起始条件很可能会落在写周期内导致第二个字节丢失。解决思路有两个一个是固定延时5ms以上再继续另一个是ACK轮询。固定延时最简单但会拖慢整体时间特别是在写几十个字节参数块的场景累计延时到了几百毫秒。ACK轮询则优雅很多:写完数据后不断发送起始条件加器件地址写(0xA0)如果芯片还处于写周期不会有ACK返回;等芯片内部完成操作第一个ACK就会出现。此时说明可以继续后续操作。void AT24C02_WaitForWriteComplete(void) { uint8_t ack; do { I2C_Start(); ack I2C_SendByte(0xA0); I2C_Stop(); } while(ack ! 0); }这个函数实测下来非常可靠而且比固定延时效率高一个数量级。需要注意的是轮询时每发一次器件地址后都要产生停止条件不能只靠起始条件加地址试探因为从机响应后状态机会变化正确处理方式就是完整试探完再停止。5.3 掉电场景下的数据校验EEPROM的作用是掉电保存数据但实际掉电场景比理想情况复杂得多。最典型的问题是:设备正在写EEPROM时突然断电数据只写了一半下一次上电读出来的数值既不是旧值也不完整程序逻辑可能因为异常数据直接跑飞。对策是增加数据校验机制。常见的做法是存两套数据:一套主数据区一套备份区每次写操作先写备份再写主区;读数据时先校验主区校验不通过就启用备份区。更简单的方式是存储时追加校验字节或CRC值读取后先校验再使用校验失败就使用默认值。我自己常用的是“魔数数据反码”的结构比如在地址0x00存一个固定魔数0x5A地址0x01开始存参数最后一个字节存所有参数的异或值。上电读取时先验证魔数再验证异或两者都对才认为数据有效。这样做的成本很低但能把掉电写一半导致的数据损坏识别出来。6. 调试实录:三个真实翻车现场6.1 SDA拉不低:上拉电阻与开漏配置有次调试用逻辑分析仪抓I2C波形发现起始条件之后SDA一直保持高电平波形上只有SCL在跳从机完全没响应。查了一圈最后发现GPIO配置成了推挽输出。推挽输出下引脚高电平是主动驱动上去的低电平也是主动拉下来的看起来比开漏还“有劲”怎么就不行呢?问题在于I2C是多设备总线设备间通过拉低总线来通信。开漏模式下引脚只能主动拉低高电平由外部上拉电阻提供这样任何一个设备拉低总线全总线都能感知。推挽模式下设备驱动高电平时如果另一个设备想拉低就会发生直接冲突电平状态变成不确定。把GPIO改成开漏输出之后波形立刻正常。此外还要检查外部上拉电阻是否真的存在。有些开发板把I2C上拉电阻做成跳线或者排针选择出厂默认不焊拿到裸板调试时就容易忽略。记得用万用表确认SCL和SDA对地电压空闲状态都应该是高电平。6.2 读回来的数据全是0xFF:器件地址位问题另一个非常经典的问题:读回的数据全是0xFF看起来像EEPROM是空的。这时如果引脚上拉到VCC空闲状态读出来就是FF说明EEPROM根本没有参与通信主机的读写操作定位错了设备。排查时先确认器件地址。AT24C02的数据手册里给出了地址字节的组成:1010加上A2、A1、A0三位引脚电平加上读写标志。A2、A1、A0如果全都接地写地址0xA0读地址0xA1。如果有人使用的是地址0x50就是把7位地址直接当字节用少了左移一位从机自然没有响应。拿到芯片时还要注意型号的尾巴AT24C02和AT24C04、AT24C08的地址位含义略有区别容量越大用于页选择的引脚位越多。如果不确认具体型号光靠猜地址很容易翻车。最稳妥的办法是先用ACK扫描方式把所有可能地址都发一遍看哪个地址能收到ACK再用那个地址操作。6.3 页写覆盖:连续写16字节丢了一半还有一次是在做参数存储功能需要连续保存一组校准数据总共16字节。我图省事直接调用页写函数地址从0x00开始一次写完16字节。结果读取时发现后半段数据全乱前8字节正常后8字节乱七八糟。原因是16字节跨越了第0页和第1页。页写操作在芯片内部有一个页地址缓冲器写入的第9个字节低3位地址回卷写回页首把前8字节里的某些数据覆盖了。这不是代码逻辑报错能看出来的问题只能通过读取回验才能发现。解决办法就是我在4.3里说的那样:按页拆分写入或者干脆用单字节写。经过那次教训我在自己的代码里加了一个通用函数传入起始地址和长度之后自动判断页边界并分段执行页写。对于AT24C02这种容量很小的芯片某些情况下直接单字节写省心且可靠。7. 几个实测有效的经验细节代码都调通之后有一些经验值得沉淀下来。第一延时函数不要用纯空循环凑时间。我早期写的延时是随便编一个for(i0;i200;i);在不同编译优化级别下时序会有差异换个编译器可能就翻车。后来改成根据系统时钟计算好的延时函数或者用定时器做超时控制时序稳定多了。第二I2C总线上所有设备都遵循“线与”逻辑。任意设备都可以拉低SDA来表示应答或忙状态。调试时用逻辑分析仪比示波器方便因为可以长时间抓取并解码直接看到每个字节和ACK/NACK状态。哪怕是几十块钱的分析仪也比拿着示波器手动数波形省力得多。第三驱动代码和业务代码要分离。把I2C底层时序函数、AT24C02驱动函数、上层应用逻辑分别放不同文件后续换存储芯片或者改成硬件I2C时只替换中间层即可上层业务完全不用动。这种分层习惯在嵌入式项目里越早养成越好。第四产品里做参数存储一定要考虑多次写入的场景。AT24C02虽然擦写寿命标称很高但实际芯片在高温、高电压等极端条件下寿命会打折。频繁刷写参数的产品可以在RAM里先缓存等参数稳定后再写入EEPROM避免每次修改都立刻落盘。第五如果你用的是标准库工程注意把I2C_Delay函数的空循环变量声明为volatile防止编译器优化掉整个循环。以前有人栽在这个坑上代码看起来在延时实际被优化得一干二净时序完全乱套。这类问题一出现波形就会变得莫名其妙排查起来非常费时间。这套流程跑通之后再去看其他I2C设备的数据手册比如传感器、RTC芯片、触摸芯片都会发现套路是一致的:起始条件、器件地址、寄存器地址、数据传输、应答、停止条件。核心的时序逻辑不变变的只是寄存器定义和内部功能把AT24C02这个基础打扎实后面就是举一反三的事了。