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 的物理层特点:

  1. 开漏输出(Open-Drain)+ 上拉电阻:所有设备的 SCL/SDA 引脚都是开漏输出,只能主动拉低、不能主动输出高电平;高电平由总线上的上拉电阻提供。
  2. 线与逻辑:任何一个设备拉低总线,总线就是低电平;只有所有设备都释放,总线才被上拉为高电平。这保证了多设备同时输出时不会短路冲突。
  3. 支持多主机、多从机:总线上可以挂多个设备,每个从机有唯一地址,主机通过地址寻址。
  4. 总线空闲状态: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 通信正常;如果显示 00FF 说明通信失败(接线、时序、地址问题)。


五、硬件 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,这里用 50kHz
  • I2C_DutyCycle:仅快速模式(>100kHz)时决定 SCL 高低电平时间比,标准模式填什么都不影响
  • I2C_Ack:使能后硬件在收到一个字节后自动回 ACK
  • I2C_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_GetIDMPU6050_GetDatamain.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?

  1. 检查 GPIO 是否配置为 AF_OD(不是普通开漏,也不是推挽)
  2. 检查 I2C 和 GPIO 时钟是否都开启(I2C2 在 APB1,别开成 APB2)
  3. 总线可能被从机锁死:可在初始化前加一段"手动给 9 个 SCL 时钟"的解锁流程
  4. 一定要用带超时的 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;
}

九、学习建议

  1. 先理解协议再看代码:对照 I2C 时序图,理解起始/终止条件、数据有效性、应答这几个核心概念。
  2. 先跑软件 I2C(10-1):手动模拟时序最直观,用万用表/逻辑分析仪观察 SCL、SDA 波形。
  3. 读 ID 验证通信:先确保 WHO_AM_I 读到 0x68,再读 6 轴数据,分两步排查问题。
  4. 再跑硬件 I2C(10-2):重点理解事件机制(EV5~EV8)和"读最后字节前提前 NACK+STOP"的原因。
  5. 对比两份代码:注意上层 MPU6050 驱动完全一致,体会分层设计的好处。
  6. 动手扩展
  • 把软件 I2C 换到其他引脚,验证分层设计
  • 实现连续读取 12 字节(一次性读 6 轴),减少重复起始
  • 给硬件 I2C 加上总线锁死的解锁(手动输出 9 个时钟)逻辑

至此,I2C 的协议原理、软件模拟实现、硬件外设实现以及 MPU6050 的完整驱动全部覆盖。I2C 是连接各种传感器最常用的总线之一,掌握后可以轻松驱动 OLED、EEPROM(AT24C02)、温湿度传感器等大量 I2C 器件。