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

时间:2015-08-18 03:09:44

标签: c arm memory-alignment

过去,ARM处理器无法正确处理未对齐的内存访问(ARMv5及更低版本)。如果u32 var32 = *(u32*)ptr;没有在4字节上正确对齐,那么ptr之类的东西就会失败(引发异常)。

编写这样的语句对于x86 / x64可以正常工作,因为这些CPU总是非常有效地处理这种情况。但根据C标准,这不是一种“正确”的写作方式。 u32显然等同于4个字节的结构,必须在4个字节上对齐。

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

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

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

嗯,问题是,在ARM上,我们并不那么幸运。

编写memcpy版本确实是安全的,但似乎会导致系统谨慎的操作,对于ARMv6和ARMv7(基本上是任何智能手机)都非常慢。

在一个严重依赖读取操作的性能导向应用程序中,可以测量第一版和第二版之间的差异:它位于> gcc -O2设置为5x 。这太过不容忽视了。

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

这还不是全部:新的GCC版本现在正在尝试实现自动矢量化。在x64上,这意味着SSE / AVX,在ARMv7上意味着NEON。 ARMv7还支持一些新的“加载多个”(LDM)和“存储多个”(STM)操作码,要求指针对齐。

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

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

现在,这是一个困境:如何在没有写入错误版本u32访问权限的情况下访问未对齐内存访问上的ARMv6 / v7的本机性能?

PS:我还尝试过__packed()条指令,从性能角度来看,它们似乎与memcpy方法完全相同。

[编辑]:感谢目前收到的优秀元素。

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

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

非常感谢@artless_noise。 godbolt服务的链接是无价的。我从来没有能够清楚地看到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

很明白......

2 个答案:

答案 0 :(得分:13)

好的,情况比想要的更令人困惑。因此,为了澄清,以下是这次旅程的结果:

访问未对齐的内存

  1. 访问未对齐内存的唯一可移植C标准解决方案是memcpy。我希望通过这个问题得到另一个,但显然它是迄今为止发现的唯一一个。
  2. 示例代码:

    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归咎于GCC很容易,但事情并非如此简单:memcpy解决方案在GCC上运行时可以正常使用x86 / x64,PPC和ARM64目标。最后,尝试另一个编译器icc13,memcpy版本在x86 / x64(4个指令,但一个应该足够)上出乎意料地更重。而那只是我到目前为止可以测试的组合。

    我要感谢godbolt的项目做出这样的陈述easy to observe

    1. 第二种解决方案是使用__packed结构。该解决方案不是C标准,完全取决于编译器的扩展。因此,编写它的方式取决于编译器,有时取决于其版本。这对于维护便携式代码来说是一团糟。
    2. 话虽如此,在大多数情况下,它会导致比memcpy更好的代码生成。在大多数情况下只... ...

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

      • 在x86上使用ICC:__packed解决方案正常工作
      • 在带有GCC的ARMv7上:__packed解决方案正常工作
      • 带有GCC的ARMv6上的
      • :不起作用。装配看起来比memcpy更加丑陋。

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

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

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

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

      作为回顾,以下是使用约定汇总为表格的结果:memcpy&gt;打包&gt;直接

      | 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    |
      

答案 1 :(得分:2)

部分问题可能是您不允许轻松的无法入侵和进一步优化。具有负载的专用功能意味着每次调用时都可以发出函数调用,这可能会降低性能。

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

如果编译器内联一个4字节的memcpy,它可能会将其转换为仍然可以在未对齐边界上工作的最有效的一系列加载或存储。因此,如果在启用编译器优化的情况下仍然看到性能较低,则可能这是您正在使用的处理器上未对齐读写的最大性能。既然你说过&#34; __packed指令&#34;正在产生与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()和朋友为所有内容分配内存和正确对齐:

  

malloc()和calloc()函数返回一个指向已分配内存的指针,该内存适合于任何类型的变量。

     

- malloc(3), Linux Programmer's Manual

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