ROS2 学习记录(一)——ROS2 的话题通信与服务通信

文章目录

一个月前我还在学习 ROS,彼时的学习记录里使用的是 ROS Noetic 发行版。直到最近上手一块 RDK 开发板,并且使用预装的 ROS2 时我才意识到,是时候重新学一下 ROS2 的使用了。ROS2 从命令到接口再到框架,几乎与 ROS1 完全不同。

例如在 ROS1 中,一个包下可以同时存在 C++ 节点和 Python 节点,但是在 ROS2 中,一个包中默认只能有 C++ 节点或者 Python 节点;ROS2 使用 setup.py 作为 Python 节点的配置文件;ROS1 使用 catkin 作为构建工具,ROS2 中使用 colcon 作为构建工具;ROS2 使用去中心化架构,不需要 ROS Core 调度;ROS2 取消了 devel 目录……

当然,即使 ROS2 在 ROS1 的基础上做了重大更新,但是开发 ROS 项目的流程没有变,工作空间-包-节点的组织结构没有变,ROS 通信模型也没有变。现在,我就需要按照两个月前学习 ROS1 的路径,重新学习 ROS2。

创建并初始化工作空间

创建一个工作空间目录 ros2-project,并在该目录下运行 colcon build,得到一个初始化的工作空间,该目录下应该有 buildinstalllogsrc 四个目录(devel 目录被取消了)。我们后续所有的包都会存储在 src 目录下。

ROS2 话题通信

C++ 实现

创建一个 C++ 包

在 ROS1 中,通过 catkin_create_pkg 命令就能直接创建一个包,而不需要考虑这个包里会存放哪些节点。然而在 ROS2 中,我们必须提前考虑好,这个包里的节点是要用 C++ 编写还是用 Python 编写。以存放 C++ 节点的包为例,创建包的命令是

1ros2 pkg create --build-type ament_cmake cpp_pkg --dependencies rclcpp std_msgs

在这条命令中,--build-type 参数表示构建类型,ament_cmake 代表 C++ 节点,ament_python 代表 Python 节点;cpp_pkg 是创建的包名;--dependencies 参数表示依赖项,rclcpp 是 ROS2 与 C++ 的接口库,对应 ROS1 中的 roscppstd_msgs 与 ROS1 中相同,表示标准消息类型。

编写发布者节点

ROS2 中的节点需要继承 ROS2 提供的节点类,在 C++ 中为 rclcpp::Node

 1#include "rclcpp/rclcpp.hpp"
 2#include "std_msgs/msg/string.hpp"
 3
 4using namespace std::chrono_literals; // 启用时间自变量,例如 500ms
 5
 6class MinimalPublisher : public rclcpp::Node { // 继承 rclcpp::Node 类,表明这是一个 C++ ROS2 节点
 7  public:
 8    MinimalPublisher() : Node("minimal_publisher"), count_(0) {
 9        publisher_ = this->create_publisher<std_msgs::msg::String>("topic", 10); // 创建发布者,发布到 topic 话题,队列大小为 10
10        timer_ = this->create_wall_timer(
11            500ms, std::bind(&MinimalPublisher::callback, this)); // 创建定时器,每 500ms 调用一次 callback 函数
12    }
13
14  private:
15    void callback() {
16        auto message = std_msgs::msg::String();                                    // 创建 std_msgs::msg::String 类型的消息
17        message.data = "Hello, World: " + std::to_string(count_++);                // 设置消息内容
18        RCLCPP_INFO(this->get_logger(), "Publishing: '%s'", message.data.c_str()); // 打印发布信息
19        publisher_->publish(message);                                              // 发布消息
20    }
21
22    rclcpp::TimerBase::SharedPtr timer_;                            // 定时器(智能指针管理)
23    rclcpp::Publisher<std_msgs::msg::String>::SharedPtr publisher_; // 发布者(智能指针管理)
24    size_t count_;                                                  // 计数器
25};
26
27int main(int argc, char **argv) {
28    rclcpp::init(argc, argv);                           // 初始化 ROS2
29    rclcpp::spin(std::make_shared<MinimalPublisher>()); // 运行节点,直到节点退出
30    rclcpp::shutdown();                                 // 关闭 ROS2
31    return 0;
32}

编写订阅者节点

 1#include "rclcpp/rclcpp.hpp"
 2#include "std_msgs/msg/string.hpp"
 3
 4class MinimalSubscriber : public rclcpp::Node { // 继承 rclcpp::Node 类,表明这是一个 C++ ROS2 节点
 5  public:
 6    MinimalSubscriber() : Node("minimal_subscriber") {
 7        // 创建 Subscription 对象,监听 topic 话题,队列大小为 10
 8        subscription_ = this->create_subscription<std_msgs::msg::String>(
 9            "topic", 10, std::bind(&MinimalSubscriber::callback, this, std::placeholders::_1));
10    }
11
12  private:
13    void callback(const std_msgs::msg::String::SharedPtr message) {
14        RCLCPP_INFO(this->get_logger(), "Received: '%s'", message->data.c_str()); // 打印接收到的消息
15    }
16    rclcpp::Subscription<std_msgs::msg::String>::SharedPtr subscription_;
17};
18
19int main(int argc, char **argv) {
20    rclcpp::init(argc, argv);                            // 初始化 ROS2
21    rclcpp::spin(std::make_shared<MinimalSubscriber>()); // 运行节点
22    rclcpp::shutdown();                                  // 关闭 ROS2
23    return 0;
24}

修改 CMakeLists.txt 文件并编译运行

ROS2 中的 CMakeLists.txt 文件与 ROS1 中不同,需要使用 ament_cmake 作为构建类型,并且需要指定依赖项。在模板文档末尾添加如下内容:

 1add_executable(publisher src/publisher.cpp)
 2ament_target_dependencies(publisher rclcpp std_msgs)
 3
 4add_executable(subscriber src/subscriber.cpp)
 5ament_target_dependencies(subscriber rclcpp std_msgs)
 6
 7install(TARGETS
 8  publisher
 9  subscriber
10  DESTINATION lib/${PROJECT_NAME})

随后在工作空间根目录运行 colcon build 命令编译包,如果只需要编译 cpp_pkg 包,则可通过 colcon build --packages-select cpp_pkg 来指定。

编译完成后,在两个终端中分别运行 ros2 run cpp_pkg publisherros2 run cpp_pkg subscriber 命令,即可启动两个节点,并且无需 ROS Core 调度。

Python 实现

创建一个 Python 包

创建 Python 包的命令如下:

1ros2 pkg create --build-type ament_python py_pkg --dependencies rclpy std_msgs

Python 包的结构如下:

 1.
 2├── package.xml
 3├── py_pkg
 4│   └── __init__.py
 5├── resource
 6│   └── py_pkg
 7├── setup.cfg
 8├── setup.py
 9└── test
10    ├── test_copyright.py
11    ├── test_flake8.py
12    └── test_pep257.py

不难发现,此 Python 包的结构与 C++ 包完全不同。该 Python 包中的脚本置于 pk_pkg/py_pkg/ 下,__init__.py 文件用于标识该目录为 Python 包,setup.py 文件用于构建该 Python 包。

编写发布者节点

 1import rclpy
 2from rclpy.node import Node
 3from std_msgs.msg import String
 4
 5
 6class MinimalPublisher(Node):  # 继承 Node 类,表示这是一个 ROS2 节点
 7
 8    def __init__(self):
 9        super().__init__("minimal_publisher")  # 初始化节点,命名为 minimal_publisher
10        self.publisher_ = self.create_publisher(
11            String, "topic", 10
12        )  # 创建发布者,发布到 topic 话题,队列大小为 10
13        self.timer = self.create_timer(
14            0.5, self.callback
15        )  # 创建定时器,每隔 0.5 秒执行一次回调函数
16        self.count = 1  # 计数器
17
18    def callback(self):
19        message = String()  # 创建 String 消息
20        message.data = "Hello, World: " + str(self.count)  # 设置消息内容
21        self.publisher_.publish(message)  # 发布消息
22        self.get_logger().info(f"Publishing: '{message.data}'")  # 打印日志
23        self.count += 1
24
25
26def main(args=None):
27    rclpy.init(args=args)  # 初始化 rclpy
28    minimal_publisher = MinimalPublisher()  # 创建节点对象
29    rclpy.spin(minimal_publisher)  # 运行节点
30    minimal_publisher.destroy_node()  # 销毁节点
31    rclpy.shutdown()  # 关闭 rclpy
32
33
34if __name__ == "__main__":
35    main()

编写订阅者节点

 1import rclpy
 2from rclpy.node import Node
 3from std_msgs.msg import String
 4
 5
 6class MinimalSubscriber(Node):
 7
 8    def __init__(self):
 9        super().__init__("minimal_subscriber")
10        self.subscription = self.create_subscription(
11            String, "topic", self.callback, 10
12        )  # 创建订阅者,订阅 topic,队列大小为 10
13
14    def callback(self, message):
15        self.get_logger().info(f"Received: '{message.data}'")
16
17
18def main(args=None):
19    rclpy.init(args=args)  # 初始化 ROS 2 上下文
20    minimal_subscriber = MinimalSubscriber()  # 创建订阅者节点实例
21    rclpy.spin(minimal_subscriber)  # 运行节点(阻塞式)
22    minimal_subscriber.destroy_node()  # 销毁节点
23    rclpy.shutdown()  # 关闭 ROS 2
24
25
26if __name__ == "__main__":
27    main()

修改 setup.py 文件并编译运行

setup.py 的原始内容如下:

 1from setuptools import setup
 2
 3package_name = "py_pkg"
 4
 5setup(
 6    name="py_pkg",
 7    version="0.0.0",
 8    packages=["py_pkg"],
 9    data_files=[
10        ("share/ament_index/resource_index/packages", ["resource/" + package_name]),
11        ("share/" + package_name, ["package.xml"]),
12    ],
13    install_requires=["setuptools"],
14    zip_safe=True,
15    maintainer="jackgdn",
16    maintainer_email="li_zhong_yao@foxmail.com",
17    description="TODO: Package description",
18    license="TODO: License declaration",
19    tests_require=["pytest"],
20    entry_points={
21        "console_scripts": [],
22    },
23)

修改时需要在 entry_points.console_scripts 添加节点,即 "publisher = py_pkg.publisher:main""subscriber = py_pkg.subscriber:main"。以前者为例,最先出现的 publisher 是编译目标名称,py_pkg 是包名,第二次出现的 publisher 是脚本名,main 是程序入口点。修改完成的 setup.py 如下:

 1from setuptools import setup
 2
 3package_name = "py_pkg"
 4
 5setup(
 6    name="py_pkg",
 7    version="0.0.0",
 8    packages=["py_pkg"],
 9    data_files=[
10        ("share/ament_index/resource_index/packages", ["resource/" + package_name]),
11        ("share/" + package_name, ["package.xml"]),
12    ],
13    install_requires=["setuptools"],
14    zip_safe=True,
15    maintainer="jackgdn",
16    maintainer_email="li_zhong_yao@foxmail.com",
17    description="TODO: Package description",
18    license="TODO: License declaration",
19    tests_require=["pytest"],
20    entry_points={
21        "console_scripts": [
22            "publisher = py_pkg.publisher:main",
23            "subscriber = py_pkg.subscriber:main",
24        ],
25    },
26)

随后在工作空间根目录运行 colcon build 命令编译包,如果只需要编译 py_pkg 包,则可通过 colcon build --packages-select py_pkg 来指定。

编译完成后,在两个终端中分别运行 ros2 run py_pkg publisherros2 run py_pkg subscriber 命令,即可启动两个节点。

ROS2 服务通信

C++ 实现

package.xml 中添加依赖

在服务通信部分,我选择直接在刚刚创建的包中添加两个新节点,用于实现简单的服务通信。我使用的是 example_interfaces 包中的 AddTwoInts 服务类型,该服务的请求为两个 int32 类型整数,响应为一个 int32 类型的整数。由于在创建该包时没有添加 example_interfaces 包作为依赖项,因此现在需要手动将该依赖添加到 package.xml 文件中。

1<depend>example_interfaces</depend>

编写服务端节点

和话题通信一样,服务通信也需要继承 ROS2 提供的节点类。

 1#include "example_interfaces/srv/add_two_ints.hpp"
 2#include "rclcpp/rclcpp.hpp"
 3
 4using std::placeholders::_1;
 5using std::placeholders::_2;
 6
 7class MinimalServer : public rclcpp::Node {
 8  public:
 9    // 构造函数,初始化节点并创建服务
10    MinimalServer() : Node("add_two_ints_server") {
11        service_ = this->create_service<example_interfaces::srv::AddTwoInts>("service",
12                                                                             std::bind(&MinimalServer::handle, this, _1, _2));
13        RCLCPP_INFO(this->get_logger(), "Server ready.");
14    }
15
16  private:
17    // 处理服务请求的回调函数
18    void handle(const example_interfaces::srv::AddTwoInts::Request::SharedPtr request,
19                const example_interfaces::srv::AddTwoInts::Response::SharedPtr response) {
20        response->sum = request->a + request->b;
21        RCLCPP_INFO(this->get_logger(), "Request: a=%ld, b=%ld", request->a, request->b);
22    }
23
24    // 服务的共享指针
25    rclcpp::Service<example_interfaces::srv::AddTwoInts>::SharedPtr service_;
26};
27
28// 主函数,初始化 ROS 2,创建并自旋服务器节点,最后关闭
29int main(int argc, char **argv) {
30    rclcpp::init(argc, argv);
31    rclcpp::spin(std::make_shared<MinimalServer>());
32    rclcpp::shutdown();
33    return 0;
34}

编写客户端节点

 1#include "example_interfaces/srv/add_two_ints.hpp"
 2#include "rclcpp/rclcpp.hpp"
 3
 4using namespace std::chrono_literals;
 5
 6// 定义一个继承自 rclcpp::Node 的类,用于实现一个最小化的客户端
 7class MinimalClient : public rclcpp::Node {
 8  public:
 9    // 构造函数,初始化节点并创建客户端和服务定时器
10    MinimalClient() : Node("mininal_client") {
11        client_ = this->create_client<example_interfaces::srv::AddTwoInts>("service");
12        timer_ = this->create_wall_timer(500ms, std::bind(&MinimalClient::send_request, this));
13    }
14
15  private:
16    // 定时器回调函数,处理服务响应
17    void timer_callback(const rclcpp::Client<example_interfaces::srv::AddTwoInts>::SharedFuture future) {
18        RCLCPP_INFO(this->get_logger(), "Response: %ld", future.get()->sum);
19    }
20
21    // 发送请求到服务端
22    void send_request() {
23        while (!client_->wait_for_service(1s)) {
24            if (!rclcpp::ok()) {
25                RCLCPP_ERROR(this->get_logger(), "Interrupted.");
26                return;
27            } else {
28                RCLCPP_INFO(this->get_logger(), "Service not available.");
29            }
30        }
31
32        auto request = std::make_shared<example_interfaces::srv::AddTwoInts::Request>();
33        request->a = i_++;
34        request->b = i_++;
35        client_->async_send_request(request, std::bind(&MinimalClient::timer_callback, this, std::placeholders::_1));
36    }
37
38    rclcpp::Client<example_interfaces::srv::AddTwoInts>::SharedPtr client_; // 服务客户端指针
39    rclcpp::TimerBase::SharedPtr timer_;                                    // 定时器指针
40    size_t i_ = 0;                                                          // 用于生成请求数据的计数器
41};
42
43// 主函数,初始化 ROS 2 环境,创建并运行客户端节点,最后关闭 ROS 2 环境
44int main(int argc, char **argv) {
45    rclcpp::init(argc, argv);
46    rclcpp::spin(std::make_shared<MinimalClient>());
47    rclcpp::shutdown();
48    return 0;
49}

修改 CMakeLists.txt 文件并编译运行

CMakeLists.txt 中,我们也需要添加 example_interfaces 作为依赖项:

1find_package(example_interfaces REQUIRED)

随后添加编译目标:

 1add_executable(server src/server.cpp)
 2ament_target_dependencies(server rclcpp example_interfaces)
 3add_executable(client src/client.cpp)
 4ament_target_dependencies(client rclcpp example_interfaces)
 5
 6install(TARGETS
 7  publisher
 8  subscriber
 9  server
10  client
11  DESTINATION lib/${PROJECT_NAME})

使用 colcon build 命令编译。

Python 实现

package.xml 中添加依赖

和 C++ 实现一样,我们需要在 package.xml 中添加 example_interfaces 作为依赖项。

1<depend>example_interfaces</depend>

整体来看,这个 XML 文件是这样:

 1<?xml version="1.0"?>
 2<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
 3<package format="3">
 4  <name>py_pkg</name>
 5  <version>0.0.0</version>
 6  <description>TODO: Package description</description>
 7  <maintainer email="li_zhong_yao@foxmail.com">jackgdn</maintainer>
 8  <license>TODO: License declaration</license>
 9
10  <depend>rclpy</depend>
11  <depend>std_msgs</depend>
12  <depend>example_interfaces</depend>
13
14  <test_depend>ament_copyright</test_depend>
15  <test_depend>ament_flake8</test_depend>
16  <test_depend>ament_pep257</test_depend>
17  <test_depend>python3-pytest</test_depend>
18
19  <export>
20    <build_type>ament_python</build_type>
21  </export>
22</package>

编写服务端节点

 1import rclpy
 2from example_interfaces.srv import AddTwoInts
 3from rclpy.node import Node
 4
 5
 6class MinimalServer(Node):
 7    # 初始化函数,设置节点名称并创建服务
 8    def __init__(self):
 9        super().__init__("minimal_server")
10        self.service = self.create_service(AddTwoInts, "service", self.handle_add)
11        self.get_logger().info("Server ready.")
12
13    # 处理加法请求的回调函数
14    def handle_add(self, request, response):
15        response.sum = request.a + request.b
16        self.get_logger().info(f"Request: a={request.a}, b={request.b}")
17        return response
18
19
20# 主函数,初始化 ROS2 环境,创建服务节点并保持运行
21def main(args=None):
22    rclpy.init(args=args)
23    server = MinimalServer()
24    rclpy.spin(server)
25    rclpy.shutdown()
26
27
28if __name__ == "__main__":
29    main()

编写客户端节点

 1import rclpy
 2from example_interfaces.srv import AddTwoInts
 3from rclpy.node import Node
 4
 5
 6class MinimalClient(Node):
 7    # 初始化节点,设置客户端并启动定时器
 8    def __init__(self):
 9        super().__init__("minimal_client")
10        self.client = self.create_client(AddTwoInts, "service")
11        self.i = 0
12        self.timer = self.create_timer(0.5, self.send_request)
13
14    # 处理服务响应的回调函数
15    def timer_callback(self, future):
16        response = future.result()
17        self.get_logger().info(f"Response: {response.sum}")
18
19    # 发送请求到服务端
20    def send_request(self):
21        while not self.client.wait_for_service(timeout_sec=1.0):
22            if not rclpy.ok():
23                self.get_logger().error("Interrupted.")
24                return
25            else:
26                self.get_logger().info("Service not available.")
27
28        request = AddTwoInts.Request()
29        request.a = self.i
30        request.b = self.i + 1
31        self.i += 2
32
33        self.future = self.client.call_async(request)
34        self.future.add_done_callback(self.timer_callback)
35
36
37def main(args=None):
38    # 初始化 ROS 2 客户端库
39    rclpy.init(args=args)
40    client = MinimalClient()
41    # 运行客户端节点直到手动停止
42    rclpy.spin(client)
43    # 关闭 ROS 2 客户端库
44    rclpy.shutdown()
45
46
47if __name__ == "__main__":
48    main()

修改 setup.py 文件并编译运行

修改后的 setup.py 文件如下:

 1from setuptools import setup
 2
 3package_name = "py_pkg"
 4
 5setup(
 6    name="py_pkg",
 7    version="0.0.0",
 8    packages=["py_pkg"],
 9    data_files=[
10        ("share/ament_index/resource_index/packages", ["resource/" + package_name]),
11        ("share/" + package_name, ["package.xml"]),
12    ],
13    install_requires=["setuptools"],
14    zip_safe=True,
15    maintainer="jackgdn",
16    maintainer_email="li_zhong_yao@foxmail.com",
17    description="TODO: Package description",
18    license="TODO: License declaration",
19    tests_require=["pytest"],
20    entry_points={
21        "console_scripts": [
22            "publisher = py_pkg.publisher:main",
23            "subscriber = py_pkg.subscriber:main",
24            "server = py_pkg.server:main",
25            "client = py_pkg.client:main",
26        ],
27    },
28)

使用 colcon build 命令编译。

系列文章