【Linux篇】ELF文件与程序加载:理解链接过程中的静态库,动态库及目标文件
📌 个人主页: 孙同学_
🔧 文章专栏:Liunx
💡 关注我,分享经验,助你少走弯路!
文章目录
- 1. 什么是库
- 2. 静态库
- 2.1 静态库的制作
- 2.2 静态库的使用
- 3. 动态库
- 3.1 库运行搜索路径
- 4. 目标文件
- 5. ELF文件
- 6. ELF从形成到加载轮廓
- 6.1 ELF形成可执行
- 6.2 ELF可执行文件的加载
- 7. 理解链接与加载
- 7.1 静态链接
- 7.2 ELF加载与进程地址空间
- 7.2.1 虚拟地址/逻辑地址
- 7.3 动态链接与动态库加载
- 7.3.1 进程如何看到动态库
- 7.3.2 动态链接
- 7.3.2.1 概要
- 7.3.2.2 我们的可执行程序被编译器动了手脚
- 7.3.2.3 动态库中的相对地址
- 7.3.2.4 我们的程序,怎么和库具体映射起来的
- 7.3.2.5 我们的程序,怎么进行库函数调用
- 7.3.2.6 全局偏移量表GOT(global offset table)
- 7.3.2.7 库间依赖
- 7.3.3 总结
1. 什么是库
什么是库呢,库就是我们对代码进行复用。像我们之前用的printf,scanf都是库帮我们实现的,如果没有库,那么我们就需要自己写printf和scanf等函数了。
本质来说库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。
静态库:.a[Linux],.lib[Windows]
动态库:.so[Linux],.dll[Windows]
比如我们有时候在我们的电脑上打开一个文件,会显示XXX.dll缺失,其实就是杀毒软件把这个软件的库给删了。

2. 静态库
注意: 动静态库中不需要包含main函数。
情景:今天我们的老师布置了一份作业,他要求我们把c语言的库函数自己实现一份,我们就写了相应的接口分别放到了mystring.h和mystdio.h当中。

后来有一天我的同桌张三就来找我了,让我把我的实现交给他,但是我不想让张三看到我的内部是怎么实现的,我们可以gcc -c mystdio.c把同名的.c文件变成目标文件.o文件。

我们一旦有了多文件,我们把多文件编译成.o,.o文件经过链接就能形成可执行程序了。
我们把.o文件交给张三,他其实就能够形成可执行了,并且我们还能不让他看到我们的源代码。
此时张三就有了我们的.o文件。

可是张三拿着我们的.o文件不知道该怎么用,我们再把我们的.h交给张三。可是.h有什么用呢?这两个.h中包含了.o文件中用到的方法的声明,参数,返回值,结构类型都有了。(头文件的本质就是对源文件方法的说明文档),所以张三不需要源文件了,我们把.h给他他就知道怎么用了。.h和.o一起形成可执行程序,这样老师检查的时候就检查不出来我和张三的代码一摸一样了。

下面的usercode.c是张三自己的源代码。要编译时将所有的源文件变成.o文件再进行链接即可。此时的张三先将他自己的源文件变成.o文件,然后把所有的.o文件链接一下就形成了a.out。

📢 结论:
-
我们将
.o文件与.h文件打包交给别人,就能实现库的效果。 -
所有的库,无论是动态库还是静态库,本质都是源文件对应的
.o!。 -
静态库的本质就是所有的
.o文件打了一个包。
如果我们未来有非常多的.o文件,gcc为我们提供了一个工具,我们可以把所有的.o打包形成一个压缩文件,而这一个压缩文件是可以被gcc直接识别的,我们都不需要解包它。

命令:ar(archive):将所有的.o文件打一个包。
选项:-rc(replace and create)
.a静态库本质是一种归档文件,不需要使用者解包,而使用gcc/g++直接进行链接。
静态库一般都以lib开头,以.a进行结尾,真正库的名字是中间的那部分。例如libmyc.a,库的名字就是myc。
现在张三.a文件和.o文件都有了。

这里链接时我们要指明链接哪个库。

上面我们已经知道了库真正的名字其实就是中间的那部分,所以我们在链接的时候应使用它真正的名字。

主要原因是gcc在查找库的时候,它默认不会在当前目录下去查找库,所以我们需要再添加一个选项-L,表明在我们指定的目录下去寻找我们的库。

至此我们的可执行程序就已经形成,运行它就能跑。

打包lib形成压缩包

张三拿到了压缩包,并且进行了解包

张三继续编写代码gcc -c usercode.c形成.o

原因是张三的usercode.c的代码里面包含了mystdio.h和mystring.h这两个头文件的,在编译器编译代码时编译器会先找我们的代码里面包含的头文件,像stdio.h这种标准c的头文件系统默认能找到,由我们提供的库的.h今天不在当前路径下,更不在系统里,这个路径属于在系统自定义路径下。所以我们要告诉我们的代码找头文件去哪里找。我们就需要加上选项-I,表明告诉gcc在未来编译代码时要查头文件除了在系统默认路径下和当前路径下去查,还要去我们-I指明的路径下去查。


🌵库也是被安装到系统中的。所谓的安装也就是拷贝。
2.1 静态库的制作
我们是一个库的制作者,我们要制作静态库并不意味着只把库交给别人。


2.2 静态库的使用
gcc默认查找某一个库时,不会在当前目录下去查找。
gcc -o usercode usercode.c -L. -lmyc
-L:表示在哪里找库。
-l:表示找什么库
如果我们要链接任何非C/C++标准库(包括其他外部库或者我们自己写的)都需要指明-L和-l,都必须指明在哪里找库,找什么库。
gcc -o usercode usercode.c -I ./lib/include -L ./lib/mylib -l myc
-I:表示在哪个路径下去找头文件
-L:表示在哪个路径下去找库文件
-l:表示要链接哪一个库

我们这样写头文件就不需要带-l选项了
我们系统当中默认搜索头文件的目录在/user/include目录下
系统当中默认搜索库文件在/lib64目录下
所以对于库所谓的安装,其实就是把对应的头文件和库文件拷贝到系统指定目录下。
库文件名称和引入库的名称:去掉前缀lib ,去掉后缀 .o ,.a ,如: libc.o -> c
3. 动态库
动态库的原理几乎和静态库是一摸一样的,只不过相比较静态库把我们的代码打包成动态库是更为常见和更为常规的操作,我们要形成动态库依旧需要gcc -c *.c把同名的.c文件变成.o文件,只不过要新增一个选项fPIC。gcc -fPIC -c *.c。

紧接着依旧是把所有的.o打包,只不过静态库打包使用的是ar命令,而动态库打包直接使用gcc命令,gcc既可以形成可执行程序,又可以形成动态库文件。
gcc -shared -o libmyc.so *.o

shared:表示生成共享库格式
fPIC:产生位置无关码(position independent code)



下面我们就可以把我们打包好的库交给别人了。


我们使用动态库该怎么编译呢?
gcc -o usercode usercode.c -I lib/include -L lib/mylib/ -lmyc

可执行程序已经有了,但是我们运行这个程序的时候它报错了,打不开这个文件,因为这个文件不存在,这个文件不是存在吗,不是在我们的 lib路径下吗?而且我们在编译器编译的时候已经告诉gcc在lib/mylib路径下有一个myc的库吗?

我们查看一个程序所依赖的库ldd。

答案是我们只告诉了gcc,当我们的可执行程序运行的时候,它和我们的动态库的依赖关系就像我们的同学和我们附近的网吧的关系一样,当我们需要上网的时候,我们走进网吧去上网。可是我们如果还在学校,但是网吧没了,我们此时想上网不就上不了。可执行程序一旦到了准备开始运行的时候,我们这个可执行程序所依赖的库是需要系统知道的,因为我们的库即不在系统里安装,又不在系统指定的路径下,我们的库在我们lib的自定路径下,系统 != gcc,我们只告诉gcc了,所以gcc帮我们把可执行程序生成了,可是我们并没有告诉系统这个动态库,所以系统找不到动态库,此时就报错出来了运行错误。
关键在于静态库怎么没有这样的问题?答案是静态库在链接时直接把库的实现拷贝到了可执行程序里,可执行程序不再依赖静态库。而动态库在加载可执行程序的同时还要找到可执行程序所依赖的库。
静态库在链接时是直接把库的实现拷贝到了我们的可执行程序里,一旦形成棵执行程序,可执行程序不再依赖静态库。而动态库在加载我们的程序时,还要找到所依赖的库。
所以静态库不存在运行找不到的问题,只要编译成功就一定能运行。
3.1 库运行搜索路径

如果我们想让我们的可执行程序运行起来该怎么办呢?
-
方法一:把我们的库直接拷贝到系统路径下

我们可以看到lib库中多了libmyc.so

此时再次运行我们的可执行程序就能运行了



-
方法二:把我们的库和系统库建立软链接

-
方法三:操作系统在运行程序的时候要查找动态库,除了在我们的系统中
lib64查找以外,也会在环境变量中查找动态库,LD_LIBRARY_PATH这个环境变量经常是空的。


⚠️注意: 这个环境变量是内存级的,当我们把xshell关闭再次打开就发现这个程序又运行不了。想要这个环境变量一直有效就得把它写到一些配置文件里。 -
方法四:在我们的系统下存在一个路径
/etc/ld.so.conf.d/,这个.conf都会包含要求系统提前要准备好要查找或者确认动态库的路径。所以我们可以在该路径下新增一个配置文件.conf结尾,这个配置文件中把我们要找的动态库的路径贴进去。后面要ldconf重新加载一下配置。



此时它还是找不到对应的库

我们要ldconfig重新加载一下配置。

此时我们的库就能找到了。
📢 结论一:
- 如果动静态库同时存在,
gcc/g++默认使用动态库 - 如果动静态库同时存在我们非要静态链接只能加
-static - 一旦
-static,就必须存在对应的静态库。
📢 结论二: 在Linux环境下,默认情况下安装的大部分库都是动态库!
📢 结论三: 库 :应用程序 = 1 :n
📢 结论四: vs不仅仅形成可执行程序,也能形成动静态库
4. 目标文件

我们首先探讨一下编译和链接的整个过程,来更好的理解动静态库的使用原理。
什么是编译呢?编译的过程其实就是将我们的源代码翻译成CPU能够直接运行的机器代码。
比如:在一个源文件hello.c里面简单的输出"hello world",并且调用一个run函数,而这个函数被定义在另一个源文件code.c中。这里我们就可以调用gcc -c来分别编译这两个源文件。
// hello.c
#include<stdio.h>
void run();
int main() {printf("hello world!\n");run();return 0;
}
// code.c
#include<stdio.h>
void run() {printf("running...\n");
}
// 编译两个源⽂件
$ gcc -c hello.c
$ gcc -c code.c
$ ls
code.c code.o hello.c hello.o
可以看到编译后会形成两个拓展名为.o的文件,他们被称作目标文件。要注意的是如果我们修改了一个源文件,那么只需单独编译它一个,而不需要浪费时间重新编译整个工程,目标问价是一个二进制文件,文件的格式是ELF,是对二进制代码的一种封装。
$ file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
## file命令⽤于辨识⽂件类型。
5. ELF文件
可执行程序,静态库,动态库,.o文件都是ELF格式的。
sp@hcss-ecs-eaf1:~/linux.practice/114/lesson1/zhangsan$ size usercodetext data bss dec hex filename2562 680 8 3250 cb2 usercode


6. ELF从形成到加载轮廓
6.1 ELF形成可执行
step-1:将多份 C/C++ 源代码,翻译成为目标.o文件 + 动静态库(ELF)step-2:将多份.o文件section进行合并

可执行程序的形成是把.o的库或者.o打包对应的静态库section对应属性的节进行合并就能形成大的可执行程序。其实就是把小的ELF这种格式链接(合并)成一个大的section。
6.2 ELF可执行文件的加载
- 一个
ELF会有多种不同的section,在加载到内存的时候也会进行section的合并,形成segment。
例如我们在写c语言的时候定义了一个字符常量,我们经常说这个字符常量被编译的时候是编译到字符常量区的,这个字符常量区未来是会和我们的代码区进行合并的,因为代码是只读的,字符串是只读的。 - 合并原则:相同属性,比如:可读,可写,可执行,需要加载时申请空间等。
- 这样即便是不同的
section,在加载到内存中,可能会以segment的形式加载到一起。 - 很显然,这个合并工作也已经在形成
ELF的时候,合并方式已经确定了,具体合并原则被记录在了ELF的程序头表(P肉gram header table)中。
查看可执行程序的sectionreadelf -S a.out
-S:表示把一个一个的数据节全部读取出来

每一个section知道指定起始地址,知道它的偏移量和大小,就能把它的节划分出来。
.bss:我们所定义的变量宏观上就分两种,全局的和局部的,我们如果定义一个局部的临时变量,它是在程序的运行期间产生的。我们定义全局变量的时候,全局变量分为两种,一种是已初始化全局变量,一种叫做未初始化全局变量,而已初始化的全局变量就必须要求把这个全局变量的数据记录下来,比如int a = 10;此时全局变量10这个数字就得记录下来,因为要在加载程序时拿10来初始化一个全局变量。我们的全局变量中也有一些是未初始化的,比如int a,int b等,假设我们定义了五十个全局变量,这些全局变量都是未初始化的,我们用不用把这些全局变量每一个都得记录下来呢?全局变量未初始化时它的默认值就是0,如果我们把这些未初始化的全局变量都记录下来的话就得int a = 0,int b = 0...,但是很明显这些全局变量都没有初始化,初始值都一样,没有必要把这些全局变量一个一个全记录下来。其实我们只需知道在我们的当前代码中有五十个全局变量,它的初始值都是0就可以了。当加载的时候,本来要存50×4 = 200个字节,但是现在只需要存50这个数字就行了,当加载这个程序的时候再基于它的bss把它展开,连续在我们的空间中开辟50个字节,全初始化为0。所以这个bss就相当与在磁盘上以最短的方式把我们没有初始化的全局变量给我们描述起来。当在内存上加载的时候再连续开辟50个整形变量的空间。这就是未初始化的全局变量默认初始化为0的原因,未初始化的全局变量会在bss中存,在真正内存展开时才把每一个全局变量初始化为0。
查看section合并的segment
readelf -l a.out
-l:表示读取可执行程序合并后的segment


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

程序表头和节表头有什么用?
我们应该用两个不同的视角来理解它们。
一个是编译与链接视角 - 对应节头表 Section header table
- 当我们在链接时,需要把所有的
.o文件的.test.data.bss等数据节合并,合并后文件的整体变大了,更新Section header table。它表示的是可执行程序是如何形成的。同时链接时也会形成Program Header Table,但是这个并不是给我们的可执行程序用的,是给加载器用的。
执行视角 - 对应程序表头Program Header Table
- 它会告诉操作系统应该如何加载这个可执行程序,完成进程内存的初始化。一个可执行程序一定要有
Program Header Table

从 链接视图 来看:
- 命令
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文件可以看到该节。
从 执行视图 来看:
- 告诉操作系统哪些模块可以被加载进内存。
- 加载进内存之后哪些分段是可读可写,哪些分段是只读,哪些分段是可执行的。

一个进程启动之后它怎么知道代码区数据是只读的,哪些数据是可写的,页表也是个数据结构,它怎么知道哪个区域是只读只写的? - 操作系统并不关心这个,而是操作系统在读取
Program Header Table时,发现某些区域的权限是只读的,在初始化页表时构建映射关系时,把权限往里一写就可以了。
再谈.symtab节 :Symbol Table 符号表,就是源码里面那些函数名、变量名和代码的对应关系。
把我们代码中所有的字符串常量,函数名,类名,动态库,静态库,各种各样的名称全部会存在类似于这样的符号表里。char lable[] = "helloworld\0func\0libc\0a\0obj\0",从此往后在可执行程序中再也不用出现像obj这种符号了,它只需记录其实偏移量就可以了。
ELF Header:代表我们整个ELF格式相关的信息,包括每一个区域的开始,相当于标注剩下的几个信息在什么位置。
读取ELF Header的命令:
readelf -h /usr/bin/ls

📌注意: 每一个ELF区域都和偏移量有关,ELF Header就相当于在偏移量为0的地址处。
7. 理解链接与加载
7.1 静态链接
- 无论是自己的
.o,还是静态库中的.o,本质都是把.o文件进行链接的过程。 - 所以研究静态链接本质就是研究
.o文件是如何链接起来的。
hello.c
#include<stdio.h>void run();
int main()
{printf("hello world!\n");run();return 0;
}
code.c
#include<stdio.h>void run()
{printf("running...\n");
}
hello.c中用到的run()方法在code.c里面。我们先把他们两个编译成.o。




将两个.o文件进行链接
gcc -o main.exe *.o //将.o文件链接形成main.exe

mian.exe里面不仅合并了两个.o文件的section,code.c和hello.c中还包含了printf,所以和c标准库也进行了动态链接。

查看编译后的.o目标文件
objdump -d code.o //objdump表示对目标文件进行反汇编

我们在没有链接.o文件的时候可以发现:它调用的printf和run对应的目标地址为全0,因为模块之间还没有合并,hello.o里面的printf是库提供的,run方法是code.c提供的,要用的printf和run方法还并没有和我们的.o文件进行链接,所以此时调用的目标地址为全零,也就是此时的hello.o文件也不知道对用的printf和run方法在哪里。编译器只能将这两个函数的跳转地址设置为0。
这个地址什么时候被修正?答案是链接的时候!!!为了让链接器将来在链接时能够正确定位到这些被修正的地址,在代码块(.data)中还存在⼀个重定位表,这张表将来在链接的时候,就会根据表里记录的地址将其修正。
# 读取code.o符合表
$ readelf -s code.o


所以所谓的链接就是每一个模块都拿着自己未定义的符号去其他模块中找。


objdump -d main.exe # 反汇编main.exe只查看代码段信息,包含源代码

此时run方法与printf后面的地址就被填充了。
📢结论: 但我们一旦链接形成可执行程序之后,就会把call后面以前为0的地址填充成具体的值了。
静态链接就是把库中的.o进行合并,所谓的静态链接就是把所有的.o文件和静态库拼接成一个独立可执行的文件。它在合并的时候会牵扯到地址的修正,当所有的模块组合到一起之后,链接器会根据我们的.o文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正他们的地址,这就是静态链接的过程。

所以链接过程中会涉及到对.o中外部符号进行地址重定位。这就是.o之所以叫做可重定位目标文件,因为在链接时它的地址会被重新修改。
7.2 ELF加载与进程地址空间
7.2.1 虚拟地址/逻辑地址
一个可执行程序,在内加载的内存中,该可执行程序是有地址de.
上面我们已经说过,我们的数据都是分节的。.test节里面会有我们写的函数。比如上面的fun,另外一个程序当中比如说是main函数调用了这个fun,所以在将来调用fun的时候必须知道这个fun的地址。我们在.text中知道起始地址和偏移量是能找到这个fun函数的,比如说起始地址是0x1111,偏移量是50,所以将来起始地址+偏移量就能找到这个fun函数了。又比如说.data里面有一个变量a,这个a变量也是有地址的,比如说它的起始地址为0x2222,偏移量为0x40,我们就能知道这个a变量的地址。

所以我们只要知道每一小节的起始地址+偏移量就能找到内部元素的地址。只要每一个section的起始地址都是不一样不重叠的,我们就可以完成整个可执行程序在磁盘上的编址。
编址后我们就不再使用这些变量名fun,a,直接使用起始地址+偏移量就能找到可执行程序中的任何一个函数和任何一个变量。
这种磁盘上的地址我们称之为逻辑地址
就相当于把每一个section合并起来看成一个整体,然后从0开始编址。
虽然起始地址是从0开始编的,但是不一定代表0号地址一定要被用。

地址都是按顺从上往下依次增大的,这种工作模式称为平坦模式编址
上一次我们谈到的从0开始编址的地址位虚拟地址空间,虚拟地址空间不仅是进程看待内存的方式,我们说的这种磁盘上的可执行程序,数据,代码等的编址,其实就是虚拟地址的统一编址。其实虚拟地址在程序还没有加载到内存时就已经编出来了。
- 进程
mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪里来的?从ELF各个segment来,每个segment有自己的起始地址和自己的长度,用来初始化内核结构中的[start, end]等范围数据,另外在用详细地址,填充页表。
所以:虚拟地址机制不光操作系统(OS)要支持,编译器也要支持。



7.3 动态链接与动态库加载
7.3.1 进程如何看到动态库

库函数调用:
- 被进程看到:动态库映射到进程的地址空间
- 被进程调用:在进程的地址空间中进行跳转
多个进程调用同一个共享库呢?

答案是两个进程的页表映射到同一物理空间。
7.3.2 动态链接
7.3.2.1 概要
静态链接最大的问题在于生成的文件的体积较大,并且相当的耗费内存资源。
这个时候动态连接的优势就体现出来了,我们可以将需要共享的代码单独提取出来,保存成一个独立的动态链接库,等到程序运行的狮虎再将它们加载到内存,这样就能节省空间了。
动态链接实际上是将链接的整个过程推迟到了程序加载的时候,比如说我们去运行一个程序,操作系统首先会将程序的代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使用情况为他们动态分配一段内存。
当动态库被加载到内存以后,一旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转地址了。
7.3.2.2 我们的可执行程序被编译器动了手脚

我们可以发现我们的可执行程序main.exe除了依赖c标准库还依赖了红色方框里面的东西,ls,pwd也是,那么这个红色方框中的东西到底是什么呢?
当我们C/C++程序中,当程序开始执行时,它首先并不会直接跳转到 main 函数。实际上,程序的入口点是 _start ,这是⼀个由C运行时库(通常是glibc)或链接器(如ld)提供的特殊函数。在_start函数中,会执行⼀系列初始化操作,这些操作包括:
- 设置堆栈:为程序创建⼀个初始的堆栈环境。
- 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零未初始化的数据段。
- 动态链接:这是关键的一步,
_start函数会调用动态链接器的代码来解析和加载程序所依赖的动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调用和变量访问能够正确地映射到动态库中的实际地址。
动态链接器:
- 动态链接器(如ld-linux.so)负责在程序运行时加载动态库。
- 当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中。
环境变量和配置文件:
- Linux系统通过环境变量(如LD_LIBRARY_PATH)和配置⽂件(如
/etc/ld.so.conf及其子配置文件)来指定动态库的搜索路径。- 这些路径会被动态链接器在加载动态库时搜索。
缓存文件:
- 为了提高动态库的加载效率,Linux系统会维护⼀个名为
/etc/ld.so.cache的缓存文件- 该文件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会首先搜索这个缓存文件。
- 调用
__libc_start_main:⼀旦动态链接完成,_start函数会调用__libc_start_main(这是glibc提供的⼀个函数)。__libc_start_main函数负责执行⼀些额外的初始化工作,比如设置信号处理函数、初始化线程库(如果使用了线程)等。 - 调用
main函数:最后__libc_start_main函数会调用程序的main函数,此时程序的执行控制权才正式交给用户编写的代码。 - 处理
main函数的返回值:当main函数返回时,__libc_start_main会负责处理这个返回值,并最终调用_exit函数来终止程序。
7.3.2.3 动态库中的相对地址
动态库为了随时进行加载,为了支持并映射到任意进程的任意位置,对于动态库中的方法也是统一编址,采用相对编址的方案进行编址的.动态库中的方法其实就是一个代码块,入口函数是该方法的起始地址,动态库也是ELF格式,我们也理解成为起始地址(0)+偏移量.
# ubuntu下查看任意⼀个库的反汇编
objdump -S /lib/x86_64-linux-gnu/libc-2.31.so | less
# Cetnos下查看任意⼀个库的反汇编
$ objdump -S /lib64/libc-2.17.so | less

7.3.2.4 我们的程序,怎么和库具体映射起来的
📌注意:
- 动态库也是⼀个文件,要访问也是要被先加载,要加载也是要被打开的.
- 让我们的进程找到动态库的本质:也是文件操作,不过我们访问库函数,通过虚拟地址进行跳转访问的,所以需要把动态库映射到进程的地址空间中.

创建内核数据结构,加载可执行程序,我们的可执行程序在执行_start的时候,它要执行动态链接器所对应的方法,他要识别到我们的这个程序要依赖于c标准库,访问c标准库它就得先找到这个库,肯定是能找到这个库的,因为ELF中的符号表会记录下来所依赖的库对应的路径

所以可执行程序所依赖的库在哪个路径下早就记录下来了,路径都知道了所以在文件系统层面上我们就能把这个库打开,根据它的struct_file找到dentry找到inode找到_block,把动态库加载到内存。
所以第一步加载磁盘文件中的数据块,第二步加载库中的内容。加载进来的同时,同代库中的方法也是采用绝对编址的,这时候我们把库中的虚拟地址当作库中方法的偏移量,与此同时这个库也有大小,我们把库映射到进程的地址空间,创建一个vm_area_struct把这个结点插入到链表当中,用库的大小来初始化vm_area_struct中的start和end,同时建立虚拟地址和物理地址之间的映射关系,库的虚拟地址加上库中方法的偏移量此时就能访问到库了。

7.3.2.5 我们的程序,怎么进行库函数调用
📌注意:
- 库已经被我们映射到了当前进程的地址空间中
- 库的虚拟起始地址我们也已经知道了
- 库中每⼀个方法的偏移量地址我们也知道
- 所以:访问库中任意方法,只需要知道库的起始虚拟地址+方法偏移量即可定位库中的方法
- 而且:整个调用过程,是从代码区跳转到共享区,调用完毕在返回到代码区,整个过程完全在进程地址空间中进行

7.3.2.6 全局偏移量表GOT(global offset table)
📌注意:
- 也就是说,我们的程序运行之前,先把所有库加载并映射,所有库的起始虚拟地址都应该提前知道
- 然后对我们加载到内存中的程序的库函数调用进行地址修改,在内存中二次完成地址设置(这个叫做加载地址重定位),谁调用库修改谁的起始地址.
- 等等,修改的是代码区?不是说代码区在进程中是只读的吗?怎么修改?能修改吗?
所以:动态链接采用的做法是在.data(可执行程序或者库自己)中专门预留一片区域用来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每⼀项都是本运行模块要引用的⼀个全局变量或函数的地址。 - 因为
.data区域是可读写的,所以可以支持动态进行修改

- 由于代码段只读,我们不能直接修改代码段。但有了GOT表,代码便可以被所有进程共享。但在不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上,就是每个进程的每个动态库都有独立的GOT表,所以进程间不能共享GOT表。
- 在单个
.so下,由于GOT表与.text的相对位置是固定的,我们完全可以利用CPU的相对寻址来找到GOT表。 - 在调用函数的时候会⾸先查表,然后根据表中的地址来进⾏跳转,这些地址在动态库加载的时候会被修改为真正的地址。
- 这种方式实现的动态链接就被叫做 PIC 地址无关代码 。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享,这也是为什么之前我们给编译器指定
-fPIC参数的原因,PIC=相对编址+GOT。
7.3.2.7 库间依赖

在main函数调用puts时还有个plt是什么呢?
📌注意:
- 不仅仅有可执行程序调用库
- 库也会调用其他库!!库之间是有依赖的,如何做到库和库之间互相调用也是与地址无关的呢?
- 库中也有
.GOT,和可执行一样!这也就是为什么大家为什么都是ELF的格式!

- 由于动态链接在程序加载的时候需要对大量函数进行重定位,这⼀步显然是非常耗时的。为了进⼀步降低开销,我们的操作系统还做了⼀些其他的优化,比如延迟绑定,或者也叫PLT(过程连接表(Procedure Linkage Table))。与其在程序⼀开始就对所有函数进行重定位,如将这个过程推迟到函数第⼀次被调用的时候,因为绝大多数动态库中的函数可能在程序运行期间⼀次都不会被使用到。
思路是:GOT中的跳转地址默认会指向⼀段辅助代码,它也被叫做桩代码
/stup。在我们第⼀次调用函数的时候,这段代码会负责查询真正函数的跳转地址,并且去更新GOT表。于是我们再次调用函数的时候,就会直接跳转到动态库中真正的函数实现。
总而言之,动态链接实际上将链接的整个过程,比如符号查询、地址的重定位从编译时推迟到了程序的运行时,它虽然牺牲了⼀定的性能和程序加载时间,但绝对是物有所值的。因为动态链接能够更有效的利用磁盘空间和内存资源,以极大方便了代码的更新和维护,更关键的是,它实现了⼆进制级别的代码复用。
📌解析依赖关系的时候,就是加载并完善互相之间的GOT表的过程.
7.3.3 总结
- 静态链接的出现,提高了程序的模块化水平。对于一个大的项目,不同的人可以独立的测试和开发自己的模块。通过静态链接,形成最终的可执行文件。
- 静态链接会将编译产生的所有目标文件和用到的各个库合并成一个独立的可执行文件,其中我们会去修正模块间函数的跳转地址,也被叫做编译重定位(也叫做静态重定位)。
- 而动态链接实际上是将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都不是固定的,但无论加载到什么地方,都要映射到进程的地址空间,然后通过
.GOT方式进行调用(运行重定位,也叫做动态地址重定位)。
👍 如果对你有帮助,欢迎:
- 点赞 ⭐️
- 收藏 📌
- 关注 🔔


