是的,ISO c++允许(但不要求)实现做出这种选择。
But also note that ISO C++ allows a compiler to emit code that crashes on purpose (e.g. with an illegal instruction) if the program encounters UB, e.g. as a way to help you find errors. (Or because it's a DeathStation 9000. Being strictly conforming is not sufficient for a C++ implementation to be useful for any real purpose). So ISO C++ would allow a compiler to make asm that crashed (for totally different reasons) even on similar code that read an uninitialized uint32_t. Even though that's required to be a fixed-layout type with no trap representations. (Note that C has different rules from C++; an uninitialized variable has an indeterminate value in C which might be a trap representation, but reading one at all is fully UB in C++. Not sure if there are extra rules for C11 _Bool which could allow the same crash behaviour as C++.)
关于真正的实现是如何工作的,这是一个有趣的问题,但请记住,即使答案不同,您的代码仍然是不安全的,因为现代c++不是汇编语言的可移植版本。
您正在为x86-64 System V ABI编译,它指定bool作为寄存器中的函数arg由register1的低8位中的位模式false=0和true=1表示。在内存中,bool是一个1字节类型,同样必须是0或1的整数值。
(ABI是一组实现选择,同一平台上的编译器同意这样它们就可以编写调用彼此函数的代码,包括类型大小、结构布局规则和调用约定。)
ISO C++ doesn't specify it, but this ABI decision is widespread because it makes bool->int conversion cheap (just zero-extension). I'm not aware of any ABIs that don't let the compiler assume 0 or 1 for bool, for any architecture (not just x86). It allows optimizations like !mybool with xor eax,1 to flip the low bit: Any possible code that can flip a bit/integer/bool between 0 and 1 in single CPU instruction. Or compiling a&&b to a bitwise AND for bool types. Some compilers do actually take advantage Boolean values as 8 bit in compilers. Are operations on them inefficient?.
一般来说,as-if规则允许编译器利用被编译的目标平台上的真实情况,因为最终结果将是可执行代码,实现与c++源相同的外部可见行为。(未定义行为对“外部可见”的所有限制:不是通过调试器,而是从格式良好/合法的c++程序中的另一个线程。)
编译器绝对可以在其代码生成中充分利用ABI保证,并使代码像您发现的那样优化strlen(which string)
5U - boolValue。(顺便说一下,这种优化有点聪明,但与分支和内联memcpyas即时数据存储相比,这可能是短视的2)。
或者编译器可以创建一个指针表,并使用bool的整数值为其建立索引,同样假设它是0或1。(这种可能性正是@Barmar的回答所暗示的。)
你的__attribute((noinline))构造函数在启用了优化后,只会从堆栈中加载一个字节作为uninitializedBool值。它用push rax为main中的对象腾出空间(它更小,由于各种原因与sub rsp一样高效,8),所以在进入main时AL中的垃圾是它用于uninitializedBool的值。这就是为什么你得到的值不是0。
5U -随机垃圾可以很容易地包装成一个大的无符号值,导致memcpy进入未映射的内存。目的地在静态存储中,而不是堆栈中,所以你不会重写返回地址之类的。
其他实现可以做出不同的选择,例如false=0和true=任何非零值。那么clang可能不会为这个特定的UB实例生成崩溃的代码。(但如果它想这么做,还是可以这么做的。)除了x86-64对bool所做的,我不知道还有其他实现,但c++标准允许在像当前cpu这样的硬件上做许多没有人做或甚至不想做的事情。
ISO C++ leaves it unspecified what you'll find when you examine or modify the object representation of a bool. (e.g. by memcpying the bool into unsigned char, which you're allowed to do because char* can alias anything. And unsigned char is guaranteed to have no padding bits, so the C++ standard does formally let you hexdump object representations without any UB. Pointer-casting to copy the object representation is different from assigning char foo = my_bool, of course, so booleanization to 0 or 1 wouldn't happen and you'd get the raw object representation.)
你已经用noinline对编译器部分“隐藏”了这个执行路径上的UB。即使它不是内联的,但是,过程间优化仍然可以生成依赖于另一个函数定义的函数版本。(首先,clang正在创建一个可执行文件,而不是一个可以发生符号插入的Unix共享库。其次,定义在类{}定义内部,因此所有翻译单元必须具有相同的定义。比如内联关键字。)
因此,编译器可能只发出一个ret或ud2(非法指令)作为main的定义,因为从main的顶部开始的执行路径不可避免地遇到未定义行为。(如果编译器决定遵循非内联构造函数的路径,则在编译时可以看到。)
任何遇到UB的程序在其整个存在过程中都是完全未定义的。但是函数内的UB或if()分支从未实际运行,不会破坏程序的其余部分。在实践中,这意味着编译器可以决定发出一个非法指令,或者一个ret,或者不发出任何东西,落入下一个块/函数,因为整个基本块可以在编译时被证明包含或导致UB。
GCC and Clang in practice do actually sometimes emit ud2 on UB, instead of even trying to generate code for paths of execution that make no sense. Or for cases like falling off the end of a non-void function, gcc will sometimes omit a ret instruction. If you were thinking that "my function will just return with whatever garbage is in RAX", you are sorely mistaken. Modern C++ compilers don't treat the language like a portable assembly language any more. Your program really has to be valid C++, without making assumptions about how a stand-alone non inlined version of your function might look in asm.
另一个有趣的例子是为什么对mmap'ed内存的未对齐访问有时会在AMD64上发生段错误?X86不会对未对齐的整数出错,对吗?那么为什么不对齐uint16_t*是一个问题?因为对齐(uint16_t) == 2,违反这个假设导致段错误时自动向量化与SSE2。
参见clang开发人员的文章《每个C程序员都应该知道的未定义行为#1/3》。
重点:如果编译器在编译时注意到UB,它可能会“破坏”(发出令人惊讶的asm)你的代码中导致UB的路径,即使目标是ABI,其中任何位模式都是bool的有效对象表示。
对程序员所犯的许多错误,尤其是现代编译器所警告的错误,要有完全的敌意。这就是为什么你应该使用-Wall和fix警告。c++不是一种用户友好的语言,c++中的某些东西可能是不安全的,即使它在你正在编译的目标的asm中是安全的。(例如,signed overflow在c++中是UB,编译器会假设它不会发生,即使在编译2的补体x86时,除非你使用clang/gcc -fwrapv。)
编译时可见的UB总是很危险的,而且真的很难确定(使用链接时间优化)你真的对编译器隐藏了UB,从而可以推断它将生成什么样的asm。
Not to be over-dramatic; often compilers do let you get away with some things and emit code like you're expecting even when something is UB. But maybe it will be a problem in the future if compiler devs implement some optimization that gains more info about value-ranges (e.g. that a variable is non-negative, maybe allowing it to optimize sign-extension to free zero-extension on x86-64). For example, in current gcc and clang, doing tmp = a+INT_MIN doesn't optimize a<0 as always-false, only that tmp is always negative. (Because INT_MIN + a=INT_MAX is negative on this 2's complement target, and a can't be any higher than that.)
因此gcc/clang目前不会回溯到计算输入的范围信息,只会基于无符号溢出假设的结果:以Godbolt为例。我不知道这是否是以用户友好的名义故意“错过”的优化。
Also note that implementations (aka compilers) are allowed to define behaviour that ISO C++ leaves undefined. For example, all compilers that support Intel's intrinsics (like _mm_add_ps(__m128, __m128) for manual SIMD vectorization) must allow forming mis-aligned pointers, which is UB in C++ even if you don't dereference them. __m128i _mm_loadu_si128(const __m128i *) does unaligned loads by taking a misaligned __m128i* arg, not a void* or char*. Is `reinterpret_cast`ing between hardware SIMD vector pointer and the corresponding type an undefined behavior?
GNU C/C++ also defines the behaviour of left-shifting a negative signed number (even without -fwrapv), separately from the normal signed-overflow UB rules. (This is UB in ISO C++, while right shifts of signed numbers are implementation-defined (logical vs. arithmetic); good quality implementations choose arithmetic on HW that has arithmetic right shifts, but ISO C++ doesn't specify). This is documented in the GCC manual's Integer section, along with defining implementation-defined behaviour that C standards require implementations to define one way or another.
编译器开发人员肯定关心实现质量问题;他们通常不会试图让编译器故意充满敌意,但利用c++中所有的UB漏洞(除了他们选择定义的那些)来更好地优化有时几乎是难以区分的。
脚注1:上面的56位可以是被调用方必须忽略的垃圾,就像通常比寄存器窄的类型一样。
(其他abi在这里确实做出了不同的选择。有些确实要求窄整数类型在传递给函数或从函数返回时为零或符号扩展以填充寄存器,如MIPS64和PowerPC64。请参阅此x86-64回答的最后一部分,其中比较了与早期isa的比较。)
例如,调用方可能在RDI中计算了& 0x01010101,并在调用bool_func(a&1)之前将其用于其他事情。调用方可以优化掉&1,因为它已经将低字节作为和edi的一部分进行了优化,0x01010101,并且它知道被调用方需要忽略高字节。
或者,如果一个bool值作为第3个参数传递,可能调用者会优化代码大小,使用mov dl, [mem]而不是movzx edx, [mem]来加载它,从而节省1个字节,代价是对RDX旧值的错误依赖(或其他部分寄存器效应,取决于CPU型号)。或者对于第一个参数,mov dil, byte [r10]而不是movzx edi, byte [r10],因为两者都需要一个REX前缀。
这就是为什么clang在Serialize中发出movzx eax, dil,而不是sub eax, edi。(对于整数参数,clang违反了ABI规则,而是依赖于gcc和clang未记录的行为,将0或符号扩展为32位的窄整数。在为x86-64 ABI的指针添加32位偏移时,是否需要一个符号或零扩展?
所以我感兴趣的是,它对bool不做同样的事情。)
脚注2:在分支之后,您将只有一个4字节的立即移动存储,或者4字节+ 1字节的存储。长度隐含在存储宽度+偏移量中。
OTOH, glibc memcpy将执行两个4字节的加载/存储,并根据长度进行重叠,因此这确实最终使整个事情在布尔上没有条件分支。参见glibc的memcpy/memmove中的L(between_4_7):块。或者至少,对memcpy分支中的任意一个布尔值都采用相同的方法来选择块大小。
如果是内联,你可以使用2x move -immediate + cmov和一个条件偏移量,或者你可以把字符串数据留在内存中。
或者如果调优英特尔冰湖(与快速短REP MOV功能),一个实际的代表movsb可能是最佳的。glibc memcpy可能会在具有该特性的cpu上开始使用rep movsb,以节省大量分支。
用于检测UB和未初始化值的使用的工具
在gcc和clang中,您可以使用-fsanitize=undefined进行编译,以添加运行时插装,该插装将对运行时发生的UB发出警告或错误。不过,这不会捕获单元化变量。(因为它不会增加类型大小来为“未初始化的”位腾出空间)。
参见https://developers.redhat.com/blog/2014/10/16/gcc-undefined-behavior-sanitizer-ubsan/
To find usage of uninitialized data, there's Address Sanitizer and Memory Sanitizer in clang/LLVM. https://github.com/google/sanitizers/wiki/MemorySanitizer shows examples of clang -fsanitize=memory -fPIE -pie detecting uninitialized memory reads. It might work best if you compile without optimization, so all reads of variables end up actually loading from memory in the asm. They show it being used at -O2 in a case where the load wouldn't optimize away. I haven't tried it myself. (In some cases, e.g. not initializing an accumulator before summing an array, clang -O3 will emit code that sums into a vector register that it never initialized. So with optimization, you can have a case where there's no memory read associated with the UB. But -fsanitize=memory changes the generated asm, and might result in a check for this.)
它将允许复制未初始化的内存,以及简单的逻辑和算术操作。通常,MemorySanitizer会无声地跟踪内存中未初始化数据的传播,并根据未初始化值在代码分支被获取(或未获取)时报告警告。
MemorySanitizer实现了Valgrind (Memcheck工具)中的一个功能子集。
它应该适用于这种情况,因为使用从未初始化的内存计算的长度调用glibc memcpy将(在库内部)导致基于长度的分支。如果它内联了一个只使用cmov、索引和两个存储的完全无分支版本,那么它可能无法工作。
Valgrind的memcheck也会寻找这类问题,如果程序只是复制未初始化的数据,同样不会抱怨。但它表示,当“条件跳转或移动依赖于未初始化的值”时,它将检测到,以尝试捕捉任何依赖于未初始化数据的外部可见行为。
也许不标记只是一个load的想法是,结构可以有填充,并且复制整个结构(包括填充)与一个宽矢量load/store即使单个成员一次只写入一个也不会出错。在asm级别,关于什么是填充以及什么是实际值的一部分的信息已经丢失。