
小叶-duck个人主页❄️个人专栏《Data-Structure-Learning》《C入门到进阶自我学习过程记录》《Linux操作系统从入门到实践》《Qt从入门到实践》《算法题讲解指南》--优选算法《算法题讲解指南》--递归、搜索与回溯算法《算法题讲解指南》--动态规划算法✨未择之路不须回头已择之路纵是荆棘遍野亦作花海遨游目录前言一、进程如何感知并加载动态库1.1 进程如何看到动态库1.2 进程间如何共享库的二、动态链接的核心工作原理2.1 程序运行前的动态链接准备2.1.1 从可执行文件到进程2.1.2 _start 函数的作用2.1.3 __libc_start_main 的作用2.2 动态库的地址无关性PIC 编译2.3 运行时的地址重定位从符号到实际地址三、GOT/PLT动态链接的核心实现机制3.1 全局偏移量表 GOTGlobal Offset Table3.2 过程链接表 PLTProcedure Linkage Table延迟绑定优化3.3 库间依赖的处理四、动态链接与静态链接的核心对比4.1 对比表格4.2 应用场景选择五、ELF 文件分析工具详解(补充)5.1 ELF 结构查看工具5.1.1 ELF Header5.1.2 Program Header Table5.1.3 Section Header Table5.1.4 查看符号表5.1.5 查看重定位表5.2 反汇编工具5.2.1 objdump5.3 查看动态依赖六、总结前言在 Linux 程序开发中动态库是实现代码复用、精简可执行文件体积、节约系统内存资源的关键技术动态链接则是支撑动态库运行、实现运行时符号绑定的底层核心机制。相较于静态链接将库代码直接合并到可执行程序不同动态链接将符号重定位推迟至程序运行阶段实现了一份库文件被多个进程共享调用极大提升了系统资源利用率。本文将围绕进程与动态库的关联、动态链接底层原理、GOT/PLT 延迟绑定机制等核心内容深度拆解 Linux 下动态库的加载与调用全过程带你吃透 Linux 底层开发的核心知识点。一、进程如何感知并加载动态库动态库本质上是一个符合 ELF 格式的二进制文件进程要使用动态库中的函数和数据首先要让动态库被加载到内存并映射到进程的虚拟地址空间中这是进程能访问动态库的前提。1.1 进程如何看到动态库进程本身并不能直接识别磁盘上的动态库文件而是通过操作系统的文件操作和内存映射机制实现对动态库的访问。当程序运行时操作系统会根据程序的依赖信息找到对应的动态库文件并打开随后通过mmap 系统调用将动态库的代码段、数据段等映射到进程的虚拟地址空间的共享区让进程在虚拟地址层面能 “看到” 动态库的内容。1.2 进程间如何共享库的Linux 系统中多个依赖同一动态库的进程并不会在物理内存中加载多份库的副本而是通过虚拟内存的页表映射机制实现共享动态库被加载到物理内存后操作系统会为其建立一份物理内存映射每个使用该动态库的进程其页表会将虚拟地址空间共享区的一段地址映射到这份物理内存进程对动态库的访问最终都会转化为对同一份物理内存的访问从而实现物理内存层面的库共享。这种机制极大节省了物理内存资源也是动态链接相比静态链接的核心优势之一。二、动态链接的核心工作原理动态链接的核心是将符号解析和地址重定位从编译链接阶段推迟到程序运行阶段。编译器编译生成可执行程序时并不会将动态库的函数地址、变量地址直接写入程序而只是记录下依赖的动态库和符号信息当程序运行时动态链接器会完成符号的解析和地址的重定位让程序能正确调用动态库中的函数。2.1 程序运行前的动态链接准备2.1.1 从可执行文件到进程当用户执行一个程序时操作系统创建进程为其分配资源读取 ELF 头检查文件格式和类型根据程序头表将各个段加载到内存如果是动态链接的程序加载动态链接器跳转到程序入口点_start2.1.2 _start 函数的作用在 C/C 程序中当程序开始执行时它首先并不会直接跳转到 main 函数。实际上程序的入口点并非我们编写的 main 函数而是链接器提供的 _start 函数。这是一个由 C 运行时库通常是 glibc或链接器如 ld提供的特殊函数。动态链接的初始化工作正是在 _start 函数中完成的其流程如下设置堆栈为程序创建初始的堆栈环境保证函数调用的栈操作正常初始化数据段将初始化的全局变量、静态变量从可执行程序复制到内存清零未初始化的bss段加载动态链接器调用系统接口加载 Linux 的动态链接器ld-linux.so由其负责后续的动态链接工作解析库依赖动态链接器读取可执行程序的动态段信息解析出程序依赖的所有动态库可通过ldd命令查看程序的库依赖加载并映射动态库按依赖顺序加载所有动态库将其映射到进程的虚拟地址空间调用__libc_start_main完成信号处理、线程库初始化等工作后最终调用 main 函数将程序控制权交给用户代码。其中动态链接器如 ld-linux.so是动态链接的核心执行者Linux 下的 ld-linux.so 负责处理所有动态库的加载、符号解析和地址重定位。# 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)2.1.3 __libc_start_main 的作用一旦动态链接完成_start函数会调用 __libc_start_main这是 glibc 提供的一个函数。__libc_start_main 函数负责执行一些额外的初始化工作比如设置信号处理函数初始化线程库如果使用了线程调用程序的 main 函数当main 函数返回时__libc_start_main会负责处理这个返回值并最终调用 _exit 函数来终止程序。2.2 动态库的地址无关性PIC 编译动态库被加载到进程虚拟地址空间的地址是不固定的操作系统会根据当前内存的使用情况为动态库分配合适的虚拟地址区间。为了让动态库能在任意地址加载后都能正常运行动态库必须采用位置无关代码Position Independent CodePIC编译。还记得前面我们手动实现一个动态库的制作在编译 .c 文件变成 .o 文件时是不是加了一个 -fPIC 的选项其实也就是采用位置无关代码编译但下面还会对这个选项进一步讲解。PIC的核心是相对编址动态库中的函数调用、变量访问均使用相对于当前指令的偏移量进行编址而非绝对地址。这样无论动态库被加载到虚拟地址空间的哪个位置只要根据偏移量计算就能正确找到目标函数或变量实现地址无关性。提问我们的程序怎么和库具体映射起来的动态库也是一个文件要访问也是要被先加载要加载也是要被打开的让我们的进程找到动态库的本质也是文件操作不过我们访问库函数通过虚拟地址进行跳转访问的所以需要把动态库映射到进程的地址空间中2.3 运行时的地址重定位从符号到实际地址当动态库被加载到进程的虚拟地址空间后其虚拟起始地址就被确定了。动态链接器会完成两步核心工作实现程序对动态库符号的访问符号解析根据可执行程序记录的符号名如函数名、变量名在已加载的动态库中找到对应的符号地址计算结合动态库的虚拟起始地址和符号在库中的相对偏移量计算出符号的实际虚拟地址地址重定位将计算出的实际虚拟地址写入程序的指定位置让程序能通过该地址调用访问动态库函数。提问我们的程序怎么进行库函数调用动态库已经被我们映射到了当前进程的地址空间中库的虚拟起始地址我们也已经知道了库中每个方法的偏移量地址我们也知道所以访问库中任意方法只需要知道库的起始虚拟地址 方法偏移量即可定位库中的方法而且整个调用过程是从代码区跳转到共享区调用完毕再返回到代码区整个过程完全在进程地址空间中进行的。简单来说程序调用动态库函数的地址最终是动态库起始虚拟地址 函数在库中的相对偏移量这也是进程能正确调用动态库函数的关键。补充点在软件 / 进程视角我们只使用虚拟地址完成函数跳转、调用、返回感知不到物理地址、页表、MMU在硬件层面CPU 通过 MMU 页表自动完成虚拟地址→物理地址的翻译访问物理内存执行指令因此软件层面可以理解为虚拟地址映射绑定物理地址后直接用虚拟地址即可完成函数调用硬件翻译过程对上层软件完全透明、不可见。所以这也间接解释了为什么调用的整个过程完全在进程地址空间中进行的三、GOT/PLT动态链接的核心实现机制程序的代码段在内存中是只读的无法直接在代码段中修改函数调用的地址因此 Linux 通过全局偏移量表GOT和过程链接表PLT解决这一问题实现了只读代码段的动态地址重定位也是 PIC 的核心实现。3.1 全局偏移量表 GOTGlobal Offset Table问题我们程序运行之前先把所有库加载并映射所有库的起始虚拟地址都应该提前知道然后对我们加载到内存中的程序的库函数调用进行地址修改在内存中二次完成地址设置这个叫做加载地址重定位等等修改的是代码区不是说代码区在进程中是只读的吗怎么修改能修改吗回答代码区是只读的完全不可修改这是事实了。那难道上面的图中下面部分难道是错的吗是的所以我们不能够在代码区中对地址进行操作那怎么实现加载地址重定向呢代码区是只读的但是数据区是可读可写的。解决方案动态链接采用的做法是在数据区 .data可执行程序或者库自己中专门预留一片区域用来存放函数的跳转地址它也被叫做全局偏移表 GOT表中每一项都是本运行模块要引用的一个全局变量或函数的地址。因为 .data 区域是可读写的所以动态链接器在程序运行时能够动态修改 GOT 表中的地址值。关键特性由于代码段只读我们不能直接修改代码段。但有了 GOT 表代码便可以被所有进程共享。但在不同进程的地址空间中各动态库的绝对地址、相对位置都不同。反映到 GOT 表上就是每个进程的每个动态库都有独立的 GOT 表所以进程间不能共享 GOT 表。在单个 .so 下由于GOT 表与 .text 的相对位置是固定的我们完全可以利用 CPU 的相对寻址来找到 GOT 表。在调用函数的时候会首先查表然后根据表中的地址来进行跳转这些地址在动态库加载的时候会被修改为真正的地址。这种方式实现的动态链接就被叫做PIC 地址无关代码。换句话说我们的动态库不需要做任何修改被加载到任意内存地址都能够正常运行并且能够被所有进程共享这也是为什么之前我们给编译器指定-fPIC参数的原因PIC 相对编址 GOT。-fPIC选项的补充点3.2 过程链接表 PLTProcedure Linkage Table延迟绑定优化动态链接器如果在程序启动时就对所有动态库符号进行解析和重定位会增加程序的启动时间 —— 因为程序运行过程中很多动态库函数可能一次都不会被调用。为了解决这一问题Linux 引入了延迟绑定Lazy Binding机制其核心实现就是过程链接表PLT。PLT 是一段位于程序代码段的桩代码stub code每个动态库函数对应一个 PLT 条目其工作流程分为第一次调用和后续调用1函数第一次被调用程序调用动态库函数时首先跳转到该函数对应的 PLT 条目PLT 条目会读取 GOT 表中对应的条目此时 GOT 表中的值指向 PLT 条目的下一条指令该指令会调用动态链接器的符号解析函数动态链接器会解析出函数的实际虚拟地址并将其写入 GOT 表对应的条目动态链接器跳转到函数的实际地址执行函数逻辑。2函数后续被调用程序再次跳转到 PLT 条目时会直接读取 GOT 表中的值此时该值已经是函数的实际虚拟地址程序直接跳转到该地址执行函数不再经过动态链接器的解析实现了调用的优化。延迟绑定将符号解析的工作推迟到函数第一次被调用时大幅减少了程序的启动时间是 Linux 动态链接的重要优化手段。延迟绑定的实现思路GOT中的跳转地址默认会指向一段辅助代码它也被叫做桩代码/stup。在我们第一次调用函数的时候这段代码会负责查询真正函数的跳转地址并且去更新GOT表。于是我们再次调用函数的时候就会直接跳转到动态库中真正的函数实现。3.3 库间依赖的处理动态库之间也存在依赖关系如库 A 依赖库 B其处理方式与程序依赖动态库一致动态链接器会按依赖顺序加载所有的动态库包括库的依赖库每个动态库也都有自己独立的 GOT 表动态链接器会依次解析所有库间的符号依赖完善各个 GOT 表这也就是为什么大家都是 ELF 的格式库间的函数调用同样通过GOT 表 相对偏移的方式实现保证了库间调用的地址无关性。所有动态库的 GOT 表完善后整个程序的动态链接过程才算完成程序才能正常运行。四、动态链接与静态链接的核心对比4.1 对比表格为了更清晰地理解动态链接的优势和特点我们将其与静态链接做核心维度的对比如下表所示对比维度静态链接/静态库动态链接/动态库链接时机编译时运行时文件后缀.a.so可执行程序体积可执行文件较大包含所有库代码可执行文件较小仅记录库依赖和符号信息内存占用每个进程独立加载一份库代码多个进程共享物理内存中的库副本磁盘占用高多个程序包含重复库代码低系统中仅存一份动态库文件加载速度较快较慢更新方式需重新编译替换库文件即可依赖性不依赖外部库依赖动态库存在编译选项-static 强制静态链接-shared 生成动态库生成工具ar -rcgcc -shared -fPIC兼容性好可执行程序独立运行依赖库版本库版本不兼容可能导致程序崩溃静态链接的出现提高了程序的模块化水平。对于一个大的项目不同的人可以独立地测试和开发自己的模块。通过静态链接生成最终的可执行文件。我们知道静态链接会将编译产生的所有目标文件和用到的各种库合并成一个独立的可执行文件其中我们会去修正模块间函数的跳转地址也被叫做编译重定位(也叫做静态重定位)。而动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存其中每个动态库的加载地址都是不固定的但是无论加载到什么地方都要映射到进程对应的地址空间然后通过.GOT方式进行调用(运行重定位也叫做动态地址重定位)。可以看到动态链接以微小的运行性能开销换来了系统资源的高效利用和程序的灵活更新这也是 Linux 系统中绝大多数程序都采用动态链接的原因。4.2 应用场景选择静态库适用场景需要在没有安装对应动态库的环境中运行对程序启动速度有较高要求程序体积不是主要考虑因素嵌入式系统或资源受限环境动态库适用场景多个程序共享同一库需要减小可执行文件体积希望能够独立更新库而不需要重新编译程序系统中存在多个依赖同一库的程序五、ELF 文件分析工具详解(补充)5.1 ELF 结构查看工具5.1.1 ELF Header使用-h或--file-header选项$ readelf -h a.out 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: DYN (Shared object file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x1060 Start of program headers: 64 (bytes into file) Start of section headers: 14768 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 13 Size of section headers: 64 (bytes) Number of section headers: 31 Section header string table index: 30关键字段解释MagicELF 文件标识符魔数固定为7f 45 4c 46Class文件类32 位 (01) 或 64 位 (02)Data数据编码方式小端序或大端序Type文件类型如可重定位文件REL、可执行文件EXEC、共享目标文件DYN等Machine目标架构如 x86‑64Entry point address程序入口点虚拟地址Start of program headers程序头表起始偏移Start of section headers节头表起始偏移对比目标文件和可执行文件目标文件 hello.oType: REL (Relocatable file)Entry point address:0x0Start of program headers:0(bytes into file)目标文件没有程序头表可执行文件 a.outType: DYN (Shared object file) 或 EXEC (Executable file)Entry point address 有具体值如0x1060有完整的程序头表和节头表5.1.2 Program Header Table使用-l或--program-headers选项$ readelf -l a.out Elf file type is EXEC (Executable file) Entry point 0x4003e0 There are 9 program headers, starting at offset 64 Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align PHDR 0x0000000000000040 0x0000000000400040 0x0000000000400040 0x00000000000001f8 0x00000000000001f8 R E 8 INTERP 0x0000000000000238 0x0000000000400238 0x0000000000400238 0x000000000000001c 0x000000000000001c R 1 [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2] LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000 0x0000000000000744 0x0000000000000744 R E 200000 LOAD 0x0000000000000e10 0x0000000000600e10 0x0000000000600e10 0x0000000000000218 0x0000000000000220 RW 200000 DYNAMIC 0x0000000000000e28 0x0000000000600e28 0x0000000000600e28 0x00000000000001d0 0x00000000000001d0 RW 8 NOTE 0x0000000000000254 0x0000000000400254 0x0000000000400254 0x0000000000000044 0x0000000000000044 R 4 GNU_EH_FRAME 0x00000000000005a0 0x00000000004005a0 0x00000000004005a0 0x000000000000004c 0x000000000000004c R 4 GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 RW 10 GNU_RELRO 0x0000000000000e10 0x0000000000600e10 0x0000000000600e10 0x00000000000001f0 0x00000000000001f0 R 1关键字段Type段类型如LOAD需要加载到内存、INTERP解释器信息、DYNAMIC动态链接信息Offset段在文件中的偏移量VirtAddr段在虚拟地址空间中的地址PhysAddr段在物理地址空间中的地址现代 OS 中可以不考虑FileSiz段在文件中的大小MemSiz段在内存中的大小可能大于 FileSiz如.bss段Flags访问权限R可读、W可写、E可执行Align对齐要求Section to Segment mappingSection to Segment mapping: Segment Sections... 00 01 .interp 02 .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .plt.got .text .fini .rodata .eh_frame_hdr .eh_frame 03 .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss 04 .dynamic ...这显示了哪些 Section 被合并到哪个 Segment 中。5.1.3 Section Header Table使用-S或--section-headers选项$ readelf -S a.out There are 31 section headers, starting at offset 0x19d8: Section Headers: [Nr] Name Type Address Offset Size EntSize Flags Link Info Align [ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0 [ 1] .interp PROGBITS 0000000000400238 00000238 000000000000001c 0000000000000000 A 0 0 1 [ 2] .note.ABI-tag NOTE 0000000000400254 00000254 0000000000000020 0000000000000000 A 0 0 4 ... [11] .init PROGBITS 0000000000400390 00000390 000000000000001a 0000000000000000 AX 0 0 4 [12] .plt PROGBITS 0000000000400590 00000590 00000000000000a0 0000000000000010 AX 0 0 16 [14] .text PROGBITS 0000000000400640 00000640 00000000000004b2 0000000000000000 AX 0 0 16 [23] .got PROGBITS 0000000000600ff8 00000ff8 0000000000000008 0000000000000000 WA 0 0 8 [24] .got.plt PROGBITS 0000000000601000 00001000 0000000000000060 0000000000000008 WA 0 0 8 [25] .data PROGBITS 0000000000601060 00001060 0000000000000004 0000000000000000 WA 0 0 1 [26] .bss NOBITS 0000000000601064 00001064 0000000000000004 0000000000000000 WA 0 0 1 ...关键字段Name节名称Type节类型如 PROGBITS程序内容、NOTE注释信息、NOBITS不占用空间如 .bssAddress节在虚拟地址空间中的地址Offset节在文件中的偏移量Size节的大小Flags访问权限AAllocatable、W可写、X可执行、MMerge、SStringsLink关联的其他节Info附加信息Align对齐要求5.1.4 查看符号表使用-s或--symbols选项$ readelf -s a.out Symbol table .dynsym contains 10 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_mainGLIBC_2.2.5 (2) ...符号类型说明FUNC函数OBJECT对象变量NOTYPE未指定类型SECTION节FILE文件名绑定属性BindLOCAL局部符号只在目标文件内部可见GLOBAL全局符号可被其他目标文件引用WEAK弱符号如果存在同名的全局符号弱符号会被忽略5.1.5 查看重定位表使用-r或--relocs选项$ readelf -r a.out Relocation section .rela.dyn at offset 0x360 contains 1 entry: Offset Info Type Sym. Value Sym. Name Addend 0000000000600e20 000000000008000000 R_X86_64_RELATIVE 600e10重定位类型R_X86_64_RELATIVE相对重定位R_X86_64_JUMP_SLOT跳转槽重定位用于 PLT/GOTR_X86_64_6464 位绝对地址重定位5.2 反汇编工具5.2.1 objdump反汇编代码段$ objdump -d a.out反汇编并显示源码需要编译时加-g选项$ objdump -S a.out查看特定 section$ objdump -s -j .text a.out5.3 查看动态依赖$ ldd a.out 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)六、总结到此 Linux 动静态库的制作与使用、ELF 文件结构、程序加载过程等核心知识点我们就全部讲解完了。对此我们将这些内容进行一个总览核心要点回顾库的本质库是写好的、现有的、成熟的、可以复用的代码本质上是一种可执行代码的二进制形式。静态库在编译链接时将库代码链接到可执行文件中程序运行时不再需要静态库。动态库在运行时才去链接动态库的代码多个程序可以共享同一份动态库的物理内存。ELF 文件结构(1)ELF 头描述文件的主要特性(2)程序头表列举所有有效的段和它们的属性执行视图(3)节头表包含对节的描述链接视图(4)节ELF 文件中的基本组成单位链接视图 vs 执行视图(1)链接视图对应节头表用于静态链接分析(2)执行视图对应程序头表用于操作系统加载程序静态链接过程将多个目标文件和静态库合并成可执行文件包括符号解析和地址重定位。动态链接过程(1)加载动态链接器(2)解析程序依赖关系(3)加载动态库到进程地址空间(4)符号解析和重定位通过 GOT/PLT 实现(5)执行初始化函数(6)跳转到程序入口点GOT 和 PLT(1)GOT全局偏移表存储外部符号的真实地址位于数据段可读写(2)PLT过程链接表提供跳转到 GOT 的代码片段位于代码段只读(3)延迟绑定将符号解析推迟到第一次调用时程序启动流程(1)操作系统加载 ELF 文件(2)跳转到_start函数(3)_start调用__libc_start_main(4)__libc_start_main执行初始化包括动态链接(5)调用main函数(6)main返回后程序退出