☰
STM32F103硬件I2C驱动AT24C02实战:从时序计算到页写边界避坑指南
2026/10/27 1:07:12 网站建设 项目流程

1. 项目缘起与整体设计思路

STM32F103 搭配 AT24C02 这个组合,在嵌入式圈子里几乎算是“Hello World”级别的经典搭配。但经典归经典,我见过太多人在这个组合上翻车——读出来全是 0xFF,写进去读出来对不上,时序跑着跑着就卡死在 while 循环里。问题往往不在代码本身,而在于对 I2C 总线的物理层理解、对 AT24C02 页写机制的认识、以及对 STM32F103 硬件 I2C 外设那些“坑”的预判不足。

这个项目的核心目标很明确:用 STM32F103 的硬件 I2C 外设,完成对 AT24C02 EEPROM 的字节写、字节读、页写、连续读,并且保证数据可靠。AT24C02 是一颗 2Kbit(256 字节)的 EEPROM,I2C 接口,支持 1.8V 到 5.5V 宽电压,页大小为 8 字节。它便宜、好买、资料多,非常适合用来练手 I2C 通信。

为什么选硬件 I2C 而不是软件模拟?这是第一个要做的方案选型。软件模拟 I2C 的好处是引脚随便选、时序完全可控、调试直观;坏处是占用 CPU 时间、速率上不去、代码量大。硬件 I2C 的好处是速率高、CPU 占用低、有中断和 DMA 支持;坏处是 STM32F103 的硬件 I2C 外设出了名的“容易卡死”,尤其是早期批次的芯片,存在一些已知的 errata。我的选择是:先用软件模拟把 AT24C02 的读写逻辑跑通,确认硬件连接和 AT24C02 本身没问题,再切换到硬件 I2C,对比两者的差异。这样做的目的是把“硬件问题”和“软件问题”分开排查,避免一上来就陷入“到底是线没接好还是 I2C 外设卡死了”的困境。

整个项目的设计思路可以概括为四层:物理层(上拉电阻、电平匹配、电源去耦)、协议层(I2C 起始、停止、应答、时序参数)、器件层(AT24C02 的设备地址、页写边界、写周期时间)、应用层(读写接口封装、错误处理、数据校验)。这四层任何一层出问题,表现都是“读不到数据”,但排查方向完全不同。后面我会逐层拆解,把每一层的坑和应对方法讲清楚。

这个内容适合谁?如果你刚接触 STM32,想找一个外设练手,AT24C02 是很好的起点;如果你已经用过 I2C 但总是遇到玄学问题,这篇里的排查思路和参数计算应该能帮到你;如果你是从 51 单片机转过来的,习惯了软件模拟 I2C,那硬件 I2C 的配置逻辑和中断处理方式需要重新适应一下。

2. 硬件层:上拉电阻、电平匹配与电源去耦

2.1 上拉电阻到底选多大

I2C 总线是开漏输出,这意味着总线上所有设备只能把线拉低,不能主动拉高。高电平是靠上拉电阻把线“拽”上去的。所以上拉电阻的值直接决定了总线的上升沿时间和功耗。

STM32F103 的 I2C 引脚在开漏模式下,输出低电平时的灌电流能力有限,一般不超过 3mA(具体看数据手册的 VOL 参数)。AT24C02 的 SDA 引脚在输出低电平时,灌电流能力是 3mA(VOL=0.4V 时)。所以上拉电阻的最小值受限于灌电流:R_min = (VDD - VOL) / I_OL。以 3.3V 供电为例,R_min = (3.3 - 0.4) / 0.003 ≈ 967Ω。也就是说,上拉电阻不能小于约 1kΩ,否则低电平可能拉不到 0.4V 以下,导致通信失败。

那最大值呢?受限于上升沿时间。I2C 标准模式(100kHz)要求上升沿时间 tr ≤ 1000ns,快速模式(400kHz)要求 tr ≤ 300ns。上升沿时间由总线电容和上拉电阻决定:tr ≈ 0.847 × R × C(从 0.3VDD 到 0.7VDD 的时间)。假设总线电容 C 为 100pF(两根线加一起,走线不长的话差不多这个量级),快速模式下 tr ≤ 300ns,则 R ≤ 300ns / (0.847 × 100pF) ≈ 3.5kΩ。所以 4.7kΩ 在快速模式下可能偏大,2.2kΩ 到 3.3kΩ 是比较稳妥的选择。

我实测下来,3.3V 供电、总线电容 100pF 左右、速率 400kHz 的场景,2.2kΩ 上拉电阻表现最稳。如果你用 100kHz,4.7kΩ 也够用。但如果你板子上 I2C 走线很长,或者挂了多个从设备,总线电容可能到 200pF 甚至更大,这时候上拉电阻要相应减小,否则上升沿太慢,数据采样出错。

注意:上拉电阻不是越大越省电越好。省的那点功耗换来的是通信不稳定,得不偿失。我见过有人用 10kΩ 上拉,100kHz 下勉强能跑,一换到 400kHz 就各种 NACK。

2.2 电平匹配:3.3V MCU 和 5V EEPROM 能直连吗

AT24C02 支持 1.8V 到 5.5V 供电,所以你可以直接给它供 3.3V,和 STM32F103 的电平完全匹配,不需要任何电平转换。这是最省事的做法。

但如果你手头的 AT24C02 模块是 5V 供电的(有些模块带 5V 稳压和电平转换芯片),那就要注意了。STM32F103 的 I/O 引脚大部分是 5V 容忍的(FT 标识),但 I2C 引脚 PA6/PA7 或 PB6/PB7 是否 5V 容忍,需要查具体型号的数据手册。以 STM32F103C8T6 为例,PB6 和 PB7 是 FT 引脚,可以容忍 5V 输入。但“容忍”不等于“推荐”,长期在 5V 电平下工作,虽然不会立刻烧,但可靠性会下降。

我的建议是:统一用 3.3V 供电。AT24C02 在 3.3V 下完全正常工作,STM32F103 也是 3.3V,两边电平一致,上拉电阻接到 3.3V,简单可靠。如果你必须用 5V 的 AT24C02 模块,那至少确认模块上的上拉电阻是接到 3.3V 还是 5V。如果接到 5V,SDA/SCL 空闲时是 5V,STM32 这边虽然能容忍,但长期看不是好设计。稳妥的做法是把模块上的上拉电阻改接到 3.3V,或者用专用的 I2C 电平转换芯片。

2.3 电源去耦与写保护引脚

AT24C02 的 VCC 引脚旁边必须放一个 0.1μF 的陶瓷电容,越靠近芯片越好。这个电容的作用是滤除电源上的高频噪声,尤其是在写操作时,EEPROM 内部电荷泵工作会产生瞬态电流,没有去耦电容的话,电源电压可能瞬间跌落,导致写失败。

AT24C02 还有一个 WP(Write Protect)引脚。这个引脚接地时,允许正常读写;接 VCC 时,写保护生效,只能读不能写。很多模块默认把 WP 接地,但如果你自己画板子,建议把 WP 通过一个 10kΩ 电阻接地,同时留一个跳线或焊盘,方便以后需要写保护时改接 VCC。我遇到过有人调试了半天写不进去,最后发现是 WP 引脚悬空,被内部电路拉高了,导致写保护生效。悬空引脚在数字电路里是大忌,一定要给它一个确定的电平。

3. I2C 协议层:时序参数与 STM32F103 硬件 I2C 配置

3.1 I2C 时序的关键参数计算

I2C 通信的可靠性建立在时序参数满足规范的基础上。STM32F103 的硬件 I2C 外设通过 I2C_CR2 寄存器中的 FREQ 字段和 I2C_CCR 寄存器来配置时序。很多人直接抄 CubeMX 生成的代码,不知道这些值是怎么来的,一旦换晶振频率或者换速率就出问题。

先看 FREQ 字段。它告诉 I2C 外设,APB1 总线的时钟频率是多少。STM32F103 的 I2C1 挂在 APB1 上,APB1 的最高频率是 36MHz。如果你用 72MHz 主频,APB1 分频系数为 2,则 APB1 时钟为 36MHz,FREQ 字段应填 36。这个值必须是实际 APB1 时钟的 MHz 数,不能随便填。

再看 CCR 寄存器。在标准模式(100kHz)下,CCR 的计算公式是:CCR = T_high / T_PCLK1,其中 T_high 是 SCL 高电平时间。I2C 标准模式要求 T_high ≥ 4.0μs,T_low ≥ 4.7μs。T_PCLK1 = 1/36MHz ≈ 27.8ns。所以 CCR = 4.0μs / 27.8ns ≈ 144。但实际配置时,CCR 的值还要考虑上升沿时间,通常取 180 到 200 左右能稳定跑 100kHz。

在快速模式(400kHz)下,CCR 的计算公式是:CCR = T_high / T_PCLK1,T_high ≥ 0.6μs,T_low ≥ 1.3μs。CCR = 0.6μs / 27.8ns ≈ 22。但实际取值通常比计算值大一些,因为要留余量给上升沿。我一般取 30 到 36 之间,实测 400kHz 下很稳。

还有一个容易忽略的参数是 TRISE(上升沿时间)。I2C_TRISE 寄存器的值 = (tr / T_PCLK1) + 1。标准模式下 tr ≤ 1000ns,TRISE = 1000ns / 27.8ns + 1 ≈ 37。快速模式下 tr ≤ 300ns,TRISE = 300ns / 27.8ns + 1 ≈ 12。这个值如果设大了,SCL 上升沿会被拉长,实际速率下降;设小了,可能违反时序规范。CubeMX 会自动算这个值,但你要知道它是怎么来的。

3.2 开漏模式与推挽模式的取舍

I2C 的 SDA 和 SCL 必须配置为开漏输出(GPIO_Mode_AF_OD),这是 I2C 协议的要求。开漏模式下,引脚只能输出低电平或高阻态,高电平靠外部上拉电阻实现。这样才能实现“线与”逻辑:总线上任何一个设备拉低,整条线就是低;所有设备都释放,线才被上拉电阻拉高。

推挽模式(GPIO_Mode_AF_PP)是绝对不能用的。推挽输出会主动输出高电平,如果两个设备同时输出,一个输出高一个输出低,就会形成电源到地的低阻通路,大电流烧毁引脚。我见过有人把 I2C 引脚配成推挽,结果一上电两个设备就发烫,幸好发现得早没烧毁。

在 CubeMX 里配置 I2C 引脚时,它会自动把引脚设为开漏复用模式,你不需要手动改。但如果你用寄存器直接写代码,一定要记得设置 GPIO_CRL 或 GPIO_CRH 寄存器对应的位为开漏复用输出。

3.3 硬件 I2C 的状态机与中断处理

STM32F103 的硬件 I2C 是一个状态机,每个操作步骤都会产生一个状态码,存放在 I2C_SR1 和 I2C_SR2 寄存器中。比如发送起始条件后,SR1 的 SB 位被置 1;发送地址后,如果收到 ACK,ADDR 位被置 1;发送数据后,如果收到 ACK,TXE 位被置 1,等等。

用轮询方式写 I2C 代码时,典型的结构是:发送起始条件,等待 SB 置位;发送设备地址+写方向,等待 ADDR 置位;发送寄存器地址,等待 TXE 置位;发送数据,等待 TXE 置位;发送停止条件。每一步都要检查对应的状态位,并且要处理超时,否则一旦某个状态位永远不置位,程序就卡死了。

我强烈建议在每一个等待循环里加超时计数。比如:

uint32_t timeout = 100000; while (!(I2C1->SR1 & I2C_SR1_SB)) { if (--timeout == 0) { return I2C_TIMEOUT; } }

这个超时值要根据你的主频和 I2C 速率来估算。100000 次循环在 72MHz 下大约几毫秒,足够覆盖正常的 I2C 操作。如果超时了,说明硬件有问题,直接返回错误,不要死等。

用中断方式的话,逻辑更复杂,但 CPU 占用低。中断服务函数里根据 SR1 的状态位判断当前处于哪个阶段,然后执行下一步操作。这种方式适合 I2C 操作频繁、CPU 还要处理其他任务的场景。但调试难度大,新手建议先用轮询方式把逻辑跑通,再考虑改中断。

4. AT24C02 器件层:设备地址、页写边界与写周期

4.1 设备地址的确定

AT24C02 的 7 位设备地址是1010加上 A2、A1、A0 三个引脚的电平。A2/A1/A0 接地时,设备地址是1010000,即 0x50。加上读写位后,写地址是 0xA0,读地址是 0xA1。

这里有一个常见的坑:很多人把 7 位地址和 8 位地址搞混。在 STM32 的硬件 I2C 中,发送地址时,你需要把 7 位地址左移一位,再或上读写位。比如写操作:(0x50 << 1) | 0x00 = 0xA0;读操作:(0x50 << 1) | 0x01 = 0xA1。如果你直接发 0x50,STM32 会把它当成 8 位地址,低位的读写位就错了,AT24C02 不会应答。

如果你板子上挂了多个 AT24C02,通过 A2/A1/A0 引脚区分,地址分别是 0x50 到 0x57。注意 AT24C02 只有 256 字节,地址范围 0x00 到 0xFF,一个字节的寄存器地址就够了。如果是 AT24C32 或更大的型号,需要两个字节的寄存器地址,读写逻辑会有所不同。

4.2 页写边界:8 字节的“隐形墙”

AT24C02 的页大小是 8 字节。这意味着你可以一次性连续写入最多 8 个字节,但不能跨页。什么叫跨页?比如你从地址 0x06 开始写 4 个字节,写到 0x06、0x07、0x08、0x09。0x06 和 0x07 在第 0 页(0x00-0x07),0x08 和 0x09 在第 1 页(0x08-0x0F)。当你写到 0x08 时,AT24C02 内部地址计数器会“回卷”到第 0 页的开头,也就是 0x00,而不是继续到 0x08。结果就是 0x08 的数据被写到了 0x00,0x09 的数据被写到了 0x01,把原来的数据覆盖了。

这个坑非常隐蔽,因为写操作本身会成功(AT24C02 会应答),但数据写到了错误的位置。你读回来发现数据不对,还以为是 I2C 通信问题,其实是页写边界问题。

正确的做法是:在页写之前,先计算当前地址到页边界的剩余空间。如果剩余空间不够,就分多次写。比如从 0x06 开始写 4 个字节,先写 2 个字节到 0x06、0x07,然后重新发起一次写操作,从 0x08 开始写剩下的 2 个字节。每次写操作之间要等待写周期完成。

void AT24C02_WriteBuffer(uint8_t addr, uint8_t *data, uint16_t len) { while (len > 0) { uint8_t page_remain = 8 - (addr % 8); uint8_t write_len = (len < page_remain) ? len : page_remain; AT24C02_PageWrite(addr, data, write_len); AT24C02_WaitForWriteComplete(); addr += write_len; data += write_len; len -= write_len; } }

这段代码的核心逻辑就是:每次写入不超过当前页剩余空间,写完后等待写周期,然后继续写下一段。

4.3 写周期时间:5ms 的等待

AT24C02 在每次写操作(字节写或页写)之后,需要一段时间把数据从内部缓冲器写入 EEPROM 存储单元,这个时间叫写周期时间(tWR),典型值是 5ms,最大 5ms。在这 5ms 内,AT24C02 不会应答任何 I2C 操作,你发什么它都不理。

如果你在写操作之后立刻发起读操作,AT24C02 不会应答,STM32 会收到 NACK,读操作失败。所以每次写完之后,必须等待写周期完成。有两种等待方式:

第一种是延时 5ms。简单粗暴,但效率低。如果你要写 256 字节,按 8 字节一页写,需要 32 次写操作,每次等 5ms,总共 160ms。对于大多数应用来说,这个时间可以接受。

第二种是“应答轮询”(Acknowledge Polling)。写操作之后,反复发起起始条件并发送设备地址,如果 AT24C02 应答了,说明写周期完成;如果没应答,继续轮询。这种方式比固定延时更高效,因为写周期可能小于 5ms,轮询能尽早发现写完成。

void AT24C02_WaitForWriteComplete(void) { uint32_t timeout = 100000; do { I2C_GenerateSTART(I2C1, ENABLE); while (!(I2C1->SR1 & I2C_SR1_SB) && --timeout); I2C_Send7bitAddress(I2C1, 0xA0, I2C_Direction_Transmitter); while (!(I2C1->SR1 & (I2C_SR1_ADDR | I2C_SR1_AF)) && --timeout); if (I2C1->SR1 & I2C_SR1_ADDR) { I2C_ClearFlag(I2C1, I2C_FLAG_ADDR); I2C_GenerateSTOP(I2C1, ENABLE); return; } I2C_ClearFlag(I2C1, I2C_FLAG_AF); I2C_GenerateSTOP(I2C1, ENABLE); } while (timeout--); }

这段代码的逻辑是:发起始条件,发设备地址,如果收到 ACK(ADDR 置位),说明写周期完成,发停止条件返回;如果收到 NACK(AF 置位),清除标志,发停止条件,继续轮询。注意每次轮询都要重新发起始条件,因为 NACK 之后总线状态需要复位。

5. 实操全流程:从 CubeMX 配置到读写验证

5.1 CubeMX 配置要点

打开 CubeMX,选好 STM32F103C8T6,第一步配置时钟。在 RCC 里把 HSE 设为 Crystal/Ceramic Resonator,然后在 Clock Configuration 里把主频拉到 72MHz,APB1 分频系数设为 2,这样 APB1 时钟是 36MHz。

第二步配置 I2C1。在 Connectivity 里选 I2C1,Mode 选 I2C,Speed Mode 选 Fast Mode(400kHz)。CubeMX 会自动把 PB6 和 PB7 配置为 I2C1_SCL 和 I2C1_SDA,引脚模式是开漏复用。在 Configuration 里检查一下 I2C 的参数:Clock Speed 是 400000Hz,Duty Cycle 是 Tlow/Thigh = 2,这些默认值就可以。

第三步配置串口。为了打印调试信息,我一般会开一个 USART1,波特率 115200,用于 printf 重定向。这样读写的数据可以直接在串口助手里看,比用调试器单步跟踪方便得多。

第四步生成代码。Toolchain 选 MDK-ARM,勾选“Generate peripheral initialization as a pair of .c/.h files”,这样 I2C 的初始化代码会单独放在 i2c.c 里,方便管理。

5.2 读写函数的实现

先写一个基础的字节写函数:

uint8_t AT24C02_WriteByte(uint8_t addr, uint8_t data) { uint32_t timeout = 100000; // 发送起始条件 I2C_GenerateSTART(I2C1, ENABLE); while (!(I2C1->SR1 & I2C_SR1_SB)) { if (--timeout == 0) return 1; } // 发送设备地址+写 I2C_Send7bitAddress(I2C1, 0xA0, I2C_Direction_Transmitter); while (!(I2C1->SR1 & I2C_SR1_ADDR)) { if (I2C1->SR1 & I2C_SR1_AF) { I2C_ClearFlag(I2C1, I2C_FLAG_AF); I2C_GenerateSTOP(I2C1, ENABLE); return 2; } if (--timeout == 0) return 1; } I2C_ClearFlag(I2C1, I2C_FLAG_ADDR); // 发送寄存器地址 I2C_SendData(I2C1, addr); while (!(I2C1->SR1 & I2C_SR1_TXE)) { if (--timeout == 0) return 1; } // 发送数据 I2C_SendData(I2C1, data); while (!(I2C1->SR1 & I2C_SR1_TXE)) { if (--timeout == 0) return 1; } // 发送停止条件 I2C_GenerateSTOP(I2C1, ENABLE); // 等待写周期完成 AT24C02_WaitForWriteComplete(); return 0; }

再写一个字节读函数:

uint8_t AT24C02_ReadByte(uint8_t addr, uint8_t *data) { uint32_t timeout = 100000; // 先写寄存器地址(伪写) I2C_GenerateSTART(I2C1, ENABLE); while (!(I2C1->SR1 & I2C_SR1_SB)) { if (--timeout == 0) return 1; } I2C_Send7bitAddress(I2C1, 0xA0, I2C_Direction_Transmitter); while (!(I2C1->SR1 & I2C_SR1_ADDR)) { if (--timeout == 0) return 1; } I2C_ClearFlag(I2C1, I2C_FLAG_ADDR); I2C_SendData(I2C1, addr); while (!(I2C1->SR1 & I2C_SR1_TXE)) { if (--timeout == 0) return 1; } // 重新发送起始条件 I2C_GenerateSTART(I2C1, ENABLE); while (!(I2C1->SR1 & I2C_SR1_SB)) { if (--timeout == 0) return 1; } // 发送设备地址+读 I2C_Send7bitAddress(I2C1, 0xA1, I2C_Direction_Receiver); while (!(I2C1->SR1 & I2C_SR1_ADDR)) { if (--timeout == 0) return 1; } // 单字节读,发送 NACK 和停止条件 I2C_AcknowledgeConfig(I2C1, DISABLE); I2C_ClearFlag(I2C1, I2C_FLAG_ADDR); I2C_GenerateSTOP(I2C1, ENABLE); while (!(I2C1->SR1 & I2C_SR1_RXNE)) { if (--timeout == 0) return 1; } *data = I2C_ReceiveData(I2C1); I2C_AcknowledgeConfig(I2C1, ENABLE); return 0; }

读函数的逻辑比写函数复杂一点:先发起始条件,发写地址,发寄存器地址,然后重新发起始条件(这叫 Repeated Start),再发读地址,然后接收数据。单字节读的时候,在接收数据之前要发送 NACK,告诉 AT24C02 不要再发了,然后发停止条件。

5.3 连续读的实现

AT24C02 支持连续读:发完读地址后,可以连续接收多个字节,每接收一个字节,主机发 ACK,AT24C02 继续发下一个字节;接收最后一个字节时,主机发 NACK,然后发停止条件。

uint8_t AT24C02_ReadBuffer(uint8_t addr, uint8_t *buf, uint16_t len) { uint32_t timeout = 100000; // 伪写,设置寄存器地址 I2C_GenerateSTART(I2C1, ENABLE); while (!(I2C1->SR1 & I2C_SR1_SB)) { if (--timeout == 0) return 1; } I2C_Send7bitAddress(I2C1, 0xA0, I2C_Direction_Transmitter); while (!(I2C1->SR1 & I2C_SR1_ADDR)) { if (--timeout == 0) return 1; } I2C_ClearFlag(I2C1, I2C_FLAG_ADDR); I2C_SendData(I2C1, addr); while (!(I2C1->SR1 & I2C_SR1_TXE)) { if (--timeout == 0) return 1; } // Repeated Start I2C_GenerateSTART(I2C1, ENABLE); while (!(I2C1->SR1 & I2C_SR1_SB)) { if (--timeout == 0) return 1; } I2C_Send7bitAddress(I2C1, 0xA1, I2C_Direction_Receiver); while (!(I2C1->SR1 & I2C_SR1_ADDR)) { if (--timeout == 0) return 1; } I2C_ClearFlag(I2C1, I2C_FLAG_ADDR); // 接收数据 for (uint16_t i = 0; i < len; i++) { if (i == len - 1) { I2C_AcknowledgeConfig(I2C1, DISABLE); I2C_GenerateSTOP(I2C1, ENABLE); } else { I2C_AcknowledgeConfig(I2C1, ENABLE); } while (!(I2C1->SR1 & I2C_SR1_RXNE)) { if (--timeout == 0) return 1; } buf[i] = I2C_ReceiveData(I2C1); } I2C_AcknowledgeConfig(I2C1, ENABLE); return 0; }

注意在接收最后一个字节之前,要先发 NACK 和停止条件,然后再读数据。这个顺序不能反,否则会多读一个字节或者总线状态出错。

5.4 验证与调试

写一个简单的测试程序:向地址 0x00 写入 0x55,延时 10ms,读回来比较;向地址 0x08 写入 8 字节的递增序列,读回来比较;向地址 0x06 写入 4 字节,验证跨页处理是否正确。

void AT24C02_Test(void) { uint8_t data; uint8_t buf[8] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}; uint8_t read_buf[8]; // 单字节读写测试 AT24C02_WriteByte(0x00, 0x55); AT24C02_ReadByte(0x00, &data); printf("Byte test: write 0x55, read 0x%02X\r\n", data); // 页写测试 AT24C02_WriteBuffer(0x08, buf, 8); AT24C02_ReadBuffer(0x08, read_buf, 8); printf("Page test: "); for (int i = 0; i < 8; i++) { printf("0x%02X ", read_buf[i]); } printf("\r\n"); // 跨页写测试 AT24C02_WriteBuffer(0x06, buf, 4); AT24C02_ReadBuffer(0x06, read_buf, 4); printf("Cross-page test: "); for (int i = 0; i < 4; i++) { printf("0x%02X ", read_buf[i]); } printf("\r\n"); }

串口打印的结果应该是:Byte test 读出 0x55;Page test 读出 11 22 33 44 55 66 77 88;Cross-page test 读出 11 22 33 44。如果跨页测试读出的数据不对,比如变成了 11 22 00 00 或者 11 22 11 22,说明页写边界处理有问题。

6. 常见问题与排查技巧实录

6.1 读出来全是 0xFF 或 0x00

这是最常见的问题。0xFF 通常意味着 AT24C02 没有应答,STM32 读到的数据线一直是高电平(上拉电阻拉高)。0x00 则可能是数据线一直被拉低,或者读到了空数据。

排查步骤:先用示波器或逻辑分析仪看 SDA 和 SCL 波形。如果没有波形,检查 GPIO 配置是否正确,I2C 外设是否使能,时钟是否开启。如果有起始条件但地址发出去后没有 ACK,检查设备地址是否正确,A2/A1/A0 引脚电平是否和代码一致,上拉电阻是否接好。如果地址有 ACK 但数据不对,检查寄存器地址是否发对,读写方向位是否正确。

我遇到过一次,读出来全是 0xFF,查了半天发现是 AT24C02 的 VCC 没接,芯片根本没工作。所以第一步永远是确认电源和地。

6.2 写进去读出来对不上

如果写操作返回成功(收到 ACK),但读出来数据不对,可能的原因有:写周期没等够,数据还没写入存储单元;页写跨页导致数据写到了错误地址;读操作的寄存器地址发错了。

先加长写周期等待时间,比如从 5ms 加到 10ms,看是否改善。如果改善了,说明写周期等待不够。然后检查页写边界,确保每次写入不超过页剩余空间。最后检查读写函数的地址参数是否一致。

6.3 程序卡死在 while 循环

STM32F103 的硬件 I2C 在某些情况下会卡死,比如总线被拉低、状态位永远不置位。解决方法是在每个 while 循环里加超时计数,超时后返回错误,并尝试复位 I2C 外设。

复位 I2C 外设的方法:先关闭 I2C 时钟,再打开 I2C 时钟,然后重新初始化 I2C。这相当于给 I2C 外设做了一次软复位。

void I2C_Reset(void) { RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, DISABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); I2C_DeInit(I2C1); I2C_Init(I2C1, &I2C_InitStructure); I2C_Cmd(I2C1, ENABLE); }

如果复位后还是卡死,可能是硬件问题,比如 SDA 或 SCL 被某个设备永久拉低。这时候需要断电重启,或者检查总线上是否有设备故障。

6.4 常见问题速查表

现象可能原因排查方法
读出来全是 0xFF从设备无应答检查电源、地址、上拉电阻
读出来全是 0x00数据线被拉低检查是否有设备故障,断开从设备单独测
写进去读出来不对写周期不够或跨页加长等待时间,检查页写边界
程序卡死状态位不置位加超时,复位 I2C 外设
偶尔通信失败上拉电阻过大或走线过长减小上拉电阻,缩短走线
400kHz 下不稳定上升沿太慢减小上拉电阻到 2.2kΩ

6.5 实操心得

第一,先软后硬。先用软件模拟 I2C 把 AT24C02 的读写逻辑跑通,确认硬件连接没问题,再切换到硬件 I2C。这样如果硬件 I2C 出问题,你可以确定是 I2C 外设配置的问题,而不是硬件连接的问题。

第二,示波器是最好的朋友。I2C 通信问题,看波形比看代码快得多。一个 200 块钱的逻辑分析仪就能抓 I2C 波形,能省下大量调试时间。

第三,超时保护必须有。不管你的代码写得多完美,硬件总会有出问题的时候。每个等待循环都加超时,超时后返回错误并尝试恢复,这样程序不会卡死,系统还能继续运行。

第四,写周期等待用轮询。固定延时 5ms 虽然简单,但效率低。用应答轮询,写周期一完成就能继续,整体写入速度能快不少。尤其是写大量数据的时候,差别很明显。

第五,AT24C02 的寿命是 100 万次写。如果你在循环里频繁写同一个地址,比如每秒写一次,大约 11 天就会写坏。对于需要频繁更新的数据,要么加缓存,要么用磨损均衡算法,要么换 FRAM 或 MRAM 这类无限次写的存储器。

7. 从 AT24C02 延伸到其他 I2C 器件的通用思路

AT24C02 只是一个入口。掌握了它的读写逻辑,你会发现大部分 I2C 器件的操作套路是相似的:发起始条件,发设备地址+写,发寄存器地址,发数据或 Repeated Start 读数据,发停止条件。区别只在于设备地址不同、寄存器地址宽度不同(有的 8 位,有的 16 位)、数据格式不同。

比如 OLED 显示屏(SSD1306),设备地址是 0x3C 或 0x3D,寄存器地址是 8 位,但数据前面要加一个控制字节(0x00 表示命令,0x40 表示数据)。再比如温度传感器(LM75),设备地址是 0x48 到 0x4F,寄存器地址是 8 位,读温度时直接读两个字节,高字节在前。

如果你要操作 16 位寄存器地址的 EEPROM(比如 AT24C32),读写函数需要发两个字节的寄存器地址,高字节在前。其他逻辑和 AT24C02 一样。

I2C 总线上挂多个设备时,要注意总线电容。每个设备的引脚都有几 pF 的电容,设备多了总线电容增大,上升沿变慢。这时候要么减小上拉电阻,要么降低通信速率。我一般会在总线上挂 3 到 4 个设备时,把上拉电阻从 4.7kΩ 降到 2.2kΩ,速率从 400kHz 降到 200kHz,保证稳定性。

最后再分享一个小技巧:如果你在调试 I2C 时怀疑某个设备有问题,可以先用 I2C 扫描程序扫一遍总线,看看哪些地址有应答。这个程序很简单,就是遍历 0x00 到 0x7F 的地址,逐个发起始条件和设备地址,看是否收到 ACK。扫出来的地址列表能帮你快速确认设备是否在线、地址是否配错。这个工具我在每次调试新 I2C 设备时都会先用一遍,能省下不少猜测的时间。

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

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

立即咨询