简介:基于STM32F103ZET6与编码器直流电机的模糊自整定增量式PID速度控制例程,面向嵌入式初学者及需要优化电机调速效果的开发者,相较普通增量式PID,其模糊自整定机制能根据运行状态动态调整参数,改善响应速度与稳态精度。程序沿用正点原子工程风格,按功能分块书写并附加注释,逻辑清晰,适合学习与二次开发。整个压缩包共170个文件,4.74MB,以C源文件、头文件为主,并包含Keil工程配置、编译产物及启动文件等,可直接用Keil打开查看或烧录,辅助的txt说明与bat脚本也有助于理解使用,已有4282人学习下载。内容覆盖定时器PWM输出、编码器速度读取、模糊规则表构建与增量式PID计算等关键环节,能直观看到完整工程的组织方式;经作者实测可靠性较高,可帮助初学者快速掌握模糊PID在真实电机控制中的嵌入方法,缩短项目调试时间。
1. 模糊自整定增量式PID:为什么电机调速的"玄学"需要一套规则来终结
电机速度控制是嵌入式开发里最典型的"调参玄学"现场。同一套固定PID参数,今天带空载跑得稳稳当当,明天换个负载或者电池电压掉了一点就原地振荡,甚至直接飞车。模糊自整定增量式PID的思路,是把老师傅调参时"误差大就加大比例、误差小就减弱微分"的手感,翻译成一张张模糊规则表,让STM32F103在每次控制周期里实时修正Kp、Ki、Kd三个系数。要真正搞懂这套机制,得拆开三层来看:增量式PID解决执行机构的安全问题,编码器测速决定反馈量的可信度,模糊规则决定参数怎么跟着工况变。这三层各自独立又互相牵制,任何一层没吃透,最后控制效果都会莫名其妙。这篇文章就从这三层一直拆到代码细节,讲清楚每个环节为什么这么设计、算错了会出什么现象。
2. 增量式PID:为什么速度环只能"算差值"而不能"算全量"
2.1 位置式PID的三个病根:积分饱和、误动作、启动冲击
很多入门教材讲PID都是从位置式开始的,输出u(k)直接等于比例项加积分项加微分项的和。这个形式数学上很干净,但落到电机调速场景里,它有三个绕不开的病根。
第一个是积分饱和。电机启动瞬间,实际速度远低于目标值,误差长时间很大,积分项会不停累加,输出很快就顶到PWM占空比上限100%。等电机终于加速到位,误差变小了,但积分项里攒的那一大坨"历史债"还在,需要好几秒才能消化完,表现为速度冲过目标值一大截才慢慢回来。增量式PID因为每次只计算输出的变化量,输出本身靠累加得到,天然没有这个历史债问题。
第二个是误动作。位置式PID的输出是绝对量,一旦编码器受到干扰跳了一个大数,误差瞬间变得很大,比例项和微分项会跟着猛跳,PWM占空比直接突变,电机会"咯噔"一下。增量式PID计算的是相邻两次误差的差值,单个跳变点会被差分运算过滤掉一部分,对执行机构的冲击小得多。
第三个是启动冲击。位置式PID上电瞬间,误差从0跳到目标值,微分项对误差突变极其敏感,会输出一个很大的尖峰。增量式PID同样有这个问题,但因为它输出的是增量而不是绝对值,限幅逻辑可以做得更细致——直接对增量限幅,再对累加后的输出限幅,实现双保险。
所以位置式PID不是不能用,而是它把"控制量应该落在哪里"和"控制量应该怎么变"两个问题混在了一起。增量式PID只回答后一个问题,把绝对位置交给积分累加去管,职责分离之后,限幅、防饱和、手自动切换都变得简单了。
2.2 增量式PID的递推公式与代码实现
增量式PID的推导不复杂,从位置式PID公式出发,写出u(k)和u(k-1),两者相减,中间三项全部抵消,最后留下:
Δu(k) = Kp * [e(k) - e(k-1)] + Ki * e(k) + Kd * [e(k) - 2*e(k-1) + e(k-2)]最终输出 u(k) = u(k-1) + Δu(k)。注意这个式子里的Ki是严格从积分系数里继承来的,它乘的是当前误差e(k),不是误差累加和。
落到STM32F103上的完整实现长这样:
typedef struct { float Kp, Ki, Kd; float err_last; // e(k-1) float err_prev; // e(k-2) float out_last; // u(k-1) float out_limit; // 输出限幅值 } IncPID; float IncPID_Calc(IncPID *pid, float target, float actual) { float err = target - actual; float d_err = err - pid->err_last; // Δe(k) float dd_err = err - 2.0f * pid->err_last + pid->err_prev; // 二阶差分 float d_out = pid->Kp * d_err + pid->Ki * err + pid->Kd * dd_err; // 增量限幅:防止单次输出突变 if (d_out > pid->out_limit) d_out = pid->out_limit; if (d_out < -pid->out_limit) d_out = -pid->out_limit; pid->out_last += d_out; // 累加得到实际输出 // 输出限幅:防止占空比越界 if (pid->out_last > 100.0f) pid->out_last = 100.0f; if (pid->out_last < -100.0f) pid->out_last = -100.0f; // 状态滚动更新 pid->err_prev = pid->err_last; pid->err_last = err; return pid->out_last; }这个实现的几个关键点值得展开。增量限幅放在累加之前,目的是把单次控制周期的变化量约束在一个安全范围内,比如占空比每周期最多变化5%。这样即使误差跳变,执行机构也不会瞬间拉满。输出限幅放在累加之后,管的是最终结果。两道限幅各自独立,缺一不可:只有输出限幅,增量可能仍然很大,电机虽然不至于超限但会剧烈抖动;只有增量限幅,累加结果仍然可能积到超过100%。
还有一个容易踩的坑是状态更新顺序。函数末尾必须先更新err_prev再更新err_last。反过来写的话,e(k-1)会被覆盖成当前误差,二阶差分计算就全错了。这种错误在仿真里跑不出明显差异,上了真机往往是间歇性振荡,排查起来很费劲。
2.3 增量式在STM32上的定点数陷阱
STM32F103没有硬件浮点单元,F1系列的内核是ARM Cortex-M3,浮点运算全靠编译器库函数模拟,一次浮点乘加要几十个周期。在20kHz的控制频率下,控制周期只有50微秒,留给PID计算的时间预算非常紧张。
常见的做法是用Q格式定点数。比如把Kp放大64倍用Q6格式存储,计算完再右移回来。但这里有一个隐蔽的精度问题:增量式PID的微分项包含二阶差分[e(k) - 2e(k-1) + e(k-2)],当速度稳定时,三个误差值都接近零,它们的差分可能会得到很小的数。定点化之后,这个数可能直接变成0,微分项就失效了。
所以实际项目中我见过两种解法。一种是把Kd单独放大到更大的Q格式,比如Q10以上,牺牲一点溢出风险换取低速段的微分灵敏度。另一种更常见——干脆把控制频率降低到1kHz到5kHz,让STM32F103有充足余量跑浮点。电机速度环的带宽通常只有几十赫兹,控制频率超过1kHz后,再往上加对动态响应几乎没有改善,纯属浪费算力。这也是为什么很多商品驱动器速度环跑到10kHz以上时会专门强调用了更高主频的芯片,F103的定位决定了它更适合理顺浮点计算而非强行定点优化。
代码里还有一个隐含的定时器协同问题:IncPID_Calc只是计算函数,它必须在定时器中断里被周期调用。中断频率和PWM频率最好同步,这样每次PWM周期更新一次占空比,避免中断异步导致PWM更新时刻不确定。
3. 编码器测速:M法、T法还是M/T法,速度反馈的精度决定控制上限
3.1 定时器编码器模式与正交解码
PID公式里的actual值不是天上掉下来的,它来自编码器反馈。STM32F103的定时器有一个编码器接口模式,硬件上直接把A、B两相正交信号接到定时器的两个通道输入,定时器自动根据两相信号的相位关系判断旋转方向,并在计数器里累加脉冲数。这个过程完全由硬件完成,不占CPU。
// 以TIM3为例,初始化编码器模式 TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_ICInitTypeDef TIM_ICStructure; TIM_TimeBaseStructure.TIM_Prescaler = 0x00; TIM_TimeBaseStructure.TIM_Period = 0xFFFF; // 计数器范围 TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); TIM_ICStructure.TIM_Channel = TIM_Channel_1; TIM_ICStructure.TIM_ICPolarity = TIM_ICPolarity_Rising; TIM_ICStructure.TIM_ICSelection = TIM_ICSelection_DirectTI; TIM_ICStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1; TIM_ICStructure.TIM_ICFilter = 0x0F; TIM_ICInit(TIM3, &TIM_ICStructure); TIM_ICStructure.TIM_Channel = TIM_Channel_2; TIM_ICStructure.TIM_ICPolarity = TIM_ICPolarity_Rising; TIM_ICStructure.TIM_ICSelection = TIM_ICSelection_IndirectTI; TIM_ICInit(TIM3, &TIM_ICStructure); TIM_EncoderInterfaceConfig(TIM3, TIM_EncoderMode_TI12, TIM_ICPolarity_Rising, TIM_ICPolarity_Rising);配置成TI12模式后,定时器同时采集两个通道的边沿,A相和B相各产生一个上升沿和下降沿,总共四倍频。这意味着一个每圈200线的增量编码器,经过4倍频后每圈能产生800个计数脉冲。方向判断也是硬件自动完成的:A相超前B相90度时计数器加,落后时减。
采集周期内只需要读一次TIM3->CNT,然后清零或减去上次读到的值,就能得到这段时间的脉冲增量。整个过程不打断主循环,非常适合20kHz的控制节奏。
3.2 M法低速失真与T法高速溢出
编码器测速有两大类方法,M法和T法,二者在低速和高速各有死穴。
M法是在固定时间窗口T内计数脉冲个数,速度 = 脉冲数 / (线数 × 倍频 × T)。窗口越长、编码器线数越多,低速时脉冲数就越充裕,精度越高。但M法有一个绕不开的缺陷:当转速很低,一个窗口内可能只有几个甚至零个脉冲,速度分辨率急剧恶化,表现为控制量在零和某个小值之间抖动。
T法正好反过来,它测量相邻两个脉冲的时间间隔,速度 = 时钟频率 / (线数 × 倍频 × 计数值)。高速时脉冲间隔极短,计数值很小,一个计数单位的误差就能造成很大的速度跳动,所以T法适合低速不适合高速。
M/T法则是在一个同步窗口内同时记录脉冲数和时钟周期数,在全速范围内都能保持较高精度。但M/T法实现复杂,需要额外的同步逻辑,在F103上用定时器主从级联才能干净地实现。对于大多数电机调速项目,一个折中方案就够了:正常运行的转速范围内用M法,设定一个脉冲率阈值,低于该阈值时切换成T法测速,两块逻辑共用同一个定时器中断。
3.3 实际采用的测速方案与单位换算
这个项目场景里,电机通常配的是霍尔编码器或者光电编码器,每圈几百线的级别。控制周期取5ms(200Hz),用M法测速时,5ms内800个脉冲/圈的码盘,在60转/分的低速下只能产生约80个脉冲,分辨率已经很紧张了。所以实际代码里不会直接把脉冲数当速度,而是换算成标准单位:
#define PULSE_PER_REV 800 // 编码器线数 × 4倍频 #define SAMPLING_TIME 0.005f // 5ms #define TO_RPM 60.0f // 脉冲数 → 转/分 float Encoder_GetSpeed(int16_t pulse_delta) { float rev_per_sec = (float)pulse_delta / PULSE_PER_REV / SAMPLING_TIME; float rpm = rev_per_sec * 60.0f; return rpm; }换算本身不复杂,真正的坑在于pulse_delta可能为负。电机反转时,TIM3->CNT是在递减的,差值自然是负的,速度函数必须返回带符号的值。有的项目图省事,直接把计数器差值取绝对值,结果正反转速度一致,PID看到的速度永远是正的,控制量就会往错误方向调整。
另一个常见问题是上位机把转速显示成整数,但PID内部用浮点,两者不一致时你永远不知道真实速度到底是多少。调试阶段我会强制在串口打印原始脉冲数、换算后的浮点转速以及PID输出三者,任何一层异常都能立刻定位。
测速代码必须在固定的时间基准里执行。如果用的是SysTick中断,一旦主循环里有阻塞操作把SysTick卡住,采样周期就飘了,M法的窗口时间不对,算出来的速度全都是错的。这也是很多"速度显示乱跳"问题的根源——不是编码器坏了,是采样基准不刚性。
4. 模糊自整定:把"调参经验"变成一张查表
4.1 模糊控制器的输入输出:误差和误差变化率
固定PID的参数选定之后就不变了,模糊自整定要做的是在运行过程中动态调整Kp、Ki、Kd。调整的依据是什么?是当前控制误差e和误差变化率ec。这两者在控制理论里本来就携带了关键信息:误差大说明偏差严重,误差变化率大说明系统正在快速偏离或快速恢复。
// 模糊PID控制器结构 typedef struct { float Kp_base, Ki_base, Kd_base; // 基准参数 float Ke, Kec; // 输入量化因子 float Ku_kp, Ku_ki, Ku_kd; // 输出比例因子 float e_last; // 上一周期误差 float rule_base[3][7][7]; // 三张规则表 } FuzzyPID;每次控制周期做四件事:计算误差e和误差变化率ec,对它们做量化映射到模糊论域,查规则表得到三个参数增量,然后乘以比例因子还原成真实的Kp、Ki、Kd增量,叠加到基准参数上。
量化因子Ke和Kec的选取直接决定模糊控制器的"灵敏度"。Ke太大,轻微的误差就触发很大的论域变化,参数修正过猛;Ke太小,误差在论域里只有零附近几个档位,模糊控制器形同虚设。经验做法是先测一下系统正常运行时误差的范围,把这个范围映射到[-6, 6]的论域上,Ke就是论域上限除以实际误差上限。
4.2 隶属函数与模糊规则表的设计
模糊化之后,e和ec各自对应一个语言变量值,比如NB(负大)、NM(负中)、NS(负小)、ZO(零)、PS(正小)、PM(正中)、PB(正大)。每个精确数值通过隶属函数计算出对多个语言变量的隶属度。三角形隶属函数计算最简单,工程上用得最多:
float Trimf(float x, float a, float b, float c) { if (x <= a || x >= c) return 0.0f; if (x < b) return (x - a) / (b - a); return (c - x) / (c - b); }规则表是模糊控制器的核心,它编码了调参经验。比如Kp的规则表,典型设计如下:
| e\ec | NB | NM | NS | ZO | PS | PM | PB |
|---|---|---|---|---|---|---|---|
| NB | PB | PB | PM | PM | PS | ZO | ZO |
| NM | PB | PB | PM | PS | PS | ZO | NS |
| NS | PM | PM | PM | PS | ZO | NS | NS |
| ZO | PM | PM | PS | ZO | NS | NM | NM |
| PS | PS | PS | ZO | NS | NS | NM | NM |
| PM | PS | ZO | NS | NM | NM | NM | NB |
| PB | ZO | ZO | NM | NM | NM | NB | NB |
这张表背后的逻辑是:当误差为NB(负大,实际速度远高于目标)且误差变化率为NB(还在加速偏离),此时必须大幅减小比例增益,防止控制量继续猛增;当误差为ZO且变化率为ZO(系统稳定在目标附近),Kp应该回到基准值附近,避免过度调节引入振荡。Ki和Kd各有一张类似的表,Ki在误差小、收敛慢时适当加大,Kd在误差变化率大时加大以抑制超调。
规则表设计没有标准答案,同一个电机不同的人会填出不同的表。但有一条通用约束:表格从左上到右下应该呈现平滑过渡,不能出现相邻格子参数跳变过大的情况,否则模糊插值之后控制量会出现台阶,表现为速度曲线上的微小毛刺。
4.3 模糊推理与解模糊:重心法
对于一个确定的e和ec,经过隶属函数计算后,每个输入会激活多个语言变量值,比如e对"PS"的隶属度是0.6、对"PM"的隶属度是0.3。这两组隶属度交叉组合会命中规则表里多格,每格对应一个输出语言变量。工程上常用的做法是取每条规则激活强度为两个输入隶属度的乘积,然后对所有被激活的规则做加权平均,这就是重心法:
float Fuzzy_Decode(float rule_out[7][7], float mu_e[7], float mu_ec[7]) { float numerator = 0.0f, denominator = 0.0f; for (int i = 0; i < 7; i++) { for (int j = 0; j < 7; j++) { float w = mu_e[i] * mu_ec[j]; // 激活强度 numerator += w * rule_out[i][j]; denominator += w; } } if (denominator == 0.0f) return 0.0f; return numerator / denominator; // 输出在[-6, 6]论域内 }上面这段代码的执行开销让很多人意外:49次浮点乘法加49次浮点加法,在F103上做完一次需要几十微秒。如果每周期对Kp、Ki、Kd各跑一遍,就是接近200微秒,控制周期直接被占用了一半以上。所以真正移植到F103上时,普遍做法是离线生成一张查询表——把输入论域[-6, 6]按0.25步长离散成49个点,提前算出所有输入组合的解模糊结果,存成49×49×3的一张表。运行时只需要做两次查表和线性插值,耗时从几百微秒降到几微秒。
查表方案还有一个额外好处:调试时可以直接把表打印出来检查,规则表有没有填反、输出论域有没有越界,一眼就能看出来。模糊控制器的"黑匣子"属性被摊开成一张可视化的表,这在实际项目里非常重要。
5. 常见问题与避坑:从积分饱和到测速跳变的排查记录
5.1 现象:电机启动猛冲一下然后停转
现象很典型:一上电,电机先是猛地往前冲一段,然后像撞墙一样停住,之后怎么给目标速度都不动。
原因不唯一,但两处嫌疑最大。第一处是增量式PID的状态变量没初始化。err_last和err_prev上电默认是0,但目标速度可能是非零值,第一次计算时e(k-1) = 0 - 0 = 0,e(k) = target - 0,差分项瞬间变得很大,d_out被增量限幅挡住,输出猛跳一下。第二处是模糊PID的基准参数在启动瞬间没有做平滑处理,Kp突然被模糊规则放大到很大,比例项输出过热。
解决方法是三段式启动:先以开环占空比(比如30%)让电机转起来,等编码器反馈的速度接近目标值一半时,再切入PID闭环,同时把模糊PID的输入误差用斜坡函数限制变化率。这相当于给控制器一个"软启动"过程。从那以后我每写一个电机驱动,都会默认加一个开环启动阶段,绝不从静止状态直接切闭环。
5.2 现象:速度在目标值附近周期性振荡
现象:电机启动正常,速度能到目标附近,但始终在那个点附近以固定频率来回摆,幅度有时大有时小。
原因:振荡频率如果和PWM频率无关,而是几赫兹级别的低频振荡,基本可以确定是模糊规则表在目标点附近输出的参数增量过大。误差在ZO附近时,模糊控制器本来应该输出接近零的修正量,但如果规则表里ZO邻域的数值写得偏大,Kp和Ki在目标点附近急剧变化,相当于参数在周期性抖动,系统自然会跟着抖。
解决:先固定Kp、Ki、Kd,把规则表暂时屏蔽,确认系统在固定参数下是稳定的。如果固定参数下也振,那是PID本体参数问题,跟模糊无关。如果固定参数下稳定、开启模糊后振荡,就把规则表里ZO行和ZO列的输出值全部改成接近零的值,再逐步扩大邻域。这一步是模糊PID调试里最磨人的环节,没有捷径,只能一点点逼近。
5.3 现象:模糊PID反而比固定PID更差
现象:理论上模糊自整定应该优于固定参数,实际测下来反而更差,超调更大、收敛更慢。
原因:量化因子和论域映射没调好。Ke设得过大时,目标速度稍微变一点就冲出中心论域,规则表顶到边界,模糊控制器失去调节能力;Kec设得过小时,误差变化率在论域里始终映射不到高值区,微分调节一直没被激活。两个输入有一路"失真",查询表就是废的。
解决:调试时把量化因子当作变量,在串口或上位机里实时显示e映射到论域上的值。如果e经常处于±6附近,说明Ke太大;如果e在运行过程中几乎只在±0.5以内活动,说明Ke太小。正确的量化设置应该让正常工况下的误差和误差变化率落在论域中段三分之一范围内,这样规则表才有足够的调节空间。
5.4 现象:编码器读数跳变导致控制量毛刺
现象:速度显示偶尔跳出一个明显不对的尖峰,紧接着PWM占空比跟着抖动一下,电机发出短暂的异响。
原因:编码器信号受电磁干扰,边沿被抖动触发,定时器计数突然多计或少计了几个脉冲。增量式PID虽然对单点跳变有抑制作用,但如果跳变量足够大,差分项仍然会响应。还有一种隐蔽情况是编码器插头松动,某个边沿丢失,导致计数方向判断错误,读数直接反向。
解决:硬件上把定时器输入滤波打开,TIM_ICFilter设置的0x0F就是输入滤波器的采样次数,滤波值越大抗干扰越强但信号延迟也越大。软件上对速度做一阶低通滤波,比如v_filtered = 0.8 * v_filtered + 0.2 * v_raw,能有效削掉毛刺。要注意滤波系数不能太激进,否则会引入相位延迟,让PID觉得系统响应变慢,反而引发振荡。更稳妥的做法是对脉冲增量做合理性检查:单周期脉冲数超过物理极限(比如最大转速对应的脉冲数)就丢弃这一帧数据。
6. 设计取舍:为什么模糊自整定没有取代传统PID
把整套机制拆完后回头来看,模糊自整定增量式PID的本质,是用一张可查的表把老师的经验固化下来。它的优势在非线性工况下很突出:负载突变时Kp能快速调大,稳定时又能收小,固定参数做不到这种自适应。但它的代价同样明显。
第一是规则表的维护成本。7×7三张表,每格填一个语言值,看起来只有百来个格子,但每个格子背后是人对系统行为的理解。换一台电机、换一个负载惯量,表格就要重新标定。而传统PID只需要调三个数,简单的整定方法比如Ziegler-Nichols法半小时就能给出可用参数。模糊PID在项目初期的参数确定时间上没有任何优势。
第二是稳定性缺乏理论保证。经典PID可以用伯德图、根轨迹分析稳定裕度,模糊PID没有通用的稳定性判据。规则表设计得不合理,系统可能在某些工况下振荡而另一些工况正常,这种"间歇性失控"在产品里是最难接受的。
第三是计算资源问题。即便用了查表法,模糊PID每周期仍要多做两次查表和插值,占用F103的中断时间预算。如果控制周期本来就紧张,多出来的开销会让中断延迟变得不可控。
所以我在实际项目中更倾向于混合策略:系统的常规运行段交给固定PID,模糊自整定只在工况变化时介入,用一个更简单的逻辑——比如负载突变检测到速度偏差超过阈值时,短暂调用模糊规则修正参数,恢复稳定后切回基准值。这样既保住了模糊控制的自适应能力,又把规则表的影响范围限制在一个可控的窗口内。
从那以后我每次接手一个调速项目,都强制自己走一遍"先固定PID跑通、记录阶跃响应、再设计模糊规则表、最后用查询表替换在线推理"的流程。这条路看起来绕,但每走一步都能知道自己在干什么,而不是对着一个黑匣子猜参数。希望帮到你。
本文还有配套的精品资源,点击获取