4、Python与C++节点编写实战:Publisher与Subscriber的Python/C++实现,自定义消息接口

好,咱们进入实战环节。

前面几章我们把ROS2的基础概念、环境搭建、工作空间都捋了一遍。现在,是时候亲手写代码了。

这一章的核心就三件事:用Python写一个发布者(Publisher)、用C++写一个订阅者(Subscriber),以及自定义一套自己的消息接口。说白了,就是让两个节点能说上话,而且说的还是我们自己定义的语言。

本章核心目标:

  • 掌握Python与C++节点的基本编写流程
  • 理解Publisher与Subscriber的通信机制
  • 学会自定义msg消息接口
本章知识体系:节点通信实战 Python Publisher rclpy.publish() 定时器回调 C++ Subscriber rclcpp::Subscription 回调函数 自定义消息 msg文件定义 CMakeLists配置 最终效果 Python节点发布自定义消息 → C++节点接收并打印 跨语言通信验证成功

4.1 先搭架子:创建功能包

我个人习惯,每次写新节点前都会先确认工作空间已经source好了。你想想看,如果环境没加载,后面编译报错都不知道错在哪。

咱们在 src 目录下创建一个新功能包,同时依赖 rclcpp 和 rclpy:

cd ~/ros2_ws/src
ros2 pkg create --build-type ament_cmake chapt4_interfaces \
  --dependencies rclcpp rclpy std_msgs

嗯,这里要注意:虽然我们后面会写Python节点,但功能包的构建类型我选了 ament_cmake。为什么?因为C++节点和自定义消息接口都需要CMake来编译,Python节点可以放在里面一起管理。这个坑我刚开始踩过——分开建两个包,结果依赖关系搞得乱七八糟。

4.2 自定义消息接口:让数据按我们的规矩来

ROS2自带的 std_msgs/String 虽然能用,但实际项目中远远不够。比如你要发一个带有速度、转向角、时间戳的车辆状态消息,就得自己定义。

在 chapt4_interfaces 包下创建 msg 文件夹,新建 VehicleStatus.msg:

# chapt4_interfaces/msg/VehicleStatus.msg
float32 speed          # 车速,单位 m/s
float32 steering_angle # 转向角,单位 rad
builtin_interfaces/Time stamp  # 时间戳
string vehicle_id      # 车辆ID

定义好之后,记得修改 CMakeLists.txt 和 package.xml。我直接给你看关键配置:

# CMakeLists.txt 中
find_package(rosidl_default_generators REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME}
  "msg/VehicleStatus.msg"
  DEPENDENCIES builtin_interfaces
)
<!-- package.xml 中 -->
<build_depend>rosidl_default_generators</build_depend>
<exec_depend>rosidl_default_runtime</exec_depend>
<member_of_group>rosidl_interface_packages</member_of_group>

我曾经犯过的错: 忘记在 package.xml 里添加 rosidl_default_generators 依赖,结果编译时死活找不到消息生成器。折腾了半小时才发现是依赖没写全。

编译一下:

cd ~/ros2_ws
colcon build --packages-select chapt4_interfaces
source install/setup.bash

验证是否成功:

ros2 interface show chapt4_interfaces/msg/VehicleStatus

如果能看到你定义的那些字段,恭喜,自定义消息接口搞定了。

4.3 Python Publisher:把数据发出去

在 chapt4_interfaces 包下创建 scripts 文件夹,新建 vehicle_publisher.py:

import rclpy
from rclpy.node import Node
from chapt4_interfaces.msg import VehicleStatus
from builtin_interfaces.msg import Time
import time

class VehiclePublisher(Node):
    def __init__(self):
        super().__init__('vehicle_publisher')
        self.publisher_ = self.create_publisher(VehicleStatus, 'vehicle_status', 10)
        self.timer = self.create_timer(1.0, self.timer_callback)
        self.get_logger().info('车辆状态发布节点已启动')

    def timer_callback(self):
        msg = VehicleStatus()
        msg.speed = 25.5
        msg.steering_angle = 0.15
        now = self.get_clock().now()
        msg.stamp = now.to_msg()
        msg.vehicle_id = 'AV-001'
        self.publisher_.publish(msg)
        self.get_logger().info(f'发布: speed={msg.speed}, steering={msg.steering_angle}')

def main(args=None):
    rclpy.init(args=args)
    node = VehiclePublisher()
    rclpy.spin(node)
    node.destroy_node()
    rclpy.shutdown()

if __name__ == '__main__':
    main()

这里有个细节:self.get_clock().now().to_msg() 是ROS2获取当前时间并转为消息格式的标准做法。我见过有人直接用 time.time() 赋值,结果类型不匹配报错。

别忘了给脚本加执行权限,并在 setup.py 里注册入口点:

entry_points={
    'console_scripts': [
        'vehicle_publisher = chapt4_interfaces.vehicle_publisher:main',
    ],
},

4.4 C++ Subscriber:把数据接住

在 src 目录下新建 vehicle_subscriber.cpp:

#include "rclcpp/rclcpp.hpp"
#include "chapt4_interfaces/msg/vehicle_status.hpp"

class VehicleSubscriber : public rclcpp::Node
{
public:
    VehicleSubscriber() : Node("vehicle_subscriber")
    {
        subscription_ = this->create_subscription<chapt4_interfaces::msg::VehicleStatus>(
            "vehicle_status", 10,
            std::bind(&VehicleSubscriber::topic_callback, this, std::placeholders::_1));
        RCLCPP_INFO(this->get_logger(), "车辆状态订阅节点已启动");
    }

private:
    void topic_callback(const chapt4_interfaces::msg::VehicleStatus::SharedPtr msg)
    {
        RCLCPP_INFO(this->get_logger(), 
            "收到: speed=%.2f, steering=%.3f, id=%s",
            msg->speed, msg->steering_angle, msg->vehicle_id.c_str());
    }
    rclcpp::Subscription<chapt4_interfaces::msg::VehicleStatus>::SharedPtr subscription_;
};

int main(int argc, char *argv[])
{
    rclcpp::init(argc, argv);
    rclcpp::spin(std::make_shared<VehicleSubscriber>());
    rclcpp::shutdown();
    return 0;
}

然后在 CMakeLists.txt 里添加编译规则:

add_executable(vehicle_subscriber src/vehicle_subscriber.cpp)
ament_target_dependencies(vehicle_subscriber rclcpp chapt4_interfaces)
install(TARGETS vehicle_subscriber DESTINATION lib/${PROJECT_NAME})

小技巧: C++里消息头文件的路径是 chapt4_interfaces/msg/vehicle_status.hpp,注意是下划线命名。Python里则是 chapt4_interfaces.msg.VehicleStatus,驼峰命名。这个区别我一开始老搞混。

4.5 跑起来看看效果

编译整个包:

colcon build --packages-select chapt4_interfaces
source install/setup.bash

开两个终端:

终端1(发布者):

ros2 run chapt4_interfaces vehicle_publisher

终端2(订阅者):

ros2 run chapt4_interfaces vehicle_subscriber

如果一切正常,你会看到订阅者终端每秒打印一次车辆状态信息。这说明Python节点和C++节点通过自定义消息成功通信了。

验证通信的另一种方式:

你也可以用命令行工具查看话题:

ros2 topic echo /vehicle_status

这样不用写订阅者也能看到发布的数据,调试时特别方便。

4.6 避坑指南与个人经验

  • 消息接口修改后必须重新编译:我经常改了msg文件忘了编译,结果节点运行时说找不到类型。记住,改完接口一定要 colcon build 并 source。
  • Python节点的可执行权限:chmod +x 这一步容易忘,忘了就会报 Permission denied。
  • C++节点的依赖声明:CMakeLists.txt 里 ament_target_dependencies 一定要写上自定义消息包名,否则链接时会报未定义引用。
  • 话题名称要一致:发布者和订阅者的话题字符串必须完全一样,大小写敏感。我见过有人把 vehicle_status 写成 VehicleStatus,结果两边对不上。

嗯,这一章的内容就这些。从自定义消息到Python发布者,再到C++订阅者,整个流程走下来,你应该对ROS2的节点通信有了切身体会。说白了,就是定义好数据格式,然后一个发一个收,跨语言也能无缝对接。

可可精品资料库