一、内存对齐概述
(一)内存对齐的定义与重要性
内存对齐在 C/C++ 中是一种重要的机制,它确保数据在内存中的存储地址满足特定的规则。具体来说,计算机系统对不同的数据类型有合法地址的限制,要求对象的地址必须是特定值的倍数,这个特定值通常与数据类型的大小或硬件平台的要求相关。例如,在 32 位系统中,整型数据的地址通常要求是 4 的倍数。
其重要性主要体现在两个方面。
一方面,适应不同的硬件平台:不是所有的硬件平台都能访问任意地址上的任意数据,某些硬件平台对数据的存储和访问有特定的要求。如果程序不遵循这些要求,可能会导致硬件异常,甚至程序崩溃。
另一方面,提升性能:现代计算机的内存访问通常是以块为单位进行的,而不是逐个字节访问。如果数据按照特定的规则对齐存储,可以使 CPU 更高效地访问内存,减少不必要的内存访问次数,从而提高程序的运行效率。
(二)内存对齐的作用
- 平台移植原因:不同的硬件平台对存储空间的处理方式有很大差异。一些特定的硬件平台只能在某些特定地址开始存取某些特定类型的数据。例如,某些架构的 CPU 在访问一个没有进行对齐的变量时会发生错误。在这种情况下,编程必须保证字节对齐,以确保程序在不同的硬件平台上都能正常运行。这就是内存对齐在平台移植方面的重要性,它确保了程序的可移植性。
- 性能提升原因:内存对齐可以显著提高内存访问速度。当内存中的数据按照特定的规则对齐存储时,CPU 可以更快地访问这些数据。例如,对于一些平台,每次读操作都是从偶地址开始。如果一个 int 型数据(假设为 32 位系统)存放在偶地址开始的地方,那么一个读周期就可以读出这 32 位数据。而如果存放在奇地址开始的地方,就需要 2 个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该 32bit 数据。此外,CPU 在读取内存时通常是以块为单位进行的,块大小可以是 2、4、8、16 字节等。如果数据没有按照块大小对齐存储在内存中,CPU 就需要进行额外的操作来从内存中读取正确的数据,这会降低程序的运行效率。
二、内存对齐规则
(一)数据成员对齐规则
在 C/C++ 中,结构(struct)或联合(union)的数据成员有特定的对齐规则。第一个数据成员放在偏移量(offset)为 0 的地方,从第二个数据成员开始,每个成员存储的起始位置要从该成员大小或者成员的子成员大小(只要该成员有子成员,比如说是数组、结构体等)的整数倍开始。例如,在 32 位机中,int 类型为 4 字节,则 int 类型的数据成员要从 4 的整数倍地址开始存储。如struct node{char a;int b};,char 的大小为 1,int 大小为 4,a 存放在 0 的位置,b 存储的起始位置为 1,但不满足对齐原则,因为 int 大小为 4,其存储位置应为 4 的整倍数,因此要在 a 后补齐,使 b 存储的起始位置为 4,所以sizeof(node)=8。
(二)结构体整体对齐规则
结构体的总大小必须按照指定对齐系数和结构体最大数据成员长度中较小的那个进行整体对齐。比如,如果编译器默认的对齐系数为 8,而结构体中最大的数据成员为 double 类型,占 8 个字节,那么结构体的总大小必须是 8 的整数倍。若结构体中各成员按规则排列后,总大小不是最大成员长度的整数倍,则需要进行补齐。例如struct node{double a;char b;},按规则可计算出结果应为 9,但结构体大小应为最大成员的整倍数,因此结果应为 16。
(三)结构体作为成员的对齐规则
如果一个结构里有某些结构体成员,那么这个结构体成员要从其内部最大基本类型成员的整数倍地址开始存储。例如struct a里存有struct b,b里有char,int,double等元素,那b应该从8的整数倍开始存储。并且结构体的整体大小为内部最大成员的最宽基本类型成员的整数倍。如struct pa{char a;node b;},b 的起始位置要是 4 的整倍数,因此要在 a 后补位,b 占 8 字节,所以sizeof(pa)=12。如果编译器中提供了#pragma pack(n),上述对齐模式就不适用了,例如设定变量以 n 字节对齐方式,则上述成员类型对齐宽度(应当也包括收尾对齐)应该选择成员类型宽度和 n 中较小者。
三、内存对齐的实现方法
(一)使用预编译命令 #pragma pack
在 C/C++ 中,可以使用预编译命令#pragma pack来改变对齐系数,从而控制内存对齐方式。例如,#pragma pack(n)可以将对齐系数设置为n,其中n可以是 1、2、4、8、16 等。当n值改变时,结构体中成员的对齐方式也会相应地发生变化。
以struct test_t {int a;char b;short c;char d[6];}为例,当#pragma pack(1)时,输出结果为sizeof(struct test_t) = 13。分析过程如下:
成员数据对齐:
- int a:int型,长度 4 > 1,按 1 对齐,起始offset = 0,0 % 1 = 0,存放位置区间[0, 3]。
- char b:char型,长度 1 = 1,按 1 对齐,起始offset = 4,4 % 1 = 0,存放位置区间[4]。
- short c:short型,长度 2 > 1,按 1 对齐,起始offset = 5,5 % 1 = 0,存放位置区间[5, 6]。
- char d[6]:看成 6 个char型变量,起始offset = 7,7 % 1 = 0,存放位置区间[7, C]。
- 整体对齐:整体对齐系数为min((max(int, short, char), 1) = 1,整体大小为成员总大小按整体对齐系数圆整,即13 % 1 = 0。
当#pragma pack(2)时,输出结果为sizeof(struct test_t) = 14。同理,随着n值的变化,可以观察到不同的对齐效果。
(二)内存对齐函数的使用
1.posix_memalign函数:
- posix_memalign(void** memptr, size_t alignment, size_t size)用于分配一块对齐的内存。例如在 Windows 平台下,实现如下:
#ifdef _WIN32
// 检查对齐值是否合法的函数
static int check_align(size_t align)
{
// 循环从指针大小开始,每次翻倍,检查是否与输入的对齐值相等
for (size_t i = sizeof(void *); i!= 0; i *= 2)
{
if (align == i)
{
return 0; // 对齐值合法,返回 0
}
}
return EINVAL; // 对齐值不合法,返回错误码 EINVAL
}
// 模拟实现 POSIX 的 posix_memalign 函数
int posix_memalign(void **ptr, size_t align, size_t size)
{
// 检查对齐值是否合法
if (check_align(align))
{
return EINVAL; // 对齐值不合法,返回错误码 EINVAL
}
int saved_errno = errno; // 保存当前错误码
// 使用 Windows 下的 _aligned_malloc 分配对齐内存
void *p = _aligned_malloc(size, align);
if (p == NULL)
{
errno = saved_errno; // 如果分配失败,恢复原始错误码
return ENOMEM; // 返回内存不足错误码
}
*ptr = p; // 将分配的内存地址赋给指针变量
return 0; // 成功返回 0
}
#endif
2._aligned_malloc函数:
- void* _aligned_malloc(size_t size, size_t alignment)也是用于分配一块对齐的内存。例如:
size_t size = 1024; // 定义变量 size,表示要分配的内存大小为 1024 字节
size_t alignment = 4096; // 定义变量 alignment,表示内存对齐的大小为 4096 字节
size_t alignment_offset; // 定义变量 alignment_offset,用于存储对齐偏移量
void* memory; // 定义指针 memory,用于存储分配的内存地址
// 使用 _aligned_malloc 函数分配一块大小为 size、按 alignment 对齐的内存
// 同时将对齐偏移量存储在 alignment_offset 的地址中
memory = _aligned_malloc(size, alignment, &alignment_offset);
if (memory == NULL) {
// 如果内存分配失败,打印错误信息并返回 1
printf("内存分配失败\n");
return 1;
}
// 如果内存分配成功,打印分配的内存起始地址
printf("分配的内存起始地址:%p\n", memory);
// 打印对齐偏移量
printf("对齐偏移量:%zu\n", alignment_offset);
// 释放分配的内存
_aligned_free(memory);
3.自定义对齐函数:
- 可以自定义对齐函数来实现特定的内存对齐需求。例如:
template <typename T>
// 这是一个模板函数,接受任意类型 T 的参数
inline T Align(const T Ptr, int Alignment) {
// 将输入的指针 Ptr 转换为 uint64_t 类型,加上 Alignment - 1
// 然后与 ~(Alignment - 1) 进行按位与操作
return (T)(((uint64_t)Ptr + Alignment - 1) & ~(Alignment - 1));
}
或者:
// 对齐内存分配函数
inline void* aligned_malloc(size_t size, size_t alignment) {
// 计算偏移量,alignment - 1 确保可以向上取整到对齐边界,加上 sizeof(void*)是为了存储原始指针
size_t offset = alignment - 1 + sizeof(void*);
// 使用标准的 malloc 分配一块足够大的内存,大小为所需大小加上偏移量
void * originalP = malloc(size + offset);
// 将原始分配的内存地址转换为 size_t 类型,获取其数值
size_t originalLocation = reinterpret_cast<size_t>(originalP);
// 计算实际对齐后的内存地址,先加上偏移量再与对齐值取反进行按位与操作,实现对齐
size_t realLocation = (originalLocation + offset) & ~(alignment - 1);
// 将对齐后的内存地址转换回 void* 类型指针
void * realP = reinterpret_cast<void*>(realLocation);
// 计算存储原始指针的位置,即对齐后的地址减去一个指针大小
size_t originalPStorage = realLocation - sizeof(void*);
// 在存储原始指针的位置存储原始指针
*reinterpret_cast<void**>(originalPStorage) = originalP;
// 返回对齐后的内存地址
return realP;
}
// 对齐内存释放函数
inline void aligned_free(void* p) {
// 计算存储原始指针的位置,即传入指针地址减去一个指针大小
size_t originalPStorage = reinterpret_cast<size_t>(p) - sizeof(void*);
// 从存储原始指针的位置获取原始指针,并释放它
free(*reinterpret_cast<void**>(originalPStorage));
}
四、内存对齐的示例分析
(一)不同对齐系数下的结构体大小分析
继续以struct test_t为例,当#pragma pack(4)时,输出结果为sizeof(struct test_t) = 16。分析过程如下:
成员数据对齐:
- int a:长度 4 = 4,按 4 对齐,起始offset = 0,0 % 4 = 0,存放位置区间[0, 3]。
- char b:长度 1 < 4,按 1 对齐,起始offset = 4,4 % 1 = 0,存放位置区间[4]。
- short c:长度 2 < 4,按 2 对齐(取成员自身大小和对齐系数中的较小值),起始offset = 6,6 % 2 = 0,存放位置区间[6, 7]。
- char d[6]:看成 6 个char型变量,起始offset = 8,8 % 1 = 0,存放位置区间[8, D]。
整体对齐:整体对齐系数为min((max(int, short, char), 4) = 4,整体大小为成员总大小按整体对齐系数圆整,即16 % 4 = 0。
当#pragma pack(8)时,输出结果为sizeof(struct test_t) = 16。分析过程与#pragma pack(4)类似,因为结构体中最大的数据成员int a为 4 字节,小于对齐系数 8,所以整体对齐仍然按照 4 字节进行。
(二)嵌套结构体的内存对齐
假设存在以下结构体定义:
struct InnerStruct {
char innerChar;
int innerInt;
short innerShort;
};
struct OuterStruct {
char outerChar;
InnerStruct innerStruct;
double outerDouble;
};
对于OuterStruct,首先分析outerChar,它的大小为 1 字节,存放在偏移量为 0 的位置。接着分析innerStruct,由于内部最大成员为int类型,占 4 字节,所以innerStruct的起始位置应该是 4 的整数倍。假设当前偏移量为 1,需要填充 3 个字节,使得innerStruct从偏移量 4 开始存储。对于innerStruct内部成员,innerChar存放在偏移量 4,innerInt存放在偏移量 8,innerShort存放在偏移量 12。然后分析outerDouble,它占 8 字节,起始位置应为 8 的整数倍。当前偏移量为 16,满足条件,所以outerDouble存放在偏移量 16。整个结构体的大小为 24,是内部最大成员double类型大小 8 的整数倍。
(三)优化结构体内存对齐
在定义结构体时,可以通过调整成员顺序来优化内存对齐,减少内存空洞。例如:
struct UnoptimizedStruct {
int unoptimizedInt;
char unoptimizedChar;
short unoptimizedShort;
};
struct OptimizedStruct {
char optimizedChar;
short optimizedShort;
int optimizedInt;
};
对于UnoptimizedStruct,unoptimizedInt占 4 字节,存放在偏移量 0,unoptimizedChar占 1 字节,由于int类型的对齐要求,它需要从偏移量 4 开始存储,浪费了 3 个字节,unoptimizedShort占 2 字节,从偏移量 5 开始存储,整个结构体大小为 8。而对于OptimizedStruct,optimizedChar占 1 字节,存放在偏移量 0,optimizedShort占 2 字节,从偏移量 1 开始存储,optimizedInt占 4 字节,从偏移量 4 开始存储,整个结构体大小为 7,相比之下,通过调整成员顺序减少了内存空洞,提高了内存利用率。在实际编程中,根据数据类型的大小和访问频率合理安排结构体成员顺序,可以提高代码效率。
五、内存对齐的注意事项与优化策略
(一)注意事项
在网络通信程序中,一定要在定义结构体或者联合之前使用#pragma pack()把内存对齐关闭。这是因为远程主机通常不知道对方使用的何种对齐方式,通过 socket 接收的字节流,然后按照字节解析得到对应的结果,如果使用内存对齐,远程主机很可能会得到错误的结果。这种情况可能会导致程序出现难以察觉的错误,且调试起来较为困难。
例如,在一个分布式系统中,不同节点之间进行通信,如果发送节点使用了特定的内存对齐方式,而接收节点没有正确处理这种对齐方式,那么接收节点在解析数据时可能会出现错误的结果,导致整个系统的行为异常。
(二)优化策略
调整结构体成员顺序是一种有效的优化策略,可以将占用空间小的类型排在前面,以节约对齐空间。例如,如果一个结构体中包含char、int和double类型的成员,可以将char类型放在最前面,接着是int类型,最后是double类型。这样可以减少内存空洞,提高内存利用率。
以一个实际的例子来说明,假设有以下结构体:
struct Data1 {
int data1Int;
char data1Char;
double data1Double;
};
struct Data2 {
char data2Char;
int data2Int;
double data2Double;
};
对于Data1,data1Int占 4 字节,存放在偏移量 0,data1Char占 1 字节,由于int类型的对齐要求,它需要从偏移量 4 开始存储,浪费了 3 个字节,data1Double占 8 字节,从偏移量 8 开始存储,整个结构体大小为 24。而对于Data2,data2Char占 1 字节,存放在偏移量 0,data2Int占 4 字节,从偏移量 4 开始存储,data2Double占 8 字节,从偏移量 8 开始存储,整个结构体大小为 16。可以看出,通过调整成员顺序,Data2比Data1节约了 8 个字节的内存空间。
此外,还可以结合特定的应用场景,合理选择内存对齐系数。如果在一些内存受限的嵌入式设备上,可以选择较小的对齐系数,以节省内存空间。但需要注意的是,选择较小的对齐系数可能会降低程序的运行效率,因为 CPU 在访问未对齐的内存时需要进行额外的操作。
在实际编程中,需要根据具体情况综合考虑内存对齐的注意事项和优化策略,以提高程序的性能和内存利用率。