浅谈链接、装载与库

本文迁移于个人博客 http://www.chenonm.com

最近阅读了《程序员的自我修养》这本书,自己得以更加深入地一窥系统软件的运行机制和原理,获益匪浅。步入正文前,我们先来看以下几个问题:

1.C/C++程序代码如何被编译成目标文件
2.程序在目标文件中如何存储
3.目标文件如何静态链接成可执行程序
4.栈与调用惯例是什么

其实,上述问题现实中是较少被人关注的,对于平时的程序开发,我们很少需要关注编译和链接过程,通常开发环境都是流行的集成开发环境IDE,比如Visual Studio、Eclipse、Idea等等,通过构建合并编译链接,整个过程一步完成,但也掩盖了很多系统软件的运行机制与机理。

首先,我们来看下面一段代码,假设文件名为hello.c
image.png

Linux下,我们通过GCC编译这段程序,只需要用最简单的命令gcc hello.c,目录下便会生成一个a.out可执行文件文件。整个过程可以分解为4个步骤,分别是预处理、编译、汇编、链接,如下图所示:
image.png

第一步是预编译,处理以#开头的预编译指令,如#include->将包含的文件插入到该预编译指令处,删除所有#define并相应展开所有宏定义,处理所有条件编译指令如#if、#else,删除所有//、/**/注释,添加行号和文件名标识便于编译器产生调试用得行号信息及用于编译错误警告时能够显示行号,保留所有#pragma编译器指令,其能设定编译器的状态或者是指示编译器完成一些特定的动作,如#pragma once在头文件最开始加入此指令保证头文件只被编译器编译一次,linux下可以使用gcc -E hello.c -o hello.i进行预编译生成hello.i文件。

第二步是编译,即将预处理后文件经过词法分析、语法分析、语义分析及优化产生汇编代码文件,linux下可以使用gcc -S hello.c -o hello.s进行编译生成hello.s汇编文件。

第三步是汇编,其过程比较简单,汇编器根据上面生成的汇编文件根据汇编指令和机器指令对照表一一翻译即可,linux下可以使用gcc -c hello.s -o hello.o进行编译生成hello.o目标文件,也可以直接从c源码生成目标文件gcc -c hello.c -o hello.o

第四步是链接,将几个输入目标文件加工后合并成一个输出文件,在上面例子中输入文件包含了hello.o还有相关依赖的系统静态库,linux下可以使用ld -static hello.o *.o进行链接最终生成a.out,其中链接又包含静态链接跟动态链接,上述程序用到了静态链接,具体链接过程下面会更为深入地探索。

我们先来解答第一个问题,编译过程是怎样的,先来看下图:
image.png

整个过程分6步,扫描、语法分析、语义分析、源代码优化、代码生成、目标代码优化。我们通过一句简单的C代码为例讲述这个过程,比如我们有以下一行代码位于Compiler.c文件中
image.png

1.扫描器首先简单地进行词法分析,运用类似有限状态机的算法将源代码的字符序列分割成一系列记号,上述代码包含了28个非空字符,一个标识符为一个记号,总共有16个记号。在这个过程中,扫描器还将标识符放至符号表,将数字、字符串常量放至文字表,以备后续步骤使用

2.语法分析器将对记号进行语法分析,产生语法树,整个过程中采用了过程无关语法,由语法分析器产生的语法树就是以表达式为节点的树,这个过程,语法分析器会初次检查表达式是否合法,比如各种括号不匹配、表达式中缺少操作符等,并相应报告语法分析阶段的错误
image.png

3.语义分析器将对表达式进行静态语义分析,包括了声明和类型的匹配,类型的转换,比如将浮点型赋值整形涉及的隐式类型转换等,经过此阶段,语法树表达式都被标识了类型
image.png

4.源代码优化器将在源代码级别进行优化,比如上述的(2+6)这个表达式可被优化成8一个节点,这个过程中需要先将语法树转换成中间代码,直接在语法树上优化比较困难,常见的中间代码有三地址码等,其格式如下所示:x = y op z,y和z进行op操作后赋值给x,上述的(2+6)被翻译成三地址码是这样的:

t1 = 2 + 6
t2 = index + 4
t3 = t2 * t1
Array[index] = t3
这里用到了几个临时变量t1 t2 t3,优化器会将2+6结果计算即t1=6,然后
将后面t1替换成6,还可以省去临时变量t3,经过优化后的代码变成了
t2 = index + 4
t2 = t2 * 8
array[index] = t2

5.代码生成器将中间代码转换成目标机器代码如x86汇编代码,以下为生成的代码序列

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

6.最后目标代码优化器对目标代码进行优化,比如选择合适寻址方式、使用移位代替乘法计算,删除多余指令,上面例子中,乘法由一条相对复杂的基址比例变址的lea指令完成

movl index.%edx
leal 32(,%edx,8),%eax //offset(base, index, scale) 基址比例变址寻址方式
movl %eax,array(,%edx,4)

//解释
例如指令 movl base(%ebx, %esi, 4), %eax
表示 %eax = [ base + %ebx + %esi4 ]
将 base + %ebx + %esi
4 指向的内存位置的值赋值给eax寄存器。

例如 leal 32(, %edx, 8), %eax
%eax = 32 + ( %edx * 8 ) = 8 * (4 + %edx)
通常可以用lea指令表示一些乘法运算。

经过这六步以后,源代码被编译成了目标代码,其实我们会发现,index和array的地址还没有确定,如果index跟array的定义在一个编译单元那么编译器会为index和array分配空间即地址,如果定义在其他模块,那么其地址需要在最终链接时确定,由链接器将这些目标文件链接起来形成可执行文件。

接着程序在目标文件是怎么存储的呢
目标文件从结构上将,是编译后的可执行文件,只是还未链接,有些符号或者有些地址还没有调整,其实它本身就是按照可执行文件格式存储的,只是结构上会稍有不同。
现在PC平台流行的可执行文件格式主要是Windows下的PE和Linux的ELF,它们都是COFF(Commont file format)格式的变种,目标文件其实是编译后未经链接的中间文件(windows下的.obj和Linux下的.o),其结构内容跟可执行文件很相似,所以基本都以可执行文件格式存储,其实如动态链接库(.dll,.so)及静态库(.lib,.a)都按照可执行文件格式存储。

接着我们来着重看看elf文件的结构格式
image.png

最开始是elf头,包含描述整个文件的基本属性,如文件版本号,程序入口地址等。Elf文件头结构如下所示
image.png

紧接着是elf的各个段如代码段、数据段、BSS段,接着就是与段有关的重要结构段表(section header table),该表描述了所有段的信息,比如所有的段名,段长,段在文件的偏移,读写权限及其他属性;接着是字符串表(string table)和段表字符串表(section header string table),字符串表主要存放符号、变量名等,使用时使用字符串在表中的偏移来引用字符串,比如下面这个字符串表
image.png

对应偏移和字符串如下图所示:
image.png

字符串表保存普通的字符串,如符号名,段表字符串表保存段表中用到的字符串,最常见的就是段名。我们回头看elf头最后一个成员e_shstrndx,它是”section header string table index”缩写,字符串表及段表字符串表本身也是elf文件中的一个普通的段,shstrndx表示的就是段表字符串表在段表中的下标,这样,通过分析elf头,就可以得到段表和段表字符串表的位置,从而解析整个elf文件。

最后来讲一下符号表(Symbol table),这个表记录了目标文件中用到的所有符号,主要有本目标文件的全局符号、本目标文件引用的全局符号、段名、编译单元内部的局部符号、行号信息(可选),每个符号对应一个结构,结构成员如下所示:
image.png

符号表在编译过程中起到了十分关键的作用,编译程序中符号表用来存放语言程序中出现的有关标识符的属性信息,这些信息集中反映了标识符的语义特征属性。在词法分析及语法在分析过程中不断积累和更新表中的信息,并在词法分析到代码生成的各阶段,按各自的需要从表中获取不同的属性信息。可以归纳为以下几点:① 收集符号属性 ② 上下文语义的合法性检查的依据 ③ 作为目标代码生成阶段地址分配的依据
① 收集符号属性 编译程序扫描说明部分收集有关标识符的属性,并在符号表中
建立符号的相应属性信息。例如,编译程序分析到下述两个说明语句
  int A;
  float B[5];
  则在符号表中收集到关于符号A的属性是一个整型变量,关于符号B的属性
是具有5个浮点型元素的一维数组。

② 上下文语义的合法性检查的依据 同一个标识符可能在程序的不同地方出现,而有关该符号的属性是在这些不同情况下收集的。特别是在多趟编译及程序分段编译(在PASCAL及C中以文件为单位)的情况下,更需检查标识符属性在上下文中的一致性和合法性。通过符号表中属性记录可进行相应上下文的语义检查。
  例如,在一个C语言程序中出现
     …
  int i [3,5]; //定义整型数组i
   …
  float i[4,2]; //定义实型数组i,重定义冲突
   …
  int i [3,5]; //定义整型数组i,重定义冲突
   …
  编译过程首先在符号表中记录了标识符i的属性是3×5个整型元素的数组,而后在分析第二、第三这两个定义说明时编译系统可通过符号表检查出标识符i的二次重定义冲突错误。本例还可以看到不论在后二句中i的其它属性与前一句是否完全相同,只要标识符名重定义,就将产生重定义冲突的语义错误。

③ 作为目标代码生成阶段地址分配的依据 每个符号变量在目标代码生成时需要确定其在存储分配的位置(主要是相对位置)。语言程序中的符号变量由它被定义的存储类别(如在C、FORTRAN语言中)或被定义的位置(如分程序结构的位置)来确定。首先要确定其被分配的区域。例如,在C语言中首先要确定该符号变量是分配在公共区(extern)、文件静态区(extern static)、函数静态区(函数中static)、还是函数运行时的动态区(auto)等。其次是根据变量出现的次序,(一般来说)决定该变量在某个区中所处的具体位置,这通常使用在该区域中相对区头的相对位置确定。而有关区域的标志及相对位置都是作为该变量的语义信息被收集在该变量的符号表属性中。

接着我们来看看目标文件目标文件如何静态链接成可执行程序

首先来看下面两段代码
image.png

我们通过之前说过的编译指令gcc -c a.c b.c 就可以得到a.o和b.o两个目标文件,a.c引用到了b.c的swap和shared,接下来就是将这两个目标文件链接在一起并形成可执行文件。

首先是空间和地址分配,链接器如何将多个目标文件的各个段合并到输出文件呢?现在链接器主要采用的是相似段合并的方法,比如将所有输入文件的.text合并至输出文件的.text段,接着是.data段和.bss段等,如下图所示:
image.png

比较有趣的一点是bss在目标文件和可执行文件并不占用文件的空间,但是在装载运行的时候占用空间,所以链接器在合并各个段的时候,也将.bss合并,并且分配虚拟空间,实际上就是在段表中合并.bss段描述的长度以及在符号表中合并对应于.bss中的符号描述。现在,我们使用objdump来查看链接前后地址分配情况
image.png

其中,VMA为虚拟地址,LMA为加载地址,在链接前目标文件所有段的VMA都是0,因为虚拟空间还未分配,所以默认为0,等到链接之后,可执行程序文件ab各个段都被分配到了相应的虚拟地址,可以看到.text分配到了0x08048094地址(Linux下elf可执行文件默认从地址0x08048000分配),目标文件各段分配如下图所示:
image.png

可以看到代码段和data段都被合并了。假设Main函数相对于a.o文件的.Text段偏移量为X,经过链接合并后,a.o的.text段位于虚拟地址0x08048094,那么main地址就是0x08048094+X,而main函数在a.o文件.text段开始处,偏移量为0,所以main函数在最终的输出文件中的地址应该是0x08048094,同理可以计算出所有符号的地址。

接着链接器在分配空间地址后进行符号解析和重定位,链接器会扫描所有输入文件的符号表,找出其中未定义的符号,比如我们查看a.o的符号表:
image.png

可以看到shared和swap都是未定义的符号,所以链接器在扫描完后,这些未定义的符号应该在全局符号表中找到,否则就报符号未定义错误。对于未定义的符号,链接器通过elf文件中的重定位表来进行地址重定位,如果代码段有要被重定位的地方,相应就会有一个叫.rel.text的重定位表,同理,.rel.data保存了数据段的重定位表,我们使用objdump来查看a.o文件的重定位表:
image.png

a.o共定义了一个函数main,函数占用0x33个字节,共17条指令,其中粗体部分就是引用shared和swap的位置,对于shared的引用是一条mov指令,总共8个字节,作用是将shared地址赋值到esp寄存器+4的地址中去,前四个字节是指令码,后四个字节是shared的地址,可以看到编译器暂时将0x00000000作为shared的地址,因为它们定义在其他目标文件中,引用shared的位置正是0000001c的位置上,同理编译器也给swap函数地址暂定为0xfffffffc一个临时的假地址,这里要特别注意的是shared在重定位后地址假设为X,那么合并后.text段中相应位置的0x00000000将被替换成X,但是假设重定位后swap函数在代码段地址为Y,那么合并后相应位置的0xfffffffc并不是直接替换成Y,原因是由于引用swap的指令为call ,操作符为0XE8,这是一条近址相对位移调用指令,即call后面四个字节应该被调用函数地址相对于调用指令下一条指令的偏移量,及相对于add $0x24,%esp这条指令的偏移量,假设重定位合并后其下一条指令地址为Next,那么引用swap处之前填上的地址会被修正成Y – Next。现在我们再来看重定位后的ab文件的反汇编代码:
image.png

可以看到引用shared处地址替换成0x8049108,即shared在data段中虚拟地址,引用swap处地址替换成0x80480c8 – 0x80480bf = 0x00000009。之所以出现两种不同的校正方式主要是因为不同指令的寻址方式存在差异,shared和swap代表了两种很有代表性的寻址方式,及R_386_32和R_386_PC32,这两种重定位指令修正方式每个被修正的位置长度都是32位,而且都是近址寻址,唯一区别是绝对寻址和相对寻址,上面重定位表显示的两个入口类型就是对应的寻址方式。
image.png

这是寻址方式及其修正方法,其中,S指重定位符号的实际地址,A指重定位前编译器暂存的地址,P指被修正的位置,对于shared,S为0x8049108,A为0x000000,所以最后修正地址S+A为0x8049108,对于swap,S为0x80480c8,A为0xfffffffc,P为0x80480bb,所以S+A-P为0x00000009,跟上面得出的地址是一致的。

至此,符号解析和重定位也已经完成了。对于不需要动态链接的程序来说,此时链接生成文件已经是真正意义上的可执行程序了。由于动态链接涉及的知识点也较多,在本篇博文先不加以阐述。

最后的一个问题是栈与调用惯例是什么

栈是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量,在经典的操作系统中,栈总是向下生长的,在i386处理器下,栈顶由esp寄存器进行定位,压栈操作使栈顶地址变小,弹出操作使栈顶地址增大。下图是栈在Linux进程中的内存布局及一个具体实例:
image.png

栈保存了一个函数调用所需要的维护信息,常常称之为堆栈帧或活动记录。其一般包括如下几方面内容:
1.函数的返回地址和参数
2.临时变量,包括函数非静态局部变量以及编译器自动生成的其他临时变量
3.保存的上下文,包括在函数调用前后需要保持不变的寄存器

一个函数的活动记录由ebp和esp这两个寄存器划定范围,esp始终指向栈顶,ebp寄存器始终指向函数记录的一个固定位置,ebp又称栈指针,一个常见的活动记录如下图所示:
image.png

ebp固定在图中所示的位置,不随这个函数的执行而变化,相反,esp始终指向栈顶,因此随着函数执行,局部变量越来越多,esp会不断变化。这样,ebp可以用来定位函数活动记录中的各个数据。如图,ebp-4地址处即是函数的返回地址,再往前是压入栈的参数,它们的地址分别为ebp-8、ebp-12等,视参数数量和大小而定,特别的是,ebp所指向的数据存放着调用该函数前ebp的值,这样函数返回时,ebp可以通过读取这个值恢复调用前的值,一个i386下的函数总是这样调用的:

---把所有或一部分参数压入栈中,如果有其它参数没入栈,那么使用某些特定寄存器传递
---把当前指令的下一条指令的地址压入栈中
---跳转到函数体执行
(其中第二步和第三步由指令call一起执行)

---push ebp :把ebp压入栈中
---mov ebp,esp :ebp = esp
---[可选] sub esp,X :在栈上分配X字节的临时空间
---[可选]push X :如有必要,保存名为X的寄存器(可重复多个)
(把ebp压入栈中,是为了函数返回时便于恢复之前的ebp值,保存一些寄存器的值,在于编译器可能要求某些寄存器在调用前后保持不变,那么可以先将寄存器的值压入栈中,结束后取出)

---[可选]pop X :如有必要,恢复保存过的寄存器(可重复多个)
---mov esp,ebp :恢复esp同时回收局部变量空间
---pop ebp :从栈中恢复保存的ebp的值
---ret 从栈中取得返回地址,并跳转到该位置

下面我们通过反汇编一个简单的函数来看着整个过程:

int main()
{
    push  ebp
    move  ebp  esp
    sub   esp  0xch
    rep   stos--->ebp<---->esp

  int  a=10;
    mov   dword ptr[ebp -4],10
  int b=20;
    mov   dword ptr[ebp - 8],14h
  int ret = 0;
    mov   dword  ptr[ebp - 0ch], 0

  ret = sum(a,b);
    mov   eax,dword ptr[ebp-8]
    push  eax

   mov   ecx,dword ptr[ebp-4]
   push  ecx

   call  sum;------跳转,将下一行指令的地址入栈(下一行指令的地址通过后面的ret得到)
   add   esp,8-----回退形参变量的栈内存

  mov   dword ptr[ebp-0ch],eax

}

int sum(int a,int b)
{
    push  ebp
    move  ebp  esp
    sub   esp  0xch
    rep   stos--->ebp<---->esp

  int temp = 0;
    mov   dword ptr[ebp-4],0

  temp = a+b;
    mov   eax,dword ptr[ebp+8]
    add   eax,dword ptr[ebp+0ch]
    mov   dword ptr[ebp-4],eax

  return temp;
    mov   eax,dword ptr[ebp-4]

}
    mov   esp,ebp
    pop   ebp----出栈,并把出栈元素赋给ebp
    ret -------出栈,栈顶元素赋给CPU  PC寄存器(PC寄存器永远存放下一行指令的地址,放那个指令的地址,就运行那个指令)

下面是过程图解:
image.png

因此我们可以认为函数的调用方和被调用方对函数如何调用有着统一的理解,例如它们双方都一致地认同参数是以某个方式压入栈内,如果不统一,那么函数将无法正确运行。如果函数调用方传递参数时先压入第一个参数,再压入第二个参数,而被调用方认为其先压入最后一个参数,后压入之前的参数,那么函数被调用方内部的形参值会被交换;再者如果调用方决定利用寄存器传递参数,而被调用方仍然以为参数通过栈传递,那么显然函数无法获取正确的参数,因此,只有双方都遵守同样的约定,函数才能被正确调用,这样的约定被称为调用惯例。

至此,以上便是阅读《程序员的自我修养》部分章节结合自己查阅相关资料后的所见所得。

你可能感兴趣的:(浅谈链接、装载与库)