跳转至

本文档属于 Robotics Tutorial 项目,作者:Pengfei Guo,达妙科技。采用 CC BY 4.0 协议,转载请注明出处。

第 62 章:腿足硬件栈——驱动器 / 通信总线 / 传感器 / Unitree SDK / ros2_control 硬件接口

难度:⭐⭐~⭐⭐⭐ | 前置:Ch31(ROS2 集成)、Ch57(腿足状态估计)、Ch61(实时 C++ 工程与 ros2_control)

核心参考文献: - Katz B., Di Carlo J., Kim S. (2019) "Mini Cheetah: A Platform for Pushing the Limits of Dynamic Quadruped Control", ICRA - Seok S. et al. (2015) "Design Principles for Energy-Efficient Legged Locomotion and Implementation on the MIT Cheetah Robot", IEEE/ASME T-Mech - Wensing P. M. et al. (2017) "Proprioceptive Actuator Design in the MIT Cheetah: Impact Mitigation and High-Bandwidth Physical Interaction for Dynamic Legged Robots", IEEE T-RO - Hutter M. et al. (2016) "ANYmal - a Highly Mobile and Dynamic Quadrupedal Robot", IROS - EtherCAT Technology Group (2023) "EtherCAT — the Ethernet Fieldbus", EtherCAT.org

建议学时:1.5 周(25--30 小时)


前置自测

📋 前置自测(答不出 \(\geq 2\) 题 → 先回对应章节复习)

  1. [Ch61] PREEMPT_RT 内核的核心作用是什么?为什么普通 Linux 内核不满足腿足 1 kHz 控制的实时性要求?

  2. [Ch61] ros2_control 中 SystemInterfaceread()write() 方法分别承担什么职责?它们在实时循环中的调用顺序是什么?

  3. [Ch61] 实时循环的"四大禁忌"是什么?为什么在 1 kHz 控制循环内调用 malloc 会导致灾难性后果?

  4. [Ch57] IMU 加速度计测量的是什么物理量?为什么静止时读数不为零?

  5. [基础电学] 直流电机的扭矩与电流之间是什么关系?扭矩常数 \(K_t\) 的物理含义?

如果第 5 题答不出——本章涉及电机控制基础,建议先了解 BLDC 电机的基本工作原理。


本章目标

学完本章后,你应该能够:

  1. **画出**腿足机器人完整硬件架构图,说清从计算平台到关节电机的每一层通信路径
  2. **解释**准直驱(QDD)电机为什么是腿足机器人的主流选择——从力控带宽和反向驱动性的角度
  3. 理解 FOC(磁场定向控制)的基本原理,知道电流环如何实现精确扭矩控制
  4. 对比 CAN 总线、EtherCAT、UDP 三种通信方案的带宽、延迟和适用场景
  5. 使用 Unitree SDK2 的 LowLevel 接口控制 Go2 的 12 个关节电机
  6. 实现 ros2_control 的 HardwareInterface 将 Unitree SDK 接入 ros2_control 框架
  7. **选择**合适的 IMU、力传感器和编码器,理解各传感器的精度指标和标定方法

62.1 腿足机器人硬件全景 ⭐

动机:从算法到电机的完整链路

本节解决的问题:腿足机器人从算法计算出一个关节扭矩值到电机实际输出该扭矩,中间经历了哪些环节?每一层的延迟和带宽限制是什么?

当你在 Ch53 的 WBC 中计算出 \(\boldsymbol{\tau}_{\text{WBC}} \in \mathbb{R}^{12}\),或在 Ch64 的 RL 策略中推理出关节位置目标 \(\boldsymbol{q}_{\text{des}} \in \mathbb{R}^{12}\) 后,这些"数字"要经过一条复杂的链路才能变成真实的物理运动。如果你不理解这条链路中每一层的工作方式和延迟特性,就无法调试真机上出现的种种问题——"为什么仿真中完美的控制器在真机上就抖动?""为什么电机偶尔不响应?""为什么关节温度升高后扭矩就不准了?"

这些问题的答案都藏在硬件栈里。

控制循环层次结构

腿足机器人的控制循环不是一个单一循环,而是**多层嵌套**的层次结构——好比交响乐团的组织架构:指挥(规划层)给出乐章结构和节奏意图,声部首席(MPC 层)将其细化为乐句的强弱和速度,每位演奏者(电机伺服层)则以极高频率精确执行每一个音符。每一层有不同的频率、不同的延迟要求、不同的计算平台:

┌────────────────────────────────────────────────────────┐
│                  规划层 (Planning)                       │
│  频率: 1-10 Hz | 延迟容忍: 100-1000 ms                   │
│  内容: 步态规划、路径规划、行为决策                         │
│  平台: CPU (非实时)                                      │
└───────────────────────┬────────────────────────────────┘
                        │ 参考轨迹 / 步态相位
┌────────────────────────────────────────────────────────┐
│             MPC 层 (Model Predictive Control)            │
│  频率: 20-100 Hz | 延迟容忍: 10-50 ms (软实时)            │
│  内容: 预测未来 0.5-1s 轨迹, 优化 GRF                     │
│  平台: CPU (PREEMPT_RT 推荐)                             │
└───────────────────────┬────────────────────────────────┘
                        │ 期望 GRF / 质心轨迹
┌────────────────────────────────────────────────────────┐
│              WBC 层 (Whole-Body Control)                  │
│  频率: 200-1000 Hz | 延迟容忍: 1-5 ms (硬实时)            │
│  内容: 逆动力学, 约束 QP, 输出关节扭矩                     │
│  平台: CPU (PREEMPT_RT, isolated CPU)                    │
└───────────────────────┬────────────────────────────────┘
                        │ τ_des / q_des / kp / kd
┌────────────────────────────────────────────────────────┐
│           通信层 (Communication Bus)                      │
│  延迟: 10-500 μs | 协议: CAN / EtherCAT / UDP            │
│  内容: 打包指令发送、接收反馈                               │
│  平台: 主控板 + 总线控制器                                 │
└───────────────────────┬────────────────────────────────┘
                        │ 电机指令帧
┌────────────────────────────────────────────────────────┐
│            电机伺服层 (Motor Servo)                       │
│  频率: 10-40 kHz | 延迟: < 100 μs                        │
│  内容: FOC 电流环, PD 位置环, 安全保护                     │
│  平台: 电机驱动器 MCU (STM32 / 专用 DSP)                  │
└───────────────────────┬────────────────────────────────┘
                        │ PWM 信号
┌────────────────────────────────────────────────────────┐
│              物理层 (Physical Plant)                      │
│  BLDC 电机 + 行星齿轮 + 关节结构                          │
│  编码器 + 电流传感器 → 反馈回伺服层                        │
└────────────────────────────────────────────────────────┘

以 Unitree Go2 为例的硬件全景

Unitree Go2 是目前学术界和工业界常用的四足平台之一。下表用于说明主流消费级四足的典型硬件分层;具体计算板、电机内部结构和传感器配置会随 Air/Pro/EDU、批次和固件版本变化,上机前必须以所持版本的官方资料、SDK 消息和实物标定为准。

层次 Go2 具体实现 说明
计算平台 EDU 常见 NVIDIA Jetson Orin NX / 其他版本定制主板 算力和可开放接口随版本变化
中间件 CycloneDDS (unitree_sdk2) DDS 是 ROS2 默认中间件的同族协议
主控板 专用 Motion Control Board 负责电机协调、IMU 数据融合、安全保护
通信总线 CAN 总线 (4 条,每腿 1 条) 每条 CAN 连接 3 个电机驱动器
关节电机 QDD BLDC 外转子电机 槽极数、绕组和驱动细节以具体拆解/规格为准;Go2 常见减速比约 6.22:1
关节编码器 绝对式磁编码器 通常集成在电机模组内;安装位置和字段含义需以实物/SDK 为准
IMU MEMS IMU(集成在主控板) 6 轴(加速度计 + 陀螺仪)
足端传感器 SDK 暴露 foot_force / foot_force_est 字段 不同版本可能来自足端传感器或估计量,不能不经标定直接当作精确力传感器
相机 双目立体 + 鱼眼 感知层,非本章重点

四条腿 \(\times\) 3 DOF/腿 = 12 个电机

  • 髋侧摆关节(hip abduction/adduction):控制腿的左右摆动
  • 髋前后关节(hip flexion/extension):控制腿的前后摆动
  • 膝关节(knee flexion/extension):控制小腿的弯曲

如果不理解硬件栈会怎样

场景 1:仿真完美但真机抖动

你在 MuJoCo 中调好了 WBC 的 PD 增益,机器人走得很稳。部署到 Go2 真机时,关节剧烈抖动。原因:真实电机有 ~2 ms 的通信延迟和 FOC 环路延迟(仿真是零延迟),PD 增益需要降低 30-50% 来补偿这个延迟。如果你不知道硬件栈中存在这些延迟,就会在 PD 调参上浪费大量时间。

场景 2:机器人偶尔"失控"一下

控制器大部分时间工作正常,但偶尔机器人会突然抖一下或者某条腿松软一下。原因可能是 CAN 总线丢帧——当总线负载过高时,低优先级的消息可能延迟甚至丢失。如果你不理解 CAN 的优先级仲裁机制,根本不知道该从哪里排查。

场景 3:电机过热保护触发

长时间站立时电机温度升高,突然进入过热保护降额模式,机器人随即倒下。原因:准直驱电机的静态负载全靠电流维持(不像谐波减速器有自锁),站立时持续消耗大量电流产生热量。理解电机热模型后,可以通过优化站立姿态(让关节接近力矩最小构型)来减少发热。

历史演进

年代 代表平台 通信 电机类型 关键特点
2005 BigDog (Boston Dynamics) 专用液压控制 液压驱动 液压力控,功率密度极高但效率低
2013 MIT Cheetah 1 自研 SPI 板 自研 QDD 首次证明电动 QDD 可做高动态运动
2016 ANYmal (ETH) EtherCAT SEA (ANYdrive) 工业级 EtherCAT,串联弹性驱动
2018 MIT Mini Cheetah 自研 SPI 板 自研 QDD 9 kg 做后空翻,QDD 路线确立
2019 Unitree A1 CAN 商用 QDD 消费级四足的开创者
2023 Unitree Go2 CAN + DDS 改进 QDD SDK2 开放生态,学术界大量采用
2024 Unitree B2 / G1 CAN / EtherCAT 高功率 QDD 工业/人形平台

⚠️ 概念误区:认为"硬件栈只是工程问题,不需要理论"

新手想法:"我只要调好算法,硬件层面的东西交给硬件工程师就好。"

实际上:腿足控制的算法设计**深度依赖**硬件特性。WBC 的 QP 约束中包含关节扭矩上限(来自电机规格)、关节速度上限(来自减速比和电机 KV 值);MPC 的预测模型需要考虑电机动力学延迟;RL 策略的 observation space 包含电机温度和电流等硬件信号。不理解硬件的算法工程师写出来的控制器,永远只能在仿真里跑。

正确认知:硬件栈是算法部署的"最后一公里"。MIT Cheetah 团队在其 2019 ICRA 论文中明确指出:"The actuator design is not separable from the control design"(驱动器设计和控制设计不可分割)。

🧠 思维陷阱:认为"仿真和真机的差距主要来自动力学建模不准"

新手想法:"Sim-to-Real gap 是因为 MuJoCo/Isaac Gym 的物理引擎不够准确。"

实际上:Sim-to-Real gap 的最大来源之一是**通信延迟和电机动力学**。真实系统中:(1) 通信有 1-5 ms 延迟;(2) 电机 FOC 环不是理想的扭矩源——有带宽限制、扭矩波纹、温度依赖的 \(K_t\) 变化;(3) 齿轮有背隙和摩擦。这些在仿真中通常被忽略或大幅简化。

正确思维:提升 Sim-to-Real 迁移效果,既要改善物理仿真精度,也要改善硬件模型——用 actuator network(Ch64)来学习真实电机的输入输出映射就是一种方案。

练习

练习 62.1.1:画出你自己实验室中一台腿足机器人(或你计划使用的平台)的完整硬件架构图。标注每一层的通信协议、频率和延迟。如果没有真机,以 Unitree Go2 为例,参考本节的架构图。

练习 62.1.2:估算从 WBC 输出 \(\boldsymbol{\tau}_{\text{des}}\) 到电机实际输出该扭矩的**总端到端延迟**。假设:WBC 计算 0.5 ms,CAN 通信 1 ms,FOC 电流环 0.1 ms(40 kHz 频率下约 1 个周期)。总延迟对 1 kHz 控制循环意味着什么?PD 增益需要如何调整?

练习 62.1.3(思考题):为什么 Boston Dynamics 早期选择液压驱动而非电驱动?液压的功率密度优势在什么条件下被电动 QDD 超越?提示:考虑系统效率、控制带宽和维护成本。


62.2 驱动器与电机 ⭐⭐

动机:为什么腿足机器人不用工业机械臂的电机

本节解决的问题:工业机械臂和腿足机器人都需要精确的关节控制,但为什么它们使用完全不同类型的电机?"准直驱"电机的核心设计思想是什么?它如何实现精确力控和高速运动?

工业机械臂(如 KUKA、ABB)使用的电机方案是**高速小扭矩电机 + 谐波减速器(减速比 100:1-160:1)。这种方案的位置精度极高(重复定位 \(\pm 0.02\) mm),但有一个致命缺陷:**减速器的高摩擦和高惯量使得反向驱动极其困难。当外力作用于关节时(比如腿着地时的冲击力),电机几乎感受不到——因为减速器把力"隔离"了。

对于腿足机器人,这是灾难性的。腿足运动的本质是**与环境持续进行力交互**——每一步着地都是一次冲击,机器人必须能感知这个冲击力并做出反应。如果电机"感受不到"外力,力控就无法实现。

如果用谐波减速器会怎样

假设你把 KUKA 的谐波减速器电机装到一台四足机器人上:

  1. 力控精度极差:谐波减速器的效率约 80%,反向效率更低(~50%)。通过电流估计的扭矩误差可达 30-50%。WBC 计算出 \(\tau = 10\) Nm,实际输出可能是 5-15 Nm——力控基本不可用
  2. 着地冲击损坏减速器:腿足着地的冲击载荷是稳态的 3-5 倍。谐波减速器的柔轮(Flexspline)在反复冲击下容易疲劳断裂
  3. 不可反向驱动:机器人被推倒时,关节无法随外力旋转(自锁),导致关节和结构件承受全部冲击——结构损坏风险极高

历史:从液压到串联弹性到准直驱

腿足机器人的驱动方案经历了三个主要阶段:

阶段 1:液压驱动(2000s-2010s)

Boston Dynamics 的 BigDog(2005)和 Atlas 早期版本使用液压驱动。液压的功率密度极高(~2 kW/kg),远超当时的电机。但液压系统噪声大、效率低(通常 <30%)、维护困难、需要外部液压泵。这限制了液压驱动在实验室之外的应用。

阶段 2:串联弹性驱动器 SEA(2010s)

Pratt 和 Williamson(1995)提出的 Series Elastic Actuator(SEA)在电机输出端串联一个弹性元件(弹簧)。弹簧的变形量通过编码器测量,根据胡克定律 \(F = k \cdot \Delta x\) 精确计算输出力。ETH Zurich 的 ANYmal(2016)使用自研的 ANYdrive SEA,实现了高精度的力控。

SEA 的优势: - 力测量精度高(通过弹簧形变测量,不依赖电流) - 被动抗冲击(弹簧吸收着地冲击能量,类似人类的跟腱 Achilles tendon) - 能量存储和释放(跑步时弹簧储存动能并在蹬地时释放,研究表明弹簧可贡献 ~50% 的跑步机械功)

SEA 的劣势: - 力控带宽受限——弹簧引入了一个共振频率,高于该频率的力控指令被衰减 - 机械复杂度高——额外的弹簧、弹簧壳体和形变传感器增加了重量和成本 - 弹簧刚度选择困难——刚度太低响应慢,刚度太高失去弹性优势

阶段 3:准直驱 QDD(2013-至今)

MIT Cheetah 团队(Seok et al., 2015; Wensing et al., 2017)提出了一条完全不同的技术路线:用大扭矩密度的 BLDC 电机 + 极低减速比(5-10:1)的行星齿轮,直接实现力控。这就是准直驱(Quasi-Direct-Drive, QDD)。

QDD 的核心设计原理

QDD 的设计哲学是**让电机尽可能"透明"——外部施加的力能几乎无损地传递到电机轴,电机输出的力也能几乎无损地传递到关节。这种"透明度"在力控术语中称为**反向驱动性(Backdrivability)

反向驱动性的物理含义

考虑一个齿轮传动系统。设电机侧扭矩为 \(\tau_m\),关节侧扭矩为 \(\tau_j\),减速比为 \(N\),传动效率为 \(\eta\)

正向传动(电机驱动关节): $\(\tau_j = N \cdot \eta \cdot \tau_m\)$

反向传动(外力驱动电机): $\(\tau_m = \frac{\tau_j}{N \cdot \eta_{\text{back}}}\)$

其中 \(\eta_{\text{back}}\) 是反向效率。对于高减速比系统:

参数 谐波减速器 (N=100) QDD 行星齿轮 (N=6)
正向效率 \(\eta\) ~80% ~95%
反向效率 \(\eta_{\text{back}}\) ~30-50% (甚至自锁) ~90%
等效关节侧惯量 \(I_{\text{eff}} = N^2 I_m\) 10000 \(I_m\) 36 \(I_m\)
反向驱动力 \(\tau_{\text{backdrive}}\) 极大 极小

关键公式:等效关节侧转动惯量 \(I_{\text{eff}} = N^2 I_m\)(反映惯量 reflected inertia)。减速比 \(N\) 的平方意味着:100:1 减速器让等效惯量放大 10000 倍!这就是高减速比系统难以反向驱动的根本原因——要推动关节,外力需要"翻越"巨大的等效惯量。

QDD 的 \(N = 6\) 意味着等效惯量只放大 36 倍——这足够低,使得外部冲击力可以自然地传递到电机。电机侧的电流传感器能直接感受到这个力,实现**本体感受(Proprioceptive)力控**。

Unitree Go2 的电机设计

Unitree Go2 的关节电机可按以下方式理解。表中的内部结构来自公开拆解和常见版本经验,用于教学建模;不应当写进控制器作为跨版本硬编码事实:

参数 Go2 电机规格
电机类型 BLDC 外转子 QDD 模组
绕线方式 以具体电机版本为准
减速器类型 单级行星齿轮
减速比 ~6.22:1
峰值关节扭矩 45 Nm
编码器类型 绝对式磁编码器
电流传感器 三相电流检测
控制 MCU 以具体驱动板版本为准

为什么用外转子(Outrunner)设计:外转子电机的转子在外、定子在内,相比内转子电机具有更大的磁极半径。根据扭矩公式 \(\tau = B \cdot I \cdot L \cdot r\)\(B\) 磁场强度,\(I\) 电流,\(L\) 导体长度,\(r\) 半径),更大的半径意味着**相同电流下更大的扭矩**。这正是腿足机器人需要的——大扭矩密度。

主流 QDD 电机对比

电机 减速比 峰值扭矩 峰值扭矩密度 用于
MIT Mini Cheetah 自研 6:1 约 17 Nm 级别 高扭矩密度 MIT Mini Cheetah
大型 MIT Cheetah 平台执行器 低减速比 QDD 百 Nm 级别 高扭矩密度 Cheetah 3 等大型平台
Unitree Go2 内置 6.22:1 45 Nm Unitree Go2
T-Motor/CubeMars AK80-6 6:1 12 Nm (峰值) ~24.7 Nm/kg (电机+减速器 0.485 kg) 开源四足 (Solo12)
T-Motor AKE90-8 8:1 ~121 Nm/kg 高性能四足/人形
ANYdrive (SEA) 50:1 40 Nm ANYmal C/D
ODRI/Solo 自研 9:1 ~2.7 Nm 开源研究平台

⚠️ 编程陷阱:混淆电机侧扭矩和关节侧扭矩

错误做法:SDK 返回 tau_est = 2.0,你以为关节侧扭矩是 2.0 Nm,直接用于 WBC 的逆动力学计算。

现象:动力学方程求解出的接触力总是偏小或偏大,控制器表现异常。

根本原因:Unitree SDK 的 tau_est 字段返回的是**关节侧**扭矩(已经考虑了减速比),但如果你使用其他电机驱动器(如 T-Motor AK 系列),返回值可能是**电机侧**扭矩,需要乘以减速比 \(N\) 才是关节侧扭矩。

正确做法:阅读驱动器文档,明确 tau 字段的定义。在代码中显式标注:

double tau_joint = driver.getTorque();  // Check: motor-side or joint-side?
// If motor-side: tau_joint = tau_motor * gear_ratio * efficiency;

💡 概念误区:认为"减速比越低越好"

新手想法:"QDD 用 6:1 减速比效果好,那直驱(1:1)不是更好?"

实际上:纯直驱(Direct Drive, DD)确实有最佳的反向驱动性,但代价是**扭矩太小**。以 Go2 为例,其电机在 6.22:1 减速下峰值扭矩为 45 Nm。如果去掉减速器,峰值扭矩只有 ~7 Nm——这不足以支撑一只腿站立。

设计权衡:减速比 \(N\) 是一个关键的设计权衡参数。\(N\) 增大:扭矩 \(\uparrow\),惯量 \(N^2 \uparrow\),反向驱动性 \(\downarrow\)。MIT Cheetah 团队通过系统分析(Seok 2015)确定了 6:1 是"力控带宽"和"扭矩能力"之间的最佳折衷点。

驱动方案选型决策

驱动方式 功率密度 力控带宽 静止功耗 成本 适用场景
液压 极高 近零 重载/低速(建筑机器人、军事后勤)
高减速比电机 极低 位控应用(工业机械臂)
QDD(准直驱) 中高 高动态足式(四足/人形跑跳)
SEA(串联弹性) 中高 需被动柔顺(人机交互、步行)
电液混合(静液支撑) 大负载+长续航(野外巡检、搬运)

💡 前沿方向:电液混合驱动

电液混合路线结合了液压的"静止时低功耗"特性和电机的"高动态响应"优势。典型应用场景是大负载四足的野外长距离运动——纯电驱动在持续负重站立时持续消耗电能维持关节力矩,而静液支撑可以通过液压锁定实现近零静态功耗。浙江大学等团队已在该方向取得进展。

参考文献:Load-Carrying Assistance of Articulated Legged Robots Based on Hydrostatic Support

⚠️ 严谨说明:液压/静液支撑可显著降低维持载荷的能耗,但泵、泄漏、阀控损耗仍然存在,并非字面意义上的"零功耗"。

💡 硬件设计→控制假设的因果链

四足机器人普遍采用**膝电机近端布置**(将膝关节电机安装在髋部,通过连杆传动)而非直接安装在膝关节上。这一设计决策的核心目的是降低腿部转动惯量,使得: 1. 腿部质量集中在躯干附近,SRBD(单刚体动力学)的"质量集中在躯干"假设更好地成立 2. Centroidal 动力学中忽略关节运动引起的惯性张量变化(\(\dot{I}_G \approx 0\))更加合理 3. 摆动腿的惯性力对躯干的扰动更小,MPC 预测更准确

换言之,膝电机近端布置不仅是机械优化,更是让简化控制模型成立的前提条件。如果腿部惯量过大(如电机直接装在膝关节),SRBD 模型精度会显著下降,MPC 控制效果变差。

练习

练习 62.2.1:计算一个 QDD 电机(\(N = 6\),电机转子惯量 \(I_m = 0.001\) kg\(\cdot\)m\(^2\))和一个谐波减速器电机(\(N = 100\)\(I_m = 0.0001\) kg\(\cdot\)m\(^2\))的等效关节侧惯量。讨论:为什么虽然谐波减速器电机本身更小更轻(\(I_m\) 更小),但等效关节侧惯量反而更大?

练习 62.2.2:ANYmal 选择了 50:1 减速比的 SEA,而 MIT Cheetah 选择了 6:1 的 QDD。从以下三个维度比较这两种方案的权衡:(a) 力控精度 (b) 最大关节速度 (c) 抗冲击能力。如果你要设计一个新的四足机器人,你会选择哪种方案?为什么?

练习 62.2.3(编程):写一个 Python 脚本,绘制减速比 \(N\) 从 1 到 100 变化时以下量的变化曲线:(a) 关节侧扭矩 \(\tau_j = N \cdot \eta \cdot K_t \cdot I_{\max}\)(假设 \(\eta = 0.95 - 0.005N\)\(K_t = 0.1\) Nm/A,\(I_{\max} = 20\) A)(b) 等效关节侧惯量 \(I_{\text{eff}} = N^2 I_m\)\(I_m = 0.001\))(c) 力控带宽的近似值 \(\omega_{\text{bw}} = \sqrt{K_p / I_{\text{eff}}}\)\(K_p = 100\))。找到三者最优折衷的 \(N\) 值范围。


62.3 电机控制基础 ⭐⭐

动机:从"给电机一个扭矩指令"到"电机实际输出该扭矩"

本节解决的问题:当你在 WBC 中算出 \(\tau = 10\) Nm 并发送给电机驱动器时,驱动器内部发生了什么?它是如何把一个"扭矩数字"变成三相电流信号来驱动 BLDC 电机旋转的?这个过程的带宽和延迟是多少?

这是腿足硬件栈中对算法工程师来说最"黑箱"的一层。大多数人把电机驱动器当作一个理想的"扭矩源"——输入扭矩指令,输出精确扭矩。但真实的驱动器有带宽限制、扭矩波纹、温度漂移等非理想特性。理解这些特性对调试真机控制器至关重要。

如果把电机当作理想扭矩源会怎样

场景:你在 WBC 中设计了一个 200 Hz 带宽的力控制器(即扭矩指令中包含 200 Hz 的分量)。在仿真中一切正常——因为仿真器中电机是理想扭矩源。部署到真机后:

  • 电机 FOC 电流环的带宽约 2-4 kHz
  • 但从 WBC 发出指令到电机执行的总链路延迟约 2-3 ms
  • 2-3 ms 纯延迟不会形成“截止频率”,但会在 200 Hz 处引入约 \(144^\circ\)--\(216^\circ\) 的相位滞后

你的 200 Hz 力控指令会因为链路延迟消耗掉大部分相位裕度,即使电流环本身足够快,也可能在闭环中振荡。解决方案:将外层力控带宽限制在通信延迟允许的范围内,或使用 Smith predictor、相位超前/前馈补偿等延迟补偿,并通过实机频响测试验证。

FOC(磁场定向控制)的基本原理

为什么需要 FOC

BLDC(Brushless DC)电机有三个相位绕组(U/V/W)。要产生恒定扭矩,需要让三相电流**精确跟踪转子的旋转位置**。这就像在旋转木马上推一个人——你必须在恰当的位置和方向施力,否则用力无效甚至反作用。

FOC 的核心思想是**坐标变换**——把实验室坐标系(三相 UVW)中复杂的时变交流控制问题,变换到**与转子同步旋转**的坐标系(d-q 坐标系)中的简单直流控制问题。

FOC 的核心步骤

  1. Clark 变换(3 相 → \(\alpha\beta\) 坐标系):把三相电流 \((i_U, i_V, i_W)\) 变换为两个正交分量 \((i_\alpha, i_\beta)\)
\[\begin{bmatrix} i_\alpha \\ i_\beta \end{bmatrix} = \frac{2}{3}\begin{bmatrix} 1 & -\frac{1}{2} & -\frac{1}{2} \\ 0 & \frac{\sqrt{3}}{2} & -\frac{\sqrt{3}}{2} \end{bmatrix} \begin{bmatrix} i_U \\ i_V \\ i_W \end{bmatrix}\]
  1. Park 变换\(\alpha\beta\)\(dq\) 坐标系):用转子角度 \(\theta_e\)(电角度)旋转到与转子同步的坐标系
\[\begin{bmatrix} i_d \\ i_q \end{bmatrix} = \begin{bmatrix} \cos\theta_e & \sin\theta_e \\ -\sin\theta_e & \cos\theta_e \end{bmatrix} \begin{bmatrix} i_\alpha \\ i_\beta \end{bmatrix}\]
  1. \(dq\) 坐标系中做 PI 控制
  2. \(i_d\) 是磁通分量(flux):通常设为 0(MTPA 控制,最大扭矩电流比)
  3. \(i_q\) 是扭矩分量(torque):直接正比于输出扭矩
\[\tau_{\text{motor}} = \frac{3}{2} \cdot p \cdot \psi_m \cdot i_q = K_t \cdot i_q\]

其中 \(p\) 是极对数,\(\psi_m\) 是永磁体磁链,\(K_t\) 是扭矩常数。

  1. 逆 Park 变换 + 逆 Clark 变换:把 PI 控制器输出的 \((v_d, v_q)\) 变回三相电压 \((v_U, v_V, v_W)\)
  2. SVPWM(空间矢量 PWM):生成实际的 PWM 信号驱动功率管

关键理解:通过 FOC,控制 BLDC 电机的扭矩**等价于控制 \(i_q\) 电流**。这是一个经典的 PI 控制问题,带宽可达 2-10 kHz。这就是为什么 QDD 电机可以做精确力控——控制电流就是控制扭矩

电机驱动器内部的控制环路

一个典型的 QDD 电机驱动器内部有**三层嵌套控制环**:

外环: 位置/扭矩环 (由用户指令驱动, ~1 kHz)
  │  ┌─── 用户可配置 ───┐
  │  │ τ_des = kp*(q_des - q) + kd*(dq_des - dq) + τ_ff │
  │  └──────────────────┘
  │         │
  │         ▼ i_q_des = τ_des / K_t
  ├── 中环: 电流环 / FOC (驱动器固件, ~20-40 kHz)
  │    │
  │    │  PI controller: i_q → v_q → SVPWM → PWM duty
  │    │
  │    └── 内环: PWM + 电流采样 (~40-100 kHz)
  │         │
  │         └── 功率管 H桥驱动 → BLDC 三相绕组
  └── 反馈: 编码器(位置) + 电流传感器(三相电流)

对于腿足控制,关键理解是

  • 用户(WBC/RL)操作的是最外环——发送 \((q_{\text{des}}, \dot{q}_{\text{des}}, k_p, k_d, \tau_{\text{ff}})\) 五个参数
  • 电流环(FOC)是驱动器固件实现的——用户通常不能修改,也不需要修改
  • 扭矩控制 = 设 \(k_p = k_d = 0\),只发 \(\tau_{\text{ff}}\):此时外环退化为直接的扭矩指令 \(i_q = \tau_{\text{ff}} / K_t\)

Unitree SDK 中的关节控制命令完美体现了这个三层结构:

\[\tau_{\text{motor}} = k_p (q_{\text{des}} - q) + k_d (\dot{q}_{\text{des}} - \dot{q}) + \tau_{\text{ff}}\]
// Unitree SDK2: joint command structure
cmd.motor_cmd()[i].q()   = q_des;    // Position target
cmd.motor_cmd()[i].dq()  = dq_des;   // Velocity target
cmd.motor_cmd()[i].kp()  = kp;       // Position gain
cmd.motor_cmd()[i].kd()  = kd;       // Velocity gain (damping)
cmd.motor_cmd()[i].tau() = tau_ff;   // Feedforward torque

// WBC pure torque mode:
cmd.motor_cmd()[i].q()   = 0.0;     // Ignored when kp=0
cmd.motor_cmd()[i].dq()  = 0.0;     // Ignored when kd=0
cmd.motor_cmd()[i].kp()  = 0.0;     // No position tracking
cmd.motor_cmd()[i].kd()  = 0.0;     // No damping
cmd.motor_cmd()[i].tau() = tau_wbc;  // Direct torque command

扭矩常数 \(K_t\) 的温度依赖性

\(K_t\) 不是一个恒定值。永磁体的磁场强度随温度升高而下降(钕铁硼磁体的温度系数约 \(-0.12\%/°C\))。这意味着:

温度 \(K_t\) 变化 对 10 Nm 指令的实际输出
25°C(标称) 基准 10.0 Nm
60°C \(-4.2\%\) 9.58 Nm
80°C \(-6.6\%\) 9.34 Nm
100°C(过热保护附近) \(-9.0\%\) 9.10 Nm

工程影响:长时间运动后电机温度升至 80°C,WBC 命令的 10 Nm 实际只输出 9.34 Nm——这 6.6% 的误差足以让状态估计器(Ch57)计算的接触力出现偏差。高性能系统需要**在线温度补偿**:\(\tau_{\text{cmd}} = \tau_{\text{des}} / (1 - \alpha_T \cdot \Delta T)\)

⚠️ 编程陷阱:WBC 纯扭矩模式下不设阻尼

错误做法:WBC 输出扭矩时设 \(k_p = 0, k_d = 0\),纯依赖 \(\tau_{\text{ff}}\)

现象:关节在不受力时自由摆动,WBC 命令的微小误差会导致关节振荡。如果 WBC 计算失败(返回零扭矩),关节完全失去控制。

根本原因:纯扭矩模式下没有任何低层阻尼来抑制振荡或提供安全保护。

正确做法:即使在 WBC 模式下,也保留一个小的 \(k_d\)(如 0.5-2.0)作为关节阻尼。这不会显著影响 WBC 的力控精度,但能提供关键的稳定性裕度:

cmd.motor_cmd()[i].kp()  = 0.0;
cmd.motor_cmd()[i].kd()  = 1.0;   // Small damping for stability
cmd.motor_cmd()[i].tau() = tau_wbc;

💡 概念误区:认为"FOC 电流环和 WBC 频率一样"

新手想法:"WBC 跑 1 kHz,电机也是 1 kHz 更新,所以两者频率匹配。"

实际上:FOC 电流环跑在 20-40 kHz(是 WBC 的 20-40 倍),每个 WBC 周期内电流环已经执行了 20-40 次迭代。这意味着电流环的响应远快于 WBC 的更新——从 WBC 的视角看,电机确实近似一个"理想扭矩源"。但从通信链路的视角看,WBC 的指令要经过 CAN/DDS 传输才能到达驱动器,这个延迟(~1-2 ms)是主要瓶颈。

关键洞察:真正限制力控带宽的不是 FOC 环,而是**通信延迟**。

练习

练习 62.3.1:解释为什么 FOC 的 Park 变换本质上是一个"坐标旋转"。提示:\(dq\) 坐标系是以电角度 \(\theta_e\) 旋转的。如果转子有 \(p\) 个极对,机械角度 \(\theta_m\) 和电角度 \(\theta_e\) 之间是什么关系?Go2 的 42 极电机有多少极对?

练习 62.3.2:一个 QDD 电机的扭矩常数 \(K_t = 0.08\) Nm/A,减速比 \(N = 6\),减速器效率 \(\eta = 0.95\)。如果最大电流 \(I_{\max} = 30\) A:(a) 关节侧峰值扭矩是多少?(b) 如果温度从 25°C 升到 80°C(\(K_t\) 下降 6.6%),峰值扭矩变为多少?(c) 为维持 45 Nm 关节扭矩,需要多大的电流?电机热损耗 \(P = I^2 R\)\(R = 0.1\ \Omega\))是多少?

练习 62.3.3(思考题):RL 策略部署到真机时,有些团队选择在驱动器层面保留 \(k_p, k_d > 0\)(PD + feedforward 模式),而不是纯扭矩模式。讨论这种做法的优缺点。从 Sim-to-Real 的角度,哪种模式更容易迁移?


62.4 通信总线 ⭐⭐

动机:指令怎样从计算机到达电机

本节解决的问题:WBC/RL 的输出是一个 12 维向量(12 个关节的扭矩/位置指令),这些数字如何**可靠地、低延迟地**从计算平台传送到 12 个分布在机器人四肢的电机驱动器?

通信总线是硬件栈中**连接软件与硬件的关键桥梁**。选择错误的总线或配置不当,会导致控制延迟不确定、丢帧、同步误差——这些问题在仿真中完全不存在,但在真机上是常见的故障源。

如果通信不可靠会怎样

场景:CAN 总线因为接触不良,偶尔丢失一帧电机反馈。状态估计器收到的关节角度突然跳变(上一帧的旧值 → 丢帧 → 下一帧的新值,中间缺了一帧),导致数值微分计算的关节速度出现尖峰。这个速度尖峰传播到 WBC 的阻尼项 \(k_d \cdot \dot{q}\),产生一个巨大的扭矩脉冲——机器人"抖"了一下。

三种主流通信方案对比

特性 CAN 总线 EtherCAT UDP (Unitree DDS)
物理层 差分信号线 (CAN_H/CAN_L) 100BASE-TX 以太网 标准以太网
带宽 1 Mbps (经典) / 5 Mbps (CAN-FD) 100 Mbps 100 Mbps - 1 Gbps
拓扑 总线型(多主) 菊花链(主从) 星型/点对点
确定性 中(优先级仲裁) 高(主站轮询) 低(无确定性保证)
典型周期 1-10 ms 0.05-1 ms 1-10 ms
最大节点数 ~127 65535 无限制
成本 中-高
代表平台 Unitree Go2/A1, Solo12 ANYmal, Talos, iCub Unitree SDK2 (上层)

CAN 总线详解

**CAN(Controller Area Network)**由 Bosch 于 1986 年为汽车行业发明。它的设计目标是在恶劣的电磁环境中提供**可靠的**多节点通信。

CAN 的工作原理

  1. 差分信号:CAN_H 和 CAN_L 两根线的电压差表示数据。差分信号对共模噪声有强抑制能力,适合电机驱动器附近的电磁干扰环境
  2. 非破坏性仲裁:多个节点同时发送时,ID 值较小的消息优先(ID 中的 0 电平"赢"过 1 电平)。输了仲裁的节点自动退让,不需要重发
  3. CRC 校验:每帧数据附带 15 位 CRC,检测传输错误

CAN 帧格式

┌─────┬──────────┬─────┬──────────────────┬──────┬──────┬──────┐
│ SOF │ ID(11b)  │ RTR │ Data (0-8 bytes) │ CRC  │ ACK  │ EOF  │
└─────┴──────────┴─────┴──────────────────┘──────┴──────┴──────┘

经典 CAN 的数据段最大 8 字节。一个电机指令通常包含 \((q_{\text{des}}, \dot{q}_{\text{des}}, k_p, k_d, \tau_{\text{ff}})\) 五个参数,每个用 16 位定点数编码——刚好 10 字节,需要用 CAN-FD(64 字节数据段)或拆成两帧。

腿足机器人的 CAN 总线配置

主控板 MCU
  ├── CAN0 ─── 电机0(Hip_Ab) ─── 电机1(Hip_Fe) ─── 电机2(Knee) [前左腿]
  ├── CAN1 ─── 电机3(Hip_Ab) ─── 电机4(Hip_Fe) ─── 电机5(Knee) [前右腿]
  ├── CAN2 ─── 电机6(Hip_Ab) ─── 电机7(Hip_Fe) ─── 电机8(Knee) [后左腿]
  └── CAN3 ─── 电机9(Hip_Ab) ─── 电机10(Hip_Fe)─── 电机11(Knee)[后右腿]

每条 CAN 总线连接 3 个电机。在 1 Mbps 带宽下,每帧(含开销)约 130 bits,3 个电机的指令 + 反馈共 6 帧 \(\times\) 130 bits = 780 bits,理论耗时 0.78 ms。加上仲裁和总线空闲时间,实际约 1-2 ms——满足 1 kHz 控制频率的要求。

Linux SocketCAN 编程

#include <linux/can.h>
#include <linux/can/raw.h>
#include <net/if.h>
#include <sys/ioctl.h>
#include <sys/socket.h>
#include <unistd.h>
#include <algorithm>
#include <cstring>

class CANInterface {
public:
    bool init(const std::string& interface_name) {
        // Create raw CAN socket
        sock_ = socket(PF_CAN, SOCK_RAW, CAN_RAW);
        if (sock_ < 0) return false;

        // Bind to specific interface (e.g., "can0")
        struct ifreq ifr;
        std::strncpy(ifr.ifr_name, interface_name.c_str(), IFNAMSIZ);
        ioctl(sock_, SIOCGIFINDEX, &ifr);

        struct sockaddr_can addr;
        addr.can_family = AF_CAN;
        addr.can_ifindex = ifr.ifr_ifindex;
        bind(sock_, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));

        return true;
    }

    bool sendMotorCommand(uint32_t motor_id,
                          float q_des, float dq_des,
                          float kp, float kd, float tau_ff) {
        struct can_frame frame;
        frame.can_id = 0x100 + motor_id;  // Command CAN ID
        frame.can_dlc = 8;

        // Classic CAN has only 8 payload bytes. This example uses 16-bit
        // q/dq/tau and coarse 8-bit kp/kd. If kp/kd need full precision,
        // send them in another frame, configure them out-of-band, or use
        // CAN-FD with a larger payload.
        int16_t q_raw   = static_cast<int16_t>(q_des * 1000.0f);
        int16_t dq_raw  = static_cast<int16_t>(dq_des * 100.0f);
        int16_t tau_raw = static_cast<int16_t>(tau_ff * 100.0f);
        std::memcpy(&frame.data[0], &q_raw, sizeof(q_raw));
        std::memcpy(&frame.data[2], &dq_raw, sizeof(dq_raw));
        std::memcpy(&frame.data[4], &tau_raw, sizeof(tau_raw));
        frame.data[6] = static_cast<uint8_t>(std::clamp(kp, 0.0f, 255.0f));
        frame.data[7] = static_cast<uint8_t>(std::clamp(kd, 0.0f, 255.0f));

        return write(sock_, &frame, sizeof(frame)) == sizeof(frame);
    }

private:
    int sock_ = -1;
};

Unitree Go2 的通信架构

Unitree Go2 的通信架构比纯 CAN 更复杂,它是一个**分层通信系统**:

┌──────────────────────────────────┐
│  用户计算平台 (Jetson Orin NX)    │
│  unitree_sdk2 / ROS2             │
└────────────┬─────────────────────┘
             │ CycloneDDS (UDP multicast)
             │ 以太网 / WiFi
┌──────────────────────────────────┐
│  主控板 (Motion Control Board)    │
│  低级别安全控制 + 电机协调         │
└────────────┬─────────────────────┘
             │ CAN 总线 (4 条)
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ 前左  │ │ 前右  │ │ 后左  │ │ 后右  │
│3电机  │ │3电机  │ │3电机  │ │3电机  │
└──────┘ └──────┘ └──────┘ └──────┘

上层(用户→主控板)使用 CycloneDDS(一种 DDS 实现,与 ROS2 的默认 DDS 兼容)通过 UDP 传输。下层(主控板→电机)使用 CAN 总线。用户不直接操作 CAN——而是通过 unitree_sdk2 的 API 间接控制。

⚠️ 编程陷阱:DDS 的 QoS 配置不当导致丢帧

错误做法:使用默认的 DDS QoS(Reliability = RELIABLE)发送高频控制指令。

现象:当网络出现瞬时拥塞时,RELIABLE 模式会重传丢失的帧,导致后续帧排队等待。控制指令到达电机时已经过时,产生延迟尖峰。

根本原因:实时控制需要的是"最新的数据",不是"所有数据都不丢"。一个 2 ms 前的扭矩指令没有任何价值——电机状态已经变了。

正确做法:使用 BEST_EFFORT + 小队列深度(History Depth = 1):

// DDS QoS for real-time control
rclcpp::QoS qos(1);  // History depth = 1
qos.reliability(RMW_QOS_POLICY_RELIABILITY_BEST_EFFORT);
qos.durability(RMW_QOS_POLICY_DURABILITY_VOLATILE);

🧠 思维陷阱:认为"UDP 没有确定性所以不能用于实时控制"

新手想法:"UDP 不保证送达,所以不能用于机器人控制。应该用 TCP 或 EtherCAT。"

实际上:Unitree 的所有产品都在用 UDP (DDS over UDP) 做上层通信,MIT Cheetah 用 LCM(也是基于 UDP multicast)。关键不在于 UDP 本身是否"确定性",而在于**整体链路的延迟是否满足要求**。在局域网环境下(计算平台和主控板之间只隔一根网线),UDP 的延迟非常稳定(<0.5 ms),丢包率极低(<0.001%)。偶尔的丢包通过**控制器的鲁棒性**来应对——WBC 使用上一帧的状态、电机驱动器有内置安全超时。

正确思维:通信协议的选择要看**具体部署环境和延迟需求**,而不是教条地比较理论特性。

练习

练习 62.4.1:计算在 1 Mbps CAN 总线上传输以下数据需要的时间:(a) 1 帧标准 CAN 帧(11 位 ID + 8 字节数据 + 开销 ≈ 130 bits)(b) 12 个电机的命令和反馈(24 帧)。讨论:CAN 1 Mbps 的带宽是否足够支撑 1 kHz 控制频率?

练习 62.4.2(编程):使用虚拟 CAN 接口测试 SocketCAN 编程。在 Linux 上执行以下步骤:

sudo modprobe vcan
sudo ip link add dev vcan0 type vcan
sudo ip link set up vcan0
然后编写一个 C++ 程序:(a) 每 1 ms 发送一帧 CAN 数据 (b) 在另一个线程中接收并打印 (c) 统计往返延迟。

练习 62.4.3(思考题):如果你要设计一个新的 12 自由度四足机器人,在以下三种场景下你会选择什么通信方案?(a) 低成本研究平台(预算 <5 万元)(b) 高性能工业巡检机器人(预算 <50 万元)(c) 人形机器人(30+ 自由度,预算无限制)


62.5 EtherCAT 详解 ⭐⭐⭐

动机:为什么工业级腿足机器人选择 EtherCAT

本节解决的问题:CAN 总线够用,为什么 ANYmal、Talos、iCub 等工业级平台还要用更复杂、更昂贵的 EtherCAT?EtherCAT 相比 CAN 有哪些本质优势?作为算法工程师,需要了解 EtherCAT 到什么程度?

当机器人的自由度增加(如人形机器人 30+ DOF)或控制频率要求提升到 2 kHz 以上时,CAN 总线的 1 Mbps 带宽成为瓶颈。12 个电机 \(\times\) 2 帧(cmd+fb) \(\times\) 130 bits \(\times\) 2 kHz = 6.24 Mbps——已经超过经典 CAN 的极限。即使使用 CAN-FD(5 Mbps),30 自由度人形机器人也难以满足。

EtherCAT 以 100 Mbps 带宽和极低延迟(<50 \(\mu\)s)解决了这个问题。EtherCAT 的"飞越处理"机制好比高速公路的 ETC 收费站——车辆(数据帧)不需要停下来缴费,以全速通过时射频天线自动完成扣款(从站读写数据),每个收费站仅引入约 1 微秒的延迟。相比之下,CAN 总线更像传统人工收费站,每辆车都必须停车、缴费、再启动。

如果用 CAN 做 30 自由度人形会怎样

30 自由度人形机器人需要每个控制周期传输:30 电机 \(\times\) (cmd 10 bytes + fb 10 bytes) = 600 bytes = 4800 bits。在 1 Mbps CAN 上,纯数据传输就需要 4.8 ms——加上仲裁和开销,总延迟超过 6 ms。1 kHz 控制(1 ms 周期)完全不可能。即使分 6 条 CAN 总线,每条 5 个电机,仍然紧张。而 EtherCAT 传输 600 bytes 只需约 50 \(\mu\)s——比 CAN 快 100 倍。

EtherCAT 的核心架构

EtherCAT(Ethernet for Control Automation Technology)由 Beckhoff 于 2003 年发明。它使用标准以太网物理层,但**完全重新定义了协议层**。

EtherCAT 的核心创新——"飞越处理"(Processing on the fly)

在传统以太网中,每个节点接收完整的帧 → 处理 → 发送新帧。EtherCAT 的从站不这样做——它们在帧**经过时**直接从帧中读取属于自己的数据,并把自己的数据写入帧。帧从头到尾只经过一次,不需要在每个节点停留。

主站 →─┬─→ 从站1 →─→ 从站2 →─→ ... →─→ 从站N →─┐
       │   (读取+写入)  (读取+写入)      (读取+写入)│
       └──────────────────────────────────────────┘
                     (帧返回主站)

每个从站引入的延迟: ~1 μs (硬件级)
N 个从站的总延迟: N × 1 μs + 线缆传输延迟

PDO(Process Data Object)——实时数据

PDO 是**每个周期必须交换**的实时数据。在腿足机器人中:

PDO 类型 方向 内容 大小
TxPDO (从站→主站) 反馈 位置、速度、扭矩、状态字、温度 ~20 bytes/电机
RxPDO (主站→从站) 指令 目标位置/扭矩、控制字、PD增益 ~16 bytes/电机

所有从站的 PDO 被打包在**一个以太网帧**中(最大 ~1500 bytes),一帧搞定所有电机的通信——这是 EtherCAT 高效的核心原因。

SDO(Service Data Object)——配置数据

SDO 用于**非实时的参数配置**(如设置 PID 增益、读取电机序列号、修改运行模式)。在 EtherCAT CoE 中,SDO 通常走 mailbox 通道,不是把数据塞进 PDO 帧的“空闲带宽”。因此它不应放进 1 kHz 实时控制周期;实时循环只交换 PDO,配置和诊断放在初始化或低频管理线程。

分布式时钟(Distributed Clocks, DC)

EtherCAT 的另一个关键特性是**硬件级时钟同步**。所有从站的时钟通过硬件自动同步到参考从站(通常是第一个从站),同步精度 <1 \(\mu\)s。这意味着所有电机可以在**同一时刻**执行新的指令——对于多关节协调运动至关重要。

CAN 总线没有硬件时钟同步——每个电机收到指令的时间取决于它在总线上的位置,可能相差几百微秒。

SOEM(Simple Open EtherCAT Master)

SOEM 是目前机器人领域最常用的开源 EtherCAT 主站库。它运行在用户空间(不需要内核模块),适合快速开发和调试。

#include "ethercat.h"  // SOEM header

// SOEM initialization sequence
int main() {
    // 1. Initialize SOEM on the network interface
    if (!ec_init("eth0")) {
        printf("SOEM init failed\n");
        return -1;
    }

    // 2. Scan for slaves (auto-detect all EtherCAT devices)
    int slave_count = ec_config_init(FALSE);
    printf("Found %d slaves\n", slave_count);

    // 3. Map PDOs (configure process data layout)
    ec_config_map(&IOmap);

    // 4. Configure Distributed Clocks
    ec_configdc();

    // 5. Switch all slaves to Operational state
    ec_slave[0].state = EC_STATE_OPERATIONAL;
    ec_writestate(0);

    // 6. Real-time cyclic loop
    while (running) {
        // Send and receive PDO data in one operation
        ec_send_processdata();
        ec_receive_processdata(EC_TIMEOUTRET);

        // Read motor feedback from IOmap
        for (int i = 0; i < slave_count; i++) {
            int32_t position = *(int32_t*)(ec_slave[i+1].inputs);
            int16_t torque   = *(int16_t*)(ec_slave[i+1].inputs + 4);
            // ... process feedback
        }

        // Write motor commands to IOmap
        for (int i = 0; i < slave_count; i++) {
            *(int32_t*)(ec_slave[i+1].outputs)     = target_position[i];
            *(int16_t*)(ec_slave[i+1].outputs + 4)  = target_torque[i];
        }

        // Wait for next cycle (e.g., 1 ms)
        clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &next_cycle, NULL);
    }

    ec_close();
    return 0;
}

ethercat_driver_ros2

ethercat_driver_ros2(ICube-Robotics 维护)是将 EtherCAT 与 ros2_control 集成的开源框架。它提供了一个通用的 EthercatDriver 基类,让你通过 YAML 配置文件定义 PDO 映射,而不需要直接写 SOEM 代码。

架构:

ros2_control controller_manager
EthercatDriver (HardwareInterface)
SOEM (EtherCAT Master)
EtherCAT 从站 (电机驱动器)

该项目基于 SOEM 的 vendored package,支持 ROS2 Humble/Iron/Jazzy,截至 2025 年仍在活跃维护中。

⚠️ 编程陷阱:SOEM 的 ec_receive_processdata 超时导致控制中断

错误做法:使用默认超时参数 EC_TIMEOUTRET = 2000(2000 \(\mu\)s)。

现象:偶尔出现超时(线缆接触不良、电磁干扰),ec_receive_processdata 阻塞 2 ms,控制周期被打乱。

根本原因:EtherCAT 帧的往返时间通常 <100 \(\mu\)s,2000 \(\mu\)s 的超时太长。如果帧真的丢了,等 2 ms 也没用——应该立即使用上一帧的数据继续控制。

正确做法:将超时设为合理值(如 500 \(\mu\)s),并在超时时使用上一帧数据 + 报警:

int wkc = ec_receive_processdata(500);  // 500 us timeout
if (wkc < expected_wkc) {
    // Use previous cycle's data, increment error counter
    comms_error_count_++;
    if (comms_error_count_ > 10) {
        triggerSafeStop();  // Too many consecutive errors
    }
} else {
    comms_error_count_ = 0;  // Reset on success
}

💡 概念误区:认为"EtherCAT 就是更快的以太网"

新手想法:"EtherCAT 用以太网线,所以和普通以太网差不多,只是更快。"

实际上:EtherCAT 只借用了以太网的**物理层**(线缆和插头)。协议层完全不同——你不能用普通以太网交换机连 EtherCAT 设备,不能用 TCP/IP 和 EtherCAT 从站通信,不能把 EtherCAT 设备插到你的笔电网口上当普通网络设备用。EtherCAT 需要专用的**主站软件**(SOEM / IgH / acontis)和支持 EtherCAT 的**从站硬件**。

正确理解:EtherCAT = 以太网物理层 + 完全自定义的实时协议层。

练习

练习 62.5.1:计算在 100 Mbps EtherCAT 网络上传输 30 个电机的 PDO 数据需要的时间。假设每个电机 RxPDO 16 bytes + TxPDO 20 bytes,以太网帧开销 42 bytes(前导码+帧头+CRC+帧间隙),所有 PDO 打包在一帧中。与 CAN 的结果对比。

练习 62.5.2(代码阅读):阅读 ethercat_driver_ros2 的 GitHub 仓库(https://github.com/ICube-Robotics/ethercat_driver_ros2),理解其 YAML 配置文件如何定义 PDO 映射。写一个配置文件描述一个假想的 3 自由度机器人腿(每个关节一个 EtherCAT 从站)。

练习 62.5.3(思考题):EtherCAT 的分布式时钟(DC)如何帮助改善腿足控制的性能?考虑以下场景:4 条腿的 12 个电机需要在同一时刻执行新指令。如果没有 DC(如 CAN 总线),各电机执行指令的时间可能相差多少?这对步态同步有什么影响?


62.6 传感器栈 ⭐⭐

动机:控制器需要知道"现在发生了什么"

本节解决的问题:腿足机器人上有哪些传感器?每种传感器的精度、噪声特性和标定方法是什么?如何选择合适的传感器组合?

Ch57 从状态估计算法的角度讨论了传感器——本节从**硬件角度**深入讨论传感器的选型、规格和标定。两个视角互补:Ch57 告诉你"传感器数据怎么用",本节告诉你"传感器数据从哪来、有多准、怎么保证它准"。

IMU 选型

IMU 是腿足状态估计的核心传感器。选择合适的 IMU 需要理解以下关键指标:

指标 含义 影响
噪声密度(Noise Density) 单位带宽内的噪声 RMS 直接影响短期测量精度
偏差稳定性(Bias Stability) 偏差的最小 Allan 方差 影响长期漂移速度
偏差不稳定性(Bias Instability) 静止时偏差的随机游走速率 Kalman 滤波器 Q 矩阵的关键参数
量程(Full Scale Range) 最大可测量范围 腿足冲击加速度可达 20-50g
带宽/输出数据率 输出频率 影响状态估计的预测步频率

三款代表性 IMU 的对比:

参数 ICM-42688-P (TDK) BMI088 (Bosch) ADIS16470 (ADI)
定位 消费级 / 无人机 无人机 / 机器人 工业级
陀螺仪噪声密度 2.8 mdps/\(\sqrt{\text{Hz}}\) 12 mdps/\(\sqrt{\text{Hz}}\) 1.6 mdps/\(\sqrt{\text{Hz}}\)
陀螺仪偏差稳定性 ~3 deg/hr ~8 deg/hr ~8 deg/hr
加速度计噪声密度 60 \(\mu\)g/\(\sqrt{\text{Hz}}\) 175 \(\mu\)g/\(\sqrt{\text{Hz}}\) 26 \(\mu\)g/\(\sqrt{\text{Hz}}\)
加速度计量程 \(\pm\)16g \(\pm\)24g \(\pm\)40g
最大输出率 32 kHz 2 kHz 2 kHz
接口 SPI (24 MHz) / I2C SPI / I2C SPI
封装 2.5\(\times\)3\(\times\)0.91 mm LGA 3\(\times\)4.5\(\times\)0.95 mm LGA 14.6 mm\(^2\) 模块
价格(量产) ~2-3 USD ~5-8 USD ~80-120 USD
典型应用 消费级四足、手机 Unitree Go2、PX4 ANYmal、工业 AMR

选型建议

  • 低成本研究平台:ICM-42688-P 是性价比最高的选择。陀螺仪噪声密度 2.8 mdps/\(\sqrt{\text{Hz}}\) 对于有腿部运动学校正的状态估计器(Ch57)足够。Go2 level 的四足大多使用类似级别的 MEMS IMU
  • 高性能工业平台:ADIS16470 的加速度计量程 \(\pm\)40g 能覆盖腿足着地冲击,陀螺仪噪声更低。适合精度要求高的工业巡检机器人
  • 人形机器人:可能需要多 IMU 融合(胸部 + 骨盆 + 脚部各一个),单个 IMU 使用 ICM-42688 或 BMI088 即可

关节编码器

关节编码器测量关节的旋转角度。腿足机器人常用两种类型:

增量式编码器(Incremental Encoder): - 输出脉冲信号,需要上电时归零(homing) - 精度高:可达 4096-65536 CPR(每圈脉冲数) - 成本低 - 缺点:上电后不知道当前位置,断电后丢失位置信息

绝对式编码器(Absolute Encoder): - 直接输出当前绝对角度(通常 12-14 位,分辨率 ~0.02-0.09 deg) - 上电即知位置,无需归零 - 缺点:精度略低于高端增量式,成本较高

为什么腿足机器人偏好绝对式编码器

腿足机器人需要**上电后立即知道当前关节位置**——不像工业机械臂可以慢慢做 homing 运动。四足机器人可能在任意姿态下启动(侧躺、趴着、被翻倒),必须在启动瞬间就知道 12 个关节的精确位置,才能计算安全的站立轨迹。

磁编码器(如 AS5048A、AS5600)是腿足领域最常用的绝对式编码器:

参数 AS5048A AS5600
分辨率 14 位 (16384 步/圈) 12 位 (4096 步/圈)
精度 \(\pm\)0.05 deg \(\pm\)0.15 deg
接口 SPI / PWM I2C / PWM
特点 可测绝对多圈 低成本
价格 ~$5 ~$1.5

Go2 使用集成在电机模组内部的磁编码器,通过检测永磁体的磁场方向来确定角度。编码器是在电机侧、减速器输出侧,还是经过驱动板融合后的关节角字段,取决于具体模组和 SDK 定义;控制代码应把 SDK 的 q 明确标注为“关节侧角度字段”,并通过零位标定和外部量具验证,而不是仅凭编码器安装位置推断精度。

接触传感器

接触检测是腿足控制的关键环节——Ch57 已经讨论了接触检测算法,本节关注硬件层面。

方案 1:足端力传感器

  • 原理:压阻式或电容式力传感器安装在脚底
  • 精度:法向力精度 ~1 N
  • 优点:直接测量,响应快
  • 缺点:增加重量和复杂度,可能被碎石损坏
  • 代表:Unitree Go2 SDK 暴露 foot_force / foot_force_est 字段;字段来源和量纲需要按机型标定确认

方案 2:本体感受接触检测(Proprioceptive Contact Detection)

  • 原理:通过电机电流/扭矩和关节运动学反推接触力
  • 方法:\(\hat{F}_{\text{contact}} = J^{-T}(\tau_{\text{measured}} - \tau_{\text{gravity}} - \tau_{\text{inertia}})\)
  • 优点:无需额外传感器,利用 QDD 电机的高反向驱动性
  • 缺点:精度受限于电机扭矩估计精度(~5-10%)
  • 代表:MIT Mini Cheetah、ANYmal(主要方法)

方案 3:六维力/力矩传感器(6-axis F/T sensor)

  • 原理:测量完整的 6 维力/力矩(\(F_x, F_y, F_z, M_x, M_y, M_z\)
  • 精度:极高(<0.1% FS)
  • 缺点:昂贵(3000-10000 USD/个)、重、脆弱
  • 应用:人形机器人脚踝(如 Atlas)、学术研究

IMU 标定

IMU 标定是确保状态估计精度的关键步骤。Ch57 已经讨论了标定的滤波层面,本节补充硬件层面的标定方法。

静态标定(Static Calibration)——最简单也最常用

struct IMUCalibration {
    Eigen::Vector3d gyro_bias;   // Gyroscope bias (rad/s)
    Eigen::Vector3d accel_bias;  // Accelerometer bias (m/s^2)

    static IMUCalibration calibrateStatic(
            const std::vector<Eigen::Vector3d>& gyro_samples,
            const std::vector<Eigen::Vector3d>& accel_samples,
            int num_samples = 3000) {  // 30 seconds at 100 Hz
        IMUCalibration cal;

        // Gyro bias: average of readings when stationary
        // (should be zero, any nonzero is bias)
        cal.gyro_bias = Eigen::Vector3d::Zero();
        for (const auto& g : gyro_samples) {
            cal.gyro_bias += g;
        }
        cal.gyro_bias /= static_cast<double>(gyro_samples.size());

        // Accel bias: average minus gravity
        // When stationary, accel should read [0, 0, 9.81] in body frame
        // (assuming robot is level)
        Eigen::Vector3d accel_mean = Eigen::Vector3d::Zero();
        for (const auto& a : accel_samples) {
            accel_mean += a;
        }
        accel_mean /= static_cast<double>(accel_samples.size());

        Eigen::Vector3d gravity_body(0.0, 0.0, 9.81);  // Assuming level
        cal.accel_bias = accel_mean - gravity_body;

        return cal;
    }
};

温度标定——高精度应用需要:

IMU 的偏差随温度变化(温度系数 ~0.01 deg/s/degC for 陀螺仪)。方法是在不同温度下(通过加热/冷却)重复静态标定,建立偏差-温度曲线:

\[b_g(T) = b_{g,0} + \alpha_{g} \cdot (T - T_0) + \beta_{g} \cdot (T - T_0)^2\]

在运行时根据当前温度实时补偿偏差。

⚠️ 编程陷阱:标定时机器人没有放水平

错误做法:在不平的桌面上做 IMU 静态标定。

现象:标定后的加速度计偏差明显偏大,状态估计器的 roll/pitch 初值有几度的误差。

根本原因:静态标定假设重力方向沿 body frame 的 \(z\) 轴(\(\boldsymbol{g}_b = [0, 0, 9.81]^T\))。如果机器人倾斜 \(\alpha\) 度,重力在 \(x/y\) 轴上有分量 \(9.81 \sin\alpha\),这个分量被错误地计入了 accel_bias。

正确做法:(a) 使用水平仪确保机器人水平(\(< 0.5\) deg);(b) 或者不假设水平,而是用加速度计读数**估计初始 roll/pitch**,然后计算重力在 body frame 中的投影。

💡 概念误区:认为"编码器精度就是关节角度精度"

新手想法:"磁编码器精度 \(\pm\)0.05 deg,所以关节角度精度也是 \(\pm\)0.05 deg。"

实际上:编码器测量的是**编码器安装位置**的角度。如果编码器在减速器输出端,直接测量关节角度——精度确实是 \(\pm\)0.05 deg。但如果编码器在电机侧(减速器输入端),需要除以减速比才是关节角度,此时分辨率提高 \(N\) 倍,但**减速器背隙**成为主要误差源。行星齿轮的背隙约 0.5-2 deg(关节侧),远大于编码器精度。

关键:关节角度精度 = min(编码器精度, 减速器背隙)。高端 QDD 电机(如 T-Motor AKE80)使用高精度行星减速器,背隙可控制在 ~9 arcmin (0.15 deg)。

练习

练习 62.6.1:使用 Allan 方差分析 IMU 噪声特性。给定一段 IMU 静止录制数据(或自行录制),用 Python 计算陀螺仪的 Allan 偏差曲线,从中提取角度随机游走(ARW)和偏差不稳定性(Bias Instability)。

练习 62.6.2:比较有足端力传感器和无足端力传感器两种接触检测方案。分析:(a) 检测延迟(力传感器 vs 电流反推)(b) 误检率(在粗糙地面上)(c) 对状态估计精度的影响。如果预算有限,你会选择装力传感器还是用本体感受方案?

练习 62.6.3(编程):写一个 IMU 静态标定程序。输入:30 秒的 IMU 原始数据(陀螺仪 + 加速度计)。输出:陀螺仪三轴偏差、加速度计三轴偏差、初始 roll/pitch 角度。用你自己的手机 IMU 数据测试。


62.7 Unitree SDK 剖析 ⭐⭐

动机:如何用代码控制一台真实的四足机器人

本节解决的问题:Unitree SDK2 的架构是什么?HighLevel 和 LowLevel 模式有什么区别?如何安全地用 SDK 控制 Go2 的 12 个关节?有哪些安全机制防止你把机器人弄坏?

unitree_sdk2 是 Unitree 为 Go2/B2/H1/G1 等机器人提供的官方开发工具包。它基于 CycloneDDS 实现通信,提供 C++ 和 Python 两种接口。理解 SDK 的架构和安全机制是从仿真到真机部署的必经之路。

如果不了解 SDK 安全机制会怎样

真实案例:一位研究生第一次用 unitree_sdk2 控制 Go2,他写了一个程序让所有 12 个关节同时移到目标位置。但他不知道 SDK 有**指令超时保护**——当 SDK 在 250 ms 内没有收到新指令时,电机会自动切换到阻尼模式。他的程序在初始化阶段花了 500 ms 建立 DDS 连接,结果电机先收到第一帧指令后,在等待第二帧的 250 ms 内触发了超时保护,四条腿的电机突然变软,机器人从桌子上"坐"了下来。

SDK2 架构

unitree_sdk2
├── unitree_sdk2/              # Core C++ library
│   ├── include/
│   │   ├── unitree/robot/channel/    # DDS communication
│   │   │   ├── channel_publisher.hpp
│   │   │   ├── channel_subscriber.hpp
│   │   │   └── channel_factory.hpp
│   │   ├── unitree/idl/             # IDL message definitions
│   │   │   ├── go2/
│   │   │   │   ├── LowCmd_.hpp       # Low-level motor commands
│   │   │   │   ├── LowState_.hpp     # Low-level motor feedback
│   │   │   │   ├── HighCmd_.hpp      # High-level body commands
│   │   │   │   └── HighState_.hpp    # High-level body state
│   │   │   └── hg/                  # Humanoid (H1/G1) messages
│   │   └── unitree/common/          # Utilities
│   └── example/
│       ├── go2/
│       │   ├── low_level/           # Direct motor control
│       │   └── high_level/          # Body-level control
│       └── b2/
└── unitree_sdk2_python/       # Python bindings (pybind11)

HighLevel vs LowLevel 模式

特性 HighLevel 模式 LowLevel 模式
控制粒度 整体运动(速度、方向) 单个关节电机
输入 线速度 \(v_x, v_y\),角速度 \(\omega_z\) 12 个 \((q, \dot{q}, k_p, k_d, \tau)\)
频率 ~100-500 Hz ~500-1000 Hz
内置算法 步态、平衡、状态估计 无(用户全权控制)
安全性 高(Unitree 内置保护) 低(用户负责安全)
用途 导航、遥控、简单行为 WBC/RL 部署、研究
DDS Topic rt/api/sport/request rt/lowcmd / rt/lowstate

对于部署 WBC 或 RL 控制器,必须使用 LowLevel 模式——因为你需要直接控制每个关节的扭矩/位置。

LowLevel 控制完整示例

下面代码采用 Unitree SDK2 当前 Go2 示例中的 unitree_go::msg::dds_ 命名空间。示例保留了一个刻意标出的并发简化:回调直接写数组,生产实现应改为后文 ros2_control 示例中的快照/三缓冲方案。

#include <unitree/robot/channel/channel_publisher.hpp>
#include <unitree/robot/channel/channel_subscriber.hpp>
#include <unitree/idl/go2/LowCmd_.hpp>
#include <unitree/idl/go2/LowState_.hpp>
#include <unitree/dds_wrapper/common/crc.h>
#include <chrono>
#include <thread>
#include <atomic>
#include <cmath>
#include <iostream>
#include <functional>

using namespace unitree::robot;

// Joint indices for Go2 (4 legs x 3 joints)
// FR=FrontRight, FL=FrontLeft, RR=RearRight, RL=RearLeft
// 0: FR_hip_ab, 1: FR_hip_fe, 2: FR_knee
// 3: FL_hip_ab, 4: FL_hip_fe, 5: FL_knee
// 6: RR_hip_ab, 7: RR_hip_fe, 8: RR_knee
// 9: RL_hip_ab, 10: RL_hip_fe, 11: RL_knee

class Go2LowLevelController {
public:
    Go2LowLevelController() {
        // Initialize DDS communication
        ChannelFactory::Instance()->Init(0, "eth0");

        // Create publisher and subscriber
        cmd_pub_.reset(new ChannelPublisher<unitree_go::msg::dds_::LowCmd_>(
            "rt/lowcmd"));
        state_sub_.reset(new ChannelSubscriber<unitree_go::msg::dds_::LowState_>(
            "rt/lowstate"));

        cmd_pub_->InitChannel();
        state_sub_->InitChannel(
            std::bind(&Go2LowLevelController::stateCallback, this,
                      std::placeholders::_1),
            1);  // History depth = 1
    }

    void stateCallback(const void* msg) {
        const auto& state =
            *static_cast<const unitree_go::msg::dds_::LowState_*>(msg);

        // Teaching shortcut: this direct write has a data race with run().
        // Production code should use a snapshot, SPSC queue, or triple buffer.
        for (int i = 0; i < 12; ++i) {
            joint_pos_[i] = state.motor_state()[i].q();
            joint_vel_[i] = state.motor_state()[i].dq();
            joint_tau_[i] = state.motor_state()[i].tau_est();
        }

        // IMU data
        for (int i = 0; i < 4; ++i)
            imu_quat_[i] = state.imu_state().quaternion()[i];
        for (int i = 0; i < 3; ++i) {
            imu_gyro_[i] = state.imu_state().gyroscope()[i];
            imu_accel_[i] = state.imu_state().accelerometer()[i];
        }
    }

    void run() {
        running_ = true;

        // Safety: first read current position and hold it
        std::this_thread::sleep_for(std::chrono::milliseconds(500));
        double init_pos[12];
        for (int i = 0; i < 12; ++i) {
            init_pos[i] = joint_pos_[i];
        }

        auto next_time = std::chrono::steady_clock::now();
        const auto period = std::chrono::microseconds(2000);  // 500 Hz

        while (running_) {
            unitree_go::msg::dds_::LowCmd_ cmd{};

            for (int i = 0; i < 12; ++i) {
                // Hold initial position with moderate PD gains
                cmd.motor_cmd()[i].mode() = 0x01;  // FOC mode
                cmd.motor_cmd()[i].q()    = init_pos[i];
                cmd.motor_cmd()[i].dq()   = 0.0;
                cmd.motor_cmd()[i].kp()   = 30.0;   // Moderate stiffness
                cmd.motor_cmd()[i].kd()   = 5.0;    // Moderate damping
                cmd.motor_cmd()[i].tau()  = 0.0;     // No feedforward
            }

            // CRC protection (required by Go2)
            cmd.crc() = computeCRC(cmd);

            cmd_pub_->Write(cmd);

            next_time += period;
            std::this_thread::sleep_until(next_time);
        }
    }

    void stop() { running_ = false; }

private:
    uint32_t computeCRC(unitree_go::msg::dds_::LowCmd_& cmd) {
        // SDK2 also provides wrappers that fill CRC internally. If using raw
        // LowCmd_, keep this calculation exactly consistent with your SDK.
        return crc32_core(reinterpret_cast<uint32_t*>(&cmd),
                          (sizeof(unitree_go::msg::dds_::LowCmd_) >> 2) - 1);
    }

    std::unique_ptr<ChannelPublisher<unitree_go::msg::dds_::LowCmd_>> cmd_pub_;
    std::unique_ptr<ChannelSubscriber<unitree_go::msg::dds_::LowState_>> state_sub_;

    double joint_pos_[12]{}, joint_vel_[12]{}, joint_tau_[12]{};
    double imu_quat_[4]{}, imu_gyro_[3]{}, imu_accel_[3]{};
    std::atomic<bool> running_{false};
};

⚠️ 并发陷阱:上述回调直接写入共享数组,控制线程同时读取——这是**数据竞争(data race,C++ 标准下的未定义行为)**。正确做法是使用双缓冲/三缓冲、SPSC 队列,或在非实时线程中做 DDS 接收后把快照发布给实时循环。不要把 std::mutex 直接放进 1 kHz 实时路径。

安全机制

Unitree SDK 内置了多层安全保护,理解这些机制对于避免损坏机器人至关重要:

安全机制 触发条件 行为 注意
指令超时 ~250 ms 无新指令 电机切入阻尼模式 确保控制循环稳定运行
CRC 校验 CRC 字段与数据不匹配 指令被丢弃 每帧必须正确计算 CRC
扭矩限制 \(\|\tau\| >\) 扭矩上限 截断到上限 上限因关节位置不同
位置限制 \(q\) 超出关节运动范围 电机进入保护模式 膝关节负角会导致结构碰撞
温度保护 温度 > 阈值(~80 degC) 降额或关闭电机 长时间站立要注意
电流保护 电流超过安全阈值 立即切断 不可恢复,需重启

从 HighLevel 切换到 LowLevel 的安全流程

  1. 首先在 HighLevel 模式下让机器人站稳
  2. 读取当前所有关节角度(通过 rt/lowstate
  3. 切换到 LowLevel 模式时,第一帧指令必须是当前角度 + 适中的 PD 增益("hold position")
  4. 确认控制稳定后,再逐渐过渡到你的控制器输出

⚠️ 编程陷阱:LowLevel 模式下忘记设 CRC

错误做法:发送 LowCmd 时 CRC 字段留零。

现象:指令全部被主控板丢弃,电机没有任何响应,像是"SDK 连接失败"。

根本原因:Go2 的主控板对每帧指令做 CRC 校验。CRC 不正确的帧直接丢弃,不会有任何错误提示。

正确做法:使用 SDK 提供的 CRC 计算函数。不同 SDK 版本的 CRC 算法可能不同,务必使用对应版本的实现。

💡 概念误区:认为"Python SDK 和 C++ SDK 性能一样"

新手想法:"unitree_sdk2_python 用起来更方便,RL 策略也是 Python,全用 Python 就好。"

实际上:Python SDK 通过 pybind11 封装 C++ 库,DDS 通信本身性能相同。但 Python 的 GIL 和垃圾回收会引入不确定的延迟——在 500 Hz 控制循环中,Python 线程可能因 GC 暂停 1-5 ms。对于 HighLevel 控制和实验原型,Python 够用;对于 LowLevel 部署,生产环境用 C++

折中方案:RL 推理用 C++ (LibTorch/ONNX Runtime,Ch64 详述),控制循环用 C++,只在数据记录和可视化时用 Python。

练习

练习 62.7.1(代码阅读):阅读 unitree_sdk2 的 example/go2/low_level/ 目录下的示例代码。回答:(a) 控制循环的频率是多少?(b) 哪些安全检查在发送指令前执行?(c) 如果断开网络连接,电机会怎么反应?

练习 62.7.2(编程):基于上面的示例代码,修改 run() 方法,让 Go2 的前右腿膝关节(index 2)做正弦运动:\(q_{\text{des}}(t) = q_0 + 0.3 \sin(2\pi \cdot 0.5 \cdot t)\)。注意:(a) 幅度不能太大(避免自碰撞)(b) 必须先检查关节运动范围 (c) 必须平滑过渡(不要从静止突然跳到正弦)。

练习 62.7.3(思考题):比较 Unitree SDK2(基于 DDS)和 MIT Cheetah 的 LCM 通信。从以下角度分析:(a) 开发便捷性 (b) 跨平台兼容性 (c) 与 ROS2 的集成难度 (d) 通信延迟。为什么 MIT 当时选择 LCM 而不是 DDS?


62.8 ros2_control 硬件接口 ⭐⭐

动机:让你的控制器适配任意硬件

本节解决的问题:你在 Ch53 实现的 WBC 控制器可以在仿真中运行。但如何让**同一个控制器代码**既能在 Gazebo 仿真中运行,也能在 Unitree Go2 真机上运行,还能在未来切换到另一个四足平台时无需修改?答案是 ros2_control 的硬件抽象层。

Ch61 介绍了 ros2_control 的框架结构。本节深入实现一个完整的 HardwareInterface,将 Unitree SDK 接入 ros2_control 框架。

如果不做硬件抽象会怎样

场景:你的 WBC 代码直接调用 Unitree SDK 读写电机数据。一年后,实验室购入了新的机器人平台(比如 Agility Robotics 的 Digit),使用完全不同的 SDK。你需要:

  1. 在 WBC 代码的所有位置查找并替换 Unitree SDK 调用 → 容易遗漏
  2. 重新测试所有代码路径 → 工作量巨大
  3. 如果要同时支持两个平台 → 代码充满 #ifdef UNITREE / #ifdef DIGIT 条件编译 → 可维护性灾难

ros2_control 的解决方案:WBC 只和 StateInterface / CommandInterface 交互——这是统一的抽象接口。每个硬件平台提供自己的 HardwareInterface 实现(UnitreeHWDigitHW 等),负责将统一接口映射到具体的 SDK 调用。切换平台只需更换一个 YAML 配置文件。

HardwareInterface 实现

以下是一个简化但完整的 Unitree Go2 硬件接口实现:

// unitree_hw.hpp
#pragma once

#include <hardware_interface/system_interface.hpp>
#include <hardware_interface/types/hardware_interface_type_values.hpp>
#include <rclcpp/rclcpp.hpp>
#include <rclcpp_lifecycle/state.hpp>

// Unitree SDK headers
#include <unitree/robot/channel/channel_publisher.hpp>
#include <unitree/robot/channel/channel_subscriber.hpp>
#include <unitree/idl/go2/LowCmd_.hpp>
#include <unitree/idl/go2/LowState_.hpp>

namespace unitree_hw {

class UnitreeHW : public hardware_interface::SystemInterface {
public:
    // ---- Lifecycle callbacks ----

    hardware_interface::CallbackReturn on_init(
            const hardware_interface::HardwareInfo& info) override {
        if (hardware_interface::SystemInterface::on_init(info) !=
            hardware_interface::CallbackReturn::SUCCESS) {
            return hardware_interface::CallbackReturn::ERROR;
        }

        // Parse URDF parameters
        num_joints_ = info_.joints.size();  // Should be 12
        joint_positions_.resize(num_joints_, 0.0);
        joint_velocities_.resize(num_joints_, 0.0);
        joint_efforts_.resize(num_joints_, 0.0);
        joint_position_cmds_.resize(num_joints_, 0.0);
        joint_velocity_cmds_.resize(num_joints_, 0.0);
        joint_effort_cmds_.resize(num_joints_, 0.0);
        joint_kp_.resize(num_joints_, 0.0);
        joint_kd_.resize(num_joints_, 0.0);

        // 预分配 shadow buffers(DDS callback 写入,read() 读取)
        shadow_positions_.resize(num_joints_, 0.0);
        shadow_velocities_.resize(num_joints_, 0.0);
        shadow_efforts_.resize(num_joints_, 0.0);

        return hardware_interface::CallbackReturn::SUCCESS;
    }

    hardware_interface::CallbackReturn on_configure(
            const rclcpp_lifecycle::State&) override {
        // Initialize Unitree SDK
        unitree::robot::ChannelFactory::Instance()->Init(0, "eth0");

        cmd_pub_ = std::make_unique<
            unitree::robot::ChannelPublisher<
                unitree_go::msg::dds_::LowCmd_>>("rt/lowcmd");
        state_sub_ = std::make_unique<
            unitree::robot::ChannelSubscriber<
                unitree_go::msg::dds_::LowState_>>("rt/lowstate");

        cmd_pub_->InitChannel();
        state_sub_->InitChannel(
            [this](const void* msg) { this->onStateReceived(msg); }, 1);

        return hardware_interface::CallbackReturn::SUCCESS;
    }

    // ---- Export interfaces ----

    std::vector<hardware_interface::StateInterface>
    export_state_interfaces() override {
        std::vector<hardware_interface::StateInterface> interfaces;
        for (size_t i = 0; i < num_joints_; ++i) {
            interfaces.emplace_back(info_.joints[i].name,
                hardware_interface::HW_IF_POSITION, &joint_positions_[i]);
            interfaces.emplace_back(info_.joints[i].name,
                hardware_interface::HW_IF_VELOCITY, &joint_velocities_[i]);
            interfaces.emplace_back(info_.joints[i].name,
                hardware_interface::HW_IF_EFFORT, &joint_efforts_[i]);
        }
        return interfaces;
    }

    std::vector<hardware_interface::CommandInterface>
    export_command_interfaces() override {
        std::vector<hardware_interface::CommandInterface> interfaces;
        for (size_t i = 0; i < num_joints_; ++i) {
            interfaces.emplace_back(info_.joints[i].name,
                hardware_interface::HW_IF_POSITION, &joint_position_cmds_[i]);
            interfaces.emplace_back(info_.joints[i].name,
                hardware_interface::HW_IF_VELOCITY, &joint_velocity_cmds_[i]);
            interfaces.emplace_back(info_.joints[i].name,
                hardware_interface::HW_IF_EFFORT, &joint_effort_cmds_[i]);
        }
        return interfaces;
    }

    // ---- Real-time loop: read / write ----

    hardware_interface::return_type read(
            const rclcpp::Time&, const rclcpp::Duration&) override {
        // ⚠️ 生产代码必须使用 RT-safe 同步!
        // onStateReceived() 在 DDS 线程写 joint 数组,
        // read() 在 RT 线程读同一数组 → 无同步 = data race (UB)
        //
        // RT-safe 同步方案(禁止 std::mutex,会导致优先级反转):
        //   方案1: std::atomic_flag + memcpy snapshot(下方演示)
        //     ⚠️ 注意:atomic_flag 的 test_and_set 循环是忙等待(spin-lock),
        //     在竞争激烈时会产生无界自旋和抖动。仅适用于临界区极短
        //     (<1 us)且竞争线程数<=2的场景。生产环境优先考虑方案2。
        //   方案2: triple buffer(Ch55.7 的 MRT 架构:三槽位原子交换,写不阻塞读取最新值,最优方案)
        //   方案3: lock-free SPSC queue

        // 方案1 示例:原子标志 + snapshot(忙等待,见上方注意事项)
        while (state_writing_.test_and_set(std::memory_order_acquire)) {}
        for (size_t i = 0; i < num_joints_; ++i) {
            joint_positions_[i]  = shadow_positions_[i];
            joint_velocities_[i] = shadow_velocities_[i];
            joint_efforts_[i]    = shadow_efforts_[i];
        }
        state_writing_.clear(std::memory_order_release);

        return hardware_interface::return_type::OK;
    }

    hardware_interface::return_type write(
            const rclcpp::Time&, const rclcpp::Duration&) override {
        unitree_go::msg::dds_::LowCmd_ cmd{};

        for (size_t i = 0; i < num_joints_; ++i) {
            cmd.motor_cmd()[i].mode() = 0x01;
            cmd.motor_cmd()[i].q()    = joint_position_cmds_[i];
            cmd.motor_cmd()[i].dq()   = joint_velocity_cmds_[i];
            cmd.motor_cmd()[i].kp()   = joint_kp_[i];
            cmd.motor_cmd()[i].kd()   = joint_kd_[i];
            cmd.motor_cmd()[i].tau()  = joint_effort_cmds_[i];
        }

        cmd.crc() = computeCRC(cmd);
        cmd_pub_->Write(cmd);

        // Teaching shortcut: DDS Write may allocate or enter the kernel.
        // A production ros2_control plugin should hand off this command to
        // a non-real-time publisher thread instead of sending here directly.

        return hardware_interface::return_type::OK;
    }

private:
    void onStateReceived(const void* msg) {
        const auto& state =
            *static_cast<const unitree_go::msg::dds_::LowState_*>(msg);
        // 写入 shadow buffer,由 read() 在 RT 线程中拷贝到 state interfaces
        // ⚠️ 忙等待:若 read() 临界区耗时过长,此处 DDS 线程会自旋等待,
        // 增加 CPU 占用和抖动。生产环境建议改用 triple buffer 消除互斥。
        while (state_writing_.test_and_set(std::memory_order_acquire)) {}
        for (size_t i = 0; i < num_joints_; ++i) {
            shadow_positions_[i]  = state.motor_state()[i].q();
            shadow_velocities_[i] = state.motor_state()[i].dq();
            shadow_efforts_[i]    = state.motor_state()[i].tau_est();
        }
        state_writing_.clear(std::memory_order_release);
    }

    uint32_t computeCRC(unitree_go::msg::dds_::LowCmd_& cmd) {
        return crc32_core(
            reinterpret_cast<uint32_t*>(&cmd),
            (sizeof(unitree_go::msg::dds_::LowCmd_) >> 2) - 1);
    }

    size_t num_joints_ = 12;
    std::vector<double> joint_positions_, joint_velocities_, joint_efforts_;
    std::vector<double> joint_position_cmds_, joint_velocity_cmds_;
    std::vector<double> joint_effort_cmds_;
    std::vector<double> joint_kp_, joint_kd_;

    // Shadow buffers + atomic flag for RT-safe DDS→read() 同步
    std::vector<double> shadow_positions_, shadow_velocities_, shadow_efforts_;
    std::atomic_flag state_writing_ = ATOMIC_FLAG_INIT;

    std::unique_ptr<unitree::robot::ChannelPublisher<
        unitree_go::msg::dds_::LowCmd_>> cmd_pub_;
    std::unique_ptr<unitree::robot::ChannelSubscriber<
        unitree_go::msg::dds_::LowState_>> state_sub_;
};

}  // namespace unitree_hw

⚠️ 实时性陷阱:示例中的 cmd_pub_->Write() 仍在 write() 路径里执行 DDS 序列化和网络发送,生产实现应通过非实时线程异步发布:实时循环只写入预分配命令快照,普通线程负责 DDS 发布。这里保留同步写法是为了让硬件接口结构更容易阅读。

对应的 URDF 配置

<!-- go2.ros2_control.xacro -->
<ros2_control name="Go2System" type="system">
    <hardware>
        <plugin>unitree_hw/UnitreeHW</plugin>
        <param name="network_interface">eth0</param>
    </hardware>

    <!-- Front Right Leg -->
    <joint name="FR_hip_ab">
        <state_interface name="position"/>
        <state_interface name="velocity"/>
        <state_interface name="effort"/>
        <command_interface name="position"/>
        <command_interface name="velocity"/>
        <command_interface name="effort"/>
    </joint>
    <joint name="FR_hip_fe">
        <!-- ... same interfaces ... -->
    </joint>
    <joint name="FR_knee">
        <!-- ... same interfaces ... -->
    </joint>
    <!-- ... repeat for FL, RR, RL legs ... -->
</ros2_control>

与 legged_control 的关系

开源项目 legged_control(qm_control 的后继)正是这种架构的实战范例。它包含:

  • legged_controllers/:WBC 控制器(ros2_control Controller 插件)
  • legged_unitree_hw/:Unitree 硬件接口(UnitreeHW
  • legged_gazebo_hw/:Gazebo 仿真硬件接口

同一个 WBC 控制器,通过 YAML 配置切换不同的硬件接口:

# Real robot
controller_manager:
  ros__parameters:
    hardware_interface: "unitree_hw/UnitreeHW"
    update_rate: 500  # Hz

# Simulation
controller_manager:
  ros__parameters:
    hardware_interface: "gazebo_ros2_control/GazeboSystem"
    update_rate: 500  # Hz

⚠️ 编程陷阱:read()write() 中做内存分配

错误做法:在 read()write() 中创建临时 std::vectorstd::string

现象:控制循环偶尔出现 1-5 ms 的延迟尖峰(由 malloc 引起)。

根本原因read()write() 在 controller_manager 的实时循环中被调用(Ch61)。任何内存分配都违反了"四大禁忌"。

正确做法:所有缓冲区在 on_init()on_configure() 中预分配。read()write() 只操作预分配的成员变量。使用 EIGEN_RUNTIME_NO_MALLOC(Ch53)在开发期检测违规。

🧠 思维陷阱:认为"ros2_control 的抽象会引入性能开销"

新手想法:"多了一层抽象,性能肯定变差。直接调 SDK 更快。"

实际上:ros2_control 的 StateInterface / CommandInterface 本质上只是一个 double* 指针——指向预分配的 double 变量。读写一个 double 指针的开销 < 1 ns,完全可忽略。真正的开销在 DDS 通信和电机驱动器的处理时间,这与是否使用 ros2_control 无关。

ros2_control 的实际价值:(1) 代码复用(同一个 WBC 适配多平台)(2) 仿真/真机无缝切换 (3) controller_manager 管理多个控制器的生命周期。这些工程价值远超任何微小的性能代价。

练习

练习 62.8.1(代码阅读):精读 legged_controllegged_unitree_hw/src/UnitreeHW.cpp。回答:(a) on_activate() 中做了哪些初始化?(b) read() 如何处理 DDS 回调和实时循环之间的线程同步?(c) 是否使用了无锁数据结构?

练习 62.8.2(编程):扩展上面的 UnitreeHW 实现,添加 IMU 数据的 StateInterface。需要导出以下接口:imu/orientation.x/y/z/w(四元数)、imu/angular_velocity.x/y/zimu/linear_acceleration.x/y/z。提示:ros2_control 支持 sensor 类型的硬件组件。

练习 62.8.3(对比练习):对比 legged_control/UnitreeHW.cppfranka_ros2/franka_hardware.cpp(Franka Emika 的 ros2_control 硬件接口)。分析:(a) 两者的 read()/write() 实现有何不同?(b) Franka 使用 libfranka(实时以太网),Unitree 使用 DDS——这对硬件接口的设计有何影响?(c) 哪一个更适合作为你自己 HW 接口的参考模板?


62.9 安全与保护 ⭐

动机:如何确保你不会把机器人弄坏

本节解决的问题:在开发和部署腿足控制器的过程中,有哪些可能损坏机器人或造成人身危险的操作?如何设计多层安全保护机制来防止这些事故?

腿足机器人不同于工业机械臂——机械臂有固定基座、工作空间受限、安全围栏保护。腿足机器人在开放空间中运动,随时可能倒下、碰撞或失控。安全保护不是可选的附加功能,而是**系统设计的基础要求**。

安全保护的层次结构

安全保护应该是**多层冗余**的——每一层独立工作,任何一层失效都有下一层兜底:

┌──────────────────────────────────────────┐
│ 第 5 层:物理安全 (人工干预)               │
│ E-Stop 急停按钮, 安全绳, 测试架             │
├──────────────────────────────────────────┤
│ 第 4 层:主控板安全 (固件级)               │
│ 通信超时保护, 过流保护, 过温保护            │
├──────────────────────────────────────────┤
│ 第 3 层:驱动器安全 (电机驱动器固件)        │
│ 关节限位, 电流限制, 速度限制               │
├──────────────────────────────────────────┤
│ 第 2 层:软件安全 (你的控制器代码)          │
│ 指令合理性检查, 状态异常检测, 软限位        │
├──────────────────────────────────────────┤
│ 第 1 层:算法安全 (WBC/MPC 约束)           │
│ 扭矩约束, 接触力约束, 质心约束             │
└──────────────────────────────────────────┘

软件层安全实现

以下是一个生产级的安全检查模块:

class SafetyChecker {
public:
    struct Limits {
        double q_min, q_max;       // Joint position limits (rad)
        double dq_max;             // Joint velocity limit (rad/s)
        double tau_max;            // Joint torque limit (Nm)
        double temp_warning;       // Temperature warning threshold (degC)
        double temp_critical;      // Temperature critical threshold (degC)
    };

    SafetyChecker(const std::vector<Limits>& limits) : limits_(limits) {}

    enum class Status { OK, WARNING, CRITICAL };

    struct CheckedCommand {
        double q;
        double dq;
        double tau;
        Status status;
    };

    CheckedCommand checkCommand(int joint_idx,
                                double q_cmd, double dq_cmd, double tau_cmd,
                                double q_cur, double temp_cur) {
        const auto& lim = limits_[joint_idx];
        Status status = Status::OK;

        // 1. Position limit check (with margin)
        double margin = 0.05;  // 0.05 rad (~3 degrees) safety margin
        if (q_cmd < lim.q_min + margin || q_cmd > lim.q_max - margin) {
            RCLCPP_WARN(rclcpp::get_logger("safety"),
                "Joint %d: position cmd %.3f near limit [%.3f, %.3f]",
                joint_idx, q_cmd, lim.q_min, lim.q_max);
            q_cmd = std::clamp(q_cmd, lim.q_min + margin, lim.q_max - margin);
        }

        // 2. Velocity limit check
        if (std::abs(dq_cmd) > lim.dq_max) {
            RCLCPP_WARN(rclcpp::get_logger("safety"),
                "Joint %d: velocity cmd %.3f exceeds limit %.3f",
                joint_idx, dq_cmd, lim.dq_max);
            dq_cmd = std::clamp(dq_cmd, -lim.dq_max, lim.dq_max);
            status = Status::WARNING;
        }

        // 3. Torque limit check
        if (std::abs(tau_cmd) > lim.tau_max) {
            RCLCPP_WARN(rclcpp::get_logger("safety"),
                "Joint %d: torque cmd %.3f exceeds limit %.3f",
                joint_idx, tau_cmd, lim.tau_max);
            tau_cmd = std::clamp(tau_cmd, -lim.tau_max, lim.tau_max);
            status = Status::WARNING;
        }

        // 4. Temperature check
        if (temp_cur > lim.temp_critical) {
            RCLCPP_ERROR(rclcpp::get_logger("safety"),
                "Joint %d: temperature %.1f degC CRITICAL (limit %.1f degC)",
                joint_idx, temp_cur, lim.temp_critical);
            return {q_cmd, 0.0, 0.0, Status::CRITICAL};
        }
        if (temp_cur > lim.temp_warning) {
            RCLCPP_WARN(rclcpp::get_logger("safety"),
                "Joint %d: temperature %.1f degC warning (limit %.1f degC)",
                joint_idx, temp_cur, lim.temp_warning);
            status = Status::WARNING;
        }

        // 5. Sudden jump detection
        double dq_estimated = (q_cmd - q_cur) * 500.0;  // at 500 Hz
        if (std::abs(dq_estimated) > lim.dq_max * 2.0) {
            RCLCPP_ERROR(rclcpp::get_logger("safety"),
                "Joint %d: cmd jump too large (%.3f rad in one step)",
                joint_idx, q_cmd - q_cur);
            return {q_cur, 0.0, 0.0, Status::CRITICAL};
        }

        return {q_cmd, dq_cmd, tau_cmd, status};
    }

    // Emergency soft stop: gradually reduce gains to zero
    void triggerSoftStop(std::vector<double>& kp, std::vector<double>& kd,
                         std::vector<double>& tau_ff, int ramp_steps = 100) {
        for (size_t i = 0; i < kp.size(); ++i) {
            kp[i]     *= (1.0 - 1.0 / ramp_steps);  // Decay over ramp_steps
            kd[i]     *= (1.0 - 0.5 / ramp_steps);  // Decay slower (keep damping)
            tau_ff[i] *= (1.0 - 1.0 / ramp_steps);
        }
    }

private:
    std::vector<Limits> limits_;
};

常见硬件故障排查

现象 可能原因 排查方法 解决方案
机器人完全不动 电池没电 / SDK 未连接 / CRC 错误 检查电池电量,ping 主控板,检查 CRC 充电 / 检查网络 / 修复 CRC
单个关节不动 CAN 线松动 / 电机故障 / 超出限位 candump 检查 CAN 帧 / 读 error code 检查线缆 / 更换电机 / 调整限位
关节抖动 PD 增益过高 / 通信延迟 / 编码器噪声 降低 \(k_p\),用示波器测 CAN 延迟 调参 / 检查总线负载
机器人倒下 IMU 漂移 / 状态估计发散 / 足底打滑 录制数据回放分析 重新标定 / 调整 KF 参数
电机过热 长时间大电流 / 散热不良 / 站姿不佳 监控温度话题 优化站姿 / 增加休息间隔
偶尔"抖一下" CAN 丢帧 / DDS QoS 不当 统计通信丢帧率 降低总线负载 / 调 QoS

⚠️ 编程陷阱:安全检查放在控制器外面(不在实时循环内)

错误做法:安全检查在一个独立的低频线程中运行(如 10 Hz),而控制循环以 500 Hz 运行。

现象:控制器输出了一个危险指令,安全线程可能在 100 ms 后才发现——此时电机已经执行了 50 个危险指令。

根本原因:安全检查的频率必须**不低于**控制器的频率。

正确做法:安全检查是 write() 函数的**第一步**——在发送任何指令之前先检查。确保安全检查代码不做内存分配(满足实时约束)。

🧠 思维陷阱:认为"有了 Unitree 的内置保护就不需要自己做安全检查"

新手想法:"Unitree 主控板已经有扭矩限制和温度保护了,我的代码不需要额外检查。"

实际上:Unitree 的内置保护是**最后一道防线**,不是唯一防线。内置保护触发时通常意味着**已经出了问题**——电流保护触发会直接切断电机,机器人瞬间倒下;温度保护触发会降额,运动能力骤降。你的代码应该在问题**即将发生**时就干预(如温度 70 degC 时就降低负载,而不是等到 80 degC 触发硬件保护)。

正确思维:安全保护是多层冗余设计——你的软件层保护让 99% 的危险情况在到达硬件保护之前就被处理。

练习

练习 62.9.1:设计一个 Go2 的完整安全参数表。对 12 个关节分别设定:关节位置上下限、最大速度、最大扭矩、温度警告/危险阈值。参考 Go2 的 URDF 和官方文档。

练习 62.9.2(编程):实现一个 Watchdog Timer。规则:如果控制器在连续 3 个周期内没有发送新指令(可能是计算超时或崩溃),自动触发 soft stop。提示:用一个计数器记录"已经多少个周期没收到新指令"。

练习 62.9.3(思考题):RL 策略有时会输出"看起来正常但实际危险"的指令——比如让两条腿交叉、让膝关节反向弯曲。这些指令不违反单关节的限位,但会导致结构自碰撞。如何检测和防止这类问题?提示:self-collision checking(用碰撞几何体做前向运动学后检查)。


62.10 本章小结

知识点总结

主题 核心概念 难度
62.1 硬件全景 控制循环层次结构,从规划到电机的完整链路
62.2 驱动器与电机 QDD vs SEA vs 谐波减速器,反向驱动性,\(I_{\text{eff}} = N^2 I_m\) ⭐⭐
62.3 电机控制基础 FOC 原理,Clark/Park 变换,\(\tau = K_t \cdot i_q\),三层控制环 ⭐⭐
62.4 通信总线 CAN vs EtherCAT vs UDP,带宽/延迟/确定性对比 ⭐⭐
62.5 EtherCAT 详解 飞越处理,PDO/SDO,分布式时钟,SOEM API ⭐⭐⭐
62.6 传感器栈 IMU 选型(ICM-42688/BMI088/ADIS16470),编码器,接触传感器 ⭐⭐
62.7 Unitree SDK SDK2 架构,HighLevel/LowLevel 模式,安全机制 ⭐⭐
62.8 ros2_control HW HardwareInterface 实现,仿真/真机无缝切换 ⭐⭐
62.9 安全与保护 多层安全,故障排查,soft stop

核心公式回顾

公式 含义 所在节
\(I_{\text{eff}} = N^2 I_m\) 等效关节侧转动惯量(reflected inertia) 62.2
\(\tau = K_t \cdot i_q\) 扭矩-电流关系(FOC 核心) 62.3
\(\tau = k_p(q_d - q) + k_d(\dot{q}_d - \dot{q}) + \tau_{\text{ff}}\) Unitree 关节控制律 62.3/62.7

累积项目:本章新增模块

项目:从零构建四足站立控制器

本章新增**硬件接口层**:

Ch47: 加载 URDF → Pinocchio 正/逆运动学
Ch49: 浮动基座动力学模型
Ch53: WBC 控制器(QP 求解)
Ch57: 状态估计器(KF/InEKF)
Ch61: 实时 C++ 框架 + ros2_control Controller 插件
Ch62: ★ ros2_control HardwareInterface (UnitreeHW)  ← 本章
      ★ 安全检查模块 (SafetyChecker)  ← 本章
      ★ IMU 标定工具  ← 本章

本章完成后,你的累积项目已经具备了从算法到硬件的完整链路——理论上可以在 Go2 真机上运行。下一步 Ch63-64 将添加 RL 策略训练和部署模块。


延伸阅读

必读

资料 类型 难度 说明
Katz et al. (2019) "Mini Cheetah", ICRA 论文 ⭐⭐ QDD 驱动器设计理念,四足硬件架构的经典范例
Wensing et al. (2017) "Proprioceptive Actuator Design in the MIT Cheetah", T-RO 论文 ⭐⭐⭐ QDD 本体感受力控的理论基础,反向驱动性分析
Unitree SDK2 官方文档 (support.unitree.com) 文档 Go2/B2 开发必备,API 参考

推荐

资料 类型 难度 说明
Seok et al. (2015) "Design Principles for Energy-Efficient Legged Locomotion", T-Mech 论文 ⭐⭐⭐ 电机设计优化的系统方法论
Hutter et al. (2016) "ANYmal - a Highly Mobile and Dynamic Quadrupedal Robot", IROS 论文 ⭐⭐ SEA 方案的代表,与 QDD 形成对比
ODRI "Open Dynamic Robot Initiative" (github.com/open-dynamic-robot-initiative) 开源 ⭐⭐ 开源硬件设计,含 solo8/solo12 电机驱动器
ethercat_driver_ros2 (github.com/ICube-Robotics) 开源 ⭐⭐⭐ EtherCAT + ros2_control 集成的实战参考
SimpleFOC (simplefoc.com) 开源 ⭐⭐ 开源 FOC 库,适合理解 FOC 底层实现

进阶

资料 类型 难度 说明
EtherCAT Technology Group 官方文档 (ethercat.org) 文档 ⭐⭐⭐ EtherCAT 协议的权威规范
MJBots moteus 驱动器 (github.com/mjbots/moteus) 开源 ⭐⭐⭐ 开源 QDD 电机驱动器,T-Motor 的开源替代
"Cycloidal QDD Actuator Designs with Learning-based Torque Estimation" (arXiv 2410.16591) 论文 ⭐⭐⭐⭐ 用 ML 学习电机扭矩模型来补偿传动非理想特性

与其他章节的衔接

向前承接: - Ch57(IMU + 状态估计)→ 62.6 传感器栈从硬件角度补充了 Ch57 的传感器模型 - Ch61(实时 C++ + ros2_control)→ 62.8 的 HardwareInterface 是 Ch61 Controller 的"下层对接" - Ch53(WBC)→ 62.3 的电机控制律是 WBC 扭矩输出的"最后一站"

向后指向: - 62.7 Unitree SDK → Ch68 legged_control 精读(完整的 SDK 集成实例) - 62.8 ros2_control HW → Ch69 Mini-Legged 综合实战(真机部署) - 62.2/62.3 电机特性 → Ch64 RL 部署中的 actuator network(学习电机的非理想特性)


上一章(Ch61)搭好了实时 C++ 和 ros2_control 的"软件地基"。本章把硬件栈的每一层——从 BLDC 电机的 FOC 控制到 CAN/EtherCAT 通信,从 IMU 传感器选型到 Unitree SDK 的安全机制——逐一拆解。到这里,算法(Ch47-60)到硬件(Ch61-62)的完整链路已经建立。下一步 Ch63-64 将进入强化学习在腿足上的训练与部署——这是当前腿足控制最活跃的研究方向。Ch63 侧重 RL 策略训练(Python + IsaacLab),Ch64 侧重部署(C++ + LibTorch/ONNX),两章的衔接正是"Sim-to-Real"的核心工程挑战——而本章 62.2-62.3 讲的电机动力学,恰恰是 Sim-to-Real gap 的重要来源之一。