【问题标题】:Standard-layout and tail padding标准布局和尾部填充
【发布时间】:2019-05-19 03:03:30
【问题描述】:

David Hollman 最近在推特上发布了以下示例(我稍微简化了):

struct FooBeforeBase {
    double d;
    bool b[4];
};

struct FooBefore : FooBeforeBase {
    float value;
};

static_assert(sizeof(FooBefore) > 16);

//----------------------------------------------------

struct FooAfterBase {
protected:
    double d;
public:  
    bool b[4];
};

struct FooAfter : FooAfterBase {
    float value;
};

static_assert(sizeof(FooAfter) == 16);

您可以检查布局 in clang on godbolt 并查看大小更改的原因是在 FooBefore 中,成员 value 放置在偏移量 16 处(与 FooBeforeBase 保持完全对齐 8)而在FooAfter,成员 value 放置在偏移量 12 处(有效使用 FooAfterBase 的尾部填充)。

我很清楚FooBeforeBase 是标准布局,但FooAfterBase 不是(因为它的非静态数据成员并不都具有相同的访问控制,[class.prop]/3)。但是,FooBeforeBase 的标准布局需要这种填充字节,这又是什么呢?

gcc 和 clang 都重用了 FooAfterBase 的填充,以 sizeof(FooAfter) == 16 结尾。但 MSVC 没有,以 24 结尾。是否有符合标准的布局要求,如果没有,为什么 gcc 和 clang 会这样做?


有一些混乱,所以只是为了澄清:

  • FooBeforeBase 是标准布局
  • FooBefore 不是(它和基类都有非静态数据成员,类似于this example 中的E
  • FooAfterBase 不是(它具有不同访问权限的非静态数据成员)
  • FooAfter 不是(出于上述两个原因)

【问题讨论】:

  • 谁说标准中的任何内容都“要求”这种行为?它可能只是编译器如何实现事物的一种表现。
  • @NicolBolas 很可能不需要。 MSVC 不这样做(它的 FooAfter 也是 24 字节),但 gcc 和 clang 这样做 - 似乎这是他们有意识的选择。
  • 这似乎是他们有意识的选择。”你为什么这么说?
  • 从不要求类成员之间没有填充。 可能需要填充。所以正确的问题不是标准的哪一部分要求 gcc 重用end-padding,而是在第二种情况下允许 这样做。另一个问题是在第一种情况下是否不允许这种重用。

标签: c++ g++ language-lawyer clang++ standard-layout


【解决方案1】:

这个问题的答案不是来自标准,而是来自 Itanium ABI(这就是为什么 gcc 和 clang 有一种行为而 msvc 做其他事情的原因)。该 ABI 定义了a layout,就本问题而言,其相关部分是:

出于规范内部的目的,我们还指定:

  • dsize(O):对象的数据大小,即不带尾边距的O大小。

我们忽略 POD 的尾部填充,因为该标准的早期版本不允许我们将其用于其他任何事情,而且它有时允许更快地复制类型。

虚拟基类以外的成员的放置定义为:

从偏移量 dsize(C) 开始,如果需要对齐基类的 nvalign(D) 或数据成员的 align(D),则递增。除非 [... 不相关 ...],否则将 D 放置在此偏移处。

POD 一词已从 C++ 标准中消失,但它意味着标准布局和可轻松复制。在这个问题中,FooBeforeBase 是一个 POD。 Itanium ABI 忽略尾部填充 - 因此 dsize(FooBeforeBase) 是 16。

FooAfterBase 不是 POD(它可以简单地复制,但它不是标准布局)。结果,尾部填充没有被忽略,所以dsize(FooAfterBase) 只是 12,float 可以直接到那里。

这会产生有趣的后果,正如 Quuxplusone 在 related answer 中指出的那样,实现者通常还假设尾部填充没有被重用,这对这个示例造成了严重破坏:

#include <algorithm>
#include <stdio.h>

struct A {
    int m_a;
};

struct B : A {
    int m_b1;
    char m_b2;
};

struct C : B {
    short m_c;
};

int main() {
    C c1 { 1, 2, 3, 4 };
    B& b1 = c1;
    B b2 { 5, 6, 7 };

    printf("before operator=: %d\n", int(c1.m_c));  // 4
    b1 = b2;
    printf("after operator=: %d\n", int(c1.m_c));  // 4

    printf("before std::copy: %d\n", int(c1.m_c));  // 4
    std::copy(&b2, &b2 + 1, &b1);
    printf("after std::copy: %d\n", int(c1.m_c));  // 64, or 0, or anything but 4
}

这里,= 做了正确的事情(它不会覆盖 B 的尾部填充),但是 copy() 有一个库优化,可以减少到 memmove() - 它不关心尾部填充,因为它假设它不存在。

【讨论】:

  • 我认为这个答案是正确的,除了一件事:POD in the ABI 的工作定义不是“琐碎 + 标准布局”的 C++11 定义,而是 C++03 中的 POD,它基本上是一个聚合 + 对成员的一些其他限制(参见 8.5.1 和 9p4)。聚合只有公共成员。标准布局类型可以有非公共成员,只要所有成员都具有 same 访问级别。所以结果是只有私有数字的类可能是微不足道的可复制和标准布局,因此是 C++11 意义上的 POD,但 not C++03 意义上的 POD 和.. .
  • ... 所以它有资格重复使用填充,如this question所示。
【解决方案2】:
FooBefore derived;
FooBeforeBase src, &dst=derived;
....
memcpy(&dst, &src, sizeof(dst));

如果额外的数据成员被放置在洞中,memcpy 会覆盖它。

正如在 cmets 中正确指出的那样,该标准不要求此 memcpy 调用应该有效。然而,Itanium ABI 的设计似乎考虑到了这种情况。或许以这种方式指定 ABI 规则是为了使混合语言编程更加健壮,或者为了保持某种向后兼容性。

相关ABI规则可以在here找到。

可以在here 找到相关答案(这个问题可能与那个问题重复)。

【讨论】:

【解决方案3】:

这是一个具体案例,说明了为什么第二种情况不能重用填充:

union bob {
  FooBeforeBase a;
  FooBefore b;
};

bob.b.value = 3.14;
memset( &bob.a, 0, sizeof(bob.a) );

这无法清除bob.b.value

union bob2 {
  FooAfterBase a;
  FooAfter b;
};

bob2.b.value = 3.14;
memset( &bob2.a, 0, sizeof(bob2.a) );

这是未定义的行为。

【讨论】:

  • "this cannot clear bob.b.value." 由于FooBefore 不是标准布局,common-initial-sequence 规则不适用。所以设置了bob.b之后就不能访问bob.a了。
  • @Holt:FooBeforeBaseFooBefore 都有非静态成员,因此 FooBefore 没有标准布局。
  • @Holt 标准布局类型的数据成员要么在基类中不在基类中,但不是两者兼有。 en.cppreference.com/w/cpp/named_req/StandardLayoutType
  • @Holt:这是“没有集合的元素”部分。一旦你解开所有规范语言,这就是它所说的。
  • CIS 没关系。它允许通过不同的成员读取,而不是写入
【解决方案4】:

FooBefore 也不是标准布局;两个类声明非静态数据成员(FooBeforeFooBeforeBase)。因此允许编译器任意放置一些数据成员。因此,出现了不同工具链上的差异。 在标准布局层次结构中,至多一个类(最派生类或至多一个中间类)应声明非静态数据成员。

【讨论】:

    【解决方案5】:

    这是与 n.m. 的回答类似的情况。

    首先,让我们有一个函数,它清除FooBeforeBase

    void clearBase(FooBeforeBase *f) {
        memset(f, 0, sizeof(*f));
    }
    

    这很好,因为clearBase 得到一个指向FooBeforeBase 的指针,它认为FooBeforeBase 具有标准布局,所以memsetting 是安全的。

    现在,如果你这样做:

    FooBefore b;
    b.value = 42;
    clearBase(&b);
    

    您不会想到,clearBase 会清除 b.value,因为 b.value 不是 FooBeforeBase 的一部分。但是,如果将FooBefore::value 放入FooBeforeBase 的尾部填充中,它也会被清除。

    是否有符合标准的布局要求?如果没有,为什么 gcc 和 clang 会这样做?

    不,不需要尾部填充。这是一个优化,gcc 和 clang 做的。

    【讨论】:

    • 但是标准不允许clearBase 处理基类子对象。好吧,如果我们要从技术上讲,memset 在 TriviallyCopyable 类型期间是不允许的,但即使那是来自零初始化 FooBeforeBase static 实例的 memcpy,它仍然不会允许在基类子对象上使用。
    • @NicolBolas:我没有说过。作为clearBase的用户,你可能不知道里面是什么。所以,编译器的这种行为保证了你不会在脚下开枪。即使问题带有语言律师标签,也请在这里更实际一点。我们已经讨论了一些标准未涵盖的内容(即尾部填充优化)。
    • 我没有说过”。你做了很多,就在你说:“现在,如果你这样做:”。这是在基类子对象(即 UB)上调用 clearBase 的代码。
    • “但标准不允许 clearBase 工作” 相反,它不保证它会工作。
    • @NicolBolas:那好吧。这种行为也有保证,即使是UB,也不会造成任何伤害。 UB 并不意味着一定会发生不好的事情。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2014-08-19
    • 2012-04-07
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多