是的,ISO C ++允许(但不是必需)实施方案来做出此选择。
但也请注意,如果程序遇到UB,ISO C ++允许编译器发出有意崩溃的代码(例如,带有非法指令),例如,它可以帮助您发现错误。(或者是因为它是DeathStation9000。仅严格遵守标准不足以使C ++实现对任何实际目的都有用)。 因此,ISO C ++允许编译器即使在读取未初始化的类似代码时也可以使asm崩溃(由于完全不同的原因)uint32_t
。 即使要求它是没有陷阱表示形式的固定布局类型。
关于实际实现的工作方式,这是一个有趣的问题,但是请记住,即使答案有所不同,您的代码仍将是不安全的,因为现代C ++并不是汇编语言的可移植版本。
您正在为x86-64 System V ABI进行编译,该系统指定a bool
作为寄存器中的函数arg由位模式false=0
和true=1
寄存器1的低8位表示。在内存中,bool
是一种1字节类型,必须再次具有0或1的整数值。
(ABI是同一平台的编译器都同意的一组实现选择,因此它们可以编写可调用彼此功能的代码,包括类型大小,结构布局规则和调用约定。)
ISO C ++没有指定它,但是这个ABI决定很普遍,因为它使bool-> int转换便宜(只是零扩展)。我不知道bool
对于任何体系结构(不仅仅是x86),任何ABI都不会让编译器为假设0或1 。它允许像优化!mybool
与xor 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
。它在由空间为对象main
用push rax
(这是较小的和关于各种原因大约同样有效sub rsp, 8
),所以无论垃圾在AL上进入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的情况下十六进制转储对象表示形式。指针广播以复制对象表示形式与赋值是不同char foo = my_bool
的,因此不会发生布尔化为0或1的情况,而您将获得原始对象表示形式。)
您已经使用编译器部分地 “隐藏”了该执行路径中的UBnoinline
。即使不内联,过程间优化仍然可以使函数的版本取决于另一个函数的定义。(首先,clang是一个可执行文件,而不是一个可以进行符号插入的Unix共享库。其次,该定义中的class{}
定义,因此所有翻译单元必须具有相同的定义。与inline
关键字一样。)
因此,编译器可能只发出一个ret
或ud2
(非法指令)作为的定义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, dil
的Serialize
,而不是,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级别上,有关填充内容以及实际上是值的一部分的信息已丢失。