543

我知道 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无关紧要,因为它无论如何都会评估为真或假。”

我已经设置了一个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,并且任何其他值都是未定义的行为领域?

4

6 回答 6

308

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

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

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


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

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

ISO C++ 没有指定它,但是这个 ABI 决定很普遍,因为它使 bool->int 转换变得便宜(只是零扩展)。我不知道有任何 ABI 不允许编译器bool为任何架构(不仅仅是 x86)假设 0 或 1。它允许像翻转低位这样的优化!myboolxor eax,1任何可以在单个 CPU 指令中翻转 0 和 1 之间的位/整数/布尔值的可能代码。或编译a&&bbool类型的按位与。一些编译器实际上在编译器中利用了布尔值作为 8 位。对它们的操作效率低吗?.

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

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

或者编译器可以创建一个指针表并用 的整数值对其进行索引bool,再次假设它是 0 或 1。(这种可能性是 @Barmar 的回答所建议的。)


__attribute((noinline))启用优化的构造函数导致 clang 仅从堆栈中加载一个字节以用作 as uninitializedBool。它为mainwith中的对象腾出空间push rax(由于各种原因,它更小,并且与 这就是为什么你实际上得到的值不仅仅是.sub rsp, 8mainuninitializedBool0

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


其他实现可以做出不同的选择,例如false=0true=any non-zero value。那么 clang 可能不会为这个特定的 UB 实例编写崩溃的代码。(但如果它愿意,它仍然会被允许。) 我不知道有任何实现会选择 x86-64 所做的任何其他事情bool,但是 C++ 标准允许许多没有人做甚至不想做的事情类似于当前 CPU 的硬件。

ISO C++ 未指定检查或修改bool. (例如,memcpy通过boolinto unsigned char,您可以这样做,因为char*可以为任何东西加上别名。并且unsigned char保证没有填充位,因此 C++ 标准确实允许您在没有任何 UB 的情况下使用 hexdump 对象表示。指针转换以复制对象当然,表示与分配不同char foo = my_bool,因此不会发生布尔化为 0 或 1 并且您将获得原始对象表示。)

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

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

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

实际上,GCC 和 Clang实际上有时会在 UB 上发出ud2而不是尝试为没有意义的执行路径生成代码。 或者对于像掉出非void函数结尾这样的情况,gcc 有时会省略一条ret指令。如果您认为“我的函数将返回 RAX 中的任何垃圾”,那您就大错特错了。 现代 C++ 编译器不再将这种语言视为可移植的汇编语言。您的程序确实必须是有效的 C++,而无需假设您的函数的独立非内联版本在 asm 中的外观。

另一个有趣的例子是为什么在 AMD64 上对 mmap 内存的非对齐访问有时会出现段错误?. x86 不会在未对齐的整数上出错,对吧?那么为什么错位uint16_t*会成为问题呢?因为alignof(uint16_t) == 2,并且在使用 SSE2 进行自动矢量化时,违反该假设会导致段错误。

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

关键点:如果编译器在编译时注意到 UB,它可能会“破坏”(发出令人惊讶的 asm)通过您的代码导致 UB 的路径,即使针对任何位模式都是bool.

期待程序员对许多错误的完全敌意,尤其是现代编译器警告的事情。这就是您应该使用-Wall和修复警告的原因。C++ 不是一种用户友好的语言,C++ 中的某些内容可能是不安全的,即使它在您正在编译的目标上的 asm 中是安全的。(例如,有符号溢出是 C++ 中的 UB,编译器会假设它不会发生,即使在为 2 的补码 x86 编译时,除非你使用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不能高于此值。)

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

另请注意,允许实现(又名编译器)定义 ISO C++ 未定义的行为。例如,所有支持英特尔内在函数的编译器(例如_mm_add_ps(__m128, __m128)手动 SIMD 矢量化)必须允许形成未对齐的指针,即使您取消引用它们,这也是 C++ 中的 UB。 __m128i _mm_loadu_si128(const __m128i *)通过采用未对齐的__m128i*arg 而不是 avoid*或来执行未对齐的负载char*硬件向量指针和相应类型之间的“reinterpret_cast”是未定义的行为吗?

GNU C/C++ 还定义了左移负符号数的行为(即使没有-fwrapv),与正常的有符号溢出 UB 规则分开。(这是 ISO C++ 中的 UB,而有符号数的右移是实现定义的(逻辑与算术);质量好的实现选择具有算术右移的硬件上的算术,但 ISO C++ 没有指定)。这记录在GCC 手册的 Integer 部分,以及定义实现定义的行为,C 标准要求实现定义一种或另一种方式。

编译器开发人员肯定会关心实现质量问题。他们通常不会尝试制造故意敌对的编译器,但利用 C++ 中的所有 UB 坑洼(他们选择定义的坑除外)来更好地优化有时几乎无法区分。


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

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

例如,调用者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 前缀。

这就是为什么 clang 发出movzx eax, dilin Serialize,而不是sub eax, edi. (对于整数参数,clang 违反了此 ABI 规则,而是根据 gcc 和 clang 的未记录行为将窄整数零或符号扩展为 32 位。 将 32 位偏移量添加到指针时是否需要符号或零扩展x86-64 ABI? 所以我很感兴趣地看到它对 .) 没有做同样的事情bool。)


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

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

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

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


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

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

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

它将容忍复制未初始化的内存,以及简单的逻辑和算术运算。一般来说,MemorySanitizer 会默默地跟踪未初始化数据在内存中的传播,并在执行(或不执行)代码分支时根据未初始化的值报告警告。

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

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

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

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

于 2019-01-10T09:42:59.200 回答
57

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

因此,如果您未能初始化 a bool,或者如果您成功通过某个不同类型的指针覆盖它,那么编译器的假设将是错误的,并且会出现未定义的行为。您已被警告:

50) 以本国际标准将布尔值描述为“未定义”的方式使用布尔值,例如通过检查未初始化的自动对象的值,可能会导致其表现得既不是真也不是假。(第 6.9.1 节第 6 段的脚注,基本类型)

于 2019-01-10T01:59:59.953 回答
53

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

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

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

注意。“未定义的行为会导致 _____ 吗?”的答案 总是“是”。这实际上是未定义行为的定义。

于 2019-01-10T02:12:19.310 回答
23

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

通常,实现将使用整数0forfalse1fortrue来简化 和 之间的转换boolintif (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数组边界之外。

于 2019-01-10T02:02:03.153 回答
16

总结了很多你的问题,你在问 C++ 标准是否允许编译器假设 abool只能具有“0”或“1”的内部数字表示并以这种方式使用它?

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

因此,编译器在看到 a 时bool有权考虑 thatbool包含 ' true' 或 ' false' 位模式并执行任何感觉。因此,如果 和 的值分别为1truefalse0,则确实允许编译器优化strlen5 - <boolean value>. 其他有趣的行为是可能的!

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

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

查看每个程序员应该了解的未定义行为

于 2019-01-10T11:48:57.783 回答
1

C++ 标准是否允许编译器假设 bool 只能具有“0”或“1”的内部数字表示并以这种方式使用它?

确实是的,如果它对任何人有用,这是发生在我身上的另一个真实例子。

我曾经花了几个星期在一个大型代码库中追踪一个晦涩难懂的错误。有几个方面使它具有挑战性,但根本原因是类变量的未初始化布尔成员。

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

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)

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

于 2022-01-03T13:57:24.597 回答