一、I2C 协议层概述
I2C 协议分为 物理层 和 协议层 两部分:
-
物理层:规定了电气特性(如两根线、上拉电阻、电平标准)
-
协议层 :规定了通讯的逻辑规则,包括 起始/停止信号、数据有效性、地址广播、响应机制、仲裁 等核心环节
为什么写程序必须精通协议层?
物理层只需要了解硬件连接方式,而协议层直接决定了代码的编写逻辑。所有 I2C 设备的通讯都必须严格遵守协议层规则,否则会出现通讯失败、数据错乱等问题。

二、I2C 三种基本通讯流程
I2C 是 主从结构 的通讯协议:
-
主机:负责产生时钟信号、起始/停止信号,发起通讯(STM32 通常作为主机)
-
从机:被动响应主机的通讯请求,总线上可以连接多个从机(最多 127 个 7 位地址设备)
生活类比
I2C 总线就像一个课堂:主机是老师,从机是学生。老师(主机)控制上课铃(时钟 SCL)和说话(数据线 SDA),学生(从机)只能在老师允许的时候发言。
2.1 主机写数据到从机(写流程)
数据包结构
起始信号 → 从机地址(7位) + 写方向位(0) → 从机应答(ACK) → 数据字节1 → 从机应答 → ... → 数据字节N → 从机应答 → 停止信号
流程说明
-
主机产生起始信号,通知所有从机"通讯开始"
-
主机广播从机地址 + 写方向位,总线上所有从机对比自己的地址
-
地址匹配的从机返回应答信号,表示"我在,可以接收数据"
-
主机开始发送数据字节,每发完一个字节等待从机应答
-
所有数据发送完成后,主机产生停止信号,结束通讯
生活类比
上课铃响(起始信号)→ 老师点名"小明"(地址+写)→ 小明答"到"(ACK)→ 老师讲课(发送数据)→ 小明每听懂一个知识点点头(ACK)→ 下课铃响(停止信号)
2.2 主机从从机读数据(读流程)
数据包结构
起始信号 → 从机地址(7位) + 读方向位(1) → 从机应答(ACK) → 从机发数据字节1 → 主机应答 → ... → 从机发数据字节N → 主机非应答(NACK) → 停止信号
流程说明
-
主机产生起始信号
-
主机广播从机地址 + 读方向位
-
地址匹配的从机返回应答信号
-
从机开始向主机发送数据字节,每发完一个字节等待主机应答
-
主机接收完最后一个字节后,发送 非应答信号 (NACK),通知从机"不需要再发了"
-
主机产生停止信号,结束通讯
生活类比
上课铃响(起始信号)→ 老师点名"小明,回答问题"(地址+读)→ 小明答"到"(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 由低电平变为高电平
重要规则
-
起始和停止信号 必须由主机产生
-
总线上检测到起始信号后,所有从机开始监听地址
-
检测到停止信号后,通讯结束,总线回到空闲状态(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 个的情况,地址分为两个字节传输:
-
第一个字节:
11110+ 地址最高 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;
}
五、注意事项(避坑指南)
-
地址混淆问题:一定要区分 7 位设备地址和 8 位读写地址,写代码时 7 位地址必须左移 1 位
-
总线死锁问题:如果通讯异常中断,从机可能一直拉低 SDA 导致总线死锁。解决方法:主机发送 9 个时钟脉冲,让从机释放 SDA
-
时序要求:标准模式(100kHz)下,高低电平持续时间至少 4.7us;快速模式(400kHz)下至少 1.3us
-
应答信号不可忽略:每发送一个字节必须等待应答,否则会导致通讯不同步
-
复合格式必须用重复起始:不能先停止再起始,否则从机会丢失之前的内部地址信息
-
GPIO 模式必须正确 :SDA 和 SCL 必须配置为 开漏复用输出,不能用推挽输出,否则会导致总线冲突
参考出处
-
《零死角玩转 STM32F103 - 指南者》第 24 章 I2C - 读写 EEPROM
-
STM32F10x 中文参考手册 第 24 章 I2C 接口
-
I2C 总线协议官方规范 v2.1




