STM32软件IIC驱动MPU6050:从时序模拟到数据处理的完整实现

STM32软件IIC驱动MPU6050:从时序模拟到数据处理的完整实现
1. 项目缘起为什么要在STM32上折腾软件IIC和MPU6050最近在做一个基于STM32的小型平衡车项目核心的传感器就是MPU6050。硬件IIC引脚被其他模块占用了只能把MPU6050接到一组普通的GPIO上。一开始想着找个现成的软件IIC库移植一下再找个MPU6050的驱动拼起来就能用。结果发现网上能找到的代码要么是硬件IIC的HAL库版本要么是软件IIC但逻辑复杂、耦合度高很难直接剥离出来用在我的工程里。要么就是代码只实现了最基础的读写对于MPU6050这种需要初始化配置、读取多字节数据的传感器用起来各种不稳定数据时不时就“飞”了。这让我下定决心自己动手从底层开始实现一个稳定、可靠、易于移植的软件IIC驱动并完成MPU6050模块的代码移植。这个过程不仅仅是“调通”更重要的是理解软件模拟IIC的每一个时序细节以及MPU6050作为IIC从设备的完整通信流程。这样以后遇到任何IIC设备都能快速适配而不是每次都在网上找一堆似是而非的代码来碰运气。如果你也正在为STM32的软件IIC通讯或者MPU6050的移植问题头疼那这篇从踩坑到填坑的完整记录应该能给你提供一个清晰的参考路径。2. 软件IIC的本质用GPIO“模仿”硬件时序在开始写代码之前我们必须彻底搞清楚软件IIC到底是什么以及它和硬件IIC最根本的区别在哪里。硬件IIC是STM32内部的一个专用外设它有自己的一套寄存器你配置好时钟、地址、模式后它会在硬件层面自动帮你生成START、STOP、ACK等信号处理时钟拉伸你只需要关注数据缓冲区的读写。而软件IIC说白了就是程序员用两个普通的GPIO引脚一个模拟SCL时钟线一个模拟SDA数据线通过精确地控制它们输出高低电平的时间来“画”出符合IIC总线协议规范的波形图。2.1 核心时序与代码实现IIC协议有几个关键时序起始条件START、停止条件STOP、发送应答ACK和等待应答ACK。这些时序的稳定与否直接决定了通讯的成败。下面是我的实现关键点在于延时的把握。首先是GPIO的初始化。这里我选择推挽输出模式并且初始状态将SDA和SCL都拉高总线空闲状态。// 软件IIC引脚定义根据你的硬件连接修改 #define IIC_SDA_PORT GPIOB #define IIC_SDA_PIN GPIO_PIN_7 #define IIC_SCL_PORT GPIOB #define IIC_SCL_PIN GPIO_PIN_6 // 引脚高低电平控制宏提高代码可读性和执行效率 #define IIC_SDA_HIGH() HAL_GPIO_WritePin(IIC_SDA_PORT, IIC_SDA_PIN, GPIO_PIN_SET) #define IIC_SDA_LOW() HAL_GPIO_WritePin(IIC_SDA_PORT, IIC_SDA_PIN, GPIO_PIN_RESET) #define IIC_SCL_HIGH() HAL_GPIO_WritePin(IIC_SCL_PORT, IIC_SCL_PIN, GPIO_PIN_SET) #define IIC_SCL_LOW() HAL_GPIO_WritePin(IIC_SCL_PORT, IIC_SCL_PIN, GPIO_PIN_RESET) // 读取SDA引脚输入状态用于接收数据 #define IIC_SDA_READ() HAL_GPIO_ReadPin(IIC_SDA_PORT, IIC_SDA_PIN) void IIC_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; // 使能GPIO时钟 __HAL_RCC_GPIOB_CLK_ENABLE(); // 配置SDA和SCL为推挽输出初始高电平 GPIO_InitStruct.Pin IIC_SDA_PIN | IIC_SCL_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; // 推挽输出 GPIO_InitStruct.Pull GPIO_PULLUP; // 内部上拉如果外部已上拉可改为NOPULL GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; // 高速模式 HAL_GPIO_Init(IIC_SDA_PORT, GPIO_InitStruct); // 将总线拉高进入空闲状态 IIC_SDA_HIGH(); IIC_SCL_HIGH(); IIC_Delay(); // 延时一小段时间让电平稳定 }接下来是几个核心时序函数。这里的IIC_Delay()函数是关键它决定了通讯速率。对于MPU6050标准模式100kHz和快速模式400kHz都支持。我选择保守的100kHz对应的半个时钟周期是5us。你可以用SysTick定时器或者简单的for循环实现微秒级延时。// 简单的微秒延时函数需根据你的系统主频调整 void IIC_Delay(void) { uint32_t i 5; // 这个值需要根据你的CPU频率实际调整 while(i--); } // 产生IIC起始信号 void IIC_Start(void) { IIC_SDA_HIGH(); IIC_SCL_HIGH(); IIC_Delay(); IIC_SDA_LOW(); // 在SCL高电平期间SDA一个下降沿表示起始 IIC_Delay(); IIC_SCL_LOW(); // 钳住总线准备发送数据 IIC_Delay(); } // 产生IIC停止信号 void IIC_Stop(void) { IIC_SDA_LOW(); IIC_SCL_LOW(); IIC_Delay(); IIC_SCL_HIGH(); IIC_Delay(); IIC_SDA_HIGH(); // 在SCL高电平期间SDA一个上升沿表示停止 IIC_Delay(); } // 等待应答信号 // 返回值0-接收应答成功1-接收应答失败从机无应答 uint8_t IIC_Wait_Ack(void) { uint8_t ucErrTime 0; IIC_SDA_HIGH(); // 主机释放SDA线使其处于输入状态 IIC_Delay(); IIC_SCL_HIGH(); // 拉高SCL此时从机应该将SDA拉低 IIC_Delay(); // 注意这里需要临时将SDA引脚切换为输入模式以读取从机应答 // 更优雅的做法是前面初始化时就为SDA配置开漏输出模式并开启内部上拉。 // 开漏模式下输出0时为低电平输出1时引脚呈高阻态依靠上拉电阻为高此时可以直接读取输入。 // 为了代码清晰这里假设SDA已配置为开漏模式。 while(IIC_SDA_READ()) { // 读取SDA电平如果为高表示从机无应答 ucErrTime; if(ucErrTime 250) { // 超时等待 IIC_Stop(); // 无应答发送停止信号 return 1; } } IIC_SCL_LOW(); // 应答完毕拉低SCL继续后续操作 IIC_Delay(); return 0; } // 产生ACK应答信号主机在接收数据后发送 void IIC_Ack(void) { IIC_SCL_LOW(); IIC_Delay(); IIC_SDA_LOW(); // 拉低SDA表示应答 IIC_Delay(); IIC_SCL_HIGH(); IIC_Delay(); IIC_SCL_LOW(); IIC_Delay(); } // 产生NACK非应答信号主机在接收最后一个字节后发送 void IIC_NAck(void) { IIC_SCL_LOW(); IIC_Delay(); IIC_SDA_HIGH(); // 拉高SDA表示非应答 IIC_Delay(); IIC_SCL_HIGH(); IIC_Delay(); IIC_SCL_LOW(); IIC_Delay(); }注意SDA引脚模式的选择。这是软件IIC第一个容易踩坑的地方。如果像上面初始化代码一样用GPIO_MODE_OUTPUT_PP推挽输出那么在IIC_Wait_Ack()函数中主机无法读取从机拉低SDA线的动作因为推挽输出模式下输出寄存器会强制驱动引脚电平。正确的做法是将SDA引脚初始化为开漏输出模式GPIO_MODE_OUTPUT_OD并启用内部或外部上拉电阻。在开漏模式下输出1时引脚相当于高阻态不会影响从机拉低SDA此时才能正确读取总线状态。很多网上代码忽略了这一点导致只能发送不能接收或者应答检测永远失败。2.2 字节读写构建在时序之上的逻辑有了底层的时序函数读写一个字节的函数就很好构建了。核心思想就是在SCL低电平时改变SDA数据在SCL高电平时保持SDA稳定让从机采样。// IIC发送一个字节 void IIC_Send_Byte(uint8_t txd) { uint8_t t; IIC_SCL_LOW(); // 拉低时钟线开始数据传输 for(t 0; t 8; t) { // 先根据要发送的位设置SDA电平 if((txd 0x80) 7) { IIC_SDA_HIGH(); } else { IIC_SDA_LOW(); } txd 1; // 左移一位准备发送下一位 IIC_Delay(); // 拉高SCL从机在此上升沿后采样SDA IIC_SCL_HIGH(); IIC_Delay(); // 拉低SCL为发送下一位做准备 IIC_SCL_LOW(); IIC_Delay(); } // 发送完8位后释放SDA线准备接收应答 IIC_SDA_HIGH(); } // IIC读取一个字节 // ack: 0-发送ACK1-发送NACK通常在读取最后一个字节时发送NACK uint8_t IIC_Read_Byte(uint8_t ack) { uint8_t i, receive 0; // 确保SDA为输入模式开漏模式下已实现 for(i 0; i 8; i) { IIC_SCL_LOW(); IIC_Delay(); // 拉高SCL主机准备读取数据 IIC_SCL_HIGH(); IIC_Delay(); // 在SCL高电平期间读取SDA数据 receive 1; // 左移为下一位腾出空间 if(IIC_SDA_READ()) { receive; } IIC_Delay(); } // 读取完8位后主机发送应答或非应答 if(!ack) { IIC_Ack(); // 发送ACK } else { IIC_NAck(); // 发送NACK } return receive; }至此一个最基础的、面向单从机的软件IIC驱动层就完成了。它包含了起始、停止、应答、发送字节、接收字节等所有原子操作。为什么强调“单从机”因为我们的代码里没有包含总线仲裁、时钟拉伸等处理多主机的复杂逻辑这符合MPU6050作为唯一IIC从设备的应用场景也让代码更简洁、更易于理解和调试。3. MPU6050驱动层移植与具体设备对话有了通用的软件IIC底层我们就可以在上面构建针对MPU6050的专用驱动层了。这一层的工作就是根据MPU6050的数据手册封装出初始化、配置、读取数据等函数。3.1 寄存器定义与设备地址首先我们需要定义一些MPU6050内部的关键寄存器地址。这些地址在数据手册里都能查到。// MPU6050内部地址定义 #define MPU6050_ADDR 0xD0 // 7位设备地址为0x68左移一位后为0xD0写 #define MPU6050_SMPLRT_DIV_REG 0x19 // 采样率分频寄存器 #define MPU6050_CONFIG_REG 0x1A // 配置寄存器低通滤波器 #define MPU6050_GYRO_CONFIG_REG 0x1B // 陀螺仪配置寄存器 #define MPU6050_ACCEL_CONFIG_REG 0x1C // 加速度计配置寄存器 #define MPU6050_PWR_MGMT_1_REG 0x6B // 电源管理寄存器1 #define MPU6050_WHO_AM_I_REG 0x75 // 器件ID寄存器 #define MPU6050_ACCEL_XOUT_H_REG 0x3B // 加速度计数据寄存器起始地址MPU6050的IIC设备地址是7位的0x68如果AD0引脚接高电平则为0x69。在IIC通讯中我们需要将这个地址左移一位并在最低位加上读写位0为写1为读。所以写地址是0xD0读地址是0xD1。很多驱动库会直接使用0x68或0x69然后在读写函数内部处理左移这两种方式都是可以的关键是保持一致。3.2 核心读写函数封装这是连接我们软件IIC底层和MPU6050应用的桥梁。我们需要实现向指定寄存器写一个字节以及从指定寄存器开始连续读取多个字节的函数。// 向MPU6050的某个寄存器写入一个字节 uint8_t MPU6050_Write_Reg(uint8_t reg_addr, uint8_t reg_data) { uint8_t res; IIC_Start(); IIC_Send_Byte(MPU6050_ADDR); // 发送设备地址写 res IIC_Wait_Ack(); if(res) { return res; } // 从机无应答返回错误 IIC_Send_Byte(reg_addr); // 发送寄存器地址 res IIC_Wait_Ack(); if(res) { return res; } IIC_Send_Byte(reg_data); // 发送要写入的数据 res IIC_Wait_Ack(); if(res) { return res; } IIC_Stop(); return 0; // 成功返回0 } // 从MPU6050的某个寄存器开始连续读取len个字节 uint8_t MPU6050_Read_Reg(uint8_t reg_addr, uint8_t *reg_data, uint8_t len) { uint8_t res; IIC_Start(); IIC_Send_Byte(MPU6050_ADDR); // 发送设备地址写 res IIC_Wait_Ack(); if(res) { return res; } IIC_Send_Byte(reg_addr); // 发送要读取的起始寄存器地址 res IIC_Wait_Ack(); if(res) { return res; } IIC_Start(); // 发送重复起始条件 IIC_Send_Byte(MPU6050_ADDR | 0x01); // 发送设备地址读 res IIC_Wait_Ack(); if(res) { return res; } while(len) { if(len 1) { *reg_data IIC_Read_Byte(1); // 读取最后一个字节发送NACK } else { *reg_data IIC_Read_Byte(0); // 读取非最后一个字节发送ACK } reg_data; len--; } IIC_Stop(); return 0; }注意重复起始条件Repeated Start。这是IIC协议中一个非常重要的特性用于在不释放总线控制权不发送STOP信号的情况下切换读写方向。在MPU6050_Read_Reg函数中我们先发送写地址和寄存器地址告诉MPU6050我们要从哪个寄存器开始读然后发送一个重复起始条件IIC_Start再发送读地址接着连续读取数据。这个过程必须一气呵成中间不能有STOP信号否则总线释放从机就不知道你要干什么了。很多IIC通讯失败问题就出在这里没有使用重复起始而是用了StopStart的组合。3.3 设备初始化与配置初始化函数的目标是唤醒MPU6050并配置好它的工作模式、量程和滤波器。这里我配置了一个常用的模式陀螺仪量程±500°/s加速度计量程±4g并开启低通滤波器。// MPU6050初始化 uint8_t MPU6050_Init(void) { uint8_t res; uint8_t id; // 1. 首先进行软件IIC底层初始化 IIC_Init(); // 2. 读取器件ID验证通讯是否正常 res MPU6050_Read_Reg(MPU6050_WHO_AM_I_REG, id, 1); if(res ! 0) { // 通讯失败可能是接线问题、上拉电阻问题或地址错误 return 1; } if(id ! 0x68) { // MPU6050的WHO_AM_I寄存器默认值是0x68 // ID不匹配可能不是MPU6050或芯片损坏 return 2; } // 3. 唤醒MPU6050退出睡眠模式 // PWR_MGMT_1寄存器bit6置0使用内部8MHz晶振 res MPU6050_Write_Reg(MPU6050_PWR_MGMT_1_REG, 0x00); if(res) { return res; } HAL_Delay(100); // 等待稳定 // 4. 配置陀螺仪量程 ±500°/s // GYRO_CONFIG寄存器FS_SEL[1:0]01 res MPU6050_Write_Reg(MPU6050_GYRO_CONFIG_REG, 0x08); if(res) { return res; } // 5. 配置加速度计量程 ±4g // ACCEL_CONFIG寄存器AFS_SEL[1:0]01 res MPU6050_Write_Reg(MPU6050_ACCEL_CONFIG_REG, 0x08); if(res) { return res; } // 6. 配置低通滤波器截止频率约10Hz // CONFIG寄存器DLPF_CFG[2:0]010 res MPU6050_Write_Reg(MPU6050_CONFIG_REG, 0x02); if(res) { return res; } // 7. 配置采样率1kHz / (1 SMPLRT_DIV) // 设置SMPLRT_DIV9则采样率 1000 / (19) 100Hz res MPU6050_Write_Reg(MPU6050_SMPLRT_DIV_REG, 0x09); if(res) { return res; } return 0; // 初始化成功 }初始化流程就像和传感器“握手”并交代任务先叫醒它退出睡眠然后告诉它陀螺仪和加速度计用多大的量程量程越大分辨率越低但测量范围越广接着给它戴上“降噪耳机”低通滤波器滤除高频振动噪声最后设定它汇报数据的频率采样率。每一步写寄存器后最好都检查一下返回值这样在调试时能快速定位是哪一步出了问题。4. 数据读取与处理从原始值到物理量初始化成功后MPU6050就会按照设定的采样率不断把最新的传感器数据更新到它的数据寄存器里。我们的任务就是定期去把这些原始数据读出来并转换成有意义的物理量如加速度g角速度°/s。4.1 读取原始数据MPU6050的加速度和陀螺仪数据是16位有符号整数分别存储在6个连续的寄存器中ACCEL_XOUT_H, ACCEL_XOUT_L, ... GYRO_ZOUT_H, GYRO_ZOUT_L。我们可以一次把它们全部读出来。// 定义用于存储原始数据的结构体 typedef struct { int16_t Accel_X_RAW; int16_t Accel_Y_RAW; int16_t Accel_Z_RAW; int16_t Gyro_X_RAW; int16_t Gyro_Y_RAW; int16_t Gyro_Z_RAW; } MPU6050_RAW_Data; // 读取MPU6050的加速度和陀螺仪原始数据 uint8_t MPU6050_Read_RawData(MPU6050_RAW_Data *data) { uint8_t buf[14]; // 14个字节6个轴数据(12字节) 温度数据(2字节) uint8_t res; // 从加速度计X轴高字节寄存器开始连续读取14个字节 res MPU6050_Read_Reg(MPU6050_ACCEL_XOUT_H_REG, buf, 14); if(res) { return res; } // 将两个8位数据组合成一个16位有符号整数 // 注意MPU6050的数据是高字节在前Big-Endian >// 定义用于存储物理量的结构体 typedef struct { float Ax; // 加速度 X轴 (g) float Ay; // 加速度 Y轴 (g) float Az; // 加速度 Z轴 (g) float Gx; // 角速度 X轴 (°/s) float Gy; // 角速度 Y轴 (°/s) float Gz; // 角速度 Z轴 (°/s) } MPU6050_Data; // 根据初始化配置定义灵敏度因子 // 加速度计灵敏度±4g对应 8192 LSB/g (查阅MPU6050寄存器手册) #define ACCEL_SCALE_FACTOR 8192.0f // 陀螺仪灵敏度±500°/s对应 65.5 LSB/(°/s) #define GYRO_SCALE_FACTOR 65.5f // 将原始数据转换为物理量 void MPU6050_RawToPhysical(MPU6050_RAW_Data *raw, MPU6050_Data *phy) { // 加速度转换原始值 / 灵敏度因子 phy-Ax raw-Accel_X_RAW / ACCEL_SCALE_FACTOR; phy-Ay raw-Accel_Y_RAW / ACCEL_SCALE_FACTOR; phy-Az raw-Accel_Z_RAW / ACCEL_SCALE_FACTOR; // 陀螺仪转换原始值 / 灵敏度因子 phy-Gx raw-Gyro_X_RAW / GYRO_SCALE_FACTOR; phy-Gy raw-Gyro_Y_RAW / GYRO_SCALE_FACTOR; phy-Gz raw-Gyro_Z_RAW / GYRO_SCALE_FACTOR; }灵敏度因子Scale Factor是关键。它表示每个单位物理量对应的数字输出是多少。比如我们配置加速度计为±4g那么它的输出范围是-4g ~ 4g对应的数字输出范围是-32768 ~ 3276716位有符号数。所以灵敏度因子就是 32768 / 4 8192 LSB/g。意思是每1g的重力加速度会产生8192个数字量的变化。陀螺仪的计算同理。务必根据你初始化时配置的量程使用正确的灵敏度因子否则转换出来的物理量数值会是错的。4.3 在主循环中调用与测试最后我们在STM32的主函数里周期性地读取并处理数据可以通过串口打印出来验证。int main(void) { HAL_Init(); SystemClock_Config(); // 初始化你的串口等外设... MPU6050_RAW_Data raw_data; MPU6050_Data phy_data; uint8_t mpu_res; mpu_res MPU6050_Init(); if(mpu_res ! 0) { printf(MPU6050 Init Failed! Error Code: %d\r\n, mpu_res); while(1); // 初始化失败死循环 } else { printf(MPU6050 Init Success!\r\n); } while (1) { // 每隔100ms读取一次数据与设定的100Hz采样率匹配 HAL_Delay(100); mpu_res MPU6050_Read_RawData(raw_data); if(mpu_res 0) { MPU6050_RawToPhysical(raw_data, phy_data); // 通过串口打印数据 printf(Accel: X%.3fg, Y%.3fg, Z%.3fg | , phy_data.Ax, phy_data.Ay, phy_data.Az); printf(Gyro: X%.2f°/s, Y%.2f°/s, Z%.2f°/s\r\n, phy_data.Gx, phy_data.Gy, phy_data.Gz); } else { printf(Read MPU6050 Data Error!\r\n); } } }把模块平放在桌面上理想情况下加速度计的Z轴读数应该接近1g或-1g取决于芯片方向X和Y轴接近0g。陀螺仪在静止时三个轴都应该接近0°/s。如果数据不对比如加速度计静止时数值很大或者陀螺仪有固定的偏置那就需要进行校准。5. 进阶话题校准、姿态解算与代码优化把数据读出来只是第一步。在实际项目中我们通常还需要做三件事传感器校准、姿态解算以及驱动代码的优化。5.1 传感器校准消除零偏任何传感器都有误差MPU6050也不例外。最主要的误差是零偏Bias即传感器在静止状态下输出不为零。对于加速度计静止时除了重力方向其他轴应为0对于陀螺仪静止时所有轴都应为0。我们需要通过校准来测量这个零偏并在后续读数中减去它。一个简单的校准方法是将传感器静止放置一段时间比如几秒钟采集大量样本计算每个轴数据的平均值这个平均值就是零偏。#define CALIBRATION_SAMPLES 1000 MPU6050_RAW_Data calib_bias {0}; void MPU6050_Calibrate(void) { MPU6050_RAW_Data temp; int32_t sum_ax0, sum_ay0, sum_az0; int32_t sum_gx0, sum_gy0, sum_gz0; printf(Start Calibration, keep sensor still...\r\n); HAL_Delay(2000); // 等待2秒让用户放好传感器 for(int i0; iCALIBRATION_SAMPLES; i) { MPU6050_Read_RawData(temp); sum_ax temp.Accel_X_RAW; sum_ay temp.Accel_Y_RAW; sum_az temp.Accel_Z_RAW; sum_gx temp.Gyro_X_RAW; sum_gy temp.Gyro_Y_RAW; sum_gz temp.Gyro_Z_RAW; HAL_Delay(5); // 间隔5ms读取一次 } calib_bias.Accel_X_RAW sum_ax / CALIBRATION_SAMPLES; calib_bias.Accel_Y_RAW sum_ay / CALIBRATION_SAMPLES; calib_bias.Accel_Z_RAW (sum_az / CALIBRATION_SAMPLES) - (int16_t)(1.0f * ACCEL_SCALE_FACTOR); // 假设Z轴朝下减去1g calib_bias.Gyro_X_RAW sum_gx / CALIBRATION_SAMPLES; calib_bias.Gyro_Y_RAW sum_gy / CALIBRATION_SAMPLES; calib_bias.Gyro_Z_RAW sum_gz / CALIBRATION_SAMPLES; printf(Calibration Done. Bias Saved.\r\n); } // 读取并校准数据 void MPU6050_Read_CalibratedData(MPU6050_RAW_Data *raw) { MPU6050_Read_RawData(raw); // 减去零偏 raw-Accel_X_RAW - calib_bias.Accel_X_RAW; raw-Accel_Y_RAW - calib_bias.Accel_Y_RAW; // 注意加速度计Z轴校准比较特殊因为包含了重力。上面校准函数已经处理了。 raw-Accel_Z_RAW - calib_bias.Accel_Z_RAW; raw-Gyro_X_RAW - calib_bias.Gyro_X_RAW; raw-Gyro_Y_RAW - calib_bias.Gyro_Y_RAW; raw-Gyro_Z_RAW - calib_bias.Gyro_Z_RAW; }注意加速度计Z轴校准。加速度计在静止时Z轴会受到重力影响。如果你的模块Z轴垂直向下那么静止时Z轴读数应该是1g约8192个LSB。所以校准Z轴零偏时不能简单地将平均值作为零偏而应该用平均值 - 1g对应的LSB值。上面的代码示例体现了这一点。更好的做法是进行六面校准采集六个面的数据来更精确地计算标度因子和零偏。5.2 姿态解算初探从数据到角度得到了校准后的加速度和角速度数据我们就可以尝试计算模块的姿态俯仰角Pitch、横滚角Roll、偏航角Yaw。这里简单提一下互补滤波这是一个在单片机中非常实用的算法。加速度计可以测量重力方向在静止或低速运动时可以通过atan2(Ay, Az)等公式计算出俯仰角和横滚角但它对运动非常敏感。陀螺仪通过积分角速度可以得到角度但存在累积误差漂移。互补滤波就是取两者之长补两者之短用陀螺仪的数据作为主体响应快用加速度计计算的角度去修正陀螺仪的漂移。float pitch 0, roll 0; // 姿态角 float dt 0.01f; // 采样周期单位秒与你的读取周期一致 void Update_Attitude(float ax, float ay, float az, float gx, float gy, float gz) { // 1. 从加速度计计算姿态角单位弧度 float accel_pitch atan2(ay, az); float accel_roll atan2(-ax, sqrt(ay*ay az*az)); // 2. 互补滤波系数通常取0.98左右 float alpha 0.98; // 3. 更新姿态角先积分陀螺仪再用加速度计修正 // 注意这里gy是绕Y轴的角速度对应pitch角gx是绕X轴的角速度对应roll角。 // 需要根据你的坐标系定义调整。 pitch alpha * (pitch gy * dt) (1 - alpha) * accel_pitch; roll alpha * (roll gx * dt) (1 - alpha) * accel_roll; // 偏航角Yaw无法用加速度计修正纯陀螺仪积分会漂移需要磁力计或更复杂的算法如DMP、卡尔曼滤波。 }这是一个极度简化的示例。真正的工程应用会复杂得多需要考虑坐标系转换、四元数、卡尔曼滤波等。MPU6050本身也集成了DMP数字运动处理器可以硬件解算四元数大大减轻MCU负担但初始化配置更为复杂。如果你的项目对姿态精度要求高研究DMP或成熟的开源算法库如MadgwickAHRS、MahonyAHRS是更好的选择。5.3 代码优化与移植要点最后分享几个让代码更健壮、更易移植的经验。第一将硬件依赖抽象出来。我的软件IIC驱动里直接用了HAL_GPIO_WritePin和HAL_Delay。如果你想移植到其他平台比如标准库、或者别的单片机改动起来会很麻烦。更好的做法是定义一个硬件抽象层HAL。// iic_port.h typedef struct { void (*sda_high)(void); void (*sda_low)(void); void (*scl_high)(void); void (*scl_low)(void); uint8_t (*sda_read)(void); void (*delay_us)(uint32_t us); } iic_port_t; void iic_init(iic_port_t *port);然后在STM32的实现文件里用宏或函数指针指向具体的HAL操作。这样换平台时只需要实现一个新的iic_port.c上层应用代码完全不用动。第二增加超时和重试机制。在实际环境中IIC总线可能受到干扰。在IIC_Wait_Ack、MPU6050_Write_Reg等函数中加入合理的超时和有限次数的重试能极大提高系统的鲁棒性。第三注意时序的严格性。软件IIC的时序是靠延时函数IIC_Delay()保证的。这个延时必须足够稳定。避免在IIC通讯函数中被中断频繁打断如果非要开中断那么中断服务程序执行时间必须非常短。也可以考虑将IIC通讯的优先级设为最高或者暂时关闭中断。第四善用逻辑分析仪。这是调试软件IIC和任何数字通讯协议的终极利器。一个几十块钱的逻辑分析仪连接到SCL和SDA线上可以清晰地看到起始、停止、应答、数据位的每一个波形延时是否足够电平是否正确一目了然。绝大多数软件IIC的问题用逻辑分析仪抓一下波形都能立刻找到原因。从最底层的GPIO时序模拟到与MPU6050的寄存器对话再到数据的校准和初步应用这个过程虽然繁琐但每一步都踩得实实在在。当你看到串口稳定地打印出正确的加速度和角速度数据时这种对系统完全掌控的感觉是直接调用一个黑盒库无法比拟的。希望这篇详细的梳理能帮你打通STM32软件IIC和MPU6050移植的任督二脉。