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-发送ACK,1-发送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通讯失败,问题就出在这里没有使用重复起始,而是用了Stop+Start的组合。
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_DIV=9,则采样率 = 1000 / (1+9) = 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 ~ +32767(16位有符号数)。所以,灵敏度因子就是 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_ax=0, sum_ay=0, sum_az=0; int32_t sum_gx=0, sum_gy=0, sum_gz=0; printf("Start Calibration, keep sensor still...\r\n"); HAL_Delay(2000); // 等待2秒,让用户放好传感器 for(int i=0; i<CALIBRATION_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移植的任督二脉。