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
标记的地方在未设置可选的情况下不执行(跳过)的语句。
成员(member)A
这里是偏移量 0x1c
进入 MyType
.检查 std::optional
的布局我们看到:
-
+0x1d
对应于bool _M_engaged
, -
+0x1c
对应于std::uint8_t _M_payload
(在匿名 union 内)。
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
),好的。 - 如果两个选项都设置了,那么比较读取初始化值,OK。
所以唯一感兴趣的情况是两个选项都是 nullopt
:
- 如果值比较相等,则代码得出结论,可选值相等,这是真的,因为它们都是
nullopt
, - 否则,如果
__lhs._M_engaged
,则代码得出的结论是可选值相等。是假的,这是真的。
在任何一种情况下,代码都会得出结论,当两个选项都是 nullopt
时,两个选项是相等的。 ; CQFD。
这是我看到 gcc 生成明显“良性”未初始化读取的第一个实例,因此我有几个问题:
- 在汇编 (x84_64) 中未初始化的读取是否正常?
- 这是在非良性情况下可能触发的优化失败综合症(反转
||
)吗?
目前,我倾向于使用 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 被证明是难以捉摸的。
最佳答案
x86 整数格式中没有陷阱值,因此读取和比较未初始化的值会产生不可预测的真/假值,并且没有其他直接危害。
在加密上下文中,导致采用不同分支的未初始化值的状态可能会泄漏到时序信息泄漏或其他边信道攻击中。但加密加固可能不是您所担心的。
gcc 在读取是否给出错误值无关紧要时执行未初始化读取这一事实并不意味着它会在重要时执行。
关于c++ - 用于比较原始类型的 std::optional 的有趣程序集,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/51616179/