☰
STM32上从零实现PID控制:位置式与增量式选择、参数整定及避坑指南
2026/10/15 19:52:34 网站建设 项目流程

1. 为什么偏偏是PID,又为什么偏偏跑在STM32上

搞嵌入式控制的人,绕不开两个东西:一个是STM32,一个是PID。前者是硬件平台,后者是控制算法,两者凑在一起,基本就是电机控制、温控、平衡车、云台、电源管理这些项目的标配组合。我最早接触这套组合是做一个小型温控箱,当时用继电器做开关控制,温度在目标值上下晃了快五度,后来换成PID加PWM,波动直接压到零点三度以内,那个瞬间我才真正理解为什么这个算法能活这么多年。

PID这三个字母拆开就是比例、积分、微分。比例项看当前误差,误差大就使劲调;积分项看历史误差的累积,专门对付那种“差一点就是到不了”的稳态偏差;微分项看误差变化的趋势,提前刹车防止冲过头。三者叠加,就能让一个原本迟钝或者暴躁的系统变得听话。STM32之所以适合跑PID,核心原因有几个:主频够高,F1系列都能跑到72MHz,F4能到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/20000=3600级,约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左右。比如输出上限是1000,Ki是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完成:

  1. 时钟配置:外部晶振8MHz,PLL倍频到72MHz
  2. TIM1配置:PWM模式,预分频器设为0,自动重装载值设为3599,这样PWM频率就是72MHz/3600=20kHz
  3. TIM2配置:预分频器设为71,自动重装载值设为999,这样中断频率就是72MHz/72/1000=1kHz
  4. TIM3配置:编码器模式,四倍频计数
  5. 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经验公式估算初始参数:Kp=0.6Ku,Ki=2Kp/Tu,Kd=Kp*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/Tt,Tt是跟踪时间常数。这个方案的好处是不需要手动设置积分限幅,积分项会自动跟踪输出饱和情况。

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吃透,已经能解决百分之九十的问题了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询