• 前言

上一篇文章从Kruchten的4+1视图视角分析了RL_SAR软件架构。Kruchten视图模型已经回答「从哪几个角度看软件」,但换电机协议会不会影响策略相关代码这件事,图上不一定看得出。四层回答的是逻辑视图里控制栈到底按什么切开,四层补的是一条领域约束。

  • 换 SDK / 仿真后端,应只动 HAL。
  • 发软、跟不住,应先查实时层的 dt 和 PD,而不是先改网络。
  • 换 himloco.pt,应只动决策配置与观测清单。
  • 加「按 3 跳舞」,应只加 FSM 技能和 YAML。

这些是变更影响规则,Kruchten 4+1视图不会自动写出来,所以,有必要补四层模型分析,否则 sim2real、换机型、换策略的边界是模糊的。

  • 为什么必须分层

可以把整机想成一家餐厅:

  • 灶台和锅(电机、IMU、通信协议)各家都不一样。
  • 后厨节奏(几毫秒发一次力矩、PD 跟踪)必须稳定,客人等不起。
  • 主厨拍板(看姿态、推策略、决定下一步动作)可以慢一点,但必须看对信息。
  • 前厅点单(走路、跳舞、接导航)才是用户真正要的服务。

如果不分层,换一台机器人就要把 PD、观测、策略、按键一起改一遍。分层之后,换机型主要改 HAL;换策略主要改感知决策的配置;加一个「跳舞」技能,只动业务层的状态机。
四层之间只通过窄接口说话:下层上报「现在关节在哪、身体怎么倾斜」,上层只下发「目标位置 / 速度 / 刚度」。上层不关心这是真机还是仿真,下层不关心当前是在走路还是在跳舞。

在这里插入图片描述
四层职责对照如下

层级典型频率核心问题在 rl_sar 中的落点
业务应用层人机交互级现在要走路、跳舞,还是跟导航走fsm_*.hpp 技能状态、Control、/cmd_velpolicy/*/config.yaml
感知决策层~50 Hz看见什么、决定下一步动作ComputeObservation()、InferenceRuntime、FSM、MotionLoader
实时运动控制层~200 Hz如何按时、安全地把目标变成力矩LoopFunc、RLControl()Interpolate()、robot_joint_controller
HAL 硬件抽象层总线级怎么把各家硬件读成同一份结构RobotState / RobotCommand、GetState() / SetCommand()

1. HAL 硬件抽象层

1.1 什么是 HAL 硬件抽象层

硬件抽象层,Hardware Abstraction Layer,它把各家机器人/电机/总线的差异挡在下面,向上提供统一的 RobotState / RobotCommand 以及 GetState() / SetCommand() 这类接口。
HAL 的目标只有一句话:上层永远面对同一份状态和同一份指令,不要面对厂商协议。工业上常见的做法是:

  • 定义统一数据结构(关节位置、速度、估计力矩、IMU 四元数)。
  • 用适配器把厂商 SDK / 仿真器填进这份结构。
  • 用一张关节映射表处理「训练时的关节顺序」和「SDK 里的关节顺序」不一致。

没有 HAL,策略网络的输入顺序会和电机编号绑死。换一台 Go2,或从仿真迁到真机,策略就要重训或手改下标。

1.2 该层项目示例

rl_sar项目的 rl_sdk.hpp 里定义了两份核心结构。读上来的叫 RobotState(IMU + 电机状态),写下去的叫 RobotCommand(每个关节的 q / dq / tau / kp / kd)。基类 RL 只声明两个纯虚函数:

  • GetState(RobotState*):把硬件读成统一状态
  • SetCommand(const RobotCommand*):把统一指令写回硬件

真正干活的是子类:RL_Real(真机)、RL_Sim(Gazebo)、MuJoCo 仿真。上层 RobotControl() 永远是同一句:先 GetState,再跑状态机,再 SetCommand。

在这里插入图片描述
以 Unitree Go2 真机为例。GetState() 从 DDS 话题 rt/lowstate 取出 IMU 四元数、陀螺仪,再按 joint_mapping 把电机 q / dq / tau_est 填进 RobotState。SetCommand() 则组装 LowCmd_,写回 rt/lowcmd,并补上 CRC。上层完全看不到 0xFE 0xEF 这种协议头。
仿真侧走另一条路:Gazebo 里没有厂商 DDS,而是 robot_msgs/MotorCommand 下发、MotorState 回读;真正把「位置目标」变成「仿真力矩」的是 robot_joint_controller 插件。对决策层来说,两边都是同一份 RobotState。
L4W4 更「直接」:自己的 UDP SDK 解析 MCU 报文。HAL 的价值在这里特别明显——策略代码一行都不用为 UDP 改。

1.3 关节映射:训练空间 ≠ 硬件空间

Go2 的 base.yaml 里关节按 FR / FL / RR / RL 排列;himloco 策略的 joint_mapping 却是 [3, 4, 5, 0, 1, 2, 9, 10, 11, 6, 7, 8]。也就是说:观测和动作按训练顺序排,读写电机按 SDK 顺序排。 G1 的 whole-body tracking 策略映射更长,因为 29 个自由度的训练顺序和 Unitree HG SDK 顺序也不一样。
可以把它理解成「插座转换头」:墙上的孔(电机编号)各家不同,插头(策略输出)必须经过 mapping 才能插上。
仿真和真机还有一个容易踩坑的细节:角速度坐标系。项目在观测里用 ang_vel_axis 区分 body 和 world——ROS1 Gazebo 是世界系,ROS2 / MuJoCo / 真机是机体坐标系。这也属于 HAL 向决策层「翻译物理含义」的一部分。

2. 实时运动控制层

2.1 实时运动控制层职责

实时运动控制层的主责是:在固定短周期内,把上层给的关节目标变成持续、安全的伺服,推理来不及也不能停发力。 它不管「往哪走、跳什么舞」,只保证在 5 ms 节拍上给电机下发目标位置、目标速度等。
和相邻层的边界:上层给的是目标,本层给的是「每拍都有效的伺服指令」。本层不读 DDS/UDP 报文格式,这些是HAL做的事。
这一层有三条铁律:

  1. 周期确定:用独立线程按固定 dt 跑,必要时绑 CPU。
  2. 控制律简单:关节级 PD(阻抗)足够快、足够好懂。
  3. 安全兜底:力矩限幅、姿态保护、柔顺下电,必须能在这一层直接生效,不能等神经网络想完再救。
    强化学习策略通常跑在 50 Hz 量级(推理有开销),而电机伺服需要 200 Hz 甚至更高。中间用 decimation(抽稀):控制环每拍都发 PD 指令,策略每隔 N 拍才更新一次目标。两次推理之间,关节仍然跟着上一拍的 q* / dq* 走。

2.2 该层项目示例

Go2 的 base.yaml 里:dt: 0.005(5 ms,200 Hz),decimation: 4。启动时拉起三条 LoopFunc 线程:

线程周期职责
loop_controldt = 5 msGetState → StateController(FSM)→ SetCommand
loop_rldt × decimation = 20 ms组观测、推理、ComputeOutput,结果推进并发队列
loop_keyboard50 ms键盘人机接口,不进实时热路径

在这里插入图片描述
策略线程和伺服线程之间用 tbb::concurrent_queue 解耦:推理慢了,控制环仍用上一拍目标,而不是卡住不发指令。Forward() 里对模型互斥锁用 try_lock——正在切换策略文件时,直接沿用上一拍 action,避免 200 Hz 环被加载模型堵住。

2.3 该层运控逻辑

策略输出的不是原始电流,而是归一化动作。ComputeOutput() 做三件事:

  1. actions × action_scale 得到相对默认姿态的增量。
  2. 普通关节变成位置目标 q* = default_dof_pos + Δq;轮足的轮子关节走速度目标(wheel_indices)。
  3. 软件侧预计算力矩 τ = K p ( q ∗ − q ) − K d q ˙ \tau = K_p (q^{*} - q) - K_d \dot{q} τ=Kp(qq)Kdq˙,再按 torque_limits 限幅。

真机上,Unitree 电机固件会再跑一遍阻抗: τ = K p ( q ∗ − q ) + K d ( q ˙ ∗ − q ˙ ) + τ f f \tau = K_p(q^{*} - q) + K_d(\dot{q}^{*} - \dot{q}) + \tau_{\mathrm{ff}} τ=Kp(qq)+Kd(q˙q˙)+τff。仿真里没有电机固件,就由 robot_joint_controller::UpdateFunc() 用同一条公式把 effort 写进 Gazebo。所以:仿真和真机都是这条 PD,只是执行地点不同。
起身、趴下不走神经网络,而走 Interpolate():在若干个 5 ms 周期里,把当前关节角线性插到 default_dof_pos,刚度用 fixed_kp / fixed_kd(比 RL 的 rl_kp / rl_kd 更「站得住」)。这是实时层的经典手法——大行程用插值,精细运动才交给策略。
被动模式更直接:kp = 0、kd = 8、tau = 0,相当于关节变阻尼器,人可以按倒机器人而不会硬顶。这是实时层的安全态,不经过策略。

3. 感知决策层

3.1 感知决策层职责

这一层回答两件事——「现在是什么情况」和「下一步做什么」
在传统机器人里,这里往往是状态估计 + 规划 + WBC。强化学习部署里,对应关系变成:

  • 感知:把 IMU、关节、指令、历史动作拼成策略训练时见过的那些向量(observation)。
  • 决策:神经网络 forward 出 action;有限状态机决定「现在该不该推理、该用哪份策略」。

注意:这里的「感知」通常不是激光建图那种环境感知,而是本体感知(proprioception)。视觉、导航可在以后挂到业务层,经 cmd_vel 灌进来。
观测必须和训练严格对齐:缩放(ang_vel_scale、dof_pos_scale)、裁剪(clip_obs)、历史帧堆叠,缺一项,部署就会「看起来在动,但不是训练时那样动」。

3.2 该层项目示例

ComputeObservation() 按 YAML 里的 observations 列表逐项拼接。Go2 的 himloco 策略菜单是:
commands, ang_vel, gravity_vec, dof_pos, dof_vel, actions → 一共 45 维。

观测项物理含义人话
commands期望 vx, vy, yaw(再乘 commands_scale)你想让它往哪走
ang_vel机体角速度身体转得有多快
gravity_vec重力在机体坐标下的方向现在是不是快要歪了
dof_pos相对默认站姿的关节角腿现在收着还是蹬着
dof_vel关节速度腿甩得有多猛
actions上一拍网络输出让策略有短期记忆

himloco 还开了 6 帧历史(observations_history: [0,1,2,3,4,5]),由 ObservationBuffer 环形缓存。网络一次看到的不是「这一瞬间」,而是最近约 0.12 秒的身体 continuity。这对抑制抖动、估计接触很有帮助。
G1 跳舞则换成另一张列表:motion_command(参考轨迹的关节位置速度)+ motion_anchor_ori_b(躯干相对动作锚点的姿态)+ 本体状态。MotionLoader 按仿真时间轴推进 BVH/CSV 参考运动。同一套 ComputeObservation(),换列表就能从「走路」变成「跟动作」。
推理后端被收在 InferenceRuntime:按文件后缀自动选 libtorch 或 ONNX Runtime。决策层同样不绑定某一种推理库。

3.3 FSM:决策层的「交通指挥」

神经网络不会自己决定「该不该站起来」。项目用通用 FSM:每个状态实现 Enter / Run / Exit / CheckChange。这里用Go2 四态讲一下逻辑:
在这里插入图片描述
进入 RLFSMStateRLLocomotion 的 Enter() 才会 InitRL(“go2/himloco”):读 config.yaml、加载 himloco.pt、重置观测。Run() 里调用 RLControl(),从队列取出策略算出的 q* / dq*,填进 RobotCommand 的 kp/kd。FSM 在控制环频率下运行,推理在更慢的环;决策结果通过队列灌进实时层。
G1 在同一套 FSM 骨架上多挂了 Charleston、Dance102、Gangnam Style。技能结束(动作播放到 100%)会 RequestStateChange 回 locomotion——这是决策层对「任务做完了」的判断,不是业务层弹窗。

4. 业务应用层

4.1 业务应用层职责

业务层回答产品问题:

  • 今天这台机器人提供哪些能力?(行走、舞蹈、导航跟随)
  • 谁来下指令?(手柄、键盘、ROS 导航栈)
  • 换任务要不要重编底层?(理想情况:只换配置和模型文件)
  • 它允许非实时、允许和人交互,但不能直接写电机寄存器。所有业务意图都要翻译成「期望速度」或「切换 FSM 状态」,再交给下面三层。

4.2 该层项目示例

G1 的业务菜单:

交互业务含义决策层加载的配置
数字键 1 / RB+方向上基础行走g1/robomimic/locomotion
数字键 2Charleston 舞蹈g1/robomimic/charleston(播完回行走)
数字键 3全身跟踪舞蹈g1/whole_body_tracking/dance_102 + 动作 CSV
数字键 4江南 Styleg1/whole_body_tracking/gangnam_style
键盘 N导航模式开关用 /cmd_vel 覆盖手柄速度

Control 结构里的 x / y / yaw 就是业务层的「速度点单」。手柄摇杆直接写入;开了 navigation_mode 后,ROS 的 geometry_msgs/Twist(/cmd_vel)覆盖这三项。于是 Nav2、键盘遥控、手柄可以共用同一个 locomotion 策略,业务层只是换了指令来源。
策略文件本身也是业务资产:policy/<机器人>/<技能>/config.yaml 描述观测清单、动作缩放、Kp/Kd、模型文件名。换一份 pt + YAML,不必改 C++,就能在同一台 G1 上换技能。这是业务层和决策层之间最干净的契约。
再往上,仿真启动方式也是业务:roslaunch rl_sar gazebo.launch rname:=go2 或 ./rl_sim_mujoco g1 scene_29dof。用户选的是「在哪演、演哪台机器人」。

4.3 多机型是业务层的「产品矩阵」

README 里的支持列表(A1、Go2、Go2W、G1、Lite3、L4W4、D1…)看起来像硬件表,从分层看其实是:每种机器人注册一个 FSMFactory(REGISTER_FSM_FACTORY),启动时按 robot_name 自动 CreateFSM。业务上「再支持一台新机器人」的标准路径是:

  1. HAL:实现该机型的 GetState / SetCommand
  2. 实时层:核对 dt、fixed_kp/kd、力矩限幅
  3. 决策层:补一份与训练对齐的 config.yaml
  4. 业务层:注册 FSM 状态(至少 Passive / GetUp / Locomotion)

下面三层稳定后,产品经理口中的「新技能」往往只发生在第 4 步。

5. 把四层串成一个控制拍

下面用 Go2 真机、已经站起来、正在摇杆前进为例,看 20 ms 里数据怎么走。

在这里插入图片描述

6. 分层带来的三条工程好处

  • 仿真和真机共用决策。 RL 基类里的观测、推理、FSM、输出缩放,仿真与真机各写一份 HAL 即可。训练–仿真–真机之间最容易弄错的是「观测定义」和「关节顺序」,它们被显式写在 YAML 里,而不是散落在 #ifdef。
  • 故障被关在该关的层。 姿态保护、力矩限幅属于实时层,即使策略输出离谱,也可以先趴下或限幅。业务层按错键,最多切到 Passive,不会直接改 CRC 或 UDP 缓冲区。
  • 时间尺度分开。 键盘 20 Hz、策略 50 Hz、伺服 200 Hz。若把推理塞进 5 ms 环,模型稍一变大就会丢拍;若把 PD 降到 50 Hz,落地冲击会明显变差。

四足和人形在这套分层里的差别,主要是 HAL 的关节数量、实时层的增益表,以及业务层挂了几个技能状态。G1 有 29 个自由度、能跳舞,Go2 是 12 关节 locomotion——对上面两层来说,都只是「另一份 YAML 和另一张 FSM 表」。
分层不是为了把代码写得好看,而是为了让「换硬件、换频率、换策略、换产品功能」四件事不要缠在一起。rl_sar 用 RobotState 这一窄接口、两条时间环、一份可配置观测、以及可注册的 FSM,把这件事做得很具体。把这四层看清,再去读 rl_sdk.cpp 和任意一个 rl_real_*.cpp,在整体逻辑的把握上,会清楚很多。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐