ROS机器人开发实践
上QQ阅读APP看书,第一时间看更新

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消息类型