CSAPP 链接

概述

链接(linking)就是将不同部分的代码和数据收集和组合成为一个单一文件的过程,这个文件可被加载(或被拷贝)到存储器并执行,链接就是编译的其中一个步骤,要理解链接就需要先大致了解编译是什么。

编译

C 语言代码最终成为机器可执行的程序,会像流水线上的产品一样接受各项处理:

  • 预处理器:将 C 语言代码(da.c)转化成 da.i 文件(gcc –E),对应于预处理命令 cpp
  • 编译器:C 语言代码(da.c, wang.c)经过编译器的处理(gcc -0g -S)成为汇编代码(da.s, wang.s)
  • 汇编器:汇编代码(da.s, wang.s)经过汇编器的处理(gcc 或 as)成为对象程序(da.o, wang.o)
  • 链接器:对象程序(da.o, wang.o)以及所需静态库(lib.a)经过链接器的处理(gcc 或 ld)最终成为计算机可执行的程序
  • 加载器:将可执行程序加载到内存并进行执行,loader 和 ld-linux.so

编译是指编译器读取源程序(字符流),对之进行词法和语法的分析,将高级语言指令转换为功能等效的汇编代码。源文件的编译过程包含两个主要阶段:
第一个阶段是预处理阶段,在正式的编译阶段之前进行。预处理阶段主要是将一些变量或者指令做替换,比如宏定义指令的替换等,也会将那些不必要的代码过滤掉。

第二个阶段编译、优化阶段,编译程序所要作得工作就是通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码。

汇编实际上指汇编器(as)把汇编语言代码翻译成目标机器指令的过程。目标文件中所存放的也就是与源程序等效的目标的机器语言代码。目标文件由段组成。通常一个目标文件中至少有两个段:

  • 代码段:该段中所包含的主要是程序的指令。该段一般是可读和可执行的,但一般却不可写
  • 数据段:主要存放程序中要用到的各种全局变量或静态的数据。一般数据段都是可读,可写,可执行


们来看一个具体的例子,假设我们有这么两个代码文件,其中 main 函数调用了另一个函数 sum:

// 文件 main.c
int sum(int *a, int n);

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

int main()
{
    int val = sum(array, 2);
    return val;
}

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

我们用下面的命令来编译执行:

linux> gcc -Og -o prog main.c sum.c
linux> ./prog

编译器实际上会分别编译不同的源代码,生成 .o 文件,具体把这些文件链接在一起的是 Linker 链接器,整个过程如下图所示:

在这里插入图片描述
编译部分的介绍就到这里,接下来着重将链接部分

链接基本知识

链接技术使分离编译(separate compilation)成为了可能,分离编译有2个优势:

  • 我们可以把程序分散到不同的小的源代码中,而不是一个巨大的类中。这样带来的好处是可以复用常见的功能/库,比方说 Math library,standard C library.
  • 改动代码时只需要重新编译改动的文件,其他不受影响。而常用的函数和功能可以封装成库,提供给程序进行调用(节省空间)

连接器主要负责做两件事
第一步:符号解析 Symbol resolution
我们在代码中会声明变量及函数,之后会调用变量及函数,所有的符号声明都会被保存在符号表(symbol table)中,而符号表会保存在由汇编器生成的 object 文件中(也就是 .o 文件)。符号表实际上是一个结构体数组,每一个元素包含名称、大小和符号的位置。

在 symbol resolution 阶段,链接器会给每个符号应用一个唯一的符号定义,用作寻找对应符号的标志。
第二步:重定位 Relocation
这一步所做的工作是把原先分开的代码和数据片段汇总成一个文件,会把原先在 .o 文件中的相对位置转换成在可执行程序的绝对位置,并且据此更新对应的引用符号(才能找到新的位置)

在具体来看这两步做了啥之前,先要理解下面几个概念。

三种对象文件

所谓的对象文件(Object File)实际上是一个统称,具体来说有以下三种形式:

  • 可重定位目标文件 Relocatable object file (.o file) 每个 .o 文件都是由对应的 .c
    - 文件通过编译器和汇编器生成,包含代码和数据,可以与其他可重定位目标文件合并创建一个可执行或共享的目标文件
  • 可执行目标文件 Executable object file (a.out file)
    - 由链接器生成,可以直接通过加载器加载到内存中充当进程执行的文件,包含代码和数据
  • 共享目标文件 Shared object file (.so file)
    - 在 windows 中被称为 Dynamic Link Libraries(DLLs),是类特殊的可重定位目标文件,可以在链接(静态共享库)时加入目标文件或加载时或运行时(动态共享库)被动态的加载到内存并执行
    -
对象文件格式

上面提到的三种对象文件有统一的格式,即 Executable and Linkable Format(ELF),因为,我们把它们统称为 ELF binaries,具体的文件格式如下
在这里插入图片描述

  • 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
    - 代码部分
  • .rodata section
    - 只读数据部分,例如switch的跳转表
  • .data section
    - 初始化的全局变量
  • .bss section
    - 未初始化的全局变量
  • .symtab section
    - 包含 symbol table, procudure 和 static variable names 以及 section names 和 location
  • .rel.txt section
    - .text section 的重定位信息
  • .rel.data section
    .data section 的重定位信息
  • .debug section
    - 包含 symbolic debugging (gcc -g) 的信息
  • Section header table
    - 每个 section 的大小和偏移量

链接器实际上会处理三种不同的符号,对应于代码中不同写法的部分:

  • 全局符号 Global symbols
    - 在当前模块中定义,且可以被其他代码引用的符号,例如非静态 C 函数和非静态全局变量
  • 外部符号 External symbols
    - 同样是全局符号,但是是在其他模块(也就是其他的源代码)中定义的,但是可以在当前模块中引用
  • 本地符号 Local symbols
    - 在当前模块中定义,只能被当前模块引用的符号,例如静态函数和静态全局变量(static)
    - 注意,Local linker symbol 并不是 local program variables
    - C语言的静态变量与Java语言的完全不同,可以简单理解为Java的private声明,任何声明带有static属性的全局变量或者函数都是模块私有的,反之亦然。

那链接器不处理的符号就是局部变量了,包括静态局部变量与非静态局部变量,二者之间的区别:

  • 局部非静态变量会保存在栈中
  • 局部静态变量会保存在 .bss 或 .data 中

那如果两个函数中定义了同名的静态变量会怎么样呢?
首先,编译器会在 .data 部分为每一个静态变量进行定义,如果遇到同名,就会在本地的符号表中自动给出唯一的编号,比如下面例子中的变量 x,可能在符号表中是 x.1 和 x.2

int f()
{
    static int x = 0;
    return x;
}

int g()
{
    static int x = 1;
    return x;
}

那如果两个文件中定义了同名的全局变量呢?要想弄清楚会发生什么,就先要知道,不同的符号是有强弱之分的:

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

我们可以来看看下面的例子

// 文件 p1.c
int foo = 5; // 强符号,已初始化
p1() { ... } // 强符号,函数

// -----------------------------------------
// 文件 p2.c
int foo;     // 弱符号,未初始化
p2() { ... } // 强符号,函数

链接器在处理强弱符号的时候遵守以下规则:

  • 不能出现多个同名的强符号,不然就会出现链接错误
  • 如果有同名的强符号和弱符号,选择强符号,也就意味着弱符号是『无效』d而
  • 如果有多个弱符号,随便选择一个

通过几个例子加强理解:

// 文件 p1.c
int x; 
p1() { ... } 

// -----------------------------------------
// 文件 p2.c
p1() { ... }

// 两个文件的p1()函数都是强符号,所以会出现链接错误
// 文件 p1.c
int x; 
p1() { ... } 

// -----------------------------------------
// 文件 p2.c
int x;
p2() { ... }

// 会引用同一个未初始化的整型,并不是两个独立的变量
// 文件 p1.c
int x; 
int y;
p1() { ... } 

// -----------------------------------------
// 文件 p2.c
double x;
p2() { ... }

这里 p1 和 p2 中定义的变量都是弱符号,不管编译器选中p1.c还是p2.c的x变量,在对 p2 中的 x 进行写入时,都会影响到p1.c的y。原因是空间是一开始就分配好了,而double的字节数是int的两倍,一旦对double x写操作,都会将y覆盖。
这里最后的例子里是两个弱符号同名变量,其实,在不同类型的同名变量中,无论强弱符号,写操作时都极可能会影响到其他同名变量。

从这些例子中,我们已经能够看出链接中可能会出现的问题,更可怕的是两个同名的弱结构体引用,不同的编译器可能有不同的对齐方式,真正编译运行的时候,就会出现非常奇怪的行为,这种 bug 一旦出现,几乎是很难在短时间内发现并解决的。

因此我们可以得到一条很重要的编程建议:
如果一定要用的话,注意下面几点:

  • 使用静态变量
  • 定义全局变量的时候初始化
  • 注意使用 extern 关键字
第二步 重定位 Relocation

重定位的过程比较简单,大概的过程,通过下图就可以看得比较清楚,就是把不同可重定位对象文件拼成可执行对象文件:
在这里插入图片描述
我们从汇编代码的角度来看看具体链接器是如何工作的,还是之前的代码:

int sum(int *a, int n);
int array[2] = {1, 2};

int main()
{
    int val = sum(array, 2);
    return val;
}

我们通过 objdump -r -d main.o 反编译对应的可重定位对象文件,可以得到如下的汇编代码:

0000000000000000 <main>:
    0: 48 83 ec 08      sub   $0x8, %rsp
    4: be 02 00 00 00   mov   $0x2, %esi 
    9: bf 00 00 00 00   mov   $0x0, %edi  # %edi = &array
                a: R_X86_64_32 array      # Relocation entry
    e: e8 00 00 00 00   callq 13 <main+0x13> # sum()
                f: R_X86_64_PC32 sum-0x4  # Relocation entry
   13: 48 83 c4 08      add   $0x8, %rsp
   17: c3               retq

这里我们可以看到,编译器用 relocation entry 来标记不同的调用(注意看对应的代码后面四组数字都是零,就是留出位置让链接器在链接的时候填上对应的实际内存地址)

在完成链接之后我们得到 prog 这个程序,同样反编译 objdump -dx prog 可以看到:

00000000004004d0 <main>:
    4004d0: 48 83 ec 08      sub   $0x8, %rsp
    4004d4: be 02 00 00 00   mov   $0x2, %esi 
    4004d9: bf 18 10 60 00   mov   $0x0, %edi  # %edi = &array
    4004de: e8 05 00 00 00   callq 4004e8 <sum> # sum()
    4004e3: 48 83 c4 08      add   $0x8, %rsp
    4004e7: c3               retq 

00000000004004e8 <sum>:
    4004e8: b8 00 00 00 00   mov   $0x0, %eax
    ...
    ...
    400501: f3 c3            repz retq

对应的地址已经被填上去了,这里注意用的是相对的位置,比方说 0x4004de 中的 05 00 00 00 的意思实际上是说要在下一句的基础上加上 0x5,也就是 0x4004e8,即 sum 函数的开始位置。

具体载入内存的时候,大概是这样的
在这里插入图片描述
需要注意左边的部分地址从上往下(上面地址较小),右边则是从下往上(下面地址较小),这也是为什么黄色和蓝色的顺序在两边是不一样的。

打包常用程序

基本上每个程序都会用到某些特定的函数,比如:数学计算, 输入输出, 内存管理, 字符串操作等等,我们能用什么方法把它们结合到程序中呢,有以下两个思路:

  • 思路 1:把所有的函数放到一个源文件中,程序员每次把这一整个大块头链接到自己的程序中,这种做法从时间和空间上来说都比较低效
  • 思路 2:不同的函数放到不同的源文件中,由程序员显式链接所需要的函数,这种做法效率更高,但是相当于是给程序员增加负担了
静态库 Static Library

比较老式的做法就是所谓的静态库(Static Libraries, .a 表示 archive files)

静态库是一个外部函数与变量的集合体。静态库的文件内容,通常包含一堆程序员自定的变量与函数,其内容不像动态链接库那么复杂,在编译期间由编译器与连接器将它集成至应用程序内,并制作成目标文件以及可以独立运作的可执行文件。而这个可执行文件与编译可执行文件的程序,都是一种程序的静态创建(static build)

具体过程就是把不同文件的 .o 文件通过 Archiver 打包成为一个 .a 文件。Archiver 支持增量更新,如果有函数变动,只需要重新编译改动的部分。

在 C 语言中最常用的是 C 标准库与 C 数学库。C 标准库一般可以通过 libc.a 来进行引用,大小 4.6 MB,包含 1496 个对象文件,主要负责输入输出、内存分配、信号处理、字符串处理、操作数据和实践、生成随机数及整型的数学运算。C 数学库可以通过 libm.a 来引用,大小 2 MB,包含 444 个对象文件,主要是提供浮点数运算的支持(比如三角函数、幂次等等)

我们来看一个具体的例子,自己编写一个向量运算库 libvector.a,其中包含两个函数 addvec 和 multvec,代码为:

// 文件 main.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;
}

// -----------------------------------------
// 文件 addvec.c
void addvec(int *x, int *y, int *z, int n)
{
    int i;
    for (i = 0; i < n; i++)
        z[i] = x[i] + y[i];
}

// -----------------------------------------
// 文件 multvec.c
void multvec(int *x, int *y, int *z, int n)
{
    int i;
    for (i = 0; i < n; i++)
        z[i] = x[i] * y[i];
}

具体过程可见下图:
在这里插入图片描述
实际上,C编译器驱动程序总是传送libc.a给链接器,所以我们不必要对libc.a引用

链接器是如何解析外部引用的呢?详细的步骤为:

  • 扫描当前命令中的 .o 和 .a 文件
  • 扫描过程中,维护一个当前未解析引用的列表
  • 扫描到新的 .o 或 .a 文件时,试图去寻找未解析引用
  • 如果扫描结束时仍旧有为解析的引用,则报错

因为是按顺序查找,所以实际上是有引用依赖问题的,也就是说写编译命令的时候,顺序是很重要的!我们看下面这个例子,这里 libtest.o 中引用了 lmine 库中的 libfun 函数,仔细比较两个的顺序:

unix> gcc -L. libtest.o -lmine
# 上面这句不会出错,但是下面的会
unix> gcc -L. -lmine libtest.o
libtest.o: In function `main`:
libtest.o(.text+0x4): Undefined reference to `libfun`

第一条命令中,在编译链接的时候,如果在 libtest.o 中发现了外部引用,就会在 -lmine 中查找,但是如果反过来,在第二条语句中 libtest.o 后面没有东西,就会出现找不到引用的错误。从中我们可以得到一个写编译命令的技巧:

把静态库都放到后面去

共享库 Shared Library

静态库很方便,但是如果我们只是想用库中的某一个函数,却仍然得把所有的内容都链接进去。一个更现代的方法则是使用共享库,避免了在文件中静态库的大量重复。

动态链接可以在首次载入的时候执行(load-time linking),这是 Linux 的标准做法,会由动态链接器 ld-linux.so 完成,比方标准 C 库(libc.so) 通常就是动态链接的,这样所有的程序可以共享同一个库,而不用分别进行封装。

还是用刚才的例子,如果我们使用动态链接,过程如下:
在这里插入图片描述
动态链接也可以在程序开始执行的时候完成(run-time linking),在 Linux 中使用 dlopen() 接口来完成(会使用函数指针),通常用于分布式软件,高性能服务器上。而且共享库也可以在多个进程间共享,这在后面学习到虚拟内存的时候会介绍。

动态链接

动态链接有一个缺点,那就是只有进程运运行后,函数的地址才能确定。而且现代操作系统不允许修改代码段,只能修改数据段,如果函数是在一个动态库(.so对象)内,修改了代码段,那么它就无法做到系统内所有进程共享同一个动态库。
所以要在文件中写入函数的地址,只能在运行后回写到数据段内,而不能回写到代码段上。那在编译阶段就已生成好的call指令,怎么感知这个已重定位好的数据段内容呢?
答案是:链接器生成一段额外的小代码片段,通过这段代码支获取printf函数地址,并完成对它的调用。

PLT与GOT
前面说明了调用动态的函数需要两样东西:

  • 需要存放外部函数的数据段
  • 获取数据段存放函数地址的一小段额外代码
    而这两样东西,就是ELF里的section,存放函数地址的数据表,称为重局偏移表(GOT, Global Offset Table),而那个额外代码段表,称为程序链接表(PLT,Procedure Link Table)。

在汇编代码中,经常看到call调用函数时后面都会加@plt,其实就是调用PLT的额外代码片段,而PLT就会帮我们去掉真正的函数。
在这里插入图片描述下面是调用glibc动态库的wirte函数和print函数的例子:
在这里插入图片描述
延迟重定位
如果可执行文件调用的动态库函数很多时,那在进程初始化时都对这些函数做地址解析和重定位工作,大大增加进程的启动时间。所以Linux提出延迟重定位机制,只有动态库函数在被调用时,才会地址解析和重定位工作。

进程启动时,先不对GOT表项做重定位,等到要调用该函数时才做重定位工作。要实现这个机制必须要有一个状态描述该GOT表项是否已完重定位。

一个显而易见的方案是在GOT中增加一个状态位,描述一个GOT表项是否已完成重定位,那么每个函数就有两个GOT表项了。相应的PLT伪代码如下:

void printf@plt()
{
    if (printf@got[0]= RELOCATED) { // 如果没完成重定位
        调用重定位函数
        printf@got[1] = 地址解析发现的printf地址;
        printf@got[0] = RELOCATED;
    }

    jmp *printf@got[1];
}

这个方案每个函数使用两个GOT表项,占用内存明显增长了一倍。但仔细观察GOT表项中的状态位和真实地址项,这两项在任何时候都不会同时使用,这样的话我们可以把两个GOT表项合成一个,只需要修改下PLT的代码:

void printf@plt()
{
address_good:
    jmp *printf@got            // 链接器将printf@got填成下一语句lookup_printf的地址

lookup_printf:
        调用重定位函数查找printf地址,并写到printf@got

        goto address_good;
}

进入PLT时,会先调用address_good的代码跳到GOT表项,如果GOT表项没有地址的话(第一次访问),会跳回PLT的lookup_printf执行,这里就会跳到动态链接器中将printf址解析出来,并重定位回printf@got项内。
那么神奇的作用来,第二次调用printf时,通过printf@got直接跳到printf函数执行了。

公共GOT表项
到这里还有一个疑问,就是上面lookup_printf是怎么查找到printf的地址的?
所有动态库函数在第一次调用时,都是通过XXX@plt -> 公共@plt -> _dl_runtime_resolve调用关系做地址解析和重定位的。
谈到这里,其实还有谜底是没有解开的,以printf函数为例:

  • _dl_runtime_resolve是怎么知要查找printf函数的
  • _dl_runtime_resolve找到printf函数地址之后,它怎么知道回填到哪个GOT表项
  • 到底_dl_runtime_resolve是什么时候被写到GOT表的

前2个问题,只需要一个信息就可以了知道,这个信息就在藏在在函数对应的xxx@plt表中,以printf@plt为例:

printf@plt>:
   jmp *0x80496f8
   push $0x00
   jmp common@plt

每个xxx@plt的第二条指令push的操作数都是不一样的,它就相当于函数的id,动态链接器通过它就可以知道是要解析哪个函数了。

第三个问题:到底_dl_runtime_resolve是什么时候被写到GOT表的
答案很简单,可执行文件在Linux内核通过exeve装载完成之后,不直接执行,而是先跳到动态链接器(ld-linux-XXX)执行。在ld-linux-XXX里将_dl_runtime_resolve地址写到GOT表项内。

事实上,不单单是预先写_dl_runtime_resolve地址到GOT表项中,在i386架构下,除了每个函数占用一个GOT表项外,GOT表项还保留了3个公共表项,也即got的前3项,分别保存:

  • got[0]: 本ELF动态段(.dynamic段)的装载地址
  • got[1]:本ELF的link_map数据结构描述符地址
  • got[2]:_dl_runtime_resolve函数的地址

动态链接器在加载完ELF之后,都会将这3地址写到GOT表的前3项。
其实上述公共的plt指令里面,还有一个操作数是没有分析的,其实它就是got[1](本ELF的link_map)地址,因为只有link_map结构,结合.rel.plt段的偏移量,才能真正找到该elf的.rel.plt表项。

链接使得我们可以用多个对象文件构造我们的程序。可以在程序的不同阶段进行(编译、载入、运行期间均可),理解链接可以帮助我们避免遇到奇怪的错误。

  • 1
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值