ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32F103实战:从零驱动AT24C02 EEPROM,I2C时序与故障排查全解析

2026/10/6 15:40:38 拓冰建站 浏览量
STM32F103实战:从零驱动AT24C02 EEPROM,I2C时序与故障排查全解析 上次调一块板子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表示“后面不用再传了”。如果主机一直回 ACKAT24C02 会以为你还要继续读地址指针会一直递增下去直到走完 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 表示地址已发送并收到 ACKEV7/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。排查路径我按这个顺序走量 VCC 和 GND排除供电问题用逻辑分析仪抓 SDA/SCL看起始条件是否存在看地址字节是不是 0xA0 或 0xA1注意七位/八位写法看芯片有没有回 ACK没有就是地址不匹配确认 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 为高把从机内部的字节计数器走完让它释放 SDAvoid 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 事件死等——其实都不复杂只是分散在手册的不同角落里。把它们整理成这篇实操记录希望能让后来的人少熬几个夜。