首页 > 解决方案 > 用于比较原始类型的 std::optional 的有趣程序集

问题描述

在我的一个单元测试中,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未执行(跳过)的语句。

此处的成员A偏移0x1cMyType. 检查布局std::optional我们看到:

感兴趣的代码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),OK。
  2. 如果两个选项都设置了,那么比较读取初始化值,OK。

所以唯一感兴趣的情况是两个选项都是nullopt

在任何一种情况下,代码因此得出结论,当两者都是nullopt;时,两个选项都相等。CQFD。


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

  1. 汇编(x84_64)中未初始化的读取是否正常?
  2. 这是||在非良性情况下可能触发的优化失败(反转)的综合症吗?

目前,我倾向于使用注释几个函数optimize(1)作为一种解决方法,以防止优化启动。幸运的是,已识别的函数对性能不是至关重要的。


环境:

注意:可以出现-O2而不是-O3,但不能没有-flto


有趣的事实

在完整的代码中,这种模式在上述函数中出现了 32 次,用于各种有效负载 : std::uint8_tstd::uint32_tstd::uint64_t甚至是struct { std::int64_t; std::int8_t; }.

它只出现在一些operator==具有约 40 个数据成员的大型比较类型中,而不出现在较小的比较类型中。std::optional<std::string_view>即使在那些特定的函数中(需要std::char_traits进行比较),它也不会出现。

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

标签: c++gccassemblyx86-64c++17

解决方案


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

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

gcc 在读取是否给出错误值无关紧要时执行未初始化读取这一事实并不意味着它会在重要时执行此操作。


推荐阅读