65.9K
CodeProject 正在变化。 阅读更多。
Home

在 C++ 中实现二进制通信协议(适用于嵌入式系统)

starIconstarIconstarIconstarIconstarIcon

5.00/5 (12投票s)

2019年3月5日

CPOL

17分钟阅读

viewsIcon

26278

在 C++11 中轻松实现编译时可配置的二进制通信协议

引言

本文介绍使用 C++11 编程语言实现二进制通信协议的简单可编译时配置的方法,主要侧重于嵌入式系统(包括裸机系统)。

感兴趣吗?那么请坐稳,继续阅读。

背景

如今,几乎所有电子设备/组件都需要通过某种 I/O 链路与其他设备、组件或外部世界进行通信。这种通信通过各种通信协议实现,而这些协议臭名昭著地需要编写大量的样板代码。实现这些协议可能是一个繁琐、耗时且容易出错的过程。因此,开发人员越来越倾向于使用第三方代码生成器进行数据(反)序列化。通常,这类工具接收在具有自定义语法的单独源文件(或文件集)中描述的协议数据结构,并生成相应的(反)序列化代码以及访问数据所需的抽象。

现有工具的主要问题在于,它们的主要目的是数据结构序列化和/或远程过程调用 (RPC) 的促进。二进制数据布局以及传输数据的使用方式则重要得多。这类工具侧重于数据序列化和 I/O 链路传输的速度而不是对格式错误数据的安全处理、嵌入式系统所需的编译时自定义以及显著减少集成生成代码到产品代码库所需的样板代码量。

另一方面,二进制通信协议(可以作为设备的 API 或控制接口)则需要不同的方法。它们的规范高度重视二进制数据布局、传输的值(数据单位、缩放系数、特殊值等)以及另一端对特定值的预期行为(哪些值被认为是有效的,以及如何处理无效值)。这要求在描述的数据结构中附加一些额外的元信息,这些信息需要在生成的代码中传播并可访问。现有工具要么无法指定此类元信息(除了注释),要么不知道如何处理它们。结果是,开发人员仍然需要编写大量的样板代码,以便将生成的以序列化为中心的代码集成到二进制通信协议处理中使用。

所需功能

作为一名嵌入式 C++ 开发人员,我至少在一定程度上需要以下功能。

多态接口配置

通常,每个消息定义都被实现(或代码生成)为单独的类。在许多情况下,存在适用于所有此类消息类的通用代码。正确的实现方式是引入多态行为,使用虚函数(例如,读取消息数据、写入消息数据、计算序列化长度等)。然而,创建完全多态的接口可能不切实际。可能使用协议定义代码的每个应用程序都不同。例如,如果许多消息是单向的,一端(客户端)将需要这些消息的多态写入(但不读取),而另一端(服务器)则需要相反的操作——多态读取(但不写入)。大多数虚函数即使未使用,最终也会包含在最终二进制/镜像中。这可能导致不必要的代码膨胀,这对于嵌入式系统(尤其是裸机系统)来说可能是一个问题。

需要对将要使用的多态接口进行编译时,或至少代码生成时配置。在最佳情况下,这种配置应该针对每个消息类进行。

额外元信息

协议定义可能附带大量的额外元信息。例如,消息中的一个协议字段需要报告两点之间的距离。协议设计者决定以厘米为单位报告距离。这类信息可能以明文形式写在协议规范中,如果使用第三方代码生成器,则此类信息可能会出现在描述字段或消息的注释部分。然而,在大多数情况下,此类信息不会出现在生成的代码中。需要将生成代码集成到其业务逻辑中的开发人员需要手动编写一些样板代码,以执行从一种距离单位(例如,对于正在开发的应用程序相关的)到另一种距离单位的转换计算。

在开发应用程序的同时指定二进制协议的情况也并非不常见。设想在开发过程中出现了一个特定的用例,其中以厘米为单位的距离不足以提供足够的精度。由于协议规范尚未最终确定,开发人员决定将报告的距离单位从厘米更改为毫米。这意味着可能已经编写了其样板代码并假定距离单位为厘米的其他开发人员必须意识到这一更改,并且不要忘记修改其代码。

许多协议设计者也不喜欢“按原样”在通信链路上发送浮点数。在许多此类情况下,浮点数会乘以某个预定义值,然后作为整数通过 I/O 链路发送,而小数点后的余数将被丢弃。接收方在接收消息时执行相反的操作,即除以相同的预定义值以获得浮点数。用于此类缩放的预定义值也是元信息,它不会“在网络上传输”,并且应该存在于协议定义生成的或手动编写的代码中。

此外,在许多情况下,协议可能会定义一些具有特殊含义的值。假设需要以秒为单位通信某个事件发生前的延迟。应该有一个特殊值表示无限持续时间。通常是0或所用无符号类型的最大可能值。将生成代码集成到应用程序中的开发人员需要知道这个值是什么。需要编写至少一个额外的样板代码块来封装特殊值并为其命名。如果生成的代码已经包含这样的辅助函数,那不是更好吗?

许多协议指定了有效值的范围,并期望对无效值有特定的行为。人们期望生成的代码能够提供查询接收值是否有效的机制,以避免手动编写检查此信息的样板代码。

此类包含协议定义中元信息但未作为消息数据传输的示例列表可以无限列举。需要生成的代码为集成开发人员提供所需的功能,而无需担心修改元信息时该怎么做。

数据类型自定义

目前大多数协议代码生成解决方案都使用硬编码的类型来表示某些特定数据结构,例如字符串使用 std::string,列表使用 std::vector。此外,许多情况下,执行序列化/反序列化的生成函数将其输入/输出接收为 std::istreamstd::ostream。这些数据结构可能不适合某些应用程序,尤其是嵌入式(包括裸机)应用程序。

需要能够用等效的替换项替换默认数据结构,最好是在编译时针对选定的字段/消息进行,但全局(例如在代码生成期间)也可能是可接受的解决方案。

排除异常和动态内存分配

许多受限的嵌入式环境使得使用异常以及动态内存分配(尤其是裸机环境)变得困难。需要一种生成不使用这两种机制的协议定义代码的能力。

消息 ID 到类型的有效内置映射

通常,当新的编码消息通过 I/O 链路到达时,它以原始数据形式编码,包含一些包含数字消息 ID 的传输帧。不幸的是,大多数(如果不是全部)可用的协议代码生成解决方案都将数字 ID 映射到实际消息类型(或适当的处理函数)的任务留给集成生成代码到业务逻辑中的开发人员来编写。通常,这种代码是样板代码,其效率取决于开发人员的能力。

第三方协议支持

许多可用的协议生成解决方案都有自己的编码和帧格式,而无法修改。使用这类工具可能适用于从头开始定义的新协议。然而,已经定义了大量的第三方协议,其代码无法用这类工具生成。

注入自定义代码

此要求是对“第三方协议支持”的补充。即使选择的代码生成解决方案支持第三方协议的定义,并且其模式文件的语法非常丰富,总会有一些协议包含细微的差别,无法使用可用语法正确表示。因此,生成的代码可能不正确和/或不完整。适当的协议代码生成解决方案应允许注入自定义手动编写的代码片段。

解决方案

不幸的是,我找不到任何第三方解决方案实现了上述大部分所需功能。我别无选择,只能自己实现。我想向您介绍 CommsChampion Ecosystem,它实现了所有前面提到的功能。它作为我的业余项目已经开发了好几年。现在它拥有稳定的 API,并已准备好供更广泛的公众使用。下面,我将再次回顾所需功能列表,并举例说明我的解决方案如何提供所需的功能。

首先,有一个仅限头文件、跨平台且非常灵活的 COMMS 库,它允许使用简单的声明性语句定义类型和类来实现在协议消息、字段和传输帧。这类语句定义了需要实现什么,库内部处理如何实现的部分。 COMMS 库的内部主要是模板,高度可编译时配置的类,利用了多种元编程技术。结果是 C++ 编译器本身成为了代码生成工具,它只引入应用程序所需的功能,提供了最佳的代码大小和速度性能。

在库之后,又有了测试工具,它允许分析、调试和可视化使用 COMMS 库实现的协议。测试工具是通用的,为所有协议提供了一个通用的测试环境。所有应用程序都是基于插件的,即使用插件来定义 I/O 套接字、数据过滤器以及自定义协议本身。这种架构允许轻松组装各种协议通信栈。该工具使用 Qt5 框架进行 GUI 界面以及加载和管理插件。它们旨在在开发 PC 上使用,并且不是 COMMS 库的一部分,尽管它们托管在同一个存储库中。

随着时间的推移,COMMS 库的功能不断增加,需要更多的认知努力来记住事物。更容易出错和/或以一种不那么通用方式实现协议。结果是,又有了代码生成器。它允许使用简单的 基于 XML 的领域特定语言 (DSL) 来定义协议,并生成协议定义(仅限头文件)库(该库反过来使用前面提到的 COMMS 库的类型和类),以及构建用于 测试工具的协议插件所需的代码。

现在,让我们再次回顾前面提到的所需功能,并查看 CommsChampion Ecosystem 提供的解决方案。

多态接口配置

协议实现库以如下方式定义了所有消息的通用接口类。注意:所有来自 COMMS 库的类型和类都位于 comms 命名空间中,而下面示例中自定义协议的定义将位于 my_prot 命名空间中,使用协议定义的应用程序代码将位于全局命名空间。

namespace my_prot
{

// Numeric IDs of the protocol messages
enum MsgId : std::uint8_t
{
    MsgId_Message1,
    MsgId_Message2,
    MsgId_Message3,
    ...
};

// Common interface class for the protocol messages
template <typename... TOptions>
using Interface = 
    comms::Message<
        comms::option::MsgIdType<MsgId>,
        TOptions...
    >;
    
} // namespace my_prot

默认定义将 comms::option::MsgIdType 传递给 comms::Message(由 COMMS 库提供的类)。它用于定义用于存储消息 ID 的类型。上面的代码等同于定义为

namespace my_prot
{

class Interface
{
public:
    // Type used for message ID
    typedef MsgId MsgIdType;
};
    
} // namespace my_prot

通用接口类的定义使用了可变参数模板,这些模板用于传递各种默认功能扩展选项COMMS 库的内部解析提供的选项并生成额外的请求功能。例如,添加自定义应用程序所需的多态消息 ID 检索可能如下所示

using MyAppInterface = 
    my_prot::Interface<
        comms::option::IdInfoInterface // Add extra polymorphic ID retrieval interface
    >;

上面的代码等同于以下定义

class MyAppInterface
{
public:
    // Type used for message ID (defined by using comms::option::MsgIdType option)
    typedef MsgId MsgIdType;
    
    
    // NVI of polymorphic retrieval of ID 
    // (defined by using comms::option::IdInfoInterface option)
    MsgIdType getId() const
    {
        return getIdImpl();
    }
    
protected:
    // Polymorphic retrieval of ID (defined by using comms::option::IdInfoInterface option)
    virtual MsgIdType getIdImpl() const = 0; // implemented in derived class
};

请注意,COMMS 库使用 非虚接口惯用法 (NVI) 来定义多态行为。非虚接口包装器函数用于检查虚函数可能需要的各种前置条件和后置条件。

COMMS 库提供了多种扩展选项,允许定义各种其他多态函数。下面是(当前的)完整列表

using MyAppInterface = 
    my_prot::Interface<
        comms::option::IdInfoInterface,                  // Add polymorphic ID 
                                                         // retrieval interface
        comms::option::ReadIterator<const std::uint8_t*>,// Add polymorphic read interface
        comms::option::WriteIterator<std::uint8_t*>,     // Add polymorphic write interface
        comms::option::LengthInfoInterface,              // Add polymorphic serialization 
                                                         // length retrieval
        comms::option::ValidCheckInterface,              // Add polymorphic contents 
                                                         // validity check
        comms::option::Handler<MyHandler>,               // Add polymorphic dispatch to 
                                                         // handling function interface
        comms::option::NameInterface                     // Add polymorphic retrieval of 
                                                         // message name
    >;

对上面列出的每个选项的详细解释超出了本文的范围。COMMS 库拥有非常详细的文档,列出并解释了其中的每一个。扩展选项背后的魔力以及它们如何最终生成额外的类型和函数也超出了本文的范围。我在免费电子书中详细解释了这一点,我称之为 C++ 通信协议实现指南

实际消息的定义可能如下所示

namespace my_prot
{

// Definition of the fields used by Message1
struct Message1Fields
{
    // 32 bit unsigned integer, initialized to 0
    using F1 = 
        comms::field::IntValue<
            comms::Field<comms::option::BigEndian>, // use big endian serialization
            std::uint32_t
        >;
        
    // 16 bit signed integer, initialized to 10
    using F2 = 
        comms::field::IntValue<
            comms::Field<comms::option::BigEndian>, // use big endian serialization
            std::int16_t,
            comms::option::DefaultNumValue<10>
        >;       
    
    // All the message fields bundled in std::tuple.
    using All = std::tuple<F1, F2>;
};

// Definition of the actual Message1.
// Template parameter TMsgBase is common interface class required by the application
template <typename TMsgBase>
class Message1 : public
    comms::MessageBase<
        TMsgBase,
        comms::option::StaticNumIdImpl<MsgId_Message1>, // Set message ID known at compile time
        comms::option::FieldsImpl<Message1Fields::All>, // Define fields of the message
        comms::option::MsgType<SimpleInts<TMsgBase, TOpt> > // Pass the actual message type 
                                                            // being defined
    >
{

public:
    ... // some small irrelevant at this moment code here
};

} // namespace demo1

实际自定义协议消息 my_prot::Message1 的定义是完全通用的。它将应用程序特定接口定义类作为其模板参数,并且根据所需的多态接口,comms::MessageBase(由 COMMS 库提供)会完成所有确定所需多态接口并实现必要虚函数的工作,同时继承提供的接口类。如何实现确定所需多态功能的这种魔力超出了本文的范围,并且在我的免费 C++ 通信协议实现指南 电子书中也有详细介绍。

因此,此类在实际应用程序中的用法可能如下所示

// Define Message1 relevant to the application
using MyMessage1 = my_prot::Message1<MyAppInterface>;

// Definition of smart pointer holding any protocol message
using MyMessagePtr = std::unique_ptr<MyAppInterface>;

// Holding Message1 by the pointer to its interface
MyMessagePtr msg(new MyMessage1);

// Retrieving serialization length, works only if comms::option::LengthInfoInterface 
// was passed as option to interface definition, if not compilation will fail.
std::size_t msgLen = msg->length();
assert(msgLen == 6U); // 4 byte of f1 + 2 bytes of f2

开发人员需要编写代码将协议定义集成到应用程序的业务逻辑中,这非常简单。所有复杂性都由 C++ 编译器在后台处理。但是,为协议定义编写通用代码可能需要更多的认知努力,并需要对 COMMS 库内部有一些了解。这就是为什么开发了独立的 代码生成器,它以模式文件作为输入,并生成易于自定义和使用的通用协议定义。

上面示例中 Message1 消息的定义在 CommsDSL 模式中可能如下所示

<?xml version="1.0" encoding="UTF-8"?>
<schema name="my_prot" endian="big">
    <fields>
        <enum name="MsgId" type="uint8">
            <validValue name="Message1" val="0" />
            <validValue name="Message2" val="1" />
            ...
        </enum>
    </fields>
    <message name="Message1" id="MsgId.Message1">
        <int name="f1" type="uint32" />
        <int name="f2" type="int16" defaultValue="10"/>
    </message>
    ...
</schema>

从此模式生成的协议定义代码将类似于上面的代码示例。

额外元信息

COMMS 库内置了对各种单位及其之间转换的支持。为了遵循上面关于距离的例子,这样的字段可以定义如下

namespace my_prot
{

using Distance = 
    comms::field::IntValue<
        comms::Field<comms::option::BigEndian>,       // Big endian serialization
        std::uint32_t,                                // 4 bytes serialization
        comms::option::UnitsCentimeters               // Contains centimeters
    >;

} // namespace my_prot

当反序列化此字段并需要以为单位的值时,可以使用 COMMS 库提供的单位检索功能。

my_prot::Distance distanceField = ...; // Some value assigned
double distanceInMeters = comms::units::getMeters<double>(distanceField);

如果字段的定义需要更改为包含毫米而不是,则客户端代码无需更改,只需重新编译即可。编译器将更改生成的数学运算以在单位之间进行转换。

COMMS 库还包含对不兼容单位之间转换的编译时保护。例如,不能从包含毫米的字段中检索,将出现带有适当错误消息的 static_assert 失败。

CommsDSL 模式的语言也包含指定单位的能力,这将导致使用适当的选项传递给生成的字段定义。

<?xml version="1.0" encoding="UTF-8"?>
<schema name="my_prot" endian="big">
    <fields>
        <int name="Distance" type="uint32" units="cm" />        
    </fields>
    ...
</schema>

COMMS 库CommsDSL 都支持缩放浮点值并将它们序列化为整数。这类字段被定义为具有缩放系数选项的整数。

namespace my_prot
{

using ScaledField = 
    comms::field::IntValue<
        comms::Field<comms::option::BigEndian>,       // Big endian serialization
        std::int32_t,                                 // 4 bytes serialization
        comms::option::ScalingRatio<1, 1000>          // Divide by 1000 to get 
                                                      // floating point value
    >;

} // namespace my_prot    

从此类字段获取/设置浮点值如下所示

my_prot::ScaledField scaledField;
scaledField.setScaled(1.23f);
float val = scaledField.getScaled<float>();

此类字段在 CommsDSL 中的定义可能如下所示

<?xml version="1.0" encoding="UTF-8"?>
<schema name="my_prot" endian="big">
    <fields>
        <int name="ScaledField" type="int32" scaling="1/1000" />        
    </fields>
    ...
</schema>

单位和缩放的组合也是可能的。让我们定义以1/10 毫米为单位的距离。

namespace my_prot
{

using NewDistance = 
    comms::field::IntValue<
        comms::Field<comms::option::BigEndian>, 
        std::uint32_t,                          
        comms::option::UnitsMilliimeters,
        comms::option::ScalingRatio<1, 10>        
    >;
    
} // namespace my_prot   

此类字段的 CommsDSL 定义如下所示

<?xml version="1.0" encoding="UTF-8"?>
<schema name="my_prot" endian="big">
    <fields>
        <int name="NewDistance" type="int32" scaling="1/10" units="mm" />        
    </fields>
    ...
</schema>

从此类字段检索以米为单位的距离仍然相同,编译器生成适当的数学运算,同时考虑单位和缩放系数。

my_prot::NewDistance distanceField = ...; // Some value assigned
double distanceInMeters = comms::units::getMeters<double>(distanceField);

CommsDSL 模式中也支持指定特殊值。例如,定义持续时间(秒),其中值 0 表示无限

<?xml version="1.0" encoding="UTF-8"?>
<schema name="my_prot" endian="big">
    <fields>
        <int name="Duration" type="uint32" units="sec" >        
            <special name="Infinite" val="0" />
        </int>
    </fields>
    ...
</schema>

为此字段生成的代码将包含适当的成员函数来帮助检查特殊值。

namespace my_prot
{

struct Duration : public 
    comms::field::IntValue<
        comms::Field<comms::option::BigEndian>, 
        std::uint32_t,                          
        comms::option::UnitsSeconds
    >
{
    static constexpr std::uint32_t valueInfinite() { return 0U; }
    bool isInfinite() const { /* checks that the stored value is 0U */ }
    void setInfinite() { /* assigns 0 to the stored value */ }
};

} // namespace my_prot

也支持指定有效值范围。例如

<?xml version="1.0" encoding="UTF-8"?>
<schema name="my_prot" endian="big">
    <fields>
        <int name="SomeField" type="uint8" >        
            <validRange value="[0, 20]" />
            <validRange value="[40, 60]" />
            <validValue value="70" />                        
        </int>
    </fields>
    ...
</schema>

上面的示例定义了一个 1 字节的无符号值字段,其中 020 之间、4060 之间以及单个值 70 都被认为是有效的。

通过 COMMS 库定义的每个字段都有 valid() 成员函数,可用于查询字段是否包含有效值。上面的示例将导致 valid() 成员函数具有适当的功能。对于 020 之间、4060 之间以及值 70 的任何值,它都将返回 true。所有其他值将返回 false

my_prot::SomeField field = ...; // some value assigned
if (!field.valid()) {
    ... // do something
}

数据类型自定义

COMMS 库在设计和实现时内置了对可能存在问题的特定数据结构和/或默认行为的自定义能力。尽管字符串和列表的存储类型的默认选择是 std::stringstd::vector,但在编译时配置中可以将其替换为其他内容。例如,某个 string 字段可以定义如下

namespace my_prot
{

template <typename... TOptions>
using SomeString = 
    comms::field::String<
        comms::Field<comms::option::bigendian>,
        TOptions... // Extra configuration options
    >;
    
} // namespace my_prot</comms::option::bigendian>

string 字段的定义允许传递额外的选项,这些选项可供应用程序使用。例如,如果应用程序是为不支持动态内存分配的裸机平台开发的,建议传递 comms::option::FixedSizeStorage 选项,以用 COMMS 库本身提供的固定最大大小的字符串容器实现(称为 comms::util::StaticString)来替换 std::string 的使用。

using MyAppString = my_prot::SomeString<comms::option::FixedSizeStorage<32> >;

也可以使用任何具有与 std::string 相同的 public 接口的自定义第三方存储类型。

using MyAppString = 
      my_prot::SomeString<comms::option::CustomStorageType<boost::container::string> >;

代码生成器能够(并且默认这样做)生成协议定义代码,该代码允许对各种可能存在问题的类型进行此类编译时自定义。

排除异常和动态内存分配

COMMS 库是专门为资源受限的环境设计的。它不使用 RTTI 和/或异常:通过断言检查前置条件和后置条件违反,而运行时错误通过返回的状态码报告。

当新的序列化消息通过 I/O 链路到达时,并且创建了相应的消息对象,默认情况下会使用动态内存分配。但是,有一个可用的编译时配置选项可以用“就地”分配替换动态内存分配(在预分配的区域上使用 placement new)。

消息 ID 到类型的有效内置映射

COMMS 库提供了多种内置选项和辅助函数,可以有效地将消息 ID 映射到适当的类型,以及将消息对象分派到其适当的处理类型。它们在可用文档中有详细的描述和记录。

第三方协议支持

COMMS 库的设计宗旨就是提供构建块并促进第三方协议的实现。其架构允许轻松地通过协议特定的细微差别来补充默认行为。

注入自定义代码

可用的 代码生成器还允许注入自定义代码片段,以替代或补充默认可能生成的代码。

摘要

CommsChampion Ecosystem 的主要目标受众是嵌入式 C++ 开发人员,他们必须实现第三方(或自己的)协议以与各种传感器、其他设备或外部世界进行通信。非嵌入式 C++ 开发人员,他们不必担心受限环境并且不需要复杂的代码自定义,由于提供了减少集成样板代码量的构造,仍然会发现提供的解决方案方便实用。

那些已经将某些协议的实现集成到其产品中的开发人员,仍然可以使用可用的 代码生成器以及 测试工具来轻松测试和调试他们的工作。

从何开始

如果您对可用的解决方案感兴趣,请从阅读 CommsChampion Ecosystem 页面的“从何开始”部分开始您的学习过程。

许可

CommsChampion Ecosystem 的所有可用组件都是免费且开源的,每个组件都有单独的许可证。请参阅 许可证页面了解详细信息。

历史

  • 2019年3月4日:初始发布
  • 2020年8月14日:许可证信息更新
  • 2020年10月8日:修复了指向已迁移存储库的链接
© . All rights reserved.