将基本类型指针转换为结构体指针——对齐和填充?

9

刚才我回答了一个问题,突然想到了一个有趣的情境,但我不确定它的行为:

假设有一个大小为n的整数数组,由intPtr指向;

int* intPtr;

同时,让我也拥有一个像这样的结构:

typedef struct {
int val1;
int val2;
//and less or more integer declarations goes on like this(not any other type)
}intStruct;

我的问题是,如果我进行强制类型转换 intStruct* structPtr = (intStruct*) intPtr;

如果我遍历结构体的元素,我是否能确保每个元素都正确?在任何架构/编译器中,是否存在错位(由于填充可能导致)的可能性?


2
有趣的问题!现在大多数编译器不会在结构体中引入任何填充,因为整数长度为4字节,数组起始位置很可能对齐...但我认为没有任何保证说这种填充通常不存在。因此,在某些假设的架构中,我认为上述代码可能会失败。 - dsign
我刚用Qt Gcc测试了一下,结果偏移量和数组的相同。即使某些成员被声明为私有,结构体被改变为类或者其中一个整数被改变为数组,这种关系仍然存在。正如其他人指出的那样,这仍然是未定义的行为,所以要保持警觉。 - Ghost2
6个回答

5
标准非常明确,即使是POD结构体(我相信这是最严格的结构体类别之一),成员之间也可以有填充。(“因此,在POD结构体对象中可能存在未命名的填充,但在其开头不会有填充,因为需要实现适当的对齐。”——这是一个非规范性注释,但仍然清楚地表明了意图)。
例如,将标准布局结构体的要求(C++11,§1.8/4)与数组的要求进行对比(§8.3.4/1):
“可平凡复制或标准布局类型(3.9)的对象应占用连续的存储字节。”
“数组类型的对象包含一组N个类型为T的非空子对象,这些对象是连续分配的。”
在数组中,元素本身需要被连续分配,而在结构体中,只需要存储是连续的。
第三种可能性是考虑一个不是平凡可复制或标准布局的结构体/类,这时候存储可能根本不是连续的。例如,实现可能会分别设置一块内存区域来保存所有私有变量和另一块完全独立的内存区域来保存所有公共变量。为了更具体地说明,可以考虑两个定义,如下所示:
class A { 
    int a;
public:
    int b;
} a;

class B {
    int x;
public:
    int y;
} b;

使用这些定义,内存可能会布局如下:
a.a;
b.x;

// ... somewhere else in memory entirely:

a.b;
b.y;

在这种情况下,元素和存储需求都不需要是连续的,因此交错完全分离的结构/类的部分是允许的。
也就是说,第一个元素必须与整个结构体位于同一地址(9.2/17):"指向POD-struct对象的指针,使用reinterpret_cast适当地转换,指向其初始成员(或如果该成员是位域,则指向它所在的单元),反之亦然。"
在您的情况下,您有一个POD结构体,因此(§9.2/17):“使用reinterpret_cast适当地转换后,指向POD结构体对象的指针指向其初始成员(或者如果该成员是位域,则指向其中所在的单元),反之亦然。”由于第一个成员必须对齐,其余成员都是相同类型,因此除了位域之外,在结构体中放置任何类型都不可能真正需要填充(即,您可以将任何类型放入数组中,其中需要连续分配元素)。如果您有比一个字更小的元素,在面向字的机器上(例如早期DEC Alpha),填充可能会使访问变得更简单。例如,早期DEC Alphas(在硬件级别上)只能一次读/写整个(64位)字。因此,让我们考虑四个char元素的结构体。
struct foo { 
   char a, b, c, d;
};

如果需要将它们在内存中连续布局,访问foo::b(例如)将要求CPU加载该字,然后将其向右移动8位,然后掩码以零扩展该字节以填充整个寄存器。
存储将更糟糕- CPU必须加载整个字的当前值,掩码出该部分恰当大小的char,将新值移动到正确位置,将其OR到字中,最后存储结果。
相比之下,在元素之间加入填充,每个元素都变成了简单的加载/存储,没有移位、掩码等操作。
至少据我所知,DEC Alpha的正常编译器中,int是32位,long是64位(它早于long long)。因此,使用四个int的结构体,您可以期望在元素之间看到另外32位的填充(在最后一个元素之后还有32位)。

考虑到您确实有一个POD结构体,您仍然有一些可能性。我可能更喜欢使用offsetof来获取结构体成员的偏移量,创建一个数组,并通过这些偏移量访问成员。我在之前的几个 答案中展示了如何做到这一点。


该架构不符合规范;1.7:3 两个或更多个执行线程可以在互不干扰的情况下更新和访问不同的内存位置。为使这样的架构符合规范,char必须为64位。 - ecatmur
@ecatmur:可能是这样——显然 DEC 在 C++11 被批准时已经消失了(我怀疑惠普不会为 Alpha 投入大量工作来符合 C++11 的规范)。无论如何,至少在我的理解中,“不干扰”仍然只需要修改被临界区包围(很可能是这样的——Alpha 重点支持线程)。 - Jerry Coffin

3

严格来说,这种指针转换是不允许的并会导致未定义的行为。

然而,这种转换的主要问题在于编译器可以在结构体内的任何位置添加任意数量的填充字节,除了第一个元素之前。因此,它是否起作用取决于特定系统的对齐要求,以及结构体填充是否启用。

int 的大小不一定与可寻址数据块的最佳大小相同,尽管这对大多数32位系统都是正确的。有些32位系统不关心不对齐,有些将允许不对齐但产生效率较低的代码,而有些必须对数据进行对齐。理论上,64位系统也可能想在 int(在那里为32位)之后添加填充以获得64位块,但实际上它们支持32位指令集。

如果你写代码依赖于这个转换,你应该添加像这样的内容:

static_assert (sizeof(intStruct) == 
               sizeof(int) + sizeof(int));

2
允许在任何两种指针类型之间进行显式指针转换(C风格或reinterpret_cast),但成员指针除外。编译器不会报错。 - Jan Hudec
通过 void* 进行强制类型转换并不能使任何事情变得更好。在 C++ 中,有些情况下通过 void* 进行强制类型转换会使情况变得更糟,尽管这不是其中之一。 - Jan Hudec
@JanHudec 好的,也许它不会给出编译器错误,但它是不被允许的,这是未定义行为。C11 6.3.2.3/7:“对象类型的指针可以转换为指向不同对象类型的指针。如果所得到的指针对于引用类型没有正确对齐,则其行为是未定义的。” - Lundin
@JanHudec 请再次阅读帖子。除了第一个元素之前的所有元素。 - Lundin
1
只要结构体本身对齐,结构体的成员也会对齐,数组的元素也会对齐。因此,所有指针的类型对齐都是正确的。唯一不能保证的是不会有额外的填充字节存在。我相信填充字节是由具体实现定义的,而不是未定义的,所以如果在特定的实现上正常工作,就保证会继续工作下去。 - Jan Hudec
显示剩余3条评论

3

如果元素类型是标准布局,那么它保证是合法的。注意:以下所有引用都来自标准。

8.3.4 数组 [dcl.array]

1 - [...] 数组类型的对象包含一个类型为T、大小为N的连续分配的非空子对象集。[...]

关于一个成员类型为Tstruct,其具有N个成员,

9.2 类成员 [class.mem]

14 - 一个非联合类的非静态数据成员,如果访问控制相同,则在类对象中后面的成员具有更高的地址。[...] 实现对齐要求可能导致两个相邻的成员不会立即相邻分配 [...]
20 - 指向标准布局结构体对象的指针,经过适当的reinterpret_cast转换,指向其初始成员 [...] 反之亦然。[注:因此,在标准布局结构体对象内部可能存在未命名的填充,但不会在其开头出现,这是为了实现适当的对齐。—结束语]

所以问题是struct内任何需要对齐的填充是否会导致其成员与彼此不相邻分配。答案是:

1.8 C++对象模型 [intro.object]

4 - [...] 一个平凡可复制或标准布局类型的对象应占用连续的存储字节。

换句话说,一个包含至少两个相同(标准布局)类型的成员x、y的标准布局结构体a,如果不满足身份证明&a.y == &a.x + 1,则违反了1.8:4。请注意,对齐方式定义为(3.11对齐方式[basic.align])给定对象可以分配的连续地址之间的字节数;因此,类型T的对齐方式不能大于T数组中相邻对象之间的距离,并且(由于5.3.3大小[expr.sizeof]指定n个元素的数组的大小为n乘以一个元素的大小),alignof(T)不能大于sizeof(T)。因此,相同类型结构体相邻元素之间的任何额外填充都不需要对齐,因此不符合9.2:14。
关于AProgrammer的观点,我认为在《26.4复数[complex.numbers]》中,语言要求std::complex<T>的实例在成员位置方面应该表现得像标准布局类型,而不必满足所有标准布局类型的要求。

我不能同意。标准布局类型必须使用连续存储,但这并不意味着其中的元素必须是连续的。它仍然可以在元素之间具有填充。 - Jerry Coffin
@JerryCoffin,1.8:4中的contiguous是什么意思?它与8.3.4:1有何不同?请解释一下。 - ecatmur

2

这里的行为几乎肯定取决于编译器、体系结构和ABI。但是,如果您使用的是gcc,您可以利用__attribute__((packed))强制编译器将结构成员一个接一个地打包,而不需要任何填充。有了这个,内存布局应该与平坦数组的布局相匹配。


1

C结构体的典型对齐方式保证了结构体中的数据成员按顺序存储,这与C数组相同。因此,顺序不可能是一个问题。

至于对齐,由于您只有一种数据类型(int),尽管编译器有资格这样做,但没有必要添加填充以对齐数据成员。编译器可以在结构体开始之前添加填充,但不能在数据结构的开头添加填充。因此,如果编译器在您的情况下添加填充,则

而不是这样: [4字节int] [4字节int] [4字节int] ... [4字节int]

您的数据结构将被存储如下:
[4字节数据][4字节填充][4字节数据]... 这是不合理的。

总的来说,我认为在您的情况下,这个转换应该可以正常工作,尽管我认为使用它是不好的实践。


1
我曾经搜索过,但没有找到任何保证它有效的东西。在C++中,对于std::complex<>的情况,我已经找到了明确的保证,如果更普遍地适用,可能会更容易制定,因此我怀疑我在搜索中错过了什么(但是缺乏证据很难证明不存在,而且标准有时在其表述上很模糊)。

这个搜索是在C++11及其StandardLayout概念之前进行的吗? - MSalters
当时既有C++03,也有草案状态的C++11 - 我记得例如std::complex<>这个东西只存在于C++11中。 - AProgrammer

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