ROS 2 怎么直接跟机器人对话?unitree_ros2 的接入机制
- 看懂 ROS 2 接入一台真实机器人需要对齐的三件事,以及每一步配置在防什么坑
- 理解 rmw 这层抽象为什么存在,以及为什么这里必须指定 CycloneDDS 而不是随便哪个实现
- 分清「原生 DDS 互通」和「桥接节点转换」两种接入路线,知道 to_real 这类包的定位
假设你手上有一台支持 SDK2 的宇树机器人,你想让它进 ROS 2。
按常规经验,你多半会先去找「驱动包」——那种在机器人的私有协议和 ROS 话题之间来回翻译的节点。串口设备是这么接的,很多相机是这么接的,绝大多数硬件都是这么接的。
但 unitree_ros2 这个仓库里没有这样的节点。
翻遍它的文件树,你找不到一个叫 unitree_driver 或者 unitree_bridge 的东西。仓库里只有两样东西:几个消息定义包,和三个 shell 脚本。
这不是仓库偷懒。README 开头那句话把原因说得很直白:SDK2 的通信建在 CycloneDDS 上,而 DDS 同样是 ROS 2 的通信机制,所以底层是兼容的——ROS 2 的 msg 可以直接用来跟机器人通信和控制,不需要再包一层 SDK 接口。
换句话说,这里要做的不是翻译,是对齐。 两边本来就说同一种语言,只是需要把词汇表、地址簿和电话线对上。这篇就把这三样东西一件件拆开看。
按惯例先说边界:本文基于该仓库公开的 README 与文件树结构,我没有真机可以验证,凡涉及运行行为的地方都以官方文档的说法为准。
第一件事:对齐消息定义
仓库的核心目录是 cyclonedds_ws/src/unitree/,下面挂着三个 ROS 2 消息包:
unitree_go/ —— 四足(Go2/B2)的消息
unitree_hg/ —— 人形(G1/H1 系列)的消息
unitree_api/ —— 请求-响应类消息
unitree_go/msg/ 下的清单,和我们在上一篇讲 DDS 消息体系时看到的那份高度重合:LowCmd / LowState、MotorCmd / MotorState、IMUState、SportModeCmd / SportModeState、BmsCmd / BmsState、HeightMap、PathPoint、WirelessController、LidarState、AudioData 等等。
unitree_hg/msg/ 那一份就明显不同了:
LowCmd.msg / LowState.msg
MotorCmd.msg / MotorState.msg
IMUState.msg
BmsCmd.msg / BmsState.msg
HandCmd.msg / HandState.msg
MainBoardState.msg
PressSensorState.msg
HandCmd / HandState 是四足那一侧没有的,PressSensorState、MainBoardState 也是。两套消息包并列摆在一个仓库里,比任何文字说明都更能说清一件事:四足和人形在软件层面被当作两条独立的线在维护。
这里有个细节值得停一秒:这些文件的后缀是 .msg,不是 .idl。
.msg 是 ROS 2 自己的接口定义格式。也就是说,宇树没有拿一份 DDS IDL 硬塞进 ROS 2,而是反过来——用 ROS 2 的原生格式重新描述了这套消息,再让 ROS 的工具链把它编译成 DDS 那一侧认识的类型。
这个判断有直接证据。README 的依赖清单里,除了 CycloneDDS 的 rmw 实现,还装了一个 rosidl-generator-dds-idl。这个包的名字已经把它干的事说完了:从 ROS 的接口定义生成 DDS IDL。
对齐就是在这一步发生的。只要生成出来的 DDS 类型和机器人固件那一侧的类型逐字段一致,两边就能直接通信——DDS 是靠类型和话题名匹配来配对的,它不关心对面那个进程是不是 ROS 节点。
还有一点容易被忽略:这个仓库里没有 std_msgs、geometry_msgs 这些标准消息包。因为不需要——它们本来就随 ROS 2 一起装在系统里。仓库只补自己独有的那部分词汇,剩下的复用生态既有的,这是一个克制且正确的选择。
第二件事:对齐话题命名
消息类型对上了,还得知道去哪个话题上找它。README 给的话题名是这样的:
sportmodestate —— 运动模式状态(位置、速度、足端、步态)
lf/sportmodestate —— 同上,低频版本
lowstate —— 低层状态(电机、电源、IMU、足端力)
lf/lowstate —— 同上,低频版本
/wirelesscontroller —— 手柄摇杆与按键
/lowcmd —— 发低层控制指令
/api/sport/request —— 发运动模式请求
utlidar/cloud —— 激光雷达点云
同一份数据挂两个话题、其中一个带 lf/ 前缀(README 说 lf 就是 low frequency),这个设计很实在。控制器需要最高频率的状态,但一个只想画曲线的监控面板不需要。 让订阅方按需选频率,比让每个订阅方自己降采样要省事得多,也省下大量本来会被丢掉的数据传输。
/lowcmd 和 /api/sport/request 这两条并列摆着,正好把高层与低层两套接口的分野摊在了明面上:往 /lowcmd 发的是 unitree_go::msg::LowCmd,里面是一整个电机指令数组,每个电机带目标位置、速度、力矩和 kp/kd;往 /api/sport/request 发的是 unitree_api::msg::Request,说的是「摆成某个姿态」「往前走」这种意图。
unitree_api 这个包本身也值得看一眼。它的消息清单是:
Request.msg / RequestHeader.msg / RequestIdentity.msg
RequestLease.msg / RequestPolicy.msg
Response.msg / ResponseHeader.msg / ResponseStatus.msg
Header、Identity、Lease、Policy——这是一套完整的 RPC 信封结构,被架在发布-订阅的底座上跑。README 里那段示例代码的写法也很典型:你不手工填 Request 的字段,而是用 SportClient 这个辅助类把参数转成 Request 消息,再交给普通的 ROS 2 publisher 发出去。客户端类只负责组装消息,传输这件事完全交还给 ROS 2 自己。 这种分工,和我们在SDK2 架构那篇里看到的思路是一脉相承的。
第三件事:对齐 DDS 网络
前两件事都是「写代码的时候对」,这一件是「跑起来的时候对」,也是新手最容易卡住的地方。
仓库根目录有三个脚本:setup.sh、setup_local.sh、setup_default.sh。README 把 setup.sh 的内容整个贴了出来,一共就干四件事:
source /opt/ros/<distro>/setup.bash # 1. ROS 2 环境
source $HOME/unitree_ros2/cyclonedds_ws/install/setup.bash # 2. 消息包环境
export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp # 3. 指定中间件实现
export CYCLONEDDS_URI='<CycloneDDS>...<NetworkInterface name="enp3s0" .../>...' # 4. 指定网卡
前两行是常规的环境加载。真正的门道在后两行。
为什么要指定 rmw 实现
ROS 2 有一层叫 rmw(ROS middleware interface) 的抽象。上面是 rclcpp / rclpy 这些客户端库,下面是具体的 DDS 厂商实现,中间靠 rmw 这个统一接口隔开。你写的节点代码只依赖 rclcpp,底下换哪家 DDS,理论上不用改一行。
这层抽象带来的好处是真实的:不同 DDS 实现在许可证、内存占用、实时性、跨平台支持上各有取舍,能换就意味着能选。ROS 2 也确实支持通过环境变量在运行时切换。
但抽象只到 API 这一层为止,线上格式不归它管。
两个用不同厂商实现的 ROS 2 节点能不能互通,取决于双方在 DDS 互操作协议上的实现细节是否真的一致。这是个理论上应该成立、实际上要看具体情况的事。而机器人那一侧的固件跑的是 CycloneDDS,你没法改它——所以只能让电脑这一侧也用 CycloneDDS。RMW_IMPLEMENTATION=rmw_cyclonedds_cpp 这一行就是在做这件事。README 在这里还专门给了一个链接,指向 ROS 官方那篇讲不同中间件供应商的文档。
这也解释了安装步骤里最反直觉的那一段。README 要求:编译 CycloneDDS 之前,终端里绝对不能先 source ROS 2 环境,如果 ~/.bashrc 里加过 source 那行,得先注释掉;等 CycloneDDS 编好了,再 source ROS 2 环境去编译 unitree_go 和 unitree_api。
顺序完全是反的,为什么?因为 ROS 2 的安装里自带了一份 CycloneDDS。你要在源码里编一份指定版本的出来,环境里却已经躺着另一份,路径、头文件、库都可能串。README 的说法是这会导致编译出错,还额外给了一条 export LD_LIBRARY_PATH=... 的应急建议——从这条建议也能反推出来,翻车确实翻在库路径上。
README 同时说明,用 Humble 的话这一步可以跳过。至于系统与发行版的搭配,README 给出的是 Ubuntu 20.04 配 Foxy、Ubuntu 22.04 配 Humble(推荐后者),这个组合以官方仓库当前文档为准。仓库里还备了 .devcontainer/ 和 .github/workflows/,README 建议编译遇到问题时去参考 CI 里的编译脚本——这是个很实用的提示,CI 配置往往是一个项目里最诚实的安装文档,因为它必须真的能跑通。
为什么要指定网卡
第四行的 CYCLONEDDS_URI 是一段内联 XML,作用是把 CycloneDDS 的网络接口锁定到某一块网卡上。
DDS 的节点发现依赖多播。默认情况下,它会在所有可用网络接口上撒网找同伴。一台开发机上通常有好几个接口:WiFi、有线、Docker 虚拟网桥、各种 VPN 的虚拟网卡。让 DDS 在这堆接口上一起做发现,轻则多播包乱飞、发现变慢,重则从错误的接口出去、根本找不到机器人。
配置流程也是配套的。README 的做法是:网线连上机器人,用 ifconfig 看清楚是哪个接口(README 的例子是 enp3s0),在系统网络设置里把这个接口的 IPv4 改成手动,配一个 192.168.123. 网段里的固定地址、掩码 255.255.255.0,然后把同一个接口名填进 setup.sh 的那段 XML 里。具体地址以仓库当前文档为准。
注意这里是静态 IP,不是 DHCP。 机器人那一侧的地址是固定的,你这一侧也得钉死在同一网段,没有 DHCP 服务器帮你分配。这一步在 README 里配了两张截图,说明它确实是个高频卡点。
另外两个脚本对应另外两种场景:setup_local.sh 把接口设成回环 lo,给「机器人没接、但想跑仿真」的情况用;setup_default.sh 干脆不指定接口。三个脚本并列,等于把三种典型场景各自固化成一条命令——不用记 XML 怎么写,选一个 source 就行。这种小设计不起眼,但能省掉大量重复踩坑。
配置完成后 README 建议重启电脑再测试,然后就是最朴素的验证方式:
source ~/unitree_ros2/setup.sh
ros2 topic list
ros2 topic echo /sportmodestate
如果 ros2 topic list 里能看见机器人的话题,三件事就都对上了。README 里贴了这两条命令的输出截图,也贴了 read_motion_state 这个例子打印出来的位置、速度、足端状态、步态类型日志。
换一条路线:to_real 是怎么回事
上面这套「原生互通」的前提是机器人本身说 DDS。如果不是呢?
unitree_ros2_to_real 就是另一条路线的样本。它的 README 说明这个版本对应的是老一代的 unitree_legged_sdk 和 Go1,包结构是两个:
unitree_legged_msgs —— 消息定义
unitree_legged_real —— ROS 与真机之间的接口
关键差别在使用方式。做任何控制之前,你得先起一个节点:
ros2 run unitree_legged_real ros2_udp highlevel
# 或
ros2 run unitree_legged_real ros2_udp lowlevel
README 对它的定位说得很清楚:这是一个连接用户和机器人的桥。名字里的 udp 已经泄了底——底层走的是 UDP,不是 DDS。这个进程一边说 ROS 2 话题,一边说 SDK 的 UDP 协议,在中间做转换。
这就是 to_real 这类包普遍在解决的问题。
ROS 生态里到处能看到 _to_real、_hardware_interface、_driver 这样的后缀。它们的共同处境是:上层已经有一套按 ROS 接口写好的代码(仿真里跑通的、别人写的算法、现成的控制器),下层是一台说着私有协议的真实硬件。中间必须有个东西把两边接上,并且要让上层代码感觉不到自己面对的是仿真还是真机。这层桥的价值不在技术含量,在于它让「仿真里验证、真机上部署」这条路走得通。
对照着看,unitree_ros2 走的是另一条路:既然底层协议本来就一样,那就把桥拆掉,让 ROS 2 直接说话。 少一个进程,少一跳转换,也少一处会挂的东西。
to_real 的另外两处细节也值得记下来。网络配置那一段除了改脚本,README 还给了把静态 IP 写进 /etc/network/interfaces 的做法——同样是静态 IP、同一个网段,两代方案在这一点上完全一致。还有一段安全提示:做低层控制前,要先用手柄让机器人坐下、切到关节级控制模式,并且必须把机器人吊起来。这类提示在任何直接操作关节的场景里都不该跳过,低层控制绕开了机器人自带的所有保护逻辑。
接进来之后,你立刻拿到了什么
回到最初的问题:费这些劲把机器人接进 ROS 2,图什么?
图的是数据一旦以 ROS 2 话题的形式出现,整个生态的现成工具就自动可用了。README 直接演示了两样:
rviz 可视化。 README 给的路径很典型——ros2 topic list 找到雷达话题 utlidar/cloud,用 ros2 topic echo --no-arr 看清它的 frame_id(例子里是 utlidar_lidar),然后开 rviz2,加一个 PointCloud 显示项、把 Fixed Frame 改成这个 frame_id,点云就出来了。注意这个流程里没有一行代码,全是通用工具在通用数据格式上的操作。
rosbag 录包回放。 仓库的例子清单里有一个 record_bag,对应 example/src/src/record_bag.cpp。录包这件事对机器人调试的意义比想象中大:真机跑一次的成本高、状态难复现、出问题的那一瞬间往往来不及看。把话题录下来,之后可以反复回放、慢放、喂给不同版本的算法比对——它把「必须守在机器人旁边调试」变成了「拿着数据在工位上调试」。
除了 README 演示的这两样,ROS 生态里还有大量按话题接口写的通用组件:坐标变换用 tf,建图与定位、路径规划各有成熟的开源栈。这里要说句实在话——这是 ROS 生态本身的性质,不是这个仓库额外做了什么。 能不能真的把某个建图或导航方案接上去,取决于你的坐标系、时间戳、传感器话题是否满足那个方案的要求,这些 unitree_ros2 的 README 并没有涉及,得自己动手对。
但即使打了这个折扣,账仍然是划算的。很多团队选 ROS,选的从来不是 ROS 本身,而是这一大堆不用自己写的东西。 一个可视化工具、一个数据录制回放系统、一套坐标变换库、若干个能跑的算法栈——单独做哪一个都不难,全都自己做一遍,一年就没了。
三件事,一句话
回头看,ROS 2 接入一台真实机器人,本质上就是让三样东西对齐:
消息定义要一致——字段逐个对上,DDS 才认得出这是同一种数据;仓库用 .msg 写、用 rosidl 生成 IDL,是让 ROS 工具链来保证这份一致。
话题名要一致——发布订阅靠名字配对,sportmodestate、/lowcmd、/api/sport/request 这些名字就是双方的约定。
DDS 的实现和网络要一致——同一个中间件实现、同一块网卡、同一个网段,缺一个就是「代码都对但一条消息也收不到」。
安装文档里那些看起来琐碎的步骤,几乎每一条都能归到这三件事里的某一件。理解了这个框架,你再遇到别家机器人的 ROS 2 接入文档,也知道该往哪儿看、卡住时该查什么。
想把这条线往下走,这一卷后面会接着讲仿真环境怎么和这套话题接口对齐;对 ROS 本身还不太熟的话,机器人操作系统那篇是更合适的起点。