- 引言 —— 直接把目标角度写进去,手臂会猛地一抖
- 梯形速度曲线 —— 最简单的轨迹
- 五次多项式轨迹 —— 把加速度也串联起来
- 关节空间插值与作业空间插值产生的不同路径
- PID —— 三个项各自修正的问题
- 前馈与重力补偿
- 控制周期和延迟如何一点点吃掉稳定性
- 为什么仿真器里调好的增益一到实机就会失灵
- 结语 —— 好的控制器,通常就是好的轨迹
引言 —— 直接把目标角度写进去,手臂会猛地一抖
假设逆运动学已经算完了。已经决定好末端要放在哪里,每个关节该是多少度也算出来了。肩部 0 度,肘部 90 度。
于是在代码里这样写:
shoulder.write(0);
elbow.write(90);
手臂猛地窜了出去。在目标角度附近晃动了几下,整条手臂剧烈颤抖,然后才停下来。如果当时正拿着什么东西,早就掉了。
角度算得完全正确,动作却一团糟。这篇文章要讲的就是这个落差。
问题的真相是这样的。上面这两行代码,对关节提出的要求是「在 0.02 秒内移动 90 度」。站在舵机的角度看,这相当于平均角速度 4500 度/秒,而且起步和到达那一瞬间的加速度是无穷大。实际能输出的扭矩是有上限的,所以关节会用它能给出的最大扭矩一路猛推,冲过目标,再折回来,又冲过头。
控制分成两层。 上层是随时间生成目标的轨迹生成,下层是让实际角度跟上那个目标的反馈回路。没有上层,下层再好,手臂照样会抖;没有下层,上层再平滑,重力也会把手臂拽下去。
梯形速度曲线 —— 最简单的轨迹
这是轨迹生成里最古老、用得也最广的一种形状。顾名思义,把速度做成梯形:以恒定加速度升上去,以最大速度巡航,再以恒定加速度降下来。
代入数字看看。让肩部移动 90 度,也就是 1.5708 rad。设这个关节能达到的最大速度是 1.0 rad/s,最大加速度是 2.0 rad/s²。
加速阶段到达最大速度所需的时间是:
t_a = v_max / a_max = 1.0 / 2.0 = 0.5秒
这段时间里走过的角度是:
d_a = 0.5 × a_max × t_a² = 0.5 × 2.0 × 0.25 = 0.25 rad
减速阶段是对称的,同样是 0.25 rad。两者合计用掉 0.5 rad,剩下的角度是:
1.5708 - 0.5 = 1.0708 rad
这一段以最大速度通过,所以:
t_c = 1.0708 / 1.0 = 1.0708秒
总的移动时间是:
T = 0.5 + 1.0708 + 0.5 = 2.0708秒
和前面那条 0.02 秒的指令相比,慢了 100 倍。而这 100 倍,正是手臂不再猛抖的原因。
这里有个重要的例外。如果移动距离很短,还没到达最大速度就得开始减速。前面算过,加减速所需的最小距离是 0.5 rad,所以如果只移动 0.2 rad,就会变成一个没有巡航段的三角形曲线。此时能达到的最高速度是:
v_peak = √(a_max × d) = √(2.0 × 0.2) = 0.6325 rad/s
T = 2 × v_peak / a_max = 2 × 0.6325 / 2.0 = 0.6325秒
代码里如果漏掉这个分支,短距离移动时曲线就会冲过目标。下面的实现里包含了这个判断。
import numpy as np
def trapezoid(dq, v_max, a_max):
"""计算梯形(或三角形)速度曲线各段的时间。"""
dq = abs(dq)
d_a = v_max ** 2 / (2 * a_max) # 加速阶段走过的距离
if 2 * d_a >= dq: # 达不到最大速度
v_peak = np.sqrt(a_max * dq)
t_a = v_peak / a_max
return t_a, 0.0, t_a, v_peak
t_a = v_max / a_max
t_c = (dq - 2 * d_a) / v_max
return t_a, t_c, t_a, v_max
def sample(t, dq, v_max, a_max):
"""返回时刻t的位置和速度。符号最后再加上去。"""
sign = 1.0 if dq >= 0 else -1.0
t_a, t_c, t_d, v_p = trapezoid(dq, v_max, a_max)
T = t_a + t_c + t_d
t = min(max(t, 0.0), T)
if t < t_a:
q, v = 0.5 * a_max * t * t, a_max * t
elif t < t_a + t_c:
q, v = 0.5 * a_max * t_a ** 2 + v_p * (t - t_a), v_p
else:
td = t - t_a - t_c
q = 0.5 * a_max * t_a ** 2 + v_p * t_c + v_p * td - 0.5 * a_max * td * td
v = v_p - a_max * td
return sign * q, sign * v
DQ, VMAX, AMAX = np.radians(90), 1.0, 2.0
ta, tc, td, vp = trapezoid(DQ, VMAX, AMAX)
print(f"加速 {ta:.4f}s 巡航 {tc:.4f}s 减速 {td:.4f}s 总计 {ta+tc+td:.4f}s")
for t in (0.0, 0.25, 0.5, 1.0, 1.5708, 2.0708):
q, v = sample(t, DQ, VMAX, AMAX)
print(f" t={t:.4f}s q={np.degrees(q):8.4f}° v={v:.5f} rad/s")
运行结果如下。
加速 0.5000s 巡航 1.0708s 减速 0.5000s 总计 2.0708s
t=0.0000s q= 0.0000° v=0.00000 rad/s
t=0.2500s q= 3.5810° v=0.50000 rad/s
t=0.5000s q= 14.3239° v=1.00000 rad/s
t=1.0000s q= 42.9718° v=1.00000 rad/s
t=1.5708s q= 75.6763° v=0.99999 rad/s
t=2.0708s q= 90.0000° v=0.00000 rad/s
梯形曲线还留着一个问题。在 t=0 和 t=0.5 处,加速度像台阶一样从 0 跳到 2.0,再从 2.0 跳回 0。加加速度(加速度的导数)在那个瞬间是无穷大。手臂轻的时候不太容易察觉,但连杆又长又细的话,这股冲击会敲击结构,留下肉眼可见的残余振动。
五次多项式轨迹 —— 把加速度也串联起来
消除加加速度问题的标准做法,是把位置写成时间的多项式,并在两端同时指定位置、速度、加速度。这就是六个条件,对应六个未知数,于是变成了五次多项式。
q(t) = a0 + a1·t + a2·t² + a3·t³ + a4·t⁴ + a5·t⁵
把两端的速度和加速度都设为零,系数就能以闭式解的形式给出。设总位移为 Δq,总时间为 T,
a0 = q0, a1 = 0, a2 = 0
a3 = 10·Δq / T³
a4 = -15·Δq / T⁴
a5 = 6·Δq / T⁵
这里会得到两个实务上很重要的数字。用归一化时间 τ = t/T 来看,速度在 τ = 0.5 处达到峰值,加速度在 τ = 0.2113 和 τ = 0.7887 处达到峰值,对应的值分别是:
v_max = 1.875 × Δq / T
a_max = (10/√3) × Δq / T² = 5.7735 × Δq / T²
在和前面梯形曲线相同的条件下比较一下。用 T = 2.0708秒 移动 Δq = 1.5708 rad,
v_max = 1.875 × 1.5708 / 2.0708 = 1.4223 rad/s
a_max = 5.7735 × 1.5708 / 2.0708² = 2.1149 rad/s²
同样的时间,所需的最大速度却不是 1.0,而是 1.4223,快了 42%。反过来,把最大速度限制在 1.0,
T = 1.875 × 1.5708 / 1.0 = 2.9452秒
原本 2.0708 秒的移动,变成了 2.9452 秒。同样相差 42%。
| 项目 | 梯形 | 五次多项式 |
|---|---|---|
| 相同时间(2.0708秒)下的最大速度 | 1.0000 rad/s | 1.4223 rad/s |
| 相同时间下的最大加速度 | 2.0000 rad/s² | 2.1149 rad/s² |
| 最大速度限制在1.0时所需的时间 | 2.0708秒 | 2.9452秒 |
| 加加速度 | 起止点无穷大 | 处处有限 |
| 电机性能利用率 | 高 | 低 |
| 计算量 | 3个条件分支 | 6个系数,无分支 |
这张表说的不是哪一种更优越。工业机器人用梯形族,是因为节拍时间直接等于生产效率;精密测量设备或相机云台用多项式,是因为残余振动直接等于成色。把两种性质结合起来的 S 型曲线,实际上用得最广,可以把它理解为梯形曲线的加减速拐角被多项式磨圆了的版本。
import numpy as np
def quintic(q0, qf, T):
"""返回两端速度和加速度都为0的五次多项式系数。"""
d = qf - q0
return np.array([q0, 0.0, 0.0, 10 * d / T ** 3, -15 * d / T ** 4, 6 * d / T ** 5])
def evaluate(coef, t):
powers = np.array([t ** i for i in range(6)])
dpow = np.array([i * t ** (i - 1) if i >= 1 else 0.0 for i in range(6)])
ddpow = np.array([i * (i - 1) * t ** (i - 2) if i >= 2 else 0.0 for i in range(6)])
return coef @ powers, coef @ dpow, coef @ ddpow
T = 2.0
c = quintic(0.0, np.radians(90), T)
print("系数:", np.round(c, 6))
for t in (0.0, 0.5, 1.0, 1.5, 2.0):
q, v, a = evaluate(c, t)
print(f" t={t}s q={np.degrees(q):8.4f}° v={v:8.5f} rad/s a={a:8.5f} rad/s²")
print(f" 预测最大速度 {1.875*np.radians(90)/T:.6f},最大加速度 {10/np.sqrt(3)*np.radians(90)/T**2:.6f}")
运行结果如下。
系数: [ 0. 0. 0. 1.963495 -1.472622 0.294524]
t=0.0s q= 0.0000° v= 0.00000 rad/s a= 0.00000 rad/s²
t=0.5s q= 9.3164° v= 0.82835 rad/s a= 2.20893 rad/s²
t=1.0s q= 45.0000° v= 1.47262 rad/s a= 0.00000 rad/s²
t=1.5s q= 80.6836° v= 0.82835 rad/s a=-2.20893 rad/s²
t=2.0s q= 90.0000° v= 0.00000 rad/s a= 0.00000 rad/s²
预测最大速度 1.472622,最大加速度 2.267249
在 t=1.0 处正好走到一半、45 度,速度达到峰值 1.4726,两端的速度和加速度都是零。预测的最大速度和采样值吻合。
关节空间插值与作业空间插值产生的不同路径
这里有一个岔路口:轨迹应该按关节角度来生成,还是按末端位置来生成?
关节空间插值在起始角度和终止角度之间用上面的曲线去填充,每个关节各自独立地沿自己的轨迹走。计算便宜,关节速度和加速度限制能直接遵守,也不用担心奇异点。
作业空间插值把起始位置和终止位置用一条直线连起来,再在直线上的每一点求解逆运动学,得到关节角度。末端实际画出的是一条直线。
来算一下这两种方法产生的路径能差多少。用和正运动学那篇一样的两连杆手臂:上臂 L1 = 0.20 m,前臂 L2 = 0.15 m。
起始姿态是 (30°, 45°),终止姿态是 (-30°, 45°),末端位置分别是:
起始 (0.212028, 0.244889) m
终止 (0.318094, -0.061177) m
在关节空间里,正中间的 (0°, 45°) 对应的末端位置是:
x = 0.20·cos(0) + 0.15·cos(45°) = 0.20 + 0.106066 = 0.306066
y = 0.20·sin(0) + 0.15·sin(45°) = 0 + 0.106066 = 0.106066
在作业空间里,直线的中点是两端点的平均值,
x = (0.212028 + 0.318094) / 2 = 0.265061
y = (0.244889 - 0.061177) / 2 = 0.091856
两点之间的距离是:
√((0.306066 - 0.265061)² + (0.106066 - 0.091856)²)
= √(0.041005² + 0.014210²)
= √(0.00168141 + 0.00020193)
= 0.043398 m = 43.4 mm
连接的是完全相同的两个点,中间却相差了 43 毫米。 手臂总长是 350 毫米,这已经超过 12%。如果这个动作是往杯子里倒水,这点差距就是成功和失败的分界线。反过来,如果这个动作只是把东西从箱子上方的 A 点挪到 B 点,一条鼓出来 43 毫米的路径,反而可能帮着绕开了障碍物。
| 标准 | 关节空间插值 | 作业空间插值 |
|---|---|---|
| 末端路径 | 难以预测的曲线 | 直线(或指定的曲线) |
| 关节速度限制 | 直接遵守 | 间接的,容易违反 |
| 奇异点 | 经过也没问题 | 经过时关节速度会发散 |
| 每周期计算量 | 一次多项式求值 | 一次逆运动学求解 |
| 解切换问题 | 没有 | 可能在elbow-up和elbow-down之间跳变 |
| 用在哪里 | 点到点移动、回到待机姿态 | 焊接、涂布、插入、倒液体 |
最后一行是实务中最让人头疼的一项。如果在直线上的每个点都独立求解逆运动学,某个点可能会突然选中另一个解,肘部瞬间翻到反方向。末端仍然在直线上,但整条手臂在一个周期内就翻到了另一侧。解决办法是不要每次都重新求解,而是强制选择离上一次最近的那个解。
PID —— 三个项各自修正的问题
轨迹每个周期给出一个目标角度之后,下层需要让实际角度跟上它。
把一个关节建模成这样。转动惯量 J = 0.02 kg·m²,粘性摩擦 b = 0.05 N·m·s/rad,再加上正运动学与静态扭矩那篇算出来的肩部重力扭矩 1.79 N·m 向下拉着。目标是 90 度。
J·q̈ = τ - b·q̇ - τ_g
把三个项逐一打开看看。
比例项按误差成比例地推。设 Kp = 20,只打开这一项,关节会大幅冲过目标,振荡很长时间。单靠摩擦没法把能量抽走多少。
微分项按速度成比例地往回拉。从物理上看,这是在制造人工摩擦。加上 Kd = 1.0,超调消失了。但它到不了目标,会在差 5.13 度的地方停下。
这个 5.13 度不是随便一个数。静止状态下速度为零,所以微分项为零,只剩比例项独自和重力抗衡。
Kp × e = τ_g
e = 1.79 / 20 = 0.0895 rad = 5.129°
因为末端离肩部有 0.35 米,
0.0895 rad × 0.35 m = 0.0313 m = 31.3 mm
下垂了 31 毫米。 手臂安静地停在那里,误差也很稳定,只是停错了地方。
积分项通过把这份残留误差随时间累积起来,把它消除掉。加上 Ki = 40,稳态误差降到 0.07 度。代价是产生了 4.75 度的超调。积分项记着过去,所以反应会慢一拍。
import numpy as np
J_INERTIA = 0.02 # kg·m²
B_VISCOUS = 0.05 # N·m·s/rad
TAU_GRAVITY = 1.79 # N·m,肩部水平时重力产生的扭矩
DT, T_END = 0.001, 2.0
REF = np.radians(90)
def simulate(kp, ki, kd, feedforward=0.0):
q, dq, integral = 0.0, 0.0, 0.0
log = []
for _ in range(int(T_END / DT)):
e = REF - q
integral += e * DT
tau = kp * e + ki * integral + kd * (0.0 - dq) + feedforward
ddq = (tau - B_VISCOUS * dq - TAU_GRAVITY) / J_INERTIA
dq += ddq * DT
q += dq * DT
log.append(q)
return np.array(log)
for name, (kp, ki, kd, ff) in {
"P only": (20, 0, 0.0, 0.0),
"PD": (20, 0, 1.0, 0.0),
"PID": (20, 40, 1.0, 0.0),
"PD+gravity": (20, 0, 1.0, TAU_GRAVITY),
}.items():
log = simulate(kp, ki, kd, ff)
err = np.degrees(REF - log[-1])
over = max(0.0, np.degrees(log.max() - REF))
print(f"{name:<11} 最终 {np.degrees(log[-1]):8.4f}° 稳态误差 {err:8.4f}° 超调 {over:7.4f}°")
运行结果如下。
P only 最终 78.4275° 稳态误差 11.5725° 超调 69.8285°
PD 最终 84.8720° 稳态误差 5.1280° 超调 0.0000°
PID 最终 90.0718° 稳态误差 -0.0718° 超调 4.7486°
PD+gravity 最终 90.0000° 稳态误差 -0.0000° 超调 0.6777°
PD 的稳态误差 5.1280 度,和手算出来的 5.129 度吻合。理论和仿真给出同样的值,说明模型是对的。
最后一行残留的 0.6777 度超调也有意义。如果把重力完全去掉,剩下的就是一个纯粹的二阶系统,算一下这组增益下的阻尼比:
ζ = (Kd + b) / (2·√(Kp·J)) = 1.05 / (2·√(20 × 0.02)) = 1.05 / 1.2649 = 0.830
是 0.83,理论超调大约是 0.9%,对应 90 度的动作就是 0.84 度。和仿真里的 0.68 度是同一个量级。只打开 PD 时超调为零,并不是因为控制器做得好,而是因为重力一直在把手臂往下拽,充当了一个免费的刹车。重力补偿也会把这个免费刹车一并拿走,所以打开补偿之后,需要重新审视 Kd。
用积分项时,有一个装置必须一起加上。如果手臂被物理卡住,或者电机撞上了扭矩极限,误差就不会再缩小——可积分项在这段时间里依然会持续累积。等障碍物之后被移开的那一刻,那个被撑得巨大的积分项会猛地把手臂甩出去。这就是积分饱和,对策是一旦输出饱和就停止累积积分,或者给积分值本身设一个上限。
// Arduino上单个关节的PID。前提是按固定周期运行。
const float KP = 20.0f, KI = 40.0f, KD = 1.0f;
const float DT = 0.005f; // 200Hz。这个值必须和实际周期一致
const float I_LIMIT = 3.0f; // 积分饱和防护上限(换算成N·m)
const float TAU_LIMIT = 8.0f; // 电机能输出的扭矩上限
float integral = 0.0f;
float prevMeasured = 0.0f;
float pidStep(float target, float measured, float gravityFeedforward) {
float error = target - measured;
// 微分项用测量值来算,而不是用误差来算。因为一旦目标像阶跃一样突变,
// 误差的导数瞬间会趋于无穷大,导致输出跳变。
float derivative = -(measured - prevMeasured) / DT;
prevMeasured = measured;
float unsaturated = KP * error + KI * integral + KD * derivative + gravityFeedforward;
// 不再往推向饱和的那个方向继续累积积分。
bool pushingIntoLimit =
(unsaturated > TAU_LIMIT && error > 0) || (unsaturated < -TAU_LIMIT && error < 0);
if (!pushingIntoLimit) {
integral += error * DT;
if (integral > I_LIMIT) integral = I_LIMIT;
if (integral < -I_LIMIT) integral = -I_LIMIT;
}
float tau = KP * error + KI * integral + KD * derivative + gravityFeedforward;
if (tau > TAU_LIMIT) tau = TAU_LIMIT;
if (tau < -TAU_LIMIT) tau = -TAU_LIMIT;
return tau;
}
留意一下,微分项是用测量值算的,而不是用误差算的。目标一旦像阶跃一样突变,误差的导数理论上是无穷大,实际中会在一个周期内就把输出顶到上限。用测量值的导数,这一项就只会关心关节的实际速度,不管目标怎么变化。
前馈与重力补偿
回到上面仿真的最后一行。给 PD 直接加上重力扭矩,稳态误差就变成了 0.0000 度——而且完全没用到积分项。
这就是前馈。反馈只有在误差已经出现之后才会做出反应。但重力是可以预测的,只要知道关节角度,现在就能算出重力正在施加多大的力。没有理由非要等手臂垂下去之后再去纠正。
把肩部的重力扭矩写成角度的函数。把每个质量的水平距离乘以重量,再加起来就行。
τ_g(θ1, θ2) = g × [ m1·(L1/2)·cos(θ1)
+ m2·(L1·cos(θ1) + (L2/2)·cos(θ1+θ2))
+ (m_grip + m_pay)·(L1·cos(θ1) + L2·cos(θ1+θ2)) ]
代入前一篇文章里的数值(m1 = 0.15、m2 = 0.10、夹爪 0.15、负载 0.25 kg),
| 肩部角度 | 肘部角度 | 重力扭矩 |
|---|---|---|
| 0°(水平) | 0° | 1.7903 N·m |
| 30° | 0° | 1.5505 N·m |
| 60° | 0° | 0.8952 N·m |
| 90°(竖直) | 0° | 0.0000 N·m |
| 0° | 45° | 1.5964 N·m |
| 0° | 90° | 1.1282 N·m |
立起来到竖直状态时正好是零,肘部一折,重心往内收,数值就跟着变小。这张表本身就是前馈项。每个周期用当前角度算一次这个公式,加到控制输出里,反馈就只需要负责模型没顾上的那一小部分了。
这里有件事要老实说清楚:这个公式是一个模型。如果手臂实际的质量分布和它对不上,算出来的值就会有偏差,而偏差的那部分依然得靠反馈去处理。即便如此,让反馈独自扛下全部 1.79 N·m,和只需要扛 0.2 N·m,完全是两个不同量级的问题。所需的 Kp 会相应变小,Kp 变小,噪声放大和振动也会一起减少。
能前馈的不只是重力。既然轨迹已经知道目标速度和目标加速度,
τ_ff = J·q̈_desired + b·q̇_desired + τ_g(q_desired)
把这三项全部加进去,模型完美的情况下,反馈的输出就会趋于零。这被称为计算力矩控制,工业机器人控制器做的事情大体上就是这个。让反馈只负责模型出错的那部分,是控制设计的大方向。
控制周期和延迟如何一点点吃掉稳定性
到目前为止讲的都是连续时间。实际的控制器是离散运行的,这个事实会直接影响稳定性。
数字控制回路至少有两种延迟:一种是零阶保持带来的平均半个周期,另一种是从读取传感器、计算、到输出所花的时间。实务上常见的经验法则是把两者合起来估算成大约 1.5 个周期。
纯延迟对相位的影响与频率成正比。
相位滞后(弧度) = ω × T_delay
把回路增益等于 1 的那个频率,也就是穿越频率,取为 20 rad/s(大约 3.2Hz),来算一下。
| 控制周期 | 有效延迟(1.5个周期) | 20 rad/s处的相位滞后 |
|---|---|---|
| 1000Hz | 1.50 ms | 1.72° |
| 200Hz | 7.50 ms | 8.59° |
| 100Hz | 15.00 ms | 17.19° |
| 50Hz | 30.00 ms | 34.38° |
50Hz: 20 × 0.030 = 0.600 rad = 34.38°
1000Hz: 20 × 0.0015 = 0.030 rad = 1.72°
差值: 32.66°
把控制周期从 1 千赫兹降到 50 赫兹,32.7 度的相位裕度就这样凭空消失了。 通常的设计目标是 45 到 60 度之间,一旦丢掉 32.7 度,几乎所剩无几。增益一个都没动,系统却振荡起来了。
这个症状有个很典型的样子:手臂在目标附近以某个固定频率颤动,把增益调低就停,再调高又用同样的频率颤动起来。如果这个频率和控制周期有关系,那原因就是延迟,不是增益。
周期不稳定也是同类问题。经常能看到这样的代码:在 Arduino 的 loop() 里塞进 PID,再用 delay(5) 去凑周期——但实际周期是 5 毫秒加上额外花掉的计算时间,哪怕只是加了一条串口打印,周期都会明显拉长。因为微分项和积分项都要除以或乘以 DT,一旦 DT 和实际不符,增益就会按同样的比例被扭曲。用硬件定时器中断来实现固定周期,或者至少实测经过的时间再拿来用,会更稳妥。
延迟不只来自软件,通信本身也是延迟。如果用一条总线把多个智能舵机串联起来,读一遍全部六个关节状态所花的时间,就直接变成了你的最小控制周期。如果用 I2C 读编码器,I2C 总线的特性会让时钟延展和从设备响应时间原样计入周期里。
为什么仿真器里调好的增益一到实机就会失灵
这篇文章的仿真里,Kp = 20、Kd = 1.0 表现得相当漂亮。把同样的值放到真实手臂上,通常会失灵。原因有好几个,而且每一个都是模型里没有的东西。
第一,背隙。 仿真里电机轴和关节轴是同一个角度。实物上,两者会相差减速器留下的那部分间隙。在这段区间里,电机在转,关节却不动,对控制器来说,这个区间的增益就是零。一旦越过间隙、齿重新啮合上,增益又会猛地恢复正常。一个增益在不同区间不一样的系统,很难靠一组增益在所有区间都表现良好。症状是目标附近的低频极限环——小幅度地来回晃个不停。
第二,关节的柔性。 连杆被当成了刚体,但实物是会挠曲的。谐波减速器原理上就用了弹性元件,3D 打印的连杆肉眼可见地会弯,皮带也会伸长。这份柔性会在电机和连杆之间形成共振,在共振频率以上,相位会翻转 180 度。仿真里的刚体模型压根就没有这个极点。
第三,摩擦不是粘性的。 模型里的 b·q̇ 是正比于速度的粘性摩擦,但实际关节里占主导的是与速度无关的库仑摩擦,以及静止时更大的静摩擦。低速时会出现粘滑现象——粘住,再滑动,如此反复。这正是「极其缓慢地移动」这种指令最难执行好的原因。
第四,编码器量化和微分噪声。 一个 12 位编码器把一圈分成 4096 份,分辨率是 0.0879 度。在 200Hz 下对这一个刻度的差值求导,
0.0879° / 0.005秒 = 17.6°/秒
哪怕关节完全静止,只要读数晃动了一个刻度,微分项看到的就是相当于 17.6 度/秒的速度。Kd 一大,这份噪声就会原样传到输出上,电机跟着嗡嗡叫。这就是为什么微分项几乎总是要配一个低通滤波器。
第五,电源。 仿真里的电机能立刻输出要求的扭矩。实物上,如果驱动电机的电源供不上电流,扭矩就出不来。好几个关节同时加速的那一瞬间,电压会塌陷,而控制器还以为自己下达的扭矩已经生效,继续照常计算下一步。如果只有手臂摆到较重的姿态时才会出问题,不妨先测测电流,而不是急着去调增益。
实务上的顺序是这样的:从仿真器里得到的不是最终增益,而是增益的量级和结构。它是一个用来确认需要哪些项、大致多大、轨迹是否落在关节限制之内的工具。到了实机上,先打开重力补偿,把 Ki 和 Kd 保持为零,把 Kp 调到开始振荡为止,再减半;接着调高 Kd 去抑制振荡;如果稳态误差还有残留,最后再加一点点很小的 Ki。顺序一旦打乱,就搞不清楚到底是哪一步修好了什么。
结语 —— 好的控制器,通常就是好的轨迹
这篇文章讲的这些对策,分别作用在不同的层次上。
轨迹生成解决的是「一开始就不要提出不可能的要求」。要求 90 度在 0.02 秒内到位,任何控制器都执行不好。让它 2.07 秒内到位,普通的控制器也能跟得很好。
前馈解决的是「把已知的东西提前给进去」。重力在每一刻都是可以算出来的,没有理由非要等误差出现了再去补救。
反馈负责剩下的一切:模型没顾上的摩擦、意料之外的外力、零件之间的个体差异。这份「剩下的」越小,所需的增益就越小,增益越小,整个系统就越宽容。
遇到手臂猛抖的时候,首先该看的不是增益,而是指令。 增益怎么调都调不好的问题,往往一行轨迹代码就能解决。延迟排第二,增益通常是最后才轮到的。
有一个实验可以自己验证这个顺序:把控制周期减半。如果症状变好了,是延迟问题;如果没什么变化,是模型问题。增益是分清这两者之后才该动手调的东西。这背后涉及的数学,已经按顺序整理在机器人学所需的数学那篇里了。
현재 단락 (1/275)
假设[逆运动学](/blog/electronics/2026-08-02-inverse-kinematics-explained)已经算完了。已经决定好末端要放在哪里,每个关节该是多少度也算出来了...