笨蛋C++017 - 内存对齐

128 阅读5分钟

什么是内存对齐?

计算机中内存的地址空间都是按照byte来划分的。从理论上来说任何类型变量的访问都可以从内存中的任意地方开始。但是实际情况是:在访问特定的类型变量的时候通常在特定的内存地址进行访问,这就需要对这些数据在内存中存放的位置来做出限制,各种类型的数据按照一定的规则在空间上排列。而不是顺序的一个一个的排放,这个就是对齐。编译器将程序中的每个数据单元的地址安排在机器字的整数倍地址指向的内存空间之中。

为什么要内存对齐?

主要是由于CPU的访问内存的特性来决定的。CPU访问内存时并不是以字节为单位来读取内存,而是以机器为单位。实际机器字长由CPU的数据总线宽度来决定的。实际CPU运行时。每一次控制内存读写信号发生时。CPU可以从内存中读取数据总线的宽度的数据。并将其写入到CPU的通用寄存器中去。比如32位CPU 字长为4位。数据总线宽度为32位。如果该CPU的地址总线也是32位。那么他可以访问的地址空间为[0xffffffff]. 内存对齐的主要目的是为了减少CPU访问内存的次数。加大CPU访问内存的吞吐量。假设读取8个字节的数据。按照每次读取4个字节的CPU操作。那么8个字节需要CPU消耗两次读取操作。

除了能够减少内存访问的次数,增加内存读取的吞吐量之外,还有以下原因:

  • 比如某些特定的硬件设备只能读取对齐的数据。存取非对齐的数据可能会引发异常、比如对于CPU的SIMD操作指令,就要求CPU内存严格对齐。
  • 每次内存的访问都是原子的。如果变量的大小不超过字长,那么内存对齐之后,对该变量的操作就是原子的。某些硬件设备不能保证在读取非对齐数据的时候操作是原子操作。因此此时CPU需要读取多次内存。这样就破坏了变量的原子性。
  • 相比于存取对齐的数据。存取非对齐的数据需要花费更多的时间。提高内存的访问效率。因为CPU在读取内存的时候,是一块块的读取。
  • 某些处理器虽然支持非对齐数据的访问。但是会引发对齐陷阱(aligment trap)。
  • 某些硬件设备只支持简单数据指令的非对齐存取。不支持复杂数据的非对齐存储。

内存对齐的原则

程序中的内存对齐大部分都是由编译器来处理的。编译器会自动在内存之间填充字节。结构体中的变量对齐的基本规则如下:

  • 结构体中的变量的首地址能够被最宽的基本类型成员的长度和对齐基数两者的较小者所整除。
  • 结构体中的static成员变量不占用结构体空间。所有该结构体实例中的静态成员都在程序第一次运行的时候就已经初始化完成。他们都指向静态存储区的同一个空间。
struct st{
    char a;
    int b;
    static double c; // 静态成员
} T;
cout<<sizeof(st)<<endl;
  • 结构体中每个成员的偏移量都是该成员大小与对齐基数中的较小者的整数倍。如果有需要的话,编译器会在成员之间填充字节(internal padding)。
  • 结构体的总大小为结构体中最宽的基本类型成员的长度与对齐基数中的二者较小者的整数倍。如果需要编译器会在最末尾的成员之后填充字节(trailing padding)。

我们可以通过Offset宏来计算结构体中每个变量的偏移地址。

#include<iostream>
using namespace std;
#define offset(TYPE,MEMBER) ((long)&*(TYPE *)0->MEMBER)
struct A{
    short var; // 偏移 0 字节 (内存对齐原则 : short 2 字节 + 填充 2 个字节)
    int var1; // 偏移 4 字节 (内存对齐原则:int 占用 4 个字节)
    long var2; // 偏移 8 字节 (内存对齐原则:long 占用 8 个字节)
    char var3; // 偏移 16 字节 (内存对齐原则:char 占用 1 个字节 + 填充 7 个字节)
    string s; // 偏移 24 字节 (string 占用 32 个字节)
};
int main(){
    string s;
    A exl;
    cout<< offset(A,var)<<endl;
    cout<< offset(A,var1)<<endl;
    cout<< offset(A,var2)<<endl;
    cout<< offset(A,var3)<<endl;
    cout<< offset(A,s)<<endl;
    cout<< offset(exl)<<endl;
    return 0;
}

运行结果如下:

0 4 8 16 24 56

指定程序对齐规则

我们可以指定结构体的对齐规则,在某些特定场景下我们需要指定结构体内存进行对齐,比如在发送特定网络协议报文、硬件协议控制、消息传递、硬件寄存器访问时,这时就就需要避免内存对齐,因为双方均按照预先定义的消息格式来进行交互,从而避免不同的硬件平台造成的差异,同时能够将双方传递的数据进行空间压缩,避免不必要的空间浪费。

programpack: 我们可以用 #progma pack(x) 指定结构体以 xx 为单位进行对齐。一般情况下我们可以使用如下:

#pragma pack(push) #pragma pack(x) // 存放需要 x 对齐方式的数据块 #pragma pack(pop)

我们指定上面的程序以一个字节进行对齐:

/*
说明:程序是在 64 位编译器下测试的
*/
#include <iostream>
using namespace std;
#define offset(TYPE,MEMBER) ((long)&((TYPE *)0)->MEMBER)
#pragma pack(push)
#pragma pack(1)
struct A
{
    short var; // 偏移 0 字节 (内存对齐原则 : short 2 字节 + 填充 2 个字节)
    int var1;  // 偏移 4 字节 (内存对齐原则:int 占用 4 个字节)
    long var2; // 偏移 8 字节 (内存对齐原则:long 占用 8 个字节)
    char var3; // 偏移 16 字节 (内存对齐原则:char 占用 1 个字节 + 填充 7 个字节)
    string s;  // 偏移 24 字节 (string 占用 32 个字节)
};
#pragma pack(pop)

int main()
{
    string s;
    A ex1;
    cout << offset(A, var) <<endl;
    cout << offset(A, var1) <<endl;
    cout << offset(A, var2) <<endl;
    cout << offset(A, var3) <<endl;
    cout << offset(A, s) <<endl;
    cout << sizeof(ex1) << endl;  // 56 struct
    return 0;
}

运行结果:

0 2 6 14 15 47

判断当前程序的内存对齐方式

alignofC++ 11 以后新增 alignof 的特性,通过调用 alignof 返回当前变量的字节对齐方式。比如以下程序:

/*
说明:程序是在 64 位编译器下测试的
*/
#include <iostream>
using namespace std;
#define offset(TYPE,MEMBER) ((long)&((TYPE *)0)->MEMBER)
#pragma pack(push)
#pragma pack(1)
struct A
{
    short var; // 偏移 0 字节 (内存对齐原则 : short 2 字节 + 填充 2 个字节)
    int var1;  // 偏移 4 字节 (内存对齐原则:int 占用 4 个字节)
    long var2; // 偏移 8 字节 (内存对齐原则:long 占用 8 个字节)
    char var3; // 偏移 16 字节 (内存对齐原则:char 占用 1 个字节 + 填充 7 个字节)
    string s;  // 偏移 24 字节 (string 占用 32 个字节)
};
#pragma pack(pop)

int main()
{
    string s;
    A ex1;
    cout << alignof(A) <<endl;
    return 0;
}

结论

内存对齐使得程序便于在不同的平台之间进行移植,因为有些硬件平台不能够支持任意地址的数据访问,只能在某些地址处取某些特定的数据,否则会抛出异常;另一方面提高内存的访问效率,因为 CPU 在读取内存时,是以块为单位进行读取。