1. 项目背景:为什么用 RK3566 跑强化学习机器人
Microduck 是一台 25 厘米级别的低成本四足机器人,整个项目在 GitHub 上完全开源,硬件物料成本控制得非常激进。我最初接触这个项目时,核心诉求很明确:不依赖昂贵的 Jetson 系列,也不堆服务器级别的算力,用几百块钱的 RK3566 开发板把强化学习策略跑起来。
先说结论:Microduck 的定位是“学走路”的科研平台,而不是“跑酷玩具”。它的强化学习训练在英伟达 GPU 上完成,训练出来的策略网络被导出成轻量级模型,部署到 RK3566 上做实时推理。整个链路的关键点在于:策略网络在训练时是一个几十 MB 的 PyTorch 模型,但部署到板子上时,必须压缩成几 MB 甚至几百 KB 的推理模型,同时保证控制频率在 100 Hz 以上,也就是每个控制周期不超过 10 毫秒。
RK3566 是一颗四核 Cortex-A55 的 SoC,主频最高 1.8 GHz;集成 0.8 TOPS 算力的 NPU,但实测下来,强化学习策略大多是几十层的小型 MLP,CPU 直接跑完全够用,NPU 在多数场景下反而是杀鸡用牛刀。这个判断直接影响了我后续的模型导出路线选择——不上 RKNN,直接用 ONNX Runtime 的 CPU 后端,这是我踩了不少坑后最终确认的方案,后面会详细展开。
这帖子适合谁看?如果你手上有一块 RK3566 开发板(泰山派、香橙派 5 等),想把强化学习策略部署到实机上做运动控制;或者你已经跑通过 Isaac Gym / MuJoCo 里的仿真训练,但搞不定“训练完怎么上真机”这个临门一脚,那么这篇手记会很有参考价值。整个项目从训练到部署,我在实机上踩了不少坑,包括 USB 设备识别、串口权限、实时性调度、模型格式转换等,下面按完整链路拆开讲。
2. 整体设计与训练阶段:从 GPU 到策略文件
2.1 强化学习框架与算法选型
Microduck 官方仓库默认用的是 legged_gym 这套 Isaac Gym 训练框架,算法是 PPO(近端策略优化)。但这里有个坑:legged_gym 依赖英伟达 Isaac Gym 的旧版本,而且是专为四足机器人设计的,环境接口很固定,如果你是第一次跑,最好严格按 README 的版本来,不要自己升级到最新版,否则 API 全变了,脚本直接跑不起来。
训练目标是让机器人学会站立、踏步、转向。奖励函数里最关键的几项是:
- 躯干高度保持:维持在 0.2 米左右,偏离越远惩罚越大。
- 关节力矩惩罚:抑制高频抖动,防止电机过热。
- 前进速度跟踪:用遥控器或上层命令给定目标线速度,策略学习追踪。
- 姿态稳定:横滚角、俯仰角保持在零附近,防止摔倒。
如果用 PPO 从头训练,在单张 RTX 4090 上大约需要 2 到 3 小时能收敛。但我更推荐直接用官方仓库提供的预训练权重,因为从头训练对 reward shaping 的调参经验要求比较高,新手很容易出现“仿真里走得挺好,一到实机就抽风”的情况。
2.2 训练环境搭建的版本坑
我用的训练环境是:
- Ubuntu 20.04
- Python 3.8
- PyTorch 1.13(不要用 2.x,legged_gym 旧版本不兼容)
- Isaac Gym Preview 4
- CUDA 11.7
这几个版本之间是有依赖关系的:Isaac Gym Preview 4 官方只支持 PyTorch 1.13 和 Python 3.8 到 3.10,装的时候千万别自作主张升级。当时我为了省事用了 Python 3.10,结果一堆 C++ 扩展编译不过去,来回折腾了大半天。
训练完成后,模型保存为.pt文件,里面是一个包含 actor 和 critic 两个网络的 state_dict。部署到实机只需要 actor 网络,也就是策略网络,它的输入是机器人状态(躯干姿态、角速度、关节角度、关节角速度、上次动作等),输出是 12 个关节的目标角度增量。对 Microduck 来说,12 个关节 = 4 条腿 × 3 个自由度(横滚、俯仰、膝关节)。
2.3 策略网络结构分析
Microduck 的 actor 网络是一个三层 MLP:
输入层(约 50 维) -> 全连接(256) -> ReLU -> 全连接(256) -> ReLU -> 全连接(12)输入维度为何是 50 多?展开来看:躯干姿态四元数(4)、角速度(3)、重力投影(3)、关节角度(12)、关节角速度(12)、上次动作(12)、以及命令速度(3,包括前进、横向、转向),再加一些历史状态,加起来 50 到 60 维。输出是 12 个关节角度增量。
这种结构意味着:推理计算量非常小,单次前向传播大约只有 2 到 3 万次浮点运算,在 RK3566 的 CPU 上跑一次不到 1 毫秒。所以整个控制周期的瓶颈根本不在神经网络推理,而在传感器读取、运动学解算和串口通信。明白了这一点,部署策略就清晰了:把算力留给传感器融合和控制循环,而不是纠结 NPU。
3. 模型转换:从 PyTorch 到 ONNX 再到实机推理
3.1 为什么不直接用 PyTorch 推理
RK3566 的 CPU 是 ARM 架构,PyTorch 虽然提供了 ARM Linux 的预编译包,但实测有两个问题:一是内存占用偏高,一个空模型就吃掉 200 多 MB;二是依赖库体积太大,整个 site-packages 打包出来接近 1 GB。对一块只有 2 到 4 GB 内存的开发板来说,太奢侈了。
所以我的做法是:把 actor 网络导出为 ONNX 格式,然后用 ONNX Runtime 的 CPU 后端在板子上做推理。ONNX 文件只有几百 KB 到 1 MB,ONNX Runtime 的 ARM 版库也就 20 多 MB,整体轻量得多。
3.2 导出 ONNX 的完整过程
先加载训练好的模型,然后只导出 actor:
import torch from legged_gym.envs import LeggedRobot from legged_gym.utils import get_args, task_registry # 加载训练配置和模型 args = get_args() env_cfg, train_cfg = task_registry.get_cfgs(args.task) env_cfg.env.num_envs = 1 env, _ = task_registry.make_env(name=args.task, args=args, env_cfg=env_cfg) from legged_gym.utils.helpers import get_load_path load_path = get_load_path(args, train_cfg, 0) model = torch.load(load_path, map_location='cpu') policy = model['model'].actor # 构造 dummy 输入 dummy_input = torch.randn(1, policy.input_dim) # 导出 ONNX torch.onnx.export( policy, dummy_input, "microduck_policy.onnx", input_names=["obs"], output_names=["action"], dynamic_axes={"obs": {0: "batch"}, "action": {0: "batch"}}, opset_version=11 ) print("导出完成")这里有个容易被忽略的关键点:model['model']是完整的ActorCritic实例,actor才是策略网络。导出时要把模型切到 eval 模式,并关掉梯度:
policy.eval() with torch.no_grad(): torch.onnx.export(...)否则导出的图里会包含训练相关的运算节点(比如 dropout、批量归一化的训练分支),推理时行为不一致,实机上表现会很怪。
另一个坑是opset_version。RK3566 的 ONNX Runtime 版本建议用 1.16 以上,对应的 opset 支持到 17 左右。我选 11 是为了保险,兼容性最好。如果你的 ONNX Runtime 版本太新,有些算子被废弃,反而可能加载失败。
3.3 在 RK3566 上安装 ONNX Runtime
这一步看似简单,其实有一个大坑。RK3566 是 ARMv8 架构(64 位),ONNX Runtime 官方提供了manylinux的 ARM 轮子,但默认是 x86 的,不能直接 pip install,必须下 ARM 版:
pip install onnxruntime --platform manylinux2014_aarch64 --only-binary=:all:另一种方式是从源码编译,但不要这么干,交叉编译 ONNX Runtime 至少需要一两个小时,而且很容易遇到 Eigen、FlatBuffers 等依赖的版本冲突,直接用官方轮子最省心。
装完后立刻验证推理是否正常:
import onnxruntime as ort import numpy as np sess = ort.InferenceSession("microduck_policy.onnx", providers=["CPUExecutionProvider"]) obs = np.random.randn(1, 50).astype(np.float32) action = sess.run(None, {"obs": obs})[0] print(action)如果输出是一组数值而不是报错,说明基础链路已经通了。实际部署时,我还会用ort.SessionOptions()设置线程数为 4,并开启图优化,具体后面在控制循环部分讲。
3.4 关于 NPU 的取舍
RK3566 集成了 0.8 TOPS 的 NPU,很多人会本能地想“是不是该把模型扔到 NPU 上跑”。实测下来,对 Microduck 这个策略网络来说,CPU 单次推理约 0.3 到 0.5 毫秒,NPU 上反而要 1 到 2 毫秒——因为 NPU 需要额外做量化、格式转换和拷贝,小模型的收益几乎为零,还没算上 RKNN 工具链的麻烦。除非你的策略网络是视觉模型(比如端到端做图输入的),否则不建议上 NPU。
4. 实机部署:RK3566 上的控制循环与硬件接线
4.1 硬件清单与系统准备
我用的是一块泰山派 RK3566 开发板,4 GB 内存版本,系统是官方 Debian 11。硬件连接上,Microduck 的 12 个舵机(通常是串行总线舵机)接到板子的 UART 串口,IMU(惯性测量单元)接 I2C 或 SPI,我这里用的是 I2C。
系统层面有四个必须调的地方:
- 串口设备权限:把当前用户加入
dialout组,否则没有权限打开/dev/ttyS0或/dev/ttyUSB0。 - 串口别名固定:RK3566 的串口设备可能在两次开机后名称变化,建议写 udev 规则固定。
- CPU 性能模式:默认的 ondemand 调速器会导致推理延时抖动,建议切到 performance。
- 减少日志写入:SD 卡的 I/O 延迟会干扰控制循环,最好把日志写到内存盘(
/tmp)或只写关键事件。
4.2 UDP 下发指令与舵机协议
Microduck 的舵机一般是串行总线舵机(类似 LX-16A 或 ST3215),协议通常是:帧头 + ID + 指令 + 参数 + 校验。我需要把策略网络输出的 12 个关节角度增量解析成舵机目标角度,再打包成串口帧发出去。
核心控制循环的伪代码如下:
import onnxruntime as ort import serial import numpy as np import time # 初始化 ONNX Runtime sess_options = ort.SessionOptions() sess_options.intra_op_num_threads = 4 sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL sess = ort.InferenceSession("microduck_policy.onnx", sess_options, providers=["CPUExecutionProvider"]) # 串口初始化 uart = serial.Serial('/dev/ttyS0', 1000000, timeout=0.01) # IMU 初始化(伪代码) imu = init_imu() # 状态缓存 prev_action = np.zeros(12, dtype=np.float32) target_joint_pos = np.zeros(12, dtype=np.float32) cmd_vel = np.array([0.2, 0.0, 0.0], dtype=np.float32) # 目标前进 0.2 m/s # 控制频率 100 Hz control_period = 0.01 next_time = time.time() while True: # 1. 读取 IMU 姿态和角速度 quat, gyro = imu.read() # 从四元数计算重力投影 gravity_proj = quat_to_gravity(quat) # 2. 读取舵机当前实际角度 joint_pos = read_servo_positions(uart) joint_vel = compute_joint_vel(joint_pos) # 差分求速度 # 3. 构造观测向量 obs = np.concatenate([quat, gyro, gravity_proj, joint_pos, joint_vel, prev_action, cmd_vel]) # 4. 策略推理 action = sess.run(None, {"obs": obs.reshape(1, -1)})[0].flatten() # 5. 累积目标角度并下发给舵机 target_joint_pos += action * 0.25 # 缩放因子 send_servo_commands(uart, target_joint_pos) # 6. 保存上次动作,并等待下一个周期 prev_action = action.copy() next_time += control_period sleep_time = next_time - time.time() if sleep_time > 0: time.sleep(sleep_time) else: print("WARNING: 控制周期超时")这段代码看起来简单,但实机上跑起来有很多细节。
4.3 实机调试的五个关键参数
动作缩放因子。这是我从仿真到实机遇到的第一个大坑。训练时策略输出的动作范围是在仿真环境里标定过的,映射到真机舵机时,直接累加会有两个问题:累加值漂移导致目标角度越界;动作增量过大导致舵机瞬间冲撞机械限位。我最后把缩放因子设成 0.25,同时加了目标角度clip到 [-1.5, 1.5] 弧度的限制。
IMU 数据质量。RK3566 的 I2C 读 IMU 频率如果太低,会导致姿态估计延迟。我实测把 I2C 时钟调到 400 kHz,MPU6050 的输出频率设到 200 Hz,控制循环每次去读最新值,这样延迟可以控制在 5 毫秒左右。注意不要用阻塞式读取,否则 IMU 慢一拍,整个控制循环都会被拖住。
舵机通信超时。串行舵机在 1 Mbps 波特率下,单条指令大约 0.1 毫秒,但 12 个舵机逐个发送如果串行做,就要 1.2 毫秒,加上舵机返回的反馈帧,一个周期内通信开销可能会到 3 到 5 毫秒。我在实机上把舵机反馈关闭了,改成只发指令不读角度,关节位置反馈通过运动学估算,省下的时间足够把控制频率稳在 100 Hz。这个取舍在初期调试阶段尤其重要。
控制频率检测。我在控制循环里加了一个超时打印,如果连续几个周期实际运行时间都超过 10 毫秒,就要优化代码而不是继续加功能。实测发现,Python 层面的列表拼接和 numpy 数组反复转换是最大的性能杀手,建议所有观测向量直接用 numpy 拼接,不要用列表 append。
上电时序。这是硬件上最容易忽略的。舵机供电和板子供电要分开,否则舵机启动瞬间的大电流会把 RK3566 拉复位。我用的是 5V/5A 的稳压模块单独给舵机供电,板子用独立的 5V/2A 供电,共地处理,这样 USB 调试口也不会被干扰。
5. 常见问题与排查技巧实录
5.1 系统识别到 RK3566 但显示为 ADB 设备
这个问题的典型现象是:泰山派通过 USB 连接到电脑,lsusb能看到设备,但设备出现在 ADB 设备列表里,而不是作为一个串口或网络设备。我排查时发现,这是因为板子默认开启了 ADB 调试模式,USB 功能被模拟成了 ADB 接口。
解决办法有两种:
- 在板子的系统设置里关闭 ADB,切换为“USB 网络共享”或“USB 串口”模式。
- 如果板子已经进不了系统,无法界面操作,可以通过
adb shell登录进去,然后修改/etc/init.d/下的 USB 配置脚本,把g_adb的加载注释掉,重启即可。
另外要留意,如果 USB 设备枚举出来但无法通信,先确认线材是不是支持数据,而不是充电线。这个问题看着低级,但我真的在调试中浪费了半小时。
5.2 舵机抖动与策略震荡
实机上最常见的现象是机器人站在原地疯狂抖动,像帕金森一样。排查下来,原因通常有三个:
- 动作频率太高:舵机的机械响应速度跟不上策略输出频率。此时要么降低控制频率到 80 Hz,要么加大动作缩放因子,降低单步动作幅度。
- 关节速度估计噪声大:差分求关节速度时,传感器噪声会被放大,策略网络吸入了噪声,输出抖动。我加了低通滤波,
filtered_vel = 0.8 * last_vel + 0.2 * current_vel,抖动明显下降。 - 舵机死区:低成本舵机有死区问题,微小角度变化无法执行,形成振荡。我设置了最小角度增量阈值,小于 0.005 弧度的增量直接忽略。
5.3 推理延时波动大
如果控制循环偶尔出现一次 20 毫秒的峰值延迟,多半是内存分配或日志写入导致的。ONNX Runtime 在第一次推理时会做内存初始化,后续推理通常稳定,但如果代码里频繁创建 numpy 数组、做类型转换,就会触发内存碎片。
我最终的优化方案:
- 所有临时数组在一开始就预分配好,循环内只做切片和赋值。
- 关掉 print 日志,改用按条件的
logger.info。 - CPU 调到 performance 模式:
cpupower frequency-set -g performance。 - 给控制线程设置高优先级:
os.sched_setscheduler(0, os.SCHED_FIFO, os.sched_param(50))。
5.4 实机走不稳,仿真走得很好
这几乎是所有强化学习机器人部署的终极难题。我用 Microduck 也遇到了。核心原因是仿真与实机的“域差异”(sim-to-real gap)。解决思路有两个方向:
- 系统辨识:测量舵机实际响应延迟、死区、力矩限制,然后把这些参数放进仿真环境里重新训练。Microduck 的仓库里提供了域随机化(domain randomization)的开关,建议把关节摩擦、质量、控制延迟这几个参数加上 ±20% 的随机扰动,训练出来的策略鲁棒性明显提升。
- 分阶段部署:不要一上来就全姿态控制。先在板子上跑固定的站立姿态,确认舵机、IMU、串口通信都正常;然后只做姿态修正(不上策略网络),用 PID 把躯干稳住;最后再切换到策略输出。这个过程虽然慢,但能帮你把问题逐层剥离,而不是出了故障一头雾水。
5.5 控制频率上不去
如果控制频率一直上不到 100 Hz,先定位瓶颈在哪里,不要盲目优化。我用的排查方法很简单:在每个环节打时间戳,算出 IMU 读取、策略推理、舵机通信各自耗时。
以我的实测数据为例:
| 环节 | 耗时(毫秒) | 说明 |
|---|---|---|
| IMU 读取 | 1.5 | I2C 频率 400 kHz |
| 关节角度读取 | 2.0 | 关闭反馈后降为 0.5 |
| 策略推理(ONNX Runtime) | 0.4 | CPU 四线程 |
| 舵机指令发送 | 2.0 | 12 个舵机串行发送 |
| 其他开销 | 1.0 | 数组拷贝、控制逻辑 |
| 合计 | 6.9 | 留有余量,可跑 100 Hz |
如果舵机通信占了大头,可以考虑用 DMA 方式发送,或者换更高性能的串行舵机。如果 IMU 读取占大头,可以换 SPI 接口的 IMU,速度比 I2C 快一个数量级。
6. 实机效果的进一步优化与扩展
6.1 把控制频率提到 200 Hz
100 Hz 是 Microduck 的典型配置,但如果你发现策略在实机上表现还是不够平滑,可以试试把控制频率拉到 200 Hz。前提是:舵机支持更高的指令频率,IMU 输出频率也要同步提高,否则观测数据跟不上控制频率,反而会引入噪声。
我实测把频率从 100 Hz 提到 200 Hz 后,动作平滑性有明显改善,但舵机温度上升也更快。建议根据舵机负载情况和机器人运动模式来权衡,不要盲目拉高。
6.2 加入遥控器指令
Microduck 支持遥控器输入,把用户的操作指令转化为目标速度。常用的有 PS2 手柄或简单的 2.4G 遥控器。实机上我使用了一款串口透传的 2.4G 接收机,把摇杆输出映射为cmd_vel的三个分量:前进速度、横向速度、偏航角速度。这样调试时可以远程控制机器人的朝向和移动,方便多角度观察步态。
6.3 云端记录与离线分析
实机调试时,我习惯把每次运行的传感器数据、策略输出和延时记录成 CSV 文件,然后上传到电脑端做离线分析。数据记录不要写到 SD 卡,而是写到/tmp内存盘,等跑完再 copy 出来。这样既不影响控制循环稳定性,又能完整回放问题场景。
有一回机器人突然侧翻,我通过回放数据发现是 IMU 的横滚角出现了近 30 度的跳变,原因是一个 I2C 通信错误导致了数据错位。若不是有日志回放,这类偶发问题排查起来会非常耗时。
6.4 从强化学习到传统控制的混用思路
如果你的目标不只是“走起来”,还想做更复杂的任务(比如越障、上斜坡),纯粹依靠一套端到端策略会很吃力。我个人的经验是:用强化学习策略负责底层足端轨迹和姿态稳定,用上层传统控制(比如 MPC 或 PID)负责路径规划和导航。这样可以把强化学习的“涌现能力”和传统控制的“稳定性保证”结合起来,这也是近两年很多机器人团队采用的混合架构。
Microduck 的硬件算力有限,跑上层规划算法时会吃力,建议上层控制放在上位机(树莓派或 x86 工控机),RK3566 只做底层策略推理和舵机驱动,通过串口或以太网与上位机通信。
6.5 训练更鲁棒的策略
最后再聊回训练侧。如果你的 Microduck 实机表现始终不佳,我强烈建议你花时间在域随机化上,而不是反复调实机代码。在 legged_gym 的配置里,找到domain_rand的开关,把以下几个参数加上随机范围:
# 在 cfg 里启用: cfg.domain_rand.randomize_friction = True cfg.domain_rand.friction_range = [0.5, 1.25] cfg.domain_rand.randomize_base_mass = True cfg.domain_rand.added_mass_range = [0.0, 0.2] # kg cfg.domain_rand.randomize_motor_strength = True cfg.domain_rand.motor_strength_range = [0.9, 1.1] cfg.domain_rand.randomize_joint_friction = True cfg.domain_rand.joint_friction_range = [0.0, 0.05]加上这些随机化参数后重新训练,策略对实机差异的容忍度会明显提高。我记得第一次加上摩擦随机化重训后,Microduck 在实机上从只能走两步变成能连续走十几步,效果立竿见影。
7. 写在最后的一点体会
整个 Microduck 部署流程走下来,我最大的感受是:强化学习机器人的难点不是训练,而是“让训练好的策略走出仿真”。仿真环境里奖励函数给你打分,实机上没人给你打分,只有硬邦邦的地面和抖动的舵机。每一步转化(PyTorch 到 ONNX、ONNX 到实机推理、实机到闭环控制)都是在缩小仿真与现实之间的缝隙。
如果你也在折腾类似的事儿,我只有一个建议:先把数据的流向捋清楚。从 IMU 读到策略输出,再到舵机响应,整个过程里每一步的数据格式、时间戳、延迟都要了如指掌。能在仿真里做好的事,绝不在实机上折腾;能在离线阶段验证的,绝不上电验证。这样踩坑的速度会慢一些,但每一步都会很扎实。
另外,Microduck 这个项目本身还在持续迭代,社区里的 issue 和 PR 都挺活跃,遇到问题先搜一遍 issue,大概率有人踩过同款坑。实在搞不定,把自己排查的过程写清楚再提问,效率会高很多。
希望这篇手记能帮你少走一些弯路,早日看到自己的 Microduck 稳稳当当地走出第一步。