云计算百科
云计算领域专业知识百科平台

STM32F103 I2C 实战:AT24C02 EEPROM 读写全流程拆解

调试记录仪参数掉电就丢?校准值每次上电都要重设?I2C 只有两根线,却让无数嵌入式开发者卡在起始条件、应答等待和 BUSY 死锁上。本文用一个成熟案例——STM32F103 硬件 I2C1 + AT24C02 读写全流程,拆解从总线时序到 SPL 事件等待的每一个细节,并附上 6 条实战避坑清单。

你遇到过这种情况吗?设备断电重启,辛辛苦苦标定的校准值全部归零。或者更头疼的——I2C 总线莫名其妙卡死,while 循环死等,看门狗复位都救不回来。

I2C 只有 SDA 和 SCL 两根线,看起来简单,真正调起来却处处是坑。起始停止时序、应答机制、事件标志位、BUSY 锁死……每个环节都有讲究。

今天不聊理论,直接拆一个成熟案例:STM32F103 硬件 I2C1 + AT24C02 EEPROM 读写全流程。从硬件接法到寄存器配置,从单字节写到跨页读,最后给你一份能直接抄的避坑清单。

01 案例:为什么是 I2C + AT24C02

先看应用场景。一台工业设备,每次上电需要读取上次保存的配置参数——校准系数、运行计数器、用户设置。这些数据必须掉电不丢,但 STM32F103 内部 Flash 擦写次数有限(约 1 万次),频繁改写不现实。

AT24C02 就是为这种场景设计的:2Kbit = 256 字节,地址范围 0x00~0xFF,支持 100 万次擦写,数据可保存 100 年。关键是它只有 8 个引脚,通过两根线(SDA + SCL)就能通信。

总线结构也简单:STM32 作为主机,AT24C02 作为从机,两根线上各挂一个上拉电阻。多从机场景下,每个从机靠 7 位地址区分——AT24C02 的地址是 1010 A2 A1 A0,当 A0/A1/A2 全部接地时,7 位地址为 0x50,写地址 0xA0,读地址 0xA1。

地址位 A2/A1/A0 就是芯片的 3 个硬件引脚:一条总线上最多挂 8 颗 AT24C02(地址 0x50~0x57),靠引脚电平区分。注意 AT24C02 的存储地址只有 1 字节(0x00~0xFF),而 AT24C16 以上的大容量型号需要 2 字节地址——代码里通常用 EE_TYPE 区分,写地址时分支处理。

结构图:STM32 主机通过 SDA/SCL 总线连接 AT24C02 从机,两根线上各接 4.7k 上拉电阻,标注 7 位地址 0x50

02 硬件与时钟:两根线的正确接法

先确认引脚。F103 上 I2C1 可以映射到 PB6(SCL) / PB7(SDA)(也可复用 PB8/PB9,但 PB6/PB7 最常用)。注意,I2C 引脚要配置为复用开漏 GPIO_Mode_AF_OD,不是推挽输出。

上拉电阻选多大?典型值 4.7kΩ。我踩过一个坑:某块板子用 10k 上拉,总线负载稍重就出现偶发通信失败,换成 4.7k 后一切正常。如果你遇到 I2C 时好时坏,先检查上拉。

时钟配置要看清:I2C1 挂在 APB1 上(PCLK1 = 36MHz),不是 APB2。别开错时钟,这是新手最常见的低级错误。想想上篇 USART1 为什么是 72MHz——USART1 挂 APB2,I2C1 挂 APB1,两条总线的时钟上限不同,外设归属要查数据手册,不能靠猜。

波特率计算(标准模式 100kHz):

CCR = PCLK1 / (2 × fSCL) = 36M / (2 × 100k) = 180

CR2 的 FREQ 字段设为 36(对应 36MHz),TRISE 设为 37(= 1000ns / (1/36MHz) + 1)。用标准外设库的话,I2C_ClockSpeed = 100000 时这些参数内部自动算好,不用手动填。

如果要跑快速模式(400kHz),还需要配置 I2C_DutyCycle——标准模式高低电平固定 1:1,快速模式可配 16:9 或 2:1,且 CCR 的算法不同(快速模式要考虑占空比系数)。标准外设库在快速模式下曾有过分频 Bug 的历史,实测频率会偏快——求稳的话,EEPROM 这类应用 100kHz 完全够用,不用追求 400kHz。

时序调不通时,示波器是最终裁判:起始/停止沿、第 9 个时钟的 ACK 位、SCL 频率,一看便知。没有示波器,十几块的 USB 逻辑分析仪也够用——I2C 是低速协议,抓一次完整通信就能看清事件顺序,排查效率翻倍。

结构图:PB6/PB7 引脚复用开漏接法,I2C1 挂 APB1 总线 36MHz,标注 CCR=180 计算过程

03 初始化:三步走

初始化流程分三步:开时钟、配 GPIO、配 I2C 外设。

void I2C1_Init(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
I2C_InitTypeDef I2C_InitStructure;

// 1. 开启时钟:I2C1 在 APB1,GPIOB 在 APB2
RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);

// 2. 配置 PB6/PB7 为复用开漏
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD; // 复用开漏
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);

// 3. 配置 I2C1 外设
I2C_InitStructure.I2C_Mode = I2C_Mode_I2C;
I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2;
I2C_InitStructure.I2C_ClockSpeed = 100000; // 100kHz 标准模式
I2C_InitStructure.I2C_Ack = I2C_Ack_Enable;
I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit;
I2C_Init(I2C1, &I2C_InitStructure);

// 4. 使能 I2C1
I2C_Cmd(I2C1, ENABLE);
}

注意两个细节:I2C_Init 只能在 PE=0(外设未使能)时调用,所以先 Init 再 Cmd。另外 I2C_OwnAddress1 是给本机当从机时用的地址,主机模式下用不到,但结构体字段必须赋值(比如 0x32),库函数要求所有字段初始化完整。

流程图:初始化三步——开启 RCC 时钟 → 配置 GPIO 复用开漏 → 配置 I2C 外设参数 → 使能外设

04 写流程:一字节的完整旅程

写一个字节到 AT24C02,完整时序是:起始条件 → 发送 0xA0 等 ACK → 发送字地址等 ACK → 发送数据等 ACK → 停止条件。

先把协议的两个基础动作说清:起始条件是 SCL 为高时 SDA 由高变低,停止条件是 SCL 为高时 SDA 由低变高。**应答(ACK)**是每个字节的第 9 个时钟脉冲上,接收方把 SDA 拉低——从机不拉低就是 NACK,表示它没收到或不想收。理解这三个动作,后面所有时序都能看懂。

硬件 I2C 是个严格状态机,SPL 库用事件标志位帮你判断状态。写流程涉及 4 个关键事件:

  • EV5(SB 置位):起始条件已发送
  • EV6(ADDR 置位):地址已发出且收到应答,进入主发送模式
  • EV8(TXE 置位):数据寄存器空,可以写入下一个字节
  • EV8_2(BTF 置位):最后一个字节发送完成,可以发停止

void AT24C02_WriteByte(uint8_t addr, uint8_t data)
{
// 1. 发送起始条件,等待 EV5
I2C_GenerateSTART(I2C1, ENABLE);
while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT));

// 2. 发送设备地址(写),等待 EV6
I2C_Send7bitAddress(I2C1, 0x50, I2C_Direction_Transmitter);
while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED));

// 3. 发送字地址,等待 EV8
I2C_SendData(I2C1, addr);
while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTING));

// 4. 发送数据,等待 EV8
I2C_SendData(I2C1, data);
while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTING));

// 5. 等待 EV8_2(BTF),发送停止
while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED));
I2C_GenerateSTOP(I2C1, ENABLE);
}

写完别急着读!AT24C02 收到停止条件后进入内部写周期 5~10ms,期间从机不应答。连续写必须等它写完,标准做法是轮询 ACK——不断发起始 + 0xA0,直到从机应答为止(真实项目里建议加超时计数,从机挂死时别死等):

void EE_WaitReady(void)
{
while (1) {
I2C_GenerateSTART(I2C1, ENABLE);
while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT));
I2C_Send7bitAddress(I2C1, 0x50, I2C_Direction_Transmitter);
if (I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)) {
I2C_GenerateSTOP(I2C1, ENABLE);
break; // 收到 ACK,写周期结束
}
}
}

还有一个大坑:页写跨页回卷。AT24C02 每页 8 字节,连续写超过页边界会回卷覆盖页首。写多字节时必须按页边界切分,不能一股脑写——比如从地址 5 开始写 20 字节,要拆成 3+8+8+1 四批:先写 5~7 填满页首,再整页写 815、1623,最后剩 1 字节单独写。这个边界判断写错,数据就悄悄错位,还不报错。

时序图:写流程完整时序——起始 → 0xA0+ACK → 字地址+ACK → 数据+ACK → 停止,标注 EV5/EV6/EV8/EV8_2 事件位置

05 读流程:重复起始的魔法

读操作比写多一个关键技巧:伪写定位。先按写地址把字地址指针定位到目标位置,然后发重复起始条件,切换为读模式。

随机读时序:起始 → 0xA0 + 字地址(伪写)→ 重复起始 → 0xA1 → 读 1 字节 → 主机发 NACK → 停止。

uint8_t AT24C02_ReadByte(uint8_t addr)
{
uint8_t data;

// 1. 伪写:定位字地址
I2C_GenerateSTART(I2C1, ENABLE);
while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT));

I2C_Send7bitAddress(I2C1, 0x50, I2C_Direction_Transmitter);
while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED));

I2C_SendData(I2C1, addr);
while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTING));

// 2. 重复起始,切换为读模式
I2C_GenerateSTART(I2C1, ENABLE);
while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT));

I2C_Send7bitAddress(I2C1, 0x50, I2C_Direction_Receiver);
while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED));

// 3. 读 1 字节(EV7:RXNE 置位)
while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_RECEIVED));
data = I2C_ReceiveData(I2C1);

// 4. 主机发 NACK + 停止
I2C_AcknowledgeConfig(I2C1, DISABLE);
I2C_GenerateSTOP(I2C1, ENABLE);

// 恢复 ACK,供后续通信使用
I2C_AcknowledgeConfig(I2C1, ENABLE);

return data;
}

顺序读(连续读 n 字节)有个关键点:最后一个字节之前,主机必须发 NACK。否则从机会认为你还要继续读,继续输出数据。AT24C02 在页内会自动地址 +1,跨页则回卷。

注意 ACK 的方向是反的:写流程里是从机应答(主机发字节,从机拉低 SDA 表示收到);读流程里是主机应答(从机发字节,主机拉低表示"继续"、拉高表示"够了")。读最后一个字节前把 I2C_AcknowledgeConfig 关掉,读完再恢复使能。

读流程涉及的事件是 EV7(RXNE 置位),表示数据寄存器已收到字节。

时序图:读流程完整时序——伪写定位 → 重复起始 → 0xA1+ACK → 读数据 → 主机 NACK → 停止

06 避坑清单:6 个血泪教训

1. BUSY 卡死,最阴的坑

总线毛刺或异常断开会导致 SR2 的 BUSY 位被锁定,且只能等检测到停止位才清除。此时任何操作都无响应。解法:软件复位 SWRST,极端情况需 GPIO 模拟释放总线后再复位。

// BUSY 卡死后:先软件复位,再重新初始化
I2C1->CR1 |= I2C_CR1_SWRST; // 置位 SWRST
I2C1->CR1 &= ~I2C_CR1_SWRST; // 清除 SWRST,外设回到复位态
I2C1_Init(); // 重新走一遍初始化

关键:所有 while 等待必须加超时,否则从机不响应直接卡死。

2. 7 位地址别左移

I2C_Send7bitAddress 直接传 0x50,库内部会处理读写位。你手动左移一位,从机根本不应答。这个错我见过不下五次。

3. 页写跨页回卷

8 字节页边界,超过就回卷覆盖。写超过 8 字节的数据必须按页切分,逐页写入。

4. 写周期等待不能省

5~10ms 内部写周期,期间从机不应答。不等待就连续写,丢数据是必然的。

5. 事件等待必须加超时

while (I2C_CheckEvent(…)) 死等是元凶。加个超时计数,超时后报错恢复,别让程序卡死在 I2C 上。

6. 上拉电阻选型

4.7kΩ 是典型值,覆盖绝大多数场景。10kΩ 在总线负载重或走线长时抗干扰能力差,容易出偶发问题。

结构图:六坑分类总结——总线恢复(BUSY/SWRST)、地址(7位左移)、页写(跨页回卷)、时序(写周期)、代码(超时保护)、硬件(上拉电阻)

总结

I2C 不难,难在细节。起始停止时序、应答方向、事件标志位、BUSY 恢复——把这套流程完整跑通一遍,你对硬件 I2C 的理解会上一个台阶。

最后留个问题:如果你的板子 I2C 总线卡死了,你的第一反应是查硬件还是查软件?欢迎在评论区聊聊你的排查思路。

赞(0)
未经允许不得转载:网硕互联帮助中心 » STM32F103 I2C 实战:AT24C02 EEPROM 读写全流程拆解
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!