个人主页:小则又沐风
个人专栏:<数据结构>
<竞赛专栏>
<C语言>
<C++>
<Linux>
座右铭
悟已往之不谏,知来者之可追
目录
前言
目标文件
ELF文件
ELF Header
节头表(Section header table)
节(Section )
程序头表(Program header table)
ELF形成可执行文件
静态链接
ELF加载与进程地址空间
动态链接
前言
上一篇的文章中我们知道了库文件的制作以及怎么使用我们自己制作的库文件,但是上述的内容只是停留在使用的层面上,今天我们来了解一下底层的知识.
目标文件
我们之前使用的编写代码的工具都是vs2022,在这上面上我们没有直观的看到代码的编译和链接
在Linux下可以清楚的看到.
//code.c #include<stdio.h> void run(); int main() { printf("hello,world\n"); run(); return 0; } //hello.c #include<stdio.h> void run() { printf("i am runing...\n"); }我们简单的编写这个代码,来看看这个代码的编译和链接的过程
gcc -c code.c hello.c可以看到的是现在我们得到了两个.o后缀的文件,这两个文件就是目标文件,如果我们修改了一个目标文件的源文件的话我们只需要重新编译就可以了,不需要进行过多的操作
我们打开目标文件就会发现这个文件是一个二进制的文件
目标文件的格式是ELF格式
ELF文件
这张图中我们看到这个信息:relocatable
这个代表的是这个文件是一个可重定向的文件(这个具体的含义之后会进行讲解)
那么ELF文件的类型都有什么?
- 可重定位⽂件(Relocatable File) xxx.o
- 可执⾏⽂件(Executable File)
- 共享⽬标⽂件(Shared Object File)xxx.so
- 内核转储(core dumps)
如上图就是ELF格式的示意图
ELF Header
这个是ELF的表头,里面将会描述ELF文件的主要特征.
主要的内容是:
- 定位节头表,程序头表
- 可执行程序的入口地址(在目标文件中是0)
- 还有特定的内容来作为自己的标识符
下面来简单的查看一下我们目录下的目标文件
readelf -h code.o我们可以这样来理解Header这个表中的信息是来标识自己的特点的,并且指出了自己的ELF中节头表和程序表的地址和文件中偏移量,并且需要我们注意的是上面中的信息有一个Entry point address
这个就是程序的入口地址(在之后会进行详细的讲解)
节头表(Section header table)
节头中会存储着我们ELF中主要的信息,可以理解为文件中的数据.程序中的数据根据着不同的特征划分在了不同的节中
那么这个节头表的作用就是用来找到不同的节,有助于之后的不同的节的合并
节(Section )
节就是存储着ELF文件的主要数据的部分,根据不同的数据类型会分成为许多不同的节
例如:
.text 代码节 存储可执行机器指令(程序核心逻辑).data 数据节 存储已初始化的全局变量和局部静态变量.bss 未初始化数据节 为未初始化的全局变量 / 静态变量预留空间(不占文件空间,加载时分配内存).symtab 符号表 存储函数名、变量名与地址的映射关系.reloc 重定位表 记录需要链接时修正的地址(如外部函数调用).rodata 只读数据节 存储字符串常量等只读数据(如 printf 的字符串)
程序头表(Program header table)
这个表仅存在于可执行文件和动态库中,因为在这些的文件中,才会进行合并的操作,这个就是文件进行合并操作的指南.
拥有这个指南之后合并两个文件形同属性的内容,形成段(Segment)
段和节的区别
我们在上述讲解的过程中产生了两个名词------段和节
那么这两个的区别是什么呢?
首先节是产生在编译的环节中的,相对于段,节对数据的分类是更加的细致的.例如:代码节,数据节.....
然而段是产生在执行的时候的,因为他是将多个ELF文件按照程序头表这个指南合并产生的段
段对数据的分类并没有那么的细致了,比如:可读,不可读....
那么为什么需要段,不能把节使用到底吗?
段的使用会使内存得到更充分的利用
假设我们的代码节的内存是4097个字节,数据段是127字节
这时候就会导致有的页会有很多的内存空余,但是如果用段合并的话,就会大大的减少上述的情况
解决了内存的碎片化
ELF形成可执行文件
- 编译形成ELF文件(将C/C++源文件编译形成目标文件.o)
- 将目标文件进行合并
下面我们先来看看静态链接的过程
静态链接
首先我们现在手下有很多的.o文件,这些文件共同的特点就是他们都是ELF格式的
在编译链接的时候这些.o文件会把相同的节进行合并
例如:
将多个的代码节合并成一个得到代码节
但是他是怎么知道那些节存储的是什么呢??这时候通过查找节头表和表头就可以知道各个节的起始和大小了,进行合并就可以了
然后将节合并之后,然后将函数的地址进行填写
objdump -d code.o运行这个指令来看一下反汇编
可以看到这个函数的回调的地址是一个0,
这就是因为我们的code.c文件中没有这个函数的实现,但是当把目标文件进行合并之后就会有了函数的地址了,所以就要进行函数地址的填写
可以看到在我们链接之后的可执行文件函数的地址的确是完成了填写的
总结一下静态链接
首先编译形成一个个的目标文件,这些的目标文件都是ELF格式,然后进行链接,将每个.o文件的相同的节进行合并,将静态库中嵌入到文件中,重定向函数的地址
ELF加载与进程地址空间
首先ELF在加载到内存的时候他是有地址的.
在链接的时候会基于平坦模式对形成的段进行编址(平坦模式就是起始地址是0),因为初始地址是0所以逻辑地址是和虚拟地址相同的,然后在Header中会存储着各个段的起始地址和长度的,并且还存储着程序的入口地址,所以在加载到内存的时候需要做的就是填写进程的mm_struct表.因为在ELF中都存有各个段的起止地址直接复制就可以.拿着程序入口地址执行程序就可以了
动态链接
动态链接与静态链接不同就是静态链接是在链接的时候就把函数的地址进行了重定向了,然而动态链接的函数的重定向被推迟到了程序加载的时候了.
那么动态库链接的详细的过程是什么呢??
首先需要确定这个程序有什么动态库吧
怎么确定的呢?
我们需要知道的是一个程序的入口不是main函数,而是一个_start函数
在这个_start函数中
- 为程序准备堆栈环境
- 初始化数据段
- 最重要的就是检查这个程序依赖的动态库有什么,然后加载动态库文件到内存中.
补充:
加载动态库的实质就是打开这个动态库的文件,打开文件就是我们熟悉的文件操作了
也就是通过目录查找文件名和inode的映射,查找目标文件的inode,找到文件的数据段
在这里就是将动态库中需要的函数和方法加载到了内存中
不过我们访问库函数,通过虚拟地址进 ⾏跳转访问的,所以需要把动态库映射到进程的地址空间中
具体就是我们通过文件操作打开了对应的库文件,加载到物理内存之后,在页表中建立映射,填写虚拟地址表,得到初始虚拟地址
那么进行库函数调用
程序进行到了动态库函数的部分,这时候就从代码区跳转到共享区,拿到库函数的起始虚拟地址,
方法的地址偏移量和起始地址就可以找到对应的方法函数
动态库为了实现随时都可以加载,采用了相对编址的方法
那么现在就是这样的过程了
首先在_start函数中加载需要的库函数,然后在我们的代码中函数的地址进行二次定位
但是有一个问题就是代码区的数据时只读的,我们没有权限对他进行修改
所以:动态链接采⽤的做法是在 .data (可执⾏程序或者库⾃⼰)中专⻔预留⼀⽚区域⽤来存放函数 的跳转地址,它也被叫做全局偏移表GOT,表中每⼀项都是本运⾏模块要引⽤的⼀个全局变量或函数 的地址。
所以动态库加载的完全体就是这样的过程:
首先在_程序启动的时候加载需要的库函数,然后在数据区中的GOT表中填写动态库的地址
我们的代码区的结构是这样的
假设我们将要调用printf函数
他会给出我们在GOT表中这个方法的位置
然后再加载入动态库之后把printf函数的地址填写到这个GOT表中
简单的来说GOT表相当于一个目录一样再加载的时候提供方法的地址
代码运行到需要库函数的时候直接查表就行了