多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

13.【Linux系统编程】从ELF格式深入理解动静态库

13.【Linux系统编程】从ELF格式深入理解动静态库 目录1. 目标文件.o都是ELF格式的1.1 ELF格式简介1.2 ELF格式详解2. ELF文件3. ELF从形成到加载轮廓3.1 ELF形成可执行3.2 ELF可执行文件加载3.2.1 Section Header 和 Program Header3.2.2 ELF Header4. 理解链接与加载4.1 静态链接4.1.1 查看code.o和hello.o的反汇编和符号表显示找不到函数4.1.2 链接后查看main.exe文件的符号表、section表及反汇编可见地址call地址修改为正确地址4.1.3 总结-静态链接过程4.2 ELF加载与进程地址空间4.2.1 虚拟地址/逻辑地址4.2.2 重新理解进程虚拟地址空间(重点)-可执行程序加载到内存并执行的流程4.2.3 磁盘、内存、可执行程序中虚拟地址和物理地址的关系总结4.2.4 连接多个.o目标文件形成可执行程序的目的总结4.3 动态链接与动态库加载4.3.1 单进程调用动态库4.3.2 多进程调用动态库进程间如何共享库4.3.3 动态链接4.3.3.1 概要4.3.3.2 我们的可执行程序被编译器动了手脚4.3.3.3 动态库中的相对地址4.3.3.4 我们的程序怎么和库具体映射起来的4.3.3.5 我们的程序怎么进行库函数调用4.3.3.6 全局偏移量表GOT(global offset table)4.3.3.7 库间依赖(简单说明即可)4.3.4 总结1. 目标文件.o都是ELF格式的1.1 ELF格式简介ELFExecutable and Linkable Format可执行与可链接格式 是 Linux 中常用的一种文件格式用来组织和保存可执行程序、目标文件和动态库等。大白话理解ELF 就像一个程序的“标准包装盒”里面不仅装着程序的代码和数据还记录了这些内容放在哪里、程序从哪里开始执行、需要哪些动态库等信息。 这样链接器就知道怎么链接程序操作系统也知道怎么加载和运行程序。常见的 ELF 文件.o目标文件等待链接。.so动态库供其他程序使用。可执行文件例如a.out可以直接运行不一定有扩展名。可以通过以下命令查看 ELF 文件的信息file a.out # 查看文件类型 readelf -h a.out # 查看 ELF 文件头 readelf -S a.out # 查看节Section信息 readelf -l a.out # 查看程序头和加载段信息一句话总结ELF 是 Linux 中组织程序代码、数据及加载信息的标准文件格式让链接器知道怎么链接让操作系统知道怎么加载执行。1.2 ELF格式详解ELF 本质是一种跨组件的二进制文件格式标准全称为 Executable and Linkable Format可执行与可链接格式。核心定位ELF 是 Linux/Unix 类系统的 “统一文件格式规范”专门用于定义可执行文件、目标文件、共享库动态库、静态库等二进制文件的存储结构。编译和链接这两个步骤在Windows下被我们的IDE封装的很完美我们一般都是一键构建非常方便但一旦遇到错误的时候呢尤其是链接相关的错误很多人就束手无策了。在Linux下我们之前也学习过如何通过gcc编译器来完成这一系列操作。接下来我们深入探讨一下编译和链接的整个过程来更好的理解动静态库的使用原理。先来回顾下什么是编译呢编译的过程其实就是将我们程序的源代码翻译成CPU能够直接运行的机器代码。比如在一个源文件hello.c里简单输出hello world!并且调用一个run函数而这个函数被定义在另一个原文件code.c中。这里我们就可以调用gcc -c来分别编译这两个原文件。// hello.c#includestdio.hvoidrun();intmain(){printf(hello world!\n);run();return0;}// code.c#includestdio.hvoidrun(){printf(running...\n);}// 编译两个源文件 $ gcc-chello.c code.c $lscode.c code.o hello.c hello.o可以看到在编译之后会生成两个扩展名为.o的文件它们被称作目标文件。目标文件是一个二进制的文件文件的格式是ELF是对二进制代码的一种封装。注意如果我们修改了一个源文件那么只需要单独编译它这一个而不需要浪费时间重新编译整个工程。$filehello.o hello.o: ELF64-bit LSB relocatable, x86-64, version1(SYSV), not stripped# file命令用于辨识文件类型。2. ELF文件要理解编译链接的细节我们不得不了解一下ELF文件。其实有以下四种文件其实都是ELF文件可重定位文件Relocatable File即 xxx.o 文件。包含适合于与其他目标文件链接来创建可执行文件或者共享目标文件的代码和数据。可执行文件Executable File即可执行程序。共享目标文件Shared Object File即 xxx.so文件。内核转储(core dumps)存放当前进程的执行上下文用于dump信号触发。一个ELF文件由以下四部分组成ELF头(ELF header)描述文件的主要特性。其位于文件的开始位置它的主要目的是定位文件的其他部分。程序头表(Program header table)列举了所有有效的段(segments)和他们的属性。表里记着每个段开始的位置和位移offset、长度毕竟这些段都是紧密的放在二进制文件中需要段表的描述信息才能把他们每个段分割开。节头表(Section header table)包含对节(sections)的描述。节Section ELF文件中的基本组成单位包含了特定类型的数据。ELF文件的各种信息和数据都存储在不同的节中如代码节存储了可执行代码数据节存储了全局变量和静态数据等。最常见的节代码节.text用于保存机器指令是程序的主要执行部分。数据节.data保存已初始化的全局变量和局部静态变量。[在磁盘中]记录未初始化的变量的数量[在内存中]加载程序时再展开并开辟空间初始化为0linux下查size命令看各个段大小bss未初始化数据段dec三个段总大小[十进制]hex三个段总大小[十六进制]$ size usercode text data bss dec hex filename15025724207881e usercode3. ELF从形成到加载轮廓3.1 ELF形成可执行step-1将多份C/C 源代码翻译成为目标.o 文件 动静态库(动态库.so是ELF格式静态库.a是归档格式但其内部封装的是ELF格式的.o文件)step-2将多份.o 文件的节section进行合并注意实际合并是在链接时进行的但是并不是这么简单的合并也会涉及对库合并此处不做过多追究。3.2 ELF可执行文件加载一个 ELF 文件中包含多种不同的 Section节例如 .text、.data、.bss 等。在链接阶段链接器会根据 Section 的权限可读、可写、可执行、内存布局等要求将相关 Section 组织到不同的 Segment段中。这些 Segment 的位置、大小、访问权限等信息会被记录在 ELF 的 程序头表Program Header Table 中。当操作系统加载 ELF 文件时就会根据程序头表中的信息将对应的 Segment 映射到进程的虚拟地址空间。一句话总结Section 是对代码和数据的分类Segment 是程序加载时使用的单位。链接阶段确定 Section 如何组织成 Segment加载阶段根据程序头表把 Segment 映射到内存。查看可执行程序的section命令readelf -选项 xxx.o/xxx.out选项功能-h查看ELF 文件的 “文件头[ELF头]ELF Header” 信息-l查看section合并的segment即 “ 程序头表Program Header”右图-S查看可执行程序的section即 “ 节头表Section Header”左图-s查看 “符号表Symbol Table”存储文件中的函数、变量等符号信息。3.2.1 Section Header 和 Program Header左图为main.exe的section(Section Header)右图为section合并的segment(Program Header)图中Key to Flag为标志的键值说明Section to Segment mapping为节到段的映射。 为什么要将section合并成为segmentSection合并的主要原因是为了减少页面碎片提高内存使用效率。如果不进行合并假设页面大小为4096字节内存块基本大小加载管理的基本单位如果.text部分为4097字节.init部分为512字节那么它们将占用3个页面而合并后它们只需2个页面。此外操作系统在加载程序时会将具有相同属性的section合并成一个大的segment这样就可以实现不同的访问权限从而优化内存管理和权限访问控制。关于4KB在“写时拷贝”、“malloc”、“new”等内存使用时都是以4KB为单位(哪怕只申请1Byte操作系统给的都是4KB)。即磁盘和内存交互的单位是4KB。通过牺牲内存提高访问时间的效率。可执行程序也是文件也是以4KB为单位保存的。回头再看第一条对于程序头表和节头表又有什么用呢其实 ELF 文件提供 2 个不同的视图/视角来让我们理解这两个部分链接视图(Linking view) - 对应节头表Section header table显示section文件结构的粒度更细将文件按功能模块的差异进行划分静态链接分析的时候一般关注的是链接视图能够理解 ELF 文件中包含的各个部分的信息。为了空间布局上的效率将来在链接目标文件时链接器会把很多节section合并规整成可执行的段segment、可读写的段、只读段等。合并了后空间利用率就高了否则很小的很小的一段未来物理内存页浪费太大物理内存页分配一般都是整数倍一块给你比如4kB所以链接器趁着链接就把小块们都合并了。执行视图(execution view) - 对应程序头表Program header table合并section告诉操作系统如何加载可执行文件完成进程内存的初始化。一个可执行程序的格式中一定有program header table。​说白了就是一个在链接时作用一个在运行加载时作用。从链接视图来看命令readelf -S hello.o可以帮助查看ELF文件的 节头表。Section功能.text节是保存了程序代码指令的代码节。.data节保存了初始化的全局变量和局部静态变量等数据。.rodata节保存了只读的数据如一行C语言代码中的字符串。由于.rodata节是只读的所以只能存在于一个可执行文件的只读段中。因此只能是在text段不是data段中找到.rodata节。.BSS节为未初始化的全局变量和局部静态变量预留位置.symtab节Symbol Table 符号表就是源码里面那些函数名、变量名和代码的对应关系。字符串表例如char lable[] “helloworld\0func\0libc\0obj\0…”而symtab只需要记住各个函数名、变量名等的起始偏移量即可。.got.plt节全局偏移表-过程链接表.got节保存了全局偏移表。.got节和.plt节一起提供了对导入的共享库函数的访问入口由动态链接器在运行时进行修改。对于GOT的理解我们后面会说。使用 readelf 命令查看 .so 文件可以看到该节。从执行视图来看告诉操作系统哪些模块可以被加载进内存。加载进内存之后哪些分段是可读可写哪些分段是只读哪些分段是可执行的。3.2.2 ELF Header我们可以在ELF头中找到文件的基本信息以及可以看到ELF头是如何定位程序头表和节头表的。例如我们查看下hello.o这个可重定位文件的主要信息# 查看目标文件hello.o的ELF头readelf-hhello.o# 查看可执行程序main.exe的ELF头$ gcc-omain.exe hello.o code.o $ readelf-hmain.exe对于ELF HEADER 这部分来说我们只用知道其作用即可它的主要目的是定位文件的其他部分。对ELF中几个内容的理解魔术通过Magic判断文件的格式。此处功能系统判断要加载的文件是不是ELF格式。Entry point address可执行程序的入口虚拟地址。其他size of this headers、size of program headers、Number of program headers等等图中有解释。总结-ELF格式文件的宏观理解包括四部分分别是ELF Header、Program Header Table、Section Header Table、Section。 注意课堂上一定要让同学们理解每个ELF区域和文件偏移量之间的关系。内容理解的第三条其他部分为此关系4. 理解链接与加载objdump命令objdump -选项 xxx.o/xxx.so分类选项功能反汇编-d将可执行段输出汇编指令4.1 静态链接无论是自己的.o , 还是静态库中的.o 本质都是把.o文件进行连接的过程所以研究静态链接本质就是研究.o 是如何链接的4.1.1 查看code.o和hello.o的反汇编和符号表显示找不到函数​ 查看code.c和hello.c文件及其反汇编和符号表如下我们可以看到反汇编中间图片的call指令它们分别对应之前调用的printf和run函数但是你会发现他们的跳转地址都被设成了0。那这是为什么呢其实就是在编译hello.c的时候编译器是完全不知道printf和run函数的存在的比如他们位于内存的哪个区块代码长什么样都是不知道的。因此编译器只能将这两个函数的跳转地址先暂时设为0。**这个地址会在哪个时候被修正链接的时候**为了让链接器将来在链接时能够正确定位到这些被修正的地址在代码块.data中还存在一个重定位表这张表将来在链接的时候就会根据表里记录的地址将其修正。 注意printf涉及到动态库这里暂不做说明4.1.2 链接后查看main.exe文件的符号表、section表及反汇编可见地址call地址修改为正确地址$ gcc-omain.exe code.o hello.o $ readelf-smain.exe#(符号表)$ readelf-Smain.exe#(节头表Section Header Table)$ objdump-dmain.exemain.s#(反汇编),打印到main.s文件中此处关注的是静态链接而run是静态链接的printf是动态链接的所以此处只看run函数4.1.3 总结-静态链接过程静态链接就是1. 把库中的.o进行合并且合并前需先做 “符号解析”2. 修改函数调用call、全局变量、静态变量的所有地址引用均属于“重定位”。和上述过程一样解释所以链接其实就是将编译之后的所有目标文件连同用到的一些静态库运行时库组合拼装成一个独立的可执行文件。其中就包括我们之前提到的地址修正当所有模块组合在一起之后链接器会根据我们的.o文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量从而修正它们的地址。这其实就是静态链接的过程。问.o为什么叫做可重定位目标文件答链接过程中会涉及到对.o中外部符号进行地址重定位。链接时地址重定位4.2 ELF加载与进程地址空间4.2.1 虚拟地址/逻辑地址问题一个ELF可执行程序在没有被加载到内存的时候有没有地址呢进程mm_struct、vm_area_struct在进程刚刚创建的时候初始化数据从里来的答案一个ELF程序在没有被加载到内存的时候本来就有地址当代计算机工作的时候都采用**“平坦模式”**进行工作。所以也要求ELF对自己的代码和数据进行统一编址下面是objdump -S main.exe反汇编之后的代码最左侧的就是ELF的虚拟地址其实严格意义上应该叫做逻辑地址(起始地址偏移量)。但是我们认为起始地址是0即将.text、.data等的起始地址都认为是0从而进行统一编址从而形成了线性地址也就是虚拟地址。所以其实虚拟地址在我们的程序还没有加载到内存的时候即存放在磁盘上的可执行程序在链接阶段就已经确定了自身的虚拟地址即完成对代码和数据的统一编址仅动态依赖的外部共享库符号地址需等到运行时由动态链接器绑定。进程mm_struct、vm_area_struct在进程刚刚创建的时候初始化数据从哪里来的从ELF各个segment来每个segment有自己的起始地址和自己的长度用来初始化内核结构中的[start, end]等范围数据另外在用详细地址填充页表。所以虚拟地址机制不光光OS要支持编译器也要支持.补汇编文件的后缀用成了.c因此代码高亮有问题正确应该是.s4.2.2 重新理解进程虚拟地址空间(重点)-可执行程序加载到内存并执行的流程ELF 在被编译好之后会把自己未来程序的入口地址记录在ELF header的Entry字段中一张图说清楚可执行程序加载到内存中的流程- 素材1可执行程序在链接阶段已确定 .text 等段的虚拟地址范围非 PIE 为绝对虚拟地址PIE 为相对偏移且程序入口虚拟地址ELF 头e_entry也在此阶段固化当程序被加载时内核先创建进程 PCBtask_struct其包含的mm_struct会通过独立的vm_area_structVMA记录 .text 等段的虚拟地址 [start, end] 及权限随后 OS 建立页表虚拟地址→物理地址映射并采用按需分页机制分配物理内存最终页表与进程绑定mm_struct及VMA供 OS 管理进程内存进程被 CPU 调度时内核通过上下文切换将入口地址加载到EIP/RIP程序正式启动执行。进入到CPU中的地址全部都是虚拟地址。CPU执行代码的地址和磁盘上的地址是一摸一样的即CPU 仅关注虚拟地址虚拟地址到物理地址的转换由 MMU 通过页表自动完成CPU 不直接处理物理地址。 CPU怎么知道从哪里开始执行程序呢即你的可执行程序的起始地址是什么ELF Header中Entry point address可执行程序的入口虚拟地址。回看3.2.24.2.3 磁盘、内存、可执行程序中虚拟地址和物理地址的关系总结在目标文件链接成为可执行程序时可执行程序就已经确定了虚拟地址磁盘上虚拟地址是 “程序运行的地址约定”与磁盘物理地址无关内存中虚拟地址通过页表映射到 “实际的内存物理地址”CPU 按虚拟地址间接访问物理内存。4.2.4 连接多个.o目标文件形成可执行程序的目的总结1.第一步解决符号依赖符号解析—— 链接的 “前提基础”多个.o目标文件是分散编译的彼此之间存在 “未定义符号” 的依赖比如main.o调用了func.o中的func()引用了global.o中的全局变量g_var。2.第二步合并分散的代码和数据节合并—— 结构上的 “整合”每个.o目标文件都有独立的.text代码、.data已初始化数据、.bss未初始化数据等节。链接器会将所有.o的同名节合并形成可执行文件的统一段布局3.第三步统一编址重定位—— 链接的 “核心动作”这就是你提到的 “统一编址”也是链接最核心的步骤。目的是给合并后的所有代码、数据分配唯一的虚拟地址并修正所有 “未确定的地址引用”函数调用、变量访问。4.第四步生成符合 OS 标准的可执行格式 —— 最终 “交付物” 要求链接器最终会将合并后的节、分配的虚拟地址、符号表可选等信息打包成操作系统可识别的可执行文件格式如 Linux 的 ELF、Windows 的 PE。4.3 动态链接与动态库加载4.3.1 单进程调用动态库库函数调用被进程看到动态库映射到进程的地址空间共享区被进程调用在进程的地址空间中进行跳转代码区跳转到共享区完成库函数调用再跳回代码区继续执行4.3.2 多进程调用动态库进程间如何共享库动态库映射到进程地址空间确实不是复制代码 / 数据到进程空间核心是「物理内存共享 进程虚拟地址空间 “预留 映射”」—— 所谓 “映射”本质是给动态库在进程的虚拟地址空间共享区分配一段虚拟地址范围再通过页表将这段虚拟地址与物理内存中的动态库代码 / 数据建立关联而非复制整个动态库。因此多进程调用动态库根据各自虚拟地址映射到相同的物理地址即可因为物理地址相同所以可以得到动态库只加载了一份所以动态库也叫做共享库4.3.3 动态链接4.3.3.1 概要动态链接其实远比静态链接要常用得多。比如我们查看下hello这个可执行程序依赖的动态库会发现它就用到了一个c动态链接库ldd命令用于打印程序或者库文件所依赖的共享库列表。$ ldd main.exe linux-vdso.so.1(0x00007ffefd43f000)libc.so.6/lib64/libc.so.6(0x00007f533380b000)/lib64/ld-linux-x86-64.so.2(0x00007f5333bd9000)这里的libc.so是C语言的运行时库里面提供了常用的标准输入输出文件字符串处理等等这些功能。那为什么编译器默认不使用静态链接呢静态链接会将编译产生的所有目标文件连同用到的各种库合并形成一个独立的可执行文件它不需要额外的依赖就可以运行。照理来说应该更加方便才对是吧静态链接最大的问题在于生成的文件体积大并且相当耗费内存资源。随着软件复杂度的提升我们的操作系统也越来越臃肿不同的软件就有可能都包含了相同的功能和代码显然会浪费大量的硬盘空间。这个时候动态链接的优势就体现出来了我们可以将需要共享的代码单独提取出来保存成一个独立的动态链接库等到程序运行的时候再将它们加载到内存这样不但可以节省空间因为同一个模块在内存中只需要保留一份副本可以被不同的进程所共享。动态链接到底是如何工作的首先要交代一个结论动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存其中每个动态库的加载地址都是不固定的操作系统会根据当前地址空间的使用情况为它们动态分配一段内存。当动态库被加载到内存以后一旦它的内存地址被确定我们就可以去修正动态库中的那些函数跳转地址了。4.3.3.2 我们的可执行程序被编译器动了手脚$ ldd /usr/bin/ls linux-vdso.so.1(0x00007fffdd85f000)libselinux.so.1/lib/x86_64-linux-gnu/libselinux.so.1(0x00007f42c025a000)libc.so.6/lib/x86_64-linux-gnu/libc.so.6(0x00007f42c0068000)libpcre2-8.so.0/lib/x86_64-linux-gnu/libpcre2-8.so.0(0x00007f42bffd7000)libdl.so.2/lib/x86_64-linux-gnu/libdl.so.2(0x00007f42bffd1000)/lib64/ld-linux-x86-64.so.2(0x00007f42c02b6000)# 动态链接器libpthread.so.0/lib/x86_64-linux-gnu/libpthread.so.0(0x00007f42bffae000)$ ldd main.exe linux-vdso.so.1(0x00007fff231d6000)libc.so.6/lib/x86_64-linux-gnu/libc.so.6(0x00007f197ec3b000)/lib64/ld-linux-x86-64.so.2(0x00007f197ee3e000)# 动态链接器在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 函数来终止程序。上述过程描述了C/C程序在main函数之前执行的一系列操作但这些操作对于大多数程序员来说是透明的。程序员通常只需要关注main函数中的代码而不需要关心底层的初始化过程。然而了解这些底层细节有助于更好地理解程序的执行流程和调试问题。4.3.3.3 动态库中的相对地址动态库为了随时进行加载为了支持并映射到任意进程的任意位置对动态库中的方法统一编址采用相对编址的方案进行编制的(其实可执行程序也一样都要遵守平坦模式只不过exe是直接加载的)。动态库也是ELF格式的文件我们也理解为 起始地址(0)偏移量# ubuntu下查看任意一个库的反汇编objdump-S/lib/x86_64-linux-gnu/libc-2.31.so|lessss# Cetnos下查看任意一个库的反汇编$ objdump-S/lib64/libc-2.17.so|less4.3.3.4 我们的程序怎么和库具体映射起来的 注意动态库也是一个文件要访问也是要被先加载要加载也是要被打开的让我们的进程找到动态库的本质也是文件操作不过我们访问库函数通过虚拟地址进行跳转访问的所以需要把动态库映射到进程的地址空间中tast_struct→mm_struct→vm_area_struct→struct file→struct path→struct dentry→struct ext2_inode找到磁盘文件中的数据块→加载库待理解4.3.3.5 我们的程序怎么进行库函数调用 注意库已经被我们映射到了当前进程的地址空间中库的虚拟起始地址我们也已经知道了库中每一个方法的偏移量地址我们也知道所以访问库中任意方法只需要知道库的 起始虚拟地址方法偏移量 即可定位库中的方法而且整个调用过程是从代码区跳转到共享区调用完毕在返回到代码区整个过程完全在进程地址空间中进行的.4.3.3.6 全局偏移量表GOT(global offset table) 注意也就是说我们的程序运行之前先把所有库加载并映射所有库的起始虚拟地址都应该提前知道然后对我们加载到内存中的程序的库函数调用进行地址修改在内存中二次完成地址设置(这个叫做加载地址重定位)等等修改的是代码区不是说代码区在进程中是只读的吗怎么修改能修改吗代码区不能修改所以动态链接采用的做法是在.data可执行程序或者库自己中专门预留一片区域用来存放函数的跳转地址它也被叫做全局偏移表GOT表中每一项都是本运行模块要引用的一个全局变量或函数的地址。因为.data区域是可读写的所以可以支持动态进行修改$ readelf-Smain.exe...[24].got PROGBITS 0000000000003fb8 00002fb8 0000000000000048 0000000000000008 WA008... $ readelf-lmain.exe# .got在加载的时候会和.data合并成为一个segment然后加载在一起... 05 .init_array .fini_array .dynamic .got .data .bss...由于代码段只读我们不能直接修改代码段。但有了GOT表代码便可以被所有进程共享。但在不同进程的地址空间中各动态库的绝对地址、相对位置都不同。反映到GOT表上就是每个进程的每个动态库都有独立的GOT表所以进程间不能共享GOT表。在单个.so下由于GOT表与.text的相对位置是固定的我们完全可以利用CPU的相对寻址来找到GOT表。在调用函数的时候会首先查表然后根据表中的地址来进行跳转这些地址在动态库加载的时候会被修改为真正的地址。这种方式实现的动态链接就被叫做PIC 地址无关代码。换句话说我们的动态库不需要做任何修改被加载到任意内存地址都能够正常运行并且能够被所有进程共享这也是为什么之前我们给编译器指定**-fPIC**参数的原因PIC相对编址GOT。$ objdump-Smain.exe... 0000000000001050putsplt:1050: f3 0f 1e fa endbr641054: f2 ff25752f 00 00 bnd jmpq *0x2f75(%rip)#3fd0 putsGLIBC_2.2.5...... 0000000000001149main:1149: f3 0f 1e fa endbr64 114d:55push %rbp 114e:4889e5 mov %rsp,%rbp1151:488d 3d ac 0e 00 00 lea 0xeac(%rip),%rdi# 2004 _IO_stdin_used0x41158: e8 f3 fe ff ff callq1050putsplt... 备注PLT是什么4.3.3.7 库间依赖(简单说明即可)注意不仅仅有可执行程序调用库库也会调用其他库库之间是有依赖的如何做到库和库之间互相调用也是与地址无关的呢库中也有.GOT,和可执行一样这也就是为什么大家为什么都是ELF的格式由于GOT表中的映射地址会在运行时去修改我们可以通过gdb调试去观察GOT表的地址变化。在这里我们只用知道原理即可有兴趣的同学可以参考使用gdb调试GOT由于动态链接在程序加载的时候需要对大量函数进行重定位这一步显然是非常耗时的。为了进一步降低开销我们的操作系统还做了一些其他的优化比如延迟绑定或者也叫PLT过程连接表Procedure Linkage Table。与其在程序一开始就对所有函数进行重定位不如将这个过程推迟到函数第一次被调用的时候因为绝大多数动态库中的函数可能在程序运行期间一次都不会被使用到。思路是GOT中的跳转地址默认会指向一段辅助代码它也被叫做桩代码/stup。在我们第一次调用函数的时候这段代码会负责查询真正函数的跳转地址并且去更新GOT表。于是我们再次调用函数的时候就会直接跳转到动态库中真正的函数实现。总而言之动态链接实际上将链接的整个过程比如符号查询、地址的重定位从编译时推迟到了程序的运行时它虽然牺牲了一定的性能和程序加载时间但绝对是物有所值的。因为动态链接能够更有效的利用磁盘空间和内存资源以极大方便了代码的更新和维护更关键的是它实现了二进制级别的代码复用。 解析依赖关系的时候就是加载并完善互相之间的GOT表的过程.4.3.4 总结静态链接的出现提高了程序的模块化水平。对于一个大的项目不同的人可以独立地测试和开发自己的模块。通过静态链接生成最终的可执行文件。我们知道静态链接会将编译产生的所有目标文件和用到的各种库合并成一个独立的可执行文件其中我们会去修正模块间函数的跳转地址也被叫做编译重定位(也叫做静态重定位)。而动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存其中每个动态库的加载地址都是不固定的但是无论加载到什么地方都要映射到进程对应的地址空间然后通过.GOT方式进行调用(运行重定位也叫做动态地址重定位)。
返回列表