打包结构是可移植的吗?

Are packed structs portable?

我在 Cortex-M4 微控制器上有一些代码,想使用二进制协议与 PC 通信。目前,我正在使用 GCC 特定 packed 属性的打包结构。

这是一个粗略的概述:

struct Sensor1Telemetry {
    int16_t temperature;
    uint32_t timestamp;
    uint16_t voltageMv;
    // etc...
} __attribute__((__packed__));

struct TelemetryPacket {
    Sensor1Telemetry tele1;
    Sensor2Telemetry tele2;
    // etc...
} __attribute__((__packed__));

我的问题是:

编辑:

如果

  • endianness 不是问题
  • 两个编译器都能正确处理打包
  • 两种 C 实现的类型定义都是准确的(符合标准)。

那么是的,“压缩结构”是可移植的。

我的口味太多 "if"s,不要这样做。不值得这么麻烦。

您可以这样做,或者使用更可靠的替代方法。

对于连载狂热者中的核心人物,CapnProto。这为您提供了一个本地结构来处理,并承诺确保当它通过网络传输并轻松处理时,另一端仍然有意义。称其为序列化几乎是不准确的;它旨在对结构的内存表示做尽可能少的事情。可能适合移植到 M4

有 Google 协议缓冲区,它是二进制的。更臃肿,但相当不错。有附带的 nanopb(更适合微控制器),但它并不能完成整个 GPB(我不认为它可以 oneof)。不过很多人都用的很成功。

一些 C asn1 运行时足够小,可以在微控制器上使用。我知道 this one 适合 M0。

考虑到上述平台,是的,打包结构完全可以使用。 x86 和 x86_64 一直支持非对齐访问,并且与普遍的看法相反,这些平台上的非对齐访问在很长一段时间内与对齐访问具有 () 相同的速度(没有非对齐访问这样的东西慢得多)。唯一的缺点是访问可能不是原子的,但我认为在这种情况下这并不重要。编译器之间有一个协议,打包的结构将使用相同的布局。

GCC/clang 支持使用您提到的语法的打包结构。 MSVC有#pragma pack,可以这样使用:

#pragma pack(push, 1)
struct Sensor1Telemetry {
    int16_t temperature;
    uint32_t timestamp;
    uint16_t voltageMv;
    // etc...
};
#pragma pack(pop)

可能会出现两个问题:

  1. 字节顺序必须跨平台相同(您的 MCU 必须使用小端)
  2. 如果您将指针分配给压缩结构成员,并且您所处的体系结构不支持未对齐访问(或使用具有对齐要求的指令,如 movapsldrd),那么你可能会在使用该指针时发生崩溃(gcc 不会警告你,但 clang 会警告你)。

这是来自 GCC 的文档:

The packed attribute specifies that a variable or structure field should have the smallest possible alignment—one byte for a variable

因此 GCC 保证不会使用填充。

MSVC:

To pack a class is to place its members directly after each other in memory

所以 MSVC 保证不会使用填充。

我发现的唯一 "dangerous" 领域是位域的使用。那么 GCC 和 MSVC 之间的布局可能会有所不同。但是,GCC 中有一个选项,可以使它们兼容:-mms-bitfields


提示:即使此解决方案现在有效,而且它停止工作的可能性很小,我还是建议您保持代码对此解决方案的依赖性较低。

注意:我在这个答案中只考虑了 GCC、clang 和 MSVC。可能有一些编译器,对于这些编译器来说,这些事情是不正确的。

这是一个算法的伪代码,可以满足您的需要,以确保使用正确的目标 OS 和平台。

如果使用 C 语言,您将无法使用 classestemplates 和其他一些东西,但您可以使用 preprocessor directives 创建您的 struct(s) 版本需要基于 OS、架构师 CPU-GPU-Hardware Controller Manufacturer {Intel, AMD, IBM, Apple, etc.}platform x86 - x64 bit,最后是字节布局的 endian。否则这里的重点将是 C++ 和模板的使用。

以你的struct(s)为例:

struct Sensor1Telemetry {
    int16_t temperature;
    uint32_t timestamp;
    uint16_t voltageMv;
    // etc...
} __attribute__((__packed__));

struct TelemetryPacket {
    Sensor1Telemetry tele1;
    Sensor2Telemetry tele2;
    // etc...
} __attribute__((__packed__));

您可以这样模板化这些结构:

enum OS_Type {
    // Flag Bits - Windows First 4bits
    WINDOWS    = 0x01  //  1
    WINDOWS_7  = 0x02  //  2 
    WINDOWS_8  = 0x04, //  4
    WINDOWS_10 = 0x08, //  8

    // Flag Bits - Linux Second 4bits
    LINUX      = 0x10, // 16
    LINUX_vA   = 0x20, // 32
    LINUX_vB   = 0x40, // 64
    LINUX_vC   = 0x80, // 128

    // Flag Bits - Linux Third Byte
    OS         = 0x100, // 256
    OS_vA      = 0x200, // 512
    OS_vB      = 0x400, // 1024
    OS_vC      = 0x800  // 2048

    //....
};

enum ArchitectureType {
    ANDROID = 0x01
    AMD     = 0x02,
    ASUS    = 0x04,
    NVIDIA  = 0x08,
    IBM     = 0x10,
    INTEL   = 0x20,
    MOTOROALA = 0x40,
    //...
};

enum PlatformType {
    X86 = 0x01,
    X64 = 0x02,
    // Legacy - Deprecated Models
    X32 = 0x04,
    X16 = 0x08,
    // ... etc.
};

enum EndianType {
    LITTLE = 0x01,
    BIG    = 0x02,
    MIXED  = 0x04,
    // ....
};

// Struct to hold the target machines properties & attributes: add this to your existing struct.

struct TargetMachine {
    unsigned int os_;
    unsigned int architecture_;
    unsigned char platform_;
    unsigned char endian_;

    TargetMachine() : 
      os_(0), architecture_(0),
      platform_(0), endian_(0) {
    }

    TargetMachine( unsigned int os, unsigned int architecture_, 
                   unsigned char platform_, unsigned char endian_ ) :
      os_(os), architecture_(architecture),
      platform_(platform), endian_(endian) {
    }    
};

template<unsigned int OS, unsigned int Architecture, unsigned char Platform, unsigned char Endian>
struct Sensor1Telemetry {       
    int16_t temperature;
    uint32_t timestamp;
    uint16_t voltageMv;
    // etc...
} __attribute__((__packed__));

template<unsigned int OS, unsigned int Architecture, unsigned char Platform, unsigned char Endian>
struct TelemetryPacket {
    TargetMachine targetMachine { OS, Architecture, Platform, Endian };
    Sensor1Telemetry tele1;
    Sensor2Telemetry tele2;
    // etc...
} __attribute__((__packed__));

有了这些 enum 标识符,您就可以使用 class template specialization 根据上述组合设置此 class 以满足其需要。在这里,我将采用所有似乎可以与 default class declaration & definition 一起正常工作的常见情况,并将其设置为 class 的主要功能。然后对于那些特殊情况,例如具有字节顺序的不同 Endian,或特定的 OS 版本以不同的方式做某事,或 GCC versus MS 编译器使用 __attribute__((__packed__))#pragma pack() 可以是少数需要考虑的专业化。您不需要为每种可能的组合指定专业化;这太令人生畏和耗时了,应该只需要做一些可能发生的罕见案例场景,以确保您始终为目标受众提供正确的代码说明。使 enums 非常方便的是,如果将它们作为函数参数传递,则可以一次设置多个,因为它们被设计为位标志。因此,如果您想创建一个函数,将此模板结构作为其第一个参数,然后支持 OS 作为其第二个参数,您可以将所有可用的 OS 支持作为位标志传递。

这可能有助于确保这组 packed structures "packed" 和/或根据适当的目标正确对齐,并且它将始终执行相同的功能以保持跨不同平台的可移植性.

现在您可能必须在不同支持编译器的预处理器指令之间进行两次专门化。这样,如果当前编译器是 GCC,因为它以一种方式定义结构及其特化,然后是另一种方式的 Clang,或 MSVC,代码块等。因此,初始设置会有一些开销,但它应该,可以高度确保它在目标机器的指定场景或属性组合中被正确使用。

这在很大程度上取决于结构是什么,请记住在 C++ 中 struct 是具有默认可见性 public.

的 class

因此您可以继承甚至向其添加虚拟,这样这可能会破坏您的东西。

如果它是纯数据 class(在 C++ 术语中是 标准布局 class)这应该与 packed 结合使用.

还要记住,如果你开始这样做,你可能会遇到编译器严格的别名规则的问题,因为你将不得不查看内存的字节表示(-fno-strict-aliasing 是你的朋友).

备注

话虽如此,我强烈建议不要使用它进行序列化。如果为此使用工具(即:protobuf、flatbuffers、msgpack 或其他工具),您将获得大量功能:

  • 语言独立性
  • rpc(远程过程调用)
  • 数据规范语言
  • schemas/validation
  • 版本控制

如果你想要最大程度便携的东西,你可以声明一个 uint8_t[TELEM1_SIZE]memcpy() 的缓冲区来回其中的偏移量,执行字节顺序转换,例如 htons()htonl()(或 little-endian 等价物,例如 glib 中的那些)。在 C++ 中,您可以使用 getter/setter 方法将其包装在 class 中,或者在 C 中使用 getter-setter 函数的结构。

你不应该跨编译域使用结构,针对内存(硬件寄存器,挑选从文件中读取的项目或在处理器之间或相同处理器的不同软件(在应用程序和内核驱动程序之间)之间传递数据)。你是在自找麻烦,因为编译器有一定的自由意志来选择对齐方式,然后用户可以通过使用修饰符使它变得更糟。

不,没有理由假设您可以跨平台安全地执行此操作,即使您使用相同的 gcc 编译器版本例如针对不同的目标(编译器的不同构建以及目标差异)。

为了减少失败的几率,首先从最大的项目开始(64 位,然后是 32 位,16 位,最后是任何 8 位项目)理想情况下对齐 32 最小值,也许 64,这是人们希望 arm 和 x86 做的,但是可以随时更改,默认值可以由从源代码构建编译器的任何人修改。

现在,如果这是一项工作保障,请务必继续,您可以对此代码进行定期维护,可能需要为每个目标定义每个结构(因此该结构的源代码的一份副本ARM 的定义和 x86 的另一个定义,或者即使不是立即也最终会需要它)。然后,每次或每隔几个产品发布,您都会被要求参与代码工作……漂亮的小维护定时炸弹会爆炸……

如果您想在相同或不同架构的编译域或处理器之间安全地通信,请使用一定大小的数组、字节流、半字流或字流。显着降低您在路上发生故障和维护的风险。不要用结构去挑那些只是还原风险和失败的项目。

人们之所以认为这是可以的,是因为对相同的目标或系列使用相同的编译器或系列(或从其他编译器选择派生的编译器),因为您了解语言的规则以及在哪里实施定义的领域是你最终会 运行 跨越差异,有时你的职业生涯需要几十年,有时需要几周......这是 "works on my machine" 问题......

不总是。当你向不同的架构处理器发送数据时,你需要考虑字节序、原始数据类型等。最好使用Thrift or Message Pack。如果没有,请自己创建 Serialize 和 DeSerialize 方法。

谈论备选方案并考虑您的问题Tuple-like container for packed data (for which I don't have enough reputation to comment on), I suggest having a look at Alex Robenko's CommsChampion项目:

COMMS is the C++(11) headers only, platform independent library, which makes the implementation of a communication protocol to be an easy and relatively quick process. It provides all the necessary types and classes to make the definition of the custom messages, as well as wrapping transport data fields, to be simple declarative statements of type and class definitions. These statements will specify WHAT needs to be implemented. The COMMS library internals handle the HOW part.

由于您正在使用 Cortex-M4 微控制器,您可能还会发现有趣的是:

The COMMS library was specifically developed to be used in embedded systems including bare-metal ones. It doesn't use exceptions and/or RTTI. It also minimises usage of dynamic memory allocation and provides an ability to exclude it altogether if required, which may be needed when developing bare-metal embedded systems.

Alex 提供了一本名为 Guide to Implementing Communication Protocols in C++ (for Embedded Systems) 的优秀免费电子书,其中介绍了内部结构。