尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

江科大STM32入门:软件I2C驱动MPU6050(代码逐行解析)

江科大STM32入门:软件I2C驱动MPU6050(代码逐行解析) 我的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接PB10SDA接PB11。三、底层 MyI2C.cGPIO引脚封装软件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.cGPIO初始化/* 软件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; // 同时配置PB10SCL和PB11SDA两个引脚 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结果就是非0SDA写1如果是0结果就是0SDA写0 MyI2C_W_SCL(1); // SCL拉高从机检测到上升沿后开始读取SDA上的数据位 MyI2C_W_SCL(0); // SCL拉低准备发送下一个数据位。I2C协议规定数据在SCL低电平期间变化高电平期间保持稳定供从机采样 } }我的理解这里Byte (0x80 i)是位操作的经典写法。比如Byte 0xD011010000Bi0: 0x80 0xD0 0x80非0→ SDA1发送Bit71i1: 0x40 0xD0 0x40非0→ SDA1发送Bit61i2: 0x20 0xD0 0x00 → SDA0发送Bit50...以此类推。⚠️边界意识这个函数发送的是纯数据位不包含起始/终止条件。调用前必须先调用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/NACK0表示应答ACK我收到了1表示非应答NACK我没收到或我不想再收了。8.1 发送应答位/* 主机发送一个应答位给从机 */ /* 参数 AckBit0表示应答(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); // 主机发送非应答NACK1。告诉从机我只要这一个字节你别再发了 MyI2C_Stop(); // 产生终止条件释放总线 return Data; // 将读取到的数据返回给调用者 }我的理解读寄存器的时序是I2C里最容易搞混的。它的完整流程是Start → 写地址 → ACK → 写寄存器地址 → ACK → Restart → 读地址 → ACK → 读数据 → NACK → Stop。注意最后主机发的是NACK1而不是ACK0因为主机只想读一个字节发NACK告诉从机到此为止。如果发ACK从机会继续发下一个寄存器的数据地址自动递增。⚠️MyI2C_SendByte(MPU6050_ADDRESS | 0x01)这里千万不能写成MPU6050_ADDRESS 1。虽然0xD01也等于0xD1但用按位或| 0x01才是标准写法语义上明确表示把最低位置1读标志其他位不变。如果地址换成别的芯片1可能会出错。十二、中间层 MPU6050.c初始化配置/* MPU6050初始化配置电源、采样率、滤波器、量程等 */ void MPU6050_Init(void) { MyI2C_Init(); // 先初始化底层I2C的GPIO引脚这是所有I2C通信的前提 MPU6050_WriteReg(MPU6050_PWR_MGMT_1, 0x01); // 电源管理寄存器10x01表示选择X轴陀螺仪作为时钟源并退出睡眠模式清除了第6位的SLEEP标志。 // 上电默认是0x40睡眠模式必须写这一句唤醒芯片否则读出来的数据全是0 MPU6050_WriteReg(MPU6050_PWR_MGMT_2, 0x00); // 电源管理寄存器20x00表示所有轴都不进入待机模式全部正常工作 MPU6050_WriteReg(MPU6050_SMPLRT_DIV, 0x09); // 采样频率分频器0x09表示分频系数为9110。如果陀螺仪输出频率为1kHz则采样率为100Hz MPU6050_WriteReg(MPU6050_CONFIG, 0x06); // 配置寄存器0x06即DLPF_CFG110低通滤波器带宽约为5Hz陀螺仪和5Hz加速度计。 // 输出数据更平滑适合对噪声敏感的场景 MPU6050_WriteReg(MPU6050_GYRO_CONFIG, 0x18); // 陀螺仪配置0x18即FS_SEL11量程为±2000°/s。高量程适合快速旋转场景但分辨率会降低 MPU6050_WriteReg(MPU6050_ACCEL_CONFIG, 0x18); // 加速度计配置0x18即AFS_SEL11量程为±16g。高量程适合剧烈运动场景 }我的理解初始化顺序是有讲究的。第一步必须先唤醒芯片写PWR_MGMT_1因为MPU6050上电默认在睡觉。如果先配置其他寄存器再唤醒有些配置可能在睡眠模式下不生效。0x01选择陀螺仪X轴作为时钟源比内部振荡器更稳定这是数据手册推荐的配置。⚠️验证方式初始化完成后建议立刻调用MPU6050_GetID()读取WHO_AM_I寄存器0x75如果返回0x68说明I2C通信和芯片初始化都正常。这是调试时的黄金验证点。十三、中间层 MPU6050.c读取设备ID与传感器数据13.1 获取设备ID/* 读取MPU6050的器件ID用于验证通信是否正常 */ /* 返回值正常应为0x68AD0接地时 */ 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这个拼接操作的前提是DataH和DataL都是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否则总线永远拉不到高电平通信必然失败。十六、踩坑记录以下为踩坑记录都是血泪史建议新手逐条对照检查❌ 坑1MPU6050上电后读不出数据ID能读到但数据全是0现象WHO_AM_I能正确返回0x68说明I2C通信没问题但读0x3B~0x48的数据寄存器全是0。原因PWR_MGMT_10x6B上电默认是0x40SLEEP位1芯片处于睡眠模式睡眠模式下传感器不工作当然读不到数据。解决初始化时务必执行MPU6050_WriteReg(MPU6050_PWR_MGMT_1, 0x01);唤醒芯片。这是最容易踩的坑没有之一。❌ 坑2I2C通信完全没反应SDA和SCL一直是低电平现象示波器看SCL和SDA始终为低起始条件都发不出去。原因GPIO初始化时没开时钟。RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE)漏掉了或者引脚选错了比如用了GPIOA但开的是GPIOB的时钟。解决检查RCC时钟开启的GPIO组是否与引脚所在组一致。PB10/PB11对应GPIOB必须开GPIOB时钟。❌ 坑3应答位一直返回1NACK从机不响应现象MyI2C_ReceiveAck()始终返回1表示从机没有应答。原因排查清单地址错了MPU6050的7位地址是0x68发送时要左移1位读写位即写地址0xD0。如果直接发0x68从机不会认。从机没供电检查MPU6050模块的VCC和GND是否接好电源指示灯是否亮。SDA/SCL接反了或者引脚虚焊、杜邦线接触不良。上拉电阻缺失如果用的是裸芯片而不是模块可能没有上拉电阻。解决先用示波器或逻辑分析仪抓波形确认起始条件、地址字节、应答位这几个关键点的波形是否正常。❌ 坑4读寄存器时数据错位读A寄存器却得到B寄存器的值现象读0x3BACCEL_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位数据先释放SDASCL高电平期间采样I2C底层MyI2C_SendAck发送1位应答0ACK继续1NACK停止I2C底层MyI2C_ReceiveAck接收1位应答每次SendByte后都要检查判断从机是否在线MPU6050中层MPU6050_WriteReg向指定寄存器写1字节完整时序Start→地址→ACK→寄存器→ACK→数据→ACK→StopMPU6050中层MPU6050_ReadReg从指定寄存器读1字节需要Restart切换方向最后发NACKMPU6050中层MPU6050_Init芯片初始化必须先写0x6B唤醒芯片否则数据全0MPU6050中层MPU6050_GetID读器件ID验证通信的黄金标准正常返回0x68MPU6050中层MPU6050_GetData读六轴原始数据高字节左移8位低字节注意用uint8_t拼接应用层main初始化→验证→循环读取显示先用GetID验证再进循环读数据写这篇文章的时候我把江科大的视频又回顾了一遍把软件I2C的每一个时序单元都在示波器上验证了一次。说实话软件I2C是理解I2C协议最好的方式——每一行代码对应总线上的一个电平变化这种看得见摸得着的感觉是硬件I2C给不了的。这篇文章我们从GPIO封装开始一步一步搭出了完整的MPU6050驱动起始/终止条件、收发字节、应答机制、寄存器读写、芯片初始化、数据读取。代码虽然看起来多但拆开来看每一块都很单纯。如果本文对你有帮助欢迎点赞 收藏 ⭐ 有任何问题欢迎在评论区交流一起学习进步
返回列表