STM32上从零实现PID控制:位置式与增量式选择、参数整定及避坑指南

发布时间:2026/10/15 19:52:38
STM32上从零实现PID控制:位置式与增量式选择、参数整定及避坑指南
1. 为什么偏偏是PID又为什么偏偏跑在STM32上搞嵌入式控制的人绕不开两个东西一个是STM32一个是PID。前者是硬件平台后者是控制算法两者凑在一起基本就是电机控制、温控、平衡车、云台、电源管理这些项目的标配组合。我最早接触这套组合是做一个小型温控箱当时用继电器做开关控制温度在目标值上下晃了快五度后来换成PID加PWM波动直接压到零点三度以内那个瞬间我才真正理解为什么这个算法能活这么多年。PID这三个字母拆开就是比例、积分、微分。比例项看当前误差误差大就使劲调积分项看历史误差的累积专门对付那种“差一点就是到不了”的稳态偏差微分项看误差变化的趋势提前刹车防止冲过头。三者叠加就能让一个原本迟钝或者暴躁的系统变得听话。STM32之所以适合跑PID核心原因有几个主频够高F1系列都能跑到72MHzF4能到168MHz甚至更高算一个PID循环也就几微秒的事外设丰富定时器、ADC、PWM、编码器接口这些控制需要的东西全都内置成本低一颗F103C8T6几块钱就能撑起一套完整的闭环控制。说白了就是性能够用、外设齐全、价格便宜没有理由不选它。这篇文章适合谁看如果你正在做电机调速、温度控制、位置伺服、平衡装置这类项目或者你已经写过PID但调参调到怀疑人生那接下来的内容应该能帮到你。我会从整体设计思路讲到具体代码实现再到调参方法和踩坑记录尽量把每个环节的“为什么”说清楚而不是只丢一段代码让你自己悟。2. 整体方案设计先想清楚再动手2.1 位置式还是增量式选错了后面全是坑PID在嵌入式里落地第一个要做的决策就是选位置式还是增量式。这两个不是随便选的用错了场景调参能把你逼疯。位置式PID的输出直接对应执行器的绝对量比如PWM占空比、阀门开度。公式是u(k) Kp * e(k) Ki * Σe(i) Kd * [e(k) - e(k-1)]它的特点是输出跟整个历史误差有关积分项需要累加。优点是直观输出就是你要的控制量。缺点是每次计算都要累加积分如果积分限幅没做好很容易积分饱和而且一旦系统重启积分项清零输出会有跳变。增量式PID输出的是控制量的增量公式是Δu(k) Kp * [e(k) - e(k-1)] Ki * e(k) Kd * [e(k) - 2e(k-1) e(k-2)]它不需要累加历史误差只需要最近三次的误差值计算量小天然抗积分饱和适合执行器带积分特性的场景比如步进电机。但它的问题在于如果执行器本身没有记忆功能比如普通的PWM驱动你需要自己维护一个输出累加变量否则增量算出来没地方加。我的经验是温控、电源这类慢过程用位置式电机速度环、电流环用增量式。平衡车、云台这类需要快速响应的位置控制位置式和增量式都有人用但增量式更常见因为它对突变误差的响应更平滑。2.2 采样周期怎么定不是越快越好采样周期是PID控制里最容易被忽视的参数。很多人觉得采样越快控制越精准实际上不是。采样太快相邻两次的误差变化很小微分项几乎为零积分项累积也慢整个PID退化成纯比例控制采样太慢系统响应滞后等你想纠正的时候误差已经跑远了容易振荡。那怎么定工程上有个经验法则采样周期取系统时间常数的十分之一到二十分之一。比如你控制一个加热系统从通电到温度稳定大概需要100秒那采样周期取5到10秒就够了。如果是电机速度环机械时间常数可能只有几十毫秒采样周期就要取1到5毫秒。在STM32上实现时我一般用定时器中断来触发采样和PID计算。比如用TIM2配置成1kHz中断每1毫秒执行一次PID循环。这样采样周期固定不受主循环里其他任务的影响。如果你把PID放在主循环里跑一旦有串口打印或者Flash写入这种耗时操作采样周期就会抖动控制效果会明显变差。注意采样周期一旦确定PID参数就是基于这个周期整定的。如果你后来改了采样周期参数必须重新调不能直接搬。2.3 执行器接口PWM频率和分辨率的取舍STM32控制执行器最常用的方式就是PWM。PWM有两个关键参数频率和分辨率。频率决定了执行器收到的开关速度分辨率决定了你能调节的最小步进。对于电机驱动PWM频率一般取10kHz到20kHz低于10kHz电机会有可闻的啸叫声高于20kHz虽然安静但开关损耗会增加。对于加热丝控制因为热惯性大PWM频率取1Hz到10Hz就够了甚至可以用时间比例控制。对于LED调光频率要高于200Hz避免肉眼可见闪烁但也不能太高以免驱动能力不足。分辨率方面STM32的定时器是16位的理论上可以做到65536级。但实际使用中如果你要同时输出多路PWM或者主频不够高就需要降低分辨率。比如72MHz主频PWM频率20kHz那最大分辨率就是72000000/200003600级约12位。这个分辨率对于电机控制足够了但对于精密温控可能不够这时候可以考虑降低PWM频率来换取更高分辨率。我一般会在初始化时算一下ARR 主频 / (PWM频率 * 预分频) - 1然后根据ARR的值判断分辨率够不够。如果ARR小于1000说明分辨率太低需要调整预分频或者降低PWM频率。3. 核心细节解析从公式到代码的每一步3.1 误差计算里的隐藏陷阱误差就是设定值减去实际值看起来简单但实际写代码时有好几个坑。第一个坑是数据类型。如果你用整数做PID误差的精度会受限于ADC的分辨率。比如12位ADC满量程4096设定值是2000实际值在1999和2001之间跳误差就是1和-1积分项累加几次就饱和了。所以PID计算最好用浮点数STM32F4有硬件FPU浮点运算几乎不耗时F1没有FPU但软件浮点也能跑只是慢一点对于1kHz的PID循环完全够用。第二个坑是误差方向。设定值减实际值还是实际值减设定值决定了输出的正负逻辑。如果你搞反了系统会正反馈直接失控。我一般会在代码里加一个宏定义#define ERROR_DIRECTION (1)方便调试时快速翻转。第三个坑是死区。实际系统中传感器有噪声执行器有最小启动阈值。如果误差在正负1以内输出可能一直在正负之间跳导致执行器频繁启停。这时候需要加一个死区误差绝对值小于死区值时输出保持不变或者置零。死区大小根据传感器精度和执行器特性来定一般取传感器最小分辨率的2到3倍。3.2 积分限幅不做这一步你的系统迟早失控积分项是PID里最危险的部分。它的本质是历史误差的累加如果系统长时间到不了设定值积分项会一直增大直到输出饱和。这时候即使误差反向积分项也需要很长时间才能降下来系统会表现出严重的超调和振荡。解决方法是积分限幅也叫抗积分饱和。具体做法有两种一种是限制积分项的累加范围比如if(integral INTEGRAL_MAX) integral INTEGRAL_MAX;另一种是在输出饱和时停止积分即当输出达到上限或下限时不再累加积分项。我一般两种都用先限制积分项本身的范围再在输出饱和时冻结积分。积分限幅的值怎么定我的经验是取输出上限除以Ki再乘以0.8左右。比如输出上限是1000Ki是0.5那积分限幅就取1600左右。这样即使积分项满额它贡献的输出也不会超过总输出的80%留出余量给比例项和微分项。提示调试时可以把积分项单独打印出来观察如果它长时间贴着限幅值说明Ki太大或者积分限幅太小。3.3 微分项与噪声一对天生的冤家微分项对误差的变化率敏感这既是优点也是缺点。优点是能提前预判误差趋势抑制超调缺点是传感器噪声会被微分放大导致输出抖动。举个例子假设你的温度传感器噪声是正负0.1度采样周期是1秒那噪声引起的误差变化率就是0.2度/秒。如果Kd是10微分项输出就会跳2个单位这在精密控制里是不可接受的。解决办法有三个一是加低通滤波对误差或者微分项做一阶滤波截止频率取系统带宽的5到10倍二是用不完全微分把微分项改成Kd * (e(k) - e(k-1)) / (采样周期 Tf)其中Tf是滤波时间常数三是直接用增量式PID因为增量式里的微分项是二阶差分对噪声的敏感度比位置式低。我一般用第一种在微分项后面加一个简单的滑动平均或者一阶RC滤波。代码大概是这样float diff (error - last_error) / sample_time; diff_filtered diff_filtered * 0.7 diff * 0.3;这个0.7和0.3就是滤波系数根据噪声大小调整。噪声大就加大历史值的权重噪声小就减小。3.4 输出限幅与执行器保护输出限幅是最后一道防线。不管PID算出来什么值最终送给执行器的信号必须在安全范围内。比如PWM占空比不能超过95%否则电机驱动可能因为自举电容充电不足而失控加热丝功率不能超过额定值的80%否则寿命会急剧缩短。输出限幅的代码很简单if(output OUTPUT_MAX) output OUTPUT_MAX; if(output OUTPUT_MIN) output OUTPUT_MIN;但要注意输出限幅和积分限幅要配合使用。如果只做输出限幅不做积分限幅积分项会继续累加导致退出饱和时响应迟钝。正确的做法是先算PID再做输出限幅然后根据限幅结果决定是否冻结积分。4. 实操过程在STM32上从零搭建PID控制器4.1 硬件准备与外设配置我以STM32F103C8T6为例搭建一个直流电机速度闭环控制。需要的外设包括TIM1输出PWM驱动电机频率20kHz分辨率3600级TIM2定时中断1kHz触发PID计算TIM3编码器接口读取电机转速USART1串口输出调试信息配置步骤用STM32CubeMX完成时钟配置外部晶振8MHzPLL倍频到72MHzTIM1配置PWM模式预分频器设为0自动重装载值设为3599这样PWM频率就是72MHz/360020kHzTIM2配置预分频器设为71自动重装载值设为999这样中断频率就是72MHz/72/10001kHzTIM3配置编码器模式四倍频计数USART1配置波特率115200用于打印调试生成代码后在main函数里启动TIM1的PWM输出、TIM2的中断和TIM3的编码器接口。4.2 PID结构体与初始化我习惯把PID的所有参数打包成一个结构体这样多路PID控制时互不干扰typedef struct { float Kp; float Ki; float Kd; float setpoint; float integral; float last_error; float prev_error; float integral_max; float output_max; float output_min; float sample_time; float dead_zone; } PID_TypeDef;初始化函数void PID_Init(PID_TypeDef *pid, float Kp, float Ki, float Kd, float sample_time, float out_max, float out_min) { pid-Kp Kp; pid-Ki Ki; pid-Kd Kd; pid-setpoint 0; pid-integral 0; pid-last_error 0; pid-prev_error 0; pid-integral_max out_max / (Ki 0 ? Ki : 1) * 0.8f; pid-output_max out_max; pid-output_min out_min; pid-sample_time sample_time; pid-dead_zone 0; }这里积分限幅的计算用了out_max / Ki * 0.8如果Ki为0就取out_max避免除零。4.3 位置式PID计算函数float PID_Compute(PID_TypeDef *pid, float feedback) { float error pid-setpoint - feedback; // 死区处理 if(fabs(error) pid-dead_zone) { error 0; } // 比例项 float proportional pid-Kp * error; // 积分项 pid-integral pid-Ki * error * pid-sample_time; if(pid-integral pid-integral_max) pid-integral pid-integral_max; if(pid-integral -pid-integral_max) pid-integral -pid-integral_max; // 微分项 float derivative pid-Kd * (error - pid-last_error) / pid-sample_time; // 输出 float output proportional pid-integral derivative; // 输出限幅 if(output pid-output_max) output pid-output_max; if(output pid-output_min) output pid-output_min; // 更新误差历史 pid-last_error error; return output; }这个函数每1毫秒调用一次传入编码器读到的实际转速返回PWM占空比。4.4 定时器中断里的完整流程void TIM2_IRQHandler(void) { if(TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); // 读取编码器值并计算转速 int16_t encoder TIM_GetCounter(TIM3); TIM_SetCounter(TIM3, 0); float speed encoder * 60.0f / (ENCODER_PPR * 4 * SAMPLE_TIME); // PID计算 float output PID_Compute(motor_pid, speed); // 设置PWM占空比 TIM_SetCompare1(TIM1, (uint16_t)output); // 调试输出 printf(%.2f,%.2f,%.2f\r\n, motor_pid.setpoint, speed, output); } }这里编码器读的是四倍频后的计数值除以编码器线数和4得到实际转数再乘以60除以采样时间得到RPM。4.5 参数整定的实操方法参数整定我一般用试凑法步骤是这样的第一步先把Ki和Kd置零只调Kp。从小到大增加Kp直到系统出现等幅振荡记录此时的Kp为临界增益Ku振荡周期为Tu。第二步根据Ziegler-Nichols经验公式估算初始参数Kp0.6KuKi2Kp/TuKdKp*Tu/8。第三步在这个基础上微调。如果响应太慢就加大Kp如果超调太大就加大Kd如果稳态误差消不掉就加大Ki。第四步观察积分项和输出波形如果积分项经常饱和就减小Ki或者加大积分限幅。我实测下来对于大多数电机速度环Kp在0.5到5之间Ki在0.01到0.5之间Kd在0到0.1之间。具体值跟电机特性、负载惯量、采样周期都有关系没有万能参数。注意调参时一定要在空载和带载两种情况下都试。空载调好的参数带载后可能振荡带载调好的参数空载可能响应慢。折中方案是取带载参数的80%作为最终值。5. 常见问题与排查技巧实录5.1 系统振荡从波形判断原因振荡是PID调试中最常见的问题。根据波形特征可以快速定位原因振荡特征可能原因解决方法高频小幅振荡Kp太大或Kd太大减小Kp检查微分滤波低频大幅振荡Ki太大或积分限幅太大减小Ki收紧积分限幅启动时超调大Kd太小或积分饱和加大Kd加积分分离稳态时来回跳死区太小或传感器噪声加大死区加滤波周期性波动采样周期与系统共振调整采样周期我遇到过一次典型的低频振荡波形周期大概2秒幅度有设定值的20%。查了半天发现是Ki设成了0.8而积分限幅设成了输出上限的2倍导致积分项严重饱和。把Ki降到0.2积分限幅改成输出上限的0.6倍问题就解决了。5.2 响应迟钝不一定是参数问题有时候你发现系统响应很慢加大Kp也没用这时候要检查几个地方第一执行器是否已经饱和。如果PWM占空比已经到100%了再加大Kp也没用因为执行器已经满负荷了。这时候需要换更大功率的驱动或者减轻负载。第二传感器是否有延迟。有些温度传感器响应时间长达几十秒PID算得再快也没用因为反馈值本身滞后。这时候要么换快速传感器要么在PID里加一个预测模型。第三采样周期是否太长。如果采样周期比系统时间常数还大PID就变成了开环控制。这时候需要提高采样频率。第四积分项是否被限幅卡住了。如果积分限幅设得太小积分项无法累积到足够的值来消除稳态误差系统就会一直差一点。5.3 积分饱和一个真实案例的排查过程我之前做一个加热台项目设定值从室温升到200度结果温度冲到230度才回落超调了30度。用串口把PID的中间变量打出来看发现积分项在升温阶段一路涨到限幅值等温度超过设定值后积分项需要好几秒才能降下来这段时间输出一直是满功率所以超调严重。解决方法是加积分分离当误差大于某个阈值时只让比例项和微分项工作积分项不累加当误差小于阈值时再启用积分。代码改动很小if(fabs(error) INTEGRAL_SEPARATION_THRESHOLD) { pid-integral pid-Ki * error * pid-sample_time; }这个阈值一般取设定值的10%到20%。加了积分分离后超调从30度降到了5度以内。5.4 编码器读数跳变硬件问题还是软件问题电机控制里编码器读数跳变很常见表现是速度反馈突然出现尖峰PID输出跟着抖。排查步骤先看硬件编码器线是否屏蔽接地A/B相是否接反电源是否干净。我遇到过编码器电源和电机电源共地导致干扰后来加了光耦隔离就好了。再看软件编码器计数是否溢出读取和清零是否原子操作。STM32的编码器接口在16位定时器上如果转速很高1毫秒内可能溢出。解决办法是提高采样频率或者用32位定时器。最后看滤波如果硬件和软件都没问题那就是机械振动导致的正常跳变加一个滑动平均滤波就行。我一般用5点滑动平均延迟小效果也不错。5.5 调试工具与技巧调试PID最有效的工具就是串口波形。我习惯用printf把设定值、实际值、输出值、积分项四个变量按CSV格式打印出来然后用串口示波器软件看波形。这样一眼就能看出是超调、振荡还是响应慢。如果串口带宽不够可以只打印关键变量或者降低打印频率。比如PID是1kHz但打印降到10Hz只打印每100次的结果。另外我强烈建议在代码里加一个参数在线修改功能。通过串口接收新参数实时更新PID结构体不用每次改参数都重新编译下载。这个功能在调参阶段能节省大量时间。void USART1_IRQHandler(void) { if(USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { char ch USART_ReceiveData(USART1); // 解析命令更新PID参数 // 格式P0.5 I0.1 D0.01 } }6. 进阶优化让PID更聪明的几个技巧6.1 前馈补偿让响应快人一步PID是反馈控制只有误差出现了才动作。如果能在误差出现之前就给出一个预估的控制量响应会快很多。这就是前馈。比如电机速度控制如果我知道目标转速是1000RPM根据电机特性大概需要50%的占空比那我可以在PID输出上直接叠加这个50%PID只需要修正剩下的偏差。这样启动时PID不用从零开始爬升响应速度能提升一倍以上。前馈值的获取有两种方式一是查表根据设定值查对应的经验输出二是模型计算根据系统传递函数算。我一般用查表加线性插值简单可靠。6.2 增益调度不同工况用不同参数有些系统在不同工作点上的特性差异很大比如电机在低速和高速时的反电动势不同温控在低温和高温时的散热速率不同。这时候用一组PID参数很难兼顾。增益调度的思路是根据当前工作点切换不同的PID参数组。比如把速度范围分成三段每段用一组参数。切换时要注意平滑过渡避免参数突变导致输出跳变。我一般用线性插值在两个工作点之间平滑过渡参数。6.3 抗积分饱和的进阶方案除了前面说的积分限幅和积分分离还有一种更优雅的方案叫反计算抗饱和。它的思路是当输出饱和时把输出限幅前后的差值反馈到积分项让积分项自动回退。float output_unsat proportional pid-integral derivative; float output output_unsat; if(output pid-output_max) output pid-output_max; if(output pid-output_min) output pid-output_min; // 反计算 float anti_windup (output - output_unsat) * pid-Kaw; pid-integral anti_windup;其中Kaw是反计算增益一般取1/Ti或者1/TtTt是跟踪时间常数。这个方案的好处是不需要手动设置积分限幅积分项会自动跟踪输出饱和情况。6.4 微分先行避免设定值突变引起的冲击标准PID里微分项对误差微分而误差等于设定值减实际值。如果设定值突然变化微分项会产生一个巨大的尖峰导致输出冲击。微分先行就是把微分项改成只对实际值微分不对设定值微分。// 标准微分 float derivative pid-Kd * (error - pid-last_error) / pid-sample_time; // 微分先行 float derivative -pid-Kd * (feedback - pid-last_feedback) / pid-sample_time;这样设定值突变时微分项不会突变输出更平滑。代价是微分项对设定值变化的预判能力减弱但对于大多数应用来说平滑性比预判性更重要。7. 我踩过的坑和总结的经验做PID控制这些年踩过的坑比写过的代码还多。有几个教训特别深刻分享出来希望能帮你少走弯路。第一个教训是不要迷信自动整定。很多PLC和控制器都有自动整定功能但自动整定出来的参数往往偏保守响应慢。而且自动整定需要系统处于稳定状态如果系统本身就不稳定自动整定根本没法用。我的建议是自动整定只用来获取初始值最终参数还是要手动微调。第二个教训是采样周期一定要用定时器中断保证。我早期做的一个项目PID放在主循环里跑结果主循环里有个Flash写入操作每次写入要几十毫秒PID采样周期跟着抖动控制效果时好时坏。后来改成定时器中断触发问题立刻消失。第三个教训是调试信息要足够详细。不要只打印最终输出要把比例项、积分项、微分项分开打印。这样出问题时一眼就能看出是哪一项在捣乱。我现在的习惯是每个PID循环都记录这四个值用环形缓冲区存起来出问题时可以回溯。第四个教训是参数要有备份。调好的参数一定要写进Flash或者EEPROM不要只放在RAM里。我遇到过调试时断电参数全丢只能重新调的情况。现在我的代码里都有参数保存和加载功能上电自动读取上次的参数。第五个教训是不要忽视机械和电气层面的问题。PID调不好很多时候不是算法的问题而是机械间隙太大、传感器安装松动、电源纹波太大。先把硬件问题解决了再调PID事半功倍。最后分享一个我常用的调试技巧在PID输出后面加一个手动模式开关。调试时可以先手动给一个固定输出观察系统响应估算出大概的增益范围然后再切到自动模式调PID。这样比一上来就自动调参效率高得多。这个内容后续还可以这样扩展加入模糊PID或者神经网络PID让参数根据工况自动调整或者把PID和状态观测器结合用观测器估计负载转矩做前馈补偿。不过对于大多数嵌入式控制场景把经典PID吃透已经能解决百分之九十的问题了。