温控箱、水泵恒压、电机转速环,业务形态看起来完全不同,但落到代码里,它们其实是同一个结构:PID 控制器。不过,参数整定和离散化实现里有不少细节,搞不清 P、I、D 三项各自的边界,调参就很容易变成瞎试。
三项各自的物理含义
先把三项的作用和失效边界摆清楚,调参才有依据。
| 项 |
物理含义 |
单独加大时的表现 |
典型副作用 |
| P |
当下偏差的即时响应 |
响应变快,稳态误差减小 |
过大则振荡,不可能完全消除稳态误差 |
| I |
历史偏差的累积补偿 |
消除稳态误差 |
相位滞后 90°,过大则超调、回程慢 |
| D |
偏差变化趋势的预测 |
阻尼增加,抑制超调 |
放大反馈噪声,对采样抖动极其敏感 |
一个常被忽略的事实:P 项无法消除稳态误差,是因为输出靠偏差乘 Kp 得到,偏差为零时输出也归零;而多数执行机构维持状态需要非零的输出,比如维持温度需要持续加热功率、维持转速需要克服摩擦的输出量。稳态运行的那个输出量只能由积分项提供——这就是 I 存在的根本理由,不是“为了让曲线更好看”。
D 项的作用是提供阻尼。它被抨击最多的地方是噪声,但真正的问题往往是:用了 D 却没做滤波,或者 D 项作用在偏差上。
位置式与增量式怎么选
先看执行机构的输入接口是什么。
// 位置式:输出的是执行机构的绝对位置(0~100% 占空比、4~20mA 对应的 DAC 码值)
// 需要自己做积分累加和输出限幅
float pid_positional(PID_t *pid, float meas, float set)
{
float err = set - meas;
pid->integral += err * pid->ki * pid->ts; // Ki 已含 1/Ti,见下文符号约定
float derivative = (err - pid->err_prev) / pid->ts;
pid->err_prev = err;
float out = pid->kp * err + pid->integral + pid->kd * derivative;
if (out > pid->out_max) out = pid->out_max; // 限幅
if (out < pid->out_min) out = pid->out_min;
return out;
}
位置式的问题是:积分显式累积,一旦输出被限幅仍在累加,就要额外处理抗饱和;另外,控制器停机再启动时必须把 integral 清零,否则会带着历史累积值突然输出。
// 增量式:输出的是本次相对上次的增量(步进电机脉冲数、阀门开度的变化量)
// 执行机构自带积分能力(位置由累积决定),天然具备"输出即位置"的记忆特性
float pid_incremental(PID_t *pid, float meas, float set)
{
float err = set - meas;
float delta = pid->kp * (err - pid->err_prev1) // 比例项增量
+ pid->ki * pid->ts * err // 积分项增量
+ pid->kd * (err - 2.0f * pid->err_prev1 + pid->err_prev2) / pid->ts;
pid->err_prev2 = pid->err_prev1;
pid->err_prev1 = err;
return pid->out_prev + delta; // 调用方负责写执行机构和总限幅
}
| 对比项 |
位置式 |
增量式 |
| 输出含义 |
绝对量 |
相对增量 |
| 积分的自然实现 |
显式累加,需抗饱和 |
由执行机构累积 |
| 切换/停机 |
需清理积分状态 |
清理 err_prev 即可 |
| 适用场景 |
DAC、PWM 占空比、电压给定 |
步进电机、电动阀门、舵机 |
| 手自动无扰切换 |
需额外处理 |
天然无扰 |
选型的判断点只有一条:执行机构自己有没有记忆。有记忆,位置由脉冲累积决定,用增量式;没记忆,给定什么输出什么,用位置式。
离散化与符号约定
连续域公式通常写成:
$$
u(t)=K_p\left(e(t)+\frac{1}{T_i}\int e\,dt+T_d\frac{de(t)}{dt}\right)
$$
这里 Ti 是积分时间常数,Td 是微分时间常数。离散化后常见的两种写法会带来完全不同的 Ki、Kd 数值:
// 写法一:保持 Kp/Ti/Td 的物理含义,Ki = Kp/Ti,Kd = Kp*Td
pid->ki = kp / ti;
pid->kd = kp * td;
// 计算时: integral += ki * err * ts; derivative = (err - err_prev)/ts;
// 写法二:把 Ts 吸收进系数(网上常见)
pid->ki = kp * ts / ti;
pid->kd = kp * td / ts;
// 计算时: integral += ki * err; derivative = (err - err_prev);
两种写法数值不同但结果等价。坑在于混用:照着写法二的赋值、用写法一的公式去算,参数会差一个采样周期的倍数。团队里必须统一,最好在结构体注释里写死约定,并且让 Ki/Kd 的计算由宏或初始化函数完成,不允许调用方手填 magic number。
采样周期 Ts 的选择依据是被控对象的时间常数:
| 对象 |
典型时间常数 |
建议 Ts |
| 开关电源输出环路 |
几十 μs ~ 几 ms |
与控制频率同频(20~100kHz),或为其整数分频 |
| 电机电流环 |
1~5 ms |
100~500 μs |
| 电机转速环 |
50~200 ms |
5~10 ms |
| 温度(加热块/箱体) |
几十秒 ~ 数分钟 |
0.5~5 s |
经验规则:Ts 取对象时间常数的 1/10 ~ 1/20,或者期望闭环带宽的 10~20 倍频率。更快没坏处,但会放大量化噪声与计算负担;更慢则相位滞后吃掉相位裕度,系统接近失稳。
还要区分两个概念:采样周期是 ADC 什么时候取数,控制周期是 PID 算一次的频率。ADC 可以跑得快做平均滤波,但 PID 不必跟着 ADC 跑。温度场景里 ADC 10ms 采一次做 16 点平均,PID 每 1s 算一次是完全合理的配置。
积分饱和与抗饱和
控制量饱和时,偏差持续存在,积分继续累积到一个离谱的值;等反馈终于追上设定值,积分池里的存量要花很久才能泄掉,期间输出仍被顶在限幅上——这就是积分饱和(integral windup)。表现是“回程大幅超调,然后慢慢荡回来”。
两种主流处理方法:
// 方法一:条件积分(clamping / conditional integration)
// 输出饱和且偏差仍在加剧时,冻结积分
float pid_pos_antiwindup_clamp(PID_t *pid, float meas, float set)
{
float err = set - meas;
float p_out = pid->kp * err;
float tentative = p_out + pid->integral; // 只算 P+I 判断是否将要饱和
bool saturating_high = (tentative >= pid->out_max) && (err > 0.0f);
bool saturating_low = (tentative <= pid->out_min) && (err < 0.0f);
pid->integral += pid->ki * err * pid->ts; // 默认累积
if (saturating_high) pid->integral = pid->out_max - p_out; // 夹到位
if (saturating_low) pid->integral = pid->out_min - p_out;
float derivative = (pid->kd * (err - pid->err_prev)) / pid->ts;
pid->err_prev = err;
float out = p_out + pid->integral + derivative;
if (out > pid->out_max) out = pid->out_max;
if (out < pid->out_min) out = pid->out_min;
return out;
}
// 方法二:反算法(back-calculation / tracking)
// 用"实际输出"与"未限幅输出"的差,按 1/Tt 的速率把积分拉回来
float pid_pos_antiwindup_back(PID_t *pid, float meas, float set)
{
float err = set - meas;
float derivative = pid->kd * (err - pid->err_prev) / pid->ts;
pid->err_prev = err;
pid->integral += pid->ki * err * pid->ts;
float raw = pid->kp * err + pid->integral + derivative;
float sat = CLAMP(raw, pid->out_min, pid->out_max);
// Tt 为跟踪时间常数,一般取 Ti 的 0.5~1 倍;越小泄放越快
pid->integral += (sat - raw) * (pid->ts / pid->tt);
return sat;
}
反算法的好处是把积分值与实际输出“对齐”了,退出饱和时能立即接管,不需要额外的恢复判据。跟踪时间常数 Tt 的选择:取 Tt ≈ Ti 时退饱和平缓,取 Tt ≈ 0.5·Ti 时更快但阻尼略降。
有一类场合不该完全抑制积分:如果工艺本身就需要长时间停留在输出限幅上,比如冷启动时加热器全功率运行,允许适度累积反而能缩短到达设定值的时间。这时用“有限积分”更合适——给积分项单独设一个上下限,而不是冻结它。
微分项的两个处理
微分先行(微分只作用于测量值)
把 D 作用在偏差上,设定值一变,微分项立刻产生一个巨大的尖峰输出。设定值阶跃是正常操作扰动,不该让它冲击执行机构。
// 微分作用于测量值而非偏差:set 阶跃时不会冲击输出
float d_meas = (pid->meas_prev - meas) / pid->ts; // 注意符号取反
float out = pid->kp * err + pid->integral + pid->kd * d_meas;
pid->meas_prev = meas;
数学上,给定值不变时两种写法完全等价;差别只在设定值变化那一瞬间。
微分低通滤波
微分本质是高通,反馈上 1 LSB 的量化噪声在除以一个很小的 Ts 之后会被放大成很大的尖峰。标准做法是一阶低通:
// 微分先行 + 一阶低通:先算原始微分,再用单极点滤波平滑
// y(k) = y(k-1) + alpha * (raw(k) - y(k-1)),alpha = N*Ts/(N*Ts + Td)
float d_raw = pid->kd * (pid->meas_prev - meas) / pid->ts; // 注意用上一拍减当前
float alpha = (pid->N * pid->ts) / (pid->N * pid->ts + pid->td);
pid->d_filtered += alpha * (d_raw - pid->d_filtered);
pid->meas_prev = meas;
低通引入相位滞后,滤波太重会把 D 的阻尼效果一起抵消。N 的典型取值 8~20,且要保证 Kd·N 的上限不让输出爬升太快。如果做了平均值滤波,比如 16 点滑动平均,也可以在前端就把噪声压下,D 的滤波可以减轻。
整定方法
临界比例度法(Ziegler-Nichols 闭环法)
临界比例度法(Ziegler-Nichols 闭环法)适合允许振荡、能快速恢复到稳态的对象,比如温箱、液位。步骤:关掉 I 和 D,从小到大增加 Kp,直到系统出现等幅振荡,记下此时的临界增益 Kc 和振荡周期 Tc,再按表填入。
| 控制器 |
Kp |
Ti |
Td |
| P |
0.5·Kc |
— |
— |
| PI |
0.45·Kc |
0.83·Tc |
— |
| PID |
0.6·Kc |
0.5·Tc |
0.125·Tc |
Z-N 参数以“1/4 衰减比”为目标,超调通常偏大,约 20%~50%。对不允许大幅超调的场合,在查表结果基础上把 Kp 调低 30%~50%,或改用下面的 Lambda 整定。
极点配置 / Lambda 整定
对可以近似成一阶惯性加纯滞后(FOPDT)的对象,传递函数为 K·e^(-θs)/(τs+1),可以直接按期望闭环时间常数 λ 反算参数,超调可控。
先通过阶跃响应测出三个参数:过程增益 K、时间常数 τ、纯滞后 θ。用飞升曲线法,给执行机构一个台阶,比如加热输出从 30% 跳到 50%,记录温度曲线。
PI 参数取:
Kp = τ / (K · (λ + θ))
Ti = τ
λ 建议取 max(1.0·τ, 3·θ),λ 越大越保守、超调越小响应越慢
这套方法的好处是“想要的响应速度”变成了显式可调的输入 λ,不再依赖反复试凑。
继电器自整定(Åström-Hägglund)
不想让过程跑到振荡边缘、又想让设备自动出参数时用。用继电特性代替线性调节器,让系统在设定值附近产生受控的小幅振荡:
// 继电器自整定:输出在 u_high / u_low 之间按测量值穿越设定值切换
// 产生的振荡幅度远小于 Z-N 的临界振荡,过程不会被推到危险区
RelayResult_t relay_autotune(float set, float d, float u_high, float u_low, float ts)
{
static bool phase = false; // false: 高输出;true: 低输出
static float peak[2], t_peak[2], t_now;
t_now += ts;
if (!phase && g_meas > set + d) { record_peak(&peak[0], &t_peak[0], t_now); phase = true; }
if (phase && g_meas < set - d) { record_peak(&peak[1], &t_peak[1], t_now); phase = false; }
// 测到若干个完整周期后,由振荡幅值 A 和周期 Pu 计算临界参数
// Kc = 4·d_relay / (π·A),Kp = 0.6·Kc 等一组关系与 Z-N 相同
return compute_pid_from_oscillation(peak, t_peak, u_high, u_low);
}
实际用于产品的自整定功能要注意三点:整定前过程必须在稳态;整定期间要冻结其它扰动,比如关掉进料阀门;整定结束要做一次合法性校验,确认 Kp、Ti 落在量程范围内且为正,否则保留原参数。
工程细节
死区
输出死区对付的是执行机构的静摩擦和回差,常见于阀门、泵。太小没作用,太大会产生极限环振荡——系统永远在死区两侧来回却达不到设定值。
// 死区放在偏差上,注意避开设定值附近的持续抖动
if (fabsf(err) < pid->deadband) err = 0.0f;
温控这类惯性系统用另一种处理:不做偏差死区,而是做“输出变化率限制”,避免频繁开关继电器和接触器。执行机构的寿命往往比控制精度更值钱。
手动自动无扰切换
操作员从手动切到自动的瞬间如果输出跳变,就是一次人工扰动。增量式天然无扰,新增量作用于当前输出;位置式需要把积分状态反算到与当前手动输出一致:
void pid_bumpless_transfer(PID_t *pid, float manual_out, float meas, float set)
{
// 让 PID 的内部状态满足:Kp*err + integral == manual_out
pid->err_prev = set - meas;
pid->integral = manual_out - pid->kp * pid->err_prev;
pid->out_prev = manual_out;
}
非对称系统
加热-自然冷却、充气-排气这类过程,两个方向的动态完全不同:加热很快,冷却靠散热很慢;充气快,泄压慢。用一组参数在两个方向上很难兼顾。做法是按误差符号切换参数组:
const PIDParam_t *param = (err > 0.0f) ? &g_pid_fast : &g_pid_slow;
切换时要注意积分连续性——直接换 Ki 会让积分值的含义突变,通常让积分项仍按统一 Ki 累积,只对 Kp 和限幅做方向区分。
变增益
某些对象的增益随工作点变化,比如阀门在不同开度下的流量增益不同、电源在不同输入电压下的环路增益不同。严重的场合要按工作点查表插值。判断依据是:在量程的低中高三点分别做阶跃响应,如果 K 相差超过 2 倍,固定参数很难全程兼顾。
完整实现
综合上面各点,一个够用的 PI+D 控制器:
typedef struct {
// 参数(Kp/Ti/Td 保持物理含义,Ts 不吸收进系数)
float kp, ki, kd;
float ti, td; // 保留原值,供抗饱和 Tt 和微分滤波计算
float ts; // 控制周期,秒
float out_min, out_max;
float tt; // 反算抗饱和跟踪时间常数
float N; // 微分滤波系数
// 状态
float integral;
float meas_prev;
float d_filtered;
bool enabled;
} PIDController_t;
void PID_Init(PIDController_t *pid, float ts)
{
memset(pid, 0, sizeof(*pid));
pid->ts = ts;
pid->tt = 1.0f; // 默认值,整定时按 Ti 覆盖
pid->N = 10.0f;
}
void PID_SetParams(PIDController_t *pid, float kp, float ti, float td)
{
pid->kp = kp;
pid->ti = ti;
pid->td = td;
pid->ki = (ti > 0.0f) ? (kp / ti) : 0.0f; // ti<=0 关闭积分
pid->kd = kp * td; // td=0 关闭微分
pid->tt = (ti > 0.0f) ? (0.7f * ti) : 1.0f; // 反算跟踪时间常数
pid->d_filtered = 0.0f; // 换参数时清掉滤波器状态
}
float PID_Compute(PIDController_t *pid, float set, float meas)
{
if (!pid->enabled) return 0.0f;
float err = set - meas;
// 微分只作用于测量值(微分先行),再做一阶低通
// 低通时间常数取 Td/N,N 越大越接近理想微分
float d_raw = pid->kd * (pid->meas_prev - meas) / pid->ts;
float alpha = (pid->N * pid->ts) / (pid->N * pid->ts + pid->td + 1e-9f);
pid->d_filtered += alpha * (d_raw - pid->d_filtered);
pid->meas_prev = meas;
// 积分 + 反算抗饱和
pid->integral += pid->ki * err * pid->ts;
float raw = pid->kp * err + pid->integral + pid->d_filtered;
float sat = raw;
if (sat > pid->out_max) sat = pid->out_max;
if (sat < pid->out_min) sat = pid->out_min;
pid->integral += (sat - raw) * (pid->ts / pid->tt);
return sat;
}
调用方只负责两件事:按固定周期调用、保证周期准确。周期抖动会直接影响 I 和 D 的等效参数——如果在 OS 里用 osDelay 调用,务必用 osDelayUntil 或定时器中断触发,vTaskDelay 的累积漂移会让 Ts 悄悄变化。把这个控制器放到 20ms 定时器中断里回调,比放到任务里更稳。