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

时间:2017-11-27 12:15:15

标签: c gcc x86-64 mmap auto-vectorization

我有这段代码在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上的段错误,以及为什么只在这种特定情况下呢?

1 个答案:

答案 0 :(得分:13)

gcc4.8做了一个尝试到达对齐边界的序言,但是它假定uint16_t *p是2字节对齐,即一定数量的标量迭代会产生指针16字节对齐。

我不认为gcc曾打算支持x86上的错位指针,它恰好适用于没有自动矢量化的非原子类型。在ISO C中,使用指向uint16_t且指针小于alignof(uint16_t)=2的指针肯定是未定义的行为。当GCC在编译时看到你违反规则时,GCC不会发出警告,并且实际上碰巧制作了工作代码(对于malloc知道返回值最小对齐的情况),但是这样做了presumably just an accident of the gcc internals,并且不应被视为&#34;支持&#34;。

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

gcc know malloc返回此目标上的16字节对齐内存(x86-64 Linux,其中maxalign_t为16字节宽,因为long double在x86中填充了16个字节-64 System V ABI)。它会看到您正在执行的操作并使用movdqu

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

(我想知道更新的glibc标头是否使用movdqa__attribute__((assume_aligned(4096)))的返回值标记为已对齐。这将是一个好主意,并且可能会给你相同的代码-gen和mmap一样。除非它不起作用,因为它会破坏malloc的错误检查,as @Alcaro points out以及关于Godbolt的示例:https://gcc.godbolt.org/z/gVrLWT)< / p>

  

在能够访问未对齐

的CPU上

SSE2 mmap != (void*)-1未对齐的段错误,并且您的元素本身未对齐,因此您有一个不常见的情况,即没有数组元素以16字节边界开始。

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

Ubuntu 14.04LTS uses gcc4.8.2(偏离主题:旧的和过时的,在许多情况下比gcc5.4或gcc6.4更糟糕的代码生成,特别是在自动矢量化时。它甚至不能识别{{1 }}。)

14是gcc启发式决定在此功能中自动矢量化循环的最低阈值movdqa且无-march=haswell或{{ 1}}选项。

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

-march

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

但malloc的代码生成如下:

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] 的使用。混合了一些标量p加载:14次迭代中有8次完成SIMD,剩下的6次加上标量。这是一个错过优化:它可以轻松地执行另一个4 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] 加载,特别是因为它在解包后填充XMM向量  在添加之前使用零来获取uint32_t元素。

(还有其他各种遗漏优化,例如可能使用movdqu乘以movzx来将水平词对添加到dword元素中。)

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

如果您确实想编写使用未对齐指针的代码,可以使用movq在ISO C中正确执行。在具有高效未对齐负载支持的目标(如x86)上,现代编译器仍然只使用一个简单的标量加载到寄存器中,就像取消引用指针一样。但是当自动矢量化时,gcc不会假设对齐的指针与元素边界对齐并且将使用未对齐的载荷。

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

1

使用memcpy,我们得到通常的标量,直到对齐边界,然后是向量循环:(Godbolt compiler explorer

memcpy

但是对于#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; } ,我们会使用未对齐的加载进行自动向量化(没有intro / outro来处理对齐),这与gcc的正常偏好不同:

gcc7.2 -O3 -DUSE_ALIGNED

在OP的情况下,简单地安排指针对齐是一个更好的选择。它避免了标量代码的缓存行拆分(或者像gcc那样进行矢量化)。它不会花费大量额外的内存或空间,并且内存中的数据布局也不会被修复。

但有时候这不是一种选择。当您复制基本类型的所有字节时,.L4: # gcc7.2 normal dereference add eax, 1 paddd xmm0, XMMWORD PTR [rdx] add rdx, 16 cmp ecx, eax ja .L4 可以完全可靠地使用现代gcc / clang进行优化。即只是一个加载或存储,没有函数调用,也没有弹跳到额外的内存位置。即使在memcpy,这个简单的.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. 内联也没有函数调用,但当然memcpy并没有优化。

无论如何,检查编译器生成的asm,如果您担心它可能不会在更复杂的情况下或使用不同的编译器进行优化。例如,ICC18不使用memcpy自动矢量化版本。

-O0然后将低3字节的memcpy编译成实际的内存副本并重新加载,这样就不是表示奇数大小类型的零扩展的好方法。