STM32F103 的 I2C 和 AT24C02 EEPROM,这个组合可以说是嵌入式学习路上的一个经典关卡。很多朋友点完灯、打印完串口,第一次接触“外设之间还要讲协议”的概念,就是从 I2C 读写 EEPROM 开始的。这篇内容不做虚的,直接把 STM32F103 通过 I2C 接口操作 AT24C02 的原理、代码、接线、调试方法,以及我在实际项目中踩过的坑都拆开讲透,给刚入门的朋友一条可以直接参考的路径。
1. 整体设计与思路拆解
1.1 这个组合能解决什么问题
单片机系统经常需要保存一些掉电不能丢失的数据,比如设备编号、校准参数、用户配置、运行计数等。STM32F103 内部虽然有 Flash,也可以存储数据,但 Flash 的擦写次数和擦除方式在有些场景下并不方便,而且如果一个页面里存了多个参数,修改其中一个往往需要整页擦写,很麻烦。外部挂一颗 EEPROM 就灵活得多。
AT24C02 是 Atmel(现在属于 Microchip)的经典串行 EEPROM 芯片,容量是 2Kbit,也就是 256 个字节,支持按字节读写和按页写入。它通过 I2C 接口跟 MCU 连接,整个通信只需要两根线:SCL(时钟)和 SDA(数据)。对于存储量不大、但需要频繁修改的参数场景,比如设备 ID、阈值设置、最后运行状态,AT24C02 这种器件几乎是首选。
用 STM32F103 驱动 AT24C02 的价值在于:你不仅学会了一个具体的元器件怎么用,更重要的是把 I2C 协议的时序、地址、ACK 机制、上拉电阻这些底层细节全部过了一遍。这一课学扎实之后,后面接温湿度传感器、OLED 屏幕、触摸芯片、电源管理芯片,基本都是同一个套路。
1.2 为什么选 AT24C02 作为 I2C 入门器件
市面上 I2C 接口的 EEPROM 非常多,比如 AT24C01/02/04/08、FM24C02、ST24C02 等。但 AT24C02 几乎是最适合新手入门的一颗。
首先是容量适中。256 字节这个量级不大不小,拿来存参数正合适,也不至于让你在处理地址时觉得复杂。其次是器件结构简单,内部就是一个存储阵列加上 I2C 接口逻辑,没有状态机、没有中断、没有复杂的寄存器配置,外部只需要给电源和两根总线就能工作。
更重要的是资料足够多。AT24C02 的数据手册写得非常直白,时序图、电气参数、应用电路都有,网上相关的例程、文章、视频遍地都是。就算你完全没接触过 I2C,遇到问题时也容易找到参考。
AT24C02 的工作电压范围是 1.8V 到 5.5V,这对 3.3V 供电的 STM32F103 最小系统板非常友好,可以直接跟单片机共用 3.3V 电源,不用担心电平匹配问题。这也是我反复强调的:刚开始学,尽量把电压域统一,减少不必要的坑。
1.3 为什么在这里选 I2C 而不是 SPI 或 UART
串行 EEPROM 也有 SPI 接口的,比如 25AA020A(也是一颗容量 2Kbit 的器件)。那为什么这个场景常用 I2C 而不是 SPI?
最核心的原因是引脚占用。I2C 只需要两根线就能挂多个设备,SPI 至少需要三根线(MOSI、MISO、SCK),而且每挂一个设备往往还要多一个片选引脚。如果你需要在一个板子上挂 EEPROM、传感器、显示屏好几个器件,I2C 的接线优势非常明显。
I2C 是多主多从架构,每个从设备都有唯一地址,总线上的设备通过地址区分。AT24C02 的地址线 A0/A1/A2 可以配置,理论上一条 I2C 总线上最多可以挂 8 颗同型号芯片。这对于需要扩展存储容量的场景也很实用。
速度方面,AT24C02 标准模式下支持最高 100kHz,快速模式支持 400kHz。EEPROM 这种存储器件用不着很高的时钟频率,I2C 的速度完全够用。而 UART 通常只适合点对点通信,不适合挂多设备,所以在这个场景里 I2C 就是更合理的方案。
2. 核心细节解析与实操要点
2.1 开漏输出、上拉电阻与电平转换
I2C 总线的一个关键电气特点是:SDA 和 SCL 都是开漏结构。什么叫开漏?你可以简单理解成:设备只能主动把引脚拉低,当它想输出高电平的时候,实际上是“释放”这根线,让外部上拉电阻把电平抬上去。
这跟普通 GPIO 推挽输出不一样。推挽输出可以主动输出高电平和低电平,而开漏输出只能输出低电平或者高阻态。既然是高阻态,那总线上就必须有上拉电阻来保证默认电平为高。这个设计的好处是:多个设备都可以挂在同一根线上,任何设备拉低都能让总线变低,不会出现两个设备同时输出高低电平导致冲突的问题。
上拉电阻的阻值不是随便选的。阻值太小,总线上电流过大,低电平可能拉不下去;阻值太大,上升沿太慢,影响通信可靠性。常见做法是 4.7kΩ,我在 3.3V 供电的短距离板子上一律用 4.7kΩ,走线稍长或者总线挂载设备多一些的时候可以用 2.2kΩ。
有些朋友会问:STM32F103 是 3.3V 的器件,AT24C02 如果工作在 5V 怎么办?这个问题其实很常见。注意,I2C 总线的高电平由上拉电阻接的电源决定,不直接由器件的 VCC 决定。如果 AT24C02 用 5V 供电,而你把上拉电阻接到了 3.3V,那么总线高电平就是 3.3V,而 AT24C02 在 5V 供电时的高电平输入阈值是 0.7 × VCC = 3.5V,3.3V 是满足不了的,通信就会不稳定。
我的建议是:在入门阶段,直接把 AT24C02 也接到 3.3V 供电,两边电压域统一,上拉电阻也接到 3.3V,完全没问题。如果项目里 EEPROM 必须用 5V 供电,那就老老实实加 I2C 电平转换芯片,比如 PCA9306,别在总线电平上赌运气。
注意:I2C 的 GPIO 一定要配置成开漏模式,不要配成推挽输出。配成推挽后,一旦总线状态配合不好,可能会造成引脚间直接短路。开漏模式配合外部上拉,才是 I2C 正确的用法。
2.2 I2C 时序拆解:起始、停止、ACK 与数据有效性
I2C 协议看起来复杂,其实核心就几个状态。
每个通信的开始有一个起始条件(START):SCL 为高电平期间,SDA 由高变低。注意,SDA 的电平变化必须发生在 SCL 为高的时候,否则不算起始条件。接着是停止条件(STOP):SCL 为高电平期间,SDA 由低变高。这个组合也是唯一的。
数据位的传输规则是:在 SCL 为高电平期间,SDA 上的数据必须保持稳定;SDA 只能在 SCL 为低电平期间变化。可以理解为:数据在时钟脉冲的高电平期间被采样读取,所以高电平期间 SDA 不能动。很多新手模拟时序时,都是在这里栽跟头,写字节的时候 SDA 变化太早或太晚,导致从机采到的数据错了。
每个字节传输完以后,紧接着的第 9 个时钟脉冲是 ACK 位。发送方在第 9 个时钟前释放 SDA,接收方如果正常接收,会在这期间把 SDA 拉低,表示 ACK;如果 SDA 保持高电平,就是 NACK。
读操作时,主机在读最后一个字节前需要回一个 NACK,告诉从机不要再发了。这是很多新手容易忽略的细节:如果主机一直回 ACK,EEPROM 就会一直输出下一个地址的数据,你读到的就不是你想要的那一段。
2.3 设备地址、页大小与寻址计算
AT24C02 的 I2C 设备地址由两部分组成:固定部分和可配置部分。固定部分是 1010(二进制),可配置部分来自 A0、A1、A2 三个引脚。最后一个 bit 是读写方向位:0 表示写,1 表示读。
如果 A0、A1、A2 都接地,那么:
- 写地址:1010 000 0,也就是 0xA0
- 读地址:1010 000 1,也就是 0xA1
很多例程里直接写AT24C02_ADDR_WRITE 0xA0、AT24C02_ADDR_READ 0xA1,新手往往一头雾水。其实只要你清楚这三根地址线的接法,就知道这个 0xA0 是怎么算出来的了。
AT24C02 内部共有 256 个字节地址,对应 0x00 到 0xFF。它总共分 32 页,每页 8 个字节。这个“页大小 8 字节”非常重要,因为 AT24C02 的页写操作最多只能连续写 8 个字节,如果超过页边界,地址会回卷,后面写的数据会覆盖到当前页的开头,而不是自动翻到下一页。
举个例子,如果你想从地址 0x07 开始连续写 4 字节,写到的地址序列是 0x07、0x00、0x01、0x02,而不是 0x07、0x08、0x09、0x0A。因为 0x07 是这一页的最后一个地址,往后再写就回到页头了。这是 AT24C02 使用中非常经典的坑。
2.4 为什么很多老工程师习惯用软件模拟 I2C
STM32F103 内部自带硬件 I2C 外设,按说用外设更省事。但现实是,很多工程师在实战中还是愿意用 GPIO 软件模拟 I2C。
原因不难理解。STM32F103 的硬件 I2C 用标准外设库时,事件标志和中断处理逻辑容易让人头晕,一个 EV5、EV6、EV7、EV8 就能把人绕进去。处理不当的时候还会出现总线卡死、引脚状态异常的问题。虽然用 HAL 库的HAL_I2C_Mem_Write和HAL_I2C_Mem_Read确实省事,但它的内部实现一样涉及超时、错误处理,新手一旦遇到问题,定位起来比较头疼。
软件模拟 I2C 的好处是:时序完全由自己控制,每一笔什么时候拉高、什么时候拉低、延时多久都清清楚楚,出了问题也容易排查。而且代码的可移植性很强,今天在 STM32F103 上用,明天换到 GD32、CH32,换个 GPIO 宏定义就能跑。
我在这个项目里就是用软件模拟的方式来实现的。这不代表硬件 I2C 不可用,而是建议:先通过软件模拟把 I2C 协议吃透,后续如果需要追求效率,再用硬件外设不迟。
3. 实操过程与核心环节实现
3.1 硬件接线与开发环境准备
我用的硬件是常见的 STM32F103C8T6 最小系统板,配套 ST-Link 下载器。AT24C02 我直接用了独立的 SOP-8 封装芯片,在面包板上搭的电路,没有用现成模块,这样更能看清每个引脚的连接关系。
接线方式如下:
| STM32F103 | AT24C02 | 说明 |
|---|---|---|
| 3.3V | VCC (Pin 8) | 电源 |
| GND | GND (Pin 4) | 地 |
| PB8 | SCL (Pin 6) | I2C 时钟 |
| PB9 | SDA (Pin 5) | I2C 数据 |
| 3.3V | WP (Pin 7) | 写保护引脚,接地或悬空表示允许写 |
| GND | A0 (Pin 1) | 地址位,接地 |
| GND | A1 (Pin 2) | 地址位,接地 |
| GND | A2 (Pin 3) | 地址位,接地 |
在 SCL 和 SDA 上各接一个 4.7kΩ 电阻到 3.3V,这就是 I2C 总线的上拉电阻。WP 引脚是写保护,我给拉到 GND,确保可以正常写入。
软件环境方面,我用的是 Keil MDK 加标准外设库(STM32F10x_StdPeriph_Lib),配合 ST-Link 调试。如果你习惯用 CubeMX 也可以,CubeMX 里把 PB8、PB9 配置成开漏输出就行,不需要启用 I2C 外设。注意 GPIO 初始化时,SCL 和 SDA 都配置成开漏模式,SDA 虽然需要读回电平,但开漏输出模式下是可以直接读引脚状态的,不需要切换成输入模式。
GPIO 初始化的核心代码如下:
void I2C_GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8 | GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); I2C_SCL_HIGH(); I2C_SDA_HIGH(); }配置成开漏输出之后,你在代码里写GPIO_SetBits和GPIO_ResetBits,实际上是控制引脚是“释放”还是“拉低”。在开漏模式下,写 1 是释放,外部上拉会把它拉高;写 0 才是真正的驱动低电平。
3.2 软件模拟 I2C 基础函数实现
首先定义好宏,方便后面移植和修改引脚:
#define I2C_GPIO_PORT GPIOB #define I2C_SCL_PIN GPIO_Pin_8 #define I2C_SDA_PIN GPIO_Pin_9 #define I2C_SCL_HIGH() GPIO_SetBits(I2C_GPIO_PORT, I2C_SCL_PIN) #define I2C_SCL_LOW() GPIO_ResetBits(I2C_GPIO_PORT, I2C_SCL_PIN) #define I2C_SDA_HIGH() GPIO_SetBits(I2C_GPIO_PORT, I2C_SDA_PIN) #define I2C_SDA_LOW() GPIO_ResetBits(I2C_GPIO_PORT, I2C_SDA_PIN) #define I2C_SDA_READ() GPIO_ReadInputDataBit(I2C_GPIO_PORT, I2C_SDA_PIN)然后是延时函数。标准的延时可以用 SysTick 实现,我这里直接用了一个简单的微秒级延时,因为 AT24C02 在标准模式下 400kHz 的时钟周期约 2.5us,每个电平维持的时长只要大于 1.3us 左右就能满足要求。为了稳妥,我一般给 2us 以上的延时。
起始条件的代码:
void I2C_Start(void) { I2C_SDA_HIGH(); I2C_SCL_HIGH(); I2C_SDA_LOW(); I2C_SCL_LOW(); }注意顺序:SCL 高期间 SDA 由高变低,产生起始条件,然后 SCL 拉低为后续传输做准备。如果把 SCL 先拉低再操作 SDA,起始条件就不成立了。
停止条件的代码:
void I2C_Stop(void) { I2C_SDA_LOW(); I2C_SCL_HIGH(); I2C_SDA_HIGH(); }在 SCL 低电平期间先把 SDA 拉低,然后把 SCL 拉高,最后再把 SDA 拉高,产生停止条件。
发送一个字节并检查 ACK:
uint8_t I2C_WriteByte(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; I2C_SCL_HIGH(); I2C_Delay(); I2C_SCL_LOW(); I2C_Delay(); } // 第9个时钟检测从机ACK I2C_SDA_HIGH(); I2C_SCL_HIGH(); I2C_Delay(); ack = I2C_SDA_READ(); I2C_SCL_LOW(); I2C_Delay(); return ack; // 返回0表示从机回复ACK,返回1表示NACK }这里有个细节:发送完第 8 个数据位之后,主机要把 SDA 释放(置高),因为第 9 个时钟期间是让从机控制 SDA 拉低来表示 ACK。如果你没有释放 SDA,从机根本拉不低,总线永远是高电平,ACK 检测必然失败。
接收一个字节并控制 ACK/NACK:
uint8_t I2C_ReadByte(uint8_t ack) { uint8_t i; uint8_t data = 0; I2C_SDA_HIGH(); for (i = 0; i < 8; i++) { data <<= 1; I2C_SCL_HIGH(); I2C_Delay(); if (I2C_SDA_READ()) data |= 0x01; I2C_SCL_LOW(); I2C_Delay(); } // 主机在第9个时钟发送ACK或NACK if (ack) I2C_SDA_LOW(); else I2C_SDA_HIGH(); I2C_SCL_HIGH(); I2C_Delay(); I2C_SCL_LOW(); I2C_Delay(); I2C_SDA_HIGH(); return data; }读字节的时候,每次 SCL 拉高之后,从机才会把下一位数据放到 SDA 上,所以读取数据要在 SCL 高电平期间采样。读完 8 位以后,如果是继续读下一个字节,主机要回 ACK(SDA 拉低);如果是最后一个字节,主机要发 NACK(SDA 释放),从机收到 NACK 后就不再发送。
3.3 AT24C02 读写函数实现
有了基础的 I2C 时序函数,读写 AT24C02 就只是拼装命令的问题了。
写单个字节的流程是:起始条件 → 发送写地址 0xA0 → 发送内部字节地址 → 发送要写入的数据 → 停止条件。
void AT24C02_WriteByte(uint8_t addr, uint8_t data) { I2C_Start(); I2C_WriteByte(0xA0); // 器件写地址 I2C_WriteByte(addr); // 内部字节地址 I2C_WriteByte(data); // 写入数据 I2C_Stop(); AT24C02_WaitWriteComplete(); // 等待写周期结束 }这里要注意的是:写操作完成后,AT24C02 会进入一个内部写周期(tWR),典型时间是 5ms,期间不响应任何外部命令。如果你不等它写完就去读,很可能读到的还是旧数据。等待的方式有两种:固定延时 5~10ms,或者轮询 ACK。轮询方法更高效,后面单独说。
页写操作的代码:
void AT24C02_PageWrite(uint8_t addr, uint8_t *data, uint8_t len) { uint8_t i; I2C_Start(); I2C_WriteByte(0xA0); I2C_WriteByte(addr); for (i = 0; i < len; i++) I2C_WriteByte(data[i]); I2C_Stop(); AT24C02_WaitWriteComplete(); }页写时请务必检查两点:写入字节数不能超过 8,写入起始地址到当前页边界剩余空间要足够。否则数据会回卷覆盖。举个例子,从地址 0x05 开始页写 4 字节,写到 0x07 后下一个地址会变成 0x00,这可能完全不是你预期的行为。
随机读一个字节的流程是:起始条件 → 发送写地址 0xA0 → 发送内部字节地址 → 重复起始条件 → 发送读地址 0xA1 → 读取一个字节(最后一个字节回 NACK)→ 停止条件。
uint8_t AT24C02_ReadByte(uint8_t addr) { uint8_t data; I2C_Start(); I2C_WriteByte(0xA0); I2C_WriteByte(addr); I2C_Start(); // 重复起始条件 I2C_WriteByte(0xA1); // 器件读地址 data = I2C_ReadByte(0); // 读最后1个字节,回NACK I2C_Stop(); return data; }连续读取多字节时,除了最后一个字节回 NACK,其余字节都要回 ACK:
void AT24C02_ReadBytes(uint8_t addr, uint8_t *buffer, uint8_t len) { uint8_t i; I2C_Start(); I2C_WriteByte(0xA0); I2C_WriteByte(addr); I2C_Start(); I2C_WriteByte(0xA1); for (i = 0; i < len; i++) { if (i == len - 1) buffer[i] = I2C_ReadByte(0); // 最后一个NACK else buffer[i] = I2C_ReadByte(1); // 中间字节回ACK } I2C_Stop(); }这个函数里最关键的就是 ACK 和 NACK 的切换时机。许多朋友写的连续读,要么全部回 ACK,导致从机一直输出,读到的数据尾部错乱;要么过早回 NACK,从机提前停止发送,数据少了。
3.4 写周期等待与轮询 ACK 技巧
AT24C02 的数据手册给出 tWR 典型时间是 5ms,最大 10ms。最简单的做法是每次写完后延时 10ms,稳稳当当。但如果你的系统里频繁写 EEPROM,每次都干等 10ms 就太浪费时间了。
更高效的办法是利用 ACK 轮询。AT24C02 在内部写周期内不会响应任何命令,也就是说,你给它发送写地址 0xA0,它不会回 ACK,SDA 保持高电平。一旦内部写周期结束,它才能正常响应。
所以轮询写周期完成的函数可以这样写:
void AT24C02_WaitWriteComplete(void) { uint8_t ack; do { I2C_Start(); ack = I2C_WriteByte(0xA0); // 发送器件写地址,等待ACK I2C_Stop(); } while (ack != 0); // 非0表示NACK,继续等 }因为I2C_WriteByte返回 0 表示 ACK,所以循环直到返回 0 就说明芯片已经准备好接收新命令了。实测下来,这个方式的等待时间通常比固定的 10ms 短很多,尤其在连续写入多个参数的时候,整体效率提升非常明显。
4. 常见问题与排查技巧实录
4.1 设备无应答(NACK)的排查思路
AT24C02 不回 ACK,是最常见的故障现象之一。如果你在I2C_WriteByte(0xA0)这一步就拿到了 NACK,先别急着怀疑芯片坏了,按顺序排查。
第一,检查电源。AT24C02 的 VCC 是否有稳定的 3.3V,GND 是否跟 STM32 共地。面包板接触不良可能导致电源有电压但芯片没工作,经验之谈。
第二,检查地址。A0、A1、A2 是否接对了?如果是模块,看模块上有没有已经把地址端固定接地的焊盘;如果是自己搭的,确认三个引脚都接到了 GND。如果某一个引脚接到了高电平,那么设备地址就会变化,0xA0 就找不到了。
第三,检查上拉电阻。缺少上拉电阻时,总线默认电平不是高,波形和逻辑都会乱。要验证这一点,直接用万用表量 SCL 和 SDA 的对地电压,正常空闲状态应该都在 3.3V 左右。
第四,检查 GPIO 配置。SDA 和 SCL 是不是配成了开漏输出?如果配成了推挽输出,通信时可能会导致总线逻辑异常。最简单的验证方式:把 SDA 和 SCL 都配置成开漏 + 外部上拉,在初始化代码里手动写 SDA_HIGH、SCL_HIGH,然后测电压,应该是高电平。
4.2 读回全是 0xFF 或乱码
写进去的数据,读出来全是 0xFF,这通常说明 I2C 从机并没有真正执行写操作,或者读操作的地址没对上。
先确认一个容易忽略的点:写完数据后是否等待了写周期?如果你写完立即读,EEPROM 还在内部写周期,读操作可能完全不生效,甚至读到的是旧数据。用我上面写的AT24C02_WaitWriteComplete()就能解决。
另一个常见原因是器件写保护引脚 WP 被接到了高电平。WP 接高时,整个芯片的写操作会被禁止,芯片照样能读,但写不进去。你写进去是假象,读出来自然还是 0xFF。
如果读出来的数据是乱码而不是全 0xFF,很大概率是时序不对。比如 SCL 高电平期间 SDA 变化了,或者数据位的时钟个数不对。这时候建议用逻辑分析仪抓一次波形,对照数据手册的时序图逐位查看,很快就能定位到是哪个字节的位偏移了。
4.3 跨页写入导致数据覆盖
前面提过,AT24C02 每页 8 字节,页写最多 8 字节,而且写到页尾后地址会回卷。很多朋友没有意识到这个问题,直接从地址 0x07 连续写 10 字节,结果发现 0x00 地址的数据莫名被改了,怎么排查都找不到原因,其实就是页回卷。
解决办法有两种。第一种是保证每次页写都不跨页:写数据前计算当前地址到页尾还剩多少字节,超出部分拆分到下一轮写。第二种更简单,干脆就用单字节写,虽然效率低一些,但在数据量不大(几十字节以内)的场景下完全够用。
如果你希望实现一个通用的写函数,可以这样做:检查(addr % 8) + len是否大于 8,如果大于就拆分两次写。这个检查一定要在写入之前做,否则数据一旦回卷覆盖,很难找回原数据。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 发送 0xA0 后无 ACK | 地址线接错、芯片无供电、上拉缺失 | 万用表查电源,确认 A0/A1/A2 接地,检查 4.7k 上拉 |
| 写后读回全 0xFF | 未等待写周期、WP 写保护 | 加上写周期等待,确认 WP 接地 |
| 读回数据乱码 | 时序不对、SCL 高期间 SDA 变化 | 用逻辑分析仪抓波形,对比数据手册 |
| 页写出现覆盖 | 跨页写入 | 计算页剩余空间,按页拆分写入 |
| 总线一直低电平 | SDA 被某设备拉死、缺少上拉 | 断开所有设备,逐个测试哪个引脚异常 |
| 通信偶尔失败 | 上拉电阻过大导致上升沿过慢 | 减小上拉电阻到 2.2k~4.7k,缩短总线走线 |
5. 调试工具与验证手段:从波形到回读
5.1 用逻辑分析仪抓 I2C 波形
我调试 I2C 习惯挂一个逻辑分析仪并行观察,虽然串口打印也能验证数据对不对,但波形能告诉你协议层次的问题。推荐使用采样率 24MHz 以上的 8 通道逻辑分析仪,几十块钱的入门款足以分析 100kHz 和 400kHz 的 I2C 信号。
抓波形的时候,把 SCL 接到逻辑分析仪的 CH0,SDA 接到 CH1,地线必须跟板子共地。软件里选择 I2C 协议解码器,设置好高电平和采样率,然后触发一次写读操作。解码结果会直接显示起始位、地址字节、ACK 位、数据字节,一目了然。
我个人最常看的几个点:地址字节是不是 0xA0 和 0xA1;第 9 个时钟的 ACK 位是低电平还是高电平;连续读数据时最后一个字节前面的 ACK/NACK 状态。有一次我排查一个偶发通信失败,波形上看到地址字节后 ACK 位没有出现,后来发现是上拉电阻虚焊导致高电平不稳定,这个在打印数据里很难察觉。
5.2 通过串口回读验证读写结果
写完驱动,总得有一个直观的验证手段。我的做法是通过串口上位机给板子发命令,控制 MCU 执行写入或读取,然后把结果通过串口打印出来。
具体流程可以这样设计:上位机发送一个字符串,比如W 0x10 0x55,表示往地址 0x10 写入数据 0x55;发送R 0x10,表示读取地址 0x10 的值,MCU 通过串口返回类似addr=0x10 data=0x55的信息。这样可以反复测试,确认读写都正常。
更完整的压力测试可以这样:循环向 0x00~0xFF 所有地址写入对应的值,再全部读回来对比。这个测试能一次性暴露页边界问题、写周期未等待问题、地址计算错误等问题。我第一次跑这种全量回环测试时,就在 0x07 和 0x08 之间的跨页写入上报错了,也正是那次让我彻底记住了页大小的限制。
跑全量回环测试时,建议在循环里用轮询 ACK 而不是固定延时,否则 256 个字节全部写一遍,每个都等 10ms,总耗时长达 2.56 秒,调试效率低得让人抓狂。用轮询方式,一般几十毫秒就能完成一轮,即使多跑几轮测试也不会心烦。
5.3 一些后续可以尝试的扩展
AT24C02 读写跑通之后,很多有意思的外设都变得好理解多了。比如 SSD1306 这种 OLED 屏幕,也是 I2C 接口,控制流程不过是初始化序列加显存数据写入。你掌握了 I2C 的起始、停止、ACK、多字节读写,再把 OLED 的命令字和数据字弄清楚,离点亮屏幕就只差一个驱动函数了。
还可以试试在一条 I2C 总线上同时挂 AT24C02 和 OLED,地址分别是 0xA0 和 0x78,总线不会冲突。这时候你会发现 I2C 双线挂多设备的优势真正体现出来了。再往后,如果想提高通信效率,就可以尝试用 STM32F103 的硬件 I2C 外设配合 DMA 传输,不过那又是另一个话题了。
我从实际项目里得到的体会是:I2C 协议本身并没有多高深,但它的每一个细节都值得认真对待。从开漏配置到上拉电阻,从 ACK 时序到页边界处理,只有亲手写一遍、跑一遍、再排查一遍,这些知识点才是真正属于你的。