我知道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
中的任何垃圾值都无关紧要,因为无论如何它都会评估为true或false。”我已经设置了Compiler Explorer example来显示反汇编中的问题,这里是完整的示例。注意:为了解决该问题,我发现有效的组合是通过将Clang 5.0.0与-O2优化一起使用。
#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, whichString, 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”并以这种方式使用?
还是这是一种实现定义的情况,在这种情况下,实现假设其所有 bool 都只会包含0或1,并且其他任何值都是未定义的行为范围?
最佳答案
是的,ISO C++允许(但不是必需)实现方案来做出此选择。
但也请注意,ISO C++允许编译器发出代码,如果程序遇到UB,则该代码有意崩溃(例如,使用非法指令)。作为帮助您发现错误的方法。 (或者因为它是DeathStation9000。仅严格遵守标准并不能使C++实现对任何实际目的都有用)。 因此,即使在读取未初始化的uint32_t
的类似代码上,ISO C++也会允许编译器制作崩溃(出于完全不同的原因)的asm。 即使这是固定的布局类型,也没有陷阱表示。
关于实际实现的工作方式,这是一个有趣的问题,但请记住,即使答案不同,您的代码仍将是不安全的,因为现代C++并不是汇编语言的可移植版本。
您正在编译x86-64 System V ABI,它指定通过寄存器1低8位中的位模式bool
和false=0
来表示作为寄存器中函数argt的true=1
。在内存中,bool
是1字节类型,必须再次具有0或1的整数值。
(ABI是同一平台的编译器都同意的一组实现选择,以便他们可以编写可调用彼此功能的代码,包括类型大小,结构布局规则和调用约定。)
ISO C++没有指定它,但是这个ABI决定很普遍,因为它使bool-> int转换便宜(只是零扩展)。我不知道对于任何体系结构(不仅仅是x86),任何ABI都不会让编译器为bool
假定0或1。它允许像!mybool
和xor eax,1
这样的优化来翻转低位Any possible code that can flip a bit/integer/bool between 0 and 1 in single CPU instruction。或将a&&b
编译为按位与的bool
类型。一些编译器实际上确实利用Boolean values as 8 bit in compilers. Are operations on them inefficient?的优势。
通常,as-if规则允许编译器利用为编译的目标平台上正确的东西,因为最终结果将是可执行代码,该代码实现与C++源代码相同的外部可见行为。 (由于“未定义行为”对实际上“外部可见”的所有限制:不是使用调试器,而是使用格式良好/合法的C++程序中的另一个线程。)
绝对可以允许编译器在其代码生成中充分利用ABI保证,并像您发现的那样将strlen(whichString)
优化为5U - boolValue
。 (顺便说一句,这种优化是一种聪明的选择,但是对于将立即数据存储为memcpy
的分支和内联而言,这可能是近视的。)
或者,编译器可以创建一个指针表,并使用bool
的整数值对其进行索引,并再次假定它为0或1。(This possibility is what @Barmar's answer suggested。)
您的启用了优化的__attribute((noinline))
构造函数导致clang仅从堆栈中加载一个字节用作uninitializedBool
。它使用main
(更小,并且出于各种原因,与push rax
一样有效)为sub rsp, 8
中的对象留出了空间,因此,AL在进入main
时在AL中留下的任何垃圾都是它用于uninitializedBool
的值。这就是为什么您实际上得到的值不只是0
的原因。5U - random garbage
可以轻松地包装为较大的无符号值,从而导致memcpy进入未映射的内存。目标位于静态存储中,而不是堆栈中,因此您不会覆盖返回地址或其他内容。
其他实现可能会做出不同的选择,例如false=0
和true=any non-zero value
。然后,clang可能不会为该特定的UB实例生成导致崩溃的代码。 (但是如果愿意的话,还是可以允许的。)我不知道任何实现会选择x86-64为bool
做的其他事情,但是C++标准允许许多人做不到甚至不想做的事情在类似于当前CPU的硬件上进行操作。
在检查或修改bool
的对象表示形式时,ISO C++会保留未指定的内容。 (例如,通过memcpy
将bool
转换为unsigned char
,您可以这样做,因为char*
可以对任何事物进行别名。而且unsigned char
保证没有填充位,因此C++标准确实允许您在不使用任何UB的情况下对对象表示形式进行十六进制转储。Pointer-当然,转换以复制对象表示形式与分配char foo = my_bool
是不同的,因此不会发生 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++,而无需假设函数的独立非内联版本在asm中的外观。
另一个有趣的示例是Why does unaligned access to mmap'ed memory sometimes segfault on AMD64?。 x86不会在未对齐的整数上出错,对吗?那么,为什么未对齐的uint16_t*
会成为问题?因为alignof(uint16_t) == 2
和违反该假设会导致在使用SSE2自动矢量化时出现段错误。
另请参见 What Every C Programmer Should Know About Undefined Behavior #1/3(由clang开发人员撰写的文章)。
关键点:如果编译器在编译时注意到UB,即使针对任何位模式都是bool
的有效对象表示形式的ABI,它也可能“破坏”(发出令人惊讶的asm)导致UB的代码路径。
期望程序员对许多错误产生完全敌意,尤其是现代编译器警告的事物。这就是为什么您应该使用-Wall
并修复警告的原因。 C++不是一种用户友好的语言,C++中的某些内容即使在您要为其编译的目标上的asm中是安全的,也可能是不安全的。 (例如,在C++中,签名溢出是UB,编译器会假定它不会发生,即使使用2的补码x86进行编译,除非您使用clang/gcc -fwrapv
。)
编译时可见的UB总是很危险的,并且很难(通过链接时优化)确保您确实从编译器中隐藏了UB,从而可以推断出它将生成哪种asm。
不要太夸张;通常,编译器确实会让您无所适从,并且即使您使用的是UB,也会发出您期望的代码。但是,如果编译器开发人员实现某种优化以获取有关值范围的更多信息(例如,变量为非负数,也许允许其优化符号扩展以在x86上释放零扩展),那么将来可能会成为问题。 64)。例如,在当前的gcc和clang中,执行tmp = a+INT_MIN
不会将a<0
优化为always-false,而只是tmp
始终为负。 (因为INT_MIN
+ a=INT_MAX
在此2的补码目标上为负数,因此a
不能高于此值。)
因此,gcc/clang当前仅在基于无符号溢出的假设的结果上回退以得出计算输入的范围信息:example on Godbolt。我不知道这是否是出于用户友好或其他目的而故意“错过”了优化。
还要注意,允许实现(也称为编译器)定义ISO C++留下未定义的行为。例如,所有支持Intel内在函数的编译器(例如用于手动SIMD矢量化的_mm_add_ps(__m128, __m128)
)都必须允许形成未对齐的指针,即使您不取消引用它们,C++中的UB也是如此。 __m128i _mm_loadu_si128(const __m128i *)
通过获取未对齐的__m128i*
arg而不是void*
或char*
来执行未对齐的加载。 Is `reinterpret_cast`ing between hardware vector pointer and the corresponding type an undefined behavior?
GNU C/C++还定义了将负号负数(即使没有-fwrapv
)左移的行为,也与常规的有符号溢出的UB规则分开。 (This is UB in ISO C++,而有符号数的右移是实现定义的(逻辑与算术);优质实现在具有算术右移的硬件上选择算术,但未指定ISO C++)。这在the GCC manual's Integer section中进行了记录,并定义了由实现标准定义的行为,C语言标准要求这些实现要求实现定义一种或另一种方式。
肯定有编译器开发人员关心的实现质量问题。他们通常不会试图使编译器故意成为敌对对象,但是有时利用C++中的所有UB漏洞(他们选择定义的漏洞除外)来更好地进行优化几乎是无法区分的。
脚注1 :通常,对于小于寄存器的类型,高56位可能是被调用者必须忽略的垃圾。
(,其他ABI确实在中做出了不同的选择。有些ABI要求将窄整数类型传递给MIPS64和PowerPC64之类的函数或从其中返回时,将其进行零或符号扩展以填充寄存器。请参见this x86-64 answer which compares vs. those earlier ISAs的最后一部分。)
例如,在调用a & 0x01010101
之前,调用者可能已经在RDI中计算了bool_func(a&1)
并将其用于其他用途。调用者可以优化&1
,因为它已经将其作为and edi, 0x01010101
的一部分对低字节进行了处理,并且它知道被调用方需要忽略高字节。
或者,如果将 bool 值作为第三个arg传递,则优化代码大小的调用程序可能会使用mov dl, [mem]
而不是movzx edx, [mem]
加载它,从而以错误依赖于RDX的旧值(或其他部分寄存器效果)的方式节省了1个字节,具体取决于CPU型号)。或对于第一个arg,用mov dil, byte [r10]
而不是movzx edi, byte [r10]
,因为两者都需要REX前缀。
这就是为什么clang在movzx eax, dil
中发出Serialize
而不是sub eax, edi
的原因。 (对于整数args,clang违反了此ABI规则,而是取决于gcc和clang的未记录行为,将窄整数零扩展或符号扩展为32位。Is a sign or zero extension required when adding a 32bit offset to a pointer for the x86-64 ABI?
因此,我很感兴趣地看到它对bool
的作用不同。
脚注2:分支后,您将立即拥有一个4字节的mov
-immediate或一个4字节+ 1字节的存储区。长度隐含在商店宽度+偏移量中。
OTOH,glibc memcpy将执行两个4字节的加载/存储,它们的重叠取决于长度,因此,这的确使整个对象摆脱了 bool 值上的条件分支。请参阅glibc的memcpy/memmove中的 L(between_4_7):
block 。至少,对于memcpy分支中的 bool 值,以相同的方式选择块大小。
如果进行内联,则可以使用2x mov
-immediate + cmov
和条件偏移量,或者可以将字符串数据保留在内存中。
或者,如果针对Intel Ice Lake(with the Fast Short REP MOV feature)进行调整,则实际的rep movsb
可能是最佳选择。对于具有该功能的CPU,glibc memcpy
可能会开始将rep movsb
用于较小的尺寸,从而节省了大量分支。
检测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会发出求和后的代码到从未初始化的 vector 寄存器中。因此,通过优化,您可能会遇到没有与UB相关联的内存读取的情况但是-fsanitize=memory
更改了生成的asm,可能会对此进行检查。)
It will tolerate copying of uninitialized memory, and also simple logic and arithmetic operations with it. In general, MemorySanitizer silently tracks the spread of uninitialized data in memory, and reports a warning when a code branch is taken (or not taken) depending on an uninitialized value.
MemorySanitizer implements a subset of functionality found in Valgrind (Memcheck tool).
在这种情况下应该起作用,因为使用未初始化内存计算出的
memcpy
调用glibc length
会(在库内部)导致基于length
的分支。如果它内联了仅使用cmov
,索引和两个存储的完全无分支版本,则可能无法正常工作。Valgrind's
memcheck
也会寻找这种问题,如果程序只是在未初始化的数据周围复制,ojit_a也不会提示。但是它说它将检测到“有条件的跳跃或移动取决于未初始化的值”,以试图捕获任何依赖于未初始化数据的外部可见行为。可能不标记负载的想法可能是结构可以具有填充,并且即使单个成员一次只写入一个成员,复制具有较大 vector 负载/存储的整个结构(包括填充)也不是错误。在asm级别上,有关填充内容以及实际上是值的一部分的信息已丢失。
关于c++ - C++标准是否允许未初始化的bool使程序崩溃?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/54120862/