ROS2 入门与实践

9.4k 词

本文结合 rm_pid_tuner(ROS Noetic 自动调参系统)的实际开发经验,讲清楚 ROS2 到底改了什么、为什么要改,以及把 ROS1 工程迁到 ROS2 时每一步会踩什么坑。不满足于”会建包、会发布订阅”,而是让你理解 DDS 通信模型 → QoS 契约 → 执行模型 → 迁移实战 的完整链路。

为什么要有 ROS2:ROS1 的架构性缺陷

ROS1 的核心是 roscore(master):所有节点启动时向 master 注册,通信双方通过 master 交换地址信息后建立点对点连接。这个架构有三个致命问题:

  1. 单点故障:master 挂了整个系统瘫痪。机器人比赛/产线上不可能接受”调度器进程崩溃 = 全车失联”
  2. 无实时性保证:roscpp 的发布订阅基于 TCP/UDP socket,传输层不可控,回调触发时机不确定
  3. 无安全/多机支持:ROS1 的跨机通信需要所有机器共享同一 ROS_MASTER_URI,没有加密、没有权限隔离,多机器人场景下 QoS 完全无法区分

ROS2 的答案是:把通信层整个换成 DDS(Data Distribution Service)。DDS 是 OMG 标准,自带服务发现(不依赖中心节点)、QoS 策略、多机组播发现。ROS2 里不存在 master——节点通过 DDS 的 RTPS 发现协议(SDP:向组播地址 239.255.0.1:7400 广播 announcement,收到者单播回复地址信息,交换 topic/类型/QoS 后建立数据通道)在同一网段内互相发现,这从架构上消灭了单点故障。

踩坑:跨网段/跨机器通信失败,90% 是组播被交换机或防火墙挡了。实战中 RoboMaster 哨兵/步兵多机通信,要么保证同一二层网段,要么配置 ROS_DOMAIN_ID 隔离 + 放行 UDP 发现端口(7400-7500)。Docker 里 --net=host 能通、bridge 网络不通,也是这个原因(下文 Docker 部分再讲)。

QoS:通信双方必须签的”契约”

QoS(Quality of Service)是 ROS2 相对于 ROS1 最重要的新概念。ROS1 里发布方和订阅方只要 topic 名+类型一致就能通信;ROS2 里还要求 QoS 策略兼容,不兼容直接收不到消息且不报错——这是从 ROS1 迁过来的人最先疯掉的地方。

常用策略:

策略 取值 含义 实战建议
Reliability RELIABLE / BEST_EFFORT 是否保证送达 控制指令用 RELIABLE;图像/点云用 BEST_EFFORT
Durability VOLATILE / TRANSIENT_LOCAL 晚加入的订阅者能否收到历史数据 地图、静态参数用 TRANSIENT_LOCAL
Depth 1~N 队列深度 图像给 1(丢旧帧优于积压),日志给大值
Deadline 时长 两次发布的最大间隔 心跳/看门狗检测用

为什么图像传输必须 BEST_EFFORT + depth=1? 考虑 30fps 的 720p 图像,每帧约 1.3MB。用 RELIABLE 时,网络抖动会导致 DDS 重传丢帧并阻塞后续帧,订阅方拿到的永远是”迟到的最新帧”,延迟从 33ms 劣化到几百 ms——视觉引导的云台瞄准直接废掉。BEST_EFFORT + depth=1 的语义是”只保留最新一帧,旧的直接覆盖”,丢帧无感、延迟恒定,跟控制回路”宁可丢一拍也不等旧数据”的思想一致。

QoS 兼容性判据(发布方 P 与订阅方 S):P 的 Reliability 必须 ≥ S 的(即 S 要求 RELIABLE 时 P 必须 RELIABLE);Depth 等由 S 决定。可以用命令行验证:

1
ros2 topic info /cmd_vel --verbose   # 显示双方 QoS,Compatibility 一栏直接告诉你是否兼容

构建系统与包结构:ament_cmake + colcon

创建功能包

1
2
3
4
5
# ROS1
catkin_create_pkg my_pkg roscpp std_msgs

# ROS2
ros2 pkg create my_pkg --build-type ament_cmake --dependencies rclcpp std_msgs

编译系统对比

ROS1 ROS2
构建工具 catkin_make / catkin build colcon build
包格式 package.xml (format 2) package.xml (format 3)
CMake find_package(catkin REQUIRED) find_package(ament_cmake REQUIRED)
依赖链接 catkin_package() 自动传递 ament_target_dependencies() 显式声明
安装 可选 必须 install(ament 强制安装阶段)

colcon 的目录布局和 catkin 有本质区别:所有包平铺在 build/、install/、log/ 三个目录下,没有 devel 空间。开发时务必加 --symlink-install,否则改代码要重新 colcon build 整个包:

1
2
colcon build --symlink-install --packages-select my_pkg
source install/setup.bash

CMakeLists 的工程化写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
cmake_minimum_required(VERSION 3.8)
project(my_pkg)

if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
add_compile_options(-Wall -Wextra -Wpedantic)
endif()

find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(std_msgs REQUIRED)

add_executable(controller_node src/controller_node.cpp)
ament_target_dependencies(controller_node rclcpp std_msgs)

install(TARGETS controller_node
DESTINATION lib/${PROJECT_NAME})
install(DIRECTORY launch config
DESTINATION share/${PROJECT_NAME})

ament_package()

坑:忘了 install() 步骤,ros2 run 找不到可执行文件(报 executable not found)——因为 ament 只从 install 空间找程序,不打包进 install 就等于没编译。

元包(metapackage)在 ROS2 里同样靠 <export><build_type>ament_cmake</build_type></export> 标记,CMakeLists 只留 ament_package(),依赖列在 package.xml 的 <depend> 里。

执行模型:executor 与回调饿死

这是 ROS2 与 ROS1 运行时行为上最大的差异。ROS1 每个节点自带多个 spinner 线程,回调并行执行;ROS2 所有回调(订阅、定时器、service)都由 executor 调度,默认 SingleThreadedExecutor 只有一个线程,一个回调里 sleep/阻塞,整个节点卡死。

rm_pid_tuner 里最典型的场景:调参节点同时要 ① 10ms 周期跑调参状态机(定时器回调)② 接收裁判系统/传感器数据(订阅回调)③ 响应调参服务请求。如果全挤在单线程 executor 里,某个回调里一次 rclcpp::spin_until_future_complete 等待 LLM API 返回(网络延迟可能 2 秒),控制状态机就断了。

工程解法——按实时性分线程:

1
2
3
4
5
6
7
8
9
10
11
12
// 控制回路:独立线程 + 独立 callback group,绝不与其他回调混用
rclcpp::CallbackGroup::SharedPtr ctrl_group =
this->create_callback_group(rclcpp::CallbackGroupType::MutuallyExclusive);

rclcpp::TimerBase::SharedPtr ctrl_timer =
this->create_wall_timer(std::chrono::milliseconds(10),
std::bind(&Node::ctrlLoop, this), ctrl_group);

// 其他回调(服务、订阅)走默认 group,交给多线程 executor
rclcpp::executors::MultiThreadedExecutor exec(
rclcpp::ExecutorOptions(), 4 /* 线程数 */);
exec.add_node(node);

坑 1:MutuallyExclusive group 内回调互斥;如果两个定时器要并行,放不同 group。坑 2:写回调里绝不能 sleep/spin,需要异步等待就用 rclcpp::AsyncParametersClient 或把耗时计算丢进 std::async。坑 3:实时性要求高的控制循环,理想做法是绕开 executor——自己起 std::thread 直接 rclcpp::spin_some 之外独立跑 RT 线程,DDS 只做数据搬运。

从 ROS1 迁移实战:以 rm_pid_tuner 为例

把 ROS Noetic 的调参系统迁到 ROS2,API 映射表如下(逐行对应):

ROS1 (roscpp) ROS2 (rclcpp) 注意
ros::NodeHandle nh rclcpp::Node::make_shared("node") 参数要在构造函数里 declare_parameter
nh.subscribe("/topic", 10, cb) create_subscription<T>(..., 10, cb) 队列长度语义≈QoS depth
nh.advertiseService create_service 回调签名 (req, resp) 带 shared_ptr
dynamic_reconfigure 参数系统 + parameter events 见下
ros::Time::now() this->now() / rclcpp::Clock(RCL_SYSTEM_TIME) 注意 use_sim_time
ros::Rate(100).sleep() rclcpp::Rate(100).sleep() 无本质变化
tf::TransformListener tf2_ros::Buffer + TransformListener API 变化大,建议直接用 tf2

动态重配置 → 参数系统

ROS1 的 dynamic_reconfigure(调参时不需要重启节点)在 ROS2 里被参数系统替代,但写法完全不同。ROS1 是生成 cfg 文件 + 回调;ROS2 是声明参数 + 监听事件:

1
2
3
4
5
6
7
8
9
10
11
// 声明参数(带默认值和描述)
declare_parameter<double>("kp", 3.5);
declare_parameter<double>("ki", 0.2);

// 参数变化事件回调(相当于 dynamic_reconfigure 的 update 回调)
rclcpp::ParameterEventHandler param_sub(this);
auto cb = param_sub.add_parameter_callback(
"kp", [this](const rclcpp::Parameter& p) {
RCLCPP_INFO(get_logger(), "kp updated to %.3f", p.as_double());
// 重新计算 PID 参数并下发
});

命令行改参:ros2 param set /tuner kp 4.2。注意:参数回调运行在 executor 线程上,如果回调里做耗时操作(如重新编译参数缓存),一样会饿死其他回调——和上面是同一个坑。

时间 API 的坑:use_sim_time

rclcpp::Clock 默认 RCL_SYSTEM_TIME(墙上时钟),但一旦有人设置 use_sim_time: true(如 bag 回放、gazebo 仿真),所有用 this->now() 的地方都会跳到仿真时间。如果系统里混用——控制节点用系统时间、导航节点用仿真时间——时间戳对不上,TF 直接报 “Lookup would require extrapolation”。迁移时统一从一个 rclcpp::Clock 取时间,或显式声明 rclcpp::Clock(RCL_STEADY_TIME) 用于纯间隔测量(不受任何时间源影响)。

ros_control → ros2_control

ROS1 的 RobotHW 基类 + controller_manager 在 ROS2 里变成 hardware_interface::SystemInterface + 插件化 controller。结构上:

1
2
3
4
controller_manager (node)
├─ controller 插件(如 joint_trajectory_controller)
└─ hardware 插件(你的电机驱动实现)
└─ 通过 hardware_interface 读写关节指令/状态

Controller 继承 controller_interface::ControllerInterface,生命周期由 controller_manager 管理(configure → activate → update → deactivate)。关键点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <controller_interface/controller_interface.hpp>
#include <hardware_interface/types/hardware_interface_type_values.hpp>

class MyController : public controller_interface::ControllerInterface {
public:
controller_interface::CallbackReturn on_init() override {
// 声明参数、读配置
return controller_interface::CallbackReturn::SUCCESS;
}
controller_interface::CallbackReturn on_activate(
const rclcpp_lifecycle::State&) override {
// 拿到硬件句柄:速度/位置接口
joint_ = get_node()->get_parameter("joint").as_string();
return controller_interface::CallbackReturn::SUCCESS;
}
controller_interface::return_type update(
const rclcpp::Time&, const rclcpp::Duration&) override {
// 控制循环:读状态 → 算控制量 → 写指令(与 ROS1 的 update 同构)
return controller_interface::return_type::OK;
}
};

// 插件化注册
#include <pluginlib/class_list_macros.hpp>
PLUGINLIB_EXPORT_CLASS(MyController, controller_interface::ControllerInterface)

继承 hardware_interface 写自己的 rmHW,就是写自己的硬件抽象层——在 ROS1 里是 RobotHW,在 ROS2 里是 SystemInterface。区别是 ROS2 强制把”读”和”写”分成 read()/write() 两个明确接口,controller 与 hardware 完全解耦(换电机驱动不需要改 controller 代码)。

周期与实时性:controller_manager 用 realtime 线程周期调用各 controller 的 update()(默认 100Hz,可配),与 executor 完全独立——这是 ROS2 相比 ROS1 控制栈最大的进步。代价:controller 代码里禁止内存分配/加锁/打印等非确定操作,调试日志要写到循环外。

Docker 开发环境

一个能用的 Dockerfile(含非 root 用户)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
FROM osrf/ros:humble-desktop

# 非 root 用户(容器内以 root 跑 colcon 会污染 install 目录权限)
ARG USERNAME=dev
RUN useradd -m $USERNAME && echo "$USERNAME ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
USER $USERNAME
WORKDIR /home/$USERNAME/ros2_ws

# 常用工具
RUN sudo apt update && sudo apt install -y \
python3-colcon-common-extensions \
python3-rosdep \
ros-humble-ros-base \
git vim tmux
1
2
3
4
5
6
7
8
9
docker build -t ros2-dev .
# --net=host:让 DDS 组播发现直接走宿主机网卡
# -v 挂载工作空间 + X11(rviz2 可视化)
docker run -it --rm --net=host \
-v $HOME/ros2_ws:/home/dev/ros2_ws \
-e DISPLAY=$DISPLAY \
-v /tmp/.X11-unix:/tmp/.X11-unix \
-v $HOME/.Xauthority:/home/dev/.Xauthority \
ros2-dev

DDS 在 Docker 里的坑

  • --net=host 是 ROS2 容器的事实标准。用 bridge 网络时容器内组播地址(239.255.0.1)被 NAT 隔离,节点互相发现不了;--net=host 直接共享宿主机网络栈,发现协议原样通过
  • 多容器/多机共用一个网络时用 ROS_DOMAIN_ID 隔离(export ROS_DOMAIN_ID=1)——不同域 ID 的节点物理上不会互相发现,比 ROS1 的命名空间隔离可靠得多
  • 大消息(点云/图像)跨容器走 UDP 会打爆带宽,可用 iceoryx 共享内存传输(RMW_IMPLEMENTATION=rmw_iceoryx_cpp),同机零拷贝,延迟从 ~1ms 降到 ~0.1ms 量级——多传感器机器人强烈建议

CLion 配置 ROS2

  1. 在 CLion 中打开 ROS2 工作空间(src/ 目录),Build Tool 选 colcon
  2. CMake 参数添加 -DCMAKE_EXPORT_COMPILE_COMMANDS=ON(代码提示/静态检查用)
  3. 必须在已 source 过 ROS2 环境的终端中启动 CLion(source /opt/ros/humble/setup.bash),否则 CMake 找不到 ament 包
  4. 调试节点:Run/Debug Configurations 选 colcon + 你的可执行文件;或先 ros2 run 起节点再 attach(gdb)——对调参节点这种带状态机的,attach 方式更实用(--ros-args -r __ns:=/debug 换命名空间起第二实例,可与正式实例并行对比日志)。

关键命令速查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 编译(报 has-built-by-catkin_build 错误 = 混用了 catkin/colcon 缓存,先清)
rm -rf build/ install/ log/
colcon build --symlink-install
source install/setup.bash

# 检查依赖
rosdep check --from-paths src --ignore-src -r -y

# 调试三板斧:节点/话题有没有起来 → 消息内容 → QoS 兼容性(收不到消息先查这个)
ros2 node list && ros2 topic list
ros2 topic echo /cmd_vel --once
ros2 topic info /cmd_vel --verbose

# 生命周期调试
ros2 lifecycle get /controller
ros2 lifecycle set /controller activate

踩坑清单(实战汇总)

  1. 收不到消息先查 QoS:ros2 topic info --verbose,Reliability/Durability 不兼容是头号原因,且系统不报错
  2. 单线程 executor 饿死控制回路:控制/状态机回调与网络/服务回调混在一个节点,必须 MultiThreadedExecutor + 独立 callback group
  3. colcon 没 install 目标:ros2 run 报 executable not found,看 CMakeLists 的 install(TARGETS ...)
  4. 跨机通信失败:先查组播/二层网络,再试 --net=host,最后换 RMW(Cyclone)
  5. 混用时间源:use_sim_time 与系统时间混用导致 TF 报 extrapolation,统一时钟源
  6. ROS1 迁移隐藏差异:advertise 的 queue size 在 ROS2 里是 QoS depth,语义是”未消费消息上限”而非”发送缓冲”
  7. Docker 权限:以 root 建的 install/ 目录,非 root 用户 source 报权限错——容器内统一用户,或把 install 挂到可写卷

迁移 Checklist(收工前逐项过):① ros2 doctor 全绿 ② 所有 topic 用 --verbose 查过 QoS 兼容 ③ 控制节点确认独立 callback group ④ 时间源统一 ⑤ rosdep 解析通过 ⑥ colcon build 无 warning。