14.3 静态链接与动态链接
解析目标文件关系后,工具链还要决定库代码何时进入程序。静态链接(static linking)从可重定位对象或静态存档中选取代码与数据,复制到最终可执行文件;动态链接(dynamic linking)保留对共享对象(shared object)的依赖,交给运行时加载器映射与连接。两者没有普遍优胜者,而是把成本与责任重新分配到构建、部署、启动、内存共享、优化、兼容和安全更新等阶段。
libparse.a 等静态存档通常是带索引的 .o 成员文件集合,并不是一个已经链接好的整体模块。链接器查询符号索引,只在某个成员定义了当前未解析符号时才将其抽取。这种按需抽取的行为能避免大型存档中每个工具函数都进入可执行文件。
存档抽取具有状态,也受顺序影响
假设 main.o 需要 parse;libA.a 中的 parse.o 定义 parse 但需要 scan;libB.a 中的 scan.o 定义 scan 又引用 parse。传统从左到右的链接会维护已定义/未解析符号集合。访问某个存档时,链接器反复抽取其中有用成员,却未必自动返回已经越过的存档。
cc main.o -lA -lB # 先抽取 parse.o,再抽取 scan.o:成功
cc main.o -lB -lA # 初次看 libB 时无需求,最后 scan 未解析循环存档依赖可能要求重复列出库文件、用链接器分组(linker group)重扫直至不再加入成员,或重构库以消除循环。--whole-archive 会强制装入所有成员,适合仅靠构造函数/元数据驱动的插件注册表,但会增加体积,也可能引入重复符号。这里的重要诊断结论是:命令行顺序属于静态链接算法,而不是无关紧要的拼写。
决定在哪个阶段付出成本
完全静态的可执行文件可作为单个制品(artifact)复制,并隔离大部分目标机器库版本漂移的问题。链接器可以垃圾回收未使用的节;配合链接时优化(link-time optimization,LTO),还可跨模块优化。代价是多个可执行文件/进程可能各自复制库代码;单个库变化也要发布较大的更新;依赖运行时模块、名称服务插件或平台框架的设施处理起来更复杂。"静态"也不自动等于自包含(hermetic):配置文件、内核接口、证书、本地化数据与动态资源依然可能在外部。
动态链接的可执行文件更小,多个进程可以共享同一共享库的干净代码页。兼容的库安全更新有机会无需重新链接就修复多个程序。代价是部署环境必须提供正确的库名称、搜索路径、架构、符号版本与 ABI。API 描述源码层调用;ABI 还包含二进制布局、调用约定、名称/版本规则与目标文件格式。源码兼容的变化仍可能破坏 ABI。
装载与绑定共享对象
可执行文件会记录依赖,例如 ELF 的 DT_NEEDED。启动时,加载器映射这些共享对象,递归发现依赖、应用所需重定位、依据作用域/版本规则解析符号、初始化线程本地存储(thread-local storage)、设置最终页面权限,并在把控制权交给 main 前运行构造函数。
绑定可以是立即(eager):执行前解析相关导入;也可以是延迟(lazy):函数第一次经 PLT/GOT 调用时才解析。立即绑定把成本放在启动阶段,却能让完整 RELRO 更早关闭可写的重定位表。延迟绑定可缩短初始启动时间,但第一次调用更慢,而且传统实现需要一段可写的修补窗口(writable patch window)。不同现代系统会选择不同的默认值与加固策略。
部署方案必须针对具体产品测量。小型恢复工具可能偏爱自包含的静态可执行文件;桌面应用可能依赖系统框架;容器服务可以在不可变镜像中使用动态库;大量工作进程的集群可从共享页面获益,而无服务器函数(serverless function)更看重冷启动延迟。应记录制品大小、比例内存集(proportional set size)、启动分析数据、更新带宽、ABI 策略、可复现性、许可证限制与事故修复流程。"静态与动态"不只是编译器开关,而是整个产品生命周期设计。