PID 用顺了,然后呢:增强手术与前馈

速度环把轮子稳住了。PID 不再是三个神秘参数,改目标值、看曲线、调系数,一套流程已经走得熟。

然后你多半会撞到两堵墙。

第一堵墙在 PID 内部:调快了超调,调稳了嫌慢;目标值一改,车先猛冲一下再回来。第二堵墙在 PID 外面:既然没学 PID 之前,开环试车时我大概知道"给多少 PWM 能跑多快",为什么一上 PID,这点经验反而用不上了?

下面分两半,一半给 PID 动手术,一半请回前馈。

半张手术台:先认识一份常见的教学代码

很多 PID 例程里,结构体长这样:

c 复制代码
typedef struct
{
    float Target;   // 目标值
    float Actual;   // 实际值(编码器读数)
    float Actual_prev;   // 上一拍测量值(微分先行用)
    float Out;      // 输出

    float Err0;     // 本次误差
    float Err1;     // 上次误差
    float ErrInt;   // 误差积分

    float ErrIntThreshold;  // 积分分离阈值
    float DeadBand;         // 输入死区
    float Kp, Ki, Kd;

    float OutMax;   // 输出上限
    float OutMin;   // 输出下限
} PID_t;

更新函数里,除了一句位置式公式,还藏着两台手术:

c 复制代码
if (fabs(p->Err0) < p->ErrIntThreshold)   // 误差不大,才积分
    p->ErrInt += p->Err0;
else
    p->ErrInt = 0;                          // 误差一大,积分清零

p->Out = p->Kp * p->Err0
       + p->Ki * p->ErrInt
       + p->Kd * (p->Err0 - p->Err1);

if (p->Out > p->OutMax) p->Out = p->OutMax; // 输出限幅
if (p->Out < p->OutMin) p->Out = p->OutMin;

一台叫输出限幅 ,一台叫积分分离。它们治的病,在下面手术单上是第一台和第三台。

手术一:输出限幅------先承认执行器的边界

电机只有一个占空比范围,方向只有正反。PID 算出 300% 的输出,电机并不会转三倍------它顶在 100%,然后你盯着曲线看它"好像没出力",其实是力已经给满了,只是出不去。

输出限幅做的事很直接:Out 一旦越过 OutMax/OutMin,就截断在那里。它把"控制器想给多少"和"执行器能吃多少"这两件事分开记账。

限幅值怎么定?看执行器:电机 PWM 是 ±100,舵机是脉宽范围,方向机是转向极限。往执行器的真实边界设,别往"我希望它能给多少"设。

它是一切后续手术的地基:没有它,下面聊的饱和、分离、前馈,全都建立在"输出其实可以无限涨"的错误假设上。

位置式(PID_Update):

c 复制代码
return PID_Limit(output, pid->out_min, pid->out_max);

增量式(IncrementalPID_Update):

c 复制代码
pid->output = PID_Limit(pid->output, pid->out_min, pid->out_max);

手术二:积分限幅------别让记忆疯涨

积分项的本质,是把过去的误差记进一笔总账。麻烦出在饱和时:输出已经顶到 100%,误差还很大,PID 还在记------账越记越大。等实际值终于追上来,误差归零了,可那笔旧账还垫着,输出继续顶着 100%,车直接冲过头,然后反方向再来一遍。这就是积分饱和(windup),新手超调里最常见的隐形凶手。

手术二的做法:给账本本身设个上限,ErrInt 单独限制在 ±某值。误差可以大,账不许无限记。限幅值通常取"输出限幅 ÷ Ki 左右"量级:让积分在合理时间内能把输出推到边界,又不至于积成天文数字。

我在实验里会故意这样测:故意让积分限幅开到极大,给一个长时间到不了的目标,再突然把目标改回来------车会像被惯性拽着一样冲过头。把积分限幅收紧,同样的操作,过冲明显变小。

位置式:

c 复制代码
pid->integral = PID_Limit(pid->integral, -pid->i_max, pid->i_max);

增量式:无。误差一直大下去,output 也只是贴着 out_max 不动;误差一反向,它立刻离开边界,不存在"旧账垫着"。

手术三:积分分离------误差大时,干脆别记

手术二管"能记多少",手术三管"什么时候才记"。看代码里那个 ErrIntThreshold:误差超过阈值,不只不积分,还把旧账一起清零。

道理是:大误差出现的时刻,通常是刚上电、刚改目标、或刚被扰动踹了一脚。这时候系统该做的是全力追赶,积分插不上手,只会把账越记越偏;等误差缩回阈值以内,再重新开始记账,追到目标后正好账本干净,不会带着一屁股旧账过冲。

阈值取多大?量纲是"误差"。速度环里常取目标值的一个比例,比如目标 100、阈值取 10~20;太小等于永远不积分------那 Ki 就白设了;太大等于没有分离,误差一大照样记。观察方法:改目标值看起步,没有分离时会先顶满再过冲,分离之后是"先全力冲、接近了才慢慢收"。

落到两种实现:位置式要在结构体里补一个 int_threshold,增量式不用。

位置式:

c 复制代码
if (fabs(error) < pid->int_threshold)
{
    pid->integral += error;
}
else
{
    pid->integral = 0.0f;
}

增量式:没有历史账可清。它的积分项就是 Ki * error,这一轮误差大,就这一轮多给一点,下一轮自动消失。

手术四:变速积分------把开关换成旋钮

积分分离是开关:过线就关、进线就开。开关有台阶------误差刚缩回阈值那一刻,积分项从 0 突然开始累积,输出会跟着轻轻跳一下。

变速积分把开关换成旋钮:误差越大,积分的"油门"越小;误差越小,积分越积极。实现只多一步:给积分乘一个随误差变化的系数,比如分成两段三段,或者 Ki_eff = Ki / (1 + |Err0| / C) 这种连续写法。

它治的病和积分分离一样,都是"大误差时别让积分添乱",但动作更平滑,适合对输出连续性敏感的场合。代价是参数多一个,调起来要多花一轮。

落到两种实现:把积分的"油门"缩成一个随误差变小、取值 0 到 1 的系数 s。

位置式:

c 复制代码
s = 1.0f / (1.0f + fabs(error) / C);   /* C 越大,衰减越缓 */
pid->integral += error * s;

增量式:

c 复制代码
s = 1.0f / (1.0f + fabs(error) / C);
pid->out_i = pid->Ki * error * s;

手术五:微分先行------别把目标的脾气算进账里

微分项读的是"误差变化得多快"。问题藏在目标突变里:目标从 0 一步跳到 100,误差瞬间从 0 变成 100,这一跳的变化率在离散代码里是一个巨大的 (Err0 - Err1)------微分项被激起一个尖峰,输出被尖峰推着猛冲,我以前改目标时就遇到过这种"一改目标车就抽一下"。

数学上说,误差对时间的导数,等于目标导数减去测量值导数:de/dt = dR/dt − dY/dt。目标不变时它只反映测量趋势;目标突变时,那个巨大的 dR/dt 就是尖峰的来源。

微分先行的做法:微分对象从"误差"换成"测量值"。目标跳变只改误差的基准,不再喂给微分项;测量值本身是连续的,微分它得到的才是干净的"趋势"。代码上把 D 项从 Kd * (Err0 - Err1) 换成对测量值差分,再按误差方向取好符号即可:

c 复制代码
// 微分先行:只看测量值的变化,不再看目标的跳变
float d_term = -p->Kd * (p->Actual - p->Actual_prev);

代价:实现里要多存一个上一拍测量值;换来的是改目标不再抽车。

落到两种实现:位置式把 D 的对象从误差换成测量值;增量式同理,D 那一段换成测量值的二阶差分。两边都要补测量值历史------位置式补一个 prev_measurement,增量式再补一个 prev_prev_measurement。

位置式:

c 复制代码
pid->out_d = -pid->Kd * (measurement - pid->prev_measurement);
pid->prev_measurement = measurement;

增量式:

c 复制代码
pid->out_d = -pid->Kd * (measurement -
                         2.0f * pid->prev_measurement +
                         pid->prev_prev_measurement);
pid->prev_prev_measurement = pid->prev_measurement;
pid->prev_measurement = measurement;

手术六:不完全微分------把高频噪声挡在门外

微分天生放大噪声。编码器读数抖一格,微分就当成趋势,输出跟着抖;测量值越干净的地方越不明显,一旦信号带噪,D 项会把系统调成"筛子"。

完全微分是放大器,噪声和信号一起放大;不完全微分在 D 项后面加一道惯性,让"持续的趋势"过去,把"一闪而过的抖动"留在门外。离散实现很便宜:给微分输出做一阶低通,保留上一次的微分结果,按系数混合:

c 复制代码
out_d += alpha * (raw_d - out_d);   // alpha 越小,滤得越狠

代价是微分反应变钝一点:趋势刚起来时它慢半拍,换来一身安静。要不要上,取决于你的测量有多脏。

落到两种实现:都是先把 D 算成 raw_d,再拿它和一阶低通的历史混合,alpha 越小滤得越狠。位置式和增量式的区别只在 raw_d 怎么算。

位置式:

c 复制代码
raw_d = -pid->Kd * (measurement - pid->prev_measurement);
pid->out_d += alpha * (raw_d - pid->out_d);

增量式:

c 复制代码
raw_d = -pid->Kd * (measurement - 2.0f * pid->prev_measurement
                              + pid->prev_prev_measurement);
pid->out_d += alpha * (raw_d - pid->out_d);

手术七:输入死区------允许一点"不完美"

到位之后,误差在 ±1 附近来回跳:加一点力就过,减一点又不够,车在目标附近嗡嗡地磨,舵机咔咔地抖。输入死区说:误差小到一定程度,就当它到了,输出归零或保持。

c 复制代码
if (fabs(p->Err0) < p->DeadBand)
    p->Err0 = 0;   // 死区内不动作(积分也会跟着停)

死区是双刃剑:它同时允许了静止误差。停得稳但差 0.5,和精确到 0 但一直在抖,你要哪个?电机堵转发热、舵机咔咔响、继电器频繁跳的场合,死区往往是救命的;对精度要求高的位置环,死区就要开得很小甚至不开。

落到两种实现:都是在算出误差之后、进 PID 之前把误差压成 0,结构体要补一个 dead_band。位置式压掉误差;增量式压掉误差以后,这一轮的增量自然是 0,output 停在原地------这就是"保持"。

位置式:

c 复制代码
error = target - measurement;
if (fabs(error) < pid->dead_band)
{
    error = 0.0f;
}

第二半:为什么还要一路前馈

手术做完了,第二堵墙还在。

回到学 PID 之前。那会儿调车,是把车放下去、跑、看速度不对、差速转弯角度不对、改一个数、再放下去......一直重复到满意。这一圈圈试出来的,是"目标速度大概对应多少 PWM""直行大概给多少差速"------一张存在你脑子里的经验表。

有了 PID 之后,这张表好像被丢了。目标改成 100,误差瞬间变成 100,P 项立刻把输出顶满------车猛冲,然后被拉回来,来回几次才站住。你在用闭环,重新踩一遍开环时早就踩过的坑。

前馈是什么:把开环的经验请回桌上

前馈的思路一句话:凡是"目标一变,我就大概知道该给多少"的事,直接给,别等误差来教。

控制器里最常见的分工是:

c 复制代码
out = Feedforward(Target)                 // 经验:这个目标大概该给多少
    + PID_Update(&pid, Target, Actual);   // 修补:实际和预期差多少

目标一变,前馈立刻把输出送到大致该在的位置,误差从一开始就小------PID 不饱和、不 windup、不猛冲;扰动来了,误差被踢大,PID 照常把它按回去。开环的经验和闭环的修正,第一次坐在同一张桌上。

前馈怎么来:从你试车的记录里长出来

最直接的前馈,就是我开环试出来的那张表。给几个目标速度,记下各自大致需要的 PWM,就能看出"每单位速度大概对应多少 PWM"------一次直线拟合,得到一个 Feedforward(Target) = k × Target + b。

那个 b 很有意思:它就是让电机刚能克服静摩擦转起来的最小 PWM,也是让舵机停在你要的角度的基底。以前开环调车时你反复试的"给多少能走",原来一直在等着变成这个 b 和 k。(这里只是最简单的用法,根据情况可添加二次项、微分项等和视觉前馈、道路曲率前馈等先验信息)

两个数都来自你自己那张记录,函数就是一行:

c 复制代码
float Feedforward(float target)
{
    return ff_k * target + ff_b;
}

查表法:不拟合,直接存

直线拟合把一整张表压成两个数,代价是"哪里不直,哪里就不准"。电机常在中低占空比带着一段死区,高段又慢慢趋平,一条直线两头都顾不上;换个减速箱、换块电池,形状还会变。

那不压就是了:把试车记下来的每一档直接存进数组。档位是均匀的(每 0.01 一档),目标除以步长就是下标,不用再存一张目标表;函数名不变,只把里面那条直线换成这张表------表里的数要填你自己那台车上测的,这里只是形状:

c 复制代码
/* 目标 0.00 ~ 2.99 m/s,每 0.01 一档,表里是实测需要的 PWM */
#define FF_STEP    0.01f
#define FF_POINTS  300U

static const float ff_table[FF_POINTS] =
{
    8.0f, 9.0f, 9.5f, 10.0f,
    /* ... 按 0.00、0.01、0.02 的顺序一直填到 2.99 那一档,共 300 个 */
};

float Feedforward(float target)
{
    uint16_t index;

    if (target <= 0.0f)
    {
        return ff_table[0];
    }

    /* 档位均匀,目标除以步长就是下标 */
    index = (uint16_t)(target / FF_STEP + 0.5f);

    if (index >= FF_POINTS)
    {
        return ff_table[FF_POINTS - 1U];
    }

    return ff_table[index];
}

目标 0 到 3 m/s,按 0.01 一档就是 0.00、0.01、...、2.99 共 300 个值。相邻两档只差 0.01,两点之间那点弯曲根本看不出来,插值可以直接省掉,按最接近的档位取值就够,误差不超过半档。表用 const 占的是 Flash。代价是标定量大,需闭环逐档记录稳态值,换电池、减速箱后要重标或补偿。

前馈的三种口味

静态前馈 :k × Target + b,目标定了输出就有底。管得了"目标是多少"。

速度前馈 :目标变化时,光有静态前馈不够------电机有惯性,追斜坡时总会落后一点。于是把目标的变化率也折算进输出:ff += Kv × 目标变化量。管得了"目标在往哪去"。车加速时提前多给一点,跟随会更紧,响应也更平滑。

加速度前馈:再往上,连"变化的变化"也算进去,对付大惯量负载。口味越高级,需要的模型越准,收益却越来越小------多数小车到速度前馈就够用了。

前馈不管什么事

要说清楚前馈的边界:它不抗扰动。 它只知道"目标该给多少",不知道"路上是不是多了个坡"。电池电压掉了、负载变了、轮子打滑了,前馈的值就不准了------这些误差还是得靠 PID 兜底。所以前馈不是替代反馈,是替反馈把"已知的活儿"先干了,让 PID 专心对付"未知的意外"。

什么时候先别加

目标很少突变、系统本来就跟得很稳、主要矛盾是扰动------这三种情况,前馈的收益很小,先别加。前馈本质是用"模型/经验"换"性能",你的经验越不准,它的收益越打折扣;经验表还没攒出来的时候,硬加一路不准的前馈,不如先把 PID 调干净。

对照总表

手段 治什么病 下刀位置 代价/注意
输出限幅 输出超出执行器边界 输出末端截断 必要的边界,可能带来 bug
积分限幅 积分饱和(windup) 限制 ErrInt 量级 限幅太小会牺牲稳态精度
积分分离 大误差时积分添乱 超阈值不积/清零 阈值需按误差量纲标定
变速积分 开关式分离有台阶 积分系数随误差变小 多一个参数
微分先行 目标突变激起 D 尖峰 D 对象从误差换测量值 多存一拍测量值
不完全微分 微分放大噪声 D 输出后加一阶低通 D 反应变钝
输入死区 到位后反复修正/抖动 误差死区内归零 允许静止误差
前馈 目标突变时反应过猛、跟踪滞后 输出层叠经验值 依赖模型,不抗扰动

最后橘猫说

PID 会用的标志不是调出三参数,而是知道它什么时候会生病、在哪下刀:输出限幅承认边界,积分限幅管账本,积分分离管记账时机,变速积分把开关换成旋钮,微分先行不背目标的锅,不完全微分挡噪声,输入死区允许不完美。而前馈,是把开环试车攒下的经验请回桌上------它管大概,PID 管残差,两路一起拉,车才又稳又跟手。

最后蜘蛛说

前馈+pid,yyds

相关推荐
zcmodeltech1 小时前
沙盘模型控制系统STM32和PLC方案对比:选型逻辑与代码实现
stm32·嵌入式硬件·自动化
Lxx_Zw4 小时前
先把轮子转稳,才不会烧心:编码器速度环 PID 怎么接回 PWM
c语言·stm32·单片机·嵌入式硬件
WD1372980155718 小时前
WD5030L,为充电桩、工业设备、5G 基站、车载系统、消费电子提供电源,7-30V 的宽压输入
stm32·单片机·嵌入式硬件·5g·车载系统·51单片机
创界工坊工作室19 小时前
30-基于STM32的小麦烘干塔温度控制系统设计
stm32·单片机·嵌入式硬件·proteus
单片机仿真设计21 小时前
【proteus仿真设计】基于 STM32 的开荒机器人遥控控制系统设计(仿真图+程序)
stm32·嵌入式硬件·机器人·proteus
姓刘的哦1 天前
STM32外扩SRAM
stm32
似是燕归来1 天前
Rtthread OS 功能开发之 socket udp协议程序开发
网络·stm32·网络协议·udp·socket·rtthread
Zw-awa1 天前
先把轮子转稳,才不会烧心:编码器速度环 PID 怎么接回 PWM
stm32
小李不想当小白1 天前
USART串口数据包(STM32标准库学习笔记)
经验分享·笔记·stm32·单片机·嵌入式硬件·学习