用于比较std :: optional原始类型的迷人程序集

Mat*_* M. 23 c++ assembly gcc x86-64 c++17

Valgrind选择了一个条件跳转或移动取决于我的一个单元测试中未初始化的值.

检查程序集,我意识到以下代码:

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

在哪里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>
Run Code Online (Sandbox Code Playgroud)

x未设置可选项的情况下,我标记了未执行(跳过)的语句.

会员A这里偏移量0x1cMyType.检查std::optional我们看到的布局:

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

感兴趣的代码std::optional是:

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);
}
Run Code Online (Sandbox Code Playgroud)

在这里,我们可以看到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;
Run Code Online (Sandbox Code Playgroud)

这相当于:

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

认为装配是正确的,如果奇怪的话.也就是说,据我所知,未初始化值之间的比较结果实际上并不影响函数的结果(与C或C++不同,我确实希望比较x86汇编中的垃圾不是UB):

  1. 如果一个是可选的nullopt而另一个是设置的,则+148跳转到end(return false)的条件跳转,OK.
  2. 如果同时设置了两个选项,则比较将读取初始值,OK.

因此唯一感兴趣的案例是两个选项都是nullopt:

  • 如果值比较相等,则代码断定选项是相等的,这是真的,因为它们都是nullopt,
  • 否则,代码断定,如果__lhs._M_engaged为假,则选项是相等的,这是真的.

在任何一种情况下,代码因此得出结论,两个选项都是相等的nullopt; 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证明是难以捉摸的.

Yak*_*ont 6

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

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

当读取给出错误值并不重要时,gcc执行未初始化读取这一事实并不意味着它会在重要时执行.


Pet*_*des 4

在 x86 asm 中,最糟糕的情况是单个寄存器具有未知值(或者在可能的内存排序情况下,您不知道它具有两个可能值中的哪一个,旧值或新值)。但是,如果您的代码不依赖于该寄存器值,那就没问题,这与 C++ 不同。C++ UB 意味着您的整个程序理论上在一次有符号整数溢出之后就完全被清除了,甚至在此之前沿着编译器可以看到的代码路径将导致 UB。在 asm 中不会发生类似的情况,至少在非特权用户空间代码中不会发生。

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


一些 ISA 具有“不可预测的行为”,例如早期的 ARM,如果您对乘法的多个操作数使用相同的寄存器,则行为是不可预测的。IDK 如果这允许破坏管道并损坏其他寄存器,或者它仅限于意外的乘法结果。后者是我的猜测。

或者 MIPS,如果将分支放入分支延迟槽中,则行为是不可预测的。(由于分支延迟槽,处理异常很混乱......)。但想必仍然存在限制,并且您不能使机器崩溃或破坏其他进程(在像 Unix 这样的多用户系统中,如果非特权用户空间进程可能破坏其他用户的任何内容,那将是很糟糕的)。

非常早期的 MIPS 还具有加载延迟槽和乘法延迟槽:您无法在下一条指令中使用加载的结果。如果您太早读取寄存器,您可能会得到寄存器的旧值,或者可能只是垃圾。MIPS = 最小互锁流水线级;他们希望将停顿转移到软件上,但事实证明,当编译器找不到任何有用的东西来执行下一个臃肿的二进制文件时,添加 NOP 会导致整体代码变慢,而不是在必要时让硬件停顿。但是我们被分支延迟槽困住了,因为删除它们会改变 ISA,这与放松对早期软件未做的事情的限制不同。