本文档属于 Robotics Tutorial 项目,作者:Pengfei Guo,达妙科技。采用 CC BY 4.0 协议,转载请注明出处。
第 69 章 Mini-Legged 综合实战——从仿真到实机的四足控制全栈¶
难度: ⭐⭐⭐ | 预计学时: 60-80 小时(3-4 周) | 前置: Ch47-68 全部完成
一句话概要: 本章是足式控制方向的**毕业设计**——你将从零搭建一个完整的四足控制栈,覆盖环境搭建、URDF/MJCF 建模、站立控制、MPC 集成、WBC 集成、步态实现、RL 策略训练、Sim-to-Real 部署,最终在 Unitree Go2 上实现稳定行走。
前置自测¶
📋 答不出 2 题以上 → 先回前置章节复习
- Pinocchio 基础(Ch47): 给定 Go2 的 URDF,如何用 Pinocchio 计算第 i 只脚在世界坐标系下的位置?写出 API 调用序列。
- QP 求解器(Ch50): ProxQP 和 qpOASES 的主要区别是什么?在 WBC 场景下为什么推荐 ProxQP?
- SRB 模型(Ch51): Single Rigid Body 模型的 13 维扩展状态向量包含哪些量?为什么要把常数项 1 放进状态来吸收重力仿射项?
- WBC 公式(Ch53): WBC 的决策变量 \((\ddot{q}, \lambda)\) 的物理含义是什么?为什么不直接把 \(\tau\) 也放进去?
- OCS2 双线程架构(Ch55): MPC_Node 和 MRT_Node 之间用什么通信机制?为什么不用 mutex?
本章目标¶
学完本章,你将能够:
- 独立搭建一个完整的四足机器人仿真与控制开发环境——包括 ROS 2、OCS2、Pinocchio、MuJoCo、Gazebo、IsaacLab
- 从 URDF 到 MJCF 完成机器人模型的转换与验证——理解碰撞几何、惯性参数、执行器建模的工程细节
- 实现从站立到行走的完整控制管线——PD 站立 → MPC 集成 → WBC 集成 → 步态调度
- 使用 IsaacLab + rsl_rl 训练端到端 RL 策略——在单卡 GPU 上完成平地行走训练,并理解训练耗时如何随显卡、环境数和任务版本变化(需要 NVIDIA GPU,推荐 RTX 3070 及以上)
- 完成 Sim-to-Real 部署——通过 Unitree SDK2 将控制策略部署到真实 Go2 上
- 具备系统级调试能力——能诊断和解决 MPC 求解慢、WBC 扭矩爆表、状态估计漂移等常见问题
69.1 项目总览 ⭐¶
动机:为什么需要一个"综合实战"章节?¶
前面 Ch47-68 的二十多章,每章聚焦一个模块——动力学建模、QP 求解、MPC 设计、WBC 实现、步态管理、状态估计......但真实的机器人控制系统,绝不是把这些模块简单拼接就能工作的。模块之间的**接口设计**、时序协调、参数联调,才是工程的核心难点。
打个比方:Ch47-68 教你制造了发动机、变速箱、底盘、轮胎——Ch69 要你把它们装配成一辆能开上路的车。
跨领域类比:Mini-Legged 项目之于前面 22 章的知识点,就像计算机科学中的"操作系统课程大作业"之于数据结构、计算机体系结构、编译原理等课程——单独学每门课时你理解了每个模块,但只有亲手实现一个微型 OS(或微型控制栈),你才真正理解模块之间的接口设计、时序依赖和故障传播。两者的共同教训是:系统的难点不在于任何单个模块,而在于模块间的"间隙"——数据格式不匹配、时钟不同步、错误处理不一致,这些"间隙"只有在集成时才会暴露。
如果不做综合实战会怎样?¶
学完理论但不做综合实战的典型后果:
| 表现 | 根本原因 |
|---|---|
| 能读懂 legged_control 源码,但自己写不出来 | 缺少"从零开始"的经验,不知道第一步该做什么 |
| 调了一周参数,机器人还是摔 | 不理解模块间耦合关系,头痛医头 |
| 仿真里跑通了,真机上完全不行 | 忽略了 sim-to-real gap 的系统性处理 |
| 论文里的方法复现不出来 | 不清楚论文省略了哪些工程细节 |
Mini-Legged 的定位与范围¶
Mini-Legged 不是 legged_control 的简化版抄写——它是你自己的控制栈,用来理解每个设计决策背后的"为什么"。
| 维度 | Mini-Legged(本章) | legged_control | ocs2_legged_robot |
|---|---|---|---|
| 定位 | 教学+研究原型 | 生产级框架 | MPC 参考实现 |
| 代码量 | ~5000-8000 行 C++ | ~15000 行 | ~10000 行 |
| MPC | SRB 凸 QP(简单直接) | OCS2 SQP(工业级) | OCS2 SQP |
| WBC | 单层加权 QP | 分层 HQP | 无(纯 MPC) |
| RL | IsaacLab 训练 + 部署 | 无 | 无 |
| Sim-to-Real | 完整流程 | 部分支持 | 仿真为主 |
本章覆盖的完整开发流程:
开发环境搭建 URDF/MJCF 模型 站立控制器
(69.2) → (69.3) → (69.4)
│
▼
MPC 集成 WBC 集成 步态实现
(69.5) ← (69.6) ← (69.7)
│
▼
RL 策略训练 Sim-to-Real 部署 性能调优
(69.8) → (69.9) → (69.10)
目标硬件与软件平台¶
| 组件 | 推荐选择 | 备选 |
|---|---|---|
| 机器人 | Unitree Go2 EDU | Unitree A1、Anymal-B (仿真) |
| 操作系统 | Ubuntu 24.04 LTS | Ubuntu 22.04 LTS(维护旧工程) |
| ROS 版本 | ROS 2 Jazzy | ROS 2 Humble(旧包兼容路线) |
| 仿真器(快速验证) | MuJoCo 3.x | — |
| 仿真器(ROS 集成) | Gazebo Harmonic (Jazzy) | Gazebo Fortress (Humble) / Gazebo Classic |
| RL 训练 | IsaacLab + rsl_rl | legged_gym (旧版) |
| GPU | NVIDIA RTX 3070+ | RTX 3090/4090(更快训练) |
| 真机 SDK | Unitree SDK2 (CycloneDDS) | unitree_ros2 |
学习路线建议¶
| 阶段 | 时间 | 内容 | 产出 |
|---|---|---|---|
| 第 1 周 | 20h | 69.1-69.4:环境搭建 + 模型 + 站立 | Go2 在 MuJoCo 中稳定站立 |
| 第 2 周 | 20h | 69.5-69.7:MPC + WBC + 步态 | Go2 做 trot 步态行走 |
| 第 3 周 | 20h | 69.8-69.9:RL 训练 + Sim-to-Real | IsaacLab 训练策略 + 真机部署 |
| 第 4 周 | 20h | 69.10 + 综合调试 + 报告 | 完整演示视频 + 技术报告 |
⚠️ 常见陷阱¶
⚠️ 编程陷阱:一上来就写 MPC - 错误做法:跳过站立控制器,直接写 MPC+WBC 全栈 - 现象:MPC 输出看起来合理,但机器人一发关节命令就摔 - 根本原因:没有验证 Pinocchio 模型加载、关节顺序映射、坐标系约定是否正确 - 正确做法:先用最简单的 PD 站立控制器验证整个数据通路,确认 URDF→Pinocchio→关节命令 全链路正确后再加复杂控制器
🧠 思维陷阱:追求完美架构 - 新手想法:"我要先设计一个完美的类层次结构,支持所有未来扩展" - 实际上:过度设计是综合实战项目的头号杀手。Mini-Legged 的目标是**先跑通,再优化** - 正确思维:用最简单的设计让机器人站起来(Day 1 目标),然后逐步重构
练习¶
练习 69.1.1 [必做]:画出你理解的 Mini-Legged 系统架构图。标注每个模块的输入输出、运行频率、所用的核心库。与本节给出的架构图对比,找出差异并解释原因。
练习 69.1.2 [思考]:如果你只有 2 周时间(而不是 4 周),你会砍掉哪些内容?为什么?这个选择反映了你对"最小可行产品"的理解。
69.2 开发环境搭建 ⭐¶
动机:环境搭建为什么值得单独一节?¶
环境搭建是所有新手**最大的时间黑洞**。一个四足控制项目依赖的软件栈极其庞大——ROS/ROS 2、Pinocchio、hpp-fcl/coal、OCS2、MuJoCo、Gazebo、IsaacLab、Unitree SDK2......它们之间的版本兼容性像一张蜘蛛网。花 2 天把环境搭对,胜过花 2 周在错误环境里调 bug。
如果不仔细搭建环境会怎样?¶
真实案例:
- Pinocchio 3.x 的
example_robot_dataAPI 与 2.x 完全不同,2.x 的EXAMPLE_ROBOT_DATA_MODEL_DIR在 3.x 中不存在 - OCS2 官方仓库(leggedrobotics/ocs2)主分支仅支持 ROS 1,ROS 2 需要用
ros2分支或社区移植版legubiao/ocs2_ros2 - hpp-fcl 于 2024 年底完成了到 coal 3.0 的品牌重命名,CMake target 名称变了
- MuJoCo 默认使用 Newton 求解器(早在 1.50 版本即为默认,并非 3.0+ 才改变),仿真行为可能与使用 PGS 的旧教程不一致
69.2.1 基础系统配置 ⭐¶
Step 1: 先选定系统路线
Mini-Legged 项目可以走两条路线。不要把两条路线的命令混在同一个 workspace 中:
| 路线 | 推荐系统 | 适合目标 | 主要栈 |
|---|---|---|---|
| 经典复现路线 | Ubuntu 20.04 + ROS Noetic | 复现 OCS2 main / legged_control 原版 | catkin、Gazebo Classic、robotpkg Pinocchio |
| ROS2 实战路线 | Ubuntu 24.04 + ROS 2 Jazzy | 新项目、ros2_control、现代 Gazebo | colcon、gz_ros2_control、OCS2 ros2 分支或社区移植 |
下面的命令采用 ROS2 实战路线。如果你的目标是复现原版 legged_control,应切换到 Noetic/catkin 路线。
# 确认系统版本
lsb_release -a
# 期望输出: Ubuntu 24.04.x LTS
# 安装基础开发工具
sudo apt update && sudo apt install -y \
build-essential cmake git wget curl \
python3-pip python3-venv \
libeigen3-dev libboost-all-dev \
liburdfdom-dev liboctomap-dev libassimp-dev \
clang-format clang-tidy \
htop tmux
为什么选这些包? 每个都有明确用途:
| 包 | 用途 | 在本项目中的角色 |
|---|---|---|
libeigen3-dev |
Eigen 3.4 线性代数库 | Pinocchio/OCS2/WBC 的矩阵运算基础 |
libboost-all-dev |
Boost C++ 库(1.74+) | OCS2 的序列化、文件系统、测试框架 |
liburdfdom-dev |
URDF 解析库 | Pinocchio 加载 URDF |
liboctomap-dev |
八叉树地图库 | 碰撞检测(hpp-fcl 依赖) |
libassimp-dev |
3D 模型导入库 | URDF 中 mesh 文件的加载 |
Step 2: ROS 2 Jazzy 安装
# 添加 ROS 2 apt 源
sudo apt install software-properties-common
sudo add-apt-repository universe
sudo curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key \
-o /usr/share/keyrings/ros-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] \
http://packages.ros.org/ros2/ubuntu $(. /etc/os-release && echo $UBUNTU_CODENAME) main" \
| sudo tee /etc/apt/sources.list.d/ros2.list > /dev/null
sudo apt update
sudo apt install -y ros-jazzy-desktop ros-jazzy-ros2-control \
ros-jazzy-ros2-controllers ros-jazzy-grid-map \
ros-jazzy-gz-ros2-control \
python3-colcon-common-extensions python3-rosdep
# 初始化 rosdep
sudo rosdep init
rosdep update
# 添加到 bashrc
echo "source /opt/ros/jazzy/setup.bash" >> ~/.bashrc
source ~/.bashrc
⚠️ 编程陷阱:忘记 source setup.bash - 错误做法:安装完 ROS 2 后直接开始编译 - 现象:
colcon build报 "找不到 ament_cmake" 或 "找不到 rclcpp" - 根本原因:ROS 2 的环境变量未加载,CMake 找不到 ROS 2 的包 - 正确做法:每次打开新终端前确认echo $ROS_DISTRO输出jazzy
69.2.2 Pinocchio + hpp-fcl(coal)安装 ⭐⭐¶
Pinocchio 是本项目的**动力学计算引擎**。OCS2 依赖 Pinocchio 进行正/逆动力学、雅可比计算、自碰撞检测。
方案 A:从 ROS 2 apt 安装(推荐初学者)
# 具体版本随 ROS 发行版和 apt 源更新而变化,安装后务必打印确认
sudo apt install -y ros-jazzy-pinocchio
python3 -c "import pinocchio as pin; print(pin.__version__)"
这种方式最简单,但版本由 ROS 包仓库决定。教程中涉及 Pinocchio 2.x/3.x API 差异时,以本机打印出的版本为准。
方案 B:从源码编译(推荐进阶)
如果你需要 Pinocchio 3.x 的新特性(如改进的自动微分支持),需要从源码编译:
# 创建工作空间
mkdir -p ~/mini_legged_ws/src && cd ~/mini_legged_ws/src
# Pinocchio 3.x/4.x 使用 coal;Pinocchio 2.x 才对应旧 hpp-fcl 路线
git clone --recursive https://github.com/coal-library/coal.git -b releases/3.0
# 克隆 Pinocchio
git clone --recursive https://github.com/stack-of-tasks/pinocchio.git -b master
# 编译(注意顺序:先 coal,再 Pinocchio)
cd ~/mini_legged_ws
colcon build --packages-select coal --cmake-args \
-DCMAKE_BUILD_TYPE=Release \
-DBUILD_PYTHON_INTERFACE=ON
source install/setup.bash
colcon build --packages-select pinocchio --cmake-args \
-DCMAKE_BUILD_TYPE=Release \
-DBUILD_WITH_COLLISION_SUPPORT=ON \
-DBUILD_PYTHON_INTERFACE=ON
⚠️ 编程陷阱:Pinocchio 头文件包含顺序错误 - 错误做法:在其他头文件之后
#include <pinocchio/fwd.hpp>- 现象:编译时出现 Boost Variant 大小不一致的奇怪错误 - 根本原因:Pinocchio 需要在 Boost 序列化头文件之前定义某些宏,否则 Boost Variant 的模板参数数量不一致 - 正确做法:永远把#include <pinocchio/fwd.hpp>放在所有 include 的第一行
69.2.3 OCS2 安装(ROS 2 版本) ⭐⭐¶
OCS2 官方主分支仍以 ROS 1/catkin 为主。对于 ROS 2,需要明确使用官方 ros2 分支或社区移植版,并接受 API/依赖随分支变化的成本:
cd ~/mini_legged_ws/src
# 方案 1:legubiao 的 ROS 2 移植(社区路线,先确认目标 ROS 发行版)
git clone --recursive https://github.com/legubiao/ocs2_ros2.git
# 方案 2:官方 ros2 分支(目标环境以仓库 README 为准)
# git clone --recursive https://github.com/leggedrobotics/ocs2.git -b ros2
# 克隆机器人模型资源
git clone https://github.com/leggedrobotics/ocs2_robotic_assets.git
# 安装依赖
cd ~/mini_legged_ws
rosdep install --from-paths src --ignore-src -r -y
# 编译(这一步耗时较长,20-40 分钟)
colcon build --packages-up-to ocs2_legged_robot_ros \
--cmake-args -DCMAKE_BUILD_TYPE=Release
常见编译错误与解决方案:
| 错误信息 | 原因 | 解决方案 |
|---|---|---|
hpp-fcl not found |
CMake 找不到 hpp-fcl | 确保 source install/setup.bash 后再编译 OCS2 |
CppAD/CG 版本不匹配 |
OCS2 依赖特定版本的 CppAD | 使用 OCS2 仓库内自带的 CppAD 子模块 |
Boost serialization 链接错误 |
Boost 版本与 Pinocchio 编译时不一致 | 统一使用系统 Boost,不混用 conda Boost |
HPIPM not found |
QP 求解器未编译 | OCS2 通常自带 HPIPM 子模块,确保 --recursive 克隆 |
69.2.4 MuJoCo 安装 ⭐¶
MuJoCo 是本项目的**快速仿真引擎**——用于控制器的初步验证,比 Gazebo 启动快 10 倍以上。
# MuJoCo 3.x 安装(pip 方式,最简单)
pip3 install mujoco
# 验证安装
python3 -c "import mujoco; print(f'MuJoCo version: {mujoco.__version__}')"
# 期望输出: MuJoCo version: 3.x.x
# 安装 MuJoCo 查看器(可选但推荐)
pip3 install mujoco-viewer
# 克隆 mujoco_menagerie(包含 Go2 模型)
cd ~/mini_legged_ws/src
git clone https://github.com/google-deepmind/mujoco_menagerie.git
为什么用 MuJoCo 而不是只用 Gazebo?
| 特性 | MuJoCo | Gazebo Harmonic |
|---|---|---|
| 启动时间 | <1 秒 | 5-15 秒 |
| 仿真速度 | 10-50x 实时 | 1-3x 实时 |
| 接触模型 | 凸优化(更物理) | 弹簧-阻尼(简化) |
| ROS 集成 | 需要手动桥接 | 原生 gz_ros2_control |
| GPU 加速 | MJX(JAX 后端) | 不支持 |
| 最佳用途 | 快速迭代、RL 训练 | 完整系统集成测试 |
开发流程建议:先在 MuJoCo 中验证控制器算法(秒级启动,快速迭代),再到 Gazebo 中做 ROS 集成测试,最后部署到真机。
69.2.5 IsaacLab 安装(RL 训练) ⭐⭐¶
IsaacLab 是 NVIDIA 的机器人学习框架,提供 GPU 并行仿真和标准化 RL 任务。2026 年前后的 IsaacLab 版本变化很快:3.0 开始支持 kit-less / Newton 后端训练;如果你需要 PhysX、ROS、URDF/MJCF importer 等完整仿真能力,仍应按官方文档安装 Isaac Sim pip 包并用 IsaacLab 的脚本入口启动。
# 前置:按官方文档选择安装路线
# - 完整 PhysX/Isaac Sim 路线:适合可视化、导入 URDF/MJCF、接近常规 Isaac Sim 工作流
# - kit-less/Newton 路线:适合官方支持任务的快速训练
# 参考: https://isaac-sim.github.io/IsaacLab/main/source/setup/installation/
# 克隆 IsaacLab
cd ~
git clone https://github.com/isaac-sim/IsaacLab.git
cd IsaacLab
# 创建并激活环境。也可使用 IsaacLab 自带的 --conda/--uv 辅助命令。
./isaaclab.sh --conda env_isaaclab
conda activate env_isaaclab
# 安装 IsaacLab 扩展和学习框架
# --install 会安装 IsaacLab 扩展;参数 rsl_rl 表示只安装本章需要的学习框架
./isaaclab.sh --install rsl_rl # 等价短选项: ./isaaclab.sh -i rsl_rl
# 验证安装
./isaaclab.sh -p scripts/tutorials/00_sim/create_empty.py
⚠️ 编程陷阱:Isaac Sim 版本与 IsaacLab 版本不匹配 - 错误做法:用旧版 Isaac Sim 配新版 IsaacLab - 现象:import 时报各种 module not found 或 API 变更错误 - 根本原因:IsaacLab 每个大版本绑定特定 Isaac Sim 版本 - 正确做法:严格按照 IsaacLab 官方文档的版本对照表安装
69.2.6 Docker 镜像与 MuJoCo 源码构建 ⭐¶
如果不想从零搭建 legged_control 的全部依赖,可以直接使用预构建的 Docker 镜像。该镜像基于云原生方法在国内服务器上构建,拉取速度有保障:
拉取完成后使用以下命令运行,每个参数都有明确用途:
docker run -it --rm \
-e "DISPLAY=$DISPLAY" \
-v "/tmp/.X11-unix:/tmp/.X11-unix:rw" \
-e NVIDIA_DRIVER_CAPABILITIES=graphics,utility,compute,display \
-v "$HOME/.Xauthority:/root/.Xauthority:rw" \
-v "$(pwd)/outdoor.bag:/tmp/rosbag.bag" \
--gpus all \
-e "QT_X11_NO_MITSHM=1" \
--network=host \
docker.cnb.cool/gpf2025/legged_control:v3
参数详解:
| 参数 | 用途 |
|---|---|
-e "DISPLAY=$DISPLAY" |
将主机 X11 显示变量传入容器,支持 GUI 可视化 |
-v "/tmp/.X11-unix:/tmp/.X11-unix:rw" |
挂载 X11 Unix socket,实现容器内的图形界面转发 |
-v "$HOME/.Xauthority:/root/.Xauthority:rw" |
挂载 X11 认证文件,允许容器访问主机显示服务 |
-v "$(pwd)/outdoor.bag:/tmp/rosbag.bag" |
将本地 rosbag 文件挂载到容器中使用 |
--gpus all |
使用主机全部 GPU(Gazebo 仿真需要 GPU 加速) |
-e "QT_X11_NO_MITSHM=1" |
禁用 MIT-SHM 扩展,避免 Docker 内 Qt 可视化崩溃 |
--network=host |
使用主机网络,方便 ROS 话题通信 |
⚠️ 工程陷阱:Gazebo 仿真需要 GPU 支持
因为 Gazebo 仿真器需要 GPU 支持才可以流畅运行,建议主机配有 NVIDIA 显卡并安装好驱动。如果没有 GPU 或驱动未正确配置,运行 Docker 内的 Gazebo 时会非常卡顿,甚至无法渲染。
MuJoCo 源码构建常见依赖问题
如果需要从源码编译 MuJoCo(例如需要 C++ API 或特定版本),可能遇到以下依赖缺失:
# 源码编译 MuJoCo
git clone https://github.com/google-deepmind/mujoco.git
cd mujoco && mkdir build && cd build
cmake ..
make -j$(nproc)
sudo make install
如果直接拉取最新版本的 MuJoCo,需要使用 C++17 及以上的编译器。如果 Ubuntu 版本较老,建议切换到较早的 MuJoCo 版本。
| 错误信息 | 解决方法 |
|---|---|
Failed to find wayland-scanner |
sudo apt install libwayland-dev |
No package 'xkbcommon' found |
sudo apt install libxkbcommon-x11-dev |
第一个错误来自 GLFW 的 CMakeLists 在构建过程中搜索 Wayland 依赖;第二个错误是因为 GLFW 需要 xkbcommon 库来处理键盘输入映射。两者都是 MuJoCo 渲染窗口的依赖,安装后重新运行 cmake 即可。
69.2.7 环境验证清单 ⭐¶
搭建完所有组件后,逐项验证:
# 1. ROS 2
ros2 topic list # 应能看到 /parameter_events 等默认话题
# 2. Pinocchio
python3 -c "
import pinocchio as pin
print(f'Pinocchio version: {pin.__version__}')
model = pin.buildSampleModelManipulator()
print(f'Sample model: {model.nq} DoF')
"
# 3. OCS2
ros2 pkg list | grep ocs2 # 应能看到 ocs2_core, ocs2_pinocchio 等
# 4. MuJoCo + Go2 模型
python3 -c "
import mujoco
m = mujoco.MjModel.from_xml_path(
'$HOME/mini_legged_ws/src/mujoco_menagerie/unitree_go2/scene.xml')
print(f'Go2 model: nq={m.nq}, nv={m.nv}, nu={m.nu}')
"
# 期望输出: Go2 model: nq=19, nv=18, nu=12
# 5. 编译器版本
g++ --version # 需要 >= 9.0,支持 C++17
⚠️ 常见陷阱¶
💡 概念误区:认为 conda 和 ROS 2 能完美共存 - 新手想法:"我用 conda 管理 Python 环境,同时用 ROS 2" - 实际上:conda 会修改
PATH和LD_LIBRARY_PATH,导致 ROS 2 找到错误版本的 Python 或 Boost。这是**最常见的环境问题** - 正确做法:ROS 2 开发用系统 Python + venv;IsaacLab 用独立的 conda 环境。切换时注意conda deactivate⚠️ 编程陷阱:在 colcon build 时不指定 --cmake-args -DCMAKE_BUILD_TYPE=Release - 错误做法:
colcon build不加任何 cmake 参数 - 现象:Debug 模式下 Pinocchio/OCS2 计算速度慢 5-10 倍,MPC 无法实时 - 根本原因:Debug 模式禁用编译器优化(-O0),Eigen 的表达式模板无法展开 - 正确做法:始终用--cmake-args -DCMAKE_BUILD_TYPE=Release,调试时对单个包切换到RelWithDebInfo
练习¶
练习 69.2.1 [必做]:按本节步骤搭建完整开发环境。截图保存每一步的验证输出。记录遇到的所有问题和解决方案(这就是你的"环境搭建手册")。
练习 69.2.2 [进阶]:编写一个 verify_env.sh 脚本,自动检测所有依赖是否正确安装,版本是否兼容。输出一份表格显示每个组件的状态(PASS/FAIL)。
69.3 URDF 与 MuJoCo 模型 ⭐⭐¶
动机:为什么模型正确性如此重要?¶
控制器的数学公式再漂亮,如果输入的机器人模型有误——关节顺序搞反、惯性张量不对、碰撞体太大——控制器的输出就是垃圾。GIGO(Garbage In, Garbage Out)原则在机器人控制中尤为致命,因为错误的扭矩命令会直接损坏电机。
如果模型不正确会怎样?¶
| 模型错误 | 后果 | 调试难度 |
|---|---|---|
| 关节轴方向搞反(+z 写成 -z) | 机器人"反关节"运动,看起来像癫痫 | 中等(观察关节方向即可) |
| 惯性张量数量级错误 | 动力学补偿完全失效,机器人软塌或弹飞 | 高(数值错误不直观) |
| 碰撞体尺寸过大 | 仿真中腿互相碰撞,站立时被弹开 | 中等(可视化碰撞体) |
| 足端坐标系定义与控制器不一致 | WBC 的足端追踪方向相反 | 极高(看起来"差不多对") |
69.3.1 Go2 URDF 获取与理解 ⭐¶
Unitree Go2 的 URDF 可从多个来源获取:
# 来源 1:mujoco_menagerie(主要提供调校好的 MJCF/XML)
ls ~/mini_legged_ws/src/mujoco_menagerie/unitree_go2/
# 来源 2:Unitree 官方 ROS 2 包或官方模型仓库(URDF)
# https://github.com/unitreerobotics/unitree_ros2
# 来源 3:example-robot-data(Pinocchio 附带)
python3 -c "
import example_robot_data
robot = example_robot_data.load('go2')
print(f'Model path: {robot.model_path}')
print(f'nq={robot.model.nq}, nv={robot.model.nv}')
"
example_robot_data.load('go2') 是否可用取决于你安装的 example-robot-data 版本;如果本机版本没有 Go2 模型,不要为了这一行去修改控制器逻辑。URDF 优先从 Unitree 官方 ROS 2 包或官方模型仓库获取,MJCF/XML 优先使用 mujoco_menagerie/unitree_go2。教学项目的重点是**同一套控制代码始终通过关节名建立映射**,模型来源只是文件路径不同。
Go2 的关节结构(12 个自由度):
Go2 运动链(每条腿 3 个关节):
base_link (浮动基座, 6 DoF)
/ | | \
FL FR RL RR (4 条腿)
│ │ │ │
hip_joint (绕 x 轴,内外展)
│
thigh_joint (绕 y 轴,前后摆)
│
calf_joint (绕 y 轴,膝关节)
│
foot (足端,无驱动)
关节命名约定(Unitree):
FL = Front Left FR = Front Right
RL = Rear Left RR = Rear Right
本章内部控制向量顺序:
q = [FL_hip, FL_thigh, FL_calf,
FR_hip, FR_thigh, FR_calf,
RL_hip, RL_thigh, RL_calf,
RR_hip, RR_thigh, RR_calf] (12 维)
浮动基座(Pinocchio 的约定):
q_float = [px, py, pz, qx, qy, qz, qw] (7 维,四元数)
v_float = [vx, vy, vz, wx, wy, wz] (6 维,速度)
上面的 [FL, FR, RL, RR] 是本章为了讲清楚控制算法而采用的**内部教学顺序**,不是所有软件栈的共同约定。当前 Unitree SDK2 的 Go2 JointIndex 使用 [FR, FL, RR, RL] 顺序:
| 本章内部索引 | Unitree SDK2 索引 | 关节名 |
|---|---|---|
| 0,1,2 | 3,4,5 | FL_Hip, FL_Thigh, FL_Calf |
| 3,4,5 | 0,1,2 | FR_Hip, FR_Thigh, FR_Calf |
| 6,7,8 | 9,10,11 | RL_Hip, RL_Thigh, RL_Calf |
| 9,10,11 | 6,7,8 | RR_Hip, RR_Thigh, RR_Calf |
因此实际工程里不要把 q[0] 直接当作某个硬件电机。更稳妥的做法是:Pinocchio/MuJoCo/IsaacLab/SDK2 各自打印关节名,然后构造 name -> index 映射;控制器内部统一使用一套顺序,进出仿真器或真机 SDK 时做显式转换。
⚠️ 编程陷阱:Pinocchio 的四元数约定与 Eigen 构造函数不同 - Pinocchio 的配置向量中四元数顺序为
[qx, qy, qz, qw](xyz-w) - ROS 的geometry_msgs/Quaternion字段顺序也是[x, y, z, w](一致) - 但 Eigen 的Quaterniond构造函数参数是Quaterniond(qw, qx, qy, qz)(w-xyz) - 这三者的混用是四足控制中最常见的 bug 来源之一
69.3.2 从 URDF 到 MJCF 的转换 ⭐⭐¶
MuJoCo 使用自己的 MJCF 格式,与 URDF 有重要区别:
| 特性 | URDF | MJCF |
|---|---|---|
| 格式 | XML | XML |
| 坐标约定 | 每个 link 有自己的原点 | 相对于父 body |
| 执行器定义 | 无(需要 ros2_control 配置) | 内置 actuator 标签 |
| 接触参数 | 无 | 内置 friction、solref 等 |
| 碰撞检测 | 独立 collision 标签 | geom 标签统一处理 |
转换方法 1:使用 mujoco_menagerie 的现成模型(推荐)
Google DeepMind 维护的 mujoco_menagerie 已经包含了经过精心调校的 Go2 MJCF 模型:
import mujoco
import mujoco.viewer
# 加载 Go2 模型
model = mujoco.MjModel.from_xml_path(
'mujoco_menagerie/unitree_go2/scene.xml'
)
data = mujoco.MjData(model)
# 查看模型信息
print(f"自由度: nq={model.nq}, nv={model.nv}")
print(f"执行器数: nu={model.nu}")
print(f"body 数: nbody={model.nbody}")
# 打印所有关节名
for i in range(model.njnt):
name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_JOINT, i)
print(f" Joint {i}: {name}, type={model.jnt_type[i]}")
# 启动查看器
mujoco.viewer.launch(model, data)
转换方法 2:手动 URDF→MJCF 转换(理解原理)
# MuJoCo 自带的编译工具可以转换 URDF
# 注意:转换结果通常需要手动调整
python3 -c "
import mujoco
model = mujoco.MjModel.from_xml_path('go2.urdf')
mujoco.mj_saveLastXML('go2_converted.xml', model)
"
转换后需要手动调整的关键参数:
<!-- 执行器定义:URDF 没有,需要手动添加 -->
<actuator>
<!-- 按关节填写 ctrlrange;不要把官网峰值扭矩当作所有关节的统一连续限幅 -->
<motor name="FL_hip" joint="FL_hip_joint"
ctrlrange="-TAU_HIP TAU_HIP" gear="1"/>
<motor name="FL_thigh" joint="FL_thigh_joint"
ctrlrange="-TAU_THIGH TAU_THIGH" gear="1"/>
<!-- ... 其余 10 个关节同理 -->
</actuator>
<!-- 接触参数调整 -->
<default>
<geom friction="0.8 0.02 0.01"
solref="0.005 1"
solimp="0.9 0.95 0.001"/>
</default>
Unitree 官方规格页会给出整机或关节电机的峰值能力(Go2 页面常见标注约 45 N.m),但控制器限幅应来自具体模型文件、SDK 参数表和实验安全策略。仿真中建议用 tau_limit[12] 数组逐关节设置,并区分峰值扭矩、连续扭矩和保守上机限幅。
69.3.3 碰撞几何与仿真稳定性 ⭐⭐¶
碰撞几何的选择直接影响仿真质量:
| 碰撞类型 | 计算速度 | 精度 | 适用场景 |
|---|---|---|---|
| 球体(sphere) | 最快 | 低 | RL 大规模训练(MJX) |
| 胶囊体(capsule) | 快 | 中 | 快速仿真验证 |
| 凸包(convex mesh) | 中等 | 高 | 精确仿真 |
| 三角网格(mesh) | 最慢 | 最高 | 渲染(不推荐用于碰撞) |
mujoco_menagerie 的 Go2 模型提供了两个版本:
- scene.xml:使用精确碰撞几何,适合单机仿真
- scene_mjx.xml:使用简化球体碰撞,适合 MJX GPU 加速训练
# 验证碰撞几何
import mujoco
model = mujoco.MjModel.from_xml_path('unitree_go2/scene.xml')
for i in range(model.ngeom):
name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_GEOM, i)
geom_type = model.geom_type[i]
type_names = {0:'plane', 2:'sphere', 3:'capsule', 4:'ellipsoid',
5:'cylinder', 6:'box', 7:'mesh'}
print(f" Geom {name}: type={type_names.get(geom_type, geom_type)}, "
f"size={model.geom_size[i]}")
碰撞几何的工程经验教训
碰撞几何的选择不仅影响仿真质量,更直接影响 MPC 求解器的实时性。以下是在自定义四足机器人上的真实失败案例。
⚠️ 工程陷阱:碰撞体过于复杂导致 OCS2/MPC 求解失败
SolidWorks 导出的 URDF 中,
<collision>和<visual>通常都指向同一个 STL 文件——这是一个常见错误。高精度 STL 网格作为碰撞几何体会导致: - 碰撞检测计算量暴增 - OCS2 的 SQP/DDP 求解器无法在规定时间内完成迭代 - 最终表现为**机器人乱动**(求解器超时返回随机解)正确做法:视觉网格(
<visual>)可以使用高精度 STL,碰撞几何(<collision>)必须简化为基本体素(<box>、<cylinder>、<sphere>、<capsule>)。
以经典的 Go2 机器人为例,其 URDF 中 <visual> 使用精细 mesh 文件渲染外观,而 <collision> 使用简单几何体近似:
<link name="base">
<visual>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="${mesh_path}/trunk.dae" scale="1 1 1"/>
</geometry>
</visual>
<collision>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<!-- 碰撞体使用简单长方体近似,而非原始 mesh -->
<box size="${trunk_length} ${trunk_width} ${trunk_height}"/>
</geometry>
</collision>
</link>
这种分离设计的核心逻辑是:视觉渲染只在可视化时计算一次,而碰撞检测在仿真的每个时间步都要执行。如果碰撞体是数万面的 STL 网格,每一步的碰撞检测计算量都会暴增,直接拖慢整个仿真循环。
⚠️ 工程陷阱:非标准垂直构型的碰撞检测
在标准垂直构型中,膝关节位于髋关节正下方(沿 Z 轴),髋关节位于侧摆关节左右方(沿 Y 轴)。如果自定义机器人的关节坐标系定义不遵循标准垂直构型——例如直接按照真实机器人的关节物理位置定义坐标变换——碰撞检测可能在"本应无碰撞"的位置误报碰撞。这是因为非标准构型下,简单几何体的碰撞包围盒会产生不必要的交叠。
建议:在机械设计之初就定义标准垂直构型下的坐标系。如果已经是非标准构型,需要进行一系列坐标变换和惯量矩阵变换才能构建正确的碰撞模型——这是一个很复杂的工程,远不如从源头避免来得高效。
69.3.4 Pinocchio 模型加载与验证 ⭐⭐¶
在控制器中使用模型之前,必须验证 Pinocchio 加载的模型与 MuJoCo 一致:
// C++ 版本:PinocchioInterface 封装
#include <pinocchio/fwd.hpp> // 必须是第一个 include!
#include <pinocchio/parsers/urdf.hpp>
#include <pinocchio/algorithm/joint-configuration.hpp>
#include <pinocchio/algorithm/frames.hpp>
#include <pinocchio/algorithm/rnea.hpp>
#include <pinocchio/algorithm/kinematics.hpp>
class PinocchioInterface {
public:
PinocchioInterface(const std::string& urdf_path) {
// 加载 URDF(浮动基座)
pinocchio::urdf::buildModel(urdf_path,
pinocchio::JointModelFreeFlyer(), model_);
data_ = pinocchio::Data(model_);
// 验证维度
assert(model_.nq == 19); // 7(浮动基座) + 12(关节)
assert(model_.nv == 18); // 6(浮动基座) + 12(关节)
// 打印关节信息用于验证
for (int i = 0; i < model_.njoints; ++i) {
std::cout << "Joint " << i << ": "
<< model_.names[i] << std::endl;
}
}
// 计算所有足端位置
Eigen::Matrix<double, 3, 4> getFootPositions(
const Eigen::VectorXd& q) {
pinocchio::forwardKinematics(model_, data_, q);
pinocchio::updateFramePlacements(model_, data_);
Eigen::Matrix<double, 3, 4> foot_pos;
for (int i = 0; i < 4; ++i) {
auto frame_id = model_.getFrameId(foot_names_[i]);
foot_pos.col(i) = data_.oMf[frame_id].translation();
}
return foot_pos;
}
Eigen::VectorXd computeGravityCompensation(const Eigen::VectorXd& q) {
// 等价于 RNEA(q, v=0, a=0),返回 18 维广义重力项。
return pinocchio::computeGeneralizedGravity(model_, data_, q);
}
private:
pinocchio::Model model_;
pinocchio::Data data_;
std::array<std::string, 4> foot_names_ = {
"FL_foot", "FR_foot", "RL_foot", "RR_foot"
};
};
交叉验证:Pinocchio vs MuJoCo 的 FK 结果
# Python 验证脚本:两个引擎的前向运动学应一致
import numpy as np
import pinocchio as pin
import mujoco
# Pinocchio FK
pin_model = pin.buildModelFromUrdf("go2.urdf",
pin.JointModelFreeFlyer())
pin_data = pin_model.createData()
q_pin = pin.neutral(pin_model) # 零位姿
pin.forwardKinematics(pin_model, pin_data, q_pin)
pin.updateFramePlacements(pin_model, pin_data)
fl_foot_id = pin_model.getFrameId("FL_foot")
fl_pos_pin = pin_data.oMf[fl_foot_id].translation
# MuJoCo FK
mj_model = mujoco.MjModel.from_xml_path("unitree_go2/scene.xml")
mj_data = mujoco.MjData(mj_model)
mujoco.mj_forward(mj_model, mj_data)
# MuJoCo 查询对象取决于具体 MJCF:
# - mujoco_menagerie 的 scene.xml 常用 foot geom 名称(如 "FL")
# - scene_mjx.xml 或自定义模型可能提供 site 名称(如 "FL_foot")
fl_geom_id = mujoco.mj_name2id(mj_model, mujoco.mjtObj.mjOBJ_GEOM, "FL")
if fl_geom_id >= 0:
fl_pos_mj = mj_data.geom_xpos[fl_geom_id]
else:
fl_site_id = mujoco.mj_name2id(
mj_model, mujoco.mjtObj.mjOBJ_SITE, "FL_foot")
assert fl_site_id >= 0, "找不到 FL 足端 geom/site,请先打印模型名称"
fl_pos_mj = mj_data.site_xpos[fl_site_id]
# 比较
print(f"Pinocchio FL_foot: {fl_pos_pin}")
print(f"MuJoCo FL_foot: {fl_pos_mj}")
print(f"Difference: {np.linalg.norm(fl_pos_pin - fl_pos_mj):.6f} m")
# 差异应 < 1mm,否则说明模型不一致
69.3.5 自定义四足模型集成最小闭环 ⭐⭐¶
前面的 69.3.1-69.3.4 以 Unitree Go2 为例讲解了模型的获取与验证。但在实际研究中,你很可能需要将**自己设计的四足机器人**集成到 legged_control 框架中。这一过程涉及 URDF 转换、配置文件适配、初始姿态设定等一系列工程细节,每一步都有潜在的坑。
本节以一个真实的自定义四足机器人(命名为 dmgo)为例,给出从 CAD 导出到仿真控制的最小闭环流程。
URDF 到 xacro 的分解¶
从 SolidWorks 导出的 URDF 通常是一个单一的大文件,包含所有 link、joint、mesh 引用。直接使用这个文件虽然可行,但难以维护和扩展。参考 Unitree 官方模型的组织方式,应将其拆分为模块化的 xacro 文件:
| 文件 | 职责 | 说明 |
|---|---|---|
robot.xacro |
顶层入口 | 包含所有子 xacro 的 include,定义 base link |
leg.xacro |
腿部运动链 | 定义 hip/thigh/calf/foot 的 link 和 joint |
transmission.xacro |
关节力矩接口 | 为每个关节定义 EffortJointInterface |
gazebo.xacro |
Gazebo 插件 | ros_control 插件 + ground truth 插件 |
imu.xacro |
IMU 传感器 | IMU 的 link、joint 和 Gazebo 仿真参数 |
拆分后的文件通过 xacro 宏机制组合。例如在 leg.xacro 开头引用 transmission:
在文件末尾调用宏,为每条腿创建力矩控制接口:
⚠️ 工程陷阱:xacro 路径格式
使用
package://dmgo_description/meshes/而非$(find dmgo_description)/meshes/。后者在某些场景下(如xacro独立调用时)会因为环境变量缺失导致 "Unable to open file" 错误。典型报错如下:
Transmission:虚拟关节电机¶
没有 transmission,Gazebo 中的四足机器人就无法运动——gazebo_ros_control 通过扫描 URDF 中所有注册了 EffortJointInterface 的关节来创建控制句柄。每个关节需要一个标准的 SimpleTransmission 块:
<transmission name="${prefix}_HAA_tran">
<type>transmission_interface/SimpleTransmission</type>
<joint name="${prefix}_HAA">
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
</joint>
<actuator name="${prefix}_HAA_motor">
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
<mechanicalReduction>1</mechanicalReduction>
</actuator>
</transmission>
这里 mechanicalReduction 设为 1 表示关节力矩和执行器力矩之间是 1:1 映射(无减速比缩放)。
IMU 传感器配置¶
IMU 的 xacro 可以直接从 Unitree 的参考实现中复制,需要注意的关键参数是所连接的坐标系、IMU 名称和位姿偏移:
<xacro:macro name="IMU" params="connected_to imu_name xyz:='0 0 0' rpy:='0 0 0'">
<joint name="${imu_name}_joint" type="fixed">
<origin xyz="${xyz}" rpy="${rpy}"/>
<parent link="${connected_to}"/>
<child link="${imu_name}"/>
</joint>
<link name="${imu_name}">
<inertial>
<mass value="0.01"/>
<origin rpy="0 0 0" xyz="0 0 0"/>
<inertia ixx="0.000001" ixy="0" ixz="0"
iyy="0.000001" iyz="0" izz="0.000001"/>
</inertial>
</link>
<!-- 防止 Gazebo 合并固定关节 -->
<gazebo reference="${imu_name}_joint">
<disableFixedJointLumping>true</disableFixedJointLumping>
</gazebo>
</xacro:macro>
⚠️ 工程陷阱:IMU link 需要质量
URDF 中 IMU 的 link 必须设置非零的
<mass>和<inertia>。缺少质量属性会导致 Gazebo 仿真异常或 link 被自动合并。即使 IMU 本身质量很小(如 0.01 kg),也必须显式声明。
Gazebo 物理参数与插件¶
腿部连杆需要在 leg.xacro 末尾添加 Gazebo 物理属性:
| 属性 | 说明 | 不设置的后果 |
|---|---|---|
mu1 / mu2 |
各向异性库仑摩擦系数 | 脚端"打滑",行为不稳定 |
self_collide |
是否允许 link 间自碰撞 | 腿间可能穿透或过度碰撞 |
disableFixedJointLumping |
禁止 Gazebo 合并固定关节 | 脚端 link 消失,接触检测异常 |
kp / kd |
接触刚度/阻尼 | 脚端下陷或抖动 |
建议对脚端/小腿启用自碰撞(self_collide=1),对大腿可视情况关闭。对 *_foot_fixed 关节必须加 disableFixedJointLumping,确保脚端作为独立接触体存在。
Gazebo 插件方面,需要两个关键插件:
<!-- ros_control 插件:连接 LeggedHWSim 硬件抽象层 -->
<gazebo>
<plugin name="gazebo_ros_control" filename="liblegged_hw_sim.so">
<robotNamespace>/</robotNamespace>
<robotParam>legged_robot_description</robotParam>
<robotSimType>legged_gazebo/LeggedHWSim</robotSimType>
</plugin>
</gazebo>
<!-- Ground truth 插件:提供真实状态(cheater 模式使用) -->
<gazebo>
<plugin name="p3d_base_controller" filename="libgazebo_ros_p3d.so">
<alwaysOn>true</alwaysOn>
<updateRate>1000.0</updateRate>
<bodyName>base</bodyName>
<topicName>ground_truth/state</topicName>
<gaussianNoise>0</gaussianNoise>
<frameName>world</frameName>
</plugin>
</gazebo>
⚠️ 工程陷阱:cheater state 仅限仿真调试
在仿真环境中,
p3d_base_controller(或 Gazebo ground truth 插件)可以直接提供机器人的精确位姿和速度——这被称为 cheater state(作弊状态)。在开发和调试控制算法时使用 cheater state 可以排除状态估计误差的干扰,快速验证控制逻辑。但在**真机部署时必须切换为状态估计器输出**(如 Ch57 中的 ESKF/InEKF)。常见的错误是:仿真中用 cheater state 调通了控制器,直接部署到真机后发现性能急剧下降甚至完全失效——因为状态估计器的噪声、延迟和漂移在仿真中被完全绕过了。
检查清单:部署前确认 launch 文件中
base_state_estimator参数已从p3d切换为kalman/invariant_ekf,且 observation 中不包含任何 ground truth 信号。
URDF 生成与部署¶
legged_control 框架要求机器人 URDF 文件存在于 /tmp/legged_control/<robot_type>.urdf。源代码提供了 generate_urdf.sh 脚本来自动完成这一步。在 launch 文件中集成如下:
<launch>
<arg name="robot_type" default="$(env ROBOT_TYPE)"/>
<!-- 1. 解析 xacro 并挂到 ROS 参数服务器 -->
<param name="legged_robot_description"
command="$(find xacro)/xacro $(find dmgo_description)/urdf/robot.xacro
robot_type:=$(arg robot_type)"/>
<!-- 2. 生成 URDF 到 /tmp/legged_control/ -->
<node name="generate_urdf" pkg="legged_common" type="generate_urdf.sh"
output="screen"
args="$(find dmgo_description)/urdf/robot.xacro $(arg robot_type)"/>
<!-- 3. 加载 IMU/接触传感器配置 -->
<rosparam file="$(find legged_gazebo)/config/default.yaml" command="load"/>
<!-- 4. 启动 Gazebo -->
<include file="$(find gazebo_ros)/launch/empty_world.launch">
<arg name="world_name"
value="$(find legged_gazebo)/worlds/empty_world.world"/>
</include>
<!-- 5. 在 Gazebo 中生成机器人(带初始关节角度) -->
<node name="spawn_urdf" pkg="gazebo_ros" type="spawn_model"
clear_params="true"
args="-z 0.5 -param legged_robot_description -urdf
-model $(arg robot_type)
-J LF_HAA 0 -J LF_HFE -0.35 -J LF_KFE -2.0
-J LH_HAA 0 -J LH_HFE -0.35 -J LH_KFE -2.0
-J RF_HAA 0 -J RF_HFE -0.35 -J RF_KFE -2.0
-J RH_HAA 0 -J RH_HFE -0.35 -J RH_KFE -2.0"
output="screen"/>
</launch>
其中 -J 参数用于设置初始关节角度,使机器人以趴卧姿态生成(而非默认的零位构型)。具体角度值需要根据自己的机器人模型调整。
⚠️ 工程陷阱:default.yaml 关键配置
legged_gazebo/config/default.yaml中的 IMU 名称和接触脚名称**必须与 URDF 中的 link/joint 名称完全匹配**:gazebo: delay: 0.009 imus: base_imu: frame_id: base_imu # 必须与 imu.xacro 中的 imu_name 一致 contacts: [ "LF_FOOT", "LH_FOOT", "RF_FOOT", "RH_FOOT" ] # 必须与 URDF 中足端 link 名一致同时,
disableFixedJointLumping必须正确设置,否则 URDF 解析时固定关节的连杆会被合并,导致碰撞模型和惯性参数错误。
关节名称方面,legged_control 框架内部对关节命名有硬编码假设(如 LF_HAA、LF_HFE、LF_KFE 等)。如果自定义关节名称与代码中的硬编码不一致,控制器将无法正确映射关节,导致力矩输出失败。建议在 SolidWorks 建模阶段就使用标准命名。
69.3.6 姿态与零点概念辨析 ⭐⭐¶
在部署自定义四足机器人时,以下三个概念经常被混淆,导致"机器人乱动/抖动/站不起来"。理清它们是成功部署的前提。
| 概念 | 定义 | 来源 | 修改方式 |
|---|---|---|---|
| 运动学零点 | 模型中 q=0 对应的关节构型 | URDF <joint> 的 <origin> 和 <axis> |
修改 URDF |
| 机械/传感器零点 | 装配或编码器定义的物理零位 | 电机编码器上电位置 | 硬件标定 |
| 控制零点 | 控制器内部 q=0 的基准 | raw encoder -> model q 的 offset 映射 | 配置 theta_offset |
三者之间的转换公式:
运动学零点不是"绝对零点",而是你人为选择的"参考姿态"——通常选一个对称、接近中位、数值稳定的姿态(比如腿略微弯曲的站立姿态)。OCS2/Pinocchio 的运动学计算默认就基于这个零点。
初始姿态 vs 参考姿态
在 legged_control 框架中,还有两个容易混淆的配置:
| 配置 | 所在文件 | 含义 | 典型取值 |
|---|---|---|---|
initialState |
task.info |
MPC 优化器启动时的状态猜测 | 趴卧或站立,取决于启动场景 |
defaultJointState + comHeight |
reference.info |
控制器的"期望站立构型" | 标准站立姿态 |
这两者不是同一件事。initialState 是 MPC 求解器的初始化猜测,如果它与实际机器人姿态差距过大,MPC 求解器可能发散。defaultJointState + comHeight 则是控制器的参考目标,是机器人"想要"达到的姿态。
如果你希望"初始是趴着,目标是站立",就把 initialState 设成趴卧姿态、defaultJointState 设成站立姿态。如果启动时就是站立,两个都设成站立即可。
⚠️ 工程陷阱:零点和初始姿态配置错误
对零点和初始姿态的配置要求很高。一旦配置出现问题就会导致求解出错,从而导致仿真中机器人出现乱动/抖动的情况,无法正常运动。从 SolidWorks 导出的 URDF 中关节原点已经隐含了运动学零点定义,如果在 xacro 中试图修改这个零点,很容易引入不一致。建议直接使用导出的原始 URDF 中的关节状态作为零点,可以保证不会因为零点转换引入额外错误。
⚠️ 常见陷阱¶
💡 概念误区:认为 URDF 和 MJCF 的惯性参数含义完全相同 - URDF 的
<inertial>是相对于 link 原点的 - MJCF 的<inertial>默认是相对于 body 质心的 - 如果直接复制数值而不做坐标变换,动力学计算会有偏差⚠️ 编程陷阱:MuJoCo 和 Pinocchio 的关节顺序不一致 - MuJoCo 的关节顺序取决于 XML 中
<joint>标签的出现顺序 - Pinocchio 的关节顺序取决于 URDF 中<joint>的拓扑排序 - 两者可能不同!必须建立显式的索引映射表
练习¶
练习 69.3.1 [必做]:加载 Go2 的 URDF 到 Pinocchio,打印所有关节名称和限位。画一张表格,列出每个关节的名称、类型、位置范围、速度范围、力矩限制。
练习 69.3.2 [必做]:用 Python 编写交叉验证脚本,在 10 个随机关节配置下比较 Pinocchio 和 MuJoCo 的 FK 结果。所有足端位置差异应小于 1mm。
练习 69.3.3 [进阶]:修改 MJCF 中的 solref 和 solimp 参数,观察仿真中足地接触的弹跳行为变化。写一段文字解释这些参数的物理含义。
69.4 站立控制器 ⭐⭐¶
动机:为什么第一步是站立?¶
站立控制器是整个控制栈的**烟雾测试(smoke test)**——如果机器人连站都站不住,任何上层控制器都没有意义。站立控制器的作用是:
- 验证数据通路:URDF → Pinocchio → 关节命令 → 仿真器 → 观测反馈,整条链路是否正确
- 验证坐标系约定:关节正方向是否与预期一致
- 提供调试基线:后续加 MPC/WBC 时,如果出问题可以退回到站立控制器排查
如果跳过站立控制器会怎样?¶
直接写 MPC+WBC 然后调试,你将面对一个**三重未知**: - 模型是否正确? - MPC 公式是否正确? - WBC 公式是否正确?
三个未知叠加,调试空间是指数级的。先用站立控制器消除第一个未知,再逐步加入 MPC 和 WBC。
本质洞察:站立控制器的价值**不在于它本身的控制性能,而在于它提供了一个"已知正确的基线"**。当你后续加入 MPC 或 WBC 后机器人出了问题,你可以退回到站立控制器确认底层数据通路是否正确——如果站立正常但 MPC 不正常,问题一定在 MPC 层;如果连站立都不正常,问题在模型或硬件接口。这就是软件工程中"二分调试法"在机器人系统中的应用。
69.4.1 重力补偿 + PD 控制 ⭐¶
跨领域类比:重力补偿 + PD 控制之于机器人站立,就像建筑的地基 + 抗震阻尼器之于建筑结构。重力补偿(\(\tau_g\))相当于地基——它承受了"静态载荷"(重力),让结构在无外力时保持平衡;PD 反馈相当于阻尼器——它抵抗"动态扰动"(推一下或踩到不平),把偏差拉回平衡点。没有地基(没有重力补偿)的建筑会塌,因为阻尼器无法承受持续的重力载荷;没有阻尼器(没有 PD)的建筑会在地震中倒,因为地基无法吸收动态能量。两者缺一不可。
最简单的站立控制器:在站立姿态下做重力补偿 + PD 反馈。
物理直觉:机器人在站立姿态下,关节扭矩需要抵消重力引起的力矩。如果我们计算出精确的重力补偿扭矩 \(\tau_g\),再加上 PD 反馈修正偏差,机器人就能稳定站立。
数学公式:
其中: - \(\tau_g(q)\) = 重力补偿扭矩,由 Pinocchio 的逆动力学在零加速度下计算得到 - \(K_p\) = 位置增益矩阵(对角阵) - \(K_d\) = 速度增益矩阵(对角阵) - \(q_{\text{des}}\) = 站立目标关节角度 - \(\dot{q}_{\text{des}} = 0\)(站立时目标速度为零)
为什么需要重力补偿? 如果只用 PD 而不加 \(\tau_g\),PD 控制器必须用位置误差来"间接"产生抵消重力的扭矩——这意味着机器人永远无法达到零误差的站立姿态,会有一个**静差(steady-state error)**。加入重力补偿后,PD 只需要处理小扰动,静差消失。
// 正确实现:重力补偿 + PD
class StandController {
public:
StandController(const PinocchioInterface& pin_interface)
: pin_(pin_interface) {
// Go2 的站立目标关节角度(弧度)
// [hip, thigh, calf] x 4 条腿
q_des_.resize(12);
q_des_ << 0.0, 0.8, -1.6, // FL
0.0, 0.8, -1.6, // FR
0.0, 1.0, -1.6, // RL
0.0, 1.0, -1.6; // RR
// PD 增益与逐关节限幅
kp_ = Eigen::VectorXd::Constant(12, 50.0); // Nm/rad
kd_ = Eigen::VectorXd::Constant(12, 3.0); // Nm*s/rad
tau_limit_ = Eigen::VectorXd::Constant(12, 25.0); // 示例保守值,实机需按关节标定
}
Eigen::VectorXd compute(const Eigen::VectorXd& q_full,
const Eigen::VectorXd& v_full) {
// 1. 计算重力补偿
Eigen::VectorXd tau_gravity = pin_.computeGravityCompensation(q_full);
// 只取关节部分(跳过浮动基座的 6 维)
Eigen::VectorXd tau_g = tau_gravity.tail(12);
// 2. 提取当前关节状态
Eigen::VectorXd q_joint = q_full.tail(12);
Eigen::VectorXd v_joint = v_full.tail(12);
// 3. PD 反馈
Eigen::VectorXd tau_pd =
kp_.cwiseProduct(q_des_ - q_joint) +
kd_.cwiseProduct(-v_joint);
// 4. 总扭矩 = 重力补偿 + PD
Eigen::VectorXd tau = tau_g + tau_pd;
// 5. 扭矩限幅:逐关节、逐平台配置,不使用单一 magic number
tau = tau.cwiseMax(-tau_limit_).cwiseMin(tau_limit_);
return tau;
}
private:
const PinocchioInterface& pin_;
Eigen::VectorXd q_des_, kp_, kd_, tau_limit_;
};
对比:两种错误实现
// 错误 1:忘记重力补偿
Eigen::VectorXd tau_wrong = kp_.cwiseProduct(q_des_ - q_joint)
+ kd_.cwiseProduct(-v_joint);
// 问题:机器人会"蹲下"(因为 PD 无法完全抵消重力)
// 且在不同 q 下表现不一致(重力力矩随姿态变化)
// 错误 2:重力补偿但不限幅
Eigen::VectorXd tau_dangerous = tau_g + tau_pd; // 没有 clamp!
// 问题:瞬态扰动可能产生远超电机能力的扭矩命令
// 在真机上会触发过流保护甚至烧电机
69.4.2 在 MuJoCo 中验证站立 ⭐⭐¶
import mujoco
import mujoco.viewer
import numpy as np
# 加载模型
model = mujoco.MjModel.from_xml_path(
'mujoco_menagerie/unitree_go2/scene.xml')
data = mujoco.MjData(model)
# 站立目标角度
q_des = np.array([
0.0, 0.8, -1.6, # FL
0.0, 0.8, -1.6, # FR
0.0, 1.0, -1.6, # RL
0.0, 1.0, -1.6 # RR
])
# PD 增益
kp = np.full(12, 50.0)
kd = np.full(12, 3.0)
def controller(model, data):
"""MuJoCo 控制回调"""
# 获取当前关节位置和速度
q = data.qpos[7:] # 跳过浮动基座(7维: pos + quat)
dq = data.qvel[6:] # 跳过浮动基座(6维: lin_vel + ang_vel)
# MuJoCo 会仿真重力;这里的纯 PD 不是“自带重力补偿”,
# 而是依靠站立姿态的稳态误差产生支撑扭矩。
# 加入 Pinocchio 重力补偿后,静差会明显减小。
tau = kp * (q_des - q) + kd * (0 - dq)
# 逐关节限幅:示例保守值,实机请用 SDK/URDF/MJCF 参数表覆盖
tau_limit = np.full(12, 25.0)
tau = np.clip(tau, -tau_limit, tau_limit)
# 设置控制信号
data.ctrl[:] = tau
# 启动仿真
with mujoco.viewer.launch_passive(model, data) as viewer:
while viewer.is_running():
controller(model, data)
mujoco.mj_step(model, data)
viewer.sync()
调参指导:
| 参数 | 过小的表现 | 过大的表现 | 推荐范围(Go2) |
|---|---|---|---|
| \(K_p\) | 机器人软塌,缓慢下沉 | 高频振动("抖动") | 30-80 Nm/rad |
| \(K_d\) | 欠阻尼,超调大 | 过阻尼,响应迟钝 | 1-5 Nm*s/rad |
调参原则:先设 \(K_d = 0\),逐步增大 \(K_p\) 直到机器人能站住;然后逐步增大 \(K_d\) 直到振动消除。这就是经典的 Ziegler-Nichols 调参思路。
⚠️ 常见陷阱¶
⚠️ 编程陷阱:MuJoCo 的 qpos 和 qvel 维度不同 -
data.qpos是 19 维(7 浮动基座 + 12 关节),其中浮动基座用四元数(4 维) -data.qvel是 18 维(6 浮动基座 + 12 关节),其中浮动基座用角速度(3 维) - qpos[7:] 和 qvel[6:] 才是关节部分,索引起点不同!💡 概念误区:认为 PD 增益越大越好 - 新手想法:"\(K_p\) 开大一点,控制更精确" - 实际上:\(K_p\) 过大会导致系统带宽超过执行器和仿真器的能力,产生高频振荡 - 物理原因:实际电机有力矩带宽限制(Go2 的电机带宽约 30-50 Hz),PD 增益隐含的等效带宽 \(\omega_n = \sqrt{K_p/J}\) 不能超过电机带宽
练习¶
练习 69.4.1 [必做]:在 MuJoCo 中实现 Go2 的站立控制器。目标:站立 60 秒不摔,基座高度波动 < 5mm。录屏保存。
练习 69.4.2 [必做]:给站立的 Go2 施加外力扰动(在 MuJoCo viewer 中 Ctrl+click 拖拽)。记录不同 \(K_p\)/\(K_d\) 下的恢复时间。画出"\(K_p\) vs 恢复时间"的曲线。
练习 69.4.3 [进阶]:实现带重力补偿的站立控制器(用 Pinocchio 的 computeGeneralizedGravity),与纯 PD 控制器对比静差。
69.5 MPC 集成 ⭐⭐¶
动机:站立之后为什么需要 MPC?¶
站立控制器只能让机器人保持固定姿态——它无法**预测**未来的运动需求。当你想让机器人行走时,需要一个能规划未来 0.5-1 秒质心轨迹和接触力的控制器。这就是 MPC(Model Predictive Control)的角色。
回顾 Ch55 的内容:OCS2 的 MPC 使用 SQP 求解非线性 OCP,计算量大但通用性强。对于 Mini-Legged 项目,我们选择更简单的方案——SRB(Single Rigid Body)凸 MPC,其核心思想来自 MIT Mini Cheetah 项目(Di Carlo et al., 2018)。
为什么选 SRB 凸 MPC 而不是 OCS2 的 SQP?¶
| 特性 | SRB 凸 MPC | OCS2 SQP MPC |
|---|---|---|
| 模型复杂度 | 13 维状态,线性化 | 24+ 维状态,非线性 |
| 求解时间 | <2 ms(QP) | 5-20 ms(SQP 迭代) |
| 实现难度 | 中等(~500 行 C++) | 高(依赖 OCS2 框架) |
| 精度 | 平地足够,粗糙地形受限 | 高精度,适合复杂任务 |
| 教学价值 | 能手推全部数学 | 需要理解 OCS2 抽象层 |
设计决策:Mini-Legged 先用 SRB 凸 MPC 跑通全栈,后续可替换为 OCS2 SQP。
69.5.1 SRB 模型复习与实现 ⭐⭐¶
SRB 模型的核心假设:把整个机器人视为一个刚体,忽略腿的惯性。这个假设在 Go2 这类小型四足上相当准确(腿质量 < 总质量的 15%)。
状态向量(13 维):
为什么把常数 1 放进状态? 这是一个工程技巧——把重力这种常量加速度写进线性系统矩阵中,使动力学方程保持仿射项被吸收到状态里的形式:
其中 \(\mathbf{u} = [\lambda_1^T, \lambda_2^T, \lambda_3^T, \lambda_4^T]^T \in \mathbb{R}^{12}\) 是四只脚的接触力。
连续时间动力学矩阵:
其中 \(r_i = p_{\text{foot},i} - p_{\text{com}}\) 是足端相对于质心的向量,\([r]_\times\) 是反对称矩阵,\(R_z\) 是 yaw 旋转矩阵(线性化近似),\(\mathbf{e}_g = [0, 0, -9.81]^T\)。最后一维状态恒为 1,所以 \(\dot{v}\) 方程中的最后一列直接贡献重力加速度;不要再把最后一维写成 \(g=-9.81\),否则会把重力量级重复乘进去。
离散化(零阶保持):
这是一阶近似,对于 \(dt = 20\text{ms}\) 的 MPC 时步足够精确。
// C++ 风格算法骨架:展示 SRB MPC 的数据流。
// buildCostMatrix/buildCostVector/buildFrictionConstraints 需要在练习中补全。
class ConvexMpc {
public:
static constexpr int kStateDim = 13;
static constexpr int kInputDim = 12; // 4 feet x 3D force
static constexpr int kHorizon = 10;
static constexpr double kDt = 0.02; // 20ms per step
struct MpcResult {
Eigen::Matrix<double, 12, 1> forces; // 当前时步的接触力
Eigen::Matrix<double, 13, 1> predicted_state; // 下一步预测
};
MpcResult solve(
const Eigen::Matrix<double, 13, 1>& x0,
const Eigen::Matrix<double, 13, Eigen::Dynamic>& x_ref,
const Eigen::Matrix<double, 3, 4>& foot_positions,
const Eigen::Matrix<int, 4, Eigen::Dynamic>& contact_schedule
) {
// 1. 构建 QP: min 0.5 z^T H z + g^T z
// s.t. A_eq z = b_eq, A_ineq z <= b_ineq
int n_dec = kInputDim * kHorizon; // 120 决策变量
// 2. 构建代价矩阵
Eigen::MatrixXd H = buildCostMatrix(x_ref);
Eigen::VectorXd g_vec = buildCostVector(x0, x_ref);
// 3. 构建约束(摩擦锥 + 接触模式)
auto [A_ineq, b_ineq] = buildFrictionConstraints(
contact_schedule, 0.6); // mu = 0.6
// 4. 调用 ProxQP 求解
proxsuite::proxqp::dense::QP<double> qp(n_dec, 0,
A_ineq.rows());
qp.init(H, g_vec, std::nullopt, std::nullopt,
A_ineq, b_ineq);
qp.solve();
MpcResult result;
result.forces = qp.results.x.head(kInputDim);
result.predicted_state = x0; // 教学占位;完整实现应由 Ad/Bd 滚动预测得到。
return result;
}
};
69.5.2 OCS2 legged_robot 配置(进阶路径) ⭐⭐⭐¶
如果你选择使用 OCS2 而非自写凸 MPC,需要配置以下文件:
task.info(OCS2 的核心配置文件):
; OCS2 legged_robot task.info 关键参数
; 模型选择
model_settings
{
; "CentroidalModel" 或 "KinoCentroidalModel"
modelType = CentroidalModel
; 步态分辨率
phaseTransitionStanceTime = 0.1
}
; SQP 求解器参数
sqp
{
nThreads = 2
dt = 0.015 ; 每步 15ms
sqpIteration = 1 ; RTI 模式:只做 1 次 SQP 迭代
deltaTol = 1e-3
costTol = 1e-4
}
; MPC 参数
mpc
{
timeHorizon = 1.0 ; 预测 1 秒
solutionTimeWindow = 0.2
coldStart = false
}
; 代价函数权重
; Q 矩阵(状态跟踪):位姿精度 vs 速度精度的权衡
; R 矩阵(输入正则):防止接触力过大
frame_declaration.info(告诉 OCS2 机器人的拓扑):
; 为 Go2 配置的 frame declaration
contactNames3DoF
{
[0] FL_foot
[1] FR_foot
[2] RL_foot
[3] RR_foot
}
; 足端名称必须与 URDF 中的 frame 名称完全匹配!
⚠️ 编程陷阱:frame_declaration 中的名称与 URDF 不匹配 - 错误做法:OCS2 配置文件写
FL_FOOT(大写),URDF 里是FL_foot(小写) - 现象:OCS2 启动时不报错,但接触力全为零,机器人直接摔 - 根本原因:OCS2 按名称查找 Pinocchio 的 frame,名称不匹配就找不到接触点 - 正确做法:用grep在 URDF 中搜索确切的 frame 名称,复制粘贴到配置文件
69.5.3 MPC 与 WBC 的接口设计 ⭐⭐¶
MPC 和 WBC 运行在不同频率,需要明确定义它们之间传递的数据:
MPC (50 Hz) WBC (1 kHz)
| |
| 输出: MpcSolution | 输入: WbcCommand
| - x_ref[k..k+N] (状态参考轨迹) | - com_acc_des (质心加速度)
| - u_ref[k..k+N] (接触力参考) | - omega_dot_des (角加速度)
| - contact_schedule (接触时序) | - foot_pos_des (足端位置)
| | - foot_vel_des (足端速度)
|---- 通过线程安全缓冲区传递 ------->| - contact_flags (接触状态)
| | - f_ref (接触力参考)
| |
| | 输出:
| | - tau (12 维关节扭矩)
线程安全通信:MPC 在独立线程运行,WBC 在实时线程运行。两者之间用无锁的 latest-value buffer 或严格三重缓冲通信,避免互斥锁导致的实时性问题(回顾 Ch55 的双线程架构)。
// SPSC latest-value buffer:一个 MPC 写线程,一个 WBC 读线程。
// read() 在收到第一帧前返回 false,避免使用未初始化数据。
template <typename T>
class LatestBuffer {
public:
void write(T data) {
const uint8_t next = 1 - write_index_.load(std::memory_order_relaxed);
slots_[next] = std::move(data);
write_index_.store(next, std::memory_order_release);
has_value_.store(true, std::memory_order_release);
}
bool read(T& out) const {
if (!has_value_.load(std::memory_order_acquire)) {
return false;
}
const uint8_t index = write_index_.load(std::memory_order_acquire);
out = slots_[index];
return true;
}
private:
std::array<T, 2> slots_{};
std::atomic<uint8_t> write_index_{0};
std::atomic<bool> has_value_{false};
};
严格的三重缓冲也可以实现“写槽、读槽、待交换槽”三者分离,但代码必须保证初始读槽已经写入过、交换后不会把空指针交给写端。本章这里用双槽 latest-value buffer 表达同一个工程意图:WBC 只需要读到 MPC 的**最新完整解**,不需要消费每一帧历史消息。
⚠️ 常见陷阱¶
💡 概念误区:认为 MPC 频率越高越好 - 新手想法:"MPC 20 Hz 太慢了,提高到 200 Hz 不是更好?" - 实际上:MPC 频率受限于求解时间。SRB 凸 MPC 求解 <2ms,理论上可以 500 Hz。但 OCS2 的 SQP 求解需要 5-20ms,所以只能 50-100 Hz。关键是 MPC 和 WBC 之间的插值质量,不是 MPC 频率本身 - 正确理解:MPC 输出的是一条**轨迹**(不是单个点),WBC 在两次 MPC 更新之间沿轨迹插值,所以即使 MPC 只有 30 Hz,WBC 仍然可以在 1 kHz 下平滑执行
⚠️ 编程陷阱:SRB 模型的惯性张量用了体坐标系下的值而非世界坐标系 - Di Carlo 2018 的原始公式中,\(I_G\) 是在**世界坐标系下**表示的惯性张量 - 即 \(I_G^{\text{world}} = R \cdot I_G^{\text{body}} \cdot R^T\) - 但 SRB 模型的近似是:**在名义姿态下**用 \(I_G^{\text{body}}\) 近似 \(I_G^{\text{world}}\)(假设姿态变化小) - 这个近似在 pitch/roll < 15 度时足够好,但大倾斜时会引入误差
练习¶
练习 69.5.1 [必做]:实现 SRB 凸 MPC(不用 OCS2),在站立状态下验证 MPC 输出的接触力是否合理(每只脚约承担 1/4 体重)。
练习 69.5.2 [进阶]:给 MPC 输入一个前进速度命令(如 \(v_x = 0.3\) m/s),观察 MPC 规划的质心轨迹和接触力变化。绘制 MPC 输出的力随时间变化曲线。
练习 69.5.3 [思考]:SRB 模型忽略了腿的惯性。对于 Go2(总质量约 15 kg,单腿约 1.5 kg),估算这个近似引入的误差大约是多少百分比。
69.6 WBC 集成 ⭐⭐¶
动机:MPC 输出了接触力参考,为什么还需要 WBC?¶
MPC 使用简化模型(SRB),输出的是**质心级**的接触力和轨迹参考。但电机需要的是**关节扭矩**。WBC(Whole-Body Controller)的任务是:把质心级指令转换为关节级命令,同时满足全身动力学约束、摩擦锥约束和关节限位。
用一个类比:MPC 是"战略规划部"(决定大方向),WBC 是"执行部门"(把战略落实为具体行动)。
69.6.1 WBC 的 QP 公式 ⭐⭐¶
回顾 Ch53 的 WBC 框架,Mini-Legged 采用**单层加权 QP**(比 legged_control 的分层 HQP 更简单):
决策变量:\(\mathbf{z} = [\ddot{q}^T, \lambda^T]^T\)
- \(\ddot{q} \in \mathbb{R}^{18}\):广义加速度(6 浮动基座 + 12 关节)
- \(\lambda \in \mathbb{R}^{12}\):接触力(4 脚 x 3D)
硬约束(等式):
- 浮动基座动力学(前 6 行):
- 接触脚零加速度:
硬约束(不等式):
- 摩擦锥(线性化为 4 面锥):
软目标(加权代价):
反推关节扭矩:
其中 \(S = [0_{12 \times 6}, I_{12}]\) 是选择矩阵,只取广义力的后 12 维关节部分。这里不能写成 \((S^T)^{-1}\):\(S^T \in \mathbb{R}^{18\times12}\) 不是方阵,也不存在普通逆矩阵。浮动基座前 6 行用于欠驱动动力学约束,后 12 行才对应执行器扭矩。
// C++ 风格算法骨架:展示 WBC 的求解顺序。
// WbcCommand、assembleCost 和各类约束组装函数需按 Ch53 的矩阵定义实现。
class WholeBodyController {
public:
Eigen::VectorXd solve(
const WbcCommand& cmd,
const Eigen::VectorXd& q,
const Eigen::VectorXd& v,
const std::array<bool, 4>& contact_flags
) {
// 1. 更新 Pinocchio 数据
pin_.computeDynamics(q, v); // M, h, J_c, dJ_c
// 2. 确定接触脚数量
int n_contact = std::count(contact_flags.begin(),
contact_flags.end(), true);
int n_lambda = 3 * n_contact;
int n_dec = 18 + n_lambda;
// 3. 组装 QP
// 代价: H z + g
Eigen::MatrixXd H = Eigen::MatrixXd::Zero(n_dec, n_dec);
Eigen::VectorXd g_vec = Eigen::VectorXd::Zero(n_dec);
assembleCost(H, g_vec, cmd);
// 等式约束: A_eq z = b_eq
// - 浮动基座动力学(6行)
// - 接触脚零加速度(3*n_contact行)
auto [A_eq, b_eq] = assembleEqualityConstraints(
contact_flags);
// 不等式约束: A_ineq z <= b_ineq
// - 摩擦锥
auto [A_ineq, b_ineq] = assembleFrictionConstraints(
contact_flags, 0.6);
// 4. 求解 QP(ProxQP)
proxsuite::proxqp::dense::QP<double> qp(
n_dec, A_eq.rows(), A_ineq.rows());
qp.init(H, g_vec, A_eq, b_eq, A_ineq, b_ineq);
qp.solve();
// 5. 反推扭矩
Eigen::VectorXd qdd = qp.results.x.head(18);
Eigen::VectorXd lambda = qp.results.x.tail(n_lambda);
Eigen::VectorXd tau_full = pin_.M() * qdd + pin_.h()
- pin_.Jc(contact_flags).transpose() * lambda;
return tau_full.tail(12); // 只返回关节扭矩
}
};
69.6.2 WBC 实时性保证 ⭐⭐¶
WBC 运行在 1 kHz 实时循环中,每次求解必须在 1ms 内完成。关键优化:
- 预分配内存:使用
EIGEN_RUNTIME_NO_MALLOC检测动态分配(Ch53 讲过) - Warm starting:用上一时步的 QP 解作为初始猜测
- 避免 Eigen 动态大小矩阵的临时对象
// 正确:预分配 + 固定大小
Eigen::Matrix<double, 18, 18> M_; // 质量矩阵(固定大小)
Eigen::Matrix<double, 18, 1> h_; // 偏置力(固定大小)
// 错误:每次求解都创建动态矩阵
Eigen::MatrixXd M = pin_.crba(q); // 每次 new 一个堆对象
// 在 1kHz 循环中,这会导致 ~1000 次/秒的 malloc/free
⚠️ 常见陷阱¶
⚠️ 编程陷阱:WBC 的动力学约束写错维度 - 浮动基座动力学只取**前 6 行**(对应 6 维浮动基座的欠驱动方程) - 如果错误地用了全 18 行,约束过多,QP 可能无解 - 正确理解:前 6 行对应"基座被动受力",后 12 行对应"关节主动驱动"
💡 概念误区:认为 WBC 的摩擦锥约束是"硬物理限制" - 实际上:摩擦系数 \(\mu\) 在仿真中是已知参数,但在真机上是估计值 - 设 \(\mu = 0.6\) 但实际地面可能是 0.3(冰面),WBC 会输出超过真实摩擦锥的力 - 工程做法:在 WBC 中设 \(\mu\) 略低于真实值(保守估计),留安全裕度
练习¶
练习 69.6.1 [必做]:实现 WBC 并与 MPC 串联。在 MuJoCo 中验证:站立时 WBC 输出的扭矩是否与纯 PD + 重力补偿接近。
练习 69.6.2 [进阶]:测量 WBC 的 QP 求解时间。画出"接触脚数量 vs 求解时间"的曲线(0 脚 / 2 脚 / 4 脚)。
练习 69.6.3 [思考]:为什么 legged_control 用分层 HQP 而 Mini-Legged 用加权 QP?在什么情况下加权 QP 会出问题?(提示:想想当两个任务权重相近时...)
69.7 步态实现 ⭐⭐¶
动机:站立到行走的关键一步¶
站立时,四只脚始终着地。行走时,必须有脚抬起(swing phase)和落下(stance phase)的循环——这就是**步态(gait)**。步态调度决定了"哪只脚在什么时候抬起/落下",是从站立到行走的核心跨越。
69.7.1 基本步态类型 ⭐¶
| 步态 | 对角支撑 | 特征 | 速度范围 | 稳定性 |
|---|---|---|---|---|
| Walk | FL-RR → FR-RL | 每次只抬一只脚,3 脚支撑 | 0-0.5 m/s | 高(静态稳定) |
| Trot | FL+RR → FR+RL | 对角两脚同时抬,2 脚支撑 | 0.3-1.5 m/s | 中(动态稳定) |
| Pace | FL+RL → FR+RR | 同侧两脚同时抬 | 0.5-1.0 m/s | 低(横向不稳) |
| Bound | FL+FR → RL+RR | 前两脚和后两脚交替 | 1.0-3.0 m/s | 低(纵向冲击大) |
| Gallop | 非对称 | 四脚依次着地 | 2.0-5.0 m/s | 最低 |
Mini-Legged 先实现 Trot,因为它是工程中最常用的步态: - 两脚支撑提供足够稳定性 - 对角腿交替,天然抵消 yaw 力矩 - 速度范围覆盖大多数室内应用
69.7.2 步态调度器实现 ⭐⭐¶
步态调度器用一个**相位变量** \(\phi \in [0, 1)\) 来描述步态周期中的位置:
class GaitSchedule {
public:
struct GaitParams {
double period; // 步态周期(秒)
double duty_factor; // 支撑相占比(0-1)
Eigen::Vector4d offsets; // 四腿相位偏移
};
GaitSchedule() : params_(trot()) {}
explicit GaitSchedule(GaitParams params)
: params_(std::move(params)) {}
// 预定义步态
static GaitParams trot() {
// 腿顺序采用本章内部 [FL, FR, RL, RR]。
// FL 和 RR 同相,FR 和 RL 同相(对角支撑)。
return GaitParams{
0.4, // 400ms 一个周期
0.5, // 50% 支撑,50% 摆动
Eigen::Vector4d(0.0, 0.5, 0.5, 0.0)
};
}
static GaitParams walk() {
return GaitParams{
0.8,
0.75, // 75% 支撑(3 脚时间更长)
Eigen::Vector4d(0.0, 0.5, 0.25, 0.75)
};
}
// 计算当前时刻每条腿的接触状态
std::array<bool, 4> getContactFlags(double time) const {
std::array<bool, 4> flags;
for (int i = 0; i < 4; ++i) {
double phase = std::fmod(
time / params_.period + params_.offsets[i], 1.0);
flags[i] = (phase < params_.duty_factor); // 支撑相
}
return flags;
}
// 计算摆动腿的归一化相位(0=刚抬起, 1=即将落下)
double getSwingPhase(int leg_id, double time) const {
double phase = std::fmod(
time / params_.period + params_.offsets[leg_id], 1.0);
if (phase >= params_.duty_factor) {
return (phase - params_.duty_factor) /
(1.0 - params_.duty_factor);
}
return -1.0; // 不在摆动相
}
private:
GaitParams params_;
};
69.7.3 摆动腿轨迹规划 ⭐⭐¶
摆动腿需要从当前位置移动到目标落脚点。轨迹规划需要: 1. 抬脚到目标高度(避免磕碰地面) 2. 前移到目标位置 3. 平稳落地(垂直速度小)
Raibert 启发式落脚点选取(Raibert, 1986):
其中: - \(p_{\text{hip}}\):髋关节在地面的投影 - \(T_{\text{stance}}\):支撑相时长 - \(v_{\text{body}}\):当前体速度 - \(v_{\text{cmd}}\):命令速度 - \(K_v\):速度反馈增益(典型值 0.03-0.1)
三次样条摆动轨迹:
def swing_trajectory(t_phase, start_pos, target_pos, swing_height=0.06):
"""
抛物线抬脚的摆动轨迹示例
t_phase: 0 -> 1 (摆动相归一化时间)
"""
# 水平方向:线性插值
xy = (1 - t_phase) * start_pos[:2] + t_phase * target_pos[:2]
# 垂直方向:先保证端点正确,再叠加中间抬脚高度。
z_base = (1 - t_phase) * start_pos[2] + t_phase * target_pos[2]
z = z_base + swing_height * 4 * t_phase * (1 - t_phase)
# 4*t*(1-t) 在 t=0 和 t=1 为 0,在 t=0.5 达到 1
return np.array([xy[0], xy[1], z])
69.7.4 步态切换逻辑 ⭐⭐¶
从站立切换到 trot 不能瞬间完成——需要平滑过渡:
工程实现:
enum class GaitState { STAND, TRANSITION, TROT, WALK };
class GaitStateMachine {
public:
void requestGait(GaitState target) {
if (current_ == target) return;
if (current_ == GaitState::STAND && target == GaitState::TROT) {
// 站立->Trot:先降重心,再开始步态
transition_timer_ = 0.5; // 0.5秒过渡
current_ = GaitState::TRANSITION;
next_ = target;
}
}
void update(double dt) {
if (current_ == GaitState::TRANSITION) {
transition_timer_ -= dt;
if (transition_timer_ <= 0) {
current_ = next_;
}
}
}
private:
GaitState current_ = GaitState::STAND;
GaitState next_ = GaitState::STAND;
double transition_timer_ = 0.0;
};
⚠️ 常见陷阱¶
⚠️ 编程陷阱:摆动腿落地瞬间的冲击 - 问题:脚从空中高速落地,产生巨大冲击力,导致机器人弹起或不稳 - 根本原因:轨迹规划没有约束落地时的垂直速度 - 正确做法:在摆动相最后 10% 阶段,将垂直速度逐渐降为零(软着陆) - 进阶做法:落地前 2-3ms 切换到低增益 PD("预接触缓冲"),吸收冲击
💡 概念误区:认为步态周期是固定不变的 - 实际上:速度越快,步态周期越短(频率越高)。Go2 在 0.3 m/s 时 trot 周期约 0.5s,在 1.5 m/s 时约 0.3s - 更高级的实现会根据速度自适应调整步态参数
练习¶
练习 69.7.1 [必做]:实现 trot 步态调度器 + 摆动腿三次样条轨迹。在 MuJoCo 中让 Go2 做 trot 行走,前进速度 0.3 m/s,录屏保存。
练习 69.7.2 [进阶]:实现 walk 步态,与 trot 对比在 0.2 m/s 下的稳定性(测量 roll/pitch 的最大偏差)。
练习 69.7.3 [思考]:为什么 bound 步态在小型四足上很少使用?从能量效率和硬件限制两个角度分析。
69.8 RL 策略训练 ⭐⭐⭐¶
动机:MPC+WBC 之后,为什么还需要 RL?¶
MPC+WBC 在平地上表现良好,但面对以下挑战时力不从心:
| 挑战 | MPC+WBC 的困难 | RL 的优势 |
|---|---|---|
| 复杂地形(楼梯、碎石) | 需要精确的地形模型 | 隐式学习地形适应 |
| 未知扰动(被踢、负载变化) | 依赖线性化模型的鲁棒性 | 训练时大量随机化 |
| 高动态运动(翻转、跳跃) | SRB 模型近似失效 | 端到端学习全身动力学 |
| 软硬件差异(sim-to-real) | 需要精确的系统辨识 | Domain Randomization 天然处理 |
RL 不是替代 MPC+WBC,而是互补。工业界的趋势是:用 MPC+WBC 提供安全基线,用 RL 提供鲁棒性和适应性。
69.8.1 IsaacLab 训练管线概览 ⭐⭐¶
IsaacLab(原 Isaac Gym / Orbit)是目前最流行的 RL 训练框架,能在单张 GPU 上并行运行 4096 个仿真环境:
训练管线:
+-------------------------------------------+
| IsaacLab (GPU 并行仿真) |
| |
| +---------+ +---------+ +---------+ |
| | Env 1 | | Env 2 | |...Env N | | N = 4096
| | Go2 | | Go2 | | Go2 | |
| | + 随机 | | + 随机 | | + 随机 | |
| | 地形 | | 摩擦 | | 质量 | |
| +----+----+ +----+----+ +----+----+ |
| | | | |
| +-----------+-----------+ |
| | |
| +-------v-------+ |
| | Observation | |
| | (body vel, | |
| | joint pos/vel| |
| | commands, | |
| | gravity proj)| |
| +-------+-------+ |
+------------------+---+-------------------+
|
+-------v-------+
| PPO (rsl_rl) |
| Actor-Critic |
| MLP [512,256] |
+-------+-------+
| actions: 12 dim
| (目标关节角度)
v
下发到所有 N 个环境
69.8.2 观测空间与动作空间设计 ⭐⭐¶
观测空间(~48 维,取决于具体配置):
| 观测量 | 维度 | 来源 | 作用 |
|---|---|---|---|
| 基座角速度 | 3 | IMU | 感知旋转 |
| 重力在体坐标系下的投影 | 3 | IMU | 感知倾斜 |
| 速度命令 | 3 | 外部输入 | 目标跟踪 |
| 关节位置(偏差) | 12 | 编码器 | 当前姿态 |
| 关节速度 | 12 | 编码器 | 运动状态 |
| 上一步动作 | 12 | 控制器 | 平滑性 |
动作空间(12 维):
RL 策略输出的是**目标关节角度偏差** \(\Delta q\),而非直接输出扭矩:
为什么输出位置而非扭矩? - 位置控制更稳定:即使策略输出垃圾值,PD 环也能提供一定的被动阻尼 - 更容易 sim-to-real:位置控制对模型误差不那么敏感 - 与传统控制框架兼容:可以直接叠加在 MPC 输出上
跨领域类比:RL 策略输出位置偏差而非直接扭矩,就像自动驾驶系统输出"目标车道"而非"方向盘角度"。目标车道(位置)经过底层的 PD 转向控制器才变成方向盘角度(扭矩)——即使上层策略偶尔给出奇怪的车道目标,转向控制器也会平滑、安全地执行,不会瞬间打满方向盘。这个中间层(PD 环/转向控制器)充当了"安全缓冲",把上层策略的不完美与底层执行器的物理极限隔离开来。
69.8.3 奖励函数设计 ⭐⭐⭐¶
奖励函数是 RL 训练中最关键的设计,也是最需要经验的部分。以下是 IsaacLab 中 Go2 locomotion 的典型奖励组成:
| 奖励项 | 公式 | 权重 | 作用 |
|---|---|---|---|
| 线速度跟踪 | \(\exp(-\|v_{xy} - v_{xy}^{\text{cmd}}\|^2 / \sigma^2)\) | 1.0 | 核心目标:跟踪命令 |
| 角速度跟踪 | \(\exp(-\|\omega_z - \omega_z^{\text{cmd}}\|^2 / \sigma^2)\) | 0.5 | 转向跟踪 |
| 线速度 z 惩罚 | \(-v_z^2\) | -2.0 | 抑制上下弹跳 |
| 角速度 xy 惩罚 | \(-\|\omega_{xy}\|^2\) | -0.05 | 抑制 roll/pitch 振荡 |
| 关节加速度惩罚 | \(-\|\ddot{q}\|^2\) | -2.5e-7 | 平滑运动 |
| 动作变化率惩罚 | \(-\|a_t - a_{t-1}\|^2\) | -0.01 | 抑制抖动 |
| 扭矩惩罚 | \(-\|\tau\|^2\) | -1e-5 | 能量效率 |
| 足端气时惩罚 | 脚在空中但不移动 | -1.0 | 避免"悬浮" |
| 足端碰地奖励 | 着地时垂直速度小 | 0.25 | 鼓励软着陆 |
奖励设计的核心原则:
-
正奖励要用指数核(exponential kernel):\(r = \exp(-e^2/\sigma^2)\) 而非 \(r = -e^2\)。原因:指数核在误差为零时给最大奖励(=1),在误差大时快速衰减但不为负,避免了"大误差 = 大惩罚 → 策略倾向不动"的问题。
-
惩罚项的权重要比奖励项小 1-2 个数量级:惩罚太大会让策略过于保守(站着不动是"安全"的)。
-
不要一次加入所有奖励项:先只用速度跟踪 + 存活奖励训练出能走的策略,然后逐步加入平滑性、能效等惩罚。
# IsaacLab 奖励函数配置示例
class Go2RewardsCfg:
# --- 正奖励(追踪目标) ---
track_lin_vel_xy_exp = RewTerm(
func=mdp.track_lin_vel_xy_exp,
weight=1.0,
params={"std": 0.25} # sigma
)
track_ang_vel_z_exp = RewTerm(
func=mdp.track_ang_vel_z_exp,
weight=0.5,
params={"std": 0.25}
)
# --- 负惩罚(正则化) ---
lin_vel_z_l2 = RewTerm(
func=mdp.lin_vel_z_l2, weight=-2.0
)
ang_vel_xy_l2 = RewTerm(
func=mdp.ang_vel_xy_l2, weight=-0.05
)
action_rate_l2 = RewTerm(
func=mdp.action_rate_l2, weight=-0.01
)
joint_acc_l2 = RewTerm(
func=mdp.joint_accel_l2, weight=-2.5e-7
)
69.8.4 Domain Randomization(域随机化) ⭐⭐⭐¶
Domain Randomization 是 sim-to-real 成功的关键——通过在训练时随机化仿真参数,让策略学会对不确定性的鲁棒性:
| 随机化参数 | 范围 | 作用 |
|---|---|---|
| 地面摩擦系数 | [0.3, 1.2] | 适应不同地面 |
| 机器人质量 | [+/-20%] | 适应负载变化 |
| 质心位置偏移 | [+/-5 cm] | 适应负载分布 |
| 关节阻尼 | [+/-30%] | 适应电机特性差异 |
| PD 增益 | [+/-20%] | 适应底层控制器差异 |
| 外部推力 | [0, 10 N] 随机 | 抗扰能力 |
| 地形高度 | [0, 10 cm] | 地形适应 |
# IsaacLab Domain Randomization 配置
class Go2DomainRandCfg:
# 每 episode 开始时随机化一次
randomize_friction = EventTerm(
func=mdp.randomize_rigid_body_material,
mode="reset",
params={
"static_friction_range": (0.4, 1.0),
"dynamic_friction_range": (0.4, 1.0),
}
)
randomize_mass = EventTerm(
func=mdp.randomize_rigid_body_mass,
mode="reset",
params={"mass_distribution_params": (-1.5, 1.5)}
)
# 每步都随机的(更强的鲁棒性训练)
push_robot = EventTerm(
func=mdp.push_by_setting_velocity,
mode="interval",
interval_range_s=(10.0, 15.0),
params={"velocity_range": {"x": (-0.5, 0.5),
"y": (-0.5, 0.5)}}
)
69.8.5 训练流程与超参数 ⭐⭐¶
# 启动训练。耗时强依赖 IsaacLab 版本、显卡、num_envs、地形复杂度和终止条件;
# 单卡 3090/4090 上平地 Go2 任务通常按“几十分钟到数小时”估算更稳妥。
cd ~/IsaacLab
./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \
--task Isaac-Velocity-Flat-Unitree-Go2-v0 \
--num_envs 4096 \
--max_iterations 1500 \
--headless
# 关键超参数
# PPO 配置(rsl_rl):
# learning_rate: 1e-3
# num_mini_batches: 4
# num_learning_epochs: 5
# gamma: 0.99
# lam: 0.95
# clip_param: 0.2
# network: MLP [512, 256, 128]
# activation: ELU
训练监控:
# 用 TensorBoard 监控训练
tensorboard --logdir logs/rsl_rl
# 关键指标:
# - episode_reward: 应持续上升
# - episode_length: 应增加(存活更久)
# - tracking_lin_vel: 应趋近 1.0(完美跟踪)
IsaacLab/rsl_rl 的日志目录通常是 logs/rsl_rl/<experiment_name>/<run_name>/...,experiment_name 由任务配置决定,不一定等于 Isaac-Velocity-Flat-Unitree-Go2-v0 这个任务 ID。调试时先用 find logs/rsl_rl -maxdepth 3 -type d 看实际生成目录,再打开对应 TensorBoard。
训练完成标志: 1. episode_reward 收敛(不再明显上升) 2. 在 viewer 中可视化:策略能稳定行走、跟踪速度命令 3. 对随机推力有恢复能力
⚠️ 常见陷阱¶
⚠️ 编程陷阱:IsaacLab 版本与 rsl_rl 版本不匹配 - IsaacLab 绑定特定版本的 rsl_rl,混用版本会导致 tensor shape 不匹配 - 正确做法:使用 IsaacLab 的
./isaaclab.sh --install自动安装匹配版本🧠 思维陷阱:认为训练越久越好 - 新手想法:"训练 10000 iterations 肯定比 1500 好" - 实际上:过长训练可能导致**过拟合到训练地形/参数分布**,在真机上反而更差 - 正确做法:用 early stopping,在 reward 不再上升时停止;或用 checkpoint 保存多个阶段的模型,在真机上分别测试
💡 概念误区:认为 RL 可以完全替代传统控制 - RL 策略在训练分布外的泛化能力有限 - 安全关键场景(如真机起身)仍需要传统控制器兜底 - 工业级方案通常是 RL + 安全监控器(如 CBF/安全集)
练习¶
练习 69.8.1 [必做]:在 IsaacLab 中训练 Go2 的平地行走策略。记录训练曲线(reward vs iteration),截图保存。
练习 69.8.2 [进阶]:修改奖励函数:去掉 action_rate_l2 惩罚后重新训练。对比有/无动作平滑惩罚的策略在 viewer 中的表现(抖动程度)。
练习 69.8.3 [研究级]:实现 Teacher-Student 蒸馏:先训练一个有**特权信息**(地面高度、真实摩擦系数)的 teacher 策略,再蒸馏到只有传感器输入的 student 策略。对比 student 与直接训练的策略的鲁棒性。
69.9 Sim-to-Real 部署 ⭐⭐⭐¶
动机:仿真到真机的最后一公里¶
本质洞察:Sim-to-Real 的本质不是"让仿真更像真实"(这是一个无底洞),而是**让控制器对"仿真与真实之间的差异"不敏感**。Domain Randomization 的深层逻辑是:与其精确建模一个真实世界(做不到),不如让控制器在**一大族可能的世界**中都能工作——真实世界只要落在这个族里面,控制器就能泛化。这与鲁棒控制理论中的"不确定集"思想一脉相承:你不需要知道干扰的精确值,只需要知道干扰的范围,然后设计在整个范围内都稳定的控制器。
仿真中完美运行的控制器,部署到真机上可能完全失败。Sim-to-Real gap(仿真到现实的鸿沟)是机器人学中最具挑战性的问题之一。本节讲解如何系统性地缩小这个鸿沟。
如果不做 Sim-to-Real 处理会怎样?¶
| Sim-to-Real 差异来源 | 仿真中 | 真机上 | 后果 |
|---|---|---|---|
| 电机延迟 | 0 ms | 5-15 ms | 控制器相位超前,产生振荡 |
| 地面摩擦 | 精确已知 | 变化的 | 计划的步态打滑 |
| IMU 噪声 | 高斯白噪声 | 有偏置+漂移 | 状态估计漂移 |
| 关节摩擦 | 无 | 存在 | 需要更大扭矩才能启动 |
| 通信延迟 | 0 ms | 1-5 ms | 控制不及时 |
69.9.1 Unitree SDK2 接口 ⭐⭐¶
Unitree Go2 EDU 使用 SDK2(基于 CycloneDDS)进行低层级控制。与旧版 SDK1 的主要区别:
| 特性 | SDK1 (UDP) | SDK2 (CycloneDDS) |
|---|---|---|
| 通信协议 | 原始 UDP | DDS (标准中间件) |
| 发现机制 | 固定 IP | 自动发现 |
| ROS 2 兼容 | 需要桥接 | 原生兼容 |
| 多机器人 | 困难 | 天然支持 |
# Python 部署流程骨架(unitree_sdk2_python):展示数据流,不是完整上机程序。
from unitree_sdk2py.core.channel import (
ChannelFactoryInitialize, ChannelPublisher, ChannelSubscriber)
from unitree_sdk2py.idl.unitree_go.msg.dds_ import LowCmd_, LowState_
import numpy as np
import time
class Go2Controller:
def __init__(self, network_interface="eth0"):
# 初始化 DDS 通信。网卡名必须换成连接 Go2 的实际接口。
ChannelFactoryInitialize(0, network_interface)
self.latest_state = None
self.cmd_pub = ChannelPublisher("rt/lowcmd", LowCmd_)
self.cmd_pub.Init()
self.state_sub = ChannelSubscriber("rt/lowstate", LowState_)
self.state_sub.Init(self._state_callback, 1)
# 加载 ONNX 策略(从 IsaacLab 导出)
import onnxruntime as ort
self.policy = ort.InferenceSession("go2_policy.onnx")
# 默认站立角度
# 内部顺序使用 [FL, FR, RL, RR];写入 SDK2 LowCmd 前必须转换到
# Unitree 当前 JointIndex 顺序 [FR, FL, RR, RL]。
self.sdk_index_from_internal = np.array([
3, 4, 5, # FL -> SDK2 indices
0, 1, 2, # FR
9, 10, 11, # RL
6, 7, 8 # RR
])
self.default_pos = np.array([
0.0, 0.8, -1.6, # FL
0.0, 0.8, -1.6, # FR
0.0, 1.0, -1.6, # RL
0.0, 1.0, -1.6 # RR
])
def _state_callback(self, msg: LowState_):
self.latest_state = msg
def run(self):
while True:
# 1. 读取状态
state = self.latest_state
if state is None:
time.sleep(0.002)
continue
obs = self.build_observation(state)
# 2. 推理
action = self.policy.run(None,
{"obs": obs.astype(np.float32)})[0]
# 3. 转换为关节目标
q_target = self.default_pos + action * 0.25 # action_scale
# 4. 发送命令
cmd = self.build_low_cmd(q_target, kp=50.0, kd=3.0)
# build_low_cmd() 必须完成内部顺序 -> SDK2 motor_cmd 索引映射,
# 并设置 mode/q/dq/kp/kd/tau 和 CRC。
self.cmd_pub.Write(cmd)
time.sleep(0.002) # 500 Hz 控制循环
build_low_cmd() 的核心不是“把 12 个数顺序写进去”,而是按关节名做重排:
下面是伪代码;不同版本的 unitree_sdk2_python 字段 setter 可能不同。
def fill_joint_targets(cmd, q_target_internal, kp, kd, sdk_index_from_internal):
for internal_i, sdk_i in enumerate(sdk_index_from_internal):
motor = cmd.motor_cmd()[sdk_i]
set motor.mode to servo/PMSM mode
set motor.q to q_target_internal[internal_i]
set motor.dq to 0.0
set motor.kp to kp
set motor.kd to kd
set motor.tau to 0.0
SDK 版本提示:以上是部署流程骨架,仍省略了 CRC、运动模式切换、急停保护和完整观测构造。Unitree SDK 更新频繁,类名、导入路径和 CRC 写法可能变化,请以 unitree_sdk2_python 官方仓库 的最新示例为准;真正上机前必须加入官方示例中的模式释放和 CRC 计算。
69.9.2 ONNX 模型导出 ⭐⭐¶
IsaacLab 训练的 PyTorch 策略需要导出为 ONNX 格式,以便在真机上高效推理。不要手动 ActorCritic(...) 再 load_state_dict():当前 rsl_rl runner 里可能还包含 observation normalizer、动作裁剪和配置参数,手动重建很容易漏掉。
更稳妥的方式是使用 IsaacLab 的 play.py 从 checkpoint 加载完整 runner,并让它导出策略:
cd ~/IsaacLab
./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/play.py \
--task Isaac-Velocity-Flat-Unitree-Go2-v0 \
--checkpoint logs/rsl_rl/<experiment_name>/<run_name>/model_1500.pt \
--num_envs 16
# 导出结果通常位于对应 run 目录下的 exported/ 子目录,例如:
# logs/rsl_rl/<experiment_name>/<run_name>/exported/policy.onnx
# logs/rsl_rl/<experiment_name>/<run_name>/exported/policy.pt
部署前还要做一次“同一观测输入、PyTorch 输出 vs ONNX Runtime 输出”的数值对比。如果 observation normalizer 没有一起导出,ONNX 在真机上会看到未归一化观测,动作幅度可能完全错误。
69.9.3 安全检查清单 ⭐⭐⭐¶
在真机上运行任何控制器之前,必须完成以下检查:
真机部署安全清单
1. 硬件检查
[ ] 电池电量 > 50%
[ ] 所有关节能手动自由转动(无异常卡顿)
[ ] 急停按钮可达且测试有效
[ ] 部署区域有软垫(防摔损)
2. 软件检查
[ ] 策略在 MuJoCo 中验证通过
[ ] 逐关节扭矩限幅已设置(来自 SDK/URDF/MJCF 参数表,并保留实机安全裕度)
[ ] 关节角度限幅已设置
[ ] 通信延迟 < 5 ms(用 ping 测试)
[ ] 急停逻辑已实现(任何异常 -> 零扭矩)
3. 渐进式测试
[ ] Step 1: 空载上电,检查传感器读数合理
[ ] Step 2: 发送零扭矩命令,验证通信正常
[ ] Step 3: 发送站立姿态(低增益 Kp=20),观察缓慢站起
[ ] Step 4: 逐步增大 Kp 到目标值
[ ] Step 5: 发送小速度命令(0.1 m/s),观察缓慢移动
[ ] Step 6: 逐步增大速度到目标值
4. 异常处理
[ ] 任何关节扭矩 > 20 Nm 持续 100ms -> 急停
[ ] 基座 roll/pitch > 45 度 -> 急停
[ ] 通信中断 > 50 ms -> 零扭矩 + 报警
⚠️ 常见陷阱¶
⚠️ 编程陷阱:真机上的关节顺序与仿真不同 - Unitree SDK2 当前 Go2 低层索引是
FR_Hip, FR_Thigh, FR_Calf, FL_Hip, ...,与本章内部[FL, FR, RL, RR]教学顺序不同 - 必须建立显式映射表,而不是假设顺序一致 - 验证方法:逐个关节发送小角度命令,目视确认是哪个关节在动🧠 思维陷阱:在真机上调参 - 新手想法:"仿真里差不多了,真机上微调一下就行" - 实际上:真机调参的代价极高(每次尝试都可能摔机器人) - 正确做法:所有参数调整在仿真中完成;真机上只做**增益缩放**(先用仿真增益的 50% 开始,逐步提高)
练习¶
练习 69.9.1 [必做(如果有真机)]:按安全检查清单部署站立控制器到真机 Go2。录制 30 秒站立视频。
练习 69.9.2 [进阶]:部署 RL 策略到真机。先在软垫上测试,再到硬地面。对比仿真和真机的行走表现,列出所有观察到的差异。
练习 69.9.3 [思考]:如果 RL 策略在真机上的表现明显差于仿真,你会从哪些方面排查?列出至少 5 个可能的原因和对应的诊断方法。
69.10 性能调优与故障排除 ⭐⭐¶
动机:所有模块都跑通了,但表现不好怎么办?¶
集成后的调优是最需要经验的环节。本节汇总所有常见故障的诊断和修复方法。
69.10.1 常见故障诊断表 ⭐⭐¶
| 故障现象 | 可能原因 | 诊断方法 | 修复方案 |
|---|---|---|---|
| 机器人瞬间弹飞 | WBC 扭矩爆表 | 打印 QP 解,检查扭矩值 | 加扭矩限幅;检查 QP 约束是否正确 |
| 机器人缓慢下沉 | 重力补偿不足 | 在站立状态检查 \(\tau_g\) 是否正确 | 验证 Pinocchio 模型的质量参数 |
| 行走时 yaw 漂移 | MPC 的 yaw 追踪权重太低 | 检查 Q 矩阵中 yaw 的权重 | 增大 yaw 追踪权重 |
| 脚在空中抖动 | swing foot PD 增益过高 | 降低摆动腿增益后观察 | 降低 \(K_p\) 或加低通滤波 |
| 步态不对称 | 关节零位标定偏差 | 比较四腿在同一命令下的角度 | 重新标定关节零位 |
| MPC 求解超时 | QP 维度过大或 warm start 失效 | 测量 QP 求解时间 | 减小 horizon;启用 warm start |
| 状态估计跳变 | KF 观测噪声设置不当 | 画出估计值 vs 真值的曲线 | 调整 R 矩阵(观测噪声) |
| 着地冲击大 | 摆动轨迹末端速度非零 | 检查轨迹规划的边界条件 | 加软着陆段(着地前降速) |
69.10.2 调参优先级 ⭐⭐¶
当需要同时调多个参数时,按以下优先级:
优先级 1:安全相关
+-- 扭矩限幅、关节限位、急停阈值
|
优先级 2:基础功能
+-- 站立 PD 增益、重力补偿
|
优先级 3:MPC 参数
+-- Q/R 权重、预测步长、摩擦系数
|
优先级 4:WBC 参数
+-- 任务权重、摩擦锥安全裕度
|
优先级 5:步态参数
+-- 步态周期、duty factor、摆动高度
|
优先级 6:性能优化
+-- 线程优先级、内存预分配、QP warm start
69.10.3 调试工具链 ⭐¶
# 1. ROS 2 话题监控
ros2 topic echo /mini_legged/joint_states # 关节状态
ros2 topic echo /mini_legged/mpc_solution # MPC 输出
ros2 topic hz /mini_legged/wbc_output # WBC 频率
# 2. rqt 可视化
rqt_plot /mini_legged/base_pose/position/z # 基座高度
rqt_plot /mini_legged/joint_torques # 关节扭矩
# 3. 性能分析
# 使用 Google Benchmark 测量关键函数耗时
# MPC 求解: < 2 ms
# WBC 求解: < 500 us
# 状态估计: < 100 us
# 总 update(): < 1 ms (1 kHz 约束)
# 4. 录制 rosbag 回放
ros2 bag record -a -o mini_legged_debug
ros2 bag play mini_legged_debug
69.10.4 性能基准测试 ⭐⭐¶
// Google Benchmark 示例
#include <benchmark/benchmark.h>
static void BM_MpcSolve(benchmark::State& state) {
ConvexMpc mpc;
auto x0 = getDefaultState();
auto x_ref = getStandingReference();
auto foot_pos = getDefaultFootPositions();
auto schedule = getTrotSchedule();
for (auto _ : state) {
auto result = mpc.solve(x0, x_ref, foot_pos, schedule);
benchmark::DoNotOptimize(result);
}
}
BENCHMARK(BM_MpcSolve);
static void BM_WbcSolve(benchmark::State& state) {
WholeBodyController wbc;
// ... 类似设置 ...
for (auto _ : state) {
auto tau = wbc.solve(cmd, q, v, contact_flags);
benchmark::DoNotOptimize(tau);
}
}
BENCHMARK(BM_WbcSolve);
BENCHMARK_MAIN();
目标性能基准:
| 模块 | 目标延迟 | 实际典型值 | 超标处理 |
|---|---|---|---|
| MPC 求解(SRB 凸 QP) | < 2 ms | 0.5-1.5 ms | 减小 horizon 或简化约束 |
| WBC 求解(ProxQP) | < 500 us | 100-300 us | 预分配内存,warm start |
| 状态估计(Linear KF) | < 100 us | 20-50 us | 基本不会超标 |
| Pinocchio FK/ID | < 200 us | 50-100 us | 预计算,缓存结果 |
| 总 update() | < 1 ms | 0.5-0.8 ms | 检查上述各项 |
⚠️ 常见陷阱¶
⚠️ 编程陷阱:Debug 模式下的性能测试无意义 - Debug 模式(-O0)下 Eigen 运算慢 5-10 倍 - WBC 在 Debug 模式下可能需要 3ms(超标!),但 Release 模式只需 200us - 性能测试必须在 Release 或 RelWithDebInfo 模式下进行
💡 概念误区:认为 1 kHz 控制频率是"越高越好" - 1 kHz 对于关节级 PD 控制已经足够 - WBC 在 1 kHz 下运行也够用 - 但如果你的控制管线总延迟 > 1ms,降到 500 Hz 并保证**稳定的延迟**,比在 1 kHz 下偶尔超时更好 - 确定性比频率更重要
练习¶
练习 69.10.1 [必做]:用 Google Benchmark 测量你的 MPC 和 WBC 求解时间。画出"n_contact(接触脚数) vs 求解时间"的柱状图。
练习 69.10.2 [进阶]:在 Gazebo 中给行走的 Go2 施加侧向推力(5N 持续 0.5 秒),记录恢复过程。调整 MPC 的 Q 矩阵权重,找到"最快恢复"的参数组合。
69.11 本章小结¶
知识点总结¶
| 节 | 核心内容 | 难度 | 关键产出 |
|---|---|---|---|
| 69.1 | 项目总览与开发流程 | ⭐ | 架构图、学习路线 |
| 69.2 | 开发环境搭建 | ⭐ | ROS 2 + Pinocchio + OCS2 + MuJoCo + IsaacLab |
| 69.3 | URDF 与 MuJoCo 模型 | ⭐⭐ | 模型加载、URDF→MJCF、交叉验证 |
| 69.4 | 站立控制器 | ⭐⭐ | PD + 重力补偿,数据通路验证 |
| 69.5 | MPC 集成 | ⭐⭐ | SRB 凸 MPC,线程安全通信 |
| 69.6 | WBC 集成 | ⭐⭐ | 加权 QP,扭矩反推,实时性保证 |
| 69.7 | 步态实现 | ⭐⭐ | Trot 步态调度,摆动腿轨迹,步态切换 |
| 69.8 | RL 策略训练 | ⭐⭐⭐ | IsaacLab 训练,奖励设计,Domain Randomization |
| 69.9 | Sim-to-Real 部署 | ⭐⭐⭐ | SDK2 接口,ONNX 导出,安全清单 |
| 69.10 | 性能调优与故障排除 | ⭐⭐ | 故障诊断表,性能基准,调试工具 |
完整系统数据流¶
速度命令 (vx, vy, omega_z)
|
v
+----------------+
| 步态调度器 | -> contact_schedule[t..t+T]
| (GaitSchedule) | -> swing_targets
+--------+-------+
|
v
+----------------+ +-------------------+
| MPC (50 Hz) |<----| 状态估计 (1 kHz) |
| SRB 凸 QP | | Linear KF |
| | | IMU + Leg Odom |
| 输出: | +-------------------+
| x_ref, f_ref |
+--------+-------+
| Latest Buffer(无锁)
v
+----------------+
| WBC (1 kHz) |
| 加权 QP |
| (ProxQP) |
| |
| 输出: tau[12] |
+--------+-------+
|
v
+----------------+ +----------------+
| 扭矩限幅 |---->| 电机驱动 |
| 安全监控 | | (Go2 SDK2) |
+----------------+ +----------------+
累积项目:本章新增模块¶
Mini-Legged 项目进度:
| 章节 | 新增模块 | 代码量(估计) |
|---|---|---|
| Ch47 | PinocchioInterface 封装 | ~200 行 |
| Ch50 | QP 求解器封装(ProxQP) | ~100 行 |
| Ch51 | SRB 模型 | ~300 行 |
| Ch53 | WBC 核心 | ~500 行 |
| Ch55 | MPC 核心 + 双线程架构 | ~800 行 |
| Ch57 | Linear KF 状态估计 | ~300 行 |
| Ch69 | 完整集成 + 步态 + RL + 部署 | ~3000 行 |
| 总计 | Mini-Legged 完整控制栈 | ~5200 行 C++ + Python |
延伸阅读¶
必读¶
| 资源 | 难度 | 内容 |
|---|---|---|
| Di Carlo et al., "Dynamic Locomotion in the MIT Cheetah 3 Through Convex MPC", IROS 2018 | ⭐⭐ | SRB 凸 MPC 的原始论文,你的 MPC 实现的理论基础 |
| Kim et al., "Highly Dynamic Quadruped Locomotion via Whole-Body Impulse Control", IROS 2019 | ⭐⭐ | MIT Mini Cheetah 的 MPC+WBC 实现,工程参考 |
| Rudin et al., "Learning to Walk in Minutes Using Massively Parallel Deep RL", CoRL 2022 | ⭐⭐ | legged_gym / IsaacLab 训练管线的原始论文 |
进阶¶
| 资源 | 难度 | 内容 |
|---|---|---|
| Grandia et al., "Perceptive Locomotion Through Nonlinear Model-Predictive Control", T-RO 2023 | ⭐⭐⭐ | OCS2 + 高程图的集成 |
| Miki et al., "Learning Robust Perceptive Locomotion for Quadrupedal Robots in the Wild", Science Robotics 2022 | ⭐⭐⭐ | Teacher-Student 蒸馏 + 真实部署 |
| Schwarke et al., "RSL-RL: A Learning Library for Robotics Research", arXiv 2025 | ⭐⭐ | rsl_rl 库的设计哲学与最新 API |
代码参考¶
| 仓库 | 用途 |
|---|---|
| legubiao/ocs2_ros2 | OCS2 的 ROS 2 移植版 |
| legubiao/quadruped_ros2_control | ROS 2 四足控制框架(含 Gazebo Harmonic 支持) |
| google-deepmind/mujoco_menagerie | Go2 的 MJCF 模型 |
| unitreerobotics/unitree_sdk2_python | Go2 SDK2 Python 接口 |
| isaac-sim/IsaacLab | RL 训练框架 |
| leggedrobotics/rsl_rl | 机器人 RL 库 |