ROS2初步
安装ROS2
下面给出一套在 Ubuntu 上可复现的安装方式(以 FishROS 脚本为例)。
1 | wget http://fishros.com/install -O fishros && bash fishros |
如果需要卸载 ROS 发行版,可执行:
1 | sudo apt remove ros-foxy-* && sudo apt autoremove |
基础概念:
与 ROS1 类似,ROS2 也由节点、工作空间、功能包等核心概念构成。
节点
每一个节点都负责一个单独的模块。
可以把系统想成一条流水线:每个节点只负责一件事,然后通过标准通信机制协作。
例如:一个节点采集激光雷达数据,一个节点做定位,一个节点做路径规划,一个节点控制底盘执行。
节点通信(详见)
ROS2 中主要有以下四种通信方式:
- 话题-topics
- 服务-services
- 动作-Action
- 参数-parameters
启动节点
需要使用指令:
1 | ros2 run <package_name> <executable_name> |
命令行查看节点信息
这里涉及以下两个概念:
- GUI(Graphical User Interface)就是平常我们说的图形用户界面,大家用的Windows是就是可视化的,我们可以通过鼠标点击按钮等图形化交互完成任务。
CLI(Command-Line Interface)就是命令行界面了,我们所用的终端,黑框框就是命令行界面,没有图形化。
节点相关CLI:
列举几个常用的:
运行节点:1
ros2 run <package_name> <executable_name>
查看节点列表(常用):
1
ros2 node list
查看节点信息(常用):
1
ros2 node info <node_name>
重映射节点名称
1
ros2 run turtlesim turtlesim_node --ros-args --remap __node:=my_turtle
工作空间&&功能包:
想要找到一个可执行文件(节点)必须依赖于一个功能包,这些包可以统一放在某个工作空间里。
创建工作空间:1
2mkdir -p turtle_ws/src
cd turtle_ws/src功能包:
可以理解为存放节点的容器。
ROS2中功能包根据编译方式的不同分为三种类型。ament_python,适用于python程序
- cmake,适用于C++
ament_cmake,适用于C++程序,是cmake的增强版
功能包获取
安装一般使用
1
sudo apt install ros-<version>-package_name
相关指令——ros2pkg
1
2
3
4
5
6create Create a new ROS2 package
executables Output a list of package specific executables
list Output a list of available packages
prefix Output the prefix path of a package
xml Output the XML of the package manifest or a specific tag1.创建功能包
1
ros2 pkg create <package-name> --build-type {cmake,ament_cmake,ament_python} --dependencies <依赖名字>
2.列出可执行文件
列出所有1
ros2 pkg executables
列出某个功能包的
1
ros2 pkg executables turtlesim
3.列出所有的包1
ros2 pkg list
4.输出某个包所在路径的前缀
1
ros2 pkg prefix <package-name>
比如小乌龟
1
ros2 pkg prefix turtlesim
5.列出包的清单描述文件
每一个功能包都有一个标配的manifest.xml文件,用于记录这个包的名字,构建工具,编译信息,拥有者,干啥用的等信息。
通过这个信息,就可以自动为该功能包安装依赖,构建时确定编译顺序等
查看小乌龟模拟器功能包的信息。1
ros2 pkg xml turtlesim
colcon:
colcon 是 ROS2 常用的构建工具。若系统未安装,可先执行:
1
sudo apt-get install python3-colcon-common-extensions
相关指令
5.1 只编译一个包
1
colcon build --packages-select YOUR_PKG_NAME
5.2 不编译测试单元
1
colcon build --packages-select YOUR_PKG_NAME --cmake-args -DBUILD_TESTING=0
5.3 运行编译的包的测试
1
colcon test
5.4 允许通过更改src下的部分文件来改变install(重要)
(每次调整 python 脚本时都不必重新build了)
1
colcon build --symlink-install
手写节点测试(C++)
这一节用 C++ 做一个最小可运行节点,重点是熟悉 ROS2 的节点编写、编译与安装流程。
创建工作空间与功能包
1
2
3mkdir -p town_ws/src
cd town_ws/src
ros2 pkg create village_wang --build-type ament_cmake --dependencies rclcppPOP 方式编写节点
在village_wang/src中创建wang2.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17#include "rclcpp/rclcpp.hpp"
int main(int argc, char **argv)
{
/*初始化rclcpp
rclcpp::init(argc, argv);
/*产生一个Wang2的节点*/
auto node = std::make_shared<rclcpp::Node>("wang2");
// 打印一句自我介绍
RCLCPP_INFO(node->get_logger(), "大家好,我是节点 wang2.");
/* 运行节点,并检测退出信号*/
rclcpp::spin(node);
rclcpp::shutdown();
return 0;
}主函数中首先初始化rclcpp,然后新建了一个Node节点的对象,命名为wang2,接着使用rclcpp让这个节点暴露在外面,并检测退出信号(Ctrl+C),检测到退出信号后,就会执行rcl.shutdown()关闭节点。
添加到cmakelists
在 CMakeLists.txt 中加入下面几行代码,让
wang2.cpp被正常编译并安装:1
2add_executable(wang2_node src/wang2.cpp)
ament_target_dependencies(wang2_node rclcpp)添加这两行代码的目的是让编译器编译wang2.cpp这个文件,不然不会主动编译。接着在上面两行代码下面添加下面的代码。
1
2
3
4install(TARGETS
wang2_node
DESTINATION lib/${PROJECT_NAME}
)这一步的作用是把编译产物安装到 ROS2 约定的目录中,否则
ros2 run找不到对应节点。编译运行:
1. 编译节点
1
colcon build
2. 加载环境
1
source install/setup.bash
3. 运行节点
1
ros2 run village_wang wang2_node
如果运行成功,你会看到节点打印自我介绍。此时再执行
ros2 node list,可以确认节点已经注册到系统中。OOP 方式编写节点
如果你希望把节点封装成类,便于后续扩展订阅器、定时器和服务端,可以改成下面这种写法。
还是在 wang2.cpp 中输入代码:
1 |
|
修改cmakelists&&运行
这部分与上一节相同:更新 CMakeLists.txt、重新编译、重新 source,然后运行节点即可。
运行成功后,说明类封装节点的方式已经走通。
通信
这一节开始进入 ROS2 的核心能力:节点之间通过标准通信接口交换数据。
这里用一个最小示例说明“发布者-订阅者”模型:
- 发布者节点负责发送消息。
- 订阅者节点负责接收消息。
- 消息内容用字符串类型表示。
这是一个一对一示例,但在 ROS2 中,话题通信同样支持 1 对多、多对 1、多对多。
话题通讯
死锁
这个例子的核心问题,不是“服务不好用”,而是阻塞式等待和单线程执行器撞在了一起。
假设服务回调里需要等待队列里攒够足够多的章节,才能把结果返回给客户端;但新章节又恰好依赖另一个订阅回调持续写入队列。如果节点仍然运行在默认的单线程执行器中,那么服务回调一旦阻塞,订阅回调就没有机会执行,队列也就永远补不满,这就是典型的死锁场景。
更直接地说:
- 服务回调在等队列变大。
- 订阅回调负责把数据写进队列。
- 单线程执行器一次只能跑一个回调。
结果就是两边互相等待,系统卡住。
解决办法有两种思路:
- 不在回调里做长时间阻塞等待,改成异步状态机或定时器驱动。
- 如果示例就是要保留阻塞式逻辑,那么至少把相关回调拆到合适的回调组里,并使用多线程执行器。
- 话题名字是关键,发布订阅接口类型要相同,发布的是字符串,接受也要用字符串来接收;
- 同一个人(节点)可以订阅多个话题,同时也可以发布多个话题,就像一本书的作者也可以是另外一本书的读者;
- 同一个小说不能有多个作者(版权问题),但跟小说不一样,同一个话题可以有多个发布者。
ROS2 里常见的做法是结合多线程执行器和回调组,把可能互相影响的回调拆开调度。先在SingleDogNode中声明一个服务回调组成员变量:
1 | <a name="FnJns"></a> |
增加消息类型
1 | ros2 topic list -t |
打印实时话题内容
1 | ros2 topic echo /chatter |
查看主题信息
1 | ros2 topic info /chatter |
查看消息类型
1 | ros2 interface show std_msgs/msg/String |
手动发布命令
1 | ros2 topic pub /chatter std_msgs/msg/String 'data: "123"' |
c++实现
创建话题订阅者的一般流程:
将wang2.cpp代码修改如下:
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
using std::placeholders::_1;
using std::placeholders::_2;
/*
创建一个类节点,名字叫做SingleDogNode,继承自Node.
*/
class SingleDogNode : public rclcpp::Node
{
public:
// 构造函数,有一个参数为节点名称
SingleDogNode(std::string name) : Node(name)
{
// 打印一句自我介绍
RCLCPP_INFO(this->get_logger(), "大家好,我是节点 %s.", name.c_str());
// 创建一个订阅者,订阅名为 sexy_girl 的话题
sub_novel = this->create_subscription<std_msgs::msg::String>("sexy_girl", 10, std::bind(&SingleDogNode::topic_callback, this, _1));
}
private:
// 声明一个订阅者(成员变量),用于订阅小说
rclcpp::Subscription<std_msgs::msg::String>::SharedPtr sub_novel;
// 收到话题数据的回调函数
void topic_callback(const std_msgs::msg::String::SharedPtr msg)
{
RCLCPP_INFO(this->get_logger(), "朕已阅:'%s'", msg->data.c_str());
};
};
int main(int argc, char **argv)
{
rclcpp::init(argc, argv);
/*产生一个Wang2的节点*/
auto node = std::make_shared<SingleDogNode>("wang2");
/* 运行节点,并检测退出信号*/
rclcpp::spin(node);
rclcpp::shutdown();
return 0;
}使用 C++ 订阅话题,需要先引入对应的消息类型头文件:
1
2创建订阅者和发布者时,依然使用
this->create_subscription和this->create_publisher。
在 C++ 中,类成员函数不能直接作为普通回调函数传入,因此这里使用std::bind把成员函数和当前对象实例绑定起来。你可以先把它理解成“把this和回调函数打包后交给 ROS2”。编译运行
1
colcon build --packages-select village_wang
发布者示例
下面补一个最小发布者节点,用来把“发布-订阅”链路完整跑通。
1 |
|
使用Ctrl+Shift+5切分一个终端出来,输入下面命令:
1 | source install/setup.bash |

发布
C++中创建一个发布者也比较简单,使用this->create_publisher即可创建一个发布者。
1 | pub_ = this->create_publisher<std_msgs::msg::UInt32>("sexy_girl_money",10); |
这里提供了三个参数,分别是该发布者要发布的话题名称(sexy_girl_money)、发布者要发布的话题类型(std_msgs::msg::UInt32)、Qos(10)
服务和接口
接口:接口其实是一种规范
当接口类型统一的时候,适配显然就不是问题了,大家的服务和响应都是一致的规范格式
常用命令
查看接口列表(当前环境下)
1 | ros2 interface list |
查看所有接口包
1 | ros2 interface packages |
查看某一个包下的所有接口
1 | ros2 interface package std_msgs |
查看某一个接口详细的内容
1 | ros2 interface show std_msgs/msg/String |
输出某一个接口所有属性
1 | ros2 interface proto sensor_msgs/msg/Image |
服务与话题的区别
话题更适合连续数据流,通常没有即时返回结果;服务则是典型的请求-响应模型,客户端发起请求,服务端处理后返回结果。
自定义话题接口
- 新建工作空间
在town_ws的src文件夹下,运行下面的指令,即可完成village_interfaces功能包的创建。
注意,这里包的编译类型我们使用ament_cmake方式。
1 | ros2 pkg create village_interfaces --build-type ament_cmake |

- 新建msg文件和Novel.msg(小说消息)
注意:消息文件名首字母通常使用大写,便于和生成后的类型名保持一致。
1 | cd village_interfaces |
- 编写Novel.msg内容
这里的目标是定义一个复合消息:既包含文本内容,也包含图像数据。文本部分可以用 string 或 std_msgs/String 表示,图像部分则使用 sensor_msgs/Image。在 msg 文件中可以使用 # 添加注释。
1 | # 标准消息接口std_msgs下的String类型 |
可以把这类自定义消息理解成三层组合:最上层是你定义的消息,中间层是 ROS2 的标准消息类型,最底层是
string、uint32 这类基础数据类型。ROS2 的所有接口本质上都是这样逐层组合出来的。1 | bool |
另一种写法
我们不使用std_msgs/String 而是直接使用最下面一层的string。
1 | # 直接使用ROS2原始的数据类型 |
为什么可以直接使用 string
如何知道,std_msgs/String是由基础数据类型string组成的,其实可以通过下面的指令来查看
1 | ros2 interface show std_msgs/msg/String |
结果如下:
1 | string data |
原来std_msgs的String就是包含一个叫变量名为data的string类型变量,这也是在4.2和4.3章节中代码要用.data才能拿到真正的数据的原因:
1 | from std_msgs.msg import String |
最终Novel.msg
1 | # 直接使用ROS2原始的数据类型 |
- 修改Cmakelists.txt
完成了代码的编写还不够,我们还需要在CMakeLists.txt中告诉编译器,你要给我把Novel.msg转换成Python库和C++的头文件。
直接添加下面的代码到CMakeLists.txt即可。
1 | #添加对sensor_msgs的 |
find_package 用于查找依赖,rosidl_generate_interfaces 用于声明接口文件和依赖。这里有两个常见坑:DEPENDENCIES里的依赖要写全,否则有些问题会在运行时才暴露。rosidl_generate_interfaces()必须写在ament_package()之前。
代码大概是这样的
- 修改package.xml
修改 village_interfaces 目录下的 package.xml,显式添加依赖更稳妥,能减少环境差异带来的问题。
1 | <depend>sensor_msgs</depend> |
代码位置:

- 编译
回到town_ws
1 | colcon build --packages-select village_interfaces |
- 验证
过上节课说过的ros2 interface常用的命令来测试。
1 | source install/setup.bash |
我们可以在运行结果中看到,Novel的消息内容是由content数据和传感器数据image共同组成的了。
服务:
- 服务:客户端发送请求给服务端,服务端可以根据客户端的请求做一些处理,然后返回结果给客户端。
启动服务端
先运行一个服务节点:
1 | ros2 run examples_rclpy_minimal_service service |
查看服务列表
1 | ros2 service list |
手动调用服务
注意请求体里的 YAML 格式要写对:
1 | ros2 service call /add_two_ints example_interfaces/srv/AddTwoInts "{a: 5,b: 10}" |
查看服务接口类型
1 | ros2 service type /add_two_ints |
查找使用某一接口的服务
1 | ros2 service find example_interfaces/srv/AddTwoInts |
自定义服务接口
先看一下服务接口的基本格式。一个 .srv 文件分成请求和响应两部分:
1 | int64 a |
与话题不同,
srv 文件中间多出的 --- 就是分界线:上面定义请求结构,下面定义响应结构。可以按下面顺序创建自定义服务:- 新建srv文件夹,并在文件夹下新建xxx.srv
- 在xxx.srv下编写服务接口内容并保存
- 在CmakeLists.txt添加依赖和srv文件目录
- 在package.xml中添加xxx.srv所需的依赖
- 编译功能包即可生成python与c++头文件
在动手写 .srv 文件之前,先根据业务需求确定请求和返回的数据结构。这里依然在 village_interfaces 下创建服务接口。
假设需求如下:
- 请求方必须携带借条信息,服务端收到后才继续处理。
- 单次放款金额不能超过当前可用资金的 10%,并且金额必须是整数。
总结一下,借钱请求至少要包含两条信息:
- 借钱者名字,字符串类型、可以用string表示
- 金额,整形,可以用uint32表示
确定了请求结构后,再设计返回结构。既然服务端可能同意,也可能拒绝,那么返回值至少需要两项:
- 是否出借:只有成功和失败两种情况,布尔类型(bool)可表示
出借金额:无符号整形,可以用uint32表示,借钱失败时为0。
创建srv文件夹及BorrowMoney.srv消息文件
编写文件内容
1
2
3
4
5string name
uint32 money
---
bool success
uint32 money修改CMakeLists.txt
我们已经添加过
msg文件和依赖,所以这里直接再加入一个srv文件即可。1
2
3
4
5
6
7
8find_package(rosidl_default_generators REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME}
#---msg---
"msg/Novel.msg"
#---srv---
"srv/BorrowMoney.srv"
DEPENDENCIES sensor_msgs
)需要关注的是
"srv/BorrowMoney.srv"这一行,它告诉构建系统要生成对应的服务接口代码。踩坑:在rosidl_generate_interfaces()函数中传递了一个依赖项sensor_msgs,但是在使用find_package()函数之前没有找到它。需要在CMakeLists.txt 文件中添加find_package(sensor_msgs REQUIRED),以确保 sensor_msgs 包被正确地找到和链接。
修改 package.xml
1
2
3
4
5<build_depend>sensor_msgs</build_depend>
<build_depend>rosidl_default_generators</build_depend>
<exec_depend>rosidl_default_runtime</exec_depend>
<member_of_group>rosidl_interface_packages</member_of_group>编译
1
colcon build --packages-select village_interfaces
测试
这次测试我们依然使用ros2 interface指令进行测试。
1
2
3
4source install/setup.bash
ros2 interface package village_interfaces
ros2 interface show village_interfaces/srv/BorrowMoney
ros2 interface proto village_interfaces/srv/BorrowMoney服务的c++实现
一句话:客户端发起请求,服务端按约定规则处理后返回结果,可按下面步骤实现。
- 导入服务接口
- 创建服务端回调函数
- 声明并创建服务端
- 编写回调函数逻辑处理请求
添加接口&&依赖
因为village_wang的包类型是ament_cmake,故需要进行以下两步操作:
第一步修改package.xml
加入下面的代码(告诉colcon,编译之前要确保有village_interfaces存在)1
<depend>village_interfaces</depend>
第二步修改和CMakeLists.txt
在CMakeLists.txt中加入下面一行代码find_package是cmake的语法,用于查找库。找到后,还需要将其和可执行文件链接起来1
find_package(village_interfaces REQUIRED)
所以还需要修改ament_target_dependencies,在其中添加village_interfaces。对于C++来说,添加服务接口只需在程序中引入对应的头文件即可。1
2
3
4ament_target_dependencies(wang2_node
rclcpp
village_interfaces
)
这个头文件就是我们SellNovel.srv生成的头文件1
声明回调函数
添加完服务接口接着就可以声明一个卖书请求回调函数。1
2
3
4
5// 声明一个回调函数,当收到买书请求时调用该函数,用于处理数据
void sell_book_callback(const village_interfaces::srv::SellNovel::Request::SharedPtr request,
const village_interfaces::srv::SellNovel::Response::SharedPtr response)
{
}
再创建一个队列,用于存放自己看过的二手书,创建队列需要用到queue容器,所以我们先用#include1
2//创建一个小说章节队列
std::queue<std::string> novels_queue;
死锁
这个例子的关键问题,是阻塞式等待放在了单线程执行器里。
如果服务回调需要等待队列中积累足够多的数据才能返回,而补充队列的数据又依赖另一个订阅回调,那么单线程执行器就会出现典型的互相等待:服务回调占住线程时,订阅回调得不到执行机会,队列也就永远补不满。
可以把这个问题理解成三句话:
- 服务回调在等待库存变多。
- 订阅回调负责补库存。
- 单线程执行器一次只能处理一个回调。
这就是死锁产生的根源。
工程上更稳妥的方案,是避免在回调里做长时间阻塞;如果教程里暂时保留这种写法,那么就应该配合回调组和多线程执行器,让订阅回调仍然有机会继续运行。
回调函数组
ROS2 中通常通过“回调组 + 多线程执行器”的组合来解决这类问题。先在 SingleDogNode 中声明一个服务回调组成员变量:
1 | // 声明一个服务回调组 |
完整结构大致如下:
1 | class SingleDogNode : public rclcpp::Node |
实例化服务端&&编写回调函数处理请求
回调组本身也是对象,可以在构造函数中通过 create_callback_group 创建:
1 | callback_group_service_ = this->create_callback_group(rclcpp::CallbackGroupType::MutuallyExclusive); |
因为这里使用成员函数作为服务回调,所以还要准备好两个占位符,对应请求和响应两个参数:
1 | using std::placeholders::_1; |
然后在 private: 区域声明服务端:
1 | // 声明一个服务端 |
最后在构造函数中实例化服务端:
1 | // 实例化卖二手书的服务 |
这段代码的四个参数分别表示:
sell_novel:服务名。std::bind(&SingleDogNode::sell_book_callback, this, _1, _2):服务请求到来时执行的回调。rmw_qos_profile_services_default:服务通信使用默认 QoS。callback_group_service_:把这个服务回调放进前面创建的回调组里,便于后续由多线程执行器调度。编写回调函数
下面是一个直接可运行的服务回调示例:
1 | // 声明一个回调函数,当收到买书请求时调用该函数,用于处理数据 |
这段逻辑可以按下面的顺序理解:
- 先根据请求参数计算本次需要返回多少章节。
- 如果库存不足,就暂时等待队列补充。
- 一旦队列数量满足条件,就把内容逐个写入响应对象。
与此同时,订阅回调也要负责把收到的新章节写入队列:
1 | // 收到话题数据的回调函数 |
修改main函数
因为这个例子依赖服务回调和订阅回调并行推进,所以 main 函数里也要把默认执行器换成多线程执行器:
1 | int main(int argc, char **argv) |
如果你只是想验证“多线程执行器是否生效”,可以先保留示例逻辑不动,观察服务等待期间订阅回调是否还能继续写入队列;这比单纯记概念更容易理解为什么这里一定要切到 MultiThreadedExecutor。
编译:
1 | colcon build --packages-select village_wang |
测试
1 | source install/setup.bash |
客户端实现?id=_3%e5%ae%a2%e6%88%b7%e7%ab%af%ef%bc%88%e5%bc%a0%e4%b8%89%ef%bc%89%e5%ae%9e%e7%8e%b0)



















