链接(1)

21 July 2025
8 mins

开始学习新的章节——链接。我们现在要研究在gcc调用的过程中,到底有哪些过程是被我们所忽略的,在编译的过程中它们又有着什么作用呢?

编译器驱动程序

我们以下面的这段代码为例,将以此来分析整个编译的过程

//file 1
int sum(int *a,int n);

int array[2] = {1,2};

int main(){
    int val = sum(array,2);
    return val;
}
//file 2
int sum(int *a,int n){
    int i,s = 0;
    
    for(int i=0;i<n;i++){
        s += a[i];
    }
    return s;
}

大多数编译系统都会提供一个编译驱动程序,在用户需要时调用语言处理器,编译器,汇编器,链接器。比如我们要用GNU编译系统进行编译时,我们就需要使用gcc编译驱动:

ylin@Ylin:~/Program/test$ gcc -Og -o test main.c sum.c

但是实际上省略了很多中间过程并没有让我们看到,我们可以通过加入-v参数来观察这个过程:

image.png

红色框出来的分别时ccl as ld的调用,我们之后会分析这几个程序的作用。现在我们可以将从ASCIII源码到执行文件的编译驱动的过程总结一下:

image.png

首先预处理器cpp(预处理器实际上和编译器是集成在一起的)将ASCII源文件翻译成一个ASCII码的中间文件

ylin@Ylin:~/Program/test$ cpp main.c -o main.i

然后驱动程序运行C编译器ccl(这里我们使用gcc实现),将中间文件翻译成汇编语言:

ylin@Ylin:~/Program/test$ gcc -S main.i

接着驱动程序运行汇编器as,将main.s翻译成一个可重定位目标文件main.o:

ylin@Ylin:~/Program/test$ as main.s -o main.o

然后对sum.c进行同样的操作,得到sum.o。然后驱动程序运行链接器程序ld(在gcc -v的过程中可以看到链接过程使用的是collect2,实际上是ld的封装用法),将main.o和sum.o以及一些必要的系统目标文件编译起来,创建一个可执行的目标文件:

ylin@Ylin:~/Program/test$ ld -o test sum.o main.o

最后当我们执行编译出来的test程序时,shell会调用一个名为加载器loader的函数,将可执行文件中的代码和数据复制到内存,然后将控制转移到这个程序的开头:

ylin@Ylin:~/Program/test$ ./test

不过实际上,这些中间过程是被什么略的,其中生成的中间文件会被存放在\tmp下,待编译结束后被清理。

静态链接

像ld这样的静态连接器以一组可重定位目标文件个命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出。输出的可重定位目标文件由各种不同的代码和数据节section组成,每一节都是一个连续的字节序列。指令在一个节中,初始化了的全局变量在另一个节中,而未初始化的变量又在另一个节中…

为了构建一个可重定位文件,链接器要实现一下的功能:

之后我们会详细的分析这几个过程。实际上我们只需要清除,目标文件实际上就是字节块的集合。这些块中,有的包含程序代码,有的包含程序数据,其他的则包含引到链接器和加载器的数据结构。链接器负责将这些块连接起来,确定被连接块的运行时的位置,并且修改代码和数据块中的各种位置。

目标文件

目标文件有三种形式:

编译器和汇编器生成可重定位目标文件(包括共享目标文件)。链接器生成可执行目标文件。

从技术上来说,一个目标模块就是一个字节序列,而一个目标文件就是一个以文件形式存储在磁盘中的目标模块。本质上它们是一样的。

不同的系统有不同的目标文件格式,如:

可重定位目标文件

image.png

这是一个典型的ELF可重定位目标文件的格式。我们从ELF头开始说起,我们可以使用readelf -h来获取一个程序的ELF头信息:

ylin@Ylin:~/Program/test$ readelf -h test
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              DYN (Position-Independent Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x1040
  Start of program headers:          64 (bytes into file)
  Start of section headers:          14016 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         13
  Size of section headers:           64 (bytes)
  Number of section headers:         29
  Section header string table index: 28

以一个16字节长的字节序列Magic开始,这个序列用于系统判断是否为ELF文件格式(字节序和位宽),ELF头的其他部分则包含了帮链接器语法分析和解释目标文件的信息。其中包括

不同节的位置和大小是由节头部表描述的,其中目标文件中的每个节都有一个固定的条目(entry)。夹在ELF头和节头部表中间的都是节。一个典型的ELF可重定位目标文件包含以下的节:

符号和符号表

符号

每个可重定位模块m都有一个符号表,它包含m定义和引用的符号的信息。在连接器的上下文中,有三种不同的符号:

链接器不关心本地局部变量。同样的.symtab中的符号表也不包含对应于本地非静态程序变量的任何符号。这些符号在运行时由栈管理。不被链接器考虑。

不过定义为带有Cstatic属性的本地过程变量时不在栈中管理的(使用static属性可以隐藏模块内部的变量和函数声明,就像class中使用private,来保护变量和函数只能被本地模块使用)。相反,编译器在.data或.bss中会为每个定义分配空间,并在符号表中创建一个有唯一名字的本地连接符号。比如:

int f(){
    static int x=0;
    return x;
}
int g(){
    static int x=0;
    return x;
}

此时编译器会向汇编器输出两个不同名字的局部链接符号。比如,可以用x.1表示函数f中的定义,用x.2表示函数g中的定义。

符号表

符号表是由汇编器构造的,使用的是编译器给出的符号。ELF符号表被存放在.symtab中。这个符号表包含着一个条目的数组,其条目结构如下:

typedef struct{
    int name;			
    char type:4;		
    	 binding:4;		
    char reversed;		
    short section;		
    long value;			
    long size;			
}ELF64_symbol;

它们的作用分别是:

每个符号都会被分配到目标文件的某个节中。由section字段表示,该字段也是一个到节头部表的索引。有三个特殊的伪节,它们在节头部表中并没有条目:

只有在可重定位目标文件中才有伪节,可执行目标文件中没有

对于COMMON符号,value给出的是对齐要求,size给出的是最小的大小。

这么一看COMMON和.bss似乎差不多。现代的汇编器根据以下规则来将符号分配到COMMON和.bss中:

COMMON  未初始化的全局变量
.bss	未初始化的静态变量,以及初始化为0的全局变量和静态变量

之后我们会详细解释这个设置的原因。


← 程序性能优化(1)

初窥深度学习(1) →