
1. 项目概述与设计思路拆解1.1 这个项目到底在做什么STM32F103 这颗芯片几乎是每个做嵌入式的人都会摸一遍的入门标配而 I2C 又是嵌入式通信里绕过不去的总线协议。把这两样东西攒到一起再配一颗最经典的 AT24C02 EEPROM就成了一个特别适合练手、又特别容易出问题的组合。这个项目的核心就一句话让 STM32F103 通过 I2C 总线往 AT24C02 里写数据、读数据。听起来简单实际上落地的时候会碰到一堆细节——上拉电阻怎么选、开漏模式到底什么意思、地址为什么要左移一位、为什么写完要等 5 毫秒、为什么硬件 I2C 会卡死。这每一个细节在这个项目里都会被真实地逼出来。所以这篇文章的受众很明确刚学完 GPIO 和串口、准备进入 I2C 通信的初学者以及被硬件 I2C 折磨过、准备回头用模拟 I2C 解决问题的老手。我把整个流程从硬件连接到软件实现、再到调试踩坑完整拆给你看代码可以直接抄思路比代码更重要。1.2 为什么选这套方案作为练手项目很多同学学完串口之后会想找一个比串口稍复杂、但又不至于直接上 SDIO 或者 USB 的协议来练手I2C 是绝佳选择。原因很简单I2C 只有两根线SDA 和 SCL逻辑清晰时序简单到可以用 GPIO 手动模拟但又有 ACK 应答、多设备寻址、时序约束这些真实通信协议该有的东西。AT24C02 这颗 EEPROM 是 I2C 设备里最乖的一种。它不需要初始化寄存器不需要复杂的配置流程上电就能访问而且容量只有 2Kbit256 字节逻辑简单非常适合作为第一个 I2C 从设备。写坏了也不心疼反正 100 万次擦写寿命够你折腾很久。选择 STM32F103 还有个现实的理由它的硬件 I2C 外设口碑比较微妙网上吐槽硬件 I2C 会卡死的人一大片。这个槽点反而成了最好的学习素材——你既可以用软件模拟 I2C 把协议彻底吃透也可以尝试调硬件 I2C在错误帧、忙标志、事件标志的泥潭里爬一圈最后对 I2C 的理解会比只看文档深一个量级。这个项目我建议两条路都走一遍。2. 硬件层面的关键细节2.1 接线与引脚规划先看 AT24C02 的引脚。DIP-8 封装一共 8 个脚A0、A1、A2 是地址选择脚一般直接接地WP 是写保护引脚接地才能写入接高电平就只读VCC 接 3.3VGND 接地剩下 SCL 和 SDA 两根线接 STM32。STM32F103 有多个 I2C 外设可用最常用的是 I2C1默认映射在 PB6SCL和 PB7SDA上。当然你也可以用 PB8、PB9 的重映射或者干脆用任意两个 GPIO 做软件模拟。这里有一个很实用的建议如果你打算先跑软件模拟请把 SCL 和 SDA 接到同一个 GPIO 端口上比如 PB6 和 PB7这样操作 GPIO 寄存器的时候能用端口 BSRR、BRR 寄存器一句话完成电平控制效率高代码也清爽。接线表直接给你AT24C02 引脚接法VCC3.3VGNDGNDA0/A1/A2全部接地WP接地SCLSTM32F103 PB6SDASTM32F103 PB7另外SDA 和 SCL 上必须接上拉电阻AT24C02 内部没有上拉STM32 的开漏输出也只是把管子拉低高电平全靠外部上拉。这就是新手最容易忽略的一步——线接完了程序写完了发现通信没反应查半天才发现忘了接上拉电阻。2.2 开漏模式和上拉电阻怎么理解I2C 协议规定总线必须工作在开漏模式这个很多人只记住了结论没搞懂为什么。用大白话说开漏输出就像一个只有接地开关的输出端它只能主动把线拉低想输出高电平的时候它就放手让上拉电阻把线拉回高电平。那为什么不直接用推挽输出两个原因。第一如果两个设备一个想拉高一个想拉低推挽输出会直接短路打架开漏模式下谁都不会主动拉高自然不存在电平冲突。第二开漏模式允许不同的设备工作在 3.3V 或 5V 电平上拉电阻接多少伏高电平就是多少伏这就是 I2C 总线能实现电平兼容的根本原因。上拉电阻的取值一般是 4.7kΩ标准 100kHz 和 400kHz 模式下都能用。如果总线距离比较长、或者线上的等效电容比较大可以换成 2.2kΩ让信号上升沿陡一些。但不建议用太小的 1kΩ会让总线上电流过大推不动小封装芯片也不建议用太大的 10kΩ上升沿会变得很缓高速模式容易出错。这是一个实测优先于理论的选择题——4.7kΩ 是绝大多数情况下的安全起点。2.3 5V 和 3.3V 的电平问题要不要处理AT24C02 的供电范围很宽2.5V 到 5.5V 都能工作。所以很多时候大家会问STM32F103 是 3.3V 供电AT24C02 如果接 5V要不要做电平转换这个问题的本质是I2C 的 SCL、SDA 走的是开漏结构高电平由上拉电阻决定。如果上拉电阻接 3.3V那总线高电平就是 3.3V5V 供电的 AT24C02 把输入高电平阈值定在 0.7 × VCC 3.5V3.3V 就够不到了。反过来如果上拉电阻接 5VSTM32 的 I/O 口是 5V 容忍的理论上能承受但这会推高 3.3V 器件的输入电平长期可靠性存疑。所以最稳妥的方案是给 AT24C02 也用 3.3V 供电上拉也接 3.3V两端电平自然一致完全不需要电平转换芯片。如果你手头只有 5V 版的 AT24C02 模块建议查看模块电路是否自带电平转换没有的话把上拉电阻的供电引到 3.3V 侧让总线电平统一在 3.3V大部分情况下也能正常通信。我在实操中就是这么处理的一切正常。3. AT24C02 的寻址机制与存储结构3.1 存储模型与页边界AT24C02 的存储容量是 2Kbit换算过来就是 256 字节地址范围从 0x00 到 0xFF。它的内部并不是简单地按字节独立操作而是按页来组织的每一页 8 字节。这个页的概念非常重要是新手写 EEPROM 驱动时最容易踩的坑。AT24C02 支持页写入也就是可以一次连续写一页但如果你要写入的数据跨越了页边界情况就变得微妙比如你从 0x06 开始写 4 个字节这 4 个字节会写到 0x06、0x07、0x08、0x09但 0x08 和 0x09 属于下一页。芯片内部写操作会绕过页边界直接把后面的字节回卷写到当前页的开头0x00、0x01而不是你以为的 0x08、0x09。这个回卷行为在数据手册里写得很清楚但你没看手册就写代码的话排查起来会非常崩溃。我的建议是凡是要写的数据超过单字节就老老实实先判断写地址和剩余长度是否在同一页内不在同一页就拆开写。表驱动也好、循环判断也好别偷懒。单字节写没有这个限制随便写。3.2 设备地址是怎么算出来的I2C 总线上挂多个设备时每个设备靠地址区分。AT24C02 的 7 位地址是固定前缀 1010 加上 A2、A1、A0 三个引脚的电平组成完整的 7 位地址。A0、A1、A2 全接地时这个 7 位地址是 0x50。但这里有个经典迷惑点数据手册上写的设备地址是 0xA0跟 0x50 对不上到底怎么回事原因在于 I2C 地址字节是 8 位其中高 7 位是设备地址最低位是读/写标志位。0xA0 1010 0000拆开看就是 7 位地址 0x501010000左移一位再加最低位 0写。所以写操作设备地址字节 0xA0读操作设备地址字节 0xA1这就是为什么很多人看到代码里写用 0xA0、读用 0xA1而手册却写着 0x50。如果你用 HAL 库的HAL_I2C_Mem_Write要注意它接收的是完整的 8 位设备地址也就是要传 0xA0/0xA1这个细节错了ACK 永远等不到。3.3 三种读操作的区别AT24C02 的读操作有三种当前地址读、随机读、顺序读。当前地址读就是直接发设备地址 读标志然后读取当前内部地址指针指向的字节这个指针在每次读写操作后会自动加一。随机读就是我们最常用的先发一个伪写操作把目标地址写进内部指针然后重新发起读操作。顺序读就是在随机读的基础上连续发 ACK芯片会自动递增地址一直读到发了 NACK 为止。实际写驱动的时候随机读是核心我给你一个通俗的理解方式随机读 伪写把地址指针指过去 重启总线 重新发读地址。中间那个重启在 I2C 协议里叫 Repeated Start很多模拟代码直接简化为 Stop 再 Start虽然协议上不完美但实际兼容性没问题。顺手记录一下我自己的模拟 I2C 代码里用的就是 Stop Start 的方式至今没翻过车。4. 软件实现从模拟 I2C 到硬件 I2C4.1 为什么建议先把模拟 I2C 跑通STM32F103 的硬件 I2C 外设确实存在功能也完整但圈内对它有个不怎么友好的评价事件标志多、状态机复杂、收发时序有一定概率卡死在 BUSY 状态。其实这个外设没到不能用的地步大量项目照样跑得好好的只是对新手来说调试门槛偏高。模拟 I2C 就不一样了它的本质是用 GPIO 引脚按软件节奏模拟出 SCL 和 SDA 的时序。每一个时钟沿、每一个 ACK、每一个起止条件都是你自己控制的出问题的时候能非常直观地定位到是哪里卡住了。先跑通模拟 I2C你对协议的理解会比直接调库函数深刻得多。所以我这个项目的第一步就是写一个模拟 I2C 驱动硬件 I2C 作为进阶玩法放在后面。4.2 模拟 I2C 的核心代码模拟 I2C 需要先定义引脚操作。SCL 用推挽输出就可以SDA 需要在读写之间切换方向。这里有个实操技巧用寄存器直接操作别用库函数的 GPIO 读写因为库函数调用开销大在模拟时序时可能引入不可控的延迟。#define SCL_H() GPIOB-BSRR GPIO_PIN_6 #define SCL_L() GPIOB-BRR GPIO_PIN_6 #define SDA_OUT_MODE() GPIOB-CRL ~(0xF 28); GPIOB-CRL | (0x3 28) #define SDA_IN_MODE() GPIOB-CRL ~(0xF 28); GPIOB-CRL | (0x8 28) #define SDA_H() GPIOB-BSRR GPIO_PIN_7 #define SDA_L() GPIOB-BRR GPIO_PIN_7 #define SDA_READ() ((GPIOB-IDR 7) 1)注意 SDA 第 7 位对应 CRL 寄存器的第 28 到 31 位所以配置模式时左移的是 28 位这个偏移量别算错。然后是原子操作static void i2c_delay(void) { volatile uint32_t i; for (i 0; i 20; i); } void I2C_Start(void) { SDA_H(); SCL_H(); i2c_delay(); SDA_L(); i2c_delay(); SCL_L(); } void I2C_Stop(void) { SCL_L(); SDA_L(); i2c_delay(); SCL_H(); i2c_delay(); SDA_H(); }起始条件是 SCL 高电平时 SDA 产生下降沿停止条件是 SCL 高电平时 SDA 产生上升沿。这个顺序错了总线直接没法工作排错时先检查这两个函数。发送字节和读取字节也不难void I2C_SendByte(uint8_t data) { uint8_t i; for (i 0; i 8; i) { SCL_L(); if (data 0x80) SDA_H(); else SDA_L(); i2c_delay(); SCL_H(); i2c_delay(); data 1; } SCL_L(); } uint8_t I2C_ReadByte(uint8_t ack) { uint8_t i, data 0; SDA_IN_MODE(); for (i 0; i 8; i) { SCL_H(); data 1; if (SDA_READ()) data | 0x01; SCL_L(); } SDA_OUT_MODE(); if (ack) SDA_L(); else SDA_H(); SCL_H(); SCL_L(); return data; }读字节的时候第 9 个时钟是应答位主机想继续读就发 ACKSDA 拉低不想读了就发 NACKSDA 释放为高。AT24C02 收到 NACK 后停止输出总线回到空闲主机再发停止条件整个读取过程就收干净了。4.3 AT24C02 的读写函数有了模拟 I2C 的底层AT24C02 的读写就是标准的业务流程。单字节写在前面已经列过这里重点看随机读的完整实现uint8_t AT24C02_ReadByte(uint8_t addr) { uint8_t data; I2C_Start(); I2C_SendByte(0xA0); // 设备地址 写 I2C_SendByte(addr); // 目标存储地址 I2C_Start(); // 重新开始 I2C_SendByte(0xA1); // 设备地址 读 data I2C_ReadByte(NACK); I2C_Stop(); return data; }如果要做页写比如一次写 8 个字节void AT24C02_WritePage(uint8_t addr, const 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(); delay_ms(6); }写完必须延时 5 到 10 毫秒因为 EEPROM 内部擦写需要时间这个延时不够会出现写完读出来是旧数据的诡异现象。我实测掉电保存类项目里写后延时放到 10 毫秒最稳。4.4 硬件 I2C 的配置与注意事顶硬件 I2C 用标准外设库配置也很快主要包括使能时钟、配置 GPIO 为开漏复用输出、配置 I2C 模式为主模式、设置时钟频率。这里直接给你一段能跑的初始化代码void I2C1_Init(void) { GPIO_InitTypeDef gpio; I2C_InitTypeDef i2c; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); gpio.GPIO_Pin GPIO_Pin_6 | GPIO_Pin_7; gpio.GPIO_Mode GPIO_Mode_AF_OD; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, gpio); i2c.I2C_Mode I2C_Mode_I2C; i2c.I2C_ClockSpeed 100000; i2c.I2C_DutyCycle I2C_DutyCycle_2; i2c.I2C_Ack I2C_Ack_Enable; i2c.I2C_AckAddress 0x00; i2c.I2C_OwnAddress1 0x30; I2C_Init(I2C1, i2c); I2C_Cmd(I2C1, ENABLE); }配置里最容易漏的是 GPIO 模式必须配成GPIO_Mode_AF_OD也就是复用开漏模式。很多人沿用控制 LED 的推挽输出习惯直接用GPIO_Mode_AF_PP结果总线拉不低或者拉不高通信直接乱套。开漏这一点软件模拟和硬件外设是一样要求的只是硬件模式下别忘记复用功能的配置。硬件 I2C 的发送流程里事件标志检查是最容易卡住的地方。很多人这么写I2C_GenerateSTART(I2C1, ENABLE); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, 0xA0, I2C_Direction_Transmitter); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED));如果没加超时一旦某个事件没等来程序就死循环卡在里面了。用逻辑分析仪或者示波器看往往是因为上一个操作还残留 BUSY 标志或者外部上拉有问题导致 ACK 没回来。正确做法是给所有 while 等待加一个超时计数超时就返回错误这是硬件 I2C 调试的保命符。如果用的是 HAL 库代码会简洁得多uint8_t data 0x5A; HAL_I2C_Mem_Write(hi2c1, 0xA0, 0x00, I2C_MEMADD_SIZE_8BIT, data, 1, 100); HAL_I2C_Mem_Read(hi2c1, 0xA0, 0x00, I2C_MEMADD_SIZE_8BIT, data, 1, 100);HAL 把事件的繁琐处理封在底层了但代价是如果硬件 I2C 出问题HAL 层的错误码只能告诉你 HAL_ERROR具体卡在哪一步还是要回到寄存器去查。这也是为什么我坚持让你先跑一遍模拟 I2C。5. 调试实录与常见问题排查5.1 常见问题速查表做这个项目的过程中我收集了一堆真实项目中遇到的现象整理成速查表按症状定位原因和解决办法实测很有用。现象可能原因解决办法上电后 SDA 一直被拉低有设备占住总线 / 起始条件时序错误复位总线给 SCL 发 9 个时钟脉冲释放总线发送地址后收不到 ACK设备地址错误 / 上拉电阻没接 / WP 引脚影响芯片状态核对 0xA0/0xA1、检查上拉、示波器看 ACK 位写完数据读回来全是 0xFF写周期未完成 / 实际没写入成功写后延时加到 10ms检查 WP 是否接地写完数据读到错位/回卷页写跨越页边界拆分写入逻辑硬件 I2C 卡死等待事件上次通信未正常结束 / 忘了超时给 while 加超时软件复位 I2C 外设高频率下偶发通信失败上拉电阻过大 / 布线过长换 2.2kΩ 上拉缩短杜邦线长度信号波形是排查 I2C 问题最有力的手段。没示波器的话至少准备一个逻辑分析仪市面上几十块那种 8 通道的就够用。我排过很多查不出原因的案例最后都是逻辑分析仪一看波形立即发现起始条件顺序是反的。新手务必把查看 SDA、SCL 波形列为常规手段不要全靠猜。5.2 页写越界的真实案例有一次做一个小项目需要往 AT24C02 写一批校准参数我偷懒没做页边界判断直接从 0x06 开始连续写 16 个字节。写进去之后读出来前面两个字节是对的后面六个字节全跑到了 0x00 到 0x05当时以为芯片坏了换了芯片还是一样。后来翻手册才发现页回卷机制。从那以后我的所有 EEPROM 驱动都带一个边界拆分函数逻辑很简单先算出当前页剩余空间如果剩余空间不够写完就拆成两段或者多段每段写完延时再续写下一段。别嫌麻烦这几乎是所有 EEPROM 应用的必修课AT24C02 是这样其他同系列芯片比如 AT24C04、AT24C16 也都遵循类似逻辑只是页大小不同。养成这个习惯以后换大容量 EEPROM 也不会栽跟头。5.3 写在最后的一点心得这个项目做完我对 I2C 的理解从背时序图变成了真的知道每一步在干什么。模拟 I2C 让我看到了时序的本质硬件 I2C 让我体验了真实外设的复杂性两者结合比只调库函数学到的东西多得多。建议每个初学者都完整走一遍这个流程。最后再说一个实用小技巧调试 I2C 设备时在代码里加一个总线扫描功能依次向 0x00 到 0x7F 的所有 7 位地址发送读请求看哪个地址返回了 ACK就可以快速确认设备枚举到了哪里、地址到底对不对。这个函数平时用不到但关键时刻比死磕代码效率高太多了。我把它放在项目工程里当调试工具留着谁用谁知道。