资讯动态

STM32实战:AT24C02的I2C读写与调试全攻略

发布时间:2026/10/25 1:03:25 来源:尧图企业网站定制
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比如 W25Q16EEPROM 的接口更简单I2C 只需要两根线硬件资源占用少适合小数据量、频繁改写、低功耗场景。我做项目时一般先把 AT24C02 的 256 字节做个分配规划前面放设备参数中间放校准数据最后放运行状态标志位。这种规划做好之后代码逻辑就会非常清晰后面读写也不容易错乱。1.2 硬件接线引脚功能与最小系统搭建AT24C02 一共 8 个引脚功能其实很简单引脚编号名称功能说明1A0地址引脚接地或接 VCC用于配置器件地址2A1地址引脚3A2地址引脚4GND电源地5SDA串行数据线双向需要上拉电阻6SCL串行时钟线由主机控制7WP写保护引脚。接高电平禁止写入接低电平允许写入8VCC电源正极STM32F103 上默认的 I2C1 外设对应 PB6SCL和 PB7SDA这也是绝大多数开发板的固定接线。我把接线方式列成表照着接基本不会错AT24C02 引脚连接到VCC3.3VGNDGNDA0/A1/A2GND默认地址WPGND允许写入SCLPB6SDAPB7这里有一个容易忽略的细节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 起始条件、停止条件和 ACKI2C 的“握手信号”I2C 协议里任何一次通信都以一个 START 条件开始以一个 STOP 条件结束。这两个条件都是用 SDA 和 SCL 的组合电平变化来定义的STARTSCL 保持高电平时SDA 从高电平跳变到低电平。STOPSCL 保持高电平时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 时这三位都是 07 位地址就是 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 Mode100kHzClock Speed 填 1000007 位地址模式GPIO 方面PB6 复用为 I2C1_SCLPB7 复用为 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(); }发送一个字节并读取 ACKuint8_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; }接收一个字节并决定是否回复 ACKuint8_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.3VGND 是否接触良好。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 状态。我最常用的抓取设置是采样率选 8MHzSDA 接通道 0SCL 接通道 1GND 必须和开发板共地。触发方式选 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 关于写周期的优化批量写入大量参数时每写一页等 5ms256 字节写满需要 32 次耗时约 160ms。在某些对启动时间敏感的场景下这个时间可能有点长。优化思路是写完一页后不固定延时而是轮询芯片是否准备好。方法是在完成传输后主动去读芯片应答静态段——如果芯片还在内部写周期它对总线上任何地址都不会响应 ACK。此时只要反复重发器件地址直到收到 ACK就说明可以继续写了。这个“忙等”策略能把实际等待时间压缩到接近芯片的最小写周期附近。5.4 参数存储规划的实践经验最后分享一个我多次项目验证过的规划思路。AT24C02 虽然只有 256 字节但用好了也能存不少东西。我会把地址 0x00~0x0F 留作系统标志区存储启动次数、软件版本号、固件升级标志0x10~0x7F 作为参数区存储用户配置和校准数据0x80~0xFF 作为日志区或预留。每个参数最好单独验证写入是否成功EEPROM 写入后立刻回读校验校验失败就重试一次重试还失败就上报错误。这个习惯在工业现场电磁环境复杂的时候能避免很多“莫名其妙丢数据”的疑难杂症。I2C 和 AT24C02 这个组合说难不难但每个环节都有细节。我个人的体会是遇到问题先别急着改代码第一步一定是量硬件第二步用逻辑分析仪看时序第三步再回来看软件逻辑。很多时候你以为的代码 bug最终都证明是硬件连接、上拉电阻、电平不匹配之类的低级问题。把基础打好I2C 也就是那么回事。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑