ELF加载器的原理与实现

本文探讨了ELF加载器的重要性,它如何通过隔离应用与内核服务、提供统一接口和动态加载功能,优化资源管理和程序灵活性。涵盖了静态库与动态库的区别,以及ELF的链接、section和segment分析,以及加载过程中的静态库加载、动态库加载和链接器原理。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

ELF加载器

为什么需要ELF

隔离应用服务和内核服务
有了ELF,我们的应用程序都可以通过编译成ELF的方式从外部加载,系统内核部分只提供关键的内核服务(内存管理/中断/调度/IPC)和系统服务(文件/网络),用户服务程序都可以通过ELF的方式进行加载。同时库的同步更新可以在不更新内核的情况进行同步更新。

提供统一编程接口
我们通过提供统一的POSIX标准库,来为用户提供标准的编程接口,方便了应用程序开发人员进行标准开发

应用程序可以动态加载和卸载
通过ELF,用户可以动态的加载和卸载相关服务,增强了应用程序加载的灵活性

ELF加载方式

静态库-独立exec

我们把所有相关源代码进行编译,链接,最后生成可执行文件,这个文件不依赖于其他模块,是一个完整的可执行单元。
操作系统处理这类文件的流程是直接将elf的所有段拷贝到内存中,然后将PC指针指向entry就可以运行了,什么场景下会这样使用呢?

  • 简单的应用程序,不依赖于其他模块
  • 依赖于其他模块,其他模块以静态库的方式链接到应用程序

优点:程序是一个完整的可执行单元,不需要操作系统去进行重定向操作
缺点:每一个依赖于某个静态库的应用程序都会包含完整的静态库,这样每一个应用程序都会占用磁盘空间和内存空间,如果这样的应用程序有成千上万,那资源浪费很大

动态库-非独立exec

非独立exec我们这里只讲依赖于动态库的应用程序,一般情况我们在编写应用程序的时候,会用到很多库,这些库是其他工程师已经写好了,我们直接用就可以了,最典型的就是C和C++库,我们在写应用程序的时候只需要关注我们自己的业务功能就可以了。
外部库的存在方式以静态和动态2种,静态库在上面已经分析了,接下来我们着重分析动态库
如果我们的C库已经编译成了动态库,那么我们把他链接到我们的应用程序的时候,编译器只会对所需C库的符号进行分析,并把他记录到rel.dyn里,并不会真正的把动态库中的具体内容进行拷贝,所以这样编译出的应用程序就不包括动态库的内容,等到将应用程序真正加载到操作系统去运行的时候,我们操作系统中会有一个动态链接器dl去完成外部符号的链接与重定位
优点:
应用程序依赖的动态库在内存中只存在一份,当然数据段是每个应用程序私有的,这样会节约磁盘空间和内存空间
当我们要更新动态库时,只需要更新动态库文件即可,所依赖的应用程序不需要单独更新

缺点:
应用程序的启动会牺牲一部分启动时间
动态库更新需要考虑兼容性问题

静态库

编译方式

静态库将库源文件打包成.a,然后main链接到静态库,形成可执行文件exe,这里的链接实际上就是把.a的代码和数据联合main打包到exe里。

编译流程

在这里插入图片描述

编译脚本
$(CC) -MD -c $(CFLAGS)  %.c -o %.c.o
$(AR) cr libxxx.a %.c.o

动态库

编译方式

动态库以.so的方式呈现,然后main链接到动态库,形成可执行文件exe,注意这里的链接并非真正的链接,并没有把.so的代码和数据拷贝到exe里,只是标记了引用的外部符号表,所以exe的大小并没有变大。

编译流程

在这里插入图片描述

编译脚本
$(CC) -MD -c $(CFLAGS)  %.c -o %.c.o
$(CC) -shared -fPIC -nostartfiles -o libxxx.so %.c.o

ELF格式分析

一个可执行文件我们可以从两个视角去剖析:

  • 链接视图:section - 节
  • 执行试图:segment - 段

在这里插入图片描述

读取elf信息
readelf -a xxx > xxx.dump
section

一个elf文件,由多个section组成,这个可以linker.lds里指定节的名称和地址,一般来说一个elf是由多个section组成的,如下图:
在这里插入图片描述

从上图可以看出,此elf由11个段组成,其中最中要的段有:

  • text
  • rodata
  • data
  • bss

在这里插入图片描述

从section(节)的角度上,我们可以看出一个elf的基本组成,以及每个节的名称,类型,运行地址,大小,属性等。从上面的例子中我们可以得到:

  • text:代码节大小为0x55e0,运行地址为0x41000000,在文件中的偏移为0x10000(64K对齐)。text包含elf的所有指令,有些链接脚本把rodata也合并到此节
  • data:数据节大小为0x470,运行地址为0x410055e8,在文件中的偏移为0x155e8。data包含已经初始化的数据,这些数据必须像指令一样保存在文件中
  • bss:bss节大小为0x238,运行地址为0x41005a58,在文件中的偏移为0x15a58。因为bss是段是未初始化和初始化为0的节,所以在文件中实在是没有必要存储一堆0,只需要保存好bss大小,在elf加载的时候再手动清零即可,这样可以减少elf文件的空间
解析header

上面的section是我们通过readelf命令读取得到的,在elf里专门有区域来存储所有section的分布信息,我们通过header信息就可以轻松得到:
首先来看一下header结构:
在这里插入图片描述

重要的数据成员:

  • e_entry:程序的入口地址,一般为main函数的地址
  • e_phoff:程序头在文件中的偏移,用于解析segment信息
  • e_shoff:节头在文件中的偏移,用于解析section信息
  • e_phnum:程序头个数
  • e_shnum:节头个数
  • e_phentsize:一个程序头的大小,32位下默认固定为32字节
  • e_shentsize:一个节头的大小,32位下默认固定位40字节

程序头
在这里插入图片描述

节头
在这里插入图片描述

刚刚上面的section信息,我们就可以读取ELF文件的前128字节来解析ELF头部,根据section的偏移,再解析就可以得到section信息了,同样segment信息也是如此

segment

segment就是我们熟悉的段,我们一般在谈论一个程序加载的过程,我们希望用segment来描述,一个segment可以包含多个section,还是之前的那个elf文件,我们来分析一下他在段的视角下是如何组成的。
在这里插入图片描述

这个elf由2个段组成,我们只关注LOAD段,LOAD段表示此段是需要加载到内存运行的,我们可以看到LOAD段的大小分为了两部分,FileSiz和MemSiz,我们先放一下,我们先关注FileSiz,也就是LOAD段实际在文件中占用了多大空间,0x5a58,我们从section视图可以看到0x5a58刚好就是data节的结束地址。由此我们可以得到FileSiz,是text+rodata+data,我们再看MemSiz,MemSiz表示此段在内存中所占用的空间,前面我们说到bss段是不需要占用文件空间的,但是他需要占用内存空间,而且需要手动进行清0操作,所以我们可以看到MemSiz的大小为FileSiz+bss_size。再看此段的属性,为RWE说明此段可读可写可执行,包含了代码数据加BSS。

如何加载ELF

静态库加载

这里说的静态库加载指的是程序链接到静态库生成的exe文件的加载,静态库的加载相对动态库要简单很多,因为全部工作已经由编译器的连接器ld给我们完成了,我们要做的只是把ELF文件map到相应运行地址空间,然后把PC指向entry即可。

  1. map我们可以理解为copy,因为每一个elf都具有相同运行的地址空间,这个可以通过链接脚本指定,上面的elf的运行地址空间从0x41000000开始,很明显这个地址是个虚拟地址,也就是说,我们要把elf文件映射到这个地址空间上去。这里就需要解决一个问题,在多个ELF加载的过程中,需要把相同运行地址空间映射到不同的物理地址空间,这样才能保证每个ELF的代码和数据是相互独立的。这个概念和多进程有点类似哦。此处不多讲,这涉及到地址空间管理的问题,总之,需要内核提供这样一个接口。
  2. 究竟拷贝哪些内容呢?我们之前的segment现在可以出马了,segment里记录了我们需要拷贝的内容的全部信息,在静态库加载中,我们直接把属性为RWE的LOAD段拷贝到0x41000000,即可,需要注意的是,需要手动把bss清0。
  3. 将PC指向main
静态库加载流程图

在这里插入图片描述

动态库加载

这里说的动态库与加载指的是程序链接到动态库生成的exe文件的加载,动态库的加载比静态库要复杂很多,程序在链接阶段,只是给我们初步进行了一个假链接,我们需要自己在程序加载的时候再去链接动态库,简单来说,我们需要自己实现一个链接器linker,把主模块和动态库链接起来。

我们对比一下静态库和动态库在进程地址空间中的分布:
在这里插入图片描述

静态库很简单,直接把数据映射到text开始的地方就可以了,动态库的映射缺分成了2部分,动态库除了节省磁盘空间之外还具有共享属性,这个属性可以节约内存空间,尤其是在多个ELF加载同一个共享可以的同时,我们只需要映射一次即可。动态库的映射除了本身主模块的映射之外,还需要把共享库可以映射到进程的共享库中去,如上图,主模块被映射到0x41000000开始,共享库被映射到0x5D000000,我们要做的就是建立主模块和共享库之间的连接(主模块需要引用动态库中的函数和数据),也就是重定位工作,这也是我们实现链接器的关键。

共享库的关键:共享库的代码段是被所有ELF共享的,数据段对于每个ELF来说是相互独立的,linux下通过cow(写时拷贝)技术实现这一机制,从根本上来说,是通过缺页异常来实现。

实现自己的链接器

链接器最重要的工作就是重定位,主要包含主模块重定位和共享库重定位两项工作,后面我们都称为main_relocate和libary_relocate。这两者有何区别?

main relocate

main relocate主要完成的工作是把引用的外部函数和变量的地址更新到got.plt表中去,这样才能完成函数跳转工作
在这里插入图片描述

library relocate

library relocate主要处理的是共享库内部的函数跳转和变量引用,共享库最后被编译成位置无关,各个源文件之间的函数跳转(非static函数)和变量引用(非static变量)都需要重定位后才能进行加载运行,重定位的类型和CPU相关,ARM下的重定位类型主要有如下:

在这里插入图片描述

链接流程

我们先来梳理一下链接器的流程:
在这里插入图片描述

判断ELF是否需要链接器

我们读取一个包含共享库的elf文件:
在这里插入图片描述

在程序头中,我们发现了INTERP,证明这个elf是需要链接器去进行重新链接共享库。同时,一般包含了INTERP,同时也会有DYNAMIC段,从INTERP可以看出,ELF需要执行/usr/lib/ld.so.1来进行链接,在我们的系统中,我们的链接器(解释器)是被编译到系统服务中去的,所以就不用执行链接器本身的自举操作了,在Linux里,链接器本身还要完成自举操作,要复杂一些。
段类型:
在这里插入图片描述

查看ELF依赖哪些动态库

经过上面的链接器检查,如果检查出ELF确实依赖了一些库,那究竟依赖于哪些库呢?这个信息从哪里可以解析出来呢?我们的dynamic段该上场了,dynamic段里存储了相关信息,我们先截取看一下:
在这里插入图片描述

首先我们可以看到,在程序头中,我们可以看到新增加了DYNAMIC段,并标记了DYNAMIC的属性,大小,偏移等。
我们首先来看看dynamic段的数据结构:
在这里插入图片描述

我们再看看有哪些类型的动态节:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

类型确实是有点太多了,先大概扫一眼

我们要解析出ELF依赖于哪些库,需要借助 NEEDED 和 STRTAB,STRSZ 来解析,下面流程图表示了解析依赖库的流程:
在这里插入图片描述

之前的解析图中我们可以看出此ELF依赖于libdl.so和libmath.so,这个依赖信息是如何生成的呢?回想一下我们在之前提到的主模块在链接动态库的时候,我们说是个假链接,意思就是说只是给生成的exe文件打包上了相关依赖库和重定向信息,所以我称为假链接,我们看一下ld信息:

LINK:    elf_test 
arm-none-eabi-ld --warn-common --gc-sections -e main -T./linker.lds -L./libs/libdl -L./libs/libmath -Map elf_test.map -o elf_test --start-group elf_loader_test.c.o -ldl -lmath --end-group

其中的 -ldl -lmath,就会生成依赖信息

主模块调用共享库里的函数接口

主模块程序如下:

/*****************
 *elf_test.c
 ****************/
#include <dl.h>
int main()
{
    int sum = dl_sum(1, 2);
    return 0;
}

共享库程序如下:

/*****************
 *dl.c
 ****************/
#include <dl.h>
int dl_sum(int a, int b)
{
	return a+b;
}

把dl.c编译成动态库libdl.so:

$(CC) -MD -c $(CFLAGS)  dl.c -o dl.c.o
$(CC) -shared -fPIC -nostartfiles -o libld.so dl.c.o

在main中链接libdl.so:

$(LD) $(LDLAGS) -e main -T./linker.lds -L./libs/libdl -o elf_test elf_loader_test.c.o -ldl

linker.lds如下:

ENTRY(main);

SECTIONS{
    . = 0x41000000;
    .text :
    {
        *(.text .text.*)
        *(.rodata .rodata.*)
    }
	
    .data :
    {
        *(.data .data.*)
    }
	
    .bss :
    {
        *(.bss .bss.*)
    }
}

我们反汇编生成的exe文件:

$(OBJDUMP) -x -D -S elf_test > elf_test.dump

我们定位到我们调用dl_sum处:
在这里插入图片描述

因为dl_sum是动态库里的,代码自然也在动态库里,那么在exe里,他跳转到了0x4100014c这个地址处:
在这里插入图片描述

这个是plt表,最后他会跳转到一个新的地址上,那这个新地址是多少呢?

这些外部函数究竟该跳转到哪里?

main里调用的所有外部动态库函数,都存放在rel.dyn节里,我们来看一下:
在这里插入图片描述

我们来分析一下rel.dyn的数据结构:
在这里插入图片描述

r_info里保存着外部变量和函数的信息:

#define ELF32_R_SYM(i) ((i)>>8)
#define ELF32_R_TYPE(i) ((unsigned char)(i))
#define ELF32_R_INFO(s,t) (((s)<<8)+(unsigned char)(t))
// index为外部符号在动态符号表中的索引
index = ELF32_R_SYM(rel->r_info);
// type为外部符号表的类型:函数/变量/......
type = ELF32_R_TYPE(rel->r_info);

找到所需外部符号表的信息流程如下:
在这里插入图片描述

总结一下:根据rel.dyn节找到所有依赖外部动态库的符号,根据符号名搜索外部库找到符号真正的地址,然后进行重定向,重定向实际上就是,修改main中的.got.plt节把他的地址指向真正的地址就可以了。这样函数在跳转流程如下:
main->plt->rel.dyn->got.plt

从外部动态库搜索符号

main 中 用到了dl_sum符号,那么如何从外部动态库中得到该符号的真正地址呢?
搜索方式有2种:字符串匹配 和 hash匹配,在符号量大的情况下建议使用hash匹配,搜索效率会比较高,下面简要介绍一下字符串匹配的流程:
在这里插入图片描述

我们看一下dl_sum的在libdl.so的地址是多少:
在这里插入图片描述

注意:这里的地址是0x790,只是一个相对偏移地址,重定向的时候需要加上一个共享库的加载地址进行重定向。

未完成的工作

前面我们完成了main的重定向,我们还没有对动态库做任何处理,显然还有一步没有完成,就是把动态库的内容映射到进程的地址空间里,不然我们重定向后,还是无法正常跳转到dl_sum

我们同样readelf来看一下动态库的基本信息:
在这里插入图片描述

动态库也是一种类型的elf,他的type为DYN,其他信息的含义和exe并无区别

在这个动态库里包含了很多动态段,我们先放一下,如果你的动态库,没有涉及到数据段和函数间的引用的话,你只需要把代码段映射到进程的共享地址空间即可,如果你的动态库里涉及到了数据段和函数调用就需要再次对库进行重定位。

如何对共享库进行重定位
static函数和变量

静态函数和变量不需要进行重定位

非静态全局变量

全局变量在库中的访问,是通过间接访问得到:
源代码:

int dl_data1 = 0x3;
int get_dl_data()
{
	return dl_data1;
}

反汇编:

000006e0 <get_dl_data>:
 6e0:	e52db004 	push	{fp}		; (str fp, [sp, #-4]!)
 6e4:	e28db000 	add	fp, sp, #0
 6e8:	e59f201c 	ldr	r2, [pc, #28]	; 70c <get_dl_data+0x2c>
 6ec:	e08f2002 	add	r2, pc, r2
 6f0:	e59f3018 	ldr	r3, [pc, #24]	; 710 <get_dl_data+0x30>
 6f4:	e7923003 	ldr	r3, [r2, r3]
 6f8:	e5933000 	ldr	r3, [r3]
 6fc:	e1a00003 	mov	r0, r3
 700:	e28bd000 	add	sp, fp, #0
 704:	e49db004 	pop	{fp}		; (ldr fp, [sp], #4)
 708:	e12fff1e 	bx	lr
 70c:	0001029c 	muleq	r1, ip, r2
 710:	00000014 	andeq	r0, r0, r4, lsl r0

在这里插入图片描述

从反汇编中,我们可以看到最后从0x109a4的地址中取出全部变量的地址,然后再ldr r3, [r3],得到全局变量的值。我们找到0x109a4的地址,发现其值为0,说明此地址是需要我们进行重定位的,我们找到dl_data真正的地址为0x109c8:
在这里插入图片描述

那么重定位就是把0x109a4地址处的值修改为0x109c8,这样就能正确访问全局变量了。
我们发现0x109a4是位于got节中的,所以我们得到,got节中应该保存着所有需要进行重定位的全局变量的地址。
在rel.dyn里记录下了所有变量所需重定向的信息:
在这里插入图片描述

非静态bss

非静态bss段的重定位和非静态的全部变量一样

全局函数

全局函数在库中的访问是通过延迟绑定来实现的:

// dl.c
int dl_test()
{
	return get_dl1_data();
}
// dl1.c
int dl1_data = 0x35;
int get_dl1_data()
{
	return dl1_data;
}

在这里插入图片描述
在这里插入图片描述

同样,最终会跳转到got表中去,我们需要对got表中的函数进行重定位:
再rel.plt节里记录了所有延迟绑定的函数:
在这里插入图片描述

Offset表示重定向的源地址,Sym.Value表示真实的函数地址

共享库重定位

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

R_ARM_ABS32,R_ARM_GLOB_DAT,R_ARM_JUMP_SLOT,都直接把offset修改为sym.value即可,R_ARM_RELATIVE比较特殊,需要把offset地址处的值修改为load_addr + [offset]

共享库重定位流程

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值