1.解释语言和编译语言差别
编译语言
把某一种高级语言程序等价地转换成另一种低级语言程序(如汇编语言或机器语言程序)的程序
解释性语言
把源语言写的源程序作为输入,但不产生目标程序,而是边解释边执行源程序
2.程序是怎么运行起来的
// hello.c
#include <stdio.h>
int main() {
printf("Hello World!\n");
return 0;
}
在Linux下,可以直接使用GCC来编译Hello World程序:
$ gcc hello.c
$ ./a.out
Hello World!
上述过程分解如下
- 预编译(预处理)
- 编译
- 汇编
- 链接
GCC 编译过程分解
3.编译流程
3.1 预编译(预处理)编译 汇编 链接
1.预编译(预处理)
预编译步骤将源代码文件hello.c以及相关头文件,如:stdio.h等预编译生成一个.i文件。对于C++程序,其源代码文件的扩展名可能是.cpp或.cxx,头文件的扩展名可能是.hpp,预编译生成.ii文件。
$ gcc -E hello.c -o hello.i (-E 表示只进行预编译)
$ cpp hello.c > hello.i
过程如下
预编译主要处理源代码中的以“#”开始的预编译指令,如:“#include”、“#define”等,其主要处理规则如下:
- 将所有的“#define”删除,并且展开所有的宏定义。
- 处理所有条件预编译指令,如:“#if”、“#ifdef”、“#else”、“#endif”。
- 处理“#include”预编译指令,将被包含的文件插入到该预编译指令的位置。该过程是递归进行的,因为被包含的文件可能还包含其他文件。
- 删除所有的注释“//”和“/ **/”。
- 添加行号和文件名标识,比如#2 “hello.c” 2,以便于编译时编译器产生调试试用的行号信息以及用于编译时产生编译错误或警告时能够显示行号。
- 保留所有的#pragma编译器指令,因为编译器须要试用他们。
预编译生成的.i文件不包含任何宏定义,因为所有的宏已经被展开,并且包含的文件也已经被插入到.i文件中。所以当我们无法判断宏定义是否正确或头文件包含是否正确时,可以查看预编译后的文件来确定问题。
2.编译
编译就是把预处理生成的文件进行一系列词法分析、语法分析、语义分析、优化,生成相应的汇编代码文件。这个过程是整个程序构建的核心部分,也是最复杂的部分之一。
编译步骤相当于执行如下命令:
1. $ gcc -S hello.i -o hello.s
或
2. $ gcc -S hello.c -o hello.s
现在版本的GCC把预编译和编译两个步骤合并成了一个步骤,使用一个叫cc1的程序来完成。该程序位于“/usr/lib/gcc/x86_64-linux-gnu/4.8/”,我们可以直接调用cc1来完成它:
$ /usr/lib/gcc/x86_64-linux-gnu/4.8/cc1 hello.c
事实上,对于不同的语言,预编译与编译的程序是不同的,如下所示:
- C:cc1
- C++:cc1plus
- Objective-C:cc1obj
- Fortran:f771
- Java:jc1
GCC是对这些后台程序的封装,它会根据不同的参数来调用预编译程序cc1、汇编器as、链接器ld。
3.汇编
汇编就是将汇编代码转换成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。汇编过程相对于编译比较简单,其没有复杂的语法、语义,也无需做指令优化,只是根据汇编指令和机器指令的对照表进行翻译。
汇编步骤相当执行如下命令:
$ gcc -c hello.s -o hello.o
或
$ gcc -c hello.c -o hello.o
GCC本质上是调用汇编器as来完成汇编步骤的,我们可以直接调用as来完成该步骤:
$ as hello.s -o hello.o
4.链接
最早的纸片机器 ->汇编语言->高级语言
为了更好地理解计算机程序的编译和链接的过程,我们简单地回顾计算机程序开发的历 史 一定会非常有益。计算机的程序开发并非从一开始就有着这么复杂的白动化编译、链接过 程。原始的链接概念远在高级程序语言发明之前就已经存在了,在最开始的时候,程序员(当 时程序员的概念应该跟现在相差很大了,先把一个程序在纸上写好,当然当时没有很高级的 语言,用的都是机器语言,甚至连汇编诺言都没有。当程序须委被运行时,程序员人工将他 写的程序写入到存储设各上,最原始的存储设备之一就是纸带,即在纸岸上打相应的孔。
现在问题来了,程序并不是一写好就永远不变化的,它可能会经常被修改。比如我们在 第1条指令之后、第5条指令之前插入了一条或多条指令,那么第5条指令及后面的指令的 位貴将会相应地往后移动,原先第一条指令的低4位的数字将需要相应地调整。在这个过程 中,程序员需要人工重新计算每个子程序或跳转的目标地址。当程序修改的时候,这此位置 都要重新计算,十分繁琐又耗时,并且很容易出错。这种重新计算各个目标的地址过程被叫 做重定位 (Relocation)。
如果我们有多条纸带的程序,这些程序之间可能会有类似的跨纸带之间的跳转,这种程 序经常修改导致跳转目标地址变化在程序拥有多个模块的时候更为严重。人工鄉定进行指令 的修正以确保所有的跳转目标地址都正确,在程序规模越水越人以后变得越来越复杂和繁琐。 没办法,这种黑暗的程序员生活是没有办法容忍的。先驱者发明了汇编语言,这相比机 器语言米说是个很大的进步。汇编语言使用接近人类的各种符号和标记来帮助记忆,比如指 令采用两个或三个字母的缩写,记佳“imp”比记住 0001xxxX 是跳转(iump)指令容易得 名了:汇编语言还可以使用符号来标记位置,比如一个符号“divide”表示一个除法子程序 的起始地址,比记住从某个位置开始的第几条指令是除法子程序方便得多。最重要的足,这 种符号的方法使得人们从具体的指令地址中逐步解放出来。比如前面纸带程序中,我们把刚 开始第5条指令开始的子程序命名为“foo”,那么第一条指令的汇编就是: jmp foo 当然人们可以使用这种符号命名子程序或跳转目标以后,不管这个“foo”之前插入或 滅少了多少条指令导致 “f00”目标地址发生了什么变化,汇编器在每次汇编程序的时候会 重新计算“foo”这个符号的地址,然后把所有引用到“foo”的指令修正到这个正确的地址。 整个过程不需要人工参与,对于一个有成百上千个类似的符号的程序,程序员终于摆脱了这 种低级的繁琐的调整地址的工作,用一句政治口号来说叫做“极大地解放了生产力”。符号 (Svmbol)这个概念随着汇编话言的普及迅凍被伸用,它用来表示一个地址.这个协址可 能是一段子程序(后来发展成函数)的起始地址, 也可以是一个变量的起始地址。
有了汇编语言以后,生产力大大提高了,随之而来的是软件的规模也开始日渐庞大。这 时程序的代码量也已经开始快速地膨账,导致人们要开始考愁将不同功能的代码以一定的方 式组织起米,使得更加容易阅读和理解,以便于日后修改和重复使用。自然而然,人们开始 将代码按照功能或性质划分,分别形成不同的功能模块,不同的模块之间按照层次结构或其 他结构米组织。这个在现代的软件源代码组织中很常见,比如在 C语言中,最小的单位是
变量和函数,若干个变量和两数组成一个模块,存放在一个“c”的源代码文件里,然后这 些源代码文件按照目录结构来组织。在比较高级的语言中,如Java 中,每个类是一个基本 的模块,若干个类模块组成一个包(Package),若干个包组合成一个程序。 在现代软件开发过程中,软件的规模往往都很大,动辄数百万行代码,如果都放在- 模块肯定无法想象。所以现代的大型软件往往拥有成千上万个模块,这些模块之间相互依赖 又相对独立。这种按照层次化及模块化存储和组织源代码有很多好处,比如代码更容易阅读 理解、重用,每个模块可以单独开发、编译、测试,改变部分代码不需要编译整个程序等,
在现代软件开发过程中,软件的规模往往都很大,动辄数百万行代码,如果都放在一个 模块肯定无法想象。所以现代的大型软件往往拥有成千上万个模块,这些模块之间相互依赖 又相对独立。这种按照层次化及模块化存储和组织源代码有很多好处,比如代码更容易阅读、 理解、重用,每个模块可以单独开发、编译、测试,改变部分代码不需要编译整个程序等。 在一个程序被分割成多个模块以后,这些模块之间最后如何组合形成一个单一的程序是 须解决的问题。模块之问如何组合的问题可以归结为模块之间如何通信的问题,最常见的属 于静态语言的 C/C++模块之间通信有两种方式, 一种是模块间的函数调用,另外一种是模块 间的变量访问。两数访问须知道目标函数的地址,变量访问也须知道目标变量的地址,所以 这两种方式都可以归结为一种方式,那就是模块间符号的引用。模块问依靠符号来通信类似 于拼图版,定义符号的模块多出一块区域,引用该符号的模块刚好少了那一块区域,两者- 拼接刚好完美组合(见图2-7)。这个模块的拼接过程就是本书的一个主题:链接 (Linking )。
链接主要是将前面步骤生成多个目标文件进行重定位等复杂的操作,从而生成可执行文件。链接可分为静态链接和动态链接。
3.2编译器做了什么
背景 为什么出现了编译器
词法分析,语法分析,语义分析,中间语言生成,目标代码生成与优化
下面我们以一行简单的C语言代码为例,简单描述从源代码(Source Code) 到最终目标代码的过程。代码示例如下:
// CompilerExpression.c
array[index] = (index + 4) * (2 + 6)\
1.词法分析
首先源代码被输入到扫描器(Scanner) ,扫描器的任务很简单,只是简单地进行词法分析,运用一种类似于有限状态机(Finite State Machine) 的算法将源代码的字符序列分割成一系列的记号(Token) 。
以上述代码为例,总共包含了28个非空字符,经过扫描后,产生了16个记号。
| 记号 | 类型 |
|---|---|
| array | 标识符 |
| [ | 左方括号 |
| index | 标识符 |
| [ | 右方括号 |
| = | 赋值 |
| ( | 左圆括号 |
| index | 标识符 |
| + | 加号 |
| 4 | 数字 |
| ) | 右圆括号 |
| * | 乘号 |
| ( | 左圆括号 |
| 2 | 数字 |
| + | 加号 |
| 6 | 数字 |
| ) | 右圆括号 |
词法分析产生的记号一般可以分为一下几类:关键字、字面量(包含数字、字符串等)和特殊符号(如加号、等号)。
在识别记号的同时,扫描器也完成了其他工作。如:将标识符存放到符号表,将数字、字符串常量存放到文字表等,以备后面的步骤使用。
有一个名为lex的程序可以实现词法扫描,它会按照用户之前描述好的词法规则将输入的字符串分割成一个个记号。正因为有这样一个程序存在,编译器的开发者就无需为每个编译器开发一个独立的词法扫描器,而是根据需要改变词法规则即可。
对于一些预处理的语言,c语言,他的宏替换和文件包含等工作一般不归编译器的范围,交给了一个独立的预处理器。
2.语法分析
语法分析器(Grammar Parser) 将对由扫描器产生的记号进行语法分析。从而产生语法树(Syntax Tree) 。整个分析过程采用了上下文无关语法(Context-freeGrammar) 的分析手段。简单地讲,由语法分析器生成的语法树是以表达式(Expression) 为节点的树。
以上述代码为例,其中的语句就是一个由赋值表达式、加法表达式、乘法表达式、数组表达式、括号表达式组成的复杂语句,下图所示为该语句经过语法分析器后生成的语法树。
在语法分析的同时,很多运算符号的优先级和含义也被确定下来了。如:乘法表达式的优先级比加法高,圆括号表达式的优先级比乘法高,等等。另外,有些符号具有多重含义,如“*”在C语言中可以表示乘法表达式,也可以表示对指针取内容的表达式,因此语法分析阶段必须对这些内容进行区分。如果出现了表达式不合法,如各种括号不匹配、表达式中缺少操作符等,编译器就会报告语法分析阶段的错误。
有一个名为yacc(Yet Another Compiler Compiler)的工具可以实现语法分析。其根据用户给定的语法规则对输入的记号序列进行解析,从而构建出语法树。对于不同的编程语言,编译器的开发者只需改变语法规则,而无需为每个编译器编写一个语法分析器。因此,其也称为“编译器编译器(Compiler Compiler)”
3.语义分析
语法分析仅仅完成了对表达式的语法层面的分析,但它并不了解这个语句的真正含义,如:C语言里两个指针做乘法运算是没有意义的,但这个语句在语法上是合法的。编译器所能分析的语义是静态语义(Static Semantic) ,所谓静态语义是指在编译期间可以确定的语义,与之对应的动态语义(Dynamic Semantic) 就是只有在运行期才能确定的语义。
静态语义通常包括声明和类型的匹配,类型的转换。比如当一个浮点型的表达式赋值给一个整型的表达式时,其中隐含了一个浮点型到整型的转换过程,语义分析过程中需要完成该步骤。比如讲一个浮点赋值给一个指针时,语义分析程序会发现这个类型不匹配,编译器将会报错。动态语义一般是指在运行期出现的语义相关的问题,比如将0作为除数是一个运行期语义错误。
经过语义分析阶段之后,整个语法树的表达式都被标识了类型,如果有些类型需要做隐式转换,语义分析程序会在语法树中插入相应的转换节点。下图所示为标记语义后的语法树。
4.中间语言生成
现代编译器有着很多层次的优化,源码优化器(Source Code Optimizer) 则是在源代码级别进行优化。上述例子中,(2 + 6)这个表达式可以被优化掉。因为它的值在编译期就可以被确定。下图所示为优化后的语法树。
事实上,直接在语法树上作优化比较困难,所以源代码优化器往往将整个语法树转换成中间代码(Intermediate Code) ,它是语法树的顺序表示,其实它已经非常接近目标代码了。但它一般与目标机器和运行时环境是无关的,比如它不包含数据的尺寸、变量地址和寄存器的名字等。
中间代码有很多种类型,在不同的编译器中有着不同的形式,比较常见的有:三地址码(Three-address Code) 、P-代码(P-Code) 。以三地址码为例,最基本的三地址码如下所示:
x = y op z
# 表示将变量y和z进行op操作后,赋值给x。
因此,可以将上述例子的代码翻译成三地址码:
t1 = 2 + 6
t2 = index + 4
t3 = t2 * t1
array[index] = t3
为了使所有的操作符合三地址码形式,这里使用了几个临时变量:t1、t2和t3。在三地址码的基础上进行优化时,优化程序会将2+6的结果计算出来,得到t1 = 6。因此,进一步优化后可以得到如下的代码:
t2 = index + 4
t2 = t2 * 8
array[index] = t2
中间代码将编译器分为前端(Front End) 和后端(Back End) 。编译器前端负责产生机器无关的中间代码,编译器后端负责将中间代码转换成目标机器代码。这样,对于一些可跨平台的编译器,它们可以针对不同的平台使用同一个前端和针对不同机器平台的数个后端。
比如clange就是一个前端工具,而LLVM则负责后端处理。GCC则是一个套装,包揽了前后端的所有任务。
5.目标代码生成与优化
- 目标代码生成
目标代码生成主要由代码生成器(Code Generator) 完成。代码生成器将中间代码转换成目标机器代码,该过程十分依赖目标机器,因为不同的机器有着不同的字长、寄存器、整数数据类型和浮点数数据类型等。
上述例子的中间代码,经过代码生成器的处理之后可能会生成如下所示的代码序列(以x86汇编为例,假设index的类型为int型,array的类型为int型数组):
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
2.目标代码优化
目标代码生成后,由目标代码优化器(Target Code Optimizer) 来进行优化。比如选择合适的寻址方式、使用位移来代替乘法运算、删除多余的指令等。
上述例子中,乘法由一条相对复杂的基址比例变址寻址(Base Index Scale Addressing) 的lea指令完成,随后由一条mov指令完成最后的赋值操作,这条mov指令的寻址方式与lea是一样的。如下所示为优化后的目标代码:
movl index, %edx
leal 32(,%edx,8), %eax
movl %eax, array(,%edx,4)
经过扫描、语法分析、语义分析、源代码优化、目标代码生成、目标代码优化等一系列步骤之后,源代码终于被编译成了目标代码。但是这个目标代码中有一个问题:
index和array的地址还没有确定
如果我们把目标代码使用汇编器编译成真正能够在机器上运行的指令,那么index和array的地址来自哪里?
如果index和array定义在跟上面的源代码同一个编译单元里,那么编译器可以为index和array分配空间,确定地址;但如果是定义在其他的程序模块呢?
事实上,定义其他模块的全局变量和函数在最终运行时的绝对地址都要在最终链接的时候才能确定。所以现代编译器可以将一个源文件编译成一个未链接的目标文件,然后由编译器最终将这些目标文件链接起来形成可执行文件。(引出链接器)
3.3 编译器前端 后端
- 传统编译器的设计
-
编译器前端(Frontend) 编译器前端的任务是解析源代码。它会进行:词法分析、语法分析、语义分析,检查源代码是否存在错误,然后构建抽象语法树,LLVM的前端会生成中间代码IR。
-
优化器(Optimizer) 优化器负责进行各种优化。改善运行时间,例如消除冗余计算等。
-
后端(Backend) 也可以叫代码生成器(CodeGenerator),将代码映射到目标指令集。生成机器语言,并且进行机器相关的代码优化。
随着高级语言越来越多,终端类型种类的增加,所使用的的CPU架构等也不尽相同。
所以为了适配多种环境,不得不设计不同的编译器,而这些编译器前端和后端往往是捆绑在一起的。
LLVM的设计之初,即将编译器前端(Frontend)和后端(Backend)进行了分离。\
将前端和后端针对不同的架构,按照独立的项目进行研发,而它们均采用通用的代码形式IR。\
当编译器决定支持多种语言或多种硬件架构时,LLVM最重要的地方就体现出来了,使用通用的代码表示形式(IR),它是用来在编译器中表示代码的形式。
所以LLVM可以为任何编程语言独立编写前端,并且可以为任意硬件架构独立编写后端。\
ios 编译架构
Objective C/C/C++使用的编译器前端是Clang,Swift是Swift,后端都是LLVM。\
4.链接器
连接器 介绍 背景
4.1 静态链接
当我们有多个目标文件时,如何将它们链接起来形成一个可执行文件呢?过程是怎么样的,这就是链接的核心内容:静态链接
如模块 a和模块 通过gcc编译器将a.c和b.c编译成目标文件a.o和b.o
gcc -c a.c b.c
经过编译,从代码中可以看b.c到定义了2个全局符号,变量shared 和 函数swap ,a.c 定义了全局符号main,模块a.c 引用了b.的 shared 和 swap,下面把a.c和b.c 链接到一起,最终为一个可执行文件
1.空间与地址分配
可执行文件中的段是由目标文件中的节合并而来的。那么,我们的第一个问题是:对于多个输入目标文件,链接器如何将它们的各个节合并到输出文件呢?或者说,输出文件中的空间如何分配给输入文件。
按序叠加
一个最简单的方案就是将输入的文件按序叠加,将目标文件依次合并
虽然这种方法非常简单,但是它存在一个问题:在有很多输入文件的情况下,输出文件会有很多零散的节。这种做法非常浪费空间,因为每个节都需要有一定的地址和空间对齐要求。x86硬件的对齐要求是4KB。如果一个节的大小只有1个字节,它也要在内存在重用4KB。这样会造成大量内部碎片。所以不是一个好的方案。
相似段合并
一个更加实际的方法便是合并相同性质的节,比如:将所有输入文件的 .text节合并到输出文件的 text段(注意,此时出现了段和节两个概念),如下图所示。
其中.bss节在目标文件和可执行文件中不占用文件的空间,但是它在装载时占用地址空间。事实上,这里的空间和地址有两层含义:
- 在输出的可执行文件中的空间
- 在装载后的虚拟地址中的空间
对于有实际数据的节,如.text和.data,它们在文件中和虚拟地址中都要分配空间,因为它们在这两者中都存在;对于.bss来,分配空间的意义只局限于虚拟地址空间,因为它在文件中并没有内容。我们在这里谈到的空间分配只关注于虚拟地址空间的分配,因为这关系到链接器后面的关于地址计算的步骤,而可执行文件本身的空间分配与链接的关系并不大。
现在的链接器空间分配的策略基本上都采用“合并相似节”的方法,使用这种方法的链接器一般采用一种叫 两步链接(Two-pass Linking) 的方法。即整个链接过程分为两步:
-
第一步 地址与空间分配
扫描所有的输入目标文件,获得它们的各个节的长度、属性、位置,并将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局的符号表。这一步,链接器能够获得所有输入目标文件的节的长度,并将它们合并,计算出输出文件中各个节合并后的长度与位置,并建立映射关系。 -
第二步 符号解析与重定位
使用前一步中收集到的所有信息,读取输入文件中节的输数据、重定位信息,并且进行符号解析与重定位、调整代码、调整代码中的地址等。事实上,第二步是链接过程的核心,尤其是重定位。
在地址与空间分配步骤完成之后,相似权限的节会被合并成段,并生成了ELF文件结构一文中没有介绍的 程序头表(Program Header Table) 结构。如下右图可执行文件结构所示,主要生成两个段:代码段( text段)、数据段( data段 )。
可以发现,链接前目标文件中所有节的 VMA(Virtual Memory Address) 都是0,因为虚拟空间还没有分配。链接后,可执行文件ab中各个节被分配到了相应的虚拟地址,如.text节被分配到了地址0x0000000000400450。
那么,为什么链接器要将可执行文件ab的.text节分配到0x0000000000400450?而不是从虚拟空间的0地址开始分配呢?这涉及到操作系统的进程虚拟地址空间的分配规则。在Linux x86-64系统中,代码段总是从0x0000000000400000开始的,另外.text节之前还有ELF Header、Program Header Table、.init等占用了一定的空间,所以就被分配到了0x0000000000400450。
符号地址确定
符号解析
在两步链接中,这一步和重定位被合并成了一步,这是因为重定位的过程是伴随着符号解析的。这里我们分开介绍。
链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。对那些和引用定义在相同模块的局部符号的引用,符号解析是非常简单的。编译器只允许每个模块中每个局部符号有一个定义。静态局部变量也会有本地链接器符号,编译器还要确保它们拥有唯一的名字。
然而,对于全局符号的解析要复杂得多。当编译器遇到一个不是在当前模块中定义的符号(变量或函数名)时,会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目,并把它交给链接器处理。如果链接器在它的任何输入模块中都找不到这个被引用符号的定义,就输出一条错误信息并终止。
另一方面,对全局符号的解析,经常会面临多个目标文件可能会定义相同名字的全局符号。这种情况下,链接器必须要么标志一个错误,要么以某种方法选出一个定义并抛弃其他定义。
COMMON块(### 多重定义的全局符号解析)
链接器的输入是一组可重定位目标模块。每个模块定义一组符号,有些是局部符号(只对定义该符号的模块可见),有些是全局符号(对其他模块也可见)。如果多个模块定义同名的全局符号,该如何进行取舍?
Linux编译系统采用如下的方法解决多重定义的全局符号解析:
在编译时,编译器想汇编器输出每个全局符号,或者是强(strong)或者是弱(weak),而汇编器把这个信息隐含地编码在可重定位目标文件的符号表中。
根据强弱符号的定义,Linux链接器使用下面的规则来处理多重定义的符号名:
- 规则1:不允许有多个同名的强符号。
- 规则2:如果有一个强符号和多个弱符号同名,则选择强符号。
- 规则3:如果有多个弱符号同名,则从这些弱符号中任意选择一个。
另一方面,由于允许一个符号定义在多个文件中,所以可能会导致一个问题:如果一个弱符号定义在多个目标文件中,而它们的类型不同,怎么办?这种情况主要有三种:
- 情况1:两个或两个以上的强符号类型不一致。
- 情况2:有一个强符号,其他都是弱符号,出现类型不一致。
- 情况3:两个或两个以上弱符号类型不一致。
其中,情况1由于多个强符号定义本身就是非法的,所以链接器就会报错。对于后两种情况,编译器和链接器采用一种叫 COMMON块(Common Block
) 的机制来处理。其过程如下:
首先,编译器将未初始化的全局变量定义为弱符号处理。对于情况3,最终链接时选择最大的类型。对于情况2,最终输出结果中的符号所占空间与强符号相同,如果链接过程中有弱符号大于强符号,链接器会发出警告。
重定位表 事实上,重定位过程也伴随着符号的解析过程。链接的前两步完成之后,链接器就已经确定所有符号的虚拟地址了,那么链接器就可以根据符号的地址对每个需要重定位的指令进行地址修正。
那么链接器如何知道哪些指令是要被调整的呢?事实上,我们前面提到的ELF文件中的 重定位表(Relocation Table) 专门用来保存这些与重定位相关的信息。
对于可重定位的ELF文件来说,它必须包含重定位表,用来描述如何修改相应的节的内容。对于每个要被重定位的ELF节都有一个对应的重定位表。如果.text节需要被重定位,则会有一个相对应叫.rel.text的节保存了代码节的重定位表;如果.data节需要被重定位,则会有一个相对应的.rel.tdata的节保存了数据节的重定位表。
我们可以使用objdump工具来查看目标文件中的重定位表:
我们可以看到每个要被重定位的地方是一个 重定位入口(Relocation Entry) 。利用数据结构成员包含的信息,即可完成重定位。
** 指令修正方式
4.2 动态链接
为什么要动态链接?
静态链接使得不同的程序开发者和部门能够相对独立地开发和测试自己的程序模块,从 某种意义上来讲大大促进了程序开发的效率,原先限制程序的规模也随之扩大。但是慢慢地 静态链接的诸多缺点也逐步暴露出来,比如浪费内存和磁盘空间、模块更新困难等问题,使 得人们不得不号找一种更好的方式来组织程序的模块
内存和磁盘空间
静态链接这种方法的确很简单,原理上很容易理解,实践上很难实現,在操作系统和硬 件不发达的早期,绝大部分系统采用这种方案。随着计算机软件的发展,这种方法的缺点很 快就暴露出来了,那就是静态连接的方式对于计算机内存和磁盘的空间浪费非常严重。特别 是多进程操作系统情况下,静态链接极大地浪费了内存空间,想象一下每个程序内部除了都 保留着 printf()函数、scanf()函数、strlen()等这样的公用库函数,还有数量相当可观的其他库 函数及它们所需要的辅助数据结构。在现在的 Linux 系统中, 一个普通程序会使用到的C 语言静态库至少在 1MB 以上,那么,如果我们的机器中运行着100 个这样的程序,就要浪 费近100 MB 的内存:如果磁盘中有2000 个这样的程序,就要浪费近 2 GB 的磁盘空间, 很多 Linux 的机器中,/usr/bin下就有数干个可执行文件。
比如图 了-1 所示的Programl 和 Program2 分别包含Programl.0 和Program2.0两个模块,
并且它们还共用 Lib.o 这两模块。在静态连接的情况下,因为 Programl 和 Program2 都用到 了 Lib.o这个模块,所以它们同时在链接输出的可执行文件 Program1 和 Program2 有两个副 本。当我们同时运行 Programl 和 Program2 时, Lib.。在磁盘中和内存中都有两份副本。当 系统中存在大最的类似于Lib.。 的被多个程序共享的目标文件时,其中很大一部分空间就被 浪费了。在静态链接中,C语言静态库是很典型的浪费空间的例子,还有其他数以千计的库 如果都需要静态链接,那么空间浪费无法想象
程席开发和发布 空间浪费是静态链接的一个问题,另一个问题是静态链接对程序的更新、部署和发布也 会带来很多麻烦。比如程序 Program1 所使用的 Lib.o是由一个第三方厂商提供的,当该厂 商更新了 Lib.o。的时候(比如修正了i.。里面包含的- 一个Bug),那么Programl 的厂商就需 要拿到最新版的 Lib.o,然后将其与 Program1.o链接后,将新的 Program1 整个发布给用户 这样做的缺点很明显,即一旦程序中有任何模块更新,整个程序就要重新链接、发布给用户。 比如一个程序有20个模块,每个模块 1MB,那么每次更新任何一个模块,用户就得重新获 取这个 20 MB 的程序。如果程序都使用静态链接,那么通过网络来更新程序将会非常不便, 因为一旦程序任何位置的一个小改动,都会导致整个程序重新下载
动态链接 要解决空间浪费和更新困难这两个问题最简单的办法就是把程序的模块相互分割开来, 形成独立的文件,而不再将它们静态地链接在一起。简单地讲,就是不对那些组成程序的目 标文件进行链接,等到程序要运行时才进行链接。也就是说,把链接这个过程推迟到了运行 时再进行,这就是动态链接(Dynamic Linking)的基本思想。 还是以 Programl 和Program2 为例,假设我们保留 Programl.0、Program2.0 和 Lib.o 个目标文件。当我们要运行 Program1 这个程序时,系统首先加载 Program1.0,当系统发现 Programl.0 中用到了 Lib.o,即 Program1.0依赖于 Lib.o,那么系统接着加载 Lib.o,如果 Program1.0或 Lib.o还依赖于其他目标文件,系统会按照这种方法将它们全部加载至内存。 所有需要的目标文件加载完牛之后,如果依赖关系满足,即所有依赖的目标文件都存在于磁 盘,系统开始进行链接工作。这个链接王作的原理与静态链接非常相似,包括符号解析、地 址重定位等,我们在前面己经很详细地介绍过了。完成这些步骤之后,系统开始把控制权交 给 Programl.∞ 的程序入口处,程序开始运行。这时如果我们需要运行 Program2,那么系统 只需要加载 Program2.0,布不需要重新加载 Lib.0,因为内存中己经存在了一份 Lib.o 的副本 (见图7-2),系统要做的只是将 Program2.0和Lib.o链接起来。
优点
很明显,上面的这种做法解决了共享的目杯文件多个副本浪费磁盘和内存空间的问题, 可以看到,磁盘和内存中只存在一份 Lib.0,而不是两份。另外在内存中共享一个目标文件
模块的好处不仅仅是节省内存,它还可以减少物理页面的换入换出,也可以增加 CPU 缓存
的命中率,因为不同进程间的数据和指令访问都集中在了同一个共享模块上。
上面的动态链接方案也可以使程序的升级变得更加容易,当我们要升级程序库或程序共
享的某个模块时,理论上只要简单地将旧的目标文件覆盖掉,而无须将所有的程序再重新链
接一遍。当程序下一次运行的时候,新版本的目标文件会被自动装载到内存并且链接起来,
程序就完成了升级的目标。
当一个程序产品的规模很大的时候,往往会分割成多个子系统及多个模块,每个模块都
由独立的小组开发,甚至会使用不同的编程语言。动态链接的方式使得开发过程中各个模块
更加独立,男合度更小,便于不同的开发者和开发组织之间独立进行开发和测试。
程序可扩展性和莱容性
动态链接还有一个特点就是程序在运行时可以动态地选择加载各种程序模块,这个优点 就是后来被人们用来制作程序的插件 (plug-in)。 比如某个公司开发完成了菜个产品,它按照一定的规则制定好程序的接口,其他公司或 开发者可以按照这种接口来编写符合要求的动态链接文件。该产品程序可以动态地载入各种 由第三方开发的模块,在程序运行时动态地链接,实现程序功能的扩展。 动态链接还可以加强程序的兼容性。一个程序在不同的平台运行时可以动态地链接到由
操作系统提供的动态链接库,这些动态链接库相当于在程序和操作系统之间增加了一个中间 层,从市消除了程序对不同平台之间依赖的差异性。比如操作系统 A 和操作系统 B对于 printfO的实现机制不同,如果我们的程序是静态链接的,那么程序需要分别链接成能够在A 运行和在 B运行的两个版本并且分开发布;但是如果是动态链接,只要操作系统 A 和操作 系统B都能提供一个动态链接库包含printf0,并且这个printfo使用相同的接口,那么程序 只需要有一个版本,就可以在两个操作系统上运行,动态地选择相应的printfo的实现版本。 当然这只是理论上的可能性,实际上还存在不少问题,我们会在后面继续探讨关于动态链接 模块之间兼容性的问题. 从上面的描述来看,动态链接是不是一种 “万能音药”,包治百病呢?很遗憾,动态链 接也有诸多的问题及令人烦恼和费解的地方。很常见的一个问题是,当程序所依赖的某个模 块更新后,由于新的模块与旧的模块之间接口不兼容,导致了原有的程序无法运行。这个问 题在早期的 Windows 版本中尤为亚重,因为它们缺少一种有效的共享库版本管理机制,使 得用户经常出现新程序安装完之后,其他某个程序无法正常工作的现象,这个问题也经常被 称为“DLL Hell”.
动态链接涉及运行时的链接以及多个文件的装载,必需要有操作系统的支持。因为动态链接的情况下,进程的虚拟地址空间的分布会比静态链接情况下更为复杂,还有一些存储管理、内存共享、进程线程等机制在动态链接下也会有一些微妙的变化。
目前,主流操作系统都支持动态链接。在Linux中,ELF动态链接文件被称为 动态共享对象(DSO,Dynamic Shared Objects) ,一般以.so为后缀;在Windows中,动态链接文件被称为 动态链接库(Dynamic Linking Library) ,一般以.dll为后缀。
在Linux中,常用的C语言库的运行库glibc,其动态链接形式的版本保留在 /lib目录下,文件名为 libc.so。整个系统只保留一份C语言动态链接文件libc.so,所有的C语言编写的、动态链接的程序都可以在运行时使用它。当程序被装载时,系统的动态链接器会将程序所需要的所有动态链接库装载到进程的地址空间,并将程序中所有未解析的符号绑定到相应的动态链接库中,并进行重定位。
动态链接缺点和解决方法
程序与 iibc.so 之间真正的链接工作是由动态链接器完成的,而不是由我们前面看到过 的静态链接器 1d 完成的。也就是说,动态链接是把链接这个过程从本来的程序装载前被推 迟到了装载的时候。可能有人会问,这样的做法的确很灵活,但是程序每次被装载时都要进 行重新进行链接,是不是很慢?的确,动态链接会导致程序在性能的一些损失,但是对动态 链接的链接过程可 以进行优化,比如我们后面要介绍的延迟鄉定 (Lazy Binding) 等方法, 可以使得动态链接的性能损失尽可能地滅小。据估算,动态链接与静态链接相比,性能损失 大约在 5%以下。当然经过实践的证明,这点性能损失用来换取程序在空间上的节省和程序 构建和升级时的灵活性,是相当值得的。
举例
Lib.c 被编译成 Lib.so共享对象文件,Programlc 被编译成 Programl.0之后,链接成为 可执行程序 Programl。图7-3 中有一个步骤与静态链接不一样,那就是 Programl.。被连接 成可执行文件的这一步。在静态链接中,这一步链接过程会把 Programl.o 和Lib.o 链接到 起,并且产生输出可执行文件 Programl。但是在这里,Lib.。 没有被链接进来,链接的输入 目标文件只有 Program1.。〔当然还有C语言运行库,我们这里暂时忽略)。但是从前面的命 令行中我们看到,Lib.so 也参与了链接过程。这是怎么回事呢? 关于模块:<Module)× 在静态链接时,整个程序最终只有一个可执行文件,它是一个不可以分割的整体;但 是在动态链接下,一个程序被分成了若千个文件,有程序的主要部分,即可执行文件 1Program1〕和程序所依赖的共享对象(Lib.so),很多时候我们也把这些部分称为模 块,即动态链接下的可执行文件和共享对象都可以看作是程序的一个模块。 让我们再回到动态链接的机制上米,当程序模块 Programl.c 被编译成为 Programl.0 时, 编详器还不不知道foobar0西数的地址,这个内容我们己在静态链接中解释过了。当链接器 将Program 1.0链接成可执行文件时,这时候链接器必须确定 Program1.0 中所引用的 foobar0 函数的性质。如果foobar0是一个定义与其他静态目标模块中的函数,那么链接器将会按照 静态链接的规则,将Program1.0 中的foobar 地址引用重定位:如果 foobar0是 一个定义在某 个动态共享对象中的函数,那么链接器就会将这个符号的引用标记为一个动态链接的符号, 不对它进行地址重定位,把这个过程留到装载时再进行。 那么这里就有个问题,链接器如何知道foobar 的引用是 一个静态符号还是一个动态符 号?这实际上就是我们要用到 Lib.so的原因。Lib.so中保存了完整的符号信息(因为运行时 进行动态链接还须使用符号信息),把Lib.so 也作为链接的输入文件之一,链接器在解析符 号时就可以知道:foobar 是一个定义在Lib.so 的动态符号。这样键接器就可以对 foobar 的引 用做特殊的处理,使它成为一个对动态符号的引用。
具体步骤
地址空间分配
对于静态链接的可执行文件来说,整个进程只有一个文件要被映射,即可执行文件。而对于动态链接,除了可执行文件,还有它所依赖的共享目标文件。
关于共享目标文件在内存中的地址分配,主要有两种解决方案,分别是:
- 静态共享库(Static Shared Library) (地址固定)
- 动态共享库(Dynamic Shared Libary) (地址不固定)
装载重定位
我们前面在静态链接时提到过重定位,那时的重定位叫做链接时重定位 ( Link Time Relocation),而现在这种情况经常被称为装载时重定位 (Load Time Relocation ),在 Windows 中,这种装载时重定位又被叫做基址重置 (Rebasing),我们在后面将会有专门章 节分析基址重置。 这种情况与我们碰到的问题很相似,都是程序模块在编详时目标地址不确定而需要在装 裁时将模块蛋定位,但是装载时重定位的方法并不适合用来解决上面的共享对象中所存在的 问题。可以想象,动态链接模块被装载映射至康拟空间后,指今部分是在名个讲程之间北享 的,由于装载时重定位的方法需要修改指令,所以没有办法做到同一份指令被多个进程共享, 因为指令被重定位后对于每个进程来讲是不同的。当然,动态连接库中的可修改数据部分对 手不同的进程来说有多个副本,所以它们可以采用装载时重定位的方法来解决。
具体是怎么做到的
地址无关代码
计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。
地址无关代码(PIC,Position-independent Code) 技术完美阐释了上面这句名言,其基本原理是:把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。
共享对象模块中的地址引用按照是否为跨模块分为两类:模块内部引用、模块外部引用。按照不用的引用方式又可分为:指令引用、数据引用。以如下代码为例,可得出如下四种类型:
- 类型1:模块内部的函数调用。
- 类型2:模块内部的数据访问,如模块中定义的全局变量、静态变量。
- 类型3:模块外部的函数调用。
- 类型4:模块外部的数据访问,如其他模块中定义的全局变量。
static int a;
extern int b;
extern void ext();
void bar() {
a = 1; // 类型2:模块内部数据访问
b = 2; // 类型4:模块外部数据访问
}
void foo() {
bar(); // 类型1:模块内部函数调用
ext(); // 类型4:模块外部函数调用
}
类型1 模块内部函数调用
由于被调用的函数与调用者都处于同一模块,它们之间的相对位置是固定的。对于现代的系统来说,模块内部的调用都可以是相对地址调用,或者是基于寄存器的相对调用,所以对于这种指令是不需要重定位的。
类型2 模块内部数据访问
一个模块前面一般是若干个页的代码,后面紧跟着若干个页的数据,这些页之间的相对位置是固定的,即任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,所以只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了。
"类型3 模块间数据访问")类型3 模块间数据访问
模块间的数据访问比模块内部稍微麻烦一些,因为模块间的数据访问目标地址要等到装载时才决定。此时,动态链接需要使用代码无关地址技术,其基本思想是把地址相关的部分放到数据段。ELF的实现方法是:在数据段中建立一个指向这些变量的指针数组,也称为全局偏移表(Global Offset Table,GOT) ,当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用。过程示意图如下所示:
类型4 模块间函数调用
对于模块间函数调用,同样可以采用类型3的方法来解决。与上面的类型有所不同的是,GOT中响应的项保存的是目标函数的地址,当模块需要调用目标函数时,可以通过GOT中的项进行间接跳转。
延迟绑定(PLT) 动态链接的确有很多优势,比静态链接要灵活得多,但它是以牺牲一部分性能为代价的。 据统计 ELF 程序在静态链接下要比动态库稍微快点,大约为 1%~5%,当然这取决于程序 本身的特性及运行环境等。我们知道动态链接比静态链接慢的主要原因是动态链接下对于全 局和静态的数搭访问都要进行复东的 GOT 定位,然后问接寻址;对于模块间的调用也要先 定位 GOT,然后再进行间接跳转,如此一来,程序的运行速度必定会减慢。另外一个减慢 运行速度的原因是动态链接的链接工作在运行时完成,即程序开始执行时,动态链接器都要 进行一次链接工作,正如我们上面提到的,动态链接器会寻找并装载所需要的共享对象,然 后进行符号查找地址重定位等工作,这些工作势必减慢程序的启动速度。这是影响动态链接 性能的两个主要问题,我们将在这一节介绍优化动态链接性能的- 一些方法。 延迟绑定实现 在动态链接下,程序模块之间包含了大最的西数引用(全局变量往往比较少,因为大量 的全局变量会导致模块之间糯合度变大),所以在程序开始执行前,动态链接会耗费不少时 问用于解决模块之间的函数引用的符号查找以及重定位,这也是我们上面提到的减慢动态链 接性能的第二个原因。不过可以想象,在- -个程序运行过程中,可能很名两数在程序执行完 时都不会被用到,比如一些错误处理两数或者是 一些用户很少用到的功能模块等,如果一开 始就把所有函数都链接好实际上是 一种浪费。所以 ELF 采用了一种叫做延迟绑定(Lazy Binding)的做法,基本的思想就是当西数第一次被用到时才进行鄉定(符号香找、重定位 等),如果没有用到则不进行鄉定。所以程序开始执行时,模块间的函数调用都没有进行绑 定,而是需要用到时才由动态链接器水负责都定。这样的做法可以大大加快程序的启动速度, 特别有利于一此有大量函数引用和大量模块的程序。
动态链接的例子可以看苹果的动态连接器
5.苹果的动态链接器
dyld
6.应用
启动优化 静态分析