是的,ISO C++ 允许(但不要求)实现做出这个选择。
但还要注意,如果程序遇到 UB,ISO C++ 允许编译器发出故意崩溃的代码(例如,使用非法指令),例如,作为帮助您查找错误的一种方式。(或者因为它是一个 DeathStation 9000。严格遵守并不足以让 C++ 实现对任何实际目的有用)。 因此,ISO C++ 将允许编译器生成崩溃的 asm(出于完全不同的原因),即使在读取未初始化uint32_t
. 即使这需要是没有陷阱表示的固定布局类型。
这是一个关于实际实现如何工作的有趣问题,但请记住,即使答案不同,您的代码仍然不安全,因为现代 C++ 不是汇编语言的可移植版本。
您正在为x86-64 System V ABI进行编译,它指定 abool
作为寄存器中的函数 arg 由位模式false=0
和寄存器true=1
1的低 8 位表示。在内存中,bool
是一个 1 字节类型,它又必须有一个整数值 0 或 1。
(ABI 是同一平台的编译器同意的一组实现选择,因此它们可以编写调用彼此函数的代码,包括类型大小、结构布局规则和调用约定。)
ISO C++ 没有指定它,但是这个 ABI 决定很普遍,因为它使 bool->int 转换变得便宜(只是零扩展)。我不知道有任何 ABI 不允许编译器bool
为任何架构(不仅仅是 x86)假设 0 或 1。它允许像翻转低位这样的优化!mybool
:xor eax,1
任何可以在单个 CPU 指令中翻转 0 和 1 之间的位/整数/布尔值的可能代码。或编译a&&b
为bool
类型的按位与。一些编译器实际上在编译器中利用了布尔值作为 8 位。对它们的操作效率低吗?.
一般而言,as-if 规则允许编译器利用正在编译的目标平台上为真的事物,因为最终结果将是实现与 C++ 源代码相同的外部可见行为的可执行代码。(未定义行为对实际“外部可见”的所有限制:不是使用调试器,而是来自格式良好/合法 C++ 程序中的另一个线程。)
绝对允许编译器在其代码生成中充分利用 ABI 保证,并制作像您发现的那样优化strlen(whichString)
到
5U - boolValue
. (顺便说一句,这种优化有点聪明,但与分支和内联memcpy
作为即时数据2的存储相比,可能是短视的。)
或者编译器可以创建一个指针表并用 的整数值对其进行索引bool
,再次假设它是 0 或 1。(这种可能性是 @Barmar 的回答所建议的。)
您__attribute((noinline))
启用优化的构造函数导致 clang 仅从堆栈中加载一个字节以用作 as uninitializedBool
。它为main
with中的对象腾出空间push rax
(由于各种原因,它更小,并且与 这就是为什么你实际上得到的值不仅仅是.sub rsp, 8
main
uninitializedBool
0
5U - random garbage
可以很容易地包装成一个大的无符号值,导致 memcpy 进入未映射的内存。目的地在静态存储中,而不是堆栈中,因此您不会覆盖返回地址或其他东西。
其他实现可以做出不同的选择,例如false=0
和true=any non-zero value
。那么 clang 可能不会为这个特定的 UB 实例编写崩溃的代码。(但如果它愿意,它仍然会被允许。) 我不知道有任何实现会选择 x86-64 所做的任何其他事情bool
,但是 C++ 标准允许许多没有人做甚至不想做的事情类似于当前 CPU 的硬件。
ISO C++ 未指定检查或修改bool
. (例如,memcpy
通过bool
into unsigned char
,您可以这样做,因为char*
可以为任何东西加上别名。并且unsigned char
保证没有填充位,因此 C++ 标准确实允许您在没有任何 UB 的情况下使用 hexdump 对象表示。指针转换以复制对象当然,表示与分配不同char foo = my_bool
,因此不会发生布尔化为 0 或 1 并且您将获得原始对象表示。)
您已使用noinline
. 但是,即使它没有内联,过程间优化仍然可以生成依赖于另一个函数定义的函数版本。(首先,clang 正在制作一个可执行文件,而不是一个可以发生符号插入的 Unix 共享库。其次,定义中的class{}
定义,因此所有翻译单元必须具有相同的定义。就像inline
关键字一样。)
所以编译器可以只发出一个ret
or 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, dil
in 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 级别,关于填充内容和实际值的一部分的信息已经丢失。