Linux 动态链接库相关整理
PingCAP(aka 贵司
)自研了套部署工具 TiUP 来取代早前使用的重型部署工具 Ansible。然而实际使用时,TiFlash 节点在打 patch 过程中出现过不符合预期的报错。后来发现,TiUP 打 patch 的操作并没有停进程,而是直接 tar
命令解压覆盖部署目录,原子性保障也是无从谈起。v6.0 版本前 TiFlash 部分模块包含动态加载 共享库
(aka 动态库
)的行为,这类行为会被搜索路径下的库文件影响。如果存在某个时刻引入的模块相互不兼容,则直接影响程序的行为。
因为是用 tar
命令解压覆盖,反而没有出现老生常谈的 cp
覆盖动态库引起 Segmentation fault 的问题。
正好借此整理下相关的几个问题:
- cp / tar 命令是如何工作的?
- cp 覆盖正在被使用的动态库为何会引发异常?
- 使用动态库要避开哪些坑?
- 动态库有哪些额外的开销?
Linux 文件系统基础
相关基础参考 Modern Operating Systems,The Ext2 Filesystem,kernel.org/doc/filesystems,深度细节建议看操作系统代码实现。
Inode
Linux 下 Inode(Index node) 是一个重要概念,是理解 Unix/Linux 文件系统和硬盘储存的基础。Inode、软链接、硬链接 等入门基础可以参考 阮一峰. 理解inode,解释得比较易懂。以下部分做点补充。
Inode 本身是一种抽象设计,不同操作系统有各自实现,此处针对传统的类 Unix 文件系统。
文件储存在硬盘上,硬盘的最小存储单位叫做 扇区(Sector)
,每个扇区大小为 512B。操作系统存取文件的最小单位是 块(Block)
,一般为连续 8 个扇区。文件数据存储在块中,Inode 则作为索引节点存储文件的元信息:Inode 号码,文件字节数、User ID、Group ID、读|写|执行权限、3 个时间戳(Inode 上次变动时间,文件数据上次变动时间,文件上次打开时间)、链接数(硬链接)、数据 Block 位置。详细定义可参考 linux/man-pages/inode。
Inode 大小(通常为 256B)和数量在格式化时给定。通过命令获取某块 3.6T 硬盘的 Inode size
值为 256B,其 Inode 区存储上限是 233M。
1 | dumpe2fs -h /dev/${disk} | grep -E "Inode size|Block size" |
1 | df -hi /dev/${disk} |
由此推算出,该硬盘最多存储 953888 个 Inode。假设每个 Inode 对应一个 4K 大小的独立目录,总容量也不超过 3.65G。所以确实会出现因 Inode 耗尽而无法创建文件的极端情况。
TiFlash 存储模块也险些暴露出来类似问题
- 早前版本中,TiFlash 的 schema sync 逻辑会把 TiDB 的每张物理表都在本地建立对应的文件夹以及 schema 相关的文件
- 倘若表的数量过多,超过磁盘 Inode 承载的上限,则会导致系统不可用
Inode 在同个文件系统内保证唯一,在不同文件系统中互不依赖,存在 2 个不同磁盘上的文件 Inode 号码相同的情况。通过 Inode 号码直接删除 Inode 节点,等同于删除文件。当文件被删除后,Inode 会被系统回收再分配。
1 | ls -i |
Unix/Linux 系统中,目录(directory)也是一种文件:
- 创建目录时,默认会生成两个目录项:
.
和..
。 - 前者的 Inode 号码就是当前目录的 Inode 号码,等同于当前目录的
硬链接
,也就使得新建空目录的链接数加 1 等于 2。 - 后者的 Inode 号码就是当前目录的父目录的 Inode 号码,等同于父目录的硬链接,令父目录的
链接数
加 1。 - 对于 ‘/‘ 目录,则两者都指向自己。
- 系统目录的 Inode 为特定项(Inode Number: 1),一般根据挂载顺序 ‘/‘ 目录的 Inode Number 为 2。
1 | stat / |
硬链接的目标和源共用同个 Inode,因此不能跨盘建立,否则报错 Invalid cross-device link
。通过 ln ${src} ${tar}
建立硬链接无法作用于目录,否则报错 hard link not allowed for directory
。
硬链接自身就是文件系统中的一环,链接数归 0 后系统才会尝试回收对应的文件,与引用计数的管理策略类似。如果目录可以建立硬链接,则容易造成循环引用,导致文件无法回收。与硬链接不同,软链接就是个独立的二进制文件,使用时再通过文件系统解析到对应的目标,如果失败则会返回错误。也就意味着软链接不会对文件系统本身产生侵入性影响。
Inode 深入分析
以 ext4 文件系统为例,硬盘格式化的时候,操作系统将其划分为多个 Block Group
- Group 0 前面预留 1024 字节,可用于安装 x86 引导扇区。
Group 0 Padding | Block Group 0 | Block Group 1 | … | Block Group N |
---|---|---|---|---|
1024 bytes |
- 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 |
参考 struct ext4_super_block 以及 struct ext4_inode
- 每个 Block Group 包含
s_blocks_per_group
个 Block,即8 * block_size_in_bytes
(Bitmap 大小为block_size_in_bytes
,最多可以表示的位数8 * block_size_in_bytes
)。 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 获取 Data Blocks 中对应的数据块
- 根据数据块中的结构读取实际数据:ext2 和 ext3 中结构为直接/间接数据块表;ext4 中的结构则是 Extent Tree;
cp/rm/mv/tar 命令
用 strace
可以很清楚地看到命令执行时的系统调用。一般 rm
主要用到 unlink*
,mv
主要用到 rename*
,cp
主要用到 open
、read
、write
。
根据 linux/man-pages/unlink,unlink
用于按照文件名删除文件:
- 如果文件无其他链接,且没有进程打开该文件,则删除文件并释放空间
- 如果文件仅此一份链接,但存在进程仍然打开该文件,删除后文件依然存在,直到引用它的最后一个文件描述符关闭,系统才会回收
因此通过 unlink
删除正在被使用的文件是安全的。
rename
面对的场景更加复杂,根据 linux/man-pages/rename,当目标已经存在时
- 如果是非跨盘行为,会保证操作的原子性
- 如果是跨盘行为,则报错
Invalid cross-device link
- 跨盘
mv
命令会忽略报错,通过 unlink 删除目标,新建文件后复制内容,最后 unlink 删除源文件
根据 linux/man-pages/open,当目标已存在,cp
命令调用 open 的参数会包含 O_TRUNC
(即 truncate 模式),复用目标的 Inode,清空内容,把源文件内容写入目标。当程序正在运行时,调用 cp
尝试覆盖其二进制文件,会报错 Text file busy
(但 cp 覆盖正在被使用的共享库则不然),这缘于操作系统的保护机制,详见 案例(2)。如果使用 cp -f
进行覆盖则会忽略报错,并执行 unlink 后再复制,前后 Inode 已经发生改变,属于安全行为。
追踪 tar
命令的执行过程,可以看到当目标已存在时,是 unlink 删除后再新建并复制写入。因此通过 tar 命令覆盖正在被使用的文件也是安全的。
Linux 动态库
强烈推荐 《程序员的自我修养——链接装载与库》 这本书,细致全面地囊括了各项相关知识。
Linux 下的库有两种:静态库
和 共享库(aka 动态库)
,静态通常用 .a
为后缀,动态库用 .so
为后缀。
Linux 下动态库、静态库、可执行文件的格式均 ELF(Executable Linkable Format)
,可通过 file
命令查看其具体类型细节。使用 ldd
工具,查看 Object file(目标文件) 依赖的动态库。
动态库的优势:
- 不同的应用程序如果调用相同的库,那么在内存里只需要有一份该共享库的实例
- 动态库的代码是在可执行程序运行时才载入内存的,在编译过程中仅简单的引用,生成的可执行程序代码体积较小
- 编译链接代价小,对于长期稳定的模块库,无需反复拷贝数据
- 动态替换,可支持程序热更新
动态库的劣势:
- 访问全局数据|静态数据成员、跨模块函数调用等行为需要额外的定位寻址开销,造成性能损耗
- 模块兼容性问题:DDL hell
链接相关概念中,函数和变量统称为 符号(Symbol)
,函数名或变量名就是符号名。每个目标文件都有一个 符号表(Symbol Table)
,表中记录了目标文件用到的所有符号。
使用 nm
命令查看目标文件的符号信息,nm(1) - Linux man page,符号类型主要包括:
- T / t:代码段中的函数
- U:被调用但并没有定义的符号(表明需要其他库支持)
- W / w:”弱态” 符号,它们虽然被定义,但是可能被其他库中的同名符号覆盖(常见于模板、结构体、类等使用场景)
- B / b:bss 中的未初始化全局/局部变量
- D / d:数据段中初始化的全局/局部变量
- …
目标文件将不同属性的信息分段(Segment)
存储,可用 objdump
或 readelf
工具导出相关信息:
.dynsym
未定义分配的符号表.rel.dyn
加载时需要重定位的变量.rel.plt
需要重定位的函数.text
源代码编译后的指令.plt
延迟绑定的外部函数调用的指令.data
已初始化的全局变量和局部静态变量.bss
未初始化的全局变量和局部静态变量.symtab
全量符号表.got
外部全局变量地址表.got.plt
外部函数地址表- …
为了使程序模块中共享的指令部分在装载时不需要随装载地址变动而变动,可将指令中需要被修改的部分分离出来放到数据部分中,这样指令部分保持不变,数据部分可以在每个进程中有独立可修改的副本,这种方案就是 地址无关代码(PIC,Position-independent Code)
。实现方式:
- ELF 在数据相关表段里建立指向这些变量的指针数组,也被称为
全局偏移表(Global Offset Table,GOT)
,当代码需要访问这些跨模块数据时,可通过 GOT 中变量对应的项找到目标地址 - GOT 本身位于数据相关表段,可以在模块装载时被修改,链接器在装载模块时会查找变量的实际地址并填充 GOT
- 模块的数据相关表段在每个进程都有独立的副本
- 通常 ELF 将 GOT 拆分成 2 个表段:
.got
和.got.plt
跨模块的函数调用也是同样原理,每次调用前需要定位到函数在当前进程中的内存虚拟地址。ELF 普遍采用延迟绑定的的做法,基本思想是当函数第一次被调用时才进行绑定(符号查找、重定位等),使用 PLT(Procedure Linkage Table)
的方法来实现,详见 案例(1)。
链接动态库
案例(1)
当程序链接多个包含相同函数的库时,可能出现非预期的结果
1 | // v1.cpp |
1 | // libtest.cpp |
1 | // v2.cpp |
1 | clang++ -c v1.cpp -o v1.o && ar -cr libv1.a v1.o |
1 | // main.cpp |
编译执行结果为
1 | clang++ main.cpp -L./ -lv2 -ltest -rpath `pwd` -o main && ./main |
在 libtest.so 内部存在已定义的函数 foo()
和 test()
,但实际上 test() 调用的是 v2 库中的 foo() 而不是 libtest.so 自身的,与直觉相悖。
交换库链接顺序 或者只链接 libtest.so,最终调用的才是 libtest.so 中的 foo() 。
1 | clang++ main.cpp -L./ -ltest -lv2 -rpath `pwd` -o main && ./main |
分析(1)
libtest.so 中对外导出函数 test()
和 foo()
,test() 内调用了 foo(),所以需要对 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 |
该案例使用动态库的方式为隐式加载(载入时加载)
- 类似静态库链接的过程发生在程序加载时,动态链接器将所有相关动态库装载到进程地址空间,将程序中未定义的符号绑定到相应的动态链接库,进行重定位工作,即
装载时重定位(Load Time Relocation)
:- 链接器按照深度|广度优先顺序加载把程序和相关共享库的符号表都合并到 全局符号表(Global Symbol Table)
- 链接器基本规则:全局符号表中已存在同名符号,则后加入的忽略
- 首先载入主程序,main 中默认导出 foo() 且对应 v2 库的实现,foo 函数符号和地址 0x201800 先注册到全局符号表。
- 2017c0: 压栈
rbp(栈基地址寄存器)
的值,保存调用者帧的栈底 - 2017c1: 将
rsp(栈指针寄存器,指向栈顶)
的值赋予 rbp,将调用者帧的栈顶设为当前帧的栈底,等于开辟新栈 - 2017c4: 预留 16字节空间给临时数据
- 2017c8: 调用 foo() 函数,callq 约等于 push %rip + jump <_Z3foov>
- 201804: 设置返回值至 eax 寄存器
- 2017cd ~ 2017eb: 调用 print() 和 test() 函数
- 2017ed: 清除预留空间,还原 rsp
- 2017f1: 还原 rbp
- 2017f2: 跳转回调用者的指令,约等于 pop %rip + jump …
1 | objdump -d main |
- 由于 libtest.so 对外导出 foo(),所以 test 函数调用 foo 无法按照模块内函数调用的方式,编译器会当作 模块外函数 处理。
- 设 α 为 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: 跳转到符号解析和重定位流程入口,从全局符号表中获取 foo() 的进程内地址 0x201800 填入地址 γ,最后调用 foo() 函数
1 | objdump -d libtest.so |
- 载入 libtest.so 符号表时,foo 函数符号无法注册到全局符号表
- libtest.so 中 PLT 重定位时填入全局符号表中 foo 函数符号对应的地址,最终调用 v2 库的逻辑
交换库链接顺序或者只链接 libtest.so 时也是同样原理,main 程序中 foo 函数符号被编译器决议成为未定义(U类),加载 libtest.so 时注册到全局符号表。
1 | nm -CD main |
1 | ... |
解决符号冲突
为避免符号冲突,可通过以下几种方式
显式加载(运行时加载)
1 | // main2.cpp |
程序运行时,在逻辑中加载 libtest.so 获取 test 函数入口。编译时则不需要链接项 -ltest。
1 | clang++ main2.cpp -L./ -lv2 -ldl -rpath `pwd` -o main2 && ./main2 |
当进程中的模块是通过 dlopen()
载入的共享对象,dlsym()
查找符号的优先级分为 2 种:
- dlopen() 参数
filename
为 nullptr, 则是从全局符号表查找,即装载序列(Load Ordering)
。 - dlopen() 指定共享对象时,则采用
依赖序列(Dependency Ordering)
的优先级:以被 dlopen() 打开的共享对象为节点,对其依赖对象进行广度优先遍历,直到找到符号。
符号隐藏
- 隐藏 main 入口 foo() 函数
1 | // main3.cpp |
1 | clang++ main3.cpp -L./ -lv2 -ltest -rpath `pwd` -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 libtest.so |
替换动态库
案例(2)
如果 so 正在被使用时,执行 cp ${newlib}.so ${oldlib}.so
,则容易引起程序 core dump。
分析(2)
- 应用程序加载动态库时,内核通过 mmap 把 so 加载到进程地址空间,对应
Virtual memory area (VMA)
中多个页(Page)
- 相同 Inode 的 so 可被不同程序共享页缓存
- 段的装载地址和空间的对齐单位是页
- dynamic linker/loader 会把 so 里面引用的外部符号按照上文所述步骤进行解析和重定位
- 当 so 被
cp
以 truncate 模式覆盖时,内核会把 so 文件在虚拟内存页清除掉 - 运行到 so 里面的代码时,因为虚拟内存页已被清除,会产生一次缺页中断
- 缺页中断会导致内核从 so 文件中拷贝对应的页到内存中,so 地址范围内的数据相关表段也会替换为原始值,GOT / PLT 相关因此丢失重定位信息
- 前后 so 不一样,则逻辑执行结果不可知
- 例如需要的访问的地址偏移大于新的 so 的地址范围,就会产生
Bus error
- 访问非法地址则会引起
Segmentation fault
- 例如需要的访问的地址偏移大于新的 so 的地址范围,就会产生
- 前后 so 文件完全一致:
- 如果调用到依赖外部符号的逻辑,但此时外部符号并没有经过重新解析,直接使用
.got
/.got.plt
段数据,则会引起访问非法地址Segmentation fault
- 如果调用的逻辑没有依赖外部符号
- 如果逻辑依赖
.data
/.bss
段数据(例如静态变量),则会因为数据被替换而引发逻辑异常 - 如果逻辑没有其他依赖,则可正常运行
- 如果逻辑依赖
- 如果调用到依赖外部符号的逻辑,但此时外部符号并没有经过重新解析,直接使用
为什么系统会阻止 cp
覆盖可执行程序,而不阻止覆盖 so 文件?
- 操作系统的 Demand Paging 机制下,加载程序时也同上文一样映射 VMA,有访存需求时才加载相关页。
- 为防止正在运行中的程序镜像(并非文件本身)被意外修改,因此内核在启动程序后会锁定这个程序镜像的 Inode。
- so 文件是靠 ld.so 加载的,属于用户态程序,没有权限锁定 Inode。
结合上述内容,替换动态库时,必须先执行系统调用 unlink*
删除目标。因此禁止直接使用 cp
,可选 install
、rm + cp
等
动态库性能开销
分析(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 |
通常来说动态库会尽量少使用外部变量和全局变量,以获得更好的隔离性,减少符号依赖风险,所以一般情况下讨论动态库的性能损耗着重于跨模块函数调用(额外的内存间接寻址,跨模块访存局部性变差)。但绝大多数实际应用层面,这些开销基本可以忽略。
共享库兼容性
案例(1),案例(2),案例(3) 中使用的均是 C++ 的 ABI (Application binary interface)
,由于其标准会随着编译器而改变,所以假如程序直接使用第三方的二进制库,务必保证双方的 ABI 标准一致。C 语言的 ABI 则相对稳定,加之大部分操作系统为其标准背书。实际生产环境应用中,跨语言 / 跨模块 交互的场景基本用的都是 C 式接口。
共享库动态链接|装载时搜索路径顺序:
- 编译目标代码时指定的共享库搜索路径(设置 rpath)
- 环境变量
LD_LIBRARY_PATH
指定共享库搜索路径 - 配置文件
/etc/ld.so.conf
中指定的共享库搜索路径 - 默认的共享库搜索路径:
/lib
、/usr/lib
、/usr/local/lib
Linux 有一套共享库命名规范 libname.so.x.y.z
- 前缀
lib
,库名称,后缀.so
,3 个版本号 x
表示主版本号(Major),y
表示次版本号(Minor),z
表示发布版本号(Release)- 主版本号不同的库不保证兼容
- 次版本号表示库增量升级,只新增符号
- 发布版本号表示内部修改,不改变符号
案例(4)
1 | // main4.cpp |
1 | // libtest4.1.1.1.cpp |
指定共享库的 SO-NAME
为 libtest4.so.1(仅保留主版本号),该软链接保持指向目录中相同主版本,次版本和发布版本最新的共享库。当进行共享库兼容式升级时,只需修改该软链接。程序打包发布时,则需打包该软链接以及实际指向的共享库文件。
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
,可见看出这段时间内出现了非预期的逻辑报错。
假如程序存在运行时装载使用共享库的行为,为了保障服务的稳定,需要尽可能令更新二进制文件的操作满足原子性,否则就得在程序逻辑侧加以控制。
案例(6)
在 x86_64
平台下,把基于 CentOS7 编译的 TiFlash 二进制文件放到较新的 Ubuntu:21.10
系统中可以正常运行,但在 aarch64
平台下则会出现以下报错
1 | ./tiflash: /lib/aarch64-linux-gnu/libpthread.so.0: version `GLIBC_PRIVATE' not found (required by ./tiflash) |
分析(6)
TiFlash 的模块构成是什么样的?
以当前较新的 commit 为例 636fcd22371266ee2792b4e0636cf96b4cacaa0c,v6.0 之后整体工具链从 GCC-7.x 切换为 LLVM-13,CentOS7 系统编译出的二进制对应的动态库依赖如下
1 | ldd ./tiflash |
libtiflash_proxy.so
是一个 Rust 语言编写的动态库:tidb-engine-ext,通过工具查看其导出的符号为:
1 | 0000000000aadda0 T bz_internal_error |
Glibc 库中可以看到类似于 GCC_
为前缀或者是 GLIBC_PRIVATE
的符号版本。后者表示 非公开版本,有可能随着共享库的版本演化而被删除或改变,最好不要使用这些符号,否则风险自负。
查看 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
为例,进到 编译目录|源码目录 下,对所有文件(包括二进制)检索关键字 __gai_sigqueue
,结果显示只有编译终产物 tiflash 二进制文件包含该信息,可以看出符号不是本地代码引入的。
1 | cd ${build_dir} |
查看 TiFlash 编译流程最后的链接命令,参数中包含 2 个外部静态库 /usr/lib64/librt.a
和 /usr/lib64/libanl.a
。
分别导出 重定位表
可知是 /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. 基于实际运行环境编译发布
- 这是风险最小的方式,面向小众平台(例如苹果的 MacOS)发布可以选用这种方式快速解决兼容性问题,缺点是成本较高
b. 将有风险的符号在本地实现
- 例如 CK 的 glibc-compatibility 模块中 glibc-compatibility.c#L18 直接将
__gai_sigqueue()
函数代码在本地实现。参考 Linux 静态库 中介绍的链接器行为,只要链接 glibc-compatibility 先于 glibc,则最终符号表里用的就是本地实现的函数; - 实际上 TiFlash 的代码里也有类似的实现 libglibc-compatibility,但在 ARM 平台编译时不启用(尚未适配)。
- 注意:如果程序用
-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 |
- 例如下面的例子,通过编译参数强制将
realpath()
指定为GLIBC_2.2.5
版本。
1 | // main6.cpp |
1 | clang++ main6.cpp -o main6 && readelf -a main6 | grep 'realpath' |
动态库 or 静态库
案例(7)
案例(6) 中提到了 Rust 语言实现的动态库 libtiflash_proxy.so
,为什么实现上要选择动态库而不是静态库?
分析(7)
从开发者角度来说理想的模式是所有功能按模块拆分成库,开发调试时用动态库,打包发布时用静态库。对于大型工程而言,动态库可灵活替换带来的便利性不言而喻。
涉及到跨语言交互的场景,符号冲突是一个不忽略的问题。Rust 关于符号处理的行为是:
- 对于
crate-type = ["cdylib"]
类型的库,Rust 默认对外 隐藏符号,如果要以 C 语言的 ABI 标准对外导出函数需显式地指定其属性为#[no_mangle] pub unsafe extern "C"
,例如 print_raftstore_proxy_version 函数定义。 - 对于
#[crate_type = "staticlib"]
类型的静态库,则是同其他语言一样默认导出符号。
由于种种原因,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
这几个本地编译的静态库相冲突。虽然理论上有诸多办法可以解决(参考 静态库冲突问题)但相对会增加心智负担。
使用动态库则相对简单,仅需把交互相关的接口定义好导出即可(同时也要避免符号污染)。对于开发者而言,对于不涉及接口格式的模块内改动,则不需要静态库那样全量编译。
根据上文的介绍,动态库装载时会进行符号绑定,所以在使用时有几点需要注意:
- 与 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 版本一致,以免产生非预期的行为。
尽管目前 libtiflash_proxy.so
是用的隐式加载的模式,但实际上核心的功能函数就只有 run_raftstore_proxy_ffi
,动态库与宿主进程间交互的接口(主要是函数指针)通过这个入口相互注册,所以完全可以做到运行时加载。
- 这种动态库设计方向,把接口封装到运行逻辑的上下文中而不是独立的函数,以此来支持热更新。当然缺点也是有的:一来是目前 tidb-engine-ext 面向的场景并不需要热更新,二来额外的封装造成接口函数调用多出些间接寻址开销(正常业务场景中可以忽略不计)。
- 运行时加载也可以解决潜在的动态库符号冲突问题,参考 案例(1)。
Linux 静态库
在软件开发体系中,把每个源代码模块独立地编译,然后按照需要 “组装” 起来,这个组装模块的过程就是链接(Linking)。链接器的工作主要是把指令对其他符号地址的引用加以修正。链接过程主要包括了地址和空间分配(Address and Storage Allocation)、符号决议(Symbol Resolution,aka 符号绑定)和重定位(Relocation)等步骤。
静态库可以看作是目标文件的合集,链接器链接静态库是以目标文件为基本单位的。例如一个简单的 hello world
程序引用了外部符号 printf
并静态链接 /lib64/libc.a
,那么链接器处理 printf
时,会先链接 /lib64/libc.a
中实现该函数的目标文件 printf.o
,将其整个纳入到输出目标中(也包括其他可能对该程序无用的函数),该目标文件还引用了外部符号 stdout
和 vfprintf
,则这些符号又会继续被处理,直到决议和重定位完所有符号。
- 理论上按照层次化|模块化存储和组织源代码有很多好处,比如代码更容易理解、重用,每个模块可以单独开发、编译、测试,改变部分代码不需要编译整个程序等。
静态库冲突问题
分析(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++ -c test7_1.cpp -o test7_1.o && ar -cr libtest7_1.a test7_1.o |
分析(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
。 - 为了避免静态库内其他目标文件无法链接被修改的符号,将静态重构成单个目标文件的结构
- 缺点:只能全量链接整个库,会增大程序体积
1 | // test7_4.cpp |
1 | clang++ -c test7_4.cpp -o test7_4.o |
TODO
未完待续