速度环把轮子稳住了。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