我们在上一篇(传送门:解锁动静态库的神秘力量1:从代码片段到高效程序的蜕变-CSDN博客)讲解了关于动静态库如何使用的要点及规则;下面肯定会有很多疑问;为什么要那么操作;此篇我们为上一篇的补充;续集;将带大家了解动静态链接的底层原理完成对上一篇所用的规则和指令展开讲解分析;准备好,那我们就出发了!!!
欢迎拜访:羑悻的小杀马特.-CSDN博客本篇主题:秒懂百科之探究动静态库第二讲
制作日期:2025.01.23
隶属专栏:linux之旅
目录
一·ELF文件:
1.1ELF文件概念:
首先我们要先命名它是什么;然后下面再介绍它的组成。
就是xxx.o⽂件;包含适合于与其他⽬标⽂件链接来创建可执⾏⽂件或者共享⽬标⽂件的代码和数据。
可以分为一下几种:
1· 可执⾏⽂件(Executable File) :即可执⾏程序。
2·共享⽬标⽂件(Shared Object File) :即xxx.so⽂件。
3· 内核转储(core dumps) ,存放当前进程的执⾏上下⽂,⽤于dump信号触发。
也就是.o:动态库;可执行文件等 。
1.2ELF内部组成:
那么它是怎么组成的呢?
⼀个ELF⽂件由以下四部分组成:
②程序头表(Program header table) :列举了所有有效的段(segments)和他们的属性。表⾥ 记着每个段的开始的位置和位移(offset)、⻓度,毕竟这些段,都是紧密的放在⼆进制⽂件中,需要段表的描述信息,才能把他们每个段分割开。 ③节头表(Section header table) :包含对节(sections)的描述。 ④节(Section ):ELF⽂件中的基本组成单位,包含了特定类型的数据。ELF⽂件的各种信息和 数据都存储在不同的节中,如代码节存储了可执⾏代码,数据节存储了全局变量和静态数据等。
①ELF头(ELF header) :描述⽂件的主要特性。其位于⽂件的开始位置,它的主要⽬的是定位⽂件的其他部分。
那么下面我们看一张ELF图:
这里的Elf也是会合并的;那么就以比如我们生成动态静态库那些.o文件底层是怎么操作的;其实就是相应位置进行有规则的合并:
将多份 C/C++ 源代码,翻译成为⽬标 .o ⽂件;将多份 .o ⽂件section进⾏合并。
一张图通俗一点:
合并原则:相同属性,⽐如:可读,可写,可执⾏,需要加载时申请空间等。
这里有两次合并:一次是形成最终的ELF也就是多个.o打包之类;它们对应的section就会合并:
下一次合并就是在我们加载的时候;也就是section合并成segment:
然而;这个合并⼯作也已经在形成ELF的时候,合并⽅式已经确定了,具体合并原则被记录在了ELF的 程序头表(Program header table) 中。
这样可以节约块内存:内存也是4kb的申请:
假设页面大小为4096字节(内存块基本大小,加载,管理的基本单位),如果.text部分 假设页面大小为4096字节(内存块基本大小,加载,管理的基本单位),如果.Text部分。
总结一句话;就是为了节约内存空间。
1.3ELF组成结构分析:
首先我们先从程序头表和节头表来分析:
链接视图(Linking view) :对应节头表 Section header table :
⽂件结构的粒度更细,将⽂件按功能模块的差异进⾏划分,静态链接分析的时候⼀般关注的是链接视图,能够理解ELF⽂件中包含的各个部分的信息。
为了空间布局上的效率,将来在链接⽬标⽂件时,链接器会把很多节(section)合并,规整成可执⾏的段(segment)、可读写的段、只读段等。合并了后,空间利⽤率就⾼了,否则,很⼩的很⼩的⼀段,未来物理内存⻚浪费太⼤(物理内存⻚分配⼀般都是整数倍⼀块给你;⽐如4k,所以,链接器趁着链接就把⼩块们都合并了。
执⾏视图(execution view) 对应程序头表 Program header table :
告诉操作系统,如何加载可执⾏⽂件,完成进程内存的初始化。⼀个可执⾏程序的格式中,⼀定有 program header table 。
说⽩了就是:⼀个在链接时作⽤,⼀个在运⾏加载时作⽤。
其次就是各个section部分:
.text节 :是保存了程序代码指令的代码节。
.data节 :保存了初始化的全局变量和局部静态变量等数据。
.rodata节 :保存了只读的数据,如⼀⾏C语⾔代码中的字符串。由于.rodata节是只读的,所以只能存在于⼀个可执⾏⽂件的只读段中。因此,只能是在text段(不是data段)中找到.rodata节。
.BSS节 :为未初始化的全局变量和局部静态变量预留位置
.symtab节 :Symbol Table符号表,就是源码⾥⾯那些函数名、变量名和代码的对应关系。
.got.plt节 (全局偏移表-过程链接表):.got节保存了全局偏移表。.got节和.plt节⼀起提供了对导⼊的共享库函数的访问⼊。由动态链接器在运⾏时进⾏修改。对于GOT的理解,我们后⾯会说
这里我们稍微对symtab节分析一下:
这里记录的是一些变量放在磁盘位置的偏移量;如"a\0 adad\O"找a只需要知道偏移量就能找到;比如:
1.4相关指令使用:
大概了解了ELF文件操作时候各部分是怎么工作的;下面我们就以实例展示一下:
首先先搞的是简单的测试代码:
分别是main.c 和halo.c:
1.4.1查看头节表:
其次还有个size指令帮助我们查看对应部分尺寸:
因为上面.o编译查找地址找不到故地址处都是0;下面我们以可执行a.out来观看:
1.4.2查看symbol节:
1.4.3查看程序头表:
这里不仅能看它还以可按合并后的segment。
下面就是我们合并后segment的记录:
1.4.4查看ELF头:
它的主要目的是定位文件的其他部分。 它的主要目的是定位文件的其他部分.
有了上面关于ELF文件是什么;以及构成部分做基础;我们下面探究一下是如何完成链接的。
二·理解链接与加载:
我们上面讲的过程可以看做静态链接宏观的结果(a.out)
2·1静态链接:
研究静态链接,本质就是研究.o是如何链接的。
这里多个.o完成静态以及.o和静态库链接等都是一样处理的 。
a. out是静态链接形成的;因为在编译成.o文件时候没进行fpic即保存对应的got表等(后面讲)故静态 。
下面我们从反汇编角度观看是如何静态链接合并文件的: 下面我们从反汇编角度观看是如何静态链接合并文件的:
反汇编指令:
下面给它链接后对应的a.out 对应e8位置:
代码块的.data 中有重定位表进行修改。
当所有模块组合在一起之后,链接器会根据我们的.o文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址。这其实就是静态链接的过程 。
所以,链接过程中会涉及到对.o中外部符号进行也址重定位。
2.1·1如何关联起来的:
首先我们要明白:
首先我们要明白磁盘里的是逻辑地址(等于内部的虚拟地址)也就是偏移量从0开始编址但是不一定从O开始使用。
虚拟地址机制,不光光OS要⽀持,编译器也要⽀持.
也就是回答一个问题程序没有被加载进行就有自己对应的逻辑地址(静态的时候和自己的虚拟地址一样)。
其实我们的可执行程序编译的时候就已经链接好了。
下面我们一张图加注释帮助理解一下:
上面我们在看EFL头的时候;不是标注了entry point adress嘛这里即用到了。
初始化进程的对应区域放大版:
2. 1.2怎么运行起来:
2.2动态链接:
动态链接其实远比静态链接要常用得多。
为什么要动态?:因为静态链接是拷贝到可执行文件如果在加载内存:这样会占据大连内存;而动态的话直接把动库加载内存供多个程序共享。
动态小tip:
动态链接实际上将链接的整个过程推迟到了程序加载的时候。
2.2.1如何链接:
这里我们以动态库和可执行程序链接为例:
如何调用相关库函数:
1.被进程看到:动态库映射到进程的地址空间
2·被进程调用:在进程的地址空间中进行跳转
如图:
那真的是像我们说的这么简单吗?
我们要知道的.data的可读区是不能修改的:
我们都是要这样访问动态库的(和静态不同)但是这样我们加载完就会修改代码区了;但是是不能能修改的因此引入got。
下面我们如果用ldd指令查看可执行程序动态链接情况:
就会发现上面划线的部分:
这里是动态链接器;当程序开始运行的时候;其中的_start函数会调用动态链接器去从指定位置查询动态库把它加载进内存(四大搜索路径).
介绍一下_start函数:
在C/C++程序中,当程序开始执⾏时,它⾸先并不会直接跳转到 main 函数。实际上,程序的⼊⼝点是 _start ,这是⼀个由C运⾏时库(通常是glibc)或链接器(如ld)提供的特殊函数。在 _start 函数中,会执⾏⼀系列初始化操作,这些操作包括:
1. 设置堆栈:为程序创建⼀个初始的堆栈环境。
2. 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零未初始化的数据段。
3· 动态链接:这是关键的⼀步, _start 函数会调⽤动态链接器的代码来解析和加载程序所依赖的动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调⽤和变量访问能够正确地映射到动态库中的实际地址。
下面就是_start函数调用动态链接器去查找我们上篇所讲的四大路径(也就是为它埋下了伏笔);此时它会从相应的缓存文件去查询的。
为了提高动态库的加载效率,Linux系统会维护一个名为/etc/ld.so.cache的缓存文件。该文件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会首先搜索这个缓存文件。
当我们告诉系统动态库位置的时候就会存入这个级存文件;到时候加载直接拿到接着就是hash文件路径名字找到dentry节点去磁盘加载到指定文件缓冲区即可。
这样就简单多了。
动态库为了随时进⾏加载,为了⽀持并映射到任意进程的任意位置,对动态库中的⽅法,统⼀编址,采⽤相对编址的⽅案进⾏编制的(其实可执⾏程序也⼀样,都要遵守平坦模式,只不过exe是直接加载的)。
如何找到:库的起始虚拟地址+方法偏移量
因此下面我们引入了got(因为代码区是不能修改的):
动态链接采⽤的做法是在 .data (可执⾏程序或者库⾃⼰)中专⻔预留⼀⽚区域⽤来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每⼀项都是本运⾏模块要引⽤的⼀个全局变量或函数的地址。
就是每个进程,每个动态库都有独立的GOT表,所以进程间不能共享GOT表。
我们的got存在于可执行文件或者动态库里面.data 里。
运行时找相应的got表:由于GOT表与 .text 的相对位置是固定的,我们完全可以利⽤CPU的相对寻址来找到GOT表。
那么:
在调⽤函数的时候会⾸先查表,然后根据表中的地址来进⾏跳转,这些地址在动态库加载的时候会被修改为真正的地址。
这种⽅式实现的动态链接就被叫做 PIC 地址⽆关代码 。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运⾏,并且能够被所有进程共享,这也是为什么之前我们给编译器指定-fPIC参数的原因,PIC=相对编址+GOT。
这也就解释了我们动态链接为了这么形成.o文件:
有了上面的知识普及;我们就把它联系起来了。
2.2.2如何加载运行:
但是真正都是在加载的时候(还没启动代码)就把对应的虚拟地址填到got表吗?
由于动态链接在程序加载的时候需要对大量函数进行重定位,这一步显然是非常耗时的。为了进一步降低开销,我们的操作系统还做了一些其他的优化,比如延迟绑定,或者也叫PLT(过程连接表(Procedure Linkage Table))。与其在程序一开始就对所有函数进行重定位,不如将这个过程推迟到函数第一次被调用的时候,因为绝大多数动态库中的函数可能在程序运行期间一次都不会被使用到。
更加清晰的解释:
也就是说;我们gcc编译.o和动库时候;我们填充好了got中的偏移量;当运行即加载的过程时;虚拟地址暂时不填入got;而是当我们某个库内东西的时候根据got位置借助plt拿着名字和偏移量借助共享区的虚拟地址去找到磁盘对应位置的数据来用:并填充好got供下一次使用。
下面看图:
上面说了我们的库自己也有got方便库与库的调用:
在内存中:
三·基于动静态链接的总结:
静态链接的出现,提⾼了程序的模块化⽔平。对于⼀个⼤的项⽬,不同的⼈可以独⽴地测试和开发⾃⼰的模块。通过静态链接,⽣成最终的可执⾏⽂件。
我们知道静态链接会将编译产⽣的所有⽬标⽂件,和⽤到的各种库合并成⼀个独⽴的可执⾏⽂件, 其中我们会去修正模块间函数的跳转地址,也被叫做编译重定位(也叫做静态重定位)。⽽动态链接实际上将链接的整个过程推迟到了程序加载的时候。⽐如我们去运⾏⼀个程序,操作系 统会⾸先将程序的数据代码连同它⽤到的⼀系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,但是⽆论加载到什么地⽅,都要映射到进程对应的地址空间,然后通过.GOT⽅式进⾏调⽤(运⾏重定位,也叫做动态地址重定位)。
静态链接优缺点:
优点:
可移植性好:不依赖外部库,能在不同环境直接运行,避免库版本兼容性问题。
执行速度快:无需运行时加载库,减少启动开销,内存访问效率高。
安全性较高:降低因外部库漏洞被攻击风险,确保代码完整性。
缺点:
文件体积大:重复包含库代码,占用更多磁盘空间,增加传输时间。
维护更新难:库更新需重新编译所有相关程序,浪费人力与资源。
内存占用多:多进程运行时,各程序都占一份库代码内存,浪费资源。
动态链接优缺点:
1·更加节省内存并减少页面交换.
2·更加节省内存并减少页面交换.
3 ·不同编程语言编写的程序只要按照函数调用约定就可以调用同一个库函数.
4·适用于大规模的软件开发,使开发过程独立、耦合度小,便于不同开发者和开发组织之间进行开发和测试.
5·运行时依赖,否则找不到库文件就会运行失败·运行加载速度相较静态库慢一些.
6·需要对库版本之间的兼容性做出更多处理.
这里可以大概记一下:动态链接减少内存但是速度慢;静态内存大但是速度快;静态就是拷贝但是动态是虚拟的链接;在加载(执行)的时候才是真正的.
我们的动静态库的使用和原理就介绍到这了;这两篇虽然不敢说是绝对严谨,详细;但是可以帮助刚入门的小白快速上手明白;了解大概得过程;故欢迎大家多多支持博主创作的这两篇文章呀!!!