微信公众号搜"智元新知"关注
微信扫一扫可直接关注哦!

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

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

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

但还要注意,如果程序遇到 UB,ISO C 允许编译器发出故意崩溃的代码(例如,使用非法指令),例如,作为帮助您查找错误的一种方式。(或者因为它是一个 DeathStation 9000。严格遵守对于 C 实现对于任何实际目的有用是不够的)。 即使这需要是没有陷阱表示的固定布局类型。

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


1的低 8 位表示。在内存中,bool一个 1 字节类型,它又必须有一个整数值 0 或 1。

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

。我不知道有任何 ABI 不允许编译器bool为任何架构(不仅仅是 x86)假设 0 或 1。它允许像翻转低位这样的优化!myboolxor eax,1任何可以在单个 cpu 指令中翻转 0 和 1 之间的位/整数/布尔值的可能代码。或编译a&&bbool类型的按位与。一些编译器实际上在编译器中利用[了布尔值作为 8 位。对它们的操作效率低吗?.

为真的事物,因为最终结果将是实现与 C代码相同的外部可见行为的可执行代码。(未定义行为对实际“外部可见”的所有限制:不是使用调试器,而是来自格式良好/合法 C 程序中的另一个线程。)

(顺便说一句,这种优化有点聪明,但与分支和内联memcpy作为即时数据2的存储相比,可能是短视的。)

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


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

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


我不知道有任何实现选择 x86-64 所做的任何其他事情bool,但是 C++ 标准允许许多没有人做甚至不想做的事情类似于当前 cpu 的硬件。

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

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

(如果编译器决定遵循通过非内联构造函数的路径,则编译器可以在编译时看到。)

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

或者对于像掉出非void函数结尾这样的情况,gcc 有时会省略一条ret指令。如果您认为“我的函数将返回 RAX 中的任何垃圾”,那您就大错特错了。

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

关键点:如果编译器在编译时注意到 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 上,l:‘5’,n:‘0’,o:’C%2B%2B+source+%231’,t:‘0’)),k:37.77562439622385,l:‘4’,n:‘0’,o:’‘,s:0,t:‘0’),(g:!((h:compiler,i:(compiler:clang700,filters:(b:‘0’,binary:‘1’,commentOnly:‘0’,demangle:‘0’,directives:‘0’,execute:‘1’,intel:‘0’,libraryCode:‘1’,trim:‘1’),fontScale:1.2899450879999999,lang:c%2B%2B,libs:!(),options:’-xc+-Wall+-Wextra+-O3+-std%3Dgnu11+-march%3Dznver1’,source:1),l:‘5’,n:‘0’,o:’x86-64+clang+7.0.0+(Editor+%231,+Compiler+%231)+C%2B%2B’,t:‘0’)),k:30.92627232139171,l:‘4’,m:100,n:‘0’,o:’‘,s:0,t:‘0’),(g:!((h:compiler,i:(compiler:g82,filters:(b:‘0’,binary:‘1’,commentOnly:‘0’,demangle:‘0’,directives:‘0’,execute:‘1’,intel:‘0’,libraryCode:‘1’,trim:‘1’),fontScale:1.2899450879999999,lang:c%2B%2B,libs:!(),options:’-Wall+-Wextra+-O3+-std%3Dgnu%2B%2B11+-fverbose-asm’,source:1),l:‘5’,n:‘0’,o:’x86-64+gcc+8.2+(Editor+%231,+Compiler+%232)+C%2B%2B’,t:‘0’)),k:31.29810328238445,l:‘4’,n:‘0’,o:’‘,s:0,t:‘0’)),l:‘2’,m:100,n:‘0’,o:’‘,t:‘0’)),version:4)的示例。我不知道这是以用户友好的名义故意“错过”的优化还是什么。

另请注意,**允许实现(又名编译器)定义 ISO C 未定义的行为。例如,所有支持 Intel 内在函数的编译器(例如_mm_add_ps(__m128, __m128)手动 SIMD 矢量化)必须允许形成未对齐的指针,即使您取消引用它们,这也是 C 中的 UB。 __m128i _mm_loadu_si128(const __m128i *)通过采用未对齐的__m128i*arg 而不是 avoid*或来执行未对齐的负载char*。GNU C/C 还定义了左移负符号数(即使没有-fwrapv)的行为,与正常的有符号溢出 UB 规则分开。这是 ISO C 中的 UB,而有符号数的右移是实现定义的(逻辑与算术);质量好的实现选择具有算术右移的硬件上的算术,但 ISO C++ 没有指定)。这记录在GCC 手册的 Integer 部分,以及定义实现定义的行为,C 标准要求实现定义一种或另一种方式。

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


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

(。有些确实需要窄整数类型在传递给 MIPS64 和 PowerPC64 等函数或从函数返回时进行零或符号扩展以填充寄存器。

例如,调用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。)


在分支之后,你只有一个 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/

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 级别,关于填充内容和实际值的一部分的信息已经丢失。

解决方法

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

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。