《程序员的自我修养》读书笔记--编译和链接

2.1 被隐藏了的过程

在平常的应用程序开发中一般都不需要关注编译和链接过程,因为在IDE开发环境中一般都将编译和链接合到一起一步完成,直接生成可执行文件;通常将这个过程称为构建(Build)

对于最经典的C语言版"Hello World"的代码:

#include <stdio.h>

int main()
{
    printf("Hello World\n");
    return 0;
}

我们在Linux下使用GCC来编译该代码时,只需使用几行简单的命令就完成对上述代码的编译等一系列过程(假设源码文件名为hello.c),生成可直接运行的的程序:

$gcc hello.c
$./a.oout
Hello World

事实上,上述过程可分解为4个步骤,分别是预处理(preprocess)编译(compilation)汇编(assembly)链接(linking),其中前三个阶段都是文本形式的处理,如下图所示:

下面分别大致介绍下各个步骤的作用。

预编译

预编译过程主要处理那些源代码文件中以“#”开始的预编译指令。比如“#include”、“#define”等,常见的处理规则如下:

  • 将所有的“#define”删除,并且展开所有的宏定义。
  • 处理所有条件预编译指令,比如“#if”、“#ifdef”、“#endif”等。
  • 处理“#include”预编译指令,将被包含的文件插入到该预编译指令的位置。注意,这个过程是递归进行的,也就是说被包含的文件可能还包含其他文件。
  • 删除所有注释“//”和“/* */”。

经过预编译后生成的hello.i文件不包含任何的宏定义了,因为所有的宏已经被展开,并且被包含的文件也被插入到.i文件中。

编译

编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生成响应的汇编代码文件;下一节会具体介绍下这几个步骤的内容。

编译后得到仍是文本形式的汇编输出文件hello.s。

汇编

汇编器的工作就是将汇编代码转变为机器可以执行的指令。这个过程比较简单,它没有复杂的语法语义,也不用做指令的优化,仅仅只是根据汇编指令和机器指令的对照表一一翻译成二进制机器码就可以了。

这个过程完成后生成的是目标文件(Object File) hello.o,其中包含的是机器可以识别和执行的二进制机器码了。

链接

最后经过链接,就能生成可执行文件.out(windows下为.exe文件)了。但上面说了汇编过程输出的目标文件中包含的已经是计算机可以执行的机器码了,那么为什么目标文件不能直接运行而是要经过链接才生成可执行文件呢?为何要多此一举呢。在后面2.3小节,我们再来详述下链接过程包含了什么内容 及 为什么要链接。


2.2 编译器做了什么

编译器做的事情,大致就是在编译原理中所学的东西,一般分为6步:词法分析、语法分析、语义分析、源代码优化、代码生成和目标代码优化。

以一段很简单的C语言代码为例子讲述下从源代码到最终目标代码的生成过程:

array[index] = (index + 4) * (2 + 6)

词法分析

首先源代码被输入到扫描器(Scanner),运用有限状态机的算法将这段字符序列分割成一系列的记号(Token);上面的这段代码所包含的28个非空白符,经过扫描后,产生了16个记号:

记号 类型
array 标识符
[ 左方括号
index 标识符
] 右方括号
= 赋值
( 左圆括号
index 标识符
+ 加号
4 数字
) 右圆括号
* 乘号
( 左圆括号
2 数字
+ 加号
6 数字
) 右圆括号

词法分析产生的记号一般分为如下几类:关键字、标识符、字面量(数字、字符串等)。在识别记号的同时,扫描器还完成了其他事情 比如标识符存放入符号表,将数字和字符串常量存放入文字表等,以备后面的步骤使用。

语法分析

接下来语法分析器(Grammar Parser)将堆扫描器产生的记号进行语法分析,运用上下文无关语法(Context-free Grammar) 从而产生语法树(Syntax Tree)。语法树就是一棵均以表达式(Expression) 为结点的树。

在C语言中一个语句就是一个表达式,而复杂语句是很多表达式的组合。上面例子中的语句就是一个由赋值表达式、加法表达式、括号表达式等组成的复杂语句。生成的语法树如下:

上面的整个语句可以看作是一个赋值表达式;赋值表达式的左边是一个数字表达式,右边是一个乘法表达式;以此递归下去,符号和数字是最小的表达式,它们不是由其他表达式组成的。

对于表达式不合法的情况,例如各种括号不匹配,表达式中缺少操作符等,编译器就会在语法分析阶段报告出错误。

语义分析

前面的语法分析仅仅是完成了表达式的语法层面的分析,但是它不了解这个语句是否真正有意义。比如对两个指针进行乘法操作这样,这个语句在语法上是合法的但却没有意义。编译器所能分析的语义是静态语义(与之对应的是动态语义,即只有在运行期才能确定的语义,比如将0作为除数是一个运行期语义错误)。

静态语义通常包括声明和类型的匹配,类型的转换。经过语义分析阶段后,整棵语法树的表达式都被标识了类型:

除以之外,语义分析器还对符号表里的符号类型做了更新。

中间语言生成

现代编译器往往在源码级别会有一个优化过程。在上面的例子中,很容易发现(2+6)这个表达式可以被优化掉,因为它的值在编译期就能确定为8了;类似的还有很多其他复杂的优化过程。

这个优化过程并不是直接在语法树上进行的,源码优化器往往是将整棵语法树转换成中间代码(Intermediate Code),它是语法树的顺序表示。中间代码是与目标机器和运行环境无关的,比如它不包含数据尺寸,变量地址等信息。

把上面例子的语法树翻译成中间代码(三地址码的形式)后是这样的:

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

在这样的三地址码形式的基础上进行优化,优化程序会将2 + 6的结果计算出来得到t1 = 8,并可以省去一个临时变量t3:

t2 = index + 4
t2 = t2 * 8
array[index] = t2

目标代码生成与优化

首先是目标代码生成;这个过程是由代码生成器将中间代码转换成目标机器代码,因而十分依赖于目标机器,因为不同的机器有着不同的字长、寄存器、整数数据类型和浮点数数据类型等。假如我们用X86汇编语言来表示,代码生成器可能会生成下面的代码序列:

movl index, %ecx
addl $4, %ecx
mull $8, %ecx
movl index, %eax
movl %ecx, array(,eax,4)

最后由目标代码优化器对上述的目标代码进行优化,比如选择合适的寻址方式、使用位移运算代替乘法运算、删除多余的指令等。


链接器年龄比编译器长

在经过词法分析、语法分析、语义分析、源码优化、目标代码生成和优化,上面的源代码终于被编译成了目标代码。但是这个目标代码还有一个问题:index和array的地址还未确定。如果index和array定义在跟上面源码同一个编译单元里,那么编译器可以为index何array分配空间并确定它们的地址;而如果是定义在其他的程序模块中的话,要怎么确定它们的访问地址呢?这时候,就需要到链接器了。

在“上古时代”,是没有高级语言甚至汇编语言的;那个时候写程序是直接写机器码的,存储程序的最原始的设置之一就是纸带,即在纸带上打相应的孔格:

假设现在有一段如上图右侧所示的机器码程序,其所运行的目标机器上 每条指令都是一字节;上面有一种跳转指令,高4位是0001,表示这是一条跳转指令,低4位存放的是跳转目的地的绝对地址。从上图可以看出,第一条就是跳转指令,要跳转到第5条指令(第5条指令的绝对地址是4)。

那么问题来了,这段程序在日后是可能会修改的,如果我们在第1条指令和第5条指令之间插入了新的指令,那么第1条跳转指令的目的地址就得做修改了。如果我们有多条纸带程序,这些程序之间可能会有类似的跨纸带之间的跳转。每当有这修改时,我们都得重新计算各个目标地址(这个过程被叫做重定位) 显然是不能容忍的。

后来,先驱者发明了汇编语言,在两点上极大地解放了生产力:

  • 采用助记符来替代机器指令,例如jmp代表跳转指令
  • 可以使用符号来标记位置,例如在前面的纸带程序中,把第5条指令开始的子程序命名为“foo”, 那么第一条指令的汇编就是:jmp foo

当人们可以使用这种符号命名子程序或跳转目标以后,不管这个“foo”之前插入或减少了指令导致“foo”目标地址发生变化,汇编器在每次汇编程序的时候都会重新计算“foo”这个符号的地址,然后把所有引用了“foo”的指令修正到正确的地址。

有了汇编语言后,生产力大大提高,随之而来的是软件程序的规模也日渐庞大,人们开始将代码按照功能或性质划分。在一个程序被分割成多个模块之后,这些模块之间最后如何组合形成一个单一的程序是需要解决的问题。模块之间如何组合的问题可以归结为模块之间如何通信的问题,主要有两方面:一是模块间的函数调用,另一是模块间的变量访问。而这两种方式都可以归结为一种方式,即模块间符号的引用。 我们将各个模块“拼合”到一起形成一个可执行程序,并为各个模块中的符号引用确定最终访问地址 的这个过程就是本书的一个主题:链接(Linking)


模块拼装——静态链接

这里先举一个例子来阐述静态链接的最基本的过程和作用:
比如我们在程序模块main.c中使用了另一个模块fun.c中的函数foo()。那么在main.c模块中每一处调用foo的时候都必须确切知道foo这个函数的地址,但由于每个模块都是单独编译的,在编译器编译main.c的时候它并不知道foo函数的地址,所以它暂时把这些调用foo的指令的目标地址搁置,等待最后链接的时候由链接器去将这些指令的目标地址修正。 使用链接器,你可以直接引用其他模块的函数和全局变量而无需知道它们的地址,因为链接器在链接的时候,会根据你所引用的符号foo,自动去相应的fun.c模块查找foo的地址,然后将main.c模块中所有引用到foo的指令重新修正,让它们的目标地址为真正的foo函数的地址。

链接器所做的工作其实跟前面所说的机器码程序中因指令增减而需要“手工调整地址”本质上是一样的,只不过现代高级语言拥有诸多特性与功能,使得编译器、链接器更为复杂,功能更为强大,但从原理上讲,它的工作无非就是把一些指令对其他符号地址的引用加以修正。链接过程主要包括了地址和空间分配(Address and Storage Allocation)符号决议(Symbol Resolution)重定位(Relocation)等这些步骤。(符号决议大致就是 为每个目标文件确定符号并在其他目标文件找到引用符号的定义的过程,后面的链接章节会详细介绍)

最基本的静态链接过程如下图所示。每个模块的源代码文件(如.c文件)经过编译器编译成目标文件(Object File,一般扩展名为.o或.obj),目标文件和库(Library)一起链接形成最终可执行文件。而最常见的库就是运行时库(Runtime Library),它是支持程序运行的基本函数的集合。库其实是一组目标文件的包,就是一些常用的代码编译成目标文件后打包存放。关于库本书的后面还会详细分析。

posted @ 2019-11-27 23:55  爱喝可乐的咖啡  阅读(305)  评论(0编辑  收藏  举报