自动控制原理 专题——基于STM32的PID控制

参考教程: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控制器只含有比例项,则有out(t)=K_{p}*error(t),可见,比例项的输出值仅取决于当前时刻的误差,与历史时刻无关当前存在误差时,比例项输出一个与误差呈正比的值;当前不存在误差时,比例项输出0

K_{p}越大,比例项权重越大,系统响应越快,但超调也会随之增加,甚至可能出现自激振荡,这是不被允许的。(超调的定义见第三章第一节)

③纯比例项控制时,系统一般会存在稳态误差,K_{p}越大,稳态误差越小。(稳态误差的定义见第三章第六节)

④如下几组实验是使用PID控制调节电机转速,其中PID控制器均只含有比例项,比较目标值波形(红色)和实际值波形(蓝色),可见只有比例项的PID控制器根本无法达到理想的控制效果。

系统进入稳态时,实际值和目标值存在始终一个稳定的差值,接下来以上面几组实验为例说明稳态误差产生的原因:

        纯比例项控制时,若误差为0,比例项结果也为0,对应电机的转速已达到目标转速,由于结果为0,电机将不再产生驱动力,然而电机转动会受到摩擦阻力,这必然会导致转速下降而偏离目标转速,以此往复,最终电机的驱动力会与其受的摩擦阻力平衡,而因为电机有驱动力输出,那么对应的误差也不会为0,这就是稳态误差。

        被控对象输入0时,一般会自发地向一个方向偏移(偏移方向即为稳态误差的方向),产生误差,如果没有自发偏移,说明不存在稳态误差。产生误差后,误差非0,比例项负反馈调控输出,当调控输出力度和自发偏移力度相同时,统达到稳态

(2)积分项(I):

①若PID控制器只含有比例项和积分项,则有out(t)=K_{p}*error(t)+K_{i}*\int_{0}^{t}error(t)dt(即PI控制),积分项的输出值取决于0~t所有时刻误差的积分,与历史时刻有关积分项将历史所有时刻的误差累积,乘上积分项系数K_{i}后作为积分项输出值

②积分项用于弥补纯比例项产生的稳态误差,若系统持续产生误差,则积分项会不断累积误差,直到控制器产生动作,让稳态误差消失

K_{i}越大,积分项权重越大,稳态误差消失越快,但系统滞后性也会随之增加

④如下几组实验是使用PID控制调节电机转速,其中PID控制器均只含有比例项和积分项,比较目标值波形(红色)和实际值波形(蓝色),可见只要通过不断地调整PI系数,总会得到一个比较理想的控制效果。

(3)微分项(D):

①含有比例项、积分项和微分项的控制器是完整的PID控制器,微分项的输出值取决于当前时刻误差变化的斜率,它与当前时刻附近误差变化的趋势有关当误差急剧变化时,微分项会负反馈输出相反的作用力,阻碍误差急剧变化

②误差变化的斜率一定程度上反映了误差未来的变化趋势,这使得微分项具有“预测未来提前调控”的特性。

微分项给系统增加阻尼,可以有效防止系统超调,尤其是惯性比较大的系统。当目标值接近实际值时误差的变化率反而非常大,这种时候就需要考虑增加微分项的权重了,否则实际值必然超调。

K_{d}越大,微分项权重越大,系统阻尼越大,但系统卡顿现象也会随之增加

二、离散形式的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)基础倒立摆程序缺陷:需要手动将倒立摆扶到中心角度附近,否则倒立摆程序无法运行,且未引入积分分离,导致倒立摆在稳态时有频繁的扰动。

### STM32 PID 控制教程与示例代码 #### 一、PID控制器简介 PID控制器是一种线性控制器,它依据给定值与实际输出值构成控制偏差,并利用这三个参数的比例(P)、积分(I)和微分(D),通过闭环反馈对时间进行调节。这种类型的控制系统广泛应用于工业自动化领域。 对于基于STM32实现的PID控制,在具体的应用场景下可以采用不同的外设资源配合完成特定的任务需求。例如,在恒流源设计中,可以通过调整电源电压达到稳定电流的效果;而在温度控制系统里,则是通过对加热元件施加不同强度的电能来维持目标温度不变[^1]。 #### 二、STM32中的PID实现方式 为了使STM32能够执行有效的PID运算并驱动相应的执行机构工作,通常需要以下几个步骤: - 初始化定时器用于周期性的采样过程以及PWM波形的发生; - 定义好输入量(如来自ADC转换后的模拟信号表示当前状态)、期望输出值(即设定的目标值); - 编写核心算法部分——`calculate()`函数负责按照既定公式计算新的控制变量; - 将得到的结果映射到合适的范围内作为最终输出,比如改变PWM占空比从而影响电机转速或者热敏电阻发热程度等物理现象[^2]。 下面给出一段简化版适用于STM32F1系列MCU上的C语言形式的PID控制逻辑框架供参考: ```c #include "stm32f1xx_hal.h" typedef struct { float Kp; // Proportional gain 比例增益 float Ki; // Integral gain 积分增益 float Kd; // Derivative gain 微分校正因子 int prev_error; int integral; } PIDController; void pid_init(PIDController *pid, float kp, float ki, float kd){ pid->Kp=kp; pid->Ki=ki; pid->Kd=kd; pid->prev_error = 0; pid->integral = 0; } float calculate(PIDController* pid,int setpoint,int pv){ static uint32_t last_time = HAL_GetTick(); uint32_t current_time = HAL_GetTick(); double dt = (current_time-last_time)/1000.0; last_time=current_time; int error=setpoint-pv; pid->integral+=error*dt; double derivative=(error-pid->prev_error)/dt; pid->prev_error=error; return pid->Kp*error + pid->Ki*pid->integral + pid->Kd*derivative ; } ``` 此段代码展示了如何创建一个简单的PID结构体及其初始化方法`pid_init()`, 同时也包含了用来处理实时数据的核心业务逻辑 `calculate()` 函数。当调用后者传入理想值(setpoint) 和测量所得的实际数值(present value),即可获得经过PID修正之后的新指令值。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Zevalin爱灰灰

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值