PID 的三个参数之外:位置式、增量式和反馈控制

经典三问

Q: PID 控制器是什么?

A: PID 根据目标与测量值的误差,结合比例、积分和微分信息计算下一次控制输出。

Q: 为什么需要反馈控制?

A: 电机负载、电池电压和摩擦会变化,只有比较实际结果并持续修正,系统才能接近目标。

Q: PID 常见使用场景?

A: 电机速度环、位置环、循迹误差控制、温度调节和姿态稳定。


我对 PID 有点偏爱。

写完 GPIO、PWM、电机和编码器之后,固定 PWM 已经能让轮子转起来,却不能保证它每次都转得一样快。电池掉电、轮胎压到地面、左右电机有差异,原来的 30% 就不再是原来的 30%。

PID 把"看结果、补动作"写成固定周期运行的计算:

text 复制代码
目标 -> 误差 -> PID -> PWM/执行机构 -> 实际结果
  ^                                      |
  |--------------- 测量反馈 -------------|

它做的不是替电机思考。它把你反复拧参数的动作交给程序。

先用游泳到固定点位把 PID 拆开

先想象你在游泳池里,要游到池底某个固定点位。你当前离目标还有多远,像编码器测到的实际值;那个点位是设定值;划水的力气和方向,像 PWM 输出。

最原始的做法是不看位置闷头游:你朝目标方向游固定的时间,至于最后停在哪里,全看水流、体力和动作效率。没有编码器的开环控制就是这样,命令发出去了,结果会上下浮动有较大随机。

如果游的时候看自己位置在哪,你每隔一小段时间就能知道自己离目标还有多远。这个距离就是误差,反馈控制也从这里开始。

PID 里的 P,是比例。 离目标越远,就用更大的力往前游;只差一点,就放轻动作。理想情况下,你会越来越接近目标。但如果水流一直把你往回推,只靠当前距离决定动作,最后可能停在目标附近,却始终差一小段。这就是稳态误差。比如还差 5m 的距离,你在想就这么点距离了,我 1m/s 游过去,5 秒就到了,好巧不巧,这个位置的水流正好 -1m/s 将你往回推,1+(-1)=0m/s 了。

PID 里的 I,是积分。 如果你连续很多次都还没到目标,积分就把这些"还差一点"累积起来,逐渐增加动作,直到抵消水流走完最后一段。误差长期存在,积分就会继续增长;如果你被水流困在原地,积分还可能积得太多,水流突然变小时就会一下游过目标,所以积分需要限幅。这里和上面的主要区别是,比例项只看当前状态,积分项是看历史总和的,主要的作用就是消除稳态误差。回到刚刚那个场景,比例项刚好 1+(-1)=0m/s,但是你寻思着,一直游也不是什么办法,于是想着,过的时间越长、误差越大,你就游得越快,在一开始的时候,虽然误差很大,但是时间才过去一点,这个时候是比例项作为主力军,到了最后面原本单比例项有稳态误差的时候了,(假设之前的都没有积分,当然工程中不会这样),你游了半天,发现一直有误差,有误差,时间又过去了,于是你加快速度游,这样就打破了原来的困境,足够游到目标了。

PID 里的 D,是微分。 它不只看还差多远,还看你正在多快地接近目标。离目标只差一点却游得很快时,D 会提前收力,减少冲过点位的机会。位置测量如果抖动,差分也会跟着抖,Kd 太大就会把噪声放大。有时候目标太远,我速度太快了,容易冲出目标位置,冲出去之后呢,我又游回来,呀,没收住,又冲了回去,把自己当猴耍,聪明的你,一定想到了,我在快到目的地的时候提前减速不就好了,那具体怎么做呢。聪明的你又想到了------导数!比例看现在,积分看过去,微分看未来。为什么能看未来呢,因为微分可以近似看成变化率嘛,这里一定一定要记住,原始公式中说的变化率是误差的变化率,不是你游泳速度的变化率,你游的越快,误差变得越小,变化负得越多,因此是减速。

下面假设正方向指向目标。这里的"加速/减速"指当前游泳方向上的速度变化(假设 Kd > 0):

当前位置 运动方向 速度状态 误差 误差变化率 D 项方向
目标之下 朝向目标 加速 正 负,误差减小 负,收力
目标之下 朝向目标 减速 正 负,误差减小 负,收力
目标之下 背离目标 加速 正 正,误差增大 正,推向目标
目标之下 背离目标 减速 正 正,误差增大 正,推向目标
目标之上 朝向目标 加速 负 正,误差变大 正,推向目标
目标之上 朝向目标 减速 负 正,误差变大 正,推向目标
目标之上 背离目标 加速 负 负,误差绝对值增大 负,收力
目标之上 背离目标 减速 负 负,误差绝对值增大 负,收力

这张表也能看出,D 项直接看的不是"游得快不快",而是误差变化率。加速还是减速只有在改变运动方向或改变接近速度时,才会间接影响下一次的误差变化率。

把这三样写进程序,每个固定周期计算一次,就是 PID:当前距离决定 P,长期积累的误差决定 I,接近目标的速度决定 D;划水的力是执行输出,位置设备是反馈,固定点位是目标。下面每一节,都是把这个游泳过程换成轮子的公式。

误差先写对

目标设为 100,测量值从 0 靠近:

text 复制代码
0, 25, 48, 66, 80, 91, 98, 104, 101, 100
c 复制代码
error = target - measurement;

实际 80,误差为正,应该加力;实际 104,误差为负,应该收力。

先架空车轮,给正方向 PWM,看编码器计数是不是也为正。电机正转、编码器却回负数时,PID 会认真地把错误越放越大,轮子直接爆转。反馈极性不对,参数不用调。

P 看现在

text 复制代码
P = Kp * error

Kp 太小,车追得慢,摩擦一来还会停在目标附近;Kp 太大,实际值越过目标,再猛地反向,曲线开始来回摆。

text 复制代码
Kp 太小:___/-----
Kp 合适:__/--\__
Kp 太大:_/\/\_/\/\_

OLED 上把目标、测量和输出几行数字放在一起,能直接看出这种差别。

I 记住过去

电机需要超过某个占空比才能克服静摩擦。P 算出来的输出小到一定程度,轮子可能不动。积分把过去的误差累起来:

text 复制代码
integral = integral + error
I = Ki * integral

误差一直存在,积分会一直长。电机被卡住时,输出早已顶到上限,积分还在记账;松开轮子以后,实际值超过目标,输出还要拖一段时间才下来------这段尾巴,是积分欠下的。后面的位置式例程里,我会先给积分和输出都加上最基本的限幅;限幅之外的更多手段,等真的被这条尾巴咬到的时候再说。

D 看趋势

text 复制代码
derivative = error - previous_error
D = Kd * derivative

误差从 20 变成 5,系统正在靠近目标,D 可以提前收一点。编码器抖一格,差分也会把这一格放大。Kd 太大,输出细碎地抖;继续加 Kd 压,反冲可能更大。

调 Kp 和 Kd 时常见"面多了加水,水多了加面":Kp 大了开始摆,Kd 加进去想压摆,噪声变成抖,再加 Kp 补响应,曲线越来越坏。先看采样周期、反馈方向和测量噪声。

位置式 PID:每次重算完整输出

text 复制代码
u(k) = Kp*e(k) + Ki*sum(e) + Kd*(e(k)-e(k-1))

两种形式用的是同一个结构体:系数、边界、状态变量各占一组。

c 复制代码
typedef struct
{
    /* 系数 */
    float Kp;                /* 比例系数 */
    float Ki;                /* 积分系数 */
    float Kd;                /* 微分系数 */

    /* 边界 */
    float i_max;             /* 积分项限幅 */
    float out_max;           /* 输出上限 */
    float out_min;           /* 输出下限 */

    /* 状态变量 */
    float integral;          /* 积分累积 */
    float prev_error;        /* 上一次误差 */
    float prev_prev_error;   /* 上上次误差 */
    float output;            /* 当前输出(增量式用) */

    /* 输出分量(调试用) */
    float out_p;             /* 比例项 */
    float out_i;             /* 积分项 */
    float out_d;             /* 微分项 */
} PID;

每个实例都用同一个宏创建,系数和边界一次给全,状态变量一律从 0 起:

c 复制代码
#define PID_CREATE(_Kp, _Ki, _Kd, _out_max, _out_min, _i_max)   \
{                                                               \
    .Kp = _Kp,                                                  \
    .Ki = _Ki,                                                  \
    .Kd = _Kd,                                                  \
    .i_max = _i_max,                                            \
    .out_max = _out_max,                                        \
    .out_min = -(_out_min),                                     \
    .integral = 0.0f,                                           \
    .prev_error = 0.0f,                                         \
    .prev_prev_error = 0.0f,                                    \
    .output = 0.0f,                                             \
    .out_p = 0.0f,                                              \
    .out_i = 0.0f,                                              \
    .out_d = 0.0f                                               \
}

PID Motor_PID = PID_CREATE(0.8f, 0.2f, 0.0f, 100.0f, 100.0f, 100.0f);

out_min 传正值、宏里取负,写的时候不用再记一个负号。这种"点名字赋值"是 C99 的写法:AC5 配 --c99 能编过,--c90 会报一串 #29(实测 C90 十三个错、C99 零错);工程没开 C99 就按字段顺序老老实实赋一遍。

积分和输出都走同一个限幅函数,所以它得先放在这儿:

c 复制代码
static float PID_Limit(float value, float minimum, float maximum)
{
    if (value > maximum)
    {
        return maximum;
    }
    if (value < minimum)
    {
        return minimum;
    }
    return value;
}

float PID_Update(PID *pid, float target, float measurement)
{
    float error = target - measurement;
    float derivative;
    float output;

    pid->integral += error;
    pid->integral = PID_Limit(pid->integral, -pid->i_max, pid->i_max);

    derivative = error - pid->prev_error;

    pid->out_p = pid->Kp * error;
    pid->out_i = pid->Ki * pid->integral;
    pid->out_d = pid->Kd * derivative;

    pid->prev_error = error;

    output = pid->out_p + pid->out_i + pid->out_d;

    return PID_Limit(output, pid->out_min, pid->out_max);
}

位置式每次给出一份完整答案,适合直接算"现在应该给多少 PWM"。积分状态、输出边界和复位都比较直观。

增量式 PID:只算这次改多少

text 复制代码
du(k) = Kp*[e(k)-e(k-1)]
      + Ki*e(k)
      + Kd*[e(k)-2e(k-1)+e(k-2)]

u(k) = u(k-1) + du(k)
c 复制代码
float IncrementalPID_Update(PID *pid, float target, float measurement)
{
    float error = target - measurement;
    float delta_output;

    pid->out_p = pid->Kp * (error - pid->prev_error);
    pid->out_i = pid->Ki * error;
    pid->out_d = pid->Kd * (error -
                            2.0f * pid->prev_error +
                            pid->prev_prev_error);

    delta_output = pid->out_p + pid->out_i + pid->out_d;

    pid->output += delta_output;
    pid->output = PID_Limit(pid->output, pid->out_min, pid->out_max);

    pid->prev_prev_error = pid->prev_error;
    pid->prev_error = error;

    return pid->output;
}

增量式保存最近两次误差和当前输出。一次计算有偏差,影响通常只在这一轮增量;输出依然需要限幅。它适合每次只调一点的执行器。

两种形式没有通用胜负。看执行器、数据噪声和输出如何复位。

用一组固定数据把两种形式摆在一起

工程观察台不接电机,也不把固定数字冒充某辆车的真实速度。它用同一组测量值跑两种算法:

c 复制代码
const float target = 100.0f;
const float measurements[] =
{
    0.0f, 25.0f, 48.0f, 66.0f, 80.0f,
    91.0f, 98.0f, 104.0f, 101.0f, 100.0f
};

OLED 每 200ms 更新四行数字:

text 复制代码
目标
测量
位置式输出×100
增量式输出×100

输出乘 100,只是把两位小数变成整数显示,代码内部仍然是 float。

把 Kp 从 0.8 改成 0.2,观察输出反应;把 Ki 改成 0,观察长期误差不再累积;把 Kd 改大,观察测量从 98 变成 104 时输出如何变化。

三个字母不一定都要用

电机速度环里,编码器计数稳定时,可以先从增量式 Ki、Kp 看起,Kd 可能完全不需要。电机响应、采样噪声和静摩擦,可能让 D 项只剩放大噪声。

循迹的位置误差没有固定角度或弧度,传感器跳变也明显。很多时候先用 Kp 和 Kd,不加 Ki;积分把一次暂时偏差记很久,车辆回到线附近还在继续拧方向。

怎么调都不对时,先问控制对象需要什么。参数少一点,有时反而更稳。

最后橘猫说

我很想继续拆 ADRC、LQR、MPC。但是我也不能跳过反馈方向、控制周期、单位、输出边界、噪声和停止路径。

PID 接到编码器后,目标从表格里的 100 换成每个控制周期希望轮子走过的脉冲,实际值来自编码器,输出落到 PWM。位置环则把编码器位置或循迹误差作为被控制的量。

控制算法最核心的动作只有一个:拿测量结果和目标比较,计算下一步怎么动。参数是手段,反馈链路才是主角。

相关推荐
F137298015572 小时前
WD5208S 采用 SOP‑8 标准封装,内部集成650V 耐压功率
stm32·单片机·嵌入式硬件·51单片机
新晨单片机设计2 小时前
S028A-基于STM32单片机智能电表【Proteus仿真+Keil程序+原理图】
stm32·单片机·proteus·智能电表
fly_sunnn3 小时前
STM32-TIM输入捕获
stm32·单片机·嵌入式硬件
创界工坊工作室4 小时前
25-基于单片机的智能水产养殖控制系统设计
stm32·单片机·嵌入式硬件·proteus
yrx0203075 小时前
迪文屏C51开发
stm32·串口屏
硅基手札5 小时前
【I2C 技术系列 11】I2C Linux 驱动框架
linux·驱动开发·stm32·单片机·嵌入式硬件·mcu·计算机外设
wuyk5556 小时前
样章-第03讲-FreeRTOS在16KB-RAM上的裁剪与陷阱
c语言·开发语言·stm32·单片机·嵌入式硬件·物联网·系统架构
zjxtxdy6 小时前
STM32hal库开发-GPIO
stm32·单片机·嵌入式硬件
youcans_15 小时前
【STM32C5教程】(3)GPIO 按键输入实验
stm32·单片机·嵌入式硬件