有趣的程序集,用于比较std :: primitive类型的可选

时间:2018-07-31 14:46:26

标签: c++ gcc assembly 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;
}

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标记的语句未执行(跳过)。

成员A0x1c处偏移MyType。检查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);
}

在这里,我们可以看到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处的条件跳转将跳转到endreturn false),确定。
  2. 如果同时设置了两个可选项,那么比较将读取初始化值,确定。

所以唯一感兴趣的情况是两个可选选项均为nullopt时:

  • 如果两个值比较相等,则代码会得出结论,这些可选参数是相等的,这是正确的,因为它们都是nullopt
  • 否则,代码会得出结论,如果__lhs._M_engaged为false,则可选选项是相等的。

无论哪种情况,代码都得出结论,当两个都是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


有趣的事实

在完整代码中,此模式在上述函数中针对各种有效载荷:std::uint8_tstd::uint32_tstd::uint64_t甚至是{{1} }。

它仅出现在几个比较大的struct { std::int64_t; std::int8_t; }类型中,具有约40个数据成员,而不是较小的类型。对于operator==,即使在那些特定的功能(调用std::optional<std::string_view>进行比较)中也不会出现。

最后,令人生厌的是,将所讨论的函数隔离在其自己的二进制文件中,使“问题”消失。神话般的MCVE难以捉摸。

3 个答案:

答案 0 :(得分:6)

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

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

gcc进行未初始化的事实并不重要,即读取的值是否错误并不意味着它会在重要的时候执行。

答案 1 :(得分:4)

在x86 asm中,发生的最坏情况是单个寄存器的值未知(或者,如果可能的内存排序,您可能不知道它具有两个旧值还是旧值)。但是if your code doesn't depend on that register value, you're fine与C ++不同。 C ++ UB意味着您的整个程序从理论上说在一个有符号整数溢出之后就完全崩溃了,甚至在此之前,编译器可以看到的代码路径都将导致UB。在asm中不会发生这种情况,至少在没有特权的用户空间代码中不会发生这种情况。

(您可能需要做一些事情,通过以怪异的方式设置控制寄存器或将不一致的内容放入页表或描述符中,从而在内核中基本上引起系统范围内的不可预测的行为,但这不会发生。即使您正在编译内核代码也是如此。)


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

或者是MIPS,如果将分支放在分支延迟插槽中,则行为是不可预测的。 (由于分支延迟插槽,处理异常情况很麻烦……)。但是大概仍然存在限制,您不能使计算机崩溃或破坏其他进程(在Unix之类的多用户系统中,如果特权用户空间的进程可能破坏其他用户的权限,那就太糟糕了。)

非常早的MIPS还具有加载延迟插槽,并增加了延迟插槽:您无法在下一条指令中使用加载结果。如果您过早地读取寄存器,则可能会得到寄存器的旧值,或者仅仅是垃圾。 MIPS =最小互锁管线阶段;他们想将停顿拖到软件上,但是事实证明,当编译器找不到对下一步膨胀的二进制文件有用的东西时,添加NOP会导致整体代码变慢,而在必要时使硬件停顿。但是,我们滞留在分支延迟插槽上,因为删除它们会改变ISA,这与放宽对早期软件无法做到的限制不同。

答案 2 :(得分:0)

我不太确定这是由编译器错误引起的。您的代码中可能有一些UB,它使编译器可以更积极地优化您的代码。无论如何,对问题:

  1. UB在组装中不是问题。在大多数情况下,将读取您所指地址下的内容。当然,大多数操作系统在将其分配给程序之前先填充内存页面,但是您的变量很可能位于堆栈中,因此很可能包含垃圾数据。好的,只要您可以进行随机数据比较(这很糟糕,可能会错误地给出不同的结果),则组装有效
  2. 最有可能是反向比较综合征