编写-C-语言编译器-全-
编写 C 语言编译器(全)
原文:
zh.annas-archive.org/md5/11efb302b9ccedc9680d5f2a58e72507译者:飞龙
前言

当我们讨论编程语言如何工作时,我们往往借用来自幻想小说的比喻:编译器是魔法,而从事编译器工作的人是巫师。龙可能以某种方式参与其中。但在大多数程序员的日常生活中,编译器的表现更像是科幻小说中的通用翻译耳机,而不是魔法物品。它们既不炫目也不戏剧化,不会吸引太多注意力。它们只是悄无声息地在后台运行,将你流利地使用(或输入)的语言翻译成机器的外语。
出于某种原因,科幻小说中的角色似乎很少去想他们的翻译器是如何工作的。但一旦你开始编写代码,就很难不对你的编译器在做什么产生好奇。几年前,这种好奇心占据了我,于是我决定通过编写自己的编译器来深入了解编译器的工作原理。对我来说,编写一个真实编程语言的编译器很重要,这是我自己使用过的语言。而且我希望我的编译器生成的汇编代码可以在没有仿真器或虚拟机的情况下运行。但当我四处查找时,我发现大多数编译器构建指南都使用运行在理想化处理器上的玩具语言。这些指南有些非常优秀,但它们并不是我真正需要的。
当一位朋友把我引导到阿卜杜拉齐兹·古鲁姆(Abdulaziz Ghuloum)的一篇短文《编译器构建的增量方法》(* <wbr>scheme2006<wbr>.cs<wbr>.uchicago<wbr>.edu<wbr>/11<wbr>-ghuloum<wbr>.pdf *)时,我终于解开了困境。文中解释了如何将 Scheme 编译为 x86 汇编,从最简单的程序开始,一次添加一个新的语言结构。我并不特别想为 Scheme 编写一个编译器,所以我将这篇论文改编为我更感兴趣的语言:C。随着我在这个项目上的不断推进,我从 x86 切换到了现代版本的 x64 汇编。我还扩展了对 C 的更大子集的支持,并添加了一些优化过程。到这时,我已经远远超出了古鲁姆原始的计划(抱歉,双关一下),但他的基本策略仍然非常有效:一次专注于语言的一个小部分,这使得我能够保持进展,看到自己在不断前进。在这本书中,你将进行同样的项目。在这个过程中,你将深入理解你编写的代码以及它运行的系统。
本书适合谁阅读
我写这本书是为了那些对编译器如何工作感到好奇的程序员。许多关于编译器构建的书籍是为大学或研究生水平的课程编写的,但这本书的目的是让那些自己探索这一主题的人也能轻松理解。你不需要具备编译器、解释器或汇编语言的任何先验知识就能完成这个项目。对计算机架构的基本理解是有帮助的,但并非必需;我会在涉及到相关概念时进行讨论,并偶尔推荐一些外部资源以供你了解更多背景信息。话虽如此,这本书并不是为初学编程的人准备的。你应该能够独立编写有实质内容的程序,并且对二进制数字、正则表达式以及图形和树等基本数据结构有所了解。你需要对 C 语言有足够的了解,能读懂并理解小型 C 程序,但不需要是 C 语言专家。在本书中,我们会随着进度一起深入探索语言的方方面面。
尽管本书是针对编程新手编写的,但对于已经有一些编译器经验的人来说同样是值得一读的。也许你曾为大学课程或个人项目实现了一个简单的语言,现在你希望做一些更实际的工作。或者,可能你曾经做过解释器相关的工作,想尝试将程序编译成机器代码。如果你属于这一类人,这本书会涉及一些你已经了解的内容,但也会提供许多新的挑战。至少,我保证你会学到一些关于 C 语言的知识。
为什么要编写 C 语言编译器?
我假设你已经对编写编译器的想法有所了解——毕竟你已经拿起了这本书。我想稍微谈一下为什么我们特别要为 C 语言编写一个编译器。简短的回答是,C 是一种(相对)简单的语言,但并不是玩具语言。从本质上讲,即使你从未编写过编译器,C 语言也足够简单,可以实现。但它也是一个特别清晰的例子,展示了编程语言如何受到它们运行的系统和使用者的影响。C 的一些特性会根据你目标的硬件而有所不同;其他特性则在操作系统之间有所差异;还有一些特性则没有明确规定,以便给编译器开发者更多的灵活性。语言中的一些部分是历史遗留物,保留至今是为了支持旧代码,而另一些则是较新的尝试,旨在让 C 语言变得更安全、更可靠。
C 语言中的这些复杂部分值得解决,原因有几个。首先,你将发展出一个清晰的思维模型,理解你的编译器如何与系统中的其他部分协同工作。其次,你会感受到不同群体对语言的不同看法,从试图消除歧义和不一致的规范作者,到寻求性能改进的编译器实现者,再到只想让代码正常工作的普通程序员。
我希望这个项目能让你以不同的方式看待所有编程语言:不仅仅是语言标准中规定的固定规则集,而是设计、实现和使用这些语言的人们之间持续的协商。一旦你开始以这种方式看待编程语言,它们就会变得更加丰富、有趣,并且不那么令人沮丧。
从 10,000 英尺高度看编译过程
在继续之前,让我们从高层次上了解一下源代码是如何转变为可执行文件的,以及编译器在这个过程中的位置。我们会清理一些术语,并在此过程中稍微回顾一下计算机架构。编译器是一个将代码从一种编程语言转换为另一种编程语言的程序。它只是整个系统的一部分(尽管通常是最复杂的部分),该系统负责让你的代码能够运行。我们将构建一个编译器,将 C 程序转换为汇编代码,这是我们希望处理器执行的指令的文本表示。
不同的处理器理解不同的指令;我们将专注于 x64 指令集,也叫做 x86-64 或 AMD64\。这是大多数人计算机运行的指令集。(你可能遇到的另一种指令集是 ARM。大多数智能手机和平板电脑使用 ARM 处理器,ARM 处理器也开始出现在笔记本电脑中。)
处理器无法理解文本,因此它无法直接运行我们的汇编代码。我们需要将其转换为目标代码,即处理器能够解码并执行的二进制指令。例如,汇编指令ret对应的字节是0xc3。汇编器处理这个转换,接收汇编程序并输出目标文件。最后,链接器将我们需要包含在最终程序中的所有目标文件合并,解决来自其他文件的变量或函数的引用,并添加一些关于如何启动程序的信息。最终的结果是一个可执行文件,我们可以运行它。这是一个极度简化的视图,但足够让我们入门。
除了编译器、汇编器和链接器,编译 C 程序还需要另一个工具:预处理器,它在编译器之前运行。预处理器会剥离注释,执行预处理指令,如 #include,并扩展宏,以生成已经准备好进行编译的预处理代码。整个过程大致如下图所示 图 1。

图 1:将源文件转化为可执行文件 描述
当您使用像 gcc 或 clang 这样的命令编译程序时,您实际上是在调用 编译器驱动程序,它是一个小的包装器,负责依次调用预处理器、编译器、汇编器和链接器。您将编写自己的编译器和编译器驱动程序,但不会编写自己的预处理器、汇编器或链接器。相反,您将使用系统中已经安装的这些工具的版本。
您将构建的内容
在本书的过程中,您将构建一个支持 C 语言大部分子集的编译器。您可以使用任何编程语言编写自己的编译器;我将以伪代码呈现实现的关键部分。本书分为三个部分。在第一部分,基础知识中,您将实现 C 语言的核心特性:表达式、变量、控制流语句和函数调用。
第一章:一个最简编译器 在本章中,您将构建一个能够处理最简单 C 程序的编译器,这些程序仅返回整数常量。您将了解编译的不同阶段,如何将 C 程序在内部表示为抽象语法树,以及如何读取简单的汇编程序。
第二章:一元运算符 接下来,您将通过实现两个一元运算符来扩展您的编译器:取反和按位取反。本章介绍了 TACKY,一种新的中间表示,它桥接了抽象语法树和汇编代码之间的差距。它还解释了如何在汇编中执行取反和按位取反,以及汇编程序如何在名为栈的内存区域中存储值。
第三章:二元运算符 在本章中,您将实现执行基本算术运算的二元运算符,如加法和减法。您将使用一种名为优先级提升(precedence climbing)的技术来解析算术表达式,确保正确的结合性和优先级,并学习如何在汇编中进行算术运算。
第四章:逻辑和关系运算符 在这一章中,您将为逻辑与(AND)、逻辑或(OR)、逻辑非(NOT)运算符以及诸如>、==和!=等关系运算符添加支持。本章介绍了几种新的汇编指令,包括条件指令和跳转指令。
第五章:局部变量 接下来,您将扩展编译器,以支持局部变量的声明、使用和赋值。在本章中,您将添加一个新的编译阶段来执行语义分析。这个阶段能够检测编程错误,比如使用未声明的变量。
第六章:if 语句和条件表达式 在本章中,您将为编译器添加对if语句的支持,这是编译器的第一个控制流结构,同时还将支持形式为a ? b : c的条件表达式。使用 TACKY 作为中间表示将在这里大有裨益;您可以通过现有的 TACKY 指令实现这两种语言结构,因此无需修改后续的编译阶段。
第七章:复合语句 在这一章中,您将添加对复合语句的支持,复合语句将语句和声明组合在一起,并控制标识符的作用域。您将详细研究 C 语言的作用域规则,并学习如何在语义分析阶段应用这些规则。
第八章:循环 本章涉及while、do和for循环,以及break和continue语句。您将编写一个新的语义分析阶段,将break和continue语句与它们所包含的循环关联起来。
第九章:函数 在本章中,您将实现对main之外的函数调用和函数声明的支持。您在这里有两个主要任务:编写类型检查器来检测语义错误,例如调用函数时传递错误数量的参数,以及生成汇编代码。您将深入学习 Unix 类系统的调用约定,这些约定决定了汇编中的函数调用方式。通过严格遵守这些约定,您将能够编译调用外部库的代码。
第十章:文件作用域变量声明与存储类说明符 接下来,你将增加对文件作用域变量声明以及extern和static说明符的支持。本章讨论了 C 语言标识符的几个特性,包括链接性和存储持续时间。它会讲解如何在语义分析阶段确定标识符的链接性和存储持续时间,以及这些特性如何影响你最终生成的汇编代码。它还介绍了内存的新区域——数据段,并描述了如何定义和操作存储在其中的值。
在第 II 部分,超越 int 的类型中,你将实现更多的类型。本部分将深入探讨 C 语言中的一些混乱、令人困惑和出乎意料的细节。
第十一章:长整型 在本章中,你将实现long类型,并为后续章节添加更多类型奠定基础。你将学习如何在类型检查时推断每个表达式的类型,并学习如何在汇编中操作不同大小的值。
第十二章:无符号整数 在这一章中,你将实现无符号整数类型。此章节详细讲解 C 语言标准中的整数类型转换规则,并介绍一些新的汇编指令,用于执行无符号整数操作。
第十三章:浮点数 接下来,你将实现浮点数类型double。本章描述了浮点数的二进制表示及浮点舍入误差的风险。它介绍了一组新的汇编指令,用于执行浮点运算,并解释了传递浮点参数和返回值的调用约定。
第十四章:指针 在本章中,你将实现指针类型以及地址和指针解引用操作符。你还将在类型检查器中验证指针操作,并为 TACKY 中间表示添加显式内存访问指令。
第十五章:数组与指针运算 本章接续第十四章的内容,增加对数组类型及相关语言特性支持:下标操作符、指针运算和复合初始化器。它深入探讨了数组和指针之间的关系,并阐明类型检查器应如何分析这些类型。
第十六章: 字符和字符串 本章涵盖了字符类型、字符常量和字符串字面量。你将了解 C 语言程序使用字符串字面量的不同方式,并且你将添加新的 TACKY 和汇编构造来表示字符串常量。在本章的最后,你将编译几个执行输入/输出(I/O)操作的示例程序。
第十七章: 支持动态内存分配 在本章中,你将实现 void 类型和 sizeof 操作符,这将使你能够编译调用 malloc 和其他内存管理函数的程序。这里最大的挑战是处理类型检查器中的 void。由于 void 是一种没有值的类型,类型检查器会与之前实现的其他类型有很大不同的处理方式。
第十八章: 结构体 结构体,以及成员访问操作符 . 和 ->,是本书中你将添加的最后一组语言特性。为了实现它们,你需要在前面章节中学到的所有技能。在语义分析阶段,你将根据 C 语言的作用域规则解析结构体标签,并分析结构体类型声明,确定它们在内存中的布局。当你生成 TACKY 时,你将把成员访问操作符转化为一系列简单的内存访问指令。而在生成汇编代码时,你将遵循传递结构体作为参数和返回值的调用约定。
在第三部分,优化中,你不会添加任何新的语言特性。相反,你将实现几个经典的编译器优化,以生成更高效的汇编代码。第三部分与第一部分和第二部分有很大不同,因为这些优化并非 C 语言特有,它们同样适用于用任何语言编写的程序。
第十九章: 优化 TACKY 程序 在本章中,你将添加一个针对 TACKY 程序的优化阶段。这个阶段将包括四种不同的优化:常量折叠、不可达代码消除、死存储消除和复制传播。这四种优化是协同工作的,每一项优化都会比单独使用时更有效。本章还介绍了几种工具,用于理解程序的行为,包括控制流图和数据流分析。你将使用这些工具发现优化程序的方法,而不会改变它们的行为。
第二十章:寄存器分配 为了完成这个项目,你将编写一个寄存器分配器,用于确定如何在汇编程序中将值存储在寄存器中,而不是内存中。你将使用图着色技术来找到值与寄存器之间的有效映射。一旦你的寄存器分配器的初始版本工作正常,你将使用另一种技术——寄存器合并,进一步提高其效率,去除一些不必要的汇编指令。
下一步 最后,我们将总结一些关于如何继续学习并独立构建你的编译器的建议。
第二部分和第三部分都建立在第一部分的基础上,但它们是相互独立的。你可以完成其中任何一部分、两部分,或都不完成。附录提供了一些有用的信息,供你在过程中参考。
附录 A:使用 GDB 或 LLDB 调试汇编代码 本附录将引导你如何使用 GDB(GNU 调试器)和 LLDB(LLVM 调试器)来调试汇编程序。当你的编译器生成有问题的汇编时,这些工具将帮助你找出问题所在。
附录 B:汇编生成与代码发射表 本附录中的表格总结了如何将每个 TACKY 构造转换为汇编,并且如何在代码发射过程中打印每个汇编构造。在更新这些处理阶段的所有章节中,都包括类似的表格,展示了该章节中所做的更改;本附录将这些内容整合在一起。
最后,免责声明:本书覆盖了很多内容,但并不涵盖所有内容。有一些 C 语言中非常重要的部分我们不会实现:如函数指针、可变长度参数列表、typedef和类型限定符,如const,仅举几例。我们并不是尽可能多地塞入功能,而是会深入探讨我们实现的功能,确保你真正理解它们是如何工作以及为什么这样工作。通过这种方式,你将掌握继续自主构建所需的所有技能和概念。
如何使用本书
每一章都是实现特定功能的详细指南。在每章的开头,我会讨论你将要构建的功能以及你需要理解的任何重要概念,以便开始。接着,我们将逐步讲解如何更新编译器的各个阶段,以支持这个新功能。如果某些步骤特别复杂或重要,我会提供伪代码。你不需要严格按照伪代码操作;它的目的是帮助你明确要完成的目标,而不是规定具体的操作细节。
每一章都在前一章的基础上构建,因此你需要按顺序完成它们,除非你跳过第二部分直接开始第三部分。
测试套件
每一章都包含几个检查点,你可以停下来并使用本书的测试套件测试你的编译器,该测试套件可以在<wbr>github<wbr>.com<wbr>/nlsandler<wbr>/writing<wbr>-a<wbr>-c<wbr>-compiler<wbr>-tests中找到。每章的测试套件包括一组无效的测试程序,你的编译器应当拒绝并显示错误信息;还包括一组有效的测试程序,编译器应能成功编译。使用提供的test_compiler脚本来运行这些测试。
额外加分特性
一些章节提到额外的语言特性,你可以自行实现;我称这些为“额外加分”特性。额外加分特性与章节中讲解的主要特性相关。你可以运用已经学到的技术来实现这些特性,但需要自己解决细节。你可能需要查看一些测试程序的汇编输出,以弄清楚如何处理这些特性。你还需要查阅外部参考资料,比如 C 标准和 x64 指令集文档(你可以在第 xxxvi 页的“附加资源”中找到这些和其他资源的链接)。这些额外加分特性完全是可选的;你可以尝试那些看起来有趣的特性,跳过那些没有兴趣的特性。
这些特性的测试已包含在测试套件中,但默认情况下不会运行。你可以通过传递适当的命令行选项给test_compiler来运行它们。
选择实现语言的一些建议
虽然可以用任何编程语言编写编译器,但有些语言比其他语言更适合这一任务。我们将创建一个for C 的编译器,但我不建议用 C 来编写它。尽管 C 作为编程语言有其优势,但这个项目并不发挥其优势。你最好选择一种具有更容易的内存管理和更丰富标准库的语言。
你还应该考虑使用支持模式匹配的语言。你可以将其看作是一种升级版的switch语句,允许你为具有不同结构和包含不同数据的值定义不同的情况。(注意,这与正则表达式匹配不同,正则表达式匹配有时也被称为“模式匹配”。)我们第一段伪代码演示了模式匹配的使用:
greet(someone):
match someone with
| ImportantPerson(title, last_name) ->
say("Good day to you, {title} {last_name}!")
| Friend(first_name) -> say("Hello, {first_name}!")
| Stranger -> say("Howdy, stranger!")
| Animal(name, species) ->
say("Hi, {name}! Who's a good {species}? It's you!")
这对于分析和转换程序非常有用,因为程序通常包含多种类型的表达式、语句等,例如:
do_something(expr):
match expr with
| Constant(int) -> do_something_for_int(int)
| BinaryExpr(op, left, right) ->
do_something(left)
do_something(right)
// handle more kinds of expressions
本书中的伪代码到处都使用了模式匹配,因此如果你使用一种支持模式匹配的语言,会更容易跟上进度。
长期以来,模式匹配是函数式编程语言(如 ML 和 Haskell)的专属功能。(这些语言在编程语言学术界非常流行,绝非巧合。)最近,几乎所有人都注意到模式匹配非常有用,它正在进入更多的主流语言。Rust 和 Swift 都支持模式匹配,Python 在 3.10 版本中添加了此功能,Java 从 16 版本开始逐步构建对其的支持。在开始用你喜欢的语言编写编译器之前,做一些研究,了解该语言对模式匹配的支持情况。根据你找到的信息,你可能决定使用该语言的最新版本,使用模式匹配库(例如,C++ 有几个这样的库),或者使用你第二喜欢的语言。或者你可能决定忽略我的建议;模式匹配很有用,但没有它也能应付。
系统要求
要完成这个项目,你需要一台运行 macOS 或 Linux 系统的计算机,且配备 x64 处理器(或者一台配备 Apple Silicon 处理器的 Mac,该处理器可以无需太多麻烦地模拟 x64)。如果你使用的是 Windows 计算机,你需要通过 Windows 子系统 Linux(WSL)设置一个 Linux 环境。你可以在 <wbr>docs<wbr>.microsoft<wbr>.com<wbr>/en<wbr>-us<wbr>/windows<wbr>/wsl<wbr>/install 找到 WSL 的安装说明。
这个项目有两个依赖项。要运行test_compiler,你需要 Python 3.8 或更高版本。你可能已经安装了较新版本的 Python;如果没有,可以从 <wbr>www<wbr>.python<wbr>.org<wbr>/downloads 下载,或通过系统的包管理器安装。(有关详细的安装说明,请参见本书的网页 <wbr>norasandler<wbr>.com<wbr>/book<wbr>/#setup。)要检查是否安装了合适版本的 Python,请运行:
$ **python3 --version**
你还需要一个真正的 C 编译器(严格来说,是一个真正的 C 编译器驱动程序)来调用预处理器、汇编器和链接器。测试脚本也依赖于编译器驱动程序。如果你使用的是 Linux,使用 GCC 作为编译器驱动程序。如果你使用的是 macOS,使用 Xcode 中包含的 Clang 版本。(测试脚本使用gcc命令来调用编译器驱动程序;Xcode 的 Clang 安装时会有 clang 和别名 gcc。)最好安装一个可以逐步调试汇编代码的调试器,帮助你调试编译器生成的代码。我建议在 Linux 上使用 GDB,在 macOS 上使用 LLDB 进行调试。
在 Linux 上安装 GCC 和 GDB
如果您使用的是 Linux,应该使用 GCC 作为编译器驱动程序,使用 GDB 作为调试器。要检查它们是否已经安装,运行:
$ **gcc -v**
$ **gdb -v**
如果缺少其中任何一条命令,您可以通过系统的包管理器安装它们。例如,在 Ubuntu 上安装这两个工具,运行:
$ **sudo apt-get install gcc gdb**
在 macOS 上安装命令行开发工具
在 macOS 上最简单的选项是安装 Xcode 命令行开发工具,这些工具包括 Clang 编译器和 LLDB 调试器。要检查它们是否已经安装,运行:
$ **clang -v**
如果工具尚未安装,当您尝试运行此命令时,系统会提示您安装它们。
本书中的示例是用 GCC 编译的,因此如果您使用 Clang 编译,它生成的汇编代码有时会有所不同。这些差异不会影响您完成项目的能力。
在 Apple Silicon 上运行
如果您的计算机使用 Apple Silicon 处理器(苹果的 ARM 芯片),您需要使用 Rosetta 2 来运行您编译的程序。最简单的解决方案是将所有内容——包括编译器和测试脚本——作为 x64 二进制文件在 Rosetta 2 下运行。要打开 x64 终端,运行:
$ **arch -x86_64 zsh**
您可以在这个终端中运行您的编译器、Clang、编译后的程序以及 test_compiler,一切应该都能正常工作。只需确保构建您的编译器以便它能在 x64 上运行,而不是 ARM。
如果 arch 命令无法工作,可能是因为尚未安装 Rosetta 2。要安装它,请运行:
$ **softwareupdate --install-rosetta --agree-to-license**
验证您的设置
测试脚本包含一个 --check-setup 选项,您可以使用它来确保系统已正确设置。运行以下命令以下载测试套件并验证您的设置:
$ **git clone https://github.com/nlsandler/writing-a-c-compiler-tests.git**
$ **cd writing-a-c-compiler-tests**
$ **./test_compiler --check-setup**
All system requirements met!
如果测试脚本没有报告任何问题,您就可以开始了!
附加资源
您可以在本书的网页上找到勘误、更新、链接以及其他资源,网址为 <wbr>norasandler<wbr>.com<wbr>/book<wbr>/。如果您在项目或测试脚本中遇到问题,请先查看此页面。本书的网页会包含针对 GCC、Xcode 命令行工具和该项目所依赖的其他工具的最新版本发布的更新。
如果您遇到困难,并且希望查看该项目的完整工作实现,请参考本书的参考实现:NQCC2,即不完全的 C 编译器,可以在 <wbr>github<wbr>.com<wbr>/nlsandler<wbr>/nqcc2 获取。它是用 OCaml 编写的,但有很多注释可以帮助您理解,即使您不是 OCaml 程序员。
最后,以下是一些你可能会发现有用的外部资源。如果你决定实现任何额外的加分功能,或者进一步构建你的编译器,这些资源尤其有用:
-
C 标准 规定了 C 程序应该如何表现。我们将使用 C17(ISO/IEC 9899:2018),这是本书编写时最新的标准版本。你可以从国际标准化组织(ISO)购买副本,网址是
www.iso.org/standard/74528.html。另外,如果你不喜欢为 PDF 支付 200 美元,你可以使用标准的类似草案版本,该版本可在www.open-std.org/JTC1/SC22/WG14/www/docs/n2310.pdf上免费下载。这是 C23 的早期草案——C17 之后的下一个版本,带有显示更改的差异标记。它不是官方的 ISO 标准,所以我不建议用它来构建生产级的 C 编译器,但对于这个项目来说,它已经足够接近了。 -
System V 应用程序二进制接口(ABI) 定义了一组约定,供 Unix 类操作系统上的可执行文件遵循。这将在 第九章 中变得重要,当我们实现函数调用时。你可以在
gitlab.com/x86-psABIs/x86-64-ABI找到 x64 系统的最新版本的 System V ABI。 -
Intel 64 软件开发者手册 (
www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html) 是英特尔官方文档,涵盖 x64 指令集。我们关注的是第 2 卷,即指令集参考。还有一个非官方版本,位于www.felixcloutier.com/x86/,它更易于浏览。 -
编译器探索器 (
godbolt.org) 是一个非常实用的网站,你可以在这里查看各种广泛使用的编译器如何将你的代码转换成汇编语言。它使得比较不同编译器的输出变得非常容易,并且能够看到不同优化级别和编译器标志的影响。
注意
C23 计划于 2024 年发布,取代 C17。就我们的目的而言,C17 和 C23 之间的差异并不显著。我们不会实现 C23 中引入的新语言特性,但我们也没有实现 C17 中的所有内容。我们实现的 C 子集在两个版本的标准中基本相同。如果你对 C23 中的不同之处感兴趣,可以在 <wbr>open<wbr>-std<wbr>.org<wbr>/JTC1<wbr>/SC22<wbr>/WG14<wbr>/www<wbr>/docs<wbr>/n3096<wbr>.pdf 找到一个免费的、几乎最终版的草案,另外也可以在 <wbr>en<wbr>.cppreference<wbr>.com<wbr>/w<wbr>/c<wbr>/23上看到一个非正式的变更列表。
让我们开始吧!
我们已经涵盖了所有的前置知识,准备好开始了。在第一章中,我们将编写我们的第一个 C 程序。
第一部分 基础知识

描述
第一章:1 一个最小的编译器

在本章中,你将编写一个最小的编译器,只能处理最简单的 C 程序。你将学习如何读取一个简单的汇编程序,并实现四个基本的编译器阶段,这些阶段将贯穿本书的其余部分。让我们先来看看这些编译器阶段。
四个编译器阶段
你在本章中编写的编译器将分四个阶段处理源代码:
-
词法分析器将源代码拆分成一系列标记。标记是程序的最小语法单位;它们包括分隔符、算术符号、关键字和标识符。如果程序像一本书,那么标记就像单独的单词。
-
解析器将标记列表转换为抽象语法树(AST),它以一种我们可以轻松遍历和分析的形式表示程序。
-
汇编生成阶段将抽象语法树转换为汇编代码。在此阶段,我们仍然以编译器可以理解的数据结构来表示汇编指令,而不是文本形式。
-
代码生成阶段将汇编代码写入文件,以便汇编器和链接器可以将其转换为可执行文件。
这是一种典型的编译器结构方式,尽管具体阶段和中间表示形式有所不同。对于本章来说,这也有些过于复杂——你可以在一个阶段内编译本章处理的程序——但是现在设置这个结构将使你在未来章节中更容易扩展编译器。随着你实现更多的语言特性,你将扩展这些编译器阶段,并添加一些新的阶段。每一章开始时,都会展示一个当前的编译器架构图,包括你已经实现的阶段以及你需要添加的任何新阶段。本章的架构图展示了你即将实现的四个阶段。在后续章节的图表中,新的阶段将用粗体标出。
在开始编写代码之前,先快速了解如何使用系统中已安装的 C 编译器将 C 代码编译为汇编代码,并了解如何阅读汇编程序。
你好,汇编语言!
最简单的 C 程序如下所示:列表 1-1。
int main(void) {
return 2;
}
列表 1-1:一个返回数字 2 的简单程序
该程序由一个单独的函数组成,main,其中包含一个< samp class="SANS_TheSansMonoCd_W5Regular_11">return语句,返回一个整数(在这种情况下是2)。将此程序保存为return_2.c,然后使用gcc命令将其转换为汇编代码:
$ **gcc -S -O -fno-asynchronous-unwind-tables -fcf-protection=none** **return_2.c**
如果你使用的是 macOS,这个命令可能会调用 Clang 而不是 GCC,但命令行选项的效果是相同的。这些选项会生成相当易读的汇编代码:
-S 不要运行汇编器或链接器。这会让编译器生成汇编代码,而不是二进制文件。
-O 优化代码。这会删除一些我们目前不关心的指令。
-fno-asynchronous-unwind-tables 不生成用于调试的展开表。我们不需要它。
-fcf-protection=none 禁用控制流保护,这是一种安全特性,会添加一些我们当前不关心的额外指令。控制流保护可能在你的系统上默认已经禁用,在这种情况下,这个选项不会起作用。如果你使用的是不支持该选项的旧版本 GCC 或 Clang,请跳过这个选项。
结果保存在return_2.s中,应该类似于清单 1-2。
.globl main
main:
movl $2, %eax
ret
清单 1-2: 清单 1-1 中的程序翻译成汇编代码
你的 .s 文件可能包含其他一些行,但你现在可以安全地忽略它们。 清单 1-2 中的四行是一个完整的汇编程序。汇编程序包含几种类型的语句。第一行,.globl main,是一个汇编指令,它为汇编器提供指示。汇编指令总是以句点开头。在这里,main是一个符号,它表示一个内存地址。符号出现在汇编指令和汇编指令中;例如,指令jmp main会跳转到main符号所指向的地址。
.globl main指令告诉汇编器,main是一个全局符号。默认情况下,你只能在定义该符号的同一个汇编文件(因此也是同一个目标文件)中使用它。但由于main是全局的,其他目标文件也可以引用它。汇编器将在目标文件的符号表部分记录这一事实,链接器在链接目标文件时会使用符号表。符号表包含目标文件或可执行文件中所有符号的信息。
在第二行,我们使用 main 作为后续代码的标签。标签由一个字符串或数字和一个冒号组成。标签标记了一个符号所指代的位置。这个特定的标签将 main 定义为下一行 movl 指令的地址。汇编器并不知道该指令的最终内存地址,但它知道该指令所在的段以及它在该段中的偏移量。(一个目标文件由多个段组成,包含不同类型的数据;例如,有用于机器指令、全局变量、调试信息等的独立段。不同的段在运行时加载到程序地址空间的不同部分。)main 的地址将在文本段中,该段包含机器指令。因为 main 指向该汇编文件中的第一条机器指令,所以它在文本段中的偏移量为 0。汇编器会在符号表中记录这个偏移量。
下一行的 movl 指令是机器指令,它出现在最终的可执行文件中。列表 1-2 中的 movl 指令将值 2 移入一个寄存器,寄存器是一个非常小且快速的存储单元,它有自己的名字,并且位于 CPU 上。这里,我们将 2 移入名为 EAX 的寄存器,它可以容纳 32 位。根据我们平台的调用约定,返回值通过 EAX(或 RAX,视返回值类型而定,64 位等价物)传递给调用者。由于调用者也知道这个约定,它可以在函数返回后从 EAX 中获取返回值。movl 中的 l 后缀表示该指令的操作数是长字,即 32 位整数(在 x64 汇编中,不同于大多数现代 C 实现,long 表示 32 位)。movq 指令操作的是四字,即 64 位整数。当我不指定指令大小时,我会直接写 mov 来引用这条指令。
最后,我们有另一条机器指令 ret,它将控制权返回给调用者。你可能会看到 retq,而不是 ret,因为该指令隐式地操作一个 64 位的返回地址。我跳过了很多细节,包括调用约定是什么、谁决定这些约定以及 ret 如何知道调用者的位置。等我们在第九章中讲解函数调用时,我会再回到这些问题。
注意
本书中所有的汇编列表都使用 AT&T 语法。在其他地方,你可能会看到用英特尔语法编写的 x64 汇编。它们是同一种语言的两种不同表示方式;最大的区别在于它们将指令操作数的顺序不同。
到此为止,公平地说,我们可以问一下调用者是谁,因为 main 是该程序中唯一的函数。你也许会想知道,为什么我们需要 .globl main 指令,因为似乎没有其他目标文件可能包含对 main 的引用。答案是,链接器会添加一些称为 crt0 的包装代码,用于在 main 执行前进行设置,并在其退出后进行清理。(crt 代表 C 运行时。)这个包装代码执行以下操作:
-
调用 main 函数。这就是为什么 main 必须是全局可见的;如果不是,crt0 就无法调用它。
-
获取 main 的返回值。
-
调用 exit 系统调用,将 main 的返回值传递给它。然后,exit 处理操作系统内部需要执行的任务,以终止进程并将返回值转化为退出码。
关键是,你无需担心进程的启动或结束;你可以将 main 当作一个普通的函数来处理。
链接器还通过一个称为 符号解析 的过程,将符号表中的每个条目与一个内存地址关联起来。然后,它执行 重定位,将每个使用符号的地方更新为使用相应的地址。(实际上,链接过程比这要复杂得多!如果你想了解更多,参考第 21 页的“附加资源”。)
要验证return_2.s中的汇编代码是否有效,先汇编并链接它,然后运行,并使用$?命令行操作符检查退出代码:
$ **gcc return_2.s -o return_2**
$ **./return_2**
$ **echo $?**
2
请注意,你可以像普通源文件一样将汇编文件传递给gcc命令。它假设所有扩展名为.s的输入文件都包含汇编代码,因此会直接汇编并链接这些文件,而不会先尝试编译它们。
编译器驱动程序
正如你在介绍中学到的,编译器单独并不十分有用。你还需要一个编译器驱动程序,它调用预处理器、编译器、汇编器和链接器。因此,你需要在开始编写编译器之前先编写一个编译器驱动程序。它应该通过三步将源文件转换为可执行文件:
1. 运行此命令以预处理源文件:
**gcc -E -P** **`INPUT_FILE`** **-o** **`PREPROCESSED_FILE`**
该命令首先对INPUT_FILE进行预处理,然后将结果写入PREPROCESSED_FILE。-E选项告诉 GCC 仅运行预处理器,而不执行编译过程的后续步骤。默认情况下,预处理器会生成行标记,指示原始源文件及该文件中每部分的起始行号。(由于#include指令,预处理文件可能包含来自多个源文件的代码。)-P选项告诉预处理器不生成行标记;我们的词法分析器和解析器将无法处理它们。按照惯例,PREPROCESSED_FILE文件应具有.i扩展名。
2. 编译预处理后的源文件,并生成一个扩展名为.s的汇编文件。你需要跳过这一步,因为你还没有编写编译器。完成后删除预处理文件。
3. 汇编并链接汇编文件,生成可执行文件,使用以下命令:
**gcc** **`ASSEMBLY_FILE`** **-o** **`OUTPUT_FILE`**
完成后删除汇编文件。
你的编译器驱动程序必须具有特定的命令行接口,以便本书的测试脚本test_compiler能够运行它。它必须是一个命令行程序,接受一个指向 C 源文件的路径作为唯一参数。如果该命令成功,它必须在与输入文件相同的目录中生成一个可执行文件,并且文件名相同(去掉文件扩展名)。换句话说,如果你运行 ./YOUR_COMPILER /path/to/program.c,它应该在/path/to/program生成一个可执行文件,并以退出码 0 终止。如果编译器失败,编译器驱动程序应该返回一个非零退出码,并且不应该生成任何汇编或可执行文件;这就是test_compiler验证编译器捕捉到无效程序错误的方法。最后,编译器驱动程序应支持以下选项,这些选项是test_compiler用于测试中间阶段的:
| --lex | 指示它运行词法分析器,但在解析之前停止 |
|---|---|
| --parse | 指示它运行词法分析器和解析器,但在汇编生成之前停止 |
| --codegen | 指示它执行词法分析、解析和汇编生成,但在代码生成之前停止 |
这些选项都不应生成任何输出文件,如果没有遇到错误,所有选项都应以退出码 0 终止。你可能还想添加一个-S选项,它指示你的编译器生成汇编文件,但不进行汇编或链接。你需要这个选项来运行第三部分中的测试;它对于第一部分和第二部分不是必须的,但它对调试非常有用。
一旦你编写了编译器驱动程序,就可以开始着手编写实际的编译器了。你需要实现本章开头列出的四个编译器阶段:词法分析器,它生成标记列表;解析器,它将这些标记转换成抽象语法树;汇编生成阶段,它将抽象语法树转换为汇编代码;以及代码生成阶段,它将汇编代码写入文件。让我们从词法分析器开始。
词法分析器
词法分析器应读取源文件并生成一个标记列表。在开始编写词法分析器之前,你需要了解可能会遇到的所有标记。以下是清单 1-1 中的所有标记:
| int | 一个关键字 |
|---|---|
| main | 一个标识符,其值为“main” |
| ( | 一个左括号 |
| void | 一个关键字 |
| ) | 一个右圆括号 |
| { | 一个左大括号 |
| return | 一个关键字 |
| 2 | 一个常量,其值为“2” |
| ; | 一个分号 |
| } | 一个右大括号 |
我在这里使用了两个与词法分析器相关的术语。标识符是一个字母或下划线,后面跟着字母、下划线和数字的混合。标识符区分大小写。整数常量由一个或多个数字组成。(我们将在第二部分中添加字符常量和浮点常量。本书中不会实现十六进制或八进制整数常量。)
请注意,标识符和常量在此标记列表中有值,但其他类型的标记没有。标识符有许多可能的值(例如,foo,variable1,以及 my_cool_function),因此词法分析器生成的每个标识符标记必须保留其特定的名称。同样,每个常量标记需要保留一个整数值。相比之下,只有一个可能的 return 关键字,因此 return 关键字标记不需要存储任何额外的信息。尽管现在只有一个标识符 main,你应该构建词法分析器以便以后支持任意标识符。还要注意,这里没有空白标记。如果我们正在编译像 Python 这样的语言,其中空白符很重要,我们就需要包括空白符标记。
你可以通过正则表达式(regex)识别每种标记类型。表 1-1 给出了每个标记在 Perl 兼容正则表达式(PCRE)语法中的对应正则表达式。
表 1-1: 标记及其正则表达式
| 标记 | 正则表达式 |
|---|---|
| 标识符 | [a-zA-Z_]\w*\b |
| 常量 | [0-9]+\b |
| int 关键字 | int\b |
| void 关键字 | void\b |
| return 关键字 | return\b |
| 左括号 | () |
| 右括号 | ) |
| 左花括号 | { |
| 右花括号 | } |
| 分号 | ; |
程序标记化的过程大致如下图所示:Listing 1-3。
while input isn't empty:
if input starts with whitespace:
trim whitespace from start of input
else:
find longest match at start of input for any regex in Table 1-1
if no match is found, raise an error
convert matching substring into a token
remove matching substring from start of input
Listing 1-3: 将字符串转换为标记序列
请注意,标识符、关键字和常量必须在单词边界处结束,单词边界由 \b 表示。例如,123;bar 的前三个数字匹配常量的正则表达式,你应该将它们转换为常量 123。这是因为 ; 不在 \w 字符类中,因此 3 和 ; 之间的边界是单词边界。然而,123bar 的前三个数字并不匹配常量的正则表达式,因为这些数字后面跟着的是属于 \w 字符类的字符,而不是单词边界。如果你的词法分析器看到像 123bar 这样的字符串,它应该抛出错误,因为该字符串的开头不匹配任何标记的正则表达式。
现在你已经准备好编写词法分析器了。以下是一些要牢记的提示:
像其他标识符一样对待关键字。
标识符的正则表达式也匹配关键字。不要试图同时找到下一个标记的结尾并判断它是否是关键字。首先,找到标记的结尾。然后,如果它看起来像是标识符,再检查它是否匹配任何关键字。
不要在空白字符处分割。
不建议通过按空白字符拆分字符串来开始,因为空白字符并不是标记之间的唯一边界。例如,main(void)包含四个标记,但没有空白字符。
你只需要支持 ASCII 字符。
本书的测试程序只包含 ASCII 字符。C 标准提供了一种叫做通用字符名称的机制,用于在标识符中包含非 ASCII 字符,但我们不会实现它们。许多 C 语言实现允许直接使用 Unicode 字符,但你也不需要支持这一点。
一旦你写完了词法分析器,下一步就是测试它。
解析器
现在你已经有了标记列表,你将需要弄清楚这些标记如何组合成语言构造。在大多数编程语言中,包括 C 语言,这种组合是分层的:程序中的每个语言构造由多个更简单的构造组成。单独的标记表示最基本的构造,如变量、常量和算术运算符。树形数据结构是表示这种层次关系的自然方式。正如我在本章开头所提到的,解析器会接受由词法分析器生成的标记列表,并生成一个称为抽象语法树(AST)的树形表示。解析器创建了 AST 之后,汇编生成阶段会遍历它,确定应该生成什么样的汇编代码。
编写解析器有两种方法:你可以手写,或者使用像 Bison 或 ANTLR 这样的解析器生成器来自动生成你的解析代码。使用解析器生成器工作量较小,但本书使用的是手写解析器。这样做有几个原因。最重要的是,手写解析器可以让你深入理解解析器的工作原理。使用解析器生成器很容易,但可能无法完全理解它所生成的代码。许多解析器生成器的学习曲线也很陡峭,因此你最好在花时间掌握具体工具之前,先学习一些通用的技术,如递归下降解析。
手写解析器相对于解析器生成器生成的解析器也有一些实际优势:它们可能更快,更容易调试,灵活性更高,并且对错误处理的支持更好。事实上,GCC 和 Clang 都使用手写解析器,这表明手写解析器并非仅仅是一个学术练习。
话虽如此,如果你更愿意使用解析器生成器,那也没问题。这完全取决于你希望从本书中获得什么。不过请注意,我不会讨论如何使用这些工具,所以你需要自己弄清楚。如果你选择这条路线,请确保研究你选择的实现语言中可用的解析库。
无论你选择哪个选项,你都需要设计你的解析器应该生成的抽象语法树。让我们从查看一个抽象语法树的示例开始。
一个示例抽象语法树
请考虑列表 1-4 中的if语句。
if (a < b) {
return 2 + 2;
}
列表 1-4:一个简单的 if 语句
相应抽象语法树的根节点表示整个if语句。这个节点有两个子节点:
-
条件,a < b
-
语句体,return 2 + 2;
这些结构中的每一个都可以进一步拆解。例如,条件是一个具有三个子节点的二元操作:
-
左操作数,变量a
-
运算符,<
-
右操作数,变量b
图 1-1 显示了这段代码的完整抽象语法树,其中一个If节点代表if语句的根节点,一个Binary节点代表条件,等等。

图 1-1:一个简单 if 语句的抽象语法树 描述
图 1-1 中的抽象语法树包含与列表 1-4 相同的信息:它展示了程序将执行的操作及其顺序。但与列表 1-4 不同,这棵抽象语法树以一种编译器可以轻松处理的方式呈现这些信息。在后续阶段,编译器将遍历这棵树,在遇到每种节点类型时执行不同的操作。你的编译器将使用这种通用策略来完成一系列不同的任务,从解析变量名到生成汇编代码。
现在,让我们看看列表 1-1 中的 C 程序的抽象语法树。图 1-2 显示了这个更简单的抽象语法树。

图 1-2: 列表 1-1 的抽象语法树 描述
接下来,你将定义必要的数据结构,以便在代码中构建像图 1-2 这样的抽象语法树。
抽象语法树定义
本书提供了用一种专门用于描述 AST 的语言——Zephyr 抽象语法描述语言(ASDL)——的 AST 描述。我在这里使用 ASDL 作为一种方便的、与编程语言无关的符号表示。你不会直接在编译器中使用 ASDL;而是会在你选择的实现语言中定义等效的数据结构。接下来的几段简要概述了 ASDL。你可以在第 21 页的“附加资源”中找到描述整个语言的原始论文链接。
列表 1-5 包含了你将在本章实现的 C 语言子集的 ASDL 定义(类似于列表 1-1 的程序)。
program = Program(function_definition)
function_definition = Function(identifier name, statement body)
statement = Return(exp)
exp = Constant(int)
列表 1-5:本章的抽象语法树定义
列表 1-5 中的每一行描述了如何构建一种 AST 节点类型。请注意,图 1-2 中的每个 AST 节点都有对应的 ASDL 定义。这个 AST 的根节点是 program 节点。目前,这个节点只能有一个子节点,类型是 function_definition。一个函数定义有两个子节点:一个函数名,类型为 identifier,以及一个函数体,类型为 statement。现在,函数仅由一个语句组成,并且没有参数。稍后,你将添加对函数参数和更复杂函数体的支持。请注意,在这个定义中,name 和 body 是字段名,它们是对人类友好的标签,不会改变 AST 的结构。字段名在 ASDL 中是可选的。当字段名存在时,它紧跟在字段类型之后,类似于 identifier name。
在 ASDL 中,identifier 是一个内置类型,用来表示函数和变量名;它们本质上是字符串,但我们希望将它们与像 "Hello, World!" 这样的字符串字面量区分开,因为它们出现在抽象语法树(AST)的不同部分。由于 identifier 是一个内置类型,它没有子节点。function_definition 节点的另一个子节点是 statement。目前,唯一的语句类型是 return 语句。这个语句有一个子节点:它的返回值,类型是 exp,即 表达式 的缩写。当前唯一的 exp 是常量整数;int 是另一个内置 ASDL 类型,因此 AST 已经完成。
当然,return 语句并不是 C 语言中的唯一语句,常量也不是唯一的表达式。在后续章节中,我们将添加新的构造函数来表示其他类型的语句和表达式。例如,我们将为 statement 添加一个 If 构造函数,用来表示 if 语句:
statement = Return(exp) | If(exp condition, statement then, statement? else)
statement? 类型表示一个可选语句,因为 if 语句并不总是有一个 else 子句。| 符号用来分隔构造函数。在这里,它告诉我们一个 statement 可以是一个 return 语句,由 Return 构造函数定义,或者是一个 if 语句,由 If 构造函数定义。
现在轮到你实现列表 1-5 中的 AST 定义,使用你写编译器时所用的任何语言。表示 AST 的标准方式在不同的编程语言之间有所不同。如果你在像 F#、ML 或 Haskell 这样的函数式语言中实现编译器,可以使用代数数据类型来定义 AST。Rust 中的枚举本质上是代数数据类型,因此它们也可以表示 AST。如果你使用的是像 Java 这样的面向对象语言,可以为每种节点类型定义一个抽象类,然后为每个构造器定义继承自这些抽象类的类。例如,你可以定义一个Exp抽象类,以及继承自它的Constant和BinaryExp类。
如果你仍然不确定如何编写 AST 定义,请查看第 21 页中的“附加资源”。
正式语法
AST 包含了编译器后续阶段所需的所有信息。然而,它并没有告诉你每个语言结构是由哪些令牌构成的。例如,列表 1-5 中的 AST 描述并没有说明return语句必须以分号结尾,或者函数体需要用大括号括起来。(这就是为什么它被称为抽象语法树——相比之下,具体语法树包括了原始输入中的每个令牌。)一旦你拥有了 AST,这些具体的细节就变得不重要,因此可以方便地省略它们。然而,当你在解析一系列令牌以构建 AST 时,这些细节非常重要,因为它们指示了每个语言结构的开始和结束。
因此,除了抽象语法树(AST)描述之外,你还需要一组规则来定义如何从令牌列表构建语言结构。这组规则称为正式语法,它与 AST 描述紧密相关。列表 1-6 定义了 C 程序的正式语法,类似于列表 1-1。
<program> ::= <function>
<function> ::= "int" <identifier> "(" "void" ")" "{" <statement> "}"
<statement> ::= "return" <exp> ";"
<exp> ::= <int>
<identifier> ::= ? An identifier token ?
<int> ::= ? A constant token ?
列表 1-6:本章的正式语法
清单 1-6 中的语法是 扩展巴科斯范式(EBNF) 表示法。该语法的每一行都是一个 产生式规则,定义了如何通过其他语言构造和符号的序列来形成一个语言构造。每个出现在产生式规则左侧的符号(如
清单 1-6 看起来与 清单 1-5 中的 AST 定义非常相似。事实上,它们具有相同的结构;清单 1-5 中的每个 AST 节点都对应于 清单 1-6 中的一个非终结符号。唯一的区别是,清单 1-6 明确指定了我们将在树的每个节点找到哪些符号,这有助于我们判断何时开始处理 AST 中下一级的节点,以及何时完成对一个节点的处理并返回到其上层父节点。
就像后续章节会为一些 AST 节点引入多个构造器一样,它们也会为相应的符号引入多个产生式规则。例如,下面是如何为
<statement> ::= "return" <exp> ";" | "if" "(" <exp> ")" <statement> ["else" <statement>]
请注意,EBNF 中的方括号表示某些内容是可选的,类似于 ASDL 中的问号。
在编写解析器时,你会参考这个正式的语法,但你不会在编译器中显式地定义这些语法规则。
递归下降解析
现在你已经有了 AST 定义和正式文法,我们来谈谈如何实际编写解析器。我们将使用一种直接的技术,叫做递归下降解析,它使用不同的函数解析每个非终结符号,并返回相应的 AST 节点。例如,当解析器期望遇到清单 1-6 中定义的符号时,它会调用一个函数来解析该符号,并从清单 1-5 中返回statement AST 节点。主要的解析函数解析符号,代表整个程序。每次调用一个函数处理一个新符号时,解析器会下降到树的更低层级。这就是递归下降中的下降部分。(它被称为递归下降是因为文法规则通常是递归的,在这种情况下,处理这些规则的函数也是递归的。例如,一个表达式的操作数可能是另一个表达式;我们将在下一章看到这个例子。)
让我们来逐步解析其中一个解析函数。清单 1-7 中的伪代码展示了如何解析一个符号。
parse_statement(tokens):
expect("return", tokens)
return_val = parse_exp(tokens)
expect(";", tokens)
return Return(return_val)
expect(expected, tokens):
actual = take_token(tokens)
if actual != expected:
fail("Syntax error")
清单 1-7:解析语句
当我们期望剩余的令牌列表以一个
注意,
现在,每个正式语法中的符号只有一个产生式规则。在后面的章节中,当某些符号有多个产生式规则时,解析器将需要确定使用哪个产生式规则。它将通过查看列表中的前几个令牌来实现这一点,而不会移除它们。递归下降解析器通过查看几个令牌来确定使用哪个产生式规则,这种解析器被称为预测解析器。预测解析的替代方法是带回溯的递归下降解析,它涉及依次尝试每个产生式规则,直到找到一个有效的规则。
现在你可以编写自己的递归下降解析器了。记住你需要为清单 1-6 中的每个非终结符号编写一个函数来解析。这里有一些提示,能让这变得更容易:
编写一个漂亮的打印程序。
一个漂亮的打印程序是一个以人类可读的方式打印出你的 AST 的函数。这将使调试解析器变得更加容易。对于 清单 1-1 中的程序,一个漂亮打印的 AST 可能如下所示:
Program(
Function(
name="main",
body=Return(
Constant(2)
)
)
)
提供有用的错误信息。
这还将帮助你调试解析器,并且使你的编译器更加用户友好。像 Expected ";" but found "return" 这样的错误信息比 Fail 更加有帮助。
汇编生成
汇编生成阶段应将 AST 转换为 x64 汇编代码,按照程序执行的大致顺序遍历 AST,为每个节点生成适当的汇编指令。首先,定义一个适当的数据结构来表示汇编程序,就像你在编写解析器时定义数据结构来表示 AST 一样。你正在添加另一个数据结构,而不是立即将汇编写入文件,这样你就可以在生成汇编代码后修改它。在本章中你不需要重写任何汇编代码,但在后面的章节中你将需要。
我将再次使用 ASDL 来描述我们将用来表示汇编的结构。清单 1-8 中有定义。
program = Program(function_definition)
function_definition = Function(identifier name, instruction* instructions)
instruction = Mov(operand src, operand dst) | Ret
operand = Imm(int) | Register
清单 1-8:汇编程序的 ASDL 定义
这看起来和上一节中的 AST 定义很相似!事实上,这就是一个 AST 定义,但它是针对汇编程序的,而不是 C 程序的。每个节点对应汇编中的一个构造,例如单条指令,而不是 C 中的构造,比如语句。我将把 清单 1-8 中定义的数据结构称为汇编 AST,以区别于 清单 1-5 中定义的 AST。
让我们来详细看看 列表 1-8。program 类型表示一个完整的汇编程序,它由一个单独的 function_definition 组成。一个 function_definition 有两个字段:函数名和一系列指令。* 在 instruction* 中表示该字段是一个列表。instruction 类型有两个构造函数,用来表示可以出现在我们汇编程序中的两条指令:mov 和 ret。mov 指令有两个操作数:它将第一个操作数(源操作数)复制到第二个操作数(目标操作数)。ret 指令没有任何操作数。operand 类型定义了指令的两个可能操作数:一个寄存器和一个 立即数(或常量)。目前,你不需要指定操作的寄存器,因为生成的代码将只使用 EAX。你将在后续章节中提到其他寄存器。这个阶段的结构与解析器类似:你需要一个函数来处理每种类型的 AST 节点,该函数会调用其他函数来处理该节点的子节点。表 1-2 描述了你应为每个 AST 节点生成的汇编代码。
表 1-2: 将 AST 节点转换为汇编代码
| AST 节点 | 汇编构造 |
|---|---|
| Program(function_definition) | Program(function_definition) |
| Function(name, body) | Function(name, instructions) |
| Return(exp) | Mov(exp, Register) Ret |
| Constant(int) | Imm(int) |
这个翻译相当直接,但有几点需要注意。首先,一个语句可能会生成多条汇编指令。其次,只有当一个表达式能够表示为单一的汇编操作数时,这种翻译才有效。现在这是成立的,因为唯一的表达式是一个常量整数,但当我们在下一章引入一元运算符后,就不再适用了。到时候,编译器需要生成多条指令来计算表达式,然后确定该表达式存储的位置,以便将其复制到 EAX 寄存器中。
代码生成
现在你的编译器可以生成汇编指令,最后一步是将这些指令写入文件。这个文件看起来会很像清单 1-2 中的汇编程序,但有几个细节因平台而异。首先,如果你使用的是 macOS,你应始终在函数名前添加一个下划线。例如,将main函数的标签输出为_main。(在 Linux 上不要添加这个下划线。)
第二,如果你使用的是 Linux,你需要在文件末尾添加这一行:
.section .note.GNU-stack,"",@progbits
这一行启用了一个重要的安全加固措施:它表明你的代码不需要可执行栈。如果处理器被允许执行存储在某个内存区域的机器指令,那么该区域就是可执行的。栈,你将在下一章中了解更多,是一个存储局部变量和临时值的内存区域。正常情况下,它不存储机器指令。将栈设置为不可执行是防范某些安全漏洞的基本手段,但并不是每个程序都能启用这一防护措施,因为一些使用了特定非标准语言扩展的程序实际上需要可执行栈。在汇编文件中包括这一行,表明它不需要可执行栈,这样就可以启用这一安全措施。我们在本书中生成的所有代码都不需要可执行栈,因此我们将始终生成这一行。(关于可执行栈的更多信息,请参见第 21 页的“附加资源”部分。)
代码生成阶段应遍历汇编 AST,并打印它遇到的每个结构,就像汇编生成阶段遍历来自清单 1-5 的 AST 一样。由于汇编 AST 与最终的汇编程序非常相似,代码生成阶段将非常简单,即使你在后续章节中向编译器添加更多功能。
表 1-3、1-4 和 1-5 说明了如何打印每个汇编结构。
表 1-3: 格式化顶级汇编结构
| 汇编顶层构造 | 输出 |
|---|
| 程序(函数定义) | 打印出函数定义。在 Linux 系统上,添加以下内容到文件末尾:
.section .note.GNU-stack,"",@progbits
|
| 函数(名称, 指令) |
|---|
.globl `<name> <name>`:
`<instructions>`
|
表 1-4: 格式化汇编指令
| 汇编指令 | 输出 |
|---|---|
| 移动(src, dst) | movl |
| 返回 | ret |
表 1-5: 格式化汇编操作数
| 汇编操作数 | 输出 |
|---|---|
| 寄存器 | %eax |
| 立即数(int) | $ |
确保在指令之间加入换行符。你还应该输出可读性强、格式良好的汇编代码,因为你在调试编译器时会花很多时间阅读这些汇编代码。你可以通过缩进每一行(标签除外)来使汇编代码更具可读性,就像在列表 1-2 中那样。考虑在你的汇编程序中添加注释。汇编中的#符号表示注释,注释掉行的其余部分,类似于 C 语言中的//。
一旦你实现了代码生成阶段,你将能够将像列表 1-1 这样的简单程序编译成可运行的可执行文件。
总结
在本章中,你编写了一个编译器,能够将完整的 C 程序转化为可以在你的计算机上运行的可执行文件。你学习了如何解释用 x64 汇编语言编写的程序、如何使用扩展的巴科斯范式(Backus-Naur Form, BNF)定义语法规则,以及如何在 ASDL 中定义抽象语法树(AST)。你在本章中学到的技能和概念——以及你实现的四个编译器阶段——为你在本书中接下来的所有工作奠定了基础。
在下一章中,你将为编译器添加对一元运算符的支持。在此过程中,你将学习汇编程序如何管理堆栈,并且你将实现一个新的中间表示,使你编译的程序更容易分析、转换和优化。
附加资源
要了解本章介绍的一些概念,查看以下资源。
链接器
-
David Drysdale 的《初学者链接器指南》是一个很好的起点(
<wbr>www<wbr>.lurklurk<wbr>.org<wbr>/linkers<wbr>/linkers<wbr>.html)。 -
Ian Lance Taylor 的 20 篇关于链接器的文章深入探讨了这一主题。第一篇文章可以在
<wbr>www<wbr>.airs<wbr>.com<wbr>/blog<wbr>/archives<wbr>/38找到,目录可以在<wbr>lwn<wbr>.net<wbr>/Articles<wbr>/276782<wbr>/查看。 -
Eli Bendersky 的《共享库中的位置独立代码(PIC)》是一篇博文,提供了编译器、链接器和汇编器如何协同工作以生成位置独立代码的概述,重点讨论了 32 位机器(
<wbr>eli<wbr>.thegreenplace<wbr>.net<wbr>/2011<wbr>/11<wbr>/03<wbr>/position<wbr>-independent<wbr>-code<wbr>-pic<wbr>-in<wbr>-shared<wbr>-libraries)。 -
Eli Bendersky 的《x64 上共享库中的位置独立代码(PIC)》在前一篇文章的基础上展开,专注于 64 位系统(
<wbr>eli<wbr>.thegreenplace<wbr>.net<wbr>/2011<wbr>/11<wbr>/11<wbr>/position<wbr>-independent<wbr>-code<wbr>-pic<wbr>-in<wbr>-shared<wbr>-libraries<wbr>-on<wbr>-x64)。
AST 定义
-
Joel Jones 的《抽象语法树实现惯例》提供了关于如何在各种编程语言中实现 AST 的良好概述(
<wbr>hillside<wbr>.net<wbr>/plop<wbr>/plop2003<wbr>/Papers<wbr>/Jones<wbr>-ImplementingASTs<wbr>.pdf)。 -
Daniel Wang、Andrew Appel、Jeff Korn 和 Christopher Serra 的《Zephyr 抽象语法描述语言》是关于 ASDL 的原创论文。它包括了几个不同语言中 AST 定义的示例(
<wbr>www<wbr>.cs<wbr>.princeton<wbr>.edu<wbr>/~appel<wbr>/papers<wbr>/asdl97<wbr>.pdf)。
可执行堆栈
- Ian Lance Taylor 的《可执行堆栈》是一篇博文,讨论了哪些程序需要可执行堆栈,并描述了 Linux 系统如何判断一个程序的堆栈是否应该是可执行的(
<wbr>www<wbr>.airs<wbr>.com<wbr>/blog<wbr>/archives<wbr>/518)。

描述
第二章:2 一元运算符

C 语言有几种 一元运算符,它们作用于单个值。在本章中,你将扩展编译器,以处理两个一元运算符:否定和按位取反。你将把复杂的、嵌套的一元表达式转换为可以在汇编中表达的简单操作。你不会在单一的编译过程中过滤这一转换,而是会在解析器生成的抽象语法树(AST)和汇编生成过程生成的汇编抽象语法树之间引入一种新的中间表示。你还将把汇编生成过程分解为几个较小的过程。新过程在本章开头的图示中已被加粗标出。
首先,我们来看看一个使用新一元运算符的 C 程序和我们将生成的相应汇编代码。
汇编中的否定和按位取反
在本章中,你将学习如何编译像清单 2-1 这样的程序。
int main(void) {
return ~(-2);
}
清单 2-1:带有否定和按位取反的 C 程序
这个程序包含了一个嵌套表达式,使用了两个新的一元运算符。第一个运算符,否定(-),否定一个整数——这没什么意外。按位取反(~)运算符将一个整数的每一位进行翻转,这样会使整数变为其负数,然后再减去 1。(之所以会有这样的效果,是因为计算机使用一种称为 二进制补码 的系统来表示有符号整数。如果你不熟悉二进制补码,请参见第 45 页中的“附加资源”,其中有一些关于其工作原理的解释链接。)
你的编译器会将清单 2-1 转换为清单 2-2 中的汇编代码。
.globl main
main:
pushq %rbp
movq %rsp, %rbp
subq $8, %rsp
❶ movl $2, ❷ -4(%rbp)
❸ negl -4(%rbp)
❹ movl -4(%rbp), %r10d
❺ movl %r10d, -8(%rbp)
❻ notl -8(%rbp)
❼ movl -8(%rbp), %eax
movq %rbp, %rsp
popq %rbp
ret
清单 2-2:对应清单 2-1 的汇编代码
在 main 后的前三条指令形成了 函数前奏,它设置了当前的栈帧;我将在下一节详细讲解栈时覆盖它们。函数前奏之后,我们计算中间结果 –2,然后是最终结果 1,并将每个结果存储在一个独立的内存地址上。这并不是很高效,因为我们浪费了大量的指令将值从一个地址复制到另一个地址。我们将在第三部分中实现的优化将清理掉这些不必要的复制。
第一条 mov 指令 ❶ 将 2 存储到内存中的一个地址。操作数 -4(%rbp) ❷ 意味着“RBP 寄存器中存储的值减去四”。RBP 中的值是栈上的一个内存地址(稍后会详细讲解),因此 -4(%rbp) 指向的是比当前地址低 4 字节的另一个内存地址。接下来,我们使用 neg 指令 ❸ 对该地址的值进行取反,因此 -4(%rbp) 包含的值变成 -2。(和 mov 一样,neg 指令也有一个 l 后缀,表示它是在操作 32 位值。)
然后我们处理外部的按位取反表达式。首先,我们将存储在 -4(%rbp) 的源值复制到目的地址 -8(%rbp)。我们不能通过单条指令完成这个操作,因为 mov 指令的源操作数和目的操作数不能都是内存地址。至少有一个操作数必须是寄存器或立即数。我们通过将 -2 从内存中复制到一个临时寄存器 R10D ❹,再从该寄存器复制到目的内存地址 ❺ 来绕过这一限制。然后,我们使用 not 指令 ❻ 对 -2 进行按位取反操作,因此内存地址 -8(%rbp) 现在包含我们想要返回的值:~(-2),其结果为 1。为了返回这个值,我们将其移动到 EAX 寄存器中 ❼。最后的三条指令是 函数尾部代码,用于销毁栈帧并从函数返回。
注意
如果你使用 GCC、Clang 或其他生产环境的 C 编译器将 示例 2-1 编译成汇编,它将与 示例 2-2 完全不同。这是因为这些编译器在编译时就会计算常量表达式,即使你已经禁用了优化!我猜它们之所以这样做,是因为某些常量表达式,比如静态变量初始化器,必须在编译时进行计算,而在编译时计算所有常量表达式比只计算部分常量表达式更简单。
栈
关于 清单 2-2,仍有两个未解的问题:函数序言和尾声做了什么,以及为什么我们要相对于 RBP 寄存器中的值引用栈地址。为了回答这些问题,我们需要讨论程序内存中的一个段,叫做 栈。RSP 寄存器,也称为 栈指针,始终保存栈顶的地址。(RSP 指向的是最后使用的栈槽,而不是第一个空闲的栈槽。)与任何栈数据结构一样,你可以将值推入栈中,也可以从栈中弹出值;push 和 pop 汇编指令正是完成这一操作的。
栈向较低的内存地址增长。当你将数据推入栈时,RSP 会减小。这意味着“栈顶”——存储在 RSP 中的地址——是栈中的 最低 地址。本书中的栈图是按照较低的内存地址位于顶部来排列的,因此栈顶位于图的顶部。可以将这些图中的内存地址视为代码列表中的行号。代码列表的顶部是第 1 行,行号随着向下移动而增加;同样,图中的地址随着向下滚动页面或屏幕而增加。需要注意的是,大多数书籍和文章中的栈图采用的是相反的布局:它们将栈顶放在图的底部,因此较低的内存地址显示在页面的下方。我觉得这种布局非常令人困惑,但如果你喜欢它,只需把书倒过来即可。
类似 push $3 这样的指令做了两件事:
-
将要推送的值(在这个例子中是 3)写入栈上的下一个空闲位置。push 和 pop 指令以 8 字节为增量调整栈指针,栈顶的值目前位于 RSP 存储的地址,因此下一个空闲位置是 RSP – 8。
-
将 RSP 减少 8 字节。此时 RSP 中的新地址就是栈顶,而该地址上的值是 3。
图 2-1 说明了 push 指令对栈和 RSP 寄存器的影响。

图 2-1:push $3 对内存和 RSP 的影响 描述
pop 指令执行相反的操作。例如,pop %rax 会将栈顶的值复制到 RAX 寄存器中,然后将 RSP 加上 8 字节。
由于push指令会将栈指针减去 8 字节,因此它必须推送一个 8 字节的值。同样,pop指令总是从栈上弹出一个 8 字节的值。像 int 中的返回值那样的类型为int的值,只有 4 字节。你不能只将 4 字节的值推入栈中,但你可以使用movl指令将一个 4 字节的值复制到你已经分配的栈空间中。Listing 2-2 中的几个指令就是这样做的,包括movl $2, -4(%rbp)。(在 32 位系统上,情况正好相反;你可以推送和弹出 4 字节的值,但不能推送 8 字节的值。在这两种系统上,尽管非常不常见,你也可以使用pushw和popw指令来推送和弹出 2 字节的值;w后缀,代表字(word),表示该指令接受一个 2 字节的操作数。我们在本书中不会使用pushw、popw或任何其他 2 字节指令。)在 x64 系统上,内存地址是 8 字节,因此你可以使用push和pop将它们放入栈中或从栈中取出。这在稍后会派上用场。
栈不仅仅是一个无差别的内存块;它被划分为叫做栈帧的不同部分。每当一个函数被调用时,它通过减少栈指针来在栈顶分配一些内存。这块内存就是该函数的栈帧,用于存储局部变量和临时值。在函数返回之前,它会释放栈帧,将栈指针恢复到之前的值。按照惯例,RBP 寄存器指向当前栈帧的基址;因此,它有时被称为基指针。我们通过相对于 RBP 存储的地址来引用当前栈帧中的数据。这意味着我们不需要绝对地址,因为我们无法提前知道它们。由于栈是向着较低的内存地址增长的,因此当前栈帧中的每个地址都低于 RBP 中存储的地址;这就是为什么像-4(%rbp)这样的局部变量地址,都是相对于 RBP 的负偏移量。在后面的章节中,我们还会提到相对于 RBP 的数据,比如调用者栈帧中的函数参数。(也可以将局部变量和参数相对于 RSP 进行引用,而根本不涉及 RBP;大多数生产环境编译器会作为一种优化这样做。)
现在你已经理解了栈的工作原理,接下来我们更详细地看一下函数前言和尾部。函数前言通过三条指令设置栈帧:
-
pushq %rbp 将当前的 RBP 值,即调用者栈帧基地址,保存到栈中。稍后恢复调用者栈帧时,我们需要这个值。这个值将位于由下一条指令建立的新栈帧的栈底。
-
movq %rsp, %rbp 将栈顶设为新栈帧的基地址。此时,当前栈帧的栈顶和栈底是相同的。当前栈帧只保存一个值,这个值是 RSP 和 RBP 都指向的:即我们在前一条指令中保存的调用者栈帧的基地址。
-
subq $n, %rsp 将栈指针减去 n 字节。此时栈帧有 n 字节的空间来存储局部变量和临时变量。
图 2-2 显示了函数前言中每条指令对栈的影响。在这个图中,subq 指令分配了 24 字节的空间,足够容纳六个 4 字节的整数。

图 2-2:函数前言每个阶段栈的状态 描述
函数尾部通过将 RSP 和 RBP 恢复到函数前言之前的相同值,恢复了调用者的栈帧。这需要两条指令:
-
movq %rbp, %rsp 将我们带回函数前言第二条指令执行后的位置:RSP 和 RBP 都指向当前栈帧的栈底,该栈帧保存了调用者的 RBP 值。
-
popq %rbp 恢复了函数前言中的第一条指令,恢复了调用者的 RSP 和 RBP 寄存器值。它通过恢复 RBP,因为栈顶的值是我们在函数前言开始时保存的调用者栈帧的基地址。通过移除栈帧中的最后一个值来恢复 RSP,使 RSP 指向调用者栈帧的栈顶。
图 2-3 显示了每条指令在函数尾部的作用。

图 2-3:函数尾部每个阶段栈的状态 描述
现在我们知道编译器应该生成什么输出,继续编程吧。我们将从扩展词法分析器和语法分析器开始。
词法分析器
在这一章中,你将扩展词法分析器,使其识别三个新的符号:
| ~ | 一个波浪号,按位取反运算符 |
|---|---|
| - | 一个连字符,取反运算符 |
| -- | 两个连字符,递减运算符 |
虽然你在这一章中不会实现递减运算符,但仍然需要为它添加一个符号。否则,编译器将接受一些应该被拒绝的程序,例如列表 2-3 中的那个程序。
int main(void) {
return --2;
}
列表 2-3:一个使用递减运算符的无效 C 程序
这段代码不应该编译通过,因为不能对常量进行递减操作。但如果你的编译器没有识别出--是一个独立的符号,它会认为列表 2-3 等同于列表 2-4,而后者是一个完全有效的程序。
int main(void) {
return -(-2);
}
列表 2-4:一个有效的 C 程序,连续两个取反运算符
你的编译器应该拒绝那些你未实现的语言特性;它不应该错误地编译它们。因此,词法分析器需要知道--是一个单独的符号,而不是两个连续的取反运算符。(另一方面,词法分析器应将解析为两个连续的按位取反运算符。像2这样的表达式是有效的。)
你可以像处理标点符号(例如;和(在第一章中的方式一样)来处理新的符号。首先,你需要为每个新的符号定义一个正则表达式。这里的正则表达式是~、-和--。接着,每当词法分析器试图生成一个符号时,它需要检查输入是否与这些新正则表达式匹配,也要检查来自上一章的正则表达式。当输入流的开始匹配多个可能的符号时,选择最长的一个。例如,如果输入流以--开头,则应将其解析为递减运算符,而不是两个连续的取反运算符。
解析器
为了解析本章中的新运算符,我们首先需要扩展我们在 第一章 中定义的抽象语法树(AST)和形式语法。首先让我们看看 AST。由于一元运算是表达式,我们用一个新的构造器为 exp AST 节点表示它们。清单 2-5 显示了更新后的 AST 定义,新增的部分已加粗。
program = Program(function_definition)
function_definition = Function(identifier name, statement body)
statement = Return(exp)
exp = Constant(int) | **Unary(unary_operator, exp)**
**unary_operator = Complement | Negate**
清单 2-5:包含一元运算的抽象语法树
更新后的 exp 规则表明,一个表达式可以是常量整数或一元运算。一元运算由两种一元运算符之一,补码 或 取反,应用于一个内层表达式。请注意,exp 的定义是递归的:Unary 构造器对于一个 exp 节点包含另一个 exp 节点。这使得我们能够构造任意深度嵌套的表达式,如 -((--(-4)))。
我们还需要对语法进行相应的更改,如 清单 2-6 所示。
<program> ::= <function>
<function> ::= "int" <identifier> "(" "void" ")" "{" <statement> "}"
<statement> ::= "return" <exp> ";"
<exp> ::= <int> **| <unop> <exp> | "(" <exp> ")"**
**<unop> ::= "-" | "~"**
<identifier> ::= ? An identifier token ?
<int> ::= ? A constant token ?
清单 2-6:包含一元运算的形式语法
清单 2-6 包含了一条新的产生式规则,用于一元表达式,并且新增了一个
减法运算符(--)在该语法中没有出现,因此当你的解析器遇到 -- 标记时应该报错。
要更新解析阶段,请修改编译器的 AST 数据结构,以匹配列表 2-5。然后,更新递归下降解析代码,以反映列表 2-6 中的更改。在这一章中,解析表达式变得有些复杂,因为您需要弄清楚应用
parse_exp(tokens):
next_token = peek(tokens)
❶ if next_token is an int:
`--snip--`
❷ else if next_token is "~" or "-":
operator = parse_unop(tokens)
inner_exp = parse_exp(tokens)
❸ return Unary(operator, inner_exp)
❹ else if next_token == "(":
take_token(tokens)
inner_exp = parse_exp(tokens)
expect(")", tokens)
❺ return inner_exp
❻ else:
fail("Malformed expression")
列表 2-7:解析表达式
首先,我们查看输入中的下一个标记,以确定应用哪个生成规则。我们调用peek来查看这个标记,但不将其从输入流中移除。一旦确定使用哪个生成规则,我们将处理整个输入,包括第一个标记,并使用该规则。因此,我们现在不想从输入中消费这个标记。
如果我们即将解析的表达式是有效的,next_token应该是一个整数、一元运算符或左括号。如果它是一个整数❶,我们像上一章那样解析它。如果它是一个一元运算符❷,我们应用列表 2-6 中的第二个生成规则来构建一个一元表达式。该规则是
如果 next_token 是一个左括号 ❹,我们将应用
最后,如果 next_token 不是整数、单目运算符或左括号 ❻,则表达式格式错误,我们会抛出语法错误。
TACKY: 一种新的中间表示
将 AST 转换为汇编并不像上一章那样直接。C 表达式可以有嵌套的子表达式,而汇编指令不能。像 -(~2) 这样的单一表达式需要分解为两条汇编指令:一条用于应用内部的按位补码操作,另一条用于应用外部的取反操作。
我们将通过一个新的中间表示(IR),三地址码 (TAC),来弥补 C 和汇编之间的差距。在 TAC 中,每条指令的操作数是常量或变量,而不是嵌套表达式。之所以称为三地址码,是因为大多数指令最多使用三个值:两个源操作数和一个目标。 (本章的指令只使用一个或两个值;我们将在实现二元运算符时在第三章介绍使用三个值的指令。)要在 TAC 中重写嵌套表达式,我们通常需要引入新的临时变量。例如,清单 2-8 显示了 return 1 + 2 * 3; 的三地址码。
tmp0 = 2 * 3
tmp1 = 1 + tmp0
return tmp1
清单 2-8:三地址码 for return 1 + 2 * 3;
使用三地址码而不是直接将 AST 转换为汇编代码有两个主要原因。首先,它使我们能够在不涉及汇编语言细节的情况下,单独处理一些重要的结构性转换——比如去除嵌套表达式——例如,确定哪些操作数对于哪些指令是有效的。这意味着我们可以编写多个较小、更简单的编译器阶段,而不是一个庞大、复杂的汇编生成阶段。其次,三地址码非常适合我们将在 Part III 中实现的几种优化。它有一个简单而统一的结构,这使得回答类似“这个表达式的结果是否会被使用?”或“这个变量是否总是具有相同的值?”这样的问题变得容易。对于这些问题的答案决定了哪些优化是安全的。
大多数编译器在内部使用某种形式的三地址码,但具体细节各不相同。我决定在本书中将这种中间表示命名为TACKY。(在我看来,为你的中间表示命名是编译器设计中最有趣的部分之一。)我为本书编造了 TACKY,但它与其他编译器中的三地址码相似。
定义 TACKY
我们将像其他中间表示一样,在 ASDL 中定义 TACKY。Listing 2-9 中 TACKY 的定义看起来类似于 Listing 2-5 中 AST 的定义,但也有一些重要的不同之处。
program = Program(function_definition)
function_definition = Function(identifier, ❶ instruction* body)
instruction = Return(val) | Unary(unary_operator, val src, val dst)
val = Constant(int) | Var(identifier)
unary_operator = Complement | Negate
Listing 2-9:TACKY 中间表示
在 TACKY 中,函数体由一系列指令❶组成,而不是单一的语句。在这方面,它与我们在上一章中定义的汇编 AST 相似。现在,TACKY 有两条指令:Return和Unary。Return返回一个值;Unary对src(表达式的源值)执行某个一元操作,并将结果存储在dst(目标)中。两条指令都操作val,它们可以是常数整数(Constant)或临时变量(Var)。我们生成的 TACKY 必须满足一个要求,这在 Listing 2-9 中没有明确指出:一元操作的dst必须是临时的Var,而不是Constant。试图将值赋给常量是没有意义的。
现在你已经看到了 TACKY 的 ASDL 定义,你需要在自己的编译器中实现这个定义,类似于 AST 和汇编 AST 的定义。一旦你有了 TACKY 数据结构,你就可以开始编写 IR 生成阶段,将 AST 转换为 TACKY。
生成 TACKY
你的 TACKY 生成阶段应该遍历 列表 2-5 中定义的 AST 形式,并返回 列表 2-9 中定义的 TACKY AST 形式。棘手的部分是将一个 exp 节点转换为指令列表;一旦你弄明白了这一点,处理其他 AST 节点就很容易了。表 2-1 列出了几个 AST 示例及其对应的 TACKY。
表 2-1: 一元表达式的 TACKY 表示
| AST | TACKY |
|---|---|
| Return(Constant(3)) | Return(Constant(3)) |
| Return(Unary(Complement, Constant(2))) | Unary(Complement, Constant(2), Var("tmp.0")) Return(Var("tmp.0")) |
|
Return(Unary(Negate,
Unary(Complement,
Unary(Negate, Constant(8)))))
| Unary(Negate, Constant(8), Var("tmp.0")) Unary(Complement, Var("tmp.0"), Var("tmp.1")) Unary(Negate, Var("tmp.1"), Var("tmp.2")) Return(Var("tmp.2")) |
|---|
在这些示例中,我们将每个一元操作转换为 Unary TACKY 指令,从最内层的表达式开始,逐步向外扩展。我们将每个 Unary 指令的结果存储在一个临时变量中,然后在外部表达式或 return 语句中使用该变量。列表 2-10 描述了如何将一个 exp AST 节点转换为 TACKY。
emit_tacky(e, instructions):
match e with
❶ | Constant(c) ->
return ❷ Constant(c)
| Unary(op, inner) ->
src = emit_tacky(inner, instructions)
dst_name = make_temporary()
dst = Var(dst_name)
tacky_op = convert_unop(op)
instructions.append(Unary(tacky_op, src, dst))
return dst
列表 2-10:将表达式转换为 TACKY 指令列表
这个伪代码通过将计算表达式所需的指令附加到 instructions 参数,来生成指令。它还返回一个 TACKY val,代表表达式的结果,我们将在翻译外部表达式或语句时使用它。
清单 2-10 中的 match 语句检查我们正在翻译的表达式类型,然后执行相应的语句来处理该表达式。如果表达式是常量,我们返回等效的 TACKY Constant,并且不生成任何新的指令。请注意,这段代码中包含了两种不同的 Constant 构造;我们匹配的是原始 AST 中的节点 ❶,而我们返回的是 TACKY AST 中的节点 ❷。接下来的语句中,两个 Unary 构造也具有相同的情况。
如果 e 是一元表达式,我们为源和目标构建 TACKY 值。首先,我们递归调用 emit_tacky 来处理源表达式,从而得到相应的 TACKY 值。这还会生成计算该值的 TACKY 指令。然后,我们为目标创建一个新的临时变量。make_temporary 辅助函数为这个变量生成一个唯一的名称。我们使用另一个辅助函数 convert_unop 来将一元运算符转换为其 TACKY 等效形式。一旦我们获得源、目标和一元运算符,就可以构建 Unary TACKY 指令,并将其附加到 instructions 列表中。最后,我们返回 dst 作为整个表达式的结果。
请记住,emit_tacky 处理的是表达式,而不是 return 语句。你需要一个单独的函数(我不会提供伪代码)来将 return 语句转换为 TACKY。这个函数应该调用 emit_tacky 来处理语句的返回值,然后生成一个 TACKY Return 指令。
生成临时变量的名称
很明显,每个临时变量都需要一个独特的名称。在后续章节中,我们还需要保证这些自动生成的名称不会与用户定义的函数和全局变量名称冲突,或者与不同函数自动生成的名称冲突。这些标识符必须是唯一的,因为我们将把所有这些标识符——包括自动生成的名称和用户定义的函数与变量名称——存储在同一个表中。
一种简单的解决方案是维护一个全局整数计数器;为了生成一个唯一的名称,递增计数器并使用其新值作为临时变量的名称。这个名称不会与其他临时名称冲突,因为每次递增计数器时它都会生成一个新值。它也不会与用户定义的标识符冲突,因为整数在 C 语言中不是有效的标识符。在表 2-1 中,我使用了这种方法的变体,将描述性字符串、句点和全局计数器的值连接起来,生成像tmp.0这样的唯一标识符。这些不会与用户定义的标识符冲突,因为 C 语言中的标识符不能包含句点。通过这种命名方案,你可以在自动生成的名称中编码有用的信息,比如它们所在的函数名称。(如果你像我这里一样将每个变量命名为tmp,那就没那么有用了。)
更新编译器驱动程序
要测试 TACKY 生成器,你需要添加一个新的--tacky命令行选项,通过该选项让编译器运行到 TACKY 生成阶段,但在汇编生成之前停止。像现有的--lex、--parse和--codegen选项一样,这个新选项不应产生任何输出。
汇编生成
TACKY 更接近汇编,但它仍然没有明确指出我们需要哪些汇编指令。下一步是将 TACKY 程序转换为我们在上一章定义的汇编抽象语法树(AST)。我们将在三个小的编译器阶段完成这个过程。首先,我们将生成一个汇编 AST,但仍然直接引用临时变量。接下来,我们将用栈上的具体地址替换这些变量。这一步会导致一些无效的指令,因为许多 x64 汇编指令不能对两个操作数使用内存地址。所以,在最后一个编译器阶段,我们将重写汇编 AST,以修复任何无效的指令。
将 TACKY 转换为汇编
我们将从扩展上一章中定义的汇编 AST 开始。我们需要一些新的构造来表示清单 2-2 中的neg和not指令。我们还需要决定如何在汇编 AST 中表示函数的前导和尾声。
处理函数序言和尾声有几种不同的方法。我们可以将 push、 pop 和 sub 指令添加到汇编 AST 中。我们可以添加对应于整个函数序言和尾声的高级指令,而不是维护汇编 AST 结构和汇编指令之间的一对一对应关系。或者我们可以完全忽略函数序言和尾声,并在代码生成过程中添加它们。我会同时使用第一种和最后一种选项的结合。本章的汇编 AST 在 Listing 2-11 中显示了与 sub 指令(函数序言中的第三条指令)对应的结构。该结构指定了我们需要从堆栈指针中减去的字节数。汇编 AST 不包括序言和尾声中的其他指令;这些指令总是相同的,因此我们可以在代码生成过程中添加它们。尽管如此,其他代表函数序言和尾声的方法也能工作,所以选择你喜欢的方式。
我们还将引入 伪寄存器 来表示临时变量。我们将伪寄存器作为汇编指令中的操作数使用,就像真实寄存器一样;唯一的区别在于我们可以无限制地使用它们。因为它们不是真实寄存器,所以它们不能出现在最终的汇编程序中;在后续的编译器通行证中,它们需要被真实寄存器或内存地址替换。目前,我们将每个伪寄存器分配到内存中的不同地址。在 第三部分 中,我们将编写一个 寄存器分配器,它将尽可能多地将伪寄存器分配给硬件寄存器,而不是内存地址。
Listing 2-11 展示了更新后的汇编 AST,新增部分已加粗。
program = Program(function_definition)
function_definition = Function(identifier name, instruction* instructions)
instruction = Mov(operand src, operand dst)
**| Unary(unary_operator, operand)**
**| AllocateStack(int)**
| Ret
**unary_operator = Neg | Not**
operand = Imm(int) | **Reg(reg) | Pseudo(identifier) | Stack(int)**
**reg = AX | R10**
Listing 2-11: 带有一元运算符的汇编 AST
instruction 节点新增了几个构造函数,用于表示我们的新汇编指令。 Unary 构造函数表示单个 not 或 neg 指令。它接受一个操作数,用作源操作数和目的操作数。 AllocateStack 构造函数表示函数序言中的第三条指令, subq $n, %rsp。它的一个子节点是整数,指示我们从 RSP 中减去的字节数。
我们还有几个新的指令操作数。Reg构造器表示硬件寄存器。它可以指定到目前为止我们看到的任一硬件寄存器:EAX 或 R10D。Pseudo操作数让我们使用任意标识符作为伪寄存器。我们用它来表示在生成 TACKY 时创建的临时变量。最终,我们需要将每个伪寄存器替换为栈上的位置;我们用Stack操作数表示这些位置,它指示了从 RBP 起始偏移量的栈地址。例如,我们可以使用汇编 AST 节点Stack(-4)来表示操作数-4(%rbp)。
注意
每个硬件寄存器有多个别名,取决于你需要多少字节的寄存器。EAX 指的是 64 位 RAX 寄存器的低 32 位,而 R10D 指的是 64 位 R10 寄存器的低 32 位。AL 和 R10B 分别指的是 RAX 和 R10 的低 8 位。在汇编 AST 中,寄存器名称不依赖于大小,因此在示例 2-11 中,AX 可以根据上下文表示寄存器别名 RAX、EAX 或 AL。(AX 通常指的是 RAX 的低 16 位,但在本书中我们不会使用 16 字节的寄存器别名。)
现在我们可以编写一个直接从 TACKY 到汇编的转换,如表 2-2 到 2-5 所示。正如表 2-2 所示,我们将 TACKY 中的Program和Function节点转换为相应的汇编构造。
表 2-2: 将顶级 TACKY 构造转换为汇编
| TACKY 顶级构造 | 汇编顶级构造 |
|---|---|
| Program(function_definition) | Program(function_definition) |
| Function(name, instructions) | Function(name, instructions) |
我们将把每条 TACKY 指令转换成一系列汇编指令,如表 2-3 所示。由于我们的新汇编指令对源和目标使用相同的操作数,我们会先将源值复制到目标,然后再发出一条一元neg或not指令。
表 2-3: 将 TACKY 指令转换为汇编
| TACKY 指令 | 汇编指令 |
|---|---|
| 返回(值) | Mov(值, Reg(AX)) Ret |
| 一元运算符(一元运算符, 源, 目标) | Mov(源, 目标) 一元运算符(一元运算符, 目标) |
表 2-4 显示了每个 TACKY 一元运算符 对应的汇编 一元运算符,而 表 2-5 显示了 TACKY 操作数到汇编操作数的转换。
表 2-4: 将 TACKY 算术运算符转换为汇编
| TACKY 运算符 | 汇编运算符 |
|---|---|
| 补码 | Not |
| 取反 | Neg |
表 2-5: 将 TACKY 操作数转换为汇编
| TACKY 操作数 | 汇编操作数 |
|---|---|
| 常量(int) | 立即数(int) |
| 变量(标识符) | 伪变量(标识符) |
请注意,我们还没有使用 AllocateStack 指令;我们将在代码生成的最后阶段添加它,当我们知道需要分配多少字节时。我们也没有使用任何 Stack 操作数;我们将在下一个编译阶段将每个 Pseudo 操作数替换为 Stack 操作数。同时,我们还没有使用 R10D 寄存器;我们将在重写无效指令时引入它。
替换伪寄存器
接下来,我们编写一个编译器阶段,将每个 Pseudo 操作数替换为 Stack 操作数,同时保持汇编 AST 的其余部分不变。在 Listing 2-2 中,我们使用了两个栈位置:-4(%rbp) 和 -8(%rbp)。此阶段遵循相同的模式:我们将看到的第一个临时变量替换为 Stack(-4),下一个替换为 Stack(-8),依此类推。我们为每个新变量减去四,因为每个临时变量都是一个 4 字节的整数。你需要在处理过程中维护一个从标识符到偏移量的映射,以便每次出现伪寄存器时都能用栈上的相同地址替换它。例如,如果你处理如下指令:
Mov(Imm(2), Pseudo("a"))
Unary(Neg, Pseudo("a"))
你应将 Pseudo("a") 在两条指令中都替换为相同的 Stack 操作数。
此编译器阶段还应返回最终临时变量的栈偏移量,因为这告诉我们在下一次阶段需要在栈上分配多少字节。
修正指令
现在,我们需要再次遍历汇编 AST 并进行两个小修正。首先,我们将在 function_definition 的指令列表开头插入 AllocateStack 指令。传递给 AllocateStack 的整数参数应该是我们在上一个编译器阶段分配的最后一个临时变量的栈偏移量。这样,我们就能在栈上分配足够的空间以容纳我们使用的每一个地址。例如,如果我们替换三个临时变量,并将最后一个替换为 -12(%rbp),我们将在指令列表的开头插入 AllocateStack(12)。
第二个修复是重写无效的 Mov 指令。当我们将伪寄存器替换为栈地址时,可能会出现 Mov 指令,其中源操作数和目标操作数都是 Stack 操作数。这种情况发生在程序中的一元表达式至少有一层嵌套时。但 mov 指令和许多其他指令一样,不能同时将内存地址作为源操作数和目标操作数。如果你尝试汇编一个类似 movl -4(%rbp), -8(%rbp) 的指令,汇编器将会拒绝它。当遇到无效的 mov 指令时,应将其重写为首先从源地址复制到 R10D 寄存器,然后再从 R10D 复制到目标地址。例如,指令
movl -4(%rbp), -8(%rbp)
变为:
movl -4(%rbp), %r10d
movl %r10d, -8(%rbp)
我选择 R10D 作为临时寄存器,因为它没有其他特殊用途。有些寄存器是特定指令所要求的;例如,执行除法的 idiv 指令要求被除数存储在 EAX 寄存器中。其他寄存器用于函数调用时传递参数。在此阶段使用这些寄存器作为临时寄存器可能会导致冲突。例如,你可能将一个函数参数复制到正确的寄存器中,但在使用该寄存器在内存地址之间传输其他值时不小心覆盖了它。由于 R10D 没有特殊用途,因此我们不必担心这些冲突。
代码输出
最后,我们将扩展代码输出阶段,以处理我们的新构造并输出函数的前言和后记。表 2-6 到 2-9 显示了如何输出每个汇编构造。新构造和我们输出现有构造的方式更改部分已加粗。
表 2-6 显示了在输出汇编 函数 时如何包含前言部分。
表 2-6: 格式化顶层汇编构造
| 汇编顶层构造 | 输出 |
|---|---|
| 程序(函数定义) |
Print out the function definition. On Linux, add at end of file:
.section .note.GNU-stack,"",@progbits
|
| 函数(名称,指令) |
|---|
.globl <name>
<name>:
pushq %rbp
movq {@}%rsp, %rbp
<instructions>
|
表 2-7 显示了如何在 Ret 指令前加入函数尾部代码,并如何输出新的 Unary 和 AllocateStack 指令。
表 2-7: 汇编指令格式
| 汇编指令 | 输出 |
|---|---|
| Mov(src, dst) |
movl <src>, <dst>
|
| Ret |
|---|
movq %rbp, %rsp
popq %rbp
ret
|
| Unary(unary_operator, operand) |
|---|
<unary_operator> <operand>
|
| AllocateStack(int) |
|---|
subq $<int>, %rsp
|
正如此表所示,你应该将 AllocateStack 输出为一个 subq 指令。根据 unary_operator 参数,将 Unary 输出为 negl 或 notl 指令。表 2-8 显示了每个 unary_operator 对应的指令。
表 2-8: 汇编操作符指令名称
| 汇编操作符 | 指令名称 |
|---|---|
| Neg | negl |
| Not | notl |
最后,表 2-9 显示了如何输出新的 Reg 和 Stack 操作数。
表 2-9: 汇编操作数格式
| 汇编操作数 | 输出 |
|---|---|
| Reg(AX) | %eax |
| Reg(R10) | %r10d |
| 堆栈(int) | |
| 立即数(int) | $ |
由于 RBP 和 RSP 包含内存地址,每个地址为 8 字节,我们总是使用四字指令来操作它们,这些指令以q后缀结尾。表 2-7 中的movl指令和序言与尾声中的movq指令除了操作数的大小外,完全相同。
总结
在本章中,你扩展了编译器以实现取反和按位补码操作。你还实现了一种新的中间表示,编写了两个新的编译器阶段来转换汇编代码,并学习了栈帧的结构。接下来,你将实现加法和减法等二进制操作。下一章对后端的改动非常简单,难点在于让解析器遵循运算符优先级和结合性规则。
附加资源
本章介绍了二进制补码,它是现代计算机表示带符号整数的方式。二进制补码将在本书中贯穿出现,因此值得花时间去理解它。以下是它如何工作的几个概述:
-
《二进制补码》由 Thomas Finley 编写,介绍了二进制补码表示法的工作原理及其原因(<
<wbr>www<wbr>.cs<wbr>.cornell<wbr>.edu<wbr>/~tomf<wbr>/notes<wbr>/cps104<wbr>/twoscomp<wbr>.html)。 -
《计算机系统的构成元素》第二章The Elements of Computing Systems,由 Noam Nisan 和 Shimon Schocken 编写(MIT 出版社,2005 年),从更侧重硬件的角度涵盖了类似的内容。这本书是 Nand 到 Tetris 项目的配套书。该章节可以在
<wbr>www<wbr>.nand2tetris<wbr>.org<wbr>/course免费下载;点击“项目 2:布尔算术”下的书本图标。

描述
第三章:3 二元运算符

在这一章中,你将实现五种新运算符:加法、减法、乘法、除法和取余运算符。这些都是 二元运算符,它们需要两个操作数。本章不需要任何新的编译器阶段;你只需要扩展你已经编写的每个阶段。在解析阶段,你将看到递归下降解析法在处理二元表达式时的局限性。相反,你将使用一种不同的技术,优先级爬升,这种技术在后续章节中更容易扩展。优先级爬升是你需要的最后一种主要解析技术。一旦它就位,你将能够以相对较少的工作量为本书的剩余部分添加新的语法。在汇编生成阶段,你将引入几个执行二元运算的汇编指令。和往常一样,我们从词法分析器开始。
词法分析器
词法分析器需要识别四个新令牌:
+ 加号,加法运算符
* 星号,乘法运算符
/ 斜杠,除法运算符
% 百分号,取余运算符
这个列表没有包括 - 令牌,因为你在上一章已经添加了它。词法分析阶段不会区分取反和减法;无论哪种情况,它都会生成相同的令牌。
你应该以与前几章中的单字符令牌相同的方式进行词法分析。
解析器
现在,你将向抽象语法树(AST)中添加另一种表达式:二元运算。 列表 3-1 显示了更新后的 AST 定义。
program = Program(function_definition)
function_definition = Function(identifier name, statement body)
statement = Return(exp)
exp = Constant(int)
| Unary(unary_operator, exp)
**| Binary(binary_operator, exp, exp)**
unary_operator = Complement | Negate
**binary_operator = Add | Subtract | Multiply | Divide | Remainder**
列表 3-1:包含二元运算的抽象语法树
请注意,解析器与词法分析器不同,它区分取反和减法。- 令牌根据它在表达式中的位置,可能被解析为 取反 或 减法。
同样需要注意的是,AST 的结构决定了嵌套表达式的计算顺序。让我们看几个例子,看看 AST 的结构如何控制运算顺序。图 3-1 中的 AST 表示表达式 1 + (2 * 3),其结果为 7。

图 3-1:表达式 1 + (2 * 3) 的 AST 描述
- 操作有两个操作数:1 和 (2 * 3)。为了评估这个表达式,你首先计算 2 * 3,然后将 1 加到结果中。另一方面,图 3-2 中的 AST 表示的是表达式 (1 + 2) * 3,其结果为 9。

图 3-2:表达式 (1 + 2) * 3 的 AST 描述
在这种情况下,你首先计算 1 + 2,然后再乘以 3。一般来说,在评估 AST 节点之前,你需要先评估它的两个子节点。这个先处理节点的子节点再处理节点本身的模式,称为 后序遍历。请注意,任何树形数据结构都可以通过后序遍历进行遍历,不仅仅是 AST。
你的编译器遍历 AST 生成代码,而不是评估表达式,但原理是一样的。当你将二元表达式的 AST 转换为 TACKY 时,你首先生成计算两个操作数的指令,然后生成操作本身的指令。(在第二章中,你也使用了后序遍历来处理一元操作。)
对于你的解析器来说,正确地分组嵌套表达式至关重要。如果你试图解析 1 + (2 * 3),但最终得到图 3-2 的 AST,你最终会错误地编译程序。
我们刚才看到的例子使用了括号来显式地分组嵌套表达式。有些表达式,比如 1 + 2 * 3,并没有为每个嵌套表达式加上括号。在这些情况下,我们会根据运算符的优先级和结合性来分组表达式。优先级较高的运算符先被求值;由于 * 的优先级高于 +,因此你会把 1 + 2 * 3 解析为 1 + (2 * 3)。结合性告诉你如何处理优先级相同的运算符。如果一个操作是左结合的,你会先应用左边的运算符;如果是右结合的,你会先应用右边的运算符。例如,由于加法和减法是左结合的,1 + 2 - 3 会被解析为 (1 + 2) - 3。本章中的所有新运算符都是左结合的,并且有两个优先级级别:*、/ 和 % 的优先级较高,而 + 和 - 的优先级较低。
递归下降解析的难题
编写一个正确处理运算符优先级和结合性的递归下降解析器出乎意料地棘手。为了理解原因,先让我们尝试向正式文法中添加一个用于二元表达式的生成规则。这个新规则在 Listing 3-2 中以粗体标出,定义在
<exp> ::= <int> | <unop> <exp> | "(" <exp> ")" | **<exp> <binop> <exp>**
Listing 3-2:一个不适用于递归下降解析器的简单语法规则
一个二元表达式由一个表达式、一个二元操作符和另一个表达式组成,因此
首先,列表 3-2 是模糊的:它允许你以多种方式解析某些输入。例如,根据这个语法,图 3-1 和图 3-2 都是1 + 2 * 3的有效解析。我们需要知道+和*的相对优先级,以决定使用哪种解析,但语法并没有包含这些信息。
其次,新的产生式规则是左递归的:该产生式规则中最左边的符号对于
我们可以通过几种方式解决这些问题。如果我们想要一个纯粹的递归下降解析器,我们可以重构语法,去除模糊性和左递归。由于这种方法有一些缺点,我们将使用优先级提升,它是递归下降解析的替代方法。然而,首先了解纯递归下降的解决方案是有帮助的。
充分的解决方案:重构语法
如果我们重构语法,我们将得到每个优先级层次的一个语法规则,像在列表 3-3 中一样。
<exp> ::= <term> {("+" | "-") <term>}
<term> ::= <factor> {("*" | "/" | "%") <factor>}
<factor> ::= <int> | <unop> <factor> | "(" <exp> ")"
列表 3-3:适用于二元操作的递归下降友好型语法
使用列表 3-3 中的语法,解析1 + 2 * 3只有一种方式,并且没有左递归。大括号表示重复,因此一个单独的
这种方法是有效的,但随着你添加更多的优先级级别,它会变得越来越笨重。现在我们有三个优先级级别,如果算上
更好的解决方案:优先级提升
优先级上升是一种更简单的方式来解析二元表达式。它可以处理像
将优先级上升与递归下降结合
我们可以使用优先级上升来解析二元表达式,同时仍然使用递归下降来解析其他语言结构。记住,递归下降解析器使用不同的函数来解析每个符号。这使得我们可以用不同的技术解析不同的符号:我们在 parse_exp 函数中使用优先级上升,在解析其他所有符号的函数中使用递归下降。parse_exp 函数将从输入流中移除标记并返回一个 AST 节点,就像基于递归下降的解析函数一样,但它将使用不同的策略来获得这个结果。
由于我们已经使用递归下降来解析一元和带括号的表达式,因此我们用一个与二元操作不同的符号来表示它们。列表 3-4 展示了结果语法,并且与第二章的变化部分已加粗显示。
<program> ::= <function>
<function> ::= "int" <identifier> "(" "void" ")" "{" <statement> "}"
<statement> ::= "return" <exp> ";"
**<exp> ::= <factor> | <exp> <binop> <exp>**
**<factor> ::= <int> | <unop> <factor> | "(" <exp> ")"**
<unop> ::= "-" | "~"
**<binop> ::= "-" | "+" | "*" | "/" | "%"**
<identifier> ::= ? An identifier token ?
<int> ::= ? A constant token ?
列表 3-4:处理二元操作的最终语法
在列表 2-6 中我们称之为
解析因子的伪代码如列表 3-5 所示。
parse_factor(tokens):
next_token = peek(tokens)
if next_token is an int:
`--snip--`
else if next_token is "~" or "-":
operator = parse_unop(tokens)
❶ inner_exp = parse_factor(tokens)
return Unary(operator, inner_exp)
else if next_token == "(":
take_token(tokens)
❷ inner_exp = parse_exp(tokens)
expect(")", tokens)
return inner_exp
else:
fail("Malformed factor")
列表 3-5:解析一个因子
这看起来和上一章的表达式解析代码非常相似(如列表 2-7 所示)。唯一的不同是,当我们期望一个
使运算符具有左关联性
接下来,让我们编写新版的parse_exp。我们将从一个简单版本的函数开始,该函数仅处理相同优先级的+和-运算符。这个简化版的parse_exp需要以左结合的方式分组表达式,但目前还不需要处理多个优先级。
在这个简单的情况下,我们会遇到像factor1 + factor2 - factor3 + factor4 这样的输入。这些表达式应该始终以左结合的方式解析,得到像((factor1 + factor2) - factor3) + factor4 这样的表达式。结果,每个表达式的右操作数,包括子表达式,都会是一个单一的因子。例如,(factor1 + factor2)的右操作数是factor2,而((factor1 + factor2) - factor3)的右操作数是factor3。
由于表达式的右操作数始终是一个单一的因子,我们可以使用列表 3-6 中的伪代码来解析这些表达式。
parse_exp(tokens):
❶ left = parse_factor(tokens)
next_token = peek(tokens)
❷ while next_token is "+" or "-":
operator = parse_binop(tokens)
❸ right = parse_factor(tokens)
❹ left = Binary(operator, left, right)
next_token = peek(tokens)
return left
示例 3-6:解析不考虑优先级的左结合表达式
我们从解析一个单一的因子 ❶ 开始,它可以是整个表达式或更大表达式的左操作数。接下来,我们检查接下来的符号是否是二元运算符 ❷。如果是,我们从输入中读取它并将其转换为 binary_operator AST 节点。然后,我们构造一个二元表达式 ❹,其中左操作数是到目前为止我们解析的内容,右操作数是下一个因子,我们通过调用 parse_factor ❸ 来获取。我们重复这个过程,直到遇到一个不再是 + 或 - 的符号;这意味着没有剩余的二元表达式可以构造,我们就完成了。
处理优先级
现在,让我们扩展 示例 3-6 来处理 *、/ 和 %。这些运算符也是左结合的,但它们的优先级比 + 和 - 要高。
一旦我们添加了这些运算符,每个表达式的右操作数可以是单一的因子,或者是仅包含新高优先级运算符的子表达式。例如,1 + 2 * 3 + 4 将被解析为 (1 + (2 * 3)) + 4。整个表达式的右操作数是一个单一的因子,4。内层子表达式的右操作数,1 + (2 * 3),是一个乘积,2 * 3。
换句话说,如果最外层的表达式是 + 或 - 操作,它的右操作数只包含因子和 *、/ 和 % 操作。但如果最外层的表达式本身是 *、/ 或 % 操作,它的右操作数必须是单一因子。
一般化来说:当我们解析形式为 e1
parse_exp(tokens, **min_prec**):
left = parse_factor(tokens)
next_token = peek(tokens)
while next_token is **a binary operator and precedence(next_token) >= min_prec**:
operator = parse_binop(tokens)
**right = parse_exp(tokens, precedence(next_token) + 1)**
left = Binary(operator, left, right)
next_token = peek(tokens)
return left
列表 3-7:使用优先级爬升解析左结合表达式
这个伪代码是我们整个优先级爬升算法的实现。min_prec 参数让我们能够声明当前解析的子表达式中的所有运算符都需要超过某个优先级水平。例如,我们可以只包含优先级高于 + 的运算符。我们通过在每次 while 循环的迭代中将当前运算符的优先级与 min_prec 进行比较来强制执行这一点;如果运算符的优先级过低,我们将排除该运算符及其后续部分。然后,当我们解析操作的右侧时,我们将最小优先级设置为高于当前运算符的优先级。这保证了优先级更高的运算符会优先计算。由于与当前运算符具有相同优先级的运算符不会被包含在右侧表达式中,因此生成的抽象语法树(AST)将是左结合的。
当你从其他函数调用 parse_exp(包括从 parse_factor,用于处理带括号的表达式)时,从最小优先级为零开始,这样结果将包括所有优先级水平的运算符。
列表 3-7 中的代码要求我们为每个二元运算符分配一个优先级值。表 3-1 显示了我分配的值。
表 3-1: 二元运算符的优先级值
| 操作符 | 优先级 |
|---|---|
| * | 50 |
| / | 50 |
| % | 50 |
| + | 45 |
| - | 45 |
精确的优先级值并不重要,只要高优先级操作符的值大于低优先级操作符的值即可。表 3-1 中的数字为我们提供了足够的空间,以便后续添加低优先级操作符。
优先级爬升示例
让我们通过一个例子来解析以下表达式:
1 * 2 - 3 * (4 + 5)
以下代码片段追踪了示例 3-7 中的优先级爬升代码在解析该表达式时的执行过程。我们从调用 parse _exp 开始,传入 0 作为最小优先级参数:
parse_exp("1 * 2 - 3 * (4 + 5)", 0):
接下来,在 parse_exp 内部,我们解析第一个因子:
left = parse_factor("1 * 2 - 3 * (4 + 5)")
= Constant(1)
next_token = "*"
第一次调用 parse_factor 解析令牌 1,返回 Constant(1)。接下来,我们查看后续的令牌 *。该令牌是一个二元操作符,优先级大于零,因此我们进入 while 循环。
循环的第一次迭代如下所示:
// loop iteration #1
operator = parse_binop("* 2 - 3 * (4 + 5)")
= Multiply
right = parse_exp("2 - 3 * (4 + 5)", 51)
left = parse_factor("2 - 3 * (4 + 5)")
= Constant(2)
next_token = "-"
// precedence(next_token) < 51
= Constant(2)
left = Binary(Multiply, Constant(1), Constant(2))
next_token = "-"
在循环内部,parse_binop从输入中消费next_token并将其转换为 AST 节点Multiply,这时剩下的表达式为2 - 3 * (4 + 5)。接下来,我们递归调用parse_exp以获取该乘积的右侧部分。由于*的优先级为 50,传递给parse_exp的第二个参数为51。在递归调用中,我们再次得到下一个因子(2)和紧随其后的令牌(-)。-令牌是一个二元运算符,但它的优先级仅为 45;它未达到 51 的最小优先级,因此我们不会进入while循环。相反,我们返回Constant(2)。
回到外层调用的parse_exp中,我们为1 * 2构造一个Binary AST 节点,基于我们迄今解析的值。然后,我们检查下一个令牌,看看是否还有子表达式需要处理。下一个令牌是-;我们已经查看了它,但并没有从输入中移除它,而是在递归调用parse_exp时。由于-是一个二元运算符,并且它的优先级超过了我们的最小优先级 0,我们跳回到while循环的开始,以解析下一个子表达式:
// loop iteration #2
operator = parse_binop("- 3 * (4 + 5)")
= Subtract
right = parse_exp("3 * (4 + 5)", 46)
left = parse_factor("3 * (4 + 5)")
= Constant(3)
next_token = "*"
// loop iteration #1
operator = parse_binop("* (4 + 5)")
= Multiply
right = parse_exp("(4 + 5)", 51)
left = parse_factor("(4 + 5)")
parse_exp("4 + 5)", 0)
`--snip--`
= Binary(Add, Constant(4), Constant(5))
= Binary(Add, Constant(4), Constant(5))
left = Binary(
Multiply,
Constant(3),
Binary(Add, Constant(4), Constant(5))
)
= Binary(
Multiply,
Constant(3),
Binary(Add, Constant(4), Constant(5))
)
left = Binary(
Subtract,
Binary(Multiply, Constant(1), Constant(2)),
Binary(
Multiply,
Constant(3),
Binary(Add, Constant(4), Constant(5))
)
)
在第二次进入循环时,我们从输入中消费-并递归调用parse_exp。这次,由于-的优先级为 45,传递给parse_exp的第二个参数为46。
按照我们通常的流程,我们获取下一个因子(3)和下一个标记(*)。由于 * 的优先级超过最小优先级,我们需要解析另一个子表达式。我们消耗了 *,剩下 (4 + 5),然后再次递归调用 parse_exp。
在这次对 parse_exp 的调用中,我们像往常一样首先调用 parse_factor。这个调用消耗了剩余的输入,并返回了 4 + 5 的 AST 节点。为了处理那个带括号的表达式,parse_factor 需要递归调用 parse_exp,并将最小优先级重置为 0,但我们这里不详细讲解。此时,我们的表达式中已经没有剩余的标记。假设这是一个有效的 C 程序,下一个标记是分号(;)。由于下一个标记不是二元操作符,我们退出循环,并返回从 parse_factor 得到的表达式。
在更高一级,我们根据此次调用中处理过的子表达式构建 3 * (4 + 5) 的抽象语法树(AST)节点。再次地,我们查看下一个标记,发现它不是二元操作符,然后返回。
最后,回到原始的对 parse_exp 的调用,我们从第一次循环迭代中构建的左操作数(1 * 2)、当前的 operator(-)以及刚从递归调用中返回的右操作数(3 * (4 + 5))构建最终的表达式。最后,我们检查下一个标记,发现它不是二元操作符,然后返回。
现在你已经了解了如何使用优先级递增法解析二元表达式,你可以开始扩展你的解析器了。记住,使用优先级递增法解析二元表达式,并使用递归下降法解析语法中的其他符号,包括因子。
TACKY 生成
接下来,让我们更新将抽象语法树(AST)转换为 TACKY 的阶段。我们将从向 TACKY 添加二元操作开始。清单 3-8 定义了更新后的 TACKY 中间表示(IR),本章新增的内容已加粗。
program = Program(function_definition)
function_definition = Function(identifier, instruction* body)
instruction = Return(val)
| Unary(unary_operator, val src, val dst)
**| Binary(binary_operator, val src1, val src2, val dst)**
val = Constant(int) | Var(identifier)
unary_operator = Complement | Negate
**binary_operator = Add | Subtract | Multiply | Divide | Remainder**
清单 3-8:向 TACKY 添加二元操作
我们已经添加了 Binary 指令来表示二元操作,并且我们定义了所有可能的运算符。像一元操作一样,TACKY 中的二元操作是作用于常量和变量,而不是嵌套子表达式。如清单 3-9 所示,我们可以通过与处理一元表达式基本相同的方式,将二元表达式转化为一系列 TACKY 指令。
emit_tacky(e, instructions):
match e with
| `--snip--`
| Binary(op, e1, e2) ->
v1 = emit_tacky(e1, instructions)
v2 = emit_tacky(e2, instructions)
dst_name = make_temporary()
dst = Var(dst_name)
tacky_op = convert_binop(op)
instructions.append(Binary(tacky_op, v1, v2, dst))
return dst
清单 3-9:将二元表达式转换为 TACKY
我们生成 TACKY 指令来求值每个操作数,然后生成使用这些源值的 Binary 指令。与处理一元表达式时的唯一不同之处在于,我们现在处理的是两个操作数,而不是一个。
在我们继续进行汇编生成之前,我想稍微提一下一个相关的点。在清单 3-9 中,我们生成的 TACKY 会先计算二元表达式的第一个操作数,再计算第二个操作数,但其实也可以先计算第二个操作数再计算第一个。根据 C 标准,同一操作的子表达式通常是无序的;也就是说,它们可以按任意顺序进行求值。如果两个子表达式是无序的,但其中一个或两个是函数调用,那么它们是不确定顺序的,意味着它们可以按任意顺序执行,但不能交错执行。在许多情况下,无序和不确定顺序的求值会导致不可预测的结果。考虑以下程序,其中包含两个不确定顺序的 printf 调用:
#include <stdio.h>
int main(void) {
return printf("Hello, ") + printf("World!");
}
你可以使用符合 C 标准的编译器编译这个程序,运行它,并得到以下任一输出:
Hello, World!
World!Hello,
在一些特殊情况下,我们必须先评估第一个操作数:逻辑运算符 && 和 ||,我们将在第四章中讲解;条件运算符 ?:,我们将在第六章中讲解;以及逗号运算符,我们不会实现。关于表达式求值顺序的更深入讨论,请参阅 cppreference.com 网站上的“Order of Evaluation”页面,这是 C/C++的参考维基 (<wbr>en<wbr>.cppreference<wbr>.com<wbr>/w<wbr>/c<wbr>/language<wbr>/eval<wbr>_order)。如果你对 C 标准中相关内容感兴趣,请参阅第 5.1.2.3 节(该节涵盖了求值顺序的一般规则,并定义了未排序和不确定排序的术语)以及第 6.5 节的第 1 到第 3 段(特别讲解了表达式操作数的求值顺序)。
未排序的操作是一个更广泛模式的例子:有很多情况下 C 标准没有明确规定程序应该如何行为。我们将在本书中看到更多的例子。通过对程序行为的某些细节不做具体规定,C 标准将很多权力交给了编译器开发者,允许他们编写复杂的编译器优化。但这也有明显的权衡:程序员很容易写出可能与预期行为不符的代码。### 汇编生成
下一步是将 TACKY 转换为汇编代码。我们需要几个新的汇编指令来处理加法、减法、乘法、除法和余数运算。我们先讨论如何使用这些指令;然后我们将在汇编生成阶段对每一轮进行必要的更新。
在汇编中进行算术运算
加法、减法和乘法的指令格式都是 op src, dst,其中:
op 是一条指令。
src 是立即数、寄存器或内存地址。
dst 是寄存器或内存地址。
每条指令将 op 应用到 dst 和 src 上,并将结果存储到 dst 中。加法、减法和乘法的指令分别是 add、sub 和 imul。像往常一样,如果操作数是 32 位,则这些指令会带有 l 后缀;如果操作数是 64 位,则会带有 q 后缀。表 3-2 展示了每条指令的示例。
表 3-2: 加法、减法和乘法的汇编指令
| 指令 | 含义 |
|---|---|
| addl $2, %eax | eax = eax + 2 |
| subl $2, %eax | eax = eax - 2 |
| imull {@}$2, %eax | eax = eax * 2 |
请注意,dst 是对应数学表达式中的第一个操作数,因此 subl a, b 计算的是 b - a,而不是 a - b。
这些指令非常容易使用和理解。如果我们生活在一个完美的世界里,我们就能以完全相同的方式执行除法操作。但事实并非如此,因此我们只能使用 idiv 指令。
我们使用idiv来实现除法和余数操作。尽管除法操作需要两个数字,但它只需要一个操作数:除数。(在a / b中,a是被除数,b是除数。)这个操作数不能是立即数。在其 32 位形式中,idiv从 EDX 和 EAX 寄存器中获取它需要的另一个值——被除数,并将其视为一个 64 位的值。它从 EDX 获取最重要的 32 位,从 EAX 获取最不重要的 32 位。与其他算术指令不同,idiv会产生两个结果:商和余数。它将商存储在 EAX 中,将余数存储在 EDX 中。(idiv的 64 位版本,写作idivq,使用 RDX 和 RAX 作为被除数,而不是 EDX 和 EAX。)
为了用idiv计算a / b,我们需要将a——它可以是一个 32 位的立即数或者一个存储在内存中的 32 位值——转化为一个跨越 EDX 和 EAX 的 64 位值。每当我们需要将一个有符号整数转换为更宽的格式时,我们会使用一个叫做符号扩展的操作。这个操作将新 64 位值的高 32 位用原 32 位值的符号位填充。
扩展正数的符号时,只需要用零填充高 32 位(4 个字节)。例如,将 3 的二进制表示进行符号扩展后变为:
00000000000000000000000000000011
变为:
0000000000000000000000000000000000000000000000000000000000000011
两种表示方式的值都是 3;第二种只是有更多的前导零。为了扩展负数的符号,我们用 1 填充高 4 个字节。例如,将负数-3 的二进制表示从
11111111111111111111111111111101
变为:
1111111111111111111111111111111111111111111111111111111111111101
多亏了二进制补码的神奇,这两个二进制数的值都是-3。(如果你不清楚如何操作,查看第二章中“附加资源”部分,详见第 45 页。)
cdq指令正是我们想要的:它将 EAX 中的值符号扩展到 EDX。如果 EAX 中的数是正数,这条指令将 EDX 置为全零。如果 EAX 是负数,它将 EDX 置为全 1。综合来看,作为一个例子,以下汇编代码计算了9 / 2和9 % 2:
movl $2, -4(%rbp)
movl $9, %eax
cdq
idivl -4(%rbp)
这将把 9 / 2 的结果,即商,存储在 EAX 中。它将把 9 % 2 的结果,即余数,存储在 EDX 中。
我们已经涵盖了本章需要的所有新指令:add、sub、imul、idiv 和 cdq。接下来,我们将把这些指令添加到汇编 AST 中,并更新从 TACKY 到汇编的转换。
将二元运算转换为汇编
列表 3-10 定义了更新后的汇编 AST,本章新增的部分以粗体显示。
program = Program(function_definition)
function_definition = Function(identifier name, instruction* instructions)
instruction = Mov(operand src, operand dst)
| Unary(unary_operator, operand)
**| Binary(binary_operator, operand, operand)**
**| Idiv(operand)**
**| Cdq**
| AllocateStack(int)
| Ret
unary_operator = Neg | Not
**binary_operator = Add | Sub | Mult**
operand = Imm(int) | Reg(reg) | Pseudo(identifier) | Stack(int)
reg = AX | **DX** | R10 | **R11**
列表 3-10:带有二元运算符的汇编 AST
由于加法、减法和乘法指令形式相同,我们将使用 Binary 构造器表示它们,适用于 instruction 节点。我们还将为新的 idiv 和 cdq 指令添加构造器。最后,我们将把 EDX 和 R11 寄存器添加到 AST 定义中;我们需要 EDX 用于除法,R11 用于指令修正过程。
现在,我们需要将新的二元操作从 TACKY 转换为汇编。对于加法、减法和乘法,我们将单个 TACKY 指令转换为两个汇编指令。也就是说,我们进行转换
Binary(op, src1, src2, dst)
转换为:
Mov(src1, dst)
Binary(op, src2, dst)
除法稍微复杂一些;我们将第一个操作数移入 EAX,使用 cdq 扩展其符号,发出 idiv 指令,然后将结果从 EAX 移动到目标位置。所以,我们进行转换
Binary(Divide, src1, src2, dst)
转换为:
Mov(src1, Reg(AX))
Cdq
Idiv(src2)
Mov(Reg(AX), dst)
余数操作完全相同,唯一不同的是,我们最终希望从 EDX 中获取余数,而不是从 EAX 中获取商。因此,我们进行转换
Binary(Remainder, src1, src2, dst)
转换为:
Mov(src1, Reg(AX))
Cdq
Idiv(src2)
Mov(**Reg(DX)**, dst)
idiv 指令不能作用于立即数,因此如果 src2 是常数,那么除法和余数的汇编指令将无效。没关系;我们将在指令修正过程中解决这个问题。表 3-3 到 3-6 总结了从 TACKY 到汇编的转换,其中新增和更改的构造以粗体显示。
表 3-3: 将顶层 TACKY 构造转换为汇编
| TACKY 顶层构造 | 汇编顶层构造 |
|---|---|
| Program(function_definition) | Program(function_definition) |
| Function(name, instructions) | Function(name, instructions) |
表 3-4: 将 TACKY 指令转换为汇编语言
| TACKY 指令 | 汇编指令 |
|---|---|
| Return(val) | Mov(val, Reg(AX)) Ret |
| Unary(unary_operator, src, dst) | Mov(src, dst) Unary(unary_operator, dst) |
| Binary(Divide, src1, src2, dst) | Mov(src1, Reg(AX)) Cdq
Idiv(src2)
Mov(Reg(AX), dst) |
| Binary(Remainder, src1, src2, dst) | Mov(src1, Reg(AX)) Cdq
Idiv(src2)
Mov(Reg(DX), dst) |
| Binary(binary_operator, src1, src2, dst) | Mov(src1, dst) Binary(binary_operator, src2, dst) |
|---|
表 3-5: 将 TACKY 算术运算符转换为汇编语言
| TACKY 运算符 | 汇编运算符 |
|---|---|
| Complement | Not |
| Negate | Neg |
| Add | Add |
| Subtract | Sub |
| Multiply | Mult |
表 3-6: 将 TACKY 操作数转换为汇编语言
| TACKY 操作数 | 汇编操作数 |
|---|---|
| Constant(int) | Imm(int) |
| Var(identifier) | Pseudo(identifier) |
请注意,表 3-4 包含了 Binary TACKY 指令的三行:一行用于除法,一行用于余数操作,另一行用于其他所有操作。
替换伪寄存器
更新此过程,以替换新 Binary 和 Idiv 指令中的伪寄存器。你应该像处理现有的 Mov 和 Unary 指令一样处理它们。当你在 Mov、Unary、Binary 或 Idiv 指令中看到伪寄存器时,用相应的堆栈地址替换它。如果该伪寄存器尚未分配到堆栈地址,则将其分配到下一个可用的 4 字节地址。
修复 idiv、add、sub 和 imul 指令
在生成最终程序的最后一次编译过程中,我们重写之前阶段产生的任何无效指令。我们需要在这里添加一些更多的重写规则。首先,我们需要修复那些采用常量操作数的 idiv 指令。每当 idiv 需要操作一个常量时,我们首先将该常量复制到我们的临时寄存器中。例如,我们重写
idivl $3
如下所示:
movl $3, %r10d
idivl %r10d
add 和 sub 指令,像 mov 一样,不能同时使用内存地址作为源操作数和目标操作数。我们像重写 mov 一样重写它们,因此
addl -4(%rbp), -8(%rbp)
变为:
movl -4(%rbp), %r10d
addl %r10d, -8(%rbp)
imul 指令不能使用内存地址作为目标,无论它的源操作数是什么。为了修复指令的目标操作数,我们使用 R11 寄存器代替 R10。因此,为了修复 imul,我们将目标加载到 R11 中,将其与源操作数相乘,然后将结果存回目标地址。换句话说,指令
imull $3, -4(%rbp)
变为:
movl -4(%rbp), %r11d
imull $3, %r11d
movl %r11d, -4(%rbp)
使用不同的寄存器来修正源操作数和目标操作数,将在第二部分中变得非常有用,因为我们有时会重写同一条指令的源和目标。我们需要两个寄存器,以便不同操作数的修正指令不会互相覆盖。
一旦你更新了汇编生成、伪寄存器替换和指令修正编译过程,你的编译器应该能够生成完整且正确的汇编程序来执行基本的算术运算。剩下的就是以正确的格式输出这些汇编程序。
代码生成
最后一步是扩展代码生成阶段,以处理我们的新汇编指令。表 3-7 到表 3-10 展示了如何打印出每个构造,其中新的构造已加粗显示。
表 3-7: 顶层汇编构造格式化
| 汇编顶层构造 | 输出 |
|---|---|
| Program(function_definition) |
Print out the function definition.
On Linux, add at end of file:
.section .note.GNU-stack,"",@progbits
|
| Function(name, instructions) |
|---|
.globl <name>
<name>:
pushq %rbp
movq %rsp, %rbp
<instructions>
|
表 3-8: 汇编指令格式化
| 汇编指令 | 输出 |
|---|---|
| Mov(src, dst) |
movl <src>, <dst>
|
| Ret |
|---|
movq %rbp, %rsp
popq %rbp
ret
|
| Unary(unary_operator, operand) |
|---|
<unary_operator> <operand>
|
| Binary(binary_operator, src, dst) |
|---|
<binary_operator> <src>, <dst>
|
| Idiv(operand) |
|---|
idivl <operand>
|
| Cdq |
|---|
cdq
|
| AllocateStack(int) |
|---|
subq $<int>, %rsp
|
表 3-9: 汇编运算符的指令名称
| 汇编运算符 | 指令名称 |
|---|---|
| Neg | negl |
| Not | notl |
| Add | addl |
| Sub | subl |
| Mult | imull |
表 3-10: 汇编操作数格式化
| 汇编操作数 | 输出 |
|---|---|
| Reg(AX) | %eax |
| Reg(DX) | %edx |
| Reg(R10) | %r10d |
| Reg(R11) | %r11d |
| Stack(int) | |
| Imm(int) | $ |
新指令操作 32 位值,因此它们带有 l 后缀(除了 cdq,它不遵循常规命名规则)。请注意,我们用来减去整数的 subl 指令和用来在栈上分配空间的 subq 指令是同一指令的 32 位和 64 位版本。
额外加分:位运算符
既然你已经学会了如何编译二进制运算符,你可以自己实现位运算二进制运算符。这些包括位与(&)、位或(|)、位异或(^)、左移(<<)和右移(>>)。你的编译器可以像处理你刚刚添加的运算符一样处理这些运算符。你需要查阅这些运算符的相对优先级,并查看 x64 指令集的文档,了解如何使用相关的汇编指令。
位运算是可选的;后续的测试用例不依赖于它们。如果你实现了位运算符,使用 --bitwise 标志来包含此功能的测试用例:
$ **./test_compiler** `**/path/to/your_compiler**` **--chapter 3 --bitwise**
在后续章节运行测试脚本时,也要包含此标志,以便包含任何使用位运算符的章节的测试用例。
总结
在本章中,你在编译器中实现了多个二元算术操作。你使用了一种新技术——优先级递增,来解析递归下降解析器处理不佳的表达式。在下一章中,你将实现更多的单目和二元操作符:逻辑运算符 !、&& 和 ||,以及像 ==、< 和 > 这样的关系运算符。部分运算符与汇编指令的对应关系较弱,因此我们将在 TACKY 中将它们分解为更低级的指令。我们还将介绍条件汇编指令,这在稍后实现如 if 语句和循环等控制流语句时,尤为重要。
附加资源
这些博客文章帮助我理解了优先级递增以及它如何与解决同一问题的类似算法相关;你可能也会觉得它们有帮助:
-
Eli Bendersky 的《通过优先级递增解析表达式》是对优先级递增算法的详细概述 (
eli.thegreenplace.net/2012/08/02/parsing-expressions-by-precedence-climbing)。列表 3-7 中的优先级递增代码在很大程度上改编自这篇博客文章;它还启发了《优先级递增实战》一文中的示例展示,在第 55 页。 -
《递归下降解析器的一些问题》,同样由 Eli Bendersky 撰写,讨论了如何使用纯递归下降解析器处理二元表达式 (
eli.thegreenplace.net/2009/03/14/some-problems-of-recursive-descent-parsers)。 -
Andy Chu 写了两篇关于优先级递增的有用博客文章。第一篇《Pratt 解析与优先级递增是相同的算法》探讨了优先级递增和 Pratt 解析算法的基本相似性 (
www.oilshell.org/blog/2016/11/01.html)。第二篇《优先级递增被广泛应用》讨论了它们之间的区别 (www.oilshell.org/blog/2017/03/30.html)。这些文章澄清了不同解析算法中一些令人困惑的术语。

Description
第四章:4 逻辑与关系操作符

现在你已经知道如何编译二元操作符,接下来你将添加大量的操作符(再加一个一元操作符)。在本章中,你将添加三个逻辑操作符:NOT(!)、AND(&&)和 OR(||)。你还将添加关系操作符:<、>、==,等等。这些操作符中的每一个都会测试某个条件,如果条件为真,则返回 1,否则返回 0。
&&和||操作符与我们迄今为止看到的二元操作符不同,因为它们是短路操作符:如果你在评估第一个操作数后就知道结果,那么就不再评估第二个操作数。为了支持短路逻辑,我们将在 TACKY 中添加新的指令,允许我们跳过代码块。在汇编生成阶段,我们还将引入几条新指令,包括条件汇编指令,只有在满足特定条件时,我们才会执行相应的操作。
在继续讲解编译器的处理过程之前,我们先简要讨论一下短路操作符。
短路操作符
C 标准保证,当你不需要第二个操作数时,&&和||会进行短路。例如,考虑表达式(1 - 1) && foo()。由于第一个操作数的值是 0,整个表达式将无论如何都评估为 0,不管foo返回什么,因此我们根本不会调用foo。同样,如果||的第一个操作数非零,我们就不再评估第二个操作数。
这不仅仅是性能优化;第二个操作数可能不会改变表达式的结果,但它的求值可能会产生可见的副作用。例如,foo 函数可能会执行 I/O 操作或更新全局变量。如果你的编译器没有实现&& 和 || 作为短路运算符,一些已编译的程序将表现异常。(标准在第 6.5.13 节第 4 段中定义了 && 运算符的行为,在第 6.5.14 节第 4 段中定义了 || 运算符的行为。)
现在我们已经明确了这些运算符的工作方式,你可以继续编码了。
词法分析器
本章中,你将添加九个新标记:
| ! | 感叹号,逻辑非运算符 |
|---|---|
| && | 两个与符号,逻辑与运算符 |
| || | 两个竖线,逻辑或运算符 |
| == | 两个等号,“等于”运算符 |
| != | 感叹号后跟等号,“不等于”运算符 |
| < | “小于”运算符 |
| > | “大于”运算符 |
| <= | “小于或等于”运算符 |
| >= | “大于或等于”运算符 |
你的词法分析器应该像处理其他运算符一样处理这些运算符。记住,词法分析器应该始终选择下一个标记的最长匹配项。例如,如果输入是 <=something,词法分析器发出的下一个标记应该是 <=,而不是 <。
解析器
接下来,我们将把新操作添加到 AST 定义中。清单 4-1 展示了更新后的定义,已添加的部分用粗体标出。
program = Program(function_definition)
function_definition = Function(identifier name, statement body)
statement = Return(exp)
exp = Constant(int)
| Unary(unary_operator, exp)
| Binary(binary_operator, exp, exp)
unary_operator = Complement | Negate **| Not**
binary_operator = Add | Subtract | Multiply | Divide | Remainder **| And | Or**
**| Equal | NotEqual | LessThan | LessOrEqual**
**| GreaterThan | GreaterOrEqual**
清单 4-1:带有比较和逻辑运算符的抽象语法树
我们还需要对语法做相应的修改,详情见清单 4-2。
<program> ::= <function>
<function> ::= "int" <identifier> "(" "void" ")" "{" <statement> "}"
<statement> ::= "return" <exp> ";"
<exp> ::= <factor> | <exp> <binop> <exp>
<factor> ::= <int> | <unop> <factor> | "(" <exp> ")"
<unop> ::= "-" | "~" **| "!"**
<binop> ::= "-" | "+" | "*" | "/" | "%" **| "&&" | "||"**
**| "==" | "!=" | "<" | "<=" | ">" | ">="**
<identifier> ::= ? An identifier token ?
<int> ::= ? A constant token ?
清单 4-2:带有比较和逻辑运算符的语法
在 清单 4-1 和 4-2 中,我们增加了一些新运算符,但没有做其他更改。现在,我们准备更新解析代码。首先,更新 parse_factor 来处理新的 ! 运算符。它应该以与解析一元运算符 ~ 和 - 运算符相同的方式解析 !。
接下来,更新 parse_exp 以处理新的二元运算符。在 第三章 中,我们将每个二元运算符与一个数字优先级值进行了关联。现在,我们将为新的运算符赋予优先级值。这些运算符的优先级低于 第三章 中的运算符,并且它们都是左结合的。在新的运算符中,<、<=、> 和 >= 拥有最高的优先级,其次是等式运算符,== 和 !=。&& 运算符的优先级低于等式运算符,而 || 拥有最低的优先级。我选择的优先级值列在 表 4-1 中,新运算符以粗体显示。
表 4-1: 旧版和新版二元运算符的优先级值
| 运算符 | 优先级 |
|---|---|
| * | 50 |
| / | 50 |
| % | 50 |
| + | 45 |
| - | 45 |
| < | 35 |
| <= | 35 |
| > | 35 |
| >= | 35 |
| == | 30 |
| != | 30 |
| && | 10 |
| || | 5 |
这些值间隔得足够远,可以为第三章中的可选位运算符留出空间。底部还留有足够的空间,供我们在接下来的两章中添加 = 和 ?: 运算符。只要所有运算符之间的优先级关系正确,你不需要使用表格中的精确值。
你还需要扩展将令牌转换为 unary _operator 和 binary_operator AST 节点的代码。例如,将 + 令牌转换为 Add 节点的函数,应该还要将 == 令牌转换为 Equal 节点。(最后两章中的伪代码调用了单独的函数,parse_unop 和 parse_binop,来处理这些转换。)
一旦你更新了解析器的优先级值表,parse _binop 和 parse_unop,你就完成了!我们在上一章实现的优先级提升算法可以处理新的运算符,无需进一步更改。
TACKY 生成
现在词法分析器和解析器已经正常工作,我们可以进入一个不太熟悉的领域:处理 TACKY 中的新运算符。你可以像实现二元运算符一样,将关系运算符转换为 TACKY。例如,给定表达式 e1 < e2,生成的 TACKY 看起来像是 清单 4-3 中的内容。
`<instructions for e1>`
v1 = `<result of e1>`
`<instructions for e2>`
v2 = `<result of e2>`
Binary(LessThan, v1, v2, result)
清单 4-3:实现 TACKY 中的 < 运算符
然而,您不能通过这种方式生成 && 和 || 操作符,因为它们是短路操作。列表 4-3 中的代码始终会评估 e1 和 e2,但我们需要生成的代码有时会跳过 e2。为了支持短路操作符,我们将添加一条 无条件跳转 指令,允许我们跳转到程序中的其他位置。我们还将添加两条 条件跳转 指令,只有在满足特定条件时才会跳转。
向 TACKY 中间表示添加跳转、复制和比较
列表 4-4 展示了最新的 TACKY 中间表示,包含新的跳转指令。
program = Program(function_definition)
function_definition = Function(identifier, instruction* body)
instruction = Return(val)
| Unary(unary_operator, val src, val dst)
| Binary(binary_operator, val src1, val src2, val dst)
**| Copy(val src, val dst)**
**| Jump(identifier target)**
**| JumpIfZero(val condition, identifier target)**
**| JumpIfNotZero(val condition, identifier target)**
**| Label(identifier)**
val = Constant(int) | Var(identifier)
unary_operator = Complement | Negate **| Not**
binary_operator = Add | Subtract | Multiply | Divide | Remainder **| Equal | NotEqual**
**| LessThan | LessOrEqual | GreaterThan | GreaterOrEqual**
列表 4-4:向 TACKY 添加比较、跳转和标签
跳转 指令的工作原理与 C 语言中的 goto 类似:它使程序跳转到某个标识符 目标 所标记的位置。标签 指令将标识符与程序中的位置关联起来。列表 4-5 中的 TACKY 代码片段展示了 跳转 和 标签 指令如何协同工作。
Unary(Negate, Constant(1), Var("tmp"))
Jump("there")
❶ Unary(Negate, Constant(2), Var("tmp"))
Label("there")
Return(Var("tmp"))
列表 4-5:TACKY 的代码片段,包含一条 跳转 指令
该程序将 -1 存储在 tmp 中,然后执行 跳转 指令,跳转到 标签 指令。接着,它执行 返回 指令,返回 -1。第二条 一元 指令 ❶ 完全不会执行,因为我们跳过了它。
TACKY IR 中的第一个条件跳转指令 JumpIfZero,当 condition 的值为 0 时,跳转到由 target 指定的指令。如果 condition 不为 0,我们不会跳转到 target,而是按常规执行下一条指令。第二个条件跳转指令 JumpIfNotZero 则相反:只有当 condition 不为 0 时,我们才会跳转到 target。我们其实并不需要这两条指令,因为你可以使用其中一条指令配合 Not 指令来实现相同的行为。但添加这两条指令可以使我们为 && 和 || 运算符生成更简单的 TACKY,从而最终转换为更简洁、短小的汇编代码。
另一个新指令是 Copy。由于 && 和 || 最终返回 1 或 0,我们使用此指令将 1 或 0 复制到临时变量中,该变量保存表达式的结果。
除了这五个额外的指令外,最新的 TACKY IR 还包括新的关系和逻辑二元运算符,以及一元 Not 运算符。
将短路运算符转换为 TACKY
让我们使用新的 TACKY 指令来实现 && 和 || 运算符。对于表达式 e1 && e2,TACKY 应该如下所示 Listing 4-6。
`<instructions for e1>`
v1 = `<result of e1>`
JumpIfZero(v1, false_label)
`<instructions for e2>`
v2 = `<result of e2>`
JumpIfZero(v2, false_label)
result = 1
Jump(end)
Label(false_label)
result = 0
Label(end)
Listing 4-6: 在 TACKY 中实现 && 运算符
我们首先评估e1。如果它是 0,我们将直接跳过后续步骤,并将result设置为0,无需评估e2。我们通过JumpIfZero指令实现这一点;如果v1为 0,我们直接跳转到false_label,然后使用Copy指令将result设置为0。(我将其写成result = 0,而不是Copy(0, result),以提高可读性。稍后的章节中,我会在 TACKY 符号表示法上做类似的处理。)如果v1不是 0,我们仍然需要评估e2。我们处理v2为 0 的情况,方式与处理v1为 0 时相同,使用JumpIfZero跳转到false_label。只有在没有执行任何条件跳转时,我们才会到达Copy指令,result = 1。这意味着e1和e2都非零,因此我们将result设置为1。然后,我们跳过result = 0,跳转到end标签,避免覆盖result。
我会把将 || 操作翻译成 TACKY 留给你自己完成。生成的 TACKY 将类似于 列表 4-6,但是它会使用 JumpIfNotZero 指令,而不是 JumpIfZero。这还剩下 ! 和所有的关系操作符;你可以像前几章中添加的一元和二元操作符一样,将它们转化为 TACKY。
生成标签
标签和临时变量一样,必须在全局范围内唯一:像 Jump("foo") 这样的指令,如果标签 foo 出现在多个地方,就没有意义。你可以通过将全局计数器纳入标签中,确保它们是唯一的,就像你在 第二章 中对变量名所做的那样。
与临时变量不同,标签会出现在最终的汇编程序中,因此它们必须是汇编器认为语法上有效的标识符。它们应只包含字母、数字、点和下划线。选择描述性标签可以让你的汇编程序更易于阅读和调试。例如,你可以使用字符串 and_falseN 作为 列表 4-6 中的 false_label,其中 N 是全局计数器的当前值。
虽然标签不能相互冲突,但它们与临时变量名冲突是可以的。如果你在这里生成的标签与用户定义的函数名冲突也是可以的,即使自动生成的标签和函数名都会在最终的汇编程序中变成标签。我们将在代码生成时对自动生成的标签进行处理,以避免它们与用户定义的标识符冲突。
汇编中的比较与跳转
在开始汇编生成过程之前,让我们先讲解一下我们需要的新汇编指令。首先,我们将讨论 cmp 指令,它用于比较两个值,以及 条件设置 指令,它根据比较结果将字节设置为 1 或 0。我们将使用这些指令来实现像 < 这样的关系操作符。接下来,我们将讨论条件跳转和无条件跳转指令。
比较和状态标志
所有条件指令依赖的“条件”是 RFLAGS 寄存器的状态。与 EAX、RSP 和我们到目前为止见过的其他寄存器不同,我们通常不能直接设置 RFLAGS。相反,CPU 每次执行指令时会自动更新 RFLAGS。如其名称所示,这个寄存器中的每一位都是一个标志,报告有关最后一条指令或 CPU 状态的某些信息。不同的指令更新不同的标志:add、sub 和 cmp 指令更新本节中我们将讨论的所有标志,而 mov 指令则不更新任何标志。我们现在可以忽略其他指令的影响。在讨论 RFLAGS 时,每当我提到“最后一条指令”或“最后结果”,我指的是影响我正在讨论的特定标志的最后一条指令。
现在,我们关注这三个标志:
零标志 (ZF)
如果最后一条指令的结果为 0,则 ZF 被设置为 1;如果最后一条指令的结果为非零值,则 ZF 被设置为 0。
符号标志 (SF)
如果最后结果的最高有效位是 1,则 SF 被设置为 1;如果最高有效位是 0,则 SF 被设置为 0。记住,在二进制补码表示法中,负数的最高有效位始终是 1,正数的最高有效位始终是 0。因此,符号标志告诉我们最后一条指令的结果是正数还是负数。(如果最后的结果应该被解释为无符号整数,那么它不能为负数,因此符号标志没有意义。)
溢出标志 (OF)
如果最后一条指令导致有符号整数溢出,则 OF 被设置为 1;否则设置为 0。整数溢出发生在有符号整数操作的结果无法用可用的位数表示时。一个正数结果会在其值超过类型可以表示的最大值时发生溢出。假设我们正在操作 4 位整数。我们可以表示的最大有符号数是 7,即二进制表示为 0111。如果我们使用 add 指令将其加 1,结果是 1000。如果我们将其解释为无符号整数,它的值是 8;但是,如果我们将其解释为二进制补码有符号整数,它的值是 -8。计算的结果应该是正数,但由于发生了溢出,它变成了负数。这个计算将溢出标志设置为 1。
我们还会遇到与此相反的整数溢出情况:当结果应该为负数,但它小于可能的最小值。例如,在普通数学中,–8 – 1 = –9。然而,如果我们使用 sub 指令从–8 的 4 位二进制补码表示中减去 1,得到的是 1000,结果会变成 0111,即 7。此时,溢出标志也会被设置为 1。
无符号结果也可能对其类型表示来说过大或过小,但在本书中我不会将其称为整数溢出。相反,我称结果为 环绕,这与 C 标准中对无符号操作的术语以及大多数关于 x64 汇编的讨论更一致。我做出这个区分是因为无符号环绕遵循的规则与有符号整数溢出不同,CPU 对其检测的方式也不同。你将在第二部分学习如何处理无符号环绕。像 SF 一样,如果结果是无符号的,OF 是没有意义的。
表 4-2 和 表 4-3 总结了每种整数溢出的可能情况。表 4-2 描述了加法的结果。
表 4-2: 从加法产生的整数溢出与下溢
| a + b | b > 0 | b < 0 |
|---|---|---|
| a > 0 | 从正数到负数的溢出 | 都不是 |
| a < 0 | 都不是 | 从负数到正数的溢出 |
表 4-3 描述了减法的结果;它只是 表 4-2 的列交换版本,因为 a - b 和 a + (- b) 是等价的。
表 4-3: 从减法产生的整数溢出与下溢
| a - b | b > 0 | b < 0 |
|---|---|---|
| a > 0 | 都没有溢出 | 从正数溢出到负数 |
| a < 0 | 从负数溢出到正数 | 都没有溢出 |
指令 cmp b, a 计算 a - b,与 sub 指令完全相同,并对 RFLAGS 产生相同的影响,但它会丢弃结果,而不是将其存储在 a 中。当你只想比较两个数,而不想覆盖 a 时,这种方式更为方便。
让我们来看看在执行 cmp b, a 指令后 ZF 和 SF 的值:
-
如果 a == b,则 a - b 的结果为 0,因此 ZF 为 1,SF 为 0。
-
如果 a > b,则 a - b 是正数,因此 SF 和 ZF 都为 0。
-
如果 a < b,则 a - b 是负数,因此 SF 为 1,ZF 为 0。
通过执行 cmp 指令,然后检查 ZF 和 SF,你可以处理本章中实现的每一个比较。但等一下!这并不完全正确,因为 a - b 可能会发生溢出,这将改变 SF 的值。让我们来看看溢出如何影响每种情况:
-
如果 a == b,则 a - b 的结果为 0,因此不会发生溢出。
-
如果 a > b,则当 a 为正数且 b 为负数时,a - b 可能会溢出。此时,正确的结果应为正数,但如果发生溢出,结果将为负数。在这种情况下,SF 将为 1,OF 也会为 1。
-
如果 a < b,则当 a 为负且 b 为正时,a - b 可能会溢出。在这种情况下,正确的结果应该是负数,但实际结果将是正数。这意味着 SF 将为 0,但 OF 将为 1。
表 4-4 给出了我们考虑的每种情况中这些标志的值。
表 4-4: cmp 指令对状态标志的 影响
| ZF | OF | SF | |
|---|---|---|---|
| a == b | 1 | 0 | 0 |
| a > b, 无溢出 | 0 | 0 | 0 |
| a > b, 溢出 | 0 | 1 | 1 |
| a < b, 无溢出 | 0 | 0 | 1 |
| a < b, 溢出 | 0 | 1 | 0 |
你可以通过检查 SF 和 OF 是否相同来判断a和b哪个更大。如果它们相同,我们知道a ≥ b。或者它们都为 0,因为我们得到的是一个没有溢出的正(或零)结果,或者它们都为 1,因为我们得到了一个溢出的正结果,直到它变为负数。如果 SF 和 OF 不同,我们知道a < b。要么我们得到一个没有溢出的负结果,要么我们得到一个溢出的负结果并变为正数。
现在你已经理解了如何设置 ZF、OF 和 SF,让我们来看一下几条依赖于这些标志的指令。
条件设置指令
要实现关系运算符,我们首先使用cmp指令设置一些标志,然后基于这些标志设置表达式的结果。我们通过条件设置指令执行第二步。每个条件设置指令接受一个寄存器或内存地址作为操作数,它根据 RFLAGS 的状态将该操作数设置为 0 或 1。所有的条件设置指令都是相同的,唯一的区别是它们测试不同的条件。表 4-5 列出了我们在本章中需要的条件设置指令。
表 4-5: 条件设置指令
| 指令 | 含义 | 标志 |
|---|---|---|
| sete | 如果 a == b 则设置字节 | ZF 设置 |
| setne | 如果 a != b 则设置字节 | ZF 未设置 |
| setg | 如果 a > b 则设置字节 | ZF 未设置且 SF == OF |
| setge | 如果 a ≥ b SF == OF | |
| setl | 如果 a < b SF != OF | |
| setle | 如果 a ≤ b ZF 已设置或 SF != OF |
与我们到目前为止看到的其他指令不同,条件设置指令仅接受 1 字节操作数。例如,sete %eax不是有效指令,因为 EAX 是 4 字节寄存器。然而,指令 sete %al 是有效的;它设置了 AL 寄存器,EAX 的最低有效字节。为了有条件地将整个 EAX 寄存器设置为 0 或 1,你需要在设置 AL 之前将 EAX 清零,因为条件设置指令不会清除其上半部分字节。例如,如果 EAX 是
11111111111111111111111111111011
然后你运行
movl $2, %edx
cmpl $1, %edx
sete %al
然后 EAX 中的新值是
11111111111111111111111100000000
这显然不是 0。sete指令将 EAX 的最后一个字节清零,但没有清除其他部分。
如果其操作数是一个内存地址,条件设置指令将更新该地址处的单字节。请注意,内存地址可以是 1 字节、4 字节或 8 字节的操作数,这取决于上下文。在 sete -4(%rbp) 中,操作数 -4(%rbp) 表示 RBP-4 处的单字节内存;在 addl $1, -4(%rbp) 中,它表示从 RBP-4 开始的 4 字节内存。
跳转指令
jmp汇编指令接受一个标签作为参数,并执行无条件跳转到该标签。跳转汇编指令操作另一个特殊目的寄存器 RIP,RIP 始终保存下一个要执行的指令的地址(IP 代表指令指针)。为了执行一系列指令,CPU 进行取指-执行周期:
-
从 RIP 指向的内存地址中取出指令并将其存储在一个特殊用途的指令寄存器中。(此寄存器没有名称,因为你无法在汇编中引用它。)
-
将 RIP 增量指向下一条指令。x64 中的指令长度并不相同,因此 CPU 必须检查它刚刚取回的指令的长度,并按该长度增加 RIP。
-
在指令寄存器中运行指令。
-
重复执行。
通常,按照这些步骤执行指令时,它们会按照出现在内存中的顺序执行。但 jmp 将一个新值放入 RIP 中,这会改变 CPU 下一步执行的指令。汇编器和链接器将跳转指令中的标签转换为 相对偏移量,该偏移量告诉你应该如何增减 RIP。请参考 清单 4-7 中的汇编代码片段。
addl $1, %eax
jmp foo
movl $0, %eax
foo:
ret
清单 4-7:包含 jmp 指令的汇编代码片段
movl $0, %eax 的机器指令长度为 5 字节。为了跳过它并执行 ret 指令,jmp 需要额外增加 5 字节。汇编器和链接器因此将 jmp foo 转换为 jmp 5 的机器指令。然后,当 CPU 执行该指令时,它:
-
获取指令 jmp 5 并将其存储在指令寄存器中。
-
增加 RIP,以指向下一条指令 movl $0, %eax。
-
执行 jmp 5。这会将 RIP 增加 5 字节,使其指向 ret。
-
获取 RIP 指向的指令 ret,并从该位置继续取指-执行周期。
请注意,标签不是指令:CPU 不执行它们,且它们不会出现在最终可执行文件的文本区中(该部分包含机器指令)。
条件跳转将标签作为参数,但只有在条件满足时才跳转到该标签。条件跳转看起来与条件设置指令非常相似;它们依赖于相同的条件,使用 RFLAGS 中的相同标志。例如,清单 4-8 中的汇编代码如果 EAX 和 EDX 寄存器相等,则返回 3,否则返回 0。
cmpl %eax, %edx
je return3
movl $0, %eax
ret
return3:
movl $3, %eax
ret
清单 4-8:包含条件跳转的汇编代码片段
如果 EAX 和 EDX 的值相等,cmpl会将 ZF 设置为 1,接着je跳转到return3。然后,紧随return3之后的两个指令会执行,函数返回3。如果 EAX 和 EDX 不相等,je不会执行跳转,函数返回0。类似地,jne只有当 ZF 为 0 时才会跳转。还有其他一些跳转指令检查不同的条件,但在这一章中我们不需要它们。
汇编生成
既然你已经理解了所需的新的汇编指令集,让我们扩展汇编 AST 并更新每个汇编生成阶段。列表 4-9 定义了最新的汇编 AST,新增的部分已加粗显示。
program = Program(function_definition)
function_definition = Function(identifier name, instruction* instructions)
instruction = Mov(operand src, operand dst)
| Unary(unary_operator, operand)
| Binary(binary_operator, operand, operand)
**| Cmp(operand, operand)**
| Idiv(operand)
| Cdq
**| Jmp(identifier)**
**| JmpCC(cond_code, identifier)**
**| SetCC(cond_code, operand)**
**| Label(identifier)**
| AllocateStack(int)
| Ret
unary_operator = Neg | Not
binary_operator = Add | Sub | Mult
operand = Imm(int) | Reg(reg) | Pseudo(identifier) | Stack(int)
**cond_code = E | NE | G | GE | L | LE**
reg = AX | DX | R10 | R11
列表 4-9:包含比较和条件指令的汇编 AST
由于所有条件跳转指令的形式相同,我们通过一个JmpCC指令表示它们,并通过不同的条件码区分它们。我们对条件设置指令也做同样的处理。虽然Label实际上不是指令(因为标签不会被 CPU 执行),但在此阶段我们将标签视为指令。
为了实现 TACKY JumpIfZero和JumpIfNotZero指令,我们使用新的JmpCC汇编指令。我们转换
JumpIfZero(val, target)
为:
Cmp(Imm(0), val)
JmpCC(E, target)
我们以相同的方式实现JumpIfNotZero,但条件码使用NE代替E。
类似地,我们使用条件设置指令实现所有的关系运算符。例如,TACKY 指令
Binary(GreaterThan, src1, src2, dst)
变为:
Cmp(src2, src1)
Mov(Imm(0), dst)
SetCC(G, dst)
对于所有其他关系运算符,替换G为相应的条件码。记得在条件设定指令之前清零目标寄存器,因为它仅设置最低字节。在cmp指令之后执行mov是安全的,因为mov不会改变 RFLAGS。一个潜在的问题是,SetCC需要一个 1 字节的操作数,但dst是 4 字节;幸运的是,我们可以在代码生成阶段处理这个问题。如果dst是内存中的位置,SetCC会设置该位置的第一个字节,这正是我们需要的行为。(由于 x64 处理器是小端,第一个字节是最低有效字节,因此将该字节设置为 1 会将整个 32 位值设置为 1。)如果dst是寄存器,我们将在代码生成过程中使用相应的 1 字节寄存器名称来发出SetCC。汇编 AST 中的寄存器大小不敏感,因此无论我们将dst作为 4 字节操作数还是 1 字节操作数使用,目前我们都以相同的方式表示它。
因为!x等价于x == 0,我们也通过条件设定指令实现一元运算符!。我们转换 TACKY 指令
Unary(Not, src, dst)
转换成:
Cmp(Imm(0), src)
Mov(Imm(0), dst)
SetCC(E, dst)
剩余的 TACKY 指令——Jump、Label和Copy——很简单。一个 TACKY 的Jump变成汇编语言中的Jmp,Label变成Label,Copy变成Mov。表 4-6 和 4-7 总结了如何将每个新的 TACKY 结构转换为汇编语言。请注意,这些表格只包含新的结构,与第二章和第三章中的等效表格不同。
表 4-6 显示了如何将新的 Copy、Label 以及条件和无条件跳转指令转换为汇编,同时展示了带有新 Not 操作符的 Unary 指令和带有新关系运算符的 Binary 指令。
表 4-6: 将 TACKY 指令转换为汇编
| TACKY 指令 | 汇编指令 |
|---|
| Unary(Not, src, dst) | Cmp(Imm(0), src) Mov(Imm(0), dst) |
SetCC(E, dst) |
| Binary(relational_operator, src1, src2, dst) | Cmp(src2, src1) Mov(Imm(0), dst)
SetCC(relational_operator, dst) |
| Jump(target) | Jmp(target) |
|---|---|
| JumpIfZero(condition, target) | Cmp(Imm(0), condition) JmpCC(E, target) |
| JumpIfNotZero(condition, target) | Cmp(Imm(0), condition) JmpCC(NE, target) |
| Copy(src, dst) | Mov(src, dst) |
| Label(identifier) | Label(identifier) |
表 4-7 给出了 TACKY 中每个关系运算符的相应条件代码。
表 4-7: 将 TACKY 比较转换为汇编
| TACKY 比较 | 汇编条件代码 |
|---|---|
| Equal | E |
| NotEqual | NE |
| LessThan | L |
| LessOrEqual | LE |
| GreaterThan | G |
| GreaterOrEqual | GE |
从现在开始,每章描述从 TACKY 到汇编的转换的表格将仅显示与前一章不同的部分。附录 B 包含了两组表格,展示了从 TACKY 到汇编的完整转换:一组显示了在 第一部分结束时的转换,另一组显示了在 第二部分结束时的转换。
替换伪寄存器
更新此过程,以便将新出现的 Cmp 和 SetCC 指令使用的任何伪寄存器替换为栈地址,就像你为其他所有指令所做的那样。
修正 cmp 指令
cmp 指令,和 mov、add、sub 指令一样,不能对两个操作数使用内存地址。我们按照通常的方式重写它,将
cmpl -4(%rbp), -8(%rbp)
转换为:
movl -4(%rbp), %r10d
cmpl %r10d, -8(%rbp)
cmp 指令的第二个操作数不能是常量。如果你记得 cmp 遵循与 sub 相同的格式,这样就能理解。sub、add 或 imul 指令的第二个操作数也不能是常量,因为该操作数保存结果。尽管 cmp 不产生结果,但相同的规则适用。我们重写
cmpl %eax, $5
作为:
movl $5, %r11d
cmpl %eax, %r11d
遵循前一章节的约定,我们使用 R10 固定 cmp 指令的第一个操作数,使用 R11 固定其第二个操作数。
代码发射
我们已经生成了一个有效的汇编程序,准备开始发射它。在本章中,代码发射稍微复杂一些,原因有两个。首先,我们处理的是 1 字节和 4 字节寄存器。我们将根据寄存器出现在条件集指令(需要 1 字节操作数)还是其他我们到目前为止遇到的指令(需要 4 字节操作数)中,打印出不同的寄存器名称。
第二个问题是生成标签。某些汇编标签是由编译器自动生成的,而另一些——函数名——是用户定义的标识符。目前,唯一的函数名是 main,但最终我们将编译包含任意函数名的程序。由于标签必须是唯一的,因此自动生成的标签不能与程序中可能出现的任何函数名冲突。
我们通过为自动生成的标签添加一个特殊的本地标签前缀来避免冲突。Linux 上的本地标签前缀是 .L,而 macOS 上是 L。在 Linux 上,这些标签不会与用户定义的标识符冲突,因为 C 语言中的标识符不能包含句点。在 macOS 上,它们也不会冲突,因为我们会在所有用户定义的名称前加下划线(例如,main 变成 _main)。
本地标签有一个方便的优点:当你需要调试这段代码时,它们不会混淆 GDB 或 LLDB。汇编器将大多数标签放入目标文件的符号表中,但会将以本地标签前缀开头的标签排除在外。如果自动生成的标签出现在符号表中,GDB 和 LLDB 会误将它们当作函数名,这样在你尝试反汇编一个函数或查看堆栈跟踪时就会出现问题。
除了这两个问题,代码生成过程相对直接。表 4-8 到 4-10 总结了这一阶段的变更。从这一点开始,代码生成表格将只显示与前一章不同的部分,就像描述从 TACKY 到汇编的转换的表格一样。请参阅 附录 B 获取完整的代码生成过程概览;它包括三组表格,展示了这一过程在 第一部分、第二部分 和 第三部分 结束时的样子。
表 4-8 展示了如何打印出本章的新汇编指令。它使用 .L 前缀来表示本地标签;如果你使用的是 macOS,请改用不带句点的 L 前缀。
表 4-8: 格式化汇编指令
| 汇编指令 | 输出 |
|---|---|
| Cmp(operand, operand) |
cmpl <operand>, <operand>
|
| Jmp(label) |
|---|
jmp .L<label>
|
| JmpCC(cond_code, label) |
|---|
j<cond_code> .L<label>
|
| SetCC(cond_code, operand) |
|---|
set<cond_code> <operand>
|
| 标签(label) |
|---|
.L<label>:
|
cmp 指令添加了一个 l 后缀,用来表示它操作的是 4 字节的值。条件设置指令不使用后缀来指示操作数大小,因为它们只支持 1 字节的操作数。跳转和标签也不使用操作数大小后缀,因为它们不需要操作数。然而,条件跳转和设置指令确实需要后缀来表示它们测试的条件。表 4-9 给出了每个条件码对应的后缀。
表 4-9: 条件码指令后缀
| 条件码 | 指令后缀 |
|---|---|
| E | e |
| NE | ne |
| L | l |
| LE | le |
| G | g |
| GE | ge |
最后,表 4-10 给出了每个寄存器的 1 字节和 4 字节别名。4 字节别名与前一章相同;新的 1 字节别名用粗体标出。
表 4-10: 格式化汇编操作数
| 汇编操作数 | 输出 |
|---|---|
| Reg(AX) | 4 字节 |
| 1 字节 | |
| Reg(DX) | 4 字节 |
| 1 字节 | |
| Reg(R10) | 4 字节 |
| 1 字节 | |
| Reg(R11) | 4 字节 |
| 1 字节 |
当寄存器出现在SetCC时,输出 1 字节的寄存器名称,而在其他地方则输出 4 字节的名称。
总结
现在你的编译器可以处理关系和逻辑运算符了。在这一章中,你为 TACKY 添加了条件跳转,以支持短路运算符,并学习了几条新的汇编指令。你还了解了 CPU 如何跟踪当前指令并记录比较的结果。你在这一章中引入的新 TACKY 和汇编指令最终将帮助你实现像if语句和循环这样的复杂控制结构。但首先,你将实现 C 语言中最基本的功能之一:变量!
附加资源
若要深入讨论未定义行为,请参阅以下博客文章:
-
John Regehr 的《C 和 C++中的未定义行为指南,第一部分》是关于 C 标准中未定义行为的含义以及它如何影响编译器设计的一个很好的概述 (
<wbr>blog<wbr>.regehr<wbr>.org<wbr>/archives<wbr>/213). -
Raph Levien 的《未定义行为下,一切皆有可能》探讨了 C 语言中未定义行为的一些来源,以及它是如何被纳入标准的历史 (
<wbr>raphlinus<wbr>.github<wbr>.io<wbr>/programming<wbr>/rust<wbr>/2018<wbr>/08<wbr>/17<wbr>/undefined<wbr>-behavior<wbr>.html).

描述
第五章:5 局部变量

到目前为止,你只能编译返回常量表达式的程序。在本章中,你将实现局部变量,这将使你能够编译出更有趣的程序。你的编译器需要支持更具表现力的语法,以便能够解析声明、赋值和引用变量的 C 程序。它还需要处理变量声明和使用中的各种错误。为了捕捉这些潜在的错误,你将添加一个语义分析阶段,这个阶段在本章开头的图表中已经加粗。这个阶段验证变量是否在同一作用域中被多次声明,或者是否在声明之前就被使用。它还会为每个变量分配一个唯一的标识符,这样你就可以在 TACKY 中安全地引用它。
幸运的是,你的编译器中的 TACKY 和汇编 IR 已经支持变量,因为它们使用临时变量来存储中间结果。这意味着在 TACKY 生成之后,你无需对编译器做任何修改。在深入编译器的各个阶段之前,让我们定义一下需要支持的语言特性。
变量、声明和赋值
为了使变量即使在一定程度上有用,我们需要实现一些新的语言特性。首先,我们需要支持变量声明。在 C 语言中,每个局部变量必须在使用之前声明。变量声明由变量的类型、名称和一个可选的表达式组成,称为初始化器,用于指定变量的初始值。以下是带有初始化器的声明:
int a = 2 * 3;
以下是没有初始化器的声明:
int b;
第二,我们必须支持在表达式中使用变量的值,像 b + 1。就像整数常量一样,变量本身就是一个完整的表达式,但也可以出现在更复杂的逻辑和算术表达式中。
最后,我们需要支持变量的赋值。在 C 语言中,你使用赋值运算符(=)来更新变量。C 语言中的变量赋值是一个表达式,像加法、减法等一样。这意味着它会计算出一个结果,你可以在return语句中使用它,或者作为更大表达式的一部分。赋值表达式的结果是目标变量的更新值。例如,表达式2 * (a = 5)计算结果为 10。首先,你将值5赋给变量a,然后你将新的a值乘以2。由于赋值是一个表达式,你可以在一个表达式中同时进行多个赋值,例如a = b = c。与我们迄今为止见过的其他二元运算不同,赋值是右结合的,因此a = b = c相当于a = (b = c)。要计算这个表达式,你首先进行赋值b = c。然后,你将该表达式的结果,即新的b值,赋给a。
变量赋值是我们遇到的第一个具有副作用的表达式。这意味着它不仅仅是简化成一个值;它还对执行环境产生了一些影响。在2 * (a = 5)中,子表达式a = 5有一个值(5),它也有一个副作用(更新a)。大多数情况下,我们只关心变量赋值的副作用,而不是结果值。
一个操作只有在其作用在语言结构外部可见时,才算作副作用。例如,更新局部变量是赋值表达式的副作用,因为变量的新值在表达式外部是可见的。但它不是包含赋值表达式的函数的副作用,因为该效果在函数外部不可见。另一方面,更新全局变量将是表达式和函数的副作用。
由于我们正在实现带有副作用的表达式,因此添加对表达式语句的支持也是合理的,这些语句会评估一个表达式但不使用其结果。像以下这种赋值给变量的语句,
foo = 3 * 3;
就是表达式语句。这个表达式有副作用,将值9赋给foo。整个表达式的结果也是值9,但这个结果没有在其他地方使用;只有更新foo的副作用影响了程序。
你也可以有没有任何副作用的表达式语句:
1 + a * 2;
通常你不会看到没有副作用的表达式语句,因为它们完全没用,但它们是完全有效的。
任何表达式都可以出现在= 运算符的右侧,但只有某些表达式可以出现在左侧。将值赋给变量、数组元素和结构体成员是合理的:
x = 3;
array[2] = 100;
my_struct.member = x * 2;
但将值赋给常量或逻辑或算术表达式的结果是没有意义的:
4 = 5;
foo && bar = 6;
a - 5 = b;
可以出现在赋值左侧的表达式被称为左值。在本章中,我们处理的唯一左值是变量。你将在第二部分学习到更复杂的左值。
现在你已经理解了本章将要添加的语言特性,让我们来扩展编译器。
词法分析器
在本章中,你将添加一个新的标记:
= 等号,赋值运算符
你不需要新的标记来表示变量名。词法分析器已经识别了标识符,例如函数名main,而变量名只是标识符。我们不会在解析阶段之前区分函数名和变量名。
解析器
像往常一样,我们将更新 AST 和语法,以支持本章的新语言结构。我们还将更新我们的优先级爬升代码,以正确解析赋值表达式。
更新后的 AST 和语法
让我们首先通过扩展 AST 定义,来支持变量的使用、声明和赋值。为了支持在表达式中使用变量,我们将为exp AST 节点添加一个Var构造函数。由于变量赋值也是一种表达式,我们还将为exp添加一个Assignment构造函数。清单 5-1 展示了更新后的exp定义。
exp = Constant(int)
**| Var(identifier)**
| Unary(unary_operator, exp)
| Binary(binary_operator, exp, exp)
**| Assignment(exp, exp)**
清单 5-1:定义 exp AST 节点,包括 Var 和 Assignment
一个Var节点保存变量名。一个Assignment由两部分组成:被更新的左值和我们将赋值给该左值的表达式。当我们解析程序时,我们将允许赋值左边是任何表达式。在语义分析阶段,我们将确保该表达式是一个有效的左值。我们在语义分析阶段验证左值,而不是在解析阶段验证,因为我们将需要在后续章节中支持更复杂的左值。
接下来,我们将扩展statement AST 节点,以支持表达式语句。我们将添加一个新的Expression构造函数,它接受一个单独的exp节点作为参数。我们还将添加一个Null构造函数,用于表示空语句,它是没有表达式的表达式语句:
statement = Return(exp) **| Expression(exp) | Null**
空语句没有内容;它只是一个分号。当语法需要一个语句,但你不希望该语句执行任何操作时,它作为一个占位符出现。列表 5-2 取自 C 标准第 6.8.3 节,第 5 段,展示了你可能需要空语句的原因。
char *s;
/* ... */
while ( ❶ *s++ != '\0')
❷ ;
列表 5-2:来自 C 标准的空语句示例
在这个例子中,while 循环通过遍历每个字符直到遇到空字节来找到一个空终止字符串的结尾。循环体不需要做任何事情,因为所有的工作都发生在控制表达式 ❶ 中,但完全省略循环体在语法上是无效的。相反,你可以使用空语句 ❷。空语句与局部变量没有真正的关系,但我们会在这里实现它们,因为它们在技术上是表达式语句的一种。(它们也容易实现。)
我们还需要一个 AST 节点来表示变量声明:
declaration = Declaration(identifier name, exp? init)
声明由一个名称和一个可选的初始化器组成。(exp? 中的问号表示该字段是可选的。)我们将在第二部分中包含声明的类型信息,但现在不需要它,因为 int 是唯一可能的类型。
声明是一个单独的 AST 节点,而不是另一种类型的语句,因为声明不是语句!概念上,区别在于语句在程序运行时会被执行,而声明只是告诉编译器某个标识符存在并可以稍后使用。在 TACKY 生成过程中,这一区别会变得非常明显:我们会像普通变量赋值一样处理带有初始化器的声明,但没有初始化器的声明将会消失。
从解析器的角度来看,更具体的区别是,程序中有些地方可以出现语句,但不能出现声明。例如,if 语句的主体总是另一个语句:
if (a == 2)
return 4;
它不能是声明,因为声明不是语句。因此,这是无效的:
if (a == 2)
int x = 0;
听到 if 语句的主体是一个单独的语句可能令人惊讶,因为 if 语句的主体通常看起来像是一个语句和声明的列表,如下所示:
if (a == 2) {
int x = 0;
return x;
}
但是,包裹在大括号中的语句和声明列表实际上是一个单独的语句,称为 复合语句。我们将在第七章中实现复合语句;目前,关键点是我们需要在 AST 中区分语句和声明。
最后,我们需要修改函数体的定义方式,以便解析包含多个声明和语句的函数,像列表 5-3 中展示的那样。
int main(void) {
int a;
a = 2;
return a * 2;
}
列表 5-3:包含声明和多个语句的程序
到目前为止,我们将函数体定义为一个单一的语句:
function_definition = Function(identifier name, statement body)
但现在,我们需要将其定义为一系列语句和声明,统称为块项。我们将添加一个新的抽象语法树(AST)节点来表示块项:
block_item = S(statement) | D(declaration)
然后我们可以将函数体表示为一系列块项:
function_definition = Function(identifier name, **block_item*** body)
这里的星号表示body是一个列表。将所有内容组合在一起,列表 5-4 展示了新的 AST 定义,章节中的新增部分以粗体显示。
program = Program(function_definition)
function_definition = Function(identifier name, **block_item*** body)
**block_item = S(statement) | D(declaration)**
**declaration = Declaration(identifier name, exp? init)**
statement = Return(exp) **| Expression(exp) | Null**
exp = Constant(int)
**| Var(identifier)**
| Unary(unary_operator, exp)
| Binary(binary_operator, exp, exp)
**| Assignment(exp, exp)**
unary_operator = Complement | Negate | Not
binary_operator = Add | Subtract | Multiply | Divide | Remainder | And | Or
| Equal | NotEqual | LessThan | LessOrEqual
| GreaterThan | GreaterOrEqual
列表 5-4:包含变量、赋值表达式和表达式语句的抽象语法树
列表 5-5 展示了更新后的语法。
<program> ::= <function>
<function> ::= "int" <identifier> "(" "void" ")" "{" **{<block-item>}** "}"
**<block-item> ::= <statement> | <declaration>**
**<declaration> ::= "int" <identifier> ["=" <exp>] ";"**
<statement> ::= "return" <exp> ";" **| <exp> ";" | ";"**
<exp> ::= <factor> | <exp> <binop> <exp>
<factor> ::= <int> **| <identifier>** | <unop> <factor> | "(" <exp> ")"
<unop> ::= "-" | "~" | "!"
<binop> ::= "-" | "+" | "*" | "/" | "%" | "&&" | "||"
| "==" | "!=" | "<" | "<=" | ">" | ">=" **| "="**
<identifier> ::= ? An identifier token ?
<int> ::= ? A constant token ?
列表 5-5:包含变量、赋值表达式和表达式语句的语法
列表 5-5 引入了一些新的 EBNF 符号表示法。将符号序列用大括号括起来表示它可以重复零次或多次,因此{
parse_function_definition(tokens):
// parse everything up through the open brace as before...
`--snip--`
function_body = []
while peek(tokens) != "}":
next_block_item = parse_block_item(tokens)
function_body.append(next_block_item)
take_token(tokens)
return Function(name, function_body)
列表 5-6:解析块项列表
你需要继续解析块项,直到遇到一个闭括号,这表示函数体的结束。然后,你可以从输入流中移除该括号并完成函数定义的处理。
就像大括号在 EBNF 表示法中表示重复一样,将一系列符号用方括号括起来表示它是可选的。我们通过表达式 ["="
在解析
列表 5-5 还包括了一个新的产生式规则,针对
改进的优先级爬升算法
使用我们当前的优先级爬升代码解析赋值表达式时有一个问题:= 运算符是右结合的,但我们的代码只能处理左结合运算符。为了提醒自己为什么如此,咱们再看一遍优先级爬升的伪代码。我们在列表 3-7 中看到了这个算法的完整版本;它在这里被复现为列表 5-7。
parse_exp(tokens, min_prec):
left = parse_factor(tokens)
next_token = peek(tokens)
while next_token is a binary operator and precedence(next_token) >= min_prec:
operator = parse_binop(tokens)
right = parse_exp(tokens, ❶ precedence(next_token) + 1)
left = Binary(operator, left, right)
next_token = peek(tokens)
return left
列表 5-7:使用优先级爬升解析左结合运算符
当我们对parse_exp进行递归调用时,我们将最小优先级设置为高于当前运算符的优先级❶。因此,如果next_token是+,并且tokens是b + 4,那么递归调用parse_exp将只返回b,因为+不满足最小优先级要求。这就是我们得到像(left + b) + 4这样的左结合表达式的方式。
然而,如果next_token是右结合的,我们不应在递归调用parse_exp时遇到相同的符号时停止,而是应将其包含在右侧表达式中。为了做到这一点,我们需要将右侧的最小优先级设置为等于当前符号的优先级。换句话说,当处理像=这样的右结合符号时,递归调用应该是:
right = parse_exp(tokens, precedence(next_token))
假设你正在解析a = b = c。你将首先将左侧的部分解析为因子a,然后递归调用parse_exp来处理b = c。如果这次递归调用中的最小优先级是precedence("=") + 1,它将只解析下一个因子b。但如果最小优先级是precedence("="),它将解析整个赋值表达式,将b = c作为右侧表达式。最终结果将是a = (b = c),这正是我们想要的。
解析变量赋值和其他二元表达式之间唯一的区别是,我们需要构造一个Assignment AST 节点,而不是Binary节点。清单 5-8 提供了更新后的带有这些调整的优先级爬升伪代码。
parse_exp(tokens, min_prec):
left = parse_factor(tokens)
next_token = peek(tokens)
while next_token is a binary operator and precedence(next_token) >= min_prec:
**if next_token is "=":**
**take_token(tokens) // remove "=" from list of tokens**
**right = parse_exp(tokens, precedence(next_token))**
**left = Assignment(left, right)**
**else:**
operator = parse_binop(tokens)
right = parse_exp(tokens, precedence(next_token) + 1)
left = Binary(operator, left, right)
next_token = peek(tokens)
return left
清单 5-8:扩展的优先级爬升算法
最后,我们需要将=添加到我们的优先级表中。表 5-1 列出了我为所有二元运算符使用的优先级值,新运算符已加粗显示。它的优先级低于我们迄今为止实现的任何其他运算符。
表 5-1: 二元运算符的优先级值
| 运算符 | 优先级 |
|---|---|
| * | 50 |
| / | 50 |
| % | 50 |
| + | 45 |
| - | 45 |
| < | 35 |
| <= | 35 |
| > | 35 |
| >= | 35 |
| == | 30 |
| != | 30 |
| && | 10 |
| || | 5 |
| = | 1 |
到目前为止,你已经知道如何为本章中遇到的每个程序构建一个有效的 AST;你现在可以更新解析器并进行测试了。
语义分析
到目前为止,我们唯一需要关注的错误是语法错误。如果我们能够解析一个程序,我们就知道剩下的编译过程会成功。现在,程序可能在语法上是正确的,但却是语义上无效的;换句话说,它可能根本没有意义。例如,一个程序可能试图将一个值赋给一个不可赋值的表达式:
2 = a * 3; // ERROR: can't assign a value to a constant
或者它可能在同一作用域内声明同一个变量两次:
int a = 3;
int a; // ERROR: a has already been declared!
或者它可能尝试在变量声明之前使用该变量:
int main(void) {
a = 4; // ERROR: a has not been declared yet!
return a;
}
所有这些示例都使用了有效的语法,但如果你尝试编译它们,应该会得到错误。语义分析阶段会检测到这种错误。这个阶段最终将包括多个不同的 pass,用来验证程序的不同方面。在本章中,我们将加入第一个语义分析 pass,变量解析。
变量解析阶段会跟踪程序中所有作用域内的变量,并通过查找相应的声明来解析每个变量的引用。如果程序声明了同一个变量多次,或者使用了未声明的变量,它会报告错误。它还会为每个局部变量重命名,使用一个全局唯一的标识符。例如,它可能会将程序
int main(void) {
int a = 4;
int b = a + 1;
a = b - 5;
return a + b;
}
转换为如下内容:
int main(void) {
int a0 = 4;
int b1 = a0 + 1;
a0 = b1 - 5;
return a0 + b1;
}
(当然,这个阶段实际上是转换 AST,而不是源文件,但我使用 C 源代码来呈现这些示例,以使其更易读。)
这个转换可能看起来帮助不大——变量名a和b已经是唯一的——但是一旦我们引入多个变量作用域,它就变得至关重要,因为不同作用域中的不同变量可以有相同的名称。例如,我们可能会将程序转换为
int main(void) {
int a = 2;
if (a < 5) {
int a = 7;
return a;
}
return a;
}
转换为:
int main(void) {
int a0 = 2;
if (a0 < 5) {
int a1 = 7;
return a1;
}
return a0;
}
这清楚地表明a0和a1是两个不同的变量,这将简化后续的编译器阶段。
变量解析
现在我们将编写变量解析阶段。在这个阶段,我们将构建一个映射,将用户定义的变量名映射到我们在后续阶段使用的唯一名称。我们将按顺序处理块项,检查错误,并在过程中替换变量名。当遇到变量声明时,我们将添加一个新的条目,将该变量名映射到我们生成的唯一名称。然后,当我们遇到使用变量的表达式时,我们将用映射中的对应唯一名称替换变量名。清单 5-9 中的伪代码演示了如何解析变量声明。
resolve_declaration(Declaration(name, init), variable_map):
❶ if name is in variable_map:
fail("Duplicate variable declaration!")
unique_name = make_temporary()
❷ variable_map.add(name, unique_name)
❸ if init is not null:
init = resolve_exp(init, variable_map)
❹ return Declaration(unique_name, init)
清单 5-9:解析变量声明
首先,我们检查声明的变量是否已经存在于变量映射中❶。如果存在,意味着它在函数中已经声明过,这是一个重复声明。在这种情况下,我们抛出一个错误。接下来,我们将用户定义的变量名与一个唯一的自动生成名称关联,在变量映射中进行更新❷。
更新变量映射后,我们将处理声明初始化器(如果有的话)❸。对resolve_exp的调用会返回一个新的初始化器副本,任何变量都会被重命名,如果初始化器使用了未声明的变量,则抛出错误。最后,我们返回一个副本的Declaration节点❹,该节点使用新生成的名称替代旧的用户定义名称,并包含从resolve_exp获取的新初始化器。
你在resolve_declaration中生成的标识符不能与临时 TACKY 变量的名称冲突。如果你使用全局计数器来生成唯一标识符,请在语义分析和 TACKY 生成阶段都使用相同的计数器。
这些标识符也不能与函数名或全局变量名冲突。(在第十章中,你将看到全局变量保留其原始名称,像函数一样,而不是像局部变量那样被重命名。)你可以依赖通常的技巧,生成那些在 C 语言中语法上无效的标识符。我建议在自动生成的名称中包含变量的原始名称,以帮助调试;例如,你可以将 a 重命名为 a.0,将 b 重命名为 b.1。
注意
你可能已经注意到,上一节中的示例使用了自动生成的标识符,这些标识符在 C 语言中是语法上有效的,例如 a0 和 b1,因为这些示例是作为 C 源代码展示的。那些示例中的命名方案在实际中无法使用,因为重命名后的变量可能会与函数名和彼此冲突。例如,两个名为 a 和 a1 的局部变量都可能被重命名为 a12。
要解析 return 语句或表达式语句,我们只需处理其中的内层表达式,正如 清单 5-10 所示。
resolve_statement(statement, variable_map):
match statement with
| Return(e) -> return Return(resolve_exp(e, variable_map))
| Expression(e) -> return Expression(resolve_exp(e, variable_map))
| Null -> return Null
清单 5-10:解析一个语句
当我们解析一个表达式时,我们会检查表达式中的所有变量使用和赋值是否有效。清单 5-11 展示了如何做到这一点。
resolve_exp(e, variable_map):
match e with
| Assignment(left, right) ->
if left is not a Var node:
fail("Invalid lvalue!")
return Assignment( ❶ resolve_exp(left, variable_map), ❷ resolve_exp(right, variable_map))
| Var(v) ->
if v is in variable_map:
return Var( ❸ variable_map.get(v))
else:
fail("Undeclared variable!")
| `--snip--`
清单 5-11:解析一个表达式
当我们遇到一个 赋值 表达式时,我们检查左侧是否是有效的左值;目前,这意味着它必须是一个 Var。然后我们递归地解析左 ❶ 和右 ❷ 子表达式。当我们遇到一个 Var 时,我们用来自变量映射表的唯一标识符替换变量名 ❸。如果它不在变量映射表中,那就意味着它还没有声明,所以我们会抛出一个错误。由于我们使用 resolve_exp 递归地处理赋值的两侧,Var 这个案例也处理了赋值表达式左侧的变量。
为了处理其他类型的表达式,我们递归地处理任何子表达式,使用 resolve_exp。最终,变量解析阶段应返回一个完整的 AST,使用自动生成的变量名而不是用户定义的变量名。
--validate 选项
要测试新的编译器传递,你需要在编译器驱动程序中添加一个--validate命令行选项。该选项应使编译器运行到语义分析阶段,并在 TACKY 生成之前停止。在后面的章节中,更新语义分析阶段以包含多个传递后,该选项应指示编译器运行所有这些传递。
像现有的编译器选项一样,这个新选项不应生成任何输出文件。像往常一样,如果编译成功,它应返回 0 退出代码,如果失败,则返回非零退出代码。
TACKY 生成
在本章中,我们无需修改 TACKY IR。我们已经可以使用 TACKY Var构造函数来引用变量,并使用Copy指令为其赋值。TACKY IR 不包含变量声明,因为它不需要它们。我们已经在语义分析阶段从变量声明中获得了所需的所有信息,现在可以丢弃它们。
尽管 TACKY 本身无需更改,但 TACKY 生成传递需要更改:我们需要扩展此传递以处理 AST 中的最新添加内容。首先,我们将处理本章中添加的两种新表达式。接下来,我们将处理 AST 中的其他新增内容,包括表达式语句和声明。
变量和赋值表达式
我们将把 AST 中的每个Var转换为 TACKY 中的Var,保持相同的标识符。由于我们是自动生成标识符的,因此可以保证它不会与 TACKY 程序中的其他标识符冲突。为了处理Assignment AST 节点,我们将发出指令以评估右侧表达式,然后发出Copy指令,将结果复制到左侧。列表 5-12 展示了如何处理这两种表达式。
emit_tacky(e, instructions):
match e with
| `--snip--`
| Var(v) -> return Var(v)
| Assignment(Var(v), rhs) ->
result = emit_tacky(rhs, instructions)
instructions.append(Copy(result, Var(v)))
return Var(v)
列表 5-12:将变量和赋值表达式转换为 TACKY
这是一种低效的变量赋值处理方式;我们经常会先计算右边的表达式,将结果存储到一个临时变量中,然后再将其复制到变量v,而不是直接将结果存储到v中,从而避免使用临时变量。我们在第三部分中实现的优化将会去除这些多余的复制操作。
声明、语句和函数体
现在我们来处理声明。如前所述,在这个阶段我们可以丢弃变量声明;在 TACKY 中,你不需要在使用变量之前进行声明。但是我们确实需要发出 TACKY 指令来初始化变量。如果声明包含初始值,我们将像普通变量赋值一样处理它。如果声明没有初始值,我们将不会发出任何 TACKY 指令。
我们还需要处理表达式语句和空语句。为了将表达式语句转换为 TACKY,我们只需处理内部表达式。这将返回一个新的临时变量,保存表达式的结果,但我们在生成 TACKY 指令时不会再次使用该变量。对于空语句,我们不会发出任何 TACKY 指令。
最后,我们将处理一个函数包含多个块项而非单个语句的情况。我们将按顺序处理函数体中的块项,并为每个块项发出 TACKY 指令。假设我们正在编译 C 函数列表 5-13。
int main(void) {
int b;
int a = 10 + 1;
b = a * 2;
return b;
}
列表 5-13:包含变量声明和赋值表达式的 C 函数
假设我们在变量解析过程中将a和b重命名为a.1和b.0,并且我们对所有临时变量使用命名方案tmp.n,其中n是全局计数器的值。那么,我们将为函数体生成列表 5-14 中所示的 TACKY 指令。(这个列表与前一章中的列表 4-6 类似,使用了dst = src这样的符号表示Copy指令,而不是Copy(src, dst)。同样,使用如dst = src1 + src2这样的符号表示Binary指令,而不是Binary(Add, src1, src2, dst)。)
tmp.2 = 10 + 1
a.1 = tmp.2
tmp.3 = a.1 * 2
b.0 = tmp.3
Return(b.0)
列表 5-14: 在 TACKY 中实现列表 5-13
我们不会为列表 5-13 中b的声明生成任何 TACKY,因为它没有包含初始化器。我们将把a的声明转换为列表 5-14 中的前两条指令,这些指令计算10 + 1并将结果复制到a。我们将把表达式语句b = a * 2;转换为接下来的两条指令,并将return语句转换为最终的Return指令。
到目前为止,你已经知道如何将整个 AST 转换为 TACKY。但是我们还没有完全完成;我们还有一个最后的边缘案例需要考虑。
没有返回语句的函数
由于我们的抽象语法树(AST)现在支持多种语句,我们可能会遇到没有return语句的函数,例如列表 5-15。
int main(void) {
int a = 4;
a = 0;
}
列表 5-15:没有返回语句的 main 函数
如果你调用这个函数,会发生什么?C 标准给出了对于main函数的一个答案,而对于其他任何函数则给出了不同的答案。(我忽略了返回类型为void的函数,因为它们没有返回值,我们还没有实现它们。)第 5.1.2.2.3 节指出,“到达结束main函数的那一行会返回值 0”,因此列表 5-15 中的代码等同于列表 5-16 中的代码。
int main(void) {
int a = 4;
a = 0;
**return 0;**
}
列表 5-16:返回 0 的 main 函数
对于其他函数,情况更加复杂。根据第 6.9.1 节第 12 段,“除非另有说明,如果结束一个函数的语句被执行,且调用者使用了该函数的返回值,则行为未定义。”这隐含地涵盖了两种可能的情况。在第一种情况中,如列表 5-17 所示,调用者尝试使用函数的返回值。
#include <stdio.h>
int foo(void) {
printf("I'm living on the edge, baby!");
// no return statement
}
int main(void) {
return foo(); // try to use return value from foo
}
列表 5-17:尝试使用一个函数的返回值,而该函数并未返回任何东西
这将导致未定义的行为,意味着一切都无法预测;标准并没有对将会发生什么做出任何保证。在第二种情况中,如列表 5-18 所示,我们调用函数但不使用其返回值。
#include <stdio.h>
int foo(void) {
printf("I'm living on the edge, baby!");
// no return statement
}
int main(void) {
foo();
return 0;
}
列表 5-18:调用一个函数但不使用其返回值
这个程序没有未定义行为;它保证会打印<сamp class="SANS_TheSansMonoCd_W5Regular_11">I'm living on the edge, baby!,然后以状态码 0 退出。当我们编译像<сamp class="SANS_TheSansMonoCd_W5Regular_11">foo这样的函数时,我们并不知道它的调用者是否使用了它的返回值,因此我们必须假设它是像清单 5-18 这样的程序的一部分。特别是,我们需要在<сamp class="SANS_TheSansMonoCd_W5Regular_11">foo的末尾恢复调用者的栈帧,并将控制权返回给调用者。因为我们没有返回任何特定的值,所以我们可以随意设置 EAX 寄存器,或者根本不设置它。
处理这两种情况的最简单方法是,在每个函数体的末尾添加一个额外的 TACKY 指令,Return(Constant(0))。这样可以为<сamp class="SANS_TheSansMonoCd_W5Regular_11">main和类似清单 5-18 的程序提供正确的行为。如果函数已经以<сamp class="SANS_TheSansMonoCd_W5Regular_11">return语句结束,这条额外的指令将永远不会执行,因此不会改变程序的行为。在第三部分中,您将学习如何在不需要时消除这条额外的<сamp class="SANS_TheSansMonoCd_W5Regular_11">Return指令。
一旦您扩展了 TACKY 生成阶段,您就可以开始测试整个编译器了!因为我们没有改变 TACKY IR,所以也不需要更改汇编生成或代码生成阶段。
额外奖励:复合赋值、增量和减量
现在您的编译器已经支持了简单的赋值运算符<сamp class="SANS_TheSansMonoCd_W5Regular_11">=,您可以选择实现多个复合赋值运算符:<сamp class="SANS_TheSansMonoCd_W5Regular_11">+=,<сamp class="SANS_TheSansMonoCd_W5Regular_11">-=,<сamp class="SANS_TheSansMonoCd_W5Regular_11">*=,<сamp class="SANS_TheSansMonoCd_W5Regular_11">/=,以及<сamp class="SANS_TheSansMonoCd_W5Regular_11">%=。如果您在第三章中添加了位运算符,您也应该在这里添加相应的复合赋值运算符:<сamp class="SANS_TheSansMonoCd_W5Regular_11">&=,<сamp class="SANS_TheSansMonoCd_W5Regular_11">|=,<сamp class="SANS_TheSansMonoCd_W5Regular_11">^=,<сamp class="SANS_TheSansMonoCd_W5Regular_11"><<=,以及<сamp class="SANS_TheSansMonoCd_W5Regular_11">>>=。
你还可以添加增量和减量运算符,++ 和 --。每个运算符有两种不同的使用方式:作为前缀运算符,例如表达式 ++a,或作为后缀运算符,例如表达式 a++。当你将 ++ 或 -- 作为前缀运算符使用时,它会递增或递减操作数,并评估为其新值。后缀运算符 ++ 或 -- 也会递增或递减操作数,但评估为操作数的原始值。和本章中的其他语言结构一样,你可以在不改变编译器任何部分的情况下,在 TACKY 生成后实现复合赋值、增量和减量运算符。
要包含增量和减量运算符的测试用例,在运行测试套件时使用 --increment 标志。要包含复合赋值的测试用例,使用 --compound 标志。只有在你同时使用 --compound 和 --bitwise 标志时,测试脚本才会运行位运算复合赋值运算符(例如 |=)的测试用例。
你可以使用 --extra-credit 标志一次性测试所有额外的加分功能。命令
$ **./test_compiler** `**/path/to/your_compiler**` **--chapter 5 --extra-credit**
等同于:
$ **./test_compiler** `**/path/to/your_compiler**` **--chapter 5 --bitwise --compound --increment**
当我们在后面的章节中引入更多额外的加分功能时,--extra-credit 标志也会涵盖这些功能。
总结
本章在几个方面都是一个里程碑:你添加了一种新的语句类型,并实现了第一个有副作用的语言结构。你还实现了语义分析阶段,用于捕获你编译的程序中的新类型的错误。在后续章节中,你将继续扩展语义分析阶段,以检测更多的错误,并收集后续编译所需的附加信息。接下来,你将实现第一个控制流结构:if 语句和条件表达式。

描述
第六章:6 IF 语句和条件表达式

在上一章中,你学习了如何编译执行一系列语句的程序。但是大多数 C 程序的执行路径更为复杂;它们通常需要根据程序的当前状态,在运行时决定执行哪些语句。程序执行语句的顺序就是它的控制流,允许你更改程序控制流的语言结构称为控制结构。
在本章中,你将实现第一个控制结构:if语句。你还将实现条件表达式。和if语句一样,条件表达式也让你控制运行哪些代码。例如,条件表达式(a == 0) ? 3 : 4在 a 等于0时评估为 3,否则评估为 4。在实现短路运算符&&和||时,我们已经为if语句和条件表达式奠定了很多基础,具体见第四章。我们已经有了 TACKY 结构,允许我们有条件地运行或跳过代码,因此在 TACKY 生成后,我们无需更改任何阶段。让我们开始吧!
词法分析器
本章将介绍四个令牌:
| if | 一个关键字,表示if语句的开始 |
|---|---|
| else | 一个关键字,表示else子句在if语句中的开始 |
| ? | 问号,条件表达式中第一个和第二个操作数之间的分隔符 |
| : | 冒号,条件表达式中第二个和第三个操作数之间的分隔符 |
一旦你的词法分析器支持这四个令牌,你就可以进行测试了。
解析器
现在我们将更新解析器以支持 if 语句和条件表达式。由于这两者是不同的语言结构,我们将逐一处理,首先从 if 语句开始。
解析 if 语句
我们将通过扩展 statement AST 节点来支持 if 语句。清单 6-1 给出了这个节点的更新定义。
statement = Return(exp)
| Expression(exp)
**| If(exp condition, statement then, statement? else)**
| Null
清单 6-1: 定义 语句 AST 节点,包括 if 语句
新的 If 构造函数接受三个参数。condition 表达式,有时称为 控制表达式,决定语句体是否被执行。then 语句是 if 语句的第一个子句,当 condition 的结果非零时执行。第二个子句,else 语句是可选的。如果它存在,当 condition 的结果为 0 时执行。
正如我在前一章中提到的,if 语句中的每个子句本身就是一个独立的语句。尽管它看起来像是多个语句,但像清单 6-2 中的 if 语句体实际上是一个复合语句。
if (a == 3) {
a = a + 1;
int b = a * 4;
return a && b;
}
清单 6-2: 一个 其语句体为复合语句的 if 语句
我们还没有实现复合语句,因此此时我们无法编译像 清单 6-2 中的代码。清单 6-3 给出了我们可以编译的 if 语句示例。
if (a == 3)
return a;
else
b = 8;
清单 6-3: 一个 不包含任何复合语句的 if 语句
我们也可以编译嵌套在其他 if 语句内的 if 语句,就像 列表 6-4 中那样。
if (a)
if (a > 10)
return a;
else
return 10 - a;
列表 6-4:一个 if 语句嵌套在另一个 if 语句内
注意,列表 6-1 中的 AST 定义没有 else if 构造,因为一个 if 语句最多只能有一个 else 子句。else if 子句实际上只是一个包含另一个 if 语句的 else 子句。以 列表 6-5 为例。
if (a > 100)
return 0;
else if (a > 50)
return 1;
else
return 2;
列表 6-5:一个 if 语句嵌套在一个 else 子句内
让我们以一种更能反映其解析方式的格式重新排版:
if (a > 100)
return 0;
else
if (a > 50)
return 1;
else
return 2;
列表 6-5 的 AST 将如下所示:
If(
condition=Binary(GreaterThan, Var("a"), Constant(100)),
then=Return(Constant(0)),
else=If(
condition=Binary(GreaterThan, Var("a"), Constant(50)),
then=Return(Constant(1)),
else=Return(Constant(2))
)
)
列表 6-6 显示了语法的变化,这些变化与抽象语法树(AST)的变化完全一致。
<statement> ::= "return" <exp> ";"
| <exp> ";"
**| "if" "(" <exp> ")" <statement> ["else" <statement>]**
| ";"
列表 6-6:语句的语法规则,包括 if 语句
我们可以使用简单的递归下降解析法来处理这个新的产生式规则。有趣的是,这个规则是模糊的,但这种模糊性不会对我们的解析器造成任何问题。让我们再看看 列表 6-4:
if (a)
if (a > 10)
return a;
else
return 10 - a;
有两种方法可以解析这个列表,两种方法都遵循我们新的语法规则:我们可以将 else 子句与第一个或第二个 if 语句组合在一起。换句话说,我们可以这样解析这个列表:
if (a) {
if (a > 10)
return a;
else
return 10 - a;
}
或者我们也可以这样解析:
if (a) {
if (a > 10)
return a;
}
else
return 10 - a;
C 标准明确规定,第一种选择是正确的;else子句应始终与最接近的if语句组合。然而,语法本身并未告诉我们应选择哪个选项。语法中的这个怪癖被称为悬挂的 else歧义,它可能会给自动将形式语法转换为解析代码的解析器生成器带来问题。
幸运的是,悬挂的 else 歧义对像我们这样的手写递归下降解析器来说并不是问题。每当我们解析一个if语句时,我们会在语句主体之后查找一个else关键字;如果找到,我们将继续解析else子句。在像 Listing 6-4 这样的情况下,这意味着我们会将else子句解析为内层if语句的一部分,这是正确的行为。
现在就实现这个产生规则吧;然后,我们将继续讨论条件表达式。
解析条件表达式
条件: ?操作符是一个三元操作符,接受三个操作数。在 Listing 6-7 中,我们将此操作符添加到exp AST 节点。
exp = Constant(int)
| Var(identifier)
| Unary(unary_operator, exp)
| Binary(binary_operator, exp, exp)
| Assignment(exp, exp)
**| Conditional(exp condition, exp, exp)**
Listing 6-7: exp AST 节点的定义,包括条件表达式
在 Listing 6-8 中,我们将其添加到<exp>语法规则中。
<exp> ::= <factor> | <exp> <binop> <exp> | **<exp> "?" <exp> ":" <exp>**
Listing 6-8: 表达式的语法规则,包括条件表达式
现在我们需要弄清楚它的优先级和结合性。对于三元表达式,优先级和结合性如何工作并不显而易见。诀窍是将其视为一个二元表达式,其中中间的操作符是 "?"
a = 1 ? 2 : 3
被解析为
a = (1 ? 2 : 3)
但
a || b ? 2 : 3
被解析为:
(a || b) ? 2 : 3
相同的逻辑适用于第三个操作数。我们解析为
1 ? 2 : 3 || 4
如
1 ? 2 : (3 || 4)
但我们解析为
1 ? 2 : a = 5
如:
(1 ? 2 : a) = 5
语义分析阶段会拒绝这个最后的表达式,因为 1 ? 2 : a 不是一个有效的左值。然而,任何表达式都可以出现在 ? 和 : 符号之间,甚至是赋值表达式。这些符号像括号一样,限定了表达式的开始和结束位置。所以,条件表达式
x ? x = 1 : 2
等同于:
x ? (x = 1) : 2
当你将一个条件表达式嵌套在另一个条件表达式内时,逻辑同样适用,这意味着
a ? b ? 1 : 2 : 3
被解析为:
a ? (b ? 1 : 2) : 3
接下来,我们来看一下结合性。条件操作符是右结合的,所以
a ? 1 : b ? 2 : 3
被解析为:
a ? 1 : (b ? 2 : 3)
由于条件表达式可以像奇怪的二元表达式一样被解析,我们几乎可以用现有的优先级爬升代码处理它们。首先,我们将 ? 添加到我们的优先级表中;表 6-1 列出了所有的优先级值。
表 6-1: 二元和三元操作符的优先级值
| 操作符 | 优先级 |
|---|---|
| * | 50 |
| / | 50 |
| % | 50 |
| + | 45 |
| - | 45 |
| < | 35 |
| <= | 35 |
| > | 35 |
| >= | 35 |
| == | 30 |
| != | 30 |
| && | 10 |
| || | 5 |
| ? | 3 |
| = | 1 |
在优先级爬升中,我们只考虑 ?
接下来,我们将再次更新我们的优先级爬升代码。在上一章中,我们将赋值作为特殊情况处理,以便我们可以使用 Assignment AST 节点。现在,我们也将条件表达式视为特殊情况处理。列表 6-9 显示了更新后的优先级爬升伪代码。与此算法的上一个版本,列表 5-8,相比,已经用粗体标记出来。
parse_exp(tokens, min_prec):
left = parse_factor(tokens)
next_token = peek(tokens)
while next_token is a binary operator and precedence(next_token) >= min_prec:
if next_token is "=":
take_token(tokens) // remove "=" from list of tokens
right = parse_exp(tokens, precedence(next_token))
left = Assignment(left, right)
**else if next_token is "?":**
**middle = parse_conditional_middle(tokens)**
**right = parse_exp(tokens, precedence(next_token))**
**left = Conditional(left, middle, right)**
else:
operator = parse_binop(tokens)
right = parse_exp(tokens, precedence(next_token) + 1)
left = Binary(operator, left, right)
next_token = peek(tokens)
return left
列表 6-9:支持条件表达式的优先级爬升
函数 parse_conditional_middle,这里没有包含代码,应该只消耗 ? 标记,然后解析表达式(将最小优先级重置为 0),然后消耗 : 标记。接下来,我们像解析其他表达式的右侧一样解析第三个操作数:通过递归调用 parse_exp。由于条件运算符是右结合的,就像赋值一样,我们在递归调用中将最小优先级设置为 precedence(next_token),而不是 precedence(next_token) + 1。最后,我们从这三个操作数构造一个 Conditional AST 节点。
清单 6-10 给出了完整的 AST 定义,支持if语句和条件表达式的更改已加粗。这些更改与本节早些时候介绍的更改相同;我把它们整理在这里,便于参考。
program = Program(function_definition)
function_definition = Function(identifier name, block_item* body)
block_item = S(statement) | D(declaration)
declaration = Declaration(identifier name, exp? init)
statement = Return(exp)
| Expression(exp)
**| If(exp condition, statement then, statement? else)**
| Null
exp = Constant(int)
| Var(identifier)
| Unary(unary_operator, exp)
| Binary(binary_operator, exp, exp)
| Assignment(exp, exp)
**| Conditional(exp condition, exp, exp)**
unary_operator = Complement | Negate | Not
binary_operator = Add | Subtract | Multiply | Divide | Remainder | And | Or
| Equal | NotEqual | LessThan |LessOrEqual
| GreaterThan | GreaterOrEqual
清单 6-10:包含条件表达式和if语句的抽象语法树
清单 6-11 展示了相应的语法更改。
<program> ::= <function>
<function> ::= "int" <identifier> "(" "void" ")" "{" {<block-item>} "}"
<block-item> ::= <statement> | <declaration>
<declaration> ::= "int" <identifier> ["=" <exp>] ";"
<statement> ::= "return" <exp> ";"
| <exp> ";"
**| "if" "(" <exp> ")" <statement> ["else" <statement>]**
| ";"
<exp> ::= <factor> | <exp> <binop> <exp> **| <exp> "?" <exp> ":" <exp>**
<factor> ::= <int> | <identifier> | <unop> <factor> | "(" <exp> ")"
<unop> ::= "-" | "~" | "!"
<binop> ::= "-" | "+" | "*" | "/" | "%" | "&&" | "||"
| "==" | "!=" | "<" | "<=" | ">" | ">=" | "="
<identifier> ::= ? An identifier token ?
<int> ::= ? A constant token ?
清单 6-11:包含条件表达式和if语句的语法
一旦你实现了这些更改,你就可以开始测试你的解析器了。
变量解析
这次修改较小。你将扩展resolve_statement和resolve_exp,以处理我们在本章中添加的新结构,遍历它们的子语句和子表达式。这将以与其他结构中的变量相同的方式更新if语句和条件表达式中的变量名。
TACKY 生成
我们可以使用现有的 TACKY 指令实现if语句和条件表达式。我们将在这里使用与第四章中短路运算符&&和||相同的基本方法:首先我们会评估控制表达式,然后使用条件跳转去到语句或表达式的相应分支。让我们先实现if语句。
将 if 语句转换为 TACKY
形式为if (
`<instructions for condition>`
c = `<result of condition>`
JumpIfZero(c, end)
`<instructions for statement>`
Label(end)
清单 6-12:用于if语句的 TACKY
就是这样!首先,我们评估控制表达式,
`<instructions for condition>`
c = `<result of condition>`
JumpIfZero(c, else_label)
`<instructions for statement1>`
Jump(end)
Label(else_label)
`<instructions for statement2>`
Label(end)
清单 6-13:带有 if 语句和 else 子句的 TACKY
就像在 清单 6-12 中一样,我们先评估控制表达式,然后在结果为 0 时执行条件跳转。但与跳到 if 语句的结尾不同,在这种情况下,我们跳到 else_label,然后执行
将条件表达式转换为 TACKY
对于条件表达式,像短路表达式一样,C 标准提供了关于哪些子表达式会被执行以及执行时机的保证。要评估表达式
`<instructions for condition>`
c = `<result of condition>`
JumpIfZero(c, e2_label)
`<instructions to calculate e1>`
v1 = `<result of e1>`
result = v1
Jump(end)
Label(e2_label)
`<instructions to calculate e2>`
v2 = `<result of e2>`
result = v2
Label(end)
列表 6-14:用于条件表达式的 TACKY
这看起来几乎与 列表 6-13 中的 TACKY 完全相同。唯一的区别是我们在每个子句结束时将结果复制到临时 result 变量中。
和往常一样,在处理 if 语句和条件表达式时,你生成的所有标签和临时变量名应该是唯一的。一旦你的 TACKY 生成阶段工作正常,你就可以编译本章的测试用例。
额外积分:带标签的语句和 goto
现在你已经有了一些添加新类型语句的经验,你可以选择实现goto,这是一个大家又爱又恨的语句。你还需要添加对标签语句的支持,以便goto能跳转到某个地方。你可以在 TACKY 生成阶段之后实现这两种语句,而无需更改任何内容;但是,你需要检测一些新的错误情况,比如在同一个函数中为两个标签语句使用相同的标签。我建议编写一个新的语义分析过程来捕捉这些错误,而不是在变量解析阶段尝试捕捉它们。
要测试这个功能,请运行测试脚本并使用--goto标志:
$ **./test_compiler** `**/path/to/your_compiler**` **--chapter 6 --goto**
如果你在前面的章节中也实现了额外加分功能,你可以通过使用--extra-credit标志一次性测试所有功能。
总结
你刚刚实现了第一个控制结构!你在早期章节中所做的所有工作开始发挥作用了。你为了支持&&和||所添加的基本 TACKY 指令让你可以轻松实现本章节中的更复杂功能。你还建立了在之前学习的解析技术的基础上,扩展了优先级提升代码来处理三元运算符。但是,你能编译的if语句仍然非常有限;你不能在if语句的主体中声明变量或执行更长的代码块。在下一章中,你将通过添加对复合语句的支持来消除这些限制。最令人兴奋的变化将在语义分析阶段,你将学习如何处理嵌套作用域。

描述
第七章:7 复合语句

在本章中,你将实现复合语句。复合语句有两个重要作用。正如你在前两章中看到的,它们将其他语句和声明组合成一个单一单元,可以出现在更大的结构中,比如< samp class="SANS_TheSansMonoCd_W5Regular_11">if语句中。更有趣的是,它们还划定了函数内不同的作用域。变量的作用域是该变量可以使用的程序部分;当你在复合语句中声明一个变量时,它的作用域仅延伸到该语句的末尾。
在本章中,我们将花一些时间扩展解析器,以便将块项组合在一起,但我们的主要任务将是扩展变量解析阶段,以跟踪每个变量的作用域。我们几乎不需要修改 TACKY 生成阶段,且不会接触词法分析器或汇编生成阶段。在开始解析器之前,我将简要概述 C 语言中作用域的工作原理,并定义一些我将在本章后续中使用的术语。
作用域的概述
可以包含声明并确定这些声明作用域的语言结构被称为块。复合语句和函数体都是块。循环也是块,我们将在第八章中实现。(从技术上讲,if语句也是,但这对我们的实现来说并不重要。)局部变量的作用域从该变量声明的地方开始。这意味着变量的作用域可以从块的中间开始。它的作用域一直延伸到声明它的块的结尾。例如,在程序中
int main(void) {
int a ❶ = 5;
return a;
❷}
变量a的作用域从其初始化器之前的❶开始,并一直延伸到函数的最后❷。
复合语句可以单独出现,也可以出现在另一个语句内部。在示例 7-1 中,我们将复合语句作为< samp class="SANS_TheSansMonoCd_W5Regular_11">if语句的主体。
int main(void) {
if (1) {
int a ❶ = 2;
return a + 1;
❷}
return 0;
}
示例 7-1:将复合语句作为 if 语句体使用
在这个例子中,变量a的作用域从❶开始,直到复合语句的结尾❷。
当你进入一个新的块时,你仍然可以使用来自外部作用域的变量,如以下代码片段所示:
int a = 2;
{
int b = a + 2;
}
尽管 a 是在外部作用域中声明的,但当我们在内部作用域初始化 b 时,我们仍然可以引用它。因此,我们将 b 初始化为 4。但让我们看看在列表 7-2 中发生了什么,在那里我们在内部块内声明了另一个名为 a 的变量。
❶ int a = 2;
{
❷ int a = 3;
int b = a + 2;
}
列表 7-2:声明两个同名但作用域不同的变量
这次,当我们初始化 b 时,作用域中有两个名为 a 的不同变量:一个是在外部作用域中声明的 ❶,另一个是在内部作用域中声明的 ❷。在这种情况下,我们总是使用在最内层作用域中声明的变量。因此,我们将 b 初始化为 5。尽管外部的 a 仍然在作用域内,但我们无法访问它;它被内部的变量所隐藏(或遮蔽)。
被隐藏与超出作用域是不同的,因为被隐藏的变量可以在程序的后续部分再次变得可见。列表 7-3,它几乎与列表 7-2 相同,说明了这一区别。
❶ int a = 2;
{
❷ int a = 3;
int b = a + 2;
}
❸ return a;
列表 7-3:一个隐藏的变量再次变得可见
正如我们在前面的示例中所看到的,第一条声明的a ❶ 被第二条声明 ❷ 隐藏了。但是 return 语句 ❸ 位于复合语句的末尾。在那个时候,第二个 a 已经超出了作用域,因此第一个 a 再次可见。因此,我们将在 return 语句中使用第一个 a,返回 2。
如果我们交换列表 7-2 中内部块内两个语句的顺序会怎样呢?那样我们就会得到:
int a = 2;
{
int b = a + 2;
int a = 3;
}
现在,当我们声明b时,内部的a还不在作用域内,因此它不会隐藏外部的a。表达式a + 2将引用第一个声明的a,因此我们会将b初始化为4。
你可以拥有许多层嵌套作用域,正如列表 7-4 所示。
int main(void) {
❶ int x = 1;
{
❷ int x = 2;
if (x > 1) {
❸ x = 3;
❹ int x = 4;
}
❺ return x;
}
❻ return x;
}
列表 7-4:多个嵌套作用域
在这个列表中,我们声明了三个不同作用域的名为x的变量。我们在❶声明第一个x,在❷声明第二个。我们在❸将值3赋给第二个x并在❺返回它,因此整个程序返回3。第三个名为x的变量,在❹声明,但从未使用。我们从未到达❻处的最后一个return语句,但如果到了那里,它将返回1,即在❶处声明的第一个变量名为x的值。
我们需要处理与变量作用域相关的两个错误情况。(我们在第五章中简要讨论过这两个问题,但在具有多个作用域的程序中,检测这些错误要复杂一些。)首先,如果没有与该名称对应的变量在作用域内,就不能使用该变量名。列表 7-5 说明了这个错误。
int main(void) {
{
int x ❶ = 4;
❷}
return ❸ x;
int x ❹ = 3;
❺}
列表 7-5:使用未声明的变量
在这个列表中,我们声明了两个不同的变量,名为x。第一个声明的作用域从❶开始,到❷结束。第二个声明的作用域从❹开始,一直到函数的末尾❺。在❸处,这两个声明都不在作用域内。此时使用变量名x是错误的,因为该名称没有指向任何东西。
其次,不能在同一作用域内有多个相同变量名的声明。如果两个变量的作用域在同一位置结束,我们就说它们有相同的作用域;也就是说,它们在同一个代码块内声明。例如,以下代码片段是无效的:
int a = 3;
{
int b = a;
int b = 1;
}
b的第二次声明是非法的,因为它与第一次声明的作用域相同。
现在你理解了需要实现的作用域规则,让我们开始解析器的工作。
解析器
被括号包裹的语句和声明列表可以是函数体或复合语句。让我们定义一个block AST 节点来表示这两种结构:
block = Block(block_item*)
请注意,这个 AST 节点并不代表if语句,且一旦我们在第八章中实现它们时,也不会表示循环语句,尽管它们在技术上也是块。
接下来,我们将扩展statement节点,以表示复合语句:
statement = Return(exp)
| Expression(exp)
| If(exp condition, statement then, statement? else)
**| Compound(block)**
| Null
我们还将更改function_definition节点,使用block:
function_definition = Function(identifier name, **block** body)
第 7-6 列表给出了包含这些更改的新 AST 定义,已加粗显示。
program = Program(function_definition)
function_definition = Function(identifier name, **block** body)
block_item = S(statement) | D(declaration)
**block = Block(block_item*)**
declaration = Declaration(identifier name, exp? init)
statement = Return(exp)
| Expression(exp)
| If(exp condition, statement then, statement? else)
**| Compound(block)**
| Null
exp = Constant(int)
| Var(identifier)
| Unary(unary_operator, exp)
| Binary(binary_operator, exp, exp)
| Assignment(exp, exp)
| Conditional(exp condition, exp, exp)
unary_operator = Complement | Negate | Not
binary_operator = Add | Subtract | Multiply | Divide | Remainder | And | Or
| Equal | NotEqual | LessThan | LessOrEqual
| GreaterThan | GreaterOrEqual
列表 7-6:带有复合语句的抽象语法树
第 7-7 列表展示了语法的相应更改。
<program> ::= <function>
<function> ::= "int" <identifier> "(" "void" ")" **<block>**
**<block> ::= "{" {<block-item>} "}"**
<block-item> ::= <statement> | <declaration>
<declaration> ::= "int" <identifier> ["=" <exp>] ";"
<statement> ::= "return" <exp> ";"
| <exp> ";"
| "if" "(" <exp> ")" <statement> ["else" <statement>]
**| <block>**
| ";"
<exp> ::= <factor> | <exp> <binop> <exp> | <exp> "?" <exp> ":" <exp>
<factor> ::= <int> | <identifier> | <unop> <factor> | "(" <exp> ")"
<unop> ::= "-" | "~" | "!"
<binop> ::= "-" | "+" | "*" | "/" | "%" | "&&" | "||"
| "==" | "!=" | "<" | "<=" | ">" | ">=" | "="
<identifier> ::= ? An identifier token ?
<int> ::= ? A constant token ?
列表 7-7:带有复合语句的语法
请记住,在"{"和"}"的定义中,
变量解析
现在我们将更新变量解析过程,以遵循本章开始时讨论的作用域规则。在这个过程中,任何在原始程序中具有相同名称的局部变量将会被赋予不同的名称。在后续的过程中,我们完全不需要考虑作用域;因为每个变量都会有一个独特的名称,我们可以像之前的章节中那样,将每个变量转换成 TACKY 变量,接着是伪寄存器,最后转换成内存地址,而不必担心每个名称所指代的对象。
在多个作用域中解析变量
作为示例,让我们再来看一遍 列表 7-4 中的程序:
int main(void) {
int x = 1;
{
int x = 2;
if (x > 1) {
x = 3;
int x = 4;
}
return x;
}
return x;
}
列表 7-8 显示了变量解析后的程序样子。
int main(void) {
int x0 = 1;
{
int x1 = 2;
if (x1 > 1) {
x1 = 3;
int x2 = 4;
}
return x1;
}
return x0;
}
列表 7-8:在进行变量解析后,列表 7-4 中的程序
现在每个变量都有了不同的名称。这些新名称清晰地标明了我们在每个时刻使用的是哪个变量。例如,现在我们可以明确知道函数开始时声明的变量(我们已将其重命名为 x0)只在最后一次使用。
我们的变量解析基本方法与之前章节相同。我们将遍历 AST,在此过程中维护一个从用户定义的名称到生成的名称的映射。现在,我们新的作用域规则将决定我们如何更新这个映射。表 7-1 显示了在 列表 7-4 中每个时刻变量映射的样子。
表 7-1: 在 列表 7-4 中的变量映射
| int main(void) { | (空映射) |
|---|
|
int x = 1;
{
| x → x0 |
|---|
|
int x = 2;
if (x > 1) {
x = 3;
| x → x1 |
|---|
|
int x = 4;
}
| x → x2 |
|---|
|
return x;
}
| x → x1 |
|---|
|
return x;
}
| x → x0 |
|---|
变量映射的状态会在两种情况下发生变化。首先,当声明一个新变量时,我们将其添加到映射中,并覆盖任何具有相同名称的现有变量。其次,当我们退出一个代码块时,我们会恢复到进入该代码块之前的变量映射状态。
第一个情况已经很熟悉:每当我们遇到一个变量声明时,我们就会添加一个映射条目。为了处理第二种情况,每当进入一个新块时,我们会复制一份变量映射。在处理该块时,我们会向这份复制的映射中添加新的条目,而不会改变外部作用域的变量映射。
现在你已经对这个过程的基本原理有了了解,接下来我们一起看一下伪代码。
更新变量解析伪代码
首先,我们来处理声明。在前面的章节中,如果编译器遇到同一个变量名的两次声明,它会失败:
resolve_declaration(Declaration(name, init), variable_map):
if name is in variable_map:
fail("Duplicate variable declaration!")
`--snip--`
但现在情况有点复杂了。重复使用相同的变量名在多个声明中是合法的。然而,在同一块内声明同一个变量名是非法的。为了强制执行这一规则,我们会追踪变量映射中每个条目的两个事实:它的新自动生成名称以及它是否在当前块中声明。列表 7-9 给出了处理声明的更新伪代码。与之前版本的 列表 5-9 相比,变化部分已加粗。
resolve_declaration(Declaration(name, init), variable_map):
if name is in variable_map **and variable_map.get(name).from_current_block:**
fail("Duplicate variable declaration!")
unique_name = make_temporary()
variable_map.add(name, **MapEntry(new_name=unique_name, from_current_block=True)**)
if init is not null:
init = resolve_exp(init, variable_map)
return Declaration(unique_name, init)
列表 7-9: 解析变量声明
接下来,我们需要一个可以按顺序处理块项的函数(我将在后续的伪代码列表中称之为 resolve_block)。你已经编写了这段代码来处理函数体;现在你只需要重构它,使其也可以用来处理复合语句。记住,在处理一个块项(特别是声明)时所做的更改,必须在处理后续块项时可见。
我们还会更新 resolve_statement 来处理复合语句。列表 7-10 给出了更新后的 resolve_statement 伪代码,和之前版本的 列表 5-10 的变化部分已加粗。这里的重要细节是:当我们遍历复合语句时,我们会传递变量映射的一个副本,这样在复合语句内部处理的任何声明在外部作用域不可见。
resolve_statement(statement, variable_map):
match statement with
| Return(e) -> return Return(resolve_exp(e, variable_map))
| Expression(e) -> return Expression(resolve_exp(e, variable_map))
**| Compound(block) ->**
**new_variable_map = copy_variable_map(variable_map)**
**return Compound(resolve_block(block, new_variable_map))**
| `--snip--`
列表 7-10: 解析复合语句
最后,我们将实现 copy_variable_map。这应该创建一个变量映射的副本,并将 from_current_block 标志设置为 False,应用于每个条目。这样,在处理内部作用域中隐藏外部作用域声明的声明时,我们就不会抛出错误。
一旦你完成了这些更改,你的变量解析阶段将能够处理嵌套作用域!### TACKY 生成
最后一步是扩展 TACKY 生成阶段,以处理复合语句。这非常直接:要将复合语句转换为 TACKY,只需将其中的每个代码块项转换为 TACKY。基本上,你应该像处理函数体一样处理复合语句。你完全不需要接触后续的编译器阶段;一旦 TACKY 生成工作完成,你就完成了本章的内容!
总结
在本章中,你通过扩展编译器的几个阶段,实现了一种新的语句类型。你编写了一个更复杂的变量解析阶段,能够正确解析多个作用域中的变量,极大地扩展了你能够编译的程序集合。接下来,你将实现循环语句、break 语句和 continue 语句。本章的工作对于你添加对 for 循环的支持特别重要,因为一个 for 循环包含了两个不同的作用域。

描述
第八章:8 循环

在本章中,你将添加所有与循环相关的内容。这包括 for、while 和 do 循环,另外还有 break 和 continue 语句,用来跳过循环的某些部分。这些是本书中你将实现的最后几个语句。一旦你完成本章的内容,并且实现了所有额外的加分特性,你的编译器就能处理每一种 C 语句。
但你首先有工作要做!你将更新词法分析器和语法分析器,以支持所有五个新语句。然后,你将增加一个新的语义分析步骤,我们称之为循环标注。这个新步骤,如本章开头的图表中加粗的部分所示,将注释 AST,将每个 break 或 continue 语句与包含它的循环关联起来。最后,你将把每个新语句翻译成一系列 TACKY 指令。你可以使用已经定义的 TACKY 指令来实现所有新语句,因此在 TACKY 生成之后你不会再更改任何阶段。
本章中新引入的语句会带来一些边界情况和错误,我们需要处理这些情况。在我们开始讲解词法分析器之前,我们将简要讨论每个语句。
循环及如何跳出它们
让我们首先看看三种循环语句,然后考虑 break 和 continue 语句。Listing 8-1 展示了一个 while 循环的示例。
while ( ❶ a > 0)
a = a / 2;
Listing 8-1: 一个 while 循环
首先,我们评估语句的控制表达式 ❶。如果它是 0(即假),循环结束,我们进入下一个语句。如果它是非零的,我们执行 while 循环体,然后返回控制表达式,清空并重复执行。
一个 do 循环,像在 Listing 8-2 中的那个,几乎是完全相同的。
do
a = a + 1;
while (a < 100);
列表 8-2:一个 do 循环
唯一的区别是我们先执行循环体,然后检查控制表达式。这意味着循环体至少会执行一次。像if语句体一样,循环体是一个单一的语句,可以是包含声明的复合语句。你在循环体内声明的任何变量,在控制表达式中将无法访问。例如,列表 8-3 是无效的。
do {
int a = a + 1;
} while (a < 100);
列表 8-3:一个 do 循环,其中控制表达式使用了一个超出作用域的变量
当for循环出现时,事情开始变得更加复杂。它们有两种不同的形式。在第一种形式中,如列表 8-4 所示,循环头由三个表达式组成。
int a;
for ( ❶ a = 0; ❷ a < 5; ❸ a = a + 1)
b = b * 2;
列表 8-4:一个 for 循环,其中初始语句是一个表达式
初始表达式❶在第一次循环迭代之前评估一次。然后,在每次迭代时,我们:
-
评估控制表达式❷。如果它为假,循环终止。否则,我们…
-
执行语句体。
-
评估最终表达式❸。
你可以省略循环头中的任何或所有表达式。如果省略初始表达式或最终表达式,当该语句通常会被评估时,什么也不会发生。如果省略控制表达式,循环将表现得好像其控制表达式始终为真(即非零)。这意味着它永远不会终止,除非它包含一个可以跳出循环体的break、goto或return语句。
列表 8-5 展示了第二种类型的for循环,其中初始语句是一个声明,而不是表达式。
for (int a = 0; a < 5; a = a + 1)
b = b * 2;
列表 8-5:一个 for 循环,其中初始语句是一个声明
for循环头引入了一个新的作用域,因此你可以像列表 8-6 那样编写代码。
int a = 5;
for (int a = 0; a < 5; a = a + 1)
b = b + a;
清单 8-6:在 for 循环前和循环头部声明两个同名变量
在这个清单中,头部声明的变量a隐藏了上一行声明的变量a。由于复合语句总是引入一个新的作用域,包括当它作为循环体出现时,清单 8-7 也是有效的。
❶ int a = 5;
for ( ❷ int a = 0; a < 5; a = a + 1) {
❸ int a = 1;
b = b + a;
}
清单 8-7:在 for 循环前,循环头部和循环体中声明三个同名变量
在清单 8-7 中,有三个不同的变量名为a:一个在循环开始前声明 ❶,一个在循环头部声明 ❷,另一个在循环体内声明 ❸。
尽管在for循环头部的表达式是可选的,但循环体是必须的。(这对于do和while循环也是如此。)然而,循环体可以是一个空语句,就像在清单 8-8 中一样。
while ((a = a + 1) < 10)
;
清单 8-8:将空语句用作循环体
这里单独的;是一个空语句。尽管这个语句什么也不做,但我们需要包含它,以便解析器能够识别循环的结束位置。正如我们在第五章中实现它们时所看到的,空语句并不是一个特定于循环的构造;你可以在任何可以使用其他类型语句的地方使用它们。实际上,它们主要出现在循环体内,因为它们很少在其他地方有用。
现在让我们讨论一下break和continue语句。两者只能出现在循环内部。(实际上,这并不完全正确;break语句也可以出现在switch语句内部,你可以将它作为本章的附加功能来实现。)break语句,像清单 8-9 中的语句一样,跳转到循环结束后的位置。
while (1) {
a = a - 1;
if (a < 0)
break;
}
return a;
清单 8-9:一个 break 语句
当我们遇到这个 break 语句时,我们将跳转到 return 语句,位于 while 循环之后。
break 语句仅终止最内层的循环。例如,参考 列表 8-10 中的代码片段。
while (b > 0) {
do {
a = a - 1;
if (a < 0)
break;
} while (1);
b = b * a;
}
return b;
列表 8-10:使用 break 语句跳出两个嵌套循环中的内层循环
当我们到达这个列表中的 break 语句时,我们将跳出内层循环,但不会跳出外层循环,因此我们会跳转到 b = b * a;。在本章中,我将把包含 break 或 continue 语句的最内层循环称为它的 封闭循环。(如果称之为“最小封闭循环”会更符合 C 标准中的术语,但这有点冗长。)
continue 语句跳转到封闭循环体内最后一条语句之后的位置。参考 列表 8-11 中的例子。
while (a > 0) {
a = a * b;
if (b > 0)
continue;
b = b + 1;
return b;
❶}
列表 8-11:A continue 语句
当我们到达 continue 语句时,我们将跳过所有后续的语句,直接跳转到循环体的末尾 ❶。从那里,while 循环照常执行,意味着它将跳转回控制表达式。像 列表 8-12 中的那种 for 循环中的 continue 语句也起到相同的作用。
for (int i = 0; i < 5; ❶ i = i + 1) {
a = a * i;
if (b > 0)
continue;
b = b + 1;
❷}
列表 8-12:A continue 语句在 for 循环内部
在这个列表中,我们仍然从 continue 语句跳转到循环体的末尾 ❷。然后,我们按常规跳转到最终表达式 ❶。
如果在循环外出现了一个 break 或 continue 语句,就像在清单 8-13 中一样,编译应该失败。
int main(void) {
break;
}
清单 8-13:无效的 break 语句
然而,如果这些语句之一嵌套在循环内部深层次的地方,像清单 8-14 中的 break 语句那样,也是完全合法的。
while (1) {
if (a > 4) {
b = b * 2;
return a + b;
} else {
int c = a ? b : 5;
{
int d = c;
break;
}
}
return 0;
}
return 1;
清单 8-14:一个 break 语句出现在循环内多层嵌套的情况
这个 break 语句跳转到 return 1;,因为那是循环结束后的下一点。
在一个循环中有多个 break 和 continue 语句是合法的,就像在清单 8-15 中一样。
for (int i = 0; i < 10; i = i + 1) {
if (i % 2 == 0)
continue;
if (x > y)
continue;
break;
}
清单 8-15:多个 break 和 continue 语句在循环内部
现在我们已经涵盖了你需要了解的关于本章将要添加的语句的关键内容,我们可以开始实现它们了。第一步,像往常一样,是更新词法分析器(lexer)。
词法分析器
本章中你将添加五个关键字:
do
while
for
break
continue
你不需要其他新的标记(tokens)。
解析器
接下来,我们将更新抽象语法树(AST)。我们将添加五个新语句:
statement = Return(exp)
| Expression(exp)
| If(exp condition, statement then, statement? else)
| Compound(block)
**| Break**
**| Continue**
**| While(exp condition, statement body)**
**| DoWhile(statement body, exp condition)**
**| For(for_init init, exp? condition, exp? post, statement body)**
| Null
break 和 continue 语句是最简单的。while 和 do 语句也相对简单;它们都有一个主体和一个控制表达式。for 语句是最复杂的:它包括一个初始子句、一个可选的控制表达式、一个可选的最终表达式和一个主体。初始子句可以是声明、表达式或没有任何内容,因此我们需要一个新的 AST 节点来描述它:
for_init = InitDecl(declaration) | InitExp(exp?)
将所有内容整合在一起,我们得到了最新的 AST 定义,如 示例 8-16 所示。
program = Program(function_definition)
function_definition = Function(identifier name, block body)
block_item = S(statement) | D(declaration)
block = Block(block_item*)
declaration = Declaration(identifier name, exp? init)
**for_init = InitDecl(declaration) | InitExp(exp?)**
statement = Return(exp)
| Expression(exp)
| If(exp condition, statement then, statement? else)
| Compound(block)
**| Break**
**| Continue**
**| While(exp condition, statement body)**
**| DoWhile(statement body, exp condition)**
**| For(for_init init, exp? condition, exp? post, statement body)**
| Null
exp = Constant(int)
| Var(identifier)
| Unary(unary_operator, exp)
| Binary(binary_operator, exp, exp)
| Assignment(exp, exp)
| Conditional(exp condition, exp, exp)
unary_operator = Complement | Negate | Not
binary_operator = Add | Subtract | Multiply | Divide | Remainder | And | Or
| Equal | NotEqual | LessThan|LessOrEqual
| GreaterThan | GreaterOrEqual
示例 8-16:包含循环的抽象语法树和 break 和 continue 语句
本章更新 AST 涉及一个复杂的问题。循环标注阶段会为程序中的每个 break、continue 和循环语句加上标签(我们将使用这些标签将每个 break 和 continue 语句与其包含的循环关联起来)。这意味着你需要一种方法将这些标签附加到 AST 中的新语句上。这里有几种不同的选择。一种方法是在每个新构造函数中包含一个 label 参数,像这样:
statement = `--snip--`
| Break(**identifier label**)
| Continue(**identifier label**)
| While(exp condition, statement body, **identifier label**)
| DoWhile(statement body, exp condition, **identifier label**)
| For(for_init init, exp? condition, exp? post, statement body, **identifier label**)
如果你选择这种方法,你可能需要在解析过程中使用虚拟标签,然后在循环标注阶段将它们替换为真实标签。另一种方法是定义两个 AST 数据结构:一个在循环标注前使用,没有注释,另一个在循环标注后使用,带有注释。正确的方法取决于你使用的编译语言(以及你的个人偏好)。
更新 AST 后,我们将对语法进行相应的修改,如 示例 8-17 所示。
<program> ::= <function>
<function> ::= "int" <identifier> "(" "void" ")" <block>
<block> ::= "{" {<block-item>} "}"
<block-item> ::= <statement> | <declaration>
<declaration> ::= "int" <identifier> ["=" <exp>] ";"
**<for-init> ::= <declaration> | [<exp>] ";"**
<statement> ::= "return" <exp> ";"
| <exp> ";"
| "if" "(" <exp> ")" <statement> ["else" <statement>]
| <block>
**| "break" ";"**
**| "continue" ";"**
**| "while" "(" <exp> ")" <statement>**
**| "do" <statement> "while" "(" <exp> ")" ";"**
**| "for" "(" <for-init> [<exp>] ";" [<exp>] ")" <statement>**
| ";"
<exp> ::= <factor> | <exp> <binop> <exp> | <exp> "?" <exp> ":" <exp>
<factor> ::= <int> | <identifier> | <unop> <factor> | "(" <exp> ")"
<unop> ::= "-" | "~" | "!"
<binop> ::= "-" | "+" | "*" | "/" | "%" | "&&" | "||"
| "==" | "!=" | "<" | "<=" | ">" | ">=" | "="
<identifier> ::= ? An identifier token ?
<int> ::= ? A constant token ?
示例 8-17:包含循环的语法和 break 和 continue 语句
我建议编写一个辅助函数来解析可选的表达式。你可以使用这个辅助函数来解析for循环头部中的两个可选表达式,以及表达式语句和空语句。这个辅助函数应该让你指定哪个标记表示可选表达式的结束;语法中的大多数可选表达式后面跟着一个分号,但for循环头部的第三个子句后面跟着一个右括号。
语义分析
目前编译器的语义分析阶段执行一个任务:解析变量名。在本章中,它将承担一个全新的任务:循环标记。循环标记步骤将每个break和continue语句与其所在的循环关联起来。更具体地说,这个步骤为每个循环语句分配一个唯一的 ID,并为每个break和continue语句添加其所在循环的 ID。如果在循环外发现break或continue语句,将抛出错误。在 TACKY 生成过程中,我们将使用这些注释信息,将每个break和continue语句转换为相对于其所在循环的跳转。
我们将在两次遍历中分别解析变量名和标记循环,每次遍历整个程序。让我们首先扩展变量解析步骤,以处理本章的新语句;然后实现循环标记步骤。
扩展变量解析
你需要扩展resolve_statement,以遍历本章中添加的五个新语句。你将像处理if语句一样处理while和do循环,递归地处理每个子语句和子表达式。解析break和continue语句要简单得多;因为它们没有子语句或子表达式,你不需要做任何额外处理。
解析一个for循环稍微复杂一些,因为循环头部引入了一个新的变量作用域。清单 8-18 演示了如何在resolve_statement中处理for循环。
resolve_statement(statement, variable_map):
match statement with
| `--snip--`
| For(init, condition, post, body) ->
new_variable_map = copy_variable_map(variable_map)
init = resolve_for_init(init, new_variable_map)
condition = resolve_optional_exp(condition, new_variable_map)
post = resolve_optional_exp(post, new_variable_map)
body = resolve_statement(body, new_variable_map)
return For(init, condition, post, body)
清单 8-18:解析一个 for 循环
我们首先创建一个新的变量映射副本,就像在复合语句的开始时一样。复制映射可以确保在循环头部声明的变量不会在循环外部可见,并且如果它隐藏了外部作用域的变量,也不会触发编译器错误。
接下来,我们使用resolve_for_init处理初始子句,稍后我们将查看这个函数。然后,我们使用新的变量映射遍历for循环的控制表达式、终止表达式和主体。我不会提供resolve_optional_exp的伪代码,它处理可选的控制表达式和终止表达式;如果表达式存在,它会调用resolve_exp,如果不存在,则不执行任何操作。
清单 8-19 显示了resolve_for_init的伪代码。
resolve_for_init(init, variable_map):
match init with
| InitExp(e) -> return InitExp(resolve_optional_exp(e, variable_map))
| InitDecl(d) -> return InitDecl(resolve_declaration(d, variable_map))
清单 8-19:解析一个 for 循环的初始子句
我们在初始子句中解析一个表达式或声明的方式与在程序其他地方解析它时完全相同。如果该子句是一个声明,调用resolve_declaration将把新声明的变量添加到变量映射中,使其在整个循环中可见。
循环标记
在解析变量后,我们将再次遍历程序,给每个循环、break 和 continue 语句标注上 ID。每当我们遇到循环语句时,我们将为其生成一个唯一的 ID。然后,在遍历循环体时,我们将这个 ID 附加到遇到的任何 break 和 continue 语句上。让我们来看几个例子。在接下来的三个列表中,标记 ❶ 和 ❷ 表示附加到抽象语法树(AST)的 ID。尽管循环标注阶段是给 AST 添加注解,而不是源文件,但为了可读性,这些列表以源代码的形式呈现。
列表 8-20 演示了我们如何标注包含两个连续循环的代码片段。
❶ while (1) {
a = a - 1;
if (a < 0)
❶ break;
}
❷ for (int b = 0; b < 100; b = b + 1) {
if (b % 2 == 0)
❷ continue;
a = a * b;
}
return a;
列表 8-20: 标注 break 和 continue 语句及其包含的循环
本列表中的两个循环各自获得一个 ID。我们将 while 循环标注为 ID ❶,将 for 循环标注为 ID ❷。每个 break 或 continue 语句都会被标注上其所包含循环的 ID,因此我们将 break 语句标注为 ID ❶,将 continue 语句标注为 ID ❷。
如果多个 break 或 continue 语句位于同一个包含循环中,它们都会被标注为相同的 ID,正如 列表 8-21 所示。
❶ for (int i = 0; i < 10; i = i + 1) {
if (i % 2 == 0)
❶ continue;
if (x > y)
❶ continue;
❶ break;
}
列表 8-21: 标注同一循环中的多个 break 和 continue 语句
由于标注为 ❶ 的 for 循环是两个 continue 语句和 break 语句的包含循环,我们将这三条语句都标注为 ID ❶。
如果 break 或 continue 语句出现在嵌套循环内,我们会用其包含的最内层循环的 ID 为其注解。列表 8-22 演示了如何注解嵌套循环。
❶ while (a > 0) {
❷ for (int i = 0; i < 10; i = i + 1) {
if (i % 2 == 0)
❷ continue;
a = a / 2;
}
if (a == b)
❶ break;
}
列表 8-22:注解嵌套循环
外部的 while 循环和内部的 for 循环分别被标注为 ❶ 和 ❷。由于 continue 语句出现在内部循环中,我们用 ID ❷ 为其注解。break 语句出现在外部循环中,因此我们用 ID ❶ 为其注解。
实现循环标注
为了实现这个编译器阶段,我们在遍历 AST 时将当前的循环 ID 作为参数传递,就像我们在变量解析阶段将变量映射传递给 resolve_statement、resolve_exp 等函数一样。当我们不在循环内时,当前的 ID 为 null 或 None,或者根据你的实现语言,表示缺失值的其他方式。当遇到循环语句时,我们会生成一个新的 ID,并用它注解该语句。然后,在遍历循环体时,我们将其作为当前 ID 传递。当遇到 break 或 continue 语句时,我们用传递给我们的 ID 为其注解。列表 8-23 中的伪代码演示了如何用循环 ID 注解语句。
label_statement(statement, current_label):
match statement with
| Break ->
if current_label is null:
fail("break statement outside of loop")
return ❶ annotate(Break, current_label)
| Continue ->
if current_label is null:
fail("continue statement outside of loop")
return ❷ annotate(Continue, current_label)
| While(condition, body) ->
new_label = ❸ make_label()
labeled_body = label_statement(body, new_label)
labeled_statement = While(condition, labeled_body)
return ❹ annotate(labeled_statement, new_label)
| `--snip--`
列表 8-23:循环注解算法
make_label辅助函数❸生成唯一的循环 ID;你可以在此使用与生成 TACKY 中唯一标签相同的辅助函数。annotate辅助函数接受一个statement AST 节点和一个标签,并返回一个带有该标签的 AST 节点副本。在这里,我们用它来注解Break ❶、Continue ❷和While ❹语句。我没有提供annotate的定义,因为它将依赖于你在 AST 中如何表示循环注解。我还省略了处理DoWhile、For以及我们在早期章节中添加的所有语句的伪代码。你可以像处理While语句一样处理DoWhile和For语句。要处理任何其他类型的语句,请递归地调用label_statement,并传递相同的current_label值给每个子语句。
一旦你更新了循环标签的传递过程,就可以测试整个语义分析阶段了。
TACKY 生成
接下来,我们将把每个新语句转换为 TACKY。在本章中,我们不会改变 TACKY IR,因为我们可以使用现有的 TACKY 指令来实现这些语句。
break 和 continue 语句
一个break语句会无条件跳转到程序中的某个点,因此我们使用单一的Jump指令来实现它。continue语句也是如此。唯一的问题是跳转到哪里。我们在上一节添加的循环注解可以帮助我们回答这个问题。
每当我们将一个循环语句转换为 TACKY 时,我们会在循环体的指令后面生成一个Label。任何该循环中的continue语句都可以实现为跳转到该标签,我将其称为continue 标签。我们将生成另一个Label作为整个循环的最后一条指令;我将其称为break 标签。
我们将根据在循环注释过程中添加的 ID 来导出这些标签。例如,如果一个循环被标记为 loop0,则其 break 和 continue 标签可能是 break_loop0 和 continue_loop0。使用此命名方案,我们将把带有 ID loop0 注释的 Break AST 节点转换为以下 TACKY 指令:
Jump("break_loop0")
我们将使用相同的注释将一个 Continue 节点转换为:
Jump("continue_loop0")
你不需要使用这个特定的命名方案(尽管你的命名方案必须保证这些标签不会与 TACKY 程序中的其他标签冲突)。重要的是,你可以在将 break 或 continue 语句转换为 TACKY 时,导出与转换其封闭循环时相同的标签,因为该语句及其封闭循环都使用相同的 ID 注释。
do 循环
我们可以通过三步执行语句 do while (
Label(start)
`<instructions for body>`
`<instructions for condition>`
v = `<result of condition>`
JumpIfNotZero(v, start)
清单 8-24:do 循环的 TACKY 指令
我们还需要 break 和 continue 标签。continue 标签位于循环体和条件之间,而 break 标签位于最后,在 JumpIfNotZero 之后。添加这两个标签可以得到完整的 TACKY for do 循环,如清单 8-25 所示。
Label(start)
`<instructions for body>`
**Label(continue_label)**
`<instructions for condition>`
v = `<result of condition>`
JumpIfNotZero(v, start)
**Label(break_label)**
清单 8-25:带有 break 和 continue 标签的 do 循环的 TACKY 指令
现在,循环体中的任何 continue 语句将跳转到 continue 标签,而任何 break 语句将跳转到 break 标签。只有在循环体中出现 break 或 continue 语句时,这些标签才是必要的——否则它们不会被使用——但为了简化,我们总是会生成这些标签。这样,我们就不需要判断循环中是否包含 break 或 continue 语句。
while 循环
我们将像处理 do 循环一样处理 while 循环,但在这种情况下,我们将在循环体之前执行条件判断,然后使用 JumpIfZero 来退出循环(如果条件为假)。我们可以将语句 while (
Label(start)
`<instructions for condition>`
v = `<result of condition>`
JumpIfZero(v, end)
`<instructions for body>`
❶ Jump(start)
Label(end)
清单 8-26: while 循环的 TACKY 指令
现在,让我们来决定将 break 和 continue 标签放在哪里。这次我们不需要额外的 Label 指令;我们可以重用 清单 8-26 中已经存在的 Label 指令。我们将把 break 标签放在本清单末尾的 Label 指令中。它将作为 JumpIfZero 指令和任何循环体中的 break 语句的目标。
同样,我们将在本清单开头的 Label 指令中放置 continue 标签。这与将 continue 标签放在循环体末尾之后 ❶ 的效果相同,因为循环体之后的指令是一个无条件跳转,它会立即将我们带回循环的开始。让 continue 语句直接跳转到循环开始处,可以让它们绕过那个 Jump 指令,从而提高一些效率。
Listing 8-27 显示了在我们将while循环转换为 TACKY 时,应该使用 break 和 continue 标签的位置。
Label(**continue_label**)
`<instructions for condition>`
v = `<result of condition>`
JumpIfZero(v, **break_label**)
`<instructions for body>`
Jump(**continue_label**)
Label(**break_label**)
Listing 8-27: 带有 break 和 continue 标签的 TACKY 指令,用于 while 循环
这个 TACKY 与 Listing 8-26 完全相同,只是它使用了continue_label和break_label,而不是start和end。
for 循环
我们的最终任务是将for循环转换为 TACKY。我们将把语句for (
`<instructions for init>`
Label(start)
`<instructions for condition>`
v = `<result of condition>`
JumpIfZero(v, break_label)
`<instructions for body>`
Label(continue_label)
`<instructions for post>`
Jump(start)
Label(break_label)
Listing 8-28: 带有 break 和 continue 标签的 TACKY 指令,用于 for 循环
首先,我们执行。然后,我们执行控制表达式,并检查结果是否为零。如果是,我们跳转到Label(break _label),跳过执行循环体和最终表达式。否则,我们执行循环体,接着是最终表达式,然后跳转回Label(start)并开始下一轮循环。我们不会再次执行,因为Label(start)在之后。请注意,continue 标签出现在循环体的末尾,紧接在之前,而 break 标签则出现在循环的最末尾,起到双重作用:既是JumpIfZero指令的目标,也是任何break语句的目标。
接下来,让我们分析如何处理循环头中的三个子句。第一个子句可以是一个表达式、一个声明,或者什么都没有。如果它是声明或表达式,我们将像处理for循环外的声明或表达式一样处理它。如果没有这个子句,我们将不生成任何指令。
第二个子句是控制表达式。如果这个表达式存在,我们将像处理while和do循环中的控制表达式一样,转换它为 TACKY。如果缺失,C 标准规定这个表达式会被“替换为一个非零常量”(第 6.8.5.3 节,第 2 段)。我们可以直接在条件跳转中使用一个非零常量:
JumpIfZero(Const(1), break_label)
但这个指令实际上什么也不做;Const(1)永远不可能等于零,因此我们永远不会跳转。相反,我们将完全省略JumpIfZero指令,因为这种方式更高效,能实现相同的行为。
最后,我们需要处理第三个子句,
在这一章中,你有机会实现 switch、case 和 default 语句。为了支持这些语句,你将需要对语义分析阶段进行重大修改。首先,你需要更改循环注解阶段,因为 break 语句可以跳出 switch 语句以及循环。你不能在 switch 语句中使用 continue 语句,因此这个阶段需要将 continue 语句与 break 语句区分开来。
你将需要额外的分析,可能是在一个单独的编译器阶段,来收集出现在每个 switch 语句中的所有情况。为了生成一个 switch 语句的 TACKY,你需要得到该语句中所有情况的列表。然而,这些信息在 AST(抽象语法树)中并不立即可用。一个 switch 语句中的情况可能嵌套了多层,或者 switch 语句的主体根本没有包含任何情况。你需要以更易用的形式将这些信息附加到 AST 上。
使用 --switch 标志来启用对 switch 语句的测试:
$ **./test_compiler** `**/path/to/your_compiler**` **--chapter 8 --switch**
或者,像往常一样,使用 --extra-credit 标志来启用所有额外的学分测试。
总结
在本章中,你实现了最后一组控制流语句。你为三种不同的循环语句添加了支持,并增加了对 break 和 continue 语句的支持。你实现了一个新的语义分析阶段,将 break 和 continue 语句与它们所包含的循环关联,并且你学会了如何将这些复杂的结构转换为一系列 TACKY 指令。
虽然我们已经完成了控制流的语句,但在下一章中,你将为一种新的控制流表达式添加支持:函数调用。你将学习关于调用约定的知识,这些约定决定了在汇编语言中函数调用的工作原理,并编写一个简单的类型检查器。最棒的是,你将通过编译“Hello, World!”来结束这一章。

描述
第九章:9 函数

函数——可以在一个地方定义并在其他地方调用的代码块——是每种主流编程语言中的基本概念。实际上,它们如此基础,以至于有专门的处理器指令来进行函数调用。在本章中,你将根据类 Unix 系统的标准调用约定实现函数调用和定义,调用约定精确定义了函数调用在汇编级别的工作方式。调用约定使得分别编译的二进制文件(即使它们可能是用不同的源语言编写的)能够相互操作。通过遵循你系统的调用约定,你将能够编译调用外部库函数的程序,包括标准库。你编译的程序最终将能够执行 I/O 操作!由其他编译器构建的程序也将能够使用你编译器构建的库。
本章的大部分内容将集中在语义分析和汇编代码生成上。在语义分析阶段,我们将添加一个新的类型检查过程,在本章开始时的图示中已被加粗显示。这个过程目前非常简化,但随着我们在第二部分中添加新类型,它会不断发展。在汇编生成阶段,我们将深入探讨系统的调用约定,它告诉我们如何设置堆栈帧、传递参数和返回值,以及如何从一个函数转移控制到另一个函数。
首先,我们来明确我们将要实现的功能。
声明、定义和调用函数
在本章中,你将实现函数调用、函数声明和函数定义。一个函数声明告诉你函数的名称和类型。声明将函数名引入作用域,以便后续调用。一个函数定义是一个包含函数体的声明。(所有函数定义都是声明,但并非所有声明都是定义。)你的编译器已经支持某些函数定义,因为它可以编译main。现在,你将将其通用化,以编译其他函数。
声明和定义
一个函数声明,像清单 9-1 中的函数声明,必须包括函数的返回类型、函数名,以及每个参数的类型和名称。
int foo(int param1, int param2, int param3);
清单 9-1:一个函数声明
目前,函数的返回类型和参数类型都必须是 int。正如我们之前看到的,如果函数没有参数,它的参数列表只是 void 关键字:
int foo(void);
函数定义看起来就像 清单 9-1 中的函数声明,外加一个函数体。清单 9-2 显示了一个函数定义的例子。
int foo(int param1, int param2, int param3) {
return param1 + param2 + param3;
}
清单 9-2:一个函数定义
你可以多次声明相同的函数,但所有声明必须兼容:返回类型、参数类型和参数个数必须相同。参数名称可以在声明之间有所不同,因为只有函数定义中的参数名称会被使用。例如,清单 9-3 就是完全有效的。
int foo(int x, int y, int z);
int main(void) {
return foo(1, 2, 3);
}
int foo(int param1, int param2, int param3);
int foo(int a, int b, int c) {
return a + b + c;
}
清单 9-3:用不同的参数名称多次声明一个函数
虽然声明一个函数多次是合法的,但你不能定义一个函数多次;如果你这么做,当调用该函数时,就无法判断执行哪个定义。
你可以在两个地方声明函数:在顶层或在其他函数的主体内。清单 9-4 包含了这两种声明方式。
int foo(int a, int b);
int main(void) {
int foo(int a, int b);
return foo(1, 2);
}
清单 9-4:嵌套和顶级函数声明
然而,你不能在另一个函数的主体内定义函数。C 标准只支持在顶层定义函数,因此它不允许像 清单 9-5 这样的程序。
int main(void) {
int foo(int a, int b) {return a + b;};
return foo(1, 2);
}
清单 9-5:嵌套函数定义(不支持)
一些编译器作为语言扩展支持嵌套函数定义,并能够成功编译 清单 9-5。我们不会实现这个语言扩展;我们只坚持使用 C 标准中的功能。
函数调用
函数调用由一个函数名,后跟一系列用逗号分隔的参数,这些参数被括在圆括号中:
foo(1, 2, 3);
在函数声明中的标识符被称为函数参数,传递给函数调用的表达式则被称为函数实参。例如,在清单 9-6 中,a、b和c是< samp class="SANS_TheSansMonoCd_W5Regular_11">foo的参数,而a + b和2 * c是传递给< samp class="SANS_TheSansMonoCd_W5Regular_11">bar的实参。
int foo(int a, int b, int c) {
return bar(a + b, 2 * c);
}
清单 9-6:函数参数与实参
如清单 9-7 所示,函数必须在调用之前声明,但不一定要定义。
int foo(int arg1, int arg2, int arg3);
int main(void) {
return foo(1, 2, 3);
}
清单 9-7:声明函数并调用它
foo的定义可能会出现在同一个文件的后面,或者可能出现在单独的库中。寻找程序调用的每个函数的定义是链接器的任务,而不是编译器。如果链接器无法找到定义,链接将失败。
在函数声明之前调用函数是非法的,因此清单 9-8 是无效的。
int main(void) {
return foo(1, 2, 3);
}
int foo(int arg1, int arg2, int arg3);
清单 9-8:调用一个未声明的函数
实际上,许多编译器会警告调用未声明函数的程序,但不会拒绝它们。我们的实现更为严格,在语义分析期间会拒绝像清单 9-8 这样的程序。
调用函数时使用错误数量的实参,或者将变量当作函数来调用也是非法的。(如果我们在实现函数指针时,这一点会有例外。)#### 标识符链接
函数名和变量名都是标识符。它们存在于相同的命名空间中,并遵循相同的作用域规则。像变量名一样,函数名也可以被内层作用域中的其他声明所遮蔽。请看清单 9-9,其中变量名foo遮蔽了函数名foo。
int foo(int a, int b);
int main(void) {
int foo = 3;
return foo;
}
清单 9-9:变量名遮蔽函数名
该程序可以正常编译并返回3。如清单 9-10 所示,函数名也可以遮蔽变量名。
int main(void) {
int a = 3;
if (a > 0) {
int a(void);
return a();
}
return 0;
}
列表 9-10:一个函数名覆盖一个变量名
在这里,函数名 a 覆盖了变量名 a;只要函数 a 在其他地方定义,这个程序也可以编译通过。
然而,在其他方面,函数声明的解析与我们之前看到的局部变量声明非常不同。每个局部变量声明都引用不同的变量,即使其中一些变量具有相同的名称(当我们在变量解析过程中为每个变量提供唯一的名称时,我们会明确指出这一点)。但是,多个同名的函数声明都引用同一个函数。考虑列表 9-11,其中包括三个使用名称 incr 的函数声明。
int two(void) {
int incr(int i);
return incr(1);
}
int incr(int i);
int main(void) {
return two() + incr(3);
}
int incr(int i) {
return i + 1;
}
列表 9-11:多个函数声明引用单个定义
这些声明中的每一个最终都引用相同的函数定义。这个列表不包含三个不同的函数声明,名为 incr;它包含三个相同函数的声明。
在 C 标准中,一个声明的 链接性 决定了它如何与同一标识符的其他声明相关联。链接性有几种不同的类型。根据 C 标准第 6.2.2 节第 2 段,“每个具有 外部链接性 的特定标识符的声明表示相同的对象或函数。”在列表 9-11 中,每个 incr 的声明都有外部链接性,因此这些声明都引用相同的函数定义。具有外部链接性的声明即使出现在不同的翻译单元中,也可以引用相同的对象或函数。(翻译单元 就是一个经过预处理的源文件。)
考虑一个由两个不同文件组成的程序。在一个文件中,如列表 9-12 所示,我们定义一个函数。
int library_fun(int a, int b) {
return a + b;
}
列表 9-12:在一个文件中定义一个库函数
在另一个文件中,如列表 9-13 所示,我们声明并使用该函数。
int library_fun(int a, int b);
int main(void) {
return library_fun(1, 2);
}
列表 9-13:在不同文件中声明并调用库函数
尽管library_fun在两个不同的文件中声明,链接器会识别这两个声明指向同一个内容:在列出 9-12 中的library_fun的定义。然后,它会更新在列出 9-13 中的每个library_fun的使用,指向列出 9-12 中的定义。
在本章中,所有函数标识符都有外部链接。另一方面,局部变量没有链接。 C 标准第 6.2.2 节第 2 段指出,“每个没有链接的标识符的声明都表示一个唯一的实体。”局部变量不能与另一个局部变量引用相同的对象,也不能与具有外部链接的标识符(如函数名)引用相同的内容。
注意
看起来标识符的链接性仅取决于它是函数还是变量,但在下一章中你将看到,情况并非如此。我们将实现具有外部链接的全局变量声明,并实现具有第三种链接性——内部链接——的函数和变量声明。具有内部链接的声明可以与同一翻译单元中的其他声明链接,但不能与其他翻译单元中的声明链接。
由于同一个函数名的所有声明必须指向同一个函数定义,即使它们出现在不同的作用域中,它们也必须兼容。列出 9-14 包含了两个不兼容的函数声明。
int main(void) {
int f(int x);
int ret = f(1);
if (ret) {
int f(int a, int b);
return f(0, 1);
}
return 0;
}
列出 9-14:冲突的函数声明
两个f的声明应当指向同一个函数,因为它们都具有外部链接。然而没有任何一个函数定义能够同时满足这两个声明,因为它们的参数数量不同。由于这两个声明冲突,这段代码无法编译。
现在我们已经讲解了一些关于函数的背景知识,接下来可以开始编写编译器。但我们不会立即从词法分析器开始。首先,我们需要更新编译器驱动程序。
编译库
在前面的章节中,我们只能编译独立的可执行文件。每个我们编译的源文件都定义了一个 main 函数,它是程序的入口点。现在我们能够处理其他函数时,我们也应该能够编译没有入口点的库。当你的编译器将源代码转换为汇编时,它并不关心是处理一个库还是可执行文件。然而,你的编译器驱动程序是关心的,因为链接器期望一个完整的程序包含 main。如果你尝试用当前的编译器驱动程序编译一个没有 main 函数的源文件,你会得到一个链接器错误,错误信息可能类似于以下内容:
/usr/bin/ld: . . ./x86_64-linux-gnu/Scrt1.o: in function `_start':
(.text+0x24): undefined reference to `main'
collect2: error: ld returned 1 exit status
这个错误意味着链接器正在尝试但未能将你的代码链接到 crt0,即调用 main 的包装代码。
gcc 命令接受一个 -c 命令行标志,告诉它不要调用链接器;当这个标志存在时,它会生成一个目标文件,而不是可执行文件。为了与测试套件一起工作,你的编译器驱动程序也应该识别 -c 标志。当它收到这个标志时,编译器驱动程序应该像往常一样先将源程序转换为汇编文件,然后运行以下命令将汇编程序转换为目标文件:
gcc -c `ASSEMBLY_FILE` -o `OUTPUT_FILE`
输出的文件名应该是原始文件名加上 .o 后缀。换句话说,./YOUR_COMPILER -c /path/to/program.c 应该生成一个位于 /path/to/program.o 的目标文件。
注意
如果你想要编译并分发一个真正的库,你不仅仅是生成一个目标文件;你应该创建一个共享库(在 Linux 上是一个 .so 文件,在 macOS 上是一个 .dylib 文件)。如果你愿意,可以向你的编译器驱动程序添加另一个选项来生成共享库;你的驱动程序可以通过使用适当的标志调用 GCC 或 Clang,将汇编程序转换为共享库,而不是目标文件。但是,编译器生成共享库的能力有一个重大限制,特别是在 Linux 上;我们将在第十章中详细讨论这个问题。
此时,你可能还想扩展你的编译器驱动程序,以接受多个输入源文件。测试套件不要求这个功能,但如果你想编译多文件程序,你将需要它。为了处理多个源文件,你的编译器驱动程序应该分别将每个源文件转换为汇编文件,然后使用 gcc 命令将它们汇编并链接在一起。
词法分析器
本章中你将添加一个新的标记:
, 逗号
函数参数或参数列表由逗号分隔。
解析器
我们需要在几个位置扩展 AST,以支持函数调用、声明和定义。让我们从函数调用开始,它是一种表达式:
exp = Constant(int)
| Var(identifier)
| Unary(unary_operator, exp)
| Binary(binary_operator, exp, exp)
| Assignment(exp, exp)
| Conditional(exp condition, exp, exp)
**| FunctionCall(identifier, exp* args)**
函数调用的 AST 节点包含函数名和一组参数。每个参数都是一个表达式。
接下来,我们将重构declaration节点,使其既能表示函数声明,也能表示变量声明:
declaration = FunDecl(function_declaration) | VarDecl(variable_declaration)
variable_declaration = (identifier name, exp? init)
我们已经将function_definition节点重命名为function_declaration。(稍后我们会详细讲解此节点的其他更改。)variable_declaration节点包含与之前章节中declaration相同的信息:变量名和可选的初始化值。但它与我们迄今为止看到的其他 AST 节点有所不同;它不像FunDecl或VarDecl那样包含一个命名构造函数。当 ASDL 中的一个节点有多个构造函数时——比如declaration节点和大多数其他 AST 节点——每个构造函数需要一个独特的名称,以便我们区分它们。但由于variable_declaration节点只有一个构造函数,因此不要求为该构造函数命名。在 ASDL 术语中,具有一个未命名构造函数的节点定义被称为产品类型。我们迄今为止使用的其他节点都是和类型,因为它们都有命名的构造函数。产品类型只是语法上的一种便捷方式,这样我们就不需要使用笨重、冗余的构造函数名称。
现在让我们更新function_declaration。以下是现有的function _definition节点:
function_definition = Function(identifier name, block body)
我们需要在这里进行一些更改。首先,正如我之前所提到的,我们将其重命名为更准确的function_declaration。我们还将添加函数参数,并将函数体设为可选,这样该节点既可以表示函数声明,也可以表示函数定义。最后,为了与variable_declaration保持一致,我们将移除Function构造函数名称,从而将其转变为产品类型。我们修改后的 AST 节点用于表示函数声明和定义如下:
function_declaration = (identifier name, identifier* params, block? body)
最后,我们需要改变程序的顶层定义。现在,程序不再是一个单独的main函数,而是一个包含函数定义和声明的列表:
program = Program(function_declaration*)
第 9-15 节展示了完整的更新后的抽象语法树。
program = Program(**function_declaration***)
**declaration = FunDecl(function_declaration) | VarDecl(variable_declaration)**
**variable_declaration = (identifier name, exp? init)**
**function_declaration = (identifier name, identifier* params, block? body)**
block_item = S(statement) | D(declaration)
block = Block(block_item*)
for_init = InitDecl(**variable_declaration**) | InitExp(exp?)
statement = Return(exp)
| Expression(exp)
| If(exp condition, statement then, statement? else)
| Compound(block)
| Break
| Continue
| While(exp condition, statement body)
| DoWhile(statement body, exp condition)
| For(for_init init, exp? condition, exp? post, statement body)
| Null
exp = Constant(int)
| Var(identifier)
| Unary(unary_operator, exp)
| Binary(binary_operator, exp, exp)
| Assignment(exp, exp)
| Conditional(exp condition, exp, exp)
**| FunctionCall(identifier, exp* args)**
unary_operator = Complement | Negate | Not
binary_operator = Add | Subtract | Multiply | Divide | Remainder | And | Or
| Equal | NotEqual | LessThan |LessOrEqual
| GreaterThan| GreaterOrEqual
第 9-15 节:带有函数调用、声明和定义的抽象语法树
一个声明可以作为一个块项出现,但是变量声明只能出现在for循环头部。请注意,这个抽象语法树可以表示嵌套的函数定义,比如第 9-5 节中的函数,尽管我们目前不支持它们。我们将在语义分析阶段检查嵌套的函数定义,并在遇到任何情况时抛出错误。
第 9-16 节展示了更新后的语法。
<program> ::= **{<function-declaration>}**
**<declaration> ::= <variable-declaration> | <function-declaration>**
**<variable-declaration> ::= "int" <identifier> ["=" <exp>] ";"**
**<function-declaration> ::= "int" <identifier> "(" <param-list> ")" (<block> | ";")**
**<param-list> ::= "void" | "int" <identifier> {"," "int" <identifier>}**
<block> ::= "{" {<block-item>} "}"
<block-item> ::= <statement> | <declaration>
<for-init> ::= **<variable-declaration>** | [<exp>] ";"
<statement> ::= "return" <exp> ";"
| <exp> ";"
| "if" "(" <exp> ")" <statement> ["else" <statement>]
| <block>
| "break" ";"
| "continue" ";"
| "while" "(" <exp> ")" <statement>
| "do" <statement> "while" "(" <exp> ")" ";"
| "for" "(" <for-init> [<exp>] ";" [<exp>] ")" <statement>
| ";"
<exp> ::= <factor> | <exp> <binop> <exp> | <exp> "?" <exp> ":" <exp>
<factor> ::= <int> | <identifier> | <unop> <factor> | "(" <exp> ")"
**| <identifier> "(" [<argument-list>] ")"**
**<argument-list> ::= <exp> {"," <exp>}**
<unop> ::= "-" | "~" | "!"
<binop> ::= "-" | "+" | "*" | "/" | "%" | "&&" | "||"
| "==" | "!=" | "<" | "<=" | ">" | ">=" | "="
<identifier> ::= ? An identifier token ?
<int> ::= ? A constant token ?
第 9-16 节:带有函数调用、声明和定义的语法
第 9-16 节中的
函数调用的优先级高于任何二元或三元运算符,因此在解析
语义分析
在变量解析阶段,我们为每个局部变量赋予一个新的、独特的名称。然而,我们不应重命名具有外部链接的实体。两个名为var的局部变量声明指向不同的内存地址,因此我们为它们分配不同的名称。但两个名为fun的函数声明指向相同的代码,因此这些声明在编译过程中应保持相同的名称。此外,具有外部链接的对象必须保留来自原始源代码的名称,因为链接器将在符号解析时依赖该名称。除非在每个目标文件编译时都保留了名称fun,否则链接器将无法将调用fun的目标文件与定义fun的目标文件链接起来。
因此,我们需要更新变量解析阶段,以重命名没有链接的标识符,但保留具有外部链接的标识符不变。(由于这个阶段将处理函数和变量,因此从现在开始我将其称为标识符解析,而非变量解析。)我们将检查所有常见的错误条件,比如重复声明和未声明的标识符;我们还将验证没有嵌套的函数定义。由于在同一作用域中多次声明具有外部链接的名称是合法的,捕获重复声明的逻辑会稍作修改。例如,示例 9-17 是完全合法的。
int main(void) {
int foo(void);
int foo(void);
return foo();
}
示例 9-17:同一作用域内的多个函数声明
由于两个foo的声明都有外部链接,它们指向相同的函数,因此没有冲突。标识符的重复声明只有在它们指向不同的实体时才会冲突;当你在同一作用域中稍后使用该标识符时,无法判断它应该指向哪个实体。
我们还有一些其他的错误情况需要检查。我们必须验证每个函数声明的参数数量是否一致,并确保没有函数被定义多次。同时,我们还要验证变量是否被当作函数使用,以及函数是否传入了正确数量的参数。这些错误和我们之前检查的错误情况不太相似,因为它们并不直接涉及标识符的作用域。它们是类型错误,当一个对象的不同声明具有冲突的类型,或者对象以其类型不支持的方式使用时,就会发生类型错误。
我们将定义一个独立的类型检查阶段来捕捉这些错误。此阶段还将构建一个符号表,用来存储程序中每个标识符的类型以及我们需要跟踪的其他一些标识符属性。在编译器的后续阶段,我们将回头参考这个符号表。(这不同于目标文件中的符号表,链接器在符号解析期间使用它。我们在类型检查器中构建的符号表是编译器内部的。)
本章结束时,语义分析阶段将包括三个阶段:标识符解析、类型检查和循环标记。循环标记阶段可以在其他两个阶段的任意时刻进行。
扩展标识符解析
让我们更新标识符解析阶段,以处理函数调用、函数声明和函数定义。我们需要为标识符映射中的每个条目跟踪一项新的信息:它是否具有外部链接性。在构建标识符映射时,不要假设函数总是具有外部链接性,而变量永远没有。这个假设目前成立,但在下一章中它将不再成立。
我们还将更新伪代码中的一些名称:将variable_map改为identifier_map,并将标识符映射中的from_current_block字段重命名为from_current_scope,因为函数声明可以出现在块之外,即在顶层。
函数调用
函数名,像变量名一样,必须在标识符映射中存在才能使用。列表 9-18 展示了如何由resolve_exp处理函数调用。
resolve_exp(e, identifier_map):
match e with
| `--snip--`
| FunctionCall(fun_name, args) ->
if fun_name is in identifier_map:
new_fun_name = identifier_map.get(fun_name).new_name
new_args = []
for arg in args:
new_args.append(resolve_exp(arg, identifier_map))
return FunctionCall(new_fun_name, new_args)
else:
fail("Undeclared function!")
列表 9-18:解析函数调用
首先,我们在标识符映射中查找函数名称,以确认它在程序的当前阶段是否在作用域中。然后,我们用标识符映射中的新名称替换这个名称。在一个有效的程序中,这个新名称将与原始名称相同,因为我们不会重命名具有外部链接的标识符。但我们也需要考虑无效程序的情况。也许fun_name实际上是一个局部变量的名称,而不是函数;在这种情况下,尝试像调用函数一样调用它就是一个类型错误。解析fun_name将使我们在类型检查时捕捉到这个类型错误。我们还会等到类型检查通过后再确认该函数调用是否有正确数量的参数。
在我们替换函数名称后,我们会递归地对每个函数参数调用resolve_exp,就像我们递归地解析一元、二元和三元表达式中的每个子表达式一样。
函数声明
现在让我们考虑函数声明。无论函数声明出现在代码块中还是顶层,我们几乎都可以以完全相同的方式处理它。首先,我们将函数名称添加到当前作用域。然后,我们处理它的参数,将它们添加到一个新的内部作用域。最后,如果有函数体,我们也处理它。清单 9-19 展示了如何解析函数声明。
resolve_function_declaration(decl, identifier_map):
if decl.name is in identifier_map:
prev_entry = identifier_map.get(decl.name)
❶ if prev_entry.from_current_scope and (not prev_entry.has_linkage):
fail("Duplicate declaration")
❷ identifier_map.add(decl.name, MapEntry(
new_name=decl.name, from_current_scope=True, has_linkage=True
))
❸ inner_map = copy_identifier_map(identifier_map)
new_params = []
for param in decl.params:
new_params.append(resolve_param(param, inner_map))
new_body = null
if decl.body is not null:
new_body = resolve_block(decl.body, inner_map)
return (decl.name, new_params, new_body)
清单 9-19: 解析函数声明
在我们更新标识符映射之前,需要确保我们没有非法地重新声明一个标识符❶。如果标识符当前不在作用域中,那么就没有冲突。如果标识符在外部作用域中已经声明,那也是没问题的;新的声明会覆盖旧的声明。到目前为止,这和我们处理变量声明的方式完全相同。不过,我们还需要考虑链接性。具有外部链接的标识符的多个声明可以出现在同一作用域中。我们已经知道新的声明具有外部链接,因为它是一个函数声明,所以只要旧的声明也具有外部链接,这样做是合法的。但是,如果旧的声明没有链接(因为它声明了一个局部变量),我们将抛出错误。标识符映射中的has_linkage属性告诉我们一个标识符是否具有外部链接。(在下一章中,它将跟踪标识符是否具有任何链接性,无论是内部还是外部。)
如果没有冲突的声明,我们将此名称添加到 identifier_map ❷。我们不会为函数生成新名称;该映射条目的 new_name 属性应为原始名称。因为该声明具有外部链接性,has_linkage 属性应为 True。
接下来,我们解析参数名称。声明中的函数参数列表开启了一个新作用域,因此我们需要复制标识符映射以跟踪它们 ❸。参数名称可以覆盖外部作用域中的名称,但同一个函数声明中的两个参数不能共享名称。所以,这是合法的:
int a;
int foo(int a);
但是,这不是:
int foo(int a, int a);
我省略了 resolve_param 的伪代码,但它应该与你现有的代码相同,用于解决变量声明:它应确保参数名称在当前作用域中尚未声明,生成一个唯一的名称,为其添加到标识符映射中,并返回新的名称。你可能想写一个辅助函数来解决参数和局部变量声明,因为在这两种情况下逻辑是相同的。
我们解析函数的参数有两个原因。首先,我们需要验证没有重复的参数名称。其次,我们需要确保在处理函数体时,参数处于作用域中。当我们处理没有函数体的函数声明时,第二点就不重要了;我们可以通过检查重复的参数而不重命名它们或更新内部作用域来完成。然而,我认为最简单的做法是无论函数是否有体,都以统一的方式处理函数声明。
Listing 9-19 的最后一步是处理函数体(如果有的话)。我们像往常一样使用 resolve_block 来处理;我们只需要确保传入 inner_map,这样函数参数就会在作用域内。函数名本身也在作用域内,因为我们在复制之前已经将其添加到外部映射中;因此,我们能够处理递归调用自身的函数。
函数参数和函数体处于同一作用域中,因此在处理函数体时,应该传入 inner_map,而不是它的副本。例如,这就是非法的:
int foo(int a) {
int a = 3;
return a;
}
变量声明 int a = 3; 是一个非法的重复声明,因为它与参数 a 在同一个作用域中。
到此为止,我们可以返回更新后的function_declaration节点。尽管函数名本身没有改变,但函数体内声明的参数列表和任何变量都已在此新节点中被重命名。
局部声明
你可以像前几章一样处理局部变量声明;只需确保在标识符映射中记录这些声明没有链接性。要处理局部函数声明,首先检查它是否有函数体。如果有,抛出一个错误;否则,调用resolve _function_declaration,这是我们在示例 9-19 中定义的。
顶层处理
最后,我们需要将所有这些内容整合在一起,以处理函数声明的列表。按顺序处理它们,同时在过程中构建标识符映射。你添加的每个函数名将在后续处理的函数声明中保持作用域。函数中的参数名和局部变量在后续函数中不可见,因为它们被添加到内层作用域。
编写类型检查器
我们剩下的验证全部是类型检查。每个标识符,无论是函数还是变量,都有一个类型。变量可以有像int、long和double这样的类型,但在我们项目的这个阶段,每个变量的类型都是int。一个函数的类型取决于其返回类型和参数的类型。例如,一个函数可以有这样的类型:“接收三个int参数并返回一个int。”现在,我们只支持接受int类型参数并返回int类型结果的函数,因此只有参数的数量会有所不同。
类型检查阶段验证所有标识符的声明和使用是否具有兼容的类型。例如,如果你声明x是一个变量,你不能像调用函数一样使用它:
int x = 3;
return x();
你不能在多个地方声明具有不同类型的函数:
int foo(int a, int b);
int foo(int a);
你不能调用具有错误参数数量的函数:
int foo(int a, int b);
int main(void) {
return foo(1);
}
你也不能多次定义相同的函数:
int foo(void) {
return 1;
}
int foo(void) {
return 2;
}
这个错误本身并不是类型错误,但最容易在这里检查。
为了进行类型检查,我们将记录符号表中每个标识符的类型。我们还将记录每个遇到的函数是已定义还是仅仅声明;也就是说,它是否有函数体。符号表将成为我们获取程序中每个标识符信息的核心来源。在本章中,我们将主要使用符号表来捕获类型错误。在未来的章节中,我们将向该表中添加更多信息,并找到更多使用它的方式。
为了构建符号表,我们需要一种方法在编译器中表示类型,就像我们需要一种方法表示抽象语法树(AST)一样。目前,你的类型定义应该是这样的:
type = Int | FunType(int param_count)
每个变量的类型是 int,我们只需要了解函数类型的一个信息,那就是它有多少个参数。在第二部分中,我们将添加更多的类型。
我们将通过按常规方式遍历程序来构建符号表。当我们遇到函数或变量声明时,我们会在符号表中记录它的类型。类型检查器不像标识符解析阶段那样转换抽象语法树,因此各个类型检查方法不会返回转换后的 AST 节点;它们只会添加符号表条目并报告错误。(类型检查器会在第二部分中转换抽象语法树。)
示例 9-20 展示了如何进行变量声明的类型检查。
typecheck_variable_declaration(decl, symbols):
symbols.add(decl.name, Int)
if decl.init is not null:
typecheck_exp(decl.init, symbols)
示例 9-20:类型检查变量声明
每个变量到此时都有了唯一的名称,因此我们知道这个声明不会与符号表中的任何现有条目冲突。我们只需将其添加到符号表中,然后检查其初始化器的类型(如果有的话)。函数稍微复杂一些。因为你可以声明一个函数多次,它可能已经在符号表中有条目。因此,在将函数添加到符号表之前,你需要验证它是否与已有条目冲突。示例 9-21 给出了类型检查函数声明的伪代码。
typecheck_function_declaration(decl, symbols):
fun_type = FunType(length(decl.params))
has_body = decl.body is not null
already_defined = False
if decl.name is in symbols:
old_decl = symbols.get(decl.name)
❶ if old_decl.type != fun_type:
fail("Incompatible function declarations")
already_defined = old_decl.defined
❷ if already_defined and has_body:
fail("Function is defined more than once")
❸ symbols.add(decl.name, fun_type, defined=(already_defined or has_body))
❹ if has_body:
for param in decl.params:
symbols.add(param, Int)
typecheck_block(decl.body)
示例 9-21:类型检查函数声明
我们首先检查函数是否已经用不同的类型声明过 ❶。然后,确保我们没有重新定义一个已经定义过的函数 ❷。函数符号表条目中的 defined 属性跟踪我们是否已经对该函数的定义进行了类型检查。(变量的符号表条目不需要这个属性。)
在验证后,我们将函数添加到符号表 ❸ 中。如果存在相应的条目,它会覆盖现有的符号表条目。这样是可以的,因为类型不会改变。我们只需要在设置 defined 属性时考虑旧的条目。如果该函数已经定义,或者当前声明具有函数体,我们会将 defined 设置为 True。最后,如果当前声明有函数体 ❹,我们会将每个函数的参数添加到符号表中,然后对函数体进行类型检查。
请记住,符号表包括我们迄今为止已进行类型检查的每一个声明,即使它不在当前作用域内。考虑这个例子:
int main(void) {
❶ int foo(int a);
return foo(1);
}
❷ int foo(int a, int b);
嵌套的函数声明 ❶ 在函数重新声明 ❷ 时不在作用域内。然而,当我们进行声明 ❷ 的类型检查时,声明 ❶ 会出现在符号表中。因此,我们会检测到这两个声明冲突并抛出错误。
我们将验证标识符的使用和声明。标识符可以在 Var AST 节点中作为变量使用,或者在 FunctionCall AST 节点中作为函数名使用。在这两种情况下,您都应该验证该标识符是否具有预期的类型。列表 9-22 演示了如何类型检查这两种类型的表达式。
typecheck_exp(e, symbols):
match e with
| FunctionCall(f, args) ->
f_type = symbols.get(f).type
❶ if f_type == Int:
fail("Variable used as function name")
❷ if f_type.param_count != length(args):
fail("Function called with the wrong number of arguments")
❸ for arg in args:
typecheck_exp(arg, symbols)
| Var(v) ->
❹ if symbols.get(v).type != Int:
fail("Function name used as variable")
| `--snip--`
列表 9-22:类型检查表达式
当标识符作为函数被调用时,您需要验证它是否被声明为函数,而不是 int ❶。您还需要验证它是否使用了正确数量的参数 ❷,然后递归地对每个参数进行类型检查 ❸。当标识符作为变量使用时,您需要验证它是否被声明为变量,而不是函数 ❹。
记住,您的符号表需要在后续的编译器过程中可访问。我建议将符号表设置为全局变量(或者根据您使用的实现语言,可以使用单例模式),这样可以方便从编译器的任何位置访问它。在我们的类型检查伪代码中,符号表是作为一个显式参数传递给 typecheck_* 函数,而不是全局变量,以便更清晰地表达。但在实际实现中,我发现使用全局变量会更简单些。
TACKY 生成
现在我们已经确认输入程序有效,让我们将其转换为 TACKY。我们需要对 TACKY IR 进行一些修改。首先,我们需要一个新的 TACKY 指令来表示函数调用。其次,我们需要在 TACKY 函数定义中包含参数。最后,我们将整个 TACKY 程序定义为一个函数列表,而不是单个函数。清单 9-23 展示了更新后的 TACKY IR 定义。
program = Program(**function_definition***)
function_definition = Function(identifier, **identifier* params,** instruction* body)
instruction = Return(val)
| Unary(unary_operator, val src, val dst)
| Binary(binary_operator, val src1, val src2, val dst)
| Copy(val src, val dst)
| Jump(identifier target)
| JumpIfZero(val condition, identifier target)
| JumpIfNotZero(val condition, identifier target)
| Label(identifier)
**| FunCall(identifier fun_name, val* args, val dst)**
val = Constant(int) | Var(identifier)
unary_operator = Complement | Negate | Not
binary_operator = Add | Subtract | Multiply | Divide | Remainder | Equal | NotEqual
| LessThan | LessOrEqual | GreaterThan | GreaterOrEqual
清单 9-23:将函数调用添加到 TACKY
这些更改与 清单 9-15 中 AST 的更改非常相似。然而,TACKY IR 所需的更改比 AST 少,因为我们在 TACKY 中不表示函数声明。就像没有初始值的变量声明一样,TACKY IR 生成时会丢弃没有函数体的声明。只有函数定义会被转换为 TACKY。
新的 FunCall 指令需要一个函数名、一组参数列表和一个返回值的目标位置。与其他 TACKY 指令的操作数一样,函数参数必须是常量或变量,而不是表达式。
要将整个程序转换为 TACKY,逐个处理顶层函数声明,将每个函数定义转换为 TACKY function_definition 并丢弃没有函数体的声明。要将函数调用转换为 TACKY,生成指令以评估每个参数,并构建由这些 TACKY 值组成的列表。函数调用 fun(e1, e2, …) 对应的 TACKY 将如下所示 清单 9-24。
`<instructions for e1>`
v1 = `<result of e1>`
`<instructions for e2>`
v2 = `<result of e2>`
`--snip--`
result = FunCall(fun, [v1, v2, . . .])
清单 9-24:将函数调用转换为 TACKY
这与我们处理其他包含嵌套子表达式的表达式的方法相同,比如一元和二元运算。现在我们只是将其推广到任意数量的嵌套表达式,因为一个函数可以有任意数量的参数。
记得在每个函数体的末尾添加 Return(0) 指令,以确保即使某些执行路径缺少 return 语句,也能正确返回调用者。接下来,我们将处理本章中最棘手的部分:在汇编中实现函数调用。
汇编生成
在本章中,我们将对 TACKY 到汇编的转换过程做出两个重大修改:将函数参数放到栈上,以便它们可以在函数体内访问,以及将新的 FunCall 指令转换为汇编代码。我们还将对伪寄存器替换和指令修正过程做一些小的修改。但在做这些修改之前,我们需要了解我们将使用的调用约定。
理解调用约定
调用约定是调用者和被调用者之间关于如何调用函数的协议。它回答了诸如以下问题:
-
参数是如何传递给被调用者的?它们是通过寄存器传递还是通过栈传递?
-
函数的返回值是如何传回给调用者的?
-
在函数结束时,是否由被调用者还是调用者负责从栈中移除参数?
-
被调用者允许覆盖哪些寄存器,哪些寄存器需要保留?
共享调用约定允许调用者和被调用者协作。调用者知道将参数放在哪里,而被调用者知道从哪里查找它们。被调用者知道将返回值存储在哪里,调用者知道在被调用者返回后从哪里找到它。调用者和被调用者都知道需要保存哪些寄存器,以确保被调用者不会覆盖调用者在函数调用后将使用的任何值。这确保了两个函数可以访问它们所需的信息。
调用约定是更大规范的一部分,称为应用二进制接口(ABI),它使得由不同编译器构建的目标文件可以链接在一起。只要目标文件共享相同的 ABI,它们就能互操作。除了调用约定,ABI 还指定了不同 C 类型在内存中的表示方式,这在第二部分中非常重要。构成 ABI 的大多数其他细节——例如可执行文件格式——由汇编器、链接器和操作系统处理,因此我们不需要担心它们。
如果你的编译器遵循你平台上的调用约定,你就可以编译依赖于标准库以及你可能想要使用的任何其他库的程序。你将能够编译进行系统调用和执行 I/O 操作的程序。你仍然无法编译标准库本身——它依赖于我们尚未实现的各种语言特性——但由于它已经被编译并且存在于你的系统中,你可以链接到它。
每个类 Unix 系统都使用 System V ABI 中定义的标准调用约定。(该 ABI 以 Unix System V 命名,这是 Unix 的早期商业版本。)由于我们正在针对 macOS 和 Linux,因此我们将使用 System V 调用约定。System V ABI 对不同处理器架构有不同版本;我们将使用针对 x64 处理器的版本。Windows 有其自己的 ABI,我们不需要担心。如果你在 Windows Subsystem for Linux 上进行这个项目,你仍然可以使用 System V 调用约定。接下来,我们将了解这种调用约定是如何工作的。 #### 使用 System V ABI 调用函数
在上一节中,我列出了一些调用约定必须回答的问题。让我们看看 System V 调用约定如何回答这些问题,以及它所施加的其他要求:
参数传递
函数的前六个整数参数按照顺序通过 EDI、ESI、EDX、ECX、R8D 和 R9D 寄存器传递(64 位整数使用这些寄存器的 64 位名称传递:RDI、RSI、RDX、RCX、R8 和 R9)。其余的参数则以相反顺序推入栈中。例如,为了实现函数调用 foo(a, b, c, d, e, f, g, h),首先将变量 a 复制到 EDI 中,然后将 b 复制到 ESI 中,依此类推,直到 f。然后,将最后一个参数 h 推入栈中,最后将 g 推入栈中。
返回值
如我们所知,函数的返回值通过 EAX(或者如果是返回 64 位整数,则通过 RAX)传递。在执行 ret 指令时,返回值必须位于 EAX 中。
参数清理
被调用者返回后,调用者会从栈中移除所有参数。被调用者不会清理参数。
调用者保存和被调用者保存的寄存器
如果寄存器是 caller-saved,被调用者可以覆盖它。因此,调用者在发出 call 指令之前,必须将寄存器的值保存在栈中,以便稍后使用。然后,在函数返回后,调用者可以从栈中弹出该值。(如果寄存器中的值在函数调用后不再使用,调用者则无需保存它。)如果寄存器是 callee-saved,那么它在函数返回时必须与函数开始时的内容相同。如果被调用者需要使用该寄存器,通常会在函数前言中将寄存器的值推入栈中,然后在函数尾部将其弹出。寄存器 RAX、R10、R11 以及所有参数传递寄存器是 caller-saved;其余寄存器是 callee-saved。
栈对齐
System V ABI 要求栈必须是 16 字节对齐的。换句话说,当我们发出 call 指令时,存储在 RSP(栈指针)中的地址必须能够被 16 整除。ABI 强制要求这一点,因为某些指令需要 16 字节对齐的操作数。如果栈一开始就是 16 字节对齐的,那么更容易保持这些操作数的正确对齐。
你可以在 <wbr>gitlab<wbr>.com<wbr>/x86<wbr>-psABIs<wbr>/x86<wbr>-64<wbr>-ABI 上找到完整的 System V x64 ABI。然而,查看一个示例可能比阅读规范更有帮助。请参考 列表 9-25。
int fun(int a, int b, int c, int d, int e, int f, int g, int h) {
return a + h;
}
int caller(int arg) {
return arg + fun(1, 2, 3, 4, 5, 6, 7, 8);
}
列表 9-25:一个包含函数调用的 C 程序
列表 9-26 给出了 fun 的汇编代码。它比编译器生成的代码更为优化,以便更清楚地说明 System V 调用约定。
.globl fun
fun:
pushq %rbp
movq %rsp, %rbp
# copy first argument into EAX
movl %edi, %eax
# add last argument to EAX
addl 24(%rbp), %eax
# epilogue
movq %rbp, %rsp
popq %rbp
ret
列表 9-26:用于 fun 的汇编代码,见 列表 9-25
列表 9-27 给出了从 caller 调用 fun 的汇编代码。
# save RDI before function call
pushq %rdi
# fix stack alignment
subq $8, %rsp
# pass first six arguments in registers
movl $1, %edi
movl $2, %esi
movl $3, %edx
movl $4, %ecx
movl $5, %r8d
movl $6, %r9d
# pass last two arguments on the stack
pushq $8
pushq $7
# transfer control to fun
call fun
# restore the stack and RDI
addq $24, %rsp
popq %rdi
列表 9-27:调用 fun 的汇编代码,见 列表 9-25
让我们一步步地跟踪这个函数调用,看看程序状态如何在每一步发生变化。在接下来的图示中,左侧列显示了堆栈和通用寄存器的内容,右侧列显示了 RIP 的内容,RIP 始终保存着下一条要执行的指令的地址。(请注意,这些图中的指令地址并不真实。这些地址表明每条指令只有 1 字节长,但指令长度是变化的,通常超过一个字节!)
图 9-1 显示了在调用 fun 之前程序的初始状态。

图 9-1: Listing 9-25 中程序的初始状态 描述
在 图 9-1 中,RSP 和 RBP 指向相同的地址。caller 中没有局部变量,因此我们不需要分配任何堆栈空间。图中的寄存器都保存着 64 位的值,但我们通常会使用 32 位寄存器名称,如 EDI、ESI 和 EDX,因为我们所有的函数参数和返回值都是 32 位整数。然而,在保存到堆栈和从堆栈恢复时,我们将使用 64 位寄存器名称,因为 push 和 pop 需要 64 位操作数。
caller 的唯一参数 arg 是通过 RDI 传递的。假设 arg 的值是 15。为了调用 fun,我们需要根据 System V 调用约定传递所有八个参数。前六个参数将通过寄存器传递,最后两个参数将通过堆栈传递。但是,将第一个参数复制到 RDI 会覆盖掉 arg,而我们在函数调用后还需要它。因此,第一步,在传递任何参数之前,就是将 arg 保存到堆栈中:
# save RDI before function call
pushq %rdi
接下来,我们调整 RSP,使其在执行call指令时保持 16 字节对齐。我们需要从栈上放置的参数和保存的寄存器的数量开始倒推。在函数调用开始之前,我们可以假设栈指针是 16 的倍数。(为了保证这一点,我们将在函数前言中分配 16 字节的栈空间。)然后,我们将把一些寄存器和函数参数压入栈中;每个将占用 8 字节。如果压入栈的寄存器和参数总数是偶数,那么在添加所有这些内容后栈将保持 16 字节对齐。如果栈上的寄存器和参数数目是奇数,我们需要从栈指针中减去 8 字节,以获得正确的对齐方式。
在这个示例中,我们将一个寄存器 RDI 压入栈中。我们还需要将两个参数压入栈中,分别是g和h。在执行call指令之前,我们总共会将三个值压入栈中,总计 24 字节。因此,在保存 RDI 后,我们需要再调整栈 8 字节:
# fix stack alignment
subq $8, %rsp
现在我们准备好设置fun的参数了。我们从前六个参数开始,它们将通过寄存器传递。因为这些参数都是 32 位整数,所以我们将在这里使用 32 位寄存器名称:
# pass first six arguments in registers
movl $1, %edi
movl $2, %esi
movl $3, %edx
movl $4, %ecx
movl $5, %r8d
movl $6, %r9d
接下来,我们将剩余的两个参数按逆序压入栈中:
# pass last two arguments on the stack
pushq $8
pushq $7
每条指令都会将一个 64 位常量压入栈中,因为push指令只能压入 64 位值。图 9-2 显示了在我们保存 RDI、调整栈并设置函数参数后程序的状态。

图 9-2:调用指令之前程序的状态 描述
你可以看出,栈确实是 16 字节对齐的,因为栈指针可以被 16 整除(或者是 16 进制的0x10)。一旦我们的参数设置好,我们就可以通过call汇编指令调用fun:
# transfer control to fun
call fun
call指令做了两件事。首先,它将紧随其后的指令地址,即返回地址,压入栈中。然后,它通过将该指令的地址复制到 RIP 中,将控制权转移到标记为fun的指令。图 9-3 显示了在执行call指令后,程序的状态。

图 9-3:调用指令执行后的程序状态 描述
我们已经熟悉的函数序言为 fun 设置了栈帧,这使得程序进入了 图 9-4 所示的状态。

图 9-4:函数序言执行后 fun 的程序状态 描述
在这个图中,带有白色背景的部分是 fun 的栈帧。带有浅灰色背景的部分是 caller 的栈帧。在 fun 中,我们需要计算 a + h。这要求我们访问通过寄存器传入的一个参数(a)和通过栈传入的另一个参数(h)。fun 中的下一条指令将 a 的值复制到 EAX 寄存器:
# copy first argument into EAX
movl %edi, %eax
接下来,我们希望将通过栈传入的 h 加到 EAX 寄存器中的值上。栈参数,就像局部变量一样,可以相对于 RBP 来访问。我们知道 RBP 指向包含调用者栈帧基地址的栈槽。就在它下面的栈槽,即 8(%rbp),包含了调用者的返回地址。紧接着下面的值,即 16(%rbp),将是第一个栈参数 g。(记住,我们是按逆序推入栈的。这意味着第一个栈参数 g 最后推入栈,因此它现在离当前栈帧最近。)下一个参数 h 将位于它下面 8 字节的位置,即 24(%rbp),我们可以相应地访问它:
# add last argument to EAX
addl 24(%rbp), %eax
我们将一个 64 位常量8压入栈中,但addl需要一个 32 位操作数。因此,它将从24(%rbp)开始的 4 个字节解释为一个 32 位整数,从而有效地丢弃了高 32 位。由于这些高 32 位只是前导零,结果值仍然是8。也就是说,尽管每个压入栈的参数必须是 64 位,但我们仍然可以在被调用者中将它们解释为 32 位整数。
此时,EAX 中存储着正确的返回值。我们已经准备好进行函数尾处理:
# epilogue
movq %rbp, %rsp
popq %rbp
请注意,movq指令在这个特定的程序中并不必要。通常,这条指令会释放当前的栈帧,将 RBP 的旧值放回栈顶。但我们没有为fun分配任何栈空间,因此 RSP 和 RBP 已经有相同的值。
函数尾处理将栈恢复到与函数开始之前相同的状态。图 9-5 显示了此时的程序状态。

图 9-5:返回给调用者之前的程序状态 描述
我们通过ret指令返回给调用者,该指令从栈中弹出返回地址,并将控制权转移到该地址。图 9-6 显示了我们返回给调用者后程序的状态。

图 9-6:返回给调用者之后的程序状态 描述
此时,栈的状态与call指令之前完全相同。最后一步是清理填充和栈参数,并将arg恢复到 RDI:
# restore the stack and RDI
addq $24, %rsp
popq %rdi
现在,栈恢复到函数调用之前的状态,RDI 也已恢复到原始状态。RAX 寄存器包含返回值,我们可以在函数体中稍后使用。由于其他寄存器在调用fun之前没有被初始化,因此我们现在不需要清理它们。图 9-7 显示了我们在清理完函数调用后程序的状态。

图 9-7:函数调用完成后的程序状态 描述
到此为止,你应该清楚地理解了如何在汇编中调用函数并访问函数参数。现在我们准备更新汇编生成阶段。
将函数调用和定义转换为汇编
现在,我们将第一次扩展我们的汇编 AST,距离第四章已经有一段时间了。清单 9-28 定义了新的 AST,并且有变化的部分已加粗。
program = Program(**function_definition***)
function_definition = Function(identifier name, instruction* instructions)
instruction = Mov(operand src, operand dst)
| Unary(unary_operator, operand)
| Binary(binary_operator, operand, operand)
| Cmp(operand, operand)
| Idiv(operand)
| Cdq
| Jmp(identifier)
| JmpCC(cond_code, identifier)
| SetCC(cond_code, operand)
| Label(identifier)
| AllocateStack(int)
**| DeallocateStack(int)**
**| Push(operand)**
**| Call(identifier)**
| Ret
unary_operator = Neg | Not
binary_operator = Add | Sub | Mult
operand = Imm(int) | Reg(reg) | Pseudo(identifier) | Stack(int)
cond_code = E | NE | G | GE | L | LE
reg = AX | **CX |** DX | **DI | SI | R8 | R9 |** R10 | R11
清单 9-28:支持函数调用的汇编 AST
首先,我们将顶层定义的 Program 修改为支持多个函数定义。我们还引入了三条新指令。为了在函数调用之前调整栈对齐,我们可以使用已经存在的 AllocateStack 指令,它最终会转换为 subq 指令。为了在函数调用后移除参数和填充内容,我们添加了一个相应的 DeallocateStack 指令,它会被转换为 addq。我们还需要 Push 指令来将参数压入栈中。我们已经在函数序言中使用过 push,但那时它的使用非常有限,可以在代码生成时机械地添加它。现在我们将更加广泛地使用它,因此需要将其添加到汇编 AST 中。当然,我们还需要 Call 指令来实际调用函数。最后,我们需要一些新的寄存器来传递参数:CX、DI、SI、R8 和 R9。参数还可以通过 DX 寄存器传递,这在我们的 AST 中已经存在。像前几章一样,AST 不区分每个寄存器的不同别名:例如,DI 会被转换为 %rdi、%edi 或 %dil,具体取决于我们是想使用整个寄存器、其低 4 个字节,还是最低的一个字节。
在这些对汇编抽象语法树(AST)的修改完成后,我们可以更新 TACKY 到汇编的转换。记住,我们对 TACKY IR 做了三处更改:我们将程序定义为一个函数列表,而不是一个单一的函数,向每个函数定义中添加了参数,并且添加了一个 FunCall 指令。处理第一个更改是直接的:我们将 TACKY 中的函数列表转换为汇编中的函数列表。接下来,我们将使函数参数在汇编中可访问。然后,我们将看到如何将新的 FunCall 指令转换为汇编代码。
汇编中访问函数参数
在函数开始时,每个参数都会存储在由我们的调用约定所指定的寄存器或栈位置中。我们可以通过直接引用这些位置来在汇编代码中访问函数参数。列表 9-26 中的 fun 的汇编代码采取了这种方法;当我们需要添加参数 a 和 h 时,我们直接引用它们的调用约定所定义的位置,%edi 和 24(%rbp)。这种方式有效,但有一些缺点。它要求我们在函数调用之前将参数传递寄存器压入栈中,之后再将其弹出,就像我们在 列表 9-27 中对 fun 调用前后必须压入和弹出 RDI 一样。它还可能导致与使用参数传递寄存器的其他指令发生冲突。例如,idiv 指令会写入 EDX 寄存器,可能会破坏存储在该寄存器中的函数参数。最后,它使得伪寄存器分配阶段变得更加复杂,因为参数必须与局部变量区别开来处理。
我们将采用一种不同的方法,绕过这些问题:在每个函数体的开始,我们会将每个参数从调用约定所定义的寄存器或内存地址复制到当前函数栈帧中的一个位置。我们来看一个简单的例子。列表 9-29 定义了一个有一个参数的函数。
int simple(int param) {
return param;
}
列表 9-29:一个带有单一参数的函数
当我们为这个函数生成汇编代码时,我们将在函数体的开始包含一个额外的 Mov 指令:
Mov(Reg(DI), Pseudo("param"))
这条指令将函数的第一个参数复制到param伪寄存器中。请记住,在 TACKY 中,任何对Var("param")的使用都会被转换为对汇编中Pseudo("param")的使用。
整个函数生成的汇编代码看起来像这样:
Mov(Reg(DI), Pseudo("param"))
Mov(Pseudo("param"), Reg(AX))
Ret
(事实上,param会在标识符解析期间被重命名,而且在 TACKY 生成过程中我们会发出额外的Return(0)指令,但这两个细节对于本示例来说并不重要。)
我们将按照通常的方式将伪寄存器替换为栈位置。由于param是我们唯一的伪寄存器,我们将其分配给Stack(-4)。最终我们将发出在 Listing 9-30 中显示的汇编程序。
.globl simple
simple:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movl %edi, -4(%rbp)
movl -4(%rbp), %eax
movq %rbp, %rsp
popq %rbp
ret
Listing 9-30: Listing 9-29 的汇编程序
将参数复制到栈上使得代码生成保持简单。我们不需要在函数调用之前保存调用者保存的寄存器,或者在调用后恢复它们,因为我们只在非常短暂的时间内使用这些寄存器。当我们通过寄存器传递函数参数时,我们会立即将它们保存到栈上,而不是长时间将它们保存在寄存器中。除了函数参数外,我们仅在调用者保存的寄存器中存储返回值、idiv指令的结果以及在指令重写阶段临时复制到 R10D 或 R11D 中的值。就像参数一样,这些值要么会立即使用,要么会复制到栈中。结果是,调用者保存的寄存器中的值不需要在函数调用之间保持持久化;这就是为什么我们不需要保存或恢复它们的原因。(同时,callee 不需要保存或恢复大多数 callee 保存的寄存器,因为我们根本不使用它们。唯一的例外是 RBP 和 RSP 寄存器,我们会在函数前言和尾声中保存和恢复它们。)
同样地,我们不需要担心idiv指令会破坏 EDX 中的参数。我们也不需要任何额外的逻辑来处理伪寄存器分配过程中的函数参数:我们可以像处理局部变量一样将它们分配到栈位置。
另一方面,将参数复制到堆栈上效率低下。首先,我们生成了额外的mov指令。其次,我们强迫程序每次读写参数时访问内存,而访问内存通常比寄存器要慢。幸运的是,当我们在第三部分实现寄存器分配时,能够消除大部分这些额外的指令和内存访问。
当你生成这些参数复制指令时,从将第一个参数从Reg(DI)移到伪寄存器开始,第二个参数从Reg(SI)移到伪寄存器,以此类推,直到第六个参数(或者如果函数参数少于六个,则按实际参数个数)。然后,从Stack(16)复制第七个参数,从Stack(24)复制第八个参数,以此类推,直到处理完所有参数。正如我们之前看到的,调用者堆栈框架的顶部8(%rbp)是返回地址,第七个参数——第一个通过堆栈传递的参数——总是在其下方,位于16(%rbp)。从那里开始,每个额外参数的偏移量增加 8 字节,因为调用者将它们作为 8 字节的值推送到堆栈上(尽管被调用者将它们解释为 4 字节的值)。
实现 FunCall
之前,我们走过了函数调用的汇编代码。现在让我们看看如何生成这些汇编代码。列表 9-31 给出了将FunCall TACKY 指令转换为汇编的伪代码。
convert_function_call(FunCall(fun_name, args, dst)):
arg_registers = [DI, SI, DX, CX, R8, R9]
// adjust stack alignment
register_args, stack_args = first 6 args, remaining args
if length(stack_args) is odd:
stack_padding = 8
else:
stack_padding = 0
if stack_padding != 0:
emit(AllocateStack(stack_padding))
// pass args in registers
reg_index = 0
for tacky_arg in register_args:
r = arg_registers[reg_index]
assembly_arg = convert_val(tacky_arg)
emit(Mov(assembly_arg, Reg(r)))
reg_index += 1
// pass args on stack
for tacky_arg in reverse(stack_args):
assembly_arg = convert_val(tacky_arg)
if assembly_arg is a Reg or Imm operand:
❶ emit(Push(assembly_arg))
else:
❷ emit(Mov(assembly_arg, Reg(AX)))
emit(Push(Reg(AX)))
// emit call instruction
emit(Call(fun_name))
// adjust stack pointer
bytes_to_remove = 8 * length(stack_args) + stack_padding
if bytes_to_remove != 0:
emit(DeallocateStack(bytes_to_remove))
// retrieve return value
assembly_dst = convert_val(dst)
emit(Mov(Reg(AX), assembly_dst))
列表 9-31:为函数调用生成汇编代码
第一步是确保堆栈正确对齐。我们必须在将参数传递到堆栈之前做这件事;如果在参数和被调用者的堆栈框架之间添加额外的填充,调用者将无法找到它们。当我们在列表 9-27 中走过函数调用时,我们看到如果将偶数个参数和调用者保存的寄存器推送到堆栈,之后堆栈仍然是 16 字节对齐的——不需要填充。如果推送的是奇数个,我们需要从堆栈指针中减去额外的 8 字节以保持正确的对齐。现在,感谢上一节中的参数复制技巧,我们只需考虑推送到堆栈上的参数,而不需要考虑调用者保存的寄存器。因此,我们只需检查将要推送到堆栈上的参数数量,如果是奇数,则生成一个AllocateStack指令。
接下来,我们传递函数参数。在处理每个参数时,我们通过 convert_val 辅助函数将其从 TACKY 值转换为汇编操作数。(我省略了 convert_val 的伪代码,因为你已经知道如何执行此转换。)前六个参数被复制到适当的寄存器中。当然,一个函数可能有少于六个参数;在这种情况下,我们将每个参数复制到一个寄存器中。
如果函数有超过六个参数,剩余的参数必须通过栈传递。我们先推送最后一个参数,再推送倒数第二个,依此类推,直到第七个参数。请记住,我们的参数是 4 字节整数,但我们需要为每个参数推送 8 字节到栈上(因为 ABI 要求这样做,而且因为 pushq 只接受 8 字节操作数)。然而,被调用函数只会使用每个参数的低 4 字节。如果某个参数是一个 Reg 或 Imm 操作数,我们通过单条 Push 指令传递它❶。如果它在内存中,我们先将参数复制到 AX 寄存器中,然后再将其推送❷。使用像 pushq $7 这样的立即数指令会将该值的 8 字节表示推送到栈中。推送一个 Reg 操作数会推送整个 8 字节寄存器,我们可以使用相应的 4 字节别名来访问其低 4 字节。(代码生成阶段将使用像 %eax 这样的 4 字节寄存器别名在大多数指令中,包括 movl,以及像 %rax 这样的 8 字节别名在 pushq 指令中。)
如果我们在指令中直接使用一个 4 字节的内存操作数,如 pushq -4(%rbp),我们会先将 4 字节的操作数压入栈中,然后再跟着压入 4 字节的内存后续内容。这通常没问题,虽然有点笨拙。但如果操作数后面的 4 字节不是可读的内存,尝试访问这些字节会触发段错误,从而导致程序崩溃。这个问题在我们从栈中压入操作数时不会发生;其后的字节要么保存当前函数的临时值,要么是调用者栈帧的基址。但是,当我们压入静态变量时,这个问题可能会出现,我们将在第十章中实现这一部分。静态变量可能出现在有效内存区域的末尾;在这种情况下,变量后面的内存地址可能是无效的。(你可以在 Randall Hyde 的 The Art of 64-Bit Assembly, Volume 1 [No Starch Press, 2021] 中了解更多这个边缘情况;参见 5.5.3.3 节,“栈上传递参数”)。通过在将操作数压入栈之前将其从内存复制到寄存器,我们可以避免这个问题。请注意,AX 是唯一可以帮助我们将内存操作数压入栈的寄存器,因为我们必须保留被调用者保存的寄存器,参数已经存放在传递参数的寄存器中,并且我们已将 R10 和 R11 保留用于指令修复阶段。
一旦每个参数都准备好,我们就发出 call 指令,将控制权转交给被调用者。调用返回后,我们不再需要栈上传递的参数,也当然不需要填充。我们使用 DeallocateStack 指令,将这些参数和填充的总大小加到栈指针上。释放这些空间后,栈指针会恢复到准备函数调用之前的位置。
最后,我们检索函数的返回值。这个值将保存在 EAX 中,我们通过 mov 指令将其复制到目标位置。
表 9-1 和 9-2 总结了本章将 TACKY 转换为汇编的变化。新构造和现有构造的修改已加粗显示。
表 9-1: 将顶层 TACKY 构造转换为汇编
| TACKY 顶层构造 | 汇编顶层构造 |
|---|---|
| Program(function_definitions) | Program(function_definitions) |
| Function(name, params, instructions) |
Function(name,
[Mov(Reg(DI), param1),
Mov(Reg(SI), param2),
<copy next four parameters from registers>,
Mov(Stack(16), param7),
Mov(Stack(24), param8),
<copy remaining parameters from stack>] +
instructions)
|
表 9-2: 将 TACKY 指令转换为汇编
| TACKY 指令 | 汇编指令 |
|---|---|
| FunCall(fun_name, args, dst) |
<fix stack alignment>
<set up arguments>
Call(fun_name)
<deallocate arguments/padding>
Mov(Reg(AX), dst)
|
函数调用的汇编太复杂,无法完全在表格中指定,因此表 9-2 中关于
替换伪寄存器
接下来,我们将更新伪寄存器替换过程。这里的大部分逻辑不会改变:我们将以与过去章节相同的方式,在每个函数定义中替换伪寄存器。正如我们之前所看到的,代表函数参数的伪寄存器不需要任何特殊处理。它们将像局部变量一样分配栈上的位置。
然而,我们需要进行几个更新。首先,我们将扩展此过程以替换新
请注意,参数会计入函数的栈大小,无论它们是通过栈传递还是通过寄存器传递,因为我们将它们复制到函数的栈帧中。
在指令修复过程中分配栈空间
我们需要对指令修正阶段做一个小调整:我们将更改添加 AllocateStack 到每个函数定义的方式。首先,我们将查找每个函数所需的堆栈空间,查找的位置是我们在伪寄存器替换过程中记录的位置。接下来,我们将把堆栈大小四舍五入到下一个 16 的倍数。将堆栈帧的大小四舍五入,使得在函数调用期间更容易保持正确的堆栈对齐。
代码输出
现在我们需要确保代码输出阶段能够处理我们所有的新指令和操作数。大部分内容都很简单直接,但也有一些特定平台的细节需要考虑。正如我们已经看到的,macOS 上的函数名会以下划线作为前缀,而 Linux 上则不会。这一点在 call 指令中也适用,因此在 macOS 上你将输出:
call _foo
在 Linux 上,你将输出:
call foo
在 Linux 上,你还会以不同的方式调用外部库中的函数,和调用同一文件中定义的函数不同。如果 foo 没有在当前翻译单元中定义,你将输出:
call foo@PLT
PLT 代表过程链接表,这是 ELF 可执行文件中的一个部分。(ELF,全称 Executable and Linkable Format,是 Linux 和大多数类 Unix 系统中对象文件和可执行文件的标准格式;macOS 使用一种名为 Mach-O 的不同文件格式。) 程序使用 PLT 来调用共享库中的函数。我们已经学习过,链接器将对象文件合并并解析符号,定位到内存中的具体位置,从而生成可执行文件。在现代系统中,这些位置通常编码为相对于当前指令的偏移量,而不是绝对的内存地址。当我们在同一个可执行文件中定义并使用符号时,链接器可以根据使用该符号的指令计算出符号的相对偏移量,并解决该引用。
共享库是另一个故事。当程序使用共享库时,链接器不会将整个库复制到可执行文件中。相反,库会在运行时被单独加载到内存中。链接器并不知道这个库在内存中的确切位置,因此它无法解析共享库函数的名称。另一个软件部分,叫做动态链接器,必须在运行时解析这些名称。动态链接器可以通过几种不同的方式来解析符号,但最常见的方法是懒惰绑定。使用懒惰绑定时,直到程序尝试调用某个函数时,我们才会确定该函数的地址。这时,PLT(过程链接表)就派上用场了。操作数foo@PLT并不指代函数foo。它指向 PLT 中的一小段代码,这段代码会在我们还不知道时确定foo的地址,然后调用foo。链接器负责生成这段代码,这段代码称为PLT 条目。
如果foo在当前翻译单元中未定义,它可能在共享库中定义,或者在链接器会包含到最终可执行文件中的另一个目标文件中。在后一种情况下,我们不需要 PLT:链接器能够确定foo的地址(或者,更准确地说,它的偏移量相对于引用它的call指令)。代码生成阶段无法区分这两种情况,因此它应该无论如何都包括@PLT后缀;即使我们不需要它,包含这个后缀也不会有害。
注意
关于 PLT 如何工作以及为什么需要它的更深入解释,请参阅第一章“附加资源”中的两个关于位置无关代码的博客文章,位于第 21 页。
在 Linux 上,要检查一个函数是否在当前翻译单元中定义——因此它是否需要@PLT修饰符——你需要在符号表中查找它。在 macOS 上,由于懒惰绑定的处理方式略有不同,你根本不需要@PLT修饰符。
表 9-3 至 9-5 展示了本章中代码生成阶段的更改,新增的构造和更新的现有构造已用粗体标出。
表 9-3: 格式化顶级汇编构造
| 汇编顶层结构 | 输出 |
|---|---|
| 程序(函数定义) |
Print out each function definition.
On Linux, add at end of file:
.section .note.GNU-stack,"",@progbits
|
表 9-4: 格式化汇编指令
| 汇编指令 | 输出 |
|---|---|
| 释放栈空间(int) |
addq $<int>, %rsp
|
| 推送(操作数) |
|---|
pushq <operand>
|
| 调用(label) |
|---|
call <label>
or
call <label>@PLT
|
表 9-5: 格式化汇编操作数
| 汇编操作数 | 输出 |
|---|---|
| 寄存器(AX) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| 寄存器(DX) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| 寄存器(CX) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| 寄存器(DI) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| 寄存器(SI) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(R8) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(R9) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(R10) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(R11) | 8 字节 |
| 4 字节 | |
| 1 字节 |
现在,我们为每个寄存器都有了 8 字节、4 字节和 1 字节的名称。在push指令中,我们将使用 8 字节寄存器名称,在条件设置指令中使用 1 字节名称,其它地方使用 4 字节名称。
调用库函数
一旦你更新了编译器的后端,你将能够编译调用标准库函数的程序。你将无法使用#include指令,因为任何标准库头文件都会使用编译器不支持的语言特性。相反,你需要显式声明任何你想要使用的库函数。
到目前为止,我们可以调用的库函数不多。因为我们实现的唯一类型是int,所以我们不能调用任何使用非int类型作为返回类型或参数类型的函数。但我们可以调用putchar,它接受一个int参数,并将对应的 ASCII 字符打印到标准输出。这足够让我们编译清单 9-32,这是一个稍微不正统的“Hello, World!”实现。
int putchar(int c);
int main(void) {
putchar(72);
putchar(101);
putchar(108);
putchar(108);
putchar(111);
putchar(44);
putchar(32);
putchar(87);
putchar(111);
putchar(114);
putchar(108);
putchar(100);
putchar(33);
putchar(10);
}
清单 9-32:Hello, World!
尝试使用你的编译器编译清单 9-32 并运行它。如果你正确实现了所有内容,它将写入标准输出:
$ **./hello_world**
Hello, World!
这是一个重大里程碑!在运行剩余的测试用例之前,先花点时间享受一下你的成就感。
总结
函数调用是我们迄今为止见过的最强大、最复杂的特性。为了实现它们,你扩展了语义分析阶段,以理解不同种类的标识符,并学会了 System V 调用约定的细节。所有这些工作都得到了回报:你终于可以编译与外部世界交互的程序了!
你还为其他语言特性奠定了基础。当你在下一章(第一部分的最后一章)实现文件作用域变量和存储类说明符时,你将扩展标识符链接的概念,并基于标识符解析阶段的最新变化。随着第二部分中更多类型的加入,你还将继续扩展类型检查器。

描述
第十章:10 文件作用域变量声明与存储类说明符

我们将在第一部分结束时实现一些与函数和变量声明相关的重要特性。我们将支持文件作用域中的变量声明——即在源文件的最顶层——并引入关键字 static 和 extern。这些关键字是存储类说明符,用于控制声明的链接性以及声明对象的存储持续时间(即该对象在内存中存在的时间)。
本章的大部分内容将集中在语义分析阶段,确定每个声明的链接性和存储持续时间。我们还需要一些新的汇编指令来定义和初始化不同类型的变量,但编译器后端的变化相对简单。让我们首先回顾一下 C 标准对声明和存储类说明符的规定。即使你已经对 C 语言很熟悉,我也建议阅读以下内容。对编译器开发者来说,语言的这一部分与 C 程序员所理解的非常不同,主要是因为你的编译器需要支持一些理智的 C 程序员不会使用的行为。
关于声明的一切
每个源文件中的声明都有几个我们需要追踪的属性,我们将在本节中逐一分析。这些属性包括声明的作用域、链接性以及它是否同时是定义和声明。(它的类型也很重要,但在本章中我们不会再讨论这个。)我们还需要追踪程序中每个变量的存储持续时间。
确定这些属性的规则复杂且繁琐。它们取决于标识符是引用函数还是变量,是否在文件范围或块范围(函数体内)声明,以及应用了哪种存储类说明符。static说明符有两种不同的含义,适用于不同的上下文。extern说明符有多种看似不相关的效果,这些效果也取决于上下文。(其他存储类说明符——auto、register、_Thread_local和typedef——用于不同的目的,我这里就不展开讨论了。我们不会实现这些。)基本上,这部分 C 标准很混乱,但我们会尽力理清其中的内容。
C 语言中关于声明的术语可能不一致,因此在开始之前,我会先说明我使用的一些术语:
-
文件或源文件是经过预处理的源文件,在 C 标准中(以及前一章)被称为“翻译单元”。
-
静态变量是具有静态存储期的变量(在“存储期”部分讨论过,见第 212 页),不仅仅是使用static存储类说明符声明的变量。所有带有static说明符的变量都是静态变量,但并非所有静态变量都是使用该说明符声明的。
-
自动变量是具有自动存储期的变量(也在“存储期”中讨论),与静态存储期的变量相对。我们在之前章节中遇到的所有变量都是自动变量。
-
外部变量是具有内部或外部链接性的任何变量,而不仅仅是使用extern存储类说明符声明的变量。正如我们将看到的,所有外部变量也是静态变量,但并非所有静态变量都是外部变量。
作用域
函数和变量遵循相同的作用域规则。变量可以在文件范围或块范围内声明,就像函数一样。文件范围的变量与函数和块范围变量一样,必须在使用之前声明,并且可能会被后来的块范围标识符覆盖。由于你已经了解了确定标识符作用域的规则,因此这里无需再多说。
链接性
到目前为止,函数声明总是具有外部链接:每个特定函数名的声明都指向同一个函数定义。我们到目前为止看到的局部变量声明没有链接:同一个变量名的不同声明总是指向不同的对象。默认情况下,文件范围内的变量声明具有外部链接,就像函数声明一样。每当有多个文件范围的相同标识符声明时,编译器需要要么协调它们,使它们都指向同一个实体,要么抛出错误。
使用 static 说明符,我们还可以声明具有 内部链接 的函数和变量。内部链接的工作方式与外部链接相似,只不过具有内部链接的声明永远不会引用其他文件中的实体。为了说明这种区别,我们考虑一个由两个源文件组成的程序。列表 10-1 显示了第一个文件。
❶ int foo(void) {
return 1;
}
❷ int bar(void) {
return 2;
}
列表 10-1:定义两个具有外部链接的函数的源文件
列表 10-2 显示了第二个文件。
❸ int foo(void);
❹ static int bar(void);
int main(void) {
return foo() + bar();
}
❺ static int bar(void) {
return 4;
}
列表 10-2:声明一个具有内部链接(bar)和两个具有外部链接(foo 和 main)的源文件
在列表 10-1 中,我们定义了两个具有外部链接的函数:foo ❶ 和 bar ❷。列表 10-2 还包括了标识符 foo ❸ 和 bar ❹❺ 的声明。首先,让我们弄清楚在列表 10-2 中,foo意味着什么。因为❸处的声明没有包含 static 说明符,它具有外部链接。因此,声明 ❶ 和 ❸ 指向同一个函数,该函数在 ❶ 处定义。
接下来,让我们考虑 bar。由于在❹中的声明包括了 static 说明符,它具有内部链接性。这意味着它并不引用❷处的定义,而是声明了一个全新的函数。该函数的定义出现在后面的❺处。由于❹和❺的声明都具有内部链接性并且出现在同一个文件中,它们指向同一个函数。因此,main 将使用❺处 bar 的定义来计算 1 + 4,并返回 5。
请注意,具有内部链接性的标识符不会覆盖具有外部链接性的标识符,反之亦然。在清单 10-2 中声明的 bar 并没有覆盖❷处在清单 10-1 中的定义;实际上,该定义在清单 10-2 中本来就不可见,因为清单 10-2 中的声明没有引用它。如果一个标识符在同一个文件中同时声明为内部链接性和外部链接性,其行为是未定义的,大多数编译器会抛出错误。
C 标准第 6.2.2 节列出了确定标识符链接性的规则,我将在这里总结一下。声明的链接性取决于两个因素:它包含的存储类说明符(如果有的话),以及它是在块作用域还是文件作用域中声明的。没有存储类说明符的函数声明总是被当作包含 extern 说明符来处理,我们稍后会讨论。如果没有存储类说明符的变量声明出现在块作用域中,它们没有链接性。如果它们出现在文件作用域中,则具有外部链接性。
在文件作用域中,static 说明符表示函数或变量具有内部链接。在块作用域中,static 说明符控制存储持续时间,而不是链接性。使用此说明符在块作用域中声明的变量没有链接性,和没有说明符声明的变量一样。块作用域中声明static 函数是非法的,因为函数没有存储持续时间。
extern 修饰符更加复杂。如果一个标识符在某个地方声明为 extern,且该标识符的先前声明可见,且先前声明具有内部或外部链接性,那么新的声明将与先前的声明具有相同的链接性。如果没有可见的先前声明,或者先前的声明没有链接性,那么 extern 声明将具有外部链接性。
在示例 10-3 中,我们使用 extern 来声明一个已经可见的标识符。
static int a;
extern int a;
示例 10-3:当先前的声明可见时使用 extern 声明一个标识符
a 的第一次声明由于使用了 static 关键字,所以具有内部链接性。由于第二次声明是在第一次声明可见的地方使用 extern 关键字,因此它也会具有内部链接性。
而在示例 10-4 中,我们在没有任何先前声明可见的地方使用 extern。
int main(void) {
extern int a;
return a;
}
int a = 5;
示例 10-4:使用 extern 在块作用域内声明一个具有外部链接性的变量
在 main 中的 a 声明和文件后面的 a 定义都具有外部链接性,因此它们指向同一个对象。因此,main 会返回 5。
如果一个具有外部链接性的变量被局部变量遮蔽,你可以使用 extern 将其重新引入作用域。示例 10-5 展示了这个过程是如何工作的。
int a = 4;
int main(void) {
int a = 3;
{
❶ extern int a;
return a;
}
}
示例 10-5:使用 extern 将具有外部链接的变量重新引入作用域
当我们在 main ❶ 中用 extern 说明符声明 a 时,之前没有任何带有内部或外部链接性的声明可见。(a 的初始文件作用域声明具有外部链接性,但被第二个在块作用域中的声明隐藏了。块作用域声明是可见的,但没有链接性。)因此,这个 extern 声明具有外部链接性。由于之前文件作用域中的 a 也具有外部链接性,因此两个声明引用的是同一个变量。接下来,我们在下一行的 return 语句中使用了这个变量。结果,main 返回 4。
之前,我提到过没有存储类说明符的函数声明总是被视为包含了 extern 说明符。考虑一下这个规则如何影响示例 10-6 中函数定义的链接性。
static int my_fun(void);
int my_fun(void) {
return 0;
}
示例 10-6:带有 static 说明符的函数声明,后跟一个没有存储类说明符的函数定义
正如我们在示例 10-3 中看到的,带有 extern 说明符的声明与该标识符的前一个声明具有相同的链接性,如果该声明可见的话。由于我们将 my_fun 的定义视为带有 extern 说明符,它将与前一行的声明具有相同的链接性;也就是说,具有内部链接性。这个规则意味着,在函数声明中包括 extern 始终是多余的(除非是内联函数,我们不会实现)。
接下来,我们将考虑本章的新概念:存储持续时间。
存储持续时间
存储持续时间是变量的一个属性;函数没有存储持续时间。C 标准第 6.2.4 节,第 1 至第 2 段提供了如下描述:“一个对象具有存储持续时间,决定了它的生命周期……一个对象的生命周期是程序执行过程中保证为其保留存储空间的那部分时间。在它的生命周期内,一个对象存在,具有恒定的地址,并保持其最后存储的值。”换句话说,在一个对象的生命周期内,你可以像平常一样使用它:你可以写入它、读取它,并得到你最后写入的值。在此期间,对象不会被释放或重新初始化。
在本章中,我们将讨论两种类型的存储持续时间:自动和静态。我们在前几章看到的所有变量都有自动存储持续时间。具有自动存储持续时间的变量的生命周期从进入声明它的代码块时开始,到退出该代码块时结束。这意味着你不能使用自动变量来跟踪一个函数被调用的次数。例如,要理解为什么,看看清单 10-7,它正是尝试这么做的。
#include <stdio.h>
int recursive_call(int count_was_initialized) {
int count;
if(!count_was_initialized) {
count = 0;
count_was_initialized = 1;
}
count = count + 1;
printf("This function has been called %d times\n", count);
if (count < 20) {
recursive_call(count_was_initialized);
}
return 0;
}
清单 10-7:错误的尝试在多个函数调用之间共享自动变量的值
清单 10-7 中的 recursive_call 函数试图在第一次调用时初始化一个局部变量 count,然后在每次后续调用时递增它。这是行不通的,因为 count 具有自动存储持续时间;每次调用 recursive_call 都会分配一个新的、未初始化的 count 副本,且当该调用返回时,这个副本会被释放。
另一方面,如果一个变量具有静态存储持续时间,那么它的生命周期将持续整个程序的执行时间。具有静态存储持续时间的变量会在程序开始之前初始化一次,并在程序退出时结束其生命周期。
确定存储持续时间的规则很简单:所有在文件作用域声明的变量都具有静态存储持续时间,所有在块作用域中声明并带有 static 或 extern 关键字的变量也具有静态存储持续时间。所有在块作用域中声明且没有存储类说明符的变量具有自动存储持续时间。标准还定义了分配存储持续时间,我们将在第二部分中讨论它,另外还有线程存储持续时间,但本书中我们不会实现它。
我们可以使用静态计数器来修复清单 10-7。清单 10-8 显示了正确实现的recursive_call。
#include <stdio.h>
int recursive_call(void) {
❶ static int count = 0;
count = count + 1;
printf("This function has been called %d times\n", count);
if (count < 20) {
recursive_call();
}
return 0;
}
清单 10-8:在多个函数调用之间正确共享静态变量的值
现在,由于count是使用static关键字声明的❶,它具有静态存储持续时间。我们将在程序开始前,只分配一次count并将其初始化为0。然后,在每次调用recursive_call时,我们将递增这个相同的count变量。
当我们进入recursive_call中的声明时,我们不会再次初始化count。声明标志着变量引入作用域的地方,而不是它在执行时初始化的地方。重要的是要理解,静态变量的作用域和生命周期是无关的。在第七章中,我将变量的作用域描述为程序中可以使用它的部分。现在我们需要细化这个定义,明确指出它是程序的源代码中可以使用变量的部分。而变量的生命周期是程序执行期间,变量有地址和值的部分。对于自动变量,作用域和生命周期是如此紧密相连,以至于这种区别几乎无关紧要:变量的生命周期从你开始执行它所在作用域的代码块时开始,执行完该代码块时结束。但静态变量的生命周期独立于它的作用域。例如,在清单 10-8 中,count的生命周期持续到程序的整个执行过程,但它的作用域仅从在recursive_call中声明的地方开始,直到函数结束。
由于静态变量在启动前就会初始化,因此它们的初始化器必须是常量。清单 10-9 显示了两个文件作用域声明,其中一个具有无效的初始化器。
int first_var = 3;
int second_var = first_var + 1;
清单 10-9:文件作用域变量声明,带有效和无效初始化器
first_var和second_var都有静态存储持续时间,因为它们是在文件作用域内声明的。first_var的初始化器是有效的,因为它是常量。然而,second_var的初始化器是无效的,因为你无法在程序开始之前计算像first_var + 1这样的表达式。
注意
C 标准允许用常量表达式初始化静态变量,比如 1 + 1,因为这些可以在编译时计算出来。为了让我们的工作稍微轻松一点,我们的编译器将只支持常量值作为初始化器,而不支持常量表达式。
定义与声明
在上一章中,我们需要区分函数定义和函数声明。在本章中,我们将把这种区分扩展到变量上。如果一个变量被定义,我们的汇编程序需要为其分配存储空间,并可能对其进行初始化。如果它被声明但没有定义,我们则不会为它分配存储空间;我们会依赖链接器在另一个目标文件中找到其定义。就像函数一样,变量可以声明多次,但只能定义一次。
识别函数定义很容易,因为它们有函数体。弄清楚什么算作变量定义则稍微复杂一些。让我们通过规则来了解哪些变量声明也是定义,哪些不是。我们还将讨论如何(以及何时)初始化没有显式初始化器的定义变量。
首先,每个带有初始化器的变量声明都是一个定义。这并不令人惊讶,因为如果没有为变量分配存储空间,就无法初始化它。其次,每个没有链接的变量声明也是一个定义。没有链接且不是定义的变量声明是完全没有意义的:没有链接的变量不能声明多次,因此你无法在程序的其他地方定义该变量。
我们如何初始化一个没有链接的变量取决于它的存储持续时间。回想一下,在前几章中,局部变量是在栈上分配空间的,但不一定会被初始化。局部静态变量,如我们稍后将看到的,它们在不同的内存段上分配空间,并且总是会被初始化。如果没有提供显式的初始化器,它们会被初始化为零。
如果一个变量声明带有extern说明符且没有初始化器,它就不是一个定义。请注意,块作用域中的extern变量声明不能有初始化器。因此,它们永远不是定义。(这类似于你可以在块作用域中声明函数,但不能定义它们。)我们可以使用extern说明符来声明在同一文件的其他地方定义的变量,如清单 10-10 所示。
extern int three;
int main(void) {
return three;
}
int three = 3;
清单 10-10:在文件开头声明一个外部变量,并在文件末尾定义它
清单开头的声明使得three进入作用域,而清单末尾的定义确定了它的初始值,3。extern说明符也允许我们声明在其他文件中定义的变量,如清单 10-11 所示。
extern int external_var;
int main(void) {
return 1 + external_var;
}
清单 10-11:声明一个变量但没有定义它
由于external_var在本文件中没有定义,编译器不会为其分配或初始化内存。链接器会在另一个文件中找到它的定义,或者抛出错误。
一个具有内部或外部链接的变量声明,没有extern说明符,也没有初始化器,就是一个初步定义。清单 10-12 展示了一个例子。
int x;
int main(void) {
return x;
}
清单 10-12:一个初步定义
本文件中唯一的x的定义是第一行的初步定义。如果一个变量是初步定义的,我们会将其初始化为零。因此,清单 10-12 的第一行的处理方式就像下面这个非初步定义一样:
int x = 0;
如果一个文件同时包含同一个变量的初步定义和明确初始化的定义,如清单 10-13 所示,明确的定义优先。
int x;
int main(void) {
return x;
}
int x = 3;
清单 10-13:一个初步定义后跟一个明确的定义
该清单以x的临时定义开始,以非临时定义结束。非临时定义具有优先权,因此x被初始化为3。第一行被当作声明处理,就像它包含了extern说明符一样。
虽然定义一个变量不可以多次进行,但拥有多个临时定义的变量是完全合法的。考虑清单 10-14 中的文件作用域声明。
int a;
int a;
extern int a;
int a;
清单 10-14:三个临时定义和一个声明
在这里,我们有三个a的临时定义和一个a的声明,由于它的extern说明符,它不是定义。因为没有非临时的a的定义,所以它将被初始化为零。因此,清单 10-14 将会被编译为包含以下行的形式:
int a = 0;
表 10-1 和表 10-2 总结了标识符的链接性、存储持续时间和定义状态是如何确定的。最左侧的列,作用域和说明符,指的是声明的语法;我们将在解析后知道声明的作用域和存储类说明符。其余的列是属性,我们将在语义分析阶段基于声明的语法来确定。
表 10-1 涵盖了变量声明。
表 10-1: 变量声明的属性
| 作用域 | 说明符 | 链接性 | 存储持续时间 | 是否定义? |
|---|---|---|---|---|
| 有初始化器 | 无初始化器 | |||
| --- | --- | |||
| 文件作用域 | 无 | 外部 | 静态 | 是 |
| static | 内部的 | 静态的 | 是 | |
| extern | 与之前可见声明匹配;默认是外部的 | 静态的 | 是 | |
| 块作用域 | 无 | 无 | 自动 | 是 |
| static | 无 | 静态的 | 是 | |
| extern | 与之前可见声明匹配;默认是外部的 | 静态的 | 无效 |
表 10-2 介绍了函数声明。
表 10-2: 函数声明的属性
| 作用域 | 说明符 | 连接性 | 定义? |
|---|---|---|---|
| 有函数体 | 无函数体 | ||
| --- | --- | ||
| 文件作用域 | 无或 extern | 与之前可见声明匹配;默认是外部的 | 是 |
| static | 内部的 | 是 | |
| 块作用域 | 无或 extern | 与先前可见声明匹配;默认外部链接性 | 无效 |
| static | 无效 | 无效 |
请注意,函数定义中的参数具有自动存储持续时间且没有链接性,类似于没有存储类说明符的块作用域变量。
到此为止,你已经理解了声明的最重要特性。你知道如何确定声明的链接性、存储持续时间,以及它是否定义了一个实体并声明了它。你还明白了这些特性如何影响你可以对标识符进行的操作。接下来,让我们讨论可能出现的问题。
错误案例
我们将在本章中需要检测一大堆错误案例。其中一些错误案例会让你觉得熟悉,因为它们来自早期的章节,尽管细节会有所变化,以适应我们新的语言结构。我们还将处理一些全新的错误案例。
冲突声明
声明可能发生冲突的方式有很多。我们的编译器已经能检测到其中的一些。例如,当同一个标识符的两个声明出现在同一局部作用域内,并且至少其中一个声明没有链接性时,编译器会检测到错误。这是一个错误,因为你无法将后续对该标识符的使用解析为单一实体。
如我之前提到的,将相同的标识符同时声明为具有内部和外部链接性也是一个错误。即使这两个声明位于源文件的完全不同部分,这也是一个问题。例如,示例 10-15 中就包含了冲突声明。
int main(void) {
extern int foo;
return foo;
}
static int foo = 3;
示例 10-15:具有冲突链接性的变量声明
在foo在main中声明时,其他声明是不可见的。(变量何时变得可见取决于它在程序源代码中的声明位置,而不是它在程序执行期间何时初始化。)根据我们之前讨论的规则,这意味着foo具有外部链接性。然而,在后面的代码中,foo在文件作用域中被声明为具有内部链接性。你不能同时定义一个具有内部和外部链接性的相同对象,所以这是不合法的。
最后,如果两个相同实体的声明具有不同类型,则它们会发生冲突。声明一个外部变量和一个具有相同名称的函数是非法的。同样,即使在像 清单 10-16 这样的程序中,声明发生在程序的完全不同部分,冲突声明仍然会导致非法。
int foo = 3;
int main(void) {
int foo(void);
return foo();
}
清单 10-16:具有冲突类型的声明
由于 foo 的两个声明都有外部链接性,它们应该引用同一个实体,但这不可能,因为一个是函数声明,另一个是变量声明。因此,这个程序是无效的。
多重定义
我们已经看到,在同一个程序中多次定义同一个函数是非法的。外部变量的多重定义也是非法的。如果在同一个文件中定义了多个外部变量,编译器应当报错。如果函数或变量在多个文件中定义,编译器无法捕获该错误,但链接器会发现。
无定义
这种错误适用于函数和变量。如果你使用一个已声明但未定义的标识符,在链接时会发生错误,当链接器试图查找定义时会失败。由于这是一个链接时错误,编译器不需要检测它。
无效的初始化器
正如我们已经看到的,静态变量的初始化器必须是常量。在块作用域内,extern 声明不能有任何初始化器,甚至不能有常量初始化器。
存储类说明符的限制
你不能将 extern 或 static 说明符应用于函数参数或在 for 循环头部声明的变量。你也不能将 static 应用于块作用域内的函数声明。(你可以将 extern 应用于它们,但它没有任何作用。)
汇编中的链接性和存储持续时间
当我们扩展编译器的各个阶段时——特别是语义分析阶段——理解我们在前一部分讨论的概念如何转化为汇编代码将非常有帮助。我将首先讨论链接性,然后是存储持续时间。链接性比较简单:如果标识符具有外部链接性,我们将为对应的汇编标签发出一个.globl指令。如果标识符没有外部链接性,我们则不会发出.globl指令。.globl指令适用于函数和变量名。
现在我们来谈谈存储持续时间。我们在前面章节中处理的那些具有自动存储持续时间的变量,都是存在栈上的。静态变量则存在内存的另一个区域——数据段。(有些静态变量存在紧密相关的 BSS 段,我稍后会讨论。)与栈一样,数据段是程序可以读写的内存区域。
然而,尽管栈被划分为由成熟调用约定管理的帧,数据段却是一个大的内存块,无论你在哪个函数中,它始终存在。这使得数据段成为存储具有静态存储持续时间变量的理想场所:数据段中的对象在我们调用和返回函数时不会被释放或覆盖。我们没有像 RSP 或 RBP 那样指向数据段特定位置的专用寄存器,也不需要它们;正如你将看到的,我们可以通过名称来引用这个区域中的变量。
默认情况下,汇编器将写入文本段,这是存放机器指令的内存区域。.data指令告诉汇编器改为开始写入数据段。示例 10-17 展示了如何在数据段初始化一个变量。
.data
.align 4
var:
.long 3
示例 10-17:在数据段初始化变量
清单 10-17 的第一行表示我们正在写入数据区段。下一行的 .align 指令确定我们将写入的下一个值的对齐方式;4 字节对齐意味着该值的地址必须是 4 的倍数。.align 指令的含义因平台而异。在 Linux 上,.align n 会产生 n 字节对齐。在 macOS 上,.align n 会产生 2^n 字节对齐。这意味着在 Linux 上 .align 4 会使下一个值进行 4 字节对齐,而在 macOS 上则会进行 16 字节对齐。
第三行是一个标签;你可以像标记文本区段中的位置一样标记数据区段中的位置。最后一行将 32 位整数 3 写入当前区段;这是数据区段,因为之前的 .data 指令。由于在 x64 汇编中 long 表示 32 位,.long 指令始终写入一个 32 位整数。(回忆一下,像 movl 这样的 32 位操作数指令中的 l 后缀代表 long。)
和其他标签一样,var 标签默认是内部标签,仅在该目标文件中可见。我们可以包含 .globl 指令,使其在其他目标文件中也可见:
.globl var
我之前提到过,一些静态变量存储在 BSS 区段 中。(由于一些晦涩的历史原因,BSS 代表 Block Started by Symbol。)这个区段的工作方式几乎与数据区段相同,唯一不同的是它仅包含初始化为零的变量。这是节省磁盘空间的一种技巧;可执行文件或目标文件只需要记录 BSS 区段的大小,而不需要记录其内容,因为它的内容全都是零。
清单 10-18 在 BSS 区段初始化一个变量。
.bss
.align 4
var:
.zero 4
清单 10-18:在 BSS 区段初始化变量
这段代码与清单 10-17 有两个不同之处。首先,我们使用 .bss 指令向 BSS 段写入数据,而不是数据段。其次,我们使用 .zero n 指令写入 n 字节的零。例如,.zero 4 将一个 4 字节的整数初始化为零。无论是在处理数据段还是 BSS 段时,我们都会使用 .align 指令,声明标签,并根据需要包含或省略 .globl 指令。
如果在你编译的文件中声明了一个变量,但没有定义它,你将不会向数据段或 BSS 段写入任何内容。
最后,让我们看看如何在汇编指令中引用数据段中的标签。这一行代码将立即数值 4 写入标签为 var 的内存地址:
movl $4, var(%rip)
操作数像 var(%rip) 使用了 RIP 相对寻址,它是指相对于指令指针的内存地址。显然,我们不能像引用栈变量那样,使用 RBP 和 RSP 来引用数据段中的符号。我们也不能在链接时将它们替换为绝对地址,因为我们正在编译位置无关代码,该代码可以加载到程序内存中的任何位置。相反,我们使用 RIP 寄存器,它保存程序文本段中当前指令的地址,用来计算类似 var 这样的变量在程序数据段中的地址。
RIP 相对寻址的细节较为复杂,因此我在这里不再详细讲解。相反,我再次推荐 Eli Bendersky 关于位置无关代码的优秀博客文章,相关链接我已经在第一章的“附加资源”中提供了,在第 21 页也有详细说明。
现在你已经理解了存储持续时间、链接和变量初始化在 C 语言和汇编中的工作原理,接下来你可以开始扩展你的编译器了。
词法分析器
你将在这一章中添加两个新的关键字:
static
extern
语法分析器
在这一章,我们将对抽象语法树(AST)进行两项修改:我们将添加变量声明作为顶层构造,并且为函数和变量声明添加可选的存储类说明符。清单 10-19 展示了更新后的 AST 定义。
program = Program(**declaration***)
declaration = FunDecl(function_declaration) | VarDecl(variable_declaration)
variable_declaration = (identifier name, exp? init, **storage_class?**)
function_declaration = (identifier name, identifier* params,
block? body, **storage_class?**)
**storage_class = Static | Extern**
block_item = S(statement) | D(declaration)
block = Block(block_item*)
for_init = InitDecl(variable_declaration) | InitExp(exp?)
statement = Return(exp)
| Expression(exp)
| If(exp condition, statement then, statement? else)
| Compound(block)
| Break
| Continue
| While(exp condition, statement body)
| DoWhile(statement body, exp condition)
| For(for_init init, exp? condition, exp? post, statement body)
| Null
exp = Constant(int)
| Var(identifier)
| Unary(unary_operator, exp)
| Binary(binary_operator, exp, exp)
| Assignment(exp, exp)
| Conditional(exp condition, exp, exp)
| FunctionCall(identifier, exp* args)
unary_operator = Complement | Negate | Not
binary_operator = Add | Subtract | Multiply | Divide | Remainder | And | Or
| Equal | NotEqual | LessThan | LessOrEqual
| GreaterThan | GreaterOrEqual
列表 10-19:带有文件作用域变量和存储类说明符的抽象语法树
我们已经定义了一个 declaration AST 节点,其中包括函数和变量声明。现在我们支持文件作用域变量声明,因此我们将在顶层使用 declaration 节点。
列表 10-20 显示了语法的相应变化。
<program> ::= {**<declaration>**}
<declaration> ::= <variable-declaration> | <function-declaration>
<variable-declaration> ::= **{<specifier>}+** <identifier> ["=" <exp>] ";"
<function-declaration> ::= **{<specifier>}+** <identifier> "(" <param-list> ")" (<block> | ";")
<param-list> ::= "void" | "int" <identifier> {"," "int" <identifier>}
**<specifier> ::= "int" | "static" | "extern"**
<block> ::= "{" {<block-item>} "}"
<block-item> ::= <statement> | <declaration>
<for-init> ::= <variable-declaration> | [<exp>] ";"
<statement> ::= "return" <exp> ";"
| <exp> ";"
| "if" "(" <exp> ")" <statement> ["else" <statement>]
| <block>
| "break" ";"
| "continue" ";"
| "while" "(" <exp> ")" <statement>
| "do" <statement> "while" "(" <exp> ")" ";"
| "for" "(" <for-init> [<exp>] ";" [<exp>] ")" <statement>
| ";"
<exp> ::= <factor> | <exp> <binop> <exp> | <exp> "?" <exp> ":" <exp>
<factor> ::= <int> | <identifier> | <unop> <factor> | "(" <exp> ")"
| <identifier> "(" [<argument-list>] ")"
<argument-list> ::= <exp> {"," <exp>}
<unop> ::= "-" | "~" | "!"
<binop> ::= "-" | "+" | "*" | "/" | "%" | "&&" | "||"
| "==" | "!=" | "<" | "<=" | ">" | ">=" | "="
<identifier> ::= ? An identifier token ?
<int> ::= ? A constant token ?
列表 10-20:带有文件作用域变量和存储类说明符的语法
我们将
解析类型和存储类说明符
我们将类型和存储类说明符合并为一个符号,因为它们可以在声明中以任何顺序出现。换句话说,声明
static int a = 3;
等同于:
int static a = 3;
当我们在 第二部分 中添加更多类型说明符时,事情会变得更加复杂。一个声明可能包含多个类型说明符(如 long 和 unsigned),这些说明符可以相对于存储类说明符和彼此以任何顺序出现。
为了构建 AST,解析器需要在声明开始时处理说明符列表,然后将其转换为一个类型和至多一个存储类说明符。列表 10-21 中的伪代码概述了如何处理说明符列表。
parse_type_and_storage_class(specifier_list):
types = []
storage_classes = []
❶ for specifier in specifier_list:
if specifier is "int":
types.append(specifier)
else:
storage_classes.append(specifier)
if length(types) != 1:
fail("Invalid type specifier")
if length(storage_classes) > 1:
fail("Invalid storage class")
❷ type = Int
if length(storage_classes) == 1:
❸ storage_class = parse_storage_class(storage_classes[0])
else:
storage_class = null
return (type, storage_class)
列表 10-21:确定声明的类型和存储类
我们首先将列表划分为类型说明符和存储类说明符❶。然后,我们验证每个列表。类型说明符列表必须只有一个值。存储类说明符列表可以为空,也可以包含一个值。最后,我们返回结果。此时,Int是唯一的可能类型❷。如果存储类说明符列表不为空,我们将把它的唯一元素转换为对应的 storage_class AST 节点❸。 (我省略了 parse_storage_class 的伪代码,因为它没有太多内容。)如果存储类说明符列表为空,则声明没有存储类。
列表 10-21 比我们目前需要的要复杂一些,但随着我们在后续章节中添加更多类型说明符,它将很容易扩展。
区分函数声明和变量声明
我们唯一剩下的挑战是,无法区分 <function -declaration> 和
现在,你拥有了扩展解析器所需的所有内容。
语义分析
接下来,我们需要扩展标识符解析和类型检查的过程。在标识符解析过程中,我们将处理顶层变量声明,并检查同一作用域内是否有重复声明。在类型检查过程中,我们将向符号表中添加存储类和链接信息,因为生成汇编代码时需要这些信息。我们还将在类型检查器中处理剩余的错误情况。
标识符解析:解析外部变量
与函数一样,外部变量在标识符解析过程中不会被重命名。我们的标识符映射会跟踪每个标识符是否具有链接(内部或外部链接)。在类型检查阶段之前,我们无需区分内部链接和外部链接。
我们需要分别处理块作用域和文件作用域的变量声明,因为这两种作用域中确定链接性的方法规则不同。列表 10-22 演示了如何解析文件作用域的变量声明。
resolve_file_scope_variable_declaration(decl, identifier_map):
identifier_map.add(decl.name, MapEntry(new_name=decl.name,
from_current_scope=True,
has_linkage=True))
return decl
列表 10-22:解析文件作用域的变量声明
如你即将看到的,这比处理块作用域变量声明的代码要简单得多。我们不需要生成唯一名称,因为外部变量在此阶段会保持其原始名称。我们不需要担心该变量的前期声明;任何先前的声明也必须具有内部或外部链接,因此它们会引用相同的对象并在标识符映射中有相同的条目。(文件作用域声明可能会以其他方式发生冲突,但我们将在类型检查器中处理这些冲突。)无论声明是否为 static,我们都可以统一处理这些声明。由于我们不需要区分内部链接和外部链接,我们将继续使用上一章中的布尔值 has_linkage 属性。对于文件作用域的标识符,该属性始终为 True。我们也不需要递归处理初始化器,因为它应该是常量,因此不应该包含需要重命名的变量。如果初始化器不是常量,我们将在类型检查过程中捕获这一点。
现在让我们考虑块作用域中的变量。如果一个变量是用 extern 关键字声明的,我们会在标识符映射中记录它有链接,并保留其原始名称。否则,我们像处理本地变量一样处理它。如果一个标识符在同一作用域中同时声明了有链接和没有链接的情况,我们就无法保持一致的标识符映射,因此会抛出错误。列表 10-23 显示了如何用伪代码实现这一点。
resolve_local_variable_declaration(decl, identifier_map):
if decl.name is in identifier_map:
prev_entry = identifier_map.get(decl.name)
❶ if prev_entry.from_current_scope:
if not (prev_entry.has_linkage and decl.storage_class == Extern):
fail("Conflicting local declarations")
if decl.storage_class == Extern:
❷ identifier_map.add(decl.name, MapEntry(new_name=decl.name,
from_current_scope=True,
has_linkage=True))
return decl
else:
unique_name = make_temporary()
❸ identifier_map.add(decl.name, MapEntry(new_name=unique_name,
from_current_scope=True,
has_linkage=False))
`--snip--`
列表 10-23:解析块作用域的变量声明
首先,我们检查是否有冲突的声明❶。如果该标识符已经在当前作用域中声明,我们检查之前声明的链接性。如果它有链接性且当前声明也有链接性(由extern关键字表示),则它们都指向相同的对象。在这种情况下,声明是一致的,至少在标识符解析的目的下是如此。如果任一标识符或两个标识符都没有链接性,则它们指向两个不同的对象,因此我们会抛出错误。
假设没有冲突,我们更新标识符映射。如果该声明有链接性,它保持当前的名称❷;否则,我们重命名它❸。请注意,无链接的变量在此处理时无论是否为static都一样。还要注意,我们不需要递归处理无链接变量的初始化器,因为它们根本不应该有初始化器。(我已省略了解决无链接变量初始化器的代码,因为它与前面章节相同。)
你不需要更改这个阶段处理函数声明的方式,唯一的小例外是:如果一个块作用域函数声明包含static修饰符,应该抛出一个错误。你可以在标识符解析阶段进行此操作,正好是在验证块作用域函数声明没有函数体的地方。然而,在类型检查器中抛出此错误,甚至在解析器中抛出,也同样有效。
类型检查:跟踪静态函数和变量
接下来,我们将更新符号表并处理剩余的错误情况。我们将向符号表中添加几项新信息。首先,我们将记录每个变量的存储持续时间。其次,我们将记录具有静态存储持续时间的变量的初始值。最后,我们将记录具有静态存储持续时间的函数和变量是否是全局可见的。这些信息都将影响我们稍后生成的汇编代码。
我们正在添加到类型检查器中的大部分逻辑本身并不是类型检查,因为标识符的存储类和链接性与其类型是分开的。但类型检查器是一个自然的位置来处理这些逻辑,因为我们将在符号表中一起跟踪每个标识符的类型、链接性和存储类。
符号表中的标识符属性
我们需要在符号表中跟踪每种标识符的不同信息:函数、具有静态存储持续时间的变量,以及具有自动存储持续时间的变量。清单 10-24 展示了一种表示这些信息的方式。
identifier_attrs = FunAttr(bool defined, bool global)
| StaticAttr(initial_value init, bool global)
| LocalAttr
initial_value = Tentative | Initial(int) | NoInitializer
列表 10-24:不同类型标识符的符号表属性
StaticAttr表示我们需要跟踪的具有静态存储持续时间的变量的属性。initial_value类型让我们能够区分带有初始化器的变量定义、没有初始化器的暂定定义,以及extern变量声明。FunAttr表示函数,LocalAttr表示具有自动存储持续时间的函数参数和变量。每个符号表条目应包括类型(如前一章所定义)和identifier_attrs。
现在我们可以在符号表中表示所需的信息,让我们来看一下我们需要进行类型检查的三种声明:函数声明、文件作用域变量声明和块作用域变量声明。
函数声明
这里的大部分逻辑将保持不变。我们将检查当前声明是否与之前的声明类型相同,并且确保函数没有被多次定义。唯一的不同是,我们还会记录函数是否是全局可见的。列表 10-25 中的伪代码展示了我们如何进行函数声明的类型检查,和列表 9-21 相比,新增的更改已加粗,部分未更改的代码已省略。(我还对代码做了一些调整,以适应我们符号表表示法的变化,尽管逻辑本质上保持不变。这些更改没有加粗。)
typecheck_function_declaration(decl, symbols):
fun_type = FunType(length(decl.params))
has_body = decl.body is not null
already_defined = False
❶ **global = decl.storage_class != Static**
❷ if decl.name is in symbols:
old_decl = symbols.get(decl.name)
if old_decl.type != fun_type:
fail("Incompatible function declarations")
already_defined = old_decl.attrs.defined
if already_defined and has_body:
fail("Function is defined more than once")
**if old_decl.attrs.global and decl.storage_class == Static:**
**fail("Static function declaration follows non-static")**
❸ **global = old_decl.attrs.global**
**attrs = FunAttr(defined=(already_defined or has_body), global=global)**
symbols.add(decl.name, fun_type, **attrs=attrs**)
`--snip--`
列表 10-25:类型检查函数声明
首先,我们查看函数的存储类 ❶。如果是static,该函数将不可全局可见,因为它的链接是内部的。如果是extern(或完全没有该声明,效果相同),我们暂时认为该函数是全局可见的,因为它的链接是外部的。然而,这可能会根据其他声明的作用域发生变化。
接下来,我们查看是否有其他声明 ❷。我们检查类型不匹配和重复定义,就像在上一章一样。然后,我们考虑链接性。如果当前声明包含显式或隐式的extern关键字,我们将保留先前声明的链接性(因此保留其global属性)。如果当前和过去的声明都有内部链接性,则没有冲突。无论哪种情况,之前声明的链接性保持不变 ❸。但是,如果函数之前声明为外部链接性,而现在声明为static关键字,则声明发生冲突,因此会抛出错误。
我已将此函数的其余部分删减掉,因为它与上一章相同。
文件范围变量声明
当我们遇到文件范围的变量声明时,我们需要确定该变量的初始值以及是否全局可见。这些属性依赖于当前声明和任何之前对同一变量的声明。清单 10-26 展示了如何进行文件范围变量声明的类型检查。
typecheck_file_scope_variable_declaration(decl, symbols):
if decl.init is constant integer i: ❶
initial_value = Initial(i)
else if decl.init is null: ❷
if decl.storage_class == Extern:
initial_value = NoInitializer
else:
initial_value = Tentative
else: ❸
fail("Non-constant initializer!")
global = (decl.storage_class != Static) ❹
if decl.name is in symbols: ❺
old_decl = symbols.get(decl.name)
if old_decl.type != Int:
fail("Function redeclared as variable")
if decl.storage_class == Extern:
global = old_decl.attrs.global
else if old_decl.attrs.global != global:
fail("Conflicting variable linkage")
if old_decl.attrs.init is a constant:
if initial_value is a constant:
fail("Conflicting file scope variable definitions") ❻
else:
initial_value = old_decl.attrs.init
else if initial_value is not a constant and old_decl.attrs.init == Tentative:
initial_value = Tentative
attrs = StaticAttr(init=initial_value, global=global)
symbols.add(decl.name, Int, attrs=attrs) ❼
清单 10-26:文件范围变量声明的类型检查
首先,我们确定变量的初始值。这取决于声明的初始化器和其存储类说明符。如果初始化器是常量,我们将使用它 ❶。如果没有初始化器 ❷,我们将记录该变量是暂时定义的,还是根本未定义,这取决于是否是extern声明。如果初始化器是任何常量以外的表达式,我们将抛出错误 ❸。
接下来,我们确定该变量是否全局可见 ❹。除非存储类说明符是static,我们暂时认为它是可见的。
然后,如果我们在符号表中记录了此标识符之前的声明,我们也会考虑这些声明 ❺。我们验证之前的声明是否为类型Int,而不是函数类型,然后我们尝试调和global属性与之前声明的匹配。如果这是一个extern声明,我们只需采用先前声明的global属性。否则,如果新的和旧的global属性不一致,我们会抛出错误。
考虑前一个声明的初始化器更加复杂。如果此声明或前一个声明有显式初始化器,我们将使用该初始化器。否则,如果新声明或前一个声明是暂时定义,我们将使用Tentative初始化器。如果到目前为止我们还没有看到任何显式或暂时定义,我们将坚持使用NoInitializer。如果新旧声明都有显式初始化器,我们将抛出一个错误❻。
最后,我们在符号表中添加(或更新)此变量的条目❼。
块作用域变量声明
我们将使用 Listing 10-27 中的伪代码来对块作用域中的变量声明进行类型检查。
typecheck_local_variable_declaration(decl, symbols):
if decl.storage_class == Extern:
if decl.init is not null: ❶
fail("Initializer on local extern variable declaration")
if decl.name is in symbols:
old_decl = symbols.get(decl.name)
if old_decl.type != Int: ❷
fail("Function redeclared as variable")
else:
symbols.add(decl.name, Int, attrs=StaticAttr(init=NoInitializer, global=True)) ❸
else if decl.storage_class == Static:
if decl.init is constant integer i: ❹
initial_value = Initial(i)
else if decl.init is null: ❺
initial_value = Initial(0)
else:
fail("Non-constant initializer on local static variable")
symbols.add(decl.name, Int, attrs=StaticAttr(init=initial_value, global=False)) ❻
else:
symbols.add(decl.name, Int, attrs=LocalAttr) ❼
if decl.init is not null:
typecheck_exp(decl.init, symbols)
Listing 10-27: 类型检查块作用域变量声明
为了处理extern变量,我们首先确保它没有初始化器❶,并且之前没有声明为函数❷。然后,如果该变量之前没有声明,我们将在符号表中记录它是全局可见且未初始化❸。如果它已经声明过,我们什么都不做:局部的extern声明永远不会改变我们已经记录的初始值或链接。
静态局部变量没有链接,因此我们不需要考虑早期的声明。我们只检查变量的初始化器:如果它是常量,我们使用它❹;如果它不存在,我们将变量初始化为零❺;如果它不是常量,我们抛出一个错误。然后,我们将该变量添加到符号表中,记录它不可全局可见❻。
我们将在符号表中为自动变量的条目中包含LocalAttr属性❼。除此之外,我们像上一章那样对这些变量进行类型检查。
当你处理for循环头中的声明时,验证它是否没有包含存储类说明符,然后再调用 Listing 10-27 中的代码。(或者,你可以在标识符解析阶段处理此错误情况,甚至在解析过程中处理。)
类型检查阶段完成了!实现 C 标准中有关定义、声明、链接和存储持续时间的复杂规则花费了不少精力。幸运的是,现在符号表已经包含了我们需要的所有信息,接下来的章节应该会轻松很多。
TACKY 生成
我们需要在 TACKY IR 中进行两个新增。首先,我们将在函数定义中添加一个新的 global 字段,这对应于最终汇编输出中的 .globl 指令:
Function(identifier, **bool global**, identifier* params, instruction* body)
第二步,我们将添加一个顶层构造来表示静态变量:
StaticVariable(identifier, bool global, int init)
我们将使用此构造来表示外部和局部静态变量。最终,我们将把每个 StaticVariable 构造转换为一组汇编指令,用于初始化数据段或 BSS 段中的对象。列表 10-28 展示了完整的 TACKY IR,并对上一章的更改进行了加粗。
program = Program(**top_level***)
**top_level** = Function(identifier, **bool global**, identifier* params, instruction* body)
**| StaticVariable(identifier, bool global, int init)**
instruction = Return(val)
| Unary(unary_operator, val src, val dst)
| Binary(binary_operator, val src1, val src2, val dst)
| Copy(val src, val dst)
| Jump(identifier target)
| JumpIfZero(val condition, identifier target)
| JumpIfNotZero(val condition, identifier target)
| Label(identifier)
| FunCall(identifier fun_name, val* args, val dst)
val = Constant(int) | Var(identifier)
unary_operator = Complement | Negate | Not
binary_operator = Add | Subtract | Multiply | Divide | Remainder | Equal | NotEqual
| LessThan | LessOrEqual | GreaterThan | GreaterOrEqual
列表 10-28:向 TACKY 添加静态变量和 global 属性
我们已将 function_definition 节点重命名为 top_level,因为它不再仅仅表示函数。请注意,当我们将程序转换为 TACKY 时,我们将局部静态变量定义移到顶层;它们变成了 StaticVariable 构造,而不是函数体中的指令。
当我们遍历抽象语法树(AST)并将其转换为 TACKY 时,我们将在每个顶层的 Function 上设置新的 global 属性。我们可以在符号表中查找此属性。对于文件作用域变量声明或带有 static 或 extern 说明符的局部变量声明,我们不会生成任何 TACKY。相反,在我们遍历 AST 后,我们将执行一个额外的步骤,检查符号表中的每个条目,并为其中一些条目生成 StaticVariable 构造。我们的最终 TACKY 程序将包括从原始 AST 转换来的函数定义和从符号表生成的变量定义。
列表 10-29 演示了如何将符号表条目转换为 TACKY 变量定义。
convert_symbols_to_tacky(symbols):
tacky_defs = []
for (name, entry) in symbols:
match entry.attrs with
| StaticAttr(init, global) ->
match init with
| Initial(i) -> tacky_defs.append(StaticVariable(name, global, i))
| Tentative -> tacky_defs.append(StaticVariable(name, global, 0))
| NoInitializer -> continue
| _ -> continue
return tacky_defs
列表 10-29:将符号表条目转换为 TACKY
我们查看每个符号表条目,以确定它是否应该转换为 StaticVariable。如果它没有 StaticAttr 属性,我们就跳过它,因为它不是静态变量。如果它的初始值是 NoInitializer,我们也跳过它,因为它在此翻译单元中未定义。任何没有被跳过的符号都会转换为 TACKY StaticVariable 并添加到 TACKY 程序中。具有临时定义的静态变量将初始化为零。
现在,先处理 AST 还是符号表并不重要。从 第十六章 开始,先处理 AST 再处理符号表将变得非常重要。在该章中,我们将在将 AST 转换为 TACKY 时向符号表中添加新的静态对象;然后,在遍历符号表时,我们将把这些新条目转换为 TACKY 构造。
汇编生成
我们将在本章对汇编 AST 进行一些小的修改。这些更改在 列表 10-30 中已加粗。
program = Program(**top_level***)
**top_level** = Function(identifier name, **bool global,** instruction* instructions)
**| StaticVariable(identifier name, bool global, int init)**
instruction = Mov(operand src, operand dst)
| Unary(unary_operator, operand)
| Binary(binary_operator, operand, operand)
| Cmp(operand, operand)
| Idiv(operand)
| Cdq
| Jmp(identifier)
| JmpCC(cond_code, identifier)
| SetCC(cond_code, operand)
| Label(identifier)
| AllocateStack(int)
| DeallocateStack(int)
| Push(operand)
| Call(identifier)
| Ret
unary_operator = Neg | Not
binary_operator = Add | Sub | Mult
operand = Imm(int) | Reg(reg) | Pseudo(identifier) | Stack(int) | **Data(identifier)**
cond_code = E | NE | G | GE | L | LE
reg = AX | CX | DX | DI | SI | R8 | R9 | R10 | R11
列表 10-30:带静态变量的汇编 AST
就像在 TACKY 中一样,我们将 function_definition 重命名为 top_level,并添加一个顶层 StaticVariable,表示每个静态变量的名称、初始值以及它是否在全局可见。我们还会给函数定义添加一个 global 属性。最后,我们添加一个新的汇编操作数 Data,用于对数据和 BSS 段的 RIP 相对访问。在伪寄存器替换过程中,我们将根据需要用 Data 操作数替换伪寄存器。
生成变量定义的汇编
将我们的新 TACKY 构造转换为汇编是简单的,因为我们只需将一些字段从 TACKY 传递到相应的汇编构造。表 10-3 总结了此转换的最新更新,新的构造和现有构造的更改已加粗。附录 B 包含了本章的完整 TACKY 到汇编转换过程,这也是 Part I 中此过程的最终版本。
表 10-3: 将顶层 TACKY 构造转换为汇编
| TACKY 顶层结构 | 汇编顶层结构 |
|---|---|
| 程序(top_level_defs) | 程序(top_level_defs) |
| 函数(name, global, params, instructions) |
Function(name, global,
[Mov(Reg(DI), param1),
Mov(Reg(SI), param2),
<copy next four parameters from registers>,
Mov(Stack(16), param7),
Mov(Stack(24), param8),
<copy remaining parameters from stack>] +
instructions)
|
| 静态变量(name,global,init) | 静态变量(name,global,init) |
|---|
我们将所有其他 TACKY 结构转换为汇编的方式不会改变。特别是,我们将每个 TACKY Var 操作数转换为汇编中的 Pseudo 操作数,无论它是具有静态存储持续时间还是自动存储持续时间。这意味着名称 Pseudo 不再完全适用;术语 伪寄存器 通常指的是那些理论上可以驻留在寄存器中的操作数,而静态变量不能这么做。我们不会费心去重新命名这个操作数,但你应该记住,我们在使用 伪寄存器 这个术语时有些不太常规。
根据存储持续时间替换伪寄存器
接下来,我们将调整如何用具体位置替换伪寄存器。在之前的章节中,每个伪寄存器都被分配到了栈上的一个位置。这一次,并非每个变量都应该放在栈上;其中一些变量存储在数据区或 BSS 段。我们将检查符号表来区分它们。回想一下,我们在伪寄存器替换过程中建立了一个从伪寄存器到具体地址的映射。当我们遇到一个不在此映射中的伪寄存器时,我们会在符号表中查找它。如果我们发现它具有静态存储持续时间,我们将把它映射到具有相同名称的 Data 操作数上。否则,我们将像往常一样为它分配栈上的新槽位。(如果它不在符号表中,说明它是一个 TACKY 临时变量,因此具有自动存储持续时间。)例如,如果 foo 是一个静态变量,那么汇编指令
Mov(Imm(0), Pseudo("foo"))
应该重写为:
Mov(Imm(0), Data("foo"))
因为静态变量不驻留在栈上,所以它们不会计入我们需要追踪的每个函数的总栈大小。
修正指令
你已经编写了多个重写规则,这些规则适用于操作数为内存地址的情况。记住,Data 操作数也是内存地址!例如,如果你遇到以下指令:
Mov(Data("x"), Stack(-4))
你应该为 Mov 指令应用通常的重写规则,前提是源操作数和目标操作数都在内存中。重写后的汇编代码将是:
Mov(Data("x"), Reg(R10))
Mov(Reg(R10), Stack(-4))
否则,此阶段不会有变化。
代码生成
为了结束本章,你将扩展代码生成阶段来处理 Listing 10-30 中的更改。你应根据汇编 AST 中 global 属性为函数包含或省略 .globl 指令。你还应该在每个函数定义的开始处包含 .text 指令。此指令告诉汇编器写入文本段;现在你也要写入数据段和 BSS 段,因此需要包括此指令。
使用 RIP 相对寻址生成 Data 操作数。例如,Data("foo") 在 Linux 上会变为 foo(%rip),在 macOS 上会变为 _foo(%rip)。将每个 StaticVariable 生成一组汇编指令。在 Linux 上,如果你有一个 StaticVariable(name, global, init),且 global 为 true 且 init 非零,你应该生成 Listing 10-31 中的汇编代码。
.globl `<name>`
.data
.align 4
`<name>`:
.long `<init>`
Listing 10-31: 全局非零静态变量的汇编代码
如果 global 为 true 且 init 为零,你应该生成 Listing 10-32 中的汇编代码。
.globl `<name>`
.bss
.align 4
`<name>`:
.zero 4
Listing 10-32: 全局静态变量的汇编代码,初始化为零
如果 global 为 false,生成 Listing 10-31 或 10-32,而不包含 .globl 指令。
在 macOS 上,你将发出几乎相同的汇编代码用于 StaticVariable,只是有一些细微的差别。首先,符号应当以下划线开始,和往常一样。其次,你应使用 .balign 指令,而不是 .align。我之前提到过,.align 指令的行为是平台相关的,所以 .align 4 在 macOS 上会生成 16 字节对齐的值。.balign 指令的工作方式与 .align 相同,只是它的行为在不同平台之间保持一致:.balign n 总是将值对齐到 n 字节,而不是 2^n 字节。(在 Linux 上,.balign 和 .align 是可以互换的,因此使用其中任何一个都可以。)
表格 10-4 和 10-5 总结了代码发射过程中的最新更新,新构造和对现有构造的更改已加粗。 附录 B 包含了本章的完整代码发射过程(这也是 第一部分 的完整代码发射过程)。
表格 10-4: 格式化顶层汇编构造
| 汇编顶层构造 | 输出 | |
|---|---|---|
| 程序(顶层) |
Print out each top-level construct.
On Linux, add at end of file:
.section .note.GNU-stack,"",@progbits
|
| 函数(名称, 全局,指令) |
|---|
<global-directive>
.text
<name>:
pushq %rbp
movq %rsp, %rbp
<instructions>
|
| StaticVariable(名称,全局,初始化) | 初始化为零 |
|---|
<global-directive>
.bss
<alignment-directive>
<name>:
.zero 4
|
| 初始化为非零值 |
|---|
<global-directive>
.data
<alignment-directive>
<name>:
.long <init>
|
| 全局指令 |
|---|
If global is true:
.globl <identifier>
Otherwise, omit this directive.
|
| 对齐指令 | 仅限 Linux | .align 4 |
|---|---|---|
| macOS 或 Linux | .balign 4 |
表 10-5: 格式化汇编操作数
| 汇编操作数 | 输出 |
|---|---|
| 数据(标识符) | <标识符>(%rip) |
一旦你更新了代码生成过程,就可以开始测试你的编译器了。
总结
你刚刚完成了第一部分的内容!你的编译器可以处理具有各种链接方式的标识符,以及静态和自动存储持续时间的标识符。你还学会了如何编写汇编程序,以定义和使用目标文件中数据和 BSS 段的值。
你现在已经实现了 C 语言的所有基本机制,从局部变量和文件作用域变量到控制流语句再到函数调用。你还通过区分函数类型和int,迈出了建立类型系统的第一步。在第二部分中,你将实现更多类型,包括不同大小的有符号和无符号整数、浮点数、指针、数组和结构体。或者,如果你愿意,你也可以直接跳到第三部分,在那里你将实现几种经典的编译器优化。到目前为止,你所做的工作为你接下来决定继续学习的部分奠定了坚实的基础。
第二部分 超出 INT 类型

描述
第十一章:11 长整型

在本章中,你将添加一个新类型:long。这是一种有符号整数类型,就像 int 一样;这两者之间唯一的区别是它们所能表示的值的范围。你还将添加一个显式类型转换操作,它将一个值转换为不同的类型。
由于 long 类型与我们已经支持的 int 类型非常相似,因此我们无需添加许多新的汇编或 TACKY 指令,也不需要实现复杂的类型转换逻辑。相反,我们将集中精力为 第二部分 的其余内容打好基础。我们将跟踪常量和变量的类型,将类型信息附加到抽象语法树(AST)上,识别隐式类型转换并使其显式,并确定汇编指令的操作数大小。我们将需要对编译器的每个阶段(除了循环标记)进行至少一点小改动。在开始之前,让我们看看 long 类型在汇编中的操作表现如何。
汇编中的长整型
C 语言标准并未指定整数类型的大小,但 System V x64 ABI 规定 int 是 4 字节,而 long 是 8 字节。为了极度简化问题,C 表达式中使用 long 操作数最终会转换为针对 quadwords(8 字节操作数)的汇编指令。例如,以下汇编指令在 quadwords 上操作来计算 2 + 2,并生成一个 quadword 结果:
movq $2, %rax
addq $2, %rax
这与使用 longwords(4 字节)编写的等效代码几乎完全相同:
movl $2, %eax
addl $2, %eax
唯一的区别是 mov 和 add 指令的后缀,以及我们是否使用整个 RAX 寄存器或仅使用其下 4 字节的 EAX。
注意
术语 word、longword* 和 quadword 起源于 16 位处理器时代,当时 int 是 2 字节,long 是 4 字节。更糟糕的是,4 字节的值常常被称为 doublewords 而不是 longwords。我使用 longword 这一术语来模仿 AT&T 汇编语法,但英特尔的文档使用 doubleword。
大多数四字指令只接受 8 字节操作数并生成 8 字节结果,就像大多数长字指令只接受 4 字节操作数并生成 4 字节结果一样。而 C 语言中的表达式则经常同时使用多种操作数类型,或者将一种类型的值赋给另一种类型的对象。在编译过程中,我们将把这些表达式分解为简单的指令,这些指令要么接受单一类型的操作数并生成相同类型的结果,要么显式地进行类型转换。幸运的是,C 标准明确告诉我们这些类型转换发生的位置。
类型转换
C 标准第 6.3.1.3 节第 1 段定义了如何在整数类型之间进行转换:“如果值可以由新类型表示,则不作更改。”换句话说,如果某个表达式计算结果为 3,然后你将其强制转换为另一种整数类型,那么该强制转换表达式的结果应该仍然是 3。
由于long大于int,我们可以安全地将任何int转换为long而不会改变其值。我们使用的是有符号整数的二进制补码表示法,因此我们将通过符号扩展将int转换为long,这种符号扩展的知识你在第三章中学过。具体来说,我们将使用movsx(或“带符号扩展的移动”)汇编指令。这条指令将 4 字节的源数据移入 8 字节的目标数据,并将值符号扩展到目标数据的上 4 字节。
将一个long转换为int会更棘手,因为它可能太大或太小,无法表示为int。C 标准第 6.3.1.3 节的第 3 段告诉我们,“当新类型为有符号类型且值无法在其中表示时,[结果]要么由实现定义,要么引发实现定义的信号。”换句话说,我们需要决定如何处理。我们的实现将按照 GCC 的方式处理这种转换,正如其文档中所述:“对于转换为宽度为N的类型,值会对 2^N取模,从而使其在类型范围内;不会引发信号” (<wbr>gcc<wbr>.gnu<wbr>.org<wbr>/onlinedocs<wbr>/gcc<wbr>/Integers<wbr>-implementation<wbr>.html).
按模 2³² 减少一个值意味着加或减一个 2³² 的倍数,使其落入 int 的范围内。这里有一个简单的例子。你可以表示的最大 int 值是 2³¹ – 1,即 2,147,483,647。假设你需要将下一个最大的整数值 (2³¹,即 2,147,483,648) 从 long 转换为 int。从该值减去 2³² 会得到 -2³¹,即 -2,147,483,648,这是 int 能表示的最小值。
在实践中,我们将通过丢弃 long 的上 4 个字节,将其转换为 int。如果一个 long 可以表示为 int,那么丢弃这些字节不会改变它的值。例如,这是 -3 的 8 字节二进制表示:
11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111101
这是相同值的 4 字节表示:
11111111 11111111 11111111 11111101
如果一个 long 无法表示为 int,丢弃其上 4 个字节的效果是将其值按模 2³² 进行减少。以我们之前的例子为例,long 2,147,483,648 的二进制表示如下:
00000000 00000000 00000000 00000000 10000000 00000000 00000000 00000000
将其转换为 int 后,结果为值 -2,147,483,648,其二进制表示如下:
10000000 00000000 00000000 00000000
要丢弃一个 long 的上 4 个字节,我们只需使用 movl 指令复制它的下 4 个字节。例如,以下指令将截断存储在 RCX 中的值:
movl %ecx, %eax
当我们将值存储在寄存器的下 4 个字节时,寄存器的上 4 个字节将被置为零。
静态长整型变量
静态存储持续时间的变量在汇编中定义方式基本相同,不管其类型如何,但静态四字和长字之间有一些小的差别。考虑以下文件作用域变量声明:
static long var = 100;
我们将把这个声明转换为 Listing 11-1 中的汇编代码。
.data
.align 8
var:
.quad 100
Listing 11-1: 在数据区初始化一个 8 字节值
这与我们为静态 int 生成的汇编有两点不同:对齐方式是 8 而不是 4,并且我们使用 .quad 指令初始化 8 字节的值,而不是使用 .long 来初始化 4 字节。
System V x64 ABI 规定 long 和 int 分别是 8 字节和 4 字节对齐的。C 标准未指定它们的对齐方式,像它们的大小一样是未定义的。
现在我们对要生成的汇编有了大致的了解,接下来让我们开始编写编译器吧! ### 词法分析器
在这一章中,您将添加以下两个标记:
long 一个关键字。
长整型常量 这些与我们当前的整型常量不同,因为它们有一个 l 或 L 后缀。长整型常量标记与正则表达式 [0-9]+[lL]\b 匹配。
解析器
在这一章中,我们将向抽象语法树(AST)添加长整型常量、类型信息和类型转换表达式。列表 11-2 展示了更新后的 AST 定义。
program = Program(declaration*)
declaration = FunDecl(function_declaration) | VarDecl(variable_declaration)
variable_declaration = (identifier name, exp? init,
❶ **type var_type,** storage_class?)
function_declaration = (identifier name, identifier* params, block? body,
❷ **type fun_type,** storage_class?)
❸ **type = Int | Long | FunType(type* params, type ret)**
storage_class = Static | Extern
block_item = S(statement) | D(declaration)
block = Block(block_item*)
for_init = InitDecl(variable_declaration) | InitExp(exp?)
statement = Return(exp)
| Expression(exp)
| If(exp condition, statement then, statement? else)
| Compound(block)
| Break
| Continue
| While(exp condition, statement body)
| DoWhile(statement body, exp condition)
| For(for_init init, exp? condition, exp? post, statement body)
| Null
exp = Constant(**const**)
| Var(identifier)
❹ **| Cast(type target_type, exp)**
| Unary(unary_operator, exp)
| Binary(binary_operator, exp, exp)
| Assignment(exp, exp)
| Conditional(exp condition, exp, exp)
| FunctionCall(identifier, exp* args)
unary_operator = Complement | Negate | Not
binary_operator = Add | Subtract | Multiply | Divide | Remainder | And | Or
| Equal | NotEqual | LessThan | LessOrEqual
| GreaterThan | GreaterOrEqual
❺ **const = ConstInt(int) | ConstLong(int)**
列表 11-2:包含长整型常量、类型信息和类型转换表达式的抽象语法树
type AST 节点可以表示 int、long 和函数类型 ❸。我们并不打算在这里定义全新的数据结构,而是可以扩展我们在第九章中开始使用的 type 结构,在符号表条目中使用。从现在开始,我们将在符号表和 AST 中都使用该数据结构。
在第九章中,我们像这样定义了type:
type = Int | FunType(int param_count)
在列表 11-2 中,我们通过添加 Long 和跟踪有关函数类型的附加信息,包括返回类型和参数类型列表,修改了这个定义。之前我们不需要这些信息,因为每个参数和返回类型的类型都必须是 int。请注意,我们新的递归定义的 type 可以表示一些无效类型,比如返回函数的函数,但解析器永远不会生成这些无效类型。
一旦我们更新了表示 type 的方式,我们将类型信息附加到变量❶和函数声明❷。我们不会在函数声明的 params 中添加类型信息,因为函数的类型已经包含了其参数的类型。我们还扩展了 exp AST 节点,以表示类型转换表达式❹,并定义了一个新的 const AST 节点,具有区分长整型和整型常量的不同构造函数❺。我们在类型检查过程中需要区分不同类型的常量。
如果你的实现语言支持有符号的 64 位和 32 位整数类型,并且支持这些类型之间的转换,且转换语义与我们在 C 实现中对 long 和 int 之间的转换相同,我建议使用这些类型来表示 AST 中的 ConstLong 和 ConstInt。(大多数语言通过默认或库提供这些语义的定长整数类型。)这将使得在编译时更容易将静态初始化器转换为正确的类型;它还将简化常量折叠,这是一种我们将在第三部分中实现的优化。如果你的实现语言没有具有正确语义的整数类型,你至少应确保 ConstLong 节点使用能够表示所有 long 值的整数类型。
更新 AST 后,我们将对语法进行相应的更改,参见列表 11-3。
<program> ::= {<declaration>}
<declaration> ::= <variable-declaration> | <function-declaration>
<variable-declaration> ::= {<specifier>}+ <identifier> ["=" <exp>] ";"
<function-declaration> ::= {<specifier>}+ <identifier> "(" <param-list> ")" (<block> | ";")
<param-list> ::= "void"
| **{<type-specifier>}+** <identifier> {"," **{<type-specifier>}+** <identifier>}
**<type-specifier> ::= "int" | "long"**
<specifier> ::= **<type-specifier> |** "static" | "extern"
<block> ::= "{" {<block-item>} "}"
<block-item> ::= <statement> | <declaration>
<for-init> ::= <variable-declaration> | [<exp>] ";"
<statement> ::= "return" <exp> ";"
| <exp> ";"
| "if" "(" <exp> ")" <statement> ["else" <statement>]
| <block>
| "break" ";"
| "continue" ";"
| "while" "(" <exp> ")" <statement>
| "do" <statement> "while" "(" <exp> ")" ";"
| "for" "(" <for-init> [<exp>] ";" [<exp>] ")" <statement>
| ";"
<exp> ::= <factor> | <exp> <binop> <exp> | <exp> "?" <exp> ":" <exp>
<factor> ::= **<const>** | <identifier>
**| "(" {<type-specifier>}+ ")" <factor>**
| <unop> <factor> | "(" <exp> ")"
| <identifier> "(" [<argument-list>] ")"
<argument-list> ::= <exp> {"," <exp>}
<unop> ::= "-" | "~" | "!"
<binop> ::= "-" | "+" | "*" | "/" | "%" | "&&" | "||"
| "==" | "!=" | "<" | "<=" | ">" | ">=" | "="
**<const> ::= <int> | <long>**
<identifier> ::= ? An identifier token ?
<int> ::= ? An int token ?
**<long> ::= ? An int or long token ?**
列表 11-3:包含长常量、长类型说明符和类型转换表达式的语法
我们需要处理两个稍微复杂的细节。首先,每当我们解析类型修饰符列表时,我们需要将它们转换为一个单一的 type AST 节点。长整型可以通过 long 修饰符来声明,也可以通过同时使用 long 和 int 来声明,顺序可以任意。 清单 11-4 说明了如何将类型修饰符列表转换为类型。
parse_type(specifier_list):
if specifier_list == ["int"]:
return Int
if (specifier_list == ["int", "long"]
or specifier_list == ["long", "int"]
or specifier_list == ["long"]):
return Long
fail("Invalid type specifier")
清单 11-4:从类型修饰符列表中确定类型
这适用于没有存储类的类型,我们通常会在参数列表或类型转换表达式中找到这些类型。对于函数和变量声明,我们将在 清单 10-21 中的修饰符解析代码的基础上进行扩展。清单 11-5 复制了该代码,并用粗体标出了更改部分。
parse_type_and_storage_class(specifier_list):
types = []
storage_classes = []
for specifier in specifier_list:
if specifier is "int" **or "long"**:
types.append(specifier)
else:
storage_classes.append(specifier)
**type = parse_type(types)**
if length(storage_classes) > 1:
fail("Invalid storage class")
if length(storage_classes) == 1:
storage_class = parse_storage_class(storage_classes[0])
else:
storage_class = null
return (type, storage_class)
清单 11-5:从修饰符列表中确定类型和存储类
我们仍然将类型修饰符与存储类修饰符分开,并像在 清单 10-21 中那样确定存储类,但我们在这里做了一些小的改动。首先,我们将 long 识别为类型修饰符。其次,我们不再要求类型修饰符列表必须恰好包含一个元素(这个更改没有用粗体标出,因为我们只是删除了一些现有代码)。第三,我们不再总是将 type 设置为 Int,而是使用新的 parse_type 函数来确定类型。
第二个复杂的细节是解析常量标记。清单 11-6 展示了如何将这些标记转换为 const AST 节点。
parse_constant(token):
v = integer value of token
if v > 2⁶³ - 1:
fail("Constant is too large to represent as an int or long")
if token is an int token and v <= 2³¹ - 1:
return ConstInt(v)
return ConstLong(v)
清单 11-6:将常量标记转换为 AST 节点
我们将一个整数常量标记(没有 l 或 L 后缀)解析为 ConstInt 节点,除非它的值超出了 int 类型的范围。类似地,我们将一个长整型常量标记(带有 l 或 L 后缀)解析为 ConstLong 节点,除非它的值超出了 long 类型的范围。如果一个整数常量标记超出了 int 类型的范围,但在 long 类型的范围内,我们将其解析为 ConstLong 节点。如果一个整数或长整型常量标记对于 long 来说过大,我们将抛出一个错误。
一个 int 是 32 位的,因此它可以保存从 –2³¹ 到 2³¹ – 1 之间的任何值,包括端点。按相同的逻辑,一个 long 可以保存从 –2⁶³ 到 2⁶³ – 1 之间的任何值。你的解析器应该检查每个常量标记是否超出了相应类型的最大值。它不需要检查最小值,因为这些标记不能表示负数;负号是一个单独的标记。
语义分析
接下来,我们将扩展执行语义分析的编译器过程。我们将对标识符解析做一个微小的机械性修改:我们将扩展 resolve_exp,使其像遍历其他类型的表达式一样遍历类型转换表达式。我以后在章节中不会每次都明确提到这种修改;从现在开始,只要我们添加一个包含子表达式的新表达式,就可以扩展标识符解析过程来遍历它。做完这个修改后,我们就可以转向更有趣的问题——扩展类型检查器。
就像 C 程序中的每个对象都有一个类型一样,每个表达式的结果也有一个类型。例如,对两个 int 操作数执行任何二元算术运算将得到一个 int 结果,对两个 long 操作数执行相同的操作将得到一个 long 结果,调用具有特定返回类型的函数将返回该类型的结果。
在类型检查过程中,我们将为 AST 中的每个表达式注释其结果的类型。我们将使用这些类型信息来确定我们在 TACKY 中生成的临时变量的类型,以便保存中间结果。这将反过来帮助我们确定汇编指令所需的操作数大小,以及为每个临时变量分配的栈空间大小。
在我们给表达式注释类型信息的同时,我们还会识别程序中的任何隐式类型转换,并通过在 AST 中插入 Cast 表达式将其显式化。然后,我们可以在 TACKY 生成过程中轻松生成正确的类型转换指令。
将类型信息添加到 AST
在我们更新类型检查器之前,我们需要一种方法来将类型信息附加到 exp AST 节点。显而易见的解决方案,如 Listing 11-7 所示,是在每个 exp 构造器中机械地添加一个 type 字段。
exp = Constant(const, **type**)
| Var(identifier, **type**)
| Cast(type target_type, exp, **type**)
| Unary(unary_operator, exp, **type**)
| Binary(binary_operator, exp, exp, **type**)
| Assignment(exp, exp, **type**)
| Conditional(exp condition, exp, exp, **type**)
| FunctionCall(identifier, exp* args, **type**)
Listing 11-7: 向 exp 节点添加类型信息
如果你使用的是面向对象的实现语言,并且每个 exp 都有一个公共基类,那么这就非常简单。你可以像 Listing 11-8 中所示那样,在基类中添加一个 type 字段。
class BaseExp {
`--snip--`
**type expType;**
}
Listing 11-8: 为 exp 节点添加类型
另一方面,如果你使用代数数据类型实现了 AST,那么这种方法会非常令人烦恼。你不仅需要更新每一个 exp 构造器,而且每当你想获取表达式的类型时,还必须在每个构造器上进行模式匹配。一个稍微不那么繁琐的方法,如 Listing 11-9 所示,是定义互相递归的 exp 和 typed_exp AST 节点。
typed_exp = TypedExp(type, exp)
exp = Constant(const)
| Var(identifier)
| Cast(type target_type, typed_exp)
| Unary(unary_operator, typed_exp)
| Binary(binary_operator, typed_exp, typed_exp)
| Assignment(typed_exp, typed_exp)
| Conditional(typed_exp condition, typed_exp, typed_exp)
| FunctionCall(identifier, typed_exp* args)
Listing 11-9: 向 exp 节点添加类型信息的另一种方式
无论你选择哪种方式,你都需要定义两个独立的 AST 数据结构——一个包含类型信息,一个不包含类型信息——或者在构建 AST 时,在解析器中为每个exp初始化一个空类型或虚拟类型。这里没有唯一正确的答案,这取决于你的实现语言和个人喜好。为了不强加某种特定方法,本书接下来的伪代码将使用两个函数来处理 AST 中的类型信息:set_type(e, t)返回带有类型t注解的e的副本,而get_type(e)返回来自e的类型注解。
类型检查表达式
一旦我们扩展了 AST 的定义,我们将重写在第九章中定义的typecheck_exp,使其返回每个处理过的exp AST 节点的新注解副本。
清单 11-10 展示了如何进行变量的类型检查。
typecheck_exp(e, symbols):
match e with
| Var(v) ->
v_type = symbols.get(v).type
if v_type is a function type:
fail("Function name used as variable")
return set_type(e, v_type)
清单 11-10:类型检查变量
首先,我们在符号表中查找变量的类型。然后,我们验证是否没有将函数名当作变量使用,就像我们在之前的章节中所做的一样。最后,我们为表达式标注上变量的类型并返回。
清单 11-11 展示了如何进行常量的类型检查。这很简单,因为不同类型的常量在抽象语法树(AST)中有不同的构造函数。
| Constant(c) ->
match c with
| ConstInt(i) -> return set_type(e, Int)
| ConstLong(l) -> return set_type(e, Long)
清单 11-11:类型检查常量
对于剩下的表达式,我们需要遍历任何子表达式并对它们进行标注。一个强制转换表达式的结果会有我们强制转换的目标类型。我们在清单 11-12 中进行了类型检查。
| Cast(t, inner) ->
typed_inner = typecheck_exp(inner, symbols)
cast_exp = Cast(t, typed_inner)
return set_type(cast_exp, t)
清单 11-12:类型检查强制转换表达式
结果为 1 或 0 表示真或假的表达式(包括比较和逻辑运算如!)的类型为int。算术和按位运算表达式的结果类型与它们的操作数类型相同。对于一元表达式,这很直接,我们在清单 11-13 中进行了类型检查。
| Unary(op, inner) ->
typed_inner = typecheck_exp(inner, symbols)
unary_exp = Unary(op, typed_inner)
match op with
| Not -> return set_type(unary_exp, Int)
| _ -> return set_type(unary_exp, get_type(typed_inner))
清单 11-13:类型检查一元表达式
二元表达式更为复杂,因为两个操作数可能具有不同的类型。对于逻辑 && 和 || 操作,这并不重要,因为它们可以依次评估每个操作数的真值。对于比较和算术操作,则需要同时使用两个操作数,这就变得重要了。C 标准定义了一组规则,称为 通常的算术转换,用于隐式地将算术表达式的两个操作数转换为相同类型,这个类型被称为 公共类型 或 公共实数类型。
给定两个操作数的类型,清单 11-14 展示了如何找到它们的公共实数类型。目前这很简单,因为只有两种可能的类型。
get_common_type(type1, type2):
if type1 == type2:
return type1
else:
return Long
清单 11-14:找到公共实数类型
如果两个类型已经相同,则不需要进行转换。如果它们不同,我们将较小的类型(必须是 Int)转换为较大的类型(必须是 Long),因此公共类型是 Long。一旦我们添加更多类型,找到公共类型就不再如此简单。
一旦我们知道了两个操作数将被转换成的公共类型,我们就可以使用 清单 11-15 中展示的 convert_to 辅助函数,将这些类型转换显式化。
convert_to(e, t):
if get_type(e) == t:
return e
cast_exp = Cast(t, e)
return set_type(cast_exp, t)
清单 11-15:将隐式类型转换显式化
如果一个表达式已经具有正确的结果类型,convert_to 将返回它,并保持不变。否则,它会将表达式包装在一个 Cast AST 节点中,然后用正确的类型对结果进行注解。
有了这两个辅助函数,我们就可以对二元表达式进行类型检查。清单 11-16 展示了 typecheck_exp 的相关部分。
| Binary(op, e1, e2) ->
❶ typed_e1 = typecheck_exp(e1, symbols)
typed_e2 = typecheck_exp(e2, symbols)
if op is And or Or:
binary_exp = Binary(op, typed_e1, typed_e2)
return set_type(binary_exp, Int)
❷ t1 = get_type(typed_e1)
t2 = get_type(typed_e2)
common_type = get_common_type(t1, t2)
converted_e1 = convert_to(typed_e1, common_type)
converted_e2 = convert_to(typed_e2, common_type)
binary_exp = Binary(op, converted_e1, converted_e2)
❸ if op is Add, Subtract, Multiply, Divide, or Remainder:
return set_type(binary_exp, common_type)
else:
return set_type(binary_exp, Int)
清单 11-16:对二元表达式进行类型检查
我们从对两个操作数进行类型检查开始❶。如果操作符是 And 或 Or,我们不进行任何类型转换。否则,我们执行常规的算术类型转换❷。我们首先找到共同类型,然后将两个操作数转换为该类型。(实际上,至少有一个操作数已经是正确的类型,因此 convert_to 将返回未更改的操作数。)接下来,我们使用这些转换后的操作数构建新的 Binary AST 节点。最后,我们用正确的结果类型注释新的 AST 节点❸。如果这是一个算术操作,结果将具有与操作数相同的类型,也就是我们之前找到的共同类型。否则,它是一个比较,结果是整数形式的真或假,因此结果类型是 Int。
在赋值表达式中,我们将赋值的值转换为它所赋给的对象的类型。清单 11-17 给出了这种情况的伪代码。
| Assignment(left, right) ->
typed_left = typecheck_exp(left, symbols)
typed_right = typecheck_exp(right, symbols)
left_type = get_type(typed_left)
converted_right = convert_to(typed_right, left_type)
assign_exp = Assignment(typed_left, converted_right)
return set_type(assign_exp, left_type)
清单 11-17:赋值表达式的类型检查
记住,赋值表达式的结果是赋值后左侧操作数的值;不出所料,结果的类型也与左侧操作数相同。
条件表达式的工作方式类似于二元算术表达式:我们找到两个分支的共同类型,将两个分支转换为该共同类型,并用该类型标注结果。我们会对控制条件进行类型检查,但不需要将其转换为其他类型。我不会为此提供伪代码。
最后但同样重要的是,清单 11-18 展示了如何进行函数调用的类型检查。
| FunctionCall(f, args) ->
f_type = symbols.get(f).type
match f_type with
| FunType(param_types, ret_type) ->
if length(param_types) != length(args):
fail("Function called with the wrong number of arguments")
converted_args = []
❶ for (arg, param_type) in zip(args, param_types):
typed_arg = typecheck_exp(arg, symbols)
converted_args.append(convert_to(typed_arg, param_type))
call_exp = FunctionCall(f, converted_args)
❷ return set_type(call_exp, ret_type)
| _ -> fail("Variable used as function name")
清单 11-18:函数调用的类型检查
我们从符号表中查找函数类型。就像在前几章中一样,我们需要确保我们尝试调用的标识符实际上是一个函数,并且我们传递给它正确数量的参数。然后,我们同时遍历函数的参数和形参❶。我们对每个参数进行类型检查,然后将其转换为对应的形参类型。最后,我们用函数的返回类型注释整个表达式❷。
类型检查 return 语句
当一个函数返回一个值时,它会隐式地转换为该函数的返回类型。类型检查器需要将这种隐式转换显式化。为了进行return语句的类型检查,我们需要查找封闭函数的返回类型,并将返回值转换为该类型。这要求我们跟踪当前正在类型检查的函数的名称,或者至少是返回类型。为了简化,我将省略类型检查return语句的伪代码,因为它非常直接。
类型检查声明和更新符号表
接下来,我们将更新如何类型检查函数和变量声明,并且更新我们在符号表中存储的信息。首先,我们需要为符号表中的每个条目记录正确的类型;我们不能仅仅假设每个变量、参数和返回值都是int类型。其次,每当我们检查是否存在冲突的声明时,我们需要验证当前声明和之前的声明是否具有相同的类型。仅仅检查一个变量之前是否被声明为一个函数,或者一个函数是否之前被声明为不同数量的参数是不够的;它们的类型必须完全相同。例如,如果一个变量被声明为int类型,之后又被重新声明为long类型,类型检查器应该抛出一个错误。第三,当我们进行自动变量的类型检查时,我们需要将它的初始化器转换为该变量的类型,就像我们将赋值表达式右侧的值转换为左侧类型一样。
最后,我们将改变符号表中静态初始化器的表示方式。静态初始化器,像常量表达式一样,现在可以是int类型或long类型。清单 11-19 给出了静态初始化器的更新定义。
initial_value = Tentative | Initial(**static_init**) | NoInitializer
**static_init = IntInit(int) | LongInit(int)**
清单 11-19:符号表中的静态初始化器
这个 static_init 的定义可能看起来很冗余,因为它与 Listing 11-2 中定义的 const AST 节点基本相同,但它们将在后续章节中有所不同。像 ConstInt 和 ConstLong AST 节点一样,你应该仔细选择在实现语言中使用什么整数类型来表示这两种初始化器。特别重要的是确保 LongInit 能够容纳任何带符号的 64 位整数。
在将表达式转换为静态初始化器时,你可能需要执行类型转换。例如,假设一个程序包含以下声明:
static int i = 100L;
常量 100L 在我们的抽象语法树(AST)中将被解析为 ConstLong。由于它被赋值给一个静态的 int,我们需要在编译时将其从 long 强制转换为 int,并将其作为 IntInit(100) 存储在符号表中。这种类型的转换在使用一个太大以至于无法用 32 位表示的 long 常量初始化 int 类型变量时特别棘手,例如在下面的声明中:
static int i = 2147483650L;
根据我们之前指定的实现定义行为,我们需要从该值中减去 2³²,直到它足够小,可以适应 int 类型。这样得到的结果是 -2,147,483,646,所以我们在符号表中记录的初始值应该是 IntInit(-2147483646)。理想情况下,你可以使用已经具有适当语义的带符号整数类型来进行类型转换,这样你就不必自己处理这些常量的二进制表示了。
这里有一些提示可以帮助你处理静态初始化器:
使你的常量类型转换代码可重用。
类型检查器并不是唯一一个会将常量转换为不同类型的地方。在 Part III 中,你将实现 TACKY 中的常量折叠。常量折叠阶段将会计算常量表达式,包括类型转换。你可能希望将你的类型转换代码构建为一个独立的模块,以便在之后的常量折叠过程中重用。
不要在静态初始化器上调用 typecheck_exp。
将每个静态初始化器直接转换为static_init,而不首先调用typecheck_exp。这样做将简化后续章节中的内容,因为在后面的章节中,typecheck_exp会以更复杂的方式转换表达式。
TACKY 生成
在本章中,我们将对 TACKY AST 做出一些修改。首先,我们将为每个顶级的StaticVariable添加类型,并用我们新定义的static_init构造表示每个静态变量的初始值:
StaticVariable(identifier, bool global, **type t, static_init init**)
我们还将重新使用来自列表 11-2 的const构造来表示常量:
val = Constant(**const**) | Var(identifier)
最后,我们将引入几个新指令,用于在不同类型之间转换值:
SignExtend(val src, val dst)
Truncate(val src, val dst)
SignExtend和Truncate指令分别将
program = Program(top_level*)
top_level = Function(identifier, bool global, identifier* params, instruction* body)
| StaticVariable(identifier, bool global, **type t, static_init init**)
instruction = Return(val)
**| SignExtend(val src, val dst)**
**| Truncate(val src, val dst)**
| Unary(unary_operator, val src, val dst)
| Binary(binary_operator, val src1, val src2, val dst)
| Copy(val src, val dst)
| Jump(identifier target)
| JumpIfZero(val condition, identifier target)
| JumpIfNotZero(val condition, identifier target)
| Label(identifier)
| FunCall(identifier fun_name, val* args, val dst)
val = Constant(**const**) | Var(identifier)
unary_operator = Complement | Negate | Not
binary_operator = Add | Subtract | Multiply | Divide | Remainder | Equal | NotEqual
| LessThan | LessOrEqual | GreaterThan | GreaterOrEqual
列表 11-20:为 TACKY 添加对长整型的支持
我们将在生成 TACKY 时,通过查找符号表中的类型和初始化信息来处理对StaticVariable的更改。如果符号表中有静态变量的暂定定义,我们将根据其类型初始化为IntInit(0)或LongInit(0)。
处理常量就更加简单了;其逻辑与前面章节基本相同。我们将直接把Constant AST 节点转换为 TACKY 的Constant,因为它们都使用相同的const定义。
回想一下,当我们将逻辑&&或||表达式转换为 TACKY 时,我们显式地将 1 或 0 赋值给保存表达式结果的变量。由于这些逻辑表达式的类型都是int,我们将它们的结果表示为ConstInt(1)和ConstInt(0)。
列表 11-21 展示了如何将类型转换表达式转换为 TACKY。我们将使用在上一个步骤中添加的类型信息来确定我们要转换的类型。
emit_tacky(e, instructions, symbols):
match e with
| `--snip--`
| Cast(t, inner) ->
result = emit_tacky(inner, instructions, symbols)
if t == get_type(inner):
❶ return result
dst_name = make_temporary()
symbols.add(dst_name, t, attrs=LocalAttr)
dst = Var(dst_name)
if t == Long:
❷ instructions.append(SignExtend(result, dst))
else:
❸ instructions.append(Truncate(result, dst))
return dst
列表 11-21:将类型转换表达式转换为 TACKY
如果内部表达式已经具有我们要转换到的类型,则转换没有任何效果;我们发出 TACKY 来评估内部表达式,但不执行其他操作 ❶。否则,我们将发出SignExtend指令,将int转换为long ❷,或者发出Truncate指令,将long转换为int ❸。
跟踪临时变量的类型
当我们在列表 11-21 中创建临时变量dst时,我们将其与适当的类型一起添加到符号表中。我们需要为每个创建的临时变量执行此操作,以便在汇编生成阶段可以查找它们的类型。汇编生成阶段将以两种方式使用此类型信息:确定每条汇编指令的操作数大小,以及确定为每个变量分配多少栈空间。
我们添加的每个临时变量都保存一个表达式的结果,因此我们可以通过检查表达式的类型注解来确定其类型。让我们再看一遍列表 3-9,它展示了如何将二元算术表达式转换为 TACKY。列表 11-22 演示了相同的转换,其中来自列表 3-9 的更改已加粗。
emit_tacky(e, instructions, **symbols**):
match e with
| `--snip--`
| Binary(op, e1, e2) ->
v1 = emit_tacky(e1, instructions, **symbols**)
v2 = emit_tacky(e2, instructions, **symbols**)
dst_name = make_temporary()
**symbols.add(dst_name, get_type(e), attrs=LocalAttr)**
dst = Var(dst_name)
tacky_op = convert_binop(op)
instructions.append(Binary(tacky_op, v1, v2, dst))
return dst
| `--snip--`
列表 11-22:将二元表达式转换为 TACKY 时跟踪临时变量的类型
这里的主要变化是将 dst 添加到符号表中。由于 dst 保存了表达式 e 的结果,我们查找 e 的类型注释以确定 dst 的类型。像所有临时变量一样,dst 是一个局部自动变量,因此我们将在符号表中给它添加 LocalAttr 属性。
让我们将其重构为一个辅助函数,如 列表 11-23 所示。
make_tacky_variable(var_type, symbols):
var_name = make_temporary()
symbols.add(var_name, var_type, attrs=LocalAttr)
return Var(var_name)
列表 11-23:用于生成 TACKY 变量的辅助函数
从现在开始,我们将在生成 TACKY 中的临时变量时使用 make_tacky_variable。
生成额外的返回指令
在第五章中,我提到过,我们在每个 TACKY 函数的末尾添加了一条额外的 Return 指令,以防原始 C 函数中的每个执行路径都没有到达 return 语句。这条额外的指令总是返回 ConstInt(0),即使函数的返回类型不是 int。当我们从 main 返回时,这是正确的返回类型。当我们从任何其他没有显式 return 语句的函数返回时,返回值是未定义的。我们仍然需要将控制权返回给调用者,但我们没有义务返回任何特定的值,因此如果我们把类型弄错了也没关系。
汇编生成
本章我们将对汇编 AST 做出几处修改。列表 11-24 给出了完整的定义,修改部分已加粗。
program = Program(top_level*)
**assembly_type = Longword | Quadword**
top_level = Function(identifier name, bool global, instruction* instructions)
| StaticVariable(identifier name, bool global, **int alignment, static_init init**)
instruction = Mov(**assembly_type,** operand src, operand dst)
**| Movsx(operand src, operand dst)**
| Unary(unary_operator, **assembly_type**, operand)
| Binary(binary_operator, **assembly_type**, operand, operand)
| Cmp(**assembly_type**, operand, operand)
| Idiv(**assembly_type**, operand)
| Cdq(**assembly_type**)
| Jmp(identifier)
| JmpCC(cond_code, identifier)
| SetCC(cond_code, operand)
| Label(identifier)
| Push(operand)
| Call(identifier)
| Ret
unary_operator = Neg | Not
binary_operator = Add | Sub | Mult
operand = Imm(int) | Reg(reg) | Pseudo(identifier) | Stack(int) | Data(identifier)
cond_code = E | NE | G | GE | L | LE
reg = AX | CX | DX | DI | SI | R8 | R9 | R10 | R11 | **SP**
列表 11-24:支持四字操作数和 8 字节静态变量的汇编 AST
最大的变化是为大多数指令标记其操作数的类型。这使得我们可以在汇编发射过程中为每条指令选择正确的后缀。我们还会为Cdq添加一个类型,因为 32 位版本的Cdq将 EAX 扩展到 EDX,而 64 位版本将 RAX 扩展到 RDX。只有三条指令需要操作数,但不需要类型:SetCC,它只接受字节大小的操作数;Push,它总是压入四字(quadword);以及新的Movsx指令,我们稍后会讨论。
我们将不再复用之前定义的源级类型,而是定义一个新的assembly_type结构。这将简化汇编类型的工作,因为我们将在后续章节中引入更多的 C 类型。例如,我们将在第十二章中添加无符号整数,但汇编语言并不区分有符号和无符号整数。
在生成汇编时,我们将根据操作数的类型确定每条指令的类型。例如,我们将转换 TACKY 指令
Binary(Add, Constant(ConstInt(3)), Var("src"), Var("dst"))
这些汇编指令如下:
Mov(Longword, Imm(3), Pseudo("dst"))
Binary(Add, Longword, Pseudo("src"), Pseudo("dst"))
由于第一个操作数是ConstInt,我们知道生成的mov和add指令应该使用长字(longword)操作数。我们可以假设第二个操作数和目标与第一个操作数具有相同的类型,因为在 TACKY 生成过程中我们已经插入了适当的类型转换指令。如果操作数是变量而不是常量,我们将查找其在符号表中的类型。
我们还将弄清楚如何根据参数的类型传递堆栈参数。列表 11-25 重现了convert_function_call的相关部分,该部分我们在列表 9-31 中定义过,并且已将此次更改加粗。
convert_function_call(FunCall(fun_name, args, dst)):
`--snip--`
// pass args on stack
for tacky_arg in reverse(stack_args):
assembly_arg = convert_val(tacky_arg)
if assembly_arg is a Reg or Imm operand **or has type Quadword**:
emit(Push(assembly_arg))
else:
emit(Mov(**Longword**, assembly_arg, Reg(AX)))
emit(Push(Reg(AX)))
`--snip--`
列表 11-25:在 convert_function_call 中传递四字(quadword)数据
在 第九章中,我们学到,如果我们使用一个 8 字节的 pushq 指令将一个 4 字节的操作数从内存推送到栈上,可能会遇到问题。为了解决这个问题,我们发出两个指令来将一个 4 字节的 Pseudo 推送到栈上:我们将其复制到 EAX 寄存器中,然后推送 RAX 寄存器。8 字节的 Pseudo 不需要此解决方法;我们通过一个 Push 指令将其传递到栈上,方式与传递立即数值相同。
为了处理从 int 到 long 的转换,我们将使用符号扩展指令 movsx。目前,这个指令不需要类型信息,因为它的源操作数必须是 int 类型,目标操作数必须是 long 类型。我们将转换
SignExtend(src, dst)
到:
Movsx(src, dst)
为了截断一个值,我们只需使用 4 字节的 movl 指令将其最低的 4 字节移动到目标位置。我们将转换
Truncate(src, dst)
到:
Mov(Longword, src, dst)
我们还调整了 StaticVariable 结构:
StaticVariable(identifier name, bool global, **int alignment, static_init init**)
我们保留了 TACKY 中的 static_init 结构,因此我们知道是否为每个静态变量初始化 4 或 8 字节。我们还添加了一个 alignment 字段,因为我们需要在汇编中指定每个静态变量的对齐方式。
最后,我们已从汇编 AST 中移除了 DeallocateStack 和 AllocateStack 指令。这些指令只是四字节加法和减法的占位符,现在我们可以通过普通的 addq 和 subq 指令表示它们。由于 DeallocateStack 和 AllocateStack 代表的是对 RSP 的加减操作,我们还将 RSP 寄存器添加到汇编 AST 中,以便在正常的指令中使用它。在前面的章节中,我们通过以下指令在函数调用前保持栈对齐:
AllocateStack(bytes)
现在我们将改用以下指令:
Binary(Sub, Quadword, Imm(bytes), Reg(SP))
类似地,不再使用
DeallocateStack(bytes)
我们将使用
Binary(Add, Quadword, Imm(bytes), Reg(SP))
用于在函数调用后恢复栈指针。
表格 11-1 至 11-4 总结了本章对从 TACKY 转换到汇编的更新。新增的结构和对现有结构的转换更改以粗体显示。
表 11-1: 将顶级 TACKY 构造转换为汇编
| TACKY 顶级构造 | 汇编顶级构造 |
|---|---|
| Function(name, global, params, instructions) |
Function(name, global,
[Mov(<param1 type>, Reg(DI), param1),
Mov(<param2 type>, Reg(SI), param2),
<copy next four parameters from registers>,
Mov(<param7 type>, Stack(16), param7),
Mov(<param8 type>, Stack(24), param8),
<copy remaining parameters from stack>] +
instructions)
|
| StaticVariable(name, global, t, init) | StaticVariable(name, global, |
|---|
表 11-2: 将 TACKY 指令转换为汇编
| TACKY 指令 | 汇编指令 |
|---|---|
| Return(val) | Mov( |
| Unary(Not, src, dst) | Cmp(
SetCC(E, dst) |
| Unary(unary_operator, src, dst) | Mov( |
|---|
| Binary(Divide, src1, src2, dst) | Mov(
Idiv(
Mov(
| Binary(Remainder, src1, src2, dst) | Mov(
Idiv(
Mov(
| Binary(arithmetic_operator, src1, src2, dst) | Mov( |
|---|
| Binary(relational_operator, src1, src2, dst) | Cmp(
SetCC(relational_operator, dst) |
| JumpIfZero(condition, target) | Cmp( |
|---|---|
| JumpIfNotZero(condition, target) | Cmp( |
| 复制(Copy(src, dst)) | Mov(<src 类型>, src, dst) |
| 函数调用(FunCall(fun_name, args, dst)) | <修正栈对齐> <设置参数>
调用(Call(fun_name))
<释放参数/填充>
Mov(<dst 类型>, Reg(AX), dst) |
| 符号扩展(SignExtend(src, dst)) | Movsx(src, dst) |
|---|---|
| 截断(Truncate(src, dst)) | Mov(Longword, src, dst) |
表 11-3: 将 TACKY 操作数转换为汇编
| TACKY 操作数 | 汇编操作数 |
|---|---|
| 常量(整型常量(ConstInt(int))) | 立即数(Imm(int)) |
| 常量(ConstLong(int)) | 立即数(Imm(int)) |
表 11-4: 将类型转换为汇编
| 源类型 | 汇编类型 | 对齐方式 |
|---|---|---|
| 整型(Int) | 长整型(Longword) | 4 |
| 长整型(Long) | 四倍长整型(Quadword) | 8 |
在表 11-3 中,我们将两种类型的 TACKY 常量转换为 立即数(Imm) 操作数。在汇编中,4 字节和 8 字节的立即数值没有区别。汇编器根据操作数所在指令的大小推断立即数的大小。
表 11-4 给出了从源级到汇编类型的转换,以及每种类型的对齐方式。请注意,这种转换与表 11-1 到表 11-3 中的转换方式有所不同,因为当我们遍历一个 TACKY 程序时,我们不会遇到可以直接转换为汇编程序中assembly_type节点的type AST 节点。如我们所见,通常我们需要推断 TACKY 指令的操作数类型,然后才能将其转换为汇编类型。唯一具有显式类型的 TACKY 构造是StaticVariable,但我们不需要将此类型转换为汇编类型;我们只需要计算它的对齐方式。我们将在此编译器步骤的下一步中再次使用表 11-4 中的转换方法,在那里我们将构建一个新的符号表来跟踪汇编类型。
在后端符号表中跟踪汇编类型
在将 TACKY 程序转换为汇编之后,我们将把符号表转换为更适合其余编译器步骤的形式。这个新的符号表将存储变量的汇编类型,而不是它们的源类型。它还将存储我们在伪寄存器替换、指令修正和代码生成步骤中需要查找的一些其他属性。我将把这个新的符号表称为后端符号表,将现有的符号表称为前端符号表,或者简称符号表。
后端符号表将每个标识符映射到一个asm_symtab_entry构造,该构造在清单 11-26 中定义。
asm_symtab_entry = ObjEntry(assembly_type, bool is_static)
| FunEntry(bool defined)
清单 11-26:后端符号表中条目的定义
我们将使用ObjEntry来表示变量(以及在后续章节中,常量)。我们将跟踪每个对象的汇编类型,以及它是否具有静态存储持续时间。FunEntry表示函数。我们不需要跟踪函数的类型——这也好,因为assembly_type无法表示函数类型——但我们会跟踪它们是否在当前翻译单元中定义。如果你在符号表中跟踪每个函数的栈帧大小,可以在FunEntry构造函数中添加一个额外的stack_frame_size字段。我建议将后端符号表设为全局变量或单例,就像现有的前端符号表一样。
在 TACKY 到汇编的转换过程结束时,您应该遍历前端符号表,并将每个条目转换为后端符号表中的条目。这个过程足够简单,我不会提供伪代码。您还需要更新伪寄存器替换、指令修复和代码生成过程中涉及前端符号表的地方,并改为使用后端符号表。
替换长字和四字伪寄存器
伪寄存器替换过程需要进行几个修改。首先,我们将扩展它,以替换新的movsx指令中的伪寄存器。其次,每当我们将栈地址分配给伪寄存器时,我们会在后端符号表中查找该伪寄存器的类型,以确定分配多少空间。如果它是四字,我们将分配 8 字节;如果它是长字,我们将像以前一样分配 4 字节。最后,我们将确保每个四字伪寄存器在栈上的地址是 8 字节对齐的。考虑以下汇编代码片段:
Mov(Longword, Imm(0), Pseudo("foo"))
Mov(Quadword, Imm(1), Pseudo("bar"))
假设我们在后端符号表中查找foo的类型,发现它是 4 字节。我们将像往常一样把它分配给-4(%rbp)。接下来,我们查找bar,发现它是 8 字节。我们可以将它分配给-12(%rbp),即在foo下方 8 字节的位置。但这样的话,bar就会发生错位,因为它的地址不是 8 字节的倍数。(记住,RBP 中的地址始终是 16 字节对齐的。)为了保持正确的对齐,我们将向下取整到下一个 8 的倍数,将bar存储在-16(%rbp)。对齐要求是 System V ABI 的一部分;如果忽略它们,您的代码可能与其他翻译单元中的代码无法正确交互。
修复指令
我们将在本章中对指令修复过程进行几处更新。首先,我们需要为现有的重写规则中的所有指令指定操作数大小。这些大小应该始终与被重写的原始指令的操作数大小相同。
接下来,我们将重写 movsx 指令。它不能使用内存地址作为目标,也不能使用立即数作为源。如果 movsx 的两个操作数无效,我们需要同时使用 R10 和 R11 来修复它们。例如,我们将重写
Movsx(Imm(10), Stack(-16))
改为:
Mov(Longword, Imm(10), Reg(R10))
Movsx(Reg(R10), Reg(R11))
Mov(Quadword, Reg(R11), Stack(-16))
在此重写规则中,使用每个 mov 指令时,重要的是使用正确的操作数大小。由于 movsx 的源操作数为 4 字节,因此在将该操作数移入寄存器时,我们指定一个长字(longword)操作数大小。由于 movsx 的结果为 8 字节,因此在将结果移动到其最终内存位置时,我们指定一个四字(quadword)操作数大小。
我们的三条二进制算术指令的四字版本(addq、imulq 和 subq)不能处理不适合 int 的立即数,cmpq 和 pushq 也不能。如果任何这些指令的源操作数超出了 int 的范围,我们需要先将其复制到 R10 中,然后才能使用它。
movq 指令可以将这些非常大的立即数值移动到寄存器中,但不能直接移入内存,因此
Mov(Quadword, Imm(4294967295), Stack(-16))
应重写为:
Mov(Quadword, Imm(4294967295), Reg(R10))
Mov(Quadword, Reg(R10), Stack(-16))
注意
汇编器仅在立即数可以表示为 有符号 32 位整数时,才允许在 addq、imulq、subq、cmpq 或 pushq 中使用立即数。这是因为这些指令都会将其立即操作数从 32 位符号扩展到 64 位。如果一个立即数仅能表示为 32 位的 无符号 整数(这意味着其高位被设置),符号扩展将改变其值。有关更多详细信息,请参见此 Stack Overflow 问题: <wbr>stackoverflow<wbr>.com<wbr>/questions<wbr>/64289590<wbr>/integer<wbr>-overflow<wbr>-in<wbr>-gas。
我们还将修复在每个函数开始时分配堆栈空间的方式。我们将不再在每个函数中添加 AllocateStack(bytes) 来分配堆栈空间,而是添加以下指令,执行相同的操作:
Binary(Sub, Quadword, Imm(bytes), Reg(SP))
我们将添加最后一条重写规则来安抚汇编器,尽管这不是绝对必要的。记住,我们将Truncate TACKY 指令转换为一个 4 字节的movl指令,这意味着我们可以生成将 8 字节立即数移动到 4 字节目标位置的movl指令:
Mov(Longword, Imm(4294967299), Reg(R10))
由于movl不能使用 8 字节的立即数,汇编器会自动将这些值截断为 32 位。例如,当它处理指令movl $4294967299, %r10d时,它会将立即数4294967299转换为3。GNU 汇编器在进行这个转换时会发出警告,但 LLVM 汇编器则不会。为了避免这些警告,我们将自己在movl指令中截断 8 字节立即数。这意味着我们将把之前的指令重写为:
Mov(Longword, Imm(3), Reg(R10))
除去汇编器警告,即使不包括这个重写规则,你的汇编程序依然能正常工作。
代码生成
我们的最终任务是扩展代码生成阶段。我们将为每条指令添加适当的后缀,发出正确的对齐和静态变量的初始值,并处理新的Movsx指令。每当指令使用寄存器时,我们会为该指令的操作数大小发出相应的寄存器名称。
操作数为 4 字节的指令有一个 l 后缀,表示长字(longword),而操作数为 8 字节的指令有一个 q 后缀,表示四倍字(quadword),有一个例外:8 字节版本的 cdq 有一个完全不同的助记符 cqo。Movsx 指令会根据其源操作数和目标操作数的大小添加后缀。例如,movslq 会将一个长字扩展为四倍字。现在,我们将始终用 lq 后缀发出此指令;随着我们在后续章节中添加更多汇编类型,将需要更多后缀。(你也可能看到此指令写作 movsx,当汇编器能够推断出两个操作数的大小时。例如,汇编器会接受指令 movsx %r10d, %r11,因为它可以从寄存器名称推断出源和目标的大小。)
表 11-5 到 11-10 总结了本章对代码发射过程的更新。新结构和对现有结构的更改已加粗。
表 11-5: 格式化顶级汇编结构
| 汇编顶级结构 | 输出 | |
|---|---|---|
| StaticVariable(name, global, alignment, init) | 初始化为零 |
<global-directive>
.bss
<alignment-directive>
<name>:
<init>
|
| 初始化为非零值 |
|---|
<global-directive>
.data
<alignment-directive>
<name>:
<init>
|
| 对齐指令 | 仅限 Linux | .align |
|---|---|---|
| macOS 或 Linux | .balign |
表 11-6: 格式化静态初始化器
| 静态初始化器 | 输出 |
|---|---|
| IntInit(0) | .zero 4 |
| IntInit(i) | .long |
| LongInit(0) | .zero 8 |
| LongInit(i) | .quad |
表 11-7: 汇编指令的格式化
| 汇编指令 | 输出 |
|---|---|
| Mov(t, src, dst) |
mov<t> <src>, <dst>
|
| Movsx(src, dst) |
|---|
movslq <src>, <dst>
|
| Unary(unary_operator, t, operand) |
|---|
<unary_operator><t> <operand>
|
| Binary(binary_operator, t, src, dst) |
|---|
<binary_operator><t> <src>, <dst>
|
| Idiv(t, operand) |
|---|
idiv<t> <operand>
|
| Cdq(Longword) |
|---|
cdq
|
| Cdq(Quadword) |
|---|
cqo
|
| Cmp(t, operand, operand) |
|---|
cmp<t> <operand>, <operand>
|
表 11-8: 汇编操作符的指令名称
| 汇编操作符 | 指令名称 |
|---|---|
| Neg | neg |
| Not | not |
| 加法 | add |
| 减法 | sub |
| 乘法 | imul |
表 11-9: 汇编类型的指令后缀
| 汇编类型 | 指令后缀 |
|---|---|
| 长字 | l |
| 四字长 | q |
表 11-10: 格式化汇编操作数
| 汇编操作数 | 输出 |
|---|---|
| Reg(SP) | %rsp |
表 11-6 展示了如何打印表示静态变量初始化器的 static_init 构造体。表 11-8 展示了从一元和二元操作符到没有后缀的指令名称的映射;后缀现在取决于指令的类型(如表 11-9 所示)。除了后缀外,这些指令名称与早期章节中的相同。
一旦你更新了代码生成阶段,就可以开始测试你的编译器了。
总结
现在你的编译器有了类型系统!在这一章中,你为抽象语法树(AST)添加了类型信息,使用符号表在多个编译阶段追踪类型信息,并在汇编生成时添加了对多种操作数大小的支持。长整数并不是最炫酷的语言特性,所以你可能觉得自己做了很多工作,却似乎没有太多展示的内容。但你在这一章中创建的基础设施将是你在第二部分中所做的一切工作的基础。在下一章,你将在此基础上继续实现无符号整数。

描述
第十二章:12 无符号整数

在本章中,您将实现我们两个有符号整数类型的无符号对等类型:unsigned int 和 unsigned long。您将扩展通常的算术转换以处理无符号整数,并实现有符号和无符号类型之间的强制转换。在后端,您将使用一些新的汇编指令来进行无符号整数算术运算。
在 第十一章 中,我们集中讨论了推断和追踪类型信息;现在我们将能够在此基础上,利用相对较少的工作来添加新类型。在修改编译器之前,让我们先快速概述一下有符号和无符号类型之间的转换。
类型转换,再次说明
每种整数类型的转换都有两个方面需要考虑:整数值的变化以及其二进制表示的变化。我们在上一章中看到过 int 和 long 之间的转换。符号扩展将有符号整数的表示从 32 位扩展到 64 位,而不改变其值。将 long 截断为 int 也会改变其表示,如果原始值无法适应新类型,还会改变其值。
有了这个区分,我将把我们的类型转换分为四种情况。在每种情况下,我会描述整数的表示如何变化。然后,我会解释这些变化如何与 C 标准中有关其值变化的规则相对应。
在相同大小的有符号和无符号类型之间转换
第一个情况是当我们在相同大小的有符号和无符号类型之间转换时:即,在 int 和 unsigned int 之间,或在 long 和 unsigned long 之间。这些转换不会改变整数的二进制表示。唯一改变的是我们是否使用二进制补码来解释其值。让我们考虑这种解释变化的影响。
如果一个有符号整数为正数,则其最高位将为 0,因此将其解释为无符号整数不会改变其值。反之亦然:如果一个无符号整数小于有符号类型能表示的最大值,则其最高位必定为 0。因此,如果我们使用二进制补码重新解释它,其值将不会发生变化。正如你在上一章中学到的,当我们将整数转换为新类型时,标准要求我们尽可能保留其值。我们在这里满足了这一要求。
这就留下了最高位为 1 的整数。当我们将一个有符号的负整数重新解释为无符号整数时,我们将最高位的值从负数变为正数。如果最高位是 1,这会导致值增加 2^N,其中N是该类型的位数。这正是标准所要求的行为;第 6.3.1.3 节第 2 段指出,如果新类型无法表示该值,并且新类型是无符号的,“则通过反复加或减去新类型能够表示的最大值加 1,直到该值落入新类型的范围。”
相反,将一个带有前导 1 的无符号类型转换为相应的有符号类型将会从其值中减去 2^N。这与我们在上一章中为有符号整数转换所选择的实现定义行为相符,遵循 GCC:“该值会按 2^N取模,以保持在类型范围内。”
将无符号整数转换为更大类型
第二种情况是当我们将无符号整数转换为更大类型时,可能是long或无符号长整数。处理这种情况时,我们会通过将新表示的高位填充为零来零扩展该整数。这种转换始终保留原始值,因为我们只是给正数添加了前导零。
将有符号整数转换为更大类型
第三个情况是我们将有符号的 int 转换为 long 或 unsigned long。我们已经使用符号扩展将 int 转换为 long。我们将以相同的方式将 int 转换为 unsigned long。如果 int 是正数,符号扩展将只会添加前导零,无论你将结果解释为有符号还是无符号,它的值都将保持不变。如果值是负数,符号扩展后再将结果解释为 unsigned long 将会向它的值添加 2⁶⁴,这是标准要求的。
从较大类型转换到较小类型
在最后一种情况中,我们将较大的类型(long 或 unsigned long)转换为较小的类型(int 或 unsigned int)。我们总是通过截断值来处理这种情况。这样会添加或减去 2³²,直到值落入新类型的范围——或者等效地,减少值对 2³² 取模——这就是我们想要的行为。我不会详细讲解为什么在每种情况下截断整数会产生正确的值;你可以自己做一些例子,或者相信我的话。
现在你知道了类型转换的预期效果,让我们开始编写编译器吧。
词法分析器
在这一章中,你将添加四个新令牌:
signed 一个关键字,用于指定有符号整数类型。
unsigned 一个关键字,用于指定无符号整数类型。
无符号整数常量 具有 u 或 U 后缀的整数常量。无符号常量令牌匹配正则表达式 [0-9]+[uU]\b。
无符号长整型常量 具有不区分大小写的 ul 或 lu 后缀的整数常量。无符号长整型常量的令牌匹配正则表达式 [0-9]+([lL][uU]|[uU][lL])\b。
更新你的词法分析器以支持这些令牌,然后进行测试。
语法分析器
接下来,我们将更新 AST 以支持这两种新的无符号类型及其对应的常量。这些更新在清单 12-1 中已加粗。
program = Program(declaration*)
declaration = FunDecl(function_declaration) | VarDecl(variable_declaration)
variable_declaration = (identifier name, exp? init,
type var_type, storage_class?)
function_declaration = (identifier name, identifier* params, block? body,
type fun_type, storage_class?)
type = Int | Long | **UInt | ULong |** FunType(type* params, type ret)
storage_class = Static | Extern
block_item = S(statement) | D(declaration)
block = Block(block_item*)
for_init = InitDecl(variable_declaration) | InitExp(exp?)
statement = Return(exp)
| Expression(exp)
| If(exp condition, statement then, statement? else)
| Compound(block)
| Break
| Continue
| While(exp condition, statement body)
| DoWhile(statement body, exp condition)
| For(for_init init, exp? condition, exp? post, statement body)
| Null
exp = Constant(const)
| Var(identifier)
| Cast(type target_type, exp)
| Unary(unary_operator, exp)
| Binary(binary_operator, exp, exp)
| Assignment(exp, exp)
| Conditional(exp condition, exp, exp)
| FunctionCall(identifier, exp* args)
unary_operator = Complement | Negate | Not
binary_operator = Add | Subtract | Multiply | Divide | Remainder | And | Or
| Equal | NotEqual | LessThan | LessOrEqual
| GreaterThan | GreaterOrEqual
const = ConstInt(int) | ConstLong(int) **| ConstUInt(int) | ConstULong(int)**
清单 12-1:包含无符号类型和无符号常量的抽象语法树
就像在上一章中你添加了ConstLong一样,你需要确保ConstUInt可以表示完整的unsigned int范围,而ConstULong可以表示完整的unsigned long范围。如果你的实现语言中有无符号的 32 位和 64 位整数类型,可以在这里使用它们。
清单 12-2 展示了更新后的语法,修改部分已加粗。
<program> ::= {<declaration>}
<declaration> ::= <variable-declaration> | <function-declaration>
<variable-declaration> ::= {<specifier>}+ <identifier> ["=" <exp>] ";"
<function-declaration> ::= {<specifier>}+ <identifier> "(" <param-list> ")" (<block> | ";")
<param-list> ::= "void"
| {<type-specifier>}+ <identifier> {"," {<type-specifier>}+ <identifier>}
<type-specifier> ::= "int" | "long" **| "unsigned" | "signed"**
<specifier> ::= <type-specifier> | "static" | "extern"
<block> ::= "{" {<block-item>} "}"
<block-item> ::= <statement> | <declaration>
<for-init> ::= <variable-declaration> | [<exp>] ";"
<statement> ::= "return" <exp> ";"
| <exp> ";"
| "if" "(" <exp> ")" <statement> ["else" <statement>]
| <block>
| "break" ";"
| "continue" ";"
| "while" "(" <exp> ")" <statement>
| "do" <statement> "while" "(" <exp> ")" ";"
| "for" "(" <for-init> [<exp>] ";" [<exp>] ")" <statement>
| ";"
<exp> ::= <factor> | <exp> <binop> <exp> | <exp> "?" <exp> ":" <exp>
<factor> ::= <const> | <identifier>
| "(" {<type-specifier>}+ ")" <factor>
| <unop> <factor> | "(" <exp> ")"
| <identifier> "(" [<argument-list>] ")"
<argument-list> ::= <exp> {"," <exp>}
<unop> ::= "-" | "~" | "!"
<binop> ::= "-" | "+" | "*" | "/" | "%" | "&&" | "||"
| "==" | "!=" | "<" | "<=" | ">" | ">=" | "="
<const> ::= <int> | <long> | **<uint> | <ulong>**
<identifier> ::= ? An identifier token ?
<int> ::= ? An int token ?
<long> ::= ? An int or long token ?
**<uint> ::= ? An unsigned int token ?**
**<ulong> ::= ? An unsigned int or unsigned long token ?**
清单 12-2:包含 signed 和 unsigned 类型说明符和无符号常量的语法
解析类型说明符比上一章更复杂,因为有许多不同的方式可以引用同一类型。例如,下面这些都是指定long类型的有效方式:
long
long int
signed long
signed long int
类型说明符的顺序并不重要,因此long signed、long int signed等都指定相同的类型。清单 12-3 中的伪代码提供了一种方式来规范这些混乱。
parse_type(specifier_list):
if (specifier_list is empty
or specifier_list contains the same specifier twice
or specifier_list contains both "signed" and "unsigned"):
fail("Invalid type specifier")
if specifier_list contains "unsigned" and "long":
return ULong
if specifier_list contains "unsigned":
return UInt
if specifier_list contains "long":
return Long
return Int
清单 12-3:从类型说明符列表中确定类型
我们首先检查错误情况。你至少需要一个说明符来指示类型,并且不能包含相同的说明符两次。例如,你不能指定类型为int long int。(long long类型说明符会使这个验证检查更复杂,但我们不打算实现它。)你也不能在同一类型说明中同时包含signed和unsigned说明符,因为它们是互相矛盾的。
一旦我们确认输入指定了有效的类型,我们会检查 unsigned 和 long 修饰符。如果两者都存在,那么类型就是 unsigned long。否则,如果存在 unsigned,类型就是 unsigned int;如果存在 long,类型就是 long;如果两者都不存在,类型就是 int。基本上,int 是默认类型,而 unsigned 和 long 修饰符可以指示除默认类型以外的其他类型。C 标准第 6.7.2 节,第 2 段列出了所有指定每种类型的方法。
我们还需要处理常量标记。在上一章中,列表 11-6 演示了如何解析有符号常量标记。我这里不再包括无符号常量标记的伪代码,但逻辑是一样的。如果无符号整数常量标记在 unsigned int 能表示的范围内(即在 0 和 2³² – 1 之间,包括 0 和最大值),我们将其解析为 ConstUInt。否则,我们将其解析为 ConstULong。
一个无符号长整型常量标记将始终解析为 ConstULong。如果任何类型的无符号常量标记不在 unsigned long 的范围内(介于 0 和 2⁶⁴ – 1 之间),我们会抛出一个错误。如果你感兴趣,C 标准第 6.4.4.1 节有关于如何确定整数常量类型的完整规则。 ### 类型检查器
在本章中我们不需要修改循环标记或标识符解析阶段。我们只需要在类型检查器中处理无符号整数。
首先,我们将更新通常算术转换的实现,这会隐式地将二元表达式中的操作数转换为通用类型。我们来了解一下整数类型的通常算术转换规则,这些规则在 C 标准第 6.3.1.8 节第 1 段中有定义。第一个规则非常直观:
如果两个操作数具有相同的类型,则不需要进一步的转换。
第二个稍微难理解一点:
否则,如果两个操作数都是有符号整数类型或都是无符号整数类型,则将具有较小整数转换等级的操作数转换为具有较大等级的操作数的类型。
这只是意味着“如果两个整数有相同的符号性,转换较小的类型为较大的类型。”我们在将值从int隐式转换为long时已经做到了这一点。第 6.3.1.1 节,第 1 段,规定了整数转换等级,它提供了每种整数类型的相对顺序,而不是精确地规定这些类型的大小。在我们目前为止使用的类型中,long和unsigned long具有最高的等级,然后是int和unsigned int。相同类型的有符号和无符号版本始终具有相同的等级。由于它们的相对转换等级,long保证至少和int一样大,但不一定更大。(事实上,在大多数 32 位系统上,这两种类型的大小是相同的。)无论它们的确切大小如何,long和int的共同类型是long,而unsigned long和unsigned int的共同类型是unsigned long。这里没有什么大惊小怪的。
第三条规则讨论的是有一个有符号操作数和一个无符号操作数的情况:
否则,如果具有无符号整数类型的操作数的等级大于或等于另一个操作数类型的等级,则具有有符号整数类型的操作数会转换为具有无符号整数类型的操作数的类型。
所以,如果两种类型的大小相同,或者无符号类型更大,我们选择无符号类型。例如,int和unsigned int的共同类型是unsigned int,而<int和unsigned long的共同类型是unsigned long。剩下的情况是有符号类型更大,这由第四条规则来处理:
否则,如果带符号整数类型的操作数能够表示无符号整数类型操作数的所有值,那么无符号整数类型的操作数将转换为带符号整数类型操作数的类型。
在 System V x64 ABI 下,long 可以表示 unsigned int 类型的所有值,因此 unsigned int 和 long 的公共类型是 long。在 long 和 int 相同大小的实现中,情况则不同。在这些实现中,long 的等级高于 int,但无法表示 unsigned int 类型的所有值。第五条规则涵盖了这些实现。尽管此规则不适用于我们,但我会为了完整性而包含它:
否则,两个操作数都将转换为与带符号整数类型操作数相对应的无符号整数类型。
因此,在 long 和 int 大小相同的系统上,long 和 unsigned int 的公共类型是 unsigned long。
这归结为查找公共类型的三条规则,清单 12-4 在伪代码中描述了这些规则。
get_common_type(type1, type2):
❶ if type1 == type2:
return type1
❷ if size(type1) == size(type2):
if type1 is signed:
return type2
else:
return type1
❸ if size(type1) > size(type2):
return type1
else:
return type2
清单 12-4:查找两个整数的公共类型
首先,如果类型相同,选择任意一个 ❶。否则,如果它们大小相同,选择无符号类型 ❷。如果它们大小不同,选择较大的类型 ❸。除了常规的算术转换,我们将对类型检查表达式的逻辑进行一次小更新:我们将使用正确的类型标注无符号常量,方式与我们已经标注带符号常量相同。
接下来,让我们看看如何在符号表中记录静态变量的初始值。我们将添加两种新的静态初始化器,就像我们添加了两种新的常量一样:
static_init = IntInit(int) | LongInit(int) | **UIntInit(int) | ULongInit(int)**
我们需要根据本章开始时介绍的类型转换规则,将每个初始化器转换为它所初始化的变量的类型。考虑以下声明:
static unsigned int u = 4294967299L;
值 4,294,967,299 超出了unsigned int的范围。当将u添加到符号表时,我们将通过从该值中减去 2³² 来将其转换为 unsigned int。(实际上,你可能只需要在实现语言中使用等效的整数类型转换。)结果初始化器将是 UIntInit(3)。
同样地,以下声明将一个超出其范围的 int 进行初始化:
static int i = 4294967246u;
一旦我们对该值进行 2³² 取模,结果初始化器将是 IntInit(-50)。
对于有符号和无符号变量,严格来说不需要使用不同的静态初始化器。相反,你可以使用 IntInit 来表示 int 和 unsigned int 的初始化器,使用 LongInit 来表示 long 和 unsigned long 的初始化器。最终,汇编器将为初始化器写出相同的字节,无论你将其表示为有符号还是无符号值:指令 .long -50 和 .long 4294967246 完全相同。将 UIntInit 和 ULongInit 初始化器分开,只是让我们的类型转换更容易追踪。
TACKY 生成
在本章中,我们将向 TACKY 添加一项:ZeroExtend 指令。列表 12-5 定义了整个 TACKY IR。
program = Program(top_level*)
top_level = Function(identifier, bool global, identifier* params, instruction* body)
| StaticVariable(identifier, bool global, type t, static_init init)
instruction = Return(val)
| SignExtend(val src, val dst)
| Truncate(val src, val dst)
**| ZeroExtend(val src, val dst)**
| Unary(unary_operator, val src, val dst)
| Binary(binary_operator, val src1, val src2, val dst)
| Copy(val src, val dst)
| Jump(identifier target)
| JumpIfZero(val condition, identifier target)
| JumpIfNotZero(val condition, identifier target)
| Label(identifier)
| FunCall(identifier fun_name, val* args, val dst)
val = Constant(const) | Var(identifier)
unary_operator = Complement | Negate | Not
binary_operator = Add | Subtract | Multiply | Divide | Remainder | Equal | NotEqual
| LessThan | LessOrEqual | GreaterThan | GreaterOrEqual
列表 12-5:将 ZeroExtend 指令添加到 TACKY
现在我们必须为将转换为和从无符号类型转换的类型转换表达式生成正确的 TACKY。在本章开始时,我们讨论了将整数转换为新类型如何影响其二进制表示的四种不同情况。列表 12-6 展示了在每种情况下应该发出什么样的 TACKY 指令。
emit_tacky(e, instructions, symbols):
match e with
| `--snip--`
| Cast(t, inner) ->
result = emit_tacky(inner, instructions, symbols)
inner_type = get_type(inner)
if t == inner_type:
return result
dst = make_tacky_variable(t, symbols)
❶ if size(t) == size(inner_type):
instructions.append(Copy(result, dst))
❷ else if size(t) < size(inner_type):
instructions.append(Truncate(result, dst))
❸ else if inner_type is signed:
instructions.append(SignExtend(result, dst))
❹ else:
instructions.append(ZeroExtend(result, dst))
return dst
列表 12-6:将类型转换表达式转换为 TACKY
与上一章一样,如果内部表达式已经具有正确的类型,则类型转换表达式不做任何操作。否则,我们检查原始类型和目标类型的大小是否相同❶。如果相同,我们不需要扩展、截断或以其他方式更改内部值,因为其在汇编中的表示不会改变。我们只是将其复制到一个具有正确类型的临时变量中。这里的Copy指令可能看起来多余,但我们需要它来帮助我们在汇编生成过程中跟踪类型信息。根据操作数是有符号还是无符号,我们将为某些 TACKY 指令生成不同的汇编。如果我们不将每个表达式的结果存储在正确类型的变量中,我们将生成不正确的汇编。
接下来,我们检查目标类型是否小于原始类型❷。如果是这种情况,我们会发出Truncate指令。如果该检查也失败,则该类型转换表达式将较小的类型转换为较大的类型。如果原始类型是有符号的❸,我们发出SignExtend指令;如果是无符号的❹,我们发出ZeroExtend指令。
一旦你的编译器为无符号常量和类型转换表达式生成了正确的 TACKY 指令,你就可以进行测试。
汇编中的无符号整数操作
在大多数情况下,我们可以使用完全相同的汇编指令来操作有符号和无符号值。然而,在两个情况下,我们需要不同地处理无符号值:比较和除法。我们还需要将新的ZeroExtend指令转换为汇编指令。在更新汇编生成阶段之前,让我们先看看在汇编中无符号比较、无符号除法和零扩展是如何工作的。
无符号比较
在第四章中,你学会了如何比较两个整数:发出cmp指令以设置 RFLAGS 寄存器,然后发出一个条件指令,其行为取决于该寄存器的状态。我们将采用相同的方法来比较无符号整数,但我们需要依赖不同的标志来使用不同的条件码。
执行算术运算的几条指令,包括add、sub和cmp,不区分有符号和无符号值。列表 12-7 演示了如何使用一个操作同时实现有符号和无符号加法。
1000
+ 0010
------
1010
列表 12-7:二进制整数加法
如果我们将操作数和结果解释为无符号的 4 位整数,清单 12-7 计算的是 8 + 2 = 10。如果我们将它们解释为有符号的 4 位整数,则计算的是 −8 + 2 = −6。只要我们一致地解释两个操作数和结果,无论哪种方式都能得到正确的答案。你可以将 add、sub 和大多数其他算术汇编指令的结果视为具有两种可能值的比特序列,一种是有符号的,一种是无符号的。
在处理器执行这些指令之一之后,RFLAGS 中的某些标志会告诉我们结果的有符号值,其他一些标志则告诉我们其无符号值,还有一些标志适用于这两种值。(有些标志与这些指令的结果无关,但我们不关心它们。)我们在第四章中讨论了三个标志:ZF,零标志;SF,符号标志;和 OF,溢出标志。无论我们将结果解释为有符号还是无符号,ZF 都适用,因为零在这两种情况下的表示方式是相同的。然而,SF 和 OF 标志仅在结果的有符号值上提供有意义的信息。
例如,SF 表示结果为负数。在第四章中,我们使用这个标志得出结论,a - b 是负数。在那种情况下,假设没有溢出,我们知道 a 小于 b。对于无符号值,这种方法不起作用,因为无符号值按定义是正数。考虑清单 12-8,它使用无符号的 4 位整数来计算 15 – 3。
1111
- 0011
------
1100
清单 12-8:减法二进制整数
由于 15 大于 3,因此此操作的结果是一个正数,12。结果具有前导 1 并没有告诉我们哪个操作数更大。类似地,OF 告诉我们某个指令结果的有符号值从正数环绕到负数,或反之,这并没有告诉我们任何关于其无符号值的有用信息。
要比较无符号整数,我们将使用 CF,即进位标志。该标志指示结果的无符号值发生了溢出,因为正确的值小于零或大于该类型能够表示的最大值。例如,假设我们要用无符号 4 位整数计算 15 + 1。15 的无符号 4 位表示为 1111;当我们将其加 1 时,它会回绕到 0000。这个计算将使进位标志被设置为 1。如果我们尝试计算 0 - 1,且结果在相反方向回绕到 1111,即 15,那么进位标志也会被设置为 1。如果 a < b,则 a - b 的结果总是回绕并设置进位标志。如果 a > b,则结果总是可以表示为无符号整数,因此不会发生回绕。让我们看看当我们将 a 和 b 作为无符号整数时,cmp b, a 会如何影响 CF 和 ZF:
-
如果 a == b,则 a - b 将为 0,因此 ZF 将为 1,CF 将为 0。
-
如果 a > b,则 a - b 将是一个正数。它将大于 0,但小于或等于 a,因此不会回绕。ZF 和 CF 都将为 0。
-
如果 a < b,则 a - b 将为负数,因此它必须回绕。ZF 将为 0,CF 将为 1。
请注意,ZF 和 CF 是互斥的;一个操作永远不会同时设置这两个标志。本章中所需的所有条件码都依赖于这两个标志之一或两者。表 12-1 列出了这些条件码。
表 12-1: 无符号比较的条件码
| 条件码 | 含义 | 标志 |
|---|---|---|
| E | a == b | ZF 被设置 |
| NE | a != b | ZF 未设置 |
| A | a > b | CF 未设置且 ZF 未设置 |
| AE | a >= b | CF 未设置 |
| B | a < b | CF 设置 |
| BE | a <= b | CF 设置或 ZF 设置 |
我们使用现有的 E 和 NE 条件码来测试相等和不等,但我们将使用新的代码来确定两个操作数中哪个更大。新代码中的 A 和 B 是“above”(上面)和“below”(下面)的助记符。新的条件码可以出现在条件跳转和设置指令中,就像旧的条件码一样。列表 12-9 演示了如何在 EAX 中设置 1,如果 EDX 中的无符号值大于 10。
cmpl $10, %edx
movl $0, %eax
seta %al
列表 12-9:在汇编中执行无符号比较
这与有符号比较的模式完全相同:我们发出 cmp 指令,然后清除目标值,最后发出带有适当条件码后缀的 set 指令。
无符号除法
对于大多数算术操作,相同的指令可以在有符号和无符号整数上正确操作。但对于除法,这种方式不适用。假设我们想要计算 1000 / 0010。如果将这些值解释为有符号 4 位整数,则为 −8 / 2,结果是 −4,表示为 1100。如果它们是无符号 4 位整数,则为 8 / 2,结果是 4,即 0100。单条指令无法在两种情况下都产生正确的结果。
因此,我们需要一个新的指令div来执行无符号除法。这个指令与idiv一样。它需要一个操作数,即除数。被除数是存储在 EDX 和 EAX 中的值,或者在处理四字时是存储在 RDX 和 RAX 中的值。它将商存储在 EAX 或 RAX 中,将余数存储在 EDX 或 RDX 中。
由于被除数是无符号的,我们将其从 EAX 扩展到 EDX(或从 RAX 扩展到 RDX),而不是进行符号扩展。我们通过将 RDX 清零,而不是发出cdq指令来实现这一点。
零扩展
我们需要实现的最后一个操作是零扩展。我们可以通过将一个长字移动到寄存器中来将其零扩展为四字;这将清除寄存器的上 4 个字节。然后,如果需要将值存储到内存中,我们可以将整个 8 字节的值移动到最终位置。以下代码将位于-4(%rbp)的值零扩展,然后将结果保存到-16(%rbp):
movl -4(%rbp), %eax
movq %rax, -16(%rbp)
我们使用 4 字节的movl指令将值复制到寄存器中,使用 8 字节的movq指令将其复制出来。如果零扩展操作的最终目标是寄存器而不是内存位置,我们只需要第一个 4 字节的movl指令。
还有一个独立的movz指令,它用于零扩展小于 4 字节的源值。我们暂时不需要这个指令,但在第十六章中实现字符类型时会用到它。
汇编生成
现在你已经知道如何在汇编中处理无符号整数,你可以开始扩展汇编生成阶段。清单 12-10 定义了最新的汇编 AST,本文中新增的部分已加粗。
program = Program(top_level*)
assembly_type = Longword | Quadword
top_level = Function(identifier name, bool global, instruction* instructions)
| StaticVariable(identifier name, bool global, int alignment, static_init init)
instruction = Mov(assembly_type, operand src, operand dst)
| Movsx(operand src, operand dst)
**| MovZeroExtend(operand src, operand dst)**
| Unary(unary_operator, assembly_type, operand)
| Binary(binary_operator, assembly_type, operand, operand)
| Cmp(assembly_type, operand, operand)
| Idiv(assembly_type, operand)
**| Div(assembly_type, operand)**
| Cdq(assembly_type)
| Jmp(identifier)
| JmpCC(cond_code, identifier)
| SetCC(cond_code, operand)
| Label(identifier)
| Push(operand)
| Call(identifier)
| Ret
unary_operator = Neg | Not
binary_operator = Add | Sub | Mult
operand = Imm(int) | Reg(reg) | Pseudo(identifier) | Stack(int) | Data(identifier)
cond_code = E | NE | G | GE | L | LE **| A | AE | B | BE**
reg = AX | CX | DX | DI | SI | R8 | R9 | R10 | R11 | SP
清单 12-10:带有无符号操作的汇编 AST
我们添加了上一节中讨论的新条件码和无符号div指令。我们还添加了一个MovZeroExtend指令来处理零扩展。目前,这个指令只是一个占位符。在指令修复阶段,我们会将其替换为一个或两个mov指令,具体取决于其目标是内存还是寄存器。(目前,目标总是内存,因此我们始终需要两个mov指令;一旦我们在第三部分中实现寄存器分配,这一点将发生变化。)当我们添加字符类型时,MovZeroExtend还将表示真正的movz指令,用于将 1 字节的值零扩展。
让我们回顾一下我们在汇编生成阶段需要做的更改。首先,当我们将源级类型转换为汇编类型时,我们失去了带符号和无符号整数之间的区别。在 TACKY 中,long和unsigned long值在汇编中都变成了四字长,而int和unsigned int值则变成了长字。
当我们将比较指令从 TACKY 转换为汇编时,我们首先查找任一操作数的类型(两个操作数的类型保证相同)。然后,根据该类型是否带符号,我们选择适当的条件码。例如,处理
Binary(LessThan, Var("src1"), Var("src2"), Var("dst"))
我们从符号表中查找src1或src2的类型。假设类型是UInt。在这种情况下,我们将生成以下汇编指令:
Cmp(Longword, Pseudo("src2"), Pseudo("src1"))
Mov(Longword, Imm(0), Pseudo("dst"))
SetCC(B, Pseudo("dst"))
这些指令与我们为带符号比较生成的指令完全相同,唯一的区别是我们使用了B条件码,而不是L。
为了处理 TACKY 的除法或余数操作,我们将第一个操作数复制到 EAX 寄存器中,如之前所做的那样。然后,如果操作数是带符号的,我们将 EAX 符号扩展到 EDX 并发出idiv指令。如果操作数是无符号的,我们将 EDX 清零并发出div指令。(自然地,如果操作数是四字长,我们将使用 RAX 和 RDX 代替 EAX 和 EDX。)例如,我们将转换为
Binary(Remainder, ConstULong(100), Var("x"), Var("dst"))
转换为:
Mov(Quadword, Imm(100), Reg(AX))
Mov(Quadword, Imm(0), Reg(DX))
Div(Quadword, Pseudo("x"))
Mov(Quadword, Reg(DX), Pseudo("dst"))
最后,我们将每个 ZeroExtend TACKY 指令转换为 MovZeroExtend 汇编指令。
表 12-2 到 12-5 总结了从 TACKY 到汇编的最新转换更新。新增的构造和我们转换现有构造的方式的更改已加粗显示。
表 12-2: 将 TACKY 指令转换为汇编
| TACKY 指令 | 汇编指令 | |
|---|---|---|
| Binary(Divide, src1, src2, dst) | 有符号 |
Mov(<src1 type>, src1, Reg(AX))
Cdq(<src1 type>)
Idiv(<src1 type>, src2)
Mov(<src1 type>, Reg(AX), dst)
|
| 无符号 |
|---|
Mov(<src1 type>, src1, Reg(AX))
Mov(<src1 type>, Imm(0), Reg(DX))
Div(<src1 type>, src2)
Mov(<src1 type>, Reg(AX), dst)
|
| Binary(Remainder, src1, src2, dst) | 有符号 |
|---|
Mov(<src1 type>, src1, Reg(AX))
Cdq(<src1 type>)
Idiv(<src1 type>, src2)
Mov(<src1 type>, Reg(DX), dst)
|
| 无符号 |
|---|
Mov(<src1 type>, src1, Reg(AX))
Mov(<src1 type>, Imm(0), Reg(DX))
Div(<src1 type>, src2)
Mov(<src1 type>, Reg(DX), dst)
|
| ZeroExtend(src, dst) | MovZeroExtend(src, dst) |
|---|
表 12-3: 将 TACKY 比较转换为汇编
| TACKY 比较 | 汇编条件码 | |
|---|---|---|
| LessThan | 有符号 | L |
| 无符号 | B | |
| LessOrEqual | 有符号 | LE |
| 无符号 | BE | |
| GreaterThan | 有符号 | G |
| 无符号 | A | |
| GreaterOrEqual | 有符号 | GE |
| 无符号 | AE |
表 12-4: 将 TACKY 操作数转换为汇编
| 粘性操作数 | 汇编操作数 |
|---|---|
| 常量(ConstUInt(int)) | Imm(int) |
| 常量(ConstULong(int)) | Imm(int) |
表 12-5: 类型转换为汇编类型
| 源类型 | 汇编类型 | 对齐 |
|---|---|---|
| UInt | Longword | 4 |
| ULong | Quadword | 8 |
接下来,我们将更新伪寄存器替换和指令修正的处理过程。
替换伪寄存器
我们将扩展这个过程,以处理新的 Div 和 MovZeroExtend 指令。否则,这里没有什么需要更改的。这个过程查看的是每个操作数的汇编类型,而不是其源级类型,因此它不会区分有符号和无符号操作数。
修正 Div 和 MovZeroExtend 指令
接下来,我们将重写 Div 和 MovZeroExtend。与 Idiv 类似,新的 Div 指令不能使用常量操作数。我们将像重写 Idiv 一样重写它,必要时将操作数复制到 R10。
我们将用一条或两条 mov 指令替换 MovZeroExtend。如果目标是寄存器,我们将发出一条 movl 指令。例如,我们将重写
MovZeroExtend(Stack(-16), Reg(AX))
如下所示:
Mov(Longword, Stack(-16), Reg(AX))
如果目标在内存中,我们将使用一条 movl 指令将其零扩展到 R11 中,然后再从那里移动到目标。因此,我们将重写
MovZeroExtend(Imm(100), Stack(-16))
如下所示:
Mov(Longword, Imm(100), Reg(R11))
Mov(Quadword, Reg(R11), Stack(-16))
我们不会对这个过程做其他更改。
代码生成
我们将在本章中对代码发射阶段进行一些更改。首先,我们将添加div指令和新的条件码。我们还将添加两个新的静态初始化器,UIntInit和ULongInit,它们的发射方式将与其有符号对应物IntInit和LongInit完全相同。表 12-6 至 12-8 展示了如何发射这些新结构。
表 12-6: 格式化静态初始化器
| 静态初始化器 | 输出 |
|---|---|
| UIntInit(0) | .zero 4 |
| UIntInit(i) | .long |
| ULongInit(0) | .zero 8 |
| ULongInit(i) | .quad |
表 12-7: 格式化汇编指令
| 汇编指令 | 输出 |
|---|---|
| Div(t, operand) |
div<t> <operand>
|
表 12-8: 条件码的指令后缀
| 条件码 | 指令后缀 |
|---|---|
| A | a |
| AE | ae |
| B | b |
| BE | be |
我没有像往常一样加粗这些表格中的新结构和更改,因为所有这些汇编结构都是新的。
概述
在本章中,你在第十一章中打下的基础上,实现了两个无符号整数类型。你深入研究了 C 标准中关于类型转换的规则,并探索了这些转换如何影响整数的表示和数值。在类型检查器中,你了解了有符号和无符号整数是如何转换为共同类型的。在汇编生成过程中,你实现了零扩展、无符号除法和比较操作。
在第十三章中,你将添加一个浮点类型,double。浮点数在硬件中的处理方式与整数有很大不同;它们甚至有自己的寄存器!正如你将看到的那样,这些硬件差异影响了从类型转换到函数调用约定的方方面面。

描述
第十三章:13 浮动点数字

你的编译器现在支持四种不同的整数类型,但它仍然不支持非整数值。它也不支持超出long和unsigned long范围的值。在本章中,你将通过实现double类型来解决这些不足。此类型使用浮动点二进制表示法,与我们目前看到的有符号和无符号整数表示法完全不同。C 标准还定义了另外两种浮动点类型,float和long double,但我们在本书中不会实现它们。
本章将有两个主要任务。第一个任务是弄清楚我们到底要实现什么行为。我们不能仅仅检查 C 标准,因为许多浮动点行为是由实现定义的。相反,我们将参考另一个标准——IEEE 754,以填补 C 标准未指定的大部分细节。我们的第二个主要任务是生成汇编代码;我们将需要一整套新的专用汇编指令和寄存器来处理浮动点数字。
我们将从快速了解 IEEE 754 标准开始,该标准定义了double的二进制格式以及浮动点行为的其他方面。接下来,我们将考虑浮动点操作中可能引入舍入误差的所有方式,并决定我们的实现将如何处理这些误差。我们不会覆盖浮动点算术的每个方面,但你可以在“附加资源”中找到指向标准本身的链接,以及对 IEEE 754、舍入误差和浮动点行为其他方面的更全面的解释,链接在 343 页。
IEEE 754,它有什么用?
IEEE 754 标准指定了几种浮点格式以及如何处理它们。它定义了一组浮点运算,包括基本的算术运算、转换和比较。它还定义了几种舍入模式,控制这些运算结果如何舍入,并定义了各种浮点异常,如溢出和除零错误。该标准可以作为实现浮点运算的任何系统的规范,无论该系统是处理器还是高级编程语言。在处理器中,所需的操作通常作为机器指令实现。在大多数编程语言中,包括 C,一些 IEEE 754 操作作为基本运算符,如 + 和 -,而其他操作则作为标准库函数实现。
几乎所有现代编程语言都以 IEEE 754 格式表示浮点数(因为它们运行在使用该格式的硬件上),但它们对标准的其他方面支持的程度各不相同。例如,并非所有编程语言都允许你检测浮点异常或使用非默认舍入模式。
理论上,你可以在不使用 IEEE 754 的情况下实现 C 语言;C 标准并没有规定如何表示 double 和其他浮点类型。然而,标准的设计是与 IEEE 754 兼容的。C 标准的附录 F 是一个可选部分,规定了如何完全支持 IEEE 754,并明确将 C 类型、操作和宏与其 IEEE 754 等效项绑定。(注意,标准中提到的“IEC 60559”其实就是 IEEE 754 的另一个名称。)
虽然 C 标准并没有指定如何表示浮点类型,但 System V x64 ABI 却有明确要求。遵循此 ABI 的实现,包括我们的实现,必须将这些类型表示为 IEEE 754 格式。然而,ABI 并没有处理 IEEE 754 的其他方面。
大多数 C 实现提供命令行选项,精确控制它们遵循 IEEE 754 的严格程度。我们的编译器将不提供这些选项;相反,它将大致匹配 Clang 和 GCC 的默认行为。这意味着我们将根据 IEEE 754 实现数学浮点运算,并正确处理大多数特殊值,但我们将忽略浮点异常和非默认舍入模式。
在接下来的几节中,我将讨论你在编写编译器时需要了解的 IEEE 754 的部分内容。我不会讨论底层硬件(如加法和减法)或 C 标准库中的操作(如平方根和余数)。你不需要了解它们的详细规格,因为这些操作已经为你处理好了。但你 确实 需要了解 IEEE 754 数字的二进制格式,所以我们将从这一部分开始。
IEEE 754 双精度格式
系统 V x64 ABI 告诉我们要使用 IEEE 754 双精度 格式表示 double,该格式宽度为 64 位。图 13-1 说明了该格式。(此图经过轻微修改,来源于 <wbr>en<wbr>.wikipedia<wbr>.org<wbr>/wiki<wbr>/Double<wbr>-precision<wbr>_floating<wbr>-point<wbr>_format。)

图 13-1:IEEE 754 双精度浮点格式 描述
双精度浮点格式有三个字段:符号位、指数字段和尾数字段。这些字段分别编码三个值:符号 s,指数 e 和尾数 f。(有时 f 被称为 尾数,而不是 significand。)这种格式的数字的值为 (–1)^s × f × 2^e,除了一些特殊情况,我们稍后会讨论这些情况。
尾数 f 是一个 二进制分数,类似于十进制数。在十进制数中,小数点左侧的数字(整数部分)表示非负的 10 的幂,而右侧的数字(小数部分)表示负的 10 的幂:1/10、1/100 等。类似地,二进制分数中整数部分的每一位代表 2 的非负幂,如 1、2、4 或 8,而小数部分的每一位代表 2 的负幂,如 1/2、1/4 或 1/8。
f 的整数部分始终为 1;52 位的尾数字段仅编码小数部分。这意味着 f 的值始终大于或等于 1 且小于 2。例如,尾数字段
1000000000000000000000000000000000000000000000000000
表示 f 的小数部分是 0.1,因此 f 的整体值是二进制分数 1.1,在十进制表示中为 1.5。隐含的前导 1 使得 52 位的尾数字段可以表示最长达 53 位的二进制分数。
e 的值在 –1,022 和 1,023 之间。指数字段使用 偏移 编码:我们将该字段中的 11 位解读为无符号整数,然后减去 1,023 得到 e 的值。例如,假设该字段有以下位:
00000000010
将这些位解释为普通无符号整数时,它们表示数字 2。因此,指数 e 的值为 2 – 1,023,即 –1,021。当指数域全为 1 或全为 0 时,表示我们稍后将讨论的某些特殊值。
由于 f 总是正数,如果符号位为 1,则整个浮点数为负数;如果符号位为 0,则为正数。本质上,浮点数让我们用科学记数法表示数字,但使用的是 2 的幂而不是 10 的幂。
IEEE 754 标准还定义了一些特殊值,它们的解释与普通浮点数不同:
零和负零
如果一个浮点数的所有位都是零,它的值是 0.0。如果它的所有位都是零,除了符号位外,它的值是 -0.0。这个值与 0.0 相等,但遵循常规规则来确定算术结果的符号。例如,-1.0 * 0.0 和 1.0 * -0.0 都计算为 -0.0。
次正规数
如我们刚才所见,大多数浮点数的有效数字在 1 和 2 之间。我们称这些数值为正规化的。标准化的 double 所能表示的最小幅度是 1 × 2^(-1,022),因为最小指数为 –1,022。在次正规数中,有效数字小于 1,这使得我们能够表示更接近零的值。一个全零的指数域表示该数字是次正规数,因此它的指数为 –1,022,有效数字的整数部分为 0,而不是 1。次正规数在硬件中比正规化数要慢得多,因此一些 C 语言实现允许用户禁用次正规数,并将任何次正规结果四舍五入为零。
无限
在另一端,标准化的 double 所能表示的最大幅度是有效数字的最大值(接近 2)乘以 2^(1,023)。任何更大的数值都会被四舍五入为无限大。将非零数除以零的结果也是无限大。IEEE 标准定义了正无穷和负无穷;例如,表达式 -1.0 / 0.0 计算为负无穷大。一个指数全为 1 且分数域全为 0 的数值表示无限大。符号位指示它是正无穷还是负无穷。
NaN
NaN 是非数字(not-a-number)的缩写。一些操作,包括0.0 / 0.0,会产生 NaN。IEEE 754 标准定义了信号 NaN,如果你尝试使用它们会引发异常,和安静 NaN,它们则不会。一个指数全为 1 且分数域非零的数字表示 NaN。
我们将支持所有这些值,除了 NaN。安静的 NaN 是额外的附加功能,因为在比较中正确处理它们需要一些额外的工作。我们可以支持负零、次正规数和无穷大,而不需要额外的工作;处理器将为我们处理这些。
除了双精度格式,IEEE 754 还定义了几个我们不会使用的浮点格式,包括单精度,对应于float,以及双扩展精度,通常对应于long double。这些格式包括与双精度相同的三个字段,使用相同的公式来确定浮点数的值,并具有相同的特殊值;它们只是具有不同的位宽。
舍入行为
你不能准确地表示每一个实数作为double。实数是无限的,而double只有 64 位。我们并不特别关心所有的实数;我们只关心在 C 程序中出现的数字。不幸的是,double也不能准确地表示大多数这些数字,所以我们需要对它们进行舍入。让我们首先看看 IEEE 754 是如何告诉我们将实数舍入为double的。然后,我们将讨论三种可能遇到舍入误差的情况:从十进制到二进制浮点数的常量转换、执行类型转换和进行算术操作时。
舍入模式
IEEE 754 定义了几种不同的舍入模式,包括舍入到最接近值、舍入到零、舍入到正无穷大和舍入到负无穷大。现代处理器支持这四种舍入模式,并提供指令让程序更改当前的舍入模式。我们将仅支持默认的 IEEE 舍入模式,即 舍入到最接近值,四舍五入时舍入偶数。顾名思义,在这种模式下,结果的真实值总是舍入到最接近的可表示的 double。 “舍入偶数”意味着如果结果恰好在两个可表示值之间,则舍入到最低有效位为 0 的那个值。我们将在将常量转换为浮点数、将整数类型转换为 double 以及在算术操作中使用这种舍入模式。
四舍五入常量
C 程序员通常使用十进制表示 double 常量。在编译时,我们会将常量从十进制表示转换为双精度浮点表示。这个转换是不精确的,因为大多数十进制常量无法在二进制浮点中精确表示。例如,你无法在二进制浮点中表示十进制数 0.1,因为二进制小数的每一位代表 2 的幂次方,但你无法通过加起来的 2 的幂次方得到 0.1。如果 C 程序的源代码中包含常量 0.1,编译器会将这个常量四舍五入为 列表 13-1 中的值,这个值是我们可以表示为 double 类型的最接近值。
0.1000000000000000055511151231257827021181583404541015625
列表 13-1:最接近 double 到 0.1 的十进制表示
与 0.1 不同,这个值可以精确表示为一个 53 位的二进制小数乘以 2 的某个幂,如 列表 13-2 中所示。
1.100110011001100110011001100110011001100110011001101 * 2^-4
列表 13-2:最接近 double 到 0.1 的二进制表示
将 0.1 表示为 double 类似于尝试用十进制表示 1/3;因为你无法将其分解为 10 的幂,所以无法用任何数量的小数位精确表示它。相反,你必须将 1/3 四舍五入为你可以在可用空间中表示的最接近值。例如,一个最多可以显示四位数字的计算器会将 1/3 显示为 .3333。
注意
IEEE 754 定义了几种十进制浮动点格式,这些格式能够表示十进制常数而不会出现这种舍入误差。这些格式将数字编码为十进制有效数字与 10 的幂相乘的形式。C23 包含了与这些格式对应的新十进制浮动点类型。
舍入类型转换
在将整数转换为<samp class="SANS_TheSansMonoCd_W5Regular_11">double时,我们也可能需要进行舍入。这个问题产生的原因是<samp class="SANS_TheSansMonoCd_W5Regular_11">double能够表示的值之间的间距。随着值的大小增加,表示值之间的间距也变得更大。到了一定程度,间距变得大于 1,这意味着你无法表示该范围内的所有整数。为了说明这个问题,让我们假设一个精度为三位数的十进制格式。这个格式可以表示任何小于 1000 的整数;例如,我们可以将 992 和 993 分别表示为 9.92 × 10²和 9.93 × 10²。但它无法表示大于 1000 的每个整数。我们可以将 1000 精确表示为 1.00 × 10³,但下一个可表示的值是 1.01 × 10³,或 1010;之间有 10 的间距。一旦达到 10,000,这个间距将增大到 100,并且在更大的数值下,间距会继续增长。当从<samp class="SANS_TheSansMonoCd_W5Regular_11">long或<samp class="SANS_TheSansMonoCd_W5Regular_11">unsigned long转换为<samp class="SANS_TheSansMonoCd_W5Regular_11">double时,我们会遇到完全相同的问题。<samp class="SANS_TheSansMonoCd_W5Regular_11">double具有 53 位精度,因为有效数字是一个 53 位的二进制分数。然而,<samp class="SANS_TheSansMonoCd_W5Regular_11">long或<samp class="SANS_TheSansMonoCd_W5Regular_11">unsigned long具有 64 位精度。假设我们需要将<samp class="SANS_TheSansMonoCd_W5Regular_11">9223372036854775803从<samp class="SANS_TheSansMonoCd_W5Regular_11">long转换为<samp class="SANS_TheSansMonoCd_W5Regular_11">double。这个<samp class="SANS_TheSansMonoCd_W5Regular_11">long的二进制表示为:
111111111111111111111111111111111111111111111111111111111111011
这是 63 位,因此无法适应<samp class="SANS_TheSansMonoCd_W5Regular_11">double的有效数字!我们需要将其舍入为最接近的<samp class="SANS_TheSansMonoCd_W5Regular_11">double,即<samp class="SANS_TheSansMonoCd_W5Regular_11">9223372036854775808.0,或 1 × 2⁶³。
舍入算术操作
最后,我们可能需要对基本的浮点操作结果进行舍入,比如加法、减法和乘法。再次强调,这是因为可表示值之间的间隙。例如,让我们试着计算 993 + 45,使用上一节中的三位十进制格式。正确的结果是 1,038,但三位数无法表示它;我们需要将其舍入为 1.04 × 10³。除法也可能产生无法在任何精度下表示的值,就像 1 / 3 的结果无法用任何数量的十进制数字表示一样。幸运的是,我们基本上可以忽略这一类舍入误差;浮点算术的汇编指令会在没有额外努力的情况下正确地进行舍入。
现在你已经了解了 IEEE 754 格式的基础和需要实现的舍入行为,你可以开始着手编写编译器了。我们将从编译器驱动程序的修改开始。
链接共享库
本章的测试套件使用了来自 <math.h> 的函数,这是标准数学库。我们将向编译器驱动程序添加一个新命令行选项,允许我们链接共享库,如 <math.h>。这个选项的形式是 -l
`./YOUR_COMPILER` /path/to/program.c -lm
它应该使用以下命令来组装和链接程序:
gcc /path/to/program.s -o /path/to/program -lm
如果你使用的是 macOS,你不需要添加这个新选项,因为标准数学库默认已经链接。不过,你可能还是希望添加这个选项,因为能够链接共享库通常是有用的。
词法分析器
在本章中,你将引入两个新标记:
double 一个关键字
浮点常量 使用科学计数法或包含小数点的常量
你还需要修改词法分析器识别常量标记的结束方式;这将影响新引入的浮点常量以及你已经支持的整数常量。
让我们先了解浮点常量的格式。然后,我们将看到如何识别常量的结束。最后,我们将为每个常量标记定义新的正则表达式。
识别浮点常量标记
带有小数点的数字,如 1.5 和 .72,是有效的标记,表示浮点数。我们将包括小数点的数字序列称为 分数常量。分数常量可以包括没有小数位的点。例如,1. 是一个有效的分数常量,值与 1.0 相同。
浮点常量也可以用科学计数法表示。使用科学计数法的标记由以下部分组成:
-
一个尾数,它可以是一个整数或分数常量
-
一个大写或小写的 E
-
一个指数,它是一个整数,前面可以有一个可选的 + 或 - 符号
100E10、.05e-2 和 5.E+3 都是有效的浮点常量。这些常量都是十进制的,其指数是 10 的幂。例如,5.E+3 是 5 × 10³,或者 5000。C 标准还定义了十六进制浮点常量,但我们不会实现它们。没有表示无穷大的常量。<math.h> 头文件定义了一个 INFINITY 宏,它应该表示正无穷大常量,但我们的编译器无法包含这个头文件,因为它使用了 float、struct 以及其他我们不支持的语言特性。因此,我们不会支持这个宏(或者说,不支持 <math.h> 中定义的任何其他宏)。
编写一个正则表达式来匹配每个浮点常量有点棘手,所以让我们将其分解为几个步骤。列表 13-3 中的正则表达式匹配的是分数常量。
[0-9]*\.[0-9]+|[0-9]+\.
列表 13-3:分数常量的正则表达式
这个正则表达式的第一部分,[0-9]*.[0-9]+,匹配任何小数点后有数字的常量,比如.03或3.14。|后面的部分匹配像3.这样的常量,即小数点后没有数字。列表 13-4 定义了一个类似的正则表达式,用来匹配科学计数法中常量的有效数值。
[0-9]*\.[0-9]+|[0-9]+\.?
列表 13-4:科学计数法中常量有效数值的正则表达式
与列表 13-3 的唯一不同之处在于,第二个子句中的尾随小数点是可选的,因此它既能匹配整数,也能匹配带尾随小数点的分数常量。
我们将使用列表 13-5 中的正则表达式来匹配浮点常量的指数部分。
[Ee][+-]?[0-9]+
列表 13-5:科学计数法中常量指数部分的正则表达式
这个正则表达式包括大小写不敏感的E,表示指数的开始,一个可选符号和指数的整数值。为了匹配任何浮点常量,我们将组合一个巨大的正则表达式,其形式为<列表 13-4> <列表 13-5> | <列表 13-3>,这给出了列表 13-6。
([0-9]*\.[0-9]+|[0-9]+\.?)[Ee][+-]?[0-9]+|[0-9]*\.[0-9]+|[0-9]+\.
列表 13-6:匹配浮点常量每个部分的正则表达式
换句话说,浮点常量要么是一个尾随指数的有效数值,要么是一个分数常量。不过,列表 13-6 并不完全:我们还需要一个额外的组件来匹配该标记的结束与下一个标记开始之间的边界。
匹配常量的结尾
直到现在,我们一直要求常量以单词边界结束。例如,给定字符串 123foo,我们不会接受子字符串 123 作为常量。现在我们将添加另一个要求:常量符号后面不能紧跟一个句点。这意味着,例如,词法分析器将识别字符串 123L; 的开始部分作为长整型常量符号 123L,但它会拒绝字符串 123L.bar; 作为格式错误。同样,词法分析器将接受字符串 1.0+x,但拒绝 1.0.+x,并且它会接受 1.},但拒绝 1..}。请注意,像 1. 这样的浮点常量的最后一个字符可以是句点,但常量之后的第一个字符不能是句点。
注意
如果你对这个要求在 C 标准中的来源感到好奇,可以查看第 6.4.8 节中对预处理数字的定义、第 5.1.1.2 节中关于翻译阶段的列表,以及第 6.4 节第 3 段中关于符号和预处理符号的讨论。这些章节描述了一个多阶段过程,用于将源文件划分为预处理符号,然后将它们转换为符号。我们并不完全遵循这个过程,但我们定义了每个符号的方式,以便在我们支持的 C 子集上产生相同的结果。
为了强制执行这个新要求,我们将在每个常量符号的正则表达式末尾使用 [^\w.] 字符类,而不是特殊的单词边界字符 \b。[^\w.] 字符类匹配任何单个字符,除了单词字符(字母、数字或下划线)或句点。这个单一的非单词、非句点字符标记了常量的结束,但不属于常量本身,因此我们将在每个正则表达式中定义一个捕获组来匹配实际的常量。
例如,我们之前用于有符号整型常量的正则表达式是 [0-9]+\b。我们的新正则表达式是 ([0-9]+)[\w.]。这个正则表达式匹配整个字符串 100;,包括结尾的 ;。捕获组 ([0-9]+) 只匹配常量 100,而不包括最终的 ; 字符。每当词法分析器识别到一个常量时,它应该只消耗输入中的常量本身,而不是紧随其后的字符。
在清单 13-7 中,我们最终定义了整个正则表达式来识别浮点常量。
(([0-9]*\.[0-9]+|[0-9]+\.?)[Ee][+-]?[0-9]+|[0-9]*\.[0-9]+|[0-9]+\.)[^\w.]
清单 13-7:识别浮点常量的完整正则表达式
这只是我们在清单 13-6 中定义的正则表达式,用括号括起来形成捕获组,并跟随 [^\w.] 字符类。
表 13-1 定义了所有常量标记的新正则表达式。
表 13-1: 常量标记的正则表达式
| 标记 | 正则表达式 |
|---|---|
| 有符号整型常量 | ([0-9]+)[^\w.] |
| 无符号整型常量 | ([0-9]+[uU])[^\w.] |
| 有符号长整型常量 | ([0-9]+[lL])[^\w.] |
| 无符号长整型常量 | ([0-9]+([lL][uU]|[uU][lL]))[^\w.] |
| 浮点常量 | (([0-9].[0-9]+|[0-9]+.?)[Ee][+-]?[0-9]+|[0-9].[0-9]+|[0-9]+.)[^\w.] |
继续添加新的浮点常量标记,并更新你如何识别前面章节中的常量标记。别忘了也要添加 double 关键字!
解析器
解析器的更改相对有限。清单 13-8 给出了更新后的 AST,其中包含了 double 类型和浮点常量。
program = Program(declaration*)
declaration = FunDecl(function_declaration) | VarDecl(variable_declaration)
variable_declaration = (identifier name, exp? init,
type var_type, storage_class?)
function_declaration = (identifier name, identifier* params, block? body,
type fun_type, storage_class?)
type = Int | Long | UInt | ULong | **Double |** FunType(type* params, type ret)
storage_class = Static | Extern
block_item = S(statement) | D(declaration)
block = Block(block_item*)
for_init = InitDecl(variable_declaration) | InitExp(exp?)
statement = Return(exp)
| Expression(exp)
| If(exp condition, statement then, statement? else)
| Compound(block)
| Break
| Continue
| While(exp condition, statement body)
| DoWhile(statement body, exp condition)
| For(for_init init, exp? condition, exp? post, statement body)
| Null
exp = Constant(const)
| Var(identifier)
| Cast(type target_type, exp)
| Unary(unary_operator, exp)
| Binary(binary_operator, exp, exp)
| Assignment(exp, exp)
| Conditional(exp condition, exp, exp)
| FunctionCall(identifier, exp* args)
unary_operator = Complement | Negate | Not
binary_operator = Add | Subtract | Multiply | Divide | Remainder | And | Or
| Equal | NotEqual | LessThan | LessOrEqual
| GreaterThan | GreaterOrEqual
const = ConstInt(int) | ConstLong(int)
| ConstUInt(int) | ConstULong(int)
**| ConstDouble(double)**
清单 13-8:带有 double 类型和浮点常量的抽象语法树
你的 AST 应该使用双精度浮点格式表示 double 常量,因为它们在运行时就是以这种格式表示的。你需要查找在你的实现语言中,哪种类型使用这种格式。如果你使用的表示方式比 double 精度低,你可能无法准确表示源代码中每个常量最接近的 double 值,从而导致编译后的程序中常量的四舍五入错误。
令人惊讶的是,使用比 double 精度更高的格式存储常量也可能会导致问题。将浮点数存储为更高精度的格式后,再将其四舍五入到较低精度的格式,可能会产生与直接四舍五入一次不同的结果。这种现象被称为 双重四舍五入误差。(这里的 double 指的是两次四舍五入,而不是 double 类型。)我们将在生成汇编代码时更深入地探讨双重四舍五入误差。
更新 AST 后,我们将对语法做出相应的更改。清单 13-9 显示了包含这些更改的完整语法,已将更改部分加粗。
<program> ::= {<declaration>}
<declaration> ::= <variable-declaration> | <function-declaration>
<variable-declaration> ::= {<specifier>}+ <identifier> ["=" <exp>] ";"
<function-declaration> ::= {<specifier>}+ <identifier> "(" <param-list> ")" (<block> | ";")
<param-list> ::= "void"
| {<type-specifier>}+ <identifier> {"," {<type-specifier>}+ <identifier>}
<type-specifier> ::= "int" | "long" | "unsigned" | "signed" **| "double"**
<specifier> ::= <type-specifier> | "static" | "extern"
<block> ::= "{" {<block-item>} "}"
<block-item> ::= <statement> | <declaration>
<for-init> ::= <variable-declaration> | [<exp>] ";"
<statement> ::= "return" <exp> ";"
| <exp> ";"
| "if" "(" <exp> ")" <statement> ["else" <statement>]
| <block>
| "break" ";"
| "continue" ";"
| "while" "(" <exp> ")" <statement>
| "do" <statement> "while" "(" <exp> ")" ";"
| "for" "(" <for-init> [<exp>] ";" [<exp>] ")" <statement>
| ";"
<exp> ::= <factor> | <exp> <binop> <exp> | <exp> "?" <exp> ":" <exp>
<factor> ::= <const> | <identifier>
| "(" {<type-specifier>}+ ")" <factor>
| <unop> <factor> | "(" <exp> ")"
| <identifier> "(" [<argument-list>] ")"
<argument-list> ::= <exp> {"," <exp>}
<unop> ::= "-" | "~" | "!"
<binop> ::= "-" | "+" | "*" | "/" | "%" | "&&" | "||"
| "==" | "!=" | "<" | "<=" | ">" | ">=" | "="
<const> ::= <int> | <long> | <uint> | <ulong> **| <double>**
<identifier> ::= ? An identifier token ?
<int> ::= ? An int token ?
<long> ::= ? An int or long token ?
<uint> ::= ? An unsigned int token ?
<ulong> ::= ? An unsigned int or unsigned long token ?
**<double> ::= ? A floating-point constant token ?**
清单 13-9:带有 double 类型说明符和浮点常量的语法
在过去的两章中,我们不得不处理许多不同的整数类型指定方式。幸运的是,指定 double 类型只有一种方式:使用 double 关键字。清单 13-10 演示了在处理类型说明符列表时如何处理 double。
parse_type(specifier_list):
if specifier_list == ["double"]:
return Double
if specifier_list contains "double":
fail("Can't combine 'double' with other type specifiers")
`--snip--`
清单 13-10:从类型说明符列表中确定类型
要么 double 应该是列表中唯一的修饰符,要么根本不应该出现;它不能与 long、unsigned 或我们迄今为止引入的任何其他类型修饰符一起使用(然而,它可以与存储类修饰符如 static 和 extern 一起出现)。
接下来,我们将把浮点常量标记转换为抽象语法树(AST)中的常量。我们之前看到,大多数十进制常量在二进制浮点表示中无法精确表示,因此我们需要对它们进行四舍五入。根据 C 标准,这里的舍入方向是由实现决定的,不一定需要与运行时舍入模式匹配。我们在这里使用四舍五入模式,就像在其他地方一样。你的实现语言的内置字符串到浮点数转换工具应能正确处理此问题。
当我们解析整数常量时,需要确保它们在该类型可表示的范围内。然而,浮点常量不能超出范围。由于 double 支持正无穷大和负无穷大,它的范围包括所有实数。因此,我们的解析器在解析 double 常量时不会遇到任何错误。
类型检查器
我们将进行一些修改,以便在类型检查器中考虑到 double。首先,我们将确保用正确的类型注释 double 常量。然后,我们将更新查找两个值的共同实数类型的方式。这里的规则很简单:如果任一值是 double,那么共同实数类型就是 double。列表 13-11 展示了如何更新 get_common_type 辅助函数以处理 double。
get_common_type(type1, type2):
if type1 == type2:
return type1
**if type1 == Double or type2 == Double:**
**return Double**
`--snip--`
列表 13-11:查找两个值的共同实数类型
我们还需要检测一些新的类型错误。按位取反运算符 ~ 和求余运算符 % 只接受整数操作数。我们将验证这两个运算符在 typecheck_exp 中的正确使用。列表 13-12 演示了如何对 ~ 运算符进行类型检查。
typecheck_exp(e, symbols):
match e with
| `--snip--`
| Unary(Complement, inner) ->
typed_inner = typecheck_exp(inner, symbols)
❶ if get_type(typed_inner) == Double:
fail("Can't take the bitwise complement of a double")
unary_exp = Unary(Complement, typed_inner)
return set_type(unary_exp, get_type(typed_inner))
列表 13-12:类型检查按位补码表达式
首先,我们对操作数进行类型检查。然后,我们验证操作数是否为整数❶。最后,我们用其结果的类型注解该表达式。只有验证步骤与早期章节不同。我们可以以类似的方式处理%运算符。
为了总结类型检查器的变更,我们将处理类型为double的静态变量。我们将为这些变量添加一种新的初始化器:
static_init = IntInit(int) | LongInit(int) | UIntInit(int) | ULongInit(int)
**| DoubleInit(double)**
像往常一样,我们会将每个初始化器转换为它初始化的变量类型,使用在运行时应用的相同规则。C 标准要求我们在将double转换为整数类型时进行向零截断。例如,我们会将2.8转换为2。如果截断后的值超出结果整数类型的范围,则结果是未定义的,因此你可以根据需要处理它。最干净的做法是直接抛出错误。
当我们将一个整数转换为double时,如果它能够被精确表示,我们将保留其值。否则,我们将舍入到最接近的可表示值。你应该能够使用你所使用的实现语言的内置类型转换工具从double转换为整数类型,反之亦然。
TACKY 生成
在 TACKY 中,我们将添加一些新指令来处理double和整数类型之间的转换。列表 13-13 给出了更新后的 TACKY IR。
program = Program(top_level*)
top_level = Function(identifier, bool global, identifier* params, instruction* body)
| StaticVariable(identifier, bool global, type t, static_init init)
instruction = Return(val)
| SignExtend(val src, val dst)
| Truncate(val src, val dst)
| ZeroExtend(val src, val dst)
**| DoubleToInt(val src, val dst)**
**| DoubleToUInt(val src, val dst)**
**| IntToDouble(val src, val dst)**
**| UIntToDouble(val src, val dst)**
| Unary(unary_operator, val src, val dst)
| Binary(binary_operator, val src1, val src2, val dst)
| Copy(val src, val dst)
| Jump(identifier target)
| JumpIfZero(val condition, identifier target)
| JumpIfNotZero(val condition, identifier target)
| Label(identifier)
| FunCall(identifier fun_name, val* args, val dst)
val = Constant(const) | Var(identifier)
unary_operator = Complement | Negate | Not
binary_operator = Add | Subtract | Multiply | Divide | Remainder | Equal | NotEqual
| LessThan | LessOrEqual | GreaterThan | GreaterOrEqual
列表 13-13:在 TACKY 中添加 double 和整数类型之间的转换
列表 13-13 介绍了四条新指令,用于在double和有符号与无符号整数类型之间转换:DoubleToInt、DoubleToUInt、IntToDouble和UIntToDouble。对于不同大小的整数操作数,我们没有不同的指令;例如,DoubleToInt可以转换为int或long。
要更新 TACKY 生成通道,只需在遇到转换为或从 double 时发出适当的转换指令。
汇编中的浮点操作
在开始编写汇编生成通道之前,我们需要理解如何在汇编中处理浮点数。因为浮点数采用与有符号和无符号整数完全不同的二进制表示方式,我们不能使用现有的算术指令对它们进行操作。相反,我们将使用一组专用指令,称为流式 SIMD 扩展(SSE)指令。这个指令集包括对浮点值和整数的操作。它之所以得名,是因为它包含了单指令,多数据(SIMD)指令,这些指令在一个包含多个值的向量上同时执行相同的操作(或者在二元操作的情况下,在两个值向量上执行)。例如,一个 SIMD 加法指令,其操作数为两个元素向量 [1.0, 2.0] 和 [4.0, 6.0],将分别将它们对应的元素相加,得到向量 [5.0, 8.0]。
SSE 这个术语有点误导,因为只有一些 SSE 指令对向量执行 SIMD 操作。其他的则对单一值进行操作。当我们谈论 SSE 指令时,我们将向量称为打包操作数,而将单一值称为标量操作数。使用这些不同类型操作数的 SSE 指令分别被称为打包指令和标量指令。我们的实现将主要使用标量指令,尽管我们将需要一个打包指令。
SSE 指令最初作为 x86 指令集的扩展被引入;它们并非在每个 x86 处理器上都可用。随着时间的推移,新增了几组 SSE 指令,分别命名为 SSE2、SSE3 和 SSE4。SSE 和 SSE2 指令最终被纳入到核心 x64 指令集,因此在每个 x64 处理器上都可以使用。第一代浮点 SSE 指令只支持单精度操作数,这些操作数对应 C 语言中的 float 类型。SSE2 添加了对双精度操作数的支持。由于我们正在处理双精度操作数,因此本章中我们将仅使用 SSE2 指令。
注意
x64 和 x86 指令集包括一套较旧的浮点指令,这些指令最早是在英特尔 8087 浮点单元(FPU)中引入的,该单元是一个处理浮点运算的独立处理器。这些指令被称为 x87 或 FPU 指令(有时简称为浮点指令)。请注意,一些关于浮点汇编的资源—特别是较旧的资源—仅讨论 x87 指令,而不提及 SSE。
就像我们已经熟悉的通用指令一样,SSE 指令也使用描述其操作数的后缀。对标量双精度值进行操作的指令使用 sd 后缀。对打包双精度值进行操作的指令使用 pd 后缀。标量和打包单精度指令分别使用 ss 和 ps 后缀。接下来的几节将介绍本章所需的 SSE 指令。
使用 SSE 指令
SSE 指令和你在之前章节学习的汇编指令之间有两个主要区别。第一个区别是 SSE 指令使用一组单独的寄存器,称为 XMM 寄存器。共有 16 个 XMM 寄存器:XMM0、XMM1,以此类推,直到 XMM15。每个 XMM 寄存器的宽度为 128 位,但我们只会使用它们的低 64 位。从现在开始,我将所有非 XMM 寄存器(例如 RAX、RSP 等)称为 通用寄存器。SSE 指令不能使用通用寄存器,而非 SSE 指令也不能使用 XMM 寄存器。SSE 和非 SSE 指令都可以引用内存中的值。
第二个区别是 SSE 指令不能使用立即数操作数。如果我们需要在 SSE 指令中使用常量,我们将在只读内存中定义该常量。然后,可以通过 RIP 相对寻址访问该常量,就像访问静态变量一样。清单 13-14 计算 1.0 + 1.0 的汇编示例,演示了如何使用 XMM 寄存器和浮点常量。
.section .rodata
.align 8
.L_one:
.double 1.0
.text
one_plus_one:
movsd .L_one(%rip), %xmm0
addsd .L_one(%rip), %xmm0
`--snip--`
清单 13-14:计算 1.0 + 1.0 在汇编中
在清单的开头,我们定义了常量 1.0。我们几乎可以像定义静态变量一样定义并初始化这个常量。关键的不同之处在于,我们并不把这个值存储在数据或 BSS 段中;相反,我们使用 .section .rodata 指令将它放在 只读数据段 中。顾名思义,程序在运行时可以从这个段读取数据,但不能写入数据。
.section 指令可以用来写入任何段。我们在这里使用它,因为我们没有专门的指令来写入只读数据段,正如我们有专门的 .text、.bss 和 .data 指令一样。在 macOS 使用的目标文件格式中,有几个只读数据段;我们将使用 .literal8 指令来写入包含 8 字节常量的段。
我们使用了一个新指令 .double,将标记为 .L_one 的内存地址初始化为浮点值 1.0。.L 前缀使得 .L_one 成为一个局部标签。正如你在第四章中学到的,局部标签会在目标文件的符号表中被省略。编译器通常使用局部标签来表示浮点常量。
现在我们已经定义了所需的数据,让我们看看汇编函数 one_plus_one 的开头。第一条指令,movsd .L_one(%rip), %xmm0,将常量 1.0 从内存复制到 XMM0 寄存器。movsd 指令与 mov 指令类似,都是将数据从一个位置复制到另一个位置。我们将使用 movsd 在 XMM 寄存器之间,或在 XMM 寄存器与内存之间复制值。
最后,我们使用 addsd 指令执行浮点加法。该指令将 .L_one 中的常量加到 XMM0 中的值,并将结果存储回 XMM0。addsd 的源可以是一个 XMM 寄存器或一个内存地址,目标必须是一个 XMM 寄存器。
现在你已经对如何使用 SSE 指令有了一个高级的了解,我们来深入探讨一些具体的内容。首先,我们将探索 System V 调用约定如何处理浮点函数参数和返回值。接着,我们将讲解如何在汇编中实现个别浮点操作,例如算术运算、比较和类型转换。到那时,你将最终准备好为你的编译器后端添加浮点支持。
在 System V 调用约定中使用浮点值
在第九章中,你学习了函数的前六个参数是通过通用寄存器传递的,而返回值是通过 EAX 寄存器(或 RAX,具体取决于其大小)传递的。System V 调用约定处理浮点值的方式稍有不同:它们是通过 XMM 寄存器而不是通用寄存器传递和返回的。
函数的前八个浮点参数是通过寄存器 XMM0 到 XMM7 传递的。任何剩余的浮点参数都会按反向顺序压入堆栈,就像整数参数一样。浮点返回值通过 XMM0 传递,而不是 RAX。考虑清单 13-15 中的函数,它接受两个double参数,将它们相加并返回结果。
double add_double(double a, double b) {
return a + b;
}
清单 13-15:添加两个 double 参数
我们可以将这个函数编译成清单 13-16 中的汇编代码。
.text
.globl add_double
add_double:
addsd %xmm1, %xmm0
ret
清单 13-16: add_double 在汇编中的实现
根据 System V 调用约定,参数a和b将分别通过寄存器 XMM0 和 XMM1 传递。因此,指令addsd %xmm1, %xmm0将会把b加到a上,并将结果存储在 XMM0 中。由于double值是通过 XMM0 返回的,因此函数的返回值在执行完该addsd指令后已经在正确的位置。此时,函数可以立即返回。此代码比你的编译器生成的代码更优化——例如,它不包含函数的前言和尾声——但它展示了如何在汇编中传递和返回浮点值。
当一个函数包含混合的 double 类型和整数类型的参数时,将正确的参数按正确的顺序压入栈中可能会很棘手。首先,我们需要将参数分配给寄存器,从参数列表的开始处开始工作。然后,我们将任何剩余的未分配参数(无论类型如何)从参数列表的末尾开始推送到栈中。让我们通过一些例子来逐步解决,从 列表 13-17 开始。
long pass_parameters_1(int i1, double d1, int i2, unsigned long i3,
double d2, double d3, long i4, int i5);
列表 13-17:一个包含整数和 double 参数的函数声明
这个例子很简单,因为我们可以通过寄存器传递每个参数。图 13-2 展示了在使用 pass_parameters_1 函数并调用 call 指令之前,各个寄存器的状态。

图 13-2:从 列表 13-17 传递参数 描述
列表 13-18 展示了一个稍微复杂的例子,其中一些整数参数通过栈传递。
double pass_parameters_2(double d1, long i1, long i2, double d2, int i3,
long i4, long i5, double d3, long i6, long i7,
int i8, double d4);
列表 13-18:一个包含更多参数的函数声明
我们将通过寄存器将每个 double 参数传递给这个函数,但最后两个整数参数,i7 和 i8,将通过栈传递。图 13-3 展示了每个参数将落到的位置。

图 13-3:从 列表 13-18 传递参数 描述
在我们将所有可用的寄存器分配给参数之后,只有 i7 和 i8 剩下。因为我们按反向顺序将栈参数压入栈中,所以我们先压入 i8,然后是 i7,这将使 i7 位于栈的顶部。
最后,让我们考虑在 列表 13-19 中声明的函数。当我们调用这个函数时,我们需要将 double 和整数类型的参数都传递到栈中。
int pass_parameters_3(double d1, double d2, int i1, double d3, double d4,
double d5, double d6, unsigned int i2, long i3,
double d7, double d8, unsigned long i4, double d9,
int i5, double d10, int i6, int i7, double d11,
int i8, int i9);
列表 13-19:一个包含过多参数的函数声明
我们将把前六个整数参数,i1 到 i6,以及前八个 double 类型参数,d1 到 d8,放在寄存器中。Listing 13-20 复制了 Listing 13-19,并且将传递在堆栈上的参数加粗显示。
int pass_parameters_3(double d1, double d2, int i1, double d3, double d4,
double d5, double d6, unsigned int i2, long i3,
double d7, double d8, unsigned long i4, **double d9**,
int i5, **double d10,** int i6, **int i7, double d11,**
**int i8, int i9**);
Listing 13-20: The declaration of pass_parameters_3, with parameters passed on the stack bolded
按照相反的顺序,我们将依次压入 i9,然后是 i8,d11,i7,d10 和 d9。Figure 13-4 图示了我们将把每个参数放置的位置。

Figure 13-4: Passing parameters from Listing 13-19 Description
现在我们理解了调用约定如何处理浮点值,让我们来看一下基本的算术和比较操作。
使用 SSE 指令进行算术运算
我们需要支持五种浮点数算术操作:加法、减法、乘法、除法和取反。我们已经看到过一个使用 addsd 指令进行加法的例子。其他的二元操作也有对应的 SSE 指令:减法使用 subsd,乘法使用 mulsd,除法使用 divsd。这四条 SSE 指令的模式与整数的 add、sub 和 imul 指令相同:接收一个源操作数和一个目标操作数,进行二元操作,并将结果存储到目标操作数中。这四条浮点指令都需要一个 XMM 寄存器或内存地址作为源操作数,以及一个 XMM 寄存器作为目标操作数。浮点除法遵循与其他算术指令相同的模式,不需要像整数除法那样特殊处理。
没有浮点取反指令。为了取反浮点值,我们将其与-0.0进行异或,后者的符号位已设置,但其他位均为零。这将翻转值的符号位,从而实现取反操作。该操作正确地取反了正常数、非正规数、正负零以及正负无穷大。
唯一的复杂之处在于,不能使用xorsd指令来对两个double进行异或。相反,我们将使用xorpd指令,它会对两个包含各两个double的打包向量进行异或操作。每个xorpd的操作数宽度为 16 字节;低 8 字节存储向量的第一个元素,高 8 字节存储第二个元素。我们将使用每个操作数的低 8 字节,并忽略高字节。像addsd和其他算术浮点指令一样,xorpd使用 XMM 寄存器或内存地址作为源操作数,并使用 XMM 寄存器作为目标。与其他指令不同,xorpd仅接受 16 字节对齐的内存地址;使用未对齐的源操作数会导致运行时异常。
假设我们想要取反-8(%rbp)处的double值,并将结果存储在-16(%rbp)。首先,我们定义常量-0.0:
.section .rodata
.align 16
.L_negative.zero:
.double -0.0
我们使用.align 16指令来确保该常量是 16 字节对齐的。接下来,我们将其与源值进行异或(XOR)操作:
movsd -8(%rbp), %xmm0
xorpd .L_negative.zero(%rip), %xmm0
movsd %xmm0, -16(%rbp)
第一条movsd指令将源值移动到 XMM0 的低 8 字节,并将高 8 字节置零。xorpd指令将 XMM0 的低 8 字节与.L_negative.zero处的 8 字节值进行异或,后者是-0.0。它同时将 XMM0 的高 8 字节与紧接在内存中-0.0后面的 8 字节进行异或。执行此指令后,XMM0 的低字节保存了我们取反的值,而高 8 字节则是无效的。最后一条movsd指令将 XMM0 的低字节复制到最终目的地-16(%rbp)。
我们还将使用 xorpd 来清零寄存器。由于任何数字与自身异或的结果为 0,像 xorpd %xmm0, %xmm0 这样的指令是清零浮点寄存器的最简单方法。
注意
XOR 技巧同样适用于通用寄存器;例如,xorq %rax, %rax 会将 RAX 清零。实际上,大多数编译器会以这种方式清零浮点寄存器和通用寄存器,因为这种方法比使用 mov 指令稍微快一些。由于我们更注重清晰和简洁而非性能,所以我们使用 mov 来清零通用寄存器。但对于 XMM 寄存器,使用 xor 来清零是更简单的选择。
浮点数比较
我们将使用 comisd 指令比较浮点值,其工作原理类似于 cmp。执行 comisd b, a 时,如果两个值相等,ZF 设置为 1,反之为 0。如果 a 小于 b,CF 设置为 1,否则为 0。这些标志与无符号比较的结果相同。与 cmp 不同,comisd 指令始终将 SF 和 OF 设置为 0。因此,我们将使用与无符号比较相同的条件码进行浮点比较:A、AE、B 和 BE。
comisd 指令能够正确处理次正规数、无穷大和负零,而无需我们额外操作。它将 0.0 和 -0.0 视为相等,正如 IEEE 754 标准要求的那样。处理 NaN(本章的额外内容)确实需要特别处理。当任一操作数为 NaN 时,comisd 会报告无序结果,而这个结果我们无法通过到目前为止学习的条件码检测到。有关更多细节,请参见 第 342 页的“额外内容:NaN”。
浮点类型与整数类型之间的转换
在清单 13-13 中,我们为四种不同的类型转换定义了 TACKY 指令:IntToDouble、DoubleToInt、UIntToDouble和DoubleToUInt。SSE 指令集包括有符号整数类型的转换,因此实现
让我们逐一讲解这四种转换。
将 double 转换为有符号整数
cvttsd2si指令将double转换为有符号整数。它会将源操作数截断到零,这是 C 标准对于从double到整数类型转换的要求。此指令接受一个后缀,表示结果的大小:cvttsd2sil将源值转换为 32 位整数,cvttsd2siq将其转换为 64 位整数。
由于double可以表示比int或long更广泛的数值范围,因此cvttsd2si的源值可能超出目标类型的范围。在这种情况下,指令会返回特殊的不确定整数值,这个值是目标类型所能支持的最小整数。同时,它会设置一个状态标志,表示操作无效。当将double转换为整数类型时,如果超出了该类型的范围,这是未定义行为,因此我们可以自由地处理这种情况。我们将使用不确定整数作为转换结果,并忽略状态标志。
更加用户友好的编译器可能会检查状态标志,并在转换超出范围时引发运行时错误,而不是默默返回一个虚假的结果。它也可能对从 double 到无符号整数类型的转换进行相同的处理,我们接下来将讨论这个问题。我们的方法虽然容易让 C 程序员“自作自受”,但至少我们并不孤单:默认情况下,GCC 和 Clang 也会以与我们相同的方式处理超出范围的转换。
将 double 转换为无符号整数
并非总能使用 cvttsd2si 指令将 double 转换为无符号整数。当 double 位于无符号整数类型的范围内,但超出了相应有符号类型的范围时,我们就会遇到问题。考虑以下 C 类型转换表达式:
(unsigned int) 4294967290.0
这应评估为 4294967290,它是一个完全有效的 unsigned int。但是,如果我们尝试使用 cvttsd2sil 指令将 4294967290.0 转换,它将产生不确定的整数,而不是正确的答案,因为该值超出了 signed int 的范围。同时也没有 SSE 指令可以将 double 转换为无符号整数。我们需要有些聪明才智来解决这些限制。
注意
一种更新的指令集扩展,名为 AVX ,确实包括了从 double 到无符号整数类型的转换,但并不是所有的 x64 处理器都支持此扩展。
为了将 double 转换为 unsigned int,我们首先将其转换为 signed long,然后截断结果。例如,要将 XMM0 中的 double 转换为 unsigned int 并将其存储到栈上,我们可以使用 Listing 13-21 中的汇编代码。
cvttsd2siq %xmm0, %rax
movl %eax, -4(%rbp)
Listing 13-21:将 double 转换为 unsigned int 的汇编代码
所有位于 无符号整型 范围内的值,也在 有符号长整型 范围内,因此 cvttsd2siq 会正确处理这些值。如果值超出了 无符号整型 的范围,行为是未定义的,因此我们不关心结果会是什么。
从 double 转换为 无符号长整型 更加复杂。首先,我们检查要转换的 double 是否位于 有符号长整型 的范围内。如果是,我们可以通过 cvttsd2siq 指令进行转换。如果不是,我们将从 double 中减去 LONG_MAX + 1 的值,以便得到一个落在 有符号长整型 范围内的结果。然后,我们将该结果通过 cvttsd2siq 指令转换为整数,转换后再加上 LONG_MAX + 1。 列表 13-22 演示了如何将存储在 XMM0 中的 double 转换为 RAX 中的 无符号长整型。
.section .rodata
.align 8
.L_upper_bound:
❶ .double 9223372036854775808.0
.text
`--snip--`
❷ comisd .L_upper_bound(%rip), %xmm0
jae .L_out_of_range
❸ cvttsd2siq %xmm0, %rax
jmp .L_end
.L_out_of_range:
movsd %xmm0, %xmm1
❹ subsd .L_upper_bound(%rip), %xmm1
cvttsd2siq %xmm1, %rax
movq $9223372036854775808, %rdx
addq %rdx, %rax
.L_end:
列表 13-22:将一个 double 转换为 无符号长整型 在汇编中的实现
我们定义了一个常量 double,其值为 LONG_MAX + 1,即 2⁶³ ❶。为了执行转换,首先我们检查 XMM0 中的值是否低于这个常量 ❷。如果是,我们可以通过 cvttsd2siq 指令将其转换为整数 ❸,然后跳过处理其他情况的指令。
如果 XMM0 大于 .L_upper_bound 常量,那么它对 cvttsd2siq 来说太大,无法转换。为了处理这种情况,我们跳转到 .L_out_of_range 标签。我们首先将源值复制到 XMM1,以避免覆盖原始值,然后从中减去 .L_upper_bound ❹。如果原始值在 unsigned long 范围内,则新值将位于 long 范围内。因此,我们可以使用 cvttsd2siq 指令将 XMM1 转换为 signed long。(如果原始值不在 unsigned long 范围内,行为将根据 C 标准未定义,cvttsd2siq 将导致不确定的整数。)此时,RAX 中的值正好比正确答案小 2⁶³(或 9,223,372,036,854,775,808),因此我们加上 9223372036854775808 以得到最终结果。
Listing 13-22 包含一个十进制值,.L_upper_bound,汇编器将其转换为双精度浮点数。它还包含浮点数相减。我们知道,这两种操作都可能引入舍入误差。这个舍入误差会导致结果不正确吗?
幸运的是,它不会出现问题。我们可以证明 示例 13-22 完全不需要任何舍入。首先,9223372036854775808.0 可以精确表示为一个 double,其中有效数字是 1,指数是 63。(这就是我们使用这个常量而不是 LONG_MAX 的原因,因为 double 无法精确表示 LONG_MAX。)double 还可以在我们关心的每种情况下精确表示 subsd .L_upper_bound(%rip), %xmm0 的结果。具体来说,我们关心的是源值大于或等于 9223372036854775808.0(即 2⁶³),但小于或等于 ULONG_MAX(即 2⁶⁴ – 1)。这意味着我们可以将该值写为 1.x × 2⁶³,其中 x 是某个比特序列。由于 double 类型具有 53 位精度,x 的长度不能超过 52 位。当我们从源值中减去 1 × 2⁶³ 时,结果将是 x × 2⁶²,该结果最多需要 52 位精度才能精确表示。(这是 Sterbenz 引理 的一个特例,如果你有兴趣可以查阅。)
因此,这次减法会给我们一个精确的结果,转换为整数后再加上 9223372036854775808 将会得到一个精确的最终答案。
将有符号整数转换为双精度浮点数
cvtsi2sd 指令将一个有符号整数转换为 double 类型。你可以根据源操作数是 32 位还是 64 位整数,使用 l 或 q 后缀。如果结果无法精确表示为 double 类型,它将根据 CPU 当前的舍入模式进行舍入,我们可以假设使用的是四舍五入到最近的模式。
将无符号整数转换为双精度浮点数
cvtsi2sd指令将其源操作数解释为二进制补码值,这意味着任何上位位设置的值都会被转换为负的double。不幸的是,我们没有可以代替的无符号版本的cvtsi2sd指令。因此,我们又回到了类似于上一节关于无符号整数的情况,因此我们将依赖于类似的技术。
为了将一个unsigned int转换为double,我们可以将其零扩展为long,然后使用cvtsi2sdq将其转换为double。Listing 13-23 展示了如何使用这种方法将无符号整数4294967290转换为double。
movl $4294967290, %eax
cvtsi2sdq %rax, %xmm0
Listing 13-23: 在汇编中将 unsigned int 转换为 double
请记住,movl指令将一个值移动到寄存器的低 32 位,并将其高 32 位清零。该示例中的第一条指令实际上是将4294967290移动到 RAX 寄存器中,并进行零扩展。这个零扩展后的数字,无论我们将其解释为有符号还是无符号,其值都是相同的,因此cvtsi2sdq指令将正确转换它,将浮点值4294967290.0存储在 XMM0 中。
这就剩下从unsigned long转换到double的部分了。为了处理这种情况,我们首先检查值是否在signed long能够表示的范围内。如果在范围内,我们可以直接使用cvtsi2sdq。否则,我们将源值减半,使其进入signed long的范围,使用cvtsi2sdq进行转换,然后将转换结果乘以二。尝试在汇编中执行这种转换可能会像 Listing 13-24 一样。
❶ cmpq $0, -8(%rbp)
jl .L_out_of_range
❷ cvtsi2sdq -8(%rbp), %xmm0
jmp .L_end
.L_out_of_range:
movq -8(%rbp), %rax
❸ shrq %rax
cvtsi2sdq %rax, %xmm0
addsd %xmm0, %xmm0
.L_end:
示例 13-24: 在汇编中错误地将 无符号长整型 转换为 double
我们首先检查源值(位于 -8(%rbp))是否越界,通过与零进行有符号比较 ❶。如果该有符号值大于或等于零,我们可以直接使用 cvtsi2sdq ❷,然后跳过处理越界情况的指令。
否则,我们跳转到 .L_out_of_range 标签。我们将源值复制到 RAX 中,然后通过将其右移 1 位,使用单目 shrq 指令将其减半 ❸。(助记符 shr 是 shift right 的缩写。)接下来,我们使用 cvtsi2sdq 将减半后的值转换为最接近的可表示的 double。最后,我们将结果加到自己身上,得到原始值的 double 表示(或者至少是 double 能精确表示的最接近的值)。
但是这段代码存在一个问题:结果并不总是正确四舍五入。当我们用 shrq 将一个整数减半时,会向下舍入;例如,将 9 减半的结果是 4。如果这个向下舍入的整数恰好位于 double 能表示的两个连续值之间的精确中点位置,cvtsi2sdq 可能再次向下舍入,尽管原始整数离上方的 double 值更近,而不是下方的那个。这就造成了双重舍入错误!
让我们通过一个具体的例子来演示。(为了让这个例子更易读,我会将相邻大数之间不同的数字用粗体显示。)我们将根据示例 13-24 将9223372036854776833转换为double类型。与我们的源操作数最接近的double值是9223372036854775808.0,比源值少了 1,025,以及9223372036854777856.0,比源值多了 1,023。我们应该将源值转换为更高的double值,因为它更接近。
使用shrq将源值减半得到4611686018427388416。这个整数正好位于两个相邻的double值的中间:4611686018427387904.0 和 4611686018427388928.0。
以二进制分数形式写出时,较低的值是:
1.000000000000000000000000000000000000000000000000000**0** * 2⁶²
而更高的一个是:
1.000000000000000000000000000000000000000000000000000**1** * 2⁶²
这种表示法显示了两个值的有效数位,写出了所有可用的精度。由于我们按四舍五入规则将值四舍五入到最接近的偶数,所以我们选择有效数位的最低有效位为 0 的那个值。在这个特定的例子中,这意味着向下舍入,因此cvtsi2sd产生了较低的double值4611686018427387904.0。然后我们将其加到自身上,最终得到的答案是9223372036854775808.0。我们并没有得到比初始值稍大的double,即正确的四舍五入结果,而是得到了比它稍小的double。 图 13-5 展示了双重舍入如何导致错误结果。(为了缩小图形大小,我们只显示了每个数字的前几位和最后几位。)

图 13-5:从无符号长整型转换到双精度浮点型时的双重四舍五入错误 描述
虚线箭头表示将 9223372036854776833 / 2 四舍五入到最近的 double 的正确结果。两条实线则展示了双重四舍五入的实际结果。
为了避免这种错误,我们需要确保在将初始值减半时,不会将结果四舍五入到两个 double 可以表示的值之间的中点。我们将使用一种名为 四舍五入到奇数 的技巧。当我们将源值减半时,不会将其截断为零,而是四舍五入到最近的奇数。使用这种四舍五入规则,我们将把 9 / 2 四舍五入到 5,而不是四舍五入到 4。类似地,我们将把 7 / 2 四舍五入到 3,而将 9223372036854776833 / 2 四舍五入到 4611686018427388417。如果除以 2 的结果已经是一个整数,我们就不需要四舍五入;例如,16 / 2 仍然是 8。(只有 shrq 的结果需要四舍五入到奇数;cvtsi2sdq 仍然会四舍五入到最近的值。)
四舍五入到奇数在这种情况下有效,因为我们想避免的中点总是偶数。二进制浮点数之间的间隔总是 2 的幂,并且在更大的数量级上间隔会增大。请记住,我们仅在整数太大以至于无法适应 long 时,才会对其进行减半转换。因此,减半后的值将在 (LONG_MAX + 1) / 2 和 ULONG_MAX / 2 之间。在这个范围内,可表示的 double 值之间的间隔是 1,024,因此每个中点都是 512 的倍数,而 512 是偶数。
图 13-6 展示了几种不同的四舍五入到奇数的情况。

图 13-6:使用奇数舍入以避免双重舍入误差 描述
在第一种情况下,舍入到奇数可以防止我们将结果舍入到中点然后向下舍入到不正确的结果。在其余情况下,它不会改变最终结果;无论是在第一次舍入时舍入到奇数还是朝向零,我们得到的答案都与仅使用最近舍入、平均分割模式下舍入一次相同。有时舍入到奇数是必要的,以获得正确的答案,有时它没有影响,但它永远不会给我们错误的答案。
现在我们知道为什么向奇数舍入有效了,让我们来看看如何在汇编中实现它。清单 13-25 展示了如何将存储在 RAX 中的值减半并将结果舍入为奇数。
movq %rax, %rdx
shrq %rdx
andq $1, %rax
orq %rax, %rdx
清单 13-25:使用 shrq 减半整数后进行奇数舍入
这个清单包含两条新指令:and 和 or。如果你在第三章中做了额外的学分部分,你应该已经熟悉它们了。这些指令执行位与和位或操作,分别与我们的其他整数二进制操作指令(包括 add 和 sub)完全相同。
让我们来看看这段代码为什么有效。首先,我们将要减半的值复制到 RDX 中,并用 shrq 减半。接下来,我们取原始值在 RAX 中和 1 的位与操作;如果原始值是奇数,则结果为 1,如果是偶数,则为 0。
现在我们需要决定如何处理 RDX 中的减半值。此时有三种可能性:
-
原始值是偶数,因此 RDX 包含减半后的确切结果。因此,我们不需要再舍入。
-
原始值为奇数,并且 shrq 的结果也是奇数。例如,如果原始值是 3,用 shrq 减半将得到 1。在这种情况下,结果已经舍入为奇数,无需更改。
-
原始值是奇数,而 shrq 的结果是偶数。例如,如果原始值是 5,使用 shrq 将其除以 2 会得到 2。在这种情况下,结果没有四舍五入为奇数,我们需要将其递增。
在这三种情况下,最后一条指令 orq %rax, %rdx 达到了预期效果。在第一种情况下,由于之前的 and 指令,RAX 为 0,所以它不做任何操作。在第二种情况下,由于 RDX 的最低有效位已经是 1,它也不做任何操作。在第三种情况下,它将 RDX 的最低有效位从 0 翻转为 1,并使得值变为奇数。
综合来看,清单 13-26 展示了将一个 unsigned long 转换为正确四舍五入的 double 的完整汇编代码。
cmpq $0, -8(%rbp)
jl .L_out_of_range
cvtsi2sdq -8(%rbp), %xmm0
jmp .L_end
.L_out_of_range:
movq -8(%rbp), %rax
**movq %rax, %rdx**
**shrq %rdx**
**andq $1, %rax**
**orq %rax, %rdx**
cvtsi2sdq **%rdx,** %xmm0
addsd %xmm0, %xmm0
.L_end:
清单 13-26:在汇编中正确地将一个 unsigned long 转换为 double 类型
这段代码与清单 13-24 中的原始转换完全相同,唯一的不同之处在于加粗部分:我们将 shrq 的结果四舍五入为奇数,并使用四舍五入后的值(在 RDX 中)作为 cvtsi2sdq 指令的源。
我们现在已经讨论了如何实现我们所需的每个浮点操作;我们准备更新汇编生成阶段!
汇编生成
如常,我们的第一项任务是更新汇编抽象语法树(AST)。我们将添加一个新的 Double 汇编类型:
assembly_type = Longword | Quadword **| Double**
我们还将添加一个新的顶级构造,表示浮点常量:
StaticConstant(identifier name, int alignment, static_init init)
这个构造几乎与 StaticVariable 相同。唯一的区别是我们可以省略 global 属性,因为我们不会定义全局常量。目前,我们仅定义浮点常量;在后续章节中,我们将使用该构造来定义其他类型的常量。
接下来,我们将添加两个新的指令,Cvtsi2sd 和 Cvttsd2si:
instruction = `--snip--`
| Cvttsd2si(assembly_type dst_type, operand src, operand dst)
| Cvtsi2sd(assembly_type src_type, operand src, operand dst)
每个操作都需要一个 assembly_type 参数,用来指定它是作用于 长字(Longword) 还是 四字(Quadword) 整数。
我们将重用现有的 Cmp 和 Binary 指令来表示使用 comisd 进行的浮点比较和使用 addsd、subsd 等进行的算术运算。我们还将添加一个新的 DivDouble 二元操作符来表示浮点除法。(回忆一下,汇编 AST 并不包括整数除法的二元操作符,因为 div 和 idiv 的模式与其他算术指令不同。)我们还将添加用于取反浮点值的 Xor 二元操作符,以及用于将 无符号长整数 转换为 双精度浮点数(double) 的位运算符 And 和 Or:
binary_operator = `--snip--` | DivDouble | And | Or | Xor
对于这种类型的转换,我们还需要一元操作符 Shr:
unary_operator = `--snip--` | Shr
最后,我们将添加 XMM 寄存器。我们需要 XMM0 到 XMM7 用于参数传递,再加上一些额外的临时寄存器用于指令重写。你可以使用 XMM0 到 XMM7 之外的任何寄存器作为临时寄存器;我将使用 XMM14 和 XMM15。你可以将所有 16 个寄存器都添加到 AST 中,或者只添加当前需要的寄存器:
reg = `--snip--` | XMM0 | XMM1 | XMM2 | XMM3 | XMM4 | XMM5 | XMM6 | XMM7 | XMM14 | XMM15
列表 13-27 给出了完整的汇编 AST,其中的更改部分已加粗。
program = Program(top_level*)
assembly_type = Longword | Quadword **| Double**
top_level = Function(identifier name, bool global, instruction* instructions)
| StaticVariable(identifier name, bool global, int alignment, static_init init)
**| StaticConstant(identifier name, int alignment, static_init init)**
instruction = Mov(assembly_type, operand src, operand dst)
| Movsx(operand src, operand dst)
| MovZeroExtend(operand src, operand dst)
**| Cvttsd2si(assembly_type dst_type, operand src, operand dst)**
**| Cvtsi2sd(assembly_type src_type, operand src, operand dst)**
| Unary(unary_operator, assembly_type, operand)
| Binary(binary_operator, assembly_type, operand, operand)
| Cmp(assembly_type, operand, operand)
| Idiv(assembly_type, operand)
| Div(assembly_type, operand)
| Cdq(assembly_type)
| Jmp(identifier)
| JmpCC(cond_code, identifier)
| SetCC(cond_code, operand)
| Label(identifier)
| Push(operand)
| Call(identifier)
| Ret
unary_operator = Neg | Not **| Shr**
binary_operator = Add | Sub | Mult **| DivDouble | And | Or | Xor**
operand = Imm(int) | Reg(reg) | Pseudo(identifier) | Stack(int) | Data(identifier)
cond_code = E | NE | G | GE | L | LE | A | AE | B | BE
reg = AX | CX | DX | DI | SI | R8 | R9 | R10 | R11 | SP
**| XMM0 | XMM1 | XMM2 | XMM3 | XMM4 | XMM5 | XMM6 | XMM7 | XMM14 | XMM15**
列表 13-27:带有浮点常量、指令和寄存器的汇编 AST
我们已经了解了如何在汇编中执行浮点运算,但仍有一些实现细节需要讨论。我们将首先处理常量。
浮点常量
在前几章中,我们将 TACKY 中的整数常量转换为汇编中的 Imm 操作数。对于浮点常量,这种方法不适用。相反,当我们遇到 TACKY 中的 double 常量时,我们将生成一个具有唯一标识符的新的顶层 StaticConstant 构造。为了在指令中使用该常量,我们像使用静态变量一样,通过 Data 操作数来引用它。例如,假设我们需要将以下 TACKY 指令转换为汇编:
Copy(Constant(ConstDouble(1.0)), Var("x"))
首先,我们将生成一个唯一的标签 const_label,该标签不会与符号表中的任何名称或我们作为跳转目标使用的内部标签冲突。然后,我们将像这样定义一个新的顶层常量:
StaticConstant(const_label, 8, DoubleInit(1.0))
这个顶层常量必须按 8 字节对齐,以符合 System V ABI。在定义此常量后,我们将生成以下汇编指令:
Mov(Double, Data(const_label), Pseudo("x"))
跟踪您在整个汇编生成过程中定义的每一个 StaticConstant。然后,在该过程结束时,将这些常量添加到您的顶层构造列表中。
除了常量处理之外,这个示例还展示了汇编生成中的一些不变事项。首先,无论我们是复制整数还是 double,我们依然会将 TACKY 的 Copy 指令转换为汇编中的 Mov 指令。其次,TACKY 变量仍然会被转换为 Pseudo 操作数,无论它们的类型是什么。
这里有一些可选的调整,您可以进行修改,以便让您的顶层常量更符合生产环境编译器生成的格式:
避免重复常量
不要生成多个等效的 StaticConstant 构造。相反,每当您在 TACKY 中看到一个 double 常量时,请检查您是否已经生成了具有相同值和对齐方式的 StaticConstant。如果已经生成了,请在汇编代码中引用该常量,而不是生成一个新的常量。只需记住,0.0 和 -0.0 是不同的常量,即使它们在大多数语言中比较时相等,仍然需要分别生成 StaticConstant 构造。
使用局部标签来处理顶层常量
编译器通常使用以 L(在 macOS 上)或 .L(在 Linux 上)开头的本地标签来表示浮点常量,这样它们就不会作为符号出现在最终的可执行文件中。(回想一下,我们已经为跳转目标使用了本地标签。)如果你想遵循这种命名约定,请暂时不要添加本地标签前缀;等到代码生成阶段再添加。现在,先将顶级常量添加到后端符号表中,并使用一个新属性将它们与变量区分开来。示例 13-28 展示了如何从示例 11-26 更新原始后端符号表条目,加入这个属性。
asm_symtab_entry = ObjEntry(assembly_type, bool is_static, **bool is_constant**)
| FunEntry(bool defined)
示例 13-28:后端符号表中条目的定义,包括 is_constant 属性
在代码生成期间,我们将使用这个新属性来确定哪些操作数应该添加本地标签前缀。is_static 属性对于常量也应该为真,因为我们将常量存储在只读数据段中,并通过 RIP 相对寻址方式访问它们。我们会等到代码生成时才添加本地标签,而不是一开始就生成它们,因为当我们在第十六章添加更多种类的顶级常量时,这种方法更容易扩展。
可以自由选择执行这两项修改,跳过这两项,或者只做其中一项。
一元指令、二元指令和条件跳转
我们将像处理整数等效指令一样,将浮点加法、减法和乘法指令从 TACKY 转换为汇编。浮点除法将遵循与这些指令相同的模式,尽管整数除法则不然。考虑以下 TACKY 指令:
Binary(Divide, Var("src1"), Var("src2"), Var("dst"))
如果其操作数的类型是 double,我们将生成以下汇编:
Mov(Double, Pseudo("src1"), Pseudo("dst"))
Binary(DivDouble, Double, Pseudo("src2"), Pseudo("dst"))
我们还将以不同于整数的方式来处理浮点数的取反操作。为了取反一个 double 类型的值,我们将它与 -0.0 进行异或操作。例如,要转换 TACKY 指令
Unary(Negate, Var("src"), Var("dst"))
我们将从定义一个新的 16 字节对齐的常量开始:
StaticConstant(const, 16, DoubleInit(-0.0))
然后,我们将生成以下汇编指令:
Mov(Double, Pseudo("src"), Pseudo("dst"))
Binary(Xor, Double, Data(const), Pseudo("dst"))
我们需要将 -0.0 对齐到 16 字节,以便在 xorpd 指令中使用。这是我们唯一一次将 double 对齐到 16 字节,而不是 8 字节。我们不需要担心 dst 的对齐;xorpd 的目标必须是一个寄存器,我们将在指令修正过程中处理这个要求。
接下来,让我们讨论如何处理关系二元运算符:Equal、LessThan 等。由于 comisd 设置了 CF 和 ZF 标志,因此我们将像无符号整数比较一样处理浮点数比较。以下是一个示例:
Binary(LessThan, Var("x"), Var("y"), Var("dst"))
如果 x 和 y 是浮点值,我们将生成以下汇编代码:
Cmp(Double, Pseudo("y"), Pseudo("x"))
Mov(Longword, Imm(0), Pseudo("dst"))
SetCC(B, Pseudo("dst"))
我们将采用与三个 TACKY 指令相似的方法,这些指令将一个值与零进行比较:JumpIfZero、JumpIfNotZero 和一元 Not 操作。我们将进行转换。
JumpIfZero(Var("x"), "label")
转换为以下汇编代码:
Binary(Xor, Double, Reg(XMM0), Reg(XMM0))
Cmp(Double, Pseudo("x"), Reg(XMM0))
JmpCC(E, "label")
请注意,我们需要使用 xorpd 清零一个 XMM 寄存器,以便进行比较。在这里你不需要使用 XMM0,但不应使用在重写过程中选择的临时寄存器。如果你严格区分每个后端过程引入的寄存器,避免寄存器使用冲突会更加容易。
类型转换
由于我们已经覆盖了每种类型转换的汇编代码,因此这里不再重复,但我会标出一些我们尚未讨论的细节。首先,你需要选择用于这些转换的硬件寄存器。所有四种 double 与无符号整数类型之间的转换都使用 XMM 寄存器、通用寄存器或两者。例如,列表 13-26 使用 RAX 和 RDX 将整数减半并四舍五入为奇数。在这里你不需要坚持使用我们之前讲解这些转换时所用的寄存器;只需避免使用被调用方保存的寄存器(RBX、R12、R13、R14 和 R15)以及你在重写过程中使用的寄存器(R10 和 R11,以及你的两个临时 XMM 寄存器;我的分别是 XMM14 和 XMM15)。
其次,当你处理从 unsigned int 到 double 的转换时,务必生成一个 MovZeroExtend 指令,显式地将源值零扩展,而不是使用 Mov 指令。这一点在我们实现第三部分的寄存器分配时非常重要。我们将使用一种叫做寄存器合并的技术来删除冗余的 mov 指令,在分配寄存器时;在这里使用 MovZeroExtend 而不是 Mov,表示你正在使用该指令清零字节,而不仅仅是移动值,因此不应将其删除。
具体而言,如果 x 是一个 unsigned int,你将转换 TACKY 指令
UIntToDouble(Var("x"), Var("y"))
转换成以下汇编:
MovZeroExtend(Pseudo("x"), Reg(AX))
Cvtsi2sd(Quadword, Reg(AX), Pseudo("y"))
在这里,你可以使用除 RAX 之外的其他寄存器,只要它满足我们刚刚讨论的要求。
函数调用
在函数调用中处理浮动点值的棘手部分是弄清楚每个参数是如何传递的。我们将在两个地方使用这些信息。首先,当我们翻译 TACKY FunCall 指令时,我们需要它来正确传递参数。其次,我们将在每个函数体的开始部分使用它来设置参数。我们将编写一个辅助函数,classify_parameters,来处理这两个地方所需的记账工作。给定一组 TACKY 值,这个辅助函数将把每个值转换为汇编操作数,并确定它的汇编类型。它还会将列表分成三部分:一部分是通过通用寄存器传递的操作数,一部分是通过 XMM 寄存器传递的操作数,另一部分是通过栈传递的操作数。列表 13-29 给出了
classify_parameters(values):
int_reg_args = []
double_reg_args = []
stack_args = []
for v in values:
operand = convert_val(v)
t = assembly_type_of(v)
❶ typed_operand = (t, operand)
if t == Double:
❷ if length(double_reg_args) < 8:
❸ double_reg_args.append(operand)
else:
stack_args.append(typed_operand)
else:
if length(int_reg_args) < 6:
❹ int_reg_args.append(typed_operand)
else:
stack_args.append(typed_operand)
return (int_reg_args, double_reg_args, stack_args)
列表 13-29:分类函数参数或参数列表
为了处理每个参数,我们首先将其从 TACKY 值转换为汇编操作数,并将其类型转换为相应的汇编类型。(我不会给出assembly_type_of的伪代码,它只是查找 TACKY 值的类型并将其转换为汇编类型。)我们将这些打包成一对,typed_operand ❶。int_reg_args和stack_args的元素将都是这种形式的对。double_reg_args的元素将是普通的汇编操作数;因为它们都是double类型,因此显式指定每个类型是多余的。
接下来,我们确定将操作数添加到哪个列表中。我们会检查是否可以通过其类型的可用寄存器传递它。例如,如果它的类型是Double,我们会检查double_reg_args是否已经包含了八个值 ❷。如果包含了,寄存器 XMM0 至 XMM7 已被占用。如果没有,至少还有一个 XMM 寄存器是可用的。
如果我们可以通过 XMM 寄存器传递操作数,我们将把operand添加到double_arg_regs ❸。如果我们可以通过通用寄存器传递它,我们将把typed_operand添加到int_arg_regs ❹。如果没有可用的正确类型的寄存器,我们将把typed_operand添加到stack_args。处理完每个值后,我们将返回这三个列表。
当我们构建这三个列表时,我们会保留值出现的顺序。特别是,我们将按值在原始列表中出现的顺序将值添加到stack_args中,而不是倒序。这意味着,stack_args中的第一个值将最后被压入栈中,并会出现在栈顶。从被调用者的角度来看,第一个值将存储在16(%rbp),第二个值将存储在24(%rbp),依此类推。
回想一下,在函数体开始时,我们会将任何参数从它们的初始位置复制到伪寄存器中。Listing 13-30 演示了如何使用classify_parameters来执行此设置。
set_up_parameters(parameters):
// classify them
int_reg_params, double_reg_params, stack_params = classify_parameters(parameters)
// copy parameters from general-purpose registers
int_regs = [DI, SI, DX, CX, R8, R9]
reg_index = 0
for (param_type, param) in int_reg_params:
r = int_regs[reg_index]
emit(Mov(param_type, Reg(r), param))
reg_index += 1
// copy parameters from XMM registers
double_regs = [XMM0, XMM1, XMM2, XMM3, XMM4, XMM5, XMM6, XMM7]
reg_index = 0
for param in double_reg_params:
r = double_regs[reg_index]
emit(Mov(Double, Reg(r), param))
reg_index += 1
// copy parameters from the stack
offset = 16
for (param_type, param) in stack_params:
emit(Mov(param_type, Stack(offset), param))
offset += 8
清单 13-30:在函数体内设置参数
在这个清单中,set_up_parameters接受一个 TACKY 的Var列表,表示函数的参数列表。我们通过classify_parameters处理这个列表,然后处理由此产生的三个汇编操作数列表。对于通过通用寄存器传递的参数,我们将 EDI(或 RDI,取决于类型)中的值复制到第一个参数的伪寄存器,将 ESI 中的值复制到第二个参数,以此类推。我们以相同的方式处理通过 XMM 寄存器传递的参数。最后,我们处理通过栈传递的参数:我们将Stack(16)中的值复制到stack_params中的第一个伪寄存器,然后将栈偏移量增加 8,以处理每个后续的参数,直到处理完整个列表。
我们还将使用classify_parameters来实现 TACKY 的FunCall指令。让我们重新审视将FunCall转换为汇编的伪代码,这段代码最早在清单 9-31 中介绍,并在清单 11-25 中更新。清单 13-31 再次展示了这个伪代码,并将处理浮点参数和返回值的新逻辑加粗。(我没有加粗像将arg_registers重命名为int_registers这样的细节变化。)
convert_function_call(FunCall(fun_name, args, dst)):
int_registers = [DI, SI, DX, CX, R8, R9]
**double_registers = [XMM0, XMM1, XMM2, XMM3, XMM4, XMM5, XMM6, XMM7]**
**// classify arguments**
**int_args, double_args, stack_args = classify_parameters(args)**
// adjust stack alignment
if length(stack_args) is odd:
stack_padding = 8
else:
stack_padding = 0
if stack_padding != 0:
emit(Binary(Sub, Quadword, Imm(stack_padding), Reg(SP)))
// pass args in registers
reg_index = 0
for (assembly_type, assembly_arg) in int_args:
r = int_registers[reg_index]
emit(Mov(assembly_type, assembly_arg, Reg(r)))
reg_index += 1
**reg_index = 0**
**for assembly_arg in double_args:**
**r = double_registers[reg_index]**
**emit(Mov(Double, assembly_arg, Reg(r)))**
**reg_index += 1**
// pass args on stack
for (assembly_type, assembly_arg) in reverse(stack_args):
if (assembly_arg is a Reg or Imm operand
or assembly_type == Quadword
**or assembly_type == Double**):
emit(Push(assembly_arg))
else:
emit(Mov(**assembly_type**, assembly_arg, Reg(AX)))
emit(Push(Reg(AX)))
// emit call instruction
emit(Call(fun_name))
// adjust stack pointer
bytes_to_remove = 8 * length(stack_args) + stack_padding
if bytes_to_remove != 0:
emit(Binary(Add, Quadword, Imm(bytes_to_remove), Reg(SP)))
// retrieve return value
assembly_dst = convert_val(dst)
return_type = assembly_type_of(dst)
**if return_type == Double:**
**emit(Mov(Double, Reg(XMM0), assembly_dst))**
**else:**
emit(Mov(return_type, Reg(AX), assembly_dst))
清单 13-31:支持 double 在函数调用中的使用
让我们逐步分析这个清单中的变化。首先,我们需要用classify_parameters对参数进行分类。int_args中的参数与之前一样通过通用寄存器传递(可能会有一些小的调整,这里没有加粗,以适应我们迭代的是带类型的汇编操作数,而不是 TACKY 值)。我们新增了一个步骤,将每个参数从double_args复制到相应的 XMM 寄存器中。
接下来,我们更新了如何通过栈传递参数。我们对 清单 11-25 做了两项小改动,上次我们查看这个步骤时就在其中。首先,像 Quadword 类型的操作数一样,Double 类型的伪操作数直接推入栈中,而不需要先将其复制到寄存器中,因为它们是符合 Push 指令的正确操作数大小。其次,在我们将操作数移动到 AX 寄存器中然后再推入栈的情况下,我们不再硬编码 Longword 作为 Mov 指令的类型;而是使用我们在 classify_parameters 中确定的操作数类型。这确保了我们的代码能够适应后续章节,其中我们将添加更多汇编类型。
最后,我们更新了如何获取函数的返回值。如果返回值是 double,我们将其从 XMM0 复制到目标位置。否则,我们会像往常一样从 EAX(或 RAX)复制返回值。我们无需更改如何在函数调用前调整栈对齐、发出 call 指令或在调用后清理参数。
返回指令
最后但同样重要的是,我们将改变如何翻译 TACKY Return 指令。例如,给定 TACKY 指令
Return(Var("x"))
我们将在后台符号表中查找 x 的类型。如果它是整数,我们可以像之前一样处理。如果它是 double,我们会将其复制到 XMM0,然后返回:
Mov(Double, Pseudo("x"), Reg(XMM0))
Ret
至此,我们已经覆盖了汇编生成过程中所有的更新。
从 TACKY 到汇编的完整转换
表格 13-2 到 13-7 总结了本章关于从 TACKY 到汇编的转换的变化。像往常一样,新的结构和现有结构的转换变化以粗体标出。 表格 13-3 中的
表格 13-2: 将顶层 TACKY 结构转换为汇编
| TACKY 顶层构造 | 汇编顶层构造 | |
|---|---|---|
| 程序(top_level_defs) |
Program(top_level_defs + <all StaticConstant constructs for
floating-point constants>)
|
|
Function(name, global, params,
instructions)
|
Function(name, global,
[Mov(<first int param type>, Reg(DI), <first int param>),
Mov(<second int param type>, Reg(SI),
<second int param>),
<copy next four integer parameters from registers>,
Mov(Double, Reg(XMM0), <first double param>),
Mov(Double, Reg(XMM1), <second double param>),
<copy next six double parameters from registers>,
Mov(<first stack param type>, Stack(16),
<first stack param>),
Mov(<second stack param type>, Stack(24),
<second stack param>),
<copy remaining parameters from stack>] +
instructions)
|
表 13-3: 将 TACKY 指令转换为汇编代码
| TACKY 指令 | 汇编指令 | |
|---|---|---|
| 返回(val) | 整数 |
Mov(<val type>, val, Reg(AX))
Ret
|
| 双精度 |
|---|
Mov(Double, val, Reg(XMM0))
Ret
|
| 一元运算(非,src,dst) | 整数 |
|---|
Cmp(<src type>, Imm(0), src)
Mov(<dst type>, Imm(0), dst)
SetCC(E, dst)
|
| 双精度 |
|---|
Binary(Xor, Double, Reg(<X>), Reg(<X>))
Cmp(Double, src, Reg(<X>))
Mov(<dst type>, Imm(0), dst)
SetCC(E, dst)
|
| 一元运算(取反,src,dst)(双精度 取反运算) |
|---|
Mov(Double, src, dst)
Binary(Xor, Double, Data(<negative-zero>), dst)
And add a top-level constant:
StaticConstant(<negative-zero>, 16,
DoubleInit(-0.0))
|
| 二元运算(除法,src1,src2,dst) (整数除法) | 有符号 |
|---|
Mov(<src1 type>, src1, Reg(AX))
Cdq(<src1 type>)
Idiv(<src1 type>, src2)
Mov(<src1 type>, Reg(AX), dst)
|
| 无符号 |
|---|
Mov(<src1 type>, src1, Reg(AX))
Mov(<src1 type>, Imm(0), Reg(DX))
Div(<src1 type>, src2)
Mov(<src1 type>, Reg(AX), dst)
|
| 跳转如果为零(condition,target) | 整数 |
|---|
Cmp(<condition type>, Imm(0), condition)
JmpCC(E, target)
|
| 双精度 |
|---|
Binary(Xor, Double, Reg(<X>), Reg(<X>))
Cmp(Double, condition, Reg(<X>))
JmpCC(E, target)
|
| 跳转如果不为零(condition,target) | 整数 |
|---|
Cmp(<condition type>, Imm(0), condition)
JmpCC(NE, target)
|
| 双精度 |
|---|
Binary(Xor, Double, Reg(<X>), Reg(<X>))
Cmp(Double, condition, Reg(<X>))
JmpCC(NE, target)
|
| 函数调用(fun_name,args,dst) |
|---|
<fix stack alignment>
<move arguments to general-purpose registers>
<move arguments to XMM registers>
<push arguments onto the stack>
Call(fun_name)
<deallocate arguments/padding>
Mov(<dst type>, <dst register>, dst)
|
| IntToDouble(src, dst) |
|---|
Cvtsi2sd(<src type>, src, dst)
|
| DoubleToInt(src, dst) |
|---|
Cvttsd2si(<dst type>, src, dst)
|
| UIntToDouble(src, dst) | 无符号整数 |
|---|
MovZeroExtend(src, Reg(<R>))
Cvtsi2sd(Quadword, Reg(<R>), dst)
|
| 无符号长整型 |
|---|
Cmp(Quadword, Imm(0), src)
JmpCC(L, <label1>)
Cvtsi2sd(Quadword, src, dst) Jmp(<label2>)
Label(<label1>)
Mov(Quadword, src, Reg(<R1>))
Mov(Quadword, Reg(<R1>), Reg(<R2>))
Unary(Shr, Quadword, Reg(<R2>))
Binary(And, Quadword, Imm(1), Reg(<R1>))
Binary(Or, Quadword, Reg(<R1>), Reg(<R2>))
Cvtsi2sd(Quadword, Reg(<R2>), dst)
Binary(Add, Double, dst, dst)
Label(<label2>)
|
| DoubleToUInt(src, dst) | 无符号整数 |
|---|
Cvttsd2si(Quadword, src, Reg(<R>))
Mov(Longword, Reg(<R>), dst)
|
| 无符号长整型 |
|---|
Cmp(Double, Data(<upper-bound>), src)
JmpCC(AE, <label1>)
Cvttsd2si(Quadword, src, dst)
Jmp(<label2>)
Label(<label1>)
Mov(Double, src, Reg(<X>))
Binary(Sub, Double, Data(<upper-bound>),
Reg(<X>))
Cvttsd2si(Quadword, Reg(<X>), dst)
Mov(Quadword, Imm(9223372036854775808), Reg(<R>))
Binary(Add, Quadword, Reg(<R>), dst)
Label(<label2>)
And add a top-level constant:
StaticConstant(<upper-bound>, 8,
DoubleInit(9223372036854775808.0))
|
表 13-4: 将 TACKY 算术运算符转换为汇编代码
| TACKY 运算符 | 汇编运算符 |
|---|---|
| 除法 | DivDouble |
| (double 除法) |
表 13-5: 将 TACKY 比较转换为汇编
| TACKY 比较 | 汇编条件代码 |
|---|---|
| LessThan | Signed |
| 无符号 或 double | |
| LessOrEqual | Signed |
| 无符号 或 double | |
| GreaterThan | Signed |
| Unsigned or double | |
| GreaterOrEqual | Signed |
| 无符号 或 double |
表 13-6: 将 TACKY 操作数转换为汇编
| TACKY 操作数 | 汇编操作数 |
|---|---|
| 常量(ConstDouble(double)) |
Data(<ident>)
And add top-level constant:
StaticConstant(<ident>, 8, DoubleInit(double))
|
表 13-7: 将类型转换为汇编
| 源类型 | 汇编类型 | 对齐 |
|---|---|---|
| Double | Double | 8 |
正如 表 13-2 中顶级 Program 构造所示,你需要将此过程中新定义的每个 StaticConstant 添加到顶级定义列表中。从这一点开始,更新后端的其余部分相对顺利。
伪寄存器替换
你应该为每个 double 伪寄存器在栈上分配 8 字节,并确保它是 8 字节对齐的。如果后端符号表表明某个 double 具有静态存储持续时间,你应该将对它的任何引用替换为 Data 操作数,就像处理其他静态变量一样。简而言之,这个过程可以将 Double 和 Quadword 伪寄存器视为相同,因为它们具有相同的大小和对齐方式。
像往常一样,你还应该扩展此过程以处理本章中的新汇编指令。
指令修复
接下来,我们将重新编写无效的 SSE 指令。我们还需要重写对整数操作的新的位运算指令。首先,我们处理 SSE 指令。你应该为修复指令的源操作数分配一个 XMM 寄存器,另一个用于修复目标。我将使用 XMM14 来修复源操作数,XMM15 来修复目标。
cvttsd2si 的目标必须是寄存器。例如,我们将重新编写
Cvttsd2si(Quadword, Stack(-8), Stack(-16))
为:
Cvttsd2si(Quadword, Stack(-8), Reg(R11))
Mov(Quadword, Reg(R11), Stack(-16))
cvtsi2sd 指令有两个约束条件:源操作数不能是常量,目标必须是寄存器。因此,我们将重新编写
Cvtsi2sd(Longword, Imm(10), Stack(-8))
为:
Mov(Longword, Imm(10), Reg(R10))
Cvtsi2sd(Longword, Reg(R10), Reg(XMM15))
Mov(Double, Reg(XMM15), Stack(-8))
comisd 指令与 cmp 指令有不同的约束条件。其第二个操作数(即“目标”位置)必须是寄存器。因此,我们将重新编写
Cmp(Double, Stack(-8), Stack(-16))
为:
Mov(Double, Stack(-16), Reg(XMM15))
Cmp(Double, Stack(-8), Reg(XMM15))
addsd、subsd、mulsd、divsd或xorpd指令的目标也必须是一个寄存器,因此我们会相应地重写所有这些指令。xorpd指令还要求源操作数为寄存器或 16 字节对齐的内存地址,但我们不需要为此编写重写规则,因为我们生成的所有xorpd指令已经满足这一要求。
我们将采用与第二章中介绍的通用mov指令相同的重写规则来处理movsd,因为它也受到相同的约束:操作数不能都在内存中。(当然,唯一的不同是我们将使用 XMM 寄存器而不是 R10 作为临时寄存器。)
这就剩下新的按位指令了。我们不需要重写shr。and和or指令的约束与整数的add和sub相同:操作数不能都为内存地址,且它们不能接受超出
还有一个约束我们现在暂时忽略:push指令不能压入 XMM 寄存器。我们将等到下一章再为无效的push指令添加重写规则,因为它将使用一种新的汇编操作数类型,我们还没有添加该类型。实际上,直到我们在第三部分中实现寄存器分配时,我们才需要这个重写规则;在此之前,我们只会压入立即数值和内存操作数(以及函数序言中的 RBP 寄存器)。
代码生成
一如既往,最后一步是输出最新的汇编抽象语法树(AST)。这一步最复杂的部分是发射浮点常数和静态变量。让我们来逐步了解如何在汇编中格式化浮点数,如何标记浮点常数,以及如何将浮点常数和变量存储到正确的段中。
浮点数格式化
在汇编中有几种不同的方式来格式化浮点数。一种选择是将这些数字作为十六进制浮点常量打印,其中尾数是一个十六进制数字,指数是 2 的幂。这个表示法可以精确地表示一个double,没有任何舍入。十六进制浮点常量的尾数有一个0x前缀,指数有一个p或P前缀。例如,十六进制浮点数中的20.0是0x2.8p+3。十六进制数0x2.8在十进制中是2.5,而 2.5 × 2³ = 20。我们可以在
.L_twenty:
.double 0x2.8p+3
当你以这种表示法输出double时,你需要最多 14 个十六进制数字才能精确表示它。不幸的是,并不是每个汇编器都理解这种格式。LLVM 汇编器(macOS 上的默认汇编器)可以理解,然而 GNU 汇编器(GAS)则不行。
如果你的汇编器不支持十六进制浮点常量,你可以输出一个四字节数(quadword),它与所需的double有相同的二进制表示。使用这种方法打印出20.0的结果是:
.L_twenty:
.quad 4626322717216342016
这不是最具可读性的汇编,但只要你的实现语言提供一种方式让你访问浮点数的二进制表示,它就能完美工作。你的最后一个选择是使用十进制浮点常量,这在之前的汇编示例中使用过:
.L_twenty:
.double 20.0
十进制可能比十六进制浮点数更不紧凑。例如,考虑0x1.999999999999ap-4,它是最接近十进制数 0.1 的double。这个值的精确十进制表示是:
1.000000000000000055511151231257827021181583404541015625e-1
你不需要输出整个值;17 个数字足以确保进行往返转换回到原始的double。换句话说,你可以输出一个 17 位数字的十进制近似值0x1.999999999999ap-4,像这样:
1.0000000000000001e-1
这不是完全正确的值,但它足够接近,以至于当汇编器将其转换回double时,你将得到原始值0x1.999999999999ap-4。
标记浮点常量
如果你在顶层常量中使用局部标签,发出这些常量标识符时,应该包含局部标签前缀(在 macOS 上为 L,在 Linux 上为 .L)。你需要检查后端符号表,以区分表示静态变量和表示常量的 Data 操作数。如果对象的 is_constant 属性为真,它需要使用局部标签前缀;否则,它是一个变量,不需要。
如果你不使用局部标签,你需要统一发出所有 Data 操作数。在 macOS 上,这意味着常量和静态变量的标签都需要加上下划线前缀。
将常量存储在只读数据区
存储常量的区段名称是平台特定的。在 Linux 上,你应使用 .section .rodata 指令来指定该区段。在 macOS 上,8 字节对齐和 16 字节对齐的常量存储在不同的区段。如果常量是 8 字节对齐的,使用 .literal8 指令将其存储在正确的区段中。对于我们的 16 字节对齐常量(-0.0,用于实现取反),使用 .literal16 指令。
macOS 链接器期望 16 字节对齐的常量长度为 16 字节,但 -0.0 只有 8 字节。紧跟在 -0.0 指令之后,发出 .quad 0 指令,以将该常量所在区段的总大小提升到 16 字节,从而满足链接器的要求。
将静态变量初始化为 0.0 或 -0.0
我们不会将类型为 double 的静态变量存储在 BSS 区段中,也不会使用 .zero 指令初始化它们,即使它们被初始化为零。这避免了关于 double 是否真的是初始化为 0.0 或 -0.0 的潜在混淆。(这两个值通常比较相等,但我们不能在 BSS 区段中存储 -0.0,也不能使用 .zero 指令初始化它,因为其二进制表示并非全为零。)
将所有内容结合起来
除了浮点常量和静态变量之外,代码生成阶段还需要处理新的 XMM 寄存器、新的指令以及现有指令的浮点版本上的 sd 后缀。这些更改很广泛,但不需要过多讨论。表 13-8 到 13-13 总结了本章对代码生成阶段的更新。新的结构和我们生成现有结构的方式的更改以粗体显示。
表 13-8: 格式化汇编顶层结构
| 汇编顶层结构 | 输出 |
|---|
|
StaticVariable(name, global,
alignment, init)
| 初始化为零的整数 |
|---|
<global-directive> .bss
<alignment-directive>
<name>:
<init>
|
| 具有非零初始化器的整数, 或任何 double |
|---|
<global-directive>
.data
<alignment-directive>
<name>:
<init>
|
|
StaticConstant(name, alignment,
init)
| Linux |
|---|
.section .rodata
<alignment-directive> <name>:
<init>
|
|
macOS (8-byte-aligned constants)
|
.literal8
.balign 8
<name>:
<init>
|
|
macOS (16-byte-aligned constants)
|
.literal16
.balign 16
<name>:
<init>
.quad 0
|
表 13-9: 格式化静态初始化器
| 静态初始化器 | 输出 |
|---|---|
| DoubleInit(d) | .double |
表 13-10: 格式化汇编指令
| 汇编指令 | 输出 |
|---|---|
| Cvtsi2sd(t, src, dst) |
cvtsi2sd<t> <src>, <dst>
|
| Cvttsd2si(t, src, dst) |
|---|
cvttsd2si<t> <src>, <dst>
|
| Binary(Xor, Double, src, dst) |
|---|
xorpd <src>, <dst>
|
| Binary(Mult, Double, src, dst) |
|---|
mulsd <src>, <dst>
|
| Cmp(Double, operand, operand) |
|---|
comisd <operand>, <operand>
|
表 13-11: 汇编操作符的指令名称
| 汇编操作符 | 指令名称 |
|---|---|
| Shr | shr |
| DivDouble | div |
| And | and |
| Or | or |
表 13-12: 汇编类型的指令后缀
| 汇编类型 | 指令后缀 |
|---|---|
| Double | sd |
表 13-13: 格式化汇编操作数
| 汇编操作数 | 输出 |
|---|---|
| Reg(XMM0) | %xmm0 |
| Reg(XMM1) | %xmm1 |
| Reg(XMM2) | %xmm2 |
| Reg(XMM3) | %xmm3 |
| Reg(XMM4) | %xmm4 |
| Reg(XMM5) | %xmm5 |
| Reg(XMM6) | %xmm6 |
| Reg(XMM7) | %xmm7 |
| Reg(XMM14) | %xmm14 |
| Reg(XMM15) | %xmm15 |
请注意,表 13-8 没有为常量添加本地标签前缀,尽管您可以选择包含这些前缀,正如我们之前讨论的那样。还需要注意的是,表 13-10 中的xorpd、comisd和mulsd指令需要特别处理。作为一个打包指令,xorpd不使用标准的sd后缀,而comisd和mulsd指令的名称与它们的整数对等物不同。
完成这些更改后,您就可以测试整个编译器了。
额外加分:NaN
您可以添加对安静 NaN 的支持,作为额外的加分功能。算术操作应该可以正常工作,而不需要额外的努力,因为 SSE 指令会适当地传播 NaN。您也不需要处理类型转换,因为从 NaN 到整数的转换是未定义的。唯一需要担心的操作是比较。
当您将任何值与 NaN 进行比较时,结果是无序的。如果x是 NaN,那么x > y、x < y和x == y都为假。NaN 甚至与自己比较时也是不相等的。comisd指令通过将三个标志设置为 1 来指示无序的结果:ZF、CF 和 PF,即奇偶标志。正如已经遇到的基于 ZF、CF 和其他状态标志的条件代码一样,P条件代码依赖于奇偶标志。例如,jp指令只有在 PF 为 1 时才会跳转。您需要使用此条件代码来正确处理浮点比较中的 NaN。
使用--nan标志来包含 NaN 的测试用例,当您运行测试套件时:
$ **./test_compiler** `**/path/to/your_compiler**` **--chapter 13 --nan**
或者使用--extra-credit标志来启用所有的加分测试,和往常一样。
总结
你的编译器现在支持浮点数了!在这一章中,你学习了如何在汇编中定义浮点常量,如何使用 SSE 指令,以及如何根据 System V 调用约定传递浮点参数。你还处理了编译器中的四舍五入误差,从解析器到代码生成。最重要的是,你看到浮点运算是多么难以正确实现。许多程序员大致知道浮点运算可能不精确;而编写编译器迫使你准确理解它是如何出错的。在下一章中,你将添加一个完全不同的类型:指针。你将处理棘手的解析问题,扩展类型检查器,并向 TACKY 和汇编 AST 中添加一些非常有用的构造。
附加资源
这些是我在编写本章时依赖的资源,按照它们与章节的相关性进行大致分类。我还包括了一些我认为特别有用的在线浮点可视化工具。
IEEE 754
-
IEEE 754 标准可以在 IEEE 官网上购买,价格为 100 美元(
<wbr>ieeexplore<wbr>.ieee<wbr>.org<wbr>/document<wbr>/8766229)。但你可能可以通过以下免费的资源获得所需的答案:-
Wikipedia 上的《双精度浮点格式》文章详细描述了 IEEE 754 双精度值的二进制编码(
<wbr>en<wbr>.wikipedia<wbr>.org<wbr>/wiki<wbr>/Double<wbr>-precision<wbr>_floating<wbr>-point<wbr>_format)。 -
David Goldberg 的《每个计算机科学家都应该知道的浮点运算》是浮点数学的最著名入门书籍之一,虽然可能不是最易读的(
<wbr>docs<wbr>.oracle<wbr>.com<wbr>/cd<wbr>/E19957<wbr>-01<wbr>/806<wbr>-3568<wbr>/ncg<wbr>_goldberg<wbr>.html)。我发现“IEEE 标准”部分中关于 IEEE 754 格式的讨论尤其有用。文章还涉及了一些我完全忽略的重要主题,例如异常和错误处理。 -
Michael Borgwardt 创建的《浮点指南》网站以通俗易懂的方式介绍了如何使用 IEEE 754 浮点数(
<wbr>floating<wbr>-point<wbr>-gui<wbr>.de)。如果前两篇文章内容过于密集,可以从这里开始。
-
-
要了解更多关于 GCC 和 Clang 中对 IEEE 754 标准的支持,请查看以下资源:
-
GCC wiki 上的“GCC 中浮点数学的语义”总结了 GCC 中浮点支持的现状,描述了默认的浮点行为,并讨论了完全符合 IEEE 754 标准的一些挑战 (
<wbr>gcc<wbr>.gnu<wbr>.org<wbr>/wiki<wbr>/FloatingPointMath). -
Clang 编译器用户手册中的“控制浮点行为”章节讨论了 Clang 中的 IEEE 754 合规性 (
<wbr>clang<wbr>.llvm<wbr>.org<wbr>/docs<wbr>/UsersManual<wbr>.html#controlling<wbr>-floating<wbr>-point<wbr>-behavior).
-
“四舍五入行为”的参考资料见 第 299 页
- Rick Regan 撰写的博客文章《二进制浮点数的间隔》讨论了连续浮点数之间的间隔 (
<wbr>www<wbr>.exploringbinary<wbr>.com<wbr>/the<wbr>-spacing<wbr>-of<wbr>-binary<wbr>-floating<wbr>-point<wbr>-numbers<wbr>/)。我发现,专注于数字线中的间隔是理解浮点四舍五入误差的关键。在阅读这篇博客后,关于这一话题的其他讨论突然变得更加清晰。
“汇编中的浮点运算”参考资料见 第 310 页
-
有关 System V 调用约定的详细信息,请参见 System V x64 ABI (
<wbr>gitlab<wbr>.com<wbr>/x86<wbr>-psABIs<wbr>/x86<wbr>-64<wbr>-ABI). -
有关单个 SSE 指令的详细信息,包括它们如何处理溢出和四舍五入,请参见英特尔 64 位软件开发者手册 (
<wbr>www<wbr>.intel<wbr>.com<wbr>/content<wbr>/www<wbr>/us<wbr>/en<wbr>/developer<wbr>/articles<wbr>/technical<wbr>/intel<wbr>-sdm<wbr>.html). -
Bruce Dawson 撰写的博客文章《有时浮点数学是完美的》概述了浮点计算不会产生四舍五入误差的情况 (
<wbr>randomascii<wbr>.wordpress<wbr>.com<wbr>/2017<wbr>/06<wbr>/19<wbr>/sometimes<wbr>-floating<wbr>-point<wbr>-math<wbr>-is<wbr>-perfect<wbr>/)。这帮助我理清了为什么我们从 double 转换为 unsigned long 的汇编代码不会有四舍五入误差。 -
Pascal Cuoq 在 Stack Overflow 上写了一篇关于从
unsigned long到double的汇编级转换的精彩回答 (stackoverflow.com/a/26799227). 这是我找到的关于这种转换的最佳解释。 -
“GCC 避免双重舍入错误,采用四舍五入到奇数”的另一篇 Rick Regan 的文章,提供了关于双重舍入错误的更多背景信息 (
www.exploringbinary.com/gcc-avoids-double-rounding-errors-with-round-to-odd/).
关于“代码生成”的参考资料,请见第 338 页
我参考了 Rick Regan 在 Exploring Binary 上的两篇博客文章,处理代码生成过程中的浮点常量:
-
“十六进制浮点常量”讲述了如何通过以十六进制表示浮点数来避免舍入误差 (
www.exploringbinary.com/hexadecimal-floating-point-constants/). -
“往返转换所需的位数”解释了为什么 17 位十进制数字足以表示浮点常量 (
www.exploringbinary.com/number-of-digits-required-for-round-trip-conversions/).
浮点数可视化工具
这些工具让你可以实验 IEEE 754 标准下的十进制数字表示:
-
由 Rick Regan 创建的十进制到浮点数转换器,允许你将十进制数转换为最接近的可表示
double,并以多种格式显示,包括原始二进制、十六进制浮点数和二进制科学记数法 (www.exploringbinary.com/floating-point-converter/). -
由 Bartosz Ciechanowski 创建的 Float Exposed,允许你查看和编辑
double的符号、指数和有效数字字段,以及其原始二进制表示 (float.exposed).

描述
第十四章:14 指针

到目前为止,你只实现了算术类型。这些类型有很多共同之处;它们都支持相同的基本数学运算,而且你总是可以从一种类型隐式转换为另一种类型。在第二部分的其余部分,我们将添加几种非算术类型,如指针、数组和结构体。这些类型与算术类型以及彼此之间差别很大。它们不支持普通的算术运算。相反,每种类型支持自己独特的一组操作。
在本章中,你将实现指针类型,它们表示内存地址。你还将添加两个用于操作指针的新运算符:地址运算符 & 和解引用运算符 *。你将学习如何解析复杂的类型说明符,并如何检测几种新的类型错误。在 TACKY 和汇编生成过程中,你将添加一些新结构来读取和写入内存中的位置。随着后续章节中更多非算术类型的加入,你将继续在这些更改的基础上构建。
首先,让我们讨论一些在本章中会提到的关键概念:对象、值和左值转换。
对象与值
对象和值在之前的章节中已经提到过,但我从未精确定义过这两个术语,也没有解释它们之间的区别。你可以将值理解为带有类型的位序列。例如,位
11111111111111111111111111111111
类型为 int 的变量,其值为 -1。到目前为止,我们只遇到过整数值和浮点值。
对象是内存中的一个位置,存储着一个值。到目前为止,我们只见过变量是对象。从程序员的角度来看,每个对象都有一个内存地址,这个地址在其生命周期内是固定的,并且有一个值,你可以通过赋值表达式更新它。(在实践中,有些对象可能存储在寄存器中而非内存中,并且你不能更新每个对象的值,但我们暂时可以忽略这些例外情况。)
在 第五章中,我将 lvalue 描述为可以出现在赋值表达式左侧的表达式。现在我们可以使用 C 标准第 6.3.2.1 节,第 1 段中更精确的定义:“lvalue 是一个… 可能表示一个对象的表达式。”(请注意,尽管名为 lvalue,它并不是一个值;它是一个表达式。)求值一个非 lvalue 表达式会产生一个值。而求值一个 lvalue 则“确定指定对象的身份”,根据标准第 5.1.2.3 节第 2 段的定义。如果一个表达式表示一个对象,你可以对它进行赋值。否则,你不能。
当你在像 x + 1 这样的表达式中使用一个对象时,你实际上是在使用它的当前值。但是当你给一个对象赋值时,你并不关心它的当前值,因为你只是要覆盖它;你关心的是它的位置,也就是你要写入的地方。换句话说,如果 x 是一个 int 类型的变量,你有时把它当作一个 int 类型的值来处理,有时又把它当作一个可以存储 int 类型值的容器来处理。C 标准将第一种情况,即在表达式中使用对象的值,称为 lvalue 转换。这是一种“转换”,因为你将一个表示对象的 lvalue 转换为一个普通的值。如果一个 lvalue 出现在赋值表达式的左操作数位置,或作为 & 运算符的操作数,它不会经过 lvalue 转换。如果它出现在表达式的其他任何地方,它会经过 lvalue 转换。例如,x 在表达式 x = 3 和 y = &x 中是一个 lvalue,但在表达式 foo(x)、x == y 和 a = x 中,它不是一个 lvalue。在后续章节中,我们会遇到其他没有经过 lvalue 转换的表达式。
这个术语让我们能够在讨论指针时不至于感到困惑。现在我们可以准确地讨论指针支持哪些操作。
指针操作
在本节中,我将介绍地址运算符 &,它用于获取指向对象的指针,以及解引用运算符 *,通过指针访问对象。我还会讨论指针的类型转换和比较,以及涉及 & 运算符的一个特殊情况。至于指针的加法和减法,我暂时不会讨论;我们将在下一章实现这些操作。
地址和解引用操作
为了了解 & 和 * 操作是如何工作的,我们来逐步分析 Listing 14-1 中的程序。我们将特别关注程序中哪些表达式表示对象,哪些表达式结果是值。
int main(void) {
int x = 0;
int *ptr = &x;
*ptr = 4;
return *ptr;
}
Listing 14-1:一个简单的程序,使用 & 和 * 操作
我们从声明一个变量开始,x。由于 x 是一个对象,它有一个地址,尽管每次运行程序时这个地址都不会相同。假设在运行 Listing 14-1 时,x 最终位于内存地址 0x7ffeee67b938。它的值也是 0。由于 x 的类型是 int,我们也将其值解释为 int 类型。
接下来,我们声明变量ptr,它也是一个对象。ptr的类型是int *,即“指向int的指针”,表示一个类型为int对象的地址。像x一样,ptr也有一个地址;假设它是0x7ffeee67b940。它还有一个值:表达式&x的结果。&操作符获取其操作数的地址,这意味着其操作数必须指代一个有地址的对象。换句话说,操作数必须是左值。然而,&操作符的结果不是一个对象,而是一个指针类型的值。
在表达式&x中,操作数是左值x。计算&x的结果是值0x7ffeee67b938,这是x的地址。我们将此值赋给变量ptr,就像我们可以将任何兼容类型的值赋给一个变量一样。为了帮助我们理清思路,图 14-1 显示了此时程序栈的内容。

图 14-1:在清单 14-1 描述中声明的对象的地址和初始值
如图所示,0x7ffeee67b938既是x的地址,也是ptr的值。我之前说过,值是具有类型的比特序列;值0x7ffeee67b938的类型是int *,因为它是类型为int对象的地址。
在清单 14-1 的下一行,我们有赋值表达式 ptr = 4,该表达式由多个子表达式组成。在右边,我们有常量 4;在左边,我们有变量 ptr,它本身是解引用表达式 ptr 的一部分。常量并不特别有趣,但另外两个子表达式很有意思。这些表达式中最内层的,ptr,表示一个类型为 int * 的对象。我们没有给它赋值或取它的地址;我们只是读取它的值。因此,我们隐式地将其转换为左值,结果是类型为 int * 的值,即 0x7ffeee67b938。我们在解引用表达式 *ptr 中使用了这个值。解引用表达式是一个左值,因此其结果是一个对象。在这个例子中,它是地址为 0x7ffeee67b938 的对象,因为这是被解引用的值。由于我们对对象 *ptr 进行赋值,而不是使用它的值,它不需要进行左值转换。图 14-2 显示了此语句执行后堆栈的内容。

图 14-2:通过解引用指针赋值后的堆栈内容 描述
我们在最后的 return 语句中再次解引用 ptr。同样,*ptr 的结果是地址为 0x7ffeee67b938 的对象。然而,这次我们并没有对该对象进行赋值,也没有对其应用 & 操作符。因此,我们进行了左值转换,结果是该对象当前的值,即 4。
现在你已经理解了 * 和 & 如何作用于对象和数值,接下来让我们讨论指针类型之间的转换。
空指针与类型转换
一个值为 0 的整数常量表达式,称为空指针常量,可以隐式转换为任何指针类型。这种转换的结果是一个空指针:
int *null = 0;
因为空指针不是一个有效的内存地址,所以解引用空指针的结果是未定义的。实际上,解引用空指针很可能会导致程序崩溃。C 标准允许像(long) 0和10 - 10这样的常量表达式作为空指针常量,但我们只支持像0和0ul这样的常量字面值。(这与我们在第十章中对静态初始化器施加的限制相同。)
除了空指针常量外,隐式地将整数转换为指针或将指针转换为整数是非法的。考虑以下代码片段:
int x = 0;
int *ptr = x;
因为x的类型是int,所以将其赋值给类型为ptr(类型为int *)是非法的。出于同样的原因,将非零常量赋值给指针也是非法的:
int *ptr1 = 3;
int *ptr2 = 0x7ffeee67b938;
这些对ptr1和ptr2的声明都是非法的,因为3和0x7ffeee67b938是整数,而不是指针。请注意,表达式的类型与其值是否是有效的内存地址无关。即使0x7ffeee67b938恰好是一个有效的地址,常量表达式0x7ffeee67b938仍然是一个long,而不是一个指针。
将一种指针类型隐式转换为另一种指针类型也是非法的(void *的转换除外,我将在第十七章中介绍)。例如,你不能将double *隐式转换为long *:
double *d = 0;
long *l = d;
GCC 会对前三个代码片段中的隐式转换发出警告,但仍然会编译它们。我们将采取更严格的做法,将这些隐式转换视为错误。
另一方面,显式地在指针类型之间,或者在指针类型和整数类型之间进行转换是合法的。清单 14-2 展示了一个从double *到unsigned long *的显式转换示例。
double negative_zero = -0.0;
double *d = &negative_zero;
❶ unsigned long *l = (unsigned long *) d;
列表 14-2:显式指针类型转换
在显式转换和赋值❶之后,d和l包含相同的内存地址,但被解释为两种不同的指针类型。
一个重要的警告是,在进行此类型转换后,如果取消引用l,会导致未定义的行为。除了少数例外情况,如果我们声明一个具有某种类型的对象(称为其有效类型),然后使用不同类型的表达式访问它,结果是未定义的。换句话说,从一种指针类型转换到另一种指针类型总是合法的,但使用该类型转换的结果可能不合法。在列表 14-2 中,negative_zero的有效类型是double,因此我们不能使用表达式l来访问它,因为该表达式的类型是unsigned long。关于可以用来访问对象的表达式类型的完整规则——非正式地称为严格别名规则*——在 C 标准的第 6.5 节第 6 至 7 段中有详细说明。幸运的是,由于我们不需要检测未定义的行为或优雅地处理它,因此可以忽略这些规则;我们的实现将愉快地编译违反这些规则的程序。
最后,你可以显式地在指针类型和整数类型之间进行转换。当你将一个空指针常量转换为指针类型时,结果是一个空指针。当你将任何其他整数转换为指针类型,或将任何指针转换为整数类型时,结果是实现定义的。在 x64 系统上,内存地址是无符号 64 位整数,类似于0x7ffeee67b938。因此,如果你将一个unsigned long转换为指针(或反之),其值不会改变。将任何其他整数类型转换为指针类型,或将指针类型转换为整数类型的效果与转换为或从unsigned long的效果相同。例如,如果你将一个带有值-1的有符号int或long转换为指针类型,它将导致表示的最大内存地址0xffffffffffffffff。这个地址不太可能包含有效的对象,因此取消引用它很可能会导致未定义的行为。
将指针类型强制转换为 double 或将 double 强制转换为指针类型是非法的。
指针比较
你可以使用 == 和 != 操作符比较相同类型的指针。两个非空指针相等时,它们指向同一个对象(或者正好指向同一个数组的末尾,等我们实现数组后)。否则它们不相等。指向有效对象的指针总是与空指针不相等,而两个空指针总是相等。你还可以在任何比较表达式是否为零的结构中使用指针,包括逻辑 !、&& 和 || 表达式;条件表达式中的条件;以及 if 语句或循环中的控制条件。在这些情况下,空指针算作零,任何非空指针算作非零。
你还可以使用其他关系运算符(如 >)比较指针,但我们暂时不支持这类操作。这种类型的指针比较在处理指向数组元素的指针时最为有用,因此我们将在下一章添加数组时实现它。
& 解引用指针的操作
我们之前看到过,& 操作符的操作数必须是一个左值。由于解引用指针是一个左值,你可以使用这个操作符获取它的地址,就像我们在 示例 14-3 中做的那样。
int *ptr = &var;
int *ptr2 = &*ptr;
示例 14-3:获取解引用指针的地址
表达式 &*ptr 是有效的,但它并不太有用。内部表达式指定了存储在某个地址的对象,而外部表达式获取该对象的地址。最终你得到的是 ptr 的值,即你最初解引用的地址。
实际上,C 标准将 &
int *null_ptr = 0;
int *ptr2 = &*null_ptr;
列表 14-4:获取反引用空指针的地址
解引用 null_ptr 通常会导致运行时错误。然而,由于列表 14-4 中的 & 和 * 表达式并没有被求值,所以这段代码的等效形式是:
int *null_ptr = 0;
int *ptr2 = null_ptr;
因此,列表 14-4 可以顺利运行,没有错误;它将 null_ptr 和 ptr2 初始化为空指针。
既然我们已经掌握了指针语义,让我们开始处理词法分析器(lexer)吧!
词法分析器
在这一章,你将添加一个单独的令牌:
& 一个和号,地址运算符
你已经添加了 * 令牌来支持乘法。如果你在第三章实现了位运算符的附加功能,那么你也已经添加了 & 令牌,因此你不需要修改词法分析器。
解析器
接下来,我们将向抽象语法树(AST)添加指针类型和两个新的指针运算符。指针类型是通过递归构造的,来自它所指向对象的类型;例如,int *、double * 和 unsigned long * 都是有效的类型。你还可以声明指向指针的指针,因此 int **、long *** 等也是有效的类型。因此,AST 通过递归定义指针类型:
type = Int | Long | UInt | ULong | Double
| FunType(type* params, type ret)
**| Pointer(type referenced)**
在 C 语言中,由更简单类型构建的类型称为派生类型。指针类型和函数类型都是派生类型。我们将在后续章节中实现的数组类型和结构类型也是派生类型。指针所指向的类型称为引用类型。例如,int * 的引用类型是 int。
我们将扩展 exp AST 节点,以表示解引用和取地址运算符:
exp = `--snip--`
| Dereference(exp)
| AddrOf(exp)
从语法上讲,这两个运算符都是一元运算符,因此你可以扩展 unary _operator,而不是 exp,如果你想这样做。但是我认为将它们作为不同的表达式处理会更容易,因为我们在类型检查和 TACKY 生成过程中将以不同的方式处理这些运算符。列表 14-5 显示了更新后的 AST,本章的新增部分已加粗。
program = Program(declaration*)
declaration = FunDecl(function_declaration) | VarDecl(variable_declaration)
variable_declaration = (identifier name, exp? init, type var_type, storage_class?)
function_declaration = (identifier name, identifier* params, block? body,
type fun_type, storage_class?)
type = Int | Long | UInt | ULong | Double
| FunType(type* params, type ret)
**| Pointer(type referenced)**
storage_class = Static | Extern
block_item = S(statement) | D(declaration)
block = Block(block_item*)
for_init = InitDecl(variable_declaration) | InitExp(exp?)
statement = Return(exp)
| Expression(exp)
| If(exp condition, statement then, statement? else)
| Compound(block)
| Break
| Continue
| While(exp condition, statement body)
| DoWhile(statement body, exp condition)
| For(for_init init, exp? condition, exp? post, statement body)
| Null
exp = Constant(const)
| Var(identifier)
| Cast(type target_type, exp)
| Unary(unary_operator, exp)
| Binary(binary_operator, exp, exp)
| Assignment(exp, exp)
| Conditional(exp condition, exp, exp)
| FunctionCall(identifier, exp* args)
**| Dereference(exp)**
**| AddrOf(exp)**
unary_operator = Complement | Negate | Not
binary_operator = Add | Subtract | Multiply | Divide | Remainder | And | Or
| Equal | NotEqual | LessThan | LessOrEqual
| GreaterThan | GreaterOrEqual
const = ConstInt(int) | ConstLong(int)
| ConstUInt(int) | ConstULong(int)
| ConstDouble(double)
列表 14-5:包含指针类型以及解引用和取地址运算符的抽象语法树
接下来,我们将更新语法并找出如何解析它。我们可以像解析其他一元运算符一样解析 * 和 & 运算符,因此我们将它们添加到
<unop> ::= "-" | "~" | "!" **| "*" | "&"**
在声明和强制类型转换表达式中解析指针类型更具挑战性。我们需要一种方法来扩展它,以处理一般的派生类型,而不仅仅是指针;否则,我们将不得不完全重写它,以应对下一章中的数组。我们将从更新解析器开始,以支持声明中的派生类型。然后,我们将处理强制类型转换表达式中的派生类型。
解析声明
一个函数或变量声明由三部分组成:一组说明符、一个声明符和一个可选的初始化器或函数体。你已经知道初始化器和函数体是什么样子,所以我这里不再讨论它们。说明符是之前章节中已经熟悉的内容:它们包括像 static 这样的存储类说明符,用于确定标识符的存储类和链接性,和像 int 这样的类型说明符,用于确定我所说的 基本类型。基本类型是标识符的类型,或者是派生其类型的起始点。(这个术语在 C 标准中并没有出现,但它有时出现在 C 声明的其他讨论中。)声明符 是其他所有内容:它表示正在声明的标识符,以及我们将应用于基本类型的派生序列。例如,var、*var、foo(int a) 和 foo[3] 都是声明符。
最简单的声明符是标识符:
int var;
在这里,基本类型是 int,声明符是 var,因此它声明了一个名为 var 的变量,其类型为 int。这个声明没有包含任何类型派生。
要派生一个新类型,我们将像 var 这样的声明符嵌套在另一个声明符中:
int *(var);
这里我们有一个指针声明符 *(var),它包含嵌套声明符 var。指针声明符采用某种类型 t,并派生出“指向 t 的指针”类型,因此这个声明声明了一个名为 var 的变量,类型为“指向 int 的指针”。请注意,C 的语法允许我们将任何声明符包裹在括号中。我将 var 包裹在括号中,以使嵌套关系变得显式,但如果我们省略括号,声明的意义是一样的:
int *var;
我们使用多层嵌套声明符来指定多个类型派生;这些派生从外到内应用,以确定最终类型。最内层的声明符始终是一个普通的标识符。这里是一个包含三个嵌套声明符的例子:
int *(*(var));
完整的声明符是 ((var)),它包含 *(var),而 *(var) 又包含 var。就像前面的例子一样,这个声明符中的括号没有任何作用;我只是为了清晰起见才加上的。
让我们来逐步解析类型推导。从外到内,我们首先看到基本类型 int。接下来,我们看到一个指针声明符,因此我们推导出类型“指向 int 的指针”。然后,我们看到另一个指针声明符,所以我们推导出“指向指向 int 的指针”。最后,我们遇到标识符,这完成了声明,但没有增加任何类型信息。最终我们得到一个变量 var,其类型是“指向指向 int 的指针”。
另外两种声明符是 函数声明符(我们已经支持)和 数组声明符(我们将在下一章添加)。函数声明符接受一个类型 t,并推导出类型“返回 t 的函数”。让我们拆解一个函数声明:
int foo(void);
这里的完整声明符是 foo(void),它包含嵌套声明符 foo。为每个声明符加上括号,得到以下等效声明:
int ((foo)(void));
我们从基本类型 int 开始。外部声明符告诉我们推导出类型“返回 int 的函数”,而内部声明符则表明我们正在声明标识符 foo。当然,函数声明符还声明了函数的参数。每个参数就像一个声明一样,包含基本类型和声明符:
int foo(int a, int *b);
正如我们已经知道的,形式为 (void) 的参数列表是一种特殊情况:它声明函数没有参数。
最后,数组声明符以类型 t 开始,并推导出类型“类型为 t 的 n 个元素的数组”。例如,以下代码包含声明符 arr[3],它有一个嵌套的声明符 arr:
int arr[3];
这声明了 arr 是一个包含三个元素的 int 类型数组。
更复杂的声明可以包括嵌套指针、数组和函数声明符的混合。我们用后缀表达式表示的函数和数组声明符优先级高于指针声明符,因此
int *arr[3];
等价于:
int *(arr[3]);
为了解释这个声明,我们从 int 开始,应用外部指针声明符得出“指向 int 的指针”,应用内部数组声明符得出“一个包含三个指向 int 的指针的数组”,最后得出最内层声明符 arr。如果要声明一个指向数组的指针,我们可以用括号覆盖这一优先级:
int (*arr)[3];
同样,这个声明声明了一个指向具有单一参数的函数的指针:
int (*fptr)(int a);
函数指针在 C 中是合法的,但在本书中我们不会实现它们。你还可以嵌套声明符来指定完全非法的类型。例如,int foo(void)(void); 声明了一个返回函数的函数,而该返回的函数又返回一个 int。这个声明在语法上是正确的,但在语义上无效;一个函数不能返回另一个函数。
现在你已经理解了声明符的基本语法,我们准备为它们编写语法规则。有关声明符的完整描述,请参见 C 标准的第 6.7.6 节。我还推荐 Steve Friedl 的《阅读 C 类型声明》,它以比标准更易理解的方式描述了声明符的语法(* <wbr>unixwiz<wbr>.net<wbr>/techtips<wbr>/reading<wbr>-cdecl<wbr>.html *)。
由于声明符有多个优先级层次,我们需要多个语法规则来定义它们的语法。在最高优先级层次,
<simple-declarator> ::= <identifier> | "(" <declarator> ")"
在下一个优先级层次,我们有 C 语法中称为直接声明符的部分,包括函数和数组声明符。本章仅支持函数声明符:
<direct-declarator> ::= <simple-declarator> [<param-list>]
<param-list> ::= "(" "void" ")" | "(" <param> {"," <param>} ")"
<param> ::= {<type-specifier>}+ <declarator>
在
<declarator> ::= "*" <declarator> | <direct-declarator>
不幸的是,这个语法实际上与我们的抽象语法树(AST)定义不完全匹配。一个小问题是,它允许我们指定一些不支持的类型,包括函数指针、返回函数的函数,以及将其他函数作为参数的函数。更严重的问题是,在我们的语法中,类型推导是从外到内应用的,而在 AST 定义中,它们是从内到外应用的。让我们重新审视一下之前看过的一个声明:
int (*arr)[3];
我们想解析这个声明并构造类型“指向类型为int的三元素数组的指针”。如果我们尝试使用递归下降解析来构造这个类型,会发生什么呢?首先,我们会遇到基本类型int。然后,我们会看到一个左括号,表示直接声明符的开始。在该直接声明符内,我们会看到一个指针声明符——然后我们就会卡住。我们应该推导出指针类型,但指向什么呢?到目前为止我们看到的基本类型是int,但“指向int的指针”是不正确的。由于类型推导是从外到内应用的,我们应该首先推导出数组类型。但我们无法做到,因为解析器必须先消费内层的带括号的声明符,才能到达指定数组类型的[3]。
我们在这里卡住了,因为我们能够识别语法符号的顺序与应用类型推导的顺序不匹配。当我们解析一个声明时,不能一边解析一边推导其类型。相反,我们首先会将每个声明符解析为一种临时表示形式,这种表示形式更接近语法,如清单 14-6 中的那样。
declarator = Ident(identifier)
| PointerDeclarator(declarator)
| FunDeclarator(param_info* params, declarator)
param_info = Param(type, declarator)
清单 14-6:表示声明符的语法
我们可以使用标准的递归下降解析法生成一个声明符构造,遵循我们刚刚介绍的语法规则。
下一步是遍历该声明符,并推导出所有我们将用于构造 AST 节点的信息:声明的类型、标识符以及任何参数的标识符。在每一层,我们都会应用适当的类型推导,然后递归地处理内层声明符。清单 14-7 展示了此步骤的伪代码。
process_declarator(declarator, base_type):
match declarator with
| Ident(name) -> return (name, base_type, []) ❶
| PointerDeclarator(d) -> ❷
derived_type = Pointer(base_type)
return process_declarator(d, derived_type)
| FunDeclarator(params, d) -> ❸
match d with
| Ident(name) -> ❹
param_names = []
param_types = []
for Param(p_base_type, p_declarator) in params: ❺
param_name, param_t, _ = process_declarator(p_declarator, p_base_type)
if param_t is a function type:
fail("Function pointers in parameters aren't supported")
param_names.append(param_name)
param_types.append(param_t)
derived_type = FunType(param_types, base_type)
return (name, derived_type, param_names)
| _ -> fail("Can't apply additional type derivations to a function type")
清单 14-7:从声明符中推导类型和标识符信息 声明符
process_declarator函数接受两个参数。第一个是声明符本身。第二个base_type是我们迄今为止推导出的类型。最初,这将是声明开始时由说明符列表指示的基本类型。例如,如果我们正在处理声明double **fun(int x),我们将从base_type为double开始。process_declarator的结果将是一个包含三个值的元组:声明的标识符、推导出的类型以及任何参数的名称。如果声明声明的是变量,或者声明的是没有参数的函数,则参数名称列表将为空。使用这三个值,我们将能够构造一个声明 AST 节点。
让我们逐步了解如何从声明符构造中提取这些值。在最简单的情况下,声明符是一个标识符 ❶。我们不需要应用任何类型推导或引入任何参数,因此我们返回标识符、不变的base_type,以及一个空的参数名列表。例如,在处理声明int x;时,我们会立即遇到这种情况,并返回("x", Int, [])。
在第二种情况下,我们处理指针声明符 ❷。在这种情况下,我们从base_type推导出指针类型。然后,我们递归地调用process_declarator,对刚刚推导出的类型和仍需要处理的内层声明符进行处理。
在最后一种情况下,我们处理函数声明符❸。这一情况有些不同,因为内部声明符必须是一个普通标识符。如果它是另一个函数声明符,我们将得到一个返回函数的函数,这是不合法的。如果它是一个指针声明符,我们将得到一个函数指针,但我们并没有实现函数指针。因此,我们验证内部声明符是一个普通标识符,而不是递归解析它❹。
假设内部声明符是有效的,下一步是确定函数类型和参数名称。我们将遍历声明符中的参数,递归调用process_declarator来获取每个参数的类型和名称❺。同时,我们会验证这些函数参数中没有是函数本身的情况。(C 标准实际上允许你声明函数类型的参数,但它要求编译器隐式地将其调整为函数指针类型。由于我们不支持函数指针,因此会拒绝这种情况。)一旦处理完所有参数,我们将构建完整的函数类型,并返回关于此声明的所有相关信息。
清单 14-8 展示了如何将所有部分组合在一起解析整个声明。
parse_declaration(tokens):
specifiers = parse_specifier_list(tokens)
base_type, storage_class = parse_type_and_storage_class(specifiers)
declarator = parse_declarator(tokens)
name, decl_type, params = process_declarator(declarator, base_type)
if decl_type is a function type:
`<construct function_declaration>`
else:
`<construct variable_declaration>`
清单 14-8:解析整个声明
我们首先按照常规方式确定声明的基本类型:我们从tokens中消耗一个说明符列表,然后将这些说明符转换为类型和存储类别。接下来,我们解析声明符,然后调用process_declarator来确定其完整类型和名称。最后,我们检查结果类型以确定它是函数声明还是变量声明,并相应地解析声明的其余部分。
解析类型名称
指针类型也可以出现在强制类型转换表达式中:
int *result_of_cast = (int *) exp;
但是,你不能在强制类型转换表达式中使用声明符,因为声明符必须包含一个标识符。C 语言通过抽象声明符解决了这个问题,抽象声明符是没有标识符的声明符。我们现在将添加抽象指针声明符,并在下一章添加抽象数组声明符。(我们不需要抽象函数声明符,因为它们只用于指定函数指针。)
一个抽象声明符可能是一个或多个*符号的序列,表示指针类型派生的序列:
(int ***) exp;
抽象声明符也可以像它们的非抽象对应物一样被括起来:
(int (*)) exp;
一个外部抽象声明符可以包含一个内部括号声明符:
(int *(*)) exp;
括号此时并没有实际意义。当我们在下一章添加数组时,它们将变得更有用。例如,表达式
(int *[3]) exp;
将exp转换为指向三个指针的数组,指向int,因为抽象数组声明符[3]的优先级更高。这个类型转换表达式是非法的,因为你不能将表达式转换为数组类型。另一方面,这个表达式是合法的:
(int (*)[3]) exp;
这个将exp转换为指向三个int元素数组的指针;括号中的指针声明符优先级更高,因此数组声明符首先应用于int。
我们使用两条语法规则来定义抽象声明符:
<abstract-declarator> ::= "*" [<abstract-declarator>]
| <direct-abstract-declarator>
<direct-abstract-declarator> ::= "(" <abstract-declarator> ")"
一个
一个
abstract_declarator = AbstractPointer(abstract_declarator)
| AbstractBase
Listing 14-9: 表示抽象声明符的语法
AbstractBase表示基本情况,其中标记后面没有跟随内部声明符。例如,我们会将抽象声明符(*)解析为AbstractPointer(AbstractPointer(AbstractBase))。目前,abstract_declarator仅告诉我们找到了多少层指针间接引用(在这个例子中是两层)。这是一种相当复杂的方式来传达一个数字,但它为下一章的数组声明符打下了基础。
类型名称在类型转换表达式中是由类型说明符序列组成,后面跟一个可选的抽象声明符,所有内容都被括号括起来:
<factor> ::= `--snip--`
| "(" {<type-specifier>}+ **[<abstract-declarator>]** ")" <factor>
| `--snip--`
要处理强制转换表达式,你需要一个与示例 14-7 中的process_declarator类似的process_abstract_declarator函数,用于将基本类型和abstract_declarator转换为派生类型。这个函数比process_declarator要简单,它不会处理函数声明符,而且只会返回类型,而不是标识符或参数列表。
将所有内容整合在一起
我们已经覆盖了对解析器所做的所有更改。示例 14-10 显示了完整的语法,新增的内容已加粗。
<program> ::= {<declaration>}
<declaration> ::= <variable-declaration> | <function-declaration>
<variable-declaration> ::= {<specifier>}+ **<declarator>** ["=" <exp>] ";"
<function-declaration> ::= {<specifier>}+ **<declarator>** (<block> | ";")
**<declarator> ::= "*" <declarator> | <direct-declarator>**
**<direct-declarator> ::= <simple-declarator> [<param-list>]**
**<param-list> ::= "(" "void" ")" | "(" <param> {"," <param>} ")"**
**<param> ::= {<type-specifier>}+ <declarator>**
**<simple-declarator> ::= <identifier> | "(" <declarator> ")"**
<type-specifier> ::= "int" | "long" | "unsigned" | "signed" | "double"
<specifier> ::= <type-specifier> | "static" | "extern"
<block> ::= "{" {<block-item>} "}"
<block-item> ::= <statement> | <declaration>
<for-init> ::= <variable-declaration> | [<exp>] ";"
<statement> ::= "return" <exp> ";"
| <exp> ";"
| "if" "(" <exp> ")" <statement> ["else" <statement>]
| <block>
| "break" ";"
| "continue" ";"
| "while" "(" <exp> ")" <statement>
| "do" <statement> "while" "(" <exp> ")" ";"
| "for" "(" <for-init> [<exp>] ";" [<exp>] ")" <statement>
| ";"
<exp> ::= <factor> | <exp> <binop> <exp> | <exp> "?" <exp> ":" <exp>
<factor> ::= <const> | <identifier>
| "(" {<type-specifier>}+ **[<abstract-declarator>]** ")" <factor>
| <unop> <factor> | "(" <exp> ")"
| <identifier> "(" [<argument-list>] ")"
<argument-list> ::= <exp> {"," <exp>}
**<abstract-declarator> ::= "*" [<abstract-declarator>]**
**| <direct-abstract-declarator>**
**<direct-abstract-declarator> ::= "(" <abstract-declarator> ")"**
<unop> ::= "-" | "~" | "!" **| "*" | "&"**
<binop> ::= "-" | "+" | "*" | "/" | "%" | "&&" | "||"
| "==" | "!=" | "<" | "<=" | ">" | ">=" | "="
<const> ::= <int> | <long> | <uint> | <ulong> | <double>
<identifier> ::= ? An identifier token ?
<int> ::= ? An int token ?
<long> ::= ? An int or long token ?
<uint> ::= ? An unsigned int token ?
<ulong> ::= ? An unsigned int or unsigned long token ?
<double> ::= ? A floating-point constant token ?
示例 14-10:带指针类型和解引用及取地址操作符的语法
我们对语法做了三处重大更改。首先,我们在函数、变量和参数声明中使用< samp class="SANS_TheSansMonoCd_W5Regular_11">
语义分析
接下来是语义分析!我们将扩展类型检查器,以验证涉及指针的表达式并推断这些表达式的类型。标识符解析阶段也会有所变化;我们将把一部分验证从这个阶段移到类型检查器中。
我们需要检测三种类型错误:
-
对不支持的类型应用操作符。例如,你不能对指针进行乘法或除法操作,也不能对算术值进行解引用。
-
对两种不兼容类型的值进行操作。这包括像尝试将指针与< s>double进行比较这样的错误。我们遇到这种错误的原因是,C 语言通常不允许像对待算术类型那样对指针类型进行隐式转换。
-
在需要左值的地方未使用左值。我们已经要求赋值表达式的左边必须是左值。现在,我们还要求< s>AddrOf表达式的操作数也必须是左值。我们还将扩展左值的定义,包含解引用指针和变量。
这种第三类错误是我们目前在标识符解析过程中处理的错误。现在从标识符解析过程中移除这种验证;稍后你会将其添加到类型检查器中。(在此期间,确保标识符解析过程遍历新的Dereference 和 AddrOf 表达式。)接下来,我们将更新逻辑以进行表达式的类型检查。
类型检查指针表达式
我们需要调整几乎所有我们支持的表达式的类型检查方法。让我们从新的 Dereference 和 AddrOf 表达式开始。然后,我们将更新现有构造的类型检查逻辑。
解引用与 AddrOf 表达式
一个 Dereference 表达式必须接受一个指针类型的操作数。它会产生一个具有其操作数引用类型(即它所指向的类型)的结果。清单 14-11 演示了如何检查一个 Dereference 表达式的类型,并用正确的结果类型进行标注。
typecheck_exp(e, symbols):
match e with
| `--snip--`
| Dereference(inner) ->
typed_inner = typecheck_exp(inner, symbols)
match get_type(typed_inner) with
| Pointer(referenced_t) ->
deref_exp = Dereference(typed_inner)
return set_type(deref_exp, referenced_t)
| _ -> fail("Cannot dereference non-pointer")
清单 14-11:类型检查一个 Dereference 表达式
我们首先像往常一样进行表达式操作数的类型检查。然后,我们查找操作数的类型。如果它是指向某种类型的指针,referenced_t,我们将<сamp class="SANS_TheSansMonoCd_W5Regular_11">referenced_t设置为整个表达式的结果类型。否则,我们抛出一个错误。
要检查一个 AddrOf 表达式的类型,我们首先检查它的操作数是否是左值(也就是说,它是一个 Var 或 Dereference 表达式)。然后,我们记录它的结果类型,即指向其操作数类型的指针。清单 14-12 演示了如何检查 AddrOf 的类型。
| AddrOf(inner) ->
if inner is an lvalue:
typed_inner = typecheck_exp(inner, symbols)
referenced_t = get_type(typed_inner)
addr_exp = AddrOf(typed_inner)
return set_type(addr_exp, Pointer(referenced_t))
else:
fail("Can't take the address of a non-lvalue!")
清单 14-12:类型检查一个 AddrOf 表达式
接下来,我们将使用Equal和NotEqual进行指针比较类型检查。(我们将在第十五章中处理使用GreaterThan、LessThan以及其他关系运算符的指针比较。)我们还将处理条件表达式,它们遵循类似的类型规则。
比较和条件表达式
如你在前面的章节中所学,比较中的两个操作数必须具有相同的类型,或者至少可以隐式转换为相同的类型。然而,我们不能对指针类型进行隐式转换。因此,如果某个操作数是指针类型,则需要两个操作数的类型相同。目前,空指针常量是此规则的唯一例外;它们是唯一可以隐式转换为指针类型的表达式。(一旦我们实现了void,我们也将允许在void *和其他指针类型之间进行隐式转换。)
例如,以下代码片段将指针与空指针常量进行比较:
double *d = get_pointer();
return d == 0;
当我们在本例中进行类型检查d == 0时,我们将0隐式转换为类型为double *的空指针。清单 14-13 定义了一个辅助函数,用于识别空指针常量。
is_null_pointer_constant(e):
match e with
| Constant(c) ->
match c with
| ConstInt(0) -> return True
| ConstUInt(0) -> return True
| ConstLong(0) -> return True
| ConstULong(0) -> return True
| _ -> return False
| _ -> return False
清单 14-13:检查一个表达式是否为空指针常量
该函数捕获了我们对表达式作为空指针常量的三个要求:它必须是常量文字,它必须是整数,并且它的值必须为 0。(请记住,我们定义空指针常量的标准比 C 标准更严格;C 标准允许更复杂的常量表达式以及常量文字。)
清单 14-14 定义了另一个辅助函数,用于确定两个表达式(其中至少一个是指针)是否具有兼容的类型。
get_common_pointer_type(e1, e2):
e1_t = get_type(e1)
e2_t = get_type(e2)
if e1_t == e2_t:
return e1_t
else if is_null_pointer_constant(e1):
return e2_t
else if is_null_pointer_constant(e2):
return e1_t
else:
fail("Expressions have incompatible types")
清单 14-14:获取两个表达式的共同类型,其中至少一个具有指针类型
当一个操作指针的表达式期望两个操作数具有相同的类型时,get_common_pointer_type会确定该类型应该是什么。如果它的参数类型不同,并且都不是空指针常量,它们就不兼容,因此会抛出错误。
现在我们已经定义了get_common_pointer_type,终于可以对Equal和NotEqual表达式进行类型检查了。列表 14-15 展示了如何对Equal表达式进行类型检查;我们会以相同的方式处理NotEqual。
typecheck_exp(e, symbols):
match e with
| `--snip--`
| Binary(Equal, e1, e2) ->
typed_e1 = typecheck_exp(e1, symbols)
typed_e2 = typecheck_exp(e2, symbols)
t1 = get_type(typed_e1)
t2 = get_type(typed_e2)
if t1 or t2 is a pointer type:
❶ common_type = get_common_pointer_type(typed_e1, typed_e2)
else:
❷ common_type = get_common_type(t1, t2)
converted_e1 = convert_to(typed_e1, common_type)
converted_e2 = convert_to(typed_e2, common_type)
equality_exp = Binary(Equal, converted_e1, converted_e2)
return set_type(equality_exp, Int)
列表 14-15:对Equal表达式进行类型检查
这遵循了类型检查比较的常见模式:我们对两个操作数进行类型检查,找到它们的共同类型,将它们都转换为该类型,然后将结果的类型设置为Int。与之前章节的关键变化是我们如何找到共同类型。如果任一操作数是指针,我们使用刚才定义的帮助函数❶。否则,我们将继续使用get_common_type❷。
当我们将两个操作数转换为通用指针类型时,我们将看到三种可能的结果之一:
-
两个操作数已经具有相同的类型,因此convert_to调用不会产生任何效果。
-
一个操作数是空指针常量,我们会将其隐式转换为另一个操作数的类型。
-
操作数具有不兼容的类型,因此get_common_pointer_type会抛出错误。
我们将使用类似的逻辑来进行条件表达式的类型检查。在表达式
赋值与仿佛通过赋值进行的类型转换
接下来,我们将处理赋值表达式。我们首先验证赋值表达式左侧是否是左值。然后,我们将表达式右侧的值转换为左侧对象的类型,如果转换不合法则失败。C 标准所称的“仿佛通过赋值进行的类型转换”在多个地方出现,不仅限于赋值表达式,因此我们将编写另一个辅助函数来处理它。Listing 14-16 定义了这个辅助函数。
convert_by_assignment(e, target_type):
if get_type(e) == target_type:
return e
if get_type(e) is arithmetic and target_type is arithmetic:
return convert_to(e, target_type)
if is_null_pointer_constant(e) and target_type is a pointer type:
return convert_to(e, target_type)
else:
fail("Cannot convert type for assignment")
Listing 14-16: 将表达式转换为目标类型,仿佛是通过赋值操作
这里的规则并不令人惊讶:我们可以将值赋给相同类型的对象,我们可以隐式地将任何算术类型转换为任何其他算术类型,并且我们可以隐式地将空指针常量转换为任何指针类型。否则,我们将引发错误。
我们将使用这个辅助函数来转换赋值表达式的右侧值,也会在其他一些地方使用它。为了进行函数调用的类型检查,我们将使用 convert_by_assignment 将每个参数转换为相应参数的类型。我们还将用它将变量初始化器转换为正确的类型,并检测无效类型的初始化器,例如以下内容:
int *d = 2.0;
最后,我们将使用 convert_by_assignment 将 return 语句中的值转换为函数的返回类型,并检测返回错误类型的函数,如 Listing 14-17 所示。
int *bad_pointer(void) {
return 2.0;
}
Listing 14-17: 返回不兼容类型值的函数
稍后,当我们实现 void 时,我们将扩展 get_common_pointer_type 和 convert_by_assignment,以接受隐式转换到 void * 和从 void * 的转换。
其他表达式
我们仍然需要处理类型转换表达式、一元运算符和二元运算符,除了 Equal 和 NotEqual。让我们从类型转换开始。如你之前所学,指针不能转换为 double 类型,或者 double 不能转换为指针。如果类型检查器遇到这种类型转换,它应该抛出一个错误。否则,它可以像处理其他类型的转换表达式一样处理指针类型的转换。
接下来,我们将处理一元运算符。将 Negate 或 Complement 运算符应用于指针是非法的,因为对内存地址取反或按位取反不会产生有意义的结果。而将 Not 运算符应用于指针是可以的,因为将内存地址与零进行比较是有意义的。
二元运算符以几种不同的方式处理指针。首先,我们有布尔运算符 And 和 Or。这些运算符的类型检查逻辑不会改变。像 Not 一样,它们都接受指针。由于它们不会将操作数转换为共同类型,因此可以对指针和算术操作数的任意组合进行操作。
另一方面,算术 Multiply、Divide 和 Remainder 运算符不接受指针。将这些运算符应用于指针类型的操作数应该产生一个错误。而指针加法和减法是合法的,指针比较也可以使用 GreaterThan、LessThan、GreaterOrEqual 和 LessOrEqual,但我们将在下一章实现这些功能。它们在本章的测试中不会出现。现在,你的编译器可以假设它永远不会遇到这些表达式,或者明确拒绝它们。
在符号表中跟踪静态指针初始化器
现在让我们谈谈静态初始化器。像非静态变量一样,指针类型的静态变量可以初始化为空指针:
static int *ptr = 0;
因此,我们需要一种方法将空指针表示为符号表中的 static_init。我们将使用 ULongInit(0) 初始化器,而不是定义一个专门用于空指针的构造,因为指针是无符号 64 位整数。
使用其他静态变量的地址来初始化指针类型的静态变量也是合法的:
static int a;
static int *a_ptr = &a;
然而,我们的实现不支持这种类型的静态初始化器;我们已经决定,只有常量字面量才是我们接受的静态初始化器。
TACKY 生成
本章将介绍三条新的 TACKY 指令,这些指令作用于指针。第一条,GetAddress,对应 AST 中的 AddrOf 操作符:
GetAddress(val src, val dst)
该指令将 src 的地址(必须是一个变量,而不是常量)复制到 dst。我们还将添加两条指令来解引用指针:
Load(val src_ptr, val dst)
Store(val src, val dst_ptr)
Load 指令将一个内存地址 src_ptr 作为源操作数。它从该内存地址获取当前值,并将其复制到 dst。Store 指令将一个内存地址 dst_ptr 作为目标操作数,并将 src 的值写入该地址。示例 14-18 定义了完整的 TACKY IR,三条新指令已加粗。
program = Program(top_level*)
top_level = Function(identifier, bool global, identifier* params, instruction* body)
| StaticVariable(identifier, bool global, type t, static_init init)
instruction = Return(val)
| SignExtend(val src, val dst)
| Truncate(val src, val dst)
| ZeroExtend(val src, val dst)
| DoubleToInt(val src, val dst)
| DoubleToUInt(val src, val dst)
| IntToDouble(val src, val dst)
| UIntToDouble(val src, val dst)
| Unary(unary_operator, val src, val dst)
| Binary(binary_operator, val src1, val src2, val dst)
| Copy(val src, val dst)
**| GetAddress(val src, val dst)**
**| Load(val src_ptr, val dst)**
**| Store(val src, val dst_ptr)**
| Jump(identifier target)
| JumpIfZero(val condition, identifier target)
| JumpIfNotZero(val condition, identifier target)
| Label(identifier)
| FunCall(identifier fun_name, val* args, val dst)
val = Constant(const) | Var(identifier)
unary_operator = Complement | Negate | Not
binary_operator = Add | Subtract | Multiply | Divide | Remainder | Equal | NotEqual
| LessThan | LessOrEqual | GreaterThan | GreaterOrEqual
示例 14-18:向 TACKY 添加指针操作
将Dereference和AddrOf转换为 TACKY 是很棘手的,因为这些转换依赖于上下文。Dereference表达式可以有三种用法:你可以将其转换为左值、对其进行赋值或取其地址。我们将在这三种情况下生成不同的 TACKY 指令。同样地,当AddrOf的操作数是变量时,我们会以一种方式处理它,而如果操作数是解除引用的指针,则以另一种方式处理它。首先,让我们看看在每种情况下应生成哪些指令。然后,我将提出一种 TACKY 转换策略,以尽量减少我们需要处理的特殊情况。
TACKY 中的指针操作
要解除引用一个指针并对结果进行左值转换,我们将使用Load指令。在这种情况下,我们可以将表达式*
`<instructions for exp>`
ptr = `<result of exp>`
result = Load(ptr)
Listing 14-19:TACKY 实现指针解除引用并进行左值转换
当我们想要对解除引用的指针进行赋值,而不是将其转换为左值时,我们会使用Store。我们将把形如*
`<instructions for left>`
ptr = `<result of left>`
`<instructions for right>`
result = `<result of right>`
Store(result, ptr)
Listing 14-20:TACKY 实现赋值表达式左侧的指针解除引用
我们首先计算ptr,即某个对象的地址,以及result,即我们想要赋值给该对象的值。然后,我们使用Store来执行赋值操作。请注意,这里唯一的Store指令实现了原始表达式中的解除引用和赋值操作。
最后,让我们考虑AddrOf表达式。如果它的操作数是变量,我们将使用GetAddress获取指向它的指针。因此,我们将把&var翻译为:
result = GetAddress(var)
但如果操作数是解引用指针,外部的AddrOf和内部的Dereference表达式都不会被求值。当我们看到形式为&*
TACKY 转换策略
为了管理所有这些不同的情况,我们将使用两个不同的函数将表达式转换为 TACKY。第一个是我们现有的emit_tacky函数。这个函数不再返回一个 TACKY 操作数。相反,它将返回一个新的构造体exp_result,它表示一个尚未进行左值转换的表达式结果。第二个函数emit_tacky_and_convert将调用emit_tacky,对结果进行左值转换(如果它是一个左值而非常量),并将其作为 TACKY 操作数返回。在大多数情况下,我们将使用emit_tacky_and_convert来处理表达式。但对于那些不应该进行左值转换的表达式——例如赋值表达式的左侧——我们将直接调用emit_tacky。
首先,让我们定义exp_result:
exp_result = PlainOperand(val) | DereferencedPointer(val)
DereferencedPointer表示通过解引用指针指定的对象,顾名思义。它接受一个参数:一个 TACKY 指针类型的操作数。PlainOperand表示一个普通的常量或变量。它的参数是任何类型的 TACKY 操作数。exp_result构造体本身不是一个 TACKY 操作数,因此它不会出现在 TACKY 指令中。它只是帮助我们处理AddrOf和赋值表达式,这些表达式操作的是对象而非值。对于这些表达式,我们将根据其操作数是解引用的指针还是普通变量,生成不同的指令。在后面的章节中,我们将添加更多解引用指针的操作符,比如数组下标和->操作符来访问结构体成员。到那时,DereferencedPointer构造器将特别有用,因为它将帮助我们以统一的方式表示所有这些不同操作符的结果。
现在,让我们更新 emit_tacky。我们将在整个函数中进行一些更改。首先,在我们当前对子表达式递归调用 emit_tacky 的地方——除非是在赋值表达式的左侧——我们将改为调用 emit_tacky_and_convert。该函数将把子表达式转换为 TACKY,并对结果进行左值转换。其次,在我们当前返回一个 TACKY 操作数的地方,我们将把这个操作数包装在一个 PlainOperand 构造函数中。列表 14-21 展示了如何处理一元表达式,并在本章中标出了更改的部分。
emit_tacky(e, instructions, symbols):
match e with
| `--snip--`
| Unary(op, inner) ->
src = **emit_tacky_and_convert**(inner, instructions, symbols)
dst = make_tacky_variable(get_type(e), symbols)
tacky_op = convert_unop(op)
instructions.append(Unary(tacky_op, src, dst))
return **PlainOperand**(dst)
列表 14-21:将一元表达式翻译为 TACKY
我们将对 emit_tacky 当前处理的每一种表达式类型进行相同的修改。
接下来,让我们处理 解引用 表达式。列表 14-22 演示了如何在 emit_tacky 中处理这些。
| Dereference(inner) ->
result = emit_tacky_and_convert(inner, instructions, symbols)
return DereferencedPointer(result)
列表 14-22:将 解引用 表达式翻译为 TACKY
为了处理这个表达式,我们首先处理并进行左值转换它的操作数。这会生成一个指针类型的 TACKY 操作数,result。然后,我们返回一个 DereferencedPointer 来表示 result 所指向的对象。
在 emit_tacky 返回一个 exp_result 后,我们要么对其进行赋值,要么获取它的地址,或者进行左值转换。列表 14-23 展示了如何处理赋值。
| Assignment(left, right) ->
❶ lval = emit_tacky(left, instructions, symbols)
❷ rval = emit_tacky_and_convert(right, instructions, symbols)
match lval with
| PlainOperand(obj) ->
❸ instructions.append(Copy(rval, obj))
return lval
| DereferencedPointer(ptr) ->
❹ instructions.append(Store(rval, ptr))
return PlainOperand(rval)
列表 14-23:将赋值表达式翻译为 TACKY
我们不会对赋值表达式的左侧进行左值转换❶,但会对右侧进行左值转换❷。如果左侧是一个 PlainOperand,我们会像之前章节中那样发出 Copy 指令❸。如果它是一个 DereferencedPointer,我们会发出 Store 指令,将数据写入内部指针指向的位置❹。请注意,即使我们通过指针进行赋值,返回的结果仍然是 PlainOperand。这是因为赋值表达式的结果是存储在左侧对象中的值,而不是对象本身。
我们使用类似的模式来处理 AddrOf。 列表 14-24 给出了伪代码。
| AddrOf(inner) ->
❶ v = emit_tacky(inner, instructions, symbols)
match v with
| PlainOperand(obj) ->
dst = make_tacky_variable(get_type(e), symbols)
❷ instructions.append(GetAddress(obj, dst))
return PlainOperand(dst)
| DereferencedPointer(ptr) ->
❸ return PlainOperand(ptr)
列表 14-24:将 AddrOf 表达式翻译成 TACKY
我们首先处理表达式的操作数,但不进行左值转换❶,然后根据结果进行模式匹配,以决定如何继续。如果是一个普通值,我们发出 GetAddress 指令❷。如果是一个解引用指针,我们就丢弃解引用,返回指针❸。
最后,在 列表 14-25 中,我们定义了 emit_tacky_and_convert,它执行左值转换。
emit_tacky_and_convert(e, instructions, symbols):
result = emit_tacky(e, instructions, symbols)
match result with
| PlainOperand(val) -> return val
| DereferencedPointer(ptr) ->
dst = make_tacky_variable(get_type(e), symbols)
instructions.append(Load(ptr, dst))
return dst
列表 14-25:将表达式翻译成 TACKY 并执行左值转换
要对解引用指针进行左值转换,我们将使用 Load 指令来检索其值。其他操作数可以原样返回,无需发出额外的指令。一个 完整表达式,如果它不是另一个表达式的一部分,总是会进行左值转换。这意味着你应该使用 emit_tacky_and_convert,而不是 emit_tacky,来处理完整表达式并获取其结果。例如,你将在循环和 if 语句中使用 emit_tacky_and_convert 来处理控制表达式。
一些完整表达式的结果——特别是for循环头部的表达式语句以及第一个和第三个子句——并未被使用。作为优化,你可以使用emit_tacky来处理这些表达式,这样可以节省掉不必要的Load指令。
为了总结这一节,我们将实现指针类型之间的类型转换。对于类型转换的目的,我们将指针类型视为与unsigned long完全相同。例如,我们通过SignExtend指令将int转换为任何指针类型,通过Truncate指令将指针类型转换为int。其他表达式的 TACKY 实现,如逻辑运算和比较操作,将保持不变。
汇编生成
在上一节中,我们添加了Load和Store指令,允许我们从内存中读取和写入数据。这意味着 TACKY 终于赶上了汇编,正如我们从第二章开始一直在使用汇编读取和写入内存一样。例如,操作数-4(%rbp)标识内存中的一个位置,我们可以使用mov指令从该位置读取或写入数据。
但是 RBP 并没有什么特别的;我们可以通过存储在任何寄存器中的地址来访问内存。以下是如何从存储在 RAX 寄存器中的地址读取值并将其复制到 RCX:
movq (%rax), %rcx
请注意,(%rax)等同于0(%rax)。
汇编的抽象语法树(AST)将略有变化,以处理像(%rax)这样的操作数。首先,我们将 RBP 寄存器添加到抽象语法树中:
reg = AX | CX | DX | DI | SI | R8 | R9 | R10 | R11 | SP | **BP** | `--snip--`
然后,我们将替换Stack操作数,该操作数允许我们访问 RBP 中某个偏移地址的内存,换成一个更通用的Memory操作数,该操作数可以使用存储在任何寄存器中的基地址:
operand = Imm(int) | Reg(reg) | Pseudo(identifier) | **Memory(reg, int)** | Data(identifier)
这使得将Load或Store指令转换为汇编变得非常简单。我们将翻译
Load(ptr, dst)
转换为:
Mov(Quadword, ptr, Reg(AX))
Mov(`<dst type>`, Memory(AX, 0), dst)
在第一条指令中,我们将内存地址 ptr 移动到寄存器中。在第二条指令中,我们将该地址处存储的值(通过 Memory 操作数访问)移动到目标位置。在这个例子中,我们将 ptr 复制到 RAX,但任何通用寄存器都可以(除了 R10、R11 或被调用者保存的寄存器)。
按照同样的思路,我们将翻译
Store(src, ptr)
转到:
Mov(Quadword, ptr, Reg(AX))
Mov(`<src type>`, src, Memory(AX, 0))
请注意,当我们将指针复制到寄存器时,我们使用 Quadword 操作数类型,因为指针占 8 个字节。但是,当我们将值从指针所指示的内存位置复制到或复制到该内存位置时,值的类型决定了 mov 指令的类型。
我们将使用一种新的汇编指令:lea(即加载有效地址)来实现 GetAddress。指令 lea src, dst 将源操作数(必须是内存操作数)的地址复制到目标。举例来说,lea (%rbp), %rax 相当于 mov %rbp, %rax。你还可以使用 lea 来获取 RIP 相对地址,因此
lea x(%rip), %rax
将符号 x 的地址存储在 RAX 寄存器中。
使用这个新指令,将 GetAddress 转换为汇编非常简单。我们将翻译
GetAddress(src, dst)
转到:
Lea(src, dst)
如我之前提到的,这里 src 必须是内存操作数,而不是常量或寄存器,原因显而易见。目前,我们能够确保满足这一约束;我们将每个伪寄存器映射到一个内存地址,并且类型检查器会捕捉到任何试图取常量地址的操作。但是在第三部分中,当我们实现寄存器分配时,我们将把一些变量存储在寄存器中,而不是内存中。到那时,我们需要额外的工作来确保 lea 不会尝试加载寄存器的地址。
列表 14-26 定义了整个汇编抽象语法树(AST),包括新的 Memory 操作数、BP 寄存器和 Lea 指令。
program = Program(top_level*)
assembly_type = Longword | Quadword | Double
top_level = Function(identifier name, bool global, instruction* instructions)
| StaticVariable(identifier name, bool global, int alignment, static_init init)
| StaticConstant(identifier name, int alignment, static_init init)
instruction = Mov(assembly_type, operand src, operand dst)
| Movsx(operand src, operand dst)
| MovZeroExtend(operand src, operand dst)
**| Lea(operand src, operand dst)**
| Cvttsd2si(assembly_type dst_type, operand src, operand dst)
| Cvtsi2sd(assembly_type src_type, operand src, operand dst)
| Unary(unary_operator, assembly_type, operand)
| Binary(binary_operator, assembly_type, operand, operand)
| Cmp(assembly_type, operand, operand)
| Idiv(assembly_type, operand)
| Div(assembly_type, operand)
| Cdq(assembly_type)
| Jmp(identifier)
| JmpCC(cond_code, identifier)
| SetCC(cond_code, operand)
| Label(identifier)
| Push(operand)
| Call(identifier)
| Ret
unary_operator = Neg | Not | Shr
binary_operator = Add | Sub | Mult | DivDouble | And | Or | Xor
operand = Imm(int) | Reg(reg) | Pseudo(identifier) | **Memory(reg, int)** | Data(identifier)
cond_code = E | NE | G | GE | L | LE | A | AE | B | BE
reg = AX | CX | DX | DI | SI | R8 | R9 | R10 | R11 | SP | **BP**
| XMM0 | XMM1 | XMM2 | XMM3 | XMM4 | XMM5 | XMM6 | XMM7 | XMM14 | XMM15
清单 14-26:带有 Memory 操作数、 BP 寄存器和 Lea 指令的汇编 AST
当我们将其他 TACKY 指令转换为汇编时,我们将指针类型视为与 unsigned long 完全相同。我们将指针类型转换为 Quadword 汇编类型,使用 cmp 指令比较指针,将指针类型的返回值传递给 RAX 寄存器,并将指针类型的参数传递给与整数参数相同的一般用途寄存器。
我们还将进行一次完全机械性的修改:在我们之前使用过的操作数形式 Stack() 中,我们将改为使用 Memory(BP, )。 表 14-1 到 14-3 总结了本章关于 TACKY 到汇编的转换更新;像往常一样,新构造和现有构造的转换更改都以粗体显示。
表 14-1: 将顶层 TACKY 构造转换为汇编
| TACKY 顶层构造 | 汇编顶层构造 |
|---|
|
Function(name, global, params,
instructions)
|
Function(name, global,
[Mov(<first int param type>, Reg(DI),
<first int param>),
Mov(<second int param type>, Reg(SI),
<second int param>),
<copy next four integer parameters from registers>,
Mov(Double, Reg(XMM0), <first double param>),
Mov(Double, Reg(XMM1), <second double param>),
<copy next six double parameters from registers>,
Mov(<first stack param type>,
Memory(BP, 16)
, <first stack param>)
, Mov(<second stack param type>,
Memory(BP, 24),
<second stack param>),
<copy remaining parameters from stack>] +
instructions)
|
表 14-2: 将 TACKY 指令转换为汇编
| TACKY 指令 | 汇编指令 |
|---|---|
| Load(ptr, dst) |
Mov(Quadword, ptr, Reg(<R>))
Mov(<dst type>, Memory(<R>, 0), dst)
|
| Store(src, ptr) |
|---|
Mov(Quadword, ptr, Reg(<R>))
Mov(<src type>, src, Memory(<R>, 0))
|
| GetAddress(src, dst) |
|---|
Lea(src, dst)
|
表 14-3: 将类型转换为汇编
| 源类型 | 汇编类型 | 对齐方式 |
|---|---|---|
| Pointer(referenced_t) | Quadword | 8 |
接下来,我们将更新伪寄存器替换和指令修正的处理过程。
用内存操作数替换伪寄存器
我们将在整个处理过程中使用新的 Memory 操作数,取代旧的 Stack 操作数。我们还将扩展这个处理过程,替换 lea 指令中的伪寄存器。我们不会做其他更改。当我们将 TACKY 指针变量转换为伪寄存器时,我们为它们分配了 Quadword 汇编类型;现在,我们将像分配其他 Quadword 一样为它们分配栈空间。
修正 lea 和 push 指令
lea 指令的目标必须是一个寄存器;我们将按照常规方式重写它。我们还将为 push 指令添加一个新的重写规则。如我在上一章中提到的,不能对 XMM 寄存器执行 push 操作,因此我们将重写类似的指令。
pushq %xmm0
如下:
subq $8, %rsp
movsd %xmm0, (%rsp)
push 指令将栈指针减去 8 字节,然后将操作数移到栈顶。当我们无法使用 push 时,我们将在两条指令中执行相同的操作:先是 sub,然后是 mov。(%rsp) 操作数指定栈顶的内存位置。
因为我们不会生成任何作用于 XMM 寄存器的 push 指令,所以这个重写规则目前并非严格必要。一旦我们在第三部分实现寄存器分配,它将变得必要;那时,我们当前存储在内存中的值可能会被分配到 XMM 寄存器。
代码生成
我们的最终任务是更新代码生成阶段,以处理新的 Lea 指令、Memory 操作数和 BP 寄存器。表 14-4 和 14-5 总结了如何输出这些新结构。(我没有在这些表格中加粗新结构和变更,因为这三种结构完全是新的。)
表 14-4: 汇编指令格式化
| 汇编指令 | 输出 |
|---|---|
| Lea(src, dst) |
leaq <src>, <dst>
|
表 14-5: 汇编操作数格式化
| 汇编操作数 | 输出 |
|---|---|
| Reg(BP) | %rbp |
| Memory(reg, int) |
我们总是使用 8 字节别名来表示 Memory 操作数中的基址寄存器和 Lea 指令中的目标寄存器,因为内存地址是 8 字节整数。
当 Memory 操作数中的偏移量为零时,你可以选择打印它或省略它;(%rax) 和 0(%rax) 都是有效的。
总结
在本章中,你为编译器添加了对指针的支持。你学习了如何将复杂的声明符解析为派生类型,并如何进行指针操作的类型检查。在 TACKY 生成阶段,你建立了一种统一的方式来处理指针解引用表达式,无论它们如何使用。在后端,你调整了现有的 Stack 操作数,它访问相对于 RBP 地址的内存,并修改它以支持存储在任意寄存器中的地址。
在下一章,你将实现数组,这是你遇到的第一个非标量类型。你还将实现数组下标和指针运算,并探索它们之间的等价性。由于指针和数组关系密切,本章中介绍的概念、技术和指令将在下一章中也至关重要。

描述
第十五章:15 数组与指针运算

在本章中,你将实现数组类型。你还将添加程序员用来处理数组的主要语言特性:复合初始化器、下标运算符和指针运算。数组和指针是不同的但密切相关的类型。许多数组类型的表达式会隐式转换为指针,而许多指针操作(如下标操作)则是用来操作指向数组元素的指针。因此,为了支持数组,你需要在上章中添加的指针支持基础上进行构建。
类型检查器在这里发挥着特别关键的作用。它将处理从数组到指针的隐式转换,并用你将依赖的类型信息注释抽象语法树(AST),以便执行指针运算。一旦类型检查器完成了所有的繁重工作,在 TACKY 生成过程中,将相对容易地将下标运算符和复合初始化器拆解为简单的指针操作。你在本章中不会添加任何新的汇编指令,但你会引入新的操作数来表示内存中的对象和这些对象中的元素。
和往常一样,我们将从我们要添加的语言构造的概述开始。我们将特别关注指针与数组之间的关系,以及这种关系如何体现在指针运算和下标表达式中。这是 C 语言中一个特别令人困惑的方面,也是本章中所有内容的关键。
数组与指针运算
让我们先定义一些术语。在上一章中,我们将我们所知道的每种对象类型分为两类:算术类型和非算术类型。现在我们将引入另一个区分。标量类型表示一个单一的值。我们已经实现的指针和算术类型都是标量类型。聚合类型表示一组值。数组是聚合类型,结构体也是聚合类型(我们将在第十八章实现它)。数组中的所有值都具有相同的类型,这就是数组的元素类型。
数组声明与初始化器
当我们声明一个数组时,我们指定它的元素类型以及它包含多少个元素。例如,我们可以声明一个包含三个int对象的数组:
int int_array[3];
或者我们也可以声明一个包含五个指向double类型的指针的数组:
double *(ptr_array[5]);
(记住,我们通过从基本类型—在这个例子中是double—开始,然后从外到内应用类型推导来解释声明。)
这两个示例都使用标量元素类型,但我们也可以使用聚合元素类型。以下示例声明了一个包含三个元素的数组,其中每个元素本身是一个包含两个long对象的数组:
long nested_array[3][2];
这样的数组数组被称为多维数组。请注意,我们仍然从外到内应用类型推导来确定这个数组的类型。我们从基础类型long开始,应用由[2]指定的推导,得到类型为“两个long对象的数组”,然后应用由[3]指定的推导,得到类型为“三个包含两个long对象数组的数组”。
你可以使用复合初始化器初始化一个数组,指定每个元素的初始值:
int int_array[3] = {1, foo(), a * 4};
你也可以使用嵌套的复合初始化器初始化一个多维数组:
long nested_array[3][2] = {{a, a + 1}, {3l, -4}, {foo(), 6}};
在这里,三个嵌套的复合初始化器初始化外部数组的三个元素。每个元素本身是一个包含两个long对象的数组。每个这些元素的嵌套初始化器指定了两个算术值(这些值可以隐式转换为long)。
你也可以让一个数组不初始化。如果它具有自动存储持续时间,它的初始值将是未定义的。如果它具有静态存储持续时间,它将被初始化为全零。换句话说,我们将未初始化的数组视为与未初始化的标量对象相同。
数组的内存布局
到此为止,讨论一下平面数组和多维数组的内存布局会有所帮助。如果你声明一个包含n个对象的数组,这些n个对象将在内存中按顺序排列。考虑清单 15-1 中的数组,它具有标量元素类型。
int six_ints[6] = {1, 2, 3, 4, 5, 6};
清单 15-1:标量值的数组
图 15-1 展示了six_ints在初始化后可能在内存中的样子(此图中的内存地址仅供说明,实际系统中这些地址不一定有效)。

图 15-1:内存中 six_ints 的布局 描述
将此声明与列表 15-2 进行对比,后者声明了一个具有与six_ints相同数量和类型的标量元素的多维数组。
int three_arrays[3][2] = {{1, 2}, {3, 4}, {5, 6}};
列表 15-2:嵌套数组的数组
存储three_arrays的内存将像图 15-1 所示那样。在内存中,无法区分一个元素的结束和另一个元素的开始,因此这两个数组是不可区分的。尽管three_arrays的嵌套结构不会影响其在内存中的布局,但它会影响如何访问单个数组元素,正如我们接下来将看到的。
数组到指针衰退
一旦我们定义并初始化了一个数组,我们能对它做什么呢?实际上,能做的很少。事实上,数组类型的对象只有两种有效操作。首先,我们可以使用sizeof运算符获取数组的大小,我们将在第十七章中实现它。其次,我们可以使用&运算符获取它的地址:
int my_array[3] = {1, 2, 3};
int (*my_pointer)[3] = &my_array;
就这样!数组上没有其他有效的操作。这听起来可能很荒谬,因为 C 程序经常读取和写入数组元素。那么这里到底发生了什么呢?C 标准(第 6.3.2.1 节,第 3 段)提供了这个谜题的答案:“除非它是sizeof运算符的操作数,或者一元的&运算符的操作数……具有‘数组类型 type’的表达式会被转换为指向数组对象初始元素的‘指针类型 type’表达式,并且不是左值。”
这种从数组到指针的隐式转换被称为数组到指针衰退。(我有时会说数组衰退为指针,有时说它隐式地转换为指针,两者意思相同。)虽然我们不能对数组做太多操作,但可以对指针进行各种有用的操作。我们已经实现了一些这样的操作,并将在本章添加更多操作。
列表 15-3 中的代码片段展示了数组衰退的示例。
int my_array[3] = {1, 2, 3};
int *my_pointer = ❶ my_array;
return ❷ *my_pointer;
列表 15-3:将数组隐式转换为指针
假设my_array的起始地址是0x10。当my_array出现在赋值表达式的右侧时,它会被隐式转换为一个指针,类型是int *,值为0x10 ❶。然后我们可以将这个指针的值赋给my_pointer。当我们解引用my_pointer时,结果是存储在my_array前 4 个字节中的int对象 ❷。因此,我们将返回该对象的当前值1。注意,地址0x10的解释可以根据其类型有所不同。作为int ,它指向数组的第一个元素,我们可以通过指针解引用操作来读写它。表达式&my_array具有相同的值0x10,但它指向整个数组,其类型是int ()[3]。
在处理多维数组时,跟踪指针的类型尤为重要。请参考清单 15-4,它尝试给两个数组元素赋值。
int nested_array[2][2] = {{1, 2}, {3, 4}};
**nested_array = 10;
*nested_array = 0;
清单 15-4:数组元素的合法和非法赋值
第一个赋值表达式,将值赋给**nested_array,是有效的。首先,我们隐式地将变量nested_array转换为指向数组初始元素的指针。该元素的类型是int[2],因此指针的类型是int()[2]。对这个指针的第一次解引用操作会返回一个类型为int[2]的数组对象。我们隐式地将这个*数组转换为类型为int 的指针。因此,第二次指针解引用操作产生一个int对象,我们可以对其进行赋值。赋值表达式将该对象当前的值1覆盖为新值10。这个表达式中的int()[2]和int *指针都指向nested_array的起始位置;只是它们的类型不同。
下一个赋值表达式,将值赋给nested_array,是非法的。它的开始与之前相同:我们隐式地将nested_array转换为类型为int()[2]的指针,解引用它,并隐式地将结果转换为类型为int *的指针。接着,我们尝试直接赋值给这个指针,但 C 标准声明这个隐式转换的结果“不是一个左值”,因此我们不能赋值给它。即使允许这种赋值,也不清楚它会做什么;它就像是对&操作的结果进行赋值一样。
现在我们知道如何访问数组的初始元素,无论在哪个维度。我们甚至可以读写数组中的初始标量对象。然而,我们通常还希望访问数组的其他元素。为此,我们需要使用指针运算。
使用指针运算访问数组元素
一旦我们获得了指向数组初始元素的指针,我们就可以使用指针加法来生成指向数组其他元素的指针。让我们通过列表 15-5 中的例子来讲解。
int array[3] = {1, 2, 3};
int *ptr = array + 1;
列表 15-5:使用指针运算访问数组后续元素
我们将再次使用0x10作为数组的起始地址。在表达式array + 1中,变量array像往常一样变为指向初始数组元素的指针。当我们将1加到这个指针时,结果是指向数组中下一个int元素的指针。由于每个int是 4 个字节,我们需要将1乘以 4 的倍数来计算需要加到array地址的字节数。结果指针的值是0x14。如果我们解引用这个指针,我们将得到地址0x14处的int对象,它的当前值是2。数组元素是从零开始索引的,所以我们说数组的初始元素位于索引 0,下一元素位于索引 1。
更一般地,当我们将一个整数n加到一个指针时,结果是指向数组中位置为n个元素后移的另一个元素的指针。类似地,我们可以通过减去一个整数(或加上一个负整数)来在数组中向后移动。如果结果会超出数组的边界,行为是未定义的。
注意
如果 x 是一个n元素的数组,x + n 指向 x 的末尾之后的位置。这个指针是一个特例,它不被认为是越界的,你可以在指针运算中使用它。例如,你可以将它与指向同一数组中其他元素的指针进行比较。(当你遍历数组元素时,这是一种有用的方式来测试你是否已经到达数组的末尾。)但解引用它是未定义行为,因为它并不指向数组中的任何元素。
当我们进行指针运算时,我们所指向的数组的嵌套结构非常重要,因为它决定了什么算作单个元素。让我们看一下清单 15-6,看看这如何适用于我们在清单 15-1 和 15-2 中定义的两个数组,这两个数组在内存中具有相同的内容。
int six_ints[6] = {1, 2, 3, 4, 5, 6};
int three_arrays[3][2] = {{1, 2}, {3, 4}, {5, 6}};
❶ int *int_ptr = six_ints + 1;
❷ int (*array_ptr)[2] = three_arrays + 1;
清单 15-6:平面和嵌套数组的指针运算
表达式 six_ints + 1 的结果是一个指向 six_ints 中索引 1 位置的元素的指针 ❶。该元素是一个值为 2 的 int。类似地,当我们计算 three_arrays + 1 时,我们得到一个指向 three_arrays 中索引 1 位置的数组元素的指针 ❷。然而,在这种情况下,该元素本身是一个包含两个 int 对象的数组,其当前值为 3 和 4。尽管 six_ints 和 three_arrays 在内存中的内容可能相同,但对它们执行相同的操作会产生非常不同的结果。
那么我们如何访问 three_arrays 中的标量对象呢?例如,如何读取这个数组中的最后一个 int,其值为 6?首先,我们将获取指向 three_arrays 中最后一个元素的指针:
int (*outer_ptr)[2] = three_arrays + 2;
这将指向整个包含两个元素的数组 {5, 6}。我们将对其进行解引用,以获取指向该数组中单个标量元素的指针:
int *inner_ptr = *outer_ptr;
该解引用表达式会产生一个类型为 int[2] 的数组,它会衰减为一个类型为 int * 的指针。现在,inner_ptr 指向这个嵌套数组中的第一个 int,其值为 5。我们将递增它,使其指向下一个 int,其值为 6:
inner_ptr = inner_ptr + 1;
此时,我们可以通过正常的指针解引用访问它的值:
int result = *inner_ptr;
我们可以将这些语句合并为 Listing 15-7 中的单一表达式。
int result = *(*(three_arrays + 2) + 1);
Listing 15-7: 访问 three_arrays 中的最后一个 int
通过反复的指针加法、解引用和从数组到指针的隐式转换,我们可以访问多维数组中的任何元素。显然,这是一个巨大的痛苦。下标操作符,[],提供了更方便的语法来完成相同的操作。表达式 a[i] 等价于 *(a + i),因此我们可以将 清单 15-7 改写为 清单 15-8。
int result = three_arrays[2][1];
清单 15-8:更便捷的访问最后一个 int 在 three_arrays
我想强调的最后一点是,下标和指针算术适用于所有指针,而不仅仅是从数组衰退出来的指针。如果被指向的对象不在数组中,我们将把它当作一个一元素数组中的唯一元素来处理。例如,清单 15-9 是完全有效的。
int a = 5;
int *ptr = &a;
return ptr[0] == 5;
清单 15-9:为标量对象进行下标操作
当我们将 0 加到 ptr 并解引用结果时,我们得到对象 a。因此,表达式 ptr[0] == 5 评估为 1(即,真)。
更多指针算术
我们将支持对指针进行另外两个操作。第一个是减法;清单 15-10 给出了一个示例。
int arr[3] = {1, 2, 3};
int *ptr = arr + 2;
return ptr - arr;
清单 15-10:减去两个指针
当我们减去指向同一数组中两个元素的指针时,结果是它们索引之间的差值。在这个例子中,毫不意外地,我们返回 2。
我们还可以比较指向数组元素的指针,就像在 清单 15-11 中一样。
int arr[3] = {1, 2, 3};
int *ptr = arr + 2;
return ptr > arr;
清单 15-11:比较指针
指向较高数组索引元素的指针会比较大于指向较低索引元素的指针。在这个例子中,ptr 指向索引为 2 的元素,arr 会退化为指向索引为 0 的元素的指针,因此比较 ptr > arr 的结果为 1。如果两个指针不指向同一个数组,那么它们相减或比较的结果是未定义的。
函数声明中的数组类型
函数不能返回数组,如以下声明所示,这是不合法的:
int foo(void)[3];
函数也不能接受数组作为参数。奇怪的是,C 标准允许你声明一个带有数组参数的函数,但它要求编译器将你的函数签名调整为接受指针。例如,声明
int foo(int array_of_three_elements[3]);
将变为:
int foo(int *array_of_three_elements);
我们将调整带有数组类型的参数,使其在类型检查器中具有相应的指针类型。
我们不实现的功能
我们不会支持的功能足够重要,我会明确提到它们。我们不会实现可变长度数组,其长度在运行时决定,如下所示:
int variable_length_array[x];
我们只允许常量作为数组声明中的维度。我们也不允许声明不完整的数组类型:
int array[];
C 要求在定义数组时指定数组的维度,但在声明时不需要。然而,我们将要求在声明和定义中都指定数组维度。
我们不会实现复合字面量,它允许你在初始化器外构造数组对象(以及其他聚合对象):
int *p = (int []){2, 4};
最后,我们不会完全支持 C 的聚合对象初始化语义。复合初始化器有些自由,你可以省略花括号,将标量值包装在花括号中,或者初始化某些元素而不初始化其他元素。这使得很难弄清楚哪个表达式应该初始化哪个元素。我们将采取更严格的方法。首先,我们将要求每个嵌套数组的初始化器周围加上花括号。换句话说,我们将接受以下声明
int arr[2][2] = {{1, 2}, {3, 4}};
但是我们将拒绝以下等效的声明,尽管 C 标准允许它:
int arr[2][2] = {1, 2, 3, 4};
我们还将拒绝围绕标量初始化器的花括号,如下所示:
int i = {3};
我们不支持设计符号,它允许你以非顺序的方式初始化元素:
int arr[3] = {0, [2] = 1};
然而,我们将允许不初始化每个数组元素的复合初始化器,如下所示:
int arr[3] = {1, 2};
在这种情况下,我们将用零填充剩余的元素;这是 C 标准所要求的行为。现在我们已经明确了我们将构建和不会构建的内容,我们可以继续进行词法分析器的部分。
词法分析器
在这一章中,你将添加两个标记:
[ 一个左方括号
] 一个右方括号
添加这些标记后,你可以测试你的词法分析器。
解析器
接下来,我们将把数组类型、下标表达式和复合初始化器添加到抽象语法树(AST)中。数组的类型表示数组中元素的数量以及这些元素的类型:
type = `--snip--` | Array(type element, int size)
我们可以通过嵌套 Array 构造器来指定多维数组。例如,我们将表示声明的类型
int x[3][4];
如 Array(Array(Int, 4), 3)。由于我们不支持变长数组,每个数组类型必须具有常量大小。
一个下标表达式包含两个子表达式,一个指针和一个索引:
exp = `--snip--`
| Subscript(exp, exp)
令人惊讶的是,这两个子表达式的出现顺序并不重要;表达式 x[1] 和 1[x] 是等价的。
最后,我们将添加一个 initializer 构造,支持标量和复合变量初始化器:
initializer = SingleInit(exp) | CompoundInit(initializer*)
我们将使用 CompoundInit 来初始化数组,使用 SingleInit 来初始化标量对象,包括单个数组元素。对于多维数组的每一行,我们将使用嵌套的 CompoundInit 构造。 Listing 15-12 显示了如何表示初始化器 {{1, 2}, {3, 4}, {5, 6}}。
CompoundInit([
CompoundInit([SingleInit(Constant(ConstInt(1))),
SingleInit(Constant(ConstInt(2)))]),
CompoundInit([SingleInit(Constant(ConstInt(3))),
SingleInit(Constant(ConstInt(4)))]),
CompoundInit([SingleInit(Constant(ConstInt(5))),
SingleInit(Constant(ConstInt(6)))])
])
Listing 15-12: 表示来自 Listing 15-2 的 three_arrays 初始化器作为 AST 节点
类型检查器将用类型注释初始化器,就像它对 exp 节点所做的一样。无论你如何支持对 exp 节点的类型注释,你也应该对 initializer 做同样的处理。
Listing 15-13 给出了完整的 AST 定义,章节中的新增内容已加粗。
program = Program(declaration*)
declaration = FunDecl(function_declaration) | VarDecl(variable_declaration)
variable_declaration = (identifier name, **initializer? init,**
type var_type, storage_class?)
function_declaration = (identifier name, identifier* params, block? body,
type fun_type, storage_class?)
**initializer = SingleInit(exp) | CompoundInit(initializer*)**
type = Int | Long | UInt | ULong | Double
| FunType(type* params, type ret)
| Pointer(type referenced)
**| Array(type element, int size)**
storage_class = Static | Extern
block_item = S(statement) | D(declaration)
block = Block(block_item*)
for_init = InitDecl(variable_declaration) | InitExp(exp?)
statement = Return(exp)
| Expression(exp)
| If(exp condition, statement then, statement? else)
| Compound(block)
| Break
| Continue
| While(exp condition, statement body)
| DoWhile(statement body, exp condition)
| For(for_init init, exp? condition, exp? post, statement body)
| Null
exp = Constant(const)
| Var(identifier)
| Cast(type target_type, exp)
| Unary(unary_operator, exp)
| Binary(binary_operator, exp, exp)
| Assignment(exp, exp)
| Conditional(exp condition, exp, exp)
| FunctionCall(identifier, exp* args)
| Dereference(exp)
| AddrOf(exp)
**| Subscript(exp, exp)**
unary_operator = Complement | Negate | Not
binary_operator = Add | Subtract | Multiply | Divide | Remainder | And | Or
| Equal | NotEqual | LessThan | LessOrEqual
| GreaterThan | GreaterOrEqual
const = ConstInt(int) | ConstLong(int)
| ConstUInt(int) | ConstULong(int)
| ConstDouble(double)
Listing 15-13: 包含数组类型、复合初始化器和下标表达式的抽象语法树
让我们逐步解析如何将这些内容添加到 AST 中。
解析数组声明符
你在上一章学习了如何解析指针和函数声明符;现在我们将扩展该代码以处理数组声明符。列表 15-14 显示了如何扩展我们在列表 14-6 中定义的 declarator 构造。
declarator = Ident(identifier)
| PointerDeclarator(declarator)
**| ArrayDeclarator(declarator, int size)**
| FunDeclarator(param_info* params, declarator)
列表 15-14:表示数组声明符
接下来,我们将向语法中添加数组声明符。由于它们的优先级高于指针声明符,因此它们应当属于
<direct-declarator> ::= <simple-declarator> [<declarator-suffix>]
<declarator-suffix> ::= <param-list> | {"[" <const> "]"}+
直接声明符是一个简单的声明符,具有一个可选的后缀:要么是括起来的函数参数列表,要么是常量数组维度的序列,形式为 [const]。每个 ArrayDeclarator 仅指定一个数组维度,因此我们将解析带有多个维度的
ArrayDeclarator(ArrayDeclarator(Ident("array"), 1), 2)
最后,我们将更新 process_declarator,它将 declarator 构造转化为 AST 节点。列表 15-15 说明了如何在 process_declarator 中处理数组声明符。
process_declarator(declarator, base_type):
match declarator with
| `--snip--`
| ArrayDeclarator(inner, size) ->
derived_type = Array(base_type, size)
return process_declarator(inner, derived_type)
列表 15-15:应用数组类型推导
这个列表遵循我们在第十四章中介绍的相同模式,用于推导指针类型。
解析抽象数组声明符
接下来,让我们处理抽象声明符,它们指定类型而不声明标识符。我们将根据列表 15-16 中的语法规则解析抽象数组声明符。
<direct-abstract-declarator> ::= "(" <abstract-declarator> ")" {"[" <const> "]"}
| {"[" <const> "]"}+
列表 15-16:抽象数组声明符的语法规则
一个直接抽象声明符要么是一个括号包围的声明符,后面可选地跟随一系列数组维度,要么仅仅是一个数组维度的序列。(请记住,EBNF 语法中的 {} 表示零次或多次重复,而 {}+ 表示一次或多次重复。)我们将在这里采取与支持普通声明符时相同的步骤。列表 15-17 显示了如何扩展 abstract_declarator 构造。
abstract_declarator = AbstractPointer(abstract_declarator)
**| AbstractArray(abstract_declarator, int size)**
| AbstractBase
列表 15-17:表示抽象数组声明符
在更新了 abstract_declarator 之后,我们将修改解析代码以处理 列表 15-16 中的语法规则。(该代码应接受整数常量作为数组维度,并拒绝浮点常量,就像解析普通声明符的代码一样。)最后,我们将更新 process_abstract _declarator。
解析复合初始化器
现在让我们定义初始化器的语法规则:
<initializer> ::= <exp> | "{" <initializer> {"," <initializer>} [","] "}"
这个规则很简单:初始化器要么是一个表达式,要么是一个大括号包围的包含一个或多个嵌套初始化器的列表。注意,在初始化器列表的最后一个元素后可以有一个逗号:{1, 2, 3} 和 {1, 2, 3,} 都是有效的复合初始化器。
解析下标表达式
我们需要解析的最后一个新语言特性是下标操作符。下标是一种后缀操作符,它跟随在它修改的表达式之后。后缀操作符的优先级高于前缀操作符,如 &、- 或 ~。我们将拆分
<primary-exp> ::= <const> | <identifier> | "(" <exp> ")"
| <identifier> "(" [<argument-list>] ")"
然后,我们将定义后缀表达式作为基本表达式,后面可选地跟随一系列下标操作符:
<postfix-exp> ::= <primary-exp> {"[" <exp> "]"}
每个下标操作符都是一个被方括号包围的表达式。最后,我们将定义一元表达式,其中包括前缀操作符和强制转换操作符:
<unary-exp> ::= <unop> <unary-exp>
| "(" {<type-specifier>}+ [<abstract-declarator>] ")" <unary-exp>
| <postfix-exp>
列表 15-18 显示了完整的语法,并将本章的更改加粗显示。
<program> ::= {<declaration>}
<declaration> ::= <variable-declaration> | <function-declaration>
<variable-declaration> ::= {<specifier>}+ <declarator> ["=" **<initializer>]** ";"
<function-declaration> ::= {<specifier>}+ <declarator> (<block> | ";")
<declarator> ::= "*" <declarator> | <direct-declarator>
<direct-declarator> ::= <simple-declarator> **[<declarator-suffix>]**
**<declarator-suffix> ::= <param-list> | {"[" <const> "]"}+**
<param-list> ::= "(" "void" ")" | "(" <param> {"," <param>} ")"
<param> ::= {<type-specifier>}+ <declarator>
<simple-declarator> ::= <identifier> | "(" <declarator> ")"
<type-specifier> ::= "int" | "long" | "unsigned" | "signed" | "double"
<specifier> ::= <type-specifier> | "static" | "extern"
<block> ::= "{" {<block-item>} "}"
<block-item> ::= <statement> | <declaration>
**<initializer> ::= <exp> | "{" <initializer> {"," <initializer>} [","] "}"**
<for-init> ::= <variable-declaration> | [<exp>] ";"
<statement> ::= "return" <exp> ";"
| <exp> ";"
| "if" "(" <exp> ")" <statement> ["else" <statement>]
| <block>
| "break" ";"
| "continue" ";"
| "while" "(" <exp> ")" <statement>
| "do" <statement> "while" "(" <exp> ")" ";"
| "for" "(" <for-init> [<exp>] ";" [<exp>] ")" <statement>
| ";"
<exp> ::= **<unary-exp>** | <exp> <binop> <exp> | <exp> "?" <exp> ":" <exp>
**<unary-exp> ::= <unop> <unary-exp>**
**| "(" {<type-specifier>}+ [<abstract-declarator>] ")" <unary-exp>**
**| <postfix-exp>**
**<postfix-exp> ::= <primary-exp> {"[" <exp> "]"}**
**<primary-exp> ::= <const> | <identifier> | "(" <exp> ")"**
**| <identifier> "(" [<argument-list>] ")"**
<argument-list> ::= <exp> {"," <exp>}
<abstract-declarator> ::= "*" [<abstract-declarator>]
| <direct-abstract-declarator>
<direct-abstract-declarator> ::= "(" <abstract-declarator> ")" **{"[" <const> "]"}**
**| {"[" <const> "]"}+**
<unop> ::= "-" | "~" | "!" | "*" | "&"
<binop> ::= "-" | "+" | "*" | "/" | "%" | "&&" | "||"
| "==" | "!=" | "<" | "<=" | ">" | ">=" | "="
<const> ::= <int> | <long> | <uint> | <ulong> | <double>
<identifier> ::= ? An identifier token ?
<int> ::= ? An int token ?
<long> ::= ? An int or long token ?
<uint> ::= ? An unsigned int token ?
<ulong> ::= ? An unsigned int or unsigned long token ?
<double> ::= ? A floating-point constant token ?
清单 15-18:包含数组类型、复合初始化器和下标表达式的语法
一旦你更新了你的解析器,以适应清单 15-18 中的所有更改,你就可以开始测试了。
类型检查器
类型检查器将在本章中完成大部分的繁重工作。它将为下标和指针算术表达式添加类型信息;验证复合初始化器的维度;并检测类型错误,例如将表达式强制转换为数组类型。它还将处理从数组到指针类型的隐式转换。就像我们将Cast表达式插入到抽象语法树(AST)中以使隐式类型转换显式一样,我们将插入AddrOf表达式,以使从数组到指针的转换变得显式。
将数组转换为指针
我们将把任何数组类型的表达式转换为指针,除非它已经是AddrOf表达式的操作数。这可能会让你想起上一章的内容,我们将每个表达式的结果转换为左值,除非我们获取了它的地址或对其进行了赋值。在第十四章中,我们引入了一个新的emit_tacky_and_convert助手函数来管理左值转换;现在,我们将在不同的编译器过程中使用类似的设计模式。我们将定义一个新的typecheck_and_convert函数,如清单 15-19 所示。
typecheck_and_convert(e, symbols):
typed_e = typecheck_exp(e, symbols)
match get_type(typed_e) with
| Array(elem_t, size) ->
addr_exp = AddrOf(typed_e)
return set_type(addr_exp, Pointer(elem_t))
| _ -> return typed_e
清单 15-19:隐式将数组转换为指针
如果一个表达式具有数组类型,我们插入一个AddrOf操作以获取它的地址。然后我们记录它的结果类型,这是指向数组元素类型的指针。这与我们从显式的&操作符中得到的结果类型不同,后者总是返回指向操作数类型的指针。以下是一个声明示例:
int arr[3];
表达式&arr的类型是int (*)[3]。另一方面,表达式arr的类型是int *。在类型检查后的 AST 中,我们使用AddrOf来表示获取对象地址的两种方式,它们会产生不同的结果类型:通过隐式转换或显式的&操作符。
一旦引入了 typecheck_and_convert,我们将用它代替 typecheck_exp 来检查子表达式和完整表达式。唯一的例外是类型检查 AddrOf 的操作数。这个操作数不应从数组转换为指针,因此我们将继续通过直接调用 typecheck_exp 来处理它。
验证 Lvalue
我们将更改验证 lvalue 的一些细节。首先,我们应该将 下标 表达式作为 lvalue 识别,除了 变量 和 解引用。
其次,我们需要拒绝尝试赋值给数组的赋值表达式。一旦数组衰退为指针,它就不再是 lvalue,不能进行赋值。为了捕捉这些无效的赋值表达式,我们将在检查它是否为 lvalue 之前,用 typecheck_and_convert 处理左操作数。列表 15-20 显示了最新的逻辑来进行赋值表达式的类型检查。
typecheck_exp(e, symbols):
match e with
| `--snip--`
| Assignment(left, right) ->
typed_left = typecheck_and_convert(left, symbols)
if typed_left is not an lvalue:
fail("Tried to assign to non-lvalue")
typed_right = typecheck_and_convert(right, symbols)
`--snip--`
列表 15-20:赋值表达式类型检查
如果左操作数是数组,typecheck_and_convert 将把它包装在一个 AddrOf 操作中。然后,由于 AddrOf 不是 lvalue,类型检查器将抛出错误。
指针算术类型检查
接下来,我们将扩展加法、减法和关系运算符,使其能够与指针一起使用。将任何整数类型加到指针上是有效的。列表 15-21 演示了如何进行加法的类型检查。
| Binary(Add, e1, e2) ->
typed_e1 = typecheck_and_convert(e1, symbols)
typed_e2 = typecheck_and_convert(e2, symbols)
t1 = get_type(typed_e1)
t2 = get_type(typed_e2)
if t1 and t2 are arithmetic:
`--snip--`
else if t1 is a pointer type and t2 is an integer type:
❶ converted_e2 = convert_to(typed_e2, Long)
add_exp = Binary(Add, typed_e1, converted_e2)
❷ return set_type(add_exp, t1)
❸ else if t2 is a pointer type and t1 is an integer type:
`--snip--`
else:
fail("Invalid operands for addition")
列表 15-21:指针加法类型检查
为了进行包含指针和整数的加法类型检查,我们首先将整数操作数转换为 long ❶。这将简化后续编译器的处理过程,当指针索引需要是 8 字节宽时,以便我们可以将它们加到 8 字节的内存地址中。这个转换并非来自 C 标准;我们只是为了自己的方便而添加它。但它也不会违反标准;将有效的数组索引转换为 long 不会改变它的值,因此整个表达式的结果无论如何都是相同的。(如果一个整数太大,无法表示为 long,我们可以安全地假设它不是一个有效的数组索引,因为没有硬件支持包含接近 2⁶³ 个元素的数组。)
指针相加的结果与指针操作数的类型相同 ❷。无论第一个还是第二个操作数是指针,我们都使用相同的逻辑,因此我省略了后一种情况的伪代码 ❸。最后,在除将指针加到整数或将两个算术操作数相加之外的任何情况下,我们都会抛出错误。
从指针中减去一个整数的方式与此相同:我们将整数操作数转换为 long,并使用与指针操作数相同的类型来注解结果。唯一的不同之处在于操作数的顺序很重要。你可以从指针中减去一个整数,但不能从整数中减去一个指针。
当我们从一个指针中减去另一个指针时,两个操作数必须具有相同的类型,结果会有一个实现定义的有符号整数类型。我们在这里使用 long 作为结果类型,这是 64 位系统上的标准类型。这个类型在 <stddef.h> 头文件中应该被别名为 ptrdiff_t,以帮助用户编写更具可移植性的代码。由于我们不支持 typedef,因此无法编译 <stddef.h>,所以我们将忽略这一要求。
列表 15-22 展示了如何进行指针减法的类型检查。
| Binary(Subtract, e1, e2) ->
typed_e1 = typecheck_and_convert(e1, symbols)
typed_e2 = typecheck_and_convert(e2, symbols)
t1 = get_type(typed_e1)
t2 = get_type(typed_e2)
if t1 and t2 are arithmetic:
`--snip--`
❶ else if t1 is a pointer type and t2 is an integer type:
converted_e2 = convert_to(typed_e2, Long)
sub_exp = Binary(Subtract, typed_e1, converted_e2)
return set_type(sub_exp, t1)
❷ else if t1 is a pointer type and t1 == t2:
sub_exp = Binary(Subtract, typed_e1, typed_e2)
return set_type(sub_exp, Long)
else:
fail("Invalid operands for subtraction")
列表 15-22:指针减法的类型检查
如果一个表达式从指针中减去一个整数,我们就像处理指针相加那样处理它 ❶。如果它从两个相同类型的指针中减去,我们记录 long 作为结果类型 ❷。在任何其他情况下——如果表达式从两个不同类型的指针中减去,或者从指针中减去一个 double,或者从算术值中减去一个指针——我们都会抛出错误。
最后,让我们处理<、<=、>和>=操作符。每个操作符都接受两个相同类型的指针操作数,并返回一个int。这些操作符的类型检查比较简单,因此我不会为这个情况提供伪代码。
注意,这些操作符都不接受空指针常量;它们将指针与同一数组中的元素进行比较,但空指针根据定义并不指向数组元素。按照同样的逻辑,你不能从空指针常量中减去一个指针。如果x是一个指针,那么表达式x == 0 和 x != 0 是合法的,但0 - x、0 < x和x >= 0则不是。 (Clang 和 GCC 在这里比标准更宽松;作为语言扩展,它们允许你在任何关系操作符中使用空指针常量。使用此扩展时,任何非空指针都会与空指针常量进行“大于”的比较。)
下标表达式的类型检查
下标表达式的一个操作数必须是指针,另一个操作数必须是整数。指针所引用的类型是结果类型。记住,这两个操作数可以任意顺序出现;我们不能假设指针一定是第一个操作数。列表 15-23 展示了如何进行下标表达式的类型检查。
| Subscript(e1, e2) ->
typed_e1 = typecheck_and_convert(e1, symbols)
typed_e2 = typecheck_and_convert(e2, symbols)
t1 = get_type(typed_e1)
t2 = get_type(typed_e2)
❶ if t1 is a pointer type and t2 is an integer type:
ptr_type = t1
❷ typed_e2 = convert_to(typed_e2, Long)
else if t1 is an integer type and t2 is a pointer type:
ptr_type = t2
typed_e1 = convert_to(typed_e1, Long)
else:
fail("Subscript must have integer and pointer operands")
subscript_exp = Subscript(typed_e1, typed_e2)
❸ return set_type(subscript_exp, ptr_type.referenced)
列表 15-23:类型检查下标表达式
首先,我们验证一个操作数是指针,另一个是整数 ❶。然后,我们将整数操作数转换为long ❷。最后,我们用指针所引用的类型注解整个表达式 ❸。
类型检查类型转换表达式
这个很简单:你不能将一个表达式转换为数组类型。例如,表达式
(int[3]) foo;
是无效的,应该产生类型错误。
类型检查函数声明
当我们处理一个函数声明时,我们需要考虑其返回类型和参数类型。如果一个函数返回数组类型,我们会抛出一个错误。如果它的任何参数有数组类型,我们会将其调整为指针类型。 示例 15-24 描述了如何验证并隐式调整函数类型。
typecheck_function_declaration(decl, symbols):
if decl.fun_type.ret is an array type:
fail("A function cannot return an array!")
adjusted_params = []
for t in decl.fun_type.params:
match t with
| Array(elem_t, size) ->
adjusted_type = Pointer(elem_t)
adjusted_params.append(adjusted_type)
| _ -> adjusted_params.append(t)
decl.fun_type.params = adjusted_params
`--snip--`
示例 15-24:调整函数声明中的数组类型
你应该将这段逻辑添加到typecheck_function _declaration的开头,以便在检查函数参数类型是否与同一标识符的先前定义冲突之前,先调整函数的参数类型。你还应该确保符号表和 AST 节点本身都使用已调整的参数类型。
类型检查复合初始化器
我们需要为每个初始化器标注其类型,并在初始化器与其应初始化的对象的类型不兼容时发出错误。为了类型检查复合初始化器,我们首先验证它初始化的对象是否是一个数组。然后,我们递归地类型检查每个嵌套的初始化器,验证它是否与数组的元素类型兼容。示例 15-25 演示了这种方法。
typecheck_init(target_type, init, symbols):
match target_type, init with
| _, SingleInit(e) -> ❶
typechecked_exp = typecheck_and_convert(e, symbols)
cast_exp = convert_by_assignment(typechecked_exp, target_type)
return set_type(SingleInit(cast_exp), target_type)
| Array(elem_t, size), CompoundInit(init_list) -> ❷
if length(init_list) > size:
fail("wrong number of values in initializer") ❸
typechecked_list = []
for init_elem in init_list:
typechecked_elem = typecheck_init(elem_t, init_elem, symbols) ❹
typechecked_list.append(typechecked_elem)
while length(typechecked_list) < size:
typechecked_list.append(zero_initializer(elem_t)) ❺
return set_type(CompoundInit(typechecked_list), target_type) ❻
| _ -> fail("can't initialize a scalar object with a compound initializer") ❼
示例 15-25:类型检查初始化器
在基本情况下,初始化器是一个单一的表达式❶。我们将对这个表达式进行类型检查,然后调用convert_by_assignment,该函数在第十四章中定义,用来将其转换为目标类型。如果它与目标类型不兼容,convert_by_assignment会抛出一个错误(这包括目标类型是数组类型的情况)。
在递归情况下,我们将使用复合初始化器❷初始化一个数组。列表中的每个项将初始化数组中的一个元素。首先,我们将检查列表中是否包含太多元素❸。然后,我们将递归地对每个列表项进行类型检查,使用数组的元素类型作为目标类型❹。如果初始化器列表包含的元素太少,我们将用零来填充❺。我们将使用zero_initializer辅助函数,我没有提供伪代码,用来生成可以添加到初始化器列表中的零值初始化器。给定一个标量类型,zero_initializer应该返回一个值为 0 的该类型的SingleInit。给定一个数组类型,它应该返回一个CompoundInit,其中标量元素(可能嵌套多层)值为 0。例如,调用zero_initializer类型为UInt时应该返回
SingleInit(Constant(ConstUInt(0)))
并且在类型为Array(Array(Int, 2), 2)时应该返回:
CompoundInit([
CompoundInit([SingleInit(Constant(ConstInt(0))),
SingleInit(Constant(ConstInt(0)))]),
CompoundInit([SingleInit(Constant(ConstInt(0))),
SingleInit(Constant(ConstInt(0)))])
])
一旦我们完成了构建类型检查过的初始化器列表,我们将把它打包成一个CompoundInit,并用目标类型❻进行注解。如果初始化器不是一个单一的表达式且目标类型不是数组类型,我们正在尝试用复合初始化器初始化一个标量对象,因此我们会抛出错误❼。
初始化静态数组
和其他静态变量一样,我们将静态数组的初始值存储在符号表中。我们需要更新用来表示这些初始值的数据结构。我们将每个对象的初始化器表示为标量值的列表:
initial_value = Tentative | Initial(**static_init* init_list**) | NoInitializer
对于标量对象,init_list将只有一个元素。声明
static int a = 3;
将会有这个初始化器:
Initial([IntInit(3)])
对于多维数组,我们将展平任何嵌套的结构。因此,声明
static int nested[3][2] = {{1, 2}, {3, 4}, {5, 6}};
将会有这个初始化器:
Initial([IntInit(1),
IntInit(2),
IntInit(3),
IntInit(4),
IntInit(5),
IntInit(6)])
接下来,我们将添加一个static_init构造函数,用于表示任何大小的零初始化对象:
static_init = IntInit(int) | LongInit(int) | UIntInit(int) | ULongInit(int)
| DoubleInit(double) **| ZeroInit(int bytes)**
ZeroInit的bytes参数指定了要初始化为零的字节数。如果一个静态数组只部分初始化,我们会使用ZeroInit来填充任何未初始化的元素。例如,声明
static int nested[3][2] = {{100}, {200, 300}};
将会有这个初始化器:
Initial([IntInit(100),
ZeroInit(4),
IntInit(200),
IntInit(300),
ZeroInit(8)])
该初始化器列表的第二个元素,ZeroInit(4),初始化了nested[0][1]处的int;最后一个元素,ZeroInit(8),初始化了嵌套数组nested[2]的两个元素。
一旦你更新了initial_value和static_init数据结构,编写一个函数将复合初始化器转换为static_init列表。你需要验证静态数组的初始化器是否具有正确的大小和结构,就像对非静态数组初始化器进行类型检查一样;你应该拒绝具有过多元素的初始化器、用于数组的标量初始化器以及用于标量对象的复合初始化器。我不会为这个转换提供伪代码,因为它与我们在列表 15-25 中检查非静态初始化器的方式类似。
使用 ZeroInit 初始化标量变量
你还可以使用ZeroInit来将标量变量初始化为零。例如,给定以下声明
static long x = 0;
你可以使用以下初始化器:
Initial([ZeroInit(8)])
在这里使用ZeroInit是可选的,但它使得代码的生成更加简单,因为你可以轻松地判断哪些初始化器属于.data段,哪些属于.bss段。只需小心不要使用ZeroInit来初始化double类型;仅当你确定该double的初始值为0.0而非-0.0时,才使用它。
TACKY 生成
为了适应指针运算和复合初始化器,我们将对 TACKY IR 进行一些更改。首先,由于我们改变了在符号表中表示初始化器的方式,我们将在 TACKY 中进行相应的更改:
top_level = Function(identifier, bool global, identifier* params, instruction* body)
| StaticVariable(identifier, bool global, type t, **static_init* init_list**)
我们还将引入一个新的指令来支持指针运算:
AddPtr(val ptr, val index, int scale, val dst)
我们将使用此指令将一个整数加到或从指针中减去,但不能将一个指针从另一个指针中减去。scale 操作数是数组中每个元素的大小(以字节为单位),而 ptr 指向的正是这个数组。例如,如果 ptr 是一个 int ,那么 scale 操作数将是 4,因为一个 int 占 4 个字节。如果 ptr 是一个 int ()[3],即指向一个包含三个 int 对象的数组的指针,那么 scale 将是 12。index 操作数告诉我们从基指针开始,应该向前或向后移动多少个元素。在运行时,程序将把 index 乘以 scale 来确定要加到基指针上的字节数。虽然可以使用现有的 TACKY 指令进行乘法和加法运算来实现指针算术,但在这里引入一个专门的 AddPtr 指令,将有助于我们利用 x64 架构对指针算术的内建支持。
我们将介绍另一个指令,以支持复合初始化器:
CopyToOffset(val src, identifier dst, int offset)
在此指令中,src 是一个标量值,dst 是某个聚合类型变量的名称,offset 指定了 dst 的起始位置与我们应该将 src 复制到的位置之间的字节数。值得注意的是,dst 表示的是一个数组,不是指向数组元素的指针。换句话说,CopyToOffset 不使用 dst 的值,而是使用 dst 来标识一个在内存中位置固定的对象。由于此指令直接作用于数组,而不是指针,因此它对于数组初始化很有用,但不适用于下标操作。在 第十八章 中,我们还将使用它来初始化和更新结构体。
Listing 15-26 展示了更新后的 TACKY IR,本章的更改以粗体标出。
program = Program(top_level*)
top_level = Function(identifier, bool global, identifier* params, instruction* body)
| StaticVariable(identifier, bool global, type t, **static_init* init_list**)
instruction = Return(val)
| SignExtend(val src, val dst)
| Truncate(val src, val dst)
| ZeroExtend(val src, val dst)
| DoubleToInt(val src, val dst)
| DoubleToUInt(val src, val dst)
| IntToDouble(val src, val dst)
| UIntToDouble(val src, val dst)
| Unary(unary_operator, val src, val dst)
| Binary(binary_operator, val src1, val src2, val dst)
| Copy(val src, val dst)
| GetAddress(val src, val dst)
| Load(val src_ptr, val dst)
| Store(val src, val dst_ptr)
**| AddPtr(val ptr, val index, int scale, val dst)**
**| CopyToOffset(val src, identifier dst, int offset)**
| Jump(identifier target)
| JumpIfZero(val condition, identifier target)
| JumpIfNotZero(val condition, identifier target)
| Label(identifier)
| FunCall(identifier fun_name, val* args, val dst)
val = Constant(const) | Var(identifier)
unary_operator = Complement | Negate | Not
binary_operator = Add | Subtract | Multiply | Divide | Remainder | Equal | NotEqual
| LessThan | LessOrEqual | GreaterThan | GreaterOrEqual
Listing 15-26: 向 TACKY IR 添加对数组的支持
有了这些新增的功能,我们可以实现本章中每个新的运算符和构造。让我们依次处理它们。
指针运算
我们将通过 AddPtr 指令实现指针运算表达式
`<instructions for ptr>`
p = `<result of ptr>`
`<instructions for int>`
i = `<result of int>`
result = AddPtr(p, i, `<size of referenced type of ptr>`)
Listing 15-27: 在 TACKY 中将整数加到指针上
关于这个列表,有几个要注意的地方。首先,指针始终是 AddPtr 指令的第一个操作数,整数始终是第二个操作数,无论它们在原始表达式中的顺序如何。第二,您需要在编译时计算指针引用类型的大小,因为 scale 操作数是常量,而不是 TACKY 值。
从指针中减去整数的 TACKY 操作几乎是相同的;我们只是先对索引取反,然后再将其包括在 AddPtr 中。我们将
`<instructions for ptr>`
p = `<result of ptr>`
`<instructions for int>`
i = `<result of int>`
j = Unary(Negate, i)
result = AddPtr(p, j, `<size of referenced type of ptr>`)
Listing 15-28: 在 TACKY 中从指针减去整数
从一个指针减去另一个指针的操作稍有不同。首先,我们使用普通的Subtract指令计算字节差值。然后,我们将结果除以一个数组元素的字节数,以计算两个指针之间的数组索引差异。换句话说,我们将
`<instructions for ptr1>`
p1 = `<result of ptr1>`
`<instructions for ptr2>`
p2 = `<result of ptr2>`
diff = Binary(Subtract, p1, p2)
result = Binary(Divide, diff, `<size of referenced type of ptr1>`)
Listing 15-29: 在 TACKY 中从两个指针中减去
我们将在编译时计算引用类型的大小。这里可以使用任一操作数的类型,因为类型检查器已经验证它们都具有相同的类型。
我们将像处理算术值一样比较指针,使用 LessThan、LessOrEqual、GreaterThan 和 GreaterOrEqual 操作符。
下标操作
根据 C 标准,下标表达式
int arr[3][4];
`--snip--`
return arr[i][j];
列表 15-30:返回下标操作符的结果
列表 15-31 显示了该示例中 TACKY 实现的 return 语句。
❶ tmp0 = GetAddress(arr)
tmp1 = AddPtr(tmp0, i, 16)
tmp2 = AddPtr(tmp1, j, 4)
❷ tmp3 = Load(tmp2)
Return(tmp3)
列表 15-31:在 TACKY 中实现 列表 15-30
首先,我们发出一个GetAddress指令,以获取指向数组arr中第一个元素的指针。接着,我们发出两个AddPtr指令,计算指向数组元素arr[i][j]的指针。最后,我们使用一个Load指令,将该数组元素的当前值读入一个临时变量,并返回该值。Listing 15-31 是高效的,没有任何多余的指令。我们之前看到,数组下标操作需要我们反复获取数组元素的地址,进行指针运算,并解引用结果。但是在这个清单中,我们只在开始时获取一次数组的地址❶,并且只在最后一次使用Load指令解引用指针❷。我们的 TACKY 生成策略是如何实现这一结果的呢?
Listing 15-32 给出了 Listing 15-30 中return语句的 AST。让我们来看看如何将这个 AST 中的每个子表达式转换为 TACKY。
❶ Return(
❷ Subscript(
❸ AddrOf(
❹ Subscript(
❺ AddrOf(Var("arr")),
Var("i")
)
),
Var("j")
)
)
Listing 15-32: Listing 15-30 的 AST
该 AST 包括我们在类型检查期间插入的两个AddrOf表达式。内部的表达式❺获取arr的地址,而外部的表达式❸获取arr[i]的地址。当然,arr、i和j在标识符解析期间会被重命名,但我们在这个示例中(以及本章后续示例中)忽略这个细节。
像往常一样,我们按照后序遍历将这个 AST 转换为 TACKY,在处理表达式本身之前先处理每个操作数。我们处理的第一个非叶子节点是内部的AddrOf表达式,它获取arr的地址❺。我们将其转换为GetAddress指令:
tmp0 = GetAddress(arr)
接下来,为了实现内部Subscript表达式❹,我们发出一个AddPtr指令:
tmp1 = AddPtr(tmp0, i, 16)
这里的规模是 16,因为 tmp0 指向一个四个 int 的数组。下标操作的第二部分是取消引用结果,因此我们将 DereferencedPointer(tmp1) 返回给调用者。
在调用者中,我们处理外部的 AddrOf 表达式 ❸。当我们获取取消引用指针的地址时,这些操作相互抵消。因此,我们将 PlainOperand(tmp1) 作为这个表达式的结果返回,不会发出任何进一步的指令。
现在我们处理外部的 Subscript 表达式 ❷。我们再次发出 AddPtr 指令:
tmp2 = AddPtr(tmp1, j, 4)
然后我们将 DereferencedPointer(tmp2) 返回给调用者。由于这个 Subscript 表达式出现在 Return 语句 ❶ 中,而不是 AddrOf 或赋值表达式中,我们对这个结果进行了左值转换。这意味着我们发出一个 Load 指令:
tmp3 = Load(tmp2)
现在 tmp3 包含了整个表达式左值转换后的结果,因此我们将其返回:
Return(tmp3)
正如这个例子所示,当我们对一个多维数组进行索引时,取消引用操作和隐式地址加载相互抵消,不会产生任何额外的指令。因此,任何下标和取消引用操作在 TACKY 中都会转化为纯粹的指针运算,不需要任何 Load 或 Store 指令,直到我们访问到标量数组元素。
复合初始化器
为了处理复合初始化器,我们计算初始化器中每个标量表达式,并通过 CopyToOffset 指令将其复制到适当的内存位置。例如,我们将转换初始化器
long arr[3] = {1l, 2l, 3l};
转换为以下指令序列:
CopyToOffset(1l, "arr", 0)
CopyToOffset(2l, "arr", 8)
CopyToOffset(3l, "arr", 16)
由于 long 是 8 字节,所以每个元素的偏移量增加 8。即使我们处理嵌套的初始化器,我们也只需将叶子节点的标量值复制到正确的内存位置。例如,我们将转换
long nested[2][3] = {{1l, 2l, 3l}, {4l, 5l, 6l}};
转换为:
CopyToOffset(1l, "nested", 0)
CopyToOffset(2l, "nested", 8)
CopyToOffset(3l, "nested", 16)
CopyToOffset(4l, "nested", 24)
CopyToOffset(5l, "nested", 32)
CopyToOffset(6l, "nested", 40)
这个转换相当直接,因此我会省略相关的伪代码。不过,我需要指出的是,你应该使用类型检查器为每个复合初始化器添加的类型信息来计算每个元素的偏移量。
暂定数组定义
记得我们将符号表条目转换为 StaticVariable 构造时,我们将暂定定义的变量初始化为零。暂定定义的数组也适用这一点。你应该使用我们在上一节中添加的新初始化器 ZeroInit(n) 来初始化一个 n 字节的数组为零。
你还可以使用 ZeroInit 来初始化暂定定义的标量变量。为了保持一致性,只有在你用它来初始化类型检查器中显式定义的标量变量为零时,才应该在这里使用 ZeroInit。
汇编生成
本章中我们不会引入任何新的汇编指令。不过,我们会介绍一种新的内存寻址模式,有时称为索引寻址。现在,我们可以使用寄存器中的基地址和常量偏移量来指定内存操作数,例如 4(%rax)。使用索引寻址时,我们可以将基地址存储在一个寄存器中,索引存储在另一个寄存器中。我们还可以指定一个比例因子,必须是常量 1、2、4 或 8 之一。下面是一个索引寻址的示例:
movl $5, (%rax, %rbx, 4)
为了找到这个 movl 指令的目标地址,CPU 将计算 RAX + RBX × 4。然后,它将在这个地址存储 4 字节常量 5。这种寻址模式对数组访问非常方便。如果 RAX 存储了一个 int 类型数组的地址,而 RBX 存储了数组的索引 i,那么操作数 (%rax, %rbx, 4) 指定了索引 i 处的元素。
我们将添加一个新操作数以支持索引寻址:
Indexed(reg base, reg index, int scale)
我们还将对汇编 AST 做一些其他更改,以帮助后续后端处理中的账务管理。首先,我们将添加另一个操作数,用于表示尚未分配固定地址的聚合对象:
PseudoMem(identifier, int)
PseudoMem 操作数的作用与现有的 Pseudo 操作数类似;它允许我们在为变量分配寄存器或内存位置之前,在汇编中表示这些变量。不同之处在于,PseudoMem 表示聚合对象,我们总是将它们存储在内存中(即使在第三部分实现寄存器分配后也是如此)。另一方面,Pseudo 表示标量对象,这些对象有可能存储在寄存器中。PseudoMem 操作数还允许我们指定对象的字节偏移量。请注意,这个操作数中的标识符指定的是聚合对象,而不是指向聚合对象的指针。
接下来,我们将添加一种新的汇编类型来表示数组。在汇编中,我们将把数组当作一块未区分的内存块来处理。我们不再需要跟踪将要存储在该内存块中的对象数量或这些对象的类型。然而,我们关心它的对齐方式和占用的空间大小,以便为它分配栈空间。因此,我们将数组类型转换为新的 ByteArray 类型:
assembly_type = Longword | Quadword | Double | **ByteArray(int size, int alignment)**
最后,我们将调整如何表示静态变量。与早期的编译器阶段一样,我们将用一组 static_init 值初始化静态变量,而不是仅用一个值:
StaticVariable(identifier name, bool global, int alignment, **static_init* init_list**)
列表 15-33 高亮显示了本章对汇编 AST 所做的所有更改。
program = Program(top_level*)
assembly_type = Longword | Quadword | Double | **ByteArray(int size, int alignment)**
top_level = Function(identifier name, bool global, instruction* instructions)
| StaticVariable(identifier name, bool global, int alignment, **static_init* init_list**)
| StaticConstant(identifier name, int alignment, static_init init)
instruction = Mov(assembly_type, operand src, operand dst)
| Movsx(operand src, operand dst)
| MovZeroExtend(operand src, operand dst)
| Lea(operand src, operand dst)
| Cvttsd2si(assembly_type dst_type, operand src, operand dst)
| Cvtsi2sd(assembly_type src_type, operand src, operand dst)
| Unary(unary_operator, assembly_type, operand)
| Binary(binary_operator, assembly_type, operand, operand)
| Cmp(assembly_type, operand, operand)
| Idiv(assembly_type, operand)
| Div(assembly_type, operand)
| Cdq(assembly_type)
| Jmp(identifier)
| JmpCC(cond_code, identifier)
| SetCC(cond_code, operand)
| Label(identifier)
| Push(operand)
| Call(identifier)
| Ret
unary_operator = Neg | Not | Shr
binary_operator = Add | Sub | Mult | DivDouble | And | Or | Xor
operand = Imm(int) | Reg(reg) | Pseudo(identifier) | Memory(reg, int) | Data(identifier)
**| PseudoMem(identifier, int) | Indexed(reg base, reg index, int scale)**
cond_code = E | NE | G | GE | L | LE | A | AE | B | BE
reg = AX | CX | DX | DI | SI | R8 | R9 | R10 | R11 | SP | BP
| XMM0 | XMM1 | XMM2 | XMM3 | XMM4 | XMM5 | XMM6 | XMM7 | XMM14 | XMM15
列表 15-33:具有聚合对象和索引寻址支持的汇编 AST
一旦我们更新了汇编 AST,我们将更新从 TACKY 到汇编的转换。
将 TACKY 转换为汇编
首先,我们将处理 TACKY 数组类型的变量。为了将这些与标量值区分开,我们将它们转换为 PseudoMem 操作数,而不是 Pseudo 操作数。例如,如果 arr 是一个数组,我们将进行转换。
GetAddress(Var("arr"), Var("dst"))
到:
Lea(PseudoMem("arr", 0), Pseudo("dst"))
每当我们将一个聚合 TACKY Var 转换为汇编时,我们会使用零偏移量,以指定整个对象。
接下来,我们处理新的 CopyToOffset 和 AddPtr 指令。我们将使用带有适当偏移量的 PseudoMem 操作数来表示 CopyToOffset 指令的目标地址。因此,我们将转换
CopyToOffset(src, dst, offset)
到:
Mov(`<src type>`, src, PseudoMem(dst, offset))
我们将使用新的 Indexed 操作数,通过 Lea 指令实现 AddPtr。具体实现将根据比例和索引的不同而有所不同。首先,我们来考虑比例为 1、2、4 或 8 的情况。我们将转换
AddPtr(ptr, index, scale, dst)
到 列表 15-34。
Mov(Quadword, ptr, Reg(AX))
Mov(Quadword, index, Reg(DX))
Lea(Indexed(AX, DX, scale), dst)
列表 15-34:在汇编中实现 AddPtr 函数
首先,我们将 ptr 和 index 复制到寄存器中;我这里使用的是 RAX 和 RDX,但除了被调用保存的寄存器或我们的临时寄存器外,任何寄存器都可以。然后,我们发出一个 Lea 指令,计算 ptr + index * scale,并将结果存储在 dst 中。
AddPtr 的比例可能不是 Indexed 支持的四个值之一,尤其是当我们对多维数组进行索引,而不是标量对象数组时。在这种情况下,我们将使用单独的指令将比例与索引相乘,正如 列表 15-35 所示。
Mov(Quadword, ptr, Reg(AX))
Mov(Quadword, index, Reg(DX))
Binary(Mult, Quadword, Imm(scale), Reg(DX))
Lea(Indexed(AX, DX, 1), dst)
列表 15-35:使用非标准比例在汇编中实现 AddPtr 函数
如果 index 操作数是常量,我们可以通过在编译时计算 index * scale 来节省一条指令。然后,我们将仅生成 列表 15-36 中的两条指令。
Mov(Quadword, ptr, Reg(AX))
Lea(Memory(AX, index * scale), dst)
列表 15-36:实现 AddPtr 与常量索引
接下来,我们将处理指针比较。我们将像无符号整数比较一样实现这些,使用无符号条件码:A、AE、B 和 BE。
最后,我们来讨论一下数组的对齐要求。我们需要计算数组对齐的几种情况:当我们将一个数组类型的 StaticVariable 从 TACKY 转换为汇编代码时(汇编中的 StaticVariable 包含一个 alignment 字段),以及当我们将一个前端符号表条目(数组类型)转换为后端符号表中的相应条目时。后端符号表中每个数组的汇编类型将是一个具有适当大小和对齐的 ByteArray。其大小将是数组元素类型的字节大小,乘以元素的数量。计算对齐的规则则不太直观。
如果一个数组小于 16 字节,它与其标量元素具有相同的对齐方式。例如,一个类型为 int[2] 的数组和一个类型为 int[2][1] 的数组都具有 4 字节的对齐方式。如果一个数组类型的变量为 16 字节或更大,它的对齐方式始终为 16,无论其元素的类型是什么。这个要求使得可以使用 SSE 指令同时操作多个数组元素。虽然我们不会以这种方式使用 SSE 指令,但我们需要与可能使用该指令的其他目标文件保持 ABI 兼容性。
请注意,这个对齐要求仅适用于变量,而不适用于嵌套数组。例如,如果我们声明一个变量
int nested[3][5];
然后 nested 需要从一个 16 字节对齐的地址开始,因为它的总大小为 60 字节。但是它的第一个和第二个元素分别从 nested 的起始地址的 20 字节和 40 字节位置开始,因此它们并不是 16 字节对齐的,尽管这两个元素的大小也都大于 16 字节。
表格 15-1 到 15-5 总结了本章对该编译器通道的更新;像往常一样,新的构造和现有构造转换的变化会加粗显示。
表 15-1: 将顶层 TACKY 结构转换为汇编
| TACKY 顶层结构 | 汇编顶层结构 |
|---|
|
StaticVariable(name, global, t, init_list)
|
StaticVariable(name, global, <alignment of t>,
init_list)
|
表 15-2: 将 TACKY 指令转换为汇编
| TACKY 指令 | 汇编指令 |
|---|
|
AddPtr(ptr, index,
scale, dst)
| 常数索引 |
|---|
Mov(Quadword, ptr, Reg(<R>))
Lea(Memory(<R>, index * scale), dst)
|
|
Variable index and scale of 1, 2, 4, or 8
|
Mov(Quadword, ptr, Reg(<R1>))
Mov(Quadword, index, Reg(<R2>))
Lea(Indexed(<R1>, <R2>, scale), dst)
|
|
Variable index and other scale
|
Mov(Quadword, ptr, Reg(<R1>))
Mov(Quadword, index, Reg(<R2>))
Binary(Mult, Quadword, Imm(scale), Reg(<R2>))
Lea(Indexed(<R1>, <R2>, 1), dst)
|
|
CopyToOffset(src, dst, offset)
|
Mov(<src type>, src, PseudoMem(dst, offset))
|
表 15-3: 将 TACKY 比较转换为汇编
| TACKY 比较 | 汇编条件码 | |
|---|---|---|
| LessThan | 有符号 | L |
| 无符号, 指针,或 双精度 | B | |
| LessOrEqual | 有符号 | LE |
| 无符号, 指针,或 双精度 | BE | |
| GreaterThan | 有符号 | G |
| 无符号, 指针,或 双精度 | A | |
| GreaterOrEqual | 有符号 | GE |
| 无符号, 指针,或 双精度 | AE |
表 15-4: 将 TACKY 操作数转换为汇编代码
| TACKY 操作数 | 汇编操作数 | |
|---|---|---|
| Var(标识符) | 标量值 | Pseudo(标识符) |
| 聚合值 | PseudoMem(标识符, 0) |
表 15-5: 将类型转换为汇编
| 源类型 | 汇编类型 | 对齐方式 |
|---|---|---|
| 数组(元素, 大小) | 16 字节或更大的变量 |
ByteArray(<size of element> * size, 16)
| 16 |
|---|
| 其他所有内容 |
ByteArray(<size of element> * size,
<alignment of element>)
| 与元素相同的对齐方式 |
|---|
接下来,我们将用具体地址替换 PseudoMem 操作数。
替换 PseudoMem 操作数
我们不能再把这个阶段称为“伪寄存器替换”了,因为我们也在替换聚合值。就像我们会为一个 长字 分配 4 个字节的栈空间,为一个 四字 分配 8 个字节一样,我们会为类型为 ByteArray(大小, 对齐方式) 的对象分配 大小 字节的空间。像往常一样,我们会将数组的地址向下舍入到适当的对齐方式。
一旦数组被分配了内存地址,我们将替换任何指向它的 PseudoMem 操作数。一个 PseudoMem 操作数包括数组起始位置的偏移量,而数组的具体地址则包括相对于 RBP 的地址偏移。我们将这两个偏移量相加,构造一个新的具体内存地址。例如,假设我们遇到以下指令:
Mov(Longword, Imm(3), PseudoMem("arr", 4))
假设我们之前将arr分配为栈地址-12(%rbp)。我们计算-12 + 4,得出我们的新操作数是-8(%rbp)。然后我们相应地重写指令:
Mov(Longword, Imm(3), Memory(BP, -8))
要访问具有静态存储持续时间的数组,我们使用现有的Data操作数。如果arr是一个静态数组,我们将转换
PseudoMem("arr", 0)
到:
Data("arr")
如果我们遇到PseudoMem("arr", n),对于任何非零的n,我们会遇到问题,因为Data操作数不包括偏移量。幸运的是,这种情况不会发生。目前,我们仅使用非零偏移量的PseudoMem操作数来初始化具有自动存储持续时间的数组,而不是访问具有静态存储持续时间的数组。
RIP 相对寻址支持常量偏移量——例如,foo+4(%rip)表示符号foo之后的 4 个字节地址——但是我们目前无法在汇编 AST 中表示这些偏移量。我们将在第十八章中添加它们,以支持对结构的操作。
修正指令
我们没有引入新的指令,因此不需要新的指令修正规则。这个阶段必须识别出新的Indexed操作数指定的是一个内存地址,因此不能在要求使用寄存器或立即数的地方使用。否则,我们不需要做任何更改。
代码输出
我们将对这个阶段进行四个小的修改。首先,我们会输出新的Indexed操作数。第二,我们会将静态ZeroInit初始化器作为.zero汇编指令输出。例如,我们会将ZeroInit(32)输出为:
.zero 32
第三,如果变量的唯一初始化器是ZeroInit,我们将其写入 BSS 段,而不是数据段。
最后,当我们定义静态变量时,我们会在关联的初始化列表中逐一输出每个项。文件作用域声明
int arr[4] = {1, 2, 3};
最终将会被转换为 Listing 15-37 中的汇编代码。
.globl arr
.data
.align 16
arr:
.long 1
.long 2
.long 3
.zero 4
清单 15-37:在汇编中初始化静态数组
请注意,我们将这个数组的最后一个元素初始化为零,因为它没有被显式初始化。
表 15-6 到 15-8 总结了这些新增的代码生成阶段内容,新的构造和对现有构造的更改已用粗体标出。
表 15-6: 格式化顶层汇编结构
| 汇编顶层结构 | 输出 |
|---|
|
StaticVariable(name, global,
alignment,
init_list)
|
Integer initialized to zero, or any variable initialized only with ZeroInit
|
<global-directive>
.bss
<alignment-directive>
<name>:
<init_list>
|
| 所有其他变量 |
|---|
<global-directive>
.data
<alignment-directive>
<name>:
<init_list>
|
表 15-7: 格式化静态初始化器
| 静态初始化器 | 输出 |
|---|---|
| ZeroInit(n) | .zero |
表 15-8: 格式化汇编操作数
| 汇编操作数 | 输出 |
|---|---|
| 索引(reg1, reg2, int) |
(<reg1>, <reg2>, <int>)
|
在进行这些更改后,你可以测试你的编译器。
总结
你刚刚实现了你的第一个聚合类型!在这一章中,你学习了如何解析数组声明符和复合初始化器。在类型检查器中,你将数组到指针的隐式转换显式化,并分析了指针算术表达式的类型。在 TACKY 生成过程中,你依赖这些转换和类型信息来干净地处理指针操作,无论它们指向数组还是标量值。而在后端,你为内存中值的寻址添加了新的、更灵活的方式。
在下一章,你将实现另外三种整数类型:char、signed char 和 unsigned char。你还将实现字符串字面量,这些字面量可以是数组初始化器,也可以是根据上下文退化为指针的char数组。因为你已经实现了整数类型、指针和数组,所以你将要进行的工作的大部分基础已经到位。

描述
第十六章:16 字符与字符串

在本章中,你将实现三种新的整数类型:char、signed char 和 unsigned char。这些是字符类型,它们的大小为 1 字节。由于你的编译器已经支持多种大小的有符号和无符号整数,你可以很轻松地添加这些新类型。你还将支持字符串字面量和字符常量。字符串字面量在 C 中扮演着一个奇怪的角色:有时它们表现得像复合初始化器,而有时它们表示常量 char 数组。为了支持后者,你将把常量字符串和变量一起存储在符号表中,并且在 TACKY 中引入静态常量作为顶级构造。
在本章结束时,我们将编译一个“Hello, World!”程序。在第九章中,我们编译了一个每次打印一个字符的版本。这一次,我们将编译一个更合理的版本,能够打印整个字符串。在开始之前,我先给你一些背景信息:我将首先简要介绍字符类型与其他整数类型之间的几个显著差异,然后描述在 C 和汇编中字符串是如何工作的。
字符类型
关于字符类型,最让人惊讶的是它们竟然有三种。int 和 signed int 之间没有区别,long 和 signed long 也没有区别,但规格说明符 char 和 signed char 指代的是两种不同的类型。是否“普通”的 char 是有符号还是无符号是由实现决定的。我们将遵循 System V ABI,它规定 char 是有符号的。
即使在我们的实现中 char 和 signed char 的行为完全相同,但它们作为不同类型的事实仍然会产生实际后果。例如,示例 16-1 是非法的,因为它将同一个全局变量同时声明为 char 和 signed char。
char c;
signed char c;
示例 16-1:具有不同字符类型的冲突文件范围变量声明
另一方面,将全局变量同时声明为 int 和 signed int 是完全合法的,因为这两种声明指定了相同的类型。
字符类型在类型转换规则上也与其他整数类型略有不同。当字符用于一元 +、- 或 ~ 操作;按位二进制操作;或常规的算术转换时,它首先会被转换为 int。这些转换称为整数提升。(如果 int 无法容纳某个特定字符类型的所有值,则该字符会被转换为 unsigned int。在我们的实现中,int 可以容纳每种字符类型的所有值,因此这一点不重要。当然,我们也可以忽略未实现操作的类型规则,比如一元 + 和按位二进制操作;我在这里提到它们只是为了完整性。)
字符和其他整数之间还有一个值得注意的区别:在 C17 中,字符类型没有标量常量。像 'a' 这样的符号都具有 int 类型,尽管它们被称为“字符常量”。也有像 char16_t、char32_t 和 wchar_t 这样的宽字符类型常量,它们用于表示多字节字符,但我们不会实现它们。
注意
C23 引入了 u8 字符常量,类型为 unsigned char。它们表示 1 字节的 UTF-8 字符。
字符串字面量
在本章中,我将区分字符串字面量和字符串。字符串字面量是源代码中出现的表达式,如"abc"。字符串是一个存在于内存中的对象——具体来说,是一个空字符终止的char数组。某些字符串在运行时不能被修改;我将这些称为常量字符串,尽管这不是一个标准术语。
你可以以两种不同的方式使用字符串字面量。首先,它可以初始化任何字符类型的数组:
signed char array[4] = "abc";
如果有空间,我们会包含一个终止的空字符;如果没有空间,则省略它。这与我们通常的数组初始化规则一致:如果初始化器比目标对象短,我们会用零填充剩余部分。因此,之前的声明等价于:
signed char array[4] = {'a', 'b', 'c', 0};
另一方面,在清单 16-2 中,我们省略了array1中的空字符,因为数组不足够大,无法包含它。因此,array1和array2具有相同的内容。
char array1[3] = "abc";
char array2[3] = {'a', 'b', 'c'};
清单 16-2:将字符串字面量用作数组初始化器而不带空字符
当字符串字面量出现在数组初始化器之外时,它表示一个常量字符串。在这种情况下,字符串字面量的行为类似于其他数组类型的表达式。它们像其他数组表达式一样衰退为指针,因此你可以对其进行下标操作,或者将其赋值给char *类型的对象。以下声明用常量字符串"abc"中第一个字符的地址初始化变量str_ptr:
char *str_ptr = "abc";
字符串字面量也是左值,因此它们支持&操作符。在这里,我们使用该操作符获取常量字符串"abc"的地址,然后将其赋值给array_ptr:
char (*array_ptr)[4] = &"abc";
与之前的例子唯一不同的是,字符串字面量不会发生数组衰退。我们最终得到一个指向整个字符串的指针,类型为char (*)[4],而不是指向其第一个元素的指针,类型为char *。在这两个例子中,我们将"abc"当作任何其他数组类型的表达式来处理。
与其他数组不同,常量字符串是恒定的。尝试修改它们,如在清单 16-3 中所示,会导致未定义行为。
char *ptr = "abc";
ptr[0] = 'x';
清单 16-3:非法修改常量字符串
尽管这段代码能够编译,但它可能会抛出运行时错误,因为大多数 C 语言实现——包括我们的实现——将常量字符串存储在只读内存中。(const限定符,虽然我们不会实现它,告诉编译器对象不能被修改。如果清单 16-3 是一个实际的 C 程序的一部分,那么给ptr添加一个const限定符会是一个好主意。)
让我们再看一个例子,如清单 16-4 所示,以澄清表示常量字符串的字符串字面量与初始化数组的字符串字面量之间的区别。
char arr[3] = "abc";
arr[0] = 'x';
清单 16-4:合法地修改从字符串字面量初始化的数组
与清单 16-3 不同,这段代码是完全合法的。在清单 16-3 中,ptr指向常量字符串"abc"的开头。另一方面,在清单 16-4 中,我们使用字符串字面量"abc"的每个字符来初始化arr的一个元素,而arr是一个普通的、非常量的char数组。
一旦我们看到它们如何转换为汇编语言,这两种情况就更容易理解了。
汇编中处理字符串
我们将使用两种不同的汇编指令来初始化汇编中的字符串。.ascii和.asciz指令都告诉汇编器将 ASCII 字符串写入目标文件,就像.quad指令告诉它写入一个四字节数一样。区别在于,.asciz会包含一个终止的空字节,而.ascii则不会。三条声明
static char null_terminated[4] = "abc";
static char not_null_terminated[3] = "abc";
static char extra_padding[5] = "abc";
对应于清单 16-5 中的汇编代码。
.data
null_terminated:
.asciz "abc"
not_null_terminated:
.ascii "abc"
extra_padding:
.asciz "abc"
.zero 1
清单 16-5:从字符串字面量在汇编中初始化三个静态 char 数组
因为 null_terminated 的长度足以容纳一个空字节,我们用 .asciz 指令初始化它。我们用 .ascii 来初始化 not_null_terminated,这样我们就不会越界访问数组。由于 extra_padding 需要两个零字节来达到正确的长度,我们先写一个空终止字符串,然后用 .zero 指令再写一个额外的零字节。请注意,这些变量都不需要 .align 指令。字符类型本身是 1 字节对齐的,因此字符数组也是如此。(包含 16 个或更多字符的数组是例外;它们是 16 字节对齐的,就像所有大于或等于 16 字节的数组变量一样。)
.ascii 和 .asciz 指令用来初始化具有静态存储持续时间的对象。接下来,让我们看看 列表 16-6,它初始化了一个非静态数组。
int main(void) {
char letters[6] = "abcde";
return 0;
}
列表 16-6:使用字符串字面量初始化一个非静态数组
列表 16-7 展示了一种在汇编中初始化 letters 的方法,通过将 "abcde" 每次一个字节地复制到栈上。
movb $97, -8(%rbp)
movb $98, -7(%rbp)
movb $99, -6(%rbp)
movb $100, -5(%rbp)
movb $101, -4(%rbp)
movb $0, -3(%rbp)
列表 16-7:在汇编中初始化一个非静态 char 数组
字符'a'、'b'、'c'、'd'和'e'的 ASCII 值分别是97、98、99、100和101。假设letters从栈地址-8(%rbp)开始,列表 16-7 中的指令会将每个字符复制到数组的适当位置。每条movb指令中的b后缀表示它是操作一个字节。
列表 16-8 展示了一种更高效的方法。我们使用一条movl指令初始化字符串的前 4 个字节,然后使用movb指令初始化剩下的 2 个字节。
movl $1684234849, -8(%rbp)
movb $101, -4(%rbp)
movb $0, -3(%rbp)
列表 16-8:在汇编中更高效地初始化一个非静态 char 数组
当我们将字符串的前 4 个字节解释为一个整数时,得到的值是1684234849。(我将在本章后面详细讨论如何得到这个整数。)这个列表与列表 16-7 的效果相同,但节省了几条指令。
接下来,我们来看常量字符串。我们将这些字符串写入目标文件的只读部分,就像浮点常量一样。在 Linux 上,我们将常量字符串存储在.rodata中;在 macOS 上,我们将它们存储在.cstring部分。请看列表 16-9 中的代码片段,它返回指向常量字符串起始位置的指针。
return "A profound statement.";
列表 16-9:返回指向字符串起始位置的指针
我们会为这个字符串生成一个唯一的标签,然后在适当的部分定义它。列表 16-10 给出了结果汇编代码。
.section .rodata
.Lstring.0:
.asciz "A profound statement."
列表 16-10:在汇编中定义一个常量字符串
常量字符串总是以空字符结尾,因为它们不需要适配任何特定的数组维度。一旦我们定义了常量字符串,就可以像访问其他静态对象一样,通过 RIP 相对寻址访问它。在这个特定的例子中,我们想要返回字符串的地址,因此我们将通过以下指令将其加载到 RAX 中:
leaq .Lstring.0(%rip), %rax
最后,让我们看看如何像在示例 16-11 中那样,使用字符串常量初始化静态指针。
static char *ptr = "A profound statement.";
示例 16-11:从字符串常量初始化静态 char * 指针
我们将以与示例 16-10 相同的方式定义字符串。然而,我们不能通过 lea 指令将其加载到 ptr 中。因为 ptr 是静态的,它必须在程序启动之前初始化。幸运的是,.quad 指令不仅接受常量,还接受标签。示例 16-12 演示了如何使用该指令初始化 ptr。
.data
.align 8
ptr:
.quad .Lstring.0
示例 16-12:用静态常量的地址初始化静态变量
指令 .quad .Lstring.0 告诉汇编器和链接器写入 .Lstring.0 的地址。
顺便提一下,实际上可以用这种方式初始化任何静态指针,而不仅仅是字符串指针。虽然我们的实现不接受像 &x 这样的表达式作为静态初始化器,但一个更完整的编译器可能会进行转换
static int x = 10;
static int *ptr = &x;
进入:
.data
.align 4
x:
.long 10
.align 8
ptr:
.quad x
到此为止,你已经了解了如何在 C 和汇编中使用字符串,可以开始编写代码了。第一步是扩展词法分析器,使其能够识别字符串常量和字符常量。
词法分析器
本章将介绍三个新的标记:
char 一个关键字,用来指定字符类型
字符常量 单个字符,例如 'a' 和 '\n'
字符串常量 一系列字符,例如 "Hello, World!"
字符常量由一个字符(如 a)或转义序列(如 \n)组成,并被单引号包围。C 标准第 6.4.4.4 节定义了一组转义序列,用于表示特殊字符。表 16-1 列出了这些转义序列及其 ASCII 码。
表 16-1: 特殊字符的转义序列
| 转义序列 | 描述 | ASCII 码 |
|---|---|---|
| ' | 单引号 | 39 |
| " | 双引号 | 34 |
| ? | 问号 | 63 |
| \ | 反斜杠 | 92 |
| \a | 可听警告 | 7 |
| \b | 退格 | 8 |
| \f | 换页符 | 12 |
| \n | 新行 | 10 |
| \r | 回车 | 13 |
| \t | 水平制表符 | 9 |
| \v | 垂直制表符 | 11 |
换行符、单引号(')和反斜杠(\)字符不能单独作为字符常量出现,必须进行转义。其他任何字符,只要它在源字符集中,即可直接用作字符常量,源字符集是可以出现在源文件中的完整字符集合。
源字符集是由实现定义的,但它必须至少包含C 标准第 5.2.1 节中规定的基本源字符集。在我们的实现中,源字符集包括所有可打印的 ASCII 字符,以及所需的控制字符:换行符、水平制表符、垂直制表符和换页符。你不需要显式地排除这个集合以外的字符;你可以简单地假设它们在源文件中永远不会出现。
表 16-1 中的一些字符,如可听警告(\a)和退格(\b),不在我们的源字符集内,因此它们只能通过转义字符表示。其他字符,包括双引号(")、问号(?)、换页符,以及水平和垂直制表符,都是源字符集的一部分;它们可以在字符常量中进行转义,但不一定需要这样做。例如,字符常量 '?' 和 '?' 是等效的;它们都表示问号字符。换行符、单引号和反斜杠都在源字符集中,但仍然需要进行转义。
我们可以通过列表 16-13 中的真正令人震惊的正则表达式来识别字符常量。
'([^'\\\n]|\\['"?\\abfnrtv])'
列表 16-13:用于识别字符常量的正则表达式
让我们来分解一下。括号表达式中的第一个备选项,字符类 [^'\\n],匹配除了单引号、反斜杠或换行符之外的任何单个字符。我们必须对反斜杠进行转义,因为它是 PCRE 正则表达式以及 C 字符串文字中的控制字符。类似地,我们在这个正则表达式中使用转义序列 \n 来匹配字面量换行符。第二个备选项 \['"?\abfnrtv] 匹配一个转义序列。第一个 \ 匹配一个单独的反斜杠,紧随其后的字符类包含在转义序列中可能跟随的每个字符。整个表达式必须以单引号开始并结束。
字符串文字由可能为空的字符和转义序列组成,且被双引号包裹。单引号可以单独出现在字符串文字中,但双引号必须转义。清单 16-14 展示了用于识别字符串文字的正则表达式。
"([^"\\\n]|\\['"\\?abfnrtv])*"
清单 16-14:用于识别字符串文字的正则表达式
这里,[^"\\n] 匹配任何单个字符,除了双引号、反斜杠或换行符。与清单 16-13 中的情况类似,第二个备选项匹配每个转义序列。我们将 * 量词应用于整个括号表达式,因为它可以重复零次或多次,并且我们用双引号将其界定。
在对字符串文字或字符标记进行词法分析后,你需要解除转义。换句话说,你需要将该标记中的每个转义序列转换为相应的 ASCII 字符。你可以现在进行,也可以在解析器中进行。
标准定义了一些其他类型的字符串文字和字符常量,我们不会实现它们。特别地,我们不会支持十六进制转义序列,如 \xff,八进制转义序列,如 \012,或者多字符常量,如 'ab'。我们也不会支持任何用于非 ASCII 编码的类型或常量,比如宽字符类型、宽字符串文字或 UTF-8 字符串文字。
解析器
我们将从三个方面扩展 AST 定义。首先,我们将添加 char、signed char 和 unsigned char 类型。第二,我们将添加一种新的表达式类型来表示字符串字面量。第三,我们将扩展 const AST 节点,以表示具有字符类型的常量:
const = ConstInt(int) | ConstLong(int) | ConstUInt(int) | ConstULong(int)
| ConstDouble(double) | **ConstChar(int) | ConstUChar(int)**
这些新的常量构造函数有些不寻常,因为它们与实际出现在 C 程序中的常量字面量不对应。像 'a' 这样的字符常量的类型是 int,因此解析器会将它们转换为 ConstInt 节点;它不会使用新的 ConstChar 和 ConstUChar 构造函数。但我们将在稍后需要这些构造函数,尤其是在类型检查期间填充部分初始化的字符数组和在 TACKY 中初始化字符数组时。
清单 16-15 给出了完整的 AST 定义,本章的更改部分已加粗。
program = Program(declaration*)
declaration = FunDecl(function_declaration) | VarDecl(variable_declaration)
variable_declaration = (identifier name, initializer? init,
type var_type, storage_class?)
function_declaration = (identifier name, identifier* params, block? body,
type fun_type, storage_class?)
initializer = SingleInit(exp) | CompoundInit(initializer*)
type = **Char | SChar | UChar |** Int | Long | UInt | ULong | Double
| FunType(type* params, type ret)
| Pointer(type referenced)
| Array(type element, int size)
storage_class = Static | Extern
block_item = S(statement) | D(declaration)
block = Block(block_item*)
for_init = InitDecl(variable_declaration) | InitExp(exp?)
statement = Return(exp)
| Expression(exp)
| If(exp condition, statement then, statement? else)
| Compound(block)
| Break
| Continue
| While(exp condition, statement body)
| DoWhile(statement body, exp condition)
| For(for_init init, exp? condition, exp? post, statement body)
| Null
exp = Constant(const)
**| String(string)**
| Var(identifier)
| Cast(type target_type, exp)
| Unary(unary_operator, exp)
| Binary(binary_operator, exp, exp)
| Assignment(exp, exp)
| Conditional(exp condition, exp, exp)
| FunctionCall(identifier, exp* args)
| Dereference(exp)
| AddrOf(exp)
| Subscript(exp, exp)
unary_operator = Complement | Negate | Not
binary_operator = Add | Subtract | Multiply | Divide | Remainder | And | Or
| Equal | NotEqual | LessThan | LessOrEqual
| GreaterThan | GreaterOrEqual
const = ConstInt(int) | ConstLong(int) | ConstUInt(int) | ConstULong(int)
| ConstDouble(double) | **ConstChar(int) | ConstUChar(int)**
清单 16-15:带有字符类型、字符常量和字符串字面量的抽象语法树
很容易扩展 const,而不是 exp,以包括字符串字面量,但字符串字面量与其他类型的常量足够不同,因此最容易将它们单独定义。例如,类型检查器在处理初始化器时,需要将它们与其他常量区分开来。
解析类型说明符
我们需要扩展parse_type,它将类型说明符的列表转换为一个type AST 节点,以处理字符类型。我不会提供此部分的伪代码,因为逻辑非常简单。如果声明中单独出现char,则表示普通的char类型。如果与unsigned关键字一起出现,则表示unsigned char类型。如果与signed关键字一起出现,则表示signed char类型。像往常一样,类型说明符的顺序不重要。char与其他任何类型说明符一起出现在声明中都是非法的。
解析字符常量
解析器应该将每个字符常量标记转换为具有适当 ASCII 值的ConstInt。例如,应该将标记'a'转换为ConstInt(97),将'\n'转换为ConstInt(10),以此类推。
解析字符串字面量
如果词法分析器没有处理过,解析器应该解除字符串字面量的转义。字符串字面量中的每个字符,包括原始源代码中由转义序列表示的字符,必须在内部表示为一个单字节。否则,我们将在类型检查器中计算不准确的字符串长度,并在运行时使用不正确的值初始化数组。
相邻的字符串字面量标记应该连接成一个单独的String AST 节点。例如,解析器应该将语句
return "foo" "bar";
转换为 AST 节点 Return(String("foobar"))。
将所有内容整合在一起
第 16-16 列表定义了完整的语法,并将本章的更改以粗体显示。
<program> ::= {<declaration>}
<declaration> ::= <variable-declaration> | <function-declaration>
<variable-declaration> ::= {<specifier>}+ <declarator> ["=" <initializer>] ";"
<function-declaration> ::= {<specifier>}+ <declarator> (<block> | ";")
<declarator> ::= "*" <declarator> | <direct-declarator>
<direct-declarator> ::= <simple-declarator> [<declarator-suffix>]
<declarator-suffix> ::= <param-list> | {"[" <const> "]"}+
<param-list> ::= "(" "void" ")" | "(" <param> {"," <param>} ")"
<param> ::= {<type-specifier>}+ <declarator>
<simple-declarator> ::= <identifier> | "(" <declarator> ")"
<type-specifier> ::= "int" | "long" | "unsigned" | "signed" | "double" **|** ❶ **"char"**
<specifier> ::= <type-specifier> | "static" | "extern"
<block> ::= "{" {<block-item>} "}"
<block-item> ::= <statement> | <declaration>
<initializer> ::= <exp> | "{" <initializer> {"," <initializer>} [","] "}"
<for-init> ::= <variable-declaration> | [<exp>] ";"
<statement> ::= "return" <exp> ";"
| <exp> ";"
| "if" "(" <exp> ")" <statement> ["else" <statement>]
| <block>
| "break" ";"
| "continue" ";"
| "while" "(" <exp> ")" <statement>
| "do" <statement> "while" "(" <exp> ")" ";"
| "for" "(" <for-init> [<exp>] ";" [<exp>] ")" <statement>
| ";"
<exp> ::= <unary-exp> | <exp> <binop> <exp> | <exp> "?" <exp> ":" <exp>
<unary-exp> ::= <unop> <unary-exp>
| "(" {<type-specifier>}+ [<abstract-declarator>] ")" <unary-exp>
| <postfix-exp>
<postfix-exp> ::= <primary-exp> {"[" <exp> "]"}
<primary-exp> ::= <const> | <identifier> | "(" <exp> ")" **|** ❷ **{<string>}+**
| <identifier> "(" [<argument-list>] ")"
<argument-list> ::= <exp> {"," <exp>}
<abstract-declarator> ::= "*" [<abstract-declarator>]
| <direct-abstract-declarator>
<direct-abstract-declarator> ::= "(" <abstract-declarator> ")" {"[" <const> "]"}
| {"[" <const> "]"}+
<unop> ::= "-" | "~" | "!" | "*" | "&"
<binop> ::= "-" | "+" | "*" | "/" | "%" | "&&" | "||"
| "==" | "!=" | "<" | "<=" | ">" | ">=" | "="
<const> ::= <int> | <long> | <uint> | <ulong> | <double> **|** ❸ **<char>**
<identifier> ::= ? An identifier token ?
**<string> ::= ? A string token ?** ❹
<int> ::= ? An int token ?
**<char> ::= ? A char token ?** ❺
<long> ::= ? An int or long token ?
<uint> ::= ? An unsigned int token ?
<ulong> ::= ? An unsigned int or unsigned long token ?
<double> ::= ? A floating-point constant token ?
第 16-16 列表:包含字符类型、字符常量和字符串字面量的语法
语法中加粗的部分对应于我们刚刚讨论的解析器的三个更改。现在语法包括了"char"类型说明符❶以及
类型检查器
在大多数情况下,类型检查器可以像处理其他整数类型一样处理字符类型。它们遵循相同的类型规则并支持相同的操作。整数提升是这一模式的唯一例外,因此我们将在本节中实现它们。我们还将为字符类型引入静态初始化器。
字符串字面量的类型检查更加复杂,尤其是当它们出现在初始化器中时。我们需要跟踪每个字符串是否应该直接使用或转换为指针,并且需要确定哪些字符串应以空字节终止。我们将向符号表中添加一些新的构造来表示这些情况。
字符
我们将会在常规算术转换中将字符类型提升为int。清单 16-17 展示了如何在get_common_type中执行这种提升。
get_common_type(type1, type2):
if type1 is a character type:
type1 = Int
if type2 is a character type:
type2 = Int
`--snip--`
清单 16-17:在常规算术转换中应用整数提升
在提升了两个操作数的类型后,我们将像往常一样找到它们的公共类型。我们还将提升一元操作符-和~的操作数。清单 16-18 演示了如何提升一个取反操作数。
typecheck_exp(e, symbols):
match e with
| `--snip--`
| Unary(Negate, inner) ->
typed_inner = typecheck_and_convert(inner, symbols)
inner_t = get_type(typed_inner)
if inner_t is a pointer type:
fail("Can't negate a pointer")
❶ if inner_t is a character type:
typed_inner = convert_to(typed_inner, Int)
unary_exp = Unary(Negate, typed_inner)
❷ return set_type(unary_exp, get_type(typed_inner))
清单 16-18:将整数提升应用于取反表达式
首先,我们确保操作数不是指针(我们在第十四章引入了此验证)。然后,我们应用整数提升。我们检查操作数是否是字符类型之一❶;如果是,我们将其转换为Int,然后对提升后的值进行取反。表达式的结果与其提升后的操作数具有相同的类型❷。我们将以相同的方式处理~,所以这里不提供该部分的伪代码。
在进行类型检查时,我们总是会将字符识别为整数类型。例如,我们会接受字符作为~和%表达式的操作数,以及指针运算中的索引。因为所有整数类型也是算术类型,所以我们会允许字符类型与任何其他算术类型之间的隐式转换,像在convert_by_assignment中那样。
我们将为字符类型添加两个静态初始化器。清单 16-19 给出了更新后的static_init定义。
static_init = IntInit(int) | LongInit(int) | UIntInit(int) | ULongInit(int)
**| CharInit(int) | UCharInit(int)**
| DoubleInit(double) | ZeroInit(int bytes)
清单 16-19:为字符类型添加静态初始化器
由于signed char和普通的char都是有符号类型,我们将使用CharInit来初始化它们。我们将根据在第十一章和第十二章中讨论的类型转换规则,将每个初始化器转换为它所初始化的类型。例如,如果一个unsigned char的值大于 255,我们将其值取模 256。
最后,我们将对非静态数组的复合初始化器进行类型检查的方式做一个小的、直接的更新。(我们将在下一节处理初始化数组的字符串字面量,作为一个单独的情况。)在前一章中,我们通过用零填充剩余元素来处理部分初始化的数组。我建议编写一个zero_initializer辅助函数来生成这些填充零的初始化器。现在,我们可以扩展该函数,以便输出ConstChar和ConstUChar来将字符类型的元素初始化为零。
表达式中的字符串字面量
当我们在表达式中遇到字符串字面量时,而不是在数组初始化器中,我们会将其标注为适当大小的char数组。清单 16-20 展示了如何在typecheck_exp中处理字符串字面量。
typecheck_exp(e, symbols):
match e with
| `--snip--`
| String(s) -> return set_type(e, Array(Char, length(s) + 1))
清单 16-20:类型检查字符串字面量
请注意,数组大小会考虑一个终止的空字节。类型检查器已经在 typecheck_and_convert 中处理了数组到指针的隐式转换。现在 typecheck_and_convert 还会将字符串字面量转换为指针,因为它们也具有数组类型。
接下来,我们将更新类型检查器,使其识别 String 表达式为左值,同时识别变量、下标运算符和解引用表达式。这使得程序可以使用 & 运算符获取它们的地址。
这已经处理了普通表达式中的字符串字面量;现在我们将对初始化器中的字符串字面量进行类型检查。
字符串字面量初始化非静态变量
通常,我们使用 typecheck_and_convert 对 SingleInit 构造进行类型检查,该函数将数组类型的值转换为指针。这种方法正确处理了初始化指针的字符串字面量。但当字符串字面量用于初始化数组时,我们会采用不同的方式进行类型检查。Listing 16-21 展示了如何处理这种情况。
typecheck_init(target_type, init, symbols):
match target_type, init with
| Array(elem_t, size), SingleInit(String(s)) ->
❶ if elem_t is not a character type:
fail("Can't initialize a non-character type with a string literal")
❷ if length(s) > size:
fail("Too many characters in string literal")
❸ return set_type(init, target_type)
| `--snip--`
Listing 16-21:类型检查初始化数组的字符串字面量
首先,我们确保目标类型是字符数组,因为字符串字面量不能初始化任何其他类型的数组 ❶。然后,我们验证字符串的长度是否适合初始化该数组 ❷。最后,我们使用目标类型注释初始化器 ❸。稍后我们将使用这个注释来确定需要向字符串附加多少个空字节。
字符串字面量初始化静态变量
我们的最终任务是处理初始化静态变量的字符串字面量。我们需要在符号表中表示两种新的初始值:ASCII 字符串(对应于 .ascii 和 .asciz 指令)和静态对象的地址(对应于像 .quad .Lstring.0 这样的指令)。我们将再次更新 static_init,以包含这两种类型的初始化器。Listing 16-22 给出了包含这两个新增项的更新定义。
static_init = IntInit(int) | LongInit(int)
| UIntInit(int) | ULongInit(int)
| CharInit(int) | UCharInit(int)
| DoubleInit(double) | ZeroInit(int bytes)
**| StringInit(string, bool null_terminated)**
**| PointerInit(string name)**
Listing 16-22:为字符串和指针添加静态初始化器
StringInit 定义了一个 ASCII 字符串初始化器。我们将用它来初始化常量字符串和字符数组。null_terminated 参数指定是否在末尾包含空字节;我们将使用此参数在代码生成时在 .ascii 和 .asciz 指令之间进行选择。PointerInit 使用另一个静态对象的地址来初始化指针。
我们还将开始在符号表中跟踪常量字符串。清单 16-23 给出了更新后的 identifier_attrs 定义,其中包括常量。
identifier_attrs = FunAttr(bool defined, bool global)
| StaticAttr(initial_value init, bool global)
**| ConstantAttr(static_init init)**
| LocalAttr
清单 16-23:在符号表中跟踪常量
与变量不同,变量可能未初始化、暂时初始化或通过值列表初始化,常量则仅通过单一值进行初始化。常量也不需要 global 标志,因为我们永远不会定义全局常量。
既然我们已经扩展了 static_init 和 identifier_attrs,让我们讨论如何处理字符数组和 char 指针的字符串初始化器。
使用字符串字面量初始化静态数组
如果字符串字面量初始化了静态数组,我们首先验证数组的类型:确保数组元素是字符类型,并且数组足够大以容纳该字符串。(这与我们在 清单 16-21 中对非静态数组执行的验证相同。)然后,我们将字符串字面量转换为 StringInit 初始化器,如果数组有足够的空间容纳终止的空字节,则设置 null_terminated 标志。如果我们需要用额外的空字节填充它,则将 ZeroInit 添加到初始化器列表中。例如,我们将转换声明
static char letters[10] = "abc";
对应符号表条目见 清单 16-24。
name="letters"
type=Array(Char, 10)
attrs=StaticAttr(init=Initial([StringInit("abc", True), ZeroInit(6)]),
global=False)
清单 16-24:由字符串字面量初始化的数组的符号表条目
这个条目用以空字符终止的字符串 "abc" 初始化 letters,接着是 6 字节的零。
使用字符串字面量初始化静态指针
如果一个字符串字面量初始化了一个类型为 char * 的静态变量,我们将在符号表中创建两个条目。第一个定义了字符串本身,第二个定义了指向该字符串的变量。让我们来看一个例子:
static char *message = "Hello!";
首先,我们为常量字符串 "Hello!" 生成一个标识符;假设这个标识符是 "string.0"。然后,我们将 Listing 16-25 中显示的条目添加到符号表中。
name="string.0"
type=Array(Char, 7)
attrs=ConstantAttr(StringInit("Hello!", True))
Listing 16-25: 在符号表中定义一个常量字符串
这个标识符必须是全局唯一的,并且必须是语法上有效的汇编标签。换句话说,它应遵循我们为浮点常量生成标识符时相同的约束条件。由于 Listing 16-25 定义了一个常量字符串,我们使用新的 ConstantAttr 结构,并用以空字符结尾的字符串 "Hello!" 来初始化它。
接着,当我们将 message 本身添加到符号表时,我们用一个指向刚才添加的符号的指针来初始化它。Listing 16-26 显示了 message 的符号表条目。
name="message"
type=Pointer(Char)
attrs=StaticAttr(init=Initial([PointerInit("string.0")]), global=False)
Listing 16-26: 在符号表中定义指向字符串的静态指针
如果一个字符串字面量初始化了一个指向除 char 类型以外的指针,我们将抛出错误。(请注意,typecheck_init 已经在非静态情况下捕获了这个错误。)即使是使用字符串字面量初始化一个 signed char * 或 unsigned char * 也是不合法的。这与普通的类型转换规则一致:字符串字面量的类型是 char ,而我们不能隐式地从一种指针类型转换到另一种。相反,一个字符串字面量可以初始化任何字符类型的数组*,因为将每个单独的字符从一种字符类型隐式转换到另一种字符类型是合法的。
到目前为止,我们已经为所有出现在静态初始化器中的字符串创建了符号表条目。在 TACKY 生成过程中,我们还将把程序中的所有其他常量字符串添加到符号表中。 ### TACKY 生成
当我们将程序转换为 TACKY 时,我们可以像处理其他整数一样处理字符。特别地,我们将使用现有的类型转换指令实现从字符类型到其他类型的转换。例如,我们将使用< samp class="SANS_TheSansMonoCd_W5Regular_11">DoubleToUInt指令实现从double到unsigned char的转换,我们将使用SignExtend指令实现从char到int的转换。然而,处理字符串字面量需要稍微多一点的工作。
字符串字面量作为数组初始化器
在类型检查器中,我们处理了初始化静态数组的字符串字面量。现在我们将对具有自动存储持续时间的数组执行相同的操作。
正如我们在本章前面所看到的,这里有两种选择。更简单的选择是一次初始化一个字符的数组。更高效的选择是一次初始化整个 4 字节或 8 字节的块。无论哪种方式,我们都会通过一系列的CopyToOffset指令将字符串复制到数组中。
让我们来详细讲解这两种选择。我们将使用清单 16-6 中的初始化器作为一个示例,具体如下:
int main(void) {
char letters[6] = "abcde";
return 0;
}
当我们第一次查看这个例子时,我们了解到'a'、'b'、'c'、'd'和'e'的 ASCII 值分别为97、98、99、100和101。使用简单的逐字节初始化方法,我们将用 TACKY 指令初始化letters,具体见清单 16-27。
CopyToOffset(Constant(ConstChar(97)), "letters", 0)
CopyToOffset(Constant(ConstChar(98)), "letters", 1)
CopyToOffset(Constant(ConstChar(99)), "letters", 2)
CopyToOffset(Constant(ConstChar(100)), "letters", 3)
CopyToOffset(Constant(ConstChar(101)), "letters", 4)
CopyToOffset(Constant(ConstChar(0)), "letters", 5)
清单 16-27:在 TACKY 中逐字节初始化一个非静态数组
使用更高效的方法,我们将用一个 4 字节的整数初始化letters,然后跟随 2 个单独的字节:
CopyToOffset(Constant(ConstInt(1684234849)), "letters", 0)
CopyToOffset(Constant(ConstChar(101)), "letters", 4)
CopyToOffset(Constant(ConstChar(0)), "letters", 5)
为了得出整数1684234849,我们取 4 个字节97、98、99和100,并将它们解释为一个单一的小端整数。以十六进制表示,这些字节为0x61、0x62、0x63和0x64。在小端整数中,第一个字节是最低有效位,所以将这个字节序列解释为一个整数给我们的是0x64636261,即十进制的1684234849。无论你在什么语言中实现编译器,它可能都有用于操作字节缓冲区并将其解释为整数的实用函数,因此你不需要自己实现这些复杂的逻辑。
为了同时初始化八个字符,我们将使用ConstLong,而不是ConstInt。我们需要小心不要超出我们正在初始化的数组的边界;在这个例子中,用两个 4 字节的整数来初始化letters是不正确的,因为它会覆盖相邻的值。
你可以选择使用这些方法中的任何一种;它们都是同样正确的。无论哪种方式,都需要确保在字符串的末尾初始化正确数量的空字节。在类型检查器中,你对每个初始化器,包括字符串字面量,都进行了类型注解。现在,你将利用这些类型信息来确定需要包含多少个空字节。如果字符串字面量比它初始化的数组长,只复制数组能够容纳的字符数量。换句话说,去掉空字节。如果字符串字面量太短,就在数组的其余部分填充零。
表达式中的字符串字面量
当我们遇到一个数组初始化器外的字符串字面量时,我们会将其添加到符号表中作为常量字符串。然后,我们将使用它的标识符作为 TACKY 中的Var。让我们回顾一下示例 16-9,它返回指向字符串中第一个字符的指针:
return "A profound statement.";
解析器和类型检查器将其转换为以下 AST 节点:
Return(AddrOf(String("A profound statement.")))
要将此 AST 节点转换为 TACKY,我们首先在符号表中定义"A profound statement.":
name="string.1"
type=Array(Char, 22)
attrs=ConstantAttr(StringInit("A profound statement.", True))
这个条目与我们在类型检查器中定义的常量字符串没有什么不同。它有一个全局唯一的、自动生成的标签。它是一个 char 数组,足够大以容纳整个字符串,包括终止的空字节。它通过新的 ConstantAttr 结构进行初始化,因为我们最终会将其存储在只读内存中。
现在我们可以引用刚刚定义的标识符—在这个例子中是 string.1—来加载字符串的地址:
GetAddress(Var("string.1"), Var("tmp2"))
Return(Var("tmp2"))
简而言之,我们像使用其他数组类型的符号一样使用 string.1。
TACKY 中的顶层常量
在将符号表中的条目转换为顶层 TACKY 定义时,我们需要考虑所有这些新的常量字符串。汇编 AST 已经有一个顶层常量结构。现在我们将为 TACKY 添加相应的结构:
top_level = `--snip--` | StaticConstant(identifier, type t, static_init init)
当我们从符号表生成顶层 TACKY 定义时,我们会为符号表中的每个常量生成一个 StaticConstant,就像我们为每个静态变量生成一个 StaticVariable 一样。在遍历符号表之前,请确保将函数定义转换为 TACKY;否则,您将错过在此过程中添加到符号表中的常量字符串。
列表 16-28 总结了 TACKY IR,并将本章新增部分加粗显示。
program = Program(top_level*)
top_level = Function(identifier, bool global, identifier* params, instruction* body)
| StaticVariable(identifier, bool global, type t, static_init* init_list)
**| StaticConstant(identifier, type t, static_init init)**
instruction = Return(val)
| SignExtend(val src, val dst)
| Truncate(val src, val dst)
| ZeroExtend(val src, val dst)
| DoubleToInt(val src, val dst)
| DoubleToUInt(val src, val dst)
| IntToDouble(val src, val dst)
| UIntToDouble(val src, val dst)
| Unary(unary_operator, val src, val dst)
| Binary(binary_operator, val src1, val src2, val dst)
| Copy(val src, val dst)
| GetAddress(val src, val dst)
| Load(val src_ptr, val dst)
| Store(val src, val dst_ptr)
| AddPtr(val ptr, val index, int scale, val dst)
| CopyToOffset(val src, identifier dst, int offset)
| Jump(identifier target)
| JumpIfZero(val condition, identifier target)
| JumpIfNotZero(val condition, identifier target)
| Label(identifier)
| FunCall(identifier fun_name, val* args, val dst)
val = Constant(const) | Var(identifier)
unary_operator = Complement | Negate | Not
binary_operator = Add | Subtract | Multiply | Divide | Remainder | Equal | NotEqual
| LessThan | LessOrEqual | GreaterThan | GreaterOrEqual
列表 16-28:将静态常量添加到 TACKY IR
此时,您的 TACKY 生成过程应该已经可以顺利进行:它可以处理单个字符、隐式转换为指针的字符串字面量以及初始化数组的字符串字面量。
汇编生成
在此阶段,我们不会做太复杂的操作。首先,我们将把对单个字符的操作转换为汇编。这将需要对汇编 AST 进行一些修改。然后,我们将处理 TACKY StaticConstant 结构,并将常量字符串添加到后端符号表中。
字符操作
大多数指令支持 1 字节操作数以及长字和四字。像指令 movb 和 andb 中的 b 后缀表示指令作用于单个字节。我们将引入一个新的 Byte 汇编类型来表示这种新的操作数大小:
assembly_type = Byte | `--snip--`
我们将把char、signed char和unsigned char类型转换为Byte。通用寄存器也有 1 字节的别名;例如,%al是 RAX 的 1 字节别名。幸运的是,我们的代码生成阶段已经支持这些别名。
除了添加Byte类型外,我们还需要正确地转换字符类型。你可以使用movz指令将 1 字节的值零扩展到更大的类型。此指令带有两个字母的后缀,用于指示源和目标的类型。movzbl指令将字节扩展为长字,movzbq将字节扩展为四字。(你还可以使用movzwl或movzwq将 2 字节的字扩展为更大的类型,但我们不使用 2 字节操作数。)我们将在汇编抽象语法树中使用现有的MovZeroExtend指令来表示movz,但我们将添加两个操作数的类型信息。
MovZeroExtend(**assembly_type src_type, assembly_type dst_type,** operand src, operand dst)
如果src_type是Byte,我们最终将生成带有正确后缀的movz指令。如果src_type是Longword,我们将在修复阶段将其重写为普通的mov指令,就像在前面的章节中一样。
要将一个字节符号扩展到更大的类型,我们将使用现有的Movsx指令。此指令还可以使用后缀来指定源和目标的类型:movsbl将字节扩展到长字,movsbq将字节扩展到四字,movslq将长字扩展到四字。我们还将在汇编抽象语法树中为此指令添加类型信息:
Movsx(**assembly_type src_type, assembly_type dst_type,** operand src, operand dst)
你可以使用 movb 指令将一个较大的整数截断为单字节,就像你可以使用 movl 将四字长截断为长字一样。请注意,当你使用 movb 指令将一个值复制到寄存器时,寄存器的高字节不会被清零。这不是问题;无论我们操作的是单字节还是长字,我们只使用寄存器中存储值的部分,忽略寄存器的高字节。
最后,让我们考虑如何在 double 和字符类型之间进行转换。没有汇编指令可以直接将 double 转换为 1 字节整数或反之。相反,我们会先将其转换为 int,作为中间步骤。为了将一个 double 转换为任何字符类型,我们首先将其转换为 int,然后进行截断,如 列表 16-29 所示。
Cvttsd2si(Longword, src, Reg(AX))
Mov(Byte, Reg(AX), dst)
列表 16-29:将一个 double 转换为字符类型
列表 16-30 给出了将一个 unsigned char 转换为 double 的汇编代码。我们将其零扩展为一个 int,然后将结果转换为一个 double。
MovZeroExtend(Byte, Longword, src, Reg(AX))
Cvtsi2sd(Longword, Reg(AX), dst)
列表 16-30:将一个 unsigned char 转换为 double
而要将任意符号字符类型转换为 double,我们将首先将其符号扩展为一个 int,正如 列表 16-31 所示。
Movsx(Byte, Longword, src, Reg(AX))
Cvtsi2sd(Longword, Reg(AX), dst)
列表 16-31:将一个 char 或 signed char 转换为 double
接下来,我们将处理第二个任务:将顶级常量从 TACKY 转换为汇编。
顶级常量
处理 TACKY StaticConstant 非常简单:我们只需将其转换为汇编的 StaticConstant。你还需要将符号表中的每个常量字符串转换为后端符号表中的等效条目,就像处理变量一样。当你将常量字符串添加到后端符号表时,将其 is_static 属性设置为 True。如果你的后端符号表包含 is_constant 属性,也将其设置为 True。(请记住,is_constant 是在 第十三章 中添加的可选项;它告诉我们在代码生成时何时使用局部标签。)
从 TACKY 到汇编的完整转换
列表 16-32 显示了本章对汇编 AST 的扩展。
program = Program(top_level*)
assembly_type = **Byte |** Longword | Quadword | Double | ByteArray(int size, int alignment)
top_level = Function(identifier name, bool global, instruction* instructions)
| StaticVariable(identifier name, bool global, int alignment, static_init* init_list)
| StaticConstant(identifier name, int alignment, static_init init)
instruction = Mov(assembly_type, operand src, operand dst)
| Movsx(**assembly_type src_type, assembly_type dst_type,** operand src, operand dst)
| MovZeroExtend(**assembly_type src_type, assembly_type dst_type,** operand src,
operand dst)
| Lea(operand src, operand dst)
| Cvttsd2si(assembly_type dst_type, operand src, operand dst)
| Cvtsi2sd(assembly_type src_type, operand src, operand dst)
| Unary(unary_operator, assembly_type, operand)
| Binary(binary_operator, assembly_type, operand, operand)
| Cmp(assembly_type, operand, operand)
| Idiv(assembly_type, operand)
| Div(assembly_type, operand)
| Cdq(assembly_type)
| Jmp(identifier)
| JmpCC(cond_code, identifier)
| SetCC(cond_code, operand)
| Label(identifier)
| Push(operand)
| Call(identifier)
| Ret
unary_operator = Neg | Not | Shr
binary_operator = Add | Sub | Mult | DivDouble | And | Or | Xor
operand = Imm(int) | Reg(reg) | Pseudo(identifier) | Memory(reg, int) | Data(identifier)
| PseudoMem(identifier, int) | Indexed(reg base, reg index, int scale)
cond_code = E | NE | G | GE | L | LE | A | AE | B | BE
reg = AX | CX | DX | DI | SI | R8 | R9 | R10 | R11 | SP | BP
| XMM0 | XMM1 | XMM2 | XMM3 | XMM4 | XMM5 | XMM6 | XMM7 | XMM14 | XMM15
列表 16-32:带字节操作数的汇编 AST
表 16-2 至 16-5 概述了从 TACKY 到汇编的最新更新,新增的构造和现有构造的转换更改已加粗。
表 16-2: 将顶级 TACKY 构造转换为汇编
| TACKY 顶级构造 | 汇编顶级构造 |
|---|---|
| StaticConstant(name, t, init) | StaticConstant(name, <t 的对齐方式>, init) |
表 16-3: 将 TACKY 指令转换为汇编
| TACKY 指令 | 汇编指令 | |
|---|---|---|
| ZeroExtend(src, dst) | MovZeroExtend( |
|
| SignExtend(src, dst) | Movsx( |
|
| Truncate(src, dst) | Mov( |
|
| IntToDouble(src, dst) | char 或 signed char | Movsx(Byte, Longword, src, Reg( |
| int 或 long | Cvtsi2sd( |
|
| DoubleToInt(src, dst) | char 或 signed char | Cvttsd2si(Longword, src, Reg( |
| 整数 或 长整数 | Cvttsd2si(<dst 类型>, src, dst) | |
| UIntToDouble(src, dst) | 无符号字符 | MovZeroExtend(Byte, 长字, src, 寄存器( |
| 无符号整数 | MovZeroExtend(长字, 四字长, src, 寄存器( |
| 无符号长整数 | Cmp(四字长, 常量(0), src) JmpCC(L,
Cvtsi2sd(四字长, src, dst)
Jmp(
标签(
Mov(四字长, src, 寄存器(
Mov(四字长, 寄存器(
Unary(Shr, 四字长, 寄存器(
二进制(And, Quadword, Imm(1), Reg(
二进制(Or, Quadword, Reg(
Cvtsi2sd(Quadword, Reg(
二进制(Add, Double, dst, dst)
标签(
| DoubleToUInt(src, dst) | 无符号字符型 | Cvttsd2si(长整型, src, Reg( |
|---|---|---|
| 无符号整数 | Cvttsd2si(Quadword, src, Reg( |
| | 无符号长整型 | Cmp(Double, Data(
Cvttsd2si(Quadword, src, dst)
跳转(
标签(
Mov(Double, src, Reg(
二进制(Sub, Double, Data(
Cvttsd2si(Quadword, Reg(
Mov(Quadword, Imm(9223372036854775808), Reg(
Binary(Add, Quadword, Reg(
Label(
并添加一个顶级常量:
StaticConstant(
DoubleInit(9223372036854775808.0)) |
表 16-4: 将 TACKY 操作数转换为汇编
| TACKY 操作数 | 汇编操作数 |
|---|---|
| Constant(ConstChar(int)) | Imm(int) |
| Constant(ConstUChar(int)) | Imm(int) |
表 16-5: 类型转换为汇编代码
| 源类型 | 汇编类型 | 对齐方式 |
|---|---|---|
| Char | Byte | 1 |
| SChar | Byte | 1 |
| UChar | Byte | 1 |
接下来,我们来看看伪操作数替换和指令修复。这两个过程的更新都很直接。
伪操作数替换
我们将在堆栈上为每个 Byte 对象分配 1 字节的空间。我们不需要担心将这些向下舍入到正确的对齐方式,因为它们都是 1 字节对齐的。
这一阶段不需要任何专门的逻辑来处理常量字符串。我们已经在后端符号表中记录了它们具有静态存储持续时间。现在,我们将像访问其他静态对象一样访问它们,使用Data操作数。
指令修复
movz指令的目标必须是一个寄存器,且源操作数不能是立即数。如果MovZeroExtend指令的源操作数大小为 1 字节且其源或目标无效,我们将按照通常的模式重写它。例如,我们将重写
movzbl $10, -4(%rbp)
如下所示:
movb $10, %r10b
movzbl %r10b, %r11d
movl %r11d, -4(%rbp)
如果其源操作数是一个长字,我们将用一个或多个mov指令替换它,像前几章中那样。
如果movb指令的源操作数是一个无法放入单个字节的立即数,我们将对其进行模 256 操作。例如,我们将重写
movb $258, %al
如下所示:
movb $2, %al
这是我们在第十一章中介绍的相同模式,用于处理源操作数为 8 字节立即数的movl指令。
代码生成
代码生成阶段需要支持字符串常量、指针初始化器以及其他一些更改。我们将每个StringInit发射为一个.ascii或.asciz指令,具体取决于它是否包含一个空字节。ASCII 字符串中的双引号、反斜杠和换行符必须进行转义。要转义这些字符,您可以使用"、\和\n转义序列,或者使用三位八进制转义序列来指定它们的 ASCII 值。例如,反斜杠字符的 ASCII 码是92,或八进制表示为134,因此可以使用转义序列\134表示它。你还可以转义其他特殊字符,但不需要。某些转义序列,如\a,在 C 语言中有效,但在汇编中无效,因此八进制转义序列是转义任意字符的最安全方式。
我们将把每个 PointerInit 作为 .quad 指令进行生成,并跟随我们希望指向的标签。我们将把 CharInit 和 UCharInit 转换为 .byte 指令,它的工作方式与 .long 和 .quad 完全相同。当你生成一个 1 字节对齐的对象时,你可以选择包括 .align 指令,或者省略它。每个对象默认至少是 1 字节对齐的,因此指定 1 字节对齐没有影响。
在 Linux 上,字符串常量将与浮点常量一起存储在 .rodata 区段。在 macOS 上,它们将存储在 .cstring 区段。如果你使用本地标签(以 .L 或 L 前缀开始)表示浮点常量,你也应该将它们用于字符串常量。用于为 Data 操作数添加此前缀的逻辑不会改变;我们仍然会在后端符号表中查找每个 Data 操作数,如果其 is_constant 属性为真,则添加该前缀。
movz 和 movsx 指令应包括后缀,以指示源和目标类型。其他指令在处理字节时应包括 b 后缀。表 16-6 至 16-9 总结了代码生成过程中的最新更新;新构造和现有构造的变化已加粗显示。
表 16-6: 顶级汇编构造的格式化
| 汇编顶级构造 | 输出 | |
|---|---|---|
| StaticConstant(name, alignment, init) | Linux |
.section .rodata
<alignment-directive>
<name>:
<init>
|
| macOS(8 字节对齐的数字常量) |
|---|
.literal8
.balign 8
<name>:
<init>
|
| macOS(16 字节对齐的数字常量) |
|---|
.literal16
.balign 16
<name>:
<init>
.quad 0
|
| macOS(字符串常量) |
|---|
.cstring
<name>:
<init>
|
表 16-7: 格式化静态初始化器
| 静态初始化器 | 输出 |
|---|---|
| CharInit(0) | .zero 1 |
| CharInit(i) | .byte |
| UCharInit(0) | .zero 1 |
| UCharInit(i) | .byte |
| StringInit(s, True) | .asciz " |
| StringInit(s, False) | .ascii " |
| PointerInit(label) | .quad |
表 16-8: 格式化汇编指令
| 汇编指令 | 输出 |
|---|---|
| Movsx(src_t, dst_t, src, dst) |
movs<src_t><dst_t> <src>, <dst>
|
| MovZeroExtend(src_t, dst_t, src, dst) |
|---|
movz<src_t><dst_t> <src>, <dst>
|
表 16-9: 汇编类型的指令后缀
| 汇编类型 | 指令后缀 |
|---|---|
| 字节 | b |
现在您的编译器支持字符串和字符了!你仍然需要运行本章的测试,以确保这些功能已正确实现,但首先,我们将尝试几个例子。
再次见面,世界!
在 第九章 中,我们逐个字符地打印了“Hello, World!”。现在我们可以使用 puts 标准库函数来编写一个更传统的“Hello, World!”程序,该函数具有如下签名:
int puts(const char *s);
由于我们不支持 const,我们将不使用它来声明 puts。 清单 16-33 展示了我们新的“Hello, World!”程序。
int puts(char *c);
int main(void) {
puts("Hello, World!");
return 0;
}
清单 16-33: 使用 puts 打印字符串
这段代码并不完全合法,因为 puts 的声明与标准库中的定义不兼容。然而,尽管有这一点小小的规则破坏,程序应该能够正常工作。编译并运行它,然后打印一条消息到标准输出:
$ **./hello_world**
Hello, World!
如果你想更加疯狂,可以甚至编译 清单 16-34,它从标准输入读取数据。
int getchar(void);
int puts(char *c);
char *strncat(char *s1, char *s2, unsigned long n);
char *strcat(char *s1, char *s2);
unsigned long strlen(char *s);
❶ static char name[30];
❷ static char message[40] = "Hello, ";
int main(void) {
puts("Please enter your name: ");
int idx = 0;
while (idx < 29) {
int c = getchar();
// treat EOF, null byte, or line break as end of input
if (c <= 0 || c == '\n') {
break;
}
name[idx] = c;
idx = idx + 1;
}
❸ name[idx] = 0; // add terminating null byte to name
// append name to message, leaving space for null byte
// and exclamation point
strncat(message, name, 40 - strlen(message) - 2);
// append exclamation point
strcat(message, "!");
puts(message);
return 0;
}
清单 16-34: 从标准输入读取
就像 清单 16-33 声明了没有 const 限定符的 puts 一样,这个程序声明了多个库函数,且没有使用它们通常的限定符,包括 const 和 restrict。我们使用 getchar 从标准输入中逐个字符读取,因为我们的编译器不容易处理使用 C 标准库函数从标准输入读取的其他方式。
清单 16-34 声明了两个静态数组:name 和 message。因为 name 是静态的,但没有显式的初始化器,它会被初始化为全零 ❶。message 的开头被初始化为字符串 "Hello, ",其余部分用空字节填充 ❷。这个程序调用 puts 输出提示,然后在一个循环中调用 getchar 来逐个字符地读取用户的响应到 name 数组中。当 getchar 返回负数(表示文件结束或错误)、空字节或换行符,或者读取了 29 个字符后,我们退出循环(先满足者)。(我们检查结果是否为负,而不是像普通 C 程序那样将其与 EOF 宏进行比较,因为我们不能包含 <stdio.h>,其中定义了 EOF)。最多读取 29 个字符是为了给终止的空字节留出空间,在退出循环后我们将其添加到 name ❸。
调用 strncat 将用户的名字追加到 message,随后调用 strcat 添加一个感叹号。最后,第二次调用 puts 将整个消息写入标准输出。你的编译器应该能够处理这个清单,快去试试看吧!我会用这个程序向我的狗 Arlo 打个招呼。(我答应过它,至少要在这本书里提到它一次。)
$ **./hello_name**
Please enter your name:
**Arlo**
Hello, Arlo!
如果这个程序正常工作,你就准备好运行完整的测试套件了。
总结
现在你的编译器可以处理与文本相关的程序了。在这一章中,你学习了如何词法分析字符串字面量和字符常量,并扩展了类型检查器,以区分常量字符串和初始化数组的字符串字面量。你还介绍了在符号表和 TACKY IR 中定义常量的新方法。在下一章中,你将介绍两种更方便动态分配内存的特性:sizeof 运算符和 void 类型。

描述
第十七章:17 支持动态内存分配

在第二部分中,你已经编译了调用越来越多标准库函数的程序。在第一部分的结尾时,你的编译器只支持那些参数和返回值类型为int的函数,如putchar。现在,你可以编译调用像fmax这样的浮点数学函数和像puts这样的字符串处理函数的程序。在本章中,你将实现调用标准库中一个非常重要部分——内存管理函数所需的剩余功能。这些函数包括动态分配内存的malloc、calloc和aligned_alloc;用于释放动态分配内存的free;以及释放一个内存块并重新分配另一个相同内容的内存块的realloc。
要编译声明并调用这些函数的程序,你需要实现void类型。直到现在,我们只用void关键字来指定空的参数列表;现在我们将其作为一个合适的类型说明符来处理。在 C 语言中,void *表示一个没有特定类型的内存块的地址;所有分配内存的标准库函数都返回这种类型。单独的void类型也非常有用。例如,你可以用它来声明那些不返回值的函数,像free。除了void外,我们还将实现sizeof运算符,它用来获取类型或对象的大小。C 程序常常使用sizeof来计算需要分配多少字节的内存。
本章的非秘密议程是让你为第十八章中实现结构体类型做准备。现实中的 C 程序通常将结构体存储在动态分配的内存中,这也是该章节许多测试的做法。我们对类型检查器所做的更改,在第十八章中也会派上用场,因为一些适用于void的类型规则也适用于结构体类型。
void 类型
C 标准(第 6.2.5 节,第 19 段)给出了以下相当神秘的void定义:“void类型包含一个空的值集合;它是一个不完整的对象类型,无法完成。”我们稍后会详细讨论“不可完成的对象类型”是什么意思。现在,主要的观点是void是一种没有值的类型。你不能用这个类型做太多事情,但它确实有一些用处。
如果函数不返回任何值,你可以给它指定一个void返回类型。为了让函数具有void返回类型,你可以使用一个没有表达式的return语句,像示例 17-1 中那样。
void return_nothing(void) {
return;
}
示例 17-1:一个具有 void 返回类型的函数
正如示例 17-2 所示,你还可以完全省略return语句。在这种情况下,当你到达函数体的末尾时,函数将返回。
void perform_side_effect(void) {
extern int some_variable;
some_variable = 100;
}
示例 17-2:一个 没有 return 语句的 void 函数
void 表达式是一个类型为void的表达式;它没有值,但你可以评估它的副作用。产生 void 表达式的方式有三种。首先,你可以调用一个void函数。其次,你可以评估一个条件表达式,其两个分支都是void类型:
flag ? perform_side_effect() : return_nothing();
第三,你可以将一个值转换为void类型:
(void) (1 + 1);
在这里,强制转换为 void 对程序执行没有影响;它的唯一目的是告诉编译器和程序员,表达式的值应该被丢弃。这是消除编译器关于未使用值警告的常见方法。
如果你特别热衷于遵循 C 语言的类型规则,你也可以像在示例 17-3 中那样,对代码进行强制转换为 void,以使两个条件分支的类型一致。
int i = 0;
flag ? perform_side_effect() : (void) (i = 3);
示例 17-3:类型为 void
如果我们在这个条件表达式中省略了对 void 的强制转换,一个分支将是 void 类型,另一个将是 int 类型。这将是非法的,尽管大多数编译器不会对此发出警告,除非你使用 -pedantic 标志启用额外的警告。我们的编译器会拒绝带有 void 分支的条件表达式,因为它一直是严格的。
有四个地方可以使用 void 表达式。首先,它可以作为条件表达式中的一个子句出现,如示例 17-3。其次,你可以将其作为独立的表达式使用:
perform_side_effect();
第三,它可以作为 for 循环头部的第一个或第三个子句出现,如示例 17-4。
for (perform_side_effect(); i < 10; perform_side_effect())
`--snip--`
示例 17-4:在 for 循环头部中使用 void 表达式
第四,你可以将 void 表达式强制转换为 void:
(void) perform_side_effect();
最后一条并不特别有用,但它是合法的。
如你所知,你还可以使用 void 关键字在函数声明中指定一个空的参数列表。这是一个特殊情况,因为它实际上并没有指定一个表达式、对象或返回值的类型为 void。即使我们扩展编译器以完全支持 void 类型,我们仍将像以前一样处理这个特殊情况。
使用 void * 进行内存管理
现在让我们看看内存管理函数如何使用 void * 来表示分配的内存。malloc 函数具有以下签名:
void *malloc(size_t size);
size 参数指定要分配的字节数。它的类型 size_t 是一个实现定义的无符号整数类型。在 System V x64 ABI 下,size_t 是 unsigned long 的别名。由于我们不支持类型别名,我们的测试程序和示例使用以下声明:
void *malloc(unsigned long size);
malloc 函数分配一块内存并返回其地址。由于 malloc 并不知道这块内存将存储什么类型的对象,因此返回一个指向 int、char 或我们到目前为止看到的其他类型的指针会产生误导。相反,它返回 void *。不过,你不能通过 void * 指针来读写内存。因此,在你可以访问通过 malloc 分配的内存之前,你需要通过将其地址从 void * 转换为其他指针类型来指定它应包含的对象类型。
你可以在不显式转换的情况下将其他指针类型转换为 void *,反之亦然。例如,你可以使用 malloc 分配一个包含 100 个 int 元素的数组:
int *many_ints = malloc(100 * sizeof (int));
当你将 malloc 的结果赋值给 many_ints 时,它会隐式地将 void * 转换为 int *。然后,你可以像访问其他 int 数组一样访问 many_ints:
many_ints[10] = 10;
free 函数接受一个 void * 参数,指定要释放的内存块:
void free(void *ptr);
这个指针必须是之前由 malloc 或其他内存分配函数返回的相同值。以下是如何使用 free 来释放 many_ints 指向的内存:
free(many_ints);
这个函数调用隐式地将 many_ints 的值从 int * 转换回 void *,结果是与最初 malloc 返回的指针相同。
calloc 和 aligned_alloc 函数提供了稍有不同的内存分配方式;与 malloc 类似,它们返回指向分配空间的指针,类型为 void *。realloc 函数接受一个大小和一个 void * 指针,指向之前分配的内存,这块内存应该被释放,它返回一个 void * 指针,指向一个新分配的内存块,大小为新的指定值,并保留原有内容。就我们的目的而言,这些函数的细节并不重要;关键概念是它们都使用 void * 指针来标识它们分配和释放的内存块。
这些内存块是我们可以读取和写入的对象,类似于变量,但它们的生命周期管理方式不同。如我们所知,变量有自动存储持续时间(生命周期持续整个代码块执行期间)或静态存储持续时间(生命周期持续整个程序)。一块已分配的内存具有 分配存储持续时间:其生命周期从分配开始,直到释放为止。
编译器必须跟踪所有具有静态或自动存储持续时间的变量,记录它们的大小和生命周期的详细信息,并在数据段或栈上为它们保留空间。但编译器不需要知道任何关于具有分配存储持续时间的对象的信息,因为这些对象的跟踪是由程序员和内存管理库负责的。
完整类型和不完整类型
如果我们知道一个对象的大小,那么它的类型是 完整的;如果我们不知道它的大小,那么它的类型是 不完整的。void 类型是我们见过的第一个不完整类型。我们不知道它的大小,因为它没有 大小。在下一章中,我们将遇到不完整的结构类型,这些类型的大小和成员对编译器不可见。不完整的结构类型可以在程序后续部分通过编译器了解到更多信息后得到完成。而 void 类型则无法被完成。
C 标准规定,“只有在不需要知道该类型对象的大小时,才能使用不完整类型”(第 6.7.2.3 节,脚注 132)。例如,你不能定义一个不完整类型的变量,因为你不知道需要为它分配多少空间。而且,你不能给一个不完整类型的对象赋值或使用它的值,因为你需要知道读写多少字节。除了少数几个例外,其他不完整类型受与void相同的限制,类型检查器会以相同方式处理它们。
所有指针都是完整类型,即使它们指向的类型是不完整的;我们知道指针的大小始终是 8 字节。这就是为什么你可以声明类型为void *的变量和参数,从函数返回void *类型的值,将它们转换为其他指针类型,等等。正如你将在下一章看到的那样,你可以以相同的方式使用指向不完整结构类型的指针。
sizeof 操作符
sizeof操作符接受表达式或类型名称。当它接受类型名称时,它返回该类型的字节大小。当它接受表达式时,它返回该表达式类型的大小。清单 17-5 展示了这两种情况。
sizeof (long);
sizeof 10.0;
清单 17-5:sizeof 的两种用法
这两个sizeof表达式的值都是 8,因为long和double类型的大小都是 8 字节。请注意,在sizeof表达式中,类型名称必须用括号括起来,但表达式不需要。
当你在sizeof表达式中使用数组时,它不会退化为指针。请参见清单 17-6 中的sizeof表达式。
int array[3];
return sizeof array;
清单 17-6:获取数组的大小
这段代码返回12,即一个包含三个int元素的数组的大小,而不是8,即指针的大小。
你总是可以在不评估表达式的情况下确定其类型——因此也能确定其大小。事实上,C 标准要求我们不要评估 sizeof 表达式的操作数。相反,我们推断操作数的类型,并在编译时评估 sizeof。这意味着 sizeof 表达式不会产生副作用。例如,语句
return sizeof puts("Shouting into the void");
不会调用 puts。它将直接返回 4,因为 puts 函数的返回类型是 int。
你也可以将 sizeof 应用于通常会产生运行时错误的表达式,正如 第 17-7 节 所演示的那样。
double *null_ptr = 0;
return sizeof *null_ptr;
第 17-7 节:在不评估表达式的情况下获取其大小
通常,解引用 null_ptr 会导致未定义行为。但是这个例子是明确定义的,因为它永远不会评估 *null_ptr。相反,它将返回 8,因为编译器可以确定 *null_ptr 的类型是 double。
可变长度数组是这一规则的唯一例外。可变长度数组的大小在编译时无法得知,因此必须在运行时进行评估。由于我们不支持可变长度数组,所以可以忽略这一情况。
现在我们知道了 C 程序如何使用 void、void * 和 sizeof,接下来让我们开始处理编译器。像往常一样,我们将从更新词法分析器开始。
词法分析器
本章将引入一个新关键字:
sizeof
你无需添加 void 关键字;词法分析器已经识别它了。
解析器
第 17-8 节展示了本章对 AST 的修改。
program = Program(declaration*)
declaration = FunDecl(function_declaration) | VarDecl(variable_declaration)
variable_declaration = (identifier name, initializer? init,
type var_type, storage_class?)
function_declaration = (identifier name, identifier* params, block? body,
type fun_type, storage_class?)
initializer = SingleInit(exp) | CompoundInit(initializer*)
type = Char | SChar | UChar | Int | Long | UInt | ULong | Double **|** **Void**
| FunType(type* params, type ret)
| Pointer(type referenced)
| Array(type element, int size)
storage_class = Static | Extern
block_item = S(statement) | D(declaration)
block = Block(block_item*)
for_init = InitDecl(variable_declaration) | InitExp(exp?)
statement = Return(**exp?**)
| Expression(exp)
| If(exp condition, statement then, statement? else)
| Compound(block)
| Break
| Continue
| While(exp condition, statement body)
| DoWhile(statement body, exp condition)
| For(for_init init, exp? condition, exp? post, statement body)
| Null
exp = Constant(const)
| String(string)
| Var(identifier)
| Cast(type target_type, exp)
| Unary(unary_operator, exp)
| Binary(binary_operator, exp, exp)
| Assignment(exp, exp)
| Conditional(exp condition, exp, exp)
| FunctionCall(identifier, exp* args)
| Dereference(exp)
| AddrOf(exp)
| Subscript(exp, exp)
**| SizeOf(exp)**
**| SizeOfT(type)**
unary_operator = Complement | Negate | Not
binary_operator = Add | Subtract | Multiply | Divide | Remainder | And | Or
| Equal | NotEqual | LessThan | LessOrEqual
| GreaterThan | GreaterOrEqual
const = ConstInt(int) | ConstLong(int) | ConstUInt(int) | ConstULong(int)
| ConstDouble(double) | ConstChar(int) | ConstUChar(int)
列表 17-8:带有 void, sizeof,和 return 没有返回值的 return 语句
我们在这里做了四个小修改。首先,我们添加了一个 void 类型。其次,Return 语句中的表达式现在是可选的,因此它可以表示有返回值和没有返回值的 return 语句。最后,有两个新的表达式,表示你可以使用 sizeof 操作符的两种方式。
接下来,我们将对语法进行相应的修改。这里唯一的难点是,除非类型转换表达式被括号括起来,否则我们不能对类型转换表达式应用 sizeof。例如,这是一个语法错误:
sizeof (int) a;
将类型转换表达式用括号括起来可以修复这个错误:
sizeof ((int) a);
这个限制使得解析器更容易区分 sizeof 操作是作用于类型名还是作用于表达式。为了在语法中捕捉到这个限制,我们需要将类型转换表达式与其他一元表达式分开。
让我们从重构类型名开始,创建一个符号,以便我们能在类型转换和 sizeof 表达式中都能使用:
<type-name> ::= {<type-specifier>}+ [<abstract-declarator>]
接下来,我们将定义新的
<cast-exp> ::= "(" <type-name> ")" <cast-exp>
| <unary-exp>
我们将更新
<unary-exp> ::= <unop> ❶ <cast-exp>
| "sizeof" ❷ <unary-exp>
| "sizeof" "(" <type-name> ")"
| <postfix-exp>
一元操作符规则,比如 -、~、! 和 & 允许类型转换表达式作为操作数 ❶,而 sizeof 的规则则不允许 ❷。
最后,我们将使用新的
<exp> ::= **<cast-exp>** | <exp> <binop> <exp> | <exp> "?" <exp> ":" <exp>
列表 17-9 给出了本章的完整语法。
<program> ::= {<declaration>}
<declaration> ::= <variable-declaration> | <function-declaration>
<variable-declaration> ::= {<specifier>}+ <declarator> ["=" <initializer>] ";"
<function-declaration> ::= {<specifier>}+ <declarator> (<block> | ";")
<declarator> ::= "*" <declarator> | <direct-declarator>
<direct-declarator> ::= <simple-declarator> [<declarator-suffix>]
<declarator-suffix> ::= <param-list> | {"[" <const> "]"}+
<param-list> ::= "(" "void" ")" | "(" <param> {"," <param>} ")"
<param> ::= {<type-specifier>}+ <declarator>
<simple-declarator> ::= <identifier> | "(" <declarator> ")"
<type-specifier> ::= "int" | "long" | "unsigned" | "signed" | "double" | "char" **| "void"**
<specifier> ::= <type-specifier> | "static" | "extern"
<block> ::= "{" {<block-item>} "}"
<block-item> ::= <statement> | <declaration>
<initializer> ::= <exp> | "{" <initializer> {"," <initializer>} [","] "}"
<for-init> ::= <variable-declaration> | [<exp>] ";"
<statement> ::= "return" **[<exp>]** ";"
| <exp> ";"
| "if" "(" <exp> ")" <statement> ["else" <statement>]
| <block>
| "break" ";"
| "continue" ";"
| "while" "(" <exp> ")" <statement>
| "do" <statement> "while" "(" <exp> ")" ";"
| "for" "(" <for-init> [<exp>] ";" [<exp>] ")" <statement>
| ";"
<exp> ::= **<cast-exp>** | <exp> <binop> <exp> | <exp> "?" <exp> ":" <exp>
**<cast-exp> ::= "(" <type-name> ")" <cast-exp>**
**| <unary-exp>**
<unary-exp> ::= <unop> **<cast-exp>**
**| "sizeof" <unary-exp>**
**| "sizeof" "(" <type-name> ")"**
| <postfix-exp>
**<type-name> ::= {<type-specifier>}+ [<abstract-declarator>]**
<postfix-exp> ::= <primary-exp> {"[" <exp> "]"}
<primary-exp> ::= <const> | <identifier> | "(" <exp> ")" | {<string>}+
| <identifier> "(" [<argument-list>] ")"
<argument-list> ::= <exp> {"," <exp>}
<abstract-declarator> ::= "*" [<abstract-declarator>]
| <direct-abstract-declarator>
<direct-abstract-declarator> ::= "(" <abstract-declarator> ")" {"[" <const> "]"}
| {"[" <const> "]"}+
<unop> ::= "-" | "~" | "!" | "*" | "&"
<binop> ::= "-" | "+" | "*" | "/" | "%" | "&&" | "||"
| "==" | "!=" | "<" | "<=" | ">" | ">=" | "="
<const> ::= <int> | <long> | <uint> | <ulong> | <double> | <char>
<identifier> ::= ? An identifier token ?
<string> ::= ? A string token ?
<int> ::= ? An int token ?
<char> ::= ? A char token ?
<long> ::= ? An int or long token ?
<uint> ::= ? An unsigned int token ?
<ulong> ::= ? An unsigned int or unsigned long token ?
<double> ::= ? A floating-point constant token ?
示例 17-9:带有 void、 sizeof 和可选返回值的语法
parse_type 辅助函数,它将类型说明符列表转换为 type AST 节点,应当拒绝任何 void 说明符与其他类型说明符(如 long 或 unsigned)一起出现的声明。否则,解析器应像处理其他类型一样处理 void。void 类型可以通过指针、数组和函数声明符进行修饰;指向 void 的指针和返回 void 的函数都是完全合法的,而其他使用 void 的方式在语法上是有效的,但在语义上是非法的。例如,声明一个 void 元素的数组,定义一个 void 变量,或声明一个带有 void 参数的函数,都是语义错误。解析器不会捕捉这些语义错误,但类型检查器会。
即使语法规则本身没有改变,您可能仍需要更改对
注意,当 void 关键字表示空参数列表时,我们不会将其翻译为 AST 中的 Void 类型。例如,给定函数声明
int main(void);
生成的 AST 节点将具有此类型:
FunType(params=[], ret=Int)
params 列表为空,就像在前面的章节中一样;它不包含 Void。
类型检查器
现在让我们弄清楚如何进行 void、void * 和 sizeof 的类型检查。我们将从 void * 与其他指针类型之间的隐式转换开始。尽管大多数指针类型之间的隐式转换是不可行的,但在几种情况下,这种转换是被允许的。接下来,我们将检测 void 可能引发的所有新的和令人兴奋的类型错误。最后,我们将处理 sizeof 操作符。
转换到和从 void *
void *与其他指针类型之间的隐式转换在三种情况下是合法的。首先,你可以使用 == 或 != 比较 void * 类型的值和另一个指针类型:
int *a;
void *b;
`--snip--`
return a == b;
第二,在形如
int *a;
void *b;
`--snip--`
return flag ? a : b;
在这两种情况下,非void 类型的指针会被转换为 void *。
第三,你可以在赋值时隐式地进行 void * 与其他指针类型之间的转换。你可以将任何指针类型的值赋给 void * 类型的对象:
int *a = 0;
void *b = a;
同样地,你也可以将 void * 类型的值赋给另一个指针类型的对象。
最后一种情况不仅包括简单的赋值操作;它涵盖了我们在第十四章中讨论的所有“类似于赋值”的转换。例如,传递 void * 类型的参数给期望其他指针类型的函数是合法的:
int use_int_pointer(int *a);
void *ptr = 0;
use_int_pointer(ptr);
并非巧合,这恰好是可以隐式地将空指针常量转换为其他指针类型的三种情况。为了支持 void * 的隐式转换,我们将扩展在 第十四章 中定义的两个辅助函数:get_common_pointer_type 和 convert_by_assignment。
让我们重新审视 列表 14-14,该列表定义了 get_common_pointer_type。它在此处复制为 列表 17-10,并已加粗本章的更改部分。
get_common_pointer_type(e1, e2):
e1_t = get_type(e1)
e2_t = get_type(e2)
if e1_t == e2_t:
return e1_t
else if is_null_pointer_constant(e1):
return e2_t
else if is_null_pointer_constant(e2):
return e1_t
**else if e1_t == Pointer(Void) and e2_t is a pointer type:**
**return Pointer(Void)**
**else if e2_t == Pointer(Void) and e1_t is a pointer type:**
**return Pointer(Void)**
else:
fail("Expressions have incompatible types")
列表 17-10:获取两个表达式的公共类型,其中至少一个具有指针类型
粗体部分的代码允许在 void * 和其他指针类型之间进行隐式转换,但不允许在 void * 和算术类型、数组类型或 void 之间进行隐式转换。接下来,我们将再次查看 列表 14-16,并将其复制到 列表 17-11 中,修改部分已加粗。
convert_by_assignment(e, target_type):
if get_type(e) == target_type:
return e
if get_type(e) is arithmetic and target_type is arithmetic:
return convert_to(e, target_type)
if is_null_pointer_constant(e) and target_type is a pointer type:
return convert_to(e, target_type)
**if target_type == Pointer(Void) and get_type(e) is a pointer type:**
**return convert_to(e, target_type)**
**if target_type is a pointer type and get_type(e) == Pointer(Void):**
**return convert_to(e, target_type)**
else:
fail("Cannot convert type for assignment")
列表 17-11:将表达式转换为目标类型,如同通过赋值操作一样
粗体部分的修改使我们可以在赋值过程中将 void * 转换为其他指针类型,反之亦然。请注意,本列表中的内容不会阻止我们将 void 表达式赋值给 void 目标类型。然而,我们将在类型检查器的其他地方引入对 void 的其他限制,以确保我们永远不会将目标类型为 void 的 convert_by_assignment 函数调用。例如,我们永远不会尝试将函数参数转换为 void,因为我们会拒绝包含 void 参数的函数声明。
返回类型为 void 的函数
接下来,我们将对带有和不带有表达式的 return 语句进行类型检查。应该使用哪种 return 语句取决于函数的返回类型。返回类型为 void 的函数不能返回表达式。任何其他返回类型的函数在返回时必须包含表达式。因此,以下两个函数定义是合法的:
int return_int(void) {
return 1;
}
void return_void(void) {
return;
}
以下两种都是非法的:
int return_int(void) {
return;
}
void return_void(void) {
return 1;
}
你甚至不能从返回类型为void的函数中返回一个 void 表达式,这也使得以下示例非法:
void return_void(void) {
return (void) 1;
}
GCC 和 Clang 都接受这个程序,但如果你使用-pedantic标志,它们会发出警告。你可以根据需要处理这种边缘情况;测试套件不会覆盖它。
我会跳过本节的伪代码,因为这只是我们现有逻辑的一种直接扩展,用于类型检查return语句。
标量和非标量类型
一些 C 语言构造要求使用标量表达式,包括&&、||和!表达式的操作数;条件表达式的第一个操作数;以及循环和if语句中的控制条件。共同点是,所有这些语言构造都将表达式的值与零进行比较。将指针或算术值与零进行比较是有意义的,但将非标量值与零进行比较则没有意义。
在前面的章节中,没有办法编写一个违反这些类型约束的程序。数组是我们唯一的非标量类型,而且在需要标量表达式的地方,数组会退化为指针。但是一旦我们把void加入其中,就需要明确执行这些约束。(尽管void不是一个聚合类型,它也不是标量类型。标量表达式有一个单一的值,而 void 表达式则没有值。)清单 17-12 定义了一个小的辅助函数来告诉我们一个类型是否为标量。
is_scalar(t):
match t with
| Void -> return False
| Array(elem_t, size) -> return False
| FunType(param_ts, ret_t) -> return False
| _ -> return True
清单 17-12:检查类型是否为标量
我们可以使用这个辅助函数来验证控制条件和逻辑操作数。例如,清单 17-13 演示了如何验证逻辑!表达式的操作数。
typecheck_exp(e, symbols):
match e with
| `--snip--`
| Unary(Not, inner) ->
typed_inner = typecheck_and_convert(inner, symbols)
if not is_scalar(get_type(typed_inner)):
fail("Logical operators only apply to scalar expressions")
`--snip--`
清单 17-13:验证逻辑操作数是否为标量
类型转换表达式略有不同。除了我们已经禁止的double与指针之间的转换外,你可以将标量表达式转换为任何标量类型。你还可以将任何类型转换为void。 清单 17-14 展示了如何进行类型检查类型转换表达式。
| Cast(t, inner) ->
typed_inner = typecheck_and_convert(inner, symbols)
`--snip--`
❶ if t == Void:
return set_type(Cast(t, typed_inner), Void)
❷ if not is_scalar(t):
fail("Can only cast to scalar type or void")
❸ if not is_scalar(get_type(typed_inner)):
fail("Cannot cast non-scalar expression to scalar type")
else:
return set_type(Cast(t, typed_inner), t)
清单 17-14:类型检查类型转换表达式
首先,我们明确拒绝 double 和指针之间的转换。这项检查在 清单 17-14 中被省略,因为它与前几章相同。接着,我们检查目标类型是否为 void ❶。如果是,我们记录下整个表达式的类型是 void。否则,我们验证目标类型 ❷ 和内层表达式 ❸ 是否都是标量类型。这将拒绝从 void 到任何非 void 类型的转换,也禁止转换到数组和函数类型,这些我们已经知道是不合法的。
清单 17-13 和 17-14 中的类型检查逻辑也适用于结构体,我们将在下一章实现它们。结构体是聚合类型,但它们不像数组那样会衰退为指针。因此,我们需要验证程序中是否在需要标量类型的地方使用了结构体。
不完整类型的限制
如果程序在需要完整类型的地方使用不完整类型,将会遇到类型错误。目前,我们将在三种情况下要求完整类型。首先,不能对指向不完整类型的指针进行加法、减法或下标操作,因为无法确定它们指向的数组元素的大小。其次,不能对不完整类型应用 sizeof,因为它的大小未知。第三,每当你指定数组类型时,其元素类型必须是完整类型。
注意
作为语言扩展,Clang 和 GCC 允许对 void 指针进行指针运算,并对 void 执行 sizeof 操作。这些表达式的实现假定 void 的大小为 1。
清单 17-15 定义了一些辅助函数来支持这种验证。
is_complete(t):
return t != Void
is_pointer_to_complete(t):
match t with
| Pointer(t) -> return is_complete(t)
| _ -> return False
清单 17-15:检查不完整类型和指向不完整类型的指针
我们将在需要检查完整类型时使用 is_complete。在需要检查指向完整类型的指针时,我们将使用 is_pointer_to_complete,特别是在我们进行指针加法、减法和下标操作时。例如,清单 17-16 演示了如何进行指针加法的类型检查。它复制了 清单 15-21,并将本章的更改加粗,同时省略了一些未更改的代码。
| Binary(Add, e1, e2) ->
typed_e1 = typecheck_and_convert(e1, symbols)
typed_e2 = typecheck_and_convert(e2, symbols)
t1 = get_type(typed_e1)
t2 = get_type(typed_e2)
if t1 and t2 are arithmetic:
`--snip--`
else if **is_pointer_to_complete(t1)** and t2 is an integer type:
`--snip--`
else if **is_pointer_to_complete(t2)** and t1 is an integer type:
`--snip--`
else:
fail("Invalid operands for addition")
清单 17-16:类型检查指针加法,额外验证指针引用的类型是否完整
在下一章,我们将扩展is_complete,以区分完整类型和不完整类型的结构体类型。
我们暂时不考虑sizeof;我们将在稍后的“sizeof 表达式”部分对其进行类型检查,见第 477 页。这意味着我们只需要处理第三种情况,确保每个数组元素的类型是完整的。这同样适用于嵌套在更大类型中的数组。例如,以下声明是无效的:
void (*ptr_to_void_array)[3];
即使每个指针都是完整类型,声明指向void元素数组的指针也是非法的。在 Listing 17-17 中,我们定义了一个额外的辅助函数来捕捉这些无效的类型说明符。
validate_type_specifier(t):
match t with
| Array(elem_t, size) ->
❶ if not is_complete(elem_t):
fail("Illegal array of incomplete type")
validate_type_specifier(elem_t)
| Pointer(referenced_t) -> ❷ validate_type_specifier(referenced_t)
| FunType(param_ts, ret_t) ->
for param_t in param_ts:
validate_type_specifier(param_t)
validate_type_specifier(ret_t)
❸ | _ -> return
Listing 17-17: 验证类型说明符
当我们看到一个数组类型时,我们会确保它的元素类型是完整的❶,然后递归验证该元素类型。这确保我们会拒绝包含void元素的嵌套数组、指向包含void元素的数组的指针数组,等等。为了处理另一个派生类型,我们将递归验证它所引用的任何类型❷。非派生类型,包括void本身,都是有效的❸。我们将在所有出现类型说明符的地方调用validate_type_specifier来验证类型说明符:在变量声明、函数声明、sizeof表达式和类型转换表达式中。
我们将在下一章引入更多对不完整类型的限制。例如,除了void之外,在条件表达式的分支中使用不完整类型是非法的。将值赋给具有不完整类型的左值也是非法的,但由于我们将在接下来实现的规则,void左值是不存在的,所以现在可以忽略这条规则。
对 void 的额外限制
在我们刚刚实现的所有不完整类型的限制之外,我们还将对void施加两个额外的限制:你不能声明void变量或参数,也不能解引用指向void的指针。(这两种使用void的方式是合法的灰色地带;详细内容请参见框“void 何时有效:过于详细的讨论”。)
这些对void的限制不适用于其他不完整类型。在下一章中,你将看到你可以声明——但不能定义——一个不完整结构体类型的变量。然后,你可以在程序的不同位置定义该变量,一旦该类型被完成。同样,你可以声明一个使用不完整结构体类型作为参数或返回类型的函数,只要在调用或定义该函数之前完成该类型。最后,解引用指向不完整结构体类型的指针是合法的,尽管这不是特别有用;你唯一能对解引用结果做的事情就是获取其地址,这只会返回你最初的指针。
我们在这里稍微妥协了一下一个边缘情况。严格来说,获取任何解引用指针的地址都是合法的,无论它是指向完整类型、未完类型结构体类型还是void。正如我们在第十四章中看到的,获取解引用指针的地址是一个特殊情况;这两个操作会互相抵消,结果是明确的,即使解引用表达式本身是未定义的。这意味着这段代码是合法的:
void *void_ptr = malloc(4);
void *another_ptr = &*void_ptr;
我们的编译器将拒绝对void *操作数的所有解引用操作,即使在这种边缘情况下也是如此。但我们并不孤单:GCC 会对这段代码发出警告,MSVC 则完全拒绝它。(当然,如果你想,你可以正确处理这个边缘情况;我们的测试套件没有涵盖它。)
具有 void 操作数的条件表达式
我们将明确允许在条件表达式中使用void操作数,正如清单 17-18 所示。
typecheck_exp(e, symbols):
match e with
| `--snip--`
| Conditional(condition, e1, e2) ->
typed_cond = typecheck_and_convert(condition, symbols)
typed_e1 = typecheck_and_convert(e1, symbols)
typed_e2 = typecheck_and_convert(e2, symbols)
❶ if not is_scalar(get_type(typed_cond)):
fail("Condition in conditional operator must be scalar")
t1 = get_type(typed_e1)
t2 = get_type(typed_e2)
if t1 == Void and t2 == Void:
❷ result_type = Void
else if t1 and t2 are arithmetic types:
result_type = get_common_type(t1, t2)
else if t1 or t2 is a pointer type:
result_type = get_common_pointer_type(typed_e1, typed_e2)
else:
fail("Cannot convert branches of conditional to a common type")
`--snip--`
清单 17-18:条件表达式的类型检查
要进行条件表达式的类型检查,我们首先验证其控制条件是否为标量❶。然后,我们考虑两个子句的类型。如果它们都是void,则结果也是void ❷。否则,我们按照之前的方法确定结果类型:如果两个操作数都是算术类型,则应用常规的算术转换;如果其中一个是指针,则找到它们的公共指针类型。如果这些情况都不适用——例如,因为一个操作数是void,而另一个是指针或算术值——我们将抛出错误。
现有的算术表达式和比较验证
接下来,我们将确保现有的逻辑在进行算术运算和比较时,即使混入了 void 类型,仍能正常进行类型检查。之前,我们可以假设每个表达式要么是算术类型,要么是指针类型。但现在我们不能再依赖这个假设。例如,我们可以重新查看 Listing 14-15,该示例展示了如何进行 Equal 表达式的类型检查。Listing 17-19 复现了这段代码,并加入了我们需要添加的额外验证逻辑。
typecheck_exp(e, symbols):
match e with
| `--snip--`
| Binary(Equal, e1, e2) ->
typed_e1 = typecheck_and_convert(e1, symbols)
typed_e2 = typecheck_and_convert(e2, symbols)
t1 = get_type(typed_e1)
t2 = get_type(typed_e2)
if t1 or t2 is a pointer type:
common_type = get_common_pointer_type(typed_e1, typed_e2)
else **if t1 and t2 are arithmetic types:**
common_type = get_common_type(t1, t2)
**else:**
**fail("Invalid operands to equality expression")**
`--snip--`
Listing 17-19: 带有额外验证的 Equal 表达式的类型检查
注意
如果你将这段代码与 Listing 14-15 进行比较,你会注意到我们已经将递归调用 typecheck_exp 替换为 typecheck_and_convert。我们在 第十五章中做了这个更改,因此在这里没有加粗。
在 第十四章中,如果 t1 和 t2 都不是指针类型,我们知道它们都是算术类型,因此可以继续执行通常的算术类型转换。现在我们将明确检查它们是否是指针类型或算术类型;如果是其他类型,则会失败。
更广泛来说,我们应该通过接受有效类型而不是拒绝无效类型来进行每个表达式操作数的类型检查。例如,我们应该验证 Multiply 和 Divide 的操作数 是 算术值,而不是确保它们 不是 指针。花点时间回顾一下你对所有关系运算和算术运算的类型检查逻辑,收紧任何过于宽松的验证。
sizeof 表达式
一个 sizeof 表达式的类型是 size_t;在我们的实现中,它就是 unsigned long。为了进行 sizeof 的类型检查,我们首先验证其操作数,然后将 unsigned long 记录为结果类型,正如 Listing 17-20 所示。
typecheck_exp(e, symbols):
match e with
| `--snip--`
| SizeOfT(t) ->
❶ validate_type_specifier(t)
❷ if not is_complete(t):
fail("Can't get the size of an incomplete type")
return set_type(e, ULong)
| SizeOf(inner) ->
❸ typed_inner = typecheck_exp(inner, symbols)
❹ if not is_complete(get_type(typed_inner)):
fail("Can't get the size of an incomplete type")
return set_type(SizeOf(typed_inner), ULong)
Listing 17-20: 类型检查 sizeof
如果 sizeof 操作的是一个类型,我们会强制执行两个关于不完整类型的规则,这些规则在“关于不完整类型的限制”中已经讨论过(见 第 471 页):你不能指定一个具有不完整元素类型的数组 ❶,而且不能对不完整类型应用 sizeof ❷。(你也不能对函数类型应用 sizeof,但我们已经在解析器中捕获了这个错误。)
如果操作数是一个表达式,我们首先推断该表达式的类型 ❸。为了避免将数组转换为指针,我们使用 typecheck_exp 而不是 typecheck_and_convert。一旦我们确定了表达式的类型,我们确保该类型是完整的 ❹。
TACKY 生成
接下来,我们将 sizeof 和 void 表达式转换为 TACKY。我们需要更新 Return 和 FunCall 指令,以适应返回类型为 void 的函数。我们还将稍微不同地处理类型为 void 的类型转换和条件表达式,与其非 void 对应类型相比;特别是,我们不会创建任何 void 临时变量。我们将在此步骤中评估 sizeof 表达式,并将它们替换为整数常量。我们无需为指向 void 的指针做任何更改。
返回类型为 void 的函数
我们将对 TACKY IR 做两项更改,以便能够调用并从返回类型为 void 的函数返回。首先,我们将使 FunCall 指令的目标变为可选:
FunCall(identifier fun_name, val* args, **val? dst**)
对于调用 void 函数的情况,我们将保持 dst 为空。对于调用其他函数的情况,dst 将是一个临时变量,用于保存返回值,就像现在这样。我们将对 Return 指令进行类似的修改:
Return(**val?**)
然后,我们将把每个没有表达式的 return 语句转换为一个没有值的 TACKY Return 指令。
一个 void 函数可能不会使用显式的 return 语句;在这种情况下,当控制流到达函数末尾时,它会自动返回。我们通过在每个 TACKY 函数的末尾添加 Return 指令,已经正确处理了这种情况。
转换为 void
列表 17-21 显示了如何处理转换为 void 的类型:只需处理内部表达式,而不发出其他任何指令。
emit_tacky(e, instructions, symbols):
match e with
| `--snip--`
| Cast(Void, inner) ->
emit_tacky_and_convert(inner, instructions, symbols)
return PlainOperand(Var("DUMMY"))
列表 17-21:将转换为 void 转换为 TACKY
你可以在这里返回任何你想要的操作数;调用者不会使用它。
带有 void 操作数的条件表达式
列表 17-22 演示了我们当前如何将条件表达式转换为 TACKY。
| Conditional(condition, e1, e2) ->
`--snip--`
cond = emit_tacky_and_convert(condition, instructions, symbols)
instructions.append(JumpIfZero(cond, e2_label))
dst = make_tacky_variable(get_type(e), symbols)
v1 = emit_tacky_and_convert(e1, instructions, symbols)
instructions.append_all(
❶ [Copy(v1, dst),
Jump(end),
Label(e2_label)])
v2 = emit_tacky_and_convert(e2, instructions, symbols)
instructions.append_all(
❷ [Copy(v2, dst),
Label(end)])
return PlainOperand(dst)
列表 17-22:将非 void 条件表达式转换为 TACKY
如果 e1 和 e2 是 void 表达式,Copy 指令 ❶❷ 就会变得有问题。我们不应该创建类型为 void 的临时变量 dst,并且我们绝对不应该将任何东西复制到它里面。为了处理条件中的 void 表达式,我们将沿用 列表 17-22 中的基本方法,但不生成 dst 或发出任何 Copy 指令。列表 17-23 显示了更新后的伪代码,用于处理 void 条件表达式。
| Conditional(condition, e1, e2) ->
`--snip--`
cond = emit_tacky_and_convert(condition, instructions, symbols)
instructions.append(JumpIfZero(cond, e2_label))
if get_type(e) == Void:
emit_tacky_and_convert(e1, instructions, symbols)
instructions.append_all(
[Jump(end),
Label(e2_label)])
emit_tacky_and_convert(e2, instructions, symbols)
instructions.append(Label(end))
❶ return PlainOperand(Var("DUMMY"))
else:
`--snip--`
列表 17-23:将带有 void 结果的条件表达式转换为 TACKY
由于我们不创建临时变量 dst,我们需要将某个其他操作数返回给调用者。我们可以返回一个虚拟值 ❶,因为我们知道调用者不会使用它。为了处理非 void 表达式,我们将生成与之前相同的指令,因此我已省略了处理该情况的伪代码。
sizeof 表达式
我们将在 TACKY 生成期间评估sizeof表达式,并将结果表示为unsigned long常量,正如示例 17-24 所示。
| SizeOf(inner) ->
t = get_type(inner)
result = size(t)
return PlainOperand(Constant(ConstULong(result)))
| SizeOfT(t) ->
result = size(t)
return PlainOperand(Constant(ConstULong(result)))
示例 17-24: 在 TACKY 生成期间评估 sizeof 的过程
由于我们没有将sizeof的操作数转换为 TACKY,因此它不会在运行时被评估。
最新且最强大的 TACKY IR
示例 17-25 定义了当前的 TACKY 中间表示(IR),本章中的两个更改已加粗。
program = Program(top_level*)
top_level = Function(identifier, bool global, identifier* params, instruction* body)
| StaticVariable(identifier, bool global, type t, static_init* init_list)
| StaticConstant(identifier, type t, static_init init)
instruction = Return(**val?**)
| SignExtend(val src, val dst)
| Truncate(val src, val dst)
| ZeroExtend(val src, val dst)
| DoubleToInt(val src, val dst)
| DoubleToUInt(val src, val dst)
| IntToDouble(val src, val dst)
| UIntToDouble(val src, val dst)
| Unary(unary_operator, val src, val dst)
| Binary(binary_operator, val src1, val src2, val dst)
| Copy(val src, val dst)
| GetAddress(val src, val dst)
| Load(val src_ptr, val dst)
| Store(val src, val dst_ptr)
| AddPtr(val ptr, val index, int scale, val dst)
| CopyToOffset(val src, identifier dst, int offset)
| Jump(identifier target)
| JumpIfZero(val condition, identifier target)
| JumpIfNotZero(val condition, identifier target)
| Label(identifier)
| FunCall(identifier fun_name, val* args, **val?** dst)
val = Constant(const) | Var(identifier)
unary_operator = Complement | Negate | Not
binary_operator = Add | Subtract | Multiply | Divide | Remainder | Equal | NotEqual
| LessThan | LessOrEqual | GreaterThan | GreaterOrEqual
示例 17-25: 向 TACKY IR 添加对返回类型为 void 的函数的支持
本节中的大部分更改—为了支持 void 类型转换、void 条件表达式和sizeof—没有影响 TACKY IR。我们将在下一节处理这两条有所更改的指令。
汇编生成
本章的结尾,我们将生成没有返回值的Return指令和没有目标的FunCall指令。我们可以通过对汇编生成过程进行一些小的修改来处理这两条指令。
通常,形式为Return(val)的指令会转换为以下汇编:
Mov(`<val type>`, val, `<dst register>`)
Ret
如果返回值缺失,我们将跳过Mov指令,只生成Ret指令。类似地,示例 17-26 总结了我们通常如何将FunCall指令转换为汇编。
`<fix stack alignment>`
`<move arguments to general-purpose registers>`
`<move arguments to XMM registers>`
`<push arguments onto the stack>`
Call(fun_name)
`<deallocate arguments/padding>`
❶ Mov(`<dst type>`, `<dst register>`, dst)
示例 17-26: 当函数返回一个值时,将 FunCall 转换为汇编
如果dst缺失,我们将不会生成最后的Mov指令❶,但其他部分将保持不变。表 17-1 总结了从 TACKY 到汇编的最新更新,其中这两个小更改已加粗。
表 17-1: 将 TACKY 指令转换为汇编
| TACKY 指令 | 汇编指令 | |
|---|---|---|
| Return(val) | 整数 | Mov( |
| double | Mov(Double, val, Reg(XMM0)) Ret | |
| void | Ret |
|
FunCall(fun_name, args,
dst)
| dst 存在 |
|---|
<fix stack alignment>
<move arguments to general-purpose registers>
<move arguments to XMM registers>
<push arguments onto the stack>
Call(fun_name)
<deallocate arguments/padding>
Mov(<dst type>, <dst register>, dst)
|
| dst 缺失 |
|---|
<fix stack alignment>
<move arguments to general-purpose registers>
<move arguments to XMM registers>
<push arguments onto the stack>
Call(fun_name)
<deallocate arguments/padding>
|
由于汇编 AST 没有改变,我们将不再修改后端的其余部分。
总结
在本章中,你实现了 void 类型和 sizeof 运算符。你学习了完整类型与不完整类型之间的差异,以及 C 程序如何使用 void 表达式。接着,你扩展了类型检查器,检测不完整类型和非标量类型的无效使用,修改了 TACKY 生成阶段,以在不求值其操作数的情况下评估 sizeof 运算符,并调整了后端以支持不返回值的函数。接下来,我们将通过添加结构体类型来完成 第二部分,结构体是你将在本书中实现的最后一种语言特性,也许是最具挑战性的。幸运的是,得益于你在之前章节中学到的技能和打下的基础,你已经为迎接这个挑战做好了充分准备。

描述
第十八章:18 结构体

在本章中,你将添加一个最终的语言特性:结构体。你还将实现 . 和 -> 运算符来访问结构成员。在第二部分的恰当结尾,你将运用前几章学到的许多技能、概念和技术。在标识符解析阶段,你将解析结构标签以及函数和变量标识符。在类型检查器中,你将像记录函数和变量声明在符号表中一样,在类型表中记录结构定义。在 TACKY 生成过程中,你将基于第十四章中处理解引用指针和其他对象操作的策略进行扩展。而在后端,你将实现 System V 调用约定中指定如何将结构体作为函数参数和返回值传递的部分。由于联合体与结构体关系密切,你可能也想实现它们。我们不会详细讨论,但你可以将它们作为额外功能添加进去。
声明结构类型
你必须在使用结构类型之前先声明它。结构类型声明有两种形式。第一种,如清单 18-1 所示,指定了结构的成员。
struct complete_struct {
long member1;
double member2;
};
清单 18-1:一个完整的结构类型声明
这个清单声明了一个包含两个成员的完整结构类型:一个 long 类型和一个 double 类型。标识符 complete_struct 是这个类型的 标签,我们可以在程序中稍后使用它来指定类型。一个完整的结构类型必须至少有一个成员,因此声明一个没有成员的结构类型是非法的:
struct empty {};
第二种结构类型声明,如清单 18-2 所示,指定了结构的标签,但没有指定其成员。
struct incomplete_struct;
清单 18-2:一个不完整的结构类型声明
列表 18-2 声明了一个不完整的结构类型。正如你在上一章学到的,你只能在某些有限的情况下使用不完整类型。例如,你不能定义一个类型为 struct incomplete_struct 的变量,但你可以定义一个指向 struct incomplete_struct 的指针。(这是因为我们知道指针需要多少内存,但不知道这个结构需要多少内存。)我们可以说,带有成员列表的结构声明同时声明和定义了一个类型,而没有成员列表的结构声明仅仅声明了一个类型。这与 C 标准以及其他地方的术语不同;特别是,当人们谈到“类型定义”时,他们通常指的是使用 typedef 关键字引入的别名。
结构标签仅在声明它们的作用域内可见,就像函数和变量名称一样。如果一个结构标签在文件作用域中声明,它从声明点开始一直可见直到文件末尾。如果它在块作用域中声明,它会一直可见直到块的末尾。如果在同一作用域中出现两个相同标签的结构类型声明,它们总是声明相同的类型;如果它们出现在不同的作用域中,则声明的是不同的类型。(类型声明没有链接性,所以你不能对它们应用 static 或 extern 关键字。)你可以多次声明相同的结构类型,但不能多次定义它。
一旦结构的定义在作用域内,它的类型就变成完整的,正如 列表 18-3 所示。
❶ struct s;
struct s *ptr = 0;
struct s {
int a;
int b;
}; ❷
❸ struct s x = {0,0};
列表 18-3:声明一个不完整的类型并将其完成
在 ❶ 和 ❷ 之间,struct s 是一个不完整类型。在程序的这两点之间,定义类型为 struct s 的变量是不合法的,但定义 ptr(指向 struct s 的指针)是合法的。在指定其成员列表的类型声明结束之后 ❷,struct s 成为一个完整类型,因此可以合法地定义该类型的变量 ❸。
当相同的结构标签在两个不同的作用域中声明时,一个可以遮蔽另一个,正如 列表 18-4 所示。
#include <stdio.h>
❶ struct s {
int a;
};
int main(void) {
printf("Outer struct size: %lu\n", ❷ sizeof (struct s));
❸ struct s {
long l;
};
printf("Inner struct size: %lu\n", ❹ sizeof (struct s));
return 0;
}
列表 18-4:一个结构类型遮蔽另一个结构类型
首先,我们在文件作用域中定义一个 struct s 类型 ❶。它的大小为 4 字节,因为它只包含一个 int。在 main 中的第一个 sizeof 表达式引用了这个类型 ❷。然后,我们在块作用域中定义另一个 struct s 类型 ❸,遮蔽了第一个类型。这个类型只包含一个 long,因此它的大小为 8 字节。两个 struct s 的定义不会冲突,因为它们出现在不同的作用域中。在第二个 sizeof 表达式 ❹ 中,指定符 struct s 引用的是内部作用域中定义的 8 字节结构体类型。运行该程序会输出以下内容:
$ ./listing_18_4
Outer struct size: 4
Inner struct size: 8
即使结构体的标签被遮蔽,它的成员仍然是可见的。请参见 示例 18-5。
int main(void) {
❶ struct shadow {
int x;
};
struct shadow outer;
outer.x = 2;
{
❷ struct shadow {
int y;
};
struct shadow inner;
inner.y = 3;
❸ return outer.x + inner.y;
}
}
示例 18-5:使用一个带有遮蔽结构类型的变量
在这个示例中,我们首先声明一个结构体类型,struct shadow ❶。然后,我们用该类型定义一个变量,outer。在内部作用域中,我们声明另一个具有相同标签的结构体类型 ❷,它会遮蔽外部的声明。接着,我们用这个新类型声明一个变量,inner。在 return 语句中,我们仍然可以访问两个变量的成员 ❸。即使在内部作用域中,编译器仍然知道原始的 struct shadow 类型,并且仍然知道 outer 属于该类型;我们只是不可以使用 shadow 标签来指定该类型。
为了保持所有结构体类型的一致性,我们将像处理变量名一样处理结构体标签:在标识符解析阶段,我们将用一个唯一标识符替换每个用户定义的标签。
结构体成员声明
结构体的成员可以是任何完整类型,包括原始类型如int,以及派生类型如数组、指针或其他结构体。然而,声明一个不完整类型的结构体成员是非法的,因为这样会导致无法确定整个结构体的大小。正如 C 标准第 6.7.2.1 节第 3 段所说:“结构体不能包含其自身的实例。”也就是说,一个struct s不能包含类型为struct s的成员。另一方面,结构体可以包含指向自身的指针,因为指针类型总是完整的。经典的示例,如 Listing 18-6 所示,是一个链表节点,它包含一个值和指向下一个列表条目的指针。
struct linked_list_node ❶ {
int val;
struct linked_list_node *next;
}; ❷
Listing 18-6:包含指向自身指针的结构体类型定义
在❶之后,struct linked_list_node作为不完整类型是可见的,因此我们可以将成员next声明为指向该类型的指针。在❷之后,该类型是完整的。
声明函数作为结构体成员也是不合法的。结构体可以包含函数指针——它们是完整类型,就像任何其他指针一样——但我们不支持函数指针,因此这对我们来说并不重要。
标签和成员命名空间
结构体标签位于与函数和变量不同的命名空间中。这意味着相同的标识符可以同时用作标签和函数或变量名,并且这两个标识符不会相互遮蔽或冲突。例如,在同一作用域内声明类型struct s和变量s是完全合法的。之所以能保持这些独立的命名空间,是因为struct关键字告诉编译器,某个特定的标识符是结构体标签。
类似地,每个结构体成员列表都有自己的命名空间。结构体成员可以与任何函数、变量或结构体类型共享名称,包括包含它的结构体类型,如以下示例所示:
struct s {
int s;
};
不同结构体中的成员也可以拥有相同的名称:
struct s1 {
int x;
};
struct s2 {
int x;
};
当标识符 x 出现在表达式中,比如 var->x 时,编译器可以从上下文中推断出它是指 s1 中的成员、s2 中的成员,还是一个函数或变量。毫不意外,同一结构体的两个成员不能共享相同的名称。
我们没有实现的结构体类型声明
C 语法不会区分结构体类型说明符和类型声明,因此你可以同时声明一个新的结构体类型,并在某个更大的构造中使用该结构体类型。例如,在清单 18-7 中,一个声明定义了一个新的结构体类型,struct s,以及一个类型为 struct s 的变量 x。
struct s {
int member;
} x;
清单 18-7:在同一个声明中定义并指定结构体类型
为了简化解析和语义分析,我们要求每个声明必须只声明一个函数、变量或类型。我们不支持像清单 18-7 中那样同时声明新类型和其他实体的声明。这也适用于不完整类型。C 标准允许你仅通过指定它来隐式声明一个不完整的结构体类型:
struct s *f(void);
即使 struct s 尚未声明,该声明仍然是合法的:它同时将 struct s 声明为一个不完整类型,并声明了一个返回指向 struct s 的指针的函数。然而,我们的实现不允许这样做。相反,我们要求首先声明 struct s:
struct s;
struct s *f(void);
要求类型在使用之前声明,也意味着你不能在另一个结构体声明内嵌套一个结构体声明,像清单 18-8 中那样。
struct outer {
struct inner {
int a;
long l;
} i;
double d;
};
清单 18-8:在同一个声明中声明 内部 结构体类型并声明具有该类型的成员 i 的声明
我们还会施加一些其他限制。我们将拒绝没有标签的结构体声明和没有名称的结构体成员,尽管 C 标准允许它们。我们也不支持 位字段成员,这使得可以在结构体内对单个比特进行寻址。
操作结构体
你可以通过 . 运算符访问结构体的成员:
struct s var;
`--snip--`
long l = var.member1;
如果你有一个指向结构体的指针,你可以通过 -> 运算符访问结构体的成员。继续使用相同的例子:
struct s *ptr = &var;
long l2 = ptr->member1;
你只能对完整的结构体类型使用 . 和 -> 运算符。你不能访问不完整结构体类型的成员,因为这些成员尚未定义。
结构体是聚合类型,像数组一样。但结构体不像数组那样会退化为指针,因此你可以以几种不能用于数组的方式使用它们。例如,你可以将它们作为函数参数和返回值。你还可以像 示例 18-9 中那样进行赋值。
struct s foo;
struct s bar;
`--snip--`
foo = bar;
示例 18-9:赋值给结构体
你也可以将值赋给结构体的单个成员,只要它们是左值。通过 -> 运算符指定的结构体成员始终是左值:
ptr->member2 = 2.0;
回想一下,所有解引用的指针都是左值。-> 运算符产生一个解引用指针,就像 * 和 [] 运算符一样,因此同样的规则适用。
如果结构体是左值,那么你通过 . 运算符访问的任何成员也是左值。如果结构体不是左值,那么它的成员也不是左值。因此,以下赋值表达式是合法的:
var.member2 = 2.0;
但是,由于函数调用的结果不是左值,因此这是非法的:
return_struct().member2 = 2.0;
结构体还可以出现在其他一些表达式中,基本上是你预期出现的地方。它们可以出现在条件表达式的分支中,只要两个分支有相同的结构体类型。你可以使用 sizeof 获取它们的大小,并将它们转换为 void,但你不能将它们转换为或从结构体类型进行转换。如果一个结构体或结构体成员是左值,你可以获取它的地址。
初始化结构体有两种方式。你可以使用与结构体类型相同的表达式进行初始化:
struct s return_struct(void);
struct s var = return_struct();
或者,你可以使用复合初始化器单独初始化每个成员,像 示例 18-10 中那样。
struct example {
int member1;
double member2;
char array[3];
};
struct example var = {1, 2.0, ❶ {'a', 'b', 'c'}};
示例 18-10:使用复合初始化器初始化结构体
复合初始化器按顺序初始化结构的成员。清单 18-10 中的初始化器将member1初始化为1,将member2初始化为2.0。清单 18-10 中的内嵌复合初始化器初始化array_member中的三个数组元素 ❶。请注意,数组和结构的复合初始化器具有相同的语法。(用于指定初始化器的语法,它用于初始化聚合对象中的特定子对象,数组元素和结构成员的语法不同,但我们不会实现指定初始化器。)通过嵌套复合初始化器,你可以初始化结构数组、包含其他结构的结构等。
结构在内存中的布局
到目前为止,我们已经对结构类型在源代码中的工作方式有了相当清晰的理解。现在让我们来看一下它们在运行时内存中的布局。内存布局部分由 C 标准规定,部分由 System V ABI 规定。按照 ABI 的规定精确布局结构非常重要,这样我们编译的代码才能与其他使用结构的代码进行互操作。
结构的成员在内存中的顺序与原始结构声明中的顺序相同。第一个成员必须具有与整个结构相同的地址;你总是可以将结构的指针转换为指向其第一个成员的指针,反之亦然。每个后续成员将存储在正确对齐的最早可用地址处。我们以清单 18-10 中的struct example类型为例。清单 18-11 重复了struct example的定义。
struct example {
int member1;
double member2;
char array[3];
};
清单 18-11:一个具有不同对齐方式的多个成员的结构类型
第一个成员必须从结构的最开始处开始。因为 member1 是一个 int 类型,它占用了结构的前 4 个字节。结构中的字节通常是零索引的,因此我们可以说 member1 占用了字节 0 到 3。接下来的空闲空间因此位于字节 4。但是 member2 是一个 double 类型,它需要 8 字节对齐;它的起始地址必须是 8 的倍数。因此,member2 会存储在字节 8 到 15 之间。我们说 member2 从结构的起始位置有 8 字节的偏移。在 member1 和 member2 之间,即字节 4 到 7 之间,我们有 4 字节的填充。
最后的成员 array 占用了 3 个字节,且对齐方式为 1 字节。由于我们不需要任何填充来正确对齐它,因此我们将其直接存储在 member2 后面,即字节 16 到 18 之间。
在结构的末尾,即 array 后面,我们还需要填充。根据 System V ABI,一个类型的大小必须是其对齐方式的倍数。ABI 还指出,结构体必须具有与其对齐最严格成员相同的对齐方式。struct example 的最严格对齐成员是 double 类型的 member2。因此,整个结构体必须是 8 字节对齐的,且其大小必须是 8 的倍数。struct example 的三个成员及它们之间的填充共占用 19 个字节。我们将在结构体的末尾添加 5 个字节的填充,将其总大小调整为 24 字节。图 18-1 展示了整个结构的布局。

图 18-1:内存中的结构布局 描述
成员之间的填充保证了每个成员最终会出现在正确对齐的内存地址。如果整个结构体的起始地址是 8 的倍数,并且 member2 从起始位置的偏移量也是 8 的倍数,那么我们可以知道 member2 的运行时内存地址也将是 8 的倍数。结构体末尾的填充保证了结构体数组中的每个元素都将正确对齐;如果一个 struct example 对象数组的第一个元素是 8 字节对齐,并且其总大小为 24 字节,那么每个后续元素也将是 8 字节对齐的。
现在你已经理解了如何在 C 中使用结构体以及它们在内存中的布局,我们开始着手实现它们吧。
词法分析器
本章中你将添加三个新令牌:
| struct | 一个关键字,表示结构体类型说明符 |
|---|---|
| . | 一个句点,表示结构体成员访问运算符 |
| -> | 一个箭头运算符,用于通过指针访问结构体成员 |
请记住,句点可以是结构体成员访问运算符,也可以是浮动点常量的一部分。我们只有在句点后跟着一个非数字字符时,才会将它识别为 . 令牌。如果句点后跟着数字,那么它要么是浮动点常量的开始,要么是无效的。例如,如果词法分析器看到输入 .100u,它应尝试将其解析为常量。然后它会报错,因为这不符合任何常量的正则表达式。它不应该把这个解析为一个 . 令牌后面跟着常量 100u。
语法分析器
在本章中,我们将向 AST 添加几个新构造:结构体声明、结构类型说明符和两个新的结构体运算符。清单 18-12 给出了结构声明的 AST 定义。
struct_declaration = (identifier tag, member_declaration* members)
member_declaration = (identifier member_name, type member_type)
清单 18-12:在 AST 中表示结构声明
一个 struct_declaration 包含一个标签和一系列成员。为了表示一个不完整的结构类型声明,我们将把成员列表留空。(记住,完整的结构类型必须至少有一个成员。)我们将用一个 member_declaration 来表示每个成员,其中包括成员名称和类型。
接下来,我们将扩展 declaration AST 节点,以支持结构类型声明以及函数和变量声明:
declaration = `--snip--` | StructDecl(struct_declaration)
我们还将扩展 type AST 节点,以包含像 struct s 这样的结构类型说明符:
type = `--snip--` | Structure(identifier tag)
最后,我们将添加两个新表达式:. 和 -> 运算符,有时分别称为 结构成员运算符 和 结构指针运算符。我们将使用更简洁的名称 Dot 和 Arrow:
exp = `--snip--`
| Dot(exp structure, identifier member)
| Arrow(exp pointer, identifier member)
这些运算符中的每一个都将一个表达式作为第一个操作数,结构成员的名称作为第二个操作数。清单 18-13 定义了完整的 AST,并以粗体突出显示了本章的更改。
program = Program(declaration*)
declaration = FunDecl(function_declaration) | VarDecl(variable_declaration)
**| StructDecl(struct_declaration)**
variable_declaration = (identifier name, initializer? init,
type var_type, storage_class?)
function_declaration = (identifier name, identifier* params, block? body,
type fun_type, storage_class?)
**struct_declaration = (identifier tag, member_declaration* members)**
**member_declaration = (identifier member_name, type member_type)**
initializer = SingleInit(exp) | CompoundInit(initializer*)
type = Char | SChar | UChar | Int | Long | UInt | ULong | Double | Void
| FunType(type* params, type ret)
| Pointer(type referenced)
| Array(type element, int size)
**| Structure(identifier tag)**
storage_class = Static | Extern
block_item = S(statement) | D(declaration)
block = Block(block_item*)
for_init = InitDecl(variable_declaration) | InitExp(exp?)
statement = Return(exp?)
| Expression(exp)
| If(exp condition, statement then, statement? else)
| Compound(block)
| Break
| Continue
| While(exp condition, statement body)
| DoWhile(statement body, exp condition)
| For(for_init init, exp? condition, exp? post, statement body)
| Null
exp = Constant(const)
| String(string)
| Var(identifier)
| Cast(type target_type, exp)
| Unary(unary_operator, exp)
| Binary(binary_operator, exp, exp)
| Assignment(exp, exp)
| Conditional(exp condition, exp, exp)
| FunctionCall(identifier, exp* args)
| Dereference(exp)
| AddrOf(exp)
| Subscript(exp, exp)
| SizeOf(exp)
| SizeOfT(type)
**| Dot(exp structure, identifier member)**
**| Arrow(exp pointer, identifier member)**
unary_operator = Complement | Negate | Not
binary_operator = Add | Subtract | Multiply | Divide | Remainder | And | Or
| Equal | NotEqual | LessThan | LessOrEqual
| GreaterThan | GreaterOrEqual
const = ConstInt(int) | ConstLong(int) | ConstUInt(int) | ConstULong(int)
| ConstDouble(double) | ConstChar(int) | ConstUChar(int)
清单 18-13:带有结构类型的抽象语法树及 . 和 -> 运算符
清单 18-14 显示了语法的相应变化。
<program> ::= {<declaration>}
<declaration> ::= <variable-declaration> | <function-declaration> **| <struct-declaration>**
<variable-declaration> ::= {<specifier>}+ <declarator> ["=" <initializer>] ";"
<function-declaration> ::= {<specifier>}+ <declarator> (<block> | ";")
**<struct-declaration> ::= "struct" <identifier>** ❶ **["{" {<member-declaration>}+ "}"] ";"**
**<member-declaration> ::= {<type-specifier>}+ <declarator> ";"**
<declarator> ::= "*" <declarator> | <direct-declarator>
<direct-declarator> ::= <simple-declarator> [<declarator-suffix>]
<declarator-suffix> ::= <param-list> | {"[" <const> "]"}+
<param-list> ::= "(" "void" ")" | "(" <param> {"," <param>} ")"
<param> ::= {<type-specifier>}+ <declarator>
<simple-declarator> ::= <identifier> | "(" <declarator> ")"
<type-specifier> ::= "int" | "long" | "unsigned" | "signed" | "double" | "char" | "void"
**| "struct" <identifier>**
<specifier> ::= <type-specifier> | "static" | "extern"
<block> ::= "{" {<block-item>} "}"
<block-item> ::= <statement> | <declaration>
<initializer> ::= <exp> | "{" <initializer> {"," <initializer>} [","] "}"
<for-init> ::= <variable-declaration> | [<exp>] ";"
<statement> ::= "return" [<exp>] ";"
| <exp> ";"
| "if" "(" <exp> ")" <statement> ["else" <statement>]
| <block>
| "break" ";"
| "continue" ";"
| "while" "(" <exp> ")" <statement>
| "do" <statement> "while" "(" <exp> ")" ";"
| "for" "(" <for-init> [<exp>] ";" [<exp>] ")" <statement>
| ";"
<exp> ::= <cast-exp> | <exp> <binop> <exp> | <exp> "?" <exp> ":" <exp>
<cast-exp> ::= "(" <type-name> ")" <cast-exp>
| <unary-exp>
<unary-exp> ::= <unop> <cast-exp>
| "sizeof" <unary-exp>
| "sizeof" "(" <type-name> ")"
| <postfix-exp>
<type-name> ::= {<type-specifier>}+ [<abstract-declarator>]
<postfix-exp> ::= <primary-exp> {**<postfix-op>}**
**<postfix-op> ::= "[" <exp> "]"**
**| "." <identifier>**
**| "->" <identifier>**
<primary-exp> ::= <const> | <identifier> | "(" <exp> ")" | {<string>}+
| <identifier> "(" [<argument-list>] ")"
<argument-list> ::= <exp> {"," <exp>}
<abstract-declarator> ::= "*" [<abstract-declarator>]
| <direct-abstract-declarator>
<direct-abstract-declarator> ::= "(" <abstract-declarator> ")" {"[" <const> "]"}
| {"[" <const> "]"}+
<unop> ::= "-" | "~" | "!" | "*" | "&"
<binop> ::= "-" | "+" | "*" | "/" | "%" | "&&" | "||"
| "==" | "!=" | "<" | "<=" | ">" | ">=" | "="
<const> ::= <int> | <long> | <uint> | <ulong> | <double> | <char>
<identifier> ::= ? An identifier token ?
<string> ::= ? A string token ?
<int> ::= ? An int token ?
<char> ::= ? A char token ?
<long> ::= ? An int or long token ?
<uint> ::= ? An unsigned int token ?
<ulong> ::= ? An unsigned int or unsigned long token ?
<double> ::= ? A floating-point constant token ?
清单 18-14:带有结构类型的语法及 . 和 -> 运算符
一个
结构成员声明的形式与变量声明相同;它包括一个类型说明符列表和一个声明符,并以分号结束。然而,与变量声明不同,结构成员不能有初始化器或存储类。我们将施加一个语法要求,尽管语法中并未体现:解析器应拒绝结构成员声明中的函数声明符,尽管 <member -declaration> 语法规则允许它们。例如,解析器应拒绝以下声明:
struct contains_function {
int foo(void);
};
结构类型说明符由两个标记组成:struct 关键字和一个标识符标记,指定结构标签。该说明符不能与其他类型说明符结合使用,但可以通过指针、数组或函数声明符进行修饰。
新的 . 和 -> 操作符是后缀操作符,类似于我们在第十五章中添加的下标操作符。所有这三种后缀操作符的优先级都高于任何前缀操作符。新的
语义分析
我们有一段时间没有对标识符解析过程做出实质性改动了。现在,我们将让它解析结构标签以及函数和变量名称。这个过程将为每个结构类型分配一个唯一的 ID,替换掉它原来的用户定义标签。如果程序在声明结构之前尝试指定结构类型,系统也会抛出错误。
在类型检查器中,我们将引入一个新的表格来跟踪结构定义。当我们对初始化器、成员访问操作符以及其他结构操作进行类型检查时,我们会参考这些定义。我们还将在后续阶段使用它们来生成 TACKY 和汇编代码。
解析结构标签
让我们来看看如何在标识符解析过程中处理结构标签。我们将以基本相同的方式重命名这些标签,就像我们重命名局部变量一样。我们将保持一个从用户定义标签到唯一标识符的映射。当我们找到新的结构类型声明时,我们会生成一个新的标识符并将其添加到映射中。当我们遇到结构类型说明符时,我们将用映射中对应的唯一标识符替换它。由于结构标签存在于与函数和变量不同的命名空间中,我们将把它们跟踪在一个单独的映射中。
定义结构标签映射
在我们现有的标识符映射中,我们追踪每个用户定义的函数或变量名称的三项信息:我们将替换它的唯一标识符,它是否有链接性,以及它是否在当前作用域中定义。在结构标签映射中,我们将跟踪每个标签的唯一标识符以及它是否在当前作用域中定义,但我们不会追踪链接性,因为这个概念不适用于类型。请定义这个数据结构。然后,我们将看看如何解析类型说明符和声明中的标签。
解析类型说明符
示例 18-15 展示了如何解析类型说明符。
resolve_type(type_specifier, structure_map):
match type_specifier with
| Structure(tag) ->
if tag is in structure_map:
unique_tag = structure_map.get(tag).new_tag
❶ return Structure(unique_tag)
else:
❷ fail("Specified an undeclared structure type")
| Pointer(referenced_t) ->
resolved_t = resolve_type(referenced_t, structure_map)
return Pointer(resolved_t)
| Array(elem_t, size) ->
`--snip--`
| FunType(param_ts, ret_t) ->
`--snip--`
| t -> return t
示例 18-15:在类型说明符中替换结构标签
resolve_type 函数接受一个类型说明符,并返回该说明符的副本,其中任何结构标签已被唯一 ID 替换。当 resolve_type 遇到结构类型时,它会用来自 structure_map ❶ 的相应标识符替换标签。如果标签不在 structure_map 中,则表示该结构尚未声明,因此会抛出错误 ❷。要解决派生类型(例如 Pointer),我们递归地解析其组成类型。我已经省略了 Array 和 FunType 的伪代码,它们的处理方式与 Pointer 相同。我们返回任何其他未变更的类型。我们将使用 resolve_type 处理 AST 中的每个类型说明符,包括函数和变量声明中的说明符、强制转换和 sizeof 表达式,以及结构成员声明。
解析结构类型声明
接下来,让我们看看 Listing 18-16 中的伪代码,它展示了如何解析结构类型声明。
resolve_structure_declaration(decl, structure_map):
prev_entry = structure_map.get(decl.tag) ❶
if (prev_entry is null) or (not prev_entry.from_current_scope):
unique_tag = make_temporary()
structure_map.add(decl.tag, MapEntry(new_tag=unique_tag, from_current_scope=True)) ❷
else:
unique_tag = prev_entry.new_tag ❸
processed_members = []
for member in decl.members:
processed_type = resolve_type(member.member_type, structure_map) ❹
processed_member = (member_name=member.member_name, member_type=processed_type)
processed_members.append(processed_member)
resolved_decl = (tag=unique_tag, members=processed_members)
return resolved_decl ❺
Listing 18-16: 将结构类型声明添加到结构标签映射中
首先,我们在结构标签映射中查找声明的标签 ❶。如果该标签尚未声明,或者它是在外部作用域声明的,则该声明引入了一个新类型。因此,我们生成一个新的标识符并将其添加到结构标签映射中 ❷。如果该结构的标签已经在当前作用域中声明,则当前声明只是重新声明了相同的类型。在这种情况下,我们不会生成新的唯一 ID;相反,我们使用映射中已有的 ID ❸。
此时,结构标签映射已是最新的。接下来,我们转变结构类型声明本身。如果该声明指定了结构的成员,我们通过对每个成员调用 resolve_type 来解析其成员类型 ❹。我们用 unique_tag(即我们在函数中之前生成或查找的 ID)替换声明的用户定义标签。最后,我们返回转换后的声明 ❺。
请注意,我们在处理任何结构成员之前,将新标签添加到 structure_map 中。这使我们能够接受自引用结构,例如来自 Listing 18-6 的链表节点:
struct linked_list_node {
int val;
struct linked_list_node *next;
};
还需要注意的是,我们不会为结构体成员生成唯一名称。变量和函数需要唯一标识符,因为它们都存储在同一个符号表中,结构体标签需要唯一,因为它们都存储在同一个类型表中,但结构体成员不会全部存储在一个表中。相反,我们会为每个结构体类型维护一个单独的成员列表,因此不同结构体中具有相同名称的成员不会互相冲突。
我们将对标识符解析过程进行两个更新。首先,在每个新作用域的开始,我们将复制结构体标签映射,并将每个条目的 from_current_scope 属性设置为 False,就像我们对标识符映射所做的那样。第二个变化是纯粹的机械操作:我们将扩展 resolve_exp 来处理新的 Dot 和 Arrow 表达式,就像它处理其他所有类型的表达式一样。我会跳过这些变化的伪代码,因为它们都很直白。
类型检查结构体
就像类型检查器会在符号表中记录关于每个函数和变量的信息一样,它也会在类型表中记录每个完整结构体类型的信息。让我们先定义类型表;然后,我们将看看如何将结构体类型声明转换为类型表条目。最后,我们将使用类型表中的信息来对声明、表达式和初始化器进行类型检查。
定义类型表
类型表将我们在前一阶段生成的结构体标签映射到 struct_entry 结构体。清单 18-17 定义了 struct_entry。
struct_entry = StructEntry(int alignment, int size, member_entry* members)
member_entry = MemberEntry(identifier member_name, type member_type, int offset)
清单 18-17:类型表中的一个条目
struct_entry 描述了一个结构体类型的对齐方式、大小和成员。我们通过 member_entry 结构来描述每个成员,该结构指定成员的名称、类型以及它在结构体起始位置的偏移量(以字节为单位)。一个 struct_entry 应该支持两种不同的方式来访问 成员:按名称查找特定成员和获取成员的整个列表。你可能想将 成员 表示为一个有序字典,如果你的实现语言支持的话。
与符号表一样,类型表应该是一个全局变量或单例,您可以在编译器的任何阶段轻松访问它。(为了清晰起见,我们将在本节的伪代码中显式地传递它。)
接下来,我们将看到如何在遍历 AST 时将结构定义添加到类型表中。
填充类型表
当类型检查器遇到一个完整结构类型的定义时,它应该验证该定义,然后将其转换为struct_entry并将其添加到类型表中。类型检查器可以忽略没有成员列表的结构类型声明;没有成员列表的声明要么声明了一个不完整的类型,要么重新声明了一个已经定义的类型。
为了验证结构类型定义,我们将首先检查该结构是否已经存在于类型表中。如果存在,意味着在相同作用域内有相同标签的另一个定义,这时我们将抛出错误。然后,我们将确保结构体的成员没有重复名称,且没有成员的类型是不完整的,也没有成员类型指定了不完整元素类型的数组。(请记住,不完整类型的数组在任何地方都是非法的,不仅仅在结构体定义中。)你可能还希望验证结构体成员是否有函数类型,但这不是严格必要的,因为我们在解析过程中已经验证过这一点。
在验证结构类型满足所有这些要求之后,我们将计算每个成员的偏移量、整个结构的大小和对齐方式。在本章前面,我们已经展示了如何执行这些计算,并且通过一个示例进行了解释。现在让我们看一下 Listing 18-18,它展示了整个过程的伪代码。
typecheck_struct_declaration(struct_decl, type_table):
❶ if struct_decl.members is empty:
return
❷ validate_struct_definition(struct_decl, type_table)
// define a member_entry for each member
❸ member_entries = []
struct_size = 0
struct_alignment = 1
for member in struct_decl.members:
member_alignment = alignment(member.member_type, type_table)
❹ member_offset = round_up(struct_size, member_alignment)
❺ m = MemberEntry(member.member_name, member.member_type,
member_offset)
member_entries.append(m)
struct_alignment = max(struct_alignment, member_alignment)
struct_size = member_offset + size(member.member_type, type_table)
// define a struct_entry for the whole structure
❻ struct_size = round_up(struct_size, struct_alignment)
struct_def = StructEntry(struct_alignment, struct_size, member_entries)
❼ type_table.add(struct_decl.tag, struct_def)
Listing 18-18: 计算结构定义
我们首先检查该声明是否包含成员列表❶。如果没有,我们立即返回,且不对类型表进行任何更改。如果有成员列表,我们将验证其是否满足本节前面描述的要求❷。由于validate_struct_definition的伪代码并不复杂,我就不提供了。
然后,我们进入有趣的部分:弄清楚每个成员在内存中的布局。在这里,我们将为每个结构成员定义一个member_entry❸。在此过程中,我们将维护一个结构体大小的总计,单位为字节,struct_size。我们还将跟踪到目前为止看到的最严格的成员对齐方式,记作struct_alignment。
为了计算结构成员的偏移量,我们采用下一个可用的偏移量,该偏移量由struct_size给出,并将其四舍五入到该成员的对齐方式❹。(稍后我们将介绍如何查找每个类型的大小和对齐方式。)我们构建其member_entry❺,然后更新struct_alignment和struct_size。
一旦处理完每个成员,我们通过将struct_size四舍五入到其对齐方式的最接近倍数来计算结构的总大小❻。这个四舍五入的大小将考虑到结构末尾的任何填充。最后,我们将整个struct_entry添加到类型表❼。
在辅助函数中处理结构体
我们将大量的类型检查逻辑集中到了一些辅助函数中,包括is_scalar和is_complete。您可能也编写了几个辅助函数,用于查找每个类型的大小、对齐方式以及其他属性,尽管我还没有为这些提供伪代码。现在我们将扩展这些助手函数,以处理结构类型。
我们在清单 17-12 中定义了is_scalar。清单 18-19 给出了更新后的定义,本章新增的部分已加粗。
is_scalar(t):
match t with
| Void -> return False
| Array(elem_t, size) -> return False
| FunType(param_ts, ret_t) -> return False
**| Structure(tag) -> return False**
| _ -> return True
清单 18-19:检查一个类型是否是标量
结构类型不是标量类型,因此这部分非常简单。我猜您可能已经编写了类似的辅助函数,用于测试类型是否为算术类型,是否为整数类型等等。这些都需要类似直接的更新,不过我们在这里不再深入探讨。
更新is_complete稍微复杂一些;我们需要查阅类型表。清单 18-20 给出了该函数的新定义。
is_complete(t, type_table):
match t with
| Void -> return False
| Structure(tag) ->
if tag is in type_table:
return True
else:
return False
| _ -> return True
清单 18-20:检查一个类型是否是完整的
如果一个结构类型在类型表中,则它是完整的;如果不在,则它是不完整的。正如我们之前看到的,结构类型在程序中的某些时刻可能是不完整的,但后来会变得完整。在类型检查期间,类型表告诉我们在当前的抽象语法树(AST)中,结构类型是否完整。请参阅清单 18-21 中的代码片段。
❶ struct s;
❷ struct t {
struct s member;
};
❸ struct s {
int a;
int b;
};
清单 18-21:声明一个包含不完整结构类型的变量
因为struct s ❶的第一次声明没有指定任何成员,我们不会将其添加到类型表中。然后,当我们验证struct t ❷的定义时,我们会在类型表中查找struct s。(严格来说,我们会查找在标识符解析阶段替代s的唯一标识符。)当我们找不到它时,我们会正确地得出结论,认为struct s是不完整的,并抛出错误。如果struct t的声明出现在struct s ❸的定义之后,我们会在处理struct t之前将struct s添加到类型表中,这样我们就不会抛出错误。
我们还需要一些辅助函数来查找类型的大小和对齐。清单 18-22 展示了alignment函数的伪代码。
alignment(t, type_table):
match t with
| Structure(tag) ->
struct_def = type_table.get(tag)
return struct_def.alignment
| Array(elem_t, size) ->
return alignment(elem_t, type_table)
| `--snip--`
清单 18-22:计算类型的对齐
要查找一个结构体的对齐,我们将在类型表中查找它。要查找数组的对齐,我们将递归地计算其元素类型的对齐。我们将硬编码其他类型的对齐,这些对齐是由 ABI 决定的。我不会提供size的伪代码,因为它与alignment相似。
注意
我们之前学到,如果一个数组类型的变量大小为 16 字节或更大,它必须是 16 字节对齐的。清单 18-22 没有体现这一要求,因为它计算的是类型的对齐,而不是变量的对齐。如果你还没有这样做,你可能需要编写一个不同的辅助函数来计算变量的对齐。
我们在前面章节中定义的其他辅助函数应该能够正确处理结构体,无需任何修改。考虑一下convert_by_assignment,我们用它来进行赋值表达式和其他需要将值“像赋值一样”转换为特定类型的地方的类型检查。清单 18-23 展示了这段代码的最新版本,来自清单 17-11。
convert_by_assignment(e, target_type):
if get_type(e) == target_type:
return e
if get_type(e) is arithmetic and target_type is arithmetic:
return convert_to(e, target_type)
if is_null_pointer_constant(e) and target_type is a pointer type:
return convert_to(e, target_type)
if target_type == Pointer(Void) and get_type(e) is a pointer type:
return convert_to(e, target_type)
if target_type is a pointer type and get_type(e) == Pointer(Void):
return convert_to(e, target_type)
else:
fail("Cannot convert type for assignment")
清单 18-23:将表达式转换为目标类型
如果我们传递给 convert_by_assignment 一个已经具有正确结构类型的表达式,它将返回原表达式而不做更改。在任何其他具有源或目标结构类型的情况下,它将失败。这是正确的行为,因为没有办法转换到或从结构类型。
处理不完整结构类型
我们需要对不完整结构类型施加若干限制。首先,我们将验证这些类型在声明中的使用;然后,我们将验证它们在表达式中的使用。
声明一个带有不完整结构类型的参数或返回值的函数是合法的,但定义则不合法。(请记住,函数定义是带有函数体的函数声明。)如果 struct s 是不完整类型,类型检查器应该接受以下声明:
void take_a_struct(struct s incomplete);
但是它应该拒绝这个定义:
void take_a_struct(struct s incomplete) {
return;
}
同样,我们将接受不完整结构类型的变量声明,但拒绝任何这些变量的定义,包括临时定义。(这比 C 标准更严格,C 标准在某些有限的情况下允许不完整类型的变量进行临时定义。)具体来说,只有在变量声明具有 extern 存储类并且没有初始化器时,我们才接受不完整结构类型的变量声明。
这处理了声明的问题;现在让我们考虑表达式。使用不完整类型的变量在表达式中只有一种合法方式。你可以获取它的地址,正如以下示例所演示的:
extern struct s my_incomplete_struct;
struct s *ptr = &my_incomplete_struct;
然后,你可以像使用任何其他不完整类型的指针一样使用 ptr。
同样,解引用指向不完整结构的指针并再取其地址也是合法的(尽管不特别有用),结果是你开始时的指针:
struct s *another_ptr = &*ptr;
任何其他对不完整结构类型的表达式使用都是无效的。你甚至不能将其转换为 void 或将其作为表达式语句使用,因此类型检查器应该拒绝以下两条语句:
(void) my_incomplete_struct;
*ptr;
我们将扩展 typecheck_and_convert 来捕获这些无效的表达式。清单 18-24 给出了这个函数的更新定义,原定义中的变化部分在 清单 15-19 中已加粗。
typecheck_and_convert(e, symbols, **type_table**):
typed_e = typecheck_exp(e, symbols, **type_table**)
match get_type(typed_e) with
| Array(elem_t, size) ->
`--snip--`
**| Structure(tag) ->**
**if tag is not in type_table:**
**fail("Invalid use of incomplete structure type")**
**return typed_e**
| _ -> return typed_e
清单 18-24:在 typecheck_and_convert 中拒绝不完整结构类型
请记住,typecheck_and_convert 处理 AST 中的每个表达式,除了静态初始化器(必须是常量)以及 SizeOf 和 AddrOf 表达式的操作数(这些表达式不进行数组衰减)。这使得 typecheck_and_convert 成为添加新验证的最便捷位置,尽管这与该函数的原始目的是隐式地将数组转换为指针无关。通过这项新验证,我们将在每种表达式中正确处理不完全类型:我们将允许在 AddrOf 表达式中使用不完全结构类型,我们现有的验证会拒绝在 SizeOf 表达式中使用所有不完全类型(包括 void),而 typecheck_and_convert 会在其他地方拒绝不完全结构类型。请注意,typecheck_and_convert 仍然接受 void 表达式,这在一些地方是合法的,而不完全结构类型的表达式则不合法。
我们已经实现了对不完全类型所需的所有其他验证。例如,我们已经要求指针运算表达式中的指针操作数指向完整类型,并且我们已经要求数组类型说明符中的元素类型必须是完整的。
成员访问运算符的类型检查
接下来,我们来检查 . 和 -> 运算符。在这两种情况下,我们会验证表达式,找出成员类型,并将其记录为整个表达式的类型。示例 18-25 展示了如何对 . 运算符进行类型检查。
typecheck_exp(e, symbols, type_table):
match e with
| `--snip--`
| Dot(structure, member) ->
typed_structure = typecheck_and_convert(structure, symbols, type_table)
❶ match get_type(typed_structure) with
| Structure(tag) ->
❷ struct_def = type_table.get(tag)
❸ member_def = `<find member in struct_def.members>`
if member_def is not found:
fail("Structure has no member with this name")
member_exp = Dot(typed_structure, member)
❹ return set_type(member_exp, member_def.member_type)
| _ -> fail("Tried to get member of non-structure")
| Arrow(pointer, member) ->
`--snip--`
示例 18-25:类型检查 . 运算符
我们首先通过调用typecheck_and_convert来检查第一个操作数structure的类型(如果structure具有不完整类型,则会抛出错误)。然后,我们验证structure确实是一个结构体 ❶。如果是,我们在类型表中查找其类型 ❷,然后在结果类型表项中查找member ❸。最后,我们用成员类型标注表达式 ❹。如果structure不是结构体,或者没有这个名字的成员,我们将抛出错误。
我不会提供关于类型检查->操作符的伪代码,因为它几乎是相同的;唯一的区别在于,我们验证第一个操作数是指向结构体的指针,而不是结构体本身。
验证左值
->表达式总是左值。为了确定.表达式是否是左值,类型检查器必须递归检查其第一个操作数是否是左值。例如,类型检查器应该拒绝表达式f().member = 3。因为f()不是左值,f().member也不是。
这意味着我们可能会遇到不是左值的数组!显式地获取这种数组的地址是类型错误,就像在清单 18-26 中那样。
struct s {
int arr[3];
};
struct s f(void);
int main(void) {
int *pointer_to_array[3] = &(f().arr);
`--snip--`
}
清单 18-26:非法获取非左值的地址
然而,这些数组仍然会衰减为指针,因此它们的地址仍然会被隐式加载。例如,清单 18-27 中的程序是完全合法的。
struct s {
int arr[3];
};
struct s f(void);
int main(void) {
return f().arr[0];
}
清单 18-27:隐式将非左值数组转换为指针
当我们对这个程序进行类型检查时,我们会插入AddrOf来获取f().arr的地址,就像我们对任何其他数组类型的表达式进行类型检查时一样。
条件表达式中的结构体类型检查
类型检查器应该接受条件表达式,其中两个分支具有相同的结构体类型。它应拒绝那些只有一个分支具有结构体类型,或者两个分支具有不同结构体类型的条件表达式。要判断两个结构体类型是否相同,应比较它们的标签,而不是它们的内容。
结构体初始化器的类型检查
最后,我们将处理结构体初始化器。正如你在本章前面学到的,你可以使用一个结构体类型的单一表达式,或者使用一个复合初始化器来初始化结构体。在第一种情况下,类型检查器不需要做任何修改。
为了处理复合初始化器,我们将对初始化器列表中的每一项进行类型检查,以确保其与相应的成员类型匹配,正如示例 18-28 所示。
typecheck_init(target_type, init, symbols, type_table):
match target_type, init with
| Structure(tag), CompoundInit(init_list) ->
struct_def = type_table.get(tag) ❶
if length(init_list) > length(struct_def.members): ❷
fail("Too many elements in structure initializer")
i = 0
typechecked_list = []
for init_elem in init_list: ❸
t = struct_def.members[i].member_type
typechecked_elem = typecheck_init(t, init_elem, symbols, type_table)
typechecked_list.append(typechecked_elem)
i += 1
while i < length(struct_def.members): ❹
t = struct_def.members[i].member_type
typechecked_list.append(zero_initializer(t))
i += 1
return set_type(CompoundInit(typechecked_list), target_type)
| `--snip--`
示例 18-28:结构体的复合初始化器类型检查
我们将首先查找类型表中的结构体 ❶。我们应该已经验证过target_type是完整的,在调用typecheck_init之前,所以此时可以安全地假设该结构体已经定义。接下来,我们将确保初始化器列表不会过长 ❷。就像我们处理数组初始化器时一样,我们会拒绝包含过多元素的初始化器列表,但接受那些包含过少元素,无法初始化整个对象的初始化器列表。
完成此检查后,我们将遍历初始化器列表 ❸。为了进行类型检查,我们将在结构体的成员列表中查找相应的成员类型,然后递归调用typecheck_init,确保初始化器与该类型兼容。最后,我们将用零填充任何未初始化的结构成员 ❹。
更新了typecheck_init后,你还需要扩展zero_initializer以处理结构体类型。为了将结构体初始化为零,zero_initializer应递归地为每个成员类型调用自身,并将结果返回为一个复合初始化器。
初始化静态结构体
如果结构体具有静态存储持续时间,我们将把它的初始值作为static_init列表存储在符号表中,就像我们处理数组一样。唯一的区别是,我们还会初始化结构体中的任何填充部分。让我们回到示例 18-10 中的例子:
struct example {
int member1;
double member2;
char array[3];
};
struct example var = {1, 2.0, {'a', 'b', 'c'}};
我们发现这个结构体在 member1 和 member2 之间包含了 4 个字节的填充,在 array 之后有 5 个字节的填充。如果 var 是一个静态变量,我们将使用清单 18-29 中的构造来表示其初始值。
Initial([IntInit(1),
❶ ZeroInit(4),
DoubleInit(2.0),
CharInit(97),
CharInit(98),
CharInit(99),
❷ ZeroInit(5)])
清单 18-29:将来自清单 18-10 的初始化器表示为一个 static_init 列表
我们使用 ZeroInit 构造来初始化填充 ❶❷,因为 C 标准要求静态结构体中的填充部分必须初始化为零。清单 18-30 展示了如何生成像清单 18-29 中的静态初始化器列表。
create_static_init_list(init_type, initializer, type_table):
match init_type, initializer with
| Structure(tag), CompoundInit(init_list) ->
❶ struct_def = type_table.get(tag)
if length(init_list) > length(struct_def.members):
fail("Too many elements in structure initializer")
current_offset = 0
static_inits = []
i = 0
for init_elem in init_list:
member = struct_def.members[i]
if member.offset != current_offset:
❷ static_inits.append(ZeroInit(member.offset - current_offset))
❸ more_static_inits = create_static_init_list(member.member_type,
init_elem,
type_table)
static_inits.append_all(more_static_inits)
current_offset = member.offset + size(member.member_type,
type_table)
i += 1
if struct_def.size != current_offset:
❹ static_inits.append(ZeroInit(struct_def.size - current_offset))
return static_inits
| Structure(tag), SingleInit(e) ->
❺ fail("Cannot initialize static structure with scalar expression")
| `--snip--`
清单 18-30:为结构体生成静态初始化器
为了处理静态结构体的复合初始化器,我们首先在类型表中查找该结构体 ❶。我们确保初始化器列表不过长,就像在处理非静态初始化器时对 typecheck_init 做的那样。然后,我们遍历初始化器列表,为结构体成员列表中的每个元素查找对应的成员定义。过程中,我们更新 current_offset 变量来追踪已初始化的字节数。
每次处理结构体成员的初始化器时,我们首先检查是否已经初始化了足够的字节,以达到预期的偏移量。如果没有,我们会使用 ZeroInit 初始化器 ❷ 来添加必要的填充。然后,我们通过递归调用 create_static_init_list ❸ 来为结构体成员本身创建初始化器列表。接下来,我们根据刚刚初始化的成员的偏移量和大小来更新 current_offset。
一旦我们初始化了所有结构体成员,如果需要,我们会添加另一个 ZeroInit,以将结构体填充到正确的大小 ❹。这个最后的 ZeroInit 会将任何未显式初始化的结构体成员以及最后一个成员后的任何填充部分置为零。
由于没有结构体类型的常量,用 SingleInit 表达式初始化静态结构体是一个类型错误 ❺。
TACKY 生成
在本节中,我们将介绍最后一条 TACKY 指令:
CopyFromOffset(identifier src, int offset, val dst)
本指令与我们在第十五章中添加的 CopyToOffset 指令相似。CopyFromOffset 中的 src 标识符是一个聚合变量的名称,offset 是该变量内子对象的字节偏移量,dst 是我们将该子对象复制到的变量。清单 18-31 定义了完整的 TACKY IR,包括新的 CopyFromOffset 指令。
program = Program(top_level*)
top_level = Function(identifier, bool global, identifier* params, instruction* body)
| StaticVariable(identifier, bool global, type t, static_init* init_list)
| StaticConstant(identifier, type t, static_init init)
instruction = Return(val?)
| SignExtend(val src, val dst)
| Truncate(val src, val dst)
| ZeroExtend(val src, val dst)
| DoubleToInt(val src, val dst)
| DoubleToUInt(val src, val dst)
| IntToDouble(val src, val dst)
| UIntToDouble(val src, val dst)
| Unary(unary_operator, val src, val dst)
| Binary(binary_operator, val src1, val src2, val dst)
| Copy(val src, val dst)
| GetAddress(val src, val dst)
| Load(val src_ptr, val dst)
| Store(val src, val dst_ptr)
| AddPtr(val ptr, val index, int scale, val dst)
| CopyToOffset(val src, identifier dst, int offset)
**| CopyFromOffset(identifier src, int offset, val dst)**
| Jump(identifier target)
| JumpIfZero(val condition, identifier target)
| JumpIfNotZero(val condition, identifier target)
| Label(identifier)
| FunCall(identifier fun_name, val* args, val? dst)
val = Constant(const) | Var(identifier)
unary_operator = Complement | Negate | Not
binary_operator = Add | Subtract | Multiply | Divide | Remainder | Equal | NotEqual
| LessThan | LessOrEqual | GreaterThan | GreaterOrEqual
清单 18-31: 将 CopyFromOffset 添加到 TACKY IR 中
不仅可以使用 CopyToOffset 和 CopyFromOffset 指令访问结构中的子对象,还可以使用 Copy、Load 和 Store 将整个结构从一个位置复制到另一个位置,或通过 Return 和 FunCall 在函数之间传递,就像标量变量一样。我们将结构类型的变量表示为普通的 TACKY Var。
接下来,我们将成员访问操作符转换为 TACKY。然后,我们将处理复合结构初始化器。我们不会改变处理大多数可以使用结构的构造的方式,例如函数调用、return 语句和条件表达式。我们也不需要对新的顶层 StructDecl 构造做任何处理;在这个阶段,我们将丢弃结构声明,就像我们丢弃没有函数体的函数声明和没有初始化器的变量声明一样。
实现成员访问操作符
在前面的章节中,你学到了可以通过三种方式使用一个对象:你可以将其转换为左值、对其进行赋值,或获取它的地址。现在有了第四种选择:如果该对象是一个结构体,你可以访问它的某个成员。由于该结构体成员本身就是一个对象,你可以将其转换为左值、对其进行赋值、获取其地址,或访问它的成员。接下来,让我们看一下在这些情况下应该生成的 TACKY。然后,基于我们在第十四章中处理解引用指针的方法,我们将引入一种新的类型exp_result来表示结构体成员。
在 TACKY 中访问结构体成员
为了对结构体成员执行任何操作,我们将首先查找成员在类型表中的偏移量。首先,让我们考虑结构体本身是 TACKY 变量,而不是解引用的指针或某个更大结构体中的子对象的情况。要将结构体成员转换为左值,我们将使用CopyFromOffset指令。我们将转换
`<instructions for struct>`
s = `<result of struct>`
result = CopyFromOffset(s, `<member offset>`)
我们将使用CopyToOffset对结构体成员进行赋值,将
`<instructions for struct>`
dst = `<result of struct>`
`<instructions for right>`
src = `<result of right>`
CopyToOffset(src, dst, `<member offset>`)
要获取结构体成员的地址,我们首先加载包含它的对象的地址,然后加上该成员的偏移量。我们将转换&
`<instructions for struct>`
s = `<result of struct>`
result = GetAddress(s)
result = AddPtr(ptr=result, index=`<member offset>`, scale=1)
要处理一系列嵌套成员访问,我们将把它们的所有偏移量加在一起,然后根据序列中最后一个成员的使用方式发出指令。请考虑清单 18-32 中的结构体声明。
struct inner {
char c;
int i;
};
struct outer {
int member1;
struct inner member2;
};
清单 18-32:声明一个包含嵌套结构的结构体
如果my_struct是一个struct outer,并且我们需要将my_struct.member2.i转换为左值,我们将生成:
result = CopyFromOffset("my_struct", 8)
因为 member2 在 struct outer 中的偏移量是 4 字节,而 i 在 struct inner 中的偏移量是 4 字节,因此通过 my_struct.member2.i 指定的对象从 my_struct 开始的总偏移量是 8 字节。
最后,考虑通过解引用指针访问结构成员。最典型的做法是使用箭头操作符,形式为
inner_struct_pointer->i = 1
到:
ptr = AddPtr(ptr=inner_struct_pointer, index=4, scale=1)
Store(1, ptr)
我们可以用完全相同的指令实现等效表达式。
(*inner_struct_pointer).i = 1
现在我们知道了想要生成的指令,将相应地更新 TACKY 生成阶段。
使用 SubObject 指定结构成员
让我们扩展 exp_result 构造体,以指定一个聚合对象的成员。列表 18-33 给出了更新后的 exp_result 定义。
exp_result = PlainOperand(val)
| DereferencedPointer(val)
**| SubObject(identifier base, int offset)**
列表 18-33:扩展 exp_result 以表示子对象
base 作为 SubObject 的参数是一个聚合对象,而不是指针。第二个参数 offset 是该对象的字节偏移量。SubObject 所指定的对象可以是标量,也可以是聚合对象。在 清单 18-34 中,我们使用该构造表示 Dot 运算符的结果。
emit_tacky(e, instructions, symbols, type_table):
match e with
| `--snip--`
| Dot(structure, member) ->
struct_def = `<look up structure's type in the type table>`
member_offset = `<look up member offset in struct_def>`
inner_object = emit_tacky(structure, instructions, symbols, type_table)
match inner_object with
| PlainOperand(Var(v)) -> return SubObject(v, member_offset) ❶
| SubObject(base, offset) -> return SubObject(base, offset + member_offset) ❷
| DereferencedPointer(ptr) -> ❸
dst_ptr = make_tacky_variable(Pointer(get_type(e)), symbols)
instr = AddPtr(ptr=ptr, index=Constant(ConstLong(member_offset)),
scale=1, dst=dst_ptr)
instructions.append(instr)
return DereferencedPointer(dst_ptr)
清单 18-34: 将 点 运算符转换为 TACKY
首先,我们查找 member 在结构中的偏移量。然后,我们处理该表达式的第一个操作数,而不进行左值转换。结果对象可以是一个普通的 TACKY 变量,一个 TACKY 变量的子对象,或一个解引用的指针。(我们知道结果不是常量,因为 TACKY 没有结构类型的常量。)
如果 inner_object 只是一个变量,我们返回一个 SubObject,指定该变量中 member_offset 位置的对象 ❶。如果 inner_object 本身是某个更大变量中的子对象,我们将其偏移量加到 member_offset 上 ❷。这处理了像本节前面提到的表达式 my_struct .member2.i 这样的嵌套成员运算符。
最后,如果内部结构是解引用的指针,我们通过指针运算符访问结构成员 ❸。由于 DereferencedPointer(ptr) 指定了整个结构,因此 ptr 必须指向结构的起始位置。我们将 member_offset 加到 ptr 上,以获取指向指定结构成员的指针。然后,我们解引用此指针来指定结构成员本身。
处理 SubObject
接下来,我们将在左值转换、赋值表达式和 AddrOf 表达式中处理 SubObject 构造。要进行左值转换一个 SubObject,我们使用 CopyFromOffset 指令将其复制到一个新变量中,正如 列表 18-35 所示。
emit_tacky_and_convert(e, instructions, symbols, type_table):
result = emit_tacky(e, instructions, symbols, type_table)
match result with
| SubObject(base, offset) ->
dst = make_tacky_variable(get_type(e), symbols)
instructions.append(CopyFromOffset(base, offset, dst))
return dst
| `--snip--`
列表 18-35:左值转换一个 SubObject
相反,当一个 SubObject 出现在赋值表达式的左侧时,我们通过 CopyToOffset 指令向其写入数据,正如 列表 18-36 所示。
emit_tacky(e, instructions, symbols, type_table):
match e with
| `--snip--`
| Assignment(left, right) ->
lval = emit_tacky(left, instructions, symbols, type_table)
rval = emit_tacky_and_convert(right, instructions, symbols, type_table)
match lval with
| SubObject(base, offset) ->
instructions.append(CopyToOffset(rval, base, offset))
return PlainOperand(rval)
| `--snip--`
列表 18-36:赋值给一个 SubObject
最后,列表 18-37 展示了如何计算一个 SubObject 的地址。我们加载基对象的地址,然后加上偏移量。
| AddrOf(inner) ->
v = emit_tacky(inner, instructions, symbols, type_table)
match v with
| SubObject(base, offset) ->
dst = make_tacky_variable(get_type(e), symbols)
instructions.append(GetAddress(Var(base), dst))
instructions.append(AddPtr(ptr=dst,
index=Constant(ConstLong(offset)),
❶ scale=1,
dst=dst))
return PlainOperand(dst)
| `--snip--`
列表 18-37:获取 a 的地址 SubObject
我们重用相同的临时变量 dst,它同时指向结构体的基址和其成员。我们也可以生成两个不同的临时变量,但不需要这么做。因为 SubObject 构造中的偏移量是以字节为单位的,所以此 AddPtr 指令的规模是 1 ❶。
实现箭头操作符
现在我们已经实现了Dot,我们也可以轻松实现Arrow。为了计算ptr->member,我们首先会对ptr进行求值并进行左值转换。然后,我们使用AddPtr加上member的偏移量。这将为我们提供指向指定结构成员的指针。最后,我们将使用DereferencedPointer构造解引用此指针。我将省略该部分的伪代码;你已经看到如何将(*ptr).member转换为 TACKY,转换ptr->member为 TACKY 也非常相似。
我们不需要额外的逻辑来处理Arrow表达式的结果。该表达式将始终生成一个DereferencedPointer构造,我们已经知道如何处理。
省略无用的 AddPtr 指令
结构中的第一个成员的偏移量始终为零。作为一种可选的优化,在计算该成员的地址时,可以跳过AddPtr指令。这影响了清单 18-34 和 18-37,以及Arrow的实现,我没有提供其伪代码。在这三种情况下,如果member_offset为0,则无需生成AddPtr指令。
将复合初始化器转换为 TACKY
为了完成这一部分,我们将复合结构初始化器转换为 TACKY。基本方法与前几章相同:我们依次评估初始化器列表中的每个表达式,并使用CopyToOffset指令将每个表达式的结果复制到目标的正确偏移量。但现在,我们需要检查类型表,以找到每个表达式的正确偏移量。我们还需要计算嵌套结构、结构数组、包含数组的结构等中的子对象的偏移量。
清单 18-38 演示了如何跟踪这些偏移量,并在遍历结构或数组的复合初始化器时发出CopyToOffset指令。
compound_initializer_to_tacky(var_name, offset, init, instructions, symbols, type_table):
match init, get_type(init) with
| SingleInit(String(s)), Array(elem_t, size) -> `--snip--`
| SingleInit(e), t ->
v = emit_tacky_and_convert(e, instructions, symbols, type_table)
instructions.append(CopyToOffset(v, var_name, offset)) ❶
| CompoundInit(init_list), Structure(tag) ->
members = type_table.get(tag).members
for mem_init, member in zip(init_list, members):
mem_offset = offset + member.offset ❷
compound_initializer_to_tacky(var_name, mem_offset, mem_init, instructions,
symbols, type_table)
| CompoundInit(init_list), Array(elem_t, size) ->
`--snip--`
列表 18-38:将复合初始化器转换为 TACKY
compound_initializer_to_tacky的参数包括var_name(正在初始化的数组或结构变量的名称)、offset(该变量中当前子对象的字节偏移量)和init(初始化器本身)。在初始化整个变量的顶级调用中,offset参数将是0。
在基础情况下,我们使用单个表达式的值初始化一个子对象。这个表达式可能是一个字符串字面量,用于初始化一个数组;我省略了这个情况的伪代码,我们在第十六章中已经讨论过了。如果不是这种情况,我们会计算该表达式并通过CopyToOffset指令❶将结果复制到目标位置。即使结果具有结构类型,我们也可以通过单条指令将其复制到目标位置。
当我们遇到一个结构的复合初始化器时,我们会在类型表中查找该结构的成员列表。通过将每个初始化项的偏移量与对应成员的偏移量相加,我们计算出初始化器列表中每个项的偏移量。然后,我们递归处理这些项。我不会详细讲解数组的复合初始化器情况,因为你已经知道如何处理了。
我们的实现与 C 标准在这里略有偏差。在某些情况下,标准要求填充部分初始化为零;列表 18-38 并未初始化结构体的填充部分,而且我们的测试也没有检查非静态结构中的填充部分的值。
到目前为止,你已经知道如何将成员访问运算符和复合结构初始化器转换为 TACKY。实现这些转换后,你可以测试此编译器阶段。
System V 调用约定中的结构
本章中生成汇编代码最棘手的部分是处理函数调用。像往常一样,我们需要根据 System V x64 调用约定传递参数和返回值。传递和返回结构的规则尤其复杂,因此在对后端做出任何更改之前,我们需要先了解这些规则。
结构分类
在 System V x64 ABI 中,每个参数和返回值都有一个类别,决定了它在函数调用期间如何传输。我们已经遇到了 ABI 中定义的两个类别,虽然我没有用类别这个术语来描述它们。具有整数、字符和指针类型的值都属于 INTEGER 类别;它们通过通用寄存器传输。具有类型double的值都属于 SSE 类别;它们通过 XMM 寄存器传输。
在本章中,我们将遇到第三个类别:MEMORY,用于必须通过内存传输的大型值。我们以前已经通过内存传递了函数参数,但通过内存传递返回值是一个新概念;稍后我们将详细介绍它是如何工作的。
ABI 提供了一个相对复杂的算法,用于对结构体和联合体进行分类。由于有许多类型我们不处理,比如float和联合体,我们可以使用这个算法的简化版本。在本节中,我们将讲解简化的结构体分类规则。有关完整的算法,请参阅“附加资源”中列出的文档,见第 553 页。
将结构体拆分成八字节
我们将为结构体的每个 8 字节部分分配一个单独的类别。ABI 称这些部分为八字节。如果结构体的大小不能被 8 整除,最后一个八字节可能会短于 8 字节(这使得这个术语有些误导)。请参考清单 18-39,它声明了一个 12 字节的结构体。
struct twelve_bytes {
int i;
char arr[8];
};
清单 18-39:一个包含两个八字节的结构体
该结构体的第一个八字节包含i和arr的前四个元素。第二个八字节长 4 字节,包含arr的最后四个元素。图 18-2 展示了该结构体在内存中的布局。

图 18-2:结构体十二字节在内存中的布局 说明
图 18-2 展示了一个像arr这样的嵌套数组可以跨越多个八字节。嵌套结构体也是如此。请参考清单 18-40 中的结构声明。
struct inner {
int i;
char ch2;
};
struct nested_ints {
char ch1;
struct inner nested;
};
清单 18-40:包含一个跨越两个八字节的嵌套结构体的结构类型
图 18-3 展示了struct nested_ints在内存中的布局。

Figure 18-3:结构体 nested_ints 在内存中的布局 描述
该结构体的第一个八字节包含两个标量值:ch1和嵌套成员nested.i。第二个八字节包含nested.ch2。在分类结构体时,我们关心的是每个八字节包含的标量值,而不关心这些值是如何在嵌套结构或数组中组织的。就我们的分类算法而言,struct nested_ints等同于 Listing 18-41 中定义的struct flattened_ints类型。
struct flattened_ints {
char c;
int i;
char a;
};
Listing 18-41:具有与 struct nested_ints 相同布局的结构体
这个结构体在内存中的布局与struct nested_ints完全相同:它的第一个八字节包含一个char和一个int,第二个八字节包含另一个char。
八字节分类
如果一个结构体大于 16 字节——换句话说,如果它由三个或更多的八字节组成——我们将把每个八字节分配到 MEMORY 类别。例如,struct large由四个八字节组成,这些八字节都被归类为 MEMORY:
struct large {
int i;
double d;
char arr[10];
};
如果一个结构体为 16 字节或更小,我们会根据其内容将每个八字节分配到 INTEGER 或 SSE 类别。如果一个八字节包含一个double,它属于 SSE 类别;如果包含其他内容,则属于 INTEGER 类别。例如,Listing 18-39 中的struct twelve_bytes的两个八字节都属于 INTEGER 类别。我们还将 Listing 18-40 中的struct nested_ints的两个八字节和 Listing 18-41 中的struct flattened_ints的两个八字节也都分配到 INTEGER 类别,因为它们都不包含double。
Listing 18-42 定义了更多的结构体类型。让我们来分类其中的每一个。
struct two_ints {
int i;
int i2;
};
struct nested_double {
double array[1];
};
struct two_eightbytes {
double d;
char c;
};
Listing 18-42:更多结构体类型
struct two_ints 由一个八字节组成,它属于 INTEGER 类。struct nested_double 由一个八字节组成,它属于 SSE 类。struct two_eightbytes 由两个八字节组成:第一个属于 SSE 类,因为它包含一个 double,第二个属于 INTEGER 类,因为它包含一个 char。
传递结构体类型的参数
一旦我们对结构体进行了分类,就可以确定如何将其作为参数传递。如果结构体由一个或两个八字节组成,我们会将结构体的每个八字节传递到该类别的下一个可用寄存器中。如果结构体由一个八字节组成,并且属于 INTEGER 类,我们将把它传递到下一个通用参数传递寄存器中。如果它由一个八字节组成,并且属于 SSE 类,我们将把它传递到下一个可用的参数传递 XMM 寄存器中。如果它由一个 INTEGER 八字节和一个 SSE 八字节组成,我们将把第一个八字节传递到通用寄存器中,第二个八字节传递到 XMM 寄存器中,依此类推。如果没有足够的寄存器来传递整个结构体,我们将把整个结构体压入栈中。
让我们来看几个例子。首先,在 清单 18-43 中,我们重现了在 清单 18-42 中定义的 struct two_eightbytes 类型,并声明了一个接收该类型参数的函数。
struct two_eightbytes {
double d;
char c;
};
void pass_struct(struct two_eightbytes param);
清单 18-43:带有 struct two_eightbytes 参数的函数声明
假设 x 是类型为 struct two_eightbytes 的变量,且该变量存储在栈上,地址为 -16(%rbp)。我们可能会将函数调用 pass _struct(x) 转换为 清单 18-44 中的汇编代码。
movsd -16(%rbp), %xmm0
movq -8(%rbp), %rdi
call pass_struct
清单 18-44:在两个寄存器中传递结构体参数
因为该结构体的第一个八字节属于 SSE 类,我们将其传递到第一个参数传递 XMM 寄存器 XMM0 中。结构体的第二个八字节属于 INTEGER 类,因此我们将其传递到第一个通用参数传递寄存器 RDI 中。
接下来,让我们看一下列表 18-45。这个列表声明了一个带有结构体参数的函数,我们需要将该参数推送到堆栈上。
struct two_longs {
long a;
long b;
};
void a_bunch_of_arguments(int i0, int i1, int i2, int i3, int i4,
struct two_longs param, int i5);
列表 18-45:带有必须通过内存传递的结构体参数的函数声明
当我们调用a_bunch_of_arguments时,我们会通过寄存器 EDI、ESI、EDX、ECX 和 R8D 传递参数i0到i4。这不会留下足够的寄存器来传递param参数;这两个八字节属于整数类型,但只有一个通用的参数传递寄存器 R9 可用。因此,我们将整个结构体推送到堆栈上。然后,由于 R9D 仍然可用,我们将使用它来传输i5。如果arg是一个具有静态存储持续时间的struct two_longs,我们可以将函数调用转换为
a_bunch_of_arguments(0, 1, 2, 3, 4, arg, 5);
列表 18-46 中的汇编代码。
movl $0, %edi
movl $1, %esi
movl $2, %edx
movl $3, %ecx
movl $4, %r8d
movl $5, %r9d
❶ pushq arg+8(%rip)
pushq arg(%rip)
call a_bunch_of_arguments
列表 18-46:将结构体传递到堆栈上
因为arg位于数据段中,我们通过 RIP 相对寻址来访问它。我们这里使用了一些新的汇编语法:arg+8(%rip)表示标签arg之后 8 字节的地址。因此,我们的第一个push指令将把结构体的第二个八字节推送到堆栈上,其中包含成员b,如图 ❶所示。这保留了结构体在内存中的布局,如图 18-4 所示。

图 18-4:将结构体推送到堆栈上 描述
列表 18-46 中的两条pushq指令将带有正确布局的arg副本推送到堆栈上。被调用者在设置堆栈帧后,可以在16(%rbp)处访问arg,这是我们总是期望找到函数第一个堆栈参数的位置。
如果一个结构体属于内存类型,我们将始终将它推送到堆栈上。请参考列表 18-47 中的结构体类型声明和函数声明。
struct pass_in_memory {
double w;
double x;
int y;
long z;
};
void accept_struct(struct pass_in_memory arg);
列表 18-47:带有属于 MEMORY 类结构参数的函数声明
列表 18-48 演示了如何将存储在 -32(%rbp) 处的结构作为参数传递给 accept_struct。
pushq -8(%rbp)
pushq -16(%rbp)
pushq -24(%rbp)
pushq -32 (%rbp)
call accept_struct
列表 18-48:在栈上传递属于 MEMORY 类的结构
在这种情况下,像在列表 18-46 中一样,我们通过将结构从后到前压入栈中来保持结构在内存中的布局。
返回结构
如果结构适合放入单个寄存器,返回它就非常直接。我们将把属于 INTEGER 类的结构返回在 RAX 中,把属于 SSE 类的结构返回在 XMM0 中。如果一个结构的大小在 8 到 16 字节之间,我们将用两个寄存器返回它。为了容纳这些结构,我们将指定另外两个寄存器来传输返回值:RDX 和 XMM1。我们将在适当类的下一个可用返回寄存器中传输结构的每个八字节部分。例如,如果一个结构的第一部分属于 SSE 类,第二部分属于 INTEGER 类,我们将把第一部分传输到 XMM0,第二部分传输到 RAX。如果两个部分都属于 SSE 类,我们将把结构传输到 XMM0 和 XMM1;如果两个部分都属于 INTEGER 类,我们将把它传输到 RAX 和 RDX。
如果结构属于 MEMORY 类,事情会更加复杂。在这种情况下,调用者为返回值分配空间,并将该空间的地址通过 RDI 寄存器传递给被调用者,就像它是第一个整数参数一样。这意味着实际的第一个整数参数必须通过 RSI 传递,第二个通过 RDX 传递,以此类推。为了返回一个值,被调用者将其复制到 RDI 指向的空间中,并将指针本身复制到 RAX 中。让我们看一下列表 18-49 中的例子。
struct large_struct {
long array[3];
};
struct large_struct return_a_struct(long i) {
struct large_struct callee_result = {{0, 1, i}};
return callee_result;
}
int main(void) {
❶ struct large_struct caller_result = return_a_struct(10);
`--snip--`
}
列表 18-49:调用返回内存中结构的函数
因为 struct large_struct 是 24 字节,所以我们将在内存中返回 return_a_struct 函数的结果。在 main 中,我们调用 return_a_struct 并将结果赋值给 caller_result ❶。假设 main 已经为 caller_result 在 -24(%rbp) 预留了栈空间,Listing 18-50 展示了如何实现此函数调用。
leaq -24(%rbp), %rdi
movq $10, %rsi
call return_a_struct
Listing 18-50:汇编中调用 return_a_struct 函数
首先,我们将 caller_result 的地址传递给 RDI,该地址将保存 return_a_struct 的结果。然后,我们在下一个可用的参数传递寄存器 RSI 中传递参数 10。最后,我们发出 call 指令。图 18-5 说明了程序在发出 call 指令前的状态。

图 18-5:调用 return_a_struct 前栈和寄存器的状态 描述
如常,RSP 和 RBP 指向当前栈帧的顶部和底部。RDI 指向尚未初始化的 caller_result。RSI 保存着传递给 return_a_struct 的第一个参数 10。
请注意,清单 18-50 没有为存储 return_a_struct 的结果分配额外的堆栈空间;它只是加载了 caller_result 变量的地址。通常这是可以的,但有一个前提:根据 ABI,存储返回值的内存“不能与被调用方通过除该参数外的其他名称可见的数据重叠。”例如,如果你需要实现如下函数调用:var = foo(1, 2, &var),那么将 var 的地址既传递给 RDI 作为返回值的存储位置,又传递给 RCX 作为普通参数,会违反 ABI。相反,你需要为存储 foo 的结果分配额外的堆栈空间,并在函数返回后将结果复制到 var。我们不需要担心这种情况,因为我们在 TACKY 生成过程中为每次函数调用生成一个新的变量来存储结果。
现在让我们来看一下实现 return_a_struct 的汇编代码 清单 18-51。
return_a_struct:
pushq %rbp
movq %rsp, %rbp
subq $32, %rsp
movq $0, -24(%rbp)
movq $1, -16(%rbp)
movq %rsi, -8(%rbp)
❶ movq -24(%rbp), %r10
movq %r10, (%rdi)
movq -16(%rbp), %r10
movq %r10, 8(%rdi)
movq -8(%rbp), %r10
movq %r10, 16(%rdi)
❷ movq %rdi, %rax
`--snip--`
清单 18-51:在内存中返回结构体
在函数开始时,我们设置堆栈帧,为局部变量 callee_result 分配堆栈空间,地址为 -24(%rbp),然后初始化它。返回 result 的汇编代码从 ❶ 开始。首先,我们将 result 复制到 RDI 指向的内存位置,每次复制 8 个字节;我们将前 8 个字节复制到 (%rdi),接下来的 8 个字节复制到 8(%rdi),最后 8 个字节复制到 16(%rdi)。然后,我们将返回值的指针从 RDI 复制到 RAX ❷。最后,我们执行函数的尾部操作,这部分代码在本清单中省略。图 18-6 展示了函数尾部操作前程序的状态。

图 18-6:从 return_a_struct 返回前堆栈和寄存器的状态 说明
调用者栈帧中的 caller_result 变量现在保存着函数的返回值,而 RAX 寄存器保存着 caller_result 的地址。在这个例子中,RDI 寄存器也保存着该地址,但这并不是 ABI 所要求的。
我们已经涵盖了与结构体调用约定相关的所有内容。现在我们准备开始处理汇编生成过程!
汇编生成
这个传递过程将会有几个变化。首先,我们需要生成汇编代码,以便将整个结构从一个位置复制到另一个位置。Copy、Load、Store 和 CopyToOffset 指令都可以传输标量值和聚合值。新的 CopyFromOffset 指令也可以实现这一点,我们需要实现它。其次,我们将实现刚刚学习过的 System V 调用约定。
我们将通过对汇编 AST 做出一些小改动来开始。
扩展汇编 AST
在 Listing 18-46 中,我们使用了操作数 arg+8(%rip) 来访问与 RIP 相对标签具有常量偏移的数据。我们通常需要这种类型的操作数来访问具有静态存储持续时间的结构成员。汇编 AST 已经可以指定从大多数内存地址的常量偏移量,但不能从 RIP 相对地址指定。我们将通过向 Data 操作数添加一个偏移量来移除这个限制:
operand = `--snip--` | Data(identifier, **int**)
我们还将引入两条新的汇编指令。首先,我们将添加左移指令 shl。该指令将一个立即数作为源操作数,一个内存地址或寄存器作为目标操作数。它将目标操作数向左移动指定的位数。例如,指令
shlq $8, %rax
将 RAX 中的值左移 1 字节,最低字节设为 0。如果 RAX 中的值在执行此指令前为 0x8,则执行后将变为 0x800。shl 指令将帮助我们将不规则大小的结构复制到寄存器中,以便将它们作为参数和返回值传递。由于我们不能直接访问寄存器中的每个字节,我们将使用 shl 将每个字节移到正确的位置。
其次,我们将添加逻辑右移指令 shr 的双操作数形式。(我们在第十三章中已经添加了单操作数形式,它将操作数向右移动 1 位。)与 shl 类似,它将目标操作数按源操作数指定的位数向右移动。它将发挥与 shl 类似的作用,帮助我们将不规则大小的结构从寄存器转移到内存中,从寄存器移出。
注意
shl 指令也有一个单操作数形式,但我们不会使用它;它是与我们已经熟悉的 shr 单操作数形式的对照形式。两条指令还有一种我们也不会使用的形式,它使用 CL 寄存器作为源操作数。
我们将扩展 binary_operator 来表示 shl 和 shr:
binary_operator = `--snip--` | **Shl | ShrTwoOp**
新的二元 shr 指令获得了一个较为笨重的名称 ShrTwoOp,以便与现有的单元 Shr 指令在汇编 AST 中区分开来。
Listing 18-52 定义了更新后的汇编 AST,变化部分已加粗显示。
program = Program(top_level*)
assembly_type = Byte | Longword | Quadword | Double | ByteArray(int size, int alignment)
top_level = Function(identifier name, bool global, instruction* instructions)
| StaticVariable(identifier name, bool global, int alignment, static_init* init_list)
| StaticConstant(identifier name, int alignment, static_init init)
instruction = Mov(assembly_type, operand src, operand dst)
| Movsx(assembly_type src_type, assembly_type dst_type, operand src, operand dst)
| MovZeroExtend(assembly_type src_type, assembly_type dst_type,
operand src, operand dst)
| Lea(operand src, operand dst)
| Cvttsd2si(assembly_type dst_type, operand src, operand dst)
| Cvtsi2sd(assembly_type src_type, operand src, operand dst)
| Unary(unary_operator, assembly_type, operand)
| Binary(binary_operator, assembly_type, operand, operand)
| Cmp(assembly_type, operand, operand)
| Idiv(assembly_type, operand)
| Div(assembly_type, operand)
| Cdq(assembly_type)
| Jmp(identifier)
| JmpCC(cond_code, identifier)
| SetCC(cond_code, operand)
| Label(identifier)
| Push(operand)
| Call(identifier)
| Ret
unary_operator = Neg | Not | Shr
binary_operator = Add | Sub | Mult | DivDouble | And | Or | Xor | **Shl | ShrTwoOp**
operand = Imm(int) | Reg(reg) | Pseudo(identifier) | Memory(reg, int) | Data(identifier, **int**)
| PseudoMem(identifier, int) | Indexed(reg base, reg index, int scale)
cond_code = E | NE | G | GE | L | LE | A | AE | B | BE
reg = AX | CX | DX | DI | SI | R8 | R9 | R10 | R11 | SP | BP
| XMM0 | XMM1 | XMM2 | XMM3 | XMM4 | XMM5 | XMM6 | XMM7 | XMM14 | XMM15
Listing 18-52:带有静态操作数偏移量和位移指令的汇编 AST
类型转换和 TACKY 操作数转换为汇编相对简单。结构类型,如数组类型,转换为 ByteArray 汇编类型。要将结构类型转换为汇编,你需要在类型表中查找它的大小和对齐方式。我们将结构类型的 TACKY 变量转换为 PseudoMem 汇编操作数,就像我们处理数组一样。我们始终将数组和结构存储在内存中,而不是寄存器中,即使我们在第三部分实现寄存器分配后也是如此。
你遇到的一些 TACKY 变量可能具有不完整的结构类型。(记住,声明一个 extern 变量并取其地址是合法的,但定义它或以其他方式使用它是非法的。)将这些变量转换为 PseudoMem 操作数,就像其他结构类型的变量一样。当你将它们添加到后端符号表时,可以为这些变量指定一个虚拟的汇编类型;该虚拟类型永远不会被使用。
接下来,让我们处理 Copy、Load、Store、CopyToOffset 和 CopyFromOffset 指令。然后,我们将处理函数调用。
复制结构体
要将结构体复制到新位置,你不需要考虑其成员的类型或偏移量;你只需要复制正确数量的字节。为了最小化所需的 mov 指令数量,一次复制 8 个字节,直到剩下少于 8 个字节需要移动。然后,每次复制 4 个字节。最后,当剩下的字节数少于 4 个字节时,每次复制 1 个字节。(x64 汇编中还有一个 2 字节的 mov 指令,但我们的汇编 AST 不支持它。)例如,如果 a 和 b 是 20 字节的结构体,你应该翻译
Copy(Var("a"), Var("b"))
到 列表 18-53 中的汇编代码。
Mov(Quadword, PseudoMem("a", 0), PseudoMem("b", 0))
Mov(Quadword, PseudoMem("a", 8), PseudoMem("b", 8))
Mov(Longword, PseudoMem("a", 16), PseudoMem("b", 16))
列表 18-53:实现 非标量值的 Copy
第一个 Mov 指令将 a 的前 8 个字节复制到 b;第二条指令将 a 的下一个 8 个字节复制到 b 中相应的偏移位置,最后一条指令复制剩余的 4 个字节。这些 Mov 指令无效,因为它们的源操作数和目标操作数都在内存中,但它们将在指令修复过程中被重写。
你可以使用相同的方法来翻译 Load 和 Store 指令。例如,如果 y 是一个 6 字节的结构体,你将翻译
Load(Var("ptr"), Var("y"))
到 列表 18-54。
Mov(Quadword, Pseudo("ptr"), Reg(AX))
Mov(Longword, Memory(AX, 0), PseudoMem("y", 0))
Mov(Byte, Memory(AX, 4), PseudoMem("y", 4))
Mov(Byte, Memory(AX, 5), PseudoMem("y", 5))
列表 18-54:实现 非标量值的 Load
第一条指令将指针复制到 RAX 寄存器中。每条后续的Mov指令将从 RAX 中的地址偏移处复制一块数据到y中的相应偏移处。
你还可以使用一系列Mov指令实现非标量值的CopyToOffset;唯一的不同之处在于,你需要将指定的偏移量添加到每条指令的目标地址中。再举个例子,假设有一个 6 字节的结构体y,你需要转换为:
CopyToOffset(src=Var("y"), dst="z", offset=8)
转到清单 18-55。
Mov(Longword, PseudoMem("y", 0), PseudoMem("z", 8))
Mov(Byte, PseudoMem("y", 4), PseudoMem("z", 12))
Mov(Byte, PseudoMem("y", 5), PseudoMem("z", 13))
清单 18-55:实现 CopyToOffset 用于非标量值
最后,你需要实现新的CopyFromOffset指令。像其他复制数据的 TACKY 指令一样,它接受标量和非标量操作数。我不会详细讲解这条指令;你应该以类似CopyToOffset的方式处理它。
我建议编写一个辅助函数,我将其命名为copy_bytes,它生成汇编指令,用于将任意数量的字节从一个Memory或PseudoMem操作数复制到另一个。你可以使用这个辅助函数来实现五条 TACKY 复制指令。当你需要将内存中的结构体作为参数传递或返回值时,它会再次派上用场。
一旦你实现了CopyFromOffset并扩展了其他复制指令以支持非标量值,你就可以继续进行函数调用的部分了。
在函数调用中使用结构体
由于传递和返回结构体的规则比较复杂,在进入伪代码之前,我们先来讨论一下总体策略。首先,我们将编写一个函数来分类每个结构体类型的八字节。然后,我们会扩展之前在第十三章中引入的辅助函数classify_parameters,帮助处理调用方和被调用方的参数传递。记住,这个函数会返回三个列表:通过通用寄存器传递的操作数,通过 XMM 寄存器传递的操作数,以及通过栈传递的操作数。一旦我们更新了这个函数,这些列表可能包含标量值和结构体的八字节值。
接下来,我们将介绍另一个辅助函数classify_return_value,以类似的方式拆分返回值。它将返回一个包含在通用寄存器中返回的操作数的列表,一个包含在 XMM 寄存器中返回的操作数的列表,以及一个布尔标志,指示返回值是否通过内存传递。当这两个列表为空时,该标志将为True。
当classify_return_value处理标量值时,它将返回一个空列表,一个包含单个元素的列表,以及一个False标志。当它处理结构时,可能会产生更有趣的结果。它的主要目的是将标量值和结构返回值转换成相同的形式,以便我们可以以统一的方式处理它们。
一旦这些辅助函数到位,我们将更新如何将FunCall TACKY 指令转换为汇编代码。大多数情况下,我们可以像前面章节一样处理参数。我们将把从classify_parameters获取的每个操作数复制到适当的寄存器中或推送到栈上,而不必担心它是标量值还是结构的一部分。只有少数几个细节会发生变化。首先,我们将考虑到如果 RDI 保存了为返回值预留的空间的地址,它可能不可用。我们还需要处理无法通过单个mov指令传送的不规则大小的八字节。我们将编写一个新的辅助函数,将这些八字节移动到寄存器中。
获取函数的返回值将需要较大的改动。我们将使用classify_return_value来了解我们可以在哪里找到返回值的每个部分,然后将每个部分从相应的寄存器或内存地址复制到最终目的地。这将需要另一个辅助函数,将不规则大小的八字节从寄存器中“取出”。
最后,我们将处理被调用方的事情。这里,与调用方类似,我们处理参数的方式只会有细微的变化,但处理返回值的方式将发生较大变化。我们将再次使用classify_return_value来确定每个返回值部分应该放在哪里。
分类结构类型
我们将首先编写一个辅助函数来分类结构类型。我们将使用在 Listing 18-56 中定义的class构造来表示我们之前讨论的三个类。
class = MEMORY | SSE | INTEGER
清单 18-56: 类 构造
分类函数将返回一个包含class 元素的列表,每个元素对应于正在分类的结构体中的每个 8 字节。为了分类一个结构体,我们首先考虑它的大小,然后查看其成员的类型。清单 18-57 给出了这个过程的伪代码。
classify_structure(StructEntry(alignment, size, members)):
if size > 16:
result = []
while size > 0:
result.append(MEMORY)
size -= 8
return result
❶ scalar_types = `<flatten out list of member types>`
❷ if size > 8:
if first and last type in scalar_types are Double:
return [SSE, SSE]
if first type in scalar_types is Double:
return [SSE, INTEGER]
if last type in scalar_types is Double:
return [INTEGER, SSE]
return [INTEGER, INTEGER]
else if first type in scalar_types is Double:
❸ return [SSE]
else:
❹ return [INTEGER]
清单 18-57: 分类结构类型
classify_structure 函数从类型表中获取结构体定义。如果结构体的大小超过 16 字节,它必须通过内存传递,因此我们返回足够的 MEMORY 元素列表来覆盖整个结构体。例如,如果结构体的大小是 17 字节,classify_structure 应该返回 [MEMORY, MEMORY, MEMORY]。
否则,结构体的分类依赖于其成员的类型。我们会构建一个包含结构体中每个标量类型的列表,包括嵌套值的类型 ❶。假设一个结构体包含两个成员:一个 int * 和一个 char[3]。结果的 scalar_types 列表将是 [Pointer(Int), Char, Char, Char]。
如果结构体的大小在 8 到 16 字节之间,我们将返回两个类别的列表 ❷。由于 double 的大小和对齐方式是 8 字节,任何出现在此大小结构体中的 double 必须完全占据第一个或第二个 8 字节。利用这一点,我们只检查 scalar_type 的第一个和最后一个元素。如果第一个元素是 Double,那么第一个 8 字节必须属于 SSE 类;否则,它必须属于 INTEGER 类。同样,只有当 scalar_type 的最后一个元素是 Double 时,第二个 8 字节才属于 SSE 类。
最后,我们将结构体大小为 8 字节或更小的分类。如果结构体包含的第一个(也是唯一的)标量类型是Double ❸,则该结构体属于 SSE 类。否则,它属于 INTEGER 类 ❹。
如果你愿意,你可以通过缓存结果来改进 列表 18-57 中的代码。你需要维护一个结构标签到其分类的映射。第一次分类某个特定结构类型时,将结果添加到该映射中。然后,如果需要再次分类该结构类型,你只需检索结果,而不必重新计算。
参数分类
接下来,我们将扩展 classify_parameters 函数。这个函数将根据每个参数是通过通用寄存器、XMM 寄存器,还是通过栈传递,将参数列表划分为三部分。现在,当它处理结构类型的值时,它会将该值拆分成八字节,并将每个八字节添加到正确的列表中。列表 18-58 复制了 列表 13-29 中 classify_parameters 的定义,并对更改部分进行了加粗标注。
classify_parameters(values, **return_in_memory**):
int_reg_args = []
double_reg_args = []
stack_args = []
**if return_in_memory:**
**int_regs_available = 5**
**else:**
**int_regs_available = 6**
for v in values:
operand = convert_val(v)
t = assembly_type_of(v)
typed_operand = (t, operand)
if t == Double:
`--snip--`
else **if t is scalar**:
if length(int_reg_args) < **int_regs_available**:
int_reg_args.append(typed_operand)
else:
stack_args.append(typed_operand)
**else:**
**// v is a structure**
**// partition it into eightbytes by class**
**classes = classify_structure(**`**<struct definition for v>**`**)** ❶
**use_stack = True**
**struct_size =****t.size**
**if classes[0] != MEMORY:** ❷
**// make tentative assignments to registers**
**tentative_ints = []**
**tentative_doubles = []**
**offset = 0**
**for class in classes:**
**operand = PseudoMem(**`**<name of v>**`**, offset)** ❸
**if class == SSE:**
**tentative_doubles.append(operand)**
**else:**
**eightbyte_type = get_eightbyte_type(offset, struct_size)** ❹
**tentative_ints.append((eightbyte_type, operand))**
**offset += 8**
**// finalize them if there are enough free registers**
**if ((length(tentative_doubles) + length(double_reg_args)) <= 8 and**
**(length(tentative_ints) + length(int_reg_args)) <= int_regs_available):** ❺
**double_reg_args.append_all(tentative_doubles)**
**int_reg_args.append_all(tentative_ints)**
**use_stack = False**
**if use_stack:**
**// add each eightbyte of the structure to stack_args**
**offset = 0**
**for class in classes:**
**operand = PseudoMem(**`**<name of v>**`**, offset)**
**eightbyte_type = get_eightbyte_type(offset, struct_size)**
**stack_args.append((eightbyte_type, operand))** ❻
**offset += 8**
return (int_reg_args, double_reg_args, stack_args)
列表 18-58:扩展 classify_parameters 以支持结构体
该函数的第一个变化是新增的布尔类型 return_in_memory 参数。顾名思义,该参数指示函数的返回值是否通过内存传递。如果是,内存地址将通过 RDI 寄存器传递,这样就会有一个较少的通用寄存器可用于其他参数。我们会相应地设置 int_regs_available。然后,当我们处理整数或指针类型的参数时,我们将使用 int_regs_available,而不是常量 6,作为可用的通用寄存器数量。(我们将以与 第十三章 中完全相同的方式处理 double 类型的参数,所以我已经将该部分从列表中剪除。)
现在我们已经进入了有趣的部分:处理结构类型的参数。我们将从调用classify_structure ❶开始。接下来,我们将检查结构的前一个八字节是否属于 MEMORY 类 ❷。如果是,那么结构的其余部分也必须属于 MEMORY 类。如果不是,我们将尝试将每个八字节分配到一个寄存器中。我们将把每个八字节转换为一个PseudoMem操作数 ❸,然后根据其类型,将其添加到两个列表之一:tentative_doubles或tentative_ints。我们知道v是一个变量,而不是常量,因为 TACKY 中没有聚合常量;该变量的名称将成为PseudoMem操作数的基值。
当我们将一个八字节添加到tentative_ints时,我们需要确定将其与哪个汇编类型关联。大多数八字节正好是 8 字节长,因此我们将它们与Quadword类型关联。但是结构中的最后一个八字节可能更短。我们将通过get_eightbyte_type辅助函数 ❹来查找每个八字节的汇编类型,我们稍后会逐步讲解这个函数。该函数接受两个参数:八字节的偏移量和结构的总大小。它将使用这些信息来确定八字节的大小,从而决定其汇编类型。
一旦我们将整个结构分割成两个临时列表,我们会检查是否有足够的空闲寄存器来容纳它们 ❺。如果有,我们将把这两个列表添加到它们各自的非临时列表中。如果没有足够的寄存器可用,或者结构属于 MEMORY 类,我们会将每个八字节添加到stack_args中 ❻。我们使用get_eightbyte _type来确定我们在堆栈上传递的每个八字节的类型。
现在让我们逐步解析get_eightbyte_type,它定义在清单 18-59 中。
get_eightbyte_type(offset, struct_size):
❶ bytes_from_end = struct_size - offset
if bytes_from_end >= 8:
return Quadword
if bytes_from_end == 4:
return Longword
if bytes_from_end == 1:
return Byte
❷ return ByteArray(bytes_from_end, 8)
清单 18-59:将八字节与 assembly_type 关联
这里的目标是确定在将此八字节移动到寄存器或栈上时,应该使用什么操作数大小。首先,我们计算此八字节的起始位置与整个结构末尾之间的字节数 ❶。如果结构中剩余的字节超过 8 个,那么这不是最后一个八字节,因此我们使用 Quadword 类型。如果此八字节正好是 8 字节、4 字节或 1 字节长,我们分别使用 Quadword、Longword 或 Byte 类型。
否则,八字节的大小是不规则的;它不是汇编指令的有效操作数大小。在这种情况下,我们使用 ByteArray 类型来记录八字节的确切字节大小 ❷。(此 ByteArray 中的对齐是一个虚拟值,稍后我们不需要它。)我们无法安全地通过单个 Mov 指令传输一个大小不规则的八字节。正如你在第九章中学到的,越过内存中某个值的末尾进行读取——例如,使用 8 字节的 pushq 指令推送一个 4 字节的值——可能会触发内存访问违规。根据同样的逻辑,使用 8 字节的 movq 指令传输 5 字节、6 字节或 7 字节的操作数,或使用 4 字节的 movl 指令传输 3 字节的操作数都是不安全的。稍后我们将讨论如何传输大小不规则的八字节。
请注意,get_eightbyte_type 不考虑八字节的类别;它会返回 Quadword 对于任何完整长度的八字节,即使它属于 SSE 类别。这是正确的,因为我们使用 get_eightbyte_type 仅仅是为了找出我们将要在通用寄存器或栈上转移的值的类型。当我们将一个结构的 8 字节推入栈时,我们不关心这些字节是否包含浮点值或整数。
返回值分类
接下来,我们将编写一个类似的辅助函数来分类返回值。这个函数比 classify_parameters 更简单。我们只需要处理一个值,而不是整个列表,因此不需要担心寄存器不够用。如果返回值需要存储在内存中,我们也不需要像在 classify_parameters 中那样将值拆分成八字节。Listing 18-60 展示了 classify_return_value 辅助函数的伪代码。
classify_return_value(retval):
t = assembly_type_of(retval)
if t == Double:
operand = convert_val(retval)
❶ return ([], [operand], False)
else if t is scalar:
typed_operand = (t, convert_val(retval))
❷ return ([typed_operand], [], False)
else:
classes = classify_structure(`<struct definition for retval>`)
struct_size = t.size
if classes[0] == MEMORY:
// the whole structure is returned in memory,
// not in registers
❸ return ([], [], True)
else:
// the structure is returned in registers;
// partition it into eightbytes by class
int_retvals = []
double_retvals = []
offset = 0
for class in classes:
operand = PseudoMem(`<name of retval>`, offset)
❹ match class with
| SSE ->
double_retvals.append(operand)
| INTEGER ->
eightbyte_type = get_eightbyte_type(offset, struct_size)
int_retvals.append((eightbyte_type, operand))
| MEMORY -> fail("Internal error")
offset += 8
return (int_retvals, double_retvals, False)
Listing 18-60:分类返回值
如果返回值是一个 double 类型,第一个列表(包含在通用寄存器中返回的操作数)将为空。第二个列表(包含在 XMM 寄存器中返回的操作数)将包含返回值。表示值将在内存中返回的标志将是 False ❶。如果返回值是其他标量类型,我们将把它添加到在通用寄存器中返回的操作数列表中,并附带其类型。XMM 寄存器中的操作数列表将为空,标志仍为 False ❷。
否则,返回值必须是一个结构体。我们将通过 classify_structure 查找它的类,然后检查它是否属于 MEMORY 类。如果属于,我们将返回两个空列表,表示寄存器中不会返回任何值,以及一个 True 标志,表示返回值将会传递到内存中 ❸。
如果结构体不在 MEMORY 类中,它将通过寄存器返回。我们将把每个八字节值转换为 PseudoMem 操作数,并根据其类将其添加到 double_retvals 或 int_retvals 中 ❹。在这里,与 classify_parameters 中一样,我们将使用 get_eightbyte_type 来查找每个操作数在 int_retvals 中的汇编类型。最后,我们将返回两个列表,并附带一个 False 标志。
实现 FunCall
接下来,让我们更新如何在汇编中实现函数调用。清单 18-61 重现了 convert_function_call 的定义,该定义来源于清单 13-31,修改部分用粗体显示,未修改的代码被省略。
convert_function_call(FunCall(fun_name, args, dst)):
int_registers = [DI, SI, DX, CX, R8, R9]
double_registers = [XMM0, XMM1, XMM2, XMM3, XMM4, XMM5, XMM6, XMM7]
**return_in_memory = False**
**int_dests = []**
**double_dests = []**
**reg_index = 0**
**// classify return value**
**if dst is not null:**
**int_dests, double_dests, return_in_memory = classify_return_value(dst)** ❶
**if return_in_memory:**
**dst_operand = convert_val(dst)**
**emit(Lea(dst_operand, Reg(DI)))** ❷
**reg_index = 1**
// classify arguments
int_args, double_args, stack_args = classify_parameters(args, **return_in_memory**)
`--snip--`
// pass args in registers
for (assembly_type, assembly_arg) in int_args:
r = int_registers[reg_index]
**if assembly_type is ByteArray(size, alignment):**
**copy_bytes_to_reg(assembly_arg, r, size)** ❸
**else:**
emit(Mov(assembly_type, assembly_arg, Reg(r)))
reg_index += 1
`--snip--`
// pass args on stack
for (assembly_type, assembly_arg) in reverse(stack_args):
**if assembly_type is ByteArray(size, alignment):**
**emit(Binary(Sub, Quadword, Imm(8), Reg(SP)))** ❹
**copy_bytes(from=assembly_arg, to=Memory(SP, 0), count=size)**
**else** if (assembly_arg is a Reg or Imm operand
or assembly_type == Quadword
or assembly_type == Double):
emit(Push(assembly_arg))
else:
emit(Mov(assembly_type, assembly_arg, Reg(AX)))
emit(Push(Reg(AX)))
`--snip--`
// retrieve return value
**if (dst is not null) and (not return_in_memory):**
**int_return_registers = [AX, DX]**
**double_return_registers = [XMM0, XMM1]**
**// retrieve values returned in general-purpose registers**
**reg_index = 0**
**for (t, op) in int_dests:**
**r = int_return_registers[reg_index]**
**if t is ByteArray(size, alignment):**
**copy_bytes_from_reg(r, op, size)** ❺
**else:**
**emit(Mov(t, Reg(r), op))** ❻
**reg_index += 1**
**// retrieve values returned in XMM registers**
**reg_index = 0**
**for op in double_dests:**
**r = double_return_registers[reg_index]**
**emit(Mov(Double, Reg(r), op))** ❼
**reg_index += 1**
清单 18-61:函数调用中的支持结构
我们首先调用 classify_return_value(除非函数调用没有返回值,因为其返回类型是 void)❶。如果我们发现返回值将通过内存传递,我们将 dst 转换为汇编操作数,然后生成指令将其地址加载到 RDI 寄存器中❷。我们还会递增 reg_index,这样我们就可以通过 RSI 而不是 RDI 传递第一个整数参数。
接下来,我们调用 classify_parameters,并传递新的 return_in_memory 标志。然后,我们调整栈指针(我已省略这一步,因为它与之前章节中的相同)。
然后我们将参数传递到通用寄存器中。如果一个参数的类型是 ByteArray,它的大小不是 1、4 或 8 字节,所以将其传送到寄存器需要多个指令。我们通过 copy_bytes_to_reg 辅助函数❸来生成这些指令,稍后我们会详细了解。如果参数是其他类型,我们将通过单一的 Mov 指令进行传送,就像之前的章节中一样。我们在 XMM 寄存器中传递参数的方式不会改变,所以我已经省略了这一步。
下一步是在栈上传递参数。我们传递 Byte、Longword、Quadword 或 Double 类型的操作数的方式不会改变。为了传递一个具有 ByteArray 类型的不规则操作数,我们首先需要从 RSP 减去 8 个字节,以为该操作数分配栈槽❹。(请记住,ABI 为结构体参数的每个八字节预留一个完整的 8 字节栈槽,即使实际的八字节小于这个大小。)为了将操作数复制到该栈槽中,我们使用已经编写的 copy_bytes 辅助函数。假设 x 是一个全局变量,且它的大小是 3 字节。我们将发出以下指令,将它作为参数传递到栈上:
subq $8, %rsp
movb x(%rip), (%rsp)
movb x+1(%rip), 1(%rsp)
movb x+2(%rip), 2(%rsp)
这些 movb 指令是无效的,因为两个操作数都在内存中;我们将在指令修正阶段重写它们。
接下来,我们发出 call 指令,并将栈指针恢复到原来的位置。我已经省略了这些步骤,因为它们与前几章相同。
最后,我们将返回值复制到目标位置。如果返回值是在内存中传递的,我们不需要做任何操作;被调用者已经为我们复制了它。否则,我们会遍历由 classify_return_value 返回的两个目标操作数列表——首先是 int_dests,然后是 double_dests——并从相应的寄存器中获取每个操作数。为了从通用寄存器中检索不规则大小的八字节,我们使用 copy_bytes_from_reg 辅助函数❺,我们稍后会定义这个函数。它是 copy_bytes_to_reg 的对等函数,后者用于传递参数。我们发出一条 Mov 指令,以从通用寄存器❻中检索一个 Byte、Longword 或 Quadword 值,或从 XMM 寄存器❼中检索一个 Double 值。
复制返回值到 dst 的代码无论 dst 是结构体还是标量对象都能正常工作。如果它是一个结构体,那么 int_dests 和 double _dests 中的每个项目都是 dst 的一个八字节,我们将从相应的返回寄存器填充它。如果 dst 是标量,那么 int_dests 或 double_dests 将只有一个元素,另一个列表将为空。在这种情况下,代码将发出一个单一的 Mov 指令,将返回值从 RAX 或 XMM0 传输到目标位置。
在寄存器中传输不规则结构体
我们仍然需要实现 copy_bytes_to_reg 和 copy_bytes_from_reg,它们将不规则大小的八字节数据复制到通用寄存器中,反之亦然。这比在内存中两个位置之间进行复制要复杂,因为我们无法直接访问通用寄存器中的每个字节。在通用寄存器中,我们可以通过适当的 1 字节别名(如 AL 或 DIL)访问其最低字节,但无法单独访问其他字节。(每个寄存器的第二低字节也有自己的别名——例如,AH 是 RAX 的第二低字节——但是我们的汇编 AST 不支持这些别名。即使它支持,我们仍然无法访问其他 6 个字节。)
我们将使用新的位移指令来绕过这个限制。让我们回顾一下上一个例子中的 3 字节全局变量 x。如果我们需要将 x 复制到 RDI 中,我们将发出以下指令:
movb x+2(%rip), %dil
shlq $8, %rdi
movb x+1(%rip), %dil
shlq $8, %rdi
movb x(%rip), %dil
我们首先将 x 的最后一个字节复制到 RDI 的最低字节中,RDI 的别名是 DIL。然后,我们发出一个 shl 指令,将 RDI 向左移动 1 字节。这将我们刚复制的字节移到 RDI 的第二低字节,并将 DIL 清零。接下来,我们将 x 的中间字节复制到 DIL,并发出另一个 shl 指令。此时,x 的最后 2 个字节已经在寄存器中的正确位置,因此我们只需将 x 的第一个字节复制到 DIL,就完成了。图 18-7 显示了每条指令执行后 RDI 的内容(以十六进制表示),假设 x 包含字节 0x1、0x2 和 0x3,且 RDI 的初始值为 0。

图 18-7:将结构以每字节方式传输到寄存器 描述
不要被这里的字节顺序弄混:由于我们的系统是小端字节序,当我们将值从 RDI 复制到内存中,或者反之,RDI 中的最小有效(最右侧)字节对应于最低的内存地址。这意味着——有些反直觉——将值向左移动会将每个字节移动到对应于更高内存地址的位置。如果在将这个结构复制到 RDI 之后,我们发出指令
movl %edi, -4(%rbp)
然后内存的内容将如图 18-8 所示。

图 18-8:从寄存器复制结构后内存的内容 描述
现在结构在内存中按照正确的顺序布局。(这个 movl 指令还会在结构的末尾写入 1 个字节的内存,如果你不打算用这个字节,这样做是没问题的。我们将不规则大小的结构以每次 1 字节的方式在寄存器中传输,但我们交互的其他翻译单元中的代码可能会在安全的情况下以 4 字节或 8 字节的块进行传输。)
这就是 copy_bytes_to_reg 的基本思想;现在让我们实现它。清单 18-62 给出了伪代码。
copy_bytes_to_reg(src_op, dst_reg, byte_count):
offset = byte_count - 1
while offset >= 0:
src_byte = add_offset(src_op, offset)
emit(Mov(Byte, src_byte, Reg(dst_reg)))
if offset > 0:
emit(Binary(Shl, Quadword, Imm(8), Reg(dst_reg)))
offset -= 1
清单 18-62:生成将字节从内存复制到寄存器的指令
该函数将 byte_count 字节从 src_op 复制到 dst_reg。因为 src_op 是一个结构的一部分,我们可以假设它是一个接受偏移量的内存操作数,如 PseudoMem 或 Memory。我们以反向顺序遍历 src_op 的字节:从它的最后一个字节开始,偏移量为 byte_count - 1,直到字节零。我们使用一个简单的辅助函数 add_offset 来构造每个字节的汇编操作数。我不会给出这个函数的伪代码,因为它只是将指定的偏移量添加到 src_op 上。例如,如果 src_op 是 PseudoMem("x", 2),那么 add_offset(src_op, 3) 应该返回 PseudoMem("x", 5)。
一旦我们获得了当前字节的汇编操作数,我们就发出一个 Mov 指令,将该字节复制到目标寄存器。接下来,在除最后一次循环迭代外的所有循环中,我们发出一个 Shl 指令,将整个寄存器向左移动 8 位。然后,我们递减偏移量,继续处理下一个字节。
要从寄存器中复制字节,我们需要倒过来做。下面是我们如何将 3 个字节从 RDI 复制到栈上 -4(%rbp):
movb %dil, -4(%rbp)
shrq $8, %rdi
movb %dil, -3(%rbp)
shrq $8, %rdi
movb %dil, -2(%rbp)
首先,我们将 RDI 的最低字节复制到内存中。然后,我们将 RDI 向右移动 1 字节,这样 DIL 就包含了结构的第二个最低字节。我们重复这个过程,直到所有字节都被传输。 清单 18-63 给出了生成这些指令的伪代码。
copy_bytes_from_reg(src_reg, dst_op, byte_count):
offset = 0
while offset < byte_count:
dst_byte = add_offset(dst_op, offset)
emit(Mov(Byte, Reg(src_reg), dst_byte))
if offset < byte_count - 1:
emit(Binary(ShrTwoOp, Quadword, Imm(8), Reg(src_reg)))
offset += 1
清单 18-63:生成将字节从寄存器复制到内存的指令
如同在Feynman 学习方法中所示,我们可以假设dst_op是带有偏移量的内存操作数。我们按顺序遍历dst_op的字节,从字节零开始。在每次迭代中,我们将src_reg的最低字节复制到当前字节位置的dst_op。然后,在除了最后一次迭代之外的所有迭代中,我们将src_reg右移 8 位。
有了这两个辅助函数,我们就完成了函数调用的实现。接下来,我们将处理被调用方的操作。
设置函数参数
在函数开始时,我们将每个参数复制到该函数的栈帧中。现在我们也将复制结构类型的参数。主要的难点是 RDI 可能保存指向返回值目的地的指针,而不是普通参数。让我们重新审视一下来自Feynman 学习方法的set_up_parameters,看看发生了哪些变化。Listing 18-64 给出了set_up_parameters的新定义,改动部分已加粗。
set_up_parameters(parameters, **return_in_memory**):
// classify them
int_reg_params, double_reg_params, stack_params = classify_parameters(parameters,
**return_in_memory**)
// copy parameters from general-purpose registers
int_regs = [DI, SI, DX, CX, R8, R9]
reg_index = 0
**if return_in_memory:**
**emit(Mov(Quadword, Reg(DI), Memory(BP, -8)))**
**reg_index = 1**
for (param_type, param) in int_reg_params:
r = int_regs[reg_index]
**if param_type is ByteArray(size, alignment):**
**copy_bytes_from_reg(r, param, size)**
**else:**
emit(Mov(param_type, Reg(r), param))
reg_index += 1
`--snip--`
// copy parameters from the stack
offset = 16
for (param_type, param) in stack_params:
**if param_type is ByteArray(size, alignment):**
**copy_bytes(from=Memory(BP, offset), to=param, count=size)**
**else:**
emit(Mov(param_type, Memory(BP, offset), param))
offset += 8
Listing 18-64: 将函数参数复制到栈上
我们添加了一个return_in_memory标志,并将其传递给classify_parameters。这个标志还决定了我们如何处理 RDI 中的值。如果 RDI 指向返回值的目的地,我们将其复制到栈上的第一个空槽,即-8(%rbp);当我们需要返回值时,会从这个槽中取出。 (在下一节中,我们将更新伪操作数替换过程,以确保它不会通过将局部变量分配到相同位置来覆盖这个指针。)在这种情况下,我们还将像在Feynman 学习方法中传递参数时那样,增加reg_index,这样我们将从 RSI 而不是 RDI 开始查找普通参数。
为了将不规则大小的操作数从寄存器中复制出来,我们将使用来自列表 18-63 的copy _bytes_from_reg辅助函数。为了复制通过栈传递的不规则大小的操作数,我们将使用copy_bytes辅助函数。如果操作数的类型是Byte、Longword、Quadword或Double,我们将使用单一的Mov指令将其复制到相应位置,不管它是标量值还是结构体的一部分。
实现 Return
列表 18-65 展示了如何将Return指令转换为汇编代码。
convert_return_instruction(Return(retval)):
if retval is null:
emit(Ret)
return
int_retvals, double_retvals, return_in_memory = classify_return_value(retval)
if return_in_memory:
emit(Mov(Quadword, Memory(BP, -8), Reg(AX))) ❶
return_storage = Memory(AX, 0)
ret_operand = convert_val(retval)
t = assembly_type_of(retval)
copy_bytes(from=ret_operand, to=return_storage, count=t.size) ❷
else:
int_return_registers = [AX, DX]
double_return_registers = [XMM0, XMM1]
reg_index = 0
for (t, op) in int_retvals: ❸
r = int_return_registers[reg_index]
if t is ByteArray(size, alignment):
copy_bytes_to_reg(op, r, size)
else:
emit(Mov(t, op, Reg(r)))
reg_index += 1
reg_index = 0
for op in double_retvals: ❹
r = double_return_registers[reg_index]
emit(Mov(Double, op, Reg(r)))
reg_index += 1
emit(Ret)
列表 18-65:实现 Return 指令
假设该函数返回一个值,而不是void,我们首先对该值进行分类。然后,我们检查是否需要将其返回到内存或寄存器中。为了将其返回到内存中,我们首先从-8(%rbp)获取目标的指针。我们将该指针复制到 RAX 中,因为系统 V 调用约定要求这样做❶。然后,我们将返回值复制到 RAX 指针所指向的内存块中。我们使用copy _bytes辅助函数进行这次复制❷。
如果返回值通过一个或多个寄存器传递,我们将遍历int_retvals中的操作数,将每个操作数复制到相应的通用寄存器中❸。接着,我们遍历double_retvals,将这些值复制到 XMM0 和 XMM1 中❹。一旦我们将返回值的每一部分复制到正确的位置,我们就会发出Ret指令。
跟踪哪些函数将返回值通过内存传递
最后,我们将扩展后端符号表,以跟踪哪些函数通过内存返回值。列表 18-66 展示了如何更新我们的后端符号表条目的定义。
asm_symtab_entry = ObjEntry(assembly_type, bool is_static, bool is_constant)
| FunEntry(bool defined, **bool return_on_stack**)
列表 18-66:后端符号表中条目的更新定义
正如你所期望的,如果一个函数通过栈传递返回值,我们将把return_on_stack设置为True,如果它通过寄存器传递返回值或返回void,则设置为False。伪操作数替换通道将使用此标志来判断从-8(%rbp)开始的四字节是否可用,或者它是否包含返回值将被传递的内存指针。如果一个函数有一个不完整的返回类型(除了void,这可能发生在它被声明但从未定义或调用的情况下),则return_on_stack标志将不会被使用,因此我们可以将其设置为False。
将所有内容整合在一起
我们现在已经覆盖了所有汇编生成的部分!表 18-1 到 18-4 表总结了从 TACKY 到汇编的最新转换更新;如同往常一样,新的构造和现有构造的转换更改都用粗体标注。附录 B 包括了本章从 TACKY 到汇编的完整转换,因为这是第二部分的最后一章。
表 18-1: 将顶层 TACKY 构造转换为汇编
| TACKY 顶层构造 | 汇编顶层构造 |
|---|
|
Function(name,
global,
params,
instructions)
| 寄存器中的返回值
或无返回
value |
Function(name, global,
[ <copy Reg(DI) into first int param/eightbyte>,
<copy Reg(SI) into second int param/eightbyte>,
<copy next four int params/eightbytes from registers>,
Mov(Double,
Reg(XMM0),
<first double param/eightbyte>),
Mov(Double,
Reg(XMM1),
<second double param/eightbyte>),
<copy next six double params/eightbytes from registers>,
<copy Memory(BP, 16) into first stack param/eightbyte>,
<copy Memory(BP, 24) into second stack param/eightbyte>,
<copy remaining params/eightbytes from stack>] +
instructions)
|
| 返回值在栈上 |
|---|
Function(name, global,
[Mov(Quadword,
Reg(DI),
Memory(BP, -8)),
<copy Reg(SI) into first int param/eightbyte>,
<copy Reg(DX) into second int param/eightbyte>,
<copy next three int params/eightbytes from registers>,
Mov(Double,
Reg(XMM0),
<first double param/eightbyte>),
Mov(Double,
Reg(XMM1),
<second double param/eightbyte>),
<copy next six double params/eightbytes from registers>,
<copy Memory(BP, 16) into first stack param/eightbyte>,
<copy Memory(BP, 24) into second stack param/eightbyte>,
<copy remaining params/eightbytes from stack>] +
instructions)
|
表 18-2: 将 TACKY 指令转换为汇编
| TACKY 指令 | 汇编指令 |
|---|---|
| Return(val) | 返回栈上 |
Mov(Quadword, Memory(BP, -8), Reg(AX))
Mov(Quadword,
<first eightbyte of return value>,
Memory(AX, 0))
Mov(Quadword,
<second eightbyte of return value>,
Memory(AX, 8))
<copy rest of return value>
Ret
|
| 寄存器中的返回 |
|---|
<move integer parts of return value into RAX, RDX>
<move double parts of return value into XMM0, XMM1>
Ret
|
| 无返回值 |
|---|
Ret
|
|
Unary(Negate, src, dst)
(double negation)
Mov(Double, src, dst)
Binary(Xor, Double, Data(<negative-zero>, 0), dst)
And add a top-level constant:
StaticConstant(<negative-zero>, 16,
DoubleInit(-0.0))
|
|
Copy(src, dst)
| 标量 |
|---|
Mov(<src type>, src, dst)
|
| 结构 |
|---|
Mov(<first chunk type>,
PseudoMem(src, 0),
PseudoMem(dst, 0))
Mov(<next chunk type>,
PseudoMem(src, <first chunk size>),
PseudoMem(dst, <first chunk size>))
<copy remaining chunks>
|
|
Load(ptr, dst)
| 标量 |
|---|
Mov(Quadword, ptr, Reg(<R>))
Mov(<dst type>, Memory(<R>, 0), dst)
|
| 结构 |
|---|
Mov(Quadword, ptr, Reg(<R>))
Mov(<first chunk type>,
Memory(<R>, 0),
PseudoMem(dst, 0))
Mov(<next chunk type>,
Memory(<R>, <first chunk size>),
PseudoMem(dst, <first chunk size>))
<copy remaining chunks>
|
|
Store(src, ptr)
| 标量 |
|---|
Mov(Quadword, ptr, Reg(<R>))
Mov(<src type>, src, Memory(<R>, 0))
|
| 结构体 |
|---|
Mov(Quadword, ptr, Reg(<R>))
Mov(<first chunk type>,
PseudoMem(src, 0),
Memory(<R>, 0))
Mov(<next chunk type>,
PseudoMem(src, <first chunk size>),
Memory(<R>, <first chunk size>))
<copy remaining chunks>
|
|
CopyToOffset(src, dst,
offset)
|
src is
scalar
|
Mov(<src type>, src, PseudoMem(dst, offset))
|
| src 是一个结构体 |
|---|
Mov(<first chunk type>,
PseudoMem(src, 0),
PseudoMem(dst, offset))
Mov(<next chunk type>,
PseudoMem(src, <first chunk size>),
PseudoMem(dst, offset + <first chunk size>))
<copy remaining chunks>
|
|
CopyFromOffset(src,
offset,
dst)
| dst 是标量 |
|---|
Mov(<dst type>, PseudoMem(src, offset), dst)
|
| dst 是一个结构体 |
|---|
Mov(<first chunk type>,
PseudoMem(src, offset),
PseudoMem(dst, 0))
Mov(<next chunk type>,
PseudoMem(src, offset + <first chunk size>),
PseudoMem(dst, <first chunk size>))
<copy remaining chunks>
|
|
FunCall(fun_name,
args, dst)
| dst 将会是
返回
在
内存 |
Lea(dst, Reg(DI))
<fix stack alignment>
<move arguments to general-purpose registers, starting with RSI>
<move arguments to XMM registers>
<push arguments onto the stack>
Call(fun_name)
<deallocate arguments/padding>
|
| | dst 将会是
返回
在
寄存器 |
<fix stack alignment>
<move arguments to general-purpose registers>
<move arguments to XMM registers>
<push arguments onto the stack>
Call(fun_name)
<deallocate arguments/padding>
<move integer parts of return value from RAX, RDX into dst>
<move double parts of return value from XMM0, XMM1 into dst>
|
| dst 不存在 |
|---|
<fix stack alignment>
<move arguments to general-purpose registers>
<move arguments to XMM registers>
<push arguments onto the stack>
Call(fun_name)
<deallocate arguments/padding>
|
|
DoubleToUInt(src, dst)
|
unsigned
char
|
Cvttsd2si(Longword, src, Reg(<R>))
Mov(Byte, Reg(<R>), dst)
|
|
unsigned
int
|
Cvttsd2si(Quadword, src, Reg(<R>))
Mov(Longword, Reg(<R>), dst)
|
unsigned
long
|
Cmp(Double, Data(<upper-bound>, 0), src)
JmpCC(AE, <label1>)
Cvttsd2si(Quadword, src, dst)
Jmp(<label2>)
Label(<label1>)
Mov(Double, src, Reg(<X>))
Binary(Sub, Double, Data(<upper-bound>, 0), Reg(<X>))
Cvttsd2si(Quadword, Reg(<X>), dst)
Mov(Quadword, Imm(9223372036854775808), Reg(<R>))
Binary(Add, Quadword, Reg(<R>), dst)
Label(<label2>)
And add a top-level constant:
StaticConstant(<upper-bound>, 8,
DoubleInit(9223372036854775808.0))
|
表 18-3: 将 TACKY 操作数转换为汇编
| TACKY 操作数 | 汇编操作数 |
|---|
|
Constant(ConstDouble(double))
|
Data(<ident>, 0)
And add a top-level constant:
StaticConstant(<ident>, 8,
DoubleInit(double))
|
表 18-4: 将类型转换为汇编
| 源类型 | 汇编类型 | 对齐 |
|---|---|---|
| 结构体(tag) |
ByteArray(<size from type table>,
<alignment from type table>)
| 来自类型表的对齐 |
|---|
请注意,我们现在在每个生成的 数据 操作数上包括偏移量。此时唯一的 数据 操作数代表浮点常量;这些包括我们在浮点数转换中使用的常量,例如 表 18-2 中的浮点数 Negate 和 DoubleToUInt,以及 表 18-3 中的普通浮点 TACKY 常量。这些操作数的偏移量都为零。
替换伪操作数
我们将对这个过程做两个小改动。首先,我们将为 数据 操作数提供偏移量。例如,如果 v 是一个静态变量,我们将转换
PseudoMem("v", 0)
到
Data("v", 0)
和
PseudoMem("v", 10)
到:
Data("v", 10)
其次,我们需要避免覆盖 -8(%rbp) 处的返回值指针。在开始分配栈空间之前,我们会检查后端符号表,以查看函数的返回值是否会通过内存传递。如果会传递,我们将保留从 -8(%rbp) 开始的四字长(quadword)作为返回值指针,并仅在 -8(%rbp) 以下的地址为伪寄存器分配空间。例如,如果我们遇到的第一个伪寄存器是 Longword,我们将它映射到 -12(%rbp)。
本章的指令修复阶段不会发生变化。在代码生成过程中,我们输出的 shl 和 shr 指令已经是有效的,不需要进一步修复,因为目标操作数始终是寄存器,而源操作数始终是立即数 8。
代码生成
代码生成阶段需要进行两个小的修改。首先,我们将包括 Data 操作数的偏移量。例如,我们将 Data("x", 4) 输出为:
x+4(%rip)
如果偏移量为零,可以选择包含或省略它。
其次,我们将新的 Shl 和 ShrTwoOp 汇编指令分别输出为 shl 和 shr,并且它们会使用常规的操作数大小后缀。
表 18-5 和 18-6 显示了代码生成阶段的这些变化。附录 B 包含了本章的完整代码生成过程,因为这是 第二部分的最后一章。
表 18-5: 汇编操作符的指令名称
| 汇编操作符 | 指令名称 |
|---|---|
| Shl | shl |
| ShrTwoOp | shr |
表 18-6: 格式化汇编操作数
| 汇编操作数 | 输出 |
|---|---|
| Data(identifier, int) |
这样,你就完成了这一章;你的编译器现在已经支持结构体了!
额外加分:联合体
结构体和联合体类型有很多相似之处。它们的类型声明共享相同的语法,并在同一个命名空间中声明标签。它们遵循相同的类型规则,并支持相同的操作,包括->和.运算符。区别在于,结构体的成员在内存中是按顺序排列的,而联合体的所有成员都从相同的地址开始,因此对一个成员的写入会覆盖其他成员。从编译器的角度看,联合体基本上是一个结构体,其中每个成员的偏移量都是零。这使得你扩展本章中所做的工作以支持联合体变得相对简单。然而,这比之前的额外加分功能要更具挑战性。这是一个你可以自己添加新语言特性的机会。
如果你实现了联合体类型,有几个要点需要记住。首先,记住我们曾限制过结构体的声明位置,以便简化编译过程。联合体的测试用例也遵循相同的限制;这意味着你不需要支持匿名联合体声明或作为其他类型或变量声明一部分的联合体声明。
在类型检查器中,你将把联合体定义添加到类型表中。结构体和联合体标签共享同一个命名空间,因此在同一作用域内定义具有相同标签的结构体和联合体类型是错误的。你需要跟踪每个联合体的大小和对齐方式(你可以查阅如何计算这些内容,在 System V ABI 中有说明)。你还需要对联合体的复合初始化器进行类型检查。联合体初始化器应该只有一个元素,用来初始化联合体的第一个成员。(C 提供了语法来指定初始化哪个联合体成员,但你不需要实现这一点。)
在后端,System V 调用约定将联合体视为类似于结构体;联合体会在内存中、两个寄存器中或一个寄存器中传递,这取决于它的大小以及成员的类型。有关详细信息,请参阅“附加资源”中的链接。祝你好运!
你可以使用--union标志来测试编译器对联合体类型的支持:
$ **./test_compiler** `**/path/to/your_compiler**` **--chapter 18 --union**
或者,你可以使用--extra-credit标志来测试每个额外加分功能。
总结
你已经完成了 第二部分!在这一章中,你学会了如何分析结构体类型声明、在 TACKY 中操作聚合对象,并根据 System V 调用约定传输结构体。你的编译器现在支持本书涵盖的每个语言特性,包括 C 语言中的大多数语句、表达式和类型。你可以正式告诉别人你已经写了一个 C 编译器。
如果你愿意,可以在这里停止。或者,你可以继续阅读 第三部分,在这里你将实现几个编译器优化,生成更高效的汇编代码。在 第十九章,你将通过消除无用指令和在编译时评估常量表达式来优化 TACKY 程序。在 第二十章,你将编写一个寄存器分配器,将伪寄存器映射到硬件寄存器,而不是堆栈上的位置。这些优化不仅仅适用于 C 或 x64 汇编;你将在支持多种源语言和目标语言的编译器中找到它们。
附加资源
如果你想了解完整的 System V x64 调用约定,包括所有传递结构体和联合体的规则,你有几个选择:
-
官方的 System V x86-64 ABI 可在
<wbr>gitlab<wbr>.com<wbr>/x86<wbr>-psABIs<wbr>/x86<wbr>-64<wbr>-ABI查阅。(我已经链接过几次了。)第 3.2.3 节讨论了参数传递。 -
Agner Fog 编写了一个有用的手册,描述了不同 C++ 编译器的调用约定(*
<wbr>www<wbr>.agner<wbr>.org<wbr>/optimize<wbr>/calling<wbr>_conventions<wbr>.pdf*)。第 7.1 节的表 6 和表 7 讲述了结构体如何传递和返回。该文档涵盖了 C++,所以有些部分可能不相关,但如何传递普通结构体和联合体的描述适用于 C 和 C++。
我发现 Fog 对调用约定的总结比官方 ABI 更容易理解。如果你决定为额外学分实现联合体,你可能需要参考这两份文档。
第三部分 优化

描述
第十九章:19 优化蹩脚的程序

在本书的前两部分,你编写了一个支持大部分 C 语言的编译器。你确保生成的可执行程序是正确的——换句话说,它们的行为符合 C 标准——但你没有考虑它们的性能。你没有尝试让它们运行得更快、占用更少的存储空间或消耗更少的内存。在第三部分,你将专注于优化这些程序——也就是说,在不改变它们行为的前提下,让它们更小、更快。
一些编译器优化是与机器无关的。这意味着它们不受目标架构细节的影响,比如可用寄存器的数量或对特定汇编指令的限制。编译器通常在将代码转换为汇编之前,首先在像 TACKY 这样的中间表示上执行这些优化。与机器相关的优化则需要考虑目标架构,因此这些优化通常在程序被转换为汇编后才执行。本章将介绍四种广泛使用的与机器无关的优化:常量折叠、不可达代码消除、复制传播和死存储消除。你将在本章开始时的图表中新增一个优化阶段,将这四种优化应用于 TACKY 程序。下一章将介绍寄存器分配,一种与机器相关的优化。
在开始第三部分之前,你不需要完成第二部分。对于每个优化,我们会从一个没有考虑到第二部分语言特性的实现开始。然后,如果需要的话,我们会扩展它以支持这些特性;如果你没有做第二部分,则跳过这一步。除了不可达代码消除以外,我们对每个优化都需要这一步,后者不受第二部分特性的影响。
这两章仅包括了在生产级编译器中能找到的一些优化,但我们将要讲解的基本概念同样适用于许多其他优化。在开始之前,让我们考虑一个对所有编译器优化都至关重要的问题:我们如何知道优化后的代码是正确的?
安全性和可观察行为
首先,编译器优化必须是安全的,意味着它们不能改变程序的语义。(如果程序不按预期运行,无论多快都没有意义!)特别是,优化不能改变程序的可观察行为,即执行环境可以看到的行为。返回退出状态、打印消息到 stdout 以及写入文件都是可观察行为的例子。程序采取的大多数动作——比如计算值、更新局部变量、从一个语句转移控制到另一个——对执行环境不可见,因此它们仅间接地影响程序的可观察行为。这为我们提供了很多灵活性,可以重新排序、替换甚至删除代码,只要不改变可观察行为。
让我们看看 GCC 如何优化一个简单的 C 程序。清单 19-1 初始化了三个变量,分别赋值为1、2 和 3,然后将它们相加并返回结果。
int main(void) {
int x = 1;
int y = 2;
int z = 3;
return x + y + z;
}
清单 19-1:一个将三个变量相加的 C 程序
该程序每次运行时都会表现出相同的可观察行为:它将以退出状态6终止。你可以运行以下命令来编译没有优化的程序:
$ **gcc -S -fno-asynchronous-unwind-tables -fcf-protection=none listing_19_1.c**
这将生成清单 19-2 中的汇编代码,或类似的代码。
.text
.globl main
main:
pushq %rbp
movq %rsp, %rbp
movl $1, -4(%rbp)
movl $2, -8(%rbp)
movl $3, -12(%rbp)
movl -4(%rbp), %edx
movl -8(%rbp), %eax
addl %eax, %edx
movl -12(%rbp), %eax
addl %edx, %eax
popq %rbp
ret
清单 19-2:未经优化的清单 19-1 汇编代码
这个汇编程序忠实地实现了清单 19-1 的源代码:它在栈上初始化了三个位置,赋值为1、2 和 3;然后将它们相加;最后在 EAX 中返回结果。现在让我们使用-O开关编译相同的源代码,以启用优化:
$ **gcc -S -O -fno-asynchronous-unwind-tables -fcf-protection=none listing_19_1.c**
这将生成清单 19-3 中的汇编代码。
.text
.globl main
main:
movl $6, %eax
ret
清单 19-3:优化后的清单 19-1 汇编代码
这个汇编程序没有初始化三个变量再相加,而是直接返回常数6。清单 19-2 和 19-3 看起来很不一样,但它们都产生了正确的可观察行为。
四个粗劣的优化
本节介绍了我们将在本章中实现的优化:常量折叠、不可达代码删除、拷贝传播和死存储删除。这些优化旨在加速代码并减少其占用的空间。单独来看,它们中的一些在实现这两个目标上有所帮助,而其他的则单独作用不大。真正的收获来自于它们的协同工作,因为运行其中任何一个优化都能创造出应用其他三个优化的机会。我们将依次查看这四种优化,然后讨论每种优化如何使其他优化更加有效。
在我们深入之前,需要注意的是,我在本章的大多数 TACKY 列表中将使用简化的符号。我会将拷贝写成 x = y,而不是像早期章节中那样写成 Copy(Var("x"), Var("y")),并且对其他指令也会采取类似的简化方式。例如,我会将二元操作写成 x = a + b,而不是写成 Binary(Add, Var("a"), Var("b"), Var("x")),并且将标签写作 Target:,而不是写成 Label(Target)。这种符号可以让我们集中精力关注 TACKY 程序的高层逻辑,而不是每个 TACKY 指令的细节。
常量折叠
常量折叠步骤会在编译时计算常量表达式。例如,常量折叠会将 Binary TACKY 指令替换为
a = 6 / 2
一条 Copy 指令:
a = 3
常量折叠还可以将条件跳转转化为无条件跳转,或者完全消除它们。它会将
JumpIfZero(0, Target)
进入
Jump(Target)
因为程序总是会执行这个跳转。它也会删除这条指令。
JumpIfZero(1, Target)
因为程序永远不会执行这个跳转。(删除无用的跳转通常被认为是一种死代码删除,而不是常量折叠,但由于我们在这个步骤中会转换条件跳转,因此我们也可以顺便删除那些无用的跳转。)
常量折叠同时有助于提高速度和减小代码体积。一个单独的算术操作或比较可能需要几条汇编指令。其中一些指令,如 idiv,非常慢。常量折叠最终会将这些汇编代码替换为一条 mov 指令。
不可达代码删除
不可达代码删除会移除我们知道永远不会执行的指令。考虑 Listing 19-4 中的 TACKY 片段。
x = 5
Jump(Target)
x = my_function()
Target:
Return(x)
列表 19-4:包含无法访问指令的 TACKY 片段
由于我们总是跳过对my_function的调用,我们可以将其移除:
x = 5
Jump(Target)
Target:
Return(x)
现在,Jump 指令变得无用了,因为它跳转到我们接下来无论如何都会执行的指令。我们也会移除这条指令:
x = 5
Target:
Return(x)
最后,我们还可以移除 Target 标签,前提是没有其他指令跳转到它:
x = 5
Return(x)
严格来说,我们刚刚移除的Jump和Label指令并不是无法访问的代码;正在运行的程序会到达这两条指令,尽管它们没有任何效果。但移除无法访问的代码通常会让跳转和标签变得无用,因此这是移除它们的一个合乎逻辑的步骤。
消除无法访问的代码显然可以减少代码的大小。同样也很清楚,移除无用的跳转可以节省时间;即便是无用的指令,也需要一些时间来执行。事实证明,移除真正无法访问的指令,如列表 19-4 中的FunCall,也能通过减轻内存压力并释放处理器指令缓存中的空间,提升程序速度。
另一方面,移除未使用的标签不会影响速度或代码大小,因为标签在最终的可执行文件中并不会变成机器指令。我们还是会移除这些标签,因为这样可以让我们的 TACKY 程序更容易阅读和调试,并且几乎不需要额外的工作量。
这个步骤对于清理我们添加到每个 TACKY 函数末尾的额外Return指令尤其有用。回想一下,我们添加这条指令是为了防止源代码中缺少 return 语句的情况。当我们将程序转换为 TACKY 时,我们无法判断这条额外的 Return 是否必要,因此最终我们会把它添加到不需要的函数中。无法访问的代码消除步骤会移除所有我们不必要添加的 Return 指令,同时保留我们实际需要的指令。这是一个更广泛原则的例子:生成低效代码并在之后优化它,通常比一开始就生成高效代码要容易。
复制传播
当程序包含 Copy 指令 dst = src 时,复制传播 过程会尝试在后续指令中用 src 替代 dst。考虑以下 TACKY 代码片段:
x = 3
Return(x)
我们可以将 x 用它当前的值 3 替换,在 Return 指令中:
x = 3
Return(3)
将变量替换为常量是复制传播的一个特例,称为 常量传播。在其他情况下,我们将用另一个变量替换一个变量。例如,我们可以重写
x = y
Return(x)
例如:
x = y
Return(y)
有时候,判断是否可以安全地进行复制传播是非常棘手的。考虑以下示例:
x = 4
JumpIfZero(flag, Target)
x = 3
Target:
Return(x)
根据我们所走的路径,x 的值将在我们到达 Return 指令时是 3 或 4。由于我们不知道会走哪条路径到达该指令,因此无法安全地将 x 替换为任何一个值。为了处理类似的情况,我们需要分析到达我们希望重写的指令的每一条可能路径。我们将使用一种叫做 数据流分析 的技术,查看函数中所有的路径,并找到可以安全执行复制传播的位置。数据流分析不仅对复制传播有用,它还被用于许多不同的编译器优化,包括死存储消除,接下来我们将讨论这个话题。
我们分析的某些复制操作将涉及具有静态存储持续时间的变量,这些变量可以被多个函数访问(或者在局部静态变量的情况下,仅为同一函数的多个调用)。我们并不总是能准确知道这些变量何时被更新,因此我们的数据流分析需要将它们与具有自动存储持续时间的变量区分开来。如果你完成了 第二部分,你将需要考虑类似的不确定性,尤其是对于那些使用 & 运算符取地址的变量,因为它们可能通过指针被更新。
复制传播本身并没有多大用处,但它能使其他优化更加有效。当我们传播常量时,就会为常量折叠创造新的机会。我们有时会将每个Copy指令的目标替换为其源,这样就使得Copy本身变得无用。我们将在最后的优化过程中移除这些无用的指令:死存储消除。
死存储消除
当一条指令更新了变量的值,但我们从未使用该新值时,这条指令就被称为死存储。(这里的store指的是任何将值存储到变量中的指令,而不是我们在第二部分中介绍的 TACKYStore指令。)由于死存储不会影响程序的可观察行为,因此可以安全地将其移除。让我们看一个简单的例子:
x = 10
Return(y)
假设x具有自动存储持续时间,指令x = 10就是死存储。在这条指令和函数结束(即x的生命周期结束)之间,我们没有使用x。
这是另一种死存储的例子:
x = a + b
x = 2
Return(x)
在这个例子中,我们永远不会使用a + b的结果,因为我们会首先覆盖它;这意味着x = a + b是一个死存储。死存储消除过程会识别这些无用的指令并将其移除。挑战在于证明一条指令确实是死存储;为此,我们需要分析函数的每一条路径,确保它为目标变量赋的值从未被使用。我们将再次使用数据流分析来确保何时可以安全地应用此优化。
与复制传播类似,当你考虑到可以由多个函数或通过指针访问的对象时,死存储消除会变得更加复杂。例如,如果x是一个全局变量,那么我们第一个例子中的指令x = 10就不是死存储;x可能会在函数返回后被使用。我们的数据流分析必须考虑到这种可能性。
当我们的力量结合时……
现在让我们看看本章中我们将实现的四种优化如何协同工作。我们将以 TACKY 程序为例进行讲解。
my_function(flag):
x = 4
y = 4 - x
JumpIfZero(y, Target)
x = 3
Target:
JumpIfNotZero(flag, End)
z = 10
End:
z = x + 5
Return(z)
清单 19-5:一个未优化的 TACKY 程序
使用所有四个优化方法,我们可以将这个函数简化为一条 Return 指令。我将展示每轮优化的结果,并突出显示任何改变的指令。由于每次优化都能为应用其他三种优化创造更多机会,我们需要多次运行大部分优化才能完全优化这个函数。现在,我们将根据每次查看代码来决定运行哪种优化,选择最有用的一种。我们将在实际实现优化管道时,采用更系统化的方法。
让我们从一次复制传播开始,将 4 替换为 x,在 y = 4 - x 中:
my_function(flag):
x = 4
**y = 4 - 4**
JumpIfZero(y, Target)
x = 3
Target:
JumpIfNotZero(flag, End)
z = 10
End:
z = x + 5
Return(z)
我们不能替换 x 的第二次使用,出现在 z = x + 5 中,因为此时 x 可能有多个值:它可能是 3 或 4,具体取决于是否进行条件跳转。接下来,我们将应用常量折叠来计算 y = 4 - 4:
my_function(flag):
x = 4
**y = 0**
JumpIfZero(y, Target)
x = 3
Target:
JumpIfNotZero(flag, End)
z = 10
End:
z = x + 5
Return(z)
通过用 Copy 指令替换二元操作,我们为复制传播创造了另一个机会。我们可以在 JumpIfZero 指令中用 0 替换 y 的值:
my_function(flag):
x = 4
y = 0
**JumpIfZero(0, Target)**
x = 3
Target:
JumpIfNotZero(flag, End)
z = 10
End:
z = x + 5
Return(z)
现在,JumpIfZero 依赖于常量条件,我们可以再次运行常量折叠,将其转化为无条件的 Jump:
my_function(flag):
x = 4
y = 0
**Jump(Target)**
x = 3
Target:
JumpIfNotZero(flag, End)
z = 10
End:
z = x + 5
Return(z)
这个变化使得 x = 3 无法到达,因此我们将运行无法到达代码消除来删除它。这个阶段还将删除 Jump 指令和 Target 标签,因为一旦我们删除了 x = 3,它们就不再有效:
my_function(flag):
x = 4
y = 0
JumpIfNotZero(flag, End)
z = 10
End:
z = x + 5
Return(z)
我们之前不能重写 z = x + 5,因为 x 在不同路径上有两个不同的值。我们通过消除 x = 3 的路径解决了这个问题。现在我们可以再次运行复制传播:
my_function(flag):
x = 4
y = 0
JumpIfNotZero(flag, End)
z = 10
End:
**z = 4 + 5**
Return(z)
然后我们将再运行一轮常量折叠:
my_function(flag):
x = 4
y = 0
JumpIfNotZero(flag, End)
z = 10
End:
**z = 9**
Return(z)
我们将最后一次运行复制传播:
my_function(flag):
x = 4
y = 0
JumpIfNotZero(flag, End)
z = 10
End:
z = 9
**Return(9)**
我们已经成功地在编译时计算了这个函数的返回值,消除了整个过程中对x、y和z的使用。现在我们将运行死代码消除,清理赋值给这三个变量的指令:
my_function(flag):
JumpIfNotZero(flag, End)
End:
Return(9)
最后,我们将运行不可达代码消除,移除JumpIfNot Zero指令和End标签。这两者都是冗余的,因为我们刚刚移除了JumpIfNotZero跳过的那条指令。这轮优化将使我们的函数简化为单一指令:
my_function(flag):
Return(9)
这个例子突出了我们的优化方法如何协同工作。复制传播可能会将变量替换为常量,从而为常量折叠创造新的机会;常量折叠将算术运算重写为Copy指令,创造了新的复制传播机会。常量折叠还可以将条件跳转替换为无条件跳转,从而使一些指令变得不可达;消除不可达代码简化了程序的控制流,这有助于复制传播。复制传播可能使Copy指令冗余,从而使我们能够在死代码消除过程中将其移除。死代码消除可能会移除跳转与其目标标签之间的每一条指令,这使得跳转和可能的标签成为不可达代码消除的候选项。
现在我们知道了每个优化的作用以及它们如何协同工作。接下来,我们将添加一些新的命令行选项,允许我们进行测试。
测试优化过程
本章的测试与早期章节的测试有所不同。我们需要验证我们的优化是否没有改变程序的可观察行为,但可以简化常量表达式并移除无用代码。我们当前的策略——编译 C 程序,运行它们,并确保它们行为正确——满足了第一个要求,但没有满足第二个要求。仅仅运行程序并不能告诉你优化阶段是否做了任何事情。为了处理第二个问题,测试脚本将检查每个测试程序的编译器汇编输出。为了处理第一个问题,它还将运行每个测试程序并验证其行为,就像在早期章节中一样。
为了支持本章的测试,你需要为编译器添加一些命令行选项:
-S 指示编译器生成汇编文件,但不进行汇编或链接。运行 ./YOUR_COMPILER -S /path/to/program.c 应该会将汇编文件写入 /path/to/program.s。(我建议在第一章中添加此选项,以帮助调试;如果你还没有添加,现在需要加上它。)
--fold-constants 启用常量折叠。
--propagate-copies 启用复制传播。
--eliminate-unreachable-code 启用不可达代码消除。
--eliminate-dead-stores 启用死存储消除。
--optimize 启用所有四项优化。
启用优化的选项应传递给优化阶段,我们将在接下来实现它。应该能够启用多个单独的优化;例如,./YOUR_COMPILER --fold-constants --propagate-copies 应同时启用常量折叠和复制传播,但不启用其他两个优化。
如果你的编译器生成的汇编与我在本书中所描述的完全不同,测试脚本仍然应该能够验证你在本章测试中的汇编输出,但有几个注意事项需要记住。首先,测试脚本只理解 AT&T 汇编语法,这也是我们在本书中使用的语法。其次,脚本并不识别每一条汇编指令;它只知道我们在本书中使用的指令以及一些在现实汇编代码中特别常见的其他指令。如果你发出的指令是测试脚本无法识别的,某些测试可能会失败。
接下来,我们将接入新的优化阶段,控制何时调用每个单独的优化。
优化阶段的接线
优化阶段将在我们将程序转换为 TACKY 后立即运行。此阶段将独立地优化每个 TACKY 函数,而不考虑程序中定义的其他函数。例如,它不会在常量折叠阶段尝试评估函数调用,也不会在死存储消除阶段删除它们。(我们不能在死存储消除阶段删除函数调用,因为我们不知道它们是否有副作用。但是我们可以在不可达代码消除阶段删除它们——如果一个函数调用永远不会执行,那么它的副作用就无关紧要。)像这些一次只转换一个函数的优化被称为过程内优化。大多数生产级编译器还会执行过程间优化,即转换整个翻译单元,而不是单个函数。
每个单独的优化将以 TACKY 函数的函数体作为输入,返回一个语义等效的函数体作为输出。在常量折叠阶段,我们将像往常一样将函数体表示为一系列 TACKY 指令。但在其他三个优化阶段,我们将每个函数表示为控制流图。这是一个中间表示,显式地建模了代码段中不同的执行路径。我们将在本章后面详细讨论如何构建控制流图以及它们为什么有用。
优化阶段将通过反复运行所有启用的优化来处理每个函数。它将在达到固定点时停止,此时再次运行优化将不会进一步改变函数。列表 19-6 展示了这个优化管道。
optimize(function_body, enabled_optimizations):
if function_body is empty:
return function_body
while True:
if enabled_optimizations contains "CONSTANT_FOLDING":
❶ post_constant_folding = constant_folding(function_body)
else:
post_constant_folding = function_body
❷ cfg = make_control_flow_graph(post_constant_folding)
if enabled_optimizations contains "UNREACHABLE_CODE_ELIM":
cfg = unreachable_code_elimination(cfg)
if enabled_optimizations contains "COPY_PROP":
cfg = copy_propagation(cfg)
if enabled_optimizations contains "DEAD_STORE_ELIM":
cfg = dead_store_elimination(cfg)
❸ optimized_function_body = cfg_to_instructions(cfg)
❹ if (optimized_function_body == function_body
or optimized_function_body is empty):
return optimized_function_body
function_body = optimized_function_body
列表 19-6:TACKY 优化管道
在这个列表中,function_body是 TACKY 函数体中指令的列表,enabled_optimizations是表示命令行中启用的优化的字符串列表。(在实际程序中,这将是存储命令行选项的一种相当笨重的方式;可以在你自己的代码中采用不同的方式表示这些选项。)如果function_body为空,我们将直接返回它,因为没有内容可以优化。否则,如果启用了常量折叠,我们将执行常量折叠❶。
接下来,我们将把函数体从指令列表转换为控制流图❷。我们将对该表示应用所有启用的优化。然后,我们将优化后的控制流图转换回指令列表❸,并与原始列表❹进行比较。如果它不同,并且我们没有优化掉整个函数,我们将重新进行循环,以利用任何新的优化机会。如果它相同,我们就不能再进一步优化了,优化过程结束。
使用清单 19-6 中的优化管道,我们就不会错过任何优化机会。每当一个优化改变了程序时,我们会重新运行其他三个优化,以利用这些变化。这是可行的,因为我们只实现了四个优化,而且我们所有的测试程序都足够小,能够快速优化。生产编译器实现了几十种优化,并编译了更大的程序,因此不会采用这种方法;如果采用,编译时间会非常长。相反,它们只会应用一次固定顺序的优化,按照可能产生最大影响的顺序运行每个优化。因此,它们可能会错过优化机会。(为任何给定的程序找到最佳优化执行顺序是一个开放的研究问题,称为阶段排序问题。)
继续将优化管道添加到你的编译器中。现在,先将每个优化定义为一个存根,它接受一个指令列表并返回不变的指令。你也可以以相同的方式为控制流图的转换添加存根。现在编写这些支撑代码,以便在实现各个优化时能够测试它们。
一旦所有内容都设置好,你就可以开始进行第一个优化:常量折叠!
常量折叠
常量折叠是本章中最简单的优化。此优化遍历 TACKY 函数中的所有指令,并评估任何具有常量源操作数的指令。首先,我们将简要讨论如何将常量折叠添加到你在第一部分中实现的编译器版本。然后,我们将讨论如何处理你在第二部分中添加的类型和 TACKY 指令。如果你还没有完成第二部分,可以跳过后面的讨论。
第一部分 TACKY 程序的常量折叠
常量折叠过程应评估来自 第一部分 的四条 TACKY 指令:Unary、Binary、JumpIfZero 和 JumpIfNotZero。当你发现一个源操作数为常量的 Unary 指令,或者一个源操作数为常量的 Binary 指令时,将其替换为 Copy。例如,你应该替换
Binary(binary_operator=Add, src1=Constant(1), src2=Constant(2), dst=Var("b"))
使用:
Copy(src=Constant(3), dst=Var("b"))
你的常量折叠(constant folding)过程可能会遇到两种无效的表达式:除以零和导致整数溢出的操作。这些都是未定义的行为,因此无论你如何评估它们,结果都一样。然而,你的编译器不能在遇到这些无效表达式时直接失败,因为只有在程序运行时实际到达这些无效表达式时,程序的行为才是未定义的。例如,如果程序在一个从未被执行的分支中包含除以零的操作,你仍然应该能够编译它。
你还应该评估常量条件下的 JumpIfZero 和 JumpIfNotZero 指令。如果条件满足,将指令替换为无条件的 Jump。如果条件不满足,则从程序中移除该指令。就这么简单!如果你只完成了 第一部分,在实现了这四条指令的常量折叠后,你可以跳过测试套件。如果你完成了 第二部分,还有一些其他指令需要处理。
支持第二部分 TACKY 程序
当我们在 第二部分 中添加了新的算术类型时,我们也添加了类型转换指令:Truncate、SignExtend、ZeroExtend、DoubleToInt、DoubleToUInt、IntToDouble 和 UIntToDouble。常量折叠过程应在源操作数为常量时评估所有这些指令。
Copy指令也可以执行类型转换;我们使用它在相同大小的有符号和无符号整数之间进行转换。当一个Copy指令将无符号常量复制到有符号变量,或反之时,这一过程应将常量转换为正确的类型。例如,如果a是一个signed char,你应该替换
Copy(src=Constant(ConstUChar(255)), dst=Var("a"))
with:
Copy(src=Constant(ConstChar(-1)), dst=Var("a"))
小心进行每一个类型转换,确保与程序运行时使用的语义完全一致。例如,当你将double转换为整数类型时,应将其值截断为零;当你将整数转换为double时,应舍入到最近的可表示值。好消息是,你已经知道如何在编译时执行所有这些类型转换,因为在第二部分中,你必须将静态初始化值转换为正确的类型。理想情况下,你应该能够重用你已经编写的代码来执行这些类型转换。
你还需要遵循 C 语言语义来评估无符号算术运算。特别是,你应该确保无符号算术运算能正确地回绕,就像运行时一样。如何实现这一点完全取决于你用来编写编译器的语言。例如,Rust 提供了像wrapping_add和wrapping_sub的方法,它们提供与 C 语言中的无符号算术相同的语义。在其他语言中,你可能需要使用第三方库来实现无符号算术。例如,Python 不提供无符号整数类型,但 NumPy 库提供了。如果你不想使用外部库,或者找不到合适的库,其实自己实现回绕无符号算术也并不困难。
最后,在评估浮点操作时,您需要使用四舍五入到最接近的值、偶数舍入规则,并正确处理负零和无穷大。如果您在第十三章为 NaN 添加了额外的支持,您还需要正确评估 NaN 上的操作。这应该不需要您做任何特别的工作——绝大多数编程语言都使用 IEEE 754 语义——但有小概率您的实现语言在处理负零、NaN 或无穷大时与 C 语言不同。从一个简单的常量折叠实现开始,不需要尝试解决这些边缘情况;您可以依赖测试套件来捕获任何问题。如果您遇到您的实现语言无法正确评估的情况,您有两个选择:要么找到一个第三方库来处理它们,要么将它们作为特殊情况自己处理。
控制流图
在本章的其余部分,我们将使用控制流图表示 TACKY 函数。图形表示非常适合我们剩下的优化,它们必须考虑我们在函数中可能经过的不同路径。控制流图中的节点表示称为基本块的直线代码序列,除了两个特殊节点,分别表示函数的入口和出口。每个节点都有指向可以立即执行的下一个节点的边。
例如,我们来看一下清单 19-7 的控制流图。
processing_loop():
LoopStart:
input = get_input()
JumpIfNotZero(input, ProcessIt)
Return(-1)
ProcessIt:
done = process_input(input)
JumpIfNotZero(done, LoopStart)
Return(0)
清单 19-7:具有多个执行路径的 TACKY 函数
该函数执行一个循环,通过调用get_input反复获取一个值,然后通过调用process_input处理该值。如果get_input返回0,该函数立即返回-1。如果process_input返回0,该函数立即返回0。图 19-1 展示了对应的控制流图。

图 19-1:清单 19-7 的控制流图 描述
从特殊的 ENTRY 节点到块 B0 有一条单一的出边,因为我们总是在函数开始时执行 B0。(ENTRY 在每个控制流图中都会有一条出边,因为 C 函数只有一个入口点。)在执行完 B0 后,有两种可能性:我们可以执行程序中的下一个块 B1,或者我们可以跳转到块 B2。因此,B0 有指向这两个块的出边。按照同样的逻辑,B2 有指向 B0 和 B3 的出边。一个 Return 指令退出函数,因此 B1 和 B3 各自有一条出边指向 EXIT。
定义控制流图
现在你已经知道控制流图是什么样子了,我们来看看如何构建一个。首先,我们将定义图的结构。清单 19-8 概述了其中一种可能的表示方法。
node_id = ENTRY | EXIT | BlockId(int num)
node = BasicBlock(node_id id, instruction* instructions,
node_id* predecessors, node_id* successors)
| EntryNode(node_id* successors)
| ExitNode(node_id* predecessors)
graph = Graph(node* nodes)
清单 19-8:表示控制流图的一种方式
图中的每个节点都有一个唯一的 node_id,用于标识该节点是 ENTRY、EXIT,还是一个编号的基本块。我们将根据它们在原始 TACKY 函数中的顺序为基本块分配数字 ID。每个基本块包含一个 TACKY 指令列表,一个 后继 列表(表示可以在其之后执行的块),以及另一个 前驱 列表(表示可以在其之前执行的块)。入口节点和出口节点不包含任何指令。ENTRY 作为函数的第一个节点,拥有后继但没有前驱。EXIT 则相反,拥有前驱但没有后继。
你需要一种方法将基本块和单独的指令与额外信息关联,以便在拷贝传播和死存储消除的过程中跟踪数据流分析的结果。清单 19-8 中的定义并没有提供跟踪这些信息的方法。你可以将它直接附加到图中,或者存储在一个单独的数据结构中。本章中的伪代码将使用 annotate _instruction 和 get_instruction_annotation 来保存和查找有关单个指令的信息。它将使用 annotate_block 和 get_block _annotation 来保存和查找通过块 ID 获取的基本块信息。
注意
你的图数据结构可能与 清单 19-8 中的结构非常不同。例如,你可能希望将图表示为从 node_id 到节点的映射,而不是节点的列表,或者将入口和出口节点与表示基本块的节点分开跟踪。你可以按照适合你实现语言的方式定义控制流图,只要它包含你所需的所有信息。
创建基本块
接下来,让我们看看如何将 TACKY 函数的主体划分为基本块。基本块的中间不能有跳转进出。执行基本块的唯一方法是从其第一条指令开始,一直到结束。这意味着Label只能作为块中的第一条指令出现,而Return或跳转指令只能作为最后一条指令出现。清单 19-9 演示了如何在这些边界上将指令列表拆分为基本块。
partition_into_basic_blocks(instructions):
finished_blocks = []
current_block = []
for instruction in instructions:
❶ if instruction is Label:
if current_block is not empty:
finished_blocks.append(current_block)
current_block = [instruction]
❷ else if instruction is Jump, JumpIfZero, JumpIfNotZero, or Return:
current_block.append(instruction)
finished_blocks.append(current_block)
current_block = []
else:
❸ current_block.append(instruction)
if current_block is not empty:
finished_blocks.append(current_block)
return finished_blocks
清单 19-9:将指令列表划分为基本块
当我们遇到一个Label指令时,我们会从该Label ❶开始一个新的基本块。当我们遇到一个Return指令或条件跳转或无条件跳转时,我们将其添加到当前块,然后开始一个新的空块 ❷。当我们遇到任何其他指令时,我们将其添加到当前块中,而不开始一个新的块 ❸。
清单 19-9 只是将函数体划分为指令的列表。接下来的步骤(我不会提供伪代码)是将这些指令列表转换为具有递增块 ID 的BasicBlock节点。然后我们将这些节点添加到图中,并添加入口节点和出口节点。
向控制流图添加边
在将每个节点添加到图中之后,我们将从每个节点添加到其后继节点的边,如清单 19-10 所示。
add_all_edges( ❶ graph):
❷ add_edge(ENTRY, BlockId(0))
for node in graph.nodes:
if node is EntryNode or ExitNode:
continue
if node.id == max_block_id(graph.nodes):
next_id = EXIT
else:
❸ next_id = BlockId(node.id.num + 1)
instr = get_last(node.instructions)
match instr with
| Return(maybe_val) -> add_edge(node.id, EXIT)
| Jump(target) ->
target_id = get_block_by_label(target)
add_edge(node.id, target_id)
| JumpIfZero(condition, target) ->
target_id = get_block_by_label(target)
❹ add_edge(node.id, target_id)
❺ add_edge(node.id, next_id)
| JumpIfNotZero(condition, target) ->
// same as JumpIfZero
`--snip--`
| _ -> add_edge(node.id, next_id)
清单 19-10:向控制流图添加边
graph 参数传递给 add_all_edges ❶ 是我们未完成的控制流图,它有节点但没有边。我们将首先添加一条从 ENTRY 到第一个基本块 ❷ 的边。(我们可以假设该函数至少包含一个基本块,因为我们不会优化空函数。)在整个示例中,我们将使用 add_edge 函数,它接受两个节点 ID,用来向图中添加边。请记住,每次我们从 node1 添加一条边到 node2 时,我们必须更新 node1 的所有后继节点以及 node2 的所有前驱节点。我已省略了 add_edge 的伪代码,因为它将取决于你如何定义控制流图。
接下来,我们将为与基本块对应的节点添加外向边。为了处理其中一个节点,我们首先要确定如果在块的末尾不进行跳转或返回,默认情况下将跟随它的下一个节点。如果我们处理的是最后一个块,下一节点将是 EXIT。否则,下一节点将是原始 TACKY 函数中紧接着的基本块 ❸。
我们将通过检查当前基本块中的最后一条指令来确定需要添加哪些边。如果它是 Return 指令,我们将添加一条指向 EXIT 的外向边。如果它是无条件的 Jump 指令,我们将添加一条指向以对应 Label 开头的块的边。我们使用 get_block_by_label 辅助函数(我不会展示其伪代码),通过标签查找以特定标签开头的块。我建议提前构建一个从标签到块 ID 的映射,以便这个函数可以直接执行映射查找。
如果一个块以条件跳转结束,我们将添加两条外向边。第一条边,表示进行跳转,将指向以对应 Label ❹ 开头的块。另一条边,表示不进行跳转 ❺,将指向默认的下一个节点,由 next_id 标识。如果一个块以其他指令结束,我们将添加一条指向默认下一个节点的外向边。
将控制流图转换为指令列表
此时,你应该已经有了能够将 TACKY 函数转换为控制流图的工作代码。你还需要一段代码来进行反向操作,将控制流图转换回指令列表。这一操作要简单得多:只需要按 ID 排序所有基本块,然后将它们的指令连接起来。
使你的控制流图代码可重用
在下一章,我们将构建汇编程序的控制流图。我们将使用相同的算法来构建这些图,但我们会寻找不同的单独控制流指令。例如,jmp、ret,以及像 jne 和 je 这样的条件跳转指令,都会标志着汇编中基本块的结束。
一旦你有了构建控制流图的工作代码,你可能想重构它,以便也能用于汇编程序。这是完全可选的,但它会在下一章节省你一些精力。
首先,你需要将 graph 数据类型进行泛化,以便一个块可以包含 TACKY 或汇编指令。接着,你需要泛化逻辑来分析 清单 19-9 和 19-10 中的具体指令。例如,你可以定义一个一次性的数据类型,来表示汇编和 TACKY 指令,这样就能捕获构建控制流图所需的信息:
generic_instruction = Return
| Jump
| ConditionalJump(identifier label)
| Label(identifier)
| Other
不需要检查单独的 TACKY 指令来确定基本块的结束位置或它的后继,你可以将每个指令转换为一个 generic_instruction 并检查它。然后,当你需要为汇编程序构建控制流图时,你将使用不同的辅助函数将汇编指令转换为 generic_instruction,但其他部分保持不变。
这就结束了我们对控制流图的讨论。现在我们准备继续进行第二个优化步骤:不可达代码消除。
不可达代码消除
我们将把这个步骤分为三步,首先移除永远不会执行的基本块,然后是无用的跳转,最后是无用的标签。最后两个步骤可能会留下不包含任何指令的空块。可选地,我们可以通过从控制流图中移除这些空块来清理优化后的结果。
消除不可达块
为了找到每一个可能执行的块,我们将从ENTRY开始遍历控制流图。我们将访问ENTRY的后继节点,然后访问该节点的所有后继节点,依此类推,直到没有更多的节点可以探索。如果这个遍历永远没有到达某个基本块,那么我们就知道该块可以安全地移除。我们来试试这种方法,基于清单 19-4 中的示例,正如我们第一次介绍无法到达的代码消除时所看的那样:
x = 5
Jump(Target)
x = my_function()
Target:
Return(x)
我们之前确定了x = my_function()是无法到达的。假设这个清单是一个 TACKY 函数的完整函数体,它将具有图 19-2 所示的控制流图。

图 19-2:控制流图,清单 19-4 描述
注意,从ENTRY到B1之间没有路径。如果我们从ENTRY开始遍历这个图,我们将访问B0、B2和EXIT。在此过程中,我们将跟踪已经访问的节点。一旦完成,我们会发现我们从未访问过B1,因此我们将其移除。我不会提供用于遍历图的伪代码,因为这只是普通的广度优先或深度优先图遍历。
当你从图中移除一个节点时,记得也要移除它的输出边。例如,当我们在图 19-2 中移除B1时,我们还应该将其从B2的前驱列表中移除。
移除无用的跳转
接下来,我们将移除所有无用的跳转指令。请记住,默认情况下,如果一个块没有以跳转或Return指令结尾,控制流将按照原始程序顺序流向下一个块。如果跳转指令指向这个默认的下一个块,我们可以删除它。
我们将查看每个以条件或无条件跳转结尾的基本块,并弄清楚如果没有执行跳转,默认情况下会跟随哪个块。如果这个默认的下一个块是它唯一的后继块,那么跳转指令就是冗余的。Listing 19-11 演示了这种方法。
remove_redundant_jumps(graph):
❶ sorted_blocks = sort_basic_blocks(graph)
i = 0
❷ while i < length(sorted_blocks) - 1:
block = sorted_blocks[i]
if block.instructions ends with Jump, JumpIfZero, or JumpIfNotZero:
keep_jump = False
default_succ = sorted_blocks[i + 1]
for succ_id in block.successors:
if succ_id != default_succ.id:
keep_jump = True
break
if not keep_jump:
❸ remove_last(block.instructions)
i += 1
Listing 19-11: 删除冗余跳转
首先,我们将按原始 TACKY 函数中的位置对基本块进行排序❶;这也是我们在构建图时为基本块编号的原因之一。接下来,我们将遍历这个排序后的基本块列表(最后一个除外,因为函数结尾的跳转永远不会是冗余的)❷。如果某个块以跳转结尾,我们将寻找一个除了列表中下一个块之外的后继块。如果找到了,我们就保留跳转指令;否则,我们就删除它❸。
请注意,列表中的下一个块不一定会有下一个连续的数字 ID,因为我们可能已经删除了前面的块。例如,块 2 可能会被块 4 跟随。这就是为什么我们不能仅通过递增块的 ID 来找到它的默认后继块。
删除无用标签
删除无用标签类似于删除无用跳转。在按数字 ID 对基本块进行排序后,如果某个基本块仅通过从前一个块的顺序流进入,而不是显式跳转到它,我们可以删除该块开头的 Label 指令。更具体地说,如果 sorted_blocks[i] 只有一个前驱块 sorted_blocks[i - 1],我们就可以删除其开头的 Label。如果 sorted_blocks[0] 的唯一前驱是 ENTRY,我们也可以删除它开头的 Label。这个转换是安全的,因为我们只是删除了冗余的跳转指令;我们知道 sorted_blocks[i - 1] 不会以显式跳转到 sorted_blocks[i] 结尾。我不会为此步骤提供伪代码,因为它基本上与 Listing 19-11 中的内容相同。
删除空块
消除不可达的跳转和标签可能会导致某些代码块没有指令。如果你愿意,可以删除它们;这将缩小图形并可能稍微加速后续的优化过程。当你删除一个块时,确保相应地更新控制流图中的边。例如,如果图中有从B0到B1的边,以及从B1到B2的边,而你删除了B1,你将需要添加一条从B0到B2的边。
关于数据流分析的一些基本介绍
本节将简要概述数据流分析,我们将在接下来的两个优化过程中依赖它。你将了解它是什么,何时有用,以及所有数据流分析共有的特征。这不是数据流分析的完整解释;我的目标只是介绍几个关键概念,并描述它们是如何结合在一起的,以便使后续部分中的具体分析更容易理解。
数据流分析回答有关值在整个函数中如何定义和使用的问题。不同的数据流分析回答不同的问题。例如,在复制传播阶段,我们将实现到达复制分析。它回答的问题是:在一个 TACKY 函数中,给定某个指令i,以及在该函数中出现的两个操作数u和v,我们能否保证在i执行之前,u和v是相等的?
我们可以将所有数据流分析分为两大类:正向分析和反向分析。在正向分析中,信息在控制流图中向前传播。达成副本分析是一种正向分析。当我们看到一条 Copy 指令 x = y 时,这意味着 x 和 y 可能在同一基本块中或该块的后继块中具有相同的值。在反向分析中,情况则相反。在死存储消除过程中,我们将实现一种反向分析,称为 活跃度分析。这种分析告诉我们一个变量的当前值是否会被使用。如果我们看到一条使用 x 的指令,这意味着 x 可能在同一基本块中或该块的前驱块中较早时刻活跃。
每个数据流分析都有其独特的传输函数和合并操作符。传输函数 计算单个基本块内的分析结果。此函数分析各个指令如何影响结果,但不需要处理多条执行路径。合并操作符 将多条路径的信息合并,以计算每个基本块如何受到其邻居的影响。我们将使用迭代算法来驱动整个分析。该算法会在每个基本块上调用传输函数和合并操作符,并跟踪哪些块仍然需要分析。它是迭代的,因为我们可能需要多次访问某些块,以便沿不同的执行路径传播信息。该算法将遍历控制流图,分析它访问的每个基本块,直到达到固定点,即分析结果不再变化。到那时,我们就知道所有可能的执行路径都已被考虑。迭代算法并不是解决数据流分析问题的唯一方法,但它是本书中唯一讨论的方法。
尽管不同的分析使用不同的传输函数和合并操作符,它们本质上都使用相同的迭代算法。正向分析和反向分析使用该算法的不同版本,因为它们在相反的方向上传递数据。我们将在接下来的两节中实现这两种版本。
副本传播
如果在一个函数中出现指令x = y,有时我们可以在函数的后续部分将x替换为y。我们称希望执行此替换的指令为i。当满足两个条件时,替换是安全的。首先,指令x = y必须出现在从程序入口点到i的每条路径上。考虑图 19-3 中的控制流图,它不满足这个条件。

图 19-3:一个不能执行复制传播的函数控制流图 描述
在这个控制流图中,从函数的起始点到Return(x)有两条路径。因为只有其中一条路径经过x = 2,所以在这个Return指令中将x替换为2是不安全的。另一方面,在图 19-4 中,每条通向Return(x)的路径都会经过x = 2。

图 19-4:一个可以执行复制传播的函数控制流图 描述
无论我们通过图 19-4 选择哪条路径,我们都会在到达Return指令之前执行x = 2,因此我们可以安全地将该指令重写为Return(2)。
图 19-5 展示了另一个稍微复杂一点的例子。

图 19-5:另一个可以安全执行复制传播的控制流图 描述
再次强调,通往Return(x)的路径有两条。两条路径都经过x = y,但它们通过不同的指令实例,这些实例出现在不同的块中。在B1中,y的值为20;在B2中,其值为100。但无论是哪种情况,当我们到达Return指令时,x和y的值是相同的。这意味着仍然可以将Return(x)重写为Return(y)。
在重写指令i之前,还有第二个条件需要满足:每条通往i的路径必须满足:在指令x = y和i之间,x和y不能再次被更新。请考虑以下 TACKY 片段:
x = 10
x = foo()
Return(x)
我们不能在Return(x)中将x替换为10,因为此时x的值不再是10。更新在Copy指令右侧出现的变量也会导致同样的问题:
x = y
y = 0
Return(x)
在y = 0之前,我们知道x和y的值是相同的。但在那条指令之后,它们的值将不同,因此我们不能重写Return(x)。当Copy指令的源或目标被更新时,我们称该复制被杀死。一旦复制被杀死,我们就不能将其传播到程序的后续位置。
x = y可能在通往i的某些路径中多次出现。只有当它在最后一次出现后被杀死时,传播它才是不安全的。在下面的示例中,将Return(x)重写为Return(2)是安全的:
x = 2
x = foo()
x = 2
Return(x)
如果有多个路径通向i,我们关注的Copy指令不能在任何路径上被杀死。看看图 19-6,其中x = y在一条路径上被杀死,但在另一条路径上没有。

图 19-6:一个控制流图,其中一个到达的复制在一条路径上被杀死 描述
如果我们跳过B1,当我们到达B2中的Return指令时,x和y将具有相同的值。但如果我们走过B1路径,它们的值将会不同。因为我们无法预先知道程序会走哪条路径,所以我们不能重写Return(x)。
让我们考虑一个最终的边缘案例。假设x = y后面跟着y = x,且中间没有其他被杀死的指令:
x = y
`--snip--`
y = x
z = x + y
通常,更新y会导致之前的Copy指令失效。但是在y = x之后,x和y仍然拥有相同的值。处理这种情况有多种正确的方法。一个选项是说y = x会使x = y失效,因此只有y = x会影响z = x + y。在这种情况下,我们会将最后一条指令重写为z = x + x。这可能会让我们在后续的死存储消除过程中删除y = x,具体取决于y在其他地方的使用情况。另一个选项是简单地忽略我们的分析中的y = x,理由是它没有任何效果;它只是将y赋值为它已经拥有的相同的值。然后,当我们重写指令时,可以直接删除y = x并将最后的指令重写为z = y + y。第三个选项是将两个副本传播到最后一条指令中,分别将x替换为y,并将y替换为x。这种替换是安全的,但并不是特别有帮助,因为它不会帮助我们去掉任何一个Copy指令。我们将选择第二个选项,去除冗余的Copy。
如果一个Copy指令出现在到达指令i的每一条路径上,并且在这些路径上都没有被消除,我们就说它到达指令i。在副本传播过程的开始,我们将执行到达副本分析,以确定哪些副本到达 TACKY 函数中的每一条指令。然后,我们将利用这个分析结果来识别可以安全重写的指令。
我们将在第一部分中定义的 TACKY 子集上实现这个优化,然后扩展它以处理第二部分中的新语言特性。
到达副本分析
为了实现到达复制分析,我们将定义之前讨论过的每一个数据流分析元素:转移函数、交汇操作符和迭代算法。我们将在本节中讨论的转移函数和交汇操作符是特定于到达复制分析的,而迭代算法适用于所有的前向数据流分析。
转移函数
转移函数会接收所有到达基本块起始位置的 Copy 指令,并计算哪些复制指令会到达块内的每个单独指令。它还会计算哪些复制指令到达块的末尾,即最后一条指令之后。这里的规则相当简单。首先,如果 i 是一条 Copy 指令,它会到达紧跟其后的指令。其次,如果某条 Copy 指令到达 i,它也会到达 i 后面的指令,除非 i 会终结它。让我们通过一个例子来理解。假设一个基本块包含了 Listing 19-12 中的指令。
x = a
y = 10
x = y * 3
Return(x)
Listing 19-12: 一个基本块
假设有一条 Copy 指令,a = y,到达这个基本块的起始位置。这条 Copy 指令将会到达第一条指令,x = a。一旦我们遇到 x = a,我们将它添加到当前的到达复制集,因此 a = y 和 x = a 都会到达下一条指令 y = 10。由于这条指令更新了 y,它终结了 a = y。因此,我们将 a = y 从到达复制集移除,但添加 y = 10。最后,x = y * 3 终结了 x = a。我们不会将 x = y * 3 添加为到达复制,因为它不是一条 Copy 指令。最终的 Return 指令不会添加或移除任何到达复制。Table 19-1 列出了每条指令在这个基本块中到达的复制指令。
表 19-1: 示例 19-12 中每条指令达到的副本
| 指令 | 达到的副本 |
|---|---|
| x = a | |
| y = 10 | |
| x = y * 3 | |
| Return(x) | |
| 块结束 |
当我们考虑具有静态存储持续时间的变量时,事情变得有些复杂。如示例 19-13 所示,这些变量可以在其他函数中被更新。
int static_var = 0;
int update_var(void) {
static_var = 4;
return 0;
}
int main(void) {
static_var = 5;
❶ update_var();
return static_var;
}
示例 19-13:一个 C 程序,其中多个函数访问同一个具有静态存储持续时间的变量
我们的达到副本分析应当识别出在 main 中调用 update _var 会导致 static_var = 5 ❶ 被修改。否则,它会错误地重写 main 使其返回常量 5。乍一看,这个问题似乎只适用于文件作用域的变量,但正如示例 19-14 所示,它同样影响静态局部变量。
int indirect_update(void);
int f(int new_total) {
static int total = 0;
total = new_total;
if (total > 100)
return 0;
total = 10;
❶ indirect_update();
return total;
}
int indirect_update(void) {
f(101);
return 0;
}
示例 19-14:一个 C 程序,其中函数调用间接更新静态局部变量
当我们分析 f 时,我们需要知道在 ❶ 处对 indirect_update 的调用可以更新 total。否则,我们会错误地将 f 重写为返回 10。
有几种方法可以解决这个问题。一种选择是找出哪些函数调用会更新哪些静态变量。这将使得到达副本分析成为一个跨过程分析,收集多个函数的信息。这种方法很快会变得复杂。我们的另一个选择是假设每个函数调用都会更新每个静态变量。我们将选择这个方法,因为它更简单。每当我们遇到一个函数调用时,我们将杀死任何指向或来自静态变量的副本。这个方法是保守的;它保证我们永远不会执行不安全的优化,但可能会导致我们不必要地杀死一些到达副本,并错过一些安全的优化。相比之下,使用跨过程分析将是一种更激进的方法,因为它会错过更少的优化。更激进的优化技术并不总是更好;它们通常会增加复杂性和更长的编译时间。
列表 19-15 给出了传递函数的伪代码。
transfer(block, initial_reaching_copies):
current_reaching_copies = initial_reaching_copies
for instruction in block.instructions:
❶ annotate_instruction(instruction, current_reaching_copies)
match instruction with
| Copy(src, dst) ->
❷ if Copy(dst, src) is in current_reaching_copies:
continue
❸ for copy in current_reaching_copies:
if copy.src == dst or copy.dst == dst:
current_reaching_copies.remove(copy)
❹ current_reaching_copies.add(instruction)
| FunCall(fun_name, args, dst) ->
for copy in current_reaching_copies:
❺ if (copy.src is static
or copy.dst is static
or copy.src == dst
or copy.dst == dst):
current_reaching_copies.remove(copy)
| Unary(operator, src, dst) ->
❻ for copy in current_reaching_copies:
if copy.src == dst or copy.dst == dst:
current_reaching_copies.remove(copy)
| Binary(operator, src1, src2, dst) ->
// same as Unary
`--snip--`
| _ -> continue
❼ annotate_block(block.id, current_reaching_copies)
列表 19-15:用于到达副本分析的传递函数
为了处理一条指令,我们首先会记录在该指令执行之前到达该点的副本集合❶。(当我们实际重写该指令时,将会用到这些信息。)然后,我们将检查该指令本身,以计算哪些副本到达指令执行后的点。在特殊情况下,当 x = y 到达 y = x 时,我们不会添加或移除任何到达副本❷。正如我们之前所看到的,y = x 不会产生任何效果,因为 x 和 y 已经具有相同的值。否则,我们将通过杀死所有指向或来自 x 的副本❸ 来处理 Copy 指令 x = y,然后将 x = y 添加到到达副本集合中❹。
当我们遇到
meet 运算符
接下来,我们将实现 meet 运算符,它将传播关于从一个块到另一个块的到达拷贝的信息。该运算符计算了我们将传递给传输函数的初始到达拷贝集合。请记住,只有当拷贝在程序中的每条路径上都出现且没有被杀死时,拷贝才能到达某一点。因此,拷贝只有在到达所有该块前驱的末尾时,才能到达该块的开头。换句话说,我们将仅仅取每个前驱的结果的集合交集。清单 19-16 给出了 meet 运算符的伪代码。
meet(block, all_copies):
❶ incoming_copies = all_copies
for pred_id in block.predecessors:
match pred_id with
❷ | ENTRY -> return {}
| BlockId(id) ->
❸ pred_out_copies = get_block_annotation(pred_id)
incoming_copies = intersection(incoming_copies, pred_out_copies)
| EXIT -> fail("Malformed control-flow graph")
return incoming_copies
清单 19-16:用于到达拷贝分析的 meet 运算符
meet 运算符接受两个参数。第一个是我们想要计算传入拷贝的块。第二个参数 all_copies 是函数中出现的所有 Copy 指令的集合。我们将传入拷贝的集合初始化为这个值 ❶,因为它是集合交集的恒等元素。也就是说,对于任何到达拷贝的集合S,与 all_copies 的交集就是S。
接下来,我们遍历块的前驱,这些前驱可能包括其他基本块、ENTRY 节点,或者两者。如果我们在前驱列表中找到了 ENTRY,我们就返回空集 ❷。(空集与任何其他集合的交集仍然是空集,所以无需再查看该块的其他前驱。)否则,我们查找到达每个前驱末端的副本集 ❸,这些副本集我们在 Listing 19-15 末尾记录过,然后将 incoming_copies 与每个副本集进行交集。
我们有一个极端情况需要考虑。如果禁用了不可达代码消除,我们正在分析的块可能没有任何前驱。对没有前驱的块调用 meet 将返回 all_copies,因此我们假设每个可能的 Copy 指令都会到达该块的起始位置。我们并不关心这最终如何影响该块本身,因为它反正不会执行。我们确实关心的是它如何影响该块的后继块,后继块可能是可达的。例如,如果一个可达块 A 和一个不可达块 B 都跳转到块 C,那么块 C 就是可达的。
幸运的是,我们的分析依然是安全的。来自 A 的真实结果和来自 B 的垃圾结果的交集将始终是实际上从 A 到 C 的副本的子集;这是一个保守的近似结果,假设我们启用了不可达代码消除并完全删除了 B。
迭代算法
一旦我们知道前面块的结果,就可以使用交集运算符和传输函数分析基本块。现在我们将把所有内容结合起来,分析整个函数。但有一个问题:控制流图可能有循环!我们无法分析一个块,直到我们分析完它的所有前驱,这要求我们分析它们的所有前驱,依此类推。一旦遇到循环,我们似乎就陷入困境了;我们无法分析循环中的任何一个块,因为每个块直接或间接地先于自己。
为了摆脱困境,我们需要某种方法来分析一个块,即使我们没有来自其所有前驱块的完整结果。解决方案是为每个块保持一个临时结果;如果我们需要在某些前驱块的结果之前分析一个块,我们可以使用那些前驱块的临时结果。最初,在我们还没有探索到某个块的任何路径之前,它的临时结果包含函数中的每一个 Copy 指令。然后,在我们探索到该块的每一条新路径时(更确切地说,是探索到该块的末端),我们会去除任何没有出现或在该路径上被“杀死”的副本。这意味着,一个块的临时结果始终告诉我们在我们目前为止探索的到达该块末端的每一条路径上,哪些到达副本出现(并且没有被杀死)。一旦我们探索了所有可能的路径,就会得到该块的最终结果。
这就是基本的思路;现在让我们付诸实践。首先,我们会用函数中所有的 Copy 指令集合对每个基本块进行注解。正如我们之前学到的,这个集合是我们 meet 操作符的单位元素。用单位元素初始化每个块可以确保我们还没有分析过的块不会改变 meet 操作符的结果。让我们在 图 19-7 中的控制流图上尝试这个方法。

图 19-7:带有循环的控制流图 描述
这个控制流图包含两个 Copy 指令:y = 3 和 y = 4。我们最初会用包含这两个副本的集合对每个块进行注解。然后,我们按顺序分析这些块。我们只需一遍就能计算出 B0 的最终结果,因为它的唯一前驱是 ENTRY。 图 19-8 展示了我们处理完 B0 后在每个块上的注解。

图 19-8:在处理 B 后,对 图 19-7 的到达副本分析的临时结果0 描述
此时,B0上的标注是正确的:只有 y = 3 到达了该块的末尾。其他两个块仍然标注着每个副本。接下来,我们将应用 meet 操作符,看看哪些副本能到达 B1 的起始位置。该块有两个前驱:B0 和它本身。因此,我们将取 {y = 3} 和 {y = 3, y = 4} 的交集,即 {y = 3}。这与如果 B0 是 B1 唯一前驱时的结果相同。这正是我们想要的行为:因为我们还没有分析 B1,它不应该对 meet 操作符的结果产生影响。一旦我们将传输函数应用于 B1,我们会发现只有 y = 4 到达了该块的末尾。然后,我们就能拥有处理 B2 所需的所有信息。图 19-9 展示了我们分析了 B1 和 B2 后每个块的标注情况。

图 19-9:分析每个基本块一次后的副本到达分析的临时结果 描述
每个块现在都有了正确的到达副本集合。但我们还没有得到 B1 中每个单独指令的正确答案。(图 19-8 和 19-9 没有显示个别指令的注释。) 当我们最后分析 B1 时,我们假设 y = 3 到达块的开始,这意味着它也到达了 x = process(y)。现在我们有了更准确的信息,我们需要重新分析 B1。这次,合并运算符将取 {y = 3} 和 {y = 4} 的交集,即空集合。我们将把这个结果传递给传递函数,以重新计算 B1 中个别指令的结果。这一次,我们将正确得出结论:没有任何 Copy 指令到达 x = process(y)(或者在 y = 4 之前块中的任何位置)。
现在我们已经看到了迭代算法的实际操作,让我们来实现它。列表 19-17 给出了该算法的伪代码。
find_reaching_copies(graph):
all_copies = find_all_copy_instructions(graph)
worklist = []
for node in graph.nodes:
if node is EntryNode or ExitNode:
continue
❶ worklist.append(node)
annotate_block(node.id, all_copies)
while worklist is not empty:
❷ block = take_first(worklist)
old_annotation = get_block_annotation(block.id)
incoming_copies = meet(block, all_copies)
transfer(block, incoming_copies)
❸ if old_annotation != get_block_annotation(block.id):
for successor_id in block.successors:
match successor_id with
| EXIT -> continue
| ENTRY -> fail("Malformed control-flow graph")
| BlockId(id) ->
successor = get_block_by_id(successor_id)
if successor is not in worklist:
worklist.append(successor)
列表 19-17:用于到达副本分析的迭代算法
我们将维护一个工作列表,包含需要处理的基本块,包括在更新其前驱之一后需要重新访问的块。在该算法的初始设置中,我们将把每个基本块添加到工作列表中 ❶,因为我们需要至少分析一次每个块。我们还将用函数中所有出现的 Copy 指令初始化每个块。
接下来,我们进入主要的处理循环,在其中我们将从工作列表的前端移除一个块 ❷,然后使用合并运算符和传递函数分析它。如果此分析更改了块的传出到达副本,我们将把所有继承者添加到工作列表中,以便使用这些新的结果重新分析它们 ❸。如果某个继承者已经在工作列表中,我们就不需要再次添加它。我们将重复此过程,直到工作列表为空。
列表 19-17 适用于任何前向数据流分析。只有传递函数、合并运算符和初始化每个基本块所使用的单位元素会有所不同。
重写 TACKY 指令
在执行到达副本分析后,我们将寻找机会重写或甚至删除 TACKY 函数中的每一条指令。为了重写一条指令,我们将检查到达它的副本是否定义了它的任何操作数。如果定义了,我们将用它们的值替换这些操作数。如果我们遇到形式为 x = y 的 Copy 指令,并且它的到达副本中包含 x = y 或 y = x,我们将删除它,而不是尝试重写它;因为如果 x 和 y 已经具有相同的值,那么该指令没有任何效果。Listing 19-18 演示了如何处理每一条指令。
replace_operand(op, reaching_copies):
if op is a constant:
return op
for copy in reaching_copies:
if copy.dst == op:
return copy.src
return op
rewrite_instruction(instr):
❶ reaching_copies = get_instruction_annotation(instr)
match instr with
| Copy(src, dst) ->
for copy in reaching_copies:
❷ if (copy == instr) or (copy.src == dst and copy.dst == src):
return null
❸ new_src = replace_operand(src, reaching_copies)
return Copy(new_src, dst)
| Unary(operator, src, dst) ->
new_src = replace_operand(src, reaching_copies)
return Unary(operator, new_src, dst)
| Binary(operator, src1, src2, dst) ->
new_src1 = replace_operand(src1, reaching_copies)
new_src2 = replace_operand(src2, reaching_copies)
return Binary(operator, new_src1, new_src2, dst)
| `--snip--`
Listing 19-18: 基于到达副本分析结果重写指令
给定到达当前指令的副本集合,replace_operand 用其值替换单个 TACKY 操作数。如果操作数是常量,或者我们无法找到分配给它的到达副本,我们将直接返回原始值。
在 rewrite_instruction 中,我们首先查找到达当前指令的副本集合 instr ❶。如果 instr 是一条 Copy 指令,我们将搜索这个集合(我们称之为 reaching_copies),寻找一个从源到目的地或反向的副本 ❷。如果找到了,instr 的操作数已经具有相同的值,因此我们可以删除它。(Listing 19-18 返回 null 来表示我们应该删除该指令;您的代码可能会以不同的方式表示这一点。)否则,我们尝试使用 replace _operand ❸ 来替换该指令的源操作数。我们将以相同的方式尝试替换其他 TACKY 指令的源操作数。Listing 19-18 演示了如何在 Unary 和 Binary 中重写源操作数;我已经省略了 Part I 中其余的 TACKY 指令,因为它们的逻辑是相同的。
到目前为止,你已经完成了一个完整的复制传播过程,该过程执行了到达复制分析,并使用结果优化了一个 TACKY 函数。如果你跳过了第二部分,可以直接进入本节的测试套件。但如果你完成了第二部分,你还有一些工作要做。
支持第二部分 TACKY 程序
为了使复制传播与我们在第二部分中生成的 TACKY 代码一起工作,我们需要解决几个问题。第一个问题是我们有时使用Copy指令进行类型转换。我们不希望在有符号和无符号类型之间传播复制,因为我们有时会为有符号和无符号值的操作生成不同的汇编代码。例如,如果我们在比较中将一个有符号值替换为无符号值,最终生成的条件码会是错误的。我们的到达复制分析会将任何有符号和无符号操作数之间的Copy视为类型转换指令,而不是普通的复制操作。我们不会将其作为到达复制添加到传递函数中,也不会在迭代算法开始时将其包含在初始到达复制集合中。
注意
另一种解决方案是为比较、余数操作和除法引入单独的有符号和无符号 TACKY 运算符,这样我们就不需要在代码生成过程中检查操作数的类型来区分这些情况。LLVM IR 采用了这种方法。
第二个问题是变量可以通过指针进行更新。这些更新很难分析。如果我们看到指令Store(v, ptr),我们不知道ptr指向哪个对象,因此也无法知道该删除哪些复制。这与我们遇到的静态变量问题类似,静态变量可能在其他函数中更新。为了解决这个问题,我们将找到所有可能通过指针访问的变量(这些变量称为别名变量)。我们将假设每个Store指令都会更新这些变量。我们还假设函数调用也会更新这些变量,因为我们可以在一个函数中声明一个变量,然后在另一个函数中通过指针更新它。让我们用这种方法来分析示例 19-19。
function_with_pointers():
x = 1
y = 2
z = 3
ptr1 = GetAddress(x)
Store(10, ptr1)
ptr2 = GetAddress(y)
z = x + y
Return(z)
示例 19-19:通过指针更新变量的 TACKY 函数
首先,我们将识别 function_with_pointers 中的别名变量。由于 x 和 y 都在 GetAddress 指令中使用,它们是别名。 (假设此列表中的变量都不是静态的,因此我们无需担心其他函数是否取用它们的地址。)接下来,我们将进行到达副本分析。由于整个函数体是一个基本块,我们可以将传递函数应用于整个函数。像往常一样,我们将 x = 1、y = 2 和 z = 3 添加到到达副本集合中。然后,当我们到达 Store 指令时,我们将删除对两个别名变量 x 和 y 的副本。表 19-2 描述了每条指令将到达哪些副本。
表 19-2: 每条指令的到达副本 Listing 19-19
| 指令 | 到达副本 |
|---|---|
| x = 1 | {} |
| y = 2 | |
| z = 3 | |
| ptr1 = GetAddress(x) | |
| Store(10, ptr1) | |
| ptr2 = GetAddress(y) | |
| z = x + y | |
| Return(z) | {} |
| 块的结尾 | {} |
我们正确地识别到 Store 指令可能会覆盖 x,这意味着我们不能在 z = x + y 中将 x 替换为 1。我们还假设 Store 指令可能会覆盖 y,因为我们的分析没有足够智能地意识到 ptr1 不可能指向 y。因此,即使将 y 替换为 2 在 z = x + y 中是安全的,我们也不会这么做。再一次,我们做出了保守的假设;我们可能会错过一些安全的优化,但绝不会应用任何不安全的优化。
实现地址获取分析
我们刚刚使用的识别别名变量的方法叫做 地址获取分析。为了执行这个分析,我们将检查 TACKY 函数中的每一条指令,并识别出每个变量,它们要么具有静态存储持续时间,要么其地址被 GetAddress 指令获取。(我们假设所有静态变量都是别名,因为它们的地址可能在其他函数中被获取。)我们将在优化管道的每次迭代中重新执行此分析,因为如果我们优化掉任何 GetAddress 指令,结果可能会发生变化。示例 19-20 展示了它如何适应我们在 示例 19-6 中定义的整体优化管道。
optimize(function_body, enabled_optimizations):
`--snip--`
while True:
**aliased_vars = address_taken_analysis(function_body)**
`--snip--`
if enabled_optimizations contains "COPY_PROP":
cfg = copy_propagation(cfg, **aliased_vars**)
if enabled_optimizations contains "DEAD_STORE_ELIM":
cfg = dead_store_elimination(cfg, **aliased_vars**)
`--snip--`
示例 19-20:将地址获取分析添加到 TACKY 优化管道中
地址获取分析只是 别名分析 的一种,也叫 指针分析,它尝试确定两个指针或变量是否可以引用同一个对象。大多数指针分析算法比地址获取分析更强大。例如,它们可以推断出在 示例 19-19 中 ptr1 永远不会指向 y。
更新传递函数
接下来,我们将扩展传递函数,以支持我们在 第二部分 中添加的新类型和指令。
列表 19-21 展示了我们新改进的传输函数。它重新生成了列表 19-15,并将支持额外类型的更改部分加粗。
transfer(block, initial_reaching_copies, **aliased_vars**):
current_reaching_copies = initial_reaching_copies
for instruction in block.instructions:
annotate_instruction(instruction, current_reaching_copies)
match instruction with
| Copy(src, dst) ->
`--snip--`
**if (get_type(src) == get_type(dst)) or (signedness(src) == signedness(dst)):**
current_reaching_copies.add(instruction)
| FunCall(fun_name, args, dst) ->
for copy in current_reaching_copies:
if (copy.src is **in aliased_vars**
or copy.dst is **in aliased_vars**
or (**dst is not null and** (copy.src == dst or copy.dst == dst))):
current_reaching_copies.remove(copy)
**| Store(src, dst_ptr) ->**
**for copy in current_reaching_copies:**
**if (copy.src is in aliased_vars) or (copy.dst is in aliased_vars):**
**current_reaching_copies.remove(copy)**
| Unary(operator, src, dst) **or any other instruction with dst field** ->
`--snip--`
| _ -> continue
annotate_block(block.id, current_reaching_copies)
列表 19-21:用于到达拷贝分析的传输函数,支持第二部分中的特性
我们已经讨论了此列表中的大多数更改。在将Copy指令添加到current_reaching_copies之前,我们将确保其源和目标具有相同类型,或者至少是具有相同符号性的类型。signedness辅助函数应将char视为有符号类型,将所有指针类型视为无符号类型,因此我们可以在char和signed char、不同指针类型之间以及指针和unsigned long之间传播拷贝。(符号性概念不适用于double或非标量类型。没关系,因为我们不会使用Copy指令来转换这些类型。如果Copy使用double或非标量操作数,则两个操作数将具有相同类型,因此我们不需要检查它们的符号性。)
当我们遇到函数调用或Store指令时,我们将删除与别名变量相关的任何拷贝操作。我们还需要考虑到函数调用可能没有目标操作数的情况。请注意,我们不会删除Store指令的dst_ptr操作数。Store不会改变目标指针本身的值,只会改变它所指向对象的值。最后,当我们遇到我们在第二部分中添加的其他指令时——包括类型转换、CopyToOffset和CopyFromOffset——我们将删除与其目标相关的任何拷贝操作。我们不会跟踪结构或数组内部单独子对象的拷贝操作,因此CopyToOffset和CopyFromOffset将删除到达拷贝,而不会生成任何新的拷贝。
更新 rewrite_instruction
我们将以与第一部分中的指令相同的方式重写第二部分中的大多数新 TACKY 指令,替换任何由传递复制定义的源操作数。唯一的例外是 GetAddress,我们永远不会重写它。对 GetAddress 应用复制传播没有意义,因为它使用的是源操作数的地址,而不是它的值。### 死存储消除
我们的最后一个优化是死存储消除。我们将使用活跃性分析,一种向后的数据流分析,来计算在我们优化的函数中的每个点哪些变量是活跃的。然后,我们将使用分析结果来识别并消除死存储。
如果一个变量在某个特定点的值可能在程序的后续部分被读取,那么它在该点是活跃的。否则,它是死的。更精确地说,当满足两个条件时,变量 x 在某个给定点 p 是活跃的。首先,必须存在至少一条从 p 到某个后续指令的路径,该路径使用了 x。我们说 x 是由任何使用它的指令生成的。其次,x 在从 p 到该后续指令的路径上不能被更新。我们说 x 是由任何更新它的指令消除的,就像当其操作数被更新时,一个传递复制会被消除一样。(在大多数数据流分析中,你会看到生成和消除这两个术语,而不仅仅是本章中提到的两个。)请参考图 19-10 中的控制流图。

图 19-10:一个控制流图,其中 x 在定义后立即变为活跃 描述
从 x = 10 到 EXIT 有两条路径。在通过 B1 的路径上,x 从未被使用。在通过 B2 的路径上,x 被用于 Return 指令。我们知道,x 在 x = 10 后是活跃的,因为它在这些路径中的一条上被生成。按照相同的逻辑,x 在 B0 的末尾、在 B2 的开头以及在 B2 中的 Return 指令前也是活跃的。另一方面,x 在 B2 的末尾以及在 B1 中的每个点都是死的,因为从这些点到任何使用 x 的指令没有路径。注意,x 在 B0 的最开始也是死的,因为在我们为其赋新值之前,我们并没有使用它(未初始化的)值。
现在让我们看一下图 19-11 中的控制流图。

图 19-11:一个具有对 x 的死存储的控制流图 描述
再次,我们有两条路径从 x = 10 到 EXIT。这两条路径都经过使用 x 的 Return 指令,但在这两条路径上,x 在 x = 10 和生成它的 Return 指令之间被杀死。这意味着 x 在 x = 10 后立即死亡。在这个控制流图中,x 仅在两个点是活跃的:在 x = f() 之后(在 B1 中),以及在 x = g() 之后(在 B2 中)。
如果一条指令赋值给一个死变量且没有其他副作用,那么它就是一条死存储。因此,x = 10 在图 19-11 中是死存储,但在图 19-10 中不是。请注意,我们关心的是变量在指令执行之后是否为死变量,而不是在执行之前。代码片段中
x = x + 1
Return(0)
x 在 x = x + 1 之前是活跃的,但之后就死亡了。由于 x 在更新后立刻死亡,这意味着这条指令是一个死存储,因此我们可以将其消除。
活跃性分析
与每个数据流分析一样,活跃性分析需要一个转移函数、一个合并运算符和一个迭代算法。由于这是一个反向流问题,转移函数将从基本块的末尾开始,逐步向前推进,而不是像我们在到达拷贝分析中那样从开始到结束。同样,合并运算符将从一个块的后继中收集信息,而不是从前驱中收集。我们还将使用一个稍微不同的迭代算法,通过控制流图将数据反向传递。让我们仔细看看这些部分。
转移函数
转移函数获取基本块结束时的活跃变量集合,并计算出每条指令之前哪些变量是活跃的。正如我们在图 19-10 和图 19-11 中看到的,一条指令会生成它读取的任何变量,并杀死它更新的任何变量。例如,要计算指令x = y * z之前的活跃变量,我们需要先获取指令执行后活跃的变量集合,再加上y和z,并移除x。如果一条指令同时读取和写入同一个变量,它会生成该变量而不是杀死它。例如,指令x = x + 1会生成x。
让我们将转移函数应用于清单 19-22 中的基本块。
x = 4
x = x + 1
y = 3 * x
Return(y)
清单 19-22:一个基本块
转移函数将从基本块的底部开始,向上处理。假设在块的末尾,即在Return指令之后,没有活跃变量。(如果函数处理静态变量,这个假设可能不成立,但我们稍后再讨论这个问题。)当我们处理Return指令时,我们会将y添加到活跃变量集合中。然后,指令y = 3 * x会杀死y并生成x。接下来的指令x = x + 1会生成x,但没有实际效果,因为x已经是活跃的。最后,指令x = 4会杀死x,使得在基本块开始时没有任何活跃变量。表 19-3 总结了清单 19-22 中每条指令之后哪些变量是活跃的。
表 19-3: 清单 19-22 中每条指令后的活跃变量
| 指令 | 活跃变量 |
|---|---|
| 块的开始 | {} |
| x = 4 | |
| x = x + 1 | |
| y = 3 * x | |
| Return(y) | {} |
静态变量使事情变得复杂,正如在到达拷贝分析时那样。我们不知道其他函数将如何与遇到的静态变量交互;它们可能会读取、更新,或者同时执行这两者。我们假设每个函数都会读取每个静态变量。这个假设是保守的,因为它防止我们忽略之前对这些变量的写操作。列表 19-23 给出了传递函数的伪代码。
transfer(block, end_live_variables, all_static_variables):
❶ current_live_variables = end_live_variables
❷ for instruction in reverse(block.instructions):
❸ annotate_instruction(instruction, current_live_variables)
match instruction with
| Binary(operator, src1, src2, dst) ->
current_live_variables.remove(dst)
if src1 is a variable:
current_live_variables.add(src1)
if src2 is a variable:
current_live_variables.add(src2)
| JumpIfZero(condition, target) ->
if condition is a variable:
current_live_variables.add(condition)
| `--snip--`
| FunCall(fun_name, args, dst) ->
current_live_variables.remove(dst)
for arg in args:
if arg is a variable:
current_live_variables.add(arg)
❹ current_live_variables.add_all(all_static_variables)
❺ annotate_block(block.id, current_live_variables)
列表 19-23:活跃性分析的传递函数
我们将从在块结束时活跃的变量集合 ❶ 开始,然后反向处理指令列表 ❷。我们用每条指令执行后活跃的变量集合对每条指令进行注释 ❸;稍后我们将使用这些注释来判断指令是否是死存储。接下来,我们计算指令执行前活跃的变量。如果它有目标变量,我们将把它杀死,然后添加每个它读取的变量。列表 19-23 包括了处理 Binary 指令的伪代码,该指令更新一个操作数并读取两个操作数,和 JumpIfZero 指令,该指令读取一个操作数但不更新任何内容。大多数其他指令的伪代码没有包括,因为它们遵循相同的模式。FunCall 是一个特例;我们将像往常一样杀死它的目标并添加它的参数,但我们还将添加所有静态变量 ❹。最后,我们将用块中第一条指令前活跃的变量对整个块进行注释 ❺。Meet 运算符将在稍后使用这些信息。
计算 all_static_variables 有几种方法。一种选择是扫描这个 TACKY 函数,在开始死存储消除过程之前寻找静态变量。另一种选择是扫描整个符号表中的静态变量,而不必担心哪些变量出现在哪些函数中。在这里添加多余的静态变量没有害处,因为它们不会改变我们最终消除的指令。
Meet 运算符
meet 运算符计算基本块结束时哪些变量是活跃的。为了找出某个块 B 结束时哪些变量是活跃的,我们将查看它的所有后继节点。如果一个变量在至少一个后继节点的起始位置是活跃的,那么它在 B 结束时也一定是活跃的,因为从 B 结束到通过该后继节点到达生成该变量的指令之间至少存在一条路径。基本上,我们将取所有后继节点起始位置的活跃变量的集合并集。
我们假设每个静态变量在 EXIT 节点是活跃的。其他函数或当前函数的其他调用可能会读取这些变量。具有自动存储持续时间的变量在 EXIT 处都是死的,因为在离开函数后,它们无法访问。清单 19-24 中的伪代码定义了 meet 运算符。
meet(block, all_static_variables):
live_vars = {}
for succ_id in block.successors:
match succ_id with
| EXIT -> live_vars.add_all(all_static_variables)
| ENTRY -> fail("Malformed control-flow graph")
| BlockId(id) ->
succ_live_vars = get_block_annotation(succ_id)
live_vars.add_all(succ_live_vars)
return live_vars
清单 19-24:生存性分析中的 meet 运算符
在到达副本分析中,我们寻找的是出现在每一条路径上的副本,因此我们使用集合交集作为 meet 运算符。在生存性分析中,我们想知道一个变量是否在从某一点出发的任何路径上被使用,因此我们改为使用集合并集。这与一种分析是前向的,另一种是后向的无关。有些前向分析使用集合并集,因为它们关心是否至少有一条路径到达某个点并具有某些属性。有些后向分析使用集合交集,因为它们关心从某个点出发的每条路径是否具有某些属性。其他一些更复杂的分析则不使用集合并集或交集,而是使用完全不同的 meet 运算符。
迭代算法
最后,我们将实现生存性分析的迭代算法。这与清单 19-17 中的迭代算法有几个不同之处。首先,当一个代码块的注解发生变化时,我们将把它的前驱节点,而不是后继节点,添加到工作列表中。其次,我们将使用不同的初始块注解。回想一下,每个块的初始注解应该是 meet 运算符的单位元素。由于我们的 meet 运算符是集合并集,因此初始注解是空集。当我们分析更多从块到程序后续点的路径时,我们会将更多的活跃变量添加到这个集合中。
我不会提供反向迭代算法的伪代码,因为它与我们之前定义的前向算法非常相似。但我会给你一些关于如何实现它的建议。首先,你可能希望在后序遍历时初始化工作列表。(回忆一下,通过执行深度优先遍历并在访问所有后继节点后访问每个节点来对图的节点进行后序排序。)这种方式使得反向算法能更快结束,就像在反向后序排序中初始化工作列表能帮助前向算法更快结束一样。这个顺序意味着,只要可能,你将在访问完所有后继节点后才访问每个块。
我的第二个建议是使你的反向迭代算法具有可重用性。在下一章中,我们将再次实现活跃性分析,这次是针对汇编程序的。meet 操作符和传递函数的细节会有所不同,但迭代算法不会改变。尝试将代码结构化,以便能够使用不同的 meet 操作符和传递函数重用相同的迭代算法;这样,你就可以在下一章中用它来分析汇编程序。
移除无效存储
在我们运行活跃性分析后,我们将找到 TACKY 函数中的所有无效存储并将其删除。如果一条指令的目标在执行时变为死值,那它就是无效存储,像下面这个例子:
x = 1
x = 2
活跃性分析会告诉我们,在执行完 x = 1 后,x 已经变为死值,这使得该指令可以安全删除。我们永远不会删除函数调用,即使它们更新了死变量,因为这些调用可能有其他副作用。我们也不会删除没有目标的指令,如 Jump 和 Label。 列表 19-25 展示了如何识别无效存储。
is_dead_store(instr):
if instr is FunCall:
return False
if instr has a dst field:
live_variables = get_instruction_annotation(instr)
if instr.dst is not in live_variables:
return True
return False
列表 19-25:识别无效存储
如果你只完成了 第一部分,你已经学到了关于无效存储消除所需的所有知识!你可以直接跳到测试套件了。否则,请继续阅读,了解我们在 第二部分 中添加的类型和指令如何处理。
支持第二部分 TACKY 程序
要更新传递函数,我们需要思考每个新指令可能生成或删除哪些活跃变量。类型转换指令,如 Truncate 和 SignExtend,是直接的。每个指令都会生成它的源操作数并删除它的目标操作数,就像我们已经处理过的 Copy 和 Unary 指令一样。AddPtr 也遵循相同的模式:它生成两个源操作数并删除它的目标。
指针和聚合类型的操作更加复杂。指针引发的基本问题和我们在分析拷贝时遇到的问题一样:当我们通过指针进行读取或写入时,我们无法判断是哪个底层对象被访问了。在不确定的情况下,我们应采取保守的方式,假设变量是活跃的。因此,通过指针读取应该生成所有别名变量,但通过指针写入不应删除任何一个变量。我们将对聚合变量采取类似的方法:读取聚合变量的部分内容将生成它,但更新它的部分内容不会使其失效。我不会提供传递函数的更新伪代码;现在我们已经覆盖了关键点,剩下的细节就交给你自己去处理。meet 操作符不会改变;特别是,静态变量在 EXIT 时仍然是活跃的,但其他别名变量不是,因为它们的生命周期在函数返回时结束。
最后,让我们更新这个优化中的最后一步,我们利用活跃性分析的结果来查找死存储并消除它们。我们永远不会消除 Store 指令,因为我们无法知道它的目的地是否已死。即使当前函数中的每一个变量都已死,Store 指令仍然可能有可见的副作用。例如,它可能会更新一个在不同函数中定义的对象,就像以下示例所示:
update_through_pointer(param):
Store(10, param)
Return(0)
在 Store 指令之后,在 update_through _pointer 中没有活跃的变量。但显然,该指令并不是死存储;它更新了一个我们分析未跟踪的对象,但该对象很可能会在程序后续中被读取。
识别死存储的常见逻辑适用于第二部分中的所有其他指令,包括Load、GetAddress、CopyToOffset和CopyFromOffset。
总结
在本章中,你实现了四个重要的编译器优化:常量折叠、不可达代码消除、复制传播和死存储消除。你学习了这些优化如何协同工作,转换程序的 TACKY 表示形式,从而生成比以前的编译器生成的更小、更快、更简洁的汇编代码。你还学习了如何构建控制流图并执行数据流分析。这些技术是许多不同优化的基础,不仅限于我们在本章中讨论的优化。如果你将来想自己实现更多 TACKY 优化,你将做好充分的准备。
在下一章中,你将编写一个寄存器分配器。你将使用图着色算法将伪寄存器映射到硬件寄存器,并学习如何在图着色失败且寄存器不足时溢出寄存器。你还将使用一种叫做寄存器合并的技术,清除汇编代码中许多不必要的mov指令。到本章结束时,你的汇编程序仍然不像生产编译器生成的那样,但会接近得多。
附加资源
本节列出了我在写这一章时参考的资源,按主题组织。
编译器优化的安全隐患
-
Zhaomo Yang 等人的《死存储消除(仍然)被认为有害》调查了程序员尝试避免不必要的死存储消除的不同方法,以及每种方法的局限性 (
<wbr>www<wbr>.usenix<wbr>.org<wbr>/system<wbr>/files<wbr>/conference<wbr>/usenixsecurity17<wbr>/sec17<wbr>-yang<wbr>.pdf). -
Vijay D’Silva、Mathias Payer 和 Dawn Song 的《编译器优化中的正确性-安全性差距》探讨了几种不同编译器优化对安全性的影响,并对优化应保持的安全性特性进行了形式化定义 (
<wbr>ieeexplore<wbr>.ieee<wbr>.org<wbr>/stamp<wbr>/stamp<wbr>.jsp<wbr>?tp<wbr>=&arnumber<wbr>=7163211).
数据流分析
-
《编译原理:原理、技术与工具》第 2 版,第九章,Alfred V. Aho 等人(Addison-Wesley,2006)对数据流分析进行了比我在这里更严谨的定义。它还证明了迭代算法是正确的,并在合理的时间内终止,并讨论了反向后序遍历(它称之为深度优先排序)在此算法中的应用。
-
Paul Hilfinger 在 UC Berkeley 的 CS164 课程讲座幻灯片提供了关于相同材料的实例丰富的概述 (
inst.eecs.berkeley.edu/~cs164/sp11/lectures/lecture37-2x2.pdf)。我发现这些幻灯片中关于活跃性分析的解释特别有帮助。 -
Eli Bendersky 的博客文章《有向图遍历、排序与数据流分析的应用》描述了如何通过后序和反向后序排序图形,以加速数据流分析 (
eli.thegreenplace.net/2015/directed-graph-traversal-orderings-and-applications-to-data-flow-analysis).
复制传播
-
每次讨论到达副本分析时,似乎都用稍微不同的方式进行表述。本章中的版本借鉴了 Jeffrey Ullman 在《编译原理:原理、技术与工具》中的讲义 (
infolab.stanford.edu/~ullman/dragon/slides3.pdf和infolab.stanford.edu/~ullman/dragon/slides4.pdf). -
我借用了 LLVM 低级复制传播阶段删除冗余副本的想法 (
llvm.org/doxygen/MachineCopyPropagation_8cpp_source.html).
别名分析
- 你可以在 Phillip Gibbons 的讲座幻灯片中找到关于别名分析算法的简要概述,这些幻灯片来自他在卡内基梅隆大学的编译器优化课程 (
www.cs.cmu.edu/afs/cs/academic/class/15745-s16/www/lectures/L16-Pointer-Analysis.pdf).

描述
第二十章:20 寄存器分配

到目前为止,你已经为每个伪寄存器在堆栈上分配了空间。这种策略很简单,但效率低下。由于指令无法总是直接操作内存中的值,因此有时你需要生成额外的指令来在这些堆栈位置和寄存器之间复制值。更糟糕的是,你生成的汇编代码必须不断访问内存,尽管寄存器的访问速度更快。现在你将解决这些问题。你将通过在本章开头的图示中添加一个 寄存器分配 过程,来完成你的编译器,将伪寄存器分配给硬寄存器,而不是分配给内存中的位置。你将使用图着色技术,这是一种经典的寄存器分配方法,来完成这一分配。
一旦寄存器分配器的初始版本启动并运行,你将给它安排另一个任务:清理在汇编生成过程中产生的一些不必要的 mov 指令。最终版本的寄存器分配器将在分配伪寄存器到硬寄存器之前执行 寄存器合并。寄存器合并步骤将查找那些源和目标可以合并成一个操作数的 mov 指令,这样就可以删除该指令。
寄存器分配涉及很多内容:高层次的理论、低层次的细节、全新的概念,以及来自前几章的熟悉技术。而且结果非常令人满意:在本章结束时,你将生成显著更高效的代码。我认为这是结束本书的一个好时机。
为了开始,我们来看一个例子,说明为什么寄存器分配是如此强大的优化手段。
寄存器分配实践
请查看 Listing 20-1 中的简单 C 函数。
int f(int x, int y) {
return 10 - (3 * y + x);
}
Listing 20-1:一个简单的 C 函数
首先,我们的编译器将把它转化为 Listing 20-2 中的简单 TACKY 函数。
f(x, y):
tmp0 = 3 * y
tmp1 = tmp0 + x
tmp2 = 10 - tmp1
Return(tmp2)
Listing 20-2:针对 Listing 20-1 的 TACKY 代码
本清单给出了优化阶段后 f 的定义。(特别是,我们已经优化掉了每个 TACKY 函数末尾的额外 Return(0),这作为缺失 return 语句的备用)
接下来,我们将把清单 20-2 转换为清单 20-3 中的汇编代码。
f:
❶ movl %edi, %x
movl %esi, %y
❷ movl $3, %tmp0
imull %y, %tmp0
movl %tmp0, %tmp1
addl %x, %tmp1
movl $10, %tmp2
subl %tmp1, %tmp2
❸ movl %tmp2, %eax
ret
清单 20-3: 清单 20-2 的汇编代码
我们设置函数的参数 ❶,然后计算 tmp0、tmp1 和 tmp2 ❷。最后,我们返回 tmp2 ❸。此清单中的操作数 %x、%y、%tmp0、%tmp1 和 %tmp2 指的是相应的伪寄存器;我将在整个章节中使用此表示法。
现在我们将通过三种方法来将这些伪寄存器替换为真实操作数。首先,我们将它们替换为栈地址,这正是我们当前编译器所做的。在下一个尝试中,我们将它们替换为硬件寄存器,而不先进行寄存器合并;这是我们寄存器分配器初始版本将执行的操作。第三次,我们将在替换伪寄存器为硬件寄存器之前执行寄存器合并。这就是我们完成的分配器将如何处理该程序。(关于术语的小提示:在本章中,我将使用寄存器一词来指代伪寄存器和硬件寄存器的统称。)
方法一:将所有内容放入栈中
在当前形式下,我们的编译器会根据表 20-1 将每个伪寄存器替换为栈槽。
表 20-1: 用栈地址替换伪寄存器
| 伪寄存器 | 真实位置 |
|---|---|
| x | -4(%rbp) |
| y | -8(%rbp) |
| tmp0 | -12(%rbp) |
| tmp1 | -16(%rbp) |
| tmp2 | -20(%rbp) |
这将给我们清单 20-4 中的汇编代码。
f:
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl $3, -12(%rbp)
❶ imull -8(%rbp), -12(%rbp)
❷ movl -12(%rbp), -16(%rbp)
❸ addl -4(%rbp), -16(%rbp)
movl $10, -20(%rbp)
❹ subl -16(%rbp), -20(%rbp)
movl -20(%rbp), %eax
ret
清单 20-4:将伪寄存器替换为栈地址后的清单 20-3
一旦我们将每个伪寄存器替换为内存地址,指令 ❶、❷、❸ 和 ❹ 将无效,因此指令修正过程需要修复它们。它会在❶之前插入一条额外的指令,将目标加载到硬寄存器中,并在之后插入另一条指令将结果存储回< sampa class="SANS_TheSansMonoCd_W5Regular_11">-12(%rbp)。它还会插入指令,将❷、❸ 和 ❹ 的源操作数加载到硬寄存器中。在寄存器分配的上下文中,我们说一个伪寄存器如果将其内容存储在栈上而不是硬寄存器中,它就被溢出到内存。我们插入的额外指令用于在寄存器和内存之间移动溢出的值,这些指令被称为溢出代码。
最终,我们将得到清单 20-5 中的汇编代码。我已经将溢出代码加粗,以便更容易找到。(我还删去了设置和拆卸栈帧的指令,这些与此无关。这些指令在本章后面的汇编程序中也会被删去。)
f:
`--snip--`
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl $3, -12(%rbp)
**movl -12(%rbp), %r11d**
imull -8(%rbp), %r11d
**movl %r11d, -12(%rbp)**
**movl -12(%rbp), %r10d**
movl %r10d, -16(%rbp)
**movl -4(%rbp), %r10d**
addl %r10d, -16(%rbp)
movl $10, -20(%rbp)
**movl -16(%rbp), %r10d**
subl %r10d, -20(%rbp)
movl -20(%rbp), %eax
`--snip--`
ret
清单 20-5:清单 20-4 包含溢出代码
这段代码非常低效。几乎每条指令都访问内存,我们浪费了大量时间将数据从一个地方复制到另一个地方。举一个特别明显的例子,我们将< sampa class="SANS_TheSansMonoCd_W5Regular_11">3 * y的结果存储在< sampa class="SANS_TheSansMonoCd_W5Regular_11">-12(%rbp),然后立即将其复制到< sampa class="SANS_TheSansMonoCd_W5Regular_11">-16(%rbp)—这需要两条< sampa class="SANS_TheSansMonoCd_W5Regular_11">mov指令—之后再也不使用< sampa class="SANS_TheSansMonoCd_W5Regular_11">-12(%rbp)了。
第二次尝试:寄存器分配
让我们尝试一个更合理的策略。这次,我们将每个伪寄存器替换为硬寄存器,而不是栈地址,如表 20-2 所示。
表 20-2: 用硬寄存器替换伪寄存器
| 伪寄存器 | 真实位置 |
|---|---|
| x | %edx |
| y | %ecx |
| tmp0 | %r8d |
| tmp1 | %r9d |
| tmp2 | %edi |
我们将把 x、y、tmp0 和 tmp1 替换为在原始汇编程序中根本没有出现的寄存器。我们将把 tmp2 替换为 EDI,这在原始程序中是有使用的。这是可以的,因为我们只在完成其他 EDI 操作后使用 tmp2。在本章后面,我们将看到如何更系统地推理哪些伪寄存器到硬寄存器的映射是安全的,哪些可能会引发冲突。
这次没有溢出代码,因此我不会提供指令修复前后单独的清单。相反,我们直接跳到最终的汇编代码,如清单 20-6 所示。
f:
`--snip--`
movl %edi, %edx
movl %esi, %ecx
movl $3, %r8d
imull %ecx, %r8d
movl %r8d, %r9d
addl %edx, %r9d
movl $10, %edi
subl %r9d, %edi
movl %edi, %eax
`--snip--`
ret
清单 20-6:经过寄存器分配后的清单 20-3 的最终汇编代码
这是一个重要的改进;我们不再访问内存,而且总体的指令数量减少了。如果我们愿意的话,甚至可以省略设置和拆除栈帧的指令,因为我们从未使用栈。但是,我们仍然在做不必要的数据移动。例如,我们将函数参数从 EDI 和 ESI 复制到新的位置,而不是将它们保留在原地。我们还将 tmp0(现在在 %r8d 中)复制到 tmp1(现在在 %r9d 中),其实我们完全可以连续两次使用 tmp0 来进行计算。这并不是寄存器分配器的错误;问题在于我们在早期的步骤中生成了低效的 TACKY 和汇编代码。但是,如果我们仔细考虑寄存器分配的方法,就可以清理早期步骤中的问题。这就是为什么我们的寄存器分配器将增加一步:寄存器合并。
第三步:带合并的寄存器分配
我们的最后一种方法包含两个步骤。首先,我们将合并寄存器:我们会检查函数中的每一条 mov 指令,决定其操作数是否可以合并。接着,我们将像在之前的尝试中那样,把任何剩余的伪寄存器替换为硬寄存器。
让我们再看一下原始的汇编程序,来自示例 20-3。这个程序包含了四个源和目标都为寄存器的 mov 指令,下面我们将加粗显示这些寄存器:
f:
**movl %edi, %x**
**movl %esi, %y**
movl $3, %tmp0
imull %y, %tmp0
**movl %tmp0, %tmp1**
addl %x, %tmp1
movl $10, %tmp2
subl %tmp1, %tmp2
**movl %tmp2, %eax**
ret
有时,从一个寄存器复制值到另一个寄存器确实是必要的。例如,如果我们后来要将另一个函数参数传递到 EDI 寄存器,我们可能需要将 x 从 EDI 中复制出来。但在这个案例中,将 x 合并到 EDI 中是安全的,因为在第一次 mov 指令之后,我们不再需要 EDI 寄存器。相同的逻辑适用于其他三个 mov 指令。我们不需要在源操作数和目标操作数中同时存储不同的值,因此将它们合并是安全的。我们将 x 合并到 EDI 中,y 合并到 ESI 中,tmp1 合并到 tmp0 中,tmp2 合并到 EAX 中。
表 20-3 总结了我们将合并的寄存器对,并显示了每对寄存器中将保留的成员。
表 20-3: 寄存器合并
| 合并后的寄存器对 | 剩余寄存器 |
|---|---|
| %edi, %x | %edi |
| %esi, %y | %esi |
| %tmp0, %tmp1 | %tmp0 |
| %tmp2, %eax | %eax |
我们还将删除所有四条 mov 指令,因为它们已经没有任何作用。示例 20-7 给出了合并后的汇编代码,并加粗显示了更新后的操作数。
f:
movl $3, %tmp0
imull **%esi**, %tmp0
addl **%edi**, **%tmp0**
movl $10, **%eax**
subl **%tmp0**, **%eax**
ret
示例 20-7: 示例 20-3 合并寄存器后的代码
这样看起来更合理了!我们将 x 和 y 保持在 EDI 和 ESI 中,正如它们最初传递到这些寄存器一样,而不是将它们复制到新位置。当我们计算返回值时,我们直接将结果存储在 EAX 中,而不是在计算后再将它复制到 EAX 中。我们也不再使用两个独立的临时寄存器来计算 3 * y + x 的中间结果和最终结果;我们始终使用 tmp0。
我们还没有完全完成;我们仍然需要将 tmp0 替换为一个硬寄存器。除了 ESI、EDI 或 EAX,任何寄存器都可以——我们选用 ECX。清单 20-8 显示了我们最终会得到的汇编代码。
f:
`--snip--`
movl $3, %ecx
imull %esi, %ecx
addl %edi, %ecx
movl $10, %eax
subl %ecx, %eax
`--snip--`
ret
清单 20-8:应用寄存器合并后,清单 20-3 的最终汇编代码
不进行寄存器合并的寄存器分配在两个方面改进了我们的代码:它减少了内存访问次数和程序中的溢出代码量。通过寄存器合并,我们通过去除之前步骤生成的许多不必要的 mov 指令,进一步优化了代码。
现在我们对要实现的目标有所了解,让我们来看看寄存器分配如何融入整个编译器流水线。
更新编译器流水线
寄存器分配器在有大量硬寄存器可用时效果最佳,因此我们要做的第一件事是将所有剩余的硬寄存器添加到汇编抽象语法树(AST)中,包括我们至今为止避免使用的调用者保存寄存器。我们还将对从 TACKY 转换到汇编的过程做一个小改动:在这一过程中,我们将把额外的信息存储在后端符号表中,记录每个函数用于传递参数和返回值的硬寄存器。
接下来,我们将实现寄存器分配器本身。寄存器分配器将在我们将程序从 TACKY 转换为汇编后运行,在其他后端编译器步骤之前。就像我们在第十九章中实现的优化一样,这一步将独立处理每个汇编函数。
即使在寄存器分配之后,程序中可能仍然存在一些伪寄存器。这可能是由于几个原因。首先,如果一个函数同时使用了大量伪寄存器,可能没有足够的硬寄存器来容纳它们。发生这种情况时,我们将不得不将一些伪寄存器溢出到内存中。我们的寄存器分配器不会替换已溢出的伪寄存器;它只会将它们保留在程序中,供下一轮处理。其次,一些伪寄存器代表具有静态存储持续时间的变量。这些变量必须存放在内存中,而不是寄存器中。如果你完成了第二部分,你还会遇到一些必须存放在内存中的对象,包括别名变量、结构体和数组。寄存器分配器也不会修改这些。
为了处理所有这些滞留的操作数,我们将在寄存器分配器之后立即运行旧的伪操作数替换过程。我们不会对这个过程做任何改动。它会以与之前完全相同的方式处理它找到的任何伪操作数;只不过它找到的数量会少很多。
接下来,我们将更新指令修正过程,处理保存和恢复被调用者保存的寄存器。我们现有的所有重写规则——包括生成溢出代码的规则——将保持不变。由于我们仍然会将一些伪寄存器替换为内存中的位置,我们仍然需要在某些情况下生成溢出代码。
最后,我们将扩展代码生成阶段,以支持本章引入的新硬寄存器。此时,你可能想要简单地先跳过新的寄存器分配阶段。然后,我们将最后一次更新汇编 AST。
扩展汇编 AST
到目前为止,汇编 AST 只包含我们为特定目的使用的寄存器,如传递参数或重写指令。它不包括任何被调用者保存的寄存器:RBX、R12、R13、R14 和 R15。现在,我们将添加这五个寄存器,以便寄存器分配器可以使用它们。我们还将添加 pop 指令,用于在函数结束时恢复被调用者保存的寄存器。如果你完成了第二部分,你还应该添加其余的 XMM 寄存器,即 XMM8 到 XMM13。这些寄存器不是被调用者保存的寄存器。
清单 20-9 显示了完整的汇编 AST,包括我们在第一部分、第二部分和第三部分中覆盖的所有内容,本章的新增内容已加粗显示。
program = Program(top_level*)
assembly_type = Byte | Longword | Quadword | Double | ByteArray(int size, int alignment)
top_level = Function(identifier name, bool global, instruction* instructions)
| StaticVariable(identifier name, bool global, int alignment, static_init* init_list)
| StaticConstant(identifier name, int alignment, static_init init)
instruction = Mov(assembly_type, operand src, operand dst)
| Movsx(assembly_type src_type, assembly_type dst_type, operand src, operand dst)
| MovZeroExtend(assembly_type src_type, assembly_type dst_type,
operand src, operand dst)
| Lea(operand src, operand dst)
| Cvttsd2si(assembly_type dst_type, operand src, operand dst)
| Cvtsi2sd(assembly_type src_type, operand src, operand dst)
| Unary(unary_operator, assembly_type, operand)
| Binary(binary_operator, assembly_type, operand, operand)
| Cmp(assembly_type, operand, operand)
| Idiv(assembly_type, operand)
| Div(assembly_type, operand)
| Cdq(assembly_type)
| Jmp(identifier)
| JmpCC(cond_code, identifier)
| SetCC(cond_code, operand)
| Label(identifier)
| Push(operand)
**| Pop(reg)**
| Call(identifier)
| Ret
unary_operator = Neg | Not | Shr
binary_operator = Add | Sub | Mult | DivDouble | And | Or | Xor | Shl | ShrTwoOp
operand = Imm(int) | Reg(reg) | Pseudo(identifier) | Memory(reg, int) | Data(identifier, int)
| PseudoMem(identifier, int) | Indexed(reg base, reg index, int scale)
cond_code = E | NE | G | GE | L | LE | A | AE | B | BE
reg = AX **| BX** | CX | DX | DI | SI | R8 | R9 | R10 | R11 **| R12 | R13 | R14 | R15** | SP | BP
| XMM0 | XMM1 | XMM2 | XMM3 | XMM4 | XMM5 | XMM6 | XMM7
**| XMM8 | XMM9 | XMM10 | XMM11 | XMM12 | XMM13** | XMM14 | XMM15
清单 20-9:包含 pop 指令和额外寄存器的完整汇编 AST
请注意,pop 仅接受寄存器,而不是其他操作数。现在我们已经更新了抽象语法树(AST),接下来让我们继续从 TACKY 到汇编的转换。
从 TACKY 到汇编的转换
我们对这个过程只做一个修改。我们不会改变生成的汇编内容;我们只是会在后端符号表中记录额外的信息。具体来说,我们将记录哪些寄存器用于传递每个函数的参数。如您将在下一节中看到的,寄存器分配器需要这些信息来确定哪些硬寄存器和伪寄存器存在冲突。
假设我们有以下的函数声明:
int foo(int i, int j);
我们将记录foo的参数是通过前两个参数传递寄存器 RDI 和 RSI 传递的。即使foo在不同的翻译单元中定义,我们也会跟踪这些信息,因为我们需要它来为调用foo的函数分配寄存器。
如果您完成了第二部分,那么您应该也跟踪哪些寄存器被用来传递每个函数的返回值。考虑到以下函数声明:
double foo(int i, double d);
我们将记录foo的参数通过 RDI 和 XMM0 传递,并且它的返回值也通过 XMM0 传递。为了找出一个函数使用哪些寄存器来传递参数和返回值,我们将使用在第十八章中实现的classify_parameters和classify_return_value辅助函数中实现的相同逻辑。请注意,我们可能会遇到具有不完整返回类型或参数类型的函数声明。无论我们记录什么关于这些函数的信息都没有关系,因为在当前翻译单元中定义或调用它们都是非法的;我们只需要在不崩溃的情况下处理它们。最简单的做法是直接记录它们没有通过寄存器传递任何值。
接下来,我们将构建寄存器分配器本身。
通过图着色进行寄存器分配
我们的编译器将把寄存器分配建模为一个图着色问题。着色一个图意味着为每个节点分配一个标签(传统上称为“颜色”),使得每个节点与其所有邻居的颜色都不同。如果一个图可以用k种或更少的颜色进行着色,那么它就是k-可着色的。图 20-1 展示了一个 3 色图。

图 20-1:一个 3 色图 描述
图着色本身就是一个重要的研究领域;自 19 世纪以来,数学家们一直在研究如何为图着色!它与寄存器分配相关,因为它捕捉了我们在将伪寄存器分配到硬件寄存器时的两个基本约束:我们有有限数量的硬件寄存器可以使用,并且一些寄存器之间会相互干扰,这意味着它们不能占用相同的物理位置。如果两个伪寄存器发生干扰,我们需要将它们分配到两个不同的硬件寄存器上。一个伪寄存器也可能与硬件寄存器发生干扰,这意味着我们不能将它分配到那个硬件寄存器上。
图着色使我们能够表达两种类型的干扰,并以统一的方式处理它们。为了将寄存器分配问题转化为图着色问题,我们首先将构建一个寄存器干扰图,其中节点表示伪寄存器和硬件寄存器,边表示任意互相干扰的寄存器之间的关系。然后,我们将对图进行着色,为每个硬件寄存器分配一种颜色。最后,我们根据颜色将每个伪寄存器分配给一个硬件寄存器。因为每个寄存器的颜色都与它的邻居不同,所以我们永远不会将互相干扰的两个伪寄存器分配到同一个硬件寄存器,也不会将一个伪寄存器分配给它与之干扰的硬件寄存器。
让我们在清单 20-10 中的汇编函数上试用这个技巧。
divide_and_subtract:
❶ movl %edi, %a
❷ movl %esi, %b
movl %a, %eax
❸ cdq
idivl %b
❹ movl %eax, %tmp
subl %b, %tmp
movl %tmp, %eax
ret
清单 20-10:一个小型汇编函数
这个函数接受两个参数,将它们复制到伪寄存器 a 和 b 中。它计算 a / b - b 并将结果存储在 tmp 中。最后,它将 tmp 的值返回在 EAX 中。我们需要找出这个函数中哪些寄存器会发生冲突,以便构建冲突图。首先,很容易看出 a 和 b 会发生冲突。如果将它们映射到同一个硬件寄存器,当我们定义 b ❷ 时,a 会被覆盖。这是一个问题,因为 a 在此时仍然是活跃的。你在 第十九章 中学到,变量是活跃的,如果它的当前值可能会在程序中后续使用,否则它就是死的。这一定义同样适用于寄存器。当一个寄存器是活跃的时,我们需要保留它的值,因此不能在同一位置存储不同的值。当它是死的时,我们可以随意覆盖它的值。这给了我们一个简单的规则来检测冲突:两个寄存器如果在一个寄存器活跃时更新另一个寄存器,就会发生冲突。
这个规则还告诉我们,b 与 tmp 存在冲突,因为在我们定义 tmp ❹ 时,b 仍然是活跃的。但 a 和 tmp 不会发生冲突;因为在我们定义 tmp 时,a 已经是死的,所以可以将它们映射到同一个硬件寄存器。
现在让我们思考一下哪些硬寄存器会与伪寄存器干扰。ESI 与 a 干扰,因为当我们定义 a 时 ESI 是活跃的 ❶。如果我们将 a 映射到 ESI,我们将在有机会将其复制到 b 之前覆盖函数的第二个参数。EAX 与 b 干扰,因为当我们将 a 复制到 EAX 准备进行除法时,b 是活跃的。最后一个干扰源则不太明显。请记住,cdq 指令会将 EAX 中的值符号扩展到 EDX ❸。因为 cdq 在 b 活跃时隐式更新 EDX,它使得 EDX 与 b 干扰;如果我们将 b 映射到 EDX,这条指令将覆盖它。(同样,idiv 指令隐式更新 EAX 和 EDX,因此如果它们尚未干扰 b,它们也会使这两个寄存器与 b 干扰。)
最后,所有硬寄存器都会相互干扰。这有点自明;它们不能占据相同的物理位置,因为它们一开始就代表不同的物理位置。不过,我们需要在干扰图中捕捉这一点,以确保每个硬寄存器都有自己的颜色。
现在我们已经弄清楚了哪些寄存器相互干扰,接下来我们将构建干扰图。为了保持图的相对简洁和可读性,我们假设唯一的硬寄存器是 EDI、ESI、EAX 和 EDX。我们真实的寄存器干扰图将包括所有可以分配伪寄存器的硬寄存器,即使汇编程序没有使用它们。然而,它们将排除 RSP、RBP 以及我们在指令修正期间使用的临时寄存器。
图 20-2 展示了 清单 20-10 中函数的干扰图。

图 20-2:divide_and_subtract 的寄存器干扰图 描述
这个图表示了我们刚刚识别出的所有干扰:a与 ESI 和b产生干扰;b与tmp、EAX 和 EDX 产生干扰,并且与a产生干扰;所有四个硬寄存器之间都会相互干扰。
现在我们将尝试对这个图进行k着色,其中k是图中硬寄存器的数量。在这个小示例中,k是 4。图 20-2 中有几种可能的 4 种着色。图 20-3 展示了其中一些。

图 20-3:为 divide_and_subtract 的寄存器干扰图提供的三种有效 4 种着色描述
这些着色中的任何一种,或者我们能想到的任何其他 4 种着色,都会给我们一个有效的寄存器分配。每个寄存器都会从它的邻居那里接收到不同的颜色。因为所有的k个硬寄存器相互之间都会产生干扰,所以我们会将每种颜色分配给恰好一个硬寄存器,从而创建一个颜色与硬寄存器之间的 1:1 映射。
在我们给图着色之后,我们将用接收到相同颜色的硬寄存器替换每个伪寄存器。如果我们使用图 20-3 中的第一个着色,我们将把a替换为 EDX,b替换为 EDI,tmp替换为 EAX,这将给我们清单 20-11 中的汇编代码。
divide_and_subtract:
movl %edi, %edx
movl %esi, %edi
movl %edx, %eax
cdq
idivl %edi
movl %eax, %eax
subl %edi, %eax
movl %eax, %eax
ret
清单 20-11:根据图 20-3 中的第一个着色替换 divide_and_subtract 中的寄存器
如果你愿意,你可以通过这个列表进行操作,确认它产生的结果与清单 20-10 中divide_and_subtract的原始代码是相同的。你还可以尝试图 20-3 中的其他着色。
注意,给图形上色会产生两种不同的映射:一种是从所有寄存器到颜色,另一种是从颜色到硬寄存器。概念上,每种颜色代表一个硬寄存器,但我们在为图形着色之前并不知道哪一个硬寄存器。如果我们将硬寄存器的名字本身用作颜色,或者提前将每个硬寄存器与颜色关联,那么我们必须在尝试为其余图形着色之前对每个硬寄存器进行预着色。预着色会给图着色问题添加更多约束,导致更难找到有效的着色方案。一些图着色实现要求预着色节点;幸运的是,我们的实现不需要。
干扰检测
之前,我说过只有在一个寄存器活跃时更新另一个寄存器,它们才会发生干扰。我们利用这个规则来确定在 divide _and_subtract 中哪些寄存器发生了干扰;它帮助我们识别了伪寄存器之间的干扰,以及伪寄存器与硬寄存器之间的干扰。但关于这个规则,还有一些重要的内容需要补充说明。
第一点是,只有在更新了一个寄存器后,另一个寄存器立即活跃时,它们才会发生干扰。这里有一个简短的例子:
movl 4(%rdi), %x
movl %x, %eax
ret
在这段代码片段中,RDI 存储着某个内存地址——假设是结构体或数组的地址。该片段中的第一条指令从 RDI + 4 所指向的内存位置获取值,并将其复制到 x 中。在此指令之前,RDI 是活跃的;之后,它变为不活跃。RDI 和 x 不会发生干扰。如果我们将 x 映射到 RDI,那么第一条 mov 指令将覆盖 RDI 中的地址。但这没关系,因为我们之后不会再使用这个地址。
第二点是,只有当两个寄存器的值不同,它们才会发生干扰。具体来说,这意味着 mov src, dst 指令即使在 src 之后仍然活跃,也不会导致 src 和 dst 发生干扰。例如,在 Listing 20-12 中,x 和 y 不会发生干扰。
movl $1, %y
movl %y, %x
addl %x, %ecx
addl %y, %eax
Listing 20-12:一段汇编代码,展示了在该指令之后源操作数仍然活跃的情况
如果我们将 x 和 y 分配到相同的硬件寄存器,那么第二条 mov 指令就不会覆盖 x 的新值,而是完全没有效果。当我们进行寄存器合并时,我们甚至会特意将 x 和 y 放入相同的寄存器,以便我们可以完全删除这条指令。
两个寄存器通过一个 mov 指令连接,但仍然可能由于其他原因发生干扰,正如 清单 20-13 所展示的那样。
movl $1, %y
movl %y, %x
addl $1, %y
addl %x, %y
清单 20-13:一段汇编代码,其中后续指令使得 mov 指令的操作数发生干扰
在这个代码片段中,第二条 mov 指令没有造成 x 和 y 的干扰,但随后的 add 指令会,因为它在 x 仍然有效时更新了 y。在这种情况下,将 x 和 y 放入同一个寄存器是不安全的。
溢出寄存器
我们不能总是 k 着色干扰图。考虑 清单 20-14 中的汇编函数,它计算 10 / arg1 + arg1 / arg2。
uncolorable:
movl %edi, %arg1
movl %esi, %arg2
movl $10, %eax
cdq
idivl %arg1
movl %eax, %tmp
movl %arg1, %eax
cdq
idivl %arg2
addl %tmp, %eax
ret
清单 20-14:计算 10 / arg1 + arg1 / arg2
为了说明这个例子,我们假设有四个硬件寄存器:ESI、EDI、EDX 和 EAX。图 20-4 显示了该清单的干扰图。我不会一一说明如何构建这个图,但如果你愿意,可以自己验证。

图 20-4:干扰图,来自 清单 20-14 描述
这个图无法进行 4 着色。注意到 EAX、EDX、arg1、arg2 和 tmp 彼此之间都存在干扰。这意味着它们每一个都必须与其他四个寄存器分配不同的颜色,这将需要五种不同的颜色。我们将通过溢出一个寄存器来解决这个问题——换句话说,就是将其从图中移除,而不是着色。溢出 arg1、arg2 或 tmp 中的任意一个,都会使得图可以着色。如果我们溢出 tmp,例如,我们可以使用图 20-5 所示的着色方式。

图 20-5:在溢出 tmp 后为干扰图着色 描述
现在我们可以替换掉我们之前标记的两个伪寄存器,但不会替换tmp。清单 20-15 展示了替换后的汇编代码,来自清单 20-14 的更改已加粗。
uncolorable:
movl %edi, **%edi**
movl %esi, **%esi**
movl $10, %eax
cdq
idivl **%edi**
movl %eax, %tmp
movl **%edi**, %eax
cdq
idivl **%esi**
addl %tmp, %eax
ret
清单 20-15:寄存器分配后的 无法着色 函数
请注意,我们已经将此清单中的前两条指令做了冗余处理。我们本可以通过删除它们进一步优化代码,但暂时不考虑这个优化。
在寄存器分配器将 arg1 和 arg2 分配到硬寄存器后,伪操作数替换过程将把 tmp 放到栈上。清单 20-16 展示了该函数的最终汇编代码,其中来自清单 20-15 的更改已加粗。
uncolorable:
`--snip--`
movl %edi, %edi
movl %esi, %esi
movl $10, %eax
cdq
idivl %edi
movl %eax, **-4(%rbp)**
movl %edi, %eax
cdq
idivl %esi
addl **-4(%rbp)**, %eax
`--snip--`
ret
清单 20-16:最终的汇编代码,适用于 无法着色
溢出一个伪寄存器使得我们能够用硬寄存器替换掉其他所有寄存器。但我们决定溢出哪个寄存器非常重要!通常,伪寄存器的访问频率越高,溢出该寄存器对性能的影响就越大。我们的分配器会为干扰图中的每个寄存器计算一个溢出成本。这是估算溢出该寄存器对性能的影响程度。然后,当我们对图进行着色时,会使用这些信息来最小化溢出的整体性能影响。
基本寄存器分配器
现在我们对寄存器分配器的工作方式有了一些了解,接下来让我们实现它吧!清单 20-17 描述了如何为单个函数分配寄存器。
allocate_registers(instructions):
interference_graph = build_graph(instructions)
add_spill_costs(interference_graph, instructions)
color_graph(interference_graph)
register_map = create_register_map(interference_graph)
transformed_instructions = replace_pseudoregs(instructions, register_map)
return transformed_instructions
清单 20-17:顶层寄存器分配算法
我们首先构建干扰图。然后,我们根据每个寄存器的使用频率计算溢出代价,并在图中注释这些信息。接下来,我们对图进行着色,为每个节点注释它的颜色。如果我们不能为每个节点着色,我们将使用在前一步计算的溢出代价来决定溢出哪个寄存器。要溢出一个节点,我们只需将其保持为无色。
最后一步是替换我们标记的所有伪寄存器。在 create _register_map 中,我们构建了一个从有颜色的伪寄存器到相同颜色的硬寄存器的映射。最后,我们重写函数体,将每个伪寄存器替换为映射中相应的硬寄存器。
继续并创建 allocate_registers 的存根。然后,我们将一步步实现这些步骤。
处理寄存器分配中的多种类型
如果你完成了第二部分,你将需要运行整个算法两次:清单 20-17 一次分配通用寄存器,一次分配 XMM 寄存器。在每次运行时,你只会在干扰图中包含适当类型的寄存器。我们在第二部分中添加的新功能也会改变构建干扰图的一些细节。当我们实现 build_graph 时,我们将更详细地查看这些变化。
在构建干扰图后的单独步骤——包括计算溢出代价、着色图以及替换伪寄存器——无论我们处理的是浮点寄存器还是通用寄存器,步骤都完全相同。第二部分中添加的其他功能也不会影响这些步骤。
定义干扰图
为了开始,我们将定义干扰图的数据结构。清单 20-18 展示了一种可能的表示方式。
node = Node(operand id, operand* neighbors, double spill_cost, int? color, bool pruned)
graph = Graph(node* nodes)
清单 20-18:寄存器干扰图的定义
图中的每个节点对应于汇编 AST 中的一个Pseudo或Register操作数。我们将跟踪每个节点的邻居、溢出代价和颜色。我们将使用整数1到k来表示颜色,其中k是可用硬件寄存器的数量。color字段是可选的,因为我们可能无法为每个节点上色。我们将在上色图时使用pruned标志;你可以在那之前忽略它。创建新节点时,应该将spill_cost初始化为0.0,将color初始化为null,并将pruned初始化为False。
这个node的定义比实际需要的要宽松一些;operand类型包括常量和内存位置,而我们永远不会将它们添加到干扰图中。或者,你可以用一个专门的node_id类型来替代operand,该类型只表示寄存器。我使用更宽松的定义,以便我们不必在整个分配器中不断地在operand和node_id之间来回转换。
构建干扰图
我们终于准备好构建干扰图了!首先,我们将通过实现一个支持来自第一部分的汇编 AST 的build_graph来演示。然后,我们将讨论如何修改它以支持来自第二部分的汇编 AST。
由于构建这个图是一个相当复杂的过程,我们将它分解为几个步骤。清单 20-19 在伪代码中展示了这些步骤。
build_graph(instructions):
❶ interference_graph = base_graph
❷ add_pseudoregisters(interference_graph, instructions)
cfg = make_control_flow_graph(instructions)
❸ analyze_liveness(cfg)
❹ add_edges(cfg, interference_graph)
return interference_graph
清单 20-19:构建干扰图
我们将从一个包含所有硬件寄存器的图开始 ❶。(据我所知,这个图没有标准术语,所以我将它称为基础图。)接下来,我们将为函数中出现的每个伪寄存器插入一个节点 ❷。最后,我们将找出哪些寄存器相互干扰。由于这取决于每个点上哪些寄存器是活动的,我们需要在汇编代码上运行活跃性分析。就像在第十九章中一样,这项分析将采用控制流图(它与干扰图不同!)并用活跃性信息对其进行注释 ❸。最后,我们将使用这些信息来确定要向干扰图中添加哪些边 ❹。让我们仔细看看每个步骤。
基础图
基础图,如图 20-6 所示,包含 12 个寄存器:RAX、RBX、RCX、RDX、RDI、RSI、R8、R9、R12、R13、R14 和 R15。

图 20-6:基础寄存器干扰图 描述
我们不会在图中包含 RSP 或 RBP,因为我们已经在使用它们来管理堆栈帧,也不会包含 R10 或 R11,因为我们将在指令修正过程中需要它们。由于所有硬寄存器彼此之间都会发生干扰,基础图中包含了每对节点之间的边。在图论术语中,这使得基础图成为一个完全图。
注意
由于本章的示例程序处理的是 4 字节的伪寄存器,大多数干扰图的示意图使用 4 字节的硬寄存器别名。我在图 20-6 中使用了 8 字节的别名,因为它表示的是您将用于所有程序的基础图,而不是任何特定程序的干扰图。我们实际构建的干扰图将使用 reg 汇编 AST 节点来表示硬寄存器,而这些节点并不指定大小。
将伪寄存器添加到图中
这部分很简单:我们只需遍历每条指令中的每个操作数,并决定是否将其添加到图中。每个出现在汇编函数中的伪寄存器都应该加入图中,除非它具有静态存储期。我会跳过add_pseudoregisters的伪代码,因为它并没有太多内容。
活跃性分析
我们已经知道活跃性分析是如何工作的,因为我们在第十九章中实现过它。现在我们需要一个新的实现,来分析汇编代码而不是 TACKY,并跟踪寄存器而不是变量。幸运的是,基本逻辑是相同的。我们甚至可以重用一些现有的代码。
首先,我们将构建一个控制流图。这个过程与从 TACKY 函数构建控制流图一样,只不过基本块之间边界的具体指令不同。我们不会寻找像Label、Return、Jump、JumpIfZero和JumpIfNotZero这样的 TACKY 控制流指令,而是寻找它们的汇编等价指令:汇编中的Label指令、Ret、Jmp和JmpCC。你已经编写了将 TACKY 转换为控制流图的代码;理想情况下,你应该能够重构它以同时处理汇编。
接下来,我们将研究活跃度分析本身的三个组成部分:迭代算法、合并运算符和传输函数。迭代算法与第十九章中完全相同,因此你应该能够使用你已经编写的该算法的实现。
我们将使用集合并集作为我们的合并运算符,就像之前一样。然而,我们将在控制流图中的EXIT节点上进行不同的处理。我们原来的合并运算符假设在函数退出时静态变量是活跃的。现在我们不关心静态变量,因为它们不在干扰图中。相反,我们必须关心硬寄存器,特别是 EAX 寄存器,它保存函数的返回值。清单 20-20 定义了我们新的合并运算符,并在原始合并运算符(定义在清单 19-24 中)上做出了一个改动,这个改动已用粗体标出。
meet(block):
live_registers = {}
for succ_id in block.successors:
match succ_id with
| EXIT -> **live_registers.add(Reg(AX))**
| ENTRY -> fail("Malformed control-flow graph")
| BlockId(id) ->
succ_live_registers = get_block_annotation(succ_id)
live_registers.add_all(succ_live_registers)
return live_registers
清单 20-20:汇编代码的活跃度分析合并运算符
我们忽略了被调用者保存的寄存器在EXIT时也处于活跃状态这一事实。我们之所以能够这么做,是因为指令修正阶段如果使用到这些寄存器,它会将其溢出:也就是说,它会在函数开始时将它们的值保存到栈中,并在返回之前恢复它们。假设这些寄存器在EXIT时是死的,使得我们实际上可以使用它们。如果我们将它们添加到活跃寄存器集合中,我们会得出它们在整个函数中都处于活跃状态的结论,并且与每一个伪寄存器发生干扰。
传输函数是活跃分析中与上一章显著不同的部分。基本思路是一样的:当寄存器被使用时,我们将其添加到活跃集合中,而当它们被更新时,我们将其移除。但具体细节有所不同,因为我们使用的是一组与之前不同的指令。
首先,让我们编写一个辅助函数,find_used_and_updated,它告诉我们每条指令使用和更新了哪些操作数。接下来我们要实现的传输函数和 add_edges 函数都将使用这个辅助函数。清单 20-21 给出了 find_used_and_updated 的伪代码。
find_used_and_updated(instruction):
match instruction with
| Mov(src, dst) -> ❶
used = [src]
updated = [dst]
| Binary(op, src, dst) -> ❷
used = [src, dst]
updated = [dst]
| Unary(op, dst) ->
used = [dst]
updated = [dst]
| Cmp(v1, v2) ->
used = [v1, v2]
updated = []
| SetCC(cond, dst) ->
used = []
updated = [dst]
| Push(v) ->
used = [v]
updated = []
| Idiv(divisor) ->
used = [divisor, Reg(AX), Reg(DX)]
updated = [Reg(AX), Reg(DX)]
| Cdq ->
used = [Reg(AX)]
updated = [Reg(DX)]
| Call(f) ->
used = `<look up parameter passing registers in the backend symbol table>` ❸
updated = [Reg(DI), Reg(SI), Reg(DX), Reg(CX), Reg(R8), Reg(R9), Reg(AX)]
| _ ->
used = []
updated = []
return (used, updated)
清单 20-21:识别每条指令使用和更新的操作数
请注意,这份清单仅涵盖了第一部分的汇编指令。Mov 是最直接的例子:它使用源操作数并更新其目标操作数 ❶。像 add src, dst 这样的二进制指令使用源操作数和目标操作数,并更新目标操作数 ❷。同样也很容易看出 Unary、Cmp、SetCC 和 Push 指令读取并更新了哪些操作数。
一些指令使用它们未明确提到的寄存器。Idiv 将存储在 EAX 和 EDX 寄存器中的值除以其源操作数,因此它使用了这三个值。它将结果存储在 EAX 和 EDX 中,所以它更新了这两个寄存器。Cdq 将 EAX 的符号扩展到 EDX,这意味着它使用 EAX 并更新 EDX。
Call 使用存储被调用者参数的寄存器;我们可以在后端符号表中查找这些寄存器,我们在汇编生成过程中记录了它们 ❸。它更新所有调用者保存的寄存器——无论我们是否通过这些寄存器传递被调用者的参数——因为这些寄存器可能会被被调用者破坏。这使得所有调用者保存的寄存器会干扰任何在调用此函数时仍然活跃的伪寄存器,因此我们的图着色算法会将这些伪寄存器分配给被调用者保存的寄存器。
如果一条指令既使用又更新相同的寄存器——例如,二进制指令既使用又更新它的目标寄存器——那么将该寄存器同时加入到 使用过的 和 更新过的 列表中是很重要的。在传输函数中,我们只关心寄存器的使用情况,因为这会使它变得活跃。但是,当我们在 add_edges 中再次使用这个辅助函数时,我们只关心寄存器的更新情况,因为这会使它与任何同时活跃的其他寄存器产生干扰。
现在我们可以编写传输函数,它定义在列表 20-22 中。
transfer(block, end_live_registers):
current_live_registers = end_live_registers
for instruction in reverse(block.instructions):
❶ annotate_instruction(instruction, current_live_registers)
❷ used, updated = find_used_and_updated(instruction)
for v in updated:
if v is a register:
current_live_registers.remove(v)
for v in used:
if v is a register:
current_live_registers.add(v)
❸ annotate_block(block.id, current_live_registers)
列表 20-22:汇编中的活动性分析传输函数
由于这是一个反向分析,我们按逆序分析汇编指令。处理一条指令时,我们首先记录指令执行后哪些寄存器是活跃的 ❶。然后,我们计算出指令执行前哪些寄存器是活跃的。我们调用刚才编写的辅助函数来确定它使用和更新了哪些操作数 ❷。接着,我们将它更新的任何寄存器从当前活跃寄存器集合中移除,并将它使用的寄存器添加进来。(如果一条指令使用并更新同一寄存器,我们将先将该寄存器从活跃寄存器集合中移除,再立刻将其添加回去。)一旦处理完每条指令,我们记录下该块开始时哪些寄存器是活跃的 ❸。
我们不会跟踪常量和内存操作数,但我们的活动寄存器集合可能仍然包括一些我们不关心的操作数(特别是具有静态存储持续时间的伪寄存器)。将它们包括在我们的活动性结果中不会造成任何 harm;当我们在下一步中使用这些结果时,我们会忽略它们。
添加边
拿到活动性信息后,我们终于可以弄清楚要向图中添加哪些边。列表 20-23 给出了这一步的伪代码。
add_edges(liveness_cfg, interference_graph):
for node in liveness_cfg.nodes:
if node is EntryNode or ExitNode:
continue
for instr in node.instructions:
used, updated = find_used_and_updated(instr)
❶ live_registers = get_instruction_annotation(instr)
for l in live_registers:
❷ if (instr is Mov) and (l == instr.src):
continue
for u in updated:
❸ if (l and u are in interference_graph) and (l != u):
add_edge(interference_graph, l, u)
列表 20-23:向干扰图中添加边
我们之前学到,当一个寄存器在另一个寄存器活跃时被更新时,这两个寄存器会产生干扰。现在我们将查看每条指令并弄清楚它创建了哪些干扰。处理单条指令时,我们首先调用 find_used_and_updated 来查找它更新了哪些操作数。(我们会忽略该函数返回的第一个列表 used,因为我们不关心指令使用了哪些操作数。)接下来,我们查找指令执行后哪些寄存器是活跃的 ❶。
然后,我们在<_samp class="SANS_TheSansMonoCd_W5Regular_11">live_registers中的每个寄存器和
在我们为两个节点之间添加一条边之前,我们会确保这两个节点已经存在于干扰图中。我们还会确保它们是不同的,因为我们不想添加一条从一个节点指向它自身的边 ❸。
构建图时处理其他类型
现在我们将处理在第 II 部分中添加的所有功能。由于我们分别分配 XMM 和通用寄存器,因此我们将构建两个干扰图。我们将从每个寄存器类别的单独基本图开始。XMM 寄存器的基本图应包含 14 个节点;它将包括 XMM0 到 XMM13,但不包括临时寄存器 XMM14 和 XMM15。在add_pseudoregisters中,我们会检查伪寄存器是否具有正确的类型,然后再将其添加到图中。当我们分配 XMM 寄存器时,我们只会将
除了浮点寄存器外,还有一些其他细节我们需要更改。我们将排除别名化的伪寄存器,因为它们不应该分配给寄存器。你可以在这里重用上一章的地址分析;只需在将程序从 TACKY 转换为汇编语言之前重新运行分析。如果在 TACKY 程序中某个变量被别名化,它在汇编程序中仍然会被别名化。
活跃度分析应反映我们在第 II 部分中实现的新调用约定。meet函数不能假定当函数退出时 EAX 仍然是活动的;它应该检查后端符号表,了解函数使用哪些寄存器来传递返回值。这些寄存器在EXIT时都会是活动的。
我们还将更新 find_used_and_updated 辅助函数。首先,这个函数需要正确处理 Memory 和 Indexed 操作数。这些操作数指定了内存中的位置,但它们在地址计算中使用了寄存器。当我们使用这些操作数时,即使我们是写入它指定的内存位置,我们也需要读取它所指向的任何寄存器。例如,指令 movl $1, 4(%rax) 使用 RAX 而不是更新它,而指令 leaq (%rax, %rcx, 4), %rdi 使用了 RAX 和 RCX,但更新了 RDI。其次,find_used_and_updated 必须识别出所有 XMM 寄存器都是调用者保存的,因此会被 Call 指令更新。最后,这个函数还需要处理我们在第二部分中添加的所有新汇编指令,但它们没有什么特别复杂的。
计算溢出代价
在构建图之后,我们会给每个寄存器标注溢出代价。如果我们无法为图中的每个节点上色,这些代价将帮助我们决定应该溢出哪个节点(或哪些节点)。我们会尽量以最小化所有溢出节点的总代价的方式为图上色。
由于我们不能溢出硬寄存器,因此我们为每个硬寄存器分配一个无限的溢出代价。为了估算每个伪寄存器的溢出代价,我们只需计算它在汇编代码中出现的次数。例如,如果我们遇到指令 movl $1, %x,我们就将 x 的溢出代价增加一。如果我们看到指令 addl %x, %x,我们就将 x 的溢出代价增加二。其背后的原理是,伪寄存器使用得越频繁,如果我们溢出它,就会引入更多的内存访问和新的指令。
坦白说,这是一个糟糕的计算溢出代价的方法。它忽视了一个基本事实,即某些指令的执行频率比其他指令更高。显然,在执行一百万次的循环中使用伪寄存器,应该比在只执行一次的指令中使用它,增加更多的溢出代价。很难准确预测一个特定指令会执行多少次,但一种方法是将循环的嵌套深度作为执行频率的粗略代理。当采用这种方法的编译器计算溢出代价时,它们会给更深层嵌套的循环中的指令更多权重。
不幸的是,我们不知道程序中循环的位置。发现循环需要我们实现一种全新的分析方法,而本章内容已经够长了。如果你想自己实现这个分析方法,我在第 669 页的“附加资源”部分中列出了一些关于识别循环的参考资料。
着色干扰图
现在是时候给图着色了!我们的目标是最小化我们留白节点的总溢出成本(理想情况下通过着色每个节点,使得总溢出成本为零)。但是,精确的图着色算法能够找到最佳着色方案,但在实际中速度太慢。因此,我们将使用一种近似算法。这个算法可能无法找到最优着色,但通常能找到一个相当不错的着色方案。
我们的图着色算法基于一个简单的观察:你总是可以给一个邻居少于k的节点着色,因为总会有一种颜色是它的邻居没有使用的。这个观察叫做度数 < k 规则。(一个节点的邻居数量称为它的度数;如果一个节点有k个或更多邻居,我们称它具有显著度数。)度数 < k 规则为我们提供了一种分解问题的方法。首先,我们将临时移除任何邻居少于k的节点,这叫做修剪图。然后,我们将以某种方式着色图的其余部分(我们暂时不关心如何做)。最后,我们会一个一个地将修剪掉的节点放回图中。当我们放回一个节点时,我们将为它分配一种与我们已经着色的邻居不冲突的颜色。由于每个节点的邻居少于k,总会有至少一种可用颜色。
让我们尝试使用这种方法为图 20-7 中的图进行三色着色,看看能达到什么程度。

图 20-7:尚未着色的图 描述
这个图有四个邻居少于三个的节点:B、C、F 和 H。我们将从图中修剪这些节点,然后弄清楚如何着色这个较小的图。我们还将定义一个栈来跟踪被修剪的节点,这些节点最终需要重新放回图中。图 20-8 显示了修剪后的图和栈。

图 20-8:从图 20-7 中移除低度数节点后的图 描述
在这个修剪后的图中,A 和 G 都有少于三个邻居。这意味着我们可以应用相同的技巧再次修剪图形!我们将把 A 和 G 推到栈顶;然后,我们将弹出它们并为它们上色,再为 B、C、F 和 H 上色。当我们弹出 A 和 G 并将它们放回图中时,它们将保持当前的度数,所以我们知道我们可以为它们找到颜色。
图 20-9 展示了修剪 A 和 G 后图形和栈的状态。

图 20-9:从图 20-7 修剪两轮后的图 描述
我们剩下的两个节点每个的邻居少于三个,因此我们可以直接为它们上色。但是,我们会采取稍微不同的方法来完成同样的任务:我们将从图中修剪它们,然后再把它们放回去。修剪后,图形为空。图 20-10 展示了修剪每个节点后的图形状态。

图 20-10:从图 20-7 修剪每个节点后的图 描述
我们的原计划是修剪图形,为剩下的节点上色,然后把修剪的节点放回。现在,修剪工作已经完成,我们不需要做第二步:没有剩下的节点需要上色!我们并不是每次都能这么幸运;有时我们会遇到无法修剪的节点。稍后我们会讨论如何处理这种情况。现在,让我们完成图形的上色。
作为我们计划的最后一步,我们将取回之前修剪的每个节点,为它分配颜色,然后将它放回图中。我们从最后一个被移除的节点开始,它在栈顶,然后重复此过程直到栈为空。图 20-11 中的图示展示了我们在这个例子中如何重建图形。

图 20-11:将节点添加回图中并分配颜色 描述
当我们将 E 加回图中时,它没有邻居,因此我们可以为它分配任何颜色。我们将它涂成白色。然后,当我们添加 D 时,它唯一的邻居是 E,所以我们可以为它分配黑色或灰色。当我们添加 G 时,我们发现它有一个白色邻居和一个灰色邻居,因此我们必须将它涂成黑色。我们继续这个过程,直到栈为空,图中的每个节点都被分配了颜色。
处理溢出
对于许多干扰图,我们可以使用上一节中的方法修剪每个节点。我们可以为这些图着色而不发生溢出。但也有其他图,我们会陷入困境:我们会遇到一个每个节点都有k个或更多邻居的情况。假设我们想为图 20-12 着色。

图 20-12:每个节点有三个或更多邻居的图 描述
如果我们尝试修剪这个图,我们将立即陷入困境:每个节点至少有三个邻居!为了摆脱困境,我们无论如何都会选择一个节点进行修剪。我们将把这个节点放入堆栈中,然后照常继续算法。这个节点是一个溢出候选项,因为当我们将它重新加入图中时,可能无法为它着色。如果运气好,它的邻居不会用尽所有颜色,那么我们就能为它着色。如果运气不好,它的邻居会用尽所有k种颜色,那么我们就不得不溢出它。
我们希望选择一个溢出成本较低的溢出候选项。但我们也希望选择一个邻居较多且尚未被修剪的溢出候选项,因为修剪我们的溢出候选项会降低其邻居的度数,并帮助我们避免稍后溢出它们。为了平衡这两者的优先级,我们将选择溢出成本 / 度数值最小的节点,其中度数是尚未被修剪的邻居的数量。
请注意,我们永远不会选择硬寄存器作为溢出候选项,因为这些寄存器的溢出成本 / 度数总是无限大。如果图中还有伪寄存器,我们将始终选择其中一个作为溢出候选项,而不是选择硬寄存器。如果没有剩余的伪寄存器,寄存器的总数必须是k个或更少,因此我们将能够修剪每个寄存器。
有些图没有有效的k着色,这使得溢出成为不可避免的。对于其他图,存在有效的着色,但我们是否能找到它取决于运气;它取决于我们从图中删除节点的精确顺序,以及我们在将它们重新加入时如何着色它们。
为了说明这种方法中的偶然性,我们将尝试几次为图 20-12 着色。这个图是可以进行 3 着色的,但我们只有一次尝试会找到一个无溢出的着色。在这两种情况下,我们都将选择C作为溢出候选项,然后修剪A和B,留下D、E和F。在这两种情况下,我们都将使用相同的策略为节点着色,当我们将它们重新加入图中时:我们将从颜色列表[白色、灰色、黑色]中选择第一个可用的颜色。唯一的区别是我们修剪其余三个节点的顺序。在第一种情况下,我们将先修剪D,然后是E,最后是F。图 20-13 显示了当我们尝试将节点重新加入时会发生什么。

图 20-13:第一次尝试为图 20-12 上色 描述
当我们到达C时,我们会发现它的邻居已经使用了所有三种颜色,因此我们将被迫进行溢出。
现在让我们重复这个过程;这次我们将修剪F,然后是E,再然后是D。图 20-14 展示了当我们将节点重新放回图中时会发生什么。

图 20-14:第二次尝试为图 20-12 上色 描述
这次,我们给A和E分配了相同的颜色,这使我们能够为C分配颜色,而不是溢出它。在像这样的简单例子中,我们很容易看到给这些节点分配相同颜色是更好的选择。但没有通用的规则可以让我们避免像图 20-13 中那样不必要的溢出;这就是这个算法近似的原因。如果我们使用精确算法,我们可以避免不必要的溢出,但正如我之前提到的,精确算法的成本太高,无法实际应用。
实现图着色算法
现在我们已经通过一些例子使用了这个算法,接下来让我们看一下伪代码,伪代码显示在列表 20-24 中。
color_graph(g):
remaining = `<unpruned nodes in g>`
❶ if remaining is empty:
return
// choose next node to prune
chosen_node = null
for node in remaining:
degree = length(`<unpruned neighbors of node>`)
if degree < k:
❷ chosen_node = node
break
if chosen_node is null:
// choose a spill candidate
best_spill_metric = infinity
for node in remaining:
degree = length(`<unpruned neighbors of node>`)
spill_metric = node.spill_cost / degree
if spill_metric < best_spill_metric:
❸ chosen_node = node
best_spill_metric = spill_metric
chosen_node.pruned = True
// color the rest of the graph
❹ color_graph(g)
// color this node
colors = [1, 2, . . ., k]
for neighbor_id in chosen_node.neighbors:
neighbor = get_node_by_id(g, neighbor_id)
if neighbor.color is not null:
colors.remove(neighbor.color)
❺ if colors is not empty:
if chosen_node is a callee-saved hard register:
chosen_node.color = max(colors)
else:
chosen_node.color = min(colors)
chosen_node.pruned = False
return
列表 20-24:图着色算法
我们将递归地为图着色。在每一步中,我们将修剪一个节点,然后递归调用着色剩余的图,再把该节点放回并为其分配一个颜色。在基本情况下,我们已经修剪了所有节点,因此没有任何事情可做❶。
在递归情况下,我们首先选择一个节点进行修剪,这个列表中将其称为chosen_node。我们将选择第一个找到的、未修剪邻居数量少于k的节点❷。(如果你分配的是通用寄存器,k是 12;如果分配的是 XMM 寄存器,k是 14。)如果搜索结果为空,我们将选择具有最小溢出成本/度数值的节点❸。要修剪一个节点,我们只需要将它的pruned属性设置为True。然后,我们将递归调用color_graph来给图中的剩余节点着色❹。
在从递归调用返回后,我们将尝试为chosen_node分配颜色。我们将取出整数列表1到k,它们表示所有可能的颜色,并移除我们已经分配给chosen_node的邻居节点的颜色。chosen_node的某些邻居可能没有颜色,要么是因为我们将它们溢出,要么是因为在chosen_node之前我们就已经修剪了它们,因此我们将稍后为它们着色。我们可以忽略这些节点。
如果列表中还有剩余的颜色,我们将为chosen_node ❺分配其中一种颜色。如果有多种颜色可用,算法并不挑剔我们选择哪一种。尽管我们选择的颜色可能会影响最终溢出的节点数量,但这种影响是不可预测的;没有一种颜色选择策略能够在所有情况下最小化溢出。因此,我们将选择一种具有不同目标的颜色:将伪寄存器分配给调用者保存的硬寄存器,而不是被调用者保存的硬寄存器。(我们希望尽量少使用被调用者保存的寄存器,以避免保存和恢复它们的成本。)当chosen_node代表一个被调用者保存的硬寄存器时,我们将为它分配可用的最高编号的颜色。否则,我们将为它分配最低编号的可用颜色。通过这种策略,着色算法倾向于将更高编号的颜色分配给被调用者保存的寄存器,将较低编号的颜色分配给调用者保存的寄存器和伪寄存器。只有当没有较低编号的颜色可用时(例如,因与每个调用者保存的寄存器冲突),伪寄存器才会得到更高编号的颜色。一旦我们选择了颜色,就将pruned属性设置回False。虽然这不是绝对必要的,因为我们之后不会再使用这个属性,但它标记着我们已经将节点重新放回了图中。
如果列表中没有剩余的颜色,我们就得溢出chosen_node。具体来说,这意味着我们不会为它分配颜色。我们也不会更新它的pruned属性,因为我们不会把这个节点重新放回图中。请注意,我们并不会显式地将节点推入栈中或之后弹出它们。我们的递归算法自然地按照正确的顺序为节点着色,从我们修剪的最后一个节点开始。
构建寄存器映射并重写函数体
一旦我们给图着色,剩下的寄存器分配过程就相对简单了。我们将构建一个从伪寄存器到硬寄存器的映射,利用这个映射来替换汇编代码中的伪寄存器。在构建这个映射的过程中,我们还将追踪已分配的被调用者保存寄存器,以便在指令修正阶段保存和恢复它们。Listing 20-25 展示了如何构建这个映射。
create_register_map(colored_graph):
// build map from colors to hard registers
color_map = `<empty map>`
for node in colored_graph.nodes:
match node.id with
| Reg(r) ->
color_map.add(node.color, r)
| Pseudo(p) -> continue
// build map from pseudoregisters to hard registers
register_map = `<empty map>`
callee_saved_regs = {}
for node in colored_graph.nodes:
match node.id with
| Pseudo(p) ->
if node.color is not null:
❶ hardreg = color_map.get(node.color)
register_map.add(p, hardreg)
if hardreg is callee saved:
❷ callee_saved_regs.add(hardreg)
| Reg(r) -> continue
❸ record_callee_saved_regs(`<current function name>`, callee_saved_regs)
return register_map
Listing 20-25: 从伪寄存器到硬寄存器构建映射
首先,我们将遍历图中的硬寄存器,构建一个从颜色到硬寄存器的映射。请记住,我们将实现颜色和硬寄存器之间的 1:1 映射,因为每个k硬寄存器必须分配给k个可能颜色中的一个。接下来,我们将遍历所有的伪寄存器。如果一个伪寄存器被分配了颜色,我们将把它映射到与该颜色相同的硬寄存器,这可以在color_map中找到❶。如果一个伪寄存器没有被分配颜色,我们将不会将其添加到映射中。
在构建register_map的同时,我们还会跟踪该函数将使用的被调用者保存寄存器的集合。每当我们添加一个从伪寄存器到被调用者保存硬寄存器的映射时,我们就会将该硬寄存器添加到这个集合中❷。我们会记录每个函数的callee_saved_regs集合,以便将这些信息传递给指令修正阶段❸。(这个列表没有指定在哪记录这些信息;你可以将其添加到函数定义本身、后端符号表或其他数据结构中,具体取决于最方便的方式。)当我们分配 XMM 寄存器时可以跳过此步骤,因为 XMM 寄存器没有被调用者保存。
最后,我们将重写汇编代码。我们将用寄存器映射中的相应硬寄存器替换每条指令中的每个伪寄存器。如果某个伪寄存器不在映射中,我们将不替换它。同时,我们将删除任何源和目标寄存器相同的mov指令。例如,如果我们已经将tmp1和tmp2都映射到 EAX,我们可以重写
my_fun:
movl %edi, %tmp1
addl $5, %tmp1
❶ movl %tmp1, %tmp2
❷ movl %tmp2, %eax
ret
如下所示:
my_fun:
movl %edi, %eax
addl $5, %eax
ret
❶和❷都将被重写为movl %eax, %eax,这并不会做任何事情,因此我们可以将它们从最终的汇编程序中删除。
这个清理步骤是删除不必要的mov指令,它与寄存器合并相关,我们将在本章后面实现。但有一些重要的不同之处。寄存器合并步骤会故意合并通过mov连接的寄存器,例如tmp1和tmp2,然后删除它们之间的mov指令。这个过程会在我们着色图形之前完成。而我们在这里做的要简单得多;我们并不是试图合并寄存器,而是如果我们恰好将mov指令的两个操作数分配了相同的颜色,我们会利用这一点。
即使我们实现了寄存器合并,这个着色后清理步骤仍然是有用的。正如我们将看到的,寄存器合并过程并不完美;有时它会遗漏一对寄存器,这对寄存器如果合并的话会非常有帮助。如果我们足够幸运,给那对寄存器分配相同的颜色,那么这个最后的步骤仍然能够删除它们之间的mov指令。
到此为止,我们已经有了一个工作的寄存器分配器!我们只需要更新指令修复和代码生成过程,然后就可以进行测试。
使用被调用者保存的寄存器修复指令
如果一个函数使用了任何被调用者保存的寄存器,我们需要在函数开始时保存它们的值,并在结束时恢复它们。我们通过将它们推入栈中,放在当前栈帧的其余部分之上来保存它们。例如,如果一个函数需要 16 个字节的栈空间用于局部变量,并且使用了 R12 和 R13 寄存器,我们将在函数体的最开始插入以下三条指令:
Binary(Sub, Quadword, Imm(16), Reg(SP))
Push(Reg(R12))
Push(Reg(R13))
初始的Sub指令分配当前的栈帧,就像前面的章节中一样。新的Push指令紧接在其后。(如果你跳过了第二部分,第一条指令将是AllocateStack,而不是Sub。)
在我们返回之前,我们通过将这些寄存器的值从栈中弹出,恢复它们的值。也就是说,我们会将此函数中的每条Ret指令重写为:
Pop(R13)
Pop(R12)
Ret
我们可以将被调用方保存的寄存器以任意顺序压入栈中,但我们始终需要以相反的顺序将其弹出,以确保每个寄存器恢复到其原始值。由于我们在代码生成过程中会添加函数尾部,我们将在恢复被调用方保存的寄存器后立即解除栈帧的分配:
popq %r13
popq %r12
movq %rbp, %rsp
popq %rbp
ret
我们还需要确保整个栈帧,包括我们保存到栈中的任何被调用方保存的寄存器的值,都保持 16 字节对齐。假设伪操作数替换阶段为特定函数分配了 20 字节的栈空间以存储局部变量。我们通常会从 RSP 减去 32 字节来保持正确的栈对齐。但是,如果该函数使用了单个被调用方保存的寄存器,我们应当首先从 RSP 减去 24 字节:
Binary(Sub, Quadword, Imm(24), Reg(SP))
Push(Reg(BX))
如果我们明确地从 RSP 减去 24 字节,再通过推送 RBX 再减去 8 字节,我们最终会总共减去 32 字节,因此栈会保持正确对齐。清单 20-26 演示了执行此复杂计算的一种方法。
calculate_stack_adjustment(bytes_for_locals, callee_saved_count):
callee_saved_bytes = 8 * callee_saved_count
total_stack_bytes = callee_saved_bytes + bytes_for_locals
❶ adjusted_stack_bytes = round_up(total_stack_bytes, 16)
❷ stack_adjustment = adjusted_stack_bytes - callee_saved_bytes
return stack_adjustment
清单 20-26:考虑到被调用方保存寄存器的栈空间计算
在此清单中,bytes_for_locals是我们在伪操作数替换过程中分配的栈空间字节数,callee_saved_count是该函数使用的被调用方保存寄存器的数量。我们首先计算被调用方保存的值占用的字节数。然后,我们将这个值加到bytes_for_locals中,并四舍五入到最接近的 16 字节的倍数,以获得栈帧的总大小❶。从这个值向回计算,我们减去被调用方保存的值占用的字节数,从而得出我们需要明确从 RSP 中减去的字节数❷。
代码生成
最后,我们将更新代码生成阶段,以处理
表格 20-4: 格式化汇编指令
| 汇编指令 | 输出 |
|---|---|
| 弹出(寄存器) | popq |
表格 20-5: 格式化汇编操作数
| 汇编操作数 | 输出 | |
|---|---|---|
| 寄存器(BX) | 8 字节 | %rbx |
| 4 字节 | %ebx | |
| 1 字节 | %bl | |
| 寄存器(R12) | 8 字节 | %r12 |
| 4 字节 | %r12d | |
| 1 字节 | %r12b | |
| 寄存器(R13) | 8 字节 | %r13 |
| 4 字节 | %r13d | |
| 1 字节 | %r13b | |
| 寄存器(R14) | 8 字节 | %r14 |
| 4 字节 | %r14d | |
| 1 字节 | %r14b | |
| 寄存器(R15) | 8 字节 | %r15 |
| 4 字节 | %r15d | |
| 1 字节 | %r15b | |
| 寄存器(XMM8) | %xmm8 | |
| Reg(XMM9) | %xmm9 | |
| Reg(XMM10) | %xmm10 | |
| Reg(XMM11) | %xmm11 | |
| Reg(XMM12) | %xmm12 | |
| Reg(XMM13) | %xmm13 |
现在你可以尝试在一些实际程序中使用你的寄存器分配器了!
寄存器合并
我们的寄存器分配器已经能正确工作。但正如我们在章节开始时的例子中看到的,如果我们加上一个合并步骤,它将生成更高效的代码。那个早期的例子还给我们提供了一个关于这个过程如何工作的总体印象:我们查看每条 mov 指令,该指令将一个值从一个寄存器复制到另一个寄存器,并决定是否合并源寄存器和目标寄存器。一旦做出这些决定,我们会重写汇编代码,替换掉合并后的寄存器,并删除那些不再需要的 mov 指令。
为了决定合并哪些寄存器,我们将参考干扰图。当满足两个条件时,我们将合并一对寄存器。第一个条件很明显:寄存器之间不能互相干扰。下面的 清单 20-13 例子再现了这个条件为何是必要的:
movl $1, %y
movl %y, %x
addl $1, %y
addl %x, %y
由于我们在 x 寄存器活跃时更新 y,这两个寄存器之间存在干扰。如果我们将它们合并,第一个 add 指令将覆盖 x,我们最终将计算出 2 + 2,而不是 1 + 2。
第二个条件更微妙:只有在不会迫使我们溢出更多寄存器的情况下,我们才会合并一对寄存器。为了理解为什么合并可能导致溢出,让我们看一下 清单 20-27。
f:
movl %edi, %arg
movl %arg, %tmp
addl $1, %tmp
imull %arg, %tmp
movl $10, %eax
subl %tmp, %eax
ret
清单 20-27:一个合并寄存器会导致溢出的汇编函数
这个汇编函数计算 10 - (arg + 1) * arg。在这个例子中,我们假设只有 EDI 和 EAX 是可用的硬件寄存器,所以 k 是 2。 图 20-15 展示了此清单的干扰图,这个图显然是可以用 2 种颜色着色的。

图 20-15: 清单 20-27 的干扰图 描述
在清单 20-27 中的第一个 mov 指令看起来是一个合并的候选项。(第二条指令不是,因为 arg 和 tmp 发生干扰。)但如果我们尝试将 arg 合并到 EDI 中,我们就会遇到问题。最终我们会得到清单 20-28 中的汇编代码。
f:
movl %edi, %tmp
addl $1, %tmp
imull %edi, %tmp
movl $10, %eax
subl %tmp, %eax
ret
清单 20-28: 清单 20-27 合并 arg 到 EDI 后的代码
图 20-16 显示了这个合并代码的干扰图。

图 20-16: 清单 20-28 的干扰图 描述
现在我们无法再对这个图进行二色着色了。由于 tmp 与两个硬件寄存器发生干扰,我们必须将其溢出到内存中。我们没有提高性能,反而让情况更糟!将 tmp 溢出到内存的代价大于去除一条 mov 指令的好处。为了避免这种情况,我们将使用一种叫做 保守合并 的策略:只有在我们能提前知道不会使干扰图着色更困难的情况下,才会合并两个寄存器。但在深入讨论保守合并之前,我们需要先学习如何保持干扰图的更新。
更新干扰图
每当我们决定合并一对寄存器时,都需要更新干扰图。否则,我们会基于错误的信息做出后续的合并决策。更新干扰图有两种方式。第一种是立即重写汇编代码,并从头开始重建干扰图。这个方法的问题在于,构建干扰图非常慢。我们可能在一个函数中合并几十个甚至上百个 mov 指令,但我们无法承受每次都重建干扰图。
更快的方法是直接在现有的干扰图中合并这两个节点,而不需要重新查看汇编代码。在图 20-17 中,我们使用这种方法将伪寄存器 tmp2 合并到 EAX 寄存器中。

图 20-17:更新干扰图以反映合并决策 描述
我们假设原本与 tmp2 发生冲突的任何寄存器现在与 EAX 发生冲突。为了使干扰图反映这种变化,我们只需从每个 tmp2 的邻居添加一条边到 EAX,然后移除 tmp2。
但是,这种更新图形的方法也存在问题:它并不总是准确的!它可能会在不发生实际冲突的寄存器之间包含一些多余的边。列表 20-29 给出了一个稍显牵强的例子。
f:
movl %edi, %tmp1
movl %edi, %tmp2
addl %tmp1, %tmp2
movl %tmp2, %eax
ret
列表 20-29:一个将其第一个参数复制到两个不同伪寄存器中的函数
请注意,tmp1 和 tmp2 发生了冲突:第二条 mov 指令在 tmp1 仍然处于活动状态时更新了 tmp2。让我们尝试将 tmp1 合并到 EDI 中,并使用我们快速、简便的方法相应地更新图形。图 20-18 显示了图形将如何变化。

图 20-18:更新干扰图以反映 列表 20-29 描述
但是,当我们实际用 EDI 替换 tmp1 时,我们会发现与 tmp2 的冲突消失了!列表 20-30 显示了更新后的汇编代码。
f:
movl %edi, %tmp2
addl %edi, %tmp2
movl %tmp2, %eax
ret
列表 20-30:列表 20-29 合并后 tmp1 合并到 EDI
我们之前学到,指令 mov src, dst 永远不会使 src 和 dst 发生冲突。最初,指令 movl %edi, %tmp2 导致了 tmp1 和 tmp2 之间的冲突。现在,我们将 tmp1 合并到 EDI 中,它不再引起冲突。
尽管它不完全准确,我们的快速更新方法仍然很有用。它产生了一个保守的真实干扰图近似;它包含了图中应该有的所有节点和边,但可能也有一些额外的边。如果这个图告诉我们两个寄存器可以安全合并,我们可以确信它们确实可以。但如果我们仅仅依赖这个方法,我们就会错过一些合并机会。例如,如果我们只查看图 20-18 中的图,我们就不会意识到可以将 tmp2 合并到 EDI 中。更糟糕的是,如果我们尝试为这个图着色,我们可能会不必要地溢出寄存器。
所以,我们将使用两种方法来更新图形。每次决定合并一对寄存器时,我们将通过合并它们的节点来快速更新。然后,在查看完每条 mov 指令并重写汇编代码之后,我们将从头重建图形。我们将重复这个构建-合并循环,直到找不到更多可以合并的寄存器。将一个快速、近似的内循环与一个缓慢、精确的外循环结合,使我们得到了两全其美的效果。我们将抓住每个合并机会,并将准确的干扰图传递到着色阶段,但我们不会在每次合并决策后都浪费时间重建图形。
保守合并
现在我们已经理解了合并如何改变图形,我们可以推测它何时可能导致溢出。基本问题是,当我们合并两个节点时,合并后的节点将比原来任何一个节点的度数都高,这可能使得剪枝变得更加困难。它还可能比原来的任何节点都有更高的溢出成本,因为它被使用得更频繁。
我们将使用两个保守的合并测试,以确保合并后的节点在给图着色时不会造成问题。Briggs 测试保证我们不会溢出合并后的节点。George 测试保证我们不会溢出任何其他节点,除非它们在原始图中已经是潜在的溢出候选节点。只有当它们通过 Briggs 测试时,我们才会合并两个伪寄存器。如果两个寄存器通过任意一个测试,我们会将伪寄存器合并到硬寄存器中;在这种情况下我们可以更加宽松,因为我们已经知道硬寄存器不会溢出。两个测试都以发明它们的人的名字命名;你可以在第 669 页的“附加资源”中找到首次提出这些测试的论文链接。
值得明确的是,保守的合并测试保证的内容,因为这个结果有点反直觉。如果你能够在不选择任何溢出候选节点的情况下完全修剪原始图,那么这些测试保证合并后的图也将保持相同的情况。在这种情况下,我们可以确定,合并不会导致任何溢出。
但如果你无法完全修剪原始图,那么预测合并的影响就变得更加困难,因为选择溢出候选节点之后发生的很多事情都取决于运气。我们在本章早些时候看到过一个例子,当时我们尝试着为 图 20-12 着色;以不同的顺序修剪节点,决定了是否能够着色一个溢出候选节点,或者最终会导致溢出。合并寄存器也可能产生类似的连锁反应。如果我们不幸,这些效应可能导致一个原本可以避免的溢出。
换句话说,如果给原始图着色时需要我们选择一个溢出候选节点,那么在合并图时也可能需要这样做——而在那时,我们无法确定将会发生什么。在这种情况下,保守的合并测试仍然给我们提供了两个有价值的保证。首先,合并后的节点本身不会发生溢出。其次,在我们遇到困境并必须选择第一个溢出候选节点时,每个节点的度数都将与如果我们没有进行合并时遇到困境时的度数相同或更低。这意味着,从总体上看,我们可能成功地剪枝更多的节点,并且溢出较少的节点,而不进行合并的话,情况可能正好相反。
现在我们将更深入地研究布里格斯和乔治测试。我们将定义这两个测试,并通过一些例子来展示它们为什么有效。
布里格斯测试
记住,如果一个节点有 k 个或更多邻居,那么它的度数就被认为是显著的。布里格斯测试允许我们合并两个节点,只要合并后的节点具有的显著度数少于 k 个邻居。当我们为图着色时,我们将能够修剪每个度数不显著的邻居。合并后的节点本身将拥有不显著的度数——它将剩下少于 k 个邻居——因此我们也可以修剪该节点。
让我们看一个例子。考虑 图 20-19 中的干扰图。

图 20-19:合并前的干扰图 描述
我们的着色算法可以毫不费力地修剪整个图。现在让我们应用布里格斯测试,看看能否将 x 合并到 y 中。图 20-20 展示了当我们合并这两个节点时图的样子。

图 20-20:图 20-19 中的图,合并 x 到 y 后的结果 描述
合并后,y将有四个邻居:a、z、ESI 和 EDI。这些节点中只有两个,ESI 和 EDI,具有显著的度数。由于 k 为 3,这个例子通过了 Briggs 测试。实际上,在我们修剪了 a 和 z 后,我们将能够修剪 y,然后像之前那样完成图的其余部分的修剪。
接下来,让我们看看一个不符合 Briggs 测试的案例。图 20-21 中的图几乎与图 20-19 中的图完全相同,唯一的不同是从 a 到 EDI 的额外边。

图 20-21:图 20-19 中的图,x 和 y 无法再合并 描述
即使有了这条额外的边,我们的着色算法仍然可以修剪整个图。但是图 20-22 展示了当我们将 x 合并到 y 时会发生什么。

图 20-22:图 20-21 中的图,合并 x 到 y 后的结果 描述
现在,y有三个邻居,它们的度数很大:ESI、EDI 和 a。这个例子不符合 Briggs 测试,y确实无法修剪。在我们修剪了 z 和 EAX 后,我们会卡住,并且被迫选择 y 或 a 作为溢出候选节点。
正如这些例子所示,Briggs 测试阻止我们将一个可着色图转换为不可着色图。它还给我们提供了另一项保证:我们永远不会将两个节点合并在一起,如果合并后的节点可能会溢出。看看图 20-23。

图 20-23:一个干扰图,我们无法将 tmp1 和 tmp2 合并 描述
假设我们想将 tmp2 合并到 tmp1 中。这显然不会让图变得更难着色;它对干扰图的影响与完全移除 tmp2 相同。但是合并这些节点有一个不好的原因。无论我们是否将其与 tmp2 合并,我们都无法给 tmp1 着色,所以合并只会通过增加 tmp1 的溢出成本来使事情变得更糟。
这个例子未通过布里格斯测试,因为合并后 tmp1 会有三个重要度较高的邻居(就像合并前一样)。如果我们可能无法给它着色,布里格斯测试将不允许我们进行合并。
最后,让我们调整这个例子,说明布里格斯测试的一个局限性。假设我们想将 tmp2 合并到 EDI,而不是 tmp1。像我们之前的例子一样,这不会让图变得更难着色。这个例子也未通过布里格斯测试,因为 EDI 会有三个重要度较高的邻居。但有一个重要的区别:作为硬寄存器,EDI 不能溢出。这意味着将 tmp2 合并到 EDI 没有什么坏处;它不会迫使我们溢出 EDI,也不会使其他节点变得更难着色。在这种情况下,我们将使用乔治测试来找到布里格斯测试忽略的合并机会。
乔治测试
当我们将伪寄存器与硬寄存器合并时,我们知道合并后的寄存器不能溢出。相反,我们担心的是稍微不同的结果:如果硬寄存器变得更难修剪,它的邻居也可能变得更难修剪。最终,这种变化可能会迫使我们溢出那些之前能够着色的节点。在涉及硬寄存器的情况下,我们将同时使用布里格斯测试和乔治测试,以尽可能多地识别合并机会。布里格斯测试证明我们可以修剪合并后的节点,因此它不会干扰着色其他节点的尝试。乔治测试证明即使我们无法修剪合并后的节点,我们也不会使合并节点的邻居变得更难修剪(因此不会使这些节点或图的其余部分变得更难着色)。我们可以合并通过这两个测试中的任意一个测试的节点对。
乔治测试表示,如果 p 的每个邻居都满足以下两个条件中的任意一个,那么你可以将伪寄存器 p 合并到硬寄存器 h 中:
-
它的邻居少于 k 个。
-
它已经与 h 有干扰。
如果一个邻居满足第一个条件,那么在我们为图着色时,肯定能对其进行修剪。如果它满足第二个条件,在合并后它的邻居将和之前完全一样(除了p),因此我们并没有让修剪变得更难;如果有的话,反而可能会让修剪变得更容易。
合并h和p也不会让h的邻居变得更难修剪。唯一可能会这样做的方式是阻止我们修剪h本身,但任何通过合并得到的h的新邻居都将具有微不足道的度,因此不会影响我们修剪它的能力。
让我们再看一遍图 20-23 中的图表,看看为什么这样做有效:

之前,我们决定将tmp2合并到 EDI 是安全的,因为 EDI 无法溢出,并且这个变化不会让其他节点变得更难着色。但我们也看到这个情况没有通过布里格斯测试,因为 EDI 那时将有三个具有显著度的邻居。现在我们将改用乔治测试。这个测试会通过,因为tmp2的两个邻居已经与 EDI 发生干扰。我们的分配器将把tmp2合并到 EDI,因为它合并通过我们两个测试的所有移动操作。
对于我们的最后一个例子,让我们重新审视图 20-15 中的图表:

上次我们查看这个图表时,我们在考虑是否将arg合并到 EDI 中。现在我们知道我们不应该合并它们,因为这个情况没有通过我们的两个测试。它没有通过布里格斯测试,因为合并后的节点将有两个具有显著度的邻居,tmp和 EAX。它也没有通过乔治测试;arg的一个邻居,tmp,具有显著度,并且不会干扰 EDI。乔治测试告诉我们,如果我们进行这个变化,tmp可能会变得更难着色;我们会让它干扰到另一个寄存器,而我们无法预知这样会产生什么影响。在这个小例子中,通过查看图表,我们可以看出,如果我们将arg合并到 EDI 中,tmp实际上会变得更难着色,因为它会干扰到两个硬寄存器。
关于乔治测试,有一个不太美观的细节我想提一下。记得我们在每次合并决策后使用一种快速的近似方法来更新图形吗?这种近似方法可能会在寄存器之间留下实际上并不干扰的边。因此,当我们将乔治测试应用于寄存器p和h时,我们可能会误以为p的某个邻居n也与h干扰,尽管实际上并没有。接着我们可能会错误地得出结论,认为p和h通过了乔治测试并将它们合并。
听起来很糟糕,但它仅稍微削弱了乔治测试提供的保证。之前我提到过,乔治测试保证我们不会让合并节点的邻居变得更难修剪。实际上,它保证我们不会让它们比我们开始当前合并轮次之前更难修剪——也就是说,最后一次我们从头重建干扰图时的状态。
这个较弱的保证依然成立,因为我们的近似图会在n和h之间仅包含一条边,当且仅当n在我们构建干扰图时确实与h干扰,但某个早期的合并决策移除了这种干扰。本质上,如果某个早期的合并决策通过移除它们之间的边使得n或h更容易修剪,我们可能会因为再次添加这条边而意外地使它们更难修剪。然而,我们永远不会让事情变得比当前合并轮次之前更糟。(还值得记住的是,布里格斯和乔治测试的目的是提高性能,而不是保证正确性;即使是一个失败了这两个测试的“不好”合并决策,也不会改变程序的可观察行为。)
我们已经看过了两种保守的合并测试,它们提供了哪些保证,以及它们为何有效。现在我们只需要实现它们。
实现寄存器合并
我们的第一步是将构建合并循环添加到顶层寄存器分配算法中。列表 20-31 给出了更新后的算法,新增部分以粗体标出。
allocate_registers(instructions):
**while True:**
**interference_graph = build_graph(instructions)**
**coalesced_regs = coalesce(interference_graph, instructions)**
**if nothing_was_coalesced(coalesced_regs):**
**break**
**instructions = rewrite_coalesced(instructions, coalesced_regs)**
add_spill_costs(interference_graph, instructions)
color_graph(interference_graph)
register_map = create_register_map(interference_graph)
transformed_instructions = replace_pseudoregs(instructions, register_map)
return transformed_instructions
列表 20-31:将寄存器合并添加到顶层寄存器分配算法中
在这个循环中,我们构建干扰图,然后寻找需要合并的寄存器。如果找到,我们会重写汇编代码并重新开始整个过程。否则,我们退出循环,按照正常方式运行分配器的其余部分。
我们在coalesced_regs中记录了我们已经合并在一起的寄存器,这是一种不相交集合数据结构。让我们编写一个简单的实现,然后定义< samp class="SANS_TheSansMonoCd_W5Regular_11">coalesce和rewrite_coalesced函数。
不相交集合
顾名思义,不相交集合数据结构表示多个不相交的值集合。每个集合由一个代表元素标识。不相交集合支持两个操作:union 合并两个集合,find 查找集合的代表元素。在我们的案例中,每个集合中的值是 Reg 和 Pseudo 操作数。最初,每个寄存器都在自己的集合中。当我们合并寄存器时,我们将使用 union 操作将这些集合合并在一起。当我们重写汇编代码时,我们将使用 find 将每个寄存器替换为其集合的代表元素。
实现不相交集合有几种不同的方法。我们将使用一种简单的实现,易于理解。清单 20-32 定义了我们的实现。
init_disjoint_sets():
❶ return `<empty map>`
union(x, y, reg_map):
❷ reg_map.add(x, y)
find(r, reg_map):
❸ if r is in reg_map:
result = reg_map.get(r)
❹ return find(result, reg_map)
return r
nothing_was_coalesced(reg_map):
❺ if reg_map is empty:
return True
return False
清单 20-32:不相交集合的基本实现
我们使用一个映射来跟踪哪些集合已经合并在一起。最初,这个映射是空的 ❶。为了合并两个代表元素分别为 x 和 y 的集合,union 操作将从 x 到 y 插入一个映射 ❷。这使得 y 成为新集合的代表元素。当我们将伪寄存器合并到硬寄存器时,重要的是要使硬寄存器成为集合的代表元素;我们不希望在稍后重写代码时将硬寄存器替换为伪寄存器。
为了查找包含寄存器 r 的集合的代表成员,find 操作首先检查 r 是否映射到其他寄存器 ❸。如果没有,r 本身就是其集合的代表成员,我们就返回它。否则,查找映射会得到 result,这就是我们之前合并 r 到的寄存器。然后我们递归地调用 find ❹,这将引导我们沿着从 r 到其代表成员的映射链。例如,如果我们将 a 合并到 b,然后将 b 合并到 c,我们将按照 a 到 b 到 c 的映射关系,确定 c 是 a 的代表成员。
在这个列表中我们定义的最后一个操作是 nothing_was_coalesced,它只是检查映射是否为空 ❺。
coalesce 函数
coalesce 函数将检查汇编代码中的每条 mov 指令,决定哪些寄存器需要合并,并在我们刚刚定义的不相交集合结构中跟踪这些决策。让我们通过 Listing 20-33 来逐步了解,这个列表给出了该函数的伪代码。
coalesce(graph, instructions):
coalesced_regs = init_disjoint_sets()
for i in instructions:
match i with
| Mov(src, dst) ->
❶ src = find(src, coalesced_regs)
dst = find(dst, coalesced_regs)
❷ if (src is in graph
and dst is in graph
and src != dst
❸ and (not are_neighbors(graph, src, dst))
❹ and conservative_coalesceable(graph, src, dst)):
if src is a hard register:
to_keep = src
to_merge = dst
else:
to_keep = dst
to_merge = src
❺ union(to_merge, to_keep, coalesced_regs)
update_graph(graph, to_merge, to_keep)
| _ -> continue
return coalesced_regs
Listing 20-33: 决定哪些寄存器需要合并
我们首先初始化一个新的不相交集结构coalesced_regs,用于跟踪我们已经合并的寄存器。然后,我们遍历指令列表。当我们遇到一个Mov指令时,我们使用find查找它当前的源操作数和目标操作数❶,因为我们可能已经将src和dst合并到了其他寄存器。请注意,这些操作数可能是常量或内存地址,而不是寄存器。这样是可以的;如果x在coalesced_regs中没有映射,find(x, coalesced_regs)将直接返回x,无论
接下来,我们决定是否将指令的源操作数和目标操作数合并❷。首先,我们检查它们是否都在干扰图中。(这可以防止我们尝试合并常量或内存操作数。)然后,我们确保它们是两个不同的寄存器,因为没有理由将寄存器与自身合并。如果这些检查通过,我们将测试之前学习的两个条件:src和dst必须没有干扰❸,并且合并它们不能使图的着色变得更加困难❹。我们用conservative_coalesceable函数来检查第二个条件,稍后我们会详细介绍这个函数。
如果src和dst满足所有这些条件,我们将合并它们!现在我们需要决定在汇编代码中保留哪一个,替换哪一个。如果其中一个操作数是硬寄存器,我们将保留那个并替换另一个。如果它们都是伪寄存器,我们将任意选择保留dst。我们调用union来实际合并这些寄存器❺,然后更新干扰图。列表 20-34 定义了执行此更新的函数。
update_graph(graph, x, y):
node_to_remove = get_node_by_id(graph, x)
for neighbor in node_to_remove.neighbors:
add_edge(graph, y, neighbor)
remove_edge(graph, x, neighbor)
remove_node_by_id(graph, x)
列表 20-34:更新干扰图
该函数处理每个 x 的邻居,移除它与 x 之间的边,并添加一条连接到 y 的边。然后,它会从图中移除 x。
conservative_coalesceable 函数
现在其余的 coalesce 已经就位,让我们来看一下 清单 20-35,它定义了保守合并测试。
conservative_coalesceable(graph, src, dst):
❶ if briggs_test(graph, src, dst):
return True
❷ if src is a hard register:
return george_test(graph, src, dst)
if dst is a hard register:
return george_test(graph, dst, src)
return False
briggs_test(graph, x, y):
significant_neighbors = 0
x_node = get_node_by_id(graph, x)
y_node = get_node_by_id(graph, y)
combined_neighbors = set(x_node.neighbors)
combined_neighbors.add_all(y_node.neighbors)
for n in combined_neighbors:
neighbor_node = get_node_by_id(graph, n)
❸ degree = length(neighbor_node.neighbors)
if are_neighbors(graph, n, x) and are_neighbors(graph, n, y):
❹ degree -= 1
if degree >= k:
significant_neighbors += 1
❺ return (significant_neighbors < k)
george_test(graph, hardreg, pseudoreg):
pseudo_node = get_node_by_id(graph, pseudoreg)
for n in pseudo_node.neighbors:
❻ if are_neighbors(graph, n, hardreg):
continue
neighbor_node = get_node_by_id(graph, n)
❼ if length(neighbor_node.neighbors) < k:
continue
return False
return True
清单 20-35:保守合并测试
在 conservative_coalesceable 中,我们首先尝试布里格斯测试 ❶。然后,如果布里格斯测试失败,并且 src 或 dst 是硬寄存器,我们就尝试乔治测试 ❷。当我们使用乔治测试时,我们将确保将硬寄存器作为第一个参数传递,将伪寄存器作为第二个参数,因为该测试不会把这些寄存器视为可以互换的。
为了应用布里格斯测试,我们首先构造 combined_neighbors,它是与 x 或 y 发生冲突的节点集合。然后,我们遍历这个集合,查找每个邻居的度数 ❸。如果某个节点与它们两个都有冲突,那么合并 x 和 y 会使该节点的度数减少 1,因此我们会相应地调整 degree ❹。如果 combined_neighbors 中少于 k 个节点具有显著的度数 ❺,我们将返回 True。
为了应用乔治测试,我们遍历伪寄存器的邻居,确保每个邻居要么与硬寄存器有冲突 ❻,要么具有微不足道的度数 ❼。如果我们找到一个不满足任何条件的邻居,我们将返回 False。如果每个邻居都满足这两个条件,我们将返回 True。
rewrite_coalesced 函数
我们将通过重写汇编代码来结束。 清单 20-36 给出了这一步骤的伪代码。
rewrite_coalesced(instructions, coalesced_regs):
new_instructions = []
for i in instructions:
match i with
| Mov(src, dst) ->
src = find(src, coalesced_regs)
dst = find(dst, coalesced_regs)
❶ if src != dst:
new_instructions.append(Mov(src, dst))
| Binary(op, src, dst) ->
src = find(src, coalesced_regs)
dst = find(dst, coalesced_regs)
new_instructions.append(Binary(op, src, dst))
| `--snip--`
return new_instructions
清单 20-36:在决定合并哪些寄存器后重写指令
我们使用find操作来替换每条指令中的每个操作数。(在这里,像在coalesce中一样,我们依赖find来正确处理非寄存器。)如果一个Mov指令的更新源和目标相同,我们将从重写的代码中省略该指令❶。作为副作用,这也会移除那些在寄存器合并前就已经冗余的Mov指令。
就这样,你完成了你的寄存器分配器!我们不需要更改其他阶段,所以你可以开始测试它了。
总结
在本章中,你构建了一个寄存器分配器。你使用了你已经学到的关于生存性分析的知识,构建了一个干扰图,然后实现了一个经典的图着色算法来对其着色。你引入了被调用保存寄存器,并学会了如何保存和恢复它们。接着,你使用寄存器合并清理了编译器早期阶段留下的杂乱。你已经写完了最后的优化,并完成了这个项目!
在本书的过程中,你已经构建了一款令人印象深刻的软件:一个优化编译器,能够处理 C 语言的一个重要部分。你已经涵盖了大量内容,从 C 标准的复杂性到 System V 调用约定的细节,再到数据流分析的基础知识。但如果你想进一步提升你的编译器,你有很多选择。我将以一些关于接下来可以进行的工作的建议来结束本书的这一部分。
附加资源
本章中你构建的寄存器分配器使用了经典的Chaitin-Briggs 算法的简化版本。本节告诉你在哪里可以找到有关该算法的原始论文,几章以更易理解的方式呈现这些内容的教科书章节,以及一些关于更具体主题的其他有用参考资料。
关键论文
-
“通过着色进行寄存器分配”,是 Gregory Chaitin 等人在 1981 年发表的论文,描述了原始的图着色寄存器分配器(
<wbr>doi<wbr>.org<wbr>/10<wbr>.1016<wbr>/0096<wbr>-0551(81)90048<wbr>-5)。它介绍了本章中的大部分基本概念,包括如何构建和着色干扰图。 -
Chaitin 于 1982 年发布了对同一分配器的更新描述,“通过图着色进行寄存器分配与溢出”(
<wbr>doi<wbr>.org<wbr>/10<wbr>.1145<wbr>/872726<wbr>.806984)。 -
“图着色寄存器分配的改进”是 Preston Briggs、Keith Cooper 和 Linda Torczon 于 1994 年发表的一篇论文,描述了 Chaitin 设计的改进版本 (
doi.org/10.1145/177492.177575)。Chaitin-Briggs 这个名字指的就是这种改进后的算法。本文提出了一种将溢出候选项放到栈上并尝试稍后着色,而不是立即溢出的技术。(Briggs 等人称这种技术为乐观着色。)它还引入了 Briggs 测试和保守合并的概念;Chaitin 的原始设计即使在使图变得更难着色时也会进行激进的合并。本文还描述了一些我们在这一章中没有涉及的技术,比如重新材料化技术。
教科书章节
-
Steven Muchnick 的《高级编译器设计与实现》第十六章(Morgan Kaufmann,1997 年)介绍了一种类似于 Chaitin-Briggs 的寄存器分配器。最显著的区别在于它没有使用保守合并;与 Chaitin 的原始分配器一样,它进行了激进的合并。我发现 Muchnick 关于如何在干扰图中包含硬寄存器、如何检测干扰以及分配器的整体结构的解释尤其有用。
-
Keith Cooper 和 Linda Torczon 的《工程化编译器》第十三章(第二版,Morgan Kaufmann,2011 年)对多种寄存器分配方法进行了出色的概述,其中包括 Chaitin-Briggs 算法以及一些我们在这里未讨论的其他方法。我参考了他们对干扰的定义,以及他们讨论的在合并过程中如何更新干扰图的内容;他们特别清楚地解释了为何需要同时进行快速但不精确的更新和缓慢但完全的更新。(你也可以参考该书的第三版,2022 年出版。)
注
如果你想实现我们跳过的 Chaitin-Briggs 部分,这两本资源都非常有用。如果你想将溢出代码生成集成到你的寄存器分配器中,Muchnick 的章节尤其有用。这两本书都讨论了如何使用活动区间(Muchnick 称之为webs)而不是伪寄存器作为干扰图中的节点,并且它们提供了比我们使用的溢出成本度量更好的方法。
保守合并
-
George 测试来源于 Lal George 和 Andrew Appel 的论文“迭代寄存器合并”(
doi.org/10.1145/229542.229546)。论文的主要观点是,如果你在合并和修剪之间交替进行,就能合并更多的寄存器;George 测试作为一个次要的实现细节进行了介绍。 -
关于 George 和 Briggs 测试的非正式讨论以及大量实例,见 Phillip Gibbons 在卡内基梅隆大学编译优化课程中的幻灯片 (
www.cs.cmu.edu/afs/cs/academic/class/15745-s19/www/lectures/L23-Register-Coalescing.pdf)。 -
Max Hailperin 的《比较保守合并标准》严谨地定义了 Briggs 和 George 测试实际证明了什么,而这是原始论文的作者们从未费心做过的事情 (
doi.org/10.1145/1065887.1065894)。我在《保守合并》一文开头关于这些测试所保证内容的讨论,主要依赖于 Hailperin 的论文。请注意,他的某些主张不适用于我们的图着色实现,因为他使用了预着色,而我们没有。
识别循环
要计算更准确的溢出成本,你需要检测程序中的循环。这些资源讲述了如何识别循环:
-
编译器:原理、技术与工具(第 2 版),Alfred Aho 等人著,章节 9,第六部分(Addison-Wesley,2006 年)。
-
Phillip Gibbons 关于归纳变量优化的讲座幻灯片,来自他在卡内基梅隆大学的编译优化课程 (
www.cs.cmu.edu/afs/cs/academic/class/15745-s19/www/lectures/L8-Induction-Variables.pdf)。这是一个很好的起点,但这些幻灯片的细节不够丰富,无法单独作为循环分析的指南。你可能需要将它们与前述参考资料或其他教科书一起使用。
第二十一章:下一步

编程语言的世界很广阔,你还有很多东西可以探索。自己扩展编译器是一个很好的方式,继续学习你最感兴趣的主题。
我将给你一些思路,帮助你开始。
添加一些缺失的特性
最明显的下一步是实现这本书没有涵盖的 C 的主要部分。如果你已经有了特别想要添加的特性列表,从那些开始。然后,如果你想继续下去,选择一个实际的 C 程序——想想一个小的程序,不是 Linux 内核——并扩展你的编译器,直到它能够成功编译该程序。你可以选择另一个程序,并重复这个过程,直到你对自己实现的语言部分感到满意。确保一次添加一个新的语言特性,在继续下一个之前彻底测试每一个。
安全处理未定义行为
我们已经看到,C 编译器可以随意处理未定义行为。但仅仅因为你可以做某件事,并不意味着你应该做。以一种清晰、可预测的方式处理未定义行为有巨大的好处:它使 C 程序更加安全,调试更加容易,并且总体上不那么可怕。例如,你可以保证有符号整数溢出总是会回绕(这就是 -fwrapv 编译器选项的作用)。或者你可以让程序在遇到未定义行为时抛出错误并退出;Clang 和 GCC 都有一个叫做 UndefinedBehaviorSanitizer 的特性,支持这种错误处理(<wbr>clang<wbr>.llvm<wbr>.org<wbr>/docs<wbr>/UndefinedBehaviorSanitizer<wbr>.html)。
想想我们在本书中讨论过的一些未定义行为的例子。你认为你的编译器应该如何处理这些?这会如何影响你实现的任何优化?某些类型的未定义行为很难检测,但其他类型则不太难处理;选择一个看起来可以处理的例子,看看你是否能干净地处理它。
编写更多 TACKY 优化
第十九章仅介绍了你在生产级编译器中会遇到的一些 IR 优化。如果你愿意,你可以自己实现更多。做一些关于常见编译器优化的研究,并挑选出最有趣的那些。如果你走这条路,你可能想将你的 TACKY 代码转换为静态单赋值(SSA)形式,在这种形式下,每个变量只会被定义一次。SSA 形式在现实世界的编译器中被广泛使用,包括 Clang 和 GCC,因为它使得许多优化的实现更加容易。
支持另一个目标架构
大多数生产级编译器都有多个不同的后端,以支持不同的目标架构。
你可以使用相同的策略,根据你所针对的系统将 TACKY 转换为不同的汇编代码。如果你使用 Windows 或 ARM 系统,并且需要一个虚拟化或仿真层来完成这个项目,一个新的后端将让你编译出能在本机上原生运行的代码。
如果你为 Windows 添加了支持,你将能够重用大部分现有的代码生成过程。唯一不同的就是 ABI。添加 ARM 后端是一个更具挑战性的项目;你需要学习一个全新的指令集。
为一个开源编程语言项目做贡献
改进你自己的编译器是学习的好方法,但也可以考虑扩展到其他项目上。许多广泛使用的编译器都是开源的,并欢迎新的贡献者。其他相关项目,比如解释器、代码检查工具和静态分析工具,也都如此。选择一个你喜欢的,了解如何参与进来。这是一个很好的方式来应用你学到的新技能,也许还能让你最喜欢的编程语言变得更快、更安全、更易用,或者更容易学习。
结束语!
我希望这本书为你打下了继续构建编译器和编程语言的基础。我也希望它改变了你对日常使用的编程语言的看法。现在你会更能欣赏那些编程语言背后所投入的心血、努力和独创性,当遇到问题时,你也不会害怕深入了解语言内部,弄清楚到底发生了什么。编译器不再看起来像魔法,而是变得像一些更有趣的东西:普通的软件。
第二十二章:A 使用 GDB 或 LLDB 调试汇编代码

在某个时刻,你的编译器可能会生成不正确行为的汇编代码,这时你需要找出原因。当发生这种情况时,命令行调试器对于理解问题所在至关重要。调试器可以让你暂停运行中的程序,逐条执行指令,并在不同的时刻检查程序状态。你可以使用 GDB(GNU 调试器)或 LLDB(LLVM 项目的调试器)来调试编译器生成的汇编代码。如果你使用的是 Linux,建议使用 GDB;如果你使用的是 macOS,则建议使用 LLDB(我认为 GDB 在处理汇编时的 UI 略好,但在 macOS 上运行它可能会有些挑战)。
本附录是一个简短的指南,介绍如何使用 GDB 或 LLDB 调试汇编程序。它介绍了如果你以前没有使用过调试器,你需要了解的基础知识。它还涵盖了调试汇编代码时你需要使用的最重要命令和选项,即使你已经熟悉使用这些工具调试源代码,某些命令的细节可能对你来说是新的。我为这两个调试器分别提供了单独的操作流程;尽管它们具有非常相似的功能,但许多命令的细节是不同的。请根据你使用的调试器,遵循相应的操作流程。
在开始之前,你应该熟悉第一章和第二章中涵盖的汇编代码基础知识。后续章节中的一些汇编内容也会涉及,但如果你还没有学习到那些内容,可以暂时跳过。
程序
我们将使用清单 A-1 中的汇编程序作为示例进行演示。
.data
.align 4
❶ integer:
.long 100
.align 8
❷ dbl:
.double 3.5
.text
.globl main
❸ main:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
# call a function
❹ callq f
❺ # put some stuff in registers
movl $0x87654321, %eax
movsd dbl(%rip), %xmm0
# put some stuff on the stack
movl $0xdeadbeef, -4(%rbp)
movl $0, -8(%rbp)
movl $-1, -12(%rbp)
movl $0xfeedface, -16(%rbp)
❻ # initialize loop counter
movl $25, %ecx
.L_loop_start:
# decrement counter
subl $1, %ecx
cmpl $0, %ecx
# jump back to start of loop
jne .L_loop_start
# return 0
movl $0, %eax
movq %rbp, %rsp
popq %rbp
ret
.text
.globl f
f:
movl $1, %eax
ret
.section .note.GNU-stack,"",@progbits
清单 A-1:一个无意义的汇编程序
这个程序没有做任何有用的事情,它只是给我们提供了一个机会来尝试调试器的最重要功能。它包括几个静态变量,供我们检查:integer ❶ 和 dbl ❷。在 main ❸ 中,首先调用了一个非常小的函数 f,这样我们就可以练习进入和退出函数调用 ❹,然后将一些数据移动到寄存器和栈中,以便我们可以练习检查程序状态 ❺。程序最后包含一个循环,每次迭代都会递减 ECX,直到它达到 0 ❻。我们将利用这个循环来练习设置条件断点。
从 <wbr>norasandler<wbr>.com<wbr>/book<wbr>/#appendix<wbr>-a 下载该程序,然后将其保存为 hello_debugger.s。该文件有两个不同版本,分别适用于 Linux 和 macOS,因此请确保选择适合您操作系统的版本。
保存文件后,组装并链接它,并确认它是否能够运行:
$ **gcc hello_debugger.s -o hello_debugger**
$ **./hello_debugger**
在 macOS 上,组装并链接文件时,请包含 -g 选项:
$ **gcc -g hello_debugger.s -o hello_debugger**
-g 选项会生成额外的调试信息。确保在组装和链接您自己的编译器汇编输出时也包含此选项以便调试。
现在,您可以开始逐步讲解。如果您使用 GDB,请按照下一部分的说明进行操作。如果您使用 LLDB,请跳转到 第 687 页的“使用 LLDB 调试”部分。
使用 GDB 调试
运行以下命令启动 GDB:
$ **gdb hello_debugger**
`--snip--`
(gdb)
这将设置 hello_debugger 为要调试的可执行文件,但并不实际执行它。在开始运行此可执行文件之前,我们来配置 UI,以便更方便地处理汇编代码。
配置 GDB UI
在 GDB 会话期间,您可以打开不同的文本窗口,以显示运行程序的不同信息。对于我们的目的来说,最重要的是 汇编窗口,它会在我们逐步执行时显示汇编代码。寄存器窗口 也很有用;默认情况下,它显示每个通用寄存器的当前内容。
layout 命令控制哪些窗口是可见的。让我们打开汇编窗口和寄存器窗口:
(gdb) **layout asm**
(gdb) **layout reg**
现在,您应该能在终端中看到三个窗口:寄存器窗口、汇编窗口和带有 (gdb) 提示符的命令窗口。它应该类似于 图 A-1。

图 A-1:一个 GDB 会话,打开了汇编窗口和寄存器窗口 描述
在启动程序之前,寄存器窗口不会显示任何信息。
您可以在当前聚焦的窗口中滚动。使用 focus 命令来更改聚焦窗口:
(gdb) **focus cmd**
(gdb) **focus asm**
(gdb) **focus regs**
启动与停止程序
接下来,我们将设置一个 断点—即调试器暂停程序执行的位置—并运行程序直到该断点。如果我们在未设置断点的情况下启动程序,它将直接执行到底部,这样不太有用。
命令 break
(gdb) **break main**
Breakpoint 1 at 0x112d
现在我们开始程序:
(gdb) **run**
Starting program: /home/ubuntu/hello_debugger
❶ Breakpoint 1, 0x000055555555512d in main ()
该命令的输出告诉我们程序已经命中我们刚设置的断点 ❶。请注意,当前的指令在汇编窗口中被高亮显示,通用寄存器的当前值也在寄存器窗口中显示,如图 A-2 所示。

图 A-2:程序在断点处停止时的 GDB 会话 描述
一旦程序暂停,你可以使用一些命令来让程序继续执行:
continue 恢复程序,并运行直到我们遇到另一个断点或退出。
finish 恢复程序,并在我们从当前函数返回时再次暂停。
stepi 执行下一条指令,然后暂停。它会进入 call 指令,在被调用函数的第一条指令处暂停。命令 stepi
nexti 执行下一条指令,然后暂停。它会跳过 call 指令,在当前函数中 call 后的下一条指令处暂停。命令 nexti
大多数 GDB 指令可以缩写为一到两个字母:你可以输入 c 来代替 continue,输入 b 来代替 break,输入 si 来代替 stepi,等等。表 A-1 以及 第 687 页 给出了我们讨论的所有命令的完整和简写版本。
警告
虽然 nexti 和 stepi 命令可以逐步执行汇编指令,但 next 和 step 命令是逐行执行原始源代码文件中的代码。由于我们没有原始源代码文件的信息,输入其中一个命令将导致程序运行直到当前函数的结束。这些命令分别简写为 n 和 s,因此在你打算使用 nexti 或 stepi 时,容易不小心执行它们。
让我们试试新的命令。首先,我们将执行两条指令,这将使我们进入对 f 的调用:
(gdb) **stepi 2**
0x0000555555555176 in ❶ f ()
从命令输出 ❶ 和汇编窗口中高亮的指令来看,我们被停在了 f 而不是 main。接下来,我们将从 f 返回:
(gdb) **finish**
Run till exit from #0 0x0000555555555176 in f ()
0x0000555555555136 in main ()
现在我们回到了 main,在 callq 后面的指令。让我们继续:
(gdb) **continue**
Continuing.
[Inferior 1 (process 82326) exited normally]
由于我们没有触发更多的断点,程序运行直到退出。为了继续调试,我们需要重新启动它:
(gdb) **run**
Starting program: /home/ubuntu/hello_debugger
Breakpoint 1, 0x000055555555512d in main ()
现在我们再次在 main 的起始位置暂停。我们将再向前执行两条指令,但这次我们将使用 nexti 来跳过 f,而不是进入它:
(gdb) **nexti 2**
0x0000555555555136 in main ()
这将把我们带回到 callq 后面的指令。
按地址设置断点
除了在函数上设置断点,你还可以在特定的机器指令上设置断点。我们将设置一个在指令 movl 0xdeadbeef, -4(%rbp) 上的断点。首先,我们将在汇编窗口中找到这个指令。它应该看起来像这样:
❶ 0x555555555143 ❷ <main+26> movl 0xdeadbeef, -4(%rbp)
指令在内存中的地址位于行的开始 ❶,后面是该地址相对于函数起始位置的字节偏移量 ❷。确切的地址可能在你的机器上有所不同,但偏移量应该是相同的。要设置此断点,你可以输入以下任一命令
(gdb) **break *main+26**
或者
(gdb) **break ***`**MEMORY_ADDRESS**`
其中 MEMORY_ADDRESS 是你在汇编窗口中找到的地址。* 符号告诉 GDB 我们指定的是一个精确的地址,而不是函数名。
管理断点
让我们列出所有已设置的断点:
(gdb) **info break**
Num Type Disp Enb Address What
1 breakpoint keep y 0x000055555555512d <main+4>
breakpoint already hit 1 time
2 breakpoint keep y 0x0000555555555143 <main+26>
每个断点都有一个唯一的编号,如果需要删除、禁用或修改它,你可以参考这个编号。我们来删除断点 1:
(gdb) **delete 1**
接下来,我们将介绍几种不同的方式来检查程序的状态。### 打印表达式
你可以使用命令 print/
让我们尝试一些示例。现在,程序应该已经在指令 movl 0x87654321, %eax 处暂停。我们将逐步执行这条指令,然后以几种不同的格式打印出 EAX 的值:
(gdb) **stepi**
`--snip--`
(gdb) **print $eax**
$1 = ❶ -2023406815
(gdb) **print/x $eax**
$2 = ❷ 0x87654321
(gdb) **print/u $eax**
$3 = ❸ 2271560481
默认情况下,GDB 会将通用寄存器中的值格式化为有符号整数❶。在这里,我们还将 EAX 的值以十六进制❷和无符号整数❸的形式显示。符号 $1、$2 等是 便捷变量,是 GDB 自动生成的,用来存储每个表达式的结果。
你可以在 x 命令的文档中找到完整的格式说明符列表,我们稍后会详细介绍:
(gdb) **help x**
`--snip--`
Format letters are o(octal), x(hex), d(decimal), u(unsigned decimal),
t(binary), f(float), a(address), i(instruction), c(char), s(string)
and z(hex, zero padded on the left).
`--snip--`
第十三章介绍了 XMM 寄存器,它们用于存储浮点值。我们程序中的下一条指令 movsd dbl(%rip), %xmm0 将静态变量 dbl 中的值 3.5 复制到 XMM0 寄存器中。
让我们逐步执行这条指令,然后检查 XMM0:
(gdb) **stepi**
`--snip--`
(gdb) **print $xmm0**
$4 = {v4_float = {0, 2.1875, 0, 0}, v2_double = {3.5, 0}, v16_int8 = {0, 0, 0, 0,
0, 0, 12, 64, 0, 0, 0, 0, 0, 0, 0, 0}, v8_int16 = {0, 0, 0, 16396, 0, 0, 0, 0},
v4_int32 = {0, 1074528256, 0, 0}, v2_int64 = {4615063718147915776, 0}, uint128 =
4615063718147915776}
GDB 向我们展示了相同数据的多种不同视图:v4_float 以四个 32 位浮点数组的形式显示这个寄存器的内容,v2_double 以 64 位双精度浮点数组的形式显示它,等等。由于我们将仅使用 XMM 寄存器来存储单个双精度浮点数,你可以通过类似这样的命令来检查它们:
(gdb) **print $xmm0.v2_double[0]**
$5 = 3.5
这会打印出寄存器低 64 位的值,并将其解释为双精度浮点数。
除了寄存器,我们还可以打印符号表中对象的值。让我们检查程序中的两个静态变量,integer和dbl:
(gdb) **print (long) integer**
$6 = 100
(gdb) **print (double) dbl**
$7 = 3.5
由于 GDB 对这些对象的类型没有任何信息,我们必须显式地将它们强制转换为正确的类型。
让我们看一些更复杂表达式的示例。除了直接引用硬件寄存器之外,这些表达式都使用普通的 C 语法。
我们可以执行基本的算术运算:
(gdb) **print/x $eax + 0x10**
$8 = 0x87654331
我们可以调用当前程序或标准库中定义的函数。在这里,我们调用f,它返回1:
(gdb) **print (int) f()**
$9 = 1
我们还可以解引用指针。让我们执行下一个指令,movl 0xdeadbeef, -4(%rbp),然后检查-4(%rbp)处的值:
(gdb) **stepi**
`--snip--`
(gdb) **print/x *(int *)($rbp - 4)**
$10 = 0xdeadbeef
首先,我们计算出要检查的内存地址,$rbp - 4。然后,我们将此地址强制转换为正确的指针类型,(int )。最后,我们使用解引用操作符对其进行解引用。这将得到一个整数,我们使用/x说明符以十六进制打印出来。
接下来,我们将看看一种更灵活的检查内存中值的方法。
检查内存
我们可以通过命令x/
让我们使用 x 命令来检查 -4(%rbp) 处的整数:
(gdb) **x/1xw ($rbp - 4)**
❶ 0x7fffffffe2ac: ❷ 0xdeadbeef
这个命令告诉 GDB 打印出一个 4 字节的十六进制数字。输出包括内存地址 ❶ 和该地址上的值 ❷。
在 清单 A-1 中的下三条指令将三个整数存储到栈上:
movl $0, -8(%rbp)
movl $-1, -12(%rbp)
movl $0xfeedface, -16(%rbp)
我们将使用 清单 A-2 中的命令逐步执行这些指令,然后打印出整个栈帧。
(gdb) **stepi 3**
(gdb) **x/6xw $rsp**
0x7fffffffe2a0: ❶ 0xfeedface 0xffffffff 0x00000000 ❷ 0xdeadbeef
0x7fffffffe2b0: ❸ 0x00000000 0x00000000
清单 A-2:前进三条指令,然后打印当前栈帧
命令 x/6xw $rsp 告诉 GDB 从 RSP 地址开始打印六个 4 字节的字。我们打印六个字是因为这个特定函数的栈帧是 24 字节的。在 main 开始时,我们将 RBP 的旧值保存在栈上。这是 8 字节。然后,我们使用命令 subq $16, %rsp 再分配了 16 字节。请记住,RSP 总是保存栈顶的地址,也就是最低的栈地址。
这个命令显示了我们保存到栈上的四个整数,顶部是 0xfeedface ❶,底部是 0xdeadbeef ❷,接着是 RBP 的旧值 ❸。在某些系统中,这个值会是 0,因为我们处于最外层的栈帧;而在其他系统中,它会是一个有效的内存地址。
保存的 RBP 值位于当前栈帧的底部。紧接着,在调用者的栈帧顶部,我们将找到调用者的返回地址——也就是我们从 main 返回时跳转到的地址。(我们在 第九章 中详细讲解了这一点。)让我们检查这个返回地址:
(gdb) **x/4ag $rsp**
0x7fffffffe2a0: 0xfffffffffeedface 0xdeadbeef00000000
0x7fffffffe2b0: 0x0 ❶ 0x7ffff7dee083 <__libc_start_main+243>
这个命令将打印出四个 8 字节的“巨型”字,首先是 RSP 地址上的值。a 修饰符告诉 GDB 以内存地址的格式输出这些值;这意味着它会以十六进制打印每个地址,并且如果可能,还会打印出它与程序中最近的符号的偏移量。由于函数和静态变量的名称在符号表中已定义,GDB 可以显示汇编指令和静态数据的相对偏移。它不会显示栈地址、堆地址或无效地址的相对偏移,因为这些完全没有意义。
输出的第一行包括我们保存在栈上的四个整数,现在它们显示为两个 8 字节的值,而不是四个 4 字节的值。下一行中的空指针 0x0 是保存的 RBP 值。由于这三个位 8 字节的值都不是有效的地址,所以 GDB 无法显示它们与符号的偏移量。栈上的下一个值是返回地址 ❶。GDB 告诉我们这是 _libc_start_main 中一条指令的地址,该函数负责调用 main 并在它退出后进行清理。
a 修饰符可以帮助我们轻松识别返回地址和指向静态变量的指针。如果你的程序的栈帧已经损坏,找到每个栈帧的返回地址会帮助你定位当前的执行位置。
设置条件断点
在这次操作演示的最后,我们将介绍如何设置 条件断点。程序只会在条件为真时暂停在条件断点处。这个条件可以是任意表达式;如果该表达式的结果为 0,GDB 会认为其为假,否则为真。
我们将在 hello_debugger 中最后一次循环迭代的 jne 指令上设置一个断点。首先,我们需要在汇编窗口中找到这个指令。它应该在函数开始后的第 65 字节位置:
0x55555555516a <main+65> jne 0x555555555164 <main+59>
我们将设置一个条件断点,当 ECX 为 0 时暂停在该指令处:
(gdb) **break *main+65 if $ecx == 0**
由于该循环会重复执行,直到 ECX 为 0,因此条件 $ecx == 0 只有在最后一次迭代时才会为真。让我们继续执行直到这个断点,然后验证该条件是否为真:
(gdb) **c**
Continuing.
Breakpoint 3, 0x000055555555516a in main ()
(gdb) **print $ecx**
$11 = 0
到目前为止,一切正常。如果你得到的 ECX 值不同,检查一下你是否正确设置了断点:
(gdb) **info break**
`--snip--`
3 breakpoint keep y 0x000055555555516a ❶ <main+65>
stop only if ❷ $ecx == 0
确保你的断点位于 main+65 ❶ 处,并且包含条件 $ecx == 0 ❷。若你的断点不同,可能是输入错误,删除后再试一次。
我们应该处于最后一次循环迭代,因此让我们执行一步指令并确保跳转没有发生:
(gdb) **stepi**
通常,jne 会跳回循环的起始位置,但在最后一次迭代时,它会跳到下一条指令。
获取帮助
要了解我们在此未涵盖的命令和选项,请参阅 GDB 文档,访问 <wbr>sourceware<wbr>.org<wbr>/gdb<wbr>/current<wbr>/onlinedocs<wbr>/gdb<wbr>/index<wbr>.html。正如你之前看到的,你也可以在提示符下输入 help 来了解更多有关 GDB 命令的信息。例如,要查看 run 命令的文档,请输入:
(gdb) **help run**
Start debugged program.
You may specify arguments to give it.
`--snip--`
表 A-1 总结了我们所介绍的命令和选项,包括每个命令的完整形式和简写形式(除了 x,它无法进一步简化)。这两种形式接受相同的参数。
表 A-1: GDB 命令汇总
| 命令 | 描述 |
|---|---|
| run | 启动程序。 |
| r | |
| continue | 恢复程序。 |
| c | |
| finish | 恢复程序,并继续执行直到当前函数退出。 |
| fin | |
| stepi [ |
执行一条指令(或 n 条指令),进入函数调用。 |
| si | |
| nexti [ |
执行一条指令(或 n 条指令),跳过函数调用。 |
| ni | |
| break |
在 |
| b | |
| info break | 列出所有断点。(其他的 info 子命令显示其他信息。) |
| i b | |
| delete [ |
删除所有断点(或删除由 |
| d | |
| print/ |
求值 |
| p | |
| x/ |
从 |
| layout |
打开 |
| la | |
| focus |
将焦点更改为 |
| fs | |
| help |
显示关于 |
| h |
现在,您准备好使用 GDB 开始调试了!
使用 LLDB 调试
运行此命令以启动 LLDB:
$ **lldb hello_debugger**
(lldb) target create "hello_debugger"
Current executable set to 'hello_debugger' (x86_64).
(lldb)
这将设置hello_debugger为调试的可执行文件,但不会立即执行它。如果提示,请输入您的用户名和密码以授权 LLDB 控制hello_debugger。
启动和停止程序
接下来,我们将设置一个断点——调试器将暂停程序的位置——并运行程序直到该断点。如果我们在没有首先设置断点的情况下启动程序,它将一直运行到结束,这样就没什么用处了。
让我们在main入口处设置一个断点:
(lldb) **break set -n main**
Breakpoint 1: where = hello_debugger`main, address = 0x0000000100003f65
请注意,main函数可能在您机器上的内存地址不同。break set命令创建了一个新的断点;-n选项指定了我们希望设置断点的函数名称。稍后我们将了解其他设置断点的方法。
现在让我们运行程序:
(lldb) **run**
Process 6750 launched: '/Users/me/hello_debugger' (x86_64)
Process 6750 stopped
* thread #1, queue = 'com.apple.main-thread', ❶ stop reason = breakpoint 1.1
frame #0: 0x0000000100003f65 hello_debugger`main
❷ hello_debugger`main:
❸ -> 0x100003f65 <+0>: pushq %rbp
0x100003f66 <+1>: movq %rsp, %rbp
0x100003f69 <+4>: subq $0x10, %rsp
0x100003f6d <+8>: callq 0x100003fb2 ; f
Target 0: (hello_debugger) stopped.
(lldb)
stop reason ❶ 告诉我们程序已命中我们刚设置的断点。LLDB 还贴心地告诉我们,我们已暂停在main函数中,并打印出接下来的几条汇编指令 ❸。
一旦程序被暂停,我们可以使用一些命令继续执行它:
continue 恢复程序运行,直到遇到另一个断点或程序退出。
finish 恢复程序运行,当我们从当前函数返回时再次暂停。
stepi 执行下一条指令,然后暂停。它会进入 call 指令,暂停在被调用函数中的第一条指令。命令 stepi -c
nexti 执行下一条指令,然后暂停。它会跳过 call 指令,暂停在当前函数中 call 后的下一条指令。命令 nexti -c
大多数 LLDB 命令都有多个别名。例如,continue 是 process continue 的快捷方式,甚至可以进一步简化为一个字母命令 c。有关我们涵盖的所有命令的完整版本和简化版本,请参阅 表 A-2 和 第 697 页。
让我们尝试这些新命令。首先,我们将执行四条指令,应该会进入对 f 函数的调用:
(lldb) **stepi -c 4**
`--snip--`
❶ hello_debugger`f:
-> 0x100003fb2 <+0>: movl $0x1, %eax
`--snip--`
从命令输出中可以看到,我们停在了 f 函数而不是 main ❶。现在我们将从 f 返回:
(lldb) **finish**
`--snip--`
hello_debugger`main:
-> 0x100003f72 <+13>: movl $0x87654321, %eax ; imm = 0x87654321
`--snip--`
这将我们带回到 main,并停在 callq 后的指令。让我们继续:
(lldb) **continue**
Process 6750 resuming
Process 6750 exited with status = 0 (0x00000000)
由于我们没有遇到更多的断点,程序一直运行直到退出。要继续调试它,我们必须重新启动程序:
(lldb) **run**
现在我们再次在 main 函数的开头暂停。我们将再次向前执行四条指令,但这次我们将使用 nexti 跳过 f,而不是进入它:
(lldb) **nexti -c 4**
`--snip--`
hello_debugger`main:
-> 0x100003f72 <+13>: movl $0x87654321, %eax ; imm = 0x87654321
`--snip--`
这将我们带回到 callq 后的指令。
按地址设置断点
除了在函数上设置断点之外,你还可以在特定的机器指令上设置断点。我们来在指令movl 0xdeadbeef, -4(%rbp)上设置一个断点。首先,我们需要找到该指令的地址。幸运的是,LLDB 已经给出了这个信息。上一条命令的输出应该像这样:
hello_debugger`main:
-> 0x100003f72 <+13>: movl $0x87654321, %eax ; imm = 0x87654321
0x100003f77 <+18>: movsd 0x181(%rip), %xmm0 ; dbl, xmm0 = mem[0],zero
❶ 0x100003f7f ❷ <+26>: movl $0xdeadbeef, -0x4(%rbp) ; imm = 0xDEADBEEF
0x100003f86 <+33>: movl $0x0, -0x8(%rbp)
这显示了接下来的几条指令,包括我们想要设置断点的那条。我们可以看到该指令的内存地址❶以及该地址相对于函数开始位置的字节偏移量❷。准确的地址在你的机器上可能不同,但偏移量应该是相同的。要设置这个断点,输入
(lldb) **break set -a** `**MEMORY_ADDRESS**`
其中MEMORY_ADDRESS是该指令在你机器上的地址。-a选项表示我们指定的是地址而不是函数名称。我们还可以使用更复杂的表达式来指定指令的地址。以下是另一种设置断点在同一指令上的方法:
(lldb) **break set -a '(void()) main + 26'**
首先,我们将main转换为函数类型,这样 LLDB 就可以在地址计算中使用它。(我们可以将其转换为任何函数类型。)然后,我们加上 26 字节的偏移量,以获取我们想要在其上设置断点的movl指令的地址。由于这个地址表达式包含空格和特殊字符,我们必须将整个表达式用引号括起来。
一会儿我们将看到如何反汇编整个函数,并查看每条指令的地址。首先,让我们看看一些管理断点的其他有用命令。
管理断点
让我们列出所有已经设置的断点:
(lldb) **break list**
Current breakpoints:
1: name = 'main', locations = 1, resolved = 1, hit count = 1
1.1: where = hello_debugger`main, address = 0x0000000100003f65, resolved, hit count = 1
2: address = hello_debugger[0x0000000100003f7f], locations = 1, resolved = 1, hit count = 0
2.1: where = hello_debugger`main + 26, address = 0x0000000100003f7f, resolved, hit count = 0
3: address = hello_debugger[0x0000000100003f7f], locations = 1, resolved = 1, hit count = 0
3.1: where = hello_debugger`main + 26, address = 0x0000000100003f7f, resolved, hit count = 0
每个断点都有一个唯一的编号,如果你需要删除、禁用或修改它,可以通过这个编号来引用。在上一节中,我们在相同的位置main+26设置了断点 2 和 3。我们来删除其中一个:
(lldb) **break delete 3**
接下来,我们将看看如何显示一个函数中的所有汇编指令,以及它们的地址。
显示汇编代码
命令disassemble -n
(lldb) **disassemble -n main**
hello_debugger`main:
0x100003f65 <+0>: pushq %rbp
0x100003f66 <+1>: movq %rsp, %rbp
0x100003f69 <+4>: subq $0x10, %rsp
0x100003f6d <+8>: callq 0x100003fb2 ; f
-> 0x100003f72 <+13>: movl $0x87654321, %eax ; imm = 0x87654321
0x100003f77 <+18>: movsd 0x181(%rip), %xmm0 ; dbl, xmm0 = mem[0],zero
0x100003f7f <+26>: movl $0xdeadbeef, -0x4(%rbp) ; imm = 0xDEADBEEF
0x100003f86 <+33>: movl $0x0, -0x8(%rbp)
0x100003f8d <+40>: movl $0xffffffff, -0xc(%rbp) ; imm = 0xFFFFFFFF
0x100003f94 <+47>: movl $0xfeedface, -0x10(%rbp) ; imm = 0xFEEDFACE
0x100003f9b <+54>: movl $0x19, %ecx
0x100003fa0 <+59>: subl $0x1, %ecx
0x100003fa3 <+62>: cmpl $0x0, %ecx
0x100003fa6 <+65>: jne 0x100003fa0 ; <+59>
0x100003fa8 <+67>: movl $0x0, %eax
0x100003fad <+72>: movq %rbp, %rsp
0x100003fb0 <+75>: popq %rbp
0x100003fb1 <+76>: retq
(lldb)
-> 符号指向当前指令。我们还可以打印出固定数量的指令,从特定地址开始。让我们从 main 中的第三条指令开始,反汇编五条指令。在这里显示的反汇编代码中,该指令的地址是 0x100003f69;在您的机器上,它可能有不同的地址。-s 选项指定了 LLDB 开始反汇编的地址,-c 选项指定了要显示的指令数量,因此我们将使用以下命令反汇编这五条指令:
(lldb) **disassemble -s 0x100003f69 -c 5**
hello_debug`main:
0x100003f69 <+4>: subq $0x10, %rsp
0x100003f6d <+8>: callq 0x100003fb2 ; f
-> 0x100003f72 <+13>: movl $0x87654321, %eax ; imm = 0x87654321
0x100003f77 <+18>: movsd 0x181(%rip), %xmm0 ; dbl, xmm0 = mem[0],zero
0x100003f7f <+26>: movl $0xdeadbeef, -0x4(%rbp) ; imm = 0xDEADBEEF
最后,我们可以使用 --pc 选项从当前指令开始反汇编:
(lldb) **disassemble --pc -c 3**
-> 0x100003f72 <+13>: movl $0x87654321, %eax ; imm = 0x87654321
0x100003f77 <+18>: movsd 0x181(%rip), %xmm0 ; dbl, xmm0 = mem[0],zero
0x100003f7f <+26>: movl $0xdeadbeef, -0x4(%rbp) ; imm = 0xDEADBEEF
该命令显示三条指令,从当前指令开始。当我们使用 -s 或 --pc 指定起始地址时,可以使用 -c 选项,但在使用 -n 反汇编整个函数时则不能使用该选项。
打印表达式
您可以使用命令 exp -f
让我们尝试一些例子。目前,程序应该在指令 movl 0x87654321, %eax 处暂停。我们将逐步执行该指令,然后以不同的格式打印出 EAX 的值:
(lldb) **stepi**
`--snip--`
hello_debugger`main:
-> 0x100003f77 <+18>: movsd 0x181(%rip), %xmm0 ; dbl, xmm0 = mem[0],zero
`--snip--`
(lldb) **exp -- $eax**
(unsigned int) $0 = ❶ 2271560481
(lldb) **exp -f x -- $eax**
(unsigned int) $1 = ❷ 0x87654321
(lldb) **exp -f d -- $eax**
(unsigned int) $2 = ❸ -2023406815
默认情况下,LLDB 将通用寄存器中的值格式化为无符号整数 ❶。在这里,我们还将 EAX 的值显示为十六进制 ❷ 和带符号整数 ❸。(要查看完整的格式列表,可以使用 help format 命令。)符号 $0、$1 等是 便利变量,LLDB 会自动生成这些变量来存储每个表达式的结果。
第十三章介绍了 XMM 寄存器,它们保存浮点值。程序中的下一条指令,movsd dbl(%rip), %xmm0,将静态变量 dbl 中的值 3.5 复制到 XMM0 中。让我们逐步执行这条指令,然后检查 XMM0。我们将使用 float64[] 格式,它将寄存器的内容显示为两个 double 数值的数组:
(lldb) **stepi**
`--snip--`
hello_debugger`main:
-> 0x100003f7f <+26>: movl $0xdeadbeef, -0x4(%rbp) ; imm = 0
`--snip--`
(lldb) **exp -f float64[] -- $xmm0**
(unsigned char __attribute__((ext_vector_type(16)))) $3 = ( ❶ 3.5, 0)
第一个数组元素对应于寄存器的低 64 位 ❶,我们已经使用 movsd 指令更新了它。第二个元素对应于寄存器的高 64 位,我们可以忽略它。
除了寄存器外,我们还可以打印符号表中对象的值。让我们检查一下程序中的两个静态变量,integer和dbl:
(lldb) **exp -f d -- integer**
(void *) $4 = 100
(lldb) **exp -f f -- dbl**
(void *) $5 = 3.5
现在让我们看几个更复杂表达式的例子。我们可以进行基本的算术运算:
(lldb) **exp -f x -- $eax + 0x10**
(unsigned int) $6 = 0x87654331
我们可以调用当前程序或标准库中的函数。这里我们调用 f,它返回 1:
(lldb) **exp -- (int) f()**
(int) $7 = 1
我们还可以解引用指针。让我们执行下一条指令,movl 0xdeadbeef, -4(%rbp),然后检查 -4(%rbp) 处的值:
(lldb) **stepi**
`--snip--`
hello_debugger`main:
-> 0x100003f86 <+33>: movl $0x0, -0x8(%rbp)
`--snip--`
(lldb) **exp -f x -- *(int *)($rbp - 4)**
(int) $8 = 0xdeadbeef
首先,我们计算出要检查的内存地址,$rbp - 4。然后,我们将此地址转换为正确的指针类型,(int *)。最后,我们使用解引用操作符 * 对其进行解引用。这将生成一个整数,我们可以使用选项 -f x 以十六进制形式打印出来。
接下来,我们将看看一种更灵活的方式来检查内存中的值。
检查内存
我们可以使用 memory read 命令检查内存。像 exp 一样,它接受一个任意表达式,该表达式必须评估为有效的内存地址。这为我们提供了另一种检查 -4(%rbp) 处整数的方式:
(lldb) **memory read -f x -s 4 -c 1 '$rbp - 4'**
0x3040bb93c: 0xdeadbeef
-f x 选项表示以十六进制格式打印输出;-s 4 表示将内存内容解释为 4 字节的值序列;而 -c 1 表示只打印其中一个值。换句话说,这条命令打印出 $rbp - 4 处的单个 4 字节整数,并以十六进制格式显示。我们必须将表达式 $rbp - 4 包含在引号中,因为它包含空格。
清单 A-1 中的接下来的三条指令将另外三个整数存储到堆栈上:
movl $0, -8(%rbp)
movl $-1, -12(%rbp)
movl $0xfeedface, -16(%rbp)
让我们逐步执行这些指令,然后打印出整个堆栈帧。我们会告诉 LLDB 打印出六个 4 字节的字,从 RSP 中的地址开始。我们将使用选项 -l 1 让每个字打印在单独的一行上:
(lldb) **stepi -c 3**
`--snip--`
hello_debugger`main:
-> 0x100003f9b <+54>: movl $0x19, %ecx
`--snip--`
(lldb) **memory read -f x -s 4 -c 6 -l 1 $rsp**
0x3040bb930: ❶ 0xfeedface
0x3040bb934: 0xffffffff
0x3040bb938: 0x00000000
0x3040bb93c: 0xdeadbeef
0x3040bb940: ❷ 0x040bba50
0x3040bb944: 0x00000003
我们打印出六个字是因为在这个特定函数中堆栈是 24 字节的。在 main 函数开始时,我们将旧的 RBP 值保存到堆栈中。那是 8 字节。然后,我们用命令 subq $16, %rsp 分配了另外 16 字节。请记住,RSP 始终保存堆栈顶部的地址,也就是 最低 的堆栈地址。
这条命令显示了我们保存到堆栈中的四个整数,其中 0xfeedface 在最上面 ❶,而旧的 RBP 值在最下面 ❷。由于 ❷ 处的值实际上是一个 8 字节的地址,我们可以更容易地读取它,如果我们将堆栈分组为 8 字节的值:
(lldb) **memory read -f x -s 8 -c 3 -l 1 $rsp**
0x3040bb930: 0xfffffffffeedface
0x3040bb938: 0xdeadbeef00000000
0x3040bb940: ❶ 0x00000003040bba50
现在很清楚,堆栈底部的 8 字节保存着一个单一的内存地址 ❶。
紧接在保存的 RBP 值下方,即调用者的堆栈帧上方,我们应该会找到调用者的返回地址——也就是我们从 main 返回时跳转的地址。(当我们在 第九章 中实现函数调用时,会详细讲解这一部分。)让我们检查一下这个地址:
(lldb) **memory read -f A -s 8 -c 4 -l 1 $rsp**
0x3040bb930: 0xfffffffffeedface
0x3040bb938: 0xdeadbeef00000000
0x3040bb940: 0x00000003040bba50
0x3040bb948: ❶ 0x0000000200012310 dyld`start + 2432
这条命令与前一条几乎相同,唯一的区别是我们使用了选项 -c 4 来打印出四个值而不是三个,使用选项 -f A 来将每个值格式化为内存地址。A 格式说明符告诉 LLDB 以十六进制打印每个地址,并且如果可能的话,打印出其相对于程序中最近符号的偏移量。由于函数和静态变量名称在符号表中定义,LLDB 可以显示汇编指令和静态数据的相对偏移量。它不会显示堆栈地址、堆地址或无效地址的相对偏移量,因为它们完全没有意义。
输出的前三行与之前相同。前两个值不是有效的内存地址,第三个是栈地址,因此 LLDB 无法显示它们与符号的偏移量。栈上的下一个值是返回地址 ❶。标签 dyld`start 告诉我们这是 dyld 动态库中 start 函数中某条指令的地址。(start 函数负责调用 main 并在其退出后进行清理;dyld 是动态链接器。)
-f A 选项使得找到返回地址和指向静态变量的指针变得容易。这在程序的栈帧损坏时特别有用;找到每个栈帧的返回地址有助于你重新确定位置。
设置条件断点
在本次演练的最后,我们将介绍如何设置 条件断点。程序仅在关联的条件为真时才会在条件断点处暂停。这个条件可以是任意表达式;如果它的计算结果为 0,LLDB 会认为它为假,否则为真。
让我们在 hello_debugger 的最后一个循环迭代的 jne 指令处设置一个断点。首先,我们将在反汇编的 main 函数中找到此指令的地址:
(lldb) **disassemble -n main**
hello_debugger`main:
`--snip--`
❶ 0x100003fa6 <+65>: jne 0x100003fa0 ; <+59>
`--snip--`
这里,jne 的地址是 0x100003fa6 ❶。现在我们将设置一个条件断点,当 ECX 为 0 时暂停在 jne 指令上。我们可以使用 -c 选项来指定条件:
(lldb) **break set -a** `**MEMORY_ADDRESS**` **-c '$ecx == 0'**
由于该循环会重复执行直到 ECX 为 0,条件 $ecx == 0 仅在最后一次迭代时为真。让我们继续执行直到断点,然后验证这个条件是否为真:
(lldb) **continue**
`--snip--`
hello_debugger`main:
-> 0x100003fa6 <+65>: jne 0x100003fa0 ; <+59>
`--snip--`
(lldb) **exp -- $ecx**
(unsigned int) $9 = 0
如果你得到不同的 ECX 值,请检查是否正确设置了断点:
(lldb) **break list**
`--snip--`
4: address = hello_debugger[0x0000000100003fa6], locations = 1, resolved = 1, hit count = 0
Condition: $ecx == 0 ❶
4.1: where = ❷ hello_debugger`main + 65, address = 0x0000000100003fa6, resolved, hit count
= 0
确保你的断点包含条件 $ecx == 0 ❶,并且位于位置 hello_debugger`main + 65 ❷。如果你的断点看起来不同,可能是打字错误;删除它并重新尝试。
我们应该在最后一次循环迭代,所以让我们向前执行一条指令,确保跳转没有发生:
(lldb) **stepi**
`--snip--`
hello_debugger`main:
-> 0x100003fa8 <+67>: movl $0x0, %eax
`--snip--`
通常,jne 会跳回循环的开始,但在最后一次迭代时,它会跳到下一条指令。
获取帮助
要了解更多我们未在此覆盖的命令和选项,请参阅 LLDB 文档,链接为 <wbr>lldb<wbr>.llvm<wbr>.org<wbr>/index<wbr>.html。你还可以在提示符下键入 help 来了解更多关于任何 LLDB 命令的信息。例如,要查看 run 命令的文档,请键入:
(lldb) **help run**
Launch the executable in the debugger
`--snip--`
表 A-2 总结了我们所覆盖的命令和选项。我们在演示中使用的每个命令版本会列在前面,后跟更短的缩写(exp 除外,通常不会进一步缩写),然后是当与我们使用的版本不同的完整形式。每个命令的所有版本都接受相同的参数。
表 A-2: LLDB 命令总结
| 命令 | 描述 |
|---|---|
| run | 启动程序。 |
| r | |
| process launch -- | |
| continue | 恢复程序。 |
| c | |
| process continue | |
| finish | 恢复程序,并继续执行直到当前函数退出。 |
| fin | |
| thread step-out | |
| stepi [-c |
执行一条指令(或 n 条指令),并进入函数调用。 |
| si | |
| 线程步进-指令 | |
| nexti [-c |
执行一条指令(或 n 条指令),并跳过函数调用。 |
| ni | |
| 线程步进-指令覆盖 | |
| 设置断点 [-n |
在函数 |
| br s | |
| 设置断点 | |
| 断点列表 | 列出所有断点。 |
| br l | |
| 断点列表 | |
| 删除断点 [ |
删除所有断点(或指定的断点 |
| 删除 br | |
| 删除断点 | |
| exp -f |
评估 |
| 表达式 | |
| memory read -f |
以 |
| 我读取 | |
| disassemble [-n |
反汇编函数 |
| di | |
| help |
显示有关 |
| h |
现在,你已经准备好开始使用 LLDB 进行调试了!
第二十三章:B 汇编生成与代码输出表格

在每一章中,关于将 TACKY 转换为汇编语言或代码生成的部分,我都包含了总结这些过程的表格。从第四章开始,这些表格仅展示了该章节中所做的更改,而非整个过程。附录中展示了总结这些过程的完整表格,位于第一部分、第二部分和第三部分的末尾。
第一部分
本节中的第一组表格说明了编译器应该如何将每个 TACKY 构造转换为汇编语言,位于第一部分的末尾。第二组表格说明了编译器应该如何输出每个汇编构造,位于第一部分的末尾。
将 TACKY 转换为汇编语言
表 B-1 到 B-5 展示了将 TACKY 转换为汇编语言的完整过程,位于第一部分的末尾。
表 B-1: 将顶级 TACKY 构造转换为汇编语言
| TACKY 顶级构造 | 汇编顶级构造 |
|---|
|
Program(top_level_defs)
|
Program(top_level_defs)
|
|
Function(name, global, params,
instructions)
|
Function(name, global,
[Mov(Reg(DI), param1),
Mov(Reg(SI), param2),
<copy next four parameters from registers>,
Mov(Stack(16), param7),
Mov(Stack(24), param8),
<copy remaining parameters from stack>] +
instructions)
|
|
StaticVariable(name, global, init)
|
StaticVariable(name, global, init)
|
表 B-2: 将 TACKY 指令转换为汇编语言
| TACKY 指令 | 汇编指令 |
|---|---|
| Return(val) | Mov(val, Reg(AX)) Ret |
| Unary(非运算, src, dst) | Cmp(Imm(0), src) Mov(Imm(0), dst)
SetCC(E, dst) |
| Unary(一元运算符, src, dst) | Mov(src, dst) Unary(一元运算符, dst) |
|---|
| Binary(除法, src1, src2, dst) | Mov(src1, Reg(AX)) Cdq
Idiv(src2)
Mov(Reg(AX), dst) |
| Binary(余数, src1, src2, dst) | Mov(src1, Reg(AX)) Cdq
Idiv(src2)
Mov(Reg(DX), dst) |
| Binary(算术运算符, src1, src2, dst) | Mov(src1, dst) Binary(算术运算符, src2, dst) |
|---|
| Binary(关系操作符, src1, src2, dst) | Cmp(src2, src1) Mov(Imm(0), dst)
SetCC(关系操作符, dst) |
| Jump(目标) | Jmp(目标) |
|---|---|
| JumpIfZero(条件, 目标) | Cmp(Imm(0), 条件) JmpCC(E, 目标) |
| JumpIfNotZero(条件, 目标) | Cmp(Imm(0), 条件) JmpCC(NE, 目标) |
| Copy(src, dst) | Mov(src, dst) |
| Label(标识符) | Label(标识符) |
| FunCall(函数名, 参数, dst) | <修复堆栈对齐> <设置参数>
Call(函数名)
<deallocate 参数/填充>
Mov(Reg(AX), dst) |
表 B-3: 将 TACKY 算术运算符转换为汇编
| TACKY 运算符 | 汇编运算符 |
|---|---|
| Complement | Not |
| Negate | Neg |
| Add | Add |
| Subtract | Sub |
| Multiply | Mult |
表 B-4: 将 TACKY 比较转换为汇编
| TACKY 比较 | 汇编条件码 |
|---|---|
| Equal | E |
| NotEqual | NE |
| LessThan | L |
| LessOrEqual | LE |
| GreaterThan | G |
| GreaterOrEqual | GE |
表 B-5: 将 TACKY 操作数转换为汇编语言
| TACKY 操作数 | 汇编操作数 |
|---|---|
| Constant(int) | Imm(int) |
| Var(identifier) | Pseudo(identifier) |
代码生成
表 B-6 到 B-10 展示了 第一部分结束时的完整代码生成过程。
表 B-6: 格式化顶层汇编结构
| 汇编语言顶层结构 | 输出 |
|---|
|
Program(top_levels)
|
Print out each top-level construct. On Linux, add at end of file:
.section .note.GNU-stack,"",@progbits
|
|
Function(name, global, instructions)
|
<global-directive>
.text
<name>:
pushq %rbp
movq %rsp, %rbp
<instructions>
|
|
StaticVariable(name, global, init)
| 初始化为零 |
|---|
<global-directive>
.bss
<alignment-directive>
<name>:
.zero 4
|
| 初始化为非零值 |
|---|
<global-directive>
.data
<alignment-directive>
<name>:
.long <init>
|
| 全局指令 |
|---|
If global is true:
.globl <identifier>
Otherwise, omit this directive.
|
| 对齐指令 | 仅限 Linux | .align 4 |
|---|---|---|
| macOS 或 Linux | .balign 4 |
表 B-7: 格式化汇编指令
| 汇编指令 | 输出 |
|---|
|
Mov(src, dst)
|
movl <src>, <dst>
|
|
Ret
|
movq %rbp, %rsp
popq %rbp
ret
|
|
Unary(unary_operator, operand)
|
<unary_operator> <operand>
|
|
Binary(binary_operator, src, dst)
|
<binary_operator> <src>, <dst>
|
|
Idiv(operand)
|
idivl <operand>
|
|
Cdq
|
cdq
|
|
AllocateStack(int)
|
subq $<int>, %rsp
|
|
DeallocateStack(int)
|
addq $<int>, %rsp
|
|
Push(operand)
|
pushq <operand>
|
|
Call(label)
|
call <label>
or
call <label>@PLT
|
|
Cmp(operand, operand)
|
cmpl <operand>, <operand>
|
|
Jmp(label)
|
jmp .L<label>
|
|
JmpCC(cond_code, label)
|
j<cond_code> .L<label>
|
|
SetCC(cond_code, operand)
|
set<cond_code> <operand>
|
|
Label(label)
|
.L<label>:
|
表 B-8: 汇编运算符的指令名称
| 汇编运算符 | 指令名称 |
|---|---|
| Neg | negl |
| Not | notl |
| Add | addl |
| Sub | subl |
| Mult | imull |
表 B-9: 条件码的指令后缀
| 条件码 | 指令后缀 |
|---|---|
| E | e |
| NE | ne |
| L | l |
| LE | le |
| G | g |
| GE | ge |
表 B-10: 汇编操作数格式
| 汇编操作数 | 输出 | |
|---|---|---|
| Reg(AX) | 8 字节 | %rax |
| 4 字节 | %eax | |
| 1 字节 | %al | |
| Reg(DX) | 8 字节 | %rdx |
| 4 字节 | %edx | |
| 1 字节 | %dl | |
| Reg(CX) | 8 字节 | %rcx |
| 4 字节 | %ecx | |
| 1 字节 | %cl | |
| Reg(DI) | 8 字节 | %rdi |
| 4 字节 | %edi | |
| 1 字节 | %dil | |
| Reg(SI) | 8 字节 | %rsi |
| 4 字节 | %esi | |
| 1 字节 | %sil | |
| Reg(R8) | 8 字节 | %r8 |
| 4 字节 | %r8d | |
| 1 字节 | %r8b | |
| Reg(R9) | 8 字节 | %r9 |
| 4 字节 | %r9d | |
| 1 字节 | %r9b | |
| Reg(R10) | 8 字节 | %r10 |
| 4 字节 | %r10d | |
| 1 字节 | %r10b | |
| Reg(R11) | 8 字节 | %r11 |
| 4 字节 | %r11d | |
| 1 字节 | %r11b | |
| Stack(int) | ||
| 立即数(int) | $ |
|
| 数据(标识符) | <标识符>(%rip) |
第二部分
本节的第一组表格展示了编译器如何将每个 TACKY 构造转换为汇编语言,在第二部分结束时。第二组表格展示了编译器如何生成每个汇编构造,同样在第二部分结束时。
将 TACKY 转换为汇编
表 B-11 至 B-16 展示了从 TACKY 到汇编的完整转换,见第二部分结束。
表 B-11: 将顶层 TACKY 构造转换为汇编
| TACKY 顶层构造 | 汇编顶层构造 |
|---|---|
| 程序(顶层定义) |
Program(top_level_defs + <all StaticConstant constructs for
floating-point constants>)
|
|
Function(name,
global,
params,
instructions)
| 寄存器中的返回值或无返回值 |
|---|
Function(name, global,
[<copy Reg(DI) into first int param/eightbyte>,
<copy Reg(SI) into second int param/eightbyte>,
<copy next four int params/eightbytes from registers>,
Mov(Double,
Reg(XMM0),
<first double param/eightbyte>),
|
Mov(Double,
Reg(XMM1),
<second double param/eightbyte>),
<copy next six double params/eightbytes from registers>,
<copy Memory(BP, 16) into first stack param/eightbyte>,
<copy Memory(BP, 24) into second stack param/eightbyte>,
<copy remaining params/eightbytes from stack>] +
instructions)
|
| 栈上的返回值 |
|---|
Function(name, global,
[Mov(Quadword,
Reg(DI),
Memory(BP, -8)),
<copy Reg(SI) into first int param/eightbyte>,
<copy Reg(DX) into second int param/eightbyte>,
<copy next three int params/eightbytes from registers>,
Mov(Double,
Reg(XMM0),
<first double param/eightbyte>),
Mov(Double,
Reg(XMM1),
<second double param/eightbyte>),
<copy next six double params/eightbytes from registers>,
<copy Memory(BP, 16) into first stack param/eightbyte>,
<copy Memory(BP, 24) into second stack param/eightbyte>,
<copy remaining params/eightbytes from stack>] +
instructions)
|
|
StaticVariable(name, global, t,
init_list)
|
StaticVariable(name, global, <alignment of t>,
init_list)
|
|
StaticConstant(name, t, init)
|
StaticConstant(name, <alignment of t>, init)
|
表 B-12: 将 TACKY 指令转换为汇编
| TACKY 指令 | 汇编指令 |
|---|---|
| 返回(val) | 栈上的返回值 |
Mov(Quadword, Memory(BP, -8), Reg(AX))
Mov(Quadword,
<first eightbyte of return value>,
Memory(AX, 0))
Mov(Quadword,
<second eightbyte of return value>,
Memory(AX, 8))
<copy rest of return value>
Ret
|
| 寄存器中的返回值 |
|---|
<move integer parts of return value into RAX, RDX>
<move double parts of return value into XMM0, XMM1>
Ret
|
| 无返回值 |
|---|
Ret
|
| 一元运算符(Not,src,dst) | 整数 |
|---|
Cmp(<src type>, Imm(0), src)
Mov(<dst type>, Imm(0), dst)
SetCC(E, dst)
|
| double |
|---|
Binary(Xor, Double, Reg(<X>), Reg(<X>))
Cmp(Double, src, Reg(<X>))
Mov(<dst type>, Imm(0), dst)
SetCC(E, dst)
|
|
Unary(Negate, src, dst)
(double negation)
|
Mov(Double, src, dst)
Binary(Xor, Double, Data(<negative-zero>, 0), dst)
And add a top-level constant:
StaticConstant(<negative-zero>, 16,
DoubleInit(-0.0))
|
| 一元运算符(unary_operator,src,dst) |
|---|
Mov(<src type>, src, dst)
Unary(unary_operator, <src type>, dst)
|
|
Binary(Divide, src1,
src2, dst)
(integer division)
| 有符号 |
|---|
Mov(<src1 type>, src1, Reg(AX))
Cdq(<src1 type>)
Idiv(<src1 type>, src2)
Mov(<src1 type>, Reg(AX), dst)
|
| 无符号 |
|---|
Mov(<src1 type>, src1, Reg(AX))
Mov(<src1 type>, Imm(0), Reg(DX))
Div(<src1 type>, src2)
Mov(<src1 type>, Reg(AX), dst)
|
|
Binary(Remainder, src1,
src2, dst)
| 有符号 |
|---|
Mov(<src1 type>, src1, Reg(AX))
Cdq(<src1 type>)
div(<src1 type>, src2)
Mov(<src1 type>, Reg(DX), dst)
|
| 无符号 |
|---|
Mov(<src1 type>, src1, Reg(AX))
Mov(<src1 type>, Imm(0), Reg(DX))
Div(<src1 type>, src2)
Mov(<src1 type>, Reg(DX), dst)
|
|
Binary(arithmetic_operator, src1,
src2, dst)
|
Mov(<src1 type>, src1, dst)
Binary(arithmetic_operator, <src1 type>, src2, dst)
|
|
Binary(relational_operator, src1,
src2, dst)
|
Cmp(<src1 type>, src2, src1)
Mov(<dst type>, Imm(0), dst)
SetCC(relational_operator, dst)
|
| 跳转(目标) |
|---|
Jmp(target)
|
|
JumpIfZero(condition,
target)
| 整数 |
|---|
Cmp(<condition type>, Imm(0), condition)
JmpCC(E, target)
|
| double |
|---|
Binary(Xor, Double, Reg(<X>), Reg(<X>))
Cmp(Double, condition, Reg(<X>))
JmpCC(E, target)
|
|
JumpIfNotZero(condition,
target)
| 整数 |
|---|
Cmp(<condition type>, Imm(0), condition)
JmpCC(NE, target)
|
| double |
|---|
Binary(Xor, Double, Reg(<X>), Reg(<X>))
Cmp(Double, condition, Reg(<X>))
JmpCC(NE, target)
|
| Copy(src, dst) | 标量 |
|---|
Mov(<src type>, src, dst)
|
| 结构 |
|---|
Mov(<first chunk type>,
PseudoMem(src, 0),
PseudoMem(dst, 0))
Mov(<next chunk type>,
PseudoMem(src, <first chunk size>),
PseudoMem(dst, <first chunk size>))
<copy remaining chunks>
|
| Load(ptr, dst) | 标量 |
|---|
Mov(Quadword, ptr, Reg(<R>))
Mov(<dst type>, Memory(<R>, 0), dst)
|
| 结构 |
|---|
Mov(Quadword, ptr, Reg(<R>))
Mov(<first chunk type>,
Memory(<R>, 0),
PseudoMem(dst, 0))
Mov(<next chunk type>,
Memory(<R>, <first chunk size>),
PseudoMem(dst, <first chunk size>))
<copy remaining chunks>
|
| Store(src, ptr) | 标量 |
|---|
Mov(Quadword, ptr, Reg(<R>))
Mov(<src type>, src, Memory(<R>, 0))
|
| 结构 |
|---|
Mov(Quadword, ptr, Reg(<R>))
Mov(<first chunk type>,
PseudoMem(src, 0),
Memory(<R>, 0))
Mov(<next chunk type>,
PseudoMem(src, <first chunk size>),
Memory(<R>, <first chunk size>))
<copy remaining chunks>
|
| GetAddress(src, dst) |
|---|
Lea(src, dst)
|
|
AddPtr(ptr, index, scale,
dst)
| 常量索引 |
|---|
Mov(Quadword, ptr, Reg(<R>))
Lea(Memory(<R>, index * scale), dst)
|
| 变量索引和 1, 2, 4 或 8 的尺度 |
|---|
Mov(Quadword, ptr, Reg(<R1>))
Mov(Quadword, index, Reg(<R2>))
Lea(Indexed(<R1>, <R2>, scale), dst)
|
| 变量索引和其他尺度 |
|---|
Mov(Quadword, ptr, Reg(<R1>))
Mov(Quadword, index, Reg(<R2>))
Binary(Mult, Quadword, Imm(scale), Reg(<R2>))
Lea(Indexed(<R1>, <R2>, 1), dst)
|
|
CopyToOffset(src, dst,
offset)
| src 是标量 |
|---|
Mov(<src type>, src, PseudoMem(dst, offset))
|
| src 是一个结构 |
|---|
Mov(<first chunk type>,
PseudoMem(src, 0),
PseudoMem(dst, offset))
Mov(<next chunk type>,
PseudoMem(src, <first chunk size>),
PseudoMem(dst, offset + <first chunk size>))
<copy remaining chunks>
|
| ``` CopyFromOffset(src,
offset,
dst)
| ``` | dst 是标量 |
|---|
Mov(<dst type>, PseudoMem(src, offset), dst)
|
| dst 是一个结构 |
|---|
Mov(<first chunk type>,
PseudoMem(src, offset),
PseudoMem(dst, 0))
Mov(<next chunk type>,
PseudoMem(src, offset + <first chunk size>),
PseudoMem(dst, <first chunk size>))
<copy remaining chunks>
|
| 标签(标识符) |
|---|
Label(identifier)
|
|
FunCall(fun_name, args,
dst)
| dst 将会存储在内存中 |
|---|
Lea(dst, Reg(DI))
<fix stack alignment>
<move arguments to general-purpose registers, starting with RSI>
<move arguments to XMM registers>
<push arguments onto the stack>
Call(fun_name)
<deallocate arguments/padding>
|
| dst 将会通过寄存器返回 |
|---|
<fix stack alignment>
<move arguments to general-purpose registers>
<move arguments to XMM registers>
<push arguments onto the stack>
Call(fun_name)
<deallocate arguments/padding>
<move integer parts of return value from RAX, RDX into dst>
<move double parts of return value from XMM0, XMM1 into dst>
|
| dst 不存在 |
|---|
<fix stack alignment>
<move arguments to general-purpose registers>
<move arguments to XMM registers>
<push arguments onto the stack>
Call(fun_name)
<deallocate arguments/padding>
|
| ZeroExtend(src, dst) |
|---|
MovZeroExtend(<src type>, <dst type>, src, dst)
|
| SignExtend(src, dst) |
|---|
Movsx(<src type>, <dst type>, src, dst)
|
| Truncate(src, dst) |
|---|
Mov(<dst type>, src, dst)
|
| IntToDouble(src, dst) | char 或 signed char |
|---|
Movsx(Byte, Longword, src, Reg(<R>))
Cvtsi2sd(Longword, Reg(<R>), dst)
|
| int 或 long |
|---|
Cvtsi2sd(<src type>, src, dst)
|
| DoubleToInt(src, dst) | char 或 signed char |
|---|
Cvttsd2si(Longword, src, Reg(<R>))
Mov(Byte, Reg(<R>), dst)
|
| 整数 或 长整数 |
|---|
Cvttsd2si(<dst type>, src, dst)
|
| UIntToDouble(src, dst) | 无符号字符 |
|---|
MovZeroExtend(Byte, Longword, src, Reg(<R>))
Cvtsi2sd(Longword, Reg(<R>), dst)
|
| 无符号整数 |
|---|
MovZeroExtend(Longword, Quadword, src, Reg(<R>))
Cvtsi2sd(Quadword, Reg(<R>), dst)
|
| 无符号长整数 |
|---|
Cmp(Quadword, Imm(0), src)
JmpCC(L, <label1>)
Cvtsi2sd(Quadword, src, dst)
Jmp(<label2>)
Label(<label1>)
Mov(Quadword, src, Reg(<R1>))
Mov(Quadword, Reg(<R1>), Reg(<R2>))
Unary(Shr, Quadword, Reg(<R2>))
Binary(And, Quadword, Imm(1), Reg(<R1>))
Binary(Or, Quadword, Reg(<R1>), Reg(<R2>))
Cvtsi2sd(Quadword, Reg(<R2>), dst)
Binary(Add, Double, dst, dst) Label(<label2>)
|
| DoubleToUInt(src, dst) | 无符号字符 |
|---|
Cvttsd2si(Longword, src, Reg(<R>))
Mov(Byte, Reg(<R>), dst)
|
| 无符号整数 |
|---|
Cvttsd2si(Quadword, src, Reg(<R>))
Mov(Longword, Reg(<R>), dst)
|
| 无符号长整数 |
|---|
Cmp(Double, Data(<upper-bound>, 0), src)
JmpCC(AE, <label1>)
Cvttsd2si(Quadword, src, dst)
Jmp(<label2>)
Label(<label1>)
Mov(Double, src, Reg(<X>))
Binary(Sub, Double, Data(<upper-bound>, 0), Reg(<X>))
Cvttsd2si(Quadword, Reg(<X>), dst)
Mov(Quadword, Imm(9223372036854775808), Reg(<R>))
Binary(Add, Quadword, Reg(<R>), dst)
Label(<label2>)
And add a top-level constant:
StaticConstant(<upper-bound>, 8,
DoubleInit(9223372036854775808.0))
|
表 B-13: 将 TACKY 算术运算符转换为汇编
| TACKY 运算符 | 汇编运算符 |
|---|---|
| 补码 | Not |
| 取反 (整数取反) | Neg |
| 加法 | Add |
| 减法 | Sub |
| 乘法 | Mult |
| 除法 (双精度 除法) | DivDouble |
表 B-14: 将 TACKY 比较操作转换为汇编
| TACKY 比较 | 汇编条件码 |
|---|---|
| 等于 | E |
| 不相等 | NE |
| 小于 | 符号数 |
| 无符号,指针,或 双精度 | B |
| 小于等于 | 有符号 |
| 无符号、指针或 双精度 | |
| 大于 | 有符号 |
| 无符号、指针或 双精度 | |
| 大于等于 | 有符号 |
| 无符号、指针或 双精度 |
表 B-15: 将 TACKY 操作数转换为汇编
| TACKY 操作数 | 汇编操作数 | |
|---|---|---|
| 常量(ConstChar(int)) | 立即数(int) | |
| 常量(ConstInt(int)) | 立即数(int) | |
| 常量(ConstLong(int)) | 立即数(int) | |
| 常量(ConstUChar(int)) | 立即数(int) | |
| 常量(ConstUInt(int)) | 立即数(int) | |
| 常量(ConstULong(int)) | 立即数(int) |
| 常量(ConstDouble(double)) | | 数据(
静态常量(
| Var(identifier) | 标量值 | Pseudo(identifier) |
|---|---|---|
| 聚合值 | PseudoMem(identifier, 0) |
表 B-16: 类型转换为汇编语言
| 源类型 | 汇编类型 | 对齐方式 | |
|---|---|---|---|
| Char | 字节 | 1 | |
| SChar | 字节 | 1 | |
| UChar | 字节 | 1 | |
| Int | 长整型 | 4 | |
| UInt | 长整型 | 4 | |
| Long | 四字长整型 | 8 | |
| ULong | 四字长整型 | 8 | |
| Double | 双精度 | 8 | |
| Pointer(referenced_t) | 四字长整型 | 8 | |
| Array(element, size) | 大小为 16 字节或更大的变量 | ByteArray( |
16 |
| 其他所有内容 | 字节数组( |
与 元素 相同的对齐方式 | |
| 结构(标签) | 字节数组( |
来自类型表的对齐 |
代码发射
表 B-17 至 B-23 显示了在 第二部分 结束时的完整代码发射过程。
表 B-17: 格式化顶级汇编构造
| 汇编顶级构造 | 输出 | |
|---|---|---|
| 程序(顶层构造) |
Print out each top-level construct.
On Linux, add at end of file:
.section .note.GNU-stack,"",@progbits
|
| 函数(名称,全局,指令) |
|---|
<global-directive>
.text
<name>:
pushq %rbp
movq %rsp, %rbp
<instructions>
|
|
StaticVariable(name, global,
alignment,
init_list)
| 初始化为零的整数,或任何仅用 ZeroInit 初始化的变量 |
|---|
<global-directive>
.bss
<alignment-directive>
<name>:
<init_list>
|
| 所有其他变量 |
|---|
<global-directive>
.data
<alignment-directive>
<name>:
<init_list>
|
|
StaticConstant(name, alignment,
init)
| Linux |
|---|
.section .rodata
<alignment-directive>
<name>:
<init>
|
| macOS(8 字节对齐的数字常量) |
|---|
.literal8
.balign 8
<name>:
<init>
|
| macOS(16 字节对齐的数字常量) |
|---|
.literal16
.balign 16
<name>:
<init>
.quad 0
|
| macOS(字符串常量) |
|---|
.cstring
<name>:
<init>
|
| 全局指令 |
|---|
If global is true:
.globl <identifier>
Otherwise, omit this directive.
|
| 对齐指令 | 仅限 Linux |
|---|
.align <alignment>
|
| macOS 或 Linux |
|---|
.balign <alignment>
|
表 B-18: 格式化静态初始化器
| 静态初始化器 | 输出 |
|---|---|
| CharInit(0) | .zero 1 |
| CharInit(i) | .byte |
| IntInit(0) | .zero 4 |
| IntInit(i) | .long |
| LongInit(0) | .zero 8 |
| LongInit(i) | .quad |
| UCharInit(0) | .zero 1 |
| UCharInit(i) | .byte |
| UIntInit(0) | .zero 4 |
| UIntInit(i) | .long |
| ULongInit(0) | .zero 8 |
| ULongInit(i) | .quad |
| ZeroInit(n) | .zero |
| DoubleInit(d) |
.double <d>
or
.quad <d-interpreted-as-long>
|
| StringInit(s, True) | .asciz " |
|---|---|
| StringInit(s, False) | .ascii " |
| PointerInit(label) | .quad |
表 B-19: 格式化汇编指令
| 汇编指令 | 输出 |
|---|---|
| Mov(t, src, dst) |
mov<t> <src>, <dst>
|
| Movsx(src_t, dst_t, src, dst) |
|---|
movs<src_t><dst_t> <src>, <dst>
|
| MovZeroExtend(src_t, dst_t, src, dst) |
|---|
movz<src_t><dst_t> <src>, <dst>
|
| Lea |
|---|
leaq <src>, <dst>
|
| Cvtsi2sd(t, src, dst) |
|---|
cvtsi2sd<t> <src>, <dst>
|
| Cvttsd2si(t, src, dst) |
|---|
cvttsd2si<t> <src>, <dst>
|
| Ret |
|---|
movq %rbp, %rsp
popq %rbp
ret
|
| Unary(unary_operator, t, operand) |
|---|
<unary_operator><t> <operand>
|
| Binary(Xor, Double, src, dst) |
|---|
xorpd <src>, <dst>
|
| Binary(Mult, Double, src, dst) |
|---|
mulsd <src>, <dst>
|
| Binary(binary_operator, t, src, dst) |
|---|
<binary_operator><t> <src>, <dst>
|
| Idiv(t, operand) |
|---|
idiv<t> <operand>
|
| Div(t, operand) |
|---|
div<t> <operand>
|
| Cdq(Longword) |
|---|
cdq
|
| Cdq(Quadword) |
|---|
cqo
|
| Push(operand) |
|---|
pushq <operand>
|
| Call(label) |
|---|
call <label>
or
call <label>@PLT
|
| Cmp(Double, operand, operand) |
|---|
comisd <operand>, <operand>
|
| Cmp(t, operand, operand) |
|---|
cmp<t> <operand>, <operand>
|
| Jmp(label) |
|---|
jmp .L<label>
|
| JmpCC(cond_code, label) |
|---|
j<cond_code> .L<label>
|
| SetCC(cond_code, operand) |
|---|
set<cond_code> <operand>
|
| Label(label) |
|---|
.L<label>:
|
表 B-20: 汇编操作符的指令名称
| 汇编操作符 | 指令名称 |
|---|---|
| Neg | neg |
| Not | not |
| Shr | shr |
| Add | add |
| Sub | sub |
| Mult (仅限整数乘法) | imul |
| DivDouble | div |
| And | and |
| Or | or |
| Shl | shl |
| ShrTwoOp | shr |
表 B-21: 汇编类型的指令后缀
| 汇编类型 | 指令后缀 |
|---|---|
| Byte | b |
| Longword | l |
| Quadword | q |
| Double | sd |
表 B-22: 条件码的指令后缀
| 条件码 | 指令后缀 |
|---|---|
| E | e |
| NE | ne |
| L | l |
| LE | le |
| G | g |
| GE | ge |
| A | a |
| AE | ae |
| B | b |
| BE | be |
表 B-23: 汇编操作数格式
| 汇编操作数 | 输出 | |
|---|---|---|
| Reg(AX) | 8 字节 | %rax |
| 4 字节 | %eax | |
| 1 字节 | %al | |
| Reg(DX) | 8 字节 | %rdx |
| 4 字节 | %edx | |
| 1 字节 | %dl | |
| Reg(CX) | 8 字节 | %rcx |
| 4 字节 | %ecx | |
| 1 字节 | %cl | |
| Reg(DI) | 8 字节 | %rdi |
| 4 字节 | %edi | |
| 1 字节 | %dil | |
| Reg(SI) | 8 字节 | %rsi |
| 4 字节 | %esi | |
| 1 字节 | %sil | |
| Reg(R8) | 8 字节 | %r8 |
| 4 字节 | %r8d | |
| 1 字节 | %r8b | |
| Reg(R9) | 8-byte | %r9 |
| 4-byte | %r9d | |
| 1-byte | %r9b | |
| Reg(R10) | 8-byte | %r10 |
| 4-byte | %r10d | |
| 1-byte | %r10b | |
| Reg(R11) | 8-byte | %r11 |
| 4-byte | %r11d | |
| 1-byte | %r11b | |
| Reg(SP) | %rsp | |
| Reg(BP) | %rbp | |
| Reg(XMM0) | %xmm0 | |
| Reg(XMM1) | %xmm1 | |
| Reg(XMM2) | %xmm2 | |
| Reg(XMM3) | %xmm3 | |
| Reg(XMM4) | %xmm4 | |
| Reg(XMM5) | %xmm5 | |
| Reg(XMM6) | %xmm6 | |
| Reg(XMM7) | %xmm7 | |
| Reg(XMM14) | %xmm14 | |
| Reg(XMM15) | %xmm15 | |
| Memory(reg, int) | ||
| Indexed(reg1, reg2, int) |
(<reg1>,
<reg2>, <int>)
|
| Imm(int) |
|---|
$<int>
|
| Data(identifier, int) |
|---|
<identifier>
+<int>(%rip)
|
第三部分
在第三部分中,我们不会改变从 TACKY 到汇编的转换,但我们会向汇编的 AST 添加一些新的寄存器,并相应地更新代码生成步骤。本节末尾的代码生成步骤如何呈现,取决于你是否先完成了第二部分,或者是直接从第一部分跳到第三部分。
表 B-24 到 B-28 展示了如果你跳过第二部分,在第三部分末尾的完整代码生成步骤。
表 B-24: 格式化顶层汇编构造
| 汇编顶层构造 | 输出 | |
|---|---|---|
| Program(top_levels) |
Print out each top-level construct.
On Linux, add at end of file:
.section .note.GNU-stack,"",@progbits
|
| Function(name, global, instructions) |
|---|
<global-directive>
.text
<name>:
pushq %rbp
movq %rsp, %rbp
<instructions>
|
| StaticVariable(name, global, init) | 初始化为零 |
|---|
<global-directive>
.bss
<alignment-directive>
<name>:
.zero 4
|
| 初始化为非零值 |
|---|
<global-directive>
.data
<alignment-directive>
<name>:
.long <init>
|
| 全局指令 |
|---|
If global is true:
.globl <identifier>
Otherwise, omit this directive.
|
| 对齐指令 | 仅限 Linux |
|---|
.align 4
|
| macOS 或 Linux |
|---|
.balign 4
|
表 B-25: 格式化汇编指令
| 汇编指令 | 输出 |
|---|---|
| Mov(src, dst) |
movl <src>, <dst>
|
| Ret |
|---|
movq %rbp, %rsp
popq %rbp
ret
|
| Unary(unary_operator, operand) |
|---|
<unary_operator> <operand>
|
| Binary(binary_operator, src, dst) |
|---|
<binary_operator> <src>, <dst>
|
| Idiv(operand) |
|---|
idivl <operand>
|
| Cdq |
|---|
cdq
|
| AllocateStack(int) |
|---|
subq $<int>, %rsp
|
| DeallocateStack(int) |
|---|
addq $<int>, %rsp
|
| Push(operand) |
|---|
pushq <operand>
|
| Pop(reg) |
|---|
popq <reg>
|
| Call(label) |
|---|
call <label>
or
call <label>@PLT
|
| Cmp(operand, operand) |
|---|
cmpl <operand>, <operand>
|
| Jmp(label) |
|---|
jmp .L<label>
|
| JmpCC(cond_code, label) |
|---|
j<cond_code> .L<label>
|
| SetCC(cond_code, operand) |
|---|
set<cond_code> <operand>
|
| Label(label) |
|---|
.L<label>:
|
表 B-26: 汇编操作符的指令名称
| 汇编操作符 | 指令名称 |
|---|---|
| Neg | negl |
| Not | notl |
| Add | addl |
| Sub | subl |
| Mult | imull |
表 B-27: 条件代码的指令后缀
| 条件代码 | 指令后缀 |
|---|---|
| E | e |
| NE | ne |
| L | l |
| LE | le |
| G | g |
| GE | ge |
表 B-28: 汇编操作数的格式化
| 汇编操作数 | 输出 |
|---|---|
| Reg(AX) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(DX) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(CX) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(BX) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(DI) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(SI) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(R8) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(R9) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(R10) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(R11) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(R12) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(R13) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(R14) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(R15) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Stack(int) | |
| Imm(int) | $ |
| Data(identifier) |
表 B-29 至 B-35 显示了完成 第一部分、第二部分 和 第三部分 后的完整代码输出过程。
表 B-29: 格式化顶级汇编构造
| 汇编顶级构造 | 输出 | |
|---|---|---|
| Program(top_levels) |
Print out each top-level construct.
On Linux, add at end of file:
.section .note.GNU-stack,"",@progbits
|
| Function(name, global, instructions) |
|---|
<global-directive>
.text
<name>:
pushq %rbp
movq %rsp, %rbp
<instructions>
|
|
StaticVariable(name, global,
alignment,
init_list)
| 初始化为零的整数,或者仅用 ZeroInit 初始化的任何变量 |
|---|
<global-directive>
.bss
<alignment-directive>
<name>:
<init_list>
|
| 所有其他变量 |
|---|
<global-directive>
.data
<alignment-directive>
<name>:
<init_list>
|
|
StaticConstant(name, alignment,
init)
| Linux |
|---|
.section .rodata
<alignment-directive>
<name>:
<init>
|
| macOS(8 字节对齐的数字常量) |
|---|
.literal8
.balign 8
<name>:
<init>
|
| macOS(16 字节对齐的数字常量) |
|---|
.literal16
.balign 16
<name>:
<init>
.quad 0
|
| macOS(字符串常量) |
|---|
.cstring
<name>:
<init>
|
| 全局指令 |
|---|
If global is true:
.globl <identifier>
Otherwise, omit this directive.
|
| 对齐指令 | 仅限 Linux |
|---|
.align <alignment>
|
| macOS 或 Linux |
|---|
.balign <alignment>
|
表 B-30: 格式化静态初始化器
| 静态初始化器 | 输出 |
|---|---|
| CharInit(0) | .zero 1 |
| CharInit(i) | .byte |
| IntInit(0) | .zero 4 |
| IntInit(i) | .long |
| LongInit(0) | .zero 8 |
| LongInit(i) | .quad |
| UCharInit(0) | .zero 1 |
| UCharInit(i) | .byte |
| UIntInit(0) | .zero 4 |
| UIntInit(i) | .long |
| ULongInit(0) | .zero 8 |
| ULongInit(i) | .quad |
| ZeroInit(n) | .zero |
| DoubleInit(d) |
.double <d>
or
.quad <d-interpreted-as-long>
|
| StringInit(s, True) | .asciz " |
|---|---|
| StringInit(s, False) | .ascii " |
| PointerInit(label) | .quad |
表 B-31: 格式化汇编指令
| 汇编指令 | 输出 |
|---|---|
| Mov(t, src, dst) |
mov<t> <src>, <dst>
|
| Movsx(src_t, dst_t, src, dst) |
|---|
movs<src_t><dst_t> <src>, <dst>
|
| MovZeroExtend(源类型, 目标类型, 源, 目标) |
|---|
movz<src_t><dst_t> <src>, <dst>
|
| Lea |
|---|
leaq <src>, <dst>
|
| Cvtsi2sd(t, 源, 目标) |
|---|
cvtsi2sd<t> <src>, <dst>
|
| Cvttsd2si(t, 源, 目标) |
|---|
cvttsd2si<t> <src>, <dst>
|
| Ret |
|---|
movq %rbp, %rsp
popq %rbp
ret
|
| Unary(一元操作符, t, 操数) |
|---|
<unary_operator><t> <operand>
|
| Binary(Xor, Double, 源, 目标) |
|---|
xorpd <src>, <dst>
|
| Binary(Mult, Double, 源, 目标) |
|---|
mulsd <src>, <dst>
|
| Binary(二元操作符, t, 源, 目标) |
|---|
<binary_operator><t> <src>, <dst>
|
| Idiv(t, 操数) |
|---|
idiv<t> <operand>
|
| Div(t, 操数) |
|---|
div<t> <operand>
|
| Cdq(长字) |
|---|
cdq
|
| Cdq(四字) |
|---|
cqo
|
| Push(操作数) |
|---|
pushq <operand>
|
| Pop(寄存器) |
|---|
popq <reg>
|
| Call(标签) |
|---|
call <label>
or
call <label>@PLT
|
| Cmp(Double, 操数, 操数) |
|---|
comisd <operand>, <operand>
|
| Cmp(t, 操数, 操数) |
|---|
cmp<t> <operand>, <operand>
|
| Jmp(标签) |
|---|
jmp .L<label>
|
| JmpCC(条件码, 标签) |
|---|
j<cond_code> .L<label>
|
| SetCC(条件码, 操数) |
|---|
set<cond_code> <operand>
|
| Label(标签) |
|---|
.L<label>:
|
表 B-32: 汇编操作符的指令名称
| 汇编操作符 | 指令名称 |
|---|---|
| Neg | neg |
| Not | not |
| Shr | shr |
| Add | add |
| Sub | sub |
| Mult (仅限整数乘法) | imul |
| DivDouble | div |
| 与 | and |
| 或 | or |
| Shl | shl |
| ShrTwoOp | shr |
表 B-33: 汇编类型的指令后缀
| 汇编类型 | 指令后缀 |
|---|---|
| 字节 | b |
| 长字 | l |
| 四字 | q |
| 双精度 | sd |
表 B-34: 条件码的指令后缀
| 条件码 | 指令后缀 |
|---|---|
| E | e |
| 不等 | ne |
| L | l |
| LE | le |
| G | g |
| GE | ge |
| A | a |
| AE | ae |
| B | b |
| BE | be |
表 B-35: 汇编操作数的格式化
| 汇编操作数 | 输出 |
|---|---|
| 寄存器(AX) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| 寄存器(DX) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| 寄存器(CX) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| 寄存器(BX) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| 寄存器(DI) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| 寄存器(SI) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| 寄存器(R8) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(R9) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(R10) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(R11) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(R12) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(R13) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(R14) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(R15) | 8 字节 |
| 4 字节 | |
| 1 字节 | |
| Reg(SP) | |
| Reg(BP) | |
| Reg(XMM0) | |
| Reg(XMM1) | |
| Reg(XMM2) | |
| Reg(XMM3) | |
| Reg(XMM4) | |
| Reg(XMM5) | |
| Reg(XMM6) | |
| Reg(XMM7) | |
| Reg(XMM8) | |
| Reg(XMM9) | |
| Reg(XMM10) | |
| Reg(XMM11) | |
| Reg(XMM12) | |
| Reg(XMM13) | |
| Reg(XMM14) | |
| Reg(XMM15) | |
| Memory(reg, int) | |
| 索引(reg1,reg2,int) | ( |
| Imm(int) | |
| 数据(identifier,0) | |
| 数据(identifier,int) |
第二十四章:参考文献
-
Aho, Alfred V., Monica S. Lam, Ravi Sethi, 和 Jeffrey D. Ullman. “机器无关优化。” 第九章,《编译原理、技术与工具》(第 2 版)。波士顿:Addison-Wesley,2006 年。
-
Ballman, Aaron. 关于问题 53631 的评论:“C 编译器:缺少诊断信息‘解引用“void ”指针’。”LLVM 问题跟踪器。GitHub,2022 年 9 月 21 日。
github.com/llvm/llvm-project/issues/53631#issuecomment-1253653888。 -
Bendersky, Eli. “C 语言语法的上下文敏感性,重审。” Eli Bendersky 的个人网站,2011 年 5 月 2 日。*
eli.thegreenplace.net/2011/05/02/the-context-sensitivity-of-cs-grammar-revisited。 -
Bendersky, Eli. “有向图遍历、顺序和数据流分析的应用。” Eli Bendersky 的个人网站,2015 年 10 月 16 日。*
eli.thegreenplace.net/2015/directed-graph-traversal-orderings-and-applications-to-data-flow-analysis。 -
Bendersky, Eli. “通过优先级爬升解析表达式。” Eli Bendersky 的个人网站,2012 年 8 月 2 日。*
eli.thegreenplace.net/2012/08/02/parsing-expressions-by-precedence-climbing。 -
Bendersky, Eli. “共享库中的位置无关代码(PIC)。” Eli Bendersky 的个人网站,2011 年 11 月 3 日。*
eli.thegreenplace.net/2011/11/03/position-independent-code-pic-in-shared-libraries。 -
Bendersky, Eli. “x64 架构下共享库中的位置无关代码(PIC)。” Eli Bendersky 的个人网站,2011 年 11 月 11 日。*
eli.thegreenplace.net/2011/11/11/position-independent-code-pic-in-shared-libraries-on-x64。 -
Bendersky, Eli. “递归下降分析器的一些问题。” Eli Bendersky 的个人网站,2009 年 3 月 14 日。*
eli.thegreenplace.net/2009/03/14/some-problems-of-recursive-descent-parsers。 -
Borgwardt, Michael. 《浮动点数指南》。访问于 2023 年 1 月 12 日。*
floating-point-gui.de*。 -
Briggs, Preston, Keith D. Cooper, 和 Linda Torczon. “图着色寄存器分配的改进。” ACM Transactions on Programming Languages and Systems 16, 第 3 期(1994 年 5 月):428–455。*
doi.org/10.1145/177492.177575*。 -
Chaitin, G. J. “通过图着色进行寄存器分配与溢出。” ACM SIGPLAN Notices 17, 第 6 期(1982 年 6 月):98–101。*
doi.org/10.1145/872726.806984*。 -
Chaitin, Gregory J., Marc A. Auslander, Ashok K. Chandra, John Cocke, Martin E. Hopkins, 和 Peter W. Markstein. “通过着色进行寄存器分配。” Computer Languages 6, 第 1 期(1981 年 1 月):47–57。*
doi.org/10.1016/0096-0551(81)90048-5*。 -
Chu, Andy. “Pratt 解析与优先级爬升是相同的算法。” Oils Blog,2016 年 11 月 1 日。*
www.oilshell.org/blog/2016/11/01.html*。 -
Chu, Andy. “优先级爬升的广泛应用。” Oils Blog,2017 年 3 月 30 日。*
www.oilshell.org/blog/2017/03/30.html*。 -
Ciechanowski, Bartosz. 《浮点数暴露》。访问于 2023 年 3 月 29 日。*
float.exposed*。 -
Cooper, Keith D., 和 Linda Torczon. “寄存器分配。” 第十三章 载于 Engineering a Compiler,第 2 版。波士顿:Morgan Kaufmann,2011 年。
-
Cordes, Peter. 回答“在 x86-64 ABI 中,将 32 位偏移量添加到指针时是否需要符号扩展或零扩展?” Stack Overflow,2016 年 4 月 21 日,更新于 2019 年 4 月 30 日。*
stackoverflow.com/a/36760539*。 -
cppreference.com. “C23。” 更新于 2023 年 9 月 25 日。*
en.cppreference.com/w/c/23*。 -
cppreference.com. “求值顺序。” 更新于 2023 年 9 月 20 日。*
en.cppreference.com/w/c/language/eval_order*。 -
Cuoq, Pascal. 回答“g++ 中的无符号 64 位到双精度转换:为什么这个算法?” Stack Overflow,2014 年 11 月 7 日,更新于 2018 年 10 月 23 日。*
stackoverflow.com/a/26799227*。 -
David542 和 Peter Cordes. “gas 中的整数溢出。” 论坛讨论。Stack Overflow,2020 年 10 月 10 日。*
stackoverflow.com/q/64289590*。 -
Dawson, Bruce. “有时候浮点数学是完美的。” Random ASCII,2017 年 6 月 19 日。
<wbr>randomascii<wbr>.wordpress<wbr>.com<wbr>/2017<wbr>/06<wbr>/19<wbr>/sometimes<wbr>-floating<wbr>-point<wbr>-math<wbr>-is<wbr>-perfect<wbr>/. -
Drysdale, David. “链接器入门指南。”更新于 2009 年。
<wbr>www<wbr>.lurklurk<wbr>.org<wbr>/linkers<wbr>/linkers<wbr>.html. -
D’Silva, Vijay, Mathias Payer 和 Dawn Song. “编译器优化中的正确性-安全性差距。”发表于 2015 IEEE 安全与隐私研讨会论文集,73–87。美国加州圣荷西,2015 年。
<wbr>doi<wbr>.org<wbr>/10<wbr>.1109<wbr>/SPW<wbr>.2015<wbr>.33. -
Finley, Thomas. “二进制补码。”康奈尔大学计算机科学系,2000 年 4 月。
<wbr>www<wbr>.cs<wbr>.cornell<wbr>.edu<wbr>/~tomf<wbr>/notes<wbr>/cps104<wbr>/twoscomp<wbr>.html. -
Fog, Agner. “不同 C++ 编译器和操作系统的调用约定。”更新于 2023 年 2 月 1 日。
<wbr>www<wbr>.agner<wbr>.org<wbr>/optimize<wbr>/calling<wbr>_conventions<wbr>.pdf. -
Friedl, Steve. “阅读 C 类型声明。”2003 年 12 月 27 日。
<wbr>unixwiz<wbr>.net<wbr>/techtips<wbr>/reading<wbr>-cdecl<wbr>.html. -
GCC Wiki. “GCC 中浮点数学的语义。”更新于 2021 年 4 月 13 日。
<wbr>gcc<wbr>.gnu<wbr>.org<wbr>/wiki<wbr>/FloatingPointMath. -
George, Lal 和 Andrew W. Appel. “迭代寄存器合并。” ACM 编程语言与系统期刊 18 卷,第 3 期(1996 年 5 月):300–324。
<wbr>doi<wbr>.org<wbr>/10<wbr>.1145<wbr>/229542<wbr>.229546. -
Ghuloum, Abdulaziz. “编译器构建的增量方法。”发表于 2006 年 Scheme 与函数式编程研讨会论文集,27–37。美国俄勒冈州波特兰,2006 年。
<wbr>scheme2006<wbr>.cs<wbr>.uchicago<wbr>.edu<wbr>/11<wbr>-ghuloum<wbr>.pdf. -
Gibbons, Phillip B. “第 8 讲:归纳变量优化。”2019 年春季卡内基梅隆大学 15-745 现代架构优化编译器课程讲义。
<wbr>www<wbr>.cs<wbr>.cmu<wbr>.edu<wbr>/afs<wbr>/cs<wbr>/academic<wbr>/class<wbr>/15745<wbr>-s19<wbr>/www<wbr>/lectures<wbr>/L8<wbr>-Induction<wbr>-Variables<wbr>.pdf. -
Gibbons, Phillip B. “讲座 16:指针分析。”卡内基梅隆大学 15-745 现代体系结构优化编译器课程的讲座幻灯片,2016 年春季学期。
www.cs.cmu.edu/afs/cs/academic/class/15745-s16/www/lectures/L16-Pointer-Analysis.pdf。 -
Gibbons, Phillip B. “讲座 23:寄存器分配:合并。”卡内基梅隆大学 15-745 现代体系结构优化编译器课程的讲座幻灯片,2019 年春季学期。
www.cs.cmu.edu/afs/cs/academic/class/15745-s19/www/lectures/L23-Register-Coalescing.pdf。 -
Godbolt, Matt. 编译器探索器。2023 年 9 月 27 日更新。
godbolt.org。 -
Goldberg, David. “每个计算机科学家应该知道的浮点运算。”ACM 计算机调查 23 卷,第 1 期(1991 年 3 月):5–48。
doi.org/10.1145/103162.103163。经过编辑的重印本已作为数值计算指南的附录 D 收录。帕洛阿尔托:Sun Microsystems,2000 年。docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html。 -
Gustedt, Jens. “C11 缺陷:填充初始化。”Jens Gustedt 的博客,2012 年 10 月 24 日。
gustedt.wordpress.com/2012/10/24/c11-defects-initialization-of-padding/。 -
Gustedt, Jens. “C23 前景下的检查整数算术。”Jens Gustedt 的博客,2022 年 12 月 18 日。
gustedt.wordpress.com/2022/12/18/checked-integer-arithmetic-in-the-prospect-of-c23/。 -
Hailperin, Max. “比较保守合并标准。”ACM 编程语言与系统交易 27 卷,第 3 期(2005 年 5 月):571–582。
doi.org/10.1145/1065887.1065894。 -
Hilfinger, Paul. “讲座 37:全局优化。”加利福尼亚大学伯克利分校 CS 164:编程语言与编译器的讲座幻灯片,2011 年春季学期。
inst.eecs.berkeley.edu/~cs164/sp11/lectures/lecture37-2x2.pdf。 -
Hyde, Randall. “过程。” 收录于 64 位汇编艺术,第 1 卷,第五章. 旧金山:No Starch Press,2021 年。
-
IEEE. IEEE 浮点运算标准. IEEE Std. 754-2019. 纽约:IEEE, 2019.
doi.org/10.1109/IEEESTD.2019.8766229. -
英特尔公司. Intel® 64 和 IA-32 架构软件开发者手册. 第 2 卷,指令集参考,A-Z. 更新于 2023 年 9 月.
www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html. 也可在网上找到非官方副本,地址为www.felixcloutier.com/x86/. -
ISO/IEC. 信息技术—编程语言—C 语言标准. 第 4 版,ISO/IEC 9899:2018. 日内瓦,瑞士:ISO,2018 年。
-
ISO/IEC. 信息技术—编程语言—C 语言标准. N3096(工作草案),2023 年 4 月 1 日.
open-std.org/JTC1/SC22/WG14/www/docs/n3096.pdf. -
Jones, Joel. “抽象语法树实现模式。” 收录于 第 10 届程序模式语言会议(PLoP2003)论文集,2003 年.
hillside.net/plop/plop2003/Papers/Jones-ImplementingASTs.pdf. -
Levien, Raph. “未定义行为,一切皆有可能。” Raph Levien’s Blog, 2018 年 8 月 17 日.
raphlinus.github.io/programming/rust/2018/08/17/undefined-behavior.html. -
LLVM. “LLVM: lib/CodeGen/MachineCopyPropagation.Cpp 源文件。” 源代码。访问日期:2021 年 12 月 24 日.
llvm.org/doxygen/MachineCopyPropagation_8cpp_source.html. -
LLVM 项目. “控制浮点行为。” Clang 编译器用户手册。访问日期:2023 年 4 月 11 日.
clang.llvm.org/docs/UsersManual.html#controlling-floating-point-behavior. -
LLVM 项目. LLDB 文档。更新于 2023 年 10 月 4 日.
lldb.llvm.org. -
Lu, H.J., Michael Matz, Milind Girkar, Jan Hubička, Andreas Jaeger, 和 Mark Mitchell 编。System V 应用程序二进制接口 AMD64 架构处理器补充(包括 LP64 和 ILP32 编程模型)。更新于 2023 年 9 月 26 日。*
<wbr>gitlab<wbr>.com<wbr>/x86<wbr>-psABIs<wbr>/x86<wbr>-64<wbr>-ABI*. -
MaskRay. “复制重定位、标准 PLT 条目和受保护的可见性。”MaskRay,2021 年 1 月 9 日。*
<wbr>maskray<wbr>.me<wbr>/blog<wbr>/2021<wbr>-01<wbr>-09<wbr>-copy<wbr>-relocations<wbr>-canonical<wbr>-plt<wbr>-entries<wbr>-and<wbr>-protected*. -
Meneide, JeanHeyd. “越来越近——C23 即将到来。”The Pasture,2022 年 2 月 28 日。*
<wbr>thephd<wbr>.dev<wbr>/ever<wbr>-closer<wbr>-c23<wbr>-improvements*. -
Muchnick, Steven S. “寄存器分配。”第十六章 收录于 Advanced Compiler Design and Implementation。旧金山:Morgan Kaufmann,1997 年。
-
Myers, Joseph. 评论 Bug 90472:“‘extern int i;’声明不能在函数内覆盖文件作用域中的‘static int i;’。”GCC Bugzilla,2019 年 5 月 16 日。*
<wbr>gcc<wbr>.gnu<wbr>.org<wbr>/bugzilla<wbr>/show<wbr>_bug<wbr>.cgi<wbr>?id<wbr>=90472#c3*. -
Nisan, Noam, 和 Shimon Schocken. “布尔运算。”第二章 收录于 The Elements of Computing Systems: Building a Modern Computer from First Principles,第 1 版。剑桥:MIT 出版社,2008 年。*
<wbr>www<wbr>.nand2tetris<wbr>.org<wbr>/<wbr>_files<wbr>/ugd<wbr>/44046b<wbr>_f0eaab042ba042dcb58f3e08b46bb4d7<wbr>.pdf*. -
Regan, Rick. “十进制到浮点转换器。”Exploring Binary,2023 年 6 月 2 日访问。*
<wbr>www<wbr>.exploringbinary<wbr>.com<wbr>/floating<wbr>-point<wbr>-converter<wbr>/*. -
Regan, Rick. “GCC 使用舍入到奇数避免双重舍入误差。”Exploring Binary,2014 年 1 月 15 日。*
<wbr>www<wbr>.exploringbinary<wbr>.com<wbr>/gcc<wbr>-avoids<wbr>-double<wbr>-rounding<wbr>-errors<wbr>-with<wbr>-round<wbr>-to<wbr>-odd<wbr>/*. -
Regan, Rick. “十六进制浮点常量。”Exploring Binary,2010 年 10 月 4 日。*
<wbr>www<wbr>.exploringbinary<wbr>.com<wbr>/hexadecimal<wbr>-floating<wbr>-point<wbr>-constants<wbr>/*. -
Regan, Rick. “往返转换所需的数字位数。” 探索二进制,2015 年 4 月 9 日。
www.exploringbinary.com/number-of-digits-required-for-round-trip-conversions/。 -
Regan, Rick. “二进制浮点数的间距。” 探索二进制,2015 年 3 月 15 日。
www.exploringbinary.com/the-spacing-of-binary-floating-point-numbers/。 -
Regehr, John. “C 和 C++ 中未定义行为指南,第一部分。” 嵌入学术界,2010 年 7 月 9 日。
blog.regehr.org/archives/213。 -
Ritchie, Dennis M. “C 语言的发展。” 收录于 第二届 ACM SIGPLAN 编程语言历史会议,201–8 页。剑桥:ACM,1993 年。
doi.org/10.1145/154766.155580。该文于 2003 年在作者主页上重印,贝尔实验室。www.bell-labs.com/usr/dmr/www/chist.html。 -
Stallman, Richard M. 和 GCC 开发者社区. “整数。” 使用 GNU 编译器集合(GCC)。访问时间:2023 年 1 月 12 日。
gcc.gnu.org/onlinedocs/gcc/Integers-implementation.html。 -
Stallman, Richard, Roland Pesch, Stan Shebs, 等人. 使用 GDB 调试:GNU 源代码级调试器。访问时间:2024 年 1 月 29 日。
sourceware.org/gdb/current/onlinedocs/gdb/index.html。 -
Taylor, Ian Lance. “链接器。” 一系列博客文章。Airs – Ian Lance Taylor,2007 年 9 月。第一篇文章请见
www.airs.com/blog/archives/38,目录请见lwn.net/Articles/276782/。 -
Ullman, Jeffrey D. “代码优化 I。” 讲义。斯坦福大学信息实验室,斯坦福大学。访问时间:2023 年 10 月 6 日。
infolab.stanford.edu/~ullman/dragon/slides3.pdf。 -
Ullman, Jeffrey D. “代码优化 II。” 讲义。斯坦福大学信息实验室,斯坦福大学。访问时间:2023 年 10 月 6 日。
infolab.stanford.edu/~ullman/dragon/slides4.pdf。 -
王丹尼尔·C., 安德鲁·W·阿佩尔, 杰夫·L·科恩, 和克里斯托弗·S·塞拉. “Zephyr 抽象语法描述语言.” 收录于领域特定语言会议(DSL '97)论文集. 圣巴巴拉, CA, 1997.
www.cs.princeton.edu/~appel/papers/asdl97.pdf. -
维基百科. “双精度浮点格式.” 最后修改于 2024 年 3 月 26 日.
en.wikipedia.org/wiki/Double-precision_floating-point_format. -
杨爱德华·Z. “AST 类型问题.” Ezyang 的博客, 2013 年 5 月 28 日.
blog.ezyang.com/2013/05/the-ast-typing-problem/. -
杨兆墨, 布赖恩·约翰内斯梅耶, 安德斯·特里尔·奥莱森, 索林·勒纳, 和基里尔·列夫琴科. “死存储消除(仍然)被认为有害.” 收录于第 26 届 USENIX 安全研讨会论文集, 1025–1040. 温哥华, BC, 2017.
www.usenix.org/system/files/conference/usenixsecurity17/sec17-yang.pdf.
第二十五章:索引
-
符号
-
- (加法)运算符。参见 加法运算符
-
+= (加法赋值)运算符, 113
-
& (地址)运算符。参见 地址运算符
-
&& (与)运算符。参见 与运算符
-
= (赋值)运算符。参见 赋值表达式
-
& (按位与)运算符, 67
-
&= (按位与赋值)运算符, 113
-
~ (按位补码)运算符。参见 按位补码运算符
-
| (按位或)运算符, 67
-
|= (按位或赋值)运算符, 113
-
?: (条件)运算符, 121–124。另见 条件表达式
-
-- (自减)运算符, 31–32, 33, 113
-
- (解引用)运算符。参见 解引用运算符
-
/ (除法)运算符。参见 除法运算符
-
/= (除法赋值)运算符, 113
-
== (等于)运算符。参见 等于运算符
-
(大于)运算符。参见 大于运算符
-
= (大于或等于)运算符。参见 大于或等于运算符
-
++ (自增)运算符, 113
-
<< (左移)运算符, 67
-
<<= (左移赋值)运算符, 67
-
< (小于)运算符。参见 小于运算符
-
<= (小于或等于)运算符。参见 小于或等于运算符
-
(乘法)运算符。参见* 乘法运算符
-
*=(乘法赋值)运算符,113
-
-(否定)运算符。参见 否定运算符
-
!(非)运算符。参见 非运算符
-
!=(不等于)运算符。参见 不等于运算符
-
||(或)运算符。参见 或运算符
-
%(余数)运算符。参见 余数运算符
-
%=(余数赋值)运算符,113
-
(右移)运算符,67
-
=(右移赋值)运算符,113
-
.(结构体成员)运算符。参见 结构体成员运算符
-
->(结构体指针)运算符。参见 结构体指针运算符
-
[](下标)运算符。参见 下标运算符
-
-(减法)运算符。参见 减法运算符
-
-=(减法赋值)运算符,113
-
^(异或)运算符,67
-
^=(异或赋值)运算符,113
-
A
-
ABI(应用二进制接口),184。另见 System V x64 ABI
-
抽象声明符,361–363
-
抽象数组声明符,395–396
-
抽象语法树(AST),4,10–14
-
添加循环标签,150
-
添加类型信息,252–253
-
AST 类型问题,253
-
其他资源,21–22
-
汇编,18,40
-
常量表示,248,276,306
-
结构体决定计算顺序,49
-
TACKY,36–37
-
add_edge 函数,579–580
-
add 指令,60,62–63
-
发射,66,270
-
修复,64,268
-
加法(+)运算符,47–50
-
汇编,60,62–63
-
浮点数,315
-
解析,50–55
-
指针加法,387–390
-
TACKY 用于,406–408
-
类型检查,400,472
-
TACKY 用于,58
-
类型检查,254–255
-
加法赋值(+=)运算符,113
-
add_pseudoregisters,632,633,637
-
AddPtr 指令,406–408
-
汇编,414–415
-
省略,517
-
使用结构成员访问,514–517
-
地址(&)运算符,349,353
-
汇编,376
-
对...的限制,364,474
-
解析,354–355
-
TACKY 用于,370–372,374,514,516–517
-
类型检查,364–365
-
地址获取分析,600–601
-
AddrOf 表达式,354。参见 地址运算符
-
数组衰退实现,398–399,409–410,441
-
addsd 指令,311–312
-
修复,337
-
高级编译器设计与实现(Muchnick),669
-
聚合类型,384
-
阿尔弗雷德·V·阿霍(Aho),611,670
-
别名分析,601
-
额外资源,611
-
别名变量,599–602,609,637
-
.align 指令,221,238–239
-
aligned_alloc 函数,461
-
分配的存储持续时间, 213, 461
-
AllocateStack 指令, 40, 42, 44, 268
-
AMD64。见 x64 指令集
-
AND (&&) 运算符, 71–77
-
短路, 72
-
TACKY for, 75–77, 259
-
类型检查, 255, 470
-
and 指令, 323–325, 337, 341
-
Appel, Andrew, 22, 670
-
Apple Silicon, xxxv
-
应用程序二进制接口 (ABI), 184. 另见 System V x64 ABI
-
算术运算。另见各个运算符的名称
-
在汇编中, 60–63
-
浮点数, 296
-
在汇编中, 311–312, 315–316, 327–328
-
舍入行为, 301
-
优先级值, 50, 55
-
类型检查, 254–255, 476–477
-
算术类型, 347, 476–477
-
通常的算术转换, 254, 279–280, 308, 435
-
ARM, xxvii, 672
-
数组声明符, 357–358
-
抽象, 361–362, 395–396
-
解析, 394–396
-
数组, 384–399
-
对齐, 415
-
汇编类型, 413
-
衰变, 386–387
-
声明, 384–385. 另见 数组声明符
-
元素比较, 389–390
-
元素类型, 384
-
完整, 471–473
-
函数声明中的数组类型, 390–391
-
隐式转换为指针。见 数组到指针的衰变
-
初始化器, 385. 另见 复合初始化器
-
字符串字面量作为, 425–426, 437–438, 440–441
-
内存布局, 385–386
-
多维的, 384–385, 386–389, 393
-
下标, 408–410
-
类型检查,在 398–399,402–405,471,472–473
-
变量长度,在 391
-
数组到指针的衰减,在 386–387
-
使用AddrOf实现,在 398–399,409–410,441
-
sizeof操作数不受影响,在 462
-
Arrow表达式,在 495。另请参见 结构指针操作符
-
ASCII,在 10,204,426–427,449–450
-
转义序列的值,在 429
-
.ascii指令,在 426–427,449–450
-
.asciz指令,在 426–427,449–450
-
ASDL(Zephyr 抽象语法描述语言),在 13–14,22,171
-
字段名称,在 14
-
产品类型,在 171
-
总和类型,在 171
-
汇编器指令,在 5。另请参见各个指令条目
-
汇编器,在 xxviii,5
-
GNU 汇编器(GAS),在 268,338
-
调用,在 7
-
LLVM 汇编器,在 268,338
-
汇编代码,在 xxvii,4–7
-
算术运算,在 60–63
-
浮点数,在 311–312,315–316,327–328
-
AT&T 与 Intel 语法,在 6,244,570
-
位操作补码,在 26–27,40–41
-
注释,在 20
-
比较运算,在 78–81,82–83,85–86
-
无符号,在 283–285,287–288
-
浮点数,在 317,328
-
调试,在 675–697
-
使用 GDB,在 677–687
-
使用 LLDB,在 687–698
-
除法运算,在 60–63
-
无符号,在 286,288
-
浮点数,在 310–336
-
函数调用,在 161,184–199,312–315,519–528
-
跳转,83–87
-
链接到,168–169,220–223
-
长整型,244–246,261–264
-
取反,26–27,40–41,315–316,327–328
-
存储持续时间,220–223
-
字符串,426–429,450
-
类型转换,244–245,317–324
-
浮点数,317–324,328–329,445
-
符号扩展,244–245,263,444
-
截断,245,263,444
-
零扩展,286–288,443–444
-
汇编生成,4,17–19
-
编译器过程,39
-
参考表,700–701,704–711
-
汇编指令,5–6,17–18。另请参见单个指令的名称
-
汇编 AST,18
-
流式 SIMD 扩展,310–312
-
后缀,6,269,311,427,443–444
-
汇编类型,261–262,265–266
-
Byte,443
-
ByteArray,413
-
Double,324
-
八字节,536–537
-
Longword,261
-
Quadword,261
-
后缀,269–271,340–341,443
-
赋值表达式,94–95
-
AST 定义,97–98
-
操作符,97,101
-
优先级值,103
-
解析,100–103
-
解析变量,107
-
TACKY,110,371–374,516
-
类型检查,256,368,399
-
验证左值,107,399
-
结合性,50–51
-
AST。参见 抽象语法树
-
AT&T 语法, 6, 244, 570
-
自动存储持续时间, 212–213, 217
-
数组初始化, 440
-
自动变量, 208
-
在符号表中, 229–230
-
类型检查, 233, 257
-
AVX 指令集扩展, 318
-
B
-
后端符号表, 266–267
-
不完全类型, 530, 546
-
寄存器使用追踪, 621–622, 635, 637, 647
-
返回值传递信息追踪, 546, 550
-
顶层常量, 327, 339, 446
-
向后分析, 584, 604
-
迭代算法, 607–608
-
.balign 指令, 238–289
-
Ballman, Aaron, 475
-
基址指针, 29
-
基本块, 576–578
-
创建, 578–579
-
空的, 583
-
不可达, 581–582
-
基本源字符集, 430
-
基本类型, 356
-
Bendersky, Eli, 21, 68, 222, 611
-
二元表达式
-
AST 定义, 48
-
形式文法, 51, 52
-
解析, 50–55
-
随着优先级提升, 51–55
-
使用递归下降, 50–51
-
操作数,未排序, 58–59
-
TACKY 用于, 58
-
类型检查, 254–255
-
二进制小数, 297
-
二元运算符。请参见 二元表达式 和单个运算符的名称
-
按位与(&)运算符, 67
-
按位与赋值(&=)运算符, 113
-
按位取反(~)运算符, 26
-
汇编, 26–27, 40–41
-
解析, 33–34
-
TACKY 用于, 36–37
-
令牌, 31–32
-
类型检查,254,308,369,435
-
按位或(|)运算符,67
-
按位或赋值(|=)运算符,113
-
块,132,135
-
复合语句作为,132
-
解析,136
-
在中解析变量,136–139
-
块作用域声明,208–217。参见 作用域
-
无效,220
-
解析标识符,228–229
-
类型检查,232–233
-
博格沃尔德,迈克尔,343
-
中断标签,155–158
-
break 语句,146–148
-
注解,150,151,152–154
-
解析,149–150
-
TACKY 用于,155–156
-
布里格斯,普雷斯顿,669
-
布里格斯测试,656–659,666–667
-
额外资源,669–670
-
限制,661–663
-
.bss 指令,222
-
BSS 段,222
-
b 后缀,427,443
-
build_graph 函数,631–632
-
.byte 指令,450
-
C
-
调用者保存和被调用者保存的寄存器,185,648–649
-
汇编 AST 中的被调用者保存的寄存器,620–621
-
在图着色算法中,645–646
-
保存与恢复,187,193–194,196–197,648–649
-
跟踪被调用者保存的寄存器使用情况,646–647
-
调用约定,161,184。参见 System V x64 调用约定
-
call 指令,186,189–190
-
发射,201–202
-
生成,198–199
-
calloc 函数,461
-
case 语句,159
-
Cast 表达式, 248
-
隐式类型转换, 255
-
强制类型转换表达式。参见 类型转换
-
解析, 247–249, 464–466
-
指针类型作为操作数, 351–352
-
TACKY for, 259–260, 281–283, 309–310, 375, 440, 479
-
类型检查, 254, 369, 402, 471, 505
-
转换为 void, 459, 471, 479
-
cdq 指令, 61–63, 262
-
发射, 66, 269
-
Chaitin, Gregory, 669
-
Chaitin-Briggs 算法, 669–670
-
字符常量, 424
-
词法分析, 429–431
-
解析, 433
-
类型的, 424
-
UTF–8, 424
-
字符类型, 423–424
-
汇编类型, 443
-
char, 423–424
-
整型提升, 424, 435
-
signed char, 423–424
-
指定符, 解析, 433
-
静态初始化器, 436
-
类型转换
-
汇编, 443–445
-
TACKY for, 440
-
unsigned char, 423–424
-
宽, 424
-
char 关键字, 429
-
char 类型, 423–424
-
符号性, 424
-
静态初始化器, 436–438
-
Chu, Andy, 68
-
Ciechanowski, Bartosz, 345
-
Clang, xxxiv–xxxv, 4–5
-
浮点支持, 296–297, 317–318, 344
-
安装, xxxiv
-
使用 gcc 命令调用, xxxv, 4
-
语言扩展,395,401,471
-
System V ABI 违规,444–445
-
void,处理,474–475
-
classify_parameters 函数,329–330,534–536
-
classify_return_value 函数,532–533,537–538
-
classify_structure 函数,533–534
-
cmp 指令,79–80,85–86,262
-
发射,90,270
-
修复,88,268
-
coalesce 函数,665–666
-
在 build-coalesce 循环中,663
-
代码发射,4,19–20。另见各个指令和语言构造的条目
-
浮点常量,338–339
-
函数名,201
-
函数序言和尾声,43–44
-
指令大小后缀,269–271,340–341,443
-
本地标签,89,339,450
-
非可执行堆栈说明,19
-
@PLT 后缀,201–202
-
参考表
-
第一部分,702–704
-
第二部分,711–715
-
第三部分,716–724
-
寄存器别名,88,90,203
-
字符串字面量,449–450
-
color_graph 函数,644–646
-
comisd 指令,317,324,328
-
发射,341
-
修复,337
-
通用实数类型,254–255,279–280,308,435
-
比较,78–83。另见 指针比较;关系运算符
-
浮点数,317,328
-
无符号,283–286
-
编译器,xxvii
-
阶段,3–4
-
编译器驱动程序,xxviii,7–8
-
命令行选项,8
-
-c,169–170
-
--codegen,8,43
-
--eliminate-dead-stores,570
-
--eliminate-unreachable-code,569
-
--fold-constants,569
-
-l,301
-
--lex,8
-
--optimize,570
-
--parse,8
-
--propagate-copies,569
-
-S,569
-
--tacky,38
-
--validate,109
-
生成汇编文件,569
-
生成目标文件,169
-
链接共享库,301
-
编译器浏览器(Godbolt),xxxvi
-
编译器:原理、技术与工具,第二版(Aho 等),611,670
-
完整类型,461–462
-
必需,471–473,477–478,488,491
-
结构体类型,486–487
-
复合赋值运算符,113–114
-
复合初始化器,385
-
汇编,413,418–419
-
AST 定义,393
-
未实现,391–392
-
解析,396
-
静态,404–405,509–511
-
对于结构体,492
-
对于 TACKY,406,410–411,517–518
-
类型检查,403–405,509–511
-
复合字面量,391
-
复合语句,131
-
作为块,132
-
解析,135–136
-
解析变量,136–139
-
由…确定的作用域,131–134
-
对于 TACKY,140
-
具体语法树, 14
-
条件 (?😃 运算符, 121–124
-
条件表达式, 117
-
分隔符令牌, 118
-
解析, 121–125
-
解析变量, 125–126
-
TACKY 的 for, 127, 479–480
-
类型检查, 256, 368, 467, 470, 476, 508
-
void 操作数, 459, 476
-
条件跳转指令。请参见 跳转指令(汇编); 跳转指令(TACKY)
-
条件设置指令, 82–83
-
发射, 88–90
-
生成, 85–86
-
SetCC, 85–86
-
条件码, 85–86, 285, 287–288
-
后缀, 90, 291
-
conservative_coalesceable 函数, 666–667
-
保守合并, 653, 656, 670
-
常量折叠, 561, 573–576
-
与其他优化结合, 569
-
常量传播, 563
-
常量字符串, 425–426
-
在汇编中, 428, 446
-
发射, 449–451
-
在符号表中, 437–439, 441
-
在 TACKY 中, 441–442
-
类型检查, 436, 437–439
-
常量令牌, 8–10
-
字符, 429–431
-
浮动点数, 302–303
-
四舍五入, 300
-
长整数, 247
-
正则表达式, 304
-
无符号整数, 275
-
无符号长整数, 275
-
continue 标签, 155–158
-
continue 语句, 146–150
-
注释, 150, 151, 152–154
-
解析, 149–150
-
TACKY 的 for, 155–156
-
控制流图, 570, 576–581
-
控制流保护, 5
-
控制表达式, 118–119
-
循环, 144–145
-
类型检查, 352, 470
-
控制结构, 117
-
类型转换等级, 279
-
convert_by_assignment 函数, 368, 469, 504–505
-
convert_function_call 函数, 197–199, 263, 331–333, 538–541
-
convert_unop 函数, 37–38
-
convert_val 函数, 198
-
Cooper, Keith, 669–670
-
copy_bytes_from_reg 函数, 543–544
-
copy_bytes_to_reg 函数, 541–543
-
CopyFromOffset 指令, 512–513
-
汇编语言, 532, 548
-
结构体成员访问, 513–514, 516
-
Copy 指令, 75–77, 110
-
汇编语言, 86
-
使用非标量操作数, 531
-
类型转换, 282, 574
-
复制传播, 563–564, 585–602。另见 到达拷贝分析
-
额外资源, 611
-
与其他优化结合, 569
-
使用第二部分 TACKY 程序, 599–602
-
重写指令, 598–599, 602
-
CopyToOffset 指令, 406–407
-
汇编语言, 414
-
使用非标量操作数, 531–532
-
使用初始化聚合对象, 410–411, 440–441, 517–518
-
结构体成员访问, 514, 516
-
Cordes, Peter, 445
-
cqo 指令, 269
-
C 标准, xxxvi
-
&(地址)运算符应用于解引用指针, 353
-
数组衰减, 386–387
-
基本源字符集, 430
-
C17, xxxvi–xxxvii, 164
-
C23 标准, xxxvi–xxxvii
-
检查的整数运算, 82
-
十进制浮点类型, 300
-
空初始化器, 519
-
空参数列表, 164
-
memset_explicit, 565
-
已删除的旧式函数定义, 164
-
u8 字符常量, 424
-
声明符, 358
-
转义序列, 429
-
求值顺序, 59
-
短路操作符, 72
-
浮点类型, 296
-
for 循环,缺少控制表达式, 158
-
实现定义的行为, 245
-
不完全类型, 461
-
链接, 167–168, 210–212
-
左值, 348
-
可观察的行为, 560
-
预处理标记, 303
-
return 语句,缺失, 111–112
-
存储持续时间, 212
-
严格别名规则, 352
-
结构体成员声明, 488–489
-
临时生命周期, 508
-
类型转换, 244, 274
-
整数常量的类型, 278
-
类型说明符, 278
-
未定义行为, 80, 91, 107, 112
-
无符号溢出, 285
-
常见算术转换, 254, 279–280
-
void, 458, 473–475
-
.cstring 指令, 428, 450
-
Cuoq, Pascal, 344
-
cvtsi2sd 指令, 320–321, 324, 329
-
字符类型转换, 445
-
修正, 337
-
cvttsd2si 指令, 317–318, 324
-
字符类型转换, 445
-
修正,337
-
D
-
悬挂 else 歧义,120–121
-
数据流分析,563,584–585
-
额外资源,611
-
活跃性分析,604–609
-
汇编程序,633–636
-
达到副本分析,589–598
-
Data 操作数,236–238
-
常量,326,339
-
偏移,529,550,551
-
数据段,221
-
戴森,布鲁斯,344
-
死代码消除,564–565,603–609
-
与其他优化结合,569
-
活跃性分析,604–609
-
迭代算法,607–608
-
meet 操作符,606–607
-
传递函数,605–606
-
使用第二部分 TACKY 程序,608–609
-
安全影响,566
-
DeallocateStack 指令,194–195,198–199,202,264
-
调试器,xxxiv,675–698
-
GDB(GNU 调试器),xxxiv–xxxv,677–687
-
LLDB(LLVM 调试器),xxxv,687–698
-
声明,94,162–163,208–220。另见 函数声明;变量声明
-
与定义,214–216
-
隐藏,133
-
链接,166–168,209–212
-
范围,131–134,208–209
-
与语句,98–99
-
结构类型,486–491
-
声明符,356–361
-
摘要,361–363,395–396
-
数组,357–358,394–396
-
在 C 标准中,358
-
函数,357
-
解析,358–361,362–363,394–396
-
指针,356,361
-
递减(--)操作符,31–32,33,113
-
default 语句,159
-
度数,638
-
度数 < k 规则,638
-
解引用 (*) 操作符,349–350
-
& 操作符应用于结果,353
-
解析,354–355
-
指向 void 的指针作为操作数,473–475
-
TACKY for,371–374
-
类型检查,364–365
-
DereferencedPointer 构造,372–374,408,410,515–517
-
派生类型,354
-
不相交集合数据结构,663–664
-
div 指令,286,287–288
-
修正,290
-
除法 (/) 操作符,47–48
-
汇编 for,60–63
-
浮点,315,327
-
无符号,286,288
-
解析,50–55
-
TACKY for,58
-
类型检查,254–255,369
-
除法赋值 (/=) 操作符,113
-
divsd 指令,315
-
DivDouble,324–325
-
修正,337
-
do 语句,144,148–151,152–155,156
-
Dot 操作符,495。另见 结构成员操作符
-
.double 指令,312,338–339
-
双精度扩展浮点格式,299
-
双精度浮点格式,297–299
-
双精度舍入误差,306
-
额外资源,344
-
类型转换与,320–323
-
DoubleToInt 指令,309–310
-
汇编 for,317–318
-
DoubleToUInt 指令, 309–310
-
汇编语言 for, 318–320
-
double 类型, 295–301。另见 浮动点常量;浮动点运算
-
对齐, 336
-
汇编语言类型, 324
-
转换。参见 整数类型中的 double 的转换;double 的类型转换
-
在函数调用中, 312–315, 329–333
-
表示, 297–299
-
精度, 301
-
四舍五入, 299–301
-
大小, 336
-
谓词, 302, 305, 306–307
-
静态初始化器 for, 308–309, 340
-
类型检查, 308–309
-
Drysdale, David, 21
-
D’Silva, Vijay, 611
-
动态链接器, 202
-
E
-
EAX 寄存器, 5–6, 40–41, 60–62, 185, 193, 525
-
EBNF。参见 扩展巴科斯-诺尔范式表示法
-
EDX 寄存器, 60–64, 185, 525
-
有效类型, 352
-
计算机系统的元素, The(Nisan 和 Schocken),45
-
ELF(可执行和可链接格式), 201
-
else 子句, 118–121, 126–127
-
悬挂 else 歧义, 120–121
-
编译器工程学, 第 2 版(Cooper 和 Torczon),669–670
-
等于 (==) 操作符, 71–74
-
汇编语言 for, 85–87
-
浮动点, 317, 328
-
指针比较, 352
-
TACKY for, 75–76, 77
-
类型检查, 254–255, 366–367, 476–477
-
转义序列, 429–431
-
在汇编语言中, 449–450
-
可执行和可链接格式(ELF), 201
-
可执行栈, 19
-
额外资源, 22
-
expect函数,16
-
表达式,14
-
转换为 TACKY,38
-
完整,374
-
左值与非左值,348
-
解析,34。另见 优先级提升
-
解析变量,107
-
类型检查,251–256
-
结果类型,251
-
无类型,459–460
-
表达式语句,95,98,110
-
扩展巴科斯-诺尔范式(EBNF)符号,15
-
可选序列,101
-
重复序列,100
-
至少一次,225
-
外部链接,167–168,209–211
-
外部变量,208
-
解析,227–229
-
extern说明符,207,208,210–212,213,214–217
-
在不完整类型的声明上,474,505
-
在标识符解析中,228–229
-
解析,225–226
-
在类型检查器中,230–233
-
额外学分特性,xxxii–xxxiii
-
位运算符,67
-
case语句,159
-
复合赋值运算符,113–114
-
自减(--)运算符,113
-
default语句,159
-
goto语句,128
-
自增(++)运算符,113
-
标记语句,128
-
NaN,342–343
-
switch语句,159
-
联合类型,552–553
-
F
-
取指令-执行周期,84
-
文件作用域,207–208
-
文件作用域变量声明,208–217
-
解析标识符,227–228
-
类型检查,231–232
-
菲利,托马斯,45
-
浮动点常量
-
汇编,311–312
-
发射,338–339
-
生成,324–327
-
局部标签,312,326–327,339
-
AST 表示,305–306
-
十六进制,302,338–339,345
-
词法分析,302–304
-
十进制常量的舍入,300,306
-
浮点格式,296–299
-
十进制,300
-
双精度扩展精度,299
-
双精度,297–298
-
IEEE 754,296–299
-
单精度,299
-
《浮点指南》(网站),343
-
浮点指令。参见 流式 SIMD 扩展指令
-
浮点运算
-
算术运算,296
-
在汇编中,311–312,315–316,327–328
-
舍入行为,301
-
比较
-
在汇编中,317,328
-
与 NaN,299,317,342
-
与负零,298,317
-
使用流式 SIMD 扩展指令,310–312
-
类型转换
-
在汇编中,317–324,328–329,445
-
舍入行为,300–301
-
在 TACKY 中,309–310,440
-
浮点寄存器。参见 XMM 寄存器
-
浮点值
-
汇编类型,324
-
在函数调用中,312–315,329–333
-
表示,297–299
-
之间的间隙,300–301,322,344
-
归一化浮点数,298
-
精度,301
-
特殊值,298–299
-
无穷大,298
-
NaN,299,342–343
-
负零,298
-
非标准数,298
-
float 类型,295,299
-
Fog, Agner,553–554
-
形式语法,14–15
-
模糊性,50,120
-
对于二元表达式,51,52
-
左递归,50
-
对于一元表达式,33,397,465
-
for 语句,144–145,148–151,152,154,157–158
-
头文件,限制条件,172,220
-
缺少控制表达式的,158
-
前向数据流分析,584
-
free 函数,460–461
-
Friedl,Steve,358
-
前端符号表,266。参见 编译器内部的符号表
-
完整表达式,374
-
FunCall 指令,182–183
-
汇编语言中的。参见 汇编中的函数调用
-
在活跃性分析中,605–606
-
可选目标,479,482
-
在达到副本分析中,591–592,601–602
-
函数调用,165
-
参数,165
-
在汇编中,161,184–194,197–199
-
带浮动点值的,312–315,329–333
-
带四字节参数,263
-
结构体中的,519–528,532–544
-
带 void 返回类型的,482
-
AST 定义,171
-
解析,172–173
-
解析标识符中的,175–176
-
TACKY 循环,182–183,479
-
类型检查,179,181–182,256
-
函数声明,162–163
-
数组类型中的,390–391
-
AST 定义,171,224,247–248
-
标识符解析中的,174,176–178
-
不完整类型,505
-
链接,166–169,209–212
-
解析,172–173,226–227
-
类型检查,179–181,230–231,257,402–403
-
带 void 参数的,466
-
函数定义,162–163
-
在汇编中,195
-
访问函数参数,195–197
-
分配栈空间,200
-
转换为 TACKY,110–111,182–183
-
嵌套,163
-
旧式,164
-
函数指针,164,359–361,364
-
函数序言和尾声,26–27,29–31
-
发射,43–44
-
函数,161–169
-
参数,165
-
调用约定,161,184–194,312–315,519–528
-
声明符,357
-
参数,162–163,165,177
-
类型,178–179,247–248
-
可变参数,191
-
带有 void 返回类型,458,469–470,479,482
-
G
-
GAS(GNU 汇编器),268,338
-
GCC,xxxiv–xxxv,4–5
-
浮点数支持,296–297,317–318,344
-
实现定义的类型转换,245
-
安装,xxxiv–xxxv
-
语言扩展,395,401,471
-
窄参数的处理,445
-
优化,27,558–559
-
未定义行为检测器,672
-
void 的处理,474–475
-
gcc 命令,4–5
-
使用 Clang 调用,xxxv,4
-
GDB(GNU 调试器)
-
调试汇编代码,677–687
-
安装,xxxiv–xxxv
-
通用寄存器,311
-
George,Lal,670
-
George 测试,659–663
-
其他资源,670
-
限制,661–663
-
GetAddress 指令,370–372,374
-
别名分析和,599–601
-
汇编,376
-
get_common_pointer_type 函数,366–368,468
-
get_common_type 函数,254–255,280,308,435
-
Ghuloum, Abdulaziz, xxvi
-
Gibbons, Phillip, 611,670
-
全局偏移表(GOT),223
-
全局符号,5,168–169
-
.globl 指令,5,20,168–169,221,238
-
GNU 汇编器(GAS),268,338
-
Goldberg, David, 343
-
goto 语句,128
-
图着色,622–646
-
算法,638–646
-
度数 < k 规则,638
-
乐观着色,669
-
溢出寄存器,627–630,642–644,646
-
大于 (>) 操作符,71–74
-
汇编,见 85–87
-
浮点,317,328
-
无符号,287–288
-
指针比较,389–390
-
TACKY for,75–76,77
-
类型检查,254–255,401
-
大于或等于 (>=) 操作符,71–74
-
汇编,见 85–87
-
浮点,317,328
-
无符号,287–288
-
指针比较,389–390
-
TACKY for,75–76,77
-
类型检查,254–255,401
-
H
-
Hailperin, Max, 670
-
“Hello, World!” 程序,204,451–453
-
十六进制浮点常量,302,338–339,345
-
Hilfinger, Paul, 611
-
Hyde, Randall, 199
-
I
-
标识符解析,174–178,227–229,364。 另见 变量解析
-
从变量解析重命名,174
-
结构标签,498–500
-
标识符,8
-
自动生成, 37–38, 105–106
-
词法分析, 8–10
-
连接, 167–169, 209–212
-
范围, 131–134, 208–209
-
结构标签, 486–488, 489–490
-
在符号表中, 179–181, 229–233, 257–258
-
类型, 178–179
-
idiv 指令, 60–65, 262
-
生成, 66, 270
-
IEEE 754 标准, 296–299
-
额外资源, 343–344
-
双精度格式, 297–299
-
浮点格式, 296–299
-
舍入模式, 299
-
if 语句, 117–121
-
AST 定义, 118–119
-
解析, 118–121
-
悬挂的 else 歧义, 120–121
-
解析变量, 125–126
-
TACKY 的, 126–127
-
立即数, 18, 268
-
作为函数参数, 198–199
-
推断大小, 266
-
Imm 操作数, 18–20
-
实现定义行为, 245–246
-
char 的符号性, 424
-
ptrdiff_t, 400
-
舍入行为, 307
-
size_t, 460
-
源字符集, 430
-
类型转换, 245, 352
-
imul 指令, 60, 62–63
-
生成, 66, 270
-
修正, 64–65, 268
-
不完整类型, 461–462
-
在后端符号表中, 530, 546
-
在函数声明中, 505
-
指针指向, 461–462, 471–472, 473, 505
-
结构类型, 486–487, 505–506
-
类型检查, 471–473, 505–506
-
增量(++)运算符,113
-
不确定顺序的求值,58–59,82
-
索引寻址,412
-
索引 操作数,412–415
-
发射,419
-
初始化器,94。另见 复合初始化器;静态初始化器
-
数组,385,425,440
-
字符串字面量,425–426,437–438,440–441
-
无效的,220
-
解析标识符,105–106
-
结构类型,492
-
针对的 TACKY,110,440–441
-
在其自身中使用变量,106–107
-
指令修复阶段,42–43
-
临时寄存器,42,64–65,325,337
-
指令指针(IP),84。另见 RIP 寄存器
-
指令寄存器,84
-
整型类,519
-
整型常量,6,8。另见 字符常量
-
解析,250–251,278
-
正则表达式,304
-
在抽象语法树中的表示,248,276
-
针对的标记,8,247,275,304
-
整型溢出,78–82
-
整型提升,424,435
-
整型
-
公共实型,254–255,279–280
-
之间的转换,244–245,274–275,279–280
-
在汇编中,244–245,263,286–288,443–444
-
转换等级,279
-
在 TACKY 中,259–260,281–283
-
转换为和从 double 类型
-
在汇编中,317–324,328–329,445
-
舍入行为,300–301
-
在 TACKY 中,309–310,440
-
解析说明符,249–250,277–278
-
英特尔 64 软件开发者手册,xxxvi,344
-
英特尔语法,6
-
交互设备,560
-
中间表示(IRs),35–36
-
控制流图,570,576–581
-
内部链接,209–212
-
跨过程优化,570
-
内部过程优化,570
-
IntToDouble 指令,309–310
-
汇编,320
-
int 类型
-
对齐,246
-
大小,244
-
静态初始化器,257
-
指令指针(IP),84
-
迭代寄存器合并,663
-
迭代算法,585
-
拷贝传播,593–599
-
死存储消除,607–608
-
J
-
je 指令,84–85
-
JmpCC 指令,85–86,89
-
jmp 指令,83–84,85
-
jne 指令,85
-
乔尔·琼斯,21
-
JumpIfNotZero 指令,75–76
-
JumpIfZero 指令,75–76
-
Jump 指令,75–76
-
跳转指令(汇编),83–85
-
在汇编生成中,85–87
-
条件,84–85
-
发出,89
-
je,84–85
-
jmp,83–84,85
-
JmpCC,85–86,89
-
jne,85
-
无条件跳转指令,83
-
跳转指令(TACKY),75–77,126–127,155–158
-
汇编,转换为,86–87,328
-
条件,75–76
-
常量折叠,561,573–576
-
移除无用的,582–583
-
无条件跳转指令,75–76
-
K
-
k-可着色图,622,624–625,627
-
Kell,Stephen,474
-
关键字,9–10
-
被杀死的副本,587
-
被杀死的变量,603
-
Korn,Jeff,22
-
L
-
带标签语句,128
-
带标签的语句(汇编),5,83–87
-
发射,88–89
-
本地,89,326–327,450
-
对于静态变量,221–222
-
标签(TACKY),75–77,86,126–127,155–158
-
避免命名冲突,77
-
移除无用的,582–583
-
懒绑定,202
-
lea(加载有效地址)指令,376–379
-
左结合操作,50,53
-
左移(<<)运算符,67
-
左移赋值(<<)运算符,67
-
小于(<)运算符,71–74
-
汇编语言 for,85–87
-
浮点,317,328
-
无符号,287–288
-
指针比较,389–390
-
TACKY for,75–76,77
-
类型检查,254–255,401
-
小于或等于(<=)运算符,71–74
-
汇编语言 for,85–87
-
浮点,317,328
-
无符号,287–288
-
指针比较,389–390
-
TACKY for,75–76,77
-
类型检查,254–255,401
-
Levien,Raph,91
-
词法分析器,4,8–10。另见 标记
-
对象的生命周期,212–213,461,508
-
行标记,7
-
连接,166–168,209–212
-
在汇编中,168–169,220–221
-
冲突,176,217–219,228–229,230–232
-
外部,167–168,209–211
-
标识符解析和,175–177,227–229
-
内部,209–212
-
类型检查和,229–233
-
链接器,xxviii,5–6
-
额外资源,21
-
动态,202
-
和标识符链接,168–169
-
调用,7
-
重定位,6
-
和共享库,202,301
-
符号解析,6,174
-
符号,5
-
符号表,5,89
-
.literal8 指令,312
-
.literal16 指令,312
-
小端,86
-
活跃性分析,584
-
汇编程序,633–636
-
meet 操作符,633–634
-
传输函数,634–636
-
用于死存储消除,604–609
-
迭代算法,607–608
-
meet 操作符,606–607
-
传输函数,605–606
-
活跃范围,625–626
-
Linux,xxxiv
-
.align 指令,221
-
本地标签前缀,89
-
程序链接表(PLT),201
-
只读数据段,339
-
设置指令,xxxiv–xxxv
-
LLDB,xxxv,687–698
-
LLVM 编译器框架,36,599
-
汇编器,268,338
-
Clang,xxxiv–xxxv,4
-
复制传播,611
-
中间表示,599
-
LLDB,xxxv,687–698
-
加载有效地址(lea)指令,376–379
-
Load 指令,370–371,374–375
-
汇编用于,376,531
-
局部标签,89
-
用于汇编常量,312,326–327,339,450
-
局部变量,93–95。另见 变量声明
-
赋值,94–95,107,110
-
声明,94,98,105–106,110
-
初始化器,94,106–107,110
-
链接,167–168,209
-
解析,104–108,136–139,178,228–229
-
在堆栈上,29
-
存储持续时间,214
-
未定义行为,96
-
逻辑运算符,71。另见各个运算符的名称
-
解析,73–75
-
优先级值,74
-
短路运算,72
-
TACKY for,75–77,259
-
用于的标记,72
-
类型检查,255,369,470
-
.long 指令,221
-
long double 类型,295,299
-
长整型,243。另见 long 类型
-
在汇编中,244–246,261–264
-
汇编类型,261
-
unsigned long 类型,273–281
-
long 关键字,247
-
long 类型,243
-
对齐,246
-
常量的,247–248,250–251,254
-
转换,244–245,274–275
-
大小,244
-
静态初始化器,257–258
-
Longword 汇编类型,261–262
-
长字,6,244,267,270
-
l 后缀,60
-
循环,144–148
-
分析, 638, 670
-
do, 144–146, 148–151, 154, 156
-
对溢出成本的影响, 638
-
封闭循环, 146, 151, 153
-
for, 144–145, 148–151, 152, 154, 157–158
-
标记, 150, 152–155
-
解析中的变量, 151–152
-
TACKY for, 155–158
-
while, 144, 148–150, 151–155, 157
-
.L 前缀, 89。另见 局部标签
-
l 值, 95, 348, 349–350
-
转换, 348, 350
-
字符串字面量, 425, 436
-
结构成员, 491, 507–508
-
在 TACKY 中, 371–374, 515–517
-
验证, 107, 364, 365, 399, 436, 507–508
-
和 void, 474–475
-
M
-
机器相关优化, 558
-
与机器无关的优化, 557–558
-
常量折叠, 561, 573–576
-
复制传播, 563–564, 585–602
-
死存储消除, 564–565, 603–609
-
不可达代码消除, 561–562, 581–584
-
机器指令, 5–6
-
Mach-O 文件格式, 201
-
macOS, xxxiv
-
局部标签前缀, 89
-
平台特定指令, 221, 238–239, 312, 339, 428, 450
-
用户定义名称的前缀, 19, 201, 238
-
设置说明, xxxiv–xxxv
-
main 函数,4,6,169
-
make_tacky_variable 函数,261
-
make_temporary 函数,37–38
-
malloc 函数,460
-
尾数,297
-
meet 操作符,585
-
活跃性分析,606–607
-
汇编程序,633–634
-
复制分析,592–593
-
成员访问操作符。参见 结构成员操作符;结构指针操作符
-
MEMORY 类,519
-
内存管理函数,457–458,460–461
-
aligned_alloc,461
-
calloc,461
-
free,460–461
-
malloc,460
-
realloc,461
-
Memory 操作数,375–379
-
mov 指令,5–6,18,261–262
-
发射,20
-
修正,42,268,270
-
movsd 指令,311–312
-
movsx 指令,244–245,261,263,444
-
发射,269,450–451
-
修正,267
-
符号扩展,263
-
MovZeroExtend 指令,287–289,443–444
-
转换为 double,329,445
-
发射,450–451
-
修正,290,449
-
movz 指令,443,449
-
斯蒂芬·马许尼克(Steven Muchnick),669
-
mulsd 指令,315
-
发射,参见 341–342
-
修复,参见 337
-
多维数组,参见 384–385,386–389,393
-
乘法(*)运算符,参见 47–48
-
汇编,参见 60,62–63
-
浮动点,参见 315,327
-
解析,参见 50–55
-
TACKY for, 58
-
类型检查,参见 254–255,369
-
乘法赋值(*=)运算符,参见 113
-
Myers, Joseph,参见 218
-
N
-
NaN(非数值),参见 299,342–343
-
比较,参见 299,317,342
-
额外学分,参见 342
-
安静,参见 299
-
信号,参见 299
-
否定(-)运算符,参见 26
-
汇编,参见 26–27,40–41
-
浮动点,参见 315–316,327–328
-
解析,参见 33–34
-
TACKY for, 36–38
-
标记,参见 31–32
-
类型检查,参见 254,369,435
-
负无穷,参见 298
-
负零,参见 298,317,326,340
-
neg 指令,参见 26–27,40–41,44
-
发射,参见 44,270
-
嵌套函数定义,参见 163
-
Nisan, Noam,参见 45
-
非标量类型,参见 470–471
-
非终结符号,参见 15
-
NOT (!)运算符,参见 71–74
-
汇编,参见 86,328
-
TACKY for, 75–76, 77
-
类型检查,参见 254,369,470
-
不等于(!=)运算符,参见 71–74
-
汇编,参见 85–87
-
浮动点,参见 317,328
-
指针比较,参见 352
-
TACKY for, 75–76, 77
-
类型检查, 254–255, 366–367
-
not 指令, 26–27, 40–41
-
发射, 44, 270
-
空指针, 351–352
-
比较, 352
-
常量, 351, 366–368, 401
-
作为静态初始化器, 369
-
空语句, 98, 110
-
O
-
对象代码, xxviii
-
对象文件, xxviii, 5
-
生成, 169–170
-
部分, 5
-
BSS, 222, 340, 418
-
数据, 221–222
-
只读数据, 311–312, 339
-
文本, 5
-
对象, 348
-
生命周期, 212–213, 461, 508
-
可观察行为, 558–560
-
OF. 参见 溢出标志
-
乐观着色, 669
-
优化管道, 570–573, 600–601
-
优化。另见 与机器无关的优化和单个优化条目
-
常量折叠, 561, 573–576
-
复制传播, 563–564, 585–602
-
死存储消除, 564–565, 603–609
-
跨过程, 570
-
过程内, 570
-
机器相关, 558
-
安全性, 558
-
安全影响, 564–565
-
不可达代码消除, 561–562, 581–584
-
optimize 函数, 570–573, 601
-
终止, 572–573
-
或(||)运算符, 71–77
-
短路, 72
-
针对的 TACKY, 75–77, 259
-
类型检查, 255, 470
-
or 指令, 323–325, 337, 341
-
溢出, 78–82
-
溢出标志(OF), 78–80, 83
-
不适用,284–285,317
-
P
-
打包操作数,310,316
-
参数传递寄存器,185,312
-
参数,162–163,165,177,195–197
-
奇偶标志(PF),342
-
parse_exp 函数,16,34,51–57,101–102,124
-
解析器生成器,11
-
解析器,4,10–17。参见 递归下降解析
-
手写,11
-
优先级爬升,51–57
-
预测,16
-
parse_type 函数,249–250,277,307,433,466
-
模式匹配,xxxiii–xxxiv
-
Payer, Mathias,611
-
PF(奇偶标志),342
-
阶段排序问题,573
-
PlainOperand 构造,372–374
-
PLT(过程链接表),201–202
-
指针分析,601
-
指针算术,387–390
-
加法,387–390
-
汇编,414–415
-
与下标操作符的关系,387–389
-
减法,388–390
-
TACKY for,406–408
-
类型检查,400–401,472
-
未定义行为,388,390
-
指针比较,352–353,389–390
-
汇编,377,415
-
类型检查,366–367,401
-
TACKY for,375,408
-
指针,347,349–353。参见 空指针
-
转换到与从,351–352,460
-
指向 void 的指针,467–469
-
TACKY for,375
-
类型检查,367–369,467–469
-
声明符,356,361
-
解引用, 349–350
-
到不完整类型, 461–462, 471–472, 473, 505
-
对其的操作, 349–353
-
指针初始化, 437, 450
-
引用类型, 354
-
静态初始化器, 369–370, 428–429, 437, 438–439
-
类型检查, 364–370, 400–402, 467–469, 471–472
-
类型
-
AST 定义, 354
-
解析, 356–364
-
弹出指令, 27–28, 30–31, 620–621, 648
-
发射, 44, 649
-
正无穷大, 298
-
后缀运算符, 113, 396–397, 498
-
后序遍历, 49
-
优先级提升, 47, 51–57
-
额外资源, 68
-
与递归下降解析结合, 52–53
-
示例, 55–57
-
伪代码, 54
-
与赋值运算符, 102
-
使用条件运算符, 124
-
右结合运算符, 101–102
-
优先级值
-
算术运算符, 55
-
赋值运算符, 103
-
条件运算符, 123
-
逻辑运算符, 74
-
关系运算符, 74
-
预着色寄存器干扰图, 625
-
预测分析器, 16
-
前缀运算符, 113, 396
-
预处理器, xxviii, 7
-
格式化打印机, 17
-
程序链接表 (PLT), 201–202
-
产生式规则, 15
-
伪内存操作数, 412–414
-
替换, 417–418
-
伪操作数, 40–42
-
伪寄存器, 40–41
-
替换, 42, 237, 267
-
推送指令, 27–30, 194–195
-
发射, 43, 203
-
修复, 378–379
-
传递参数, 188–189, 198–199, 263, 332
-
putchar 函数, 204
-
puts 函数, 451–453
-
Python, xxxiv
-
Q
-
.quad 指令, 246, 270, 428, 450
-
Quadword 汇编类型, 261–262
-
四字组, 6
-
参数, 263
-
指令, 244, 261–262
-
后缀, 6, 269
-
伪寄存器, 267
-
静态,246
-
R
-
RAX 寄存器, 5–6, 40–41, 60–62, 185, 193, 525
-
RBP 寄存器, 29–30, 375
-
RDX 寄存器, 60–64, 185, 525
-
达到拷贝, 589
-
达到拷贝分析, 584, 589–599
-
迭代算法, 593–599
-
meet 操作符, 592–593
-
传输函数, 589–592, 601–602
-
只读数据区, 311–312, 339
-
realloc 函数, 461
-
递归下降解析, 15–17
-
带回溯, 17
-
二元运算, 50–51
-
与优先级提升结合, 52–53
-
处理悬挂的 else 歧义, 120–121
-
声明符, 359
-
优先级和结合性问题, 50–51
-
雷根,里克, 344–345
-
雷吉尔,约翰, 91
-
寄存器分配, 613–619。参见 溢出
-
附加资源, 669–670
-
图着色, 622–646
-
算法, 638–646
-
degree < k 规则, 638
-
处理多种类型, 631, 637
-
寄存器合并,618–619,651–668
-
迭代,663
-
顶层算法,630
-
寄存器合并,614,618–619,651–653,663–667
-
保守合并,653,656,670
-
布里格斯测试,657–659,661–663,666–667,669–670
-
乔治测试,659–663,666–667,670
-
迭代,663
-
更新图表时,653–656,663,666
-
寄存器干扰图,622–626
-
建筑,631–637
-
着色,622–625,638–646
-
预着色,625
-
检测干扰,623–624,626–627
-
更新,653–656,666
-
寄存器,5–6
-
别名,40
-
汇编 AST 定义,18,40,62,620–621
-
参数传递寄存器,195
-
RBP 寄存器,375
-
RSP 寄存器,264
-
XMM 寄存器,325
-
调用者保存和被调用者保存,185,620–621,645–646,648–649
-
通用,311
-
指令,84
-
参数传递,185,195,312
-
XMM,311–312,316,325
-
Reg 操作数,40
-
关系运算符,71。另见各个运算符名称
-
汇编,85–88
-
浮动点,317,328
-
无符号,285,287–288
-
解析,73–75
-
优先级值,74
-
指针操作数,352,389–390
-
TACKY,75–76
-
令牌,72
-
类型检查,254–255,366–367,401,476–477
-
余数(%)运算符, 47–48
-
汇编, 60–63
-
无符号, 288
-
解析, 50–55
-
TACKY for, 58
-
类型检查, 254–255, 308, 369
-
余数赋值(%=)运算符, 113
-
替换伪寄存器, 42
-
不同类型, 267
-
PseudoMem 操作数, 417–418
-
静态存储周期, 237
-
ret 指令, 6, 18
-
发射, 20, 44
-
return 语句, 4
-
汇编, 18, 333, 482, 545–546
-
AST 定义, 13–14
-
缺失, 111–113, 458
-
解析, 14–17
-
无返回值, 458, 469–470, 479, 482
-
TACKY for, 36–38, 479
-
类型检查, 256–257, 469–470
-
返回值, 4–6, 14
-
缺失, 458, 469–470, 479, 482
-
分类, 537–538. 另见 classify _return_value 函数
-
浮点类型, 312–313, 333
-
结构类型, 525–528, 537–541, 545–546
-
rewrite_coalesced 函数, 667–668
-
RFLAGS 寄存器, 78
-
右结合运算符, 50, 101–102, 123–124
-
右移(>>)运算符, 67
-
右移赋值(>>=)运算符, 113
-
RIP 寄存器, 83–84, 189–190, 222
-
RIP 相对寻址,222,223,311,376,529
-
数据操作数,236–238
-
Ritchie, Dennis,390
-
.rodata指令,311–312,339,428,450
-
Rosetta 2,xxxv
-
四舍五入模式,299,320
-
四舍五入至最近偶数,299,321,575
-
四舍五入至奇数,322–324
-
RSP 寄存器,27–30,43–44,185,264
-
S
-
优化的安全性,558
-
标量类型,384,470–471
-
Schocken, Shimon,45
-
作用域,131–134,208–209
-
块作用域,208
-
复合语句的确定,131–134
-
文件作用域,207–208
-
与存储持续时间的比较,213
-
.section .rodata指令,311–312
-
语义分析,93,103–104
-
标识符解析(即变量解析),104–109,174–178,227–229
-
循环标记,150,152–155
-
类型检查,178–182,251–258
-
Serra, Christopher,22
-
SetCC指令,85–87
-
发射,89–90
-
set_up_parameters函数,544–545
-
SF(符号标志),78–80,83
-
左移(shl)指令,529–530,541–543,551
-
右移(shr)指令,320–321,323–325,529
-
双操作数形式,529,543,551
-
短路运算符,72,76–77
-
signed char 类型,423–424
-
有符号整数,243
-
溢出,78–82
-
表示,26,61,244
-
类型转换,244–246,274–275
-
signed 关键字,275
-
SignExtend 指令,259–260,263,282–283
-
符号扩展,61,244–245,275
-
在汇编中,263,444
-
在 TACKY 中, 259–260,282–283
-
符号标志(SF),78–80,83
-
重要程度,638
-
单精度格式,299
-
sizeof 运算符,458,462–466,471,477–478,480–481
-
Song, Dawn,611
-
源字符集,430
-
源文件,xxviii,7–8,208
-
特殊字符,429,450
-
特殊序列(EBNF),15
-
溢出,616,627–630,642–644,646
-
候选,642
-
溢出代码,616,620
-
溢出成本,630–631,638,642,644–645
-
SSA(静态单一赋值)形式,672
-
SSE。参见 流式 SIMD 扩展指令
-
SSE 类,519
-
堆栈,19,27–31
-
对齐,185,197–198,648–649
-
可执行文件,19
-
附加资源,22
-
帧,29–31
-
分配,42,197–199,200–201
-
指针,27
-
堆栈帧,29–31
-
Stack 操作数,40,42,44
-
被替换为 Memory 操作数,375
-
StaticConstant 构造(汇编),324,326,336,446
-
发射,340
-
StaticConstant 构造(TACKY),442,446
-
静态初始化器,213–214。另见 ZeroInit 构造
-
在汇编中,221–222,238–239
-
对于字符,436
-
复合,404–405
-
在汇编中,418–419
-
对于结构体,509–511
-
对于 double 类型,308–309,340
-
对于长整型,246,257–258,270
-
对于指针,369–370,428–429,437,438–439
-
空指针作为,369
-
字符串作为,437–439
-
在符号表中,257
-
类型检查,257–258
-
对于无符号整数,280–281
-
静态单一赋值(SSA)形式,672
-
static 说明符,208,209–211,213,216–217,230–233
-
静态存储持续时间,213–214。另见 静态变量
-
使用替代寄存器,237
-
StaticVariable 构造(汇编),235–236,263–264,413
-
发射,238–239
-
StaticVariable 构造(TACKY),234–235,258–259,406
-
静态变量,213–214
-
汇编,221–222,235–239,246
-
初始化,213–214。另见 静态初始化器
-
在 TACKY 中,234–235
-
类型检查,229–230,231–233
-
状态标志,78–80
-
进位,284–285,317
-
溢出,78–80,83
-
奇偶性,342
-
符号, 78–80, 83
-
零, 78–80, 83
-
Sterbenz 引理, 319
-
存储类别说明符, 207–208, 223
-
效果, 209–217
-
解析, 225–226
-
存储持续时间, 207, 212–213
-
已分配, 213, 461
-
在汇编中, 221–222
-
自动, 212–213, 217
-
vs. 范围, 213
-
静态, 213–214, 237
-
在符号表中, 229–230
-
线程, 213
-
Store 指令, 370–374
-
及活跃性分析, 609
-
及到达副本分析, 599–600, 601–602
-
流式 SIMD 扩展 (SSE) 指令, 310–312
-
算术, 315–316
-
比较, 317
-
类型转换, 317, 320
-
严格别名规则, 352
-
字符串字面量, 425–426
-
作为数组初始化器, 425, 426
-
TACKY for, 440–441
-
类型检查, 437–438
-
在汇编中, 426–429
-
AST 定义, 324
-
发射, 449–450
-
指定常量字符串, 425–426
-
TACKY for, 441–442
-
类型检查, 436, 438–439
-
词法分析, 429–431
-
左值, 425, 436
-
解析, 433
-
struct 关键字, 494
-
结构成员 (.) 运算符, 491, 495
-
解析, 497–498
-
TACKY for, 513–517
-
for 词法单元, 494
-
类型检查, 506–508
-
结构体指针 (->) 运算符, 491, 495
-
解析, 497–498
-
TACKY for, 514–515, 517
-
for 词法单元, 494
-
类型检查, 506–507
-
结构标签, 486–488, 489–490
-
解析, 498–500
-
结构体类型
-
分类, 519–522, 533–534
-
完整, 486–487, 503
-
复制, 531–532
-
声明, 486–491
-
定义, 486
-
在类型表中, 501–502
-
验证, 501
-
在函数调用中, 519–528, 532–546
-
不完整, 486–487, 490, 503, 505–506
-
初始化器, 492
-
TACKY for, 517–518
-
类型检查, 509–511
-
内存中的布局, 492–494
-
未实现, 490–491
-
操作, 491–492。 参见 结构体成员操作符; 结构体指针操作符
-
填充, 493, 510–511, 518–519
-
返回值, 525–528, 545–546
-
格式化符, 498
-
标签, 486–488, 489–490
-
解析, 498–500
-
类型检查, 500–511
-
sub 指令, 29–30, 60, 62–63
-
发射, 66, 270
-
修复, 64, 268
-
非规格化数, 298
-
SubObject 构造, 515–517
-
下标([])操作符, 389
-
AST 定义, 393
-
生成, 408
-
解析, 396–397
-
TACKY for, 408–410
-
类型检查, 399, 401–402, 471–472
-
subsd 指令, 315
-
修复, 337
-
减法(-)操作符, 47–48
-
汇编, 60, 62–63
-
浮点数, 315, 327
-
解析, 50–55
-
指针减法, 388–390
-
TACKY for, 406–408
-
类型检查, 400–401, 472
-
TACKY for, 58
-
类型检查,254–255
-
减法赋值(-=)操作符,113
-
switch 语句,159
-
符号(汇编),5
-
全局与局部,168–169
-
符号表,5,89
-
编译器内部的符号表,174–175,179–181。另见 后端符号表
-
从中生成 TACKY 顶级定义,234–235,442
-
标识符属性,229–233,257–258,438
-
重命名为前端符号表,266
-
临时变量,260–261
-
临时定义,229–230,235
-
对象文件中的符号表,5,89
-
System V x64 ABI,xxxvi,184。另见 System V x64 调用约定
-
数组的对齐,415
-
char,符号的有符号性,424
-
Clang 对此的违规,444–445
-
浮动点格式,296,297
-
int 和 long
-
对齐,246
-
大小,244
-
size_t,定义,460
-
结构体的大小和对齐,493
-
System V x64 调用约定,184–194
-
其他资源,344,553–554
-
值的分类,519
-
浮动点值,312–315
-
狭义参数,444–445
-
结构体,519–528
-
T
-
TAC(三地址码),35–36
-
TACKY,36–38
-
Constant 操作数,36
-
生成,37–38
-
地址(&)操作符,370–372,374,514,516–517
-
赋值表达式,110,371–374,516
-
二元表达式,58
-
break 和 continue 语句,155–156
-
类型转换表达式,259–260,281–283,309–310,375,440,479
-
复合初始化器,406,410–411,517–518
-
复合语句,140
-
条件表达式,127,479–480
-
解引用(*)运算符,371–374
-
函数调用,182–183,479
-
函数定义,182–183
-
if 语句,126–127
-
循环,155–158
-
指针运算,406–408
-
return 语句,36–38,479
-
短路运算符,76–77
-
sizeof 运算符,480–481
-
静态变量,234–235
-
结构体成员访问运算符,513–517
-
下标([])运算符,408
-
一元表达式,37–38
-
指令,42–43
-
lvalue 转换,371–374,515–517
-
临时变量,36–38,260–261
-
顶层常量,442
-
Var 操作数,36
-
Taylor, Ian Lance,21,22
-
临时生命周期,508
-
临时变量,36–38
-
命名,38
-
在栈上,29
-
符号表中,260–261
-
暂定定义,215–216
-
转换为 TACKY,235
-
在符号表中,229–230,235,411
-
类型检查,231–232
-
未定义行为,219–220
-
终结符,15
-
三元运算符,121。另见 条件表达式
-
.text 指令,238
-
文字段,5,283
-
线程存储持续时间,213
-
三地址码 (TAC),35–36。另见 TACKY
-
标记,3,8–10
-
常量,8–10
-
字符,429
-
浮点数,302–304
-
整数,8,247,275,304
-
标识符,8–10
-
字符串字面量,429
-
托尔宗,琳达,669–670
-
转换函数,584–585
-
活跃性分析,605–606
-
汇编代码,634–636
-
与第二部分类型一起使用,608–609
-
可达副本分析,589–592
-
与第二部分类型一起使用,601–602
-
翻译单元,167,208
-
截断指令,259–260,263,282
-
二补数,26,45,78,274
-
类型检查,178–182,251–258
-
数组,398–399,402–405,471,472–473
-
复合初始化器,403–405,509–511
-
声明,179–181,230–233,257–258,402–403
-
double,308–309
-
表达式,253–256
-
算术运算符,254–255,369,435,476–477
-
赋值,256,368,399
-
按位取反(~)运算符,308,369,435
-
类型转换,254,369,402,471,505
-
条件,256,368,467,470,476,508
-
逻辑运算符,254–255,470
-
指针运算,400–401,472
-
关系运算符, 254–255, 366–367, 401, 476–477
-
余数 (%) 运算符, 254–255, 308, 369
-
sizeof 运算符, 477–478
-
结构成员访问运算符, 506–507
-
subscript ([]) 运算符, 399, 401–402, 471–472
-
文件作用域变量声明, 231–232
-
函数调用, 179, 181–182, 256
-
不完整类型, 471–473, 505–506
-
指针, 364–370, 400–402, 467–469, 471–472
-
return 语句, 256–257, 469–470
-
字符串字面量, 436–439
-
结构类型, 500–511
-
类型错误, 174
-
类型转换
-
在汇编中, 244–245, 317–324
-
浮点数, 317–324, 328–329
-
符号扩展, 244–245, 444
-
截断, 245, 263, 444
-
零扩展, 286–288, 443–444
-
字符, 443–445
-
double, 317–324
-
从字符类型转换, 445
-
四舍五入行为, 300–301
-
未定义, 308, 371
-
实现定义, 245, 352
-
隐式, 254–255, 279, 351, 467–469
-
像赋值那样, 368, 468–469, 504–505
-
Cast 表达式表示, 255
-
常规算术转换, 254–255, 279–280, 308, 435
-
整数, 244–245, 274–275
-
指针, 351–352, 460
-
在 TACKY 中, 259–260, 281–283, 309–310
-
Copy, 259–260
-
到和从 double, 309–310
-
SignExtend, 259–260
-
Truncate, 259–260
-
ZeroExtend, 281–283
-
typedef 声明, 108–109
-
类型错误, 174. 另见 类型检查
-
类型名称, 361–363, 462, 465–466
-
类型, 178–179. 另见 字符类型; 整数类型; void 类型
-
聚合, 384
-
算术, 347, 476–477
-
数组, 384–392
-
派生, 354
-
在 exp 节点上, 252–253
-
浮点数, 295–299
-
函数, 178–179, 247–248
-
不完整, 461–462
-
非标量, 470–471
-
指针, 347, 349–353
-
标量, 384, 470–471
-
类型说明符
-
char, 429
-
字符, 433
-
double, 302, 306–307
-
int, 8
-
整数, 249–250, 277–278
-
long, 247
-
signed, 275
-
结构体, 498
-
unsigned, 275
-
void, 8
-
类型表, 500–502, 503–504, 506–507, 509–511, 515, 517–518
-
U
-
UIntToDouble 指令,309–310
-
汇编,320–324
-
乌尔曼,杰弗里,611
-
一元表达式,25–27,31–38
-
AST 定义,33
-
解析,33–34
-
格式文法,33,397,465
-
TACKY for,36–38
-
类型检查,254
-
一元运算符。参见 一元表达式 和单个运算符名称
-
无条件跳转指令。参见 跳转指令(汇编);跳转指令(TACKY)
-
未声明的变量,104,107,134
-
未定义行为,80–82
-
额外资源,91
-
冲突链接,218–219
-
安全处理,672
-
整数溢出,80–82
-
缺少 return 语句,111–112
-
修改对象,425–426,508
-
越界类型转换,308,317
-
指针运算,388,390
-
指针解引用,351–352
-
临时定义,219–220
-
变量访问,96,106–107
-
未定义行为检测器,672
-
联合类型,552–553
-
通用字符名称,10
-
不可达代码消除,561–562,581–584
-
与其他优化相结合,569
-
无序计算,58–59,82
-
unsigned char 类型,424
-
无符号整数,273–289
-
在汇编中,283–289
-
汇编类型,287
-
无符号比较,283–285,287–288
-
无符号除法,286,288
-
常量,275–278
-
正则表达式,304
-
静态初始化器,280–281
-
类型转换,274–275,279–280,282–283
-
unsigned int 类型,273–281
-
unsigned long 类型,273–281
-
环绕,79,285–286,575
-
unsigned 关键字,275
-
常规算术转换,254–255,279–280,308,435
-
V
-
值,348
-
变量声明,94–95,208–220
-
数组类型,384–385
-
AST 定义,98,171
-
连接,209–212
-
解析,100–101,224–227
-
解析标识符,105–106,138–139,227–229
-
范围,131–134,208–209
-
块范围,208
-
文件范围,208
-
存储持续时间,212–214
-
类型检查,179–180,231–233,257–258
-
变量解析,104–108,136–139,227–229
-
条件表达式,125–126
-
if 语句,125–126
-
循环,151–152
-
多重范围,136–139
-
重命名标识符解析,174
-
变量,93–97,208–222。另见 静态变量
-
别名,599–602,609,637
-
自动,208
-
外部,208,227–229
-
活跃,603
-
局部,93–95
-
解析,104–107,227–229
-
范围,131–134,208–209
-
在 TACKY 中,36–38,110
-
临时,36–38,260–261
-
类型检查,181,253
-
变量解析,107
-
可变参数函数,191
-
void 表达式,459
-
void 关键字,8–9
-
as 参数列表,162,459,466–467
-
void 类型,458–460
-
强制转换到,459,471,479
-
使用条件表达式,459,476,479–480
-
在 C 标准中,458,474–475
-
返回函数,458,469–470,479,482
-
指针,460–461,475
-
转换到和从,467–469
-
对于,473–476
-
当有效时,473–475
-
易变对象,560
-
W
-
王丹尼尔,22
-
while 语句,144,148–150,151–155,157
-
空白字符,9–10
-
宽字符类型,424
-
Windows 子系统 for Linux (WSL),xxxiv
-
w 后缀,28
-
X
-
x64 指令集,xxvii。参见 汇编代码 及单个指令名称
-
AT&T 与 Intel 语法,6,244,570
-
文档,xxxvi
-
流式 SIMD 扩展指令,310–312
-
x64 处理器,xxxiv
-
小端序,86
-
内存地址大小,28
-
x86-64。参见 x64 指令集;x64 处理器
-
Xcode,xxxiv–xxxv
-
XMM 寄存器,311–312,325
-
分配,631
-
构建寄存器干扰图,637
-
在函数调用中,312–315,329–333,519,532–541,545–546
-
清零,316
-
XOR(^)运算符,67
-
XOR 赋值(^=)运算符,113
-
xorpd 指令,316,324–325,328
-
发射,341
-
修复,337
-
Y
-
杨爱德华,253
-
杨兆茂,611
-
Z
-
Zephyr 抽象语法描述语言。参见 ASDL
-
.zero指令,222
-
ZeroExtend指令,281–283,288
-
零扩展,274,281–282,286–288,443–444
-
零标志(ZF),78–80,83
-
comisd,由,317
-
在无符号比较中,284–285
-
ZeroInit构造,405
-
发射,418–419
-
初始化填充,510–511
-
初始化标量变量,405
-
初始化暂时定义的数组,411
流程图展示了编译过程的各个步骤。
1. 过程从 C 源代码(文本)开始。
2. 预处理器将源代码转化为预处理后的源代码(文本)。
3. 编译器将预处理后的源代码转化为汇编代码(文本)。
4. 汇编器将汇编代码转化为目标文件(二进制文件)。
5. 链接器将目标文件和另外两个目标文件(二进制文件)转化为可执行文件(也是二进制文件)。
返回正文
流程图展示了编译器的各个阶段。
1. 词法分析器将 program.c 转化为标记列表。
2. 解析器将标记列表转化为抽象语法树。
3. 汇编生成将抽象语法树转化为汇编代码。
4. 代码发射将汇编代码写入 program.s。
返回正文
树形图展示了抽象语法树(AST)。
· 根节点是"If(condition, then)"。
● 根节点的"condition"子节点是"Binary(operator, left, right)"。
○ "左"子节点是"Var(a)"。
○ "operator"子节点是"LessThan"。
○ "右"子节点是"Var(b)"。
● 根节点的"then"子节点是"Return(exp)"。
○ "exp"子节点是"Binary(operator, left, right)"。
■ "左"子节点是"Constant(2)"。
■ "operator"子节点是"Add"。
■ "右"子节点是"Constant(2)"。
返回正文
树形图展示了抽象语法树(AST)。每个节点只有一个子节点。
· 根节点是"Program(function_definition)"。
● "function_definition"子节点是"Function(main, body)"。
■ "body"子节点是"Return(exp)"。
● "exp"子节点是"Constant(2)"。
返回正文
流程图展示了编译器的各个阶段。
1. 词法分析器将 program.c 转化为标记列表。
2. 解析器将标记列表转化为抽象语法树。
3. TACKY 生成(一个新阶段)将抽象语法树转化为 TACKY。
4. 汇编生成将 TACKY 转换为汇编。它有三个步骤(全部是新的):
a. 将 TACKY 转换为汇编
b. 替换伪寄存器
c. 指令修正
5. 代码发射将汇编写入 program.s。
返回文本
该图展示了 push 指令执行前后的系统状态。
· 在 push 前,栈的内容从上到下如下:
● 地址 0x7ffeea685918 处的 "a"
● 地址 0x7ffeea685920 处的 "b"
● 地址 0x7ffeea685928 处的 "c"
RSP 保存地址 0x7ffeea685918,并指向 "a"。
· 执行 push 后,栈的内容从上到下如下:
● 地址 0x7ffeea685910 处的 3
● 地址 0x7ffeea685918 处的 "a"
● 地址 0x7ffeea685920 处的 "b"
● 地址 0x7ffeea685928 处的 "c"
RSP 保存地址 0x7ffeea685910,并指向 3。
返回文本
四个图展示了函数开始时以及函数序言中每条指令执行后的系统状态。
· 在函数开始时,调用者栈帧从栈顶的地址 0x7ffeea685918 开始,延伸至地址 0x7ffeea685960。RSP 保存地址 0x7ffeea685918,并指向调用者栈帧的顶部。RBP 保存地址 0x7ffeea685960,并指向调用者栈帧的底部。
· 执行 pushq %rbp 后,栈的内容从上到下如下:
● 地址 0x7ffeea685910 处的值 0x7ffeea685960
● 调用者栈帧,从地址 0x7ffeea685918 开始,延伸至地址 0x7ffeea685960。
RSP 保存地址 0x7ffeea685910,并指向栈顶的值。RBP 保存地址 0x7ffeea685960,并指向调用者栈帧的底部。
· 执行 movq %rsp, %rbp 后,栈的内容从上到下如下:
● 地址 0x7ffeea685910 处的值 0x7ffeea685960
● 调用者栈帧,从地址 0x7ffeea685918 开始,延伸至地址 0x7ffeea685960。
RSP 和 RBP 都保存地址 0x7ffeea685910,并指向栈顶的值。
· 执行 subq $24, %rsp 后,栈的内容从上到下如下:
● 地址 0x7ffeea6858f8 开始的 24 字节未使用内存
● 地址 0x7ffeea685910 处的值 0x7ffeea685960
● 调用者栈帧,从地址 0x7ffeea685918 开始,延伸至地址 0x7ffeea685960。
RSP 保存地址 0x7ffeea6858f8,并指向未使用内存的起始位置。
RBP 保存地址 0x7ffeea685910,并指向未使用内存下方的值 0x7ffeea685960。
返回文本
三个图展示了函数开始时以及函数序言中每条指令执行后的系统状态。
· 在尾声开始时,栈的内容从上到下如下:
● 从地址 0x7ffeea6858f8 开始的 24 字节局部变量。
● 地址 0x7ffeea685910 处的值 0x7ffeea685960
● 调用者栈帧,从地址 0x7ffeea685918 开始,延伸至地址 0x7ffeea685960。
RSP 保存地址 0x7ffeea6858f8,指向存储局部变量的内存开始处。
RBP 保存地址 0x7ffeea685910,指向局部变量下方的值 0x7ffeea685960。
· 执行 movq %rbp, %rsp 后,栈的内容如下,从上到下:
● 地址 0x7ffeea685910 处的值为 0x7ffeea685960
● 调用者栈帧从地址 0x7ffeea685918 开始,到地址 0x7ffeea685960 结束。
RSP 和 RBP 都保存地址 0x7ffeea685910,指向栈顶的值 0x7ffeea685960。
· 在执行 popq %rbp 后,调用者栈帧从栈顶地址 0x7ffeea685918 开始,到地址 0x7ffeea685960 结束。RSP 保存地址 0x7ffeea685918,指向调用者栈帧栈的顶部。RBP 保存地址 0x7ffeea685960,指向调用者栈帧的底部。
返回文本
流程图展示了编译器的各个阶段。
● 词法分析器将 program.c 转换为令牌列表。
● 解析器将令牌列表转换为抽象语法树。
● TACKY 生成将抽象语法树转换为 TACKY。
● 汇编生成将 TACKY 转换为汇编。它有三个步骤:
● 将 TACKY 转换为汇编
● 替换伪寄存器
● 指令修正
● 代码发射将汇编写入 program.s。
返回文本
树形图展示了抽象语法树(AST)。
● 根节点是 +。它有两个子节点。
● 根节点的第一个子节点是 1。
● 根节点的第二个子节点是 *。它有两个子节点。
● 第一个子节点是 2。
● 第二个子节点是 3。
返回文本
树形图展示了抽象语法树(AST)。
● 根节点是 *。它有两个子节点。
● 根节点的第一个子节点是 +。它有两个子节点。
● 第一个子节点是 1。
● 第二个子节点是 2。
● 根节点的第二个子节点是 3。
返回文本
流程图展示了编译器的各个阶段。
1. 词法分析器将 program.c 转换为令牌列表。
2. 解析器将令牌列表转换为抽象语法树。
3. TACKY 生成将抽象语法树转换为 TACKY。
4. 汇编生成将 TACKY 转换为汇编。它有三个步骤:
a. 将 TACKY 转换为汇编
b. 替换伪寄存器
c. 指令修正
5. 代码发射将汇编写入 program.s。
返回文本
流程图展示了编译器的各个阶段,包括一个新阶段。
1. 词法分析器将 program.c 转换为令牌列表。
2. 解析器将令牌列表转换为抽象语法树。
3. 语义分析(新阶段)将 AST 转换为转换后的 AST。它有一个步骤:
a. 变量解析
4. TACKY 生成将转换后的 AST 转换为 TACKY。
5. 汇编生成将 TACKY 转换为汇编。它有三个步骤:
a. 将 TACKY 转换为汇编
b. 替换伪寄存器
c. 指令修正
6. 代码发射将汇编写入 program.s。
返回文本
流程图展示了编译器的各个阶段。
1. 词法分析器将 program.c 转换为标记列表。
2. 解析器将标记列表转换为抽象语法树。
3. 语义分析将 AST 转换为转换后的 AST。它有一个步骤:
a. 变量解析
4. TACKY 生成将转换后的 AST 转换为 TACKY。
5. 汇编生成将 TACKY 转换为汇编。它有三个步骤:
a. 将 TACKY 转换为汇编
b. 替换伪寄存器
c. 指令修正
6. 代码发射将汇编写入 program.s。
返回文本
一个流程图显示了编译器的各个阶段。
1. 词法分析器将 program.c 转换为标记列表。
2. 解析器将标记列表转换为抽象语法树。
3. 语义分析将 AST 转换为转换后的 AST。它有一个步骤:
a. 变量解析
4. TACKY 生成将转换后的 AST 转换为 TACKY。
5. 汇编生成将 TACKY 转换为汇编。它有三个步骤:
a. 将 TACKY 转换为汇编
b. 替换伪寄存器
c. 指令修正
6. 代码发射将汇编写入 program.s。
返回文本
一个流程图显示了编译器的各个阶段,包括语义分析阶段中新增加的一个步骤。
1. 词法分析器将 program.c 转换为标记列表。
2. 解析器将标记列表转换为抽象语法树。
3. 语义分析将 AST 转换为转换后的 AST。它有两个步骤:
a. 变量解析
b. 循环标记(新增步骤)
4. TACKY 生成将转换后的 AST 转换为 TACKY。
5. 汇编生成将 TACKY 转换为汇编。它有三个步骤:
a. 将 TACKY 转换为汇编
b. 替换伪寄存器
c. 指令修正
6. 代码发射将汇编写入 program.s。
返回文本
一个流程图显示了编译器的各个阶段,包括语义分析阶段中新增加的一个步骤。
1. 词法分析器将 program.c 转换为标记列表。
2. 解析器将标记列表转换为抽象语法树。
3. 语义分析将 AST 转换为转换后的 AST。它有三个步骤:
a. 标识符解析
b. 类型检查(新增步骤)
c. 循环标记
4. TACKY 生成将转换后的 AST 转换为 TACKY。
5. 汇编生成将 TACKY 转换为汇编。它有三个步骤:
a. 将 TACKY 转换为汇编
b. 替换伪寄存器
c. 指令修正
6. 代码发射将汇编写入 program.s。
返回文本
● 左列:寄存器和堆栈并排显示。堆栈顶部的值是先前堆栈帧的保存基地址。先前的堆栈帧紧随其下。
RSP 和 RBP 指向堆栈顶部保存的基地址。RDI 保存值 15. RSI、RDX、RCX、R8、R9 和 RAX 尚未初始化。
堆栈的内容从上到下列出:
○ 值 0x7000000000160 存储在地址 0x7000000000110
○ 先前的堆栈帧从地址 0x7000000000118 扩展到 0x7000000000160
寄存器的内容如下表所示。
| 寄存器 | 内容 |
|---|---|
| RSP | 0x7000000000110 |
| RBP | 0x7000000000110 |
| RDI | 15 |
| RSI | 未初始化 |
| RDX | 未初始化 |
| RCX | 未初始化 |
| R8 | 未初始化 |
| R9 | 未初始化 |
| RAX | 未初始化 |
● 右栏:调用 fun 的汇编指令,来自列出 9-27,已存储在从地址 0x10000000 开始的内存中。RIP 保存地址 0x10000000,并指向第一条指令,“pushq %rdi”。指令及其地址列在以下表中。
| 指令地址 | 指令 |
|---|---|
| 0x10000000 | pushq %rdi |
| 0x10000001 | subq $8, %rsp |
| 0x10000002 | movl $1, %edi |
| 0x10000003 | movl $2, %esi |
| 0x10000004 | movl $3, %edx |
| 0x10000005 | movl $4, %ecx |
| 0x10000006 | movl $5, %r8d |
| 0x10000007 | movl $6, %r9d |
| 0x10000008 | pushq $8 |
| 0x10000009 | pushq $7 |
| 0x1000000a | call fun |
| 0x1000000b | addq $24, %rsp |
| 0x1000000c | popq %rdi |
返回文本
● 左栏:寄存器和栈并排显示,如图 9-1 所示。四个新项已推送到栈上。这些项从上到下分别是:值 7、值 8、填充和值 15。接下来是之前栈帧的保存基地址,再之后是之前的栈帧,如之前一样。
现在,RSP 指向栈顶的值 7。RBP 指向之前栈帧的保存基地址,如之前一样。RDI、RSI、RDX、RCX、R8 和 R9 分别保存着值 1 到 6,而 RAX 尚未初始化。
栈中的内容从上到下列出:
○ 地址 0x70000000000f0 处的值 7
○ 值 8
○ 填充
○ 值 15
○ 地址 0x7000000000110 处的值 0x7000000000160
○ 之前的栈帧,地址范围从 0x7000000000118 到 0x7000000000160
寄存器的内容如下表所示。
| 寄存器 | 内容 |
|---|---|
| RSP | 0x70000000000f0 |
| RBP | 0x7000000000110 |
| RDI | 1 |
| RSI | 2 |
| RDX | 3 |
| RCX | 4 |
| R8 | 5 |
| R9 | 6 |
| RAX | 未初始化 |
● 右栏:调用 fun 的汇编指令,来自列出 9-27,已存储在从地址 0x10000000 开始的内存中。RIP 保存地址 0x1000000a,并指向第十一条指令,“call fun”。指令及其地址列在以下表中。
| 指令地址 | 指令 |
|---|---|
| 0x10000000 | pushq %rdi |
| 0x10000001 | subq $8, %rsp |
| 0x10000002 | movl $1, %edi |
| 0x10000003 | movl $2, %esi |
| 0x10000004 | movl $3, %edx |
| 0x10000005 | movl $4, %ecx |
| 0x10000006 | movl $5, %r8d |
| 0x10000007 | movl $6, %r9d |
| 0x10000008 | pushq $8 |
| 0x10000009 | pushq $7 |
| 0x1000000a | call fun |
| 0x1000000b | addq $24, %rsp |
| 0x1000000c | popq %rdi |
返回文本
● 左列:寄存器和堆栈并排显示,如图 9-2 所示。堆栈上推入了一个新值:0x1000000b,它是调用指令后面汇编指令的地址。RSP 指向这个新值。其余寄存器和堆栈内容与图 9-2 相同。
堆栈的内容从上到下列出:
○ 地址 0x70000000000e8 处的值 0x1000000b
○ 地址 0x70000000000f0 处的值 7
○ 值 8
○ 填充
○ 值 15
○ 地址 0x7000000000110 处的值 0x7000000000160
○ 先前的堆栈帧,从地址 0x7000000000118 延伸到 0x7000000000160
寄存器的内容在下表中列出。
| 寄存器 | 内容 |
|---|---|
| RSP | 0x70000000000e8 |
| RBP | 0x7000000000110 |
| RDI | 1 |
| RSI | 2 |
| RDX | 3 |
| RCX | 4 |
| R8 | 5 |
| R9 | 6 |
| RAX | 未初始化 |
● 右列:调用fun的汇编指令,来自清单 9-27,存储在从地址 0x10000000 开始的内存中。这些指令在图中以灰色显示。
来自清单 9-26 的fun函数的汇编指令存储在从地址 0x10000100 开始的内存中。RIP 保存地址 0x10000100 并指向fun函数中的第一条指令“pushq %rbp”。
调用fun的指令及其地址在下表中列出。
| 指令地址 | 指令 |
|---|---|
| 0x10000000 | pushq %rdi |
| 0x10000001 | subq $8, %rsp |
| 0x10000002 | movl $1, %edi |
| 0x10000003 | movl $2, %esi |
| 0x10000004 | movl $3, %edx |
| 0x10000005 | movl $4, %ecx |
| 0x10000006 | movl $5, %r8d |
| 0x10000007 | movl $6, %r9d |
| 0x10000008 | pushq $8 |
| 0x10000009 | pushq $7 |
| 0x1000000a | call fun |
| 0x1000000b | addq $24, %rsp |
| 0x1000000c | popq %rdi |
“fun”函数中的指令及其地址在下表中列出。
| 指令地址 | 指令 |
|---|---|
| 0x10000100 | pushq %rbp |
| 0x10000101 | movq %rsp, %rbp |
| 0x10000102 | movl %edi, %eax |
| 0x10000103 | addl 24(%rbp), %eax |
| 0x10000104 | movq %rbp, %rsp |
| 0x10000105 | popq %rbp |
| 0x10000106 | ret |
返回文本
● 左列:寄存器和堆栈并排显示,如图 9-3 所示。堆栈上推入了一个新值:0x7000000000110,它是 RBP 的先前值。RSP 和 RBP 都指向这个新值。其余寄存器和堆栈内容与图 9-3 相同。
堆栈被三种不同的背景色阴影标记,以指示不同的区域。堆栈的内容从上到下列出,按区域组织。
○ 白色区域包含一项:在地址 0x70000000000e0 处的值 0x7000000000110
○ 浅灰色区域包含六个项:
■ 地址 0x70000000000e8 处的值 0x1000000b
■ 地址 0x70000000000f0 处的值 7
■ 值 8
■ 填充
■ 值 15
■ 地址 0x7000000000110 处的值 0x7000000000160
○ 深灰色区域是先前的栈帧,扩展从地址 0x7000000000118 到 0x7000000000160
寄存器的内容列在下表中。
| 寄存器 | 内容 |
|---|---|
| RSP | 0x70000000000e0 |
| RBP | 0x70000000000e0 |
| RDI | 1 |
| RSI | 2 |
| RDX | 3 |
| RCX | 4 |
| R8 | 5 |
| R9 | 6 |
| RAX | 未初始化 |
● 右列:调用 fun 的汇编指令,来自清单 9-27,存储在从地址 0x10000000 开始的内存中。这些指令在该图中被灰色标出。
来自清单 9-26 的 fun 函数的汇编指令存储在从地址 0x10000100 开始的内存中。RIP 保存地址 0x10000102,并指向 fun 中的第三条指令,“movl %edi, %eax”。
调用 fun 的指令及其地址列在下表中。
| 指令地址 | 指令 |
|---|---|
| 0x10000000 | pushq %rdi |
| 0x10000001 | subq $8, %rsp |
| 0x10000002 | movl $1, %edi |
| 0x10000003 | movl $2, %esi |
| 0x10000004 | movl $3, %edx |
| 0x10000005 | movl $4, %ecx |
| 0x10000006 | movl $5, %r8d |
| 0x10000007 | movl $6, %r9d |
| 0x10000008 | pushq $8 |
| 0x10000009 | pushq $7 |
| 0x1000000a | call fun |
| 0x1000000b | addq $24, %rsp |
| 0x1000000c | popq %rdi |
“fun” 函数中的指令及其地址列在下表中。
| 指令地址 | 指令 |
|---|---|
| 0x10000100 | pushq %rbp |
| 0x10000101 | movq %rsp, %rbp |
| 0x10000102 | movl %edi, %eax |
| 0x10000103 | addl 24(%rbp), %eax |
| 0x10000104 | movq %rbp, %rsp |
| 0x10000105 | popq %rbp |
| 0x10000106 | ret |
返回文本
● 左列:寄存器和栈并排显示,如图 9-4 所示。RBP 的保存值 0x7000000000110 已从栈顶弹出。栈内容与图 9-4 中相同。
RSP 指向值 0x1000000b,即当前位于栈顶的返回地址。RBP 指向先前栈帧的保存基址,它是栈顶第六项。RAX 保存值 9。其他寄存器与图 9-4 中的状态相同。
栈的内容按从上到下的顺序列出:
○ 地址 0x70000000000e8 处的值 0x1000000b
○ 地址 0x70000000000f0 处的值 7
○ 值 8
○ 填充
○ 值 15
○ 地址 0x7000000000110 处的值 0x7000000000160
○ 先前的栈帧,扩展从地址 0x7000000000118 到 0x7000000000160
寄存器的内容列在下表中。
| 寄存器 | 内容 |
|---|---|
| RSP | 0x70000000000e8 |
| RBP | 0x7000000000110 |
| RDI | 1 |
| RSI | 2 |
| RDX | 3 |
| RCX | 4 |
| R8 | 5 |
| R9 | 6 |
| RAX | 9 |
● 右列:调用 fun 的汇编指令,来自清单 9-27,存储在从地址 0x10000000 开始的内存中。这些指令在该图中被灰色标出。
来自列表 9-26 的 fun 汇编指令存储在从地址 0x10000100 开始的内存中。RIP 寄存器存储地址 0x10000106,并指向 fun 中的第七条也是最后一条指令,“ret”。
调用 fun 的指令及其地址列在下表中。
| 指令地址 | 指令 |
|---|---|
| 0x10000000 | pushq %rdi |
| 0x10000001 | subq $8, %rsp |
| 0x10000002 | movl $1, %edi |
| 0x10000003 | movl $2, %esi |
| 0x10000004 | movl $3, %edx |
| 0x10000005 | movl $4, %ecx |
| 0x10000006 | movl $5, %r8d |
| 0x10000007 | movl $6, %r9d |
| 0x10000008 | pushq $8 |
| 0x10000009 | pushq $7 |
| 0x1000000a | call fun |
| 0x1000000b | addq $24, %rsp |
| 0x1000000c | popq %rdi |
“fun”函数中的指令及其地址列在下表中。
| 指令地址 | 指令 |
|---|---|
| 0x10000100 | pushq %rbp |
| 0x10000101 | movq %rsp, %rbp |
| 0x10000102 | movl %edi, %eax |
| 0x10000103 | addl 24(%rbp), %eax |
| 0x10000104 | movq %rbp, %rsp |
| 0x10000105 | popq %rbp |
| 0x10000106 | ret |
返回文本
● 左侧列:寄存器和栈并排显示,如图 9-5 所示。返回地址 0x1000000b 已从栈顶弹出。栈内容与图 9-5 相同,未发生变化。
RSP 指向栈顶,现在栈顶的值是 7。其他寄存器的内容与图 9-5 中相同。
栈的内容按从上到下的顺序列出:
○ 地址 0x70000000000f0 处的值 7
○ 值 8
○ 填充
○ 值 15
○ 地址 0x7000000000110 处的值 0x7000000000160
○ 先前的栈帧,地址范围从 0x7000000000118 到 0x7000000000160
寄存器的内容列在下表中。
| 寄存器 | 内容 |
|---|---|
| RSP | 0x70000000000f0 |
| RBP | 0x7000000000110 |
| RDI | 1 |
| RSI | 2 |
| RDX | 3 |
| RCX | 4 |
| R8 | 5 |
| R9 | 6 |
| RAX | 9 |
● 右侧列:用于调用 fun 的汇编指令,来自列表 9-27,存储在从地址 0x10000000 开始的内存中。这些指令不再是灰色的。RIP 存储地址 0x1000000b,并指向第十二条指令,“addq $24, %rsp”。
调用 fun 的指令及其地址列在下表中。
| 指令地址 | 指令 |
|---|---|
| 0x10000000 | pushq %rdi |
| 0x10000001 | subq $8, %rsp |
| 0x10000002 | movl $1, %edi |
| 0x10000003 | movl $2, %esi |
| 0x10000004 | movl $3, %edx |
| 0x10000005 | movl $4, %ecx |
| 0x10000006 | movl $5, %r8d |
| 0x10000007 | movl $6, %r9d |
| 0x10000008 | pushq $8 |
| 0x10000009 | pushq $7 |
| 0x1000000a | call fun |
| 0x1000000b | addq $24, %rsp |
| 0x1000000c | popq %rdi |
返回文本
● 左栏:显示寄存器和栈的内容,类似图 9-6。栈顶已移除四个值,先前栈帧的保存基地址现在位于栈顶,先前栈帧紧接其下。
RSP 和 RBP 指向栈顶保存的基地址。RDI 保存值 15. RAX 保存值 9,其余寄存器保存从 2 到 6 的值,如图 9-6 所示。
栈的内容从上到下列出:
○ 值 0x7000000000160 位于地址 0x7000000000110
○ 先前的栈帧,范围从地址 0x7000000000118 到 0x7000000000160
以下表格给出了寄存器的内容。
| 寄存器 | 内容 |
|---|---|
| RSP | 0x7000000000110 |
| RBP | 0x7000000000110 |
| RDI | 15 |
| RSI | 2 |
| RDX | 3 |
| RCX | 4 |
| R8 | 5 |
| R9 | 6 |
| RAX | 9 |
● 右栏:调用 fun 的汇编指令,来自清单 9-27,存储在从地址 0x10000000 开始的内存中。RIP 保存地址 0x1000000d,并指向“popq %rdi”之后的未指定指令。
调用 fun 的指令及其地址在下表中给出。
| 指令地址 | 指令 |
|---|---|
| 0x10000000 | pushq %rdi |
| 0x10000001 | subq $8, %rsp |
| 0x10000002 | movl $1, %edi |
| 0x10000003 | movl $2, %esi |
| 0x10000004 | movl $3, %edx |
| 0x10000005 | movl $4, %ecx |
| 0x10000006 | movl $5, %r8d |
| 0x10000007 | movl $6, %r9d |
| 0x10000008 | pushq $8 |
| 0x10000009 | pushq $7 |
| 0x1000000a | call fun |
| 0x1000000b | addq $24, %rsp |
| 0x1000000c | popq %rdi |
| 0x1000000d | ... |
返回文本
一张流程图展示了编译器的各个阶段。
1. 词法分析器将 program.c 转换为一个标记列表。
2. 解析器将标记列表转化为抽象语法树(AST)。
3. 语义分析将抽象语法树(AST)转化为变换后的抽象语法树(AST)。它有三个步骤:
a. 标识符解析
b. 类型检查
c. 循环标签化
4. TACKY 生成将变换后的抽象语法树(AST)转化为 TACKY。
5. 汇编生成将 TACKY 转换为汇编代码。它有三个步骤:
a. 将 TACKY 转换为汇编代码
b. 替换伪寄存器
c. 指令修复
6. 代码生成将汇编写入 program.s。
返回文本
一张流程图展示了编译器的各个阶段。
1. 词法分析器将 program.c 转换为一个标记列表。
2. 解析器将标记列表转化为抽象语法树(AST)。
3. 语义分析将抽象语法树(AST)转化为变换后的抽象语法树(AST)。它有三个步骤:
a. 标识符解析
b. 类型检查
c. 循环标签化
4. TACKY 生成将变换后的抽象语法树(AST)转化为 TACKY。
5. 汇编生成将 TACKY 转换为汇编代码。它有三个步骤:
a. 将 TACKY 转换为汇编代码
b. 替换伪寄存器
c. 指令修复
6. 代码生成将汇编写入 program.s。
返回文本
一张流程图展示了编译器的各个阶段。
-
词法分析器将 program.c 转换为标记列表。
-
解析器将标记列表转化为抽象语法树(AST)。
-
语义分析将 AST 转化为变换后的 AST。它包含三个步骤:
a. 标识符解析
b. 类型检查
c. 循环标号
-
TACKY 生成将变换后的 AST 转化为 TACKY。
-
汇编生成将 TACKY 转换为汇编。它包含三个步骤:
a. 将 TACKY 转换为汇编
b. 替换伪寄存器
c. 指令修正
- 代码生成将汇编写入 program.s。
返回文本
一个流程图展示了编译器的各个阶段。
-
词法分析器将 program.c 转换为标记列表。
-
解析器将标记列表转化为抽象语法树(AST)。
-
语义分析将 AST 转化为变换后的 AST。它包含三个步骤:
a. 标识符解析
b. 类型检查
c. 循环标号
-
TACKY 生成将变换后的 AST 转化为 TACKY。
-
汇编生成将 TACKY 转换为汇编。它包含三个步骤:
a. 将 TACKY 转换为汇编
b. 替换伪寄存器
c. 指令修正
- 代码生成将汇编写入 program.s。
返回文本
一个图示展示了双精度格式中的三个字段。
● 分数部分是 52 位,位于第 0 到 51 位。
● 指数部分是 11 位,位于第 62 到 52 位。
● 符号位是 1 位,位于第 63 位。
返回文本
图示展示了通用寄存器和浮点寄存器的内容。这里它们的内容呈现为两个列表。
通用寄存器:
● RDI: i1
● RSI: i2
● RDX: i3
● RCX: i4
● R8: i5
● R9: 未使用
浮点寄存器:
● XMM0: d1
● XMM1: d2
● XMM2: d3
● XMM3 到 XMM7 未使用。
返回文本
图示展示了通用寄存器、浮点寄存器和栈的内容。这里它们的内容呈现为三个列表。
通用寄存器:
● RDI: i1
● RSI: i2
● RDX: i3
● RCX: i4
● R8: i5
● R9: i6
浮点寄存器:
● XMM0: d1
● XMM1: d2
● XMM2: d3
● XMM3: d4
● XMM4 到 XMM7 未使用。
栈内容(从栈顶到底部):
● i7
● i8
● 调用者栈帧
RSP 指向栈顶的 i7。
返回文本
图示展示了通用寄存器、浮点寄存器和栈的内容。这里它们的内容呈现为三个列表。
通用寄存器:
● RDI: i1
● RSI: i2
● RDX: i3
● RCX: i4
● R8: i5
● R9: i6
浮点寄存器:
● XMM0: d1
● XMM1: d2
● XMM2: d3
● XMM3: d4
● XMM4: d5
● XMM5: d6
● XMM6: d7
● XMM7: d8
栈内容(从栈顶到底部):
● d9
● d10
● i7
● d11
● i8
● i9
● 调用者栈帧
RSP 指向栈顶的 d9。
返回文本
一个数字线展示了 4,611,686,018,427,388,416.5 四舍五入到最接近的双精度数的正确和错误方法。数字线上有四个标记:
-
4,611,686,018,427,387,904.0
-
4,611,686,018,427,388,416
3. 4,611,686,018,427,388,416.5
4. 4,611,686,018,427,388,928.0。
一个虚线箭头从 4,611,686,018,427,388,416.5(原始值)指向 4,611,686,018,427,388,928.0。
一个实心箭头从 4,611,686,018,427,388,416.5 向下指向 4,611,686,018,427,388,416,第二个实心箭头从那里指向 4,611,686,018,427,387,904.0。
返回文本
四条数字线展示了不同的奇数舍入情况。每条数字线从 4,611,686,018,427,387,904.0 开始,直到 4,611,686,018,427,388,928.0。每条数字线的标签间隔为 0.5,从 4,611,686,018,427,388,414.5 到 4,611,686,018,427,388,417。
● 在第一种情况下,我们将 4,611,686,018,427,388,416.5 向上舍入到 4,611,686,018,427,388,417,然后从那里向上舍入到 4,611,686,018,427,388,928.0。
● 在第二种情况下,我们只进行一次舍入,将 4,611,686,018,427,388,416 向下舍入到 4,611,686,018,427,387,904.0。
● 在第三种情况下,我们将 4,611,686,018,427,388,415.5 向下舍入到 4,611,686,018,427,388,415,然后从那里向下舍入到 4,611,686,018,427,387,904.0。
● 在第四种情况下,我们将 4,611,686,018,427,388,414.5 向上舍入到 4,611,686,018,427,388,415,然后从那里向下舍入到 4,611,686,018,427,387,904.0。
返回文本
一个流程图展示了编译器的各个阶段。
1. 词法分析器将 program.c 转换为令牌列表。
2. 解析器将令牌列表转换为抽象语法树(AST)。
3. 语义分析将 AST 转换为转换后的 AST,包含三个步骤:
a. 标识符解析
b. 类型检查
c. 循环标签化
4. TACKY 生成将转换后的 AST 转换为 TACKY。
5. 汇编生成将 TACKY 转换为汇编,包含三个步骤:
a. 将 TACKY 转换为汇编
b. 替换伪寄存器
c. 指令修正
6. 代码生成将汇编写入 program.s。
返回文本
栈内容,从上到下:
● 值 0 位于地址 0x7ffeee67b938(变量 x)
● 值 0x7ffeee67b938 位于地址 0x7ffeee67b940(变量 ptr)
返回文本
栈内容,从上到下:
● 值 4 位于地址 0x7ffeee67b938(变量 x)
● 值 0x7ffeee67b938 位于地址 0x7ffeee67b940(变量 ptr)
返回文本
一个流程图展示了编译器的各个阶段。
1. 词法分析器将 program.c 转换为令牌列表。
2. 解析器将令牌列表转换为抽象语法树(AST)。
3. 语义分析将 AST 转换为转换后的 AST,包含三个步骤:
a. 标识符解析
b. 类型检查
c. 循环标签化
4. TACKY 生成将转换后的 AST 转换为 TACKY。
5. 汇编生成将 TACKY 转换为汇编,包含三个步骤:
a. 将 TACKY 转换为汇编
b. 替换伪操作数
c. 指令修正
6. 代码生成将汇编写入 program.s。
返回文本
内存内容如下表所示。
| 内存地址 | 内存内容 |
|---|---|
| 0x10 | 1 |
| 0x14 | 2 |
| 0x18 | 3 |
| 0x1c | 4 |
| 0x20 | 5 |
| 0x24 | 6 |
返回文本
显示了编译器的各个阶段的流程图。
1. 词法分析器将 program.c 转化为令牌列表。
2. 解析器将令牌列表转化为抽象语法树。
3. 语义分析将 AST 转化为转换后的 AST。它有三个步骤:
a. 标识符解析
b. 类型检查
c. 循环标记
4. TACKY 生成将转换后的 AST 转化为 TACKY。
5. 汇编生成将 TACKY 转化为汇编。它有三个步骤:
a. 将 TACKY 转化为汇编
b. 替换伪操作数
c. 指令修正
6. 代码生成将汇编写入 program.s。
返回文本
显示了编译器的各个阶段的流程图。
1. 词法分析器将 program.c 转化为令牌列表。
2. 解析器将令牌列表转化为抽象语法树。
3. 语义分析将 AST 转化为转换后的 AST。它有三个步骤:
a. 标识符解析
b. 类型检查
c. 循环标记
4. TACKY 生成将转换后的 AST 转化为 TACKY。
5. 汇编生成将 TACKY 转化为汇编。它有三个步骤:
a. 将 TACKY 转化为汇编
b. 替换伪操作数
c. 指令修正
6. 代码生成将汇编写入 program.s。
返回文本
显示了编译器的各个阶段的流程图。
1. 词法分析器将 program.c 转化为令牌列表。
2. 解析器将令牌列表转化为抽象语法树。
3. 语义分析将 AST 转化为转换后的 AST。它有三个步骤:
a. 标识符解析
b. 类型检查
c. 循环标记
4. TACKY 生成将转换后的 AST 转化为 TACKY。
5. 汇编生成将 TACKY 转化为汇编。它有三个步骤:
a. 将 TACKY 转化为汇编
b. 替换伪操作数
c. 指令修正
6. 代码生成将汇编写入 program.s。
返回文本
结构中成员和填充的位置以列表形式给出。
● 字节 0 到 3 包含 member1。
● 字节 4 到 7 包含填充。
● 字节 8 到 15 包含 member2。
● 字节 16 到 18 包含数组。字节 16 包含数组元素 0,字节 17 包含数组元素 1,字节 18 包含数组元素 2。
● 字节 19 到 23 包含填充。
返回文本
字节 0 到 7 是第一个八字节。字节 8 到 11 是第二个八字节。结构中成员和填充的位置以列表形式给出。
● 字节 0 到 3 包含 i。
● 字节 4 到 11 包含 arr。字节 4 包含 arr 的元素 0,字节 5 包含 arr 的元素 1,依此类推,直到字节 11,它包含 arr 的元素 7。
返回文本
字节 0 到 7 是第一个八字节。字节 8 到 11 是第二个八字节。结构中成员和填充的位置以列表形式给出。
● 字节 0 包含 ch1。
● 字节 1 到 3 包含填充。
● 字节 4 到 11 包含嵌套。在嵌套中:
○ 第 4 到第 7 字节包含 i。
○ 第 8 字节包含 ch2。
○ 第 9 到第 11 字节包含填充数据。
返回文本
四个图示展示了在函数调用中的不同点栈的内容,来自 Listing 18-46。在这些图示中,栈被不同的背景色阴影标识为三个区域。第一,函数调用前的调用者栈帧。第二,调用者在函数调用期间压入栈的值。第三,被调用者的栈帧。在每个图示中,栈的内容按区域从上到下列出(不是每个区域在每个图示中都有)。
四个图示如下:
● 在参数传递之前,栈的内容如下:
○ 调用者的栈帧:
■ 地址为−16(%rbp)的一个局部变量
■ 地址为−8(%rbp)的另一个局部变量
■ 地址为 0(%rbp)的 RBP 的旧值
● 在第一次执行 pushq 指令后,栈的内容如下,从上到下:
○ 调用时压入的内容:
■ 地址为−24(%rbp)的 arg.b
○ 调用者的栈帧:
■ 地址为−16(%rbp)的一个局部变量
■ 地址为−8(%rbp)的另一个局部变量
■ 地址为 0(%rbp)的 RBP 的旧值
● 在第二次执行 pushq 指令后,栈的内容如下:
○ 调用时压入的内容:
■ 地址为−32(%rbp)的 arg.a
■ 地址为−24(%rbp)的 arg.b
○ 调用者的栈帧:
■ 地址为−16(%rbp)的一个局部变量
■ 地址为−8(%rbp)的另一个局部变量
■ 地址为 0(%rbp)的 RBP 的旧值
● 在被调用者的序言后,RBP 指向栈顶。请注意,这会改变现有项相对于 RBP 的地址,尽管它们的绝对地址相同。栈的内容如下:
○ 被调用者的栈帧:
■ 地址为 0(%rbp)的调用者 RBP
○ 调用时压入的内容:
■ 地址为 8(%rbp)的返回地址
■ 地址为 16(%rbp)的 arg.a
■ 地址为 24(%rbp)的 arg.b
○ 调用者的栈帧:
■ 一个局部变量
■ 另一个局部变量
■ RBP 的旧值
返回文本
栈的内容按从上到下的顺序列出:
● 8 字节的填充数据
● 24 字节(或 3 个八字节栈槽)的未初始化内存,保留给 caller_result。
● 之前的 RBP
● 先前的栈帧
栈寄存器的内容在此图下方的文字中做了总结。以下是详细说明:
● RSP 保存地址 0x70000000000f0,并指向栈顶的填充区域。
● RDI 保存地址 0x70000000000f8,并指向为 caller_result 保留的内存的起始位置
● RBP 保存地址 0x7000000000110,并指向之前的 RBP
● RSI 保存值 10。
● 剩余的寄存器(RDX、RCX、R8、R9 和 RAX)未初始化。
返回文本
栈被不同的背景色阴影标识,以指示不同的栈帧。
栈的内容按从上到下的顺序列出,按栈帧组织。
● 被调用者的栈帧:
○ 8 字节的填充数据
○ 值为 0
○ 值 1
○ 值 10
○ 值 0x7000000000110
被叫者栈帧中保存值 0、1 和 10 的部分标记为 callee_result。
● 调用者的栈帧:
○ 返回地址
○ 8 个字节的填充
○ 值 0
○ 值 1
○ 值 10
○ 之前的 RBP
调用者栈帧中保存值 0、1 和 10 的部分标记为 caller_result。
● 之前的栈帧,未显示单独项。
寄存器的内容如下列表所示:
● RSP 保存地址 0x70000000000c0,并指向栈顶的填充部分
● RBP 保存地址 0x70000000000e0,并指向被叫者栈帧底部的值 x7000000000110
● RAX 保存值 0x70000000000f8,并指向调用者栈帧中的值 0,这是 caller_result 中的第一个值。
● RDI 还保存值 0x70000000000f8,并指向调用者栈帧中的值 0。
● RSI 保存值 10。
● 其余的寄存器(RDX、RCX、R8、R9 和 RAX)未初始化。
返回文本
五个图表展示了每一步 RDI 的内容。这里每个图表以表格形式呈现,其中每个单元格包含一个字节。表头标明了每个字节所包含的寄存器别名。字节按从左到右、从最重要到最不重要的顺序列出。
● 在 movb x+2(%rip), %dil 之后
| RDI | RDI 和 EDI | RDI、EDI 和 DIL |
|---|---|---|
| 00 | 00 | 00 |
● 在 shlq $8, %rdi 之后
| RDI | RDI 和 EDI | RDI、EDI 和 DIL |
|---|---|---|
| 00 | 00 | 00 |
● 在 movb x+1(%rip), %dil 之后
| RDI | RDI 和 EDI | RDI、EDI 和 DIL |
|---|---|---|
| 00 | 00 | 00 |
● 在 shlq $8, %rdi 之后
| RDI | RDI 和 EDI | RDI、EDI 和 DIL |
|---|---|---|
| 00 | 00 | 00 |
● 在 movb x (%rip), %dil 之后
| RDI | RDI 和 EDI | RDI、EDI 和 DIL |
|---|---|---|
| 00 | 00 | 00 |
返回文本
内存的内容如下表所示。
| 内存地址 | 内存内容 |
|---|---|
| -4(%rbp) | 0x01 |
| -3(%rbp) | 0x02 |
| -2(%rbp) | 0x03 |
| -1(%rbp) | 0x00 |
返回文本
一张流程图展示了编译器的各个阶段,包括一个新的优化阶段。
1. 词法分析器将 program.c 转换为一个标记列表。
2. 解析器将标记列表转换为抽象语法树(AST)。
3. 语义分析将抽象语法树(AST)转换为转换后的 AST。它有三个步骤:
a. 标识符解析
b. 类型检查
c. 循环标签化
4. TACKY 生成将转换后的 AST 转换为 TACKY。
5. 优化(一个新阶段)将 TACKY 转换为优化后的 TACKY。它有四个步骤:
a. 常量折叠
b. 无法访问代码消除
c. 复制传播
d. 死存储消除
一条箭头指向每一步的下一步。另一条箭头则将死存储消除连接回常量折叠。
6. 汇编生成将优化后的 TACKY 转换为汇编代码。它有三个步骤:
a. 转换 TACKY 为汇编
b. 替换伪操作数
c. 指令修正
7. 代码生成将汇编写入 program.s。
返回文本
这里,控制流图中的节点以列表形式展示。基本块标记为 B0、B1 等。我们给出每个节点的内容,然后列出其外部边缘。
1. ENTRY
边缘列表:
● B0
2. B0
LoopStart:
input = get_input()
JumpIfNotZero(input, ProcessIt)
边缘列表:
● B1
● B2
3. B1
返回(-1)
边缘列表:
● EXIT
4. B2
ProcessIt:
done = process_input(input)
JumpIfNotZero(done, LoopStart)
边缘列表:
● B0
● B3
5. B3
返回(0)
边缘列表:
● EXIT
6. EXIT. 无外部边缘。
返回文本
这里,控制流图中的节点以列表形式展示。我们给出每个节点的内容,然后列出其外部边缘。
● ENTRY
边缘列表:
○ B0
● B0
x = 5
Jump(Target)
边缘列表:
○ B2
● B1
x = my_function()
边缘列表:
○ B2
● B2
Target:
返回(x)
边缘列表:
● EXIT
● EXIT. 无外部边缘。
返回文本
这里,控制流图中的节点以列表形式展示。我们给出每个节点的内容,然后列出其外部边缘。
1. ENTRY
边缘列表:
● B0
2. B0
x = foo()
JumpIfNotZero(arg, End)
边缘列表:
● B1
● B2
3. B1
x = 2
边缘列表:
● B2
4. B2
End:
返回(x)
边缘列表:
● EXIT
5. EXIT. 无外部边缘。
返回文本
这里,控制流图中的节点以列表形式展示。我们给出每个节点的内容,然后列出其外部边缘。
1. ENTRY
边缘列表:
● B0
2. B0
x = 2
JumpIfNotZero(arg, End)
边缘列表:
● B1
● B2
3. B1
do_something()
边缘列表:
● B2
4. B2
End:
返回(x)
边缘列表:
● EXIT
6. EXIT. 无外部边缘。
返回文本
这里,控制流图中的节点以列表形式展示。我们给出每个节点的内容,然后列出其外部边缘。
1. ENTRY
边缘列表:
● B0
2. B0
JumpIfNotZero(arg, A)
边缘列表:
● B1
● B2
3. B1
y = 20
x = y
Jump(End)
边缘列表:
● BL2>B3
4. B2
A:
y = 100
x = y
边缘列表:
● B3
5. B3
End:
返回(x)
边缘列表:
● EXIT
6. EXIT. 无外部边缘。
返回文本
这里,控制流图中的节点以列表形式展示。我们给出每个节点的内容,然后列出其外部边缘。
1. ENTRY
边缘列表:
● B0
2. B0
y = foo()
x = y
JumpIfNotZero(arg, End)
边缘列表:
● B1
● B2
3. B1
y = 10
边缘列表:
● B2
4. B2
End:
返回(x)
边缘列表:
● EXIT
5. EXIT. 无外部边缘。
返回文本
这里,控制流图中的节点以列表形式展示。我们给出每个节点的内容,然后列出其外部边缘。
1. ENTRY
边缘列表:
● B0
2. B0
y = 3
边缘列表:
● B1
3. B1
Loop:
x = process(y)
y = 4
JumpIfNotZero(x, Loop)
边缘列表:
● B1
● B2
4. B2
返回(x)
边缘列表:
● EXIT
5. EXIT. 无外部边缘。
返回文本
这里控制流图中的节点以列表形式呈现。我们给出每个节点的内容,然后是其注释,再列出其外向边。
1. ENTRY
注释:空集
边列表:
● B0
2. B0
y = 3
注释:{ y = 3 }
边列表:
● B1
3. B1
Loop:
x = process(y)
y = 4
JumpIfNotZero(x, Loop)
注释:{ y = 3, y = 4 }
边列表:
● B1
● B2
4. B2
Return(x)
注释:{ y = 3, y = 4 }
边列表:
● EXIT
5. EXIT。没有注释。没有外向边。
返回文本
这里控制流图中的节点以列表形式呈现。我们给出每个节点的内容,然后是其注释,再列出其外向边。
1. ENTRY
注释:空集
边列表:
● B0
2. B0
y = 3
注释:{ y = 3 }
边列表:
● B1
3. B1
Loop:
x = process(y)
y = 4
JumpIfNotZero(x, Loop)
注释:{ y = 4 }
边列表:
● B1
● B2
4. B2
Return(x)
注释:{ y = 4 }
边列表:
● EXIT
5. EXIT。没有注释。没有外向边。
返回文本
这里控制流图中的节点以列表形式呈现。我们给出每个节点的内容,然后列出其外向边。
1. ENTRY
边列表:
● B0
2. B0
x = 10
JumpIfNotZero(arg, A)
边列表:
● B1
● B2
3. B1
Return(0)
边列表:
● EXIT
4. B2
A:
Return(x)
边列表:
● EXIT
5. EXIT。没有外向边。
返回文本
这里控制流图中的节点以列表形式呈现。我们给出每个节点的内容,然后列出其外向边。
1. ENTRY
边列表:
● B0
2. B0
x = 10
JumpIfNotZero(arg, A)
边列表:
● B1
● B2
3. B1
x = f()
Return(x)
边列表:
● EXIT
4. B2
A:
x = g()
Return(x)
边列表:
● EXIT
5. EXIT。没有外向边。
返回文本
一个流程图展示了编译器的各个阶段,包括汇编生成阶段中新增加的步骤。
1. 词法分析器将 program.c 转换为标记列表。
2. 解析器将标记列表转换为抽象语法树。
3. 语义分析将 AST 转换为转换后的 AST。它有三个步骤:
a. 标识符解析
b. 类型检查
c. 循环标记
4. TACKY 生成将转换后的 AST 转换为 TACKY。
5. 优化将 TACKY 转变为优化后的 TACKY。它有四个步骤:
a. 常量折叠
b. 不可达代码消除
c. 复制传播
d. 死存储消除
一个箭头从每个步骤指向下一个步骤。另一个箭头从死存储消除回到常量折叠。
6. 汇编生成将优化后的 TACKY 转换为汇编。它有三个步骤:
a. 将 TACKY 转换为汇编
b. 寄存器分配(一个新步骤)
c. 替换伪操作数
d. 指令修正
7. 代码生成将汇编写入 program.s。
返回文本
一个无向图,包含五个节点,标记为 A 到 E。每个节点被涂成黑色、白色或灰色。C 位于中心。A、B、D 和 E 被排列在 C 的四周。A 在上方,B 在左侧,D 在右侧,E 在下方。围绕 C 的四个节点形成菱形,并且都与 C 相连。C 为黑色。A 和 E 为白色。B 和 D 为灰色。
图形在此也以节点列表的形式呈现。每个节点的邻居及其颜色都列在下面。
● A,白色,上方。邻居:
○ B,灰色
○ C,黑色
○ D,灰色
● B,灰色,左侧。邻居:
○ A,白色
○ C,黑色
○ E,白色
● C,黑色,中心。邻居:
○ A,白色
○ B,灰色
○ D,灰色
○ E,白色
● D,灰色,右侧。邻居:
○ A,白色
○ C,黑色
○ E,白色
● E,白色,下方。邻居:
○ B,灰色
○ C,黑色
○ D,灰色
返回文本
一个无向图,包含七个节点。四个硬件寄存器:EDI、ESI、EAX 和 EDX。三个伪寄存器:a、b 和 tmp。
所有节点除了 tmp 都排列成一个圆圈。a 和 b 在圆圈的右侧。tmp 位于 b 的右侧。图后的文字总结了边的情况。
这里图被展示为一个节点列表。每个节点的邻居被列在它下面。
● EDI。邻居:
○ ESI
○ EAX
○ EDX
● ESI。邻居:
○ EDI
○ EAX
○ EDX
○ a
● EAX。邻居:
○ EDI
○ ESI
○ EDX
○ b
● EDX。邻居:
○ ESI
○ EDI
○ EAX
○ b
● a。邻居:
○ ESI
○ b
● b. 邻居:
○ EAX
○ EDX
○ a
○ tmp
● tmp。邻居:
○ b
返回文本
三个图。每个图展示了图 20-2 中的图形,并为节点着色。颜色有黑色、白色、灰色和条纹色。对于每个图,我们总结了节点的颜色,然后列出每个节点、它的颜色以及它的邻居的颜色。
● 第一个图。EDI 和 b 为条纹色。ESI 为黑色。EAX 和 tmp 为白色。EDX 和 a 为灰色。
○ EDI,条纹色。邻居:
● ESI,黑色
● EAX,白色
● EDX,灰色
○ ESI,黑色。邻居:
● EDI,条纹色
● EAX,白色
● EDX,灰色
● a,灰色
○ EAX,白色。邻居:
● EDI,条纹色
● ESI,黑色
● EDX,灰色
● b,条纹色
○ EDX,灰色。邻居:
● EDI,条纹色
● ESI,黑色
● EAX,白色
● b,条纹色
○ a,灰色。邻居:
● ESI,黑色
● b,条纹色
○ b,条纹色。邻居:
● EAX,白色
● EDX,灰色
● a,灰色
● tmp,白色
○ tmp,白色。邻居:
● b,条纹色
● 第二个图。EDI 为黑色。ESI 和 b 为灰色。EAX、a 和 tmp 为条纹色。EDX 为白色。
○ EDI,黑色。邻居:
● ESI,灰色
● EAX,条纹色
● EDX,白色
○ ESI,灰色。邻居:
● EDI,黑色
● EAX,条纹色
● EDX,白色
● a,条纹色
○ EAX,条纹色。邻居:
● EDI,黑色
● ESI,灰色
● EDX,白色
● b,灰色
○ EDX,白色。邻居:
● EDI,黑色
● ESI,灰色
● EAX,条纹色
● b,灰色
○ a,条纹色。邻居:
● ESI,灰色
● b,灰色
○ b,灰色。邻居:
● EAX,条纹色
● EDX,白色
● a,条纹色
● tmp,条纹色
○ tmp,条纹色。邻居:
● b,灰色
● 第三个图。EDI 和 a 为灰色。ESI 和 b 为白色。EAX 为条纹色。EDX 和 tmp 为黑色。
○ EDI,灰色。邻居:
● ESI,白色
● EAX,条纹色
● EDX,黑色
○ ESI,白色。邻居:
● EDI,灰色
● EAX,条纹
● EDX,黑色
● a,灰色
○ EAX,条纹。邻居:
● EDI,灰色
● ESI,白色
● EDX,黑色
● b,白色
○ EDX,黑色。邻居:
● EDI,灰色
● ESI,白色
● EAX,条纹
● b,白色
○ a,灰色。邻居:
● ESI,白色
● b,白色
○ b,白色。邻居:
● EAX,条纹
● EDX,黑色
● a,灰色
● tmp,黑色
○ tmp,黑色。邻居:
● b,白色
返回文本
一个无向图,包含七个节点。四个硬件寄存器:EDI、ESI、EAX 和 EDX,三个伪寄存器:arg1、arg2 和 tmp。
节点排列在四行中。第一行:EDI 和 ESI。第二行:EAX 和 EDX。第三行:arg1 和 arg2。最后一行:tmp。
所有四个硬件寄存器都是邻居。EAX 和 EDX 都是三个伪寄存器的邻居。这三个伪寄存器是邻居。arg1 还与 ESI 相邻。
图形也以节点列表的形式呈现。
● EDI. 邻居:
○ ESI
○ EAX
○ EDX
● ESI. 邻居:
○ EDI
○ EAX
○ EDX
○ arg1
● EAX. 邻居:
○ EDI
○ ESI
○ EDX
○ arg1
○ arg2
○ tmp
● EDX. 邻居:
○ EDI
○ ESI
○ EAX
○ arg1
○ arg2
○ tmp
● arg1
○ ESI
○ EAX
○ EDX
○ arg2
○ tmp
● arg2
○ EAX
○ EDX
○ arg1
○ tmp
● tmp
○ EAX
○ EDX
○ arg1
○ arg2
返回文本
从图 20-4 中删除 tmp 后的图形。每个剩余的节点都有颜色。EDI 和 arg1 是白色的,ESI 和 arg2 是灰色的,EAX 是黑色的,EDX 是条纹的。
所有四个硬件寄存器都是邻居。EAX 和 EDX 都是 arg1 和 arg2 的邻居。arg1 和 arg2 是邻居。arg1 还与 ESI 相邻。
图形也以节点列表的形式呈现。
● EDI,白色。邻居:
○ ESI,灰色
○ EAX,黑色
○ EDX,条纹
● ESI,灰色。邻居:
○ EDI,白色
○ EAX,黑色
○ EDX,条纹
○ arg1,白色
● EAX,黑色
○ EDI,白色
○ ESI,灰色
○ EDX,条纹
○ arg1,白色
○ arg2,灰色
● EDX,条纹。邻居:
○ EDI,白色
○ ESI,灰色
○ EAX,黑色
○ arg1,白色
○ arg2,灰色
● arg1,白色
○ ESI,灰色
○ EAX,黑色
○ EDX,条纹
○ arg2,灰色
● arg2,灰色
○ EAX,黑色
○ EDX,条纹
○ arg1,白色
返回文本
图形有十二个节点,按圆形排列,每个寄存器一个。每个寄存器与其他 11 个寄存器互相干扰。
返回文本
图形中有八个节点,标记为 A 到 H。它们按顺序排列成三行。第一行包含 A 和 B,第二行包含 C、D 和 E,第三行包含 F、G 和 H。
图形以节点列表的形式呈现。
● A. 邻居:
○ B
○ C
○ D
○ E
● B. 邻居:
○ A
○ E
● C. 邻居:
○ A
○ D
● D. 邻居:
○ A
○ C
○ E
○ F
○ G
● E. 邻居:
○ A
○ B
○ D
○ G
● F. 邻居:
○ D
○ G
● G. 邻居:
○ D
○ E
○ F
○ H
● H. 邻居:
○ G
返回文本
图中的所有节点和边都与图 20-7 中的位置相同。剪枝的节点是带虚线边框的圆圈。任何端点已被剪枝的边是虚线。剩余的节点是带实线边框的圆圈,剩余的边是实线。
A、D、E 和 G 保留在图中。在剩余的图中,A、D 和 E 都是邻居,D 和 E 也是 G 的邻居。
包括已修剪节点在内的完整图呈现为列表。
● A,剩余。邻居:
○ B,已修剪
○ C,已修剪
○ D,剩余
○ E,剩余
● B,已修剪。邻居:
○ A,剩余
○ E,剩余
● C,已修剪。邻居:
○ A,剩余
○ D,剩余
● D,剩余。邻居:
○ A,剩余
○ C,已修剪
○ E,剩余
○ F,已修剪
○ G,剩余
● E,剩余。邻居:
○ A,剩余
○ B,已修剪
○ D,剩余
○ G,剩余
● F,已修剪。邻居:
○ D,剩余
○ G,剩余
● G,剩余。邻居:
○ D,剩余
○ E,剩余
○ F,已修剪
○ H,已修剪
● H,已修剪。邻居:
○ G,剩余
堆栈内容,从上到下:
● H
● F
● C
● B
返回文本
图中的所有节点和边与图 20-7 中的位置相同。修剪过的节点有虚线边框。任何端点被修剪的边为虚线。剩余节点有实线边框,剩余的边为实线。
D 和 E 保留在图中。它们是邻居。
包括已修剪节点在内的完整图呈现为列表。
● A,已修剪。邻居:
○ B,已修剪
○ C,已修剪
○ D,剩余
○ E,剩余
● B,已修剪。邻居:
○ A,已修剪
○ E,剩余
● C,已修剪。邻居:
○ A,已修剪
○ D,剩余
● D,剩余。邻居:
○ A,已修剪
○ C,已修剪
○ E,剩余
○ F,已修剪
○ G,已修剪
● E,剩余。邻居:
○ A,已修剪
○ B,已修剪
○ D,剩余
○ G,已修剪
● F,已修剪。邻居:
○ D,剩余
○ G,已修剪
● G,已修剪。邻居:
○ D,剩余
○ E,剩余
○ F,已修剪
○ H,已修剪
● H,已修剪。邻居:
○ G,已修剪
堆栈内容,从上到下:
● G
● A
● H
● F
● C
● B
返回文本
图中的所有节点和边与图 20-7 中的位置相同。所有节点的边框为虚线,所有边为虚线,表示它们已从图中修剪。
堆栈内容,从上到下:
● E
● D
● G
● A
● H
● F
● C
● B
返回文本
显示了九张图。每张图都展示了图和堆栈。在每张图中,图中的所有节点和边都与图 20-7 中的位置相同。修剪的节点有虚线边框,任何端点被修剪的边为虚线。
被放回的节点为白色、黑色或灰色,并具有实线边框。两个端点都被放回的边为实线。对于每张图,我们给出一个总结。然后描述完整的图,包括修剪过的节点。接着描述堆栈。
● 第一张图。该图与图 20-10 完全相同。所有节点已被修剪。
堆栈内容,从上到下:
○ E
○ D
○ G
○ A
○ H
○ F
○ C
○ B
● 第二张图。E 被弹出堆栈。E 是图中唯一的节点,它是白色的。
完整图,以列表形式展示:
○ A,已修剪。邻居:
● B,已修剪
● C,已修剪
● D,已修剪
● E,白色
○ B,已修剪。邻居:
● A,已修剪
● E, 白色
○ C, 剪枝。邻居:
● A, 剪枝
● D, 剪枝
○ D, 剪枝。邻居:
● A, 剪枝
● C, 剪枝
● E, 白色
● F, 剪枝
● G, 剪枝
○ E, 白色。邻居:
● A, 剪枝
● B, 剪枝
● D, 剪枝
● G, 剪枝
○ F, 剪枝。邻居:
● D, 剪枝
● G, 剪枝
○ G, 剪枝。邻居:
● D, 剪枝
● E, 白色
● F, 剪枝
● H, 剪枝
○ H, 剪枝。邻居:
● G, 剪枝
栈内容,从上到下:
○ D
○ G
○ A
○ H
○ F
○ C
○ B
第三个图。D 被弹出栈。图中有两个节点:D 为灰色,E 为白色。它们是邻居。
完整图,作为列表:
○ A, 剪枝。邻居:
● B, 剪枝
● C, 剪枝
● D, 灰色
● E, 白色
○ B, 剪枝。邻居:
● A, 剪枝
● E, 白色
○ C, 剪枝。邻居:
● A, 剪枝
● D, 灰色
○ D, 灰色。邻居:
● A, 剪枝
● C, 剪枝
● E, 白色
● F, 剪枝
● G, 剪枝
○ E, 白色。邻居:
● A, 剪枝
● B, 剪枝
● D, 灰色
● G, 剪枝
○ F, 剪枝。邻居:
● D, 灰色
● G, 剪枝
○ G, 剪枝。邻居:
● D, 灰色
● E, 白色
● F, 剪枝
● H, 剪枝
○ H, 剪枝。邻居:
● G, 剪枝
栈内容,从上到下:
○ G
○ A
○ H
○ F
○ C
○ B
第四个图。G 被弹出栈。图中有三个节点:G 为黑色,D 为灰色,E 为白色。它们是彼此的邻居。
完整图,作为列表:
○ A, 剪枝。邻居:
● B, 剪枝
● C, 剪枝
● D, 灰色
● E, 白色
○ B, 剪枝。邻居:
● A, 剪枝
● E, 白色
○ C, 剪枝。邻居:
● A, 剪枝
● D, 灰色
○ D, 灰色。邻居:
● A, 剪枝
● C, 剪枝
● E, 白色
● F, 剪枝
● G, 黑色
○ E, 白色。邻居:
● A, 剪枝
● B, 剪枝
● D, 灰色
● G, 黑色
○ F, 剪枝。邻居:
● D, 灰色
● G, 黑色
○ G, 黑色。邻居:
● D, 灰色
● E, 白色
● F, 剪枝
● H, 剪枝
○ H, 剪枝。邻居:
● G, 黑色
栈内容,从上到下:
○ A
○ H
○ F
○ C
○ B
第五个图。A 被弹出栈并添加到图中。A 为黑色。它与 D(灰色)和 E(白色)相邻。与之前的图没有其他变化。
完整图,作为列表:
○ A, 黑色。邻居:
● B, 剪枝
● C, 剪枝
● D, 灰色
● E, 白色
○ B, 剪枝。邻居:
● A, 黑色
● E, 白色
○ C, 剪枝。邻居:
● A, 黑色
● D, 灰色
○ D, 灰色。邻居:
● A, 黑色
● C, 剪枝
● E, 白色
● F, 剪枝
● G, 黑色
○ E, 白色。邻居:
● A, 黑色
● B, 剪枝
● D, 灰色
● G, 黑色
○ F, 剪枝。邻居:
● D, 灰色
● G, 黑色
○ G, 黑色。邻居:
● D, 灰色
● E, 白色
● F, 剪枝
● H, 剪枝
○ H, 剪枝。邻居:
● G, 黑色
栈内容,从上到下:
○ H
○ F
○ C
○ B
第六个图。H 被弹出栈并添加到图中。H 为白色。它与 G(黑色)相邻。与之前的图没有其他变化。
完整图,作为列表:
○ A, 黑色。邻居:
● B, 剪枝
● C, 剪枝
● D, 灰色
● E, 白色
○ B, 剪枝。邻居:
● A, 黑色
● E, 白色
○ C, 剪枝。邻居:
● A, 黑色
● D, 灰色
○ D, 灰色。邻居:
● A, 黑色
● C, 剪枝
● E, 白色
● F, 剪枝
● G, 黑色
○ E, 白色。邻居:
● A, 黑色
● B, 剪枝
● D, 灰色
● G, 黑色
○ F, 剪枝。邻居:
● D, 灰色
● G, 黑色
○ G, 黑色。邻居:
● D,灰色
● E,白色
● F,已修剪
● H,白色
○ H,白色。邻居:
● G,黑色
栈内容,从上到下:
○ F
○ C
○ B
● 第七个图。F 从栈中弹出并添加到图中,变为白色。它的邻居是 D(灰色)和 G(黑色)。与上一个图相比没有其他变化。
完整图,作为列表:
○ A,黑色。邻居:
● B,已修剪
● C,已修剪
● D,灰色
● E,白色
○ B,已修剪。邻居:
● A,黑色
● E,白色
○ C,已修剪。邻居:
● A,黑色
● D,灰色
○ D,灰色。邻居:
● A,黑色
● C,已修剪
● E,白色
● F,白色
● G,黑色
○ E,白色。邻居:
● A,黑色
● B,已修剪
● D,灰色
● G,黑色
○ F,白色。邻居:
● D,灰色
● G,黑色
○ G,黑色。邻居:
● D,灰色
● E,白色
● F,白色
● H,白色
○ H,白色。邻居:
● G,黑色
栈内容,从上到下:
○ C
○ B
● 第八个图。C 从栈中弹出并添加到图中,变为白色。它的邻居是 A(黑色)和 D(灰色)。与上一个图相比没有其他变化。
完整图,作为列表:
○ A,黑色。邻居:
● B,已修剪
● C,白色
● D,灰色
● E,白色
○ B,已修剪。邻居:
● A,黑色
● E,白色
○ C,白色。邻居:
● A,黑色
● D,灰色
○ D,灰色。邻居:
● A,黑色
● C,白色
● E,白色
● F,白色
● G,黑色
○ E,白色。邻居:
● A,黑色
● B,已修剪
● D,灰色
● G,黑色
○ F,白色。邻居:
● D,灰色
● G,黑色
○ G,黑色。邻居:
● D,灰色
● E,白色
● F,白色
● H,白色
○ H,白色。邻居:
● G,黑色
栈内容:
○ B
● 第九个图。B 从栈中弹出并添加到图中,变为灰色。它的邻居是 A(黑色)和 E(白色)。与上一个图相比没有其他变化。
完整图,作为列表:
○ A,黑色。邻居:
● B,灰色
● C,白色
● D,灰色
● E,白色
○ B,灰色。邻居:
● A,黑色
● E,白色
○ C,白色。邻居:
● A,黑色
● D,灰色
○ D,灰色。邻居:
● A,黑色
● C,白色
● E,白色
● F,白色
● G,黑色
○ E,白色。邻居:
● A,黑色
● B,灰色
● D,灰色
● G,黑色
○ F,白色。邻居:
● D,灰色
● G,黑色
○ G,黑色。邻居:
● D,灰色
● E,白色
● F,白色
● H,白色
○ H,白色。邻居:
● G,黑色
栈为空。
返回文本
一个无向图,有六个节点,标记为 A 到 F。A、B、D 和 E 围绕 C 排列。
A 在顶部,B 在左侧,D 在右侧,E 在底部。F 在 E 下方。围绕 C 的四个节点连接成菱形。所有四个节点也都与 C 相连。F 与 B、D 和 E 相连。
作为节点列表:
● A。邻居:
○ B
○ C
○ D
● B。邻居:
○ A
○ C
○ E
○ F
○ C。邻居:
○ A
○ B
○ D
○ E
● D。邻居:
○ A
○ C
○ E
● E。邻居:
○ B
○ C
○ D
○ F
● F。邻居:
○ B
○ D
○ E
返回文本
显示了六个图。每个图都展示了图和栈的状态。在每个图中,图中的所有节点和边都与图 20-12 中的位置相同。
已修剪的节点有虚线边框。任何端点已修剪的边缘是虚线。已恢复的节点是白色、黑色或灰色,并且具有实线边框。两端都恢复的边缘是实线。
对于每张图,我们给出一个总结。然后我们描述完整的图形,包括已修剪的节点。然后我们描述栈。
● 第一张图。所有节点都有虚线边框,所有边缘都是虚线,表示它们已经从图形中修剪掉。
完整图形,作为列表:
○ A,已修剪。邻居:
● B,已修剪
● C,已修剪
● D,已修剪
○ B,已修剪。邻居:
● A,已修剪
● C,已修剪
● E,已修剪
● F,已修剪
○ C,已修剪。邻居:
● A,已修剪
● B,已修剪
● D,已修剪
● E,已修剪
○ D,已修剪。邻居:
● A,已修剪
● C,已修剪
● E,已修剪
● F,已修剪
○ E,已修剪。邻居:
● B,已修剪
● C,已修剪
● D,已修剪
● F,已修剪
○ F,已修剪。邻居:
● B,已修剪
● D,已修剪
● E,已修剪
栈内容,从上到下:
○ F
○ E
○ D
○ B
○ A
○ C
● 第二张图。F 从栈中弹出。它是图中唯一的节点,且为白色。
完整图形,作为列表:
○ A,已修剪。邻居:
● B,已修剪
● C,已修剪
● D,已修剪
○ B,已修剪。邻居:
● A,已修剪
● C,已修剪
● E,已修剪
● F,白色
○ C,已修剪。邻居:
● A,已修剪
● B,已修剪
● D,已修剪
● E,已修剪
○ D,已修剪。邻居:
● A,已修剪
● C,已修剪
● E,已修剪
● F,白色
○ E,已修剪。邻居:
● B,已修剪
● C,已修剪
● D,已修剪
● F,白色
○ F,白色。邻居:
● B,已修剪
● D,已修剪
● E,已修剪
栈内容,从上到下:
○ E
○ D
○ B
○ A
○ C
● 第三张图。E 从栈中弹出,图中有两个节点:E,灰色,和 F,白色。它们是邻居。
完整图形,作为列表:
○ A,已修剪。邻居:
● B,已修剪
● C,已修剪
● D,已修剪
○ B,已修剪。邻居:
● A,已修剪
● C,已修剪
● E,灰色
● F,白色
○ C,已修剪。邻居:
● A,已修剪
● B,已修剪
● D,已修剪
● E,灰色
○ D,已修剪。邻居:
● A,已修剪
● C,已修剪
● E,灰色
● F,白色
○ E,灰色。邻居:
● B,已修剪
● C,已修剪
● D,已修剪
● F,白色
○ F,白色。邻居:
● B,已修剪
● D,已修剪
● E,灰色
栈内容,从上到下:
○ D
○ B
○ A
○ C
● 第四张图。D 从栈中弹出,图中有三个节点:D,黑色,E,灰色,以及 F,白色。它们是邻居。
完整图形,作为列表:
○ A,已修剪。邻居:
● B,已修剪
● C,已修剪
● D,黑色
○ B,已修剪。邻居:
● A,已修剪
● C,已修剪
● E,灰色
● F,白色
○ C,已修剪。邻居:
● A,已修剪
● B,已修剪
● D,黑色
● E,灰色
○ D,黑色。邻居:
● A,已修剪
● C,已修剪
● E,灰色
● F,白色
○ E,灰色。邻居:
● B,已修剪
● C,已修剪
● D,黑色
● F,白色
○ F,白色。邻居:
● B,已修剪
● D,黑色
● E,灰色
栈内容,从上到下:
○ B
○ A
○ C
● 第五张图。B 从栈中弹出并加入图中。它是黑色的。它与 E,灰色,和 F,白色是邻居。与上一张图没有其他变化。
完整图形,作为列表:
○ A,已修剪。邻居:
● B,黑色
● C,已修剪
● D,黑色
○ B,黑色。邻居节点:
● A,已修剪
● C,已修剪
● E,灰色
● F,白色
○ C,已修剪。邻居节点:
● A,已修剪
● B,黑色
● D,黑色
● E,灰色
○ D,黑色。邻居节点:
● A,已修剪
● C,已修剪
● E,灰色
● F,白色
○ E,灰色。邻居节点:
● B,黑色
● C,已修剪
● D,黑色
● F,白色
○ F,白色。邻居节点:
● B,黑色
● D,黑色
● E,灰色
栈内容,从上到下:
○ A
○ C
● 第六个图示。A 被从栈中弹出并加入到图中。它是白色的。它是 B,黑色的邻居,也是 D,黑色的邻居。与前一个图示相比没有其他变化。
完整图示,列表形式:
○ A,白色。邻居节点:
● B,黑色
● C,已修剪
● D,黑色
○ B,黑色。邻居节点:
● A,白色
● C,已修剪
● E,灰色
● F,白色
○ C,已修剪。邻居节点:
● A,白色
● B,黑色
● D,黑色
● E,灰色
○ D,黑色。邻居节点:
● A,白色
● C,已修剪
● E,灰色
● F,白色
○ E,灰色。邻居节点:
● B,黑色
● C,已修剪
● D,黑色
● F,白色
○ F,白色。邻居节点:
● B,黑色
● D,黑色
● E,灰色
栈内容:
○ C
返回文本
展示了七个图示。每个图示都显示了图和栈。在每个图示中,所有节点和边的位置与图 20-12 中相同。
已放回的节点被标记为白色、黑色或灰色。已修剪和已上色的节点与图 20-13 中的表示方式相同。
对于每个图示,我们提供了总结。然后描述完整图示,包括已修剪的节点。接着描述栈内容。
● 第一个图示。所有节点的边框为虚线,所有边为虚线,表示它们已从图中修剪掉。
栈内容,从上到下:
○ D
○ E
○ F
○ B
○ A
○ C
● 第二个图示。D 被从栈中弹出。它是图中唯一的节点,颜色为白色。
完整图示,列表形式:
○ A,已修剪。邻居节点:
● B,已修剪
● C,已修剪
● D,白色
○ B,已修剪。邻居节点:
● A,已修剪
● C,已修剪
● E,已修剪
● F,已修剪
○ C,已修剪。邻居节点:
● A,已修剪
● B,已修剪
● D,白色
● E,已修剪
○ D,白色。邻居节点:
● A,已修剪
● C,已修剪
● E,已修剪
● F,已修剪
○ E,已修剪。邻居节点:
● B,已修剪
● C,已修剪
● D,白色
● F,已修剪
○ F,已修剪。邻居节点:
● B,已修剪
● D,白色
● E,已修剪
栈内容,从上到下:
○ E
○ F
○ B
○ A
○ C
● 第三个图示。E 被从栈中弹出。图中有两个节点:E,灰色和 D,白色。它们是邻居节点。
完整图示,列表形式:
○ A,已修剪。邻居节点:
● B,已修剪
● C,已修剪
● D,白色
○ B,已修剪。邻居节点:
● A,已修剪
● C,已修剪
● E,灰色
● F,已修剪
○ C,已修剪。邻居节点:
● A,已修剪
● B,已修剪
● D,白色
● E,灰色
○ D,白色。邻居节点:
● A,已修剪
● C,已修剪
● E,灰色
● F,已修剪
○ E,灰色。邻居节点:
● B,已修剪
● C,已修剪
● D,白色
● F,已修剪
○ F,已修剪。邻居节点:
● B,已修剪
● D,白色
● E,灰色
栈内容,从上到下:
○ F
○ B
○ A
○ C
● 第四个图示。F 被从栈中弹出。图中有三个节点:F,黑色;E,灰色;D,白色。它们都是邻居节点。
完整图示,列表形式:
○ A,已修剪。邻居节点:
● B,已修剪
● C,已修剪
● D,白色
○ B,已修剪。邻居节点:
● A,已修剪
● C, 剪枝
● E, 灰色
● F, 黑色
○ C, 剪枝。邻居:
● A, 剪枝
● B, 剪枝
● D, 白色
● E, 灰色
○ D, 白色。邻居:
● A, 剪枝
● C, 剪枝
● E, 灰色
● F, 黑色
○ E, 灰色。邻居:
● B, 剪枝
● C, 剪枝
● D, 白色
● F, 黑色
○ F, 黑色。邻居:
● B, 剪枝
● D, 白色
● E, 灰色
栈内容,从上到下:
○ B
○ A
○ C
● 第五个图。B 被从栈中弹出并添加到图中。它是白色的。它的邻居是 E(灰色)和 F(黑色)。与前一个图没有其他变化。
完整图,列表形式:
○ A, 剪枝。邻居:
● B, 白色
● C, 剪枝
● D, 白色
○ B, 白色。邻居:
● A, 剪枝
● C, 剪枝
● E, 灰色
● F, 黑色
○ C, 剪枝。邻居:
● A, 剪枝
● B, 白色
● D, 白色
● E, 灰色
○ D, 白色。邻居:
● A, 剪枝
● C, 剪枝
● E, 灰色
● F, 黑色
○ E, 灰色。邻居:
● B, 白色
● C, 剪枝
● D, 白色
● F, 黑色
○ F, 黑色。邻居:
● B, 白色
● D, 白色
● E, 灰色
栈内容,从上到下:
○ A
○ C
● 第六个图。A 被从栈中弹出并添加到图中。它是灰色的。它的邻居是 B(白色)和 D(白色)。与前一个图没有其他变化。
完整图,列表形式:
○ A, 灰色。邻居:
● B, 白色
● C, 剪枝
● D, 白色
○ B, 白色。邻居:
● A, 灰色
● C, 剪枝
● E, 灰色
● F, 黑色
○ C, 剪枝。邻居:
● A, 灰色
● B, 白色
● D, 白色
● E, 灰色
○ D, 白色。邻居:
● A, 灰色
● C, 剪枝
● E, 灰色
● F, 黑色
○ E, 灰色。邻居:
● B, 白色
● C, 剪枝
● D, 白色
● F, 黑色
○ F, 黑色。邻居:
● B, 白色
● D, 白色
● E, 灰色
栈内容,从上到下:
○ C
● 第七个图。C 被从栈中弹出并添加到图中。它是黑色的。它的邻居是 A(灰色)、B(白色)、D(白色)和 E(灰色)。与前一个图没有其他变化。
完整图,列表形式:
○ A, 灰色。邻居:
● B, 白色
● C, 黑色
● D, 白色
○ B, 白色。邻居:
● A, 灰色
● C, 黑色
● E, 灰色
● F, 黑色
○ C, 黑色。邻居:
● A, 灰色
● B, 白色
● D, 白色
● E, 灰色
○ D, 白色。邻居:
● A, 灰色
● C, 黑色
● E, 灰色
● F, 黑色
○ E, 灰色。邻居:
● B, 白色
● C, 黑色
● D, 白色
● F, 黑色
○ F, 黑色。邻居:
● B, 白色
● D, 白色
● E, 灰色
栈为空。
返回文本
一个无向图,包含四个节点:EDI、EAX、tmp 和 arg。EDI 与 EAX 相邻。EAX 也与 tmp 相邻。tmp 也与 arg 相邻。
返回文本
一个无向图,包含三个节点:EDI、EAX 和 tmp。三个节点互为邻居。
返回文本
两个图显示了合并前后的干扰图。
● 原始干扰图。一个无向图,包含七个节点。三个硬寄存器:EDI、ESI 和 EAX。四个伪寄存器:tmp1、tmp2、tmp3 和 tmp4。
左边,EDI、ESI 和 EAX 被排列成一个三角形。在它们的右侧,tmp1、tmp2、tmp3 和 tmp4 被排列成一个方形。所有三个硬寄存器都是邻居。EDI 还与 tmp1 相邻。tmp1 还与 tmp2 相邻。tmp2 还与 tmp3 相邻。tmp3 还与 tmp4 相邻。
作为节点列表:
○ EDI。邻居:
● ESI
● EAX
● tmp1
○ ESI。邻居:
● EDI
● EAX
○ EAX。邻居:
● EDI
● ESI
○ tmp1。邻居:
● EDI
● tmp2
○ tmp2。邻居:
● tmp1
● tmp3
○ tmp3。邻居:
● tmp2
● tmp4
○ tmp4。邻居:
● tmp3
● 在将 tmp2 合并到 EAX 后,之前的图发生了三次变化。首先,tmp2 被移除。第二,添加了一条从 tmp1 到 EAX 的边。第三,添加了一条从 tmp3 到 EAX 的边。
图以列表形式展示:
○ EDI。邻居:
● ESI
● EAX
● tmp1
○ ESI。邻居:
● EDI
● EAX
○ EAX。邻居:
● EDI
● ESI
● tmp1
● tmp3
○ tmp1。邻居:
● EDI
● EAX
○ tmp3。邻居:
● EAX
● tmp4
○ tmp4。邻居:
● tmp3
返回文本
两个图展示了合并前后的干扰图。
● 原始干扰图。一个包含五个节点的无向图。三个硬件寄存器:EDI、ESI 和 EAX。两个伪寄存器:tmp1 和 tmp2。在左侧,EDI、ESI 和 EAX 形成一个三角形。它们都是邻居。在右侧,tmp1 位于 tmp2 之上。它们是邻居。
作为节点列表:
○ EDI。邻居:
● ESI
● EAX
○ ESI。邻居:
● EDI
● EAX
○ EAX。邻居:
● EDI
● ESI
○ tmp1。邻居:
● tmp2
○ tmp2。邻居:
● tmp1
● 在将 tmp1 合并到 EDI 后,之前的图发生了两个变化。首先,tmp1 被移除。其次,添加了一条从 tmp2 到 EDI 的边。
图以列表形式展示:
○ EDI。邻居:
● ESI
● EAX
● tmp2
○ ESI。邻居:
● EDI
● EAX
○ EAX。邻居:
● EDI
● ESI
○ tmp2。邻居:
● EDI
返回文本
一个包含七个节点的无向图。三个硬件寄存器:EDI、ESI、EAX。四个伪寄存器:a、x、y 和 z。
EAX、ESI 和 EDI 排列成一个三角形。在它们的右侧,x、y 和 z 排成一个三角形。a 位于图形的顶部。
三个硬件寄存器都是邻居。z 与 x 和 y 为邻。y 也与 EDI 为邻。x、ESI 和 a 都是邻居。
作为节点列表:
● EDI。邻居:
○ ESI
○ EAX
○ y
● ESI。邻居:
○ EDI
○ EAX
○ a
○ x
● EAX。邻居:
○ EDI
○ ESI
● a。邻居:
○ ESI
○ x
● x。邻居:
○ ESI
○ a
○ z
● y。邻居:
○ EDI
○ z
● z。邻居:
○ x
○ y
返回文本
图 20-19 中的图形,发生了三次变化。首先,x 被移除。第二,添加了一条从 a 到 y 的边。第三,添加了一条从 ESI 到 y 的边。
图以节点列表的形式展示。
● EDI。邻居:
○ ESI
○ EAX
○ y
● ESI。邻居:
○ EDI
○ EAX
○ a
○ y
● EAX。邻居:
○ EDI
○ ESI
● a。邻居:
○ ESI
○ y
● y。邻居:
○ EDI
○ ESI
○ a
○ z
● z。邻居:
○ y
返回文本
图以节点列表的形式展示。
● EDI。邻居:
○ ESI
○ EAX
○ a
○ y
● ESI。邻居:
○ EDI
○ EAX
○ a
○ x
● EAX。邻居:
○ EDI
○ ESI
● a。邻居:
○ EDI
○ ESI
○ x
● x。邻居:
○ ESI
○ a
○ z
● y。邻居:
○ EDI
○ z
● z。邻居:
○ x
○ y
返回文本
来自图 20-21 的图,包含三个变化。首先,x 被移除。其次,a 到 y 之间添加了一条边。第三,ESI 到 y 之间添加了一条边。
图以节点列表的形式呈现。
● EDI. 邻居:
○ ESI
○ EAX
○ a
○ y
● ESI. 邻居:
○ EDI
○ EAX
○ a
○ y
● EAX. 邻居:
○ EDI
○ ESI
● a. 邻居:
○ EDI
○ ESI
○ y
● y. 邻居:
○ EDI
○ ESI
○ a
○ z
● z. 邻居:
○ y
返回文本
一个包含五个节点的无向图。三个硬寄存器:EDI、ESI 和 EAX。两个伪寄存器:tmp1 和 tmp2. EDI、ESI、EAX 和 tmp1 都是邻居。tmp2 和 ESI、EAX 是邻居。
作为节点列表:
● EDI. 邻居:
○ ESI
○ EAX
○ tmp1
● ESI. 邻居:
○ EDI
○ EAX
○ tmp1
○ tmp2
● EAX. 邻居:
○ EDI
○ ESI
○ tmp1
○ tmp2
● tmp1. 邻居:
○ EDI
○ ESI
○ EAX
● tmp2. 邻居:
○ ESI
○ EAX
返回文本
一个包含十个节点的无向图。三个硬寄存器:EDI、ESI、EAX。七个伪寄存器:a、b、c、d、x、y 和 z。
EAX、ESI 和 EDI 排成一个三角形。在它们的右侧,x、y 和 z 排成一个三角形。在图的顶部,a 被 b、c 和 d 围绕。
所有三个硬寄存器都是邻居。z 和 x、y 是邻居。y 也和 EDI 是邻居。x、ESI 和 a 都是邻居。a 还与 b、c 和 d 是邻居。
作为节点列表:
● EDI. 邻居:
○ ESI
○ EAX
○ y
● ESI. 邻居:
○ EDI
○ EAX
○ a
○ x
● EAX. 邻居:
○ EDI
○ ESI
● a. 邻居:
○ ESI
○ b
○ c
○ d
○ x
● b. 邻居:
○ a
● c. 邻居:
○ a
● d. 邻居:
○ a
● x. 邻居:
○ ESI
○ a
○ z
● y. 邻居:
○ EDI
○ z
● z. 邻居:
○ x
○ y
返回文本
上述图有三个变化。首先,x 被移除。其次,a 到 y 之间添加了一条边。第三,ESI 到 y 之间添加了一条边。
图以节点列表的形式呈现。
● EDI. 邻居:
○ ESI
○ EAX
○ y
● ESI. 邻居:
○ EDI
○ EAX
○ a
○ y
● EAX. 邻居:
○ EDI
○ ESI
● a. 邻居:
○ ESI
○ b
○ c
○ d
○ y
● b. 邻居:
○ a
● c. 邻居:
○ a
● d. 邻居:
○ a
● y. 邻居:
○ EDI
○ ESI
○ a
○ z
● z. 邻居:
○ y
返回文本
一个终端的截图。顶部窗口显示“寄存器值不可用”的消息。中间窗口显示了来自列表 A-1 的前十条指令及其内存地址。底部窗口显示了刚刚输入的“layout reg”命令。
返回文本
一个终端的截图。顶部窗口显示了八字节通用寄存器和一些其他寄存器的值,以十进制和十六进制表示。中间窗口显示了来自列表 A-1 的前十条指令及其内存地址。第三条指令,“sub $0x10, %rsp”,被高亮显示。底部窗口显示了前两条命令及其输出。
返回文本


浙公网安备 33010602011771号