是的,ISO C++允许(但不要求)实现做出这个选择。
但请注意,ISO C++允许编译器故意生成会导致程序崩溃的代码(例如,使用非法指令),如果程序遇到未定义行为(UB),这样可以帮助您找到错误(或者因为它是DeathStation 9000。严格符合标准并不足以使C++实现对任何实际目的有用)。因此,ISO C++允许编译器生成即使在类似的读取未初始化的uint32_t的代码上也会崩溃的汇编代码(出于完全不同的原因)。即使这是一个具有固定布局且没有陷阱表示的类型所必需的。(请注意,C和C++有不同的规则;在C中,未初始化的变量具有不确定的值,可能是陷阱表示,但在C++中,读取未初始化的变量完全是未定义行为。不确定是否有额外的规则适用于C11的_Bool,这可能允许与C++相同的崩溃行为。)
这是一个关于实际实现如何工作的有趣问题,但请记住,即使答案不同,你的代码仍然是不安全的,因为现代C++并不是可移植的汇编语言版本。
你正在编译
x86-64 System V ABI,该规范指定寄存器中的函数参数
bool
由位模式
false=0
和
true=1
表示,位于寄存器的低8位
1。在内存中,
bool
是一个1字节的类型,其整数值必须为0或1。
ABI(Application Binary Interface)是编译器为同一平台上的代码所达成的一组实现选择,以便它们可以调用彼此的函数,包括类型大小、结构布局规则和调用约定。根据ISO C++标准,违反ABI的对象表示被称为“陷阱表示”,尽管CPU在执行字节指令时并不直接陷入。只有在违反软件假设后才会导致后续故障。在ISO C17的6.2.6.1 #5中,它进一步指出某些对象表示不需要表示对象类型的值。如果存储的对象值具有这样的表示,并且由不具有字符类型的lvalue表达式读取,则行为是未定义的...并且继续说它被称为陷阱表示。我不知道ISO C++中是否存在相同的语言。
ISO C++没有明确规定,但这种ABI决策是普遍的,因为它使得bool->int的转换廉价(只需零扩展)。我不知道有任何ABI不允许编译器假设对于任何架构(不仅仅是x86),bool的值为0或1。它允许像!mybool
这样的优化,通过xor eax,1
来翻转最低位:任何可以在单个CPU指令中翻转位/整数/bool的可能代码。或者将a&&b
编译为位与操作对于bool
类型。一些编译器实际上确实利用了这一点编译器中将布尔值作为8位处理,操作是否低效?。
通常情况下,仿佛规则允许编译器利用在目标平台上成立的事实,因为最终的结果将是实现与C++源代码相同的外部可见行为的可执行代码。(在所有未定义行为对实际“外部可见性”有限制的情况下,不包括调试器,但在一个良好形式/合法的C++程序的另一个线程中是可见的。)
编译器绝对可以充分利用ABI保证来生成代码,并将像你发现的代码优化为
strlen(whichString)
变为
5U - boolValue
。(顺便说一句,这种优化方法相当巧妙,但可能相对于分支和内联
memcpy
作为立即数据存储来说有点短视。)
或者编译器可以创建一个指针表,并使用
bool
的整数值作为索引,再次假设它是0或1。(这种可能性正是@Barmar的回答所建议的。)
你的启用优化的
__attribute((noinline))
构造函数导致clang只是从堆栈中加载一个字节作为
uninitializedBool
。它在
main
中为对象腾出空间,使用
push rax
(比
sub rsp, 8
更小且出于各种原因同样高效),所以无论在进入
main
时AL中有什么垃圾值,它都会用作
uninitializedBool
的值。这就是为什么你实际上得到的值不仅仅是
0
。
5U - 随机垃圾值
很容易变成一个大的无符号值,导致memcpy进入未映射的内存。目标是静态存储而不是堆栈,所以你不会覆盖返回地址或其他东西。
其他实现可能会做出不同的选择,例如
false=0
和
true=任何非零值
。然后,Clang可能不会为
这个特定的未定义行为实例生成导致崩溃的代码。(但如果它愿意的话,仍然可以这样做。)我不知道有哪些实现会选择与x86-64不同的东西来表示
bool
,但C++标准允许许多在任何类似当前CPU的硬件上都没有人做或者想做的事情。
ISO C++没有规定当您检查或修改
bool
的对象表示时会得到什么结果。(例如,通过将
bool
复制到
unsigned char
中进行
memcpy
,这是允许的,因为
char*
可以别名任何类型。而且
unsigned char
保证没有填充位,所以C++标准确实允许您以十六进制转储对象表示而没有任何未定义行为。将指针强制转换以复制对象表示与赋值
char foo = my_bool
是不同的,当然,所以不会发生布尔化为0或1,并且您将获得原始对象表示。)
你用`noinline`在这个执行路径上“部分”地将UB(未定义行为)从编译器中“隐藏”起来了。即使它不进行内联,但是跨过函数定义的互操作优化仍然可能生成一个依赖于另一个函数定义的函数版本。(首先,clang正在生成一个可执行文件,而不是可能发生符号重定位的Unix共享库。其次,定义在`class{}`定义内部,因此所有的翻译单元必须具有相同的定义,就像使用`inline`关键字一样。)
因此,编译器可以只发出`ret`或`ud2`(非法指令)作为`main`的定义,因为从`main`顶部开始的执行路径无法避免地遇到未定义行为。(如果编译器决定沿着非内联构造函数的路径进行编译,它可以在编译时看到这一点。)
任何遇到未定义行为(UB)的程序在其整个存在期间都是完全未定义的。但是在一个从未实际运行的函数或if()分支中的UB不会破坏程序的其余部分。实际上,这意味着编译器可以决定发出非法指令、ret指令,或者不发出任何指令并跳转到下一个块/函数,对于在编译时可以证明包含或导致UB的整个基本块。
实际上,GCC和Clang有时会在UB上发出ud2指令,而不是尝试为没有意义的执行路径生成代码。或者在非void函数的末尾掉落的情况下,gcc有时会省略ret指令。如果你认为"我的函数将返回RAX中的任何垃圾",那你大错特错了。现代C++编译器不再将语言视为可移植的汇编语言。你的程序必须是有效的C++,不能对一个独立的非内联版本的函数在汇编中的样子做出假设。
另一个有趣的例子是
为什么在AMD64上对未对齐的mmap内存访问有时会导致段错误?。x86不会在未对齐的整数上出错,对吧?那么为什么一个错误对齐的
uint16_t*
会成为一个问题呢?因为
alignof(uint16_t) == 2
,违反了这个假设会导致使用SSE2进行自动向量化时发生段错误。
另请参阅
《每个C程序员都应该了解的未定义行为》第1/3部分,这是一位clang开发者撰写的文章。
关键点:如果编译器在编译时注意到了未定义行为,即使针对的是一个任意位模式都是有效对象表示的ABI,它也可以“打破”(生成令人惊讶的汇编代码)导致未定义行为的代码路径。
对于程序员的许多错误,尤其是现代编译器警告的事情,应该期望完全的敌意。这就是为什么你应该使用
-Wall
并修复警告。C++并不是一种用户友好的语言,即使在目标编译平台上的汇编语言中是安全的,C++中的某些内容可能仍然是不安全的。(例如,有符号溢出在C++中是未定义行为,编译器会假设它不会发生,即使在编译为2的补码x86时,除非你使用
clang/gcc -fwrapv
。)
编译时可见的未定义行为总是危险的,而且很难确定(通过链接时优化)你是否真的将未定义行为隐藏在编译器之外,从而可以推断出它将生成什么样的汇编语言。
不要太夸张,通常编译器确实会让你做一些事情,并生成你期望的代码,即使存在未定义行为。但也许将来会出现问题,如果编译器开发人员实现了一些优化,获得了关于值范围的更多信息(例如,一个变量是非负的,可能允许它在x86-64上优化符号扩展为零扩展)。例如,在当前的gcc和clang中,执行tmp = a+INT_MIN不会将a<0优化为始终为假,只会优化tmp始终为负数。(因为在这个二进制补码目标上,INT_MIN + a=INT_MAX是负数,而a不能比这个更高。)
所以gcc/clang目前不会回溯以推导计算输入的范围信息,只会基于无符号溢出的假设对结果进行回溯:
在Godbolt上的示例。我不知道这种优化是出于用户友好性的考虑还是其他原因而故意“忽略”了。
请注意,实现(也称为编译器)可以定义ISO C++未定义的行为。例如,所有支持Intel的内部函数(如
_mm_add_ps(__m128, __m128)
用于手动SIMD向量化)的编译器必须允许形成未对齐的指针,即使您不对它们进行解引用,这在C++中是未定义的行为。
__m128i _mm_loadu_si128(const __m128i *)
通过使用未对齐的
__m128i*
参数而不是
void*
或
char*
来进行未对齐的加载。
在硬件SIMD向量指针和相应类型之间进行reinterpret_cast是否是未定义行为?
GNU C/C++还定义了对负有符号数进行左移的行为(即使没有使用
-fwrapv
),与正常的有符号溢出UB规则分开。这是ISO C++中的UB(
这是ISO C++中的UB),而有符号数的右移是实现定义的(逻辑右移还是算术右移);优质的实现会在具有算术右移的硬件上选择算术右移,但ISO C++没有指定。这在
GCC手册的整数部分中有详细说明,同时还定义了C标准要求实现以某种方式定义的实现定义行为。
编译器开发人员确实关心实现质量问题;他们通常并不是有意制作敌对的编译器,但利用C++中的所有UB陷阱(除了他们选择定义的陷阱)来进行更好的优化有时几乎是无法区分的。
脚注1:调用方必须忽略高56位的垃圾值,这在比寄存器窄的类型中是常见的做法。
(其他ABI在这里做出了不同的选择。有些要求窄整数类型在传递给函数或从函数返回时,必须进行零扩展或符号扩展以填充寄存器,比如MIPS64和PowerPC64。请参阅{{link1:这个x86-64答案的最后一节,与那些早期的ISA进行比较。))
例如,调用方可能在RDI中计算了`a & 0x01010101`并将其用于其他用途,然后调用`bool_func(a&1)`。调用方可以优化掉`&1`,因为它已经在`and edi, 0x01010101`的过程中对低字节进行了操作,并且它知道被调用方需要忽略高字节。
或者,如果第三个参数传递的是一个布尔值,那么调用者可能会为了代码大小而使用
mov dl, [mem]
来加载它,而不是
movzx edx, [mem]
,这样可以节省一个字节,但会导致对RDX的旧值产生错误依赖(或其他部分寄存器效果,取决于CPU型号)。或者对于第一个参数,可以使用
mov dil, byte [r10]
来代替
movzx edi, byte [r10]
,因为两者都需要使用REX前缀。
这就是为什么clang在
Serialize
中发出
movzx eax, dil
,而不是
sub eax, edi
。(对于整数参数,clang违反了这个ABI规则,而是依赖于gcc和clang的未记录行为来将窄整数零扩展或符号扩展为32位。
在x86-64 ABI中,将32位偏移添加到指针时是否需要进行符号或零扩展?因此,我很想看到它对
bool
不做同样的处理。)
脚注2: 在分支之后,你只需要一个4字节的
mov
-immediate,或者一个4字节+1字节的存储。长度在存储宽度和偏移量中是隐含的。
另一方面,glibc的memcpy将执行两个4字节的加载/存储,重叠取决于长度,因此整个过程确实不需要在布尔值上进行条件分支。请参见glibc的memcpy/memmove中的
L(between_4_7):
block 。或者,至少在memcpy的分支中,选择一个块大小。
如果进行内联,你可以使用2个
mov
-immediate +
cmov
和一个条件偏移量,或者可以将字符串数据保留在内存中。
或者,如果针对英特尔Ice Lake进行调优(使用
快速短REP MOV功能),实际上使用
rep movsb
可能是最佳选择。glibc的
memcpy
在具备该功能的CPU上,可能会开始使用
rep movsb
来处理小尺寸的数据,从而节省了很多分支操作。
检测未定义行为和未初始化值的工具
在gcc和clang中,您可以使用-fsanitize=undefined
编译选项,添加运行时的仪器,以便在运行时发生未定义行为时发出警告或报错。然而,这不会捕捉到未初始化的变量。(因为它不会增加类型大小以为“未初始化”位腾出空间)。
请参阅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
来检测未初始化内存读取的示例。如果您在编译时不进行优化,所有变量的读取都将从内存中加载到汇编中,这可能是最好的方法。他们在一个不会被优化的情况下展示了在
-O2
下使用它的情况。我自己还没有尝试过。(在某些情况下,例如在对数组求和之前未初始化累加器,clang -O3会生成从未初始化的向量寄存器中求和的代码。因此,在优化时,可能没有与UB相关的内存读取。但是
-fsanitize=memory
会改变生成的汇编代码,并可能导致对此进行检查。)
它将容忍未初始化内存的复制,以及与之进行的简单逻辑和算术操作。总的来说,MemorySanitizer会在内存中静默跟踪未初始化数据的传播,并在代码分支根据未初始化值被执行(或不被执行)时报告警告。
MemorySanitizer实现了Valgrind(Memcheck工具)中的部分功能。
对于这种情况,它应该能够工作,因为对于使用未初始化内存计算得到的长度调用glibc的memcpy函数将会(在库内部)基于长度进行分支。如果它内联了一个完全无分支的版本,只使用cmov、索引和两个存储操作,可能就无法工作。
Valgrind的memcheck也会寻找这种问题,但如果程序只是简单地复制未初始化数据,它不会抱怨。但它表示它会检测到“条件跳转或移动依赖于未初始化的值”,以尝试捕捉任何依赖于未初始化数据的外部可见行为。
或许不对仅仅一个负载进行标记的背后的想法是,结构体可以有填充,使用宽向量加载/存储复制整个结构体(包括填充)即使每个成员只写入一次也不会出错。在汇编级别上,关于哪些是填充和哪些实际上是值的信息已经丢失。
static_cast<bool>()
)。但这并不是关于编译器选择的bool
内部表示的要求。 - Euro Micelli