☰
STM32软件模拟I2C读写AT24C02:从协议时序到工程实践全解析
2026/10/7 1:15:39 网站建设 项目流程

直接说结论:这篇内容适合所有想彻底搞懂 I2C 通信和 EEPROM 读写的朋友,无论你是刚点亮第一个 LED 的新手,还是被硬件 I2C 的 Bug 折磨过的老手,都能在这里找到对应的解决方案。文章会从最底层的协议时序讲起,到 AT24C02 的存储原理,再到 STM32F103 上的完整代码实现,最后连排查硬件问题的思路和调试技巧也一并奉上。我写这篇文章时尽量不讲废话,每个环节都按照实际跑通项目的顺序来,你完全可以照着一步步复现。

1. 为什么用软件模拟 I2C 而不是硬件 I2C:一次权衡后的选择

1.1 硬件 I2C 的纠缠:STM32F103 的一个经典痛点

STM32F103 内置了硬件 I2C 外设,但很多从 STM32 入门一路走过来的开发者,都有过被这个硬件外设折腾得死去活来的经历。它的状态寄存器 I2C_SR1 和 I2C_SR2 配合起来判断总线状态时,逻辑非常绕,尤其容易在“忙标志位”上卡死——总线明明已经释放了,硬件却还认为它忙,导致后续通信直接罢工。为了解决这个问题,网上流传着各种奇葩招数,比如复位外设、延时等待、调整中断优先级,但都治标不治本。

当然我不是说硬件 I2C 绝对不能用,如果你的项目对时序稳定性要求非常高(比如高速连续读写),且你愿意深入啃参考手册 RM0008 里 I2C 那一百多页的细节,硬件外设放电,然后重新初始化,是最粗暴但有效的复位方式。这里恨不能一笔带过。我的选择是,如果项目里只有一颗 EEPROM 这类低速设备,用 GPIO 模拟 I2C 足矣。

1.2 软件模拟的优势:可控性和可移植性

软件模拟 I2C 的好处,就是用 GPIO 的电平翻转去模仿 I2C 协议的时序,整个流程完全在你的掌控之内。时钟线产生多快、数据线什么时候切换、从设备没应答时你怎么处理——每一步的过程你都看得见摸得着。

代码的可移植性更是没得说。今天你用的是 STM32F103,明天换了 STM32F407,后天用上 CH32V307,只要把底层两个 GPIO 管脚的宏定义改一改,剩下协议层代码可以直接复制粘贴。这对喜欢用 Proteus 仿真(比如仿真 STM32F103 驱动 OLED12864 或 AT24C02)的朋友来说更是个福音,因为仿真里硬件 I2C 模型时常有奇怪行为,而软件模拟完全绕开了这套机制。

1.3 什么时候不用软件模拟

不过我要说句公道话:软件模拟也有它的短板。一是它需要 CPU 全程参与,反复翻转 GPIO 会占用不少主循环时间,不适合高速场景。二是它受中断影响很大,如果你在通信过程中频繁进出中断,时序可能被拉长甚至破坏,对实时性要求苛刻的项目需要特别小心。

所以我的最终建议是:功能验证、学习原理、多平台移植——用软件模拟;量产产品、高速传输、有 DMA 需求——老老实实调通硬件 I2C。下面的内容,我以软件模拟为主线,最后会提一下怎么迁移到硬件 I2C。

2. AT24C02 的存储机制:只有弄懂它才能正确读写

2.1 芯片引脚与内存地址映射

AT24C02 是 Atmel(现在叫 Microchip)家的经典 EEPROM 芯片,容量 2Kbit,也就是 256 字节。它的内存地址范围是 0x00 到 0xFF,属于最基础的串行 EEPROM 类型。芯片有 8 个引脚:A0、A1、A2 是地址选择脚,WP 是写保护脚,SCL 接时钟,SDA 接数据,VCC 和 GND 接电源。

地址选择脚的接法直接决定了芯片在 I2C 总线上的从设备地址。I2C 总线上一开始要发送一个设备地址字节,AT24C02 的地址是 0xA0 作为基地址,实际上它由固定部分 1010 加 A2 A1 A0 加 R/W 位组成。如果 A0、A1、A2 全部接地,那么设备地址写操作就是 0xA0,读操作就是 0xA1。如果你在同一条 I2C 总线上挂了两颗 AT24C02,把其中一颗的 A0 接 VCC,那么它的地址就变成了 0xA2,这样就能区分开了。这个细节在项目里非常实用。

2.2 页写入与页边界:新手最容易踩的坑

AT24C02 有一个很微妙又很重要的特性——页写入。它内部把 256 字节分成了 32 页,每页 8 字节。写入操作支持连续写多个字节,但有一个限制:你一次连续写入的字节数不能跨页边界,而且在页写入模式下,如果你写入的数据超过了当前页的末尾,地址会自动回卷到该页的起始位置,而不是顺序进入下一页。

举个例子:你往地址 0x07 连续写入 3 个字节,数据会写到 0x07、0x08、0x09 吗?不会——因为 0x07 是第 0 页的最后一个字节,之后字节会回卷到 0x00,把 0x00 和 0x01 覆盖掉。这个坑我见过太多人踩了,写出的数据地址错乱,排查半天找不到原因,到最后一查参考手册里那句英文才恍然大悟。解决办法很简单:写多字节前,先计算从当前地址到页末尾还剩多少字节,超过就拆分写入。

2.3 写周期延时:EEPROM 的“刷牙时间”

EEPROM 写入数据和读数据不一样,写操作需要芯片内部通过电荷泵把电荷注入浮栅,这个过程需要时间,通常叫做写周期 tWR。AT24C02 的典型写周期是 5ms,也就是你发出一个完整的写命令后,不能马上進行下一次操作,必须等待 5ms 以上。这有点像你刷牙要刷满两分钟才有效果——命令发出去了,芯片内部还在忙,你这时候去问它“好了没”,它给你的唯一反馈就是 SDA 线保持高电平不响应。

很多人的代码逻辑看起来没错,但实际运行时老是丢数据,原因就出在连续写入时没有等待写周期完成。常见的等待方式有两种:一种是写完后固定延时 5ms 或 10ms,简单粗暴但可靠;另一种是采用 ACK 轮询——写完一个字节后再次发送起始信号,看从设备能不能拉低 SDA 回应答,如果可以了,说明内部写完成。后者省时间,但在软件模拟场景下,我更推荐前者的简单可靠,为什么后面细说。

3. I2C 协议时序:逐位拆解通信的骨架

3.1 起始条件、停止条件与空闲状态

I2C 通信在总线空闲时,SCL 和 SDA 都保持高电平(这正是为什么两颗上拉电阻必须接上)。要开始一次通信,主机需要产生一个起始条件(START),时序是:在 SCL 为高电平时,SDA 从高电平跳变到低电平。注意,这个跳变必须发生在 SCL 高电平期间,这是从设备判断“数据开始”的唯一信号。

停止条件(STOP)正好相反:在 SCL 高电平时,SDA 从低电平跳变到高电平。起始和停止都是 SDA 线在 SCL 高电平期间发生的电平变化,这是它和数据位传输(数据位只在 SCL 低电平时改变)最本质的区别。

代码实现也很直白,无非是把 GPIO 电平翻转封装成几个基础函数。这里有一个容易被忽略的细节:起始条件的 SDA 要先拉高再拉低,不能直接拉低,因为总线上可能残留上一个操作的时序混乱状态,先恢复高电平可以确保芯片正确识别起始。

3.2 数据位传输与应答位机制

正式的数据传输过程中,每传输一位数据都需要配合一个 SCL 时钟脉冲。主机在 SCL 低电平时把 SDA 引脚设置为输出,并放置数据(高电平为1,低电平为0),等到 SCL 拉高,从设备在 SCL 高电平期间读取 SDA 电平,接着 SCL 拉低,准备下一位。数据的传输顺序是从高位开始,也就是 MSB first。

经过 8 个 SCL 脉冲传完一个字节后,根据是写还是读,决策不同。写情况之下:主机在第 9 个时钟脉冲释放 SDA(变为输入模式),如果从设备正确收到数据并写入内部寄存器,它会主动把 SDA 拉低,这就是 ACK 应答。如果从设备不响应(比如地址不对、芯片损坏、设备正忙),SDA 保持高电平,就是 NACK。读情况之下:主机作为接收方,在第 9 个脉冲时可以选择发出 ACK(表示还要继续读下一字节)或者 NACK(表示就到这里,之后主机发出停止条件)。

3.3 字节的发送与接收基础函数

把这些协议抽象成函数,就是整个 I2C 软件模拟的核心代码。发送一个字节的函数逻辑很简单:循环 8 次,每次取数据最高位,放到 SDA 上,然后拉高 SCL、延时、拉低 SCL,最后释放 SDA 等待 ACK。接收一个字节的函数则相反,每次 SCL 高电平时读取 SDA 电平,移位拼接成字节。这两个函数是整个通信的基础,后面所有读写操作都是基于它们的排列组合。

延时参数很关键。I2C 标准模式下 SCL 频率是 100kHz,快速模式是 400kHz。软件模拟时,把时钟线拉高、拉低之间的延时控制在 1~5 微秒基本没问题,主要看你系统的主频和 GPIO 翻转速度。主频 72MHz 的 STM32F103,翻转一次 GPIO 大约 50~100ns,理论上最快能把 SCL 做到几百 kHz。不过实际工程里我会把延时调大一点,给自己留缓冲,也防止线上信号质量变差时通信出错。

4. AT24C02 读写全流程:从单字节到页写入的代码实现

4.1 基础宏定义与 I2C 初始化

这部分我做了一个完整的代码示例,按实际工程的组织习惯来写。先是管脚定义,我用 PB6 做 SCL,PB7 做 SDA,原因很简单——这个组合在最小系统板和很多开发板上都是预留好的位置,方便接线。如果你要改成 PA 口,只需要更换宏定义里的 GPIO 名字。

#define I2C_SCL_GPIO_PORT GPIOB #define I2C_SCL_GPIO_PIN GPIO_Pin_6 #define I2C_SDA_GPIO_PORT GPIOB #define I2C_SDA_GPIO_PIN GPIO_Pin_7 #define I2C_SCL_HIGH() GPIO_SetBits(I2C_SCL_GPIO_PORT, I2C_SCL_GPIO_PIN) #define I2C_SCL_LOW() GPIO_ResetBits(I2C_SCL_GPIO_PORT, I2C_SCL_GPIO_PIN) #define I2C_SDA_HIGH() GPIO_SetBits(I2C_SDA_GPIO_PORT, I2C_SDA_GPIO_PIN) #define I2C_SDA_LOW() GPIO_ResetBits(I2C_SDA_GPIO_PORT, I2C_SDA_GPIO_PIN)

初始化函数里有一点和普通 GPIO 初始化不同:SDA 线在等待 ACK 时需要切换方向,所以我把它配置为开漏输出模式。开漏输出的好处是既能拉低,又能释放总线(变高阻态),配合外部上拉电阻可以把电平拉起。如果用了推挽输出,释放 SDA 的动作做不出来,总线时序就乱了。SCL 也需要开漏模式,因为 SCL 在有些情况(比如时钟拉伸,主要是某些从设备的特殊能力)下也可能需要从设备来拉低,虽然 AT24C02 不支持时钟拉伸,但养成开漏输出的习惯对后续移植很有帮助。

void I2C_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = I2C_SCL_GPIO_PIN | I2C_SDA_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); I2C_SDA_HIGH(); I2C_SCL_HIGH(); }

初始化完成之后别忘了检查外部电路:这两根线上必须各有一个上拉电阻,阻值常见 4.7kΩ。如果你用的是现成开发板或最小系统板,多数已经集成;如果是自己画 PCB,这个电阻忘记焊的话,I2C 总线直接瘫痪,SDA 根本拉不高,所有通信都失败。

4.2 核心时序层:起始、停止、发送字节、接收字节

void I2C_Start(void) { I2C_SDA_HIGH(); I2C_SCL_HIGH(); delay_us(5); I2C_SDA_LOW(); delay_us(5); I2C_SCL_LOW(); } void I2C_Stop(void) { I2C_SDA_LOW(); I2C_SCL_HIGH(); delay_us(5); I2C_SDA_HIGH(); delay_us(5); } uint8_t I2C_SendByte(uint8_t data) { uint8_t i; uint8_t ack; for (i = 0; i < 8; i++) { if (data & 0x80) I2C_SDA_HIGH(); else I2C_SDA_LOW(); data <<= 1; delay_us(2); I2C_SCL_HIGH(); delay_us(3); I2C_SCL_LOW(); delay_us(2); } // 第9个时钟:释放SDA,读取应答 I2C_SDA_HIGH(); delay_us(2); I2C_SCL_HIGH(); delay_us(3); ack = GPIO_ReadInputDataBit(GPIOB, I2C_SDA_GPIO_PIN); I2C_SCL_LOW(); delay_us(2); return ack; } uint8_t I2C_ReceiveByte(uint8_t ack) { uint8_t i; uint8_t data = 0; I2C_SDA_HIGH(); // 释放总线,准备读取 for (i = 0; i < 8; i++) { delay_us(2); I2C_SCL_HIGH(); delay_us(3); if (GPIO_ReadInputDataBit(GPIOB, I2C_SDA_GPIO_PIN)) data |= 0x80; I2C_SCL_LOW(); delay_us(2); if (i < 7) data <<= 1; } // 第9个时钟:发出应答或非应答 if (ack) I2C_SDA_LOW(); else I2C_SDA_HIGH(); delay_us(2); I2C_SCL_HIGH(); delay_us(3); I2C_SCL_LOW(); delay_us(2); I2C_SDA_HIGH(); return data; }

4.3 设备地址拼装与写操作实现

有了上面这些底层函数,写 AT24C02 就变成了按部就班地发出命令序列。写一个字节到指定地址的操作,完整时序是这样的:起始条件→发送设备地址(0xA0)→发送内存地址→发送数据→停止条件。

#define AT24C02_ADDR_W 0xA0 #define AT24C02_ADDR_R 0xA1 uint8_t AT24C02_WriteByte(uint8_t mem_addr, uint8_t data) { I2C_Start(); if (I2C_SendByte(AT24C02_ADDR_W) == 1) // 非应答说明设备不存在 { I2C_Stop(); return 1; } I2C_SendByte(mem_addr); I2C_SendByte(data); I2C_Stop(); delay_ms(5); // 等待内部写周期完成 return 0; }

你可能会好奇,为什么设备地址发送失败时要停止并返回?原因很现实:如果总线上其他从设备或者根本没接这颗芯片,你继续往下发送内存地址和数据,毫无意义,只会浪费时间。提前退出并返回错误码,比闷头往下执行要好得多——这是我在调试多个 I2C 设备共存的总线时学到的经验。

4.4 页写入的实现与拆分逻辑

如果要连续写入多个字节,直接用页写入能大幅节省通信时间。但前面提到过页边界回卷的坑,所以我在写批量数据函数里做了一件事:先计算当前页剩余空间,再限制本次写入长度,然后按页边界拆分成若干段写入。

void AT24C02_WritePage(uint8_t mem_addr, uint8_t *buf, uint8_t len) { uint8_t page_remain; uint8_t chunk; uint8_t i; while (len > 0) { // 当前页剩余字节数 = 8 - (mem_addr % 8) page_remain = 8 - (mem_addr % 8); chunk = (len > page_remain) ? page_remain : len; I2C_Start(); I2C_SendByte(AT24C02_ADDR_W); I2C_SendByte(mem_addr); for (i = 0; i < chunk; i++) { I2C_SendByte(*buf++); } I2C_Stop(); delay_ms(5); // 每一页写完都要等写周期 mem_addr += chunk; len -= chunk; } }

这里我提一个容易忽略的细节:连续写入多个字节时,芯片内部会自动把内存地址加一,所以你在页写入过程中只需要连续发送数据即可,不需要每次单独送内存地址。前提是没超过页边界——超过页码回卷,数据就乱套了。函数里那个page_remain计算就是为了防止这种情况。

4.5 读操作:立即读、随机读、顺序读

读操作比写操作稍微绕一点,因为要先“假写”一下来确定你想要的地址。具体流程:起始→发送设备地址(写模式0xA0)→发送内存地址→重新发送起始(这就是所谓的 repeated start 或 restart)→发送设备地址(读模式0xA1)→读取数据→停止。

uint8_t AT24C02_ReadByte(uint8_t mem_addr) { uint8_t data; I2C_Start(); I2C_SendByte(AT24C02_ADDR_W); I2C_SendByte(mem_addr); // 重复起始 I2C_Start(); I2C_SendByte(AT24C02_ADDR_R); data = I2C_ReceiveByte(0); // 最后一个字节发 NACK I2C_Stop(); return data; }

顺序读取(从上一次读的地址继续往下读)的原理是:每次你读完一个字节,如果向从设备发送 ACK,芯片就会自动把地址加一,继续输出下一个字节。连续读 256 字节时,最后一个字节之前要发 ACK,读到最后一个字节时要发 NACK 再停止——如果始终发 ACK 且从不停止,读完之后芯片会继续输出,但地址会回卷,数据就乱了。

引出一个经验:读多字节时,尤其是超过页边界时,读操作没有写那样的页边界限制。读地址加一是芯片端的自动进位,到 0xFF 后会回到 0x00,这个行为在参考手册里有说明,记住就好。

5. 实测中发现的高频坑:写周期、上拉电阻、电平转换

5.1 写周期:为什么数据“丢”了,然后又出现在奇怪的地方

我不知道你们有没有遇到过这个现象:往 EEPROM 里写了数据,阅读的时候发现——第一个字节写进去是对的,后面几个全丢了,或者读出来的是上一轮残留的数据。大多数人会以为是代码 BUG 或者芯片坏了,反复调试,最后发现,只是没有等待内部写周期完成就直接发出了下一笔写命令。

事情的真相是:AT24C02 在执行内部写操作的 5ms 内,芯片完全不响应外部命令,此时你发出的任何起始条件、命令字节,它都“假装听不见”。等你 5ms 后想看它写了什么,发现数据确实没存进去,它就一脸无辜。调试这种问题,建议用逻辑分析仪抓信号,非常直白——你会看到第二笔写命令的设备地址根本没有 ACK。

也有一个更加高效的轮询方式。在发出写命令之后,不固定延时,而是反复发送“写地址字节”,如果芯片返回 ACK,说明内部写周期已结束。这种做法的优点是省时间,比如写很多页时能把总耗时从几十毫秒砍到几毫秒。代价是代码逻辑复杂一点。我在实际批量写数据比较多时经常用这个技巧,给你们一个实现思路:发一次起始,然后只发送设备地址(0xA0),检查应答位,有应答就表示可以继续操作,无应答就循环等待,直到超时退出。

5.2 上拉电阻必须要有,这事实在强调多少遍都不算多

I2C 总线是开漏结构,SDA 和 SCL 本身不会被主动拉高,只能靠外部上拉电阻。如果你把这两根线直接接到 STM32F103 的 GPIO(设置为开漏输出)上,却发现通信失败,先不要怀疑代码,拿万用表量一下 SCL 和 SDA 对地电压,如果是 0V 而不是 3.3V,那必然是上拉电阻缺失或虚焊。

阻值选择:标准模式用 4.7kΩ 到 10kΩ,快速模式建议 2.2kΩ 到 4.7kΩ。阻值太小,灌电流太大,能力弱的 GPIO 拉低会拉不动;阻值太大,RC 延迟明显,SCL 上升沿变缓,通信速率上不去。我常用 4.7kΩ,基本兼顾了稳定性和速率。

这里还要插一句:如果你使用的模块板载了上拉电阻,且模块供电电压是 3.3V,那没问题;如果模块是 5V 供电(有些 AT24C02 模块可以直接接 5V),它的上拉电阻接的是 5V,那你拿到只支持 3.3V 的 MCU 上直接通信,IO 电平不匹配,长期使用有烧毁风险。遇到这种情况,需要用 5V 转 3.3V 电平转换电路,最简单的方式是两个 MOS 管搭双向电平转换。

5.3 5V 供电模块接 3.3V MCU:电平转换的一个实用方案

网上有大量 AT24C02 模块是 5V 兼容的,因为 AT24C02 本身供电范围能到 5.5V,很多人就直接供 5V。而 STM32F103 的 GPIO 耐压不超过 3.6V(除非是 FT 容忍类型的引脚,但电压推荐也不要超过 4V 左右),把 5V 上拉后的 SDA 直接接进去,时间长了,引脚内部保护二极管可能会出问题。

解决思路不是换芯片,而是做一个电平转换电路。最简单的是 MOSFET 双向电平转换方案——两个 N-MOS 管,一个接 SDA,一个接 SCL。3.3V 侧接 MCU,5V 侧接模块,中间靠 MOS 管的导通特性实现双向传输。这个电路的细节:3.3V 侧需要上拉到 3.3V,5V 侧上拉到 5V,MOS 管门极接 3.3V,当任何一侧拉低时,另一侧也会被拉低,高电平时各自被上拉到自己的电压。这就是所谓的“低电平一致,高电平各自上拉”,对 I2C 这种开漏协议是完美适配。

5.4 Proteus 仿真时的一个特殊现象

你们知道搜索关键词里有一个“Proteus OLED12864 I2C”很热门,我在 Proteus 里仿真 STM32F103 驱动 AT24C02 时也踩过坑。Proteus 的 I2C 仿真模型对时序比较敏感,如果你的软件模拟 I2C 延时过短,SCL 频率太高,仿真模型经常出现“无应答”的意外现象。这不是你的代码错了,是仿真速度太快、模型反应不过来。在仿真工程里,把延时从 1~2 微秒加大到 5~10 微秒,通信立刻就稳定了。真机跑的时候再缩短,不影响。

如果你发现仿真里 EEPROM 写入后立即读取显示 0xFF,别慌。先确认是否等待了写周期。Proteus 的 EEPROM 模型在执行写指令后和真实芯片一样会有一段时间不响应,但它的“写周期结束”不是按时间去模拟的,而是按是否给它时钟判定的。所以仿真时,如果忽略延时,它很可能根本没有完成内部存储操作。解决办法依然是老老实实等 5ms 再读。

6. ULN2003 这类 5V 器件都扯不上关系,但 24C02 能接 5V

停顿一下,不要把话题扯开。回到正经的技术点:如果你用的是 0.9 寸 OLED 模块(SSD1306 驱动),它的 I2C 接口兼容性也要注意。SSD1306 的 I2C 地址也是 0x78/0x7A 这类,但有些模块的默认地址会和 AT24C02 的 0xA0 冲突吗?并不会,因为它们走不同的设备地址,I2C 总线本来就是多地址共存的。只是如果两者同时挂在一根总线上,上拉电阻只要一组就够了,不用重复加。实测下来,OLED 和 EEPROM 共存时总线电容会变大,SDA 上升沿变缓,如果你通信速率调高,可能偶发失败。降低 SCL 频率、或者干脆给两根线各加一颗 4.7kΩ 上拉,问题就解决了。

另一个常见误区是 0.9 寸 OLED 的 I2C 地址可能有两种:0x78(7 位地址 0x3C 左移一位)或 0x7A(7 位地址 0x3D 左移一位)。很多人在这上面耗时耗力,我用过很多模块,发现大多数默认是 0x78,但也有少数是 0x7A,判断方法就是看模块背面丝印或直接扫描总线。写一个 I2C 地址扫描函数,把所有 7 位地址循环发送一遍,看哪个地址有 ACK 回应,一下子就能找出所有挂载设备的地址,这对排错极其有用——也顺便能验证你的 AT24C02 地址是不是真的 0xA0。

7. Keil MDK 工程搭建与 ST-Link 调试的全流程

7.1 工程创建,少走弯路的配置

既然标题里有“STM32F103”,那必然绕不开 Keil MDK 工程搭建和 ST-Link 调试这两个基础步骤。很多刚上手的朋友把大量时间花在了“找不到头文件路径”“版本对不上”“Pack 包安装失败”这些环境问题上,代码本身反而没写几行。

我建议工程搭建顺序:先装 Keil MDK,然后装对应的 Device Pack。搜索词里提到的“STM32F103 pack 包下载”,指的是 Keil 的 Device Family Pack,如果你用的芯片是 STM32F103C8T6 这种主流货,直接在 Keil 的包管理器里搜索 STM32F1 系列,安装即可。要注意的是 Pack 版本和 Keil 版本要兼容,太老的 Keil 打不开新版的 Pack。

创建工程时,选择芯片型号时搜索 STM32F103C8 就好。新建工程后,你还需要把启动文件、系统文件这些全部想清楚。我一般用标准外设库(Standard Peripheral Library)来写代码,这篇文章的示例也都是基于这一套库的。如果你选择使用 CubeMX 生成 HAL 库工程,逻辑也差不多,只是把 GPIO 初始化换成了 HAL 风格——注意 CubeMX 生成的初始化里,GPIO 默认是输入模式还是输出模式,要确认改成开漏输出。

7.2 ST-Link 调试:用观察窗口盯时序变量

调试 I2C 最有效的手段是看变量变化和波形。在 Keil 里用 ST-Link 连接后,除了常规的打断点查看寄存器,你可以在 I2C_SendByte 函数里加断点,单步执行,观察 SDA 引脚对应的 GPIO IDR 寄存器值变化,或者更直接地,用示波器/逻辑分析仪看波形。没有硬件调试工具时,还可以用串口打印调试,把每一次通信的 ACK 状态发到上位机。

这里有一个实用技巧:在调试软件模拟 I2C 时,把 SCL 频率尽量降低(延时拉大),这样用逻辑分析仪抓波形时非常清晰,肉眼就能数出位序。我当时调通整套读写,就是靠逻辑分析仪抓取 SDA 波形,逐个核对每一段时序——起始之后 8 位地址、ACK 位、8 位内存地址、ACK 位、数据位、停止条件。只要波形能和人脑里的协议时序图对上,代码基本就没问题。

7.3 标准库和 HAL 库的 GPIO 开漏配置差异

这个值得单独说一句,很多人从标准库迁移到 HAL 库时,很容易漏掉开漏配置。标准库里是GPIO_Mode_Out_OD,HAL 库则是GPIO_MODE_OUTPUT_OD,同时还要配置GPIO_PULLUP,否则开漏模式下 SDA 在高电平位上有可能悬空。HAL 库初始化 GPIO 时连带设置内部上拉也行,但如果你外部已有 4.7kΩ 上拉,内部上拉不开也不会出问题。

8. 硬件排查链路:写完代码先问自己这几个问题

8.1 通信失败的定向排查思路

如果你按上面的代码写完,发现运行后读出来的全是 0xFF 或者数据不对,不要着急怀疑芯片坏了,按这个顺序排查,大概率能找到病根:

  1. 上电后量一下 VCC:AT24C02 的供电是否在正常工作范围(1.8V~5.5V)?如果模块独立供电,看看电源是否稳定。
  2. 万用表量 SDA 和 SCL 的空闲电压:都应该是接近供电电压的高电平。如果其中一根是低电平,那就有问题——要么上拉电阻缺失、要么接线错误、要么某个设备把总线一直拉低。
  3. 用 I2C 地址扫描程序:循环发送 7 位地址,看 0xA0/0xA1 地址是否有 ACK。如果扫描不到,先检查接线和设备地址配置脚。
  4. 确认延时参数:软件模拟 I2C 的延时如果太短,在面包板上容易因线路电容导致信号畸变。
  5. 区分“数据写不进”和“数据读不出”:写后用读命令验证,才确认是写入环节的问题还是读取环节的问题。

8.2 为什么读回数据总是 0xFF

读回 0xFF 的常见原因有三个:一是地址不对,比如你写进了 0x00,却从 0x10 去读;二是芯片写周期未完成就断电或执行下一笔写,导致写入真实失败;三是从设备供电和逻辑电平不匹配,芯片根本没被正确寻址。

有一个排查技巧:初始化后先把整片 256 字节读出来,如果全部是 0xFF,大概率是芯片没写入成功或没被正确寻址。这时候你用 I2C 地址扫描工具,看能否扫到 EEPROM 的 ACK。如果扫描时能扫到设备,但读全是 FF,那更应该怀疑写失败,而不是读失败。

8.3 用示波器和逻辑分析仪的实测建议

如果你手头只有万用表,能测出空闲电平但不能细致看波形。这时候我更推荐几十块钱的逻辑分析器加上配套软件,可以轻易抓到通信过程中 SDA 和 SCL 的完整时序。抓一波后你可以直接对照 I2C 时序图,很容易判断到底是起始条件没出来、还是 ACK 没等到、还是数据字节的位序错了(常见坑:把数据位从 LSB 开始发,而协议要求 MSB first)。

9. 扩展路径:同一套 I2C 框架还能干很多事

9.1 把 AT24C02 换成 SSD1306 OLED

等你能跑通 AT24C02 的读写,再回头去驱动 0.9 寸 OLED(SSD1306)或者 12864 OLED,其实只是换了一套命令序列的事。SSD1306 的 I2C 地址是 0x78(写)/0x7A(读),初始化时发一串控制命令,之后就是往显存里写数据。底层那个软件模拟 I2C 都不用动,只需要封装一个 OLED 专用的“写命令”和“写数据”函数。这就是为什么学好软件模拟 I2C 一劳永逸——协议是通用的,变的只有上层命令。

9.2 挂多颗 I2C 从设备时要注意的总线仲裁

一个 I2C 总线上可以同时挂 AT24C02、OLED、传感器等一堆设备,它们靠设备地址区分。每次通信前都要确认目标和这次通信方向对应的设备地址。设备多了之后,总线的等效电容上升,SCL/SDA 上升时间增长,所以要有测试调试余量。如果实在不行,也可以用 I2C 总线扩展器(比如 PCA9548A),通过多路选择来隔离不同总线段的电容负载。

9.3 从模拟到硬件外设的迁移路径

等你吃透了这套时序,再回头用 STM32F103 的硬件 I2C,你会发现理解成本低了很多——硬件外设本质上就是把你刚才用 GPIO 手搓的时序封装在芯片内部,你只需要配置寄存器即可。硬件 I2C 在连续大批量读写时性能占用更少,还可以配合 DMA。迁移的关键是把原来对 SCL/SDA 的 GPIO 操作全部替换成外设寄存器操作,同时初始化时注意 I2C 外设的时钟、占空比、ACLK 配置,以及最重要的是开启外设之前要让 GPIO 复用到 AF 功能(推挽开漏模式设置接好)。

10. 结尾留点总结之外的实在话

最后聊聊我在实际项目中做 I2C 通信的一些体会。

第一,软件模拟 I2C 调试成功的关键不是“代码”,是“波形”。代码写错往往是看逻辑分析仪一眼就能发现,但你不去抓波形就很容易在代码海里浪费时间。我有一次调了半天发现只是某个 GPIO 端口时钟没开,因为 STM32F103 的 GPIO 在读写 IDR 之前必须使能对应端口的时钟,否则读上来的数据全是乱的。

第二,EEPROM 写周期真的不是小事。我在批量写入参数时踩过一次:连续写 100 个字节,没有等内部写周期,结果读回来的数据一半是旧值一半是新值。后来用 ACK 轮询方式优化,才真正做到了又快又稳。

第三,如果准备把这个代码放到产品里持续运行,建议把所有 I2C 通信函数里的延时都用定时器精确控制,而不是裸的 delay 循环,因为低优先级的中断随时可能打断裸延时,导致时序被拉长到不可控;裸延时的问题在工程环境里不触发还好,触发起来很难排查。

如果你照着文章里的代码和思路走一遍,把 AT24C02 读写跑通,那 I2C 这套总线协议基本上就吃透了。之后无论是换主控芯片、换存储器、挂传感器,都是顺水推舟的事,底层逻辑你已经完全掌握了。遇到问题欢迎在评论区提出,我再根据大家的实战反馈继续整理更刁钻的调试案例。

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

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

立即咨询