编程代码无非是由函数和各种变量以及对这些变量的读、写所组成,而不管是变量还是函数,它们最终都要存储在内存里。为每个变量和函数正确地分配内存空间,记录它们的地址,并把这个地址复写回调用或引用它们的地方,这是一个十分重要且困难的任务。

我们在使用gcc时,往往执行一个命令后,就能得到可执行程序,所以你可能会误认为是编译器负责为变量分配内存地址,但是实际上,这个工作是由链接器来完成的。每个变量都有自己的明成,通常我们把这些名称叫作符号。简单来讲,链接器的作用就是为符号转换成地址,一般来说可以分为三种情况:

  1. 生成二进制可执行文件的过程中。这种情况称为静态链接。
  2. 在二进制文件被加载进内存时。这种情况是在二进制文件保留符号,在加载时再把符号解析成真实的内存地址,这种情况被称为动态链接。
  3. 在运行期间解析符号。这种情况会把符号的解析延迟到最后不得不做时才去做符号的解析,这也是动态链接的一种。

链接小例子

编译

gcc example.c -c -o example.o -fno-PIC -g
gcc external.c -c -o external.o -fno-PIC -g

-c: 告诉gcc不要进行链接,只要编译到.o就可以了。

-o: 指定输出文件名。

-fno-PIC: 告诉编译器不要生成PIC的代码。gcc4.8版本在编译过程中默认就是PIC模式。这个选项对于动态链接意义较大。静态链接不使用。

-g:打开调试信息,让我们分析过程中能够对源码有更完整的对应关系。

链接

将两个.o文件链接生成可执行文件,由目标文件生成可执行文件的过程就是链接。

gcc external.o example.o -o a.out -no-pie

-no-pie表示关闭pie模式。

gcc会默认打开pie模式,也就意味着系统loader对加载可执行文件时的起始地址,会随机加载。关闭pie后,在Linux64位的系统下,默认的加载起始地址是0x400000。

这样,就得到了可执行二进制文件a.out,以上内容就是编译和链接的全过程。

链接器的作用

程序员在开发代码的过程中,也是直接跟这些符号打交道的。如果想获取某个变量的值,就直接从变量符号里读取内容;如果想调用某个函数,也是直接下一个函数符号的调用语句。

但是,我们知道,CPU在执行程序代码的时候,并不理解符号的概念,它所理解的只有内存地址的概念。不管是读数据,调用函数还是读指令,对于CPU而言都是一个个的内存地址。因此,这里需要一个连接CPU与程序员之间的桥梁,把程序中的符号转换成CPU执行的内存地址。这个桥梁就是链接器,它负责将符号转换为地址。

链接器的第一个作用就是把多个中间文件合并成一个可执行文件。每个中间文件都有自己的代码段和数据段等多个section,在合并成一个可执行程序时,多个中间文件的代码段会被合并到可执行文件的代码段,它们数据段也会被合并为可执行文件的数据段。

但是链接器在合并多个目标文件的时候并不是简单地将各个section合并就可以了,它还需要考虑每个目标中的符号地址,这就引出了链接器的第二个任务:重定位。所谓重定位,就是当被调用者的地址变化了,要让调用者知道新的地址是什么。

两步链接

根据上面的分析,链接器的工作流程主要分两步:

第一步是,链接器需要对编译器生成的多个目标(.o)文件进行合并,一般采取的策略是相似段的合并,最终生成共享文件(.so)或者可执行文件。这个阶段中,链接器对输入的各个目标文件进行扫描,获取各个段的大小,并且同时会收集所有的符号定义以及引用信息,构建一个全局的符号表。当链接器构造好了最终的文件布局以及虚拟内存布局后,我们根据符号表,也就能确定了每个符号的虚拟地址了。

第二步是,链接器会对整个文件再进行第二遍扫描,这一阶段,会利用第一遍扫描得到的符号表信息,依次对文件中每个符号引用的地方进行地址替换。也就是对符号的解析以及重定位过程。

这就是链接器常用的两部链接(two-pass linking)的步骤。简单来讲就是进行两遍扫描:第一遍扫描完成文件合并、虚拟内存布局的分配以及符号信息收集;第二遍扫描则是完成了符号的重定位过程。

总结

我们说,从源文件生成二进制可执行文件,这一过程主要包含了编译和链接两个步骤。其中,编译的作用是生成性能优越的机器码。对于编译单元内部的静态函数,可以在编译时通过相对地址的办法,生成call指令,因为无论将来调用者和被调用者被安置到什么地方,它们之间的相对距离不会发生变化。

而其他类型的变量和函数在编译时,编译器并不知道它们的最终地址,所以只能使用占位符(比如0)来临时代替目标地址。

而链接器的任务是为所有变量和函数分配地址,并把被分配到的地址回写到调用处。链接器的过程主要分为两步,第一步是多文件合并,同时为符号分配地址,第二步则是将符号的地址回写到引用它的地方。其中,地址回写有一个专门的名字叫做重定位。重定位的过程依赖目标文件中的重定位表。

编译器和链接器的作用

编译器的作用主要是把源代码文件翻译成中间结构(例如LVM IR),然后对它进行优化,以优化程序性能。编译器的输出是汇编文件,汇编文件中仍然保留了符号。然后,汇编编辑器再将汇编文件翻译成中间文件。最后,链接器将中间文件合并起来,组成二进制可执行程序。这种合并不是简单地拼接,而是要为符号分配地址,然后再把地址回填到引用符号的地方,这个过程就是重定位。而重定位才是链接器最重要的任务。

内容来自极客时间