第二章——静态链接

静态链接

1 编译和链接

1.1 被隐藏了的过程

例如:

#include<stdio.h>

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

在Linux下,使用GCC编译:

gcc hello.c

./a.out

Hello World

事实上,上述过程由4个步骤,分别是预处理、编译、汇编和链接,如图所示:

 

1.1.1 预编译

首先是源代码hello.c和相关的头文件,如stdio.h等被预编译器cpp预编译成一个.i文件。

gcc -E hello.c -o hello.i

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

1)将所有的”#define“删除,并且展开所有的宏定义。

2)处理所有条件预编译指令,比如”#if“、”#ifdef“、”#elif“、”#else“、”#endif“

3)处理”#include“预编译指令,将被包含的文件插入到该预编译指令的位置。注意,这个过程是递归进行的,也就是说被包含的文件可能还包含其他文件。

4)删除所有的注释”//"和“/* */"

5)添加行号和文件名标识,比如#2 ”hello.c“ 2,以便于编译时产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号。

6)保留所有的#pragma编译器指令,因为编译器需要使用它们。

经过预编译处理后的.i文件不包含任何宏定义。

1.1.2 编译

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

上面编译过程相当于:

gcc -S hello.i -o hello.s

现在的GCC将预编译和编译合成一个步骤了,使用一个叫cc1的程序来完成这两个步骤。

gcc -S hello.c -o hello.s

所以实际上gcc这个命令指示这些后台程序的包装,它会根据不同的参数要求去调用预编译编译程序cc1、汇编器as、链接器ld。

1.1.3 汇编

汇编器是将汇编代码转变为机器可执行的指令,每一行汇编语句机会都对应一条机器指令。所以汇编器的汇编过程相对于编译器来讲比较简单,它没有复杂的语法,也没有语义,也不需要做指令优化。汇编过程由汇编器as完成:

as hello.s -o hello.o

或者:

gcc -c hello.s -o hello.o

或者,使用gcc命令从源代码开始,经过预编译、编译和汇编直接输出目标文件:

gcc -c hello.c -o hello.o

1.1.4 链接

链接就是讲一堆.o文件进行链接,生成可执行文件a.out。

1.2 编译器做了什么

编译过程一般可以分为6步:扫描、语法分析、语义分析、源代码分析、代码生成和目标代码优化。整个过程如下;

 

我们结合上图简单的描述从源代码到最终目标代码的过程。

例如:

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

CompilerExpression.c

1.2.1 词法分析

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

词法分析产生的记号一般可以分为如下几类:关键字、字面量和特殊符号。在识别记号的同时,扫描器也完成了其他工作。比如将标识符存放到符号表,将数字、字符串常量存放到文字表等。

1.2.2 语法分析

语法分析器将由扫描器产生的记号进行语法分析,从而产生语法树。整个分析过程采用了上下文无关语法的分析手段。上面的表达式由一个赋值表达式、加法表达式、乘法表达式、数组表达式、括号表达式组成的复杂语句。

语法树:

 

我们可以看到,整个语句被看作是一个赋值表达式;赋值表达式的左边是一个数组表达式,它的右边是一个乘法表达式;数组表达式又由两个符号表达式组成,等等。

根据语法树分析表达式是否合法,不如各种括号是否匹配、表达式中是否缺少操作符等,编译器就会报告语法分析错误。

1.2.3 语义分析

接下来是语义分析,由语义分析器完成。语法分析仅仅是完成了对表达式的语法层面的分析,但是并不了解这个语句真正的意义。比如两个指针相乘,一个指针和一个浮点数相乘是否合法等。编译器所能分析的语义是静态语义,所谓静态语义是指在编译期间可以确定的,与之对应的动态语义就是只有在运行期才能确定的语义。

静态语义通常包括什么和类型的匹配,类型转换。

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

 

语义分析器还对符号表里的符号类型也做了更新。

1.2.4 中间语言生成

现代的编译器会有多个层次的优化,往往在源代码级别会有一个优化过程。例如,语法树优化后:

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

定义在其他模块的全局变量和函数在最终运行时的绝对地址都要在链接的时候才能确定。

1.3 链接器年龄比编译器长

在软件开发的过程中,规模往往很大,如果都放在一个模块肯定无法想象。所以现代的大型软件往往都拥有成千上万个模块,这些模块之间相互依赖又相对独立。在一个程序被分割成多个模块以后,这些模块之间最后如何组合形成一个单一的程序是需要解决的问题。模块之间如何组合的问题可以归结为模块之间如何通信的问题,最常见的属于静态语言的C/C++模块之间通信的两种方式,一种是模块间的函数调用,另外一种是模块间的变量访问。函数访问须知道目标函数的地址,变量访问也须知道目标变量的地址,所以这两种方式都可以归结为一种方式,那就是模块间符号的引用。模块间的引用可以用下图表示,定义符号的模块多出一块区域,引用该符号的模块刚好少了那一块区域,两者一拼接刚好组合,这个模块拼接的过程就是本书的一个主题:链接。

1.4 模块拼接——静态链接

链接的主要过程包括了地址和空间分配、符号决议和重定位等这些步骤。

最基本的静态链接过程如图所示。每个模块的源代码文件(如.c)文件经过编译器编译成目标文件(.o),目标文件和库一起链接形参最终的可执行文件。而最常见的库就是运行时库,它是支持程序运行的基本函数的集合。库其实是一组目标文件的包,就是一些最常见的代码编译成目标文件后打包存放。

 

2 目标文件里面有什么

2.1 目标文件的格式

在Linux下的ELF文件,就是一种可执行文件的格式。目标文件就是源代码编译后但未进行链接的那些中间文件,它跟可执行文件的内容和结构很相似,所以一般跟可执行文件格式一起采用一种存储格式。在Linux下,我们一起统称为ELF文件。

一般可执行文件、动态链接库和静态链接库都按照Linux的ELF格式存储。ELF文件标准里面把系统中采用ELF格式的文件归为如下4类:

 

在Linux下,我们使用file命令来查看文件格式,上面几种文件在file命令下会显示出相应的类型:

2.2 目标文件是什么样的

目标文件的机器指令包含代码、数据。除了这些内容以外,目标文件中还包括了链接时所需要的一些信息,比如符号表、调试相信、字符串等。一般目标文件将这些信息按不同的属性,以“节”的形式存储,有时候也叫“段”,在一般情况下,它们都表示一个一定长度的区域,基本上不加以区别,唯一的区别是在ELF的链接视图和装载视图的时候。

 

程序源代码编译后的机器指令经常被放在代码段里,代码段常见的名字有“.code”或“.text“;全局变量和局部静态变量经常放在数据段,数据段的一般名字都叫”.data“。

 

 

图的可执行文件的格式是ELF,ELF文件的开头是一个“文件头”,它描述了整个文件的文件属性,包括文件是否可执行、是静态链接还是动态链接及入口地址(如果是可执行文件)、目标硬件、目标操作系统等信息,文件头还包括一个段表,段表其实是一个描述文件中各个段的数组。段表描述了文件中各个段在文件中的偏移位置及段的属性等,从段表里面可以得到每个段的所有信息。文件头后面就是各个段的内容,比如代码段保存的就是程序的指令,数据段保存的就是程序的静态变量等。

 

一般未初始化的全局变量和局部静态变量一般都放在”.bss“段里,本来它们也可以放在.data段的,但是因为它们都是0,所以为它们在.data段分配空间并且存放数据0是没有必要的。但是程序运行的时候它们的确是要占内存空间的,并且可执行文件必须记录所有未初始化的全局变量和局部静态变量的大小总和,记为.bss段。所有.bss段只是为未初始化的全局变量和局部静态变量预留位置而已,它并没有内容,所以它在文件中也不占据空间

 

总体来说,程序源代码被编译以后主要分成两种段:程序指令和程序数据。代码段属于程序指令,而数据段和.bss段属于程序数据

 

为什么程序要分多个段?

1)一方面是程序被装载后,数据和指令分别被映射到不同的虚存区域。数据区域是可读写的,而指令区域是只读的。

2)为了提高缓存的命中率一般将数据和指令分离

3)当系统中运行多个程序的副本时,它们的指令都是一样的,所以内存中只需要保存一份改程序的指令部分。对于指令这种只读的区域来说可以只保存一份副本,但是每个副本进程的数据区域是不一样的,它们是进程私有的,需要在每个进程中都保存有副本。

2.3 挖掘simplesection.o

 

我们使用GCC来一般这个文件(参数-c)表示只编译不链接。

gcc -c SimpleSection.c

我们得到一个1104字节的SimpleSection.o目标文件。使用工具objdump来查看object内部的结构:

objdump -h SimpleSection.o

 

SimpleSection.o的段的数量比我们想象中的要多,除了最基本的代码段、数据段和BSS段以外,还有3个段分别是只读数据段(.rodata)、注释信息段(.comment)和堆栈提示段。每个段的第二行中的“COUNTENTS”、“ALLOC”等表示段的各种属性,“CONTENTS”表示该段在文件中存在。我们看到BSS段没有“COUNTENTS”,表示它实际上在ELF文件中不存在内容。它们的ELF中的结构如图所示:

 

有一个命令叫做“size”,它可以用来查看ELF文件的代码段、数据段和BSS段的长度。

size SimpleSection.o

2.3.1 代码段

挖掘各个段的内容,我们还是离不开objdump这个利器。objdump的“-s”参数可以将所有段的内容以十六进制的方式打印出来,“-d”参数可以将所有包含指令的段反汇编。

 

2.3.2 数据段和只读数据段

.data段保存的是那些已经初始化了的全局静态变量和局部静态变量。前面的SimpleSection.c代码里有两个这样的变量,分别是global_init_varabal与static_var。这两变量都是4字节,一共8字节,所以".data"这个段的大小是8字节。

SimpleSection.c里面我们调用了“printf”的时候,用到了一个字符串常量“%d\n”,它是一种只读数据,放到了“.rodata”。

2.3.3 BSS段

.bss段存放的是未初始化的全局变量和局部静态变量,如上global_uninit_var和static_var2就是被存放在.bss段,其实更准确的说法是.bss段为它预留了空间。但是我们可以看到该段的大小只有4个字节,这与global_uninit_val和static_var2的大小8字节不符。

其实我们从符号表看到,只有static_var2被存放在.bss段,而global_uninit_var却没有被存放在任何段,只是一个未定义的符号“COMMON符号”(因为该变量可能在别的模块中定义)。

SYMBOL TABLE:
00000000 l    df *ABS*    00000000 SimpleSection.c
00000000 l    d  .text    00000000 .text
00000000 l    d  .data    00000000 .data
00000000 l    d  .bss    00000000 .bss
00000000 l    d  .rodata    00000000 .rodata
00000004 l     O .data    00000004 static_var.1242
00000000 l     O .bss    00000004 static_var2.1243
00000000 l    d  .note.GNU-stack    00000000 .note.GNU-stack
00000000 l    d  .comment    00000000 .comment
00000000 g     O .data    00000004 global_init_var
00000004       O *COM*    00000004 global_uninit_var
00000000 g     F .text    0000001b func1
00000000         *UND*    00000000 printf
0000001b g     F .text    00000035 main

如果另外定义两个变量:

static int x1=0;

static int x2=1;

x1和x2会被存放在什么段中呢?

其实x1被放在.bss段,x2会被存放在.data段中。为什么一个在bss段中,一个在data段中呢,因为x1被初始化为0,可以认为是未初始化的,所以被优化掉了防止bss段中,这样可以节省磁盘的空间,因为.bss段不占用磁盘空间

SYMBOL TABLE:
00000000 l    df *ABS*    00000000 SimpleSection.c
00000000 l    d  .text    00000000 .text
00000000 l    d  .data    00000000 .data
00000000 l    d  .bss    00000000 .bss
00000000 l     O .bss    00000004 x1
00000004 l     O .data    00000004 x2
00000000 l    d  .rodata    00000000 .rodata
00000008 l     O .data    00000004 static_var.1244
00000004 l     O .bss    00000004 static_var2.1245
00000000 l    d  .note.GNU-stack    00000000 .note.GNU-stack
00000000 l    d  .comment    00000000 .comment
00000000 g     O .data    00000004 global_init_var
00000004       O *COM*    00000004 global_uninit_var
00000000 g     F .text    0000001b func1
00000000         *UND*    00000000 printf
0000001b g     F .text    00000035 main

2.3.4 其他段

除了.text、.data、.bss这3个最常用的段之外,ELF还有其他的段,例如:

2.4 ELF文件结构描述

下图描述了ELF目标文件的总体结构,我们省去了ELF一些繁琐的结构,把最重要的结构提取出来,形参了ELF文件基本结构图。

 

ELF目标文件格式的最前端是ELF文件头(ELF header),包含了描述整个文件的基本属性,比如ELF文件版本、目标机器型号、程序入口地址等。紧接着是ELF文件各个段。其中ELF文件中与段相关的中央结构就是段表(section header table),该表描述了ELF文件包含的所有段的信息,比如每个段的段名、段的长度、在文件中的偏移、读写权限及段的其他属性。接着还有字符串表、符号表等。

2.4.1 头文件

用readelf命令看ELF文件:

查看ELF文件头:

[root@localhost programer]# readelf -h SimpleSection.o 
ELF Header:
  Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF32
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              REL (Relocatable file)
  Machine:                           Intel 80386
  Version:                           0x1
  Entry point address:               0x0
  Start of program headers:          0 (bytes into file)
  Start of section headers:          276 (bytes into file)
  Flags:                             0x0
  Size of this header:               52 (bytes)
  Size of program headers:           0 (bytes)
  Number of program headers:         0
  Size of section headers:           40 (bytes)
  Number of section headers:         11
  Section header string table index: 8

2.4.2 段表

ELF文件是由各个段组成的,这个段表就是保存了这些段的基本信息。比如每个段的段名、段的长度、在文件中的偏移、读写权限及段的其他属性。也就是说,ELF文件的段结构就是由段表决定的,编译器、链接器和装载器都是依靠段表来定位和访问各个段的属性的。

我们可以使用"objdump -h"来查看ELF文件中的段,但是这其中只显示了关键的段。使用readelf工具查看,才是真正的段表:

[root@localhost programer]# readelf -S SimpleSection.o 
There are 11 section headers, starting at offset 0x114:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0
  [ 1] .text             PROGBITS        00000000 000034 000050 00  AX  0   0  4
  [ 2] .rel.text         REL             00000000 000448 000028 08      9   1  4
  [ 3] .data             PROGBITS        00000000 000084 00000c 00  WA  0   0  4
  [ 4] .bss              NOBITS          00000000 000090 000008 00  WA  0   0  4
  [ 5] .rodata           PROGBITS        00000000 000090 000004 00   A  0   0  1
  [ 6] .comment          PROGBITS        00000000 000094 00002d 01  MS  0   0  1
  [ 7] .note.GNU-stack   PROGBITS        00000000 0000c1 000000 00      0   0  1
  [ 8] .shstrtab         STRTAB          00000000 0000c1 000051 00      0   0  1
  [ 9] .symtab           SYMTAB          00000000 0002cc 000110 10     10  12  4
  [10] .strtab           STRTAB          00000000 0003dc 00006c 00      0   0  1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings)
  I (info), L (link order), G (group), x (unknown)
  O (extra OS processing required) o (OS specific), p (processor specific)

段表中有11个元素的数组,ELF文件中类型为”NULL“是无效的段,其他的段都是有效的。

ELF文件中的段描述符结构如下:

 

到这一步,我们可以看出,包含了11个段描述符,每个段描述符为40字节,这个长度刚好是sizeof(ELF32_Shdr),符号段描述符长度。

 

2.4.3 重定位表

我们主要到有个叫".rel.text"的段,它的类型为"SHT_REL",也就是说它是一个重定位表。因为目标文件的偏移量都是0,链接器在处理目标文件时,需要对目标文件中某些部位进行重定位,即代码段和数据段中那些对绝对地址的引用的位置。这些重定位信息都记录在ELF文件的重定位表里面,对于要重定位的代码段和数据段,都有一个重定位表。例如,".rel.text"就是针对".text"段的重定位表,".data"段的重定位表".rel.data"。

2.4.4 字符串表

ELF文件用到了很多字符串,比如段名、变量名等。一种常见的做法是把字符串集中起来存放到一个表,然后使用字符串在表中的偏移来引用字符串。例如:

 

一般字符串表在ELF文件中也以段的形式保存,常见的段名为".strtab"或".shstrtab"。这两个字符串表分别为字符串表和段表字符串表。字符串表保存的是普通的字符串;段表字符串表保存的是用到的字符串,最常见的就是段名。

2.5 链接的接口——符号

链接过程的本质就是要把多个不同的目标文件之间相互“粘”在一起。我们将符合看做是链接中的粘合剂,整个链接过程正是基于符号才能够正确完成。链接过程中很关键的一部分就是符号的管理,每一个目标文件都会有一个相应的符号表,这个表里面记录了目标文件中所用的所有符号。

这些符号主要包括:

1)定义在本目标文件的全局符号,可以被其他目标文件引用

2)在本目标文件引用的全局符号,却没有定义在本目标文件,叫做外部符号

3)段名

4)局部符号

5)行号信息

对我们来说的,最关心的是全局符号,即上面的1和2,我们可以使用nm命令查看符号表:

[root@localhost programer]# nm SimpleSection.o
00000000 T func1
00000000 D global_init_var
00000004 C global_uninit_var
0000001b T main
         U printf
00000008 d static_var.1244
00000004 b static_var2.1245
00000000 b x1
00000004 d x2

2.5.1 ELF符号结构

ELF文件中有一个段,段名为".symtab"。结构如下:

使用readelf工具查看符号表:

[root@localhost programer]# readelf -s SimpleSection.o

Symbol table '.symtab' contains 17 entries:
   Num:    Value  Size Type    Bind   Vis      Ndx Name
     0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 00000000     0 FILE    LOCAL  DEFAULT  ABS SimpleSection.c
     2: 00000000     0 SECTION LOCAL  DEFAULT    1 
     3: 00000000     0 SECTION LOCAL  DEFAULT    3 
     4: 00000000     0 SECTION LOCAL  DEFAULT    4 
     5: 00000000     4 OBJECT  LOCAL  DEFAULT    4 x1
     6: 00000004     4 OBJECT  LOCAL  DEFAULT    3 x2
     7: 00000000     0 SECTION LOCAL  DEFAULT    5 
     8: 00000008     4 OBJECT  LOCAL  DEFAULT    3 static_var.1244
     9: 00000004     4 OBJECT  LOCAL  DEFAULT    4 static_var2.1245
    10: 00000000     0 SECTION LOCAL  DEFAULT    7 
    11: 00000000     0 SECTION LOCAL  DEFAULT    6 
    12: 00000000     4 OBJECT  GLOBAL DEFAULT    3 global_init_var
    13: 00000004     4 OBJECT  GLOBAL DEFAULT  COM global_uninit_var
    14: 00000000    27 FUNC    GLOBAL DEFAULT    1 func1
    15: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND printf
    16: 0000001b    53 FUNC    GLOBAL DEFAULT    1 main

其中,ndx如果符号定义在目标文件中,那么这个成员表示符号所在的段在段表中的下标;但是如果符号不是定义在本目标文件中,或者对于有些特殊符号,则显示如下:

 

2.5.2 特殊符号

当我们使用ld作为链接器来生产可执行文件时,它会为我们定义很多特殊的符号,这些符号并没有在你的程序中定义,但是你可以直接上门并且引用它,我们称之为特殊符号。其实这些符号是被定义在ld链接器的链接脚本中的。几个有代表性的特殊符号如下:

1)__executable_start:该符号为程序的起始地址

2)__etext或_etext或etext,该符号为代码段结束地址

3)_edata或edata,该符号为数据段的结束地址

4)_end或end,该符号为程序结束地址

以上地址都是程序被装载时的虚拟地址。

2.5.3 符号修饰与函数签名

C语言源代码文件中的所有全局的变量和函数经过编译以后,相对应的符号名前加上上下划线"_"。

C++符号修饰

由于C++中函数重载的存在,在编译器及链接器处理符号时,它们使用某种名称修饰的方法,使得每个函数签名对应一个修饰后名称。编译器在将C++源代码编译成目标文件时,会将函数和变量的名字进行修饰,形参符号名,也就是说,C++的源代码编译后的目标文件中所使用的符号名是相应的函数和变量的修饰后的名称。C++编译器和链接器都使用符号来识别和处理函数和变量,对于不同函数签名的函数,即使函数名相同,编译器和链接器都认为它们是不同的函数。

例如:

2.5.4 extern"C"

C++为了与C兼容,在符号的管理上,C++有一个用来声明或定义一个C的符号的“extern”C"关键字

extern "C"{

  int func(int);

  int val;

}

C++编译器会将在extern "C"的大括号内部的代码当作C语言代码处理。

 2.5.5 弱符号和强符号

多个目标文件中含有相同名字全局符号的定义,那么这些目标文件链接的时候会出现重复定义的错误。这种符号的定义可以被称为强符号。有些符号可以被称为弱符号。

对于C/C++来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。注意,强符号和弱符号都是针对定义来说的,不是针对符号的引用。

例如:

extern int ext;

int weak;
int strong=1;
__attribute__ ((weak)) weak2=2;
int main()
{
    return 0;
}

其中,"weak"和"weak2"是弱符号,"strong"和"main"是强符号,而"ext"既非强符号也非弱符号,因为它是一个外部引用。强符号和弱符号的处理规则:

1)规则1:不允许强符号被多次定义

2)规则2:如果一个符号在某个目标文件中是强符号,在其他文件中都是弱符号,那么选择强符号

3)规则3:如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个。比如目标文件A定义全局变量global为int型,占4字节;目标文件B定义global 为double型,占8个字节,那么目标文件A和B链接后,符号global占8字节。

弱引用和强引用 在目标文件中引用的符号都要被正确决议,如果没有找到该符号的定义,链接器就会报符号未定义的错误,这种称为强引用。与之相反的是弱引用,在处理弱引用时,如果该符号有定义,则链接器将该符号的引用决议;如果该符号未被定义,则链接器对于该引用不报错。一般对于未定义的弱引用,链接器默认其为0,或者是一个特殊的值,以便于程序代码能够识别。

2.6 调试信息——符号

目标文件里面还有可能保存的是调试信息。几乎所有现代的编译器都支持源代码级别的调试,比如我们可以在函数里面设置断点,可以监控变量变化,可以单步行进等,前提是编译器必须提前将源代码与目标文件之间的关系等,比如目标代码中的地址对应源代码中的哪一行、函数和变量的类型、结构体的定义、字符串保存到目标文件里面。

[root@localhost programer]# readelf SimpleSection.o -S
There are 23 section headers, starting at offset 0x490:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0
  [ 1] .text             PROGBITS        00000000 000034 000050 00  AX  0   0  4
  [ 2] .rel.text         REL             00000000 000a14 000028 08     21   1  4
  [ 3] .data             PROGBITS        00000000 000084 00000c 00  WA  0   0  4
  [ 4] .bss              NOBITS          00000000 000090 000008 00  WA  0   0  4
  [ 5] .debug_abbrev     PROGBITS        00000000 000090 00008b 00      0   0  1
  [ 6] .debug_info       PROGBITS        00000000 00011b 0000ea 00      0   0  1
  [ 7] .rel.debug_info   REL             00000000 000a3c 0000b8 08     21   6  4
  [ 8] .debug_line       PROGBITS        00000000 000205 000046 00      0   0  1
  [ 9] .rel.debug_line   REL             00000000 000af4 000008 08     21   8  4
  [10] .rodata           PROGBITS        00000000 00024b 000004 00   A  0   0  1
  [11] .debug_pubnames   PROGBITS        00000000 00024f 00004f 00      0   0  1
  [12] .rel.debug_pubnam REL             00000000 000afc 000008 08     21  11  4
  [13] .debug_aranges    PROGBITS        00000000 00029e 000020 00      0   0  1
  [14] .rel.debug_arange REL             00000000 000b04 000010 08     21  13  4
  [15] .debug_str        PROGBITS        00000000 0002be 00008b 01  MS  0   0  1
  [16] .comment          PROGBITS        00000000 000349 00002d 01  MS  0   0  1
  [17] .note.GNU-stack   PROGBITS        00000000 000376 000000 00      0   0  1
  [18] .debug_frame      PROGBITS        00000000 000378 000054 00      0   0  4
  [19] .rel.debug_frame  REL             00000000 000b14 000020 08     21  18  4
  [20] .shstrtab         STRTAB          00000000 0003cc 0000c2 00      0   0  1
  [21] .symtab           SYMTAB          00000000 000828 000180 10     22  19  4
  [22] .strtab           STRTAB          00000000 0009a8 00006c 00      0   0  1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings)
  I (info), L (link order), G (group), x (unknown)
  O (extra OS processing required) o (OS specific), p (processor specific)

3 静态链接

当有两个目标文件时,如何将它们链接起来形成一个可执行文件?

例如:

a.c

 extern int shared;
    
   int main()
  {
       int a=100;
       swap(&a,&shared);
  }

b.c

int shared=1;
        
void swap(int *a,int *b) 
{       
    *a^=*b^=*a^=*b;                                                             
}

假设我们的程序只有这两个模块"a.c"和"b.c"。首先编译成目标文件:

gcc -c a.c b.c

经过编译后的目标文件a.o和b.o。b.c中定义了两个全局符号,一个是变量shared,另一个是swap;a.c定义了一个全局符号main。模块a.c里面引用了b.c里面的swap和shared。接下来就是要把a.o和b.o这两个目标文件链接在一起并最终形成一个可执行文件ab。

3.1 空间与地址分配

链接的过程就是将多个输入目标文件加工合并成一个输出文件,输出可执行文件ab。可执行文件的代码段和数据段都是由输入的目标文件合并而成的。那么链接器如何将它们的各个段合并到输出文件?

3.1.1 按序叠加

一个简单的方案就是将输入的目标文件按照次序叠加起来,如图所示:

 

如果这样做,当有数百个目标文件时,如果每个目标文件都分别有.text段、.data段和.bss段,那最后的输出文件将会由成百上千零散的段。

3.1.2 相似段合并

一个更实际的方法是将相似的段合并到一起,比如将所有输入文件的".text"合并到输出文件的".text"段,接着是".data"段、".bss"段等,如图所示:

 

正如我们前面提到的,".bss"段在目标文件盒可执行文件中并不占用文件的空间,但是它在装载时占用地址空间。所以链接器在合并各个段的同时,也将".bss"合并,并且分配虚拟空间。从".bss"段的空间分配上我们可以思考一个问题,那就是这里的所谓的”空间分配“到底是什么空间?

”链接器为目标文件分配地址和空间“这句话中的”地址和空间“其实有两个含义:第一个是输出的可执行文件中的空间;第二个是在装载后的虚拟地址中的虚拟地址空间。对于有实际数据的段,比如".text"和".data"来说,它们在文件中和虚拟地址空间都要分配空间;而对于".bss"段,分配的空间只局限于虚拟地址空间,因为它们在文件中没有内容。

链接过程分为两步:

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

第二步 符号解析与重定位 使用上面收集的信息,读取文件中段的数据、重定位信息、并且进行符号解析和重定位、调整代码中的地址等。

使用ld链接器链接a.o和b.o:

ld a.o b.o -e main -o ab

1)-e main 表示将main函数作为程序入口,ld链接器默认的程序入口为_start。

2)-o ab 表示链接输出文件为ab

链接前和链接后的各个段的属性:

[root@localhost programer]# objdump -h a.o 

a.o:     file format elf32-i386

Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .text         00000027  00000000  00000000  00000034  2**2
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
  1 .data         00000000  00000000  00000000  0000005c  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00000000  00000000  00000000  0000005c  2**2
                  ALLOC
  3 .comment      0000002d  00000000  00000000  0000005c  2**0
                  CONTENTS, READONLY
  4 .note.GNU-stack 00000000  00000000  00000000  00000089  2**0
                  CONTENTS, READONLY
[root@localhost programer]# objdump -h b.o

b.o:     file format elf32-i386

Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .text         0000003a  00000000  00000000  00000034  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .data         00000004  00000000  00000000  00000070  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00000000  00000000  00000000  00000074  2**2
                  ALLOC
  3 .comment      0000002d  00000000  00000000  00000074  2**0
                  CONTENTS, READONLY
  4 .note.GNU-stack 00000000  00000000  00000000  000000a1  2**0
                  CONTENTS, READONLY
[root@localhost programer]# ld -o ab -e main a.o b.o
[root@localhost programer]# objdump -h ab

ab:     file format elf32-i386

Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .text         00000062  08048094  08048094  00000094  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .data         00000004  080490f8  080490f8  000000f8  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  2 .comment      0000002c  00000000  00000000  000000fc  2**0
                  CONTENTS, READONLY

其中的VMA表示虚拟地址,LMA表示加载地址,正常情况下这两个值应该是一样的。

链接前后的程序中所使用的地址已经是程序在进程中的虚拟地址,及我们关心的是VMA和size,而忽略文件偏移。我们可以看到,在链接之前,目标文件中的所有段的VMA都是0,因为虚拟空间还没有被分配,所以它们默认都为0.等到链接后,可执行文件ab中的各个段都被分配了相应的虚拟地址。例如,ab中,.text段被分配到0x08048094 ,长度为0x62。整个链接的过程如图所示:

 

为什么链接器将可执行文件ab的.text分配到0x08048094 ,将.data分配到0x080490f8 呢?而不是从虚拟空间的0地址开始分配呢?这涉及操作系统的进程虚拟地址空间的分配规则,在Linux下,ELF可执行文件默认从地址0x08048094开始分配。

3.1.3 符号地址的确定

在第一步的扫描和空间分配阶段,链接器按照前面介绍的空间分配方法进行分配,这时候输入文件中的各个段在链接后的虚拟地址就已经确定了,比如".text"段起始地址为0x08048094,".data"段的起始地址为0x080490f8。当这一步完成后,链接器就开始计算各个符号的虚拟地址。因为各个符号在段内的相应位置是固定的,所以这时候其实"main"、"shared"和"swap"的地址也已经是确定的了,只不过链接器需要给每个符号加一个偏移量,使它们能够调整到正确的虚拟地址。

比如,我们假设a.o中的main函数相对于.text段的偏移是X,但是经过链接合并以后,a.o的.text段位于虚拟地址0x08048094,那么main的地址应该是0x08048094+X.从前面的objdump的输出可以看出,main位于a.o的.text段的最开始,也就是偏移量为0,所以main这个符号在最终的输出文件中的地址应该是0x08048094+0,即0x08048094。

所以链接器在更新全局符号表的符号地址之后,各个符号的最终地址如表所示:

3.2 符号解析与重定位

3.2.1 重定位

在完成空间和地址的分配步骤以后,链接器就进入了符号解析与重定位的步骤,这也是静态链接的核心内容。在分配符号解析和重定位之前,首先让我们来看看a.o里面是怎么使用这两个外部符号的,也就是说我们在a.c中的shared和swap,那么编译器在将a.c编译成指令的时候,怎么访问shared,如何调用swap呢?

使用objdump的-d参数看反汇编代码:

 

注意:其中的的地址都是虚拟地址,这里main的起始地址为0x00000000,这是因为在未进行前面的空间分配之前,目标文件代码的起始地址以0x00000000开始,等到空间分配完成以后,各个函数才能确定自己的虚拟地址空间中的位置。

其中粗体标出了两个引用shared和swap的位置。

当源代码a.c被编译成目标文件时,编译器并不知道shared和swap的地址,因为定义在其他的目标文件中。所以编译器就暂时把地址0看做是shared的地址,我们可以看到mov指令,shared的地址是0x00000000

编译器把这两天指令的地址暂时用0x00000000和0xfffffffc代替着,把真正的地址计算工作留给了链接器。我们从前面的空间与地址分配可知,链接器在完成地址和空间分配就已经确定了所有符号的虚拟地址了,那么链接器就可以根据符号的地址对每个需要重定位的指令进行低位修正。用objdump反汇编ab,可以看到main的地址已经被修正了:

经过修正后,shared和swap的地址分别为0x08049108和0x000000009.也即0x080480bf+9=0x080480c8,即刚好是swap的地址。

3.2.2 重定位表

链接器是怎么知道哪些指令要被调整的呢?事实上在ELF文件中,有一个叫重定位表的结构专门用来保存这些与重定位相关的信息。

对于可重定位的ELF文件来说,它必须包含重定位表,用来描述如何修改相应的段里的内容。对于每个要被重定位的ELF段都有一个对应的重定位表,而一个重定位表往往就是ELF文件中的一个段,所以其实重定位表也可以叫重定位段,我们在这里统一称作重定位表。比如代码段.text有对应的.rel.text段保存了代码段的重定位表;如果数据段.data需要重定位,就会有一个相应的.rel.data段保存了数据段的重定位表。

可以用objdump看查看重定位表:

 

可以看到需要重定位的地方,即a.o中所有引用到外部符号的地址。每个要被重定位的地方叫一个重定位入口,看到a.o中有两个重定位入口。重定位入口的偏移表示该入口在要被重定位的段中的位置。

3.2.3 符号解析

之所以需要链接是因为我们目标文件中用到的符号被定义在其他目标文件,所以要将它们链接起来。

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

比如查看a.o的符号表:

 

GlOBAL类型的符号,除了main函数都定义在代码段之外,其他两个shared和swap都是UND,即undefined未定义类型,这种未定义的符号都是因为该目标文件中有关于它们的重定位项。所以在链接器扫描完所有的输入目标文件之后,所有这些未定义的符号都应该能够在全局符号表中找到,否则链接器就报符号未定义错误。

3.3 COMMON块

如果弱符号机制允许同一符号的定义存在多个文件中,所以可能会导致一个问题是:如果一个弱符号定义在多个目标文件中,而他们的类型又不同,怎么办?目前的链接器本身并不支持符号的类型,即变量类型对于链接器来说是透明的,它只知道一个符号的名字,并不知道类型是否一致。那么当我们定义的多个符号的类型不一致时,链接器如何处理?主要分3中情况:

1)两个或两个以上强符号类型不一致

2)有一个强符号,其他都是弱符号,出现类型不一致

3)两个或两个以上弱符号类型不一致

对于3种情况,第一种是错误的,链接器处理后两者情况。

其实COMMON是针对都是弱符号的情况,因为只要是强符号就选用强符号。这种使用COMMON机制的原因是编译器和链接器允许不同类型的弱符号存在。在目标文件中,编译器为什么不直接把未初始化的全局变量也当作未初始化的局部静态变量一样处理,为它在BSS段分配空间,而是将其标记为COMMON类型呢?

主要是因为当编译器将一个编译单元编译成目标文件时,如果该编译单元包含了弱符号,那么该弱符号最终所占空间的大小是未知的,因为有可能在其他编译单元中该符号所占的空间比本编译单元该符号所占的空间要大。所以编译器此时无法为该弱符号在BSS段分配空间,因为所需要空间的大小未知。但是链接器可以确定弱符号的大小,因为链接了所有的目标文件,弱符号的大小也确定了,所以它可以在最终输出文件的BSS段为其分配空间。所以总体来看,未初始化全局变量最终还是被放在BSS段的。

3.4 C++相关问题

3.4.1 重复代码消除

C++编译器在很多时候会产生重复的代码,比如模板、内联函数和虚函数表都有可能在不同的编译单元里生成相同的代码。例如,在一个单元里模板被实例化,它并不知道在其他的单元是否有相同的实例化。

一个比较好的做法是将每个模板的实例代码都单独地存放在一个段里,每个段只包含一个模板实例。比如有个模板函数是add<T>(),某个编译单元 以int和float类型实例化了该模板函数,那么该编译单元的目标文件中就包含了两个该模板实例的段。这样,当别的编译单元也以int或float类型实例化该模板函数后,也会生成相同的名字,这样链接器在最终链接的时候可以区分这些相同的模板实例段,合并入最后的代码段。

3.4.2 全局构造与析构

一般的C/C++程序是从main开始执行的,随着main函数的结束而结束。然而,在main函数之前,为了程序能够顺利通过,要先初始化进程执行环节,比如堆分配初始化,线程子系统等。

Linux系统下一般程序的入口是“_start",这个函数是Linux系统库的一部分。在main函数执行完成以后,返回到初始化部分,它进行一些清理工作,然后结束进程。程序的一些操作必须在main函数之前执行,还有一些必须在main函数之后执行,因此ELF文件中定义了两种特殊的段:

1).init 该段里面保存的是可执行指令,它构成了进程的初始化代码。

2) .fini 该段保存着进程终止代码指令。

这两个段.init和.fini的存在有着特别的目的,如果一个函数放在.init段,在main函数执行前系统就会执行它。同理,假如一个函数放到.fini段,在main函数返回后该函数会被执行。

3.5 静态库链接

其实一个静态库可以简单地看成一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件。比如Linux中最常用的C语言静态库libc位于/usr/lib/libc.a,它属于glibc项目的一部分。将一些.o文件使用ar压缩程序将这些目标文件压缩到一起,并且对其进行编号和索引,以便于查找和检索,就形成了libc.a这个静态库。

Q&A

Q:为什么静态运行库里面一个目标文件只包含一个函数?比如libc.a里面printf.o只有printf()函数、strlen.o只有strlen()函数?

A:链接器在进行静态链接时是以目标文件为单位的。比如我们引用了静态库中的printf()函数,那么垃圾器就会把库中包含printf()函数的那个目标文件链接进来,如果多个函数都放在一个目标文件中,很可能一些没有用的函数也被链接进输出结果中,这样浪费了空间。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值