首页 / 资讯中心 / 文章详情

数字式PID控制算法从原理到单片机C语言实现与调试

数字式PID控制算法从原理到单片机C语言实现与调试 ★ FEATURED ARTICLE
1. 从一个温控项目说起为什么数字式 PID 值得单独拎出来讲前阵子帮一个做小型恒温设备的朋友调程序他用的是一块很常见的单片机控制对象是一个加热片目标是把腔体温度稳定在某个设定值上。他一开始写的是最简单的开关控制——温度低于目标就全功率加热高于目标就断开。结果温度曲线像锯齿一样上下振荡超调能到七八度根本没法用。后来换成数字式 PID花了大概两天时间调参最终稳态误差压到了正负零点三度以内超调也控制在一度左右。这个经历让我觉得数字式 PID 控制算法和它的程序结构值得单独拿出来系统讲一讲。原因很简单它是嵌入式控制领域最基础、最通用、也最容易被写错的一类算法。很多人以为 PID 就是三个系数加一个公式抄一段代码就能跑但实际调试时会发现积分饱和、微分噪声、采样周期抖动、输出限幅这些问题一个接一个冒出来。这篇文章面向的是有一定编程基础、正在做或准备做闭环控制的开发者比如做温控、电机调速、液位控制、压力调节这类项目的人。我会从算法原理讲到程序结构再讲到实际调试中踩过的坑尽量把每个设计决策背后的理由说清楚。你不需要有很深的控制理论基础但需要能看懂基本的 C 语言代码和简单的数学公式。整篇内容围绕一个核心问题展开如何把连续的 PID 公式变成一段能在单片机上稳定运行的离散代码并且让它真正好用。这里面涉及的采样周期选择、积分限幅、微分滤波、输出映射等细节才是决定控制效果好坏的关键。2. 数字式 PID 的核心原理拆解2.1 从连续到离散PID 公式到底在算什么先把最基础的东西摆出来。连续形式的 PID 控制律是这样的u(t) Kp * e(t) Ki * ∫e(t)dt Kd * de(t)/dt其中 e(t) 是设定值与实际值之间的偏差u(t) 是控制器输出。三项分别对应比例、积分、微分。比例项负责“当前偏差有多大就出多大力”积分项负责“把历史偏差累积起来消除稳态误差”微分项负责“根据偏差变化趋势提前刹车”。但单片机没法处理连续时间它只能每隔一个固定时间间隔采样一次然后计算一次输出。所以需要把上面的公式离散化。离散化之后积分变成累加微分变成差分u(k) Kp * e(k) Ki * T * Σe(j) Kd * (e(k) - e(k-1)) / T这里 T 是采样周期k 是第 k 次采样。这个公式就是位置式 PID 的基本形式。注意积分项里乘了一个 T微分项里除了一个 T这是因为积分和微分本身带有时间量纲离散化时必须把时间因素补进去。很多人抄代码的时候直接把 T 忽略掉把 Ki 和 Kd 当成“已经包含时间因素的系数”来用。这样做不是不行但会导致一个问题当你改变采样周期时同样的 Ki 和 Kd 会表现出完全不同的控制效果。所以我的建议是在公式里显式保留 T让三个系数保持物理意义的一致性这样换采样率时只需要改 T不用重新推导系数。2.2 位置式与增量式两种程序结构的本质区别数字式 PID 在程序实现上分为两大流派位置式和增量式。位置式 PID 直接计算输出的绝对数值每次输出都对应执行机构的具体位置比如阀门开度、PWM 占空比。它的程序结构通常是这样的typedef struct { float Kp; float Ki; float Kd; float T; // 采样周期单位秒 float integral; // 积分累加值 float prev_error; // 上一次偏差 float out_min; // 输出下限 float out_max; // 输出上限 } PID_Position; float PID_Position_Calc(PID_Position *pid, float setpoint, float feedback) { float error setpoint - feedback; pid-integral error * pid-T; float derivative (error - pid-prev_error) / pid-T; float output pid-Kp * error pid-Ki * pid-integral pid-Kd * derivative; pid-prev_error error; // 输出限幅 if (output pid-out_max) output pid-out_max; if (output pid-out_min) output pid-out_min; return output; }增量式 PID 计算的是输出的变化量每次只输出“比上次增加或减少多少”。它的公式是位置式两次计算的差值化简后得到Δu(k) Kp*(e(k)-e(k-1)) Ki*T*e(k) Kd*(e(k)-2e(k-1)e(k-2))/T增量式的程序结构不需要累加积分项只需要保存最近三次的偏差typedef struct { float Kp; float Ki; float Kd; float T; float prev_error; float prev_prev_error; float out_min; float out_max; } PID_Increment; float PID_Increment_Calc(PID_Increment *pid, float setpoint, float feedback) { float error setpoint - feedback; float delta pid-Kp * (error - pid-prev_error) pid-Ki * pid-T * error pid-Kd * (error - 2*pid-prev_error pid-prev_prev_error) / pid-T; pid-prev_prev_error pid-prev_error; pid-prev_error error; // 增量限幅 if (delta pid-out_max) delta pid-out_max; if (delta pid-out_min) delta pid-out_min; return delta; }两种结构各有适用场景。位置式适合执行机构需要绝对位置指令的场合比如温控加热功率、阀门开度。增量式适合执行机构本身带有积分特性的场合比如步进电机、带积分环节的驱动器因为增量式不会产生积分饱和的累积问题。我个人的经验是如果执行机构是“给多少就出多少”的类型用位置式如果执行机构是“在上次基础上增减”的类型用增量式。选错了结构后面调参会很别扭。2.3 采样周期的选择不是越快越好采样周期 T 的选择是数字式 PID 里最容易被忽视、但影响很大的一个参数。理论上采样越快离散化越接近连续控制效果越好。但实际上有几个约束第一采样太快会让微分项对噪声极其敏感。假设编码器或传感器每次采样有正负一个最小单位的抖动采样周期越短差分算出来的微分值就越大输出会剧烈抖动。第二采样太快会浪费 CPU 资源。如果控制周期是 1 毫秒但被控对象的响应时间常数是 10 秒那大部分计算都是多余的。第三采样周期必须大于传感器和整个控制回路的延迟。如果传感器更新一次需要 50 毫秒你设 10 毫秒的采样周期那大部分采样拿到的都是旧数据微分项会算出错误的趋势。一个实用的经验法则是采样周期取被控对象时间常数的十分之一到二十分之一。比如一个温控系统加热片从通电到温度明显上升大概需要 30 秒那采样周期取 1 到 3 秒就够了。如果是电机电流环时间常数可能只有几毫秒那采样周期就要取到几十微秒级别。另外采样周期必须严格固定。如果这次 1 秒、下次 1.2 秒积分和微分的计算就会出错。所以实际工程中通常用定时器中断来触发 PID 计算保证周期稳定。3. 程序结构设计的几个关键决策3.1 模块化封装把 PID 做成一个可复用的组件我见过很多项目把 PID 计算直接写在主循环里几个变量散落在各处调完一个项目换一个项目又要重写。更好的做法是把 PID 封装成一个独立的模块对外只暴露初始化、计算、参数设置这几个接口。一个典型的模块头文件大概长这样// pid.h #ifndef PID_H #define PID_H typedef struct PID_Handle PID_Handle; PID_Handle* PID_Create(float Kp, float Ki, float Kd, float T, float out_min, float out_max); void PID_Destroy(PID_Handle *h); float PID_Compute(PID_Handle *h, float setpoint, float feedback); void PID_SetTunings(PID_Handle *h, float Kp, float Ki, float Kd); void PID_Reset(PID_Handle *h); #endif这样做的好处是第一PID 的内部状态对外不可见避免被误改第二可以同时创建多个 PID 实例分别控制不同回路第三换项目时直接把这个模块拷过去改改参数就能用。内部结构体里除了前面提到的系数和状态变量我建议再加几个东西积分分离阈值、微分一阶滤波系数、输出死区。这几个在后面会详细说。3.2 积分限幅与抗饱和必须处理的问题积分饱和是位置式 PID 最常遇到的问题。场景是这样的系统刚启动时实际值离设定值很远偏差很大积分项在短时间内累积到一个非常大的值。等实际值接近设定值时积分项已经太大了导致输出持续偏高系统超调严重甚至振荡。解决积分饱和有几种常用方法方法一积分限幅。直接限制积分累加值的上下限。比如输出范围是 0 到 100那积分项贡献的输出部分不应该超过这个范围。可以这样写pid-integral error * pid-T; // 积分限幅 float integral_max pid-out_max / pid-Ki; float integral_min pid-out_min / pid-Ki; if (pid-integral integral_max) pid-integral integral_max; if (pid-integral integral_min) pid-integral integral_min;方法二积分分离。当偏差大于某个阈值时暂时不累加积分只用 PD 控制快速接近目标当偏差小于阈值时再引入积分消除稳态误差。这样既保证了快速性又避免了大幅超调。方法三反计算抗饱和。当输出达到限幅值时把超出部分的量反向扣回积分项。这种方法更平滑但实现稍复杂。我一般先用积分分离加积分限幅的组合简单有效。积分分离阈值取设定值范围的百分之五到百分之十左右具体看系统响应速度。3.3 微分项的噪声处理一阶低通滤波微分项对噪声的放大作用是天然的因为它是差分除以采样周期。传感器哪怕只有一点点随机噪声微分项都会把它放大成剧烈的输出抖动。解决办法是在微分项后面加一个一阶低通滤波器。离散形式是filtered_derivative alpha * derivative (1 - alpha) * prev_filtered_derivative其中 alpha 是滤波系数取值在 0 到 1 之间。alpha 越小滤波越强但相位滞后也越大。一般取 0.1 到 0.3 之间比较合适。加了滤波之后微分项的实际效果会变弱所以 Kd 可能需要适当调大一点来补偿。这个在调参时要注意。还有一种做法是只对反馈量做微分不对偏差做微分。因为设定值突变时偏差的微分会产生一个很大的尖峰而反馈量的微分不会。这在设定值经常变化的场合很有用。4. 完整实操从零搭建一个数字式 PID 控制程序4.1 参数计算与初始化假设我们要控制一个温控系统被控对象是一个加热片通过 PWM 驱动。已知条件目标温度范围室温到 200 摄氏度传感器热电偶更新率 10 Hz加热片最大功率对应 PWM 占空比 100%系统时间常数约 20 秒根据前面的经验法则采样周期取时间常数的十分之一到二十分之一也就是 1 到 2 秒。但传感器更新率只有 10 Hz也就是 100 毫秒一次所以采样周期不能低于 100 毫秒。综合考虑取 T 1 秒。输出范围PWM 占空比 0 到 100对应 out_min 0out_max 100。初始 PID 参数可以用齐格勒-尼科尔斯法或者简单试凑法。这里我先给一组经验初值Kp 2.0Ki 0.05Kd 5.0。注意这里的 Ki 和 Kd 是已经包含 T 的系数实际计算时公式里不再单独乘除 T。初始化代码PID_Handle *temp_pid PID_Create(2.0, 0.05, 5.0, 1.0, 0.0, 100.0); PID_SetIntegralSeparation(temp_pid, 10.0); // 偏差大于10度时分离积分 PID_SetDerivativeFilter(temp_pid, 0.2); // 微分滤波系数4.2 主控制循环的编写主循环里做三件事读取传感器、计算 PID、输出 PWM。用定时器中断保证 1 秒周期void Timer_1s_ISR(void) { float temp Sensor_ReadTemperature(); float pwm PID_Compute(temp_pid, target_temp, temp); PWM_SetDuty((uint8_t)pwm); }注意 PID_Compute 内部已经做了输出限幅所以返回的 pwm 一定在 0 到 100 之间。PWM_SetDuty 直接使用即可。如果系统需要手动模式切换还要加一个无扰切换逻辑从手动切自动时把积分项预置成当前手动输出对应的值避免切换瞬间输出跳变。4.3 调参过程实录调参是最考验耐心的环节。我的步骤通常是第一步只加比例项。把 Ki 和 Kd 设为 0逐渐增大 Kp直到系统出现等幅振荡。记录此时的 Kp 为 Ku振荡周期为 Tu。第二步按经验公式给初值。对于 PID 控制Kp 0.6KuKi 2Kp/TuKd Kp*Tu/8。这是齐格勒-尼科尔斯法的经典公式。第三步微调。观察响应曲线如果超调太大就减小 Kp 或增大 Kd如果稳态误差消除太慢就增大 Ki如果振荡频繁就减小 Kd。在实际温控项目里我最终调到的参数是 Kp 3.5Ki 0.08Kd 8.0。阶跃响应上升时间约 15 秒超调约 0.8 度稳态误差在正负 0.3 度以内。这里有个小技巧调参时把设定值做成方波观察系统跟踪方波的能力。这比单次阶跃能看出更多问题比如上升沿和下降沿是否对称、有没有积分饱和、微分项是否引起抖动。5. 常见问题与排查技巧实录5.1 输出振荡不停先查采样周期和微分项系统如果出现高频振荡第一嫌疑是微分项。把 Kd 设为 0 试试如果振荡消失说明微分噪声太大。解决办法是加强微分滤波或者降低采样频率。第二嫌疑是采样周期不稳定。如果 PID 计算不是在固定周期中断里调用而是放在主循环里主循环其他任务耗时变化会导致采样周期抖动积分和微分都会算错。解决办法是把 PID 计算移到定时器中断里。第三嫌疑是 Kp 太大。比例增益过大本身就会引起振荡这时候需要减小 Kp。5.2 稳态误差消不掉检查积分项是否被限幅如果系统稳定后始终和设定值差一点说明积分项没有正常工作。可能的原因积分分离阈值设得太小偏差一直大于阈值积分从未启用积分限幅设得太紧积分累加值被限制住了Ki 太小积分速度太慢排查方法是把积分限幅放宽观察积分累加值是否在变化。如果积分值到了限幅边界就不动了说明限幅太紧。5.3 启动时超调严重积分饱和的典型表现启动超调几乎都是积分饱和引起的。系统刚上电时偏差很大积分项迅速累积到很大值等接近目标时积分项还在往外推。解决办法前面说过用积分分离。偏差大于阈值时完全不累加积分只用 PD 快速接近。等偏差小于阈值再启用积分。阈值一般取目标范围的百分之五到百分之十。还有一个办法是启动时先给一个固定的预热输出等温度接近目标再切到 PID。这在温控系统里很常用可以大幅缩短启动时间并减少超调。5.4 常见问题速查表现象可能原因排查方向解决办法高频振荡微分噪声大设 Kd0 观察加强微分滤波或减小 Kd低频振荡Kp 太大或 Ki 太大减小 Kp 或 Ki重新调参稳态误差积分未生效检查积分累加值放宽积分限幅或增大 Ki启动超调积分饱和观察启动时积分值积分分离或预置积分输出跳变手动自动切换检查切换逻辑加无扰切换响应迟钝Kp 或 Ki 太小增大参数重新调参采样抖动周期不固定检查调用位置移到定时器中断5.5 几个容易被忽略的细节输出死区。如果执行机构在输出很小时没有响应比如 PWM 占空比低于百分之五时加热片完全不热那 PID 输出在这个区间内变化是无效的。可以在输出上加一个死区补偿或者把输出下限设成能产生实际响应的最小值。积分项初始化。系统第一次运行时积分项应该初始化为一个合理的值而不是零。比如温控系统如果环境温度是 25 度目标 100 度那初始输出应该对应一个能维持 25 度的加热功率而不是零。这样可以避免启动时输出从零开始爬升。参数保存。调好的 PID 参数应该保存到非易失存储器里下次上电直接加载。否则每次断电都要重新调参很不现实。多回路耦合。如果同时控制多个相互影响的回路比如温度和湿度两个 PID 之间会互相干扰。这时候需要考虑解耦或者降低其中一个回路的响应速度让两个回路的带宽错开。6. 进阶话题从固定参数到自适应固定参数的 PID 在工况变化不大的场合够用但如果被控对象的特性随工况变化比如加热片在不同温度下的散热系数不同固定参数就很难在全范围内都表现良好。一个实用的改进是增益调度根据当前工作点切换不同的 PID 参数组。比如低温段用一组参数高温段用另一组。实现上就是在 PID_Compute 里根据反馈值查表选择参数。另一个方向是模糊 PID用模糊规则根据偏差和偏差变化率实时调整 PID 参数。这个实现起来复杂一些但在非线性严重的系统里效果明显。不过我的建议是先把固定参数 PID 调好把采样周期、积分限幅、微分滤波这些基础问题处理干净。很多所谓的“PID 效果不好”其实不是算法问题而是这些工程细节没做到位。基础打牢之后再考虑自适应才有意义。我在实际项目里遇到的大部分控制问题最后发现根源都是采样周期不稳定、积分饱和、微分噪声这三类。把这三个问题解决掉固定参数 PID 在大多数场合都能跑得不错。
阅读完成 · 觉得有帮助?
咨询建站