25伺服人型机器人
硬件配置
- 主站: Linux (MCU+RTL8309N) × 1 + 单核RT驱动
- EtherCAT 拓扑: 8 * 4芯线 (4路冗余)
- 关节驱动: 一体化关节模组 × 25 (PDO , 支持位置/力矩/阻抗模式)
- 关节分布 (25 DOF):
- 环线1 (左臂路): 腰偏航×1 + 腰俯仰×1 + 左肩×3 + 左肘×1 + 左腕×1 = 7轴
- 环线2 (右臂路): 颈偏航×1 + 右肩×3 + 右肘×1 + 右腕×1 = 6轴
- 环线3 (左腿): 左髋×3 + 左膝×1 + 左踝×2 = 6轴
- 环线4 (右腿): 右髋×3 + 右膝×1 + 右踝×2 = 6轴
- 传感器:
- 六维力传感器 × 2 (左脚/右脚, 分别挂载于环线3/环线4末端)
- IMU × 1 (躯干, 挂载于环线1, 与腰部关节相邻)
- 走线拓扑 (MCU 位于胸腔中央):
- 环线1: MCU → IMU → 腰偏航 → 腰俯仰 → ↗ 左肩 → 左肘 → 左腕 → 返回
- 环线2: MCU → 颈偏航 → ↗ 右肩 → 右肘 → 右腕 → 返回
- 环线3: MCU → ↘ 左髋 → 左膝 → 左踝 → 左脚力传感器 → 返回
- 环线4: MCU → ↘ 右髋 → 右膝 → 右踝 → 右脚力传感器 → 返回
- 网络隔离: RTL8309N 端口隔离, 4 路环线互不干扰
- 算法: Isaac Lab 离线训练 RL 策略 (ONNX部署) + 复合视觉模型触发
性能指标
- 控制周期: 500 µs (2 kHz)
- 关节总数: 25轴 (腰2 + 左臂5 + 颈1 + 右臂5 + 左腿6 + 右腿6)
- EtherCAT 节点总数: 28 (25关节模组 + 2力传感器 + 1 IMU)
- 环线负载: 8 / 6 / 7 / 7 节点
- 关节位置精度: ±0.01°
- 全身运动规划频率: 500 Hz
应用场景
基于 Linux 实时内核的人形机器人全身运动控制系统。MCU 位于胸腔中央,通过 RTL8309N 九口交换芯片(1口连 MCU、8口连4路冗余环线的收发端)驱动25个一体化关节模组。
腰部和颈部关节不单独占用环线,而是就近串入臂部环线:环线1经过腰部后上行至左臂(7轴),环线2经过颈部后上行至右臂(6轴),环线3/4分别独立下行至左/右腿(各6轴)。
每条环线末端挂载传感器(脚部力传感器或躯干IMU),线缆沿肢体内部走线。关节模组支持位置/力矩/阻抗混合控制,主站以 2kHz 频率下发 PDO 指令。
代码说明
整体架构
系统架构
系统采用异步双频率控制,策略推理线程与 PDO 伺服回调解耦:
策略网络由 Isaac Lab 在仿真环境中训练完成后,导出为 ONNX 模型部署到真实硬件。部署时不依赖 Isaac Lab,仅需 ONNX Runtime。
推理线程独立运行,通过锁将最新目标位置写入共享缓冲区;PDO 回调以 2 kHz 读取最新目标并下发,不阻塞于推理耗时。
观测空间 (96 维) 组成: projected_gravity(3) + base_ang_vel(3) + joint_pos-default(25) + joint_vel(25) + last_actions(25) + foot_contact(6+6) + commands(3)
本案例中的 RL 策略训练 (Isaac Lab 仿真环境搭建、奖励函数设计、PPO 训练调参)、 ONNX 模型部署优化等底层时序逻辑为附加定制服务, 不包含在 EtherCAT 主站软件授权中。
如需人形机器人全身控制方案定制,请联系技术团队。
4 路环线均采用双向冗余——主站从环线两端同时发送 LRW 帧。任意一处线缆断裂不丢失从站数据,对应用层透明。
人形机器人关节处线缆受反复弯折,环断线概率高于固定安装场景。断线后自动从 DUAL 切换到 DEGRADED 模式,PDO 数据不中断。
关节模组断电重连 (碰撞保护、维修更换) 时,驱动通过 WKC 监控 + 逐站探测自动检测从站离线,并区分环断线与真正的热插拔。从站重新上线后自动恢复,应用层收到上线回调。
代码示例
PDO 结构体定义
import ctypes
class JointInput(ctypes.LittleEndianStructure):
"""关节模组反馈 PDO (TxPDO)"""
_pack_ = 1
_fields_ = [
("Mode", ctypes.c_uint8), # 当前模式
("ErrorCode", ctypes.c_uint8), # 错误码
("Position", ctypes.c_int32), # 实际位置 (0.001°)
("Velocity", ctypes.c_int32), # 实际速度 (0.01°/s)
("Torque", ctypes.c_int16), # 实际力矩 (0.01Nm)
("Temperature", ctypes.c_uint8), # 温度 (°C)
]
class JointOutput(ctypes.LittleEndianStructure):
"""关节模组指令 PDO (RxPDO)"""
_pack_ = 1
_fields_ = [
("Mode", ctypes.c_uint8), # 0=空闲 1=位置 2=速度 3=力矩
("TargetPosition", ctypes.c_int32), # 目标位置 (0.001°)
("FeedforwardTorque", ctypes.c_int16), # 前馈力矩 (0.01Nm)
("Kp", ctypes.c_uint16), # 位置增益
("Kd", ctypes.c_uint16), # 阻尼增益
]
class ForceInput(ctypes.LittleEndianStructure):
"""六维力传感器反馈 PDO"""
_pack_ = 1
_fields_ = [
("Fx", ctypes.c_int32), ("Fy", ctypes.c_int32), ("Fz", ctypes.c_int32),
("Mx", ctypes.c_int32), ("My", ctypes.c_int32), ("Mz", ctypes.c_int32),
] # 单位: 0.001N / 0.001Nm
class ImuInput(ctypes.LittleEndianStructure):
"""IMU 反馈 PDO"""
_pack_ = 1
_fields_ = [
("AccX", ctypes.c_int16), ("AccY", ctypes.c_int16), ("AccZ", ctypes.c_int16),
("GyroX", ctypes.c_int16), ("GyroY", ctypes.c_int16), ("GyroZ", ctypes.c_int16),
("Roll", ctypes.c_int16), ("Pitch", ctypes.c_int16), ("Yaw", ctypes.c_int16),
]
控制代码
RL策略部署 → EtherCAT PDO 异步桥接 (策略由 Isaac Lab 离线训练)。推理线程异步运行(50 Hz),PDO 回调只读取最新目标并下发(2 kHz),推理耗时不阻塞 PDO 周期。
import math
import time
import threading
import numpy as np
import onnxruntime as ort
from darra_ethercat import DarraEtherCAT
NUM_JOINTS = 25
OBS_DIM = 96
INFERENCE_HZ = 50
# 逻辑关节索引 → 物理从站索引 映射表 (仅关节, 传感器通过独立常量访问)
# EtherCAT 扫描顺序沿环线拓扑, 传感器穿插其中, 关节不一定从0开始连续编号
# 环线1: [0]=IMU, [1]=腰偏航, [2]=腰俯仰, [3~7]=左肩×3+左肘+左腕
# 环线2: [8]=颈偏航, [9~13]=右肩×3+右肘+右腕
# 环线3: [14~19]=左髋×3+左膝+左踝×2, [20]=左脚力传感器
# 环线4: [21~26]=右髋×3+右膝+右踝×2, [27]=右脚力传感器
JOINT_SLAVE_MAP = [
1, 2, # 腰偏航, 腰俯仰 (环线1, IMU后)
3, 4, 5, 6, 7, # 左肩×3, 左肘, 左腕 (环线1)
8, # 颈偏航 (环线2)
9, 10, 11, 12, 13, # 右肩×3, 右肘, 右腕 (环线2)
14, 15, 16, 17, 18, 19, # 左髋×3, 左膝, 左踝×2 (环线3)
21, 22, 23, 24, 25, 26, # 右髋×3, 右膝, 右踝×2 (环线4)
]
IMU_SLAVE_IDX = 0 # IMU 从站索引 (环线1首个节点)
FORCE_L_SLAVE_IDX = 20 # 左脚力传感器从站索引
FORCE_R_SLAVE_IDX = 27 # 右脚力传感器从站索引
# 关节限位 (°) — 与训练环境 URDF 一致
JOINT_MIN = np.array([
-90,-30, -180,-90,-90,0,-180, # 环线1: 腰×2 + 左臂×5
-90, -180,-90,-90,0,-180, # 环线2: 颈×1 + 右臂×5
-90,-45,-45,0,-45,-30, # 环线3: 左腿×6
-90,-45,-45,0,-45,-30, # 环线4: 右腿×6
], dtype=np.float32)
JOINT_MAX = np.array([
90,30, 180,90,90,135,180,
90, 180,90,90,135,180,
90,45,45,135,45,30,
90,45,45,135,45,30,
], dtype=np.float32)
class RLPolicyBridge:
"""RL策略部署桥接 (Isaac Lab训练 → ONNX部署 → EtherCAT PDO)
推理线程以 50 Hz 异步运行, PDO 回调以 2 kHz 读取最新目标。
用法与 C# 类库一致: InputsMapping/OutputsMapping 映射 PDO 结构体。
"""
def __init__(self, master: DarraEtherCAT,
onnx_path: str, norm_path: str):
self.master = master
self.policy = ort.InferenceSession(
onnx_path, providers=['CPUExecutionProvider'])
self.input_name = self.policy.get_inputs()[0].name
# 加载训练导出的归一化参数
norm = np.load(norm_path)
self.obs_mean = norm['obs_mean'].astype(np.float32)
self.obs_var = norm['obs_var'].astype(np.float32)
self.action_scale_deg = np.degrees(norm.get(
'action_scale',
np.full(NUM_JOINTS, 0.5, dtype=np.float32)))
self.default_pos = norm.get(
'default_pos',
np.zeros(NUM_JOINTS, dtype=np.float32))
self.obs = np.zeros(OBS_DIM, dtype=np.float32)
self.last_actions = np.zeros(NUM_JOINTS, dtype=np.float32)
self.commands = np.zeros(3, dtype=np.float32)
# 预获取所有 PDO 映射 (引用指向共享内存, 数据由主站循环自动更新)
self._joint_inputs = [
self.master.Slaves[JOINT_SLAVE_MAP[i]].InputsMapping(JointInput)
for i in range(NUM_JOINTS)
]
self._joint_outputs = [
self.master.Slaves[JOINT_SLAVE_MAP[i]].OutputsMapping(JointOutput)
for i in range(NUM_JOINTS)
]
self._imu_input = self.master.Slaves[IMU_SLAVE_IDX].InputsMapping(ImuInput)
self._force_inputs = [
self.master.Slaves[s].InputsMapping(ForceInput)
for s in (FORCE_L_SLAVE_IDX, FORCE_R_SLAVE_IDX)
]
# 共享目标缓冲区 (推理线程写, PDO回调读)
self._lock = threading.Lock()
self._targets = self.default_pos.copy()
self._running = False
# ── 异步推理线程 (50 Hz) ──────────────────────────
def start(self):
"""启动异步推理线程"""
self._running = True
self._thread = threading.Thread(
target=self._inference_loop, daemon=True)
self._thread.start()
def _inference_loop(self):
"""独立线程: 50 Hz 采集观测 → 推理 → 更新共享目标"""
interval = 1.0 / INFERENCE_HZ
while self._running:
t0 = time.monotonic()
self._build_obs()
self._infer()
elapsed = time.monotonic() - t0
if elapsed < interval:
time.sleep(interval - elapsed)
def _build_obs(self):
"""构建观测向量 (与 Isaac Lab 训练环境完全一致)"""
idx = 0
# ① projected_gravity (3)
imu = self._imu_input
roll = math.radians(imu.Roll * 0.01)
pitch = math.radians(imu.Pitch * 0.01)
self.obs[idx] = -math.sin(pitch)
self.obs[idx + 1] = math.sin(roll) * math.cos(pitch)
self.obs[idx + 2] = -math.cos(roll) * math.cos(pitch)
idx += 3
# ② base_ang_vel (3)
self.obs[idx] = math.radians(imu.GyroX * 0.01)
self.obs[idx + 1] = math.radians(imu.GyroY * 0.01)
self.obs[idx + 2] = math.radians(imu.GyroZ * 0.01)
idx += 3
# ③ joint_pos - default (25) + ④ joint_vel (25)
for i in range(NUM_JOINTS):
inp = self._joint_inputs[i]
self.obs[idx + i] = \
inp.Position * 0.001 - self.default_pos[i]
self.obs[idx + NUM_JOINTS + i] = \
inp.Velocity * 0.01
idx += NUM_JOINTS * 2
# ⑤ last_actions (25)
self.obs[idx:idx + NUM_JOINTS] = self.last_actions
idx += NUM_JOINTS
# ⑥ foot_contact_force (12)
for ft in self._force_inputs:
for val in [ft.Fx, ft.Fy, ft.Fz,
ft.Mx, ft.My, ft.Mz]:
self.obs[idx] = val * 0.001
idx += 1
# ⑦ commands (3)
self.obs[idx:idx + 3] = self.commands
def _infer(self):
"""ONNX 推理 → 更新共享目标"""
obs_norm = (self.obs - self.obs_mean) \
/ np.sqrt(self.obs_var + 1e-8)
try:
actions = self.policy.run(
None,
{self.input_name: obs_norm.reshape(1, OBS_DIM)}
)[0][0]
except Exception as e:
print(f"[警告] ONNX 推理异常: {e}")
return
actions = np.clip(actions, -1.0, 1.0).astype(np.float32)
self.last_actions = actions
targets = self.default_pos + actions * self.action_scale_deg
np.clip(targets, JOINT_MIN, JOINT_MAX, out=targets)
with self._lock:
self._targets = targets
# ── 2 kHz PDO 回调 (不阻塞) ──────────────────────
def step(self):
"""每个 PDO 周期调用, 仅读取共享目标并写入 PDO"""
with self._lock:
targets = self._targets
for i in range(NUM_JOINTS):
outp = self._joint_outputs[i]
outp.Mode = 1
outp.TargetPosition = int(targets[i] * 1000)
outp.Kp = 600
outp.Kd = 40
def close(self):
self._running = False
self._thread.join(timeout=1)
del self.policy