Linux 动态链接库相关整理
PingCAP(aka 贵司)自研了套部署工具 TiUP 来取代早前使用的重型部署工具 Ansible。然而实际使用时,TiFlash 节点在打 patch 过程中出现过不符合预期的报错。后来发现,TiUP 打 patch 的操作并没有停进程,而是直接 tar 命令解压覆盖部署目录,原子性保障也是无从谈起。v6.0 版本前 TiFlash 部分模块包含动态加载 共享库(aka 动态库)的行为,这类行为会被搜索路径下的库文件影响。如果存在某个时刻引入的模块相互不兼容,则直接影响程序的行为。
因为当时使用的 tar 实现会先移除旧目录项再创建新文件,已经映射旧 Inode 的进程没有遇到 cp 原地截断共享库常见的 Segmentation fault 问题。但这并不意味着整个 patch 过程具备原子性:更新期间启动的新进程或再次执行 dlopen() 的线程,仍可能看到缺失、未写完或彼此不兼容的一组文件。
正好借此整理下相关的几个问题:
- cp / tar 命令是如何工作的?
- cp 覆盖正在被使用的动态库为何会引发异常?
- 使用动态库要避开哪些坑?
- 动态库有哪些额外的开销?
结论先行:
- 不要用
cp原地覆盖正在被映射的共享库。 - 单文件更新应在同一目录写完临时文件,再通过
rename()原子替换目标;如需保证掉电后的持久性,还要同步文件和目录元数据。 - 多个共享库必须作为整体保持兼容时,应发布到新的版本目录,完成校验后再原子切换入口软链接,而不是逐个覆盖。
- 动态加载不等于符号隔离;稳定的 C ABI、最小导出符号集和明确的版本策略仍然是基础。
Linux 文件系统基础(背景知识)
相关基础参考 Modern Operating Systems,The Ext2 Filesystem,kernel.org/doc/filesystems,深度细节建议看操作系统代码实现。
Inode
Linux 下 Inode(Index node) 是一个重要概念,是理解 Unix/Linux 文件系统和硬盘储存的基础。Inode、软链接、硬链接 等入门基础可以参考 阮一峰. 理解inode,解释得比较易懂。以下部分做点补充。
Inode 本身是一种抽象设计,不同操作系统有各自实现,此处针对传统的类 Unix 文件系统。
块设备会暴露逻辑扇区和物理扇区,常见大小为 512B 或 4KiB;文件系统块大小由格式化参数和文件系统实现决定,并不固定为 8 个扇区。文件数据存储在数据块中,Inode 则作为索引节点保存文件类型、权限、User ID、Group ID、大小、链接数、时间戳以及数据块索引等元信息。传统 Unix 语义主要关注 atime、mtime 和 ctime,部分文件系统还支持 birth time。详细定义可参考 linux/man-pages/inode。
以 ext4 为例,Inode 大小和数量通常在格式化时确定。通过命令获取某块 3.6T 硬盘的信息:Inode size 为 256B,df -i 显示可用 Inode 总数约为 233M。
1 | dumpe2fs -h /dev/${disk} | grep -E "Inode size|Block size" |
1 | df -hi /dev/${disk} |
也就是说,该文件系统最多只能分配约 233M 个 Inode,而不是根据磁盘字节数临时增长。若粗略假设每个目录除 Inode 外至少占一个 4KiB 数据块,仅目录数据块就约为 932GiB,还没有计算 Inode Table、位图和其他元数据。因此磁盘仍有容量却因 Inode 耗尽而无法创建文件,并不是只存在于理论上的情况。
TiFlash 存储模块也险些暴露出来类似问题
- 早前版本中,TiFlash 的 schema sync 逻辑会把 TiDB 的每张物理表都在本地建立对应的文件夹以及 schema 相关的文件
- 倘若表的数量过多,超过磁盘 Inode 承载的上限,则会导致系统不可用
Inode 号码在同一个文件系统内标识对象,在不同文件系统中互不依赖,因此不同文件系统完全可能出现相同的 Inode 号码。普通文件 API 并不能“直接删除 Inode”;下面的 find -inum -delete 是先按 Inode 号码找到对应目录项,再对目录项执行删除。最后一个硬链接消失且没有其他内核引用后,文件系统才会回收 Inode 和数据块。
1 | ls -i |
Unix/Linux 系统中,目录(directory)也是一种文件:
- 创建目录时,默认会生成两个目录项:
.和..。 - 前者的 Inode 号码就是当前目录的 Inode 号码,等同于当前目录的
硬链接,也就使得新建空目录的链接数加 1 等于 2。 - 后者的 Inode 号码就是当前目录的父目录的 Inode 号码,等同于父目录的硬链接,令父目录的
链接数加 1。 - 对于 ‘/‘ 目录,则两者都指向自己。
.和..的目录项及链接数语义由文件系统维护。ext 系列文件系统的根目录通常使用 Inode 2,但这不是所有文件系统的通用约定。下面/proc和/sys显示为 Inode 1,是因为它们来自各自独立挂载的伪文件系统。
1 | stat / |
硬链接的目标和源共用同个 Inode,因此不能跨盘建立,否则报错 Invalid cross-device link。通过 ln ${src} ${tar} 建立硬链接无法作用于目录,否则报错 hard link not allowed for directory。
硬链接自身就是文件系统命名体系的一环,链接数归 0 且不存在其他内核引用后,系统才会回收对应文件,与引用计数的管理策略类似。如果允许用户任意为目录建立硬链接,容易破坏目录树结构并形成循环。与硬链接不同,软链接是具有独立 Inode 的特殊文件,内容保存目标路径;访问时由路径解析逻辑继续查找目标,失败则返回错误。软链接不会增加目标文件的链接数,也不能阻止目标被删除。
Inode 深入分析
以下只介绍传统 ext4 的简化布局,不展开 bigalloc、flex_bg、meta_bg、稀疏 Super Block 等扩展特性。格式化时,文件系统会被划分为多个 Block Group。
- ext 系列文件系统的主 Super Block 从文件系统起始偏移 1024 字节处开始;前 1024 字节传统上可供引导代码等用途使用。它和 Block Group、文件系统块之间的具体位置关系还取决于块大小。
| Group 0 Padding | Block Group 0 | Block Group 1 | … | Block Group N |
|---|---|---|---|---|
| 1024 bytes |
- 在未考虑备份 Super Block、
flex_bg、meta_bg等特性的情况下,Block Group 的概念布局大致如下:
| ext4 Super Block | Group Descriptors | Reserved GDT Blocks | Data Block Bitmap | inode Bitmap | inode Table | Data Blocks |
|---|---|---|---|---|---|---|
| 1 block | N blocks | N blocks | 1 block | 1 block | N blocks | N blocks |
详细字段可参考 Linux 内核文档中的 ext4 Super Block 和 Inode Table。
- 在传统的“一位表示一个文件系统块”的 Block Bitmap 布局中,一个块大小的 Bitmap 最多表示
8 * block_size_in_bytes个 Block,因此这是常见的每组块数上限。启用bigalloc后,一位表示一个 Cluster,不能再直接套用该等式。 s_inodes_per_group表示每个 Block Group 中 Inode 的数量。- 已知某个文件的 Inode 号码 为 inum,则查找文件内容的过程为:
(inum - 1 ) / s_inodes_per_group得到 Inode 所在的 Block Group(inum - 1 ) % s_inodes_per_group可得到 Inode 在 inode Table 中的偏移量- 通过 Block Group 的 Group Descriptors 找到 inode Table,根据偏移获取实际的 Inode 数据
- 通过 Inode 结构中的
i_block定位文件数据;该字段既可能保存直接/间接块索引,也可能保存 Extent Tree 根节点或内联数据 - 根据数据块中的结构读取实际数据:ext2 和 ext3 中结构为直接/间接数据块表;ext4 中的结构则是 Extent Tree;
cp/rm/mv/tar 命令
用 strace 可以很清楚地看到具体工具和参数在特定系统上的系统调用。以常见 GNU coreutils 为例,rm 主要使用 unlink*,同一文件系统内的 mv 主要使用 rename*,cp 主要使用 open*、数据复制和写入相关调用;遇到跨文件系统、稀疏文件、reflink 或特殊选项时,实际流程会不同。
根据 linux/man-pages/unlink,unlink 用于按照文件名删除文件:
- 如果文件无其他链接,且没有进程打开该文件,则删除文件并释放空间
- 如果文件仅此一份链接,但存在进程仍然打开该文件,删除后文件依然存在,直到引用它的最后一个文件描述符关闭,系统才会回收
因此,对已经持有文件描述符或文件映射的进程而言,unlink 不会立即使对象失效;但目标路径会消失,之后按路径打开文件的进程会失败。
rename 面对的场景更加复杂,根据 linux/man-pages/rename,当目标已经存在时
- 如果源和目标位于同一挂载文件系统,目标存在时会被原子替换;已打开的文件描述符不受影响
- 如果是跨盘行为,则报错
Invalid cross-device link - 命令行
mv遇到跨文件系统场景通常会退化为复制后删除源文件,但目标文件的具体创建、覆盖和清理步骤属于工具实现细节,不再具备单次rename()的原子语义
根据 linux/man-pages/open,常见 cp 实现覆盖已有普通文件时,会以包含 O_TRUNC 的方式打开目标,复用目标的 Inode,先截断内容再写入源数据。当程序正在运行时,覆盖其主程序镜像通常会报错 Text file busy,但覆盖正在被映射的共享库往往不会,详见 案例(2)。GNU cp -f 的语义是目标无法打开时删除目标后重试,并不保证所有覆盖场景都会先 unlink,更不能作为原子发布机制。
特定 tar 实现和参数可能会在解包时先删除旧目录项再创建文件,这可以避免修改旧 Inode,但路径会经历缺失或内容未写完的窗口,而且不同 tar 实现与覆盖选项并不保证采用相同流程。因此不能把直接解包覆盖视为安全发布方案。可靠的单文件替换应先在目标同目录生成完整临时文件,再使用 rename();多文件发布则需要额外的版本切换协议。
Linux 动态库
强烈推荐 《程序员的自我修养——链接装载与库》 这本书,细致全面地囊括了各项相关知识。
Linux 下的库有两种:静态库 和 共享库(aka 动态库),静态通常用 .a 为后缀,动态库用 .so 为后缀。
Linux 下的可执行文件、共享库和静态库中的目标文件通常采用 ELF(Executable and Linkable Format)。.a 文件本身通常是 ar archive,内部成员才是 ELF relocatable object。可通过 file、ar 和 readelf 查看类型与内容;ldd 可显示动态可执行文件或共享对象在当前环境下解析到的依赖,但不适合对不可信二进制直接执行。
动态库的优势:
- 不同进程映射同一共享库时,只读的文件页通常可以通过页缓存共享;每个进程仍有独立的虚拟地址映射和私有可写数据
- 动态库的代码是在可执行程序运行时才载入内存的,在编译过程中仅简单的引用,生成的可执行程序代码体积较小
- 编译链接代价小,对于长期稳定的模块库,无需反复拷贝数据
- 在 ABI、状态迁移、线程同步和对象生命周期都经过专门设计时,可以通过动态装载机制实现模块更新;替换文件本身并不等于安全热更新
动态库的劣势:
- 访问全局数据|静态数据成员、跨模块函数调用等行为需要额外的定位寻址开销,造成性能损耗
- 模块兼容性问题:DLL hell
链接相关概念中,函数和变量统称为 符号(Symbol),函数名或变量名就是符号名。每个目标文件都有一个 符号表(Symbol Table),表中记录了目标文件用到的所有符号。
使用 nm 命令查看目标文件的符号信息,详见 GNU Binutils nm 文档。常见符号类型包括:
- T / t:代码段中的函数
- U:被调用但并没有定义的符号(表明需要其他库支持)
- W / w:弱符号;已定义弱符号与普通强定义同时参与静态链接时通常选择强定义,未定义弱符号在找不到定义时也不一定导致链接失败。大小写和运行时绑定细节还与目标格式及工具链有关
- B / b:bss 中的未初始化全局/局部变量
- D / d:数据段中初始化的全局/局部变量
- …
ELF 同时包含 节(Section) 和 段(Segment) 两个视角:Section 主要服务于编译和链接,Segment 由 Program Header 描述,主要服务于运行时装载。下面列出的 .dynsym、.text、.got 等都是 Section,可用 objdump 或 readelf 查看:
.dynsym动态链接需要使用的符号表,既可能包含已定义符号,也可能包含未定义符号.rel.dyn/.rela.dyn动态链接器处理的一般重定位项,并不限于变量.rel.plt/.rela.plt与 PLT 调用入口相关的重定位项,常见于外部函数调用.text源代码编译后的指令.plt延迟绑定的外部函数调用的指令.data已初始化的全局变量和局部静态变量.bss未初始化的全局变量和局部静态变量.symtab供静态链接和调试使用的符号表,发布产物经过 strip 后可能不存在.got保存重定位后需要间接访问的地址,不限于外部全局变量.got.plt常用于 PLT 调用入口的地址槽;具体布局与架构、ABI 和链接参数有关- …
为了使程序模块中共享的指令部分在装载时不需要随装载地址变动而变动,可将指令中需要被修改的部分分离出来放到数据部分中,这样指令部分保持不变,数据部分可以在每个进程中有独立可修改的副本,这种方案就是 地址无关代码(PIC,Position-independent Code)。实现方式:
- ELF 在数据相关表段里建立指向这些变量的指针数组,也被称为
全局偏移表(Global Offset Table,GOT),当代码需要访问这些跨模块数据时,可通过 GOT 中变量对应的项找到目标地址 - GOT 位于可装载的数据区域,动态链接器在装载和重定位模块时查找符号地址并填充相应表项;启用 RELRO 后,已完成重定位的区域可以被改成只读,Full RELRO 通常还会配合立即绑定保护
.got.plt - 每个进程拥有独立的虚拟地址映射和可写 COW 状态;未被修改的只读文件页仍可能通过页缓存共享物理内存
- 通常 ELF 将 GOT 拆分成 2 个表段:
.got和.got.plt
跨模块的函数调用也是同样原理,需要把符号绑定到当前进程中的虚拟地址。ELF 工具链可以通过 PLT(Procedure Linkage Table) 实现延迟绑定,即函数第一次被调用时才执行符号查找和重定位;LD_BIND_NOW、RTLD_NOW 或链接参数 -z now 则会要求提前绑定。详见 案例(1)。
链接动态库
案例(1)
当程序链接多个包含相同函数的库时,可能出现非预期的结果
1 | // v1.cpp |
1 | // libtest.cpp |
1 | // v2.cpp |
1 | clang++ -c v1.cpp -o v1.o && ar rcs libv1.a v1.o |
1 | // main.cpp |
编译执行结果为
1 | clang++ main.cpp -L. -lv2 -ltest -Wl,-rpath,'$ORIGIN' -o main && ./main |
在 libtest.so 内部存在已定义的函数 foo() 和 test(),但实际上 test() 调用的是 v2 库中的 foo() 而不是 libtest.so 自身的,与直觉相悖。
交换库链接顺序 或者只链接 libtest.so,最终调用的才是 libtest.so 中的 foo() 。
1 | clang++ main.cpp -L. -ltest -lv2 -Wl,-rpath,'$ORIGIN' -o main && ./main |
分析(1)
libtest.so 对外导出 test() 和 foo(),test() 内通过 PLT 调用默认可见的 foo(),因此链接器为它生成动态重定位项。符号绑定既可能在装载时完成,也可能在启用懒绑定时推迟到第一次调用。
1 | nm -CD libtest.so |
foo 外部函数入口偏移地址为 0x3910,在 .rela.plt 段的下标为 2,.got.plt 起始地址为 0x38e8,.got.plt 前 3 项为:.dymanic 段地址、本模块 ID、符合解析和重定位相关函数地址。验证得 0x38e8 + 8 * (3 + 2) = 0x3910。
1 | readelf -r libtest.so |
1 | readelf -S libtest.so |
魔改下 main.cpp 令其持续 sleep 不退出,运行时可以根据 pid 查看进程地址空间:
- 00201000-00202000 段主要是源代码编译后的指令,可读可执行,不可写
- 00200000-00201000 ,00202000-00203000 段为只读数据部分,前者主要是字符串常量,后者则主要是静态数据
- 00203000-00204000 段为可写数据部分
- libtest.so 对应的内存地址空间从 7f0fb3704000 开始,布局与上面类似
1 | cat /proc/<pid-of-program>/maps |
该案例使用动态库的方式为隐式加载(载入时加载)。
- 程序启动时,动态链接器根据主程序和
DT_NEEDED依赖建立 link map 与符号查找作用域,把相关共享对象映射到进程地址空间,并处理重定位。它不是简单地把所有符号表物理合并成一张“全局符号表”。 - 每个重定位项会在对应的 lookup scope 中按顺序查找可见且版本匹配的定义,通常选择第一个符合条件的符号。主程序中的可见定义一般位于共享对象之前,因此可以抢占共享对象中的同名、默认可见符号。
- 本案例中,静态库
libv2.a的foo()被纳入主程序。链接器发现动态对象需要该符号后,将其放入主程序的动态符号表;可以通过readelf --dyn-syms --wide main | c++filt验证。运行时,地址 0x201800 的foo()因此先于libtest.so中的同名定义被选中。该行为由本次链接结果和符号可见性决定,并非所有主程序符号都会默认导出。 - 2017c0: 压栈
rbp(栈基地址寄存器)的值,保存调用者帧的栈底 - 2017c1: 将
rsp(栈指针寄存器,指向栈顶)的值赋予 rbp,为当前函数建立栈帧基址;真正预留局部空间发生在后续调整rsp时 - 2017c4: 预留 16字节空间给临时数据
- 2017c8: 调用 foo() 函数,callq 约等于 push %rip + jump <_Z3foov>
- 201804: 设置返回值至 eax 寄存器
- 2017cd ~ 2017eb: 调用 printf() 和 test() 函数
- 2017ed: 清除预留空间,还原 rsp
- 2017f1: 还原 rbp
- 2017f2: 跳转回调用者的指令,约等于 pop %rip + jump …
1 | objdump -d main |
foo()使用默认 visibility,ELF 的 semantic interposition 语义允许其他对象抢占它,因此本次编译通过 PLT 发起调用,而不是直接绑定模块内地址。hiddenvisibility、-Bsymbolic或-fno-semantic-interposition等设置都可能改变生成结果和抢占语义。- 设 α 为 libtest.so 载入程序的起始内存地址
- 1664: 通过命令可以看到
<_Z4testv>实际调用的是<_Z3foov@plt>而非<_Z3foov>- 指令码为 e8 77 00 00 00,第一字节表示指令类型为 相对地址调用(Call near, relative, displacement relative to next instruction),后四字节是目标地址相对于当前指令下一条指令的偏移 0x77,即 0x1669 + 0x77 = 0x16e0,最后调用的是 α + 0x16e0
- 16e0: 通过偏移地址 0x3910 间接跳转
- 读取指令寄存器 rip 中的值 β(α + 0x16e6);加上偏移 γ = β + 0x222a = α + 0x3910(该地址位于
.got.plt段,用于保存外部函数 foo() 对应的项);从地址 γ 读取地址 δ;跳转到地址 δ; - 如果链接器已经初始化 γ,δ 为外部函数 foo() 的进程内地址,则可直接跳转实现函数调用
- 为了实现延迟绑定,初始化时填入 γ 实际上是 “16e6:” 行对应的地址 α + 0x16e6,等效于是间接跳转到下一行
- 读取指令寄存器 rip 中的值 β(α + 0x16e6);加上偏移 γ = β + 0x222a = α + 0x3910(该地址位于
- 16e6: 压栈外部函数 foo() 在
.rela.plt段中的下标 2 - 16eb: 跳转到符号解析和重定位流程入口,在当前重定位项的 lookup scope 中解析
foo(),得到进程内地址 0x201800 并填入地址 γ,最后调用该函数
1 | objdump -d libtest.so |
libtest.so自身虽然定义了foo(),但该符号是默认可见且可被抢占的;在本次 lookup scope 中,主程序的定义排在它之前libtest.so的 PLT 重定位最终填入主程序中foo()的地址,因此调用了 v2 的逻辑
交换库链接顺序或者只链接 libtest.so 时也是同样原理:主程序中的 foo() 引用保持为未定义符号(U 类),运行时在它的 lookup scope 中由 libtest.so 的定义满足。
1 | nm -CD main |
1 | ... |
解决符号冲突
为避免符号冲突,可通过以下几种方式
显式加载(运行时加载,但不等于符号隔离)
1 | // main2.cpp |
程序运行时,在逻辑中加载 libtest.so 获取 test 函数入口。编译时不需要链接项 -ltest。在本案例的链接参数和符号可见性下,结果符合预期:
1 | clang++ main2.cpp -L. -lv2 -ldl -Wl,-rpath,'$ORIGIN' -o main2 && ./main2 |
这个结果不能推广成“dlopen() 可以自动隔离符号”。根据 dlopen(3):
dlopen(nullptr, flags)返回主程序的 handle;对该 handle 调用dlsym()时,会依次搜索主程序、启动时加载的依赖以及以RTLD_GLOBAL加载的对象。- 对指定共享对象的 handle 调用
dlsym()时,会按广度优先顺序搜索该对象及其依赖。 - 更关键的是,共享对象内部的未定义符号会先在主程序及其依赖、此前以
RTLD_GLOBAL加载的对象中解析,之后才轮到共享对象自身及其依赖。默认的RTLD_LOCAL只限制该对象的符号被后续对象使用,并不会建立独立命名空间。
因此,控制导出符号和使用 hidden visibility 是更通用的办法。确实需要装载命名空间隔离时,可评估 glibc 的 dlmopen(LM_ID_NEWLM, ...);RTLD_DEEPBIND 和链接参数 -Bsymbolic 会改变符号抢占语义,也可能破坏 interposition,应在理解副作用后使用。
符号隐藏
- 隐藏 main 入口 foo() 函数
1 | // main3.cpp |
1 | clang++ main3.cpp -L. -lv2 -ltest -Wl,-rpath,'$ORIGIN' -o main3 && ./main3 |
- 隐藏共享库内 foo() 函数,推荐这种方式,尽可能对外隐藏无关符号
1 | // libtest2.cpp |
1 | clang++ -fPIC libtest2.cpp -L. -lv1 -shared -o libtest2.so |
foo() 对外不可见;此时 test() 调用 foo() 为模块内调用,无需通过 PLT;
1 | objdump -d libtest2.so |
替换动态库
案例(2)
如果 so 正在被使用时,执行 cp ${newlib}.so ${oldlib}.so,则容易引起程序 core dump。
分析(2)
- 应用程序加载动态库时,内核通过 mmap 把 so 加载到进程地址空间,对应
Virtual memory area (VMA)中多个页(Page)- 相同 Inode 的 so 可被不同程序共享页缓存
- 段的装载地址和空间的对齐单位是页
- dynamic linker/loader 会把 so 里面引用的外部符号按照上文所述步骤进行解析和重定位
cp以O_TRUNC原地覆盖时,文件路径仍指向相同 Inode,但文件长度和内容在映射仍存活时发生变化。- 对
MAP_PRIVATE文件映射而言,mmap(2)明确指出:映射建立后,底层文件变化是否会反映到映射区域属于未指定行为。Linux 上的实际结果还取决于页是否已经驻留、是否发生 COW、截断和回写时序、文件尺寸以及内核版本。 - 在常见 Linux 实验中,截断会使部分文件页映射失效;后续缺页可能从正在改写的文件读取新内容或不完整内容。若访问落到文件当前末尾之外,可能收到
SIGBUS;执行到不匹配的代码或使用损坏的重定位数据,则可能收到SIGSEGV或产生不可预测的逻辑结果。 - 即使新旧文件字节最终完全一致,原地复制过程仍存在长度为 0、中间长度和内容尚未写完的时刻,不能据此认为操作安全。
上述内容描述的是 Linux 上可观察到的风险模型,而不是可依赖的跨内核接口保证。复现实验应记录内核、glibc、动态链接器和 coreutils 版本,并同时观察缺页、映射和信号行为。
为什么系统会阻止 cp 覆盖可执行程序,而不阻止覆盖 so 文件?
- 操作系统的 Demand Paging 机制下,主程序同样通过文件映射建立 VMA,并按需装入相关页。
- Linux 的
execve()路径会对正在执行的主程序文件建立写入排斥,因此尝试原地写入可能返回ETXTBSY。 - 共享库由动态链接器通过普通文件映射装载,没有获得等价的写入排斥。历史上的
MAP_DENYWRITE标志现在已被 Linux 忽略;差异不是因为用户态动态链接器“权限不够锁定 Inode”,而是内核对主程序执行映像和普通映射采用了不同语义。
结合上述内容,禁止直接用 cp 原地覆盖正在使用或可能被并发加载的共享库。仅执行 unlink + cp 也不够:它虽然保留了旧进程引用的 Inode,却会向新加载者暴露目标缺失或尚未写完的窗口。
单文件发布的基本模式是:在目标的同一目录创建临时文件,完整写入并设置权限,校验通过后再原子 rename()。下面是 GNU 工具环境下的示意流程:
1 | target=/opt/app/lib/libtest.so |
临时文件必须和目标位于同一挂载文件系统,才能依赖 rename() 的原子替换语义。正在运行的进程继续引用旧 Inode,之后打开目标路径的进程只会看到完整的新文件。如需保证系统崩溃或掉电后的持久性,还应在 rename() 前同步临时文件,并在替换后同步父目录。涉及多个必须同时兼容的文件时,可将完整版本写入新目录,再原子切换一个入口软链接或由程序实现版本握手,不能逐个替换文件来模拟事务。
动态库性能开销
分析(1) 中已基本描述了加载动态库和调用动态库内函数的流程。除此之外,动态库使用外部变量和全局变量时也有额外的寻址开销。
案例(3)
1 | // libtest3.cpp |
1 | clang++ -fPIC libtest3.cpp -shared -o libtest3.so |
全局变量 k 和外部变量 p 的访问方式相同,需要读 GOT,例如 koo() 中的步骤为:
- 1764: 读取
rip寄存器中下个指令的实际内存地址,加上k在 GOT 中对应项的偏移地址 0x12bd,读取k的内存地址并保存至rax寄存器 - 176b: 根据
rax寄存器中的值再次寻址读取保存至eax寄存器
静态局部变量 goo()::g 和静态全局变量 foo()::f 的访问则无需 GOT:
- 1784: 通过
rip寄存器和偏移地址 0x22b2 直接获取goo()::g在当前进程内存空间中的值至eax寄存器 - 动态库内访问静态变量,与使用静态链接库时是一样的 8b 类型的 mov 指令,性能上差距微乎其微
1 | nm -CD libtest3.so |
设计良好的动态库通常会减少可抢占全局变量和跨模块数据依赖,以获得更清晰的隔离边界。默认可见的跨模块函数和数据可能增加一次间接寻址,并限制编译器的部分跨模块优化;具体代价取决于 visibility、-fno-semantic-interposition、LTO、绑定模式和调用热点,不能只由“是否为动态库”判断。对大多数非热点调用,这些开销通常不是主要瓶颈,应通过基准测试确认。
共享库兼容性
案例(1),案例(2),案例(3) 中使用了 C++ 符号和对象。C++ ABI 会受到目标平台、编译器 ABI、标准库版本、编译选项和构建配置影响,因此直接使用第三方二进制库时,必须明确双方支持的 ABI 边界。C ABI 通常更稳定,所以跨语言或插件接口经常使用 extern "C";但接口中若暴露布局不稳定的结构体、所有权不清晰的内存、异常或不同分配器管理的对象,仍然可能不兼容。
根据 ld.so(8),依赖字符串包含 / 时会直接按路径处理;不包含 / 时,常见 glibc 动态链接器按以下顺序搜索:
- 如果存在
DT_RPATH且不存在DT_RUNPATH,搜索DT_RPATH - 搜索环境变量
LD_LIBRARY_PATH,安全执行模式会忽略该变量 - 搜索
DT_RUNPATH;它只作用于当前对象的直接DT_NEEDED依赖,不会自动传递给依赖的子依赖 - 查询
/etc/ld.so.cache;/etc/ld.so.conf通常由ldconfig处理并进入该缓存,而不是由每次装载直接逐行搜索 - 搜索默认目录,例如
/lib、/usr/lib,部分 64 位架构使用/lib64、/usr/lib64
Linux 生态常见的共享库命名形式为 libname.so.x.y.z,但版本位数和兼容策略是项目约定,不是动态链接器强制执行的语义:
- 常见形式由前缀
lib、库名称、后缀.so和项目自定义的版本号组成 - 项目通常用
x表示 ABI 主版本,并把 SONAME 设置为libname.so.x y、z常用于兼容升级和修订,但“只新增符号”或“不改变符号”必须由项目的 ABI 策略和测试保证,文件名本身不会提供保证- 开发链接名
libname.so、SONAME 链接和真实文件之间的软链接通常由构建、安装工具或包管理器维护,不会自动指向目录中版本号最大的文件
案例(4)
1 | // main4.cpp |
1 | // libtest4.1.1.1.cpp |
指定共享库的 SONAME 为 libtest4.so.1(仅保留 ABI 主版本号)。链接和安装流程需要显式维护软链接,使 libtest4.so.1 指向当前部署的真实文件;确认新库与该 SONAME 对应的 ABI 兼容后,才可以更新链接。程序打包发布时需要携带 SONAME 链接以及实际共享库文件。
1 | clang++ -fPIC libtest4.1.1.1.cpp -shared -Wl,-soname,libtest4.so.1 -o libtest4.so.1.1.1 |
案例(5)
1 | // libtest5.cpp |
1 | // libtest5.2.cpp |
1 | // libtest5_expect.cpp |
1 | // libtest5_expect.2.cpp |
1 | // main5.cpp |
1 | clang++ -fPIC libtest5.2.cpp -shared -o libtest5.2.so |
在 main5 运行过程中执行 mv libtest5.2.so libtest5.so,等待 10s 后再执行 mv libtest5_expect.2.so libtest5_expect.so,可以看到这段时间内出现非预期的逻辑报错。
同一文件系统内的每个 mv 本身都是原子替换,但两个独立文件的替换合在一起并不具备事务性。本案例暴露的是跨文件版本一致性问题。若程序会在运行时反复装载一组共享库,应发布完整的版本目录并原子切换统一入口,或者在程序侧验证版本并拒绝不兼容组合。
案例(6)
在 2022 年的测试中,基于 CentOS 7 编译的 TiFlash x86_64 二进制可以在 Ubuntu 21.10 上运行,但对应的 AArch64 构建产物在测试环境中出现以下报错:
1 | ./tiflash: /lib/aarch64-linux-gnu/libpthread.so.0: version `GLIBC_PRIVATE' not found (required by ./tiflash) |
分析(6)
TiFlash 的模块构成是什么样的?
以下分析基于 2022 年当时的 commit 636fcd22371266ee2792b4e0636cf96b4cacaa0c。TiFlash v6.0 前后工具链从 GCC 7.x 切换为 LLVM 13;当时在 CentOS 7 构建的 x86_64 二进制具有如下动态库依赖:
1 | ldd ./tiflash |
libtiflash_proxy.so 是一个 Rust 语言编写的动态库:tidb-engine-ext,通过工具查看其导出的符号为:
1 | 0000000000aadda0 T bz_internal_error |
glibc 对外符号通常使用 GLIBC_x.y 版本节点,GLIBC_PRIVATE 表示 glibc 内部接口,不承诺跨版本兼容;GCC_x 版本节点通常来自 libgcc 等 GCC runtime,而不是 glibc 的公开 ABI。应用程序不应直接依赖 GLIBC_PRIVATE,因为它可能随 glibc 实现演化而改变或消失。
查看 TiFlash 的符号表可知共有 3 个地方用到 GLIBC_PRIVATE。而 Ubuntu:21.10 中 的 /lib/aarch64-linux-gnu/libpthread.so.0 和 /lib/aarch64-linux-gnu/libc.so.6 已然没有对应的符号。
1 | readelf -a --wide ./tiflash | grep 'GLIBC_PRIVATE' |
符号是如何被引入的?
以 __gai_sigqueue 为例,在编译目录和源码目录中检索关键字,结果只有最终的 tiflash 二进制匹配。这个结果只能作为“符号可能由链接输入引入”的初步线索,不能单独证明来源;后续还需要结合最终链接命令、静态库重定位表和符号表定位完整依赖链。
1 | cd ${build_dir} |
查看 TiFlash 编译流程最后的链接命令,参数中包含 2 个外部静态库 /usr/lib64/librt.a 和 /usr/lib64/libanl.a。
下面 libanl.a 的重定位与反汇编输出来自 x86_64 构建环境,用于展示同一条符号依赖链;前面的实际加载错误来自 AArch64,二者的指令编码和重定位类型不同,不能把下面的 x86_64 偏移直接套用到 AArch64。
分别导出 重定位表 可知是 /usr/lib64/libanl.a 的 gai_notify.o 引用了 __gai_sigqueue,对应 2 个 重定位入口(Relocation Entry):OFFSET 0000000000000081,OFFSET 00000000000001c2。
1 | objdump -r /usr/lib64/libanl.a |
反汇编 /usr/lib64/libanl.a 找到 gai_notify.o 的 81 位置附近信息,可知是 __gai_notify_only() 和 __gai_notify() 函数调用了 __gai_sigqueue() 函数
- 80 位置开始是一条 5 字节的指令码,80 位置的一个字节表示指令类型,81~84 位置表示 4 字节的偏移地址,当前全都是 0
- OFFSET 为 0x81 的重定位入口类型为
R_X86_64_PC32,相关信息为__gai_sigqueue-0x0000000000000004,等同于在链接阶段由链接器修正 81 位置开始的 4 字节偏移地址 - 假设最终
__gai_sigqueue@plt被装载到地址 a,__gai_notify_only()被装载到地址 b,则偏移地址被修正为 a - (0x85 - 0x50 + b) - 1c1: 行的偏移地址修正也是同理
1 | objdump -d /usr/lib64/libanl.a |
排查函数调用关系可以得出符号被引入 TiFlash 的过程:
- libc.so:实现并对外导出
__gai_sigqueue()函数,对应版本GLIBC_PRIVATE - libanl.a:
gai_notify.o中__gai_notify()和__gai_notify_only()调用了__gai_sigqueue()getaddrinfo_a.o中getaddrinfo_a()调用了__gai_notify_only()gai_misc.o中handle_requests()调用了__gai_notify()
- TiFlash 代码中的
poco模块调用了getaddrinfo_a()函数:pingcap/poco/Net/src/DNS.cpp#L167 - libanl.a 和 libc.so 被先后链接,
getaddrinfo_a被决议成本地符号,__gai_sigqueue被决议成外部符号
1 | nm -CD /lib64/libc.so.6 |
如何解决上述外部符号问题?
a. 定义并固定最低运行环境
- Linux 二进制通常应在支持范围内最老的 glibc 基线、对应 sysroot 或受控构建容器中编译,并在所有目标发行版和架构上测试。为每个实际运行环境单独构建也能规避一部分问题,但会增加产物数量和环境漂移,不一定是整体风险最低的长期方案。
b. 将有风险的符号在本地实现
- 例如 CK 的 glibc-compatibility 模块中 glibc-compatibility.c#L18 在本地实现
__gai_sigqueue()。参考 Linux 静态库 中介绍的链接器行为,控制链接顺序可以令最终符号引用落到本地实现; - 实际上 TiFlash 的代码里也有类似的实现 libglibc-compatibility,但在 ARM 平台编译时不启用(尚未适配)。
- 这是一种需要逐版本审计和测试的兼容层方案,不是依赖
GLIBC_PRIVATE后的通用补救措施。内部符号的调用约定和语义同样可能改变,仅复制某个版本的实现并不能自动获得兼容性。 - 注意:如果程序用
-static的模式编译(即链接/lib64/libc.a),则要注意符号冲突。- 例如本案例中
__gai_sigqueue来自gai_sigqueue.o,glibc 中该目标文件下没有定义导出其他符号,不会有影响 - 假如本地实现了
getnssent_r.o的__nss_endent,则需要同时实现__nss_getent_r和__nss_setent。如果程序其他模块引用了__nss_getent_r并需要链接getnssent_r.o,则容易引起符号冲突。
- 例如本案例中
c. 利用 asm 显式地为公开函数指定 glibc 符号版本。这种方式只适用于目标版本确实提供兼容实现的公开符号,需要控制影响范围并持续测试
1 | .symver __foo_old, foo@VER1 |
- 例如下面的例子,通过源码中的
.symver指令将realpath()引用指定为GLIBC_2.2.5版本。它不会把新接口自动转换成旧 ABI,也不应被用于假装兼容GLIBC_PRIVATE。
1 | // main6.cpp |
1 | clang++ main6.cpp -o main6 && readelf -a main6 | grep 'realpath' |
动态库 or 静态库
案例(7)
案例(6) 中提到了 Rust 语言实现的动态库 libtiflash_proxy.so,为什么实现上要选择动态库而不是静态库?
分析(7)
动态库和静态库不存在适用于所有工程的固定搭配。选择时需要同时考虑 ABI 边界、进程隔离、升级方式、启动和调用开销、产物大小、可观测性、重链接成本以及依赖治理。大型工程使用动态库可以缩小部分链接和部署边界,但只有在兼容协议完善时,替换灵活性才会成为优势。
涉及跨语言交互的场景,符号冲突是一个不可忽略的问题。Rust 关于产物和符号处理的行为是:
crate-type = ["cdylib"]用于生成面向其他语言调用的动态库。需要跨语言调用的入口应显式使用 C ABI,并按所用 Rust edition 的要求声明稳定的导出名;旧代码常见#[no_mangle] pub unsafe extern "C",新 edition 对 unsafe attribute 的语法可能不同。例如 print_raftstore_proxy_version 函数定义。staticlib会把 Rust crate 及其需要的依赖打包为供其他语言链接的静态库,最终链接器可能看到大量全局符号;最终可执行文件对外导出哪些符号,还取决于链接参数、visibility 和 version script。
由于种种原因,tidb-engine-ext 需要同 TiKV 的代码栈保持基本同步。如果将 raftstore_proxy 的类型改成 #[crate_type = "staticlib"],编译得到 libraftstore_proxy.a,强制魔改下 TiFlash 的代码,让其链接这个静态库而非 libtiflash_proxy.so,可以看到多处链接时报错 ld.lld: error: duplicate symbol。报错的符号有 __rust_drop_panic,rust_panic,grpc_set_ssl_roots_override_callback 等,与 libsymbolization.a,libgrpc.a 这几个本地编译的静态库相冲突。虽然理论上有诸多办法可以解决(参考 静态库冲突问题)但相对会增加心智负担。
使用动态库可以把交互面收敛到少量导出接口,同时避免把整个 Rust 静态依赖图纳入同一次最终链接,但仍要处理符号污染和 ABI 兼容。对于不涉及接口格式的模块内改动,动态库通常只需重建并重新部署该库;静态库通常需要重新链接最终产物,但并不等于重新编译所有未修改的源文件。
根据上文的介绍,动态库装载时会进行符号绑定,所以在使用时有几点需要注意:
- 与 TiKV 不同,tidb-engine-ext 不指定内存分配器(即令
malloc|free之类的内存管理接口被编译器决议成未定义符号),此举是为了保证同宿主进程兼容。例如 TiFlash 中使用了自定义的内存分配器 jemalloc,malloc|free 之类的函数被决议成内部重载的版本,参照上文 分析(1) 的动态库装载过程,libtiflash_proxy.so最终使用的是 TiFlash 主进程提供的内存分配器。如果 TiFlash 也不指定内存分配器,则最终使用的是 Glibc 相关库中的默认版本。 - 使用 tidb-engine-ext 时以下几个符号需要注意,不要有本地同名符号,以免载入动态库时这些函数被本地实现覆盖:
bz_internal_error由 bzip2-sys 引入;perf_signal_handler由 pprof-rs 引入;rust_eh_personality则是编译器的保留项; - 如果本地同时引入了其他 Rust 环境,例如该版本中的
libsymbolization.a,则需要尽量保证这些环境使用的 Rust 版本和 ABI 配置一致,以免产生非预期行为。
在本文分析的版本中,libtiflash_proxy.so 使用隐式加载模式,核心功能入口主要是 run_raftstore_proxy_ffi;动态库与宿主进程间交互的接口(主要是函数指针)通过这个入口相互注册,因此从接口形态上具备改为运行时加载的条件。
- 这种动态库设计方向把接口封装到运行逻辑的上下文中而不是暴露大量独立函数,为未来的模块替换保留了可能性。但真正支持热更新还需要定义状态迁移、线程退出、回调失效、对象所有权和回滚协议;函数指针注册本身并不足够。额外的间接调用开销在正常业务场景中通常可以忽略。
- 运行时加载可以缩小静态依赖关系,但不会自动解决符号冲突。仍需结合最小导出集、visibility、链接选项;需要严格隔离时使用独立装载命名空间,参考 案例(1)。
Linux 静态库
本节作为动态链接主题的补充附录,用静态归档的抽取规则解释前文遇到的重复符号问题。
在软件开发体系中,把每个源代码模块独立地编译,然后按照需要 “组装” 起来,这个组装模块的过程就是链接(Linking)。链接器的工作主要是把指令对其他符号地址的引用加以修正。链接过程主要包括了地址和空间分配(Address and Storage Allocation)、符号决议(Symbol Resolution,aka 符号绑定)和重定位(Relocation)等步骤。
静态库可以看作是目标文件的合集,链接器从静态库抽取成员时以目标文件为基本单位。例如一个简单的 hello world 程序引用外部符号 printf 并静态链接 /lib64/libc.a,链接器会找到定义该符号的目标文件并将其纳入链接过程;该目标文件引用的其他未定义符号又会继续参与决议。若编译时使用 -ffunction-sections / -fdata-sections 且链接时启用 --gc-sections,最终输出仍可能丢弃已抽取目标文件中的未引用 Section,因此“抽取整个目标文件”不等于所有字节一定保留在最终程序中。
下面案例描述的是普通的按命令行顺序扫描归档文件。循环依赖可通过 --start-group / --end-group 要求链接器重复扫描一组归档,--whole-archive 则会强制纳入归档的全部成员;这些选项会改变抽取结果和产物大小。
- 理论上按照层次化|模块化存储和组织源代码有很多好处,比如代码更容易理解、重用,每个模块可以单独开发、编译、测试,改变部分代码不需要编译整个程序等。
静态库冲突问题
分析(7) 中提到了 TiFlash 静态链接 libraftstore_proxy.a 时遇到符号冲突的问题。本章节介绍几种解决静态库符号冲突的方法。
案例(8)
1 | // test7_1.cpp |
1 | // test7_2.cpp |
1 | // test7_3.cpp |
1 | // main7.cpp |
3 个静态库内同时实现了 foo 函数和其他自定义函数,main7 编译链接 libtest7_2.a、libtest7_3.a 和 libtest7_1.a,最后报错 duplicate symbol: foo()
- 为什么会产生
duplicate symbol? libtest7_3.a中也实现了foo函数,为什么没有出现在报错信息里?
1 | clang++ --version |
分析(8)
链接过程详解:
- 每个模块都是独立编译的,编译器在编译 main7.cpp 时并不知道其引用的几个函数(包括
printf)的地址,所以生成的目标文件(此处为临时文件,例如/tmp/main7-6aa576.o)的符号表中包含未定义类型的符号_Z3foov,_Z3koov,_Z3goov,printf。 - 根据链接参数的顺序,首先处理的是
/tmp/main7-6aa576.o。链接器将其纳入输出目标,此时_Z3foov、_Z3koov、_Z3goov、printf均为待决议符号。 - 链接器从
libtest7_2.a的test7_2.o模块的符号表中找到待决议的符号_Z3foov和_Z3koov,纳入该目标文件并完成重定位。还剩下_Z3goov,printf。 - 链接器从
libtest7_3.a各个模块的符号表中找不到待决议的符号,则直接跳过。 - 链接器从
libtest7_1.a的test7_1.o模块的符号表中找到待决议的符号_Z3goov,但在链接模块test7_1.o过程中发现符号_Z3foov已经被绑定,则对外报错。
解决方案(8)
删除冲突的符号
- 如果可以修改源代码,这是最简单有效的方法。
- 如果没有源码,可以借助工具
llvm-ar -x lib__.a拆分成独立目标文件,按需移除归档成员后再重新打包静态库- 仅通过移除归档成员时,操作单位是目标文件;如果同时删掉有用符号,后续链接会报错
undefined symbol。若冲突符号和有用符号位于同一个目标文件,应优先考虑后文的重命名或本地化方案,而不是直接删除该成员。
- 仅通过移除归档成员时,操作单位是目标文件;如果同时删掉有用符号,后续链接会报错
修改符号名称
- 如果可以修改源代码则相对简单。
- 如果没有源码,可借助工具
llvm-objcopy --redefine-sym <old>=<new> lib__.a,直接修改符号名称- 缺点:实际使用修改后的静态库时,需要适配新符号。
修改冲突符号的类型
- 将冲突符号的类型由
GLOBAL修改为LOCAL。 - 为了避免静态库内其他目标文件无法引用被本地化的符号,可先把相关目标文件部分链接成一个新的 relocatable object
- 缺点:链接器抽取该组合对象时会把它作为一个成员处理,可能增加参与链接的 Section;最终是否保留未引用 Section 仍取决于
--gc-sections等选项
- 缺点:链接器抽取该组合对象时会把它作为一个成员处理,可能增加参与链接的 Section;最终是否保留未引用 Section 仍取决于
1 | // test7_4.cpp |
1 | clang++ -c test7_4.cpp -o test7_4.o |
总结
- 原地截断正在映射的共享库会破坏进程对文件内容稳定性的假设;发布时应写入同目录临时文件,再用
rename()原子替换。 - 单文件原子替换不能提供多文件事务。存在成组兼容关系时,应切换完整版本目录或在程序侧执行版本握手。
- 动态装载、
RTLD_LOCAL与符号隔离是不同概念。控制 ABI、符号可见性和装载命名空间比依赖偶然的链接顺序更可靠。 - SONAME 和版本号只是兼容策略的载体,真正的兼容性需要 API/ABI 规范、构建基线和持续测试共同保证。
readelf、objdump、nm、strace和/proc/<pid>/maps能把链接与装载问题转化为可验证的证据,但分析结论必须注明架构、工具链、glibc 和内核版本。