教程 · 入门 · 阅读约 18 分钟
如何整定 PID 控制器:一份实用指南
按步骤整定 PID 控制器:第一步设定比例增益,第二步加入微分来抑制超调,最后才加入积分项,并注意避免积分饱和。
PID 控制器把系统当前所处位置与目标位置之间的误差,转换成对控制量的修正量。PID 控制器由比例、积分、微分三项构成,所谓整定,就是决定每一项以多强的力度参与控制。可靠的做法是每次只调一项,按固定顺序进行,每改一次就观察一次响应。你可以打开 PID 控制器模拟器跟着一起做。
比例增益起
先把积分增益和微分增益归零。然后提高比例增益(Kp),直到设定值变化时系统能够快速响应。增益太小,反应迟钝;增益太大,则会超调并振荡。到响应够快、只有轻微振荡的时候就停下来——这个状态就是接下来加入其余两项的起点。
有一条好用的经验法则:**P 项给你的是速度。**它只对当下的误差作出反应,除此之外什么也不做。
加微分来压住超调
接着提高微分增益(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 增益归零,按上面的顺序走一遍。留意超调、调节时间和稳态误差分别是怎么响应单独一项的。等你在这里把各种形状都看明白了,同样的直觉就能迁移到真实的电机、加热器,或者 循迹模拟器 里的转向环上。
探索关联图谱
本教程所属的搭建
包含本教程的项目和学习路线。
延伸阅读