☰
STM32F103驱动AT24C02实战:I2C时序、页写与避坑指南
2026/10/7 20:04:03 网站建设 项目流程

1. 为什么AT24C02是I2C入门的最佳练手对象

搞STM32的兄弟基本都绕不开I2C这个外设,而AT24C02几乎是所有人接触I2C的第一个实战器件。原因很简单:它便宜、好买、协议标准、时序宽容度高,而且掉电不丢数据这个特性让它在很多小项目里充当"参数存储器"的角色。你做的那个温控器要记住上次设定的温度、你的小台灯要保存亮度档位、你的数据采集板要存校准系数,这些场景AT24C02都能顶上。

但很多人第一次调AT24C02的时候都会卡住——要么读出来全是0xFF,要么写进去读出来不对,要么干脆卡在等待ACK的死循环里。这些问题背后其实都是对I2C时序理解不到位、对AT24C02的页写机制和写周期不了解造成的。我见过太多人直接抄一份代码能用就完事,结果换个芯片、换个上拉电阻值就歇菜了。

这篇内容我打算把STM32F103驱动AT24C02这件事从头到尾拆开讲。从硬件电路怎么搭、I2C的开漏模式为什么必须配外部上拉、到软件层面用寄存器还是HAL库、页写怎么处理跨页、写周期等待怎么做才不丢数据,全部按实际项目里踩过的路子来。不管你是刚上手STM32的新手,还是想把这套东西整理成自己代码库的老手,应该都能拿到点能直接用的东西。

2. 硬件设计:别小看那两颗上拉电阻

2.1 AT24C02的引脚连接与地址配置

AT24C02是8引脚的小芯片,I2C接口只占两根线:SCL(串行时钟)和SDA(串行数据)。剩下的引脚里,A0/A1/A2是地址选择脚,WP是写保护脚,VCC和GND供电。

地址这块很多人第一次会懵。AT24C02的7位从机地址是1010加上A2 A1 A0三位,所以:

A2A1A07位地址8位写地址8位读地址
0000x500xA00xA1
0010x510xA20xA3
0100x520xA40xA5
1110x570xAE0xAF

实际用的时候,如果总线上只挂一片AT24C02,把A0/A1/A2全接地就行,地址就是0x50。注意这里说的是7位地址,HAL库函数里传的参数需要左移一位变成8位形式,这个后面代码部分会细说。

WP写保护脚要特别注意。接高电平时芯片进入只读状态,你写什么都写不进去,而且不会报错,就是默默失败。调试阶段建议直接接地,等确认读写都正常了再考虑要不要用写保护功能。我遇到过有人WP悬空导致写入时好时坏的情况,因为悬空电平不确定,芯片可能在保护和非保护之间乱跳。

2.2 上拉电阻选型:4.7k不是万能答案

I2C总线是开漏结构,这意味着SCL和SDA线只能被拉低,不能被主动拉高。所以必须外接上拉电阻,否则总线永远起不来。STM32F103的I2C引脚配置成开漏复用模式后,内部弱上拉根本不够用,必须外部加。

上拉电阻的取值是个权衡:

  • 阻值太大(比如10k以上):上升沿变缓,高速通信时波形还没到高电平就被拉低了,导致通信失败。示波器上看就是上升沿是个圆弧而不是陡峭的直角。
  • 阻值太小(比如1k以下):灌电流太大,器件可能扛不住。AT24C02的SDA引脚最大灌电流是3mA,3.3V供电时理论上最小电阻是1.1k。

实际项目里4.7k是最常用的值,对应3.3V系统、100kHz标准模式、总线电容不超过200pF的场景。但如果你的总线走线长、挂了多个器件、或者跑400kHz快速模式,4.7k可能就偏大了。这时候可以降到2.2k甚至1.8k。

有个经验公式可以估算:上升时间tr ≈ 0.847 × R × C,I2C标准模式要求上升时间小于1000ns,快速模式小于300ns。假设总线电容100pF,标准模式下R最大约11.8k,快速模式下R最大约3.5k。所以跑400kHz的时候4.7k其实是超标的,虽然很多时候能凑合工作,但余量不够。

实操建议:调试阶段先用4.7k,如果示波器看到上升沿明显变圆或者通信不稳定,换成2.2k试试。手头没有合适阻值的时候,可以用两个10k并联得到5k,效果接近。

2.3 电平匹配问题:3.3V MCU和5V EEPROM能直连吗

AT24C02有不同电压版本,常见的有2.5V、3.3V、5V供电的型号。如果你用的是5V供电的AT24C02,而STM32F103是3.3V系统,这里就有电平匹配问题。

好消息是I2C的开漏结构天然适合电平转换。因为总线上的高电平是由上拉电阻决定的,不是由器件推出来的。所以只要把上拉电阻接到3.3V,5V的AT24C02和3.3V的STM32就能正常通信——AT24C02的SDA/SCL引脚在5V供电时,输入高电平门限大约是0.7×VCC=3.5V,3.3V可能刚好卡在边缘。

稳妥的做法是上拉电阻接到3.3V,同时确认AT24C02的VIH参数。查AT24C02手册,VIH最小值是0.7×VCC,5V供电时就是3.5V,3.3V确实不够。这时候要么换3.3V版本的AT24C02,要么加电平转换电路。

最简单的电平转换方案是用一个N沟道MOS管加两个上拉电阻,但更省事的办法是直接用3.3V供电的AT24C02,现在市面上3.3V版本已经很常见了,没必要给自己找麻烦。

3. STM32F103的I2C外设配置:寄存器还是HAL库

3.1 开漏模式与推挽模式的本质区别

STM32的GPIO有推挽和开漏两种输出模式,I2C必须用开漏。这个点很多人知道结论但说不清原因。

推挽输出是上下两个MOS管交替导通,输出高电平时上管导通、下管截止,引脚被主动拉到VCC;输出低电平时下管导通、上管截止,引脚被拉到GND。这种模式下,如果两个器件同时输出,一个输出高一个输出低,就会形成VCC到GND的直接通路,大电流烧毁引脚。

开漏输出只有下管,没有上管。输出低电平时下管导通拉低总线;输出高电平时下管截止,引脚处于高阻态,靠外部上拉电阻把电平拉高。这样多个器件的开漏输出接在一起,任何一个拉低总线都是低电平,全部释放才是高电平,这就是I2C的线与逻辑,也是总线仲裁的基础。

配置的时候,STM32F103的I2C引脚要设成复用开漏模式(GPIO_Mode_AF_OD),速度根据通信速率选,100kHz选2MHz就够了,400kHz建议选50MHz。

3.2 硬件I2C vs 软件模拟I2C

STM32F103的硬件I2C外设有个众所周知的毛病:在某些情况下会死锁,尤其是总线受到干扰或者从机没有正常响应的时候。这个问题的根源是硬件I2C状态机在异常情况下可能卡在某个状态出不来,需要复位整个I2C外设才能恢复。

所以实际项目里,很多人宁愿用软件模拟I2C。软件模拟的好处是:

  • 时序完全可控,出问题容易排查
  • 不会死锁,超时了直接返回错误就行
  • 移植方便,换个MCU改改GPIO操作就行
  • 可以灵活处理各种非标准时序

坏处是占用CPU时间,高速通信时CPU开销大。不过AT24C02这种器件,100kHz的速率软件模拟完全够用,CPU占用率也不高。

我的建议是:如果是学习或者对可靠性要求高的场景,用软件模拟;如果是产品开发且硬件I2C调通了,用硬件I2C省CPU。下面两种方式我都会讲。

3.3 软件模拟I2C的时序实现要点

软件模拟I2C的核心就是精确控制SCL和SDA的时序。先定义几个基本操作:

// 引脚定义 #define I2C_SCL_PIN GPIO_Pin_6 #define I2C_SDA_PIN GPIO_Pin_7 #define I2C_GPIO GPIOB // SCL和SDA操作宏 #define SCL_H() GPIO_SetBits(I2C_GPIO, I2C_SCL_PIN) #define SCL_L() GPIO_ResetBits(I2C_GPIO, I2C_SCL_PIN) #define SDA_H() GPIO_SetBits(I2C_GPIO, I2C_SDA_PIN) #define SDA_L() GPIO_ResetBits(I2C_GPIO, I2C_SDA_PIN) #define SDA_READ() GPIO_ReadInputDataBit(I2C_GPIO, I2C_SDA_PIN)

起始条件:SCL为高时,SDA从高变低。

void I2C_Start(void) { SDA_H(); SCL_H(); delay_us(4); SDA_L(); delay_us(4); SCL_L(); delay_us(4); }

停止条件:SCL为高时,SDA从低变高。

void I2C_Stop(void) { SDA_L(); SCL_H(); delay_us(4); SDA_H(); delay_us(4); }

发送一个字节:从最高位开始,每bit在SCL低电平时准备好SDA,然后SCL拉高,从机在SCL高电平期间采样。

void I2C_SendByte(uint8_t byte) { for (int i = 0; i < 8; i++) { SCL_L(); delay_us(2); if (byte & 0x80) SDA_H(); else SDA_L(); byte <<= 1; delay_us(2); SCL_H(); delay_us(4); } SCL_L(); delay_us(2); }

接收ACK:发送完8bit后,主机释放SDA,从机拉低表示应答。

uint8_t I2C_WaitAck(void) { uint8_t ack; SDA_H(); // 释放SDA delay_us(2); SCL_H(); delay_us(4); ack = SDA_READ(); // 0表示有ACK SCL_L(); delay_us(2); return ack; }

这里的delay_us很关键。100kHz的I2C,每个时钟周期10us,高电平低电平各5us左右。上面的延时加起来大概符合这个节奏。但要注意,delay_us的实现要准确,用SysTick或者定时器都行,别用for循环空跑,那个时间不准。

踩坑记录:我最早写软件I2C的时候,delay时间给太短,示波器上看SCL频率到了300多kHz,AT24C02直接不响应。后来把延时调大,降到100kHz左右就正常了。AT24C02标准模式最高100kHz,别超。

4. AT24C02读写全流程拆解

4.1 字节写:从起始到ACK的完整时序

AT24C02的字节写流程是这样的:

  1. 主机发送起始条件
  2. 主机发送设备地址+写方向(0xA0)
  3. 等待从机ACK
  4. 主机发送要写入的内存地址(0x00~0xFF)
  5. 等待从机ACK
  6. 主机发送要写入的数据字节
  7. 等待从机ACK
  8. 主机发送停止条件
  9. 等待写周期完成(约5ms)

代码实现:

uint8_t AT24C02_WriteByte(uint8_t addr, uint8_t data) { I2C_Start(); I2C_SendByte(0xA0); // 设备地址+写 if (I2C_WaitAck()) { I2C_Stop(); return 1; // 错误 } I2C_SendByte(addr); // 内存地址 if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte(data); // 数据 if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_Stop(); delay_ms(5); // 等待写周期 return 0; }

这里最后那个delay_ms(5)非常关键。AT24C02在收到停止条件后,内部开始擦写EEPROM单元,这个过程需要时间,典型值5ms,最大可能到10ms。在这段时间内,芯片不会响应任何I2C命令。如果你紧接着发下一个写命令,从机不会回ACK,你的程序就会卡在等待ACK的地方。

4.2 页写:一次写8字节的正确姿势

AT24C02的页大小是8字节。页写就是一次连续写入最多8个字节,地址在页内自动递增。但要注意,如果起始地址不是页对齐的,写到页边界后会回卷到本页开头,覆盖之前的数据。

比如从地址0x05开始写8个字节,实际会写到0x05、0x06、0x07、0x00、0x01、0x02、0x03、0x04。0x00~0x04的数据被覆盖了,这不是你想要的。

所以页写的时候,要么保证起始地址页对齐(0x00、0x08、0x10...),要么在跨页的时候拆成两次写。

uint8_t AT24C02_WritePage(uint8_t addr, uint8_t *data, uint8_t len) { if (len > 8) return 1; // 超过一页 if ((addr / 8) != ((addr + len - 1) / 8)) return 1; // 跨页 I2C_Start(); I2C_SendByte(0xA0); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte(addr); if (I2C_WaitAck()) { I2C_Stop(); return 1; } for (uint8_t i = 0; i < len; i++) { I2C_SendByte(data[i]); if (I2C_WaitAck()) { I2C_Stop(); return 1; } } I2C_Stop(); delay_ms(5); return 0; }

如果要写超过8字节的数据,需要自己封装一个跨页写函数,按页拆分:

uint8_t AT24C02_WriteBuffer(uint8_t addr, uint8_t *data, uint16_t len) { while (len > 0) { uint8_t page_remain = 8 - (addr % 8); // 当前页剩余空间 uint8_t write_len = (len < page_remain) ? len : page_remain; if (AT24C02_WritePage(addr, data, write_len)) return 1; addr += write_len; data += write_len; len -= write_len; } return 0; }

4.3 随机读:先写地址再读数据

AT24C02的读操作分两种:当前地址读和随机读。当前地址读是读内部地址指针指向的位置,地址指针在每次读写后自动加1。随机读是先发送要读的地址,然后重新起始,再发送读命令。

随机读的流程:

  1. 主机发送起始条件
  2. 主机发送设备地址+写方向(0xA0)
  3. 等待ACK
  4. 主机发送要读的内存地址
  5. 等待ACK
  6. 主机重新发送起始条件
  7. 主机发送设备地址+读方向(0xA1)
  8. 等待ACK
  9. 主机读取一个字节,发送NACK(表示只读一个)
  10. 主机发送停止条件
uint8_t AT24C02_ReadByte(uint8_t addr, uint8_t *data) { I2C_Start(); I2C_SendByte(0xA0); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte(addr); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_Start(); // 重新起始 I2C_SendByte(0xA1); // 设备地址+读 if (I2C_WaitAck()) { I2C_Stop(); return 1; } *data = I2C_ReadByte(); // 读一个字节 I2C_SendNack(); // 发送NACK I2C_Stop(); return 0; }

读多个字节的时候,前面每个字节读完后主机发送ACK,最后一个字节发送NACK,然后停止。

uint8_t AT24C02_ReadBuffer(uint8_t addr, uint8_t *buf, uint16_t len) { I2C_Start(); I2C_SendByte(0xA0); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte(addr); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_Start(); I2C_SendByte(0xA1); if (I2C_WaitAck()) { I2C_Stop(); return 1; } for (uint16_t i = 0; i < len; i++) { buf[i] = I2C_ReadByte(); if (i < len - 1) I2C_SendAck(); else I2C_SendNack(); } I2C_Stop(); return 0; }

4.4 写周期等待:轮询ACK比死等延时更靠谱

前面代码里用delay_ms(5)等待写周期,简单但效率低。更好的做法是轮询:发一个起始条件+设备地址,如果从机回ACK说明写周期结束了,如果没回ACK就继续等。

void AT24C02_WaitReady(void) { uint32_t timeout = 0; do { I2C_Start(); I2C_SendByte(0xA0); if (I2C_WaitAck() == 0) { I2C_Stop(); return; } I2C_Stop(); delay_us(100); timeout++; } while (timeout < 1000); // 最多等100ms }

这种方式比固定延时灵活,写周期短的时候能更快进行下一步,写周期长的时候也不会丢数据。实际测试AT24C02的写周期通常在3~5ms之间,用轮询方式平均能省2ms左右。

5. 常见问题排查与避坑指南

5.1 读出来全是0xFF是怎么回事

这是最常见的问题,原因通常有这几个:

从机没应答。用示波器看SDA线,如果发送地址后SDA一直是高电平,说明从机根本没响应。检查:设备地址对不对(0xA0还是0xA1)、WP脚是不是被拉高了、供电是否正常、上拉电阻有没有焊。

地址搞错了。AT24C02的7位地址是0x50,但HAL库的I2C函数需要传8位地址,也就是0xA0。如果你传了0x50,实际发送的是0x50左移一位还是0x50,从机地址就错了。软件模拟的时候直接发0xA0就行。

写周期没等。写完一个字节立刻读,从机还在忙内部擦写,不会响应,读出来就是0xFF。加延时或者轮询ACK。

页写回卷。从非页对齐地址开始写多个字节,数据被覆盖了。检查写入地址和长度。

5.2 通信不稳定、时好时坏

上拉电阻偏大。示波器看SCL/SDA上升沿,如果上升时间超过1us,换小一点的电阻。

总线电容太大。走线太长、挂了太多器件、PCB布局不好都会增加总线电容。I2C标准规定总线电容不超过400pF,超了就要想办法减小。

电源干扰。AT24C02的VCC引脚旁边加一个0.1uF的去耦电容,越近越好。

地线没接好。MCU和AT24C02必须共地,否则电平参考不一致,通信肯定出问题。

5.3 硬件I2C死锁怎么恢复

STM32F103的硬件I2C死锁后,SDA可能被从机拉低不放,SCL也动不了。恢复方法是:把I2C引脚临时配置成普通GPIO开漏输出,手动发送9个SCL脉冲,让从机把剩余的数据位发完释放SDA,然后发送停止条件,再重新初始化I2C外设。

void I2C_BusRecovery(void) { GPIO_InitTypeDef gpio; // 配置SCL和SDA为普通开漏输出 gpio.GPIO_Pin = I2C_SCL_PIN | I2C_SDA_PIN; gpio.GPIO_Mode = GPIO_Mode_Out_OD; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(I2C_GPIO, &gpio); SDA_H(); for (int i = 0; i < 9; i++) { SCL_L(); delay_us(5); SCL_H(); delay_us(5); } // 发送停止条件 SDA_L(); delay_us(5); SCL_H(); delay_us(5); SDA_H(); delay_us(5); // 重新初始化I2C I2C_Init(); }

5.4 常见问题速查表

现象可能原因排查方法解决措施
读全0xFF从机无应答示波器看ACK位检查地址、WP脚、供电
写入后读不对写周期未等待加长延时测试用轮询ACK替代固定延时
通信时好时坏上拉电阻偏大看上升沿时间换2.2k~4.7k电阻
页写数据错乱跨页回卷检查地址和长度按页拆分写入
硬件I2C卡死状态机死锁看SDA是否被拉低总线恢复+重新初始化
只能读不能写WP脚被拉高测WP脚电平WP接地
高速通信失败总线电容大测上升时间减小上拉、缩短走线

独家经验:调试I2C的时候,手边一定要有示波器或者逻辑分析仪。光靠串口打印很难定位问题,因为I2C的问题大多出在时序和电气层面。一个几十块的逻辑分析仪配合开源软件,能把起始、地址、ACK、数据全部解码出来,比猜效率高十倍。

6. 从AT24C02延伸出去的几个实用技巧

6.1 用结构体封装读写接口

实际项目里不会一个字节一个字节地读写,通常是把参数打包成结构体,整体存取。但要注意结构体的字节对齐问题,不同编译器对齐方式可能不一样,存进去读出来可能错位。

稳妥的做法是手动序列化:

typedef struct { uint16_t temperature; uint8_t brightness; uint8_t mode; uint32_t counter; } DeviceConfig; void SaveConfig(DeviceConfig *cfg) { uint8_t buf[8]; buf[0] = cfg->temperature >> 8; buf[1] = cfg->temperature & 0xFF; buf[2] = cfg->brightness; buf[3] = cfg->mode; buf[4] = (cfg->counter >> 24) & 0xFF; buf[5] = (cfg->counter >> 16) & 0xFF; buf[6] = (cfg->counter >> 8) & 0xFF; buf[7] = cfg->counter & 0xFF; AT24C02_WriteBuffer(0x00, buf, 8); }

这样不管编译器怎么对齐,存进去的字节顺序都是确定的。

6.2 数据校验:别让坏数据坑了你

EEPROM不是绝对可靠的,写入过程中断电、芯片老化都可能导致数据损坏。重要的参数建议加校验,简单点用累加和,复杂点用CRC16。

uint8_t CalcChecksum(uint8_t *data, uint8_t len) { uint8_t sum = 0; for (uint8_t i = 0; i < len; i++) sum += data[i]; return sum; }

存储的时候把校验和放在最后,读取的时候算一遍对比,不对就恢复默认值。

6.3 磨损均衡:AT24C02也有寿命

AT24C02的擦写寿命是100万次,单看数字很大,但如果你的程序每秒写一次,不到12天就写废了。对于频繁写入的场景,可以做简单的磨损均衡:把数据轮流写到不同的地址块,每次写之前记录当前块号。

比如把256字节分成16块,每块16字节,轮流写。这样寿命就变成了1600万次,够用了。

6.4 用HAL库的I2C函数实现

如果你用STM32CubeMX生成了HAL库工程,I2C的读写可以用HAL库函数:

// 写一个字节 HAL_I2C_Mem_Write(&hi2c1, 0xA0, addr, I2C_MEMADD_SIZE_8BIT, &data, 1, 100); // 读一个字节 HAL_I2C_Mem_Read(&hi2c1, 0xA0, addr, I2C_MEMADD_SIZE_8BIT, &data, 1, 100); // 写多个字节 HAL_I2C_Mem_Write(&hi2c1, 0xA0, addr, I2C_MEMADD_SIZE_8BIT, buf, len, 100); // 读多个字节 HAL_I2C_Mem_Read(&hi2c1, 0xA0, addr, I2C_MEMADD_SIZE_8BIT, buf, len, 100);

注意HAL库的HAL_I2C_Mem_Write内部已经处理了写周期等待,但它的等待方式是固定延时,不是轮询ACK。如果写周期比较长,可能需要手动加延时。

另外HAL库的I2C函数在通信失败时会返回错误码,可以根据返回值判断问题:

  • HAL_OK:成功
  • HAL_ERROR:总线错误
  • HAL_BUSY:总线忙
  • HAL_TIMEOUT:超时

调试的时候把返回值打印出来,能快速定位问题。

6.5 逻辑分析仪抓包实战

最后说下怎么用逻辑分析仪抓I2C波形。把分析仪的CH0接SCL,CH1接SDA,GND接板子GND,采样率设1MHz以上,协议选I2C。

正常的写时序应该是这样的:

Start | 0xA0 | ACK | 0x00 | ACK | 0x55 | ACK | Stop

如果看到某个ACK位是高电平,说明从机没应答,问题就出在那一步。如果Start条件都出不来,说明总线被拉死了,需要做总线恢复。

逻辑分析仪的好处是能把整个通信过程可视化,比示波器看单个信号更直观。特别是调试多字节读写的时候,能清楚看到每个字节的传输和应答情况。

最后分享一个小技巧:如果手头没有逻辑分析仪,可以用另一块STM32的I2C从机模式来监听总线,把收到的数据通过串口打印出来。虽然麻烦点,但应急够用。

这套AT24C02的读写流程我前后调过不下十次,每次换芯片、换板子、换速率都会遇到新问题。但核心的东西就那些:开漏上拉、地址正确、时序匹配、写周期等待。把这四点吃透,I2C这块基本就通了。后面换其他I2C器件,比如OLED屏、温湿度传感器、EEPROM,套路都是一样的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询