为什么在AMD64架构上,对mmap内存的未对齐访问有时会导致段错误?

13

我有一段代码,在AMD64兼容CPU上运行Ubuntu 14.04时会导致分段错误:

#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能够访问未对齐地址的情况下,而且只在这种特定情况下发生呢?

3
在Debian上无法重现此问题。你能否发布生成的汇编代码?(如果你使用gcc编译器,可以使用-S选项获取汇编输出,并将汇编输出写入.s文件中。) - cmaster - reinstate monica
2
你确定你的 mmap() 成功了吗?也许它返回了一个错误... - Ctx
1
如果您调用malloc,然后在malloc调用之后添加“volatile uint8_t dummy = buffer [0];”这一行会发生什么?同样的错误吗?我想要的是实际堆分配可能会延迟到实际使用数据时。由于从malloc返回的缓冲区的内容保证保持未指定的值,因此C编译器可能认为它不必实际分配任何内容。 - Lundin
1
@Lundin 但是原帖作者声称,它可以使用malloc()而不能使用mmap() - Ctx
uint16_t *p = (buffer + 1)” 这段代码可以编译通过吗? - curiousguy
显示剩余3条评论
1个回答

20

相关:Pascal Cuoq的博客文章展示了GCC假设对齐指针的情况(即两个int*不会部分重叠):GCC总是假设对齐的指针访问。他还链接到了一篇2016年的博客文章(一个错误的故事:x86上的数据对齐),该文章与本问题具有完全相同的bug:使用错误对齐指针进行自动向量化 ->导致段错误。


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

我认为gcc从未打算在x86上支持不对齐的指针,它只是在使用非原子类型时无需自动矢量化。在ISO C中,使用小于alignof(uint16_t)=2对齐的uint16_t指针是明显未定义的行为。GCC在编译时可以看到您违反规则时不会发出警告,并且实际上会生成工作代码(对于malloc,它知道返回值的最小对齐),但这presumably just an accident of the gcc internals,不应被视为“支持”的指示。


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

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

但是gcc不将mmap视为内置函数,因此它不知道它返回页面对齐的内存,并应用其通常的自动向量化策略,该策略显然假定uint16_t * p是2字节对齐的,因此在处理错误对齐后可以使用movdqa。您的指针未对齐,违反了此假设。

(我想知道新的glibc头文件是否使用__attribute__((assume_aligned(4096)))mmap的返回值标记为对齐。这是一个好主意,并且可能会给您带来与malloc相同的代码生成。除了它不起作用,因为它会破坏mmap!=(void*)-1的错误检查,正如@Alcaro在Godbolt上指出的那样https://gcc.godbolt.org/z/gVrLWT


在能够访问不对齐数据的CPU上,SSE2的movdqa指令会导致段错误,而且你的元素本身就是未对齐的,因此你处于一个不寻常的情况,没有任何数组元素从16字节边界开始。

SSE2是x86-64的基线,所以gcc使用它。


Ubuntu 14.04LTS使用gcc4.8.2(离题:这是旧的和过时的,在许多情况下比gcc5.4或gcc6.4更差的代码生成,特别是在自动向量化时。它甚至不认识-march=haswell。)

14是此函数中gcc启发式算法决定自动向量化您的循环的最低阈值,使用-O3且没有-march-mtune选项。

我将你的代码放在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之前要执行多少个标量迭代,但是没有任何代码路径导致MOVDQU循环。即gcc没有处理p为奇数的情况的代码路径。

但malloc的代码生成看起来像这样:

    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个使用标量。这是一个未优化的地方:它可以轻松地使用movq加载另外4个,特别是因为在填充XMM向量后使用零来获取uint32_t元素之前进行解包。
(还有其他各种未优化的地方,比如可能使用pmaddwd和乘数1来将横向的一对字加入到dword元素中。)

使用不对齐指针编写安全代码:

如果您确实想编写使用不对齐指针的代码,可以使用memcpy在ISO C中正确地完成。在具有高效不对齐加载支持的目标(如x86)上,现代编译器仍将使用简单的标量加载到寄存器中,就像解引用指针一样。但是,在自动矢量化时,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编译器探索者)

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

但是使用memcpy,我们可以获得自动向量化的效果,即使是未对齐的加载(无需引入/退出以处理对齐),这与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的情况下,仅仅安排指针对齐是更好的选择。它避免了标量代码(或者像gcc这样向量化)的缓存行分裂。它不会花费太多额外的内存或空间,并且内存中的数据布局并非固定。
但有时这不是一个选项。当你复制原始类型的所有字节时,memcpy通常可以完全优化掉,现代gcc/clang可以做到这一点。即,只是一个加载或存储,没有函数调用和跳转到额外的内存位置。即使在 -O0 下,这个简单的memcpy也可以内联,没有函数调用,但是当然tmp不能被优化掉。
无论如何,如果你担心在更复杂的情况下或使用不同的编译器时可能不会优化掉,请检查编译器生成的汇编语言。例如,ICC18不会自动向量化使用memcpy的版本。 uint64_t tmp=0;然后复制低3个字节的memcpy编译成实际的内存复制和重新加载,因此这不是表达奇数大小类型零扩展的好方法。

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

如果GCC不知道指针是否对齐,即此用例,某些ISAs上的memcpy将无法内联。您可以使用带有GCC属性的typedef来创建类型的未对齐版本。

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

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

相关: 为什么glibc的strlen需要如此复杂才能快速运行?展示了如何使用位运算使C语言的strlen函数更加安全。

请注意,ICC似乎不支持__attribute__((may_alias)),但是gcc / clang支持。我最近尝试使用它来编写可移植且安全的4字节SIMD加载程序,例如_mm_loadu_si32(GCC缺失)。https://godbolt.org/z/ydMLCK在某些编译器上具有各种组合的安全但效率低下的代码生成,或者在ICC上不安全但在其他地方表现良好。

aligned(1)在像MIPS这样的ISA上可能比memcpy更好,因为不能在一条指令中执行未对齐的加载操作。

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

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

当然,您可以像正常索引一样对其进行索引,如p[i]


5
我不确定gcc曾经支持超出C或C++标准要求的未对齐指针的想法是从哪里来的。据我所见,它总是将指针代码编译成几乎最简单的代码,就像大多数其他现代编译器一样。当然,由于x86在许多地方都支持未对齐访问,因此在很多情况下这通常可以正常工作,但我认为这并不是任何证明gcc“支持”它的证据!非支持会是什么样子?主动检查未对齐指针并中止程序吗?当然,除了进行卫生处理之外,没有编译器会这样做。 - BeeOnRope
1
在我所知道的少数几种可能能够区分支持和不支持的情况中,_gcc会直接生成需要符合标准要求时的对齐方式的指令_。例如,当指针类型表示16字节对齐时,它很高兴使用movaps来移动16位值。它确实不会做任何事情来支持未对齐的atomic_tstd::atomic<>值:它发出的指令只有在指针正确对齐时才具有适当的原子性保证。 Gcc似乎像其他编译器一样处理对齐方式。 - BeeOnRope
1
你可以将编译器根据特定类型的UB划分为三类:(1)尽一切可能允许它存在,(2)生成代码时假设其不存在但不会检测或防止它,以及(3)尽一切可能检测并采取措施。在我看来,大多数编译器针对大多数类型的UB通常采用第二种方式。这毕竟是最简单的方式,并且生成的代码速度最快。安全问题已经使第三种方式变得更加流行,即使在运行时也需要付出代价(例如,缓冲区检查)。 - BeeOnRope
1
有趣的是,对于 OP 代码中的 malloc 版本,clang 编译整个函数为 xor eax, eax,因为它可能看到了由 malloc 返回的未初始化内存的访问并采取了捷径。@PeterCordes - memcpy 版本 似乎可以生成向量化代码,而不需要任何复制(似乎与原始直接指针访问相同的代码)。我的经验是,与 gccclang 相比,iccmsvc 在处理这种类型的 memcpy 时都很糟糕。对于这些编译器来说,直接(可能是 UB)指针访问要好得多。 - BeeOnRope
5
新版的glibc头文件是否使用__attribute__((assume_aligned(4096)))标记mmap?不是,并且也不应该这么做。mmap在失败时返回MAP_FAILED,即(void*)-1,它不是4096对齐的,因此GCC将删除您的错误检查。https://gcc.godbolt.org/z/gVrLWT - Alcaro
显示剩余13条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接