
1. 项目缘起与整体设计思路STM32F103 配 AT24C02 这个组合在嵌入式圈子里算是“Hello World”级别的经典搭配。但经典归经典真要把 I2C 读写 EEPROM 这条链路跑通、跑稳里面藏着不少细节坑。我见过太多人卡在“能写进去但读出来全是 0xFF”或者“第一次上电正常断电重启数据就丢了”这类问题上。这篇内容就把整个流程从头到尾拆开把每一步为什么这么做、参数怎么算、坑在哪里全部讲清楚。先明确这个项目要解决的核心问题让 STM32F103 通过 I2C 总线对 AT24C02 这颗 2Kbit256 字节的 EEPROM 进行可靠的字节读写和页读写。AT24C02 的容量不大256 个字节按页组织每页 8 字节一共 32 页。它便宜、简单、掉电不丢数据适合存一些配置参数、校准系数、设备序列号这类小数据。STM32F103 这边I2C 外设是硬件模块支持标准模式 100kHz 和快速模式 400kHz配合 GPIO 的开漏输出加上拉电阻就能构成完整的 I2C 物理层。为什么选硬件 I2C 而不是软件模拟这是第一个要做的方案选型。硬件 I2C 的优势在于时序由外设自动产生CPU 负担小速率稳定不占用定时器资源。但 STM32F103 的硬件 I2C 在圈子里有个“容易卡死”的名声尤其是早期固件库版本遇到总线异常时状态机可能锁死。软件模拟 I2C 则灵活得多任意 GPIO 都能用时序完全可控出问题好排查代价是占用 CPU 时间、速率受限。我的建议是如果只是读写 AT24C02 这种低速从机软件模拟 I2C 反而更省心如果系统里 I2C 总线上挂的设备多、通信频繁那就用硬件 I2C但必须加上超时和总线恢复机制。这篇内容两种方式都会涉及重点放在硬件 I2C 的完整流程上因为它是“正规军”打法理解了它软件模拟就是降维打击。整个项目的设计思路可以概括为四层物理层GPIO 开漏加外部上拉、协议层I2C 起始、停止、应答、数据位、设备层AT24C02 的器件地址、字地址、页边界、应用层读写函数封装、页写算法、延时与轮询。每一层都有它自己的规则和陷阱下面逐层拆解。注意AT24C02 的供电范围是 1.8V 到 5.5VSTM32F103 的 IO 是 3.3V 电平。如果 AT24C02 用 5V 供电I2C 总线上拉也要拉到 5V这时候 STM32 的 IO 直接接上去会有电平不匹配的问题。最稳妥的做法是 AT24C02 也用 3.3V 供电全系统统一电平省掉电平转换的麻烦。2. 硬件连接与 I2C 物理层关键细节2.1 引脚分配与上拉电阻计算STM32F103 的 I2C1 默认引脚是 PB6SCL和 PB7SDAI2C2 是 PB10SCL和 PB11SDA。用哪一组都行我习惯用 I2C1因为它的复用功能在大多数最小系统板上都引出来了。接线很简单PB6 接 AT24C02 的第 6 脚SCLPB7 接第 5 脚SDAAT24C02 的第 8 脚接 3.3V第 1 到第 4 脚全部接地A0、A1、A2 决定器件地址全接地时地址是 0xA0 写、0xA1 读第 7 脚WP接地表示允许写入。上拉电阻是 I2C 物理层的灵魂。I2C 总线是开漏结构任何设备只能把线拉低不能主动拉高所以必须靠外部上拉电阻把线拉回高电平。上拉电阻的取值不是随便拍脑袋的它由总线电容和上升时间决定。I2C 标准模式 100kHz 要求上升时间 Tr ≤ 1000ns快速模式 400kHz 要求 Tr ≤ 300ns。上升时间公式是Tr ≈ 0.847 × R × C其中 R 是上拉电阻C 是总线总电容包括 PCB 走线电容、引脚电容、器件电容。一般 PCB 走线短、只挂一两个器件时总线电容大约在 50pF 到 100pF 之间。代入快速模式的要求300ns 0.847 × R × 100pF算出来 R ≈ 3.5kΩ。所以4.7kΩ 是一个很通用的选择它在 100kHz 下绰绰有余在 400kHz 下也基本够用。如果总线电容更大比如走线长、挂了很多器件就要减小电阻比如 2.2kΩ。但电阻也不能太小否则从机拉低时灌电流太大超过器件的驱动能力AT24C02 的 SDA 灌电流能力是 3mA。3.3V 除以 2.2kΩ 约等于 1.5mA是安全的如果降到 1kΩ就是 3.3mA已经踩线了。总线电容100kHz 推荐上拉400kHz 推荐上拉灌电流3.3V50pF10kΩ4.7kΩ0.7mA100pF4.7kΩ2.2kΩ1.5mA200pF2.2kΩ1.0kΩ3.3mA超限实操心得很多最小系统板已经在 PB6、PB7 上焊了 10kΩ 上拉电阻。10kΩ 在 100kHz 下能用但波形上升沿会比较缓用示波器看会明显看到“圆角”。如果你要跑 400kHz最好换成 4.7kΩ 或 2.2kΩ。我实测过 10kΩ 跑 400kHz偶尔会出现应答位采样错误换成 4.7kΩ 后稳定。2.2 开漏模式配置与常见误区STM32 的 GPIO 要配置成复用开漏输出AF_OD模式而不是推挽。为什么因为 I2C 是多主多从的总线结构如果两个设备同时输出一个拉高一个拉低推挽输出会直接短路烧毁引脚。开漏模式下引脚只能主动拉低释放时靠外部上拉电阻拉高这样多个设备“线与”在一起就不会冲突。配置代码用标准外设库写出来是这样GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_OD; // 复用开漏 GPIO_Init(GPIOB, GPIO_InitStructure);这里有个容易忽略的点GPIO 速度要设成 50MHz。虽然 I2C 只有 100kHz 或 400kHz但开漏输出的上升沿是靠外部电阻拉起来的引脚内部驱动电路的速度等级影响的是下降沿。设成 2MHz 也能用但 50MHz 下波形更干净。另外复用功能下不需要手动设置输出数据寄存器硬件 I2C 外设会自动控制引脚。如果你用软件模拟 I2C那就配置成通用开漏输出GPIO_Mode_Out_OD然后手动翻转引脚。软件模拟时SCL 和 SDA 的初始状态都要置高释放总线。注意有些教程说软件模拟 I2C 可以用推挽输出因为只有一个主机。这话理论上没错但一旦总线上还有其他设备比如你同时挂了 OLED 和 EEPROM推挽就会出问题。养成开漏的习惯永远不会错。3. AT24C02 器件地址与读写时序拆解3.1 器件地址的构成与计算AT24C02 的 7 位器件地址是1010加上 A2、A1、A0 三个引脚的电平。A2、A1、A0 接地时为 000所以 7 位地址是 1010000即 0x50。I2C 传输时地址字节是 8 位高 7 位是器件地址最低位是读写方向位0 表示写1 表示读。所以写操作地址字节 0x50 1 | 0 0xA0读操作地址字节 0x50 1 | 1 0xA1如果你把 A0 接 VCC那器件地址就变成 0x51写地址 0xA2读地址 0xA3。A0、A1、A2 可以组合出 8 个不同地址意味着同一条 I2C 总线上最多挂 8 片 AT24C02总容量 2KB。这个设计很巧妙用三个引脚就解决了地址冲突问题。3.2 字节写时序五步走AT24C02 的字节写操作完整时序是这样的主机发送起始条件STARTSCL 高电平时SDA 从高变低。主机发送器件地址字节 0xA07 位地址加写方向位然后等待从机应答。主机发送字地址Word Address8 位范围 0x00 到 0xFF指定要写入哪个字节位置。主机发送数据字节8 位要写入的内容。主机发送停止条件STOPSCL 高电平时SDA 从低变高。每发送一个字节从机都要在第 9 个时钟周期拉低 SDA 表示应答ACK。主机在发送完停止条件后AT24C02 内部开始擦写这个擦写过程需要时间典型值 5ms最大 10ms。在这段时间内AT24C02 不会响应任何新的通信所以写完之后必须等待或者轮询应答。用标准外设库的代码实现字节写void AT24C02_ByteWrite(uint8_t wordAddr, uint8_t data) { // 等待总线空闲 while(I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY)); // 发送起始条件 I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); // 发送器件地址写 I2C_Send7bitAddress(I2C1, 0xA0, I2C_Direction_Transmitter); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); // 发送字地址 I2C_SendData(I2C1, wordAddr); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); // 发送数据 I2C_SendData(I2C1, data); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); // 发送停止条件 I2C_GenerateSTOP(I2C1, ENABLE); // 等待擦写完成 AT24C02_WaitStandby(); }这里的AT24C02_WaitStandby()是关键。有两种实现方式一种是简单粗暴的delay_ms(10)另一种是轮询应答ACK Polling。轮询应答的做法是反复发送起始条件加器件地址 0xA0如果 AT24C02 返回 ACK说明擦写完成如果返回 NACK说明还在忙。这种方式比固定延时更高效因为大多数情况下擦写在 3ms 左右就完成了。void AT24C02_WaitStandby(void) { do { I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, 0xA0, I2C_Direction_Transmitter); } while(I2C_GetFlagStatus(I2C1, I2C_FLAG_AF)); // 等待ACK I2C_ClearFlag(I2C1, I2C_FLAG_AF); I2C_GenerateSTOP(I2C1, ENABLE); }实操心得轮询应答时如果 AT24C02 一直不响应比如芯片坏了或者没接好这个循环会死等。一定要加超时计数比如循环 1000 次后退出并返回错误码。我吃过这个亏调试时程序卡死找了半天才发现是 EEPROM 的 WP 引脚被拉高了写保护导致永远不应答。3.3 页写时序与页边界陷阱AT24C02 支持页写每页 8 字节。页写的时序和字节写类似区别在于发送完字地址后连续发送多个数据字节每发一个字节地址自动加一。但这里有个大坑当地址到达页边界时地址不会跳到下一页而是回卷到当前页的开头。举个例子从地址 0x06 开始写 4 个字节地址依次是 0x06、0x07、0x00、0x01。0x07 是第 0 页的最后一个字节下一个地址不是 0x08而是回卷到 0x00。这意味着如果你从 0x06 写 8 个字节实际写入的是 0x06、0x07、0x00、0x01、0x02、0x03、0x04、0x05原来 0x00 到 0x05 的数据被覆盖了。所以页写函数必须处理页边界。我的做法是把要写的数据按页拆分每次最多写到当前页的末尾。比如要从 0x06 写 10 个字节第一次写 2 个字节到 0x06、0x07第二次写 8 个字节到 0x08 到 0x0F。void AT24C02_PageWrite(uint8_t wordAddr, uint8_t *data, uint8_t len) { uint8_t pageRemain 8 - (wordAddr % 8); // 当前页剩余空间 uint8_t firstLen (len pageRemain) ? len : pageRemain; // 写第一段 AT24C02_WriteSegment(wordAddr, data, firstLen); AT24C02_WaitStandby(); // 如果还有剩余写第二段 if(len firstLen) { AT24C02_WriteSegment(wordAddr firstLen, data firstLen, len - firstLen); AT24C02_WaitStandby(); } }这个页边界问题是 AT24C02 读写中最容易翻车的地方。很多人测试时只写一两个字节没问题一旦写超过页边界的连续数据就发现数据错乱。记住AT24C02 的页写不会自动跨页地址在页内回卷。4. 完整读写流程与关键代码实现4.1 当前地址读与随机读的区别AT24C02 的读操作分两种当前地址读和随机读。当前地址读是读取内部地址计数器指向的字节读完后地址自动加一。随机读是先写一个“伪写”操作发送字地址然后重新发送起始条件和读地址再读取数据。随机读的完整时序发送起始条件。发送器件地址 0xA0写方向。发送字地址要读哪个位置。再次发送起始条件Restart。发送器件地址 0xA1读方向。读取数据字节主机返回 NACK表示不再继续读。发送停止条件。注意第 6 步主机在读取最后一个字节时要返回 NACK告诉从机“我读完了”。如果主机返回 ACK从机会继续输出下一个字节的数据。很多初学者忘记发 NACK结果读出来的数据后面跟着一堆无效字节。uint8_t AT24C02_RandomRead(uint8_t wordAddr) { uint8_t data; // 伪写发送字地址 I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, 0xA0, I2C_Direction_Transmitter); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); I2C_SendData(I2C1, wordAddr); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); // 重启切换为读 I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, 0xA1, I2C_Direction_Receiver); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED)); // 读取数据最后一个字节前发NACK I2C_AcknowledgeConfig(I2C1, DISABLE); I2C_GenerateSTOP(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_RECEIVED)); data I2C_ReceiveData(I2C1); I2C_AcknowledgeConfig(I2C1, ENABLE); // 恢复ACK return data; }4.2 连续读与地址自动递增AT24C02 支持连续读读完一个字节后如果主机返回 ACK从机会继续输出下一个地址的数据地址自动加一到达 0xFF 后回卷到 0x00。连续读的时序和随机读类似只是在读取阶段前 N-1 个字节返回 ACK最后一个字节返回 NACK。void AT24C02_SequentialRead(uint8_t wordAddr, uint8_t *buf, uint8_t len) { // 伪写发送字地址 I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, 0xA0, I2C_Direction_Transmitter); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); I2C_SendData(I2C1, wordAddr); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); // 重启读模式 I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, 0xA1, I2C_Direction_Receiver); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED)); while(len) { if(len 1) { I2C_AcknowledgeConfig(I2C1, DISABLE); I2C_GenerateSTOP(I2C1, ENABLE); } while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_RECEIVED)); *buf I2C_ReceiveData(I2C1); len--; } I2C_AcknowledgeConfig(I2C1, ENABLE); }连续读的好处是效率高一次通信可以读很多字节不用反复发起始条件和地址。但要注意连续读没有页边界限制地址会一直递增到 0xFF 然后回卷到 0x00。所以读 256 个字节会读完整片 EEPROM。4.3 硬件 I2C 超时与总线恢复机制STM32F103 的硬件 I2C 有个让人头疼的问题当总线受到干扰或者从机异常时状态机可能卡在某个状态I2C_CheckEvent死循环。解决办法是给每个等待循环加超时#define I2C_TIMEOUT 100000 uint8_t I2C_WaitEvent(uint32_t event) { uint32_t timeout I2C_TIMEOUT; while(!I2C_CheckEvent(I2C1, event)) { if(--timeout 0) return 1; // 超时 } return 0; }如果超时了需要执行总线恢复把 SCL 配置成普通 GPIO 开漏输出手动发送 9 个时钟脉冲让从机释放 SDA然后发送停止条件最后重新初始化 I2C 外设。这个恢复流程能解决大部分“总线卡死”的问题。void I2C_BusRecovery(void) { GPIO_InitTypeDef GPIO_InitStructure; // 关闭I2C外设 I2C_Cmd(I2C1, DISABLE); // 配置SCL和SDA为普通开漏输出 GPIO_InitStructure.GPIO_Pin GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure); // 发送9个时钟脉冲 for(int i 0; i 9; i) { GPIO_ResetBits(GPIOB, GPIO_Pin_6); delay_us(5); GPIO_SetBits(GPIOB, GPIO_Pin_6); delay_us(5); } // 发送停止条件 GPIO_ResetBits(GPIOB, GPIO_Pin_7); delay_us(5); GPIO_SetBits(GPIOB, GPIO_Pin_6); delay_us(5); GPIO_SetBits(GPIOB, GPIO_Pin_7); delay_us(5); // 重新初始化I2C I2C_Init(I2C1, I2C_InitStructure); I2C_Cmd(I2C1, ENABLE); }注意总线恢复的 9 个时钟脉冲是有讲究的。I2C 协议规定如果从机在输出数据时被复位它可能一直拉低 SDA。主机发送最多 9 个时钟脉冲从机每收到一个脉冲就输出一位9 个脉冲后从机必然完成一个字节的传输并释放 SDA。这时候主机再发停止条件总线就恢复正常了。5. 常见问题排查与避坑经验实录5.1 读写失败问题速查表现象可能原因排查方法解决方案读出来全是 0xFF从机没应答或上拉电阻缺失用示波器看 SDA 是否有拉低检查上拉电阻确认 AT24C02 供电写进去读出来不对页边界回卷检查写入地址是否跨页按页拆分写入第一次正常重启丢数据写后没等擦写完成测量写后延时是否足够加轮询应答或延时 10ms偶尔通信失败总线电容过大上升沿太缓示波器看波形上升时间减小上拉电阻到 2.2kΩ程序卡死在 while 循环硬件 I2C 状态机锁死加超时计数实现总线恢复机制只能写不能读读时序中 NACK 位置错误检查最后一个字节前是否发 NACK修正 ACK/NACK 控制WP 引脚悬空导致写入失败写保护引脚状态不确定测量 WP 引脚电平WP 接地或接 VCC 明确状态5.2 那些文档里不会写的坑第一个坑AT24C02 的 WP 引脚。很多最小系统板上的 AT24C02 模块WP 引脚是悬空的。悬空意味着电平不确定有时候能写有时候不能写。我遇到过一批模块WP 悬空时写入成功率大概 70%时好时坏。后来把 WP 直接接地问题消失。所以拿到模块第一件事确认 WP 引脚的状态。第二个坑上电后的第一次通信。AT24C02 上电后需要一段时间稳定典型值是 1ms 到 5ms。如果你在main函数一开始就立刻读写可能会失败。稳妥的做法是在初始化 I2C 之后加一个delay_ms(10)再开始通信。第三个坑I2C 速率与电源电压的关系。AT24C02 在 3.3V 供电时标准模式 100kHz 没问题快速模式 400kHz 也能跑。但如果电压降到 2.5V400kHz 就可能不稳定了。手册里写的“400kHz 2.5V”是在理想条件下的实际 PCB 上有电容、有干扰降额使用更安全。我一般跑 100kHz对 EEPROM 来说完全够用写一个字节 5ms读一个字节几十微秒瓶颈在擦写时间不在通信速率。第四个坑连续写多个字节时的等待。页写 8 个字节后需要等待擦写完成。如果你连续调用页写函数写 32 页每页之间都要等待。有人为了省事在页写函数里不加等待结果后面的数据覆盖前面的。记住每次写操作字节写或页写之后必须等待 AT24C02 完成内部擦写。第五个坑用I2C_CheckEvent时的顺序。标准外设库的I2C_CheckEvent会检查多个标志位的组合如果顺序不对可能永远等不到。比如发送完地址后要等I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED这个事件包含了 SB、ADDR、TRA、BUSY 等多个标志。如果你先清了某个标志事件就永远不成立。所以不要手动清除这些标志让库函数自己管理。5.3 调试手段与验证方法调试 I2C 最有效的工具是示波器或者逻辑分析仪。看波形能直接判断问题起始条件是否标准、时钟频率是否正确、数据位是否稳定、应答位是否出现。如果没有这些设备可以用“分段测试法”先只发起始条件和地址看能否收到 ACK再加字地址看能否收到 ACK最后加数据。每加一步测试一次定位问题在哪一步。另一个技巧是用已知数据填充。先把整个 EEPROM 写 0x00 到 0xFF 的递增序列然后读出来对比。如果读出来的数据有规律地错位说明地址计算有问题如果全是 0xFF说明根本没写进去如果部分正确部分错误说明页边界处理有问题。// 自检函数写入递增序列并校验 uint8_t AT24C02_SelfTest(void) { uint8_t buf[256]; for(int i 0; i 256; i) buf[i] i; // 分页写入 for(int i 0; i 256; i 8) { AT24C02_PageWrite(i, buf[i], 8); } // 连续读取并校验 uint8_t readBuf[256]; AT24C02_SequentialRead(0, readBuf, 256); for(int i 0; i 256; i) { if(readBuf[i] ! buf[i]) return i; // 返回第一个错误位置 } return 0xFF; // 全部正确 }这个自检函数能覆盖页边界、连续读、地址回卷等所有关键路径。每次硬件改动后跑一遍心里有底。6. 软件模拟 I2C 的补充方案与选型建议6.1 什么时候该用软件模拟硬件 I2C 虽然“正规”但在 STM32F103 上确实有它的脾气。如果你遇到以下情况软件模拟 I2C 是更好的选择总线上的设备兼容性差、需要非标准时序、硬件 I2C 引脚被占用、或者你就是不想跟那个复杂的状态机打交道。软件模拟 I2C 的代码量不大核心就是几个宏SCL 拉高、SCL 拉低、SDA 拉高、SDA 拉低、读 SDA 电平。#define SCL_H() GPIO_SetBits(GPIOB, GPIO_Pin_6) #define SCL_L() GPIO_ResetBits(GPIOB, GPIO_Pin_6) #define SDA_H() GPIO_SetBits(GPIOB, GPIO_Pin_7) #define SDA_L() GPIO_ResetBits(GPIOB, GPIO_Pin_7) #define SDA_READ() GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_7) void I2C_Delay(void) { delay_us(5); } // 100kHz void I2C_Start(void) { SDA_H(); SCL_H(); I2C_Delay(); SDA_L(); I2C_Delay(); SCL_L(); I2C_Delay(); } void I2C_Stop(void) { SDA_L(); SCL_H(); I2C_Delay(); SDA_H(); I2C_Delay(); } uint8_t I2C_SendByte(uint8_t byte) { for(int i 0; i 8; i) { if(byte 0x80) SDA_H(); else SDA_L(); byte 1; I2C_Delay(); SCL_H(); I2C_Delay(); SCL_L(); I2C_Delay(); } SDA_H(); I2C_Delay(); // 释放SDA等待ACK SCL_H(); I2C_Delay(); uint8_t ack SDA_READ(); // 读ACK0表示应答 SCL_L(); I2C_Delay(); return ack; }软件模拟的延时delay_us(5)对应大约 100kHz 的速率。如果要跑 400kHz改成delay_us(1)左右但要注意 STM32F103 在 72MHz 主频下delay_us(1)的精度可能不够实际速率会偏低。软件模拟的速率不需要很精确I2C 是同步通信从机跟着主机的时钟走慢一点没关系。6.2 硬件 I2C 与软件模拟的对比对比项硬件 I2C软件模拟 I2CCPU 占用低外设自动处理高需要循环等待速率稳定性高时钟精确一般受中断影响引脚灵活性固定引脚任意 GPIO代码复杂度高状态机复杂低逻辑直观抗干扰能力一般异常时可能锁死好完全可控多主机支持支持不支持调试难度高状态多低看波形即可我的建议是产品开发用硬件 I2C加上超时和恢复机制快速验证和调试用软件模拟省心。两者不冲突可以在同一个项目里共存比如用硬件 I2C 接 EEPROM用软件模拟接 OLED各取所需。6.3 从字节读写到结构化数据存储实际项目中我们不会只存单个字节而是存结构化的配置参数。比如一个设备配置结构体typedef struct { uint16_t deviceId; uint8_t brightness; uint8_t volume; uint32_t calibration; uint8_t checksum; } DeviceConfig;把结构体写入 EEPROM 时直接按字节写入即可但要注意结构体对齐问题。编译器可能会在成员之间插入填充字节导致sizeof(DeviceConfig)大于各成员之和。用#pragma pack(1)可以取消对齐确保结构体在内存中是紧凑的写入 EEPROM 后读出来才能正确解析。#pragma pack(push, 1) typedef struct { uint16_t deviceId; uint8_t brightness; uint8_t volume; uint32_t calibration; uint8_t checksum; } DeviceConfig; #pragma pack(pop)另外建议在结构体末尾加一个校验字节比如所有字节的异或和。读取时先校验校验失败就加载默认配置。这样能防止 EEPROM 数据损坏导致系统异常。uint8_t CalcChecksum(uint8_t *data, uint8_t len) { uint8_t sum 0; for(int i 0; i len - 1; i) sum ^ data[i]; return sum; }我在实际项目里用这套方案存设备参数跑了三年多没出现过数据丢失。关键就是写后等待、读后校验、异常时恢复默认值。这三条做到了AT24C02 的可靠性完全够用。最后分享一个小技巧如果你觉得每次写一个字节等 5ms 太慢可以用“脏标记”策略——在 RAM 里维护一份配置副本修改时只改 RAM定期或者断电前统一写入 EEPROM。这样既减少了擦写次数AT24C02 的擦写寿命是 100 万次又提高了响应速度。但要注意断电前写入需要提前检测电源掉电用 ADC 监测供电电压降到阈值以下时触发紧急保存。这个方案稍微复杂一些适合对写入频率有要求的场景。