2_程度链接

程序链接

程序链接过程就是把多个程序的模块, 最终装配成一个执行文件的过程

C 语言示例程序

// main.c

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

int main()
{
    swap();
    return 0;
}
// swap.c

extern int buf[1];

int *bufp0 = &buf[0];

static int *bufp1;

void swap()
{
    int temp;

    bufp1 = &buf[1];
    temp = *bufp0;
    *bufp0 = *bufp1;
    *bufp1 = temp;
}

我们用一个 C 语言程序来说明程序链接, 源程序里面有两个 .c 的模块.

首先它里面声明了一个一维数组, 数组只有两个元素. main 函数调用 swap 函数, 实际上把数组里面两个元素交换了一下位置.

静态链接 (Static Linking)

我们看看怎么通过一个静态链接的过程, 把这个程序最终转换成一个执行文件

这张图给出来 GCC 真正编译一个程序的时候, 它的步骤. 实际上它是先是编译, 后是链接.

我们把它中间生成的 .s 文件打开来看, 会发现里面有很多标号, 也就是各种数据的地址. 但是这些个地址, 只有在链接之后才会有一个确切的地址常量值.

程序链接的作用

作用一 : 模块化好

我们可以通过链接把多个比较小的模块或者源程序, 链接成一个程序, 而不是一个巨大的单一的源文件

可以将多个常用的通用函数链接成库文件, 比如数学计算库, 标准 C 库

作用二 : 效率比较高

我们只需要维护多个小文件, 就是一个小的源文件被修改之后, 如果你要重新编译的话, 就重新编译这一块就行了, 然后重链接, 而不需要编译所有文件.

第二个就是空间效率高一些, 因为可以把多个通用函数集成到一个文件当中, 这样, 程序运行时用到哪一个函数, 就把那个函数链接进来内存就可以了.

链接步骤

步骤一 : 符号解析

  • 编译器定义以及引用了一系列符号 (symbols, 包括变量与函数)

从 C 语言程序角度而言, 程序里面定义了一系列的符号 (Symbols), 这个符号包括变量和函数. 就拿刚才例子来说 :

void swap(){...} /* define symbol swap */

swap(); /* reference symbol a */

int *xp = &x; /* define symbol xp, reference x */

  • 编译器将符号定义存储在符号表 (symbol table) 中

    • Symbol table is an array of structs.

    • Each entry includes name, size, and location of symbol.

  • 链接器将每一个符号引用 (reference) 与符号定义联系起来

上面就是符号解析的过程.

步骤二 : 重定位

解析过后就是重定位了.

  • 将多个文件的数据/代码段集成为单一的数据段和代码段

一堆 .c 转换成 .o 都有自己独立的代码段数据段, 但是最终的执行文件里面, 原来所有人的代码段拼到一块, 所有人的数据段都拼到一块

  • 将 .o 文件中的符号解析为绝对地址

  • 然后将所有的符号引用更新为这些新的地址

就是拼完之后, 在内存里面怎么个分布, 我就清楚了, 我的绝对地址就可以指定出来了.

就是把原来我们用标号来表示地址的地方, 更新换成绝对地址, 这就是重定位.

三种不同的对象文件

  • 重定向对象文件 (.o 文件)

    • 含有一定格式的代码与数据内容, 可以与其它重定向文件一起集成为可执行文件
    • 一个 .o 文件由唯一的一个源文件生成
  • 执行文件 (a.out 文件)

    • 含有一定格式的代码与数据内容, 可以直接被装载入内存并执行
  • 共享对象文件 (.so)

    • 特殊类型的重定向文件, 可以被装载入内存后进行动态链接 ; 链接可以在装载时或者运行时完成

    • windows 系统下被称为 dll 文件

Executable and Linkable Format (ELF)

  • 对象文件标准二进制格式 (之一)

上面提到的三种文件都可以采用这一个统一格式

  • 最初是由 AT&T System V Unix 系统采用
    • 后来被广泛使用, 包括 BSD Unix 与 Linux 系统

ELF 文件的格式

  • ELF header

    • Word size, byte ordering, file type(.o, exec, .so), machine type, etc.
  • Segment header table

    • Page size, virtual addresses memory segments (sections), segment sizes
  • .text section

    • code
  • .rodata section

    • read only data : jump tables, ...
  • .data section

    • Initialized global variables
  • .bss section

    • Uninitialized global variables
    • "Block Started by Symbol"
  • .symtab section

    • symbol table
    • procedure and static variable names
    • section names and locations
  • .rel.text section

    • relocation info for .text section
    • addresses of instructions that will need to be modified in the executable
  • .rel.data section

    • relocation info for .data section
    • address of .pointer data that will need to be modified in the merged executable
  • .debug section

    • info for symbolic debugging (gcc -g)
  • section header table

    • offsets and sizes of each section
- -
- ELF Header
- Segment header table (required for executables)
- .text section
- .rodata section
- .bss section
- .symtab section
- .rel .txt section
- .rel .data section
- .debug section
- Section header table

链接符号

这里讲了我们在进行符号链接的话, 具体要处理哪些符号

  • 全局符号

    • 某一个模块定义的, 且可以被其它模块引用的变量或者函数符号
    • 比如某些非静态的 C 函数以及非静态的 C 全局变量
  • 外部符号

    • 某个模块引用的, 由其它模块定义的全局符号
  • 局部符号

    • 由某个模块定义, 且仅有该模块引用的符号
    • 比如静态的 C 函数以及静态的 C 全局变量
    • 这与程序的局部变量不是一个概念 (不知道大家还记不记得 C 语言全局变量与局部变量在汇编上的区别)

符号解析

讲完链接符号, 就到了符号解析

符号解析就是你的 C 模块里面, 声明了哪些用了哪些, 另一个 C 模块里面声明了哪些用了哪些, 相互比对, 看能不能对得上. 就是你用我的, 我用你的, 相互对上了这个事情就 OK 了, 就解析完了.

还是刚才那个示例程序, 我们看看里面出现了哪些变量或者函数, 是全局的还是局部的, 哪些是外部的.

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

int main()
{
    swap();
    return 0;
}
// main.c

buffer, 这肯定是全局的, 因为它是定义在大括号外面

然后 main() 函数肯定是全局的

swap() 当然不是它自己定义的, 它是在另外一个模块定义的, 但是这里 main() 函数都用了, 所以 swap() 是个外部的

extern int buf[];

int *bufp0 = &buf[0];
static int *bufp1;

void swap()
{
    int temp;

    bufp1 = &buf[1];
    temp = *bufp0;
    *bufp0 = *bufp1;
    *bufp1 = temp;
}
// swap.c

这里面呢, 首先 int buffer 在 wap.c 里, 这肯定是一个外部的, 因为都显示声明了

然后 int *bufp0, 很明显是全局的

下面的这个 int 实际上是全局的, 但是它是静态 static 的, 只有它自己能用, 所以是局部的

swap() 是全局的函数

最后一个很有意思, temp, temp 实际是一个局部变量, linker 链接器根本就不知道 temp 在什么地方, 它对 temp 根本就没有什么概念. 因为链接器解析的是可以被外部去调用的符号, 而局部变量只能在函数内部使用, 所以链接器就完全感知不到它的存在.

那现在问题来了, 局部变量地址链接器搞不定怎么办.

现在我们回到很久之前讲过的内容, 局部变量会放在哪里. 一是说局部变量通过编译器, 分配到一个通用寄存器中; 还有一个就是说, 局部变量有可能是放在栈帧里面的某个位置, 就是相对 ebp 或者 esp 的地址.

这样我们就搞定了局部变量的问题. 我们 linker 完全就不涉及局部变量的内容.

代码与数据重定位

链接器搞定了符号解析之后, 就是重定位了.

第一个 system.code, 你可能去连接一些系统, 比如说 libc 库里面的一些代码, 它的正文段和数据段都有

再就是 main.o, main.o 就是通过编译生成的一个重定向文件.

再看 main.o 里面, 首先它的正文段里面声明了一个 main 的全局函数, 另外它的 data 段里面也声明了一个长度为 2 的初始化过的一个 叫作 buffer 的 int 类型数组.

而 swap.o 里面呢, 首先是声明一个名字为 swap 的全局的函数; 在 data 段里面也声明了一个类型为 int * 的名字叫作 bufferp0 的这么一个初始化过的一个变量; 然后在 .bss 段里面有一个静态的全局的一个 int 类型指针, 没有被初始化过所以放到 .bss 段里面去, 这个指针叫 bufp1.

三个内容, 通过 link, 分别把它们的代码段装配到一块, 把数据段装配到一块, 形成右侧的这个执行文件.

这样一来, 里面的每个符号的位置, 都有了一个绝对的地址.

重定位信息

重定位信息 - main

我们从 main.c 入手, 来看一看重定位信息怎么描述.

我们先把 main.c 编译生成一个 main.o, 然后我们用 objdump 把它反汇编出来, 内容如图. 由于版本的差异, 内容可能会有区别.

main 里面实际上需要重定位的就一个东西, 也就是 swap 函数. 你要调用 swap 函数, 而这个 swap 函数在什么地方, 编译的时候根本不知道, 所以在 .o 文件中, 这里面红色的这一行 12 : R_386_PC32 swap 给出了重定位信息.

我们对这个重定位信息一个个地解释.

首先这个 12, 这个表示我所要重定位的东西, 在我这个段里面的位置, 这里是代码段. 实际上 12 就是 offset, e8 就是 11, fc 就是 12 了.

然后是 R_386_PC32 swap, 顾名思义这个是代码的一个地址, pc32 program counter, 宽度是 32 比特也就是 4 byte, 所以从这里开始有 4 个 byte, 我要填一个代码的地址放进去. 这个代码的地址就是 swap 的地址.

对于 main.c 而言, 它还需要重定位的东西还有数据段里面的 buffer. 这个全局变量到底定义在什么地方, 编译的时候不知道, 但是我知道它的值, 里面有个 1 和 2, 这个全局变量的位置在链接完之后才能知道.

重定位信息 - swap, .text

swap 函数的重定位信息有点麻烦, 我们先看看它 .text 代码段里面的重定位信息.

我们还是解释这些红色或者是黄色的部分.

实际上这个解释方式和刚才是一样的.

比如说 2:R_386_32 bufp0 说明从 swap 它所在的这个代码段 offset 2 开始, 0 1 15 后面 00 就是 2 的位置. 就是我这个 4 个红色的 0 表示我要填入一个 32 位的地址, 这个地址的值就是 bufp0, 也实际上就是这个变量所在的位置.

下面也类似, 7:R_386_32 buf, 标号对应的是 buffer, 也就是说我获得这个 buffer 地址之后, 我要对应地把这个位置填到 offset 7 开始的连续的 4 个字节里面去. 但这里面有意思, 你看前面那个是 00 00 00 00, 这边是 04 00 00 00. 这个 04 是干嘛的呢. 因为 buf 本身代表了一个全局变量所在的地址, 但是呢我左侧代码, 有的是取决于 bufp1 的地址, bufp1 和 buf 这个起始地址起始差个 4, 那这个 4 怎么表示呢, 它就把 4 搁到这里了, 就是 04 00 00 00, 我把这 buf 地址拿过来之后, 再加上我原来这个域里头填了一个 4 就是我最终所需要的地址.

再看这个黄的, 从 10 这个位置开始, 也就是第一个黄色 00 这个地方开始, 我们要填 bss 的起始地址, 然后 bss 里面填了 int *bufp1 的地址, 它这个地址一旦解析完成之后, 就填到 10 位置开始的连续 4 个字节的空间里面去.

后面也是类似的了, 就不详细说明了.

重定位信息 - swap, .data

这个数据段里面可以看到, 它里面声明了一个 bufp0, 这个 bufp0 本身的位置我们就不知道, 链接完了之后才知道. 另外, 它里面所知道的初始化的值是 buf 0 第一个元素的起始位置, 实际也是 buf 数组起始的位置, 具体什么位置我们还是不知道.

buf 数组默认填 0.

不知道怎么办呢, 我们还是以此类推, 填一个数 0, 我这边要把 buf 的这个地址填到 0 位置开始的连续 4 个字节里面去. 在你程序最后链接之后, bufp0 是要被初始化的一个全局变量, 所以在你执行之前, 你这个数值必须被填上.

重定位前后的对比 (.text)

这个是 main 函数重定位前后的对比, 我们还是看红色这一行.

首先它把 swap 这个位置填到 offset 为 12 开始的这个连续字节里面去.

但是我们要注意, swap 的起始位置, 实际上是 80 4b 3b 0, 那么 call 里面实际上就填这个数. 程序链接过后就知道了 swap 的绝对地址了, 但是没有链接之前, 这里的汇编指令是填的相对地址, 实际上是 swap 的入口地址和当前 PC 的一个差值

我们以前写程序的时候, 认为这里是个绝对的地址, 但是实际上这里填的是两者的差值, 这个差值是你下一条要执行指令的 pc 跟你这个目标入口函数之间的 offset. 这一点大家知道就可以了.

mov 0x8049628, edx, 49628 指的是什么呢, 就是 buffer fp0, 注意 8049628 前面没有 $ 符号, 这说明我是把这个数作为地址, 然后把这个地址里面的数据放到 edx 里面去, 下面的这个类似.

mov 0x8049624, %eax 与之类似就不说了.

下面的 movl $0x8049624, 0x8049630, 这个有点绕. 前面的操作数加了 $ 符号, 后面的操作数没加. 加 $ 的意思是把这个数作为常数处理, 放到目标操作数这个 地址 里面去.

mov %ecx, 0x8049624 也是类似, 把数据取出来放到 buf 1 这个地址指向的内存里面去.

上面代码无非就是把两个 buffer 的元素交换一下位置, 大家可以自己去对比一下代码看一看.

重定位前后的对比 (.data)

还有个不要忘了, swap 里面还有个全局数据段需要进行重定位.

那么 buffer 的话, 实际上都用到过了, 因为你所有的数据段都拼到一块, 之后每一个里面变量的起始位置, 全局初始化过的起始位置, 这个时候都能确定了.

buffer 在 49620 这个位置, fp0 就放在 49628 这个位置.

所以总的来说链接器就完成了两个位置, 第一个是我把每个模块所定义的以及它要引用的那些符号给它归纳一下; 第二个因为有多个模块, 所有被引用的全局符号都应该找到一个落脚处, 然后把所有的 .o 文件里面正文段和数据段分别拼接成一个连续的大的正文段或者数据段.

这样每个全局符号的绝对位置我就知道了, 回过头来我把这些绝对位置地址插回去, 用来替换刚才那些用来表示地址的符号, 替换这些符号, 替换完了就完成了链接工作, 然后就可以生成执行文件了.


反过来讲, 如果链接不成功. 我们大家很多时候经常碰到的一个问题是你这个标号要用, 结果没有人定义, 你可能是忘了连里面某个库之类的, 得好好找一找. 如果全部解析完成, 那这个就不是问题了.

将常用函数打包

有了链接功能之后, 我可以把一些常用的函数打包成库文件

  • 如何打包 ?

    • Math 库, I/O 库, 内存管理函数, 字符串处理函数等等
    1. 第一个选择 : 把所以函数放入同一个源文件
      • 程序员把这一个 "大" 对象文件链入自己的软件
      • 空间 / 时间效率不高
    2. 第二个选择 : 每个函数一个文件
      • 程序员有选择性的链接所需的对象文件
      • 高效, 但是程序员负担较重

还有种解决方案就是所谓的静态库文件

解决方案 : 静态库文件 (.a 文件)

将多个相关的重定位对象文件 (.o 文件) 集成为一个单一的带索引的文件 (称为归档文件, archive file)

然后我们增强链接器的功能, 使它能够在归档文件中解析外部符号

就一个归档文件比方说它提供了一个 malloc 或者 free 这么一个函数的声明, 那么在这个程序里面可以用它们, 然后链接器去归档文件里面找, 看你有没有这个定义, 二者匹配上的话, 问题就解决了.

也就是说, 如果归档文件中的某个成员能够解析了, 就是它定义了某个外部符号, 就将其链入执行文件, 就 OK 了.

创建静态库文件

如图, 比方说我有 3 个常用的函数, 每个就是一个 .c 文件, 分别通过编译转换为一个 .o, 然后专门有一个打包归档的命令叫 ar, 把它们打包成一个大包. 这里给出的是 libc.a 的例子.

归档文件可以以增量方式更新

重编译更新过的源文件, 并替换归档文件

常用库文件 (举例)

  • libc.a (the C standard library)

    • 8 MB archive of 1392 object files
    • I/O memory allocation, signal handing, string handing, data and time, random numbers, integer math
  • libm.a (the C math library)

    • 1 MB archive of 401 object files.
    • floating point math (sin, cos, tan, log, exp, sqrt...)

与静态库链接

我们生成一个 addvec.o 向量加和一个 multvec.o 向量乘, 两个 .c 转换成一个 .o, 归档成一个 libvector, 然后这边你写的源文件就是 main2.c 里面用到了这些向量加或者向量乘的这个函数.

当然这种情况下你不需要去定义它们, 但是你要声明, 你要引用一个相关的 vector.h 的这个头文件, 然后呢 main.c 加 vector.h 的这个头文件, 编译生成一个 main2.o, 这个 main2.o 和 libvector.a 同时你默认还有 libc.a (一般情况下 C 默认链接标准 C 库), 三者在 link 的作用下, 看看你到底用了哪些 libvector.a 里面的函数或者是 libc.a 里面的函数, 把它们链接进来, 最终生成一个执行文件也就是 P2. 这就是与静态库链接生成.

共享库文件

还有一种文件是共享库文件, 因为静态库文件有其劣势.

  • 静态库文件有其劣势 :
    • 执行文件中会重复包含所需库文件函数或者数据
    • 运行时内存中也会有重复部分
    • 库文件的细微变动需要所有相关执行文件进行重链接

比如说我有一百个程序, 每一个都用了 printf.o, 我把执行文件装到内存中, printf.o 也被我装了一百次, 多占了一百份空间, 这时候我们可以做一个共享库文件.

  • 更好的方案 : 共享库文件方式

    • 特殊类型的重定向文件, 可以被装载入内存后进行动态链接 ; 链接可以在装载时或者运行时完成

    • Windows 下称为 dll 文件

我的执行文件里面声明一下我要用你这个 printf, 但我并不把这个真正代码给链接进我的执行文件中去, 我只在我执行的时候或者我真正用到它的时候, 给它链接进来.