☰
STM32F103实战:从零驱动AT24C02 EEPROM,I2C时序与故障排查全解析
2026/10/6 15:40:29 网站建设 项目流程

上次调一块板子,I2C 接口的 AT24C02 死活读不出数据,写进去的字节复位后再读回来全是 0xFF。查了两天,最后发现不是代码逻辑的问题,而是器件地址最高位搞错了。类似这种问题,网上搜“STM32F103 I2C 读写 EEPROM”能搜到一堆代码,但大多数只贴个 main 函数,很少有人把“从时序到总线、从软件模拟到硬件外设、从正常流程到故障排查”的完整链路讲透。这篇文章就是我基于 STM32F103 从零驱动 AT24C02 的全过程拆解,适合刚接触 I2C 和 EEPROM 的嵌入式新手,也适合被硬件 I2C 卡住之后想回头看协议本质的进阶开发者。

我先把结论放在前面:I2C 本身不复杂,真正让新手翻车的往往是三个点——器件地址是七位还是八位、时序里起始/停止/应答的边沿关系、以及写周期那 5ms 延时。下面逐层拆。

1. 为什么拿 AT24C02 来啃 I2C:选型背后的逻辑

1.1 AT24C02 的身份信息:256 字节的“掉电记事本”

AT24C02 是一颗 2Kbit 的串行 EEPROM,换算过来就是 256 字节,每个地址对应 1 字节数据。它最核心的特性是掉电不丢失,设计寿命通常在 100 万次擦写以上,数据保存周期能到 100 年左右。拿它当练习对象,比直接上温湿度传感器、OLED 屏幕要合适得多——因为你只需要关心“能不能把一个字节写进去、再原样读出来”,没有数据转换公式、没有初始化命令序列这些干扰项。

它走 I2C 总线,只需要两根信号线:SCL(时钟)和 SDA(数据),加上 VCC 和 GND 一共四个引脚就能工作。另外还有 A0/A1/A2 三个地址引脚和 WP 写保护引脚。A0/A1/A2 用于设定器件地址,WP 拉高时整个芯片只读,拉低或悬空时正常读写。

很多开发板上 AT24C02 的 WP 引脚默认接地,但如果你从别处买的核心板不带这个芯片、自己外接模块,一定记得确认 WP 的电平。我见过有人把 WP 接到了 VCC,结果写入操作返回正常、读出来却永远是旧数据,排查了一下午。

1.2 一条总线上挂多颗芯片:A0/A1/A2 和七位地址的坑

AT24C02 的器件地址格式是1010 A2 A1 A0 R/W,前四位 1010 是芯片家族固定的,A0/A1/A2 由硬件引脚决定,最低位 R/W 表示读还是写。所以当 A0/A1/A2 全部接地时:

  • 写操作器件地址:1010 000 0,即 0xA0
  • 读操作器件地址:1010 000 1,即 0xA1

这里就是新手最容易踩的第一个坑:I2C 协议里常说“7 位地址”,而 AT24C02 的 0xA0 是“8 位地址”(含方向位)。如果你在某篇帖子里看到“地址是 0x50”,那是它把 7 位地址单独拎出来说了(0xA0 >> 1 = 0x50)。这两个写法都对,但混用就会出问题。我自己的习惯是:操作函数里统一用 8 位地址 0xA0/0xA1,因为发送时本来就要拼上方向位,直接按 8 位写不容易乱。

同一个 I2C 总线上最多可以挂 8 片 AT24C02,靠 A0/A1/A2 的 8 种组合区分。地址引脚悬空时内部可能读到随机电平,所以不要偷懒不接,该接地就接地,该接 VCC 就接 VCC。

1.3 为什么用 STM32F103 而不是其他平台

STM32F103 是学习 I2C 很典型的平台,原因有三。第一,它自带硬件 I2C 外设,但恰恰是这个外设在社区里有不少争议,很多人反映容易卡在 BUSY 状态,这恰好逼着你去理解协议底层。第二,它的 GPIO 支持开漏输出模式,配外部上拉电阻就是标准的 I2C 电气结构,软件模拟 I2C 也很方便。第三,它主频 72MHz,指令速度足够快,用 GPIO 翻转模拟时序时时序裕量充足。

我个人的建议是:第一版先用软件模拟 I2C 把 AT24C02 跑通,再回头碰硬件 I2C。软件模拟能让你把每个字节的波形变化看得清清楚楚,之后再去看外设寄存器、事件标志,就不会觉得那是一堆魔法数字。

2. I2C 协议时序拆解:起始、停止、应答和一个字节的流动

2.1 两条线的规矩:SCL 高电平期间 SDA 必须稳定

I2C 是同步串行协议,SCL 由主机控制,SDA 用来传数据。有一个铁律:SCL 高电平期间,SDA 上的电平必须保持稳定;SDA 只允许在 SCL 低电平期间变化。你可以把 SCL 想象成相机的快门,快门打开时画面(SDA)不能动,快门关闭时才能换底片。这个规则如果不遵守,很容易被从机误判成起始或停止条件。

AT24C02 支持标准模式 100kHz 和快速模式 400kHz。软件模拟时并不严格要求跑满某个速率,但必须保证时序满足芯片手册里的建立保持时间。对新手来说,宁慢勿快,先把逻辑跑通,再考虑提速。

2.2 起始条件与停止条件:看边沿,不看电平

很多初学者把起始条件和停止条件记成“SDA 拉低”“SDA 拉高”,这是不对的。准确说法:

  • 起始条件:SCL 为高电平时,SDA 从高电平跳变到低电平
  • 停止条件:SCL 为高电平时,SDA 从低电平跳变到高电平

也就是说,SCL 保持高电平期间,SDA 的下降沿是 START,上升沿是 STOP。这就是为什么上一节说要“SDA 只能在 SCL 低电平时变化”——如果违反了这个规矩,总线上随时可能冒出伪起始、伪停止,从机状态机就会乱。

空闲状态下,SCL 和 SDA 都应该是高电平,由外部上拉电阻维持。如果上电后量到 SDA 一直是低,说明有器件在占用总线,这在后面故障章节会仔细说。

2.3 应答信号:第 9 个时钟,SDA 被拉低表示“我收到了”

数据是按字节传输的,每个字节 8 位,后面必须跟一个应答位。发送方在第 9 个时钟周期释放 SDA(拉高),接收方如果正常收到数据,就会把 SDA 拉低,表示 ACK;如果不拉低,就是 NACK。

对 AT24C02 来说,如果主机发送的器件地址没有匹配到任何从机、或者寄存器地址超范围,芯片就不会应答,SDA 保持高电平。后面读回来 0xFF 这种经典故障,本质就是主机在地址阶段收到 NACK 之后,读数据阶段读到的 SDA 一直为高,自然全是 0xFF。

读操作里,最后一个字节读取完成后,主机需要回一个 NACK 再发 STOP,表示“后面不用再传了”。如果主机一直回 ACK,AT24C02 会以为你还要继续读,地址指针会一直递增下去,直到走完 256 字节循环回去,这个细节在写连续读函数时尤其重要。

2.4 一次完整写读操作的字节序列

我用一张表把 AT24C02 的基础操作序列整理出来,后面写代码就是照着这个序列逐拍实现:

操作完整字节序列
写 1 字节START + 0xA0 + 寄存器地址 + 数据 + STOP
当前地址读START + 0xA1 + 读数据(发NACK) + STOP
指定地址读START + 0xA0 + 寄存器地址 + 重启START + 0xA1 + 读数据(发NACK) + STOP
页写START + 0xA0 + 寄存器地址 + 数据0 + 数据1 + ... + STOP
连续读START + 0xA0 + 寄存器地址 + 重启START + 0xA1 + 读字节0(ACK) + ... + 读字节N(NACK) + STOP

指定地址读里那个“重启START”就是重复起始条件(Restart),它本质上又是一个起始条件,但不先发停止。Restart 的作用是让主机在不释放总线的情况下切换传输方向,避免两次操作之间其他主机抢占总线。

3. 软件模拟 I2C:完整代码实现与逐行逻辑

3.1 为什么第一版坚持用 GPIO 模拟

我见过不少新手一上来就照着 CubeMX 生成硬件 I2C 代码,跑通了还好,一旦卡在 BUSY 位那个死循环里,就完全不知道发生了什么。软件模拟虽然看起来“低级”,但每个函数的执行都对应着明确的波形变化,出问题可以直接用逻辑分析仪对照代码逐行查。等你把软件模拟这套逻辑彻底吃透了,再去看硬件 I2C 的事件状态机,理解成本会低很多。

下面这段代码基于标准外设库,用 PB6 做 SCL、PB7 做 SDA。如果你用的是 HAL 或者寄存器版,逻辑完全一样,换一下引脚操作宏即可。

3.2 底层时序函数:每个函数对应一段波形

先看引脚初始化和延时。GPIO 要配成开漏输出,因为 I2C 总线依赖外部上拉电阻实现高电平,开漏模式下引脚只能主动拉低,不能主动输出高,这是电气层面的硬性要求:

#define I2C_SCL_PIN GPIO_Pin_6 #define I2C_SDA_PIN GPIO_Pin_7 #define I2C_GPIO_PORT GPIOB #define I2C_SCL_HIGH() GPIO_SetBits(I2C_GPIO_PORT, I2C_SCL_PIN) #define I2C_SCL_LOW() GPIO_ResetBits(I2C_GPIO_PORT, I2C_SCL_PIN) #define I2C_SDA_HIGH() GPIO_SetBits(I2C_GPIO_PORT, I2C_SDA_PIN) #define I2C_SDA_LOW() GPIO_ResetBits(I2C_GPIO_PORT, I2C_SDA_PIN) #define I2C_SDA_READ() GPIO_ReadInputDataBit(I2C_GPIO_PORT, I2C_SDA_PIN) void I2C_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = I2C_SCL_PIN | I2C_SDA_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; // 开漏输出 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(I2C_GPIO_PORT, &GPIO_InitStructure); I2C_SDA_HIGH(); I2C_SCL_HIGH(); } void I2C_Delay(void) { volatile uint8_t i = 30; while (i--) ; }

延时函数我故意用了 volatile 局部变量,防止编译器优化掉循环。在 72MHz 主频下,实测这个延时大约在 2 微秒左右,足够满足 100kHz 模式的要求。如果你换了主频或者开了优化等级,最好拿逻辑分析仪确认一下波形占空比。

然后是起始、停止、单字节收发、应答这些核心原语:

void I2C_Start(void) { I2C_SDA_HIGH(); I2C_SCL_HIGH(); I2C_Delay(); I2C_SDA_LOW(); // SCL 高电平期间 SDA 拉低 => 起始条件 I2C_Delay(); I2C_SCL_LOW(); // 拉低 SCL 准备发送数据 I2C_Delay(); } void I2C_Stop(void) { I2C_SDA_LOW(); I2C_SCL_HIGH(); I2C_Delay(); I2C_SDA_HIGH(); // SCL 高电平期间 SDA 拉高 => 停止条件 I2C_Delay(); } void I2C_SendByte(uint8_t dat) { uint8_t i; for (i = 0; i < 8; i++) { if (dat & 0x80) I2C_SDA_HIGH(); else I2C_SDA_LOW(); dat <<= 1; I2C_Delay(); I2C_SCL_HIGH(); // 数据稳定后拉高 SCL,从机在上升沿采样 I2C_Delay(); I2C_SCL_LOW(); // 拉低 SCL,允许 SDA 变化 I2C_Delay(); } } uint8_t I2C_RecvByte(void) { uint8_t i, dat = 0; I2C_SDA_HIGH(); // 释放 SDA,让从机控制电平 for (i = 0; i < 8; i++) { dat <<= 1; I2C_SCL_HIGH(); I2C_Delay(); if (I2C_SDA_READ()) dat |= 0x01; // 在 SCL 高电平期间读 SDA I2C_SCL_LOW(); I2C_Delay(); } return dat; } uint8_t I2C_WaitAck(void) { uint8_t timeout = 0; I2C_SDA_HIGH(); // 释放 SDA,等待从机拉低 I2C_Delay(); I2C_SCL_HIGH(); I2C_Delay(); while (I2C_SDA_READ()) // 如果一直是高,说明从机没应答 { if (++timeout > 250) { I2C_SCL_LOW(); return 1; // 返回 1 表示无应答 } } I2C_SCL_LOW(); I2C_Delay(); return 0; // 返回 0 表示收到 ACK } void I2C_Ack(void) { I2C_SDA_LOW(); // 主机拉低 SDA,表示应答 I2C_Delay(); I2C_SCL_HIGH(); I2C_Delay(); I2C_SCL_LOW(); I2C_Delay(); I2C_SDA_HIGH(); // 释放总线 } void I2C_NAck(void) { I2C_SDA_HIGH(); // 主机保持 SDA 高,表示非应答 I2C_Delay(); I2C_SCL_HIGH(); I2C_Delay(); I2C_SCL_LOW(); I2C_Delay(); I2C_SDA_HIGH(); }

这段代码里有一个我特意加的防护:I2C_WaitAck里设置了超时计时。如果从机没有应答,SDA 会一直保持高电平,没有超时的while循环会把程序卡死。这个习惯来自一次真实事故——当时器件地址写错,程序就卡在等待 ACK 那里,连调试器都差点没法停住。

3.3 业务层函数:把时序序列翻译成 AT24C02 操作

有了底层原语,AT24C02 的单字节读写就是按第 2 章的序列拼装:

void AT24C02_WriteByte(uint8_t addr, uint8_t dat) { I2C_Start(); I2C_SendByte(0xA0); // 器件地址,写方向 I2C_WaitAck(); I2C_SendByte(addr); // 寄存器地址 I2C_WaitAck(); I2C_SendByte(dat); // 要写入的数据 I2C_WaitAck(); I2C_Stop(); delay_ms(10); // 等待内部写周期完成,至少 5ms } uint8_t AT24C02_ReadByte(uint8_t addr) { uint8_t dat; I2C_Start(); I2C_SendByte(0xA0); // 先发写方向,把寄存器地址告诉芯片 I2C_WaitAck(); I2C_SendByte(addr); I2C_WaitAck(); I2C_Start(); // 重复起始条件 I2C_SendByte(0xA1); // 切换为读方向 I2C_WaitAck(); dat = I2C_RecvByte(); I2C_NAck(); // 读最后一个字节后主机发 NACK I2C_Stop(); return dat; } void AT24C02_ReadBuffer(uint8_t addr, uint8_t *buf, uint8_t len) { uint8_t i; I2C_Start(); I2C_SendByte(0xA0); I2C_WaitAck(); I2C_SendByte(addr); I2C_WaitAck(); I2C_Start(); I2C_SendByte(0xA1); I2C_WaitAck(); for (i = 0; i < len; i++) { buf[i] = I2C_RecvByte(); if (i < len - 1) I2C_Ack(); // 前 N-1 字节回 ACK else I2C_NAck(); // 最后一个字节回 NACK } I2C_Stop(); }

AT24C02_ReadByte是新手最容易写错的地方:忘记了先发寄存器地址、直接用 0xA1 去读,结果读到的是“当前地址指针”指向的内容,完全不是你想要的地址。正确做法是先发 0xA0 写入寄存器地址,再用重启 START 切到读方向。

3.4 页写入和跨页边界:8 字节一页的隐藏规则

AT24C02 的页大小为 8 字节。页写操作可以一次写入最多 8 字节,前提是这些字节落在同一页内。如果跨页了,芯片内部的地址计数器只会在一个页内循环,跨过页边界时会回绕到页首,把前面已经写过的数据覆盖掉。

这就是为什么网上很多人说“连续写超过 8 个字节就丢数据”,其实不是丢,是被页内回绕覆盖了。安全写法是:写之前先算当前页还剩多少字节,按页拆分:

void AT24C02_WritePage(uint8_t addr, uint8_t *buf, uint8_t len) { uint8_t i; I2C_Start(); I2C_SendByte(0xA0); I2C_WaitAck(); I2C_SendByte(addr); I2C_WaitAck(); for (i = 0; i < len; i++) { I2C_SendByte(buf[i]); I2C_WaitAck(); } I2C_Stop(); delay_ms(10); } void AT24C02_WriteMulti(uint8_t addr, uint8_t *buf, uint16_t len) { uint8_t pageRemain = 8 - (addr % 8); // 当前页剩余字节数 uint8_t partLen = (len < pageRemain) ? len : pageRemain; uint8_t sent = 0; while (len > 0) { AT24C02_WritePage(addr + sent, buf + sent, partLen); len -= partLen; sent += partLen; partLen = (len < 8) ? len : 8; // 后续每页最多写 8 字节 } }

这个AT24C02_WriteMulti的写法是关键。比如要往地址 5 写入 10 字节,第一页剩余 3 字节(地址 5/6/7),先写 3 字节;剩下的 7 字节从地址 8 开始写,正好一页装下。如果不拆,地址 5 写满 8 字节后,最后两个字节会回绕到地址 0 和 1,把之前的数据冲掉。

4. 硬件 I2C 外设实测:事件标志、BUSY 锁死这些坑是怎么来的

4.1 正常初始化:复用开漏加外设时钟

如果你坚持用 STM32F103 的硬件 I2C 外设,配置本身并不复杂。PB6/PB7 要配成复用开漏模式,然后开 I2C1 外设时钟,设置速率和地址模式:

void I2C_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; I2C_InitTypeDef I2C_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); 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); I2C_InitStructure.I2C_Mode = I2C_Mode_I2C; I2C_InitStructure.I2C_ClockSpeed = 100000; // 标准模式 100k I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2; I2C_InitStructure.I2C_Ack = I2C_Ack_Enable; I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit; I2C_InitStructure.I2C_OwnAddress1 = 0x30; I2C_Init(I2C1, &I2C_InitStructure); I2C_Cmd(I2C1, ENABLE); }

这里有一个很多人忽略的点:STM32F103 的 I2C 引脚复用。默认情况下 PB6/PB7 不一定是 I2C1 的复用功能,你需要确认 AFIO 映射。不过 F103 的 I2C1 就在 PB6/PB7 上,不做重映射时直接配 AF_OD 就能用。

4.2 EV5/EV6/EV7 事件轮询的等待陷阱

标准外设库里最常见的写法是发送起始条件之后,死等事件标志:

I2C_GenerateSTART(I2C1, ENABLE); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); // EV5 I2C_Send7bitAddress(I2C1, 0xA0, I2C_Direction_Transmitter); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); // EV6

理论上没问题,实际工程里风险很大。EV5 表示起始条件已发送,EV6 表示地址已发送并收到 ACK,EV7/EV8 分别对应接收/发送缓冲区事件。问题在于:如果你的主循环里还有其他中断、或者刚好和 DMA 传输撞在一起,事件标志可能被外设内部状态机的某些转换跳过,CheckEvent会一直返回 false,程序死等。

我遇到过一次很诡异的现象:直接在 main 里按顺序调用这段代码没问题,放进定时器中断回调里就一次都跑不通,加打印发现 EV6 永远等不到。后来查资料才知道,硬件 I2C 在有些异常场景下会进入一种“事件丢失”的状态,需要清标志或者完全复位外设才能恢复。

我的处理办法是给每个等待加超时:

uint32_t timeout = 0xFFFF; while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)) { if (--timeout == 0) { I2C_SoftwareResetCmd(I2C1, ENABLE); // 超时则软件复位 I2C_SoftwareResetCmd(I2C1, DISABLE); I2C_Cmd(I2C1, ENABLE); return 1; // 返回失败 } }

4.3 BUSY 位锁死:为什么说 F103 硬件 I2C 让人又爱又恨

硬件 I2C 最著名的坑是I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY)一直为 SET,导致主函数里发个起始条件都发不出去。常见触发场景:从机异常复位后 SDA 被拉低、主机在收到 NACK 后没有正确发 STOP、或者上电时序里 SCL/SDA 就处于不确定状态。

外设手册里有软件复位位 SWRST,置 1 再清 0 可以复位 I2C 模块全部寄存器,这是最常用的恢复手段:

void I2C_Bus_Reset(void) { I2C_SoftwareResetCmd(I2C1, ENABLE); I2C_SoftwareResetCmd(I2C1, DISABLE); I2C_Cmd(I2C1, ENABLE); // 重新初始化时序参数 // I2C_Init(...); }

如果 SWRST 之后 BUSY 还是忙,问题往往不在外设内部,而在总线上有设备一直拉低 SDA。这时候需要用 GPIO 手动模拟 9 个 SCL 时钟脉冲,把处于异常状态的从机状态机“踢”出来——这根总线没法自动恢复,因为 I2C 本身没有复位线。

4.4 我最终怎么选:两种方案的分工

被硬件 I2C 折腾过几次之后,我现在的工作习惯是这样的:

  • 简单读写几个字节、系统里 I2C 设备不超过两个、主循环不忙:用软件模拟,代码短、逻辑透明、好排查
  • 数据量较大、要用 DMA 自动搬运、或者对 CPU 占用率有硬性要求:用硬件 I2C + DMA + 超时保护,绝不用裸死等事件标志
  • 总线上挂着多个 I2C 设备、速率要跑到 400kHz:用硬件 I2C,软件模拟很难把时序控制得那么精准

不是说硬件 I2C 不能用,而是它要求你对外设状态机有完整理解,并且必须把所有等待环节都加上超时保护。没有超时保护的硬件 I2C 代码,在意外场景下就是一颗定时炸弹。

5. 读回 0xFF、连续写入丢数据、总线卡死:三个故障的真实排查过程

5.1 读回全是 0xFF:先怀疑地址,而不是先怀疑芯片

那天晚上我把 AT24C02 的写入函数跑了一遍,读回来全是 0xFF,第一反应是芯片坏了,换了三颗都一样。后来静下心用逻辑分析仪抓了波形,发现主机发的地址字节根本不是 0xA0,而是 0x50。

原因是我参考的一个早期代码里用了“7 位地址 0x50”,而我在另一个头文件里又按“8 位地址 0xA0”做了宏定义,两套定义混用后,发送函数里又做了一次右移,最终发到总线上的地址变成了 0x50 的完整形式 0xA0 的一半偏移量。AT24C02 收到不匹配的地址,自然不回 ACK,后续读数据的 9 个时钟里 SDA 都是高,读回来当然全是 0xFF。

排查路径我按这个顺序走:

  1. 量 VCC 和 GND,排除供电问题
  2. 用逻辑分析仪抓 SDA/SCL,看起始条件是否存在
  3. 看地址字节是不是 0xA0 或 0xA1,注意七位/八位写法
  4. 看芯片有没有回 ACK,没有就是地址不匹配
  5. 确认 A0/A1/A2 引脚实际电平和你代码里预期的一致

最后一条也容易忽略:如果你代码用的是 0xA0,但 A0 引脚接的是 VCC,那么实际器件地址变成了 0xA2,照样不回 ACK。

5.2 连续写入丢数据:写周期延时不够,不是总线问题

另一个高频故障是:单字节读写都正常,连续写 50 个字节后,读回来只有前几个是对的,后面一大片是旧值。排查了地址、页写入、上拉电阻都没问题,最后加了打印发现是写周期延时不够。

AT24C02 每次写入(包括页写)之后,内部需要大约 5ms 时间把数据真正烧进存储阵列。在这 5ms 内,芯片不响应任何 I2C 命令。如果你紧接着就发下一条写命令,芯片还没准备好,命令直接丢掉。

我最初的代码每写完一个字节只延时 1ms,连续写 100 字节时,后半段几乎全丢。改成 10ms 后全部正常。这里有个经验:手册上的 5ms 是典型值,量产批次差异、环境温度变化都会让实际值偏大,延时取 10ms 更稳妥。如果你用页写,一次写 8 字节后等 10ms,比逐字节写快很多。

5.3 SDA 被拉低、总线卡死:9 个时钟脉冲的强制恢复

第三种情况是写操作过程中程序跑飞或者被调试器打断,总线停在一个奇怪的中间状态。这时候用万用表量 SDA,发现一直是低电平,SCL 正常。这就是典型的“某个从机把 SDA 锁住了”,因为 I2C 协议规定 SDA 的电平状态由从机状态机决定,主机如果没走完 STOP 序列就停止操作,从机会一直认为数据传输还没结束。

我常用的恢复办法是把 SCL 对应的引脚临时配成普通推挽输出,手动翻转 9 次时钟,同时保持 SDA 为高,把从机内部的字节计数器走完,让它释放 SDA:

void I2C_Recover_Bus(void) { uint8_t i; GPIO_InitTypeDef GPIO_InitStructure; // 先把 SCL 配成推挽输出 GPIO_InitStructure.GPIO_Pin = I2C_SCL_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(I2C_GPIO_PORT, &GPIO_InitStructure); for (i = 0; i < 9; i++) { I2C_SCL_LOW(); I2C_Delay(); I2C_SCL_HIGH(); I2C_Delay(); } I2C_Stop(); }

这个“9 个时钟”不是随便定的,I2C 规范规定从机最多在 8 位数据后输出 1 个应答位,发 9 个时钟能保证无论从机停在字节的哪个位置,都能把它推到释放 SDA 的状态。执行完再重新初始化 GPIO 配置、重新初始化 I2C 外设或软件模拟状态,总线就能恢复。

6. 电路层面值得注意的四件事:上拉电阻、去耦、WP 与电平匹配

6.1 上拉电阻选型:4.7k 起步,400kHz 换小阻值

I2C 总线的 SDA 和 SCL 都必须接外部上拉电阻。电阻太小,灌入电流大,会拉低低电平的噪声容限;电阻太大,上升沿变缓,400kHz 模式下波形就不合格了。我整理的选型建议如下:

场景建议阻值说明
标准模式 100kHz,短走线4.7k - 10k最常见组合,兼容性最好
快速模式 400kHz2.2k - 4.7k上升沿要足够陡峭
总线挂多个从机或线缆较长1k - 2.2k用示波器实测边沿,别盲目加大
低功耗常电应用10k - 100k漏电流更小,但对上升沿要求高的别用

STM32F103 和 AT24C02 都是标准电压器件,3.3V 供电时各加一颗 4.7k 上拉到 3.3V 是最稳的起点。如果你后续要在总线上挂 0.9 寸 OLED、AS5600 这类模块,它们内部可能自带弱上拉,多设备并联时等效上拉会变小,实测波形如果出现振铃,再针对性调整。

6.2 去耦电容与 WP 引脚的隐藏坑

AT24C02 的 VCC 和 GND 之间要放一颗 0.1µF 陶瓷电容,尽量靠近芯片电源脚。这不是摆设,EEPROM 在写周期内部会切换电荷泵,瞬间电流比较大,没有去耦电容可能导致邻近的模拟电路看到电压毛刺。

WP 引脚的功能之前提过:高电平写保护。有些 AT24C02 模块上的 WP 已经通过跳线或电阻接到 GND,有些则悬空。悬空状态是低电平还是高电平取决于内部设计和外部电路,不要想当然。我手里的模块就遇到过 WP 悬空时被拉高,导致写操作表面成功、实际没写入的情况,把 WP 明确接地后一切正常。

6.3 电平匹配:同一根总线上混接 3.3V 和 5V 器件

STM32F103 的 GPIO 是 3.3V 逻辑,而老版本 AT24C02 的数据手册供电范围是 4.5V 到 5.5V。如果你手上的板子直接给芯片供 5V,那 STM32F103 的 GPIO 开漏高电平经过上拉电阻到 3.3V 后,能不能被 5V 的 AT24C02 识别为高电平,需要确认芯片手册里的 VIH 参数;反过来,AT24C02 的 SDA 输出高电平是 5V,直接灌进 STM32F103 的引脚又可能超压。

最省心的做法是选宽电压版本的 EEPROM(比如支持 1.8V 到 5.5V 的新型号),全部用 3.3V 供电。如果只能混接,就需要加电平转换电路或者专用的 I2C 电平转换芯片,别直接硬连。

6.4 PCB 走线的小建议

I2C 信号本身速率不算高,但也不是随便拉两根飞线就万事大吉。SDA 和 SCL 尽量短,别跟 PWM 输出、电机驱动线近距离平行走线,否则高频干扰会耦合到时钟线上,造成额外跳变。如果走线确实长,可以在靠近主控的位置各串联一颗 33Ω 的小电阻,配合上拉电阻抑制振铃。

我个人最推荐的做法是:先在最简单的两块板子之间用杜邦线跑通软件模拟,再用逻辑分析仪确认时序,最后才画 PCB。这样即便后面硬件有问题,也有清晰的参照物可以对比。

7. 写在最后:这套东西还能怎么扩展

把 AT24C02 的基础读写跑通之后,I2C 协议的理解已经够用一阵子了。后续可以往三个方向延伸:一是把总线上挂更多设备,把地址管理、总线仲裁搞清楚;二是换用硬件 I2C 加 DMA,体验一次外设中断、DMA 完成中断配合的状态机设计;三是给这套代码加一层抽象,把 I2C 设备读写封装成统一接口,后面换驱动 OLED、温湿度传感器或者读 AS5600 位置传感器,都能直接复用这套底层的时序逻辑和排查思路。

我踩过的那些坑——七位八位地址混用、页边界覆盖、写周期延时不足、硬件 I2C 事件死等——其实都不复杂,只是分散在手册的不同角落里。把它们整理成这篇实操记录,希望能让后来的人少熬几个夜。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询