STM32平衡小车串级PID控制实战:从MPU6050到灰度循迹

STM32平衡小车串级PID控制实战:从MPU6050到灰度循迹 1. 项目背景与核心概念最近在复刻电赛 24 年 H 题这类两轮平衡控制小车项目核心方案是 stm32f103c8t6 mpu6050 五路灰度传感器。24 年 H 题的重点在于“让小车自己站起来并在赛道环境中完成寻线和定点任务”落到实际工程里就是两个闭环角度环和位置环。角度环负责让车体保持直立位置环负责让小车在赛道上稳定移动或定点停车。两个环串在一起就构成了嵌入式控制里非常经典的串级 PID 结构。为什么必须用串级 PID因为平衡小车本质上是一个不稳定的倒立摆系统。只让电机按当前倾角去“追回”直立位置还不够车速会影响车体倾角倾角又反过来影响车速两者相互耦合。单环 PID 很难同时兼顾“站得稳”和“走得准”。角度环作为内环以 mpu6050 解算出的俯仰角作为反馈输出电机加速度修正量位置环作为外环以编码器测速结果或灰度传感器识别到的赛道信息作为反馈输出目标倾角给内环。外环管定位内环管姿态逻辑清晰调试起来也方便。本文会从硬件选型、mpu6050 姿态读取、五路灰度传感器处理、串级 PID 实现到参数整定方法和常见坑点完整走一遍。适合准备电赛、刚入门 STM32 控制的小组和个人开发者阅读。读完你就能理解这类题目的工程骨架也能拿到一套可以直接移植到自己的工程里的代码思路。1.1 系统整体组成这套系统的本质是一个“平衡 巡线”的两轮小车两路带编码器的直流减速电机负责驱动左右轮。mpu6050 六轴传感器采集加速度和角速度用于姿态解算。五路灰度传感器安装在小车底盘前方用于识别黑色引导线或特殊停车标志。stm32f103c8t6 作为主控负责采集、解算、PID 运算和 PWM 输出。电机驱动模块例如 TB6612接收 STM32 的 PWM 和方向信号。整个控制链路是传感器 → STM32 读取数据 → 软件解算姿态 → PID 计算 → 输出 PWM → 电机转动 → 小车姿态和位置变化 → 传感器再次读取。这个闭环每 1ms 到 5ms 循环一次速度足够快才能让小车表现出“稳定直立”的效果。1.2 为什么选 stm32f103c8t6c8t6 是 STM32F103 系列里性价比很高的一颗芯片64KB Flash、20KB RAM。虽然资源不算大但控制两台电机、采集一路 I2C 传感器、处理五路开关量和一路编码器输入完全够用。Keil MDK 开发环境成熟网上例程多遇到问题也容易搜到。对于电赛和课程设计这类场景用 c8t6 作为主控是非常稳妥的选择。实际项目中要注意的是不要把所有功能都堆到一个定时器里。c8t6 的定时器资源有限如果同时需要编码器模式、PWM 输出和控制周期中断要提前规划定时器分配否则后面会为了抢定时器改代码很浪费时间。2. 硬件选型与环境准备2.1 硬件清单模块型号/说明数量主控stm32f103c8t6 最小系统板1姿态传感器mpu6050 模块带稳压1循迹传感器五路灰度传感器模块1电机带编码器直流减速电机2驱动TB6612 或 DRV88331电源7.4V 锂电池 稳压模块1下载器ST-Link V21硬件采购时优先选带编码器的电机。编码器对于位置环来说非常重要如果没有编码器就只能通过灰度传感器做非常粗略的位置判断控制精度会差很多。另外mpu6050 模块尽量选带稳压和低通滤波电路的版本不要买那种只有裸芯片的否则供电纹波会直接反映在姿态数据上。2.2 开发环境与工程配置推荐使用 Keil MDK5配合 ST 官方标准外设库或 HAL 库都可以。本文示例代码以标准外设库风格给出因为控制类代码在标准库下面读起来更直接逻辑更集中适合快速理解和修改。版本方面不要死抠具体版本号只要你手上的工程能正常编译、下载、串口打印就继续往下走。如果是第一次建工程建议先用一个最简单的点灯工程验证下载链路确认 ST-Link 驱动和 Keil 设置没问题再开始移植本文的代码。有一点要提醒标准外设库和 HAL 库在底层函数命名上有差异如果你用的是 HAL 库工程直接把标准库风格的代码复制进去是不能编译的。建议先搞清楚自己的工程底层是哪种风格再对照修改底层接口。2.3 引脚分配参考功能引脚I2C1_SCLPB6I2C1_SDAPB7电机 PWM 左PA0电机 PWM 右PA1电机方向左PB12、PB13电机方向右PB14、PB15编码器左PA6、PA7编码器右PB0、PB1灰度传感器PA2、PA3、PA4、PA5、PA8引脚分配不同项目可以调整只要不与 I2C 和串口冲突即可。建议把灰度这一类快速变化的信号放在支持外部中断的引脚上方便后续扩展比如用中断方式检测起跑线或停车标志。3. 核心模块原理与代码实现3.1 mpu6050 姿态采集mpu6050 内置三轴加速度计和三轴陀螺仪。加速度计在静止状态下能准确给出重力方向但动态响应中噪声较大陀螺仪短时间积分角度准确但长时间会有漂移。所以常见做法有两种一是用 mpu6050 内部的 DMPDigital Motion Processor直接输出四元数再转换成欧拉角二是自己写互补滤波或卡尔曼滤波把加速度计和陀螺仪的数据融合起来。对平衡小车而言我们最关心的是俯仰角 pitch也就是车体绕 Y 轴旋转的角度。使用 DMP 时代码流程为I2C 初始化。复位 mpu6050。配置陀螺仪量程和加速度计量程。加载 DMP 固件并使能。循环读取 FIFO得到四元数。四元数转欧拉角。读取 mpu6050 芯片 ID 的代码片段如下可以用来验证 I2C 通信是否正常// 文件路径工程目录/MPU6050/mpu6050.c // 读取 mpu6050 寄存器地址返回读取到的字节 uint8_t MPU_Read_Byte(uint8_t reg) { uint8_t res 0; I2C_GenerateSTART(MPU_I2C, ENABLE); while (I2C_GetFlagStatus(MPU_I2C, I2C_FLAG_BUSY) SET); I2C_Send7bitAddress(MPU_I2C, MPU_ADDR, I2C_Direction_Transmitter); while (I2C_GetFlagStatus(MPU_I2C, I2C_FLAG_ADDR) RESET); I2C_ClearFlag(MPU_I2C, I2C_FLAG_ADDR); I2C_SendData(MPU_I2C, reg); while (I2C_GetFlagStatus(MPU_I2C, I2C_FLAG_BTF) RESET); I2C_GenerateSTART(MPU_I2C, ENABLE); while (I2C_GetFlagStatus(MPU_I2C, I2C_FLAG_BUSY) SET); I2C_Send7bitAddress(MPU_I2C, MPU_ADDR, I2C_Direction_Receiver); while (I2C_GetFlagStatus(MPU_I2C, I2C_FLAG_ADDR) RESET); I2C_ClearFlag(MPU_I2C, I2C_FLAG_ADDR); I2C_ReceiveData(MPU_I2C); while (I2C_GetFlagStatus(MPU_I2C, I2C_FLAG_BTF) RESET); res I2C_ReceiveData(MPU_I2C); I2C_GenerateSTOP(MPU_I2C, ENABLE); return res; }注意这段代码是示意性的核心片段实际工程中还要考虑超时保护避免 I2C 总线异常时程序卡死在 while 循环里。可以加一个超时计数超过一定次数就跳出并返回错误标志。3.2 五路灰度传感器读取灰度传感器的输出一般有两种模拟量和数字量。五路灰度模块通常把五个探头集成在一起数字输出型模块比较常见每个探头检测到黑线时对应引脚输出低电平或高电平具体极性要看模块手册。在典型模块上检测到黑线时输出低电平检测到白色地面时输出高电平。读取方式很简单把五个引脚配置成上拉输入然后直接读电平// 文件路径工程目录/Gray/gray.c // 读取五路灰度值1 表示检测到黑线0 表示白色地面 void Gray_Read(uint8_t *gray) { gray[0] GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_2); gray[1] GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_3); gray[2] GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_4); gray[3] GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_5); gray[4] GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_8); } // 根据五路灰度值计算横向偏差返回 -2 到 2 的偏差等级 int Gray_GetDeviation(uint8_t *gray) { int deviation 0; int count 0; for (int i 0; i 5; i) { if (gray[i] 1) { deviation (i - 2); count; } } if (count 0) { return 0; // 没有检测到黑线保持当前方向 } return deviation / count; }这里要注意探头安装高度和供电电压。模块供电不稳会导致电平判断错误建议给灰度模块单独用稳压后的 3.3V 或 5V并且保证五个探头离地高度一致。安装高度太高黑线识别不灵敏太低碰到地面凸起容易损坏探头一般控制在 1cm 到 2cm 比较合适。3.3 电机驱动与编码器读取电机控制的核心是 PWM。平衡小车需要快速响应PWM 频率建议在 10kHz 到 20kHz 之间。频率太低电机会有啸叫太高会增大开关损耗驱动芯片也可能发热。编码器用于测量实际转速。带编码器的直流减速电机编码器输出一般是 AB 相正交信号。通过定时器的编码器模式可以自动计数不需要外部中断逐边沿处理// 文件路径工程目录/Motor/motor.c // 初始化编码器模式以 TIM2 为例 void Encoder_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_ICInitTypeDef TIM_ICInitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period 0xFFFF; TIM_TimeBaseStructure.TIM_Prescaler 0; TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); TIM_ICInitStructure.TIM_Channel TIM_Channel_1; TIM_ICInitStructure.TIM_ICPolarity TIM_ICPolarity_Rising; TIM_ICInitStructure.TIM_ICSelection TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter 0x0F; TIM_ICInit(TIM2, TIM_ICInitStructure); TIM_EncoderInterfaceConfig(TIM2, TIM_EncoderMode_TI12, TIM_ICPolarity_Rising, TIM_ICPolarity_Rising); TIM_Cmd(TIM2, ENABLE); } // 获取编码器计数值表示一段时间内电机转过的角度 int16_t Encoder_GetCount(TIM_TypeDef *TIMx) { int16_t count 0; count (int16_t)TIMx-CNT; TIMx-CNT 0; return count; }编码器计数方向可以反映电机正反转方向。在位置环中编码器速度是最重要的反馈量之一。读取编码器后一定要根据左右轮安装方向统一正方向否则两个轮子一个正转一个反转小车会原地打转。4. 串级 PID 控制实战4.1 控制逻辑拆解串级 PID 的关键是“外环输出作为内环目标”。对平衡小车来说位置环输入是“目标速度/目标位置减去当前实际速度/当前位置”输出是目标俯仰角。角度环输入是“目标俯仰角减去当前实际俯仰角”输出是电机 PWM 修正量。PWM 修正量经过左右轮分配送到电机驱动。为什么外环输出的是角度而不是直接给 PWM因为小车的运动学决定了倾斜角度产生加速度。你要让小车向前移动首先要让它向前倾斜然后利用重力分力产生前向加速度再通过角度环不断把倾斜角度控制在合适范围。所以位置环最终是通过目标角度来“指挥”小车运动的。控制周期上角度环要快建议 1ms 到 2ms位置环可以稍慢5ms 到 10ms。内环和外环做好时序错开避免同一时刻读角度又读编码器导致数据互扰。转向控制可以单独处理。五路灰度传感器检测到小车偏离直线时计算出偏差值根据偏差给左右轮加上一个相反的 PWM 偏置实现差速转向。这个偏置可以叠加在角度环输出之上也可以叠加在位置环输出之上具体看控制结构推荐叠加在最终输出上逻辑最简单。4.2 PID 代码实现先实现一个通用的 PID 结构体和计算函数// 文件路径工程目录/PID/pid.h #ifndef __PID_H #define __PID_H typedef struct { float target; // 目标值 float actual; // 实际值 float err; // 偏差 float err_last; // 上一次偏差 float err_sum; // 积分累计 float Kp; float Ki; float Kd; float output; // 输出 float output_max; // 输出限幅 float integral_max; // 积分限幅 } PID_TypeDef; float PID_Calc(PID_TypeDef *pid, float target, float actual); void PID_Clear(PID_TypeDef *pid); #endif对应的计算函数// 文件路径工程目录/PID/pid.c #include pid.h float PID_Calc(PID_TypeDef *pid, float target, float actual) { pid-target target; pid-actual actual; pid-err target - actual; // 积分分离偏差太大时停止积分防止积分饱和 if (pid-err 30.0f pid-err -30.0f) { pid-err_sum pid-err; } // 积分限幅 if (pid-err_sum pid-integral_max) { pid-err_sum pid-integral_max; } else if (pid-err_sum -pid-integral_max) { pid-err_sum -pid-integral_max; } // 位置式 PID pid-output pid-Kp * pid-err pid-Ki * pid-err_sum pid-Kd * (pid-err - pid-err_last); // 输出限幅 if (pid-output pid-output_max) { pid-output pid-output_max; } else if (pid-output -pid-output_max) { pid-output -pid-output_max; } pid-err_last pid-err; return pid-output; } void PID_Clear(PID_TypeDef *pid) { pid-target 0; pid-actual 0; pid-err 0; pid-err_last 0; pid-err_sum 0; pid-output 0; }这个 PID 实现里加了积分分离和两种限幅在工程中很实用。积分分离避免了小车刚启动时偏差过大导致积分迅速饱和的问题输出限幅则保证 PWM 不会超出电机驱动能接受的范围。在主控制逻辑中两个环按下面的顺序调用// 控制主循环核心片段 // 1. 读取姿态角和编码器速度 pitch MPU_Get_Pitch(); speed Encoder_Get_Speed(); // 2. 位置环根据目标速度和当前速度得到目标倾角 target_pitch PID_Calc(pid_speed, target_speed, speed); // 目标倾角要限幅防止小车大幅度倾斜 if (target_pitch 10.0f) target_pitch 10.0f; if (target_pitch -10.0f) target_pitch -10.0f; // 3. 角度环根据目标倾角和实际倾角得到电机输出 motor_pwm PID_Calc(pid_angle, target_pitch, pitch); motor_pwm LIMIT(motor_pwm, -500, 500); // 4. 叠加转向修正输出到左右电机 Motor_SetLeft(motor_pwm steer); Motor_SetRight(motor_pwm - steer);这里的 steer 是转向修正量来自五路灰度传感器的偏差值。灰度传感器识别到小车偏左就向右修正一点。4.3 参数整定思路串级 PID 整定比单环复杂建议按下面的顺序来先把位置环去掉只调角度环目标是让小车空载能直立站稳。角度环先用 P从小到大加直到小车有轻微振荡此时 P 基本合适。加一点 D用来抑制振荡可以明显改善回正过程。Kd 不能太大否则高频噪声会被放大电机发烫。角度环稳定后再接入位置环。位置环先给一个很小的目标速度观察小车是否匀速前后移动。位置环 P 决定响应快慢I 用于消除静态误差D 一般不常用。很多项目调不好不是算法问题而是数据没滤干净。mpu6050 原始角度噪声太大时Kd 一高就抖。先把数据质量搞好再调 PID会顺利很多。5. 主循环与整体流程5.1 控制时序一个比较实用的主循环用定时器中断驱动TIM3 中断 1ms进入一次控制计算读取 mpu6050运行角度环。每 5 个周期5ms读取编码器速度运行位置环更新目标倾角。每 20 个周期20ms读取灰度传感器更新转向修正值。把不同频率的任务拆开能避免 I2C 读取占用过长代码导致控制周期抖动。也可以直接用一个 1ms 中断在中断里做计数分频代码更简洁。5.2 主循环代码骨架// 文件路径工程目录/User/main.c volatile uint16_t tick_1ms 0; void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) ! RESET) { tick_1ms; Control_Loop_1ms(); TIM_ClearITPendingBit(TIM3, TIM_IT_Update); } } void Control_Loop_1ms(void) { static uint8_t cnt5 0; static uint8_t cnt20 0; cnt5; cnt20; if (cnt5 5) { cnt5 0; Control_Loop_5ms(); } if (cnt20 20) { cnt20 0; Control_Loop_20ms(); } } void Control_Loop_5ms(void) { // 读取编码器速度 speed_left Encoder_GetCount(TIM2); speed_right Encoder_GetCount(TIM4); speed (speed_left speed_right) / 2.0f; // 运行位置环 target_pitch PID_Calc(pid_speed, target_speed, speed); LIMIT(target_pitch, -10.0f, 10.0f); } void Control_Loop_20ms(void) { // 读取灰度传感器 uint8_t gray[5]; Gray_Read(gray); int deviation Gray_GetDeviation(gray); // 转向修正比例系数根据实际调试调整 steer deviation * 30.0f; }在主函数的 while(1) 循环里只需要处理串口打印、按键输入这类非实时任务不要放任何延时或者耗时操作。5.3 运行与验证正确接线后先用串口把姿态角度、编码器速度、两个 PID 输出打印出来。调试时给车一个外力让它倾斜观察角度环输出是否反向、是否能快速回正。验证串级 PID 是否生效有一个简单方法扶着车给它一个水平方向的速度感受电机是否在主动推你。如果电机产生和推动方向相反的阻力说明位置环方向正确。如果电机顺着推力方向加速说明位置环反馈方向反了需要把编码器方向或者位置环输出极性反过来。6. 常见问题与排查问题现象常见原因解决思路上电后 mpu6050 读不到数据I2C 地址或接线错误检查 AD0 电平确认地址是 0x68 还是 0x69用 I2C 扫描程序确认姿态角度跳动很大传感器供电不稳或信号线过长加稳压电容缩短杜邦线使用双绞线小车直立时左右抖动角度环 P 过大或 D 过小降低 P微调 D检查 PWM 死区小车始终往一边倒左右电机 PWM 零位不一致校准电机零偏在输出处统一加偏置灰度传感器识别不稳定安装高度过高或阈值不合适调整探头离地高度观察原始电压确定合理阈值加上位置环后车开始原地振荡位置环周期太快或 P 过大放慢位置环周期减小位置环 P电机 PWM 输出有尖叫声PWM 频率选择不合适尝试 10kHz 到 20kHz 之间的不同频率6.1 常见坑点说明第一个坑是 I2C 读到全 0xFF。不要急着怀疑代码先检查接线mpu6050 的 SCL 接 PB6SDA 接 PB7VCC 必须稳定在 3.3V。有的模块自带稳压可以接 5V但有的不行。上电后用示波器或逻辑分析仪看 SCL、SDA 有没有波形是最快的定位方式。如果没有示波器也可以用软件模拟 I2C很多时候硬件 I2C 配置有问题软件模拟反而更稳定。第二个坑是灰度传感器的阈值。五个探头虽然是同一个模块但每个探头的输出电压可能不一样。不要五个通道共用一个阈值最好是分别标定。调试时可以先把五个原始值打印出来记录黑线和白色地面各自的范围再定阈值。第三个坑是 PID 输出没有限幅。角度环输出过大时PWM 占空比可能超过 100%这时候输出数据看起来很大实际电机已经饱和控制会失效。务必要做输出限幅并考虑积分饱和也就是当输出达到限幅值时停止积分累加。第四个坑是定时器资源冲突。c8t6 的 TIM2 和 TIM4 可能同时被编码器和 PWM 使用如果初始化顺序不对可能会出现 PWM 没输出或者编码器计数不变化。建议先初始化 PWM再初始化编码器最后启动控制中断。7. 最佳实践与工程建议7.1 控制频率与数据质量优先平衡小车是一个强实时系统。控制周期不一定要极限快但一定要稳定。1ms 的控制周期已经足够前提是每次中断内只做必要运算不要频繁开关外设和打印日志。串口打印建议放到主函数 while(1) 循环中用标志位控制频率不占用中断时间。另外陀螺仪数据的零偏校准很重要。每次上电后在静止状态下采集几百组陀螺仪数据取平均得到零偏值后续读取时减去这个零偏。这一步能明显改善角度漂移问题尤其是使用互补滤波而不是 DMP 的时候。7.2 把调试能力提前建好整套系统调试起来很依赖数据回传。建议提前做好串口协议最好能支持上位机曲线显示比如把角度、角速度、PID 输出打包成帧发出去。调参时不要凭感觉要看曲线。只有看到曲线你才知道是 P 太大还是 D 太小是延时太长还是数据有噪声。调 PID 的时候建议把参数定义成全局变量并通过串口指令在线修改。这样不用每次改参数都重新编译下载能节省大量时间。等参数确认了再写成固定值固化到代码里。7.3 安全与保护机制控制类小车调试过程中最常见的故障是参数突变导致小车猛冲。为保护设备和人身安全建议给电机电流加限幅软件上限制 PWM 绝对值不超过某个最大值。设置保护定时器如果角度超过 45 度且持续一段时间立即关闭 PWM。调试时把小车放在软垫附近或者用绳子拴住底盘。所有参数先在小速度、小角度下验证再逐步放大。第一次上电时不要直接给目标速度先让小车在直立状态下观察几分钟确认姿态稳定再动。7.4 代码可维护性项目涉及 mpu6050、编码器、灰度、电机、PID 多个模块建议每个外设一个 .c/.h 文件接口统一。控制算法单独放一个 pid.c不要和硬件采集混在一起。这样换传感器、换芯片时只改底层接口控制逻辑可以原样保留。电赛这种场景代码写得清楚比写得炫技更重要。比赛现场调试时间非常紧张如果代码结构混乱改一个参数要找半天非常影响效率。建议每个文件头部写明文件名、作者、日期、主要功能和修改记录这些习惯在项目后期会非常有用。8. 总结与下一步这篇文章围绕 stm32f103c8t6 mpu6050 五路灰度传感器完整梳理了复刻电赛 24 年 H 题这类平衡控制小车的关键环节硬件选型、姿态读取、灰度采集、电机驱动以及最重要的角度环和位置环串级 PID 设计。核心收获可以概括为三点角度环是让小车“站起来”的基础内环响应要快、要稳。mpu6050 姿态数据要先做好滤波和零偏校准再进入 PID 计算。位置环是让小车“走起来”的关键外环输出的是目标角度而不是直接 PWM。编码器速度和灰度传感器信息是位置环的主要反馈来源。调试靠数据不靠感觉串口曲线和合理的保护机制能省下大量时间。参数在线修改功能在调参阶段几乎是必备的。如果你照着上面的思路搭完系统下一步可以继续研究 mpu6050 的 DMP 解算细节、编码器测速如何做滤波、以及如何用上位机做 PID 在线整定。这些内容都是平衡小车进阶的必经方向也几乎覆盖了电赛中大部分控制题的底层框架。最后补充一句个人经验复刻这类题目不要一开始就追求“一次性跑完整赛道”。先让小车站稳再让它直线走最后再加灰度循迹和转向。每往前走一步都要保证上一步是稳定可靠的。这样到比赛或者验收时你手里的系统才是可以被信任的。祝调参顺利欢迎交流指正。