ELF文件的加载与进程地址空间,动态加载

1 虚拟地址和逻辑地址

这里首先考虑一个问题,在前面文章中讲解ELF文件中,存在一个地址,那么这个是物理地址,还是虚拟地址或者说逻辑地址呢?其实是逻辑地址。

⼀个ELF程序,在没有被加载到内存的时候,本来就有地址,当代计算机工作的时候,都采⽤"平坦模式"进行工作。所以也要求ELF对自己的代码和数据进行统⼀编址。

下面是 objdump -S 反汇编之后的代码 :

ELF文件的加载与进程地址空间,动态加载_第1张图片

最左侧的就是ELF的虚拟地址,其实,严格意义上应该叫做逻辑地址(起始地址+偏移量),但是我们 认为起始地址是0,也就是说,其实虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执行程序进行统⼀编址了。

这里还有一个就是在进程中会有虚拟地址和物理地址构建的页表,所以就存在mm_struct、vm_area_struct(这里在小编前面“虚拟地址空间”中有讲解)进程刚刚创建的时候,就有了,那这部分的数据从何而来呢?

从ELF各个 segment来,每个segment有自己的起始地址和自己的长度,用来初始化内核结构中的[start,end] 等范围数据,另外再用详细地址,填充页表。

 这里也就可以得到一个结论:虚拟地址空间技术,不仅OS支持,编译器也支持

2 更近进程虚拟地址空间

ELF在被编译好之后,会把自己未来程序的入口地址记录在ELFheader的Entry字段中:

通过readelf -h a.out 命令查看:

这里可以理解ELF文件加载到内存时,在磁盘中找到对应的数据块确定在内存中的物理地址,然后再通过mm_struct、vm_area_struct等区域(虚拟地址)与对应的数据块进行构建映射,创建页表,这样CPU在执行进程时,就可以通过一个虚拟地址,访问到一个物理地址。

3 动态链接与动态库加载

首先理解动态库,在使用时,需不需要加载到内存中,答案是肯定的,但是和静态库不一样的是,内存中只需要加载一次,所有需要用到该动态库的进程,就都可以通过构建页表,实现虚拟到物理的转化进而实现对库方法的使用(提高内存的使用率),还有个问题是与虚拟地址空间的那个区域进行映射呢?

通过下面的图可以看出,存在共享区中的:

ELF文件的加载与进程地址空间,动态加载_第2张图片
 

 3.1 动态链接

这里通过我们平时用的C语言的标准库为例,比如我们查看下打印hello这个可执行程序依赖的动态库,会发现它就用到了⼀个C动态链接库:

ldd test.c
     linux-vdso.so.1 => (0x00007fffeb1ab000)
     libc.so.6 => /lib64/libc.so.6 (0x00007ff776af5000)
     /lib64/ld-linux-x86-64.so.2 (0x00007ff776ec3000)

libc.so是C语言的运行时库,里面提供了常用的标准输入输出文件字符串处理等等这些功能。

这里也可以讨论:

那为什么编译器默认不使用静态链接呢?

看起来使用静态链接,会将编译产生的所有目标文件,连同用到的各种库,合并形成⼀个独立的可执行文件,它不需要额外的依赖就可以运行,应该更加方便。

但是:静态链接最大的问题在于生成的文件体积大,并且相当耗费内存资源。其次不同的软件就有可能都包含了相同的功能和代码,显然会浪费大量的硬盘空间。动态链接的优势就体现出来了。

同一个库在内存中保留一份副本,可以被不同的进程所共享。

3.2 程序加载时链接

这里还有抛出一个结论:动态链接实际上将链接的整个过程推迟到了程序加载的时候。

当我们在运行一个程序时,操作系统会首先将程序的数据代码连同它用到的⼀系列动态库先加载到内存(但是这个加载地址是不固定的)当动态库被加载到内存以后,内存地址被确定,就可以去修正动态库中的那些函数跳转

3.2.1 真正的入口

当开始运行一个程序,这里的起始地址并不是main而是_start,实际上,程序的入口点是 _start ,这是⼀个由C运行时库(通常是glibc)或链接器(如ld)提供的特殊函数。

这里也可以理解为_start在为后面main函数的执行做一些初始化操作:

1.设置堆栈:为程序创建⼀个初始的堆栈环境。

2.初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零未初始化的数据段。

3.动态链接:这是关键的⼀步, _start 函数会调用动态链接器的代码来解析和加载程序所依赖的动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调用和变量访问能够正确地映射到动态库中的实际地址。

 动态链接器(主要功能):

程序运行时加载动态库。

解析程序中的动态库依赖,并加载这些库到内存中。

 这里其实还有几个文件和环境变量来配合这个动态链接器:

环境变量和配置文件:

通过环境变量(如LD_LIBRARY_PATH)和配置文件(如/etc/ld.so.conf及其子配置文件)来指定动态库的搜索路径。

这些路径会被动态链接器在加载动态库时搜索。

 缓存文件:

为了提高动态库的加载效率,Linux系统会维护⼀个名为/etc/ld.so.cache的缓存文件。

该文件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会首先 搜索这个缓存文件。

最后动态链接完成,_start 函数会调用 __libc_start_main (这是glibc提供的⼀个函数)。 __libc_start_main 函数负责执行 ⼀些额外的初始化工作,例如设置信号处理函数、初始化线程库等。这下才是调用main函数,main函数调用完成__libc_start_main 会负责处理这个返回值,并最终调用 _exit 函数来终止程序。

3.3 动态库中的相对地址

动态库为了随时进行加载,对动态库中的编址方法,统⼀编址, 采用相对编址的方案进行编制的

 第一:动态库也是⼀个文件,要访问也是要被先加载到内存,要加载也是要被打开,找到文件inode,找到对应的数据块。

第二:进程找到动态库的本质,也是文件操作,访问库函数,通过虚拟地址进行跳转访问的,所以需要把动态库映射到进程的地址空间中。

所以:这里库的虚拟起始地址已经找到 ,库中每⼀个方法的偏移量地址也有了,访问方法也就只需要知道库的起始虚拟地址+方法偏移量

这整个流程就是通过call从代码区跳转到共享区,调用完毕在返回到代码区,整个过程完全在进程地址空间中进行的。

ELF文件的加载与进程地址空间,动态加载_第3张图片

3.4 GOT全局偏移量表(global offset table)

这里有一个补充问题:在上面加载地址重定位时,代码段是只读的,不能进行修改的,这个时候其实是GOT表(存在于.data(这个段是可读可写的)中,用来存放函数的跳转地址)发挥的作用。

所以真正的逻辑是:

ELF文件的加载与进程地址空间,动态加载_第4张图片

是通过callGOT表的起始地址,再加偏移量,在修改GOT表找到对应的方法。

这种方式实现的动态链接就被叫做 PIC 地址无关代码 。也就是说我们的动态库不需要做任何修 改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享。这也是为什么之前制作动态库时(之前小编“动静态库”文章中有讲解如何制作库),编译器指定 -fPIC 参数的原因,PIC=相对编址+GOT。

你可能感兴趣的:(linux,运维,服务器)