写点什么

深入了解之链接器与加载器

作者:邱学喆
  • 2022 年 9 月 24 日
    广东
  • 本文字数:6295 字

    阅读完需:约 21 分钟

深入了解之链接器与加载器

一. 概述

在我们开发应用程序时,往往都是基于 IDE 来编程,都是通过 IDE 上某个按钮去执行编译、链接、加载等动作,对其按钮后面的原理往往不是很清楚。

借助这次机会,对链接以及加载的原理进行梳理以及总结,加深其理解。

二. ELF

ELF 格式的可执行文件是 linux 系列的程序格式。对其理解,对链接以及加载动作有事半功倍的效果。下面有关使用 readelf 和 objdump 的例子,都是基于以下代码:

//pointer.c#include<stdio.h>#include<stdlib.h>void main(){    int i = 1;    int * j = &i;    int **k = &j;    printf("i=%ld\n",i);    printf( "j.address = %ld, j.value=%ld\n",j,*j);    printf("k.address.1 = %ld, k.address.2=%ld, k.value = %ld\n",k,*k,**k);    exit(0);}
复制代码

2.1 结构总览

2.1.1 ELF Header

这个结构对整个 ELF 文件的描述。

  • e_type——ELF 文件主要分为可执行文件、重定向文件、共享文件(动态库),核心文件。其值如下:

  • e_entry——程序的入口。当程序加载到操作系统时,操作系统会为其创建一个进程,同时会初始化相关数据结构,这时 CPU 控制权还是在操作系统。当加载完毕后,会跳转到执行 e_entry 指定的地址后,控制权就会交给这个进程,就会执行 e_entry 地址的相关代码;

  • program——程序头部,之所以叫程序,是因为其区域数据是给加载器去加载解析使用。通过着三个 e_phoff、e_phentsize、e_phnum 字段所存放偏移位置以及数量等信息,可以间接定位到程序段的相关信息;

  • section——一个程序文件,有各种各样的数据信息,为了更好的管理,就需要区分。所涉及到字段 e_shoff、e_shentsize、e_shnum。

通过下面的图,可以看到 program header 与 section header 的区别:

看一下真实的头部信息,如下图:

2.1.2 Program Header

这块信息是给加载器加载解析使用;其结构如下:

  • p_type——程序段的类型,当加载器解析类型时,会去做对应的操作;该值如下列表;

  • PT_LOAD——当加载器加载程序时,会将该段指定的数据拷贝到系统内存中去

  • PT_INTERP——存储的是动态链接器的程序。也就是说,当加载器加载该段时,如果存在动态链接器,则全权交由动态链接器去负责处理,加载器将不再参与;

  • PT_DYNAMIC——指向类型为 SHT_DYNAMIC 区的数据信息。这块的数据是给动态链接器所加载解析使用。

看一下真实例子,如下图:

2.1.3 Section Header

区头部结构中的每一项是对区的简单描述,如下图:

  • sh_type——区的类型,指明这个区的数据是什么类型的数据,如下图。我们这里重点关注以下类型:

  • 代码区、数据区 ——> SHT_PROGBITS(代码区、数据区)

  • 符号区——> SHT_SYMTAB、SHT_DYNSYM

  • 字符串区——>SHT_STRTAB,存放各种字符串值,其他区的名称所存储的都是字符串区的索引位置。

  • 重定位区——>SHT_REL、SHT_RELA

  • hash 区——>SHT_HASH,是为了快速定位一个符号所对应的地址信息;

  • 动态区——>SHT_DYNAMIC

真实例子,如下图:

2.1.4 Section

2.1.4.1 字符串区

具体查看其用例数据:

2.1.4.2 {动态}符号表

  • st_info——额外的信息,这里所包含的信息由符号的类型以及作用域。具体如下图

  • type——符号类型,低四位;

  • bind——作用域,高四位;

  • st_shndx,这个符号关联的其他区索引,常规来说是数字型,由几个是例外的,如下图;都是表明未关联到对应的区信息;

例子:

2.1.4.3 重定位表

这个重定位表是给链接过程和动态链接过程所使用;来修改引用符号对应的地址;

  • r_offset——引用这个符号的地址;当连接过程或者动态链接过程中,会修改这个地址上的信息;

  • r_info——这里面包含了两大部分,一部分是符号索引信息,另外一部分是该符号的类型,来表明其地址的计算规则,规则如下。




翻一下上面关键字的所代表的意思(如有错误,请指出):

  • S——符号的所在的位置信息

  • A——addend 的值

  • B——基地址,指的是共享库加载到内存的基地址。

其余的还没理清楚,到后续遇到复杂的再进行介绍;

接下来针对例子解读,如下图:

从上面 info 列,我们可以看到 printf 以及 exit 的符号索引信息分别为 1 和 2;接着我去查看动态符号表位置信息,是一一对应的:

那么前面的值,601018 和 601020 是全局偏移量表的地址信息,我们通过 objdump -d pointer 来看到 plt 区的相关代码指令:


额外说明一下:这块是动态链接器做的处理,当执行一次后,会将 printf 和 exit 函数的入口地址,填充到 601018 和 601020 地址处;第二次调用时,就无需执行后面的指令:pushq $0x*; jmpq 4002d0;

2.1.4.4 动态区

这个动态区的信息所存放的是其他区域的开始位置信息,是用于动态链接器去解析和使用;

  • DT_HASH——哈希区

  • DT_PLTGOT——全局偏移量区

  • DT_NEEDED——依赖,用于表明这个程序或者共享库所依赖的其他库,我们通过 ldd 命令来查看依赖情况,其基本原理是读取这块区域的信息;

  • DT_STRTAB——字符串区

  • DT_SYMTAB——动态符号区

  • DT_RELA——重定向区

  • DT_RELA——重定向区

例子如下:

2.1.4.5 过程链接表

如果一个目标模块调用定义在共享库中的任何函数,那么在 PLT 区(Procedure Linkage Table 过程链接表)中都会有对应一条记录信息。PLT 是一个数组项,每个条目是 16 字节代码;

在例子上,我们调用共享库的 printf 函数,我们查看其内容:

2.1.4.6 全局偏移量表

如果一个目标模块调用定义在共享库中的任何函数,同样在 GOT 区(Global Offset Table,全局偏移量表)中都会有一条记录信息。GOT 是一个数组项,每个条目是 8 字节地址;

2.1.4.7 哈希区

由于其结构没有在规范文件中提现,由各个实现方来指定。但基本不会绕过 hash 表结构的数据结构原理;这里补一句,之所以存在这块区域的信息,是为了能快速定位指定符号的所在的位置信息。

这里不再阐述;

2.2 梳理总结

一个 ELF 文件,首先需要一个头部结构来介绍 ELF 的整体信息,算是一个概览;也就是 ELF Header。

如果是可执行文件,就一定要有 Program Header 头部结构,该结构的相关信息是给加载器以及动态链接器是使用;其中类型为 PT_LOAD 的,则是加载器将其指定的数据信息加载到虚拟内存;而类型 PT_INTERP 是指定动态链接器名称,加载器去加载这个动态链接器,接着控制权交由动态链接器去操作,而类型 PT_DYNAMIC 的数据信息是给动态链接器使用;

一个程序有很多形式多样的数据,ELF 将其分类,例如代码区和数据区,字符串区、符号区、重定位区等。

三. 链接器 &动态链接器

链接器主要的工作职责是将各个目标文件按照脚本指定的规则合并各个区域数据,如下图:

同时也做重定位计算等工作;

而一些库,很多应用程序都依赖,如果每个应用程序有拷贝一份,那么每个应用程序在加载到内存时,将会浪费大量的内存空间,特此提出了一个共享库,也就是各个应用程序共用一份数据以及代码。而这个共享库就需要动态链接器去达到该效果;由此可以推论出动态链接器主要的工作是运行期间计算函数(符号)的真实内存地址,从而准确的跳转到指定的函数运行该逻辑;

额外的话题,由于 binutils 开源库的源代码较为难解读,短时间内不好理解其实现过程。但我们可以通过实例来验证我们的猜想。

为了更好的理解 ELF 以及链接器的原理,编写了一个较为简单的程序。代码如下:

  • 动态库, share.c

#include<stdio.h>static int share_in_variable = 101;int share_out_variable = 102 ;int share_bss_variable;static void share_in_method();void share_out_method(){    printf("================share===============\n");    printf("share_out_method....\n");    printf("share_out_variable=%d\n",share_out_variable);    share_in_method();    printf("share_bss_variable=%d\n",share_bss_variable);    printf("==============share-end============\n");}static void share_in_method(){    printf("share_in_method.....\n");    printf("share_in_varaible=%d\n",share_in_variable);}
复制代码
  • 静态库, static_1.c 和 static_2.c

//static_1.c #include<stdio.h>
static int static_in_variable_1 = 201;int static_out_variable_1 = 202;int static_bss_variable_1 ;static void static_in_method_1();void static_out_method_1(){ printf("===================static_1===============\n"); printf("static out method 1......\n"); printf("static_out_variable_1=%d\n",static_out_variable_1); static_in_method_1(); printf("static_bss_variable_1=%d\n",static_bss_variable_1); printf("==================static_1-end=================\n");}
static void static_in_method_1(){ printf("static in method......"); printf("static_in_variable_1=%d\n",static_in_variable_1);}//static_2.c#include<stdio.h>
static int static_in_variable_2 = 401;int static_out_variable_2 = 402;int static_bss_variable_2 ;static void static_in_method_2();void static_out_method_2(){ printf("=============================static_2================\n"); printf("static out method 2......\n"); printf("static_out_variable_2=%d\n",static_out_variable_2); static_in_method_2(); printf("static_bss_variable_1=%d\n",static_bss_variable_2); printf("===========================static_2-end============\n");}
static void static_in_method_2(){ printf("static in method......"); printf("static_in_variable_2=%d\n",static_in_variable_2);}
复制代码
  • 重定向目标文件 rel.c

#include<stdio.h>
static int rel_in_variable = 301;int rel_out_variable = 302;int rel_bss_variable;static void rel_in_method();void rel_out_method(){ printf("================rel=================\n"); printf("rel out method...."); printf("rel_out_variable=%d\n",rel_out_variable); rel_in_method(); printf("rel_bss_variable=%d\n",rel_bss_variable); printf("===================rel-end===========\n");}static void rel_in_method(){ printf("rel in method....."); printf("rel_in_variable=%d\n",rel_in_variable);}
复制代码
  • 主程序 main.c

extern void share_out_method(void);extern int share_bss_variable;
extern void static_out_method_1(void);extern int static_bss_variable_1;
extern void static_out_method_2(void);extern int static_bss_variable_2;
extern void rel_out_method(void);extern int rel_bss_variable;
void main(){ share_bss_variable = 10000; static_bss_variable_1 = 20000; static_bss_variable_2 = 30000; rel_bss_variable = 400000; share_out_method(); static_out_method_1(); static_out_method_2(); rel_out_method();}
复制代码

编译步骤:

//动态库gcc -fPIC -shared share.c -o share.so//静态库编译:gcc -c static_1.c static_2.car rc static.a static_1.o static_2.o//可重定向文件gcc -Og -S rel.c as rel.s -o rel.o//目标文件main.ogcc -c main.c -o main.o//编译ld -o main   main.o  ./rel.o  ./share.so \	-lstatic \	--dynamic-linker /lib64/ld-linux-x86-64.so.2  \	-L /home/bybank/c_test/language/ld \	/usr/lib64/crt1.o  \	/usr/lib64/crti.o  \	-lc \	/usr/lib/gcc/x86_64-redhat-linux/4.8.5/crtbegin.o   \	/usr/lib/gcc/x86_64-redhat-linux/4.8.5/crtend.o \	/usr/lib64/crtn.o 
复制代码

3.1 区合并

我们重点查看符号区以及代码区的相关数据;

  • 符号区


查看最终的可执行文件:readelf -s main

  • 代码区



3.2 符号解析

上面的【区合并】小节,可以看到符号解析的相关信息了;

符号解析,目的是为了将符号位置信息符号做绑定关系,从而为后续的重定位等操作搭下基础。

所以,我们直接看到符号区的信息,就是符号解析最终的结果数据;

3.3 重定位

把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有这些符号的应用,使得他们指向这个内存地址;

我们可以查看 main.o 文件中重定位信息,如下图:

对应的代码处,如下图:

我们以 static_out_method_1 符号进行讲解,其是如何修改处的地址信息的。

从上面的图我们可以得到如下信息

offset=32, addend=-4, type=R_X86_64_PC32
复制代码

我们也可以通过下面的图,也可以看到一些数据信息;

当区合并完成后,我们查找 static_out_method_1 符号的内存地址:0x40066e

以及.text 区的起始位置信息,如下:0x4005a0

通过计算,得到 98,计算过程如下;

//static_out_method_1reloc.offset = 32;reloc.symbol = static_out_method_1reloc.addend = -4
ADDR(main) = 0x0x4005a0ADDR(static_out_method_1) = 0x40066e
开始计算:refaddr = ADDR(main) + reloc.offset = 0x0x4005a0 + 32 = 0x4005d2*refptr = ADDR(static_out_method_1) + reloc.addend - refaddr = 0x40066e - 4 - 0x4005d2 = 98
复制代码

就去 0x4005d2 位置信息去替换对应的位置信息 98。

所以,我们通过 objdump 命令,去验证一下:

3.4 延迟绑定

这个小节是针对动态链接器所使用的技术;由于共享库加载内存的位置信息是随机的,也就是意味着在运行期间计算绑定这个符号所对应的内存位置信息;通过延迟绑定技术来实现;

其通过 PLT 区数据以及 GOT 区数据实现动态绑定绑定;

  • 过程链接表数据


  • 重定向项数据

  • 全局偏移量表信息

通过伪代码,来介绍其原理:

long GOT[7] = 0x400596;//share_out_method//原始地址,经过延迟解析后,得到该符号的内存地址;===============================goto  share_out_method;===============================0x400596:	call  dynamic_linker (paramter: 4),======================
/** * 入参.rela.plt区的数组角标 */dynamin_linker: //查找该符号所在的共享库 //加载该共享库 //得到该符号的内存地址 //修改GOT[7]地址上的内存地址 //跳转到符号的内存地址
复制代码

简而言之,GOT 的存放的该符号的入口地址;

首次入口地址:是指向 PLT 链接过程表中的动态链接器地址;

1.其去找到该符号的入口地址;

2.然后修改 GOT 区域的该符号的入口地址;

3.然后直接跳转到该符号的入口地址;

后续的入口地址:符号的真正的入口地址;

四. 加载器

当运行可执行文件时,是通过操作系统层面上的加载器去加载可执行文件。在 linux 系统中,主要的入口是 exec.c 文件中定义的 do_execve(系统调用),感兴趣的可以在网络上去了解其技术细节。

由于没有去查阅其源代码实现细节,但从其相关的数据结构中可以大体猜测出其实现原理;

数据结构如下:

  • struct linux_binprm 也就是 ELF 文件在内存的映像结构

  • struct task_struct 进程对象

  • struct mm_struct 内存对象

在介绍前,我们首先要了解进程的地址空间布局,如下图:

地址空间布局,基本上可以在 task_struct 结构中找到相关字段,如下图:

加载器加载 ELF 可执行文件时,首先会去解析 program header 头部信息,如 PT_LOAD 段,那么将会调用 mmap 进行映射,也就意味着不会一下子将所有的代码段以及数据段的数据加载到内存,而是借助”按需分页“,也就是通过缺页中断来实现懒加载。这样子可以有效的减缓内存压力。

当解析到有 PT_INTER 时,就去加载链接器,然后交由动态链接器去加载该程序。


大体介绍 do_execve 其调用链,如下图:

重点是红色圈出来的过程,其中 load_elf_binary 有很多实现,可以重点查阅 binfmt_elf.c 文件中的 load_elf_library 函数;这里不再介绍;

拓展章节,可以去阅读《深入 Linux 内核架构》中的第 4 章节【进程虚拟内存】。

五. 总结

通过对 ELF 文件结构的解析,以及链接器和加载器的了解,对程序的概念有进一步的认知。待后续有时间,在继续拓展深入的解读其技术细节;

引用

  1. ELF_Format.pdf


发布于: 2022 年 09 月 24 日阅读数: 10
用户头像

邱学喆

关注

签约作者;计算机原理的深度解读,源码分析 2018.08.26 加入

在IT领域keep Learning。要知其然,也要知其所以然。原理的爱好,源码的阅读。输出我对原理以及源码解读的理解。个人的仓库:https://gitee.com/Michael_Chan

评论

发布
暂无评论
深入了解之链接器与加载器_加载器_邱学喆_InfoQ写作社区