我最近正好在调试一块用 STM32F103 驱动 AT24C02 的板子,从 I2C 时序到读写稳定性踩了不少坑,也有不少收获。这篇文章不是那种“对着 CubeMX 点一圈就完事”的教程,我想把 AT24C02 这颗 EEPROM 在 STM32F103 上的完整读写流程,包括硬件设计、I2C 协议细节、代码实现和问题排查,全部拆开讲清楚。无论你是刚接触 I2C 通信的新手,还是被总线卡死、数据错乱折磨过的老手,这篇内容应该都能帮上忙。
1. 硬件连接与电路设计要点
很多朋友一开始就直接在开发板上插模块、飞线,然后跑去调代码。这样做不是不行,但一旦通信不稳定,排查起来就特别痛苦。先把硬件端想清楚,后面能省一半的调试时间。
1.1 AT24C02 引脚功能与接线方式
AT24C02 是 Atmel(现在归 Microchip)出品的一颗 2Kb(也就是 256 字节)串行 EEPROM,采用 I2C 接口通信,工作电压范围通常在 1.8V 到 5.5V。它只有 8 个引脚,非常小巧。
引脚功能大家应该要背下来:
| 引脚 | 名称 | 功能说明 |
|---|---|---|
| 1 | A0 | 地址输入位 0 |
| 2 | A1 | 地址输入位 1 |
| 3 | A2 | 地址输入位 2 |
| 4 | GND | 接地 |
| 5 | SDA | 数据线 |
| 6 | SCL | 时钟线 |
| 7 | WP | 写保护,低电平或悬空可写,高电平禁止写 |
| 8 | VCC | 电源正极 |
A0、A1、A2 这三个引脚是硬件地址选择。AT24C02 的 I2C 从机地址是 7 位地址,高四位固定是 1010,低三位由 A2、A1、A0 引脚决定。如果三个引脚全部接地,那设备地址就是 0x50(7 位地址),在 8 位地址格式下写地址就是 0xA0,读地址是 0xA1。
这里要特别提醒的是,很多开发板上的 AT24C02 模块已经把 A0/A1/A2 全部接地了,所以代码里如果写成 0xA0 或者 0x50,实际上指的是同一个器件。但如果你自己在 PCB 上接了别的电平,一定要按实际接线修正地址,否则读回来的数据全是 0xFF,你在那调半天代码根本没意义。
1.2 开漏模式与上拉电阻的选型逻辑
ST 的 I2C 外设跟 SPI 不一样,它被设计成开漏(Open-Drain)输出,而不是推挽(Push-Pull)输出。这一点非常重要。开漏的意思是,芯片内部只负责把总线拉低,释放总线时就处于高阻态,总线电平靠外部上拉电阻拉高。
为什么要开漏?因为 I2C 是半双工、多主机的总线结构,一根线上可以挂很多设备。如果用推挽输出,两个设备同时输出一个高一个低,就会短路烧管子。开漏输出配合上拉电阻,天然支持“线与”逻辑,任何一方拉低总线,总线就是低电平,不会出现互怼的情况。
于是问题来了:上拉电阻选多大?选小了,总线灌电流太大,设备负担重甚至损坏;选大了,总线上升沿太慢,时序不过关。AT24C02 的 datasheet 通常要求上升时间在标准模式下不超过 1000ns,快速模式下不超过 300ns。STM32F103 的 I2C 如果用 100kHz 标准模式,常见选 4.7kΩ 到 10kΩ;如果上到 400kHz 快速模式,建议 1kΩ 到 3kΩ 之间。
实际经验是,3.3V 供电下用 4.7kΩ 上拉到 VCC,跑 400kHz 通常没问题。如果总线长度超过 20cm,或者并联设备比较多,可以适当减小上拉电阻到 2.2kΩ。但千万不要小于 1kΩ,否则 SDA 低电平可能超过 VIL 阈值,设备识别不了低电平。
在代码里面,GPIO 的配置也要匹配这个开漏特性。假如你用的是 HAL 库,初始化 I2C 引脚时要把 GPIO 配成开漏输出、带上拉。很多人这里省事直接复用默认的推挽配置,结果总线波形乱七八糟,还查半天不知道为什么。
1.3 5V 与 3.3V 电平转换到底要不要做
这是个老生常谈的问题。AT24C02 的工作电压范围是 1.8V 到 5.5V,所以如果板子上有 5V 电源,直接把 AT24C02 接到 5V、上拉到 5V,也是可以的,因为它的输入阈值能正确识别 3.3V 的高电平。反过来,如果 AT24C02 是 3.3V 供电,而 SCL/SDA 是从 5V 单片机的 I2C 引脚出来,那就必须做电平转换,因为芯片的 VCC 只有 3.3V,输入高电平阈值通常在 0.7×VCC 附近,5V 的高电平虽然不会立刻烧掉这颗芯片,但长期工作有明显风险,而且总线上其它 3.3V 设备可能直接被 5V 灌坏。
STM32F103 是 3.3V 供电,配上 3.3V 供电的 AT24C02,两端 I2C 电平匹配,完全不需要额外转换电路。很多网上的模块之所以标着“5V 兼容”,是因为模块上带了电平转换芯片或者分压电路,不是为了让你直接拿 5V 怼 STM32 的引脚。
如果你确实想设计一个更稳妥的电路,在 3.3V 主控和 5V EEPROM 之间可以串一个 MOS 管电平转换电路,原理是让 SDA/SCL 两边各自通过上拉电阻接到各自电源,中间用 N-MOS 管互联。这个电路中 MOS 管的栅极接 3.3V 侧电源,源极接地,漏极接 5V 侧的数据线,就能实现双向电平转换,不需要软件参与。
2. I2C 协议与 AT24C02 内部结构拆解
搞硬件连接只是第一步,要让通信真正跑起来,你心里必须有 I2C 协议的数据链路图。AT24C02 虽然看起来简单,但它的读时序比写时序复杂得多,尤其是随机读和连续读,很多新手就是栽在这里。
2.1 I2C 总线通信机制
I2C 通信有两个核心信号:SCL(时钟)和 SDA(数据)。数据传输的节奏完全由主设备(STM32F103)控制,从设备(AT24C02)只是被动响应。
一次完整的 I2C 传输包含以下环节:
- 起始条件:SCL 为高时,SDA 由高转换到低。所有从设备看到这个电平变化后,开始准备接收地址。
- 发送从机地址:主设备在 SCL 低电平期间改变 SDA,SCL 高电平期间数据被采样。先发送 7 位设备地址,再发送 1 位读写标志(0 表示写,1 表示读)。
- 从设备应答:第 9 个时钟周期,如果从设备地址匹配成功,它会把 SDA 拉低,发出 ACK 信号。如果地址不匹配,SDA 保持高电平,主设备收到 NACK。
- 数据传输:每次 8 个 bit,跟地址发送类似,每字节后跟一个 ACK/NACK。
- 停止条件:SCL 为高时,SDA 由低转换到高。总线释放,通信结束。
这里有个所有初学者都会忽略的点:SDA 的电平只能在 SCL 为低的时候变化,SCL 为高的时候 SDA 必须保持稳定,否则会被误判为起始或者停止条件。我之前用逻辑分析仪看到波形时才发现,自己写的软件模拟 I2C 里电平切换时机不对,导致设备时不时出现“假起始”误判。
2.2 AT24C02 存储结构与地址映射
AT24C02 的存储空间是 256 字节,实际内部是按“页”组织的,每页 8 字节。为什么要强调页的概念?因为写操作有“页写”机制。在页写模式下,你可以连续写入一串数据,但它的地址递增是有限制的——超过了物理页尾后,地址会“回卷”到当前页首,而不是进入下一页。
举个非常容易踩坑的例子:你想往地址 0x06 开始连续写 4 个字节,这 4 个字节横跨了第 0 页的末尾(0x06、0x07)和第 1 页的开头(0x08、0x09)。AT24C02 的做法不是自动进入下一页,而是把 0x08、0x09 两个字节写回 0x00、0x01。结果就是你读出来一堆错乱数据。
所以在写连续数据时,必须先判断“本次写入是否会跨页”。如果跨页,就要拆成两笔事务,第一笔写到页尾为止,第二笔从下一页页首继续。这也是为什么很多驱动库的写接口都要做跨页判断,不是代码故意写得复杂,是芯片硬件机制决定的。
2.3 写周期时间与地址数据格式
AT24C02 每个字节写入后,内部需要一点时间把数据固化到非易失存储,文档上写的是最大 5ms 写周期时间,典型值在 2ms 左右。在这个时间内,芯片不会响应任何来自主机的命令,包括 ACK。如果你的代码写完一个字节马上就去读,大概率会收到 NACK 或者读出旧数据。
正确的做法有两个方向:一个是写完之后固定延时 5ms 以上再操作;另一个是轮询 ACK——写完发一个“哑指令”,如果芯片返回 ACK 说明写周期完成,如果 NACK 说明还在忙。轮询 ACK 在实时性要求高的场合更好用,省时间,也不依赖你延时的准确性。
读操作的数据格式稍微复杂一点。写操作就是“从机地址 + 寄存器地址 + 数据”,一次搞定。读随机地址则需要两段事务:先发写模式的从机地址和寄存器地址,告诉芯片“我要读哪个地址”(这个叫 dummy write),然后重复起始条件,再发读模式的从机地址,之后读取数据。如果漏掉了 dummy write 阶段,芯片会从上次访问的地址继续读,不是你期望的地址。
3. STM32F103 的 I2C 外设配置实战
STM32F103 的 I2C 外设性能其实不错,但它最容易出问题的地方,反而在初始化配置上。这里我把标准外设库和 HAL 库两条路都讲一讲,同时结合热词里提到的“最小系统”“5V 转 3.3V 电路”这些点展开说明。
3.1 最小系统与准备工作
STM32F103 的最小系统包括:电源、晶振、复位电路、BOOT 引脚设置、SWD 下载接口。这里我不准备重新讲一遍最小系统原理,只提一个跟 I2C 项目直接相关的点:如果你想在这块板子上同时用串口调试输出和 I2C,那么电源稳定性非常关键。
I2C 总线上的上拉电阻直接接 VCC,如果 VCC 纹波大,总线电平的判断就会抖动。我给 STM32F103 供电时习惯在 VCC 和 GND 之间加一个 100nF 陶瓷电容,靠近芯片电源引脚放置,然后外加一个 10μF 的电解电容在电源入口处做储能。这个成本极低,但对通信稳定性的改善非常明显。
开发环境的搭建方面,我强烈建议下载 ST 官方的器件支持包,无论你用的是新版 CubeMX 直接在线下载,还是手动装离线 pack,完成之后都要确认 STM32F103 的型号系列已经出现在芯片列表里。很多编译报错“device not found”都是 pack 版本不匹配造成的。
3.2 GPIO 与 I2C 时钟配置
STM32F103 的 I2C1 可以映射到 PB6(SCL)和 PB7(SDA),也支持重映射到 PB8/PB9。I2C2 则用 PB10/PB11。不管用哪个外设,GPIO 模式必须配成“开漏输出、带上拉”。
用标准外设库的初始化代码通常长这样:
GPIO_InitTypeDef GPIO_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, 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_InitStructure.GPIO_Pull = GPIO_Pull_Up; // 内部上拉 GPIO_Init(GPIOB, &GPIO_InitStructure); I2C_InitTypeDef I2C_InitStructure; I2C_InitStructure.I2C_Mode = I2C_Mode_I2C; I2C_InitStructure.I2C_ClockSpeed = 400000; // 400kHz 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 = 0x00; I2C_Init(I2C1, &I2C_InitStructure); I2C_Cmd(I2C1, ENABLE);注意这里的几个细节。第一,GPIO_Mode 一定要是 AF_OD(复用开漏),不是 AF_PP。第二,I2C_ClockSpeed 我填的是 400000,也就是快速模式,但 AT24C02 是支持 400kHz 的,没问题。如果总线很长或者上拉电阻偏大导致上升沿太慢,可以降回 100000。第三,DutyCycle 在快速模式下配成 2(即 16:9 的占空比),这个参数只影响快速模式,标准模式下不用设。
用 HAL 库更简单,CubeMX 里只要把 I2C1 的 SCL、SDA 引脚配置为开漏输出,然后在 I2C 配置页填上时钟速度、Duty Cycle 等参数,生成代码后 HAL_I2C_Init() 会自动搞定 GPIO 复用。但我还是建议你手写一版寄存器或标准库的初始化,因为这样你才知道底层到底发生了什么事,后面排查问题会快很多。
3.3 软件模拟 I2C 还是硬件 I2C?
这也是一个很常见的抉择。很多开发者在 STM32F103 上用硬件 I2C 碰壁之后(比如总线卡死、状态寄存器混乱),干脆换成了 GPIO 软件模拟 I2C,反而稳定了。我个人的观点是:如果你只是驱动一颗 AT24C02,软件模拟完全够用,且可移植性强,Debug 更直观;但如果你要用到 DMA、多主机仲裁或者要挂多颗 I2C 设备,硬件 I2C 外设是更合适的选择。
软件模拟的好处在于时序完全可控,兼容性极好,不受芯片 I2C 外设已知怪癖的影响。缺点是占用 CPU,无法自动处理多主机冲突,但 AT24C02 项目场景里根本用不到这些高级功能。
如果你也想留一条软件模拟的后路,那么 GPIO 配置就不要选 AF_OD,而是用普通 GPIO 的开漏模式。代码里控制 SCL 和 SDA 电平就靠两个宏:
#define I2C_SCL_H() GPIOB->BSRR = GPIO_Pin_6 #define I2C_SCL_L() GPIOB->BRR = GPIO_Pin_6 #define I2C_SDA_H() GPIOB->BSRR = GPIO_Pin_7 #define I2C_SDA_L() GPIOB->BRR = GPIO_Pin_7开漏模式下想拉高总线,其实靠的是上拉电阻,所以“拉高”宏本质是释放引脚,让它进入高阻态。明白这一点,你就不会在软件模拟时纠结“为什么我输出高了电平还是不高”这种问题。
4. AT24C02 读写完整实现与代码拆解
这一节是全文的重头戏。我直接给出可用的代码,并解释每一段的意图。这些代码基于标准外设库,逻辑对所有系列通用的。
4.1 单字节写入与读取
先说单字节写。一个完整的写时序是:起始条件 → 发送从机地址(写标志)→ 等待 ACK → 发送存储地址 → 等待 ACK → 发送数据 → 等待 ACK → 停止条件。
uint8_t AT24C02_WriteByte(uint8_t addr, uint8_t data) { 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)); I2C_SendData(I2C1, addr); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); I2C_SendData(I2C1, data); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); I2C_GenerateSTOP(I2C1, ENABLE); delay_ms(5); // 写周期等待,至少 5ms return 0; }这段代码看起来不长,但有一个关键陷阱:每次调用 I2C_GenerateSTART 之后,如果总线正好处于忙状态,这个函数会一直卡在 while 等待里,形成死锁。所以实际项目里应该在等待事件时加一个超时计数器,超时就返回错误,而不是盲目死等。
读单字节的代码稍微绕一点,因为要先发一个 dummy write:
uint8_t AT24C02_ReadByte(uint8_t addr) { uint8_t data = 0; // 第一阶段:发送设备地址(写)和存储地址 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)); I2C_SendData(I2C1, addr); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); // 第二阶段:重复起始,切换到读模式 I2C_GenerateSTART(I2C1, ENABLE); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, 0xA1, I2C_Direction_Receiver); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED)); // 读一个字节后回 NACK I2C_AcknowledgeConfig(I2C1, DISABLE); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_RECEIVED)); data = I2C_ReceiveData(I2C1); I2C_AcknowledgeConfig(I2C1, ENABLE); I2C_GenerateSTOP(I2C1, ENABLE); return data; }为什么读最后一个字节前要关闭 ACK?因为如果主机回 ACK,从设备会认为你还要继续读下一字节,继续把总线拉住;你回 NACK,它才知道传输结束了,才能释放总线,你才好发出停止条件。
4.2 跨页连续写入的实现
连续写多个字节时,前面提到的跨页回卷问题必须处理。下面这段代码是一个安全的写函数:
uint8_t AT24C02_WritePage(uint8_t addr, uint8_t *buf, uint16_t len) { uint16_t i; uint8_t pageSize = 8; uint8_t offset = addr % pageSize; // 当前页内偏移 uint8_t remainInPage = pageSize - offset; // 本页剩余空间 uint8_t writeLen; while (len > 0) { writeLen = (len > remainInPage) ? remainInPage : len; 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)); I2C_SendData(I2C1, addr); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); for (i = 0; i < writeLen; i++) { I2C_SendData(I2C1, buf[i]); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); } I2C_GenerateSTOP(I2C1, ENABLE); delay_ms(5); addr += writeLen; buf += writeLen; len -= writeLen; remainInPage = pageSize; // 跨页后重新按整页计算 } return 0; }offset 的计算就是为了找出第一次写入会写到哪一页的哪个位置。比如 addr 是 0x06,pageSize 是 8,offset 是 6,remainInPage 是 2,那么这一轮最多写 2 字节,避免写到 0x08 触发回卷。第二轮 addr 变成 0x08,偏移归零,后面就可以整页写。这么干虽然代码多几行,但保证了数据不会因为芯片的页回卷机制而错乱。
4.3 连续读与验证
连续读没有任何页限制,因为读操作不会触发回卷,可以一路递增地址读到 0xFF 再归零。下面是一个简单的读函数:
uint8_t AT24C02_ReadBuffer(uint8_t addr, uint8_t *buf, uint16_t len) { uint16_t i; 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)); I2C_SendData(I2C1, addr); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); I2C_GenerateSTART(I2C1, ENABLE); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, 0xA1, I2C_Direction_Receiver); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED)); for (i = 0; i < len; i++) { if (i == len - 1) { I2C_AcknowledgeConfig(I2C1, DISABLE); // 最后一字节 NACK } while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_RECEIVED)); buf[i] = I2C_ReceiveData(I2C1); } I2C_AcknowledgeConfig(I2C1, ENABLE); I2C_GenerateSTOP(I2C1, ENABLE); return 0; }写完读回做对比验证,是每一个 EEPROM 项目都应该有的自检步骤。我的习惯是写一个 0xAA、0x55、0x01、0x02、0x03 这样的测试序列,写到 5 个不同地址,再读回来比对。如果比对失败,直接串口打印出错的地址和期望值、实际值,这比慢慢看变量省力多了。
调试配合串口打印是必备手段。STM32F103 的 USART1 初始化和 I2C 不冲突,但要注意一点:如果用 HAL 库配置 USART1 的 DMA 接收,有时会出现首帧数据丢失。这个问题的原因通常是 DMA 在使能前已经处于空闲状态,外部中断或串口空闲中断在缓冲区还未就绪时先触发了一次。解决方法是初始化完成后再调用一次 UART_Receive_DMA 或者 HAL_UART_Receive_DMA,同时确保串口空闲中断在初始化期间处于关闭状态。这个坑跟 I2C 没有直接关系,但如果你习惯用串口打印来验证 I2C 读写结果,迟早会撞到。
4.4 用 HAL 库实现读写的一个替代方案
如果你更习惯 HAL 库,其实代码会更简洁。发送阶段 HAl_I2C_Mem_Write() 一行就能完成,读取用 HAL_I2C_Mem_Read()。核心逻辑变成:
uint8_t HAL_AT24C02_WriteByte(uint16_t addr, uint8_t data) { HAL_StatusTypeDef status; status = HAL_I2C_Mem_Write(&hi2c1, 0xA0, addr, I2C_MEMSIZE_8BIT, &data, 1, 100); if (status != HAL_OK) return 1; HAL_Delay(5); return 0; } uint8_t HAL_AT24C02_ReadByte(uint16_t addr) { uint8_t data = 0; HAL_I2C_Mem_Read(&hi2c1, 0xA0, addr, I2C_MEMSIZE_8BIT, &data, 1, 100); return data; }HAL 库的 Mem_Write 函数内部自动处理了“写地址-发数据-等待 ACK-停止”的完整流程,还内置超时和错误处理,省心不少。但在性能要求苛刻、需要自定义时序的场合,标准库或者寄存器操作更有优势。两种方案没有绝对的好坏,关键要看你后期要不要做资源开销优化。
5. 常见问题与排查技巧实录
下面这些坑,都是我实际调试过程中踩过,或者帮别人排查时遇到的。我整理成一个 Q&A 风格速查表,方便你按图索骥。
5.1 I2C 总线卡死,SCL 被拉低不动
这是最常见的问题。上电后 SCL 保持低电平,复位也没用,程序一直卡在等待事件那里。原因是 I2C 总线被某个从设备锁死了,常见于主设备异常掉电或代码在传输中途复位,导致从设备还在等待剩余的时钟周期完成当前事务。
解决办法很简单:在主设备初始化 I2C 之前,对 SCL 做若干次手动翻转(比如 9 个时钟脉冲),把挂在总线上的从设备“解复位”。在代码里可以这样写:
void I2C_BusReset(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); GPIO_WriteBit(GPIOB, GPIO_Pin_6 | GPIO_Pin_7, Bit_SET); for (int i = 0; i < 9; i++) { GPIO_WriteBit(GPIOB, GPIO_Pin_6, Bit_RESET); delay_us(5); GPIO_WriteBit(GPIOB, GPIO_Pin_6, Bit_SET); delay_us(5); } GPIO_WriteBit(GPIOB, GPIO_Pin_7, Bit_RESET); // 停止条件 delay_us(5); GPIO_WriteBit(GPIOB, GPIO_Pin_7, Bit_SET); }这个技巧看起来有点粗暴,但非常有效。它模拟了主设备主动产生 9 个时钟脉冲和一个停止条件,从设备内部状态机会因为收到停止条件而回到空闲状态。
5.2 读出的数据全是 0xFF
如果读回的数据全部是 0xFF,大概率不是芯片的问题,而是 I2C 从机地址不对。常见原因有三个:
第一,设备地址算错。AT24C02 的地址是 0x50(7 位),换算成 8 位发送格式写就是 0xA0。很多库函数里第一个参数要求填 8 位地址,你填了 0x50,相当于把地址左移了一位,设备当然不会应答。
第二,A0/A1/A2 引脚接法跟代码不一致。如果你的模块上 A0 接高电平,而代码里用的是 0xA0,地址就不匹配。
第三,WP 引脚被拉高,芯片进入了写保护状态。读操作也会受影响,因为写保护状态下芯片对写命令不响应,但读命令一般还能执行。如果连读都不响应,多半是 WP 引脚没有正确接低电平。
排查方法:用逻辑分析仪抓 SDA/SCL,看设备地址发出后,第 9 个时钟 SDA 有没有被拉低。如果没有 ACK,就是地址错误或者上拉电阻问题;有 ACK 但数据不对,那就是写保护或者页回卷问题。
5.3 写入成功但断电重启后数据丢失
这个问题常见于对 EEPROM 时序理解不深的情况。写入流程虽然发出了停止条件,但芯片内部的写周期还没完成,硬件上电瞬间 VCC 掉电,数据没有固化完毕就没了。
解决办法:第一,确保每次写操作后等待至少 5ms;第二,如果数据量少,可以在 Page Write 后轮询 ACK,而不是固定延时;第三,检查电源在上电和掉电瞬间是否有足够的储能电容,EEPROM 在写周期时电流尖峰如果被电源噪声干扰,写入也可能失败。
5.4 上升沿过缓导致通信不稳定
如果总线频率设成 400kHz,但示波器上看 SCL 上升沿很平缓,那基本上可以判定是上拉电阻偏大或者总线寄生电容偏大。此时应该:
- 把上拉电阻换小一点,比如从 10kΩ 换成 2.2kΩ;
- 缩短 SCL/SDA 走线长度;
- 避免在总线上挂过多设备,把总线上多个设备的 SDA 称为一体后,总电容可能超过 400pF 规格。
反过来,如果总线上拉电阻太小,低电平电压可能达不到 VIL 要求,同样通信失败。所以上拉不是越小越好,具体值最好用示波器看波形来确定。
5.5 HAL 库串口 DMA 首帧丢失的处理
虽然这不是 I2C 的直接问题,但我在调试 EEPROM 时经常碰到。表现是用串口打印第一帧数据时丢失前面几个字节,后续帧正常。解决方案是:DMA 初始化后,在开启接收 DMA 之前先调用一次清空标志位的操作,然后调用接收函数,确保在空闲中断使能之前 DMA 已经在等待数据。具体代码各家库的版本略有差异,但思路就是“先清标志、再使能、不遗漏”。
如果你是第一次调试这种问题,建议直接先不用 DMA,改用阻塞式串口打印验证逻辑。排除 I2C 本身问题之后,再回头优化串口传输方式,这样排查步骤更清晰。
6. 一些我踩过坑之后才明白的经验
文章写到这里,代码该给的也给了,问题该排的也排了,最后聊几句实际的体会。
我调试 AT24C02 时最大的感受是:硬件 I2C 的很多“玄学问题”,其实大多不是芯片的问题,而是硬件上拉电阻、线长、供电纹波这些基础项没做好。你把 I2C 时序的每个阶段都搞清楚,再配合逻辑分析仪看波形,90% 的问题都能定位。建议手上常备一个便宜的逻辑分析仪,哪怕只有 8 通道、20MHz 采样率,也足够看 100kHz 和 400kHz 的 I2C 波形了。
另一个经验是写 EEPROM 驱动函数时一定要处理超时和错误返回,不要用 while(1) 死等。I2C 总线偶尔被拉死、设备偶尔 NACK,都是很正常的现象,代码要能容错、重试、向上层报告错误,而不是卡死在等待循环里。
最后,如果你以后要上 RTOS 或者裸机多任务,注意 I2C 总线操作要加互斥保护,防止两个任务同时操作 EEPROM 导致读写交错。我自己就吃过这个亏:一个任务在写数据,另一个任务在启动读,结果两边都以为自己在控制总线,最后数据全是乱的。
AT24C02 是个很经典的器件,弄懂它,你是真的把 I2C 通信的底子打实了。后面再去驱动各种传感器、OLED 屏幕、RTC 芯片,很多思路都是相通的。希望这篇实战拆解能帮你少走点弯路。