C/C++结构填充在AAPCS(ARM ABI)下有多稳定?

3

问题

C99标准告诉我们:

结构体对象内可以有未命名填充位,但不可以在其开头。

以及

结构体或联合体的末尾可以有未命名填充位。

我假设这同样适用于任何 C++ 标准,但我没有检查过。

让我们假设一个运行在 ARM Cortex-M 上的 C/C++ 应用程序(即应用程序中使用了两种语言)将一些持久数据存储在本地介质上(例如串行 NOR-flash 芯片),并在断电后读取它,可能会在将来升级应用程序之后进行读取。升级后的应用程序可能已经使用升级后的编译器进行了编译(我们假设使用 gcc)。

让我们进一步假设开发者很懒(当然不是我),直接将一些纯 C 或 C++ 的 struct 流式传输到闪存中,而不是像任何 多疑 经验丰富的开发者那样首先对它们进行序列化。

事实上,所涉及的开发者很懒,但不是完全无知,因为他已经阅读了 AAPCS(Arm 架构过程调用标准)

他的理由除了懒惰之外还有:

  • 他不想压缩 struct 以避免在应用程序的其余部分中出现对齐问题。
  • AAPCS 为每种基本数据类型指定了固定的对齐方式。
  • 填充的唯一合理动机是实现适当的对齐。
  • 因此,他认为 AAPCS 完全确定了任何 C 或 C++ 的 struct 的填充(因此包括成员 offsetof 和总 sizeof)。
  • 因此,他进一步推断,只要闪存中数据的偏移量在写入和读取之间没有改变,我的应用程序就没有任何理由无法解释某个早期版本的同一应用程序写入的一些读回数据。

然而,这位开发者还有良心,有点担心:

  • C 标准没有提到填充的任何理由。实现适当的对齐可能是填充的唯一合理理由,但编译器可以在标准规定范围内自由地进行填充。
  • 他如何确信他的编译器真正遵循了 AAPCS
  • 他的假设是否会被他开始使用的某些看似不相关的编译器标志或编译器升级突然打破?
我的问题是:那个懒惰的开发者生活有多危险?换句话说,假设以上条件,C/C++的结构体中填充字段的稳定性如何?
结论:
在这个问题被提出两周后,唯一得到的答案并没有真正回答所提出的问题。我还在ARM社区论坛上提出了完全相同的问题,但是没有得到任何答案。
然而,我选择接受3246135作为答案,因为:
1. 我认为对于这种情况来说,缺乏正确答案的存在本身就是非常重要的信息。解决软件问题的正确性应该是显而易见的。我所做的假设可能是正确的,但我不能轻易地证明它们的正确性。此外,如果这些假设是不正确的,在一般情况下其后果可能是灾难性的。 2. 相比风险,使用答案中提出的策略对开发者的负担似乎非常合理。假设端序是不变的(这很容易实现),它是百分之百安全的(任何偏差都会在编译时生成错误),而且比完整的序列化要轻得多。基本上,答案中提出的策略是为了使人们的C/C++结构体可以独立于任何ABI而持久化而必须付出的最低代价。
如果您是一个开发者,并且正在思考上述问题,请不要懒惰,使用接受的答案中所提出的策略或其他能够保证软件发布间填充字段不变的策略

1
一个你可能没有考虑过的替代方案:定义两个版本的结构体,一个是紧凑的,另一个不是。在存储结构之前,将所有成员从非紧凑结构复制到紧凑结构。然后写入非易失性存储器。要从非易失性存储器中恢复,请读取到紧凑结构中,然后将成员复制到非紧凑结构中供应用程序使用。确保只使用<stdint.h>中的类型(至少在紧凑结构中),以便编译器不能更改大小。 - user3386109
@user3386109,谢谢。在变懒之前,开发者经常使用那种方法。 - nilo
这种填充可以通过编译器选项进行更改。由于总线架构的原因,一些CPU具有更严格的对齐要求。例如,16位总线无法保证原子32位加载而不需要额外的总线锁定基础设施。即使是未对齐的16位传输也不会是原子的。您可以将此扩展到其他类型,如8、16、32、64、128。因此,CPU本身可能会导致额外的填充。例如,存储函数指针可能会有问题,因为它们可能/可能没有设置低位。此外,您有什么保证工具套件是100%AAPCS兼容(到某个任意版本)? - artless noise
PRO/CONS 中存在一些明显的遗漏,比如序列化/反序列化代码的开销,包括运行时和内存开销。没有代码是完全没有错误的。而且,有些序列化代码实际上可以减少 NV 存储器的占用(大多数 32 位值实际上在 0-255 范围内)。如果选择打包,则应该在启动时进行某种运行时假设验证。如果使用 CI/CD,则测试套件可能足够了。静态分析工具也可能能够验证这些假设是有效的。即,在这些结构体中没有函数指针。 - artless noise
C/C++标准在这里是无关紧要的,因为正如您所指出的,它们并不涉及填充的位置问题。您关心的只是ABI说了什么,以及您的编译器是否遵守它。 - Nate Eldredge
1个回答

7

无法保证编译器不会在某种情况下引入填充。但是,您可以通过遵循以下几个规则来降低风险:

  • 对于所有成员都使用固定大小的类型,例如uint32_tint64_t等。
  • 将每个成员的偏移量设置为该成员大小的倍数(或者如果成员是数组/结构体,则是最大成员的大小的倍数)。
  • 避免使用位域。

请注意,这样做可能会引入一些显式的填充字段以满足对齐要求。

例如:

struct orig {
    int a;
    char b;
    int c[10];
    short d;
    char e[15];
    long f;
    int g;
};

假设sizeof(short) == 2sizeof(int) == 4sizeof(long) == 8,则此结构体成员的大小为74。如果考虑可能出现的填充,则大小可能会更大。
struct orig_padded {
    int a;
    char b;
    char pad1[3];
    int c[10];
    short d;
    char e[15];
    char pad2[7];
    long f;
    int g;
    char pad3[4];
};

你的结构体大小为88。

通过一些重新排列,我们可以将其缩小到74:

struct reordered {
    int64_t f;
    int32_t a;
    int32_t c[10];
    int32_t g;
    int16_t d;
    char b;
    char e[15];
};

通过按照字段大小的降序排列,我们基本上消除了字段之间的填充,只留下可能存在于最后的填充。还要注意使用固定大小来避免一些意外情况。然后作为防护措施,我们添加:
static_assert(sizeof(struct reordered) == 74);

因此,如果结构体的编译大小发生改变,您将在编译时知道。

要了解更多详细信息,请查看《结构体内存对齐的失落艺术》


虽然这些建议通常非常明智,但我真的很想阅读一些ARM ABI(应用程序二进制接口)的具体信息。 - nilo
另外,请注意,虽然避免任何填充是确保常量填充的一种方式,但我的目标实际上是常量填充,而不是没有填充。 - nilo
我的理解是 int64_t 应该作为第一个字段,即从大到小排序的字段。这可能会影响结构体之前的填充(例如对齐到64位边界),但不会影响结构内部的填充。 - Thomas Matthews
这是一个关于结构体打包的绝佳链接。对于OP来说,最好的部分是static_assert,它可以在构建时验证事物,以确保工具不受任何假设的影响。在数据中没有指针使得打包结构体代替序列化更容易。 - artless noise
1
@ThomasMatthews 如果您确保前面的内容对齐,就不一定需要首先具有最大的元素,但是如果最大的字段首先出现,则更不容易出现填充(并且更不容易出错)。我已更新示例以使用此排序。 - dbush

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接