内存对齐
简介
在传统体系的计算机中,我们知道CPU的运算速度是最快的,也是最昂贵的部件。其次是寄存器,加速优化与内存的读写速度,寄存器的速度也是快于内存。然后是多级缓存。之后就进入到内存,内存的读取写入速度要远慢于CPU的速度,价格上也是如此。内存对齐是为了降低cpu访问内存的次数,更高效的使用CPU。CPU读取内存是高耗时的指令,所以内存对齐,是在内存的使用量和CPU计算上做的一种居中的优化策略。这种策略是由编译器决和CPU共同决定,并且程序员可以设置对齐的长度。
很多 CPU(如基于 Alpha,IA-64,MIPS,和 SuperH 体系的)拒绝读取未对齐数据。当一个程序要求这些 CPU 读取未对齐数据时,这时 CPU 会进入异常处理状态并且通知程序不能继续执行。举个例子,在 ARM 硬件平台上,当操作系统被要求存取一个未对齐数据时会默认给应用程序抛出硬件异常。所以,如果编译器不进行内存对齐,那在很多平台的上的开发将难以进行。
- 总结一句话:内存对齐是以牺牲内存空间来提高CPU的性能的空间换时间的策略。
sizeof关键字
先看一段代码:
#include <iostream>
int main(int argc, char** argv) {
std::cout << sizeof(long long) << std::endl;
std::cout << sizeof(long) << std::endl;
std::cout << sizeof(unsigned long) << std::endl;
std::cout << sizeof(int) << std::endl;
std::cout << sizeof(unsigned int) << std::endl;
std::cout << sizeof(short) << std::endl;
std::cout << sizeof(unsigned short) << std::endl;
std::cout << sizeof(char) << std::endl;
std::cout << sizeof(float) << std::endl;
std::cout << sizeof(double) << std::endl;
return 0;
}
在我的环境下gcc version 6.5.0 (MacPorts gcc6 6.5.0_1)
运行结果如下:
我的机器是64位,但是如果是32位的,运行的结果有些就会不一样了,比如
sizeof(long)
的结果为4,但是sizeof(char)
的结果仍然为1。上面的所有结果都是基础的类型,没有指针类型,下面看一下指针类型:
int main(int argc, char** argv) {
std::cout << sizeof(double*) << std::endl;
std::cout << sizeof(int*) << std::endl;
std::cout << sizeof(short*) << std::endl;
std::cout << sizeof(char*) << std::endl;
std::cout << sizeof(float*) << std::endl;
std::cout << sizeof(long long*) << std::endl;
std::cout << sizeof(long*) << std::endl;
return 0;
}
在我的64位机器上输出结果如下:
所有的结果都是8,这是因为
sizeof
对他们取的是指针所占的内存大小,而不是具体的类型占用内存大小。所以不论是什么类型的指针,sizeof
出来的结果都是8。当然如果是在32位机器上的话,结果都是4,因为32位机器32bit位足以表示内存地址,那么就不需要使用更多的内存去存储指针。
数据对齐
数据成员对齐
首先我们先看一段演示的代码:
#include <iostream>
struct A {
int a;
char b;
long c;
double* d;
};
struct B {
double* a;
int b;
long c;
char d;
};
int main(int argc, char** argv) {
std::cout << sizeof(A) << std::endl;
std::cout << sizeof(B) << std::endl;
return 0;
}
在我的机器上输出的结果为:
这里就是内存对齐导致的两个结构体,只是内部的元素位置变换,而占有的内存空间缺不一样。对于我的机器,64位系统,
gcc version 6.5.0 (MacPorts gcc6 6.5.0_1)
,默认是以8字节对齐。首先我们知道结构体分配内存时,按照声明的变量顺序来存储数据。对于A
结构体,首先是分配int a
的空间,分配4字节,然后再是char b
,char b
只占用一个字节,此时给它分配内存时,就会在int a
后面空余的4个字节从第一个字节开始分配给它。于是这个char b
占用了4个字节,当然后面的3个字节不属于它,但是也并没有使用,填充空字节。因为接下来的是long c
和double* d
这两个都是8字节的内存空间,于是总共分配的内存空间就是4+1+3+8+8=24字节。同理可得到结构体B,8+4+4+8+1+7=32字节。所以我们在定义结构体时,稍微注意一下声明的顺序,就可以节约许多的内存。下面我们看一下内存的地址是否与我们分析的一致:
int main(int argc, char** argv) {
A a;
std::cout << &a << std::endl;
std::cout << &a.a << std::endl;
std::cout << static_cast<void*>(&a.b) << std::endl;
std::cout << &a.c << std::endl;
std::cout << &a.d << std::endl;
return 0;
}
输出结果:
image.png
结果中我们可以看到:
- 结构体的地址就是结构体第一个数据的地址
- 内存占用与分析的一致
数据对齐的规则与对齐系数
-
#pragma pack(n)
这个参数表示指定的数值n和这个数据成员自身长度中较小那个的整数倍,这个数据作为在内存中的偏移。out = N * min(n, min(struct))
-
数据成员对齐规则
struct
或union
的数据成员,第一个数据成员放在偏移为 0 的地方(偏移起始的地址为结构体的的地址),以后每个数据成员的偏移为预先指定的数值和这个数据成员自身长度中较小那个的整数倍,现在的默认64位机器为8字节。 -
数据成员为结构体
如果结构体的数据成员还为结构体,则该数据成员的“自身长度”为其内部最大元素的大小。如:struct a 里存有 struct b,b 里有char,int,double等元素,那 b “自身长度”为 8。len(a) = len(max(b.element))
-
结构体的整体对齐规则
在数据成员按照2号规则完成各自对齐之后,结构体本身也要进行对齐。对齐会将结构体的大小增加为#pragma pack(n)
指定的数值和结构体最大数据成员长度中,两个数字中较小那个数字的整数倍。out = N * min(n, max(struct))
下面是验证的代码,我们将上面的代码设置上#pragma pack(4):
#include <iostream>
#pragma pack(4)
struct A {
int a;
char b;
long c;
double* d;
};
struct B {
double* a;
int b;
long c;
char d;
};
int main(int argc, char** argv) {
std::cout << sizeof(A) << std::endl;
std::cout << sizeof(B) << std::endl;
return 0;
}
运行结果如下:
image.png
为什么需要内存对齐
我们在上面已经讲了,内存对齐是一种优化CPU性能的方法,那么为什么CPU会需要内存对齐来优化性能呢?没有内存对齐的数据为什么会大大降低CPU的性能?
- 字节对齐取数据
首先假设各位都是学习过微机原理或者是懂一些这方面知识。我们知道计算机中虚拟内存地址对应于实际的物理地址,能够保证CPU取到每一个字节内存的物理地址,也就是说每一个内存的字节都会有地址。但是多数CPU并不是以字节为单位去取物理内存上面的数据,假如CPU需要取一个8字节的数据到CPU中运算,那么取数据将会花费8个取内存数据的指令周期,这还不包括地址偏移和数据合并的指令周期。实际中,CPU的运算速度是非常的快,但是时间都花费在了取内存数据上,这是对CPU的浪费。所以CPU一般会以2/4/8/16/32
字节为单位来进行存取操作。我们将上述这些存取单位称为内存存取粒度,这样假设8字节的数据,那么使用内存存取粒度为8的话,取一次内存数据就完成了。 - 硬件设计
最初的 68000 处理器的存取粒度是双字节,没有应对非对齐内存地址的电路系统。当遇到非对齐内存地址的存取时,它将抛出一个异常。随后的 680x0 系列,像 68020,放宽了这个的限制,支持了非对齐内存地址存取的相关操作。这解释了为什么一些在 68020 上正常运行的旧软件会在 68000 上崩溃,当然这也跟编译存在一定的关系。
处理器都是使用有限的晶体管来完成工作。支持非对齐内存地址的存取操作会消减“晶体管预算”,这些晶体管原本可以用来提升其他模块的速度或者增加新的功能。现在内存对齐基本已经成了一个约定,如果在编译时,没有内存对齐,而CPU也不支持非内存对齐,那么抛出异常交给操作系统取处理。一般来说,硬件的解决方案会比软件解决方案快非常多,所以现在编译器默认都是有内存对齐的。