被隐藏了的过程

考虑下面一段程序

  1. #include<stdio.h>
  2. int main(){
  3. printf("hello world\n");
  4. return 0;
  5. }

IDE将其转换为可执行程序过程中经历了下面四个步骤:

  • 预处理(preprocessing):xx.c->xx.i
  • 编译(compilation):xx.i->xx.s
  • 汇编(assembly):xx.s->xx.o
  • 链接(linking):xx.o->xx.out

fig_0201.png

预编译

预编译做了什么?主要是对代码做了一些简单的预处理:
预编译过程主要处理那些源代码文件中的以#开始的预编译指令。

  1. 将所有的#define删除,并且展开所有的宏定义。
  2. 处理所有条件预编译指令,比如#if#ifdef#elif#else#endif
  3. 处理#include预编译指令,将被包含的文件插入到该预编译指令的位置。注意,这个过程是递归进行的,也就是说被包含的文件可能还包含其他文件。
  4. 删除所有的注释///**/
  5. 添加行号和文件名标识,比如#2"hello.c" 2,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号。
  6. 保留所有的#pragma编译器指令,囚为编译器须要使用它们。

    编译

    编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析优化后生产相应的汇编代码文件,这个过程往往是我们所说的整个程序构建的核心部分,也是最复杂的部分之一。

    汇编

    汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。
    经过预编译、编译和汇编直接输出目标文件(Object File)

    链接

    如果不用gcc,手动链接的话:

    1. $ld -static /usr/lib/crtl.o /usr/lib/crti.o
    2. /usr/lib/gcc/1486-linux-gnu/4.1.3/crtbeginT.o
    3. -L/uar/lib/gcc/1486-linux-gnu/4.1.3 -L/uar/lib -L/lib hello.o --start-group
    4. -lgcc-lgcc_eh -le --end-group /usr/lib/gcc/1486-linux-gnu/4.l.3/crtend.o
    5. /usr/lib/crtn.o
    6. 省略路径实际上是如下的操作
    7. ld -static crtl.o crti.o crtbeginT.o hello.o -start-group -lgcc -lgcc_eh -le
    8. -end-group crtend.o crtn. o

    可以看到链接实际上就是把一堆库文件中的目标文件链接到我现在正在编译的文件上。

    编译器做了什么

    考虑为什么要有链接器之前,先来分析一下编译器都做了啥。从最直观的角度来讲,编译器就是将高级语言翻译成机器语言的一个工具
    fig_0202.svg
    编译过程一般可以分为6步:

  7. 扫描(scanner)

  8. 语法分析(parser)
  9. 语义分析(semantic analyzer)
  10. 源代码优化(source code optimizer)
  11. 代码生成(code generator)
  12. 目标代码优化(code optimizer)

比如我们有下面一行代码:

  1. array[index] = (index + 4)*(2 + 6)
  2. CompilerExpression.c

词法分析

首先源代码程序被输入到扫描器(Scanner),扫描器的任务很简单,它只是简单地进行词法分析,运用一种类似于有限状态机(Finite State Machine)的算法可以很轻松地将源代码的字符序列分割成一系列的记号(Token)

Tokens 类型
array 标识符
[ 左方括号
index 标识符
= 赋值
  1. 记号的类型一般可以是:关键字、标识符、字面量(包含数字、字符串等)和特殊符号(如加号、等号)
  2. 在识别记号的同时,扫描器也完成了其他工作。比如将标识符存放到符号表,将数字、字符串常戳存放到文宁表等,以备后面的步骤使用。

    语法分析

    语法分析器(Grammar Parser)将对由扫描器产生的记号进行语法分析,从而产生语法树(Syntax Tree)。整个分析过程采用了上下文无关语法(Context-free Grammar) 的分析手段。简单地讲,由语法分析器生成的语法树就是以表达式(Expression)为节点的树。如下图所示:
    fig_0203.svg
    在语法分析的同时,很多运算符号的优先级和含义也被确定下来了。
    如果出现了表达式不合法,比如各种括号不匹配、表达式中缺少操作符等,编译器就会报告语法分析阶段的错误。

    语义分析

    语法分析仪是完成了对表达式的语法层面的分析,但是它并不了解这个语句是否真正有意义。比如C语言里面两个指针做乘法运算是没有意义的,但是这个语句在语法上是合法的。因此编译器要进行语义分析

  3. 编译器所能分析的语义是静态语义(Static Semantic),所谓静态语义是指在编译期可以确定的语义。静态语义通常包括声明和类型的匹配,类型的转换。

  4. 与之对应的动态语义Dynamic Semantic)就是只有在运行期才能确定的语义。

经过语义分析阶段以后,整个语法树的表达式都被标识了类型
fig_0204.svg

中间语言生成

现代的编译器有着很多层次的优化,往往在源代码级别会有一个优化过程。源代码级优化器会在源代码级别进行优化,比如说上述的2+6可以直接被优化成8 :::tips 直接在语法树上作优化比较困难,所以源代码优化器往往将整个语法树转换成中间代码(Intermediate Code),它是语法树的顺序表示,其实它已经非常接近目标代码了。但是它一般跟目标机器和运行时环境是无关的,比如它不包含数据的尺寸、变量地址和寄存器的名字等。 ::: 中间代码有很多种类型,在不同的编译器中 有着不同的形式,比较常见的有:三地址码(Three-address Code)和P-代码(P-Code)。

  1. t1=2+6
  2. t2 = index + 4
  3. t3 = t2*t1
  4. array[index] = t3

优化前用了三个临时变量,如果进行优化:

  1. t2=index+4
  2. t2=t2*8
  3. array[index]=t2;

优化后只用了一个临时变量。当然这只是一个例子。 :::tips 中间代码使得编译器可以被分为前端和后端。编译器前端负责产生机器无关的中间代码,编译器后端将中间代码转换成目标机器代码。 :::


目标代码生成与优化

编译器后端主要包括代码生成器(Code Generator)和目标代码优化器(Target Code Optimizer)。 :::info 代码生成器将中间代码转换成目标机器代码,这个过程十分依赖于目标机器,因为不同的机器有着不同的字长、寄存器、整数数据类型和浮点数数据类型等 :::

  1. movl index, %ecx ; value of index t o ecx
  2. addl $4,%ecx ; ecx = ecx + 4
  3. mull $8,%ecx ; ecx = ecx * 8
  4. movl index, %eax ; value of index to eax
  5. movl %ecx, array(,eax, 4) ; array[index ] = ecx

例如根据上述的中间代码,我们得到了如上的汇编
最后目标代码优化器对上述的目标代码进行优化,比如选择合适的寻址方式、使用位移来代替乘法运算、删除多余的指令等。例如最终优化成这样

  1. movl index, %edx
  2. leal 32 (, %edx, 8), %eax
  3. movl %eax, array (, &edx,4)

引子

但是这个目标代码中有一个问题是:index和array的地址还没有确定。如果我们要把目标代码使用汇编器编译成真正能够在机器上执行的指令,那么index和array的地址应该从哪儿得到呢? :::info 如果他俩定义在跟上面的源代码同一个编译单元里面,那么编译器可以为index和array分配空间确定它们的地址。那如果是定义在其他的程序模块呢? :::

链接器

事实上,定义其他模块的全局变量和函数在最终运行时的绝对地址都要在最终链接的时候才能确定。所以现代的编译器可以将一个源代码文件编译成一个未链接的目标文件,然后由链接器最终将这些目标文件链接起来形成可执行文件

在没有链接器之前

  • 在没有链接器之前,如果修改了程序中某个函数的代码,那么所有函数模块的地址偏移量都变了,因此需要重新手动计算所有函数的绝对地址,这样十分麻烦。

于是在汇编语言中通过符号来标记某个函数入口的地址,这样不用手动计算了。

  • 当程序变得越来越庞大后,人们开始将代码按照功能或性质划分,分别形成不同的功能模块,不同的模块之间按照层次结构或其他结构来组织。这些模块之间相互依赖又相对独立这种按照层次化及模块化存储和组织源代码有很多好处,比如代码更容易阅读、理解、重用,每个模块可以单独开发、编译、测试,改变部分代码不需要编译整个程序等。

    链接器的作用

    在一个程序被分割成多个模块以后,这些模块之间最后如何组合形成一个单一的程序是须解决的问题。模块之间如何组合的问题可以归结为模块之间如何通信的问题,最常见的属千静态语言 的C/C++模块之间通信有两种方式,一种是模块间的函数调用,另外一种是模块间的变量访问。 :::tips 函数访问须知道目标函数的地址,变量访问也须知道目标变量的地址,所以这两种方式都可以归结为一种方式,那就是模块间符号的引用 ::: image.png
    模块间依靠符号来通信类似于拼图版,定义符号的模块多出一块区域,引用该符号的模块刚好少了那一块区域,两者一拼接刚好完美组合。这个模块的拼接过程就是本书的一个主题:链接(Linking)。

    模块拼装——静态链接

    链接的主要内容就是把各个模块之间相互引用的部分都处理好,使得各个模块之间能够正确地衔接。链接器所要做的工作其实跟前面所描述的“程序员人工调整地址”本质上没什么两样。

:::tips 链接过程主要包括了地址和空间分配(Addressand Storage Allocation)符号决议(Symbol Resolution)重定位(Relocation)等这些步骤. ::: 最基本的静态链接过程:每个模块的源代码文件(如c)文件经过编译器编译成目标文件(Object File,一般扩展名为.o或obj),目标文件和库(Library)一起链接,最终形成可执行文件。 :::info 库其实是一组目标文件的包,就是一些最常用的代码编译成目标文件后打包存放。 :::

一些例子

  1. 例如在main.c中调用了func.c中的一个函数foo()。
  2. 在编译时,foo()的地址会先被搁置,等待最后链接的时候由链接器去将这些指令的目标地址修正。

假设我们有个全局变量叫做var,它在目标文件A里面。我们在目标文件B里面要访问这个全局变量,比如我们在目标文件B里面有这么一条指令:
movl $0x2a, var这条指令就是给这个var变量赋值0x2a,相当千C语言里面的语句var=42。
fig_0205.svg
由于在编译目标文件B的时候,编译器并不知道变量var的目标地址,所以编译器在没法确定地址的情况下,将这条mov指令的目标地址置为0,等待链接器在将目标文件A和B链接起来的时候再将其修正。 :::tips 这个地址修正的过程也被叫做重定位(Relocation),每个要被修正的地方叫一个重定位入口(Relocation Entry)。重定位所做的就是给程序中每个这样的绝对地址引用的位置“打补丁”,使它们指向正确的地址。 :::