ROS是一个适用于机器人编程的框架,这个框架把原本松散的零部件耦合在了一起,为他们提供通信架构,相当于应用程序之间的沟通桥梁。

ROS采用了分布式的框架,通过点对点的设计让机器人的进程可以分别运行。

分布式:将不同功能数据放到不同模块,将相同数据放到不同服务器,他们之间依靠通信网络沟通。

1. ROS文件系统

1.1 Catkin编译

Catkin是基于CMake的编译构建系统,具有以下特点:

  • Catkin沿用了包管理的传统像find_package()基础结构
  • 扩展了CMake,例如:软件包编译后无需安装就可使用;自动生成 find_package()代码,pkg-config文件;解决了多个软件包构建顺序问题。

一个Catkin的软件包(package)必须要包括两个文件:

  • package.xml:包括了package的描述信息
  • CMakeLists.txt:构建package所需的CMake文件

Catkin工作流程如下:

  1. 首先在工作空间catkin_ws/src/下递归的查找其中每一个ROS的package。
  2. package中会有package.xmlCMakeLists.txt文件,Catkin(CMake)编译系统 据CMakeLists.txt文件,从而生成makefiles(放在catkin_ws/build/)。
  3. 然后make刚刚生成的makefiles等文件,编译链接生成可执行文件(放在catkin_ws/devel)。

可以看到,Catkin就是将cmakemake指令做了一个封装从而完成整个编译过程的工具。

实际的使用过程如下:

cd    ~/catkin_ws    #回到工作空间,catkin_make必须在工作空间下执行
sudo catkin_make                #开始编译 
source    ~/catkin_ws/devel/setup.bash    #刷新坏

注意:catkin编译之前必须要回到工作空间目录(work station),在其他路径下编译不会成功;如果有新的目标文件产生,那么需要刷新环境,是的系统1.2 Package软件包够遭到刚才编译生成的ROS可执行文件。

package是ROS的基本编译单元,任何ROS程序只有组织成package才能编译,所以package也是ROS源代码存放的地方。一个package可以编译出来多个目标文件(ROS可执行程序、动态静态库、头文件等等)。

一个package下常见的文件、路径有:

其中定义package的是CMakeLists.txt和package.xml,这两个文件是package中必不可少的。catkin编译系统在编译前,首先就要解析这两个文件,这两个文件就定义了一个package。

创建package需要在工作空间目录catkin_ws/src,使用catkin_create_pkg命令,用法是:

$ catkin_create_pkg    package    depends

其中package是包名,depends是依赖的包名,可以依赖多个软件包。

例如,新建一个package叫做test_pkg,依赖roscpp、rospy、std_msgs(常用依赖)。

$ catkin_create_pkg    test_pkg    roscpp    rospy    std_msgs

这样就会在当前路径下新建test_pkg软件包,包括:

catkin_create_pkg帮你完成了软件包的初始化,填充好了CMakeLists.txtpackage.xml,并且将依赖项填进了这两个文件中。需要注意的是,需要刷新文件才能找到。

1.3 CMakeList写法

思路是:这个package的依赖是什么?要生成哪些目标?如何编译?

以turtlesim小海龟这个pacakge为例,可roscdtuetlesim包下查看,在 turtlesim/CMakeLists.txt

find_package(catkin REQUIRED COMPONENTS ...)

项目依赖的其他软件包,都会自动成为catkin的组件(components)(就CMake而言)。因此可以将这些依赖包指定为catkin的组件,而不必再使用find_package,这样将会变得简单。

include_directories(include ${catkin_INCLUDE_DIRS} ${Boost_INCLUDE_DIRS})

头文件地址,配合后面的link链接过程,参数由上一步find_package生成(省略了Boost的find过程)

add_message_files(DIRECTORY msg FILES Color.msg Pose.msg)

在被ROS软件包编译和使用之前,ROS中的消息(.msg)、服务(.srv)和操作(.action)文件需要特殊的预处理器编译步骤。这些宏的要点是生成编程语言特定的文件,以便可以在编程语言中使用消息、服务和操作。编译系统将使用所有可用的生成器(例如gencpp、genpy、genlisp)生成绑定。

提供了三个宏来分别处理消息,服务和操作:

  • add_message_files
  • add_service_files
  • add_action_files

这些宏后面必须调用一个调用生成的宏generate_messages()(不是message专有,通用,只写一次)

示例的命令,是从目标文件的msg子文件下面,添加文件名(FILES) Color.msgPose.msg的消息。服务也是同理。

catkin_package(CATKIN_DEPENDS geometry_msgs message_runtime std_msgs std_srvs)

catkin_package() 是catkin支持的 CMake 宏指令。用来向编译系统指明 catkin-specific 的信息,而编译系统来生成 pkg-config和CMake files。必须放在消息、服务和操作之后,链接执行之前

该函数有5个参数,这里只用了CATKIN_DEPENDS这一个,在之前的find_package中就已经找到了

add_executable(turtlesim_node ${turtlesim_node_SRCS} ${turtlesim_node_MOCS})

作用是添加一个可执行文件构建目标,可执行文件名字叫做turtlesim_node,源代码由set(turtlesim_node_SRCS ...)设置;mocs是QT相关qt5_wrap_cpp(turtlesim_node_MOCS ${turtlesim_node_HDRS})

后面的target_link_libraries则是将node和widgets与之前find的catkin和boost相链接;然后再为node添加依赖turtlesim_gencpp

2. ROS通信架构

ROS的通信方式有以下四种:

  • Topic 主题
  • Service 服务
  • Parameter Service 参数服务器
  • Actionlib 动作库

2.1 Node & Master

在Ros中最小的进程单元是节点(Node),一个软件包里可以有多个可执行文件,可执行文件在运行之后就成了一个进程(process),这个进程在ROS中就叫做节点。从程序的角度来说,node是一个可执行文件;从功能角度来说,一个node负责某一个单独的功能

Master是节点管理器,由于实际情况中node数量众多,需要一个管理器在整个网络通信架构里管理各种node。node首先在master处进行注册,之后master会将该node纳入整个ROS程序中。 node之间的通信也是先由master进行“牵线”,才能两两的进行点对点通信。当ROS程序启动 时,第一步首先启动master,由节点管理器处理依次启动node。

当我们要启动ROS时,输入:

$ roscore

此时会启动三个功能:ROS masterrosoutparameter server。这其中rosout负责日志输出的一个节点,其作用是告知用户当前系统的状态,包括输出系统的error、warning等 ,并且将log记录于日志文件中。parameter server是参数服务器,它并不是一个node, 而是存储参数配置的一个服务器。

启动master以后,节点管理器就开始按照系统的安排协调进行启动具体的节点,具体启动node的语句是:

$    rosrun    pkg_name    node_name

rosrun将会寻找pkg_name下的名为node_name的可执行程序。

rosnode的常用命令如下:

2.2 launch文件

通常运行一个机器人系统我们需要启动多个node,为了方便我们用launch的方式来统一启动,命令是:

$    roslaunch    pkg_name    file_name.launch

roslaunch命令首先会自动进行检测系统的roscore有没有运行,也即是确认节点管理器是否在运行状态中,如果master没有启动,那么roslaunch就会首先启动master,然后再按照launch的规则执行。

launch文件里已经配置好了启动的规则。所以roslaunch就像是一个启动工 具,减少我们在终端中一条条输入指令的麻烦。

2.3 Topic

对于实时性、周期性的消息,使用topic来传输是最佳的选择。topic是一种点对点的单向通信方式,这里的“点”指的是node,也就是说node之 间可以通过topic方式来传递信息。

topic的步骤是:

  1. publisher节点和subscriber节点到节点管理器进行注册
  2. publisher发布topic
  3. subscriber在master的指挥下订阅该topic

Subscriber接收消息会进行处理,一般这个过程叫做回调(Callback)。所谓回调就是提前定义好了一个处理函数(写在代码中),当有消息来就会触发这个处理函数,函数会对消息进行处理。

topic通信的特点是:

  1. 异步通信。发送时调用publish()方法,发送完成立即返回,不用等待反 馈。
  2. subscriber通过回调函数的方式来处理消息
  3. topic可以同时有多个subscribers,也可以同时有多个publishers

Msg

topic有很严格的格式要求,比如摄像头拍摄的rgb图像topic,就必然要遵循ROS中 义好的rgb图像格式,这种数据格式就是Message。Message按照定义解释就是topic内容的数据类型,也称之为topic的格式标准,这里和我们平常用到的Massage直观概念有所不同。

基本的msg包括bool、int8、int16、int32等等常用的数据类型,但实际情况中,我们需要重新封装,比如image类型:

std_msg/Header    header                
    uint32                seq                
    time                stamp                
    string                frame_id 
uint32                height 
uint32                width 
string                encoding 
uint8                is_bigendian 
uint32                step 
uint8[]                data

这种结构类似于C语言结构体,我们可以将msg进一步理解为一个,我们每次发布的内容可以理解为实例化的对象

ROS中往往针对不同的传感器、导航系统封装好了许多msg。

2.4 Service

道topic是ROS中的一种单向的异步通信方式。然而有些时候单向的通信满足不了通信要求,比如当一些节点只是临时而 非周期性的需要某些数据,如果用topic通信方式时就会消耗大量不必要的系统资源,造成系统的低效率高功耗。

这种情况下,就需要有另外一种请求-查询式的通信模型。这节我们来介绍ROS通信中的另一 种通信方式——service(服务)。

步骤是:

  1. 请求方(Client)就会发送一个request,阻塞等待。
  2. server处理,反馈回一个reply。

NodeB是server(应答方),提供了一个服务的接口,叫做/Service,通过这个接口和Clinet通信。

Service是同步通信方式,所谓同步就是说,此时NodeA发布请求后会在原地等待reply,直到 NodeB处理完了请求并且完成了reply,NodeA才会继续执行。这样避免了频繁的消息传递。

Srv

类似msg文件,srv文件是用来描述service数据类型的, service通信的数据格式定义在 *.srv

举个例子msgs_demo/srv/DetectHuman.srv

bool    start_detect 
---
my_pkg/HumanPose[]    pose_data

该服务例子取自OpenNI的人体检测ROS软件包。它是用来查 询当前深度摄像头中的人体姿态和关节数的。第一行是请求格式,中间用—-隔开,第三行是应答格式。在本例中,请求为是否开始检测,应答为一个数组,数组的每个元素为某个人的姿态。而对于人的姿态HumanPose,其实是一个msg,如下。所以srv可以嵌套msg在其中,但它不能嵌套srv。

msgs_demo/msg/HumanPose.msg

std_msgs/Header    header 
string    uuid 
int32    number_of_joints 
my_pkg/JointPose[]    joint_data

2.5 Parameter server

除了前文介绍的主题和服务这两种通信方式,另一种特殊的通信方式是参数服务器。于参数服务器是节点存储参数的地方、用于配置参数,全局共享参数。参数服务器使用互联网传输,在节点管理器中运行,实现整个通信过程。参数服务器维护着一个数据字典,字典里存储着各种参数和配置。

每一个key不重复,且每一个key对应着一个value,这就是字典的映射关系。,我们往往将一些不常用到的参数和配置放入参数服务器里的字典里,这样对这些数据进行读写都将方便高效。

2.6 Action

Action是对service的一个改进,适合于长时间通信。假如利用service通信方式,那么publisher会很长时间接受不到反馈的reply,会致使通信受阻。actionlib通信过程可以随时被查看过程进度,也可以终止请求,使得 在一些特别的机制中拥有很高的效率。

通信双方在ROS Action Protocol下进行交流通信是通过接口来实现,类似于TCP下的socket套接字接口。

如上图,客户端会向服务器发送目标指令和取消动作指令,而服务器则可以给客户端发送 实时的状态信息,结果信息,反馈信息等等,从而完成了service没法做到的部分。

动作的内容格式应包含三个部分:目标、结果、反馈

  • 目标:机器人执行一个动作,应该有明确的移动目标信息,包括一些参数的设定,方向、角度、速 度等等。从而使机器人完成动作任务。
  • 结果:当运动最终完成时,动作服务器把本次运动的结果数据发送给客户端,使客户端得到本次动作的全部信息,例如可能包含机器人的运动时长,最终姿势等等。
  • 反馈:在动作进行的过程中,应该有实时的状态信息反馈给服务器的实施者,告诉实施者动作完成的状态,可以使实施者作出准确的判断去修正命令。

Action规范文件的后缀名是.action,它的内容格式如下:

#    Define the    goal 
uint32    dishwasher_id        
--
#    Define the    result 
uint32    total_dishes_cleaned 
--
#    Define a feedback message 
float32    percent_complete

3. roscpp

如roscpp是C++语言ROS接口,我们直接调用它所 提供的函数就可以实现topic、service等通信功能。roscpp位于/opt/ros/kinetic之下,用C++实现了ROS通信。通常我们要调用ROS的C++接口,首先就需要#include

roscpp的主要部分包括:

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

3.1 编写消息发布器和订阅器

创建程序包

首先需要创建Catkin工作空间:

$ mkdir -p ~/catkin_ws/src #创建一个目录,-p表示如果不存在就创建否则不创建
$ cd ~/catkin_ws/
$ catkin_make

catkin_ws工作空间内$ cd ~/catkin_ws/src

然后创建一个名为beginner_tutorials的新程序包,这个包依赖于std_msg、roscpp和rospy:

$ catkin_create_pkg beginner_tutorials std_msgs rospy roscpp

这将会创建一个名为beginner_tutorials的文件夹,这个文件夹里面包含一个package.xml文件和一个CMakeLists.txt文件,这两个文件都已经自动包含了部分在执行catkin_create_pkg命令时提供的信息。

发布器节点

首先进入之前创建的 beginner_tutorials package 路径下,在这之中创建一个src文件夹:

cd ~/catkin_ws/src/beginner_tutorials
mkdir -p ~/catkin_ws/src/beginner_tutorials/src

这个文件夹将会用来放置 beginner_tutorials package 的所有源代码。然后创建src/talker.cpp 文件,在其中写发布器源代码,如下:

#include "ros/ros.h"
#include "std_msgs/String.h"
#include <sstream>

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

  ros::init(argc, argv, "talker");
  ros::NodeHandle n;

  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 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;
}

下面分块解释:

1.用init初始化这个节点,唯一命名为talker,然后为他创建一个句柄n(这里是自动对应上的,一个一个进程只能有一个节点)

ros::init(argc, argv, "talker");
ros::NodeHandle n;

2.告诉 master 我们将要在 chatter(话题名) 上发布 std_msgs/String 消息类型的消息。这样 master 就会告诉所有订阅了 chatter 话题的节点,将要有数据发布。第二个参数是发布序列的大小,如果我们发布的消息的频率太高,缓冲区中的消息在大于 1000 个的时候就会开始丢弃先前发布的消息。

ros::Publisher chatter_pub = n.advertise<std_msgs::String>("chatter", 1000);

3.ros::Rate 对象可以允许你指定自循环的频率。它会追踪记录自上一次调用 Rate::sleep() 后时间的流逝,并休眠直到一个频率周期的时间。在这个例子中,我们让它以 10Hz 的频率运行。

ros::Rate loop_rate(10);

4.roscpp 会默认生成一个 SIGINT 句柄,它负责处理 Ctrl-C 键盘操作——使得 ros::ok() 返回 false。如果下列条件之一发生,ros::ok() 返回false:

  • SIGINT 被触发 (Ctrl+C)
  • 被另一同名节点踢出 ROS 网络
  • ros::shutdown() 被程序的另一部分调用
  • 节点中的所有 ros::NodeHandles 都已经被销毁
 while (ros::ok())
 {...}

5.为msg添加数据成员hello world;用ROS_INFO 和其他类似的函数可以用来代替 printf/cout 等函数;在这个例子中 ros::spinOnce()非必须,因为我们不接受回调。然而,如果程序里包含其他回调函数,加上 ros::spinOnce()这一语句,否则回调函数就永远也不会被调用了。

这是ROS消息回调处理函数,在ROS的主循环中,程序需要不断调用ros::spin()ros::spinOnce()两者区别在于前者调用后不会再返回,也就是你的主程序到这儿就不往下执行了,而后者在调用后还可以继续执行之后的程序。

loop_rate.sleep();是调用 ros::Rate 对象来休眠一段时间以使得发布频率为 10Hz。

  while (ros::ok())
  {
    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;
  }

订阅器节点

beginner_tutorials package 目录下创建 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::init(argc, argv, "listener");
    ros::NodeHandle n;

    ros::Subscriber sub = n.subscribe("chatter", 1000, chatterCallback);

    ros::spin();

    return 0;
}

这是一个回调函数,当接收到 chatter 话题的时候就会被调用。消息是以 boost shared_ptr 指针的形式传输,这就意味着可以存储它而又不需要复制数据。

void chatterCallback(const std_msgs::String::ConstPtr& msg)
{
  ROS_INFO("I heard: [%s]", msg->data.c_str());
}

告诉 master 我们要订阅 chatter 话题上的消息。当有消息发布到这个话题时,ROS 就会调用 chatterCallback() 函数。第二个参数是队列大小,以防我们处理消息的速度不够快,当缓存达到 1000 条消息后,再有新的消息到来就将开始丢弃先前接收的消息。

ros::Subscriber sub = n.subscribe("chatter", 1000, chatterCallback);

编译节点并测试

之前创建包时,创建了创建了 package.xmlCMakeLists.txt 文件,在生成好的CMakeLists.txt后面加入

## Build talker and listener
include_directories(include ${catkin_INCLUDE_DIRS})

add_executable(talker src/talker.cpp)
target_link_libraries(talker ${catkin_LIBRARIES})
add_dependencies(talker beginner_tutorials_generate_messages_cpp)

add_executable(listener src/listener.cpp)
target_link_libraries(listener ${catkin_LIBRARIES})
add_dependencies(listener beginner_tutorials_generate_messages_cpp)

然后运行catkin_make。注意:如果你是添加了新的 package,你需要通过 --force-cmake 选项告诉 catkin 进行强制编译

# In your catkin workspace
$ catkin_make

按顺序启动并刷新:

$ roscore
# In your catkin workspace
$ cd ~/catkin_ws
$ source ./devel/setup.bash

注意!roscore,talker,listener需要开三个不同的terminal,talker和listener开启时都需要刷新。

启动发布器:

$ source ./devel/setup.bash
$ rosrun beginner_tutorials talker

启动订阅器:

$ source ./devel/setup.bash
$ rosrun beginner_tutorials listener

3.2 编写简单的服务器和客户端

首先需要创建一个srv,用来描述服务器数据类型。

int64 a
int64 b
---
int64 sum

编写服务器节点

创建add_two_ints_server.cpp

#include "ros/ros.h"
#include "beginner_tutorials/AddTwoInts.h"

bool add(beginner_tutorials::AddTwoInts::Request  &req,
         beginner_tutorials::AddTwoInts::Response &res)
{
  res.sum = req.a + req.b;
  ROS_INFO("request: x=%ld, y=%ld", (long int)req.a, (long int)req.b);
  ROS_INFO("sending back response: [%ld]", (long int)res.sum);
  return true;
}

int main(int argc, char **argv)
{
  ros::init(argc, argv, "add_two_ints_server");
  ros::NodeHandle n;

  ros::ServiceServer service = n.advertiseService("add_two_ints", add);
  ROS_INFO("Ready to add two ints.");
  ros::spin();

  return 0;
}

我们自己创建了一个add函数,这个函数提供两个int值求和的服务,int值从request里面获取,而返回数据装入response内,这些数据类型都定义在srv文件内部,函数返回一个boolean值。

Request值从分割线上方获取,Response值从分割线下方获取

bool add(beginner_tutorials::AddTwoInts::Request  &req,
         beginner_tutorials::AddTwoInts::Response &res)
{
  res.sum = req.a + req.b;
  ROS_INFO("request: x=%ld, y=%ld", (long int)req.a, (long int)req.b);
  ROS_INFO("sending back response: [%ld]", (long int)res.sum);
  return true;
}

通过ros::NodeHandle::advertiseService()来创建ros::ServiceServeradvertiseService() 工作方式类似 subscribe()函数,提供一个服务名和回调函数。

ros::ServiceServer service = n.advertiseService("add_two_ints", add);
ROS_INFO("Ready to add two ints.");

编写客户端节点

在beginner_tutorials包中创建src/add_two_ints_client.cpp文件,并复制粘贴下面的代码:

#include "ros/ros.h"
#include "beginner_tutorials/AddTwoInts.h"
#include <cstdlib>

int main(int argc, char **argv)
{
  ros::init(argc, argv, "add_two_ints_client");
  if (argc != 3)
  {
    ROS_INFO("usage: add_two_ints_client X Y");
    return 1;
  }

  ros::NodeHandle n;
  ros::ServiceClient client = n.serviceClient<beginner_tutorials::AddTwoInts>("add_two_ints");
  beginner_tutorials::AddTwoInts srv;
  srv.request.a = atoll(argv[1]);
  srv.request.b = atoll(argv[2]);
  if (client.call(srv))
  {
    ROS_INFO("Sum: %ld", (long int)srv.response.sum);
  }
  else
  {
    ROS_ERROR("Failed to call service add_two_ints");
    return 1;
  }

  return 0;
}

add_two_ints service创建一个client。ros::ServiceClient 对象待会用来调用service。

ros::ServiceClient client = n.serviceClient<beginner_tutorials::AddTwoInts>("add_two_ints");

实例化一个由ROS编译系统自动生成的service类,并给其request成员赋值。一个service类包含两个成员requestresponse。同时也包括两个类定义RequestResponse。atoll把字符串转换为long long integer

  beginner_tutorials::AddTwoInts srv;
  srv.request.a = atoll(argv[1]);
  srv.request.b = atoll(argv[2]);

代码是在调用service。由于service的调用是模态过程(调用的时候占用进程阻止其他代码的执行),一旦调用完成,将返回调用结果。如果service调用成功,call()函数将返回true,srv.response里面的值将是合法的值。如果调用失败,call()函数将返回false,srv.response里面的值将是非法的。

if (client.call(srv))

还是老规矩,三个terminal启动,注意刷新,注意客户端需要传参

$ roscore
$ rosrun beginner_tutorials add_two_ints_server
$ rosrun beginner_tutorials add_two_ints_client 1 3

服务器窗口: