c++ - 用于比较原始类型的 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
偏移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
),OK。 - 如果两个选项都设置了,那么比较读取初始化值,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 在读取是否给出错误值无关紧要时执行未初始化读取这一事实并不意味着它会在重要时执行此操作。
推荐阅读
- java - 无法安装链码
- java - 完全序列化适用于任何 Java 对象的对象图
- mysql - 在 MySQL 中使用 CHECK 和 LIKE?
- vue.js - 如何更改 nuxt.js 中的 vue-intro.js css
- web-crawler - 微服务可以根据资源使用情况进行拆分吗?
- java - 以编程方式设置约束布局的比例 x 和 y 以适应任何设备中的屏幕
- app-store - 自制的 Android 应用程序不适合所有人,仅供公司使用
- sql - 使用 .net 从 MS Access 数据库中删除不存在的记录
- nextcloud - 无法上网 Nextcloud Connection Assistant
- python - Asyncio 服务器 - 如何通过访问 recipent 客户端的 writer 对象在两个客户端之间正确传达消息?