【Linux】ELF 与 库加载

个人主页:矢望
个人专栏:C++、Linux、C语言、数据结构、Coze-AI
一、ELF 文件
1.1 ELF 是什么?
当我们提到可执行程序,我们想到的就是一个由一堆01组成的二进制文件。但它并不是随意放在一起的二进制,它有自己的结构和格式,这个格式就是ELF格式。
ELF(Executable and Linkable Format)是一种用于可执行文件、目标代码、共享库和核心转储的文件格式。ELF 文件格式包含以下几个关键部分:ELF 头、程序头、节头、数据部分。
在 Linux 系统中,当你编译 C 程序生成的可执行文件、目标.o文件、运行程序时加载的共享库.so 文件等都是使用 ELF 格式的。
当你要编译链接形成可执行时,是先将多份 C/C++ 源代码,翻译成为目标 .o 文件,将多份 .o 文件的节部分section进行合并,如下图所示。
所以.o可以形成形成可执行程序或者 .so库文件是因为它们都是ELF格式的。
1.2 理解 ELF 的各部分

ELF头ELF header :描述文件的主要特性。其位于文件的开始位置,它的主要目的是定位文件的其他部分。
程序头表Program header table :列举了所有有效的段segments和它们的属性。表里记着每个段的开始的位置和位移offset、长度,毕竟这些段都是紧密的放在二进制文件中,需要段表的描述信息,才能把它们每个段分割开。
节头表Section header table :包含对节sections的描述。
节Section :ELF文件中的基本组成单位,包含了特定类型的数据。ELF文件的各种信息和数据都存储在不同的节中,如代码节存储了可执行代码,数据节存储了全局变量和静态数据等。
现在有一个有关打印的可执行程序。
- 查看可执行程序
ELF header的指令:readelf -h XXX,用于查看指定ELF文件的文件头。

我们的可执行程序是ELF文件,既然是文件,我们知道文件由文件内容和文件属性构成,我们可以把文件看做一个一维数组,我们在上图中看到,ELF header是64字节,所以就是这个一维数组的前面64字节是存储ELF header的。
其中上面的Entry point address表示可执行程序的入口地址是0x400440。
上图还记录了其它区域的偏移量。
- 查看
ELF文件的节头表信息的指令:readelf -S XXX。

以表的其中一个节为例进行解析,.text 是程序的机器代码所在。偏移量 0x440 指明代码内容在文件中的起始位置。地址 0x400440 正是我们之前从 readelf -h 中看到的程序入口点,这确认了程序执行的第一条指令就在 .text 节的开头。
所以通过先查询ELF Header就可以确定Section Headers的位置,再查询它就可以知道所有节的位置。
- 查询
ELF文件的程序头部(段头)信息指令:readelf -l XXX。

节和段之间的关系:
如上,程序头部信息中包含段的信息,而我们的ELF文件中是没有段的,ELF中是存在一个个的节的。ELF是文件,是数据,它存储在磁盘上,以4KB为单位进行存储的,将来OS读取时也是以4KB为单位进行读取的。ELF文件中的不同的节大小不一,如果给每个节在内存中都申请一个 4KB 的空间,就会浪费空间资源,所以在加载到内存时,OS就会将多个相同权限的节作为一个整体进行映射,这个整体就叫做段!
这些相同权限的节在磁盘上是分开存储、连续排列的,只有在加载到内存时,才被操作系统作为一个段整体映射,并赋予统一的权限。
还记得之前博客给出的空间布局图吗?
上面有代码段、数据段等等,我们上面程序头部提到的段,就是虚拟地址空间上的段!
ELF 文件中的段 虚拟地址空间中的段
────────────────────────────────────────────────
LOAD段 (R E) ───映射──> 代码段 (.text)
(包含 .text, .rodata) 只读数据段 (.rodata)
初始化代码 (.init)
LOAD段 (RW) ───映射──> 数据段 (.data)
(包含 .data, .bss) BSS段 (.bss)
动态链接信息

程序头部中的段如上图。我们之前说过页表是有权限的,我们说正文代码段是不可写的,因为它的权限被设定为了只读,后来又说字符常量区也是不可写的。现在我们就明白了,字符常量区和正文代码区它们在存储的时候在不同的节中存储,但是当加载到内存时,它们被加载到同一个段了!所以它们的权限都是只读,所以都不可写!
所以我们在加载可执行程序到内存中时,可以较为优先的看ELF文件的程序头部Program Header Table部分,这个里面存储着各个段的信息,然后知道这些信息之后再从ELF头ELF Header中查询节头表Section Header Table,这个里面存储的就是一个个节的信息,然后按照程序头部给出的段信息,就可以加载节了。
- 查询
ELF文件各个节的具体内容信息,没有指令能够查询,但可以通过将程序转向反汇编查看。
objdump -S 的作用是:反汇编可执行文件或目标文件,并尽可能同时显示对应的源代码。
接下来我们执行objdump -S test > t.s命令将反汇编信息转移到t.s文件中查看节的具体内容。

1.3 关于可执行程序加载
我们知道,创建一个进程,是先创建内核相关的数据结构,之后再将ELF格式的二进制文件加载到内存。
那么程序在没有加载到内存的时候,程序自己有地址吗? 我们上面进行的反汇编的t.s文件已经告诉了我们答案,程序在没有加载到内存的时候,是有地址的。
平坦模式下对我们的可执行程序的每一行都进行了编址,并且原则上是从0号地址开始的,由全0到全1结束。
注:平坦模式是指将整个内存地址空间视为一个从0开始连续编址的单一线性空间,程序可以直接使用这个空间中的任意地址进行访问。
那么这是什么地址? 这种全0到全1编址的地址就叫做虚拟地址! 更准确地说是链接时确定的虚拟地址。它们是链接器在生成可执行文件时,就已经为代码和数据分配好的。更严格意义上讲,我们把这种地址叫做逻辑地址,从磁盘角度观察,在平坦模式下,就是0 + 偏移量的方式,所以它就是逻辑地址。
虚拟地址空间这个技术或者标准要求OS遵守,也要求编译器遵守。虚拟地址空间是硬件、操作系统和编译器/链接器三者共同遵守的一套标准,任何一个角色不遵守,程序都无法正确运行。
前面提到,创建一个进程,是先创建内核相关的数据结构。那么创建之后是不是还要进行初始化呀,初始化数据从哪里来呢? 它的初始化数据从可执行程序中来,它可以读取ELF各部分的属性信息初始化PCB自身的mm_struct,以Program Header为例:
上面的 LOAD 类型的程序头,正是内核需要加载和初始化到进程虚拟地址空间中的核心部分。
ELF文件的每一行代码都有自己的虚拟地址,将来加载到内存的时候,就会形成自己的物理地址,所以加载到内存后它们都有自己的虚拟地址和物理地址。这样就可以完成页表中虚拟地址到物理地址的映射了。
这样全部都建立完成之后,CPU通过程序的入口地址Entry point address就可以执行程序了。
进入CPU的地址都是虚拟地址,CPU中有一个CR3它直接指向页表的物理地址处,CPU中还有一个硬件单元MMU。当CPU要把EIP中的虚拟地址交给MMU转换时,MMU就通过CR3找到页表,完成虚拟地址到物理地址的转换,出CPU的地址就是物理地址了。虚拟地址到物理地址的整个过程都在CPU内部完成,硬件工程师什么都不用做。
1.4 编译的本质
我们反汇编之后会形成一条条的汇编语句。
这些一行行的语句将来会被翻译成CPU能听懂的指令,这每一条指令都有它的长度,操作码,数据等等,这样PC指针就可以通过每一条指令的长度去依次执行这一条条指令。
在CPU中还存在指令集的东西。CPU其实不太懂磁盘,网卡等这些设备是什么,但它知道加减这些基础指令,通过这些基础指令的组合就可以完成很多复杂的事,比如“去前面的桌子上端杯水给我”可以拆解成“走向前”“端起水”“走向后”“传递水”等基础指令。
由于二进制指令很复杂容易出错,所以程序员将汇编语句和二进制指令进行了映射,所以就形成了汇编语言。更复杂的汇编语言经过自由组合就可以形成更高级的语言。
所以当我们在编译程序时,本质是将C/C++语言编译成为CPU的指令集。
所以为什么在Windows/Linux下编译的程序为什么不能在安卓手机上运行呢?因为在Window/Linux下使用的CPU是Intel的CPU,而手机上使用的是Arm的CPU,它们的指令集不一样! 就像一个外国人用英语告诉一个婴儿给我端杯水时,他听不懂。
所以软件具不具备可移植性的根本在软件上叫做系统调用,在硬件上叫做指令集!
1.5 理解静态链接
静态链接本质是把库中的相关代码拷贝到你的程序中。现在有一个程序如下图。
test.c中调用了run.c的方法。接下来我们依次形成它们的.o文件。
先实现一下Makefile。
test:test.o run.o
gcc -o $@ $^
%.o:%.c
gcc -c $<
.PHONY:clean
clean:
rm -rf test *.o

将这两个文件进行反汇编查看它们的内容。
objdump -d run.o:
objdump -d test.o:
我们可以看到callq要跳转的函数是知道的,但是前面关于要跳转的地址都被设置成了0,也就是不知道要跳转的地址是什么。这其实就是因为在编译 test.c 的时候,编译器是完全不知道 printf 和 run 函数的存在的,比如他们位于内存的哪个区块,代码长什么样都是不知道的。因此,编辑器只能将这两个函数的跳转地址先暂时设为0。编译run.c时也一样。也就是多个.o彼此不知道对方。
我们再来看看它们两个.o文件的符号表。
如上run.c中有run函数的实现所以run.o的符号表中run函数是已知的,但run函数中使用了printf函数,它是UND(未被定义)的;test.o的符号表中run和printf函数都是UND的。未定义说白了就是本.o文件找不到。
接下来,我们读取可执行程序的符号表readelf -s test。
从上图,我们看到最终可执行程序的符号表中,它找到了run函数并且还知道这个函数的地址,这就是静态链接,尽管我们gcc编译的时候是动态链接,但run函数的实现没有动态库版本的,所以只能静态链接。(FUNC: 表示run符号类型是个函数,13: 表示run函数最终被合并到哪一个section中了,13就是下标。) 当然我们的printf函数还是UND,因为使用的是动态链接,只有在运行的时候才能找到。这个动态链接的过程我们接下来会说。
我们在上面还发现main和run函数都在13号节里面,我们看看这个节是什么,readelf -S test。
如上,它们都在.text代码段中。
那么关于之前的test.o和run.o里call的00 00 00 00有没有被修改成为具体的最终函数地址呢?我们可以通过反汇编可执行程序查看objdump -d test > test.s。
如上,链接的时候,会修改.o中没有确定的函数地址,在合并完成之后,进行相关call地址,完成代码调用。
链接其实就是将编译之后的所有目标文件连同用到的一些静态库运行时库组合,拼装成一个独立的可执行文件。其中就包括我们提到的地址修正,当所有模块组合在一起之后,链接器会根据我们的.o文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址。这其实就是静态链接的过程。
# 静态链接的调用
400540: e8 07 00 00 00 callq 40054c <run> # 地址直接填好了
# 动态链接的调用
400536: e8 d5 fe ff ff callq 400410 <puts@plt> # 指向 PLT
总结:静态库没有加载的过程,链接的过程在编译器进行链接的时候,在可执行程序加载之前就已经全部完成了。
所有的地址修正、符号解析,在生成 test 可执行文件的那一刻已经做完;程序启动时,不需要任何额外的链接工作,直接开始执行;这就是为什么静态链接的程序启动更快、不依赖外部库。
1.6 关于 动态库的加载
动态链接形成的可执行程序,在进行程序加载到内存的时候,是要先找到并加载所依赖的动态库,再将可执行程序加载到内存。 找到库文件需要知道它的路径,所以我们之前的博客才会给出四种确定库路径的方法。
那么动态库就会先加载到物理内存,之后,你的可执行程序才会被加载进来。库文件也有自己的物理地址,所以就可以进行物理地址和虚拟地址的映射,那么库文件会被映射到哪里呢?它会被映射到下图的共享区中。

如上图,展示了动态库映射到进程地址空间的共享区,将来我们之前的程序要执行代码区printf函数时,是跳转到共享区通过页表的映射去寻找相关函数,执行完成后再回到代码区继续执行。
所以库函数的调用本质也是在进程的虚拟地址空间内进行函数跳转。
当同时存在多个进程都使用同一个动态库里的函数时,此时它们就会映射到同一个物理内存中的库。
所以共享库是多个进程需要用到的资源只需要在内存中形成一份。所以为了知道哪些库有没有被加载到内存中,OS就需要对这些加载的库通过先描述,再组织进行管理。
静态链接最大的问题在于生成的文件体积大,并且相当耗费内存资源。动态链接实际上是将链接的整个过程推迟到了程序加载的时候。
在C/C++程序中,当程序开始执行时,它首先并不会直接跳转到 main 函数。实际上,程序的入口点是 _start。
在 _start 函数中,会执行一系列初始化操作,这些操作包括:设置堆栈:为程序创建一个初始的堆栈环境;初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零未初始化的数据段;动态链接:这是关键的一步, _start 函数会调用动态链接器的代码来解析和加载程序所依赖的动态库shared libraries。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调用和变量访问能够正确地映射到动态库中的实际地址。
我们知道库中是没有main函数的,动态库的内部包含了大量方法的实现,里面的每一个方法都要有自己的地址。库中的方法也是平坦模式编址,每个方法的编址都是相对全0地址的逻辑偏移地址。
如上图所示,我们知道库中每个方法相对全0地址的逻辑偏移地址,那么我们还知道库加载到物理内存之后的物理地址,那么我们通过物理地址 + 库方法的逻辑偏移地址就知道了库中所有方法的物理地址。我们还知道库起始的虚拟地址,那么通过虚拟地址 + 库方法的逻辑偏移地址,我们就知道库中所有方法的虚拟地址,同时知道了它们的虚拟地址和物理地址,再通过页表建立虚拟地址到物理地址的映射,程序就能正确调用到库函数了。
所以库函数调用的流程如下图所示。
所以在加载的时候会修改代码中call后面的 00 00 00 00,将它修改成动态库的起始虚拟地址 + 方法在库中的逻辑偏移地址。
因此加载的先后顺序是先加载进程的内核数据结构,之后加载动态库,最后再加载可执行程序的代码和数据。
那么现在有一个问题,地址重定位就是修改call后面的函数地址的内容,可是call是在代码区,代码区可是只读的,它是如何修改的?
动态链接采用的做法是在 .data 中专门预留一片区域用来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每一项都是本运行模块要引用的一个全局变量或函数的地址。
因为.data区域是可读写的,所以可以支持动态修改。
readelf -S test:
readelf -l test:
所以call最终要跳转的地方是.got表的一个位置,.got表中有很多库函数的方法。所以最终call要跳转的地方是.got表的地址 + .got表中要调用方法的偏移地址。当库加载成功并且映射成功之后,只需要修改.got表相应方法中库的起始虚拟地址就可以完成地址重定位了,这样也保证了代码段永远不变。
每个进程的每个动态库都有独立的GOT表,所以进程间不能共享GOT表。在单个.so下,由于GOT表与 .text 的相对位置是固定的,我们完全可以利用CPU的相对寻址来找到GOT表。
在调用函数的时候会首先查表,然后根据表中的地址来进行跳转,这些地址在动态库加载的时候会被修改为真正的地址。
这种方式实现的动态链接就被叫做 PIC 地址无关代码 。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享,这也是为什么之前我们给编译器指定-fPIC参数的原因,PIC = 相对编址 + GOT。
扩充:库间依赖,库也可以调用其他库,库之间是有依赖的,如何做到库和库之间互相调用也是与地址无关的呢? 库中也使用.GOT,和可执行一样!这也就是为什么都是ELF的格式!
总结:
以上就是本期博客分享的全部内容啦!如果觉得文章还不错的话可以三连支持一下,你的支持就是我前进最大的动力!
技术的探索永无止境! 道阻且长,行则将至!后续我会给大家带来更多优质博客内容,欢迎关注我的CSDN账号,我们一同成长!
(~ ̄▽ ̄)~
更多推荐



所有评论(0)