链接原理浅析(基于Unix ELF文件格式)

一.源文件至可执行目标文件的转换过程

源程序转换过程
源程序转换过程(CSAPP)

1.预处理器: 处理以字节#开头的指令,将指向的文件原样替换当前#指令
2.编译器: 将高级语言程序编译为汇编语言,经历过程: 词法分析,语法分析,语义分析,中间代码生成,中间代码优化,目标代码生成
3.汇编器: 将汇编语言翻译为机器语言
4.链接器: 将相互关联的文件组合起来,生成可执行程序

二. 目标文件

1.可重定位目标文件: 由汇编器生成,包含二进制代码与数据,可与其他可重定位目标文件合并,创建可执行目标文件
2.可执行目标文件: 由链接器生成,包含二进制代码与数据,可直接拷贝到存储器并执行
3.共享目标文件: 特殊的可重定位目标文件,可以在加载或运行时动态加载到存储器并链接

目标文件格式(CSAPP)

可重定位目标文件:

  • ELF头: 描述字的大小和生成该文件的系统的字节顺序,且包含帮助链接器解析和解释目标文件的信息:ELF头大小,目标文件类型,机器类型,节头部表文件偏移,节头部表中的表目大小和数量
  • 节头部表: 描述不同节的位置和大小
  • .text: 程序的机器语言代码
  • .rodata: 只读数据,如switch语句的跳转表
  • .data: 已初始化的全局变量,局部变量运行时保存在栈中
  • .bss: 未初始化的全局变量,不占据磁盘空间
  • .symtab: 符号表,符号表是汇编器使用编译器输出到.s文件中的符号构造的,与编译器中的符号表不同在.symtab不包含局部变量的表目
  • .rel.text: 存放帮助.text节进行重定位的信息
  • .rel.data: 存放帮助.data节进行重定位的信息
  • .debug: 调试符号表
  • .line: 源程序行号与.text节中机器指令间的映射,只有以-g选项调用编译驱动程序才会得到这张表
  • .strtab: 字符串表,内容包含.symtab, .debug节中的符号表以及节头部中的节名字。字符串表就算以null结尾的字符串序列

可执行目标文件:大体与可重定位目标文件相同。不同点在于ELF头部还包括程序的入口点, .text, .rodata, .data节已完成重定位,.init节定义函数_init, 由程序初始化代码调用,且由于已经完成链接因此不需要.relo节。段头表描述了可执行文件到存储器段的映射关系,即执行时存储器段地址,大小等。

符号: 每个可重定位目标模块m都有一个符号表(.symtab), 包含m所定义引用的符号的信息。在链接器上下文中符号可分为三种:

  • 由m定义并能被其他模块引用的全局符号
  • 由其他模块定义并被模块m引用的全局符号(extern)
  • 只被模块m定义和引用的本地符号(static)

static属性的本地过程变量由编译器在.data和.bss中为每个定义分配空间,并在符号表中创建一个有唯一名字的本地链接器符号(即在变量名后添加.x,使得符号唯一)

符号表(.symtab): .symtab节由Elf_Symbol数组构成

Elf_Symbol数据结构(CSAPP)
  • name: 字符串表中的字节偏移,指向符号名
  • value: 距定义目标节的起始位置的偏移
  • size: 目标的大小(以字节为单位)
  • type: 符号类型,如数据,函数等
  • binding: 本地/全局
  • section: 域, 即哪一个节,值为节头部表内的索引。有三个特殊的伪节在节头部表内无表目: ABS(不该被重定位的符号), UNDEF(未定义的符号,如在其他地方定义由本模块引用的符号), COMMON(未初始化的数据目标,value给出对齐请求,size给出最小大小)

可使用 readelf -s 指令查看符号表信息,示例:

readelf指令读取符号表(CSAPP)

其中Ndx=1表示.text节(函数名),Ndx=3表示.data节(变量名)

三. 符号解析

符号解析指链接器对符号引用的解析过程,即为符号引用与符号定义建立关联。当编译器编译过程中遇到未定义符号时将会默认其为外部定义符号,将会生成一个符号表表目(UNDEF), 并等待链接器处理,当链接器在所有输入模块中都找不到符号定义时将产生错误信息。

链接器处理重复定义的全局符号的方法:

编译器在编译过程中为每个全局符号赋予”强”或”弱”属性,由汇编器将其隐含地编码在可重定位目标文件的符号表中,其中函数和已初始化的全局变量为强符号未初始化的全局变量是弱符号。则链接器对于多文件输入下的重复符号定义处理规则如下:

  • 不允许有重复强符号
  • 如果有一个强符号和多个弱符号,选择强符号
  • 如果有多个弱符号,从弱符号中任意选择一个

若链接器处理过程中发现重复符号违反了上述规则(如两个文件均定义了main函数)将产生错误信息,否则将按照规则运行,但可能因此产生预料之外的结果(如定义的变量被其他文件内的代码所修改),因此可在链接过程中添加–warn-common选项让链接器遇到重复定义的全局符号时输出警告信息

四.与静态库链接

静态库指编译系统提供的一种打包机制,能够将所有相关的目标模块打包为一个单独的文件,它也可以用作链接器的输入。静态库以称为存档的特殊文件格式存放在磁盘中,存档文件是一组连接起来的可重定位目标文件集合,有一个头部描述每个成员目标文件的大小和位置。Unix下可以使用ar指令创建库。

静态库的优点

在不使用静态库的情况下向用户提供printf等标准函数的方法:
1.编译器辨认出对标准函数的调用并直接生成相应的代码。缺点:每次对标准函数的修改都需要一个新的编译器版本
2.将所有标准函数都放在一个单独的可重定位目标模块中。缺点:系统中每个可执行文件都会包含一份标准函数集合的完全拷贝,对标准函数的改变将要求标准函数开发人员重新编译整个源文件,用户需要显示链接合适的目标模块到可执行文件中

使用静态库的情况下向用户提供标准函数的优点:
1.链接时链接器只拷贝被程序引用的目标模块,这就减少了可执行文件在磁盘额存储器中的大小。
2.用户只需要包含较少的库文件的名字

链接器使用静态库解析引用的过程:

链接器维护三个集合E,U,D:
E: 可重定位目标文件集合
U: 未解析的符号集合
D: 已定义的符号集合

  • 对于命令行上的每个输入文件f,若f为目标文件,链接器将把f添加到E,修改U和D来反映f中的符号定义和引用
  • 若f为存档文件,链接器将尝试匹配U中的符号与f中的文件成员定义的符号。若某个文件成员m定义的一个符号能够解析U中的一个引用,则m加入E中,链接器修改U和D来反映m中的符号定义和引用,重复此过程直到U和D不再变化,则丢弃f中不包含在E中的成员文件
  • 链接器完成命令行上输入文件的扫描后,若U非空则链接器输出错误信息并终止,否则它将合并和重定位E中的目标文件,从而构建可执行文件

由于链接器根据命令行上的输入文件序列顺序遍历,因此命令行上的文件顺序非常重要,若需要满足依赖需求,可以在命令行上重复库。

静态库链接过程(CSAPP)

五. 重定位

重定位在链接器完成符号解析后进行,将合并输入模块,并为每个符号分配运行时地址,重定位分为两步:

  • 重定位节和符号定义。链接器合并相同类型的节为m(如将输入模块的所有.data节合并为输出的可执行目标文件的.data节),再将运行时存储器地址赋给m,即输入模块定义的每个节以及输入模块定义的每个符号。这一步完成后程序中的每个指令和全局变量都有唯一的运行时存储器地址了
  • 重定位节中的符号引用。链接器修改.text,.data中对每个符号的引用,使得他们指向正确的运行时地址。为了执行这一步,链接器依赖于称为重定位表目的可重定位目标模块中的数据结构(.relo.text, .relo.data)
重定位表目数据结构(CSAPP)
  • offset: 需要被修改的引用的节内偏移
  • symbol: 被修改的引用应该指向的符号
  • type: 寻址方式(直接寻址,间接寻址等)

重定位算法:遍历每个节中的重定位表目,按照寻址方式为每个符号引用寻址

六. 加载可执行目标文件

将程序拷贝到存储器并运行的过程叫做加载。在Unix中每个程序都有一个运行时存储器映像:

Unix程序运行时存储器映像(CSAPP)

只读段即代码段,读/写段即数据段,内核虚拟存储器存放系统内核的代码与数据。当加载器运行时创建存储器映像,将可执行文件拷贝到代码和数据段。

七. 动态链接共享库

静态库的缺点:

  1. 若库更新则用户必须显式地将程序与新的库重新链接
  2. 当有多个进程同时需要使用标准库时这些函数的代码会被复制到每个运行进程的文本段中,造成存储器系统资源的浪费

共享库也称为共享目标,在运行时可以加载到任意的存储器地址,并在存储器中和一个程序链接起来,这个过程称为动态链接,由动态链接器执行

共享库的“共享”在于两方面:

  1. 所有引用共享库的可执行目标文件共享库文件中的代码和数据,而不是像静态库的内容那样被拷贝和嵌入到引用他们的可执行文件中
  2. 在存储器中一个共享库的.text节只有一个副本可以被不同的正在运行的进程共享

使用指令 gcc -shared -fPIC 构造共享库,-fPIC选项指示编译器生成与位置无关的代码,-shared选项指示链接器创建一个共享的目标文件。

在使用共享库的情况下,当创建可执行文件时静态执行一些链接,此时没有任何共享库的代码和数据节被拷贝到可执行文件中,链接器仅拷贝了一些重定位和符号表信息,使得运行时可以解析共享库中代码和数据的引用。动态链接在程序加载时完成,当加载器加载和运行可执行文件时它会注意到它包含一个.interp节,这个节包含动态链接器的路径名,而动态链接器本身就算一个共享目标(.so文件),加载器此时不将控制传递给应用,而是加载和运行这个动态链接器,动态链接器接着通过执行下面的重定位完成链接任务:

  • 重定位共享库的文本和数据到某个存储器段
  • 重定位可执行目标文件中所有对共享库中定义的符号的引用

最后动态链接器将控制传递给应用程序,从此共享库的位置固定,且在程序执行过程中都不会改变。

用共享库动态链接(CSAPP)

利用共享库,应用程序可以在运行时要求动态链接器加载和链接任意共享库,且无需在编译时链接那些库到应用中,比如Linux系统为动态链接器提供简单的接口,允许应用程序在运行时加载和链接共享库。

八. 与位置无关的代码(PIC)

由于共享库的目的在于允许多个正在运行的进程共享存储器中相同的库代码,因此一种方法是为每个共享库预先分配地址空间,但这种方式对地址空间的使用效率不高且难以管理。更好的方法是编译库代码,这种代码即为与位置无关的代码(PIC)

PIC数据引用:

编译器在数据段开始处创建一个全局偏移量表(GOT), GOT包含每个被这个目标模块引用的全局数据目标的表目,在加载时动态链接器会重定位GOT中的每个表目,使得包含正确的绝对地址,每个引用全局数据的目标模块都有一张自己的GOT。则数据引用时采用如下代码形式:

PIC数据引用(CSAPP)

call L1将L1地址压入栈中,通过pop1指令将地址放入%ebx中,则此时%ebx内存放PC值。add1指令使得%ebx指向GOT中相应的表目,再利用两次mov指令将全局遍历的内容放入%eax中

PIC函数调用:

函数调用可以采用与数据引用相同的方式进行,但这样每次调用都需要三条额外的指令,因此ELF编译系统采用延迟绑定技术,延迟绑定依赖于GOT与PLT(过程链接表)数据结构进行(其中GOT是.data的一部分,PLT是.text的一部分)。

以一次全局函数调用为例:

GOT表目示例(CSAPP)
PLT表目示例(CSAPP)

当addvec函数第一次被调用时程序将跳转至PLT[2]执行第一条指令,由于GOT[4]内默认存放的地址为下一条指令,因此继续执行push1指令将符号ID压入栈中,下一条指令跳转至PLT[0],将另外一个标志信息的字压入栈中,再通过GOT[2]跳转至动态链接器中,由动态链接器确定addvec的位置,并用这个地址覆盖GOT[4],再将控制传递给addvec。则下一次调用addvec函数时仅执行跳转指令即可。

Related post

  1. 常用工具指令

    2022-09-18

  2. Java 多线程

    2020-09-26

  3. Windows下ARM编程实验

    2020-11-29

  4. Emacs for Java

    2023-01-16

There are no comment yet.

COMMENT

Take a Coffee Break

Recommend post

  1. 常用工具指令

    2022-09-18

Category list

ABOUT

Welcome to FullStar, a captivating online destination where the realms of software development and personal reflections intertwine.

April 2025
M T W T F S S
 123456
78910111213
14151617181920
21222324252627
282930  

Life Logs

  1. 回首

    2023-07-14

Return Top