参考教程:PID入门教程-电机控制 倒立摆 持续更新中_哔哩哔哩_bilibili
一、PID基本原理回顾与横展
1、PID简介
(1)PID是比例(Proportional)、积分(Integral)、微分(Differential)的缩写。
(2)PID是一种闭环控制算法,它动态改变施加到被控对象的输出值(Out),使得被控对象某一物理量的实际值(Actual),能够快速、准确、稳定地跟踪到指定的目标值(Target)。
(3)PID是一种基于误差(Error)调控的算法,规定“误差=目标值-实际值”,PID的任务是使误差始终为0。
(4)PID对被控对象模型要求低,无需建模,即使被控对象内部运作规律不明确PID也能进行调控。
2、PID公式与系统框图
3、PID公式分解分析
(1)比例项(P):
①若PID控制器只含有比例项,则有,可见,比例项的输出值仅取决于当前时刻的误差,与历史时刻无关。当前存在误差时,比例项输出一个与误差呈正比的值;当前不存在误差时,比例项输出0。
②越大,比例项权重越大,系统响应越快,但超调也会随之增加,甚至可能出现自激振荡,这是不被允许的。(超调的定义见第三章第一节)
③纯比例项控制时,系统一般会存在稳态误差,越大,稳态误差越小。(稳态误差的定义见第三章第六节)
④如下几组实验是使用PID控制调节电机转速,其中PID控制器均只含有比例项,比较目标值波形(红色)和实际值波形(蓝色),可见只有比例项的PID控制器根本无法达到理想的控制效果。
⑤系统进入稳态时,实际值和目标值存在始终一个稳定的差值,接下来以上面几组实验为例说明稳态误差产生的原因:
纯比例项控制时,若误差为0,比例项结果也为0,对应电机的转速已达到目标转速,由于结果为0,电机将不再产生驱动力,然而电机转动会受到摩擦阻力,这必然会导致转速下降而偏离目标转速,以此往复,最终电机的驱动力会与其受的摩擦阻力平衡,而因为电机有驱动力输出,那么对应的误差也不会为0,这就是稳态误差。
被控对象输入0时,一般会自发地向一个方向偏移(偏移方向即为稳态误差的方向),产生误差,如果没有自发偏移,说明不存在稳态误差。产生误差后,误差非0,比例项负反馈调控输出,当调控输出力度和自发偏移力度相同时,系统达到稳态。
(2)积分项(I):
①若PID控制器只含有比例项和积分项,则有(即PI控制),积分项的输出值取决于0~t所有时刻误差的积分,与历史时刻有关。积分项将历史所有时刻的误差累积,乘上积分项系数
后作为积分项输出值。
②积分项用于弥补纯比例项产生的稳态误差,若系统持续产生误差,则积分项会不断累积误差,直到控制器产生动作,让稳态误差消失。
③越大,积分项权重越大,稳态误差消失越快,但系统滞后性也会随之增加。
④如下几组实验是使用PID控制调节电机转速,其中PID控制器均只含有比例项和积分项,比较目标值波形(红色)和实际值波形(蓝色),可见只要通过不断地调整PI系数,总会得到一个比较理想的控制效果。
(3)微分项(D):
①含有比例项、积分项和微分项的控制器是完整的PID控制器,微分项的输出值取决于当前时刻误差变化的斜率,它与当前时刻附近误差变化的趋势有关。当误差急剧变化时,微分项会负反馈输出相反的作用力,阻碍误差急剧变化。
②误差变化的斜率一定程度上反映了误差未来的变化趋势,这使得微分项具有“预测未来提前调控”的特性。
③微分项给系统增加阻尼,可以有效防止系统超调,尤其是惯性比较大的系统。当目标值接近实际值时误差的变化率反而非常大,这种时候就需要考虑增加微分项的权重了,否则实际值必然超调。
④越大,微分项权重越大,系统阻尼越大,但系统卡顿现象也会随之增加。
二、离散形式的PID
1、连续形式PID离散化
(1)连续域积分器对应离散域的累加器,连续域的微分对应离散域的差分,由此可推出离散形式的PID公式。
(2)连续形式PID离散化的示意图:
2、位置式PID与增量式PID
(1)增量式PID的推导:
(2)位置式PID与增量式PID的区别与联系:
①位置式PID由连续形式PID直接离散得到,每次计算得到的是全量的输出值可以直接给被控对象。(一般优先考虑使用位置式PID)
②增量式PID由位置式PID推导得到,它不需要累加器,只需要记忆前两个采样时刻的输出值。
③增量式PID每次计算得到的是输出值的增量,如果直接给被控对象输出增量,则需要被控对象内部有积分功能。
④增量式PID也可在控制器内进行积分然后输出积分后的结果,此时增量式PID与位置式PID整体功能没有区别。
⑤位置式PID和增量式PID计算时产生的中间变量不同,如果对这些变量加以调节,可以实现不同的特性。
(3)PID程序实现:
①大体框架如下,确定一个调控周期T,每隔时间T,程序执行一次PID调控(以在中断服务函数中执行为例)。
②位置式PID程序实现:
③增量式PID程序实现(控制器内积分,输出全量):
三、基础驱动代码编写及相关背景和知识储备
1、实验硬件原理图
(1)OLED模块的I2C通信线分别连接STM32开发板的PB8和PB9引脚,这与STM32教程中的一致。
(2)STM32开发板的PC13引脚连接开发板自身带有的测试LED灯。
(3)STM32开发板的PB10、PB11、PA11和PA12依次与按键K1、K2、K3和K4的一端连接。
(4)STM32开发板的PA2、PA3、PA4和PA5依次与4个电位器旋钮的模拟输出引脚连接,PA2复用了ADC12的IN2(也即ADC1和ADC2的通道2),PA3复用了ADC12的IN3(也即ADC1和ADC2的通道3),PA4复用了ADC12的IN4(也即ADC1和ADC2的通道4),PA5复用了ADC12的IN5(也即ADC1和ADC2的通道5)。
(5)STM32开发板只能同时直连2个编码电机,其中M1和M3共用一个接口,M2和M4共用一个接口。
(6)编码电机需要由电机驱动模块驱动,其中M1+和M1-接在了A路输出,其对应的控制引脚为PWMA、AIN1、AIN2,分别连接STM32开发板的PA0、PB12、PB13,M2+和M2-接在了B路输出,其对应的控制引脚为PWMB、BIN1、BIN2,分别连接STM32开发板的PA1、PB14、PB15。(IN引脚负责控制电机旋转方向)
(7)串口与STM32开发板的连接如下图所示。
2、编码电机的工作原理
(1)编码电机电路板原理图如下图所示。
①最右端的6个引脚,1与6直接连到电机两端,中间的引脚则属于编码器部分(编码器和电机是两个独立的电路)。
②最左边的是指示灯电路,只要接通电源,LED便被点亮。
③中间两部分是霍尔传感器,它们的信号分别通过EA和EB输出,以下以YS2402型号的霍尔传感器为例进行介绍。
(2)霍尔传感器有很多种类,比如线性型、开关型、单极型、双极型(可区分磁铁的南北极)、锁存型(磁场消失时输出信号可维持先前的状态)、非锁存型等等,本实验使用的是双极锁存型霍尔传感器,如下图所示。
①信号处理电路中包含施密特触发器和迟滞比较器。
②输出驱动部分是一个开漏输出电路,MOS管导通则输出为低电平,OUT引脚输出被强制拉低,MOS管断开则输出为高阻态,OUT引脚输出通过上拉电阻连接VCC,被VCC拉高。
③当磁铁为S极,且强度越过BOP时,OUT信号突变为低电平;当磁铁为S极,且强度越过BRP时,OUT信号突变为高电平;其它情况均保持上一时刻的状态。(由磁滞比较器电路实现)
④当磁铁S极正对霍尔传感器时,OUT输出低电平;当磁铁N极正对霍尔传感器时,OUT输出高电平。
(3)编码电机中有一块跟随电机旋转的磁铁——多级径向磁铁,“多级”对应磁铁有多个N极和S极,“径向”对应N极和S极是朝直径方向分布的,磁铁的级数可以定值,级数越高,编码器分辨率越高,下图时22极型号的多级径向磁铁。
(4)电机带着磁铁旋转,磁铁的N极和S极会分别轮流正对霍尔传感器,这样A相和B相就能各生成一串正交脉冲波形(A相和B相相位相差90°),霍尔传感器的放置位置有如下讲究,当径向磁铁有一极正对霍尔传感器时,另一个霍尔传感器会正对径向磁铁的N-S极分界,如下图所示(一般一对霍尔传感器都以磁铁为圆心呈90°分布,当然,不一定必须要90°,满足讲究的条件即可)。
3、基础驱动代码移植与编写
(1)拷贝一份STM32教程中“使用OLED屏进行显示”的工程文件夹,并更名为“基础驱动代码”。
(2)OLED模块的移植:
①在STM32教程中的OLED屏篇找到4针脚OLED屏的示例程序(采用UTF-8编码格式),拷贝其中的OLED.c、OLED.h、OLED_Data.c、OLED_Data.h文件,将其添加进“基础驱动代码”工程中(后续将其简称为本实验工程,具体视语境判断),在此之前先移除旧的OLED.c和OLED.h文件,这是因为这两个文件的功能不能够满足本实验的现象展示。
②点击下图红色箭头指示的按钮,进入“C/C++”选项卡,在下图红框所示的位置输入“--no-multibyte-chars”。
(3)测试LED模块的移植:
①由于本实验只有开发板上的测试LED,故需更改LED.c中的函数实现,将原本PA1及PA2的配置和操作函数同步用PC13也写一次,同时移除PA1及PA2的配置和操作函数。
[1]测试LED的初始化:
/**
* 函 数:LED初始化
* 参 数:无
* 返 回 值:无
*/
void LED_Init(void)
{
/*开启时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE);//开启GPIOC时钟
/*GPIO初始化*/
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_13;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOC, &GPIO_InitStructure); //将PC13引脚初始化为推挽输出
/*设置GPIO初始化后的默认电平*/
GPIO_SetBits(GPIOC, GPIO_Pin_13); //设置PC13引脚为高电平
}
[2]测试LED亮灯:
/**
* 函 数:LED开启
* 参 数:无
* 返 回 值:无
*/
void LED_ON(void)
{
GPIO_ResetBits(GPIOC, GPIO_Pin_13); //设置PC13引脚为低电平
}
[3]测试LED灭灯:
/**
* 函 数:LED关闭
* 参 数:无
* 返 回 值:无
*/
void LED_OFF(void)
{
GPIO_SetBits(GPIOC, GPIO_Pin_13); //设置PC13引脚为高电平
}
[4]测试LED状态翻转:
/**
* 函 数:LED状态翻转
* 参 数:无
* 返 回 值:无
*/
void LED_Turn(void)
{
if (GPIO_ReadOutputDataBit(GPIOC, GPIO_Pin_13) == 0) //获取输出寄存器的状态,如果当前引脚输出低电平
{
GPIO_SetBits(GPIOC, GPIO_Pin_13); //则设置PC13引脚为高电平
}
else //否则,即当前引脚输出高电平
{
GPIO_ResetBits(GPIOC, GPIO_Pin_13); //则设置PC13引脚为低电平
}
}
②LED.h文件中的函数声明也需修改。
#ifndef __LED_H
#define __LED_H
void LED_Init(void);
void LED_ON(void);
void LED_OFF(void);
void LED_Turn(void);
#endif
(4)定时器模块的移植:
①在STM32教程中的定时器篇找到“定时器定时中断”实验工程,拷贝其中的Timer.h、Timer.c文件,将其添加进本实验工程中。
②在本实验中,定时中断使用TIM1定时器实现,故Timer中原TIM2的配置需迁移到TIM1上,同时配置TIM1的定时时间为1ms。
[1]定时器初始化函数:
void Timer_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB2Periph_TIM1, ENABLE);
TIM_InternalClockConfig(TIM1);
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1;
TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInitStructure.TIM_Period = 10 - 1; //重装载值
TIM_TimeBaseInitStructure.TIM_Prescaler = 7200 - 1; //预分频系数
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM1, &TIM_TimeBaseInitStructure);
TIM_ClearFlag(TIM1, TIM_FLAG_Update);
TIM_ITConfig(TIM1, TIM_IT_Update, ENABLE);
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = TIM1_UP_IRQn; //使用更新中断
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
NVIC_Init(&NVIC_InitStructure);
TIM_Cmd(TIM1, ENABLE);
}
[2]定时中断函数模板:
/*
void TIM1_UP_IRQHandler(void)
{
if (TIM_GetITStatus(TIM1, TIM_IT_Update) == SET)
{
TIM_ClearITPendingBit(TIM1, TIM_IT_Update);
}
}
*/
(5)按键模块的移植:
①在编程技巧教程中“按键控制LED灯闪烁模式”章节的实验工程里找到Key.c和Key.h文件并拷贝,将其添加进本实验工程中,在此之前先移除旧的Key.c和Key.h文件。
②本实验的硬件环境有4个按键连接了STM32开发板的4个GPIO引脚,因此需分别对这4个引脚(或者说4个按键)进行初始化。
void Key_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
/* 初始化PB10、PB11 */
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
GPIO_Init(GPIOB, &GPIO_InitStructure);
/* 初始化PA11、PA12 */
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1 | GPIO_Pin_11;
GPIO_Init(GPIOA, &GPIO_InitStructure);
}
③更改获取按键键码的函数。
uint8_t Key_GetState(void)
{
if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_10) == 0)
return 1; //按键1按下,返回键码1
if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11) == 0)
return 2; //按键2按下,返回键码2
if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_11) == 0)
return 3; //按键3按下,返回键码3
if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_12) == 0)
return 4; //按键4按下,返回键码4
return 0; //无按键按下,返回键码0
}
(6)AD模块(电位器旋钮模块要用到模数转换)的移植:
①在STM教程中“AD多通道”的实验工程里找到AD.c和AD.h文件,更名为RP.c和RP.h,将其添加进本实验工程中。
②本实验计划使用ADC2实现电位器旋钮的模数转换,于是需要更改RP.c中的代码,同时RP.h可能也要做出相应更改。
[1]初始化部分:
void RP_Init(void)
{
/*开启时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC2, ENABLE); //开启ADC2时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);//开启GPIOA时钟
/*设置ADC时钟*/
RCC_ADCCLKConfig(RCC_PCLK2_Div6); //选择时钟6分频,ADCCLK = 72MHz / 6 = 12MHz
/*GPIO初始化*/
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4 |GPIO_Pin_5;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure); //将PA2、PA3、PA4和PA5引脚初始化为模拟输入
/*ADC初始化*/
ADC_InitTypeDef ADC_InitStructure; //定义结构体变量
ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; //模式,选择独立模式,即单独使用ADC1
ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; //数据对齐,选择右对齐
ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; //外部触发,使用软件触发,不需要外部触发
ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; //连续转换,失能,每转换一次规则组序列后停止
ADC_InitStructure.ADC_ScanConvMode = DISABLE; //扫描模式,失能,只转换规则组的序列1这一个位置
ADC_InitStructure.ADC_NbrOfChannel = 1; //通道数,为1,仅在扫描模式下,才需要指定大于1的数,在非扫描模式下,只能是1
ADC_Init(ADC2, &ADC_InitStructure); //将结构体变量交给ADC_Init,配置ADC2
/*ADC使能*/
ADC_Cmd(ADC2, ENABLE); //使能ADC1,ADC开始运行
/*ADC校准*/
ADC_ResetCalibration(ADC2); //固定流程,内部有电路会自动执行校准
while (ADC_GetResetCalibrationStatus(ADC2) == SET);
ADC_StartCalibration(ADC2);
while (ADC_GetCalibrationStatus(ADC2) == SET);
}
[2]获取相应电位器旋钮(或者说相应通道)模数转换结果的函数:
uint16_t RP_GetValue(uint8_t n)
{
if(n == 1) //如为1,返回通道1的模拟值
ADC_RegularChannelConfig(ADC2, ADC_Channel_2, 1, ADC_SampleTime_55Cycles5);
else if(n == 2) //如为2,返回通道2的模拟值
ADC_RegularChannelConfig(ADC2, ADC_Channel_3, 1, ADC_SampleTime_55Cycles5);
else if(n == 3) //如为3,返回通道3的模拟值
ADC_RegularChannelConfig(ADC2, ADC_Channel_4, 1, ADC_SampleTime_55Cycles5);
else if(n == 4) //如为4,返回通道4的模拟值
ADC_RegularChannelConfig(ADC2, ADC_Channel_5, 1, ADC_SampleTime_55Cycles5);
ADC_SoftwareStartConvCmd(ADC2, ENABLE); //软件触发AD转换一次
while (ADC_GetFlagStatus(ADC2, ADC_FLAG_EOC) == RESET); //等待EOC标志位,即等待AD转换结束
return ADC_GetConversionValue(ADC2); //读数据寄存器,得到AD转换的结果
}
[3]由于函数名和文件名也有变更,故RP.h文件也要做相应修改。
#ifndef __RP_H
#define __RP_H
void RP_Init(void);
uint16_t RP_GetValue(uint8_t ADC_Channel);
#endif
(7)直流电机模块的移植:
①在STM32教程中的定时器篇找到驱动直流电机的工程,拷贝其中的Motor.c、Motor.h、PWM.c、PWM.h文件,将其添加进本实验工程中。
②计划使用TIM2的通道1输出PWM波驱动M3电机,那么PWM输出模块需要做更改,主要是更改配置的通道以及函数名(头文件也需同步做修改,此处不再赘述)。
[1]初始化函数:
void PWM_Init(void)
{
/*开启时钟*/
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); //开启TIM2时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);//开启GPIOA时钟
/*GPIO初始化*/
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure); //将PA0引脚初始化为复用推挽输出
//TIM2算是片上外设,受外设控制的引脚,均需要配置为复用模式
/*配置时钟源*/
TIM_InternalClockConfig(TIM2); //选择TIM2为内部时钟
/*时基单元初始化*/
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure; //定义结构体变量
TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1; //选择不分频
TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up; //向上计数
TIM_TimeBaseInitStructure.TIM_Period = 100 - 1; //计数周期,即ARR的值
TIM_TimeBaseInitStructure.TIM_Prescaler = 36 - 1; //预分频器,即PSC的值
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0; //高级定时器才会用到
TIM_TimeBaseInit(TIM2, &TIM_TimeBaseInitStructure); //将结构体变量交给TIM_TimeBaseInit,配置TIM2的时基单元
/*输出比较初始化*/
TIM_OCInitTypeDef TIM_OCInitStructure; //定义结构体变量
TIM_OCStructInit(&TIM_OCInitStructure); //结构体初始化,若结构体没有完整赋值
//则最好执行此函数,给结构体所有成员都赋一个默认值
//避免结构体初值不确定的问题
TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; //输出比较模式,选择PWM模式1
TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; //输出极性,选择为高,若选择极性为低,则输出高低电平取反
TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; //输出使能
TIM_OCInitStructure.TIM_Pulse = 0; //初始的CCR值
TIM_OC1Init(TIM2, &TIM_OCInitStructure); //将结构体变量交给TIM_OC1Init,配置TIM2的输出比较通道1
/*TIM使能*/
TIM_Cmd(TIM2, ENABLE); //使能TIM2,定时器开始运行
}
[2]设置比较值函数:
void PWM_SetCompare1(uint16_t Compare)
{
TIM_SetCompare1(TIM2, Compare); //设置CCR1的值
}
③根据原理图的连接方式,电机M3间接由STM32的PB12和PB13引脚控制转动方向,故电机模块也需做更改,主要是更改配置的GPIO口以及设置转速函数(原本的设置转速函数是开环的,它实际上只设置PWM波,为避免与闭环控速冲突,需将其名字和参数变更,头文件也需同步做修改,此处不再赘述)。
[1]初始化函数:
void Motor_Init(void)
{
/*开启时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);//开启GPIOB时钟
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12 | GPIO_Pin_13;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure); //将PB12和PB13引脚初始化为推挽输出
PWM_Init(); //初始化直流电机的底层PWM
}
[2]设置PWM波函数:
void Motor_SetPWM(int8_t PWM)
{
if (PWM >= 0) //如果设置正转的速度值
{
GPIO_SetBits(GPIOB, GPIO_Pin_12); //PB12置高电平
GPIO_ResetBits(GPIOB, GPIO_Pin_13); //PB13置低电平,设置方向为正转
PWM_SetCompare1(PWM); //PWM设置为速度值
}
else //否则,即设置反转的速度值
{
GPIO_ResetBits(GPIOB, GPIO_Pin_12); //PB12置低电平
GPIO_SetBits(GPIOB, GPIO_Pin_13); //PB13置高电平,设置方向为反转
PWM_SetCompare1(-PWM); //PWM设置为负的速度值,因为此时速度值为负数,而PWM只能给正数
}
}
(8)编码器模块的移植:
①在STM32教程中的定时器篇找到编码器接口测速的工程,拷贝其中的Encoder.c、Encoder.h文件,将其添加进本实验工程中。
②STM32对M3进行信号接收使用的编码器接口是PA6和PA7,源文件原本对编码器接口的配置也是如此,故不需要更改。
③定义电机顺时针转动为正向(速度为正值),逆时针转动为反向(速度为负值),于是编码器接口通道2需要配置为反相,如果定义与配置冲突,在PID调控时会出现目标速度与实际速度异号的情况,从而引发正反馈导致电机失控。
void Encoder_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); //开启TIM3时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);//开启GPIOA时钟
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure); //将PA6和PA7引脚初始化为上拉输入
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1;
TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInitStructure.TIM_Period = 65536 - 1;
TIM_TimeBaseInitStructure.TIM_Prescaler = 1 - 1;
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM3, &TIM_TimeBaseInitStructure);
/*输入捕获初始化*/
TIM_ICInitTypeDef TIM_ICInitStructure; //定义结构体变量
TIM_ICStructInit(&TIM_ICInitStructure);
TIM_ICInitStructure.TIM_Channel = TIM_Channel_1; //选择配置定时器通道1
TIM_ICInitStructure.TIM_ICFilter = 0xF;
TIM_ICInit(TIM3, &TIM_ICInitStructure);
TIM_ICInitStructure.TIM_Channel = TIM_Channel_2; //选择配置定时器通道2
TIM_ICInitStructure.TIM_ICFilter = 0xF;
TIM_ICInit(TIM3, &TIM_ICInitStructure);
/*编码器接口配置*/
TIM_EncoderInterfaceConfig(TIM3, TIM_EncoderMode_TI12, TIM_ICPolarity_Rising, TIM_ICPolarity_Falling);
//配置编码器模式以及两个输入通道是否反相
//注意此时参数的Rising和Falling不代表上升沿和下降沿,而是代表是否反相
//此函数须在输入捕获初始化后进行,否则输入捕获的配置会覆盖此函数的部分配置
TIM_Cmd(TIM3, ENABLE); //使能TIM3,定时器开始运行
}
(9)串口模块的移植:
①在STM32教程中的串口篇找到串口发送+接收的工程,拷贝其中的Serial.c、Serial.h文件,将其添加进本实验工程中。
②串口与STM32开发板的连接与STM32教程中的一致,故不需要做任何修改,不过值得注意的是,本实验在调试时需要使用SerialPlot接收STM32发来的数据串,数据串中的各个数据用逗号分隔,SerialPlot会将各个数据以图象的形式呈现,横坐标为程序运行时间(SerialPlot的使用比较简单,和一般的串口助手有异曲同工之妙,这里不再赘述)。
四、PID闭环控制实验
1、位置式PID定速控制实验
(1)复制一份“基础驱动代码”的工程,并更名为“位置式PID定速控制”。
(2)在主函数中写入以下代码,主要执行模块初始化、PID系数和目标值调节以及显示数据等操作。
uint8_t KeyNum; int16_t PWM, Speed;
float Target, Actual, Out; //位置式PID控制中的目标值、实际值、输出值
float Kp = 0.3, Ki = 0.2, Kd = 0; //位置式PID控制中的比例系数、积分系数、微分系数
float Error0, Error1, ErrorInt; //位置式PID控制中的本次误差、上次误差和总误差
int main(void)
{
/*模块初始化*/
OLED_Init(); Key_Init(); Motor_Init(); Encoder_Init();
Timer_Init(); RP_Init(); Serial_Init();
OLED_Printf(0, 0, OLED_8X16, "Speed Control");
while (1)
{
KeyNum = Key_GetNum(); //获取按键事件标志位
/* 按键控制目标转速
if(KeyNum == 1) Target += 10; //正向加速/反向减速
if(KeyNum == 2) Target -= 10; //正向减速/反向加速
if(KeyNum == 3) Target = 0; //电机停转
*/
Kp = RP_GetValue(1) / 4095.0 * 2; //读取RP1的值赋给Kp,取值范围为[0,2]
Ki = RP_GetValue(2) / 4095.0 * 2; //读取RP2的值赋给Ki,取值范围为[0,2]
Kd = RP_GetValue(3) / 4095.0 * 2; //读取RP3的值赋给Kd,取值范围为[0,2]
Target = RP_GetValue(4) / 4095.0 * 300 - 150; //读取RP4的值赋给Target,取值范围为[-150,150]
OLED_Printf(0, 16, OLED_8X16, "Kp:%+4.2f", Kp);
OLED_Printf(0, 32, OLED_8X16, "Ki:%+4.2f", Ki);
OLED_Printf(0, 48, OLED_8X16, "Kd:%+4.2f", Kd);
OLED_Printf(64, 16, OLED_8X16, "Tar:%+04.0f", Target);
OLED_Printf(64, 32, OLED_8X16, "Act:%+04.0f", Actual);
OLED_Printf(64, 48, OLED_8X16, "Out:%+04.0f", Out);
OLED_Update(); //更新显示
Serial_Printf("%f,%f,%f\r\n", Target, Actual, Out); //将PID参数发送至串口
}
}
(3)在TIM1定时中断函数中写入以下代码,主要执行增量采集(或者说速度采集,周期最好与PID调控周期保持一致)和PID调速的操作。
void TIM1_UP_IRQHandler(void)
{
static uint16_t Count;
if (TIM_GetITStatus(TIM1, TIM_IT_Update) == SET)
{
Key_Tick(); Count++;
if(Count >= 40) //每40ms获取一次增量值,并进行一次PID调控
{
Count = 0;
Speed = Encoder_Get();
//获取增量的周期最好与PID调控周期保持一致
//长于PID调控周期,精度虽然更高,但PID效果变差
//短于PID调控周期,刷新频率虽然更高,但对PID控制没有意义
/* PID调控 */
{
/* 获取实际速度值 */
Actual = Speed;
/* 获取本次误差和上次误差 */
Error1 = Error0; //获取上次误差
Error0 = Target - Actual; //获取本次误差
/* 误差积分(累加) */
if(fabs(Ki) > 0.0001) ErrorInt += Error0; //防止Ki=0时有积分饱和现象
else ErrorInt = 0;
/* PID计算 */
Out = Kp * Error0 + Ki * ErrorInt + Kd * (Error0 - Error1);
/* 输出限幅 */
if(Out > 100) Out = 100;
if(Out < -100) Out = -100;
/* 执行控制 */
Motor_SetPWM(Out); //借助PWM波给电机施加驱动力
}
}
TIM_ClearITPendingBit(TIM1, TIM_IT_Update);
}
}
(4)基础逻辑完成后,程序可下载到开发板上进行调试,当然,在此之前可以大概预设一下PID的三个系数,系数K预设取值的数量级可由[Out的最值]/[Actual的最值]得出。(其实在程序中更改预设值没有什么意义,主要是电位器旋钮需要旋转到对应的位置上)
(5)使用串口将开发板与电脑连接,打开SerialPlot。首先旋动第四个电位器旋钮,它会更改电机的目标速度值(Target),通过SerialPlot观察电机实际速度(Actual)跟随目标速度的情况,判断当前的P系数是否合适。旋动第一个电位器旋钮可对Kp进行微调,旋动第二个电位器旋钮可对Ki进行微调,旋动第三个电位器旋钮可对Kd进行微调(如果仅调节前两个参数,电机转速没有超调现象,那么就不需要调节Kd)。
2、增量式PID定速控制实验
(1)复制一份“位置式PID定速控制”的工程,并更名为“增量式PID定速控制”。
(2)更改全局变量的命名,即ErrorInt→Error2,Error2的意义是PID控制的上上次误差。
(3)更改PID调控部分的代码实现。
/* PID调控 */
{
/* 获取实际速度值 */
Actual = Speed;
/* 获取本次误差、上次误差和上上次误差 */
Error2 = Error1; //获取上上次误差
Error1 = Error0; //获取上次误差
Error0 = Target - Actual; //获取本次误差
/* PID计算(增量计算出来直接累加到Out上) */
Out += Kp * (Error0 - Error1)
+ Ki * Error0
+ Kd * (Error0 - 2 * Error1 + Error2);
/* 输出限幅 */
if(Out > 100) Out = 100;
if(Out < -100) Out = -100;
/* 执行控制 */
Motor_SetPWM(Out); //借助PWM波给电机施加驱动力
}
(4)基础逻辑完成后,程序可下载到开发板上进行调试,使用串口将开发板与电脑连接,打开SerialPlot。首先旋动第四个电位器旋钮,它会更改电机的目标速度值(Target),通过SerialPlot观察电机实际速度(Actual)跟随目标速度的情况,判断当前的P系数是否合适。旋动第一个电位器旋钮可对Kp进行微调,旋动第二个电位器旋钮可对Ki进行微调,旋动第三个电位器旋钮可对Kd进行微调(如果仅调节前两个参数,电机转速没有超调现象,那么就不需要调节Kd)。
3、位置式PID定位置控制实验
(1)复制一份“位置式PID定速控制”的工程,并更名为“位置式PID定位置控制”。
(2)更改全局变量的命名,即Speed→Location,Location的意义是电机的位置。
(3)预设的Ki参数更改为0,这是因为如果在控制时引入I项,极有可能会造成抖动,当然,I项也并不是毫无用处,当施加一个外力改变电机的位置时,I项可以将偏差不断累积,最终生成一个较大的驱动力将电机复位。(其实程序中更改预设值没有什么意义,主要是电位器旋钮需要旋转到对应的位置上)
uint8_t KeyNum;
int16_t PWM;
int16_t Location;
float Target, Actual, Out; //位置式PID控制中的目标值、实际值、输出值
float Kp = 0.4, Ki = 0, Kd = 0.2; //位置式PID控制中的比例系数、积分系数、微分系数
float Error0, Error1, ErrorInt; //位置式PID控制中的本次误差、上次误差和总误差
(4)电机位置的取值范围为[-408,408],主函数中RP4的映射区间需要做相应修改。
Kp = RP_GetValue(1) / 4095.0 * 2; //读取RP1的值赋给Kp,取值范围为[0,2]
Ki = RP_GetValue(2) / 4095.0 * 2; //读取RP2的值赋给Ki,取值范围为[0,2]
Kd = RP_GetValue(3) / 4095.0 * 2; //读取RP3的值赋给Kd,取值范围为[0,2]
Target = RP_GetValue(4) / 4095.0 * 816 - 408; //读取RP4的值赋给Target,取值范围为[-408,408]
(5)更改TIM1定时中断函数,主要是Speed→Location,且由于是控制位置,那么编码器接口获取的增量可以直接累加到Location上。
void TIM1_UP_IRQHandler(void)
{
static uint16_t Count;
if (TIM_GetITStatus(TIM1, TIM_IT_Update) == SET)
{
Key_Tick();
Count++;
if(Count >= 40) //每40ms获取一次增量值,并进行一次PID调控
{
Count = 0;
Location += Encoder_Get();
/* PID调控 */
{
/* 获取实际位置值 */
Actual = Location;
/* 获取本次误差和上次误差 */
Error1 = Error0; //获取上次误差
Error0 = Target - Actual; //获取本次误差
/* 误差积分(累加) */
if(fabs(Ki) > 0.0001) //防止Ki为0时出现积分饱和现象
ErrorInt += Error0;
else
ErrorInt = 0;
/* PID计算 */
Out = Kp * Error0 + Ki * ErrorInt + Kd * (Error0 - Error1);
/* 输出限幅 */
if(Out > 100) Out = 100;
if(Out < -100) Out = -100;
/* 执行控制 */
Motor_SetPWM(Out); //借助PWM波给电机施加驱动力
}
}
TIM_ClearITPendingBit(TIM1, TIM_IT_Update);
}
}
(6)基础逻辑完成后,程序可下载到开发板上进行调试,使用串口将开发板与电脑连接,打开SerialPlot。首先旋动第四个电位器旋钮,它会更改电机的目标位置值(Target),通过SerialPlot观察电机实际位置(Actual)跟随目标位置的情况,判断当前的P系数是否合适。旋动第一个电位器旋钮可对Kp进行微调,旋动第二个电位器旋钮可对Ki进行微调,旋动第三个电位器旋钮可对Kd进行微调(引入D项可以减小输出过冲,即在实际位置靠近目标位置时驱动力输出不稳定;如果仅调节Kp和Kd两个参数,电机响应的效果就已经比较理想,那么就不需要引入I项)。
4、增量式PID定位置控制实验
(1)复制一份“增量式PID定速控制”的工程,并更名为“增量式PID定位置控制”。
(2)更改全局变量的命名,即Speed→Location,Location的意义是电机的位置。
(3)预设的Ki参数更改为0.1,如果在控制时不引入I项,频繁大幅度变更Target时可能会出现Target和Actual无法重合(程度不能容忍)的情况,这是因为增量在某次计算时可能会受到噪声干扰,而增量式PID如不引入I项很难消除这种历史累积的误差,使得误差一直保留,亦或者是电机受到外力堵转,没有I项的误差累积,Out输出的驱动力也就不能持续增加,很可能无法与外力抗衡。(其实程序中更改预设值没有什么意义,主要是电位器旋钮需要旋转到对应的位置上)
uint8_t KeyNum;
int16_t PWM;
int16_t Location;
float Target, Actual, Out; //位置式PID控制中的目标值、实际值、输出值
float Kp = 0.4, Ki = 0.1, Kd = 0.2; //位置式PID控制中的比例系数、积分系数、微分系数
float Error0, Error1, Error2; //位置式PID控制中的本次误差、上次误差和上上次误差
(4)电机位置的取值范围为[-408,408],主函数中RP4的映射区间需要做相应修改。
Kp = RP_GetValue(1) / 4095.0 * 2; //读取RP1的值赋给Kp,取值范围为[0,2]
Ki = RP_GetValue(2) / 4095.0 * 2; //读取RP2的值赋给Ki,取值范围为[0,2]
Kd = RP_GetValue(3) / 4095.0 * 2; //读取RP3的值赋给Kd,取值范围为[0,2]
Target = RP_GetValue(4) / 4095.0 * 816 - 408; //读取RP4的值赋给Target,取值范围为[-408,408]
(5)更改TIM1定时中断函数,主要是Speed→Location,且由于是控制位置,那么编码器接口获取的增量可以直接累加到Location上。
void TIM1_UP_IRQHandler(void)
{
static uint16_t Count;
if (TIM_GetITStatus(TIM1, TIM_IT_Update) == SET)
{
Key_Tick();
Count++;
if(Count >= 40) //每40ms获取一次增量值,并进行一次PID调控
{
Count = 0;
Location += Encoder_Get();
/* PID调控 */
{
/* 获取实际位置值 */
Actual = Location;
/* 获取本次误差、上次误差和上上次误差 */
Error2 = Error1; //获取上上次误差
Error1 = Error0; //获取上次误差
Error0 = Target - Actual; //获取本次误差
/* PID计算 */
Out += Kp * (Error0 - Error1)
+ Ki * Error0
+ Kd * (Error0 - 2 * Error1 + Error2);
/* 输出限幅 */
if(Out > 100) Out = 100;
if(Out < -100) Out = -100;
/* 执行控制 */
Motor_SetPWM(Out); //借助PWM波给电机施加驱动力
}
}
TIM_ClearITPendingBit(TIM1, TIM_IT_Update);
}
}
(6)基础逻辑完成后,程序可下载到开发板上进行调试,使用串口将开发板与电脑连接,打开SerialPlot。首先旋动第四个电位器旋钮,它会更改电机的目标位置值(Target),通过SerialPlot观察电机实际位置(Actual)跟随目标位置的情况,判断当前的P系数是否合适。旋动第一个电位器旋钮可对Kp进行微调,旋动第二个电位器旋钮可对Ki进行微调,旋动第三个电位器旋钮可对Kd进行微调(引入D项可以减小输出过冲,即在实际位置靠近目标位置时驱动力输出不稳定)。
五、PID算法改进(基于位置式PID介绍)
1、改进方法概览
(1)积分限幅:限制积分的幅度,防止积分深度饱和。
(2)积分分离:误差小于一个限度才开始积分,反之则去掉积分部分。
(3)变速积分:根据误差的大小调整积分的速度。
(4)不完全微分:给微分项加入一阶惯性单元(低通滤波器)。
(5)微分先行:将对误差的微分替换为对实际值的微分。
(6)输出偏移:在非0输出时,给输出值加一个固定偏移。
(7)输入死区:误差小于一个限度时不进行调控。
2、积分限幅
(1)积分限幅可解决的问题:如果执行器因为卡住、断电、损坏等原因不能消除误差,则误差积分会无限制加大,进而达到深度饱和状态,此时PID控制器会持续输出最大的调控力,即使后续执行器恢复正常,PID控制器在短时间内也会维持最大的调控力,直到误差积分从深度饱和状态退出,这在某些情况下是非常危险的。
(2)积分限幅实现思路:对误差积分或积分项输出进行判断,如果幅值超过指定阈值,则进行限制。(增量式PID的积分环节可认为并在了Out上,Out在输出时本身就有限制,所以积分饱和一般只会出现在位置式PID中)
(3)复制一份“位置式PID定速控制”的工程,并更名为“位置式PID定速控制 - 积分限幅”,并更改PID调控部分的代码实现,其中积分限幅的上下限可由[输出最值]/[Ki]得出。
/* PID调控 */
{
/* 获取实际速度值 */
Actual = Speed;
/* 获取本次误差和上次误差 */
Error1 = Error0; //获取上次误差
Error0 = Target - Actual; //获取本次误差
/* 误差积分(累加) */
ErrorInt += Error0;
/* 积分限幅 */
if(ErrorInt > 100 / Ki) Out = 100;
if(ErrorInt < -100 / Ki) Out = -100;
/* PID计算 */
Out = Kp * Error0 + Ki * ErrorInt + Kd * (Error0 - Error1);
/* 输出限幅 */
if(Out > 100) Out = 100;
if(Out < -100) Out = -100;
/* 执行控制 */
Motor_SetPWM(Out); //借助PWM波给电机施加驱动力
}
3、积分分离
(1)积分分离可解决的问题:积分项作用一般位于调控后期,用来消除持续的误差,调控前期一般误差较大且不需要积分项作用,如果此时仍然进行积分,则调控进行到后期时,积分项可能已经累积了过大的调控力,这会导致超调。(定速控制因为到达目标转速后电机还需要驱动力对抗转动阻力,I项引发的Out一般不会导致超调,而定位置控制达到目标位置后电机是不需要驱动力的,而I项的误差累积还没有消除,于是Out不为0,从而导致超调)
(2)积分分离实现思路:对误差大小进行判断,如果误差绝对值小于指定阈值则加入积分项作用,反之,则直接将误差积分清零或不加入积分项作用。
(3)复制一份“位置式PID定位置控制”的工程,并更名为“位置式PID定位置控制 - 积分分离”,并更改PID调控部分的代码实现,其中阈值可取最终误差正常波动的幅度,再留些余量(积分分离可能不能完全抑制超调,但也会将超调抑制到能够容忍的程度)。
/* PID调控 */
{
/* 获取实际位置值 */
Actual = Location;
/* 获取本次误差和上次误差 */
Error1 = Error0; //获取上次误差
Error0 = Target - Actual; //获取本次误差
/* 误差积分 + 积分分离 */
if(fabs(Error0) < 50) //阈值可取最终误差正常波动的幅度,再留些余量
ErrorInt += Error0;
else
ErrorInt = 0;
/* PID计算 */
Out = Kp * Error0 + Ki * ErrorInt + Kd * (Error0 - Error1);
/* 输出限幅 */
if(Out > 100) Out = 100;
if(Out < -100) Out = -100;
/* 执行控制 */
Motor_SetPWM(Out); //借助PWM波给电机施加驱动力
}
4、变速积分
(1)变速积分可解决的问题:如果积分分离阈值没有设定好,被控对象正好在阈值之外停下来,则此时控制器完全没有积分作用,误差不能消除。
(2)变速积分实现思路:变速积分是积分分离的升级版,变速积分需要设计一个函数值随误差绝对值增大而减小的函数,函数值作为调整系数,用于调整误差积分的速度或积分项作用的强度。
(3)复制一份“位置式PID定位置控制 - 积分分离”的工程,并更名为“位置式PID定位置控制 - 变速积分”,并更改PID调控部分的代码实现。
/* PID调控 */
{
/* 获取实际位置值 */
Actual = Location;
/* 获取本次误差和上次误差 */
Error1 = Error0; //获取上次误差
Error0 = Target - Actual; //获取本次误差
/* 变速积分 */
float C = 1 / (0.2 * fabs(Error0) + 1);
/* 误差积分 + 积分分离 */
if(fabs(Error0) < 50) //阈值可取最终误差正常波动的幅度,再留些余量
ErrorInt += C * Error0;
else
ErrorInt = 0;
/* PID计算 */
Out = Kp * Error0 + Ki * ErrorInt + Kd * (Error0 - Error1);
/* 输出限幅 */
if(Out > 100) Out = 100;
if(Out < -100) Out = -100;
/* 执行控制 */
Motor_SetPWM(Out); //借助PWM波给电机施加驱动力
}
5、微分先行
(1)微分先行可解决的问题:普通PID的微分项对误差进行微分,当目标值大幅度跳变时,误差也会瞬间大幅度跳变,这会导致微分项突然输出一个很大的调控力(对误差曲线进行求导,可发现误差在突变的一刻会输出一个接近正无穷大的正向调控力,紧接着又会输出一个很大的负向调控力),如果系统的目标值频繁大幅度切换,则此时的微分项可能会不利于系统稳定,具体需视情况而定。
(2)微分先行实现思路:将对误差的微分替换为对实际值的微分。(此方法会使得实际值跟踪目标值变得缓慢)
(3)复制一份“位置式PID定位置控制”的工程,并更名为“位置式PID定位置控制 - 微分先行”。
①增加两个全局变量,分别记录位置式PID控制中的微分项的输出、上次的实际值。
float Target, Actual, Out; //位置式PID控制中的目标值、实际值、输出值
float Kp = 0.4, Ki = 0, Kd = 0.2; //位置式PID控制中的比例系数、积分系数、微分系数
float Error0, Error1, ErrorInt; //位置式PID控制中的本次误差、上次误差和总误差
float DifOut, Actual1; //位置式PID控制中的微分项的输出、上次的实际值
②更改串口发送的数据串,在末尾增加微分项结果的数值。
Serial_Printf("%f,%f,%f,%f\r\n", Target, Actual, Out, DifOut); //将PID参数发送至串口
③更改PID调控部分的代码实现。
/* PID调控 */
{
/* 获取上次实际值和本次实际位置值 */
Actual1 = Actual1;
Actual = Location;
/* 获取本次误差和上次误差 */
Error1 = Error0; //获取上次误差
Error0 = Target - Actual; //获取本次误差
/* 误差积分(累加) */
if(fabs(Ki) > 0.0001) //防止Ki为0时出现积分饱和现象
ErrorInt += Error0;
else
ErrorInt = 0;
/* PID计算 */
// DifOut = Kd * (Error0 - Error1); 原PID微分项
DifOut = -Kd * (Actual - Actual1);
Out = Kp * Error0 + Ki * ErrorInt + DifOut;
/* 输出限幅 */
if(Out > 100) Out = 100;
if(Out < -100) Out = -100;
/* 执行控制 */
Motor_SetPWM(Out); //借助PWM波给电机施加驱动力
}
6、不完全微分
(1)不完全微分可解决的问题:传感器获取的实际值经常会受到噪声干扰,而PID控制器中的微分项对噪声最为敏感,这些噪声干扰可能会导致微分项输出抖动,进而影响系统性能。
(2)不完全微分实现思路:给微分项加入一阶惯性单元(低通滤波器),如下所示,其中α用于调节滤波强度,不完全微分PID的微分项等于[(1 - α ) * 本次输入 + α * 上次输出]。
(3)复制一份“位置式PID定位置控制”的工程,并更名为“位置式PID定位置控制 - 不完全微分”。
①增加两个全局变量,分别记录位置式PID控制中的微分项的输出、一阶滤波系数α。
float Target, Actual, Out; //位置式PID控制中的目标值、实际值、输出值
float Kp = 0.4, Ki = 0, Kd = 0.2; //位置式PID控制中的比例系数、积分系数、微分系数
float Error0, Error1, ErrorInt; //位置式PID控制中的本次误差、上次误差和总误差
float DifOut, a = 0.9; //位置式PID控制中的微分项的输出、一阶滤波系数α
②更改串口发送的数据串,在末尾增加微分项结果的数值。
Serial_Printf("%f,%f,%f,%f\r\n", Target, Actual, Out, DifOut); //将PID参数发送至串口
③引入头文件stdlib.h,需要使用rand函数模拟随机噪声。
#include "stdlib.h"
④更改PID调控部分的代码实现。
/* PID调控 */
{
/* 获取实际位置值 */
Actual = Location;
Actual += rand() % 41 - 20; //软件制造随机噪声
/* 获取本次误差和上次误差 */
Error1 = Error0; //获取上次误差
Error0 = Target - Actual; //获取本次误差
/* 误差积分(累加) */
if(fabs(Ki) > 0.0001) //防止Ki为0时出现积分饱和现象
ErrorInt += Error0;
else
ErrorInt = 0;
/* PID计算 */
// DifOut = Kd * (Error0 - Error1); 原PID微分项
DifOut = (1 - a) * Kd * (Error0 - Error1) + a * DifOut;
Out = Kp * Error0 + Ki * ErrorInt + DifOut;
/* 输出限幅 */
if(Out > 100) Out = 100;
if(Out < -100) Out = -100;
/* 执行控制 */
Motor_SetPWM(Out); //借助PWM波给电机施加驱动力
}
7、输出偏移
(1)输出偏移可解决的问题:对于一些启动需要一定力度的执行器,若输出值较小,执行器可能完全无动作,这可能会引起调控误差,同时会降低系统响应速度。
(2)输出偏移实现思路:若输出值为0,则正常输出0,不进行调控;若输出值非0,则给输出值加一个固定偏移,跳过执行器无动作的阶段。
(3)复制一份“位置式PID定位置控制”的工程,并更名为“位置式PID定位置控制 - 输出偏移 - 输入死区”,同时更改PID调控部分的代码实现。
/* PID调控 */
{
/* 获取实际位置值 */
Actual = Location;
/* 获取本次误差和上次误差 */
Error1 = Error0; //获取上次误差
Error0 = Target - Actual; //获取本次误差
/* 误差积分(累加) */
if(fabs(Ki) > 0.0001) //防止Ki为0时出现积分饱和现象
ErrorInt += Error0;
else
ErrorInt = 0;
/* PID计算 */
Out = Kp * Error0 + Ki * ErrorInt + Kd * (Error0 - Error1);
/* 输出偏移 */
if(Out > 0)
{
Out += 6; //Out>6时驱动力才能克服摩擦等阻力使电机转动
}
else if(Out <0)
{
Out -= 6; //Out<-6时驱动力才能克服摩擦等阻力使电机转动
}
else
{
Out = 0;
}
/* 输出限幅 */
if(Out > 100) Out = 100;
if(Out < -100) Out = -100;
/* 执行控制 */
Motor_SetPWM(Out); //借助PWM波给电机施加驱动力
}
8、输入死区
(1)输入死区可解决的问题:在某些系统中,输入的目标值或实际值有微小的噪声波动,或者系统有一定的滞后,这些情况可能会导致执行器在误差很小时频繁调控,不能最终稳定下来。
(2)输入死区实现思路:若误差绝对值小于一个限度,则固定输出0,不进行调控。
(3)基于上一个实验工程进行实验,上一个工程的实验现象并不完美,在电机转动到目标位置附近时抖动严重,这时可引入输入死区,更改PID调控部分的代码实现。
/* PID调控 */
{
/* 获取实际位置值 */
Actual = Location;
/* 获取本次误差和上次误差 */
Error1 = Error0; //获取上次误差
Error0 = Target - Actual; //获取本次误差
/* 输入死区 */
if(fabs(Error0) < 5) //设定死区阈值为5
{
Out = 0;
}
else
{
/* 误差积分(累加) */
if(fabs(Ki) > 0.0001) //防止Ki为0时出现积分饱和现象
ErrorInt += Error0;
else
ErrorInt = 0;
/* PID计算 */
Out = Kp * Error0 + Ki * ErrorInt + Kd * (Error0 - Error1);
/* 输出偏移 */
if(Out > 0)
Out += 6; //Out>6时驱动力才能克服摩擦等阻力使电机转动
else if(Out <0)
Out -= 6; //Out<-6时驱动力才能克服摩擦等阻力使电机转动
else
Out = 0;
}
/* 输出限幅 */
if(Out > 100) Out = 100;
if(Out < -100) Out = -100;
/* 执行控制 */
Motor_SetPWM(Out); //借助PWM波给电机施加驱动力
}
六、双环PID基本原理
1、多环串级PID
(1)单环PID只能对被控对象的一个物理量进行闭环控制,而当用户需要对被控对象的多个维度物理量(例如速度、位置、角度等)进行控制时,则需要多个PID控制环路,即多环PID,多个PID串级连接,也称作串级PID。
(2)多环PID相较于单环PID,功能上可以实现对更多物理量的控制,性能上可以使系统拥有更高的准确性、稳定性和响应速度。
(3)定位置控制单环PID与双环PID比较:
①定位置控制单环PID:
位置环PID(通常使用PD控制器)的任务是动态输出PWM波驱动电机,使得电机的实际位置贴合目标位置,仅对位置进行闭环。
②定位置控制双环PID:
速度环PID(通常使用PI控制器)的任务是通过改变输出的PWM波,使得电机的实际速度贴合目标速度,只要输入的目标速度不为0,由于积分项的作用,电机一定不会一直保持不动,完全不会存在启动死区的问题。
在明确速度环PID的作用以后,再将位置环PID的输出接到速度环PID的输入,只要目标位置与实际位置不相等,位置环PID的输出就不会为0,这样速度环PID的目标速度也不为0,由此驱动电机转动。
假设电机已经处于稳态,这时给电机一个正向的外力,这将导致实际位置偏离目标位置,位置环PID会输出一个负向的目标速度,同时电机受到正向力,实际速度为正,这将导致速度环PID输出一个很大的负向力抵抗外力,由此将电机维持在目标位置。
(4)多环PID的调参顺序一般是先调内环,再调外环,在调节内环参数时不能让外环工作。
2、双环PID的程序实现
(1)变量定义及目标位置预设:
(2)在定时中断函数中实现内外环PID,内外环PID可以有不同的调控周期(内环的调控周期不能大于外环,否则外环频繁调控对实际输出而言没有任何意义):
①Count1为内环PID的计数分频,每触发一次定时中断Count1自增,到达一定值后内环PID进行一次调控,同时Count1清零,接下来就是PID调控的基础代码。
②Count2为外环PID的计数分频,每触发一次定时中断Count2自增,到达一定值后外环PID进行一次调控,同时Count2清零,接下来就是PID调控的基础代码。
3、双环PID基础程序示例
(1)复制一份“位置式PID定速度控制”的工程,并更名为“双环PID定速度定位置控制”。
(2)为内外环的PID参数创建全局变量,并新增位置变量记录电机的位置。
int16_t Speed, Location;
float Inner_Target, Inner_Actual, Inner_Out; //内环环PID控制中的目标值、实际值、输出值
float Inner_Kp = 0.3, Inner_Ki = 0.3, Inner_Kd = 0; //内环环PID控制中的比例系数、积分系数、微分系数
float Inner_Error0, Inner_Error1, Inner_ErrorInt;
float Outer_Target, Outer_Actual, Outer_Out; //外环PID控制中的目标值、实际值、输出值
float Outer_Kp = 0.3, Outer_Ki = 0, Outer_Kd = 0.4; //外环PID控制中的比例系数、积分系数、微分系数
float Outer_Error0, Outer_Error1, Outer_ErrorInt;
(3)更改定时器中断服务函数的实现,在其中添加外环PID调控的代码。
void TIM1_UP_IRQHandler(void)
{
static uint16_t Count1,Count2;
if (TIM_GetITStatus(TIM1, TIM_IT_Update) == SET)
{
Key_Tick();
/* 内环PID调控 */
{
Count1++;
if(Count1 >= 40)
{
Count1 = 0;
/* 获取实际速度值和位置值 */
Speed = Encoder_Get();
Inner_Actual = Speed;
Location += Speed;
/* 获取本次误差和上次误差 */
Inner_Error1 = Inner_Error0; //获取上次误差
Inner_Error0 = Inner_Target - Inner_Actual; //获取本次误差
/* 误差积分(累加) */
if(fabs(Inner_Ki) > 0.0001) //防止Ki为0时出现积分饱和现象
Inner_ErrorInt += Inner_Error0;
else
Inner_ErrorInt = 0;
/* PID计算 */
Inner_Out = Inner_Kp * Inner_Error0
+ Inner_Ki * Inner_ErrorInt
+ Inner_Kd * (Inner_Error0 - Inner_Error1);
/* 输出限幅 */
if(Inner_Out > 100) Inner_Out = 100;
if(Inner_Out < -100) Inner_Out = -100;
/* 执行控制 */
Motor_SetPWM(Inner_Out); //借助PWM波给电机施加驱动力
}
}
/* 外环PID调控 */
{
Count2++;
if(Count2 >= 40)
{
Count2 = 0;
/* 获取实际位置值 */
Outer_Actual = Location;
/* 获取本次误差和上次误差 */
Outer_Error1 = Outer_Error0; //获取上次误差
Outer_Error0 = Outer_Target - Outer_Actual; //获取本次误差
/* 误差积分(累加) */
if(fabs(Outer_Ki) > 0.0001) //防止Ki为0时出现积分饱和现象
Outer_ErrorInt += Outer_Error0;
else
Outer_ErrorInt = 0;
/* PID计算 */
Outer_Out = Outer_Kp * Outer_Error0
+ Outer_Ki * Outer_ErrorInt
+ Outer_Kd * (Outer_Error0 - Outer_Error1);
/* 输出限幅(范围与Inner_Target,即目标速度值一致) */
if(Outer_Out > 150) Outer_Out = 150;
if(Outer_Out < -150) Outer_Out = -150;
/* 执行控制 */
Inner_Target = Outer_Out; //外环输出值作为内环输入值(调节内环参数时需注释)
}
}
TIM_ClearITPendingBit(TIM1, TIM_IT_Update);
}
}
(4)更改主函数的实现,在其中添加调节内环参数和外环参数的代码并注释,在需要调参时按照引导解除注释。
int main(void)
{
/*模块初始化*/
OLED_Init(); //OLED初始化
Key_Init(); //按键初始化
Motor_Init(); //电机初始化
Encoder_Init(); //编码器接口初始化
Timer_Init(); //定时器1初始化
RP_Init(); //电位器旋钮初始化
Serial_Init(); //串口初始化
OLED_Printf(0, 0, OLED_8X16, "2*PID Control");
OLED_Update(); //更新显示
while (1)
{
KeyNum = Key_GetNum(); //获取按键事件标志位
/* 按键控制目标转速
if(KeyNum == 1) Inner_Target += 10; //正向加速/反向减速
if(KeyNum == 2) Inner_Target -= 10; //正向减速/反向加速
if(KeyNum == 3) Inner_Target = 0; //电机停转
*/
/* 目标位置值设置 */
Outer_Target = RP_GetValue(4) / 4095.0 * 816 - 408; //读取RP4的值赋给Outer_Target,取值范围为[-408,408]
/* 内环PID参数调节
Inner_Kp = RP_GetValue(1) / 4095.0 * 2; //读取RP1的值赋给Inner_Kp
Inner_Ki = RP_GetValue(2) / 4095.0 * 2; //读取RP2的值赋给Inner_Ki
Inner_Kd = RP_GetValue(3) / 4095.0 * 2; //读取RP3的值赋给Inner_Kd
Inner_Target = RP_GetValue(4) / 4095.0 * 300 - 150; //读取RP4的值赋给Inner_Target,取值范围为[-150,150]
OLED_Printf(0, 16, OLED_8X16, "Kp:%+4.2f", Inner_Kp);
OLED_Printf(0, 32, OLED_8X16, "Ki:%+4.2f", Inner_Ki);
OLED_Printf(0, 48, OLED_8X16, "Kd:%+4.2f", Inner_Kd);
OLED_Printf(64, 16, OLED_8X16, "Tar:%+04.0f", Inner_Target);
OLED_Printf(64, 32, OLED_8X16, "Act:%+04.0f", Inner_Actual);
OLED_Printf(64, 48, OLED_8X16, "Out:%+04.0f", Inner_Out);
OLED_Update(); //更新显示
Serial_Printf("%f,%f,%f\r\n", Inner_Target, Inner_Actual, Inner_Out); //将PID参数发送至串口
*/
/* 外环PID参数调节
Outer_Kp = RP_GetValue(1) / 4095.0 * 2; //读取RP1的值赋给Outer_Kp
Outer_Ki = RP_GetValue(2) / 4095.0 * 2; //读取RP2的值赋给Outer_Ki
Outer_Kd = RP_GetValue(3) / 4095.0 * 2; //读取RP3的值赋给Outer_Kd
Outer_Target = RP_GetValue(4) / 4095.0 * 816 - 408; //读取RP4的值赋给Outer_Target,取值范围为[-408,408]
OLED_Printf(0, 16, OLED_8X16, "Kp:%+4.2f", Outer_Kp);
OLED_Printf(0, 32, OLED_8X16, "Ki:%+4.2f", Outer_Ki);
OLED_Printf(0, 48, OLED_8X16, "Kd:%+4.2f", Outer_Kd);
OLED_Printf(64, 16, OLED_8X16, "Tar:%+04.0f", Outer_Target);
OLED_Printf(64, 32, OLED_8X16, "Act:%+04.0f", Outer_Actual);
OLED_Printf(64, 48, OLED_8X16, "Out:%+04.0f", Outer_Out);
OLED_Update(); //更新显示
Serial_Printf("%f,%f,%f\r\n", Outer_Target, Outer_Actual, Outer_Out); //将PID参数发送至串口
*/
}
}
4、PID代码封装
(1)复制一份“双环PID定速度定位置控制”的工程,并更名为“双环PID定速度定位置控制 - PID代码封装”。
(2)在User组中添加PID.c和PID.h文件。
(3)在PID.h和PID.c文件中进行PID代码封装:
①在PID.h文件中用结构体封装PID控制器的参数,并对实现PID调控的函数进行声明。
#ifndef __PID_H
#define __PID_H
typedef struct
{
float Target; //目标值
float Actual; //实际值
float Out; //输出值
float Kp; //P项系数
float Ki; //I项系数
float Kd; //D项系数
float Error0; //本次误差
float Error1; //上次误差
float ErrorInt; //误差积分值
float OutMax; //输出上限值
float OutMin; //输出下限值
}PID_t;
void PID_Update(PID_t* p);
#endif
②在PID.c文件中用自定义函数封装PID调控的代码。
#include "stm32f10x.h" // Device header
#include "PID.h"
#include "math.h"
void PID_Update(PID_t* p)
{
/* 获取本次误差和上次误差 */
p->Error1 = p->Error0; //获取上次误差
p->Error0 = p->Target - p->Actual; //获取本次误差
/* 误差积分(累加) */
if(fabs(p->Ki) > 0.0001) //防止Ki为0时出现积分饱和现象
p->ErrorInt += p->Error0;
else
p->ErrorInt = 0;
/* PID计算 */
p->Out = p->Kp * p->Error0 + p->Ki * p->ErrorInt + p->Kd * (p->Error0 - p->Error1);
/* 输出限幅 */
if(p->Out > p->OutMax) p->Out = p->OutMax;
if(p->Out < p->OutMin) p->Out = p->OutMin;
}
(4)在main.c文件中包含PID.h文件,并进行以下修改:
①分别对内环PID和外环PID创建结构体,删除之前内环PID和外环PID相关参数的全局变量。
uint8_t KeyNum;
int16_t PWM;
int16_t Speed, Location;
PID_t Inner = {
.Kp = 0.3,
.Ki = 0.3,
.Kd = 0,
.OutMax = 100,
.OutMin = -100,
}; //内环PID控制器结构体创建及初始化
PID_t Outer = {
.Kp = 0.3,
.Ki = 0,
.Kd = 0.4,
.OutMax = 150,
.OutMin = -150,
}; //外环PID控制器结构体创建及初始化
②主函数中访问的PID参数,由全局变量全部更换为结构体成员。
int main(void)
{
/*模块初始化*/
OLED_Init(); //OLED初始化
Key_Init(); //按键初始化
Motor_Init(); //电机初始化
Encoder_Init(); //编码器接口初始化
Timer_Init(); //定时器1初始化
RP_Init(); //电位器旋钮初始化
Serial_Init(); //串口初始化
OLED_Printf(0, 0, OLED_8X16, "2*PID Control");
OLED_Update(); //更新显示
while (1)
{
KeyNum = Key_GetNum(); //获取按键事件标志位
/* 目标位置值设置 */
Outer.Target = RP_GetValue(4) / 4095.0 * 816 - 408; //读取RP4的值赋给Outer_Target,取值范围为[-408,408]
/*内环PID参数调节
Inner.Kp = RP_GetValue(1) / 4095.0 * 2; //读取RP1的值赋给Inner.Kp Inner.Ki = RP_GetValue(2) / 4095.0 * 2; //读取RP2的值赋给Inner.Ki
Inner.Kd = RP_GetValue(3) / 4095.0 * 2; //读取RP3的值赋给Inner.Kd
Inner.Target = RP_GetValue(4) / 4095.0 * 300 - 150; //读取RP4的值赋给Inner.Target,取值范围为[-150,150]
OLED_Printf(0, 16, OLED_8X16, "Kp:%+4.2f", Inner.Kp);
OLED_Printf(0, 32, OLED_8X16, "Ki:%+4.2f", Inner.Ki);
OLED_Printf(0, 48, OLED_8X16, "Kd:%+4.2f", Inner.Kd);
OLED_Printf(64, 16, OLED_8X16, "Tar:%+04.0f", Inner.Target);
OLED_Printf(64, 32, OLED_8X16, "Act:%+04.0f", Inner.Actual);
OLED_Printf(64, 48, OLED_8X16, "Out:%+04.0f", Inner.Out);
OLED_Update(); //更新显示
Serial_Printf("%f,%f,%f\r\n", Inner.Target, Inner.Actual, Inner.Out); //将PID参数发送至串口
*/
/*外环PID参数调节
Outer.Kp = RP_GetValue(1) / 4095.0 * 2; //读取RP1的值赋给Outer.Kp
Outer.Ki = RP_GetValue(2) / 4095.0 * 2; //读取RP2的值赋给Outer.Ki
Outer.Kd = RP_GetValue(3) / 4095.0 * 2; //读取RP3的值赋给Outer.Kd
Outer.Target = RP_GetValue(4) / 4095.0 * 816 - 408; //读取RP4的值赋给Outer.Target,取值范围为[-408,408]
OLED_Printf(0, 16, OLED_8X16, "Kp:%+4.2f", Outer.Kp);
OLED_Printf(0, 32, OLED_8X16, "Ki:%+4.2f", Outer.Ki);
OLED_Printf(0, 48, OLED_8X16, "Kd:%+4.2f", Outer.Kd);
OLED_Printf(64, 16, OLED_8X16, "Tar:%+04.0f", Outer.Target);
OLED_Printf(64, 32, OLED_8X16, "Act:%+04.0f", Outer.Actual);
OLED_Printf(64, 48, OLED_8X16, "Out:%+04.0f", Outer.Out);
OLED_Update(); //更新显示
Serial_Printf("%f,%f,%f\r\n", Outer.Target, Outer.Actual, Outer.Out); //将PID参数发送至串口
*/
}
}
③将定时器1中断服务函数中PID调控的代码全部换成PID_Update函数。
void TIM1_UP_IRQHandler(void)
{
static uint16_t Count1,Count2;
if (TIM_GetITStatus(TIM1, TIM_IT_Update) == SET){
Key_Tick();
/* 内环PID调控 */
{
Count1++;
if(Count1 >= 40){
Count1 = 0;
/* 获取实际速度值和位置值 */
Speed = Encoder_Get();
Inner.Actual = Speed;
Location += Speed;
/* PID控制器运算 */
PID_Update(&Inner);
/* 执行控制 */
Motor_SetPWM(Inner.Out); //借助PWM波给电机施加驱动力
}
}
/* 外环PID调控 */
{
Count2++;
if(Count2 >= 40){
Count2 = 0;
/* 获取实际位置值 */
Outer.Actual = Location;
/* PID控制器运算 */
PID_Update(&Outer);
/* 执行控制 */
Inner.Target = Outer.Out; //外环输出值作为内环输入值(调节内环参数时需注释)
}
}
TIM_ClearITPendingBit(TIM1, TIM_IT_Update);
}
}
七、倒立摆实验
1、倒立摆系统概述
(1)倒立摆系统硬件结构如下图所示,其中①为摆杆,②为角度传感器,摆杆可以绕角度传感器输出轴自由转动,角度传感器可以测量摆杆当前的角度;水平的杆子③叫横杆,连接④法兰联轴器,下面的⑥电机输出轴连接⑤黄铜联轴器,两个联轴器由一根金属钢轴硬性连接在一起。
(2)从控制系统的角度来看,STM32的输入信号有通过电机编码器获取的电机输出轴位置和速度,以及由角度传感器读取的摆杆角度,输出信号则是驱动电机的PWM波。
(3)稳态下摆杆需要稳定地立住,横杆也需要固定,不能随意移动,要想实现这个功能,则软件中需要使用两个PID控制器。
(4)倒立摆控制系统架构如下图所示:
①角度环用于控制摆杆稳定倒立,位置环用于控制横杆的位置固定在一个地方(中心角度为摆杆倒立时角度传感器读取到的角度),它们都属于定位置控制。
②控制思路:当倒立摆杆有朝左倒的趋势时,需控制电机使横杆左转,接住摆杆;当倒立摆杆有朝右倒的趋势时,需控制电机使横杆右转,接住摆杆。
③不同情况下PID控制器的作用:
[1]当横杆目标位置与实际位置贴合时,位置环输出角度为0,此时目标角度等于中心角度,角度环仅负责控制摆杆稳定倒立。如果实际角度与目标角度相等,角度环输出为0;如果实际角度小于目标角度,倒立摆杆有朝左倒的趋势,角度环会输出驱动电机“左转”的PWM波,使横杆左转接住摆杆;如果实际角度大于目标角度,倒立摆杆有朝右倒的趋势,角度环会输出驱动电机“右转”的PWM波,使横杆右转接住摆杆。
[2]假设实际角度与中心角度相等,当横杆目标位置在实际位置左侧时,位置环输出角度小于0,它与中心角度相加形成目标角度,目标角度小于实际角度,角度环会输出驱动电机“左转”的PWM波,使横杆左转,横杆目标位置与实际位置逐渐贴合。
[3]假设实际角度与中心角度相等,当横杆目标位置在实际位置右侧时,位置环输出角度大于0,它与中心角度相加形成目标角度,目标角度大于实际角度,角度环会输出驱动电机“右转”的PWM波,使横杆右转,横杆目标位置与实际位置逐渐贴合。
2、电位器式角度传感器介绍
(1)电位器式角度传感器硬件结构拆解图:
(2)电位器式角度传感器电路结构图:
(3)工作原理:
①从硬件结构拆解图1(从左到右)上看,角度传感器有两圈大圆,其中外圈较粗的大圆弧是电位器RP1的主体,圆弧左下端连接引脚GND,圆弧右下端连接引脚VCC;内圈较细的小圆连接引脚OUT,它是电位器RP1的抽头端,其内阻很小,可以忽略不计。
②从硬件结构拆解图2(从左到右)上看,金属片转子有两个触点,上方的触点离中心较远,它压在RP1的主体上,与其接通,下方的触点离中心较近,它压在RP1的抽头端,也即内圈较细的小圆,与其接通。
③由上述可得,金属片转子旋转,其两个触点接触电阻环的位置也随之改变,其实就相当于改变RP1的抽头位置,那么输出OUT也会随之改变,根据OUT的值可以换算出当前传感器读到的角度。
④外圈的电阻环并不是封闭的,当金属片离中心较远的触点旋转到外圈开口时,RP1断路,此处就是角度传感器的盲区(可以同时使用两个角度传感器,一个传感器的盲区被另一个传感器覆盖即可)。图示的角度传感器盲区为26.7°,测量范围为0°~333.33°。
(4)角度传感器的盲区可以设置在倒立摆摆杆垂直下垂的位置,这样,当摆杆立起来时就不会受到盲区的影响了。(如下图所示,其中的数值为对应角度下STM32的AD外设读到的电压,单位为AD的最小分辨率,实际实验时稳态读数可能会和理论值有偏差)
(5)实验使用的开发板提供两个角度传感器接口,其中J1的OUT引脚连接STM32的PB0引脚,J2的OUT引脚STM32的PB1引脚,下面的实验如无特殊说明,均使用J1接口(PB0可用作STM32的ADC1、ADC2外设的通道8)。
3、代码编写
(1)复制一份“基础驱动代码”的工程,并更名为“倒立摆基础驱动代码”。
(2)电位器式角度传感器驱动代码编写:
①在STM32教程中的定时器篇找到“AD单通道”实验工程,拷贝其中的AD.c和AD.h文件,将其添加进本实验工程中。
②读取电位器旋钮的信号时已经使用了ADC2,那么读取角度传感器的信号时可以使用ADC1,于是需要更改AD.c文件中的初始化函数。
void AD_Init(void)
{
/*开启时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE); //开启ADC1时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);//开启GPIOB时钟
/*设置ADC时钟*/
RCC_ADCCLKConfig(RCC_PCLK2_Div6); //选择时钟6分频
/*GPIO初始化*/
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure); //将PB0引脚初始化为模拟输入
/*规则组通道配置*/
ADC_RegularChannelConfig(ADC1, ADC_Channel_8, 1, ADC_SampleTime_55Cycles5); //规则组序列1的位置,配置为通道8
/*ADC初始化*/
ADC_InitTypeDef ADC_InitStructure; //定义结构体变量
ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; //模式,选择独立模式,即单独使用ADC1
ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; //数据对齐,选择右对齐
ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; //外部触发,使用软件触发,不需要外部触发
ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; //连续转换,失能,每转换一次规则组序列后停止
ADC_InitStructure.ADC_ScanConvMode = DISABLE; //扫描模式,失能,只转换规则组的序列1这一个位置
ADC_InitStructure.ADC_NbrOfChannel = 1; //通道数,为1,仅在扫描模式下,才需要指定大于1的数,在非扫描模式下,只能是1
ADC_Init(ADC1, &ADC_InitStructure); //将结构体变量交给ADC_Init,配置ADC1
/*ADC使能*/
ADC_Cmd(ADC1, ENABLE); //使能ADC1,ADC开始运行
/*ADC校准*/
ADC_ResetCalibration(ADC1);
while (ADC_GetResetCalibrationStatus(ADC1) == SET);
ADC_StartCalibration(ADC1);
while (ADC_GetCalibrationStatus(ADC1) == SET);
}
(3)主程序准备工作:
①在User组中添加PID.c和PID.h文件,也就是前面封装的PID代码。
②检查必要的头文件是否包含。
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "LED.h"
#include "Key.h"
#include "Timer.h"
#include "Motor.h"
#include "Serial.h"
#include "Encoder.h"
#include "RP.h"
#include "AD.h"
#include "PID.h"
③做好宏定义、全局变量等准备工作,其中理想的摆杆中心角度值需实测调整,如果稳定状态下摆杆总往一侧倒,说明此时设置的中心角度偏向该侧而非实际中心角度,需纠正。
#define CENTER_ANGLE 2048 //理想的摆杆中心角度值
#define CENTER_RANGE 500 //以中心角度为基可调控的幅值(超出范围即失控)
uint8_t KeyNum;
uint8_t RunState = 0; //表示倒立摆程序的运行状态,为0表示停止,否则表示运行
uint16_t Angle;
int16_t Speed,Location;
//定义角度环内环PID变量
PID_t AnglePID =
{
.Target = CENTER_ANGLE, //内环摆杆目标角度值初始化为中心角度值
.Kp = 0.3, .Ki = 0.01, .Kd = 0.4,
.OutMax = 100, .OutMin = -100, //受限于电机能力
};
//定义位置环外环PID变量
PID_t LocationPID =
{
.Target = 0, //外环横杆目标位置值初始化为0
.Kp = 0.4, .Ki = 0, .Kd = 4,
.OutMax = CENTER_RANGE, .OutMin = -CENTER_RANGE,
};
④主函数编写,在其中实现硬件初始化,以及添加PID调参使用的代码和按键实现相应功能的代码。
int main(void)
{
/*模块初始化*/
OLED_Init(); //OLED初始化
Key_Init(); //按键初始化
Motor_Init(); //电机初始化
Encoder_Init(); //编码器接口初始化
Timer_Init(); //定时器1初始化
AD_Init(); //角度传感器接口初始化
Serial_Init(); //串口初始化
LED_Init(); //LED初始化
while (1){
KeyNum = Key_GetNum(); //获取按键事件标志位
if(KeyNum == 1){ //切换倒立摆程序的启停状态
RunState = !RunState;
}
if(KeyNum == 2){ //横杆正转一圈
LocationPID.Target += 408;
if(LocationPID.Target > 4080) LocationPID.Target = 4080;
}
if(KeyNum == 3){ //横杆反转一圈
LocationPID.Target -= 408;
if(LocationPID.Target < -4080) LocationPID.Target = -4080;
}
if(RunState) LED_ON(); //倒立摆程序运行时点亮LED
else LED_OFF(); //倒立摆程序停止时熄灭LED
/* 内环PID调参时需解除注释
AnglePID.Kp = RP_GetValue(1) / 4095.0 * 1; //Kp取值范围为[0,1]
AnglePID.Ki = RP_GetValue(2) / 4095.0 * 1; //Ki取值范围为[0,1]
AnglePID.Kd = RP_GetValue(3) / 4095.0 * 1; //Kd取值范围为[0,1]
*/
OLED_Printf(0, 0, OLED_6X8, "Angle", Angle);
OLED_Printf(0, 12, OLED_6X8, "Kp:%05.3f", AnglePID.Kp);
OLED_Printf(0, 20, OLED_6X8, "Ki:%05.3f", AnglePID.Ki);
OLED_Printf(0, 28, OLED_6X8, "Kd:%05.3f", AnglePID.Kd);
OLED_Printf(0, 40, OLED_6X8, "Tar:%04.0f", AnglePID.Target);
OLED_Printf(0, 48, OLED_6X8, "Act:%04d", Angle);
OLED_Printf(0, 56, OLED_6X8, "Out:%+04.0f", AnglePID.Out);
/* 外环PID调参时需解除注释
LocationPID.Kp = RP_GetValue(1) / 4095.0 * 1; //Kp取值范围为[0,1]
LocationPID.Ki = RP_GetValue(2) / 4095.0 * 1; //Ki取值范围为[0,1]
LocationPID.Kd = RP_GetValue(3) / 4095.0 * 9; //Kd取值范围为[0,9]
*/
OLED_Printf(64, 0, OLED_6X8, "Location", Location);
OLED_Printf(64, 12, OLED_6X8, "Kp:%05.3f", LocationPID.Kp);
OLED_Printf(64, 20, OLED_6X8, "Ki:%05.3f", LocationPID.Ki);
OLED_Printf(64, 28, OLED_6X8, "Kd:%05.3f", LocationPID.Kd);
OLED_Printf(64, 40, OLED_6X8, "Tar:%04.0f", LocationPID.Target);
OLED_Printf(64, 48, OLED_6X8, "Act:%04d", Location);
OLED_Printf(64, 56, OLED_6X8, "Out:%+04.0f", LocationPID.Out);
OLED_Update(); //更新显示
}
}
⑤中断函数代码编写:
void TIM1_UP_IRQHandler(void)
{
static uint16_t Count1, Count2;
if (TIM_GetITStatus(TIM1, TIM_IT_Update) == SET){
Key_Tick();
Angle = AD_GetValue(); //获取摆杆当前角度
Speed = Encoder_Get(); //获取横杆当前速度
Location += Speed; //获取横杆当前位置
if(!(Angle > CENTER_ANGLE - CENTER_RANGE
&& Angle < CENTER_ANGLE + CENTER_RANGE)){
RunState = 0; //摆杆失控,程序需要立即停止
}
if(RunState){ //判断用户预期的倒立摆程序状态,以决定是否进行PID调控
Count1++;
if(Count1 >= 5){ //内环调控周期很大程度上取决于硬件能力,需不断更改(硬件允许的情况下尽可能小)
Count1 = 0;
AnglePID.Actual = Angle; //获取实际角度值
PID_Update(&AnglePID); //内环PID调控
Motor_SetPWM(AnglePID.Out); //执行控制
}
Count2++;
if(Count2 >= 50){ //外环调控周期要求比内环低,可适当放大
Count1 = 0;
LocationPID.Actual = Location; //获取实际位置值
PID_Update(&LocationPID); //外环PID调控
AnglePID.Target = CENTER_ANGLE - LocationPID.Out; //执行控制(注意极性问题)
}
}
else{
Motor_SetPWM(0); //倒立摆程序停止,电机应该不旋转
}
TIM_ClearITPendingBit(TIM1, TIM_IT_Update);
}
}
(4)调参思路:
①先依次调节内环PID的P项、D项和I项:
[1]调P项时要观察倒立摆的响应速度,调D项时要观察倒立摆的稳定情况。
[2]当摆杆靠近中心角度时,误差较小,如果不引入I项,由于摆杆存在重力分力,会导致最后的一点误差难以消除,进而导致摆杆无法稳定,考虑到这一点,需引入I项,但并不能太大,否则容易超调。
②再依次调节外环PID的P项、D项和I项:
[1]调P项时要观察倒立摆的响应速度,调D项时要观察倒立摆的稳定情况。
[2]当设置的中心角度与实际中心角度有偏差时,位置环若仅采用PD控制器,容易有稳态误差,为此也可引入I项,将稳态误差消除,不过引入I项可能会引起超调,为此还需要引入积分分离的改进措施。
(5)基础倒立摆程序缺陷:需要手动将倒立摆扶到中心角度附近,否则倒立摆程序无法运行,且未引入积分分离,导致倒立摆在稳态时有频繁的扰动。