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
在0x1c
处偏移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):>
nullopt
,而另一个已设置,则在+148
处的条件跳转将跳转到end
(return false
),确定。所以唯一感兴趣的情况是两个可选选项均为nullopt
时:
nullopt
,__lhs._M_engaged
为false,则可选选项是相等的。无论哪种情况,代码都得出结论,当两个都是nullopt
时,两个可选参数是相等的; CQFD。
这是我第一次看到gcc产生明显“良性”的未初始化读取,因此我有几个问题:
||
),可能在非良性情况下触发吗? 就目前而言,我倾向于使用optimize(1)
来注释一些函数,以作为防止优化开始的一种变通方法。幸运的是,已识别的函数对性能并不重要。
环境:
-std=c++17 -g -Wall -Werror -O3 -flto
(+包括在内)-O3 -flto
(+相应的库)注意:可以显示为-O2
而不是-O3
,但永远不会显示-flto
。
有趣的事实
在完整代码中,此模式在上述函数中针对各种有效载荷:std::uint8_t
,std::uint32_t
,std::uint64_t
甚至是{{1} }。
它仅出现在几个比较大的struct { std::int64_t; std::int8_t; }
类型中,具有约40个数据成员,而不是较小的类型。对于operator==
,即使在那些特定的功能(调用std::optional<std::string_view>
进行比较)中也不会出现。
最后,令人生厌的是,将所讨论的函数隔离在其自己的二进制文件中,使“问题”消失。神话般的MCVE难以捉摸。
答案 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,它使编译器可以更积极地优化您的代码。无论如何,对问题: