ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

库制作与原理---库的理解和加载(下)

库制作与原理---库的理解和加载(下) 目录1.库的理解与加载2.ELF程序如何加载到内存的找到它路径文件名ELF程序是如何转换称为进程的[逻辑地址物理地址虚拟地址]虚拟地址空间重新理解进程虚拟地址空间​编辑3.动态库是如何和我们的可执行程序关联---通过地址空间进行关联进程如何看到动态库进程间如何共享库的4.动态库是如何加载的我们的可执行程序被编译器动了手脚动态库中的相对地址我们的程序怎么进行库函数调用全局偏移量表GOT(globaloffsettable)库间依赖(简单说明即可)总结1.库的理解与加载2.ELF程序如何加载到内存的找到它路径文件名ELF程序是如何转换称为进程的[逻辑地址物理地址虚拟地址]虚拟地址空间一个可执行程序如果没有被加载到内存中该可执行程序有没有地址---有地址起始地址偏移量就可以找到每一个内部元素的地址对可执行程序完成在磁盘上的编址。磁盘上的地址称为逻辑地址如果每个seg的开始地址都是0呢 32位64位---所有的可执行程序就是一个seg那么所有seg所有函数变量编址起始偏移量都从0开始objdump -d main.exe main.sobjdump -d main.exe main.s 是一个用于反汇编的命令它将二进制可执行文件 main.exe 中的机器码转换为汇编语言并保存到 main.s 文件中。[user1iZ5waahoxw3q2bZ 26-5-18.3]$ objdump -S main.exe main.s [user1iZ5waahoxw3q2bZ 26-5-18.3]$ vim main.s这种地址都是从小到大依次增大的采用平坦模式对于全0到全F统一编址平坦模式编址一个ELF程序在没有被加载到内存的时候,本来就有地址当代计算机工作的时候都采用平坦 模式进行工作。所以也要求ELF对自己的代码和数据进行统一编址。也可以叫做线性地址。上一次说的全0到全F的 虚拟地址空间---不仅仅是进程看待内存的方式磁盘上的可执行程序代码和数据编码其实就是虚拟地址的统一编址在磁盘中可执行程序里依旧习惯叫做逻辑地址在Linux系统里实际加载内存了已经是虚拟地址了最左侧的就是ELF的虚拟地址其实严格意义上应该叫做逻辑地址(起始地址偏移量),但是我们认为起始地址是0.也就是说其实虚拟地址在我们的程序还没有加载到内存的时候就已经把可执行程序进行统⼀编址了.• 进程mm_struct、vm_area_struct在进程刚刚创建的时候初始化数据从哪里来的从ELF各个segment来每个segment有自己的起始地址和自己的长度用来初始化内核结构中的[start,end]等范围数据另外在用详细地址填充页表。所以虚拟地址机制不光光OS要支持编译器也要支持所以多个segment合并是在对可执行程序进行统一编制。有了统一地址之后再去修改曾经每个.o里的call把全0改成真实的地址。所以可执行程序之间互相调用的关系就产生了。所以只要加载可执行程序操作系统就会将可执行程序的地址比如各个segment每个segment都有自己的起始地址加上偏移量初始化我们的地址空间。重新理解进程虚拟地址空间我们对应的可执行程序在没有加载到物理内存时本身就作为可执行程序的一部分就在代码中存在这样虚拟地址已经有了。加载到内存当中加载到代码区。除了物理内存还要创建mm_struct结构体、创建进程PCBmm_struct结构体里有一个代码区。有了代码区就要有它的起始地址跟偏移量。直接用物理内存中那个1060做初始化开辟出偏移量这个大小就是可执行程序代码区的大小假设是100字节。那么那边就是100字节当我们把程序加载到物理内存时本来就有虚拟地址。用它的虚拟地址初始化mm_struct。当我们代码加载到物理内存的时候每一行代码真正都加载到物理内存上所以每一行代码都存在它的物理地址。一旦加载了页表左边就是虚拟地址右边物理地址。这样虚拟地址和物理地址映射关系就建立好了。cpu怎么知道你的可执行程序的起始地址是什么也就是CPU怎么知道从哪里开始执行呢cpu内部存在一个指针寄存器叫做EIP。用来表明当前程序CPU执行指令下一条将要执行的指令在内存中的地址CPU 本身并不知道程序的起始地址而是由操作系统或引导程序负责设置 CPU 的指令指针寄存器如 EIP/RIP将其指向程序的入口点然后 CPU 才开始从该地址取指执行。ELF格式是有自己的ELF Header表明这个可执行程序的整体情况其中有个非常重要的字段Entry point address这是我们可执行文件的入口地址被单独记录在ELF Header当中的Entry point address会把我们这个地址load到CPU的EIP中从而CPU开始调度我们的进程了。CPU开始取EIP的地址CR3是一个寄存器指向当前进程的页表在CPU内部还有一个MMU寻址时CPU要先把虚拟地址转成物理地址CPU 执行指令时指令中给出的地址如mov eax, [0x8049000]是一个虚拟地址。该虚拟地址被送到 MMUMMU 借助页表在内存中或 TLB 缓存中将其翻译成物理地址然后用物理地址访问内存。OS直接将物理内存中那条指令load到CPU内部。进到CPU内部的地址全都是虚拟地址出来的都是物理地址。也就是说我们的可执行程序入口是被记录在对应的ELF Header中这个地址就是一个虚拟地址。在CPU内部这个地址叫做虚拟地址在磁盘上叫做逻辑地址。所以这样操作系统和编译器就统一了操作系统认为这个叫做虚拟地址空间编译器认为这个叫做平坦模式假如我们这边磁盘上加载的是main.exe创建数据结构加载可执行程序加载可执行程序的时候不一定把我们的代码和数据加载进来但一定要把管理信息加载进来(ELF Header、Program Header、Section Header)。所以构建进程PCB、构建进程地址空间形成mm_struct构建页表然后加载我们对应的代码和数据。我们自己的ELF格式入口地址dentry虚拟地址等去初始化。这样虚拟地址跟物理地址都有了建立了映射关系。那我们在操作系统中怎么找到这个exe呢这个文件在我们OS中就相当于struct_file其中包含了dentry进而找到节点找到inode找到block然后把它的代码和数据记录下来了[user1iZ5waahoxw3q2bZ 26-5-18.3]$ readelf -h main.exe ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2s complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: EXEC (Executable file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x400430 Start of program headers: 64 (bytes into file) Start of section headers: 6520 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 9 Size of section headers: 64 (bytes) Number of section headers: 31 Section header string table index: 30 [user1iZ5waahoxw3q2bZ 26-5-18.3]$ readelf -h hello.o ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2s complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: REL (Relocatable file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x0 Start of program headers: 0 (bytes into file) Start of section headers: 728 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 0 (bytes) Number of program headers: 0 Size of section headers: 64 (bytes) Number of section headers: 13 Section header string table index: 12为什么两个的Entry point address不一样一个是0x400430一个是0x0。linux中程序入口地址叫做start而start向后执行会调用libc_start_main就执行了我们对应的main函数了虚拟地址空间的形成是通过ELF读取ELF相关的属性和它上面的虚拟地址构成的3.动态库是如何和我们的可执行程序关联---通过地址空间进行关联进程如何看到动态库库从代码区跳到共享区把库函数调完返回就可以完成库函数调用函数调用是代码区内部跳转库函数调用1.被进程看到动态库映射到进程的地址空间2.被进程调用在进程的地址空间进行跳转如果有两个进程呢进程间如何共享库的进程A加载代码和数据进程B加载代码和数据。比如进程A加载到内存里就可以在共享区里面访问这个库了同样B也一样映射过去也在代码区在共享区访问这个库了。动态库的本质就是在系统层面上把公共的代码抽取出来动态库的代码不会出现重复了所以动态库也叫做共享库4.动态库是如何加载的动态链接其实远比静态链接要常用得多。[user1iZ5waahoxw3q2bZ 26-5-18.3]$ ldd main.exe linux-vdso.so.1 (0x00007fffa36cf000) libc.so.6 /lib64/libc.so.6 (0x00007f75fbb66000) /lib64/ld-linux-x86-64.so.2 (0x00007f75fbf34000)静态链接最大的问题在于生成的文件体积大并且相当耗费内存资源。随着软件复杂度的提升我们的操作系统也越来越臃肿不同的软件就有可能都包含了相同的功能和代码显然会浪费大量的硬盘空间这个时候动态链接的优势就体现出来了我们可以将需要共享的代码单独提取出来保存成⼀个独立的动态链接库等到程序运行的时候再将它们加载到内存这样不但可以节省空间因为同⼀个模块在内存中只需要保留⼀份副本可以被不同的进程所共享。动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行⼀个程序操作系统会首先将程序的数据代码连同它⽤到的⼀系列动态库先加载到内存其中每个动态库的加载地址都是不固定的操作系统会根据当前地址空间的使⽤情况为它们动态分配⼀段内存。当动态库被加载到内存以后⼀旦它的内存地址被确定我们就可以去修正动态库中的那些函数跳转地址了我们的可执行程序被编译器动了手脚除了依赖C标准库还依赖 第二行的东西libc.so.6 /lib64/libc.so.6 (0x00007f75fbb66000) /lib64/ld-linux-x86-64.so.2 (0x00007f75fbf34000)这个其实就是加载器运行的方法在C/C程序中当程序开始执行时它首先并不会直接跳转到main 函数。实际上程序的入口点是_start这是⼀个由C运行时库通常是glibc或链接器如ld提供的特殊函数。在_start函数中会执行⼀系列初始化操作这些操作包括1. 设置堆栈为程序创建⼀个初始的堆栈环境。2. 初始化数据段将程序的数据段如全局变量和静态变量从初始化数据段复制到相应的内存位置并清零未初始化的数据段。3. 动态链接这是关键的⼀步_start函数会调⽤动态链接器的代码来解析和加载程序所依赖的动态库sharedlibraries。动态链接器会处理所有的符号解析和重定位确保程序中的函数调用和变量访问能够正确地映射到动态库中的实际地址。动态链接器◦ 动态链接器如ld-linux.so负责在程序运行时加载动态库。◦ 当程序启动时动态链接器会解析程序中的动态库依赖并加载这些库到内存中。环境变量和配置文件◦ Linux系统通过环境变量如LD_LIBRARY_PATH和配置⽂件如/etc/ld.so.conf及其⼦配置文件来指定动态库的搜索路径。◦ 这些路径会被动态链接器在加载动态库时搜索。缓存文件◦ 为了提高动态库的加载效率Linux系统会维护⼀个名为/etc/ld.so.cache的缓存文件。◦ 该文件包含了系统中所有已知动态库的路径和相关信息动态链接器在加载动态库时会首先搜索这个缓存文件。4. 调用__libc_start_main⼀旦动态链接完成_start函数会调用__libc_start_main这是glibc提供的⼀个函数。__libc_start_main函数负责执行⼀些额外的初始化工作比如设置信号处理函数、初始化线程库如果使用了线程等5. 调用main函数最后__libc_start_main 函数会调⽤程序的main函数此时程序的执行控制权才正式交给用户编写的代码。6. 处理main函数的返回值当main 函数返回时__libc_start_main会负责处理这个返回值并最终调⽤_exit函数来终止程序。动态库中的相对地址动态库为了随时进行加载为了⽀持并映射到任意进程的任意位置对动态库中的⽅法统⼀编址采用相对编址的方案进行编制的(其实可执行程序也⼀样都要遵守平坦模式只不过exe是直接加载的)。动态库也是ELF我们也理解为 起始地址(0)偏移量代码块 # ubuntu下查看任意一个库的反汇编 objdump -S /lib/x86_64-linux-gnu/libc-2.31.so | less # Cetnos下查看任意一个库的反汇编 $ objdump -S /lib64/libc-2.17.so | less我们的程序怎么和库具体映射起来的注意• 动态库也是⼀个⽂件要访问也是要被先加载要加载也是要被打开的• 让我们的进程找到动态库的本质也是文件操作不过我们访问库函数通过虚拟地址进进行跳转访问的所以需要把动态库映射到进程的地址空间中我们的程序怎么进行库函数调用注意• 库已经被我们映射到了当前进程的地址空间中• 库的虚拟起始地址我们也已经知道了• 库中每⼀个⽅法的偏移量地址我们也知道• 所有访问库中任意⽅法只需要知道库的起始虚拟地址⽅法偏移量即可定位库中的方法• 而且整个调用过程是从代码区跳转到共享区调用完毕在返回到代码区整个过程完全在进程地址空间中进行的.全局偏移量表GOT(globaloffsettable)注意• 也就是说我们的程序运行之前先把所有库加载并映射所有库的起始虚拟地址都应该提前知道• 然后对我们加载到内存中的程序的库函数调用进行地址修改在内存中⼆次完成地址设置(这个叫做加载地址重定位)• 等等修改的是代码区不是说代码区在进程中是只读的吗怎么修改能修改吗---不能修改所以动态链接采用的做法是在.data可执行程序或者库自己中专门预留一片区域用来存放函数的跳转地址它也被叫做全局偏移表GOT表中每⼀项都是本运行模块要引用的⼀个全局变量或函数的地址。• 因为.data区域是可读写的所以可以支持动态进行修改1. 由于代码段只读我们不能直接修改代码段。但有了GOT表代码便可以被所有进程共享。但在不同进程的地址空间中各动态库的绝对地址、相对位置都不同。反映到GOT表上就是每个进程的每个动态库都有独立的GOT表所以进程间不能共享GOT表。2. 在单个.so下由于GOT表与.text 的相对位置是固定的我们完全可以利⽤CPU的相对寻址来找到GOT表。3. 在调用函数的时候会首先查表然后根据表中的地址来进行跳转这些地址在动态库加载的时候会被修改为真正的地址。4. 这种方式实现的动态链接就被叫做PIC地址⽆关代码。换句话说我们的动态库不需要做任何修改被加载到任意内存地址都能够正常运⾏并且能够被所有进程共享这也是为什么之前我们给编译器指定-fPIC参数的原因PIC相对编址GOT。库间依赖(简单说明即可)注意•不仅仅有可执行程序调⽤库•库也会调用其他库库之间是有依赖的如何做到库和库之间互相调⽤也是与地址⽆关的呢•库中也有.GOT,和可执⾏⼀样这也就是为什么大家为什么都是ELF的格式由于GOT表中的映射地址会在运行时去修改我们可以通过gdb调试去观察GOT表的地址变化。在这里我们只用知道原理即可。• 由于动态链接在程序加载的时候需要对⼤量函数进⾏重定位这⼀步显然是⾮常耗时的。为了进⼀步降低开销我们的操作系统还做了⼀些其他的优化⽐如延迟绑定或者也叫PLT过程连接表ProcedureLinkageTable。与其在程序⼀开始就对所有函数进行重定位不如将这个过程推迟到函数第⼀次被调⽤的时候因为绝大多数动态库中的函数可能在程序运行期间⼀次都不会被使用到。思路是GOT中的跳转地址默认会指向⼀段辅助代码它也被叫做桩代码/stup。在我们第⼀次调用函数的时候这段代码会负责查询真正函数的跳转地址并且去更新GOT表。于是我们再次调用函数的时候就会直接跳转到动态库中真正的函数实现。总而言之动态链接实际上将链接的整个过程⽐如符号查询、地址的重定位从编译时推迟到了程序的运行时它虽然牺牲了⼀定的性能和程序加载时间但绝对是物有所值的。因为动态链接能够更有效的利用磁盘空间和内存资源以极大方便了代码的更新和维护更关键的是它实现了⼆进制级别的代码复用。解析依赖关系的时候就是加载并完善互相之间的GOT表的过程.总结• 静态链接的出现提高了程序的模块化水平。对于⼀个大的项目不同的⼈可以独⽴地测试和开发自己的模块。通过静态链接⽣成最终的可执行文件。• 我们知道静态链接会将编译产⽣的所有目标文件和用到的各种库合并成⼀个独立的可执行文件其中我们会去修正模块间函数的跳转地址也被叫做编译重定位(也叫做静态重定位)。• 而动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行⼀个程序操作系统会首先将程序的数据代码连同它⽤到的⼀系列动态库先加载到内存其中每个动态库的加载地址都是不固定的但是⽆论加载到什么地方都要映射到进程对应的地址空间然后通过.GOT⽅式进行调用(运行重定位也叫做动态地址重定位)感谢你的观看期待我们下次再见
返回列表