c++标准是否允许未初始化的bool使程序崩溃?

我知道c++中的“未定义行为”可以让编译器做任何它想做的事情。然而,当我以为代码足够安全时,我却遇到了意外的崩溃。

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

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

如果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中的垃圾值是什么都无关紧要,因为它无论如何都会计算为真或假。”

我已经设置了一个编译器资源管理器示例,显示了拆卸中的问题,这里是完整的例子。# EYZ1

#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’,并以这样的方式使用它?

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

38521 次浏览

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

因此,如果你未能初始化bool,或者如果你成功地通过一些不同类型的指针覆盖了它,那么编译器的假设将是错误的,未定义行为将随之而来。你已经被警告过了:

50)以本标准所描述的“未定义”的方式使用bool值,例如检查一个未初始化的自动对象的值,可能会导致它表现为既非真也非假。# EYZ0

bool只允许保存内部用于truefalse的依赖于实现的值,生成的代码可以假设它只保存这两个值中的一个。

通常,实现将为false使用整数0,为true使用整数1,以简化boolint之间的转换,并使if (boolvar)生成与if (intvar)相同的代码。在这种情况下,可以想象在赋值中为三元生成的代码将使用该值作为指向两个字符串的指针数组的索引,即它可能被转换为如下内容:

// 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数组的范围。

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

错误存在于调用函数中,可以通过代码审查或调用函数的静态分析检测到。使用编译器资源管理器链接,gcc 8.2编译器可以检测到这个错误。(也许您可以针对clang提交错误报告,说明它没有发现问题)。

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

NB。“未定义的行为会导致_____吗?”的答案总是“是的”。这就是未定义行为的定义。

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

但也要注意,ISO c++允许编译器在程序遇到UB时发出故意崩溃的代码(例如使用非法指令),例如,作为一种帮助你发现错误的方式。(或者因为它是死亡站9000。对c++实现来说,严格的一致性是不够的,它对任何真正的目的都是有用的)。尽管这需要是一个没有陷阱表示的固定布局类型。(注意C有不同于c++的规则;一个未初始化的变量C中有不确定,它可能是一个陷阱表示,但读取一个是完全在c++的UB。不确定C11 _Bool是否有额外的规则,可以允许与c++相同的崩溃行为。)

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


在寄存器的低8位您正在编译x86-64 System V ABI,它指定#EYZ0作为寄存器中的函数参数由位模式#EYZ1和true=1表示。在内存中,bool是一个1字节类型,同样必须具有0或1的整数值。

(ABI是一组实现选择,同一平台上的编译器同意这样它们就可以编写调用彼此函数的代码,包括类型大小、结构布局规则和调用约定。)

# EYZ5。我不知道任何不让编译器为bool假设0或1的ABIs,对于任何架构(不仅仅是x86)。它允许像!myboolxor eax,1这样的优化来翻转低位:可以在单个CPU指令中将位/整数/bool值在0到1之间翻转的任何可能的代码。或者将a&&b编译为bool类型的逐位与。一些编译器确实利用了布尔值在编译器中为8位。对它们的操作效率低吗?

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

编译器绝对可以在其代码生成中充分利用ABI保证,并像您发现的那样优化strlen(whichString)
的代码 # EYZ0。(顺便说一句,这个优化是聪明的,但可能是短视的vs.分支和内联memcpyas存储即时数据2.)

或者编译器可以创建一个指针表,并使用bool的整数值对其进行索引,同样假设它是0或1。(# EYZ1)。


你的__attribute((noinline))构造函数启用了优化,只会从堆栈中加载一个字节作为uninitializedBool。它用push rax(它更小,由于各种原因与sub rsp, 8一样高效)为main中的对象腾出空间,因此在进入main时AL中的任何垃圾都是它用于uninitializedBool的值。这就是为什么你得到的值不仅仅是0

5U - random garbage可以很容易地包装成一个大的无符号值,导致memcpy进入未映射的内存。目的地在静态存储中,而不是堆栈中,所以你不会重写返回地址之类的。


我不知道还有什么实现选择了x86-64为bool所做的,但是c++标准允许许多没有人做的事情,甚至没有人想在像当前cpu这样的硬件上做。

# EYZ7。(例如,通过memcpying boolunsigned char,你可以这样做,因为char*可以别名任何东西。unsigned char被保证没有填充位,所以c++标准正式允许你在没有任何UB的情况下对对象表示进行十六进制转储。当然,复制对象表示的指针转换不同于赋值char foo = my_bool,所以布尔化不会发生0或1,你会得到原始对象表示。)

# EYZ3。即使它不是内联的,但是,过程间优化仍然可以生成依赖于另一个函数定义的函数版本。(首先,clang正在创建一个可执行文件,而不是一个可以发生符号插入的Unix共享库。其次,该定义在class{}定义中,因此所有翻译单元必须具有相同的定义。比如inline关键字。)

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

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

或者对于像从非void函数的末尾删除这样的情况,gcc有时会省略ret指令。如果您认为“我的函数只会返回rax中的任何垃圾”,那么您就大错特错了。# EYZ4

另一个有趣的例子是为什么对mmap'ed内存的未对齐访问有时在AMD64上发生段故障?。X86不会对未对齐的整数出错,对吗?那么为什么不对齐的uint16_t*会是一个问题呢?因为在使用SSE2进行自动向量化时,alignof(uint16_t) == 2和违反该假设会导致段错误。

另请参阅 每一个C程序员应该知道的关于未定义行为#1/3, clang开发者的一篇文章。

重点:如果编译器在编译时注意到UB,它可以 "break"(发出令人惊讶的asm)通过代码导致UB的路径,即使目标是ABI,其中任何位模式都是bool的有效对象表示。

对程序员所犯的许多错误,尤其是现代编译器所警告的错误,要有完全的敌意。这就是为什么你应该使用-Wall和修复警告。c++不是一种用户友好的语言,c++中的某些东西可能是不安全的,即使它在你正在编译的目标的asm中是安全的。(例如,signed overflow在c++中是UB,编译器会假设它不会发生,即使在编译2的补体x86时,除非你使用clang/gcc -fwrapv。)

编译时可见的UB总是很危险的,而且真的很难确定(使用链接时间优化)你真的对编译器隐藏了UB,从而可以推断它将生成什么样的asm。

不要太夸张;通常编译器确实会让你摆脱一些事情,并像你期望的那样发出代码,即使某些东西是UB。但是,如果将来编译器开发人员实现了一些优化,获得了更多关于值范围的信息(例如,一个变量是非负的,可能允许它在x86-64上优化符号扩展为释放零扩展),这可能会成为一个问题。例如,在当前的gcc和clang中,执行tmp = a+INT_MIN并不会将a<0优化为始终为false,只有tmp始终为负。(因为INT_MIN + a=INT_MAX在这个2的补码目标上是负的,而a不能比它更高。)

因此gcc/clang目前不会回溯到计算输入的范围信息,只会基于没有带符号溢出的假设得出结果:Godbolt实例。我不知道这是不是优化是故意“错过”的;以用户友好之类的名义。

还要注意实现(又名编译器)被允许定义ISO c++未定义的行为。例如,所有支持Intel intrinsic的编译器(如用于手动SIMD向量化的_mm_add_ps(__m128, __m128))必须允许形成错误对齐的指针,这在c++中是UB,即使您使用来解引用它们。__m128i _mm_loadu_si128(const __m128i *)通过使用未对齐的__m128i*参数来执行未对齐的加载,而不是void*char*。# EYZ7

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

编译器开发人员肯定关心实现质量问题;它们通常不是尝试来制作有意敌对的编译器,但是利用c++中所有的UB漏洞(除了他们选择定义的那些)来更好地优化有时几乎是难以区分的。


脚注1:上面的56位可以是被调用者必须忽略的垃圾,就像通常比寄存器窄的类型一样。

(# EYZ0。有些确实要求窄整数类型在传递给函数或从函数返回时为零或符号扩展以填充寄存器,如MIPS64和PowerPC64。请参阅这个x86-64的答案与早期的isa进行了比较的最后一部分。)

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

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

这就是为什么clang在Serialize中释放movzx eax, dil,而不是sub eax, edi。(对于整数参数,clang违反了ABI规则,而是依赖于gcc和clang未记录的行为,将0或符号扩展为32位的窄整数。当为x86-64 ABI的指针添加32位偏移时,是否需要一个符号或零扩展名? 所以我有兴趣看到它不做同样的事情bool.)


分支之后,你只会有一个4字节的mov-immediate,或者4字节+ 1字节的存储。长度隐含在存储宽度+偏移量中。

OTOH, glibc memcpy将执行两个4字节的加载/存储,并根据长度进行重叠,因此这确实最终使整个事情在布尔上没有条件分支。参见glibc的memcpy/memmove中的# EYZ0块。或者至少,对memcpy分支中的任意一个布尔值都采用相同的方法来选择块大小。

如果内联,你可以使用2x mov-immediate + cmov和一个条件偏移量,或者你可以把字符串数据留在内存中。

或者如果调优Intel Ice Lake (具有快速短REP MOV功能),实际的rep movsb可能是最优的。glibc memcpy可能会在具有该特性的cpu上开始使用rep movsb,以节省大量分支。


用于检测UB和未初始化值的使用的工具

在gcc和clang中,您可以使用-fsanitize=undefined进行编译,以添加运行时检测,它将在运行时发生的UB上发出警告或错误。不过,这不会捕获单元化变量。(因为它没有增加类型大小来为“未初始化的”腾出空间;位)。

看到# EYZ0

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

它将允许复制未初始化的内存,以及简单的逻辑和算术操作。通常,MemorySanitizer会无声地跟踪内存中未初始化数据的传播,并根据未初始化值在代码分支被获取(或未获取)时报告警告。

MemorySanitizer实现了Valgrind (Memcheck工具)中的一个功能子集。

它应该适用于这种情况,因为使用从未初始化的内存中计算的length来调用glibc memcpy将(在库内部)产生一个基于length的分支。如果它内联了一个完全无分支的版本,只使用cmov、索引和两个存储,那么它可能无法工作。

Valgrind的# EYZ0也将查找这类问题,如果程序只是复制未初始化的数据,同样不会抱怨。但它表示,当“条件跳转或移动依赖于未初始化的值”时,它将检测到,以尝试捕捉任何依赖于未初始化数据的外部可见行为。

也许不标记只是一个load的想法是,结构可以有填充,并且复制整个结构(包括填充)与一个宽矢量load/store即使单个成员一次只写入一个也不会出错。在asm级别,关于什么是填充以及什么是实际值的一部分的信息已经丢失。

总结一下你的问题,你在问c++标准是否允许编译器假设bool只能有“0”或“1”的内部数字表示,并以这样的方式使用它?

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

所以,编译器,当它看到一个bool是有权考虑说,bool包含'true'或'false'位模式,并做任何它觉得。因此,如果truefalse的值分别为1和0,编译器确实被允许优化strlen5 - <boolean value>。其他有趣的行为也是可能的!

正如这里反复强调的,未定义的行为会产生未定义的结果。包括但不限于

  • 您的代码按照您的预期工作
  • 你的代码会随机失败
  • 你的代码根本没有运行。

看到# EYZ0

c++标准是否允许编译器假设bool类型只能有一个内部数字表示‘0’或‘1’,并以这样的方式使用它?

是的,如果它对任何人都有用,这里有另一个现实世界的例子。

我曾经花了几周时间在一个大型代码库中追踪一个模糊的bug。有几个方面使它具有挑战性,但根本原因是类变量的一个未初始化的布尔成员。

有一个包含这个成员变量的复杂表达式的测试:

if(COMPLICATED_EXPRESSION_INVOLVING(class->member)) {
...
}

我开始怀疑这个测试没有评估“真实”。当它应该。我不记得在调试器下运行是否不方便,或者我不相信调试器,或者其他什么,但我选择了用一些调试打印输出来增强代码的蛮力技术:

printf("%s\n", COMPLICATED_EXPRESSION_INVOLVING(class->member) ? "yes" : "no");


if(COMPLICATED_EXPRESSION_INVOLVING(class->member)) {
printf("doing the thing\n");
...
}

想象一下,当代码打印出“no"后面跟着"doing the thing"。

检查程序集代码可以发现,有时编译器(gcc)通过将其与0进行比较来测试布尔成员,但其他时候,它使用测试最小有效位指令。当事情失败时,未初始化的布尔变量碰巧包含值2。所以,在机器语言中,这个测试相当于

if(class->member != 0)

成功了,但考验等同于

if(class->member % 2 != 0)

失败了。布尔变量实际上同时是真和假!如果这不是未定义的行为,我不知道什么是!