一、前言
上一篇文章我们搭好了串级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 → 电机 → 编码器测量 → 反馈看起来没问题,但跑起来会这样:
- 车速比设定值慢 → PID要求加速 → 减小θ(车身前倾) → 获得向前的水平加速度
- 为了前倾,车轮需要往后撤一小段 → 编码器读数显示车轮在减速(甚至反转)
- PID看到车速更慢了 → 进一步加大输出 → θ进一步前倾 → 车轮进一步后撤
- 恶性循环 → 车身过度前倾 → 倒下
悖论的核心:编码器测量的是车轮相对于车身的转动,而不是车身相对于地面的移动。当车身调整姿态时,车轮会转,但车身不一定在动。
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.15.3 参数整定结果
引入pi控制器
| 参数 | 值 | 依据 |
|---|---|---|
| Kp | 10.0 | 速度偏差的直接响应,较大的Kp让车速跟踪更果断 |
| Ki | 1.0 | 消除稳态误差——静止时车速必须精确归零 |
| Kd | 0 | 速度反馈本身就是微分量的产物,再加微分噪声太大 |
为什么 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);// 新增:控制系统复位#endif6.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参数已经调好,遥控器发来的速度指令会被平滑追踪。
八、经验总结
速度环悖论是理解平衡车控制的关键门槛。编码器测量的不只是车速——它还包含了车身姿态调整时车轮被迫转动的分量。不分解这个量,速度环永远调不通。
反馈值分解是数学,不是黑魔法。
ω_2 = -ω × (L+r) / r这个公式直接来自运动学约束:车轮与地面的纯滚动条件。读懂了这个公式,你就真正理解了倒立摆的物理本质。速度环在"单片机世界"和"物理世界"之间架了一座桥:
- 物理世界的编码器读数被"洗"干净后送入MCU
- MCU的速度PID输出被逆解算成倾角指令后送回物理世界
- 两个世界通过传感器和执行器形成完整闭环
atan(v/g) 是速度环的画龙点睛之笔。它不是可有可无的近似,而是物理学要求的精确变换——平衡车只靠重力加速,所以加速度和倾角的关系是精确的
a = g·tan(θ)。四层串级各有分工:
- 速度环:决定"以多快速度移动"(秒级响应)
- 角度环:决定"倾斜多少来产生加速度"(百毫秒级)
- 角速度环:决定"回正的力度"(十毫秒级)
- 电机速度环:决定"轮子怎么转"(毫秒级)
- 每层只处理自己能快速响应的物理量,层与层之间通过物理方程精确连接
App_Control_Reset()是安全必需品。切换模式(平衡→停止→平衡)时,如果不清零PID的积分项,上次的积分累积会导致上电瞬间的剧烈抖动。复位函数一次性清零所有状态。为什么角度环从固定目标变为动态目标?
- 没有速度环:θ_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/h | MPU6050驱动 + 互补滤波 |
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倒立摆控制:从建模到代码
如果你觉得这篇文章对你有帮助,欢迎点赞收藏 👍