结构体打包是确定的吗?

45
例如,假设我有两个等价的结构体ab在不同的项目中:

a and b are equivalent structs in different projects.

typedef struct _a
{
    int a;
    double b;
    char c;
} a;

typedef struct _b
{
    int d;
    double e;
    char f;
} b;
假设我没有使用任何指令,例如#pragma pack,并且这些结构体在相同编译器、相同架构和相同优化设置的情况下进行编译,那么它们之间的变量填充是否相同?
假设我没有使用任何指令,例如#pragma pack,并且这些结构体在相同编译器、相同架构和相同优化设置的情况下进行编译,那么它们之间的变量填充是否相同?

24
如果是“在相同的编译器和架构下,使用相同的优化设置”,那么可以。但是过度依赖这种方式可能会导致代码质量问题。请问您需要解决什么具体问题? - Cody Gray
11
问题并不是主机架构,而是目标架构。实际上,仅仅知道架构还不够,你需要知道平台。但这似乎是一个XY问题,并且你的方法注定会失败。为什么你要依赖于相同的布局?如果你想将它们转储到文件中或通过某些套接字等传输,请使用合适的编组技术。你怎么能确定未来布局不会改变呢? - too honest for this site
4
顺便提一下,在同步数字计算机中,基本上每个内部状态变化都是确定性的。获取非确定性结果(例如真正的随机值)实际上是一个重大问题。您不是指确定性,而是“保证和相同的布局”(打包也有其他含义)。 - too honest for this site
2
只有编译器使用真正的随机函数才需要。仔细阅读我的评论,这是非常正确的!而且他们为什么需要呢?他们只需要是唯一的。伪随机数或来自输入的哈希是足够的;一个简单的计数器可能就足够了(可能带有哈希前缀等)。如果它们不是真正的随机数,还可以简化编译器的调试。 - too honest for this site
6
“代码异味”/“XY问题”思想的来源是什么,这个问题为什么会被投下一个“-1”的反对票?原帖提出了一个明确的技术问题,从未暗示他实际想要做这件事或者提议将其作为一种遵循的模式。深入探究是一件好事。重点不是为了得到像这样做的免费门票,而是学习所有可能影响/机制的各种可能性,无论是否能够使这成为可能。正如@rici在他的回答中提供的那样。 - AnoE
显示剩余3条评论
8个回答

55

编译器是确定性的;如果不是这样,分离编译将是不可能的。具有相同 struct 声明的两个不同翻译单元将一起工作;这是由于 §6.2.7/1: 兼容类型和组合类型所保证的。

此外,同一平台上的两个不同编译器应该可以互操作,尽管这不是标准所保证的。(这是一个实现质量问题。)为了允许互操作性,编译器编写者会就平台ABI(应用程序二进制接口)达成一致意见,其中将包括如何表示组合类型的精确规范。通过这种方式,使用一个编译器编译的程序可以使用与另一个编译器编译的库模块。

但你不仅对确定性感兴趣,还希望两个不同类型的布局相同。

根据标准,如果两个 struct 类型的成员(按顺序取出)兼容,并且它们的标记和成员名称相同,则它们兼容。由于你的示例structs具有不同的标记和名称,即使它们的成员类型是兼容的,也不能在需要另一个时使用。

可能看起来奇怪的是,标准允许标记和成员名称影响兼容性。标准要求将 struct 的成员按声明顺序布局,因此名称不能改变结构体内成员的顺序。那么,为什么它们能影响填充?我不知道任何编译器会这样做,但标准的灵活性基于这样的原则,即要求应尽可能少,以保证正确执行。不同标记的结构体之间的别名不允许在翻译单元之间,所以没有必要在不同的翻译单元之间容忍它。因此,标准不允许它。(即使需要确定性地添加填充来为此类信息提供空间,实现也可以在struct的填充字节中插入关于类型信息的信息。唯一的限制是不能在struct的第一个成员之前放置填充。)

平台ABI可能会在不涉及标签或成员名称的情况下指定复合类型的布局。在特定平台上,如果存在这样的平台ABI规范和编译器符合该平台ABI的文档,则可以使用别名,尽管这不是技术上正确的,而且显然前提条件使其不可移植。


同时,FFI也是不可能的:从非C语言连接到C接口。 - Kaz
@kaz:这也不是标准要求的 :-). 但当然它很有用。 - rici
编译器可能会在对象中插入运行时信息,这些信息的大小可能会因类型名称而异。这将导致具有相同用户数据但不同类型的对象具有不同的大小和布局。 - Peter - Reinstate Monica
@peteraschneider:是的,基本上这就是我在第五段括号里写的内容。 - rici

16
C标准本身对此没有说明,因此原则上你无法确定。
但是:最有可能的是你的编译器遵循某种特定的ABI,否则与其他库和操作系统通信将会是一场噩梦。在这种情况下,ABI通常会规定打包方式的确切方法。
例如:
在x86_64 Linux/BSD上,SystemV AMD64 ABI是参考标准。在这里(§3.1)对于每个原始处理器数据类型,详细介绍了与C类型的对应关系、大小和对齐要求,并解释了如何使用这些数据来构成位域、结构体和联合体的内存布局;除了填充的实际内容之外,一切都被指定和确定。许多其他架构也是如此,请参见这些链接
ARM 推荐其EABI用于其处理器,并且通常由Linux和Windows遵循;聚合体对齐在“ARM Architecture文档的过程调用标准”,§4.3中指定。
在Windows上没有跨供应商的标准,但VC++基本上规定了ABI,几乎所有编译器都遵循;对于x86_64,可以在这里找到,对于ARM,则可在这里找到(但对于此问题感兴趣的部分仅涉及ARM EABI)。

10

任何理智的编译器都会为这两个结构体生成相同的内存布局。编译器通常是作为完全确定性程序编写的。非确定性需要明确而有意地添加,我个人认为这样做没有好处。

然而,这并不允许您将struct _a*强制转换为struct _b*并通过两者都访问其数据。据我所知,即使内存布局相同,这仍将违反严格别名规则,因为它将允许编译器重新排序通过struct _a*访问和通过struct _b*访问,这将导致不可预测的未定义行为。


这就是工会存在的原因之一:让你可以返回各种结构,具体哪一个在运行时确定。例如,Xwindows事件。 - jamesqf

8

他们是否会在变量之间具有相同的填充?

实际上,它们大多数时候喜欢具有相同的内存布局。

理论上,由于标准没有详细说明如何在对象上使用填充,因此您无法真正假设任何关于元素之间填充的内容。

另外,我甚至看不出为什么要知道/假设结构体成员之间的填充。只需编写符合标准的C代码,您就会没问题。


4
如果结构体的布局不是确定性的,那么将无法为开发提供带有头文件的编译二进制库(至少在涉及结构体的情况下是如此)。 - Kaz
1
在许多情况下,@kaz,这是不可能的。特别是,您不能使用两个不同版本的Microsoft编译器来完成此操作。其他编译器可能会将此类二进制兼容性作为设计特性,但它们不需要按照语言标准执行此操作,而且许多编译器也不支持此功能。然而,这与“确定性”与“非确定性”输出关系不大。 - Cody Gray
3
有时你可能希望使用一个结构体来精确匹配网络数据包的布局,例如,即使是关于结构体成员之间填充的细节,你也想知道/假设一些内容。请注意,这句话并未明确表示作者是否同意此做法。 - Alnitak
@Alnitak即使您使用套接字,也不能改变“a”不是“b”的事实。它们是不同的类型。 - David Haim
这不是一个答案。 “它们大多喜欢具有相同的内存布局” - 什么时候是这样,什么时候不是?这真的只是个人偏好吗? - Michael Foukarakis

5
在不同的系统上,无法以确定性方式接近 C 语言中结构或联合体的布局。尽管很多时候不同编译器生成的布局似乎相同,但你必须考虑实用和功能便利性所决定的趋同情况,这是编译器设计选择自由范围内程序员所拥有的,并且不是有效的。C11 标准 ISO/IEC 9899:2011,几乎没有改变之前的标准,在第6.7.2.1节“结构和联合体说明符”中明确指出:每个结构体或联合体对象的非位域成员在适合其类型的实现定义方式下对齐。更糟糕的是位域的情况,其中程序员留给了大量自主权:工艺实现可能分配任何足够大的可寻址存储单元来容纳位域。如果还有足够的空间,紧随该结构中另一个位域后面的位域将被打包到同一单元的相邻位中。如果空间不足,则不适合的位域是否放入下一个单元或者重叠相邻单元是由实现定义的。单元内位域的分配顺序(高位向低位或低位向高位)是由实现定义的。可寻址存储单元的对齐未指定。只需计算文本中出现“实现定义”和“未指定”这两个术语的次数。虽然每次运行之前检查结构或联合体在不同系统上的编译器版本、机器和目标架构是不可承受的,但你应该已经得到了一个体面的答案。现在假设有一种绕过的方法。务必明确这并不是解决方案,但它是一种常见的方法,在数据结构交换在不同系统之间共享时可以找到:将结构元素打包在值 1(标准字符大小)上。使用打包和精确的结构定义可以导致足够可靠的声明,可用于不同的系统。打包强制编译器去除实现定义的对齐,减少由标准引起的任何不兼容性。此外,避免使用位域,可以消除残留的实现相关不一致性。最后,由于缺乏对齐,访问效率可以通过手动添加一些虚拟声明来重新创建,这些声明被精心设计为强制将每个字段回到正确的对齐方式。作为一个残余情况,你必须考虑一些编译器添加的结构填充物,但因为没有有用的数据相关联,所以可以忽略它(除非是用于动态空间分配,但同样可以处理)。

4
ISO C规定,如果两个不同翻译单元中的struct类型具有相同的标记和成员,则它们是兼容的。更确切地说,以下是C99标准的正文:

6.2.7 兼容类型和复合类型

如果两种类型的类型相同,则它们具有兼容类型。确定两种类型是否兼容的其他规则在类型说明符的6.7.2节,类型限定符的6.7.3节和声明符的6.7.5节中描述。此外,如果在单独的翻译单元中声明了两个结构、联合或枚举类型,则它们的标记和成员满足以下要求时是兼容的:如果一个用标记声明,则另一个必须用相同的标记声明。如果两者都是完整类型,则还需满足以下附加要求:其成员之间应该存在一一对应关系,以便每对相应成员都是用兼容类型声明的,并且如果相应一对成员中的一个带有名称,则另一个成员也带有相同的名称。对于两个结构体,相应的成员应该按相同的顺序声明。对于两个结构体或联合体,相应的位域应该具有相同的宽度。对于两个枚举类型,相应的成员应该具有相同的值。

如果我们从“标记或成员名称会影响填充吗”的角度来解释它,那么似乎非常奇怪。但基本上这些规则是尽可能严格的,同时又允许常见情况:多个翻译单元通过头文件共享完全相同的结构体声明。如果程序遵循更松散的规则,则不会出错;只是它们不是依赖于标准的行为要求,而是依赖于其他地方的行为要求。

在您的示例中,由于只具有结构等价性而没有等效的标记和成员名称,因此您正在违反语言规则。实际上,这并没有得到执行;在不同的翻译单元中具有不同标记和成员名称的结构体类型仍然是事实上物理兼容的。所有技术都依赖于此,例如从非C语言到C库的绑定。

如果两个项目都是用C(或C++)编写的,那么将定义放入公共头文件中可能值得一试。

还可以考虑加入一些防止版本问题的防御措施,例如大小字段:

// Widely shared definition between projects affecting interop!
// Do not change any of the members.
// Add new ones only at the end!
typedef struct a
{
    size_t size; // of whole structure
    int a;
    double b;
    char c;
} a;

这个想法是,任何构造一个 a 实例的人必须将 size 字段初始化为 sizeof (a)。然后当对象被传递到另一个软件组件(也许来自另一个项目)时,它可以将大小与 sizeof (a) 进行比较。如果大小字段更小,则它知道构造 a 的软件正在使用旧的声明,其中包含较少的成员。因此,不存在的成员不应被访问。

2

任何特定编译器应该是确定性的,但在任何两个编译器之间,甚至是使用不同编译选项的同一编译器,或者甚至是在同一编译器的不同版本之间,都不能保证结果相同。

如果你不依赖于结构的细节,或者如果你确实依赖于它们,你应该嵌入代码以在运行时检查结构是否符合你的要求,这样会更好。

一个很好的例子是从32位架构到64位架构的最近变化,即使你没有改变结构中使用的整数的大小,部分整数的默认打包方式也会发生变化;以前,在一排3个32位整数中,它们可以完美地打包,现在它们打包成了两个64位插槽。

你不可能预测未来会发生什么变化;如果你依赖于语言不保证的细节,比如结构打包,你应该在运行时验证你的假设。


-1

是的。您应该始终假定编译器具有确定性行为。

[编辑] 从下面的评论中可以明显看出,有很多Java程序员在阅读上面的问题。让我们明确一点:C结构体不会在目标文件、库或dll中生成任何名称、哈希值或类似内容。C函数签名也不会引用它们。这意味着,成员变量的名称可以随意更改 - 真的!- 只要成员变量的类型和顺序相同。在C中,示例中的两个结构是等效的,因为打包不会改变。这意味着,在C中,以下滥用是完全有效的,并且在一些最广泛使用的库中肯定会发现更糟糕的滥用。

[EDIT2] 永远不要在C++中尝试以下任何操作

/* the 3 structures below are 100% binary compatible */
typedef struct _a { int a; double b; char c; }
typedef struct _b { int d; double e; char f; }
typedef struct SOME_STRUCT { int my_i; double my_f; char my_c[1]; }

struct _a a = { 1, 2.5, 'z' };
struct _b b;

/* the following is valid, copy b -> a  */
*(SOME_STRUCT*)&a = *(SOME_STRUCT*)b;
assert((SOME_STRUCT*)&a)->my_c[0] == b.f);
assert(a.c == b.f);

/* more generally these identities are always true. */
assert(sizeof(a) == sizeof(b));
assert(memcmp(&a, &b, sizeof(a)) == 0);
assert(pure_function_requiring_a(&a) == pure_function_requiring_a((_a*)&b));
assert(pure_function_requiring_b((b*)&a) == pure_function_requiring_b(&b));

function_requiring_a_SOME_STRUCT_pointer(&a);  /* may generate a warning, but not all compiler will */
/* etc... the name space abuse is limited to the programmer's imagination */

3
如果C标准没有特别规定,那么编译器可能不会使用随机数生成器,但这并不意味着不存在风险。即使编译器隐式地定义了未定义行为,该行为仍然是未定义的。此外,这样做会降低代码的可移植性。因此,“真实”只能说是在某种程度上成立。 - John Coleman
2
我不反对你的答案(我也没有给它投反对票),但我认为应该加上关于编写非可移植代码危险性的警告。 - John Coleman
1
被踩 - 不是因为我认为你错了,而是因为仅仅断言一个立场而没有任何参考或其他理由对于提问者来说并不特别有帮助。在某些其他语言中,例如,字段可以被排列,以便使用成员名称的适当哈希来寻址,这显然会在 {a,b,c} 结构体和 {d,e,f} 结构体之间有所不同。您应该解释为什么 C 编译器不允许这样做。 - Toby Speight
坦白地说,如果你没有提到哈希和结构体成员名称,我不会回答你的评论。提问者要求在非常特定的上下文中回答问题。我不认为他是在要求一个100页的结构体打包手册,否则他会去查阅手册。我建议你这样做。任何合格的C程序员都知道,任何按顺序包含int、double和char的结构体可以指针转换为任何按顺序包含int、double和char的结构体,只要两者的打包方式相同即可。成员名称并不重要。C库不导出结构体。 - Michaël Roy
1
@Peter:我认为这是对别名规则的误解。(6.5/7) 禁止的不是强制转换;你可以将指向任何结构类型的指针强制转换为指向任何其他结构类型的指针,然后再转回来,而不考虑结构兼容性。你不能做的是使用强制转换后的指针,而且即使指针通过char*进行了“消毒”,也没有帮助。如果最终有一个struct T*和一个struct U*,它们都指向同一个对象,并且你对它们进行了解引用操作,那么你就违反了别名规则,编译器可能会表现得好像这些指针并不是... - rici
显示剩余13条评论

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