
搞嵌入式的人迟早都会跟 I2C 打交道。STM32F103 跑通了 I2C 读写 AT24C02基本上就把协议里最常用的那套东西摸透了起始停止条件、地址应答、连续读写、页写限制、写周期延时……后面不管你是去驱动 OLED、传感器还是外部存储核心都是同一套东西。这篇就把我实际调试这个项目的过程完整拆一遍从硬件接线到代码逻辑再到踩坑现场适合正在学 STM32 的朋友也适合已经把板子点亮、但对 I2C 总感觉“差点意思”的开发者。1. 项目定位与选型思路1.1 为什么 I2C一根总线挂多个设备很多第一次接触 I2C 的人都会问串口和 SPI 也能传数据为什么非要用 I2C答案其实很实际I2C 只需要两根线一根时钟线SCL和一根数据线SDA而且所有挂在总线上的设备都是并联在这两根线上靠不同的从机地址来区分彼此。这个特性在嵌入式项目里非常诱人。一个 STM32F103 芯片GPIO 资源本来就很紧张如果用 SPI 接多个设备每个设备至少要占一根片选线三个设备就是三条 CS。而 I2C 不管挂几个设备两根线就够了。你一个项目里既要用 EEPROM 存参数又要挂 OLED 屏幕显示信息I2C 总线就是那个把大家串起来的低成本方案。I2C 的工作模式也很适合板级通信。主设备是 STM32F103从设备是各种外设芯片通信速率一般选标准模式 100kbps 或者快速模式 400kbps。对于 EEPROM、OLED、温湿度传感器这类设备这个速率绰绰有余。你不需要像 SPI 那样还要考虑 MISO、MOSI 分方向I2C 是半双工靠地址帧里的读写位来决定数据流动方向协议层面自解释写起来很直观。1.2 为什么选 AT24C02协议简单、坑还特别典型AT24C02 是 Atmel现在归 Microchip出的 2Kbit 串行 EEPROM也就是说内部有 2048 个 bit按 8 bit 一个字节排列正好 256 个字节。这个容量放在今天看确实不大但作为学习 I2C 协议的载体它有一个天然优势协议足够简单坑又足够典型。从协议层面看AT24C02 只有四类操作写字节、页写、读当前地址、读指定地址包含连续读。每一类操作都对应 I2C 协议里最经典的那几个组合你把这几类操作吃透了I2C 主模式通信的基本功就算练出来了。相比去啃一上来就让你配置寄存器数组的传感器AT24C02 的时序可以说是“人畜无害”的入门标杆。从工程角度看256 个字节虽然不大但存一组校准参数、一组 WiFi 配置、几十条开关机日志完全够用。而且 EEPROM 和 Flash 有个重要区别EEPROM 按字节擦写不需要像片上 Flash 那样按扇区整块擦除。这就让它很适合存那些需要频繁小量更新的数据比如设备的运行状态、掉电需要保留的计数值、用户设置的阈值。寿命方面AT24C02 的写寿命典型值在 100 万次左右读操作不限次数正常设备生命周期内不太容易写穿。1.3 硬件地址与写保护引脚AT24C02 的 I2C 从机地址是 0x507 位地址在 8 位总线地址里写成 0xA0读方向是 0xA1。这个地址不是写死的芯片的 A0、A1、A2 三个引脚可以改。如果三个引脚都接地地址就是 0x50把 A0 拉高地址就变成 0x51。一块总线上最多可以挂 8 片 AT24C02通过不同的 A0/A1/A2 组合来区分这也是 I2C 设备地址配制的典型摸式。说完地址必须重点提醒 WP 引脚。WP 是写保护引脚把它接到高电平整个芯片就变成只读你写任何数据都会失败而且是无声失败——设备照样 ACK但数据根本没进存储阵列读回来还是旧值。我见过不止一个新手代码写得完全没问题就是 WP 脚悬空没接结果写入全部失败。注意WP 悬空在一些芯片上内部默认是低电平也就是允许写入但不同批次可能行为不一致强烈建议硬件上直接拉低到 GND彻底关掉这个变量。2. 硬件准备与最小系统搭建2.1 STM32F103 最小系统要点做这个项目你首先得有一块能跑起来的 STM32F103 核心板或者自己搭一个最小系统。STM32F103 最小系统包含几个必需品3.3V 供电、8MHz 无源晶振芯片内部 PLL 倍频到 72MHz、复位电路、BOOT0 引脚拉低到 GND、以及 SWD 调试接口。这个清单里最容易出问题的其实是晶振。STM32F103 的外部高速晶振推荐带两个负载电容常见配法是一对 20pF 电容分别从晶振两端接到 GND。电容配得不对晶振要么不起振要么起振后频率偏得离谱。判断晶振是否正常工作最直接的办法是拿示波器点 OSC_OUT 引脚能看到稳定的正弦波就说明起振了没有示波器的话也可以用 Keil 调试查看 SystemCoreClock 变量的值如果是 72000000说明 PLL 链路没问题。BOOT0 的配置也要注意。BOOT0 拉高的话芯片复位后会从系统存储器启动进入下载模式程序能下载但一上电不跑你的代码新手很容易被这个状态搞懵。正确做法是 BOOT0 接一个 10k 下拉电阻到 GNDBOOT1 随意默认进 Flash 启动。SWD 调试接口只需要 SWDIO、SWCLK 两根线再加上 GND用 ST-Link 就能烧录和在线调试不用额外接一堆线。2.2 5V 转 3.3V 供电与上拉电阻计算STM32F103 的工作电压是 2.0V 到 3.6V主流板子都按 3.3V 供电来设计。如果你的开发板或者面包板是从 USB 的 5V 取电那就需要一个 5V 转 3.3V 的电路。最简单的方案是 AMS1117-3.3 这类 LDO输入 5V输出 3.3V输入输出各加一个 0.1μF 到 10μF 的电容滤波输出引脚再并联一个 10μF 的钽电容保证瞬态响应。AMS1117 的压差大约 1V5V 转 3.3V 绰绰有余。有一点我要特别强调绝对不要把模块或传感器的 VCC 直接接到 STM32F103 的 3.3V 引脚上除非你确认那个模块是 3.3V 供电的。很多 OLED 模块、传感器模块上面印着 VCC 和 GND实际工作电压是 3.3V但有些扩展板上带了 5V 转 3.3V 的 LDO接了 5V 也没事。接线前务必看清模块丝印说明不然烧芯片只是几秒钟的事。I2C 总线还需要上拉电阻而且是必须的。因为 STM32 的 I2C 引脚配置为开漏输出开漏模式下只能主动拉低拉高要靠外部上拉电阻完成。电阻值的选择有讲究太大会导致上升沿太慢波形圆圆滚滚高速模式下直接通信失败太小又会导致灌电流过大引脚吃不消。计算上拉电阻的经验方法是看上升沿时间约束。I2C 规范规定快速模式 400kbps 下上升沿最大 300ns。上升沿时间近似为 tR 0.8473 × R × Cb其中 Cb 是总线电容典型场景下几个器件大约 50pF 到 100pF。代入 R 4.7kΩ、Cb 50pF 算一下tR ≈ 0.8473 × 4700 × 50e-12 ≈ 199ns满足 300ns 要求。而到了 100kbps 标准模式上升沿限制放宽到 1000ns10kΩ 也可以用。上拉电阻适用场景备注1kΩ极少用灌电流太大约 3.3mA长期用发热4.7kΩ最常用标准/快速模式皆可默认选它10kΩ低速、短距离100kbps 可用线长超过 20cm 波形会变差实际做项目时只要板子上的 I2C 走线不超过 10cm器件就一两个直接上 4.7kΩ 准没错。注意如果你的开发板是那种自带 OLED 排母的很多模块上自己已经焊了上拉电阻接到同一总线上再额外加上拉电阻相当于并联阻值会变小一般不会出问题但如果你发现 I2C 波形异常可以先看看是不是上拉电阻太低导致灌电流过大。2.3 实际接线与实验板方案我的实验环境是一块 STM32F103ZET6 核心板加面包板用杜邦线连接 AT24C02 模块。连接关系很简单引脚连接对象说明PB6AT24C02 SCLI2C1 时钟线用硬件 I2C 时的默认引脚软件模拟也可自定义PB7AT24C02 SDAI2C1 数据线3.3VAT24C02 VCC供电GNDAT24C02 GND共地GNDAT24C02 WP写保护拉低允许写入GNDAT24C02 A0/A1/A2地址固定为 0x50杜邦线的质量直接影响 I2C 波形。面包板上的杜邦线如果接触不良SDA 线上的毛刺会导致起始条件被误判。我在这个项目里踩过一个大坑用了两根很长的母对母杜邦线接 SDA 和 SCL结果上升到沿明显变圆400kbps 下完全通信不了降到 100kbps 才勉强工作。后来换成 10cm 以内的短杜邦线问题直接消失。所以如果你是面包板上做实验尽量把线剪短或者用排线直接在排母上跳接。3. 软件方案先选对路再写代码3.1 硬件 I2C 与软件模拟 I2C 怎么选聊到 STM32F103 的 I2C绕不开一个“硬件 I2C”和“软件模拟 I2C”的选择题。老实说STM32F103 的硬件 I2C 外设口碑两极分化有人说它好用有人说它把自己锁死。客观讲硬件 I2C 本身是有能力完成主模式通信的但它的寄存器设计和库函数接口比较绕加上早期的固件库有一段“臭名昭著”的 Bug 历史导致很多人一上来就被 EV5、EV6、EV7 这些事件标志搞到怀疑人生。对比来看软件模拟 I2C 就是用 GPIO 手动翻转时钟、发送数据。一个字节的收发大概几十个 GPIO 操作在 72MHz 主频下每一拍也就零点几微秒即便加上延时代码通信速率也足够跑在 100kbps 量级。软件模拟的优点是逻辑完全透明时序走到哪一步你心里一清二楚出问题只需要看一个 GPIO 的状态就行不需要去解事件标志。对比项硬件 I2C软件模拟 I2C调试难度需要理解事件标志、错误标志新手容易卡死GPIO 状态一目了然配合示波器/逻辑分析仪直接看线稳定性正确配置后很稳但踩坑时需要查半天只要延时小函数没问题基本一次过移植性绑定 STM32 外设换平台要重写任意 GPIO 皆可模拟换芯片只改宏定义CPU 开销低可以 DMA收发时 CPU 忙等但 EEPROM 场景完全可接受代码量配置复杂库函数多核心函数 5 个几十行搞定我的建议是学习阶段用软件模拟 I2C 把协议吃透跑通整个项目后再回过头对照参考手册把硬件 I2C 工程写一遍。这两种方式不是替代关系而是相辅相成——你先知道时序“应该长什么样”再去理解硬件外设“为什么要这么设计”就能事半功倍。3.2 GPIO 开漏输出模拟 I2C 的技巧用 GPIO 模拟 I2C 时引脚模式的配置有个小技巧把 SCL 和 SDA 都配置成开漏输出模式而不是推挽输出。开漏输出的好处是写 0 时引脚主动拉低写 1 时引脚处于高阻状态由外部上拉电阻拉高当前工程中就是 4.7kΩ。这样做的意义在于读取 SDA 引脚的电平时你不需要切换方向直接读输入数据寄存器IDR就能拿到实际总线电平。如果用推挽输出模拟 SDA发送位和接收位就得频繁切换 GPIO 模式不仅代码复杂还有可能在切换瞬间产生毛刺把从机搞糊涂。开漏输出模式配合 IDR 读取收发逻辑就统一了发送时写 ODR接收时读 IDR简单可靠。因为 SDA 在开漏模式下既当输出又当输入所以接收字节的函数可以这样设计先读 SDA 的电平判断这一位是 0 还是 1然后翻转 SCL 通知从机“这一位我收到了”。整个读字节过程不需要改一次 GPIO 模式配置稳定性非常好。3.3 代码框架驱动分层与功能划分写代码之前先把驱动框架想清楚。我的做法是分两层底层是 I2C 时序函数只管“怎么把起始、停止、一个字节、一个 ACK 在电平上做出来”上层是 AT24C02 驱动函数只管“用底层时序拼出读地址、读数据、页写这些业务操作”。两层之间用一个头文件里的宏和函数声明隔离将来换芯片或换协议比如换到 STM32G4 的硬件 I2C只需要重写底层上层业务逻辑基本不动。这个分层思路是从实际项目里总结出来的。一开始我图省事把 I2C 时序和 AT24C02 操作写在同一个文件里后来要挂 OLED 屏幕发现所有 GPIO 操作都要复用又得回头拆。不如一开始就按“底层时序层 设备驱动层 应用层”三件套划分后面扩展会非常舒服。4. I2C 时序与 AT24C02 读写的核心逻辑4.1 起始、停止、地址帧协议的最小单元I2C 通信的起点是起始条件终点是停止条件这两个条件有个共同点都是在 SCL 为高电平的时候让 SDA 发生电平跳变。具体来说SCL 高电平期间SDA 从高电平跳变到低电平就是起始条件SCL 高电平期间SDA 从低电平跳变到高电平就是停止条件。为什么要在 SCL 高的时候改变 SDA因为 I2C 规定数据有效期间 SDA 必须保持不变只有 SCL 为低电平时才允许 SDA 改变。这个规定保证了数据线在时钟高电平的时候是稳定的从机才能在 SCL 上升沿准确采样。起始和停止是两个特殊的“违反规则”的时刻从机通过识别这种跳变来感知通信的开始和结束。地址帧紧随起始条件之后。主设备先发送一个字节高 7 位是从机地址最低位是读写位0 表示写1 表示读。比如操作 AT24C02写地址就是 0xA00x50 1读地址就是 0xA1。第九个 SCL 时钟是应答位ACK如果总线上存在地址匹配的从机它会把 SDA 拉低主设备读到低电平就说明“有人在”。如果读回高电平就是 NACK说明地址没匹配上或设备不在线。4.2 一页只有 8 字节页写限制的坑AT24C02 的页面大小是 8 字节。所谓页写就是一次连续写入最多 8 个字节到同一页里。注意“同一页”这个限制如果发送了 8 个字节但起始地址在页中间例如从地址 0x05 开始写 8 个字节数据会跨到下一页边界这时芯片的地址指针会发生回卷把多余的数据写到本页开头而不是顺序写到下一页。这个行为相当隐蔽。你写代码的时候觉得“我发了 8 个字节存进去应该 8 个字节连在一起”实际读回来却发现顺序乱了。我在做这个项目时就犯过这个错在 0x05 地址处写了 8 个字节结果 0x05 到 0x07 写入了前三个字节0x00 到 0x04 被后五个字节覆盖整片数据面目全非。解决思路也简单写之前判断“从当前地址到页边界的剩余字节数”如果数据长度超过这个数就分多次页写。更省事的做法是——单次写入超过 8 字节的场景先把数据放到一个 RAM 数组里再按页拆开逐页写入每次写完都加 5ms 延时等待内部擦写完成。4.3 读 EEPROM 的三种方式与伪写机制读 AT24C02 有三种基本方式读当前地址、随机地址读、连续读。读当前地址最简单就是从上次访问后芯片内部的地址指针读一个字节。这个“指针”听起来玄其实就是一个内部计数器上电默认指向 0x00每次读写后自动加一。随机地址读是更常用的方式先通过写操作把地址指针设到目标位置再发起读操作。注意这个“写操作”不是真正往 EEPROM 写数据而是只发送设备地址写方向加字节地址所以叫伪写dummy write。随机地址读的完整时序是起始 → 发送写地址0xA0→ 发送字节地址 → 重复起始 → 发送读地址0xA1→ 读取一个字节 → 主设备发 NACK → 停止。最后一个 NACK 很重要它告诉从机“我只要这一个字节你不用再准备下一个了”。如果主设备发了 ACK从机会认为你还要继续读接着把下一个字节传上来行为就不受控制了。连续读就简单了从当前地址或任意指定地址开始主设备每读一个字节就回一个 ACKEEPROM 的地址指针自动加一可以一直读下去。当你读到第 256 字节地址 0xFF时指针回卷到 0x00。这个特性在做整片读写测试时很有用一次性把 256 字节全 dump 出来对比。4.4 写周期 5ms最容易忽略的延时写一个字节或者写一页到 EEPROM芯片内部需要时间把数据真正“烧”进浮栅存储单元这个时间叫写周期 tWR典型值 5ms。在写周期内芯片不响应任何 I2C 指令即使你发 ACK 也不会理你。很多新手在写完数据后立刻去读读回来全是 0xFF 或者旧值就是因为没等这 5ms。这里有一个进阶技巧EEPROM 芯片在写周期内会保持 SDA 为低电平所以你可以写完后重复发起始条件直到从机回 ACK才算上一次写真正完成。不过这个技巧在做软件模拟 I2C 时实现起来稍微有点绕。更稳妥的做法是每次写完页或字节后直接加一个 5 到 10ms 的延时函数简单粗暴也不会出问题。5. 完整代码实现与运行流程5.1 底层 I2C 时序函数代码软件的底层时序函数是整个项目的地基。我用宏定义了 SCL 和 SDA 的引脚以及微秒级延时函数。// 引脚宏定义 #define I2C_SCL_PIN GPIO_Pin_6 #define I2C_SCL_PORT GPIOB #define I2C_SDA_PIN GPIO_Pin_7 #define I2C_SDA_PORT GPIOB #define SCL_HIGH() GPIO_SetBits(I2C_SCL_PORT, I2C_SCL_PIN) #define SCL_LOW() GPIO_ResetBits(I2C_SCL_PORT, I2C_SCL_PIN) #define SDA_HIGH() GPIO_SetBits(I2C_SDA_PORT, I2C_SDA_PIN) #define SDA_LOW() GPIO_ResetBits(I2C_SDA_PORT, I2C_SDA_PIN) // 读取 SDA 引脚电平开漏模式下读 IDR #define SDA_READ() (GPIO_ReadInputDataBit(I2C_SDA_PORT, I2C_SDA_PIN)) // 微秒级延时72MHz 下粗略实现 void Delay_Us(uint16_t us) { uint16_t i; while (us--) { for (i 0; i 18; i); } } void I2C_Start(void) { SDA_HIGH(); SCL_HIGH(); Delay_Us(5); SDA_LOW(); // SCL 高电平期间 SDA 拉低产生起始条件 Delay_Us(5); SCL_LOW(); } void I2C_Stop(void) { SDA_LOW(); SCL_HIGH(); Delay_Us(5); SDA_HIGH(); // SCL 高电平期间 SDA 拉高产生停止条件 Delay_Us(5); } uint8_t I2C_WaitAck(void) { uint8_t timeout 100; SDA_HIGH(); // 释放 SDA等待从机拉低 Delay_Us(2); SCL_HIGH(); while (SDA_READ()) { if (--timeout 0) { SCL_LOW(); return 1; // 超时无 ACK } } SCL_LOW(); // 完成 ACK 时钟周期 return 0; } void I2C_Ack(void) { SDA_LOW(); SCL_HIGH(); Delay_Us(3); SCL_LOW(); SDA_HIGH(); } void I2C_NAck(void) { SDA_HIGH(); SCL_HIGH(); Delay_Us(3); SCL_LOW(); SDA_HIGH(); } void I2C_SendByte(uint8_t data) { uint8_t i; for (i 0; i 8; i) { SCL_LOW(); if (data 0x80) { SDA_HIGH(); } else { SDA_LOW(); } data 1; SCL_HIGH(); // 上升沿时从机采样 SDA Delay_Us(3); } SCL_LOW(); I2C_WaitAck(); } uint8_t I2C_RecvByte(void) { uint8_t i, data 0; SDA_HIGH(); for (i 0; i 8; i) { data 1; SCL_HIGH(); Delay_Us(3); if (SDA_READ()) { data | 0x01; } SCL_LOW(); Delay_Us(3); } return data; }这个底层函数里我特意把 I2C_WaitAck 加了一个超时判断。你看I2C_SendByte 里每发完一个字节都要等 ACK如果从机地址写错或者从机不在线程序会一直卡在 while 循环里。加了 timeout 之后最多等几百微秒就返回错误码方便上层做错误处理。5.2 AT24C02 驱动层代码底层有了之后上层驱动就只是拼装逻辑代码量不大。注意我在页写函数里加了页边界保护判断。#define AT24C02_ADDR_W 0xA0 // 写地址 #define AT24C02_ADDR_R 0xA1 // 读地址 #define AT24C02_PAGE_SIZE 8 // AT24C02 页大小是 8 字节 uint8_t AT24C02_WriteByte(uint16_t addr, uint8_t data) { I2C_Start(); I2C_SendByte(AT24C02_ADDR_W); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte(addr 0xFF); I2C_SendByte(data); I2C_Stop(); Delay_Us(6000); // 等待写周期完成实测 6ms 更稳妥 return 0; } uint8_t AT24C02_ReadByte(uint16_t addr, uint8_t *buf) { I2C_Start(); I2C_SendByte(AT24C02_ADDR_W); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte(addr 0xFF); I2C_Start(); // 重复起始条件 I2C_SendByte(AT24C02_ADDR_R); if (I2C_WaitAck()) { I2C_Stop(); return 1; } *buf I2C_RecvByte(); I2C_NAck(); I2C_Stop(); return 0; } uint8_t AT24C02_WritePage(uint16_t addr, uint8_t *buf, uint8_t len) { uint8_t i; // 检查页边界不能跨页写入 if (len AT24C02_PAGE_SIZE - (addr % AT24C02_PAGE_SIZE)) { return 1; // 越界返回错误 } I2C_Start(); I2C_SendByte(AT24C02_ADDR_W); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte(addr 0xFF); for (i 0; i len; i) { I2C_SendByte(buf[i]); } I2C_Stop(); Delay_Us(6000); return 0; } uint8_t AT24C02_ReadSequential(uint16_t addr, uint8_t *buf, uint16_t len) { uint16_t i; I2C_Start(); I2C_SendByte(AT24C02_ADDR_W); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte(addr 0xFF); I2C_Start(); I2C_SendByte(AT24C02_ADDR_R); if (I2C_WaitAck()) { I2C_Stop(); return 1; } for (i 0; i len; i) { buf[i] I2C_RecvByte(); if (i len - 1) { I2C_Ack(); // 还没读完回 ACK 让从机继续 } else { I2C_NAck(); // 最后一个字节回 NACK 表示结束 } } I2C_Stop(); return 0; }页写函数返回 1 的场景是调用方传的参数非法比如从地址 0x06 开始要写 5 个字节明显超过页边界。这时候函数直接拒绝写入比写到一半回卷要好得多。5.3 main 函数联调与结果验证main 函数里我做了两件事先往 AT24C02 整片写入一些测试数据然后逐字节读回来比对是否一致。这里我用了一个 256 字节的数组读回全部数据如果每位都相同则点亮板载 LED 或者串口打印 OK否则打印错误位置。#include stm32f10x.h #include i2c_soft.h #include at24c02.h uint8_t write_buf[256]; uint8_t read_buf[256]; void GPIO_Init_For_I2C(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(GPIOB, GPIO_InitStructure); SCL_HIGH(); SDA_HIGH(); } int main(void) { uint16_t i; uint8_t err_pos 0; GPIO_Init_For_I2C(); USART_Config(); // 串口初始化用于打印 // 生成测试数据 for (i 0; i 256; i) { write_buf[i] (uint8_t)(i * 3 1); } // 整片写入按页拆分写入 for (i 0; i 256; i 8) { uint8_t ret AT24C02_WritePage(i, write_buf[i], 8); if (ret) { printf(Write error at page: %d\r\n, i / 8); while (1); } } // 整片读回并比对 AT24C02_ReadSequential(0, read_buf, 256); for (i 0; i 256; i) { if (write_buf[i] ! read_buf[i]) { err_pos 1; printf(Mismatch at addr %d: w%02x r%02x\r\n, i, write_buf[i], read_buf[i]); } } if (err_pos 0) { printf(EEPROM test passed!\r\n); } else { printf(EEPROM test failed!\r\n); } while (1); }测试通过后整片 256 字节都能正确写读说明 I2C 时序、页写边界、写周期延时这几个关键点都没问题。工程上常用的验证方法就是这种“写死数据再读回对比”比你肉眼去看 OLED 上显示的数值要靠谱得多。6. 调试实录我踩过的坑与排查方法6.1 万用表先量静态电平这个项目遇到的大多数 I2C 问题都可以用万用表在静态条件下定位一大半。把系统上电后先不运行任何通信代码用万用表量一下 SDA 和 SCL 引脚对 GND 的电压。正常情况下因为上拉电阻的存在两条线都应该是接近 3.3V 的高电平。如果测量结果是 0V 或者很低说明有东西把线拉低了。这时候先断开所有从设备单独量 MCU 引脚如果 MCU 引脚正常、接上 AT24C02 之后被拉低那大概率是从设备本身有问题或者 SDA/SCL 接反了。这个排查思路来自我一次真实的翻车经历我把 SDA 和 SCL 接到 AT24C02 模块上后万用表量 SDA 电压 1.1VSCL 电压 3.3V。查了半天发现模块的 SDA 引脚被我从面包板上一根松弛的杜邦线碰短路到了 GND。这个经验强烈建议你养成任何 I2C 通信异常先用万用表量两条线的静态电压很大概率能直接发现问题。6.2 用逻辑分析仪抓时序波形静态电平没有问题之后就轮到动态波形登场了。逻辑分析仪是调试 I2C 的神器不需要多高级几十块那种 USB 逻辑分析仪就行8 个通道采样率 24MHz 起步抓 I2C 100kbps 的信号绰绰有余。抓波形的接线也简单把逻辑分析仪的通道 0 接 SCL通道 1 接 SDA然后共地。注意在逻辑分析仪软件里把通道 0 设置为时钟通道 1 设置为数据再选中 I2C 协议解码软件就会自动把波形翻译成地址、ACK、数据帧。我建议的波形检查顺序是先看起始条件产生是否正确再看设备地址和 ACK 位是否正确最后看数据字节的每一位是否稳定采样。如果发现在起始条件之后、地址帧发送过程中SDA 的电平跳变发生时 SCL 不为低那就说明你的发送字节函数时序有问题数据在线路上不稳定。6.3 常见问题速查表调试过程中我把踩过的坑整理成了下面的速查表按“现象 → 可能原因 → 排查顺序”排列遇到问题先查表再动手现象可能原因排查顺序发地址后无 ACK设备不在线、地址写错、接线反1. 量静态电平 2. 用地址扫描函数扫 0x00-0x7F 3. 确认 AT24C02 供电写入后读回 0xFFWP 引脚拉高、写周期未等待1. 检查 WP 是否接 GND 2. 写后延时加到 10ms 3. 单字节写读验证读回数据顺序错乱页写跨页回卷1. 检查起始地址到页边界剩余量 2. 用页写函数限制长度写读偶尔成功偶尔失败上拉电阻太大、杜邦线过长1. 换 4.7kΩ 上拉 2. 缩短连线 3. 降速到 100kbps程序死等不运行I2C_WaitAck 卡死没有超时1. 检查地址 2. 加超时计数总线 SDA 一直被拉低从机处于错误状态或坏片1. 复位从机电源 2. 对 SCL 发 9 个时钟脉冲释放总线6.4 总线卡死的解锁技巧I2C 常见的另一个疑难杂症是总线卡死SDA 持续为低主设备怎么发起始条件都不成功。这种情况往往是从机在传输中途接收到了异常信号内部状态机卡住了它霸占着 SDA 不肯松手。解锁技巧其实就一条对 SCL 线连续发送 9 个时钟脉冲脉冲期间保持 SDA 为高电平让从机把内部状态走到一个已知边界释放 SDA。我当时在调试 AT24C02 时遇到过两次总线卡死都是用这个方式救回来的。如果 9 个脉冲没用更粗暴的方案是直接给从机断电重启——这也是很多工业 I2C 主机处理器会内置“总线恢复”机制的原因。从软件上规避这个问题的方法是在每个通信事务结束后把 SDA 和 SCL 都置为高电平确保总线回到空闲态。我之前的一段代码在通信异常时直接 return忘了把 SDA 拉高导致下次 Start 时总线状态不对被坑了好几个小时。现在我的习惯是所有错误分支退出前都强制调用一次 I2C_Stop()保证总线状态干净。7. 继续扩展从 EEPROM 到 OLED、数据记录器7.1 总线上挂多个 I2C 设备I2C 的最大价值在于总线复用所以跑通 AT24C02 之后下一步自然是往同一条总线上挂更多设备。最常见的是挂 0.9 寸或者 1.3 寸的 OLED 屏它们大部分用的是 SSD1306 或者 SH1106 驱动芯片I2C 地址通常是 0x3C 或 0x3D和 AT24C02 的 0x50 完全不冲突。这样一根总线上既有存储、又有显示地址不打架数据还能共用。但挂多个设备时有一个隐藏的兼容问题有些 OLED 模块出厂时没有焊上拉电阻只靠 MCU 内部上拉总线电容一旦变大波形就会变差。我在一个项目里把 OLED 和 AT24C02 挂同一条总线上后OLED 显示出现雪花条纹后来发现是模块上拉电阻缺失。解决方式是只在总线上保留一组 4.7kΩ 上拉电阻由主机负责提供不要每个模块都焊接上拉否则电阻并联后阻值太低也会出问题。还有一种情况是不同厂家的模块对 I2C 时序的容忍度不一样。AT24C02 对时序要求相对宽松而 SSD1306 某些版本对上电时序比较敏感。我在实践中发现先让 OLED 初始化稳定 100ms再去操作 EEPROM能够避免上电瞬间总线冲突。如果你遇到“单独能跑、一起就崩”的问题优先检查两者的上电时序和上拉电阻配置。7.2 用定时器做掉电数据记录AT24C02 的另一个典型应用场景是配合定时器做掉电保存记录。STM32F103 的定时器 3 可以配置为定时中断比如每 1 秒触发一次中断在中断里把一个运行计数器的值写入 EEPROM。这个思路在真实产品里很常见设备需要记录总运行时间、累计开机次数、最近一次故障码等关键数据掉电后不能丢失。实现起来也不复杂。定时器 3 配置为向上计数模式产生 1 秒中断中断服务函数里把一个变量自增同时调用 AT24C02_WriteByte 把当前值写到固定地址。但注意一个细节EEPROM 的写寿命是 100 万次如果每秒钟写一次一块芯片只能写大约 11.5 天就报废了。解决办法也很经典——磨损均衡把计数器循环写到 256 个地址上用一个索引指针记录当前写到哪个地址。这样寿命扩展到 2.56 亿次写入实用价值立刻就出来了。实现磨损均衡还有一个额外的好处上电后可以扫描整片 EEPROM找出最大的计数值作为上次掉电前的运行时间。这比在固定地址“读一个数、加一、再写回”的做法可靠得多而且不怕频繁启动导致某几个地址被写穿。7.3 驱动抽象与换芯片思路最后聊聊怎么把代码变成能复用的“资产”。如果你在项目里写了软件模拟 I2C 驱动下次换到 STM32G4、STM32L4 甚至其他厂家的 MCU 时只需要改一下底层的 GPIO 操作宏上层驱动完全不用动。换 EEPROM 芯片也一样比如从 AT24C02 换到 AT24C32。AT24C02 是 8 字节页写AT24C32 是 32 字节页写容量上 AT24C02 的字节地址是 1 字节0-255AT24C32 的字节地址涨到 2 字节0-4095读写时序上的差异也集中在这里。如果你在驱动层把“地址长度”和“页大小”抽象成一个配置结构体换芯片时只需要改两个宏驱动代码一行的不用动。这种模块化设计刚开始会让人觉得“是不是过度设计”但我做过的项目越多越觉得这个习惯值得养成。你永远不会知道现在的 RAM 数组保存在哪里将来的产品是不是需要把同样的数据放到更小的 MCU 上跑。先花 10 分钟把驱动写得更通用后面能给你省下半天甚至一天的移植排查时间。回到项目本身我实际跑通这个 AT24C02 读写流程后最大的感受是硬件 I2C 那些让人头疼的事件标志在软件模拟的方式下全都不存在了你看到的就是真实到不能再真实的总线电平。而当你看着逻辑分析仪上那串地址和 ACK 波形心里对 I2C 协议的每一个细节都有了底再回头去读 RM0008 参考手册那些寄存器描述都会变得非常流畅。对了最后分享一个我保留至今的小习惯驱动代码里一定要留一个 I2C 地址扫描函数。它不复杂就是遍历 0x00 到 0x7F 所有地址逐个发起始和地址看哪个地址能收到 ACK。哪怕你不做多设备这个函数在调试初期总能帮你快速确认“器件到底在不在总线上”省下的排查时间绝对值得那几十行代码。