前言
欢迎持续关注专栏:juejin.cn/column/7265...
上一篇文章我们简述了编译的4个步骤,这节我们来看看其中第二个流程即编译阶段,编译器都帮我们做了什么。
正文
从最原始的角度来看,编译器就是将高级语言编译成机器能够运行的语言的一种工具,注意,由于后面还有一个汇编器进行汇编操作的过程,所以这里的编译流程其实是编译成特定平台的汇编代码。
这里补充一个小知识点,为什么要有汇编代码?直接编译成二进制给机器执行不可以吗?
首先汇编代码是和操作系统和处理器强相关的 ,不同的处理器架构有着不同的指令集和执行方式,所以不同的处理器需要使用相应的汇编指令进行编程,比如x86架构和ARM架构都有各自的指令集和汇编语法。
操作系统管理与硬件交互的底层任务 ,并且提供高级抽象的接口供应用层程序使用,不同的操作系统可能有不同的系统调用、内存管理机制、进程间通信等功能,这些功能在汇编代码中会有不同的实现方式和调用约定。
因此编写汇编代码需要考虑所针对的处理器架构和操作系统,以保证代码能够与特定的硬件和操作系统进行交互,汇编代码在不同的处理器架构和操作系统之间不能通用,需要进行适配和修改。
所以我们就可以知道编译器把代码编译成汇编语言就已经足够了,而汇编器是把汇编语言编译成二进制而已。同时从另一个角度来看,汇编语言其实就是给人看的机器码,方便开发人员调试问题。
那为什么要使用高级语言来进行编程呢,原因最直观的有以下几点:
- 使用机器指令或者汇编语言写程序非常费劲,效率低下,不易阅读。
- 机器语言或者汇编语言一般都是依赖于特定的机器,程序员可不想为每一种CPU架构都写一份代码。
- 使用类似自然语言的高级语言,比如C/C++等,可以更好地组织代码,使用各种设计模式,再通过编译器编译成不同平台的机器码,可以提高效率。
- 高级语言可以让程序员更关注逻辑,而不是计算机本身的限制,比如字长、内存大小等。
所以这个编译过程非常复杂,大致可以分为6步:扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化,整个过程如图:

能够理解这6大过程,可以很大程度提高对编程语言的理解,尤其是日常工作中碰到的语法错误、优化等现象,甚至理解编译过程,我们可以自己创造一门语言。
词法分析
首先是将源代码程序输入到扫描器中,进行词法分析,把源代码中的字符序列分割成一系列的记号(Token),说白了就是简单地将代码进行分割和整理。
比如测试代码array[index] = (index + 4) * (2 + 6)
,这行代码一共有28个非空字符,经过扫描后,可以产生16个记号,如下表:
记号 | 类型 |
---|---|
array | 标识符 |
[ | 左方括号 |
index | 标识符 |
] | 右方括号 |
= | 赋值 |
( | 左圆括号 |
index | 标识符 |
+ | 加号 |
4 | 数字 |
) | 右圆括号 |
* | 乘号 |
( | 左圆括号 |
2 | 数字 |
+ | 加号 |
6 | 数字 |
) | 右圆括号 |
词法分析产生的记号分为关键字、标识符、字面量(包含数字、字符串等)和特殊符号(加号、乘号等),在识别记号的同时,扫描器也完成了一些其他动作,比如将标识符放到符号表中,将数字、字符串常量放入到文字表中。
由此可见,这个词法分析的规则也是不同语言有着不同的规定,比如这里的array
并不会分析判断为arr
和ay
这2个标记符,同样的对于括号都有着特殊匹配规则,当写出不符合词分析的代码时,在这一步便会报错。
语法分析
词法分析完后就是进行语法分析,由语法分析器(Grammer Parser)对记号进行语法分析,产生语法树(Syntax Tree)。这一步非常重要,也是我们学习一门新语言时经常会出现IDE提示语法错误,而如何判断语法错误,就是这一步分析的。
首先有个概念就是一条语句是一个表达式 ,而复杂的语句由多个表达式组合起来的,比如上面那行代码就是由赋值表达式、加法表达式、乘法表达式、数组表达式、括号表达式组成的复杂表达式。
这时就有个关键问题,就是表达式是具有优先级的。上面示例代码中,我们肯定知道核心是一个赋值表达式,先把赋值表达式左右都计算完,再进行赋值操作。但是这是建立在我们学过编程的前提下,当对于分析器来说,不给它设定规则,它就无法知道是先计算乘法还是先进行赋值操作。
不仅仅是运算符优先级,还包括多重含义符号的区分 ,比如*
,包括表达式是否合法,比如缺少括号、操作符缺少参数等,这些编译器都会报错。

语法分析进行完后,至少可以判断我们写的代码有没有语法错误,而有兴趣的可以查看一下语法分析的算法和规则,这样可以更加理解语法分析过程,帮助我们平时碰到语法错误时,能快速找到原因。
语义分析
经过语法分析后的语法树基本上能对简单的错误进行判断和提示了,比如运算符缺少参数等,但是语义错误无法判断,这就需要语义分析器来进行语义分析了。
什么是语义呢?就是这个语句是否有意义 ,比如在C语言中,2个指针做乘法是没有意义的,但是在语法层面是可行的。这种编译器能分析的语义是静态语义(Static Semantic) ,与之对应的是动态语义(Dynamic Semantic),就是只能在运行期才能确定是否非法的语义,比如除零操作。
静态语义分析有个非常重要的的作用是声明和类型的匹配 ,以及类型的转换。对于声明和类型匹配,对于C++这种复杂的编程语言来说,是经常写错的,尤其是复合类型和函数类型。而类型转换一般就是隐式类型转换,比如把一个浮点型的表达式赋值给一个整型的表达式,就隐含了一个浮点型转到整型的过程。
经过语义分析后的语法树,每个表达式都有了固定的类型,很多编程语言都是静态类型语言,在这个阶段后要不我们自己声明要不编译器推导,整个语法树的表达式都会被标识上类型。
中间代码生成
现代编译器有着很多的优化,其中一个就是源码级别会有一个优化过程 。比如上面代码中(2+8)
就可以被优化掉,因为它的值已经被确定了。
除了这种,还有一些常见的优化:
- 代码消除:编译器会检测和删除不会影响程序结果的代码,从而减少不必要的计算和内存访问。 比如代码:
C++
int square(int x) {
int result = x * x;
return result;
}
这里的变量result
可能就会被编译器给优化掉,直接返回x * x
;
- 内联函数:编译器会尝试内联一些简单的函数,可以避免函数调用带来的额外性能开销。 比如代码:
C++
inline int square(int x) {
return x * x;
}
int calculateSquare(int y) {
return square(y); // 内联函数调用
}
比如这里在调用square()
方法时,会直接把其函数体复制到调用处,以减少函数调用带来的额外开销。
- 公共子表达式消除:对于在代码中,编译器可以识别出重复的子表达式,然后把子表达式的值进行缓存,从而减少计算次数。比如代码:
C++
int calculateExpression(int x, int y) {
int z = x * 5 + y * 5; // 公共子表达式
int w = x * 5 + y * 5; // 公共子表达式
return z + w;
}
在这里公共的子表达式就是x *5 + y * 5
,编译器会把其的值缓存起来,进行一次计算即可。
- 编译时常量表达式优化:在编译时如果可以确定一些常量表达式的值,就可以在编译时把结果替换。比如下面代码:
C++
constexpr int factorial(int n) {
return (n <= 1) ? 1 : n * factorial(n - 1); // 编译时常量表达式
}
int result = factorial(5); // 在编译期间计算结果
这里的factorial(5)
的值在编译时就可以计算出来,因为方法是常量表达式,所以在编译时就可以把计算的值给到result
变量。
- 循环展开:这是一种将循环展开为迭代的一种技术,以减少循环控制的开销,但并不是所有循环展开都有正向的收益,这个看编译器具体实现,比如如下代码:
C++
void printArray(const int* arr, int size) {
for (int i = 0; i < size; i++) {
cout << arr[i] << " ";
}
}
这里循环控制的条件是判断i和size的关系,所以这里可以把循环稍微展开一下,变成如下:
C++
void printArray(const int* arr, int size) {
for (int i = 0; i < size - 1; i += 2) {
cout << arr[i] << " ";
cout << arr[i + 1] << " ";
}
if (size % 2 != 0) {
cout << arr[size - 1] << " "; // 处理剩余的单个元素
}
}
这种情况下就会少判断一半的循环条件。同时循环展开在寄存器级别也有一些优化,比如将连续的读取和写入操作组合为更高效的指令序列等。
前面介绍了一些常见的在编译期编译器对代码做的一些优化的例子,回到本章本节主题上,经过词法分析、语法分析、语义分析后的代码就成了带有类型的表达式语法树,这就有个问题,直接在语法树上进行优化是非常困难的一件事,所以大多数编译器会把语法树变成中间代码的格式。
中间代码又有多种类型,在不同的编译器中有着不同的形式,比较常见就是三地址码,最基本的三地址码是这样的:
ini
x = y op z
就是表示将变量y和z进行op操作后,赋值给x,这里的op操作可以是算术运算,也可以是其他任何可以用到y和z的操作。使用该方法把前面例子中的语法树就可以翻译成如下这样:
ini
t1 = 2 + 6;
t2 = index + 4
t3 = t2 * t1
array[index] = t3
这里我们可以看到所有的操作都符合三地址码的形式,这里利用了几个临时变量t1、t2和t3。在三地址码上进行优化就比较容易,比如优化程序会将2+6
的结果给计算出来,得到t1 = 8
,然后将代码中的t1替换为数字8,还可以省略t3,因为t2可以重复利用,所以优化后的代码如下:
ini
t2 = index +4
t2 = t2 * 8
array[index] = t2
中间代码使得编译器可以分为前端和后端 。编译器前端负责产生机器无关的中间代码,编译器后端将中间代码转换成目标机器代码,这样对于一些可以跨平台的编译器而言,他们可以针对不同的平台使用同一个前端和针对不同机器平台的多个后端。
目标代码生成与优化
源代码级优化器产生的中间代码标志着下面的过程都属于编译器后端。编译器后端的工作主要包括代码生成器(Code Generator)和目标代码优化器(Target Code Optimizer)。
这里代码生成器就是生成汇编语言,在前面我们说了汇编语言是和机器平台强相关的,我们用x86汇编语言来表示上面代码可能的代码序列:
perl
movl index, %ecx; value of index to ecx
addl $4, %ecx; ecx = ecx + 4;
mull $8, %ecx; ecx = ecx * 8;
movl index, %eax; value of index to eax;
movl %ecx, array(,eax,4); array[index] = ecx;
我们可以来简单说明一下这段汇编代码,movl index, %ecx
就是将index
变量的值移动到寄存器ecx
中,其中movl
是汇编指令,用于将数据从源操作数(这里是index
)移动到目标操作数(这里是%ecx
),l表示移动的是32位数据。在该条指令执行完后,寄存器ecx
中就存储了index
的值,可以在后续汇编代码中使用%ecx
来操作这个值。 接下来几行汇编语言就是对exc
寄存器的值进行操作,以及再把index
的值移动到eax
中,而最后一行汇编语句就是将ecx
的值移动到数组array
的指定位置上,这个位置由eax
寄存器为基址,偏移量为4,因为数组元素的大小为4个字节。
最后一步就是目标代码优化器对上述的汇编代码进行优化 ,比如选择合适的寻址方式、删除多余指令等等。比如这里可以用leal
指令来代替上面的乘法、加法指令,优化后如下:
perl
movl index, %edx
leal 32(,%edx,8), %eax
movl %eax, array(,%edx,4)
我们分析最开始的汇编代码,其中的操作是是(index + 4) * 8
,其实也就是32 + 8 * index
,而这里的leal
是一种加载有效地址的指令,他可以执行简单的地址计算并且将结果存储到目标寄存器中。计算步骤如下:
- 先将
edx
乘以8(偏移量乘以数组元素的大小),得到edx * 8
; - 然后将结果和偏移量32相加,得到
32 + edx * 8
; - 将结果存储在
eax
中。
这里我们发现效果和前面汇编代码是一样的。
现在的编程语言非常复杂,同时现代的CPU架构也非常复杂,为了支持这些特性,编译器的机器指令优化是一个十分复杂的过程,我们只是简单了解即可。
总结
经过扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化后,我们的源代码终于被编译成了目标代码。但是这里还有一个问题,就是index
和array
的地址还没有确定。假如我们使用汇编器把这段汇编代码编译成真正能够机器上执行的指令,那么index
和array
应该从哪里得到呢?假如定义在其他模块呢?
这是一个看似简单的问题,但是引出了一个很大的话题,就是链接。定义在其他模块的全局变量和函数,在最终运行时的绝对地址都要在最终链接的时候才能确定。所以现代编译器可以将一个源代码文件编译成一个未链接的目标文件,然后由连接器将这些文件链接成可执行文件。