try ai
科普
编辑
分享
反馈
  • 编译器正确性

编译器正确性

SciencePedia玻尔百科
核心要点
  • 编译器正确性并非绝对,而是相对于一个特定的语义模型来定义的。该模型规定了程序的各种可观察行为,例如其最终输出、性能或副作用。
  • 一个正确的编译器必须遵守目标硬件和语言的严格语义,因为像结合律这样的理想化数学定律对于有限精度的机器算术并不总是成立。
  • 对于并发程序,正确性涉及严格遵守内存一致性模型,以确保内存操作的重排序不会破坏线程间的同步。
  • 编译器正确性是软件安全的基石,因为不正确的优化可能会通过移除安全检查或泄露秘密信息等方式,无意中引入漏洞。

引言

编译器扮演着一个精密翻译器的角色,将人类可读的源代码转换为计算机能理解的机器语言。这个过程中的核心挑战是确保翻译的完美性——这一学科被称为编译器正确性。但一个编译后的程序“正确”究竟意味着什么?这个问题揭示了深度的复杂性,在这里,看似显而易见的数学真理会失效,而与硬件的微妙交互可能导致灾难性的失败。本文旨在探讨在编译中定义和实现正确性所面临的挑战。我们将首先深入探讨核心的“原理与机制”,探索正确性的定义如何随上下文而变化,以及编译器必须遵守的复杂规则。然后,我们将在“应用与跨学科联系”中看到这些原理的深远影响,发现编译器正确性是构建快速、安全和可靠软件系统的基石。

原理与机制

想象你有一位技艺高超的翻译家,他能将一首优美的英文诗歌转换成一首同样优美的日文诗歌。翻译家的任务不仅仅是替换词语,而是要保留诗歌的意义、节奏、情感冲击力——即其灵魂所在。编译器就是这样一位针对逻辑语言的翻译家。它接收一个人类可读的程序——一首精心构建的指令“诗篇”,并将其翻译成原始而强大的机器语言。编译器正确性就是确保在翻译过程中没有任何东西失真的艺术与科学。

但是,翻译的“正确”到底意味着什么?这个问题表面看似简单,却将带领我们踏上一段从看似微不足道到极其复杂的旅程,揭示软件、硬件以及意义与信任本质之间的微妙之舞。

“相同”意味着什么?

让我们从一个非常简单的谜题开始。思考两个程序片段。第一个是 skip,一条什么也不做的指令。第二个是 x := x,一条将变量 x 的值赋回给自身的指令。这两个程序是相同的吗?

你的第一直觉很可能是:“当然,它们是相同的!”如果你有一个值为 5 的变量 x,当你执行 x := x 后,它的值仍然是 5。计算机内存的最终状态与你只执行 skip 时的状态完全相同。从这个意义上说,这两个程序在​​语义上是等价的​​。理论上,编译器可以发现 x := x 并通过将其替换为 skip 来“优化”它。这似乎是一个完全有效的小改进。只要我们的正确性概念仅仅基于内存中的最终值,这个优化就是无懈可击的。

但如果我们改变我们选择观察的内容呢?如果我们不仅关心终点,也关心过程呢?想象一下,我们的程序运行在一个每条指令都会消耗微量能量或花费少量时间的系统上。让我们定义一个​​成本语义​​,其中 skip 的成本为 000,而执行赋值 x := x 的成本为 111。突然之间,我们那两个“相同”的程序不再相同了!一个没有成本,另一个则有代价。如果我们正在为电池供电的设备编写软件,其中每个时钟周期都至关重要,那么改变成本的优化在这个新的、考虑成本的模型下便不再是保持语义的了。

让我们再进一步。如果 x 不仅仅是内存中的一个变量,而是一个特殊的硬件寄存器,它控制着(比如说)屏幕上的一个像素或工厂里的一个阀门呢?在这个充满​​输入/输出(I/O)​​的世界里,“写入”这个行为本身就是一个可观察的事件,即使你写入的是已经存在的值。写入寄存器可能会导致像素闪烁或阀门重新复位。在这种情况下,x := x 是一个具有现实世界后果的动作,而 skip 则是无作为。两者现在截然不同。一个将赋值替换为 skip 的编译器将会犯下灾难性的错误。

这个简单的例子揭示了编译器正确性的第一个、也是最根本的原则:​​正确性并非绝对​​。它是相对于一个特定​​语义模型​​的契约。编译器并非要保留程序员虚无缥缈的“意图”;它保留的是由编程语言规则定义的可观察行为。一个优化是否正确,完全取决于规则规定了什么是可观察的:是仅仅最终状态,还是性能,亦或是副作用。

显而易见真理的背叛

如果定义“相同”都很棘手,那么当我们考虑到数学中那些“显而易见”的真理时,情况就变得更加微妙了。我们从小就被教导加法是满足结合律的:(a+b)+c=a+(b+c)(a+b)+c = a+(b+c)(a+b)+c=a+(b+c)。这是一条坚如磐石的定律,并且似乎是优化的绝佳来源。编译器可以重排加法顺序以使代码运行得更快。

但计算机并非数学家理想中的黑板。它是一台使用有限表示法的物理机器。让我们考虑一个简化的十进制浮点系统,其中数字只用 3 位精度存储。取三个数:

  • a=1.00×105a = 1.00 \times 10^{5}a=1.00×105
  • b=−1.00×105b = -1.00 \times 10^{5}b=−1.00×105
  • c=1.23×100c = 1.23 \times 10^{0}c=1.23×100

现在,我们来计算 (a⊕b)⊕c(a \oplus b) \oplus c(a⊕b)⊕c,其中 ⊕\oplus⊕ 表示我们计算机的浮点加法。首先,a⊕ba \oplus ba⊕b 是 (100000)⊕(−100000)(100000) \oplus (-100000)(100000)⊕(−100000),结果恰好是 000。然后,0⊕c0 \oplus c0⊕c 是 0⊕1.230 \oplus 1.230⊕1.23,结果是 1.231.231.23。最终结果是 1.231.231.23。

现在让我们试试另一种结合方式:a⊕(b⊕c)a \oplus (b \oplus c)a⊕(b⊕c)。内部部分是 b⊕cb \oplus cb⊕c,即 (−100000)⊕(1.23)(-100000) \oplus (1.23)(−100000)⊕(1.23),结果是 −99998.77-99998.77−99998.77。但是我们的机器只能存储 3 位有效数字!与 −99998.77-99998.77−99998.77 最接近的可表示数为 −1.00×105-1.00 \times 10^5−1.00×105。由于这个舍入误差,c 的小数值被完全“吞噬”而消失了。所以,b⊕cb \oplus cb⊕c 的计算结果为 −1.00×105-1.00 \times 10^5−1.00×105。现在我们计算最终结果:a⊕(−1.00×105)a \oplus (-1.00 \times 10^5)a⊕(−1.00×105),即 (1.00×105)⊕(−1.00×105)(1.00 \times 10^5) \oplus (-1.00 \times 10^5)(1.00×105)⊕(−1.00×105),得到 000。

看看发生了什么!我们发现 (a⊕b)⊕c=1.23(a \oplus b) \oplus c = 1.23(a⊕b)⊕c=1.23,而 a⊕(b⊕c)=0a \oplus (b \oplus c) = 0a⊕(b⊕c)=0。神圣的结合律被打破了! 一个盲目地对浮点数进行重结合的编译器,不仅仅是产生一个稍微不那么精确的答案;它是在产生一个性质上不同且错误的答案。

这个原则远不止于浮点数。考虑一下乘以 2(i * 2)和按位左移 1 位(i 1)之间看似显而易见的等价性。对于正整数,它们是相同的。但在许多常见语言(如 C 语言)中,对负数进行位移的规则是模糊的,会导致​​未定义行为(UB)​​。对于这些语言,只有在 i 是非负数这个前提条件下,这种转换才是正确的。如果编译器无法证明这一点,它就绝不能执行这个优化。这揭示了第二个深刻的原则:​​一个正确的编译器必须严格遵守目标机器的语义,而不是纯粹数学的理想化语义。​​

看不见的世界:顺序、状态与并行

到目前为止,我们主要关注计算的结果。但程序所做的大部分工作不仅仅是计算值,还在于改变世界的状态。正是在这里,正确性变得更加微妙。

考虑一种名为​​公共子表达式消除(CSE)​​的优化。如果你写下 y := f(0) + f(0),一个聪明的编译器可能会注意到 f(0) 被计算了两次。为什么不计算一次然后重用结果呢?它可能会将代码转换为 t := f(0); y := t + t。这看起来更高效。但它正确吗?

这完全取决于函数 f() 的作用。如果 f() 是一个​​纯函数​​——一个只接受输入并返回输出,没有任何其他影响的数学映射——那么这个优化是完全安全的。但如果 f() 有​​副作用​​呢?假设每次调用 f() 都会使一个全局计数器 g 递增。在原始代码中,g 递增两次。在优化后的代码中,它只递增一次。g 的最终值将会不同,而且原始代码中两次调用 f() 的返回值也会不同,从而导致 y 的结果完全不同。一个正确的编译器必须能够区分纯函数与非纯函数,而这通常是一项极其困难的任务。

这种隐藏依赖性的思想在多核处理器的世界中变得至关重要。想象一个程序,你需要从内存中加载两个值,先 load A 再 load B。如果这两个加载是独立的,编译器能否为了提高​​指令级并行(ILP)​​而将它们重排为先 load B 再 load A?在单线程世界里,这通常是安全的。但在多线程世界里,这可能是灾难的根源。

内存操作的顺序通常是线程之间一种无形的通信方式。一个线程(“生产者”)可能会将数据写入位置 A,然后设置位置 B 的一个标志来表示数据已准备就绪。另一个线程(“消费者”)等待位置 B 的标志被设置,然后才从 A 读取数据。如果消费者的编译器将其读取操作重排为先 load A 再 load B,它可能会在看到标志之前就读取了数据,导致它处理了过时的、不正确的信息。

为了防止这种混乱,硬件和编程语言定义了一个​​内存一致性模型​​。这是一套严格的规则,规定了不同线程能看到什么样的内存操作顺序。像​​顺序一致性(SC)​​这样的强模型可能会禁止大多数重排序,使编程更容易但硬件更慢。而像​​释放一致性(RC)​​这样的弱模型则允许更多的重排序以提高性能,但要求程序员(和编译器)使用特殊的同步指令,如 fences(内存屏障)或 acquire/release 操作,在关键点强制执行顺序。在这个并行的世界里,正确性意味着尊重编译器、线程和硬件内存系统之间这个复杂的契约。

建立信任的基石

面对这一系列令人眼花缭乱的隐藏规则和微妙交互,我们如何才能相信一个复杂的、百万行代码的编译器是正确的呢?绝对的证明是极其困难的,但我们有强有力的策略。

一种方法是​​差分测试​​。我们取两个不同的编译器 C1C_1C1​ 和 C2C_2C2​,给它们同样一套大量的测试程序。我们运行生成的二进制文件并比较它们的可观察输出。如果在相同输入下,两个二进制文件产生了不同的结果(在仔细过滤掉涉及未定义行为的情况后),我们就知道至少有一个编译器存在 bug。我们可能不知道哪一个是正确的,但我们成功地发现了一个缺陷 [@problem_-id:3634594]。

一种更强大的方法是走向形式化证明。与其试图证明整个编译器对所有可能的输入都是正确的——这是一项赫拉克勒斯般的任务——我们可以设计一个​​生成证明的编译器​​。对于它编译的每一个特定程序,该编译器还会输出一个形式化的证明 π\piπ,证明该翻译是保持语义的。然后我们可以用第二个、更小更简单的程序,称为​​验证器​​,来检查这个证明。这被称为​​翻译验证​​。如果这个小验证器是正确的,并且它接受了证明,我们就可以信任编译后的代码。

这引出了最后一个美妙的、近乎悖论的问题:为了创建这个经过验证的生态系统,我们需要编译我们的生成证明的编译器和我们的验证器。但是我们用什么编译器来做这件事呢?如果我们使用一个现有的、不受信任的编译器,它会不会是恶意的?这就是著名的“对信任的信任的反思”(Reflections on Trusting Trust)困境。

解决方案是一个壮丽的自举过程。我们可以用一个不受信任的编译器 C0C_0C0​ 来编译我们的生成证明的编译器 CπC^{\pi}Cπ 的源代码。这给了我们一个我们无法信任的二进制文件 B0B_0B0​。但现在,我们执行一个巧妙的操作:我们使用这个(不受信任的)B0B_0B0​ 再次编译它自己的源代码 CπC^{\pi}Cπ。并且我们要求它不仅生成一个新的二进制文件 B1B_1B1​,还要生成一个证明 Π1\Pi_1Π1​,证明 B1B_1B1​ 是 CπC^{\pi}Cπ 的正确编译结果。最后,我们用我们小型的、简单的、经过形式化审查的受信任验证器 VVV 来检查这个证明 Π1\Pi_1Π1​。如果验证器说证明有效,我们就成功了!我们已经使用一个不受信任的工具链,产生了一个现在经过形式化验证的编译器二进制文件 B1B_1B1​。我们从怀疑中创造出了信任。

因此,编译器正确性的旅程不仅仅是消除 bug。它是对我们代码意义、我们机器物理现实以及支撑我们世界的数字基础设施信任基础的深刻探索。它证明了人类有能力在层层精心管理的复杂性之上,构建出精密、可靠的系统。

应用与跨学科联系

我们花了一些时间探索一个正确的编译器那错综复杂的机制,那是一个由图、逻辑和形式化规则构成的世界。人们很容易迷失在这种美丽的抽象中,而忘记问那个最重要的问题:这一切究竟是为了什么?这种对正确性的追求仅仅是计算机科学家的一种迂腐操练,一种确保我们的程序在某种空洞的学术意义上是“正确”的方式吗?还是说,它揭示了我们能用计算机构建的世界的某些深刻之处?

你可能会惊喜地发现,答案是,编译器正确性不是一个牢笼,而是一把钥匙。它是我们数字世界中最勇敢的速度、安全和可靠性壮举所依赖的、沉默而坚固的基础。它是一门做出承诺——关于程序将如何行为的承诺——并信守承诺的科学,无论程序多么复杂,环境多么恶劣。让我们暂离编译器的内部机制,去看看它所帮助塑造的世界。

对速度的追求

优化编译器最古老也最负盛名的角色,就是追求速度。我们希望我们的程序运行得更快,在更短的时间内做更多的事情。但原始的速度往往与安全相悖。一个正确的编译器是那个让我们能两全其美的仲裁者。

想象一个宏大的天体力学模拟,或是一个蛋白质折叠过程。这些任务涉及处理巨大的数据数组。一个现代、安全的编程语言会坚持检查每一次数组访问,以确保它在正确的边界内,从而防止一整类灾难性的错误。这种安全性非常棒,但数百万次微小检查的成本可能会让一台超级计算机陷入停顿。我们必须在安全和速度之间做出选择吗?一个正确的编译器说:不。通过仔细分析程序循环的结构——运用归纳变量和支配关系等原理——它通常可以证明,在一个 for k from 0 to n-1 的循环中,像 positions[k] 这样的访问将永远不会越界,前提是数组的长度也是 n。在用数学的确定性证明了这一点之后,它就可以消除检查,让我们在拥有现代语言保证安全性的同时,获得不安全语言的原始性能。这不是猜测;这是一个逻辑推导,它解锁了高性能科学计算的大门。

在我们每天使用的网页浏览器和应用平台的世界里,速度和安全之间的这种博弈变得更加动态。像 JavaScript 和 Java 这样的语言极其灵活。编译器无法预先确切地知道代码会做什么。为了让它们快速运行,即时(JIT)编译器会根据程序当前似乎的行为方式,做出大胆的、推测性的优化。但如果它的猜测是错误的呢?这时,编译器正确性提供了一个至关重要的安全网:​​去优化​​。如果 JIT 编译器的优化世界被证明是建立在一个错误的假设之上,它可以优雅且即时地将程序的状态恢复到一个较慢、未优化但可验证为正确的代码版本。这种能够进行激进押注并有保证回退计划的能力,正是现代网络感觉如此灵敏的原因。

看不见的战场:作为护盾的正确性

如果说正确性赋予了速度,那么它也是我们在一个看不见的数字战场上的主要护盾。编译器执行的每一次优化,每一次转换,如果做得不正确,都可能成为一个潜在的安全漏洞。

想想一个简单的缓冲区溢出,这个几十年来一直困扰着软件的经典漏洞。我们有防御措施,比如“栈金丝雀”——放置在栈上的秘密值,它们应该保持不变。如果缓冲区溢出覆盖了一个金丝雀值,程序就可以在攻击者劫持它之前检测到篡改并关闭。金丝雀检查是在函数的尾声(epilogue)中,就在函数返回之前执行的。但当编译器执行尾调用优化时会发生什么?这是一个通过将调用转换为跳转来节省内存的聪明技巧,它完全绕过了函数的尾声。安全检查就消失了!一个正确的编译器必须理解这种交互。它必须知道,为了保持安全不变量,它必须在执行优化的尾跳转之前,明确地重新插入金丝雀检查。性能优化和安全特性之间的冲突,通过对正确性的更深刻理解得到了解决。

威胁比内存损坏更加微妙。一个看似无害的编译器优化可能会造成信息泄露。想象一个程序处理一个秘密,然后用零覆盖它以清除它,接着将该内存区域暴露给一个公共通道。编译器的别名分析可能会错误地断定,指向秘密的指针和指向公共数据的指针引用的是不同的东西(也许因为它们的静态类型不同)。由于没有看到依赖关系,它可能会为了效率而重排操作,将公共读取操作移到秘密被清除之前。结果是:秘密被泄露。一个正确的、具有安全意识的别名分析能够理解不同的指针可以引用相同的底层字节,并尊重必要的操作顺序,从而防止泄露。

这种“安全感知的正确性”的概念甚至更深。编译器可能会为了性能而特化一个函数,创建两个版本——一个用于常见情况,一个用于罕见情况。在其优化的热情中,它可能会注意到函数内部的安全检查在常见情况下是多余的,并将其消除。如果系统其他地方的一个 bug 允许攻击者在没有适当授权的情况下进入“常见情况”路径,那么被优化掉的安全检查就造成了一个巨大的漏洞。因此,验证正确性不仅仅是确保函数的输出相同;它还要证明基本的安全属性在所有转换和特化中都得以保留。

也许最令人费解的前沿是密码学。在这里,正确性不仅关乎程序计算什么,还关乎它如何计算。攻击者可以测量程序所需的时间、其功耗或其电磁辐射来推断出密钥。一个密码学工程师可能会写下一系列操作,如 $x \leftarrow x \oplus k; x \leftarrow x \oplus k$。从纯功能的角度看,这是一个空操作(no-op),因为 $x \oplus k \oplus k = x$。一个天真的窥孔优化器会急切地删除这些“冗余”指令。但它们可能是为了平衡不同代码路径的执行时间或功耗而有意放置的,从而使侧信道攻击者无法区分它们。一个真正正确的现代编译器必须被教会去看到这种意图,也许是通过代码上的特殊注解,并理解移除这些指令虽然在功能上是正确的,却将是一个灾难性的安全失败。

构建我们能信任的世界

除了速度和防御,编译器正确性还给予我们信心去构建全新类型的可靠系统,从无限小到全球分布。

在根本层面上,它帮助我们管理复杂性。每个程序员可能都曾忘记关闭文件或释放锁,导致资源泄漏,慢慢地拖垮一个系统。一个配备了数据流分析的编译器可以追踪这类资源的生命周期。它可以证明代码中的某些路径未能释放资源。更好的是,它可以自动修复问题。通过插入一个“作用域守卫”对象,其生命周期与代码的词法作用域绑定,编译器可以保证无论函数如何退出——无论是正常返回、提前退出还是异常——资源都会被释放。这就是作为孜孜不倦、完美助手的编译器,确保我们的程序行为得体。

这种保证良好行为的能力让我们能够突破界限。想象一下,你想教你的计算机大脑——操作系统内核——一个新花样。这是一个危险的游戏;内核代码中的一个错误就可能使整个系统崩溃。然而,现代网络、可观察性和安全性都依赖于安全地扩展内核功能。这就是像 eBPF 这样的技术的魔力。一个受限的编程模型与一个可验证正确的预先(AOT)编译器工具链相结合,可以用数学的确定性证明,一个用户提供的程序不会使内核崩溃,不会陷入无限循环,也不会访问未经授权的内存。编译器将安全证明本身融入到原生代码中,从而在操作系统的核心实现了前所未有的可编程性。

正确性的定义甚至可以扩展到物理世界。在机器人学和信息物理系统中,某件事何时发生与什么事发生同样重要。考虑一个机器人的控制循环:读取传感器,计算滤波器,命令执行器。这里有严格的时间预算;执行器命令必须在读取传感器后的几分之一秒内发出。一个不了解这种物理约束的编译器可能会为了提高“性能”而重排指令,也许会将一个耗时的日志记录操作移动到传感器和执行器之间的关键路径上。这可能导致机器人错过其最后期限,从而导致不稳定的行为。对于这个领域来说,一个真正正确的编译器必须将时序约束作为其必须维护的语义契约的核心部分。

在区块链和智能合约的世界里,正确性的风险从未如此之高,一个 bug 就可能导致数百万美元的损失。在这里,环境有其独特的规则。交易是原子的,一个合约可以调用另一个合约,并可能在第一个调用完成之前“重入”原始合约。像聚合体的标量替换(Scalar Replacement of Aggregates)这样的优化,通过将值保存在临时寄存器中来推迟对持久化存储的写入,在大多数情况下是一种标准且安全的优化。但在智能合约中,这可能是一场灾难。如果在寄存器中的值被改变之后、写入存储之前发生了一个外部调用,一个重入调用可能会从存储中读取旧的、过时的值,从而破坏合约的逻辑。为这种新型金融基础设施服务的正确编译器,必须深刻理解这些独特的事务性语义。

未来:人工智能时代的正确性

展望未来,编译器正确性的角色变得更加核心。我们正在进入一个由人工智能驱动的开发时代,机器学习模型可以被训练来发现极其聪明和不明显的程序优化。这些由 AI 建议的转换可能优于任何人类所能设计的,但它们正确吗?AI 模型基于相关性和概率运行,而非逻辑确定性。

在这里,我们看到了终极的结合:机器学习的创造性、启发式能力与形式化验证的绝对、严格保证之间的伙伴关系。一个 ML 模型可以提出一个大胆的转换。一个形式化等价性检查器,作为最终的安全网,可以接受该转换并试图用数学逻辑的全部力量来证明它保持了程序的语义。这个检查器必须足够复杂,能够使用归纳法来推理循环,能够处理浮点数的奇怪算术,以及并发硬件那令人困惑的复杂内存模型。如果它无法生成证明,或者超时,该转换就会被拒绝。无论 AI 有多“自信”,没有什么能替代证明。

因此,编译器正确性不是一个来自过去时代的已解决问题。它是一个充满活力且不断扩展的前沿领域。它是将我们的人类意图转化为我们可以信任的机器行为的无形准则。它是一门确保我们的数字世界不仅快速,而且安全、可靠和稳固的艺术与科学。