安装ROS2

鱼香yyds

1
wget http://fishros.com/install -O fishros && bash fishros

卸载ROS
1
sudo apt remove ros-foxy-* && sudo apt autoremove


基础概念:

与ROS1类似,ROS中同样具有节点,工作空间,功能包等概念

节点

每一个节点都负责一个单独的模块。

举个不太恰当的例子:外卖员小哥外卖给主播小姐姐吃,送累了就刷小姐姐直播跳舞,这里外卖小哥和小姐姐都是一个节点,大家共同构成了一个整体,营造出lianghao社会(bushi)
ROS2中的节点也是如此,每一个节点也是只负责一个单独的模块化的功能(比如一个节点负责控制车轮转动,一个节点负责从激光雷达获取数据、一个节点负责处理激光雷达的数据、一个节点负责定位等等)

节点通信(详见)

ROS2中主要有以下四种通信方式:

  • 话题-topics
  • 服务-services
  • 动作-Action
  • 参数-parameters

Nodes-TopicandService.gif

启动节点

需要使用指令:

1
ros2 run <package_name> <executable_name>

命令行查看节点信息

这里涉及以下两个概念:

  • GUI(Graphical User Interface)就是平常我们说的图形用户界面,大家用的Windows是就是可视化的,我们可以通过鼠标点击按钮等图形化交互完成任务。
  • CLI(Command-Line Interface)就是命令行界面了,我们所用的终端,黑框框就是命令行界面,没有图形化。

    节点相关CLI:

    列举几个常用的:
    运行节点(

    1
    ros2 run <package_name> <executable_name>Copy to clipboardErrorCopied

    查看节点列表(常用):

    1
    ros2 node listCopy to clipboardErrorCopied

    查看节点信息(常用):

    1
    ros2 node listCopy to clipboardErrorCopied

    重映射节点名称

    1
    ros2 run turtlesim turtlesim_node --ros-args --remap __node:=my_turtle

    工作空间&&功能包:

    想要找到一个可执行文件(节点)必须依赖于一个功能包,这些包可以统一放在某个工作空间里。
    创建工作空间:

    1
    2
    mkdir -p turtle_ws/src
    cd turtle_ws/src

    功能包:
    可以理解为存放节点的容器。
    ROS2中功能包根据编译方式的不同分为三种类型。

  • ament_python,适用于python程序

  • cmake,适用于C++
  • ament_cmake,适用于C++程序,是cmake的增强版

    功能包获取

  • 安装一般使用

    1
    sudo apt install ros-<version>-package_name
  • 手动编译:有点麻烦,一般都是需要对包进行修改的时候shiytong

    相关指令——ros2pkg

    1
    2
    3
    4
    5
    6
    create       Create a new ROS2 package
    executables Output a list of package specific executables
    list Output a list of available packages
    prefix Output the prefix path of a package
    xml Output the XML of the package manifest or a specific tag

    1.创建功能包

    1
    ros2 pkg create <package-name>  --build-type  {cmake,ament_cmake,ament_python}  --dependencies <依赖名字>Copy to clipboardErrorCopied

    2.列出可执行文件
    列出所有

    1
    ros2 pkg executablesCopy to clipboardErrorCopied

    列出某个功能包的

    1
    ros2 pkg executables turtlesimCopy to clipboardErrorCopied


    3.列出所有的包

    1
    ros2 pkg listCopy to clipboardErrorCopied

    4.输出某个包所在路径的前缀

    1
    ros2 pkg prefix  <package-name>Copy to clipboardErrorCopied

    比如小乌龟

    1
    ros2 pkg prefix turtlesimCopy to clipboardErrorCopied

    5.列出包的清单描述文件
    每一个功能包都有一个标配的manifest.xml文件,用于记录这个包的名字,构建工具,编译信息,拥有者,干啥用的等信息。
    通过这个信息,就可以自动为该功能包安装依赖,构建时确定编译顺序等
    查看小乌龟模拟器功能包的信息。

    1
    ros2 pkg xml turtlesim

    colcon:

    colcon其是就是个功能包的构建工具,说白了就是编译器。
    ros2默认死没有colcon的,所以需要安装

    1
    sudo apt-get install python3-colcon-common-extensions

    相关指令

    5.1 只编译一个包

    1
    colcon build --packages-select YOUR_PKG_NAME Copy to clipboardErrorCopied

    5.2 不编译测试单元

    1
    colcon build --packages-select YOUR_PKG_NAME  --cmake-args -DBUILD_TESTING=0Copy to clipboardErrorCopied

    5.3 运行编译的包的测试

    1
    colcon testCopy to clipboardErrorCopied

    5.4 允许通过更改src下的部分文件来改变install(重要)

    (每次调整 python 脚本时都不必重新build了)

    1
    colcon build --symlink-install

    手撸节点test(c++)

    由于python的运行效率实在是一言难尽,我们只学习C++_的版本

    创建工作空间&& 功能包

    1
    2
    3
    mkdir -p town_ws/src
    cd town_ws/src
    ros2 pkg create village_wang --build-type ament_cmake --dependencies rclcpp

    创建完成的目录结构如下:
    image-20210727193256467.png

    POP方式编写节点

    在village_wang/src中创建wang2.cpp

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    #include "rclcpp/rclcpp.hpp"


    int main(int argc, char **argv)
    {
    /*初始化rclcpp
    rclcpp::init(argc, argv);
    /*产生一个Wang2的节点*/
    auto node = std::make_shared<rclcpp::Node>("wang2");
    // 打印一句自我介绍
    RCLCPP_INFO(node->get_logger(), "大家好,我是单身狗wang2.");
    /* 运行节点,并检测退出信号*/
    rclcpp::spin(node);
    rclcpp::shutdown();
    return 0;
    }

    主函数中首先初始化rclcpp,然后新建了一个Node节点的对象,命名为wang2,接着使用rclcpp让这个节点暴露在外面,并检测退出信号(Ctrl+C),检测到退出信号后,就会执行rcl.shutdown()关闭节点。

    添加到cmakelists

    在CmakeLists.txt最后一行加入下面两行代码。

    1
    2
    add_executable(wang2_node src/wang2.cpp)
    ament_target_dependencies(wang2_node rclcpp)

    添加这两行代码的目的是让编译器编译wang2.cpp这个文件,不然不会主动编译。接着在上面两行代码下面添加下面的代码。

    1
    2
    3
    4
    install(TARGETS
    wang2_node
    DESTINATION lib/${PROJECT_NAME}
    )

    这个是C++比Python要麻烦的地方,需要手动将编译好的文件安装到install/village_wang/lib/village_wang下.

    编译运行:

    打开vscode终端,进入town_ws

    编译节点

    1
    colcon build

    source环境

    1
    source install/setup.bash

    运行节点

    1
    ros2 run village_wang wang2_node

    不出意外,你可以看到王二的自我介绍。
    a85481a9b56ae5cf6bee46a440140235.png
    当节点运行起来后,使用ros2 node list 指令来查看现有的节点。972b7669eb967a31c2a0d959b7301ad9.png

    OPP方式编写节点

    还是在wang2.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

    #include "rclcpp/rclcpp.hpp"

    /*
    创建一个类节点,名字叫做SingleDogNode,继承自Node.
    */
    class SingleDogNode : public rclcpp::Node
    {

    public:
    // 构造函数,有一个参数为节点名称
    SingleDogNode(std::string name) : Node(name)
    {
    // 打印一句自我介绍
    RCLCPP_INFO(this->get_logger(), "大家好,我是单身狗%s.",name.c_str());
    }

    private:

    };

    int main(int argc, char **argv)
    {
    rclcpp::init(argc, argv);
    /*产生一个Wang2的节点*/
    auto node = std::make_shared<SingleDogNode>("wang2");
    /* 运行节点,并检测退出信号*/
    rclcpp::spin(node);
    rclcpp::shutdown();
    return 0;
    }

    修改cmakelists&&运行

    同上,不多赘述
    8014891cb75b9e90255db630b7115fad.png
    运行成功。

    通信

    鱼香教程里的举例实在是难以忘却,这里我cv过来

    1
    2
    3
    4
    5
    6
    7
    8
    这里的王二和李四两个节点,通过话题来互相通信(传递数据)。

    李四节点会创建一个发布者(Publisher)来发布一个话题(艳娘传奇,小鱼取个英文名叫sexy_girl)。单身汉王二节点,他创建了一个订阅者(Subscriber)来订阅李四发布的话题sexy_girl。

    那艳娘传奇的内容是什么呢?我们暂且规定为由文字组成的字符串(连插图都没的那种)。

    [object Promise]
    李四王二通信模型是一个一对一(一个发布者,一个订阅者)的模型,除此之外ROS2中话题通信其实还可以是1对n,n对1,n对n的。

    话题通讯

    规则:

  • 话题名字是关键,发布订阅接口类型要相同,发布的是字符串,接受也要用字符串来接收;

  • 同一个人(节点)可以订阅多个话题,同时也可以发布多个话题,就像一本书的作者也可以是另外一本书的读者;
  • 同一个小说不能有多个作者(版权问题),但跟小说不一样,同一个话题可以有多个发布者。

    相关工具:

    rqt_graph:
    ROS2作为一个强大的工具,在运行过程中,我们是可以通过命令来看到节点和节点之间的数据关系的。image-20210803113450234.png

命令行界面——CLI

返回系统活动所有主题列表

1
ros2 topic list

增加消息类型
1
ros2 topic list -t

打印实时话题内容
1
2
ros2 topic echo /chatter


查看主题信息
1
ros2 topic info  /chatter

查看消息类型
1
ros2 interface show std_msgs/msg/String

手动发布命令
1
ros2 topic pub /chatter std_msgs/msg/String 'data: "123"'

c++实现

创建话题订阅者的一般流程:

  1. 导入订阅的话题接口类型
  2. 创建订阅回调函数
  3. 声明并创建订阅者
  4. 编写订阅回调处理逻辑

    王三

  • 将wang2.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
    #include "rclcpp/rclcpp.hpp"
    #include "std_msgs/msg/string.hpp"
    #include "std_msgs/msg/u_int32.hpp"


    using std::placeholders::_1;
    using std::placeholders::_2;

    /*
    创建一个类节点,名字叫做SingleDogNode,继承自Node.
    */
    class SingleDogNode : public rclcpp::Node
    {

    public:
    // 构造函数,有一个参数为节点名称
    SingleDogNode(std::string name) : Node(name)
    {
    // 打印一句自我介绍
    RCLCPP_INFO(this->get_logger(), "大家好,我是单身狗%s.", name.c_str());
    // 创建一个订阅者来订阅李四写的小说,通过名字sexy_girl
    sub_novel = this->create_subscription<std_msgs::msg::String>("sexy_girl", 10, std::bind(&SingleDogNode::topic_callback, this, _1));
    }

    private:
    // 声明一个订阅者(成员变量),用于订阅小说
    rclcpp::Subscription<std_msgs::msg::String>::SharedPtr sub_novel;

    // 收到话题数据的回调函数
    void topic_callback(const std_msgs::msg::String::SharedPtr msg)
    {
    RCLCPP_INFO(this->get_logger(), "朕已阅:'%s'", msg->data.c_str());
    };

    };

    int main(int argc, char **argv)
    {
    rclcpp::init(argc, argv);
    /*产生一个Wang2的节点*/
    auto node = std::make_shared<SingleDogNode>("wang2");
    /* 运行节点,并检测退出信号*/
    rclcpp::spin(node);
    rclcpp::shutdown();
    return 0;
    }

    使用C++订阅话题,需要添加对应的消息类型头文件:

    1
    2
    #include "std_msgs/msg/string.hpp"
    #include "std_msgs/msg/u_int32.hpp"

    创建订阅者和发布者时依然使用this->create_subscription和this->create_publisher方法。
    C++中创建一个订阅者,需要传入话题类型、话题名称、所要绑定的回调函数,以及通信Qos.
    std::bind()
    C++的类成员函数不能像普通函数那样用于回调,因为每个成员函数都需要有一个对象实例去调用它。 通常情况下,要实现成员函数作为回调函数:一种过去常用的方法就是把该成员函数设计为静态成员函数(因为类的成员函数需要隐含的this指针 而回调函数没有办法提供),但这样做有一个缺点,就是会破坏类的结构性,因为静态成员函数只能访问该类的静态成员变量和静态成员函数,不能访问非静态的,要解决这个问题,可以把对象实例的指针或引用做为参数传给它。 后面就可以靠这个对象实例的指针或引用访问非静态成员函数。另一种办法就是使用std::bind和std::function结合实现回调技术。(目前还看不太懂)

  • 编译运行

    1
    colcon build --packages-select village_wang
  • source运行

    李四

    突然发现李四的源码在教程里没有,自己搓了个试试。

    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
    #include "rclcpp/rclcpp.hpp"
    #include "std_msgs/msg/string.hpp"


    using std::placeholders::_1;

    // 定义一个小说内容的数组
    std::string novel[] = {
    "第一回:潋滟湖 1 次偶遇胡艳娘",
    "第二回:潋滟湖 2 次偶遇胡艳娘",
    "第三回:潋滟湖 3 次偶遇胡艳娘",
    "第四回:潋滟湖 4 次偶遇胡艳娘",
    "第五回:潋滟湖 5 次偶遇胡艳娘"
    };

    // 定义一个小说内容的索引
    int nb = 0;

    /*
    创建一个类节点,名字叫做WriterNode,继承自Node.
    */
    class WriterNode : public rclcpp::Node
    {

    public:
    // 构造函数,有一个参数为节点名称
    WriterNode(std::string name) : Node(name)
    {
    // 打印一句自我介绍
    RCLCPP_INFO(this->get_logger(), "大家好,我是%s,我是一名作家!", name.c_str());
    // 创建一个发布者来发布小说内容,通过名字sexy_girl
    pub_novel = this->create_publisher<std_msgs::msg::String>("sexy_girl", 10);
    // 创建一个定时器,每隔五秒发布一章小说内容
    timer = this->create_wall_timer(std::chrono::seconds(5), std::bind(&WriterNode::timer_callback, this));
    }

    private:
    // 声明一个发布者(成员变量),用于发布小说内容
    rclcpp::Publisher<std_msgs::msg::String>::SharedPtr pub_novel;

    // 声明一个定时器(成员变量),用于定时发布小说内容
    rclcpp::TimerBase::SharedPtr timer;

    // 定时器触发的回调函数
    void timer_callback()
    {
    // 新建一个小说内容的消息
    std_msgs::msg::String novel_msg;
    // 判断小说内容的索引是否超出数组范围
    if (nb < sizeof(novel) / sizeof(novel[0]))
    {
    // 获取小说内容
    novel_msg.data = novel[nb];
    // 发布小说内容
    pub_novel->publish(novel_msg);
    // 打印发布的小说内容
    RCLCPP_INFO(this->get_logger(), "发布小说内容:%s", novel_msg.data.c_str());
    // 小说内容的索引加一
    nb++;
    }
    else
    {
    // 小说内容已经发布完毕,取消定时器
    timer->cancel();
    // 打印结束语
    RCLCPP_INFO(this->get_logger(), "小说已经完结,感谢大家的支持!");
    }
    };
    };

    int main(int argc, char **argv)
    {
    rclcpp::init(argc, argv); // 初始化rclcpp
    auto node = std::make_shared<WriterNode>("li4"); // 新建一个节点
    rclcpp::spin(node); // 保持节点运行,检测是否收到退出指令(Ctrl+C)
    rclcpp::shutdown(); // 关闭rclcpp
    return 0;
    }

    使用Ctrl+Shift+5切分一个终端出来,输入下面命令:

    1
    2
    source install/setup.bash 
    ros2 run village_li li4_node

    image-20210804074600329.png

    发布

    C++中创建一个发布者也比较简单,使用this->create_publisher即可创建一个发布者。

    1
    pub_ = this->create_publisher<std_msgs::msg::UInt32>("sexy_girl_money",10);

    这里提供了三个参数,分别是该发布者要发布的话题名称(sexy_girl_money)、发布者要发布的话题类型(std_msgs::msg::UInt32)、Qos(10)

    服务和接口

    接口:接口其实是一种规范

    当接口类型统一的时候,适配显然就不是问题了,大家的服务和响应都是一致的规范格式

    常用命令

    查看接口列表(当前环境下)
    1
    ros2 interface list


    查看所有接口包
    1
    ros2 interface packages


    查看某一个包下的所有接口
    1
    ros2 interface package std_msgs


    查看某一个接口详细的内容
    1
    ros2 interface show std_msgs/msg/String


    输出某一个接口所有属性
    1
    ros2 interface proto sensor_msgs/msg/Image

显然,服务和话题的区别在于话题是没有返回的,只是单向的数据传递。而服务是双向的客户端发送,服务端响应。

自定义话题接口

  • 新建工作空间

在town_ws的src文件夹下,运行下面的指令,即可完成village_interfaces功能包的创建。
注意,这里包的编译类型我们使用ament_cmake方式。

1
ros2 pkg create village_interfaces --build-type ament_cmake 

image-20210809151545012.png

  • 新建msg文件和Novel.msg(小说消息)

注意:msg文件开头首字母一定要大写,ROS2强制要求,盲猜应该是为了和类名保持一致

1
2
3
4
cd village_interfaces
mkdir msg
touch Novel.msg

  • 编写Novel.msg内容

我们的目的是给李四的小说每一章增加一张图片,原来李四写小说是对外发布一个std_msgs/msg/String字符串类型的数据。
而发布图片的格式,我们需要采用ros自带的传感器消息接口中的图片sensor_msgs/msg/Image数据类型,所以我们新的消息文件的内容就是将两者合并,在ROS2中可以写做这样:
在msg文件中可以使用#号添加注释。

1
2
3
4
# 标准消息接口std_msgs下的String类型
std_msgs/String content
# 图像消息,调用sensor_msgs下的Image类型
sensor_msgs/Image image

这种组合结构图如下:
[object Promise]
这个图一共三层,第一层是消息定义层,第二层是ROS2已有的std_msgs,sensor_msgs,其组成关系是由下一层组合成上一层。
最下面一层string、uint8、uint32是ROS2中的原始数据类型,原始数据类型有下面几种,ROS2中所有的接口都是由这些原始数据类型组成。
1
2
3
4
5
6
7
8
9
bool
byte
char
float32,float64
int8,uint8
int16,uint16
int32,uint32
int64,uint64
string

  • Another way

我们不使用std_msgs/String 而是直接使用最下面一层的string。

1
2
3
4
# 直接使用ROS2原始的数据类型
string content
# 图像消息,调用sensor_msgs下的Image类型
sensor_msgs/Image image

  • 说明

如何知道,std_msgs/String是由基础数据类型string组成的,其实可以通过下面的指令来查看

1
ros2 interface show std_msgs/msg/String

结果如下:
1
2
string data


原来std_msgs的String就是包含一个叫变量名为data的string类型变量,这也是在4.2和4.3章节中代码要用.data才能拿到真正的数据的原因:
1
2
3
4
5
from std_msgs.msg import String
msg = String()
msg.data = '第%d回:潋滟湖 %d 次偶遇胡艳娘' % (self.i,self.i)
# msg 是 std_msgs.msg.String() 的对象
# msg.data data是string类型的对象,其定义是string data

最终Novel.msg
1
2
3
4
# 直接使用ROS2原始的数据类型
string content
# 图像消息,调用sensor_msgs下的Image类型
sensor_msgs/Image image

  • 修改Cmakelists.txt

完成了代码的编写还不够,我们还需要在CMakeLists.txt中告诉编译器,你要给我把Novel.msg转换成Python库和C++的头文件。
直接添加下面的代码到CMakeLists.txt即可。

1
2
3
4
5
6
7
8
#添加对sensor_msgs的
find_package(sensor_msgs REQUIRED)
find_package(rosidl_default_generators REQUIRED)
#添加消息文件和依赖
rosidl_generate_interfaces(${PROJECT_NAME}
"msg/Novel.msg"
DEPENDENCIES sensor_msgs
)

find_package用于查找rosidl_default_generators位置,下面rosidl_generate_interfaces就是声明msg文件所属的工程名字、文件位置以及依赖DEPENDENCIES。
踩坑报告:

  • 重点强调一下依赖部分DEPENDENCIES,我们消息中用到的依赖这里必须写上,即使不写编译器也不会报错,直到运行的时候才会出错。
  • 而且rosidl_generate_interfaces() 函数必须在 ament_package() 函数之前调用。

代码大概是这样的
e3877412c40448c0995ba2884a1ae7c1.png

  • 修改package.xml

修改village_interfaces目录下的package.xml,添加下面三行代码,为工程添加一下所需的依赖。
这里其实不添加也可以

1
2
3
4
<depend>sensor_msgs</depend>
<build_depend>rosidl_default_generators</build_depend>
<exec_depend>rosidl_default_runtime</exec_depend>
<member_of_group>rosidl_interface_packages</member_of_group>

代码位置:
image-20210816145202732.png

  • 编译

回到town_ws

1
colcon build --packages-select village_interfaces

  • 验证

过上节课说过的ros2 interface常用的命令来测试。

1
2
3
4
5
source install/setup.bash 
ros2 interface package village_interfaces #查看包下所有接口
ros2 interface show village_interfaces/msg/Novel #查看内容
ros2 interface proto village_interfaces/msg/Novel #显示属性


image-20210816145503946.png

我们可以在运行结果中看到,Novel的消息内容是由content数据和传感器数据image共同组成的了。

服务:

  • 服务:客户端发送请求给服务端,服务端可以根据客户端的请求做一些处理,然后返回结果给客户端。

Service-SingleServiceClient.gif
Service-MultipleServiceClient.gif

下面操作一下ros2自带的样例服务:

启动服务端

运行一个服务节点

1
2
ros2 run examples_rclpy_minimal_service service
//服务的功能是将两个数字相加,给定a,b两个数,返回sum也就是ab之和

查看服务列表
1
ros2 service list

手动调用服务(一定要注意a: b: 的空格)
1
2
ros2 service call /add_two_ints example_interfaces/srv/AddTwoInts "{a: 5,b: 10}"
//需要再启动一个终端

查看服务接口类型
1
2
ros2 service type /add_two_ints

查找使用某一接口的服务
1
2
ros2 service find example_interfaces/srv/AddTwoInts

自定义服务接口

我们来看一下服务的消息接口长什么样子?
服务接口格式:xxx.srv

1
2
3
4
int64 a
int64 b
---
int64 sum

与话题不同的是,srv文件比msg文件中间多出了三个—-这三个杠杠就是分界线,上方的是客户端发送请求的数据结构定义,下方的是服务端响应结果的数据结构定义。
参考下面的步骤:

  • 新建srv文件夹,并在文件夹下新建xxx.srv
  • 在xxx.srv下编写服务接口内容并保存
  • 在CmakeLists.txt添加依赖和srv文件目录
  • 在package.xml中添加xxx.srv所需的依赖
  • 编译功能包即可生成python与c++头文件

当然在做上面的步骤之前,我们还需要做一件很重要的事情。就是根据业务需求,确定好请求的数据结构和返回的数据结构。我们依然是在village_interfaces下创建服务接口。
开始之前,我们先根据李四的需求来确定数据结构。
上一节中李四对借钱的要求如下:

  1. 借钱一定要打欠条,收到欠条才能给钱
  2. 每次借钱不能超过自己全部资金的10%且一定是整数,也就是说李四假如现在有100块钱,那么最多借出去100x10%=10块钱

总结一下就是,李三发送借钱请求的时候一定要有欠条,我们想一下,欠条中应该至少包含两条信息

  • 借钱者名字,字符串类型、可以用string表示
  • 金额,整形,可以用uint32表示

那请求的数据结构我们就可以确定下来了,接着确定返回的数据的格式。
既然是借钱,那李四就有可能拒绝,会有借钱失败的情况,所以返回数据应该有这两条信息:

  • 是否出借:只有成功和失败两种情况,布尔类型(bool)可表示
  • 出借金额:无符号整形,可以用uint32表示,借钱失败时为0。

    创建srv文件夹及BorrowMoney.srv消息文件

    在village_interfaces下新建srv文件夹
    image-20210811162010736.png

    编写文件内容
    1
    2
    3
    4
    5
    string name
    uint32 money
    ---
    bool success
    uint32 money

    修改Cmakelists.txt

    我们已经添加过依赖DEPENDENCIES和msg文件了,所以这里我们直接添加一个srv即可。

    1
    2
    3
    4
    5
    6
    7
    8
    find_package(rosidl_default_generators REQUIRED)
    rosidl_generate_interfaces(${PROJECT_NAME}
    #---msg---
    "msg/Novel.msg"
    #---srv---
    "srv/BorrowMoney.srv"
    DEPENDENCIES sensor_msgs
    )

    需要关注的是这一行”srv/BorrowMoney.srv”,添加了对应的文件位置。

  • 踩坑:在rosidl_generate_interfaces()函数中传递了一个依赖项sensor_msgs,但是在使用find_package()函数之前没有找到它。需要在CMakeLists.txt 文件中添加find_package(sensor_msgs REQUIRED),以确保 sensor_msgs 包被正确地找到和链接。

    修改package.xml
    1
    2
    3
    4
    5
    <build_depend>sensor_msgs</build_depend>
    <build_depend>rosidl_default_generators</build_depend>
    <exec_depend>rosidl_default_runtime</exec_depend>
    <member_of_group>rosidl_interface_packages</member_of_group>

    编译
    1
    colcon build --packages-select village_interfaces

    测试

    这次测试我们依然使用ros2 interface指令进行测试。

    1
    2
    3
    4
    source install/setup.bash 
    ros2 interface package village_interfaces
    ros2 interface show village_interfaces/srv/BorrowMoney
    ros2 interface proto village_interfaces/srv/BorrowMoney

    服务的c++实现

    一句话:张三拿多少钱钱给王二,王二凑够多少个章节的艳娘传奇给他,可参考以下步骤

  1. 导入服务接口
  2. 创建服务端回调函数
  3. 声明并创建服务端
  4. 编写回调函数逻辑处理请求
    添加接口&&依赖
    因为village_wang的包类型是ament_cmake,故需要进行以下两步操作:
    第一步修改package.xml
    加入下面的代码(告诉colcon,编译之前要确保有village_interfaces存在)
    1
    <depend>village_interfaces</depend>
    image-20210816153438400.png
    第二步修改和CMakeLists.txt
    在CMakeLists.txt中加入下面一行代码
    1
    find_package(village_interfaces REQUIRED)
    find_package是cmake的语法,用于查找库。找到后,还需要将其和可执行文件链接起来
    所以还需要修改ament_target_dependencies,在其中添加village_interfaces。
    1
    2
    3
    4
    ament_target_dependencies(wang2_node 
    rclcpp
    village_interfaces
    )
    对于C++来说,添加服务接口只需在程序中引入对应的头文件即可。
    这个头文件就是我们SellNovel.srv生成的头文件
    1
    #include "village_interfaces/srv/sell_novel.hpp"
    声明回调函数
    添加完服务接口接着就可以声明一个卖书请求回调函数
    1
    2
    3
    4
    5
    // 声明一个回调函数,当收到买书请求时调用该函数,用于处理数据
    void sell_book_callback(const village_interfaces::srv::SellNovel::Request::SharedPtr request,
    const village_interfaces::srv::SellNovel::Response::SharedPtr response)
    {
    }

再创建一个队列,用于存放自己看过的二手书,创建队列需要用到queue容器,所以我们先用#include在程序开头引入该容器,再在代码中添加下面这句话。

1
2
//创建一个小说章节队列
std::queue<std::string> novels_queue;

死锁

当张三请求王二买二手书的时候,假如王二手里书的数量不足,王二就等攒够了对应数量的书再返回给张三。
等待攒够章节的操作需要在卖书服务函数中阻塞当前线程,阻塞后王二就收不到李四写的小说了,这样一来就会造成一个很尴尬的情景:
在卖书服务回调函数中等着书库(队列)里小说章节数量满足张三需求,接收小说的程序等着这边的卖书回调函数结束,好把书放进书库(队列)里。
这种互相等待的情况,我们称之为死锁
ROS2默认是单线程的,同时只有一个线程在跑,大家都是顺序执行,你干完我干,一条线下去。
所以为了解决这个问题,我们可以使用多线程,即每次收到服务请求后,单独开一个线程来处理,不影响其他部分。

回调函数组

ROS2中要使用多线程执行器和回调组来实现多线程,我们先在SingleDogNode中声明一个回调组成员变量。

1
2
// 声明一个服务回调组
rclcpp::CallbackGroup::SharedPtr callback_group_service_;

最终结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class SingleDogNode : public rclcpp::Node 
{

public:
// 构造函数
SingleDogNode(std::string name) : Node(name)
{
}

private:
// 声明一个服务回调组
rclcpp::CallbackGroup::SharedPtr callback_group_service_;
//创建一个小说章节队列
std::queue<std::string> novels_queue;
// 声明一个服务端
rclcpp::Service<village_interfaces::srv::SellNovel>::SharedPtr server_;
// 声明一个回调函数,当收到买书请求时调用该函数,用于处理数据
void sell_book_callback(const village_interfaces::srv::SellNovel::Request::SharedPtr request,
const village_interfaces::srv::SellNovel::Response::SharedPtr response)
{
//对请求数据进行处理
}
};

实例化服务端&&编写回调函数处理请求

在ROS2中,回调函数组也是一个对象,通过实例化create_callback_group类即可创建一个callback_group_service的对象。
在SingleDogNode的构造函数中添加下面这行代码,即可完成实例化

1
callback_group_service_ = this->create_callback_group(rclcpp::CallbackGroupType::MutuallyExclusive);


我们使用成员函数作为回调函数,这里要根据回调函数中参数个数,设置占位符,即告诉编译器,这个函数需要传入的参数个数。
在之前订阅话题的回调函数中,我们已经用到过一次了,因为话题回调只有一个参数,所以只需要一个占位符,这里服务的回调是两个参数,所以要设置两个
1
2
using std::placeholders::_1;
using std::placeholders::_2;

在private:下声明服务端
1
2
// 声明一个服务端
rclcpp::Service<village_interfaces::srv::SellNovel>::SharedPtr server_;

在构造函数中实例化服务端
1
2
3
4
5
// 实例化卖二手书的服务
server_ = this->create_service<village_interfaces::srv::SellNovel>("sell_novel",
std::bind(&SingleDogNode::sell_book_callback,this,_1,_2),
rmw_qos_profile_services_default,
callback_group_service_);

实例化服务端可以直接使用create_service函数,该函数是一个模版函数,需要输入要创建的服务类型,这里我们使用的是,这个函数有四个参数需要输入,小鱼接下来进行一一介绍

  • “sell_novel”服务名称,没啥好说的,要唯一哦,因为服务只能有一个
  • std::bind(&SingleDogNode::sell_book_callback,this,_1,_2)回调函数,这里指向了我们2.3.1中我们声明的sell_book_callback
  • rmw_qos_profile_services_default 通信质量,这里使用服务默认的通信质量
  • callbackgroup_service,回调组,我们前面创建回调组就是在这里使用的,告诉ROS2,当你要调用回调函数处理请求时,请把它放到单独线程的回调组中
    编写回调函数
    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
    // 声明一个回调函数,当收到买书请求时调用该函数,用于处理数据
    void sell_book_callback(const village_interfaces::srv::SellNovel::Request::SharedPtr request,
    const village_interfaces::srv::SellNovel::Response::SharedPtr response)
    {
    RCLCPP_INFO(this->get_logger(), "收到一个买书请求,一共给了%d钱",request->money);
    unsigned int novelsNum = request->money*1; //应给小说数量,一块钱一章

    //判断当前书库里书的数量是否满足张三要买的数量,不够则进入等待函数
    if(novels_queue.size()<novelsNum)
    {
    RCLCPP_INFO(this->get_logger(), "当前艳娘传奇章节存量为%d:不能满足需求,开始等待",novels_queue.size());

    // 设置rate周期为1s,代表1s检查一次
    rclcpp::Rate loop_rate(1);

    //当书库里小说数量小于请求数量时一直循环
    while (novels_queue.size()<novelsNum)
    {
    //判断系统是否还在运行
    if(!rclcpp::ok())
    {
    RCLCPP_ERROR(this->get_logger(), "程序被终止了");
    return ;
    }
    //打印一下当前的章节数量和缺少的数量
    RCLCPP_INFO(this->get_logger(), "等待中,目前已有%d章,还差%d章",novels_queue.size(),novelsNum-novels_queue.size());

    //rate.sleep()让整个循环1s运行一次
    loop_rate.sleep();
    }
    }
    // 章节数量满足需求了
    RCLCPP_INFO(this->get_logger(), "当前艳娘传奇章节存量为%d:已经满足需求",novels_queue.size());

    //一本本把书取出来,放进请求响应对象response中
    for(unsigned int i =0 ;i<novelsNum;i++)
    {
    response->novels.push_back(novels_queue.front());
    novels_queue.pop();
    }
    }

    当收到请求时,先计算一下应该给张三多少书novelsNum,然后判断书库里书的数量够不够,不够则进入攒书程序。如果够或者攒够了就把书放到服务响应对象里,返回给张三。这里我们还需要修改一下话题回调函数,增加了一行代码,将小说放到书库里novels_queue.push(msg->data);
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // 收到话题数据的回调函数
    void topic_callback(const std_msgs::msg::String::SharedPtr msg){
    // 新建一张人民币
    std_msgs::msg::UInt32 money;
    money.data = 10;

    // 发送人民币给李四
    pub_->publish(money);
    RCLCPP_INFO(this->get_logger(), "王二:我收到了:'%s' ,并给了李四:%d 元的稿费", msg->data.c_str(),money.data);

    //将小说放入novels_queue中
    novels_queue.push(msg->data);
    };
    修改main函数
    因为我们要让整个程序变成多线程的,所以我们要把节点的执行器变成多线程执行器。
    修改一下main函数,新建一个多线程执行器,添加王二节点并spin,完整代码如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    int main(int argc, char **argv)
    {
    rclcpp::init(argc, argv);
    /*产生一个Wang2的节点*/
    auto node = std::make_shared<SingleDogNode>("wang2");
    /* 运行节点,并检测退出信号*/
    rclcpp::executors::MultiThreadedExecutor exector;
    exector.add_node(node);
    exector.spin();
    rclcpp::shutdown();
    return 0;
    }
    wang2.cpp
    编译:
    1
    colcon build --packages-select village_wang
    测试
    1
    2
    3
    4
    5
    6
    7
    8
    9
    source install/setup.bash
    ros2 run village_wang wang2_node
    source install/setup.bash
    ros2@ubuntu:~/code/town_ws$ ros2 service list -t
    /sell_book [village_interfaces/srv/SellNovel]
    ros2 service call /sell_book village_interfaces/srv/SellNovel "{money: 5}"
    source install/setup.bash
    ros2 run village_li li4_node


    image-20210831124712850.png

客户端实现?id=_3%e5%ae%a2%e6%88%b7%e7%ab%af%ef%bc%88%e5%bc%a0%e4%b8%89%ef%bc%89%e5%ae%9e%e7%8e%b0)28bff3501425d8b0e5a018fb3f7cfb1b.png