找回密码
立即注册
搜索
发回帖 发新帖

6726

积分

1

好友

841

主题
发表于 10 小时前 | 查看: 4| 回复: 0

温控箱、水泵恒压、电机转速环,业务形态看起来完全不同,但落到代码里,它们其实是同一个结构: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 定时器中断里回调,比放到任务里更稳。




上一篇:嵌入式 GUI 选型:LVGL 资源占用、组件生态与实战上手全解析
下一篇:嵌入式数据序列化:结构体直传的坑与TLV、Protobuf方案
您需要登录后才可以回帖 登录 | 立即注册

关闭

站长推荐上一条 /1 下一条

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-10-28 13:46 , Processed in 0.081636 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表