This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,158 @@
<audio id="audio" title="20 | 高效运行:编译器的后端技术" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/37/4a/375e318f6491529a3a406400581d504a.mp3"></audio>
前18节课我们主要探讨了编译器的前端技术它的重点是让编译器能够读懂程序。无结构的代码文本经过前端的处理以后就变成了Token、AST和语义属性、符号表等结构化的信息。基于这些信息我们可以实现简单的脚本解释器这也从另一个角度证明了我们的前端处理工作确实理解了程序代码否则程序不可能正确执行嘛。
实际上,学完前端技术以后,我们已经能做很多事情了,比如让软件有自定义功能,就像我们在[15讲](https://time.geekbang.org/column/article/136557)中提到的报表系统,这时,不需要涉及编译器后端技术。
但很多情况下,我们需要继续把程序编译成机器能读懂的代码,并高效运行。**这时,我们就面临了三个问题:**
1.我们必须了解计算机运行一个程序的原理(也就是运行期机制),只有这样,才知道如何生成这样的程序。<br>
2.要能利用前端生成的AST和属性信息将其正确翻译成目标代码。<br>
3.需要对程序做尽可能多的优化,比如让程序执行效率更高,占空间更少等等。
弄清这三个问题,是顺利完成编译器后端工作的关键,本节课,我会让你对程序运行机制、生成代码和优化代码有个直观的了解,然后再在接下来的课程中,将这些问题逐一击破。
## 弄清程序的运行机制
总的来说,编译器后端要解决的问题是:现在给你一台计算机,你怎么生成一个可以运行的程序,然后还能让这个程序在计算机上正确和高效地运行?
我画了一个模型:
<img src="https://static001.geekbang.org/resource/image/0a/40/0ab606233923bd3904950acf39f6a440.jpg" alt="">
基本上,我们需要面对的是两个硬件:
<li>
**一个是CPU它能接受机器指令和数据并进行计算。**它里面有寄存器、高速缓存和运算单元,充分利用寄存器和高速缓存会让系统的性能大大提升。
</li>
<li>
**另一个是内存。**我们要在内存里保存编译好的代码和数据,还要设计一套机制,让程序最高效地利用这些内存。
</li>
通常情况下,我们的程序要受某个操作系统的管理,所以也要符合操作系统的一些约定。但有时候我们的程序也可能直接跑在硬件上,单片机和很多物联网设备采用这样的结构,甚至一些服务端系统,也可以不跑在操作系统上。
你可以看出,编译器后端技术跟计算机体系结构的关系很密切。我们必须清楚地理解计算机程序是怎么运行的,有了这个基础,才能探讨如何编译生成这样的程序。
所以,我会在下一节课,也就是**21讲**将运行期的机制讲清楚比如内存空间如何划分和组织程序是如何启动、跳转和退出的执行过程中指令和数据如何传递到CPU整个过程中需要如何跟操作系统配合等等。
也有的时候我们的面对的机器是虚拟机Java的运行环境就是一个虚拟机JVM那我们需要就了解这个虚拟机的特点以便生成可以在这个虚拟机上运行的代码比如Java的字节码。同时字节码有时仍然需要编译成机器码。
在对运行期机制有了一定的了解之后,我们就有底气来进行下一步了,生成符合运行期机制的代码。
## 生成代码
编译器后端的最终结果就是生成目标代码。如果目标是在计算机上直接运行就像C语言程序那样那这个目标代码指的是汇编代码。而如果运行目标是Java虚拟机那这个目标代码就是指JVM的字节码。
基于我们在编译器前端所生成的成果,我们其实可以直接生成汇编代码,在后面的课程中,我会带你做一个这样的尝试。
你可能惧怕汇编代码,觉得它肯定很难,能写汇编的人一定很牛。在我看来,这是一个偏见,因为汇编代码并不难写,为什么呢?
其实汇编没有类型,也没有那么多的语法结构,它要做的通常就是把数据拷贝到寄存器,处理一下,再保存回内存。所以,从汇编语言的特性看,就决定了它不可能复杂到哪儿去。
你如果问问硬件工程师就知道了,因为他们经常拿汇编语言操作寄存器、调用中断,也没多难。但另一方面,正是因为汇编的基础机制太简单,而且不太安全,用它编写程序的效率太低,所以现在直接用汇编写的程序,都是处理很小、很单一的问题,我们不会再像阿波罗登月计划那样,用汇编写整个系统,这个项目的代码最近已经开源了,如果现在用高级语言去做这项工作,会容易得多,还可以像现在的汽车自动驾驶系统一样实现更多的功能。
所以,**在22和23讲**我会带你从AST直接翻译成汇编代码并编译成可执行文件这样你就会看到这个过程没有你想象的那么困难你对汇编代码的恐惧感也会就此消失了。
当然,写汇编跟使用高级语言有很多不同,**其中一点就是要关心CPU和内存这样具体的硬件。**比如你需要了解不同的CPU指令集的差别你还需要知道CPU是64位的还是32位的有几个寄存器每个寄存器可以用于什么指令等等。但这样导致的问题是每种语言针对每种不同的硬件都要生成不同的汇编代码。你想想看一般我们设计一门语言要支持尽可能多的硬件平台这样的工作量是不是很庞大
所以,为了降低后端工作量,提高软件复用度,就需要引入**中间代码Intermediate RepresentationIR的机制**它是独立于具体硬件的一种代码格式。各个语言的前端可以先翻译成IR然后再从IR翻译成不同硬件架构的汇编代码。如果有n个前端语言m个后端架构本来需要做m*n个翻译程序现在只需要m+n个了。这就大大降低了总体的工作量。
<img src="https://static001.geekbang.org/resource/image/23/ea/23578fc6e348e79876bdeb90f0ee30ea.jpg" alt="">
甚至很多语言主要做好前端就行了后端可以尽量重用已有的库和工具这也是现在推出新语言越来越快的原因之一。像Rust就充分利用了LLVMGCC的各种语言如C、C++、Object C等也是充分共享了后端技术。
IR可以有多种格式在第24讲我们会介绍三地址代码、静态单赋值码等不同的IR。比如“x + y * z”翻译成三地址代码是下面的样子每行代码最多涉及三个地址其中t1和t2是临时变量
```
t1 := y * z
t2 := x + t1
```
Java语言生成的字节码也是一种IR我们还会介绍LLVM的IR并且基于LLVM这个工具来加速我们后端的开发。
其实IR这个词直译成中文是“中间表示方式”的意思不一定非是像汇编代码那样的一条条的指令。所以AST其实也可以看做一种IR。我们在前端部分实现的脚本语言就是基于AST这个IR来运行的。
每种IR的目的和用途是不一样的
- AST主要用于前端的工作。
- Java的字节码是设计用来在虚拟机上运行的。
- LLVM的中间代码主要是用于做代码翻译和编译优化的。
- ……
总的来说,我们可以把各种语言翻译成中间代码,再针对每一种目标架构,通过一个程序将中间代码翻译成相应的汇编代码就可以了。然而事情真的这么简单吗?答案是否定的,因为我们还必须对代码进行优化。
## 代码分析和优化
生成正确的、能够执行的代码比较简单,可这样的代码执行效率很低,因为直接翻译生成的代码往往不够简洁,比如会生成大量的临时变量,指令数量也较多。因为翻译程序首先照顾的是正确性,很难同时兼顾是否足够优化,这是一方面。另一方面,由于高级语言本身的限制和程序员的编程习惯,也会导致代码不够优化,不能充分发挥计算机的性能。所以我们一定要对代码做优化。程序员在比较各种语言的时候,一定会比较它们的性能差异。一个语言的性能太差,就会影响它的使用和普及。
实际上就算是现在常见的脚本语言如Python和JavaScript也做了很多后端优化的工作包括编译成字节码、支持即时编译等这些都是为了进一步提高性能。从谷歌支持的开源项目V8开始JavaScript的性能获得了巨大的提高这才导致了JavaScript再一次的繁荣包括支持体验更好的前端应用和基于Node.js的后端应用。
优化工作又分为**“独立于机器的优化”和“依赖于机器的优化”**两种。
独立于机器的优化是基于IR进行的。它可以通过对代码的分析用更加高效的代码代替原来的代码。比如下面这段代码中的foo()函数里面有多个地方可以优化。甚至我们连整个对foo()函数的调用也可以省略因为foo()的值一定是101。这些优化工作在编译期都可以去做。
```
int foo(){
int a = 10*10; //这里在编译时可以直接计算出100这个值
int b = 20; //这个变量没有用到,可以在代码中删除
if (a&gt;0){ //因为a一定大于0所以判断条件和else语句都可以去掉
return a+1; //这里可以在编译器就计算出是101
}
else{
return a-1;
}
}
int a = foo(); //这里可以直接地换成 a=101;
```
上面的代码,通过优化,可以消除很多冗余的逻辑。这就好比你正在旅行,先从北京飞到了上海,然后又飞到厦门,最后飞回北京。然后你朋友问你现在在哪时,你告诉他在北京。那么他虽然知道你在北京,但并没有意识到你已经在几个城市折腾了一圈,因为他只关心你现在在哪儿,并不关心你的中间过程。 我们在给a赋值的时候只需要知道这个值是101就行了。完全不需要在运行时去兜一大圈来计算。
计算机代码里有很多这种需要优化的情形。我们在27和28讲会介绍多种优化技术比如局部优化和全局优化常数折叠、拷贝传播、删除公共子表达式等其中数据流分析方法比较重要会重点介绍。
**依赖于机器的优化,则是依赖于硬件的特征。**现代的计算机硬件设计了很多特性,以便提供更高的处理能力,比如并行计算能力,多层次内存结构(使用多个级别的高速缓存)等等。编译器要能够充分利用硬件提供的性能,比如
<li>
**寄存器优化。**对于频繁访问的变量,最好放在寄存器中,并且尽量最大限度地利用寄存器,不让其中一些空着,有不少算法是解决这个问题的,教材上一般提到的是染色算法;
</li>
<li>
**充分利用高速缓存。**高速缓存的访问速度可以比内存快几十倍上百倍所以我们要尽量利用高速缓存。比如某段代码操作的数据在内存里尽量放在一起这样CPU读入数据时会一起都放到高速缓存中不用一遍一遍地重新到内存取。
</li>
<li>
**并行性。**现代计算机都有多个内核,可以并行计算。我们的编译器要尽可能把充分利用多个内核的计算能力。 这在编译技术中是一个专门的领域。
</li>
<li>
**流水线。**CPU在处理不同的指令的时候需要等待的时间周期是不一样的在等待某些指令做完的过程中其实还可以执行其他指令。就比如在星巴克买咖啡交了钱就可以去等了收银员可以先去处理下一个顾客而不是要等到前一个顾客拿到咖啡才开始处理下一个顾客。
</li>
<li>
**指令选择。**有的时候CPU完成一个功能有多个指令可供选择。而针对某个特定的需求采用A指令可能比B指令效率高百倍。比如X86架构的CPU提供SIMD功能也就是一条指令可以处理多条数据而不是像传统指令那样一条指令只能处理一条数据。在内存计算领域SIMD也可以大大提升性能我们在第30讲的应用篇会针对SIMD做一个实验。
</li>
<li>
**其他优化。**比如可以针对专用的AI芯片和GPU做优化提供AI计算能力等等。
</li>
可以看出来,做好依赖于机器的优化要对目标机器的体系结构有清晰的理解,如果能做好这些工作,那么开发一些系统级的软件也会更加得心应手。实际上,数据库系统、大数据系统等等,都是要融合编译技术的。
总结起来,在编译器中需要对代码进行的优化非常多。因此,这部分工作也是编译过程中耗时最长、最体现某个编译器的功力的一类工作,所以更值得引起你的重视。
## 课程小结
本节课,我们对编译器的后端技术做了概述。你了解到要做好后端工作,必须熟悉计算机体系结构和程序的运行时机制;还要从前端生成中间代码,然后基于中间代码生成针对不同平台的目标代码;最后,需要对代码做各种优化工作,包括独立于机器的优化和依赖于机器的优化。
刚接触编译技术的时候你可能会把视线停留在前端技术上以为能做Lexer、Parser就是懂编译了。实际上词法分析和语法分析比较成熟有成熟的工具来支撑。**相对来说,后端的工作量更大,挑战更多,研究的热点也更多。**比如人工智能领域又出现了一些专用的AI芯片和指令集就需要去适配。
编译器的后端,要把高级语言翻译成计算机能够理解的目标语言。它跟前端相比,关注点是不同的。前端关注的是正确反映了代码含义的静态结构,而后端关注的是让代码良好运行的动态结构。它们之间的差别,从我讲解“作用域”和“生存期”两个概念时就能看出来。作用域是前端的概念,而生存期是后端的概念。
其实在前面的课程中我们已经涉及了少量的后端技术的概念比如生存期、栈桢因为我们要让脚本语言运行起来。但这个运行环境比较简单脚本的执行也是简单的基于AST所以性能是比较低的。但在后端部分我们会实现一门静态编译型的语言因此会对对运行期机制做更加深入的解读和实现。
如果能把后端技术学好,你对计算机底层运行机制的理解会更上一层楼,也会成为一名底子更加扎实的软件工程师。
## 一课一思
我们说编译器后端的任务是让程序适配硬件、高效运行。对于你所熟悉的程序语言,它的后端技术有什么特点呢?比如它采用了哪些技术使得性能更高,或者代码尺寸更小,或者能更好地兼容硬件?欢迎在留言区分享你的经验和观点。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。

View File

@@ -0,0 +1,218 @@
<audio id="audio" title="21 | 运行时机制:突破现象看本质,透过语法看运行时" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/62/fc/624f041baa6ae6709497ea01849211fc.mp3"></audio>
编译器的任务,是要生成能够在计算机上运行的代码,但要生成代码,我们必须对程序的运行环境和运行机制有比较透彻的了解。
你要知道大型的、复杂一点儿的系统比如像淘宝一样的电商系统、搜索引擎系统等等都存在一些技术任务是需要你深入了解底层机制才能解决的。比如淘宝的基础技术团队就曾经贡献过Java虚拟机即时编译功能中的一个补丁。
这反映出掌握底层技术能力的重要性,所以,如果你想进阶成为这个层次的工程师,不能只学学上层的语法,而是要把计算机语言从上层的语法到底层的运行机制都了解透彻。
本节课,我会对计算机程序如何运行,做一个解密,话题分成两个部分:
1.了解程序运行的环境包括CPU、内存和操作系统探知它们跟程序到底有什么关系。<br>
2.了解程序运行的过程。比如,一个程序是怎么跑起来的,代码是怎样执行和跳转的,又是如何管理内存的。
首先,我们先来了解一下程序运行的环境。
## 程序运行的环境
程序运行的过程中主要是跟两个硬件CPU和内存以及一个软件操作系统打交道。
<img src="https://static001.geekbang.org/resource/image/eb/cd/eba17e1195eae228fd9dceea3b06efcd.jpg" alt="">
本质上我们的程序只关心CPU和内存这两个硬件。你可能说“不对啊计算机还有其他硬件比如显示器和硬盘啊。”但对我们的程序来说操作这些硬件也只是执行某些特定的驱动代码跟执行其他代码并没有什么差异。
#### 1.关注CPU和内存
CPU的内部有很多组成部分对于本课程来说我们重点关注的是**寄存器以及高速缓存,**它们跟程序的执行机制和优化密切相关。
**寄存器**是CPU指令在进行计算的时候临时数据存储的地方。CPU指令一般都会用到寄存器比如典型的一个加法计算c=a+b的过程是这样的
>
<p>指令1mov从内存取a的值放到寄存器中<br>
指令2add再把内存中b的值取出来与这个寄存器中的值相加仍然保存在寄存器中<br>
指令3mov最后再把寄存器中的数据写回内存中c的地址。</p>
寄存器的速度也很快,所以能用寄存器就别用内存。尽量充分利用寄存器,是编译器做优化的内容之一。
**而高速缓存**可以弥补CPU的处理速度和内存访问速度之间的差距。所以我们的指令在内存读一个数据的时候它不是老老实实地只读进当前指令所需要的数据而是把跟这个数据相邻的一组数据都读进高速缓存了。这就相当于外卖小哥送餐的时候不会为每一单来回跑一趟而是一次取一批如果这一批外卖恰好都是同一个写字楼里的那小哥的送餐效率就会很高。
内存和高速缓存的速度差异差不多是两个数量级也就是一百倍。比如高速缓存的读取时间可能是0.5ns而内存的访问时间可能是50ns。不同硬件的参数可能有差异但总体来说是几十倍到上百倍的差异。
你写程序时,尽量把某个操作所需的数据都放在内存中的连续区域中,不要零零散散地到处放,这样有利于充分利用高速缓存。**这种优化思路,叫做数据的局部性。**
**这里提一句,**在写系统级的程序时你要对各种IO的时间有基本的概念比如高速缓存、内存、磁盘、网络的IO大致都是什么数量级的。因为这都影响到系统的整体性能也影响到你如何做程序优化。如果你需要对程序做更多的优化还需要了解更多的CPU运行机制包括流水线机制、并行机制等等这里就不展开了。
讲完CPU之后还有内存这个硬件。
程序在运行时操作系统会给它分配一块虚拟的内存空间让它在运行期可以使用。我们目前使用的都是64位的机器你可以用一个64位的长整型来表示内存地址它能够表示的所有地址我们叫做寻址空间。
64位机器的寻址空间就有2的64次方那么大也就是有很多很多个TBTerabyte大到你的程序根本用不完。不过操作系统一般会给予一定的限制不会给你这么大的寻址空间比如给到100来个G这对一般的程序也足够用了。
在存在操作系统的情况下,程序逻辑上可使用的内存一般大于实际的物理内存。程序在使用内存的时候,操作系统会把程序使用的逻辑地址映射到真实的物理内存地址。有的物理内存区域会映射进多个进程的地址空间。
<img src="https://static001.geekbang.org/resource/image/e1/48/e17dc76e20cfb194dac757f2e10e4b48.jpg" alt="">
对于不太常用的内存数据,操作系统会写到磁盘上,以便腾出更多可用的物理内存。
当然,也存在没有操作系统的情况,这个时候你的程序所使用的内存就是物理内存,我们必须自己做好内存的管理。
**对于这个内存,该怎么用呢?**
本质上来说你想怎么用就怎么用并没有什么特别的限制。一个编译器的作者可以决定在哪儿放代码在哪儿放数据当然了别的作者也可能采用其他的策略。实际上C语言和Java虚拟机对内存的管理和使用策略就是不同的。
尽管如此大多数语言还是会采用一些通用的内存管理模式。以C语言为例会把内存划分为代码区、静态数据区、栈和堆。
<img src="https://static001.geekbang.org/resource/image/45/6a/452137a61a7b051ffceb40ae45199f6a.jpg" alt="">
一般来讲,代码区是在最低的地址区域,然后是静态数据区,然后是堆。而栈传统上是从高地址向低地址延伸,栈的最顶部有一块区域,用来保存环境变量。
**代码区(也叫文本段)存放编译完成以后的机器码。**这个内存区域是只读的,不会再修改,但也不绝对。现代语言的运行时已经越来越动态化,除了保存机器码,还可以存放中间代码,并且还可以在运行时把中间代码编译成机器码,写入代码区。
**静态数据区保存程序中全局的变量和常量。**它的地址在编译期就是确定的在生成的代码里直接使用这个地址就可以访问它们它们的生存期是从程序启动一直到程序结束。它又可以细分为Data和BSS两个段。Data段中的变量是在编译期就初始化好的直接从程序装在进内存。BSS段中是那些没有声明初始化值的变量都会被初始化成0。
**堆适合管理生存期较长的一些数据,这些数据在退出作用域以后也不会消失。**比如,我们在某个方法里创建了一个对象并返回,并希望代表这个对象的数据在退出函数后仍然可以访问。
**而栈适合保存生存期比较短的数据,比如函数和方法里的本地变量。**它们在进入某个作用域的时候申请内存,退出这个作用域的时候就可以释放掉。
讲完了CPU和内存之后我们再来看看跟程序打交道的操作系统。
#### 2.程序和操作系统的关系
程序跟操作系统的关系比较微妙:
<li>
一方面我们的程序可以编译成不需要操作系统也能运行,就像一些物联网应用那样,完全跑在裸设备上。
</li>
<li>
另一方面,有了操作系统的帮助,可以为程序提供便利,比如可以使用超过物理内存的存储空间,操作系统负责进行虚拟内存的管理。
</li>
在存在操作系统的情况下,因为很多进程共享计算机资源,所以就要遵循一些约定。这就仿佛办公室是所有同事共享的,那么大家就都要遵守一些约定,如果一个人大声喧哗,就会影响到其他人。
**程序需要遵守的约定包括:**程序文件的二进制格式约定,这样操作系统才能程序正确地加载进来,并为同一个程序的多个进程共享代码区。在使用寄存器和栈的时候也要遵守一些约定,便于操作系统在不同的进程之间切换的时候、在做系统调用的时候,做好上下文的保护。
所以我们编译程序的时候要知道需要遵守哪些约定。因为就算是使用同样的CPU针对不同的操作系统编译的结果也是非常不同的。
好了,我们了解了程序运行时的硬件和操作系统环境。接下来,我们看看程序运行时,是怎么跟它们互动的。
## 程序运行的过程
你天天运行程序,可对于程序运行的细节,真的清楚吗?
#### 1.程序运行的细节
首先可运行的程序一般是由操作系统加载到内存的并且定位到代码区里程序的入口开始执行。比如C语言的main函数的第一行代码。
每次加载一条代码程序都会顺序执行碰到跳转语句才会跳到另一个地址执行。CPU里有一个指令寄存器里面保存了下一条指令的地址。
<img src="https://static001.geekbang.org/resource/image/3b/f5/3bd535433e4aad9140bc0e114498def5.jpg" alt="">
假设我们运行这样一段代码编译后形成的程序:
```
int main(){
int a = 1;
foo(3);
bar();
}
int foo(int c){
int b = 2;
return b+c;
}
int bar(){
return foo(4) + 1;
}
```
我们首先激活Activatemain()函数main()函数又激活foo()函数然后又激活bar()函数bar()函数还会激活foo()函数其中foo()函数被两次以不同的路径激活。
<img src="https://static001.geekbang.org/resource/image/42/fa/4281fe310ee37428f91acb31d3a733fa.jpg" alt="">
我们把每次调用一个函数的过程叫做一次活动Activation。每个活动都对应一个活动记录Activation Record这个活动记录里有这个函数运行所需要的信息比如参数、返回值、本地变量等。
目前我们用栈来管理内存,所以可以把活动记录等价于栈桢。栈桢是活动记录的实现方式,我们可以自由设计活动记录或栈桢的结构,下图是一个常见的设计:
<img src="https://static001.geekbang.org/resource/image/a2/c3/a2ecc1e47c00e015558bacc83d3dd0c3.jpg" alt="">
<li>
返回值一般放在最顶上这样它的地址是固定的。foo()函数返回以后,它的调用者可以到这里来取到返回值。在实际情况中,我们会优先通过寄存器来传递返回值,比通过内存传递性能更高。
</li>
<li>
参数在调用foo函数时把参数写到这个地址里。同样我们也可以通过寄存器来传递而不是内存。
</li>
<li>
控制链接:就是上一级栈桢的地址。如果用到了上一级作用域中的变量,就可以顺着这个链接找到上一级栈桢,并找到变量的值。
</li>
<li>
返回地址foo函数执行完毕以后继续执行哪条指令。同样我们可以用寄存器来保存这个信息。
</li>
<li>
本地变量foo函数的本地变量b的存储空间。
</li>
<li>
寄存器信息我们还经常在栈桢里保存寄存器的数据。如果在foo函数里要使用某个寄存器可能需要先把它的值保存下来防止破坏了别的代码保存在这里的数据。**这种约定叫做被调用者责任,**也就是使用寄存器的人要保护好寄存器里原有的信息。某个函数如果使用了某个寄存器,但它又要调用别的函数,为了防止别的函数把自己放在寄存器中的数据覆盖掉,要自己保存在栈桢中。**这种约定叫做调用者责任。**
</li>
<img src="https://static001.geekbang.org/resource/image/d9/41/d95e2987786756a6ecb2d1f47df37841.jpg" alt="">
你可以看到,每个栈桢的长度是不一样的。
用到的参数和本地变量多,栈桢就要长一点。但是,栈桢的长度和结构是在编译期就能完全确定的。这样就便于我们计算地址的偏移量,获取栈桢里某个数据。
总的来说,栈桢的设计很自由。但是,你要考虑不同语言编译形成的模块要能够链接在一起,所以还是要遵守一些公共的约定的,否则,你写的函数,别人就没办法调用了。
在[08讲](https://time.geekbang.org/column/article/128623),我提到过栈桢,这次我们用了更加贴近具体实现的描述:栈桢就是一块确定的内存,变量就是这块内存里的地址。在下一讲,我会带你动手实现我们的栈桢。
#### 2.从全局角度看整个运行过程
了解了栈桢的实现之后,我们再来看一个更大的场景,从全局的角度看看整个运行过程中都发生了什么。
<img src="https://static001.geekbang.org/resource/image/31/2f/31ec7430fcb8bb2752151a38ed65672f.jpg" alt="">
代码区里存储了一些代码main函数、bar函数和foo函数各自有一段连续的区域来存储代码我用了一些汇编指令来表示这些代码实际运行时这里其实是机器码
假设我们执行到foo函数中的一段指令来计算“b+c”的值并返回。这里用到了mov、add、jmp这三个指令。mov是把某个值从一个地方拷贝到另一个地方add是往某个地方加一个值jmp是改变代码执行的顺序跳转到另一个地方去执行汇编命令的细节我们下节再讲你现在简单了解一下就行了
```
mov b的地址 寄存器1
add c的地址 寄存器1
mov 寄存器1 foo的返回值地址
jmp 返回地址 //或ret指令
```
执行完这几个指令以后foo的返回值位置就写入了6并跳转到bar函数中执行foo之后的代码。
这时foo的栈桢就没用了新的栈顶是bar的栈桢的顶部。理论上讲操作系统这时可以把foo的栈桢所占的内存收回了。比如可以映射到另一个程序的寻址空间让另一个程序使用。但是在这个例子中你会看到即使返回了bar函数我们仍要访问栈顶之外的一个内存地址也就是返回值的地址。
所以目前的调用约定都规定程序的栈顶之外仍然会有一小块内存比如128K是可以由程序访问的比如我们可以拿来存储返回值。这一小段内存操作系统并不会回收。
我们目前只讲了栈,堆的使用也类似,只不过是要手工进行申请和释放,比栈要多一些维护工作。
## 课程小结
本节课我带你了解了程序运行的环境和过程我们的程序主要跟CPU、内存以及操作系统打交道。你需要了解的重点如下
<li>
CPU上运行程序的指令运行过程中要用到寄存器、高速缓存来提高指令和数据的存取效率。
</li>
<li>
内存可以划分成不同的区域保存代码、静态数据,并用栈和堆来存放运行时产生的动态数据。
</li>
<li>
操作系统会把物理的内存映射成进程的寻址空间,同一份代码会被映射进多个进程的内存空间,操作系统的公共库也会被映射进进程的内存空间,操作系统还会自动维护栈。
</li>
程序在运行时顺序执行代码,可以根据跳转指令来跳转;栈被划分成栈桢,栈桢的设计有一定的自由度,但通常也要遵守一些约定;栈桢的大小和结构在编译时就能决定;在运行时,栈桢作为活动记录,不停地被动态创建和释放。
以上这些内容就是一个程序运行时的秘密。你再面对代码时脑海里就会想象出它是怎样跟CPU、内存和操作系统打交道的了。而且有了这些背景知识你也可以让编译器生成代码按照本节课所说的模式运行了
## 一课一思
本节课我概要地介绍了程序运行的环境和运行过程。常见的静态编译型的语言比如C语言、Go语言差不多都是这个模式。那么你是否了解你所采用的计算机语言的运行环境和运行过程跟本文描述的哪些地方相同哪些地方不同欢迎在留言区分享你的经验。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。

View File

@@ -0,0 +1,321 @@
<audio id="audio" title="22 | 生成汇编代码(一):汇编语言其实不难学" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bc/ca/bccec8d844433a8b64e1ea9896e52fca.mp3"></audio>
>
敲黑板课程用的是GNU汇编器macOS和Linux已内置本文的汇编语言的写法是GNU汇编器规定的写法。Windows系统可安装MinGW或Linux虚拟机。
对于静态编译型语言比如C语言和Go语言编译器后端的任务就是生成汇编代码然后再由汇编器生成机器码生成的文件叫目标文件最后再使用链接器就能生成可执行文件或库文件了。
<img src="https://static001.geekbang.org/resource/image/fe/76/feadbf7a473c420d0693c249b48e0e76.jpg" alt="">
就算像JavaScript这样的解释执行的语言也要在运行时利用类似的机制生成机器码以便调高执行的速度。Java的字节码在运行时通常也会通过JIT机制编译成机器码。**而汇编语言是完成这些工作的基础。**
对你来说掌握汇编语言是十分有益的因为哪怕掌握一小点儿汇编技能就能应用到某项工作中比如在C语言里嵌入汇编实现某个特殊功能或者读懂某些底层类库或驱动程序的代码因为它可能是用汇编写的。
本节课我先带你了解一下汇编语言来个破冰之旅。然后在接下来的课程中再带你基于AST手工生成汇编代码破除你对汇编代码的恐惧了解编译期后端生成汇编代码的原理。
以后当你看到高级语言的代码以及IR时就可以想象出来它对应的汇编代码是什么样子实现从上层到底层认知的贯通。
## 了解汇编语言
机器语言都是0101的二进制的数据不适合我们阅读。而汇编语言简单来说是可读性更好的机器语言基本上每条指令都可以直接翻译成一条机器码。
跟你日常使用的高级语言相比汇编语言的语法特别简单但它要跟硬件CPU和内存打交道我们来体会一下。
计算机的处理器有很多不同的架构比如x86-64、ARM、Power等每种处理器的指令集都不相同那也就意味着汇编语言不同。我们目前用的电脑CPU一般是x86-64架构是64位机。如不做特别说明本课程都是以x86-64架构作为例子的
说了半天汇编代码长什么样子呢我用C语言写的例子来生成一下汇编代码。
```
#include &lt;stdio.h&gt;
int main(int argc, char* argv[]){
printf(&quot;Hello %s!\n&quot;, &quot;Richard&quot;);
return 0;
}
```
在macOS中输入下面的命令其中的-S参数就是告诉编译器把源代码编译成汇编代码而-O2参数告诉编译器进行2级优化这样生成的汇编代码会短一些
```
clang -S -O2 hello.c -o hello.s
或者:
gcc -S -O2 hello.c -o hello.s
```
生成的汇编代码是下面的样子:
```
.section __TEXT,__text,regular,pure_instructions
.build_version macos, 10, 14 sdk_version 10, 14
.globl _main ## -- Begin function main
.p2align 4, 0x90
_main: ## @main
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
leaq L_.str(%rip), %rdi
leaq L_.str.1(%rip), %rsi
xorl %eax, %eax
callq _printf
xorl %eax, %eax
popq %rbp
retq
.cfi_endproc
## -- End function
.section __TEXT,__cstring,cstring_literals
L_.str: ## @.str
.asciz &quot;Hello %s!\n&quot;
L_.str.1: ## @.str.1
.asciz &quot;Richard&quot;
.subsections_via_symbols
```
你如果再打下面的命令就会把这段汇编代码编译成可执行文件在macOS或Linux执行as命令就是调用了GNU的汇编器
```
as hello.s -o hello.o //用汇编器编译成目标文件
gcc hello.o -o hello //链接成可执行文件
./hello //运行程序
```
以上面的代码为例,来看一下汇编语言的组成元素。**这是汇编语言入门的基础,也是重点内容,在阅读时,你不需要死记硬背,而是要灵活掌握,**比如CPU的指令特别多我们记住常用的就行了不太常用的可以去查手册。
#### 1.汇编语言的组成元素
这段代码里有**指令、伪指令、标签和注释**四种元素,每个元素单独占一行。
**指令instruction是直接由CPU进行处理的命令**例如:
```
pushq %rbp
movq %rsp, %rbp
```
其中开头的一个单词是助记符mnemonic后面跟着的是操作数operand有多个操作数时以逗号分隔。第二行代码的意思是把数据从这里拷贝到那里目的这跟“请倒杯咖啡给我”这样的自然语句是一样的先是动词然后是动词的作用对象咖啡再就是目的地给我
**伪指令以“.”开头,末尾没有冒号“:”。**
```
.section __TEXT,__text,regular,pure_instructions
.globl _main
.asciz &quot;Hello %s!\n&quot;
```
伪指令是是辅助性的汇编器在生成目标文件时会用到这些信息但伪指令不是真正的CPU指令就是写给汇编器的。每种汇编器的伪指令也不同要查阅相应的手册。
**标签以冒号“:”结尾,用于对伪指令生成的数据或指令做标记。**例如L_.str: 标签是对一个字符串做了标记。其他代码可以访问标签,例如跳转到这个标签所标记的指令。
```
L_.str: ## @.str
.asciz &quot;Hello %s!\n&quot;
```
标签很有用,它可以代表一段代码或者常量的地址(也就是在代码区或静态数据区中的位置)。可一开始,我们没法知道这个地址的具体值,必须生成目标文件后,才能算出来。所以,标签会简化汇编代码的编写。
**第四种元素,注释,以“#”号开头这跟C语言中以//表示注释语句是一样的。**
因为指令是汇编代码的主要部分,所以我们再把与指令有关的知识点展开讲解一下。
#### 2.详细了解指令这个元素
在代码中助记符“movq”“xorl”中的“mov”和“xor”是指令而“q”和“l”叫做后缀表示操作数的位数。后缀一共有b, w, l, q四种分别代表8位、16位、32位和64位。
<img src="https://static001.geekbang.org/resource/image/83/4b/83e27f35ac31ae773e52e8826e6e534b.jpg" alt="">
比如movq中的q代表操作数是8个字节也就是64位的。movq就是把8字节从一个地方拷贝到另一个地方而movl则是拷贝4个字节。
而在指令中使用操作数,可以使用四种格式,它们分别是:**立即数、寄存器、直接内存访问和间接内存访问。**
**立即数以$开头,** **比如$40**下面这行代码是把40这个数字拷贝到%eax寄存器
```
movl $40, %eax
```
除此之外我们在指令中最常见到的就是对寄存器的访问GNU的汇编器规定寄存器一定要以%开头。
**直接内存访问:**当我们在代码中看到操作数是一个数字时,它其实指的是内存地址。不要误以为它是一个数字,因为数字立即数必须以$开头。另外汇编代码里的标签也会被翻译成直接内存访问的地址。比如“callq _printf”中的“_printf”是一个函数入口的地址。汇编器帮我们计算出程序装载在内存时每个字面量和过程的地址。
**间接内存访问:**带有括号,比如(%rbp它是指%rbp寄存器的值所指向的地址。
间接内存访问的完整形式是:
>
偏移量(基址,索引值,字节数)这样的格式。
其地址是:
>
基址 + 索引值*字节数 + 偏移量
举例来说:
>
<p>8(%rbp),是比%rbp寄存器的值加8。<br>
-8(%rbp),是比%rbp寄存器的值减8。<br>
%rbp, %eax, 4的值等于%rbp + %eax*4。这个地址格式相当于访问C语言中的数组中的元素数组元素是32位的整数其索引值是%eax而数组的起始位置是%rbp。其中字节数只能取1,2,4,8四个值。</p>
你现在应该对指令的格式有所了解了,接下来,我们再学几个常用的指令:
**mov指令**
```
mov 寄存器|内存|立即数, 寄存器|内存
```
这个指令最常用到用于在寄存器或内存之间传递数据或者把立即数加载到内存或寄存器。mov指令的第一个参数是源可以是寄存器、内存或立即数。第二个参数是目的地可以是寄存器或内存。
**lea指令lea是“load effective address”的意思装载有效地址。**
```
lea 源,目的
```
比如前面例子代码中的leaq指令是把字符串的地址加载到%rdi寄存器。
```
leaq L_.str(%rip), %rdi
```
**add指令是做加法运算它可以采取下面的格式**
```
add 立即数, 寄存器
add 寄存器, 寄存器
add 内存, 寄存器
add 立即数, 内存
add 寄存器, 内存
```
比如典型的c=a+b这样一个算术运算可能是这样的
```
movl -4(%rbp), %eax #把%rbp-4的值拷贝到%eax
addl -8(%rbp), %eax #把%rbp-8地址的值加到%eax上
movl %eax, -12(%rbp) #把%eax的值写到内存地址%rbp-12
```
这三行代码分别是操作a、b、c三个变量的地址。它们的地址分别比%rbp的值减4、减8、减12因此a、b、c三个变量每个都是4个字节长也就是32位它们是紧挨着存放的并且是从高地址向低地址延伸的这是栈的特征。
**除了add以外其他算术运算的指令**
<img src="https://static001.geekbang.org/resource/image/5b/5e/5b945cfd9287417e801819a22f5a8b5e.jpg" alt="">
**与栈有关的操作:**
<img src="https://static001.geekbang.org/resource/image/72/df/72dd44d44e416cf59bc3bb40efdb99df.jpg" alt="">
**跳转类:**
<img src="https://static001.geekbang.org/resource/image/81/58/814115093a062cfcde9054d4bd957858.jpg" alt="">
**过程调用:**
<img src="https://static001.geekbang.org/resource/image/71/15/71014fa3d6f218ba4cd0d65ae8966615.jpg" alt="">
**比较操作:**
<img src="https://static001.geekbang.org/resource/image/48/f0/48ed198159b91b09a317493870faecf0.jpg" alt="">
以上我列举的指令,是你在编写汇编代码时,经常会用到的,比较重要,会满足你编写简单汇编程序的需求,所以你需要重点关注。
x86-64是复杂指令集的处理器有非常多的指令差不多有上千条全部记住是比较难的。更好的办法是记住主要的指令其他指令在使用时去查[Intel公司的手册](https://software.intel.com/en-us/download/intel-64-and-ia-32-architectures-sdm-combined-volumes-1-2a-2b-2c-2d-3a-3b-3c-3d-and-4),在这里我就不举例了。
## x86-64架构的寄存器
在汇编代码中,我们经常要使用各种以%开头的寄存器的符号。初学者阅读这些代码时,通常会有一些疑问:有几个寄存器可以用?我可以随便用它们吗?使用不当会不会造成错误?等等。所以,有必要让你熟悉一下这些寄存器,了解它们的使用方法。
x86-64架构的CPU里有很多寄存器我们在代码里最常用的是16个64位的通用寄存器分别是
>
%rax%rbx%rcx%rdx%rsi%rdi%rbp%rsp %r8%r9%r10%r11%r12%r13%r14%r15。
这些寄存器在历史上有各自的用途比如rax中的“a”是Accumulator(累加器)的意思,这个寄存器是累加寄存器。
但随着技术的发展这些寄存器基本上都成为了通用的寄存器不限于某种特定的用途。但是为了方便软件的编写我们还是做了一些约定给这些寄存器划分了用途。针对x86-64架构有多个调用约定Calling Convention包括微软的x64调用约定Windows系统、System V AMD64 ABIUnix和Linux系统下面的内容属于后者
<li>
%rax 除了其他用途外,通常在函数返回的时候,把返回值放在这里。
</li>
<li>
%rsp 作为栈指针寄存器,指向栈顶。
</li>
<li>
%rdi%rsi%rdx%rcx%r8%r9 给函数传整型参数依次对应第1参数到第6参数。超过6个参数怎么办放在栈桢里我们[21讲](https://time.geekbang.org/column/article/146635)已经讲过了。
</li>
<li>
如果程序要使用%rbx%rbp%r12%r13%r14%r15 这几个寄存器是由被调用者Callee负责保护的也就是写到栈里在返回的时候要恢复这些寄存器中原来的内容。其他寄存器的内容则是由调用者Caller负责保护如果不想这些寄存器中的内容被破坏那么要自己保护起来。
</li>
上面这些寄存器的名字都是64位的名字对于每个寄存器我们还可以只使用它的一部分并且另起一个名字。比如对于%rax如果使用它的前32位就叫做%eax前16位叫%ax前8位0到7位叫%al8到15位叫%ah。
<img src="https://static001.geekbang.org/resource/image/db/1a/dbde233c28b9f92b38286abb49c1411a.jpg" alt="">
其他的寄存器也有这样的使用方式,当你在汇编代码中看到这些名称的时候,你就知道其实它们有可能在物理上是同一个寄存器。
<img src="https://static001.geekbang.org/resource/image/b9/3d/b9bec8ec5536a5d1fc346e79b0357a3d.jpg" alt="">
除了通用寄存器以外,有可能的话,还要了解下面的寄存器和它们的用途,我们写汇编代码时也经常跟它们发生关联:
<li>
8个80位的x87寄存器用于做浮点计算
</li>
<li>
8个64位的MMX寄存器用于MMX指令即多媒体指令这8个跟x87寄存器在物理上是相同的寄存器。在传递浮点数参数的时候要用mmx寄存器。
</li>
<li>
16个128位的SSE寄存器用于SSE指令。我们将在应用篇里使用SSE指令讲解SIMD的概念。
</li>
<li>
指令寄存器rip保存指令地址。CPU总是根据这个寄存器来读取指令。
</li>
<li>
flags64位rflags, 32位eflags寄存器每个位用来标识一个状态。比如它们会用于比较和跳转的指令比如if语句翻译成的汇编代码就会用它们来保存if条件的计算结果。
</li>
总的来说,我们的汇编代码处处要跟寄存器打交道,正确和高效使用寄存器,是编译期后端的重要任务之一。
## 课程小结
本节课,我讲解了汇编语言的一些基础知识,由于汇编语言的特点,涉及的知识点和细节比较多,在这个过程中,你无需死记硬背,只需要掌握几个重点内容:
1.汇编语言是由指令、标签、伪指令和注释构成的。其中主要内容都是指令。指令包含一个该指令的助记符和操作数。操作数可以使用直接数、寄存器,以及用两种方式访问内存地址。
2.汇编指令中会用到一些通用寄存器。这些寄存器除了用于计算以外,还可以根据调用约定帮助传递参数和返回值。使用寄存器时,要区分由调用者还是被调用者负责保护寄存器中原来的内容。
另外,我们还要注意按照一定的规则维护和使用栈桢,**这个知识点会在后面的加餐中展开来讲一个例子。**
鉴于你可能是第一次使用汇编语言,所以我**提供两个建议,让你快速上手汇编语言:**
1.你可以用C语言写一些示例代码然后用编译器生成汇编代码看看能否看懂。
2.模仿文稿中的例子,自己改写并运行你自己的汇编程序,这个过程中,你会发现真的没那么难。
## 一课一思
你之前学习过或者在项目中使用过汇编语言吗?感受是什么呢?有什么经验和体会呢?欢迎在留言区分享你的经验与感受。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。

View File

@@ -0,0 +1,450 @@
<audio id="audio" title="23 | 生成汇编代码(二):把脚本编译成可执行文件" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3f/07/3fbda6bc711a84d6785530dd81f18207.mp3"></audio>
学完两节课之后,对于后端编译过程,你可能还会产生一些疑问,比如:
1.大致知道汇编程序怎么写却不知道如何从AST生成汇编代码中间有什么挑战。
2.编译成汇编代码之后需要做什么,才能生成可执行文件。
本节课我会带你真正动手基于AST把playscript翻译成正确的汇编代码并将汇编代码编译成可执行程序。
通过这样一个过程,可以实现从编译器前端到后端的完整贯通,帮你对编译器后端工作建立比较清晰的认识。这样一来,你在日常工作中进行大型项目的编译管理的时候,或者需要重用别人的类库的时候,思路会更加清晰。
## 从playscript生成汇编代码
**先来看看如何从playscript生成汇编代码。**
我会带你把playscript的几个有代表性的功能而不是全部的功能翻译成汇编代码一来工作量少一些二来方便做代码优化。这几个有代表性的功能如下
1.支持函数调用和传参(这个功能可以回顾加餐)。
2.支持整数的加法运算(在这个过程中要充分利用寄存器提高性能)。
3.支持变量声明和初始化。
具体来说,要能够把下面的示例程序正确生成汇编代码:
```
//asm.play
int fun1(int x1, int x2, int x3, int x4, int x5, int x6, int x7, int x8){
int c = 10;
return x1 + x2 + x3 + x4 + x5 + x6 + x7 + x8 + c;
}
println(&quot;fun1:&quot; + fun1(1,2,3,4,5,6,7,8));
```
在加餐中我提供了一段手写的汇编代码功能等价于这段playscript代码并讲述了如何在多于6个参数的情况下传参观察栈帧的变化过程你可以看看下面的图片和代码回忆一下
<img src="https://static001.geekbang.org/resource/image/45/89/45587ab64c83ea52f9d1fd3fedc6b189.jpg" alt="">
```
# function-call2-craft.s 函数调用和参数传递
# 文本段,纯代码
.section __TEXT,__text,regular,pure_instructions
_fun1:
# 函数调用的序曲,设置栈指针
pushq %rbp # 把调用者的栈帧底部地址保存起来
movq %rsp, %rbp # 把调用者的栈帧顶部地址,设置为本栈帧的底部
movl $10, -4(%rbp) # 变量c赋值为10,也可以写成 movl $10, (%rsp)
# 做加法
movl %edi, %eax # 第一个参数放进%eax
addl %esi, %eax # 加参数2
addl %edx, %eax # 加参数3
addl %ecx, %eax # 加参数4
addl %r8d, %eax # 加参数5
addl %r9d, %eax # 加参数6
addl 16(%rbp), %eax # 加参数7
addl 24(%rbp), %eax # 加参数8
addl -4(%rbp), %eax # 加上c的值
# 函数调用的尾声,恢复栈指针为原来的值
popq %rbp # 恢复调用者栈帧的底部数值
retq # 返回
.globl _main # .global伪指令让_main函数外部可见
_main: ## @main
# 函数调用的序曲,设置栈指针
pushq %rbp # 把调用者的栈帧底部地址保存起来
movq %rsp, %rbp # 把调用者的栈帧顶部地址,设置为本栈帧的底部
subq $16, %rsp # 这里是为了让栈帧16字节对齐实际使用可以更少
# 设置参数
movl $1, %edi # 参数1
movl $2, %esi # 参数2
movl $3, %edx # 参数3
movl $4, %ecx # 参数4
movl $5, %r8d # 参数5
movl $6, %r9d # 参数6
movl $7, (%rsp) # 参数7
movl $8, 8(%rsp) # 参数8
callq _fun1 # 调用函数
# 为pritf设置参数
leaq L_.str(%rip), %rdi # 第一个参数是字符串的地址
movl %eax, %esi # 第二个参数是前一个参数的返回值
callq _printf # 调用函数
# 设置返回值。这句也常用 xorl %esi, %esi 这样的指令,都是置为零
movl $0, %eax
addq $16, %rsp # 缩小栈
# 函数调用的尾声,恢复栈指针为原来的值
popq %rbp # 恢复调用者栈帧的底部数值
retq # 返回
# 文本段,保存字符串字面量
.section __TEXT,__cstring,cstring_literals
L_.str: ## @.str
.asciz &quot;fun1 :%d \n&quot;
```
接下来我们动手写程序从AST翻译成汇编代码相关代码在playscript-java项目的[AsmGen.java](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/AsmGen.java)类里)。
**我们实现加法运算的翻译过程如下:**
```
case PlayScriptParser.ADD:
//为加法运算申请一个临时的存储位置,可以是寄存器和栈
address = allocForExpression(ctx);
bodyAsm.append(&quot;\tmovl\t&quot;).append(left).append(&quot;, &quot;).append(address).append(&quot;\n&quot;); //把左边节点拷贝到存储空间
bodyAsm.append(&quot;\taddl\t&quot;).append(right).append(&quot;, &quot;).append(address).append(&quot;\n&quot;); //把右边节点加上去
break;
```
**这段代码的含义是:**我们通过allocForExpression()方法为每次加法运算申请一个临时空间可以是寄存器也可以是栈里的一个地址用来存放加法操作的结果。接着用mov指令把加号左边的值拷贝到这个临时空间再用add指令加上右边的值。
生成汇编代码的过程基本上就是基于AST拼接字符串其中bodyAsm变量是一个StringBuffer对象我们可以用StringBuffer的toString()方法获得最后的汇编代码。
按照上面的逻辑针对“x1 + x2 + x3 + x4 + x5 + x6 + x7 + x8 + c”这个表达式形成的汇编代码如下
```
# 过程体
movl $10, -4(%rbp)
movl %edi, %eax //x1
addl %esi, %eax //+x2
movl %eax, %ebx
addl %edx, %ebx //+x3
movl %ebx, %r10d
addl %ecx, %r10d //+x4
movl %r10d, %r11d
addl %r8d, %r11d //+x5
movl %r11d, %r12d
addl %r9d, %r12d //+x6
movl %r12d, %r13d
addl 16(%rbp), %r13d //+x7
movl %r13d, %r14d
addl 24(%rbp), %r14d //+x8
movl %r14d, %r15d
addl -4(%rbp), %r15d //+c本地变量
```
**看出这个代码有什么问题了吗?**我们每次执行加法运算的时候都要占用一个新的寄存器。比如x1+x2使用了%eax再加x3时使用了%ebx按照这样的速度寄存器很快就用完了使用效率显然不高。所以必须要做代码优化。
如果只是简单机械地翻译代码,相当于产生了大量的临时变量,每个临时变量都占用了空间:
```
t1 := x1 + x2;
t2 := t1 + x3;
t3 := t2 + x4;
...
```
**进行代码优化**可以让不再使用的存储位置t1t2t3…能够复用从而减少临时变量也减少代码行数[优化后的申请临时存储空间的方法](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/AsmGen.java#L164)如下:
```
//复用前序表达式的存储位置
if (ctx.bop != null &amp;&amp; ctx.expression().size() &gt;= 2) {
ExpressionContext left = ctx.expression(0);
String leftAddress = tempVars.get(left);
if (leftAddress!= null){
tempVars.put(ctx, leftAddress); //当前节点也跟这个地址关联起来
return leftAddress;
}
}
```
**这段代码的意思是:**对于每次加法运算,都要申请一个寄存器,如果加号左边的节点已经在某个寄存器中,那就直接复用这个寄存器,就不要用新的了。
**调整以后,生成的汇编代码就跟手写的一样了。**而且,我们至始至终只用了%eax一个寄存器代码数量也减少了一半优化效果明显
```
# 过程体
movl $10, -4(%rbp)
movl %edi, %eax
addl %esi, %eax
addl %edx, %eax
addl %ecx, %eax
addl %r8d, %eax
addl %r9d, %eax
addl 16(%rbp), %eax
addl 24(%rbp), %eax
addl -4(%rbp), %eax
# 返回值
# 返回值在之前的计算中,已经存入%eax
```
**对代码如何使用寄存器进行充分优化,是编译器后端一项必须要做的工作。**这里只用了很粗糙的方法,不具备实用价值,后面可以学习更好的优化算法。
弄清楚了加法运算的代码翻译逻辑我们再看看AsmGen.java中的[generate()](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/AsmGen.java#L71)方法和[generateProcedure()](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/AsmGen.java#L107)方法,看看汇编代码完整的生成逻辑是怎样的。这样可以帮助你弄清楚整体脉络和所有的细节,比如函数的标签是怎么生成的,序曲和尾声是怎么加上去的,本地变量的地址是如何计算的,等等。
```
public String generate() {
StringBuffer sb = new StringBuffer();
// 1.代码段的头
sb.append(&quot;\t.section __TEXT,__text,regular,pure_instructions\n&quot;);
// 2.生成函数的代码
for (Type type : at.types) {
if (type instanceof Function) {
Function function = (Function) type;
FunctionDeclarationContext fdc = (FunctionDeclarationContext) function.ctx;
visitFunctionDeclaration(fdc); // 遍历代码生成到bodyAsm中了
generateProcedure(function.name, sb);
}
}
// 3.对主程序生成_main函数
visitProg((ProgContext) at.ast);
generateProcedure(&quot;main&quot;, sb);
// 4.文本字面量
sb.append(&quot;\n# 字符串字面量\n&quot;);
sb.append(&quot;\t.section __TEXT,__cstring,cstring_literals\n&quot;);
for(int i = 0; i&lt; stringLiterals.size(); i++){
sb.append(&quot;L.str.&quot; + i + &quot;:\n&quot;);
sb.append(&quot;\t.asciz\t\&quot;&quot;).append(stringLiterals.get(i)).append(&quot;\&quot;\n&quot;);
}
// 5.重置全局的一些临时变量
stringLiterals.clear();
return sb.toString();
}
```
**generate()方法是整个翻译程序的入口,它做了几项工作:**
1.生成一个.section伪指令表明这是一个放文本的代码段。
2.遍历AST中的所有函数调用generateProcedure()方法为每个函数生成一段汇编代码,再接着生成一个主程序的入口。
3.在一个新的section中声明一些全局的常量字面量。整个程序的结构跟最后生成的汇编代码的结构是一致的所以很容易看懂。
**generateProcedure()方法把函数转换成汇编代码,里面的注释也很清晰,开头的工作包括:**
1.生成函数标签、序曲部分的代码、设置栈顶指针、保护寄存器原有的值等。
2.接着是函数体,比如本地变量初始化、做加法运算等。
3.最后是一系列收尾工作,包括恢复被保护的寄存器的值、恢复栈顶指针,以及尾声部分的代码。
我们之前已经理解了一个函数体中的汇编代码的结构,所以看这段翻译代码肯定不费事儿。
```
private void generateProcedure(String name, StringBuffer sb) {
// 1.函数标签
sb.append(&quot;\n## 过程:&quot;).append(name).append(&quot;\n&quot;);
sb.append(&quot;\t.globl _&quot;).append(name).append(&quot;\n&quot;);
sb.append(&quot;_&quot;).append(name).append(&quot;:\n&quot;);
// 2.序曲
sb.append(&quot;\n\t# 序曲\n&quot;);
sb.append(&quot;\tpushq\t%rbp\n&quot;);
sb.append(&quot;\tmovq\t%rsp, %rbp\n&quot;);
// 3.设置栈顶
// 16字节对齐
if ((rspOffset % 16) != 0) {
rspOffset = (rspOffset / 16 + 1) * 16;
}
sb.append(&quot;\n\t# 设置栈顶\n&quot;);
sb.append(&quot;\tsubq\t$&quot;).append(rspOffset).append(&quot;, %rsp\n&quot;);
// 4.保存用到的寄存器的值
saveRegisters();
// 5.函数体
sb.append(&quot;\n\t# 过程体\n&quot;);
sb.append(bodyAsm);
// 6.恢复受保护的寄存器的值
restoreRegisters();
// 7.恢复栈顶
sb.append(&quot;\n\t# 恢复栈顶\n&quot;);
sb.append(&quot;\taddq\t$&quot;).append(rspOffset).append(&quot;, %rsp\n&quot;);
// 8.如果是main函数设置返回值为0
if (name.equals(&quot;main&quot;)) {
sb.append(&quot;\n\t# 返回值\n&quot;);
sb.append(&quot;\txorl\t%eax, %eax\n&quot;);
}
// 9.尾声
sb.append(&quot;\n\t# 尾声\n&quot;);
sb.append(&quot;\tpopq\t%rbp\n&quot;);
sb.append(&quot;\tretq\n&quot;);
// 10.重置临时变量
rspOffset = 0;
localVars.clear();
tempVars.clear();
bodyAsm = new StringBuffer();
}
```
最后,你可以通过-S参数运行playscript-java将asm.play文件生成汇编代码文件asm.s再生成和运行可执行文件
```
java play.PlayScript -S asm.play -o asm.s //生成汇编代码
gcc asm.s -o asm //生成可执行文件
./asm //运行可执行文件
```
另外我们的翻译程序只实现了少量的特性加法运算、本地变量、函数……。我建议基于这个代码框架做修改增加其他特性比如减法、乘法和除法支持浮点数支持if语句和循环语句等。学过加餐之后你应该清楚如何生成这样的汇编代码了。
到目前为止我们已经成功地编译playscript程序并生成了可执行文件为了加深你对生成可执行文件的理解我们再做个挑战用playscript生成目标文件让C语言来调用。这样可以证明playscript生成汇编代码的逻辑是靠谱的以至于可以用playscript代替C语言来写一个共用模块。
## 通过C语言调用playscript模块
我们在编程的时候经常调用一些公共的库实现一些功能这些库可能是别的语言写的但我们仍然可以调用。我们也可以实现playscript与其他语言的功能共享在示例程序中实现很简单微调一下生成的汇编代码使用“.global _fun1”伪指令让_fun1过程变成全局的这样其他语言写的程序就可以调用这个_fun1过程实现功能的重用。
```
# convention-fun1.s 测试调用约定_fun1将在外部被调用
# 文本段,纯代码
.section __TEXT,__text,regular,pure_instructions
.globl _fun1 # .global伪指令让_fun1函数外部可见
_fun1:
# 函数调用的序曲,设置栈指针
pushq %rbp # 把调用者的栈帧底部地址保存起来
movq %rsp, %rbp # 把调用者的栈帧顶部地址,设置为本栈帧的底部
movl $10, -4(%rbp) # 变量c赋值为10,也可以写成 movl $10, (%rsp)
# 做加法
movl %edi, %eax # 第一个参数放进%eax
addl %esi, %eax # 加参数2
addl %edx, %eax # 加参数3
addl %ecx, %eax # 加参数4
addl %r8d, %eax # 加参数5
addl %r9d, %eax # 加参数6
addl 16(%rbp), %eax # 加参数7
addl 24(%rbp), %eax # 加参数8
addl -4(%rbp), %eax # 加上c的值
# 函数调用的尾声,恢复栈指针为原来的值
popq %rbp # 恢复调用者栈帧的底部数值
retq # 返回
```
接下来再写一个C语言的函数来调用fun1()其中的extern关键字说明有一个fun1()函数是在另一个模块里实现的:
```
/**
* convention-main.c 测试调用约定。调用一个外部函数fun1
*/
#include &lt;stdio.h&gt;
//声明一个外部函数,在链接时会在其他模块中找到
extern int fun1(int x1, int x2, int x3, int x4, int x5, int x6, int x7, int x8);
int main(int argc, char *argv[])
{
printf(&quot;fun1: %d \n&quot;, fun1(1,2,3,4,5,6,7,8));
return 0;
}
```
然后在命令行敲下面两个命令:
```
# 编译汇编程序
as convention-fun1.s -o convention-fun1.o
# 编译C程序
gcc convention-main.c convention-fun1.o -o convention
```
<li>
第一个命令把playscript生成的汇编代码编译成一个二进制目标文件。
</li>
<li>
<p>第二个命令在编译C程序的时候同时也带上这个二进制文件那么编译器就会找到fun1()函数的定义,并链接到一起。<br>
最后生成的可执行文件能够顺利运行。</p>
</li>
**这里面,我需要解释一下链接过程,**它有助于你在二进制文件层面上加深对编译过程的理解。
其实,高级语言和汇编语言都容易阅读。而二进制文件,则是对计算机友好的,便于运行。汇编器可以把每一个汇编文件都编译生成一个二进制的目标文件,或者叫做一个模块。而链接器则把这些模块组装成一个整体。
但在C语言生成的那个模块中调用fun1()函数时它没有办法知道fun1()函数的准确地址,因为这个地址必须是整个文件都组装完毕以后才能计算出来。所以,汇编器把这个任务推迟,交给链接器去解决。
<img src="https://static001.geekbang.org/resource/image/71/3b/71d5ff8c02eb1f0c98fc55862e4ca63b.jpg" alt="">
**这就好比你去饭店排队吃饭,首先要拿个号(函数的标签),但不知道具体坐哪桌。等叫到你的号的时候(链接过程),服务员才会给你安排一个确定的桌子(函数的地址)。**
既然我们已经从文本世界进入了二进制的世界,那么我们可以再加深一下对可执行文件结构的理解。
## 理解可执行文件
我们编译一个程序,最后的结果是生成可运行的二进制文件。其实,生成汇编代码以后,我们就可以认为编译器的任务完成了。后面的工作,其实是由汇编器和链接器完成的。但我们也可以把整个过程都看做编译过程,了解二进制文件的结构,也为我们完整地了解整个编译过程划上了句号。
当然了,对二进制文件格式的理解,也是做**大型项目编译管理、二进制代码分析等工作的基础,**很有意义。
对于每个操作系统我们对于可执行程序的格式要求是不一样的。比如在Linux下目标文件、共享对象文件、二进制文件都是采用ELF格式。
实际上,这些二进制文件的格式跟加载到内存中的程序的格式是很相似的。这样有什么好处呢?它可以迅速被操作系统读取,并加载到内存中去,加载速度越快,也就相当于程序的启动速度越快。
同内存中的布局一样在ELF格式中代码和数据也是分开的。这样做的好处是程序的代码部分可以在多个进程中共享不需要在内存里放多份。放一份然后映射到每个进程的代码区就行了。而数据部分则是每个进程都不一样的所以要为每个进程加载一份。
这样讲的话,**你就理解了可执行文件、目标文件等二进制文件的原理了,**具体的细节,可以查阅相关的文档和手册。
## 课程小结
这节课我们实现了从AST到汇编代码汇编代码到可执行文件的完整过程。现在你应该对后端工作的实质建立起了直接的认识。我建议你抓住几个关键点
首先从AST生成汇编代码可以通过比较机械的翻译来完成我们举了加法运算的例子。阅读示例程序你也可以看看函数调用、参数传递等等的实现过程。总体来说这个过程并不难。
第二,这种机械地翻译生成的代码,一定是不够优化的。我们已经看到了加法运算不够优化的情况,所以一定要增加一个优化的过程。
第三,在生成汇编的过程中,最需要注意的就是要遵守调用约定。这就需要了解调用约定的很多细节。只要遵守调用约定,不同语言生成的二进制目标文件也可以链接在一起,形成最后的可执行文件。
现在我已经带你完成了编译器后端的第一轮认知迭代,并且直接操刀汇编代码,破除你对汇编的恐惧心。在之后的课程中,我们会进入第二轮迭代:中间代码和代码优化。
## 一课一思
我们针对加法计算、函数调用等语法生成了汇编代码。你能否思考一下,如果要支持其他运算和语法,比如乘法运算、条件判断、循环语句等,大概会怎样实现?如果要支持面向对象编程,又该怎样实现呢?欢迎你打开思路,在留言区分享自己的想法。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
示例代码我放在文末,供你参考。
- AsmGen.java将AST翻译成汇编代码 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/AsmGen.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/AsmGen.java)
- asm.play用于生成汇编码的playscript脚本 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/examples/asm.play) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/examples/asm.play)

View File

@@ -0,0 +1,410 @@
<audio id="audio" title="24 | 中间代码:兼容不同的语言和硬件" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9f/09/9f3b7c6de4ad431ce4e85c1119f35e09.mp3"></audio>
前几节课我带你尝试不通过IR直接生成汇编代码这是为了帮你快速破冰建立直觉。在这个过程中你也遇到了一些挑战比如
<li>
你要对生成的代码进行优化,才有可能更好地使用寄存器和内存,同时也能减少代码量;
</li>
<li>
另外针对不同的CPU和操作系统你需要调整生成汇编代码的逻辑。
</li>
这些实际体验,都进一步验证了[20讲](https://time.geekbang.org/column/article/145472)中IR的作用我们能基于IR对接不同语言的前端也能对接不同的硬件架构还能做很多的优化。
既然IR有这些作用那你可能会问**IR都是什么样子的呢有什么特点如何生成IR呢**
本节课我就带你了解IR的特点认识常见的三地址代码学会如何把高级语言的代码翻译成IR。然后我还会特别介绍LLVM的IR以便后面使用LLVM这个工具。
首先来看看IR的特征。
## 介于中间的语言
IR的意思是中间表达方式它在高级语言和汇编语言的中间这意味着它的特征也是处于二者之间的。
与高级语言相比IR丢弃了大部分高级语言的语法特征和语义特征比如循环语句、if语句、作用域、面向对象等等它更像高层次的汇编语言而相比真正的汇编语言它又不会有那么多琐碎的、与具体硬件相关的细节。
相信你在学习汇编语言的时候,会发现汇编语言的细节特别多。比如,你要知道很多指令的名字和用法,还要记住很多不同的寄存器。[在22讲](https://time.geekbang.org/column/article/147854)我提到如果你想完整地掌握x86-64架构还需要接触很多指令集以及调用约定的细节、内存使用的细节等等[参见Intel的手册](https://software.intel.com/en-us/download/intel-64-and-ia-32-architectures-sdm-combined-volumes-1-2a-2b-2c-2d-3a-3b-3c-3d-and-4))。
仅仅拿指令的数量来说据有人统计Intel指令的助记符有981个之多都记住怎么可能啊。**所以说,汇编语言并不难,而是麻烦。**
IR不会像x86-64汇编语言那么繁琐但它却包含了足够的细节信息能方便我们实现优化算法以及生成针对目标机器的汇编代码。
另外我在20讲提到IR有很多种类AST也是一种IR每种IR都有不同的特点和用途有的编译器甚至要用到几种不同的IR。
我们在后端部分所讲的IR目的是方便执行各种优化算法并有利于生成汇编。**这种IR可以看做是一种高层次的汇编语言主要体现在**
- 它可以使用寄存器,但寄存器的数量没有限制;
- 控制结构也跟汇编语言比较像,比如有跳转语句,分成多个程序块,用标签来标识程序块等;
- 使用相当于汇编指令的操作码。这些操作码可以一对一地翻译成汇编代码,但有时一个操作码会对应多个汇编指令。
下面来看看一个典型IR三地址代码简称TAC。
## 认识典型的IR三地址代码TAC
下面是一种常见的IR的格式它叫做三地址代码Three Address Code, TAC它的优点是很简洁所以适合用来讨论算法
```
x := y op z //二元操作
x := op y //一元操作
```
每条三地址代码最多有三个地址其中两个是源地址比如第一行代码的y和z一个是目的地址也就是x每条代码最多有一个操作op
我来举几个例子,带你熟悉一下三地址代码,**这样,你能掌握三地址代码的特点,从高级语言的代码转换生成三地址代码。**
**1.基本的算术运算:**
```
int a, b, c, d;
a = b + c * d;
```
TAC
```
t1 := c * d
a := b + t1
```
t1是新产生的临时变量。当源代码的表达式中包含一个以上的操作符时就需要引入临时变量并把原来的一条代码拆成多条代码。
**2.布尔值的计算:**
```
int a, b;
bool x, y;
x = a * 2 &lt; b;
y = a + 3 == b;
```
TAC
```
t1 := a * 2;
x := t1 &lt; b;
t2 := a + 3;
y := t2 == b;
```
布尔值实际上是用整数表示的0代表false非0值代表true。
**3.条件语句:**
```
int a, b c;
if (a &lt; b )
c = b;
else
c = a;
c = c * 2;
```
TAC
```
t1 := a &lt; b;
IfZ t1 Goto L1;
c := a;
Goto L2;
L1:
c := b;
L2:
c := c * 2;
```
IfZ是检查后面的操作数是否是0“Z”就是“Zero”的意思。这里使用了标签和Goto语句来进行指令的跳转Goto相当于x86-64的汇编指令jmp
**4.循环语句:**
```
int a, b;
while (a &lt; b){
a = a + 1;
}
a = a + b;
```
TAC
```
L1:
t1 := a &lt; b;
IfZ t1 Goto L2;
a := a + 1;
Goto L1;
L2:
a := a + b;
```
三地址代码的规则相当简单我们可以通过比较简单的转换规则就能从AST生成TAC。
在课程中,三地址代码主要用来描述优化算法,因为它比较简洁易读,操作(指令)的类型很少,书写方式也符合我们的日常习惯。**不过,我并不用它来生成汇编代码,因为它含有的细节信息还是比较少,**比如整数是16位的、32位的还是64位的目标机器的架构和操作系统是什么生成二进制文件的布局是怎样的等等
**我会用LLVM的IR来承担生成汇编的任务**因为它有能力描述与目标机器CPU、操作系统相关的更加具体的信息准确地生成目标代码从而真正能够用于生产环境。
**在讲这个问题之前我想先延伸一下讲讲另外几种IR的格式**主要想帮你开拓思维如果你的项目需求恰好能用这种IR实现到时不妨拿来用一下
<li>
首先是四元式。它是与三地址代码等价的另一种表达方式格式是OParg1arg2result所以“a := b + c” 就等价于(+bca
</li>
<li>
另一种常用的格式是逆波兰表达式。它把操作符放到后面所以也叫做后缀表达式。“b + c”对应的逆波兰表达式是“b c +”而“a = b + c”对应的逆波兰表达式是“a b c + =”。
</li>
**逆波兰表达式特别适合用栈来做计算。**比如计算“b c +”先从栈里弹出加号知道要做加法操作然后从栈里弹出两个操作数执行加法运算即可。这个计算过程跟深度优先的遍历AST是等价的。所以采用逆波兰表达式有可能让你用一个很简单的方式就实现公式计算功能**如果你编写带有公式功能的软件时可以考虑使用它。**而且从AST生成逆波兰表达式也非常容易。
三地址代码主要是学习算法的工具或者用于实现比较简单的后端要实现工业级的后端充分发挥硬件的性能你还要学习LLVM的IR。
## 认识LLVM汇编码
**LLVM汇编码LLVM Assembly是LLVM的IR。**有的时候我们就简单地称呼它为LLVM语言因此我们可以把用LLVM汇编码书写的一个程序文件叫做LLVM程序。
我会在下一讲详细讲解LLVM这个开源项目。本节课作为铺垫告诉我们在使用LLVM之前要先了解它的核心——IR。
**首先LLVM汇编码是采用静态单赋值代码形式的。**
在三地址代码上再加一些限制就能得到另一种重要的代码即静态单赋值代码Static Single Assignment, SSA在静态单赋值代码中一个变量只能被赋值一次来看个例子。
“y = x1 + x2 + x3 + x4”的普通三地址代码如下
```
y := x1 + x2;
y := y + x3;
y := y + x4;
```
其中y被赋值了三次如果写成SSA的形式就只能写成下面的样子
```
t1 := x1 + x2;
t2 := t1 + x3;
y := t2 + x4;
```
为什么要费力写成这种形式呢还要为此多添加t1和t2两个临时变量原因是SSA的形式体现了精确的“使用-定义”关系。
每个变量很确定地只会被定义一次然后可以多次使用。这种特点使得基于SSA更容易做数据流分析而数据流分析又是很多代码优化技术的基础所以几乎所有语言的编译器、解释器或虚拟机中都使用了SSA因为有利于做代码优化。而LLVM的IR也是采用SSA的形式也是因为SSA方便做代码优化。
**其次LLVM IR比起三地址代码有更多的细节信息。**比如整型变量的字长、内存对齐方式等等所以使用LLVM IR能够更准确地翻译成汇编码。
看看下面这段C语言代码
```
int fun1(int a, int b){
int c = 10;
return a + b + c;
}
```
对应的LLLM汇编码如下这是我在macOS上生成的
```
; ModuleID = 'fun1.c'
source_filename = &quot;fun1.c&quot;
target datalayout = &quot;e-m:o-i64:64-f80:128-n8:16:32:64-S128&quot;
target triple = &quot;x86_64-apple-macosx10.14.0&quot;
; Function Attrs: noinline nounwind optnone ssp uwtable
define i32 @fun1(i32, i32) #0 {
%3 = alloca i32, align 4 //为3个变量申请空间
%4 = alloca i32, align 4
%5 = alloca i32, align 4
store i32 %0, i32* %3, align 4 //参数1赋值给变量1
store i32 %1, i32* %4, align 4 //参数2赋值给变量2
store i32 10, i32* %5, align 4 //常量10赋值给变量3
%6 = load i32, i32* %3, align 4 //
%7 = load i32, i32* %4, align 4
%8 = add nsw i32 %6, %7
%9 = load i32, i32* %5, align 4
%10 = add nsw i32 %8, %9
ret i32 %10
}
attributes #0 = { noinline nounwind optnone ssp uwtable &quot;correctly-rounded-divide-sqrt-fp-math&quot;=&quot;false&quot; &quot;disable-tail-calls&quot;=&quot;false&quot; &quot;less-precise-fpmad&quot;=&quot;false&quot; &quot;no-frame-pointer-elim&quot;=&quot;true&quot; &quot;no-frame-pointer-elim-non-leaf&quot; &quot;no-infs-fp-math&quot;=&quot;false&quot; &quot;no-jump-tables&quot;=&quot;false&quot; &quot;no-nans-fp-math&quot;=&quot;false&quot; &quot;no-signed-zeros-fp-math&quot;=&quot;false&quot; &quot;no-trapping-math&quot;=&quot;false&quot; &quot;stack-protector-buffer-size&quot;=&quot;8&quot; &quot;target-cpu&quot;=&quot;penryn&quot; &quot;target-features&quot;=&quot;+cx16,+fxsr,+mmx,+sahf,+sse,+sse2,+sse3,+sse4.1,+ssse3,+x87&quot; &quot;unsafe-fp-math&quot;=&quot;false&quot; &quot;use-soft-float&quot;=&quot;false&quot; }
!llvm.module.flags = !{!0, !1, !2}
!llvm.ident = !{!3}
!0 = !{i32 2, !&quot;SDK Version&quot;, [2 x i32] [i32 10, i32 14]}
!1 = !{i32 1, !&quot;wchar_size&quot;, i32 4}
!2 = !{i32 7, !&quot;PIC Level&quot;, i32 2}
!3 = !{!&quot;Apple LLVM version 10.0.1 (clang-1001.0.46.4)&quot;}
```
这些代码看上去确实比三地址代码复杂但还是比汇编精简多了比如LLVM IR的指令数量连x86-64汇编的十分之一都不到。
**我们来熟悉一下里面的元素:**
- 模块
LLVM程序是由模块构成的这个文件就是一个模块。模块里可以包括函数、全局变量和符号表中的条目。链接的时候会把各个模块拼接到一起形成可执行文件或库文件。
在模块中你可以定义目标数据布局target datalayout。例如开头的小写“e”是低字节序Little Endian的意思对于超过一个字节的数据来说低位字节排放在内存的低地址端高位字节排放在内存的高地址端。
```
target datalayout = &quot;e-m:o-i64:64-f80:128-n8:16:32:64-S128&quot;
```
“target triple”用来定义模块的目标主机它包括架构、厂商、操作系统三个部分。
```
target triple = &quot;x86_64-apple-macosx10.14.0&quot;
```
- 函数
在示例代码中有一个以define开头的函数的声明还带着花括号。这有点儿像C语言的写法比汇编用采取标签来表示一个函数的可读性更好。
函数声明时可以带很多修饰成分比如链接类型、调用约定等。如果不写缺省的链接类型是external的也就是可以像[23讲](https://time.geekbang.org/column/article/150798)中做链接练习的那样暴露出来被其他模块链接。调用约定也有很多种选择缺省是“ccc”也就是C语言的调用约定C Calling Convention而“swiftcc”则是swift语言的调用约定。**这些信息都是生成汇编时所需要的。**
示例中函数fun1还带有“#0”的属性值,定义了许多属性。这些也是生成汇编时所需要的。
- 标识符
分为全局的Glocal和本地的Local全局标识符以@开头,包括函数和全局变量,前面代码中的@fun1就是;本地标识符以%开头。
有的标识符是有名字的,比如@fun1或%a有的是没有名字的用数字表示就可以了如%1。
- 操作码
alloca、store、load、add、ret这些都是操作码。它们的含义是
<img src="https://static001.geekbang.org/resource/image/b6/3e/b60c17cd8aa27160003884a2e1e4fd3e.jpg" alt="">
它们跟我们之前学到的汇编很相似。但是似乎函数体中的代码有点儿长。怎么一个简单的“a+b+c”就翻译成了10多行代码还用到了那么多临时变量不要担心**这只是完全没经过优化的格式,**带上优化参数稍加优化以后,它就会被精简成下面的样子:
```
define i32 @fun1(i32, i32) local_unnamed_addr #0 {
%3 = add i32 %0, 10
%4 = add i32 %3, %1
ret i32 %4
}
```
- 类型系统
汇编是无类型的。如果你用add指令它就认为你操作的是整数。而用fadd或addss指令就认为你操作的是浮点数。这样会有类型不安全的风险把整型当浮点数用了造成的后果是计算结果完全错误。
LLVM汇编则带有一个类型系统。它能避免不安全的数据操作并且有助于优化算法。这个类型系统包括**基础数据类型、函数类型和void类型。**
<img src="https://static001.geekbang.org/resource/image/09/2e/090b2841d969debe803346460764242e.jpg" alt="">
**函数类型**是包括对返回值和参数的定义比如i32 (i32)
**void类型**不代表任何值,也没有长度。
- 全局变量和常量
在LLVM汇编中可以声明全局变量。全局变量所定义的内存是在编译时就分配好了的而不是在运行时例如下面这句定义了一个全局变量C
```
@c = global i32 100, align 4
```
你也可以声明常量,它的值在运行时不会被修改:
```
@c = constant i32 100, align 4
```
- 元数据
在代码中你还看到以“!”开头的一些句子,这些是元数据。这些元数据定义了一些额外的信息,提供给优化器和代码生成器使用。
- 基本块
函数中的代码会分成一个个的基本块可以用标签Label来标记一个基本块。下面这段代码有4个基本块其中第一个块有一个缺省的名字“entry”也就是作为入口的基本块这个基本块你不给它标签也可以。
```
define i32 @bb(i32) #0 {
%2 = alloca i32, align 4
%3 = alloca i32, align 4
store i32 %0, i32* %3, align 4
%4 = load i32, i32* %3, align 4
%5 = icmp sgt i32 %4, 0
br i1 %5, label %6, label %9
; &lt;label&gt;:6: ; preds = %1
%7 = load i32, i32* %3, align 4
%8 = mul nsw i32 %7, 2
store i32 %8, i32* %2, align 4
br label %12
; &lt;label&gt;:9: ; preds = %1
%10 = load i32, i32* %3, align 4
%11 = add nsw i32 %10, 3
store i32 %11, i32* %2, align 4
br label %12
; &lt;label&gt;:12: ; preds = %9, %6
%13 = load i32, i32* %2, align 4
ret i32 %13
}
```
这段代码实际上相当于下面这段C语言的代码
```
int bb(int b){
if (b &gt; 0)
return b * 2;
else
return b + 3;
}
```
每个基本块是一系列的指令。我们分析一下标签为9的基本块**让你熟悉一下基本块和LLVM指令的特点**
第一行(%10 = load i32, i32* %3, align 4的含义是把3号变量32位整型从内存加载到寄存器叫做10号变量其中内存对齐是4字节。
**我在这里延伸一下,**我们在内存里存放数据的时候有时会从2、4、8个字节的整数倍地址开始存。有些汇编指令要求必须从这样对齐的地址来取数据。另一些指令没做要求但如果是不对齐的比如是从0x03地址取数据就要花费更多的时钟周期。但缺点是内存对齐会浪费内存空间。
第一行是整个基本块的唯一入口,从其他基本块跳转过来的时候,只能跳转到这个入口行,不能跳转到基本块中的其他行。
第二行(%11 = add nsw i32 %10, 3的含义是把10号变量32位整型加上3保存到11号变量其中nsw是加法计算时没有符号环绕No Signed Wrap的意思。它的细节你可以查阅“[LLVM语言参考手册](http://llvm.org/docs/LangRef.html)”。
第三行store i32 %11, i32* %2, align 4的含义是把11号变量32位整型存入内存中的2号变量内存对齐4字节。
第四行br label %12的含义是跳转到标签为12的代码块。其中br指令是一条终结指令。终结指令要么是跳转到另一个基本块要么是从函数中返回ret指令基本块的最后一行必须是一条终结指令。
最后我要强调,从其他基本块不可以跳转到入口基本块,也就是函数中的第一个基本块。这个规定也是有利于做数据优化。
以上就是对LLVM汇编码的概要介绍更详细的信息了解可以参见“LLVM语言参考手册”
这样你实际上就可以用LLVM汇编码来编写程序了或者将AST翻译成LLVM汇编码。听上去有点让人犯怵因为LLVM汇编码的细节也相当不少好在LLVM提供了一个IR生成的API应用编程接口可以让我们更高效、更准确地生成IR。
## 课程小结
IR是我们后续做代码优化、汇编代码生成的基础在本节课中我想让你明确的要点如下
1.三地址代码是很常见的一种IR包含一个目的地址、一个操作符和至多两个源地址。它等价于四元式。我们在27讲和28讲中的优化算法会用三地址代码来讲解这样比较易于阅读。
2.LLVM IR的第一个特点是静态单赋值SSA也就是每个变量地址最多被赋值一次它这种特性有利于运行代码优化算法第二个特点是带有比较多的细节方便我们做优化和生成高质量的汇编代码。
通过本节课你应该对于编译器后端中常常提到的IR建立了直观的认识相信通过接下来的练习你一定会消除对IR的陌生感让它成为你得心应手的好工具
## 一课一思
我们介绍了IR的特点和几种基本的IR在你的领域比如人工智能领域你了解其他的IR吗它带来了什么好处欢迎分享你的经验和观点。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的人。

View File

@@ -0,0 +1,285 @@
<audio id="audio" title="25 | 后端技术的重用LLVM不仅仅让你高效" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6a/1c/6aa5ff3e6128c29a09685c86a83d701c.mp3"></audio>
在编译器后端,做代码优化和为每个目标平台生成汇编代码,工作量是很大的。那么,有什么办法能降低这方面的工作量,提高我们的工作效率呢?**答案就是利用现成的工具。**
在前端部分我就带你使用Antlr生成了词法分析器和语法分析器。那么在后端部分我们也可以获得类似的帮助比如利用LLVM和GCC这两个后端框架。
相比前端的编译器工具如LexFlex、YaccBison和Antlr等对于后端工具了解的人比较少资料也更稀缺如果你是初学者那么上手的确有一些难度。不过我们已经用2024讲铺垫了必要的基础知识也尝试了手写汇编代码这些知识足够你学习和掌握后端工具了。
本节课我想先让你了解一些背景信息所以会先概要地介绍一下LLVM和GCC这两个有代表性的框架的情况这样当我再更加详细地讲解LLVM带你实际使用一下它的时候你接受起来就会更加容易了。
## 两个编译器后端框架LLVM和GCC
LLVM是一个开源的编译器基础设施项目主要聚焦于编译器的后端功能代码生成、代码优化、JIT……。它最早是美国伊利诺伊大学的一个研究性项目核心主持人员是Chris Lattner克里斯·拉特纳
LLVM的出名是由于苹果公司全面采用了这个框架。苹果系统上的C语言、C++、Objective-C的编译器Clang就是基于LLVM的最新的Swift编程语言也是基于LLVM支撑了无数的移动应用和桌面应用。无独有偶在Android平台上最新的开发语言Kotlin也支持基于LLVM编译成本地代码。
另外由Mozilla公司Firefox就是这个公司的产品开发的系统级编程语言RUST也是基于LLVM开发的。还有一门相对小众的科学计算领域的语言叫做Julia它既能像脚本语言一样灵活易用又可以具有C语言一样的速度在数据计算方面又有特别的优化它的背后也有LLVM的支撑。
OpenGL和一些图像处理领域也在用LLVM我还看到一个资料**说阿里云的工程师实现了一个Cava脚本语言用于配合其搜索引擎系统HA3。**
[LLVM的logo一只漂亮的龙](https://en.wikipedia.org/wiki/File:LLVM_Logo.svg)
<img src="https://static001.geekbang.org/resource/image/d2/ac/d212b52e14007278e8ee417e20e94bac.png" alt="">
还有在人工智能领域炙手可热的TensorFlow框架在后端也是用LLVM来编译。它把机器学习的IR翻译成LLVM的IR然后再翻译成支持CPU、GPU和TPU的程序。
所以这样看起来你所使用的很多语言和工具背后都有LLVM的影子只不过你可能没有留意罢了。所以在我看来要了解编译器的后端技术就不能不了解LLVM。
与LLVM起到类似作用的后端编译框架是GCCGNU Compiler CollectionGNU编译器套件。它支持了GNU Linux上的很多语言例如C、C++、Objective-C、Fortran、Go语言和Java语言等。其实它最初只是一个C语言的编译器后来把公共的后端功能也提炼了出来形成了框架支持多种前端语言和后端平台。最近华为发布的方舟编译器据说也是建立在GCC基础上的。
LLVM和GCC很难比较优劣因为这两个项目都取得了很大的成功。
在本课程中我们主要采用LLVM但其中学到的一些知识比如IR的设计、代码优化算法、适配不同硬件的策略在学习GCC或其他编译器后端的时候也是有用的从而大大提升学习效率。
接下来我们先来看看LLVM的构成和特点让你对它有个宏观的认识。
## 了解LLVM的特点
LLVM能够支持多种语言的前端、多种后端CPU架构。在LLVM内部使用类型化的和SSA特点的IR进行各种分析、优化和转换
<img src="https://static001.geekbang.org/resource/image/07/1c/079aa0c78325b3a4420d78523b5aa51c.png" alt="">
LLVM项目包含了很多组成部分
<li>
LLVM核心core。就是上图中的优化和分析工具还包括了为各种CPU生成目标代码的功能这些库采用的是LLVM IR一个良好定义的中间语言在上一讲我们已经初步了解它了。
</li>
<li>
Clang前端是基于LLVM的C、C++、Objective-C编译器
</li>
<li>
LLDB一个调试工具
</li>
<li>
LLVM版本的C++标准类库。
</li>
<li>
其他一些子项目。
</li>
**我个人很喜欢LLVM想了想主要有几点原因 **
首先LLVM有良好的模块化设计和接口。以前的编译器后端技术很难复用而LLVM具备定义了良好接口的库方便使用者选择在什么时候复用哪些后端功能。比如针对代码优化LLVM提供了很多算法语言的设计者可以自己选择合适的算法或者实现自己特殊的算法具有很好的灵活性。
第二LLVM同时支持JIT即时编译和AOT提前编译两种模式。过去的语言要么是解释型的要么编译后运行。习惯了使用解释型语言的程序员很难习惯必须等待一段编译时间才能看到运行效果。很多科学工作者习惯在一个REPL界面中一边写脚本一边实时看到反馈。LLVM既可以通过JIT技术支持解释执行又可以完全编译后才执行这对于语言的设计者很有吸引力。
第三有很多可以学习借鉴的项目。Swift、Rust、Julia这些新生代的语言实现了很多吸引人的特性还有很多其他的开源项目而我们可以研究、借鉴它们是如何充分利用LLVM的。
第四全过程优化的设计思想。LLVM在设计上支持全过程的优化。Lattner和Adve最早关于LLVM设计思想的文章[《LLVM: 一个全生命周期分析和转换的编译框架》,](https://llvm.org/pubs/2003-09-30-LifelongOptimizationTR.pdf)就提出计算机语言可以在各个阶段进行优化,包括编译时、链接时、安装时,甚至是运行时。
以运行时优化为例基于LLVM我们能够在运行时收集一些性能相关的数据对代码编译优化可以是实时优化的、动态修改内存中的机器码也可以收集这些性能数据然后做离线的优化重新生成可执行文件然后再加载执行**这一点非常吸引我,**因为在现代计算环境下,每种功能的计算特点都不相同,确实需要针对不同的场景做不同的优化。下图展现了这个过程(图片来源《 LLVM: A Compilation Framework for Lifelong Program Analysis &amp; Transformation》
<img src="https://static001.geekbang.org/resource/image/07/6e/071b0421588472cda2033c75124ee96e.png" alt="">
**我建议你读一读Lattner和Adve的这篇论文**(另外强调一下,当你深入学习编译技术的时候,阅读领域内的论文就是必不可少的一项功课了)。
第五LLVM的授权更友好。GNU的很多软件都是采用GPL协议的所以如果用GCC的后端工具来编写你的语言你可能必须要按照GPL协议开源。而LLVM则更友好一些你基于LLVM所做的工作完全可以是闭源的软件产品。
而我之所以说“LLVM不仅仅让你更高效”就是因为上面它的这些特点。
现在你已经对LLVM的构成和特点有一定的了解了接下来我带你亲自动手操作和体验一下LLVM的功能这样你就可以迅速消除对它的陌生感快速上手了。
## 体验一下LLVM的功能
首先你需要安装一下LLVM参照[官方网站](http://releases.llvm.org/)上的相关介绍下载安装。因为我使用的是macOS所以用brew就可以安装。
```
brew install llvm
```
因为LLVM里面带了一个版本的Clang和C++的标准库与本机原来的工具链可能会有冲突所以brew安装的时候并没有在/usr/local下建立符号链接。你在用LLVM工具的时候要配置好相关的环境变量。
```
# 可执行文件的路径
export PATH=&quot;/usr/local/opt/llvm/bin:$PATH&quot;
# 让编译器能够找到LLVM
export LDFLAGS=&quot;-L/usr/local/opt/llvm/lib&quot;
export CPPFLAGS=&quot;-I/usr/local/opt/llvm/include”
```
安装完毕之后我们使用一下LLVM自带的命令行工具分几步体验一下LLVM的功能
1.从C语言代码生成IR<br>
2.优化IR<br>
3.从文本格式的IR生成二进制的字节码<br>
4.把IR编译成汇编代码和可执行文件。
从C语言代码生成IR代码比较简单上一讲中我们已经用到过一个C语言的示例代码
```
//fun1.c
int fun1(int a, int b){
int c = 10;
return a+b+c;
}
```
用前端工具Clang就可以把它编译成IR代码
```
clang -emit-llvm -S fun1.c -o fun1.ll
```
其中,-emit-llvm参数告诉Clang生成LLVM的汇编码也就是IR代码如果不带这个参数就会生成针对目标机器的汇编码所生成的IR我们上一讲也见过你现在应该能够读懂它了。你可以多写几个不同的程序看看生成的IR是什么样的比如if语句、循环语句等等这时你完成了第一步
```
; ModuleID = 'function-call1.c'
source_filename = &quot;function-call1.c&quot;
target datalayout = &quot;e-m:o-i64:64-f80:128-n8:16:32:64-S128&quot;
target triple = &quot;x86_64-apple-macosx10.14.0&quot;
; Function Attrs: noinline nounwind optnone ssp uwtable
define i32 @fun1(i32, i32) #0 {
%3 = alloca i32, align 4
%4 = alloca i32, align 4
%5 = alloca i32, align 4
store i32 %0, i32* %3, align 4
store i32 %1, i32* %4, align 4
store i32 10, i32* %5, align 4
%6 = load i32, i32* %3, align 4
%7 = load i32, i32* %4, align 4
%8 = add nsw i32 %6, %7
%9 = load i32, i32* %5, align 4
%10 = add nsw i32 %8, %9
ret i32 %10
}
attributes #0 = { noinline nounwind optnone ssp uwtable &quot;correctly-rounded-divide-sqrt-fp-math&quot;=&quot;false&quot; &quot;disable-tail-calls&quot;=&quot;false&quot; &quot;less-precise-fpmad&quot;=&quot;false&quot; &quot;min-legal-vector-width&quot;=&quot;0&quot; &quot;no-frame-pointer-elim&quot;=&quot;true&quot; &quot;no-frame-pointer-elim-non-leaf&quot; &quot;no-infs-fp-math&quot;=&quot;false&quot; &quot;no-jump-tables&quot;=&quot;false&quot; &quot;no-nans-fp-math&quot;=&quot;false&quot; &quot;no-signed-zeros-fp-math&quot;=&quot;false&quot; &quot;no-trapping-math&quot;=&quot;false&quot; &quot;stack-protector-buffer-size&quot;=&quot;8&quot; &quot;target-cpu&quot;=&quot;penryn&quot; &quot;target-features&quot;=&quot;+cx16,+fxsr,+mmx,+sahf,+sse,+sse2,+sse3,+sse4.1,+ssse3,+x87&quot; &quot;unsafe-fp-math&quot;=&quot;false&quot; &quot;use-soft-float&quot;=&quot;false&quot; }
!llvm.module.flags = !{!0, !1}
!llvm.ident = !{!2}
!0 = !{i32 1, !&quot;wchar_size&quot;, i32 4}
!1 = !{i32 7, !&quot;PIC Level&quot;, i32 2}
!2 = !{!&quot;clang version 8.0.0 (tags/RELEASE_800/final)&quot;}
```
上一讲我们提到过可以对生成的IR做优化让代码更短你只要在上面的命令中加上-O2参数就可以了这时你完成了第二步
```
clang -emit-llvm -S -O2 fun1.c -o fun1.ll
```
这个时候函数体的核心代码就变短了很多。这里面最重要的优化动作是从原来使用内存alloca指令是在栈中分配空间store指令是往内存里写入值优化到只使用寄存器%0、%1是参数%3、%4也是寄存器
```
define i32 @fun1(i32, i32) #0 {
%3 = add nsw i32 %0, %1
%4 = add nsw i32 %3, 10
ret i32 %4
}
```
你还可以用opt命令来完成上面的优化具体我们在27、28讲中讲优化算法的时候再细化。
**另外LLVM的IR有两种格式。**在示例代码中显示的,是它的文本格式,文件名一般以.ll结尾。第二种格式是字节码bitcode格式文件名以.bc结尾。**为什么要用两种格式呢?**因为文本格式的文件便于程序员阅读,而字节码格式的是二进制文件,便于机器处理,比如即时编译和执行。生成字节码格式之后,所占空间会小很多,所以可以快速加载进内存,并转换为内存中的对象格式。而如果加载文本文件,则还需要一个解析的过程,才能变成内存中的格式,效率比较慢。
调用llvm-as命令我们可以把文本格式转换成字节码格式
```
llvm-as fun1.ll -o fun1.bc
```
我们也可以用clang直接生成字节码这时不需要带-S参数而是要用-c参数。
```
clang -emit-llvm -c fun1.c -o fun1.bc
```
因为.bc文件是二进制文件不能直接用文本编辑器查看而要用hexdump命令查看这时你完成了第三步
```
hexdump -C fun1.bc
```
<img src="https://static001.geekbang.org/resource/image/74/b1/7466ca0d3d8beb0c4d570091512da1b1.png" alt="">
LLVM的一个优点就是可以即时编译运行字节码不一定非要编译生成汇编码和可执行文件才能运行这一点有点儿像Java语言这也让LLVM具有极高的灵活性比如可以在运行时根据收集的性能信息改变优化策略生成更高效的机器码。
再进一步我们可以把字节码编译成目标平台的汇编代码。我们使用的是llc命令命令如下
```
llc fun1.bc -o fun1.s
```
用clang命令也能从字节码生成汇编代码要注意带上-S参数就行了
```
clang -S fun1.bc -o fun1.s
```
**到了这一步,我们已经得到了汇编代码,**接着就可以进一步生成目标文件和可执行文件了。
实际上使用LLVM从源代码到生成可执行文件有两条可能的路径
<img src="https://static001.geekbang.org/resource/image/5a/d4/5ad8793ffba445c8f95d417f4ae9e6d4.jpg" alt="">
<li>
第一条路径,是把每个源文件分别编译成字节码文件,然后再编译成目标文件,最后链接成可执行文件。
</li>
<li>
第二条路径,是先把编译好的字节码文件链接在一起,形成一个更大的字节码文件,然后对这个字节码文件进行进一步的优化,之后再生成目标文件和可执行文件。
</li>
第二条路径比第一条路径多了一个优化的步骤,第一条路径只对每个模块做了优化,没有做整体的优化。所以,如有可能,尽量采用第二条路径,这样能够生成更加优化的代码。
现在你完成了第四步对LLVM的命令行工具有了一定的了解。总结一下我们用到的命令行工具包括clang前端、llvm-as、llc其他命令还有opt代码优化、llvm-dis将字节码再反编译回ll文件、llvm-link链接你可以看它们的help信息并练习使用。
在熟悉了命令行工具之后我们就可以进一步在编程环境中使用LLVM了不过在此之前需要搭建一个开发环境。
## 建立C++开发环境来使用LLVM
LLVM本身是用C++开发的所以最好采用C++调用它的功能。当然采用其他语言也有办法调用LLVM
- C语言可以调用专门的C接口
- 像Go、Rust、Python、Ocaml、甚至Node.js都有对LLVM API的绑定
- 如果使用Java也可以通过JavaCPP类似JNI技术调用LLVM。
在课程中我用C++来做实现因为这样能够最近距离地跟LLVM打交道。与此同时我们前端工具采用的Antlr也能够支持C++开发环境。**所以我为playscript建立了一个C++的开发环境。**
**开发工具方面:**原则上只要一个编辑器加上工具链就行但为了提高效率有IDE的支持会更好我用的是JetBrains的Clion
**构建工具方面:**目前LLVM本身用的是CMake而Clion刚好也采用CMake所以很方便。
**这里我想针对CMake多解释几句**因为越来越多的C++项目都是用CMake来管理的LLVM以及Antlr的C++版本也采用了CMake**你最好对它有一定了解。**
CMake是一款优秀的工程构建工具它类似于Java程序员们习惯使用的Maven工具。对于只包含少量文件或模块的C或C++程序,你可以仅仅通过命令行带上一些参数就能编译。
不过实际的项目都会比较复杂往往会包含比较多的模块存在比较复杂的依赖关系编译过程也不是一步能完成的要分成多步。这时候我们一般用make管理项目的构建过程这就要学会写make文件。但手工写make文件工作量会比较大而CMake就是在make的基础上再封装了一层它能通过更简单的配置文件帮我们生成make文件帮助程序员提升效率。
整个开发环境的搭建我在课程里就不多写了,你可以参见示例代码所附带的文档。文档里有比较清晰的说明,可以帮助你把环境搭建起来,并运行示例程序。
另外我知道你可能对C++并不那么熟悉。但你应该学过C语言所以示例代码还是能看懂的。
## 课程小结
本节课为了帮助你理解后端工具我先概要介绍了后端工具的情况接着着重介绍了LLVM的构成和特点然后又带你熟悉了它的命令行工具让你能够生成文本和字节码两种格式的IR并生成可执行文件最后带你了解了LLVM的开发环境。
本节课的内容比较好理解因为侧重让你建立跟LLVM的熟悉感没有什么复杂的算法和原理而我想强调的是以下几点
1.后端工具对于语言设计者很重要,我们必须学会善加利用;<br>
2.LLVM有很好的模块化设计支持即时编译JIT和提前编译AOT支持全过程的优化并且具备友好的授权值得我们好好掌握<br>
3.你要熟悉LLVM的命令行工具这样可以上手做很多实验加深对LLVM的了解。
最后我想给你的建议是一定要动手安装和使用LLVM写点代码测试它的功能。比如写点儿C、C++等语言的程序并翻译成IR进一步熟悉LLVM的IR。下一讲我们就要进入它的内部调用它的API来生成IR和运行了
## 一课一思
很多语言都获得了后端工具的帮助比如可以把Android应用直接编译成机器码提升运行效率。你所经常使用的计算机语言采用了什么后端工具有什么特点欢迎在留言区分享。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你分享给更多的朋友。

View File

@@ -0,0 +1,508 @@
<audio id="audio" title="26 | 生成IR实现静态编译的语言" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c2/ed/c2d12d8e9b24543ca2e3f5b30d90b7ed.mp3"></audio>
目前来讲你已经初步了解了LLVM和它的IR也能够使用它的命令行工具。**不过我们还是要通过程序生成LLVM的IR**这样才能复用LLVM的功能从而实现一门完整的语言。
不过如果我们要像前面生成汇编语言那样通过字符串拼接来生成LLVM的IR除了要了解LLVM IR的很多细节之外代码一定比较啰嗦和复杂因为字符串拼接不是结构化的方法所以最好用一个定义良好的数据结构来表示IR。
好在LLVM项目已经帮我们考虑到了这一点它提供了代表LLVM IR的一组对象模型我们只要生成这些对象就相当于生成了IR这个难度就低多了。而且LLVM还提供了一个工具类IRBuilder我们可以利用它进一步提升创建LLVM IR的对象模型的效率让生成IR的过程变得更加简单
接下来就让我们先来了解LLVM IR的对象模型。
## LLVM IR的对象模型
LLVM在内部有用C++实现的对象模型能够完整表示LLVM IR当我们把字节码读入内存时LLVM就会在内存中构建出这个模型。只有基于这个对象模型我们才可以做进一步的工作包括代码优化实现即时编译和运行以及静态编译生成目标文件。**所以说这个对象模型是LLVM运行时的核心。**
<img src="https://static001.geekbang.org/resource/image/ce/9f/ced8f09e66d4bbd60eb524456d165e9f.jpg" alt="">
IR对象模型的头文件在[include/llvm/IR](https://github.com/llvm/llvm-project/tree/master/llvm/include/llvm/IR)目录下,其中最重要的类包括:
- Module模块
Module类聚合了一个模块中的所有数据它可以包含多个函数。你可以通过Model::iterator来遍历模块中所有的函数。它也包含了一个模块的全局变量。
- Function函数
Function包含了与函数定义definition或声明declaration有关的所有对象。函数定义包含了函数体而函数声明则仅仅包含了函数的原型它是在其他模块中定义的在本模块中使用。
你可以通过getArgumentList()方法来获得函数参数的列表也可以遍历函数体中的所有基本块这些基本块会形成一个CFG控制流图
```
//函数声明,没有函数体。这个函数是在其他模块中定义的,在本模块中使用
declare void @foo(i32)
//函数定义,包含函数体
define i32 @fun3(i32 %a) {
%calltmp1 = call void @foo(i32 %a) //调用外部函数
ret i32 10
}
```
- BasicBlock基本块
BasicBlock封装了一系列的LLVM指令你可以借助bigin()/end()模式遍历这些指令还可以通过getTerminator()方法获得最后一条指令也就是终结指令。你还可以用到几个辅助方法在CFG中导航比如获得某个基本块的前序基本块。
- Instruction指令
Instruction类代表了LLVM IR的原子操作也就是一条指令你可以通过getOpcode()来获得它代表的操作码它是一个llvm::Instruction枚举值你可以通过op_begin()和op_end()方法对获得这个指令的操作数。
- Value
Value类代表一个值。在LLVM的内存IR中如果一个类是从Value继承的意味着它定义了一个值其他方可以去使用。函数、基本块和指令都继承了Value。
- LLVMContext上下文
这个类代表了LLVM做编译工作时的一个上下文包含了编译工作中的一些全局数据比如各个模块用到的常量和类型。
这些内容是LLVM IR对象模型的主要部分我们生成IR的过程就是跟这些类打交道其他一些次要的类你可以在阅读和编写代码的过程中逐渐熟悉起来。
接下来就让我们用程序来生成LLVM的IR。
## 尝试生成LLVM IR
我刚刚提到的每个LLVM IR类都可以通过程序来构建。那么为下面这个fun1()函数生成IR应该怎么办呢
```
int fun1(int a, int b){
return a+b;
}
```
**第一步,**我们可以来生成一个LLVM模块也就是顶层的IR对象。
```
Module *mod = new Module(&quot;fun1.ll&quot;, TheModule);
```
**第二步,**我们继续在模块中定义函数fun1因为模块最主要的构成要素就是各个函数。
不过在定义函数之前要先定义函数的原型或者叫函数的类型。函数的类型我们在前端讲过如果两个函数的返回值相同并且参数也相同这两个函数的类型是相同的这样就可以做函数指针或函数型变量的赋值。示例代码的函数原型是返回值是32位整数参数是两个32位整数。
有了函数原型以后,就可以使用这个函数原型定义一个函数。我们还可以为每个参数设置一个名称,便于后面引用这个参数。
```
//函数原型
vector&lt;Type *&gt; argTypes(2, Type::getInt32Ty(TheContext));
FunctionType *fun1Type = FunctionType::get(Type::getInt32Ty(TheContext), //返回值是整数
argTypes, //两个整型参数
false); //不是变长参数
//函数对象
Function *fun = Function::Create(fun1Type,
Function::ExternalLinkage, //链接类型
&quot;fun2&quot;, //函数名称
TheModule.get()); //所在模块
//设置参数名称
string argNames[2] = {&quot;a&quot;, &quot;b&quot;};
unsigned i = 0;
for (auto &amp;arg : fun-&gt;args()){
arg.setName(argNames[i++]);
}
```
**这里你需要注意,代码中是如何使用变量类型的。**所有的基础类型都是提前定义好的可以通过Type类的getXXXTy()方法获得我们使用的是Int32类型你还可以获得其他类型
**第三步,**创建一个基本块。
这个函数只有一个基本块你可以把它命名为“entry”也可以不给它命名。在创建了基本块之后我们用了一个辅助类IRBuilder设置了一个插入点后序生成的指令会插入到这个基本块中IRBuilder是LLVM为了简化IR生成过程所提供的一个辅助类
```
//创建一个基本块
BasicBlock *BB = BasicBlock::Create(TheContext,//上下文
&quot;&quot;, //基本块名称
fun); //所在函数
Builder.SetInsertPoint(BB); //设置指令的插入点
```
**第四步,**生成"a+b"表达式所对应的IR插入到基本块中。
a和b都是函数fun的参数我们把它取出来分别赋值给L和RL和R是Value。然后用IRBuilder的CreateAdd()方法生成一条add指令。这个指令的计算结果存放在addtemp中。
```
//把参数变量存到NamedValues里面备用
NamedValues.clear();
for (auto &amp;Arg : fun-&gt;args())
NamedValues[Arg.getName()] = &amp;Arg;
//做加法
Value *L = NamedValues[&quot;a&quot;];
Value *R = NamedValues[&quot;b&quot;];
Value *addtmp = Builder.CreateAdd(L, R);
```
**第五步,**利用刚才获得的addtmp创建一个返回值。
```
//返回值
Builder.CreateRet(addtmp);
```
**最后一步,**检查这个函数的正确性。这相当于是做语义检查,比如,基本块的最后一个语句就必须是一个正确的返回指令。
```
//验证函数的正确性
verifyFunction(*fun);
```
完整的代码我也提供给你,放在[codegen_fun1()](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/26-llvmdemo/main.cpp#L49)里了你可以看一下。我们可以调用这个方法然后打印输出生成的IR
```
Function *fun1 = codegen_fun1(); //在模块中生成Function对象
TheModule-&gt;print(errs(), nullptr); //在终端输出IR
```
生成的IR如下
```
; ModuleID = 'llvmdemo'
source_filename = &quot;llvmdemo&quot;
define i32 @fun1(i32 %a, i32 %b) {
%1 = add i32 %a, %b
ret i32 %1
}
```
这个例子简单过程直观只有一个加法运算而我建议你在这个过程中注意每个IR对象都是怎样被创建的在大脑中想象出整个对象结构。
为了熟悉更多的API接下来我再带你生成一个稍微复杂一点儿的带有if语句的IR。然后来看一看函数中包含多个基本块的情况。
## 支持if语句
具体说我们要为下面的一个函数生成IR函数有一个参数a当a大于2的时候返回2否则返回3
```
int fun_ifstmt(int a)
if (a &gt; 2)
return 2;
else
return 3
}
```
这样的一个函数需要包含4个基本块**入口基本块、Then基本块、Else基本块和Merge基本块。**控制流图CFG是先分开再合并像下面这样
<img src="https://static001.geekbang.org/resource/image/ce/2a/ce96ecd42b4b4e095d4671e1b658582a.jpg" alt="">
**在入口基本块中,**我们要计算“a&gt;2”的值并根据这个值分别跳转到ThenBB和ElseBB。这里我们用到了IRBuilder的CreateICmpUGE()方法UGE的意思是”不大于等于“也就是小于。这个指令的返回值是一个1位的整型也就是int1。
```
//计算a&gt;2
Value * L = NamedValues[&quot;a&quot;];
Value * R = ConstantInt::get(TheContext, APInt(32, 2, true));
Value * cond = Builder.CreateICmpUGE(L, R, &quot;cmptmp&quot;);
```
接下来我们创建另外3个基本块并用IRBuilder的CreateCondBr()方法创建条件跳转指令当cond是1的时候跳转到ThenBB0的时候跳转到ElseBB。
```
BasicBlock *ThenBB =BasicBlock::Create(TheContext, &quot;then&quot;, fun);
BasicBlock *ElseBB = BasicBlock::Create(TheContext, &quot;else&quot;);
BasicBlock *MergeBB = BasicBlock::Create(TheContext, &quot;ifcont&quot;);
Builder.CreateCondBr(cond, ThenBB, ElseBB);
```
**如果你细心的话,**可能会发现在创建ThenBB的时候指定了其所在函数是fun而其他两个基本块没有指定。这是因为我们接下来就要为ThenBB生成指令所以先加到fun中。之后再顺序添加ElseBB和MergeBB到fun中。
```
//ThenBB
Builder.SetInsertPoint(ThenBB);
Value *ThenV = ConstantInt::get(TheContext, APInt(32, 2, true));
Builder.CreateBr(MergeBB);
//ElseBB
fun-&gt;getBasicBlockList().push_back(ElseBB); //把基本块加入到函数中
Builder.SetInsertPoint(ElseBB);
Value *ElseV = ConstantInt::get(TheContext, APInt(32, 3, true));
Builder.CreateBr(MergeBB);
```
**在ThenBB和ElseBB**这两个基本块的代码中我们分别计算出了两个值ThenV和ElseV。它们都可能是最后的返回值但具体采用哪个还要看实际运行时控制流走的是ThenBB还是ElseBB。这就需要用到phi指令它完成了根据控制流来选择合适的值的任务。
```
//MergeBB
fun-&gt;getBasicBlockList().push_back(MergeBB);
Builder.SetInsertPoint(MergeBB);
//PHI节点整型两个候选值
PHINode *PN = Builder.CreatePHI(Type::getInt32Ty(TheContext), 2);
PN-&gt;addIncoming(ThenV, ThenBB); //前序基本块是ThenBB时采用ThenV
PN-&gt;addIncoming(ElseV, ElseBB); //前序基本块是ElseBB时采用ElseV
//返回值
Builder.CreateRet(PN);
```
从上面这段代码中你能看出,**在if语句中phi指令是关键。**因为当程序的控制流经过多个基本块每个基本块都可能改变某个值的时候通过phi指令可以知道运行时实际走的是哪条路径从而获得正确的值。
最后生成的IR如下其中的phi指令指出如果前序基本块是then取值为2是else的时候取值为3。
```
define i32 @fun_ifstmt(i32 %a) {
%cmptmp = icmp uge i32 %a, 2
br i1 %cmptmp, label %then, label %else
then: ; preds = %0
br label %ifcont
else: ; preds = %0
br label %ifcont
ifcont: ; preds = %else, %then
%1 = phi i32 [ 2, %then ], [ 3, %else ]
ret i32 %1
}
```
其实循环语句也跟if语句差不多因为它们都是要涉及到多个基本块要用到phi指令**所以一旦你会写if语句肯定就会写循环语句的。**
## 支持本地变量
在写程序的时候本地变量是必不可少的一个元素所以我们趁热打铁把刚才的示例程序变化一下用本地变量b保存ThenBB和ElseBB中计算的值借此学习一下LLVM IR是如何支持本地变量的。
改变后的示例程序如下:
```
int fun_localvar(int a)
int b = 0;
if (a &gt; 2)
b = 2;
else
b = 3;
return b;
}
```
其中函数有一个参数a一个本地变量b如果a大于2那么给b赋值2否则给b赋值3。最后的返回值是b。
**现在挑战来了,**在这段代码中b被声明了一次赋值了3次。我们知道LLVM IR采用的是SSA形式也就是每个变量只允许被赋值一次那么对于多次赋值的情况我们该如何生成IR呢
其实LLVM规定了对寄存器只能做单次赋值而对内存中的变量是可以多次赋值的。对于“int b = 0;”我们用下面几条语句生成IR
```
//本地变量b
AllocaInst *b = Builder.CreateAlloca(Type::getInt32Ty(TheContext), nullptr, &quot;b&quot;);
Value* initValue = ConstantInt::get(TheContext, APInt(32, 0, true));
Builder.CreateStore(initValue, b);
```
上面这段代码的含义是首先用CreateAlloca()方法在栈中申请一块内存用于保存一个32位的整型接着用CreateStore()方法生成一条store指令给b赋予初始值。
上面几句生成的IR如下
```
%b = alloca i32
store i32 0, i32* %b
```
接着我们可以在ThenBB和ElseBB中分别对内存中的b赋值
```
//ThenBB
Builder.SetInsertPoint(ThenBB);
Value *ThenV = ConstantInt::get(TheContext, APInt(32, 2, true));
Builder.CreateStore(ThenV, b);
Builder.CreateBr(MergeBB);
//ElseBB
fun-&gt;getBasicBlockList().push_back(ElseBB);
Builder.SetInsertPoint(ElseBB);
Value *ElseV = ConstantInt::get(TheContext, APInt(32, 3, true));
Builder.CreateStore(ElseV, b);
Builder.CreateBr(MergeBB);
```
最后在MergeBB中我们只需要返回b就可以了
```
//MergeBB
fun-&gt;getBasicBlockList().push_back(MergeBB);
Builder.SetInsertPoint(MergeBB);
//返回值
Builder.CreateRet(b);
```
最后生成的IR如下
```
define i32 @fun_ifstmt.1(i32 %a) {
%b = alloca i32
store i32 0, i32* %b
%cmptmp = icmp uge i32 %a, 2
br i1 %cmptmp, label %then, label %else
then: ; preds = %0
store i32 2, i32* %b
br label %ifcont
else: ; preds = %0
store i32 3, i32* %b
br label %ifcont
ifcont: ; preds = %else, %then
ret i32* %b
}
```
当然,使用内存保存临时变量的性能比较低,但我们可以很容易通过优化算法,把上述代码从使用内存的版本,优化成使用寄存器的版本。
通过上面几个示例现在你已经学会了生成基本的IR包括能够支持本地变量、加法运算、if语句。那么这样生成的IR能否正常工作呢我们需要把这些IR编译和运行一下才知道。
## 编译并运行程序
现在已经能够在内存中建立LLVM的IR对象了包括模块、函数、基本块和各种指令。LLVM可以即时编译并执行这个IR模型。
我们先创建一个不带参数的__main()函数作为入口。同时我会借这个例子延伸讲一下函数的调用。我们在前面声明了函数fun1现在在__main()函数中演示如何调用它。
```
Function * codegen_main(){
//创建main函数
FunctionType *mainType = FunctionType::get(Type::getInt32Ty(TheContext), false);
Function *main = Function::Create(mainType, Function::ExternalLinkage, &quot;__main&quot;, TheModule.get());
//创建一个基本块
BasicBlock *BB = BasicBlock::Create(TheContext, &quot;&quot;, main);
Builder.SetInsertPoint(BB);
//设置参数的值
int argValues[2] = {2, 3};
std::vector&lt;Value *&gt; ArgsV;
for (unsigned i = 0; i&lt;2; ++i) {
Value * value = ConstantInt::get(TheContext, APInt(32,argValues[i],true));
ArgsV.push_back(value);
if (!ArgsV.back())
return nullptr;
}
//调用函数fun1
Function *callee = TheModule-&gt;getFunction(&quot;fun1&quot;);
Value * rtn = Builder.CreateCall(callee, ArgsV, &quot;calltmp&quot;);
//返回值
Builder.CreateRet(rtn);
return main;
}
```
调用函数时我们首先从模块中查找出名称为fun1的函数准备好参数值然后通过IRBuilder的CreateCall()方法来生成函数调用指令。最后生成的IR如下
```
define i32 @__main() {
%calltmp = call i32 @fun1(i32 2, i32 3)
ret i32 %calltmp3
}
```
接下来我们调用即时编译的引擎来运行__main函数与JIT引擎有关的代码放到了DemoJIT.h中你现在可以暂时不关心它的细节留到以后再去了解。使用这个JIT引擎我们需要做几件事情
1.初始化与目标硬件平台有关的设置。
```
InitializeNativeTarget();
InitializeNativeTargetAsmPrinter();
InitializeNativeTargetAsmParser();
```
2.把创建的模型加入到JIT引擎中找到__main()函数的地址整个过程跟C语言中使用函数指针来执行一个函数没有太大区别
```
auto H = TheJIT-&gt;addModule(std::move(TheModule));
//查找__main函数
auto main = TheJIT-&gt;findSymbol(&quot;__main&quot;);
//获得函数指针
int32_t (*FP)() = (int32_t (*)())(intptr_t)cantFail(main.getAddress());
//执行函数
int rtn = FP();
//打印执行结果
fprintf(stderr, &quot;__main: %d\n&quot;, rtn);
```
3.程序可以成功执行并打印__main函数的返回值。
**既然已经演示了如何调用函数在这里我给你揭示LLVM的一个惊人的特性**我们可以在LLVM IR里调用本地编写的函数比如编写一个foo()函数,用来打印输出一些信息:
```
void foo(int a){
printf(&quot;in foo: %d\n&quot;,a);
}
```
然后我们就可以在__main里直接调用这个foo函数就像调用fun1函数一样
```
//调用一个外部函数foo
vector&lt;Type *&gt; argTypes(1, Type::getInt32Ty(TheContext));
FunctionType *fooType = FunctionType::get(Type::getVoidTy(TheContext), argTypes, false);
Function *foo = Function::Create(fooType, Function::ExternalLinkage, &quot;foo&quot;, TheModule.get());
std::vector&lt;Value *&gt; ArgsV2;
ArgsV2.push_back(rtn);
if (!ArgsV2.back())
return nullptr;
Builder.CreateCall(foo, ArgsV2, &quot;calltmp2&quot;);
```
注意我们在这里只对foo函数做了声明并没有定义它的函数体这时LLVM会在外部寻找foo的定义它会找到用C++编写的foo函数然后调用并执行如果foo函数在另一个目标文件中它也可以找到。
刚才讲的是即时编译和运行,你也可以生成目标文件,然后再去链接和执行。生成目标文件的代码参见[emitObject()](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/26-llvmdemo/main.cpp#L298)方法,基本上就是打开一个文件,然后写入生成的二进制目标代码。针对目标机器生成目标代码的大量工作,就用这么简单的几行代码就实现了,是不是帮了你的大忙了?
## 课程小结
本节课我们我们完成了从生成IR到编译执行的完整过程同时也初步熟悉了LLVM的接口。当然了完全熟悉LLVM的接口还需要多做练习掌握更多的细节。就本节课而言我希望你掌握的重点如下
<li>
LLVM用一套对象模型在内存中表示IR包括模块、函数、基本块和指令你可以通过API来生成这些对象。这些对象一旦生成就可以编译和执行。
</li>
<li>
对于if语句和循环语句需要生成多个基本块并通过跳转指令形成正确的控制流图CFG。当存在多个前序节点可能改变某个变量的值的时候使用phi指令来确定正确的值。
</li>
<li>
存储在内存中的本地变量,可以多次赋值。
</li>
<li>
LLVM能够把外部函数和IR模型中的函数等价对待。
</li>
另外为了降低学习难度本节课我没有做从AST翻译成IR的工作而是针对一个目标功能比如一个C语言的函数硬编码调用API来生成IR。你理解各种功能是如何生成IR以后再从AST来翻译就更加容易了。
## 一课一思
既然我带你演示了if语句如何生成IR那么你能思考一下对于for循环和while循环语句它对应的CFG应该是什么样的应该如何生成IR欢迎你在留言区分享你的看法。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。

View File

@@ -0,0 +1,364 @@
<audio id="audio" title="27 | 代码优化:为什么你的代码比他的更高效?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9d/6d/9d54233386f3f9347abd3252782ff86d.mp3"></audio>
在使用LLVM的过程中你应该觉察到了优化之后和优化之前的代码相差很大。代码优化之后数量变少了性能也更高了。而针对这个看起来很神秘的代码优化我想问你一些问题
- 代码优化的目标是什么?除了性能上的优化,还有什么优化?
- 代码优化可以在多大的范围内执行?是在一个函数内,还是可以针对整个应用程序?
- 常见的代码优化场景有哪些?
这些问题是代码优化的基本问题,很重要,我会用两节课的时间带你了解和掌握。
当然了代码优化是编译器后端的两大工作之一另一个是代码生成弄懂它你就掌握了一大块后端技术。而学习代码优化的原理然后通过LLVM实践一下这样原理与实践相结合会帮你早日弄懂代码优化。
接下来,我带你概要地了解一下代码优化的目标、对象、范围和策略等内容。
## 了解代码优化的目标、对象、范围和策略
- 代码优化的目标
代码优化的目标是优化程序对计算机资源的使用。我们平常最关心的就是CPU资源最大效率地利用CPU资源可以提高程序的性能。代码优化有时候还会有其他目标比如代码大小、内存占用大小、磁盘访问次数、网络通讯次数等等。
- 代码优化的对象
从代码优化的对象看大多数的代码优化都是在IR上做的而不是在前一阶段的AST和后一阶段汇编代码上进行的为什么呢
**其实在AST上也能做一些优化**比如在讲前端内容的时候我们曾经会把一些不必要的AST层次削减掉例如add-&gt;mul-&gt;pri-&gt;Int每个父节点只有一个子节点可以直接简化为一个Int节点但它抽象层次太高含有的硬件架构信息太少难以执行很多优化算法。 **在汇编代码上进行优化**会让算法跟机器相关,当换一个目标机器的时候,还要重新编写优化代码。**所以在IR上是最合适的**它能尽量做到机器独立,同时又暴露出很多的优化机会。
- 代码优化的范围
从优化的范围看,分为本地优化、全局优化和过程间优化。
优化通常针对一组指令最常用也是最重要的指令组就是基本块。基本块的特点是每个基本块只能从入口进入从最后一条指令退出每条指令都会被顺序执行。因着这个特点我们在做某些优化时会比较方便。比如针对下面的基本块我们可以很安全地把第3行的“y:=t+x”改成“y:= 3 * x”因为t的赋值一定是在y的前面
```
BB1:
t:=2 * x
y:=t + x
Goto BB2
```
这种针对基本块的优化,我们叫做**本地优化Local Optimization。**
**那么另一个问题来了:**我们能否把第二行的“t:=2 * x”也优化删掉呢这取决于是否有别的代码会引用t。所以我们需要进行更大范围的分析才能决定是否把第二行优化掉。
超越基本块的范围进行分析,我们需要用到**控制流图Control Flow GraphCFG。**CFG是一种有向图它体现了基本块之前的指令流转关系。如果从BB1的最后一条指令是跳转到BB2那么从BB1到BB2就有一条边。一个函数或过程里如果包含多个基本块可以表达为一个CFG。
<img src="https://static001.geekbang.org/resource/image/32/9e/327a0631236e89016d9bf56feed3309e.jpg" alt="">
如果通过分析CFG我们发现t在其他地方没有被使用就可以把第二行删掉。这种针对一个函数、基于CFG的优化叫做**全局优化Global Optimization。**
比全局优化更大范围的优化,叫做**过程间优化Inter-procedural Optimization**它能跨越函数的边界,对多个函数之间的关系进行优化,而不是仅针对一个函数做优化。
- 代码优化的策略
最后你不需要每次都把代码优化做彻底因为做代码优化本身也需要消耗计算机的资源。所以你需要权衡代码优化带来的好处和优化本身的开支这两个方面然后确定做多少优化。比如在浏览器里加载JavaScript的时候JavaScript引擎一定会对JavaScript做优化但如果优化消耗的时间太长界面的响应会变慢反倒影响用户使用页面的体验所以JavaScript引擎做优化时要掌握合适的度或调整优化时机。
接下来,我带你认识一些常见的代码优化的场景,这样可以让你对代码优化的认识更加直观,然后我们也可以将这部分知识作为后面讨论算法的基础。
## 一些优化的场景
- 代数优化Algebraic Optimazation
代数优化是最简单的一种优化,当操作符是代数运算的时候,你可以根据学过的数学知识进行优化。
比如“x:=x+0 ”这行代码操作前后x没有任何变化所以这样的代码可以删掉又比如“x:=x*0” 可以简化成“x:=0”对某些机器来说移位运算的速度比乘法的快那么“x:=x*8”可以优化成“x:=x&lt;&lt;3”。
- 常数折叠Constant Folding
它是指,对常数的运算可以在编译时计算,比如 “x:= 20 * 3 ”可以优化成“x:=60”。另外在if条件中如果条件是一个常量那就可以确定地取某个分支。比如“If 2&gt;0 Goto BB2” 可以简化成“Goto BB2”就好了。
- 删除不可达的基本块
有些代码永远不可能被激活。比如在条件编译的场景中我们会写这样的程序“if(DEBUG) {...}”。如果编译时DEBUG是一个常量false那这个代码块就没必要编译了。
- 删除公共子表达式Common Subexpression Elimination
下面这两行代码x和y右边的形式是一样的如果这两行代码之间a和b的值没有发生变化比如采用SSA形式那么x和y的值一定是一样的。
```
x := a + b
y := a + b
```
那我们就可以让y等于x从而减少了一次“a+b”的计算这种优化叫做删除公共子表达式。
```
x := a + b
y := x
```
- 拷贝传播Copy Propagation和常数传播Constant Propagation
下面的示例代码中第三行可以被替换成“z:= 2 * x” 因为y的值就等于x这叫做拷贝传播。
```
x := a + b
y := x
z := 2 * y
```
如果y := 10常数10也可以传播下去把最后一行替换成 z:= 2 * 10这叫做常数传播。再做一次常数折叠就变成 z:=20了。
- 死代码删除Ded code elimination
在上面的拷贝传播中如果没有其他地方使用y变量了那么第二行就是死代码就可以删除掉这种优化叫做死代码删除。
**最后我强调一下,**一个优化可能导致另一个优化比如拷贝传播导致y不再被使用我们又可以进行死代码删除的优化。所以一般进行多次优化、多次扫描。
了解了优化的场景之后,你能直观地知道代码优化到底做了什么事情,不过知其然还要知其所以然,你还需要了解这些优化都是怎么实现的。
## 如何做本地优化
上面这些优化场景,可以用于本地优化、全局优化和过程间优化。这节课我们先看看如何做本地优化,因为它相对简单,学习难度较低,下节课再接着讨论全局优化。
假设下面的代码是一个基本块(省略最后的终结指令):
```
a := b
c := a + b
c := b
d := a + b
e := a + b
```
为了优化它们我们的方法是计算一个“可用表达式available expression”的集合。可用表达式是指存在一个变量保存着某个表达式的值。
**我们从上到下顺序计算这个集合:**
1.一开始是空集。<br>
2.经过第一行代码后集合里增加了“a:=b”<br>
3.经过第二行代码后增加了“c:=a+b”。<br>
**4.注意,**在经过第三行代码以后由于变量c的定义变了所以“c:=a+b”不再可用而是换成了“c:=b”。
<img src="https://static001.geekbang.org/resource/image/ee/70/eeeff152fea3ede1b9bae3892bdc4070.jpg" alt="">
你能看到代码“e:=a+b”和集合中的“d:=a+b”等号右边部分是相同的所以我们首先可以**删除公共子表达式,**优化成“e:=d”。变成下面这样
<img src="https://static001.geekbang.org/resource/image/2f/94/2f3d1f14385efd1e6d336e962ddf5494.jpg" alt="">
然后,我们可以做一下**拷贝传播,**利用“a:=b”把表达式中的多个a都替换成b。
<img src="https://static001.geekbang.org/resource/image/2b/08/2b3e1177ce5d7f3e5f003df7c8980508.jpg" alt="">
到目前为止a都被替换成了b对e的计算也简化了优化后的代码变成了下面这样
```
a := b
c := b + b
c := b
d := b + b
e := d
```
观察一下这段代码,它似乎还存在可优化的空间,比如,会存在死代码,而我们可以将其删除。
假设在后序的基本块中b和c仍然会被使用但其他变量就不会再被用到了。那么上面这5行代码哪行能被删除呢这时我们要做另一个分析活跃性分析Liveness Analysis
我们说一个变量是活的意思是它的值在改变前会被其他代码读取。对于SSA格式的IR变量定义出来之后就不会再改变所以你只要看后面的代码有没有使用这个变量的就可以了我们会分析每个变量的活跃性把死的变量删掉。
**怎么做呢?**我们这次还是要借助一个集合,不过这个集合是从后向前,倒序计算的。
<img src="https://static001.geekbang.org/resource/image/1d/84/1d37597496a58e0e59e9748f13b6e884.jpg" alt="">
一开始集合里的元素是{b, c}这是初始值表示b和c会被后面的代码使用所以它们是活的。
- 扫描过“e := d”后因为用到了d所以d是活的结果是{b, c, d}。
- 再扫描“d := b + b”用到了b但集合里已经有b了这里给d赋值了已经满足了后面代码对d的要求所以可以从集合里去掉d了结果是{bc}。
- 再扫描“c := b”从集合里去掉c结果是{b}。
- 继续扫描,一直到第一行,最后的集合仍然是{b}。
现在,基于这个集合,我们就可以做死代码删除了。**当给一个变量赋值时,它后面跟着的集合没有这个变量,说明它不被需要,就可以删掉了。**图中标橙色的三行,都是死代码,都可以删掉。
<img src="https://static001.geekbang.org/resource/image/d9/42/d9161dc7dc88123948dace3e2d199042.jpg" alt="">
删掉以后,只剩下了两行代码。**注意,**由于“ e := d”被删掉了导致d也不再被需要变成了死变量。
<img src="https://static001.geekbang.org/resource/image/ca/65/caf9537c22f8c8d969746f1061ddbc65.jpg" alt="">
把变量d删掉以后就剩下了一行代码“c := b”了。
<img src="https://static001.geekbang.org/resource/image/89/c2/899dbdf21a4aa1661ef4cb46de1d3cc2.jpg" alt="">
到此为止我们完成了整个的优化过程5行代码优化成了1行代码成果是很显著的
**我来带你总结一下这个优化过程:**
<li>
我们首先做一个正向扫描,进行可用表达式分析,建立可用表达式的集合,然后参照这个集合替换公共子表达式,以及做拷贝传播。
</li>
<li>
接着,我们做一个反向扫描,进行活跃性分析,建立活变量的集合,识别出死变量,并依据它删除给死变量赋值的代码。
</li>
<li>
上述优化可能需要做不止一遍,才能得到最后的结果。
</li>
这样看来,优化并不难吧?当然了,目前我们做的优化是基于一段顺序执行的代码,没有跳转,都是属于一个基本块的,属于本地优化。
直观地理解了本地优化之后,我们可以把这种理解用**更加形式化的方式表达出来,**这样你可以理解得更加透彻。本地优化中可用表达式分析和活跃性分析都可以看做是由下面4个元素构成的
<li>
D方向。是朝前还是朝后遍历。
</li>
<li>
V。代码的每一个地方都要计算出一个值。可用表达式分析和活跃性分析的值是一个集合也有些分析的值并不是集合在下一讲你会看到这样的例子。
</li>
<li>
F转换函数对V进行转换。比如在做可用表达式分析的时候遇到了“c := b”时可用表达式的集合从{a := b, c := a + b}转换成了{a := b, c := b}。**这里遵守的转换规则是:**因为变量c被重新赋值了那么就从集合里把变量c原来的定义去掉并把带有c的表达式都去掉因为过去的c已经失效了然后把变量c新的定义加进去。
</li>
<li>
I初始值是算法开始时V的取值。做可用表达式分析的时候初始值是空集。在做活跃性分析的时候初始值是后面代码中还会访问的变量也就是活变量。
</li>
这样形式化以后我们就可以按照这个模型来统一理解各种本地优化算法。接下来我们来体验和熟悉一下LLVM的优化功能。
## 用LLVM来演示优化功能
在[25讲](https://time.geekbang.org/column/article/153192)中我们曾经用Clang命令带上O2参数来生成优化的IR
```
clang -emit-llvm -S -O2 fun1.c -o fun1-O2.ll
```
实际上LLVM还有一个单独的命令opt来做代码优化。缺省情况下它的输入和输出都是.bc文件所以我们还要在.bc和.ll两种格式之间做转换。
```
clang -emit-llvm -S fun1.c -o fun1.ll //生成LLVM IR
llc fun1.ll -o fun1.bc //编译成字节码
opt -O2 fun1.bc -o fun1-O2.bc //做O2级的优化
llvm-dis fun1-O2.bc -o fun1-O2.ll //将字节码反编译成文本格式
```
**其中要注意的一点,**是要把第一行命令生成的fun1.ll文件中的“optnone”这个属性去掉因为这个它的意思是不要代码优化。
我们还可以简化上述操作给opt命令带上-S参数直接对.ll文件进行优化
```
opt -S -O2 fun1.ll -o fun1-O2.ll
```
**另外,我解释一下-O2参数**-O2代表的是二级优化LLVM中定义了多个优化级别基本上数字越大所做的优化就越多。
我们可以不使用笼统的优化级别而是指定采用某个特别的优化算法比如mem2reg算法会把对内存的访问优化成尽量访问寄存器。
```
opt -S -mem2reg fun1.ll -o fun1-O2.ll
```
用opt --help命令可以查看opt命令所支持的所有优化算法。
对于常数折叠在调用API生成IR的时候LLVM缺省就会去做这个优化。比如下面这段代码是返回2+3的值但生成IR的时候直接变成了5因为这种优化比较简单不需要做复杂的分析
```
Function * codegen_const_folding(){
//创建函数
FunctionType *funType = FunctionType::get(Type::getInt32Ty(TheContext), false);
Function *fun = Function::Create(funType, Function::ExternalLinkage, &quot;const_folding&quot;, TheModule.get());
//创建一个基本块
BasicBlock *BB = BasicBlock::Create(TheContext, &quot;&quot;, fun);
Builder.SetInsertPoint(BB);
Value * tmp1 = ConstantInt::get(TheContext, APInt(32, 2, true));
Value * tmp2 = ConstantInt::get(TheContext, APInt(32, 3, true));
Value * tmp3 = Builder.CreateAdd(tmp1, tmp2);
Builder.CreateRet(tmp3);
return fun;
}
```
生成的IR如下
```
define i32 @const_folding() {
ret i32 5
}
```
**你需要注意,**很多优化算法,都是要基于寄存器变量来做,所以,我们通常都会先做一下-mem2reg优化。
在LLVM中做优化算法很方便因为它采用的是SSA格式。具体来说LLVM中定义了Value和User两个接口它们体现了LLVM IR最强大的特性即静态单赋值中的定义-使用链,这种定义-使用关系会被用到优化算法中。
在[26讲](https://time.geekbang.org/column/article/154438)中我们已经讲过了Value类。
如果一个类是从Value继承的意味着它定义了一个值。另一个类是User类函数和指令也是User类的子类也就是说在函数和指令中可以使用别的地方定义的值。
<img src="https://static001.geekbang.org/resource/image/43/40/43261470d69b33bb36930dfa698c4b40.jpg" alt="">
**这两个类是怎么帮助到优化算法中的呢?**
在User中可以访问所有它用到的Value比如一个加法指令%c = add nsw i32 %a, %b用到了a和b这两个变量。
而在Value中可以访问所有使用这个值的User比如给c赋值的这条指令。
所以你可以遍历一个Value的所有User把它替换成另一个Value这就是拷贝传播。
**接下来我们看看如何用程序实现IR的优化。**
在LLVM内部优化工作是通过一个个的Pass来实现的它支持三种类型的Pass
<li>
一种是分析型的PassAnalysis Passes只是做分析产生一些分析结果用于后序操作。
</li>
<li>
一些是做代码转换的Transform Passes比如做公共子表达式删除。
</li>
<li>
还有一类pass是工具型的比如对模块做正确性验证。你可以查阅LLVM所支持的[各种Pass。](https://llvm.org/docs/Passes.html)
</li>
下面的代码创建了一个PassManager并添加了两个优化Pass
```
// 创建一个PassManager
TheFPM = std::make_unique&lt;legacy::FunctionPassManager&gt;(TheModule.get());
// 窥孔优化和一些位计算优化
TheFPM-&gt;add(createInstructionCombiningPass());
// 表达式重关联
TheFPM-&gt;add(createReassociatePass());
TheFPM-&gt;doInitialization();
```
之后再简单地调用PassManager的run()方法,就可以对代码进行优化:
```
TheFPM-&gt;run(*fun);
```
你可以查看本讲附带的代码,尝试自己编写一些示例程序,查看优化前和优化后的效果。
## 课程小结
本节课我带你学习了代码优化的原理然后通过LLVM实践了一下演示了优化功能我希望你能记住几个关键点
1.代码优化分为本地优化、全局优化和过程间优化三个范围。有些优化对于这三个范围都是适用的,但也有一些优化算法是全局优化和过程间优化专有的。
2.可用表达式分析和活跃性分析是本地优化时的两个关键算法。这些算法都是由扫描方向、值、转换函数和初始值这四个要素构成的。
3.LLVM用pass来做优化你可以通过命令行或程序来使用这些Pass。你也可以编写自己的Pass。
最后我建议你多编写一些测试代码并用opt命令去查看它的优化效果在这个过程中增加对代码优化的感性认识。
## 一课一思
针对不同的领域(商业、科学计算、游戏等),代码优化的重点可能是不同的。针对你所熟悉的计算机语言和领域,你知道有哪些优化的需求?是采用什么技术实现的?欢迎在留言区分享你的观点。
最后,感谢你的阅读,如果这篇文章有所收获,也欢迎你将它分享给更多的朋友。

View File

@@ -0,0 +1,188 @@
<audio id="audio" title="28 | 数据流分析:你写的程序,它更懂" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a2/8d/a24a8b5371075824b3eec964ee244c8d.mp3"></audio>
上一讲,我提到了删除公共子表达式、拷贝传播等本地优化能做的工作,其实,这几个工作也可以在全局优化中进行。
只不过全局优化中的算法不会像在本地优化中一样只针对一个基本块。而是更复杂一些因为要覆盖多个基本块。这些基本块构成了一个CFG代码在运行时有多种可能的执行路径这会造成多路径下值的计算问题比如活跃变量集合的计算。
当然了,还有些优化只能在全局优化中做,在本地优化中做不了,比如:
- 代码移动code motion能够将代码从一个基本块挪到另一个基本块比如从循环内部挪到循环外部来减少不必要的计算。
- 部分冗余删除Partial Redundancy Elimination它能把一个基本块都删掉。
总之全局优化比本地优化能做的工作更多分析算法也更复杂因为CFG中可能存在多条执行路径。不过我们可以在上一节课提到的本地优化的算法思路上解决掉多路径情况下V值的计算问题。**而这种基于CFG做优化分析的方法框架就叫做数据流分析。**
本节课我会把全局优化的算法思路讲解清楚借此引入数据流分析的完整框架。而且在解决多路径情况下V值的计算问题时我还会带你学习一个数学工具半格理论。这样你会对基于数据流分析的代码优化思路建立清晰的认识从而有能力根据需要编写自己的优化算法。
## 数据流分析的场景:活跃性分析
[上一讲,](https://time.geekbang.org/column/article/155338)我已经讲了本地优化时的活跃性分析,那时,情况比较简单,你不需要考虑多路径问题。**而在做全局优化时,情况就要复杂一些:**代码不是在一个基本块里简单地顺序执行而可能经过控制流图CFG中的多条路径。我们来看一个例子例子由if语句形成了两条分支语句
<img src="https://static001.geekbang.org/resource/image/16/e2/16486275b06058985190f1a5ae51a6e2.jpg" alt="">
基于这个CFG我们可以做全局的活跃性分析从最底下的基本块开始倒着向前计算活跃变量的集合也就是从基本块5倒着向基本块1计算
**这里需要注意,**对基本块1进行计算的时候它的输入是基本块2的输出也就是{a, b, c}和基本块3的输出也就是{a, c},计算结果是这两个集合的并集{a, b, c}。也就是说基本块1的后序基本块有可能用到这三个变量。这里就是与本地优化不同的地方我们要基于多条路径来计算。
<img src="https://static001.geekbang.org/resource/image/c4/71/c453c9f74802eee6d98bdd813b66a271.jpg" alt="">
基于这个分析图我们马上发现y变量可以被删掉因为它前面的活变量集合{x}不包括y也就是不被后面的代码所使用并且影响到了活跃变量的集合。
<img src="https://static001.geekbang.org/resource/image/19/57/191329a421402539bff0babf41b9de57.jpg" alt="">
删掉y变量以后再继续优化一轮会发现d也可以删掉。
<img src="https://static001.geekbang.org/resource/image/c3/7c/c339a2653ab9ce296ada6a3f49c25a7c.jpg" alt="">
d删掉以后2号基本块里面已经没有代码了也可以被删掉**最后的CFG是下面这样**
<img src="https://static001.geekbang.org/resource/image/1d/31/1da42a4e00475f274281ecb1a702be31.jpg" alt="">
到目前为止我们发现全局优化总体来说跟本地优化很相似唯一的不同就是要基于多个分支计算集合的内容也就是V值。在进入基本块1时2和3两个分支相遇meet我们取了2和3V值的并集。**这就是数据流分析的基本特征,你可以记住这个例子,建立直观印象。**
但是上面这个CFG还是比较简单的因为它没有循环属于有向无环图。**这种图的特点是:**针对图中的每一个节点,我们总能找到它的前序节点和后序节点,所以我们只需要按照顺序计算就好了。但是如果加上了环路,就不那么简单了,来看一看下面这张图:
<img src="https://static001.geekbang.org/resource/image/a2/6e/a2aa2818e6890db5dc4ca2ee02bad36e.jpg" alt="">
基本块4有两个后序节点分别是5和1所以要计算4的活跃变量就需要知道5和1的输出是什么。5的输出好说但1的呢还没计算出来呢。因为要计算1就要依赖2和3从而间接地又依赖了4。**这样一来1和4是循环依赖的。**再进一步探究的话你发现其实1、2、3、4四个节点之间都是循环依赖的。
所以说一旦在CFG中引入循环回路严格的前后计算顺序就不存在了。**那你要怎么办呢?**
其实我们不是第一次面对这个处境了。在前端部分我们计算First和Follow集合的时候就会遇到循环依赖的情况只不过那时候没有像这样展开细细地分析。不过你可以回顾一下[17讲](https://time.geekbang.org/column/article/138385)和[18讲](https://time.geekbang.org/column/article/139628),那个时候你是用什么算法来破解僵局的呢?是不动点法。**在这里,我们还是要运用不动点法,具体操作是:**给每个基本块的V值都分配初始值也就是空集合。
<img src="https://static001.geekbang.org/resource/image/ee/ef/eead71f9e3ae1486465e8d6adcfc96ef.jpg" alt="">
然后对所有节点进行多次计算直到所有集合都稳定为止。第一遍的时候我们按照5-4-3-2-1的顺序计算实际上采取任何顺序都可以计算结果如下
<img src="https://static001.geekbang.org/resource/image/9e/63/9e4acf5bd72492306c230b11d6f6fd63.jpg" alt="">
如果现在计算就结束我们实际上可以把基本块2中的d变量删掉。但如果我们再按照5-4-3-2-1的顺序计算一遍就会往集合里增加一些新的元素在图中标的是橙色。**这是因为,**在计算基本块4的时候基本块1的输出{b, c, d}也会变成4的输入。这时我们发现进入基本块2时活变量集合里是含有d的所以d是不能删除的。
<img src="https://static001.geekbang.org/resource/image/54/cc/547bb7c93a63468b854a2b0d7188b7cc.jpg" alt="">
你再仔细看看这个d是哪里需要的呢**是基本块3需要的**它会跟1去要1会跟4要4跟2要。所以再次证明1、2、3、4四个节点是互相依赖的。
我们再来看一下,对于活变量集合的计算,当两个分支相遇的情况下,最终的结果我们取了两个分支的并集。
<img src="https://static001.geekbang.org/resource/image/28/fc/28c7218ee10c14ce2b121aa527191bfc.jpg" alt="">
在上一讲我们说一个本地优化分析包含四个元素方向D、值V、转换函数F和初始值I。在做全局优化的时候我们需要再多加一个元素就是两个分支相遇的时候要做一个运算计算他们相交的值这个运算我们可以用大写的希腊字母Λlambda表示。包含了D、V、F、I和Λ的分析框架**就叫做数据流分析。**
那么Λ怎么计算呢研究者们用了一个数学工具叫做“半格”Semilattice帮助做Λ运算。
## 直观地理解半格理论
如果要从数学理论角度完全把“半格”这个概念说清楚需要依次介绍清楚“格”Lattice、“半格”Semilattice和“偏序集”Partially Ordered Set等概念。我想这个可以作为爱好数学的同学的一个研究题目或者去向离散数学的老师求教。**在我们的课程里,我只是通过举例子,让你对它有直观的认识。**
首先,半格是一种偏序集。偏序集就是集合中只有部分成员能够互相比较大小。**举例来说会比较直观。**在做全局活跃性分析的时候,{a, b, c}和{a, c}相遇,产生的新值是{a, b, c}。我们形式化地写成{a, b, c} Λ {a, c} = {a, b, c}。
这时候我们说{a, b, c}是可以跟{a, c}比较大小的。那么哪个大哪个小呢?
>
如果XΛY=X我们说X&lt;=Y。
所以,{a, b, c}是比较小的,{a, c}是比较大的。
当然,{a, b, c}也可以跟{a, b}比较大小,但它没有办法跟{c, d}比较大小。所以把包含了{{a, b, c}、{a, c}、{a, b}、{c, d}…}这样的一个集合,叫做偏序集,它们中只有部分成员之间可以比较大小。哪些成员可以比较呢?就是下面的半格图中,可以通过有方向的线连起来的。
半格可以画成图形理解起来更直观假设我们的程序只有a, b, c三个变量那么这个半格画成图形是这样的
<img src="https://static001.geekbang.org/resource/image/d9/85/d9811d73fef1347e92fc3151fdd48485.jpg" alt="">
沿着上面图中的线,两个值是可以比较大小的,按箭头的方向依次减少:{}&gt;{a}&gt;{a, b}&gt; {a, b, c}。如果两个值之间没有一条路径,那么它们之间就是不能比较大小的,就像{a}和{b}就不能比较大小。
对于这个半格,我们把{}空集叫做TopTop大于所有的其他的值。而{a, b, c}叫做Bottom它是最小的值。
在做活跃性分析时我们的Λ运算是计算两个值的最大下界Greatest Lower Bound。怎么讲呢就是比两个原始值都小的值中取最大的那个。{a}和{b}的最大下界是{a, b}{a, b, c} 和{a, c}的最大下界就是{a, b, c} 。
<li>
如果一个偏序集中,任意两个元素都有最大下界,那么这个偏序集就叫做**交半格Meet Semilattice**
</li>
<li>
与此相对应的,如果集合中的每个元素都有**最小上界Least Upper Bound<strong>那么这个偏序集叫做**并半格Join Semilattice</strong>
</li>
<li>
如果一个偏序集既是交半格,又是并半格,我们说这个偏序集是一个格,示例的这个偏序集就是一个格。
</li>
你可能会奇怪,为什么要引入这么复杂的一套数学工具呢?不就是集合运算吗?两个分支相遇,就计算它们的并集,不就可以了吗?**事情没那么简单。**因为并不是所有的分析其V值都是一个集合就算是集合相交时的运算也不一定是求并集而有可能是求交集。
我们通过另一个案例来分析一下非集合的半格运算:**常数传播。**
## 数据流分析的场景:常数传播
常数传播就是如果知道某个变量的值是个常数那么就把用到这个变量的表达式都用常数去替换。看看下面的例子在基本块4中a的值能否用一个常数替代
<img src="https://static001.geekbang.org/resource/image/ec/0e/ecf6d32b7428d960654400ddd34be90e.jpg" alt="">
**答案是不能。**到达基本块4的两条路径一条a=3另一条a=4。我们不知道在实际运行的时候会从哪条路径过来所以这个时候a的取值是不确定的基本块4中的a无法用常数替换。
那么,运用数据流分析的框架怎么来做常数传播分析呢?
在这种情况下V不再是一个集合而是a可能取的常数值但a有可能不是一个常数啊所以我们再定义一个特殊的值TopT
除了T之外我们再引入一个与T对应的特殊值Bottom它的含义是某个语句永远不会被执行。总结起来常数传播时V的取值可能是3个
- 常数c
- Top意思是a的值不是一个常数
- Bottom某个语句不会被执行。
**这些值是怎么排序的呢?**最大的是Top中间各个常数之间是无法比较的Bottom是最小的。
<img src="https://static001.geekbang.org/resource/image/3e/ae/3e7cf0f8d1052d125ada693afee96aae.jpg" alt="">
接下来我们看看如何计算多个V值相交的值。
我们再把计算过程形式化一下。在这个分析中当我们经过每个语句的时候V值都可能发生变化我们用下面两个函数来代表不同地方的V值
- C(a, s, in)。表示在语句s之前a的取值比如C(a, b:=a+2, in) = 3。
- C(a, s, out)。表示在语句s之后a的取值比如C(a, a:=4, out) = 4。
如果s的前序有i条可能的路径那么多个输出和一个输入“C(a, si, out)和C(a, s, in)”的关系,可以制定一系列规则:
<img src="https://static001.geekbang.org/resource/image/cf/8e/cf28a8e40983204c6d0381197b471e8e.jpg" alt="">
1.如果有一条输入路径是Top或者说C(a, si, out)是Top那么结果C(a, s, in)就是Top。
2.如果输入中有两个不同的常数比如3和4那么结果也是Top我们的示例就是这种情况
3.如果所有的输入都是相同的常数或Bottom那么结果就是该常数。如果所有路径a的值都是3那么这里就可以安全地认为a的值是3。那些Bottom路径不影响因为整条路径不会执行。
4.如果所有的输入都是Bottom那么结果也是Bottom。
**上面的这4个规则就是一套半格的计算规则。**
在这里我们也可以总结一下它的转换规则也就是F考虑一下某个Statement的in值和out值的关系也就是经过该Statement以后V值会有啥变化
<img src="https://static001.geekbang.org/resource/image/03/a2/0344859185c57f3cd6f7bbb83f364fa2.jpg" alt="">
1.如果输入是Bottom那么输出也是Bottom。也就是这条路径不会经过。<br>
2.如果该Statement就是“ a := 常数”,那么输出就是该常数。<br>
3.如果该Statement是a赋予的一个比较复杂的表达式而不是常数那么输出就是Top。<br>
4.如果该Statement不是对a赋值的那么V值保持不变。
好了转换函数F也搞清楚了。初始值I是什么呢是Top因为一开始的时候a还没有赋值所以不会是常数方向D是什么呢D是向下。**这个时候D、V、F、I和Λ5个元素都清楚了我们就可以写算法实现了。**
## 课程小结
本节课,我们基于全局优化分析的任务,介绍了数据流分析这个框架,并且介绍了半格这个数学工具。**我希望你在本讲记住几个要点:**
<li>
全局分析比本地分析多处理的部分就是CFG因为有了多条执行分支所以要计算分支相遇时的值当CFG存在环路的时候要用不动点法来计算出所有的V值。
</li>
<li>
数据流分析框架包含方向D、值V、转换函数F、初始值I和交运算Λ5个元素只要分析清楚这5个元素就可以按照固定的套路来编写分析程序。
</li>
<li>
对于半格理论,关键是要知道如何比较偏序集中元素的大小,理解了这个核心概念,那么求最大下界、最小上界这些也就没有问题了。
</li>
**数据流分析也是一个容易让学习者撞墙的知识点,**特别是再加上“半格”这样的数学术语的时候。不过,我们通过全局活跃性分析和全局常数传播的示例,对“半格”的抽象数学概念建立了直觉的理解。遇到全局分析的任务,你也应该能够比照这两个示例,设计出完整的数据流分析的算法了。**不过我建议你,**还是要按照上一讲中对LLVM优化功能的介绍多做几个例子实验一下。
## 一课一思
如果我们想做一个全局分析用于删除公共子表达式它的数据流分析框架应该是怎样的也就是D、V、F、I和Λ各自应该如何设计呢欢迎分享你的想法。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。

View File

@@ -0,0 +1,240 @@
<audio id="audio" title="29 | 目标代码的生成和优化(一):如何适应各种硬件架构?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c5/dd/c56a40e80036f8cd4f63709deca4bedd.mp3"></audio>
在编译器的后端,我们要能够针对不同的计算机硬件,生成优化的代码。在[23讲](https://time.geekbang.org/column/article/150798),我曾带你试着生成过汇编代码,但当时生成汇编代码的逻辑是比较幼稚的,一个正式的编译器后端,代码生成部分需要考虑得更加严密才可以。
那么具体要考虑哪些问题呢?**其实主要有三点:**
<li>
指令的选择。同样一个功能,可以用不同的指令或指令序列来完成,而我们需要选择比较优化的方案。
</li>
<li>
寄存器分配。每款CPU的寄存器都是有限的我们要有效地利用它。
</li>
<li>
指令重排序。计算执行的次序会影响所生成的代码的效率。在不影响运行结果的情况下,我们要通过代码重排序获得更高的效率。
</li>
我会用两节课的时间带你对这三点问题建立直观认识然后我还会介绍LLVM的实现策略。这样一来你会对目标代码的生成建立比较清晰的顶层认知甚至可以尝试去实现自己的算法。
接下来,我们针对第一个问题,聊一聊为什么需要选择指令,以及如何选择指令。
## 选择正确的指令
你可能会问:我们为什么非要关注指令的选择呢?我来做个假设。
如果我们不考虑目标代码的性能可以按照非常机械的方式翻译代码。比如我们可以制定一个代码翻译的模板把形如“a := b + c”的代码都翻译成下面的汇编代码
```
mov b, r0 //把b装入寄存器r0
add c, r0 //把c加到r0上
mov r0, a //把r0存入a
```
那么,下面两句代码:
```
a := b + c
d := a + e
```
将被机械地翻译成:
```
mov b, r0
add c, r0
mov r0, a
mov a, r0
add e, r0
mov r0, d
```
你可以从上面这段代码中看到第4行其实是多余的因为r0的值就是a不用再装载一遍了。另外如果后面的代码不会用到a也就是说a只是个临时变量那么第3行也是多余的。
这种算法很幼稚,正确性没有问题,但代码量太大,代价太高。所以我们最好用聪明一点儿的算法来生成更加优化的代码。**这是我们要做指令选择的原因之一。**
**做指令选择的第二个原因是,**实现同一种功能可以使用多种指令特别是CISC指令集可替代的选择很多但各自有适用的场景
对于某个CPU来说完成同样的任务可以采用不同的指令。比如实现“a := a + 1”可以生成三条代码
```
mov a, r0
add $1, r0
mov r0, a
```
也可以直接用一行代码采用inc指令而我们要看看用哪种方法总体代价最低
```
inc a
```
第二个例子把r0寄存器置为0也可以有多个方法
```
mov $0, r0 //赋值为立即数0
xor r0, r0 //异或操作
sub r0, r0 //用自身的值去减
...
```
再比如a * 7可以用 a&lt;&lt;3 - a实现首先移位3位相当于乘8然后再减去一次a就相当于乘以7。虽然用了两条指令但是可能消耗的总的时钟周期更少。
**在这里我想再次强调一下,**无论是为了生成更简短的代码,还是从多种可能的指令中选择最优的,我们确实需要关注指令的选择。那么,我们做指令选择的思路是什么呢?目前最成熟的算法都是基于树覆盖的方法,我通过一个例子带你了解一下,**什么是树覆盖算法。**
a[i] = b这个表达式的意思是给数组a的第i个元素赋值为b。假设a和b都是栈里的本地变量i是放在寄存器ri中。这个表达式可以用一个AST表示。
<img src="https://static001.geekbang.org/resource/image/4f/a1/4f732f78dfe9cebb4265c26c5dc3ffa1.jpg" alt="">
你可能觉得这棵树看着像AST但又不大像那是因为里面有mem节点意思是存入内存、mov节点、栈指针(fp)。**它可以算作低级low-levelAST是一种IR的表达方式有时被称为结构化IR。**这个AST里面包含了丰富的运行时的细节信息相当于把LLVM的IR用树结构来表示了。你可以把一个基本块的指令都画成这样的树状结构。
基于这棵树,我们可以翻译成汇编代码:
```
load M[fp+a], r1 //取出数组开头的地址放入r1fp是栈桢的指针a是地址的偏移量
addi 4, r2 //把4加载到r2
mul ri, r2 //把ri的值乘到r2上即i*4即数组元素的偏移量每个元素4字节
add r2, r1 //把r2加到r1上也就是算出a[i]的地址
load M[fp+b], r2 //把b的值加载到r2寄存器
store r2, M[r1] //把r2写入地址为r1的内存
```
在这里我用了一种假想的汇编代码跟LLVM IR有点儿像但更简化、易读
<img src="https://static001.geekbang.org/resource/image/04/7f/04c989734ec56c979db6002144d6417f.jpg" alt="">
**注意,**我们生成的汇编代码还是比较精简的。如果采用比较幼稚的方法,逐个树节点进行翻译,代码会很多,你可以手工翻译试试看。
用树覆盖的方法可以大大减少代码量,其中用橙色的线包围的部分被形象地叫做**一个瓦片(tiling)**那些包含了操作符的瓦片,就可以转化成一条指令。每个瓦片可以覆盖多个节点,所以生成的指令比较少。
<img src="https://static001.geekbang.org/resource/image/82/09/826353008c1523120ae46439ca5b0f09.jpg" alt="">
那我们是用什么来做瓦片的呢原来每条机器指令都会对应IR的一些模式Pattern可以表示成一些小的树而这些小树就可以当作瓦片
<img src="https://static001.geekbang.org/resource/image/8f/54/8fcf946ca8f73f351ff7c2050e71bc54.jpg" alt="">
我们的算法可以遍历AST遇到上面的模式就可以生成对应的指令。**以load指令为例它有几个模式**任意一个节点加上一个常量就行这相当于汇编语言中的间接地址访问或者mem下直接就是一个常量就行这相当于是直接地址访问。最后地址值还可以由下级子节点计算出来。
所以从一棵AST生成代码的过程就是用上面这些小树去匹配一棵大树并把整个大树覆盖的过程所以叫做树覆盖算法。2、4、5、6、8、9这几个节点依次生成汇编代码。
要注意的是,覆盖方式可能会有多个,比如下面这个覆盖方式,相比之前的结果,**它在8和9两个瓦片上是有区别的**
<img src="https://static001.geekbang.org/resource/image/0d/25/0d631b14c3f3d4e15bfb373bb191bc25.jpg" alt="">
生成的汇编代码最后两句也不同:
```
load M[fp+a], r1 //取出数组开头的地址放入r1fp是栈桢的指针a是地址的偏移量
addi 4, r2 //把4加载到r2
mul ri, r2 //把ri的值乘到r2上即i*4即数组元素的偏移量每个元素4字节
add r2, r1 //把r2加到r1上也就是算出a[i]的地址
addi fp+b, r2 //把fp+b的值加载到r2寄存器
movm M[r2], M[r1] //把地址为r2到值拷贝到地址为r1内存里
```
你可以体会一下,这两个覆盖方式的差别:
<li>
对于瓦片8中的加法运算一个当做了间接地址的计算一个就是当成加法
</li>
<li>
对于根节点的操作一个翻译成从store把寄存器中的b的值写入到内存。一个翻译成movm指令直接在内存之间拷贝值。至于这两种翻译方法哪种更好比较总体的性能哪个更高就行了。
</li>
到目前为止你已经直观地了解了为什么要进行指令选择以及最常用的树覆盖方法了。当然了树覆盖算法有很多比如Maximal Munch算法、动态规划算法、树文法等LLVM也有自己的算法。
**简单地说一下Maximal Munch算法。**Maximal Munch直译成中文是每次尽量咬一大口的意思。具体来说就是从树根开始每次挑一个能覆盖最多节点的瓦片这样就形成几棵子树。对每棵子树也都用相同的策略这样会使得生成的指令是最少的。注意指令的顺序要反过来按照深度优先的策略先是叶子再是树根。这个算法是Optimal的算法。
Optimal被翻译成最佳我不太赞正这种翻译方法翻译成“较优”会比较合适它指的是在局部相邻的两个瓦片不可能连接成代价更低的瓦片。覆盖算法除了Optimal的还有Optimum的Optimum是全局最优化的状态就是代码总体的代价是最低的。
关于其他算法的细节在本节课就不展开了,因为根据我的经验,在学指令选择时,最重要的还是建立图形化的、直观的理解,理解什么是瓦片,如何覆盖会得到最优的结果。
接下来,我们继续探讨开篇提到的第二个问题:寄存器分配。
## 分配寄存器
寄存器优化的任务是:最大程度地利用寄存器,但不要超过寄存器总数量的限制。
因为我们生成IR时是不知道目标机器的信息的也就不知道目标机器到底有几个寄存器可以用所以我们在IR中可以使用无限个临时变量每个临时变量都代表一个寄存器。
现在既然要生成针对目标机器的代码也就知道这些信息了那么就要把原来的IR改写一下以便使用寄存器时不超标。
那么寄存器优化的原理是什么呢?**我用一个例子带你了解一下。**
下图左边的IR中a、d、f这三个临时变量不会同时出现。假设a和d在这个代码块之后成了死变量那么这三个变量可以共用同一个寄存器就像右边显示的那样
<img src="https://static001.geekbang.org/resource/image/fa/6a/fa047ba1f0d83d048b06f94d9cdcb36a.jpg" alt="">
实际上这三行代码是对“b + c + e + 10”这个表达式的翻译所以a和d都是在转换为IR时引入的中间变量用完就不用了。这和在23讲我们把8个参数以及一个本地变量相加时只用了一个寄存器来一直保存累加结果是一样的。
所以,通过这个例子,**你可以直观地理解寄存器共享的原则:**如果存在两个临时变量a和b它们在整个程序执行过程中最多只有一个变量是活跃的那么这两个变量可以共享同一个寄存器。
在[27](https://time.geekbang.org/column/article/155338)和[28讲](https://time.geekbang.org/column/article/156878)中,你已经学过了如何做变量的活跃性分析,所以你可以很容易分析出,在任何一个程序点,活跃变量的集合。然后,你再看一下,哪些变量从来没有出现在同一个集合中就行。**看看下面的这个图:**
<img src="https://static001.geekbang.org/resource/image/cb/08/cb7f92bdd1b8b280cc05fdbda5931308.jpg" alt="">
上图中凡是出现在同一个花括号里的变量都不能共享寄存器因为它们在某个时刻是同时活跃的。那a到f哪些变量从来没碰到过呢我们再画一个图来寻找一下。
下图中,每个临时变量作为一个节点,如果两个变量同时存在过,就画一条边。这样形成的图,叫做寄存器干扰图(Register Interference Graph, RIG)。在这张图里凡是没有连线的两个变量就可以分配到同一个寄存器例如a和bb和da和db和ea和e。
<img src="https://static001.geekbang.org/resource/image/45/47/4568f4898523c5cfbf03799ced3cbb47.jpg" alt="">
**那么问题来了:**针对这个程序,我们一共需要几个寄存器?怎么分配呢?
**一个比较常用的算法是图染色算法:**只要两个节点之间有连线节点就染成不同的颜色。最后所需要的最少颜色就是所需要的寄存器的数量。我画了两个染色方案都是需要4种颜色
<img src="https://static001.geekbang.org/resource/image/bc/b5/bc48864acb35432ba68b67918c9f33b5.jpg" alt="">
不过我们是手工染色的那么如何用算法来染色呢假如一共有4个寄存器我们想用算法知道寄存器是否够用**应该如何染色?**
染色算法很简单。如果想知道k个寄存器够不够用你只需要找到一个少于k条边的节点把它从图中去掉。接着再找下一个少于k条边的节点再去掉。如果最后整个图都被删掉了那么这个图一定可以用k种颜色来染色。
<img src="https://static001.geekbang.org/resource/image/c7/18/c7e3d74bd9dfb74ef08e65a50a711f18.jpg" alt="">
**为什么呢?**因为如果一个图蓝色边的是能用k种颜色染色的那么再加上一个节点它的边的数量少于k个比如是n那么这个大一点儿的图橙色边的还是可以用k种颜色染色的。道理很简单因为加进来的节点的边数少于k个所以一定能找到一个颜色与这个点的n个邻居都不相同。
所以,我们把刚才一个个去掉节点的顺序反过来,把一个个节点依次加到图上,每加上一个,就找一个它的邻居没有用的颜色来染色就行了。整个方法简单易行。
但是如果所需要寄存器比实际寄存器的数量多该怎么办呢当然是用栈了。这个问题就是寄存器溢出Register Spilling溢出到栈里去我在[21讲](https://time.geekbang.org/column/article/146635)关于运行时机制时提到过,像本地变量、参数、返回值等,都尽量用寄存器,如果寄存器不够用,那就放到栈里。另外再说一下,无论放在寄存器里,还是栈里,都是活动记录的组成部分,所以活动记录这个概念比栈桢更广义。
**还是拿上面的例子来说,**如果只有3个寄存器那么要计算一下3个寄存器够不够用。我们先把a和b从图中去掉
<img src="https://static001.geekbang.org/resource/image/d6/18/d69bb8a9362cc35bf4136fa015ab2c18.jpg" alt="">
这时你发现剩下的4个节点每个节点都有3个邻居。所以3个寄存器肯定不够用必须要溢出一个去。我们可以选择让f保存在栈里把f去掉以后剩下的cde可以用3种颜色成功染色。
这就结束了吗当然没有。f虽然被保存到了栈里但每次使用它的时候都要load到一个临时变量也就是寄存器中。每次保存f也都要用一个临时变量写入到内存。所以我们要把原来的代码修改一下把每个使用f的地方都加上一条load或save指令以便在使用f的时候把f放到寄存器用完后再写回内存。**修改后的CFG如下**
<img src="https://static001.geekbang.org/resource/image/f7/cb/f7374940932e5fade63ac3632bed23cb.jpg" alt="">
因为原来有4个地方用到了f所以我们引入了f1到f4四个临时变量。这样的话总的临时变量反而变多了从6个到了9个。不过没关系虽然临时变量更多了但这几个临时变量的生存期都很短图里带有f的活跃变量集合比之前少多了。所以即使有9个临时变量也能用三种颜色染色如下图所示
<img src="https://static001.geekbang.org/resource/image/c0/2a/c03659bb2d1e989d8bf6a4b00c86e02a.jpg" alt="">
最后,在选择把哪个变量溢出的时候,你实际上是要有所选择的。你最好选择使用次数最少的变量。在程序内循环中的变量,就最好不要溢出,因为每次循环都会用到它们,还是放在寄存器里性能更高。
目前为止代码生成中的第二项重要工作分配寄存器就概要地讲完了。我留给你一段时间消化本节课的内容在下一讲我会接着讲指令重排序和LLVM的实现。
## 课程小结
目标代码生成过程中有三个关键知识点:指令选择、寄存器分配和指令重排序,本节课,我讲了前两个,期望能帮你理解这两个问题的实质,让你对指令选择和寄存器分配这两个问题建立直观理解。这样你再去研究不同的算法时,脑海里会有这两个概念的顶层的、图形化的认识,事半功倍。与此同时,本节课我希望你记住几个要点如下:
<li>
相同的IR可以由不同的机器指令序列来实现。你要理解瓦片为什么长那个样子并且在大脑里建立用瓦片覆盖一棵AST的直观印象最好具备多种覆盖方式从而把这个问题由抽象变得具象。
</li>
<li>
寄存器分配是编译器必须要做的一项工作它把可以使用无限多寄存器的IR变成了满足物理寄存器数量的IR超出的要溢出到内存中保管。染色算法是其中一个可行的算法。
</li>
## 一课一思
关于指令选择,你是否知道其他的例子,让同一个功能可以用不同的指令实现?欢迎在留言区分享你的经验。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。

View File

@@ -0,0 +1,226 @@
<audio id="audio" title="30 | 目标代码的生成和优化(二):如何适应各种硬件架构?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/14/5b/141d10c21ecf2a2a59f9f1518909e65b.mp3"></audio>
前一讲,我带你了解了指令选择和寄存器分配,本节课我们继续讲解目标代码生成的,第三个需要考虑的因素:**指令重排序Instruction Scheduling。**
我们可以通过重新排列指令,让代码的整体执行效率加快。那你可能会问了:就算重新排序了,每一条指令还是要执行啊?怎么就会变快了呢?
别着急本节课我就带你探究其中的原理和算法来了解这个问题。而且我还会带你了解LLVM是怎么把指令选择、寄存器分配、指令重排序这三项工作组织成一个完整流程完成目标代码生成的任务的。这样你会对编译器后端的代码生成过程形成完整的认知为正式做一些后端工作打下良好的基础。
首先,我们来看看指令重排序的问题。
## 指令重排序
如果你有上面的疑问其实是很正常的。因为我们通常会把CPU看做一个整体把CPU执行指令的过程想象成依此检票进站的过程改变不同乘客的次序并不会加快检票的速度。所以我们会自然而然地认为改变顺序并不会改变总时间。
但当我们进入CPU内部会看到CPU是由多个功能部件构成的。下图是Ice Lake微架构的CPU的内部构成从[Intel公司的技术手册](https://software.intel.com/sites/default/files/managed/9e/bc/64-ia-32-architectures-optimization-manual.pdf)中获取):
<img src="https://static001.geekbang.org/resource/image/d5/72/d542a9f16a9153cf7ddd1d85b83af172.png" alt="">
在这个结构中,一条指令执行时,要依次用到多个功能部件,分成多个阶段,虽然每条指令是顺序执行的,但每个部件的工作完成以后,就可以服务于下一条指令,从而达到并行执行的效果。这种结构叫做**流水线pipeline结构。**我举例子说明一下比如典型的RISC指令在执行过程会分成前后共5个阶段。
- IF获取指令
- ID或RF指令解码和获取寄存器的值
- EX执行指令
- ME或MEM内存访问如果指令不涉及内存访问这个阶段可以省略
- WB写回寄存器。
对于CISC指令CPU的流水线会根据指令的不同分成更多个阶段比如7个、10个甚至更多。
在执行指令的阶段不同的指令也会由不同的单元负责我们可以把这些单元叫做执行单元比如Intel的Ice Lake架构的CPU有下面这些执行单元
<img src="https://static001.geekbang.org/resource/image/24/2b/2401aa716a0c74399de1659b3354662b.jpg" alt="">
其他执行单元还有BM、Vec ALU、Vec SHFT、Vec Add、Vec Mul、Shuffle等。
因为CPU内部存在着多个功能单元所以在同一时刻不同的功能单元其实可以服务于不同的指令看看下面这个图
<img src="https://static001.geekbang.org/resource/image/a4/cc/a4dd7af42c3584583feaaee0745612cc.jpg" alt="">
这样的话,多条指令实质上是并行执行的,从而减少了总的执行时间,这种并行叫做**指令级并行:**
<img src="https://static001.geekbang.org/resource/image/a3/00/a35bdd36774d6b901f3f7b49f3ef4000.jpg" alt="">
如果没有这种并行结构,或者由于指令之间存在依赖关系,无法并行,那么执行周期就会大大加长:
<img src="https://static001.geekbang.org/resource/image/88/ef/882d6476ba5c9e68396e7d9f5b319fef.jpg" alt="">
**我们来看一个实际的例子。**
**为了举例子方便,我们做个假设:**假设load和store指令需要3个时钟周期来读写数据add指令需要1个时钟周期mul指令需要2个时钟周期。
图中橙色的编号是原来的指令顺序绿色的数字是每条指令开始时的时钟周期你把每条指令的时钟周期累计一下就能算出来。最后一条指令开始的时钟周期是20该条指令运行需要3个时钟周期所以在第22个时钟周期执行完所有的指令。右边是重新排序后的指令一共花了13个时钟周期。**这个优化力度还是很大的!**
<img src="https://static001.geekbang.org/resource/image/41/1a/4141c409e10c26acb3642ffde72a171a.jpg" alt="">
仔细看一下左边前两条指令这两条指令的意思是先加载数据到寄存器然后做一个加法。但加载需要3个时钟周期所以add指令无法执行只能干等着。
右列的前三条都是load指令它们之间没有数据依赖关系我们可以每个时钟周期启动一个到了第四个时钟周期每一条指令的数据已经加载完毕所以就可以执行加法运算了。
我们可以把右边的内容画成下面的样子,你能看到,很多指令在时钟周期上是重叠的,**这就是指令级并行的特点。**
<img src="https://static001.geekbang.org/resource/image/3a/29/3a5274e2e422d64237d846496ab7a629.jpg" alt="">
当然了不是所有的指令都可以并行最后的3条指令就是顺序执行的导致无法并行的原因有几个
- 数据依赖约束
如果后一条指令要用到前一条指令的结果那必须排在它后面比如下面两条指令add和mul。
对于第二条指令来说除了获取指令的阶段IF可以和第一条指令并行以外其他阶段需要等第一条指令的结果写入r1第二条指令才可以使用r1的值继续运行。
```
add r2, r1
mul r3, r1
```
<img src="https://static001.geekbang.org/resource/image/26/69/263002b235228a1558f03ca3d950ab69.jpg" alt="">
- 功能部件约束
如果只有一个乘法计算器,那么一次只能执行一条乘法运算。
<img src="https://static001.geekbang.org/resource/image/b7/71/b71572487c156634271b72c8b5bad071.jpg" alt="">
- 指令流出约束
指令流出部件一次流出n条指令。
- 寄存器约束
寄存器数量有限,指令并行时使用的寄存器不可以超标。
后三者也可以合并成为一类,称作资源约束。
在数据依赖约束中,如果有因为使用同一个存储位置,而导致不能并行的,可以用重命名变量的方式消除,这类约束被叫做伪约束。而先写再写,以及先读后写是伪约束的两种呈现方式:
<li>
先写再写如果指令A写一个寄存器或内存位置B也写同一个位置就不能改变A和B的执行顺序不过我们可以修改程序让A和B写不同的位置。
</li>
<li>
先读后写如果A必须在B写某个位置之前读某个位置那么不能改变A和B的执行顺序。除非能够通过重命名让它们使用不同的位置。
</li>
以上就是指令重排序的原理,掌握这个原理你就明白为什么重排序可以提升性能了,**不过明白原理之后,我们还有能够用算法实现出来才行。**
用算法排序的关键点,是要找出代码之间的数据依赖关系。下图展现了示例中各行代码之间的数据依赖,可以叫做**数据的依赖图dependence graph。**它的边代表了值的流动比如a行加载了一个数据到r1b行利用r1来做计算所以b行依赖a行这个图也可以叫做优先图precedence graph因为a比b优先b比d优先。
<img src="https://static001.geekbang.org/resource/image/fe/c7/fea031f5e118c90910ff8d9a1149afc7.jpg" alt="">
我们可以给图中的每个节点再加上两个属性,利用这两个属性,就可以对指令进行排序了:
- 一是操作类型,因为这涉及它所需要的功能单元。
- 二是时延属性,也就是每条指令所需的时钟周期。
图中的a、c、e、g是叶子它们没有依赖任何其他的节点所以尽量排在前面。b、d、f、h必须出现在各自所依赖的节点后面。而根节点i总是要排在最后面。
根据时延属性我们计算出了每个节点的累计时延每个节点的累计时延等于父节点的累计时延加上本节点的时延。其中a-b-d-f-h-i 路径是关键路径,代码执行的最少时间就是这条路径所花的时钟周期之和。
<img src="https://static001.geekbang.org/resource/image/3e/a0/3eed222cc7b0beb7fb0a9011e6795ea0.jpg" alt="">
因为a在关键路径上所以首先考虑把a节点排在第1行。
<img src="https://static001.geekbang.org/resource/image/da/ea/da26ecdc8469a6b7bb5c10337e17fcea.jpg" alt="">
剩下的树中c-d-f-h-i变成了关键路径因为c的累计时延最大。c节点可以排在第2行。
<img src="https://static001.geekbang.org/resource/image/d4/73/d411f2990a11676a3765e4c269d9d073.jpg" alt="">
b和e的累计时延都是最长的但由于b必须在a执行完毕后才会开始执行所以最好等够a的3个时钟周期否则还是会空等所以先不考虑b而是把e放到第3行。
<img src="https://static001.geekbang.org/resource/image/21/ed/21eaa664cd7463824bc17f9e48409fed.jpg" alt="">
继续按照这个方式排最后可以获得a-c-e-b-d-g-f-h-i的指令序列。不过这个代码其实还可以继续优化也就是发现并消除其中的伪约束。
c和e都向r2里写了值而d使用的是c写入的值。如果修改变量名称比如让e使用r3寄存器我们就可以去掉e跟d以及e与c之间伪约束让e就可以排在c和d之前。同理也可以让g使用r4寄存器使得g可以排在e和f的前面。当然了在这个示例中这种改变并没有减少总的时间消耗因为关键路径上的依赖没有变化它们都使用了r1寄存器。但在别的情况下就有可能带来更大的优化。
<img src="https://static001.geekbang.org/resource/image/28/d5/28062620b1e1662a5804032704b162d5.jpg" alt="">
我们刚才其实是采用了一种最常见的算法List Scheduling算法**大致分为4步**
1.把变量重命名来消除伪约束(可选步骤)。<br>
2.创建依赖图。<br>
3.为每行代码计算优先值(计算方法可以有很多,比如我们示例中基于最长时延的方法就是一种)。<br>
4.迭代处理代码并排序。
除了List Scheduling算法以外还有其他的算法这里我就不展开了。不过讲到算法时我们需要考虑算法的复杂度。前一讲讲算法时我没有提这个问题是想在这里集中讲一下。
这两节课中关于指令选择、寄存器分配和指令重排序的算法其难度时间复杂度都是“NP-完全”的。“NP-完全”是什么意思呢?也就是这类问题找不到一个随规模(代码行数)计算量增长比较慢的算法(多项式时间算法)来找到最优解。反之,有可能计算量会随着代码行数呈指数级上升。因此,编译原理中的一些难度最高的算法,都在代码生成这一环。
当然了,找最优解太难,我们可以退而求其次,找一个次优解。就比如我们用地图软件导航的时候,没必要要求导航路径每次都是找到最短的。这时,就会有比较简单的算法,计算量不会随规模增长太快,但结果还比较理想。**我们这两讲的算法都是这个性质的。**
到目前为止我带你了解了目标代码生成的三大考虑因素指令选择、寄存器分配和指令重排序。现在我们来看看目标代码生成在LLVM中是如何实现的这样你能从概念过渡到实操从而把知识点掌握得更加扎实。
## LLVM的实现
LLVM的后端需要多个处理步骤来生成目标代码
<img src="https://static001.geekbang.org/resource/image/79/fd/79bde8c10be1eaea92a70890dbea56fd.jpg" alt="">
图中橙色的部分是重要的步骤它本身包含了多个Pass所以也叫做超级Pass。图中蓝框的Pass是用来做一些额外的优化处理关于LLVM的Pass机制我在27讲提到过如果你忘记了可以回顾一下
接下来我来讲解一下LLVM生成目标代码的关键步骤。
- 指令选择
LLVM的指令选择算法是基于DAG有向无环图的树模式匹配与前一讲基于AST的算法有一些区别但总思路是一致的具体算法描述可以参见[这篇论文](http://www.llvm.org/pubs/2008-CGO-DagISel.pdf)。这个算法是Near-Optimal接近Optimal能够在线性的时间内完成指令的选择并且它特别关注产生的代码的尺寸要求尺寸足够小。
DAG是融合了公共子表达式的AST也是一种结构化的IR。下面两行代码对应的AST和DAG分如图所示你能看到DAG把a=5这棵子树给融合了
```
a = 5
b = (2 + a+ (a * 3)
```
<img src="https://static001.geekbang.org/resource/image/25/d0/2515d15395bdf611a2a13a26dadf26d0.jpg" alt="">
LLVM把内存中的IR模型转化成了一个体现了某个目标平台特征的SelectionDAG用于做指令选择。每个基本块转化成一个DAGDAG中的节点通常代表指令边代表指令之间的数据流动。
在这个阶段之后LLVM会把DAG中的LLVM IR节点全部转换成目标机器的节点代表目标机器的指令而不是LLVM的指令。
- 指令排序(寄存器分配之前)
基于前一步的处理结果我们要对指令进行排序。但因为DAG不能反映没有依赖关系的节点之间的排序所以LLVM要先把DAG转换成一种三地址模式这个格式叫做MachineInstr。这个阶段会把指令排序并尽量发挥指令级并行的能力。
- 寄存器分配
接下来做寄存器的分配。LLVM的IR支持无限多的寄存器在这个环节要分配到实际的寄存器上分配不下的就溢出到内存。
- 指令排序(寄存器分配之后)
分配完寄存器之后LLVM会再做一次指令排序。因为寄存器分配会指定确定的寄存器而访问不同的寄存器的时钟周期可能是不同的。对于溢出到内存中的变量也增加了一些指令在内存和寄存器之间传输数据。利用这些信息LLVM可以进一步优化指令的排序。
- 代码输出
做完上面的所有工作后,就可以输出目标代码了。
LLVM在这一步把MachineInstr格式转换为MCInst格式因为后者更有利于汇编器和链接器输出汇编代码或二进制目标代码。
**在这里,我想延伸一下,和你探讨一个问题:**如果现在有一个新的CPU架构要实现一个崭新的后端来支持各种语言应该怎么做。
在我国大力促进芯片研发的背景下这是一个值得探讨的问题新芯片需要编译器的支持才可以呀。你要实现各种指令选择的算法、寄存器分配的算法、指令排序的算法来反映这款CPU的特点。
对于这个难度颇高的任务LLVM的TableGen模块会给你提供很大的帮助。这个模块能够帮助你为某个新的CPU架构快速生成后端。你可以用一系列配置文件定义你的CPU架构的特征比如寄存器的数量、指令集等等。
一旦你定义了这些信息TableGen模块就能根据这些配置信息生成各种算法如指令选择器、指令排序器、一些优化算法等等。这就像编译器前段工具可以帮你生成词法分析器和语法分析器一样能够大大降低开发一个新后端的工作量所以说把LLVM研究透彻会有助于你在这样的重大项目中发挥重要作用。
## 课程小结
本节课,我讲解了目标代码生成的第三个主题:指令重排序。
要理解这个主题你首先要知道CPU内部是分成多个功能部件的要知道一条指令的执行过程中指令获取、解码、执行、访问数据都是如何发生的这样你会知道指令级并行的原理。
其次从算法角度你要知道List Scheduling的步骤掌握基于最大时延的优先级计算策略。有了这个基础之后你可以进一步地研究其他算法。
**我想强调的是,**指令选择、寄存器分配、指令重排序这三个领域的算法都是“NP-完全”的,所以寻找优化的算法,是这个领域最富有挑战的任务。要研究清楚这些算法,你需要阅读相关的资料,比如本讲推荐的论文和其他该领域的经典论文。
另外我建议你阅读CPU厂商的手册因为只有手册才会提供相关CPU的具体信息解答你对技术细节的一些疑惑。比如网上曾经有人提问说为什么mov指令要用到ALU部件这个其实看一下手册就知道了。
最后我带你了解了LLVM是如何做这些后端工作的这样可以加深你对代码生成这部分知识的了解。
## 一课一思
为了方便教学本讲的示例用的时延值都比较少这其实是不符合实际的。假设我们忽略指令获取和解码的阶段只考虑执行和写入寄存器两个阶段这时候add指令需要3个时钟周期2个执行1个写寄存器mul指令也需要3个时钟周期那么会对示例代码的排序产生什么影响呢你可以实际推演一下这对于你理解指令重排序的算法会很有帮助。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。

View File

@@ -0,0 +1,496 @@
<audio id="audio" title="加餐 | 汇编代码编程与栈帧管理" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/47/f0/47bbe8056d999ecd7da58570cbc831f0.mp3"></audio>
在[22讲](https://time.geekbang.org/column/article/147854)中,我们侧重讲解了汇编语言的基础知识,包括构成元素、汇编指令和汇编语言中常用的寄存器。学习完基础知识之后,你要做的就是多加练习,和汇编语言“混熟”。小窍门是查看编译器所生成的汇编代码,跟着学习体会。
不过,可能你是初次使用汇编语言,对很多知识点还会存在疑问,比如:
- 在汇编语言里调用函数(过程)时,传参和返回值是怎么实现的呢?
- [21讲](https://time.geekbang.org/column/article/146635)中运行期机制所讲的栈帧,如何通过汇编语言实现?
- 条件语句和循环语句如何实现?
- ……
为此,我策划了一期加餐,针对性地讲解这样几个实际场景,希望帮你加深对汇编语言的理解。
## 示例1过程调用和栈帧
这个例子涉及了一个过程调用相当于C语言的函数调用。过程调用是汇编程序中的基础结构它涉及到**栈帧的管理、参数的传递**这两个很重要的知识点。
假设我们要写一个汇编程序实现下面C语言的功能
```
/*function-call1.c */
#include &lt;stdio.h&gt;
int fun1(int a, int b){
int c = 10;
return a+b+c;
}
int main(int argc, char *argv[]){
printf(&quot;fun1: %d\n&quot;, fun1(1,2));
return 0;
}
```
fun1函数接受两个整型的参数a和b来看看这两个参数是怎样被传递过去的手写的汇编代码如下
```
# function-call1-craft.s 函数调用和参数传递
# 文本段,纯代码
.section __TEXT,__text,regular,pure_instructions
_fun1:
# 函数调用的序曲,设置栈指针
pushq %rbp # 把调用者的栈帧底部地址保存起来
movq %rsp, %rbp # 把调用者的栈帧顶部地址,设置为本栈帧的底部
subq $4, %rsp # 扩展栈
movl $10, -4(%rbp) # 变量c赋值为10也可以写成 movl $10, (%rsp)
# 做加法
movl %edi, %eax # 第一个参数放进%eax
addl %esi, %eax # 把第二个参数加到%eax,%eax同时也是存放返回值的寄存器
addl -4(%rbp), %eax # 加上c的值
addq $4, %rsp # 缩小栈
# 函数调用的尾声,恢复栈指针为原来的值
popq %rbp # 恢复调用者栈帧的底部数值
retq # 返回
.globl _main # .global伪指令让_main函数外部可见
_main: ## @main
# 函数调用的序曲,设置栈指针
pushq %rbp # 把调用者的栈帧底部地址保存起来
movq %rsp, %rbp # 把调用者的栈帧顶部地址,设置为本栈帧的底部
# 设置第一个和第二个参数,分别为1和2
movl $1, %edi
movl $2, %esi
callq _fun1 # 调用函数
# 为pritf设置参数
leaq L_.str(%rip), %rdi # 第一个参数是字符串的地址
movl %eax, %esi # 第二个参数是前一个参数的返回值
callq _printf # 调用函数
# 设置返回值。这句也常用 xorl %esi, %esi 这样的指令,都是置为零
movl $0, %eax
# 函数调用的尾声,恢复栈指针为原来的值
popq %rbp # 恢复调用者栈帧的底部数值
retq # 返回
# 文本段,保存字符串字面量
.section __TEXT,__cstring,cstring_literals
L_.str: ## @.str
.asciz &quot;Hello World! :%d \n&quot;
```
**需要注意,**手写的代码跟编译器生成的可能有所不同,但功能是等价的,代码里有详细的注释,你肯定能看明白。
**借用这个例子,我们讲一下栈的管理。**在示例代码的两个函数里,有这样的固定结构:
```
# 函数调用的序曲,设置栈指针
pushq %rbp # 把调用者的栈帧底部地址保存起来
movq %rsp, %rbp # 把调用者的栈帧顶部地址,设置为本栈帧的底部
...
# 函数调用的尾声,恢复栈指针为原来的值
popq %rbp # 恢复调用者栈帧的底部数值
```
在C语言生成的代码中一般用%rbp寄存器指向栈帧的底部而%rsp则指向栈帧的顶部。**栈主要是通过push和pop这对指令来管理的**push把操作数压到栈里并让%rsp指向新的栈顶pop把栈顶数据取出来同时调整%rsp指向新的栈顶。
在进入函数的时候用pushq %rbp指令把调用者的栈帧地址存起来根据调用约定保护起来而把调用者的栈顶地址设置成自己的栈底地址它等价于下面两条指令你可以不用push指令而是运行下面两条指令
```
subq $8, %rsp #把%rsp的值减8也就是栈增长8个字节从高地址向低地址增长
movq %rbp, (%rsp) #把%rbp的值写到当前栈顶指示的内存位置
```
而在退出函数前调用了popq %rbp指令。它恢复了之前保存的栈指针的地址等价于下面两条指令
```
movq (%rsp), %rbp #把栈顶位置的值恢复回%rbp这是之前保存在栈里的值。
addq $8, %rsp #把%rsp的值加8也就是栈减少8个字节
```
上述过程画成一张直观的图,表示如下:
<img src="https://static001.geekbang.org/resource/image/45/df/450388ce0b3189fbf263da402bc447df.jpg" alt="">
上面每句指令执行以后,我们看看%rbp和%rsp值的变化
<img src="https://static001.geekbang.org/resource/image/1b/6a/1beeb1ded99922d15cc98e7cc3359a6a.jpg" alt="">
再来看看使用局部变量的时候会发生什么:
```
subq $4, %rsp # 扩展栈
movl $10, -4(%rbp) # 变量c赋值为10也可以写成 movl $10, (%rsp)
...
addq $4, %rsp # 缩小栈
```
我们通过减少%rsp的值来扩展栈然后在扩展出来的4个字节的位置上写入整数这就是变量c的值。在返回函数前我们通过addq $4, %rsp再把栈缩小。这个过程如下图所示
<img src="https://static001.geekbang.org/resource/image/94/4a/94cf6dbfae7169f6ef01a09e804b7c4a.jpg" alt="">
在这个例子中,我们通过移动%rsp指针来改变帧的大小。%rbp和%rsp之间的空间就是当前栈帧。而过程调用和退出过程分别使用call指令和ret指令。“callq _fun1”是调用_fun1过程这个指令相当于下面两句代码它用到了栈来保存返回地址
```
pushq %rip # 保存下一条指令的地址,用于函数返回继续执行
jmp _fun1 # 跳转到函数_fun1
```
_fun1函数用ret指令返回它相当于
```
popq %rip #恢复指令指针寄存器
jmp %rip
```
上一讲我提到在X86-64架构下新的规范让程序可以访问栈顶之外128字节的内存所以我们甚至不需要通过改变%rsp来分配栈空间而是直接用栈顶之外的空间。
上面的示例程序你可以用as命令生成可执行程序运行一下看看然后试着做一下修改逐步熟悉汇编程序的编写思路。
## 示例2同时使用寄存器和栈来传参
上一个示例中函数传参只使用了两个参数这时是通过两个寄存器传递参数的。这次我们使用8个参数来看看通过寄存器和栈传参这两种不同的机制。
在X86-64架构下有很多的寄存器所以程序调用约定中规定尽量通过寄存器来传递参数而且只要参数不超过6个都可以通过寄存器来传参使用的寄存器如下
<img src="https://static001.geekbang.org/resource/image/4d/53/4d066afb9834f2a602bca2010e6edb53.jpg" alt="">
超过6个的参数的话我们要再加上栈来传参
<img src="https://static001.geekbang.org/resource/image/45/89/45587ab64c83ea52f9d1fd3fedc6b189.jpg" alt="">
根据程序调用约定的规定参数16是放在寄存器里的参数7和8是放到栈里的先放参数8再放参数7。
在23讲我会带你为下面的一段playscript程序生成汇编代码
```
//asm.play
int fun1(int x1, int x2, int x3, int x4, int x5, int x6, int x7, int x8){
int c = 10;
return x1 + x2 + x3 + x4 + x5 + x6 + x7 + x8 + c;
}
println(&quot;fun1:&quot; + fun1(1,2,3,4,5,6,7,8));
```
现在,我们可以按照调用约定,先手工编写一段实现相同功能的汇编代码:
```
# function-call2-craft.s 函数调用和参数传递
# 文本段,纯代码
.section __TEXT,__text,regular,pure_instructions
_fun1:
# 函数调用的序曲,设置栈指针
pushq %rbp # 把调用者的栈帧底部地址保存起来
movq %rsp, %rbp # 把调用者的栈帧顶部地址,设置为本栈帧的底部
movl $10, -4(%rbp) # 变量c赋值为10,也可以写成 movl $10, (%rsp)
# 做加法
movl %edi, %eax # 第一个参数放进%eax
addl %esi, %eax # 加参数2
addl %edx, %eax # 加参数3
addl %ecx, %eax # 加参数4
addl %r8d, %eax # 加参数5
addl %r9d, %eax # 加参数6
addl 16(%rbp), %eax # 加参数7
addl 24(%rbp), %eax # 加参数8
addl -4(%rbp), %eax # 加上c的值
# 函数调用的尾声,恢复栈指针为原来的值
popq %rbp # 恢复调用者栈帧的底部数值
retq # 返回
.globl _main # .global伪指令让_main函数外部可见
_main: ## @main
# 函数调用的序曲,设置栈指针
pushq %rbp # 把调用者的栈帧底部地址保存起来
movq %rsp, %rbp # 把调用者的栈帧顶部地址,设置为本栈帧的底部
subq $16, %rsp # 这里是为了让栈帧16字节对齐实际使用可以更少
# 设置参数
movl $1, %edi # 参数1
movl $2, %esi # 参数2
movl $3, %edx # 参数3
movl $4, %ecx # 参数4
movl $5, %r8d # 参数5
movl $6, %r9d # 参数6
movl $7, (%rsp) # 参数7
movl $8, 8(%rsp) # 参数8
callq _fun1 # 调用函数
# 为pritf设置参数
leaq L_.str(%rip), %rdi # 第一个参数是字符串的地址
movl %eax, %esi # 第二个参数是前一个参数的返回值
callq _printf # 调用函数
# 设置返回值。这句也常用 xorl %esi, %esi 这样的指令,都是置为零
movl $0, %eax
addq $16, %rsp # 缩小栈
# 函数调用的尾声,恢复栈指针为原来的值
popq %rbp # 恢复调用者栈帧的底部数值
retq # 返回
# 文本段,保存字符串字面量
.section __TEXT,__cstring,cstring_literals
L_.str: ## @.str
.asciz &quot;fun1 :%d \n&quot;
```
用as命令把这段汇编代码生成可执行文件运行后会输出结果“fun1: 46”。
```
as functio-call2-craft.s -o function-call2
./function-call2
```
这段程序虽然有点儿长但思路很清晰比如每个函数过程都有固定的结构。710行我叫做序曲是设置栈帧的指针25~26行我叫做尾声是恢复栈底指针并返回13~22行是做一些计算还要为本地变量在栈里分配一些空间。
**我建议你读代码的时候,**对照着每行代码的注释,弄清楚这条代码所做的操作,以及相关的寄存器和内存中值的变化,脑海里有栈帧和寄存器的直观的结构,就很容易理解清楚这段代码了。
除了函数调用以外我们在编程时经常使用循环语句和if语句它们转换成汇编是什么样子呢我们来研究一下首先看看while循环语句。
## 示例3循环语句的汇编码解析
看看下面这个C语言的语句
```
void fun1(int a){
while (a &lt; 10){
a++;
}
}
```
我们要使用"gcc -S ifstmt.c -o ifstmt.s"命令,把它转换成汇编语句(注意不要带优化参数):
```
.section __TEXT,__text,regular,pure_instructions
.macosx_version_min 10, 15
.globl _fun1 ## -- Begin function fun1
.p2align 4, 0x90
_fun1: ## @fun1
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
movl %edi, -4(%rbp) #把参数a放到栈里
LBB0_1: ## =&gt;This Inner Loop Header: Depth=1
cmpl $10, -4(%rbp) #比较参数1和立即数10,设置eflags寄存器
jge LBB0_3 #如果大于等于则跳转到LBB0_3基本块
## %bb.2: ## in Loop: Header=BB0_1 Depth=1
movl -4(%rbp), %eax #这2行是给a加1
addl $1, %eax
movl %eax, -4(%rbp)
jmp LBB0_1
LBB0_3:
popq %rbp
retq
.cfi_endproc
## -- End function
.subsections_via_symbols
```
这段代码的15、16、21行是关键我解释一下
- 第15行用cmpl指令将%edi寄存器中的参数1即C代码中的参数a和立即数10做比较比较的结果会设置EFLAGS寄存器中的相关位。
EFLAGS中有很多位下图是[Intel公司手册](https://software.intel.com/en-us/download/intel-64-and-ia-32-architectures-sdm-combined-volumes-1-2a-2b-2c-2d-3a-3b-3c-3d-and-4)中对各个位的解释有的指令会影响这些位的设置比如cmp指令有的指令会从中读取信息比如16行的jge指令
<img src="https://static001.geekbang.org/resource/image/d7/46/d79cff3bef9e77f825ed9866c5dd1146.jpg" alt="">
<li>
第16行jge指令。jge是“jump if greater or equal”的缩写也就是当大于或等于的时候就跳转。大于等于是从哪知道的呢就是根据EFLAGS中的某些位计算出来的。
</li>
<li>
第21行跳转到循环的开始。
</li>
在这个示例中我们看到了jmp无条件跳转指令和jge条件跳转指令两个跳转指令。条件跳转指令很多它们分别是基于EFLAGS的状态位做不同的计算判断是否满足跳转条件看看下面这张表格
<img src="https://static001.geekbang.org/resource/image/ce/d5/ce52ac9632428550896ce20f958651d5.jpg" alt="">
表格中的跳转指令,是基于有符号的整数进行判断的,对于无符号整数、浮点数,还有很多其他的跳转指令。现在你应该体会到,汇编指令为什么这么多了。**好在其助记符都是有规律的,可以看做英文缩写,所以还比较容易理解其含义。**
**另外我再强调一下,**刚刚我让你生成汇编时,不要带优化参数,那是因为优化算法很“聪明”,它知道这个循环语句对函数最终的计算结果没什么用,就优化掉了。后面学优化算法时,你会理解这种优化机制。
不过这样做也会有一个不好的影响就是代码不够优化。比如这段代码把参数1拷贝到了栈里在栈里做运算而不是直接基于寄存器做运算这样性能会低很多这是没有做寄存器优化的结果。
## 示例4if语句的汇编码解析
循环语句看过了if语句如何用汇编代码实现呢
看看下面这段代码:
```
int fun1(int a){
if (a &gt; 10){
return 4;
}
else{
return 8;
}
}
```
把上面的C语言代码转换成汇编代码如下
```
.section __TEXT,__text,regular,pure_instructions
.macosx_version_min 10, 15
.globl _fun1 ## -- Begin function fun1
.p2align 4, 0x90
_fun1: ## @fun1
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
movl %edi, -8(%rbp)
cmpl $10, -8(%rbp) #将参数a与10做比较
jle LBB0_2 #小于等于的话就调转到LBB0_2基本块
## %bb.1:
movl $4, -4(%rbp) #否则就给a赋值为4
jmp LBB0_3
LBB0_2:
movl $8, -4(%rbp) #给a赋值为8
LBB0_3:
movl -4(%rbp), %eax #设置返回值
popq %rbp
retq
.cfi_endproc
## -- End function
.subsections_via_symbols
```
了解了条件跳转指令以后再理解上面的代码容易了很多。还是先做比较设置EFLAGS中的位然后做跳转。
## 示例5浮点数的使用
之前我们用的例子都是采用整数,现在使用浮点数来做运算,看看会有什么不同。
看看下面这段代码:
```
float fun1(float a, float b){
float c = 2.0;
return a + b + c;
}
```
使用-O2参数把C语言的程序编译成汇编代码如下
```
.section __TEXT,__text,regular,pure_instructions
.macosx_version_min 10, 15
.section __TEXT,__literal4,4byte_literals
.p2align 2 ## -- Begin function fun1
LCPI0_0:
.long 1073741824 ## float 2 常量
.section __TEXT,__text,regular,pure_instructions
.globl _fun1
.p2align 4, 0x90
_fun1: ## @fun1
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
addss %xmm1, %xmm0 #浮点数传参用XMM寄存器加法用addss指令
addss LCPI0_0(%rip), %xmm0 #把常量2.0加到xmm0上xmm0保存返回值
popq %rbp
retq
.cfi_endproc
## -- End function
.subsections_via_symbols
```
这个代码的结构你应该熟悉了,栈帧的管理方式都是一样的,都要维护%rbp和%rsp。不一样的地方有几个地方
<li>
传参。给函数传递浮点型参数是要使用XMM寄存器。
</li>
<li>
指令。浮点数的加法运算使用的是addss指令它用于对单精度的标量浮点数做加法计算这是一个SSE1指令。SSE1是一组指令主要是对单精度浮点数(比如C或Java语言中的float)进行运算的而SSE2则包含了一些双精度浮点数比如C或Java语言中的double的运算指令。
</li>
<li>
返回值。整型返回值是放在%eax寄存器中而浮点数返回值是放在xmm0寄存器中的。调用者可以从这里取出来使用。
</li>
## 课程小结
利用本节课的加餐,我带你把编程中常见的一些场景,所对应的汇编代码做了一些分析。你需要记住的要点如下:
<li>
函数调用时会使用寄存器传参超过6个参数时还要再加上栈这都是遵守了调用约定。
</li>
<li>
通过push、pop指令来使用栈栈与%rbp和%rsp这两个指针有关。你可以图形化地记住栈的增长和回缩的过程。需要注意的是是从高地址向低地址走所以访问栈里的变量都是基于%rbp来减某个值。使用%rbp前要先保护起来别破坏了调用者放在里面的值。
</li>
<li>
循环语句和if语句的秘密在于比较指令和有条件跳转指令它们都用到了EFLAGS寄存器。
</li>
<li>
浮点数的计算要用到MMX寄存器指令也有所不同。
</li>
通过这次加餐你会更加直观地了解汇编语言接下来的课程中我会带你尝试通过翻译AST自动生成这些汇编代码让你直观理解编译器生成汇编码的过程。
## 一课一思
你了解到哪些地方会使用汇编语言编程?有没有一些比较有意思的场景?是否实现了一些普通高级语言难以实现的结果?欢迎在留言区分享你的经验。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。