1. 项目缘起与整体设计思路搞嵌入式开发的朋友大概率都碰过这样一个场景板子跑起来了传感器数据也读到了但一断电之前辛辛苦苦标定的参数全没了下次上电又得重新来一遍。这时候就需要一颗能记住事儿的芯片而AT24C02就是这类需求里出场率极高的角色。它是一颗2Kbit容量的EEPROM换算成字节就是256字节通过I2C总线跟主控通信掉电不丢数据价格便宜封装小巧几乎成了STM32入门阶段绕不开的一个外设。这次要拆解的项目就是用STM32F103这颗经典MCU通过硬件I2C或者软件模拟I2C的方式完成对AT24C02的完整读写操作。说白了就是三件事往里面写一个字节、从里面读一个字节、以及按页批量读写。听起来简单但真正动手的时候时序、地址、应答、写周期等待这些细节一个没处理好就是读出来一堆0xFF或者数据错位。我见过不少新手卡在这里好几天最后发现是上拉电阻没接或者写周期没等够。这个内容适合谁看如果你刚接触STM32的I2C外设或者之前用库函数糊里糊涂能跑但不知道底层发生了什么那这篇拆解就是给你准备的。我会从硬件连接讲到寄存器层面的时序再落到HAL库的实际代码最后把常见坑一个个拎出来。目标很明确看完你能自己独立完成一套稳定的EEPROM读写驱动而不是复制粘贴一堆代码然后祈祷它能跑。整体设计上我选择用STM32F103C8T6作为主控这是最常见的入门型号I2C1挂在PB6和PB7上。AT24C02的A0/A1/A2全部接地所以器件地址是0xA0写和0xA1读。通信速率先用100kHz标准模式稳定之后再尝试400kHz快速模式。软件层面我打算两条腿走路先讲硬件I2C的配置再讲软件模拟I2C的实现因为实际项目里这两种方案各有各的适用场景硬件I2C省CPU但灵活性差软件模拟占用CPU但引脚随便换、时序可控。为什么这么设计因为很多初学者一上来就用CubeMX生成硬件I2C代码结果遇到总线锁死或者从机不响应的时候完全不知道从哪查起。如果先理解了软件模拟的时序再回头看硬件I2C的寄存器配置整个逻辑就通透了。这就像学开车先知道离合油门怎么配合再去开自动挡就心里有数。2. AT24C02核心细节与I2C时序解析2.1 器件地址与存储结构AT24C02的存储空间是256字节地址范围0x00到0xFF。它的器件地址是7位高4位固定为1010也就是0xA低3位由A2/A1/A0三个引脚决定。这三个引脚接地就是000接VCC就是111所以一条I2C总线上最多可以挂8颗AT24C02总容量2KB。实际用的时候如果只挂一颗A0/A1/A2全接地最省事器件地址就是0xA0。这里有个容易混淆的点很多人看到数据手册上写1010就以为是0xA0其实0xA0是8位地址包含了读写位。7位地址是0x50左移一位加上写位0变成0xA0加上读位1变成0xA1。我刚开始学的时候就在这里绕了半天发送0x50死活没应答后来才反应过来应该发0xA0。存储结构上AT24C02分成32页每页8字节。这个页的概念非常关键因为写操作分两种字节写和页写。字节写就是一次写一个字节页写是一次写一页最多8个字节但页写有个坑——如果你从页中间开始写写到页尾会自动翻转到本页开头而不是下一页开头。比如从地址0x05开始写8个字节实际会写到0x05、0x06、0x07、0x00、0x01、0x02、0x03、0x04数据被覆盖了。这个翻转行为是硬件决定的不处理好就是数据错乱。2.2 I2C通信时序的关键节点I2C总线就两根线SCL时钟线和SDA数据线都是开漏输出所以必须接上拉电阻。阻值一般选4.7kΩ到10kΩ速率越高阻值越小。我实测下来100kHz用4.7kΩ很稳400kHz建议用2.2kΩ到4.7kΩ。上拉电阻接到3.3V还是5V取决于MCU和EEPROM的供电电压STM32F103是3.3VAT24C02支持1.8V到5.5V所以统一3.3V最方便。起始条件SCL为高时SDA从高变低。停止条件SCL为高时SDA从低变高。这两个条件必须由主机产生而且时序要标准SDA变化时SCL必须保持稳定。数据位传输SCL为低时SDA可以变化SCL为高时SDA必须稳定接收方在SCL高电平期间采样。每传输一个字节接收方要拉低SDA一个时钟周期作为应答ACK主机在第九个时钟周期释放SDA并读取电平低电平就是应答高电平就是非应答NACK。写一个字节的完整流程是这样的起始条件 → 发送器件地址写位0xA0→ 等待ACK → 发送字节地址0x00到0xFF→ 等待ACK → 发送数据字节 → 等待ACK → 停止条件 → 等待写周期约5ms。注意最后这个写周期AT24C02在收到停止条件后会进入内部写操作这段时间它不响应任何总线请求必须等它写完才能进行下一次操作。数据手册标称最大5ms我一般延时5到10ms保险。读操作稍微复杂一点分当前地址读、随机读和顺序读。随机读最常用起始 → 发送0xA0 → 等待ACK → 发送字节地址 → 等待ACK → 重新起始 → 发送0xA1 → 等待ACK → 读取数据 → 发送NACK → 停止。注意最后主机要发NACK而不是ACK告诉从机我读够了你别再发了然后发停止条件。2.3 写周期等待与应答轮询前面说的延时5ms是最简单的处理方式但效率低。更聪明的做法是应答轮询发完停止条件后立刻再发起始条件并发送器件地址写位如果AT24C02还在忙它不会应答主机收到NACK就知道还没写完过一会儿再试直到收到ACK说明写完了。这样平均等待时间远小于5ms尤其在小数据量频繁写入的场景下能明显提升效率。不过应答轮询也有个细节每次轮询失败后要发停止条件再重新起始不能一直挂着。我一般写成循环最多轮询100次每次间隔100微秒超时就报错。实测下来单字节写通常1到3ms就完成了页写8字节大概3到5ms。注意AT24C02的写次数标称100万次但这是整片寿命不是每个字节。如果你在同一个地址反复写那个字节会先坏。实际项目里要做磨损均衡比如用一个计数器记录当前写到哪个位置轮流使用不同地址。3. 硬件连接与工程配置实操3.1 硬件连线与上拉电阻选择先把手上的东西理清楚STM32F103C8T6最小系统板一块AT24C02模块或者裸芯片一个杜邦线若干如果有条件的话用逻辑分析仪或者示波器看波形没有的话靠串口打印调试也行。连线方案STM32的PB6接AT24C02的SCLPB7接SDAVCC接3.3VGND接GNDA0/A1/A2全部接地WP引脚接地写保护关闭。上拉电阻两个4.7kΩ分别从SCL和SDA拉到3.3V。如果你买的是模块板上通常已经带了上拉电阻就不用外接了但要注意有些模块的上拉电阻是10kΩ跑400kHz可能波形上升沿不够陡这时候要么换电阻要么降速。我踩过的一个坑有一次用杜邦线连了20厘米长100kHz都通信失败波形上升沿惨不忍睹。后来把线缩短到5厘米以内就正常了。I2C虽然只有两根线但对分布电容很敏感线越长电容越大上升沿越慢。所以实际画PCB的时候I2C走线尽量短远离高频信号线。3.2 CubeMX配置硬件I2C打开CubeMX选好STM32F103C8T6在Connectivity里找到I2C1模式选I2C速度模式选Standard Mode时钟频率填100000。引脚会自动分配到PB6和PB7。然后在Clock Configuration里确认I2C1的时钟源是APB1频率36MHz。这里有个计算I2C的CCR寄存器值等于APB1时钟除以2乘以目标频率36MHz除以200kHz等于180所以CCR设为180实际速率就是100kHz。CubeMX会自动算但知道这个公式有助于你排查为什么实际速率不对。生成代码后HAL库会初始化I2C1。但要注意HAL库的I2C函数有时候会卡死尤其是从机不响应的时候。我建议在I2C初始化之后加一句__HAL_I2C_ENABLE_IT(hi2c1, I2C_IT_ERR)使能错误中断或者在超时参数上设一个合理的值比如100ms避免死等。3.3 软件模拟I2C的引脚配置如果你选择软件模拟就把PB6和PB7配置成开漏输出模式输出速度选50MHz。开漏模式配合外部上拉电阻才能实现真正的双向SDA。有些新手把SDA配成推挽输出读数据的时候主机和从机同时驱动轻则读错重则烧引脚。记住I2C的SDA在任何时候都应该是开漏SCL主机驱动时也是开漏。软件模拟的延时函数很关键。100kHz意味着一个时钟周期10微秒半周期5微秒。用__NOP()或者简单的for循环延时都行但要注意编译器优化等级。我一般用delay_us(2)这种实测在72MHz主频下2微秒的延时加上函数调用开销刚好接近5微秒半周期。如果你用示波器看波形发现频率偏差大就调整延时值。4. 完整读写流程与代码实现4.1 硬件I2C字节写与页写先看字节写的HAL库实现。假设器件地址宏定义AT24C02_ADDR_WRITE 0xA0字节地址变量mem_addr数据变量dataHAL_StatusTypeDef AT24C02_WriteByte(uint8_t mem_addr, uint8_t data) { HAL_StatusTypeDef status; status HAL_I2C_Mem_Write(hi2c1, AT24C02_ADDR_WRITE, mem_addr, I2C_MEMADD_SIZE_8BIT, data, 1, 100); if (status ! HAL_OK) { return status; } HAL_Delay(5); return HAL_OK; }HAL_I2C_Mem_Write这个函数封装了起始、发送器件地址、发送内存地址、发送数据、停止的完整流程。最后一个参数100是超时时间单位毫秒。写完之后的HAL_Delay(5)就是等写周期。如果你用应答轮询可以把延时换成轮询函数HAL_StatusTypeDef AT24C02_WaitReady(void) { uint8_t retry 100; while (retry--) { if (HAL_I2C_IsDeviceReady(hi2c1, AT24C02_ADDR_WRITE, 1, 10) HAL_OK) { return HAL_OK; } HAL_Delay(1); } return HAL_ERROR; }页写跟字节写类似只是数据长度变成最多8字节而且要注意不能跨页。我一般写一个页写函数内部先判断起始地址和长度如果跨页就拆成多次写HAL_StatusTypeDef AT24C02_WritePage(uint8_t mem_addr, uint8_t *data, uint8_t len) { uint8_t page_remain 8 - (mem_addr % 8); if (len page_remain) { return HAL_I2C_Mem_Write(hi2c1, AT24C02_ADDR_WRITE, mem_addr, I2C_MEMADD_SIZE_8BIT, data, len, 100); } else { HAL_StatusTypeDef status; status HAL_I2C_Mem_Write(hi2c1, AT24C02_ADDR_WRITE, mem_addr, I2C_MEMADD_SIZE_8BIT, data, page_remain, 100); if (status ! HAL_OK) return status; HAL_Delay(5); status HAL_I2C_Mem_Write(hi2c1, AT24C02_ADDR_WRITE, mem_addr page_remain, I2C_MEMADD_SIZE_8BIT, data page_remain, len - page_remain, 100); HAL_Delay(5); return status; } }4.2 随机读与顺序读实现随机读用HAL_I2C_Mem_ReadHAL_StatusTypeDef AT24C02_ReadByte(uint8_t mem_addr, uint8_t *data) { return HAL_I2C_Mem_Read(hi2c1, AT24C02_ADDR_READ, mem_addr, I2C_MEMADD_SIZE_8BIT, data, 1, 100); }注意这里器件地址用的是读地址0xA1。HAL_I2C_Mem_Read内部会自动处理重新起始条件先写地址再读数据。顺序读就是读多个字节把长度改成你要读的字节数就行。AT24C02支持整片顺序读地址到0xFF后会自动回卷到0x00不会出错。4.3 软件模拟I2C的底层函数软件模拟的核心是六个函数起始、停止、发送字节、接收字节、发送应答、接收应答。我贴几个关键实现void I2C_Start(void) { SDA_OUT(); SDA_HIGH(); SCL_HIGH(); delay_us(4); SDA_LOW(); delay_us(4); SCL_LOW(); delay_us(4); } void I2C_SendByte(uint8_t byte) { SDA_OUT(); for (uint8_t i 0; i 8; i) { if (byte 0x80) SDA_HIGH(); else SDA_LOW(); byte 1; delay_us(2); SCL_HIGH(); delay_us(4); SCL_LOW(); delay_us(2); } } uint8_t I2C_ReadByte(void) { uint8_t byte 0; SDA_IN(); for (uint8_t i 0; i 8; i) { SCL_HIGH(); delay_us(4); byte 1; if (SDA_READ()) byte | 0x01; SCL_LOW(); delay_us(4); } return byte; }SDA_OUT()和SDA_IN()是切换引脚方向的宏发送时输出接收时输入。SDA_HIGH()和SDA_LOW()在输出模式下控制电平在输入模式下SDA_HIGH()要配置成上拉输入或者浮空输入让外部上拉电阻把线拉高。基于这些底层函数字节写就是起始 → 发送0xA0 → 等待应答 → 发送内存地址 → 等待应答 → 发送数据 → 等待应答 → 停止 → 延时。读就是起始 → 发送0xA0 → 等待应答 → 发送内存地址 → 等待应答 → 起始 → 发送0xA1 → 等待应答 → 读数据 → 发送NACK → 停止。4.4 实测数据与波形验证我用逻辑分析仪抓过一组波形100kHz下写一个字节的完整帧大概是这样的起始条件占约5微秒器件地址字节9个时钟周期约90微秒内存地址字节90微秒数据字节90微秒停止条件5微秒总共约280微秒。加上5ms写周期一次字节写耗时约5.3ms。页写8字节的话数据传输部分约1.5ms写周期5ms总共6.5ms平均每个字节0.8ms比字节写快很多。读操作没有写周期随机读一帧约300微秒顺序读N个字节就是300微秒加上(N-1)乘以90微秒。所以读操作比写操作快一个数量级。实际项目里如果频繁记录数据建议攒够一页再写能显著延长EEPROM寿命。提示如果你用HAL库发现HAL_I2C_Mem_Write返回HAL_BUSY或者HAL_TIMEOUT先检查上拉电阻和连线再用逻辑分析仪看波形。90%的问题出在硬件上剩下10%是地址搞错了。5. 常见问题排查与避坑经验5.1 通信失败速查表现象可能原因排查方法解决方式一直返回HAL_ERROR器件地址错误确认A0/A1/A2接地地址应为0xA0用示波器看发送的地址字节读出来全是0xFF写周期没等够写后延时5ms再读加应答轮询或延长延时读出来全是0x00SDA被拉死检查SDA是否被从机拉低重新上电或发9个时钟解锁偶尔通信失败上拉电阻太大测上升沿时间换4.7kΩ或更小400kHz下不稳定线太长或上拉不够缩短线长减小电阻降到100kHz先用页写数据错乱跨页翻转检查起始地址和长度拆分成不跨页的写5.2 总线锁死的解锁方法I2C总线锁死是常见问题通常是因为主机在从机发送数据时复位了导致从机一直拉着SDA不放。解锁方法很简单把SCL配置成推挽输出手动发送9个时钟脉冲然后发一个停止条件。具体操作是SCL拉低延时SCL拉高延时重复9次然后SDA拉低SCL拉高SDA拉高产生停止条件。之后重新初始化I2C外设就能恢复正常。我一般会在I2C初始化之前加一段解锁代码不管有没有锁死都执行一遍成本很低但能避免很多莫名其妙的故障。尤其是调试阶段频繁复位的时候这个习惯能省不少时间。5.3 数据可靠性保障技巧EEPROM虽然掉电不丢数据但也不是绝对可靠。我一般会在数据后面加一个校验字节比如CRC8或者简单的累加和。读的时候校验一下不对就重读或者用默认值。另外重要数据存两份一份在0x00一份在0x80读的时候对比不一致就以其中一份为准并报警。还有一点AT24C02的写周期是5ms但这5ms内如果电源波动可能写失败。所以写完之后最好读回来验证一下确认写进去了再继续。这个写-读-验证的流程在工业项目里几乎是标配多花几毫秒换来的可靠性提升非常值得。5.4 软件模拟与硬件I2C的取舍最后说说选型。硬件I2C的优点是省CPU、速率高、代码简洁缺点是引脚固定、调试困难、某些型号的STM32硬件I2C有已知bug。软件模拟的优点是引脚任意、时序可控、移植方便缺点是占CPU、速率上限低、代码量大。我的经验是如果项目对功耗和CPU占用敏感用硬件I2C如果引脚紧张或者需要挂多个I2C设备在不同引脚上用软件模拟。初学者建议先学软件模拟把时序吃透再用硬件I2C就会觉得很简单。反过来先学硬件I2C遇到问题容易一头雾水。我个人在实际操作中的体会是AT24C02这颗芯片虽然简单但把I2C协议、时序、写周期、页管理这些概念都串起来了是学习嵌入式存储外设的绝佳入口。把这颗芯片玩透了后面换AT24C256、FRAM、甚至SPI Flash思路都是相通的。踩过几次坑之后我现在拿到任何I2C器件第一件事就是看时序图、确认地址、算上拉电阻、写测试代码这套流程已经成了肌肉记忆。