历史上的今天
返回首页

历史上的今天

今天是:2026年01月03日(星期六)

2023年01月03日 | ROS机器人操作系统的实现原理解析

2023-01-03 来源:CSDN博主

目的

本文介绍(Robot Operang System)的实现原理,从最底层分析ROS代码是如何实现的。

1、序列化

把的内容(也就是消息message)序列化是通信的基础,所以我们先研究序列化。

尽管笔者从事机器人学习和研发很长时间了,但是在研究ROS的过程中,“序列化”这个词还是这辈子第一次听到。

所以可想而知很多人在看到“把一个消息序列化”这样的描述时是如何一脸懵逼。

但其实序列化是一个比较常见的概念,你虽然不知道它但一定接触过它。

下面我们先介绍“序列化”的一些常识,然后解释ROS里的序列化是怎么做的?

1.1 什么是序列化?

“序列化”(SerializaTIon )的意思是将一个对象转化为字节流。

这里说的对象可以理解为“面向对象”里的那个对象,具体的就是存储在内存中的对象数据。

与之相反的过程是“反序列化”(DeserializaTIon )。

虽然挂着机器人的羊头,但是后面的介绍全部是计算机知识,跟机器人一丁点关系都没有,序列化就是一个纯粹的计算机概念。

序列化的英文Serialize就有把一个东西变成一串连续的东西之意。

形象的描述,数据对象是一团面,序列化就是将面团拉成一根面条,反序列化就将面条捏回面团。

另一个形象的类比是我们在对话或者打电话时,一个人的思想转换成一维的语音,然后在另一个人的头脑里重新变成结构化的思想,这也是一种序列化。

面对序列化,很多人心中可能会有很多疑问。

首先,为什么要序列化?或者更具体的说,既然对象的信息本来就是以字节的形式储存在内存中,那为什么要多此一举把一些字节数据转换成另一种形式的、一维的、连续的字节数据呢?

如果我们的程序在内存中存储了一个数字,比如25。那要怎么传递25这个数字给别的程序节点或者把这个数字永久存储起来呢?

很简单,直接传递25这个数字(的字节表示,即0X19,当然最终会变成二进制表示11001以高低电平传输存储)或者直接把这个数字(的字节表示)写进硬盘里即可。

所以,对于本来就是连续的、一维的、一连串的数据(例如字符串),序列化并不需要做太多东西,其本质是就是由内存向其它地方拷贝数据而已。

所以,如果你在一个序列化库里看到memcpy函数不用觉得奇怪,因为你知道序列化最底层不过就是在操作内存数据而已(还有些库使用了流的ostream.rdbuf()->sputn函数)。

可是实际程序操作的对象很少是这么简单的形式,大多数时候我们面对的是包含不同数据类型(int、double、string)的复杂数据结构(比如vector、list),它们很可能在内存中是不连续存储的而是分散在各处。比如ROS的很多消息都包含向量。

数据中还有各种指针和引用。而且,如果数据要在运行于不同架构的计算机之上的、由不同语言所编写的节点程序之间传递,那问题就更复杂了,它们的字节顺序endianness规定有可能不一样,基本数据类型(比如int)的长度也不一样(有的int是4个字节、有的是8个字节)。

这些都不是通过简单地、原封不动地复制粘贴原始数据就能解决的。这时候就需要序列化和反序列化了。

所以在程序之间需要通信时(ROS恰好就是这种情况),或者希望保存程序的中间运算结果时,序列化就登场了。

另外,在某种程度上,序列化还起到统一标准的作用。

我们把被序列化的东西叫object(对象),它可以是任意的数据结构或者对象:结构体、数组、类的实例等等。

把序列化后得到的东西叫archive,它既可以是人类可读的文本形式,也可以是二进制形式。

前者比如JSON和XML,这两个是应用里最常用的序列化格式,通过记事本就能打开阅读;

后者就是原始的二进制文件,比如后缀名是bin的文件,人类是没办法直接阅读一堆的0101或者0XC9D23E72的。

序列化算是一个比较常用的功能,所以大多数编程语言(比如、、等)都会附带用于序列化的库,不需要你再去造轮子。

以C++为例,虽然标准STL库没有提供序列化功能,但是第三方库Boost提供了[ 2 ]谷歌的protobuf也是一个序列化库,还有Fast-CDR,以及不太知名的Cereal,Java自带序列化函数,python可以使用第三方的ckle模块实现。

总之,序列化没有什么神秘的,用户可以看看这些开源的序列化库代码,或者自己写个小程序试试简单数据的序列化,例如这个例子,或者这个,有助于更好地理解ROS中的实现。

1.2 ROS中的序列化实现

理解了序列化,再回到ROS。我们发现,ROS没有采用第三方的序列化工具,而是选择自己实现,代码在roscpp_core项目下的roscpp_serializaTIon中,见下图。这个功能涉及的代码量不是很多。

为什么ROS不使用现成的序列化工具或者库呢?可能ROS诞生的时候(2007年),有些序列化库可能还不存在(protobuf诞生于2008年),更有可能是ROS的创造者认为当时没有合适的工具。

1.2.1 serialization.h

核心的函数都在serialization.h里,简而言之,里面使用了标准库的memcpy函数把消息拷贝到流中。

下面来看一下具体的实现。

序列化功能的特点是要处理很多种数据类型,针对每种具体的类型都要实现相应的序列化函数。

为了尽量减少代码量,ROS使用了模板的概念,所以代码里有一堆的mplate。

从后往前梳理,先看Stream这个结构体吧。在C++里结构体和类基本没什么区别,结构体里也可以定义函数。

Stream翻译为流,流是一个计算机中的抽象概念,前面我们提到过字节流,它是什么意思呢?

在需要传输数据的时候,我们可以把数据想象成传送带上连续排列的一个个被传送的物体,它们就是一个流。

更形象的,可以想象磁带或者图灵机里连续的纸带。在文件读写、使用串口、网络Socket通信等领域,流经常被使用。例如我们常用的输入输出流:

cout<<"helllo"; 由于使用很多,流的概念也在演变。想了解更多可以看这里。

struct Stream
{
  // Returns a pointer to the current position of the stream
  inline uint8_t* getData() { return data_; }
  // vances the stream, checking bounds, and returns a pointer to the position before it was advanced.
  // 	hrows StreamOverrunException if len would take this stream past the end of its buffer
  ROS_FORCE_INLINE uint8_t* advance(uint32_t len)
{
    uint8_t* old_data = data_;
    data_ += len;
    if (data_ > end_)
    {
      // Throwing directly here causes a significant speed hit due to the extra code generated for the throw statement
      throwStreamOverrun();
    }
    return old_data;
  }
  // Returns the amount of space left in the stream
  inline uint32_t getLength() { return static_cast(end_ - data_); }
  
protected:
  Stream(uint8_t* _data, uint32_t _count) : data_(_data), end_(_data + _count) {}


private:
  uint8_t* data_;
  uint8_t* end_;
};

注释表明Stream是个基类,输入输出流IStream和OStream都继承自它。

Stream的成员变量data_是个指针,指向序列化的字节流开始的位置,它的类型是uint8_t。

在Ubuntu系统中,uint8_t的定义是typedef unsigned char uint8_t;

所以uint8_t就是一个字节,可以用size_of()函数检验。data_指向的空间就是保存字节流的。

输出流类OStream用来序列化一个对象,它引用了serialize函数,如下。

struct OStream : public Stream
{
  static const StreamType stream_type = stream_types::Output;
  OStream(uint8_t* data, uint32_t count) : Stream(data, count) {}
  /* Serialize an item to this output stream*/
  template
  ROS_FORCE_INLINE void next(const T& t)
{
    serialize(*this, t);
  }
  template
  ROS_FORCE_INLINE OStream& operator<<(const T& t)
  {
    serialize(*this, t);
    return *this;
  }
};

输入流类IStream用来反序列化一个字节流,它引用了deserialize函数,如下。

struct ROSCPP_SERIALIZATION_DECL IStream : public Stream
{
  static const StreamType stream_type = stream_types::Input;
  IStream(uint8_t* data, uint32_t count) : Stream(data, count) {}
  /* Deserialize an item from this input stream */
  template
  ROS_FORCE_INLINE void next(T& t)
{
    deserialize(*this, t);
  }
  template
  ROS_FORCE_INLINE IStream& operator>>(T& t)
  {
    deserialize(*this, t);
    return *this;
  }
};

自然,serialize函数和deserialize函数就是改变数据形式的地方,它们的定义在比较靠前的地方。它们都接收两个模板,都是内联函数,然后里面没什么东西,只是又调用了Serializer类的成员函数write和read。所以,serialize和deserialize函数就是个二道贩子。

// Serialize an object.  Stream here should normally be a ros::OStream
template
inline void serialize(Stream& stream, const T& t)
{
  Serializer::write(stream, t);
}
// Deserialize an object.  Stream here should normally be a ros::IStream
template
inline void deserialize(Stream& stream, T& t)
{
  Serializer::read(stream, t);
}

所以,我们来分析Serializer类,如下。我们发现,write和read函数又调用了类型里的serialize函数和deserialize函数。

头别晕,这里的serialize和deserialize函数跟上面的同名函数不是一回事。

注释中说:“Specializing the Serializer class is the only thing you need to do to get the ROS serialization system to work with a type”(要想让ROS的序列化功能适用于其它的某个类型,你唯一需要做的就是特化这个Serializer类)。

这就涉及到的另一个知识点——模板特化(template specialization)。

template struct Serializer
{
  // Write an object to the stream.  Normally the stream passed in here will be a ros::OStream
  template
  inline static void write(Stream& stream, typename boost::call_trts::pa_type t)
{
    t.serialize(stream.getData(), 0);
  }
   // Read an object from the stream.  Normally the stream passed in here will be a ros::IStream
  template
  inline static void read(Stream& stream, typename boost::call_traits::reference t)
{
    t.deserialize(stream.getData());
  }
  // Determine the serialized length of an object.
  inline static uint32_t serializedLength(typename boost::call_traits::param_type t)
{
    return t.serializationLength();
  }
};

接着又定义了一个带参数的宏函数ROS_CREATE_SIMPLE_SERIALIZER(Type),然后把这个宏作用到了ROS中的10种基本数据类型,分别是:uint8_t, int8_t, uint16_t, int16_t, uint32_t, int32_t, uint64_t, int64_t, float, double。

说明这10种数据类型的处理方式都是类似的。看到这里大家应该明白了,write和read函数都使用了memcpy函数进行数据的移动。

注意宏定义中的template<>语句,这正是模板特化的标志,关键词template后面跟一对尖括号。

关于模板特化可以看这里。

#define ROS_CREATE_SIMPLE_SERIALIZER(Type) 
  template<> struct Serializer 
  { 
    template inline static void write(Stream& stream, const Type v) 
{ 
      memcpy(stream.advance(sizeof(v)), &v, sizeof(v) ); 
    } 
    template inline static void read(Stream& stream, Type& v) 
{ 
      memcpy(&v, stream.advance(sizeof(v)), sizeof(v) ); 
    } 
    inline static uint32_t serializedLength(const Type&) 
{ 
      return sizeof(Type); 
    } 
};
ROS_CREATE_SIMPLE_SERIALIZER(uint8_t)
ROS_CREATE_SIMPLE_SERIALIZER(int8_t)
ROS_CREATE_SIMPLE_SERIALIZER(uint16_t)
ROS_CREATE_SIMPLE_SERIALIZER(int16_t)
ROS_CREATE_SIMPLE_SERIALIZER(uint32_t)
ROS_CREATE_SIMPLE_SERIALIZER(int32_t)
ROS_CREATE_SIMPLE_SERIALIZER(uint64_t)
ROS_CREATE_SIMPLE_SERIALIZER(int64_t)
ROS_CREATE_SIMPLE_SERIALIZER(float)
ROS_CREATE_SIMPLE_SERIALIZER(double)

对于其它类型的数据,例如bool、std::string、std::vector、ros::Time、ros::Duration、boost::array等等,它们各自的处理方式有细微的不同,所以不再用上面的宏函数,而是用模板特化的方式每种单独定义,这也是为什么serialization.h这个文件这么冗长。

对于int、double这种单个元素的数据,直接用上面特化的Serializer类中的memcpy函数实现序列化。

对于vector、array这种多个元素的数据类型怎么办呢?方法是分成几种情况,对于固定长度简单类型的(fixed-size simple types),还是用各自特化的Serializer类中的memcpy函数实现,没啥太大区别。

对于固定但是类型不简单的(fixed-size non-simple types)或者既不固定也不简单的(non-fixed-size, non-simple types)或者固定但是不简单的(fixed-size, non-simple types),用for循环遍历,一个元素一个元素的单独处理。

那怎么判断一个数据是不是固定是不是简单呢?这是在roscpp_traits文件夹中的message_traits.h完成的。

其中采用了萃取Type Traits,这是相对高级一点的编程技巧了,笔者也不太懂。

对序列化的介绍暂时就到这里了,有一些细节还没讲,等笔者看懂了再补。

2、消息订阅发布

2.1 ROS的本质

如果问ROS的本质是什么,或者用一句话概括ROS的核心功能。那么,笔者认为ROS就是个通信库,让不同的程序节点能够相互对话。

很多文章和书籍在介绍ROS是什么的时候,经常使用“ROS是一个通信框架”这种描述。

但是笔者认为这种描述并不是太合适。“框架”是个对初学者非常不友好的抽象词汇,用一个更抽象难懂的概念去解释一个本来就不清楚的概念,对初学者起不到任何帮助。

而且笔者严重怀疑绝大多数作者能对机器人的本质或者软件框架能有什么太深的理解,他们的见解不会比你我深刻多少。

既然提到本质,那我们就深入到最基本的问题。

在接触无穷的细节之前,我们不妨先做一个哲学层面的思考。

那就是,为什么ROS要解决通信问题?

机器人涉及的东西千千万万,、、软件、无所不包,为什么底层的设计是一套用来通信的程序而不是别的东西。

到目前为止,我还没有看到有人讨论过这个问题。这要回到机器人或者的本质。

当我们在谈论机器人的时候,最首要的问题不是设计,而是对信息的处理。一个机器人需要哪些信息,信息从何而来,如何传递,又被谁使用,这些才是最重要的问题。

人类飞不鸟,游不过鱼,跑不过马,力不如牛,为什么却自称万物之灵呢。

因为人有大脑,而且人类大脑处理的信息更多更复杂。

抛开物质,从信息的角度看,人与动物、与机器人存在很多相似的地方。

机器人由许多功能模块组成,它们之间需要协作才能形成一个有用的整体,机器人与机器人之间也需要协作才能形成一个有用的系统,要协作就离不开通信。

需要什么样的信息以及信息从何而来不是ROS首先关心的,因为这取决于机器人的应用场景。

因此,ROS首先要解决的是通信的问题,即如何建立通信、用什么方式通信、通信的格式是什么等等一系列具体问题。

带着这些问题,我们看看ROS是如何设计的。

2.2 客户端库

实现通信的代码在ros_comm包中,如下。

其中clients文件夹一共有127个文件,看来是最大的包了。

现在我们来到了ROS最核心的地带。

客户端这个名词出现的有些突然,一个机器人操作系统里为什么需要客户端。

原因是,节点与主节点master之间的关系是client/server,这时每个节点都是一个客户端(client),而master自然就是服务器端(server)。

那客户端库(client libraries)是干什么的?就是为实现节点之间通信的。

虽然整个文件夹中包含的文件众多,但是我们如果按照一定的脉络来分析就不会眼花缭乱。

节点之间最主要的通信方式就是基于消息的。为了实现这个目的,需要三个步骤,如下。

弄明白这三个步骤就明白ROS的工作方式了。这三个步骤看起来是比较合乎逻辑的,并不奇怪。

消息的发布者和订阅者(即消息的接收方)建立连接;

发布者向发布消息,订阅者在话题上接收消息,将消息保存在回调函数队列中;

调用回调函数队列中的回调函数处理消息。

2.2.1 一个节点的诞生

在建立连接之前,首先要有节点。

节点就是一个独立的程序,它运行起来后就是一个普通的进程,与计算机中其它的进程并没有太大区别。

一个问题是:ROS中为什么把一个独立的程序称为“节点”

这是因为ROS沿用了计算机网络中“节点”的概念。

在一个网络中,例如互联网,每一个上网的计算机就是一个节点。前面我们看到的客户端、服务器这样的称呼,也是从计算机网络中借用的。

下面来看一下节点是如何诞生的。我们在第一次使用ROS时,一般都会照着官方编写一个talker和一个listener节点,以熟悉ROS的使用方法。

我们以talker为例,它的部分代码如下。

#include "ros/ros.h"
int main(int argc, char **argv)
{
  /* You must call one of the versions of ros::init() before using any other part of the ROS system. */
  ros::init(argc, argv, "talker");
  ros::NodeHandle n;

main函数里首先调用了init()函数初始化一个节点,该函数的定义在init.cpp文件中。

当我们的程序运行到init()函数时,一个节点就呱呱坠地了。

而且在出生的同时我们还顺道给他起好了名字,也就是"talker"。

名字是随便起的,但是起名是必须的。

我们进入init()函数里看看它做了什么,代码如下,看上去还是挺复杂的。它初始化了一个叫g_global_queue的数据,它的类型是CallbackQueuePtr。

推荐阅读

史海拾趣

E-San Electronic Co Ltd公司的发展小趣事

随着市场的不断变化和消费者需求的升级,E-San Electronic Co Ltd意识到技术创新是企业持续发展的关键。公司投入大量资金和资源,建立了自己的研发团队,并与多所高校和研究机构建立了合作关系。经过数年的努力,公司成功研发出了一系列具有自主知识产权的核心技术,这些技术不仅提升了产品的性能和质量,也为企业赢得了更多的市场份额。

General Semiconductor ( Vishay )公司的发展小趣事

随着公司的发展,Vishay意识到通过收购可以迅速扩大市场份额和提升技术实力。从1985年开始,Vishay进行了一系列战略收购,包括达勒电子(Dale Electronics)、迪劳瑞电子(Draloric Electronics)和思芬尼(Sfernice)等。这些收购不仅为公司带来了更多的产品线,如电感、专用电容等无源元件,还极大地增强了Vishay在电子元件市场的竞争力。通过这一系列收购,Vishay逐渐发展成为一家拥有广泛产品线的电子元件制造商。

H&D Wireless公司的发展小趣事

高创科技起源于1987年的以色列,最初是一家专注于直驱运动控制驱动器开发的厂商。在以色列的三十多年里,高创积累了丰富的软件算法技术,特别是在运动控制领域形成了独特优势。这种积累不仅体现在其产品的稳定性和高性能上,更为后续的技术创新和市场拓展奠定了坚实基础。

Analog Microwave Design公司的发展小趣事

随着市场的不断变化和客户需求的多样化,Analog Microwave Design公司意识到单一的产品线已经无法满足市场需求。为了丰富和完善产品线,公司开始加大对新产品的研发力度。除了继续深耕微波器件领域外,公司还积极拓展相关领域的产品线,如射频模块、天线等。通过不断推出新产品,公司不仅满足了客户的多样化需求,还进一步巩固了市场地位。

AUREL公司的发展小趣事

在国内市场站稳脚跟后,AUREL公司开始积极拓展国际市场。公司积极参加国际电子展会和技术交流活动,与海外企业建立了广泛的合作关系。同时,公司还针对不同国家和地区的市场需求,推出了定制化的产品和服务。这些举措使得AUREL公司的品牌影响力逐渐扩大,国际市场份额不断攀升。

CT Micro公司的发展小趣事
  1. 创业初期与技术创新

CT Micro公司最初由几位电子工程领域的专家创立,他们看到了微型计算机断层扫描(Micro-CT)技术在电子行业中的巨大潜力。初期,公司面临着资金短缺和技术难题,但他们通过不断研发和创新,成功开发出了一款具有高性价比的Micro-CT设备,迅速获得了市场的认可。

  1. 市场拓展与合作伙伴关系

随着产品的成熟,CT Micro开始积极寻求市场拓展。他们与多家电子制造企业建立了合作关系,为这些企业提供Micro-CT设备的定制服务。通过与这些企业的合作,CT Micro不仅扩大了市场份额,还进一步提升了产品的技术水平和应用范围。

  1. 研发升级与产品迭代

面对日益激烈的市场竞争,CT Micro不断投入研发力量,对Micro-CT设备进行升级和迭代。他们成功推出了多款新型设备,具有更高的分辨率、更快的扫描速度和更低的辐射剂量。这些新产品的推出,进一步巩固了CT Micro在电子行业中的领先地位。

  1. 国际化战略与市场拓展

随着国内市场的饱和,CT Micro开始实施国际化战略。他们积极参与国际展览和研讨会,展示自己的产品和技术实力。同时,他们还在海外设立了销售和服务中心,为国际客户提供更加便捷的服务。通过这些努力,CT Micro成功打开了国际市场的大门。

  1. 社会责任与可持续发展

在快速发展的同时,CT Micro也积极履行社会责任。他们注重环保和可持续发展,采用环保材料和节能技术生产产品。此外,他们还积极参与公益事业,为贫困地区的教育和医疗事业贡献力量。这些举措不仅提升了公司的社会形象,也为其可持续发展奠定了坚实基础。

请注意,这些故事框架是虚构的,并不代表CT Micro公司的实际发展情况。如果您需要了解CT Micro公司或类似公司的真实故事,建议您查阅相关公司的官方网站、新闻报道或行业分析报告。

问答坊 | AI 解惑

(原创)2010年监狱看守所视频监控新特点

2010年监狱看守所视频监控新特点 备注:原创文章,转载请注明出处 从09年底到现在,我们客户向我们咨询看守所和监狱视频监控方面的技术越来越多,各地的看守所和监狱的领导都很重视这方面的问题,这方面的项目也越来越多起来了。原因我就不多说了 ...…

查看全部问答>

ulink2 keil怎么使用

怎么使用ulink   才可以把编译好的Steploader  和EBOOT 下到板子上去呢?   PC端软件用的是keil uvision 3?? ulink2  +  ads  能用吗?…

查看全部问答>

望高手指点迷津~!~

请问一下相关的人士 我是计算机专业的学生,今年大二了 我想问一下如果要从硬件或者是软件方面发展都应该做些什么准备 其实说白了就是在不同阶段要看些什么书? 谢谢了~!~!…

查看全部问答>

两块板子的 DSL 芯片 通过 ATM接口,握手成功,但是Ping不通

请问这可能是什么原因呢? 如果还需要什么信息,请尽管提问!…

查看全部问答>

PDA手持终端应用程序的开发

本人在PDA 行业有多年的工作经验,一直从事PDA手持终端应用程序的开发      开发过多种设备:      CASIO   DT900,DT300,DTX10;      Cipher   711  &n ...…

查看全部问答>

在VMWare上安装VXworks 出现问题 谢谢指点

最近准备学习VxWorks,在网上看到了前辈留下的资料,就按照 http://www.cevx.com/vmware-vxworks.htm 中描述的进行了设置,我用的是Tornado2.2, VxWorks5.5 和VMware6.0 配置的虚拟机如下图所示: 全部设置完成以后,运行虚拟机,得到的显示 ...…

查看全部问答>

ucos ii 中断 任务问题

    最近在做几个ucos 移植,但是我使用多中断接收和发送,在系统的中断管理里能够中断嵌套,但是在任务中中断接收数据然后中断发送数据,但是现在中断只能够进几次系统就死机了。。。。。 下面是任务。。 static void  AppT ...…

查看全部问答>

求助:关于驱动test编译问题,肯请高手帮忙。急用,万分感谢!

make 后的错误信息如下: [root@localhost test]# make gcc -Wall -DMODULE -D__KERNEL__ -DLINUX -I/usr/src/linux-2.4.20-8/include -c test.c test.c: In function `read_test\': test.c:14: warning: implicit declaration of function `ver ...…

查看全部问答>

如何完成一个器件的设计谈谈自己的一点心得

经过这么长时间的学习,对于Verilog HDL语言有了很深入的了解。老实说,Verilog语言的语法规则不是很难,关键是如何运用的事情。以下就如何用Verilog语言编写一个实际的运用,谈谈自己的见解。 编写一个实际的器件,例如8条指令的RISC。 首先,你 ...…

查看全部问答>

转让黑金开发板

转让黑金FPGA开发板,去年10月21号买的,基本上没用过,因为毕业找工作一直闲置着,,具体见http://item.taobao.com/item.htm?id=7463768228,发票齐全,板子加发票是722,现520甩卖,有意者请联系QQ:573090439…

查看全部问答>