【问题标题】:Why is compiler generating 4-byte load instead of 1-byte load where the wider load may access unmapped data?为什么编译器生成 4 字节负载而不是 1 字节负载,因为更广泛的负载可能会访问未映射的数据?
【发布时间】:2016-12-13 18:15:56
【问题描述】:

我有一个填充了可变长度记录的字节缓冲区,其长度由记录的第一个字节决定。读取单个记录的 C 函数的简化版本

void mach_parse_compressed(unsigned char* ptr, unsigned long int* val)
{
    if (ptr[0] < 0xC0U) {
        *val = ptr[0] + ptr[1];
        return;
    }

  *val = ((unsigned long int)(ptr[0]) << 24)
      | ((unsigned long int)(ptr[1]) << 16)
      | ((unsigned long int)(ptr[2]) << 8)
      | ptr[3];
}

生成程序集(x86_64 上的 GCC 5.4 -O2 -fPIC),首先在 ptr 加载四个字节,将第一个字节与 0xC0 进行比较,然后处理两个或四个字节。未定义的字节被正确丢弃,但为什么编译器认为首先加载四个字节是安全的?因为没有例如ptr 的对齐要求,它可能指向内存页的最后两个字节,就我们所知,该内存页位于未映射的内存页旁边,从而导致崩溃。

需要 -fPIC 和 -O2 或更高版本才能重现。

我在这里遗漏了什么吗?编译器这样做是否正确,我该如何解决这个问题?

我可以得到上述显示 Valgrind/AddressSanitiser 错误或使用 mmap/mprotect 崩溃:

//#define HEAP
#define MMAP
#ifdef MMAP
#include <unistd.h>
#include <sys/mman.h>
#include <stdio.h>
#elif HEAP
#include <stdlib.h>
#endif

void
mach_parse_compressed(unsigned char* ptr, unsigned long int* val)
{
    if (ptr[0] < 0xC0U) {
        *val = ptr[0] + ptr[1];
        return;
    }

    *val = ((unsigned long int)(ptr[0]) << 24)
        | ((unsigned long int)(ptr[1]) << 16)
        | ((unsigned long int)(ptr[2]) << 8)
        | ptr[3];
}

int main(void)
{
    unsigned long int val;
#ifdef MMAP
    int error;
    long page_size = sysconf(_SC_PAGESIZE);
    unsigned char *buf = mmap(NULL, page_size * 2, PROT_READ | PROT_WRITE,
                              MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    unsigned char *ptr = buf + page_size - 2;
    if (buf == MAP_FAILED)
    {
        perror("mmap");
        return 1;
    }
    error = mprotect(buf + page_size, page_size, PROT_NONE);
    if (error != 0)
    {
        perror("mprotect");
        return 2;
    }
    *ptr = 0xBF;
    *(ptr + 1) = 0x10;
    mach_parse_compressed(ptr, &val);
#elif HEAP
    unsigned char *buf = malloc(16384);
    unsigned char *ptr = buf + 16382;
    buf[16382] = 0xBF;
    buf[16383] = 0x10;
#else
    unsigned char buf[2];
    unsigned char *ptr = buf;
    buf[0] = 0xBF;
    buf[1] = 0x10;
#endif
    mach_parse_compressed(ptr, &val);
}

MMAP 版本:

Segmentation fault (core dumped)

使用 Valgrind:

==3540== Process terminating with default action of signal 11 (SIGSEGV)
==3540==  Bad permissions for mapped region at address 0x4029000
==3540==    at 0x400740: mach_parse_compressed (in /home/laurynas/gcc-too-wide-load/gcc-too-wide-load)
==3540==    by 0x40060A: main (in /home/laurynas/gcc-too-wide-load/gcc-too-wide-load)

使用 ASan:

ASAN:SIGSEGV
=================================================================
==3548==ERROR: AddressSanitizer: SEGV on unknown address 0x7f8f4dc25000 (pc 0x000000400d8a bp 0x0fff884e56c6 sp 0x7ffc4272b620 T0)
    #0 0x400d89 in mach_parse_compressed (/home/laurynas/gcc-too-wide-load/gcc-too-wide-load+0x400d89)
    #1 0x400b92 in main (/home/laurynas/gcc-too-wide-load/gcc-too-wide-load+0x400b92)
    #2 0x7f8f4c72082f in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2082f)
    #3 0x400c58 in _start (/home/laurynas/gcc-too-wide-load/gcc-too-wide-load+0x400c58)

AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: SEGV ??:0 mach_parse_compressed

带有 Valgrind 的 HEAP 版本:

==30498== Invalid read of size 4
==30498==    at 0x400603: mach_parse_compressed (mach0data_reduced.c:9)
==30498==    by 0x4004DE: main (mach0data_reduced.c:34)
==30498==  Address 0x520703e is 16,382 bytes inside a block of size 16,384 alloc'd
==30498==    at 0x4C2DB8F: malloc (vg_replace_malloc.c:299)
==30498==    by 0x4004C0: main (mach0data_reduced.c:24)

带有 ASan 的堆栈版本:

==30528==ERROR: AddressSanitizer: stack-buffer-overflow on address
0x7ffd50000440 at pc 0x000000400b63 bp 0x7ffd500003c0 sp
0x7ffd500003b0
READ of size 4 at 0x7ffd50000440 thread T0
    #0 0x400b62 in mach_parse_compressed
CMakeFiles/innobase.dir/mach/mach0data_reduced.c:15
    #1 0x40087e in main CMakeFiles/innobase.dir/mach/mach0data_reduced.c:34
    #2 0x7f3be2ce282f in __libc_start_main
(/lib/x86_64-linux-gnu/libc.so.6+0x2082f)
    #3 0x400948 in _start
(/home/laurynas/obj-percona-5.5-release/storage/innobase/CMakeFiles/innobase.dir/mach/mach0data_test+0x400948)

谢谢

编辑:添加了实际崩溃的 MMAP 版本,澄清了编译器选项

编辑 2: 将其报告为 https://gcc.gnu.org/bugzilla/show_bug.cgi?id=77673。对于解决方法,在if 语句之后插入编译器内存屏障asm volatile("": : :"memory"); 可以解决该问题。谢谢大家!

【问题讨论】:

  • 听起来像一个编译器错误。您可能需要提交错误报告。
  • 可能,但想先在这里咨询语言律师/编译器专家,一个明显的编译器错误通常是用户错误
  • 编译器可能知道这个 4 字节加载永远不会导致目标架构崩溃(尽管有 valgrind 报告)。如果您可以生成一个实际崩溃的示例,那么它将加强编译器错误的情况。
  • Gcc 显然是在作弊。它知道对齐不是问题(因为这是 x86)。不太清楚它对malloc 有什么了解。要让它真正崩溃,请尝试使用mmap 和mprotect 将缓冲区放入一个页面,然后是一个不可读的页面。如果 gcc 仍然执行四字节加载,它将进入不可读的页面,您应该得到一个运行时错误,因此有一个很好的错误演示。
  • @M.M,我添加了一个实际崩溃的 mmap/mprotect 版本

标签: c compiler-optimization


【解决方案1】:

恭喜!你发现了一个真正的编译器错误!

您可以使用http://gcc.godbolt.org 来探索来自不同编译器和选项的汇编输出。

对于 x86 64 位 linux 的 gcc 版本 6.2,使用 gcc -fPIC -O2,您的函数会编译为不正确代码:

mach_parse_compressed(unsigned char*, unsigned long*):
    movzbl  (%rdi), %edx
    movl    (%rdi), %eax   ; potentially incorrect load of 4 bytes
    bswap   %eax
    cmpb    $-65, %dl
    jbe     .L5
    movl    %eax, %eax
    movq    %rax, (%rsi)
    ret
.L5:
    movzbl  1(%rdi), %eax
    addl    %eax, %edx
    movslq  %edx, %rdx
    movq    %rdx, (%rsi)
    ret

您正确诊断了问题,mmap 示例提供了良好的回归测试。 gcc 正在努力优化这个函数,结果代码肯定是不正确的:对于大多数 X86 操作环境来说,从一个未对齐的地址读取 4 个字节是可以的,但读取数组末尾之后就不行了。

编译器可以假设如果数组末尾没有跨越 32 位甚至 64 位边界,则读取超出数组末尾是可以的,但是对于您的示例,这种假设是不正确的。如果您将分配的块设置为足够大,您可能会导致使用malloc 分配的块崩溃。 malloc 将mmap 用于非常大的块(>= 128KB 默认为 IRCC)。

请注意,此错误是在 5.1 版编译器中引入的。

另一方面clang没有这个问题,但是代码在一般情况下似乎效率较低:

#    @mach_parse_compressed(unsigned char*, unsigned long*)
mach_parse_compressed(unsigned char*, unsigned long*):         
    movzbl  (%rdi), %ecx
    cmpq    $191, %rcx
    movzbl  1(%rdi), %eax
    ja      .LBB0_2
    addq    %rcx, %rax
    movq    %rax, (%rsi)
    retq
.LBB0_2:
    shlq    $24, %rcx
    shlq    $16, %rax
    orq     %rcx, %rax
    movzbl  2(%rdi), %ecx
    shlq    $8, %rcx
    orq     %rax, %rcx
    movzbl  3(%rdi), %eax
    orq     %rcx, %rax
    movq    %rax, (%rsi)
    retq

【讨论】:

    【解决方案2】:

    似乎编译器优化了对 ptr 的访问。只需添加关键字 volatile 即可禁用访问 ptr 的优化。在这种情况下,MMAP 变体不会崩溃。

    //#define HEAP
    #define MMAP
    #ifdef MMAP
    #include <unistd.h>
    #include <sys/mman.h>
    #include <stdio.h>
    #elif HEAP
    #include <stdlib.h>
    #endif
    
    void
    mach_parse_compressed(volatile unsigned char* ptr, unsigned long int* val)
    {
        if (ptr[0] < 0xC0U) {
            *val = ptr[0] + ptr[1];
            return;
        }
    
        *val = ((unsigned long int)(ptr[0]) << 24)
            | ((unsigned long int)(ptr[1]) << 16)
            | ((unsigned long int)(ptr[2]) << 8)
            | ptr[3];
    }
    
    int main(void)
    {
        unsigned long int val;
    #ifdef MMAP
        int error;
        long page_size = sysconf(_SC_PAGESIZE);
        unsigned char *buf = (unsigned char *) mmap(NULL, page_size * 2, PROT_READ | PROT_WRITE,
                                  MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
        unsigned char *ptr = buf + page_size - 2;
        if (buf == MAP_FAILED)
        {
            perror("mmap");
            return 1;
        }
        error = mprotect(buf + page_size, page_size, PROT_NONE);
        if (error != 0)
        {
            perror("mprotect");
            return 2;
        }
        *ptr = 0xBF;
        *(ptr + 1) = 0x10;
        mach_parse_compressed(ptr, &val);
    #elif HEAP
        unsigned char *buf = malloc(16384);
        unsigned char *ptr = buf + 16382;
        buf[16382] = 0xBF;
        buf[16383] = 0x10;
    #else
        unsigned char buf[2];
        unsigned char *ptr = buf;
        buf[0] = 0xBF;
        buf[1] = 0x10;
    #endif
        mach_parse_compressed(ptr, &val);
    }
    

    【讨论】:

    • 我想知道(稍后会尝试)我是否可以插入内存屏障而不是重型 volatile-hammer 来解决问题
    • 是的,一个“asm volatile("": : :"memory");" if 语句之后是一种变通方法,并且不会使代码悲观。
    【解决方案3】:

    在某些架构(例如 STM32)上,4 字节加载/存储操作应用于“定位”操作数的 4 字节段。

    例如,来自地址 0x80000003 的 4 字节加载将应用于 0x80000000。

    除此之外,内存总线还映射一个地址空间,该地址空间从一个 4 字节对齐的地址开始,包含整数个 4 字节段。

    例如,地址空间从 0(包括)开始,到 0x80000000(不包括)结束。

    现在,假设我们采用这样的架构,并配置总线以允许读取(加载)整个地址空间。

    随后,一个 4 字节的加载操作将在给定地址空间内的任何位置成功完成(不会导致总线故障)。


    话虽如此,据我所知,x86/x64 上的情况并非如此......

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2016-08-07
      • 1970-01-01
      • 1970-01-01
      • 2016-02-26
      • 1970-01-01
      • 1970-01-01
      • 2019-05-17
      相关资源
      最近更新 更多