ROS1教程 7:ROS1通信机制——话题通信


ROS1教程 7:ROS1通信机制——话题通信

话题通信是ROS1中使用频率最高的一种通信模式,话题通信是基于发布订阅模式的,也即:一个节点发布消息,另一个节点订阅该消息

话题通信的应用场景也极其广泛,比如下面一个常见场景:

机器人在执行导航功能,使用的传感器是激光雷达,机器人会采集激光雷达感知到的信息并计算,然后生成运动控制信息驱动机器人底盘运动。

在上述场景中,就不止一次使用到了话题通信:

  • 以激光雷达信息的采集处理为例,在ROS1中有一个节点需要时时的发布当前雷达采集到的数据,导航模块中也有节点会订阅并解析雷达数据
  • 再以运动消息的发布为例,导航模块会根据传感器采集的数据时时的计算出运动控制信息并发布给底盘,底盘也可以有一个节点订阅运动信息并最终转换成控制电机的脉冲信号

以此类推,像雷达、摄像头、GPS……等等一系列传感器数据的采集,都是使用了话题通信。换言之,话题通信适用于不断更新的数据传输相关的应用场景

1. 话题通信介绍

1.1 基础知识

话题通信是以发布订阅的方式实现不同节点之间数据交互的通信模式话题通信用于不断更新的、少逻辑处理的数据传输场景

例如摄像头不断通过发布图像到/img话题下,而诸如人脸识别物体检测这样的复杂、低频处理,最好还是以服务的形式来实现。

后面,我们首先将实现最基本的发布订阅模型:发布方以固定频率发送一段文本,订阅方接收文本并输出

基础案例

然后再实现对自定义消息的发布与订阅

自定义消息

1.2 理论模型

话题通信实现模型是比较复杂的,该模型如下图所示,该模型中涉及到三个角色:

  • ROS1 Master(管理者)
  • Talker(发布者)
  • Listener(订阅者)

ROS1 Master负责保管TalkerListener注册的信息,并匹配话题相同的TalkerListener,帮助TalkerListener建立连接,连接建立后,Talker可以发布消息,且发布的消息会被Listener订阅。

话题通信理论模型

我们按照流程来走一遍话题通信的整个流程:

  1. 首先是Talker启动,即数据发布者启动,此时Talker会被注册在ROS1 MasterTalker启动后,会通过RPCROS1 Master中注册自身信息,其中包含所发布消息的话题名称。ROS1 Master会将节点的注册信息加入到注册表中。
  2. 然后是Listener启动,即数据接受者启动,此时Listener会被注册在ROS1 MasterListener根据接收到的RPC地址,通过RPCTalker发送连接请求,传输订阅的话题名称、消息类型以及通信协议(TCP/UDP)。
  3. 接下来,ROS1 Master会根据注册表中的信息匹配TalkerListener。在匹配成功后,ROS1 Master会通过RPCListener发送TalkerRPC地址信息。
  4. 接着ListenerTalker发送请求。当Listener接收到的RPC地址后,会通过RPCTalker发送连接请求,传输订阅的话题名称、消息类型以及通信协议(TCP/UDP)。
  5. 而后Talker确认请求Talker接收到Listener的请求后,也是通过RPCListener确认连接信息,并发送自身的TCP地址信息。
  6. TalkerListener建立连接Listener根据步骤5返回的消息使用TCPTalker建立网络连接。
  7. TalkerListener发送消息。连接建立后,Talker开始向Listener发布消息。

注意:

  • 上述实现流程中,前五步使用的RPC协议,最后两步使用的是TCP协议
  • TalkerListener的启动无先后顺序要求,但是ROS1 Master必须先启动
  • TalkerListener都可以有多个
  • TalkerListener连接建立后,不再需要ROS1 Master。也即,即便关闭ROS1 MasterTalkerListern依旧可以正常通信

最重要的是,这7个步骤ROS1已经为我们封装好了,我们不需要手动注册、建立链接,对我们来说就是几个API就可以解决的事情

因此,我们在话题通信的时候,需要注意的是:

  • 话题的设置
  • 发布者的实现
  • 订阅者的实现
  • 消息载体的实现

2. 话题通信C++实现

我们上面介绍了话题通信理论知识,我们接下来用C++来实现话题通信

2.1 需求

编写发布订阅实现,要求发布方以10HZ(每秒10次)的频率发布文本消息,订阅方订阅消息并将消息内容打印输出。

2.2 分析

ROS1话题通信模型的七步实现中,ROS1 Master不需要实现,而连接的建立也已经被封装了,因此,我们自己写代码的时候,需要关注的关键点有三个:

  1. 发布方
  2. 接收方
  3. 数据(此处为普通文本)

我们接下来就将围绕这三个点进行实现

2.3 实现流程

按照上面的分析,我们按照下面的流程进行实现:

  1. 编写发布方实现
  2. 编写订阅方实现
  3. 编辑配置文件
  4. 编译并执行

PS:我们这里先运行下面的命令创建一个名为topic_communication的功能包

cd 工作目录/src
catkin_create_pkg topic_communication roscpp rospy std_msgs

A 发布方实现

我们在工作目录/src/topic_communication/src下新建一个名为pub.cpp的文件,即发布者的实现。

1. 单纯发布消息

我们首先实现一个最简单的版本,该版本中发布者只要不断发出消息即可。

发布者最重要的就是ROS1::Publisher对象,该对象可以用ROS1::NodeHandle.advertise方法创建,该方法支持重载,我们需要发布什么类型的消息就重载什么类型的消息。

此外,ROS1::Publisher对象有一个publish方法,该方法用于发布消息,本质上也是一个重载函数,不过已经对message做了屏蔽。

所以,下面的代码中,除了正常的ROS1::init()初始化节点和创建ROS1::Nodehandle句柄外,我们需要特别关注ROS1::NodeHandle相关的语句

注意这里我们设置节点名称为publisher_cpp,用于和后面的Python进行区别

#include "ROS1/ROS1.h"
#include "std_msgs/String.h"            // 普通文本类型的消息,ROS1中已经为其封装了一个标准实现
#include <sstream>

int main(int argc, char *argv[]){
    // 初始化当前节点,节点名设置为publisher
    ROS1::init(argc, argv, "publisher_cpp");
    // 创建节点句柄
    ROS1::NodeHandle nh;
    // 初始化发布者
    ROS1::Publisher pub = nh.advertise<std_msgs::String>("messages", 10, true);
    // 发布消息前,创建String消息对象
    std_msgs::String msg;

    // 当前节点没有退出或者被意外终止就继续运行
    while (ROS1::ok()){
        // 设置String消息的内容
        msg.data = "Hello I'm publisher!";
        // 发布者发送消息
        pub.publish(msg);
    }

    return 0;
}

然后编辑CMakeLists.txt,向其中添加pub.cpp

注意这里我们修改了可执行文件名称为publisher_cpp,用于和后面的Python进行区别

add_executable(
    publisher_cpp
    src/pub.cpp
)

add_dependencies(
    publisher_cpp
    ${${PROJECT_NAME}_EXPORTED_TARGETS}
    ${catkin_EXPORTED_TARGETS}
)

target_link_libraries(publisher_cpp
  ${catkin_LIBRARIES}
)

在命令号编译之后运行:

cd 工作目录
catkin_make
rospack profile            # 新的功能包第一次编译后生成一下profile,从而添加rosrun的补全
roscore
rosrun topic_communication publisher_cpp                # 新的终端下运行

此时命令行没有任何输出,这是正常的,因为我们没有使用ROSINFO来向终端输出任何内容

命令行没有输出

此时如何检测我们的publisher是否真的在发布消息呢?我们首先运行rqt_graph来看看所有的节点和话题

rqt_graph

注意,rqt_graph一开始的设置是只显示节点,并且默认隐藏没有订阅者的话题的,即Leaf Topic,同时还会隐藏dead sink,所我们需要:

  • 取消Hide Leaf Topic
  • 取消Hide Dead Sinks
  • 设置为Nodes/Topics(all)

要修改的三个设置

设置了rqt_graph之后,就可能看到publisher节点确实向message话题中发布了数据

设置之后正常显示节点和话题

然而此时还有一个问题,就是即便publisher确实和message话题有关系,但是我们如何确定message这个话题中是否有数据?

此时我们可以运行rostopic这个命令,rostopic命令的echo选项可以用来订阅任意话题中的数据,并且输出到终端上

rostopic echo /message

rostopic向终端上输出数据

此时我们再新开一个终端,检查一下此时的节点关系,注意,rostopic echo /messagerosrun topic_communication publisher不要中止

运行rostopic echo后的节点图

此时,我们就会发现,有一个新节点订阅了我们的messages话题,这个节点其实就是rostopic命令运行的节点。而且,rostopic会给新的节点后面添加时间戳,来唯一标记当前echo的节点

2. 设置频率发布消息

我们上面已经成功的实现了发布者,并且检查了发布者能够向指定的话题中发布消息。

我们接下来添加频率设置,即让发布者按照指定的频率来发布消息,为此我们可以使用ROS1::Rate对象。ROS1::Rate对象有一个sleep方法,这个方法会自动计算距离上次运行到sleep消耗的时间,然后自动计算需要睡眠的时间,然后进行睡眠。

有一个需要注意的是如果频率过高的话,ROS1::Rate是没法实现的。即如果循环一次要500毫秒,但是我们却要求ROS1::Rate保证10Hz,即每秒运行10次循环,这个时候ROS1::Rate就没有用了。

详细代码如下,注意ROS1::Rate相关的语句,

#include "ROS1/ROS1.h"
#include "std_msgs/String.h"            // 普通文本类型的消息,ROS1中已经为其封装了一个标准实现
#include <sstream>

int main(int argc, char *argv[]){
    // 初始化当前节点
    ROS1::init(argc, argv, "publisher_cpp");
    // 创建节点句柄
    ROS1::NodeHandle nh;
    // 初始化发布者
    ROS1::Publisher pub = nh.advertise<std_msgs::String>("messages", 10, true);
    // 发布消息前,创建String消息对象
    std_msgs::String msg;

    // 创建循环频率,每秒10Hz
    ROS1::Rate rate(10);

    int count = 0;              // 等会给每句话添加编号
    std::stringstream ss;       // 等会给每句话添加编号用

    // 当前节点没有退出或者被意外终止就继续运行
    while (ROS1::ok()){
        // 设置String消息的内容, 用string stream (sstream)来实现字符串拼接
        ss.str(std::string());                                  // 清空string stream
        ss <<  "Hello I'm publisher -- " << count++ << "!";     // 拼接字符串
        msg.data = ss.str();                                    // 复制字符串到data中
        // 发布者发送消息
        pub.publish(msg);
        // 向命令行输出消息
        ROS_INFO("发布数据: %s", ss.str().c_str());
        // ROS1::Rate将会自动休眠,以满足初始化时频率的要求
        rate.sleep();
    }

    return 0;
}

但是这个时候,如果直接运行的话,命令行中中文的输出是乱码的,这是因为C++中的编码不对,即内存中的中文的编码格式不对。

C++中文输出乱码

为此,我们在C++代码的前面设置一下编码格式,注意下面放在最前面的set_local函数

#include "ROS1/ROS1.h"
#include "std_msgs/String.h"            // 普通文本类型的消息,ROS1中已经为其封装了一个标准实现
#include <sstream>

int main(int argc, char *argv[]){
    // 设置编码格式
    setlocale(LC_ALL, "");

    // 初始化当前节点
    ROS1::init(argc, argv, "publisher_cpp");
    // 创建节点句柄
    ROS1::NodeHandle nh;
    // 初始化发布者
    ROS1::Publisher pub = nh.advertise<std_msgs::String>("messages", 10, true);
    // 发布消息前,创建String消息对象
    std_msgs::String msg;

    // 创建循环频率,每秒10Hz
    ROS1::Rate rate(10);

    int count = 0;              // 等会给每句话添加编号
    std::stringstream ss;       // 等会给每句话添加编号用

    // 当前节点没有退出或者被意外终止就继续运行
    while (ROS1::ok()){
        // 设置String消息的内容, 用string stream (sstream)来实现字符串拼接
        ss.str(std::string());                                  // 清空string stream
        ss <<  "Hello I'm publisher -- " << count++ << "!";     // 拼接字符串
        msg.data = ss.str();                                    // 复制字符串到data中
        // 发布者发送消息
        pub.publish(msg);
        // 向命令行输出消息
        ROS_INFO("发布数据: %s", ss.str().c_str());
        // ROS1::Rate将会自动休眠,以满足初始化时频率的要求
        rate.sleep();
    }

    return 0;
}

中文正常输出

最后,我们运行一下rostopic echorqt_graph来看看

roscore
rosrun topic_communication publisher_cpp            # 新的终端中运行
rostopic echo /message                                                # 新的终端中运行
rqt_graph                                                                            # 新的终端中运行

运行结果

3. 完整代码

最后,完整版,带注释的代码如下:

// pub.cpp
/*
    需求: 实现基本的话题通信,一方发布数据,一方接收数据,
         实现的关键点:
         1.发送方
         2.接收方
         3.数据(此处为普通文本)

         PS: 二者需要设置相同的话题

    消息发布方:
        循环发布信息:HelloWorld 后缀数字编号

    实现流程:
        1.包含头文件 
        2.初始化 ROS1 节点:命名(唯一)
        3.实例化 ROS1 句柄
        4.实例化 发布者 对象
        5.组织被发布的数据,并编写逻辑发布数据

*/

// 1.包含头文件 
#include "ROS1/ROS1.h"
#include "std_msgs/String.h" //普通文本类型的消息,ROS1中已经为其封装了一个标准实现
#include <sstream>


int main(int argc, char  *argv[])
{   
    // 设置编码
    setlocale(LC_ALL,"");

    // 2.初始化 ROS1 节点:命名(唯一)
    //      参数1和参数2后期为节点传值会使用
    //      参数3是节点名称,是一个标识符,需要保证运行后,在 ROS1 网络拓扑中唯一
    ROS1::init(argc,argv,"publisher");
    // 3.实例化 ROS1 句柄
    ROS1::NodeHandle nh;//该类封装了 ROS1 中的一些常用功能

    // 4.实例化 发布者 对象
    //      泛型: 发布的消息类型
    //      参数1: 要发布到的话题
    //      参数2: 队列中最大保存的消息数,超出此阀值时,先进的先销毁(时间早的先销毁)
    ROS1::Publisher pub = nh.advertise<std_msgs::String>("message",10);

    // 5.组织被发布的数据,并编写逻辑发布数据

      // 发布消息前,创建String消息对象
    std_msgs::String msg;

    // 创建循环频率,每秒10Hz
    ROS1::Rate rate(10);

    int count = 0;              // 等会给每句话添加编号
    std::stringstream ss;       // 等会给每句话添加编号用

    // 当前节点没有退出或者被意外终止就继续运行
    while (ROS1::ok()){
        // 设置String消息的内容, 用string stream (sstream)来实现字符串拼接
        ss.str(std::string());                                  // 清空string stream
        ss <<  "Hello I'm publisher -- " << count++ << "!";     // 拼接字符串
        msg.data = ss.str();                                    // 复制字符串到data中
        // 发布者发送消息
        pub.publish(msg);
        // 向命令行输出消息
        ROS_INFO("发布数据: %s", ss.str().c_str());
        // ROS1::Rate将会自动休眠,以满足初始化时频率的要求
        rate.sleep();
    }

    return 0;
}

B 订阅方实现

1. 代码讲解

订阅方的实现很多都和发布方是一样的,不同的是我们需要使用函数nh.subscribe来创建订阅者ROS1::Subscribernh.subscribe需要使用一个回调函数,这个回调函数接受的参数类型是const 消息属于的功能包::消息类型::ConstPtr。这个是ROS1的要求,是没法改的。

稍后我们会介绍如何自定义消息,事实上,自定义消息之后,catkin编译系统会自动帮我们创建消息的头文件和对象,这个在后面我们再详细介绍

最后我们有一个ROS1::spin()函数,这个函数的意思就是类似于协程的概念。因为我们的节点中可能有不止一个订阅者,可能会有多个订阅者。而这个时候是当前节点接收到了哪一类消息,那么就会在所有的订阅者中循环检查,看看是哪个一个订阅者的回调函数处理这一类消息,然后就会调用这一个回调函数去处理消息。

而所有静态代码中所有订阅者的队列、运行时候的消息队列等等都是由ROS1去维护的。

具体代码如下,注意依旧是要设置一下内存中的字符的编码格式

#include "ROS1/ROS1.h"
#include "std_msgs/String.h"

// 订阅者的回调函数,当消息传到的时候将调用该函数处理
void do_msg(const std_msgs::String::ConstPtr &msg){
    // 这个回调函数就是单纯的输出
    ROS_INFO("订阅数据: %s", msg->data.c_str());
}

int main(int argc, char *argv[]){

    setlocale(LC_ALL, "");

    // 初始化节点
    ROS1::init(argc, argv, "subscriber_cpp");
    // 创建节点句柄
    ROS1::NodeHandle nh;

    // 创建订阅者对象
    ROS1::Subscriber sub = nh.subscribe("messages", 10, do_msg);

    // 循环处理
    ROS1::spin();

    return 0;
}

然后编辑CMakeLists.txt,添加订阅者的内容

add_executable(
    subscriber_cpp
    src/sub.cpp
)

add_dependencies(
    subscriber_cpp
    ${${PROJECT_NAME}_EXPORTED_TARGETS}
    ${catkin_EXPORTED_TARGETS}
)

target_link_libraries(subscriber_cpp
  ${catkin_LIBRARIES}
)

最后,编译代码并运行,顺便跑一个rqt_graph来看看

cd 工作目录
catkin_make
roscore
rosrun topic_communication publisher                # 新开一个终端
rosrun topic_communication subscriber                # 新开一个终端
rqt_graph                                                                        # 新开一个终端

订阅方运行结果

2. 完整代码

最后,带注释的完整代码如下

/*
    需求: 实现基本的话题通信,一方发布数据,一方接收数据,
         实现的关键点:
         1.发送方
         2.接收方
         3.数据(此处为普通文本)


    消息订阅方:
        订阅话题并打印接收到的消息

    实现流程:
        1.包含头文件 
        2.初始化 ROS1 节点:命名(唯一)
        3.实例化 ROS1 句柄
        4.实例化 订阅者 对象
        5.处理订阅的消息(回调函数)
        6.设置循环调用回调函数

*/
// 1.包含头文件 
#include "ROS1/ROS1.h"
#include "std_msgs/String.h"

// 订阅者的回调函数,当消息传到的时候将调用该函数处理
void do_msg(const std_msgs::String::ConstPtr &msg){
    // 这个回调函数就是单纯的输出
    ROS_INFO("订阅数据: %s", msg->data.c_str());
}

int main(int argc, char *argv[]){

    setlocale(LC_ALL, "");

    // 2.初始化 ROS1 节点: 命名(唯一)
    ROS1::init(argc, argv, "subscriber_cpp");
    // 3.实例化 ROS1 句柄
    ROS1::NodeHandle nh;

    // 4.实例化 订阅者 对象
    ROS1::Subscriber sub = nh.subscribe("messages", 10, do_msg);

    // 循环处理订阅的消息(回调函数)
    ROS1::spin();

    return 0;
}

2.4 注意事项

最后,在实现的时候可能会有一些问题,我们在这里讲讲

A. char const *argv[]问题

vscode中的main函数声明可能是int main(int argc, char const *argv[]),即argvconst修饰,表示不可修改,但是ROS1::init()中的argv类型是char *argv[],所以此时传入char const *argv[]的话,就会报错,类型不匹配(CMakeLists.txt加入了strict-prototype标志)。

此时,需要去除该修饰符

B. 找不到ROS1.h头文件

报错是:ROS1/ROS1.h No such file or directory ...

这个一般是CMakeList.txt出错了,导致VSCode没法正常解析CMakeList.txt,从而无法去搜索头文件、补全。

已知会导致该问题的错误有:

  • CMakeLists.txt中,find_package出现重复,此时删除重复的内容即可

参考资料:https://answers.ROS1.org/question/237494/fatal-error-rosrosh-no-such-file-or-directory/

此外,有的时候find_package中没有添加一些包,但也可以运行,这个原因在ROS1.wiki中的解释如下:

You may notice that sometimes your project builds fine even if you did not call find_package with all dependencies. This is because catkin combines all your projects into one, so if an earlier project calls find_package, yours is configured with the same values. But forgetting the call means your project can easily break when built in isolation.

C. 订阅时,第一条/前几条数据丢失

如果Subscriber先启动,然后Publisher后启动,那么Subscriber可能会丢失前几条消息

丢失第一条/前几条数据

原因:发送第一条数据时,Publisher还未在 ROS1 Master中注册完毕

解决:注册后,休眠几秒ROS1::Duration(3.0).sleep()以保证第一条数据的发送时Publisher已经完成注册

3. 话题通信Python实现

我们上面介绍了话题通信的Python实现,接下来我们介绍一下话题通信的Python实现。

3.1 需求

Python的话题通信实现和C++的话题通信实现需求是一样的

编写发布订阅实现,要求发布方以10HZ(每秒10次)的频率发布文本消息,订阅方订阅消息并将消息内容打印输出。

3.2 分析

Python的分析和C++的分析也是一样的

ROS1话题通信模型的七步实现中,ROS1 Master不需要实现,而连接的建立也已经被封装了,因此,我们自己写代码的时候,需要关注的关键点有三个:

  1. 发布方
  2. 接收方
  3. 数据(此处为普通文本)

我们接下来就将围绕这三个点进行实现

3.3 实现流程

PythonC++的实现流程有些不同,我们接下来按照下面的流程来实现Python话题通信:

  1. 编写发布方实现
  2. 编写订阅方实现
  3. Python文件添加可执行权限
  4. 编辑配置文件
  5. 编译并执行

A 发布方实现

首先还是先在topic_communication功能包下新建一个scripts文件夹,然后再scripts文件夹中新建pub.py文件

1. 单纯发布消息

类似C++一样,我们首先实现一个最简单的Python发布者,即只能循环发布消息的发布者

需要注意的是,std_msgs包中的String消息类型是要从std_msgs.msg中直接导入的。然后Python中并不是ROS1::ok(),而是rospy.is_shutdown(),所以前面要加一个not来取反。

剩下的逻辑都和C++基本一致

# pub.py
import sys
import rospy
from std_msgs.msg import String

if __name__ == "__main__":
    # 初始化节点
    rospy.init_node(name="publisher_py", argv=sys.argv)
    # 初始化发布者
    pub = rospy.Publisher(name="messages", data_class=String, queue_size=10)
    # 初始化要发布的数据
    msg = String()
    # 循环发布数据,只要当前节点没有被关闭
    while not rospy.is_shutdown():
        msg.data = "Hello I'm publisher of Python"
        pub.publish(msg)

然后命令行中为pub.py添加可执行文件

cd 工作目录/src
chmod +x topic_communication/scripts/pub.py

接下来在功能包的CMakeLists.txt中添加一下Python脚本的安装目录

catkin_install_python(PROGRAMS
  scripts/pub.py
  DESTINATION ${CATKIN_PACKAGE_BIN_DESTINATION}

最后在命令行中编译一下,安装一下Python脚本

安装Python脚本

最后在命令行中运行:

roscore
rosrun topic_communication pub.py            # 新开中断运行

需要注意的是,如果命令行中报错command not found或者cannot read XXX,这种是因为rosrun使用的解释器不对,此时我们需要指定一下需要使用的解释器

command not found报错

此时在命令行运行下属的命令,然后将获取到的当前环境的Python解释器位置放到第一行

which python

which python的输出

#! /home/jack/miniconda3/bin/python
import sys
import rospy
from std_msgs.msg import String

if __name__ == "__main__":
    # 初始化节点
    rospy.init_node(name="publisher_py", argv=sys.argv)
    # 初始化发布者
    pub = rospy.Publisher(name="messages", data_class=String, queue_size=10)
    # 初始化要发布的数据
    msg = String()
    # 循环发布数据,只要当前节点没有被关闭
    while not rospy.is_shutdown():
        msg.data = "Hello I'm publisher of Python"
        pub.publish(msg)

然后此时再运行就好了。

同时我们新开终端,运行rqt_graphrostopic echo

roscore
rosrun topic_communication pub.py                    # 新开终端
rostopic echo messages                                        # 新开终端
rqt_graph                                                                    # 新开终端

运行结果

2. 设置频率发布消息

C++实现一样,Python中也有一个Rate对象来控制循环频率

这个对象很简单,直接添加即可

#! /home/jack/miniconda3/bin/python
import sys
import rospy
from std_msgs.msg import String

if __name__ == "__main__":
    # 初始化节点
    rospy.init_node(name="publisher_py", argv=sys.argv)
    # 初始化发布者
    pub = rospy.Publisher(name="messages", data_class=String, queue_size=10)
    # 初始化要发布的数据
    msg = String()

    # 设置频率
    rate = rospy.Rate(hz=10)
    counter = 0

    # 循环发布数据,只要当前节点没有被关闭
    while not rospy.is_shutdown():
        msg.data = ("Hello I'm publisher of Python -- %d" % counter)
        counter += 1
        pub.publish(msg)
        rospy.loginfo("发布数据:" + msg.data)
        rate.sleep()

最后运行的结果如下,也是非常简单

roscore
rosrun topic_communication pub.py

运行结果

B 订阅方实现

我们接下来在Python中实现订阅者。在topic_communication功能包的scripts文件夹下新建sub.py文件

具体实现流程其实和C++是一样的,都是需要一个回调函数,然后还有一个rospy.Subscriber对象,然后rospy.spin()即可

Python的实现确实简单不少,不过就像前面说的,Python开发效率高,但是性能差,而C++开发效率低,但是性能高

#! /home/jack/miniconda3/bin/python

import sys
import rospy
from std_msgs.msg import String

def do_msg(str: String) -> None:
    rospy.loginfo("订阅数据: " + str.data)


if __name__ == "__main__":
    rospy.init_node(name="subscriber_py", argv=sys.argv)

    sub = rospy.Subscriber(name="messages", data_class=String, callback=do_msg, queue_size=10)

    rospy.spin()

然后添加可执行权限

chmod +x topic_communication/src/sub.py

接下来编辑CMakeLists.txt,添加一下安装当前脚本的命令

catkin_install_python(PROGRAMS
  scripts/sub.py
  DESTINATION ${CATKIN_PACKAGE_BIN_DESTINATION}
)

接下来运行测试一下

cd 工作目录
catkin_make
roscore
rosrun topic_communication pub.py                # 新开一个终端运行
rosrun topic_communication sub.py                # 新开一个终端运行
rqt_graph                                                                # 新开一个终端运行

运行结果

4. ROS1语言独立性

我们在最前面介绍过了,ROS1的一个特点就是语言独立性

  • 语言独立性:ROS1的开发语言包括JavaC++Python等。为了支持更多应用开发和移植,ROS1设计为一种语言弱相关的框架结构,使用简洁,中立的定义语言描述模块间的消息接口,在编译中再产生所使用语言的目标文件,为消息交互提供支持,同时允许消息接口的嵌套使用

我们在前面,一直没有机会能够介绍ROS1的语言独立性,这里我们恰好有了C++Python开发的节点,所以我们就展示一下ROS1这语言独立性这个能力。

ROS1的语言独立性具体来说就是各个节点可以用不同的语言开发,而ROS1提供的API内实现了统一的访问。所以我们可以运行C++的发布者,而后运行Python订阅者

roscore
rosrun topic_communication publisher_cpp            # 新的终端中运行
rosrun topic_communication sub.py                            # 新的终端中运行
rqt_graph                                                                            # 新的终端中运行

我们可以看到,不同语言实现的节点,通过ROS1框架,能够进行统一的通信,这就是ROS1的第一个强大之处

ROS1的语言独立性

5. 自定义消息类型介绍

ROS1通信协议中,最重要的有如下三个元素:

  • 发布者
  • 订阅者
  • 消息

我们前面讲了如何实现发布者和订阅者,但是却没有将如何自定义消息类型,下面我们就将讲解如何自定义消息类型。

数据载体是话题通信的一个重要组成部分,ROS1中在std_msgs功能包封装了一些原生的数据类型,例如:

  • String
  • Int32
  • Int64
  • Char
  • Bool
  • Empty

但是,这些数据作为基础类型,一般只包含一个data字段,即只有一个数据域。结构的单一意味着功能上的局限性,当传输一些复杂的数据,比如: 激光雷达的数据,需要角度、距离和反射强度的三合一信息,即很多时候我们需要传输一个结构,这个时候依旧使用std_msgs就显得力不从心了

事实上std_msgs作为基础数据类型,由于描述性较差,因此往往在复杂的消息类型中都用作为自定义的复杂消息类型中的元素,从而解决诸如激光雷达这样的我问题的。

5.1 std_msgs介绍

std_msgs实际上是一个专门定义了基础数据类型的功能包,其中有非常多的.h.py文件可以让我们导入,而这些文件中定义了诸如StringInt32等等的实现。

而为什么我们在别的功能包中能够include或者import别的功能包呢?这个实际上涉及了ROS1的另外一个特性:松耦合

  • 松耦合:ROS1中功能模块封装于独立的功能包或元功能包,便于分享,功能包内的模块以节点为单位运行,以ROS1标准的IO作为接口,开发者不需要关注模块内部实现,只要了解接口规则就能实现复用,实现了模块间点对点的松耦合连接

关于这里我们这里不展开讲,我们后面会有专门的例子来介绍什么是松耦合,以及如何让自己的功能包为其他功能包提供接口。

5.2 .msg文件介绍

A. 自动化生成消息

如何定义一个消息类型呢?我们能想到的一种解决方案是,ROS1提供一个ros_topic_message的基类,其中定义了几个纯虚函数来定义接口。然后所有消息类型都必须继承ros_topic_message这个基类,而且每个消息类型都必须重写这些纯虚函数,例如上面String中的data

当然可以这样做,可是这样做就有一个问题,就是会增加开发的复杂程度,因为作为开发者,我们还需要去关注具体得消息的实现。但事实上:

  • 定义新的对象表示新的消息类型
  • 让新的消息类型继承消息的基类
  • 在新的消息类型对象中定义数据域

这几步完全可以自动化生成,而后具体的需要重写的方法生成的时候留空,而后我们如果有需要则自己重写即可。

例如,假设我们现在给定新消息类型的模板:

typdef struct __laser_msg_t {
  int64 distance;
  float64 angle;
  float64 reflect_tensity
} laster_msg_t;

然后根据消息类型的模板生成下面的类:

class laser_msg : public ros_topic_message {

public:
  int64 distance;
  float64 angle;
  float64 reflect_tensity;

  void init(){}
  void data(){}
  // ...
  // ...
}

那么我们在代码中就可以按照下面的方式调用:

#include <my_msgs>
my_msgs::laser_msg L;
// ...

而这个从模板到类的自动化生成,核心就是我们需要知道将要生成的新消息的中含有的数据对象。为此,ROS1专门使用一个后缀为.msg的文件来保存。我们这里先对.msg的详细介绍,而如何定义.msg文件,以及如何自动生成代码形式的消息的,在后面再进行介绍。

B. .msg文件

.msg只是简单的文本文件,每行都是字段类型 字段名称record,用于表示符合对象中的一个成员。例如我们查看std_msgs功能包中的我们用过的String.msg文件。

首先查找std_msgs文件的位置,如果你没有改ROS1安装的位置的话,这个std_msgs功能包一般在/opt/ROS1/noetic/share目录下,当然可以运行下面的命令来直接输出安装位置

echo $ROS_ROOT

ROS_ROOT的值

然后我们打开$ROS_ROOT/share/std_msgs功能包,查看其中所有的.msg文件

std_msgs功能包中所有的.msg文件

然后再查看我们前面一直在用的String.msg文件

String.msg文件

其中就定义了String这个消息类型的数据域string.data

而除了string这个数据域类型外,在std_msgsmsg文件夹下,还有其他的消息,其中包含了其他各种不同的数据域。而未来我们自定义的.msg文件中,就得用到这些数据域来组合,最后得到我们需要的消息类型。

我们通过cat来显示一下上面的ls所显示的std_msgs功能包中定义的其他消息类型其中包含的各种不同的字段。

cat显示消息类型的字段

总结一下,.msg可以使用的字段类型有:

  • 有符号类型:int8/16/32/64
  • 无符号类型:uint8/16/32/64
  • 浮点数类型:float32/64
  • 字符/字符串:char/string
  • 时间戳/时间段:time/duration
  • 数组:variable-length array[]/fixed-length array[C]

ROS1中还有一种特殊类型:Header,标头包含时间戳和ROS1中常用的坐标帧信息。会经常看到msg文件的第一行具有Header标头。例如我们正在进行跟随的时候,那么每时每刻人的位置都是不同的,所以十秒前人的位置不能用于指导机器人现在的运动,所以就会有一个Header标头

我们其实可以cat查看一下Header标头

cat /opt/ROS1/noetic/share/std_msgs/msg/Header.msg

Header标头

介绍完了.msg的理论知识之后,我们接下来来介绍如何自定义消息,即开始实现。

6. 自定义消息实现

我们接下来给定一个需求,然后来自定义消息类型

6.1. 需求

需求:创建自定义消息,该消息包含了一个人的基础信息:姓名、身高、年龄等。

流程:

  1. 添加依赖的功能包
  2. 按照固定格式创建msg文件
  3. 编辑配置文件
  4. 编译生成可以被PythonC++调用的中间文件

A 添加依赖的功能包

ROS1中生成自定义的消息类型需要使用两个功能包:

  • message_generation
  • message_runtime

第一个功能包是用于生成静态的消息文件,第二个功能包是用于运行自定义消息

我们除了在创建功能包的时候可以指定当前功能包依赖的功能包外,还可以直接编辑Packages.xml文件和CMakeLists.txt来说明当前编译当前功能包所依赖的功能包。

1. Package.xml

关于Package.xml中的内容,我们在前面ROS1文件系统那一节已经有介绍了,这里再重复讲讲。Package.xml文件中,<build_depend><build_export_depend><exec_depend>这三个标签联合在一起定义了当前功能包依赖的功能包

  <build_depend>roscpp</build_depend>
  <build_depend>rospy</build_depend>
  <build_depend>std_msgs</build_depend>
  <build_export_depend>roscpp</build_export_depend>
  <build_export_depend>rospy</build_export_depend>
  <build_export_depend>std_msgs</build_export_depend>
  <exec_depend>roscpp</exec_depend>
  <exec_depend>rospy</exec_depend>
  <exec_depend>std_msgs</exec_depend>

package.xml中定义当前功能包依赖的功能包

我们可以手动添加<build_depend><build_export_depend><exec_depend>标签从而实现在创建功能包后添加依赖的功能包。

需要注意的是这三个标签分别表示当前功能包不同的依赖类型

  • <build_depend>表示的功能包是在编译阶段依赖的功能包

  • <build_export_depend>表示的功能包将被间接依赖

    关于间接依赖

    假设我们正在开发A功能包,然后A依赖了B功能包,并且A提供了头文件来让其他的功能包导入,则此时如果我们新开发一个C功能包的话,那么C功能包其实就是间接依赖了B功能包,此时我们就需要在A功能包的package.xml中声明B功能包为<build_export_depend>

  • <exec_depend>表示功能包是在运行阶段依赖的的功能包

这里我们直接添加依赖即可

cd 工作目录/src/topic_communication/
vim package.xml

而前面说过,message_generation用于编译时生成静态消息文件,而message_runtime用于运行时运行自定义消息

所以在Package.xml中添加下面的内容:

<build_depend>message_generation</build_depend>
<exec_depend>message_runtime</exec_depend>

package.xml中添加依赖

注意,有些早期的教程中添加运行时依赖的标签是<run_depend>,事实上这个标签已经被<exec_depend>所取代了,现在是非法的标签了

2. CMakeLists.txt

编辑完了Package.xml之后,第二个要编辑的就是CMakeLists.txt

ROS1在编译功能包的时候,会自动为当前的功能包生成.cmake文件,然后将该.cmake文件exportcmake全局的搜索库路径中,这样我们就可以在其他功能包中导入当前功能包的头文件了。

当然,这样讲太抽象了,我们下面就举topic_communication功能包的例子。在topic_communication/CMakeLists.txt中,有find_package函数:

topic_communication功能包下的CMakeLists.txt文件

find_packageCMake自带的函数,会在指定的路径下搜索库。


文章作者: Jack Wang
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Jack Wang !
  目录