深入解析ELF文件与动态链接机制

🔥个人主页: Milestone-里程碑

❄️个人专栏: <<力扣hot100>> <<C++>><<Linux>>

<<Git>><<MySQL>>

🌟心向往之行必能至

目录

1.1ELF形成可执行

2.ELF可执行文件加载

[3. 静态链接](#3. 静态链接)

[4. 动态链接和动态库](#4. 动态链接和动态库)

[4.1 进程如何看到库](#4.1 进程如何看到库)

[4.2 动态链接](#4.2 动态链接)

4.2.1我们的可执⾏程序被编译器动了⼿脚

[4.2.3 动态库的相对地址](#4.2.3 动态库的相对地址)

[4.2.4 程序如何与库映射](#4.2.4 程序如何与库映射)

[4.2.5 程序如何调用库](#4.2.5 程序如何调用库)

[4.2.6 全局偏移量表 GOT](#4.2.6 全局偏移量表 GOT)

[4.2.7 库间依赖](#4.2.7 库间依赖)


ELF(Executable and Linkable Format)是一种用于可执行文件、目标文件、共享库和核心转储的标准文件格式。广泛应用于 Unix 和类 Unix 系统(如 Linux、FreeBSD)。ELF 文件包含程序代码、数据、符号表、重定位信息等

1.1ELF形成可执行

step-1:将多份 C/C++ 源代码,翻译成为⽬标 .o ⽂件 + 动静态库(ELF)
step-2:将多份 .o ⽂件section进⾏合并

其中合并,相同的合并一起,如.data与.data合并在一起

2.ELF可执行文件加载

⼀个ELF会有多种不同的Section,在加载到内存的时候,也会进⾏Section合并,形成segment
合并原则:相同属性,⽐如:可读,可写,可执⾏,需要加载时申请空间等.
这样,即便是不同的Section,在加载到内存中,可能会以segment的形式,加载到⼀起
很显然,这个合并⼯作也已经在形成 ELF 的时候,合并⽅式已经确定了,具体合并原则被记录在了 ELF 的 程序头表 (Program header table) 中

ELF如何加载到内存中呢?依靠路径+文件名


问:一个可执行程序,如果没有加载到内存中,该可执行程序有无地址? 有

因为会预先对可执行程序进行磁盘的编址

如果让每个seg的起始地址都为0呢,那么我们不是只要记录偏移量就可以了,这与我们前面提到的虚拟空间地址类似,其实两者就是同一东西,不同的叫法

Section合并的主要原因是为了减少⻚⾯碎⽚,提⾼内存使⽤效率。如果不进⾏合并,
假设⻚⾯⼤⼩为4096字节(内存块基本⼤⼩,加载,管理的基本单位),如果.text部分
为4097字节,.init部分为512字节,那么它们将占⽤3个⻚⾯,⽽合并后,它们只需2个
⻚⾯。
此外,操作系统在加载程序时,会将具有相同属性的section合并成⼀个⼤的
segment,这样就可以实现不同的访问权限,从⽽优化内存管理和权限访问控制。


那么OS又怎么知道从哪进呢?

ELF头表会存储一个进入地址

那么CPU又怎么知道你每个可执行程序的起始地址是什么呢?

我们的segment存储了起始地址

命令 readelf -S hello.o 可以帮助查看ELF⽂件的 节头表。
.text 节 :是保存了程序代码指令的代码节。
.data 节 :保存了初始化的全局变量和局部静态变量等数据。 •
.rodata 节 :保存了只读的数据,如⼀⾏C语⾔代码中的字符串。由于.rodata节是只读的,所
以只能存在于⼀个可执⾏⽂件的只读段中。因此,只能是在text段(不是data段)中找到.rodata节。
.BSS 节 :为未初始化的全局变量和局部静态变量预留位置(节省空间,对于未初始化的量,只需要记录有几个即可)
.symtab 节 : Symbol Table 符号表,就是源码⾥⾯那些函数名、变量名和代码的对应关系。
.got.plt 节 (全局偏移表-过程链接表):.got节保存了全局偏移表。.got节和.plt节⼀起提供
了对导⼊的共享库函数的访问⼊⼝,由动态链接器在运⾏时进⾏修改。对于GOT的理解,我们后⾯会说。
使⽤ readelf 命令查看 .so ⽂件可以看到该节。

3. 静态链接

⽆论是⾃⼰的 .o , 还是静态库中的 .o ,本质都是把.o⽂件进⾏连接的过程
所以:研究静态链接,本质就是研究 .o 是如何链接

bash 复制代码
$ ll
-rw-rw-r-- 1 whb whb 62 Oct 31 15:36 code.c
-rw-rw-r-- 1 whb whb 103 Oct 31 15:36 hello.c
whb@bite:~/test/test/test$ gcc -c *.c
whb@bite:~/test/test/test$ gcc *.o -o main.exe
$ ll
-rw-rw-r-- 1 whb whb 62 Oct 31 15:36 code.c
-rw-rw-r-- 1 whb whb 1672 Oct 31 15:46 code.o
-rw-rw-r-- 1 whb whb 103 Oct 31 15:36 hello.c
-rw-rw-r-- 1 whb whb 1744 Oct 31 15:46 hello.o
-rwxrwxr-x 1 whb whb 16752 Oct 31 15:46 main.exe

查看编译后的.o⽬标⽂件

我们会发现调用这些函数的地址为0,其实是.o文件不认识这些函数,不知道它们在哪个内存块,代码是什么,因此,编译器只能将这两个函数的跳转地址先暂时设为0。

而当最后进行连接时,会通过一个重定位表进行修正

4. 动态链接和动态库

4.1 进程如何看到库

与代码和数据加载到内存类似,库也是如此加载,只不过存在了内存的共享区,给人们调用

代码区调用动态库,先去共享区,调用完成再回代码区

如果有多个进程调用动态库呢?

动态库在物理内存仍只有一份,但哪个进程的代码区需要调用动态库,就在该进程的页表加载动态库对应的虚拟内存

4.2 动态链接

动态链接比静态链接更常用,原因在前面已说:节省空间

bash 复制代码
[root@hcss-ecs-1cde ~]# ldd /usr/bin/ls
	linux-vdso.so.1 =>  (0x00007fffea991000)
	libselinux.so.1 => /lib64/libselinux.so.1 (0x00007f4a3ec49000)
	libcap.so.2 => /lib64/libcap.so.2 (0x00007f4a3ea44000)
	libacl.so.1 => /lib64/libacl.so.1 (0x00007f4a3e83b000)
	libc.so.6 => /lib64/libc.so.6 (0x00007f4a3e46d000)
	libpcre.so.1 => /lib64/libpcre.so.1 (0x00007f4a3e20b000)
	libdl.so.2 => /lib64/libdl.so.2 (0x00007f4a3e007000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f4a3ee70000)
	libattr.so.1 => /lib64/libattr.so.1 (0x00007f4a3de02000)
	libpthread.so.0 => /lib64/libpthread.so.0 (0x00007f4a3dbe6000)
[root@hcss-ecs-1cde ~]# ldd /usr/bin/pwd
	linux-vdso.so.1 =>  (0x00007ffc0ed24000)
	libc.so.6 => /lib64/libc.so.6 (0x00007f771b403000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f771b7d1000)

4.2.1我们的可执⾏程序被编译器动了⼿脚

上面的程序,我们可以看见两个特殊的代码

bash 复制代码
/lib64/ld-linux-x86-64.so.2 (0x00007f4a3ee70000)
/lib64/ld-linux-x86-64.so.2 (0x00007f771b7d1000)

在C/C++程序中,当程序开始执⾏时,它⾸先并不会直接跳转到 main 函数。实际上,程序的⼊⼝点 是 _start ,这是⼀个由C运⾏时库(通常是glibc)或链接器(如ld)提供的特殊函数。
在 _start 函数中,会执⾏⼀系列初始化操作,这些操作包括:

  1. 设置堆栈:为程序创建⼀个初始的堆栈环境。
  2. 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位 置,并清零未初始化的数据段。
  3. 动态链接:这是关键的⼀步, _start 函数会调⽤动态链接器的代码来解析和加载程序所依赖的 动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调⽤和变量访问能够正确地映射到动态库中的实际地址。
    因此程序入口不是main函数,而是先进入_start函数进行初始化

4.2.3 动态库的相对地址

动态库也是ELF,为了随时都能加载,为了⽀持并映射到任意进程的任意位置,对动态库中的⽅法,统⼀编址, 采⽤相对编址的⽅案进⾏编制的(其实可执⾏程序也⼀样,都要遵守平坦模式,只不过exe是直接加载的)。

4.2.4 程序如何与库映射

• 动态库也是⼀个⽂件,要访问也是要被先加载,要加载也是要被打开的
• 让我们的进程找到动态库的本质:也是⽂件操作,不过我们访问库函数,通过虚拟地址进
⾏跳转访问的,所以需要把动态库映射到进程的地址空间中

在库的代码和数据会存储库中方法的偏移量

4.2.5 程序如何调用库

库已经被我们映射到了当前进程的地址空间中
库的虚拟起始地址我们也已经知道了
库中每⼀个⽅法的偏移量地址我们也知道
所有:访问库中任意⽅法,只需要知道库的起始虚拟地址+⽅法偏移量即可定位库中的⽅法
⽽且:整个调⽤过程,是从代码区跳转到共享区,调⽤完毕在返回到代码区,整个过程完
全在进程地址空间中进⾏的.

例子:

4.2.6 全局偏移量表 GOT

我们上面说了程序调用库,但细看,在调用时候,对我们内存中的代码段进行了修改地址,但代码段不是只读的吗?
所以:动态链接采⽤的做法是在 .data (可执⾏程序或者库⾃⼰)中专⻔预留⼀⽚区域⽤来存放函数 的跳转地址,它也被叫做全局偏移表GOT**,表中每⼀项都是本运⾏模块要引⽤的⼀个全局变量或函数的地址**。
因为.data区域是可读写的,所以可以⽀持动态进⾏修改
就是每个进程的每个动态库都有独⽴的GOT表,所以进程间不能共享GOT表。


bash 复制代码
$ objdump -S a.out
...
0000000000001050 <puts@plt>:
1050: f3 0f 1e fa endbr64
1054: f2 ff 25 75 2f 00 00 bnd jmpq *0x2f75(%rip) #
3fd0 <puts@GLIBC_2.2.5>
...
...
0000000000001149 <main>:
1149: f3 0f 1e fa endbr64
114d: 55 push %rbp
114e: 48 89 e5 mov %rsp,%rbp
1151: 48 8d 3d ac 0e 00 00 lea 0xeac(%rip),%rdi
# 2004 <_IO_stdin_used+0x4>
1158: e8 f3 fe ff ff callq 1050 <puts@plt>

细看上面的代码,我们会发现调用的也不是库,而是plt

那么何为plt

这种⽅式实现的动态链接就被叫做 PIC 地址⽆关代码 。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运⾏,并且能够被所有进程共享,这也是为什么之前我们给编译器指定-fPIC参数的原因,PIC=相对编址+GOT。

4.2.7 库间依赖

不仅仅有可执⾏程序调⽤库
库也会调⽤其他库!!库之间是有依赖的,如何做到库和库之间互相调⽤也是与地址⽆关的呢??

库中也有.GOT,和可执⾏⼀样!这也就是为什么⼤家为什么都是ELF的格式
由于动态链接在程序加载的时候需要对⼤量函数进⾏重定位( 重定位是动态链接中修正函数 / 变量地址的过程 。 ),这⼀步显然是⾮常耗时的。为了进⼀ 步降低开销,我们的操作系统还做了⼀些其他的优化,⽐如延迟绑定,或者也叫PLT(过程连接表 (Procedure Linkage Table))。与其在程序⼀开始就对所有函数进⾏重定位,不如将这个过程推迟到函数第⼀次被调⽤的时候,因为绝⼤多数动态库中的函数可能在程序运⾏期间⼀次都不会被使⽤到。

相关推荐
安科士andxe5 小时前
深入解析|安科士1.25G CWDM SFP光模块核心技术,破解中长距离传输痛点
服务器·网络·5g
春日见7 小时前
拉取与合并:如何让个人分支既包含你昨天的修改,也包含 develop 最新更新
大数据·人工智能·深度学习·elasticsearch·搜索引擎
寻寻觅觅☆7 小时前
东华OJ-基础题-106-大整数相加(C++)
开发语言·c++·算法
偷吃的耗子8 小时前
【CNN算法理解】:三、AlexNet 训练模块(附代码)
深度学习·算法·cnn
小白同学_C8 小时前
Lab4-Lab: traps && MIT6.1810操作系统工程【持续更新】 _
linux·c/c++·操作系统os
今天只学一颗糖8 小时前
1、《深入理解计算机系统》--计算机系统介绍
linux·笔记·学习·系统架构
2601_949146538 小时前
Shell语音通知接口使用指南:运维自动化中的语音告警集成方案
运维·自动化
儒雅的晴天8 小时前
大模型幻觉问题
运维·服务器
化学在逃硬闯CS8 小时前
Leetcode1382. 将二叉搜索树变平衡
数据结构·算法
ceclar1239 小时前
C++使用format
开发语言·c++·算法