# 37 | 从AST到IR:体会数据流和控制流思维 你好,我是宫文学。 在上一节课,我们已经初步认识了基于图的IR。那接下来,我们就直接动手来实现它,这需要我们修改之前的编译程序,基于AST来生成IR,然后再基于IR生成汇编代码。 过去,我们语言的编译器只有前端和后端。加上这种中间的IR来过渡以后,我们就可以基于这个IR添加很多优化算法,形成编译器的中端。这样,我们编译器的结构也就更加完整了。 今天这节课,我先带你熟悉这个IR,让你能够以数据流和控制流的思维模式来理解程序的运行逻辑。之后,我还会带你设计IR的数据结构,并介绍HIR、MIR和LIR的概念。最后,我们再来讨论如何基于AST生成IR,从而为基于IR做优化、生成汇编代码做好铺垫。 首先,我还是以上一节课的示例程序为础,介绍一下程序是如何基于这个IR来运行的,加深你对控制流和数据流的理解。 ## 理解基于图的运行逻辑 下面是上节课用到的示例程序,一个带有if语句的函数,它能够比较充分地展示数据流和控制流的特点: ```plain function foo(a:number, b:number):number{ let x:number; if (a>10){ x = a + b; } else{ x = a - b; } return x; } ``` 我们把这个程序转化成图,是这样的: ![图片](https://static001.geekbang.org/resource/image/13/65/13fe00d052e8ccc7923424b7a2b34e65.jpg?wh=1920x1047) 我们之前说了,这个图能够忠实地反映源代码的逻辑。那如果程序是基于这个图来解释执行的,它应该如何运行呢?我们来分析一下。 第1步,从start节点进入程序。 第2步,程序顺着控制流,遇到if节点,并且要在if节点这里产生分支。但为了确定如何产生分支,if节点需要从数据流中获取一个值,这个值是由“>”运算符节点提供的。所以,“a>10”这个表达式,必须要在if节点之前运行完毕,来产生if节点需要的值。 第3步,我们假设a>10返回的是true,那么控制流就会走最左边的分支,也就是if块,直到这个块运行结束。而如果返回的是false,那么就走右边的分支,也就是else块,直到这个块运行结束。这里,if块和else块都是以Begin节点开始,以End节点结束。如果块中有if或for循环这样导致控制流变化的语句,那么它们对应的控制流就会出现在Begin和End之间,作为子图。 第4步,在if块或else执行结束后,控制流又会汇聚到一起。所以图中这里就出现了一个Merge节点。这个节点把两个分支的End节点作为输入,这样我们就能知道实际程序执行的时候,是从哪个分支过来的。 第5步,控制流到达Return节点。Return节点需要返回x的值,所以这就要求数据流必须在Return之前把x的值提供出来。那到底是x1的值,还是x2的值呢?这需要由Phi节点来确定。而Phi节点会从控制流的Merge节点获取控制流路径的信息,决定到底采用x1还是x2。 最后,return语句会把所获取的x值返回,程序结束。 在我这个叙述过程中,你有没有发现一个重要的特点,就是**程序的控制流和数据流是相对独立的,只是在个别地方有交互**。这跟我们平常写程序的思维方式是很不一样的。在写程序的时候,我们是把数据流与控制流混合在一起的,不加以区分。 比如,针对当前我们的示例程序,我们的源代码里一个if语句,然后在if块和else块中分别写一些代码。这似乎意味着,只能在进入if块的时候,才运行x1=a+b的代码,而在进入else块的时候,才可以运行x2=a-b的逻辑。 但如果你把数据流和控制流分开来思考,你会发现,其实我们在任何时候都可以去计算x1和x2的值,只要在return语句之前计算完就行。比如说,你可以把x1和x2的计算挪到if语句前面去,相当于把程序改成下面的样子: ```plain function foo(a:number, b:number):number{ x1 = a + b; x2 = a - b; if (a>10){ x = x1; } else{ x = x2; } return x; } ``` 当然,针对我们现在的例子,把x1和x2提前计算并没有什么好处,反倒增加了计算量。我的用意在于说明,**其实数据流和控制流之间可以不必耦合得那么紧,可以相对独立。** 我们可以用这种思想再来分析下我们上节课提到的几个优化技术。 比如,我们上一节课曾经提到过“循环无关变量外提”的优化技术。而基于当前的IR,我们马上就会识别出,其实与这个变量有关的数据流,是跟循环语句的控制流没有依赖关系的,所以自然就可以提到外面去。 如果采用CFG的数据结构,我们需要把代码从一个基本块挪到另一个基本块,这个过程比较复杂。而采用基于图的IR,我们只需要在生成代码的时候,再决定把数据流对应的代码生成到哪个基本块里就好了。 其实,虽然llvm采用了CFG表示大的控制流逻辑,但它同时也采用了use-def链来表示程序中的数据流逻辑,因为优化算法需要同时用到这两方面的信息。但相对来说,我们现在的IR让控制流和数据流更大程度地解耦了,带来了算法上的便利。 而且,这个例子中的数据流节点,并不受限于if语句的控制流,在任何时候你都可以计算它,可以灵活地调整执行的先后顺序,这个时候我们说它们是浮动(floating)节点。它们的计算顺序只受输入关系的限制。 我们后面还会遇到一些情况,比如数据流的某些节点没有那么自由,它们不可以随意改变计算顺序,那我们说这些节点是固定的。 好了,你现在已经对基于IR的运行逻辑有了一定的理解了。那接下来,我们就开始动手做实现吧!首先,我们要用TypScript设计一些数据结构,来表示这种基于图的IR,就像我们之前设计了一些数据结构来表示AST那样。 ## 设计IR的数据结构 要表达这种基于图的IR,重点就是**设计各种各样的节点**。而节点之间的连线呢,则是**通过节点之间的互相引用来表示**的。 我先设计了一个叫IRNode的基类,其他的节点都是从这个基类派生的。 ```plain //基类 export abstract class IRNode{ } ``` IRNode有两个直接的子类,DataNode和ControlNode。 DataNode是所有数据节点的基类。DataNode可能从别的DataNode获得输入,也会成为其他DataNode的输入,这样就构成了use-def链。这个链是双向的连接,DataNode的子类只需要维护自己的input的一边,uses是被使用到它的其他节点在构造函数里自动维护的。 ```plain //数据流节点的基类 export abstract class DataNode extends IRNode{ //该节点的输入 abstract get inputs():DataNode[]; //使用该节点的节点,形成use-def链,自动维护 uses:DataNode[] = []; //数据类型 theType:Type; } ``` DataNode的一个子类是二元运算节点。在这里,你可以看看其中的uses是如何被自动维护的: ```plain //二元运算节点 export class BiOpNode extends DataNode{ left:DataNode; right:DataNode; constructor(left:DataNode, right:DataNode, theType:Type){ super(theType); this.left = left; this.right = right; //自动建立双向的use-def链 left.uses.push(this); right.uses.push(this); } get inputs():DataNode[]{ return [this.left, this.right]; } } ``` IRNode的另一个子类ControlNode,是各种控制节点的共同基类。控制节点可能有多个后序节点,但最多只能有一个前序节点。 ```plain //控制流节点的基类 export abstract class ControlNode extends IRNode{ //后序节点列表 abstract get successors():IRNode[]; //前序节点列表,自动维护 predecessors:IRNode[] = []; } ``` 在ControlNode的子类中,我们只需要维护自己的后序节点,形成正向的链接就好了。而前序节点是被自动维护的,形成反向的链接。这样,前后两个节点之间就有了双向链接。 ControlNode的一个子类是IfNode。它有两个后序节点,并且还需要一个来自DataNode的输入作为if的条件。 ``` //if节点 export class IfNode extends ControlNode{ thenBranch:Begin; elseBranch:Begin; condition:DataNode; //If条件 constructor(condition:DataNode, thenBranch:Begin, elseBranch:End){ super(); this.condition = condition; this.thenBranch = thenBranch; this.elseBranch = elseBranch; thenBranch.predecessors.push(this); elseBranch.predecessors.push(this); } get successors():IRNode[]{ return [this.thenBranch, this.elseBranch]; } } ``` 基于这个思路,我们可以设计出目前需要的各种IR节点,如下图所示: ![图片](https://static001.geekbang.org/resource/image/60/8b/604402e08f7d654f32a473697ca07b8b.jpg?wh=1080x1132) 这里的具体实现,你可以看[ir.ts](https://gitee.com/richard-gong/craft-a-language/blob/master/37/ir.ts)。 你看到这里,肯定会有很多的问题。你可能会问,为什么要设计出这样的节点?IR中包含哪些类型的节点,有没有什么依据呀?这些节点怎么跟AST差不多呀?那我们就来分析一下这几个IR设计的问题。 ## HIR、MIR和LIR 其实,我们目前设计的IR节点,都是抽象度比较高的。换句话说,它跟AST在语义上是差不多的,只不过是换了一种表示方式而已。这种比较贴近源代码的、抽象层次比较高的IR,被叫做**HIR**。 与之相对应的另一端,是比较贴近机器实现的、容易转化成机器码或者汇编码的IR,叫做**LIR**。 我们具体说说HIR和LIR的区别,也就是说,抽象层级的差别,到底体现在哪里。 **首先,是一些控制流节点的差别**。在HIR里,你会见到像if节点这样的元素,显然这种元素来自源代码。在LIR里,像if节点这样的节点会被类似跳转指令的节点所代替。它们都是实现控制流的管理的,但一个抽象层级更高,一个更底层。 **第二,在数据节点方面也有区别。**在HIR里,我们用加减乘数这样的节点来表达运算。每个运算节点可以由两个节点来提供数据,计算结果保存到另一个节点,这样一共是3个地址。而在LIR里,有的CPU架构和指令集,比如X86的架构,是不支持三地址运算的,只能把一个数据加到另一个数据上,所以还必须进行IR的转换。 **最后,在数据类型方面也有很大差别。**高级语言中有丰富的类型系统,你可以在HIR中使用它们。但到了LIR层面,你只能使用CPU可以识别的数据类型,比如各种不同位数的整型和浮点型。但是对象、数组这些,通通都消失不见了。 通过这样的对比,你大概能够明白HIR和LIR的区别了。 那最后,位于HIR和LIR之间,还有一种叫做**MIR**。它既能让我们人类比较容易理解,又能够尽量保持对特定硬件平台的独立性。 **在传统的编译器中,我们需要分别设计HIR、MIR和LIR,然后实现依次的转换。这个过程,就叫做Lower的过程。** 在前面的课程里,我们曾经使用了一些内部的数据结构,比如Inst和Oprand来表示汇编代码,这就可以看做是很Low很Low的IR了,因为它很贴近X86架构的实现,没有什么跨硬件平台的能力。就算这样,我们仍然从中分出了两个层次。像函数调用、浮点数字面量的实现,我们一开始还是采用比较抽象的Oprand,之后再Lower到跟汇编代码能够完全一一对应的Oprand。 这里我要说明一下,基于图的IR还有另一个重要的优点,就是**我们可以用同一种数据结构来表示HIR、MIR和LIR**。它们的区别,只是体现在不同的节点类型上。在Lower的过程中,你可以用低抽象度的节点替换高抽象度的节点就行了。 好了,我们现在理解了IR设计中的抽象层次问题。那接下来,我们就要把AST翻译成IR。 ## 把AST翻译成IR 我们一开始只需要把AST翻译成HIR。因为这两者在语义上是比较相似的,所以翻译的难度比较低。相比而言,我们之前直接从AST翻译成汇编代码,中间的跨度就有点大,需要处理的细节就很多。 首先,我们看最简单的情况,也就是没有if语句、for循环语句这种程序分支的情况。比如下面的代码: ```plain function foo(a:number, b:number){ let x:number = a + b; let y:number = a + b; let z:number = x + y + 1; } ``` 在这种情况下,a和b被翻译成参数节点,1被翻译成常量节点,每个表达式都被翻译成了一个运算节点,这些节点也是x、y和z三个本地变量的定义。 ![图片](https://static001.geekbang.org/resource/image/7e/03/7ecb77f3eb515e649172c1987db3cc03.jpg?wh=1528x905) 这里你要注意几个点。首先,本地变量都是被参数、常数和其他变量定义出来的,是数据流的中间节点。而参数和常量节点才可以是叶子节点。这是IR跟AST的一个很大的不同,因为AST中,本地变量是可以作为叶子节点的。 第二,一个运算节点可能跟多个变量相关联,比如示例程序中的a + b,就既代表了x变量,又代表了y变量。 最后,上面的示例程序中还有一个现象,就是在变量z的定义中,出现了连续的加法运算。这个时候,中间的一个+号节点并不对应某个变量的符号,这时候它相当于一个临时变量。 **接下来,我们把这个例子再复杂化一点,让变量x做了第二次赋值。这个时候,我们需要把这两个x区分开,从而让生成的IR保持SSA格式。** ```plain function foo(a:number, b:number){ let x:number = a + b; let y:number = a + b; x = a - b; let z:number = x + y + 1; } ``` 把x分解成x1和x2以后,这个示例程序就相当于变成了下面的样子: ```plain function foo(a:number, b:number){ let x1:number = a + b; let y:number = a + b; let x2 = a - b; let z:number = x2 + y + 1; } ``` 其中,变量z的定义引用的是x2,跟x1没有关系。所以说,在直线式运行的代码中,我们能很容易地对同一个变量的多个分身进行区分。我们总是采用最后一个分身的值。 不过,当存在控制流的分支的时候,要确定采用哪个分身的值,就没这么简单了,这也是**Phi运算**要发挥作用的时候。 现在我们就来讨论一下如何把if语句转化成IR。我们还是用这节课一开头的例子,改成SSA格式以后大约相当于下面的伪代码: ``` function foo(a:number, b:number):number{ let x1:number; let x2:number; if (a>10){ x1 = a + b; } else{ x2 = a - b; } let x = phi(which-if-branch,x1,x2); //根据if分支来确定使用x1还是x2。 return x; } ``` 这里,我用x1和x2代替了原来的x,在if语句之后,用了一个Phi运算来得到最后x的值。 if语句的控制流部分和条件部分,我们可以根据这节课一开头我们的分析,生成相应的节点就好了。这里涉及的控制节点包括IfNode、BeginNode、EndNode和MergeNode。在IfNode和MergeNode这里,要跟数据流建立连接。 这里的具体实现,你可以参考[ir.ts](https://gitee.com/richard-gong/craft-a-language/blob/master/37/ir.ts)中的源代码。另外,你还可以运行node play example\_ir.ts --dumpIR命令,这会把ir输出成.dot文件。.dot文件可以用graphviz软件打开查看,你能看到编译生成的ir图。这里其实还有更简单的办法,就是直接在visual studio code中打开,并用预览模式查看图形。 这样,我们就把if语句分析完了。下一节,我会继续带你分析一个更复杂一点的例子,就是for循环语句,带你更加深入的掌握生成IR的思路,从而也能够更加洞察这种IR的内在逻辑。 ## 课程小结 今天课程的新内容也不少。我梳理一下其中的要点,希望你能记住: 首先,在基于图的IR中,控制流和数据流是相对独立的,耦合度较低。数据流节点往往是浮动的,并不像源代码里那样被限制在某个基本块中。这个特征有利于代码在不同的基本块中的迁移,实现一些优化效果。 第二,IR的设计中,数据节点要保存输入信息,形成自己的定义。同时,数据节点也会被自动维护该节点使用信息,也就是自己构成了哪些其他变量的定义,从而形成了双向的use-def链。而控制节点则要保存自己的后序节点信息,它的前序节点会被自动维护,这样也就构成了可以双向导航的链。在当前的设计方案中,每个控制节点最多只能有一个前序节点。 第三,IR可以划分为HIR、MIR和LIR,它们的抽象层次越来越低,从贴近高级语言,逐步Lower到贴近CPU架构。抽象层次体现在使用的节点的类型和数据类型等方面。基于图的IR的有一个优点,就是它能够用同一个数据结构,承载不同抽象层次的IR,只需要我们把节点逐步替换就行。 最后,在把AST翻译成IR的过程中,你要体会出AST和基于图的IR的不同之处。包括本地变量不会作为端点出现,必然是被其他节点定义出来的。再比如,一个节点可能对应AST中的两个变量。 ## 思考题 我们今天讲到了HIR、MIR和LIR的区别。那么,我这里有三个使用IR的场景,你能帮我判断一下它应该属于哪类IR吗? * 场景1:访问对象mammal的属性weight; * 场景2:根据对象引用,加上一个偏移量,然后获取该地址的数值; * 场景3:根据对象在x86-64架构下的地址,加上一个64位的偏移量,获取这个地址下的双精度浮点数值。 欢迎你把这节课分享给更多感兴趣的朋友。我是宫文学,我们下节课见。 ## 资源链接 [今天的示例代码目录在这里!](https://gitee.com/richard-gong/craft-a-language/tree/master/37)