是可移植的包装结构

Are packed structs portable?

本文关键字:结构 包装 可移植      更新时间:2023-10-16

我在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__));

我的问题是:

  • 假设我对 MCU 和客户端应用程序上的TelemetryPacket结构使用了完全相同的定义,上面的代码是否可以跨多个平台移植?(我对x86和x86_64感兴趣,需要它在Windows,Linux和OS X上运行。
  • 其他编译器是否支持具有相同内存布局的打包结构?用什么语法?

编辑

  • 的,我知道打包结构是非标准的,但它们似乎足够有用,可以考虑使用它们。
  • 我对 C 和 C++ 都感兴趣,尽管我不认为 GCC 会以不同的方式处理它们。
  • 这些结构不是继承的,也不会继承任何内容。
  • 这些结构仅包含固定大小的整数字段和其他类似的打包结构。(我以前被花车烧过...

考虑到提到的平台,是的,打包结构完全可以使用。 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的文档:

pack 属性指定变量或结构字段 应具有尽可能小的对齐方式 - 变量为一个字节

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

MSVC:

打包类就是将其成员直接放在彼此之后 记忆

因此,MSVC保证不会使用任何填充。

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


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

注意:在这个答案中,我只考虑了GCC,clang和MSVC。也许有编译器,这些事情是不正确的。

if

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

那么是的,">包装结构"是便携式的。

为了我的口味,太多的"如果",不要这样做。不值得麻烦出现。

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

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

有谷歌协议缓冲区,这是二进制的。更臃肿,但相当不错。有随附的nanopb(更适合微控制器),但它不能完成整个GPB(我认为它没有oneof)。不过,许多人成功地使用它。

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

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

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

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

现在,如果这是一个工作保障问题,当然继续,你可以对这段代码进行定期维护,可能需要为每个目标定义每个结构(所以ARM结构定义的源代码的一个副本和x86的另一个源代码副本,或者如果不是立即需要的话,最终将需要这个)。 然后,每个或每隔几个产品版本,您都会被调用来执行代码工作......漂亮的小维护定时炸弹爆炸...

如果要在相同或不同体系结构的编译域或处理器之间进行安全通信,请使用一定大小的数组、字节流、半字流或字流。 显著降低故障和维护的风险。 不要使用结构来挑选那些只会恢复风险和故障的项目。

人们似乎认为这是可以的,因为对相同的目标或家族(或从其他编译器选择派生的编译器)使用相同的编译器或家族,因为您了解语言的规则以及实现定义的区域在哪里,您最终会遇到差异,有时需要几十年的职业生涯, 有时需要数周时间...这是"在我的机器上工作"的问题...

如果你想要一些最大可移植性的东西,你可以声明一个缓冲区,uint8_t[TELEM1_SIZE]memcpy()到其中的偏移量,执行字节序转换,如htons()htonl()(或小端等效项,如 glib 中的那些)。 你可以用 C++ 中的 getter/setter 方法包装它,或者在 C 中用 getter-setter 函数包装一个结构。

这在很大程度上取决于结构是什么,请记住,在C++struct中是一个具有默认可见性的公共类。

因此,您可以继承甚至添加虚拟内容,这样就可以为您破坏事情。

如果它是一个纯数据类(C++术语是标准布局类),这应该与packed结合使用。

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

注意

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

  • 语言独立性
  • RPC(远程过程调用)
  • 数据规范语言
  • 架构/验证
  • 版本控制

谈到替代方案并考虑您的问题 用于打包数据的元组式容器(我没有足够的声誉来评论),我建议看看 Alex Robenko 的 CommsChampion 项目:

COMMS 是仅 C++(11) 个标头、独立于平台的库,这使得通信协议的实现成为一个简单且相对快速的过程。它提供了所有必要的类型和类,以使自定义消息的定义以及包装传输数据字段成为类型和类定义的简单声明性语句。这些语句将指定需要实现的内容。COMMS 库内部处理 HOW 部分。

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

COMMS 库是专门为嵌入式系统(包括裸机系统)而开发的。它不使用异常和/或 RTTI。它还最大限度地减少了动态内存分配的使用,并在需要时提供了完全排除它的能力,这在开发裸机嵌入式系统时可能需要。

Alex提供了一本优秀的免费电子书,名为《在C++中实现通信协议指南(用于嵌入式系统),其中描述了内部结构。

这是针对可能适合您需求的算法的伪代码,以确保在正确的目标操作系统和平台上使用。

如果使用C语言,您将无法使用classestemplates和其他一些东西,但是您可以使用preprocessor directives根据OS,架构师CPU-GPU-Hardware Controller Manufacturer {Intel, AMD, IBM, Apple, etc.}platform x86 - x64 bit,最后是字节布局的endian来创建所需的struct(s)版本。否则,这里的重点将是模板的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设置为其需要。在这里,我将采用所有似乎适用于defaultclass declaration & definition的常见情况,并将其设置为主类的功能。然后,对于这些特殊情况,例如具有字节顺序的不同Endian,或者特定操作系统版本以不同的方式执行某些操作,或者使用__attribute__((__packed__))#pragma pack()的编译器GCC versus MS可能是需要考虑的少数专业化。您不需要为每个可能的组合指定专用化;这将太令人生畏和耗时,应该只需要执行可能发生的少数罕见情况,以确保您始终为目标受众提供正确的代码说明。同样使enums非常方便的是,如果您将这些作为函数参数传递,您可以一次设置多个参数,因为它们被设计为位标志。因此,如果要创建一个将此模板结构作为其第一个参数的函数,然后将支持的操作系统作为其第二个参数,则可以将所有可用的操作系统支持作为位标志传递。

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

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

并非总是如此。当你将数据发送到不同的架构处理器时,你需要考虑字节序、原始数据类型等。最好使用节俭或消息包。如果没有,请改为创建自己的序列化和反序列化方法。