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

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

为了重现这个问题并最大限度地简化它,我尝试了几种方法。下面是一个名为Serialize的函数的摘录,它将接受bool形参,并将字符串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中的垃圾值是什么并不重要,因为它无论如何都会计算为true或false。"

我已经设置了一个编译器资源管理器的例子,显示了在拆卸的问题,这里是完整的例子。注意:为了重现这个问题,我发现使用Clang 5.0.0与-O2优化的组合是有效的。

#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,任何其他值都是未定义的行为领域?


当前回答

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)

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

其他回答

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

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

50)以本标准所描述的“未定义”的方式使用bool值,例如检查一个未初始化的自动对象的值,可能会导致它表现为既非真也非假。(§6.9.1第6段的脚注,基本类型)

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

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

因此,当编译器看到一个bool类型时,它有权认为该bool类型包含'true'或'false'位模式,并做任何它想做的事情。因此,如果true和false的值分别为1和0,编译器确实被允许将strlen优化为5 - <布尔值>。其他有趣的行为也是可能的!

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

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

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

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)

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

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

通常,实现将使用整数0表示false, 1表示true,以简化bool和int之间的转换,并使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是未初始化的,它实际上可以保存任何整数值,这将导致访问字符串数组的边界之外。

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

But also note that ISO C++ allows a compiler to emit code that crashes on purpose (e.g. with an illegal instruction) if the program encounters UB, e.g. as a way to help you find errors. (Or because it's a DeathStation 9000. Being strictly conforming is not sufficient for a C++ implementation to be useful for any real purpose). So ISO C++ would allow a compiler to make asm that crashed (for totally different reasons) even on similar code that read an uninitialized uint32_t. Even though that's required to be a fixed-layout type with no trap representations. (Note that C has different rules from C++; an uninitialized variable has an indeterminate value in C which might be a trap representation, but reading one at all is fully UB in C++. Not sure if there are extra rules for C11 _Bool which could allow the same crash behaviour as C++.)

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


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

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

ISO C++ doesn't specify it, but this ABI decision is widespread because it makes bool->int conversion cheap (just zero-extension). I'm not aware of any ABIs that don't let the compiler assume 0 or 1 for bool, for any architecture (not just x86). It allows optimizations like !mybool with xor eax,1 to flip the low bit: Any possible code that can flip a bit/integer/bool between 0 and 1 in single CPU instruction. Or compiling a&&b to a bitwise AND for bool types. Some compilers do actually take advantage Boolean values as 8 bit in compilers. Are operations on them inefficient?.

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

编译器绝对可以在其代码生成中充分利用ABI保证,并使代码像您发现的那样优化strlen(which string) 5U - boolValue。(顺便说一下,这种优化有点聪明,但与分支和内联memcpyas即时数据存储相比,这可能是短视的2)。

或者编译器可以创建一个指针表,并使用bool的整数值为其建立索引,同样假设它是0或1。(这种可能性正是@Barmar的回答所暗示的。)


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

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


其他实现可以做出不同的选择,例如false=0和true=任何非零值。那么clang可能不会为这个特定的UB实例生成崩溃的代码。(但如果它想这么做,还是可以这么做的。)除了x86-64对bool所做的,我不知道还有其他实现,但c++标准允许在像当前cpu这样的硬件上做许多没有人做或甚至不想做的事情。

ISO C++ leaves it unspecified what you'll find when you examine or modify the object representation of a bool. (e.g. by memcpying the bool into unsigned char, which you're allowed to do because char* can alias anything. And unsigned char is guaranteed to have no padding bits, so the C++ standard does formally let you hexdump object representations without any UB. Pointer-casting to copy the object representation is different from assigning char foo = my_bool, of course, so booleanization to 0 or 1 wouldn't happen and you'd get the raw object representation.)

你已经用noinline对编译器部分“隐藏”了这个执行路径上的UB。即使它不是内联的,但是,过程间优化仍然可以生成依赖于另一个函数定义的函数版本。(首先,clang正在创建一个可执行文件,而不是一个可以发生符号插入的Unix共享库。其次,定义在类{}定义内部,因此所有翻译单元必须具有相同的定义。比如内联关键字。)

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

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

GCC and Clang in practice do actually sometimes emit ud2 on UB, instead of even trying to generate code for paths of execution that make no sense. Or for cases like falling off the end of a non-void function, gcc will sometimes omit a ret instruction. If you were thinking that "my function will just return with whatever garbage is in RAX", you are sorely mistaken. Modern C++ compilers don't treat the language like a portable assembly language any more. Your program really has to be valid C++, without making assumptions about how a stand-alone non inlined version of your function might look in asm.

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

参见clang开发人员的文章《每个C程序员都应该知道的未定义行为#1/3》。

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

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

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

Not to be over-dramatic; often compilers do let you get away with some things and emit code like you're expecting even when something is UB. But maybe it will be a problem in the future if compiler devs implement some optimization that gains more info about value-ranges (e.g. that a variable is non-negative, maybe allowing it to optimize sign-extension to free zero-extension on x86-64). For example, in current gcc and clang, doing tmp = a+INT_MIN doesn't optimize a<0 as always-false, only that tmp is always negative. (Because INT_MIN + a=INT_MAX is negative on this 2's complement target, and a can't be any higher than that.)

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

Also note that implementations (aka compilers) are allowed to define behaviour that ISO C++ leaves undefined. For example, all compilers that support Intel's intrinsics (like _mm_add_ps(__m128, __m128) for manual SIMD vectorization) must allow forming mis-aligned pointers, which is UB in C++ even if you don't dereference them. __m128i _mm_loadu_si128(const __m128i *) does unaligned loads by taking a misaligned __m128i* arg, not a void* or char*. Is `reinterpret_cast`ing between hardware SIMD vector pointer and the corresponding type an undefined behavior?

GNU C/C++ also defines the behaviour of left-shifting a negative signed number (even without -fwrapv), separately from the normal signed-overflow UB rules. (This is UB in ISO C++, while right shifts of signed numbers are implementation-defined (logical vs. arithmetic); good quality implementations choose arithmetic on HW that has arithmetic right shifts, but ISO C++ doesn't specify). This is documented in the GCC manual's Integer section, along with defining implementation-defined behaviour that C standards require implementations to define one way or another.

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


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

(其他abi在这里确实做出了不同的选择。有些确实要求窄整数类型在传递给函数或从函数返回时为零或符号扩展以填充寄存器,如MIPS64和PowerPC64。请参阅此x86-64回答的最后一部分,其中比较了与早期isa的比较。)

例如,调用方可能在RDI中计算了& 0x01010101,并在调用bool_func(a&1)之前将其用于其他事情。调用方可以优化掉&1,因为它已经将低字节作为和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不做同样的事情。)


脚注2:在分支之后,您将只有一个4字节的立即移动存储,或者4字节+ 1字节的存储。长度隐含在存储宽度+偏移量中。

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

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

或者如果调优英特尔冰湖(与快速短REP MOV功能),一个实际的代表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/

To find usage of uninitialized data, there's Address Sanitizer and Memory Sanitizer in clang/LLVM. https://github.com/google/sanitizers/wiki/MemorySanitizer shows examples of clang -fsanitize=memory -fPIE -pie detecting uninitialized memory reads. It might work best if you compile without optimization, so all reads of variables end up actually loading from memory in the asm. They show it being used at -O2 in a case where the load wouldn't optimize away. I haven't tried it myself. (In some cases, e.g. not initializing an accumulator before summing an array, clang -O3 will emit code that sums into a vector register that it never initialized. So with optimization, you can have a case where there's no memory read associated with the UB. But -fsanitize=memory changes the generated asm, and might result in a check for this.)

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

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

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

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

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