浅谈程序中的内存对齐问题
引言
首先说一下最近遇到的问题,CPU 是 Arm v7 架构,操作系统是的 32 位的 RT-linux 系统,是一个高度裁减的用于嵌入式应用的操作系统。在进行通信协议开发时需要对一帧中的变量进行解析。例如,有一帧 40 字节的消息包,现在存放在 buffer 里:
1 | uint8_t buf[40]; |
需要对 buffer 中的字节按照约定进行解析,例如协议规定 buf 的前13个字节用于存放帧头、消息类型、时间等,第13 - 16字节存放 一个 float 类型的变量代表当前高度、第17 - 20字节存放一个float类型变量代表当前的速度。解析消息帧的代码可能是下面这样:
1 | // 解析帧头和时间等 |
这段代码看上去没有什么问题,C 语言允许我们对内存操作的最小单位是字节,因此我们可以进行上述操作,取任意连续的几个字节按照我们规定含义进行解析。
事实上,在上述环境下这段代码大部分时间也是正常工作了,但有时候测试我得到了一个致命的内存对齐错误信息,并且程序崩溃了。
错误是操作系统输出的,大概含义是在内存地址 0x0066d17d
的位置发生了一个非对齐内存访问的错误。
内存对齐问题
什么是内存对齐呢?CPU 对内存进行访问是通过指令进行的,例如从内存中取 4 字节至寄存器或将寄存器中的数保存到内存中。对内存访问需要提供的操作数是内存的地址,在很多 CPU 架构中访问的变量的地址需要是变量本身大小的整数倍,这种访问被称为是内存对齐的访问,例如访问 4 字节大小的 int 类型变量,其地址需要是 4 的整数倍,倘若其地址为 0x0001,就属于非对齐内存访问。
那么有些架构的 CPU 为什么要求内存对齐访问呢?CPU 对内存访问是通过总线进行的,拿 32 位总线对 4 字节的 int 类型变量访问为例,对于内存对齐的访问,只需要一次总线操作就能够把内存中的变量读进寄存器中,而如果变量位于 0x0009,就需要读取 0x0008 和 0x000C 地址各 4 个字节然后进行拼凑得到。这样严重影响了内存访问的效率。
然而,现在大多数 CPU 支持对于非对齐内存的访问,但是会损失性能。从我查到的资料和实验的情况来看,总结如下:
- 对于 x86 架构的 CPU,可以进行非对齐内存的访问,程序不会因此而受到致命影响,但是性能会受到影响。
- 对于 Arm 架构的 CPU,非对齐内存访问支持从 Arm v6开始支持,但是并不是所有指令都支持,且需要操作系统内核实现相应的非对齐访问处理机制。
- MIPS架构不支持非对齐访问。
实际情况一般是,x86 架构的 Intel 64 和 IA-32 都支持对非对齐内存的访问,有些许的额外开销;Arm 架构的 CPU 对非对齐内存访问支持要看具体实现,非对齐内存访问即使是正常工作也有额外的开销。手机和新版树莓派 CPU 一般是 Arm v8 架构,可以正常处理非对齐内存的访问。
一些测试
首先就是引言说的那块 Armv7 架构 CPU 的板子,对于某些非对齐内存访问的指令是不支持的,因此有时候会触发上述错误。
借用 https://www.cnblogs.com/zhao-zongsheng/p/9099603.html中相关测试代码进行测试:
1 |
|
然后,我在树莓派 4b 上进行测试,其 CPU 是 ARM Cortex-A72 架构,是基于 Armv8 架构的,发现有时候有部分性能损耗,有时候没有(在编译器优化等级O0-O2均是这样):
这其实比较奇怪,性能损耗非常小,总之,在这个平台下,内存非对齐访问没有什么问题。
最后使用 x86_64 的我的笔记本测试,如下:
可以看到,关闭所有编译器优化的情况下,非对齐内存访问性能受到严重影响,对于非对齐内存访问,几乎就是访问了两次内存。
开启等级为 -O2
的编译器优化,非对齐内存访问几乎没有性能影响:
非对齐访问原因和规避
- 结构体中指定对齐
我们知道,对于结构体变量,编译器会自动填充一些字节来保证变量的地址对齐,以获得最高的内存访问性能。例如:
1 | struct MM { |
上述结构体使用 sizeof
打印占用的内存一般会是 12 字节,对齐的规则如下,所有基础类型的对齐字节大小为本身的大小,也就是说 char 类型变量需要按照 1 字节对齐,int 类型变量需要按照 4 字节对齐,也就是说其地址需要被 4 整除。对于 struct, union, class 类型的变量,其对齐字节大小为内部非静态成员的最大对齐值。
也就是说对于上述结构体,首先为了保证变量 b 的 4 字节对齐要求,会在 a 和 b 之间填充 3 个字节,而为了保证整个结构体 MM 的对齐要求(4字节),会在变量 c 后再填充 2 字节,因此整个结构体的大小为 12 字节。
因此,通过改变结构体中成员的排布可以减少内存浪费:
1 | struct MM { |
可以通过#pragma pack(1)
命令来取消编译器的自动填充(按照1字节进行对齐),这样结构体的大小就是成员的大小之和,但这样也会造成有些成员的内存不能对齐的问题,在不需要追求极致内存使用的情况下,请不要启用该命令。
1 |
|
- 指针转换
C语言中的指针强制类型转换,特别是从底位宽变量指针转换为高位宽的指针,也就是引言部分的操作。或者通过 C++ 中的reinterpret_cast执行上述转换,都是危险的。
但是有些情况,不得不进行类似上述操作,例如协议制定好之后就是需要按照非对齐内存进行变量的解析。可以通过 memcpy 来规避上述问题(在CPU不支持非对齐内存访问的情况下)。
1 | // 解析帧头和时间等 |
大多数编译器对 memcpy 优化做的很好,而且 memcpy 函数能够很好的处理内存非对齐问题。
总结
非对齐内存访问在不同架构下的 CPU 上支持是不同的,目前很多 CPU 都是支持非对齐内存访问操作的,但是会牺牲部分性能,此外编译器可能对非对齐内存访问操作进行优化;而有些架构的 CPU 不能支持所有指令的非对齐内存访问操作,这时内核可能会实现对非对齐访问的处理,但是可能会发生异常错误。
对于程序员来说,首先应该尽量避免非对齐内存访问,也就是不要破坏编译器对 struct 的字节补齐操作;在对指针进行强制类型转换(C风格或reinterpret_cast操作)时,需要考虑是否内存对齐,或者尽量避免上述操作。
有时候无法避免对一整段连续内存进行非对齐的解析,这时需要确定程序运行的 CPU 和操作系统对非对齐内存访问的支持情况,在有可能发生非对齐内存访问异常的情况下,使用 memcpy 对非对齐的内存进行访问。