14.4 共享库与符号可见性
共享库不是换了后缀的 static archive,而是带公共二进制契约的可加载映像。它导出的 symbol name、version、type、object layout、calling convention、exception rule 与初始化行为,可能被相隔数年编译的程序依赖。主动设计这条边界,能够同时改善兼容性、优化空间、启动成本与安全性。
在 ELF 中,完整 .symtab 主要服务于链接与调试,发布时通常可 strip。较小的 .dynsym 保存动态链接需要的 symbols。若导出每个 global definition,就会膨胀 .dynsym 与 hash table,增加 relocation/lookup 工作,暴露内部实现名称,并限制优化,因为其他 module 可能 interpose 这些名称。
Visibility 同时服务于优化与兼容
可靠默认值是 可靠的默认值是默认隐藏(hidden by default)**:内部定义用隐藏可见性(hidden visibility)编译,只有预期的 API 才显式标为默认可见(default-visible)。链接阶段再以版本脚本(version script)或导出列表(export list)作为第二层白名单。Windows 上可用 __declspec(dllexport) 或模块定义文件实现类似控制。目标并不是极端追求最少符号,而是得到一个小型、经过评审且稳定的集合。
默认可见的 ELF 符号一般可被抢占(preempt):依据动态解析作用域,其他对象的定义可能替换库自身的引用。这支持 LD_PRELOAD 与传统符号插入(interposition),却使编译器/链接器不能确信内部调用一定到达本地函数。隐藏或已内部化(internalize)的定义允许直接调用、内联、常量传播与无用代码删除。-Bsymbolic 等选项会改变共享对象内部的绑定行为,但可能让期待符号插入的程序意外失效,因此必须作为明确的 ABI 选择。
稳定 ABI 不只是保留函数名
把 int draw(Point*) 改成 long draw(Point*),在 C 中可能仍有相同源码名称,却会改变返回值约定或语义。重排 public structure 字段会改变 offset 与 size。修改 C++ 编译配置可能改变 mangled name、vtable layout、RTTI、exception 或 standard-library type。删除 enum value、global variable 或 constructor side effect 也会破坏已有 consumers。
常见设计方法包括不透明句柄(opaque handle)、创建/销毁函数、显式结构体大小字段(structure-size field)、保留槽(reserved slot)和带版本号的入口点(versioned entry point)。struct widget; 这种不透明类型把布局留在库内,使库能演进而无需重新编译客户端。公开选项结构体可把 size 放在开头,并要求调用者把未知字段清零,从而允许向后兼容的扩展。符号版本控制(symbol versioning)可以保留旧实现,同时让新链接获得更新的契约,但不会自动修复所有数据布局(data layout)变更。
GOT、PLT、解析作用域与加固
位置无关代码通常通过全局偏移表(Global Offset Table,GOT)访问外部数据。外部函数调用可能经过过程链接表(Procedure Linkage Table,PLT)存根,由它从 GOT 槽中取出目标地址。延迟绑定时,该槽最初指向动态解析路径;第一次调用会识别符号、在允许的对象作用域/版本中搜索、修补槽值,再继续执行已解析函数;后续调用直接使用缓存地址。立即绑定则在 main 之前解析所有槽值。
依据平台与加载模式,解析顺序可包含主可执行文件、预加载对象、直接依赖及其依赖作用域。dlopen 标志可让新对象的符号只在本地可见或加入全局;弱符号(weak symbol)、版本要求、RTLD_NEXT 与 -Bsymbolic 都会改变搜索行为。因此调试时不能只问"哪个库定义了这个名字",还要检查实际加载了哪些对象、加载顺序与命名空间、请求的版本、可见性和具体重定位。
加固(hardening)会缩短并减少攻击者通过写入来重定向分派的机会。RELRO 在修补完成后把重定位表设为只读;立即绑定能更早完成完整保护;W^X 分离可写与可执行内存;控制流完整性(control-flow integrity)技术还可约束间接跳转。这些机制与狭窄的导出面(export surface)互补,不能互相替代。实用的库处理流水线应把导出 ABI 与版本库基线比较;对意外增删失败;用旧客户端测新库、用新客户端测最老受支持库;检查运行时依赖搜索路径;并测量启动时重定位与稳态调用的开销。