程序的编译与链接

一、编译链接的步骤

1.1预处理

命令:gcc -E hello.c -o hello.i

预编译主要处理源文件中以#开始的预编译指令

  • 将#define删除,并展开所有的宏定义
  • 处理条件预编译指令,如#if,#ifdef,#ifndef,#elif,#else,#endif
  • 处理#include预编译指令,将被包含的文件插入到该预编译指令的位置。这个过程是递归的,即被包含的文件还可能包含其他文件。
  • 删除所有的注释
  • 添加行号和文件名标识,便于编译时编译器产生调试用的行号或编译错误警告时的行号
  • 保留所有的#pragma编译器指令,因为编译器需要使用它们

1.2编译

命令:gcc -S hello.i -o hello.s

编译是将预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生成的汇编代码文件。实际上,gcc这个命令只是某些程序的包装,它会根据不同的参数要求调用预编译编译程序cc1、汇编器as、链接器ld。

  • 词法分析:运用类似有限状态机的算法将源程序的字符序列分割成一系列记号(token)
  • 语法分析:通过上下文无关语法对token进行语法分析形成语法树
  • 语义分析:将语法树的表达式标识类型,如果有类型需要做隐式转换,语义分析程序会在语法树上插入相应的转换节点。
  • 中间代码生成:在源代码级别进行优化,比如将2+6优化成8,最常见的中间代码类型是三地址码,即x = y op z,将y,z进行op操作后的结果赋给x。
  • 目标代码生成及优化:编译器后端主要包括代码生成器和目标代码优化器。代码生成器将中间代码转换成目标机器代码,这个过程依赖目标机器。目标代码优化器对目标代码进行优化,比如选择合适的寻址方式等。

1.3汇编

命令:gcc -c hello.s -o hello.o

汇编将汇编代码转成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。汇编器没有优化,只是根据汇编指令和机器指令的对照表翻译即可。

1.4链接

链接就是把各个模块之间相互引用的部分处理好,包括变量的地址和函数的地址,统称为符号的地址。链接的过程主要包括地址和空间分配、符号决议(symbol resolution)和重定位等。

举例来说,在程序模块main.c中使用另一个模块func.c中的函数foo(),则在main.c中每一处调用foo的位置都需要知道foo函数的地址,但是由于每个模块是单独编译的,编译器编译main.c的时候并不知道foo函数的地址,所以它将调用foo的指令的目标地址搁置(地址为0),等待最后链接的时候由链接器将指令的目标地址修正,这个地址修正的过程就叫重定位。当func.c模块被重新编译,foo函数的地址有可能改变,则main.c中所有用到foo地址的指令都需要更新其中的地址。

二、深入目标文件

2.1目标文件的格式

目标文件从结构上讲,它已经是编译后的可执行文件格式,只是还没有经过链接的过程,其中有些符号的地址还没有调整。但是本身就是按照可执行文件格式存储的,只是跟真正的可执行文件在结构上稍有不同。静态库、动态库、可执行文件都是按照可执行文件的格式去存储,统称为ELF文件,可以使用file 文件名查看文件格式。

ELF格式文件
   ELF文件类型                                                     说明             实例
可重定位文件包括了代码和数据,静态链接库也是这类

Linux的.o

Windows的.obj

可执行文件包含了可直接执行的程序,一般没有扩展名

/bin/bash文件

Windows的.exe

共享目标文件包含了代码和数据,可用于产生新的共享目标文件或可执行文件

Linux的.so

Windows的.dll

核心转储文件进程意外终止,系统可将该进程的地址空间和内容转储到核心转储文件Linux的core dump

 

2.2目标文件的内容

ELF文件开始是一个文件头,它描述了整个文件的文件属性,包括文件是否可执行、是静态链接还是动态链接、目标硬件、目标操作系统等信息。文件头还包括一个段表,段表描述了文件中各个段在文件中的长度,偏移位置及段的属性等,可以通过命令readelf -h 文件名查看。一般C语言编译后执行语句的机器代码保存在.text段,已初始化的全局变量和局部静态变量保存在.data段,未初始化的全局变量保存在.bss段,.bss段只是为未初始化的全局变量和局部静态变量预留位置而已,它并没有内容,所以它在文件中也不占空间。可以使用objdump -h 文件名将ELF文件的各个段的基本信息打印出来,Size表示段的大小,File off表示段所在的位置。使用命令objdump -s -d 文件名,表示将所有段的内容以二进制的方式打印,-d表示将包含指令的段反汇编。

总体来说,程序源代码被编译成两种段:指令和数据,代码段属于指令,数据段属于数据。为什么妖精程序的指令和数据分开呢?首先程序被装载后,数据和指令分别被映射到两个虚存区域,数据区是可读可写的,指令区是只读的,其次CPU的缓存一般是数据缓存和指令缓存分离,程序的指令和数据分开存放可以提高CPU的缓存命中率。最后如果系统中运行该程序的多个副本时,它们的指令都是一样的,所以保存一份即可。

2.3符号修饰与函数签名

现代Unix的C语言规定,源代码中所有的全局变量和函数经过编译以后,符号名和变量名/函数名一致,C++为支持函数重载使用了符号修饰与函数签名,函数签名包括函数名,参数类型,所在类和命名空间等信息,函数签名用于识别不同的函数,编译器及链接器处理符号时,使用某种符号修饰的方法,使得每个函数签名对应一个修饰后的名称,即C++编译源代码后的目标文件所使用的符号名是函数/变量被修饰后的名称。

2.4强符号和弱符号

编译器默认函数和初始化了的全局变量是强符号,未初始化的全局变量是弱符号。链接器按照如下规则处理多次定义的全局符号:

  • 不允许强符号被多次定义(不同目标文件中不能有同名的强符号),如果有则链接器报重复定义错误。
  • 如果一个符号在某个目标文件中是强符号,在其他文件中都是弱符号,那么选择强符号。
  • 如果一个符号在所有目标文件中都是弱符号,那么选择占用内存空间最大的那个。比如目标文件A定义全局变量x是int,占4B,目标文件B定义x是double,占8B,则A和B链接后,x占8B。

2.5强引用和弱引用

如果链接器在链接目标文件时,没有找到强引用符号的定义,就会报符号未定义错误,但是在处理弱引用时,如果该符号有定义则使用该定义,如果没有定义链接器也不会报错,一般对于未定义的弱引用,链接器默认其值为0,或者是一个特殊的值。

三、静态链接

命令:ar -cr lib.a a.o b.o,-c表示不显示警告,-r表示替换已存在或者新建.a静态库,-t表示显示静态库包含的文件,-x表示解压。

链接就是修正目标文件之间对地址的引用,读取各个段的数据和重定位信息,并且进行重定位与符号解析、调整代码中的地址等。

对于多个输入目标文件,链接器采用相似段合并的方法将它们各自段合并到输出文件,即将所有输入目标文件的.text段合并到输出文件的.text段,.data段和.bss段等都采取这样的方法。

“链接器为目标文件分配地址和空间”这句话中的“地址和空间”有两个含义,第一个是输出的可执行文件中的空间,第二个是装载后的虚拟地址空间。对于有实际数据的段,比如.text和.data来说,它们在文件中和虚拟地址中都要分配空间,因为它们在这两者中都存在,而对于.bss段来说,分配空间的意义只在于虚拟地址空间,因为它在文件中并没有内容,而我们关注的也只限于虚拟地址空间的分配,因为这个关系到链接器后面的关于地址的计算。

现在链接器都采取两步链接的方法,就是说链接过程分为两步,第一步是空间与地址分配,第二步是符号解析与重定位。

3.1空间与地址分配

扫描所有的输入目标文件,获得各个段的长度、属性和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表。这一步链接器将能够获得所有输入目标文件的段长度,并且把它们合并,计算出输出文件中各个段合并后的长度与位置,并建立映射关系。

用objdump -h 可执行文件查看段属性,VMA表示Virtual Memory Address,即虚拟地址,LMA表示Load Memory Address,一般情况下两者相同,这里只需要关心VMA。链接前,目标文件中的所有段的VMA都是0,因为还没有分配虚拟地址,链接后,可执行文件的各个段都分配到了相应的虚拟地址。

因为各个符号在段内的相对地址是固定的,所以各函数的地址也是确定的,只不过链接器需要给每个符号加上一个偏移量,使它们能够调整到正确的虚拟地址。

3.2重定位

ELF文件中有一个重定位表专门用来保存与重定位相关的信息。比如代码段.text中有被重定位的地方,就会有一个相应段.rela.text保存了代码段的重定位表,如果数据段.data中有被重定位的地方,就会有一个相应段.rela.data保存了数据段的重定位表,可以使用objdump -r 目标文件查看目标文件的重定位表。每个要被重定位的地方叫做重定位入口,重定位入口的偏移(offset)表示该入口在要被重定位的段中的位置。

3.3符号解析

重定位过程伴随着符号的解析过程,每个目标文件都可能定义一些符号,也可能引用到定义在其他目标文件的符号。重定位的过程中,每个重定位入口都是对一个符号的引用,那么当链接器要对某个符号的引用进行重定位时,它就要确定这个符号的目标地址,这时候链接器就会去查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位。目标文件中所有未定义的符号都应该能够在全局符号表中找到,否则链接器就会报符号未定义错误。

每一个目标文件都有一个符号表,其中记录了目标文件所有用到的符号,可以使用命令nm 文件名查看符号表,也可以通过readelf -s 文件名查看符号表,第一列Num表示符号表数组的下标,第二列Value时符号值,引用其他模块的函数或标量的该值为0,第三列Size是符号的大小,第四列Type是符号类型,第五列Bing是绑定信息,第七列Ndx是该符号所属的段,最后一列是符号名称。

四、动态链接

不立即对目标文件进行链接,推迟到程序运行时才进行链接,当一个目标文件依赖另一个文件的时候才加载到内存中,直至所有的目标文件加载完毕,系统开始进行链接工作,链接的原理同样包括重定位和符号解析等。共享对象最终的装载地址在编译时也是不确定的,而是在装载时,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给共享对象。

4.1对比静态链接

静态链接的缺点:

  • 浪费内存和磁盘空间。静态链接将程序所有用到的目标文件都打包在一起,每个程序内除了保留printf(),scanf(),strlen()等这样的公用库函数,还有数量客观的其他库函数,每一个程序都会打包一份。在多进程系统中,每一个进程都会拥有同样一份代码。
  • 模块更新困难。一旦程序中有任何一个模块更新,整个程序就要重新链接、发布给用户。

动态链接的优点:

  • 当一个目标文件依赖另一个文件的时候才加载到内存中,直至所有的目标文件加载完毕,系统开始进行链接工作。这样就解决了共享的目标文件多个副本浪费磁盘和内存空间的问题。
  • 当要升级程序库或者程序共享的某个模块时,只要将旧的模块覆盖掉,无需将所有程序再重新链接。

4.2地址无关代码(PIC)

命令:gcc -fPIC -shared -o Lib.so Lib.c,-fPIC表示生成地址无关代码,-shared表示产生共享对象。

重定位分为三种,分别是链接时重定位,装载时重定位和运行时重定位。装载时重定位是解决动态模块中有绝对地址引用的方法之一,但是它有一个很大的缺点是指令部分无法在多个进程之间共享,这样就失去了动态链接节省内存的一大优势。运行时重定位就是把指令中需要被修改的部分分离出来,跟数据放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本,这种方案就叫做地址无关代码(PIC)。

可以根据共享模块中的地址引用是否是跨模块分为两种情况:

  1. 模块内部的函数调用/数据访问
  2. 模块外部的函数调用/数据访问

对于第一种情况,因为被调用的函数和调用者处于同一个模块,它们之间的相对位置是固定的,所以根据相对地址调用即可,不需要重定位。一个模块前面一般是若干页的代码,后面紧跟若干页的数据,这些页之间的相对位置是固定的,那么只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了。

对于第二种情况,前面已经提到,要做到代码地址无关,需要把与地址有关的部分放到数据段。其他模块的全局变量的地址是跟该模块的装载地址有关的,ELF的做法是在数据段里面建立一个指向这些变量的指针数组,即全局偏移表(GOT),当代码需要引用该全局变量时,可以通过GOT中对应项间接引用。链接器在装载模块的时候会查找每个变量所在的地址,然后填充GOT中的每一项,以确保每个指针所指向的地址正确。由于GOT时放在数据段的,所以它可以在模块装载时被修改,并且每个进程都有独立的副本,相互不受影响。怎么在GOT做到对应项的间接引用呢?之前已经提到,模块在编译时可以确定模块内部变量相对于当前指令的偏移,那么在编译时也可以确定GOT相对于当前指令的偏移,然后再根据待查找变量在GOT中的偏移就可以得到变量的地址。对于跨模块的函数调用同样使用上面的方法,只是GOT中保存的不是变量所在的地址,而是函数所在的地址,当模块需要调用目标函数时,可以通过GOT中的项实现间接跳转。

小插曲:fpic和fPIC的区别,使用这两个参数就是用来生成地址无关代码的,这两个参数的功能是完全一样的,只是fPIC生成的代码要更大更慢,fpic生成的代码相对更小更快,但是为什么都使用fPIC,而不使用fpic呢?这是因为地址无关代码是跟硬件平台相关的,不同平台有着不同的实现,-fpic在某些平台上会有一些限制,但是fPIC则没有,所以保险起见,大多数情况都是用fPIC。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值