注:本文为 “PID 算法 | stm32 直流电机控制” 相关合辑。
图片清晰度受引文原图所限。
略作重排,未全校去重。
如有内容异常,请看原文。
STM32—PID 控制在直流电机中的应用
Aspirant-GQ 于 2020-04-28 23:23:39 发布
一、PID 控制算法
1. PID(Proportion-Integral-Differential)
在过程控制领域,PID 算法是一种广泛应用的控制算法。PID 控制算法通过对偏差进行比例、积分、微分运算实现控制,使偏差趋近于某一固定值。PID 控制器由三个核心单元构成:比例单元(P)、积分单元(I)和微分单元(D)。PID 控制本质上是一种基于误差的控制方式。
PID 控制系统(模拟)的框图如下:
2. PID 系数的理解
理解 PID 的三个系数时,可结合 PID 调节过程中的响应曲线。若要使实际响应曲线趋近于理想响应曲线,需关注三个关键指标:快速性(P)、准确性(I)和稳定性(D)。要使控制量保持在理想状态,这三个参数必须调整至合理范围。
以下是三个系数对整体控制效果的影响分析:
Ⅰ. 比例(P)部分
当输入值与目标值产生偏差 e e e 时,需减小偏差。比例系数 P P P 用于缩小偏差,使控制量向减小偏差的方向调整。比例系数 P P P 越大,偏差缩小的速度越快,因此 P P P 的作用是使控制量更快地接近目标值。
然而,当 P P P 过大时,系统易超出目标值,引发反向调整,进而在目标值附近产生振荡。这是 P P P 过大的局限性:导致系统稳定性下降。
因此,虽然较大的 P P P 可实现快速响应,但也易引发振荡,破坏系统稳定性。可通过适当增大微分系数 D D D 以提高系统稳定性。
Ⅱ. 积分(I)部分
积分环节的表达式如下:
K p T i ∫ 0 t e ( τ ) d τ \frac{K_p}{T_i} \int_{0}^{t} e(\tau) \, d\tau TiKp∫0te(τ)dτ
由表达式可知,只要存在偏差,积分结果就会持续增大,即控制作用会不断增强;当偏差为 0 时,积分结果为一常数,此时控制作用才可能处于稳定状态。
积分环节的作用是消除系统的静态偏差。一旦存在误差,积分结果便会增加,系统随之做出反应,直至偏差为 0。因此,积分环节保证了控制的准确性。
积分环节虽能消除静态偏差,但会降低系统的响应速度,即积分环节对比例环节 P P P 存在抑制作用。
Ⅲ. 微分(D)部分
当比例系数 P P P 过大时,会引发系统振荡,降低系统稳定性。可通过增大微分系数 D D D 来减弱振荡。
微分环节的作用是根据偏差的变化趋势预先进行纠正。由于微分可看作求导运算,而求导能反映函数的变化趋势,因此通过微分环节可对偏差的变化进行预判并抑制,避免出现矫枉过正的现象。
微分环节的引入有助于减小振荡,使系统趋于稳定。微分系数 D D D 越大,对比例环节 P P P 的抑制效果越强。
3. PID 的数字化处理
由于计算机控制属于采样控制,仅能依据采样时刻的偏差值计算控制量,因此需通过软件实现控制算法。对 PID 进行离散化处理(即数字化处理),也就是根据离散采样点的偏差值实施控制。离散化处理后的变化如下:
- 用差分代替微分
- 用累加代替积分
数字化处理后的 PID 系统如下:
采样周期越小,系统越接近模拟控制,控制效果也就越优。
二、位置闭环控制
位置闭环控制,又称位置式 PID 控制,是传统的 PID 控制方式。其通过使控制偏差逐渐趋近于 0,使控制量趋于目标值。
在电机控制中,位置闭环控制用于调控电机的转动位置。通过编码器的脉冲累加测量电机的位置信息,并与目标位置进行比较,得到偏差值。随后通过比例、积分、微分的 PID 算法进行控制,使偏差趋于 0。
在电机控制中,输出量为电机控制模块输出的占空比,作用于电机转速。此时离散 PID 的公式如下:
根据位置式离散 PID 公式:
P w m = K p ⋅ e ( k ) + K i ⋅ ∑ e ( k ) + K d ⋅ [ e ( k ) − e ( k − 1 ) ] Pwm = K_p \cdot e(k) + K_i \cdot \sum e(k) + K_d \cdot [e(k) - e(k-1)] Pwm=Kp⋅e(k)+Ki⋅∑e(k)+Kd⋅[e(k)−e(k−1)]
其中:
- e ( k ) e(k) e(k):本次偏差
- e ( k − 1 ) e(k-1) e(k−1):上一次的偏差
- ∑ e ( k ) \sum e(k) ∑e(k): e ( k ) e(k) e(k) 及之前偏差的累积和( k k k 为 1, 2, …, k k k)
- P w m Pwm Pwm 代表输出
其控制框图如下:
控制实现代码如下:
/* 返回输出
* Encoder 为输入(编码器测量的信号,即电机转速)
* Target 为目标值
*/
// KP、KI、KD 为 PID 的系数
int Position_PID(int Encoder, int Target)
{
static float Bias, Pwm, Integral_bias, Last_bias;
Bias = Encoder - Target; // 计算偏差
Integral_bias += Bias; // 计算偏差积分,累加
Pwm = KP * Bias + KI * Integral_bias + KD * (Bias - Last_bias); // 计算输出,根据 PID
Last_bias = Bias; // 保存为上一次偏差
return Pwm; // 返回输出
}
在定时中断中实现 PID 控制,定时中断相当于离散的采样点。中断服务函数中可调用控制函数:
// 中断中如下调用
Moto = Position_PID(Encoder, Target);
// 最终的控制落回到控制电机转速上
Set_Pwm(Moto);
位置控制的调节经验:先仅采用 P P P 控制,逐渐增大 P P P 的值,待系统出现振荡后加入微分控制 D D D 以抑制振荡,调整 K D K_D KD 直至振荡消除,之后再根据系统对响应速度和静差的要求,调节 P 、 I P、I P、I 参数。
三、速度闭环控制
速度闭环控制,又称增量式 PID 控制,与位置式 PID 控制的区别在于:位置式 PID 输出的是控制量的新状态,而增量式 PID 输出的是控制量的增量。
增量式 PID 的离散公式如下:
Δ
Pwm
=
K
P
⋅
[
e
(
k
)
−
e
(
k
−
1
)
]
+
K
I
⋅
e
(
k
)
+
K
D
⋅
[
e
(
k
)
−
2
e
(
k
−
1
)
+
e
(
k
−
2
)
]
\Delta \text{Pwm} = K_P \cdot [e(k) - e(k-1)] + K_I \cdot e(k) + K_D \cdot [e(k) - 2e(k-1) + e(k-2)]
ΔPwm=KP⋅[e(k)−e(k−1)]+KI⋅e(k)+KD⋅[e(k)−2e(k−1)+e(k−2)]
其中:
- Δ Pwm \Delta \text{Pwm} ΔPwm 为控制量的增量(如 PWM 占空比的变化量)
- e ( k ) e(k) e(k) 为当前时刻的偏差(设定值 - 反馈值)
- e ( k − 1 ) e(k-1) e(k−1)、 e ( k − 2 ) e(k-2) e(k−2) 分别为上一时刻、上两时刻的偏差
- K P K_P KP、 K I K_I KI、 K D K_D KD 分别为比例、积分、微分系数
位置闭环控制用于使电机准确转动到某一位置,而速度闭环控制用于使电机以某一速度转动。在速度闭环控制中,仅需采用 PI 控制 即可,因此简化后的公式如下:
Pwm + = K p ⋅ [ e ( k ) − e ( k − 1 ) ] + K i ⋅ e ( k ) \text{Pwm} += K_p \cdot [e(k) - e(k-1)] + K_i \cdot e(k) Pwm+=Kp⋅[e(k)−e(k−1)]+Ki⋅e(k)
注意:公式直接将控制量的增量叠加在上一次控制量的状态上!
控制框图如下:
控制代码如下:
/* 返回输出
* Encoder 为输入(编码器测量的信号,即电机转速)
* Target 为目标值
*/
// KP、KI 为 PID 的系数
int Incremental_PI(int Encoder, int Target)
{
static float Bias, Pwm, Last_bias;
Bias = Encoder - Target; // 计算偏差
Pwm += KP * (Bias - Last_bias) + KI * Bias; // 增量式 PI
Last_bias = Bias; // 保存为上一次偏差
return Pwm; // 返回输出
}
在定时中断中实现 PID 控制,定时中断相当于离散的采样点。中断服务函数中可调用控制函数:
// 中断中如下调用
Moto = Incremental_PI(Encoder, Target);
// 最终的控制落回到控制电机转速上
Set_Pwm(Moto);
需要注意的是,速度闭环控制采用 PI 控制,其公式与 PID 有所不同,输出的是控制量的增量。此处将增量直接叠加在上一次的状态量上,因此输出的仍是控制量的新状态。
最后附上常用的 PID 参数调整口诀:
常用口诀:
参数整定找最佳,从小到大顺序查
先是比例后积分,最后再把微分加
曲线振荡很频繁,比例度盘要放大
曲线漂浮绕大湾,比例度盘往小扳
曲线偏离回复慢,积分时间往下降
曲线波动周期长,积分时间再加长
曲线振荡频率快,先把微分降下来
动差大来波动慢,微分时间应加长
理想曲线两个波,前高后低 4 比 1
一看二调多分析,调节质量不会低
STM32 直流电机控制—PID 算法篇
“星云 - 视界” 于 2020-10-05 00:58:28 发布
一、常用的控制算法
1. 控制系统的基本结构:
控制系统的基本结构如图:
控制目的:
控制的根本目的是使控制对象当前的状态值与用户的设定值相同(最大限度接近)。
基本思想:
用户设定值 S v S_v Sv 与被控制对象当前的值 P v P_v Pv 同时送入由特定硬件电路模型或特定软件算法组成的控制算法逻辑中,利用不同的控制算法对 S v S_v Sv 和 P v P_v Pv 进行分析、判断、处理,从而产生当前应输出的控制信号 O U T OUT OUT。控制信号经过执行机构施加到控制对象上,以产生预期的控制效果。
2. 常用控制算法:位式控制
① 二位式控制算法
二位式控制算法具体控制逻辑如图所示:
特点:
- 二位式控制算法输出的控制量只有高、低 2 种状态。
- 执行机构使控制对象要么全额工作,要么停止工作。当 P v P_v Pv 低于 S v S_v Sv 时全额工作, P v P_v Pv 大于或等于 S v S_v Sv 时彻底停止工作。若控制对象是一个 1000 W 的加热器,温度未达设定值时以 1000 W 全功率运行,温度达到设定值时停止工作。
- 由于环境因素、控制系统传输延时或控制对象本身的惯性等因素,控制效果往往是 P v P_v Pv 在 S v S_v Sv 的上下有较大波动。
- 在 P v P_v Pv 接近 S v S_v Sv 的临界点时,控制输出信号 O U T OUT OUT 往往在 H H H 和 L L L 之间频繁转换,导致执行部件的触点频繁开关动作,易产生干扰及缩短执行部件的寿命。
② 具有回差的二位式控制算法
具有回差的二位式控制算法具体控制逻辑如图所示:
特点:
- 取 S v S_v Sv 的正负 10% 左右作为回差调节上下限,高于上限才开始输出 L L L,低于下限才开始输出 H H H;
- 避免了一般二位式控制算法在临界点时执行部件频繁动作;
- 因控制对象只有全额运行或不运行两种状态,仍然存在一般二位式控制算法的缺点: P v P_v Pv 总是在 S v S_v Sv 附近波动。
③ 三位式控制算法
三位式控制算法具体控制逻辑如图所示:
特点:
- 在二位式控制的基础上对控制对象的功率分为 0 功率(停止工作)、半功率、全功率三种情况(即三位)。
- 当前值低于设定值一定比例(一般 10%)时 O U T 1 OUT1 OUT1 和 O U T 2 OUT2 OUT2 同时起控制作用,控制对象全功率运行;
- 当前值在设定值的正负 10% 范围时, O U T 1 OUT1 OUT1 单独作用,工作于半功率状态;
- 当前值达到或超过设定值时 O U T 1 OUT1 OUT1 和 O U T 2 OUT2 OUT2 都停止输出,控制对象停止工作;
- 相对一般二位式控制算法,三位式算法对控制对象的当前状态值做了简单分析,并根据不同的当前状态值输出不同的控制信号,能够较好地对输出产生控制效果。
④ 小结
位式控制的主要特征:
- 控制算法只关注控制当前的状态值( P v P_v Pv)与设定值之间的差值——二者当前有差值就输出控制信号,二者当前无差值就不输出控制信号。
- 位式控制算法的输出信号状态单一,只输出高、低两种状态,这两种状态对应着控制对象的工作与不工作——如果是温度控制系统,就是加热器加热与不加热。
- 由于控制系统自身的延时或者控制对象自身的惯性,位式控制算法只能使控制对象当前的状态值在设定值附近波动,不能很好地跟踪在设定值附近甚至与之相等。
二、PID 控制算法
1. PID 控制算法的基本思想
PID 控制算法具体控制逻辑如图所示:
PID 算法是一种具有预见性的控制算法,其核心思想是:
- PID 算法不但考虑控制对象的当前状态值(现在状态),而且还考虑控制对象过去一段时间的状态值(历史状态)和最近一段时间的状态值变化(预期),由这 3 方面共同决定当前的输出控制信号;
- PID 控制算法的运算结果是一个数,利用这个数来控制被控对象在多种工作状态(比如加热器的多种功率,阀门的多种开度等)工作,一般输出形式为 PWM,基本满足了按需输出控制信号、根据情况随时改变输出的目的。
2. PID 算法分析
设某控制系统:用户设定值为 S v S_v Sv(即希望通过 PID 控制算法使被控制对象的状态值保持在 S v S_v Sv 附近)。
① 从系统投入运行开始,控制算法每隔一段时间对被控制对象的状态值进行采样。由此,可得到开机以来由各个采样时间点被控制对象的状态值所形成的数据序列:
X 1 , X 2 , X 3 , X 4 , … , X k − 2 , X k − 1 , X k X_1, X_2, X_3, X_4, \ldots, X_{k-2}, X_{k-1}, X_k X1,X2,X3,X4,…,Xk−2,Xk−1,Xk
说明:
X 1 X_1 X1:开机以来的第一次采样值
X k X_k Xk:目前的采样值(最近一次的采样值)
② 从这个采样值序列中提取出三方面信息:
-
当前采样值 X k X_k Xk 与用户设定值 S v S_v Sv 之间的差值: E k E_k Ek
E k = S v − X k E_k = S_v - X_k Ek=Sv−Xk分析 E k E_k Ek:
E k > 0 E_k > 0 Ek>0:说明当前状态值未达标
E k = 0 E_k = 0 Ek=0:说明当前控制状态值正好满足要求
E k < 0 E_k < 0 Ek<0:说明当前状态值已经超标结论: E k E_k Ek 反应了控制对象当前值与设定值的偏差程度,可根据 E k E_k Ek 的大小对输出信号 O U T OUT OUT 进行调整:偏差程度大则 O U T OUT OUT 增大,偏差程度小则 O U T OUT OUT 减小。即输出信号的强弱与当前偏差程度的大小成比例,因此根据 E k E_k Ek 的大小来给出控制信号 O U T OUT OUT 的当前值的算法称为比例控制(Proportion)。其数学模型可表示为:
P OUT = ( K p ⋅ E k ) + Out 0 P_{\text{OUT}} = (K_p \cdot E_k) + \text{Out}_0 POUT=(Kp⋅Ek)+Out0
其中, K p K_p Kp 一般称之为比例系数,可理解为硬件上的放大器(或衰减器),适当选取 K p K_p Kp 可将当前误差值 E k E_k Ek 按一定的增益放大或缩小,以提高控制算法的响应速度; Out 0 \text{Out}_0 Out0 是一个常数,目的是当 E k E_k Ek 为 0 时,确保输出信号不为 0,以避免在当前值与设定值相等时控制器输出信号 O U T OUT OUT 为 0,导致系统处于无控制信号的失控状态。
-
将投入运行以来的各个采样值都与设定值相减,可得到开机以来每个采样时刻的偏差序列数据:
E 1 , E 2 , E 3 , … , E k − 2 , E k − 1 , E k E_1, E_2, E_3, \ldots, E_{k-2}, E_{k-1}, E_k E1,E2,E3,…,Ek−2,Ek−1,Ek说明:
E 1 E_1 E1:开机的第一个采样点与设定值的偏差, E 1 = S v − X 1 E_1 = S_v - X_1 E1=Sv−X1;
E 2 = S v − X 2 E_2 = S_v - X_2 E2=Sv−X2;
… \ldots …
E k − 2 = S v − X k − 2 E_{k-2} = S_v - X_{k-2} Ek−2=Sv−Xk−2;
E k − 1 = S v − X k − 1 E_{k-1} = S_v - X_{k-1} Ek−1=Sv−Xk−1;
E k E_k Ek:当前的采样值与设定值的偏差, E k = S v − X k E_k = S_v - X_k Ek=Sv−Xk。分析开机以来的误差序列:
每个偏差值可能有 > 0 >0 >0、 < 0 <0 <0、 = 0 =0 =0 这三种可能的值。从开机到现在,控制算法不断输出控制信号对被控对象进行控制,导致过去这段时间有时超标( E x < 0 E_x < 0 Ex<0),有时未达标( E x > 0 E_x > 0 Ex>0),有时正好满足要求( E x = 0 E_x = 0 Ex=0)。若将这些偏差值进行累加求代数和得到 S k S_k Sk,即:S k = E 1 + E 2 + E 3 + … + E k − 2 + E k − 1 + E k S_k = E_1 + E_2 + E_3 + \ldots + E_{k-2} + E_{k-1} + E_k Sk=E1+E2+E3+…+Ek−2+Ek−1+Ek
分析 S k S_k Sk:
S k > 0 S_k > 0 Sk>0:过去大多数时候未达标
S k = 0 S_k = 0 Sk=0:过去控制效果较理想
S k < 0 S_k < 0 Sk<0:过去大多数时候已经超标结论:
-
通过对 S k S_k Sk 的分析,可对控制算法过去的控制效果进行综合评估。这体现了控制算法按照原来的方式输出的控制信号导致了现在的控制结果,因此应利用这个值对当前要输出的控制信号 O U T OUT OUT 进行修正,以确保控制对象在将来的一小段时间尽快达到用户设定的值。
-
S k S_k Sk 实际上是过去每个时间点的误差相加,与数学上的定积分运算类似,因此根据 S k S_k Sk 对输出信号进行调节的算法称为积分(Integral)算法。积分控制的数学模型为:
I OUT = ( K p ⋅ ( 1 T i ∫ E x d t ) ) + Out 0 I_{\text{OUT}} = \left( K_p \cdot \left( \frac{1}{T_i} \int E_x \text{d}t \right) \right) + \text{Out}_0 IOUT=(Kp⋅(Ti1∫Exdt))+Out0其中, K p K_p Kp 是一常数,其目的类似硬件上的放大器,用于将 S k S_k Sk 放大或衰减; Out 0 \text{Out}_0 Out0 是一常数,用于在历史积分偏差值为 0 时确保系统有一个输出值,避免失控; T i T_i Ti 是积分时间常数,取值越大,输出量 O U T OUT OUT 越小,可理解为历史上较早的误差值对当前输出信号的影响;取值越小,输出 O U T OUT OUT 越强烈,可理解为积分只考虑了最近一段时间的误差。实际中,若系统已运行“很长”一段时间,早期采样的偏差值可忽略其对当前控制的影响,因此应根据情况选择合理的 T i T_i Ti 值以获得良好的控制效果。
-
-
最近两次的偏差之差 D k D_k Dk
D k = E k − E k − 1 D_k = E_k - E_{k-1} Dk=Ek−Ek−1
说明:
E k E_k Ek:当前的偏差
E k − 1 E_{k-1} Ek−1:当前的前一个采样时刻的偏差值(即上一次的偏差值)
分析 D k D_k Dk:
D k > 0 D_k > 0 Dk>0:说明从上一采样时刻到当前误差有增大趋势
D k = 0 D_k = 0 Dk=0:说明从上一采样时刻到当前误差平稳
D k < 0 D_k < 0 Dk<0:说明从上一采样时刻到当前误差有减小趋势结论:
-
D k D_k Dk 能够说明从上次采样到当前采样的这段时间被控制对象的状态变化趋势,这种变化趋势很可能在一定程度上延续到下一个采样时间点,因此可根据这个变化趋势( D k D_k Dk 的值)对输出信号 O U T OUT OUT 进行调整,达到提前控制的目的。
-
D k D_k Dk 形如数学上的微分运算,反应了控制对象在一段时间内的变化趋势及变化量,因此利用 D k D_k Dk 对控制器输出信号进行调节的算法称为微分(Differential)算法。其数学模型可表达为:
D OUT = K p ⋅ ( T d ⋅ d e d t ) + Out 0 D_{\text{OUT}} = K_p \cdot \left( T_d \cdot \frac{\text{d}e}{\text{d}t} \right) + \text{Out}_0 DOUT=Kp⋅(Td⋅dtde)+Out0其中, K p K_p Kp 为一常数,可理解为硬件上的放大器或衰减器,用于对输出信号 O U T OUT OUT 的增益进行调整; Out 0 \text{Out}_0 Out0 为一常数,用于在 D k D_k Dk 为 0 时确保 O U T OUT OUT 有一个稳定的控制值,避免失控; T d T_d Td 叫微分时间常数(犹如硬件上电感器的自感系数), T d T_d Td 越大, O U T OUT OUT 增大,对输出信号产生强烈影响。
-
③ PID 算法的形成
比例、积分、微分三种算法的优缺点分析:
P OUT = ( K p ⋅ E k ) + Out 0 P_{\text{OUT}} = (K_p \cdot E_k) + \text{Out}_0 POUT=(Kp⋅Ek)+Out0 ——比例算法
I OUT = ( K p ⋅ ( 1 T i ∫ E x d t ) ) + Out 0 I_{\text{OUT}} = \left( K_p \cdot \left( \frac{1}{T_i} \int E_x \text{d}t \right) \right) + \text{Out}_0 IOUT=(Kp⋅(Ti1∫Exdt))+Out0 ——积分算法
D OUT = K p ⋅ ( T d ⋅ d e d t ) + Out 0 D_{\text{OUT}} = K_p \cdot \left( T_d \cdot \frac{\text{d}e}{\text{d}t} \right) + \text{Out}_0 DOUT=Kp⋅(Td⋅dtde)+Out0 ——微分算法
比例算法:只考虑控制对象当前误差,当前有误差才输出控制信号,当前没有误差就不输出控制信号。即只要偏差已产生,比例算法才采取措施进行调整,因此单独的比例算法不可能将控制对象的状态值控制在设定值上,始终在设定值上下波动;但比例控制反应灵敏,有误差马上就反应到输出。
积分算法:考虑了被控制对象的历史误差情况,过去的误差状况参与当前的输出控制。但在系统未达到目标期间,历史误差往往会对当前控制产生干扰(即拖后腿),使用不当反而会干扰当前输出。不过在系统进入稳定状态后,特别是当前值与设定值没有偏差时,积分算法可根据过去的偏差值输出一个相对稳定的控制信号,以防止产生偏离目标,起到预防作用。
微分算法:单纯考虑近期的变化率,当系统的偏差趋近于某一固定值时(变化率为 0),微分算法不输出信号对其偏差进行调整,因此微分算法不能单独使用,它只关心偏差的变化速度,不考虑是否有偏差(偏差变化率为 0 时偏差不一定是 0)。但微分算法能获得控制对象近期的变化趋势,可协助输出信号尽早抑制控制对象的变化。可理解为当即将有剧烈变化时,就大幅度调整输出信号进行抑制,避免控制对象的大幅度变化。
以上三种算法综合起来产生一个当前的控制量对控制对象进行控制,它们的优缺点互补,即形成经典的 PID 算法。
3. PID 算法数学模型
O U T = P OUT + I OUT + D OUT OUT = P_{\text{OUT}} + I_{\text{OUT}} + D_{\text{OUT}} OUT=POUT+IOUT+DOUT
即:
O U T = ( ( K p ⋅ E k ) + Out 0 ) + ( K p ⋅ ( 1 T i ∫ E x d t ) + Out 0 ) + ( K p ⋅ ( T d ⋅ d e d t ) + Out 0 ) OUT = \left( (K_p \cdot E_k) + \text{Out}_0 \right) + \left( K_p \cdot \left( \frac{1}{T_i} \int E_x \text{d}t \right) + \text{Out}_0 \right) + \left( K_p \cdot \left( T_d \cdot \frac{\text{d}e}{\text{d}t} \right) + \text{Out}_0 \right) OUT=((Kp⋅Ek)+Out0)+(Kp⋅(Ti1∫Exdt)+Out0)+(Kp⋅(Td⋅dtde)+Out0)
整理该式,将各项的 Out 0 \text{Out}_0 Out0 归并为 O U T 0 OUT_0 OUT0,得到:
O U T = K p ( E k + ( 1 T i ∫ E x d t ) + ( T d ⋅ d e d t ) ) + O U T 0 OUT = K_p \left( E_k + \left( \frac{1}{T_i} \int E_x \text{d}t \right) + \left( T_d \cdot \frac{\text{d}e}{\text{d}t} \right) \right) + OUT_0 OUT=Kp(Ek+(Ti1∫Exdt)+(Td⋅dtde))+OUT0
PID 算法在单片机中的应用
1)PID 算法在单片机中应用时,对积分和微分项可作近似变换:
对于积分项,可改写成:
I = 1 T i ∑ k = 0 n E k ⋅ T I = \frac{1}{T_i} \sum_{k=0}^{n} E_k \cdot T I=Ti1∑k=0nEk⋅T
即用过去一段时间的采样点的偏差值的代数和代替积分。其中, T T T 是采样周期,也叫控制周期,每隔 T T T 时间段进行一次 PID 计算。
对于微分项,可改写成:
D = T d ⋅ E k − E k − 1 T D = T_d \cdot \frac{E_k - E_{k-1}}{T} D=Td⋅TEk−Ek−1
其中, E k E_k Ek 为本次偏差, E k − 1 E_{k-1} Ek−1 为上次的偏差值。
2)位置式 PID 算法数学模型
由此可得到单片机中 PID 算法的表达式:
O U T = K p ( E k + ( 1 T i ∫ E x d t ) + ( T d ⋅ d e d t ) ) + O U T 0 OUT = K_p \left( E_k + \left( \frac{1}{T_i} \int E_x \text{d}t \right) + \left( T_d \cdot \frac{\text{d}e}{\text{d}t} \right) \right) + OUT_0 OUT=Kp(Ek+(Ti1∫Exdt)+(Td⋅dtde))+OUT0
即:
O U T = K p ( E n + ( 1 T i ∑ k = 0 n E k ⋅ T ) + ( T d ⋅ E k − E k − 1 T ) ) + O U T 0 OUT = K_p \left( E_n + \left( \frac{1}{T_i} \sum_{k=0}^{n} E_k \cdot T \right) + \left( T_d \cdot \frac{E_k - E_{k-1}}{T} \right) \right) + OUT_0 OUT=Kp(En+(Ti1∑k=0nEk⋅T)+(Td⋅TEk−Ek−1))+OUT0
进一步展开得:
O U T = ( K p ⋅ E k ) + ( K p ⋅ T T i ∑ k = 0 n E k ) + ( K p ⋅ T d T ( E k − E k − 1 ) ) + O U T 0 OUT = (K_p \cdot E_k) + \left( K_p \cdot \frac{T}{T_i} \sum_{k=0}^{n} E_k \right) + \left( K_p \cdot \frac{T_d}{T} (E_k - E_{k-1}) \right) + OUT_0 OUT=(Kp⋅Ek)+(Kp⋅TiT∑k=0nEk)+(Kp⋅TTd(Ek−Ek−1))+OUT0
令 K i = K p ⋅ T T i K_i = K_p \cdot \frac{T}{T_i} Ki=Kp⋅TiT, K d = K p ⋅ T d T K_d = K_p \cdot \frac{T_d}{T} Kd=Kp⋅TTd,则:
O U T = ( K p ⋅ E k ) + ( K i ∑ k = 0 n E k ) + ( K d ( E k − E k − 1 ) ) + O U T 0 OUT = (K_p \cdot E_k) + \left( K_i \sum_{k=0}^{n} E_k \right) + \left( K_d (E_k - E_{k-1}) \right) + OUT_0 OUT=(Kp⋅Ek)+(Ki∑k=0nEk)+(Kd(Ek−Ek−1))+OUT0
程序设计时,利用 C 语言或汇编语言可方便实现这个计算公式。 O U T OUT OUT 即为本次运算的结果,利用 O U T OUT OUT 可驱动执行机构输出对应的控制信号,例如温度控制中可控制 PWM 的宽度,电磁阀中可改变电磁线圈电流以改变阀门开度,或可控硅的导通角度等。这种 PID 算法计算出的结果( O U T OUT OUT 值)表示当前控制器应该输出的控制量,因此称为位置式(直接输出了执行机构应该达到的状态值)。
3)增量式 PID 算法
位置式 PID 算法计算量较大,较消耗处理器资源。在有些控制系统中,执行机构本身没有记忆功能,如 MOS 管是否导通完全取决于控制极电压,可控硅是否导通取决于触发信号,继电器是否接通取决于线圈电流等,只要控制信号丢失,执行机构就停止,在这些应用中应采用位置式 PID。
也有一些执行机构本身具有记忆功能,如步进电机,即使控制信号丢失,由于其自身的机械结构会保持在原来的位置等,在这些控制系统中,PID 算法无需输出本次应该到达的真实位置,只需说明应在上次的基础上对输出信号做多大的修正(可正可负)即可,这就是增量式 PID 算法。
增量式 PID 计算出的是应在当前控制信号上的调整值,若计算结果为正,则增强输出信号;若为负则减弱输出信号。
增量式 PID 算法数学模型:
若用 O U T k − 1 OUT_{k-1} OUTk−1 表示上次的输出控制信号值,当前的输出值应为 O U T k OUT_k OUTk,则两者关系为:
O U T k = O U T k − 1 + Δ O U T OUT_k = OUT_{k-1} + \Delta OUT OUTk=OUTk−1+ΔOUT
其中, Δ O U T \Delta OUT ΔOUT 即为应输出的增量值。上式变形得:
Δ O U T = O U T k − O U T k − 1 \Delta OUT = OUT_k - OUT_{k-1} ΔOUT=OUTk−OUTk−1
本次的位置式算法输出:
O U T k = ( K p ⋅ E k ) + ( K i ∑ k = 0 n E k ) + ( K d ( E k − E k − 1 ) ) + O U T 0 OUT_k = (K_p \cdot E_k) + \left( K_i \sum_{k=0}^{n} E_k \right) + \left( K_d (E_k - E_{k-1}) \right) + OUT_0 OUTk=(Kp⋅Ek)+(Ki∑k=0nEk)+(Kd(Ek−Ek−1))+OUT0 ——1 式
上次的位置式算法输出:
O U T k − 1 = ( K p ⋅ E k − 1 ) + ( K i ∑ k = 0 n − 1 E k ) + ( K d ( E k − 1 − E k − 2 ) ) + O U T 0 OUT_{k-1} = (K_p \cdot E_{k-1}) + \left( K_i \sum_{k=0}^{n-1} E_k \right) + \left( K_d (E_{k-1} - E_{k-2}) \right) + OUT_0 OUTk−1=(Kp⋅Ek−1)+(Ki∑k=0n−1Ek)+(Kd(Ek−1−Ek−2))+OUT0 ——2 式
上述 1 式减 2 式即得到相邻两次的增量:
如前所述, K i = K p ⋅ T T i K_i = K_p \cdot \frac{T}{T_i} Ki=Kp⋅TiT, K d = K p ⋅ T d T K_d = K_p \cdot \frac{T_d}{T} Kd=Kp⋅TTd,则:
Δ O U T = O U T k − O U T k − 1 = K p ( E k − E k − 1 ) + ( K p ⋅ T T i ) E k + ( K p ⋅ T d T ) ( E k − 2 E k − 1 + E k − 2 ) \Delta OUT = OUT_k - OUT_{k-1} = K_p (E_k - E_{k-1}) + \left( K_p \cdot \frac{T}{T_i} \right) E_k + \left( K_p \cdot \frac{T_d}{T} \right) (E_k - 2E_{k-1} + E_{k-2}) ΔOUT=OUTk−OUTk−1=Kp(Ek−Ek−1)+(Kp⋅TiT)Ek+(Kp⋅TTd)(Ek−2Ek−1+Ek−2)
其中, E k E_k Ek 为本次的偏差, E k − 1 E_{k-1} Ek−1 为上次的偏差, E k − 2 E_{k-2} Ek−2 为上上次的偏差, K p K_p Kp 为算法增益调节, T i T_i Ti 为积分时间, T d T_d Td 为微分时间常数。
结论:
增量式 PID 的计算只需最近 3 次的偏差(本次偏差、上次偏差、上上次偏差),不需要处理器存储大量的历史偏差值,计算量也相对较少,容易实现。
4)关于 T i T_i Ti 和 T d T_d Td 的理解
在 PID 控制算法中,当前的输出信号由比例项、积分项、微分项共同作用形成。当比例项输出不为 0 时,若积分项对运算输出的贡献作用与比例项对运算输出的贡献相同(即同为正或同为负),积分项相当于重复了一次比例项产生作用的时间,这个时间可理解为积分时间。
当比例项不为 0 时,若微分项在一段时间里计算的结果与比例项对输出的贡献相同(即同为正或同为负),微分项相当于在一段时间里重复了比例项的作用,这段时间可理解为微分时间。
实际应用中应合理选择 K p K_p Kp、 T i T_i Ti、 T d T_d Td 以确保三者对输出的贡献平衡,从而使控制对象稳定在设定值附近。
4. 小结
PID 是比例、积分、微分的简称,PID 控制的难点并非编程,而是控制器的参数整定。参数整定的关键是正确理解各参数的物理意义,PID 控制的原理可用人对炉温的手动控制来理解。阅读本文不需要高深的数学知识。
-
比例控制
有经验的操作人员手动控制电加热炉的炉温,可获得非常好的控制品质,PID 控制与人工控制的控制策略有很多相似之处。下面介绍操作人员怎样用比例控制的思想来手动控制电加热炉的炉温。假设用热电偶检测炉温,用数字仪表显示温度值。在控制过程中,操作人员用眼睛读取炉温,并与炉温给定值比较,得到温度的误差值。然后用手操作电位器,调节加热的电流,使炉温保持在给定值附近。
操作人员知道炉温稳定在给定值时电位器的大致位置(我们将它称为位置 L L L),并根据当时的温度误差值调整控制加热电流的电位器的转角。炉温小于给定值时,误差为正,在位置 L L L 的基础上顺时针增大电位器的转角,以增大加热的电流。炉温大于给定值时,误差为负,在位置 L L L 的基础上反时针减小电位器的转角,并令转角与位置 L L L 的差值与误差成正比。上述控制策略就是比例控制,即 PID 控制器输出中的比例部分与误差成正比。
闭环中存在着各种各样的延迟作用。例如调节电位器转角后,到温度上升到新的转角对应的稳态值时有较大的时间延迟。由于延迟因素的存在,调节电位器转角后不能马上看到调节的效果,因此闭环控制系统调节困难的主要原因是系统中的延迟作用。
比例控制的比例系数如果太小,即调节后的电位器转角与位置 L L L 的差值太小,调节的力度不够,使系统输出量变化缓慢,调节所需的总时间过长。比例系数如果过大,即调节后电位器转角与位置 L L L 的差值过大,调节力度太强,将造成调节过头,甚至使温度忽高忽低,来回震荡。
增大比例系数使系统反应灵敏,调节速度加快,并且可以减小稳态误差。但是比例系数过大会使超调量增大,振荡次数增加,调节时间加长,动态性能变坏,比例系数太大甚至会使闭环系统不稳定。
单纯的比例控制很难保证调节得恰到好处,完全消除误差。
-
积分控制
PID 控制器中的积分对应于图 1 中误差曲线与坐标轴包围的面积(图中的灰色部分)。PID 控制程序是周期性执行的,执行的周期称为采样周期。计算机的程序用图 1 中各矩形面积之和来近似精确的积分,图中的 T S T_S TS 就是采样周期。图 1 积分运算示意图
每次 PID 运算时,在原来的积分值的基础上,增加一个与当前的误差值 e v ( n ) ev(n) ev(n) 成正比的微小部分。误差为负值时,积分的增量为负。
手动调节温度时,积分控制相当于根据当时的误差值,周期性地微调电位器的角度,每次调节的角度增量值与当时的误差值成正比。温度低于设定值时误差为正,积分项增大,使加热电流逐渐增大,反之积分项减小。因此只要误差不为零,控制器的输出就会因为积分作用而不断变化。积分调节的“大方向”是正确的,积分项有减小误差的作用。一直要到系统处于稳定状态,这时误差恒为零,比例部分和微分部分均为零,积分部分才不再变化,并且刚好等于稳态时需要的控制器的输出值,对应于上述温度控制系统中电位器转角的位置 L L L。因此积分部分的作用是消除稳态误差,提高控制精度,积分作用一般是必须的。
PID 控制器输出中的积分部分与误差的积分成正比。因为积分时间 T I T_I TI 在积分项的分母中, T I T_I TI 越小,积分项变化的速度越快,积分作用越强。
-
PI 控制
控制器输出中的积分项与当前的误差值和过去历次误差值的累加值成正比,因此积分作用本身具有严重的滞后特性,对系统的稳定性不利。如果积分项的系数设置得不好,其负面作用很难通过积分作用本身迅速地修正。而比例项没有延迟,只要误差一出现,比例部分就会立即起作用。因此积分作用很少单独使用,它一般与比例和微分联合使用,组成 PI 或 PID 控制器。PI 和 PID 控制器既克服了单纯的比例调节有稳态误差的缺点,又避免了单纯的积分调节响应慢、动态性能不好的缺点,因此被广泛使用。
如果控制器有积分作用(例如采用 PI 或 PID 控制),积分能消除阶跃输入的稳态误差,这时可以将比例系数调得小一些。
如果积分作用太强(即积分时间太小),相当于每次微调电位器的角度值过大,其累积的作用会使系统输出的动态性能变差,超调量增大,甚至使系统不稳定。积分作用太弱(即积分时间太大),则消除稳态误差的速度太慢,积分时间的值应取得适中。
-
微分作用
误差的微分就是误差的变化速率,误差变化越快,其微分绝对值越大。误差增大时,其微分为正;误差减小时,其微分为负。控制器输出量的微分部分与误差的微分成正比,反映了被控量变化的趋势。有经验的操作人员在温度上升过快,但是尚未达到设定值时,根据温度变化的趋势,预感到温度将会超过设定值,出现超调。于是调节电位器的转角,提前减小加热的电流。这相当于士兵射击远方的移动目标时,考虑到子弹运动的时间,需要一定的提前量一样。
图 2 阶跃响应曲线
图 2 中的 c ( ∞ ) c(\infty) c(∞) 为被控量 c ( t ) c(t) c(t) 的稳态值或被控量的期望值,误差 e ( t ) = c ( ∞ ) − c ( t ) e(t) = c(\infty) - c(t) e(t)=c(∞)−c(t)。在图 2 中启动过程的上升阶段,当 c ( t ) < c ( ∞ ) c(t) < c(\infty) c(t)<c(∞) 时,被控量尚未超过其稳态值。但是因为误差 e ( t ) e(t) e(t) 不断减小,误差的微分和控制器输出的微分部分为负值,减小了控制器的输出量,相当于提前给出了制动作用,以阻碍被控量的上升,所以可以减少超调量。因此微分控制具有超前和预测的特性,在超调尚未出现之前,就能提前给出控制作用。
闭环控制系统的振荡甚至不稳定的根本原因在于有较大的滞后因素。因为微分项能预测误差变化的趋势,这种“超前”的作用可以抵消滞后因素的影响。适当的微分控制作用可以使超调量减小,增加系统的稳定性。
对于有较大的滞后特性的被控对象,如果 PI 控制的效果不理想,可以考虑增加微分控制,以改善系统在调节过程中的动态特性。如果将微分时间设置为 0,微分部分将不起作用。
微分时间与微分作用的强弱成正比,微分时间越大,微分作用越强。如果微分时间太大,在误差快速变化时,响应曲线上可能会出现“毛刺”。
微分控制的缺点是对干扰噪声敏感,使系统抑制干扰的能力降低。为此可在微分部分增加惯性滤波环节。
-
采样周期
PID 控制程序是周期性执行的,执行的周期称为采样周期。采样周期越小,采样值越能反映模拟量的变化情况。但是太小会增加 CPU 的运算工作量,相邻两次采样的差值几乎没有什么变化,将使 PID 控制器输出的微分部分接近为零,所以也不宜将采样周期取得过小。应保证在被控量迅速变化时(例如启动过程中的上升阶段),能有足够多的采样点数,不致因为采样点数过少而丢失被采集的模拟量中的重要信息。
-
PID 参数的调整方法
在整定 PID 控制器参数时,可以根据控制器的参数与系统动态性能和稳态性能之间的定性关系,用实验的方法来调节控制器的参数。有经验的调试人员一般可以较快地得到较为满意的调试结果。在调试中最重要的问题是在系统性能不能令人满意时,知道应该调节哪一个参数,该参数应该增大还是减小。为了减少需要整定的参数,首先可以采用 PI 控制器。为了保证系统的安全,在调试开始时应设置比较保守的参数,例如比例系数不要太大,积分时间不要太小,以避免出现系统不稳定或超调量过大的异常情况。给出一个阶跃给定信号,根据被控量的输出波形可以获得系统性能的信息,例如超调量和调节时间。应根据 PID 参数与系统性能的关系,反复调节 PID 的参数。
如果阶跃响应的超调量太大,经过多次振荡才能稳定或者根本不稳定,应减小比例系数、增大积分时间。如果阶跃响应没有超调量,但是被控量上升过于缓慢,过渡过程时间太长,应按相反的方向调整参数。
如果消除误差的速度较慢,可以适当减小积分时间,增强积分作用。
反复调节比例系数和积分时间,如果超调量仍然较大,可以加入微分控制,微分时间从 0 逐渐增大,反复调节控制器的比例、积分和微分部分的参数。
总之,PID 参数的调试是一个综合的、各参数互相影响的过程,实际调试过程中的多次尝试是非常重要的,也是必须的。
三、STM32 代码控制说明
1. 硬件电路搭建
本实验使用司南电气控制 STM32 单片机开发板来实现使用 PID 算法对直流电机的速度控制。
实验材料:
- 司南电气控制 STM32 单片机开发板
- 带编码器的直流电机
- OLED 显示屏
接线如图:
2. 软件实现
PID 算法,速度控制代码如下:
PID pid; //存放 PID 算法所需要的数据
void PID_Calc() //pid 计算
{
u8 strr[2],str[2]; //检测脉冲变量
float DelEk;
float ti,ki;
// float Iout;
// float Pout;
// float Dout;
float td;
float kd;
float out;
extern float pulse; //检测脉冲变量
if(pid.C200ms < (pid.T)) //计算时间未到,200ms
{
return ;
}
LED4=!LED4;
//为什么是 2.84?
/*
因为电机转一圈黄色线向单片机输出 105.6 个脉冲
=>pulse/105.6 (200ms 的圈数)
=>(pulse/105.6)*5 (1s 的圈数)
=>(pulse/105.6)*5*60 (1min 的圈数)
=> pulse*2.48 (1min 的圈数)
*/
pid.Pv = pulse * 2.84; //速度采样
pid.Ek = pid.Sv - pid.Pv; //得到当前的偏差值
pid.Pout = pid.Kp * pid.Ek; //比例输出
pid.SEk += pid.Ek; //历史偏差总和
DelEk = pid.Ek - pid.Ek_1; //最近两次偏差之差
ti = pid.T / pid.Ti;
ki = ti * pid.Kp;
pid.Iout = ki * pid.SEk * pid.Kp; //积分输出
td = pid.Td / pid.T;
kd = pid.Kp * td;
pid.Dout = kd * DelEk; //微分输出
out = pid.Pout + pid.Iout + pid.Dout;
//////////////////////////////////////////////////////////
if(out > pid.pwmcycle)
{
pid.OUT = pid.pwmcycle;
}
else if(out < 0)
{
pid.OUT = pid.OUT0;
}
else
{
pid.OUT = out;
}
//pid.OUT +=; //本次的计算结果
pid.Ek_1 = pid.Ek; //更新偏差
TIM2->CCR1 = (u16)pid.OUT; TIM2->CCR4 = 0; //PWM 输出
TIM2->CCR2 = 0; TIM2->CCR3 = (u16)pid.OUT; //PWM 输出
sprintf(strr,"%0.1f\0",pid.OUT); //打印 PWM 输出值
OLED_ShowString(48,3,strr); //打印 PWM 输出值
sprintf(str,"%0.1f\0",pid.Pv); //打印当前速度输出值
OLED_ShowString(56,6,str); //打印当前速度输出值
pid.C200ms = 0;
pulse = 0; //检测脉冲变量清零
}
基于 STM32 的减速直流电机 PID 算法控制
混分巨兽龙某某 已于 2022-06-11 21:52:53 修改
本例程采用 HAL 库进行项目开发(主要使用软件 CubeMX 和 Keil5),文章末尾附有代码开源,欢迎各位对文章进行指正和探讨。
一、硬件模块与原理图
1. 硬件组成
硬件组成包括:STM32F103C8T6 最小系统板;0.96 英寸 LED12864(I2C 通讯模式);智能小车 12V 移动电源;25GA370 直流减速电机(带霍尔编码器);JDY-31 蓝牙模块;L298N 电机驱动模块;若干杜邦线;1 个面包板。
图片如下:
2. 模块分析
1. L298N 电机驱动模块
- 该模块可驱动两路直流电机,输出 A 和 B 各接一直流电机即可。
- 若使用 12V 供电,将 12V 供电端口及 GND 接上电源正负即可,同时 5V 供电端可作为最小系统板的输入电源。
- 若不需要使用 PWM 调速,仅需控制电机正反转,则逻辑 A 与 B 跳线帽插上即可,相当于始终使能。
- 若需要使用 PWM 调速,需将跳线帽拔起,将使能端接上单片机 IO 口(定时器 IO 口,PWM 输出模式)。
- 逻辑输入四个端口 IN1、IN2、IN3、IN4 接单片机四个 IO 口,每两个端口控制一路电机。
温馨提示: 特别不建议新手或资金有限的情况下,使用电机驱动模块直连成品开发板,很容易烧坏。
原因如下:
- 由于电机的特性,在堵转或高负载下,电流会增大,可能影响单片机。
- 新手操作单片机可能出现短路等情况,容易导致开发板损坏。
L298N 的转动逻辑图:
2. 0.96 英寸 OLED(I2C 通讯)
- 目前市面上主要分为 OLED 与 LCD 两种屏幕。
- OLED 具有自发光特性,而 LCD 需要背光,OLED 的显示效果更好。
- OLED 支持多种接口方式:6800、8080 两种并行接口方式,4 线的 SPI 接口,以及 IIC 接口(2 线)。
- OLED 的工作电压不宜过高,3.3V 即可正常工作。
- OLED 的不足之处在于做大尺寸屏幕时成本较高。
本实验采用 0.96 英寸 OLED 屏幕(通讯方式为 IIC),4 个接线柱(SCL、SDA、GND、VCC)。
IIC 通讯实现方式: IIC(Inter-Integrated Circuit)总线是一种由 PHILIPS 公司开发的两线式串行总线,用于连接微控制器及其外围设备。它由数据线 SDA 和时钟线 SCL 构成,可发送和接收数据。高速 IIC 总线一般可达 400kbps 以上。
模拟 IIC 通讯:
I2C 支持多从机,即一个 I2C 控制器下可以挂载多个 I2C 从设备。这些不同的 I2C 从设备具有不同的器件地址,I2C 主控制器可以通过 I2C 设备的器件地址访问指定的 I2C 设备。SDA 和 SCL 这两根线必须接一个上拉电阻,一般为 4.7kΩ。其余的 I2C 从器件都挂接到 SDA 和 SCL 这两根线上,通过 SDA 和 SCL 这两根线访问多个 I2C 设备。
I2C 协议: 包括起始位、停止位、数据传输、应答信号、I2C 写时序、I2C 读时序。
I2C 写时序:
- 发送起始信号。
- 发送 I2C 设备地址,每个 I2C 器件都有一个设备地址,通过发送具体的设备地址决定访问哪个 I2C 器件。这是一个 8 位的数据,其中高 7 位是设备地址,最后 1 位是读写位。若为 1,则表示这是一个读操作;若为 0,则表示这是一个写操作。
- I2C 器件地址后面跟着一个读写位,为 0 表示写操作,为 1 表示读操作。
- 从机发送的 ACK 应答信号。
- 重新发送起始信号。
- 发送要写入数据的寄存器地址。
- 从机发送的 ACK 应答信号。
- 发送要写入寄存器的数据。
- 从机发送的 ACK 应答信号。
- 发送停止信号。
I2C 读时序:
I2C 单字节读时序比写时序复杂,分为四大步:第一步是发送设备地址,第二步是发送要读取的寄存器地址,第三步重新发送设备地址,最后一步是从 I2C 从器件中读取寄存器值。具体步骤如下:
- 主机发送起始信号。
- 主机发送要读取的 I2C 从设备地址。
- 读写控制位,由于是向 I2C 从设备发送数据,因此是写信号。
- 从机发送的 ACK 应答信号。
- 重新发送 START 信号。
- 主机发送要读取的寄存器地址。
- 从机发送的 ACK 应答信号。
- 重新发送 START 信号。
- 重新发送要读取的 I2C 从设备地址。
- 读写控制位,这里是读信号,表示接下来是从 I2C 从设备中读取数据。
- 从机发送的 ACK 应答信号。
- 从 I2C 器件中读取到的数据。
- 主机发出 NO ACK 信号,表示读取完成,不需要从机再发送 ACK 信号。
- 主机发出 STOP 信号,停止 I2C 通信。
3. JDY-31 蓝牙模块
市场上蓝牙模块种类繁多,常见的有 JDY-xx 和 HC-xx 系列。这些看似高级的蓝牙功能背后,实际上是简单的串口通讯。
USART 的全称是 Universal Synchronous/Asynchronous Receiver/Transmitter,也就是同步/异步串行收发器。相比 UART,它多了一条时钟线,具备同步功能。一般 USART 可以作为 UART 使用,即不使用其同步功能。
串口通讯协议:
数据包由发送设备通过自身的 TXD 接口传输到接收设备的 RXD 接口。在协议层中,数据包的内容包括起始位、主体数据(8 位或 9 位)、校验位以及停止位。通讯双方必须将数据包的格式约定一致,才能正常收发数据。
具体如图所示:
波特率: 由于异步通信中没有时钟信号,接收双方需约定好波特率,即每秒传输的码元个数,以便对信号进行解码。常见波特率有 4800、9600、115200 等。STM32 中波特率的设置通过串口初始化结构体实现。
注意:MCU 设置的波特率大小要与蓝牙 APP 设置的大小一致!
- 6 线减速电机(带编码器)模块:
市面上电机种类繁多,常用的有步进电机、直流减速电机、伺服电机等。编码器是一种用于测量电机转速的仪器元件,常见的有霍尔编码器、光电编码器等。电机的驱动原理很简单,给电压差即可使电机转动,调速则利用 PWM 调节实现。
编码器原理: 编码器是一种将角位移或角速度转换成一串电数字脉冲的旋转式传感器。霍尔编码器由霍尔码盘和霍尔元件组成。霍尔码盘在一定直径的圆板上等分布置不同磁极。霍尔码盘与电动机同轴,电动机旋转时,霍尔元件检测输出若干脉冲信号。为判断转向,一般输出两组存在一定相位差的方波信号。
注意:通过判断 A 与 B 相哪一位在前,即可判断正转还是反转。
二、CubexMX 设置
使用的 MCU 为 STM32F103C8T6:
RCC:
SYS:
注意:Debug 一定要设置为 Serial Wire,否则可能出现芯片自锁。
GPIO 设置:
定时 TIM2 用于测速与测量正转反转(计数器模式):
定时 3:PWM 调节:
I2C:
USART1:
之后按照个人习惯生成初始化文件。
三、代码
自动生成的:
需要自己编写的:
I2C 代码:
#include "oled.h"
#include "asc.h"
#include "main.h"
void WriteCmd(unsigned char I2C_Command)
{
HAL_I2C_Mem_Write(&hi2c2, OLED0561_ADD, COM, I2C_MEMADD_SIZE_8BIT, &I2C_Command, 1, 100);
}
void WriteDat(unsigned char I2C_Data)
{
HAL_I2C_Mem_Write(&hi2c2, OLED0561_ADD, DAT, I2C_MEMADD_SIZE_8BIT, &I2C_Data, 1, 100);
}
void OLED_Init(void)
{
HAL_Delay(100);
WriteCmd(0xAE); // display off
WriteCmd(0x20); // Set Memory Addressing Mode
WriteCmd(0x10); // 00,Horizontal Addressing Mode;01,Vertical Addressing Mode;10,Page Addressing Mode (RESET);11,Invalid
WriteCmd(0xb0); // Set Page Start Address for Page Addressing Mode,0-7
WriteCmd(0xc8); // Set COM Output Scan Direction
WriteCmd(0x00); // ---set low column address
WriteCmd(0x10); // ---set high column address
WriteCmd(0x40); // --set start line address
WriteCmd(0x81); // --set contrast control register
WriteCmd(0xff); // 0x00~0xff
WriteCmd(0xa1); // --set segment re-map 0 to 127
WriteCmd(0xa6); // --set normal display
WriteCmd(0xa8); // --set multiplex ratio(1 to 64)
WriteCmd(0x3F);
WriteCmd(0xa4); // 0xa4,Output follows RAM content;0xa5,Output ignores RAM content
WriteCmd(0xd3); // -set display offset
WriteCmd(0x00); // -not offset
WriteCmd(0xd5); // --set display clock divide ratio/oscillator frequency
WriteCmd(0xf0); // --set divide ratio
WriteCmd(0xd9); // --set pre-charge period
WriteCmd(0x22);
WriteCmd(0xda); // --set com pins hardware configuration
WriteCmd(0x12);
WriteCmd(0xdb); // --set vcomh
WriteCmd(0x20); // 0x20,0.77xVcc
WriteCmd(0x8d); // --set DC-DC enable
WriteCmd(0x14);
WriteCmd(0xaf); // --turn on oled panel
}
void OLED_SetPos(unsigned char x, unsigned char y)
{
WriteCmd(0xb0 + y);
WriteCmd(((x & 0xf0) >> 4) | 0x10);
WriteCmd((x & 0x0f) | 0x01);
}
void OLED_Fill(unsigned char fill_Data)
{
unsigned char m, n;
for (m = 0; m < 8; m++)
{
WriteCmd(0xb0 + m); // page0-page1
WriteCmd(0x00); // low column start address
WriteCmd(0x10); // high column start address
for (n = 0; n < 128; n++)
{
WriteDat(fill_Data);
}
}
}
void OLED_CLS(void)
{
OLED_Fill(0x00);
}
void OLED_ON(void)
{
WriteCmd(0X8D); // ???
WriteCmd(0X14); // ???
WriteCmd(0XAF); // OLED???
}
void OLED_OFF(void)
{
WriteCmd(0X8D); // ???
WriteCmd(0X10); // ???
WriteCmd(0XAE); // OLED???
}
// Parameters : x,y -- ???(x:0~127, y:0~7); ch[] -- ???; TextSize -- ???(1:6*8 ; 2:8*16)
// Description : ??codetab.h??ASCII??,?6*8?8*16???
void OLED_ShowStr(unsigned char x, unsigned char y, unsigned char ch[], unsigned char TextSize)
{
unsigned char c = 0, i = 0, j = 0;
switch (TextSize)
{
case 1:
{
while (ch[j] != '\0')
{
c = ch[j] - 32;
if (x > 126)
{
x = 0;
y++;
}
OLED_SetPos(x, y);
for (i = 0; i < 6; i++)
WriteDat(F6x8[c][i]);
x += 6;
j++;
}
}
break;
case 2:
{
while (ch[j] != '\0')
{
c = ch[j] - 32;
if (x > 120)
{
x = 0;
y++;
}
OLED_SetPos(x, y);
for (i = 0; i < 8; i++)
WriteDat(F8X16[c * 16 + i]);
OLED_SetPos(x, y + 1);
for (i = 0; i < 8; i++)
WriteDat(F8X16[c * 16 + i + 8]);
x += 8;
j++;
}
}
break;
}
}
// Parameters : x,y -- ???(x:0~127, y:0~7); N:???.h????
// Description : ??ASCII_8x16.h????,16*16???
void OLED_ShowCN(unsigned char x, unsigned char y, unsigned char N)
{
unsigned char wm = 0;
unsigned int adder = 32 * N;
OLED_SetPos(x, y);
for (wm = 0; wm < 16; wm++)
{
WriteDat(F16x16[adder]);
adder += 1;
}
OLED_SetPos(x, y + 1);
for (wm = 0; wm < 16; wm++)
{
WriteDat(F16x16[adder]);
adder += 1;
}
}
// ????????????????,????????“??——???——????”??????ascll.h?????(????)
// ??????:x:?????
// ??????y:???(??0-7)
// ??????begin:????????????????ascll.c????????
// ??????num:????????
// ??????“??”,??????????????????0,1,???0,??????,??:x:0,y:2,begin:0,num:2
void OLED_ShowCN_STR(u8 x, u8 y, u8 begin, u8 num)
{
u8 i;
for (i = 0; i < num; i++)
{
OLED_ShowCN(i * 16 + x, y, i + begin);
} // OLED????
}
// Parameters : x0,y0 -- ???(x0:0~127, y0:0~7); x1,y1 -- ???(???)???(x1:1~128,y1:1~8)
// Description : ??BMP???
void OLED_DrawBMP(unsigned char x0, unsigned char y0, unsigned char x1, unsigned char y1, unsigned char BMP[])
{
unsigned int j = 0;
unsigned char x, y;
if (y1 % 8 == 0)
y = y1 / 8;
else
y = y1 / 8 + 1;
for (y = y0; y < y1; y++)
{
OLED_SetPos(x0, y);
for (x = x0; x < x1; x++)
{
WriteDat(BMP[j++]);
}
}
}
void OLED_ShowChar(u8 x, u8 y, u8 chr, u8 Char_Size)
{
unsigned char c = 0, i = 0;
c = chr - ' '; // ???????
if (x > 128 - 1)
{
x = 0;
y = y + 2;
}
if (Char_Size == 16)
{
OLED_SetPos(x, y);
for (i = 0; i < 8; i++)
WriteDat(F8X16[c * 16 + i]);
OLED_SetPos(x, y + 1);
for (i = 0; i < 8; i++)
WriteDat(F8X16[c * 16 + i + 8]);
}
else
{
OLED_SetPos(x, y);
for (i = 0; i < 6; i++)
WriteDat(F6x8[c][i]);
}
}
u32 oled_pow(u8 m, u8 n)
{
u32 result = 1;
while (n--)
result *= m;
return result;
}
// ??2???
// x,y :???
// len :????
// size:????
// mode:?? 0,????;1,????
// num:??(0~4294967295);
void OLED_ShowNum(u8 x, u8 y, u32 num, u8 len, u8 size2)
{
u8 t, temp;
u8 enshow = 0;
for (t = 0; t < len; t++)
{
temp = (num / oled_pow(10, len - t - 1)) % 10;
if (enshow == 0 && t < (len - 1))
{
if (temp == 0)
{
OLED_ShowChar(x + (size2 / 2) * t, y, ' ', size2);
continue;
}
else
enshow = 1;
}
OLED_ShowChar(x + (size2 / 2) * t, y, temp + '0', size2);
}
}
UART 代码:
#include "uart.h"
uint8_t USART1_RX_BUF[USART1_REC_LEN]; // ???,??USART_REC_LEN???.
uint16_t USART1_RX_STA = 0; // ??????//bit15:??????,bit14~0:??????????
uint8_t USART1_NewData; // ??????????1????????
extern int flag;
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) // ????????
{
if (huart == &huart1)
{
if ((USART1_RX_STA & 0x8000) == 0) // ??????
{
if (USART1_NewData == 0x5A) // ?????0x5A
{
USART1_RX_STA |= 0x8000; // ??????,?USART2_RX_STA??bit15(15?)?1
}
else
{
USART1_RX_BUF[USART1_RX_STA & 0X7FFF] = USART1_NewData;
if (USART1_RX_BUF[1] == 0x01)
{
flag = 2;
}
USART1_RX_STA++; // ??????1
if (USART1_RX_STA > (USART1_REC_LEN - 1))
USART1_RX_STA = 0; // ???????,??????
}
}
HAL_UART_Receive_IT(&huart1, (uint8_t *)&USART1_NewData, 1);
}
}
常规的编写如上,但本人的 MCU 存在问题,单片机未能接收到预设的数据。因此,本人项目中采用了下方代码:
#include "uart.h"
uint8_t USART1_RX_BUF[USART1_REC_LEN]; // ???,??USART_REC_LEN???.
uint16_t USART1_RX_STA = 0; // ??????//bit15:??????,bit14~0:??????????
uint8_t USART1_NewData; // ??????????1????????
extern int flag;
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) // ????????
{
if (huart == &huart1)
{
USART1_RX_BUF[USART1_RX_STA & 0X7FFF] = USART1_NewData;
USART1_RX_STA++; // ??????1
if (USART1_RX_STA > (USART1_REC_LEN - 1))
USART1_RX_STA = 0; // ???????,??????
if (USART1_RX_BUF[USART1_RX_STA - 4] == 0xA0)
{
flag = 1;
}
if (USART1_RX_BUF[USART1_RX_STA - 4] == 0x90)
{
flag = 2;
}
if (USART1_RX_BUF[USART1_RX_STA - 4] == 0xD0)
{
flag = 3;
}
if (USART1_RX_BUF[USART1_RX_STA - 4] == 0x88)
{
flag = 4;
}
if (USART1_RX_BUF[USART1_RX_STA - 4] == 0x48)
{
flag = 5;
}
HAL_UART_Receive_IT(&huart1, (uint8_t *)&USART1_NewData, 1);
}
}
如果大家自己使用,可以根据自己的蓝牙 APP 修改这段程序,有问题欢迎留言。
Motor 代码:
#include "motor.h"
void MOTOR_GO()
{
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 3000);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);
}
void MOTOR_BACK()
{
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET);
}
void MOTOR_STOP()
{
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET);
}
void MOTOR_UP()
{
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 1);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);
}
void MOTOR_DOWN()
{
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 400);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);
}
PID
PID 算法:
PID 分为位置型和增量型。增量型通过 u ( k ) − u ( k − 1 ) u(k) - u(k-1) u(k)−u(k−1) 得出公式:
公式的第一部分是比例项,用于让值按一定比例达到目标值;
第二部分是积分项,正值,在计算过程中往往会受到环境等其他因素的影响,导致值不能到达目标值;
第三部分是微分项,通常是负值,后一次偏差值往往小于前一次偏差值,目的是防止值增加过大,起到阻碍作用。
PID 代码:
#include "pid.h"
#include "tim.h"
#include "main.h"
#include "math.h"
#include "i2c.h"
#include "oled.h"
unsigned int MotorSpeed; // 全局变量,电机当前转速
int SpeedTarget = 750; // 目标转速
int MotorOutput; // 电机输出
// 1. 使用 TIM2 计算电机转速
void GetMotorSpeed(void)
{
int CaptureNumber = (short)__HAL_TIM_GET_COUNTER(&htim2); // 获取计数值
__HAL_TIM_GET_COUNTER(&htim2) = 0; // 清零计数器
int Direction = __HAL_TIM_IS_TIM_COUNTING_DOWN(&htim2); // 判断计数方向
if (Direction == 1)
{
CaptureNumber -= 65535;
}
MotorSpeed = CaptureNumber;
OLED_ShowNum(40, 0, MotorSpeed, 4, 16); // 显示转速
HAL_Delay(100);
OLED_CLS();
}
// 2. 增量式 PID 控制器(PID 常见的有位置 PID 和增量 PID)
int Error_Last, Error_Prev; // 上次误差,上上次误差
int Pwm_add, Pwm; // PWM 增量,PWM 值
int Kp = 5, Ki = 3, Kd = 1; // PID 参数,比例、积分、微分系数
int SpeedInnerControl(int Speed, int Target) // 速度内环控制
{
int Error = Target - Speed; // 误差 = 目标速度 - 实际速度
Pwm_add = Kp * (Error - Error_Last) + // 比例项
Ki * Error + // 积分项
Kd * (Error - 2.0f * Error_Last + Error_Prev); // 微分项
Pwm += Pwm_add; // 输出 = 初始值 + 增量
Error_Prev = Error_Last; // 更新上上次误差
Error_Last = Error; // 更新上次误差
if (Pwm > 4999)
Pwm = 3000; // 限制 PWM 上下限
if (Pwm < -4999)
Pwm = -3000;
return Pwm; // 返回输出值
}
// 3. 设置电机电压和方向(PID 控制)
void SetMotorVoltageAndDirection(int Pwm)
{
if (Pwm < 0) // 如果 PWM 小于 0
{
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET);
Pwm = (-Pwm); // PWM 取绝对值
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, Pwm); // 设置 PWM 占空比
}
else
{
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET);
HAL_GPIO_WritePin(GPIO_PIN_5, GPIO_PIN_SET);
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, Pwm); // 设置 PWM 占空比
}
}
void ModePID()
{
GetMotorSpeed();
MotorOutput = SpeedInnerControl(MotorSpeed, SpeedTarget);
SetMotorVoltageAndDirection(MotorOutput);
}
主函数代码:
while (1)
{
switch (flag)
{
case (1):
MOTOR_GO();
break;
case (2):
MOTOR_BACK();
break;
case (3):
MOTOR_STOP();
break;
case (4):
MOTOR_UP();
break;
case (5):
ModePID();
break;
default:
break;
}
if (flag != 5)
{
int CaptureNumber = (short)__HAL_TIM_GET_COUNTER(&htim2); // 获取计数值
__HAL_TIM_GET_COUNTER(&htim2) = 0;
int Direction = __HAL_TIM_IS_TIM_COUNTING_DOWN(&htim2);
if (Direction == 1)
{
CaptureNumber -= 65535;
}
int Speed = CaptureNumber;
OLED_ShowNum(40, 0, Speed, 5, 16);
HAL_Delay(100);
OLED_CLS();
}
int Direction = __HAL_TIM_IS_TIM_COUNTING_DOWN(&htim2);
OLED_ShowCN_STR(0, 0, 0, 3);
OLED_ShowNum(90, 0, Speed, 4, 16);
OLED_ShowStr(90, 0, "cm/s", 2);
OLED_ShowCN_STR(0, 3, 3, 2);
if (Direction == 0)
{
OLED_ShowCN_STR(40, 3, 5, 2);
}
if (Direction == 1)
{
OLED_ShowCN_STR(40, 3, 7, 2);
}
}
蓝牙 APP 源代码以及技术论文:
链接:https://pan.baidu.com/s/1-rbicxuyLVCq6rglCWcJTg
提取码:huzm
使用 STM32 实现电机的 PID 控制
倩倩草原晖太郎 已于 2024-06-13 19:14:02 修改
PID 控制是一种非常古老且应用广泛的控制算法,小到热水壶温度控制,大到无人机的飞行姿态和飞行速度控制等。在电机控制中,PID 算法尤为常见。本文将由浅入深介绍使用 STM32 实现电机的 PID 控制,希望能帮助到有需要的人。
直接上代码仓库链接:gitee-基于 STM32 的 PID 电机控制源码
一、电机基本控制
直流电机的内部结构和工作原理在高中的物理教材中已经讲过,这里主要讨论如何使用单片机和电机驱动模块驱动一个直流电机。
1.1 器件准备和接线
本文介绍使用的单片机型号是 STM32F103RCT6,也可以使用 C8T6。
电机驱动选择 TB6612。
直流电机选择这种带霍尔传感器的编码电机,12V,减速比 1/30,速度 330rpm。
为了方便观察和操作,使用了一块 0.96 英寸的 OLED。
接线表:
模块引脚 | 单片机引脚 |
---|---|
OLED_SCL | PB8 |
OLED_SDA | PB9 |
按键 K1 | PC9 |
按键 K2 | PC8 |
TB6612_AIN1 | PB12 |
TB6612_AIN2 | PB13 |
编码器 A 相 | PB6 |
编码器 B 相 | PB7 |
1.2 代码展示
TB6612 的驱动非常简单,使用两个普通的 GPIO 输出高低电平控制电机正反转,再使用一个复用定时器的 IO 生成一个 PWM 控制电机转速即可。
motor.c
部分代码如下:
#include "motor.h"
/**
* @brief 电机方向控制引脚设置
* @param None
* @retval None
*/
static void motor_gpio_Init(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); // 使能 PB 端口时钟
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12 | GPIO_Pin_13; // 端口配置
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; // 推挽输出
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; // 50M
GPIO_Init(GPIOB, &GPIO_InitStructure); // 根据设定参数初始化 GPIOB
}
/**
* @brief 定时器初始化
* @param arr:自动重装值,设为一个时钟频率的最大值
* @param psc:预分频值
* @retval None
*/
void Motor_PWM_Init(u16 arr, u16 psc)
{
GPIO_InitTypeDef GPIO_InitStructure;
TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;
TIM_OCInitTypeDef TIM_OCInitStructure;
motor_gpio_Init();
RCC_APB2PeriphClockCmd(RCC_APB2Periph_TIM1, ENABLE); // 使能 TIM1 时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // 使能 GPIOA 时钟
// 设置该引脚为复用输出功能, 输出 TIM1 CH1 CH4 的 PWM 脉冲波形
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_11; // TIM_CH1 // TIM_CH4
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; // 复用推挽输出
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
TIM_TimeBaseStructure.TIM_Period = arr; // 设置在下一个更新事件装入活动的自动重装载寄存器周期的值
TIM_TimeBaseStructure.TIM_Prescaler = psc; // 设置用来作为 TIMx 时钟频率除数的预分频值
TIM_TimeBaseStructure.TIM_ClockDivision = 0; // 设置时钟分割: TDTS = Tck_tim
TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; // TIM 向上计数模式
TIM_TimeBaseInit(TIM1, &TIM_TimeBaseStructure); // 根据 TIM_TimeBaseInitStruct 中指定的参数初始化 TIMx 的时间基数单位
TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; // 选择定时器模式: TIM 脉冲宽度调制模式 1
TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; // 比较输出使能
TIM_OCInitStructure.TIM_Pulse = 0; // 设置待装入捕获比较寄存器的脉冲值
TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; // 输出极性: TIM 输出比较极性高
TIM_OC4Init(TIM1, &TIM_OCInitStructure); // 根据 TIM_OCInitStruct 中指定的参数初始化外设 TIMx
TIM_CtrlPWMOutputs(TIM1, ENABLE); // MOE 主输出使能
TIM_OC4PreloadConfig(TIM1, TIM_OCPreload_Enable); // CH4 预装载使能
TIM_ARRPreloadConfig(TIM1, ENABLE); // 使能 TIMx 在 ARR 上的预装载寄存器
TIM_Cmd(TIM1, ENABLE); // 使能 TIM1
}
使用一个函数即可,输入的是带符号的整型变量,正负号代表选择方向,绝对值代表占空比。
/**
* @brief 电机输出控制
* @param motor_pwm: 占空比 0-7200
* @retval None
*/
void Set_Pwm(int motor_pwm)
{
if (motor_pwm > 0)
{
BIN1 = 1;
BIN2 = 0; // 前进
}
else if (motor_pwm < 0)
{
BIN1 = 0;
BIN2 = 1; // 后退
}
else // 停止
{
BIN1 = 0;
BIN2 = 0;
}
PWMB = myabs(motor_pwm);
TIM_SetCompare4(TIM1, PWMB);
}
1.3 效果展示
OLED 的第一行显示运行状态(ON 或 OFF),第二行显示电机 PWM 数值(+ 表示正转,- 表示反转)。按下 K1 切换电机旋转方向,按下 K2,电机启动/关闭。
二、电机速度读取
所用的编码器是一个霍尔传感器,两个霍尔元件相差 90 度放置。编码电机速度的读取方式从单片机读取方式上分有定时器输入捕获法和外部中断法,从编码器原理上又分为二倍频和四倍频,从速度计算方式上又分为 M 法测速和 T 法测速。
下面简单了解 M 法测速和 T 法测速。
-
M 法测速:在一个固定的计时周期内,统计这段时间内的编码器脉冲数,从而计算速度值。
转速 n n n 的计算公式为:
n = M 0 C × T 0 n = \frac{M_0}{C \times T_0} n=C×T0M0
其中:
- C C C:编码器单圈总脉冲数
- T 0 T_0 T0:计数周期,单位为秒(s)
- M 0 M_0 M0:在计数周期内统计到的编码器脉冲数
假设已知编码器转过一圈需要 100 个脉冲,在 100ms 内测得产生了 20 个脉冲。通过公式计算 n = 20 100 × 0.1 = 2 n = \frac{20}{100 \times 0.1} = 2 n=100×0.120=2(圈/秒)。
-
T 法测速:这种方法是建立一个已知频率的高频脉冲并对其计数。
转速 n n n 的计算公式为:
n = F 0 C × M 1 n = \frac{F_0}{C \times M_1} n=C×M1F0
其中:
- C C C:编码器单圈总脉冲数
- F 0 F_0 F0:高频脉冲的频率
- M 1 M_1 M1:一个脉冲转的圈数
T 法是利用一个已知脉冲来测量编码器两个脉冲之间的时间,从而计算出速度。
假设编码器转过一圈需要 100 个脉冲( C = 100 C = 100 C=100),则 1 个脉冲转了 1 100 \frac{1}{100} 1001 圈,用时为 20ms,即 50Hz,转速为 0.5 圈/秒。
M 法测速和 T 法测速是解决速度计算层面的问题,下面以定时器输入捕获法和外部中断法展开讨论。
2.1 定时器输入捕获法
2.1.1 定时器输入捕获内部结构
首先是两个正交信号从 GPIO 输入到滤波器,然后再进行边沿检测和极性选择,接到定时器内部的编码器接口。
使用定时器输入捕获法测速其实是利用了 STM32 定时器自带的编码器接口功能:
编码器接口可接收增量(正交)编码器的信号,根据编码器旋转产生的正交信号脉冲,自动控制 CNT 自增或自减,从而指示编码器的位置、旋转方向和旋转速度。
- 每个高级定时器和通用定时器都拥有 1 个编码器接口。
- 两个输入引脚借用了输入捕获的通道 1 和通道 2。
下面是计数方向与编码信号的关系:
2.1.2 代码展示
encoder.c
部分代码如下:
/**
* @brief 编码器初始化, 使用定时器输入捕获法
* @param None
* @retval None
*/
void Encoder_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
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(GPIOB, &GPIO_InitStructure);
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1;
TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInitStructure.TIM_Period = 65536 - 1; // ARR
TIM_TimeBaseInitStructure.TIM_Prescaler = 1 - 1; // PSC
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM4, &TIM_TimeBaseInitStructure);
TIM_ICInitTypeDef TIM_ICInitStructure;
TIM_ICStructInit(&TIM_ICInitStructure);
TIM_ICInitStructure.TIM_Channel = TIM_Channel_1;
TIM_ICInitStructure.TIM_ICFilter = 0xF;
TIM_ICInit(TIM4, &TIM_ICInitStructure);
TIM_ICInitStructure.TIM_Channel = TIM_Channel_2;
TIM_ICInitStructure.TIM_ICFilter = 0xF;
TIM_ICInit(TIM4, &TIM_ICInitStructure);
/* TI1 和 TI2 都计数,上升沿计数 */
TIM_EncoderInterfaceConfig(TIM4, TIM_EncoderMode_TI12, TIM_ICPolarity_Rising, TIM_ICPolarity_Rising);
TIM_Cmd(TIM4, ENABLE);
}
/**
* @brief 获取定时器的计数值
* @param None
* @retval None
*/
int16_t Encoder_Get(void)
{
int16_t Temp;
Temp = TIM_GetCounter(TIM4);
TIM_SetCounter(TIM4, 0);
return Temp;
}
此外,还需要再使用一个定时器定时采集速度:
/**
* @brief 定时器中断,每 100ms 更新一次速度
* @param None
* @retval None
*/
void TIM2_IRQHandler(void)
{
if (TIM_GetITStatus(TIM2, TIM_IT_Update) == SET)
{
Speed = Encoder_Get();
TIM_ClearITPendingBit(TIM2, TIM_IT_Update);
}
}
2.2 外部中断法
2.2.1 外部中断法简介
使用单片机引脚的跳变信号触发中断,然后在中断里判断两个编码器引脚的电平,让计数值增加或者减少。我们规定,正转计数值 + 1 +1 +1,反转计数值 − 1 -1 −1。
采用 4 倍频计数,即 A 相的上升沿、下降沿和 B 相的上升沿、下降沿都触发中断。
A 相边沿 | B 相电平 | 对应区域 | 计数值 |
---|---|---|---|
上升沿 | L | 2 | Encoder_EXTI++ |
下降沿 | H | 4 | Encoder_EXTI++ |
B 相边沿 | A 相电平 | 对应区域 | 计数值 |
---|---|---|---|
上升沿 | H | 3 | Encoder_EXTI++ |
下降沿 | L | 1 | Encoder_EXTI++ |
当电机反转时,A 相、B 相的信号与正转时的相位相差 90 度。换句话说,正转时 A 相先触发上升沿,反转时 B 相先触发上升沿。
A 相边沿 | B 相电平 | 对应区域 | 计数值 |
---|---|---|---|
上升沿 | H | 3 | Encoder_EXTI– |
下降沿 | L | 1 | Encoder_EXTI– |
B 相边沿 | A 相电平 | 对应区域 | 计数值 |
---|---|---|---|
上升沿 | L | 2 | Encoder_EXTI– |
下降沿 | H | 4 | Encoder_EXTI– |
2.2.2 代码展示
部分代码如下:
/**
* @brief 编码器初始化, 使用外部中断法
* @param None
* @retval None
*/
void Encoder_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct;
EXTI_InitTypeDef EXTI_InitStruct;
NVIC_InitTypeDef NVIC_InitStruct;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPD;
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7;
GPIO_Init(GPIOB, &GPIO_InitStruct);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE);
// 设置 IO 口与中断线的映射关系
GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource6);
GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource7);
// 初始化线上中断
EXTI_InitStruct.EXTI_Line = EXTI_Line6 | EXTI_Line7;
EXTI_InitStruct.EXTI_LineCmd = ENABLE;
EXTI_InitStruct.EXTI_Mode = EXTI_Mode_Interrupt;
EXTI_InitStruct.EXTI_Trigger = EXTI_Trigger_Rising_Falling; // 跳变沿触发
EXTI_Init(&EXTI_InitStruct);
// 配置中断分组
NVIC_InitStruct.NVIC_IRQChannel = EXTI9_5_IRQn;
NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStruct.NVIC_IRQChannelSubPriority = 0;
NVIC_Init(&NVIC_InitStruct);
}
/**
* @brief 中断服务函数,采用 4 倍频测速
* @param None
* @retval None
*/
int Encoder_EXTI = 0;
void EXTI9_5_IRQHandler(void)
{
if (EXTI_GetITStatus(EXTI_Line6) != RESET) // 右轮 A 相 PB6
{
EXTI_ClearITPendingBit(EXTI_Line6); // 清除 LINE 上的中断标志位
if (PBin(6) == 0) // 这里判断检测到的是否是下降沿
{
if (PBin(7) == 0) Encoder_EXTI++; // B 相的电平如果是低,电机就是正转加 1
else Encoder_EXTI--; // 否则就是反转减 1
}
else // 上升沿
{
if (PBin(7) == 1) Encoder_EXTI++; // B 相电平如果为高,电机就是正转加 1
else Encoder_EXTI--; // 否则就是反转减 1
}
}
if (EXTI_GetITStatus(EXTI_Line7) != RESET) // 右轮 B 相 PB7
{
EXTI_ClearITPendingBit(EXTI_Line7); // 清除 LINE 上的中断标志位
if (PBin(7) == 0) // 这里判断检测到的是否是下降沿
{
if (PBin(6) == 1) Encoder_EXTI++; // A 相的电平如果是高,电机就是正转加 1
else Encoder_EXTI--; // 否则就是反转减 1
}
else // 上升沿
{
if (PBin(6) == 0) Encoder_EXTI++; // A 相电平如果为低,电机就是正转加 1
else Encoder_EXTI--; // 否则就是反转减 1
}
}
}
/**
* @brief 获取中断的计数值
* @param None
* @retval None
*/
int16_t Encoder_Get(void)
{
int16_t Temp;
Temp = Encoder_EXTI;
Encoder_EXTI = 0;
return Temp;
}
/**
* @brief 定时器中断,每 100ms 更新一次速度
* @param None
* @retval None
*/
void TIM2_IRQHandler(void)
{
if (TIM_GetITStatus(TIM2, TIM_IT_Update) == SET)
{
Speed = Encoder_Get();
TIM_ClearITPendingBit(TIM2, TIM_IT_Update);
}
}
2.2.3 效果展示
关于电机轮子实际转速的计算:
-
首先根据速度的计算公式本质
v = s t v = \frac{s}{t} v=ts
其中:
- t t t:已知的编码器计数周期(100ms)
- s s s:轮子在这个计数周期内走过的路程
-
下面计算 s s s:
我们知道,编码器采集的是单位时间内的脉冲个数,而且编码器的码盘安装在电机轴的末端,测的是电机输出轴的速度。电机轴还需要经过减速器才能和轮子相连。
因此,我们可以先算出单位时间内,一个脉冲轮子走过的距离:
l = 2 × π × R 4 × n × i l = \frac{2 \times \pi \times R}{4 \times n \times i} l=4×n×i2×π×R
其中:
- 2 × π × R 2 \times \pi \times R 2×π×R 是轮子周长, R R R 是轮子半径(34mm)
- 使用 4 倍频计数, n n n 是编码器的码盘转一圈的脉冲数(11)
- i i i 是电机的减速比(30)
-
一个脉冲轮子走过的距离可以直接手算出来,那么我们再乘以编码器测量的脉冲个数,就可以知道轮子在这个计数周期内走过的路程 s s s:
s = m × l s = m \times l s=m×l
其中 m m m 是编码器测量的脉冲个数。
上述计算过程使用 C 代码表示如下:
/**
* @brief 编码器读数转换为轮子速度(mm/s)
* @param encoder:编码器计数
* @retval : Velocity 轮子速度
*/
int Get_Velocity_Form_Encoder(int encoder)
{
float Distance, Velocity;
Distance = 2 * 3.14159 * 34 / (4 * 11 * 30); // 单位是 mm
Velocity = encoder * Distance / 0.1; // 单位是 mm/s。0.1 就是编码器计数周期 100ms,0.1s
return Velocity;
}
三、位置式 PID
3.1 计算公式
在电机控制中,我们给电机输出的是一个 PWM 占空比的数值。
位置式 PID 控制的输出公式为:
Pwm = K p ⋅ e ( k ) + K i ⋅ ∑ j = 0 k e ( j ) + K d [ e ( k ) − e ( k − 1 ) ] \text{Pwm} = K_p \cdot e(k) + K_i \cdot \sum_{j=0}^{k} e(j) + K_d \left[ e(k) - e(k-1) \right] Pwm=Kp⋅e(k)+Ki⋅j=0∑ke(j)+Kd[e(k)−e(k−1)]
其中:
- e ( k ) e(k) e(k):本次偏差
- e ( k − 1 ) e(k-1) e(k−1):上一次的偏差
- ∑ j = 0 k e ( j ) \sum_{j=0}^{k} e(j) ∑j=0ke(j): e ( k ) e(k) e(k) 及之前偏差的累积和(其中 k k k 为 1, 2, …)
- Pwm \text{Pwm} Pwm:输出
- K p K_p Kp:比例项参数
- K i K_i Ki:积分项参数
- K d K_d Kd:微分项参数
控制流程图如下:
上图中的目标位置一般可以通过按键或者开关等方式编程实现改变目标值,测量位置就是通过 STM32 去采集编码器的数据。目标位置和测量位置之间做差,这个就是目前系统的偏差。送入 PID 控制器进行计算输出,然后再经过电机驱动的功率放大控制电机的转动去减小偏差,最终达到目标位置的过程。
3.2 C 语言实现
位置式 PID 具体通过 C 语言实现的代码如下:
先定义一个 PID 参数的结构体:
typedef struct
{
float target_val; // 目标值
float Error; // 第 $k$ 次偏差
float LastError; // $\text{Error}[-1]$,第 $k-1$ 次偏差
float PrevError; // $\text{Error}[-2]$,第 $k-2$ 次偏差
float Kp, Ki, Kd; // 比例、积分、微分系数
float integral; // 积分值
float output_val; // 输出值
} PID;
然后定义一个 PID 参数初始化的函数:
/**
* @brief PID 参数初始化
* @note 无
* @retval 无
*/
void PID_param_init(void)
{
PosionPID.target_val = 3600;
PosionPID.output_val = 0.0;
PosionPID.Error = 0.0;
PosionPID.LastError = 0.0;
PosionPID.integral = 0.0;
PosionPID.Kp = 10;
PosionPID.Ki = 0.5;
PosionPID.Kd = 0.8;
}
最后根据公式写出位置式 PID 的实现函数:
/**
* @brief 位置 PID 算法实现
* @param actual_val: 实际测量值
* @note 无
* @retval 通过 PID 计算后的输出
*/
float PosionPID_realize(PID *pid, float actual_val)
{
/* 计算目标值与实际值的误差 */
pid->Error = pid->target_val - actual_val;
/* 积分项 */
pid->integral += pid->Error;
/* PID 算法实现 */
pid->output_val = pid->Kp * pid->Error +
pid->Ki * pid->integral +
pid->Kd * (pid->Error - pid->LastError);
/* 误差传递 */
pid->LastError = pid->Error;
/* 返回当前实际值 */
return pid->output_val;
}
函数入口参数为编码器的速度测量值和 PID 参数的结构体,返回值为电机控制 PWM。
四、增量式 PID
4.1 计算公式
增量式 PID 也称速度环 PID,速度闭环控制就是根据单位时间获取的脉冲数(这里使用了 M 法测速)测量电机的速度信息,并与目标值进行比较,得到控制偏差,然后通过对偏差的比例、积分、微分进行控制,使偏差趋向于零的过程。
增量式 PID 控制的输出公式为:
Pwm + = K p [ e ( k ) − e ( k − 1 ) ] + K i ⋅ e ( k ) + K d [ e ( k ) − 2 e ( k − 1 ) + e ( k − 2 ) ] \text{Pwm} += K_p \left[ e(k) - e(k-1) \right] + K_i \cdot e(k) + K_d \left[ e(k) - 2e(k-1) + e(k-2) \right] Pwm+=Kp[e(k)−e(k−1)]+Ki⋅e(k)+Kd[e(k)−2e(k−1)+e(k−2)]
其中:
- e ( k ) e(k) e(k):本次偏差
- e ( k − 1 ) e(k-1) e(k−1):上一次的偏差
- e ( k − 2 ) e(k-2) e(k−2):上上次的偏差
- K p K_p Kp:比例项参数
- K i K_i Ki:积分项参数
- K d K_d Kd:微分项参数
- Pwm \text{Pwm} Pwm:代表增量输出
在速度控制闭环系统中只使用 PI 控制,因此对 PID 控制器可简化为以下公式:
P w m + = K p [ e ( k ) − e ( k − 1 ) ] + K i ⋅ e ( k ) Pwm += K_p [e(k) - e(k-1)] + K_i \cdot e(k) Pwm+=Kp[e(k)−e(k−1)]+Ki⋅e(k)
控制框图和位置式的一样。
上图中的目标速度一般可以通过按键或者开关等方式编程实现改变目标值,测量速度前面在编码器的章节已经有提到,即通过单片机定时去采集编码器的数据并清零。目标速度和测量速度之间做差,这个就是目前系统的偏差。送入 PID 控制器进行计算输出,然后再经过电机驱动的功率放大控制电机的转动去减小偏差,最终达到目标速度的过程。
4.2 C 语言实现
增量式 PID 的结构体定义和成员初始化与位置式相同,通过 C 语言实现的代码如下:
/**
* @brief 速度 PID 算法实现
* @param actual_val: 实际值
* @note 无
* @retval 通过 PID 计算后的输出
*/
float addPID_realize(PID *pid, float actual_val)
{
/* 计算目标值与实际值的误差 */
pid->Error = pid->target_val - actual_val;
/* PID 算法实现,照搬公式 */
pid->output_val += pid->Kp * (pid->Error - pid->LastError) +
pid->Ki * pid->Error +
pid->Kd * (pid->Error - 2 * pid->LastError + pid->PrevError);
/* 误差传递 */
pid->PrevError = pid->LastError;
pid->LastError = pid->Error;
/* 返回当前实际值 */
return pid->output_val;
}
函数入口参数为编码器的速度测量值和 PID 参数的结构体,返回值为电机控制 PWM。可以看出增量式 PID 只与最近三次的测量值有关。
五、串级 PID
串级 PID 就是先输入位置 PID,再经过速度 PID,最后输出。
六、P、I、D 各个参数的作用
自动控制系统的性能指标主要有三个方面:稳定性、快速性和准确性。
-
稳定性:系统在受到外作用后,若控制系统使其被控变量随时间的增长而最终与给定期望值一致,则称系统是稳定的,我们一般称为系统收敛。
如果被控量随时间的增长,越来越偏离给定值,则称系统是不稳定的,我们一般称为系统发散。
稳定的系统才能完成自动控制的任务,所以,系统稳定是保证控制系统正常工作的必要条件。一个稳定的控制系统其被控量偏离给定值的初始偏差应随时间的增长逐渐减小并趋于零。
-
快速性:快速性是指系统的动态过程进行的时间长短。过程时间越短,说明系统快速性越好,过程时间持续越长,说明系统响应迟钝,难以实现快速变化的指令信号。稳定性和快速性反映了系统在控制过程中的性能。系统在跟踪过程中,被控量偏离给定值越小,偏离的时间越短,说明系统的动态精度偏高。
-
准确性:是指系统在动态过程结束后,其被控变量(或反馈量)对给定值的偏差而言,这一偏差即为稳态误差,它是衡量系统稳态精度的指标,反映了动态过程后期的性能。
在实践生产工程中,不同的控制系统对控制器效果的要求不一样。比如平衡车、倒立摆对系统的快速性要求很高,响应太慢会导致系统失控。智能家居里面的门窗自动开合系统,对快速性要求就不高,但是对稳定性和准确性的要求就很高,所以需要严格控制系统的超调量和静差。
总结
本文主要介绍了在电机的 PID 控制中常用的位置式 PID 和增量式 PID。
上述基于 STM32 的 PID 电机控制源码库链接:
- stm32_Motor_PID: 个人学习 PID 时做的,基于 stm32 的 PID 电机控制源码,本实验包含 6 个电机控制实验,电机的 PWM 控制,编码器测速_定时器捕获法和外部中断法,位置式 PID,增量式 PID, 串级 PID,代码简洁易懂,十分适合 PID 入门。 - Gitee.com
https://gitee.com/tang176084/stm32_Motor_PID/tree/master
via:
-
STM32—PID 控制在直流电机中的应用_分析 p,i,d 控制参数对直流电机运行的影响 - CSDN 博客
https://blog.csdn.net/qq_43743762/article/details/105827410 -
stm32 直流电机控制 —PID 算法篇_直流电机速度控制的 pid 算法 - CSDN 博客
https://blog.csdn.net/weixin_43281206/article/details/108916349 -
基于 stm32 的减速直流电机 PID 算法控制_直流减速电机的控制 - CSDN 博客
https://blog.csdn.net/black_sneak/article/details/125237524 -
使用 stm32 实现电机的 PID 控制_stm32pid 控制电机 - CSDN 博客
https://blog.csdn.net/weixin_43811044/article/details/127956227