闭环控制是指控制论的一个基本概念。指作为被控的输出量以一定方式返回到作为控制的输入端,并对输入端施加控制影响的一种控制关系。带有反馈信息的系统控制方式。(源自:百度百科)
小伙伴们你们好,我是集。欢迎你打开入门智能车的第六篇章:闭环控制。我们在入门智能车第二篇章转动舵机中简单提到过闭环控制,今天我们要动手来实现电机的闭环控制,使得电机能够按照我们的要求转动起来。
在开始讲闭环控制之前,我们先回忆一下在入门智能车第一篇章中我们是怎么控制电机转速的:这里就要请出我们的老朋友 - 脉冲宽度调制了,我们通过调整占空比来改变电机在一个周期中通电的时间,从而调整施加在电机两端的电压大小,使得电机因为流过电流的变化工作在不同的转速之下。我们把整个过程简化,只留下占空比和电机转速,得到的框图会是像下面这样的
在这个过程中,我们通过人为的调整占空比大小使得电机拥有不同的转速。这样的过程我们称之为开环控制,这么讲你们可能会难以理解开环控制的概念,我们先来思考这样一个问题:
假设说我们给定了20%的占空比用以调整电机的转速,经过测量发现在20%占空比时电机的转速大小为50转每分钟(RPM)。这时如果想要电机工作在70转每分钟,我们应该怎么做?
直觉告诉我们,想要有更快的电机转速,就应该设置更高的占空比,但高多少才能够获得目标转速70RPM却成了一个难题。这里我们可以计算1%的占空比能获得的转速,再根据目标转速反推出需要设置的占空比,但因为电压死区的存在,这样做难以获得准确的结果。所以我们想到老方法:一个一个试,不断地调整占空比、测转速,慢了就增加占空比,快了就减小占空比。
给定占空比 -> 测转速 -> 比较实际转速和目标转速 -> 重新调整占空比,这样的过程其实就是一个闭环控制,我们发现这个过程形成了一个回环:每次调整的占空比大小都是基于上一次结果得到的。相比开环控制,闭环控制多了信息反馈环节(测电机转速),我们根据反馈信息再做出进一步调整,接着获得调整后的反馈信息,再基于更新过的反馈信息进行新一轮的调控。
现在我们已经知道了实现闭环控制的方法,但显然人为的去测转速、改占空比的操作太花费时间和精力了,有没有什么办法让MCU自己实现闭环控制呢?答案是有的,它就是我们接下来要介绍的PID算法。
在讲解PID算法之前,我们先来认识几个符号:
error
:误差,目标值与实际值的差值,有正有负
target
:目标值,我们设定的目标转速(以转速闭环为例)
actual
:实际值,我们测量得到的实际转速(以转速闭环为例)
我们规定误差的计算公式如下:
error
= target
- actual
(即误差 = 目标值 - 实际值,这么规定的目的是统一正负号)
认识完以上几个符号之后,我们就可以开始学习PID算法了,这里我们给出增量式PID算法的计算公式
增量式PID的离散化公式如下:
u(k) = Kp(e(k) - e(k-1)) + Ki(e(k)) + Kd(e(k) - 2e(ek-1) + e(k-2))
这么一长串公式让人看着有点慌张,我们试着把公式分为几个小块:
P = Kp(e(k) - e(k-1))
#比例项
I = Ki(e(k))
#积分项
D = Kd(e(k) - 2e(ek-1) + e(k-2))
#微分项
把它们加起来就是原来的公式了:
u(k) = P + I + D
公式看着简单多了,我们再试着深入挖掘下每个小块的内容:
Kp
、Ki
、Kd
:这三个变量是PID的调节系数,由我们指定,用以调节PID的性能e(k)
、e(k-1)
、e(k-2)
:指的都是误差error
k
、k-1
、k-2
:表示不同的时刻。与误差error
配合用以表示这个时刻的误差、上个时刻的误差以及上上个时刻的误差u(k)
:PID算法的计算结果,即控制量。在电机速度闭环中可以是给定的占空比把上面的内容代回到PID计算公式中,我们就得到PID的计算方法啦,可以发现PID算法中需要通过计算获取的变量只有一个:误差,其他剩下的就只有我们给定的系数了
知道了PID的计算公式,我们要如何利用它来实现闭环控制呢?我们可以看下下面的框图
我们把目标转速t
和实际转速a
输入到PID模块中,在模块内自动计算、保存误差e
,再把计算得到的误差e
套入PID公式中获得控制量u(k)
,接着把控制量u(k)
当作占空比进行一轮更新,之后再把更新后的占空比得到的实际转速t
传回PID模块,开始新一轮的更新。如此反复就实现了PID闭环控制,是不是很神奇呢~讲到这里我们就可以开始动手实践了,不过在开始之前我们还需要考虑一个问题:如何获得误差。
在电机速度闭环的例子里,我们如何计算获得误差e
呢?从公式中我们可以得知,误差的计算会用到目标值和实际值。速度闭环中的目标值是我们给定的目标转速,由我们自己设定,而实际值:实际转速的获取则需要花点功夫了:
我们在比赛中主要用编码器(蓝色)获取电机的转速。从图中可以看到,电机齿轮(红色)带着轮子转动,而编码器又与轮子上的齿轮接触,所以电机的转速经由齿轮传递给了编码器。那MCU又是怎么通过编码器测量出速度值的呢?我们来看一下编码器的输出信号:
图中分别展示了带方向的编码器
和正交解码编码器
的输出信号波形图。从图中我们可以知道,这两种编码器都可以用于获取转速和转向,只不过带方向的编码器是直接输出转向(通过高低电平表示),而正交解码编码器则是通过AB两相脉冲的上升沿判断转向(正转A相的上升沿比B相来得早,反转则相反)。
知道了判断转向的方法,我们再来看看如何通过编码器获取转速。我们从图中可以知道,除了方向,编码器还输出了一定数量的脉冲。
从逐飞给出的参数表中我们得知,不管是带方向的编码器还是正交解码编码器,它们的输出脉冲数都是1024线。这里的输出脉冲数指的其实是编码器每转动一圈产生的脉冲个数,即编码器每转动一圈产生1024个脉冲。
如果我们用一个计数器来累计编码器产生的脉冲数量n
,从0开始,每过一分钟对计数值进行保存、清零,则在每次清零时,我们都可以通过公式(n/1024)/1
计算出编码器在这一分钟内的平均转速。比如在一分钟内计数器获取到了n=4096
个脉冲,则在这一分钟内编码器的平均转速为4RPM。
对于闭环控制来说,一分钟时间调整一次显然太久了(我们可能每次调整只能让实际值向目标值趋近一点点),在电机速度闭环里面我们也希望用瞬时速度而不是平均速度表示电机的转速。所以我们加快一点时间,每过几毫秒就对计数值进行保存、清零,虽然通过公式(脉冲个数/输出脉冲数)/ 经过时间
算出的仍是这几毫秒内的平均转速,但因为经过时间足够短,平均速度可以被近似看作是瞬时速度被我们用到闭环控制中去。这样我们就可以把几毫秒内的脉冲计数值当作实际转速(瞬时转速)拿去计算误差值啦。
聪明的小伙伴可能已经想到了结合时间和脉冲个数计算电机实际转速(带公制单位)的方法了,但在闭环控制中,我们其实用不着换算出带公制单位的转速,因为不管是转每分钟还是转每秒还是弧度每秒(都是线性关系),它们表示的都是同一个物理量:转速。所以在速度闭环控制中,我们直接把一段时间内的脉冲个数当作实际转速使用就可以惹。
编码器作为常用元器件同样是被封装到了逐飞开源库中,我们在使用时直接调用集成好的函数就可以了。不同芯片的开源库编码器的文件可能放在不同位置,就MM32F3277
来说编码器相关的函数是放在zf_tim.c
文件中的,其他开源库可能得在工程范围搜索一下关键字encoder
用以找到编码器相关的函数。
因为每过一段时间要对脉冲计数进行保存、清零,所以我们可能需要用上定时器中断。定时器中断相关的函数位于文件zf_pit.c
中,初始化定时器后设定好定时时间,程序就会每过一段时间自动进入到isr.c
文件中的定时中断服务函数中去执行里面的语句了。
我们实际上手使用编码器后,把从编码器获取到的脉冲计数值显示到显示屏上,前后转动轮子我们会发现计数值时而正时而负,这代表什么呢?动动脑筋,想想看为什么会有负的值出现。
讲到这里我们已经能计算误差啦,剩下的就是把误差代入到PID公式中计算获得控制量,再把控制量应用到电机转速控制中去,最后循环起来就能实现闭环控制啦。在这里我们还遇到一个小问题,何时调用PID更新控制量呢?这里我们就需要引入采样周期和控制周期的概念。
采样周期其实就是我们每过多久获取一次实际值。在电机速度闭环的例子中,我们每过几毫秒获取一次脉冲计数值,所以这里我们的采样周期是几毫秒(可以是2ms,也可以是5ms)。而控制周期呢则是我们每过多久计算一次控制量,并把控制量应用到控制系统中去。
原则上来说,我们不希望控制周期
短于采样周期
,这样会导致在做闭环控制时我们拿着同样的数据进行重复计算,反应出来的结果可能就是控制性能不佳、无法应用于实际。所以我们应该尽量保证控制周期不会短于采样周期。至于具体的控制周期应该如何选择,则应该看具体应用场合(在串级PID中,控制周期的选择尤为重要),对于单个PID,我们可以简单粗暴使控制周期与采样周期相同,在每次采样之后立即调用PID计算、更新、应用控制量。
在前面的篇章中,我们介绍到增量式PID的离散化公式长这样:
u(k) = Kp(e(k) - e(k-1)) + Ki(e(k)) + Kd(e(k) - 2e(ek-1) + e(k-2))
但我们在实际应用这个公式的时候,需要对控制量进行累加:
u(k) += Kp(e(k) - e(k-1)) + Ki(e(k)) + Kd(e(k) - 2e(ek-1) + e(k-2))
这也是增量式PID里增量的体现所在。
既然应用到了累加,我们就会考虑到一个问题:如果误差一直存在,控制量会不会一直累加,直至超出变量的存储范围?这个问题是实际存在的,如果我们不加以限制,误差一直存在时,我们的控制量会不断累加直至爆炸,所以我们在计算获得控制量u(k)
后,需要对其加以限制,如果它超出限制范围,我们就需要采取暴力手段使其回到限制范围内。
我们在动手实践时发现PID计算出了负的值,这是怎么回事呢?联系我们之前提到的从编码器获取到负的计数值,想想看正负号所代表的含义,以及考虑考虑我们是否可以设置一个负的目标值呢?负的目标值又代表着什么呢?
到这里我们已经解决了PID中的绝大多数问题了,我们还剩的一个疑问是PID的三个参数如何调节呢?对于增量式PID,我们通常不使用公式中的微分项Kd
,所以对于Kd
我们可以设置成0。另外两个系数我们在没有调节前可以先设置为1,有需要时再对这两个系数进行调节。
关于PID的参数调节,可以参考一下这个视频:【Arduino 101】五分钟搞懂PID控制算法
在PID中,每个系数都是可以调节的,尝试通过按键修改这些参数,看看PID的性能有哪些变化?
除了增量式PID,我们还比较常使用到的另一种PID是位置式PID。
在图像循迹中,我们其实也是利用边界中点与图像中心点的误差调整舵机的转向。在实现增量式PID控制电机转速后,你也可以尝试着自己把位置式PID应用到图像循迹中去。
这个问题一直困扰着我,对于不同的控制系统,我究竟应该选择增量式PID还是应该选择位置式PID。
以电机速度闭环来说,我认为采用增量式PID会是比较好的选择,因为增量式PID积分器的特性,它在误差接近零时控制量会稳定保持在一个数值范围内,而不是像位置式PID那样直接把0当作控制量。
而对于图像-舵机闭环,因为我希望舵机转角能快速变化,并且在误差等于0时能够让小车直行,这时采用位置式PID会是更好的选择。所以究竟选择哪一种PID,应该看具体的应用场景,如果实在无法做出抉择的话,大不了两种PID都试一下,哪种参数好调节、效果棒就用哪种PID嘛~
文章的最后贴一段我写的代码模板吧~
/*------------------------------------------------------*/
/* 头文件声明 */
/*======================================================*/
#ifndef _pid_h
#define _pid_h
/*------------------------------------------------------*/
/* 头文件调用 */
/*======================================================*/
/*------------------------------------------------------*/
/* 外部变量声明 */
/*======================================================*/
/*----------------------*/
/* PID模块 */
/*======================*/
// 结构体声明
typedef struct pidpara{
// 执行函数
void (*action)(void); // 动作执行
void (*getAct)(void); // 实际值获取
// PID参数
float alpha; // 一阶低通滤波系数
float Kp;
float Ki;
float Kd;
// 计算相关
float I; // 积分项暂存
int e1, e2, e3; // 误差
int rs; // 计算结果
int thrsod; // 阈值
int act; // 实际值
}pidpara;
typedef struct{
void (*INC)(pidpara *, const short);
void (*POS)(pidpara *, const short);
}pidfunc;
// 外部结构体声明
extern pidfunc PID;
extern pidpara lmor_spd;
extern pidpara rmor_spd;
/*------------------------------------------------------*/
/* 函数声明 */
/*======================================================*/
void inc_pid(struct pidpara *para, const short tar);
void pos_pid(struct pidpara *para, const short tar);
int getCalculateResult(const struct pidpara *para); // 获取计算结果,注意返回值类型要与结构体成员rs对应
#endif
/*--------------------------------------------------------------*/
/* 头文件加载 */
/*==============================================================*/
#include "pid.h"
#include "stdlib.h"
#include "math.h"
/*--------------------------------------------------------------*/
/* 函数声明 */
/*==============================================================*/
static void dutyUpdate(void); // 结构体里有外部接口,可以声明静态不被其他文件发现,防止函数重名
static void getActualSpeed(void);
/*--------------------------------------------------------------*/
/* 结构体定义 */
/*==============================================================*/
pidfunc PID = { // 对增量式PID和位置式PID进行结构体封装,使用例:PID.INC(&lmor, target);
.INC = inc_pid,
.POS = pos_pid
};
// 左电机转速
pidpara lmor_spd = {
.alpha = 0.3,
.Kp = 1,
.Ki = 2,
.Kd = 0,
.thrsod = 7000,
.action = dutyUpdate,
.getAct = getActualSpeed
};
// 右电机转速
pidpara rmor_spd = {
.alpha = 0.15,
.Kp = 2,
.Ki = 1,
.Kd = 0,
.thrsod = 7000,
.action = dutyUpdate,
.getAct = getActualSpeed
};
/*--------------------------------------------------------------*/
/* 函数定义 */
/*==============================================================*/
/*----------------------*/
/* 增量PID模块 */
/*======================*/
void inc_pid(struct pidpara *para, const short tar){
// 参数列表-> para:调定参数 | tar:目标值 | act:实际值 | value:控制量 | thrsod:阈值
// 变量定义
float yn;
// 保存和计算误差
para->e3 = para->e2;
para->e2 = para->e1;
para->e1 = tar - para->act;
yn = para->I;
// PID公式
para->I = para->Ki*para->e1;
// 一阶低通滤波(积分项
para->I = para->alpha*para->I + (1-para->alpha)*yn;
para->rs += para->Kp*(para->e1-para->e2) + para->I + para->Kd*(para->e1 - 2*para->e2 + para->e3);
// 阈值限定
if(abs(para->rs) > para->thrsod){
if(para->rs >= 0)
para->rs = para->thrsod;
else
para->rs = -para->thrsod;
}
}
/*----------------------*/
/* 位置PID模块 */
/*======================*/
void pos_pid(struct pidpara *para, const short tar){
// 参数列表-> para:调定参数 | tar:目标值 | act:实际值 | max:最大控制值 | min:最小控制值
// 保存和计算误差
para->e2 = para->e1;
para->e1 = tar - para->act;
// PID公式
para->rs = (para->Kp)*para->e1 + para->Kd*(para->e1 - para->e2);
// 阈值限定
if(abs(para->rs) > para->thrsod){
if(para->rs >= 0)
para->rs = para->thrsod;
else
para->rs = -para->thrsod;
}
}
int getCalculateResult(const struct pidpara *para){ // 返回计算结果
return para->rs;
}
static void dutyUpdate(void){ // 用以更新占空比
return;
}
static void getActualSpeed(void){ // 用以获取实际速度
return;
}
2021/12/1更新:
最近忙着培训和摸鱼,之前写好的文章一直没有回头检查,直到今天有同学问我问题,我才后知后觉的发现文章中有许多问题没有讲明白,于是晚上花一点时间对文章进行了修改和补充。
2022/3/11更新:
添加代码模板