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 七位地址与器件寻址:一帧数据怎么定位到AT24C02
I2C寻址用的是7位地址加1位读写标志,组合起来正好一个字节。AT24C02的7位地址前4位是固定的1010,后3位来自硬件引脚A2、A1、A0的电平状态。这3个引脚在芯片内部有下拉电阻,悬空时为0,这在小封装特别是贴片封装里很常见,省去了外部连线。
把A2、A1、A0都接GND,7位地址就是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(i=0;i<200;i++);,在不同编译优化级别下时序会有差异,换个编译器可能就翻车。后来改成根据系统时钟计算好的延时函数,或者用定时器做超时控制,时序稳定多了。
第二,I2C总线上所有设备都遵循“线与”逻辑。任意设备都可以拉低SDA来表示应答或忙状态。调试时用逻辑分析仪比示波器方便,因为可以长时间抓取并解码,直接看到每个字节和ACK/NACK状态。哪怕是几十块钱的分析仪,也比拿着示波器手动数波形省力得多。
第三,驱动代码和业务代码要分离。把I2C底层时序函数、AT24C02驱动函数、上层应用逻辑分别放不同文件,后续换存储芯片或者改成硬件I2C时,只替换中间层即可,上层业务完全不用动。这种分层习惯在嵌入式项目里越早养成越好。
第四,产品里做参数存储,一定要考虑多次写入的场景。AT24C02虽然擦写寿命标称很高,但实际芯片在高温、高电压等极端条件下寿命会打折。频繁刷写参数的产品,可以在RAM里先缓存,等参数稳定后再写入EEPROM,避免每次修改都立刻落盘。
第五,如果你用的是标准库工程,注意把I2C_Delay函数的空循环变量声明为volatile,防止编译器优化掉整个循环。以前有人栽在这个坑上,代码看起来在延时,实际被优化得一干二净,时序完全乱套。这类问题一出现,波形就会变得莫名其妙,排查起来非常费时间。
这套流程跑通之后,再去看其他I2C设备的数据手册,比如传感器、RTC芯片、触摸芯片,都会发现套路是一致的:起始条件、器件地址、寄存器地址、数据传输、应答、停止条件。核心的时序逻辑不变,变的只是寄存器定义和内部功能,把AT24C02这个基础打扎实,后面就是举一反三的事了。