这次回顾深入理解计算机系统第7章 ,这一章介绍了链接。

电子书地址:

http://eol.bnuz.edu.cn/meol/common/script/preview/download_preview.jsp?fileid=2169600&resid=242120&lid=28605

备注:图片和总结内容均来自于电子书。

第7章:链接

重要概念提要

  • 链接:
    • 概念以及作用;
    • 执行时间:
      • 编译时;
      • 加载时;
      • 运行时;
  • gcc编译链接的过程:
    • 编译器驱动程序;
    • C预处理器;
    • C编译器;
    • 汇编器;
    • 链接器;
  • 静态链接:
    • 符号解析;
    • 重定位;
  • 目标文件:
    • 可重定位目标文件;
    • 可执行目标文件;
    • 共享目标文件;
  • EFL文件格式:
    • .text
    • .data
    • .bss
    • COMMON
  • 符号和符号表:
    • 全局符号;
      • 强符号和弱符号;
    • 外部符号;
    • 局部符号(static);
  • 几种文件格式:
    • 静态库(.a):将常用的函数的可执行目标文件打包为存档的形式。
    • 可重定位目标文件(.o);
    • 可执行目标文件(.out);
    • 共享库(.so, .dll);
  • 库打桩机制:
    • 截获对共享库的调用,取而代之执行自己的代码;

简介

  • 链接:
    • 将各种代码和数据片段收集并组合成为一个单一文件的过程,该文件被加载(复制)到内存并执行。
  • 执行时间:
    • 编译时:源代码被翻译时;
    • 加载时:被加载器加载到内存并执行时;
    • 运行时:由程序自动执行;
  • 作用:
    • 分离编译,将大型程序分解为更好管理的小模块。

7.1 编译器驱动程序

概念

  • 编译器驱动程序
  • C预处理器
  • C编译器
  • 汇编器
  • 链接器

例子

通过示例理解上述概念。

main.c:

/* main.c */
/* $begin main */
int sum(int *a, int n);

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

int main() 
{
    int val = sum(array, 2);
    return val;
}
/* $end main */

sum.c

/* sum.c */
/* $begin sum */
int sum(int *a, int n)
{
    int i, s = 0;
    
    for (i = 0; i < n; i++) { 
        s += a[i];
    }
    return s;
}        
/* $end sum */

linux中编译该程序的方法是:

gcc -Og -o prog main.c sum.c

这里gcc就是编译驱动程序。

编译驱动程序实际上调用如下程序:

  • C预处理器
  • C编译器
  • 汇编器
  • 链接器
  • 加载器

整个流程如下:

第一步通过C预处理器(cpp)将C程序源文件翻译成ASCII码的中间文件.i,命令如下:

cpp [other arguments] main.c /tmp/main.i

第二步通过C编译器(cc1)将中间文件翻译为ASCII汇编文件.s,代码如下:

cc1 /tmp/main.i -Og [other argument] -o /tmp/main.s

第三步通过编译器(as)将.s文件翻译为一个可重定位目标文件.o,代码如下:

as [other argument] -o /tmp/main.o /tmp/main.s

第四步利用链接器(ld)将.o文件以及一些必要的系统文件组合起来创建可执行文件:

ld -o prog [system object files and args] /tmp/main.o /tmp/sum.o

最后一步调用加载器将可执行程序复制到内存,然后执行:

./prog

7.2 静态链接

可重定位目标文件的构成:

  1. 代码;
  2. 数据节:包含初始化的全局变量以及未初始化的变量等等;

静态链接的作用:

  1. 符号解析:关联符号引用和符号解析;
  2. 重定位:指定符号在内存中的位置;

7.3 目标文件

目标文件都包含二进制代码和数据,其三种形式为:

  • 可重定位目标文件。
    • 可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。
    • 由编译器和汇编器生成。
  • 可执行目标文件。
    • 可以被直接复制到内存并执行。
    • 由连接器生成。
  • 共享目标文件。
    • 特殊的可重定位目标文件,可以在加载或运行时被动态地加载进内存并连接。
    • 由编译器和汇编器生成。

各平台上目标文件的格式:

  • Windows
    • 可移植可执行格式(Portable Executable, PE)。
  • MacOS
    • Mach-O格式。
  • Linux
    • 可执行可链接格式(Executable and Linkable Format, ELF)。

7.4 可重定位目标文件

ELF可重定位目标文件格式:

作用介绍:

  • ELF头:
    • 以16字节的序列开始,描述了生成该文件系统的字的大小和字节顺序;
    • ELF头的大小;
    • 目标文件的类型;
    • 机器类型(例如x86-64);
    • 节头部表的文件偏移;
    • 节头部表中条目的大小和数量;
  • 节头部表:
    • 每个节的位置和大小;
  • .text:
    • 已编译程序的机器代码;
  • .rodata:
    • 只读数据,例如printf语句中的格式串等等;
  • .data:
    • 已初始化的全局变量和静态C变量;
  • .bss:
    • 未初始化的全局变量和静态C变量,以及所有被初始化为0的全局变量或C变量;
    • 在目标文件中不占据实际空间,仅仅是占位符;
    • 可以理解为Better Save Space;
  • .symtab:
    • 符号表,存放程序中定义和引用的函数和全局变量信息,不包含局部变量的信息;
  • .rel.text:
    • .text节中位置的列表,链接时可能需要修改某些位置,可执行目标文件中通常不存在;
  • .rel.data:
    • 被模块引用或定义的所有全局变量的重定位信息;
  • .debug:
    • 调试符号表,包含程序种局部,全局变量,以及C源文件;
  • .line:
    • C源程序中的行号和.text节中机器指令的映射关系;
  • .strtab:
    • 字符串表(即字符串序列),内容包括.symtab和.debug节中的符号表以及节头部中的节名字;

7.5 符号和符号表

符号

记当前可重定位目标模块为$m$。

符号类型:

  • 由模块$m$定义并能被其他模块引用的全局符号,对应于非静态的C函数和全局变量;
  • 由其他模块定义并被模块$m$引用的全局符号,也称为外部符号,对应于在其他模块中定义的非静态C函数和全局变量;
  • 只被模块$m$定义和引用的局部符号,对应于带static属性的C函数和全局变量,这些变量无法被其他模块引用;

static的含义:

  • C中,源文件扮演模块的角色,带有static属性声明的全局变量或者函数都是模块私有的。

符号表

符号表是条目的数组,每个条目的格式如下:

/* $begin elfsymbol */
typedef struct { 
    int   name;      /* String table offset */ 
    char  type:4,    /* Function or data (4 bits) */ 
	  binding:4; /* Local or global (4 bits) */ 
    char  reserved;  /* Unused */  
    short section;   /* Section header index */
    long  value;     /* Section offset or absolute address */ 
    long  size;      /* Object size in bytes */ 
} Elf64_Symbol; 
/* $end elfsymbol */

字段说明:

  • name:
    • 字符串表中的字节偏移,字符串名字的位置;
  • type:
    • 数据或函数;
  • binding:
    • 表示符号是本地的还是全局的;
  • reserved:
    • 未使用;
  • section:
    • 符号被分配到目标文件的具体节;
    • 节头部表的索引;
      • 有三个伪节在节头部表中没有条目(这些伪节只在可重定位目标文件中存在,可指定目标文件中不存在):
        • ABS:不该被重定位的符号;
        • UNDEF:未定义的符号(当前模块未定义,但是其他地方有定义);
        • COMMON:未被分配位置的未初始化的数据目标;
  • value:
    • 符号的地址;
    • 可重定位目标文件:距定义目标的节的起始位置偏移;
    • 可执行目标文件:绝对运行时地址;
  • size:
    • 目标的大小;

补充:

gcc中按照如下规定将可重定位目标文件中的符号分配到COMMON和.bss中:

  • COMMON:未初始化的全局变量;
  • .bss:未初始化的静态变量,以及初始化为$0$的全局或静态变量;

7.6 符号解析

全局符号的分类

全局符号是强或者是弱的,定义如下:

  • 强:函数和已初始化的全局变量;
  • 弱:未初始化的全局变量;

链接器处理全局符号的规则:

  • 不允许有多个同名的强符号;
  • 如果有一个强符号和多个弱符号同名,那么选择强符号;
  • 如果有多个弱符号同名,那么从这些弱符号中任意选择一个;

静态库

理念:

  • 将常用的函数的可执行目标文件打包为存档的形式(.a格式)。

优点:

  • 减少内存,不需要把全部函数链接。

生成静态库的方式:

gcc -c addvec.c  multvec.c 
ar rcs libvector.a addvec.o multvec.o

使用:

gcc -c main2.c
gcc -static -o prog2c main2.o ./libvector.a

gcc -c main2.c
gcc -static -o prog2c main2.o -L -lvector

其中-L表示在当前目录下查找,lvector是libvector.a的缩写。

完整流程:

使用静态库解析引用

使用静态库链接的一般形式如下:

gcc -static s.a ... f.c ...

静态库和C文件的放置顺序规则如下:

  • 如果库相互独立,则可以使用任意顺序;
  • 如果库不是相互独立,那么需要进行排序,使得每个被存档文件的成员外部引用的符号$s$,在命令行中至少有一个$s$的定义是在对$s$引用之后的;

7.7 重定位

在符号解析完成后,代码中每个符号引用正好和符号定义(即一个输入目标模块中的一个符号表条目)关联起来,后续链接器进行重定位,一共分为两步:

  • 重定位节和符号定义
    • 将相同类型的节合并为聚合节,例如.data节被合并为可执行目标文件中的.data节。
    • 将运行时内存地址赋给新的聚合节,输入模块定义的每个节以及输入模块定义的每个符号。
  • 重定位节中的符号引用
    • 利用重定位条目修改代码节和数据节中的符号引用。

重定位条目

重定位条目告诉链接器在将目标文件合并成可执行目标文件时如何修改目标引用的位置,代码的重定位条目放在.rel.text中,已初始化数据的重定位条目放在.rel.data中。

ELF重定位条目的格式如下:

/* $begin elfrelo */
typedef struct { 
    long offset;    /* Offset of the reference to relocate */ 
    long type:32,   /* Relocation type */ 
	 	symbol:32; /* Symbol table index */ 
    long addend;    /* Constant part of relocation expression */
} Elf64_Rela; 
/* $end elfrelo */

字段说明:

  • offset:
    • 需要被修改的引用的节偏移;
  • symbol:
    • 被修改的引用该指向的符号;
  • type:
    • 告知链接器如何修改新的引用;
    • 基本的类型:
      • R_X86_64_PC32:重定位一个使用32位PC相对地址的引用,PC值为下一条指令的地址。
      • R_X86_64_32:重定位一个使用32位绝对地址的引用。
  • addend:
    • 有符号常数,用作偏移调整;

重定位符号引用

重定位算法:

foreach section s {
    foreach relocation entry r {
        refptr = s + r.offset;  /* ptr to reference to be relocated */
        
        /* Relocate a PC-relative reference */
        if (r.type == R_X86_64_PC32){
            refaddr = ADDR(s) + r.offset;   /* ref's run-time address */
            *refptr = (unsigned) (ADDR(r.symbol) + r.addend - refaddr);
        }

        /* Relocate an absolute reference */
        if (r.type == R_X86_64_32)
            *refptr = (unsigned) (ADDR(r.symbo1) + r.addend);
    }
}

说明:

s表示节,r表示重定位条目,这里假设节s对应的地址ADDR(s)以及每个符号的运行时地址ADDR(r.symbol)都已知。

7.8 可执行目标文件

可执行目标文件格式:

部分节的介绍:

  • ELF头:
    • 描述文件的总体格式;
    • 包括入口点(程序运行时第一条指令的地址);
  • .text:
    • 类似可重定位目标文件中的相同部分,但是已重定位到最终的运行时地址;
  • .rodata:
    • 类似可重定位目标文件中的相同部分,但是已重定位到最终的运行时地址;
  • .data:
    • 类似可重定位目标文件中的相同部分,但是已重定位到最终的运行时地址;
  • .init:
    • 定义了程序初始化代码时调用的函数_init;

备注:可执行文件是完全连接的,所以没有.rel节。

程序头部表

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

示例:

LOAD off    0x0000000000000000 vaddr 0x0000000000400000 paddr 0x0000000000400000 align 2**21
     filesz 0x000000000000069c memsz 0x000000000000069c flags r-x
LOAD off    0x0000000000000df8 vaddr 0x0000000000600df8 paddr 0x0000000000600df8 align 2**21
     filesz 0x0000000000000228 memsz 0x0000000000000230 flags rw-

字段说明:

  • off:目标文件中的偏移;
  • vaddr/paddr:内存地址;
  • align:对齐要求;
  • filesz:目标文件中的段大小;
  • memsz:内存中的段大小;
  • flags:运行时访问权限;

约束:

vaddr mod align = off mod align

7.9 加载可执行目标文件

为了运行可执行目标文件prog,在linux shell命令行中会执行如下命令:

./prog

该过程如下:

  • shell判断prog是否是内置的shell命令,如果是否,则将prog视为可执行目标文件;
  • 调用称为加载器的操作系统代码;
  • 加载器将prog的代码和数据复制到内存中;
    • 该过程称为加载;
  • 通过跳转到程序的第一条指令或入口点来运行程序;

加载器运行时,创建的内存映象如下:

对于加载和运行,更详细的步骤如下:

  • 加载器运行时创建类似上图的内存映像;
  • 根据程序头部表,将可执行文件的片复制到代码段和数据段;
  • 跳转到程序入口$\text{_start}$函数,该函数定义在$\text{ctrl.o}$中;
  • $\text{_start}$函数调用系统函数$\text{__lib_start_main}$,该函数定义在$\text{libc.so}$中;
  • $\text{__lib_start_main}$函数初始化执行环节,调用$\text{main}$函数;

备注:

  • 由于.data段有对齐要求,所以代码段和数据段之间是有间隙的,上图中并没有表示这点;
  • 分配栈,共享库和堆段运行时地址时,链接器会使用第三章提到的地址空间布局随机化;
  • 尽管运行地址可能变化,但是相对位置是不变的;

7.10 动态链接共享库

静态库缺陷:

  • 需要定期维护,要使用最新的静态库,需要做以下两点:
    • 了解到库的更新情形;
    • 显式的将程序和静态库链接;
  • 将标准I/O函数使用频率很高,将其代码复制到进程的文本段中很浪费内存资源;

共享库(shared library)是为了解决上述缺陷的产物,其特点如下:

  • 是目标模块,在运行或加载时,可以加载到任意的内存地址并和内存中的程序链接起来;
    • 该过程程序动态链接;
  • 共享库也称为共享目标(shared object),Linux中以.so后缀来表示,windows中被称为DLL(动态链接库)

共享库实现“共享”的方式:

  • 每个库只有一个.so文件,所有引用该库的可执行目标文件共享该.so文件的代码和数据;
  • 共享库的.text节副本可以被不同正在运行的进程共享;

动态链接过程:

生成共享库的指令:

gcc -shared -fpic -o libvector.so addvec.c multvec.c

说明:

  • -fpic指示编译器生成位置无关代码;
  • -shared指示链接器创建共享目标文件;

编译链接:

gcc -o prog21 main2.c ./libvector.so

说明:

  • 该指令的含义是:创建可执行文件时,静态执行一些链接;程序加载时,动态执行一些链接;
  • 此时没有任何libvector.so代码和数据节被复制到可执行文件prog21中;
  • 只复制了一些重定位和符号表信息用以运行时解析libvector.so文件;
  • prog21包含.interp节,该节包含动态链接器的路径名;
    • linux中动态链接器为ld-linux.so,也是一个共享目标;

对于包含动态链接的可执行目标文件,其运行流程如下(以prog21为例):

  • shell判断prog是否是内置的shell命令,如果是否,则将prog视为可执行目标文件;
  • 调用称为加载器的操作系统代码;
  • 加载器将prog的代码和数据复制到内存中;
  • 运行动态链接器;
    • 重定位libc.so的文本和数据到某个内存段;
    • 重定位libvector.so的文本和数据到某个内存段;
    • 重定位prog21中所有由libc.so,libvector.so定义的符号引用;
  • 通过跳转到程序的第一条指令或入口点来运行程序;

7.11 从应用程序中加载和链接共享库

Linux给动态链接器提供了简单的接口,运行应用程序在运行时加载和链接共享库。

dlopen接口:

#include <dlfcn.h>

void *dlopen(const char *filename, int flag);

返回:若成功则为指向句柄的指针,若出错则为 NULL

说明:

  • 加载和链接共享库filename;
  • 如果编译选项中带-rdynamic,则全局符号可用;
  • flag有两种选择:
    • RTLD_NOW:立即解析对外部符号的引用;
    • RTLD_LAZY:推迟解析,直到执行来自库中代码;

dlsym接口:

#include <dlfcn.h>

void *dlsym(void *handle,char *symbol);

返回:若成功则为指向符号的指针,若出错则为 NULL
  • handle:dlopen打开的句柄;
  • symbol:符号名称;

dlclode接口:

#include <dlfcn.h>

int dlclose (void *handle);

返回:若成功则为0,若出错则为-1
  • 卸载handle对应的共享库;

dlerror接口:

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

返回:如果前面对dlopen、dlsym或 dlclose的调用失败,则为错误消息;如果前面的调用成功,则为 NULL

例子

代码:

/* $begin dll */
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>

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

int main() 
{
    void *handle;
    void (*addvec)(int *, int *, int *, int);
    char *error; 

    /* Dynamically load the shared library that contains addvec() */
    handle = dlopen("./libvector.so", RTLD_LAZY);
    if (!handle) {
	fprintf(stderr, "%s\n", dlerror());
	exit(1);
    }

    /* Get a pointer to the addvec() function we just loaded */
    addvec = dlsym(handle, "addvec");
    if ((error = dlerror()) != NULL) {
	fprintf(stderr, "%s\n", error);
	exit(1);
    }

    /* Now we can call addvec() just like any other function */
    addvec(x, y, z, 2);
    printf("z = [%d %d]\n", z[0], z[1]);

    /* Unload the shared library */
    if (dlclose(handle) < 0) {
	fprintf(stderr, "%s\n", dlerror());
	exit(1);
    }
    return 0;
}
/* $end dll */

编译指令:

gcc -rdynamic -o prog2r dll.c -ldl

7.12 位置无关代码

  • 可以加载而无需重定位的代码称为位置无关代码(Position-Independent Code,PIC);
  • GCC中使用-fpic选项指示GNU编译系统生成PIC代码;
  • 共享库的编译必须使用该选项;

重要事实:

目标模块数据段和代码段的距离总是保持不变。

PIC数据引用

  • 在数据开始处创建全局偏移量表(Global Offset Table, GOT)
  • 所有被目标模块引用的全局数据目标(过程或全局变量)在GOT中都有一个8字节条目
  • 编译器为每个条目生成一个重定位记录

图示:

个人理解:

GOT记录全局数据目标的地址,该地址对应的值应该是可变的,所以代码段可以在GOT中定位到全局数据目标(注意GOT的位置是可以找到的,因为数据段和代码段的距离保持不变),

PIC函数调用

使用延迟绑定技术,将过程地址的绑定推迟到第一次调用该过程时。

延迟绑定的实现使用了GOT和过程链接表(Procedure Linkage Table,PLT)。

如果一个目标模块调用定义在共享库中的任何函数,那么它就有自己的GOT和PLT;GOT是数据段的一部分,PLT是代码段的一部分。

流程:

个人理解:

  • 函数在GOT中有一个对应位置;
  • 函数在PLT中有对应代码;
  • 第一次调用函数时修改全局偏移量表中函数的地址(利用栈信息计算),然后跳转;
  • 后续调用时根据全局偏移量表中函数的地址跳转;

7.13 库打桩机制

Linux链接器支持库打桩(library interpositioning),其作用如下:

  • 截获对共享库的调用,取而代之执行自己的代码。

后续内容的代码可以参考书籍网站。

编译时打桩

编译和链接:

gcc -DCOMPILETIME -c mymalloc.c
gcc -I. -o intc int.c mymalloc.o

含义:

-I.参数表示会进行打桩,具体来说,C预处理器在搜索系统目录之前,优先在当前目录中查找malloc.h。

链接时打桩

编译:

gcc -DLINKTIME -c mymalloc.c
gcc -c int.c

链接:

gcc -W1,--wrap,malloc -W1,--wrap,free -o int1 int.o mymalloc.o

含义:

  • Linux静态链接器支持用—wrap f标志进行打桩:
    • 把对符号$\text{f}$的引用解析成$\text{_ _wrap_f}$,把$\text{_ _real_f}$解析为$\text{f}$。
  • -W1,option标志把option传递给链接器,option中每个逗号都要替换为一个空格。
    • -W1,—wrap,malloc的含义为把—wrap malloc传递给链接器。

运行时打桩

  • 运行时打桩需要利用动态链接器的LD_PRELOAD环境变量
    • LD_PRELOAD环境变量被设置为一个共享库路径名的列表(以空格或分号分隔);
    • 加载和执行程序时,动态链接器(LD-LINUX.SO)会优先搜索LD_PRELOAD库,然后才搜索其他库;

编译:

gcc -DRUNTIME -shared -fpic -o mymalloc.so mymalloc.c -ldl
gcc -o intr int.c

运行(bash):

LD_PRELOAD="./mymalloc.so" ./intr

7.14 处理目标文件的工具

处理目标文件的Linux工具:

  • AR:创建静态库,插人、删除、列出和提取成员。
  • STRINGS:列出一个目标文件中所有可打印的字符串。
  • STRIP:从目标文件中删除符号表信息。
  • NM:列出一个目标文件的符号表中定义的符号。
  • SIZE:列出目标文件中节的名字和大小。
  • READELF:显示一个目标文件的完整结构,包括ELF头中编码的所有信息。包含SIZE和NM的功能。
  • OBJDUMP:所有二进制工具之母。能够显示一个目标文件中所有的信息。它最大的作用是反汇编.text节中的二进制指令。

操作共享库的程序:

  • LDD:列出一个可执行文件在运行时所需要的共享库。