ROS1教程 7:ROS1通信机制——话题通信
话题通信是ROS1
中使用频率最高的一种通信模式,话题通信是基于发布订阅模式的,也即:一个节点发布消息,另一个节点订阅该消息。
话题通信的应用场景也极其广泛,比如下面一个常见场景:
机器人在执行导航功能,使用的传感器是激光雷达,机器人会采集激光雷达感知到的信息并计算,然后生成运动控制信息驱动机器人底盘运动。
在上述场景中,就不止一次使用到了话题通信:
- 以激光雷达信息的采集处理为例,在
ROS1
中有一个节点需要时时的发布当前雷达采集到的数据,导航模块中也有节点会订阅并解析雷达数据 - 再以运动消息的发布为例,导航模块会根据传感器采集的数据时时的计算出运动控制信息并发布给底盘,底盘也可以有一个节点订阅运动信息并最终转换成控制电机的脉冲信号
以此类推,像雷达、摄像头、GPS……等等一系列传感器数据的采集,都是使用了话题通信。换言之,话题通信适用于不断更新的数据传输相关的应用场景。
1. 话题通信介绍
1.1 基础知识
话题通信是以发布订阅的方式实现不同节点之间数据交互的通信模式。话题通信用于不断更新
的、少逻辑处理
的数据传输场景。
例如摄像头不断通过发布图像到/img
话题下,而诸如人脸识别
、物体检测
这样的复杂、低频处理,最好还是以服务的形式来实现。
后面,我们首先将实现最基本的发布订阅模型:发布方以固定频率发送一段文本,订阅方接收文本并输出
然后再实现对自定义消息的发布与订阅
1.2 理论模型
话题通信实现模型是比较复杂的,该模型如下图所示,该模型中涉及到三个角色:
ROS1 Master
(管理者)Talker
(发布者)Listener
(订阅者)
ROS1 Master
负责保管Talker
和Listener
注册的信息,并匹配话题相同的Talker
与Listener
,帮助Talker
与Listener
建立连接,连接建立后,Talker
可以发布消息,且发布的消息会被Listener
订阅。
我们按照流程来走一遍话题通信的整个流程:
- 首先是
Talker
启动,即数据发布者启动,此时Talker
会被注册在ROS1 Master
中。Talker
启动后,会通过RPC
在ROS1 Master
中注册自身信息,其中包含所发布消息的话题名称。ROS1 Master
会将节点的注册信息加入到注册表中。 - 然后是
Listener
启动,即数据接受者启动,此时Listener
会被注册在ROS1 Master
中。Listener
根据接收到的RPC
地址,通过RPC
向Talker
发送连接请求,传输订阅的话题名称、消息类型以及通信协议(TCP/UDP
)。 - 接下来,
ROS1 Master
会根据注册表中的信息匹配Talker
和Listener
。在匹配成功后,ROS1 Master
会通过RPC
向Listener
发送Talker
的RPC
地址信息。 - 接着
Listener
向Talker
发送请求。当Listener
接收到的RPC
地址后,会通过RPC
向Talker
发送连接请求,传输订阅的话题名称、消息类型以及通信协议(TCP/UDP)。 - 而后
Talker
确认请求。Talker
接收到Listener
的请求后,也是通过RPC
向Listener
确认连接信息,并发送自身的TCP
地址信息。 Talker
和Listener
建立连接。Listener
根据步骤5返回的消息使用TCP
与Talker
建立网络连接。Talker
向Listener
发送消息。连接建立后,Talker
开始向Listener
发布消息。
注意:
- 上述实现流程中,前五步使用的
RPC
协议,最后两步使用的是TCP
协议Talker
与Listener
的启动无先后顺序要求,但是ROS1 Master
必须先启动Talker
与Listener
都可以有多个Talker
与Listener
连接建立后,不再需要ROS1 Master
。也即,即便关闭ROS1 Master
,Talker
与Listern
依旧可以正常通信
最重要的是,这7个步骤ROS1
已经为我们封装好了,我们不需要手动注册、建立链接,对我们来说就是几个API
就可以解决的事情
因此,我们在话题通信的时候,需要注意的是:
- 话题的设置
- 发布者的实现
- 订阅者的实现
- 消息载体的实现
2. 话题通信C++实现
我们上面介绍了话题通信理论知识,我们接下来用C++来实现话题通信
2.1 需求
编写发布订阅实现,要求发布方以10HZ(每秒10次)的频率发布文本消息,订阅方订阅消息并将消息内容打印输出。
2.2 分析
在ROS1
话题通信模型的七步实现中,ROS1 Master
不需要实现,而连接的建立也已经被封装了,因此,我们自己写代码的时候,需要关注的关键点有三个:
- 发布方
- 接收方
- 数据(此处为普通文本)
我们接下来就将围绕这三个点进行实现
2.3 实现流程
按照上面的分析,我们按照下面的流程进行实现:
- 编写发布方实现
- 编写订阅方实现
- 编辑配置文件
- 编译并执行
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 echo /message
和rosrun topic_communication publisher
不要中止
此时,我们就会发现,有一个新节点订阅了我们的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++
代码的前面设置一下编码格式,注意下面放在最前面的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 echo
和rqt_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::Subscriber
。nh.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[])
,即argv
被const
修饰,表示不可修改,但是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
不需要实现,而连接的建立也已经被封装了,因此,我们自己写代码的时候,需要关注的关键点有三个:
- 发布方
- 接收方
- 数据(此处为普通文本)
我们接下来就将围绕这三个点进行实现
3.3 实现流程
Python
和C++
的实现流程有些不同,我们接下来按照下面的流程来实现Python
话题通信:
- 编写发布方实现
- 编写订阅方实现
- 为
Python
文件添加可执行权限 - 编辑配置文件
- 编译并执行
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
脚本
最后在命令行中运行:
roscore
rosrun topic_communication pub.py # 新开中断运行
需要注意的是,如果命令行中报错command not found
或者cannot read XXX
,这种是因为rosrun
使用的解释器不对,此时我们需要指定一下需要使用的解释器
此时在命令行运行下属的命令,然后将获取到的当前环境的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_graph
和rostopic 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
的开发语言包括Java
,C++
,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
的第一个强大之处
5. 自定义消息类型介绍
ROS1
通信协议中,最重要的有如下三个元素:
- 发布者
- 订阅者
- 消息
我们前面讲了如何实现发布者和订阅者,但是却没有将如何自定义消息类型,下面我们就将讲解如何自定义消息类型。
数据载体是话题通信的一个重要组成部分,ROS1
中在std_msgs
功能包封装了一些原生的数据类型,例如:
String
Int32
Int64
Char
Bool
Empty
但是,这些数据作为基础类型,一般只包含一个data
字段,即只有一个数据域。结构的单一意味着功能上的局限性,当传输一些复杂的数据,比如: 激光雷达的数据,需要角度、距离和反射强度的三合一信息,即很多时候我们需要传输一个结构,这个时候依旧使用std_msgs
就显得力不从心了。
事实上std_msgs
作为基础数据类型,由于描述性较差,因此往往在复杂的消息类型中都用作为自定义的复杂消息类型中的元素,从而解决诸如激光雷达这样的我问题的。
5.1 std_msgs
介绍
std_msgs
实际上是一个专门定义了基础数据类型的功能包,其中有非常多的.h
和.py
文件可以让我们导入,而这些文件中定义了诸如String
、Int32
等等的实现。
而为什么我们在别的功能包中能够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/share/std_msgs
功能包,查看其中所有的.msg
文件
然后再查看我们前面一直在用的String.msg
文件
其中就定义了String
这个消息类型的数据域string.data
。
而除了string
这个数据域类型外,在std_msgs
的msg
文件夹下,还有其他的消息,其中包含了其他各种不同的数据域。而未来我们自定义的.msg
文件中,就得用到这些数据域来组合,最后得到我们需要的消息类型。
我们通过cat
来显示一下上面的ls
所显示的std_msgs
功能包中定义的其他消息类型其中包含的各种不同的字段。
总结一下,.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
介绍完了.msg
的理论知识之后,我们接下来来介绍如何自定义消息,即开始实现。
6. 自定义消息实现
我们接下来给定一个需求,然后来自定义消息类型
6.1. 需求
需求:创建自定义消息,该消息包含了一个人的基础信息:姓名、身高、年龄等。
流程:
- 添加依赖的功能包
- 按照固定格式创建
msg
文件 - 编辑配置文件
- 编译生成可以被
Python
或C++
调用的中间文件
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>
我们可以手动添加<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>
注意,有些早期的教程中添加运行时依赖的标签是<run_depend>
,事实上这个标签已经被<exec_depend>
所取代了,现在是非法的标签了
2. CMakeLists.txt
编辑完了Package.xml
之后,第二个要编辑的就是CMakeLists.txt
ROS1
在编译功能包的时候,会自动为当前的功能包生成.cmake
文件,然后将该.cmake
文件export
到cmake
全局的搜索库路径中,这样我们就可以在其他功能包中导入当前功能包的头文件了。
当然,这样讲太抽象了,我们下面就举topic_communication
功能包的例子。在topic_communication/CMakeLists.txt
中,有find_package
函数:
find_package
是CMake
自带的函数,会在指定的路径下搜索库。