3. 位置式PID算法:公式推导、C语言实现、积分饱和问题

好,咱们今天来聊聊位置式PID。说实话,这是PID最经典、最直观的一种形式。我刚入行那会儿,第一个调通的PID程序就是位置式的。你想想看,它就像是一个精打细算的管家,每次都要算清楚“现在该给多少”,而不是“这次比上次多给多少”。

3.1 公式推导:从连续到离散

先看连续域的PID公式,这个大家应该都见过:

u(t) = Kp * e(t) + Ki * ∫e(τ)dτ + Kd * de(t)/dt

但在单片机里,我们处理的是离散信号。说白了,就是每隔一段时间算一次。这个时间间隔,我们叫它采样周期T。

好,我来一步步拆解:

  • 比例项:直接拿当前误差算,Kp * e(k)。这个最简单。
  • 积分项:连续积分变成累加求和。Ki * T * Σe(i),i从0到k。
  • 微分项:导数变成差分。Kd * (e(k) - e(k-1)) / T。

把这三项加起来,就得到了位置式PID的离散公式:

u(k) = Kp * e(k) + Ki * T * Σe(i) + Kd * (e(k) - e(k-1)) / T

这里有个小细节。我习惯把Ki和Kd的定义里直接包含T,这样公式看起来更清爽:

u(k) = Kp * e(k) + Ki * Σe(i) + Kd * (e(k) - e(k-1))

嗯,这里的Ki和Kd已经是包含了采样周期的等效系数了。我个人习惯用这种形式,写代码时少做一次乘法。

3.2 C语言实现:手把手写一个

理论讲完了,咱们直接上代码。我当年在做一个温控项目时,用的就是下面这个结构体:

typedef struct {
    float Kp;           // 比例系数
    float Ki;           // 积分系数
    float Kd;           // 微分系数
    
    float target;       // 目标值
    float feedback;     // 反馈值
    
    float error;        // 当前误差
    float last_error;   // 上一次误差
    
    float integral;     // 积分累加值
    float output;       // 输出值
    
    float integral_limit;  // 积分限幅
    float output_limit;    // 输出限幅
} PID_TypeDef;

这是核心计算函数。我写代码有个习惯,喜欢把每一步拆开,方便调试时看中间变量:

float PID_Calculate(PID_TypeDef *pid, float feedback) {
    // 1. 计算当前误差
    pid->feedback = feedback;
    pid->error = pid->target - pid->feedback;
    
    // 2. 计算比例项
    float p_out = pid->Kp * pid->error;
    
    // 3. 积分累加
    pid->integral += pid->error;
    
    // 4. 积分限幅 —— 这个很重要,后面会讲
    if(pid->integral > pid->integral_limit) 
        pid->integral = pid->integral_limit;
    if(pid->integral < -pid->integral_limit) 
        pid->integral = -pid->integral_limit;
    
    float i_out = pid->Ki * pid->integral;
    
    // 5. 计算微分项
    float d_out = pid->Kd * (pid->error - pid->last_error);
    
    // 6. 总输出
    pid->output = p_out + i_out + d_out;
    
    // 7. 输出限幅
    if(pid->output > pid->output_limit) 
        pid->output = pid->output_limit;
    if(pid->output < -pid->output_limit) 
        pid->output = -pid->output_limit;
    
    // 8. 更新上一次误差
    pid->last_error = pid->error;
    
    return pid->output;
}

你看,代码逻辑很清晰。但这里有个坑——积分项的处理。我曾经在这个地方栽过跟头,下面详细说说。

3.3 积分饱和问题:一个让人头疼的Bug

什么是积分饱和?我给你讲个真实案例。

有一次我做电机速度控制,目标转速是1000转。系统启动时,电机还没转起来,误差是1000。积分项开始疯狂累加,几秒钟就积到了一个很大的值。等电机终于跑到1000转时,误差变成0了,但积分项里还存着那个巨大的累加值!

结果呢?电机继续加速,冲到了1500转。然后积分项开始反向累加,慢慢往回拉。这个过程来回震荡,系统要花很长时间才能稳定下来。

这就是积分饱和。说白了,就是积分项“记仇”,记了太多旧账,导致系统反应迟钝。

⚠️ 避坑指南
我曾经在一个项目中,因为没处理积分饱和,系统超调量达到了40%,花了近10秒才稳定。加了积分限幅后,超调降到5%,稳定时间缩短到2秒。差别就这么大。

3.4 解决积分饱和的几种方法

我常用的方法有这三种,按推荐程度排序:

  1. 积分限幅法:给积分项设个上限。就像上面代码里写的那样,简单粗暴但有效。
  2. 积分分离法:误差大的时候,暂时关闭积分。等误差小了再打开。
  3. 抗积分饱和法:当输出达到限幅值时,停止积分累加。

我个人最常用的是第一种。为什么?因为简单。你想想看,嵌入式系统的资源有限,能少写几行判断就少写几行。

💡 我的经验
积分限幅值怎么设?我一般取输出限幅值的1/3到1/2。比如输出限幅是1000,积分限幅就设在300到500之间。当然,具体值还是要根据实际系统调。

这里再补充一个技巧。如果你用的是积分分离法,判断阈值怎么定?我习惯用目标值的5%作为分界线。比如目标1000,误差小于50时开启积分,大于50时关闭。这个比例是我在多个项目中试出来的,效果还不错。

3.5 位置式PID的优缺点

优点 缺点
公式直观,容易理解 积分饱和问题需要处理
输出直接对应执行器位置 需要保存所有历史误差(累加)
适合控制阀门、舵机等位置型执行器 积分项可能引起较大超调
调试方便,每个参数作用明确 对采样周期变化敏感

最后说一句。位置式PID虽然经典,但不是万能的。如果你的系统对响应速度要求极高,或者执行器是增量型的(比如步进电机),那增量式PID可能更合适。这个我们下一章再聊。

嗯,今天就到这里。代码你可以直接拿去用,但记得根据你的硬件平台调整数据类型。比如在8位单片机上,float运算可能有点慢,可以考虑用定点数替代。