为什么对 mmap 内存的未对齐访问有时会在 AMD64 上出现段错误?

Why does unaligned access to mmap'ed memory sometimes segfault on AMD64?

我有一段代码,当 运行 on Ubuntu 14.04 on AMD64 compatible CPU:

#include <inttypes.h>
#include <stdlib.h>

#include <sys/mman.h>

int main()
{
  uint32_t sum = 0;
  uint8_t *buffer = mmap(NULL, 1<<18, PROT_READ,
                         MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
  uint16_t *p = (buffer + 1);
  int i;

  for (i=0;i<14;++i) {
    //printf("%d\n", i);
    sum += p[i];
  }

  return sum;
}

如果使用 mmap 分配内存,这只会出现段错误。如果我使用 malloc、堆栈上的缓冲区或全局变量,它不会出现段错误。

如果我将循环的迭代次数减少到小于 14,它就不再出现段错误。如果我从循环中打印数组索引,它也不再出现段错误。

为什么在能够访问未对齐地址的 CPU 上发生未对齐内存访问段错误,为什么仅在这种特定情况下?

相关:Pascal Cuoq 的博客 post 展示了 GCC 假定对齐指针(两个 int* 不部分重叠)的情况:GCC always assumes aligned pointer accesses. He also links to a 2016 blog post (A bug story: data alignment on x86)具有完全相同的错误作为这个问题:auto-vectorization 指针未对齐 -> 段错误。


gcc4.8 制作了一个尝试到达对齐边界的循环序言,但是它假设uint16_t *p 是 2 字节对齐的,即一些数量标量迭代将使指针 16 字节对齐。

我不认为 gcc 曾经打算在 x86 上支持未对齐的指针,它只是碰巧适用于没有 auto-vectorization 的 non-atomic 类型。在 ISO C 中使用小于 alignof(uint16_t)=2 对齐的指向 uint16_t 的指针绝对是未定义的行为。当 GCC 发现您在编译时违反了规则时,它不会发出警告,而实际上恰好生成了工作代码(对于 malloc,它知道 return-value 最小对齐),但那是 presumably just an accident of the gcc internals,不应被视为 "support".

的指示

尝试 -O3 -fno-tree-vectorize-O2。如果我的解释是正确的,那不会出现段错误,因为它只会使用标量加载(正如你在 x86 上所说的那样没有任何对齐要求)。


gcc 知道此目标上的 malloc returns 16 字节对齐内存(x86-64 Linux,其中 maxalign_t 是 16 字节宽,因为 long double 在 x86-64 System V ABI 中填充到 16 个字节)。它会看到您在做什么并使用 movdqu.

但 gcc 不将 mmap 视为内置函数,因此它不知道它 return 是 page-aligned 内存,并应用其通常的 auto-vectorization显然假设 uint16_t *p 是 2 字节对齐的策略,因此它可以在处理错位后使用 movdqa。您的指针未对齐并违反了此假设。

(我想知道较新的 glibc headers 是否使用 __attribute__((assume_aligned(4096)))mmap 的 return 值标记为已对齐。那将是一个好主意,并且可能给你的 code-gen 和 malloc 差不多。除了它不起作用,因为它会破坏 mmap != (void*)-1as @Alcaro points out with an example on Godbolt: https://gcc.godbolt.org/z/gVrLWT)[=72 的 error-checking =]


on a CPU that is able to access unaligned

SSE2 movdqa 未对齐时出现段错误,并且您的元素本身未对齐,因此出现异常情况,即没有数组元素从 16 字节边界开始。

SSE2 是 x86-64 的基线,因此 gcc 使用它。


Ubuntu 14.04LTS uses gcc4.8.2(题外话:旧的和过时的,在很多情况下 code-gen 比 gcc5.4 或 gcc6.4 更糟糕,尤其是 auto-vectorizing。它甚至不认识-march=haswell.)

14 是 gcc 的试探法决定 auto-vectorize 您在此函数中的循环的最小阈值 -O3 而没有 -march-mtune 选项。

我把你的代码on Godbolt,这是main的相关部分:

    call    mmap    #
    lea     rdi, [rax+1]      # p,
    mov     rdx, rax  # buffer,
    mov     rax, rdi  # D.2507, p
    and     eax, 15   # D.2507,
    shr     rax        ##### rax>>=1 discards the low byte, assuming it's zero
    neg     rax       # D.2507
    mov     esi, eax  # prolog_loop_niters.7, D.2507
    and     esi, 7    # prolog_loop_niters.7,
    je      .L2
    # .L2 leads directly to a MOVDQA xmm2, [rdx+1]

它计算出(使用此代码块)在到达 MOVDQA 之前要进行多少次标量迭代,但是 none 的代码路径导致 MOVDQU 循环。即 gcc 没有代码路径来处理 p 为奇数的情况。


但是 malloc 的 code-gen 看起来像这样:

    call    malloc  #
    movzx   edx, WORD PTR [rax+17]        # D.2497, MEM[(uint16_t *)buffer_5 + 17B]
    movzx   ecx, WORD PTR [rax+27]        # D.2497, MEM[(uint16_t *)buffer_5 + 27B]
    movdqu  xmm2, XMMWORD PTR [rax+1]   # tmp91, MEM[(uint16_t *)buffer_5 + 1B]

注意 movdqu 的用法。混合了更多标量 movzx 负载:14 次总迭代中有 8 次通过 SIMD 完成,其余 6 次使用标量。这是一个 missed-optimization:它可以很容易地用 movq 加载另外 4 个,特别是因为它在解包后填充了一个 XMM 向量 在添加之前用零获得 uint32_t 个元素。

(还有其他各种 missed-optimization,比如可能使用 pmaddwd 和乘数 1 将水平单词对添加到双字元素中。)


具有未对齐指针的安全代码:

如果您确实想编写使用未对齐指针的代码,您可以在 ISO C 中使用 memcpy 正确地完成它。在具有高效未对齐加载支持的目标上(如 x86),现代编译器仍将只使用简单的标量加载到寄存器中,就像取消引用指针一样。但是当 auto-vectorizing 时,gcc 不会假定对齐的指针与元素边界对齐,并且会使用未对齐的加载。

memcpy 是您在 ISO C/C++ 中表达未对齐加载/存储的方式。

#include <string.h>

int sum(int *p) {
    int sum=0;
    for (int i=0 ; i<10001 ; i++) {
        // sum += p[i];
        int tmp;
#ifdef USE_ALIGNED
        tmp = p[i];     // normal dereference
#else
        memcpy(&tmp, &p[i], sizeof(tmp));  // unaligned load
#endif
        sum += tmp;
    }
    return sum;
}

使用 gcc7.2 -O3 -DUSE_ALIGNED,我们得到通常的标量,直到对齐边界,然后是向量循环:(Godbolt compiler explorer)

.L4:    # gcc7.2 normal dereference
    add     eax, 1
    paddd   xmm0, XMMWORD PTR [rdx]
    add     rdx, 16
    cmp     ecx, eax
    ja      .L4

但使用 memcpy,我们得到 auto-vectorization 未对齐的负载(没有 intro/outro 来处理对齐),这与 gcc 的正常偏好不同:

.L2:   # gcc7.2 memcpy for an unaligned pointer
    movdqu  xmm2, XMMWORD PTR [rdi]
    add     rdi, 16
    cmp     rax, rdi      # end_pointer != pointer
    paddd   xmm0, xmm2
    jne     .L2           # -mtune=generic still doesn't optimize for macro-fusion of cmp/jcc :(

    # hsum into EAX, then the final odd scalar element:
    add     eax, DWORD PTR [rdi+40000]   # this is how memcpy compiles for normal scalar code, too.

在 OP 的情况下,简单地安排指针对齐是更好的选择。它避免了标量代码的 cache-line 拆分(或 gcc 的矢量化方式)。它不会占用大量额外内存或 space,并且内存中的数据布局不固定。

但有时这不是一种选择。 memcpy 当您复制原始类型的所有字节时,使用现代 gcc / clang 相当可靠地完全优化。即只是加载或存储,没有函数调用,也没有弹跳到额外的内存位置。即使在 -O0,这个简单的 memcpy 内联也没有函数调用,但是 curse tmp 没有优化。

无论如何,如果您担心它可能无法在更复杂的情况下或使用不同的编译器进行优化,请检查 compiler-generated asm。例如ICC18没有auto-vectorize使用memcpy的版本。

uint64_t tmp=0; 然后 memcpy 在低 3 个字节上编译为实际副本到内存并重新加载,所以这不是表达 zero-extension 类型的好方法 odd-sized ,因为例如。


GNU C __attribute__((aligned(1)))may_alias

而不是 memcpy(当 GCC 不知道指针对齐时,它不会在某些 ISA 上内联,即正是这个 use-case),您还可以使用带有GCC 属性,用于创建类型的 under-aligned 版本。

typedef int __attribute__((aligned(1), may_alias)) unaligned_aliasing_int;

typedef unsigned long __attribute__((may_alias, aligned(1))) unaligned_aliasing_ulong;

相关:Why does glibc's strlen need to be so complicated to run quickly? 展示了如何使 word-at-a-time bithack C strlen 安全。

请注意,ICC 似乎不尊重 __attribute__((may_alias)),但 gcc/clang 尊重。我最近在尝试编写一个可移植且安全的 4 字节 SIMD 加载,如 _mm_loadu_si32(GCC 缺失)。 https://godbolt.org/z/ydMLCK 在某些编译器上有各种安全无处不在但效率低下的组合 code-gen,或者在 ICC 上不安全但无处不在。

aligned(1) 在像 MIPS 这样的 ISA 上,在一条指令中无法完成未对齐加载的情况下,可能不如 memcpy 糟糕。

您可以像使用任何其他指针一样使用它。

unaligned_aliasing_int *p = something;
int tmp = *p++;
int tmp2 = *p++;

当然你可以像p[i]一样正常索引它。