V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
NebulaGraph
V2EX  ›  推广

记一起由 Clang 编译器优化触发的 Crash

  •  1
     
  •   NebulaGraph · 2020-12-10 09:58:31 +08:00 · 2563 次点击
    这是一个创建于 1445 天前的主题,其中的信息可能已经有所发展或是发生改变。

    摘要:一个有意思的 Crash 探究过程,Clang 有 GCC 没有

    本文首发于 Nebula Graph 官方博客:https://nebula-graph.com.cn/posts/troubleshooting-crash-clang-compiler-optimization/

    troubleshooting-crash-clang-compiler-optimization

    如果有人告诉你,下面的 C++ 函数会导致程序 crash,你会想到哪些原因呢?

    std::string b2s(bool b) {
        return b ? "true" : "false";
    }
    

    如果再多给一些描述,比如:

    • Crash 以一定的概率复现
    • Crash 原因是段错误( SIGSEGV )
    • 现场的 Backtrace 经常是不完整甚至完全丢失的。
    • 只有优化级别在 -O2 以上才会(更容易)复现
    • 仅在 Clang 下复现,GCC 复现不了

    好了,一些老鸟可能已经有线索了,下面给出一个最小化的复现程序和步骤:

    // file crash.cpp
    #include <iostream>
    #include <string>
    
    std::string __attribute__((noinline)) b2s(bool b) {
        return b ? "true" : "false";
    }
    
    union {
        unsigned char c;
        bool b;
    } volatile u;
    
    int main() {
        u.c = 0x80;
        std::cout << b2s(u.b) << std::endl;
        return 0;
    }
    
    $ clang++ -O2 crash.cpp
    $ ./a.out
    truefalse,d$x4DdzRx
    
    Segmentation fault (core dumped)
    
    $ gdb ./a.out core.3699
    Core was generated by `./a.out'.
    Program terminated with signal SIGSEGV, Segmentation fault.
    #0  0x0000012cfffff0d4 in ?? ()
    (gdb) bt
    #0  0x0000012cfffff0d4 in ?? ()
    #1  0x00000064fffff0f4 in ?? ()
    #2  0x00000078fffff124 in ?? ()
    #3  0x000000b4fffff1e4 in ?? ()
    #4  0x000000fcfffff234 in ?? ()
    #5  0x00000144fffff2f4 in ?? ()
    #6  0x0000018cfffff364 in ?? ()
    #7  0x0000000000000014 in ?? ()
    #8  0x0110780100527a01 in ?? ()
    #9  0x0000019008070c1b in ?? ()
    #10 0x0000001c00000010 in ?? ()
    #11 0x0000002ffffff088 in ?? ()
    #12 0xe2ab001010074400 in ?? ()
    #13 0x0000000000000000 in ?? ()
    

    因为 backtrace 信息不完整,说明程序并不是在第一时间 crash 的。面对这种情况,为了快速找出第一现场,我们可以试试 AddressSanitizer ( ASan ):

    $ clang++ -g -O2 -fno-omit-frame-pointer -fsanitize=address crash.cpp
    $ ./a.out
    =================================================================
    ==3699==ERROR: AddressSanitizer: global-buffer-overflow on address 0x000000552805 at pc 0x0000004ff83a bp 0x7ffd7610d240 sp 0x7ffd7610c9f0
    READ of size 133 at 0x000000552805 thread T0
        #0 0x4ff839 in __asan_memcpy (a.out+0x4ff839)
        #1 0x5390a7 in b2s[abi:cxx11](bool) crash.cpp:6
        #2 0x5391be in main crash.cpp:16:18
        #3 0x7faed604df42 in __libc_start_main (/usr/lib64/libc.so.6+0x23f42)
        #4 0x41c43d in _start (a.out+0x41c43d)
    
    0x000000552805 is located 59 bytes to the left of global variable '<string literal>' defined in 'crash.cpp:6:25' (0x552840) of size 6
      '<string literal>' is ascii string 'false'
    0x000000552805 is located 0 bytes to the right of global variable '<string literal>' defined in 'crash.cpp:6:16' (0x552800) of size 5
      '<string literal>' is ascii string 'true'
    SUMMARY: AddressSanitizer: global-buffer-overflow (/home/dutor.hou/Wdir/nebula-graph/build/bug/a.out+0x4ff839) in __asan_memcpy
    Shadow bytes around the buggy address:
    …
    ...
    

    从 ASan 给出的信息,我们可以定位到是函数 b2s(bool) 在读取字符串常量 "true" 的时候,发生了“全局缓冲区溢出”。好了,我们再次以上帝视角审视一下问题函数和复现程序,“似乎”可以得出结论:因为 b2s 的布尔类型参数 b 没有初始化,所以 b 中存储的是一个 01 之外的值[1]。那么问题来了,为什么 b 的这种取值会导致“缓冲区溢出”呢?感兴趣的可以将 b 的类型由 bool 改成 char 或者 int,问题就可以得到修复。

    想要解答这个问题,我们不得不看下 clang++ 为 b2s 生成了怎样的指令(之前我们提到 GCC 下没有出现 crash,所以问题可能和代码生成有关)。在此之前,我们应该了解:

    • 样例程序中,b2s 的返回值是一个临时的 std::string 对象,是保存在栈上的
    • C++ 11 之后,GCC 的 std::string 默认实现使用了 SBO ( Small Buffer Optimization ),其定义大致为 std::string{ char *ptr; size_t size; union{ char buf[16]; size_t capacity}; }。对于长度小于 16 的字符串,不需要额外申请内存。

    OK,那我们现在来看一下 b2s 的反汇编并给出关键注解:

    (gdb) disas b2s
    Dump of assembler code for function b2s[abi:cxx11](bool):
       0x00401200 <+0>:     push   %r14
       0x00401202 <+2>:     push   %rbx
       0x00401203 <+3>:     push   %rax
       0x00401204 <+4>:     mov    %rdi,%r14         # 将返回值(string)的起始地址保存到 r14
       0x00401207 <+7>:     mov    $0x402010,%ecx    # 将 "true" 的起始地址保存至 ecx
       0x0040120c <+12>:    mov    $0x402015,%eax    # 将 "false" 的起始地址保存至 eax
       0x00401211 <+17>:    test   %esi,%esi         # “测试” 参数 b 是否非零
       0x00401213 <+19>:    cmovne %rcx,%rax         # 如果 b 非零,则将 "true" 地址保存至 rax
       0x00401217 <+23>:    lea    0x10(%rdi),%rdi   # 将 string 中的 buf 起始地址保存至 rdi
                                                     # (同时也是后面 memcpy 的第一个参数)
       0x0040121b <+27>:    mov    %rdi,(%r14)       # 将 rdi 保存至 string 的 ptr 字段,即 SBO
       0x0040121e <+30>:    mov    %esi,%ebx         # 将 b 的值保存至 ebx
       0x00401220 <+32>:    xor    $0x5,%rbx         # 将 0x5 异或到 rbx (也即 ebx )
                                                     # 注意,如果 rbx 非 0 即 1,那么 rbx 保存的就是 4 或 5,
                                                     # 即 "true" 或 "false" 的长度 
       0x00401224 <+36>:    mov    %rax,%rsi         # 将字符串起始地址保存至 rsi,即 memcpy 的第二个参数
       0x00401227 <+39>:    mov    %rbx,%rdx         # 将字符串的长度保存至 rdx,即 memcpy 的第三个参数
       0x0040122a <+42>:    callq  <memcpy@plt>      # 调用 memcpy
       0x0040122f <+47>:    mov    %rbx,0x8(%r14)    # 将字符串长度保存到 string::size
       0x00401233 <+51>:    movb   $0x0,0x10(%r14,%rbx,1)  # 将 string 以 '\0' 结尾
       0x00401239 <+57>:    mov    %r14,%rax         # 将 string 地址保存至 rax,即返回值
       0x0040123c <+60>:    add    $0x8,%rsp
       0x00401240 <+64>:    pop    %rbx
       0x00401241 <+65>:    pop    %r14
       0x00401243 <+67>:    retq
    End of assembler dump.
    

    到这里,问题就无比清晰了:

    1. clang++ 假设了 bool 类型的值非 01
    2. 在编译期,”true””false” 长度已知
    3. 使用异或指令( 0x5 ^ false == 5, 0x5 ^ true == 4)计算要拷贝的字符串的长度
    4. bool 类型不符合假设时,长度计算错误
    5. 因为 memcpy 目标地址在栈上(仅对本例而言),因此栈上的缓冲区也可能溢出,从而导致程序跑飞,backtrace 缺失。

    注:

    1. C++ 标准要求 bool 类型至少_能够_表示两个状态: true 和 false ,但并没有规定 sizeof(bool) 的大小。但在几乎所有的编译器实现上, bool 都占用一个寻址单位,即字节。因此,从存储角度,取值范围为 0x00-0xFF,即 256 个状态。

    喜欢这篇文章?来来来,给我们的 GitHub 点个 star 表鼓励啦~~ 🙇‍♂️🙇‍♀️ [手动跪谢]

    交流图数据库技术?交个朋友,Nebula Graph 官方小助手微信:NebulaGraphbot 拉你进交流群~~

    推荐阅读

    12 条回复    2020-12-11 01:45:27 +08:00
    codehz
        1
    codehz  
       2020-12-10 12:59:01 +08:00 via Android   ❤️ 9
    看到 union 就可以结案了...
    标准约定,在没有共同初始化序列的类型上,不可以在 union 的不同 field 上存取数据,也就是你放什么,就只能拿什么,不然就是未定义行为
    (这个规则可以扩展到任何重新解释内存的尝试,比如用指针强制转换也是未定义的,但是用 static_cast 转基础数字类型是合法的)
    codehz
        2
    codehz  
       2020-12-10 13:05:14 +08:00 via Android
    bool 类型即使在实现上不能只用一个 bit,但是标准约定,只有两个合法的状态,其他的状态都是非法的(
    不开优化没炸只是因为 cpu 指令刚好有非零和零的条件跳转,能匹配标准规定的语义,所以多数情况不会出问题
    但不代表你放别的数值就是合法的布尔类型了
    nightwitch
        3
    nightwitch  
       2020-12-10 13:20:56 +08:00
    #1 加一

    如果你仔细阅读 cppreference 的 union 章节,你会找到这样一句。
    It's undefined behavior to read from the member of the union that wasn't most recently written. Many compilers implement, as a non-standard language extension, the ability to read inactive members of a union.

    读如果读 union 最近被写的成员以外的成员是未定义行为,虽然许多编译器提供了非标准的扩展用于读 union 的非活跃成员。

    触发了未定义行为这还能说啥
    cholerae
        4
    cholerae  
       2020-12-10 13:36:03 +08:00
    不优化不炸,优化就炸,ub 无疑了
    fuxiuyin
        5
    fuxiuyin  
       2020-12-10 13:41:51 +08:00
    很好奇这个优化器是怎么写的。如果是 return b? "aaaaaaaa" : "bbbbbbbbbbbb" 他会优化成(0xc ^ (b << 2))?
    e583409
        6
    e583409  
       2020-12-10 14:45:17 +08:00
    哈哈 在 v 站见到你
    ivan_wl
        7
    ivan_wl  
       2020-12-10 16:59:50 +08:00
    @codehz c 标准中也有类似的规定么?
    typetraits
        8
    typetraits  
       2020-12-10 17:03:58 +08:00
    所以推荐使用 std::variant
    codehz
        9
    codehz  
       2020-12-10 17:12:23 +08:00
    @ivan_wl #7 c 可以使用 union 作为类型双关使用,但是 c 里也没严格的 bool 类型啊(标准提供的 stdbool 只是给你一个 bool 的 alias,实际 c 标准没有规定 bool 的行为)
    不过即使允许类型双关,还有 strict alias 规则等着你(
    ivan_wl
        10
    ivan_wl  
       2020-12-10 17:52:13 +08:00
    @codehz c 中用 union 做类型双关不违背 strict aliasing 的吧
    codehz
        11
    codehz  
       2020-12-10 18:32:30 +08:00
    @ivan_wl #10 strict aliasing 是说指针(数组)一类的间接访问的问题,所以只要不涉及指针 /数组类型(或者取地址然后解引用),就是合法的(不过转换结果仍然是未定义的)
    wty
        12
    wty  
       2020-12-11 01:45:27 +08:00 via Android
    如果是写库或者读取文件的话,bool 传入奇怪的值这种有办法解决吗?我经常会写 xxx ? true:false 这种,感觉挺蠢,而且可能还没用的样子。。。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   926 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 26ms · UTC 19:42 · PVG 03:42 · LAX 11:42 · JFK 14:42
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.