我知道 C++ 中的 “未定义行为” 几乎可以让编译器做它想做的任何事情。然而,我有一个让我吃惊的崩溃,因为我认为代码足够安全。
在这种情况下,真正的问题只发生在使用特定编译器的特定平台上,并且只有在启用优化的情况下才会发生。
我尝试了几件事以重现问题并最大限度地简化它。这是一个名为 的函数的摘录Serialize,它将采用 bool 参数,并将字符串复制true或复制false到现有的目标缓冲区。
Serialize
true
false
如果 bool 参数是一个未初始化的值,这个函数会不会在代码审查中,实际上没有办法告诉它可能会崩溃?
// Zero-filled global buffer of 16 characters char destBuffer[16]; void Serialize(bool boolValue) { // Determine which string to print based on boolValue const char* whichString = boolValue ? "true" : "false"; // Compute the length of the string we selected const size_t len = strlen(whichString); // Copy string into destination buffer, which is zero-filled (thus already null-terminated) memcpy(destBuffer, whichString, len); }
如果使用 clang 5.0.0 + 优化执行此代码,它将/可能崩溃。
预期的三元运算符boolValue ? "true" : "false"对我来说看起来足够安全,我假设,“无论是什么垃圾值都boolValue无关紧要,因为它无论如何都会评估为真或假。”
boolValue ? "true" : "false"
boolValue
我已经设置了一个Compiler Explorer 示例,它显示了反汇编中的问题,这里是完整的示例。 注意:为了重现该问题,我发现有效的组合是使用带有 -O2 优化的 Clang 5.0.0。
#include <iostream> #include <cstring> // Simple struct, with an empty constructor that doesn't initialize anything struct FStruct { bool uninitializedBool; __attribute__ ((noinline)) // Note: the constructor must be declared noinline to trigger the problem FStruct() {}; }; char destBuffer[16]; // Small utility function that allocates and returns a string "true" or "false" depending on the value of the parameter void Serialize(bool boolValue) { // Determine which string to print depending if 'boolValue' is evaluated as true or false const char* whichString = boolValue ? "true" : "false"; // Compute the length of the string we selected size_t len = strlen(whichString); memcpy(destBuffer, whichString, len); } int main() { // Locally construct an instance of our struct here on the stack. The bool member uninitializedBool is uninitialized. FStruct structInstance; // Output "true" or "false" to stdout Serialize(structInstance.uninitializedBool); return 0; }
问题是由于优化器而出现的:它很聪明地推断出字符串“true”和“false”的长度仅相差 1。因此,它不是真正计算长度,而是使用 bool 本身的值,这 应该 技术上是 0 或 1,如下所示:
const size_t len = strlen(whichString); // original code const size_t len = 5 - boolValue; // clang clever optimization
虽然这很“聪明”,但可以这么说,我的问题是: C++ 标准是否允许编译器假设 bool 只能具有“0”或“1”的内部数字表示并以这种方式使用它?
或者这是实现定义的情况,在这种情况下,实现假设它的所有布尔值将只包含 0 或 1,并且任何其他值都是未定义的行为领域?
但还要注意,如果程序遇到 UB,ISO C 允许编译器发出故意崩溃的代码(例如,使用非法指令),例如,作为帮助您查找错误的一种方式。(或者因为它是一个 DeathStation 9000。严格遵守对于 C 实现对于任何实际目的有用是不够的)。 因此,ISO C++ 将允许编译器生成崩溃的 asm(出于完全不同的原因),即使在读取未初始化uint32_t. 即使这需要是没有陷阱表示的固定布局类型。
uint32_t
这是一个关于实际实现如何工作的有趣问题,但请记住,即使答案不同,您的代码仍然不安全,因为现代 C++ 不是汇编语言的可移植版本。
您正在为x86-64 System V ABI进行编译,它指定 abool作为寄存器中的函数 arg 由位模式false=0和寄存器true=11的低 8 位表示。在内存中,bool是一个 1 字节类型,它又必须有一个整数值 0 或 1。
bool
false=0
true=1
(ABI 是同一平台的编译器同意的一组实现选择,因此它们可以编写调用彼此函数的代码,包括类型大小、结构布局规则和调用约定。)
ISO C++ 没有指定它,但是这个 ABI 决定很普遍,因为它使 bool->int 转换便宜(只是零扩展)。我不知道有任何 ABI 不允许编译器bool为任何架构(不仅仅是 x86)假设 0 或 1。它允许像翻转低位这样的优化!mybool:xor eax,1任何可以在单个 CPU 指令中翻转 0 和 1 之间的位/整数/布尔值的可能代码。或编译a&&b为bool类型的按位与。一些编译器实际上在编译器中利用[了布尔值作为 8 位。对它们的操作效率低吗?.
!mybool
xor eax,1
a&&b
一般而言,as-if 规则允许编译器利用*正在编译的目标平台上*为真的事物,因为最终结果将是实现与 C 源代码相同的外部可见行为的可执行代码。(未定义行为对实际“外部可见”的所有限制:不是使用调试器,而是来自格式良好/合法 C 程序中的另一个线程。)
绝对允许编译器在其代码生成中充分利用 ABI 保证,并制作像您发现的那样优化strlen(whichString)为 5U - boolValue. (顺便说一句,这种优化有点聪明,但与分支和内联memcpy作为即时数据2的存储相比,可能是短视的。)
strlen(whichString)
5U - boolValue
memcpy
或者编译器可以创建一个指针表并用 的整数值对其进行索引bool,再次假设它是 0 或 1。
您__attribute((noinline))启用优化的构造函数导致 clang 仅从堆栈中加载一个字节以用作 as uninitializedBool。它为mainwith中的对象腾出空间push rax(由于各种原因,它更小,并且与 这就是为什么你实际上得到的值不仅仅是.sub rsp, 8``main``uninitializedBool``0
__attribute((noinline))
uninitializedBool
main
push rax
sub rsp, 8``main``uninitializedBool``0
5U - random garbage可以很容易地包装成一个大的无符号值,导致 memcpy 进入未映射的内存。目的地在静态存储中,而不是堆栈中,因此您不会覆盖返回地址或其他东西。
5U - random garbage
其他实现可以做出不同的选择,例如false=0和true=any non-zero value。那么 clang 可能不会为*这个*特定的 UB 实例编写崩溃的代码。(但如果它愿意,它仍然是允许的。) 我不知道有任何实现选择 x86-64 所做的任何其他事情bool,但是 C++ 标准允许许多没有人做甚至不想做的事情类似于当前 CPU 的硬件。
true=any non-zero value
**ISO C 未指定检查或修改bool**. (例如,memcpy通过boolinto unsigned char,您可以这样做,因为char*可以为任何东西加上别名。并且unsigned char保证没有填充位,因此 C 标准确实允许您在没有任何 UB 的情况下使用 hexdump 对象表示。指针转换以复制对象当然,表示与分配不同char foo = my_bool,因此不会发生布尔化为 0 或 1 并且您将获得原始对象表示。)
unsigned char
char*
char foo = my_bool
您*已*使用noinline. 但是,即使它没有内联,过程间优化仍然可以生成依赖于另一个函数定义的函数版本。(首先,clang 正在制作一个可执行文件,而不是一个可以发生符号插入的 Unix 共享库。其次,定义中的class{}定义,因此所有翻译单元必须具有相同的定义。就像inline关键字一样。)
noinline
class{}
inline
所以编译器可以只发出一个retor ud2(非法指令)作为 的定义main,因为从顶部开始的执行路径main不可避免地会遇到未定义的行为。(如果编译器决定遵循通过非内联构造函数的路径,则编译器可以在编译时看到。)
ret
ud2
任何遇到 UB 的程序对于它的整个存在都是完全未定义的。但是在从未实际运行过的函数或分支中的 UBif()不会破坏程序的其余部分。在实践中,这意味着编译器可以决定发出非法指令,或者 a ret,或者不发出任何东西并落入下一个块/函数,因为整个基本块可以在编译时证明包含或导致 UB。
if()
实际上,GCC 和 Clang实际上有时*确实*会在 UB 上发出ud2,而不是尝试为没有意义的执行路径生成代码。 或者对于像掉出非void函数结尾这样的情况,gcc 有时会省略一条ret指令。如果您认为“我的函数将返回 RAX 中的任何垃圾”,那您就大错特错了。 现代 C 编译器不再将这种语言视为可移植的汇编语言。您的程序确实必须是有效的 C,而无需假设您的函数的独立非内联版本在 asm 中的外观。
void
另一个有趣的例子是为什么在 AMD64 上对 mmap 内存的非对齐访问有时会出现段错误?. x86 不会在未对齐的整数上出错,对吧?那么为什么错位uint16_t*会成为问题呢?因为alignof(uint16_t) == 2,并且在使用 SSE2 进行自动矢量化时,违反该假设会导致段错误。
uint16_t*
alignof(uint16_t) == 2
期待程序员对许多错误的完全敌意,尤其是现代编译器警告的事情。这就是您应该使用-Wall和修复警告的原因。C 不是一种用户友好的语言,C 中的某些内容可能是不安全的,即使它在您正在编译的目标上的 asm 中是安全的。(例如,有符号溢出是 C++ 中的 UB,编译器会假设它不会发生,即使在为 2 的补码 x86 编译时,除非你使用clang/gcc -fwrapv.)
-Wall
clang/gcc -fwrapv
编译时可见的 UB 总是很危险的,并且很难确定(通过链接时优化)你真的对编译器隐藏了 UB,因此可以推断出它将生成什么样的 asm。
不要过于戏剧化;通常编译器确实可以让您摆脱某些事情并像您期望的那样发出代码,即使某些东西是 UB 也是如此。但是,如果编译器开发人员实施一些优化以获得更多关于值范围的信息(例如,一个变量是非负的,可能允许它优化符号扩展以释放 x86 上的零扩展),那么将来可能会成为一个问题。 64)。例如,在当前的 gcc 和 clang 中,doingtmp = a+INT_MIN不会优化a<0为始终为假,只是tmp始终为负。(因为INT_MIN+a=INT_MAX在这个 2 的补码目标上是负数,并且a不能高于这个值。)
tmp = a+INT_MIN
a<0
tmp
INT_MIN
a=INT_MAX
a
因此,gcc/clang 目前不回溯以获取计算输入的范围信息,仅基于基于无符号溢出假设的结果:Godbolt 上,l:‘5’,n:‘0’,o:’C%2B%2B+source+%231’,t:‘0’)),k:37.77562439622385,l:‘4’,n:‘0’,o:’‘,s:0,t:‘0’),(g:!((h:compiler,i:(compiler:clang700,filters:(b:‘0’,binary:‘1’,commentOnly:‘0’,demangle:‘0’,directives:‘0’,execute:‘1’,intel:‘0’,libraryCode:‘1’,trim:‘1’),fontScale:1.2899450879999999,lang:c%2B%2B,libs:!(),options:’-xc+-Wall+-Wextra+-O3+-std%3Dgnu11+-march%3Dznver1’,source:1),l:‘5’,n:‘0’,o:’x86-64+clang+7.0.0+(Editor+%231,+Compiler+%231)+C%2B%2B’,t:‘0’)),k:30.92627232139171,l:‘4’,m:100,n:‘0’,o:’‘,s:0,t:‘0’),(g:!((h:compiler,i:(compiler:g82,filters:(b:‘0’,binary:‘1’,commentOnly:‘0’,demangle:‘0’,directives:‘0’,execute:‘1’,intel:‘0’,libraryCode:‘1’,trim:‘1’),fontScale:1.2899450879999999,lang:c%2B%2B,libs:!(),options:’-Wall+-Wextra+-O3+-std%3Dgnu%2B%2B11+-fverbose-asm’,source:1),l:‘5’,n:‘0’,o:’x86-64+gcc+8.2+(Editor+%231,+Compiler+%232)+C%2B%2B’,t:‘0’)),k:31.29810328238445,l:‘4’,n:‘0’,o:’‘,s:0,t:‘0’)),l:‘2’,m:100,n:‘0’,o:’‘,t:‘0’)),version:4)的示例。我不知道这是以用户友好的名义故意“错过”的优化还是什么。
另请注意,**允许实现(又名编译器)定义 ISO C 未定义的行为。例如,所有支持 Intel 内在函数的编译器(例如_mm_add_ps(__m128, __m128)手动 SIMD 矢量化)必须允许形成未对齐的指针,即使您不取消引用它们,这也是 C 中的 UB。 __m128i _mm_loadu_si128(const __m128i *)通过采用未对齐的__m128i*arg 而不是 avoid*或来执行未对齐的负载char*。GNU C/C 还定义了左移负符号数(即使没有-fwrapv)的行为,与正常的有符号溢出 UB 规则分开。这是 ISO C 中的 UB,而有符号数的右移是实现定义的(逻辑与算术);质量好的实现选择具有算术右移的硬件上的算术,但 ISO C++ 没有指定)。这记录在GCC 手册的 Integer 部分,以及定义实现定义的行为,C 标准要求实现定义一种或另一种方式。
_mm_add_ps(__m128, __m128)
__m128i _mm_loadu_si128(const __m128i *)
__m128i*
void*
-fwrapv
编译器开发人员肯定会关心实现质量问题。他们通常不会尝试制造故意敌对的编译器,但利用 C++ 中的所有 UB 坑洼(他们选择定义的坑除外)来更好地优化有时几乎无法区分。
脚注 1:高 56 位可能是被调用者必须忽略的垃圾,通常用于比寄存器窄的类型。
(其他 ABI在这里*确实*做出了不同的选择。有些确实需要窄整数类型在传递给 MIPS64 和 PowerPC64 等函数或从函数返回时进行零或符号扩展以填充寄存器。
例如,调用者a & 0x01010101在调用bool_func(a&1). 调用者可以优化掉 ,&1因为它已经将低字节作为 的一部分and edi, 0x01010101,并且它知道被调用者需要忽略高字节。
a & 0x01010101
bool_func(a&1)
&1
and edi, 0x01010101
或者,如果一个 bool 作为第三个参数传递,则可能为代码大小优化的调用者使用mov dl, [mem]而不是加载它,movzx edx, [mem]以对 RDX 的旧值的错误依赖为代价节省 1 个字节(或其他部分寄存器效果,取决于在 CPU 型号上)。或者对于第一个参数,mov dil, byte [r10]而不是movzx edi, byte [r10], 因为两者都需要 REX 前缀。
mov dl, [mem]
movzx edx, [mem]
mov dil, byte [r10]
movzx edi, byte [r10]
这就是为什么 clang 发出movzx eax, dilin Serialize,而不是sub eax, edi. (对于整数参数,clang 违反了此 ABI 规则,而是根据 gcc 和 clang 的未记录行为将窄整数零或符号扩展为 32 位。 将 32 位偏移量添加到指针时是否需要符号或零扩展x86-64 ABI?所以我很感兴趣地看到它对 .) 没有做同样的事情bool。)
movzx eax, dil
sub eax, edi
脚注 2: 在分支之后,你只有一个 4 字节的mov立即数,或者一个 4 字节 + 1 字节的存储。长度隐含在存储宽度 + 偏移中。
mov
OTOH,glibc memcpy 将执行两个 4 字节的加载/存储,其重叠取决于长度,所以这确实最终使整个事情在布尔值上没有条件分支。请参阅 glibc 的 memcpy/memmove 中的L(between_4_7):块。或者至少,对 memcpy 分支中的任一布尔值采用相同的方式来选择块大小。
L(between_4_7):
如果内联,您可以使用 2x mov-immediate +cmov和条件偏移量,或者您可以将字符串数据留在内存中。
cmov
或者,如果针对 Intel Ice Lake 进行调整(具有 Fast Short REP MOV 功能),实际rep movsb可能是最佳的。glibcmemcpy可能会开始rep movsb 在具有该功能的 CPU 上使用小尺寸,从而节省大量分支。
rep movsb
在 gcc 和 clang 中,您可以编译-fsanitize=undefined以添加运行时检测,该检测将在运行时发生的 UB 上发出警告或错误。不过,这不会捕获单元化的变量。(因为它不会增加类型大小来为“未初始化”位腾出空间)。
-fsanitize=undefined
见https://developers.redhat.com/blog/2014/10/16/gcc-undefined-behavior-sanitizer-ubsan/
要查找未初始化数据的使用情况,clang/LLVM 中有 Address Sanitizer 和 Memory Sanitizer。 https://github.com/google/sanitizers/wiki/MemorySanitizer展示了clang -fsanitize=memory -fPIE -pie检测未初始化内存读取的示例。如果您在*没有*优化的情况下进行编译,它可能会工作得最好,因此所有变量的读取最终实际上都是从 asm 的内存中加载的。他们表明它在-O2负载不会优化的情况下使用。我自己没试过。(在某些情况下,例如在对数组求和之前不初始化累加器,clang -O3 将发出代码,将总和到从未初始化的向量寄存器中。因此,通过优化,您可能会遇到没有与 UB 关联的内存读取的情况。 但-fsanitize=memory更改生成的 asm,并可能导致对此进行检查。)
clang -fsanitize=memory -fPIE -pie
-O2
-fsanitize=memory
它将容忍复制未初始化的内存,以及简单的逻辑和算术运算。一般来说,MemorySanitizer 会默默地跟踪未初始化数据在内存中的传播,并在执行(或不执行)代码分支时根据未初始化的值报告警告。 MemorySanitizer 实现了 Valgrind(Memcheck 工具)中的功能子集。
它将容忍复制未初始化的内存,以及简单的逻辑和算术运算。一般来说,MemorySanitizer 会默默地跟踪未初始化数据在内存中的传播,并在执行(或不执行)代码分支时根据未初始化的值报告警告。
MemorySanitizer 实现了 Valgrind(Memcheck 工具)中的功能子集。
memcpy它应该适用于这种情况,因为使用未初始化内存计算的 glibc 调用length将(在库内部)导致基于length. 如果它内联了一个完全无分支的版本,它只使用了cmov、索引和两个存储,它可能不起作用。
length
Valgrindmemcheck也会寻找这种问题,如果程序只是复制未初始化的数据,同样不会抱怨。但它表示它将检测“条件跳转或移动取决于未初始化的值”的时间,以尝试捕捉任何依赖于未初始化数据的外部可见行为。
memcheck
也许不标记负载背后的想法是结构可以具有填充,并且使用宽向量加载/存储复制整个结构(包括填充)不是错误,即使单个成员一次只写入一个。在 asm 级别,关于填充内容和实际值的一部分的信息已经丢失。