5Computer Science from the Bottom Up

动态链接

共享库的加载

静态链接共享库动态链接器GOTPLT延迟绑定PIC符号版本LD_PRELOAD

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第5章 动态链接

共享库的加载

章节导读

在 Linux 上运行 ldd /bin/ls,你会看到它依赖七八个共享库:libc、libselinux、libacl……这些库的代码并不在 ls 的可执行文件里,而是在程序启动时(甚至第一次调用某个函数时)才被找到、加载、连接。完成这一切的幕后英雄是动态链接器(Dynamic Linker,Linux 上是 /lib64/ld-linux-x86-64.so.2)。本章是全书工具链部分的收官:第 4 章讲了链接器如何在构建时把目标文件拼成可执行文件,本章讲剩下的另一半故事——那些被推迟到运行时的链接工作是如何完成的。

链接方式链接时机可执行文件大小内存占用更新便利性启动速度
静态链接编译时大(包含所有库代码)高(每进程独立副本)需重新编译快(无链接开销)
动态链接(eager)启动时小(仅引用)低(多进程共享)替换 .so 即可慢(启动时解析全部)
动态链接(延迟绑定)首次调用时小(仅引用)低(多进程共享)替换 .so 即可快(按需解析)
静态链接 + PIE编译时大高需重新编译快,支持 ASLR

为什么要发明动态链接?回到 1980 年代的 Unix:当时磁盘以 MB 计、内存以 KB 计,每个程序都静态链接一份 libc,几十个程序就是几十份相同的代码副本,磁盘和内存被大量浪费。共享库的思想是:把公共代码抽出来做成 .so 文件,磁盘上只存一份;运行时所有进程的 libc 代码段映射到同一批物理页(回忆第 3 章的虚拟内存共享机制),物理内存中也只有一份;库升级时只需替换 .so 文件,所有程序下次启动自动用上新版本——安全补丁可以在几分钟内推送到全系统,而不必重编译每一个程序。这个机制如此成功,以至于今天几乎没有 Linux 程序是完全静态链接的。

但把链接推迟到运行时带来了三个技术难题。第一,加载地址不确定:共享库会被不同进程加载到不同的虚拟地址(ASLR 更是每次运行都随机化),库代码中不能含有写死的绝对地址——解决方案是位置无关代码(PIC):代码段通过 PC 相对寻址访问,数据段通过一张运行时填写的地址表(GOT)间接访问,于是代码段在所有进程中完全相同可以共享物理页,只有每个进程私有的 GOT/.data 需要独立副本。第二,符号解析开销:程序可能引用库里成千上万个函数,启动时全部解析太慢——解决方案是延迟绑定(Lazy Binding):通过 PLT 桩代码,函数第一次被调用时才解析地址,之后直接跳转。第三,版本兼容:库升级后函数行为或结构体布局变了,老程序怎么办——解决方案是符号版本控制(Symbol Versioning):glibc 的每个符号都带版本标签(如 memcpy@GLIBC_2.14),同一函数可以共存多个版本的实现,程序链接时锁定它构建时的版本,永不意外改变行为。

动态链接的强大也是一把双刃剑。LD_PRELOAD 环境变量可以让任意共享库抢在所有库之前加载,其中的同名符号会拦截(interpose)系统库的函数——这是调试和性能分析的神器(可以拦截 malloc 统计内存、拦截 connect 记录网络调用),也是提权攻击的通道(setuid 程序若不防范,恶意库可以劫持它的任何 libc 调用)。理解 GOT/PLT 的机制,你还能看懂一整类安全攻防:GOT 覆写攻击、返回导向编程(ROP)为什么盯着可写又含指针的 GOT 表、RELRO(Relocation Read-Only)加固为什么要把 GOT 变成只读。

本章将沿着「程序启动」的时间线完整走一遍动态链接的全过程:内核加载 ELF 并把控制权交给动态链接器,链接器递归加载所有依赖库,重定位数据符号,设置 PLT 桩,最后跳转到程序入口。每一步都配有可以用 readelf、objdump、LD_DEBUG 亲手验证的实验。学完本章,你对「一个程序如何跑起来」的理解将贯穿从比特(第 1 章)、CPU(第 2 章)、操作系统(第 3 章)、构建工具(第 4 章)到运行时链接的完整链条——这正是 Computer Science from the Bottom Up 这本书的终极目标。

核心概念

  • 静态链接
  • 共享库
  • 动态链接器
  • GOT
  • PLT
  • 延迟绑定
  • PIC
  • 符号版本
  • LD_PRELOAD

要点精析

静态链接的完整过程——符号解析、重定位、合并。在理解动态链接之前,先把静态链接的三步走彻底吃透(它是动态链接的「提前完成版」)。第一步符号解析:链接器扫描所有输入的 .o 和 .a,为每个未定义符号(UNDEF)找到唯一定义(DEF);静态库按「成员是否解决当前未解析符号」决定是否抽取,因此库的命令行顺序敏感。第二步节合并:所有输入文件的 .text 拼接成一个连续的代码段,.data、.bss、.rodata 同理,拼接后每个符号获得最终的虚拟地址(链接脚本决定各段的基址与对齐)。第三步重定位:遍历每个 .o 的重定位条目,按类型计算并回填地址——R_X86_64_PC32 类型填入「目标地址 - 指令地址 - 4」的相对偏移,R_X86_64_32 填入绝对地址。完成后,可执行文件中所有指令的目标地址都已确定,加载到固定地址即可直接执行(传统 ET_EXEC 类型)。静态链接的产物是自包含的:没有运行时依赖、启动无解析开销、链接器还能做跨模块内联和死代码消除(配合 LTO 更彻底)。代价在第 4 章已经详述:代码重复、库更新困难、二进制体积大。一个有趣的数据点:静态链接 glibc 的程序约 800KB-2MB,而动态链接的 hello world 只有 16KB——但后者运行时需要加载 2MB 的 libc.so,只是这 2MB 被全系统所有进程共享。静态与动态的本质区别是「链接工作发生在构建时还是运行时」,动态链接的全部机制都是为把这项工作安全高效地搬到运行时而生。

共享库的优势——内存共享与独立更新。共享库的第一大优势是物理内存的节省,其机制建立在第 3 章的虚拟内存之上:libc.so 的代码段(.text)在所有进程中映射到各自虚拟地址空间的某个位置,但页表指向同一批物理页帧——100 个进程使用 libc,物理内存中只有一份 libc 代码。这要求代码段必须是位置无关的(同一份物理页要服务加载到不同虚拟地址的进程,代码中不能有绝对地址)且运行时只读(任何进程都不能修改共享的代码页)。数据段则相反:每个进程有私有的 .data/.bss/GOT 副本,通过私有映射(MAP_PRIVATE)+ 写时复制实现——初始内容共享物理页,某进程写入时才复制出私有页。第二大优势是独立更新:库的 bug 修复或安全补丁只需替换 .so 文件(通常配合 soname 版本号,如 libc.so.6),所有动态链接它的程序下次启动自动生效,无需重新编译和分发——对比 2014 年 Heartbleed 漏洞修复:动态链接 OpenSSL 的系统只需升级一个 libssl.so 包,而静态链接了 OpenSSL 的每个程序(如某些版本的 nginx 发行包)都必须单独重编译重发布。第三大优势是插件架构:dlopen/dlsym 让程序在运行时按需加载扩展模块(Apache 的 MPM 模块、Python 的 C 扩展、数据库的存储引擎都是 .so 插件),主程序与插件独立开发部署。代价是启动时的符号解析开销(毫秒级)、跨库调用的 PLT 间接跳转、以及依赖管理的复杂性(版本冲突、库找不到、ABI 不兼容)——「DLL 地狱」这个词就是代价的纪念碑。

共享库的加载过程——动态链接器的工作时间线。当你在 shell 敲下 ./app 回车,完整时间线如下:(1) shell 调用 fork + execve,内核读取 app 的 ELF 头,发现 PT_INTERP 段指定了解释器 /lib64/ld-linux-x86-64.so.2——动态链接器本身就是一个共享库;(2) 内核把 app 和动态链接器都映射进地址空间,把控制权交给动态链接器的入口(而不是 app 的 main);(3) 动态链接器初始化自身(它自己的符号也要重定位,这是一个精心设计的自举过程),然后从 app 的 .dynamic 节读取 DT_NEEDED 条目,得到直接依赖的库列表(如 libc.so.6);(4) 按搜索顺序找到每个库:DT_RPATH(已废弃)→ LD_LIBRARY_PATH 环境变量 → DT_RUNPATH → /etc/ld.so.cache(ldconfig 生成的缓存,避免每次遍历目录)→ 默认路径 /lib、/usr/lib;(5) 递归加载依赖的依赖,用 inode 判重防止同一库加载两次,循环依赖通过多轮解析处理;(6) 以广度优先序对所有对象做符号解析与重定位:处理 R_X86_64_GLOB_DAT(全局变量地址填入 GOT)、R_X86_64_COPY(可执行文件中的数据副本从库中拷贝初值)等重定位类型;(7) 依次调用各库的初始化函数(.init_array 中的函数指针,构造函数和 __attribute__((constructor)) 在此执行);(8) 跳转到 app 的真正入口 _start,由 C 运行时(crt1.o)初始化 stdio、调用 main。整个过程的每个细节都可以用 LD_DEBUG=all ./app 打印出来——这是学习动态链接最直观的工具,输出包括每个库的搜索路径、每个符号的解析结果、每次重定位的类型。

GOT(全局偏移表)——数据与函数地址的运行时登记簿。GOT(Global Offset Table)是共享库和 PIE 可执行文件中的一张指针表,位于 .got(数据引用)和 .got.plt(函数引用)节,可读写。它存在的根本原因是 PIC 的约束:代码段必须位置无关且全进程共享,因此代码中不能内嵌「某个全局变量/函数的绝对地址」——地址统一存放在每个进程私有的 GOT 里,代码通过「PC 相对寻址找到 GOT 基址 + 固定偏移取出指针」两级间接访问目标。x86-64 的典型模式:每条指令序列先用 lea 或 mov 配合 RIP 相对偏移定位到 GOT 表项(如 mov rax, [rip+0x2fe2]),再从表项中加载真实地址。GOT 的填写时机分两类:数据符号(全局变量)在加载时由动态链接器一次性重定位完成(R_X86_64_GLOB_DAT);函数符号在延迟绑定模式下先填入 PLT 桩的回跳地址,首次调用时才由链接器填入真实地址(R_X86_64_JUMP_SLOT)。GOT 还有一个重要角色:共享库通过 GOT[1](或专用寄存器)找到自己的 link_map 结构,这是延迟绑定回调的入口参数。安全视角:GOT 可写且充满函数指针,是内存破坏攻击的头号目标——攻击者用任意写原语把 GOT 中 free 的地址改成 system,程序下次 free(user_input) 就变成 system(user_input)。防御手段是 RELRO:Partial RELRO 把 .got 重定位后设为只读(.got.plt 仍可写),Full RELRO(-Wl,-z,now 配合 -Wl,-z,relro)在启动时完成全部绑定后把 .got.plt 也设为只读,彻底封死 GOT 覆写攻击——现代发行版默认开启 Full RELRO,代价是失去延迟绑定(启动时全量解析)。

PLT(过程链接表)——函数调用的蹦床。PLT(Procedure Linkage Table)是 .plt 节中的一组小桩代码(stub/trampoline),每个跨模块调用的函数对应一个表项。当 app 调用共享库的 printf 时,编译器生成的不是直接 call printf,而是 call printf@plt。PLT 表项的三条指令完成一场精巧的接力:第一条 jmp GOT[n]——跳转到 GOT 中对应的表项所指的地址;延迟绑定模式下,GOT[n] 的初始值被链接器设为 PLT 表项内第二条指令的地址,于是「跳转」实际落回本表项继续执行;第二条 push n——把该函数在重定位表中的索引压栈;第三条 jmp PLT[0]——跳到 PLT 的第 0 项(公共入口),它 push GOT[1](link_map 指针)后跳转 GOT[2](动态链接器的 _dl_runtime_resolve)。解析器根据索引找到符号名(printf),在所有已加载库中按全局作用域搜索,找到真实地址后把它写入 GOT[n],然后直接跳到 printf 执行——首次调用的开销约几微秒。从第二次调用开始,第一条 jmp GOT[n] 直接跳到 printf 的真实地址,后两条指令永远不会再执行——这就是延迟绑定的「一次解析,永久直达」。PLT 的设计哲学与第 3 章的缺页中断同构:把昂贵的工作推迟到真正需要时(demand-driven),大多数程序引用的库函数只有一小部分会被实际调用,延迟绑定让启动时间与实际使用量成正比而非与符号总量成正比。用 objdump -d -j .plt 可以直接看到这些桩代码,对照 GOT 初始值(readelf -r 的 JUMP_SLOT 条目)就能完整还原这场接力。

延迟绑定——用推迟换启动速度。延迟绑定(Lazy Binding)是动态链接器的默认策略:函数符号的地址解析推迟到第一次调用时。动机是量化可见的:一个中型程序链接 glibc 后引用几千个符号,每个符号解析要做字符串比较和作用域搜索(约 1-5 微秒),启动时全量解析要花费几毫秒到几十毫秒;而实际运行中大多数程序只调用其中一小部分函数(错误处理路径、罕用功能可能永远不执行),全量解析的绝大部分工作是浪费的。延迟绑定把这笔开销摊到运行期且只付实际使用的部分。代价与权衡:首次调用有微秒级抖动,对延迟敏感的实时系统不可接受;GOT 必须保持可写,给 GOT 覆写攻击留了窗口。因此出现了两个关闭延迟绑定的机制:链接期 -Wl,-z,now(配合 -z relro 实现 Full RELRO,启动时解析全部符号然后把 GOT 设为只读,安全加固的标准配置)和运行期环境变量 LD_BIND_NOW=1(临时全量绑定,用于调试)。现代趋势是安全优先:主流发行版(Fedora、Ubuntu、Debian)默认对大部分包启用 Full RELRO,延迟绑定逐渐退场;但共享库自身仍大量依赖延迟语义,理解 PLT/GOT 的接力过程依然是读懂反汇编、分析二进制、理解 RELRO 加固原理的基础。一个实用的调试技巧:LD_BIND_NOT=1 让每次调用都重新解析(不缓存结果),配合 LD_DEBUG=bindings 可以观察程序实际调用了哪些跨模块函数——这是分析程序行为的另类 profiler。

符号版本控制——解决库升级的兼容性难题。共享库「独立更新」的优势隐含一个危险:新库必须与所有老程序二进制兼容(ABI 兼容),但有时 bug 修复本身就会改变行为。经典案例:glibc 2.14 修复了 memcpy 的一个性能问题,顺带改变了它对「源和目标缓冲区重叠」这一未定义行为的实际表现,导致某些依赖旧行为的程序(如某些版本的 Flash)崩溃。GNU 符号版本控制(Symbol Versioning)是解决方案:库用版本脚本(version script)给每个符号打上版本标签,如 memcpy@@GLIBC_2.14 和 memcpy@GLIBC_2.2.5 是同一函数名的两个版本实现,@@ 标记默认版本(新程序链接时用),@ 标记历史版本(老程序继续用)。程序链接时,链接器把当时解析到的版本记录进可执行文件的 .gnu.version_r 节(版本需求表);运行时动态链接器严格按「符号名 + 版本标签」匹配,老程序永远拿到老实现,新程序拿到新实现——同一进程甚至可以同时加载两个版本的同名符号。用 readelf -V libc.so.6 可以看到 glibc 的完整版本树(GLIBC_2.2.5 到 GLIBC_2.3x 层层继承),objdump -T 显示每个符号的版本标签。版本脚本还用于控制共享库的导出面:{ global: my_api; local: ; }; 只导出公开 API,隐藏内部符号——减少符号冲突、加快解析、防止内部实现被依赖。没有版本控制的库只能靠 soname 大版本号(libfoo.so.1 → libfoo.so.2)表达不兼容变更,新老版本可并存但程序必须重编译;符号版本则实现了「同一个 soname 内的平滑演进」,是 glibc 三十年保持 ABI 稳定的核心机制。

位置无关代码(PIC)——一份代码服务所有地址。PIC(Position-Independent Code)是共享库的基石:代码不含绝对地址,加载到任何虚拟地址都能正确执行。x86-64 实现 PIC 的三大手段:其一,代码内跳转(同一模块内的函数调用和分支)用 PC 相对寻址——call/jmp 的目标是「相对本指令的偏移」,与加载地址无关;其二,模块内数据访问用 RIP 相对寻址——lea rax, [rip+offset] 直接算出数据地址,一条指令完成,这也是 x86-64 相比 x86 的重大改进(32 位 PIC 需要把 GOT 基址常驻 EBX 寄存器,靠 call/pop 技巧获取当前 PC,代价高昂);其三,跨模块引用(外部全局变量、外部函数)经过 GOT 间接访问——代码只含「到 GOT 表项的 RIP 相对偏移」(固定不变),GOT 表项中的绝对地址由动态链接器在运行时填写。PIC 的收益:代码段在所有进程间逐字节相同,可以映射到同一批物理页(真正的共享);配合 ASLR,库每次加载到随机地址也不影响正确性。代价:跨模块数据访问多一次内存间接(GOT 查表),跨模块调用经过 PLT 桩——这就是为什么对性能极端敏感的场景(数据库内核、游戏引擎)倾向静态链接或把热点代码收进同一模块。编译选项:-fPIC(无 GOT 大小限制,共享库标准选项)与 -fpic(某些平台有 GOT 上限);可执行文件的 PIE(-fPIE -pie)是同样的思想应用于主程序,让 ASLR 能随机化主程序基址——现代发行版默认全部 PIE。用 readelf -h 看 Type: DYN 即知是 PIC 产物,objdump -d 中满眼的 rip 相对寻址就是 PIC 的指纹。

LD_PRELOAD 与符号插桩——动态链接的双刃剑。动态链接器解析符号时按「全局作用域」顺序搜索:主程序 → LD_PRELOAD 指定的库 → DT_NEEDED 依赖(广度优先)。LD_PRELOAD 环境变量(或 /etc/ld.so.preload 文件)让指定库插队到最前面,其中的同名符号会「拦截」(interpose)后面库的实现——这叫符号插桩。合法用途极其广泛:调试与观测(拦截 malloc/free 检测内存泄漏,如 Electric Fence、gperftools;拦截 connect/sendto 记录网络行为;拦截 open 追踪文件访问);性能分析(拦截 MPI 通信函数做集群级 profiling);行为修改(libfaketime 伪造系统时间测试时间敏感逻辑,libeatmydata 让 fsync 变空操作加速测试环境);兼容性垫片(为老程序提供新版库缺失的符号)。写法很简单:实现与目标函数同签名的函数,内部用 dlsym(RTLD_NEXT, "malloc") 拿到「被我拦截的那个实现」的指针,做完自己的逻辑后转调它。危险面同样真实:setuid root 程序(如 passwd、sudo)如果允许 LD_PRELOAD,普通用户可以注入库劫持其内部的任何 libc 调用从而提权——因此动态链接器对 setuid 程序会忽略 LD_PRELOAD、LD_LIBRARY_PATH 等所有 LD_* 变量(secure-execution mode,可用 AT_SECURE 辅助向量检测);容器逃逸攻击中挂载恶意 /etc/ld.so.preload 是已知手法;供应链攻击可以替换系统库本身。防御与审计:检查 /etc/ld.so.preload 是否存在、用 Full RELRO 和 -Wl,-z,defs 加固、setuid 程序用静态链接或显式清除环境(execve 传空 envp)。理解插桩机制,你就同时掌握了最锋利的调试工具和最隐蔽的攻击面——这正是「理解底层」的价值与责任。

dlopen 家族——运行时的显式动态链接。除了启动时的隐式链接,程序可以在运行中主动加载库:dlopen(filename, flag) 加载共享库并返回句柄,flag 控制符号可见性(RTLD_LAZY 延迟绑定 / RTLD_NOW 立即解析 / RTLD_GLOBAL 符号进入全局作用域 / RTLD_LOCAL 仅本库可见);dlsym(handle, name) 按名字查找符号返回函数或变量指针(调用前需转成正确的函数指针类型);dlclose 卸载库(引用计数归零且无依赖时才真正卸载);dlerror 返回最近一次错误的字符串。特殊句柄 RTLD_DEFAULT(按默认顺序搜索所有已加载库)和 RTLD_NEXT(搜索「当前库之后」的库,LD_PRELOAD 插桩转调原实现的关键)。dlopen 的典型应用是插件系统:主程序定义插件 ABI(如每个插件导出 plugin_init/plugin_run 函数),启动时扫描插件目录逐个 dlopen + dlsym,插件与主程序独立编译发布——Apache httpd 模块、Python 的 import(CPython 扩展模块本质是 dlopen 一个 .so 再查找 PyInit_xxx 符号)、数据库存储引擎、音频软件的 VST 插件全是这个模式。工程注意事项:dlopen 的库也要处理符号版本和 ABI 兼容(插件与主程序用同一编译器同一标准库构建);dlclose 后库中代码的指针全部悬空,卸载前要确保没有回调还会触发;RTLD_LOCAL 加载的库其依赖的符号解析可能失败,需要 RTLD_GLOBAL 或依赖库自带完整解析;错误处理必须检查每一步返回值并打印 dlerror。dlopen 把「链接」从构建期工具链的专属工作变成了运行期可编程的能力,是动态链接机制开放性的最终体现。

代码示例

/* 观察 PLT/GOT 接力:延迟绑定的完整证据链 */
#include <stdio.h>
#include <math.h>

int main(void) {
    printf("sqrt(2) = %f\n", sqrt(2.0));
    printf("again   = %f\n", sqrt(2.0));
    return 0;
}

/* 构建与观察(gcc plt_demo.c -o demo -lm):

   1. objdump -d -j .plt demo | grep -A3 sqrt
      看到 sqrt 的 PLT 桩:
      jmp *0xNNNN(%rip)   先跳 GOT 表项指向的地址
      push $0xN           压入重定位索引
      jmp <PLT0>          跳到公共解析入口
      第一次调用时 GOT 表项指回 push 指令,
      于是走完整解析流程;解析后 GOT 被改写为
      libm 中 sqrt 的真实地址,第二次调用
      第一条 jmp 就直达目标。

   2. readelf -r demo | grep sqrt
      看到 R_X86_64_JUMP_SLOT 条目,
      它的「Addend」栏显示初始值指向 PLT 桩内部。

   3. LD_DEBUG=bindings ./demo 2>&1 | grep sqrt
      动态链接器亲口告诉你:
      binding file ./demo to /lib/x86_64-linux-gnu/libm.so.6:
      normal symbol sqrt——这就是首次调用触发的解析。

   4. LD_BIND_NOW=1 LD_DEBUG=bindings ./demo
      对比:解析发生在 main 运行之前(启动时全量绑定)。

   5. gdb 动态验证:b main 后 disassemble main 确认
      call 的目标是 sqrt@plt;用 x/gx 查看 GOT 表项
      在首次调用前后的值变化——从 PLT 桩地址变成
      libm 中 sqrt 的地址。
   五步证据链完整还原了延迟绑定的每个细节。 */
/* LD_PRELOAD 插桩实战:拦截 malloc 统计内存分配 */
/* malloc_trace.c —— 编译: gcc -shared -fPIC malloc_trace.c -o malloc_trace.so -ldl */
#define _GNU_SOURCE
#include <stdio.h>
#include <dlfcn.h>
#include <stdlib.h>

static void *(*real_malloc)(size_t) = NULL;
static void (*real_free)(void *) = NULL;
static long alloc_count = 0;
static long alloc_bytes = 0;

static void init_hooks(void) {
    real_malloc = dlsym(RTLD_NEXT, "malloc");
    real_free = dlsym(RTLD_NEXT, "free");
}

void *malloc(size_t size) {
    if (!real_malloc) init_hooks();
    void *p = real_malloc(size);
    if (p) { alloc_count++; alloc_bytes += (long)size; }
    return p;
}

void free(void *ptr) {
    if (!real_free) init_hooks();
    real_free(ptr);
}

__attribute__((destructor))
static void report(void) {
    fprintf(stderr,
            "[malloc_trace] allocations=%ld bytes=%ld\n",
            alloc_count, alloc_bytes);
}

/* 使用方式:
   LD_PRELOAD=./malloc_trace.so ls -l /usr
   LD_PRELOAD=./malloc_trace.so python3 -c "print(1)"

   任何程序的每次 malloc 都被透明拦截,
   退出时打印分配统计——不改一行目标程序代码。
   技术要点:
   1. dlsym(RTLD_NEXT, "malloc") 返回「作用域中
      当前库之后」的 malloc,即 glibc 的真实现,
      这是插桩库转调原函数的标准手法;
   2. init_hooks 惰性初始化:dlsym 内部自己也会
      调用 malloc,若在库加载期就调 dlsym 可能
      陷入递归,首次进入 malloc 时才取真指针最安全;
   3. destructor 属性让 report 在进程退出前自动执行;
   4. 输出到 stderr 避免污染被拦截程序的 stdout。
   同样的模板可以拦截 open/connect/time 等任何函数,
   这就是 libfaketime、strace 之外用户态观测工具的
   通用原理。注意 setuid 程序会忽略 LD_PRELOAD,
   这是动态链接器的安全模式在起作用。 */
/* dlopen 插件系统:运行时加载与符号查找 */
/* plugin.h —— 插件 ABI 约定 */
/* typedef struct { const char *name; int (*run)(int); } Plugin; */
/* 每个插件导出: Plugin *plugin_get(void); */

/* plugin_a.c —— 编译: gcc -shared -fPIC plugin_a.c -o plugin_a.so */
#include <stdio.h>

static int run_impl(int x) {
    printf("[plugin_a] received %d\n", x);
    return x * 10;
}

typedef struct { const char *name; int (*run)(int); } Plugin;
static Plugin self = { "plugin_a", run_impl };
Plugin *plugin_get(void) { return &self; }

/* host.c —— 主程序 */
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>

typedef struct { const char *name; int (*run)(int); } Plugin;

int main(int argc, char **argv) {
    for (int i = 1; i < argc; i++) {
        void *h = dlopen(argv[i], RTLD_NOW | RTLD_LOCAL);
        if (!h) {
            fprintf(stderr, "dlopen %s: %s\n", argv[i], dlerror());
            continue;
        }
        Plugin *(*get)(void) = dlsym(h, "plugin_get");
        const char *err = dlerror();
        if (err) {
            fprintf(stderr, "dlsym: %s\n", err);
            dlclose(h);
            continue;
        }
        Plugin *p = get();
        printf("loaded plugin: %s, run(7) = %d\n",
               p->name, p->run(7));
        dlclose(h);
    }
    return 0;
}

/* 编译运行:
   gcc -shared -fPIC plugin_a.c -o plugin_a.so
   gcc host.c -o host -ldl
   ./host ./plugin_a.so ./not_exist.so

   这个 60 行的骨架就是 Apache/Python/数据库
   插件系统的核心模式。要点:
   1. RTLD_NOW 在 dlopen 时立即解析全部符号,
      让缺依赖的插件立刻失败而不是运行到一半崩溃;
   2. RTLD_LOCAL 防止插件符号污染全局作用域,
      两个插件可以有同名内部符号互不冲突;
   3. dlsym 返回值必须检查 dlerror——
      符号存在但值为 NULL 与符号不存在是两回事;
   4. 函数指针转型要精确匹配签名,
      直接 (int(*)(int))dlsym(...) 更常见但
      ISO C 不保证对象指针与函数指针可互转,
      严谨写法经过 void** 中转;
   5. dlclose 后插件的一切指针失效,
      真实系统要管理插件生命周期与回调注销。
   CPython 加载扩展模块、加载 PyInit_xxx 符号
   的流程与此完全同构。 */
/* 符号版本与 PIC:读懂共享库的导出面 */
/* mylib.c —— 编译: gcc -shared -fPIC mylib.c -o libmylib.so */
#include <stdio.h>

int my_public_api(int x) { return x + 1; }
static int internal_helper(int x) { return x * 2; }
int another_public(int x) { return internal_helper(x); }

/* 在 shell 中观察:

   1. readelf --dyn-syms libmylib.so
      动态符号表只含 my_public_api 和 another_public,
      internal_helper 是 static(LOCAL)不导出——
      这就是符号可见性控制的第一层。

   2. 用版本脚本收紧导出面:
      创建 mylib.map 内容:
      { global: my_public_api; local: *; };
      gcc -shared -fPIC -Wl,--version-script=mylib.map           mylib.c -o libmylib.so
      再 readelf --dyn-syms:another_public 也消失了,
      只有 my_public_api 导出。LOCAL 化的符号
      不参与动态解析,调用还能被直接内联优化。

   3. 观察 glibc 的真实版本控制:
      readelf -V /lib/x86_64-linux-gnu/libc.so.6 | head -50
      看到 GLIBC_2.2.5 / 2.3 / ... / 2.3x 的版本定义树;
      objdump -T /lib/x86_64-linux-gnu/libc.so.6 | grep memcpy
      看到 memcpy@@GLIBC_2.14 与历史版本共存。

   4. 观察 PIC 指纹:
      objdump -d libmylib.so | head -40
      满眼的 lea/mov 配 rip 相对偏移;
      readelf -r libmylib.so 中 R_X86_64_GLOB_DAT
      和 R_X86_64_RELATIVE 条目就是运行时
      由动态链接器填写 GOT 的清单。

   5. 对比非 PIC 的后果:
      gcc -shared -fno-pic mylib.c -o bad.so 2>&1
      x86-64 上会报 relocation R_X86_64_32 against ...
      can not be used when making a shared object——
      编译器拒绝把含绝对地址重定位的代码放进共享库。
   这组观察把「可见性、版本控制、PIC」三个概念
   全部落到可验证的命令输出上。 */

实验笔记

本章实验把「程序启动的最后一环」完全透明化,需要 Linux 环境、gcc、binutils(readelf/objdump/nm)和 glibc 文档。实验一「启动时间线追踪」:写一个链接 libc 和 libm 的小程序,用 LD_DEBUG=all 运行并把输出保存到文件,按时间顺序标注出:动态链接器自举、每个库的搜索与加载路径(观察 ld.so.cache 命中的证据)、每个 GLOB_DAT/JUMP_SLOT 重定位、init_array 初始化函数的调用、最终跳转到 _start;再用 LD_DEBUG=statistics 查看符号解析的统计数字(重定位总数、耗时),对比 LD_BIND_NOW=1 前后统计的差异。实验二「PLT/GOT 显微镜」:按代码示例完成五步证据链,额外用 gdb 在 main 下断点,单步进入一次 printf 调用,亲眼走过 PLT 桩的三条指令,在 _dl_runtime_resolve 返回后再次查看 GOT 表项确认地址已被改写;然后用 -Wl,-z,now -Wl,-z,relro 重新编译,验证 .got.plt 被并入 RELRO 段(readelf -l 中 GNU_RELRO 覆盖范围变化)且 objdump 中不再出现延迟绑定桩行为。实验三「自制插桩库」:完成 malloc 拦截示例后扩展它——统计每个调用点的分配(用 __builtin_return_address(0) 获取返回地址,配合 addr2line 翻译成源码行),实现一个迷你内存 profiler;再拦截 time() 实现「时间偏移」垫片,让任意程序看到一小时后的时间,验证 libfaketime 的原理。实验四「插件宿主」:完成 dlopen 插件骨架,添加插件目录自动扫描(opendir + 后缀过滤)、插件间依赖(RTLD_GLOBAL 的利弊实验:两个插件导出同名符号时分别用 LOCAL/GLOBAL 加载观察冲突行为)、以及 dlclose 后调用悬空指针的崩溃复现(理解为什么插件卸载需要引用计数)。实验五「soname 与版本」:用 -Wl,-soname,libfoo.so.1 构建带 soname 的库,安装为 libfoo.so.1.0 并运行 ldconfig,用 ldd 验证程序记录的是 soname 而非文件名;模拟库升级:改实现不改 soname 替换文件,确认程序无需重编译即生效;再改 soname 为 libfoo.so.2,观察老程序报「cannot open shared object file」——这就是 ABI 版本管理的完整闭环。每个实验记录命令、关键输出片段和结论,最终整理成一份「动态链接全景图」:从 execve 到 main 的每一个环节都标注对应的 ELF 结构和工具证据。

延伸阅读

  • 《程序员的自我修养——链接、装载与库》第 6-8 章——中文世界对动态链接讲解最细致的材料,GOT/PLT 的图解和 Windows DLL 对比章节尤其出色,与本章互为印证。
  • 《深入理解计算机系统》第 7 章共享库部分与附录——CSAPP 用精确的图示讲解 PIC、延迟绑定和库打桩机制,配套的 Bomb Lab 与本章实验互补。
  • Ian Wienand《Computer Science from the Bottom Up》在线原文的 Dynamic Linking 章节(bottomupcs.com)——本章的直接来源,作者以 Solaris/Linux 双视角讲解链接过程,免费在线阅读。
  • How To Write Shared Libraries(Ulrich Drepper)——glibc 维护者写的共享库权威指南,PIC、符号可见性、版本脚本、ABI 设计的工程最佳实践全在其中,PDF 免费获取。
  • glibc 手册的 Dynamic Linking 相关章节与 ld.so(8) man page——LD_DEBUG、LD_PRELOAD、LD_LIBRARY_PATH、搜索顺序、secure-execution mode 的官方定义,写插桩工具前必读。
  • 《Linkers and Loaders》(John R. Levine)第 5、7、9 章——跨平台对比动态链接设计(ELF vs PE vs Mach-O),深入符号解析作用域和运行时链接的历史演进。
  • System V ABI gABI 与 Processor Supplement(x86-64 部分)——重定位类型、PLT/GOT 布局、符号版本机制的规范原文,遇到 readelf 输出中不认识的重定位类型时查它。
  • Plt and Got - the key to code sharing and dynamic libraries(系统博客文章)与 AIX/Oracle 的 linker 文档——多篇工程博客用调试器单步演示 PLT 接力过程,适合视觉化学习;进阶可读 _dl_runtime_resolve 的 glibc 源码(sysdeps/x86_64/dl-trampoline.h)。
5/5动态链接
0%