生成代码
- 编译器后端的最终结果,就是生成目标代码。如果目标是在计算机上直接运行,就像 C 语言程序那样,那这个目标代码指的是汇编代码。而如果运行目标是 Java 虚拟机,那这个目标代码就是指 JVM 的字节码。
程序运行的环境
- 程序运行的过程中,主要是跟两个硬件(CPU 和内存)以及一个软件(操作系统)打交道。
CPU 的内部有很多组成部分,重点关注的是寄存器以及高速缓存,它们跟程序的执行机制和优化密切相关。
寄存器
- 寄存器是 CPU 指令在进行计算的时候,临时数据存储的地方。CPU 指令一般都会用到寄存器,比如,典型的一个加法计算(c=a+b)的过程是这样的:
- 指令 1(mov):从内取 a 的值放到寄存器中;
- 指令 2(add):再把内存中 b 的值取出来与这个寄存器中的值相加,仍然保存在寄存器中;
- 指令 3(mov):最后再把寄存器中的数据写回内存中 c 的地址。
- 寄存器的速度也很快,所以能用寄存器就别用内存。尽量充分利用寄存器,是编译器做优化的内容之一。
高速缓存
- 高速缓存可以弥补 CPU 的处理速度和内存访问速度之间的差距。所以,我们的指令在内存读一个数据的时候,它不是老老实实地只读进当前指令所需要的数据,而是把跟这个数据相邻的一组数据都读进高速缓存了。
- 程序在运行时,操作系统会给它分配一块虚拟的内存空间,让它在运行期可以使用。我们目前使用的都是 64 位的机器,你可以用一个 64 位的长整型来表示内存地址,它能够表示的所有地址,我们叫做寻址空间。
- 在存在操作系统的情况下,程序逻辑上可使用的内存一般大于实际的物理内存。程序在使用内存的时候,操作系统会把程序使用的逻辑地址映射到真实的物理内存地址。有的物理内存区域会映射进多个进程的地址空间。
- CPU 上运行程序的指令,运行过程中要用到寄存器、高速缓存来提高指令和数据的存取效率。
- 内存可以划分成不同的区域保存代码、静态数据,并用栈和堆来存放运行时产生的动态数据。
- 操作系统会把物理的内存映射成进程的寻址空间,同一份代码会被映射进多个进程的内存空间,操作系统的公共库也会被映射进进程的内存空间,操作系统还会自动维护栈。
- 程序在运行时顺序执行代码,可以根据跳转指令来跳转;栈被划分成栈桢,栈桢的设计有一定的自由度,但通常也要遵守一些约定;栈桢的大小和结构在编译时就能决定;在运行时,栈桢作为活动记录,不停地被动态创建和释放。
生成汇编代码
在存在操作系统的情况下,程序逻辑上可使用的内存一般大于实际的物理内存。程序在使用内存的时候,操作系统会把程序使用的逻辑地址映射到真实的物理内存地址。有的物理内存区域会映射进多个进程的地址空间。
- 对于静态编译型语言,比如 C 语言和 Go 语言,编译器后端的任务就是生成汇编代码,然后再由汇编器生成机器码,生成的文件叫目标文件,最后再使用链接器就能生成可执行文件或库文件了。
Java 的字节码,在运行时通常也会通过 JIT 机制编译成机器码。而汇编语言是完成这些工作的基础。
LLVM
- LLVM 能够支持多种语言的前端、多种后端 CPU 架构。在 LLVM 内部,使用类型化的和 SSA 特点的 IR 进行各种分析、优化和转换:
- LLVM 项目包含了很多组成部分:
- LLVM 核心(core)。就是上图中的优化和分析工具,还包括了为各种 CPU 生成目标代码的功能;这些库采用的是 LLVM IR,一个良好定义的中间语言。
- Clang 前端(是基于 LLVM 的 C、C++、Objective-C 编译器)。
- LLDB(一个调试工具)。
- LLVM 版本的 C++ 标准类库。
- 其他一些子项目。
使用 LLVM 从源代码到生成可执行文件有两条可能的路径:
- 第一条路径,是把每个源文件分别编译成字节码文件,然后再编译成目标文件,最后链接成可执行文件。
- 第二条路径,是先把编译好的字节码文件链接在一起,形成一个更大的字节码文件,然后对这个字节码文件进行进一步的优化,之后再生成目标文件和可执行文件。
第二条路径比第一条路径多了一个优化的步骤,第一条路径只对每个模块做了优化,没有做整体的优化。所以,如有可能,尽量采用第二条路径,这样能够生成更加优化的代码。