【问题标题】:Guaranteed memory layout for standard layout struct with a single array member of primitive type具有原始类型的单个数组成员的标准布局结构的保证内存布局
【发布时间】:2019-09-03 04:04:38
【问题描述】:

考虑以下简单结构:

struct A
{
    float data[16];
};

我的问题是:

假设float 是一个32 位IEEE754 浮点数的平台(如果这很重要的话),C++ 标准是否保证struct A 的预期内存布局?如果不是,它保证什么和/或执行保证的方法是什么

预期内存布局是指结构占用内存中的16*4=64 字节,每个连续的4 字节被data 数组中的单个float 占用。换句话说,预期内存布局意味着以下测试通过:

static_assert(sizeof(A) == 16 * sizeof(float));
static_assert(offsetof(A, data[0]) == 0 * sizeof(float));
static_assert(offsetof(A, data[1]) == 1 * sizeof(float));
...
static_assert(offsetof(A, data[15]) == 15 * sizeof(float));

offsetof 在这里是合法的,因为A 是标准布局,见下文)

如果这让您感到困扰,请使用 gcc 9 HEAD 在 wandbox 上测试 actually passes。我从来没有遇到过一个平台和编译器的组合可以提供这个测试可能会失败的证据,如果它们确实存在,我很想了解它们。

为什么还要关心:

  • 类似 SSE 的优化需要一定的内存布局(和对齐,我在这个问题中忽略了这一点,因为它可以使用标准的 alignas 说明符来处理)。
  • 这种结构的序列化可以简单地归结为一个漂亮且可移植的write_bytes(&x, sizeof(A))
  • 某些 API(例如 OpenGL,特别是 glUniformMatrix4fv)期望这种精确的内存布局。当然,可以只传递指向data 数组的指针来传递这种类型的单个对象,但是对于这些序列(例如,用于上传矩阵类型顶点属性)仍然需要特定的内存布局。李>

实际保证的内容:

据我所知,这些是 struct A 可以期待的:

  • standard layout
  • 由于是标准布局,指向A 的指针可以是reinterpret_cast 指向其第一个数据成员的指针(大概是data[0]?),即没有填充第一个成员之前。

标准提供的其余两个(据我所知)保证是:

  • 原始类型数组的元素之间没有填充 (我确信这是错误的,但我没有找到确认参考),
  • struct A 内部的data 数组 没有填充。

【问题讨论】:

  • C++ 2017 (draft n4659) 11.3.4,“数组” [dcl.array]:“数组类型的对象包含连续分配的非空N 类型为 T 的子对象集。” 1998 版除了在 8.3.4 中带有连字符的“子对象”之外具有相同的文本。
  • @EricPostpischil 感谢您的澄清!在这种情况下,“连续分配”到底是什么意思?
  • @lisyarus:它是“简单的英语”,或者至少是该领域从业者使用的英语——标准中没有正式定义。我很确定这意味着数组中元素的字节一个接一个地布局在内存中,元素之间没有填充。
  • 在 C 中,不能保证剩下的第二个保证,并且有一些原因,“困难”的 C 实现可能会填充包含单个数组的结构。例如,我们可以想象一个实现会将struct { char x[2]; }填充到四个字节,如果它的目标硬件对内存的四字节字寻址有强烈的偏见,并且实现决定使所有结构至少四字节对齐以满足 C 标准对所有结构指针的一种表示的要求。我希望 C++ 类似,但不能自信地谈论它……
  • … 请注意,这是一种“理论上的”可能性。最有可能的是,struct { float data[16]; } 不会被任何普通的 C 或 C++ 实现提供任何尾随填充——在任何普通的目标平台中都没有理由这样做。但是,在 C++ 标准中没有明确规范的情况下,唯一的保证方法是项目要求用于编译它的任何 C++ 实现都满足此属性。可以用断言对其进行测试。

标签: c++ language-lawyer memory-layout standard-layout


【解决方案1】:

布局不能保证的一件事是字节顺序,即多字节对象中的字节顺序。 write_bytes(&x, sizeof(A)) 不是跨具有不同字节序的系统的可移植序列化。

A 可以是reinterpret_cast 指向其第一个数据成员的指针(大概是data[0]?)

更正:第一个数据成员是data,您可以使用它重新解释强制转换。至关重要的是,数组不能与其第一个元素指针互转换,因此您无法重新解释它们之间的强制转换。但是地址保证是相同的,所以据我所知,在std::launder 之后重新解释为data[0] 应该没问题。

原始类型数组的元素之间没有填充

数组保证是连续的。对象的sizeof 是根据将元素放入数组所需的填充来指定的。 sizeof(T[10]) 的大小与 sizeof(T) * 10 完全相同。如果相邻元素的非填充位之间有填充,则该填充位于元素本身的末尾。

原始类型一般不保证没有填充。例如,x86 扩展精度long double 为 80 位,填充为 128 位。

charsigned charunsigned char 保证没有填充位。 C 标准(在这种情况下 C++ 将规范委托给它)保证固定宽度的 intN_tuintN_t 别名没有填充位。在不可能的系统上,不提供这些固定宽度类型。

【讨论】:

  • 绝对清楚。您的最后一段是针对第二个未回答问题的直接反例吗?我是从复合类型的角度来询问的,例如 struct S {char a,b,c;}; 如果填充到 4*sizeof(char) 可能在最后有填充。就此而言,我们无法告诉除a 之外的任何成员的相对地址,我认为它们可以在编译器认为合适的情况下重新排序。是吗?
  • @luk32 char 元素之间不可能需要填充,因为它们的对齐方式为 1。任何合理的 ABI 都会放置填充(如果有) 的 S 最后。但事实上,我不知道 C++ 标准中有明确的保证。
  • 能否请您详细说明std::launder 的这种用法?
  • @eerorika 很抱歉,但根据 cppreference 文章,我很难理解这里需要 std::launder 的原因。
  • @lisyarus 在C C++中,指针不指向内存位置,它指向指定对象:指针是高级类型,而不是低级地址就像在组装中一样。 指向一个对象与指向同一个地址的不同对象不同。我问过很多与指针相关的问题(几乎所有问题都非常糟糕),称其为指针的“语义值”,而不是数值。观察。如果你越过比赛。由 diff comp 编译的边界和调用函数。只有指针对象的数值(状态)很重要(由 ABI 描述)。
【解决方案2】:

如果一个标准布局类对象有任何非静态数据成员,它的 address 与其第一个非静态数据成员的地址相同。 否则,它的地址与它的第一个基地址相同 类子对象(如果有)。 [注意:因此,标准布局结构对象中可能存在未命名的填充,但不是在其开头,这是实现适当对齐所必需的。 ——尾注]

因此,标准保证

static_assert(offsetof(A, data[0]) == 0 * sizeof(float));

数组类型的对象包含一个连续分配的非空 T 类型的 N 个子对象的集合。

因此,以下是正确的

static_assert(offsetof(A, data[0]) == 0 * sizeof(float));
static_assert(offsetof(A, data[1]) == 1 * sizeof(float));
...
static_assert(offsetof(A, data[15]) == 15 * sizeof(float));

【讨论】: