公司动态
技术书籍精读笔记:程序员的自我修养-链接,转载与库(陆)
目录1.Linux系统动态链接的思想2.使用动态链接的理由3.Linux中动态链接机制4.动态链接程序运行时地址空间分布5.地址无关代码1.固定装载地址2.装载时重定位基址重置3.地址无关代码(PIC)前言在本篇博客中主要讲解在Linux系统下关于动态链接的内容Linux中动态链接的思想是什么Linux中动态链接的好处是什么Linux中动态链接的相关结构以及步骤和实现动态链接的技术演进过程如何。相信通过该片文章你能很好的了解在Linux系统下的动态链接的过程对Linux系统中的动态链接也会有一个新的认识。Linux系统动态链接的思想不对那些组成程序的目标文件进行链接等到程序要运行时才进行链接把链接的过程推迟到了运行时再进行PS静态链接是一次性在运行前完成所有的符号解析和重定位运行时无需链接操作动态链接是将符号解析和重定位推迟到运行时也就是第一次使用该符号时运行时需要动态链接器的支持来完成实际的链接操作。而这也会导致动态链接的程序在性能上会有一些损失后续会讲到延迟绑定使动态链接的性能损失尽可能的减小使用动态链接的理由对于使用动态链接的理由我们要将静态链接和动态链接进行比较动态链接相比于静态链接有哪些优点。对此以下几点便是使用动态链接的理由1.静态链接对于每一个程序的运行都需要对应的静态链接库尽管这些程序可能会使用到同一个静态链接库但是当这些程序运行时都会创建对应的副本占用内存空间。而动态链接则开可以存储一份动态链接的文件使多个程序在运行时进行复印节省占用的内存空间2.对于静态链接的程序倘若供应商提供的静态链接库更新那么自己开发的程序需要程序打包发布而动态链接库只需要更新对应的动态链接库即可。而客户也由原来的更新整个程序调整为更新单个模块3.动态链接的程序在调用动态链接库的时候会把动态链接库调到内存当下一个程序需要使用该链接库的时就不需要再去调到内存使用了。这可以减少物理页面的换入换出也可以增加CPU缓存的命中率4.对于静态链接库假设操作系统A和操作系统B对于某一个函数的实现机制不同对于静态链接则需要程序分别链接成能够在操作系统A和操作系统B的两个版本进行发布而动态链接库则不需要针对不同的系统进行不同的链接其程序会动态地选择相应的函数实现版本Linux中动态链接机制在Linux系统中ELF动态链接文件被称为动态共享对象它们一般是以“.so”为扩展名的一些文件。对于Linux中的动态链接库的编译可以参考 《Linux动态库和静态库的编译与使用》。在Linux中动态链接主要由动态链接器实现在程序启动时内核会先加载动态链接器由动态链接器其负责以下工作1.解析依赖读取可执行文件中的.dynamic段获取所需的共享库列表2.查找库文件按照一定顺序搜索共享库路径3.加载库将共享库映射到进程的虚拟地址空间4.重定位修正符号的引用地址5.执行初始化代码调用库中的.init段或构造函数6.跳转到主程序入口开始执行main()可以通过下图简单的理解动态链接的过程图1.动态链接过程动态链接程序运行时地址空间分布对于静态链接的可执行文件来说整个进程只有一个文件要被映射那就是可执行文件本身。但是对于动态链接来说除了可执行文件本身之外还有它所依赖的共享目标文件。那么这种情况下进程的地址空间分布又会怎样呢?以下是在Windows中输出的可执行文件相关的信息 Progrma1.exe runtime output Printing from Lib.dll 1 Loaded modules 0 0x00007ff650400000-0x00007ff650414000 81920 Progrma1.exe 1 0x00007ffa0c6c0000-0x00007ffa0c926000 2514944 ntdll.dll 2 0x00007ffa0b600000-0x00007ffa0b6c9000 823296 KERNEL32.DLL 3 0x00007ffa098e0000-0x00007ffa09cde000 4186112 KERNELBASE.dll 4 0x00007ffa09f30000-0x00007ffa0a07c000 1359872 ucrtbase.dll 5 0x00007ff9f8280000-0x00007ff9f8294000 81920 Lib.dll在这段输出的数据中我们可以观察到进程里实际装入了哪些模块例如Progrmal.exeLib.dllntdll.dll和kernel32.dll等。除此之外我们可以从以下信息中知道代码段、数据段、堆、栈里的典型地址分别落在哪里。 Marker addresses EXE base 0x00007ff650400000 DLL base 0x00007ff9f8280000 main 0x00007ff650401c6a foobar 0x00007ff9f8281310 exe_global 0x00007ff650404000 exe_text 0x00007ff650405000 library_anchor() 0x00007ff9f8283010 library_message() 0x00007ff9f8284000 heap allocation 0x000001a3de00af20 stack variable 0x000000692e5ffb64而对于Windows系统中加载dll文件的地址如何分配我们可以参考使用objdump命令输出的Lib.dll信息分析。Lib.dll: file format pei-x86-64 Characteristics 0x2026 executable line numbers stripped large address aware DLL Time/Date Sun Jul 26 11:55:14 2026 Magic 020b (PE32) MajorLinkerVersion 2 MinorLinkerVersion 45 SizeOfCode 0000000000001400 SizeOfInitializedData 0000000000001600 SizeOfUninitializedData 0000000000000200 AddressOfEntryPoint 0000000000001200 BaseOfCode 0000000000001000 ImageBase 00000001d5a40000 SectionAlignment 00001000 FileAlignment 00000200 MajorOSystemVersion 4 MinorOSystemVersion 0 MajorImageVersion 0 MinorImageVersion 0 MajorSubsystemVersion 5 MinorSubsystemVersion 2 Win32Version 00000000 SizeOfImage 00014000 SizeOfHeaders 00000600 CheckSum 0000b702 Subsystem 00000003 (Windows CUI) DllCharacteristics 00000160 HIGH_ENTROPY_VA DYNAMIC_BASE NX_COMPAT SizeOfStackReserve 0000000000200000 SizeOfStackCommit 0000000000001000 SizeOfHeapReserve 0000000000100000 SizeOfHeapCommit 0000000000001000 LoaderFlags 00000000 NumberOfRvaAndSizes 00000010通过objdump -p Lib.dll可以看到Lib.dll是一个pei-x86-64格式的64位Windows动态链接库其首选装载基址ImageBase为 0x00000001d5a40000映像总大小SizeOfImage为0x00014000。同时输出中的DllCharacteristics包含DYNAMIC_BASE标志说明该动态链接库支持地址空间布局随机化加载器在运行时可以根据当前进程虚拟地址空间的空闲情况将其装载到不同的虚拟地址而不必固定装载到ImageBase指定的位置。此外SectionAlignment为0x1000说明该映像装入内存时按页对齐这也符合进程虚拟内存分页管理的方式。结合程序运行时输出可以看到Lib.dll在本次执行中实际被装载到地址0x00007ff9f8ac0000而不是PE头中给出的首选基址0x00000001d5a40000。这说明Windows下动态链接库的最终装载地址并不是在编译时固定决定的而是在装载时由系统加载器动态分配的。也就是说动态链接库和Linux下的共享对象一样其运行时装载位置依赖于当前进程地址空间的实际使用情况如果首选基址不可用或者系统启用了地址随机化机制加载器就会通过重定位把映像映射到其他可用区域。因此从实验结果可以得出结论Windows 环境下DLL 的装载同样具有运行时动态分配地址空间的特征。地址无关代码本小节间针对地址无关代码进行讲解主要讲解地址无关代码是什么以及地址无关代码的发展过程和演进过程。1.固定装载地址在早期一些系统中并没有动态链接这个概念。为了实现库的共享有人提出了将每一个模块在导入系统的内存中时都指定该模块的地址即固定每一个模块的装载地址这种做法叫做静态共享库。例如以下一些系统1.UNIX System V Release 3.2(COFF format)2.旧的Linux system(a.out format)3.BSD/OS derivative of 4.4BSD(a.out and ELF format)对于固定装载地址的实现是比较简单的但是当系统维护的人多并且开发的模块多时就很容易导致模块的地址指定变多而为了管理这些模块地址还需要不少的精力具体可以参考下图图2.固定装载地址的困扰2.装载时重定位基址重置为了解决固定装载地址的困扰便出现了装载时重定位在Windows系统中也叫基址重置。因为可执行文件往往是第一个被加载的文件Linux下一般是0x08040000。装载时重定位会在链接时对模块中所有的绝对地址的引用不作重定位处理把这一步推迟到装载时再完成。一旦模块装载地址确定即目标地址确定那么系统便会对重新中所有使用该模块的绝对地址引用进行重定位。具体可以参考下图图3.装载时重定位转载时重定位可以解决地址冲突的问题但是并不能解决复用模块的问题即指令部分无法在多个进程之间共享。因为系统对每一个进程都维护一份独立的已重定位的代码副本。即存在程序A和程序B的使用模块A时两者会各自导入一份模块A的副本到内存中当程序A导入模块A到地址0x10000000时那么foobar()函数则会重定位至0x10000100当程序B导入模块A到地址0x20000000时那么foobar()函数则会重定位至0x20000100。这就导致了导入同一个模块A但是不能复用3.地址无关代码(PIC)为了解决装载时重定位导致的指令无法复用的问题我们希望重新模块中共享的指令部分在装载时不需要因为装载地址的改变而改变其实现思路是把指令中那些需要被修改的部分分离出来跟数据部分放到一起这样指令部分就可以保持不变而数据部分可以在每一个进程中拥有一个副本从而解决复用指令的问题。为了把需要修改的指令分离到数据部分我们需要分析模块中各个类型的地址引用方式看看模块中对应类型要如何实现。具体的模块存在的地址引用类型如下1.模块内部的函数调用跳转等由于这种情况下被调用的函数或者对象和调用者都处于同一个模块中它们之间的相对位置是固定的所以这种指令不需要进行重定位void fun(){ printf(“fun函数的调用”); } int main(){ fun(); // 在同一个模块中调用函数 int a 0; printf(输出%d\n,a); // 在同一个模块中调用对象 }2.模块内部数据范围把“访问绝对地址”转换为“访问当前位置附近的某个固定偏移”。只要代码段和数据段在模块内部的相对布局不变那么无论模块被映射到哪里这个偏移始终成立。static int counter 10; int read_counter(void) { return counter; // 模块内部全局数据访问 }3.模块间数据访问对模块外部数据的访问PIC不再直接计算“变量本体”的地址而是先找到该变量在GOT中对应的表项再由这个表项间接得到真实地址。这样装载器只需要改GOT 表项而不需要改text指令本身于是代码段仍然可以保持只读和可共享。这也是PIC真正节省内存的关键点之一多个进程可以共享同一份代码页但各自拥有自己的GOT/重定位结果。extern int shared_value; int read_shared(void) { return shared_value; }4.模块间函数调用、跳转通过PLTGOT完成。对外部函数的调用代码通常先跳到PLT表项再由PLT配合GOT找到目标函数的真实入口地址。这样既避免了在代码段中写死外部函数绝对地址也为延迟绑定提供了基础。图4.模块间函数调用跳转流程