教程 · 入门 · 阅读约 18 分钟

如何整定 PID 控制器:一份实用指南

按步骤整定 PID 控制器:第一步设定比例增益,第二步加入微分来抑制超调,最后才加入积分项,并注意避免积分饱和。

PID 控制器把系统当前所处位置与目标位置之间的误差,转换成对控制量的修正量。PID 控制器由比例、积分、微分三项构成,所谓整定,就是决定每一项以多强的力度参与控制。可靠的做法是每次只调一项,按固定顺序进行,每改一次就观察一次响应。你可以打开 PID 控制器模拟器跟着一起做。

比例增益起

先把积分增益和微分增益归零。然后提高比例增益(Kp),直到设定值变化时系统能够快速响应。增益太小,反应迟钝;增益太大,则会超调并振荡。到响应够快、只有轻微振荡的时候就停下来——这个状态就是接下来加入其余两项的起点。

有一条好用的经验法则:**P 项给你的是速度。**它只对当下的误差作出反应,除此之外什么也不做。

同一个被控对象在四种 PID 增益下的阶跃响应,纵轴是位置、横轴是时间,时间跨度四秒,图中还有一条位于 1 处的虚线水平目标线。比例增益较小时曲线上升缓慢,稳定值远低于目标线。比例增益较大时曲线冲过目标线很远,经过几轮大幅振荡之后才稳定下来。仅用中等比例增益时超调一次,随后稳定在略低于目标线的位置。比例、积分、微分同时作用的那条曲线上升很快,只略微超调,最终正好稳定在目标线上。
同一个被控对象,四种整定参数。每一种调坏都有自己的形状:或是一条永远补不上的偏差,或是一阵停不下来的振铃,或者一条差一点就停稳的曲线。 下载 SVG

加微分来压住超调

接着提高微分增益(Kd)。微分作用响应的是误差变化得有多快,所以当系统接近设定值时,它就像刹车一样把系统拉住。纯比例控制留下的超调和振铃,正是靠它消除的。

只加到刚好能让振荡稳定下来即可。加得太多会让控制器变得神经质,因为微分项会把测量值里的任何噪声都放大。如果你的信号本身噪声很大,就在微分之前先滤波,或者干脆更多地依靠比例项和积分项。

最后才加积分

比例项和微分项一起用,仍然可能留下一个小小的稳态偏差——系统会停在离设定值差一点的地方。积分项(Ki)解决的正是这个问题:它把误差随时间累积起来,持续推动,直到偏差被消除。

只有当稳态误差稳定存在、每次都能重复出现时,才慢慢加入积分增益。积分是三项里响应最慢的一项,也最容易加过头,所以一点点就能起很大作用。

当心积分饱和

积分饱和是 PID 最经典的失效方式。当执行器饱和时——电机已经全速运转,阀门已经全开——系统根本无法响应,但积分项还在继续累积误差。等到误差终于反号,此前积攒的那一大股作用力会一次性释放出来,系统因此严重超调。

防止它的办法有两种:一是限幅积分项(输出处于饱和状态时不再往里累加),二是反算(用请求输出与实际输出之间的差值把积分项慢慢泄放下去)。最简单的限幅写法如下:

float candidateIntegral = integral + error * dt;
float raw = kp * error + ki * candidateIntegral + kd * derivative;
float output = constrain(raw, outputMin, outputMax);
if (raw == output) integral = candidateIntegral; // only accumulate when not saturated

别在循环时序上自欺欺人

三项运算都依赖一个稳定的时间步长。在单片机里,循环中一个漫长的 delay() 会额外引入死区时间,让微分项无论调到多高都没有用,同时还会让积分项失真。要让循环跑得够快、间隔足够稳定,并把真实流逝的时间传进计算里。

快速逼近:Ziegler–Nichols 起点

不断加大 P 直到找到振荡,这一步不只是做个合理性检查——它还给了你一个数字,整套参数都可以从它算出来。把 I 和 D 设为零,不断加大 P,直到输出以稳定的幅度振荡,既不增大也不衰减,然后记下两个数:

  • Ku——出现这种振荡时的比例增益(极限增益)
  • Tu——振荡的周期,单位是秒

然后:

控制器 Kp Ki Kd
只用 P 0.5 Ku — —
PI 0.45 Ku 0.54 Ku / Tu —
PID(经典) 0.6 Ku 1.2 Ku / Tu 0.075 Ku Tu
PID(无超调) 0.2 Ku 0.4 Ku / Tu 0.066 Ku Tu

这些参数是**起点,不是答案。**经典那一行是故意选得激进的,通常会带来 20–40% 的超调:对电机来说没问题,对一只靠近桌面的机械臂来说则完全不能接受。最后一行则是拿响应速度去换不超调的落点。

这个方法的价值在于,它把三个未知量换成了两次测量。花二十分钟测出 Ku 和 Tu,比瞎猜一整个下午都更接近正确答案,之后你就能从一个合理的起点凭手感继续微调。

对测量值求微分,而不是对误差求微分

只需要改一行代码,就能解决关于 D 项最常见的一条抱怨。

设定值发生阶跃时,误差也跟着阶跃。阶跃信号的导数在瞬间大得惊人,于是控制器会为一个与物理毫无关系的原因输出一个剧烈的修正——什么都没动,你只是要求了另一个目标值。放到机器人上,就是每次改变目标时都会来一次猛烈的抖动。

// Derivative kick: the setpoint step goes straight into the output.
float derivative = (error - lastError) / dt;

// Derivative on measurement: the setpoint contributes nothing at all.
float derivative = -(measurement - lastMeasurement) / dt;

之所以要取相反的符号,是因为误差定义为 setpoint − measurement,改成对测量值求微分就相当于取一次负号。设定值保持不变时,这两种写法在数学上完全等价;一旦设定值变化,只有后一种写法的行为是正确的。

**除非你有具体的理由不支持,否则就对测量值求微分。**几乎没有哪种场合是需要这个微分冲击的。

给微分项加滤波,否则 D 只会放大你的噪声

求微分会放大高频成分,而传感器噪声按定义就是高频的。一个每 10 ms 采样一次、抖动幅度为 ±2 个计数的信号,微分估计值的摆动范围会达到每秒 ±200 个计数——而 D 项会把这些摆动直接变成电机指令。

常见的结果是,人们因此断定 D 项没有用,索性把它设成零。真正的问题在上游:

// A first-order filter on the derivative term only.
// alpha = dt / (tau + dt); tau is the filter time constant in seconds.
const float tau = 0.05f;                       // 50 ms
const float alpha = dt / (tau + dt);
dFiltered += alpha * (dRaw - dFiltered);

tau 的取值是一个权衡:取小了噪声照样存活,取大了 D 项就是在依据已经过时的信息做决策,而这恰恰是 D 项存在的意义所要避免的。先从循环周期的三到五倍开始取值,只在一抖动停止之后再往上加。

一个完整的控制器

把上面所有内容合到一处:

struct Pid {
  float kp, ki, kd;
  float outMin, outMax;
  float tau;                 // derivative filter time constant

  float integral = 0;
  float lastMeasurement = 0;
  float dFiltered = 0;
  bool  primed = false;
};

float pidUpdate(Pid &c, float setpoint, float measurement, float dt) {
  if (dt <= 0) return 0;

  const float error = setpoint - measurement;

  // Derivative on measurement, low-pass filtered.
  if (!c.primed) { c.lastMeasurement = measurement; c.primed = true; }
  const float dRaw = -(measurement - c.lastMeasurement) / dt;
  const float alpha = dt / (c.tau + dt);
  c.dFiltered += alpha * (dRaw - c.dFiltered);
  c.lastMeasurement = measurement;

  // Provisional integral, committed only if the output does not saturate.
  const float candidate = c.integral + error * dt;
  const float raw = c.kp * error + c.ki * candidate + c.kd * c.dFiltered;
  const float out = constrain(raw, c.outMin, c.outMax);
  if (raw == out) c.integral = candidate;      // conditional integration

  return out;
}

这里面有三样东西,是“能在机器人上工作的控制器”和“只在教科书上成立的控制器”之间的区别:对测量值求微分、对微分项做滤波,以及条件积分——执行器饱和时积分项不会被累积起来,因此不会发生积分饱和。

从响应形状做诊断

只看响应曲线,几乎每一种整定故障你都能叫出名字:

看到的现象 这意味着 该改什么
上升缓慢,始终到不了目标 P 太小,而且没有 I 加大 P;如果还剩偏差,再加上 I
稳定后永久性地停在目标之前 没有积分项 慢慢加入 I
超调一次,然后稳定下来 P 略高,而且没有 D 加入 D
反复超调,并且逐渐衰减 P 太大 减小 P,或者加入 D
以恒定幅度永远振荡 P 已经到达极限增益 你找到 Ku 了——把它减半
振荡幅度不断增大 P 超过了 Ku 立刻减小
输出又快又毛刺、抖动 D 在对噪声做动作 给微分项滤波,或者给传感器滤波
设定值一变化就猛烈抖动 微分冲击 对测量值求微分
大幅动作之后一路冲过目标 积分饱和 条件积分或者限幅
昨天还好好的,今天就变迟钝 被控对象变了——通常是电池 增益是被控对象的属性,不是代码的属性

最后一行值得特别强调,因为那并不是缺陷。增益描述的是一个特定的被控对象,而被控对象里还包括电池电量、底盘质量、地面状况和减速箱温度。一套在满电电池上完美工作的参数,放到电量将尽的电池上就会变得迟钝,因为同样的占空比现在能产生的转矩更小了。专业的做法有两种:在下面套一层速度环(这样控制器下发的是速度而不是占空比),或者做增益调度(准备好几套参数,按工况选用)。

把循环时序量化一下

I 项和 D 项都是按流逝时间来算的,所以一个“随时跑完、随时进入下一轮”的循环,等于拿着一句谎话在算这两项。

循环的行为 对控制器的影响
固定间隔 一切按设计正常工作
间隔有变化,但测量了 dt 并传入 同样正确——这就是实用做法
间隔有变化,却假设 dt 是常数 I 项累积错误;D 项只剩噪声
循环里有 delay() 引入死区时间——D 加多少都补不回来

**用 micros() 测量 dt 并把它传进去。**这件事不花任何代价,能扛住随着功能增加而不断变化的循环时长,还能把整类莫名其妙的问题从整定过程中清除掉。

如果还能让循环跑得更快,那就去做——一个以 20 Hz 采样的控制器,拿到的是最多已经过去 50 ms 的信息,而过时的信息不是任何增益能补救的。把 delay() 调用去掉,通常能让第一台机器人从 20 Hz 提升到几百赫兹,而且跟踪效果变好完全不需要动任何增益。

自己动手试一试

打开 PID 控制器模拟器,把 I 和 D 增益归零,按上面的顺序走一遍。留意超调、调节时间和稳态误差分别是怎么响应单独一项的。等你在这里把各种形状都看明白了,同样的直觉就能迁移到真实的电机、加热器,或者 循迹模拟器 里的转向环上。

探索关联图谱

本教程所属的搭建

包含本教程的项目和学习路线。

延伸阅读

参考资料