把强化学习机器人部署到实机,听起来就是训练完导出权重再跑起来,真做起来才发现,从英伟达 GPU 的仿真环境到 RK3566 主控的实机,中间隔着工具链、实时性、Sim-to-Real 一大堆问题。Microduck 是一台 25 厘米的四足机器人,运动控制完全由强化学习策略驱动,没有手写步态,也没有显式姿态 PID。这篇手记记录了我把训练好的策略从仿真搬到 RK3566 实机的完整过程,适合正在折腾 Microduck 或者类似低成本 RL 四足平台的开发者参考。全文偏部署实践,训练部分只讲能让模型落地的关键细节,GPU 上怎么调 reward 这类可以放到以后单独写。
1. 为什么是“英伟达 GPU 训练 + RK3566 实机”这条路线
1.1 微型四足机器人选型时的核心约束
Microduck 这类 25 厘米级四足机器人,和实验室里那些动辄几万块的科研平台有个本质区别:它要在低成本、低功耗、小体积的前提下跑完完整的强化学习部署链路。机身只有这么点空间,电池容量有限,电机功率有限,主控板也不能像工控机那样随便堆散热。
强化学习策略在训练阶段确实吃算力。我在英伟达 GPU 上用 MuJoCo 仿真并行采集数据,单张 RTX 3090 可以同时跑 2000 个机器人环境,一个百万步的离线强化学习任务几个小时就能出结果。这种算力需求,嵌入式平台在物理上就不可能满足,也没必要满足。训练和推理本来就是两套不同规模的问题。
但实机推理就完全是另一回事了。Microduck 的运动控制策略是一个多层感知机,参数不到 1MB,单次前向传播在四核 A55 处理器上跑只需要 1 到 2 毫秒。这个算力门槛低到随便一块开发板都能胜任。真正的难点反而不在算力,而在 I/O 接口是否齐全、能否稳定支撑实时控制循环、系统是不是好调试。
1.2 RK3566 对比树莓派 4B:为什么最后选了 RK3566
我最早考虑过树莓派 4B,毕竟社区资料最多,出问题随便一搜就有答案。但仔细对比下来,RK3566 在几个关键维度上更匹配这种低成本机器人项目。
| 项目 | RK3566 | 树莓派 4B |
|---|---|---|
| CPU | 四核 Cortex-A55,1.8GHz | 四核 Cortex-A72,1.5GHz |
| NPU | 0.8 TOPS,支持 RKNN | 无 |
| 内存 | 2GB/4GB LPDDR4 | 2GB/4GB/8GB LPDDR4 |
| 典型价格 | 约 200 元级别 | 约 400 元级别 |
| 板载 UART/SPI/I2C | 丰富,容易引出 | 也够用,但树莓派生态偏桌面 |
| 5V 供电稳定性 | 耐造,机器人供电波动影响小 | 对供电要求更敏感,电压跌落容易重启 |
| 实时性改造空间 | Linux 普通内核即可,PREEMPT_RT 也支持 | 硬件中断和电源管理在强实时场景下略繁琐 |
选择 RK3566 真正的理由有三条。第一是价格,200 元级别的板子损坏成本低,在机器人上试错压力小;第二是接口,机器人要接 IMU、串口通信模块、看门狗和电源管理,RK3566 的引脚引出方便很多;第三是 NPU 的存在给后续扩展留了余地,虽然 Microduck 的 MLP 策略在 CPU 上就够跑,但以后如果要加视觉传感器,RK3566 的 0.8 TOPS NPU 至少能提供一个本地推理的选项。
1.3 上下位机分工架构
在实机上,我没有让 RK3566 直接驱动电机。电机控制是一个强实时任务,需要 1kHz 甚至更高频率的电流环/位置环,Linux 非实时内核很难稳定保证这种时序。所以整体架构采用上位机加下位机的分工方式:
- RK3566 作为上位机,运行 Linux,负责加载强化学习策略、读取 IMU 和关节状态、构建观测向量、执行网络推理、输出目标关节角度。
- STM32 作为下位机,运行裸机或 RTOS,负责电机位置环、编码器读取、电流采样、通信协议解析。控制频率可以做到 1kHz。
- 上下位机之间用串口 UART 连接,波特率 921600,STM32 以 500Hz 上报状态,RK3566 以 100Hz 下发关节目标角度。
这种架构的好处是把实时性要求高的部分放到单片机,把算法部署方便的部分放到 Linux 板,两边各干各擅长的事。整个链路走通之后,后续换更复杂的策略、加传感器,都只需要改 RK3566 这边的代码,下位机基本不用动。
2. 训练阶段:Microduck 的强化学习策略是怎么在仿真里“长”出来的
2.1 任务定义与观测动作空间
部署之前必须先搞清楚一件事:策略到底在解决什么问题,输入输出是什么,频率是多少。这个决策直接影响后面的模型转换和实机控制循环设计。
我把 Microduck 的运动控制任务定义为:给定前进速度 vx、横向速度 vy、偏航角速度 wyaw 三个指令,策略输出 12 个关节的目标位置。这里假设 Microduck 是每条腿 3 个自由度、共 12 个关节的常见四足构型。如果你的 Microduck 是每条腿 2 个自由度,只需要把维度改成 8,整体思路完全一致。
观测向量是 45 维,具体为:
- 线速度和偏航角速度指令 3 维:vx、vy、wyaw
- 基座角速度 3 维:从 IMU 陀螺仪读取
- 基座坐标系下的重力向量 3 维:从 IMU 加速度计估计并归一化
- 12 个关节角度
- 12 个关节角速度
- 上一时刻的 12 个动作
动作空间是 12 个关节位置目标,范围限制在正负 0.8 rad。这里最关键的是把“上一个动作”放进观测向量。由于策略网络本身是无记忆的 MLP,如果不给这一项,策略就不知道自己的历史输出是什么,实机上很容易出现高频抖动。仿真里加了这一项之后,控制输出平滑程度会明显改善。
控制频率在训练和仿真里统一设定为 200Hz,每步 5ms。这个频率对位置控制的四足机器人来说够用,而且实机上也能通过 STM32 串口稳定支撑。
2.2 为什么选择 IQL 离线强化学习而不是纯 PPO
训练算法我在 IQL 和 PPO 之间来回试过好几轮,最终主力用了 IQL 离线强化学习。
PPO 是强化学习机器人运动控制的经典选择,但它有一个很现实的问题:在线 rollout 的方差很大。每次训练都要不断和环境交互采集新数据,训练曲线波动明显,一张 GPU 要同时处理采样和梯度更新,出问题之后也不容易定位是采样问题还是奖励设计问题。
IQL 的思路是先离线收集一批数据,再用这些数据学习 Q 函数,最后从 Q 函数里提取策略。由于数据是固定的,训练过程非常稳定,可以反复调试奖励系数和网络结构而不需要重新采样。这对实机部署前的迭代非常有利。
数据采集是离线强化学习能否成功的关键。我用仿真里已有的一个传统步态控制器作为数据生成策略,在 MuJoCo 里随机跑不同的速度和转向指令,采集了约 50 万条转移样本。数据里还刻意加了一些极端动作和扰动,让离线数据集覆盖足够广的状态空间。整个采集过程大概用了半天时间,之后这一份数据可以反复用于训练实验。
IQL 训练的核心超参数我放在了下面:
| 超参数 | 数值 |
|---|---|
| expectile | 0.7 |
| batch size | 256 |
| 学习率 | 3e-4 |
| discount | 0.99 |
| 训练步数 | 100 万 |
| 网络结构 | MLP 256×3 |
| 激活函数 | ReLU |
| 动作范围 | 正负 0.8 rad |
在 RTX 3090 上,100 万步训练大约 4 小时。训练完成后,我会在 MuJoCo 里用固定随机种子做 50 次 rollout 评估,统计行走距离、姿态稳定时间和抗推成功率,只挑选评估结果最好的 checkpoint 进入部署流程。
2.3 奖励函数设计与 Domain Randomization
Microduck 能在实机上走路,训练时的奖励函数和域随机化缺一不可。奖励函数我用了加权组合,每项都有明确物理含义:
| 奖励项 | 表达式 | 作用 |
|---|---|---|
| 速度跟踪 | exp(- | |
| 姿态保持 | 重力向量投影尽量接近 [0,0,1] | 保持机身水平 |
| 期望高度 | exp(-(h - h_ref)²) | 维持指定躯干高度 |
| 力矩惩罚 | -w_tau * sum(tau²) | 降低电机负担和发热 |
| 动作平滑 | -w_rate * sum((a_t - a_{t-1})²) | 抑制抖动 |
权重参数我边训练边调,最终确定速度跟踪权重最高,力矩惩罚和动作平滑次之。我的经验是:如果机器人站不稳,优先检查姿态保持项;如果站得稳但走不动,优先检查速度跟踪项;如果走起来抖动厉害,重点调动作平滑权重和动作范围。
Domain Randomization 是 Sim-to-Real 转移的护城河。我在仿真里对摩擦系数、电机延迟、关节零位偏置、负载质量、传感器噪声都做了随机化。比如摩擦系数范围设为 0.2 到 1.2,电机动作延迟随机 0 到 2 个控制步,关节零位加入正负 0.05 rad 的随机偏置。这些看似微小的随机化,是实机能够站稳的关键前提。没有域随机化的策略,在仿真里再漂亮,上了实机基本都会躺平或者抽搐。
3. 模型从 PyTorch 到 RK3566 的转换:ONNX、RKNN 和一场几乎翻车的量化
3.1 先导 ONNX,再验证数值一致性
训练完的策略网络是 PyTorch 格式,要上 RK3566 实机,第一件事是转换成通用中间格式。我首选 ONNX,因为它在 CPU 推理和 RKNN 转换两条路上都走得通。
导出脚本的核心部分长这样:
import torch import numpy as np model = torch.jit.load("policy.pt") model.eval() dummy_input = torch.randn(1, 45) torch.onnx.export( model, dummy_input, "policy.onnx", input_names=["obs"], output_names=["action"], dynamic_axes={"obs": {0: "batch"}, "action": {0: "batch"}}, opset_version=12, )导出之后,我没有直接拿去部署,而是在 PC 上先用 ONNX Runtime 和 PyTorch 的原始输出做数值一致性验证。这里很容易踩坑——ONNX 导出看起来成功,但某些算子实现上会有细微差异,模型权重是好的但推理结果悄悄变了。
import onnxruntime as ort import torch sess = ort.InferenceSession("policy.onnx", providers=["CPUExecutionProvider"]) x = np.random.randn(1, 45).astype(np.float32) y_onnx = sess.run(["action"], {"obs": x})[0] with torch.no_grad(): y_torch = model(torch.from_numpy(x)).numpy() print("max abs diff:", np.max(np.abs(y_onnx - y_torch)))这个值我要求小于 1e-5。如果超过这个量级,说明 ONNX 模型已经和原始模型有了可感知的偏差,实机上很可能会表现为动作抖动。我实际测试中遇到过算子融合导致的 1e-3 级别差异,最后是通过固定 opset 版本解决的。
3.2 试过 RKNN 量化,最后选择 CPU 上的 ONNX Runtime
下一步按理说应该转 RKNN,用 RK3566 的 NPU 跑推理。我也确实试过,而且试了很久。
RKNN 转换的基本流程是:
from rknn.api import RKNN rknn = RKNN() rknn.config(target_platform="rk3566") rknn.load_onnx(model="policy.onnx") rknn.build(do_quantization=False) rknn.export_rknn("policy_fp16.rknn")但问题出在两个地方。第一,RK3566 的 NPU 对 INT8 量化支持最好,我的 RL 策略输入输出都是连续浮点数,量化之后关节位置输出出现了约 0.02 rad 的抖动。这个幅度看着不大,但在实机上足以让 Microduck 站都站不稳,所有关节都在微颤。第二,策略网络是一个标准的 MLP,对 NPU 来说属于小尺寸网络,单次推理虽然只要零点几毫秒,但驱动调用、缓存一致性和初始化开销反而比计算本身更不稳定。
我一度尝试混合量化,保留部分对精度敏感的层为浮点,但 RKNN 工具链对这种小网络的量化校准支持并不算友好,反复调了几版效果都不理想。后来我做了一个性能对比,发现这个决策可以更快做出来。
| 方案 | 平均推理耗时 | 稳定性 | 实机表现 |
|---|---|---|---|
| RKNN INT8 | 约 0.8ms | 偶发抖动 | 站立时关节微颤,行走时转弯漂移 |
| RKNN FP16 | 约 1.5ms | 相对稳定 | 可用,但工具链适配麻烦 |
| ONNX Runtime CPU FP32 | 约 1.8ms | 非常稳定 | 和仿真表现基本一致 |
对于 25 厘米级别的四足机器人,100Hz 控制周期意味着每周期只有 10ms 预算,CPU 上 1.8ms 的推理耗时完全够用,而且省掉了 NPU 推理带来的数值精度问题。所以我最终选择了一个比较务实的方案:模型直接用 ONNX Runtime 的 C++ API 在 RK3566 的 CPU 上跑 FP32 推理。NPU 留给以后加视觉模型时再用。
3.3 一个小坑:RKNN 更偏向图像输入,MLP 的 shape 需要适配
这里提一个经验,如果你还是想用 RKNN 跑这种策略网络,一定要提前处理输入 shape。RKNN 工具链最常用的是 4 维图像输入格式,比如 1×3×224×224,对纯 MLP 的 2 维输入支持比较别扭。我最早直接把 (1, 45) 的输入丢进去,转换时不报错,但在板端推理时维度对不上。
解决办法是把输入 reshape 成 1×1×1×45 之类的 4 维张量,同时在导出 ONNX 之前就固定好输入形状。但这会引入额外的一次内存拷贝,虽然延迟不大,但代码维护起来比较烦。所以我还是更推荐小策略网络直接走 CPU 推理,省心且稳定。
4. RK3566 实机部署:控制循环、上下位机通信与实时性优化
4.1 实机系统架构与传感器接入
RK3566 运行的是 Debian 系统,启动后自动加载策略模型和控制程序。整个实机部署按模块拆成三层:
- 感知层:IMU 通过 SPI 连接 RK3566,读取 500Hz 的陀螺仪和加速度计数据;关节状态由 STM32 编码器采集后通过串口上报。
- 决策层:RK3566 上的 C++ 控制程序从状态缓冲区构建 45 维观测,调用 ONNX Runtime 推理,得到 12 维动作。
- 执行层:RK3566 把动作通过串口发送给 STM32,STM32 解析后执行电机位置环控制。
IMU 我建议单独接在 RK3566 上而不是经过 STM32 转发。这样做的原因是强化学习策略对观测延迟非常敏感,如果 IMU 数据先到 STM32 再串口转发到 RK3566,会多出至少一个传输周期的延迟,实机站立时会明显给人一种“反应迟钝”的感觉。直接 SPI 读取可以将整个 IMU 数据链路延迟控制在 1ms 以内。
4.2 100Hz 控制循环的 C++ 骨架
实机控制频率我没有直接沿用仿真的 200Hz,而是先降到 100Hz。原因有两个:一是 ONNX Runtime CPU 推理在 RK3566 上约 1.8ms,加上传感器读取和串口通信,200Hz 时每周期只有 5ms 预算,余量太小;二是 STM32 的电机位置环本身是 1kHz,策略输出的是位置目标而非力矩,100Hz 输出位置对电机执行来说完全够用。
控制主循环的核心结构如下:
#include <chrono> #include <thread> auto next = std::chrono::steady_clock::now(); while (running) { // 1. 读取 IMU 和关节状态 read_imu(&imu_data); read_joint_state_from_stm32(&joint_state); // 2. 构建观测向量 float obs[45]; build_obs(obs, imu_data, joint_state, cmd, prev_action); // 3. ONNX Runtime 推理 float action[12]; run_policy(obs, action); // 4. 动作滤波与限幅 smooth_action(action, prev_action, &cmd); // 5. 发送给 STM32 send_cmd_to_stm32(cmd); prev_action = cmd; next += std::chrono::milliseconds(10); std::this_thread::sleep_until(next); }关键的时序问题是:传感器数据是上一时刻的,推理输出要下一时刻才生效,所以观测里必须携带上一时刻动作。在代码里我把 prev_action 和当前传感器数据一起构建观测,这样网络内部能够把“上一时刻我做了什么动作”和“当前我看到什么状态”在时间上对齐。
4.3 实时性优化:不是硬实时,但不能有大抖动
RK3566 跑的是通用 Linux 内核,要做到严格的实时控制不太现实,但四足机器人运动控制对周期抖动有一个容忍上限。我的经验是:100Hz 控制循环,单周期最大抖动不能超过 3ms,否则策略感知到的状态时间间隔不稳定,动作会变得不连贯。
实测经验下来,以下三个优化手段最有效。
第一,设置线程调度策略为 SCHED_FIFO,优先级 80。这样控制线程可以抢占大部分普通进程,避免日志写入或网络服务造成干扰。
struct sched_param param = { .sched_priority = 80 }; pthread_setschedparam(thread, SCHED_FIFO, ¶m);第二,设置 CPU 亲和性。把控制线程绑定到独立的 CPU 核心,其他系统任务分散到其他核,避免线程在核间迁移带来的 cache 失效和调度延迟。
cpu_set_t set; CPU_ZERO(&set); CPU_SET(3, &set); pthread_setaffinity_np(thread, sizeof(set), &set);第三,在控制循环里统计实际周期时间。我调试时会把每轮实际耗时写到一个共享缓冲区,另一个普通线程每秒钟打印一次 P95 和最大周期,而不是在控制线程里直接打印日志。这样既能观察实时性,又不影响控制时序。
实测在 RK3566 上,经过这三步优化,100Hz 控制循环的 P95 周期抖动稳定在 1.2ms 左右,最大周期不超过 2.5ms。这个水平对 Microduck 的运动控制完全够用,站姿稳定,行走节奏也没有异常。
5. Sim-to-Real 实测踩坑:从“疯狂抽搐”到稳定行走
5.1 关节零位标定引起的“假瘫痪”
第一次给 Microduck 上电,策略给出的动作明明是站立指令,机器人却直接趴在地上,四条腿的姿态和仿真里完全对不上。排查了很久才发现是关节零位标定问题。
在仿真里,关节角度 0 对应标准站姿。但实机的电机编码器零点在装配时是随机的位置,没有校准过的 RK3566 根本不知道仿真里的 0 对应实机的哪个角度。策略在仿真里学到的所有状态映射关系,到了实机上都因为零位偏移而错位。
解决办法是做一个简单的零位标定流程:把 Microduck 放在一个水平平面上,手动调整 12 个关节到仿真里的标准站姿角度,记录每个关节编码器的原始读数,作为零位偏移量存储下来。控制程序读取关节状态时先减去这个偏移量,再进行归一化。标定之后,机器人第一次上电就能站起来,虽然还有些晃,但状态方向完全正确。
5.2 IMU 方向约定不一致导致“仰头暴走”
第二个大坑出现在 IMU 数据处理上。仿真里的重力向量是在基座坐标系下表示的,也就是说,如果机器人水平站立,重力向量在基座系下应该是 [0, 0, 1]。但我的 IMU 驱动最初输出的方向约定完全不同,重力向量的符号和坐标轴方向都没对齐。
这个问题的表现非常诡异:机器人站立时,四条腿会不断调整姿势,整体呈现一种“仰着头想往前冲”的状态,偶尔还会突然加速,像在追一个看不见的目标。
调试过程其实不复杂,我在 RK3566 上读取 IMU 原始数据并打印,和仿真里的观测值做对比。把机器人手动摆成已知姿态,验证 IMU 数据经过旋转矩阵后是否与仿真约定一致。最后在驱动代码里加了一个坐标轴交换和符号翻转,重力向量对齐之后,整个策略瞬间就“安静”了。这个教训是:Sim-to-Real 最容易忽略的不是算法差异,而是坐标系的工程约定。
5.3 动作滤波与输出死区:细节决定站立质感
即使零位和 IMU 都处理好了,Microduck 在站立时还是能感觉到轻微的抖动,手摸上去能感受到高频微震。原因在于策略输出本身带有小的噪声,这些噪声经过电机位置环放大,虽然幅度不大,但持续存在会加速电机发热,也会让站立姿态看起来不自然。
我在控制循环里加了一阶低通滤波:
cmd_filtered = alpha * action + (1.0 - alpha) * prev_cmd;alpha 取 0.6,配合 100Hz 控制频率,相当于大约 10Hz 截止频率。滤波之后的动作变化明显平滑。同时我又加了一个死区判断:如果当前动作和上一时刻动作的差值小于 0.005 rad,直接沿用上一时刻动作,不更新指令。这两个小改动叠加之后,Microduck 的站立状态从“微颤”变成了“安静”,行走姿态也更自然。
5.4 意外摔倒与自动恢复策略
实机测试中不可能总是一次成功,机器人走着走着可能被地面异物绊倒,或者转弯速度过快侧翻。如果没有一套可靠的急停和恢复机制,一块几百块的板子很可能在一次摔倒中损坏。
我在控制循环顶部加入状态监测:如果 IMU 检测到机身姿态超过安全角度(比如横滚角大于 60 度),或者串口通信连续超时 100ms,立刻停止发送动作指令,并向 STM32 发送急停命令。同时把 12 个关节目标位置切换到一条预设的“躺平”轨迹,让机器人尽量以低姿态落地,减少冲击。
这个机制看似简单,但非常救命。有一次测试中,Microduck 在加速行走时被地毯边缘绊住,如果没有急停,电机会在堵转状态下持续输出大电流,轻则烧驱动,重则打齿。加了急停之后,最多就是翻个身,重新扶起来就能继续跑。
5.5 供电跌落问题
RK3566 开发板对供电波动比想象中敏感。Microduck 的电机瞬态电流很大,站立和急加速时容易拉低电池电压,导致 RK3566 重启。最初出现这个问题时,我一度以为是策略出错,后来查看系统日志才发现是反复重启。
解决方法是把 RK3566 的供电和电机供电完全分开,用一路独立的 5V 稳压模块给 RK3566 供电,电池直接给电机驱动供电。另外在电池端并联一个较大容量的电容,吸收瞬态压降。经过这些处理之后,再没出现过重启问题。这一条对任何带嵌入式主控的小型机器人项目都适用。
6. 部署完成后的性能数据与后续扩展
6.1 RK3566 实机实测数据
整个链路稳定之后,我记录了一些参考数据,给后来者一个量级概念。环境是室内平整地面,Microduck 重量约 1.2kg,2S 锂电池容量 2200mAh。
| 项目 | 实测数据 |
|---|---|
| 单个策略推理耗时 | 约 1.8ms(CPU FP32) |
| 控制循环频率 | 100Hz |
| P95 周期抖动 | 约 1.2ms |
| 待机功耗 | 约 4.5W |
| 站立功耗 | 约 12W |
| 行走功耗 | 约 18W |
| 单电池续航 | 约 20 到 25 分钟 |
| RK3566 核心温度 | 正常工作约 55 到 60 摄氏度 |
这个功耗水平对于 25 厘米级机器人来说算比较合理的。如果后续想提升续航,优先优化策略的动作平滑度,减少电机的无效做功,比单纯加大电池更有意义。
6.2 下一步可以做的方向
跑通这条部署链路之后,我自己的路线图是往两个方向扩展。
第一个方向是在 Microduck 上加视觉传感器。25 厘米级机器人虽然小,但装一个 120 度广角摄像头绰绰有余。RK3566 的 NPU 正好可以用来跑视觉障碍物检测模型,把视觉特征和运动控制策略拼接成一个更大的端到端网络。这也是 RK3566 相对纯 CPU 板卡的最大优势。
第二个方向是尝试在仿真里加入更多地形干扰。目前部署的是平地行走策略,域随机化虽然让机器人具备了抗小扰动能力,但面对门槛、斜坡、碎石地面等复杂地形仍然不够。可以继续用 IQL 离线强化学习,在仿真数据里加入更多地形样本,训练出适应性更强的策略,然后复用现有的部署链路直接迭代。
6.3 部署完整个项目之后最想说的
如果只总结一条经验,我会说:强化学习机器人项目的难点从来不止是算法和训练,而是仿真到实机之间那一段“看不见的工程距离”。模型转换要验证数值一致性,控制循环要计算延迟预算,IMU 要统一坐标系,关节要标定零位,供电要隔离,摔倒要有保护。每一步单独拿出来都不难,但串在一起就构成了 RL 机器人从 GPU 到实机的完整壁垒。
Microduck 现在能稳定走完会议室走廊,转弯、停止、抗推都还算自然。回头再看整个过程,最有价值的不是最终模型跑得有多好,而是我终于知道了一套低成本四足平台从训练到部署的完整链路该怎么搭。希望这篇手记能帮准备入坑的朋友少熬几个夜。