简介面向嵌入式开发者的STM32F103C8T6与MPU6050六轴传感器数据读取工程完整演示了通过I2C驱动MPU6050、读取加速度与角速度原始数据并结合卡尔曼滤波抑制噪声和漂移最终经USART1发送至上位机。工程覆盖MPU6050初始化、寄存器读写、I2C时序配置、滤波算法移植及中断定时读取等关键环节卡尔曼滤波部分包含状态预测、观测更新与协方差迭代能平滑多帧数据提高姿态解算稳定性适合正在学习STM32外设通信与数据处理算法的开发者对照研究。资源共120个文件压缩包2.44MB其中包含13个C源文件和17个头文件主体Keil工程配置齐全并附带axf、hex等编译产物既能查看源码逻辑也可直接烧录验证。已有6100人学习源码按驱动、I2C通信、滤波算法等模块拆分并保留部分编译中间文件与备份便于理解完整工程结构并迁移到其他项目。 STM32F103C8T6这颗芯片在玩四轴、平衡车、云台的人手里基本就是还愿板——这玩意儿真的是烂大街又便宜某宝买个最小系统板才几块钱。而MPU6050更是陀螺仪加速度计里的常青树几乎每个人入门姿态解算都绕不开它。但偏偏就是这套组合我见过太多人卡在同一个地方I2C时序对了、寄存器读出来全是0x00或者数据读到是读到了波形图一开跟疯了一样乱跳。这篇文章我就把自己实际调通的这套流程完整走一遍从硬件接线、模拟I2C的底层实现到卡尔曼滤波的公式落地最后再到串口观察数据时该注意什么希望对正在折腾这块板的你有实际帮助。这套东西适合谁来参考我觉得分两类人一是刚接触STM32想做个小项目但不想糊里糊涂抄两份代码就完事的人二是已经能跑通例程但想把MPU6050数据用卡尔曼滤波真正融合成稳定欧拉角解决数据乱跳、零漂太重、一倾斜就飞这类问题的同学。我的建议是准备一块ST-Link或者便宜点的DAP-Link下载器一块C8T6最小系统板然后照着下文一步步来。1. 为什么选C8T6搭配MPU6050这套组合解决了什么问题先说说我为什么一直推荐用C8T6而不是更大容量的型号来做这件事。很多人觉得F103ZET6更正统毕竟战舰板、精英板满天飞。但实际做姿态采集这种活儿C8T6的64KB Flash、20KB SRAM完全够用——一个MPU6050的驱动加卡尔曼滤波编译下来固件不会超过25KBRAM占用更是不到4KB。选体积更大的芯片除了买板子贵没有任何收益。另外一个关键点是C8T6是LQFP48封装引脚也不多但恰恰是这48个引脚把I2C、USART、SPI的常用通道都覆盖了而且很多引脚的复用关系非常规整不容易出现想用串口3结果发现和I2C1冲突这种选型灾难。还重要的是C8T6可以直接用STM32CubeMX或者标准外设库开发网上能查到的参考资料最多遇到问题搜一下基本都有答案。对新手来说可查资料的数量本身就是一种生产力。再说MPU6050这边。这颗芯片内置了三轴陀螺仪和三轴加速度计理论上能输出六个轴的原始数据。它内部还带了一个DMP数字运动处理器可以直接输出四元数省掉很多姿态解算的功夫。但这里我要说一句可能会得罪很多人、但确实是实测结论的话DMP我建议新手先不要碰。DMP输出的是四元数你得懂四元数怎么转欧拉角而且它融合出来的效果虽然稳但中间过程对你完全是个黑盒。一旦出了问题你根本不知道是硬件问题还是算法问题。做DIY项目宁可先用卡尔曼滤波手搓一遍把数据链路搞通透再用DMP来提升体验。所以这套组合的核心价值就在于C8T6提供了一个足够精简但完整的MCU平台MPU6050则是一个信息量丰富但原始数据没法直接用的传感器两者凑在一起再加上一个合适的滤波算法就能把物理世界的倾斜角度变成一个稳定的数字量供平衡车、云台、机械臂去用。2. 硬件接线与应用场景动手前必须想清楚的三件事这一步看似简单但我实际调试过程中踩过好几次坑。MPU6050模块的引脚其实就那么几个VCC、GND、SCL、SDA还有两个额外的引脚XDA、XCL用于外接磁力计和AD0I2C地址选择。我建议上电之前先把以下三件事逐一确认能帮你省很多查错时间。第一件事确认AD0的电平状态。MPU6050的I2C地址是7位的默认是0x68也就是AD0引脚拉低模块默认状态。如果你把AD0接到VCC地址就变成了0x69。我见过不少人在代码里写死0x68但买的模块上AD0被厂家默认用电阻拉高了结果一直通信失败。拿到模块第一步拿万用表量一下AD0对地电压如果是高电平你就得改代码里的地址。当然一般模块会把AD0引脚引出来你没动它就默认是低电平。第二件事电源滤波别糊弄。我实测发现MPU6050对电源纹波极其敏感。如果VCC直接接在C8T6板子的3.3V输出脚上而板子又是从USB口取的5V电那USB供电的高频噪声会直接影响陀螺仪的输出——静止状态下手动转动模块再回正数据会过很久才归零甚至伴随持续的低频漂移。解决办法很简单在VCC和GND之间加一个10uF电解电容和104陶瓷电容位置尽量靠近MPU6050模块本身。如果手头有AMS1117-3.3这样的LDO串一级给传感器单独供电效果会更好。第三件事接线确定之前先想好安装方向。陀螺仪和加速度计的坐标系在芯片内部是固定的X轴指向芯片上丝印的某个方向。如果你把模块歪着焊在板子上或者用杜邦线甩着接那后面标定出来的欧拉角指向就会是错乱的算法怎么调都救不回来。我的建议是先把模块固定在板子上确定好坐标系和系统坐标系的对应关系然后再写代码。尤其是做平衡车的时候模块的安装角度差个90度PID参数全部白调。这里顺便给一张我常用的接线表照着接基本不会错MPU6050引脚STM32F103C8T6引脚备注VCC3.3V板载注意不要接5V会烧GNDGND共地必须SCLPB6 或 任意普通IO口软件I2C硬件I2C的话用PB6SDAPB7 或 任意普通IO口软件I2C硬件I2C的话用PB7AD0GND保持默认拉低地址为0x68XDA/XCL悬空不用磁力计就别接注意如果你用的是软件模拟I2C那SCL和SDA可以随便接在任意两个IO口上只要代码里改定义就行。我第一次调试就是用了硬件I2C后面被折腾得不行这个坑我下一节细说。3. 数据读取的两种方式我为什么最终选了软件模拟I2CMPU6050和STM32之间的通信是基于I2C总线。STM32F103C8T6自己带了硬件I2C外设理论上用CubeMX配置一下调用HAL库的HAL_I2C_Mem_Read就能读数据。听起来很美好但我要很直接地告诉你F1系列硬件I2C的体验极其糟糕我强烈建议你用软件模拟I2C。原因有五点一条条说F1的硬件I2C模块设计本身有瑕疵主模式接收时容易出现BUSY位卡死的问题尤其是刚复位之后状态寄存器没清干净程序第一次读数据就会卡在等待BUSY清零的死循环里。HAL库的HAL_I2C_Mem_Read函数内部封装得很重里面有各种超时机制和错误处理一旦出问题排查起来非常头大——问题到底是在I2C层、寄存器地址配置还是数据长度上你根本分不清。软件I2C的时序完全由你自己控制时钟高低电平的延时可以按模块的实际要求调整遇到问题直接老逻辑分析仪或者示波器抓波形一眼就能看出问题在哪。C8T6的频率是72MHzGPIO翻转速度用软件模拟I2C跑到100KHz乃至400KHz完全没压力。一个字节8个bit一帧数据几十个字节全程耗时在微秒级根本不会影响主循环的其他任务。也是最重要的一点代码可移植性。软件I2C那几行代码拷到任何板子上都能用不需要依赖具体芯片的I2C外设寄存器配置。那软件I2C具体怎么写呢核心其实就是把SCL和SDA对应的GPIO拉高拉低再加上合适的延时模拟出I2C协议里的起始信号START、停止信号STOP、应答信号ACK和数据位。我把自己用的驱动关键部分贴出来代码注释比较细可以直接抄。// 引脚定义 #define MPU6050_SCL_PORT GPIOB #define MPU6050_SCL_PIN GPIO_Pin_6 #define MPU6050_SDA_PORT GPIOB #define MPU6050_SDA_PIN GPIO_Pin_7 // SCL/SDA 拉高拉低宏 #define SCL_H() GPIO_SetBits(MPU6050_SCL_PORT, MPU6050_SCL_PIN) #define SCL_L() GPIO_ResetBits(MPU6050_SCL_PORT, MPU6050_SCL_PIN) #define SDA_H() GPIO_SetBits(MPU6050_SDA_PORT, MPU6050_SDA_PIN) #define SDA_L() GPIO_ResetBits(MPU6050_SDA_PORT, MPU6050_SDA_PIN) // SDA 输入模式读取数据 #define SDA_READ() GPIO_ReadInputDataBit(MPU6050_SDA_PORT, MPU6050_SDA_PIN)GPIO配置这里有一个关键点SDA这个引脚必须在输出模式推挽输出和输入模式上拉输入之间按需切换。写数据时SDA是输出模式读数据比如读ACK、读器件返回的数据字节时SDA要切回输入模式。我见不少人把SDA一直保持推挽输出结果读回来的数据永远是0xFF就是这个原因。引脚模式切换用下面这个函数void SDA_Mode_Output(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin MPU6050_SDA_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(MPU6050_SDA_PORT, GPIO_InitStructure); } void SDA_Mode_Input(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin MPU6050_SDA_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(MPU6050_SDA_PORT, GPIO_InitStructure); }注意我这里用的是标准外设库如果你更喜欢寄存器操作或者HAL库思路完全一样只是GPIO操作的API不同。标准库的好处是代码量少、直观但CubeMX生态下你可以直接把上面的宏替换成HAL的函数调用不影响整体逻辑。接下来是I2C的起始和停止信号。起始信号的定义是SCL保持高电平时SDA从高电平跳变到低电平。停止信号则是SCL保持高电平时SDA从低电平跳变到高电平。这两行代码是整个I2C通信的骨架不能写错。void I2C_Start(void) { SDA_H(); SCL_H(); Delay_us(5); SDA_L(); Delay_us(5); SCL_L(); } void I2C_Stop(void) { SDA_L(); SCL_H(); Delay_us(5); SDA_H(); Delay_us(5); }然后是发送一个字节和读取一个字节。void I2C_SendByte(uint8_t byte) { uint8_t i; SDA_Mode_Output(); for (i 0; i 8; i) { if (byte 0x80) SDA_H(); else SDA_L(); byte 1; SCL_H(); Delay_us(2); SCL_L(); Delay_us(2); } } uint8_t I2C_ReadByte(void) { uint8_t i, data 0; SDA_Mode_Input(); for (i 0; i 8; i) { SCL_H(); Delay_us(2); data 1; if (SDA_READ()) data | 0x01; SCL_L(); Delay_us(2); } return data; }读到字节之后接收方需要在第9个时钟周期拉低SDA来表示ACK应答。MPU6050在读数据时如果主机在读完最后一个字节后不再需要后续数据就需要回一个NACK拉高SDA然后发停止信号。这个细节直接影响通信能否正常结束。void I2C_Ack(void) { SDA_Mode_Output(); SDA_L(); SCL_H(); Delay_us(2); SCL_L(); SDA_H(); } void I2C_NAck(void) { SDA_Mode_Output(); SDA_H(); SCL_H(); Delay_us(2); SCL_L(); SDA_L(); }有了这几个底层原语MPU6050的寄存器读写就很简单了。读操作本质上是往从机地址写寄存器地址然后发起一次重复起始信号再以读模式从从机那里取数据。写操作则更直接往指定寄存器写值就行。我封装的函数长这样uint8_t MPU6050_ReadReg(uint8_t reg) { uint8_t data; I2C_Start(); I2C_SendByte(0x68 1); // 从机地址写位 I2C_SendByte(reg); // 寄存器地址 I2C_Start(); // 重复起始 I2C_SendByte((0x68 1) | 1); // 从机地址读位 data I2C_ReadByte(); I2C_NAck(); I2C_Stop(); return data; }寄存器初始化那块常用的几个配置寄存器是电源管理寄存器0x6B、采样率分频寄存器0x19、配置寄存器0x1A、陀螺仪配置寄存器0x1B、加速度计配置寄存器0x1C。初始化的顺序也有讲究——先唤醒芯片再配量程和滤波最后等芯片稳定。void MPU6050_Init(void) { MPU6050_WriteReg(0x6B, 0x00); // 解除休眠 MPU6050_WriteReg(0x19, 0x07); // 采样率 陀螺仪输出频率 / (17) MPU6050_WriteReg(0x1A, 0x06); // 低通滤波截止频率约5Hz MPU6050_WriteReg(0x1B, 0x08); // 陀螺仪量程 ±500°/s MPU6050_WriteReg(0x1C, 0x00); // 加速度计量程 ±2g Delay_ms(50); }这里有一个对新手特别重要的概念寄存器0x1B和0x1C里的量程配置决定了传感器输出的原始值满量程对应多大物理量。比如陀螺仪配置成±500°/s时量程是16位ADC-32768到32767所以原始值和角速度的换算关系是原始值 / 65.5 角速度(°/s)。加速度计配成±2g时换算关系是原始值 / 16384 加速度(g)。这个比例系数写错后面的滤波和姿态解算全都会变形。4. 卡尔曼滤波的原理与代码实现从一维到三轴的具体分析现在硬件通了原始数据也能读回来了但你要是直接把读回来的加速度计数值打印出来那波形图上的数据就跟过山车一样——静止状态下都在上下跳动。陀螺仪的原始数据看着稳但它有一个致命问题积分之后会漂移。这俩传感器单独用都有明显缺陷所以需要把它们的数据融合起来取长补短。卡尔曼滤波就是干这个的。卡尔曼滤波的本质是一个预测-更新的迭代过程。在姿态测量的场景里我们可以把问题简化成一个一维的模型要估计的是当前角度θ系统的状态方程很简单——θ(k) θ(k-1) ω * dt也就是说当前角度等于上一时刻角度加上陀螺仪测量得到的角速度乘以积分时间。测量方程则是加速度计直接测量的角度值α θ 噪声。用卡尔曼滤波的术语来说预测阶段要做的就是先用上一时刻的状态估计值和系统模型算出当前时刻的先验估计同时更新误差协方差。更新阶段则是根据卡尔曼增益把先验估计和测量值做一个加权平均得到最终的后验估计。这个增益不是固定的它是根据测量噪声协方差和过程噪声协方差动态变化的。我实际用下来在MPU6050的场景下一维卡尔曼滤波通常就够用了。在给出代码之前先把几个需要整定的参数说清楚因为这是整个滤波效果好坏的分水岭R测量噪声协方差代表你对加速度计测量值的信任程度。R越大滤波器越不相信测量值输出曲线越平滑但响应变慢。实际整定时直接把静止时加速度计角度输出的方差算出来填进去就行一般在0.01~0.1之间。Q过程噪声协方差代表你所建立的模型本身的不确定度。Q越大滤波器越相信测量值响应快但噪声大。这个值没有严格的公式我的经验是先在0.01这个量级起步然后观察波形动态调整。dt采样周期两次滤波计算之间的时间间隔值要和你代码里实际跑这个滤波函数的周期一致。如果你500Hz跑一次滤波但代码里写的是dt0.02那积分就会偏慢或者偏快。下面是一维卡尔曼滤波的结构体定义和核心计算函数这份代码我已经用了很多次值得直接抄走typedef struct { float Q_angle; // 过程噪声角度 float Q_bias; // 过程噪声陀螺仪零漂 float R_measure; // 测量噪声加速度计 float angle; // 融合后的角度 float bias; // 陀螺仪零漂估计 float P[2][2]; // 误差协方差矩阵 float dt; // 采样周期 } Kalman_t; void Kalman_Init(Kalman_t *kalman) { kalman-Q_angle 0.001f; kalman-Q_bias 0.003f; kalman-R_measure 0.03f; kalman-angle 0; kalman-bias 0; kalman-P[0][0] 0; kalman-P[0][1] 0; kalman-P[1][0] 0; kalman-P[1][1] 0; kalman-dt 0.002f; } float Kalman_GetAngle(Kalman_t *kalman, float newAngle, float newRate, float dt) { // 预测 kalman-angle (newRate - kalman-bias) * dt; kalman-P[0][0] dt * (dt * kalman-P[1][1] - kalman-P[0][1] - kalman-P[1][0] kalman-Q_angle); kalman-P[0][1] - dt * kalman-P[1][1]; kalman-P[1][0] - dt * kalman-P[1][1]; kalman-P[1][1] kalman-Q_bias * dt; // 更新 float S kalman-P[0][0] kalman-R_measure; float K[2]; K[0] kalman-P[0][0] / S; K[1] kalman-P[1][0] / S; float y newAngle - kalman-angle; kalman-angle K[0] * y; kalman-bias K[1] * y; float P00_ kalman-P[0][0]; float P01_ kalman-P[0][1]; kalman-P[0][0] - K[0] * P00_; kalman-P[0][1] - K[0] * P01_; kalman-P[1][0] - K[1] * P00_; kalman-P[1][1] - K[1] * P01_; return kalman-angle; }这个一维卡尔曼滤波的使用前提是把某轴的角度独立出来算。比如你要算绕X轴的俯仰角Pitch就用加速度计的Y轴和Z轴数据算出角度作为测量值陀螺仪的X轴角速度作为角速度输入。绕Y轴的翻滚角Roll同理。也就是说你跑两次这个滤波函数就能得到两个稳定的欧拉角。具体的角度计算映射关系要看你怎么安装传感器这里不展开但方向映射错了数据会非常奇怪。在三轴姿态解算里如果你追求更精确的航向角Yaw那单纯靠加速度计和陀螺仪是算不出来的必须引入磁力计。因为加速度计只能测重力方向无法感知物体绕重力轴的旋转。在没有磁力计的情况下Yaw角只能靠陀螺仪积分零漂是迟早的事。这就是为什么很多DIY四轴航向角飘得厉害不是算法问题是传感器硬件就缺了磁力计。如果只是做云台或者平衡车Pitch和Roll两个角就够了Yaw不参与闭环控制也无伤大雅。卡尔曼滤波的两个参数整定我给一个经验性的调试方法先固定R不动把Q从非常小比如0.0001开始往上调观察输出波形和输入波形的跟随程度直到响应速度和噪声之间达到你觉得合理的平衡点。然后微调R让静止时的输出足够稳定。整个过程就是一遍遍录数据、看波形、调参数没有捷径可走。5. 实测调试流程与避坑串口波形异常排查实录代码写完烧进去串口助手打开原始数据和滤波后的数据一对比几乎所有人第一眼都会愣住——滤波后的曲线怎么还是带毛刺或者响应特别慢又或者初始阶段要花好几秒才收敛到真实角度这些我都遇到过下面把常见的坑逐个过一遍。现象一滤波数据静止时稳定一大幅运动就严重滞后。这是我把Q整定得过小的典型结果。滤波器太信任陀螺仪积分模型加速度计的修正作用被压得很小大幅运动时加速度计的测量值模型已经跟不上了。解决办法是把Q_angle调大一些比如从0.001调到0.01提高对加速度计的信任度。现象二滤波后的角度存在一个固定的偏置比如静止放着显示15度。这个基本可以断定是你加速度计的角度换算公式写错了。比如atan2里面的轴序搞反了或者加速度计输出有零偏。如果不是换算公式错那就是传感器安装不是水平的模块本身的PCB没有完全垂直于重力方向。解决办法先跑一个水平校准在程序启动时读一次原始值把零偏记录并减掉。现象三卡尔曼滤波初始阶段波形要跳好几下才稳定。这说明初始误差协方差矩阵P设置得不合理。初始化时P不能全是0要给一定的初始不确定度否则滤波器在最初几个周期里不相信任何输入。把P[0][0]初始化为1P[1][1]初始化为1就能显著缩短收敛时间。现象四串口打印的数据看起来没问题但采集到的波形有规律地跳变。我遇到过一次排查到最后发现是printf重定向的串口和I2C的引脚复用冲突了。C8T6上USART1的TX/RX是PA9/PA10PB6/PB7是I2C1本来不冲突。但我的代码里用软件I2C却选的是PA9和PA10刚好和串口打架一打印串口数据I2C时序就被干扰。后来把软件I2C挪到PB6/PB7问题立刻消失。这个教训告诉我软件I2C的引脚选择也要避让串口、定时器PWM这些高频率翻转的引脚。现象五读出来的加速度计在某个方向的大幅变化是反向的。这个问题很多人会忽略——MPU6050的加速度计数据是以g为单位的但符号方向取决于芯片坐标系和你的安装方向。我调云台的时候把模块正着安装和反着安装Pitch和Roll的方向完全相反。如果你发现数据反了最简单的办法不是改代码公式而是加两个负号。另外补充一个调试技巧我强烈建议你先把原始数据的波形采集到确认传感器本身没有硬件问题再开始调卡尔曼参数。最容易实现的观察方式不是接LCD屏帧率太低看不出动态响应而是用虚拟示波器。网上有很多串口虚拟示波器上位机把数据当成两通道波形输出一帧一帧地看曲线变化调整参数的时候直观很多。我自己常用的是VOFA免费的配置极简串口数据按特定协议发上去就能画波形。最后再分享一个调试中的隐藏细节串口打印频率和卡尔曼滤波频率一定不要混为一谈。我建议滤波频率保持在一个较高的固定值比如500Hz但串口打印频率可以单独降下来比如50Hz。因为滤波算法的效果和数据打印的帧率没有直接关系打印太频繁反而会把数据流冲爆上位机上的波形也看不清楚。两个频率分开之后调参体验会舒服很多。如果你打算用这套方案继续往下走接下来的方向大概有三个一是把一维滤波升级成完整的四元数姿态解算用Mahony互补滤波把三轴陀螺仪和加速度计的数据融合起来二是给MPU6050接上磁力计比如HMC5883L把航向角的累积漂移也解决掉三是把卡尔曼滤波的输出作为PID反馈接上电机驱动板做一个真正的自平衡小车。每一步都有各自的坑但底层的这套数据链路是共通的模拟I2C读寄存器、换算物理量、滤波融合、串口输出。把这个链路跑通一次后面做任何惯性测量相关的项目你都心里有底了。本文还有配套的精品资源点击获取