I2C 这个东西,做嵌入式的迟早要碰。AT24C02 作为最经典的一颗 I2C EEPROM,几乎是我见过所有入门教程、实际项目里出场率最高的存储芯片。但真上手的时候,很多人却被 I2C 的时序细节、引脚配置、ACK 应答这些概念搞到心态爆炸。
这篇文章就是一次完整的 STM32F103 + AT24C02 实战记录,从硬件接线、协议原理,到 HAL 库代码实现、软件模拟 I2C,再到调试过程中踩过的各种坑,一次性讲明白。不管你是刚接触嵌入式的新手,还是被 I2C 折腾过的老手,这篇内容应该都能帮你少走不少弯路。
1. 方案选型与硬件设计思路
1.1 为什么选 AT24C02:一颗“小而经典”的存储芯片
AT24C02 是 Microchip(原 Atmel)推出的一颗 I2C 接口 EEPROM,容量 2Kbit,也就是 256 字节。它内部按页组织,每页 8 字节,共 32 页。当然实际使用时你不需要关心"页"这个概念那么深,只需要知道它支持字节读写,写入之后掉电不丢失。
这颗芯片的工作电压范围是 1.8V 到 5.5V,所以 3.3V 的 STM32F103 可以直接给它供电,不需要额外的电平转换。擦写寿命官方标称是 100 万次,数据保存时间约 100 年。对于大多数设备参数存储场景,比如保存设备地址、校准系数、开关机次数、用户偏好设置,这个寿命和容量绰绰有余。
那为什么不直接用 STM32F103 内部的 Flash 存储?内部 Flash 按扇区擦除,最小擦除粒度大,频繁改写会磨损 Flash,而且操作过程要小心处理中断。EEPROM 的优势是字节级随机读写,想改哪个字节就改哪个字节,不需要先擦除整个扇区,使用上灵活很多。对比外挂 SPI Flash(比如 W25Q16),EEPROM 的接口更简单,I2C 只需要两根线,硬件资源占用少,适合小数据量、频繁改写、低功耗场景。
我做项目时,一般先把 AT24C02 的 256 字节做个分配规划:前面放设备参数,中间放校准数据,最后放运行状态标志位。这种规划做好之后,代码逻辑就会非常清晰,后面读写也不容易错乱。
1.2 硬件接线:引脚功能与最小系统搭建
AT24C02 一共 8 个引脚,功能其实很简单:
| 引脚编号 | 名称 | 功能说明 |
|---|---|---|
| 1 | A0 | 地址引脚,接地或接 VCC,用于配置器件地址 |
| 2 | A1 | 地址引脚 |
| 3 | A2 | 地址引脚 |
| 4 | GND | 电源地 |
| 5 | SDA | 串行数据线,双向,需要上拉电阻 |
| 6 | SCL | 串行时钟线,由主机控制 |
| 7 | WP | 写保护引脚。接高电平禁止写入,接低电平允许写入 |
| 8 | VCC | 电源正极 |
STM32F103 上默认的 I2C1 外设对应 PB6(SCL)和 PB7(SDA),这也是绝大多数开发板的固定接线。我把接线方式列成表,照着接基本不会错:
| AT24C02 引脚 | 连接到 |
|---|---|
| VCC | 3.3V |
| GND | GND |
| A0/A1/A2 | GND(默认地址) |
| WP | GND(允许写入) |
| SCL | PB6 |
| SDA | PB7 |
这里有一个容易忽略的细节:VCC 和 GND 之间建议就近加一个 0.1uF 的去耦电容。EEPROM 虽然功耗很低,但如果在写周期瞬间电流波动,旁边如果有电机、继电器这类负载,可能造成供电毛刺,导致 I2C 时序异常。我最初搭实验板的时候偷懒没加电容,后来在强干扰环境下频繁出现写失败,加了这个电容之后基本没再出过问题。
A0/A1/A2 干的活就是配置器件的低三位地址。三个引脚都接地时,7 位器件地址是 0x50。如果接成二进制组合,比如 A0 接高、A1 低、A2 低,地址就是 0x51。一条 I2C 总线上最多可以挂 8 颗 AT24C02(如果地址完全独立的话),这个特性后面扩展多路存储的时候很有用。
提示:如果从开发板上拆模块,注意看模块上是否有自带上拉电阻。很多买来的 AT24C02 小模块已经贴好了 4.7k 或 10k 的上拉电阻,这种情况下你就不用再额外接了,直接接 SCL、SDA 就能用。自己画板子的话,上拉电阻必须自己加。
1.3 上拉电阻阻值选择:为什么说 4.7k 最稳妥
I2C 总线是开漏结构,SCL 和 SDA 本身没有能力输出高电平,必须靠外部上拉电阻把电平拉到 VCC。所以上拉电阻不是可选项,而是必选项。阻值选大了,上升沿太慢,高速模式下时序容易出错;选小了,灌电流太大,不仅费电,还可能损坏芯片引脚。
上拉电阻的取值和总线电容(包括导线、芯片引脚寄生电容)有关,上升时间可以用一个工程估算公式描述:t_rise ≈ 0.8473 × R_pullup × C_bus。I2C 标准模式(100kHz)要求上升时间不超过 1 微秒,快速模式(400kHz)要求不超过 300 纳秒。
按经验来说,100kHz 标准模式下,4.7kΩ 是最通用的选择,搭配 100~200pF 的总线电容,上升时间大概在 0.4~0.8 微秒,余量充足。如果总线比较长(比如超过 30cm)或挂载设备比较多,可以降到 3.3kΩ 或 2.2kΩ,把上升沿拉得再陡一些。400kHz 快速模式下我习惯直接上 2.2kΩ。STM32F103 的硬件 I2C 一般在标准模式下工作,所以 4.7kΩ 就是那个"不会错"的答案。
1.4 3.3V 和 5V 电平问题:能不能直接连
AT24C02 工作电压范围很大,所以最容易规避电平转换问题的方法就是让它和 STM32F103 共用 3.3V 电源。这种情况下 SCL 和 SDA 的电平标准完全一致,直连即可,不需要任何转换电路。
但有些场景下,如果 EEPROM 模块是 5V 供电的(尤其某些老开发板上的集成 EEPROM 是接 5V 的),那 STM32F103 的 3.3V 引脚直接连过去就存在隐患:3.3V 引脚输出高电平约 3.3V,但 AT24C02 的输入高电平阈值一般是 0.7×VCC,也就是 3.5V,这时芯片可能识别不到高电平。反过来,5V 侧 SDA 的高电平接近 5V,灌进 STM32F103 引脚可能超过芯片绝对最大额定值,导致引脚损坏。
解决思路有两个:最省事的是把 EEPROM 模块改造成 3.3V 供电。如果模块上有独立的 VCC 跳线,直接切到 3.3V。如果必须保持 5V 供电,那就需要加一个双向电平转换电路,业界最常见的是两个 N-MOS 管组成的方案,用 BSS138 就可以完成 3.3V 和 5V 之间的双向往返转换。这个电路并不复杂,但对新手来说有一定门槛,所以我的建议是:能用 3.3V 就优先用 3.3V,少给自己找麻烦。
2. I2C 协议核心机制拆解
2.1 开漏模式与推挽模式:I2C 为什么必须用开漏
很多人第一次看 I2C 代码时会疑惑:GPIO 为什么非要配置成开漏输出,而不是推挽输出?这里面的道理其实很底层。
推挽输出模式下,引脚既能主动输出高电平,也能主动输出低电平。如果一条总线上挂着两个设备,一个输出高,一个输出低,那低电平引脚会把电流从高电平引脚拉向地,轻则通信错误,重则烧毁引脚。I2C 总线是要支持多个设备共存的,天然不能再推挽模式工作。
开漏输出就不一样了。开漏模式下,引脚输出低电平时是主动拉低,输出高电平时相当于“断开”,电平完全由外部上拉电阻决定。这样一来,任何设备想表达低电平就拉低总线,不想表达时就释放总线,让上拉电阻把电平恢复成高。这种机制叫“线与”。
把 I2C 想象成一个会议室里的发言系统:平时话筒线电压是高的(空闲),任何一个人按住按钮把线拉低(发言),全场都能听到;说完松开,电压恢复高,下一个人再按。多个人同时按下也不会短路打架,因为大家能做的只是拉低总线,没有人会主动输出高电平去对抗。
STM32F103 上配置开漏输出很简单。使用 HAL 库时,在 CubeMX 里选择 GPIO 模式为 Output Open Drain,然后把 I2C 引脚配置为复用开漏模式(AF_OD)。标准库时代是这样:
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;HAL 库配完 CubeMX 后会自动生成对应代码,GPIO 初始化为GPIO_MODE_AF_OD,外部还需要接上拉电阻,这点千万别漏。
2.2 起始条件、停止条件和 ACK:I2C 的“握手信号”
I2C 协议里,任何一次通信都以一个 START 条件开始,以一个 STOP 条件结束。这两个条件都是用 SDA 和 SCL 的组合电平变化来定义的:
- START:SCL 保持高电平时,SDA 从高电平跳变到低电平。
- STOP:SCL 保持高电平时,SDA 从低电平跳变到高电平。
为什么非要在 SCL 高电平期间跳变?因为数据传输规定,SCL 高电平期间 SDA 的电平必须保持稳定,只有 SCL 为低电平时 SDA 才允许变化。START 和 STOP 故意反着来,打破这个规则,就是告诉所有总线上的设备:“注意,下面开始/结束通信了”。
字节传输都是高位先行。每次发送 8 位数据,第 9 个时钟周期是应答位(ACK)。发送方在第 9 个时钟释放 SDA,接收方如果正常接收,就把 SDA 拉低,表示“我收到,请继续”。如果接收方不拉低,总线保持高电平,这就表示 NACK,通常意味着“我不认识这个地址”或者“我忙不过来,别发了”。
这个 ACK 机制是 I2C 调试中极其重要的诊断依据。如果主机发出设备地址后收不到 ACK,基本可以断定是地址不对、芯片没上电、或者硬件连接有问题。我经常跟人说,学会看 ACK,你就已经会 I2C 调试的一半了。
2.3 AT24C02 的器件地址与完整读写时序
AT24C02 的地址字节结构是 1010 + A2 + A1 + A0 + R/W。高 4 位固定是 1010,这是 AT24C02 家族的统一编码。A2/A1/A0 三个位由芯片引脚电平决定,所以前面接线中我们给 A0/A1/A2 都接 GND 时,这三位都是 0,7 位地址就是 0x50。加上读写位,写地址变成 0xA0,读地址变成 0xA1。
这个地址问题是新手最容易搞混的地方。HAL 库中HAL_I2C_Master_Transmit函数的设备地址参数要求传入包含读写位的 8 位地址,所以写操作传 0xA0,读操作传 0xA1,而不是传 0x50。如果传 0x50 过去,HAL 库会在内部左移一位变成 0xA0 发送,正好也能工作,但代码里逻辑容易绕晕,建议统一用 0xA0/0xA1 这种写法。
写一个字节到 AT24C02 的流程是:
- 主机发送 START
- 主机发送器件地址(写方向)0xA0,等待 ACK
- 主机发送要写入的内存字节地址,等待 ACK
- 主机发送要写入的数据字节,等待 ACK
- 主机发送 STOP,启动芯片内部写周期
注意第 5 步之后,AT24C02 进入内部写周期,典型耗时约 5ms。在这个期间内芯片不会响应任何外部命令,主机必须等写入完成后才能进行下一次操作。如果不管这个写周期,紧接着去读数据,读出来的往往还是旧值。
读一个字节的流程稍有不同。AT24C02 支持“随机读”,需要先用一个“伪写”操作把内部的地址指针设置到想读的位置:
- 主机发送 START
- 主机发送器件地址(写方向)0xA0,等待 ACK
- 主机发送要读取的内存字节地址,等待 ACK
- 主机再次发送 START(重复起始)
- 主机发送器件地址(读方向)0xA1,等待 ACK
- 从机发送数据,主机返回 NACK(表示只读一个字节)
- 主机发送 STOP
在 HAL 库中,第 4 步到第 7 步可以封装成HAL_I2C_Master_Receive。整个流程对芯片来说就是先定位再读取,中间是否真的产生了重复起始,对最终结果没有影响,我用 HAL 库测试过多次,这个写法是稳定可靠的。
3. 软件架构与代码实现
3.1 工程初始化:CubeMX 配置与时钟设置
我平时开发 STM32F103 习惯用 HAL 库 + CubeMX 组合,外设初始化界面化,代码风格统一,多人协作时也容易维护。针对这个项目,CubeMX 里需要配置的核心项有:
时钟树方面,我使用外部 8MHz 晶振作为 HSE,通过 PLL 倍频到 72MHz 主频。I2C1 外设挂在 APB1 总线上,APB1 时钟通常是 36MHz,这个时钟源给 I2C 提供工作时钟,CubeMX 会帮我自动算好分频系数,不需要手动改。
I2C1 参数配置:
- Master Mode 勾上
- Timing Mode 选择 Standard Mode(100kHz)
- Clock Speed 填 100000
- 7 位地址模式
GPIO 方面,PB6 复用为 I2C1_SCL,PB7 复用为 I2C1_SDA,模式是 Alternate Function Open-Drain,速度可以选 High。这些在 CubeMX 中操作都是图形化完成的,生成代码后我一般只改 main.c 和自建外设模块文件。
生成代码后,i2c.c里自动生成的初始化可以看到关键参数:
hi2c1.Instance = I2C1; hi2c1.Init.ClockSpeed = 100000; hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2; hi2c1.Init.OwnAddress1 = 0; hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT; hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE; hi2c1.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE; hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE;我这里通常保持默认,尤其是 OwnAddress1,主机模式下这个值基本无关紧要,留 0 就行。如果设置了非零值,在某些库版本中反而可能引发奇怪问题。
3.2 基于 HAL 库的单字节读写实现
初始化完成后,读写 AT24C02 的核心代码其实非常简洁。我习惯把 EEPROM 操作封装成一个独立模块,方便多个文件调用。
写单字节函数:
uint8_t EEPROM_WriteByte(uint16_t mem_addr, uint8_t data) { uint8_t buf[2]; buf[0] = (uint8_t)mem_addr; // 内存地址 buf[1] = data; // 要写入的数据 if (HAL_I2C_Master_Transmit(&hi2c1, 0xA0, buf, 2, 100) != HAL_OK) { return 0; // 写失败 } HAL_Delay(5); // 等待内部写周期完成 return 1; }读单字节函数:
uint8_t EEPROM_ReadByte(uint16_t mem_addr) { uint8_t mem_addr_byte = (uint8_t)mem_addr; uint8_t data = 0; // 第一步:发送内存地址(伪写) if (HAL_I2C_Master_Transmit(&hi2c1, 0xA0, &mem_addr_byte, 1, 100) != HAL_OK) { return 0; } // 第二步:发起读操作 if (HAL_I2C_Master_Receive(&hi2c1, 0xA1, &data, 1, 100) != HAL_OK) { return 0; } return data; }这里值得多说两句。HAL_I2C_Master_Transmit的第三个参数是数据缓冲区指针,第四个参数是数据长度。写单字节时,我把内存地址和数据打包在一个数组里,一次发送两个字节,芯片内部会自动解析:第一个字节是地址,第二个字节是数据。这个打包方式很直观,不用分开两次调用。
超时时间我一般给 100ms。正常情况下 I2C 传输完成只需要几百微秒,100ms 纯粹是防御性设置。如果总线被拉死、芯片不响应,这个超时就能让 HAL 函数及时返回错误,而不是无限卡死。但要注意,如果整个工程运行在低优先级任务或者中断频繁的环境里,HAL 的 Tick 计数被抢占,这个超时时间可能偏短,需要视情况调大些。
写周期等待的 5ms 是必须的。AT24C02 数据手册上写内部写周期大约 5ms,但不巧的是,不同批次的芯片可能稍有出入,所以我一般等 5~10ms。在需要连续写大量数据的场景下,这个延时是主要耗时来源。有一种更高效的做法:写完立即发起一个读操作,如果芯片没有响应,说明还在内部写周期,继续等待;如果芯片响应了,说明写完可以继续操作。这可以动态缩短等待时间,但代码复杂度会高一些。一般场景下固定延时 5ms 最省心。
3.3 页写入:8 字节边界这个坑必须避开
AT24C02 支持页写模式,可以一次连续写入多个字节,最多 8 字节,也就是一页。页写和多次单字节写的本质区别是:页写只需要发起一次传输,速度更快,而且写周期只算一次。
但这里有一个 AT24C02 的经典大坑:页写时,如果写入地址跨越了页边界,地址指针不会进入下一页,而是会“回卷”到当前页的起始地址,把前面刚写的数据覆盖掉。
举个例子:当前页起始地址是 0x08,如果从地址 0x0D 开始连续写 4 个字节(0x0D、0x0E、0x0F、0x10),因为 0x0F 已经是当前页最后一个字节,第 4 个字节 0x10 实际会被写到 0x08 的位置,把之前存好的数据破坏掉。
解决办法是写之前判断剩余空间是否放得下。我写了一个通用的连续写函数,自动处理跨页拆分:
uint8_t EEPROM_WriteBytes(uint16_t mem_addr, uint8_t *data, uint16_t len) { const uint8_t page_size = 8; uint8_t page_offset = mem_addr % page_size; // 当前页内偏移 uint8_t write_len = page_size - page_offset; // 当前页剩余可用字节数 while (len > 0) { if (write_len > len) { write_len = len; } uint8_t buf[9]; // 1字节地址 + 最多8字节数据 buf[0] = (uint8_t)mem_addr; for (uint8_t i = 0; i < write_len; i++) { buf[1 + i] = data[i]; } if (HAL_I2C_Master_Transmit(&hi2c1, 0xA0, buf, write_len + 1, 100) != HAL_OK) { return 0; } HAL_Delay(5); // 等待写周期 // 更新指针,继续下一段 data += write_len; mem_addr += write_len; len -= write_len; write_len = page_size; // 后续都是完整页 } return 1; }这个函数里核心是mem_addr % page_size算出当前地址在页内的偏移,然后用page_size - page_offset得出当前页剩余可写字节数。之后每轮只写这么多个字节,写完等待写周期,再继续下一段。逻辑上涵盖了所有情况:地址任意、长度任意、甚至长度超过 256 字节也不会出问题。
实际上我遇到过不少人写了这个函数后仍然出问题,排查后大多是因为传入了大于 8 的单次写入长度,没有做拆分。页写循环的关键就是每次传输的数据长度绝不能让内部地址跨页。
3.4 软件模拟 I2C:不依赖硬件外设的备选方案
虽然 STM32F103 自带了硬件 I2C 外设,但实际项目里我依然经常使用软件模拟 I2C。原因很实际:软件模拟不依赖固定的引脚映射,SCL 和 SDA 可以自由配置到任意 GPIO 上;排错时可以直接用 GPIO 翻转波形观察时序;而且在硬件 I2C 出现异常无法快速定位时,软件模拟一下就能确认是芯片问题还是外设问题。
软件模拟 I2C 的关键是把 GPIO 配置成开漏输出,并且利用开漏模式可回读引脚电平的特性。我的 GPIO 初始化如下:
static void Soft_I2C_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); // SCL:开漏输出,带上拉 GPIO_InitStruct.Pin = GPIO_PIN_6; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull = GPIO_PULLUP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, &GPIO_InitStruct); // SDA:同样是开漏输出,读取应答时可以直接读电平 GPIO_InitStruct.Pin = GPIO_PIN_7; HAL_GPIO_Init(GPIOB, &GPIO_InitStruct); SDA_H(); SCL_H(); }然后是核心时序函数。先定义几个宏:
#define I2C_SCL_PIN GPIO_PIN_6 #define I2C_SDA_PIN GPIO_PIN_7 #define I2C_PORT GPIOB #define SCL_H() HAL_GPIO_WritePin(I2C_PORT, I2C_SCL_PIN, GPIO_PIN_SET) #define SCL_L() HAL_GPIO_WritePin(I2C_PORT, I2C_SCL_PIN, GPIO_PIN_RESET) #define SDA_H() HAL_GPIO_WritePin(I2C_PORT, I2C_SDA_PIN, GPIO_PIN_SET) #define SDA_L() HAL_GPIO_WritePin(I2C_PORT, I2C_SDA_PIN, GPIO_PIN_RESET) #define SDA_READ() HAL_GPIO_ReadPin(I2C_PORT, I2C_SDA_PIN) static void I2C_Delay(void) { // 72MHz主频下大约延时5us,实际数值需要根据编译优化调整 for (volatile uint8_t i = 0; i < 40; i++); }START 和 STOP 信号的实现:
void Soft_I2C_Start(void) { SDA_H(); SCL_H(); I2C_Delay(); SDA_L(); // SCL高期间SDA拉低,产生起始条件 I2C_Delay(); SCL_L(); } void Soft_I2C_Stop(void) { SDA_L(); SCL_H(); I2C_Delay(); SDA_H(); // SCL高期间SDA拉高,产生停止条件 I2C_Delay(); }发送一个字节并读取 ACK:
uint8_t Soft_I2C_WriteByte(uint8_t data) { // 发送8个数据位,高位先行 for (uint8_t i = 0; i < 8; i++) { if (data & 0x80) SDA_H(); else SDA_L(); data <<= 1; I2C_Delay(); SCL_H(); // SCL高电平期间,SDA数据必须稳定 I2C_Delay(); SCL_L(); I2C_Delay(); } // 释放SDA,读取从机的ACK应答 SDA_H(); I2C_Delay(); SCL_H(); I2C_Delay(); uint8_t ack = (SDA_READ() == 0) ? 1 : 0; // 读到低电平表示ACK SCL_L(); I2C_Delay(); return ack; }接收一个字节并决定是否回复 ACK:
uint8_t Soft_I2C_ReadByte(uint8_t ack_en) { uint8_t data = 0; SDA_H(); // 释放SDA,让从机控制数据线 for (uint8_t i = 0; i < 8; i++) { data <<= 1; SCL_H(); I2C_Delay(); if (SDA_READ()) data |= 0x01; SCL_L(); I2C_Delay(); } // 主机在第9个时钟决定是否回复ACK if (ack_en) SDA_L(); // 发送ACK,表示继续读 else SDA_H(); // 发送NACK,表示读完最后一个字节 I2C_Delay(); SCL_H(); I2C_Delay(); SCL_L(); I2C_Delay(); SDA_H(); return data; }有了这几个基础函数,写 AT24C02 的逻辑就很简单了,按照前面协议流程拼装即可。这里有个使用软件模拟时的注意点:延时函数的实际值取决于主频和编译器优化等级。在 -O0 下循环 40 次大约延时 5 微秒,但 -O2 下编译器可能优化掉空循环,导致时序快很多。稳妥的做法是把延时函数放到独立 C 文件并加__attribute__((optimize("O0"))),或者干脆用 SysTick 或 DWT 精确延时。
3.5 完整读写流程:从封装到底层调用
不管是硬件 I2C 还是软件模拟,应用层的读写封装逻辑是一致的。我通常会提供一个统一接口给上层业务代码调用:
// 功能:向指定地址写入数据 uint8_t EEPROM_Write(uint16_t addr, uint8_t *buf, uint16_t len); // 功能:从指定地址读取数据 uint8_t EEPROM_Read(uint16_t addr, uint8_t *buf, uint16_t len); // 功能:擦除一个扇区(将一整块区域置为0xFF) uint8_t EEPROM_Erase(uint16_t addr, uint16_t len);这个接口层的好处是,底层不管用的是硬件 I2C 还是软件模拟,上层业务逻辑不用改。这在后期切换方案、迁移平台时特别省事。我最初在某个项目里先跑通软件模拟,后来量产时把同一套代码切到硬件 I2C,应用层一句没动。
写一个简单的自测函数验证读写是否正常:
void EEPROM_SelfTest(void) { uint8_t test_data[16]; // 准备测试数据 for (uint8_t i = 0; i < 16; i++) { test_data[i] = i + 1; } // 写到地址0x10处 if (EEPROM_Write(0x10, test_data, 16) == 0) { printf("EEPROM write failed\r\n"); return; } HAL_Delay(20); // 读出来对比 uint8_t read_data[16]; EEPROM_Read(0x10, read_data, 16); for (uint8_t i = 0; i < 16; i++) { if (read_data[i] != test_data[i]) { printf("data mismatch at index %d\r\n", i); return; } } printf("EEPROM test passed\r\n"); }这里的测试数据选择也是有讲究的,最好用 1、2、3... 这样有规律递增的数据,万一读出来是乱序或者全 0xFF,很快就能看出规律性错误。另外一个常用技巧是先写入 0xAA 和 0x55 这类交替比特,然后读回来验证,这能暴露数据线和地址线之间的短路或串扰问题。
4. 调试实录与常见问题排查
4.1 经典坑:I2C 总线卡死,SDA 一直被拉低
这是我遇到最多的一个故障现象。调用HAL_I2C_Master_Transmit后返回HAL_BUSY或者超时,用逻辑分析仪或者示波器一抓,发现 SDA 波形一直处于低电平,根本没有正常波形。
总线卡死的根源通常有两个。第一,通信中途主机或从机异常复位,导致状态机卡在某个半截状态,从机牢牢拽住 SDA 不撒手。第二,软件代码中某次传输没有完整执行到 STOP,比如在收到错误后直接 return,总线停留在非正常状态。
解决办法也很直接。首先,如果是主机自己“以为”总线忙,可以复位 I2C 外设,或者把超时时间调大重新尝试。其次,如果是从机把总线拉死,可以手动触发一个“总线释放”操作:在 SCL 上产生 9 个时钟脉冲,同时保持 SDA 为高,让从机从错误的内部状态中退出。这个操作在 I2C 规范里被视为一种恢复手段,很多芯片都支持。
我写过一个总线恢复函数,在每次初始化之后调用一次,防止上电瞬间总线状态不确定:
void I2C_Bus_Reset(void) { SCL_L(); SDA_H(); for (uint8_t i = 0; i < 9; i++) { SCL_H(); I2C_Delay(); SCL_L(); I2C_Delay(); } SDA_H(); SCL_H(); }4.2 经典坑:主机收到 NACK,芯片始终不应答
如果发送器件地址之后返回值是失败,抓波形会看到第 9 个时钟周期 SDA 一直是高,这说明从机没有拉低总线应答。排查顺序我一般是这样:
先查地址对不对。A0/A1/A2 的接线决定了地址,三个都接地就是 0xA0/0xA1。如果地址线有上拉或者接到了高电平,地址就变了。很多开发板模块已经把 A0/A1/A2 固定,购买模块后要确认模块原理图。如果买的是带自锁底座的那种模块,有时候引脚没有完全插到位,也会出现地址不对的现象。
再查供电和接线。用万用表量 VCC 是否为 3.3V,GND 是否接触良好。SCL 和 SDA 有没有接反。这个问题听起来低级,但实际调试中我见过不止一次,尤其在飞线连接的时候,SCL 和 SDA 两根线颜色接近,很容易插错。
最后查上拉电阻。如果没有上拉电阻,SDA 本来就会维持低电平,或者在上拉后噪声大导致时序不稳定,主机也可能收不到 ACK。用示波器看波形最快。没有示波器时,可以用万用表量 SDA 对地电压,正常空闲状态下 SDA 应该接近 VCC 电平,如果在 2V 以下,很可能上拉电阻缺失或阻值过大。
4.3 经典坑:写入后读出来全是 0xFF
写入之后读回来全是 0xFF,这种症状很多人遇到过。问题往往出在三个地方。
第一,WP 写保护引脚没有拉低。AT24C02 的 WP 引脚接高电平时,整个芯片进入写保护状态,所有写操作都会被忽略,但读操作正常。这种情况下写操作返回成功(因为 I2C 层面芯片仍然响应了地址并接收了数据),只是数据根本没写进存储阵列,读回来自然全是 0xFF。解决方法就是确保 WP 接 GND,或者干脆悬空(内部有下拉,但为了稳妥还是接 GND 好)。
第二,写周期没有等待够。写完一个字节或一页,芯片内部还在擦写存储阵列时,如果紧接着进行下一次写或读,读回来的可能是旧数据。这个之前提过,加 5ms 延时就可以解决。
第三,页边界回卷。如果用了页写但没有做跨页拆分,超出当前页的数据会被回卷覆盖。这个我也踩过好几次,写进去 16 个字节,后面 8 个字节跑到页开头去了,读出来前面的数据被冲掉,看起来就像“写入失败”。所以说跨页拆分不是一个可选项,而是必须实现的逻辑。
4.4 调试观测技巧:逻辑分析仪和串口配合
I2C 调试最直观的工具是逻辑分析仪。几十块钱的 8 通道逻辑分析仪配合开源软件,就能完整解码 I2C 波形,看到地址帧、数据帧、ACK 状态。我最常用的抓取设置是:采样率选 8MHz,SDA 接通道 0,SCL 接通道 1,GND 必须和开发板共地。触发方式选 I2C Start 条件,一按复位键就能捕捉到完整通信过程。
解码后在软件界面上能看到每次传输的地址、方向和内容。比如发送地址 0xA0 后没有 ACK,波形上会显示 NACK;数据不对,能直接看到具体哪个字节跟预期不一致。有了这个工具,调试效率能提升好几倍。
没有逻辑分析仪的时候,串口打印是最后的调试手段。HAL 库的 I2C 函数返回值本身就有丰富的信息:HAL_OK、HAL_BUSY、HAL_TIMEOUT、HAL_ERROR,把这些值打印出来,起码能判断是卡在哪一步。也可以直接在 EEPROM 初始化结束后读一次全片数据,把 256 个字节通过串口全部打出来,看看是否有异常的区域。如果整个都是 0xFF,那芯片是全新未写过的状态;如果有些字节是随机值,可能之前被写过或者模块是二手拆机片。
上电后的第一件事,我建议做一次全片写 0xAA,然后断电,重新上电后再读一遍,确认数据仍然存在。这个操作验证的不只是 I2C 通信,还顺带验证了供电稳定性和芯片保持能力。如果断电后数据丢失,优先排查 VCC 上的大电容漏电、芯片供电引脚虚焊这类硬件问题。
4.5 硬件 I2C 和软件模拟 I2C 的选型建议
STM32F103 的硬件 I2C 在社区里一直有“bug 多”的说法,有些人甚至直接放弃硬件 I2C,全部用软件模拟。以我实际体验来看,所谓 bug 大多数情况是使用姿势不对:中断没有配好、EV 事件标志没及时清除、超时设置不合理、位时序时钟分频配错。按照 CubeMX 生成的默认配置,老老实实走阻塞式读写,硬件 I2C 是非常稳定的。真正遇到硬件外设本身问题的情况,我这么多年只碰到过一两次,而且大多可以通过重新初始化外设解决。
但在两类场景下我倾向于直接用软件模拟。第一类是引脚资源紧张,想把 I2C 移到任意 GPIO 上;第二类是调试阶段,想先排除硬件外设配置的干扰,快速验证芯片本身。如果项目已经进入量产阶段,我更推荐硬件 I2C,功耗、速度、代码可维护性都更好。毕竟软件模拟的延时是靠空循环撑出来的,在实时性要求较高的系统中,这种占 CPU 的方式并不划算。
5. 进阶扩展思路与实操心得
5.1 多设备挂载:地址分配与总线管理
AT24C02 的地址线是三个引脚,理论上一根 I2C 总线上最多可以挂 8 颗,前提是每颗的地址互不相同。实际项目中挂两三颗的情况比较常见,比如一颗存设备参数,一颗存运行日志。接线时只要把每颗的 A0/A1/A2 拨到不同组合,然后分别用 0xA0、0xA2、0xA4 这类地址访问即可。
多设备挂载还要注意总线电容。每多一个设备,总线寄生电容就大一些,上拉电阻可能需要相应调小。信号完整性下降时,最直接的表现是高速模式不工作、偶尔出现 ACK 丢失。我一般三颗以下还用 4.7kΩ,再往上就会降级到 3.3kΩ。
5.2 同系列芯片的地址差异
AT24C01/02/04/08/16 是一脉相承的系列。其中 AT24C01 和 AT24C02 是同样的地址结构和页大小(8 字节),但 AT24C04 开始,因为容量增大,地址线中的 A0 引脚不再作为硬件地址使用,而是用来选择内部存储区。如果用熟悉 AT24C02 的思路去操作 AT24C04,就会遇到地址对不上的情况。切换芯片型号时务必翻一遍数据手册的地址分配章节,不要想当然。
5.3 关于写周期的优化
批量写入大量参数时,每写一页等 5ms,256 字节写满需要 32 次,耗时约 160ms。在某些对启动时间敏感的场景下,这个时间可能有点长。优化思路是:写完一页后不固定延时,而是轮询芯片是否准备好。方法是在完成传输后主动去读芯片应答静态段——如果芯片还在内部写周期,它对总线上任何地址都不会响应 ACK。此时只要反复重发器件地址,直到收到 ACK,就说明可以继续写了。这个“忙等”策略能把实际等待时间压缩到接近芯片的最小写周期附近。
5.4 参数存储规划的实践经验
最后分享一个我多次项目验证过的规划思路。AT24C02 虽然只有 256 字节,但用好了也能存不少东西。我会把地址 0x00~0x0F 留作系统标志区,存储启动次数、软件版本号、固件升级标志;0x10~0x7F 作为参数区,存储用户配置和校准数据;0x80~0xFF 作为日志区或预留。每个参数最好单独验证写入是否成功,EEPROM 写入后立刻回读校验,校验失败就重试一次,重试还失败就上报错误。这个习惯在工业现场电磁环境复杂的时候,能避免很多“莫名其妙丢数据”的疑难杂症。
I2C 和 AT24C02 这个组合,说难不难,但每个环节都有细节。我个人的体会是,遇到问题先别急着改代码,第一步一定是量硬件,第二步用逻辑分析仪看时序,第三步再回来看软件逻辑。很多时候你以为的代码 bug,最终都证明是硬件连接、上拉电阻、电平不匹配之类的低级问题。把基础打好,I2C 也就是那么回事。