C语言结构体成员对齐、填充和数据打包
文章目录
- C语言结构体成员对齐、填充和数据打包
- 1、内存中的数据对齐
- 2、C语言中的结构填充
- 3、如何减少结构填充
- 4、C语言中结构填充的常见问题解答
在 C 语言中,结构用作数据包。它们不提供任何数据封装或数据隐藏功能。在本文中,我们将讨论 C 语言中结构体填充的属性以及数据对齐和结构打包。
1、内存中的数据对齐
C 中的每种数据类型都有对齐要求(实际上,它是由处理器架构而不是语言强制要求的)。处理器的处理字长与数据总线大小的字长相同。在 32 位计算机上,处理字大小为 4 个字节。
从历史上看,内存是字节可寻址的,并按顺序排列。如果内存被安排为一个字节宽度的单个组,则处理器需要发出 4 个内存读取周期来获取整数。在一个内存周期内读取整数的所有 4 个字节更经济。为了利用这种优势,存储器将排列为一组 4 个组,如上图所示。
内存寻址仍是顺序的。如果Bank0 占用地址 X,则Bank1、Bank2 和Bank3 将位于 (X + 1)、(X + 2) 和 (X + 3) 地址。如果在 X 地址上分配了 4 个字节的整数(X 是 4 的倍数),则处理器只需要一个内存周期即可读取整个整数。然而,如果整数分配在 4 的倍数以外的地址,则它跨越两行Bank,如下图所示。这样的整数需要两个内存读取周期来获取数据。
一个变量的数据对齐方式涉及到数据在这些存储区中的存储方式。例如,在 32 位计算机上,int 的自然对齐方式为 4 个字节。当数据类型自然对齐时,CPU 会在最短的读取周期内获取它。
同样,短 int 的自然对齐方式为 2 个字节。这意味着短 int 可以存储在 bank 0 – bank 1 对或 bank 2 – bank 3 对中。双精度需要 8 个字节,并在内存库中占据两行。任何 double 的未对齐都将强制两个以上的读取周期来获取 double 数据。
请注意,在 32 位计算机上,将在 8 字节边界上分配双精度变量,并且需要两个内存读取周期。在 64 位机器上,基于多个Bank,将在 8 字节边界上分配一个双精度变量,并且只需要一个内存读取周期。
2、C语言中的结构填充
结构填充是在结构中添加一些空字节的内存,以自然地对齐内存中的数据成员。这样做是为了最小化 CPU 读取周期,以检索结构中的不同数据成员。
尝试计算以下结构的大小:
// structure A
typedef struct structa_tag {
char c;
short int s;
} structa_t;
// structure B
typedef struct structb_tag {
short int s;
char c;
int i;
} structb_t;
// structure C
typedef struct structc_tag {
char c;
double d;
int s;
} structc_t;
// structure D
typedef struct structd_tag {
double d;
int s;
char c;
} structd_t;
通过直接将所有成员的大小相加来计算每个结构的大小,我们得到:
- struct A 的大小 = (char + short int) 的大小 = 1 + 2 = 3。
- struct B 的大小 = (short int + char + int) = 2 + 1 + 4 = 7 。
- struct C 的大小 = (char + double + int) 的大小 = 1 + 8 + 4 = 13。
- struct D 的大小 = (double + int + char) 的大小 = 8 + 4 + 1= 13。
// C Program to demonstrate the structure padding property
#include <stdio.h>
// Alignment requirements
// (typical 32 bit machine)
// char 1 byte
// short int 2 bytes
// int 4 bytes
// double 8 bytes
// structure A
typedef struct structa_tag {
char c;
short int s;
} structa_t;
// structure B
typedef struct structb_tag {
short int s;
char c;
int i;
} structb_t;
// structure C
typedef struct structc_tag {
char c;
double d;
int s;
} structc_t;
// structure D
typedef struct structd_tag {
double d;
int s;
char c;
} structd_t;
int main()
{
printf("sizeof(structa_t) = %lu\n", sizeof(structa_t));
printf("sizeof(structb_t) = %lu\n", sizeof(structb_t));
printf("sizeof(structc_t) = %lu\n", sizeof(structc_t));
printf("sizeof(structd_t) = %lu\n", sizeof(structd_t));
return 0;
}
正如我们所看到的,结构的大小与我们计算的大小不同。
这是因为各种数据类型的对齐要求,结构的每个成员都应该自然对齐。结构的成员按升序顺序分配。
让我们分析一下上面程序中声明的每个结构。为了方便起见,假设每个结构类型变量都分配在一个 4 字节的边界上(比如 0x0000),即结构的基址是 4 的倍数(不一定总是这样,请参阅structc_t的解释)。
structure A
structa_t第一个元素是 char,它对齐一个字节,然后是 short int。short int 是 2 个字节对齐。如果短 int 元素在 char 元素之后立即分配,它将从一个奇数地址边界开始。编译器将在 char 后插入一个填充字节,以确保短 int 的地址倍数为 2(即 2 个字节对齐)。structa_t的总大小将是:
sizeof(char) + 1 (padding) + sizeof(short), 1 + 1 + 2 = 4字节
structure B
structb_t 的第一个成员是短 int,后跟 char。由于 char 可以在任何字节边界上,因此短 int 和 char 之间不需要填充,因此它们总共占用 3 个字节。下一个成员是 int。如果立即分配 int,它将从奇数字节边界开始。我们需要在 char 成员之后填充 1 字节,以使下一个 int 成员的地址 4 字节对齐。总的来说,
2 + 1 + 1 (padding) + 4 = 8字节
structure C
每个结构也将有对齐要求。
应用相同的分析,structc_t需要 sizeof(char) + 7 字节填充 + sizeof(double) + sizeof(int) = 1 + 7 + 8 + 4 = 20 字节。但是,sizeof(structc_t) 为 24 个字节。这是因为,除了结构成员外,结构类型变量也将具有自然对齐。让我们通过一个例子来理解它。比如说,我们声明了一个structc_t数组,如下所示
structc_t structc_array[3];
假设structc_array的基址是0x0000的,以便于计算。如果structc_t占用我们计算的 20 (0x14) 字节,则第二个structc_t数组元素(索引为 1)将在 0x0000 + 0x0014 = 0x0014。它是数组的索引 1 元素的起始地址。此structc_t的 double 成员将在 0x0014 + 0x1 + 0x7 = 0x001C(十进制 28)上分配,它不是 8 的倍数,并且与 double 的对齐要求冲突。正如我们在顶部提到的,double 的对齐要求是 8 个字节。
为了避免这种错位,编译器将对齐要求引入到每个结构中。它将作为结构中最大的成员。在我们的例子中,structa_t 的对齐是 2,structb_t 是 4,structc_t 是 8。如果我们需要嵌套结构,则最大内部结构的大小将是直接较大结构的对齐方式。
在上述程序structc_t,int 成员后面将有一个 4 字节的填充,以使结构大小是其对齐方式的倍数。因此,(structc_t) 的大小为 24 字节。即使在阵列中,它也能保证正确的对齐。
structure D
以类似的方式,结构 D 的大小为:
sizeof(double) + sizeof(int) + sizeof(char) + padding(3) = 8 + 4 + 1 + 3 = 16字节
3、如何减少结构填充
到现在为止,我们知道填充可能是不可避免的。有一种方法可以最小化填充。程序员应按大小递增/递减的顺序声明结构成员。我们的代码中structd_t给出了一个示例,其大小为 16 个字节,而不是 24 个字节的structc_t。
什么是结构体对齐?
有时,必须避免在结构成员之间填充字节。例如,读取 ELF 文件头或 BMP 或 JPEG 文件头的内容。我们需要定义一个类似于标题布局的结构并映射它。但是,在接触这些成员时应谨慎行事。通常,逐字节读取是避免未对齐异常的一种选择,但会对性能造成影响。
大多数编译器都提供非标准扩展来关闭默认填充,如编译指示或命令行开关。有关更多详细信息,请参阅相应编译器的文档。
在 GCC 中,我们可以使用以下代码进行结构包装:
#pragma pack(1)
或者
struct name {
...
}__attribute__((packed));
如下面代码所示:
// C Program to demonstrate the structure packing
#include <stdio.h>
#pragma pack(1)
// structure A
typedef struct structa_tag {
char c;
short int s;
} structa_t;
// structure B
typedef struct structb_tag {
short int s;
char c;
int i;
} structb_t;
// structure C
typedef struct structc_tag {
char c;
double d;
int s;
} structc_t;
// structure D
typedef struct structd_tag {
double d;
int s;
char c;
} structd_t;
int main()
{
printf("sizeof(structa_t) = %lu\n", sizeof(structa_t));
printf("sizeof(structb_t) = %lu\n", sizeof(structb_t));
printf("sizeof(structc_t) = %lu\n", sizeof(structc_t));
printf("sizeof(structd_t) = %lu\n", sizeof(structd_t));
return 0;
}
sizeof(structa_t) = 3
sizeof(structb_t) = 7
sizeof(structc_t) = 13
sizeof(structd_t) = 13
4、C语言中结构填充的常见问题解答
1)堆栈是否应用对齐?
是的。堆栈也是内存。系统程序员应使用正确对齐的内存地址加载堆栈指针。通常,处理器不会检查堆栈对齐,程序员有责任确保堆栈内存的正确对齐。任何错位都会导致运行时意外。
例如,如果处理器字长度为 32 位,则堆栈指针也应对齐为 4 个字节的倍数。
2)如果 char 数据放置在 bank 0 以外的 bank 中,则在读取内存时,它将被放置在错误的数据行上。处理器如何处理字符类型?
通常,处理器会根据指令识别数据类型(例如,ARM 处理器上的 LDRB)。根据存储的字节库,处理器将字节移位到最低有效数据线上。
3)当参数在堆栈上传递时,它们是否受到对齐的影响?
是的。编译器帮助程序员进行正确的对齐。例如,如果将 16 位值推送到 32 位宽的堆栈上,则该值会自动填充到 32 位的零。请考虑以下程序。
void argument_alignment_check( char c1, char c2 )
{
// Considering downward stack
// (on upward stack the output will be negative)
printf("Displacement %d\n", (int)&c2 - (int)&c1);
}
在 32 位计算机上,输出将为 4。这是因为由于对齐要求,每个字符占用 4 个字节。
4)如果我们尝试访问未对齐的数据,会发生什么?
这取决于处理器体系结构。如果访问未对齐,处理器会自动发出足够的内存读取周期,并将数据正确打包到数据总线上。惩罚是针对绩效的。而很少有处理器没有最后两条地址线,这意味着无法访问奇数字节边界。每个数据访问都必须正确对齐(4 个字节)。未对准访问是此类处理器的一个关键例外。如果忽略异常,则读取的数据将不正确,因此结果将不正确。
5)有没有办法查询数据类型的对齐要求?
是的。编译器为此类需求提供非标准扩展。例如,Visual Studio 中的 __alignof() 有助于获取数据类型的对齐要求。有关详细信息,请阅读 MSDN。
6)当内存读取在 32 位计算机上一次读取 4 个字节时,为什么要在 8 字节边界上对齐双精度类型?
需要注意的是,大多数处理器都有一个数学协处理器,称为浮点单元 (FPU)。代码中的任何浮点运算都将转换为 FPU 指令。主处理器与浮点执行无关。所有这些都将在幕后完成。
按照标准,double 类型将占用 8 个字节。而且,在 FPU 中执行的每个浮点运算都将是 64 位长度。甚至浮点类型也会在执行之前提升到 64 位。
FPU 寄存器的 64 位长度强制在 8 字节边界上分配 double 类型。假设在 FPU 操作的情况下,数据获取可能会有所不同,意思是数据总线,因为它进入 FPU。因此,对于双精度类型(预计位于 8 字节边界上),地址解码将有所不同。这意味着浮点单元的地址解码电路将没有最后 3 个引脚。