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.3V,AT24C02支持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的SCL,PB7接SDA,VCC接3.3V,GND接GND,A0/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,数据变量data:
HAL_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_Read:
HAL_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再读 | 加应答轮询或延长延时 |
| 读出来全是0x00 | SDA被拉死 | 检查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器件,第一件事就是看时序图、确认地址、算上拉电阻、写测试代码,这套流程已经成了肌肉记忆。