GCC编译原理——链接

4 链接

4.1 链接的基本描述

  • 链接是将各种代码和数据片段收集并组合成一个单一文件的过程,该文件可被加载到内存并执行。链接可以执行与编译时,在源代码翻译成机器代码时;也可执行与加载时,也就是在程序被加载器加载到内存并执行时;还可以执行与运行时,也就是由应用程序来执行。
  • 链接器在软件开发中扮演一个关键的角色,它们使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立的修改和编译这些模块。当我们改变这些模块中的一个时,只需简单的重新编译它,并重新链接应用,而不必重新编译编译其他文件。
  • 理解链接器的好处:
    1)理解链接器将帮助你构造大型程序。构造大型程序的程序员经常会遇到由于缺少模块、缺少库或者不兼容库版本引起的链接器错误。
    2)理解链接器将帮助你避免一些危险的编程。
    3)理解链接将帮助你理解语言的作用域规则是如何实现的。
    4)理解链接将帮助你理解其他重要的系统概念。
    5)理解链接将使你能够利用共享库。

4.2 程序的编译及优化过程

4.2.1 程序的编译过程

以hello.c为例,讲解程序的编译过程,如下所示:
GCC编译原理——链接_第1张图片
GCC编译器驱动程序读取源程序文件hello.c,并把它翻译成一个可执行目标文件hello。此翻译过程分为四个阶段完成。

  • 1)预处理阶段
    预处理器(cpp)根据以字节#开头的命令,修改原始的C程序。如:宏展开、头文件插入等。
    编译命令:gcc –E hello.c -O hello.i
  • 2)编译阶段
    编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。该过程会进行相关编程语言的语法分析。
    编译命令:gcc -S hello.i -O hello.s
  • 3)汇编阶段
    汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在木匾文件hello.o中。该过程生成的文件是一个二进制文件,用文本编辑器打开时将看到一堆乱码。
    编译命令:gcc -C hello.s -O hello.o
  • 4)链接阶段
    链接器(ld)负责将某个文件中的代码合并到其调用者中,其结果就得到了hello文件,此文件是一个可执行目标文件(或可执行文件),其可被加载到内存中,有系统执行。

备注:

  • 1)编译环境变量外的头文件
    编译命令:gcc main.c -I 头文件路径 -O main
  • 2)编译动态库或静态库
    编译命令:gcc main.c -L 库文件路径 -labc -O main
    abc:为库名,Linux下常见库文件名为libabc.so(动态库)、libabc.a(静态库)。
    注意:在动态库与静态库库名相同时,GCC优先链接动态库,而要使用静态库则需使用-static编译选项。

4.2.2 编译优化

编译优化命令:-O(默认-O1)

  • -O0
    不优化。

  • -O1
    不使用-O'选项时,编译器的目标是减少编译的开销,使编译结果能够调试。语句是独立的:如果在两条语句之间用断点中止程序,你可以对任何变量重新赋值,或者在函数体内把程序计数器指到其他语句,以及从源程序中精确地获取你期待的结果。 不使用-O’选项时,只有声明了 register 的变量才分配使用寄存器。编译结果比不用-O'选项的 PCC 要略逊一筹。 使用了-O’选项,编译器会试图减少目标码的大小和执行时间。

  • -O2
    多优化一些。除了涉及空间和速度交换的优化选项,执行几乎所有的优化工作。例如不进行循环展开(loop unrolling)和函数内嵌(inlining)。和 -O 选项比较,这个选项既增加了编译时间,也提高了生成代码的运行效果。

  • -O3
    优化的更多。除了打开-O2 所做的一切,它还打开了-finline-functions 选项。

  • Os
    在O2的基础上,再对代码空间进行优化,减小可执行文件的大小,一般用于正式版本发布的时候使用。

  • 注意:如果指定了多个 -O 选项,不管带不带数字,最后一个选项才是生效的选项。优化级别越高,编译时间越长,执行效率越高。

调试命令:-g(默认-g2)

  • -g1:不包括局部变量和行号有关的调试信息,因此只能够用于回溯跟踪和堆栈转储;
  • -g2:扩展符号表、行号、局部或外部变量信息;
  • -g3:包含-g2中的所有调试信息,以及源代码中定义的宏。

4.2.3 GCC反汇编

反汇编指令:objdump

  • objdump -x 可执行文件:以某种分类信息的形式把目标文件的数据组成输出;
  • objdump -t 可执行文件:输出目标文件的符号表;
  • objdump -h 可执行文件:输出目标文件的所有段的信息,如.text、.bss、.data等;
  • objdump -j ./text/ .data –S 可执行文件:输出指定段的信息(反汇编源代码);
  • objdump -S 可执行文件:输出目标文件的符号表,-S 尽可能的反汇编出源代码,尤其是编译过程使用了-g的编译选项时效果会比较明显,并隐含了-d参数。

对任意二进制文件进行反汇编:
objdump -D -b binary –m i386 可执行文件
命令解析:

  • -d:–disassemble 反汇编指定段的代码;
  • -D:–disassemble –all 反汇编所有的代码段;
  • -b:指定可执行文件的格式,非必须命令选项;
  • -m:指定反汇编目标文件时使用的架构,objdump –m 可查看更多支持的指令级架构。

4.3 静态链接

  • Linux的静态链接器以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出。输入的可重定位目标文件由各种不同的代码和数据节组成,每一节都是一个连续的字节序列。
    为了构造可执行文件,链接器必须完成两个主要任务:
  •  符号解析
    目标文件定义和引用符号,每个符号对应于一个函数、一个全局变量或一个静态变量。符号解析的目的是将每个符号引用正好和一个符号定义关联起来。
  •  重定位
    编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得他们指向这个内存位置。链接器使用汇编器产生的重定位条目的详细指令,不加甄别地执行这样的重定位。

4.4 目标文件

目标文件分为三种。

  •  可重定位目标文件
    包包含二进制代码和数据,其形式可在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。
  •  可执行目标文件
    包含二进制代码和数据,其形式可以直接被复制到内存并执行。
  •  共享目标文件
    一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载进内存并链接。

4.5可重定位目标文件

下图是一个典型的ELF可重定位目标文件的格式。ELF头以一个16字节的序列开始,该序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,主要有ELF头的大小、目标文件的类型(如:可重定位、可执行或共享)、机器类型(如:x86-64)、节头部表中条目的大小和数量。不同节的位置和大小都有一个固定大小的条目。
夹在ELF头和节头部表之间的都是节。一个典型的ELF可重定位目标文件包含下面几个节:
GCC编译原理——链接_第2张图片

  • .text:代码段,已编译程序的机器代码;该部分在程序运行前就已知其大小,通常属于只读数据,某些架构允许代码段可写。
  • .rodata:只读数据,通常用于存放常量。
  • .data:数据段,已初始化的全局变量和静态变量。
  • .bss:未初始化的全局变量和静态变量,以及所有被初始化为0的全局或静态变量。目标文件在这个节中不占据实际的硬盘存储空间,仅仅是一个占位符。运行时,在内存中分配这些变量,初始值为0。
  • .symtab:一个符号表,他存放在程序中定义和引用的函数和全局变量的信息。
  • .rel.text:一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。
  • .rel.data:被模块引用或定义的所有全局变量的重定位信息。一般而言,任何已初始化的全局变量,如果他的初始化值是一个全局变量地址或者外部定义函数的地址,都需要被修改。
  • .debug:一个调式符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件。只有以-g选项调用编译器驱动程序时,才会得到这张表。
  • .line:原始C源程序中的行号和.text节中机器指令之间的映射。只有以-g选项调用编译器驱动程序时,才会得到这张表。
  • .strtab:一个字节表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。字符串就是以NULL结尾的字符串的序列。

4.6 符号和符号表

  • 每个可重定位目标模块m都有一个符号表,它包含m定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:
  •  由模块m定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的C函数和全局变量。
  •  由其他模块定义并被模块m引用的全局符号。这些符号称为外部符号,对应于其他模块中定义的非静态C函数和全局变量。
  •  只被模块m定义和引用的局部符号。他们对应于待static属性的C函数和全局变量。

4.7 符号解析

  • 链接器解析符号引用的方法是将每个引用与他输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。对那些和引用定义在相同模块中的局部符号的引用,符号解析是非常简单明了的。编译器只允许每个模块中每个局部符号有一个定义。静态局部变量也会有本地链接器符号,编译器还要确保他们拥有唯一的名字。
  • 对全局符号的引用解析就棘手得多。当编译器遇到一个不是当前模块中定义的符号(变量或函数名)时,会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目,并把它交给链接器处理。若链接器在它的任何输入模块中都找不到这个被引用符号的定义,就输出一条错误信息并终止。如下程序示例:
1   void foo(void);
2
3   int main()
4   {
5       foo();
6       return 0;
7   }
  • 编译器会没有障碍的运行,但是当链接器无法解析对foo的引用时,就会终止:
    /tmp/cc4sgXQJ.o: In function main': a.c:(.text+0x7): undefined reference tofoo’
    collect2: error: ld returned 1 exit status
  • 对全局符号的符号解析很棘手,还因为多个目标文件可能会定义相同名字的全局符号。在这种情况下,链接器必须要么标志一个错误,要么以某种方法选出一个定义并抛弃其他定义。Linux系统采纳的方法设计编译器、汇编器和链接器之间的协作。

4.7.1 链接器如何解析多重定义的全局符号

  • 链接器的输入是一组可重定位目标模块。对于多个模块定义同名的全局符号,Linux编译系统采用的方法如下:
  • 在编译时,编译器向汇编器输出每个全局符号,或者强或者弱,而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。
    根据强弱符号的定义,Linux链接器根据以下规则来处理多重定义的符号名:
  •  规则1:不允许有多个同名的强符号;
  •  规则2:如果有一个强符号和多个弱符号同名,那么选择强符号;
  •  规则3:如果有多个弱符号同名,则从这些弱符号中任意选择一种。

4.7.2 与静态库链接

  • 相关的函数可被编译为独立的目标模块,然后封装为一单独的静态库文件。而应用程序可通过命令行上指定单独的文件名字来使用该库文件中定义的函数。并在链接时,链接器只需复制被程序引用的目标模块,这可减少可执行文件在磁盘和内存中的大小。另一方面,应用程序只需包含较少的库文件名字即可。
  • 在Linux系统中,静态库以一种称为存档的特殊文件格式存放在磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,有一头部文件用来描述每个成员目标文件的大小和位置。存档文件有后缀.a标识。
  • 创建静态库的方法:
  • 1.编译库文件:gcc –c hello.c
  • 2.编译静态文件(使用AR工具):ar rcs libhello.a hello.o
    由上即可生成.a的静态文件。

4.7.3 链接器使用静态库解析引用

  • 如果各个库的成员是相互独立的(即没有成员引用另外一个成员定义的符号),那么这些库就可以以任何顺序放置在命令行的结尾处。另外一方面,如果库不是相互独立的,那么必须对他们排序,使得对于每个被存档文件的成员外部引用的符号s,在命令行中至少有一个s的定义是在s的引用之后的。比如,foo.c调用libx.a和libz.a中的函数,而这连个库有调用liby.a的函数,则在命令行中libx.a和libz.a必须在liby.a之前:
    gcc foo.c libx.a libz.a liby.a
  • 若满足依赖需求,可在命令行上重复库。比如:foo.c调用lib.a中的函数,该库又调用liby.a中的函数,而liby.a有调用libx.a中的函数,则libx.a必须在命令行上重复出现:
    gcc foo.c libx.a liby.a libx.a

4.8 重定位

  • 在链接器完成符号解析时已把每个符号引用和符号定义关联起来,此时链接器可知道他的输入模块中的代码节和数据节的确切大小。因此可进行重定位步骤,该过程主要是将各个输入模块合并起来(输入模块为.o文件),并为每个符号分配运行时的地址,由两步组成:
  •  重定位节和符号定义。链接器将所有相同类型的节合并为同一类型的新的聚合节。如:来自所有输入模块的.data节将被合并成输出的可执行目标文件的.data节,同时链接器将运行时内存地址赋给该聚合节。因此,在完成这一步时,程序中的每条指令和全局变量都将拥有唯一的运行时的内存地址。
  •  重定位节中的符号引用。链接器修改代码节和数据节中每个符号的引用,使得他们执行正确的运行地址。而执行这一步,链接器要依赖于可重定位目标模块中称为定位条目的数据结构。

4.8.1 重定位条目

  • 在汇编器生成的目标模块并不知道数据和代码将放在内存中的具体地址,也无法知道该模块所引用的任何外部定义的函数或者全局变量的位置。因此,汇编器在遇到对最终位置未知的目标引用,就会生成一个重定位条目,用以告知链接器在目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中,已初始化数据的重定位条目放在.rel.data中。

4.8.2 重定位符号引用

1.重定位PC相对引用;
2.重定位绝对引用。

4.9 可执行目标文件

典型的ELF可执行文件中各类信息:
GCC编译原理——链接_第3张图片

  • 可执行目标文件的格式类似于可重定位目标文件的格式。ELF头描述文件的总体格式,它还包括程序的入口点,即当程序运行时要执行的第一条指令的地址。.text、.rodata和.data节与可重定位目标文件中的节是相似的,这些节被重定位到他们最终运行时内存地址。.init节中定义了一个小函数,叫做_init,程序初始化代码会调用它。段头部表描述了可执行文件的连续片映射到连续内存段的映射关系,使得ELF可执行文件被设计得很容易加载到内存中。

4.10 加载可执行目标文件

GCC编译原理——链接_第4张图片

  • 当加载器运行时,他创建类似上图所示的内存映像。在程序头部表的引导下,加载器可将执行文件的片复制到代码段和数据段。接下来,加载器跳转到程序的入口点,即_start函数的地址,该函数是在系统目标文件ctrl.o中定义的,对所有的C程序都是一样的。而_start函数调用系统启动函数__libc_start_main,该函数定义在libc.so中。它初始化执行环境,并调用用户层的main函数,处理main函数的返回值,并且在需要的时候把控制返回给内核。

4.11 动态链接共享库

  • 共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来,该过程称为动态链接,是由一个动态链接器的程序来执行的,共享库也称为共享目标,Linux系统中用.so后缀表示,Windows中用.dll表示。
    共享库的共享方式:首先,在任何给定的文件系统中,对于一个库只有一个.so文件,所有引用该库的可执行目标文件共享这个.so文件中的代码和数据,而不是像静态库的内容那样呗复制和嵌入到引用他们的可执行文件中。其次,在内存中,一个共享库的.text节的一个副本可以被不同的正在运行的进程共享。

  • 创建共享库:gcc -shared -fpic -o libvector.so addvec.c multvec.c

  • 编译链接共享库:gcc -o main main.c ./libvector.so

  • Main中包含.interp节,该节包含动态链接器的路径名,同时动态链接器是一个共享目标,而加载器将加载和运行这个动态链接器,则动态链接器通过执行下面的重定位完成链接任务:

  •  重定位libc.so(该库为标准C库)的文本和数据到某个内存段;

  •  重定位libvector.so的文本和数据到另一个内存段;

  •  重定位main中所有有libc.so和libvector.so定义的符号的引用。

4.12 库打桩机制

  • Linux链接器支持库打桩,它允许你截获你对共享库函数的调用,取而代之执行自己的代码。使用打桩机制,可追踪对某个特殊库函数的调用次数,验证和追踪他的输入和输出值,或者甚至把它替换成一个完全不同的实现。
  • 打桩机制的基本思想:给定一个需要打桩的目标函数,创建一个包装函数,其原型与目标函数完全一样。使用特殊的打桩机制,就可以欺骗系统调用包装函数而不是目标函数。打桩可发生在编译时、链接时或当程序被加载和执行的运行时。

4.12.1 编译时打桩

示例代码:
GCC编译原理——链接_第5张图片
编译方法:

  • gcc -DCOMPILETIME -c mymalloc.c
  • gcc -I库路径 -o intc int.c mymalloc.o
    由于有-I库路径,在进行打桩时,会告诉C预处理器在搜索通常的系统目录之前,先在指定的库路径查找malloc.h。mymalloc.c中的包装函数是使用标准malloc.h头文件编译的。

4.12.2 链接时打桩

  • Linux静态链接器支持用–warp f标志进行链接打桩,该标志会告诉链接器,把对符号f 的引用解析成__warp_f(前缀为两个下划线),还要把对符号__real_f(前缀是两个下划线)的引用解析为f。
  • 编译方法:
    把源文件编译成可重定位目标文件:
    gcc -DLINKTIME -c mymalloc.c
    gcc -c int.c
  • 把目标文件链接可执行文件:
    gcc -Wl,–wrap,malloc -Wl,–wrap,free -o intl int.o mymalloc.o
    -Wl,option标志把option传递给链接器,option中的每个逗号都需要替换成一个空格,即-Wl,–wrap,malloc就把–wrap malloc传递给链接器。

4.12.3 运行时打桩

  • 编译时打桩需要能够访问程序的源代码,链接时打桩需要能够访问程序的可重定位对象文件,运行时打桩只需要能够访问可执行目标文件。该机制基于动态链接器的LD_PRELOAD环境变量。
  • 构建包装函数的共享库的方法:
    gcc -DRUNTIME -shared -fpic -o mymalloc.so mymalloc.c –ldl
    gcc -o intr int.c

4.13 处理目标文件的工具

  •  Linux系统大量的工具可用于帮助理解和处理目标文件;
  •  AR:创建静态库,插入、删除、列出和提取成员;
  •  STRINGS:列出目标文件中所有可打印的字符串;
  •  STRIP:从目标文件中删除符号表信息;
  •  NM:列出一个目标文件中节的名字和大小;
  •  SIZE:列出目标文件中节的名字和大小;
  •  READELF:显示一个目标文件的完整结构,包括ELF头总的编码的所有信息,含SIZE和NM的功能;
  •  OBJDUMP:所有二进制工具之母,能显示一个目标文件中的所有信息,常用于反汇编代码;
  •  LDD:列出一个可执行文件在运行时所需要的共享库。

你可能感兴趣的:(编译原理)