一、ROS介绍

ROS 是机器人操作系统的简称(Robot Operating System),其具备操作系统的许多功能。但是在实际应用中,它需要建立在计算机操作系统之上,例如 Linux,因此有时候也称 ROS 为元操作系统。 ROS 的主要功能是提供用户、计算机操作系统以及外部设备间的通讯功能,这些外部设备包括传感器、摄像机,同时也包括机器人。与其它操作系统一样,ROS 的优点在于其硬件抽象能力,以及它所具备的在不需要用户详尽了解机器人各种细节的前提下对机器人进行控制的能力。

下面介绍 ROS 节点、话题与消息、节点管理器

1. ROS 节点(Node)

一般而言,节点就是执行某些动作的进程。ROS 节点本身实际上就是一个软件模块,除了具体的软件功能之外,还具有注册连接到 ROS 节点服务器,并和 ROS 网络中的其它节点通信的功能。 ROS 节点的设计理念是每一个节点都是独立的模块,相互之间通过 ROS 的通信能力实现交互。

对于节点而言,既可以独立地运行代码来完成其作业任务,也可以通过发送或接收消息来与其它节点进行通信。消息包含了数据、命令或者其它对于应用程序而言必要的信息。

2. ROS 话题(Topic)

对于节点而言,有些节点主要为其它节点提供信息,例如为节点提供摄像机图像数据。这样的节点发布信息,并由其它节点接收。这些发布的信息,在 ROS 中称为话题。话题定义了将由该话题发送的消息的类型。

传递数据的节点发布话题的名称以及将要发送的消息的类型。节点能够订阅话题,订阅了话题的节点将能够接收话题传递的消息。

3. ROS 消息(Message)

在 ROS 中,消息是由消息类型数据格式共同定义的。举个 C 语言中的例子:

1
2
3
4
struct People{
int age;
string name;
} people;

在上面的代码中,可以把 people 看作是消息对象。那么,int 和 string 则为消息类型,People 为数据格式。也就是说,在 C 语言中,srtuct 关键字定义了数据的类型和结构。在 ROS 中也有类似的方法定义消息类型,并且最后生成可供调用的头文件中也是以结构体的方式组织的。我们会在实战部分进行讲解演示。

4. 节点管理器(Master)

ROS 节点通常是小的,能够同时在多个系统上运行的独立程序。在 ROS 中,Master 为节点提供命名和注册服务。它能够跟踪话题的发布来源和订阅者。节点间的通信是通过 ROS 节点管理器建立的。

在 Linux 下,使用 roscore 命令可以启动 ROS Master。ROS Master 使得节点能够注册。因此,在运行 ROS 程序时,一定要先执行 roscore 命令。这一点在实战的讲解中还会反复强调。

二、ROS的标准文件结构

新建一个文件夹,在终端执行以下命令

1
2
3
4
5
mkdir src #新建源目录
cd src
catkin_init_workspace
cd .. #回到新建的文件夹
catkin_make

便可以看到文件夹下生成了 src build devel 三个文件夹,如下图所示

其中最顶层的catkin工作空间,它是整个ROS工程中层次最高的概念。这个也是管理和组织ROS工程项目的地方,编译和运行都在这里进行。
其下一层的一级目录有4个:

  • src:源空间
  • build:编译空间
  • devel:开发空间
  • *install:安装空间(该文件夹不是默认创建的,需要执行catkin_make install 才会被创建。不过一般来说不会使用到这个命令,因此后文也不讨论该文件夹)

1.src:源空间

存放功能包(package)。

创建功能包的命令如下

1
2
cd src #确保当前在src源空间当中
catkin_create_pkg package roscpp rospy std_msgs

上述最后一段命令的作用是创建名为package(第一个参数)的功能包,
并为其添加roscpp、rospy、std_msgs三个依赖功能包。这里是通过命令的方式直接添加,因此生成的配置文件CMakeLists.txt和package.xml也是已经配置好了的。如果后期需要添加新的依赖,则需要自己手动修改上述配置文件中的内容。

关于ros已有的常用功能包,我们会在后一章节进行介绍

功能包是ROS文件系统中组织程序文件的基本单元,也就是catkin编译的基本单元。一个 package 下必须包含 CMakeLists.txt 和 package.xml 两个文件:

  • CMakeLists.txt 文件中规定了功能包的编译规则,包括指定功能包名称,指定编译依赖项,指定要编译的源文件,指定要添加的消息格式文件/服务格式文件/动作格式文件,指定生成的消息/服务/动作,指定头文件搜索目录,指定链接库搜索目录,指定生成的静态链接库文件,指定需要链接的库文件,指定编译生成的可执行文件以及路径等等。
  • package.xml 文件定义了功能包的属性信息,包括包名,版本号,作者,编译依赖和运行依赖等。

另外,还有几个文件夹:

  • include 和 src 分别存放头文件(*.h)和源程序文件(*.c/*.cpp等)
  • scripts 存放脚本文件(比如Python文件 *.py,shell文件 *.sh)
  • launch 存放 launch文件(*.launch),用于批量运行多个可执行文件
  • config 存放配置文件(*.yaml等);
  • 此外,还包括消息(*.msg)、服务(*.srv)以及动作(*.action)。

如果有读者按照前面的命令创建了一个package包,则会发现文件夹下只有include和src两个子文件夹。其实问题不大,剩下的缺少了的文件夹根据需要自己手动创建即可。

2.build:编译空间

存放CMake和catkin的缓存信息、配置信息和其他中间文件。

若程序转移到了不同的电脑上,亦或是修改了最顶层文件夹的名字,则需要将build文件夹删除并重新执行catkin_make命令。此时,该命令会重新生成build文件夹并进行相应配置。

3.devel:开发空间

存放编译后生成的目标文件以及setup文件,包括头文件、动态&静态链接库、可执行文件等。

setup文件是用于向终端注册当前程序的,否则程序就无法在当前终端启动。这一点读者可以在实际开发中体会。

此外,用户自定义的消息类型在经过编译后也会在devel文件夹中生成相应的头文件,之后使用时必须引用此头文件。因此,一般自定义消息类型的工作是放在最开始的时候,并且在使用之前需要进行编译。可以在ROS实战中的用户自定义消息中体会。

三、ROS功能包、常用功能包介绍

ROS系统自带一些常用的功能包,可以在终端输入 rosrun 然后按 tab 查看所有自带的功能包。如下图:

整个ROS包括的包和层级如下:

此处介绍一些在实际开发中会使用到的ROS功能包。若后续有新的需求,会相应进行补充介绍。

1、roscpp

ROS的C++库,是目前最广泛应用的ROS客户端库,执行效率高

roscpp的主要部分和功能简介如下(加粗的是今后会使用到的部分)

roscpp的主要部分 功能
ros::init() 解析传入的ROS参数,创建node第一步需要用到的函数
ros::NodeHandle 和topic、service、param等交互的公共接口
ros::master 包含从master查询信息的函数
ros::this_node 包含查询这个进程(node)的函数
ros::service 包含查询服务的函数
ros::param 包含查询参数服务器的函数,而不需要用到NodeHandle
ros::names 包含处理ROS图资源名称的函数

总的来说,roscpp的功能分为以下几类:初始化与关闭、话题、服务、参数服务器、定时器、节点句柄、回调和自旋、日志、名称管理、时钟、异常

这些内容是ROS下用C++开发的基础,开发中多多少少都会用到。读者可以在实际项目中进行体会。

官方文档:http://docs.ros.org/en/api/roscpp/html/index.html

2、rospy

rospy是Python版本的ROS客户端库,提供了Python编程需要的接口。rospy包含的功能与roscpp相似,都有关于node、topic、service、param、time相关的操作。但同时rospy和roscpp也有一些区别:

  1. rospy没有一个NodeHandle,像创建publisher、subscriber等操作都被直接封装成了rospy中的函数或类,调用起来简单直观。
  2. rospy一些接口的命名和roscpp不一致,有些地方需要开发者注意,避免调用错误。

ROS Python 中常用的API如下,与 C++ 略有不同:

  • Node 相关
  • Publisher 相关
  • Subscriber 相关

(由于 ROS 官方的API文档404了,想进一步了解的读者可以在项目实战中体会,或是自己查阅相关资料进行了解)

这里放上编写该部分时的参考资料:https://zhuanlan.zhihu.com/p/449107620

3、std_msgs

std_msgs 包含了 ROS 中的中基本的消息类型,如果需要依靠话题-消息方式进行节点间通讯,则必须要添加 std_msgs 这个依赖包。

4、message_generation

用于生成消息类型的功能包,在创建自定义消息类型的项目中必须包括这个包。关于自定义消息类型,我们会在下一小节进行讲解。

5、sensor_msgs 重点是其中的image

这个是 ROS 中定义传感器消息类型的功能包,是 ROS 为机器人身上传感器所产生的数据提供的比较方便的程序接口。我们目前并没有做得这么深入,因此我们只需要了解其中的 image 类型即可。

简单来说,image 类型是 ROS 能够通过消息机制直接传递的图片类型。OpenCV 中的 cv::Mat 可以通过 cv_bridge 包,以 image_transport 为消息载体实现与 sensor_msg::image 之间的相互转换,从而实现图像的传输。cv_bridge 和 image_transport 两个功能包将在接下来进行讲解。

6、cv_bridge

cv_bridge 从字面上也很好理解:它是实现 OpenCV 数据类型和 ROS 消息类型之间转换的桥梁。

使用方法示例:

1
2
3
4
5
6
7
cv::Mat src; //假设此处src非空
sensor_msgs::ImagePtr msg;

//src -> msg
msg=cv_bridge::CvImage(std_msgs::Header(),"bgr8",src).toImageMsg();
//msg->src
src=cv_bridge::toCvShare(msg,"bgr8")->image;

上述代码中,“bgr8” 表示的是三个通道一次为 b,g,r 且每个通道占8位。这也是 OpenCV 中三通道图像最常用的颜色空间。如果要传输的是单通道的图像,字符串需改为 “mono8”。

在我们的实际项目中,相机采集的图像理论上是可以直接用 ROS 中的类型进行表示的。但是当前已有的相机驱动程序是几年前老学长写的,其中图像是用 OpenCV 中的 Mat 类进行表示的。 因此,在使用 ROS 时需要进行图像类型的转换,显然在执行过程中需要额外的开销。所以现有的相机驱动需要修改,改为使用 ROS 中的消息类型进行表示。这样的话,图像转换只需要在 DEBUG 模式下使用,将消息类型的图像转换为 cv::Mat 类型并在屏幕上显示出来。这样可以保证在实际执行过程中性能不受影响。

7、image_transport

image_transport 是 ROS 中用于图像消息传输的功能包。在创建对象时,需要使用已有的 ros::NodeHandle 对象进行初始化,之后的一系列使用方法跟 ros::NodeHandle 基本上一致。

使用方法示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void imageCallback(const sensor_msgs::ImageConstPtr& msg)
{
// ...
}
ros::NodeHandle nh;

//初始化对象
image_transport::ImageTransport it(nh);

//创建订阅者对象
image_transport::Subscriber sub = it.subscribe("topic_name", 1, imageCallback);

//创建发布者对象
//(消息类型为sensor_msgs::ImagePtr,这里隐形声明了)
image_transport::Publisher pub = it.advertise("topic_name", 1);

8、总结

  1. 通过上面的例子,我们会发现 ROS 中不同的功能包有着不同的命名空间,且命名空间的名称基本上与功能包的名称相同。对于 C++ 代码而言,可以使结构更加清晰,也方便初学者在阅读源码时了解功能包是怎么工作的、在哪里工作、不同功能包之间是怎么关联的。这一点在实战中会有所体会
  2. 在学习了 sensor_msg::image、cv_bridge、image_transport 三个功能包后,读者可以参考下图,了解这三个功能包是怎么合作实现 cv::Mat 图像类型的传输的。

cv::Mat 图像传递流程图

四、ROS中的标准消息类型和自定义消息类型

ROS使用了一种简化的消息类型描述语言来描述ROS节点发布的数据值。通过这样的描述语言,ROS能够使用多种编程语言生成不同类型消息的源代码。

ROS消息所使用的标准数据类型

基本类型 串行化 C++ Python2 / Python3
bool unsigned 8-bit int uint8_t bool
int8 signed 8-bit int int8_t int
uint8 unsigned 8-bit int uint8_t int
int16 signed 16-bit int int16_t int
uint16 unsigned 16-bit int uint16_t int
int32 signed 32-bit int int32_t int
uint32 unsigned 32-bit int uint32_t int
int64 signed 64-bit int int64_t long int
uint64 unsigned 64-bit int uint64_t long int
float32 32-bit IEEE float float float
float64 64-bit IEEE float double float
string ascii string std::string str bytes
time secs/nsecs unsigned 32-bit ints ros::Time rospy.Time
duration secs/nsecs signed 32-bit ints ros::Duration rospy.Duration

自定义消息类型

(注:此处仅讲解自定义消息类型的语法和理论,程序实现请参考ROS实战中的相关内容)

显然,ROS提供的标准消息类型很简单,并不能直接满足编程需要,因此在实际的消息传递过程中,使用的消息往往是用户自定义的消息类型。

在 ROS 中,用户可以通过选取若干标准消息类型进行组合(类似于结构体),同时可以指定偏移量(定长不定长均可)。

例如下面一个自定义消息类型的文件:

1
2
3
4
5
6
# MyMsg.msg

time stamp # 时间戳
uint32 data_len # 数据长度
uint8[] data # 数据,不定长
uint8[20] data_fixed # 定长数据,长度为20

该消息包括时间戳、不定长数据部分(数据长度和数据数组)和定长数据部分。

可以通过命令查看自定义的MyMsg消息类型,如下图:

关于自定义消息类型的声明和使用,我们会在ROS实战中进行介绍。

ROS 实战

“亲身下河知深浅、亲口尝梨知酸甜。” 在大致了解了 ROS 理论知识后,一定要动手实践,才知道自己是否掌握了所学的知识点。因此,在接下来的 ROS 实战中,每一个任务都需要先自己独立思考。如果确实没思路或者不会,可以参考提供的源代码,但在参考完之后仍需要自己再写一遍,去理解每一行代码实现了什么功能,为什么要这么实现。相信经过完整的实战练习,每个人都能掌握 ROS ,并将其应用到实际的视觉项目中。

零、先导知识

常用的 ROS 命令介绍

命令 使用示例 示例说明
roscore roscore 注册ROS Master节点
catkin_init_workspace catkin_init_workspace 初始化工作空间
(建立指向ROS内核的软连接)
catkin_create_pkg catkin_create_pkg pkg_name pkg1 pkg2 … 创建名为pkg_name 的功能包,并添加pkg1 pkg2作为依赖功能包
catkin_make catkin_make 配置+编译
该命令相当于执行cmake跟make。如果在功能包CMakeLists.txt内没有找到需要编译的文件,则会跳过该功能包的make步骤。
catkin_make --pkg pkg1 pkg2 … 只对指定的功能包进行编译
rosrun rosrun pkg exec <argv> … 运行pkg功能包中的exec程序。
<argv>是程序的外部参数,使用到的时候会进行讲解
rosnode list rosnode list 列出当前所有的活动节点
rosnode kill rosnode kill /node_name 杀死节点名称为node_name的节点

功能包中的 CMakeLists.txt 介绍

在用 ROS 命令创建完一个功能包后,会在功能包目录中生成一个 CMakeLists.txt 文件。这个文件内容很多,并且看起来比较复杂,初学者很容易被吓到。事实上,在这个 CMakeLists.txt 中几乎把所有可能使用到的 CMake 配置指令都写在里面并进行了注释,使用的好的话之后配置只需要取消注释并进行简单修改,十分方便。因此,帮助初学者了解这份 CMakeLists.txt 是非常有必要的。

除了这里的讲解,建议读者自行阅读 CMakeLists.txt 中的说明性的注释。这些注释是 ROS 开发人员写的,基本上是最准确的使用方法。以下讲解只是我个人的观点,不够准确,且难免会有错误。如果遇到问题,以文件中的注释为主要参考。

说明:1. 以下内容,带 * 的只需要了解,不带 * 的要求掌握。

2. 标题中的括号表示该部分对应在 CMakeLists.txt 中的行号。创建不同的功能包时,相同内容对应的行号可能不同。

3. 为了方便读者理解,这里放上此处参考的CMakeLists.txt

由于Gitee上没有显示行号,因此读者可以将以下内容拷贝到自己的编辑器中,或是直接将此文件下载到本地进行查看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
cmake_minimum_required(VERSION 3.0.2)
project(pkg)

## Compile as C++11, supported in ROS Kinetic and newer
# add_compile_options(-std=c++11)

## Find catkin macros and libraries
## if COMPONENTS list like find_package(catkin REQUIRED COMPONENTS xyz)
## is used, also find other catkin packages
find_package(catkin REQUIRED COMPONENTS
roscpp
rospy
std_msgs
)

## System dependencies are found with CMake's conventions
# find_package(Boost REQUIRED COMPONENTS system)


## Uncomment this if the package has a setup.py. This macro ensures
## modules and global scripts declared therein get installed
## See http://ros.org/doc/api/catkin/html/user_guide/setup_dot_py.html
# catkin_python_setup()

################################################
## Declare ROS messages, services and actions ##
################################################

## To declare and build messages, services or actions from within this
## package, follow these steps:
## * Let MSG_DEP_SET be the set of packages whose message types you use in
## your messages/services/actions (e.g. std_msgs, actionlib_msgs, ...).
## * In the file package.xml:
## * add a build_depend tag for "message_generation"
## * add a build_depend and a exec_depend tag for each package in MSG_DEP_SET
## * If MSG_DEP_SET isn't empty the following dependency has been pulled in
## but can be declared for certainty nonetheless:
## * add a exec_depend tag for "message_runtime"
## * In this file (CMakeLists.txt):
## * add "message_generation" and every package in MSG_DEP_SET to
## find_package(catkin REQUIRED COMPONENTS ...)
## * add "message_runtime" and every package in MSG_DEP_SET to
## catkin_package(CATKIN_DEPENDS ...)
## * uncomment the add_*_files sections below as needed
## and list every .msg/.srv/.action file to be processed
## * uncomment the generate_messages entry below
## * add every package in MSG_DEP_SET to generate_messages(DEPENDENCIES ...)

## Generate messages in the 'msg' folder
# add_message_files(
# FILES
# Message1.msg
# Message2.msg
# )

## Generate services in the 'srv' folder
# add_service_files(
# FILES
# Service1.srv
# Service2.srv
# )

## Generate actions in the 'action' folder
# add_action_files(
# FILES
# Action1.action
# Action2.action
# )

## Generate added messages and services with any dependencies listed here
# generate_messages(
# DEPENDENCIES
# std_msgs
# )

################################################
## Declare ROS dynamic reconfigure parameters ##
################################################

## To declare and build dynamic reconfigure parameters within this
## package, follow these steps:
## * In the file package.xml:
## * add a build_depend and a exec_depend tag for "dynamic_reconfigure"
## * In this file (CMakeLists.txt):
## * add "dynamic_reconfigure" to
## find_package(catkin REQUIRED COMPONENTS ...)
## * uncomment the "generate_dynamic_reconfigure_options" section below
## and list every .cfg file to be processed

## Generate dynamic reconfigure parameters in the 'cfg' folder
# generate_dynamic_reconfigure_options(
# cfg/DynReconf1.cfg
# cfg/DynReconf2.cfg
# )

###################################
## catkin specific configuration ##
###################################
## The catkin_package macro generates cmake config files for your package
## Declare things to be passed to dependent projects
## INCLUDE_DIRS: uncomment this if your package contains header files
## LIBRARIES: libraries you create in this project that dependent projects also need
## CATKIN_DEPENDS: catkin_packages dependent projects also need
## DEPENDS: system dependencies of this project that dependent projects also need
catkin_package(
# INCLUDE_DIRS include
# LIBRARIES pkg
# CATKIN_DEPENDS roscpp rospy std_msgs
# DEPENDS system_lib
)

###########
## Build ##
###########

## Specify additional locations of header files
## Your package locations should be listed before other locations
include_directories(
# include
${catkin_INCLUDE_DIRS}
)

## Declare a C++ library
# add_library(${PROJECT_NAME}
# src/${PROJECT_NAME}/pkg.cpp
# )

## Add cmake target dependencies of the library
## as an example, code may need to be generated before libraries
## either from message generation or dynamic reconfigure
# add_dependencies(${PROJECT_NAME} ${${PROJECT_NAME}_EXPORTED_TARGETS} ${catkin_EXPORTED_TARGETS})

## Declare a C++ executable
## With catkin_make all packages are built within a single CMake context
## The recommended prefix ensures that target names across packages don't collide
# add_executable(${PROJECT_NAME}_node src/pkg_node.cpp)

## Rename C++ executable without prefix
## The above recommended prefix causes long target names, the following renames the
## target back to the shorter version for ease of user use
## e.g. "rosrun someones_pkg node" instead of "rosrun someones_pkg someones_pkg_node"
# set_target_properties(${PROJECT_NAME}_node PROPERTIES OUTPUT_NAME node PREFIX "")

## Add cmake target dependencies of the executable
## same as for the library above
# add_dependencies(${PROJECT_NAME}_node ${${PROJECT_NAME}_EXPORTED_TARGETS} ${catkin_EXPORTED_TARGETS})

## Specify libraries to link a library or executable target against
# target_link_libraries(${PROJECT_NAME}_node
# ${catkin_LIBRARIES}
# )

#############
## Install ##
#############

# all install targets should use catkin DESTINATION variables
# See http://ros.org/doc/api/catkin/html/adv_user_guide/variables.html

## Mark executable scripts (Python etc.) for installation
## in contrast to setup.py, you can choose the destination
# catkin_install_python(PROGRAMS
# scripts/my_python_script
# DESTINATION ${CATKIN_PACKAGE_BIN_DESTINATION}
# )

## Mark executables for installation
## See http://docs.ros.org/melodic/api/catkin/html/howto/format1/building_executables.html
# install(TARGETS ${PROJECT_NAME}_node
# RUNTIME DESTINATION ${CATKIN_PACKAGE_BIN_DESTINATION}
# )

## Mark libraries for installation
## See http://docs.ros.org/melodic/api/catkin/html/howto/format1/building_libraries.html
# install(TARGETS ${PROJECT_NAME}
# ARCHIVE DESTINATION ${CATKIN_PACKAGE_LIB_DESTINATION}
# LIBRARY DESTINATION ${CATKIN_PACKAGE_LIB_DESTINATION}
# RUNTIME DESTINATION ${CATKIN_GLOBAL_BIN_DESTINATION}
# )

## Mark cpp header files for installation
# install(DIRECTORY include/${PROJECT_NAME}/
# DESTINATION ${CATKIN_PACKAGE_INCLUDE_DESTINATION}
# FILES_MATCHING PATTERN "*.h"
# PATTERN ".svn" EXCLUDE
# )

## Mark other files for installation (e.g. launch and bag files, etc.)
# install(FILES
# # myfile1
# # myfile2
# DESTINATION ${CATKIN_PACKAGE_SHARE_DESTINATION}
# )

#############
## Testing ##
#############

## Add gtest based cpp test target and link libraries
# catkin_add_gtest(${PROJECT_NAME}-test test/test_pkg.cpp)
# if(TARGET ${PROJECT_NAME}-test)
# target_link_libraries(${PROJECT_NAME}-test ${PROJECT_NAME})
# endif()

## Add folders to be run by python nosetests
# catkin_add_nosetests(test)
*1. 指定编译选项 (5)

这个编译选项也就是使用 gcc 或 g++ 编译器编译时的选项,一般来说是指定采用哪个标准的 C++ 进行编译。对于 ROS 的程序而言,最低需要使用 C++11 标准进行编译。(这是因为 ROS 中大部分的数据结构都是基于 C++11 中的智能指针实现的)

2. 寻找 catkin 中的依赖包 (10-15)

用于寻找 ROS 中指定的功能包,会自动添加 catkin_create_pkg 命令后面跟着的功能包。如果之后需要依赖其它的功能包,在这里手动添加即可。

另外,如果需要添加第三方库作为依赖,只需在17行后面再添加 find_package 即可。例如:

1
find_package(OpenCV REQUIRED)

(当然在哪里添加都可以,这么做是为了不破坏原有的结构性)

3. 添加消息文件(50-54)

这一部分是用于添加消息文件的。使用时,取消50、51、54行注释,并添加指定的消息文件。这条命令会从 msg 文件夹中寻找对应名称的消息文件。如果没有找到,则执行编译的时候会报错。

4.生成消息类型 (71-74)

用于生成可供调用的消息头文件,使用时取消71-74行的注释即可。

3、4 点一般是配合使用,用于自定义消息类型

*5.catkin 配置(105-110)

这里一般不需要动。如果遇到编译链接问题的话,也许需要稍微改动一下。

*6. 包含目录 (118-121)

这里一般也不需要动,在生成时已经自动配置好了

*7. 生成链接库 (124-126)

用于编译生成动态链接库。不过一般来说我们的程序最终目的是生成可执行文件,所以这里也不用改动。(但并不确定未来是否会用到,也许可能大概没准说不定会用到)

8.生成可执行文件(136)

用于生成可执行的项目,第一个参数是生成可执行文件的名称,第二个参数是源代码文件(包括路径)。如果有多个源代码文件,需要将这些文件名(包括路径)存放在一个变量中,并以这个变量作为参数。(参考 2022 赛季源码中的 CMakeLists.txt)

只有 CMakeLists.txt 中具备了这个编译选项,才会对指定的源代码文件进行编译,并生成可被执行的文件。

*9.重命名可执行文件 (142)

用于对生成的可执行文件重命名。可能是为了增加代码的可读性才推出这一条配置选项,一般来说在生成可执行文件时就可以直接指定可执行文件的名字了。

10.添加可执行文件的链接库 (149-151)

用于添加编译时的链接库。只要是生成可执行文件,都必须将这三行取消注释。如果依赖了第三方库,例如 OpenCV,则需要加上 ${OpenCV_LIBS}

(其它的第三方链接库可能不是 LIBS 这种形式,需要查阅官方文档随机应变)

*11.install 和 test (154- )

之后的这些是安装和测试的部分,对于我们来说暂时用不上,可忽略。

一. ROS安装

1
2
3
4
5
6
7
8
# Ubuntu 2004中
sudo sh -c 'echo "deb http://packages.ros.org/ros/ubuntu $(lsb_release -sc) main" > /etc/apt/sources.list.d/ros-latest.list'
sudo apt-key adv --keyserver 'hkp://keyserver.ubuntu.com:80' --recv-key C1CF6E31E6BADE8868B172B4F42ED6FBAB17C654
sudo apt update
sudo apt install ros-noetic-desktop-full
echo "source /opt/ros/noetic/setup.bash" >> ~/.bashrc
source ~/.bashrc
sudo apt install python3-rosinstall python3-rosinstall-generator python3-wstool build-essential

测试ROS是否安装成功(需打开三个终端依次输入以下命令)

1
2
3
roscore  #启动ROS核心,否则无法运行ROS相关的程序
rosrun turtlesim turtlesim_node #生成海龟
rosrun turtlesim turtle_teleop_key #激活按键控制海龟的功能
  • 若能够在终端通过方向键控制海龟,说明ROS安装成功

安装时遇到的问题汇总

问题一:

二 编写第一个ROS程序

2.1 创建一个工作区

工作区可以作为一个独立的项目进行编译,存放ROS程序的源文件、编译文件和执行文件。建立工作区的方法如下:

1
2
3
mkdir -p ~/catkin_ws/src
cd ~/catkin_ws/src
catkin_init_workspace

虽然这时候工作区是空的,但是我们依然可以进行编译:

1
2
cd ~/catkin_ws/
catkin_make

这时候,会在当前文件夹下生成devel,build这两个子文件夹,在devel文件夹下能看到几个setup.*sh文件。

接下来把工作区在bash中注册
注意!!!这个操作只对当前终端有效。

之后会涉及到多个终端执行两个程序,需要在新建的终端里重新执行以下命令

另外,在新终端编译前也要进行此操作

1
source devel/setup.bash

2.2 创建一个ROS工程包

在一个工作区内,可能会包含多个ROS工程包。而最基本ROS工程包中会包括CmakeLists.txt和Package.xml这两个文件,其中Package.xml中主要包含本项目信息和各种依赖(depends),而CmakeLists.txt中包含了如何编译和安装代码的信息。

首先切换到工作区:

1
cd ~/catkin_ws/src

现在可以使用catkin_create_pkg命令去创建一个叫beginner_tutorials的包,这个包依靠std_msgs、roscpp、rospy。

1
catkin_create_pkg beginner_tutorials std_msgs rospy roscpp

接下来在工作区编译这个工程包。

1
2
cd ~/catkin_ws
catkin_make

2.3.一个简单的发布、订阅程序

2.3.1 写一个发布(Publisher)节点

节点(node)是连接到ROS网络中可执行的基本单元。我们在这创建一个发布者—“talker”节点,这个节点持续对外发布消息。

首先我们要把目录切换到我们的beginner_tutorials工程包中

1
cd ~/catkin_ws/src/beginner_tutorials

因为我们已经编译过这个工程包了,所以会在beginner_tutorials文件夹下看到CmakeList.txt、package.xml文件和include、src这两个目录。接下来进入src子目录

1
cd src

在src目录中创建一个talker.cpp文件,里面的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
#include "ros/ros.h"
#include "std_msgs/String.h"

#include <sstream>
int main(int argc, char **argv)
{
/**
* The ros::init() function needs to see argc and argv so that it can perform
* any ROS arguments and name remapping that were provided at the command line. For programmatic
* remappings you can use a different version of init() which takes remappings
* directly, but for most command-line programs, passing argc and argv is the easiest
* way to do it. The third argument to init() is the name of the node.
*
* You must call one of the versions of ros::init() before using any other
* part of the ROS system.
*/
ros::init(argc, argv, "talker");

/**
* NodeHandle is the main access point to communications with the ROS system.
* The first NodeHandle constructed will fully initialize this node, and the last
* NodeHandle destructed will close down the node.
*/
ros::NodeHandle n;

/**
* The advertise() function is how you tell ROS that you want to
* publish on a given topic name. This invokes a call to the ROS
* master node, which keeps a registry of who is publishing and who
* is subscribing. After this advertise() call is made, the master
* node will notify anyone who is trying to subscribe to this topic name,
* and they will in turn negotiate a peer-to-peer connection with this
* node. advertise() returns a Publisher object which allows you to
* publish messages on that topic through a call to publish(). Once
* all copies of the returned Publisher object are destroyed, the topic
* will be automatically unadvertised.
*
* The second parameter to advertise() is the size of the message queue
* used for publishing messages. If messages are published more quickly
* than we can send them, the number here specifies how many messages to
* buffer up before throwing some away.
*/
ros::Publisher chatter_pub = n.advertise<std_msgs::String>("chatter", 1000);

ros::Rate loop_rate(10);

/**
* A count of how many messages we have sent. This is used to create
* a unique string for each message.
*/
int count = 0;
while (ros::ok())
{
/**
* This is a message object. You stuff it with data, and then publish it.
*/
std_msgs::String msg;

std::stringstream ss;
ss << "hello world " << count;
msg.data = ss.str();

ROS_INFO("%s", msg.data.c_str());

/**
* The publish() function is how you send messages. The parameter
* is the message object. The type of this object must agree with the type
* given as a template parameter to the advertise<>() call, as was done
* in the constructor above.
*/
chatter_pub.publish(msg);

ros::spinOnce();

loop_rate.sleep();
++count;
}


return 0;
}
2.3.2 写一个订阅(监听)节点

还是在src目录下,创建一个listener.cpp文件。内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include "ros/ros.h"
#include "std_msgs/String.h"

/**
* This tutorial demonstrates simple receipt of messages over the ROS system.
*/
void chatterCallback(const std_msgs::String::ConstPtr& msg)
{
ROS_INFO("I heard: [%s]", msg->data.c_str());
}

int main(int argc, char **argv)
{
/**
* The ros::init() function needs to see argc and argv so that it can perform
* any ROS arguments and name remapping that were provided at the command line. For programmatic
* remappings you can use a different version of init() which takes remappings
* directly, but for most command-line programs, passing argc and argv is the easiest
* way to do it. The third argument to init() is the name of the node.
*
* You must call one of the versions of ros::init() before using any other
* part of the ROS system.
*/
ros::init(argc, argv, "listener");

/**
* NodeHandle is the main access point to communications with the ROS system.
* The first NodeHandle constructed will fully initialize this node, and the last
* NodeHandle destructed will close down the node.
*/
ros::NodeHandle n;

/**
* The subscribe() call is how you tell ROS that you want to receive messages
* on a given topic. This invokes a call to the ROS
* master node, which keeps a registry of who is publishing and who
* is subscribing. Messages are passed to a callback function, here
* called chatterCallback. subscribe() returns a Subscriber object that you
* must hold on to until you want to unsubscribe. When all copies of the Subscriber
* object go out of scope, this callback will automatically be unsubscribed from
* this topic.
*
* The second parameter to the subscribe() function is the size of the message
* queue. If messages are arriving faster than they are being processed, this
* is the number of messages that will be buffered up before beginning to throw
* away the oldest ones.
*/
ros::Subscriber sub = n.subscribe("chatter", 1000, chatterCallback);

/**
* ros::spin() will enter a loop, pumping callbacks. With this version, all
* callbacks will be called from within this thread (the main one). ros::spin()
* will exit when Ctrl-C is pressed, or the node is shutdown by the master.
*/
ros::spin();

return 0;
}
2.3.3 编译创建的节点

在编译我们创建的节点之前,我们还需要编辑Cmakelist.txt文件(注意:是beginner_tutorials项目包下的CMakelist文件),告诉编辑器我们需要编辑什么文件,需要什么依赖。

在文件末尾添加如下语句:

1
2
3
4
5
6
7
8
9
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(listener ${PROJECT_NAME}_generate_messages_cpp)

将目录切换到工作区目录,并执行catkin_make运行命令:

1
2
cd ~/catkin_ws
catkin_make

在这里,可能会出现如下错误:

2022.8.16更新:实际上这个错误应该不影响,只需要再执行一次 catkin_make 即可

CMake Error at turotials/CMakeLists.txt:213 (add_dependencies):
The dependency target “turotials_generate_messages_cpp” of target “talker”
does not exist.

CMake Error at turotials/CMakeLists.txt:217 (add_dependencies):
The dependency target “turotials_generate_messages_cpp” of target
“listener” does not exist.

此时需要修改CMakeLists.txt 105-110 行代码,取消两个地方的注释,如下:

1
2
3
4
5
6
catkin_package(
INCLUDE_DIRS include
# LIBRARIES turotials
CATKIN_DEPENDS roscpp rospy std_msgs
# DEPENDS system_lib
)

并在上面添加的add_dependencies后面再加上${catkin_EXPORTED_TARGETS},如下

1
2
add_dependencies(talker ${PROJECT_NAME}_tutorials_generate_messages_cpp ${catkin_EXPORTED_TARGETS})
add_dependencies(listener ${PROJECT_NAME}_tutorials_generate_messages_cpp ${catkin_EXPORTED_TARGETS})

之后编译时,需要改用以下命令进行编译

1
catkin_make --pkg beginner_tutorials #这里的beginner_tutorials是工程的名字

执行程序

首先,我们得要启动ROS核心程序roscore(在单独的终端进行)

1
roscore

在使用我们的程序之前,需要先把程序注册

1
2
cd ~/catkin_ws
source ./devel/setup.bash

运行talker节点:

1
rosrun beginner_tutorials talker 

这时候会看到如下信息:

1
2
3
4
5
6
[INFO] [WallTime: 1314931831.774057] hello world 1314931831.77
[INFO] [WallTime: 1314931832.775497] hello world 1314931832.77
[INFO] [WallTime: 1314931833.778937] hello world 1314931833.78
[INFO] [WallTime: 1314931834.782059] hello world 1314931834.78
[INFO] [WallTime: 1314931835.784853] hello world 1314931835.78
[INFO] [WallTime: 1314931836.788106] hello world 1314931836.79

这就表示发布(Publisher)节点已经正确的运行了。

接下来在新的终端运行listener节点(注意需要再source一次):

1
rosrun beginner_tutorials listener

这时候会看到如下信息:

1
2
3
4
5
6
7
[INFO] [WallTime: 1314931969.258941] /listener_17657_1314931968795I heard hello world 1314931969.26
[INFO] [WallTime: 1314931970.262246] /listener_17657_1314931968795I heard hello world 1314931970.26
[INFO] [WallTime: 1314931971.266348] /listener_17657_1314931968795I heard hello world 1314931971.26
[INFO] [WallTime: 1314931972.270429] /listener_17657_1314931968795I heard hello world 1314931972.27
[INFO] [WallTime: 1314931973.274382] /listener_17657_1314931968795I heard hello world 1314931973.27
[INFO] [WallTime: 1314931974.277694] /listener_17657_1314931968795I heard hello world 1314931974.28
[INFO] [WallTime: 1314931975.283708] /listener_17657_1314931968795I heard hello world 1314931975.28

这说明订阅节点(listener)已经成功的接收到了发布节点(talker)发布的信息。至此,整个程序结束!

三 上述示例程序中的源码解析

编程绝不可浅尝辄止。在参考教程复现出功能之后,也一定要自己独立思考,把整个程序的执行流程吃透了,才算把教程的东西变成自己的。接下来我会对上述的程序进行解析,虽然不难,但是其中蕴含的东西,足够让初学者花几小时时间好好体会并消化了。希望在之后的实战程序中,读者也能够按照这种方式独立思考,相信一定会有收获的。

3.1 ros::init() 初始化函数

该函数的作用是初始化ros节点,也可以理解为建立新节点

传入参数为

  • argc:remapping参数的个数
  • argv:remapping参数的列表
  • name:节点名,必须是一个基本名称,不能包含命名空间
  • options:[可选]用于启动节点的选项(ros::init_options中的一组位标志)

最常用的方式

1
ros::init(argc,argv,"node_name");//node_name为用户自定义的节点名

3.2 ros::NodeHandle类 (直译为节点句柄)

NodeHandle is the main access point to communications with the ROS system.
The first NodeHandle constructed will fully initialize this node, and the last NodeHandle destructed will close down the node.

这段话翻译过来,意思大概是:NodeHandle类是ROS系统中主要的通信手段。在一段代码(准确点来说是一个节点)中可能会声明多个NodeHandle类,在构造第一个NodeHandle对象时会完全初始化这个节点,在析构最后一个NodeHandle对象时会关闭这个节点。

我们可以先看看NodeHandle类的主要成员函数及其作用

函数名 作用 用法 用法解释
advertise 发布话题 obj.advertise<msg_type> (“topic_name”,queue_size) · 该函数的返回值为ros::Publisher,即发布者对象
· 用法中msg_type为消息类型、topic_name为话题名
· queue_size为缓冲区大小,即最多可以容纳的未被接收的消息数
· 具体用法参考上述talker.cpp源码以及注释
subscribe 订阅话题 (也可以把订阅理解为监听) subscribe (topic_name,queue_size,*func) · 该函数的返回值为ros::Subscriber,即订阅者对象
· topic_name,queue_size作用同上
· *func 为回调函数[注],在每次成功接收后都会调用一次回调函数,用于对接收到的消息的处理,也可以进行其它操作
· 回调函数要求返回值为void,入口参数为消息类型
· 具体用法参考上述listener.cpp源码以及注释
advertiseServer 发布服务 · 返回值为ros::ServiceServer
serviceClient 客户端调用服务 · 返回值为ros::ServiceClient
createTimer 创建定时器,按一定周期执行指定的函数 createTimer (period,*fun) period为周期,*fun为回调函数指针

[注]:回调函数 (Call-back Function) 指的是该函数本身能够被其它函数调用,也就是说该函数的函数名可以作为另一个函数的入口参数。读者可以阅读 listener.cpp 代码体会一下

其中,客户端和服务的关系就类似与发布者和订阅者的关系,只不过前者靠话题(topic)通讯,后者靠服务(service)通讯(个人理解,毕竟我也暂时还没学到这一块)。感兴趣的话也可以自行了解,日后有时间我也会补充完整。

关于createTimer,目前还没使用过,也留给日后进行补充吧。

3.3 ros::Publisher::publish() 发布者发布消息

The publish() function is how you send messages. The parameter
is the message object. The type of this object must agree with the type
given as a template parameter to the advertise<>() call, as was done
in the constructor above.

翻译一下:publish这个函数是发送消息的手段,入口参数是消息对象,并且这个对象类型要和在之前完成构造时,调用advertise<>()函数时给定的模板类型一致。

这里的advertise即之前讲到了NodeHandle中的成员函数。

注:发布者发布消息时调用这个函数,而订阅者并没有专门用于接收消息的函数。这是因为订阅者对象在构造完成之后,便可以通过ros::spin()(这个函数后面会讲到)持续对该话题进行监听。至于如何判断发布者是否发布了消息,我们之前讲过。实在想不起来就重新看一遍NodeHandler类的讲解吧

入口参数

  • msg_type:消息类型。这些类型可以是ROS消息所使用的标准数据类型,也可以是用户自定义的数据类型。
    (关于自定义数据类型消息后面会讲到,并且在实际项目中也更常用到)

3.4 ros::Rate

ros::Rate loop(50)规定了循环的频率50Hz,这个频率是指运行上一次loop.sleep()到下一次loop.sleep()之间保持的时间,通常情况下,代码运行速度比设定的频率要快,所以如果运行到下一次loop.sleep()后未达到0.02s(1/50Hz),则会开始休眠,等到0.02s后再执行下一句程序。

这个很好理解,看代码 talker.cpp 体会。

3.5 ros::spin() 和 ros::spinOnce()

ros::spin() will enter a loop, pumping callbacks. With this version, all
callbacks will be called from within this thread (the main one). ros::spin()
will exit when Ctrl-C is pressed, or the node is shutdown by the master.

这个用文字解释起来比较复杂。简单来说,spin()主要负责监听节点中的回调函数和对应的消息。当有消息到来时,就会执行对应的回调函数里面的内容。(底层原理我也没搞懂,不过确实是实现这么一个功能)

最后一段话翻译过来是说ros::spin()会在按下Ctrl-C时退出,也就是说任何程序在执行到ros::spin()时都会阻塞住,并且持续进行监听。因此,之后的代码都不会被执行。

ros::spinOnce()实现的功能和ros::spin()是相同的,不同点在于ros::spinOnce()是非阻塞的,执行一次之后就开始执行下一句了,一般会写在 while(ros::ok()) 中。

可以对比 listener.cpp 和 talker.cpp 中的代码进行体会。

注:虽然 talker.cpp 里面的 ros::spinOnce() 没起作用,但随手写一个也算是好的习惯。因为在之后的实际编程中,可能一个节点既是发布者,也是订阅者,此时该函数就会起到作用了。另外,这个函数也可以表示一段循环体的结束,可以提高代码的可读性。

3.6 ROS_INFO

这是一个ROS中的宏,其调用规则与C语言的printf()函数基本上一样,区别在于输出的时候ROS_INFO会输出更多的信息。这点可在代码中体会。

3.7 整体运行流程图

经过前边的对各个函数的解析,读者可以参考以下程序流程图,加深对程序的理解,并体会ROS的工作原理。

实战1:自定义消息类型基础

任务:自定义一个消息类型,并基于该消息类型在不同节点间实现消息传递。

在开始编程之前,我们需要解决以下几个问题:

  • 自定义消息类型需要ROS提供哪些功能包
  • 我们应如何声明自定义消息类型才能让ROS系统识别出来
  • 我们的程序应如何调用自定义消息类型

1.自定义消息类型需要ROS提供哪些功能包

  • std_msgs:显然,自定义消息类型是基于标准消息类型的,因此这个包必须是要有的
  • message_generation:用于生成消息类型的包
  • roscpp、rospy:使得生成的消息类型能够被C++和Python调用

2、我们应如何声明自定义消息类型才能让ROS系统识别出来

声明自定义消息类型需要符合ROS自定义消息的格式规范和语法规范,只有这样才能被ROS所识别

ROS中的自定义消息类型文件需放在功能包的msg文件夹下,并以.msg作为文件后缀。之后在CMakeLists.txt中进行一些配置,即可被ROS识别。

3、我们的程序应如何调用自定义消息类型

在进行完第2步的配置后,回到最高级目录下编译,编译成功后便会在devel文件夹下生成供C++和Python调用的头文件

之后在功能包中添加这个自定义消息依赖(名称为该自定义消息类型的功能包名称),便可在该功能包中使用相应的消息类型了。

此时一定注意,若在程序中要使用该消息类型,一定要确保包含的是devel文件夹下相应的头文件。包含其它的头文件在调用时会报错。

在该头文件中,对于C++调用而言,该消息类型的命名空间名为其功能包的名称,类型名为.msg文件名称。这点在接下来的实战中会有所体会。
(注:Python调用不是本节讨论的内容,我们会在后续的实战项目进行了解)

编程实战

1.创建工作空间
1
2
3
4
5
mkdir sample_1
cd sample_1
mkdir src
cd src
catkin_init_workspace
2. 创建自定义数据类型的功能包
1
2
3
# 确保当前在sample_1/src下
# 此处直接通过命令添加依赖,为了之后修改CMakeLists.txt和package.xml更方便
catkin_create_pkg fyt_msg_t roscpp rospy message_generation std_msgs
3. (选)回到主目录,编译

此处是为了创建build和devel两个文件夹,让项目的文件结构好看一些。当然,如果你没有强迫症,可以无视这一步,在需要编译的时候编译即可

1
2
cd ..  # 回到sample_1目录下
catkin_make
4. 创建msg文件夹和文件

在 fyt_msg_t 文件夹下创建 msg 文件夹,并在其中创建DworryMsg.msg文件,进行以下编辑并保存

1
2
string name
int8 age
5. 配置CMakeLists.txt,生成相应头文件

打开 fyt_msg_t 文件夹下的CMakeLists.txt,进行以下修改。

  • 将第51-55行内容改成以下形式
1
2
3
4
5
add_message_files(
FILES
DworryMsg.msg
# Message2.msg
)
  • 将72-75行取消注释
1
2
3
4
generate_messages(
DEPENDENCIES
std_msgs
)

编译,成功后可在devel/include中看到对应的C++头文件

6.创建talker和listener功能包
1
2
3
cd src #进入src文件夹
catkin_create_pkg talker roscpp std_msgs fyt_msg_t
catkin_create_pkg listener roscpp std_msgs fyt_msg_t

分别进入两个功能包的src文件夹下,创建talker.cpp和listener.cpp,进行以下编辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//talker.cpp
#include "fyt_msg_t/DworryMsg.h"
#include "ros/ros.h"

int main(int argc, char **argv){
ros::init(argc,argv,"talker_node");
ros::NodeHandle n;
ros::Publisher pub=n.advertise<fyt_msg_t::DworryMsg>("sample_1",10);
ros::Rate loop_rate(20);
fyt_msg_t::DworryMsg msg;
while(ros::ok()){
msg.name="Dworry";
msg.age=3;
pub.publish(msg);
ROS_INFO("I'm %s, and I'm %d years old",msg.name.c_str(),msg.age);
ros::spinOnce();
loop_rate.sleep();
}
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//listener.cpp
#include "fyt_msg_t/DworryMsg.h"
#include <ros/ros.h>

void callback(const fyt_msg_t::DworryMsgConstPtr &msg){
ROS_INFO("I known, your name is %s, you are %d years old",msg->name.c_str(),msg->age);
}
int main(int argc, char **argv){
ros::init(argc,argv,"listener_node");
ros::NodeHandle n;
ros::Subscriber sub=n.subscribe("sample_1",5,callback);
ros::spin();
return 0; //Aha, this will never happen
}
7. 配置、编译、运行
  • 在talker功能包中的CMakeLists.txt末尾添加以下内容
1
2
3
4
add_executable(talker src/talker.cpp)
target_link_libraries(talker ${catkin_LIBRARIES})
# add_dependencies(talker ${PROJECT_NAME}_generate_messages_cpp)
# 注释掉add_dependencies就不会报does not exist这个错误,但暂时没看出有什么影响
  • 在listener功能包中的CMakeLists.txt末尾添加以下内容
1
2
3
add_executable(listener src/listener.cpp)
target_link_libraries(listener ${catkin_LIBRARIES})
# add_dependencies(listener ${PROJECT_NAME}_generate_messages_cpp)
  • 编译
1
catkin_make
  • 运行
1
2
source devel/setup.bash
rosrun talker talker

会看到如下输出

1
2
3
4
[ INFO] [1660900453.169758246]: I'm Dworry, and I'm 3 years old
[ INFO] [1660900453.219729039]: I'm Dworry, and I'm 3 years old
[ INFO] [1660900453.269727186]: I'm Dworry, and I'm 3 years old
[ INFO] [1660900453.319718240]: I'm Dworry, and I'm 3 years old

同时,在新终端运行listener程序

1
2
source devel/setup.bash
rosrun listener listener

会看到如下输出:

1
2
3
4
[ INFO] [1660900453.170097656]: I known, your name is Dworry, you are 3 years old
[ INFO] [1660900453.220118809]: I known, your name is Dworry, you are 3 years old
[ INFO] [1660900453.270109795]: I known, your name is Dworry, you are 3 years old
[ INFO] [1660900453.320089636]: I known, your name is Dworry, you are 3 years old

至此,整个程序结束。

程序源代码放在sample_1文件夹中,可供参考学习

注 (2022.8.24补充):在删除了 build 和 devel 之后重新编译时,需要先对自定义消息的功能包进行单独编译,之后在进行整体编译。否则有可能由于可执行程序功能包在自定义消息功能包之前编译,导致找不到头文件而出现编译错误。

对于这个程序,编译步骤如下:

1
2
catkin_make --pkg fyt_msg
catkin_make

2022-11-12 upd 上述命令存在不完美的地方,即使指定了功能包,但还是会扫描所有的功能包。所以可能会存在编译失败的情况。

可替换成以下命令:

1
2
catkin_make -DCATKIN_WHITELIST_PACKAGES="fyt_msg" # 先单独编译这个包
catkin_make -DCATKIN_WHITELIST_PACKAGES="" # 再编译剩下的所有包

之后只要是涉及到自定义消息类型的程序,在移植到其它电脑上编译部署时,也是如此操作。

实战2:两个窗口间实现半双工通信

任务:通过 ROS 的消息机制,创建两个进程,使这两个进程间可以实现半双工通信。

半双工通信:双方的信息传递可以是双向的,但同一时刻只能有一方发送信息,另一方只能接收。例如对讲机。

问题分析

实现半双工通信,可以有以下两种实现方法。

方法一:建立两个话题,进程1在第一个话题上发消息,接收第二个话题的消息;进程2在第二个话题上发消息,接收第一个话题的消息。如下图:

这种方法是最直接最简单的,如果进一步加上多线程,可以实现全双工通信。但是对于半双工通信来说,这显然不是最佳的选择。因为当进程多起来后,话题的数量也要相应地增加,每个进程需要监听的话题也要更多。也就是说每计划增加一个进程,都需要对已有的代码进行修改,并且代码之间是不可复用的,会非常的麻烦。

方法二、只建立一个话题,所有进程通信都使用这个话题。

这种方式就可以大大减少了编程的复杂程度。但是采用这种方法仍需解决两个问题:如何避免收到自己发出的消息?如何确保每一时刻只有一个进程发布消息?

第一个问题很好解决,我们只需要在消息中加入发布者信息,然后在接收时判断这个消息是不是自己发出去的即可。此时需要用到自定义消息类型。

第二个问题,可以采用信号量机制来解决(操作系统原理中会学习)。但是在进程间使用信号量又十分困难,解决方法仍然是话题-消息机制。我们可以再声明一个话题,用来表示当前信道是否有进程占用。如下图:

每个进程在通信前,都会检查一下当前是否有进程正在通信。如果有的话,则本次通信只接收不发送;如果没有的话,则先发布正在通信的状态消息,之后发布正式通信的消息。通信结束后,会再次发布通信结束的状态消息

观察示意图会发现,这种方法跟第一种方法好像没有什么区别。这是因为我们只是举进程数为2的例子。当进程数量大于2个的时候,第一种方法就需要创建更多的话题,而第二种方法则可以继续用两个话题胜任这项任务。

通过对这个实例的理解,读者也可以发现,消息机制主要解决的是进程之间的通信问题。这也正是我们这个赛季使用 ROS 的初衷。

编程实战

首先还是创建工作区,创建功能包,配置等。跟之前是一样的,这里就不再赘述了。

另外,我们仍然需要自定义消息类型,创建 msg 文件夹,编辑 ChatMsg.msg 文件如下

1
2
string publisher_name #发布者节点名
string message_str #消息内容

之后在src文件夹下创建 .cpp 文件,开始写代码。需要注意并了解以下几点:

  1. 由于我们的目的是设计可复用的程序,因此我们需要利用外部参数对节点进行命名(因为 ROS 不允许存在相同名称的节点),具体做法如下:
1
2
3
4
5
6
...
int main(int argc,char **argv){
if(argc==1) this_node_name="default";
else this_node_name=argv[1];
ros::init(argc,argv,argv[1]);
...

上述代码中,argc 表示外部参数的数量 argv 表示这些参数的值。通过以下这个例子来说明这两个变量怎么决定的:

假设有个可执行文件的名称为 exe

执行以下命令

1
./exe

则其中的参数值为:

1
2
argc=1
argv[0]= "exe"

若执行的是

1
./exe aha

则其中参数的值为:

1
2
3
argc=2
argv[0]="exe"
argv[1]="aha"

在这个程序中,我们采用这种方式进行外部参数传递。

  1. 与Windows不同, Linux下没有提供现成的键盘敲击检测的函数,因此我们采用 OpenCV 中的 cv::waitKey() 代替,作用是告诉程序我要发送消息了。

参考代码为

1
2
3
4
5
cv::Mat src=cv::Mat::zeros(cv::Size(300,100),CV_8UC3);
cv::imshow(argv[1],src);
if(cv::waitKey(1)){
...
}

在我自己写的示例程序中,需要在图像上按Enter键,之后就可以在终端中输入想要的信息了。

程序源代码放在 sample_2 文件夹中,可供参考学习。 记得在运行时一定要加上外部参数,用于给当前进程节点命名

运行效果(同时运行4个):

实战3:C++ Python 混合编程

任务:使用 Python 和 C++ 编程,实现实战1中的功能。

子任务1:使用C++编写发布者节点,Python编写订阅者节点

子任务2:使用Python编写发布者节点,C++编写订阅者节点

问题分析

1. C++ 和 Python 在 ROS 的结构中有什么不同

在 ROS 标准文件结构中, C++ 文件需要放在 src 文件夹下,而 Python 文件需要放在 scripts 文件夹下。(可参考理论部分讲过的 ROS 标准文件结构)

2. 基于 Python 实现的功能包需要怎么进行配置

跟 C++ 不同的是,Python 功能包在建立时完全不需要进行配置,只需要在 .py 文件中使用 import 关键字导入相应的功能包即可。

3. ROS 中如何执行 Python 程序

Python 属于脚本语言(解释型语言),本身就可以被执行。因此只需要给一个可执行权限,就可以执行了。但是在 ROS 中,脚本默认是用 shell 解释器执行,因此需要在 Python 代码的第一行指定使用 Python 解释器执行。

在第一行添加以下内容,指定 Python 解释器:

1
#!/usr/bin/python3

假设一个功能包的结构如下:

1
2
3
4
5
6
├── listener_py
│ ├── CMakeLists.txt
│ ├── package.xml
│ ├── scripts
│ │ └── listener.py
│ └── src

执行其中的 listener.py 的命令是

1
2
3
4
5
# 当前在工作空间目录下

sudo chmod +x src/listener_py/scripts/listener.py #给权限
source devel/setup.bash #注册
rosrun listener_py listener.py #执行
4.Python中的API如何使用

使用前需要 import rospy。具体的API及使用方法可以参考理论部分中关于 rospy 功能包的讲解,其中很多与 C++ 不同,且普遍比 C++ 来得简洁。

另外,在这个例子中,还需要注意以下几点:

  1. Python 的入口函数是需要自己指定的,指定的方式为
1
2
if __name__=='__main__':
function() # 以函数function作为程序入口
  1. C++ 中的 ROS_INFO() 在 Python 中变成了 rospy.loginfo(),用法也发生了变化:只能以字符串类型作为参数。如果需要输出数字类型,可以使用 str() 函数将其转化为字符串类型。如:
1
rospy.loginfo("balabala"+str(12345)+"balabala")
  1. Python 是弱类型语言,且没有指针的概念(对于程序员是不可见的),因此在设计回调函数时非常简洁。这一点可以在编程中体会。不过也正是因为简洁,导致采用自定义数据类型时,编辑器不会进行补全,因此需要程序员自己将成员变量熟记于心。
  2. 同样由于 Python 是弱类型语言,自定义的数据类型的声明和 C++ 也有所不同。具体如下:
1
2
3
msg=MsgType() #相当于把 msg 变量声明为 MsgType 类型
msg.variable1=...
msg.variable2=...
  1. 在调用发布和订阅函数时,Python 是直接将消息类型作为函数的参数(参考后面的代码)。这样做的可以带来很多便利,我们会在下一个实战例子中进行讲解
  2. 对于Python,ROS 不提供 ros::spinOnce,只提供 ros::spin。因此在编写发布者线程时,需要为 spin 单独开一个线程, 用于处理可能出现的回调函数。(我也不知道为什么不提供 spinOnce …)

这里使用 Python 中的 threading 库实现(应该是最简单的实现方法了)

1
2
3
4
5
6
def ros_spin():
print("start spin thread")
rospy.spin()
def main():
add_thread=threading.Thread(target=ros_spin)
add_thread.start()

对于不熟悉 Python 的读者来说,可以自行了解一下 Python 的语法。一般来说,只要学懂了 C++,学习 Python 就是“降维打击”,只需一个小时就能精通 Python 。

编程实战

这里可以直接把实战1中的项目拿过来用,在此基础上进行修改。
(为了与实战1区别,这里将话题名改为统一 sample_3)

  1. 在 listener 功能包下创建 scripts 文件夹,并新建 listener.py 文件,添加以下内容:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#! /usr/bin/python3

import rospy
from fyt_msg_t.msg import DworryMsg

def callback(msg):
rospy.loginfo("I known, you are "+msg.name+" and you are "+str(msg.age)+" years old.")

def listener():
rospy.init_node("listener_node")

sub=rospy.Subscriber("sample_3",DworryMsg,callback)
rospy.spin()

# 程序入口
if __name__=='__main__':
listener()
  1. 同理在 talker 功能包下创建 scripts 文件夹,并新建 talker.py 文件,添加以下内容:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#! /usr/bin/python3

import threading
import rospy
from fyt_msg_t.msg import DworryMsg

def ros_spin():
print("start spin thread")
rospy.spin()

def talker():
rospy.init_node("talker_node")

# 为 spin 创建一个新的线程
add_thread=threading.Thread(target=ros_spin)
add_thread.start()

pub=rospy.Publisher("sample_3",DworryMsg,queue_size=3)
rate=rospy.Rate(10)

#指定数据类型
msg=DworryMsg()

while not rospy.is_shutdown():
msg.name="Dworry"
msg.age=3

pub.publish(msg)
rospy.loginfo("I'm "+msg.name+" and I'm "+str(msg.age)+" years old.")
rate.sleep()

#程序入口
if __name__=='__main__':
talker()

  1. 运行

C++ 发布, Python 订阅

终端1(发布):

1
2
3
4
# 确保自己在最高级工作目录下,之后的终端也是同理
catkin_make
source devel/setup.bash
rosrun talker talker

终端2(接收):

1
2
3
source devel/setup.bash
sudo chmod +x src/listener/scripts/listener.py
rosrun listener listener.py

C++ 订阅,Python 发布

终端1(发布):

1
2
3
source devel/setup.bash
sudo chmod +x src/talker/scripts/talker.py
rosrun talker talker.py

终端2(接收):

1
2
3
catkin_make
source devel/setup.bash
rosrun listener listener

同理也可以进行 Python 发布和 Python 订阅,运行结果跟实战1中的一致。

通过这个例子,读者可以体会到 C++ 和 Python 混合编程的魅力,本质上还是利用消息机制实现进程间的通讯。在实际的视觉项目中,Python 可以兼容大多数的网络模型,但执行速度慢;C++ 执行速度快,但能够调用的模型少之又少,导致好的模型无法调用而不禁扼腕叹息。采用C++ 和 Python 的混合编程,能够取各自之所长,避各自之所短,使得我们之后的代码既能使用好的模型,也能具备好的性能。这也是使用 ROS 的目的之所在。

程序源代码放在 sample3 文件夹中,可供参考学习。

实战4:基于消息机制的图像传输

任务:编写两个进程,第一个用于读取一段视频流,并将每一帧图像通过话题进行发送;第二个进程用于接收图像,并将其显示出来。

进一步,读视频、传送图像的进程使用 C++ 编写;接收图像的进程采用 Python 编写。这样更加接近我们实际的使用情况。

问题分析

图像传输我们在 ROS 理论的功能包讲解中讲到过,需要使用到 sensor_msgs、image_transport、cv_bridge 三个功能包。传递原理如下(回顾一下,也是在 ROS 理论中提到过的):

在实战这里,我们可以这样理解:图像传输是以 cv_bridge 为格式转换工具、以 sensor_msgs::Image 为载体、以 image_transport 为通信信道实现的。同样,我们这里需要解决几个问题:

1. 这几个功能包怎么使用

以下以 C++ 为主进行讲解。Python 中的使用有所不同,但比 C++ 更简单。有些有所出入的地方我也会简单讲解一下。

cv_bridge 在理论部分有所提及:

1
2
3
4
5
6
7
cv::Mat src; //假设此处src非空
sensor_msgs::ImagePtr msg;

//src -> msg
msg=cv_bridge::CvImage(std_msgs::Header(),"bgr8",src).toImageMsg();
//msg->src
src=cv_bridge::toCvShare(msg,"bgr8")->image;

该功能包在 Python 中的使用有所不同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 导入功能包中的子功能包
from sensor_msgs.msg import Image
from cv_bridge import CvBridge
import cv2

src=cv2.Mat()
msg=Image()

# 需要先声明一个对象,转换时需要调用该对象的成员函数
bridge=CvBridge()

# msg -> src
src=bridge.imgmsg_to_cv2(img_msg=msg,desired_encoding="bgr8")

# src -> msg
msg=bridge.cv2_to_imgmsg(img, "bgr8")

image_transport 也是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void imageCallback(const sensor_msgs::ImageConstPtr& msg)
{
// ...
}
ros::NodeHandle nh;

//初始化对象
image_transport::ImageTransport it(nh);

//创建订阅者对象
image_transport::Subscriber sub = it.subscribe("topic_name", 1, imageCallback);

//创建发布者对象
//(消息类型为sensor_msgs::ImagePtr,这里隐形声明了)
image_transport::Publisher pub = it.advertise("topic_name", 1);

Python 中的使用,比 C++ 方便多了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import rospy
from sensor_msgs.msg import Image
from cv_bridge import CvBridge

# "name=" 等是用于指定当前传入的参数对应于函数入口的变量名
## 如果没有指定则默认从前往后依次传递
# 由此可以看到 Python 编程的灵活性还是很高的


# 订阅
sub = rospy.Subscriber(name="sample_4",data_class=Image,queue_size=1,callback=image_callback)

# 发布
pub = rospy.Publisher("sample_4", Image, queue_size=1)
msg=Image()
pub.publish(msg)

sensor_msgs 功能包是用于声明变量的,如

1
sensor_msgs::ImagePtr msg;

在 Python 中,由于没有指针概念,直接使用 Image 即可。

编程实战

相信经过前面几次实战,读者能够熟练掌握建立工作区和创建功能包的技能,因此此处便不再对创建功能包的步骤进行过多讲解。同时,也请读者们自行思考在这个项目中需要使用的功能包有哪些,并自行进行配置和编程。

程序源代码放在 sample_4 文件夹中,可供参考学习。

读者可以进一步将发送图像的进程也用 Python 实现。

实战5:基于消息机制传递 OpenCV 类型的数据

任务:在一个进程中自定义一个旋转矩形 (cv::RotatedRect 类) 和一个圆,并通过消息机制传递给另一个进程,将相应的图形绘制出来。

两个进程均使用 C++ 编写

问题分析

在 ROS 的标准消息类型中,显然不会包括 OpenCV 类型。所以,我们又一次需要自定义消息类型。

我在去年下半年培训 C++ 的时候留过这么一道作业题:如何用结构体定义矩形类旋转矩形类。在当时给出的方案是用中心点坐标、长宽和旋转角度来表示一个旋转矩形。因此在这里,我们也可以采用相同的方案。

同样的,对于圆的类型就更简单了。我们这里采用圆心坐标和半径两个参数来表示一个圆。

至此,这个程序最关键的部分——消息类型的定义问题得到了解决,之后就是一些 OpenCV 的应用和编程了,对于使用过的读者来说不难。

编程实战

同样地,这里只大致介绍上面提到的消息类型的创建,并简要做一些补充。

创建 fyt_msg 功能包,在其中创建 msg 文件夹,并创建 CvCircle.msg 文件,编辑以下内容:

1
2
3
int32 center_x
int32 center_y
int32 radius

创建 CvRotatedRect.msg 文件,编辑以下内容:

1
2
3
4
5
int32 center_x
int32 center_y
int32 width
int32 height
int32 angle

配置完 CMakeLists.txt 之后编译,即可生成对应的消息类型供 C++ 和 Python 调用。

一些补充

由于 OpenCV 并没有为 Python 提供 RotatedRect 等与图形相关的数据类型。参考 cv2.minAreaRect 函数的返回值(这个函数在 C++ 中返回值是 cv::RotatedRect 类),可以用二维数组表示旋转矩形类。如下:

1
2
3
4
5
6
7
8
9
#定义一个矩形
rect=[[center_x,center_y],[width,height],angle]

# 展开解释:
rect[0][0]=center_x #矩形中心的x坐标
rect[0][1]=center_y #矩形中心的y坐标
rect[1][0]=width #矩形的宽
rect[1][1]=height #矩形的高
rect[2][0]=angle #矩形的旋转角度(0-90取值)

这个在之后的实际项目中会使用到类似的方法,读者可以细品一下这种方式。对于这个项目,采用这种方式没有学习意义,因此两个程序均采用 C++ 进行编写。(另外 OpenCV 中没有定义圆类,因此在 C++ 中仍然需要读者自己定义。)

另外,对 cv::RotatedRect 类型初始化时,可以采用以下方式

1
2
//初始化旋转矩形,中心点为(250,250),长宽比为50*70,旋转角度为35度
cv::RotatedRect rect(cv::Point(250,250),cv::Size(50,70),35);

接下来就可以自行完成这个项目了。


程序源代码放在 sample_5 文件夹中,可供参考学习。

实战6:基于消息机制传递多组 OpenCV 类型的数据

“不知不觉间,你已经完成了前面的五项实战任务。现在这个任务是最后一项任务,用于检测你是否真正学会了 ROS 的消息机制。在这个实战任务中,问题分析和编程实战均由你自己独立完成。另外,这项任务也会直接在未来的视觉项目中得以应用,务必重视和认真对待。”

——编者按

任务:在一个进程中生成多组(不定长)旋转矩形类,通过消息机制发送给另一个进程,并在该进程中绘制出来。

程序源代码放在 sample_6 文件夹中,可供学习参考。