3.6 话题中的Publisher与Subscriber
我们以第一节的乌龟仿真为例,看一下在这个例程中存在哪些Publisher(发布者)和Subscriber(订阅者)。
3.6.1 乌龟例程中的Publisher与Subscriber
按照3.1节的方法运行乌龟例程,然后使用如下命令查看例程的节点关系图:
$ rqt_graph
该命令可以查看系统中的节点关系图,乌龟例程中的节点关系如图3-25所示。
图3-25 乌龟仿真例程中的节点关系图
当前系统中存在两个节点:teleop_turtle和turtlesim,其中teleop_turtle节点创建了一个Publisher,用于发布键盘控制的速度指令,turtlesim节点创建了一个Subscriber,用于订阅速度指令,实现小乌龟在界面上的运动。这里的话题是/turtle1/cmd_vel。
Publisher和Subscriber是ROS系统中最基本、最常用的通信方式,接下来我们就以经典的“Hello World”为例,一起学习如何创建Publisher和Subscriber。
3.6.2 如何创建Publisher
Publisher的主要作用是针对指定话题发布特定数据类型的消息。我们尝试使用代码实现一个节点,节点中创建一个Publisher并发布字符串“Hello World”,源码learning_communication\src\talker.cpp的详细内容如下:
#include <sstream> #include "ros/ros.h" #include "std_msgs/String.h" int main(int argc, char **argv) { // ROS节点初始化 ros::init(argc, argv, "talker"); // 创建节点句柄 ros::NodeHandle n; // 创建一个Publisher,发布名为chatter的topic,消息类型为std_msgs::String ros::Publisher chatter_pub = n.advertise<std_msgs::String>("chatter", 1000); // 设置循环的频率 ros::Rate loop_rate(10); int count = 0; while (ros::ok()) { // 初始化std_msgs::String类型的消息 std_msgs::String msg; std::stringstream ss; ss << "hello world " << count; msg.data = ss.str(); // 发布消息 ROS_INFO("%s", msg.data.c_str()); chatter_pub.publish(msg); // 循环等待回调函数 ros::spinOnce(); // 按照循环频率延时 loop_rate.sleep(); ++count; } return 0; }
下面逐行剖析以上代码中Publisher节点的实现过程。
1.头文件部分
#include "ros/ros.h" #include "std_msgs/String.h"
为了避免包含繁杂的ROS功能包头文件,ros/ros.h已经帮我们包含了大部分ROS中通用的头文件。节点会发布String类型的消息,所以需要先包含该消息类型的头文件String.h。该头文件根据String.msg的消息结构定义自动生成,我们也可以自定义消息结构,并生成所需要的头文件。
2.初始化部分
ros::init(argc, argv, "talker");
初始化ROS节点。该初始化的init函数包含三个参数,前两个参数是命令行或launch文件输入的参数,可以用来完成命名重映射等功能;第三个参数定义了Publisher节点的名称,而且该名称在运行的ROS中必须是独一无二的,不允许同时存在相同名称的两个节点。
ros::NodeHandle n;
创建一个节点句柄,方便对节点资源的使用和管理。
ros::Publisher chatter_pub = n.advertise<std_msgs::String>("chatter", 1000);
在ROS Master端注册一个Publisher,并告诉系统Publisher节点将会发布以chatter为话题的String类型消息。第二个参数表示消息发布队列的大小,当发布消息的实际速度较慢时,Publisher会将消息存储在一定空间的队列中;如果消息数量超过队列大小时,ROS会自动删除队列中最早入队的消息。
ros::Rate loop_rate(10);
设置循环的频率,单位是Hz,这里设置的是10 Hz。当调用Rate::sleep()时,ROS节点会根据此处设置的频率休眠相应的时间,以保证循环维持一致的时间周期。
3.循环部分
int count = 0; while (ros::ok()) {
进入节点的主循环,在节点未发生异常的情况下将一直在循环中运行,一旦发生异常,ros::ok()就会返回false,跳出循环。
这里的异常情况主要包括。
·收到SIGINT信号(Ctrl+C)。
·被另外一个相同名称的节点踢掉线。
·节点调用了关闭函数ros::shutdown()。
·所有ros::NodeHandles句柄被销毁。
std_msgs::String msg; std::stringstream ss; ss << "hello world " << count; msg.data = ss.str();
初始化即将发布的消息。ROS中定义了很多通用的消息类型,这里我们使用了最为简单的String消息类型,该消息类型只有一个成员,即data,用来存储字符串数据。
chatter_pub.publish(msg);
发布封装完毕的消息msg。消息发布后,Master会查找订阅该话题的节点,并且帮助两个节点建立连接,完成消息的传输。
ROS_INFO("%s", msg.data.c_str());
ROS_INFO类似于C/C++中的printf/cout函数,用来打印日志信息。这里我们将发布的数据在本地打印,以确保发出的数据符合要求。
ros::spinOnce();
ros::spinOnce用来处理节点订阅话题的所有回调函数。
虽然目前的发布节点并没有订阅任何消息,spinOnce函数不是必需的,但是为了保证功能无误,建议所有节点都默认加入该函数。
loop_rate.sleep();
现在Publisher一个周期的工作已经完成,可以让节点休息一段时间,调用休眠函数,节点进入休眠状态。当然,节点不可能一直休眠下去,别忘了之前设置了10Hz的休眠时间,节点休眠100ms后又会开始下一个周期的循环工作。
以上详细讲解了一个Publisher节点的实现过程,虽然该节点的实现较为简单,却包含了实现一个Publisher的所有流程,下面再来总结这个流程:
·初始化ROS节点。
·向ROS Master注册节点信息,包括发布的话题名和话题中的消息类型。
·按照一定频率循环发布消息。
3.6.3 如何创建Subscriber
接下来,我们尝试创建一个Subscriber以订阅Publisher节点发布的“Hello World”字符串,实现源码learning_communication\src\listener.cpp的详细内容如下:
#include "ros/ros.h" #include "std_msgs/String.h" // 接收到订阅的消息后,会进入消息回调函数 void chatterCallback(const std_msgs::String::ConstPtr& msg) { // 将接收到的消息打印出来 ROS_INFO("I heard: [%s]", msg->data.c_str()); } int main(int argc, char **argv) { // 初始化ROS节点 ros::init(argc, argv, "listener"); // 创建节点句柄 ros::NodeHandle n; // 创建一个Subscriber,订阅名为chatter的话题,注册回调函数chatterCallback ros::Subscriber sub = n.subscribe("chatter", 1000, chatterCallback); // 循环等待回调函数 ros::spin(); return 0; }
下面剖析以上代码中Subscriber节点的实现过程。
1.回调函数部分
void chatterCallback(const std_msgs::String::ConstPtr& msg) { // 将接收到的消息打印出来 ROS_INFO("I heard: [%s]", msg->data.c_str()); }
回调函数是订阅节点接收消息的基础机制,当有消息到达时会自动以消息指针作为参数,再调用回调函数,完成对消息内容的处理。如上是一个简单的回调函数,用来接收Publisher发布的String消息,并将消息数据打印出来。
2.主函数部分
主函数中ROS节点初始化部分的代码与Publisher的相同,不再赘述。
ros::Subscriber sub = n.subscribe("chatter", 1000, chatterCallback);
订阅节点首先需要声明自己订阅的消息话题,该信息会在ROS Master中注册。Master会关注系统中是否存在发布该话题的节点,如果存在则会帮助两个节点建立连接,完成数据传输。NodeHandle::subscribe()用来创建一个Subscriber。第一个参数即为消息话题;第二个参数是接收消息队列的大小,和发布节点的队列相似,当消息入队数量超过设置的队列大小时,会自动舍弃时间戳最早的消息;第三个参数是接收到话题消息后的回调函数。
ros::spin();
接着,节点将进入循环状态,当有消息到达时,会尽快调用回调函数完成处理。ros::spin()在ros::ok()返回false时退出。
根据以上订阅节点的代码实现,下面我们来总结实现Subscriber的简要流程。
·初始化ROS节点。
·订阅需要的话题。
·循环等待话题消息,接收到消息后进入回调函数。
·在回调函数中完成消息处理。
3.6.4 编译功能包
节点的代码已经完成,C++是一种编译语言,在运行之前需要将代码编译成可执行文件,如果使用Python等解析语言编写代码,则不需要进行编译,可以省去此步骤。
ROS中的编译器使用的是CMake,编译规则通过功能包中的CMakeLists.txt文件设置,使用catkin命令创建的功能包中会自动生成该文件,已经配置多数编译选项,并且包含详细的注释,我们几乎不用查看相关的说明手册,稍作修改就可以编译自己的代码。
打开功能包中的CMakeLists.txt文件,找到以下配置项,去掉注释并稍作修改:
include_directories(include ${catkin_INCLUDE_DIRS}) add_executable(talker src/talker.cpp) target_link_libraries(talker ${catkin_LIBRARIES}) add_dependencies(talker ${PROJECT_NAME}_generate_messages_cpp) add_executable(listener src/listener.cpp) target_link_libraries(listener ${catkin_LIBRARIES}) add_dependencies(talker ${PROJECT_NAME}_generate_messages_cpp)
对于这个较为简单的功能包,主要用到了以下四种编译配置项。
(1)include_directories
用于设置头文件的相对路径。全局路径默认是功能包的所在目录,比如功能包的头文件一般会放到功能包根目录下的include文件夹中,所以此处需要添加该文件夹。此外,该配置项还包含ROS catkin编译器默认包含的其他头文件路径,比如ROS默认安装路径、Linux系统路径等。
(2)add_executable
用于设置需要编译的代码和生成的可执行文件。第一个参数为期望生成的可执行文件的名称,后边的参数为参与编译的源码文件(cpp),如果需要多个代码文件,则可在后面依次列出,中间使用空格进行分隔。
(3)target_link_libraries
用于设置链接库。很多功能需要使用系统或者第三方的库函数,通过该选项可以配置执行文件链接的库文件,其第一个参数与add_executable相同,是可执行文件的名称,后面依次列出需要链接的库。此处编译的Publisher和Subscriber没有使用其他库,添加默认链接库即可。
(4)add_dependencies
用于设置依赖。在很多应用中,我们需要定义语言无关的消息类型,消息类型会在编译过程中产生相应语言的代码,如果编译的可执行文件依赖这些动态生成的代码,则需要使用add_dependencies添加${PROJECT_NAME}_generate_messages_cpp配置,即该功能包动态产生的消息代码。该编译规则也可以添加其他需要依赖的功能包。
以上编译内容会帮助系统生成两个可执行文件:talker和listener,放置在工作空间的~/catkin_ws/devel/lib/<package name>路径下。
CMakeLists.txt修改完成后,在工作空间的根路径下开始编译:
$ cd ~/catkin_ws $ catkin_make
3.6.5 运行Publisher与Subscriber
编译完成后,我们终于可以运行Publisher和Subscriber节点了。在运行节点之前,需要在终端中设置环境变量,否则无法找到功能包最终编译生成的可执行文件:
$ cd ~/catkin_ws $ source ./devel/setup.bash
也可以将环境变量的配置脚本添加到终端的配置文件中:
$ echo "source ~/catkin_ws/devel/setup.bash" >> ~/.bashrc $ source ~/.bashrc
环境变量设置成功后,可以按照以下步骤启动例程。
1.启动roscore
在运行节点之前,首先需要确保ROS Master已经成功启动:
$ roscore
2.启动Publisher
Publisher和Subscriber节点的启动顺序在ROS中没有要求,这里先使用rosrun命令启动Publisher:
$ rosrun learning_communication talker
如果Publisher节点运行正常,终端中会出现如图3-26所示的日志信息。
图3-26 Publisher节点启动成功后的日志信息
3.启动Subscriber
Publisher节点已经成功运行,接下来需要运行Subscriber节点,订阅Publisher发布的消息:
$ rosrun learning_communication listener
如果消息订阅成功,会在终端中显示接收到的消息内容,如图3-27所示。
图3-27 Subscriber节点启动成功后的日志信息
这个“Hello World”例程中的Publisher与Subscriber就这样运行起来了。我们也可以调换两者的运行顺序,先启动Subscriber,该节点会处于循环等待状态,直到Publisher启动后终端中才会显示订阅收到的消息内容。
3.6.6 自定义话题消息
在以上例程中,chatter话题的消息类型是ROS中预定义的String。在ROS的元功能包common_msgs中提供了许多不同消息类型的功能包,如std_msgs(标准数据类型)、geometry_msgs(几何学数据类型)、sensor_msgs(传感器数据类型)等。这些功能包中提供了大量常用的消息类型,可以满足一般场景下的常用消息。但是在很多情况下,我们依然需要针对自己的机器人应用设计特定的消息类型,ROS也提供了一套语言无关的消息类型定义方法。
msg文件就是ROS中定义消息类型的文件,一般放置在功能包根目录下的msg文件夹中。在功能包编译过程中,可以使用msg文件生成不同编程语言使用的代码文件。例如下面的msg文件(learning_communication/msg/Person.msg),定义了一个描述个人信息的消息类型,包括姓名、性别、年龄等:
string name uint8 sex uint8 age
这里使用的基础数据类型string、uint8都是语言无关的,编译阶段会变成各种语言对应的数据类型。
在msg文件中还可以定义常量,例如上面的个人信息中,性别分为男和女,我们可以定义“unknown”为0,“male”为1,“female”为2:
string name uint8 sex uint8 age uint8 unknown = 0 uint8 male = 1 uint8 female = 2
这些常量在发布或订阅消息数据时可以直接使用,相当于C++中的宏定义。
很多ROS消息定义中还会包含一个标准格式的头信息std_msgs/Header:
#Standard metadata for higher-level flow data types uint32 seq time stamp string frame_id
其中:seq是消息的顺序标识,不需要手动设置,Publisher在发布消息时会自动累加;stamp是消息中与数据相关联的时间戳,可以用于时间同步;frame_id是消息中与数据相关联的参考坐标系id。此处定义的消息类型较为简单,也可以不加头信息。
为了使用这个自定义的消息类型,还需要编译msg文件。msg文件的编译需要注意以下两点。
(1)在package.xml中添加功能包依赖
首先打开功能包的package.xml文件,确保该文件中设置了以下编译和运行的相关依赖:
<build_depend>message_generation</build_depend> <run_depend>message_runtime</run_depend>
(2)在CMakeLists.txt中添加编译选项
然后打开功能包的CMakeLists.txt文件,在find_package中添加消息生成依赖的功能包message_generation,这样在编译时才能找到所需要的文件:
find_package(catkin REQUIRED COMPONENTS geometry_msgs roscpp rospy std_msgs message_generation )
catkin依赖也需要进行以下设置:
catkin_package( …… CATKIN_DEPENDS geometry_msgs roscpp rospy std_msgs message_runtime ……)
最后设置需要编译的msg文件:
add_message_files( FILES Person.msg ) generate_messages( DEPENDENCIES std_msgs )
以上配置工作都完成后,就可以回到工作空间的根路径下,使用catkin_make命令进行编译了。编译成功后,可以使用如下命令查看自定义的Person消息类型(见图3-28):
$ rosmsg show Person
Person消息类型已经定义成功,在代码中就可以按照以上String类型的使用方法使用Person类型的消息了。
图3-28 查看自定义的Person消息类型