从嵌套表达式谈抽象语法树(AST)到平台无关中间指令(IR)的翻译过程(线性化)
#一些想到的东西:
## 要掌握编译器前端Parser的语法解析是怎么回事,不需要用完整的C语言系的字符串,只要考虑带括号的数学中缀表达式怎么处理就行了。
## 这之间,关键的3点需要掌握:
(1)怎么写一个算符优先、递归下降的BNF文法
(2)怎么处理优先级和结合性的问题
## 前端Parser存在性能的好坏么?这个是依据函数的调用/回溯个数来做判断(递归下降的思路),还是根据空间占用?(LR系的语法解析会有这个问题)
## 理解了表达式怎么parse,则一般的程序就是个小case,但这里有个困难的提升:
### OO系及动态语言,需要理解对象/类的继承树层次的内存布局是怎么回事?比如C++的vtable、Python/Ruby/JS的原型链、PHP的Object/Value底层数据结构
### C/C++这类底层的系统编程语言里,函数的调用传参什么的,涉及到ABI、Stack Frame等概念
## 但是对于掌握前端parse的基本原理,我觉得这些不重要
## 当然,用Lambda也可以学习编程语言的基本构造,但那玩意儿太过学术抽象,没有表达式那么直观容易理解
#什么是平台无关中间指令
平台无关中间指令(下面一律简称为IR,中间表示)不对应任何特定的硬件CPU架构,但是,你仍然需要定义最基本的内存数据load/store、寄存器寻址、分支跳转、栈操作等等。LLVM当初主要优点就是引入了SSA(虽然不是原创,据说原创是Open64),但SSA仅仅是一种约束,以方便后端优化。那么,什么是IR的核心特性?“虚拟寄存器”。
所谓的“虚拟寄存器”,就是在IR里仍然寄存器是不受限的,从0号开始分配,最终映射到特定硬件CPU架构的物理寄存器,比如x86、ARM、MIPS等等。这个映射过程就是一个著名的“寄存器分配”算法,可以使用基本的先到先得能Work就行的处理,也可以应用贪心策略,当然,理论上还有一个基于图着色模型的算法,不过一般好像并不用。
最后再说一句,IR的核心就是“虚拟寄存器”。
#现在我们考虑表达式对应的AST结构怎么转换(映射)IR序列,这里称之为“线性化”过程
这也就是本文想重点讲的一个东西。
How?这里没有什么特别的理论,使用一个简单但是能够说明问题的例子来illustrate就可以了:
设想下面的表达式:a * (b-c) / (b+c)。我们用%0、%1、... 表示分配的虚拟寄存器(不考虑这里a、b、c是不是内存变量需要load/store)。我们可以从左往右挨个运算(方法1):
%0 = b-c
%1 = a * %0%2 = b+c%3 = %1 / %2
OK,这是正常的思路,但我们也可以先优先算括号里面的(方法2):
%0 = b-c
%1 = b+c%2 = a * %0%3 = %2 / %1
方法1通常比较容易想到:当对一个AST进行DFS遍历,一边递归下降一边生成IR序列。但是这个代码不大容易写对!
方法2就不一样了,当然,它生成的对应IR序列同样也可以通过对AST进行递归遍历来生成,但是,我可以不这么做,而是引入一个基于依赖图的模型,优先级高的子表达式相当于被外层所依赖。这样,我们把AST不再当作是一棵树,而是提升为一个图,然后我们来个拓扑排序(注意,这是数据结构课程里的经典内容)。。。搞定。
我想这就是JVM里所谓Graph IR归约的基本原理。(不过我没有验证,也许懂的人可以说一下)
不过为防止我的理解偏差,我将这个新方法称之为“基于数据依赖图的AST到IR生成”。方法2相比方法1到底有什么好处呢?前面已经说了,方法1需要一边递归遍历AST一边生成IR,这个过程的代码实际上很不容易写对,人的大脑不适合思考递归;而方法2实际上却是基于迭代的思路,这个代码写起来就清晰多了。
注意这里方法1和方法2最终都使用了相同数目的虚拟寄存器。而且默认都是SSA的形式,当去除SSA时(Collapse),可能就不需要这么多虚拟寄存器了,其实思路也很简单:当虚拟寄存器的某个Def在IR序列中不再被Use,这个虚拟寄存器就可以被回收重用了(这里似乎又可以应用垃圾回收的一般理论算法了)。比如方法1和方法2 IR序列的最后一个%3都可以替换为%0。
接下来就是IR到硬件指令的映射,不考虑各种平台特定的intrinsics,实际上最关键的处理就是怎么把无限的虚拟寄存器映射到有限的物理寄存器,麻烦的地方在于:
(1)某些硬件寄存器已经由于ABI不能被自由征用;
(2)怎么优先分配?(3)没分配到的只能Push到栈上暂存,这里又涉及到ABI
不过这个问题就不是本文想要探讨的内容了。