字节对齐

计算机中内存大小的基本单位是字节(byte),理论上来讲,可以从任意地址访问某种基本数据类型。 但是实际上,计算机并非逐字节大小读写内存,而是以2,4,8…的倍数的字节块来读写内存,如此一来就会对基本数据类型的合法地址作出一些限制,即它的地址必须是2,4,8…的倍数。那么就要求各种数据类型按照一定的规则在空间上排列,这就是字节对齐。

字节对齐机制主要是为了访存效率,因为对齐的字节访存效率更高。

计算机底层存储硬件比如内存、CPU cache、寄存器等的访问都不是一次一个字节而是一次一批/一组:

  • 字(word):CPU指令处理的数据单元,分为WORD(16bits)、DWORD(32bits)、QWORD(64bits)
  • 寄存器(register):CPU通用寄存器通常是64bits,也允许访问寄存器的前8bits、前16bits、前32bits。
  • 缓存行(cache line):长度通常是512bits,即64字节。
  • 页(page):一个页大小通常是4096字节。 例如一个4字节的整除原本只需要一次访存,如果组织不当导致跨过了两个cache line的边界则需要两次访存,开销高了一倍!

所以字节对齐的本质就是在内存空间占用和访存效率之间做折中

struct字节对齐准则:

  1. 不允许对结构体成员进行重排序,即成员的内存排列顺序一定是定义顺序
  2. 结构体第一个成员的偏移量(offset)为0
  3. 结构体每个成员相对第一个成员的offset都是其size的整数倍,如不满足,对前一个成员填充字节以满足。
  4. 结构体的总size为结构体中最大成员的size的整数倍,如不满足,在结构体最后填充字节以满足。
  5. 结构体嵌套时,递归的按照结构体中最大的那个成员对齐。(注意不是最大的结构体,而是结构体中最大的那个成员)
#include <cstdio>
#include <cstdint>

// 空结构体和空类所占内存大小为1
// 编译器会给空类/结构体隐式的加一个字节,确保每个实例在内存中有唯一的地址
struct DemoStruct {
};

struct DemoStruct0 {
    char c1;     // 1B
    int i1;      // 4B
    uint64_t n1; // 8B
};

struct DemoStruct1 {
    char c1;     // 1B
    uint64_t n1; // 8B
    int i1;      // 4B
};

int main()
{
    // 1
    printf("sizeof(DemoStruct)=%lu\n", sizeof(DemoStruct));
    // 16
    printf("sizeof(DemoStruct0)=%lu\n", sizeof(DemoStruct0));
    // 24
    printf("sizeof(DemoStruct1)=%lu\n", sizeof(DemoStruct1));
    return 0;
}

再看嵌套struct的例子:

#include <cstdio>
#include <cstdint>
#include <cstddef>


struct DemoStruct2 {
    short s1;                  // 2B
    short s2;                  // 2B
                               // Padding 4B(因为sdb1结构体是8字节对齐的, 所以sdb1.sc1相对于首地址的偏移必须是8的倍数,所以在sdb1.sc1之前填充4B)
    struct SubDemoStruct1 {
        char sc1;              // 1B
                               // Padding 7B
        long long int slli1;   // 8B
    } sdb1;
    struct SubDemoStruct2 {
        char sc1;              // 1B
    } sdb2;
                               // Padding 3B(因为sdb3结构体是4字节对齐的, 所以sdb3.si1相对于首地址的偏移必须是4的倍数,所以在sdb3.si1之前填充3B)
    struct SubDemoStruct3 {
        int si1;               // 4B
    } sdb3;
    char c1;                   // 1B
                               // Padding 7B(因为结构体中最大成员的size是8, 所以结构体的总大小必须是8的倍数,所以在结构体最后填充7B)

};

int main()
{
    DemoStruct2 ds2;
    printf("sizeof(DemoStruct2)=%lu\n", sizeof(ds2));

    printf("DemoStruct2 结构中的 s1 偏移 = %lu 字节。\n",  offsetof(struct DemoStruct2, s1));
    printf("DemoStruct2 结构中的 s2 偏移 = %lu 字节。\n", offsetof(struct DemoStruct2, s2));
    printf("DemoStruct2 结构中的 sdb1.sc1 偏移 = %lu 字节。\n", offsetof(struct DemoStruct2, sdb1.sc1));
    printf("DemoStruct2 结构中的 sdb1.slli1 偏移 = %lu 字节。\n", offsetof(struct DemoStruct2, sdb1.slli1));
    printf("DemoStruct2 结构中的 sdb2.sc1 偏移 = %lu 字节。\n", offsetof(struct DemoStruct2, sdb2.sc1));
    printf("DemoStruct2 结构中的 sdb3.si1 偏移 = %lu 字节。\n", offsetof(struct DemoStruct2, sdb3.si1));
    printf("DemoStruct2 结构中的 phone 偏移 = %lu 字节。\n",   offsetof(struct DemoStruct2, c1));
    return 0;
}

输出:

sizeof(DemoStruct2)=40
DemoStruct2 结构中的 s1 偏移 = 0 字节。
DemoStruct2 结构中的 s2 偏移 = 2 字节。
DemoStruct2 结构中的 sdb1.sc1 偏移 = 8 字节。
DemoStruct2 结构中的 sdb1.slli1 偏移 = 16 字节。
DemoStruct2 结构中的 sdb2.sc1 偏移 = 24 字节。
DemoStruct2 结构中的 sdb3.si1 偏移 = 28 字节。
DemoStruct2 结构中的 phone 偏移 = 32 字节。

C/C++编译器会自动处理struct的内对齐,同时提供了一些机制让程序员手动控制内存对齐(#pragma pack directive)。

#include <stdio.h>
#include <stdint.h>

struct DemoStruct {
  char c1;     // 1B
  uint64_t n1; // 8B
};

#pragma pack(1)  // 显式指定字节对齐规则
struct DemoPackStruct {
  char c1;     // 1B
  uint64_t n1; // 8B
};

#pragma pack(2)
struct DemoPack2Struct {
  char c1;     // 1B
  uint64_t n1; // 8B
};

#pragma pack()  // 设置为默认字节对齐规则
struct DemoPack3Struct {
  char c1;     // 1B
  uint64_t n1; // 8B
};

int main() {
    // 16
    printf("sizeof(DemoStruct)=%d\n", sizeof(DemoStruct));
    // 9
    printf("sizeof(DemoPackStruct)=%d\n", sizeof(DemoPackStruct));
    // 10
    printf("sizeof(DemoPack2Struct)=%d\n", sizeof(DemoPack2Struct));
    // 16
    printf("sizeof(DemoPack3Struct)=%d\n", sizeof(DemoPack3Struct));
    return 0;
}

pragma pack用法:

#pragma pack(show) // 显示当前内存对齐的字节数,编辑器默认4字节对齐
#pragma pack(n)    // 设置编译器按照n个字节对齐,n可以取值1,2,4,8,16
#pragma pack(0)    // 恢复编译器默认对齐方式
#pragma pack()     // 恢复编译器默认对齐方式

#pragma pack(push)      // 将当前的对齐字节数压入栈顶,不改变对齐字节数
#pragma pack(push, n)   // 将当前的对齐字节数压入栈顶,并按照n字节对齐
#pragma pack(pop)       // 弹出栈顶对齐字节数,不改变对齐字节数
#pragma pack(pop, n)    // 弹出栈顶并直接丢弃,按照n字节对齐

说明:

  • #pragma pack(push): push是“压入”的意思。编译器编译到此处时将保存对齐状态(保存的是push指令之前的对齐状态)。
  • #pragma pack(pop): pop是”弹出“的意思。编译器编译到此处时将恢复push指令前保存的对齐状态(请在使用该预处理命令之前使用#pragma pack(push))。

push和pop是一对应该同时出现的名词,只有pop没有push不起作用,只有push没有pop可以保持之前对齐状态(但是这样就没有使用push的必要了)。

#pragma pack()是取消自定义对齐方式,恢复默认方式,而push之后pop是回到push指令之前的对齐方式。