浅谈程序中的内存对齐问题

引言

首先说一下最近遇到的问题,CPU 是 Arm v7 架构,操作系统是的 32 位的 RT-linux 系统,是一个高度裁减的用于嵌入式应用的操作系统。在进行通信协议开发时需要对一帧中的变量进行解析。例如,有一帧 40 字节的消息包,现在存放在 buffer 里:

1
uint8_t buf[40];

需要对 buffer 中的字节按照约定进行解析,例如协议规定 buf 的前13个字节用于存放帧头、消息类型、时间等,第13 - 16字节存放 一个 float 类型的变量代表当前高度、第17 - 20字节存放一个float类型变量代表当前的速度。解析消息帧的代码可能是下面这样:

1
2
3
4
5
6
7
// 解析帧头和时间等
...
// 按照协议规定解析消息帧中的物理量
float alt = *(float*)(buf + 12);
float velocity = *(float*)(buf + 16);
uint8_t flag = buf[20];
double lon = *(double*)(buf + 21);

这段代码看上去没有什么问题,C 语言允许我们对内存操作的最小单位是字节,因此我们可以进行上述操作,取任意连续的几个字节按照我们规定含义进行解析。

事实上,在上述环境下这段代码大部分时间也是正常工作了,但有时候测试我得到了一个致命的内存对齐错误信息,并且程序崩溃了。

错误是操作系统输出的,大概含义是在内存地址 0x0066d17d 的位置发生了一个非对齐内存访问的错误。

内存对齐问题

什么是内存对齐呢?CPU 对内存进行访问是通过指令进行的,例如从内存中取 4 字节至寄存器或将寄存器中的数保存到内存中。对内存访问需要提供的操作数是内存的地址,在很多 CPU 架构中访问的变量的地址需要是变量本身大小的整数倍,这种访问被称为是内存对齐的访问,例如访问 4 字节大小的 int 类型变量,其地址需要是 4 的整数倍,倘若其地址为 0x0001,就属于非对齐内存访问。

那么有些架构的 CPU 为什么要求内存对齐访问呢?CPU 对内存访问是通过总线进行的,拿 32 位总线对 4 字节的 int 类型变量访问为例,对于内存对齐的访问,只需要一次总线操作就能够把内存中的变量读进寄存器中,而如果变量位于 0x0009,就需要读取 0x0008 和 0x000C 地址各 4 个字节然后进行拼凑得到。这样严重影响了内存访问的效率。

然而,现在大多数 CPU 支持对于非对齐内存的访问,但是会损失性能。从我查到的资料和实验的情况来看,总结如下:

  1. 对于 x86 架构的 CPU,可以进行非对齐内存的访问,程序不会因此而受到致命影响,但是性能会受到影响
  2. 对于 Arm 架构的 CPU,非对齐内存访问支持从 Arm v6开始支持,但是并不是所有指令都支持,且需要操作系统内核实现相应的非对齐访问处理机制。
  3. MIPS架构不支持非对齐访问。

实际情况一般是,x86 架构的 Intel 64 和 IA-32 都支持对非对齐内存的访问,有些许的额外开销;Arm 架构的 CPU 对非对齐内存访问支持要看具体实现,非对齐内存访问即使是正常工作也有额外的开销。手机和新版树莓派 CPU 一般是 Arm v8 架构,可以正常处理非对齐内存的访问。

一些测试

首先就是引言说的那块 Armv7 架构 CPU 的板子,对于某些非对齐内存访问的指令是不支持的,因此有时候会触发上述错误。

借用 https://www.cnblogs.com/zhao-zongsheng/p/9099603.html中相关测试代码进行测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
#include <chrono>

using namespace std;
using namespace std::chrono;

milliseconds test_duration(volatile int * ptr) // 使用volatile指针防止编译器的优化
{
auto start = steady_clock::now();
for (unsigned i = 0; i < 100'000'000; ++i)
{
++(*ptr);
}
auto end = steady_clock::now();
return duration_cast<milliseconds>(end - start);
}

int main()
{
int raw[2] = {0, 0};
{
int* ptr = raw;
cout << "address of aligned pointer: " << (void*)ptr << endl;
cout << "aligned access: " << test_duration(ptr).count() << "ms" << endl;
*ptr = 0;
}
{
int* ptr = (int*)(((char*)raw) + 1);
cout << "address of unaligned pointer: " << (void*)ptr << endl;
cout << "unaligned access: " << test_duration(ptr).count() << "ms" << endl;
*ptr = 0;
}
cin.get();
return 0;
}

然后,我在树莓派 4b 上进行测试,其 CPU 是 ARM Cortex-A72 架构,是基于 Armv8 架构的,发现有时候有部分性能损耗,有时候没有(在编译器优化等级O0-O2均是这样):

这其实比较奇怪,性能损耗非常小,总之,在这个平台下,内存非对齐访问没有什么问题。

最后使用 x86_64 的我的笔记本测试,如下:

可以看到,关闭所有编译器优化的情况下,非对齐内存访问性能受到严重影响,对于非对齐内存访问,几乎就是访问了两次内存。

开启等级为 -O2的编译器优化,非对齐内存访问几乎没有性能影响:

非对齐访问原因和规避

  1. 结构体中指定对齐

我们知道,对于结构体变量,编译器会自动填充一些字节来保证变量的地址对齐,以获得最高的内存访问性能。例如:

1
2
3
4
5
6
struct MM {
char a;
int b;
short c;
}
// sizeof(MM) == 12

上述结构体使用 sizeof 打印占用的内存一般会是 12 字节,对齐的规则如下,所有基础类型的对齐字节大小为本身的大小,也就是说 char 类型变量需要按照 1 字节对齐,int 类型变量需要按照 4 字节对齐,也就是说其地址需要被 4 整除。对于 struct, union, class 类型的变量,其对齐字节大小为内部非静态成员的最大对齐值。

也就是说对于上述结构体,首先为了保证变量 b 的 4 字节对齐要求,会在 a 和 b 之间填充 3 个字节,而为了保证整个结构体 MM 的对齐要求(4字节),会在变量 c 后再填充 2 字节,因此整个结构体的大小为 12 字节。

因此,通过改变结构体中成员的排布可以减少内存浪费:

1
2
3
4
5
6
struct MM {
int b;
short c;
char a;
}
// sizeof(MM) == 8

可以通过#pragma pack(1)命令来取消编译器的自动填充(按照1字节进行对齐),这样结构体的大小就是成员的大小之和,但这样也会造成有些成员的内存不能对齐的问题,在不需要追求极致内存使用的情况下,请不要启用该命令

1
2
3
4
5
6
7
#pragma pack(1)
struct MM {
int b;
short c;
char a;
}
// sizeof(MM) == 7
  1. 指针转换

C语言中的指针强制类型转换,特别是从底位宽变量指针转换为高位宽的指针,也就是引言部分的操作。或者通过 C++ 中的reinterpret_cast执行上述转换,都是危险的。

但是有些情况,不得不进行类似上述操作,例如协议制定好之后就是需要按照非对齐内存进行变量的解析。可以通过 memcpy 来规避上述问题(在CPU不支持非对齐内存访问的情况下)。

1
2
3
4
5
6
7
8
9
// 解析帧头和时间等
...
// 按照协议规定解析消息帧中的物理量
float alt, velocity;
double lon;
memcpy(&alt, buf + 12, sizeof(float));
memcpy(&velocity, buf + 16, sizeof(float));
uint8_t flag = buf[20];
memcpy(&lon, buf + 21, sizeof(double));

大多数编译器对 memcpy 优化做的很好,而且 memcpy 函数能够很好的处理内存非对齐问题。

总结

非对齐内存访问在不同架构下的 CPU 上支持是不同的,目前很多 CPU 都是支持非对齐内存访问操作的,但是会牺牲部分性能,此外编译器可能对非对齐内存访问操作进行优化;而有些架构的 CPU 不能支持所有指令的非对齐内存访问操作,这时内核可能会实现对非对齐访问的处理,但是可能会发生异常错误。

对于程序员来说,首先应该尽量避免非对齐内存访问,也就是不要破坏编译器对 struct 的字节补齐操作;在对指针进行强制类型转换(C风格或reinterpret_cast操作)时,需要考虑是否内存对齐,或者尽量避免上述操作。

有时候无法避免对一整段连续内存进行非对齐的解析,这时需要确定程序运行的 CPU 和操作系统对非对齐内存访问的支持情况,在有可能发生非对齐内存访问异常的情况下,使用 memcpy 对非对齐的内存进行访问。