☰
# STM32平衡车开发日记 — 速度环:从悖论到闭环(附完整代码)
2026/10/11 5:10:22 网站建设 项目流程

一、前言

上一篇文章我们搭好了串级PID的骨架,让平衡车能站稳——角度环保持在0°,车身不倒了。

但站稳只是第一步。一辆实用的平衡车需要能听话地前进后退:你推摇杆往前,它就按你指定的速度往前走;推摇杆往后,它就倒车。这就需要一个速度环——以线速度(m/s)为目标的闭环控制系统。

本文在串级PID的基础上,加入速度环作为最外层,形成四层串级控制:

层级名称周期输入输出
第0层(新)速度环5ms目标车速(m/s)目标倾角(rad)
第1层角度环5ms目标倾角目标角速度(rad/s)
第2层角速度环5ms目标角速度目标角加速度(rad/s²)
—逆解算5ms角加速度车轮加速度(m/s²)
—积分5ms加速度车轮转速(rad/s)
第3层电机速度环1ms目标转速PWM

本文对应课程第8.1~8.4节:绘制系统结构框图 → 速度环悖论 → 闭环传递函数与参数整定 → 编写代码。

硬件平台:STM32F103C8T6 + MPU6500 + TB6612 + 霍尔编码器直流减速电机(22线/1:20.4减速比)。


二、系统结构框图:划分"单片机世界"与"物理世界"

2.1 为什么要画这条分界线?

在写速度环代码之前,我们需要先想清楚一个问题:哪些量在MCU内部计算,哪些量属于外部物理世界?

这条分界线看似简单,但其实很关键——它直接决定了反馈回路该怎么接:

单片机内部(MCU) │ 物理世界(Physical World) ───────────────────────────────────┼────────────────────────────────── 速度PID、角度PID、角速度PID │ 电机、车轮、车身 逆解算、积分 │ 重力 设定值管理 │ MPU6050(测量倾角、角速度) │ 编码器(测量转速)

2.2 完整的四层系统框图


速度设定值(m/s) │ ↓ ┌─────────────── 第0层:速度环(5ms)───────┐ │ v_ref →[+]→ [PID_v] → atan(v/g) → θ_ref │ │ ↑ Kp=10, Ki=1 │ │ │ 实际车速 x_dot (分解后) │ └───────────────┬──────────────────────────┘ ↓ θ_ref ┌─────────────── 第1层:角度环(5ms)───────┐ │ θ_ref →[+]→ [PID_θ] → ω_ref │ │ ↑ Kp=4.0 │ │ │ pitch (MPU6050) │ └───────────────┬──────────────────────────┘ ↓ ω_ref ┌─────────────── 第2层:角速度环(5ms)─────┐ │ ω_ref →[+]→ [PID_ω] → α_ref │ │ ↑ Kp=10, Ki=10 │ │ │ gx (MPU6050) │ └───────────────┬──────────────────────────┘ ↓ α_ref ┌──────── 逆解算:α_ref → x''_ref ────────┐ │ x'' = (g·sinθ - α_ref·L) / cosθ │ └───────────────┬──────────────────────────┘ ↓ x''_ref ┌──────── 积分:x'' → ω_wheel ────────────┐ │ ω_wheel += x''/rw · dt │ └───────────────┬──────────────────────────┘ ↓ ω_wheel ┌─────────────── 第3层:电机速度环(1ms)──┐ │ ω_wheel →[+]→ [PI_L/R] → PWM │ │ ↑ Kp=0.5, Ki=7 │ │ │ 编码器T法测速 │ └──────────────────────────────────────────┘

注意:从速度环的视角看,θ(倾角)属于"物理世界"——它是车身姿态的物理量,速度环不直接控制它,而是通过改变角度环的设定值来间接影响车速。

2.3 为什么角度环的设定值不再固定为0?

在上一篇文章中,角度环的目标永远是θ_ref = 0°(直立)。加入速度环后,θ_ref变成了动态值:

  • 想前进 →θ_ref略大于 0(车身前倾,重力产生向前的水平分量)
  • 想后退 →θ_ref略小于 0(车身后仰)
  • 想停下 →θ_ref = 0°(车身直立,不产生水平加速度)

这就是平衡车"听话"的物理本质:通过微调倾角来改变重力带来的水平加速度,从而控制车速。


三、速度环悖论

3.1 悖论描述

课程第8.2节提出了一个非常精彩的问题——如果我直接用编码器测量的车轮转速做反馈,能控制速度吗?

目标车速 → [PID] → PWM → 电机 → 编码器测量 → 反馈

看起来没问题,但跑起来会这样:

  1. 车速比设定值慢 → PID要求加速 → 减小θ(车身前倾) → 获得向前的水平加速度
  2. 为了前倾,车轮需要往后撤一小段 → 编码器读数显示车轮在减速(甚至反转)
  3. PID看到车速更慢了 → 进一步加大输出 → θ进一步前倾 → 车轮进一步后撤
  4. 恶性循环 → 车身过度前倾 → 倒下

悖论的核心:编码器测量的是车轮相对于车身的转动,而不是车身相对于地面的移动。当车身调整姿态时,车轮会转,但车身不一定在动。

3.2 编码器测量值的两个分量

编码器的实测值 ω_encoder 实际包含了两个物理来源:

ω_encoder = ω_移动 + ω_姿态调整 ↑ ↑ 平衡车在地面 车身倾角变化导致 上真实移动 车轮被迫转动
分量来源物理含义
ω_移动车身在地面上实际平移我们要控制的目标量
ω_姿态调整车身倾角变化迫使车轮转动平衡控制的"副产物",不是真实移动

3.3 解决方案:分解反馈值

既然编码器值被"污染"了,我们就要把它洗干净——在输入速度PID之前,先减去姿态调整产生的转动分量:

// ω_姿态调整 = -角速度 × (摆长 + 轮半径) / 轮半径floatomega_2=-omega*(lp+rw)/rw;// ω_移动 = 编码器实测 - ω_姿态调整floatomega_1=velocity_omega-omega_2;// 实际车速 = ω_移动 × 轮半径floatx_dot=omega_1*rw;


在单片机内部减去欧米噶2,只得到平衡车在地上真实移动的距离

物理推导:车身以角速度 ω 前倾时,车轮中心相对于地面接触点产生 (L + r)·ω 的线速度(L为摆长,r为轮半径)。除以轮半径 r 就得到车轮的等效角速度。负号是因为前倾时车轮相对于车身向后转。

这个"分解术"是整个速度环的理论核心。如果不做这一步,速度PID收到的反馈是被姿态控制"污染"的数据,参数永远调不通。


四、逆解算:从速度到角度

4.1 物理直觉

速度环PID输出的是一个"加速度需求"(m/s²):“我想让车速增加多少”。但平衡车不是靠轮子直接加速的——它靠在重力的分量来加速:

车速慢 → 需要加速 → PID输出正加速度 → 车身往前倾 → 重力产生向前分量 → 实际加速

4.2 公式推导

对平衡车做受力分析:车身倾角为 θ 时,重力在水平方向的分量为:

F_h = m·g·tan(θ)

水平加速度:

a = g·tan(θ)

所以,要实现加速度velocity_output,需要的倾角为:

θ_ref = atan(velocity_output / g)

代码中就是这一行:

floattheta_ref=qatan(velocity_output/g);

为什么是atan而不是简单的线性关系?因为平衡车可能有大角度倾斜(比如急加减速),tan的线性近似 sinθ≈θ 在大角度时误差太大了。atan是精确的物理关系。

4.3 限幅的意义

速度环输出限幅设为 ±0.5g:

PID_LimitConfig(&pid_velocity,0.5f*g,-0.5f*g);

对应最大倾角atan(0.5) ≈ 26.6°。这个值不是随便定的:

  • 太大会导致车身过度倾斜,传感器可能失效
  • 太小会限制加速能力
  • 0.5g 对应约 5 m/s² 的加速度,对平衡车来说已经相当激进

4.4完善系统框图,在单片机内部引入逆解算

五、闭环传递函数与参数整定

5.1框图化简

对Gtheta(s)化简

详细化简过程参考ppt8.3

5.2 系统传递函数

课程第8.3节用MATLAB分析了速度环的闭环特性:

被控对象(倒立摆 + 角度环 + 角速度环)已在上篇文章中验证稳定 速度环在此基础上串联一个PI控制器

MATLAB验证代码(来自课程PPT"8.3 参数整定"):

s=tf('s');% 定义拉普拉斯算子Gpid=4;% 角度环等效增益(上一章已验证)% 角度→角速度传递函数Gtheta_dot=1/(s*(s/14.8+1)*(s/20.4+1));% 闭环传递函数Phi=feedback(Gpid*Gtheta_dot,1);% 分析结果dcgain(Phi)% 增益 = 1zero(Phi)% 没有零点pole(Phi)% 极点:-5.06±4.75i, -25.1

5.3 参数整定结果


引入pi控制器

参数值依据
Kp10.0速度偏差的直接响应,较大的Kp让车速跟踪更果断
Ki1.0消除稳态误差——静止时车速必须精确归零
Kd0速度反馈本身就是微分量的产物,再加微分噪声太大

为什么 Ki 这么小?

速度环的Ki=1.0,比角速度环的Ki=10.0小了10倍。因为:

  • 角速度环需要快速消除角速度的稳态误差(车身偏了必须立刻纠正)
  • 速度环的时间尺度更慢——车速变化要经过"倾角→角速度→加速度→积分"才能响应,这是一个三阶延迟链。Ki太大会让速度环过度修正,车身来回摆动

5.4 极点分析

闭环系统的主导极点是-5.06 ± 4.75i(共轭复极点,阻尼比约 0.73)和-25.1(实极点)。共轭极点说明系统在响应阶跃输入时会有轻微振荡但快速衰减——这是速度环的典型行为:车速会在目标值附近"悠"一下再稳定。


六、代码实现

6.1app_control.h— 新增复位接口

#ifndef__APP_CONTRL_H__#define__APP_CONTRL_H__#include"stm32f10x.h"voidApp_Control_Init(void);voidApp_Control_Proc(void);voidApp_Control_Reset(void);// 新增:控制系统复位#endif

6.2app_control.c— 四层串级PID完整实现

#include"app_control.h"#include"pid.h"#include"task.h"#include"app_mpu6050.h"#include"qmath.h"#include"delay.h"#include"app_motor.h"#include"app_encoder.h"staticPID_TypeDef pid_velocity;//速度PID控制器,用于计算速度参考值staticPID_TypeDef pid_theta;//角度PID控制器staticPID_TypeDef pid_omega;//角速度PID控制器staticfloatomega_ref=0.0f;staticconstfloatg=9.8f;// 重力加速度 (m/s²)staticfloatlp=0.062f;// 摆长 (m)staticconstfloatrw=0.032f;// 车轮半径 (m)staticuint64_tlast_time=0;voidApp_Control_Init(void){// 角度环:纯P, Kp=4.0PID_Init(&pid_theta,4.0f,0.0f,0.0f,0.0f);// 角速度环:PI, Kp=10.0, Ki=10.0PID_Init(&pid_omega,10.0f,10.0f,0.0f,0.0f);// 速度环:PI, Kp=10.0, Ki=1.0PID_Init(&pid_velocity,10.0f,1.0f,0.0f,0.0f);// 输出限幅PID_LimitConfig(&pid_theta,12.57f,-12.57f);// 角速度 ±2转/秒PID_LimitConfig(&pid_omega,125.7f,-125.7f);// 角加速度限幅PID_LimitConfig(&pid_velocity,0.5f*g,-0.5f*g);// 加速度 ±0.5g}voidApp_Control_Proc(void){PERIODIC(5);uint64_tnow_time=GetUs();floatdt=(now_time-last_time)*1.0e-6f;// ═══ 第0层:速度环 ═══// #-1. 设置速度目标值PID_ChangeSetpoint(&pid_velocity,0.0f);// #-2. 读取传感器floattheta=App_MPU6050_GetPitch()/360.0f*2.0f*3.1415926f;floatomega=App_MPU6050_GetGx()/360.0f*2.0f*3.1415926f;floatvelocity_omega=(App_Encoder_GetSpeed_R()+App_Encoder_GetSpeed_L())/2.0f;// #-3. 分解编码器反馈值(核心算法)// ω_2 = 姿态角调整产生的车轮转动(非真实移动)// ω_1 = ω_encoder - ω_2 = 真实移动产生的车轮转动floatomega_2=-omega*(lp+rw)/rw;floatomega_1=velocity_omega-omega_2;floatx_dot=omega_1*rw;// 实际车速 (m/s)// #-4. 速度PID计算floatvelocity_output=PID_Calc(&pid_velocity,x_dot);// #-5. 逆解算:加速度 → 倾角floattheta_ref=qatan(velocity_output/g);// ═══ 第1层:角度环 ═══PID_ChangeSetpoint(&pid_theta,theta_ref);floattheta_output_ref=PID_Calc(&pid_theta,theta);// ═══ 第2层:角速度环 ═══PID_ChangeSetpoint(&pid_omega,theta_output_ref);floatomega_output_ref=PID_Calc(&pid_omega,omega);// ═══ 逆解算 + 积分 ═══floatx_dot_dot_ref=(g*qsin(theta)-omega_output_ref*lp)/qcos(theta);if(dt>0.0f){omega_ref+=1.0f/rw*x_dot_dot_ref*dt;}// ═══ 第3层:电机速度环(由 App_Motor_Proc 独立运行)═══App_Motor_SetOmega(omega_ref,omega_ref);last_time=now_time;}// 控制系统复位:所有PID归零,积分清零voidApp_Control_Reset(void){PID_Reset(&pid_theta);PID_Reset(&pid_omega);PID_Reset(&pid_velocity);omega_ref=0.0f;last_time=0;}

6.3 代码关键路径解析

理解这段代码的核心是数据流向,我给你逐行拆解:

第1步:PID_ChangeSetpoint(&pid_velocity, 0.0f) → 告诉速度环"目标车速=0",替换为遥控器指令就能前进后退 第2步:float velocity_omega = (App_Encoder_GetSpeed_R() + ...) / 2.0f → 读左右编码器测量的车轮转速(rad/s),取平均作为反馈值 第3步:float omega_2 = -omega * (lp + rw) / rw → 计算姿态调整产生的"虚假速度" → 前倾(omega>0)时车轮往后转,所以有个负号 第4步:float x_dot = (velocity_omega - omega_2) * rw → 编码器测量值减去虚假分量 = 真实车速 第5步:float velocity_output = PID_Calc(&pid_velocity, x_dot) → 速度PID输出"需要的加速度"(m/s²) 第6步:float theta_ref = qatan(velocity_output / g) → 要获得这个加速度,车身需要倾斜 atan(a/g) 的角度 → 这成为了角度环的"新指令" 第7步:PID_ChangeSetpoint(&pid_theta, theta_ref) → 不再是固定的0°,而是速度环动态计算的倾斜角度! → 从这一步往后,就是上篇文章的串级PID逻辑了

6.4 反馈值分解后再做什么?

编码器GetSpeed得到的是车轮的瞬时角速度,但如第三节所述,这个值里混合了"真实移动"和"姿态调整"两个分量。关键就在后面两行:

floatomega_2=-omega*(lp+rw)/rw;// 姿态调整分量floatomega_1=velocity_omega-omega_2;// 真实移动分量floatx_dot=omega_1*rw;// 实际车速 (m/s)

拆干净之后才送入速度PID——这是整个速度环不失控的保证。


七、主程序集成

7.1 不变的主循环

main.c不需要改——速度环已经集成在App_Control_Proc()内部:

while(1){App_Bat_Proc();// 电池电压(100ms)App_Motor_Proc();// 电机速度环(1ms)App_Button_Proc();// 按键(轮询)App_Control_Proc();// 串级PID + 速度环(5ms)}

7.2 后续集成:遥控器

未来接入蓝牙遥控时,只需要把速度设定值从0.0f改成遥控器指令即可:

// 当前:PID_ChangeSetpoint(&pid_velocity,0.0f);// 将来:PID_ChangeSetpoint(&pid_velocity,remote_speed_cmd);

速度环的PI参数已经调好,遥控器发来的速度指令会被平滑追踪。


八、经验总结

  1. 速度环悖论是理解平衡车控制的关键门槛。编码器测量的不只是车速——它还包含了车身姿态调整时车轮被迫转动的分量。不分解这个量,速度环永远调不通。

  2. 反馈值分解是数学,不是黑魔法。ω_2 = -ω × (L+r) / r这个公式直接来自运动学约束:车轮与地面的纯滚动条件。读懂了这个公式,你就真正理解了倒立摆的物理本质。

  3. 速度环在"单片机世界"和"物理世界"之间架了一座桥:

    • 物理世界的编码器读数被"洗"干净后送入MCU
    • MCU的速度PID输出被逆解算成倾角指令后送回物理世界
    • 两个世界通过传感器和执行器形成完整闭环
  4. atan(v/g) 是速度环的画龙点睛之笔。它不是可有可无的近似,而是物理学要求的精确变换——平衡车只靠重力加速,所以加速度和倾角的关系是精确的a = g·tan(θ)。

  5. 四层串级各有分工:

    • 速度环:决定"以多快速度移动"(秒级响应)
    • 角度环:决定"倾斜多少来产生加速度"(百毫秒级)
    • 角速度环:决定"回正的力度"(十毫秒级)
    • 电机速度环:决定"轮子怎么转"(毫秒级)
    • 每层只处理自己能快速响应的物理量,层与层之间通过物理方程精确连接
  6. App_Control_Reset()是安全必需品。切换模式(平衡→停止→平衡)时,如果不清零PID的积分项,上次的积分累积会导致上电瞬间的剧烈抖动。复位函数一次性清零所有状态。

  7. 为什么角度环从固定目标变为动态目标?

    • 没有速度环:θ_ref = 0°(只管站稳,不关心车速)
    • 有速度环:θ_ref = atan(v_ref/g)(通过倾角"调度"重力来实现速度指令)
    • 这个转变是平衡车从"能站住"升级为"能听话"的核心

九、完整工程

本文涉及的代码变更:

文件变更内容
user/app_control.h新增App_Control_Reset()接口
user/app_control.c新增pid_velocity,四层串级PID实现,编码器反馈值分解,atan逆解算
user/app_encoder.c/h(无变更,使用已有的GetSpeed接口)
user/main.c(无变更,速度环已集成在App_Control_Proc内部)

已有基础设施:

文件功能
my_lib/pid.c/h位置式PID库(梯形积分 + 抗饱和)
my_lib/qmath.c/h快速三角函数库(含qatan)
user/app_mpu6050.c/hMPU6050驱动 + 互补滤波
user/app_encoder.c/h编码器T法测速 + 位置累计
user/app_motor.c/h电机速度闭环PI
my_lib/task.h轻量级周期调度宏

📁 工程路径:G:\Balance_Car_New\2.2\

📅 2026-07-08

🔗 前文:STM32平衡车教程 — 串级PID倒立摆控制:从建模到代码

如果你觉得这篇文章对你有帮助,欢迎点赞收藏 👍

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询