成为控制大师[1]——闭环控制与滤波器设计
介绍
在写了这么多视觉和深度学习的博客之后,我也总算念完了大二了,接下来就要逐步成为控制大师。
在很早之前我尝试过做无人机,但是不知道为什么无人机飞起来特别困难,在之后我尝试了穿越机以及穿越机的PID调参,我忽然明白了些什么但是不多,所以本期博客就要详细地叙述一下。为了有一个良好的参考,这里我选择了一个编码电机用于演示。
开环控制很简单,我们给多少PWM它就出多大力,至于负载变没变、电池电压掉没掉,它一概不管,转速飘了也没人发现。闭环就不一样了:我们实时把电机的实际转速测出来,和期望速度比较,差了就补、多了就减,这样负载再怎么变,转速也能稳在目标附近。要闭环,先得有一路靠得住的反馈,所以才用编码电机来当例子——它尾巴上带着编码器,转了多少自己会报出来。

单片机选择的是f407vet6,控制板是亚博的,编码电机是同学的(硬抢),然后我们需要完成一下最基本的stm32配置。
设备驱动
编码电机的驱动分两步:
- 让一个定时器输出PWM波,负责驱动电机转动
- 把另一个定时器配成编码器接口,负责读取电机转了多少
PWM的道理其实不复杂。定时器内部就是个不断累加的计数器,时钟每来一拍加一,加到自动重装载值(ARR)就归零重来;同时硬件拿计数值和比较寄存器(CCR)比大小,比CCR小就输出高电平,比CCR大就输出低电平,于是一个周期里高电平占的比例就由CCR决定,这就是占空比。频率和占空比的计算就是:
要调速就改CCR,要改PWM频率就动PSC和ARR。
PWM输出定时器,这里我用了TIM1,TIM1在APB2总线上,频率为84MHz,经过分频和自动重装载后可以输出50Hz的PWM波

照图中的参数算一遍:84MHz的时钟先过84分频,再用20000个计数当一个周期,84MHz除以84再除以20000,正好是50Hz。图中CH1和CH2都开成了PWM输出,我们这套代码只用到了CH1。
编码器部分使用了TIM2,我们只需要它数数,所以预分频不动,计数周期(AutoReload)保持默认的65535,把16位计数器用满。编码器模式开TI1 and TI2,两路信号都参与计数,下面的Prescaler Division Ratio选的是Division by 2,也就是每两个边沿才记一个数。

编码器出来的是电机尾部两路相差90度的方波,一路叫A相一路叫B相。正转的时候A相超前B相,反转的时候B相超前A相,定时器盯着这两路的跳变顺序,就能自动让CNT加一或者减一,计数值里天然带着方向信息;而且这个过程纯硬件完成,不需要CPU插手,我们隔一段时间去读一次CNT就行。Division by 2把计数率降了一半,分辨率相应粗一点,好处是高速时计数值不至于涨得太快,想要更细的分辨率把它改回No Division即可。
然后我们还需要一个串口用于绘制波形,这里选择的是VOFA+上位机(好看,比匿名上位机好看),串口配置过于基础,这里不说了。
测速
大家可能会好奇,为什么我不使用一个TIM中断进行定时测速?那我们就不得不说一下中断是什么了。
中断是时序恶魔:
我这么说可能击碎了很多人脆弱的心脏,但事实上对于单核CPU或者说单片机而言,中断是噩梦一般的存在。
我们都知道程序保存在连续的内存空间内,CPU加载指令到缓存中可以非常方便地执行。如果产生了一个IRQ,打断了当前正在运行的指令,CPU就不得不跳转到另一段连续的内存空间中去,本来安排好的节奏全乱套了。中断的响应要花时间,进出中断还要保存、恢复现场,开销一点不小;如果主进程是一个对时间要求非常高的进程,这些都会影响到它的执行时机。为什么我们在控制的时候一定要做好对时间的把握?因为外部的干扰需要实时反馈,中断会破坏这个实时性,让控制机错过很多信息。举个例子:
小明驾车出去玩,当车辆行驶在公路(主进程)时忽然来了一个电话(中断),这时会产生两个情况:
- 小明接电话了,但是在接电话时迎面驶来了一个失控的货车,小明观察不及时而寄了
- 小明没接电话,而是选择在休息站(主进程空闲)接电话,这时迎面驶来一个失控的货车,小明及时反应而活了下来
没错,这个故事告诉我们一心不可二用,单片机也是如此(当然多核中断随便用,因为可以把任务分散给不同核心,但是单核就不可以了)。
单片机控制电机的时候需要实时查看电机情况,不管响应是超前还是滞后,都得先把电机的状态拿到手,等超过了调整时间再反应过来就晚了,控制会直接失控。
那不用中断,测速的问题怎么解决?其实编码器接口本来就在后台硬件计数,我们只要在主循环里按固定节拍去读CNT,把两次的差值算出来,速度就出来了。这种固定时间数脉冲的方法叫M法,适合中高速场合;对应的还有T法,测的是两个脉冲之间的时间,低速时更准。我们这个编码器转起来脉冲密密麻麻的,直接用M法就够。
如此说来我们就应该实现主程序计时。
1 | |
这样,电机参数更新的函数就实现了。HAL_GetTick()是SysTick撑起来的1ms计数,所以时间片的精度就是1ms,想换周期只要改TimePeriod这一个宏。这套写法放到实时操作系统里也一样能用,让任务按固定周期跑。
1 | |
那么可能会有人问,100ms一次的定时会不会不准确呢?答案很简单,编码器计数值在10000以内我们就认为没有溢出,假设每个计数对应0.5cm的位移,那10000个计数就对应50米,要让计数在一个周期内溢出,电机的速度得远超声速。所以这种速度完全足够了。(你在小看单片机的速度)
顺便说下这个10000是怎么来的。TIM2的计数器是16位的,从0数到65535再绕回0,如果中间真的绕了一圈,两次读数的差值就成了乱码。正常情况下100ms里电机最多走几百个计数,离10000还差得远,所以超过10000就当成异常丢掉,免得把速度环带跑偏;偶尔的采样抖动或者编码器干扰都可能让某一拍差值离谱。如果想严格一点,可以按int16_t去解释差值,让计数器绕圈也能自动接上,我们这个场景用不上,先图省事。
另外注意,这里算出来的速度单位是“每个TimePeriod里编码器走了多少个计数”,不是物理转速。想换成转/分,得先知道输出轴转一圈对应多少个计数——这个值跟编码器线数、分频设置和减速比都有关系,懒得算就用手慢慢转一圈输出轴,看CNT总共变了多少,实测一下最省事。拿到这个数以后,转速就等于计数除以它,再除以周期秒数、乘以60。
然后在主程序实时输出速度即可。

这张是滤波前的原始速度,曲线呈现明显的台阶状,这来自编码器计数只有整数;毛刺和抖动也很明显,拿这种数据直接丢给PID的话,微分项会被噪声放大,输出跟着乱抖。光靠这些数据去控制肯定不行,需要通过频谱分析找出噪声分布,再进行低通滤波,确保获取一个准确的速度。
数字信号处理(DSP)
既然噪声混在高频里,那就用低通滤波器把高频压下去,不过在选参数之前,得先搞清楚有效信号在哪个频率上。我们把串口数据保存下来,然后利用matlab绘制一下FFT,确定一下频率中心、带宽以及采样率。
顺便说下采样率这件事。我们每100ms更新一次速度,相当于对速度信号以10Hz的频率采样。采样定理要求采样率至少是信号最高频率的两倍,否则高频成分会折叠到低频里去,混在一起再也分不开,所以10Hz采样能分辨的最高频率是5Hz,也就是奈奎斯特频率。滤波器的截止频率要放在有效信号和噪声之间,又不能超过这个上限。
1 | |
绘制一下图像可以得到:

matlab的代码没什么好说的,把每行一个的速度值读进来,做FFT,用fftshift把零频挪到中间,再按f = (n - N/2) * 10 / N算频率轴,其中10就是采样率,N是数据点数,横轴因此覆盖-5Hz到5Hz,每根谱线的间隔是fs/N。这个10是写死的,以后换了采样率记得一起改,不然频率轴就对不上了。
从图上看,2.5Hz左右数据分布密度最大,其他的集中在2.47627Hz到2.5232Hz之间,我们就可以确定中心频率为2.5Hz,频率宽度在0.04693Hz。采样率就是我们更新电机速度的频率,我这里的TimePeriod是100,也就是100ms,换算频率为10Hz,所以我们就用10Hz的采样率进行计算。
这个结果说明速度的波动集中在2.5Hz附近,再往上的成分基本就是噪声了。低通滤波器的特征频率就先取在2.5Hz旁边,滤波的力度再通过Q去调,Q大了滤得松,Q小了滤得狠。
接下来我们实现滤波器的设计。这里用的是双二阶(biquad)IIR滤波器,一个二阶节就够实现低通、高通、带通这些常见类型,系数用的就是经典的RBJ公式,网上那些“双二阶滤波器计算器”算的都是这一套。它的传递函数长这样:
代码里a、b各存三个系数,w存两个内部状态,计算结构是转置直接II型,代码短、占的存储也少。先看头文件里的定义:
1 | |
初始化函数的几个参数各有各的用处:f0是特征频率,对低通来说就是拐点;fs是采样率,本次取10Hz;Q是品质因数,管的是过渡带的陡缓,巴特沃斯响应取0.7071,Q越大通带边缘越容易翘起来,Q小于0.5就没有峰值了,曲线从低频一路缓缓压下去;gain是留给增益的,目前几个初始化函数里都没用到。
然后我们看看C语言如何实现具体功能:
1 | |
这套代码把所有滤波器类型都铺了一遍,不过仔细看会发现,它们的初始化其实是同一套双二阶公式,FIR那一路只是先占了个位置,真要写FIR得换结构。这次真正用到的是LPF这一路,其他类型放着备查。另外有个小细节:这套LPF的b系数没有除以2,直流增益是2,也就是滤波后的速度数值会比原始计数大一倍,如果在意绝对幅值,把b[0]、b[1]、b[2]都再除以2就对了。
数据初始化也很简单:
1 | |
我们将电机更新方法重新实现一下:
1 | |
最后我们看一下速度波形曲线:

电机的启动需要时间,所以前期会缓慢上升,最后速度的波形也是相对不错的。滤波器带来的代价是相位滞后,曲线会显得慢半拍,压噪声和保动态这两个目标是互相拉扯的,截止频率调得越低曲线越平滑,但控制的反应也越迟钝,实际用的时候得根据波形自己找个平衡点。
速度控制
速度控制是整套系统里最要紧的部分,我们这里使用了PID控制。PID的思路不复杂,看一眼公式就明白了:
e是期望值减实际值。P项看当前的误差,差多少就出多少力;I项把历史误差一点点攒起来,专门消稳态误差;D项看误差变化的趋势,起阻尼作用,能压超调。D项对测量噪声特别敏感,所以真用的时候一般要配个低通,或者干脆改成微分先行。单片机里没有积分符号,得把公式离散化,按位置式一拍一拍地算:
T就是两拍之间的间隔,代码里用HAL_GetTick()前后两次的差值来算。为了方便适配不同情况,我这里把PID代码发出来大家自行参考:
1 | |
1 | |
这里我们实现了很多额外PID计算的功能,所以先解释下结构体里几个关键字段:Kp、Ki、Kd是三个基本参数;Max_out是输出限幅,防止输出冲出允许范围;Dead_band是输出死区,误差小到一定程度就直接不响应;Err和Last_Err存当前和上一次的误差;ITerm是本拍要累加的积分增量,I_out是积分累计结果;P_out、I_out、D_out分别是三项的贡献;Improve是个按位或的开关组合,决定启用哪些增强,可选项都在PID_Improvement枚举里。
具体实现了这些增强:梯形积分用首尾两拍误差的平均值来近似积分面积,比矩形积分准一点;变速积分根据误差大小调整积分的力度,误差大时先把积分让出去,防止饱和;微分先行只对反馈值求微分,不碰参考值的突变,改目标速度的时候就不会被“踢”一脚;微分滤波把微分结果再滤一遍,专门对付噪声;输出滤波是给最终输出加了个一阶低通;积分限幅管积分饱和;堵转检测在误差长时间压不下来时报一个PID_BLOCKED_ERROR。
这里我使用了变速积分器。它的分段逻辑是:误差小于B时全额积分,保证稳态精度;误差在B到A+B之间按比例打折;再大就把本拍的积分直接清零。积分限幅那段代码则做了两件事,一是输出已经超限、误差还和积分同方向时,把本拍积分增量清掉,断了继续灌积分的念头;二是把积分累计值直接夹在±Integral_Limit里,双保险;不过这段在本例没启用,Improve里没有打开这一项。
我们找个位置声明一下PID参数:
1 | |
这里的参数含义前面都说过,需要单独说的是量级:Kp=230看着很大,是因为误差单位是每100ms的编码器计数,误差动辄几十上百,输出又直接是定时器比较值;Max_out=10000对应ARR两万的一半,也就是占空比最多给到50%;积分限幅5000是个经验上限,不过本例没启用这一项,这个值不会起作用。换电机或者换测速周期,这几个数的量级都会变,不能照抄。
有两个坑得提前说。一个是INIT_PID_CONFIG这个宏只填了Kp、Ki、Kd、积分限幅、输出限幅和扩展模式,Coef_A、Coef_B这些字段会被默认初始化成0(Dead_band也一样,默认0等于没有死区)。变速积分的公式里A、B是分段阈值,保持0的话,误差和积分同向时积分增量会被反复清零,等于把积分项废掉了大半,我们这套参数差不多就是这么个情况,稳态离目标差一截,跟它也有关系;想让积分认真干活,记得按误差的量级把Coef_A、Coef_B补上。另一个是dt这个字段,名字叫dt,存的其实是上一次调用PID_Calculate的时间戳,第一次进函数的时候它还是0,算出来的dt会等于开机到现在的毫秒数,积分累计值会一下变得很大,输出最终被Max_out限住;本例没开积分限幅,I_out本身不会回夹。虽然始终生效的Output_Limit会兜着不至于出事,但第一拍总归不正常,讲究一点的话第一次调用前先把pid.dt赋成HAL_GetTick()。
在stm32的初始化阶段就把PID初始化好,数字滤波器初始化好后就可以把PID的输出放入PWM输出了:
1 | |
最后我们完成控制:
1 | |
主循环里的活是:先从串口DMA里收两个字节的期望速度,上位机发的是大端,Motor_Speed[0]是高字节,拼起来要左移8位;然后Motor_update刷新实测速度;把两个值丢进PID_Calculate,算出来的输出直接写进TIM1的CCR1,占空比就跟着变了;最后把实测速度和期望速度用逗号隔开发回上位机,VOFA+按两个通道画出来。
我们看一下PID控制效果:

图里492是实测速度,500是输入的期望速度,可以看到效果非常不错,两条线基本贴在一起,稳态就差几个计数。这时候如果我们用力去捏电机的轴,速度只会小范围移动——负载压上来速度掉一点,误差随之变大,P项马上加大输出把速度顶回去,手一松又回到目标值,这就是闭环和开环的区别。开环的时候这么一捏速度就直接掉下去了,能不能回来得看电压和负载的脸色。这样我们的PID就算控制好了。
总结
本次尝试了一下闭环控制,算是小试牛刀(用时比深度测距用得久)。从编码器测速、FFT找频率,到双二阶滤波器和变速积分PID,整个速度环算是完整跑通了,捏住电机轴速度也能稳得住。假期会做一个六足机器人,同样会教大家一些好玩的知识,敬请期待哦!