云计算百科
云计算领域专业知识百科平台

计算机原理—程序的链接

一、程序的链接

在前面的动态库分析中,对程序的链接进行过一些整体的流程分析。单纯的代码示例一般是很简单的链接,特别是当资源等几乎不需要的情况下,链接可能会相当简单。但在实际的工程中,c++程序往往成百上千个编译单元甚至万以上的编译单元,大量的各种资源及相关的动态和静态库等。这都需要链接器一一将其最终链接并形成可执行单元。这样才能供后面的装载器将可执行单元装载到内存中并最终展开执行,实现代码编写的最终的目的。
要想理解链接就要明白,链接和编译的划分界限。也就是说,要明白上面提到的编译单元和资源库等文件何时会转向链接的时间点。正常的情况下,编译器会把代码中每个独立的源文件编译为独立的目标文件(即.o文件,相反的例子就是如果编译时使用-c选择,则只编译不会形成可链接的编译单元)。这些目标文件中包含了编译后的机器指令,但不包括尚未确定地址的函数调用、全局变量访问以及外部符号和重定位信息等。本文将以Linux平台为基础,对程序的链接过程进行详细的分析说明。

二、链接器

链接器,Linker是一个系统工具。其主要目的是将编译器生成的多个目标文件(.o)和库文件合并成为一个共享库或可执行文件。链接器的核心就是处理符号-Symbol。它有三种类型即:

  • 已定义符号:当前文件提供实现
  • 未定义符号:当前文件引用其他文件的实现
  • 局部符号:只在当前目标文件内部可见
  • 如果想对这些符号的类型有更深入的学习和分析,请查找链接器相关的书籍和资料。这一部分的书籍并不多,大家可自行查找即可。其本质就是要处理两个问题:

  • 符号解析
    Symbol Resolution,也就是说将程序中的每个编译单元形成的目标文件中引用的各种符号,如函数名、变量名等与实际的定义处一一映射起来
  • 重定位
    Relocation,处理代码和数据中的地址占位符。原因是编译器在编译时是不知道最终的内存地址的,所以只能空置而在链接时再处理这些最终的地址。当然,在最新的编译到执行的链路中,有些最终的地址是在装载器中处理的(参看动态链接器)。不再对其进行展开分析
  • 传统的链接器指的是usr/bin/ld,它在学术上被称为静态链接器(Static Linker)。静态链接器负责在构建期读取目标文件和库、解析普通符号、抽取静态库成员、合并 Section、分配地址、处理构建期重定位、生成GOT/PLT和动态重定位项、记录DT_NEEDED、创建Program Header,并链接出最终可执行ELF文件。
    而ld-linux.so(/lib64/ld-linux-x86-64.so.2)是内核提供的一个组件用来装载或动态链接相关的目标,所以也被称为是动态链接器(Dynamic Linker)。它在程序运行时负责加载共享库、递归处理依赖、计算装载基址、处理动态重定位、填写GOT、解析PLT函数、初始化TLS,以及执行共享库构造函数和析构函数。
    需要说明的是,不能简单的把静态和动态链接器严格的对立起来,静态链接器既可以处理动态库也可以处理静态库而动态链接器则不会处理静态库。因为静态库在ld已经完全复制到可执行文件了。
    对于链接器来说,其具体的职责包括:

    • 合并目标文件
    • 解析符号
    • 抽取静态库成员
    • 分配虚拟地址
    • 处理重定位
    • 记录动态库依赖
    • 引入C/C++运行时启动代码
    • 生成ELF Program Header

    在目前实际的应用中,Linux平台常用的链接器主要有:

  • ld:传统的链接器,以单线程链接为主
  • GNU gold:ld的替代品,采用了并行处理,通过多线程实现快速的多目标文件链接。可使用编译器选项-fuse-ld=gold来应用
  • LLVM lld:LLVM中的链接器,支持多线程。但早期,其–threads选项默认是关闭。目前它已是一个高效的并行链接器
  • mold:现代的链接器,速度最快。核心是提供更优秀的并行编程和多线程支持。默认使用所有可能使用的cpu来加速链接
  • 三、简单示例

    通过库可以更好的理解链接,下面给出简单的应用例子:

  • 动态库的编译和链接
    动态库一般是以.so为后缀的文件。它可以被链接后加载到不同的虚拟地址,它一般使用下面的方式进行编译和链接:

    #创建
    g++ -fPIC -c mul.cpp -o mul.o
    g++ -shared -Wl,-soname,libmath.so.1 -o libmath.so.1.0 mul.o
    #版本管理应用链接
    ln -s libmath.so.1.0 libmath.so.1 #创建SONAME符号链接,运行时链接器可调用
    ln -s libmath.so.1 libmath.so #开发符号链接
    #链接
    g++ main.o -L. -lmath -o myapp
    #SONAME验证
    readelf -d libmath.so.1.0 | grep SONAME

  • 静态库的编译和链接
    静态库一般是以.a为后缀的文件。它的本质是一个由多个.o目标文件组成的集合。其一般使用下面的方式进行编译和链接:

    #创建
    g++ -c add.cpp -o add.o
    g++ -c sub.cpp -o sub.o
    ar rcs libmath.a add.o sub.o #生成静态库
    ar t libmath.a  #列出静态库成员文件
    #链接
    g++ main.o -L. -lmath -o myapp

    注意,静态库的链接顺序通常是从左到右扫描库,所以依赖库应该在前而被依赖库要在后。

  • 四、链接器任务处理

    在不同的场景下,链接器的具体的工作调度和处理机制也有所不同。在原来的开发任务相对简单的情况下,链接器的任务也相对简单。所以在后来项目变得越来越复杂时,编译器端提供了各种并行的编译任务处理。同样,链接器也提供了类似的处理。
    链接器的任务并行有两两种机制。一种是启动多个链接任务进行链接;另外一个是在一个链接任务内并行调用链接处理。有过并行开发的同学们当然明白哪种更适合面方,更有弹性和可扩展性。肯定会选择一个链接任务内进行并行处理。
    链接并行和普通的并行开发没有什么不同,其实就是资源的管理和调度的处理问题,包括线程数量、内存数量以及io瓶颈等等。所以对于开发者来说,要通过相关的编译链接工具来有针对性的指定链接器线程的设置,特别是可以有针对性的限制不同场景下的线程的数量大小。比如可以在相关的工程文件中(如Makefile)中将整体的编译构建体系进行阶段划分,指定在编译和链接不同阶段的并行值。
    在实际的开发场景中,有可能遇到不同的开发情况。比如少量的较大的(百兆以上)单体目标文件生成库以及多个小目标文件生成库或者混合的情况。其处理的机制也可以有针对性的提供相应的方法:

  • 大型单体目标文件
    由于需要读入目标文件过大相关的信息(调试、重定位表和符号表等),其瓶颈在于cpu和内存占用过多。推荐使用mold链接器或LLVM lld链接器,它们支持大型单体目标文件的并行链接。但一般不推荐使用gold和ld。mold链接器会通过分区技术,将整个链接过程划分成多个独立的子链接过程并分配到相应的线程中并行进行
  • 大小和数量中等文件
    可根据实际情况来选择相关的链接器如lld。如果内存较小可以使用单线程模式运行并关闭选用的链接器中的并行模式或任务数量缩减为1
  • 多个小目标文件
    这种情况下,由于启动的链接任务太多,会导致CPU调度复杂,IO及内存占用过多。这种情况下链接器选择没有什么限制。可根据实际情况进行分阶段的构建控制、限制链接的任务数,推荐使用Ninja构建系统。也可以从整体上直接限制链接器任务的数量或者根据实际的需求进行动态的链接(这种机制比较麻烦,谨慎)
  • 五、整体流程

    对于链接器来说,其整体的流程如下:

  • 解析相关参数并启动
    g++会根据实际的输入参数来确定输入文件、搜索目录、库的种类及相关的加载模式、安全选择等等
  • 读取目标文件
    这个很好理解,不管是符号还是重定位都需要将相关的目标文件、库的Section、符号表及TLS等信息一一从输入文件中读取进来
  • 解析普通目标文件符号
    下来就是对读取的普通的目标文件的符号进行解析了,从而为后期使用提供相关内容。如使用的库中的函数或相关的不同单元中的函数名称等
  • 扫描静态库
    静态库需要拷贝到程序中,所以链接器只要能够满足符号的可解析即达到目的
  • 处理特定的内容
    对于一些c++中特定的内容如模板、虚函数表、RTTI以及全局对象的构造和析构等的处理
  • 合并相关Section
    将不同单元的相同节进行合并,并处理Section的对齐、边界处理及权限和TLS、RELRO等
  • 分配地址空间
    将相关的节的地址进行分配,特别是PIE(执行文件)和PIC(共享库)通常使用相对地址,需要在运行时再加上装载器的地址
  • 处理重定位
    编译期只是一种静态的处理,其最终的地址往往需要在链接时才能确定。而编译的目标文件的重定位表会记录位置、类型、符号和加数,链接器据此修正指令或数据
  • 生成Program Header
    Section主要面向链接和分析,内核装载时主要看Program Header。链接器将Section组织成可加载段,供后面的加载器使用
  • 生成动态链接信息
    在分析程序的执行过程中,知道了动态链接器使用这些结构读取DT_NEEDED、符号表、字符串表和动态重定位表来进行程序的加载执行。动态链接程序中经常可以看到的信息包括:.dynamic .dynsym .plt .got .got.plt等
  • 生成ELF文件
    最后就是真正生成可执行的ELF文件。可以通过“file”命令来查看具体的相关信息
  • 当然,在实际的场景中,可能情况相对复杂。包含各种动态库和静态库及多种目标文件。但整体的流程基本就在上面的描述内,只是增减具体的一些内容罢了。

    六、链接分析

  • 动态库运行时加载
    动态库是可以在运行时动态加载的。即链接器在构建时只记录相关的依赖项,但真正的加载是在程序启动后。这样,动态库的相关代码可以为多个进程。当然,其中的每个进程各自的受保护数据及TLS等仍然是保持独立的
  • 位置无关
    在编译动态库时经常可以看到-fpic和-fPIC。使用这两人个编译选项的目的是为了生成“位置无关的代码”。即可以让代码在内存的任意位置正常运行而不需要修改代码本身。
    位置无关代码,Position-Independent Code, 缩写为PIC。在普通的代码编译时,需要明确代码的具体位置,这意味着这些代码中包含了函数或变量的绝对地址。而这种情况下,程序必须加载到这个固定的地址才可以正常的运行。但动态库的目标就是为了为多个程序共享,而为了达到多个进程可以映射不同的虚拟地址的情况,就出现了位置无关的方法。-fpic和-fPIC,前者追求效率对GOT大小有限制,适合于中小程序;而后者没有对GOT的限制,更通用,适合于大型的程序
  • GOT和PLT
    在前面的分析中知道,动态库函数的地址在编译时通常未知,程序不能直接生成具体的实际地址调用。链接器会使用GOT(Global Offset Table,过程链接表)和PLT(Procedure Linkage Table,全局偏移表)来进行处理。
    由于在编译时,库加载时需要由ASLR(地址空间布局随机化)来确定基地址,导致有些符号(如函数)的地址未知,而真实的运行时又需要这个真实的地址。而PLT可以通过PLT桩实现抽象的地址替代,而在首次加载调用时,GOT中的地址指向PLT的相关解析代码找到具体的符号的实际的地址。形成惰性绑定(Lazy Binding)机制,避免了在程序启动时解析所有动态符号,可以显著加快启动速度。更具体的流程和详细的技术细节,大家可以查找相关的链接器资料进行学习和分析,此处不再展开
  • 七、常见问题和分析工具

    在实际的开发中,经常遇到一些链接错误。下面给出一些经典的链接问题,供参考:

  • undefined reference
    这种最典型的就是找不到链接的函数名,一般这种情况出现的原因在于没有实现函数、实现但未被编译器处理、C和C++混合使用中的C++改名机制、ABI不兼容及库的顺序错误或者其它特别机制导致的函数名隐藏等等
  • 未找到库
    这也是很经典的问题,一般这种问题的原因在于未实现库、库的名称错误(如大小写的i)、库未拷贝或指定到对应在的环境变量、搜索路径错误等
  • 运行时缺少库
    这也是常见的链接问题。一般情况下是未拷贝到当前或指定目录、未指定依赖、指定依赖但未更新配置。ldd命令就是处理这个问题的
  • 符号重定义
    这种也很常见,典型的就是全局变量的多处包含、重名以及不同C++版本中对一些特殊情况如const,constexpr,inline的处理
  • 为了分析和解决相关的问题,在Linux平台上有不少的相关工具可供使用。这在前面也介绍过不少,此处再次列举一下,但不再详细分析说明。主要包括:file、readelf、nm、objdump、ar t、g++ -v等等。

    八、总结

    通过上面的分析,已经基本对程序的链接有了一个整体的理解。需要说明的是,链接不要认为只是在库应用时才会使用。链接其实是整个程序构建的一个重要的环节。只不过在库的应用时其可以更容易的体现链接的流程。
    从宏观上看,链接器在很多情况下都划到了编译的整体流程当中,所以如果看到资料和书籍中,在编译过程中对链接器详细分析说明,也不要感到惊讶。可以理解为它们都是程序构建的具体的环节。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 计算机原理—程序的链接
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!