C 程序员应该知道的未定义行为 #3/3
在 第一部分 中,我们介绍了 C 语言中的未定义行为,并展示了一些 C 语言比“安全”语言性能更高的案例。在 第二部分 中,我们探讨了这种行为导致的令人意外的错误,以及许多程序员对 C 语言的普遍误解。在本文中,我们将讨论编译器在提供关于这些陷阱的警告方面面临的挑战,并介绍 LLVM 和 Clang 提供的一些功能和工具,以帮助实现性能提升,同时消除一些意外情况。翻译语言:日语
为什么基于未定义行为优化时无法发出警告?
人们经常问为什么编译器在利用未定义行为进行优化时不发出警告,因为任何这种情况下都可能是用户代码中的错误。这种方法的挑战在于:1) 可能会产生过多的警告而无用,因为这些优化在没有错误的情况下会一直启动,2) 仅在用户需要时产生这些警告非常困难,3) 我们没有很好的方法来表达(给用户)一系列优化如何结合起来,以暴露正在被优化的机会。让我们依次来看一下这些问题。
让它真正有用“非常困难”
让我们看一个例子:即使无效类型转换错误经常被基于类型的别名分析暴露出来,但在优化“zero_array”(来自 我们系列文章的第 1 部分)时,发出“优化器假设 P 和 P[i] 不重叠”的警告将无济于事。
float *P;
void zero_array() {
int i;
for (i = 0; i < 10000; ++i)
P[i] = 0.0f;
}
除了这种“误报”问题之外,还有一个逻辑问题是,优化器没有足够的信息来生成合理的警告。首先,它是在代码的抽象表示 (LLVM IR) 上工作的,这种表示与 C 语言有很大不同,其次,编译器高度分层,以至于试图“将加载操作从循环中提升到 P”的优化不知道是 TBAA 分析解决了指针别名查询。是的,这是本文“编译器人员抱怨”的部分:),但它确实是一个难题。
仅在用户需要时生成这些警告很困难
Clang 为未定义行为的简单明了的情况(例如超出范围的移位,如“x << 421”)实现了许多警告。你可能会认为这是一件简单明了的事,但事实证明这很难,因为 人们不想在死代码中收到关于未定义行为的警告(另见 重复项)。
这种死代码可以采取多种形式:当传递一个常量时,以一种奇怪的方式展开的宏,我们甚至收到了抱怨,说我们在需要 控制流分析 来证明 switch 语句中的情况不可达的情况下发出警告。C 语言中的 switch 语句 并非总是正确结构化的,这一点并没有帮助。
Clang 中解决这个问题的方法是不断完善处理“运行时行为”警告的基础设施,以及代码来修剪这些警告,这样如果我们后来发现该代码块不可执行,就不会报告它们。但这有点像与程序员之间的军备竞赛,因为总是会有一些我们没有预料到的习语,在前端进行这种操作意味着它无法捕获所有用户希望捕获的情况。
解释一系列暴露机会的优化
如果前端在生成良好警告方面存在挑战,那么我们也许可以从优化器中生成它们!这里生成有用警告的最大问题是数据跟踪。编译器优化器包含数十个优化过程,每个过程都会在代码通过时进行修改,以对其进行规范化或(希望)使其运行更快。如果内联器决定内联一个函数,这可能会暴露其他机会,例如 优化掉“X*2/2”。
虽然我给出了 相对简单且独立的示例 来演示这些优化,但大多数它们起作用的情况是在宏实例化、内联以及编译器执行的其他抽象消除活动中出现的代码。现实情况是,人类很少直接编写这种愚蠢的东西。对于警告来说,这意味着为了将问题回传给用户代码,警告必须准确地重建编译器如何获得正在处理的中间代码。我们需要能够说出类似以下的话:
警告:经过 3 级内联(可能跨越文件,使用链接时优化),一些公共子表达式消除,在将此项提升到循环之外并证明这 13 个指针不重叠之后,我们发现了一个你执行了未定义操作的情况。这可能是因为你的代码中存在错误,也可能是因为你使用了宏和内联,并且无效代码在动态上不可达,但我们无法证明它是死代码。
不幸的是,我们根本没有内部跟踪基础设施来生成它,即使我们有,编译器也没有足够好的用户界面来向程序员表达这一点。
最终,未定义行为对优化器很有价值,因为它表示“此操作无效 - 你可以假设它永远不会发生”。在像“*P”这样的情况下,这使优化器能够推断 P 不能为 NULL。在像“*NULL”这样的情况下(例如,经过一些常量传播和内联之后),这使优化器能够知道代码一定不可达。这里重要的区别是,由于无法解决停机问题,编译器无法知道代码实际上是死代码(如 C 标准所述)还是在(可能很长)一系列优化之后暴露的错误。由于没有通用的好方法来区分两者,因此生成的几乎所有警告都是误报(噪音)。
Clang 处理未定义行为的方法
鉴于我们在处理未定义行为方面处于这种糟糕的状态,你可能会想知道 Clang 和 LLVM 在努力改善这种情况方面做了些什么。我已经提到了其中的一些:Clang 静态分析器、Klee 项目 以及-fcatch-undefined-behavior标志是用于追踪这些错误的某些类别的有用工具。问题是这些工具不像编译器那样被广泛使用,因此我们在编译器中直接做任何事情都比在这些其他工具中做任何事情更有效。但请记住,编译器受限于没有动态信息,并且受限于它可以在不消耗大量编译时间的情况下做的事情。
Clang 改进世界代码的第一步是默认情况下启用比其他编译器更多的警告。虽然一些开发人员很有纪律,并且使用“-Wall -Wextra”(例如),但许多人不知道这些标志,或者根本不使用它们。默认情况下启用更多警告,可以更频繁地捕获更多错误。
第二步是 Clang 为许多类别的未定义行为(包括对空值的解引用、过大的移位等)生成警告,这些行为在代码中很明显,可以捕获一些常见错误。上面提到了其中的一些注意事项,但这些方法在实践中似乎效果很好。
第三步是,LLVM 优化器通常比它可能做到的对未定义行为的利用少得多。虽然标准说,未定义行为的任何实例对程序的影响是完全不受约束的,但这并不是一个特别有用或对开发人员友好的行为。相反,LLVM 优化器以几种不同的方式处理这些优化(链接描述了 LLVM IR 的规则,而不是 C 语言,抱歉!)。
- 如果有一种好的方法,未定义行为的某些情况会被静默地转换为隐式陷阱操作。例如,使用 Clang,以下 C++ 函数
int *foo(long x) {
return new int[x];
}
编译成以下 X86-64 机器代码__Z3fool:
movl $4, %ecx
movq %rdi, %rax
mulq %rcx
movq $-1, %rdi # Set the size to -1 on overflow
cmovnoq %rax, %rdi # Which causes 'new' to throw std::bad_alloc
jmp __Znam
而不是 GCC 生成的代码__Z3fool:
salq $2, %rdi
jmp __Znam # Security bug on overflow!
这里不同的是,我们决定投入一些周期来防止可能出现的 严重的整数溢出错误,这种错误会导致缓冲区溢出和漏洞利用(operator new 通常相当昂贵,因此开销几乎不会被注意到)。GCC 的开发人员已经 至少从 2005 年开始就意识到这个问题了,但在撰写本文时还没有修复它。 - 在 对未定义值进行运算 的算术运算被认为会产生一个未定义值,而不是产生未定义行为。区别在于,未定义值不会格式化你的硬盘驱动器或产生其他不良影响。当算术运算在任何可能的未定义值的实例下都会产生相同的输出位时,会发生一个有用的改进。例如,优化器假设“undef & 1”的结果的高位为零,仅将低位视为未定义。这意味着 ((undef & 1) >> 1) 在 LLVM 中被定义为 0,而不是未定义。
- 动态执行未定义操作(例如带符号整数溢出)的算术运算会生成一个逻辑 陷阱值,该值会破坏基于它的任何计算,但不会破坏你的整个程序。这意味着未定义操作下游的逻辑可能会受到影响,但你的整个程序不会被破坏。这就是为什么优化器最终会删除对未初始化变量进行操作的代码,例如。
- 对空值的存储和通过空指针调用会被转换为 __builtin_trap() 调用(在 x86 上会转换为像“ud2”这样的陷阱指令)。这些操作在优化代码中经常出现(作为内联和常量传播等其他转换的结果),我们以前只是删除包含它们的代码块,因为它们“显然不可达”。
虽然(从迂腐的语言律师的角度来看)这是严格正确的,但我们很快发现人们偶尔会解引用空指针,并且让代码执行只是落入下一个函数的顶部,会让问题难以理解。从性能的角度来看,暴露这些操作的最重要方面是压制下游代码。因此,clang 将它们转换为运行时陷阱:如果其中一个操作实际上被动态地到达,程序会立即停止并可以进行调试。这样做的缺点是我们稍微增加了代码的膨胀,因为有了这些操作以及控制它们谓词的条件。 - 优化器确实会尽力“做正确的事情”,当程序员的意图很明显时(例如,代码执行“*(int*)P”时,P 是指向浮点数的指针)。这在许多常见情况下都有帮助,但你真的不应该依赖它,而且有很多你可能认为是“显而易见”的例子,但在对你的代码应用了一系列转换之后就不再是“显而易见”的了。
- 不属于任何这些类别的优化,例如第 1 部分中的 zero_array 和 set/call 示例,会像描述的那样进行优化,即静默地进行,没有任何提示给用户。我们这样做是因为我们没有有用的信息要提供,而且对于(有错误的)现实世界代码来说,被这些优化破坏的情况非常罕见。
我们能改进的一个主要方面是陷阱插入。我认为添加一个(默认情况下关闭)警告标志会很有趣,它会在优化器生成陷阱指令时发出警告。这对于某些代码库来说会非常嘈杂,但对于其他代码库来说可能很有用。这里的第一个限制因素是使优化器产生警告的基础设施工作:除非打开调试信息,否则它没有有用的源代码位置信息(但这可以解决)。
另一个更重要的限制因素是,警告不会有任何“跟踪”信息来解释操作是循环展开三次并在四级函数调用中内联的结果。我们最多只能指出原始操作的文件/行/列,这在最简单的情况下是有用的,但在其他情况下很可能非常令人困惑。无论如何,我们并没有将此作为优先事项来实施,因为 a) 它不太可能提供良好的体验 b) 我们将无法默认启用它,以及 c) 实施起来工作量很大。
使用更安全的 C 语言方言(和其他选项)
如果你不关心“最终性能”,最后一个选项是使用各种编译器标志来启用 C 的方言,这些方言消除了这些未定义的行为。例如,使用-fwrapv标志消除了由带符号整数溢出导致的未定义行为(但是,请注意,它不会消除可能的整数溢出安全漏洞)。-fno-strict-aliasing标志禁用基于类型的别名分析,因此你可以随意忽略这些类型规则。如果有需求,我们可以为 Clang 添加一个标志,该标志隐式地将所有局部变量清零,一个在每次使用可变移位计数的移位之前插入“与”操作的标志,等等。不幸的是,没有一种可行的方法可以完全消除 C 中的未定义行为,而不会破坏 ABI 并完全破坏其性能。另一个问题是,你不再编写 C 代码,而是在编写类似的、但不可移植的 C 方言。
如果你不喜欢用不可移植的 C 方言编写代码,那么-ftrapv和-fcatch-undefined-behavior标志(以及之前提到的其他工具)可以成为你武器库中用来追踪这些类型错误的有用武器。在调试构建中启用它们可以成为早期发现相关错误的好方法。如果要构建安全关键应用程序,这些标志在生产代码中也很有用。虽然它们不能保证能发现所有错误,但它们确实能发现错误的一个有用子集。
归根结底,这里真正的问题是 C 语言根本不是一种“安全”语言,而且(尽管它很成功很流行)许多人并不真正理解这种语言的工作原理。在 1989 年标准化之前的几十年里,C 从“位于 PDP 汇编之上的一层薄薄的低级系统编程语言”发展成为“低级系统编程语言,试图通过打破许多人的预期来提供不错的性能”。一方面,这些 C “作弊”几乎总是有效的,代码通常因此而更加高效(在某些情况下,效率高得多)。另一方面,C 作弊的地方往往是人们最意想不到的地方,通常会在最糟糕的时候发生。
C 远不止一个可移植的汇编器,有时甚至会以非常令人惊讶的方式。我希望这次讨论能帮助你了解 C 语言中未定义行为的一些问题,至少从编译器实现者的角度来看。
-Chris Lattner