江科大STM32入门:软件I2C驱动MPU6050(代码逐行解析)
2026/8/8 9:08:51 网站建设 项目流程

🚀 我的STM32学习专栏:[点击直达 | 持续更新中]

📌开发环境:Keil5 + STM32F103C8T6最小系统板 + ST-Link
📌参考资料:江科大STM32入门教程、《STM32F10xxx参考手册》、MPU6050数据手册
📌前置文章:江科大STM32入门:MPU6050六轴姿态传感器详解(原理+硬件+寄存器)
📌适用硬件:MPU6050模块(GY-521等通用模块)


一、前言

上一篇文章我们把MPU6050的原理、硬件、寄存器都梳理了一遍,但光知道原理没用,最终得让STM32和MPU6050"说上话"。它们之间靠I2C总线通信。

STM32F103C8T6虽然自带了硬件I2C外设,但江科大的教程里用的是软件模拟I2C——也就是用两个普通的GPIO口,按照I2C协议的时序,手动拉高拉低电平来通信。

💡我的理解:很多新手会问,明明有硬件I2C,为什么要用软件模拟?其实有两个很现实的原因:第一,硬件I2C在某些批次芯片上存在BUG,容易死锁在总线忙状态,调试起来非常痛苦;第二,软件I2C不依赖特定引脚,任意两个GPIO都能用,移植起来极其方便。对于入门学习来说,软件I2C能让你把I2C协议的每一个时序细节看得清清楚楚,对理解通信原理大有裨益。

这篇文章我会把整个代码从底层到应用层全部拆开,逐行解析,保证你看完不仅能抄会,还能理解每一行在干什么。


二、整体架构:三层模块化设计

在动手写代码之前,先搞清楚整体框架。江科大的代码采用了非常清晰的三层结构:

层级文件职责
应用层main.c调用MPU6050初始化、读取数据、显示数据
中间层MPU6050.c / MPU6050.h基于I2C通信层,实现指定寄存器的读写;对芯片进行配置;读取传感器数据
底层I2C.c / I2C.hGPIO初始化、I2C时序基本单元(起始、终止、发送/接收一个字节、发送/接收应答)

硬件接线图,SCL接PB10,SDA接PB11。


三、底层 MyI2C.c:GPIO引脚封装

软件I2C的本质就是用GPIO口模拟SCL和SDA的电平变化。所以第一步,要把操作引脚的库函数封装成更直观的函数。

3.1 写SCL和写SDA

/* 对操作端口的库函数进行封装 */ /* 参数 BitValue:要写入SCL引脚的电平值,0或1 */ void MyI2C_W_SCL(uint8_t BitValue) { GPIO_WriteBit(GPIOB, GPIO_Pin_10, (BitAction)BitValue); // 对PB10引脚写入BitValue电平,BitAction是库函数要求的枚举类型,强制类型转换避免警告 Delay_us(10); // 延时10微秒,给从机足够的反应时间,同时也让示波器能看清波形(调试时很重要) } void MyI2C_W_SDA(uint8_t BitValue) { GPIO_WriteBit(GPIOB, GPIO_Pin_11, (BitAction)BitValue); // 对PB11引脚写入BitValue电平,SDA数据线 Delay_us(10); // 同样延时10微秒,保证时序稳定 }

💡我的理解:这里为什么要单独封装?直接写GPIO_WriteBit不行吗?其实是为了解耦,说白了就是为了方便。万一以后换引脚(比如从PB10/PB11换成PB6/PB7),只需要改这两个函数里的引脚号,上层代码完全不用动。另外,Delay_us(10)这个延时是软件I2C的"灵魂"——I2C协议对时序有要求,但软件模拟不需要严格到纳秒级,10微秒对于MPU6050来说完全够用,还能让波形在示波器上清晰可辨。

3.2 读SDA

/* 读取SDA引脚的当前电平 */ /* 返回值:0表示低电平,1表示高电平 */ uint8_t MyI2C_R_SDA(void) { uint8_t BitValue; // 定义局部变量,用于存储读到的电平值 BitValue = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11); // 读取PB11引脚的输入数据寄存器,获取当前SDA线上的电平 Delay_us(10); // 延时10微秒,保证读取稳定 return BitValue; // 将读到的电平值返回给调用者 }

💡我的理解:注意这里是GPIO_ReadInputDataBit,不是GPIO_ReadOutputDataBit。因为SDA是双向的——主机有时候输出数据,有时候要接收从机发来的数据。读的时候必须读输入寄存器,才能得到总线上的真实电平。


四、底层 MyI2C.c:GPIO初始化

/* 软件I2C初始化:配置SCL和SDA对应的GPIO为开漏输出模式 */ void MyI2C_Init(void) { RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); // 开启GPIOB的时钟,STM32所有外设使用前必须开时钟,否则引脚不工作 GPIO_InitTypeDef GPIO_InitStructure; // 定义GPIO初始化结构体变量 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; // 配置为开漏输出模式(Out_OD = Open Drain)。开漏模式的关键特性:输出1时引脚呈高阻态,相当于释放总线;输出0时拉低总线。这样多个设备挂在同一总线上不会冲突(线与特性) GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11; // 同时配置PB10(SCL)和PB11(SDA)两个引脚 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; // 引脚输出速度设为50MHz,软件I2C对速度要求不高,但设高一点总没错 GPIO_Init(GPIOB, &GPIO_InitStructure); // 调用库函数,将上述配置写入GPIOB的寄存器,正式生效 GPIO_SetBits(GPIOB, GPIO_Pin_10 | GPIO_Pin_11); // 初始化完成后,把SCL和SDA都置为高电平。此时I2C总线处于空闲状态(I2C协议规定:SCL和SDA同时为高时表示总线空闲) }

💡我的理解:这里有个特别重要的点——为什么用开漏输出(Open Drain),而不是推挽输出(Push-Pull)?因为I2C总线是"线与"结构,多个从机共享SCL和SDA。如果用推挽输出,当一个设备输出高电平、另一个输出低电平时,就会形成短路。开漏输出输出高电平时实际上是"放手"(高阻态),靠外部上拉电阻把总线拉到高电平,这样任何设备都能随时把总线拉低,完美实现"线与"。MPU6050模块上已经内置了4.7kΩ上拉电阻,所以我们直接用开漏模式就行。

⚠️ 如果你发现I2C通信时好时坏,首先检查引脚配置是不是推挽模式。推挽模式在单主机单从机场景下可能碰巧能用,但严格来说是错误的,而且可能损坏芯片。


五、底层 MyI2C.c:起始条件与终止条件

I2C协议规定,通信必须以起始条件(Start)开始,以终止条件(Stop)结束。

5.1 起始条件

/* 产生I2C起始条件:SCL高电平期间,SDA从高变低 */ void MyI2C_Start(void) { MyI2C_W_SDA(1); // 先确保SDA为高电平。如果之前是低电平,这里先释放SDA MyI2C_W_SCL(1); // 再确保SCL为高电平。此时SDA和SCL都为高,总线处于空闲状态 MyI2C_W_SDA(0); // 在SCL保持高电平的情况下,将SDA拉低。这个"高电平期间的下降沿"就是I2C协议定义的起始信号 MyI2C_W_SCL(0); // 最后把SCL拉低,准备开始传输数据。起始条件结束后,SCL为低,进入数据传输阶段 }

💡我的理解:起始条件的口诀是——"SCL高,SDA跳"。也就是在SCL为高的时候,SDA产生一个下降沿。这个下降沿就像一个"哨声",告诉总线上所有从机:"主机要开始说话了,都注意听!" 最后把SCL拉低,是为了给后面的数据发送做准备(I2C规定数据在SCL低电平期间变化)。

5.2 终止条件

/* 产生I2C终止条件:SCL高电平期间,SDA从低变高 */ void MyI2C_Stop(void) { MyI2C_W_SDA(0); // 先确保SDA为低电平。终止条件要求SDA从低变高,所以先拉低 MyI2C_W_SCL(1); // 将SCL拉高。注意:此时SDA为低,SCL为高 MyI2C_W_SDA(1); // 在SCL保持高电平的情况下,将SDA释放为高电平。这个"高电平期间的上升沿"就是I2C协议定义的终止信号 }

💡我的理解:终止条件和起始条件是对称的——"SCL高,SDA跳",只不过这次是上升沿。终止信号就像一个"收工哨",告诉所有从机:"本次通信结束,总线释放了。" 终止条件结束后,SCL和SDA都为高,总线回到空闲状态。


六、底层MyI2C.c:发送一个字节

/* 主机向从机发送一个字节(8位),高位先行(MSB First) */ void MyI2C_SendByte(uint8_t Byte) { uint8_t i; // 定义循环变量i,用于遍历8个比特位 for (i = 0; i < 8; i++) // 循环8次,每次发送1个比特位,从最高位(Bit7)到最低位(Bit0) { MyI2C_W_SDA(Byte & (0x80 >> i)); // 核心语句:Byte & (0x80 >> i) 用于提取Byte的第(7-i)位。 // 0x80是10000000B,右移i位后依次变成01000000、00100000...00000001。 // 与Byte做按位与运算,如果该位是1,结果就是非0(SDA写1);如果是0,结果就是0(SDA写0) MyI2C_W_SCL(1); // SCL拉高,从机检测到上升沿后,开始读取SDA上的数据位 MyI2C_W_SCL(0); // SCL拉低,准备发送下一个数据位。I2C协议规定:数据在SCL低电平期间变化,高电平期间保持稳定供从机采样 } }

💡我的理解:这里Byte & (0x80 >> i)是位操作的经典写法。比如Byte = 0xD0(11010000B):

  • i=0: 0x80 & 0xD0 = 0x80(非0)→ SDA=1,发送Bit7(1)

  • i=1: 0x40 & 0xD0 = 0x40(非0)→ SDA=1,发送Bit6(1)

  • i=2: 0x20 & 0xD0 = 0x00 → SDA=0,发送Bit5(0)

  • ...以此类推。

⚠️边界意识:这个函数发送的是纯数据位,不包含起始/终止条件。调用前必须先调用MyI2C_Start(),调用后还需要根据协议发送应答或终止。不要单独调用这个函数就以为能完成一次完整通信。


七、底层 MyI2C.c:接收一个字节

/* 主机从从机接收一个字节(8位),高位先行 */ /* 返回值:接收到的8位数据 */ uint8_t MyI2C_ReceiveByte(void) { uint8_t i, Byte = 0x00; // i是循环变量;Byte用于存储接收到的数据,初始化为0x00,准备用按位或的方式逐位设置 MyI2C_W_SDA(1); // 主机释放SDA(输出1,开漏模式下相当于高阻态),把总线控制权交给从机,让从机可以把数据放到SDA上 for (i = 0; i < 8; i++) // 循环8次,每次接收1个比特位 { MyI2C_W_SCL(1); // SCL拉高,从机检测到上升沿后,会把当前要发送的位稳定地放在SDA上 if (MyI2C_R_SDA() == 1) // 主机读取SDA电平。如果读到高电平(1),说明从机发来的这一位是1 { Byte |= (0x80 >> i); // 将Byte对应的位(从Bit7到Bit0)置1。0x80 >> i生成对应位的掩码,|=操作只把该位置1,其他位保持不变 } MyI2C_W_SCL(0); // SCL拉低,告诉从机:"这一位我读完了,你可以准备下一位了" } return Byte; // 8位全部接收完毕,将组装好的字节返回给调用者 }

💡我的理解:接收和发送是"镜像"操作。发送时主机控制SDA,接收时主机要先释放SDAMyI2C_W_SDA(1)在 open-drain 模式下就是释放总线),让从机接管。然后SCL拉高时从机放数据,主机读数据,SCL拉低时从机准备下一位。Byte |= (0x80 >> i)是"按位或赋值",只把对应位置1,其他位不动,这样8次循环后就把8个位都拼好了。


八、底层 MyI2C.c:发送应答位与接收应答位

I2C协议中,每发送完一个字节,接收方都要回一个应答位(ACK/NACK):0表示应答(ACK,我收到了),1表示非应答(NACK,我没收到或我不想再收了)。

8.1 发送应答位

/* 主机发送一个应答位给从机 */ /* 参数 AckBit:0表示应答(ACK),1表示非应答(NACK) */ void MyI2C_SendAck(uint8_t AckBit) { MyI2C_W_SDA(AckBit); // 把应答位放到SDA上。0表示告诉从机"我收到了,继续发";1表示"我不要了,停吧" MyI2C_W_SCL(1); // SCL拉高,从机检测到上升沿后读取应答位 MyI2C_W_SCL(0); // SCL拉低,应答位发送完毕,进入下一个时序单元 }

8.2 接收应答位

/* 主机接收从机返回的应答位 */ /* 返回值:0表示从机应答(ACK),1表示从机非应答(NACK) */ uint8_t MyI2C_ReceiveAck(void) { uint8_t AckBit; // 定义变量存储应答位 MyI2C_W_SDA(1); // 主机释放SDA,让从机有机会把应答位放到SDA上。开漏模式下写1就是释放总线 MyI2C_W_SCL(1); // SCL拉高,从机检测到上升沿后,应答位已经稳定地放在SDA上了 AckBit = MyI2C_R_SDA(); // 主机读取SDA电平,0表示从机拉低了SDA(应答),1表示从机也释放了SDA(非应答) MyI2C_W_SCL(0); // SCL拉低,应答位接收完毕,进入下一个时序单元 return AckBit; // 将应答位返回给调用者,上层代码可以据此判断通信是否成功 }

💡我的理解:这两个函数虽然只操作1个bit,但非常重要。很多人学I2C时容易忽略应答位,结果通信失败了也不知道问题出在哪。每次发送完一个字节(无论是地址还是数据),都必须检查应答位。如果从机没有应答(返回1),说明从机不存在、地址错了、或者从机正忙,这时候应该终止通信,而不是继续发数据。


九、中间层 MPU6050_Reg.h:寄存器地址宏定义

在写MPU6050的读写函数之前,先把寄存器地址用宏定义整理好,避免代码里出现"魔法数字"。

#ifndef __MPU6050_REG_H // 头文件防重复包含的标准写法:如果没有定义这个宏,就执行下面的内容 #define __MPU6050_REG_H // 定义这个宏,防止下次重复包含时再次执行 /* 采样频率分频器:控制数据输出速率 */ #define MPU6050_SMPLRT_DIV 0x19 // SMPLRT_DIV寄存器地址,值越大采样率越低 /* 配置寄存器:包含外部同步设置和低通滤波器配置 */ #define MPU6050_CONFIG 0x1A // CONFIG寄存器地址,DLPF_CFG位在此配置 /* 陀螺仪配置寄存器:设置量程和自测 */ #define MPU6050_GYRO_CONFIG 0x1B // GYRO_CONFIG寄存器地址,FS_SEL[1:0]控制量程 /* 加速度计配置寄存器:设置量程和自测 */ #define MPU6050_ACCEL_CONFIG 0x1C // ACCEL_CONFIG寄存器地址,AFS_SEL[1:0]控制量程 /* 加速度计X/Y/Z轴数据输出寄存器(高字节/低字节) */ #define MPU6050_ACCEL_XOUT_H 0x3B // X轴加速度高字节 #define MPU6050_ACCEL_XOUT_L 0x3C // X轴加速度低字节 #define MPU6050_ACCEL_YOUT_H 0x3D // Y轴加速度高字节 #define MPU6050_ACCEL_YOUT_L 0x3E // Y轴加速度低字节 #define MPU6050_ACCEL_ZOUT_H 0x3F // Z轴加速度高字节 #define MPU6050_ACCEL_ZOUT_L 0x40 // Z轴加速度低字节 /* 温度传感器数据输出寄存器 */ #define MPU6050_TEMP_OUT_H 0x41 // 温度高字节 #define MPU6050_TEMP_OUT_L 0x42 // 温度低字节 /* 陀螺仪X/Y/Z轴数据输出寄存器(高字节/低字节) */ #define MPU6050_GYRO_XOUT_H 0x43 // X轴陀螺仪高字节 #define MPU6050_GYRO_XOUT_L 0x44 // X轴陀螺仪低字节 #define MPU6050_GYRO_YOUT_H 0x45 // Y轴陀螺仪高字节 #define MPU6050_GYRO_YOUT_L 0x46 // Y轴陀螺仪低字节 #define MPU6050_GYRO_ZOUT_H 0x47 // Z轴陀螺仪高字节 #define MPU6050_GYRO_ZOUT_L 0x48 // Z轴陀螺仪低字节 /* 电源管理寄存器1:包含时钟源选择和睡眠位 */ #define MPU6050_PWR_MGMT_1 0x6B // PWR_MGMT_1寄存器地址,上电默认0x40(睡眠模式!) /* 电源管理寄存器2:控制各轴的待机模式 */ #define MPU6050_PWR_MGMT_2 0x6C // PWR_MGMT_2寄存器地址 /* 器件ID寄存器:用于验证通信是否正常 */ #define MPU6050_WHO_AM_I 0x75 // WHO_AM_I寄存器地址,正确读取应返回0x68 #endif // #ifndef的结束配对

💡我的理解:宏定义是嵌入式开发的"基本功"。直接写0x6B别人不知道是什么,但写MPU6050_PWR_MGMT_1一目了然。而且万一以后要换芯片(比如换成MPU9250),只要改这个头文件里的宏定义,其他代码完全不用动。


十、中间层 MPU6050.c:写寄存器

写寄存器是I2C通信中最基础的操作——告诉MPU6050:"我要往某某地址写某某数据"。

#include "stm32f10x.h" // STM32标准库头文件,包含所有寄存器定义和库函数声明 #include "MyI2C.h" // 包含底层I2C通信层的头文件,使用MyI2C_xxx函数 #include "MPU6050_Reg.h" // 包含MPU6050寄存器地址宏定义的头文件 /* MPU6050的I2C从机地址(含写操作位) */ /* 7位地址是0x68,左移1位后变成0xD0,最低位为0表示写操作 */ #define MPU6050_ADDRESS 0xD0 /* 向MPU6050的指定寄存器写入一个字节 */ /* 参数 RegAddress:目标寄存器地址;Data:要写入的数据 */ void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data) { MyI2C_Start(); // 第1步:产生起始条件,占用总线,通知所有从机主机要开始通信了 MyI2C_SendByte(MPU6050_ADDRESS); // 第2步:发送从机地址+写标志(0xD0)。MPU6050听到地址匹配自己的0x68,就知道主机在找它 MyI2C_ReceiveAck(); // 第3步:接收从机的应答位。MPU6050收到地址后会回一个ACK(拉低SDA),表示"我在,你说" MyI2C_SendByte(RegAddress); // 第4步:发送寄存器地址。这个地址会被存入MPU6050内部的地址指针,告诉它"接下来我要操作这个寄存器" MyI2C_ReceiveAck(); // 第5步:接收应答。MPU6050收到寄存器地址后回ACK MyI2C_SendByte(Data); // 第6步:发送要写入的数据。数据会被写入第4步指定的寄存器中 MyI2C_ReceiveAck(); // 第7步:接收应答。MPU6050收到数据后回ACK,表示"写进去了" MyI2C_Stop(); // 第8步:产生终止条件,释放总线。本次写操作完成 }

💡我的理解:这个函数就是I2C单字节写时序的"代码翻译"。对照上一篇文章里的时序图,你会发现完全对应:Start → 地址+W → ACK → RA → ACK → DATA → ACK → Stop。这里有一个细节——每次SendByte之后都要ReceiveAck()。很多新手会漏掉应答位,结果通信失败还找不到原因。应答位就像快递的"签收单",不确认对方收到了就继续发,数据可能根本没写进去。


十一、中间层 MPU6050.c:读寄存器

读寄存器比写寄存器复杂一点,因为I2C协议规定:要改变通信方向(从写变成读),必须重新发送起始条件

/* 从MPU6050的指定寄存器读取一个字节 */ /* 参数 RegAddress:目标寄存器地址 */ /* 返回值:读取到的数据 */ uint8_t MPU6050_ReadReg(uint8_t RegAddress) { uint8_t Data; // 定义局部变量,用于存储读到的数据 /* ---------- 第一阶段:指定要读的寄存器地址(Dummy Write) ---------- */ MyI2C_Start(); // 产生起始条件 MyI2C_SendByte(MPU6050_ADDRESS); // 发送从机地址+写标志(0xD0),先建立写方向的通信 MyI2C_ReceiveAck(); // 接收应答 MyI2C_SendByte(RegAddress); // 发送要读取的寄存器地址。MPU6050内部的地址指针会指向这个寄存器 MyI2C_ReceiveAck(); // 接收应答 /* ---------- 第二阶段:重新启动,切换为读方向 ---------- */ MyI2C_Start(); // 重新产生起始条件(Restart)。这是I2C协议规定的方向切换方式:不需要Stop,直接再发一个Start MyI2C_SendByte(MPU6050_ADDRESS | 0x01); // 发送从机地址+读标志。0xD0 | 0x01 = 0xD1,最低位为1表示读操作 MyI2C_ReceiveAck(); // 接收应答。MPU6050准备好数据后回ACK /* ---------- 第三阶段:接收数据并结束 ---------- */ Data = MyI2C_ReceiveByte(); // 调用接收字节函数,读取MPU6050发来的1字节数据 MyI2C_SendAck(1); // 主机发送非应答(NACK,1)。告诉从机:"我只要这一个字节,你别再发了" MyI2C_Stop(); // 产生终止条件,释放总线 return Data; // 将读取到的数据返回给调用者 }

💡我的理解:读寄存器的时序是I2C里最容易搞混的。它的完整流程是:Start → 写地址 → ACK → 写寄存器地址 → ACK → Restart → 读地址 → ACK → 读数据 → NACK → Stop。注意最后主机发的是NACK(1)而不是ACK(0),因为主机只想读一个字节,发NACK告诉从机"到此为止"。如果发ACK,从机会继续发下一个寄存器的数据(地址自动递增)。

⚠️MyI2C_SendByte(MPU6050_ADDRESS | 0x01)这里千万不能写成MPU6050_ADDRESS + 1。虽然0xD0+1也等于0xD1,但用按位或| 0x01才是标准写法,语义上明确表示"把最低位置1(读标志)",其他位不变。如果地址换成别的芯片,+1可能会出错。


十二、中间层 MPU6050.c:初始化配置

/* MPU6050初始化:配置电源、采样率、滤波器、量程等 */ void MPU6050_Init(void) { MyI2C_Init(); // 先初始化底层I2C的GPIO引脚,这是所有I2C通信的前提 MPU6050_WriteReg(MPU6050_PWR_MGMT_1, 0x01); // 电源管理寄存器1:0x01表示选择X轴陀螺仪作为时钟源,并退出睡眠模式(清除了第6位的SLEEP标志)。 // 上电默认是0x40(睡眠模式),必须写这一句唤醒芯片,否则读出来的数据全是0! MPU6050_WriteReg(MPU6050_PWR_MGMT_2, 0x00); // 电源管理寄存器2:0x00表示所有轴都不进入待机模式,全部正常工作 MPU6050_WriteReg(MPU6050_SMPLRT_DIV, 0x09); // 采样频率分频器:0x09表示分频系数为9+1=10。如果陀螺仪输出频率为1kHz,则采样率为100Hz MPU6050_WriteReg(MPU6050_CONFIG, 0x06); // 配置寄存器:0x06即DLPF_CFG=110,低通滤波器带宽约为5Hz(陀螺仪)和5Hz(加速度计)。 // 输出数据更平滑,适合对噪声敏感的场景 MPU6050_WriteReg(MPU6050_GYRO_CONFIG, 0x18); // 陀螺仪配置:0x18即FS_SEL=11,量程为±2000°/s。高量程适合快速旋转场景,但分辨率会降低 MPU6050_WriteReg(MPU6050_ACCEL_CONFIG, 0x18); // 加速度计配置:0x18即AFS_SEL=11,量程为±16g。高量程适合剧烈运动场景 }

💡我的理解:初始化顺序是有讲究的。第一步必须先唤醒芯片(写PWR_MGMT_1),因为MPU6050上电默认在睡觉。如果先配置其他寄存器再唤醒,有些配置可能在睡眠模式下不生效。0x01选择陀螺仪X轴作为时钟源,比内部振荡器更稳定,这是数据手册推荐的配置。

⚠️验证方式:初始化完成后,建议立刻调用MPU6050_GetID()读取WHO_AM_I寄存器(0x75),如果返回0x68,说明I2C通信和芯片初始化都正常。这是调试时的"黄金验证点"。


十三、中间层 MPU6050.c:读取设备ID与传感器数据

13.1 获取设备ID

/* 读取MPU6050的器件ID,用于验证通信是否正常 */ /* 返回值:正常应为0x68(AD0接地时) */ uint8_t MPU6050_GetID(void) { return MPU6050_ReadReg(MPU6050_WHO_AM_I); // 直接调用读寄存器函数,读取0x75地址的数据并返回 }

13.2 获取六轴原始数据

/* 读取MPU6050的六轴原始数据(加速度X/Y/Z + 陀螺仪X/Y/Z) */ /* 参数为6个int16_t指针,用于将读取到的数据返回给调用者 */ 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; // 定义两个8位变量,分别存储高字节和低字节。MPU6050的数据是16位的,分两个字节存储 /* ---------- 读取X轴加速度 ---------- */ DataH = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_H); // 读取X轴加速度高字节(0x3B) DataL = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_L); // 读取X轴加速度低字节(0x3C) *AccX = (DataH << 8) | DataL; // 将高字节左移8位,与低字节按位或,拼接成16位有符号数。高字节在高位,低字节在低位 /* ---------- 读取Y轴加速度 ---------- */ DataH = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_H); // 读取Y轴加速度高字节(0x3D) DataL = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_L); // 读取Y轴加速度低字节(0x3E) *AccY = (DataH << 8) | DataL; // 同样方式拼接 /* ---------- 读取Z轴加速度 ---------- */ DataH = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_H); // 读取Z轴加速度高字节(0x3F) DataL = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_L); // 读取Z轴加速度低字节(0x40) *AccZ = (DataH << 8) | DataL; // 同样方式拼接 /* ---------- 读取X轴陀螺仪 ---------- */ DataH = MPU6050_ReadReg(MPU6050_GYRO_XOUT_H); // 读取X轴陀螺仪高字节(0x43) DataL = MPU6050_ReadReg(MPU6050_GYRO_XOUT_L); // 读取X轴陀螺仪低字节(0x44) *GyroX = (DataH << 8) | DataL; // 同样方式拼接 /* ---------- 读取Y轴陀螺仪 ---------- */ DataH = MPU6050_ReadReg(MPU6050_GYRO_YOUT_H); // 读取Y轴陀螺仪高字节(0x45) DataL = MPU6050_ReadReg(MPU6050_GYRO_YOUT_L); // 读取Y轴陀螺仪低字节(0x46) *GyroY = (DataH << 8) | DataL; // 同样方式拼接 /* ---------- 读取Z轴陀螺仪 ---------- */ DataH = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_H); // 读取Z轴陀螺仪高字节(0x47) DataL = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_L); // 读取Z轴陀螺仪低字节(0x48) *GyroZ = (DataH << 8) | DataL; // 同样方式拼接 }

💡我的理解:这里用的是"分次读取"的方式,每个寄存器单独读。优点是代码直观,缺点是效率低——每次读都要发地址、Restart、读数据,一来一回花了6倍的时间。优化方案是用Burst Read(连续读):从0x3B开始,发送一次地址,然后连续读14个字节(6字节加速度+2字节温度+6字节陀螺仪),MPU6050内部地址指针会自动+1。这样能把读取时间缩短到原来的1/6左右,做平衡车或飞控时强烈建议用Burst Read。

⚠️(DataH << 8) | DataL这个拼接操作的前提是DataHDataL都是uint8_t类型。如果误定义为int8_t或有符号类型,左移时可能会发生符号扩展,导致拼接结果错误。务必确保这两个变量是无符号8位整型


十四、应用层 main.c:调用流程

#include "stm32f10x.h" // STM32标准库头文件 #include "Delay.h" // 延时函数头文件 #include "OLED.h" // OLED显示屏驱动头文件 #include "MPU6050.h" // MPU6050驱动头文件 uint8_t ID; // 定义变量存储器件ID int16_t AX, AY, AZ, GX, GY, GZ; // 定义6个变量分别存储加速度X/Y/Z和陀螺仪X/Y/Z的原始数据。int16_t是16位有符号数,与MPU6050的ADC输出匹配 int main(void) { OLED_Init(); // 初始化OLED显示屏,用于显示调试信息 MPU6050_Init(); // 初始化MPU6050:配置GPIO、唤醒芯片、设置采样率和量程等 OLED_ShowString(1, 1, "ID:"); // 在第1行第1列显示字符串"ID:",用于标识 ID = MPU6050_GetID(); // 读取MPU6050的器件ID,验证通信是否正常 OLED_ShowHexNum(1, 4, ID, 2); // 在第1行第4列以16进制显示ID值,显示2位数字。正常应显示0x68 while (1) // 主循环,不断读取传感器数据并显示 { MPU6050_GetData(&AX, &AY, &AZ, &GX, &GY, &GZ); // 读取六轴原始数据,通过指针传参将数据存入6个变量中 OLED_ShowSignedNum(2, 1, AX, 5); // 在第2行第1列显示X轴加速度,带符号,占5位宽度 OLED_ShowSignedNum(3, 1, AY, 5); // 在第3行第1列显示Y轴加速度,带符号,占5位宽度 OLED_ShowSignedNum(4, 1, AZ, 5); // 在第4行第1列显示Z轴加速度,带符号,占5位宽度 OLED_ShowSignedNum(2, 8, GX, 5); // 在第2行第8列显示X轴陀螺仪,带符号,占5位宽度 OLED_ShowSignedNum(3, 8, GY, 5); // 在第3行第8列显示Y轴陀螺仪,带符号,占5位宽度 OLED_ShowSignedNum(4, 8, GZ, 5); // 在第4行第8列显示Z轴陀螺仪,带符号,占5位宽度 } }

💡我的理解main.c的逻辑非常清晰——初始化→验证ID→循环读取→显示。这里用指针传参把数据从MPU6050_GetData带出来,而不是用返回值,因为一次要返回6个数据,C语言函数只能有一个返回值,用指针是最简洁的方式。OLED_ShowSignedNum显示带符号的数很重要,因为传感器数据是有正有负的(比如反方向倾斜时加速度就是负的)。

更多工程代码见:STM32_Project: 基于江科大的STM32的教程编写的程序


十五、个人理解与心得

(以下为个人理解,仅供参考,欢迎评论区交流)

15.1 关于延时时间的选取

代码里用了Delay_us(10),也就是10微秒。这个值不是随便定的——I2C标准模式最高100kHz,周期10微秒;快速模式400kHz,周期2.5微秒。我的10微秒延时对应约50kHz的通信速率,比标准模式还慢一点。

💡我的理解:慢一点没关系,稳定第一。MPU6050对I2C速率要求很宽松,100kHz以下完全OK。如果你把延时改成Delay_us(2),通信速率提到250kHz左右,在快速模式范围内,也能正常工作。但如果是第一次调试,建议先用10微秒确保通上,再逐步缩短延时提速

15.2 关于开漏输出与上拉电阻

我一开始有个疑惑:开漏输出模式下,主机输出1时引脚是高阻态,那SDA怎么保持高电平?答案是靠外部上拉电阻把总线拉到高电平。MPU6050模块上已经焊好了4.7kΩ上拉电阻,所以直接接就行。但如果自己用裸芯片画板子,千万别忘了在SCL和SDA各加一个4.7kΩ上拉电阻到VCC,否则总线永远拉不到高电平,通信必然失败。


十六、踩坑记录

(以下为踩坑记录,都是血泪史,建议新手逐条对照检查)

❌ 坑1:MPU6050上电后读不出数据,ID能读到但数据全是0

现象WHO_AM_I能正确返回0x68,说明I2C通信没问题,但读0x3B~0x48的数据寄存器全是0。

原因PWR_MGMT_1(0x6B)上电默认是0x40,SLEEP位=1,芯片处于睡眠模式!睡眠模式下传感器不工作,当然读不到数据。

解决:初始化时务必执行MPU6050_WriteReg(MPU6050_PWR_MGMT_1, 0x01);唤醒芯片。这是最容易踩的坑,没有之一。

❌ 坑2:I2C通信完全没反应,SDA和SCL一直是低电平

现象:示波器看SCL和SDA始终为低,起始条件都发不出去。

原因:GPIO初始化时没开时钟RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE)漏掉了,或者引脚选错了(比如用了GPIOA但开的是GPIOB的时钟)。

解决:检查RCC时钟开启的GPIO组是否与引脚所在组一致。PB10/PB11对应GPIOB,必须开GPIOB时钟。

❌ 坑3:应答位一直返回1(NACK),从机不响应

现象MyI2C_ReceiveAck()始终返回1,表示从机没有应答。

原因排查清单

  1. 地址错了:MPU6050的7位地址是0x68,发送时要左移1位+读写位,即写地址0xD0。如果直接发0x68,从机不会认。

  2. 从机没供电:检查MPU6050模块的VCC和GND是否接好,电源指示灯是否亮。

  3. SDA/SCL接反了:或者引脚虚焊、杜邦线接触不良。

  4. 上拉电阻缺失:如果用的是裸芯片而不是模块,可能没有上拉电阻。

解决:先用示波器或逻辑分析仪抓波形,确认起始条件、地址字节、应答位这几个关键点的波形是否正常。

❌ 坑4:读寄存器时数据错位,读A寄存器却得到B寄存器的值

现象:读0x3B(ACCEL_XOUT_H)得到的是其他寄存器的值。

原因:读寄存器时序错误,比如漏掉了Restart,或者Restart后发送的还是写地址(0xD0)而不是读地址(0xD1),导致从机继续接收数据而不是发送数据,总线状态混乱。

解决:严格对照MPU6050_ReadReg的代码,确保时序是:Start→写地址→ACK→写寄存器地址→ACK→Restart读地址→ACK→读数据→NACK→Stop。


十七、总结表格

模块函数名功能关键注意点
I2C底层MyI2C_W_SCL/SDA写SCL/SDA引脚电平封装引脚操作,方便移植
I2C底层MyI2C_R_SDA读SDA引脚输入电平必须读输入寄存器,不是输出寄存器
I2C底层MyI2C_InitGPIO开漏输出初始化开漏模式+上拉电阻是I2C的标配;初始化后SCL/SDA置高
I2C底层MyI2C_Start起始条件SCL高期间SDA下降沿
I2C底层MyI2C_Stop终止条件SCL高期间SDA上升沿
I2C底层MyI2C_SendByte发送8位数据高位先行,SCL低电平期间放数据,高电平期间从机采样
I2C底层MyI2C_ReceiveByte接收8位数据先释放SDA,SCL高电平期间采样
I2C底层MyI2C_SendAck发送1位应答0=ACK继续,1=NACK停止
I2C底层MyI2C_ReceiveAck接收1位应答每次SendByte后都要检查,判断从机是否在线
MPU6050中层MPU6050_WriteReg向指定寄存器写1字节完整时序:Start→地址→ACK→寄存器→ACK→数据→ACK→Stop
MPU6050中层MPU6050_ReadReg从指定寄存器读1字节需要Restart切换方向,最后发NACK
MPU6050中层MPU6050_Init芯片初始化必须先写0x6B唤醒芯片,否则数据全0
MPU6050中层MPU6050_GetID读器件ID验证通信的"黄金标准",正常返回0x68
MPU6050中层MPU6050_GetData读六轴原始数据高字节左移8位+低字节,注意用uint8_t拼接
应用层main初始化→验证→循环读取显示先用GetID验证,再进循环读数据

写这篇文章的时候,我把江科大的视频又回顾了一遍,把软件I2C的每一个时序单元都在示波器上验证了一次。说实话,软件I2C是理解I2C协议最好的方式——每一行代码对应总线上的一个电平变化,这种"看得见摸得着"的感觉是硬件I2C给不了的。

这篇文章我们从GPIO封装开始,一步一步搭出了完整的MPU6050驱动:起始/终止条件、收发字节、应答机制、寄存器读写、芯片初始化、数据读取。代码虽然看起来多,但拆开来看每一块都很单纯。

如果本文对你有帮助,欢迎点赞 👍 收藏 ⭐ !有任何问题欢迎在评论区交流,一起学习进步!

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询