引言
运动学回答两个问题:给定关节角,末端在哪里(正解);给定末端位姿,关节角是多少(逆解)。正解是唯一且总是有闭式解,逆解则可能无解、多解或无穷多解。所有上层能力——轨迹规划、笛卡尔阻抗控制、力控、抓取 ——都建立在运动学之上,因此它的实现质量直接决定了系统的精度与鲁棒性。
工程上的难点不在公式推导,而在几个具体问题:DH 参数与 URDF 不一致导致模型对不上实物;逆解在奇异位形附近关节速度爆炸;多解选择不当导致机械臂绕远路甚至撞到自身;数值逆解不收敛时没有兜底策略。这些问题在仿真里往往不出现,只有在真机上、在特定位形下才暴露。
本文从齐次变换出发,完整推导 DH 参数与变换矩阵,给出六轴臂的正解实现与数值验证方法;然后系统讲雅可比矩阵的两种求法,逆解的解析与数值两条路线,以及阻尼最小二乘、可操作度、多解选择等工程细节。所有代码基于 Eigen 与 Pinocchio 的接口约定,可以直接嵌入 ROS2 节点。
读完本文你应当能够:从实物或 CAD 提取 DH 参数、用代码验证正解正确性、为六轴臂实现稳定的逆解、以及在奇异位形附近给出合理的处理策略。
目录
- 坐标系、旋转与齐次变换
- DH 参数:标准型与改进型
- 正运动学:链式相乘与实现
- URDF 与 DH 参数的对应
- 雅可比矩阵:几何法与解析法
- 逆运动学:解析解
- 逆运动学:数值解与阻尼最小二乘
- 奇异位形、可操作度与多解选择
- 工程实践:Pinocchio、KDL 与验证
1. 坐标系、旋转与齐次变换
刚体变换用 4×4 齐次矩阵表示,它同时编码旋转与平移,且支持矩阵乘法复合。
T = [ R p ] R 为 3×3 旋转矩阵(正交,det=1)
[ 0 1 ] p 为 3×1 平移向量
复合:T_0_2 = T_0_1 · T_1_2
逆: T^-1 = [ R^T -R^T·p ]
[ 0 1 ]
#include <Eigen/Dense>
using Eigen::Matrix4d;
using Eigen::Matrix3d;
using Eigen::Vector3d;
// 绕固定轴的变换构造
Matrix4d rotX(double a) {
Matrix4d T = Matrix4d::Identity();
T.block<3,3>(0,0) = Eigen::AngleAxisd(a, Vector3d::UnitX()).toRotationMatrix();
return T;
}
Matrix4d rotZ(double a) {
Matrix4d T = Matrix4d::Identity();
T.block<3,3>(0,0) = Eigen::AngleAxisd(a, Vector3d::UnitZ()).toRotationMatrix();
return T;
}
Matrix4d trans(double x, double y, double z) {
Matrix4d T = Matrix4d::Identity();
T.block<3,1>(0,3) = Vector3d(x, y, z);
return T;
}
旋转的三种表示要分清用途:旋转矩阵用于计算(无奇异但 9 个参数冗余),欧拉角用于人机交互(有万向锁),四元数用于状态估计与插值(无奇异、可归一化)。姿态插值必须用四元数或旋转矩阵的 SLERP,直接对欧拉角线性插值会在特定角度产生非预期的大幅摆动。
2. DH 参数:标准型与改进型
Denavit-Hartenberg 参数用四个量描述相邻连杆的变换,是运动学建模的事实标准。
标准 DH(Denavit-Hartenberg,1955):
a_i :沿 x_i 轴,从 z_i 到 z_{i+1} 的距离
alpha_i:绕 x_i 轴,从 z_i 到 z_{i+1} 的转角
d_i :沿 z_i 轴,从 x_{i-1} 到 x_i 的距离
theta_i:绕 z_i 轴,从 x_{i-1} 到 x_i 的转角(转动关节的变量)
变换矩阵(标准型):
A_i = Rot_z(theta_i) · Trans_z(d_i) · Trans_x(a_i) · Rot_x(alpha_i)
改进 DH(Craig,Modified DH):
把坐标系固定在连杆近端而非远端,变换顺序不同
A_i = Rot_x(alpha_{i-1}) · Trans_x(a_{i-1}) · Rot_z(theta_i) · Trans_z(d_i)
Matrix4d dhTransform(double a, double alpha, double d, double theta) {
// 标准 DH:Rot_z(theta) * Trans_z(d) * Trans_x(a) * Rot_x(alpha)
return rotZ(theta) * trans(0, 0, d) * trans(a, 0, 0) * rotX(alpha);
}
两种形式的差别只在坐标系定义,最终的正解结果完全一致。混合使用是灾难:从论文抄标准 DH,从 URDF 导出改进 DH,两者一拼就会得到错误的正解,而且错误往往在特定关节角才明显。项目开始时就应锁定一种形式并在文档中写明。
以 UR5 为例,其标准 DH 参数为:
| 关节 | a (m) | alpha (rad) | d (m) | theta 偏移 |
|---|---|---|---|---|
| 1 | 0 | +π/2 | 0.089159 | 0 |
| 2 | -0.425 | 0 | 0 | 0 |
| 3 | -0.39225 | 0 | 0 | 0 |
| 4 | 0 | +π/2 | 0.10915 | 0 |
| 5 | 0 | -π/2 | 0.09465 | 0 |
| 6 | 0 | 0 | 0.0823 | 0 |
3. 正运动学:链式相乘与实现
正解就是所有连杆变换连乘,复杂度 O(n)。
struct DH {
double a, alpha, d, theta_offset;
int type; // 0 = revolute, 1 = prismatic
};
Matrix4d forwardKinematics(const std::vector<DH> & chain,
const std::vector<double> & q) {
Matrix4d T = Matrix4d::Identity();
for (size_t i = 0; i < chain.size(); ++i) {
const DH & l = chain[i];
double theta = (l.type == 0) ? (q[i] + l.theta_offset) : l.theta_offset;
double d = (l.type == 1) ? (q[i] + l.d) : l.d;
T = T * dhTransform(l.a, l.alpha, d, theta);
}
return T;
}
import numpy as np
def dh(a, alpha, d, theta):
ct, st = np.cos(theta), np.sin(theta)
ca, sa = np.cos(alpha), np.sin(alpha)
return np.array([
[ct, -st*ca, st*sa, a*ct],
[st, ct*ca, -ct*sa, a*st],
[0.0, sa, ca, d],
[0.0, 0.0, 0.0, 1.0],
])
def fk(chain, q):
T = np.eye(4)
for (a, alpha, d, off), qi in zip(chain, q):
T = T @ dh(a, alpha, d, qi + off)
return T
正解验证是必做步骤,三种方法互相印证:与 URDF 在随机位形下对比(Pinocchio 的 forwardKinematics);与厂商手册中的标称位姿对比(例如 UR5 零位下末端应在 (0.817, 0.191, -0.005) 附近);用物理测量验证(把末端推到某个已知点,看模型算出的位置与实测是否一致,误差应小于 1 mm)。
4. URDF 与 DH 参数的对应
URDF 用 <joint><origin xyz rpy/><axis/></joint> 描述连杆,与 DH 不是一一对应关系。从 URDF 提取 DH 参数需要把每段变换分解成 DH 形式,这个过程不总是有解(URDF 更通用)。
<joint name="shoulder_pan_joint" type="revolute">
<parent link="base_link"/>
<child link="shoulder_link"/>
<origin xyz="0 0 0.089159" rpy="0 0 0"/>
<axis xyz="0 0 1"/>
<limit lower="-6.2832" upper="6.2832" effort="150" velocity="3.15"/>
</joint>
实践中的正确做法是以 URDF 为唯一真相源,用 Pinocchio 或 KDL 直接从 URDF 构建模型,不做 DH 转换。只有在需要解析逆解或做理论分析时才用 DH,此时应写一个转换脚本并配单元测试,断言两条路径在随机位形下结果一致(误差 < 1e-9)。
URDF 里两个容易忽略的点:axis 的方向决定关节正方向,与 DH 的 theta 符号可能相反;limit 的 velocity 与 effort 必须在规划与控制器中被真正读取,而不是只写在 URDF 里好看。
5. 雅可比矩阵:几何法与解析法
雅可比把关节速度映射到末端速度:v = J(q) · q̇,其中 v = [线速度; 角速度] 为 6×1,J 为 6×n。
几何法(对每个关节 i):
转动关节:
J_v,i = z_{i-1} × (p_e - p_{i-1})
J_w,i = z_{i-1}
移动关节:
J_v,i = z_{i-1}
J_w,i = 0
其中 z_{i-1} 是第 i-1 个关节轴在世界系中的方向
p_{i-1} 是第 i-1 个坐标系原点在世界系中的位置
p_e 是末端位置
// 用 Pinocchio 求雅可比,避免手写推导出错
#include <pinocchio/parsers/urdf.hpp>
#include <pinocchio/algorithm/jacobian.hpp>
#include <pinocchio/algorithm/kinematics.hpp>
pinocchio::Model model;
pinocchio::urdf::buildModel("arm.urdf", model);
pinocchio::Data data(model);
Eigen::VectorXd q = Eigen::VectorXd::Zero(model.nq);
pinocchio::computeJointJacobians(model, data, q);
pinocchio::forwardKinematics(model, data, q);
Eigen::Matrix<double, 6, Eigen::Dynamic> J(6, model.nv);
J.setZero();
pinocchio::getJointJacobian(model, data,
model.getJointId("tool0"), pinocchio::WORLD, J);
解析法(对旋转矩阵直接求导)能得到闭式表达式,速度更快但推导量大、易出错。工程建议是:用 Pinocchio 或 KDL 计算,只在性能极端敏感时手写解析式,并保留一个数值微分作为交叉验证。
数值微分的实现很直接,可作为校验基准:
Eigen::MatrixXd jacobianNumerical(FkFn fk, const Eigen::VectorXd & q,
double eps = 1e-6) {
const int n = q.size();
const Eigen::Matrix4d T0 = fk(q);
const Eigen::Matrix3d R0 = T0.block<3,3>(0,0);
const Eigen::Vector3d p0 = T0.block<3,1>(0,3);
Eigen::MatrixXd J(6, n);
for (int i = 0; i < n; ++i) {
Eigen::VectorXd qp = q; qp[i] += eps;
Eigen::Matrix4d T1 = fk(qp);
J.block<3,1>(0,i) = (T1.block<3,1>(0,3) - p0) / eps;
// 角速度:用旋转矩阵的反对称部分提取
Eigen::Matrix3d dR = (T1.block<3,3>(0,0) - R0) / eps;
Eigen::Matrix3d W = dR * R0.transpose();
J(3,i) = W(2,1); J(4,i) = W(0,2); J(5,i) = W(1,0);
}
return J;
}
6. 逆运动学:解析解
六轴臂(且满足 Pieper 条件:三个相邻关节轴交于一点或平行)存在闭式解。UR5、Panda、多数工业臂都满足。解析解速度快(微秒级)且能枚举全部解。
典型 6R 臂的解析步骤(球形手腕):
1. 用腕心位置解前三个关节
腕心 p_wc = p_e - d6 · R_e · z_6
θ1 = atan2(p_wc_y, p_wc_x) ± π/2 修正
θ3 由余弦定理:cos θ3 = (r² - a2² - a3²) / (2·a2·a3)
θ2 = atan2(...) - atan2(...)
2. 由 R_3_6 = R_0_3^T · R_e 解后三个关节(欧拉角分解)
θ4, θ5, θ6 从 ZYZ 型旋转矩阵提取
3. 每个 θ1 分支对应两组解,共 8 组(受关节限位过滤后通常剩 2~4 组)
def ik_ur5(T_target, d1=0.089159, a2=-0.425, a3=-0.39225,
d4=0.10915, d5=0.09465, d6=0.0823):
"""返回所有满足关节限位的解(最多 8 组)"""
R = T_target[:3, :3]
p = T_target[:3, 3]
# 腕心位置
p_wc = p - d6 * R[:, 2]
sols = []
for s1 in (+1, -1):
theta1 = np.arctan2(p_wc[1], p_wc[0]) + s1 * np.pi / 2
r = np.hypot(p_wc[0], p_wc[1])
if abs(p_wc[2] - d1) > (abs(a2) + abs(a3)):
continue # 超出工作空间,跳过该分支
cos3 = (r**2 - a2**2 - a3**2) / (2 * a2 * a3)
if abs(cos3) > 1.0:
continue # 不可达
for s3 in (+1, -1):
theta3 = s3 * np.arccos(np.clip(cos3, -1, 1))
# ... 由几何关系求 theta2,再由 R 求 theta4/5/6
sols.append((theta1, theta2, theta3, theta4, theta5, theta6))
return sols
解析解的工程价值在于可枚举:拿到全部解后可以按「与当前位形最近」「避开关节限位」「远离奇异」等准则选最优。数值解只能给出一个解,且依赖初值,无法保证找到可行解。
7. 逆运动学:数值解与阻尼最小二乘
当机构不满足 Pieper 条件(如 7 自由度冗余臂、带并联结构的腕),只能数值求解。基本迭代是:
牛顿-拉夫逊迭代:
1. 计算当前位姿误差:e = [p_target - p(q); 姿态误差]
2. 求解 J(q) · Δq = e
3. 更新 q ← q + α · Δq
4. 若 ||e|| < ε 则收敛,否则重复(上限 100~200 次)
直接求逆 Δq = J^-1 · e 在奇异位形附近会失败,因为 J 不满秩。最小二乘解 J^+ = J^T (J J^T)^-1 在接近奇异时也会放大,因为 J J^T 接近奇异。标准对策是阻尼最小二乘(Levenberg-Marquardt 形式):
Δq = J^T (J J^T + λ² I)^-1 · e
λ 的取值策略(由 σ_min 决定):
σ_min > σ_0 :λ = 0(正常伪逆)
σ_min < σ_0 :λ² = (1 - (σ_min/σ_0)²) · λ_max²
这样在远离奇异时退化为普通伪逆,在奇异附近平滑地增加阻尼
典型值:σ_0 = 0.05, λ_max = 0.05
// 阻尼最小二乘逆解迭代
bool ikDLS(const pinocchio::Model & model, pinocchio::Data & data,
const Eigen::Matrix4d & T_target, Eigen::VectorXd & q,
double tol = 1e-4, int max_iter = 200) {
const double lambda = 0.05;
for (int it = 0; it < max_iter; ++it) {
pinocchio::forwardKinematics(model, data, q);
const Eigen::Matrix4d T = data.oMi[model.nv - 1].toHomogeneousMatrix();
// 位姿误差:位置差 + 旋转的 so(3) 对数映射
Eigen::Matrix<double, 6, 1> e;
e.head<3>() = T_target.block<3,1>(0,3) - T.block<3,1>(0,3);
const Eigen::Matrix3d dR = T_target.block<3,3>(0,0)
* T.block<3,3>(0,0).transpose();
e.tail<3>() = pinocchio::log3(dR);
if (e.norm() < tol) return true;
Eigen::Matrix<double, 6, Eigen::Dynamic> J(6, model.nv);
pinocchio::computeJointJacobians(model, data, q);
J.setZero();
pinocchio::getJointJacobian(model, data,
model.nv - 1, pinocchio::LOCAL, J);
// Δq = J^T (J J^T + λ²I)^-1 e
Eigen::Matrix<double, 6, 6> JJt = J * J.transpose();
JJt += lambda * lambda * Eigen::Matrix<double, 6, 6>::Identity();
Eigen::Matrix<double, 6, 1> dq = J.transpose() * JJt.ldlt().solve(e);
q = pinocchio::integrate(model, q, dq); // 正确的流形积分
}
return false;
}
两个细节决定成败:一是用 pinocchio::integrate 而不是直接 q += dq,因为对四元数关节直接相加会破坏单位约束;二是姿态误差必须用 so(3) 的对数映射(旋转向量),而不是欧拉角差,否则在 ±π 附近会出现跳变导致不收敛。
8. 奇异位形、可操作度与多解选择
奇异位形是 J 降秩的位形,此时某些方向的末端运动无法由关节运动实现,逆解会给出无穷大关节速度。六轴臂的典型奇异有三类:
腕部奇异:θ5 ≈ 0 或 π,第 4、6 轴共线
现象:θ4 与 θ6 有无穷多组合,数值解剧烈跳变
对策:给 θ5 加一个小偏置(如 ±0.02 rad)绕开
肘部奇异:手臂完全伸直,θ3 ≈ 0
现象:沿手臂方向的运动不可实现
对策:限制工作空间,不让目标点落在伸直构型上
肩部奇异:腕心落在第 1 轴线上
现象:θ1 不确定
对策:目标点避开基座轴线附近区域
可操作度(Yoshikawa)量化了「离奇异有多远」:w = sqrt(det(J J^T)),等于所有奇异值之积。工程上更常用最小奇异值 σ_min 作为判据:
Eigen::JacobiSVD<Eigen::MatrixXd> svd(J);
double sigma_min = svd.singularValues().minCoeff();
double sigma_max = svd.singularValues().maxCoeff();
double cond = sigma_max / sigma_min; // 条件数
// 分级策略
if (sigma_min > 0.05) {
// 正常:普通伪逆
} else if (sigma_min > 0.01) {
// 接近奇异:启用阻尼最小二乘
} else {
// 严重奇异:拒绝该目标,上报规划层重新选点
}
多解选择的准则按优先级排序:先过滤掉超出关节限位的解;再按关节空间距离选最近的(避免大幅绕路);最后检查与自身碰撞。冗余臂(7 自由度)还有零空间自由度,可以用 q̇ = J^+ v + (I - J^+ J) z 在零空间中优化次级目标(如远离限位、保持肘部姿态),这是冗余臂的核心价值所在。
9. 工程实践:Pinocchio、KDL 与验证
三个主流库的定位不同,按场景选择:
| 库 | 优势 | 劣势 | 适用 |
|---|---|---|---|
| Pinocchio | 速度最快、支持解析导数、现代 API | 学习曲线陡 | 高性能控制、优化 |
| KDL(orocos) | ROS 生态集成好、MoveIt 默认 | 性能一般、API 陈旧 | MoveIt 内集成 |
| TRAC-IK | 逆解成功率高于 KDL 数倍 | 只做 IK | 需要高 IK 成功率的场景 |
| IKFast | 生成解析解,微秒级 | 需离线生成、只支持特定结构 | 量产、极致性能 |
验证运动学实现的完整流程应该是自动化的:
// 单元测试:正解与 Pinocchio 对比,逆解往返验证
TEST(Kinematics, ForwardMatchesPinocchio) {
for (int trial = 0; trial < 1000; ++trial) {
Eigen::VectorXd q = randomConfig(); // 随机位形
Eigen::Matrix4d T1 = forwardKinematics(chain_, q);
Eigen::Matrix4d T2 = pinocchioFk(model_, q);
EXPECT_LT((T1 - T2).norm(), 1e-9); // 数值级一致
}
}
TEST(Kinematics, InverseRoundTrip) {
for (int trial = 0; trial < 1000; ++trial) {
Eigen::VectorXd q_true = randomConfig();
Eigen::Matrix4d T = forwardKinematics(chain_, q_true);
Eigen::VectorXd q_sol;
ASSERT_TRUE(ikDLS(model_, data_, T, q_sol));
EXPECT_LT((forwardKinematics(chain_, q_sol) - T).norm(), 1e-6);
}
}
逆解往返测试是发现符号错误、DH 参数笔误、坐标系定义混乱的最有效手段。1000 组随机位形的测试通常能在几秒内跑完,应放进 CI。
权衡取舍
| 决策 | 选 A | 选 B |
|---|---|---|
| 建模方式 | DH:需解析解、理论分析 | URDF:直接用库、少出错 |
| DH 形式 | 标准 DH:文献多、工业界通用 | 改进 DH:与 URDF 更接近 |
| 逆解 | 解析解:快、可枚举、需满足 Pieper | 数值解:通用、依赖初值 |
| 奇异处理 | 阻尼最小二乘:平滑但精度下降 | 拒绝目标:安全但可用空间变小 |
| 雅可比 | 手写解析式:最快、易错 | 库计算:稳妥、够快 |
| 库选型 | Pinocchio:性能优先 | KDL/TRAC-IK:ROS 集成优先 |
判断原则:能用解析解就用解析解,但只在满足 Pieper 条件时;其余情况用阻尼最小二乘并配好奇异检测。任何情况下都不要在没有奇异检测的情况下把逆解直接接到控制器上。
常见坑清单
- 混用标准 DH 与改进 DH,正解在特定位形才错——项目开始锁定一种形式并写进文档。
- 关节
axis方向与 DH 的 theta 符号相反,运动方向整体反了——用零位与一个已知位形双向验证。 - 数值逆解直接
q += dq,四元数关节失去单位约束——必须用pinocchio::integrate做流形积分。 - 姿态误差用欧拉角相减,在 ±π 附近跳变导致不收敛——用 so(3) 对数映射。
- 无阻尼伪逆在奇异附近给出巨大关节速度——启用阻尼最小二乘并按 σ_min 分级。
- 腕部奇异(θ5≈0)导致 θ4/θ6 剧烈跳变——给 θ5 加 0.02 rad 偏置绕开。
- 多解选择只用「最近」而不检查碰撞与限位——按限位→距离→碰撞的顺序过滤。
- 从 URDF 手工转 DH,转换脚本无测试,参数悄悄错位——以 URDF 为真相源,转换脚本配一致性断言。
- URDF 的 limit 不读,规划出超出关节范围的目标——规划与控制器都必须读 limit。
- 只测零位附近的位形,奇异与极限位形上线才暴露——单元测试必须覆盖随机位形 1000 组以上。
小结
运动学的工程要点可以归纳为:锁定建模约定、以 URDF 为真相源、正解用库、逆解分级处理、测试覆盖随机位形。数学推导本身不难,难的是让代码与实物、与库、与文档三者一致,而这靠的是自动化的交叉验证而非人工核对。
下一步建议沿着控制链路深入:先读动力学与控制基础 ,看运动学如何进入动力学方程与计算力矩控制;再读机械臂控制与抓取规划 ,看逆解在 MoveIt2 的规划管线中如何被调用与约束;涉及移动平台时,SLAM 与定位建图 会用到另一套基于位姿图而非 DH 的运动学表达。
最后强调一句:任何运动学改动,先跑逆解往返测试。1000 组随机位形的往返验证只需几秒,却能拦住绝大多数符号与参数错误。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。