跳转至

链接

链接(linking) 是将各种代码和数据片段收集并组合成为一个单一文件的过程, 这个文件可被加载(复制)到内存并执行.
链接可被执行于三个阶段:

  • 编译时: 在源代码翻译成机器代码时的传统静态链接.
  • 加载时: 在程序被加载器加载到内存并执行时的动态链接.
  • 运行时: 由应用程序来执行的动态链接.

链接是由链接器(linker) 程序自动执行, 链接的存在使得分离编译成为可能, 一个大型应用程序可以分解成若干个小的模块, 只需要对这些模块进行修改编译, 然后通过链接器将其组合成大的可执行文件就行.

基本概念

编译器驱动程序

大多数编译系统提供编译器驱动程序(compiler driver), 它代表用户在需要时调用语言处理器, 编译器, 汇编器和链接器.

此时我们输入 linux> gcc -Og -o prog main.c sum.c

GCC驱动程序

  • 首先运行 C 预处理器(cpp), 将 C 的源程序 main.c 翻译成一个 ASCII 码的中间文件 main.i
    cpp [other argument] main.c /tmp/main.i
  • 然后运行 C 编译器(ccl), 将 main.i 翻译成一个 ASCII 汇编语言文件 main.s ccl /tmp/main.i -Og [other argument] -o /tmp/main.s
  • 运行汇编器(as), 它将 main.s 翻译成一个可重定位目标文件 main.o as [other argument] -o /tmp/main.o /tmp/main.s
  • 对 sum.c 经过相同的过程生成 sum.o
  • 运行链接器程序 ld, 将 main.o 和 sum.o 以及一些必要的系统目标文件组合起来, 创建一个可执行目标文件 prog. ld -o prog [system object files and args] /tmp/main.o /tmp/sum.o
  • shell 调用操作系统中的加载器, 将可执行文件 prog 中的代码和数据复制到内存, 然后将控制转移到这个程序的开头.
    linux> ./prog

目标文件

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

编译器和汇编器生成可重定位目标文件(包括共享目标文件), 链接器生成可执行目标文件. 一个目标模块就是一个字节序列, 而一个目标文件是一个以文件形式存放在磁盘的目标模块.

现代 x86-64 Linux 和 Unix 系统使用可执行可链接格式(Executable and Linkable Format, ELF).

可重定位目标文件

  • ELF 头(header)以一个 16 字节的序列开始, 这个序列描述了生成该文件的系统的字的大小和字节顺序. 还包括了帮助链接器语法分析和解释目标文件的信息, 如 ELF 头的大小, 目标文件的类型, 机器类型(如 x86-64), 节头部表的文件偏移, 节头部表中条目的大小和数量.

    • 节头部表(section header table): 描述了不同节的位置和大小, 目标文件中每个节都有一个固定大小的条目在表中.
    • .text: 已编译程序的机器代码
    • .rodata: 只读数据. 如跳转表, printf 中的格式串.
    • .data: 已初始化的全局和静态 C 变量.
    • .bss: 未初始化的全局和静态 C 变量, 以及所有被初始化为 0 的全局或静态变量.
    • 目标文件中这个节不占据实际空间, 仅是一个占位符.
    • 区分已初始化和未初始化变量, 是因为在目标文件中, 未初始化变量不需要占据任何实际的磁盘空间, 运行时再在内存中分配这些变量, 初始值为0.
    • 局部 C 变量在运行时只保存在栈中, 不出现在.data.bss中.
  • .symtab: 一个符号表, 它存放在程序中定义和引用的函数和全局变量的信息. 每个可重定位目标文件在.symtab都有一张符号表. 不包含局部变量的条目.

  • .rel.text: .text节的重定位信息, 可执行目标文件中需要修改的指令地址
  • .rel.data: .data节的重定位信息, 合并后的可执行目标文件中需要修改的指针数据的地址

    • 一般已初始化的全局变量, 如果初始值是一个全局变量地址或外部定义函数的地址, 就需要被修改.
    • 可执行目标文件已完成重定位, 就不需要.rel.text.rel.data数据节.
  • .debug: 调试符号表, 条目是程序中定义的局部变量和类型定义, 程序中定义的和引用的全局变量, 以及原始的 C 源文件.

  • .line: 原始 C 源程序中的行号和.text节中机器指令之间的映射. 注意: 只有以 -g 选项调用编译器驱动程序,才会出现.debug.line
  • .strtab: 字符串表, 包括.symtab.debug节中的符号表, 以及节头部中的节名字

可执行目标文件

  • ELF 头描述文件的总体格式, 还包括程序的入口点(entry point), 即程序执行时的第一条指令的地址.
  • .init节定义了一个小函数_init, 程序的初始化代码会调用它.
  • 因为可执行文件是完全链接的(已被重定位), 因此它不需要 rel 节.

程序头部表描述了可执行文件连续的片被映射到连续的内存段的映射关系.

  • off 是目标文件中的偏移, 表示要从目标文件的什么位置开始读取该段
  • vaddr/paddr 是内存地址, 表示要将该段加载到的内存地址
  • align 是对齐要求
  • filesz 是目标文件中的段大小, 则通过 off 和 filesz 就能确定我们要加载的段的内容
  • memsz 是内存中的段大小, 表示我们将目标文件中的该段加载到多大的内存空间中
  • flags 表示该段运行时的访问权限

对任何段 s, 链接器必须选择一个起始地址 vaddr, 使得 vaddr mod aligh = off mod align.
这种对齐是一种优化, 使得当程序执行时, 目标文件中的段能有效率地传送到内存中.

符号和符号表

每个可重定位目标模块 m 都有一个符号表. 它包含 m 定义和引用的符号的音系.

  • 全局链接器符号: 由模块 m 定义并能被其他模块引用的全局符号. 对应非静态的 C 函数和全局变量.
  • 外部符号: 由其他模块定义并被 m 模块引用的全局符号. 对应其他模块中定义的非静态的 C 函数和全局变量.
  • 局部符号: 只被模块 m 定义和引用. 对应带 static 属性的 C 函数和全局变量.

Note

局部静态变量不在栈中管理, 因此需要编译器在.data.bss中为每个定义分配空间, 并在符号表中创建一个有唯一名字的本地链接器符号.

.symtab 中每个条目的格式:

  • name: 符号的名字, 是在字符串表.strtab中的字节偏移.
  • value: 符号的地址.
    对可重定位目标文件来说, value 是距定义目标的节的起始位置的偏移(如函数被定义在.text中, value 就是定义的位置距离此节中的偏移);
    对可执行目标文件来说, value 是绝对运行时的地址.
  • size: 目标的大小(以字节为单位)
  • type: 说明该符号的类型, 是函数, 变量还是数据节等等.
  • binding: 说明该符号是本地的还是局部的.
  • section: 说明该符号保存在目标文件的哪个节中, 也是节头部表中的一个索引(偏移量) 可重定位目标文件中有三个无法通过节头部表索引的数据节, 称为伪节(pseudosection).(可执行目标文件中没有)
    • ABS: 代表不该被重定位的符号
    • UNDEF: 代表未定义的符号, 也就是在本目标模块中引用却在其他地方定义的符号
    • COMMON: 代表还未被分配位置的未初始化的数据目标.(如未被初始化的全局变量) 此时 value 给出对齐要求, size 给出最小大小.

静态链接

Linux LD 程序这样的静态链接器以一组可重定位目标文件和命令行参数作为输入, 生成一个完全链接的, 可以加载和运行的可执行目标文件作为输出.
为了构造可执行文件, 链接器必须完成两个主要任务:

  • 符号解析(symbol resolution): 目标文件定义和引用符号, 每个符号对应一个函数, 一个全局变量或一个静态变量. 符号解析的目的是将每个符号引用正好和一个符号定义关联起来.
  • 重定位(relocation): 编译器和汇编器生成从地址 0 开始的代码和数据节. 连接器通过把每个符号定义与一个内存位置关联起来, 从而重定位这些节, 然后修改所有对这些符号的引用, 使得它们指向这个内存位置.

符号解析

链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中一个确定的符号定义关联起来.

  • 对局部符号, 引用和定义都在相同模块中, 符号解析是简单明了的. 编译器只允许每个可重定位目标文件中每个局部符号只有一个定义. 而局部静态变量(根据定义不属于局部符号)也会有局部链接器符号, 所以编译器还要确保它有一个唯一的名字.
  • 对全局或外部符号, 编译器可能会碰到不在当前文件中定义的符号,则会假设该符号是在别的文件中定义的,就会在重定位表中产生该符号的条目,让链接器去解决. 而链接器可能还会碰到在多个可重定位目标文件中定义相同名字的全局符号,也要解决这些冲突.

解析多重定义的全局符号

在编译时, 编译器会向汇编器输出每个全局符号是还是的信息, 汇编器将这个信息隐含地编码在可重定位目标文件的符号表里.
函数和已初始化的全局变量是强符号, 未初始化的全局变量是弱符号.
规则:

  • 不允许有多个同名的强符号.
  • 如果有一个强符号与多个弱符号同名, 那么选择强符号.
  • 如果有多个弱符号同名, 那么选择任意一个.

当编译器遇到一个弱全局符号时, 它无法确定其他模块是否有定义, 也无法确定链接器会采用哪个定义, 所以编译器把未初始化的全局符号放在common表中, 让链接器去决定. 而已经被初始化 0 的全局变量, 他必然是唯一的, 编译器可以自信地把他分配给.bss.
静态符号也是唯一的, 编译器可以直接将其放到.data.bss中.(取决于是否被初始化以及是否被初始化为0)
需要注意的是, 同名符号的数据类型可以是不同的, 因此规则 23 可能带来意想不到的错误.

与静态库链接

编译系统提供一种机制, 将所有相关的目标模块打包成一个单独的文件, 称为静态库(static library). 它可以用作链接器的输入. 当链接器构造一个输出的可执行文件时, 它只复制静态库里被应用程序引用的目标模块, 这减少了可执行文件在磁盘和内存中的大小.
在 Linux 系统中, 静态库以一种存档(archive) 的特殊文件格式存放在磁盘中. 存档文件是一组连接起来的可重定位目标文件的集合, 有一个头部来描述每个成员目标文件的大小和位置. 存档文件铭由后缀 .a 标识.

创建静态库

要把这些函数创建为一个静态库, 我们使用 AR 工具.

linux> gcc -c addvec.c multvec.c
linux> ar rcs libvector.a addvec.o multvec.o
由此我们得到了静态库 libvector.a

与静态库链接

// main2.c
#include <stdio.h>
#include "vector.h"

int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];

int main()
{
    addvec(x, y, z, 2);
    printf("z = [%d %d]\n", z[0], z[1]);
    return 0;
}
为了创建这个可执行文件, 我们要编译和链接输入文件 main2.o 和 libvector.a
linux> gcc -c main2.c
linux> gcc -static -o prog2c main2.o ./libvector.a
或者第二行也可gcc -static -o prog2c main.o -L. -lvector 这里的 -lvector 是 libvector.a 的缩写, -L. 告诉链接器在当前目录中查找 libvector.a 静态库.

使用静态库解析引用

符号解析阶段, 链接器从左到右按照它们在编译器驱动程序命令行上出现的顺序扫描可重定位目标文件和存档文件.(驱动程序自动将 .c 翻译为 .o 文件) 扫描中链接器维护一个可重定位目标文件的集合 E(这个集合中的文件会被合并起来形成可执行文件), 一个未解析的符号(即引用了但是尚未定义的符号)集合 U, 以及一个在前面输入文件中已经定义的符号集合 D. 初始时, E D U 均为空.

  • 对于命令行上每个输入文件 f, 链接器会判断 f 是一个目标文件还是一个存档文件.
    • 目标文件: 把 f 添加到 E, 修改 U 和 D 来反映 f 中的符号定义和使用.
    • 存档文件: 链接器尝试匹配 U 中未解析的符号和由存档文件成员定义的符号. 如果某个存档文件成员 m, 定义了一个符号来解析 U 中的引用(即 m 定义了一个 U 中的符号), 那么就将 m 加入到 E 中, 并且链接器修改 U 和 D 来反映 m 中的符号定义和引用. 其他不包含在 E 中的成员将被抛弃.
  • 如果当链接器完成对文件的扫描后, U 是非空的, 那么链接器就会输出一个错误并终止. 否则它会合并和重定位 E 中的目标文件, 构建输出的可执行文件.

需要注意的是, 如果定义一个符号的库出现在引用这个符号的目标文件之前, 那么引用是不能被解析的, 链接会失败. 关于库的一般准则是把它们放在命令行的结尾. 如果库不是相互独立的, 需要对他们排序.

重定位

当链接器完成了符号解析, 就要开始重定位. 这个步骤中, 将合并输入模块并为每个符号分配运行时地址.

  • 重定位节和符号定义 链接器将所有相同类型的节合并为同一类型的聚合节. 例如来自所有输入模块的.data节被全部合并为一个节, 这个节成为输出的可执行目标文件的.data节. 然后链接器将运行时内存地址赋给新的聚合节, 赋给输入模块定义的每个节, 以及输入模块定义的每个符号.
  • 重定位节中的符号引用 链接器修改代码节和数据节中对每个符号的引用, 使其指向正确的运行时位置.

重定位条目

无论何时, 汇编器遇到对最终位置未知的目标引用, 它就会生成一个重定位条目, 告诉链接器在将目标文件合并成可执行文件时如何修改这个引用. 代码的重定位条目放在.rel.text中, 已初始化的数据的重定位条目放在.rel.data中.

  • offset: 需要被修改的引用的节偏移
  • symbol: 被修改引用的符号
  • type: 告知链接器如何修改新的引用(相对/绝对寻址)
  • addend: 一个有符号常数, 一些类型的重定位需要使用它对修改引用的值做偏移调整.

ELF 定义了两种基本的重定位类型:

  • R_X86_64_PC32: 使用 32 位 PC 相对地址的引用.(注意一般 PC 是下一条指令在内存中的地址)
  • R_X86_64_32: 使用 32 位绝对地址的引用.

重定位符号引用

int sum(int *a, int n)      //defined in sum.c

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

int main()
{
    int val = sum(array, 2);
    return val;
}
其反汇编代码为:

  • 重定位 PC 相对引用

    该重定位条目主要用来产生 32 位PC相对地址的引用, 即函数调用时的重定位.

    在上面的例子中, main 调用函数 sum, 但 sum 函数是在 sum.o 中定义. (起初 call 后面的数字是占位符)
    我们可以得到其重定位条目:

    r.offset = 0xf
    r.symbol = sum
    r.type = R_X86_64_PC32
    r.addend = -4
    
    • call 指令开始于节偏移 0xe 的地方, 同时 call 本身的操作码 0xe8 占了一个字节, 因此 sum 的 offset 为 0xf.
    • addend是因为, 当我们在执行这条指令时, PC 已经指向下一条指令(0x13), 需要通过这个偏移量来调整.

    我们假设

    ADDR(s) = ADDR(.text) = 0x4004d0
    refaddr = ADDR(s) + r.offset
            = 0x4004d0 + 0xf
            = 0x4004df
    
    那么可以计算得到
    refptr = s + r.offset //占位符的指针
    ADDR(r.symbol) = ADDR(sum) = 0x4004e8
    *refptr = (unsigned)(ADDR(s.symbol) + r.addend - refaddr)
            = (unsigned)(0x4004e8 + (-4) - 0x4004df)
            = (unsigned) 0x5
    

    于是我们有 4004de: e8 05 00 00 00 callq 4004e8 <sum>

  • 重定位绝对引用

    该重定位条目主要用来产生 32 位绝对地址的引用, 即数组的重定位.

    在上面的例子中, 对 array 的引用就使用绝对引用.

    r.offset = 0xa
    r.symbol = array
    r.type = R_X86_64_32
    r.addend = 0
    
    于是
    refptr = s + r.offset //占位符的指针
    *refptr = (unsigned)(ADDR(r.symbol) + r.addend)
            = (unsigned) 0x601018 
    
    最后得到 4004d9: bf 18 10 60 00 mov $0x601018, %edi

加载可执行目标文件

当得到可执行目标文件 prog 后, 输入linux> ./prog
因为 prog 不是一个内置的 shell 命令, 所以 shell 会认为 prog 是一个可执行目标文件, 通过调用某个驻留在存储器中被称为加载器的操作系统代码来运行它.
加载器将可执行目标文件中的代码和数据从磁盘复制到内存中, 然后通过跳转到程序第一条指令或入口点来运行该程序. 这个将程序复制到内存并运行的过程叫加载.

当加载器运行时, 它创建类似下图的内存映像.

  • 代码段和数据段: x86-64 通常将代码段保存在 0x400000 处, 所以会将可执行目标文件的代码段和数据段映射为如上形式. 注意: 这里数据段为了满足对齐要求, 会和代码段之间存在间隙.
  • 运行时堆: 在数据段之后会有一个运行时堆,是通过调用 malloc 库动态往上增长的
  • 共享库: 在堆之后是一个共享库的内存映射区域
  • 用户栈: 用户栈是从最大的合法用户地址开始, 向较小的地址增长
  • 内核: 最上方的是位内核中的数据和代码保留的, 是操作系统驻留在内存的位置

共享库

静态库和所有的软件一样, 需要定期维护和更新. 此外几乎每个 C 程序都会使用标准 I/O 函数, 在运行时这些函数被赋值到每个运行进程的文本段里, 这对内存是极大的浪费.
共享库(shared library) 是一个目标模块, 在运行或加载时, 可以加载到任意的内存地址, 并和一个在内存中的程序链接起来. 这个过程称为动态链接, 是由动态链接器执行. 共享库也被称为共享目标, linux 系统中通常用 .so 后缀来表示.
动态链接器本身就是一个共享目标, Linux 中为 ld-linux.so
共享库中的"共享"有两种含义:

  • 在任意文件系统中, 对于一个库只有一个 .so 文件. 所有引用该库的可执行目标文件共享这个 .so 文件中的代码和数据, 而不是像静态库那样被复制和嵌入到引用它们的可执行文件中去.
  • 在内存中, 一个共享库的.text节的一个副本可以被不同的正在进行的进程共享.

加载时动态链接

我们可以这样生成共享库:
linux> gcc -shared -fpic -o libvector.so addvec.c multvec.c
其中-fpic选项指示编译器生成与位置无关的代码, -share选项指示链接器创建一个共享的目标文件.
随后我们可以通过下面的方式运用共享库:
linux> gcc -o prog21 main2.c ./libvector.so
由此创建了可执行文件 prog21.

  • 在创建可执行目标文件时, 链接器会复制共享库中的重定位.rel和符号表.symtab信息, 使得运行时可以解析对共享库中代码和数据的引用,由此得到部分链接的可执行目标文件.
    注意: 此时没有将共享库的代码和数据节复制到可执行文件中.
  • 调用加载器加载部分链接的可执行目标文件时, 加载器会在段头部表的引导下, 将可执行文件中的数据段和代码段复制到对应的内存位置.
  • 加载器可以在 prog2l 中发现.interp节,其中保存了动态链接器的路径,则加载器会加载和运行这个动态链接器
  • 动态链接器会将不同的共享库的代码和数据保存到不同的内存段中
  • 动态链接器还会根据共享库在内存的位置, 来重定位 prog2l 中所有对共享库定义的符号的引用
  • 最后加载器将控制权传递给应用程序, 此时共享库的位置就固定了, 并在程序执行的过程中不会改变.

运行时动态链接

#include <dlfcn.h>
void *dlopen(const char *filename, int flag);

dlopen 函数加载和链接共享库 filename, 并返回句柄指针. 参数 flag 可用于确定共享库的解析方式以及作用范围:

  • RTLD_NOW: 链接器立即解析对外部符号的引用
  • RTLD_LAZY: 链接器推迟符号解析直到执行来自库中的代码
  • RTLD_GLOBAL: 共享库中定义的符号可被其后打开的其他库用于符号解析
  • RTLD_LOCAL: 共享库中定义的符号不能被其后打开的其他库用于重定位, 是默认的
#include <dlfcn.h>
void *dlsym(void *handle, char *symbol);

dlsym 函数返回之前打开的共享库的句柄中symbol指定的符号的地址.

#include <dlfcn.h>
void dlclose(void *handle); 

用来关闭打开的共享库句柄

#include <dlfcn.h>
const char *dlerror(void);

如果 dlopen, dlsym 或 dlclose 函数发生错误, 就返回字符串.

Example

该程序就会在运行时动态链接共享库 libvector.so, 然后调用 addvec 函数.
linux> gcc -rdynamic -o prog2r dll.c -ldl
我们可以用这样的方式编译. 其中-rdynamic通知链接器将全部符号加入到动态符号表中, 就可以通过使用 dlopen 来实现向后跟踪, -ldl表示程序运行时会动态加载共享库.

位置无关代码

当链接器产生可执行目标文件时,已为目标文件中的数据节和符号分配好了内存地址,如果可执行目标文件有引用共享库中的符号时,就需要假设共享库符号的地址。较早存在静态共享库(Static Shared Library) 方法,即操作系统会在某个特定的地址中划分一部分,为已知的共享库预留空间,则共享库会被加载到对应的地址空间中,而可执行目标文件就可以在对应的地址空间中找到想要的共享库。但是该方法会造成地址冲突,并造成地址空间的浪费,以及维护的困难.

所以就想能否将共享库加载到任意的内存位置,还能使得可执行目标文件能找到。类似于使用静态库时,链接器会根据重定位表和分配好的内存地址来替换编译时未知的地址,这里可以使用加载时重定位(Load Time Relocation) 方法,由于编译、汇编和链接时对共享库在内存的位置是未知的,所以可执行目标文件对共享库的符号的引用也用占位符代替,当加载器加载可执行目标文件进行加载时,会调用动态链接加载器将共享库加载到内存中,此时就能根据共享库被加载的内存地址,对可执行目标文件中的占位符进行重定位。但是该方法会对共享库中的指令进行修改,由于指令被重定位后对于每个进程是不同的,所以该共享库无法在多个进程中共享。但是共享库中的数据部分在多个进程中是有自己备份的,所以可以通过该方法来解决.

我们的目的其实就是希望共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为地址无关代码(PC, Position-independent Code) 的技术.

PIC 数据引用

当你在代码中调用共享库中的函数或全局变量时,编译器、汇编器以及链接器并不知道该函数和全局变量在内存中的位置,只有当可执行目标文件加载时或运行时动态链接共享库,动态链接器将共享库加载到内存时,才知道共享库中的地址.

但是可执行目标文件中的代码段是不可写的,所以无法通过动态链接器对可执行目标文件的代码段进行修改,使其指向共享库的函数或变量的地址;其次,比如动态库A的函数调用了动态库glibc.so中定义的printf函数时,只有在动态链接器加载了glibc.so时,动态库A才能知道printf函数所在的内存地址,但是我们也不能对动态库A的代码段进行修改,否则动态库A就无法在各个进程中共享了.

我们在数据段的开始的地方创建了全局偏移量表(Global Offset Table, GOT). 在 GOT 中, 每个被这个目标模块引用的全局数据目标(过程或全局变量)都会有一个 8 字节条目. 编译器还为其中每个条目都生成了一个重定位记录, 在加载时重定位 GOT 中每个条目, 使得它包含目标的正确的绝对地址.

GOT 的设计利用了一个事实: 数据段与代码段的距离总是不变的. 因此代码段中的任意指令呵数据段中任何变量之间的距离都是一个运行时常量.

Example

20220827102734

PIC 函数调用

假设程序调用一个由共享库定义的函数, 编译器没法正确预测这个函数的运行时地址. 于是它生成一条重定位记录, 然后动态链接器在程序加载时再解析它. GNU 编译系统使用了延迟绑定(lazy bindng), 将过程地址的绑定延迟到了第一次调用该过程的时候.

我们采用 GOT 和过程链接表(Procedure Linkage Table, PLT) 的方法, 如果一个目标模块调用定义在共享库中的任何函数, 那么它就有自己的 GOT 和 PLT.

  • PLT: PLT 是一个数组, 其中每个条目都是 16 字节代码. PLT[0] 是一个特殊条目, 它跳转到动态链接器中. PLT[1] 调用系统启动函数初始化执行环境, 其他条目调用用户代码调用的函数.
  • GOT: GOT 是一个数组, 每个条目都是 8 字节地址. GOT[0] 和 [1] 是包含动态链接器在解析函数地址时会使用的信息, GOT[2] 是动态链接器在 ld-linux.so 模块中的入口, 其余每个条目对应一个被调用的函数.

Example

20220827103542

库打桩

库打桩(library interpositioning), 允许截获对共享库函数的调用, 取而代之执行自己的代码. 使用打桩机制, 我们可以追踪对某个特殊库函数的调用次数, 验证和追踪它的输入和输出值, 或者甚至把它替换成一个完全不同的实现.

基本思想: 给定一个需要打桩的目标函数, 创建一个包装函数, 它的原型与目标函数完全一样. 使用某种特殊打桩机制, 你就可以欺骗系统调用包装函数而不是目标函数了.

编译时打桩

20220827105317

我们这样编译和链接:

linux> gcc -DCOMPILETIME -c mymalloc.c
linux> gcc -I. -o intc int.c mymalloc.c
由于有 -I. 参数, 所以会进行打桩. 它告诉 C 预处理器在搜索通常的系统目录之前先在当前目录查找 malloc.h.

链接时打桩

Linux 静态链接器支持用 --wrap f 标志进行链接时打桩. 这个符号告诉链接器把符号 f 的引用解析成 __wrap_f(前缀两个下划线), 还要把对符号 __real_f 的引用解析为 f.

20220827111742

用下述方法编译:

linux> gcc -DLINKTIME -c mymalloc.c
linux> gcc -c int.c
然后链接:
linux> gcc -W1,--wrap,malloc -W1,--wrap,free -o int1 int.o mymalloc.i -W1,option 标志把 option 传递给链接器. option 中每个都要替换为一个空格, 因此-W1,--wrap,malloc就把--wrap malloc传递给链接器.

运行时打桩

如果 LD_PRELOAD 环境变量被设置为一个共享库路径名的列表(以空格或分号分隔), 那么当你加载和执行一个程序需要解析未定义的引用时, 动态链接器就会先搜索 LD_PRELOAD 共享库, 然后才搜索任何其他的库.

20220827112607

编译:

linux> gcc -DRUNTIME -shared -fpic -o mymalloc.so mymalloc.c -ldl
linux> gcc -o intr int.c
当运行时, linux> LD_PRELOAD="./mymalloc.so" ./intr 就实现了打桩.


最后更新: 2022年9月5日 23:38:05
创建日期: 2022年9月5日 23:38:05

评论