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


500

我知道C ++ 中的“未定义行为”几乎可以允许编译器执行其想要的任何操作。但是,由于我认为代码足够安全,因此发生了一次崩溃,这让我感到惊讶。

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

为了重现此问题并将其简化到最大程度,我尝试了几件事。下面是一个函数的提取物被称为Serialize,这将需要一个布尔参数,并复制字符串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,因为无论如何它都会评估为真或假。”

我已经设置了一个Compiler Explorer示例,该示例在反汇编中显示了问题,此处是完整的示例。注意:为了解决该问题,我发现有效的组合是通过将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 ++标准是否允许编译器假设布尔型只能以内部数字表示“ 0”或“ 1”并以这种方式使用?

还是这是一种实现定义的情况,在这种情况下,实现假设其所有布尔仅包含0或1,并且其他任何值都是未定义的行为范围?


200
这是一个很好的问题。这充分说明了未定义行为不仅是理论上的问题。当人们说UB导致任何事情都可能发生时,“任何事情”真的很令人惊讶。有人可能会认为未定义的行为仍会以可预测的方式表现出来,但是如今,现代优化器已经完全不正确了。OP花了一些时间来创建MCVE,对问题进行了彻底调查,检查了拆卸情况,并提出了一个清晰,直接的问题。不能要求更多。
John Kugelman

7
请注意,“非零值求和”的要求true是关于布尔运算的规则,包括“分配给布尔值”(可能隐式调用static_cast<bool>()特定条件)。但是bool,对于编译器选择的内部表示形式不是必需的。
欧洲米切利

2
评论不作进一步讨论;此对话已转移至聊天
塞缪尔·刘

3
值得一提的是,这是二进制不兼容的“有趣”来源。如果您有一个ABI A,它在调用函数之前对值进行零填充,但在编译函数时假定参数被零填充,而ABI B则相反(不是零填充,但不假设零-papped parameters),它通常可以正常工作,但是如果使用A ABI调用带有“ small”参数的函数,则使用B ABI的函数会引起问题。IIRC,您在带有clang和ICC的x86上具有此功能。
TLW

1
@TLW:尽管标准不要求实现提供任何调用或被外部代码调用的方法,但如果有一种方法可以为相关的实现指定这样的东西(这在没有详细说明的实现中,则可能会有所帮助)相关可能会忽略此类属性)。
超级猫

Answers:


285

是的,ISO C ++允许(但不是必需)实施方案来做出此选择。

但也请注意,如果程序遇到UB,ISO C ++允许编译器发出有意崩溃的代码(例如,带有非法指令),例如,它可以帮助您发现错误。(或者是因为它是DeathStation9000。仅严格遵守标准不足以使C ++实现对任何实际目的都有用)。 因此,ISO C ++允许编译器即使在读取未初始化的类似代码时也可以使asm崩溃(由于完全不同的原因)uint32_t 即使要求它是没有陷阱表示形式的固定布局类型。

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


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

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

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

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

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

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


__attribute((noinline))启用了优化功能的构造函数导致clang只是从堆栈中加载一个字节以用作as uninitializedBool。它在由空间为对象mainpush rax(这是较小的和关于各种原因大约同样有效sub rsp, 8),所以无论垃圾在AL上进入main是它用于值uninitializedBool。这就是为什么您实际上得到的价值不只是为什么0

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


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

ISO C ++保留了未指定的内容,当您检查或修改的对象表示形式时会发现什么bool。(例如,memcpy通过bool进入into unsigned char,您可以执行此操作,因为它char*可以别名任何东西。并且unsigned char保证没有填充位,因此C ++标准确实允许您在没有任何UB的情况下十六进制转储对象表示形式。指针广播以复制对象表示形式与赋值是不同char foo = my_bool的,因此不会发生布尔化为0或1的情况,而您将获得原始对象表示形式。)

您已经使用编译器部分地 “隐藏”了该执行路径中的UBnoinline。即使不内联,过程间优化仍然可以使函数的版本取决于另一个函数的定义。(首先,clang是一个可执行文件,而不是一个可以进行符号插入的Unix共享库。其次,该定义中的class{}定义,因此所有翻译单元必须具有相同的定义。与inline关键字一样。)

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

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

GCC和锵在实践中确实有时会发出ud2关于UB,而不是甚至还试图生成,使没有意义的执行路径代码。 或者,对于诸如无法void正常运行的情况,gcc有时会省略一条ret指令。如果您以为“我的函数将随RAX中的任何垃圾一起返回”,那么您会感到非常误解。 现代C ++编译器不再像可移植汇编语言那样对待该语言。您的程序实际上必须是有效的C ++,而无需假设函数的独立非内联版本在asm中的外观。

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

另请参见 每位C程序员应了解的有关未定义行为的信息1/3,这是由clang开发人员撰写的文章。

关键点:如果编译器在编译时注意到UB,它可能会 “破坏”(发出令人惊讶的asm)代码中导致UB的路径,即使将目标定位为ABI的任何位模式都可以作为目标的ABI bool

期望程序员对许多错误完全怀有敌意,尤其是现代编译器警告的事物。这就是为什么您应该使用-Wall和修复警告的原因。C ++不是一种用户友好的语言,C ++中的某些内容可能是不安全的,即使它在您要为其编译的目标上的asm中是安全的。(例如,签名溢出是C ++中的UB,并且编译器会假定它不会发生,即使使用2的补码x86进行编译,除非您使用clang/gcc -fwrapv。)

编译时可见的UB总是很危险的,并且很难(通过链接时优化)确保您已经从编译器中真正隐藏了UB,从而可以推断出它将生成哪种asm。

不要太夸张;通常,编译器确实会让您无所事事,并且即使您使用的是UB,也会发出您期望的代码。但是,如果编译器开发人员实施某种优化以获取有关值范围的更多信息(例如,一个变量为非负数,也许允许其优化符号扩展以在x86上释放零扩展),那么将来可能会成为问题。 64)。例如,在当前的gcc和clang中,doing tmp = a+INT_MIN操作不会优化a<0为always-false,而只会tmp始终为负。(因为INT_MIN+ a=INT_MAX在此2的补码目标上为负数,并且a不能高于此值。)

因此,gcc / clang当前仅在基于无符号溢出的假设的结果上才回溯以得出计算输入的范围信息:Godbolt上的示例。我不知道这是否是出于用户友好或其他目的而故意“错过”了优化。

还要注意,允许实现(也称为编译器)定义ISO C ++保持未定义状态的行为。例如,所有支持Intel内在函数的编译器(例如_mm_add_ps(__m128, __m128)用于手动SIMD矢量化的编译器)都必须允许形成未对齐的指针,即使您取消引用它们,C ++中的UB也是如此。 __m128i _mm_loadu_si128(const __m128i *)通过使用未对齐的__m128i*arg而不是a void*或来执行未对齐的负载char*硬件向量指针和相应类型之间的“ reinterpret_cast”运算是否是未定义的行为?

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

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


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

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

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

或者,如果将布尔值作为第三个arg传递,则优化代码大小的调用程序可能会使用mov dl, [mem]而不是来加载它,而不movzx edx, [mem]是以虚假依赖RDX的旧值(或其他部分寄存器效应,取决于在CPU型号上)。或对于第一个arg,mov dil, byte [r10]而不是movzx edi, byte [r10],因为两者都需要REX前缀。

这就是为什么铛发出movzx eax, dilSerialize,而不是,sub eax, edi。(对于整数args,clang违反了此ABI规则,而是取决于gcc和clang的未记录行为,将窄整数零扩展或符号扩展为32位。 当为指针添加32位偏移量时,是否需要符号扩展或零扩展? x86-64 ABI吗? 所以我很感兴趣地发现它对bool


脚注2: 分支之后,您将mov立即拥有4字节的存储区或4字节+ 1字节的存储区。长度隐含在商店宽度+偏移量中。

OTOH,glibc memcpy将执行两个4字节的加载/存储,它们的重叠取决于长度,因此,这的确使整个对象摆脱了布尔值上的条件分支。请参阅glibc的memcpy / memmove中的代码L(between_4_7):。至少,对于memcpy分支中的布尔值,以相同的方式选择块大小。

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

或者,如果针对Intel Ice Lake进行调优(具有快速短REP MOV功能),则实际rep movsb可能是最佳选择。glibc memcpy可能会开始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使用length未初始化内存计算出的glibc调用(在库内部)将导致基于的分支length。如果它已内联了仅使用cmov,索引和两个存储的完全无分支版本,则可能无法正常工作。

Valgrindmemcheck也会寻找这种问题,如果程序只是在未初始化的数据周围复制,Valgrind也不会抱怨。但是它说它将检测“有条件的跳跃或移动取决于未初始化的值”,以试图捕获任何依赖于未初始化数据的外部可见行为。

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


2
我见过一个更糟糕的情况,变量取的值不在8位整数范围内,而是整个CPU寄存器的值。而且Itanium的情况更糟,使用未初始化的变量可能会完全崩溃。
约书亚

2
@Joshua:好的,好的,Itanium的明确推测是将寄存器值标记为等同于“ not a number”,从而使用值错误。
彼得·科德斯

11
此外,这也说明了为什么在UB featurebug在摆在首位的语言C和C ++的设计介绍:因为它可以让编译器正是这种自由的,它现在已经允许的最现代的编译器来执行这些高品质使C / C ++成为高性能中级语言的优化。
The_Sympathizer

2
因此,C ++编译器作者和试图编写有用程序的C ++程序员之间的战争仍在继续。这个答案在回答这个问题时非常全面,也可以用作静态分析工具供应商的有说服力的广告文案……
davidbak

4
@The_Sympathizer:包含了UB,以允许实现以对客户最有用的任何方式表现。并不意味着建议所有行为都应被视为同等有用。
超级猫

56

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

因此,如果您未能初始化a bool,或者通过其他类型的指针成功覆盖了它,则编译器的假设将是错误的,并且将导致未定义的行为。您被警告过:

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


11
true值不必与整数1相同”是一种误导。当然,实际的位模式可以是别的,但是当隐式转换/提升(唯一会看到true/ 以外的值的方式false)时,true它始终是1,并且false始终是0。当然,这样的编译器也将无法使用该编译器试图使用的技巧(使用bools的实际位模式只能是0or 的事实1),因此它与OP的问题无关。
ShadowRanger

4
@ShadowRanger您始终可以直接检查对象表示。
TC

7
@shadowranger:我的意思是实施负责。如果它限制true了位模式的有效表示1,那是它的特权。如果它选择其他表示形式,则它确实不能使用此处提到的优化。如果确实选择了该特定表示,则可以。它只需要在内部保持一致。您可以bool通过将a的表示形式复制到字节数组中来进行检查;那不是UB(但它是实现定义的)
rici

3
是的,优化编译器(即现实世界中的C ++实现)有时通常会生成依赖于或bool的位模式的代码。他们不会在每次从内存(或包含函数arg的寄存器)中读取时重新布尔化它们。这就是这个答案的意思。 例子:gcc4.7 +可以优化到在一个函数返回,或MSVC可以优化到。x86 是按位排列的,因此if 和test根据设置标志。01boolreturn a||bor eax, ediboola&btest cl, dltest andcl=1dl=2cl&dl = 0
彼得·科德斯

5
关于未定义行为的要点是,允许编译器得出更多结论,例如,假设根本不会使用会导致访问未初始化值的代码路径,因为这确保了程序员的确切职责。因此,低电平值可能不同于零或一的可能性不大。
Holger

52

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

该错误位于调用函数中,可以通过对调用函数进行代码审查或静态分析来检测到。使用您的编译器浏览器链接,gcc 8.2编译器确实可以检测到该错误。(也许您可以针对clang提交一个错误报告,指出它没有发现问题)。

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

注意 答案为“未定义的行为会导致_____吗?” 始终为“是”。从字面上看,这就是未定义行为的定义。


2
第一条是真的吗?是否仅复制未初始化的bool触发器UB?
约书亚·格林

10
@JoshuaGreen请参阅[dcl.init] / 12“如果评估产生不确定的值,则该行为未定义,但以下情况除外:”(并且这些情况都没有例外bool)。复制需要评估源
MM

8
@JoshuaGreen这样做的原因是,如果访问某些类型的某些无效值,则可能会有一个平台触发硬件故障。这些有时称为“陷阱表示”。
David Schwartz

7
Itanium虽然晦涩难懂,但它仍在生产中,具有陷阱值,并且至少有两个半现代的C ++编译器(Intel / HP)。它的字面量为truefalse并且not-a-thing为布尔值。
MSalters

3
另一方面,“标准是否要求所有编译器以某种方式处理某些东西”的答案通常是“否”,甚至/尤其是在很明显任何质量的编译器都应该这样做的情况下;事物越明显,该标准的作者就不必真正说出它了。
超级猫

23

布尔仅允许保留内部用于true和的与实现相关的值false,并且生成的代码可以假定其仅保留这两个值之一。

典型地,实现将使用整数0false1true,以简化之间转换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数组范围之外进行访问。


1
@SidS谢谢。从理论上讲,内部表示形式可能与它们向整数或从整数强制转换的方式相反,但这将是错误的。
Barmar

1
您是对的,您的示例也会崩溃。但是,对于代码审阅,您将未初始化的变量用作数组的索引是“可见的”。同样,它甚至在调试时也会崩溃(例如,某些调试器/编译器将使用特定的模式进行初始化,以使其在崩溃时更容易看到)。在我的示例中,令人惊讶的部分是bool的用法是不可见的:优化程序决定在源代码中不存在的计算中使用它。
雷姆兹

3
@Remz我只是在使用数组来显示生成的代码可以等同于什么,而不是建议任何人实际写出来的代码。
Barmar

1
@Remz重铸bool,以int*(int *)&boolValue和打印进行调试,看它是否是以外的任何其他01崩溃时。如果真是这样,它在很大程度上证实了编译器正在优化inline-if的理论,即将其作为数组解释其崩溃的原因。
Havenard

2
@MSalters:std::bitset<8>并没有为我所有的不同标志命名。取决于它们是什么,这可能很重要。
马丁·邦纳

15

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

该标准没有说明的内部表示bool。它只定义铸造时会发生什么情况boolint(反之亦然)。通常,由于这些整数转换(并且人们非常依赖它们),编译器将使用0和1,但不必(尽管它必须遵守它使用的任何较低级别的ABI的约束)。 )。

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

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

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

请参阅每个程序员应了解的未定义行为

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.