在进一步学习STM32后,I2C通信可以说是一个转折点。不同的时序图到底表示什么,软件和硬件读写到底有什么区别,如何才能高效理解并运用I2C成为了一个难题。说实在的,第一次接触I2C时,跟着一步步看时序图其实并不是很困难。但最让人头疼的环节,我觉得是把时序图和代码结合起来,保证在之后的运用中不会出错,这也是初学者必需迈过的一道坎。于是我也对此进行了一个梳理。
在此之前,先弄懂单工,半双工和全双工的区别。
类型 | 双向通信? | 同时发送/接收? | 举例 |
---|---|---|---|
单工(Simplex) | 只能一个方向 | 无 | 电视广播(只能接收) |
半双工(Half Duplex) | 双向但不能同时 | 一次一个方向 | 对讲机、一些串口通信 |
全双工(Full Duplex) | 双向并且同时 | 同时双向收发 | 电话通话、UART串口通信等 |
1.软件 I2C 的底层本质是什么?
就是你用普通 GPIO 来控制两个信号线的高低电平(SDA 数据线、SCL 时钟线),手动模拟 I2C 协议的电平波形。I2C 是一个基于开漏输出 + 上拉电阻的协议,总线空闲时是高电平,所有设备只能拉低,不能主动输出高电平。
名称 | 引脚 | 说明 |
---|---|---|
SDA | 数据线(Data) | 双向传输数据 |
SCL | 时钟线(Clock) | 主设备输出的时钟信号 |
特点:
主从结构:主机(比如 STM32)发起通信,从机响应。
每个设备有唯一地址(7位或10位地址)。
支持多个从机,靠地址识别。
通信以“起始位 - 地址 - 数据 - 停止位”流程进行。
2.理解 GPIO 模拟 I2C 的五个基本动作(你必须掌握)
我们要实现下面 5 个函数,它们组合起来构成整个 I2C 协议的通信过程:
函数 | 含义 | 在协议中代表什么 |
---|---|---|
MyI2C_Start() | 起始条件 | SDA下降,SCL高电平 |
MyI2C_Stop() | 停止条件 | SDA上升,SCL高电平 |
MyI2C_SendByte() | 发送1字节(主机→从机) | 发送地址、寄存器、数据 |
MyI2C_ReceiveByte() | 接收1字节(从机→主机) | 读取寄存器数据 |
MyI2C_ReceiveAck() / MyI2C_SendAck() | 接收/发送ACK | 第9位响应信号 |
void MyI2C_W_SCL(uint8_t BitValue)
{
GPIO_WriteBit(GPIOB, GPIO_Pin_10, (BitAction)BitValue);
Delay_us(10);
}
void MyI2C_W_SDA(uint8_t BitValue)
{
GPIO_WriteBit(GPIOB, GPIO_Pin_11, (BitAction)BitValue);
Delay_us(10);
}
uint8_t MyI2C_R_SDA(void)
{
uint8_t BitValue;
BitValue = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11);
Delay_us(10);
return BitValue;
}
函数名 | 作用 | 引脚 | 备注 |
---|---|---|---|
MyI2C_W_SCL | 输出 SCL 信号高/低 | GPIOB Pin 10 | 控制时钟线电平,延时 10us |
MyI2C_W_SDA | 输出 SDA 信号高/低 | GPIOB Pin 11 | 控制数据信号电平,延时 10us |
MyI2C_R_SDA | 读取 SDA 信号电平 | GPIOB Pin 11 | 读取数据线当前电平,延时 10us |
3.发送起始信号和停止信号
void MyI2C_Start(void)
{
MyI2C_W_SDA(1);
MyI2C_W_SCL(1);
MyI2C_W_SDA(0);
MyI2C_W_SCL(0);
}
void MyI2C_Stop(void)
{
MyI2C_W_SDA(0);
MyI2C_W_SCL(1);
MyI2C_W_SDA(1);
}
首先起始状态,先把SCL和SDA保持高电平顺序无所谓,接着把SDA置低电平,然后SCL再置低。
接着是停止状态,先把SDA置低电平,再把SCL拉高,再把SDA拉高。这里有个问题,为什么SCL一开始不用拉低。
停止必须满足两个条件:
SCL == 1(时钟线为高)
SDA: 0 → 1(数据线在高时钟上升)
也就是说:
SCL 要先高,再 SDA 上升
而之后SCL为什么还要拉高,这里我的理解,可以把它看成一个高电平的状态,不管之前是否已经拉高。在这必须保持SCL是高电平才能进行后续的SDA拉高。
4.发送字节,接收字节
void MyI2C_SendByte(uint8_t Byte)
{
uint8_t i;
for (i = 0; i < 8; i ++)
{
MyI2C_W_SDA(Byte & (0x80 >> i));
MyI2C_W_SCL(1);
MyI2C_W_SCL(0);
}
}
uint8_t MyI2C_ReceiveByte(void)
{
uint8_t i, Byte = 0x00;
MyI2C_W_SDA(1);
for (i = 0; i < 8; i ++)
{
MyI2C_W_SCL(1);
if (MyI2C_R_SDA() == 1){Byte |= (0x80 >> i);}
MyI2C_W_SCL(0);
}
return Byte;
}
第一步:先写 SDA,送第 i 位(从高位到低位)
第二步:拉高 SCL(数据在 SCL 高电平时被采样)
第三步:拉低 SCL(准备下一位)
第一步,释放 SDA,让从机驱动数据
第二步,提供时钟,准备采样
第三步,如果为高,则置对应位
第四步,拉低 SCL,准备下一位
//假设从机 SDA 输出 = 10100101
i=0: Byte |= 0x80 >> 0 = 0x80 → Byte = 0x00 | 0x80 = 0x80
i=1: Byte |= 0x80 >> 1 = 0x40 → 不执行(SDA为0)
i=2: Byte |= 0x80 >> 2 = 0x20 → Byte = 0x80 | 0x20 = 0xA0
i=3: Byte |= 0x80 >> 3 = 0x10 → 不执行(SDA为0)
i=4: Byte |= 0x80 >> 4 = 0x08 → 不执行(SDA为0)
i=5: Byte |= 0x80 >> 5 = 0x04 → Byte = 0xA0 | 0x04 = 0xA4
i=6: Byte |= 0x80 >> 6 = 0x02 → 不执行(SDA为0)
i=7: Byte |= 0x80 >> 7 = 0x01 → Byte = 0xA4 | 0x01 = 0xA5
5.发送和接收应答
void MyI2C_SendAck(uint8_t AckBit)
{
MyI2C_W_SDA(AckBit); // 设置 SDA:0=ACK,1=NACK
MyI2C_W_SCL(1); // 拉高 SCL → 从机在此时读取确认位
MyI2C_W_SCL(0); // 拉低 SCL → 结束这 1 位传输
}
uint8_t MyI2C_ReceiveAck(void)
{
uint8_t AckBit;
MyI2C_W_SDA(1); // 释放 SDA,让从机驱动确认位
MyI2C_W_SCL(1); // 提供时钟,上升沿准备读取
AckBit = MyI2C_R_SDA(); // 读取 SDA 电平:0=ACK,1=NACK
MyI2C_W_SCL(0); // 拉低 SCL,结束 ACK 位
return AckBit;
}
名称 | SDA 说明 | 意义 |
---|---|---|
ACK | SDA = 0 | 确认,表示“我收到了” |
NACK | SDA = 1 | 非确认,表示“我不要了”或“出错了” |
场景 | ACK发出方 | 操作细节 | 对应代码函数 |
---|---|---|---|
主机写数据,等待ACK | 从机 | 主机释放SDA,SCL高时检测SDA电平 | MyI2C_ReceiveAck() |
主机读数据,发送ACK | 主机 | 主机控制SDA拉低或释放,SCL高时有效 | MyI2C_SendAck() |
6.完整写入流程
1.发送起始信号(Start)
2.发送设备地址 + 写(0xD0)
3.发送寄存器地址(如 0x3B)
4.读取 1 字节数据
5.发送停止信号(Stop)
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data)
{
MyI2C_Start();
MyI2C_SendByte(MPU6050_ADDRESS);
MyI2C_ReceiveAck();
MyI2C_SendByte(RegAddress);
MyI2C_ReceiveAck();
MyI2C_SendByte(Data);
MyI2C_ReceiveAck();
MyI2C_Stop();
}
7.完整读取流程
1.发送起始信号(Start)
2.发送设备地址 + 写(0xD0)
3.发送寄存器地址(如 0x3B)
4.再次发送起始信号(Repeated Start)
5.发送设备地址 + 读(0xD1)
6.读取 1 字节数据
7.主机发送 NACK
8.发送停止信号(Stop)
uint8_t MPU6050_ReadReg(uint8_t RegAddress)
{
uint8_t Data;
MyI2C_Start();
MyI2C_SendByte(MPU6050_ADDRESS);
MyI2C_ReceiveAck();
MyI2C_SendByte(RegAddress);
MyI2C_ReceiveAck();
MyI2C_Start();
MyI2C_SendByte(MPU6050_ADDRESS | 0x01);
MyI2C_ReceiveAck();
Data = MyI2C_ReceiveByte();
MyI2C_SendAck(1);
MyI2C_Stop();
return Data;
}