跳到主要内容

VC中的字节对齐

· 阅读需 24 分钟

当我们在C语言中定义一个结构体时,其大小是否等于所有字段大小的总和?编译器又是如何在内存中安排这些字段的?ANSI C标准对结构体的内存布局有哪些规定?而我们的程序是否能够依赖这些布局?这些问题可能困扰着不少开发者,因此本文将试图揭开其中的奥秘。

结构体的内存布局

字段顺序与内存地址

首先,有一点是确定的:ANSI C保证结构体中各字段在内存中的位置与其声明顺序一致,且第一个字段的首地址与整个结构体实例的首地址相同。例如,以下代码验证了这一点:

struct vector { 
int x, y, z;
} s;

int *p, *q, *r;
struct vector *ps;

p = &s.x;
q = &s.y;
r = &s.z;
ps = &s;

assert(p < q); // x 的地址小于 y
assert(p < r); // x 的地址小于 z
assert(q < r); // y 的地址小于 z
assert((int*)ps == p); // 结构体地址等于第一个字段地址

在这个例子中,断言始终成立。然而,有读者可能会好奇:ANSI C是否保证相邻字段在内存中也是紧邻的?
答案是否定的,ANSI C标准并未对此做出保证。因此,程序中不应假定字段在内存中是连续的。

尽管如此,我们仍然可以通过了解编译器和平台的实现,构建一幅更清晰的内存布局图。要做到这一点,需要先了解一个关键概念——内存对齐

内存对齐的概念

在许多计算机系统中,基本类型数据在内存中的存放位置受到限制。具体来说,某些数据类型的首地址必须是某个特定值 ( k )(如4或8)的倍数。这种限制称为内存对齐,而 ( k ) 被称为数据类型的对齐模数(alignment modulus)。

如果一种数据类型 ( S ) 的对齐模数是另一种数据类型 ( T ) 的整数倍,则称 ( S ) 的对齐要求比 ( T ) 更严格,反之 ( T ) 则更宽松。

内存对齐的主要目的是:

  1. 简化处理器与内存之间的传输设计。
  2. 提高数据访问效率。例如,一个8字节对齐的 double 类型变量只需要一次内存操作即可读取;而未对齐的数据可能需要两次内存操作,因为它可能跨越两个对齐块。

某些处理器在遇到未对齐数据时会产生错误,但Intel的IA32架构处理器可以正确处理未对齐数据。不过,Intel建议开发者尽量遵循对齐要求以提升性能。

不同平台的对齐规则

在Win32平台上,微软C编译器(cl.exe for 80x86)默认使用以下规则:任何基本数据类型的对齐模数等于其大小(即 sizeof(T))。例如:

  • double 类型(占8字节)的地址必须是8的倍数。
  • int 类型(占4字节)的地址必须是4的倍数。
  • char 类型(占1字节)则可以存储在任何地址。

这种规则确保了基本数据类型的访问效率。

在Linux平台上,GCC的对齐规则稍有不同(以下内容根据文档整理,未验证,如有错误请指正):

  • 任何2字节大小的数据类型(如 short)的对齐模数为2。
  • 超过2字节大小的数据类型(如 longdouble)的对齐模数为4。

这些差异表明,结构体的内存布局并不完全统一,而是依赖于编译器和平台。了解这些规则后,我们可以更好地优化代码,同时避免潜在的跨平台问题。

结构体布局的实例分析

结构体的对齐与填充

现在,我们回到结构体的讨论中。ANSI C规定,一个结构体的大小等于其所有字段大小与填充字节的总和。那么,什么是填充字节?填充字节是编译器为了满足字段的对齐要求而在字段之间或结构体尾部额外分配的空间。

此外,结构体本身也有对齐要求。根据ANSI C标准,结构体的对齐模数必须是其所有字段中对齐模数最严格的那个。虽然标准允许编译器使用更严格的对齐方式,但这并非强制要求。例如,微软的VC7.1编译器默认让结构体的对齐要求与最严格字段的对齐要求相同。

以下示例展示了填充字节如何影响结构体的布局:

简单结构体的布局

typedef struct ms1 {  
char a;
int b;
} MS1;

假设 MS1 的内存布局如下:

|   a   |       b       |
Bytes: 1 4

MS1 中,对齐模数最严格的字段是 bint),其对齐模数为4。因此,根据对齐规则,MS1 的首地址必须是4的倍数。但在这种布局下,b 的地址并未满足4字节对齐要求。

为了满足要求,编译器会在 ab 之间插入填充字节(padding):

|   a   | padding |       b       |
Bytes: 1 3 4

这种布局使得 b 的地址对齐到4的倍数,同时满足了结构体整体对齐的要求。最终,sizeof(MS1) 为8字节,b 字段相对于结构体首地址的偏移量为4。

复杂结构体的布局

现在,我们交换字段的顺序:

typedef struct ms2 {  
int a;
char b;
} MS2;

如果按照直觉,MS2 的内存布局可能是:

|       a       |   b   |
Bytes: 4 1

这种布局看似合理,但忽略了一个重要问题——数组的连续性

结构体数组的对齐

ANSI C标准规定,任何类型(包括结构体)的数组,所有元素必须是连续存储的。因此,一个 MS2 数组的布局应满足以下条件:

|<- array[0] ->|<- array[1] ->|<- array[2] ->| ...

如果 MS2 的大小是5字节,那么每个结构体元素之间会出现空隙,违反了数组的连续性规则。

为了避免这种情况,编译器会在 b 后面添加填充字节,使结构体的大小对齐到 a 的对齐模数(即4的倍数)。最终,MS2 的实际内存布局如下:

|       a       |   b   | padding |
Bytes: 4 1 3

因此,sizeof(MS2) 为8字节,确保数组元素可以连续存储。

当数组的首地址满足对齐要求时,我们需要确保数组中的每个元素都符合结构体字段的对齐规则。如果某个字段无法在数组中连续对齐,则需要通过增加填充字节解决这一问题。

例如,对于以下结构体:

typedef struct ms2 {
int a;
char b;
} MS2;

通过调整布局,可以让数组中每个元素的所有字段都满足对齐要求:

|       a       |   b   | padding |
Bytes: 4 1 3

这种布局确保了数组中的每个元素(如 array[0].aarray[1].a)都对齐到 4 字节的边界,同时满足字段的对齐规则。sizeof(MS2) 为 8,a 的偏移量为 0,b 的偏移量为 4。

更复杂的结构体

接下来,分析一个稍微复杂的结构体类型:

typedef struct ms3 {
char a;
short b;
double c;
} MS3;

通过以下步骤,我们可以推导出正确的内存布局:

  1. a 是一个 char 类型,占用 1 字节。
  2. b 是一个 short 类型,占用 2 字节,但要求从偶数地址开始,因此在 a 后面填充 1 个字节。
  3. c 是一个 double 类型,占用 8 字节,要求从 8 的倍数地址开始。在 b 后面需要填充 4 个字节。

最终的内存布局如下:

|   a   | padding |   b   | padding |       c       |
Bytes: 1 1 2 4 8

根据该布局:

  • sizeof(MS3) 为 16。
  • 字段偏移:a 为 0,b 为 2,c 为 8。

嵌套结构体

现在,考虑一个包含其他结构体类型的结构体:

typedef struct ms4 {
char a;
MS3 b;
} MS4;
  1. a 是一个 char 类型,占用 1 字节。
  2. b 是一个 MS3 类型,其对齐模数等于 double 的对齐模数(8 字节)。因此,在 a 后面需要填充 7 个字节以满足对齐要求。

布局如下:

|   a   | padding |               b               |
Bytes: 1 7 16

因此:

  • sizeof(MS4) 为 24。
  • 字段偏移:a 为 0,b 为 8。

对齐规则的自定义与平台依赖性

改变对齐规则

在实际开发中,可以通过编译器选项来更改对齐规则。例如,在 Visual C++ 中,使用 /Zp 选项可以指定最大对齐模数 (n 可以是 1、2、4、8 或 16)。这些对齐规则的效果如下:

  • 小于或等于 n 字节的基本数据类型,其对齐方式保持不变。
  • 大于 n 字节的类型,其对齐模数将受到限制,不超过 n

例如,默认情况下,VC 的对齐方式相当于 /Zp8。然而,编译器文档(MSDN)明确指出,在某些平台(如 MIPS 和 Alpha)上使用 /Zp1/Zp2 可能导致问题,而在 16 位平台上指定 /Zp4/Zp8 也可能出现问题。这些警告提醒我们,对齐规则的影响不仅仅是内存布局,还可能影响程序的可移植性和性能。

程序的可移植性

由于结构体的内存布局依赖于 CPU 架构、操作系统、编译器以及编译时的对齐选项,开发人员在跨平台开发时必须小心处理:

  1. 避免依赖特定的内存布局:除非绝对必要,否则不应依赖编译器生成的特定对齐方式。例如,当设计开放源码库时,不同用户可能会在不同的平台或编译器下编译代码,这可能导致潜在的兼容性问题。
  2. 模块间对齐一致性:如果程序的不同模块是用不同的对齐选项编译的,可能导致非常微妙且难以调试的错误。在调试复杂行为时,应检查所有模块的编译选项是否一致。

实践与总结

实践与思考题

以下是一些结构体的定义,请分析它们在你的开发环境中的内存布局,并尝试通过调整字段顺序来优化内存使用:

A. struct P1 { int a; char b; int c; char d; };
B. struct P2 { int a; char b; char c; int d; };
C. struct P3 { short a[3]; char b[3]; };
D. struct P4 { short a[3]; char *b[3]; };
E. struct P5 { struct P2 *a; char b; struct P1 arr[2]; };

提示:优化字段顺序时,需要遵循以下原则:

  • 将对齐要求更高的字段放在前面,减少填充字节。
  • 尽量避免因数组或嵌套结构体导致的对齐浪费。

sizeof 的案例分析

考虑以下结构体:

struct MyStruct {
double dda1;
char dda;
int type;
};

从表面看,sizeof(MyStruct) 可能被误以为是 sizeof(double) + sizeof(char) + sizeof(int) = 13。但在实际测试中,结果是 16。这是因为编译器为提高 CPU 访问效率,对成员变量的存储进行了 对齐处理

  1. double 类型占用 8 字节,偏移量必须是 8 的倍数。
  2. char 类型占用 1 字节,但为了满足后续 int 类型的对齐需求,需在后面填充 3 字节。
  3. int 类型占用 4 字节,偏移量必须是 4 的倍数。

因此,实际布局如下:

|   dda1   |   dda   | padding |  type   |
Bytes: 8 1 3 4

在结构体中,每个成员变量的对齐规则根据其数据类型的大小决定,具体如下:

  • char:偏移量必须是 sizeof(char)(1 字节)的倍数。
  • short:偏移量必须是 sizeof(short)(2 字节)的倍数。
  • intfloat:偏移量必须是 sizeof(int)sizeof(float)(4 字节)的倍数。
  • double:偏移量必须是 sizeof(double)(8 字节)的倍数。

此外,为了确保结构体的总大小是其最大对齐要求的倍数,编译器可能会在最后填充一些额外字节。

分配空间与对齐规则

示例 1:原始结构体

struct MyStruct {
double dda1;
char dda;
int type;
};

空间分配过程

  1. double dda1

    • 起始地址偏移量为 0,是 sizeof(double) 的倍数(8),满足对齐规则。
    • 占用 8 字节。
  2. char dda

    • 下一个可用地址偏移量为 8,是 sizeof(char) 的倍数(1),满足对齐规则。
    • 占用 1 字节。
    • 为了满足下一个 int 的对齐要求(偏移量为 4 的倍数),需要填充 3 字节。
  3. int type

    • 下一个可用地址偏移量为 12,是 sizeof(int) 的倍数(4),满足对齐规则。
    • 占用 4 字节。

总大小

  • 成员变量总占用:8 + 1 + 3 + 4 = 16 字节。
  • sizeof(MyStruct):16,正好是最大对齐要求(sizeof(double) = 8)的倍数,无需额外填充。

示例 2:交换成员变量顺序

struct MyStruct {
char dda;
double dda1;
int type;
};

空间分配过程

  1. char dda

    • 起始地址偏移量为 0,是 sizeof(char) 的倍数(1),满足对齐规则。
    • 占用 1 字节。
    • 为了满足下一个 double 的对齐要求(偏移量为 8 的倍数),需要填充 7 字节。
  2. double dda1

    • 下一个可用地址偏移量为 8,是 sizeof(double) 的倍数(8),满足对齐规则。
    • 占用 8 字节。
  3. int type

    • 下一个可用地址偏移量为 16,是 sizeof(int) 的倍数(4),满足对齐规则。
    • 占用 4 字节。

总大小

  • 成员变量总占用:1 + 7 + 8 + 4 = 20 字节。
  • 为了满足最大对齐要求(sizeof(double) = 8)的倍数,结构体末尾需填充 4 字节。
  • sizeof(MyStruct)24

在上述两个例子中,填充字节的数量差异明显:

  1. 示例 1:填充了 3 字节。
  2. 示例 2:填充了 7 + 4 = 11 字节。

这表明,字段的声明顺序会显著影响结构体的内存布局和填充字节的数量。合理调整字段顺序可以显著减少内存浪费。

结构对齐的自定义控制与 #pragma pack

在默认情况下,VC 提供了对齐机制以提升 CPU 访问速度,但在某些场景下,我们需要调整或屏蔽这种默认对齐方式。通过 #pragma pack(n) 指令,可以自定义结构体的对齐方式。

  1. 成员变量的起始地址偏移规则

    • 如果 n 大于等于 变量类型占用的字节数,则偏移量遵循默认对齐规则。
    • 如果 n 小于 变量类型占用的字节数,则偏移量为 n 的倍数。
  2. 结构体总大小的约束规则

    • 如果 n 大于等于 所有成员变量的对齐需求,则结构体总大小是最大对齐单位的倍数
    • 如果 n 小于 所有成员变量的对齐需求,则结构体总大小是 n 的倍数。
#pragma pack(push)  // 保存当前对齐状态
#pragma pack(4) // 设置为 4 字节对齐

struct Test {
char m1;
double m4;
int m3;
};

#pragma pack(pop) // 恢复之前的对齐状态
  1. char m1

    • 起始地址偏移量为 0,是 sizeof(char) 的倍数(1),占用 1 字节。
    • 下一成员变量需要满足 4 字节对齐,因此填充 3 字节。
  2. double m4

    • 偏移量为 4,满足 4 字节对齐规则,占用 8 字节。
  3. int m3

    • 偏移量为 12,满足 4 字节对齐规则,占用 4 字节。

总大小

  • 成员变量占用:1 + 3 + 8 + 4 = 16 字节。
  • 满足 4 字节对齐,无需额外填充。
  1. 结构的总大小
    • 总占用为 20 字节,不是 16 的倍数,需填充 4 字节。
    • 最终 sizeof(Test)24

通过这种方式,我们可以灵活控制结构体的对齐方式,以满足内存优化或跨平台兼容的需求。

sizeof 的用法总结

在 C++ 中,sizeof 是一个非常强大的工具,用于确定数据类型、变量、数组、指针、结构或类的大小。但它也容易引发理解误区。以下总结 sizeof 的常见使用场景与注意事项:

A. 数据类型或一般变量

  • 示例:sizeof(int)sizeof(long)
  • 注意:结果可能因系统或编译器而异。例如:
    • 在 16 位系统中,int2 字节;
    • 在 32 位或 64 位系统中,int 通常占 4 字节。

B. 数组与指针

  • 示例:
    int a[50];         // sizeof(a) = 4 * 50 = 200
    int *p = new int[50]; // sizeof(p) = 4(在 32 位系统中,指针占 4 字节)
    注意
    • 数组名是数组的起始地址,但 sizeof 会计算整个数组的大小。
    • 对于指针,sizeof 返回指针本身的大小,而不是指针指向的内容。

C. 结构体与类

  1. 静态成员

    • 静态成员不参与实例的大小计算,因为它们的存储与实例无关。
    • 示例:
      class Test {
      int a;
      static double c;
      };
      sizeof(Test) = 4; // 静态成员不影响结果
  2. 空结构体或类

    • 即使没有成员变量,结构体或类的大小也为 1,以确保实例有唯一的地址。
    • 示例:
      class Empty {};
      sizeof(Empty) = 1;

D. 函数参数与返回值

  • 示例:
    int func(char s[5]) {
    return 1;
    }
    sizeof(func("1234")) = 4; // 返回值类型为 int,因此结果为 4
    注意
    • 数组参数在函数中会被视为指针,sizeof 返回指针的大小(而不是数组本身的大小)。

实用建议

  1. 分析内存布局:理解结构体的对齐规则与大小计算方法,能帮助定位潜在的性能问题与内存浪费。
  2. 跨平台兼容:当开发需要在多种系统或编译器下运行的程序时,应避免直接依赖 sizeof 的结果,而是明确指定数据类型的大小(如使用 stdint.h 提供的固定大小整数类型)。
  3. 善用对齐指令:通过 #pragma pack 调整对齐方式,可以在性能和内存使用之间找到平衡点。