目录前言一、先搞懂进程虚拟地址空间 —— 程序的 “内存舞台”1.1 虚拟地址 vs 物理地址为何需要 “中间层”1.2 虚拟地址空间的布局动态库的 “专属区域”1.3 关键问题ELF 文件未加载时有地址吗1.4 进程地址空间的初始化从 ELF 文件到 vm_area_struct二、动态库加载核心进程如何 “找到” 并 “共享” 动态库2.1 进程如何看到动态库—— 文件映射机制动态库映射的完整流程编辑实战验证用 mmap 手动映射动态库2.2 进程间如何共享动态库—— 虚拟内存的 “Copy-On-Write”实战验证多个进程共享动态库代码段2.3 动态库加载的 “灵魂”位置无关代码PIC什么是位置无关代码为什么需要 PIC实战验证动态库的 PIC 特性三、动态链接核心函数调用的 “地址解析” 过程3.1 核心痛点代码段只读如何修改函数地址3.2 全局偏移表GOT函数地址的 “查找表”3.3 过程链接表PLT函数调用的 “跳板”PLT 的工作流程以调用printf为例实战验证PLT 与 GOT 的协作3.4 动态链接的完整流程从程序启动到函数调用阶段 1程序启动main 函数执行前阶段 2函数调用main 函数执行中阶段 3程序退出main 函数返回后四、动态库的查找与加载配置解决 “libxxx.so not found”4.1 动态库的查找顺序4.2 解决动态库查找问题的 4 种方案方案 1设置 LD_LIBRARY_PATH 环境变量临时方案 2拷贝动态库到系统默认路径永久方案 3添加软链接到系统路径永久方案 4配置 /etc/ld.so.conf永久实战验证动态库查找配置4.3 查看程序的动态库依赖五、动静态链接对比如何选择合适的链接方式5.1 核心差异对比表5.2 适用场景选择优先选择静态链接的场景优先选择动态链接的场景5.3 实战同一程序的动静态链接对比1. 动态链接默认2. 静态链接需制作静态库对比结果总结前言在 Linux 系统中动态库.so之所以能实现 “一份代码、多进程共享”核心依赖两大底层机制进程虚拟地址空间和动态链接技术。你是否好奇动态库为何能被多个进程同时使用而不冲突程序运行时如何找到动态库的函数地址虚拟地址空间又是如何为动态库 “腾地方” 的今天我们就从进程地址空间的本质入手层层拆解动态链接的核心原理结合实战操作带你彻底搞懂动态库从 “被找到” 到 “被共享” 再到 “被调用” 的完整流程。下面就让我么正式开始吧一、先搞懂进程虚拟地址空间 —— 程序的 “内存舞台”在聊动态库加载之前必须先明确一个核心概念进程虚拟地址空间。现代操作系统中每个进程都拥有独立的虚拟地址空间通常是 64 位系统下的 0x0000000000000000 到 0xFFFFFFFFFFFFFFFF进程访问的所有 “内存地址” 都是虚拟地址而非物理内存的真实地址。1.1 虚拟地址 vs 物理地址为何需要 “中间层”直接访问物理内存存在三大问题地址冲突多个进程可能同时访问物理内存的同一地址导致数据混乱。内存利用率低程序需要连续的物理内存块大块内存分配困难。安全风险进程可直接访问其他进程的物理内存存在数据泄露风险。虚拟地址空间通过 “页表映射” 解决了这些问题进程操作的是虚拟地址由 CPU 的 MMU内存管理单元通过页表将虚拟地址转换为物理地址。每个进程有独立的页表相同虚拟地址可映射到不同物理地址实现进程隔离。支持内存分页和交换提高内存利用率。我们可以用一个生动的比喻理解虚拟地址空间是进程的 “专属舞台”物理内存是后台的 “道具仓库”页表是 “舞台与仓库的映射清单”。进程在 “舞台” 上表演执行代码需要的 “道具”数据通过清单从仓库调取不同进程的 “舞台” 互不干扰。1.2 虚拟地址空间的布局动态库的 “专属区域”64 位 Linux 系统中进程虚拟地址空间的布局大致如下从低地址到高地址其中共享库区mmap 区域是动态库的 “专属地盘”。操作系统会将动态库加载到这个区域多个进程可通过页表映射到同一份物理内存的动态库代码实现 “共享”。1.3 关键问题ELF 文件未加载时有地址吗答案是有现代编译器采用 “平坦模式” 编译程序ELF 文件可执行程序、动态库、目标文件在编译链接阶段就已经完成了 “虚拟地址编址”。也就是说ELF 文件中的代码和数据在未加载到内存时就已经分配了虚拟地址。我们用readelf -h查看动态库的 ELF 头验证这一点# 查看C标准库的ELF头 readelf -h /lib/x86_64-linux-gnu/libc-2.31.so | grep -E Entry point|Type输出Type: DYN (Shared object file) # 类型动态库 Entry point address: 0x27000 # 入口点虚拟地址Entry point address: 0x27000这是动态库的入口函数_init的虚拟地址在编译时就已确定。动态库中的所有函数、变量都有固定的虚拟地址偏移量相对于库的起始虚拟地址。这意味着动态库加载时操作系统只需将库的虚拟地址范围 “映射” 到物理内存无需修改库的代码因为代码采用 “位置无关编址” PIC即可让进程通过虚拟地址访问库函数。1.4 进程地址空间的初始化从 ELF 文件到 vm_area_struct进程创建时内核会为其分配mm_struct内存描述符和多个vm_area_struct虚拟内存区域描述符这些结构的初始化数据全部来自 ELF 文件的程序头表Program Header Table。vm_area_struct会描述虚拟地址空间中的一个连续区域如代码区、数据区、共享库区记录区域的起始地址、长度、权限可读 / 可写 / 可执行等。动态库加载时内核会新建一个vm_area_struct描述动态库在共享库区的虚拟地址范围并通过页表将其映射到物理内存中的动态库代码和数据。我们可以用cat /proc/self/maps查看当前进程的虚拟内存区域分布# 查看当前shell进程的虚拟内存布局 cat /proc/$$/maps | grep -E libc|mmap输出关键部分7f8b4d800000-7f8b4d9c0000 r--p 00000000 08:01 131346 /lib/x86_64-linux-gnu/libc-2.31.so 7f8b4d9c0000-7f8b4db70000 r-xp 001c0000 08:01 131346 /lib/x86_64-linux-gnu/libc-2.31.so # 代码段r-xp读执行 7f8b4db70000-7f8b4dbc0000 r--p 00370000 08:01 131346 /lib/x86_64-linux-gnu/libc-2.31.so 7f8b4dbc0000-7f8b4dbc4000 rw-p 003c0000 08:01 131346 /lib/x86_64-linux-gnu/libc-2.31.so # 数据段rw-p读写可以看到libc.so被加载到7f8b4d800000起始的虚拟地址区域且代码段和数据段有明确的权限设置。二、动态库加载核心进程如何 “找到” 并 “共享” 动态库动态库的加载过程本质是 “文件映射 地址解析”核心解决两个问题进程如何找到动态库、多个进程如何共享动态库。2.1 进程如何看到动态库—— 文件映射机制动态库本质是磁盘上的一个 ELF 文件进程要访问动态库首先需要将其 “映射” 到自己的虚拟地址空间。这个过程类似 “打开文件”但不是读取文件内容到内存缓冲区而是通过mmap系统调用将文件的磁盘地址直接映射到进程的虚拟地址空间。动态库映射的完整流程动态链接器启动程序运行时内核先启动动态链接器ld-linux.so由动态链接器负责加载程序依赖的动态库。查找动态库文件动态链接器根据LD_LIBRARY_PATH环境变量、/etc/ld.so.conf配置文件、/etc/ld.so.cache缓存找到动态库的磁盘路径如/lib/x86_64-linux-gnu/libc.so.6。打开动态库文件动态链接器调用open系统调用打开动态库文件获取文件描述符。映射到虚拟地址空间调用mmap系统调用将动态库的代码段、数据段等映射到进程的共享库区虚拟地址空间。建立页表映射内核为映射区域创建vm_area_struct并更新页表将动态库的虚拟地址映射到物理内存或磁盘文件采用 “按需加载” 策略。这个过程可以用一张图直观理解实战验证用 mmap 手动映射动态库我们可以用mmap系统调用手动映射动态库模拟动态链接器的核心操作#include stdio.h #include stdlib.h #include fcntl.h #include sys/mman.h #include unistd.h #include sys/stat.h int main() { const char *lib_path /lib/x86_64-linux-gnu/libc-2.31.so; // 1. 打开动态库文件 int fd open(lib_path, O_RDONLY); if (fd 0) { perror(open); return 1; } // 2. 获取文件大小 struct stat st; if (fstat(fd, st) 0) { perror(fstat); close(fd); return 1; } off_t lib_size st.st_size; printf(libc.so size: %ld bytes\n, lib_size); // 3. 映射动态库到虚拟地址空间共享库区 void *lib_addr mmap(NULL, lib_size, PROT_READ, MAP_PRIVATE, fd, 0); if (lib_addr MAP_FAILED) { perror(mmap); close(fd); return 1; } printf(libc.so mapped to virtual address: %p\n, lib_addr); // 4. 解除映射关闭文件 munmap(lib_addr, lib_size); close(fd); return 0; }编译运行gcc mmap_lib.c -o mmap_lib ./mmap_lib输出libc.so size: 2029592 bytes libc.so mapped to virtual address: 0x7f9a0b400000可以看到动态库被成功映射到0x7f9a0b400000共享库区的虚拟地址这与我们之前通过/proc/$$/maps看到的地址范围一致。2.2 进程间如何共享动态库—— 虚拟内存的 “Copy-On-Write”多个进程使用同一个动态库时物理内存中只需要保留一份动态库的代码只读这是通过虚拟内存的Copy-On-Write写时复制机制实现的代码段共享动态库的代码段.text是只读的多个进程的页表会将虚拟地址映射到同一份物理内存的代码段。进程执行动态库函数时直接读取这份共享的代码无需复制。数据段私有动态库的数据段.data是可写的每个进程会有一份私有副本。当进程修改动态库的数据时内核会为该进程复制一份数据段到新的物理内存并更新页表映射不影响其他进程。这个机制的核心优势是节省内存。例如100 个进程都使用libc.so物理内存中只需保留一份libc.so的代码约 2MB而不是 100 份约 200MB。实战验证多个进程共享动态库代码段我们编写两个简单程序都依赖libc.so然后查看它们的虚拟内存映射程序 1test1.c#include stdio.h int main() { printf(test1: libc.so printf address: %p\n, printf); getchar(); // 暂停方便查看 return 0; }程序 2test2.c#include stdio.h int main() { printf(test2: libc.so printf address: %p\n, printf); getchar(); // 暂停方便查看 return 0; }编译运行gcc test1.c -o test1 gcc test2.c -o test2 # 打开两个终端分别运行test1和test2 # 终端1 ./test1 # 输出test1: libc.so printf address: 0x7f8b4d9e75a0 # 终端2 ./test2 # 输出test2: libc.so printf address: 0x7f8b4d9e75a0可以看到两个进程中printf函数的虚拟地址完全相同0x7f8b4d9e75a0。这意味着它们的页表都映射到同一份物理内存的printf函数代码实现了代码共享。我们通过下面这张图片来总结一下2.3 动态库加载的 “灵魂”位置无关代码PIC动态库能被加载到任意虚拟地址并正常运行核心是因为编译时使用了-fPIC参数生成了位置无关代码Position Independent Code。什么是位置无关代码位置无关代码是指代码的执行不依赖于其在内存中的绝对地址而是通过 “相对地址” 或 “间接寻址” 访问函数和变量。例如动态库中的函数调用不会直接使用绝对地址如0x7f8b4d9e75a0而是使用 “相对于当前指令的偏移量” 或 “通过全局偏移表GOT间接访问”。为什么需要 PIC如果动态库的代码是 “位置相关” 的依赖固定的绝对地址那么动态库只能加载到固定的虚拟地址否则函数调用会跳转到错误地址。多个动态库可能会因为地址冲突而无法同时加载。PIC 解决了这个问题让动态库可以 “按需加载” 到任意虚拟地址极大提高了灵活性。实战验证动态库的 PIC 特性我们分别编译带-fPIC和不带-fPIC的动态库观察差异1不带-fPIC编译动态库# 编写简单动态库 echo int add(int a, int b) { return a b; } libadd.c # 不带-fPIC编译警告 gcc -shared libadd.c -o libadd_no_pic.so输出警告/usr/bin/ld: /tmp/cc8Z7X7a.o: warning: relocation against __stack_chk_fail in read-only section .text /usr/bin/ld: warning: creating DT_TEXTREL in a PIE警告表明不带-fPIC的动态库会生成 “文本重定位DT_TEXTREL”即代码段需要修改无法实现真正的位置无关。2带-fPIC编译动态库# 带-fPIC编译无警告 gcc -fPIC -shared libadd.c -o libadd_pic.so无警告生成的动态库是纯 PIC 的可加载到任意虚拟地址。3查看动态库的重定位类型# 查看不带-fPIC的动态库有DT_TEXTREL readelf -d libadd_no_pic.so | grep TEXTREL # 输出 0x0000000000000016 (TEXTREL) 0x0 # 查看带-fPIC的动态库无DT_TEXTREL readelf -d libadd_pic.so | grep TEXTREL # 无输出说明无文本重定位这验证了只有带-fPIC编译的动态库才是真正的位置无关代码支持任意地址加载。三、动态链接核心函数调用的 “地址解析” 过程动态库加载到虚拟地址空间后程序如何调用库中的函数这就是动态链接的核心符号解析 地址重定位。与静态链接编译时重定位不同动态链接的重定位发生在程序运行时主要依赖两个关键结构全局偏移表GOT和过程链接表PLT。3.1 核心痛点代码段只读如何修改函数地址程序的代码段.text是只读的动态链接时不能直接修改代码中的函数调用地址。为了解决这个问题动态链接采用了“间接寻址”方案在可写的数据段.data中创建全局偏移表GOT存储动态库函数的实际地址。代码中的函数调用不直接跳转到库函数地址而是跳转到过程链接表PLT的桩代码。桩代码读取 GOT 中的函数地址跳转执行库函数。由于 GOT 位于可写的数据段动态链接器可以在运行时修改 GOT 中的地址无需修改只读的代码段。3.2 全局偏移表GOT函数地址的 “查找表”GOTGlobal Offset Table是一个数组每个元素存储一个动态库函数或全局变量的实际虚拟地址。GOT 位于可写的数据段.got 或.got.plt动态链接器会在动态库加载后填充 GOT 中的地址。我们用readelf -S查看可执行程序的 GOT 段# 编译一个依赖动态库的程序 gcc test1.c -o test1 # 查看GOT段 readelf -S test1 | grep -E got|GOT输出[24] .got PROGBITS 0000000000600fc0 00000fc0 [25] .got.plt PROGBITS 0000000000601000 00001000.got存储全局变量的地址。.got.plt存储动态库函数的地址与 PLT 配合使用。3.3 过程链接表PLT函数调用的 “跳板”PLTProcedure Linkage Table是一组桩代码stub每个桩代码对应一个动态库函数。程序调用动态库函数时先跳转到对应的 PLT 桩代码再由桩代码通过 GOT 查找实际地址并跳转。PLT 的工作流程以调用printf为例程序代码中的call printf指令实际跳转到printfpltPLT 桩代码。printfplt首先检查.got.plt中对应的条目如果是第一次调用GOT 条目未填充桩代码会调用动态链接器的_dl_runtime_resolve函数解析printf的实际地址并填充到 GOT 中。如果不是第一次调用GOT 条目已填充直接跳转到 GOT 中存储的printf实际地址。执行printf函数完成后返回程序代码。这个过程被称为 “延迟绑定Lazy Binding”—— 函数地址的解析推迟到第一次调用时避免程序启动时解析所有函数提高启动速度。实战验证PLT 与 GOT 的协作我们用objdump -d反汇编可执行程序查看printfplt的桩代码objdump -d test1 | grep -A 10 printfplt输出0000000000400520 printfplt: 400520: f3 0f 1e fa endbr64 400524: f2 ff 25 d6 0a 20 00 bnd jmpq *0x200ad6(%rip) # 601000 printfGLIBC_2.2.5 40052b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)0x200ad6(%rip)RIP 相对寻址指向.got.plt中的printf条目地址0x601000。第一次调用时0x601000存储的是printfplt的下一条指令地址桩代码会跳转到动态链接器解析地址解析完成后0x601000会被更新为printf的实际地址。我们再用gdb调试观察 GOT 条目的变化gdb test1 (gdb) start # 启动程序 (gdb) p printfplt # 查看printfplt的地址 $1 (text variable, no debug info *) 0x400520 (gdb) x/xw 0x601000 # 查看GOT中printf对应的条目未调用前 0x601000: 0x00400526 # 指向printfplt的下一条指令 (gdb) call printf(hello) # 第一次调用printf hello (gdb) x/xw 0x601000 # 再次查看GOT条目已解析 0x601000: 0x7f8b4d9e75a0 # 指向printf的实际地址完美验证了延迟绑定的过程第一次调用后GOT 条目被更新为printf的实际地址后续调用无需再解析。3.4 动态链接的完整流程从程序启动到函数调用结合前面的知识点我们梳理动态链接的完整流程阶段 1程序启动main 函数执行前内核加载可执行程序到虚拟地址空间启动动态链接器ld-linux.so。动态链接器解析可执行程序的动态依赖通过.dynamic段找到所有需要加载的动态库如libc.so。动态链接器依次加载每个动态库查找动态库文件打开并映射到进程的共享库区。解析动态库的依赖库可能依赖其他库递归加载所有依赖库。动态链接器初始化 GOT 表填充部分核心函数地址如_dl_runtime_resolve。动态链接器调用__libc_start_main初始化 C 运行时环境最终调用main函数。阶段 2函数调用main 函数执行中程序调用动态库函数如printf跳转到对应的 PLT 桩代码。PLT 桩代码检查 GOT 表未解析调用_dl_runtime_resolve解析函数实际地址填充到 GOT 表跳转执行函数。已解析直接通过 GOT 表跳转执行函数。函数执行完成返回程序代码。阶段 3程序退出main 函数返回后main函数返回__libc_start_main调用exit函数。动态链接器执行动态库的析构函数_fini。内核释放进程的虚拟地址空间、页表等资源进程终止。四、动态库的查找与加载配置解决 “libxxx.so not found”动态链接器加载动态库时需要按特定顺序查找库文件。如果找不到会报 “error while loading shared libraries: libxxx.so: cannot open shared object file: No such file or directory” 错误。4.1 动态库的查找顺序动态链接器查找动态库的优先级的顺序可执行程序的DT_RPATH段编译时通过-rpath指定已过时。环境变量LD_LIBRARY_PATH临时生效开发常用。系统缓存文件/etc/ld.so.cache通过ldconfig生成。系统默认路径/lib、/lib64、/usr/lib、/usr/lib64。4.2 解决动态库查找问题的 4 种方案方案 1设置 LD_LIBRARY_PATH 环境变量临时适用于开发测试重启终端后失效# 临时添加当前目录到动态库查找路径 export LD_LIBRARY_PATH./:$LD_LIBRARY_PATH # 运行程序 ./test1方案 2拷贝动态库到系统默认路径永久适用于长期使用的库# 拷贝动态库到/usr/lib需要root权限 sudo cp libmystdio.so /usr/lib # 更新系统缓存可选 sudo ldconfig方案 3添加软链接到系统路径永久避免拷贝库文件方便更新# 创建软链接到/usr/lib sudo ln -s $(pwd)/libmystdio.so /usr/lib/libmystdio.so # 更新缓存 sudo ldconfig方案 4配置 /etc/ld.so.conf永久适用于自定义库路径推荐生产环境使用# 创建自定义配置文件 sudo echo $(pwd) /etc/ld.so.conf.d/mystdio.conf # 更新系统缓存必须执行使配置生效 sudo ldconfig实战验证动态库查找配置我们以自定义动态库libmystdio.so为例验证方案 4# 1. 制作自定义动态库参考之前的my_stdio.c和my_string.c gcc -fPIC -shared my_stdio.c my_string.c -o libmystdio.so # 2. 配置动态库路径 sudo echo $(pwd) /etc/ld.so.conf.d/mystdio.conf sudo ldconfig # 3. 编译测试程序 gcc main.c -lmystdio -o main # 4. 运行程序无需设置LD_LIBRARY_PATH ./main程序正常运行说明动态链接器成功通过配置文件找到了libmystdio.so。4.3 查看程序的动态库依赖用ldd命令可以查看可执行程序依赖的所有动态库以及它们的查找结果ldd main输出linux-vdso.so.1 (0x00007fffacbbf000) libmystdio.so /home/user/test/libmystdio.so (0x00007f8917335000) # 找到自定义库 libc.so.6 /lib64/libc.so.6 (0x00007f8916f67000) /lib64/ld-linux-x86-64.so.2 (0x00007f8917905000)如果某个库显示 “not found”说明动态链接器找不到该库可通过上面的 4 种方案解决。最后我们用一张图来总结一下函数调用的过程五、动静态链接对比如何选择合适的链接方式了解了动态链接的原理后我们再对比静态链接总结两种链接方式的优缺点和适用场景。5.1 核心差异对比表特性静态链接.a动态链接.so链接时机编译时运行时可执行文件体积大包含库代码小仅包含函数地址表运行依赖无独立运行依赖动态库文件内存占用高多个进程多份副本低代码段共享更新维护需重新编译程序直接替换动态库启动速度快无运行时解析慢需解析函数地址灵活性低库更新需重编高支持版本切换编译参数-static-fPIC -shared5.2 适用场景选择优先选择静态链接的场景嵌入式系统存储空间有限且不需要频繁更新库。独立工具软件需要跨平台部署避免依赖系统库版本差异如curl、wget。对启动速度要求高如实时控制系统需快速启动并运行。无网络环境部署无法下载依赖的动态库静态链接可独立运行。优先选择动态链接的场景服务器程序多个进程共享库节省内存如 Web 服务器、数据库。大型软件系统模块化开发便于更新和维护如操作系统、办公软件。需要版本兼容多个程序依赖不同版本的库动态库可并行存在。对文件体积敏感如移动端应用需减小安装包体积。5.3 实战同一程序的动静态链接对比我们用之前的main.c程序分别进行静态链接和动态链接对比结果1. 动态链接默认gcc main.c libmystdio.so -o main_dynamic -L. -lmystdio ls -l main_dynamic # 输出-rwxrwxr-x 1 user user 8600 11月 8 15:30 main_dynamic2. 静态链接需制作静态库# 制作静态库 ar -rc libmystdio.a my_stdio.o my_string.o # 静态链接-static gcc main.c libmystdio.a -o main_static -L. -lmystdio -static ls -l main_static # 输出-rwxrwxr-x 1 user user 835880 11月 8 15:31 main_static对比结果动态链接程序体积8.6KB静态链接程序体积835KB包含了自定义库和 C 标准库的代码运行验证# 动态链接程序依赖libmystdio.so ./main_dynamic # 静态链接程序无依赖可删除库文件 rm -f libmystdio.so ./main_static静态链接程序依然能正常运行而动态链接程序会报错库文件被删除完美体现了两种链接方式的核心差异。总结动态库的加载与动态链接看似复杂但只要抓住 “虚拟地址空间” 和 “延迟绑定” 两个核心就能逐步拆解其底层逻辑。理解这些原理不仅能帮助我们解决开发中常见的 “库找不到”“版本冲突” 等问题还能让我们更深入地理解操作系统的内存管理和程序执行机制。如果你在实际开发中遇到动态库相关的疑难问题欢迎在评论区交流 也可以尝试用本文介绍的工具readelf、objdump、ldd、gdb分析自己的程序加深对动态链接原理的理解