一篇长文带你深析Linux动态链接的全过程

引言:最近受到了一位老师的启发(我不是CS专业的,我去找了信院里的操作系统的老师),又给我本狭隘的视野开拓了一些目光所及之处,交流了两个小时,倍感遗憾怎么没有早点认识这个老师,也倍感欣慰终于遇到了这种老师。

这篇文章可能有点晦涩难懂,我希望你有以下的前置基础知识(基础即可,我会尽量说清):

  • 操作系统(最好看过CSAPP
  • AT&T语法的汇编语言(Intel汇编也可以,但是可能有些地方不同得去查询指令集开发手册了)
  • CC++有一定的掌握
  • Linux操作熟练,对Linux内核和系统有一定的了解
  • GDB调试(主要是反汇编)有一定了解
  • 对编译原理、数据结构和计组有一定了解

相关代码实现部分我后续会补上。

其中关于GNU Glibc C语言库GNU LD手册Intel指令集开发手册以及其他里面资料大家可以私信我自取。

为了减少复杂性,后面的反汇编都是通过该源代码实现的

// t.c
#include <stdio.h>  // printf
#include <unistd.h>  // sleep

int main(int argc, char *argv[]) {
	printf("Hello World!\n");  // 调用write(...)
	sleep(-1);  // 表示一直阻塞
}

// gcc -o file t.c

为什么要动态链接?

静态链接使得不同的程序开发者能够相对独立的开发和测试自己的程序模块,但是模块更新困难、浪费内存和磁盘空间等问题也凸显出来。

动态链接基本思想:对组成程序的目标文件等到程序运行的时候才进行链接,也就是说,把链接这个过程推迟到了运行时再进行,这就是动态链接的基本思想。

目前主流的操作系统都支持动态链接,在Linux系统中,ELF动态链接文件被称为动态共享文件(DSO,dynamic shared objects),简称共享对象,一般以.so结尾。

全过程:把主程序进行虚拟内存映射、进入链接器的入口链接器执行重定位和其他操作、进入到主程序的入口执行程序。

此处有个地方需要进行认知:在静态链接时,整个程序最终只有一个可执行文件,他是一个不可以分割的整体,但是在动态链接下,一个程序被分成了若干个文件,有程序的主要部分,即可执行文件和程序所依赖的共享对象。很多时候我们也把这些部分称为模块,即动态链接下的可执行文件和共享对象都可以看做是程序的一个模块。

动态链接程序运行时地址空间分布

$ ./file &
[1] 748098
$ Hello World!
cat /proc/748098/maps
# 可执行文件虚拟空间映射
561c05d55000-561c05d56000 r--p 00000000 fc:01 1058139  /root/file
# 堆区虚拟空间映射
561c07aaa000-561c07acb000 rw-p 00000000 00:00 0        [heap]
# 动态链接器虚拟空间映射
7fd564277000-7fd564299000 r--p 00000000 fc:01 1705537  /usr/lib/x86_64-linux-gnu/libc-2.31.so
# 栈区虚拟空间映射
7ffcb0418000-7ffcb0439000 rw-p 00000000 00:00 0        [stack]

此处有必要提一句,共享对象的最终装载地址在编译时是不确定的。

地址无关代码

如何确定共享对象在进程虚拟地址空间中的位置?我们是否可以让共享对象在任意地址加载?或者说,共享对象在编译时不能假设自己在进程虚拟地址空间中的位置。

静态链接是链接时进行重定位(链接器是在多个模块合并成一个模块的时候进行重定位并生成重定位表,此时生成的是一个可执行文件),动态链接是在装载时进行重定位(可执行文件装载进内存)

地址无关代码技术:指令部分保持不变,需要进行修改的部分和数据部分(数据段)放在一起。

地址无关代码主要有以下四个方式

模块内部的函数调用,跳转等

因为被调用的函数与调用者都处于同一个模块,所以这种情况不需要进行重定位,不作概述

模块内部的数据访问

与上同理

模块外部的数据访问

因为模块间的数据访问模板地址要等到装载时才能决定,所以根据地址无关性,把跟地址相关的部分放到数据段里面。

ELF通常的做法是在数据段里面建立一个指向这些变量的指针数组(里面存放的是指针),也被称为全局偏移表(GOT,global offset table,当代码需要引用这个全局变量时,可以通过GOT中相对应的项间接引用)
链接器在装载模块的时候会查找每个变量的地址,然后填充GOT中的各个项,以确保每个指针所执行的地址正确

查看GOT表的位置

$ objdump -h file

.got   00000050  0000000000003fb0  0000000000003fb0  00002fb0  2**3
        CONTENTS, ALLOC, LOAD, DATA

查看需要重定位的项

$ objdump -R file

DYNAMIC RELOCATION RECORDS
OFFSET           TYPE              VALUE 
0000000000003fc8 R_X86_64_JUMP_SLOT  puts@GLIBC_2.2.5
0000000000003fd0 R_X86_64_JUMP_SLOT  sleep@GLIBC_2.2.5

模块外部的函数调用,跳转等

对于模块间的跳转和调用,也可以采用GOT表的项保存目标函数的地址来实现,当模块需要调用目标函数时,可以通过GOT中的项进行间接跳转。

注意静态链接是在编译可执行文件的时候形成VMA的时候就将虚拟内存地址放入了相对的地址,是在链接的时候形成的。

如何区分一个DSO是不是PIC

readelf -d xxx.so | grep TEXTREL

共享模块的全局变量问题

当一个模块引用了一个定义在共享对象的全局变量的时候,比如一个共享对象定义了一个全局对象global,而模块module.c中是这么引用的

extern int global;

int foo() {
	global = 1;
}

则可以通过所有的使用这个变量的指令都执行位于可执行文件中的那个副本,ELF共享库在编译时,默认都把定义在模块内部的全局变量当做定义在其他模块的全局变量,也就是通过GOT表进行访问。

当共享模块被装载时,如果某个全局变量在可执行文件中拥有副本,那么动态链接器就会把GOT中的相应地址指向该副本,这样该变量在运行时实际上就只有一个实例(如果变量在共享模块中被初始化,那么动态链接器还需要将该初始化值赋值到程序主模块的变量副本,如果该全局变量在程序主模块中没有副本,那么GOT中相应的地址就指向模块内部的该变量副本)

和线程私有存储有点类似(thread local storage)

延迟绑定(PLT)

动态链接的缺点:

  • 动态链接对于模块的调用以及全局和静态的数据访问都要进行GOT定位
  • 在程序加载的时候,动态链接器会装载所有的共享对象,然后进行符号查找地址重定位等工作。

延迟绑定:在动态链接的时,程序模块之间包含了大量的阿含糊引用,所以程序在刚开始执行前,必然会消耗大量的时候去重定位,但是有时候有些函数程序几乎不会用到,比如错误处理函数,这很浪费!因此ELF采用了延迟绑定,基本的是

当函数第一次被用到时才进行绑定(符号查找、重定位等)。
如果没有用到则不进行绑定,此时程序开始执行的时候,模块间的函数调用都没有进行绑定,而是需要用到的时才由动态链接器进行绑定,大大加快了程序的启动速度。

ELF使用PLT(procedure linkage table)的方式来实现,在ld-xxx-xxx.so链接器调用函数_dl_runtime_resovlve(module, function)来实现他,这个函数保存在PLT表中,所以可以直接调用,其中module指的是共享对象的名称,function值得是共享对象中的某个函数。

PLT在直接通过GOT找到响应的项增加了一层跳转,调用函数并不直接通过GOT跳转,而是通过一个PLT的项的结构进行跳转,每个外部函数在PLT中都有一个相应的项,如function()函数叫做function@plt

实现过程

function@plt:
	# 如果GOT已经存在该函数地址,则直接跳转,否则该项中的地址是该指令的下一条指令,即push n
	jmp *(function@GOT)
	# 这个数字是该符号在.rel.plt中的下标
	push n
	# 模块id入栈
	push moduleID
	jump _dl_runtime_resolve

来看看源码的反汇编

0000000000001169 <main>:
	...
	1178:	e8 e3 fe ff ff       	callq  1060 <puts@plt>
	...
0000000000001060 <puts@plt>:
    1064:	f2 ff 25 5d 2f 00 00 	bnd jmpq *0x2f5d(%rip)        
    106b:	0f 1f 44 00 00       	nopl   0x0(%rax,%rax,1)

一般情况下,GOT表被分成了.got.got.plt.got表用来保存全局变量引用的的地址,.got.plt用来保存函数引用的地址。

PLT表的前三项分别是

  • .dynamic段的地址
  • 本模块的ID
  • _dl_runtime_resolve()的地址。

PLTELF文件中以独立的段存放,段名通常叫做.plt,因为他本身是一些地址无关代码,所以可以跟代码段等一起合并成同一个可读可执行的segmane被装载入内存。

动态链接相关结构

在动态链接情况下,操作系统在映射完可执行文件之后,会先启动一个动态链接器(dynamic linker),操作系统同样以映射的方式将他加载到进程的地址空间中,然后将控制权交给动态链接器的入口地址,当链接工作完成后,动态链接器将控制权转交给可执行文件的入口地址。

.interp段

.interp段中保存的就是一个字符串,这个字符串就是可执行文件所需要动态链接器的路径。

查看.interp

Contents of section .interp:
 0318 2f6c6962 36342f6c 642d6c69 6e75782d  /lib64/ld-linux-
 0328 7838362d 36342e73 6f2e3200           x86-64.so.2.

查看使用动态链接器类型

readelf -l a.out | grep interpreter

[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]

.dynamic段

保存了动态链接器所需要的基本信息,比如依赖于哪些共享对象,动态符号表的位置,动态链接重定位表的位置等等。

/* Dynamic section entry.  */

typedef struct
{
  Elf32_Sword   d_tag;                  /* Dynamic entry type */
  union
    {
      Elf32_Word d_val;                 /* Integer value */
      Elf32_Addr d_ptr;                 /* Address value */
    } d_un;
} Elf32_Dyn;

查看.dynamic段信息

$ readelf -d file

Dynamic section at offset 0x2dc0 contains 27 entries:
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x000000000000000c (INIT)               0x1000
 0x000000000000000d (FINI)               0x1208
 0x0000000000000019 (INIT_ARRAY)         0x3db0
 0x000000000000001b (INIT_ARRAYSZ)       8 (bytes)
 0x000000000000001a (FINI_ARRAY)         0x3db8
 0x000000000000001c (FINI_ARRAYSZ)       8 (bytes)
 0x000000006ffffef5 (GNU_HASH)           0x3a0
 0x0000000000000005 (STRTAB)             0x488
 0x0000000000000006 (SYMTAB)             0x3c8
 0x000000000000000a (STRSZ)              136 (bytes)
 0x000000000000000b (SYMENT)             24 (bytes)
 0x0000000000000015 (DEBUG)              0x0
 0x0000000000000003 (PLTGOT)             0x3fb0
 0x0000000000000002 (PLTRELSZ)           48 (bytes)
 0x0000000000000014 (PLTREL)             RELA
 0x0000000000000017 (JMPREL)             0x600
 0x0000000000000007 (RELA)               0x540
 0x0000000000000008 (RELASZ)             192 (bytes)
 0x0000000000000009 (RELAENT)            24 (bytes)
 0x000000000000001e (FLAGS)              BIND_NOW
 0x000000006ffffffb (FLAGS_1)            Flags: NOW PIE
 0x000000006ffffffe (VERNEED)            0x520
 0x000000006fffffff (VERNEEDNUM)         1
 0x000000006ffffff0 (VERSYM)             0x510
 0x000000006ffffff9 (RELACOUNT)          3
 0x0000000000000000 (NULL)               0x0

查看一个程序依赖于哪些模块或共享库

$ ldd file
	# 系统调用库
	linux-vdso.so.1 (0x00007ffd11337000)
	# GNU C库
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fcc4edc3000)
	# 动态链接库
	/lib64/ld-linux-x86-64.so.2 (0x00007fcc4efc4000)

动态符号表

动态符号表,保存了与动态链接相关的符号,对于模块内部的符号,比如模块私有变量则不保存,这些一般保存在.symtab中。所以一般情况下都有这两个表。

动态符号也需要一些辅助表,比如保存符号名的字符串表,如动态符号字符串表.dynstr;由于动态链接下,我们需要在程序运行时查找符号,为了加快符号查找的过程,往往还是辅助的符号哈希表.hash

查看动态符号表和哈希表

Symbol table '.symtab' contains 66 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
    # puts函数,是一个UND(undefined)状态
    49: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND puts@@GLIBC_2.2.5
    61: 0000000000001169    37 FUNC    GLOBAL DEFAULT   16 main
    64: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND sleep@@GLIBC_2.2.5

Symbol table of '.gnu.hash' for image:
  Num Buc:    Value          Size   Type   Bind Vis      Ndx Name
    7   0: 0000000000000000     0 FUNC    WEAK   DEFAULT UND __cxa_finalize

动态链接重定位表

对于PIC技术的可执行文件或共享对象来说,虽然他们的代码不需要重定位(因为地址无关),但是数据段还包含了绝对地址的引用,因为代码段中绝对地址相关的部分被分离了出来(PIC地址无关技术),变成了GOT,而GOT实际上是数据段的一部分。

主要有两个段,一个是.rel.dyn是对数据引用的修正,他所修正的位置位于.got,一个是.rel.plt是对函数引用的修正,他所修正的位置位于.got.plt

查看重定位表

$ readelf -r file

Relocation section '.rela.dyn' at offset 0x540 contains 8 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000003db0  000000000008 R_X86_64_RELATIVE                    1160
000000003db8  000000000008 R_X86_64_RELATIVE                    1120
000000004008  000000000008 R_X86_64_RELATIVE                    4008
000000003fd8  000100000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTMClone + 0
000000003fe0  000300000006 R_X86_64_GLOB_DAT 0000000000000000 __libc_start_main@GLIBC_2.2.5 + 0
000000003fe8  000400000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
000000003ff0  000500000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCloneTa + 0
000000003ff8  000700000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize@GLIBC_2.2.5 + 0

Relocation section '.rela.plt' at offset 0x600 contains 2 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
# 两个共享对象函数引用
000000003fc8  000200000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0
000000003fd0  000600000007 R_X86_64_JUMP_SLO 0000000000000000 sleep@GLIBC_2.2.5 + 0

当动态链接器需要进行重定位时,他首先查找printf的位置,printf位于libc-2.6.1.so,假设链接器在全局符号表里面找到printf的地址为0x08801234,那么链接器就会将这个地址填入到.got.plt中偏移为000000003fc8的位置中去,从而实现了地址的重定位,例如在前面调用puts@plt中反汇编代码为

0000000000001060 <puts@plt>:
    1064:	f2 ff 25 5d 2f 00 00 	bnd jmpq *0x2f5d(%rip) # 3fc8 <puts@GLIBC_2.2.5>      
    106b:	0f 1f 44 00 00       	nopl   0x0(%rax,%rax,1)

动态链接时进程堆栈初始化信息

进程在初始化的时候,堆栈里面保存了动态链接器所需要的一些辅助信息数组(auxiliary vector)

typedef struct
{
  uint32_t a_type;              /* Entry type */
  union
    {
      uint32_t a_val;           /* Integer value */
      /* We use to have pointer elements added here.  We cannot do that,
         though, since it does not work when using 32-bit definitions
         on 64-bit platforms and vice versa.  */
    } a_un;
} Elf32_auxv_t;

其他信息

特殊符号

当使用ld作为链接器来连接生成可执行文件时,他会为完美定义很多特殊的符号,这些符号在我们的程序中并没有定义,但是外面可以直接声明并且引用他,我们称之为特殊符号,其实这些符号是定义在ld脚本文件中的,有几个常见的

  • __executable_start:该符号为程序起始地址,不是入口地址,是程序的最开始地址
  • __etext_etextetext:代码段结束地址
  • _edataedata:数据段结束地址
  • _endend:程序结束地址

以上部分都为程序被装载时的虚拟地址

ld默认链接脚本中

PROVIDE (__executable_start = SEGMENT_START("text-segment", 0x400000)); . = SEGMENT_START("text-segment", 0x400000) + SIZEOF_HEADERS;
PROVIDE (__etext = .);
PROVIDE (_etext = .);
PROVIDE (etext = .);
_edata = .; PROVIDE (edata = .);
_end = .; PROVIDE (end = .);

在某些情况下, 一个符号被引用到的时候只在连接脚本中定义,而不在任何一个被连接进来的目标文件中定义

验证并打印地址

#include <stdio.h>

extern char __executable_start[];
extern char __etext[];
extern char _edata[];
extern char _end[];

int main(int argc, char *argv[]) {
    printf("%X.\n", __executable_start);
    printf("%X.\n", __etext);
    printf("%X.\n", _edata);
    printf("%X.\n", _end);
}

/*
	B0926000.
	B0927255.
	B092A010.
	B092A018.
*/

动态链接器实现步骤

动态链接器自举

自举(bootstrap):动态链接器本身不可以依赖其他任何共享对象;动态链接器本身的所需要的全局和静态变量的重定位工作由他本身完成。

动态链接器入口地址即是自举代码的入口,当操作系统将进程控制权交给动态链接器的时,动态链接器的自举代码开始执行,自举代码首先首先找到自己的GOT表,而GOT表的第一个地址是.dynamic段,通过该信息,自举代码便可以获得动态链接器本身的重定位表和符号表等,从而得到动态链接器的重定位入口,先将他们全局重定位。

装载共享对象

完成自举之后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表当中,成为全局符号表(global symbol table),然后链接器开始寻找可执行文件所依赖的共享对象

此处执行的算法是BFS。

重定位和初始化

当上面的步骤完成之后,链接器便开始重新遍历可执行文件和每个共享对象的重定位表,将页面的GOTPLT中的每个需要重定位的位置进行修正。

如果某个共享对象有.init段,那么链接器执行该段,实现共享对象特有的初始化过程,当执行.final段时,进程会退出

Linux动态链接器是什么

Linux链接器本身也是一个共享对象,是个特殊的共享对象,是个可执行的程序。

显示运行时链接

这一章不是必要的,但又是必要的,很矛盾就是说。

支持动态链接的系统往往都支持一种更加灵活的模块架子啊方式,即显示运行时链接,也叫做运行时加载,也就是让程序自己在运行时控制加载指定的模块,并且在不需要该模块时将其卸载,这种共享对象被佳作动态装载库(dynamic loading library)。

共享对象和动态装载库其实没什么区别,只是角度不同罢了。

他和之前的区别是,动态库的加载通过一系列由动态连接器提供的API,具有有四个函数:dlopen打开动态库,dlsym查找符号,dlerror错误处理,dlclose关闭动态库。

dlopen()

该函数用来打开一个动态库,并将其加载到进程的地址空间,完成初始化过程

// filename 被加载动态库的路径
// flag 符号的解析方式
void *dlopen(const char *filename, int flags);

dlsym()

通过该函数找到所需要的符号

// handle 句柄
// symbol 函数名
void *dlsym(void *handle, const char *symbol);

dlerror()

每次调用dlopen()dlsym()dlclose()之后,都可以调用dlerror()来判断上一次调用是否成功

char *dlerror(void);

dlclose()

将一个已经加载的模块卸载,系统会维护一个加载引用计数器,每次使用dlopen()加载某模块时,相应的计数器加一,每次使用dlclose()卸载某个模块时,相应计数器减一。

当减为0的时候,模块才真正被卸载,卸载的过程相反,先执行.final段的代码,然后将相应的符号从符号表中取出,取消进程空间跟模块和映射关系,然后关闭模块文件。

如下

#include <stdio.h>
#include <dlfcn.h>

int main(int argc, char *argv[]) {
    void *handle;
    double (*func)(double);
    char *error;

    handle = dlopen(argv[1], RTLD_NOW);
    if (handle == NULL) {
        printf("open library %s error: %s.\n", argv[1], dlerror());
        return -1;
    }
    func = dlsym(handle, "sin");
    if ((error = dlerror()) != NULL) {
        printf("symbal sin not found: %s.\n", error);
        return -1;
        // goto exit_runso;
    }
    printf("%f.\n", func(3.1415926 / 2));
    // exit_runso;
    dlclose(handle);
}

共享库的创建和安装

为什么会产生共享库?大量的程序开始使用动态链接机制,导致系统里面存在数量极为庞大的共享对象,如果没有很好的方法将这些共享对象组织起来,整个系统的共享对象文件则会散落在各个目录下,造成巨大困难,所以操作系统一般会对共享对象的目录组织和使用方法有一定的规则,即共享库。

共享库的创建

与创建共享对象过程一致,需要两个参数-shared表示输出结果是共享库类型的,-fPIC表示是地址无关代码技术来产生输出文件,

构造和析构函数

很多时候在共享库被加载的时候能够进行一些初始化工作,比如打开文件,网络连接等等,使得共享库里面的函数接口能够正常工作,GCC提供了一种共享库的构造函数,只要在函数声明时加上__attribute__((constructor))的属性,即指定该函数为共享库构造函数,拥有这种属性的函数会在共享库加载时被执行,即在程序的main函数之前执行。

如果我们使用dlopen()打开共享库,共享库构造函数会在dlopen()返回之前被执行。

与共享库构造函数相对于的是析构函数__attribute__((destructor))的属性,这种函数会在main()函数执行完毕之后执行(或者是程序调用exit()时执行)

如果共享库是运行时加载的,那么使用dlclose()来卸载共享库时,析构函数将会在他返回之前执行。

void __attribute__((constructor)) init_function(void);
void __attribute__((constructor)) fini_function(void);

总结

写了那么多,知识点貌似有点分散,但是每一个部分都有他的作用,先来口语简略的说一下不在显示运行时链接的程序执行过程(显示链接无法就是把主动权交给了程序,被动转为 了主动,差别不大)。

源程序(.c)经过预处理、编译(.s)、汇编(.o)形成对象文件(或者叫目标文件),接着进行链接(在.interp段中指定链接器地址,生成重定位表.rel.dyn.rel.plt,生成字符表。symtab,生成.got(直接存放绝对地址)和.got.plt段(存放假地址),PLT存放所有函数的假地址,初始化堆栈信息,此时都是虚存),执行文件,链接器进行自举和重定位,然后进入进程地址空间,读取进程堆栈信息,合并一个全局符号表,因为是PIC技术,所以暂时不加载函数引用。当进行函数引用的时候,寻找相关函数位置,进程代码进入到GOT表中寻找,无法找到,则跳转到PLT表的地址,PLT通过dl_runtime_close()函数找到地址并放入下标为n的位置,并填入GOT中,代码直接从GOT表中即可得到该函数地址,并执行该函数。

完美!

后续开始看Linux-0.12源码版本

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

妖怪喜欢风

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值