STM32 I2C 通信学习文档(软件 I2C 与硬件 I2C 读写 MPU6050)
基于江协科技 STM32 入门教程 10-1 软件I2C读写MPU6050、10-2 硬件I2C读写MPU6050 两个项目整理
一、I2C 总线基础
1.1 什么是 I2C
I2C(Inter-Integrated Circuit,内部集成电路总线)是一种两线制、同步、半双工的串行通信总线,由飞利浦公司提出,广泛用于连接 MCU 与各种片上传感器(如 MPU6050、OLED、EEPROM 等)。
1.2 物理层:只需要两根线
| 信号线 | 全称 | 作用 |
|---|---|---|
| SCL | Serial Clock | 时钟线,由主机产生时钟,同步双方 |
| SDA | Serial Data | 数据线,双向,传输数据 |
另外所有设备共地(GND)。
I2C 的物理层特点:
- 开漏输出(Open-Drain)+ 上拉电阻:所有设备的 SCL/SDA 引脚都是开漏输出,只能主动拉低、不能主动输出高电平;高电平由总线上的上拉电阻提供。
- 线与逻辑:任何一个设备拉低总线,总线就是低电平;只有所有设备都释放,总线才被上拉为高电平。这保证了多设备同时输出时不会短路冲突。
- 支持多主机、多从机:总线上可以挂多个设备,每个从机有唯一地址,主机通过地址寻址。
- 总线空闲状态:SCL 和 SDA 都为高电平。
本项目中 STM32 引脚配置为开漏输出模式,正是为了模拟 I2C 总线的线与特性。
1.3 协议层:通信时序
(1)起始条件与终止条件
SDA ─────┐
│ 起始条件 START:SCL高电平期间,SDA由高变低
SCL ─────────
SDA ┌─────
│ 终止条件 STOP:SCL高电平期间,SDA由低变高
SCL ─────────
- 起始条件(S):SCL 为高时,SDA 从高电平拉到低电平
- 终止条件(P):SCL 为高时,SDA 从低电平释放到高电平
- 起始和终止条件都由主机产生
(2)数据有效性
SCL ──┐ ┌──┐ ┌──┐ ┌──
│ │ │ │ │ │
└──┘ └──┘ └──┘
SDA ──< bit0 >< bit1 >──
SCL低时变化 SCL高时采样
- SCL 低电平期间,发送方改变 SDA 数据
- SCL 高电平期间,SDA 数据必须保持稳定,接收方在此刻读取
- 每个时钟周期传输 1 位,高位(MSB)在前
(3)应答位 ACK
每传输完 8 位数据(1 字节),接收方需要在第 9 个时钟周期回发 1 位应答:
- 应答 ACK(0):接收方拉低 SDA,表示"我收到了,请继续"
- 非应答 NACK(1):接收方释放 SDA(高电平),表示"我不收了"或读取结束
(4)从机地址与读写位
主机在起始条件后发送的第一个字节是从机地址 + 读写位:
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ A6│ A5│ A4│ A3│ A2│ A1│ A0│ R/│
└───┴───┴───┴───┴───┴───┴───┴───┘
7位从机地址 W位
- 高 7 位:从机地址
- 最低位:
0表示主机要写从机,1表示主机要读从机
MPU6050 的地址:
- 7 位地址为
1101000(0x68) - 写地址:
0x68 << 1 | 0 = 0xD0 - 读地址:
0x68 << 1 | 1 = 0xD1 - 所以代码中
MPU6050_ADDRESS = 0xD0,读的时候0xD0 | 0x01 = 0xD1
1.4 I2C 读写寄存器的完整时序
写寄存器流程
S │ 从机地址+W │ ACK │ 寄存器地址 │ ACK │ 数据 │ ACK │ P
主机依次发送:起始 → 从机写地址 → 寄存器地址 → 要写的数据 → 终止。
读寄存器流程(需要重复起始)
S │ 从机地址+W │ ACK │ 寄存器地址 │ ACK │ Sr │ 从机地址+R │ ACK │ 读数据 │ NACK │ P
先"写"告诉从机要读哪个寄存器,再发一个重复起始(Sr),切换为读方向,然后从机把数据发回来。读完后主机发 NACK + STOP 结束。
二、MPU6050 简介
2.1 芯片功能
MPU6050 是一款 6 轴运动传感器:
- 3 轴加速度计(X/Y/Z):测量线性加速度
- 3 轴陀螺仪(X/Y/Z):测量角速度
- 内置温度传感器
- 通过 I2C 接口与 MCU 通信
2.2 关键寄存器(MPU6050_Reg.h)
| 寄存器宏名 | 地址 | 作用 |
|---|---|---|
| SMPLRT_DIV | 0x19 | 采样率分频 |
| CONFIG | 0x1A | 配置寄存器(低通滤波 DLPF) |
| GYRO_CONFIG | 0x1B | 陀螺仪量程配置 |
| ACCEL_CONFIG | 0x1C | 加速度计量程配置 |
| ACCEL_XOUT_H/L | 0x3B/0x3C | 加速度X轴高/低字节 |
| ... | 0x3D~0x48 | Y/Z轴加速度、温度、X/Y/Z陀螺仪 |
| PWR_MGMT_1 | 0x6B | 电源管理1(唤醒、时钟源) |
| PWR_MGMT_2 | 0x6C | 电源管理2(各轴待机控制) |
| WHO_AM_I | 0x75 | 器件ID(固定值 0x68,用于通信测试) |
注意数据是 16 位的,分高字节(H)和低字节(L)两个寄存器,读取后需要拼接:Data = (DataH << 8) | DataL。
2.3 硬件接线(两个项目相同)
| MPU6050 | STM32 |
|---|---|
| VCC | 3.3V |
| GND | GND |
| SCL | PB10 |
| SDA | PB11 |
三、软件 I2C 实现(10-1)
软件 I2C 的思路:用普通 GPIO 手动翻转电平,按照 I2C 时序图一步一步模拟。代码分为两层:
- 引脚配置层:直接操作 GPIO(写 SCL/SDA、读 SDA)
- 协议层:基于引脚层组合出起始、终止、收发字节、收发应答等时序
3.1 引脚配置层(MyI2C.c)
初始化:开漏输出
void MyI2C_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; // 开漏输出
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11; // PB10=SCL, PB11=SDA
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);
GPIO_SetBits(GPIOB, GPIO_Pin_10 | GPIO_Pin_11); // 默认拉高,释放总线
}
为什么用开漏输出而不是推挽输出?
- I2C 是多设备总线,需要线与逻辑。开漏输出只能拉低、释放(靠外部上拉为高),不会出现一个设备输出高、另一个输出低而短路的情况。
- SDA 需要双向(既要输出也要读取),开漏输出模式下引脚也可以读回输入电平,释放后读取即可获得从机的应答。
三个基础引脚操作
void MyI2C_W_SCL(uint8_t BitValue) // 写SCL电平
{
GPIO_WriteBit(GPIOB, GPIO_Pin_10, (BitAction)BitValue);
Delay_us(10); // 每次操作延时10us,控制I2C时钟频率
}
void MyI2C_W_SDA(uint8_t BitValue) // 写SDA电平
{
GPIO_WriteBit(GPIOB, GPIO_Pin_11, (BitAction)BitValue);
Delay_us(10);
}
uint8_t MyI2C_R_SDA(void) // 读SDA电平
{
uint8_t BitValue = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11);
Delay_us(10);
return BitValue;
}
Delay_us(10) 把 I2C 速率限制在约 50kHz 以内,保证时序不超过从机支持的最大频率。
3.2 协议层:起始与终止
void MyI2C_Start(void)
{
MyI2C_W_SDA(1); // 先确保SDA为高
MyI2C_W_SCL(1); // 再确保SCL为高
MyI2C_W_SDA(0); // SCL高期间,SDA拉低 → 起始条件
MyI2C_W_SCL(0); // 拉低SCL,占用总线,方便后续时序拼接
}
void MyI2C_Stop(void)
{
MyI2C_W_SDA(0); // 先确保SDA为低
MyI2C_W_SCL(1); // SCL拉高
MyI2C_W_SDA(1); // SCL高期间,SDA释放为高 → 终止条件
}
起始的时序顺序很关键:必须先让 SCL、SDA 都为高,再在 SCL 高电平期间把 SDA 拉低。
3.3 协议层:发送一个字节
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); // SCL拉高,从机在高电平期间读SDA
MyI2C_W_SCL(0); // SCL拉低,准备下一位
}
}
逐位拆解(以 Byte=0xD0=11010000 为例):
- i=0:
0x80>>0 = 0x80(10000000),取出最高位 bit7=1 - i=1:
0x80>>1 = 0x40(01000000),取出 bit6=1 - i=7:
0x80>>7 = 0x01,取出最低位 bit0=0
!! 双取反的作用:把任意非零值统一变成 1,零保持 0,确保写入 SDA 的电平非 0 即 1。
3.4 协议层:接收一个字节
uint8_t MyI2C_ReceiveByte(void)
{
uint8_t i, Byte = 0x00; // 必须初始化为0
MyI2C_W_SDA(1); // 主机释放SDA,交给从机驱动
for (i = 0; i < 8; i++)
{
MyI2C_W_SCL(1); // SCL高电平期间采样
if (MyI2C_R_SDA()) { Byte |= (0x80 >> i); } // 读到1就置位,读到0保持初值0
MyI2C_W_SCL(0);
}
return Byte;
}
3.5 协议层:应答
void MyI2C_SendAck(uint8_t AckBit) // 主机发送应答
{
MyI2C_W_SDA(AckBit); // 0=ACK应答,1=NACK非应答
MyI2C_W_SCL(1);
MyI2C_W_SCL(0);
}
uint8_t MyI2C_ReceiveAck(void) // 主机接收从机的应答
{
uint8_t AckBit;
MyI2C_W_SDA(1); // 释放SDA,交给从机
MyI2C_W_SCL(1); // 高电平期间读取
AckBit = MyI2C_R_SDA();
MyI2C_W_SCL(0);
return AckBit; // 0=从机应答,1=从机非应答(异常)
}
3.6 软件 I2C 的分层结构总结
应用层:MPU6050_WriteReg / ReadReg(组合协议层函数)
│ 调用
协议层:Start / Stop / SendByte / ReceiveByte / SendAck / ReceiveAck
│ 调用
引脚层:W_SCL / W_SDA / R_SDA(直接操作GPIO)
这种分层设计的好处:换引脚、换芯片时只需修改引脚层,协议层和应用层完全复用。
四、基于软件 I2C 的 MPU6050 驱动
4.1 写寄存器
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data)
{
MyI2C_Start(); // 起始
MyI2C_SendByte(MPU6050_ADDRESS); // 发送从机写地址 0xD0
MyI2C_ReceiveAck(); // 收应答
MyI2C_SendByte(RegAddress); // 发送寄存器地址
MyI2C_ReceiveAck();
MyI2C_SendByte(Data); // 发送数据
MyI2C_ReceiveAck();
MyI2C_Stop(); // 终止
}
4.2 读寄存器(重点:重复起始)
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(); // 重复起始 Sr(注意不是Stop后再Start)
MyI2C_SendByte(MPU6050_ADDRESS | 0x01); // 读地址 0xD1,最低位置1
MyI2C_ReceiveAck();
Data = MyI2C_ReceiveByte(); // 接收数据
MyI2C_SendAck(1); // 主机回 NACK,告诉从机"读完了,别再发"
MyI2C_Stop();
return Data;
}
为什么需要重复起始而不是 Stop 后重新 Start?
读寄存器要先写寄存器地址、再切换方向读数据。如果中间发 Stop 释放总线,其他主机可能抢占总线,导致寻址的不是同一个从机。重复起始不释放总线,保证整个过程是原子的。
为什么读完要发 NACK?
从机被寻址为发送方后,会一直准备发送下一个字节。主机回 NACK 是告诉从机"最后一个字节已收到,停止输出",然后主机才能发 Stop。
4.3 MPU6050 初始化
void MPU6050_Init(void)
{
MyI2C_Init(); // 先初始化底层软件I2C
MPU6050_WriteReg(MPU6050_PWR_MGMT_1, 0x01); // 唤醒,时钟源选X轴陀螺仪(上电默认是睡眠模式!)
MPU6050_WriteReg(MPU6050_PWR_MGMT_2, 0x00); // 所有轴不待机
MPU6050_WriteReg(MPU6050_SMPLRT_DIV, 0x09); // 采样率分频
MPU6050_WriteReg(MPU6050_CONFIG, 0x06); // 低通滤波
MPU6050_WriteReg(MPU6050_GYRO_CONFIG, 0x18); // 陀螺仪 ±2000°/s
MPU6050_WriteReg(MPU6050_ACCEL_CONFIG, 0x18); // 加速度计 ±16g
}
重点:PWR_MGMT_1 必须配置。MPU6050 上电默认处于睡眠模式,不唤醒则读不到数据。写入 0x01 解除睡眠并选择陀螺仪时钟源(比内部内部 RC 振荡器更稳定)。
4.4 获取 ID 与 6 轴数据
uint8_t MPU6050_GetID(void)
{
return MPU6050_ReadReg(MPU6050_WHO_AM_I); // 应返回 0x68
}
void MPU6050_GetData(int16_t *AccX, int16_t *AccY, int16_t *AccZ,
int16_t *GyroX, int16_t *GyroY, int16_t *GyroZ)
{
uint8_t DataH, DataL;
DataH = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_H);
DataL = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_L);
*AccX = (DataH << 8) | DataL; // 高低字节拼接为16位有符号整数
// ... 其余5轴同理
}
为什么用 int16_t(有符号)?
加速度和角速度有正负方向,16 位数据范围 -32768~32767,必须用有符号类型。
4.5 main.c
uint8_t ID;
int16_t AX, AY, AZ, GX, GY, GZ;
int main(void)
{
OLED_Init();
MPU6050_Init();
OLED_ShowString(1, 1, "ID:");
ID = MPU6050_GetID();
OLED_ShowHexNum(1, 4, ID, 2); // 正常应显示 68
while (1)
{
MPU6050_GetData(&AX, &AY, &AZ, &GX, &GY, &GZ); // 指针作为输出参数
OLED_ShowSignedNum(2, 1, AX, 5);
// ... 显示6个数据
}
}
验证通信是否成功的方法:先读 WHO_AM_I,如果显示 68 说明 I2C 通信正常;如果显示 00 或 FF 说明通信失败(接线、时序、地址问题)。
五、硬件 I2C 实现(10-2)
硬件 I2C 的思路:使用 STM32 内置的 I2C 外设,配置好后由硬件自动产生时序,CPU 只需要操作寄存器/库函数、等待事件标志,不需要手动翻转电平。
本项目使用 I2C2(PB10=SCL,PB11=SDA)。注意硬件 I2C 项目没有单独的 MyI2C.c,I2C 底层操作直接写在 MPU6050.c 中。
5.1 GPIO 配置:复用开漏输出
RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C2, ENABLE); // I2C2在APB1总线
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD; // 复用开漏输出
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);
与软件 I2C 的区别:
- 软件 I2C:
GPIO_Mode_Out_OD(普通开漏,软件控制电平) - 硬件 I2C:
GPIO_Mode_AF_OD(复用开漏,引脚控制权交给 I2C 外设)
5.2 I2C 外设初始化结构体
I2C_InitTypeDef I2C_InitStructure;
I2C_InitStructure.I2C_Mode = I2C_Mode_I2C; // I2C模式(另一种是SMBus)
I2C_InitStructure.I2C_ClockSpeed = 50000; // 时钟频率50kHz
I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2; // 快速模式下占空比(标准模式此参数无影响)
I2C_InitStructure.I2C_Ack = I2C_Ack_Enable; // 使能自动应答
I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit; // 7位地址模式
I2C_InitStructure.I2C_OwnAddress1 = 0x00; // 自身地址(做从机时才用,主机随意)
I2C_Init(I2C2, &I2C_InitStructure);
I2C_Cmd(I2C2, ENABLE); // 使能I2C2
参数说明:
I2C_ClockSpeed:标准模式最高 100kHz,快速模式最高 400kHz,这里用 50kHzI2C_DutyCycle:仅快速模式(>100kHz)时决定 SCL 高低电平时间比,标准模式填什么都不影响I2C_Ack:使能后硬件在收到一个字节后自动回 ACKI2C_OwnAddress1:自身作为从机时的地址,本项目 STM32 是主机,填 0x00 即可
5.3 事件(Event)机制——硬件 I2C 的核心
硬件 I2C 每完成一个动作,状态寄存器 SR 就会置起对应的事件标志,程序必须等待事件发生后再进行下一步,这相当于软件 I2C 中"等待电平变化"的作用。
| 事件 | 标志 | 含义 | 对应软件I2C的步骤 |
|---|---|---|---|
| EV5 | SB | 起始条件已发出 | Start 完成 |
| EV6 | ADDR | 地址字节已发送并收到应答 | SendByte(地址)+ReceiveAck |
| EV8 | TxE | 发送数据寄存器空,可以写下一个字节 | SendByte 过程中 |
| EV8_2 | TxE+BTF | 字节全部移位发送完毕 | SendByte 完成 |
| EV7 | RxNE | 接收数据寄存器非空,有数据可读 | ReceiveByte |
事件等待函数(带超时保护)
void MPU6050_WaitEvent(I2C_TypeDef* I2Cx, uint32_t I2C_EVENT)
{
uint32_t Timeout = 10000;
while (I2C_CheckEvent(I2Cx, I2C_EVENT) != SUCCESS)
{
Timeout--;
if (Timeout == 0) break; // 超时退出,防止硬件异常时程序死等卡死
}
}
为什么要加超时? 硬件 I2C 有一个著名的坑:如果总线受干扰或从机异常,事件标志可能永远不置位,程序会死在 while 循环里。超时机制保证即使通信失败程序也能继续运行。这是软件 I2C(靠固定延时)不会遇到的问题。
5.4 硬件 I2C 写寄存器
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data)
{
I2C_GenerateSTART(I2C2, ENABLE); // 产生起始
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT); // 等EV5
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter); // 发送从机地址(写)
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED); // 等EV6
I2C_SendData(I2C2, RegAddress); // 发送寄存器地址
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTING); // 等EV8
I2C_SendData(I2C2, Data); // 发送数据
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED); // 等EV8_2(确保发完)
I2C_GenerateSTOP(I2C2, ENABLE); // 产生终止
}
注意 I2C_Send7bitAddress:函数名叫"发送7位地址",但传入的 MPU6050_ADDRESS=0xD0 是已经左移过的 8 位形式,方向由第三个参数单独指定,库函数内部会自动处理最低位。
5.5 硬件 I2C 读寄存器(难点)
uint8_t MPU6050_ReadReg(uint8_t RegAddress)
{
uint8_t Data;
// 第一段:写寄存器地址
I2C_GenerateSTART(I2C2, ENABLE);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT); // EV5
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED); // EV6
I2C_SendData(I2C2, RegAddress);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED); // EV8_2
// 重复起始,切换为读
I2C_GenerateSTART(I2C2, ENABLE);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT); // EV5
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Receiver);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED); // EV6
// 关键:读最后一个字节前,要提前关闭ACK并提前发STOP
I2C_AcknowledgeConfig(I2C2, DISABLE);
I2C_GenerateSTOP(I2C2, ENABLE);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_RECEIVED); // EV7
Data = I2C_ReceiveData(I2C2); // 读取数据
I2C_AcknowledgeConfig(I2C2, ENABLE); // 恢复ACK,不影响后续多字节读取
return Data;
}
为什么要在接收最后一个字节"之前"就关闭 ACK、发 STOP?
这是硬件 I2C 最反直觉的地方。硬件收发是流水线式的:当你看到 EV7(数据已到)时,硬件可能已经在准备接收下一个字节了。应答位是在接收过程中自动发出的,所以必须在最后一个字节接收完成之前,就提前配置好"回 NACK + 发 STOP",硬件才会在正确的时刻自动完成收尾。如果等读完再配置就来不及了。
5.6 上层代码完全一致
对比两个项目可以发现:MPU6050_Init 中的寄存器配置、MPU6050_GetID、MPU6050_GetData、main.c完全一样。区别只在于底层的 MPU6050_WriteReg/ReadReg 实现方式不同。
这正是分层设计的价值:应用层代码与底层 I2C 实现解耦,软件 I2C 换硬件 I2C 时上层一行都不用改。
六、软件 I2C 与硬件 I2C 全面对比
| 对比维度 | 软件 I2C(10-1) | 硬件 I2C(10-2) |
|---|---|---|
| 实现方式 | GPIO 手动翻转电平模拟时序 | I2C 外设硬件自动产生时序 |
| GPIO 模式 | Out_OD 普通开漏 | AF_OD 复用开漏 |
| 时钟来源 | Delay_us 软件延时控制 | I2C_ClockSpeed 配置(50kHz) |
| 同步方式 | 固定延时,无需等待 | 必须等待事件 EV5/EV6/EV7/EV8 |
| 应答处理 | 手动收发 ACK 位 | 硬件自动应答(I2C_Ack) |
| 引脚选择 | 任意 GPIO,灵活 | 固定复用引脚(I2C2=PB10/PB11) |
| CPU 占用 | 高(bit-bang 全程占用) | 低(硬件自动移位,CPU 只读写DR) |
| 通信速度 | 慢(受延时限制) | 快(最高 400kHz,可用 DMA/中断) |
| 代码复杂度 | 协议层代码多但直观 | 代码少但事件机制抽象、有坑 |
| 稳定性 | 时序可控,不易卡死 | 可能因总线锁死卡在等待事件,需超时 |
| 可移植性 | 极强,换引脚只改引脚层 | 依赖具体 MCU 的 I2C 外设 |
| 适用场景 | 引脚不够用、学习协议、低速器件 | 高速通信、多设备、正式产品 |
选择建议
- 学习阶段:先学软件 I2C,手动模拟一遍时序能彻底理解 I2C 协议。
- 实际项目:优先硬件 I2C(省 CPU、速度快),但务必加超时保护;若遇到 STM32 F1 硬件 I2C 的总线锁死 bug 且难以解决,软件 I2C 反而是更稳妥的工程选择(很多实际产品也用软件 I2C)。
七、常见问题与注意事项
7.1 读 WHO_AM_I 得到 0x00 或 0xFF?
- 0x00:SDA 一直被拉低,检查接线、SDA/SCL 是否接反、GND 是否共地
- 0xFF:从机完全没应答,SDA 全程被上拉为高。检查:VCC 是否供电、地址是否正确(0xD0)、上拉电阻是否存在
- 先用示波器/逻辑分析仪看 SCL 有没有时钟波形、SDA 有没有 ACK
7.2 为什么 SCL/SDA 必须接上拉电阻?
I2C 设备引脚是开漏输出,只能拉低不能输出高。没有上拉电阻,总线释放后无法回到高电平,通信无法进行。MPU6050 模块通常自带上拉电阻;若没有,需外接 4.7kΩ~10kΩ 上拉到 3.3V。
7.3 软件 I2C 为什么每个操作后都 Delay_us(10)?
一是控制 SCL 频率不超过从机上限,二是给电平上升留出时间(开漏+上拉的上升沿较慢)。延时太短可能导致通信不稳定。
7.4 硬件 I2C 程序卡死在 WaitEvent?
- 检查 GPIO 是否配置为
AF_OD(不是普通开漏,也不是推挽) - 检查 I2C 和 GPIO 时钟是否都开启(I2C2 在 APB1,别开成 APB2)
- 总线可能被从机锁死:可在初始化前加一段"手动给 9 个 SCL 时钟"的解锁流程
- 一定要用带超时的 WaitEvent,避免永久死循环
7.5 读数据为什么要 NACK?多读几个字节怎么处理?
主机每收一个字节,硬件会自动回 ACK 表示"继续发";收最后一个字节前必须改为 NACK 表示"结束"。如果要连续读 N 个字节:前 N-1 个字节保持 ACK,最后一个字节前再关 ACK + STOP。
7.6 数据为什么要高低字节拼接?
MPU6050 的每个轴是 16 位数据,存在两个连续的 8 位寄存器中(H 高字节、L 低字节)。必须 (DataH << 8) | DataL 拼成 16 位,且用 int16_t 有符号类型才能正确表示负值。
7.7 MPU6050 上电后读不到变化的数据?
忘记配置 PWR_MGMT_1。该寄存器默认让芯片处于睡眠模式,必须写入 0x01(或 0x00)唤醒。
7.8 从机地址为什么是 0xD0 而不是 0x68?
0x68 是 7 位地址。I2C 线上实际传输的是 8 位:7 位地址 + 1 位读写位。写操作时最低位补 0,即 0x68<<1 = 0xD0;读操作时补 1,即 0xD1。数据手册通常给 7 位地址,写代码时要左移一位。
八、代码模板
8.1 软件 I2C 引脚层与协议层(模板)
/* ===== 引脚层:换引脚只改这里 ===== */
void MyI2C_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);
GPIO_SetBits(GPIOB, GPIO_Pin_10 | GPIO_Pin_11);
}
static void W_SCL(uint8_t v){ GPIO_WriteBit(GPIOB,GPIO_Pin_10,(BitAction)v); Delay_us(10);}
static void W_SDA(uint8_t v){ GPIO_WriteBit(GPIOB,GPIO_Pin_11,(BitAction)v); Delay_us(10);}
static uint8_t R_SDA(void){ uint8_t v=GPIO_ReadInputDataBit(GPIOB,GPIO_Pin_11); Delay_us(10); return v;}
/* ===== 协议层:与引脚无关,可直接复用 ===== */
void MyI2C_Start(void){ W_SDA(1); W_SCL(1); W_SDA(0); W_SCL(0); }
void MyI2C_Stop(void) { W_SDA(0); W_SCL(1); W_SDA(1); }
void MyI2C_SendByte(uint8_t Byte){
for(uint8_t i=0;i<8;i++){ W_SDA(!!(Byte&(0x80>>i))); W_SCL(1); W_SCL(0); }
}
uint8_t MyI2C_ReceiveByte(void){
uint8_t Byte=0; W_SDA(1);
for(uint8_t i=0;i<8;i++){ W_SCL(1); if(R_SDA())Byte|=(0x80>>i); W_SCL(0);}
return Byte;
}
void MyI2C_SendAck(uint8_t a){ W_SDA(a); W_SCL(1); W_SCL(0); }
uint8_t MyI2C_ReceiveAck(void){ uint8_t a; W_SDA(1); W_SCL(1); a=R_SDA(); W_SCL(0); return a; }
8.2 通用 I2C 写/读寄存器(软件 I2C 版)
void Dev_WriteReg(uint8_t DevAddr, uint8_t Reg, uint8_t Data)
{
MyI2C_Start();
MyI2C_SendByte(DevAddr); MyI2C_ReceiveAck();
MyI2C_SendByte(Reg); MyI2C_ReceiveAck();
MyI2C_SendByte(Data); MyI2C_ReceiveAck();
MyI2C_Stop();
}
uint8_t Dev_ReadReg(uint8_t DevAddr, uint8_t Reg)
{
uint8_t Data;
MyI2C_Start();
MyI2C_SendByte(DevAddr); MyI2C_ReceiveAck();
MyI2C_SendByte(Reg); MyI2C_ReceiveAck();
MyI2C_Start();
MyI2C_SendByte(DevAddr|0x01);MyI2C_ReceiveAck();
Data = MyI2C_ReceiveByte();
MyI2C_SendAck(1);
MyI2C_Stop();
return Data;
}
8.3 硬件 I2C 初始化与读写(模板)
void HW_I2C_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C2, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);
I2C_InitTypeDef I2C_InitStructure;
I2C_InitStructure.I2C_Mode = I2C_Mode_I2C;
I2C_InitStructure.I2C_ClockSpeed = 50000;
I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2;
I2C_InitStructure.I2C_Ack = I2C_Ack_Enable;
I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit;
I2C_InitStructure.I2C_OwnAddress1 = 0x00;
I2C_Init(I2C2, &I2C_InitStructure);
I2C_Cmd(I2C2, ENABLE);
}
static void WaitEvent(uint32_t EVENT){
uint32_t t=10000;
while(I2C_CheckEvent(I2C2,EVENT)!=SUCCESS) if(--t==0) break;
}
九、学习建议
- 先理解协议再看代码:对照 I2C 时序图,理解起始/终止条件、数据有效性、应答这几个核心概念。
- 先跑软件 I2C(10-1):手动模拟时序最直观,用万用表/逻辑分析仪观察 SCL、SDA 波形。
- 读 ID 验证通信:先确保
WHO_AM_I读到 0x68,再读 6 轴数据,分两步排查问题。 - 再跑硬件 I2C(10-2):重点理解事件机制(EV5~EV8)和"读最后字节前提前 NACK+STOP"的原因。
- 对比两份代码:注意上层 MPU6050 驱动完全一致,体会分层设计的好处。
- 动手扩展:
- 把软件 I2C 换到其他引脚,验证分层设计
- 实现连续读取 12 字节(一次性读 6 轴),减少重复起始
- 给硬件 I2C 加上总线锁死的解锁(手动输出 9 个时钟)逻辑
至此,I2C 的协议原理、软件模拟实现、硬件外设实现以及 MPU6050 的完整驱动全部覆盖。I2C 是连接各种传感器最常用的总线之一,掌握后可以轻松驱动 OLED、EEPROM(AT24C02)、温湿度传感器等大量 I2C 器件。