代码之家  ›  专栏  ›  技术社区  ›  Matthieu M.

用于比较std::optional of primitive类型的有趣程序集

  •  23
  • Matthieu M.  · 技术社区  · 6 年前

    瓦尔格林抓起一阵慌乱 条件跳转或移动取决于未初始化的值 在我的一个单元测试中。

    在检查程序集时,我发现以下代码:

    bool operator==(MyType const& left, MyType const& right) {
        // ... some code ...
        if (left.getA() != right.getA()) { return false; }
        // ... some code ...
        return true;
    }
    

    在哪里? MyType::getA() const -> std::optional<std::uint8_t> ,生成了以下程序集:

       0x00000000004d9588 <+108>:   xor    eax,eax
       0x00000000004d958a <+110>:   cmp    BYTE PTR [r14+0x1d],0x0
       0x00000000004d958f <+115>:   je     0x4d9597 <... function... +123>
    x  0x00000000004d9591 <+117>:   mov    r15b,BYTE PTR [r14+0x1c]
    x  0x00000000004d9595 <+121>:   mov    al,0x1
    
       0x00000000004d9597 <+123>:   xor    edx,edx
       0x00000000004d9599 <+125>:   cmp    BYTE PTR [r13+0x1d],0x0
       0x00000000004d959e <+130>:   je     0x4d95ae <... function... +146>
    x  0x00000000004d95a0 <+132>:   mov    dil,BYTE PTR [r13+0x1c]
    x  0x00000000004d95a4 <+136>:   mov    dl,0x1
    x  0x00000000004d95a6 <+138>:   mov    BYTE PTR [rsp+0x97],dil
    
       0x00000000004d95ae <+146>:   cmp    al,dl
       0x00000000004d95b0 <+148>:   jne    0x4da547 <... function... +4139>
    
       0x00000000004d95b6 <+154>:   cmp    r15b,BYTE PTR [rsp+0x97]
       0x00000000004d95be <+162>:   je     0x4d95c8 <... function... +172>
    
        => Jump on uninitialized
    
       0x00000000004d95c0 <+164>:   test   al,al
       0x00000000004d95c2 <+166>:   jne    0x4da547 <... function... +4139>
    

    在那里我用 x 在未设置可选项的情况下不执行(跳过)的语句。

    成员 A 这是偏移量 0x1c 进入之内 MyType . 检查的布局 std::optional 我们看到:

    • +0x1d 对应于 bool _M_engaged ,
    • +0x1c 对应于 std::uint8_t _M_payload (在一个匿名联盟内)。

    兴趣代码 标准::可选 是:

    constexpr explicit operator bool() const noexcept
    { return this->_M_is_engaged(); }
    
    // Comparisons between optional values.
    template<typename _Tp, typename _Up>
    constexpr auto operator==(const optional<_Tp>& __lhs, const optional<_Up>& __rhs) -> __optional_relop_t<decltype(declval<_Tp>() == declval<_Up>())>
    {
        return static_cast<bool>(__lhs) == static_cast<bool>(__rhs)
             && (!__lhs || *__lhs == *__rhs);
    }
    

    在这里,我们可以看到gcc对代码进行了实质性的转换;如果我理解正确,在C中,这将给出:

    char rsp[0x148]; // simulate the stack
    
    /* comparisons of prior data members */
    
    /*
    0x00000000004d9588 <+108>:   xor    eax,eax
    0x00000000004d958a <+110>:   cmp    BYTE PTR [r14+0x1d],0x0
    0x00000000004d958f <+115>:   je     0x4d9597 <... function... +123>
    0x00000000004d9591 <+117>:   mov    r15b,BYTE PTR [r14+0x1c]
    0x00000000004d9595 <+121>:   mov    al,0x1
    */
    
    int eax = 0;
    if (__lhs._M_engaged == 0) { goto b123; }
    bool r15b = __lhs._M_payload;
    eax = 1;
    
    b123:
    /*
    0x00000000004d9597 <+123>:   xor    edx,edx
    0x00000000004d9599 <+125>:   cmp    BYTE PTR [r13+0x1d],0x0
    0x00000000004d959e <+130>:   je     0x4d95ae <... function... +146>
    0x00000000004d95a0 <+132>:   mov    dil,BYTE PTR [r13+0x1c]
    0x00000000004d95a4 <+136>:   mov    dl,0x1
    0x00000000004d95a6 <+138>:   mov    BYTE PTR [rsp+0x97],dil
    */
    
    int edx = 0;
    if (__rhs._M_engaged == 0) { goto b146; }
    rdi = __rhs._M_payload;
    edx = 1;
    rsp[0x97] = rdi;
    
    b146:
    /*
    0x00000000004d95ae <+146>:   cmp    al,dl
    0x00000000004d95b0 <+148>:   jne    0x4da547 <... function... +4139>
    */
    
    if (eax != edx) { goto end; } // return false
    
    /*
    0x00000000004d95b6 <+154>:   cmp    r15b,BYTE PTR [rsp+0x97]
    0x00000000004d95be <+162>:   je     0x4d95c8 <... function... +172>
    */
    
    //  Flagged by valgrind
    if (r15b == rsp[097]) { goto b172; } // next data member
    
    /*
    0x00000000004d95c0 <+164>:   test   al,al
    0x00000000004d95c2 <+166>:   jne    0x4da547 <... function... +4139>
    */
    
    if (eax == 1) { goto end; } // return false
    
    b172:
    
    /* comparison of following data members */
    
    end:
        return false;
    

    相当于:

    //  Note how the operands of || are inversed.
    return static_cast<bool>(__lhs) == static_cast<bool>(__rhs)
             && (*__lhs == *__rhs || !__lhs);
    

    认为 总成是正确的,如果奇怪的话。也就是说,正如我所看到的,未初始化值之间的比较结果实际上不影响函数的结果(而不像C或C++,我期望在x86程序集中比较垃圾不是UB):

    1. 如果一个选项是 nullopt 另一个被设置,然后条件跳转 +148 跳到 end ( return false ),好的。
    2. 如果设置了两个选项,则比较读取初始化值,确定。

    所以唯一值得关注的是当两个选项都是 无效选择 :

    • 如果值比较相等,则代码得出结论,选项是相等的,这是正确的,因为它们都是 无效选择 ,
    • 否则,代码得出结论,如果 __lhs._M_engaged 是假的,这是真的。

    因此,无论哪种情况,代码都得出结论,当两个选项都是 无效选择 ;CQFD。


    这是我第一次看到gcc生成明显“良性”的未初始化读取,因此我有几个问题:

    1. 程序集(x84_64)中的未初始化读取是否正常?
    2. 这是优化失败的症候群吗(逆转 || )在非良性情况下,哪种情况会触发?

    现在,我倾向于用 optimize(1) 作为防止优化启动的一项工作。幸运的是,所识别的函数不是性能关键的。


    环境:

    • 编译器:gcc 7.3
    • 编译标志: -std=c++17 -g -Wall -Werror -O3 -flto (+适当的包括)
    • 链接标志: -O3 -flto (+适当的库)

    注意:可以显示为 -O2 而不是 -O3 ,但决不能没有 -flto .


    有趣的事实

    在完整代码中,对于各种有效载荷,此模式在上述函数中出现32次: std::uint8_t , std::uint32_t , std::uint64_t 甚至 struct { std::int64_t; std::int8_t; } .

    它只出现在几个大的 operator== 比较大约40个数据成员的类型,而不是较小的类型。但它并没有出现在 std::optional<std::string_view> 即使在那些特定的函数中 std::char_traits 作为比较)。

    最后,令人恼火的是,将有问题的函数隔离在它自己的二进制文件中会使“问题”消失。神秘的MCVE被证明是难以捉摸的。

    3 回复  |  直到 6 年前
        1
  •  4
  •   Peter Cordes Steve Bohrer    6 年前

    在x86 asm中,最糟糕的情况是单个寄存器有一个未知值(或者您不知道它有两个可能的值中的哪一个,旧的还是新的,以防可能的内存顺序)。但是 if your code doesn't depend on that register value, you're fine 和C++不同的是。C++ UB意味着你的整个程序在理论上完全在一个有符号整数溢出之后被处理,甚至在此之前,编译器可以看到的代码路径将导致UB。在asm中从来没有发生过这样的事情,至少在没有特权的用户空间代码中没有。

    (通过以奇怪的方式设置控制寄存器或将不一致的内容放入页表或描述符,您可以做一些事情来基本上导致内核中的系统范围内的不可预测行为,但这种情况不会发生,即使您正在编译内核代码。)


    有些is a有“不可预测的行为”,比如早期ARM如果对乘法的多个操作数使用相同的寄存器,则行为是不可预测的。如果这允许中断管道和损坏其他寄存器,或者仅限于意外的乘法结果,则返回IDK。我猜是后者。

    或者MIPS,如果将分支放在分支延迟槽中,则行为是不可预测的。(由于分支延迟时隙,处理异常非常混乱…)。但是,假设仍然有限制,您不能使机器崩溃或中断其他进程(在多用户系统(如Unix)中,如果没有特权的用户空间进程可以为其他用户中断任何内容,那将是糟糕的)。

    早期的MIPS也有加载延迟时隙和乘法延迟时隙:不能在下一条指令中使用加载的结果。如果读得太早,可能会得到寄存器的旧值,或者可能只是垃圾。MIPS=Minimally Interlocked Pipeline Stages;他们希望将暂停卸载到软件中,但结果发现,当编译器找不到任何有用的东西来执行下一个臃肿的二进制文件时,添加NOP会导致整体代码变慢,而在必要时硬件暂停。但是我们会遇到分支延迟槽,因为移除它们会改变ISA,不像放松对软件早期没有做的事情的限制。

        2
  •  6
  •   Yakk - Adam Nevraumont    6 年前

    x86整数格式中没有陷阱值,因此读取和比较未初始化的值会生成不可预测的真/假值,并且不会产生其他直接危害。

    在加密上下文中,导致采取不同分支的未初始化值的状态可能会泄漏到定时信息泄漏或其他侧通道攻击中。但是加密强化可能不是你所担心的。

    gcc在不重要的时候进行未初始化的读取,如果读取的值不正确,并不意味着它会在重要的时候进行读取。

        3
  •  0
  •   bartop    6 年前

    我不确定它是由编译器错误引起的。可能代码中有一些UB允许编译器优化更具攻击性的代码。不管怎样,对于这些问题:

    1. UB不是程序集中的问题。在大多数情况下,你所指的地址下面的内容会被读取。当然,大多数操作系统在将内存页交给程序之前都会先填满内存页,但您的变量很可能位于堆栈中,所以很可能它包含垃圾数据。Soo,只要你对随机数据比较满意(这很糟糕,因为可能会产生不同的结果),汇编就有效
    2. 很可能是反向比较综合症