在编写干净的 C 代码时利用 ARM 未对齐的内存访问

Take advantage of ARM unaligned memory access while writing clean C code

以前是ARM处理器无法正确处理未对齐的内存访问(ARMv5及以下)。如果 ptr 没有在 4 字节上正确对齐,像 u32 var32 = *(u32*)ptr; 这样的东西只会失败(引发异常)。

写这样的声明对于 x86/x64 来说会很好,因为这些 CPU 总是非常有效地处理这种情况。但是按照C标准,这不是"proper"的写法。 u32 显然等同于一个 4 字节的结构, 必须 在 4 字节上对齐。

在保持正统正确性 确保与任何 cpu 完全兼容的同时实现相同结果的正确方法是:

u32 read32(const void* ptr) 
{ 
    u32 result; 
    memcpy(&result, ptr, 4); 
    return result; 
}

这个是正确的,将为任何 cpu 能够或不能在未对齐位置读取的代码生成正确的代码。更好的是,在 x86/x64 上,它针对单个读取操作进行了适当优化,因此具有与第一条语句相同的性能。它便携、安全、快速。谁能问更多?

嗯,问题是,在 ARM 上,我们就没那么幸运了。

memcpy版本确实是安全的,但是好像是系统性的谨慎操作,对于ARMv6和ARMv7(基本上任何智能手机)都非常慢。

在严重依赖读取操作的以性能为导向的应用程序中,可以测量第 1 版和第 2 版之间的差异:在 > 5xgcc -O2设置。这太多了,不容忽视。

试图找到一种使用 ARMv6/v7 功能的方法,我在周围的一些示例代码中寻找指导。不幸的是,他们似乎 select 第一个声明(直接 u32 访问),这不应该是正确的。

这还不是全部:新的 GCC 版本现在正在尝试实现自动矢量化。在 x64 上,这意味着 SSE/AVX,在 ARMv7 上,这意味着 NEON。 ARMv7 还支持一些新的 "Load Multiple" (LDM) 和 "Store Multiple" (STM) 操作码,需要 指针对齐。

这是什么意思?好吧,编译器可以自由使用这些高级指令,即使它们不是专门从 C 代码调用的(非内部指令)。为了做出这样的决定,它使用了 u32* pointer 应该在 4 个字节上对齐的事实。如果不是,那么所有赌注都将关闭:未定义的行为,崩溃。

这意味着即使在支持未对齐内存访问的 CPU 上,现在使用直接 u32 访问也是危险的,因为它可能导致在高优化设置下生成错误代码(-O3).

所以现在,这是一个难题:如何访问 ARMv6/v7 未对齐内存访问的本机性能 写入不正确的版本 u32 访问?

PS : 我也试过 __packed() 指令,从性能的角度来看,它们似乎与 memcpy 方法完全一样。

[编辑] : 感谢迄今为止收到的优秀元素。

查看生成的程序集,我可以确认@Notlikethat 发现 memcpy 版本确实生成了正确的 ldr 操作码(未对齐加载)。但是,我还发现生成的程序集无用地调用 str(命令)。所以完整的操作现在是一个未对齐的加载,一个对齐的存储,然后是一个最终的对齐加载。这比必要的工作要多得多。

回答@haneefmubarak,是的,代码已正确内联。不,memcpy 远未提供最佳速度,因为强制代码接受直接 u32 访问会转化为巨大的性能提升。所以一定存在更好的可能性。

非常感谢@artless_noise。 link 至高无上的服务是无价的。我从来没有能够如此清楚地看到 C 源代码及其汇编表示之间的等价性。这非常鼓舞人心。

我完成了一个@artless 示例,它给出了以下内容:

#include <stdlib.h>
#include <memory.h>
typedef unsigned int u32;

u32 reada32(const void* ptr) { return *(const u32*) ptr; }

u32 readu32(const void* ptr) 
{ 
    u32 result; 
    memcpy(&result, ptr, 4); 
    return result; 
}

一旦使用 ARM GCC 4.8.2 在 -O3 或 -O2 编译:

reada32(void const*):
    ldr r0, [r0]
    bx  lr
readu32(void const*):
    ldr r0, [r0]    @ unaligned
    sub sp, sp, #8
    str r0, [sp, #4]    @ unaligned
    ldr r0, [sp, #4]
    add sp, sp, #8
    bx  lr

很有说服力....

部分问题可能是您没有考虑到简单的内联性和进一步优化。具有用于加载的专用函数意味着每次调用时都可能发出函数调用,这可能会降低性能。

您可能会做的一件事是使用 static inline,这将允许编译器内联函数 load32(),从而提高性能。但是,在更高级别的优化中,编译器应该已经为您内联了它。

如果编译器内联一个 4 字节的 memcpy,它可能会将其转换为最有效的加载或存储系列,这些加载或存储仍将在未对齐的边界上运行。因此,如果即使启用了编译器优化,您仍然看到性能低下,可能 这是您正在使用的处理器上未对齐读取和写入的最大性能。既然你说“__packed 指令”产生与 memcpy() 相同的性能,这似乎就是这种情况。


此时,除了对齐数据外,您几乎无能为力。但是,如果您要处理未对齐的 u32 的连续数组,您可以做一件事:

#include <stdint.h>
#include <stdlib.h>

// get array of aligned u32
uint32_t *align32 (const void *p, size_t n) {
    uint32_t *r = malloc (n * sizeof (uint32_t));

    if (r)
        memcpy (r, p, n);

    return r;
}

这只是使用 malloc() 分配一个新数组,因为 malloc() 和朋友为所有内容分配正确对齐的内存:

The malloc() and calloc() functions return a pointer to the allocated memory that is suitably aligned for any kind of variable.

- malloc(3), Linux Programmer's Manual

这应该相对较快,因为您只需为每组数据执行一次。此外,在复制它时,memcpy() 将只能针对初始对齐不足进行调整,然后使用可用的最快对齐加载和存储指令,之后您将能够使用正常对齐处理数据以全性能读取和写入。

好吧,情况比人们想象的还要混乱。因此,为了澄清,这里是这次旅程的发现:

访问未对齐的内存

  1. 访问未对齐内存的唯一 portable C 标准解决方案是 memcpy 解决方案。我希望通过这个问题得到另一个,但显然这是迄今为止唯一找到的。

示例代码:

u32 read32(const void* ptr)  { 
    u32 value; 
    memcpy(&value, ptr, sizeof(value)); 
    return value;  }

此解决方案在所有情况下都是安全的。它还使用 GCC 在 x86 目标上编译成一个简单的 load register 操作。

但是,在使用 GCC 的 ARM 目标上,它会转化为一个太大且无用的汇编序列,这会降低性能。

在 ARM 目标上使用 Clang,memcpy 工作正常(请参阅下面的@notlikethat 评论)。很容易将整个问题归咎于 GCC,但事情并非如此简单:memcpy 解决方案在具有 x86/x64、PPC 和 ARM64 目标的 GCC 上运行良好。最后,尝试另一个编译器 icc13,memcpy 版本在 x86/x64 上出奇地重(4 条指令,而一条应该足够了)。这只是我到目前为止可以测试的组合。

要感谢godbolt的项目才能做出这样的说法easy to observe.

  1. 第二种解决方案是使用__packed结构。此解决方案不是 C 标准,完全取决于编译器的扩展。因此,编写它的方式取决于编译器,有时还取决于它的版本。这是维护portable代码的一团乱麻。

也就是说,在大多数情况下,它会生成比 memcpy 更好的代码生成。在大多数情况下仅...

例如,对于上述 memcpy 解决方案不起作用的情况,以下是调查结果:

  • 在带 ICC 的 x86 上:__packed 解决方案有效
  • 在带有 GCC 的 ARMv7 上:__packed 解决方案有效
  • 在带有 GCC 的 ARMv6 上:不起作用。组装看起来比 memcpy.

    还要丑
    1. 最后一个解决方案是使用直接 u32 访问未对齐的内存位置。此解决方案过去在 x86 cpu 上工作了几十年,但不推荐使用,因为它违反了一些 C 标准原则:编译器被授权将此声明视为数据正确对齐的保证,从而导致错误代码生成。

不幸的是,至少在一种情况下,它是唯一能够从目标中提取性能的解决方案。即用于 ARMv6 上的 GCC。

但不要将此解决方案用于 ARMv7:GCC 可以生成为对齐内存访问保留的指令,即 LDM(加载多个),导致崩溃。

即使在 x86/x64,如今以这种方式编写代码也变得很危险,因为新一代编译器可能会尝试自动向量化一些兼容的循环,生成 SSE/AVX 代码 基于这些内存位置正确对齐的假设,导致程序崩溃

作为回顾,这里是总结为 table 的结果,使用约定:memcpy > packed > direct。

| compiler  | x86/x64 | ARMv7  | ARMv6  | ARM64  |  PPC   |
|-----------|---------|--------|--------|--------|--------|
| GCC 4.8   | memcpy  | packed | direct | memcpy | memcpy |
| clang 3.6 | memcpy  | memcpy | memcpy | memcpy |   ?    |
| icc 13    | packed  | N/A    | N/A    | N/A    | N/A    |