Finding and Understanding Bugs in C Compilers
原文链接: https://www.cs.cornell.edu/courses/cs6120/2019fa/blog/bug-finding/
Overview
Csmith 是一种工具,可通过随机测试(也称为模糊测试)帮助发现编译器中的错误。 给定多个实现 C 标准的 C 编译器,我们可以通过使用多个编译器编译运行程序并比较它们的输出。
我们可以通过对输出进行多数投票来确定哪些编译器存在“错误”。 尽管这有可能将 bug 归咎于错误的编译器,但不同的编译器会产生不同的输出这一事实就已经令人担忧和警惕了。 这种测试允许相对快速地搜索程序空间以确定很可能有错误的程序。
Discussion Question: Is this kind of random testing still useful in the face of formally verified compilers, e.g., CompCert?
Examples of Wrong Code
为了更具体地了解我们可以期望在哪些类型的程序中找到错误,作者提供了一些实际发现的错误。 通常,这些错误发生在编译器对程序执行优化时。
Wrong Safety Check:
当 c1 和 c2 是常数且 c1 < c2 时,
(x == c1) || (x < c2)
等价于
x < c2
但是,Csmith 在 LLVM 中找到了一个示例,其中 (x == 0) || (x < -3) 被转换为 (x < -3),即使不是 0 < -3。 问题是 LLVM 进行了无符号比较,因此错误地认为转换是安全的。
Wrong Analysis:
static int g[1];
static int *p = &g[0];
static int *q = &g[0];
int foo (void) {
g[0] = 1;
*p = 0;
*p = *q;
return g[0];
}
在这里,GCC 将此程序评估为 1 而不是 0。原因是因为 q 被意外标记为只读,这意味着 p 和 q 不能别名(即使它们确实如此)。 因此,第 7 行看起来像死代码,因为第 8 行只是简单地覆盖了 *p; 这仅在 p 和 q 不别名时才是安全的。 所以第 7 行被 dead store elimination 删除了。
Wrong Analysis:
void foo(void) {
int x;
for (x = 0; x < 5; x++) {
if (x) continue;
if (x) break;
}
printf("%d", x);
}
使用 LLVM 编译导致该程序打印 1 而不是 5。称为“标量演化分析”的循环优化评估循环属性,如归纳变量和最大迭代次数。 优化看到第 5 行并错误地确定循环运行一次,这意味着 x 必须评估为 1。
我发现这种程序很有趣,因为实际上很少有程序员最终会编写一个使用由相同条件保护的 continue 和 break 的函数。 但是,在测试计算程序属性的优化时,测试从未实际运行的代码似乎是合理的。
Design Goals
作者为 Csmith 提供了两个设计目标:
- 每个随机生成的程序都必须格式正确,并且具有基于 C 标准的单一解释。
- 最大化“表现力”。 表现力是指生成的程序应该使用多种语言特征的组合。 例如,Csmith 允许具有函数定义、全局定义、大多数 C 表达式和语句、控制流、数组、指针等的程序。 一些不允许的语言特性包括动态内存分配、递归和函数指针。
避免未定义行为很重要,因为根据定义,允许编译器在存在未定义行为的情况下为同一程序生成不同的输出。 在极端情况下,请考虑这篇博文中的这个程序:
#include <cstdlib>
typedef int (*Function)();
static Function Do;
static int EraseAll() {
return system("rm -rf /");
}
void NeverCalled() {
Do = EraseAll;
}
int main() {
return Do();
}
在没有优化的情况下使用 clang 编译时,程序会出现段错误。 但是,当使用 -O1 编译时,程序将运行 rm -rf /
。
有趣的是,设计目标#2 与设计目标#1 直接相反。 我们允许测试的功能越多(如数组和指针),就越容易导致未定义的行为。 首先,我们将在高层次上讨论程序是如何随机生成的。 然后,我们将探讨 Csmith 如何确保生成的代码不会表现出未定义的行为。
Randomly Generating Programs
为了生成程序,首先 Csmith 生成可以在以后程序生成期间使用的类型。 然后从 main 开始,作者分解了程序是如何填充的。
- Csmith 根据当前上下文从语法中选择一个随机产生式,具有潜在的非均匀概率。 例如,当在函数体内时,Csmith 可以选择 if 和 for 语句之类的内容,但如果代码不在循环中,则不能选择 continue。
- Csmith 对 C 的一个子集进行操作。有趣的是,作者似乎没有为这个子集明确写下语法。
- 对于需要目标(例如变量)的产品,Csmith 可以随机决定生成新目标或使用现有目标。
- 对于需要类型的产品也是如此。
- 在选择非末端 production 时,CSMITH将在适当的环境下递归选择 production。 例如,如果选择函数调用产生式,则必须生成参数表达式。
- Csmith 实现了一个过程间指针分析,它需要跟踪有关程序的“指向事实”。 Csmith 在生成程序时会保持这组指向事实的最新状态。 更详细的解释可以在 github 上找到。
- Csmith 使用一组安全检查来确保新生成的代码位格式正确,遵循设计目标 #1。 如果安全检查失败,则撤消更改并重试。
Safety Mechanisms
程序可能因各种原因崩溃。 这可能是因为编译器故障,但更常见的是由于程序不安全。 Csmith 希望通过适当的安全机制避免这些陷阱。
讨论问题:想想随机生成的程序会导致哪些安全问题。
下面显示的所有机制的摘要。 我们将为每个人提供详细的解释。
Integer Safety
整数的安全问题来自未定义的行为(UB),例如有符号溢出( signed overflow):
int signedOverflow(int x) {
return x+1 > x; // either true or UB due to signed overflow
}
and shift-past-bitwidth:
int shiftPastBitwidth() {
return 1 << 32; // UB when evaluated on 32 bit platform
}
Csmith 为其操作数可能根据编译器标准溢出的算术运算符生成包装函数,尽管有一些棘手的 UB 未定义并且作者必须自己弄清楚(例如 INT_MIN % -1
)。
Type Safety
C 类型安全的棘手方面是限定符安全。 Modifying constant-qualified or volatile-qualified objects through nonconstant references is 未定义的行为。
const int **ipp; // the value pointer to shall not be modified
int *ip;
const int i = 42;
void constViolation(void) {
ipp = &ip; // UB
*ipp = &i; // Valid
*ip = 0; // Modifies constant i (was 42)
}
Csmith 通过类型检查确保类型安全。
Pointer safety
第一种指针安全问题是空指针解引用。
int a = 10;
void nullDereference(int *p) {
*p = a; // cause execption if p is NULL
}
这个可通过动态检查避免。
int a = 10;
void safeDereference(int *p) {
if (p != NULL) {
*p = a;
}
}
但是,没有可靠的方法来识别指向函数范围变量的无效指针。
void invalidDereference(int *p) {
int a = 10;
if( p != NULL){
*p = a;
}
}
// outside this function, we cannot dereference or compare p with other pointer
// before it becomes valid again!
Discussion Question: What could be the solution here?*
一种方法是强制指向函数范围变量的指针永远不会超过函数; 但是,这太严格了。 相反,Csmith 选择进行流敏感、字段敏感、上下文敏感、路径不敏感和数组元素不敏感的指针分析。 它维护包含显式位置(包括 null 和超出范围的元素)的指向集,这些位置可以在每次引用之前被引用和检查。
Effect Safety
在 C99 标准中,未定义的行为可能是由“函数指示符、实际参数和实际参数中的子表达式的求值顺序”引起的,如果“在两个序列点之间,一个对象被多次修改,或者 被修改并读取先前值而不是确定要存储的值。”
void undefinedExcutionSequence() {
some_func(printf("first\n"), printf("second\n")); // which printf is called first?
i = ++i + 1; // what is i?
}
Csmith 保守地分析每个表达式的效果。 效果包含表达式的可读取和可写入位置。 除赋值外,同一位置不能同时读写或写入两次。 这是逐步完成的。 对于新生成的代码,执行检查以决定放弃或保留代码。 例如,当生成p + funcMayModifyP()
时,Csmith 将放弃 funcMayModifyP()
。
Array Safety
阵列总是存在超出边界的安全问题。
void outOfBound() {
int a[2];
a[2] = 10; // out of boundary
}
我给出的示例很容易避免,但是当 a 的索引是变量时,很难判断它是否在界内。 Csmith 只生成 for 循环计数器的索引变量,并确保 for 循环永远不会超出边界。 对于任意索引变量,Csmith 应用模运算符。 如果这两种技术都不起作用——例如,当数组长度增加时——Csmith 会针对数组长度发出显式检查。
Initializer Safety
Csmith 在声明后立即初始化变量并禁止 goto 语句以确保执行是有序的。
Discussion Question: What kind of cases can be omitted due to the current design of program generation and safety constraints?
Global Safety
由于 Csmith 以增量方式生成代码,因此新生成的代码可能会威胁到旧代码。
void incrementallyGeneratedUnsafeProgram() {
int *p = &i;
while (...) {
*p = 3;
p = 0; // unsafe because of the back-edge
}
}
Csmith 在每个新生成的行上执行检查,除了循环。 当逻辑创建后边缘时,最后会检查循环。 如果出现不安全行,Csmith 会逐行删除,直到满足安全要求。
Design Trade-offs
Allow Implementation-defined Behavior
实现定义的行为等同于未指定的行为,这可能因编程语言的不同实现而异。 Csmith 的设计者认为“在所有可能的实现定义的行为选择中保留单一解释”是不现实的。 当编译器具有以下实现定义的行为时,它们允许 Csmith 提供不同的输出:
- 整数的宽度和表示。
- 当值无法在目标类型的对象中表示时,转换为有符号整数类型时的行为。
- 对有符号整数进行按位运算的结果。
大致有三种编译器目标:
Csmith 在等价类内但不跨类执行测试。
No Ground Truth
Csmith 没有基本事实,因为让人工检查每个程序是不现实的。 相反,它需要多数票。 这组作者说,在实践中,两个不相关的编译器并没有输出相同的错误输出。 对此的解释是中间语言(IR)之间的巨大差异。
No Guarantee of Termination
Csmith 生成的程序可以是任意长度。 在实践中,调用超时函数来终止需要太长时间才能完成的程序。
Target Middle-End Bugs
Csmith 的目标是检查编译器如何在 IR 上执行转换,而不是作为商业测试套件的标准一致性。 例如,Csmith 没有花精力测试编译器如何处理长标识符名称。
由于目标,有几个选择:
- Csmith 设计师手动调整 80 个概率变量,以生成算术和按位运算、循环和直线代码、单级和多级间接等平衡组合的程序。
- 鼓励 Csmith 生成惯用代码,例如,访问数组所有元素的循环。
- 阻止 Csmith 生成不太可能提高编译器 IR 的“覆盖率”的源代码级别的多样性,例如,在表达式周围添加额外级别的括号。
- 设计 Csmith 可以在几秒钟内高效地生成几万行的可运行程序。
Evaluation
Opportunistic Bug Finding
Csmith 设计人员测试了 11 个编译器并将错误报告给开发人员。 商业编译器开发人员并不在意,而 GCC 和 LLVM 团队反应迅速。 到论文定稿时,报告了 79 个 GCC 错误和 202 个 LLVM 错误(占所有 LLVM 错误报告的 2%),其中大部分已修复。 CompCert 是如此出色的编译器,以至于 CompCert 的开发中版本是唯一的编译器,Csmith 在经过 6 个 CPU 年的测试后仅发现 2 个错误代码错误。
Bug Types
在我们继续之前,我想介绍一下错误类型以了解实验结果:
- 崩溃错误 ( crash error ) 是在编译期间使编译器崩溃或以非零终止代码退出的错误。
- wrong-code 错误是指在运行时,程序产生错误结果、崩溃或异常终止代码,或者永远不会终止。
- silent wrong-code error 是在编译期间不会产生编译器警告的错误错误。
- 断言失败 ( assertion failure ) 是 LLVM 内部一致性检查失败。
- 内部编译器故障 (internal compiler failure) 是 GCC 内部一致性检查故障。
Quantitative Comparison of GCC and LLVM Versions
下图显示了在输入 1,000,000 个 Csmith 随机生成程序的情况下,LLVM 1.9–2.8、GCC 3.[0–4].0 和 GCC 4.[0–5].0 的编译和执行结果。 每个程序都在 –O0、–O1、–O2、–Os 和 –O3 编译。 如果每个编译器在 5 分钟内终止并且每个已编译的随机程序在 5 秒内终止,则测试用例是有效的。 顶行和底行是不同编译器版本的错误率。 作者还找到了编译错误的来源,并将其绘制在中间一行。
Bug-Finding Performance as a Function of Test-Case Size
设计 Csmith 的目标是快速发现许多缺陷,而 Csmith 应该生成多大的程序才能实现该目标成为一个问题。 在报告错误时,作者更喜欢较小的程序而不是较大的程序,因为它们更容易调试和报告。 下图显示了为学习相同运行时的错误数和运行时权衡而执行的实验。
Discussion Questions:
- Is there a big benefit in using small vs. large programs as tests?
- Most tests we write are relatively small; are there bugs that can only be caught with really large programs?
- How small would our program space need to be in order to “exhaustively” search for bugs?
Bug-Finding Performance Compared to Other Tools
最后,还有一个 Csmith 针对其他 bug 查找工具的性能测试,如下表所示。
在发现错误的同时,Csmith 比现有的测试工具效率更高。
Code Coverage
最后,还有一个 Csmith 生成程序的代码覆盖率测试,如下表所示。
向现有的 LLVM 和 GCC 测试套件添加 10,000 个 Csmith 生成的程序并没有显着增加覆盖率。
Discussion Questions:
- The authors do not come up with a good explanation for the code coverage issue. What might be the reason?
- Tests that are randomly generated will never be like tests that are created by humans. For example, the factorial function will almost never be randomly generated. Does this mean that this kind of testing is still useful? Why and how?