STM32F103 学习笔记-24-I2C-读写EEPROM(第2节)-I2C协议层介绍

一、I2C 协议层概述

I2C 协议分为 物理层 ​ 和 协议层​ 两部分:

  • 物理层:规定了电气特性(如两根线、上拉电阻、电平标准)

  • 协议层 :规定了通讯的逻辑规则,包括 起始/停止信号、数据有效性、地址广播、响应机制、仲裁​ 等核心环节

为什么写程序必须精通协议层?

物理层只需要了解硬件连接方式,而协议层直接决定了代码的编写逻辑。所有 I2C 设备的通讯都必须严格遵守协议层规则,否则会出现通讯失败、数据错乱等问题。

二、I2C 三种基本通讯流程

I2C 是 主从结构​ 的通讯协议:

  • 主机:负责产生时钟信号、起始/停止信号,发起通讯(STM32 通常作为主机)

  • 从机:被动响应主机的通讯请求,总线上可以连接多个从机(最多 127 个 7 位地址设备)

生活类比

I2C 总线就像一个课堂:主机是老师,从机是学生。老师(主机)控制上课铃(时钟 SCL)和说话(数据线 SDA),学生(从机)只能在老师允许的时候发言。

2.1 主机写数据到从机(写流程)

数据包结构
复制代码
起始信号 → 从机地址(7位) + 写方向位(0) → 从机应答(ACK) → 数据字节1 → 从机应答 → ... → 数据字节N → 从机应答 → 停止信号
流程说明
  1. 主机产生起始信号,通知所有从机"通讯开始"

  2. 主机广播从机地址 + 写方向位,总线上所有从机对比自己的地址

  3. 地址匹配的从机返回应答信号,表示"我在,可以接收数据"

  4. 主机开始发送数据字节,每发完一个字节等待从机应答

  5. 所有数据发送完成后,主机产生停止信号,结束通讯

生活类比

上课铃响(起始信号)→ 老师点名"小明"(地址+写)→ 小明答"到"(ACK)→ 老师讲课(发送数据)→ 小明每听懂一个知识点点头(ACK)→ 下课铃响(停止信号)

2.2 主机从从机读数据(读流程)

数据包结构
复制代码
起始信号 → 从机地址(7位) + 读方向位(1) → 从机应答(ACK) → 从机发数据字节1 → 主机应答 → ... → 从机发数据字节N → 主机非应答(NACK) → 停止信号
流程说明
  1. 主机产生起始信号

  2. 主机广播从机地址 + 读方向位

  3. 地址匹配的从机返回应答信号

  4. 从机开始向主机发送数据字节,每发完一个字节等待主机应答

  5. 主机接收完最后一个字节后,发送 非应答信号 (NACK),通知从机"不需要再发了"

  6. 主机产生停止信号,结束通讯

生活类比

上课铃响(起始信号)→ 老师点名"小明,回答问题"(地址+读)→ 小明答"到"(ACK)→ 小明回答问题(发送数据)→ 老师每听完一句点头(ACK)→ 老师说"够了"(NACK)→ 下课铃响(停止信号)

2.3 复合格式(先写后读,最常用)

为什么需要复合格式?

从机内部通常有多个寄存器或存储单元(如 EEPROM 的 256 个字节、传感器的温度寄存器)。主机需要先告诉从机"我要操作哪个内部地址",然后才能进行读写操作。

数据包结构
复制代码
起始信号1 → 从机地址+写方向 → 从机应答 → 内部地址(寄存器/存储器地址) → 从机应答 → 重复起始信号2 → 从机地址+读方向 → 从机应答 → 从机发数据 → 主机应答 → ... → 主机NACK → 停止信号
关键区别
  • 包含 两个起始信号,没有中间的停止信号

  • 第一个写过程用于指定从机的内部地址

  • 第二个读过程用于读取该内部地址的数据

重要区分:设备地址 ≠ 内部地址

  • 设备地址:从机在 I2C 总线上的唯一标识(相当于学生的名字)

  • 内部地址:从机内部某个寄存器或存储单元的地址(相当于学生的数学成绩、语文成绩等属性)

生活类比

上课铃响(起始1)→ 老师点名"小明"(地址+写)→ 小明答"到"(ACK)→ 老师说"报一下你的数学成绩"(内部地址)→ 小明点头(ACK)→ 老师再问"好了吗"(重复起始2+地址+读)→ 小明答"到"(ACK)→ 小明报成绩"90分"(发送数据)→ 老师说"知道了"(NACK)→ 下课铃响(停止信号)

三、I2C 关键信号时序详解

所有 I2C 信号都是通过 SDA(串行数据线) ​ 和 **SCL(串行时钟线)**​ 的不同电平组合来表示的。

3.1 起始信号(S)与停止信号(P)

时序定义
  • 起始信号SCL 为高电平时,SDA 由高电平变为低电平

  • 停止信号SCL 为高电平时,SDA 由低电平变为高电平

重要规则
  1. 起始和停止信号 必须由主机产生

  2. 总线上检测到起始信号后,所有从机开始监听地址

  3. 检测到停止信号后,通讯结束,总线回到空闲状态(SDA 和 SCL 都为高电平)

代码示例(GPIO 模拟)

复制代码
// bsp_i2c.h
#define SCL_HIGH()  GPIO_SetBits(GPIOB, GPIO_Pin_6)
#define SCL_LOW()   GPIO_ResetBits(GPIOB, GPIO_Pin_6)
#define SDA_HIGH()  GPIO_SetBits(GPIOB, GPIO_Pin_7)
#define SDA_LOW()   GPIO_ResetBits(GPIOB, GPIO_Pin_7)
#define SDA_READ()  GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_7)

// bsp_i2c.c
// 产生起始信号
void I2C_Start(void) {
    SDA_HIGH();  // 先确保SDA和SCL都为高(总线空闲)
    SCL_HIGH();
    Delay_us(1); // 满足时序要求,标准模式至少4.7us
    SDA_LOW();   // SCL高时SDA变低,产生起始
    Delay_us(1);
    SCL_LOW();   // 拉低SCL,准备传输数据
}

// 产生停止信号
void I2C_Stop(void) {
    SDA_LOW();   // 先确保SDA为低
    SCL_HIGH();
    Delay_us(1);
    SDA_HIGH();  // SCL高时SDA变高,产生停止
    Delay_us(1);
}

3.2 数据有效性规则

核心规则
  • SCL 为高电平时,SDA 必须保持稳定,此时数据有效(高电平 = 逻辑 1,低电平 = 逻辑 0)

  • SCL 为低电平时,SDA 可以切换电平,准备下一个数据位

为什么这样设计?

SCL 是同步时钟,只有在 SCL 高电平时,接收方才会采样 SDA 的电平。如果 SCL 高电平时 SDA 发生变化,会导致采样错误。

传输规则
  • 每个时钟周期传输 1 位数据

  • 8 个时钟周期传输 1 个字节,高位在前(MSB)

3.3 从机地址与数据方向

3.3.1 7 位地址模式(最常用)

I2C 协议规定设备地址可以是 7 位或 10 位,实际 99% 的外设使用 7 位地址。

  • 7 位地址 + 1 位读写方向位 = 1 个完整的 8 位字节

    • 读写方向位:0表示写方向(主机 → 从机),1表示读方向(从机 → 主机)
新手最容易混淆的地址问题
概念 定义 示例(AT24C02)
7 位设备地址 芯片手册中给出的地址,不包含读写方向位 0x50
8 位写地址 7 位地址左移 1 位 + 0 0x50 << 1 |0 = 0xA0
8 位读地址 7 位地址左移 1 位 + 1 0x50 << 1 |1 = 0xA1

避坑提示

90% 的 I2C 通讯失败都是因为地址错误!很多芯片手册写的是 7 位地址,写代码时一定要左移 1 位再加上读写方向位。

3.3.2 10 位地址模式(极少用)

用于总线上设备超过 127 个的情况,地址分为两个字节传输:

  1. 第一个字节:11110+ 地址最高 2 位 + 读写方向位

  2. 第二个字节:地址低 8 位

3.4 响应(ACK/NACK)机制

核心规则

每传输完 1 个字节(8 位)后,必须有 1 位响应信号,占用第 9 个时钟周期。

响应信号的产生者

响应信号由 数据接收方​ 产生(谁接收数据谁响应):

  • 写流程:主机发送数据,从机响应

  • 读流程:从机发送数据,主机响应

信号定义
  • ACK(应答):接收方将 SDA 拉低,表示"我收到了,可以继续发送"

  • NACK(非应答):接收方将 SDA 保持高电平,表示"我不需要了,请停止传输"

总线控制权切换

发送方在第 9 个时钟周期会 释放 SDA 的控制权(将 SDA 设为高阻态),由接收方控制 SDA 的电平来表示应答或非应答。

代码示例
复制代码
// 等待从机应答,返回1=应答成功,0=应答失败
uint8_t I2C_WaitAck(void) {
    uint8_t ack;
    SDA_HIGH();       // 主机释放SDA,设为输入模式
    Delay_us(1);
    SCL_HIGH();       // 第9个时钟脉冲
    Delay_us(1);
    if(SDA_READ()) {  // 读取SDA电平
        ack = 0;      // 高电平=非应答
    } else {
        ack = 1;      // 低电平=应答
    }
    SCL_LOW();        // 拉低SCL,结束应答周期
    return ack;
}

// 主机产生应答信号
void I2C_Ack(void) {
    SDA_LOW();        // 拉低SDA表示应答
    Delay_us(1);
    SCL_HIGH();
    Delay_us(1);
    SCL_LOW();
    SDA_HIGH();       // 释放SDA
}

// 主机产生非应答信号
void I2C_NAck(void) {
    SDA_HIGH();       // 保持高电平表示非应答
    Delay_us(1);
    SCL_HIGH();
    Delay_us(1);
    SCL_LOW();
}

四、I2C 单字节读写完整代码框架(GPIO 模拟)

复制代码
// 主机向从机发送一个字节
void I2C_SendByte(uint8_t byte) {
    uint8_t i;
    for(i=0; i<8; i++) {  // 发送8位,高位在前
        if(byte & 0x80) {
            SDA_HIGH();
        } else {
            SDA_LOW();
        }
        Delay_us(1);
        SCL_HIGH();      // 拉高SCL,让从机采样
        Delay_us(1);
        SCL_LOW();       // 拉低SCL,准备下一位
        byte <<= 1;      // 左移一位,准备发送下一位
    }
    I2C_WaitAck();       // 等待从机应答
}

// 主机从从机接收一个字节
uint8_t I2C_ReceiveByte(void) {
    uint8_t i, byte=0;
    SDA_HIGH();          // 主机释放SDA,设为输入模式
    for(i=0; i<8; i++) {
        SCL_HIGH();      // 拉高SCL,采样数据
        Delay_us(1);
        byte <<= 1;
        if(SDA_READ()) {
            byte |= 0x01;
        }
        SCL_LOW();       // 拉低SCL
        Delay_us(1);
    }
    return byte;
}

// 主机向从机指定内部地址写一个字节
uint8_t I2C_WriteByte(uint8_t dev_addr_7bit, uint8_t reg_addr, uint8_t data) {
    I2C_Start();
    I2C_SendByte((dev_addr_7bit << 1) | 0);  // 发送7位地址+写方向
    if(!I2C_WaitAck()) { I2C_Stop(); return 0; }
    
    I2C_SendByte(reg_addr);                  // 发送内部寄存器地址
    if(!I2C_WaitAck()) { I2C_Stop(); return 0; }
    
    I2C_SendByte(data);                      // 发送数据
    if(!I2C_WaitAck()) { I2C_Stop(); return 0; }
    
    I2C_Stop();
    return 1;
}

// 主机从从机指定内部地址读一个字节
uint8_t I2C_ReadByte(uint8_t dev_addr_7bit, uint8_t reg_addr) {
    uint8_t data;
    I2C_Start();
    I2C_SendByte((dev_addr_7bit << 1) | 0);  // 第一步:写内部地址(复合格式)
    if(!I2C_WaitAck()) { I2C_Stop(); return 0xFF; }
    
    I2C_SendByte(reg_addr);                  // 发送要读取的内部地址
    if(!I2C_WaitAck()) { I2C_Stop(); return 0xFF; }
    
    I2C_Start();                              // 重复起始信号
    I2C_SendByte((dev_addr_7bit << 1) | 1);  // 第二步:发送7位地址+读方向
    if(!I2C_WaitAck()) { I2C_Stop(); return 0xFF; }
    
    data = I2C_ReceiveByte();                 // 接收数据
    I2C_NAck();                               // 主机发送非应答
    I2C_Stop();
    
    return data;
}

五、注意事项(避坑指南)

  1. 地址混淆问题:一定要区分 7 位设备地址和 8 位读写地址,写代码时 7 位地址必须左移 1 位

  2. 总线死锁问题:如果通讯异常中断,从机可能一直拉低 SDA 导致总线死锁。解决方法:主机发送 9 个时钟脉冲,让从机释放 SDA

  3. 时序要求:标准模式(100kHz)下,高低电平持续时间至少 4.7us;快速模式(400kHz)下至少 1.3us

  4. 应答信号不可忽略:每发送一个字节必须等待应答,否则会导致通讯不同步

  5. 复合格式必须用重复起始:不能先停止再起始,否则从机会丢失之前的内部地址信息

  6. GPIO 模式必须正确 :SDA 和 SCL 必须配置为 开漏复用输出,不能用推挽输出,否则会导致总线冲突

参考出处

  1. 《零死角玩转 STM32F103 - 指南者》第 24 章 I2C - 读写 EEPROM

  2. STM32F10x 中文参考手册 第 24 章 I2C 接口

  3. I2C 总线协议官方规范 v2.1

相关推荐
项目題供诗1 小时前
STM32-DMA数据转运+AD多通道(二十一)
stm32·单片机·嵌入式硬件
z200509301 小时前
【C++学习】C++ 类型转换深度解析:从 C 风格缺陷到 C++ 四种安全转换的思想内核
c语言·c++·学习
三品吉他手会点灯2 小时前
STM32F103 学习笔记-24-I2C-读写EEPROM(第3节)-STM32的I2C框图详解
笔记·stm32·学习
sheeta19982 小时前
LeetCode 每日一题笔记 日期:2026.06.14 题目:2130. 链表最大孪生和
笔记·leetcode·链表
踏着七彩祥云的小丑2 小时前
嵌入式测试学习第 36 天:串口日志分析、通过日志定位简单问题
单片机·嵌入式硬件·学习
Flittly2 小时前
【AgentScope Java新手村系列】(7)子Agent编排
java·spring boot·笔记·spring·ai
MartinYeung52 小时前
[论文学习]LLM 情境学习资料的快速精确遗忘技术:基于 In-Context Learning 与量化 K-Means 的 ERASE 方法
学习·算法·kmeans
踏着七彩祥云的小丑2 小时前
Go学习第8天:接口 + 泛型 + 错误处理
开发语言·学习·golang·go
fanged2 小时前
高通学习12--调试工具(TODO)
学习