关于llvm:C ++标准是否允许未初始化的bool使程序崩溃?

Does the C++ standard allow for an uninitialized bool to crash a program?

我知道C++中的一个"未定义的行为"几乎可以让编译器做任何它想做的事情。但是,我发生了一次使我吃惊的崩溃,因为我认为代码是足够安全的。

在这种情况下,真正的问题只发生在使用特定编译器的特定平台上,并且仅在启用了优化的情况下发生。

为了重现这个问题并最大限度地简化它,我尝试了几个方法。这里是一个名为Serialize的函数的提取,它将接受一个bool参数,并将字符串truefalse复制到现有的目标缓冲区。

如果bool参数是一个未初始化的值,那么这个函数是否在代码检查中,就无法判断它是否可能崩溃?

1
2
3
4
5
6
7
8
9
10
11
12
13
// 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中的垃圾值无关紧要,因为它无论如何都将被评估为真或假。"

我已经设置了一个编译器资源管理器示例,显示了反汇编中的问题,这里是完整的示例。注意:为了重新解决这个问题,我发现有效的组合是使用clang 5.0.0和-o2优化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#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,如下所示:

1
2
const size_t len = strlen(whichString); // original code
const size_t len = 5 - boolValue;       // clang clever optimization

虽然这是"聪明的",可以说,我的问题是:C++标准是否允许编译器假设BoL只能具有"0"或"1"的内部数字表示,并以这样的方式使用它?

或者,这是一个定义了实现的情况,在这种情况下,实现假定其所有的bools都只包含0或1,而任何其他值都是未定义的行为领域?


是的,ISO C++允许(但不需要)实现做出这种选择。

但是请注意,如果程序遇到UB,则ISO C++允许编译器发出故意崩溃的代码(例如,用非法指令),例如帮助您发现错误。(或者因为它是一个死亡站9000。严格遵守是不足以实现C++实现对任何实际目的有用的。因此,ISO C++将允许编译器使ASM崩溃(因为完全不同的原因),即使在读取未初始化EDCOX1×0的类似代码的情况下。即使这需要是一个没有陷阱表示的固定布局类型。好的。

这是一个关于实际实现如何工作的有趣问题,但是请记住,即使答案不同,您的代码仍然是不安全的,因为现代C++不是汇编语言的便携版本。好的。

您正在为x86-64 System v ABI编译,它指定作为寄存器中函数arg的bool由寄存器1的低位8位中的位模式false=0true=1表示。在内存中,bool是一种1字节类型,它必须具有0或1的整数值。好的。

(ABI是一组实现选项,由同一平台的编译器商定,以便他们可以生成调用彼此函数的代码,包括类型大小、结构布局规则和调用约定。)好的。

ISO C++没有指定它,但是这个ABI决策是广泛的,因为它使布尔-int转换便宜(只是零扩展)。我不知道有任何ABI不允许编译器为任何体系结构(不仅仅是x86)假设bool为0或1。它允许像!myboolxor eax,1这样的优化翻转低位:在单CPU指令中,任何可能在0到1之间翻转位/整数/bool的代码。或将a&&b编译为按位和按bool类型。有些编译器确实在编译器中利用布尔值作为8位。他们的行动效率低下吗?.好的。

一般来说,IAF规则允许编译器利用在编译的目标平台上真实的东西,因为最终结果将是实现与C++源相同的外部可见行为的可执行代码。(带有未定义行为的所有限制,实际上是"外部可见的":不是用调试器,而是来自一个格式良好/合法C++程序中的另一个线程。)好的。

编译器绝对可以充分利用其代码生成中的ABI保证,并使代码像您发现的那样优化strlen(whichString)到江户十一〔11〕。(顺便说一句,这种优化有点聪明,但与分支和内联memcpy相比,它可能是短视的,因为它存储即时数据2。)好的。

或者编译器可以创建一个指针表,并用EDOCX1的整数值(0)对其进行索引,同样假定它是0或1。(这种可能性是@barmar的答案所建议的。)好的。

启用了优化的__attribute((noinline))构造函数导致clang只从堆栈中加载一个字节用作uninitializedBool。它为main中的对象腾出了空间(push raxsub rsp, 8小,而且由于各种原因,效率与sub rsp, 8一样高),所以进入main时的所有垃圾都是用于uninitializedBool的价值。这就是为什么你得到的值不仅仅是0。好的。

5U - random garbage可以很容易地包装成一个大的无符号值,从而导致memcpy进入未映射的内存。目的地在静态存储中,而不是堆栈中,因此您不会覆盖返回地址或其他内容。好的。

其他实现可以做出不同的选择,例如false=0true=any non-zero value。那么,clang可能不会使这个特定的ub实例的代码崩溃。(但如果愿意的话,它仍然是允许的。)我不知道任何其他的选择,比如X86—64对EDCOX1的0度的选择,但是C++标准允许很多人在硬件上不做任何事情,甚至不想在当前的CPU上做任何事情。好的。

当你检查或修改一个EDCOX1(0)项的对象表示时,ISO C++就不知道你会发现什么。(例如,通过memcpybool转换成unsigned char,您可以这样做,因为char*可以对任何事物进行别名。并且EDCOX1的16保证不具有填充位,因此C++标准在没有任何UB的情况下,正式地让您进行HOXDIP对象表示。复制对象表示形式的指针转换与分配char foo = my_bool是不同的,因此不会发生对0或1的布尔化,您将得到原始对象表示形式。)好的。

您已经将这个执行路径上的ub部分"隐藏"在使用noinline的编译器中。即使它没有内联,但是,过程间优化仍然可以生成依赖于另一个函数定义的函数版本。(首先,clang正在生成一个可执行文件,而不是一个可以发生符号插入的Unix共享库。第二,class{}定义中的定义,因此所有翻译单元必须具有相同的定义。就像使用inline关键字。)好的。

因此,编译器可以只发出一个retud2(非法指令)作为main的定义,因为从main顶部开始的执行路径不可避免地遇到未定义的行为。(如果编译器决定通过非内联构造函数遵循路径,则在编译时可以看到。)好的。

任何遇到ub的程序对于其整个存在都是完全不定义的。但是在一个从未实际运行的函数或if()分支中的ub不会破坏程序的其余部分。在实践中,这意味着编译器可以决定为整个基本块发出一条非法指令或一个ret,或者不发出任何内容,并进入下一个块/函数,在编译时可以证明这些基本块包含或导致ub。好的。

实际上,gcc和clang有时确实会在ub上发出ud2,而不是试图为毫无意义的执行路径生成代码。或者,对于从非void函数的末尾脱落的情况,gcc有时会省略ret指令。如果你在想"我的功能将随rax中的垃圾一起返回",那你就大错特错了。现代C++编译器不再把语言当作一种可移植的汇编语言来对待。您的程序必须是有效的C++,而不必假设ASM中函数的独立非内联版本如何看起来。好的。

另一个有趣的例子是,为什么对mmap'ed内存的未对齐访问有时会在amd64上出现segfault?.x86不会在未对齐的整数上出错,对吗?那么,为什么失调的uint16_t*会是一个问题?因为alignof(uint16_t) == 2违反了这一假设,在使用sse2进行自动矢量化时会导致segfault。好的。

另请参阅每个C程序员应该知道的关于未定义行为的内容1/3,一篇由Clang开发人员撰写的文章。好的。关键点:如果编译器在编译时注意到ub,它可能会"中断"(发出令人惊讶的asm)导致ub的代码路径,即使目标是任何位模式都是bool的有效对象表示形式的ABI。

期望程序员对许多错误完全敌视,尤其是现代编译器警告的事情。这就是为什么您应该使用-Wall并修复警告的原因。C++不是一种用户友好的语言,C++中的某些东西即使在编译的目标上是安全的,也可能是不安全的。(例如,在C++中签名溢出是UB,编译器会假设它不会发生,即使编译2的补码x86,除非您使用EDCOX1×9)。好的。

编译时可见的ub总是很危险的,很难确定(通过链接时间优化)您是否真的从编译器中隐藏了ub,从而可以推断出它将生成哪种类型的asm。好的。

不要过于戏剧化;编译器通常会让您摆脱一些事情,并像在UB中一样发出您所期望的代码。但是,如果编译器开发人员实现一些优化以获得有关值范围的更多信息(例如变量为非负,可能允许它在x86-64上将符号扩展优化为自由零扩展),那么将来可能会出现问题。例如,在当前的GCC和CLANG中,执行tmp = a+INT_MIN并不能像往常那样优化a<0,只是tmp总是负数。(因此,它们不会从计算的输入中回溯以获得范围信息,而只是基于没有符号溢出的假设的结果:godbolt上的示例。我不知道这是有意的用户友好还是只是错过了优化。)好的。

还要注意,实现(AKA编译器)允许定义ISO C++留下未定义的行为。例如,支持英特尔内部的所有编译器(如EDCOX1对手动SIMD矢量化3)必须允许形成错误对齐的指针,即使是在不引用它们的情况下,也可以是C++中的UB。__m128i _mm_loadu_si128(const __m128i *)通过采用未对准的__m128i*arg而不是void*char*来进行未对准负载。硬件向量指针和相应类型之间的"reinterpret"转换是否为未定义的行为?好的。

GNU C/C++还定义了左移位负符号数的行为(即使没有EDCOX1〔8〕),也与正常签名的溢出UB规则分开。(这是在ISO C++中的UB,而符号数的右移是实现定义的(逻辑与算术);好的质量实现在算术右移的HW上选择算术,但ISO C++没有指定)。这一点记录在GCC手册的整数部分,同时定义了C标准要求实现以某种方式定义的实现定义行为。好的。

编译器开发者肯定关心的是实现质量问题;他们通常不想让有意恶意的编译器,但是利用C++中的所有UB坑(除了它们选择定义的)来更好地优化,有时几乎是不可区分的。好的。

脚注1:上面的56位可能是垃圾,被调用方必须忽略,对于比寄存器窄的类型,这是正常的。好的。

(其他ABI在这里做了不同的选择。有些函数在传递给mips64和powerpc64等函数或从函数返回时,需要将窄整数类型扩展为零或符号来填充寄存器。请参阅此x86-64答案的最后一节,该部分将与以前的ISA进行比较。)好的。

例如,在调用bool_func(a&1)之前,调用方可能已经计算了RDI中的a & 0x01010101,并将其用于其他用途。调用者可以对&1进行优化,因为它已经将其作为and edi, 0x01010101的一部分对低字节进行了优化,并且它知道被调用者需要忽略高字节。好的。

或者,如果一个bool作为第三个参数传递,可能是一个优化代码大小的调用者用mov dl, [mem]而不是movzx edx, [mem]加载它,以牺牲对RDX旧值的错误依赖(或其他部分寄存器效果,取决于CPU模型)为代价,节省了1个字节。或者对于第一个arg,mov dil, byte [r10]而不是movzx edi, byte [r10],因为两者都需要rex前缀。好的。

这就是为什么clang在Serialize中发射movzx eax, dil而不是sub eax, edi的原因。(对于integer参数,clang违反了ABI规则,而取决于gcc的未记录行为和clang为零-或符号将窄整数扩展到32位。为x86-64 ABI的指针添加32位偏移量时是否需要符号或零扩展?所以我很感兴趣的是,它对bool没有同样的作用。)好的。

脚注2:在分支之后,您只需要一个4字节的mov立即数,或者一个4字节+1字节的存储。长度隐含在存储宽度+偏移中。好的。

另外,glibc memcpy将执行两个4字节的加载/存储,其重叠部分取决于长度,因此这确实会使整个过程不受布尔值上的条件分支的影响。见glibc的memcpy/memmove中的L(between_4_7):块。或者至少,对memcpy分支中的布尔值使用相同的方法来选择块大小。好的。

如果是内联,则可以使用2x mov—immediate+cmov和条件偏移量,也可以将字符串数据保留在内存中。好的。

或者,如果针对Intel Ice Lake进行调优(具有快速的短rep mov功能),实际的rep movsb可能是最佳的。glibc memcpy可能会开始在具有该功能的CPU上使用小尺寸的rep movsb,从而节省了大量分支。好的。用于检测ub和使用未初始化值的工具

在gcc和clang中,可以使用-fsanitize=undefined编译以添加运行时检测,它将在运行时发生的ub上发出警告或出错。不过,这不会捕获统一的变量。(因为它不会增加类型大小来为"未初始化"位腾出空间)。好的。

参见https://developers.redhat.com/blog/2014/10/16/gcc-undefined-behavior-sanitizer-ubsan/好的。

要查找未初始化数据的用法,clang/llvm中有地址消毒剂和内存消毒剂。https://github.com/google/sanitizers/wiki/memorysanitizer显示了clang -fsanitize=memory -fPIE -pie检测未初始化内存读取的示例。如果在没有优化的情况下编译,它可能会工作得最好,因此对变量的所有读取最终都会从ASM中的内存中实际加载。他们显示,在负载无法优化的情况下,它被用于-O2。我自己也没试过。(在某些情况下,例如在求和数组之前不初始化累加器,clang-o3将发出代码,这些代码求和到一个它从未初始化过的向量寄存器中。因此,通过优化,您可以得到一个没有与UB相关联的内存读取的情况。但-fsanitize=memory会更改生成的asm,并可能导致对此进行检查。)好的。

It will tolerate copying of uninitialized memory, and also simple logic and arithmetic operations with it. In general, MemorySanitizer silently tracks the spread of uninitialized data in memory, and reports a warning when a code branch is taken (or not taken) depending on an uninitialized value.

Ok.

MemorySanitizer implements a subset of functionality found in Valgrind (Memcheck tool).

Ok.

对于这种情况,它应该有效,因为从未初始化的内存计算对glibc memcpy的调用(在库内)将导致基于length的分支。如果它内联了一个完全没有分支的版本,只使用了cmov、索引和两个存储,那么它可能不会工作。好的。

Valgrind的memcheck也会寻找这种问题,如果程序只是复制未初始化的数据,那么它也不会抱怨。但它表示,它将检测"条件跳转或移动何时依赖于未初始化的值",以尝试捕获依赖于未初始化数据的任何外部可见行为。好的。

也许不标记负载的背后的想法是,结构可以有填充,并且用宽向量加载/存储复制整个结构(包括填充)不是一个错误,即使单个成员一次只写一个。在asm级别,有关填充内容和值的实际组成部分的信息已丢失。好的。好啊。


编译器可以假定作为参数传递的布尔值是有效的布尔值(即已初始化或转换为truefalse的布尔值)。true值不必与整数1相同——实际上,truefalse可以有各种表示,但参数必须是这两个值之一的有效表示,其中定义了"有效表示"。

因此,如果您未能初始化bool,或者您通过不同类型的指针成功地覆盖了它,那么编译器的假设将是错误的,并且将导致未定义的行为。你受到警告:

50) Using a bool value in ways described by this International Standard as"undefined", such as by examining the value of an uninitialized automatic object, might cause it to behave as if it is neither true nor false. (Footnote to para 6 of §6.9.1, Fundamental Types)


函数本身是正确的,但在您的测试程序中,调用函数的语句使用未初始化变量的值导致未定义的行为。

错误存在于调用函数中,可以通过代码复查或调用函数的静态分析来检测。使用编译器资源管理器链接,GCC8.2编译器会检测到该错误。(也许你可以针对clang提交一份bug报告,说明它没有发现问题)。

未定义的行为意味着任何事情都可能发生,包括程序在触发未定义行为的事件之后崩溃几行。

铌。回答"未定义的行为会导致什么?"总是"是"。这就是未定义行为的定义。


bool只允许保存值01,生成的代码可以假定它只保存这两个值中的一个。为赋值中的三元生成的代码可以将该值用作指向两个字符串的指针数组的索引,也就是说,它可以转换为如下类型:

1
2
3
     // the compile could make asm that"looks" like this, from your source
const static char *strings[] = {"false","true"};
const char *whichString = strings[boolValue];

如果boolValue未初始化,它实际上可以保存任何整数值,这将导致访问超出strings数组的边界。


总结你的问题很多,你是问C++标准允许编译器假设一个EDCOX1?5,它只能有一个内部的数字表示"0"或"1",并用这种方式吗?

该标准没有说明bool的内部表示。它只定义了将bool强制转换为int时发生的情况(反之亦然)。大多数情况下,由于这些积分转换(以及人们非常依赖它们的事实),编译器将使用0和1,但它不必使用(尽管它必须遵守所使用的任何较低级别ABI的约束)。

因此,当编译器看到bool时,它有权认为所述bool包含'true或'false位模式中的任何一种,并做任何它感兴趣的事情。因此,如果truefalse的值分别为1和0,编译器确实可以将strlen优化为5 - 。其他有趣的行为也是可能的!

正如这里反复提到的,未定义的行为有未定义的结果。包括但不限于

  • 您的代码按预期工作
  • 您的代码在随机时间失败
  • 您的代码根本没有运行。

看看每个程序员应该知道什么关于未定义的行为