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

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

我知道 C++ 中的 "undefined behaviour" 几乎可以让编译器做任何它想做的事情。但是,我遇到了让我感到惊讶的崩溃,因为我认为代码足够安全。

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

为了重现问题并最大限度地简化问题,我尝试了几种方法。这是一个名为 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 + 优化执行此代码,它 will/can 会崩溃。

预期的三元运算符 boolValue ? "true" : "false" 对我来说看起来足够安全,我假设 "Whatever garbage value is in boolValue doesn't matter, since it will evaluate to true or false anyhow."

我已经设置了一个 Compiler Explorer example 来显示反汇编中的问题,这里是完整的示例。 注意:为了重现问题,我发现有效的组合是使用 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

虽然这是 "clever",可以这么说,我的问题是:C++ 标准是否允许编译器假定 bool 只能具有“0”或“0”的内部数字表示'1'然后这样用?

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

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

因此,如果您无法初始化一个 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)

bool 只允许保存 truefalse 内部使用的依赖于实现的值,并且生成的代码可以假定它只会保存这两个值之一。

通常,实现将使用整数 0 表示 false,使用 1 表示 true,以简化 bool 和 [=18] 之间的转换=],并使 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 数组的范围。

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

错误在调用函数中,可以通过代码审查或调用函数的静态分析来检测。使用您的编译器资源管理器 link,gcc 8.2 编译器确实检测到该错误。 (也许你可以提交一份针对 clang 的错误报告,它没有发现问题)。

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

注意。 "Can undefined behaviour cause _____ ?" 的答案总是 "Yes"。这实际上是未定义行为的定义。

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

但还要注意,ISO C++ 允许编译器在程序遇到 UB 时发出故意崩溃的代码(例如使用非法指令),例如作为一种帮助您发现错误的方法。 (或者因为它是 DeathStation 9000。严格遵守不足以使 C++ 实现对任何实际用途有用)。 因此 ISO C++ 将允许编译器生成崩溃的 asm(出于完全不同的原因),即使是在读取未初始化的 uint32_t. 的类似代码上也是如此,尽管这需要 fixed-layout 没有陷阱表示的类型。

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


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

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

ISO C++ 没有指定它,但这个 ABI 决定很普遍,因为它使 bool->int 转换便宜(只是 zero-extension)。我不知道有任何 ABI 不允许编译器为任何体系结构(不仅仅是 x86)假设 bool 为 0 或 1。它允许像 !myboolxor eax,1 这样的优化来翻转低位:. Or compiling a&&b to a bitwise AND for bool types. Some compilers do actually take advantage .

一般来说,as-if 规则允许编译器利用在为[编译的目标平台上是真实的东西] =237=],因为最终结果将是执行与 C++ 源代码相同的 externally-visible 行为的 executable 代码。 (由于未定义行为对实际 "externally visible" 施加的所有限制:不是使用调试器,而是来自 well-formed / 合法 C++ 程序中的另一个线程。)

绝对允许编译器在其 code-gen 中充分利用 ABI 保证,并生成将 strlen(whichString) 优化为
的代码 5U - boolValue.
(顺便说一句,这种优化有点聪明,但相对于分支和内联memcpy作为即时数据的存储2可能是短视的.)

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


您的 __attribute((noinline)) 启用优化的构造函数导致 clang 仅从堆栈加载一个字节以用作 uninitializedBool。它用 push raxmain 中的对象创建了 space(它更小并且由于各种原因与 sub rsp, 8 一样有效),所以无论 AL 中的垃圾是什么main 是它用于 uninitializedBool 的值。这就是为什么您实际上得到的值不仅仅是 0.

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


其他实现可以做出不同的选择,例如false=0true=any non-zero value。然后 clang 可能不会为 this 特定的 UB 实例生成崩溃的代码。 (但如果它愿意,它仍然被允许。) 我不知道有任何实现选择 x86-64 为 bool 所做的任何事情,但 C++ 标准允许很多事情没有人甚至不想在像当前 CPUs.

这样的硬件上做

ISO C++ 未指定在检查或修改 bool 的对象表示时您会发现什么。 (例如,通过 memcpybool 转换为 unsigned char,您可以这样做,因为 char* 可以为任何东西起别名。并且 unsigned char 保证没有填充位,因此 C++ 标准确实正式允许您在没有任何 UB 的情况下对对象表示进行 hexdump。Pointer-casting 复制对象表示与分配 char foo = my_bool 不同,当然,因此布尔化为 0 或 1 不会发生,你会得到原始对象表示。)

部分 "hidden" UB 在这个执行路径上来自编译器noinline。但是,即使它不内联,过程间优化仍然可以生成依赖于另一个函数定义的函数版本。 (首先,clang 正在制作一个 executable,而不是一个可以发生 symbol-interposition 的 Unix 共享库。其次,class{} 定义中的定义因此所有翻译单元必须具有相同的定义.喜欢用inline关键字。

所以编译器可以发出 retud2(非法指令)作为 main 的定义,因为执行路径从main 的顶部不可避免地遇到未定义的行为。(如果编译器决定遵循通过 non-inline 构造函数的路径,编译器可以在编译时看到它。)

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

GCC 和 Clang 在实践中 do 实际上有时会在 UB 上发出 ud2,而不是尝试为执行路径生成代码没有意义。 或者对于像从非 void 函数结尾掉落这样的情况,gcc 有时会省略 ret 指令。如果您认为 "my function will just return with whatever garbage is in RAX",那您就大错特错了。 现代 C++ 编译器不再将这种语言视为 portable 汇编语言。您的程序确实必须是有效的 C++,而无需假设您的函数的 stand-alone 非内联版本在 asm 中的外观。

另一个有趣的例子是。 x86 不会对未对齐的整数出错,对吗?那么为什么未对齐的 uint16_t* 会成为问题呢?因为 alignof(uint16_t) == 2,并且在使用 SSE2 auto-vectorizing 时违反该假设会导致段错误。

另请参阅 What Every C Programmer Should Know About Undefined Behavior #1/3,clang 开发人员的文章。

关键点:如果编译器在编译时注意到 UB,它 可以 "break"(发出令人惊讶的 asm)导致 UB 的代码路径针对 ABI,其中任何 bit-pattern 都是 bool.

的有效对象表示

预计程序员对许多错误的完全敌意,尤其是现代编译器警告的事情。这就是为什么您应该使用 -Wall 并修复警告。 C++ 不是 user-friendly 语言,C++ 中的某些内容可能是不安全的,即使它在您正在编译的目标上的 asm 中是安全的。 (例如,有符号溢出在 C++ 中是 UB,编译器会假设它不会发生,即使在编译 2 的补码 x86 时,除非您使用 clang/gcc -fwrapv。)

Compile-time-visible UB 总是危险的,而且很难确定(通过 link-time 优化)你真的从编译器中隐藏了 UB,因此可以推断出什么样的 asm它会生成。

不会over-dramatic;编译器通常会让您摆脱某些事情并发出您期望的代码,即使某些东西是 UB。但是,如果编译器开发人员实施一些优化以获得有关 value-ranges 的更多信息(例如,变量是 non-negative,可能允许它优化 sign-extension 以释放zero-extension 在 x86-64 上)。例如,在当前的 gcc 和 clang 中,执行 tmp = a+INT_MIN 不会将 a<0 优化为 always-false,只是 tmp 始终为负数。 (因为 INT_MIN + a=INT_MAX 在这个 2 的补码目标上是负数,并且 a 不能比那个更高。)

因此 gcc/clang 目前不回溯以推导计算输入的范围信息,仅基于基于无符号溢出假设的结果:example on Godbolt。不知道这是打着user-friendliness的名义故意"missed"优化还是什么

另请注意,允许实现(也称为编译器)定义 ISO C++ 未定义的行为。例如,所有支持 Intel 内在函数的编译器(如 _mm_add_ps(__m128, __m128) 用于手动 SIMD 向量化)必须允许形成 mis-aligned 指针,这在 C++ 中是 UB 即使你 取消引用它们。 __m128i _mm_loadu_si128(const __m128i *) 通过采用未对齐的 __m128i* arg 而不是 void*char* 来执行未对齐的加载。

GNU C/C++ 还定义了 left-shifting 负符号数(即使没有 -fwrapv)的行为,与正常的 signed-overflow UB 规则分开。 (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,以及定义 implementation-defined C 标准要求实现以一种或另一种方式定义的行为。

肯定有quality-of-implementation编译器开发人员关心的问题;他们通常不会 尝试 制造故意敌对的编译器,但利用 C++ 中的所有 UB 坑洞(除了他们选择定义的坑洞)来更好地优化几乎无法区分次。


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

(其他 ABI do 在这里做出不同的选择。有些确实要求窄整数类型为零或 sign-extended在传递给函数或从函数(如 MIPS64 和 PowerPC64)中 returned 时填充寄存器。请参阅 this x86-64 answer which compares vs. those earlier ISAs 的最后一节。)

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

或者如果一个 bool 作为第三个 arg 传递,也许调用者优化 code-size 用 mov dl, [mem] 而不是 movzx edx, [mem] 加载它,节省 1 个字节,代价是对 RDX 旧值的错误依赖(或其他 partial-register 效果,取决于 CPU 模型)。或者对于第一个参数,mov dil, byte [r10] 而不是 movzx edi, byte [r10],因为两者都需要一个 REX 前缀。

这就是为什么 clang 在 Serialize 中发出 movzx eax, dil 而不是 sub eax, edi 的原因。 (对于整数参数,clang 违反了此 ABI 规则,而是取决于 gcc 的未记录行为和 clang 为零或 sign-extend 将整数缩小为 32 位。 所以我很想知道它对 bool 没有做同样的事情。)


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

OTOH,glibc memcpy 将执行两个 4 字节 loads/stores,重叠取决于长度,所以这确实最终使整个事情没有布尔条件分支。请参阅 glibc 的 memcpy/memmove 中的 L(between_4_7): block 。或者至少,对 memcpy 分支中的任一布尔值采用相同的方式 select 块大小。

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

或者如果针对 Intel Ice Lake (with the Fast Short REP MOV feature) 进行调整,实际的 rep movsb 可能是最佳的。 glibc memcpy 可能会开始使用 rep movsb 在具有该功能的 CPU 上使用小尺寸,从而节省大量分支。


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

在 gcc 和 clang 中,您可以使用 -fsanitize=undefined 进行编译以添加 run-time 检测,该检测将在运行时发生的 UB 上发出警告或错误。但是,这不会捕获单元化变量。 (因为它不会增加字体大小来为 "uninitialized" 位腾出空间)。

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,并可能导致对此进行检查。)

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.

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

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

Valgrind's memcheck 也会查找此类问题,如果程序只是简单地复制未初始化的数据,也不会抱怨。但它表示它将检测何时 "Conditional jump or move depends on uninitialised value(s)",以尝试捕获任何依赖于未初始化数据的 externally-visible 行为。

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

总结了很多你的问题,你在问 C++ 标准是否允许编译器假设 bool 只能有一个内部数字表示 '0' 或 '1' 并在这样的情况下使用它方式?

该标准未提及 bool 的内部表示。它仅定义将 bool 转换为 int 时发生的情况(反之亦然)。大多数情况下,由于这些整数转换(以及人们相当依赖它们的事实),编译器将使用 0 和 1,但它不必这样做(尽管它必须遵守它使用的任何较低级别 ABI 的约束).

因此,编译器在看到 bool 时有权认为所述 bool 包含“true”或“false”位模式并做任何感觉的事情。因此,如果 truefalse 的值分别为 1 和 0,则确实允许编译器将 strlen 优化为 5 - <boolean value>。其他有趣的行为也是可能的!

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

  • 您的代码按预期工作
  • 您的代码随机失败
  • 您的代码根本就不是 运行。

What every programmer should know about undefined behavior

Does the C++ standard allow a compiler to assume a bool can only have an internal numerical representation of '0' or '1' and use it in such a way?

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

我曾经花了数周时间在一个大型代码库中追踪一个不明显的错误。有几个方面使其具有挑战性,但根本原因是 class 变量的未初始化布尔成员。

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

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)

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