[英]Does the C++ standard allow for an uninitialized bool to crash a program?
我知道 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"
对我来说看起来足够安全,我假设,“ boolValue
中的任何垃圾值都无关紧要,因为无论如何它都会评估为 true 或 false。”
我已经设置了一个Compiler Explorer 示例,它显示了反汇编中的问题,这里是完整的示例。 注意:为了重现这个问题,我发现有效的组合是使用带有 -O2 优化的 Clang 5.0.0。
#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”的内部数字表示并以这种方式使用它?
或者这是实现定义的情况,在这种情况下,实现假设其所有布尔值只包含 0 或 1,并且任何其他值都是未定义的行为领域?
但另请注意,如果程序遇到UB,ISO C ++允许编译器发出故意崩溃的代码(例如,使用非法指令),例如,作为帮助您查找错误的方法。 (或者因为它是一个DeathStation 9000.严格遵守是不足以使C ++实现对任何真正的目的都有用)。 因此,即使在读取未初始化的uint32_t
类似代码上,ISO C ++也允许编译器使asm崩溃(出于完全不同的原因)。 即使这需要是一个没有陷阱表示的固定布局类型。
这是一个关于真实实现如何工作的有趣问题,但请记住,即使答案不同,您的代码仍然不安全,因为现代C ++不是汇编语言的可移植版本。
你正在编译为x86-64的系统V ABI ,它指定了一个bool
作为在寄存器中的功能ARG由位模式表示的false=0
和true=1
在寄存器1的低8位。 在内存中, bool
是1字节类型,同样必须具有0或1的整数值。
(ABI是一组实现选择,同一平台的编译器同意这样做,因此他们可以创建调用彼此函数的代码,包括类型大小,结构布局规则和调用约定。)
ISO C ++没有指定它,但是这个ABI决定很普遍,因为它使bool-> int转换变得便宜(只是零扩展) 。 对于任何体系结构(不仅仅是x86),我都不知道任何不让编译器为bool
假设为0或1的ABI。 它允许像!mybool
和xor eax,1
这样的优化来翻转低位: 在单CPU指令中可以在0和1之间翻转位/整数/布尔值的任何可能代码 。 或者将a&&b
编译为bool
类型的按位AND。 有些编译器确实在编译器中利用布尔值作为8位。 对他们的操作是否效率低下? 。
通常,as-if规则允许编译器利用正在编译的目标平台上的事物 ,因为最终结果将是实现与C ++源相同的外部可见行为的可执行代码。 (具有Undefined Behavior放置在实际上“外部可见”的所有限制:不是使用调试器,而是来自格式良好/合法的C ++程序中的另一个线程。)
绝对允许编译器在其代码中充分利用ABI保证,并使您发现的代码优化strlen(whichString)
5U - boolValue
。 (顺便说一句,这种优化有点聪明,但可能是近视与分支和内联memcpy
作为即时数据存储2。 )
或者编译器可以创建一个指针表并用bool
的整数值对其进行索引,再次假设它是0或1.( 这种可能性是@Barmar的答案所建议的 。)
启用了优化的__attribute((noinline))
构造函数导致clang只是从堆栈加载一个字节以用作uninitializedBool
。 它使用push rax
main
对象创建了空间(由于各种原因,它与sub rsp, 8
一样有效sub rsp, 8
),因此在进入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 。复制对象表示的指针转换与赋值char foo = my_bool
,当然,布局化为0或1也不会发生,你将获得原始对象表示。)
您使用noinline
从编译器中部分 “隐藏”了UB 。 但是,即使它不是内联的,过程间优化仍然可以使函数的版本依赖于另一个函数的定义。 (首先,clang正在创建一个可执行文件,而不是一个可以发生符号插入的Unix共享库。其次, class{}
定义中的定义所以所有翻译单元必须具有相同的定义。与inline
关键字一样。)
所以编译器只能发出一个ret
或ud2
(非法指令)作为main
的定义,因为从main
顶部开始执行的路径不可避免地遇到Undefined Behavior。 (如果编译器决定遵循通过非内联构造函数的路径,编译器可以在编译时看到。)
任何遇到UB的程序都是完全未定义的。 但是在函数或if()
分支中从不实际运行的UB不会破坏程序的其余部分。 在实践中,这意味着编译器可以决定发出非法指令或ret
,或者不发出任何内容并进入下一个块/函数,以获得可以在编译时证明包含或导致UB的整个基本块。
GCC和锵在实践中也确实有时发出ud2
上UB,而不是甚至还试图生成,使没有意义的执行路径代码。 或者对于非void
函数结束的情况,gcc有时会省略ret
指令。 如果你认为“我的函数只会返回RAX中的任何垃圾”,你就会非常错误。 现代C ++编译器不再将语言视为可移植汇编语言。 你的程序实际上必须是有效的C ++,而不假设你的函数的独立非内联版本可能在asm中看起来如何。
另一个有趣的例子是为什么对mmap的内存进行未对齐访问有时会在AMD64上出现段错误? 。 x86对未对齐的整数没有错,对吧? 那么为什么一个错位的uint16_t*
会成为一个问题呢? 因为alignof(uint16_t) == 2
,并且违反该假设导致在使用SSE2自动向量化时出现段错误。
另请参阅 每个C程序员应该知道的关于未定义行为的内容#1/3 ,这是clang开发人员的一篇文章。
bool
的有效对象表示。 期待程序员对许多错误的完全敌意,特别是现代编译器警告的事情。 这就是你应该使用-Wall
并修复警告的原因。 C ++不是一种用户友好的语言,即使在你编译的目标上asm是安全的,C ++中的东西也是不安全的。 (例如,签名溢出是C ++中的UB,编译器会认为它不会发生,即使编译2的补码x86,除非你使用clang/gcc -fwrapv
。)
编译时可见的UB总是危险的,并且很难确定(使用链接时优化)你真的从编译器中隐藏了UB,因此可以推断出它会产生什么样的asm。
不要过于戏剧化; 通常编译器会让你逃避一些事情并发出像你期望的那样的代码,即使是某些东西是UB。 但是,如果编译器开发人员实现一些优化以获得关于值范围的更多信息(例如,变量是非负的,可能允许它优化符号扩展以在x86上释放零扩展),那么将来可能会出现问题。 64)。 例如,在当前的gcc和clang中,执行tmp = a+INT_MIN
并不tmp = a+INT_MIN
a<0
始终为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而不是void*
或char*
执行未对齐的加载。 在硬件向量指针和相应类型之间`reinterpret_cast`是一个未定义的行为吗?
GNU C / C ++还定义了左移一个负的有符号数(即使没有-fwrapv
)的行为,与普通的有符号溢出UB规则分开。 ( 这是ISO C ++中的UB ,而有符号数的右移是实现定义的(逻辑与算术);高质量的实现选择具有算术右移的HW算术,但ISO C ++没有指定)。 这在GCC手册的Integer部分中有记录 ,同时定义了C标准要求实现以这种或那种方式定义的实现定义的行为。
编译器开发人员肯定会关注实现的质量问题; 他们通常不会试图制造故意不利的编译器,但是利用C ++中的所有UB坑洼(除了他们选择定义的那些)来进行更好的优化,有时几乎无法区分。
脚注1 :高56位可以是被调用者必须忽略的垃圾,通常用于比寄存器窄的类型。
( 其他的ABI在这里做的做出不同的选择 。有些人需要窄的整数类型是零或符号扩展传递时,或者从函数返回,就像MIPS64和PowerPC64填补寄存器见的最后一节这x86-64的答案与早期的ISA相比较 。)
例如,在调用bool_func(a&1)
之前,调用者可能已在RDI中计算a & 0x01010101
并将其用于其他内容。 调用者可以优化掉&1
因为它已经作为and edi, 0x01010101
一部分对低字节进行了and edi, 0x01010101
,并且它知道被调用者需要忽略高字节。
或者如果bool作为第3个arg传递,也许优化代码大小的调用者使用mov dl, [mem]
而不是movzx edx, [mem]
加载它,节省1个字节,代价是对旧的错误依赖RDX的值(或其他部分寄存器效果,取决于CPU型号)。 或者对于第一个arg, mov dil, byte [r10]
而不是movzx edi, byte [r10]
,因为两者都需要REX前缀。
这就是为什么clang在Serialize
发出movzx eax, dil
,而不是sub eax, edi
。 (对于整数args,clang违反了这个ABI规则,而是取决于gcc和clang的未记录行为,将零或符号扩展为窄整数为32位。 当向指针添加32位偏移时,是否需要符号或零扩展x86-64 ABI?所以我有兴趣看到它对bool
没有做同样的事情。)
脚注2:分支后,你只需要一个4字节的mov
-immediate,或一个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
可能是最佳的。 glibc memcpy
可能会开始在具有该功能的CPU上使用rep movsb
来实现小尺寸,从而节省了大量的分支。
在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工具)中的一部分功能。
它应该适用于这种情况,因为使用从未初始化的内存计算的length
调用glibc memcpy
将(在库内)导致基于length
的分支。 如果它内联一个完全无cmov
版本,只使用了cmov
,索引和两个商店,它可能没有用。
Valgrind的memcheck
也会寻找这种问题,如果程序只是简单地复制未初始化的数据,也不会抱怨。 但它表示它会检测“条件跳转或移动取决于未初始化的值”,以尝试捕获依赖于未初始化数据的任何外部可见行为。
也许不标记一个加载背后的想法是结构可以有填充,并且使用宽向量加载/存储复制整个结构(包括填充)不是错误,即使每个成员一次只写一个。 在asm级别,有关填充内容和实际值的一部分的信息已丢失。
允许编译器假定作为参数传递的布尔值是有效的布尔值(即已初始化或转换为true
或false
)。 true
值不必与整数1相同 - 实际上,可以有各种表示true
和false
- 但参数必须是这两个值之一的有效表示,其中“有效表示”是实现定义的。
因此,如果您未能初始化bool
,或者如果您通过某个不同类型的指针成功覆盖它,那么编译器的假设将是错误的,并且随后会出现未定义的行为。 你被警告过:
50)以本国际标准描述的方式将bool值用作“未定义”,例如通过检查未初始化的自动对象的值,可能会使其表现为既不是真也不是假。 (§6.9.1第6段脚注,基本类型)
函数本身是正确的,但在测试程序中,调用函数的语句通过使用未初始化变量的值导致未定义的行为。
该错误在调用函数中,可以通过代码检查或调用函数的静态分析来检测。 使用编译器资源管理器链接,gcc 8.2编译器会检测错误。 (也许你可以提交针对clang的bug报告,它没有发现问题)。
未定义的行为意味着任何事情都可能发生,其中包括程序在触发未定义行为的事件之后崩溃几行。
NB。 答案“未定义的行为会导致_____吗?” 总是“是”。 这就是未定义行为的定义。
bool只允许保存值0
或1
,生成的代码可以假定它只保存这两个值中的一个。 在赋值中为三元生成的代码可以使用该值作为指向两个字符串的指针数组的索引,即它可能转换为类似的内容:
// the compile could make asm that "looks" like this, from your source
const static char *strings[] = {"false", "true"};
const char *whichString = strings[boolValue];
如果boolValue
未初始化,它实际上可以保存任何整数值,这将导致访问strings
数组的边界之外。
总结你的问题很多,你问的是C ++标准是否允许编译器假设bool
只能有一个'0'或'1'的内部数字表示并以这种方式使用它?
该标准没有说明bool
的内部表示。 它只定义了将bool
转换为int
时会发生什么(反之亦然)。 大多数情况下,由于这些完整的转换(以及人们非常依赖它们的事实),编译器将使用0和1,但它不必(尽管它必须遵守它使用的任何较低级别ABI的约束) )。
所以,编译器在看到bool
有权考虑所说的bool
包含' true
'或' false
'位模式中的任何一种并做任何感觉。 因此,如果true
和false
的值分别为1和0,则确实允许编译器将strlen
优化为5 - <boolean value>
。 其他有趣的行为是可能的!
正如在此重复陈述的那样,未定义的行为具有未定义的结果。 包括但不仅限于
C++ 标准是否允许编译器假设 bool 只能具有“0”或“1”的内部数字表示并以这种方式使用它?
是的,如果它对任何人有用,这里是另一个发生在我身上的真实例子。
我曾经花了几个星期来追踪大型代码库中的一个不起眼的错误。 有几个方面使它具有挑战性,但根本原因是类变量的未初始化布尔成员。
有一个测试涉及一个涉及这个成员变量的复杂表达式:
if(COMPLICATED_EXPRESSION_INVOLVING(class->member)) {
...
}
我开始怀疑这个测试在它应该评估的时候没有评估“真实”。 我不记得在调试器下运行是否不方便,或者我是否不信任调试器,或者什么,但我选择了使用一些调试打印输出来扩充代码的蛮力技术:
printf("%s\n", COMPLICATED_EXPRESSION_INVOLVING(class->member) ? "yes" : "no");
if(COMPLICATED_EXPRESSION_INVOLVING(class->member)) {
printf("doing the thing\n");
...
}
想象一下当代码打印“ no
”后跟“ doing the thing
”时我的惊讶。
反汇编显示,有时,编译器(即 gcc)通过将布尔成员与 0 进行比较来测试它,但其他时候,它使用的是测试最低有效位指令。 未初始化的布尔变量恰好包含值 2。因此,在机器语言中,测试等效于
if(class->member != 0)
成功,但测试相当于
if(class->member % 2 != 0)
失败的。 该变量实际上同时是真和假! 如果这不是未定义的行为,我不知道是什么!
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.