教程 · 高级 · 阅读约 22 分钟

二连杆机器人手臂的逆运动学求解

理解平面二连杆机械臂的逆运动学:肘部朝上与肘部朝下两组解、可达工作空间、奇异点,以及那些够不着的目标点。

正运动学很容易:给定关节角,把连杆变换乘起来,你就知道工具在哪儿。逆运动学反着跑——给定一个目标位置,找出够到它的关节角——而有趣的问题都住在那个方向上。平面二连杆臂是把它们全部展示出来的最小例子。在机械臂模拟器里探索它。

可达工作空间

连杆长 L₁、L₂,工具只能够到环带(一个圆环)内的点。外半径是 L₁ + L₂(臂完全伸直),内半径是 |L₁ − L₂|(臂完全折叠)。环外太远,洞内太近。没有任何关节角能到那些点,所以正确的答案就是“到不了”。

一条二连杆机械臂从同一底座画了两次、指向同一个目标点,一次肘部向上弯、一次肘部向下弯,显示出两组不同的关节配置到达完全相同的位置。一段外弧标出两连杆之和的臂展极限,一段内侧虚线弧标出内边界。注解说明超出臂展极限时两个解都不存在,反正切返回 NaN。
两组关节角把工具放在完全相同的地方。在它们之间选哪个是你的决定,不是数学的决定。 下载 SVG

两个解:肘上与肘下

对环内大多数目标点,肘部可以朝两个方向弯而仍然把工具放在同一个点上。这就是肘上和肘下两种配置。它们来自第二关节余弦定理角度里的那个正负号:

cos(θ₂) = (x² + y² − L₁² − L₂²) / (2·L₁·L₂)
θ₂ = ± acos(cos(θ₂))        # + is one elbow, − is the other
θ₁ = atan2(y, x) − atan2(L₂·sin θ₂, L₁ + L₂·cos θ₂)

因为有两个有效答案,逆运动学不是一个函数——它是一个选择。机器人会挑那个让关节远离限位、把移动量降到最低、或者避开障碍的配置。

奇异

在工作空间的边缘——臂完全伸直或完全折叠——两个解合并成一个。这些地方就是奇异,而在它们附近,工具的一点小动作可能要求关节的大幅动作。规划器会在奇异位姿附近放慢速度或绕开它,因为在那里臂会短暂地失去沿某个方向移动工具的能力。

什么时候无解?

目标落在可达环之外时无解,需要一个硬件禁止的关节角时无解,需要一个可用关节产生不了那个朝向时也无解。一个好的模拟器会诚实地把这几种情况区分开,而不是吸附到最近的位姿、假装自己成功了。

解析求解器与数值求解器

两三根连杆时几何有闭式(解析)解,所以你可以直接、精确、快速地算出关节角。这让解析逆解成为教学和测试的理想选择。冗余的或完全空间的手臂通常没有闭式解,需要迭代逼近解的数值求解器——更通用,但更慢,而且更难推理,因为它们可能漏解或者在奇异附近停滞。

把求解器写安全

上面那些方程是正确的,而在真实硬件上它们仍然会产出 NaN。下面是同样的数学,只是每一次失败都显式处理:

enum IkStatus { IK_OK, IK_TOO_FAR, IK_TOO_CLOSE, IK_JOINT_LIMIT, IK_SINGULAR };

struct IkResult {
  IkStatus status;
  float theta1, theta2;      // radians
};

IkResult solveIk(float x, float y, float L1, float L2, bool elbowUp) {
  IkResult r{};

  const float r2 = x * x + y * y;
  const float reach = sqrtf(r2);

  // 1. Reachability FIRST, before any trigonometry.
  //    A small epsilon keeps a target exactly on the boundary from
  //    producing an acos argument of 1.0000001 through rounding.
  const float eps = 1e-4f;
  if (reach > L1 + L2 - eps)          { r.status = IK_TOO_FAR;   return r; }
  if (reach < fabsf(L1 - L2) + eps)   { r.status = IK_TOO_CLOSE; return r; }

  // 2. Now acos is guaranteed to be in range.
  float c2 = (r2 - L1 * L1 - L2 * L2) / (2.0f * L1 * L2);
  c2 = constrain(c2, -1.0f, 1.0f);              // belt and braces
  const float s2 = sqrtf(1.0f - c2 * c2);

  r.theta2 = elbowUp ? atan2f(s2, c2) : atan2f(-s2, c2);

  // 3. atan2, never atan — this is what gets all four quadrants right.
  r.theta1 = atan2f(y, x) - atan2f(L2 * sinf(r.theta2), L1 + L2 * cosf(r.theta2));

  // 4. Warn near the boundary, where the arm is ill-conditioned.
  if (reach > (L1 + L2) * 0.95f) { r.status = IK_SINGULAR; return r; }

  // 5. Mechanical limits are part of the answer, not an afterthought.
  if (r.theta1 < T1_MIN || r.theta1 > T1_MAX ||
      r.theta2 < T2_MIN || r.theta2 > T2_MAX) { r.status = IK_JOINT_LIMIT; return r; }

  r.status = IK_OK;
  return r;
}

有五处细节让它区别于教科书版本,每一处都对应一次真实的故障:

**先判可达,再算三角。**目标在 L1 + L2 之外时,余弦定理会给出一个落在 −1 到 1 之外的参数,acos 返回 NaN。那个 NaN 接着就流进舵机指令,变成一个垃圾角度——这正是“够不到”的目标往往产生一次剧烈移动而不是一次拒绝移动的原因。

**那个极小余量。**一个算出来正好落在边界上的目标,经过浮点舍入后可能给出 c2 = 1.0000001。用一个小余量测可达性,能在 acos 之前抓住它。

**用 atan2f,不用 atan。**单参数的 atan 只取一个商,分不出前面的目标和它背后镜像的那个,于是臂在两个象限里正常、在另外两个象限里镜像。这是第一个逆解实现里最常见的缺陷。

**给奇异警告而不是拒绝。**接近全伸时解仍然有效,但病态——一点点工具动作就要求巨大的关节动作。标出来就能让调用方放慢或绕开。

**把关节限位当成一种状态。**一条大臂不能低于 −10°,于是就存在一些几何上可达、机械上不可达的目标。悄悄钳位会产出一台去了目标附近某个地方却报告成功的臂。

在两个解之间选

数学把两个分支都给你,却没法告诉你该用哪个。那是一个机器人决策,常见的规则有四条:

规则 选那个……的分支 适合
最小移动量 离当前关节角最近的 平滑连续路径——常规默认
固定分支 永远是肘上(或永远肘下) 可预测;避开臂下方的桌子
关节限位余量 让两个关节都离限位最远的 行程受限的臂
避障 不与已知障碍相撞的 杂乱的工作空间

最小移动量是正确的默认值,而理由在于不这样做会发生什么:如果求解器在路径中途悄悄切换分支,臂就会翻过一次幅度大、速度快的、完全没被计划过的动作。在真实的臂上,那既吓人又可能造成破坏。

// Pick the branch closest to where the arm already is.
IkResult up   = solveIk(x, y, L1, L2, true);
IkResult down = solveIk(x, y, L1, L2, false);

if (up.status == IK_OK && down.status == IK_OK) {
  const float costUp   = fabsf(up.theta1   - cur1) + fabsf(up.theta2   - cur2);
  const float costDown = fabsf(down.theta1 - cur1) + fabsf(down.theta2 - cur2);
  return (costUp <= costDown) ? up : down;
}
return (up.status == IK_OK) ? up : down;

直线运动每一步都要逆解

在关节空间里在两个位姿之间插值——让每个关节从起始角线性走到终止角——很简单,而且工具会沿一条曲线跟随。很多任务这样就够。但任何必须沿直线走的场合,都要在笛卡尔空间里插值,并在每一步解一次逆解:

void moveStraight(float x0, float y0, float x1, float y1, int steps) {
  for (int i = 1; i <= steps; i++) {
    const float t = (float)i / steps;
    const float x = x0 + (x1 - x0) * t;
    const float y = y0 + (y1 - y0) * t;

    IkResult r = solveClosest(x, y);
    if (r.status != IK_OK) { abortMove(r.status); return; }   // do not skip the point
    commandJoints(r.theta1, r.theta2);
  }
}

第一次这么做就会有两件事变得明显。关节速度并不恒定,尽管工具速度是——在工作空间边缘,相同的工具动作要求关节动得快得多。而一条直线可能穿过臂够不到的区域,哪怕两个端点都没问题,这正是循环内部那次状态检查重要、而只查端点不够的原因。

从关节角到舵机计数

求解器返回的是一个数学坐标系下的关节角。舵机对那个坐标系一无所知:它的零位是舵盘装在哪里的地方,而且它可能朝相反方向转。

struct Joint {
  uint8_t channel;
  float   offsetDeg;   // servo angle when the joint is at mathematical zero
  int8_t  sign;        // +1 or -1
  float   minDeg, maxDeg;
};

int servoAngle(const Joint &j, float jointRad) {
  const float deg = jointRad * 57.2957795f;
  const float clamped = constrain(deg, j.minDeg, j.maxDeg);
  return (int)(j.offsetDeg + j.sign * clamped);
}

那个结构体就是运动学与硬件之间整座桥,而把它显式化,正是让你能把求解器缺陷和标定缺陷区分开的原因。一台运动平滑却落在离目标老远地方的臂,几乎总是这个原因,而不是数学。

故障排查

现象 可能原因 解决
前面够得对,背后镜像 用了 atan 而不是 atan2 atan2 能解出全部四个象限
返回 NaN,臂猛扑 acos 参数超出范围 在三角函数之前测可达性
靠近底座的目标失败 落在 |L1 − L2| 的洞里 正确——工作空间是环带,不是圆盘
路径中途臂剧烈翻转 求解器切换了分支 选离当前位姿最近的分支
全伸附近关节速度尖峰 奇异 把规划路径限制在最大臂展约 95% 以内
往返都正确,落在错的地方 舵机偏移或方向符号 逐关节标定偏移和方向
全伸时始终偏短 重力下垂,不是数学 检查大臂的转矩预算;做配重
落点随接近方向而异 减速箱背隙 永远从同一个方向接近目标
直线运动中途失败 端点之间的路径离开了工作空间 每一步插值都查状态

自己试

在机械臂模拟器里拖着目标走,看臂实时求解。切换肘部分支去看另一个解,把目标推到环外去看一个诚实的“到不了”,朝工作空间边缘移动去体会奇异。

探索关联图谱

本教程所属的搭建

包含本教程的项目和学习路线。

延伸阅读

参考资料