Linux 动态链接库相关整理

PingCAP(aka 贵司)自研了套部署工具 TiUP 来取代早前使用的重型部署工具 Ansible。然而实际使用时,TiFlash 节点在打 patch 过程中出现过不符合预期的报错。后来发现,TiUP 打 patch 的操作并没有停进程,而是直接 tar 命令解压覆盖部署目录,原子性保障也是无从谈起。v6.0 版本前 TiFlash 部分模块包含动态加载 共享库(aka 动态库)的行为,这类行为会被搜索路径下的库文件影响。如果存在某个时刻引入的模块相互不兼容,则直接影响程序的行为。

因为是用 tar 命令解压覆盖,反而没有出现老生常谈的 cp 覆盖动态库引起 Segmentation fault 的问题。

正好借此整理下相关的几个问题:

  • cp / tar 命令是如何工作的?
  • cp 覆盖正在被使用的动态库为何会引发异常?
  • 使用动态库要避开哪些坑?
  • 动态库有哪些额外的开销?

Linux 文件系统基础

相关基础参考 Modern Operating SystemsThe Ext2 Filesystemkernel.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
2
3
4
dumpe2fs -h /dev/${disk} | grep -E "Inode size|Block size"

Block size: 4096
Inode size: 256
1
2
3
4
df -hi /dev/${disk}

Filesystem Inodes IUsed IFree IUse% Mounted on
.... 233M 33M 201M 14% ....

由此推算出,该硬盘最多存储 953888 个 Inode。假设每个 Inode 对应一个 4K 大小的独立目录,总容量也不超过 3.65G。所以确实会出现因 Inode 耗尽而无法创建文件的极端情况。


TiFlash 存储模块也险些暴露出来类似问题

  • 早前版本中,TiFlash 的 schema sync 逻辑会把 TiDB 的每张物理表都在本地建立对应的文件夹以及 schema 相关的文件
  • 倘若表的数量过多,超过磁盘 Inode 承载的上限,则会导致系统不可用

Inode 在同个文件系统内保证唯一,在不同文件系统中互不依赖,存在 2 个不同磁盘上的文件 Inode 号码相同的情况。通过 Inode 号码直接删除 Inode 节点,等同于删除文件。当文件被删除后,Inode 会被系统回收再分配。

1
2
3
4
5
6
ls -i

134610994 anaconda-post.log 1320256620 dev 134611301 home 134611303 lib64 146932813 misc 148638932 opt 148768809 root 134611337 sbin 1 sys 148768954 usr
134610995 bin 148635658 etc 134611302 lib 134611304 media 134611305 mnt 1 proc 148768952 run 134611338 srv 148768953 tmp 148772892 var

find . -inum 134610994 -delete

Unix/Linux 系统中,目录(directory)也是一种文件:

  • 创建目录时,默认会生成两个目录项:...
  • 前者的 Inode 号码就是当前目录的 Inode 号码,等同于当前目录的 硬链接,也就使得新建空目录的链接数加 1 等于 2。
  • 后者的 Inode 号码就是当前目录的父目录的 Inode 号码,等同于父目录的硬链接,令父目录的 链接数 加 1。
  • 对于 ‘/‘ 目录,则两者都指向自己。
  • 系统目录的 Inode 为特定项(Inode Number: 1),一般根据挂载顺序 ‘/‘ 目录的 Inode Number 为 2。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
stat /

File: ‘/’
Size: 4096 Blocks: 8 IO Block: 4096 directory
Device: 803h/2051d Inode: 2 Links: 26
Access: (0555/dr-xr-xr-x) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2022-04-23 16:41:30.312343163 +0800
Modify: 2022-04-21 17:17:40.483912721 +0800
Change: 2022-04-21 17:17:40.483912721 +0800
Birth: -

ls -ai / | grep " 1 "

1 proc
1 sys

硬链接的目标和源共用同个 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 主要用到 openreadwrite

根据 linux/man-pages/unlinkunlink 用于按照文件名删除文件:

  • 如果文件无其他链接,且没有进程打开该文件,则删除文件并释放空间
  • 如果文件仅此一份链接,但存在进程仍然打开该文件,删除后文件依然存在,直到引用它的最后一个文件描述符关闭,系统才会回收

因此通过 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) 存储,可用 objdumpreadelf 工具导出相关信息:

  • .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
2
// v1.cpp
int foo(){return 1;}
1
2
3
// libtest.cpp
int foo();
int test(){return foo();}
1
2
// v2.cpp
int foo(){return 2;}
1
2
3
clang++ -c v1.cpp -o v1.o && ar -cr libv1.a v1.o
clang++ -c v2.cpp -o v2.o && ar -cr libv2.a v2.o
clang++ -fPIC libtest.cpp -L. -lv1 -shared -o libtest.so
1
2
3
4
5
// main.cpp
#include<stdio.h>
int foo();
int test();
int main(){printf("%d,%d\n",foo(),test());}

编译执行结果为

1
2
clang++ main.cpp -L./ -lv2 -ltest -rpath `pwd` -o main && ./main
2,2

在 libtest.so 内部存在已定义的函数 foo()test(),但实际上 test() 调用的是 v2 库中的 foo() 而不是 libtest.so 自身的,与直觉相悖。

交换库链接顺序 或者只链接 libtest.so,最终调用的才是 libtest.so 中的 foo() 。

1
2
3
4
clang++ main.cpp -L./  -ltest -lv2 -rpath `pwd` -o main && ./main
1,1
clang++ main.cpp -L./ -ltest -rpath `pwd` -o main && ./main
1,1

分析(1)

libtest.so 中对外导出函数 test()foo() ,test() 内调用了 foo(),所以需要对 foo() 进行装载时重定位。

1
2
3
4
5
6
7
8
9
10
nm -CD libtest.so

w __cxa_finalize
0000000000001698 T _fini
w __gmon_start__
000000000000167c T _init
w _ITM_deregisterTMCloneTable
w _ITM_registerTMCloneTable
0000000000001670 T foo()
0000000000001660 T test()

foo 外部函数入口偏移地址为 0x3910,在 .rela.plt 段的下标为 2,.got.plt 起始地址为 0x38e8,.got.plt 前 3 项为:.dymanic 段地址、本模块 ID、符合解析和重定位相关函数地址。验证得 0x38e8 + 8 * (3 + 2) = 0x3910。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
readelf -r libtest.so

Relocation section '.rela.dyn' at offset 0x430 contains 7 entries:
Offset Info Type Sym. Value Sym. Name + Addend
0000000026f0 000000000008 R_X86_64_RELATIVE 26f0
0000000026f8 000000000008 R_X86_64_RELATIVE 1610
000000002700 000000000008 R_X86_64_RELATIVE 1650
0000000028c8 000100000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
0000000028d0 000200000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTMClone + 0
0000000028d8 000300000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCloneTa + 0
0000000028e0 000400000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize@GLIBC_2.2.5 + 0

Relocation section '.rela.plt' at offset 0x4d8 contains 3 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000003900 000100000007 R_X86_64_JUMP_SLO 0000000000000000 __gmon_start__ + 0
000000003908 000400000007 R_X86_64_JUMP_SLO 0000000000000000 __cxa_finalize@GLIBC_2.2.5 + 0
000000003910 000800000007 R_X86_64_JUMP_SLO 0000000000001670 _Z3foov + 0
1
2
3
4
5
6
7
8
9
10
11
12
readelf -S libtest.so

...
[13] .plt PROGBITS 00000000000016b0 000006b0
0000000000000040 0000000000000000 AX 0 0 16
[18] .got PROGBITS 00000000000028c8 000008c8
0000000000000020 0000000000000000 WA 0 0 8
[19] .data PROGBITS 00000000000038e8 000008e8
0000000000000000 0000000000000000 WA 0 0 1
[21] .got.plt PROGBITS 00000000000038e8 000008e8
0000000000000030 0000000000000000 WA 0 0 8
...

魔改下 main.cpp 令其持续 sleep 不退出,运行时可以根据 pid 查看进程地址空间:

  • 00201000-00202000 段主要是源代码编译后的指令,可读可执行,不可写
  • 00200000-00201000 ,00202000-00203000 段为只读数据部分,前者主要是字符串常量,后者则主要是静态数据
  • 00203000-00204000 段为可写数据部分
  • libtest.so 对应的内存地址空间从 7f0fb3704000 开始,布局与上面类似
1
2
3
4
5
6
7
8
9
10
11
12
cat /proc/<pid-of-program>/maps

00200000-00201000 r--p 00000000 103:00 122162998 /.../main
00201000-00202000 r-xp 00000000 103:00 122162998 /.../main
00202000-00203000 r--p 00000000 103:00 122162998 /.../main
00203000-00204000 rw-p 00000000 103:00 122162998 /.../main
...
7f0fb3704000-7f0fb3705000 r--p 00000000 103:00 122163012 /.../libtest.so
7f0fb3705000-7f0fb3706000 r-xp 00000000 103:00 122163012 /.../libtest.so
7f0fb3706000-7f0fb3707000 r--p 00000000 103:00 122163012 /.../libtest.so
7f0fb3707000-7f0fb3708000 rw-p 00000000 103:00 122163012 /.../libtest.so
...

该案例使用动态库的方式为隐式加载(载入时加载)

  • 类似静态库链接的过程发生在程序加载时,动态链接器将所有相关动态库装载到进程地址空间,将程序中未定义的符号绑定到相应的动态链接库,进行重定位工作,即 装载时重定位(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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
objdump -d main

...
00000000002017c0 <main>:
2017c0: 55 push %rbp
2017c1: 48 89 e5 mov %rsp,%rbp
2017c4: 48 83 ec 10 sub $0x10,%rsp
2017c8: e8 33 00 00 00 callq 201800 <_Z3foov>
2017cd: 89 45 fc mov %eax,-0x4(%rbp)
2017d0: e8 0b 01 00 00 callq 2018e0 <_Z4testv@plt>
2017d5: 8b 75 fc mov -0x4(%rbp),%esi
2017d8: 89 c2 mov %eax,%edx
2017da: 48 bf b0 05 20 00 00 movabs $0x2005b0,%rdi
2017e1: 00 00 00
2017e4: b0 00 mov $0x0,%al
2017e6: e8 05 01 00 00 callq 2018f0 <printf@plt>
2017eb: 31 c0 xor %eax,%eax
2017ed: 48 83 c4 10 add $0x10,%rsp
2017f1: 5d pop %rbp
2017f2: c3 retq
...
0000000000201800 <_Z3foov>:
201800: 55 push %rbp
201801: 48 89 e5 mov %rsp,%rbp
201804: b8 02 00 00 00 mov $0x2,%eax
201809: 5d pop %rbp
20180a: c3 retq
...
00000000002018e0 <_Z4testv@plt>:
2018e0: ff 25 42 22 00 00 jmpq *0x2242(%rip) # 203b28 <_Z4testv@Base>
2018e6: 68 02 00 00 00 pushq $0x2
2018eb: e9 c0 ff ff ff jmpq 2018b0 <_fini+0x10>
  • 由于 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,等效于是间接跳转到下一行
    • 16e6: 压栈外部函数 foo() 在 .rela.plt 段中的下标 2
    • 16eb: 跳转到符号解析和重定位流程入口,从全局符号表中获取 foo() 的进程内地址 0x201800 填入地址 γ,最后调用 foo() 函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
objdump -d libtest.so

0000000000001660 <_Z4testv>:
1660: 55 push %rbp
1661: 48 89 e5 mov %rsp,%rbp
1664: e8 77 00 00 00 callq 16e0 <_Z3foov@plt>
1669: 5d pop %rbp
166a: c3 retq
...

0000000000001670 <_Z3foov>:
1670: 55 push %rbp
1671: 48 89 e5 mov %rsp,%rbp
1674: b8 01 00 00 00 mov $0x1,%eax
1679: 5d pop %rbp
167a: c3 retq
...

00000000000016e0 <_Z3foov@plt>:
16e0: ff 25 2a 22 00 00 jmpq *0x222a(%rip) # 3910 <_Z3foov@@Base+0x22a0>
16e6: 68 02 00 00 00 pushq $0x2
16eb: e9 c0 ff ff ff jmpq 16b0 <_fini+0x18>
...
  • 载入 libtest.so 符号表时,foo 函数符号无法注册到全局符号表
  • libtest.so 中 PLT 重定位时填入全局符号表中 foo 函数符号对应的地址,最终调用 v2 库的逻辑

交换库链接顺序或者只链接 libtest.so 时也是同样原理,main 程序中 foo 函数符号被编译器决议成为未定义(U类),加载 libtest.so 时注册到全局符号表。

1
2
3
4
5
6
7
8
9
10
11
nm -CD main

0000000000201880 T _fini
w __gmon_start__
0000000000201864 T _init
w _ITM_deregisterTMCloneTable
w _ITM_registerTMCloneTable
U __libc_start_main
U printf
U foo()
U test()
1
2
3
4
5
6
7
...
00000000002017b0 <main>:
...
2017b8: e8 03 01 00 00 callq 2018c0 <_Z3foov@plt>
...
2017c0: e8 0b 01 00 00 callq 2018d0 <_Z4testv@plt>
...

解决符号冲突

为避免符号冲突,可通过以下几种方式

显式加载(运行时加载)

1
2
3
4
5
6
7
8
9
10
11
12
// main2.cpp
#include<stdio.h>
#include<dlfcn.h>
#include<assert.h>
int foo();
int main()
{
void *handle = dlopen("./libtest.so",RTLD_LAZY); assert(handle);
int (*test)() = (int (*)())dlsym(handle,"_Z4testv"); assert(test);
printf("%d,%d\n", foo(), test());
dlclose(handle);
}

程序运行时,在逻辑中加载 libtest.so 获取 test 函数入口。编译时则不需要链接项 -ltest。

1
2
3
4
clang++ main2.cpp -L./ -lv2 -ldl -rpath `pwd` -o main2 && ./main2
2,1
clang++ main2.cpp -L./ -lv1 -ldl -rpath `pwd` -o main2 && ./main2
1,1

当进程中的模块是通过 dlopen() 载入的共享对象,dlsym() 查找符号的优先级分为 2 种:

  • dlopen() 参数 filename 为 nullptr, 则是从全局符号表查找,即 装载序列(Load Ordering)
  • dlopen() 指定共享对象时,则采用 依赖序列(Dependency Ordering) 的优先级:以被 dlopen() 打开的共享对象为节点,对其依赖对象进行广度优先遍历,直到找到符号。

符号隐藏

  • 隐藏 main 入口 foo() 函数
1
2
3
4
5
// main3.cpp
#include<iostream>
__attribute__ ((visibility ("hidden"))) int foo();
int test();
int main(){std::cout<<foo()<<","<<test()<<std::endl;}
1
2
clang++ main3.cpp -L./ -lv2 -ltest -rpath `pwd` -o main3 && ./main3
2,1
  • 隐藏共享库内 foo() 函数,推荐这种方式,尽可能对外隐藏无关符号
1
2
3
// libtest2.cpp
__attribute__ ((visibility ("hidden"))) int foo();
int test(){return foo();}
1
2
3
clang++ -fPIC libtest2.cpp -L. -lv1 -shared -o libtest2.so
clang++ main.cpp -L./ -lv2 -ltest2 -rpath `pwd` -o main && ./main
2,1

foo() 对外不可见;此时 test() 调用 foo() 为模块内调用,无需通过 PLT;

1
2
3
4
5
6
7
8
objdump -d libtest.so

...
0000000000001620 <_Z4testv>:
1620: 55 push %rbp
1621: 48 89 e5 mov %rsp,%rbp
1624: e8 07 00 00 00 callq 1630 <_Z3foov>
...

替换动态库

案例(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 文件完全一致:
    • 如果调用到依赖外部符号的逻辑,但此时外部符号并没有经过重新解析,直接使用 .got / .got.plt 段数据,则会引起访问非法地址 Segmentation fault
    • 如果调用的逻辑没有依赖外部符号
      • 如果逻辑依赖 .data / .bss 段数据(例如静态变量),则会因为数据被替换而引发逻辑异常
      • 如果逻辑没有其他依赖,则可正常运行

为什么系统会阻止 cp 覆盖可执行程序,而不阻止覆盖 so 文件?

  • 操作系统的 Demand Paging 机制下,加载程序时也同上文一样映射 VMA,有访存需求时才加载相关页。
  • 为防止正在运行中的程序镜像(并非文件本身)被意外修改,因此内核在启动程序后会锁定这个程序镜像的 Inode。
  • so 文件是靠 ld.so 加载的,属于用户态程序,没有权限锁定 Inode。

结合上述内容,替换动态库时,必须先执行系统调用 unlink* 删除目标。因此禁止直接使用 cp,可选 installrm + cp

动态库性能开销

分析(1) 中已基本描述了加载动态库和调用动态库内函数的流程。除此之外,动态库使用外部变量和全局变量时也有额外的寻址开销。

案例(3)

1
2
3
4
5
6
7
8
// libtest3.cpp
int k=123;
int koo(){return k++;}
int goo(){static int g=234;return g++;}
extern int p;
int poo(){return p++;}
static int f=666;
int foo(){return f++;}
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
nm -CD libtest3.so

w __cxa_finalize
00000000000017f4 T _fini
w __gmon_start__
00000000000017d8 T _init
w _ITM_deregisterTMCloneTable
w _ITM_registerTMCloneTable
0000000000003a38 D k
U p
00000000000017c0 T foo()
0000000000001780 T goo()
0000000000001760 T koo()
00000000000017a0 T poo()

objdump -d libtest3.so

...
0000000000001760 <_Z3koov>:
1760: 55 push %rbp
1761: 48 89 e5 mov %rsp,%rbp
1764: 48 8b 05 bd 12 00 00 mov 0x12bd(%rip),%rax # 2a28 <k@@Base-0x1010>
176b: 8b 00 mov (%rax),%eax
176d: 89 c2 mov %eax,%edx
176f: 83 c2 01 add $0x1,%edx
1772: 48 8b 0d af 12 00 00 mov 0x12af(%rip),%rcx # 2a28 <k@@Base-0x1010>
1779: 89 11 mov %edx,(%rcx)
177b: 5d pop %rbp
177c: c3 retq
177d: 0f 1f 00 nopl (%rax)

0000000000001780 <_Z3goov>:
1780: 55 push %rbp
1781: 48 89 e5 mov %rsp,%rbp
1784: 8b 05 b2 22 00 00 mov 0x22b2(%rip),%eax # 3a3c <_ZZ3goovE1g>
178a: 89 c1 mov %eax,%ecx
178c: 83 c1 01 add $0x1,%ecx
178f: 89 0d a7 22 00 00 mov %ecx,0x22a7(%rip) # 3a3c <_ZZ3goovE1g>
1795: 5d pop %rbp
1796: c3 retq
1797: 66 0f 1f 84 00 00 00 nopw 0x0(%rax,%rax,1)
179e: 00 00

00000000000017a0 <_Z3poov>:
17a0: 55 push %rbp
17a1: 48 89 e5 mov %rsp,%rbp
17a4: 48 8b 05 85 12 00 00 mov 0x1285(%rip),%rax # 2a30 <p@Base>
17ab: 8b 00 mov (%rax),%eax
17ad: 89 c2 mov %eax,%edx
17af: 83 c2 01 add $0x1,%edx
17b2: 48 8b 0d 77 12 00 00 mov 0x1277(%rip),%rcx # 2a30 <p@Base>
17b9: 89 11 mov %edx,(%rcx)
17bb: 5d pop %rbp
17bc: c3 retq
17bd: 0f 1f 00 nopl (%rax)

00000000000017c0 <_Z3foov>:
17c0: 55 push %rbp
17c1: 48 89 e5 mov %rsp,%rbp
17c4: 8b 05 76 22 00 00 mov 0x2276(%rip),%eax # 3a40 <_ZL1f>
17ca: 89 c1 mov %eax,%ecx
17cc: 83 c1 01 add $0x1,%ecx
17cf: 89 0d 6b 22 00 00 mov %ecx,0x226b(%rip) # 3a40 <_ZL1f>
17d5: 5d pop %rbp
17d6: c3 retq
...

通常来说动态库会尽量少使用外部变量和全局变量,以获得更好的隔离性,减少符号依赖风险,所以一般情况下讨论动态库的性能损耗着重于跨模块函数调用(额外的内存间接寻址,跨模块访存局部性变差)。但绝大多数实际应用层面,这些开销基本可以忽略。

共享库兼容性

案例(1)案例(2)案例(3) 中使用的均是 C++ 的 ABI (Application binary interface),由于其标准会随着编译器而改变,所以假如程序直接使用第三方的二进制库,务必保证双方的 ABI 标准一致。C 语言的 ABI 则相对稳定,加之大部分操作系统为其标准背书。实际生产环境应用中,跨语言 / 跨模块 交互的场景基本用的都是 C 式接口。

共享库动态链接|装载时搜索路径顺序:

  1. 编译目标代码时指定的共享库搜索路径(设置 rpath)
  2. 环境变量 LD_LIBRARY_PATH 指定共享库搜索路径
  3. 配置文件 /etc/ld.so.conf 中指定的共享库搜索路径
  4. 默认的共享库搜索路径:/lib/usr/lib/usr/local/lib

Linux 有一套共享库命名规范 libname.so.x.y.z

  • 前缀 lib,库名称,后缀 .so,3 个版本号
  • x 表示主版本号(Major),y 表示次版本号(Minor),z 表示发布版本号(Release)
    • 主版本号不同的库不保证兼容
    • 次版本号表示库增量升级,只新增符号
    • 发布版本号表示内部修改,不改变符号

案例(4)

1
2
3
4
5
6
7
8
// main4.cpp
#include<stdio.h>
extern "C" {
int foo();
}
int main() {
printf("version %d\n", foo());
}
1
2
// libtest4.1.1.1.cpp
extern "C" int foo() {return 10101;}

指定共享库的 SO-NAME 为 libtest4.so.1(仅保留主版本号),该软链接保持指向目录中相同主版本,次版本和发布版本最新的共享库。当进行共享库兼容式升级时,只需修改该软链接。程序打包发布时,则需打包该软链接以及实际指向的共享库文件。

1
2
3
4
5
6
7
8
9
10
11
12
clang++ -fPIC libtest4.1.1.1.cpp -shared -Wl,-soname,libtest4.so.1 -o libtest4.so.1.1.1
ln -sf libtest4.so.1.1.1 libtest4.so.1
ln -sf libtest4.so.1 libtest4.so

readelf -d libtest4.so.1.1.1 | grep 'SONAME'
0x000000000000000e (SONAME) Library soname: [libtest4.so.1]

clang++ main4.cpp -L`pwd` -rpath `pwd` -ltest4 -o main4 && ./main4
version 10101

ldd main4 | grep test4
libtest4.so.1 (0x00007f59c9009000)

案例(5)

1
2
// libtest5.cpp
extern "C" int foo(){return 1234;}
1
2
// libtest5.2.cpp
extern "C" int foo(){return 8888;}
1
2
// libtest5_expect.cpp
extern "C" int go(){return 1234;}
1
2
// libtest5_expect.2.cpp
extern "C" int go(){return 8888;}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// main5.cpp
#include<stdio.h>
#include<dlfcn.h>
#include<assert.h>
#include <unistd.h>

int load_run(const char * lib_name, const char * func_name, int * res) {
void * handle = dlopen(lib_name, RTLD_LAZY);
if (handle) {
int (*foo)() = (int (*)())dlsym(handle, func_name);
assert(foo);
*res = foo();
int r = dlclose(handle);
assert(!r);
return 0;
}
return -1;
}
int main()
{
while(1) {
int expect_val = 0;
int res = 0;
assert(!load_run("./libtest5.so", "foo", &res));
assert(!load_run("./libtest5_expect.so", "go", &expect_val));
if(res != expect_val) {
printf("invalid result, expect %d got %d\n", expect_val, res);
} else {
printf("result equal %d\n", res);
}
sleep(2);
}
return 0;
}
1
2
3
4
5
clang++ -fPIC libtest5.2.cpp -shared -o libtest5.2.so
clang++ -fPIC libtest5.cpp -shared -o libtest5.so
clang++ -fPIC libtest5_expect.cpp -shared -o libtest5_expect.so
clang++ -fPIC libtest5_expect.2.cpp -shared -o libtest5_expect.2.so
clang++ -L`pwd` -o main5 -ldl main5.cpp

在 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
2
3
4
5
6
7
8
9
10
11
12
13
ldd ./tiflash

linux-vdso.so.1 (0x00007ffc575f5000)
libc++.so.1 => /root/test/tiflash/./libc++.so.1 (0x00007f109618c000)
libc++abi.so.1 => /root/test/tiflash/./libc++abi.so.1 (0x00007f1096148000)
libtiflash_proxy.so => /root/test/tiflash/./libtiflash_proxy.so (0x00007f1093343000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f109333c000)
librt.so.1 => /lib/x86_64-linux-gnu/librt.so.1 (0x00007f1093337000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f1093253000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f109324c000)
libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f1093232000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f109300a000)
/lib64/ld-linux-x86-64.so.2 (0x00007f109625d000)

libtiflash_proxy.so 是一个 Rust 语言编写的动态库:tidb-engine-ext,通过工具查看其导出的符号为:

1
2
3
4
5
0000000000aadda0 T bz_internal_error
0000000000ee9e30 T perf_signal_handler
00000000010b5200 T print_raftstore_proxy_version
00000000010b5210 T run_raftstore_proxy_ffi
0000000000e96c00 T rust_eh_personality

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
2
3
4
5
6
7
8
9
10
11
12
readelf -a --wide ./tiflash | grep 'GLIBC_PRIVATE'

00000726a048 026c00000406 R_AARCH64_TLS_TPR 0000000000000000 errno@GLIBC_PRIVATE + 0
0000072827b8 026b00000402 R_AARCH64_JUMP_SL 0000000000000000 __pthread_get_minstack@GLIBC_PRIVATE + 0
0000072827c0 026d00000402 R_AARCH64_JUMP_SL 0000000000000000 __gai_sigqueue@GLIBC_PRIVATE + 0
619: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __pthread_get_minstack@GLIBC_PRIVATE (9)
620: 0000000000000000 0 TLS GLOBAL DEFAULT UND errno@GLIBC_PRIVATE (10)
621: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __gai_sigqueue@GLIBC_PRIVATE (10)
268: 2 (GLIBC_2.17) 4 (GLIBC_2.17) 4 (GLIBC_2.17) 9 (GLIBC_PRIVATE)
26c: a (GLIBC_PRIVATE) a (GLIBC_PRIVATE) 2 (GLIBC_2.17) 4 (GLIBC_2.17)
0x0080: Name: GLIBC_PRIVATE Flags: none Version: 9
0x00d0: Name: GLIBC_PRIVATE Flags: none Version: 10

符号是如何被引入的?

__gai_sigqueue 为例,进到 编译目录|源码目录 下,对所有文件(包括二进制)检索关键字 __gai_sigqueue,结果显示只有编译终产物 tiflash 二进制文件包含该信息,可以看出符号不是本地代码引入的。

1
2
3
4
cd ${build_dir}
grep '__gai_sigqueue' -r .

Binary file dbms/src/Server/tiflash matches

查看 TiFlash 编译流程最后的链接命令,参数中包含 2 个外部静态库 /usr/lib64/librt.a/usr/lib64/libanl.a

分别导出 重定位表 可知是 /usr/lib64/libanl.agai_notify.o 引用了 __gai_sigqueue,对应 2 个 重定位入口(Relocation Entry):OFFSET 0000000000000081,OFFSET 00000000000001c2。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
objdump -r /usr/lib64/libanl.a

gai_notify.o: file format elf64-x86-64

RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
...
0000000000000081 R_X86_64_PC32 __gai_sigqueue-0x0000000000000004
...
00000000000001c2 R_X86_64_PC32 __gai_sigqueue-0x0000000000000004
...

objdump -r /usr/lib64/librt.a | grep '__gai_sigqueue'

反汇编 /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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
objdump -d /usr/lib64/libanl.a

gai_notify.o: file format elf64-x86-64
...
Disassembly of section .text:
...
0000000000000050 <__gai_notify_only>:
...
80: e8 00 00 00 00 callq 85 <__gai_notify_only+0x35>
85: 48 83 c4 50 add $0x50,%rsp
89: c1 f8 1f sar $0x1f,%eax
8c: 5b pop %rbx
...
0000000000000120 <__gai_notify>:
...
1bd: 49 8b 34 24 mov (%r12),%rsi
1c1: e8 00 00 00 00 callq 1c6 <__gai_notify+0xa6>
1c6: 48 8b 7b 08 mov 0x8(%rbx),%rdi
1ca: eb b0 jmp 17c <__gai_notify+0x5c>
...

排查函数调用关系可以得出符号被引入 TiFlash 的过程:

  • libc.so:实现并对外导出 __gai_sigqueue() 函数,对应版本 GLIBC_PRIVATE
  • libanl.a:
    • gai_notify.o__gai_notify()__gai_notify_only() 调用了 __gai_sigqueue()
    • getaddrinfo_a.ogetaddrinfo_a() 调用了 __gai_notify_only()
    • gai_misc.ohandle_requests() 调用了 __gai_notify()
  • TiFlash 代码中的 poco 模块调用了 getaddrinfo_a() 函数:pingcap/poco/Net/src/DNS.cpp#L167
  • libanl.a 和 libc.so 被先后链接,getaddrinfo_a 被决议成本地符号,__gai_sigqueue 被决议成外部符号
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
nm -CD /lib64/libc.so.6

...
0000000000111a90 T __gai_sigqueue
...

nm /lib64/libanl.a

...
gai_notify.o:
U free
0000000000000120 T __gai_notify
0000000000000050 T __gai_notify_only
U __gai_sigqueue
U malloc
U pthread_attr_init
U pthread_attr_setdetachstate
U pthread_create
U sigemptyset
0000000000000000 W _.stapsdt.base
...
getaddrinfo_a.o:
U errno
U __gai_enqueue_request
U __gai_notify_only
U __gai_requests_mutex
0000000000000000 T getaddrinfo_a
U getpid
U _GLOBAL_OFFSET_TABLE_
U malloc
U pthread_mutex_lock
U pthread_mutex_unlock
w pthread_setcancelstate
...
gai_misc.o:
...
0000000000000000 t handle_requests
...

nm -Cg ./tiflash

...
0000000007fa4330 T getaddrinfo_a
...

如何解决上述外部符号问题?

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
2
3
4
.symver __foo_old, foo@VER1
.symver __foo_new, foo@@VER2
.symver __bar_old, bar@@VER1
....
  • 例如下面的例子,通过编译参数强制将 realpath() 指定为 GLIBC_2.2.5 版本。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// main6.cpp
#include <limits.h>
#include <stdlib.h>
#include <stdio.h>

#ifdef REALPATH_HACK
__asm__(".symver realpath,realpath@GLIBC_2.2.5");
#endif
int main()
{
const char* unresolved = "/lib64";
char resolved[PATH_MAX+1];

if(!realpath(unresolved, resolved))
{ return 1; }

printf("%s\n", resolved);

return 0;
}
1
2
3
4
5
6
7
8
9
10
11
clang++ main6.cpp -o main6 && readelf -a  main6 | grep 'realpath'

000000203ae8 000400000007 R_X86_64_JUMP_SLO 0000000000000000 realpath@GLIBC_2.3 + 0
4: 0000000000000000 0 FUNC GLOBAL DEFAULT UND realpath@GLIBC_2.3 (3)
32: 0000000000000000 0 FUNC GLOBAL DEFAULT UND realpath

clang++ main6.cpp -DREALPATH_HACK -o main6 && readelf -a main6 | grep 'realpath'

000000203ac8 000500000007 R_X86_64_JUMP_SLO 0000000000000000 realpath@GLIBC_2.2.5 + 0
5: 0000000000000000 0 FUNC GLOBAL DEFAULT UND realpath@GLIBC_2.2.5 (2)
33: 0000000000000000 0 FUNC GLOBAL DEFAULT UND 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_panicrust_panicgrpc_set_ssl_roots_override_callback 等,与 libsymbolization.alibgrpc.a 这几个本地编译的静态库相冲突。虽然理论上有诸多办法可以解决(参考 静态库冲突问题)但相对会增加心智负担。

使用动态库则相对简单,仅需把交互相关的接口定义好导出即可(同时也要避免符号污染)。对于开发者而言,对于不涉及接口格式的模块内改动,则不需要静态库那样全量编译。

根据上文的介绍,动态库装载时会进行符号绑定,所以在使用时有几点需要注意:

  • 与 TiKV 不同,tidb-engine-ext 不指定内存分配器(即令 mallocfree 之类的内存管理接口被编译器决议成未定义符号),此举是为了保证同宿主进程兼容。例如 TiFlash 中使用了自定义的内存分配器 jemalloc,malloc|free 之类的函数被决议成内部重载的版本,参照上文 分析(1) 的动态库装载过程,libtiflash_proxy.so 最终使用的是 TiFlash 主进程提供的内存分配器。如果 TiFlash 也不指定内存分配器,则最终使用的是 Glibc 相关库中的默认版本。
  • 使用 tidb-engine-ext 时以下几个符号需要注意,不要有本地同名符号,以免载入动态库时这些函数被本地实现覆盖:bz_internal_errorbzip2-sys 引入;perf_signal_handlerpprof-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,将其整个纳入到输出目标中(也包括其他可能对该程序无用的函数),该目标文件还引用了外部符号 stdoutvfprintf,则这些符号又会继续被处理,直到决议和重定位完所有符号。

  • 理论上按照层次化|模块化存储和组织源代码有很多好处,比如代码更容易理解、重用,每个模块可以单独开发、编译、测试,改变部分代码不需要编译整个程序等。

静态库冲突问题

分析(7) 中提到了 TiFlash 静态链接 libraftstore_proxy.a 时遇到符号冲突的问题。本章节介绍几种解决静态库符号冲突的方法。

案例(8)

1
2
3
// test7_1.cpp
int foo(){return 1;}
int goo(){return 11;}
1
2
3
// test7_2.cpp
int foo(){return 2;}
int koo(){return 22;}
1
2
// test7_3.cpp
int foo(){return 3;}
1
2
3
4
5
6
7
8
// main7.cpp
#include <stdio.h>
int foo();
int koo();
int goo();
int main() {
printf("%d %d %d\n", foo(), koo(), goo());
}

3 个静态库内同时实现了 foo 函数和其他自定义函数,main7 编译链接 libtest7_2.alibtest7_3.alibtest7_1.a,最后报错 duplicate symbol: foo()

  • 为什么会产生 duplicate symbol
  • libtest7_3.a 中也实现了 foo 函数,为什么没有出现在报错信息里?
1
2
3
4
5
6
7
8
9
10
11
clang++ -c test7_1.cpp -o test7_1.o && ar -cr libtest7_1.a test7_1.o
clang++ -c test7_2.cpp -o test7_2.o && ar -cr libtest7_2.a test7_2.o
clang++ -c test7_3.cpp -o test7_3.o && ar -cr libtest7_3.a test7_3.o
clang++ main7.cpp -L./ -ltest7_2 -ltest7_3 -ltest7_1 -o main7 -static --verbose && ./main7

ld.lld: error: duplicate symbol: foo()
>>> defined at test7_2.cpp
>>> test7_2.o:(foo()) in archive ./libtest7_2.a
>>> defined at test7_1.cpp
>>> test7_1.o:(.text+0x0) in archive ./libtest7_1.a
clang-13: error: linker command failed with exit code 1 (use -v to see invocation)

分析(8)

链接过程详解:

  • 每个模块都是独立编译的,编译器在编译 main7.cpp 时并不知道其引用的几个函数(包括 printf)的地址,所以生成的目标文件(此处为临时文件,例如 /tmp/main7-6aa576.o)的符号表中包含未定义类型的符号 _Z3foov_Z3koov_Z3goovprintf
  • 根据链接参数的顺序,首先被载入的是 /tmp/main7-6aa576.o,链接器其纳入输出目标,_Z3foov_Z3koov_Z3goovprintf 则是待决议。
  • 链接器从 libtest7_2.atest7_2.o 模块的符号表中找到待决议的符号 _Z3foov_Z3koov,纳入该目标文件并完成重定位。还剩下 _Z3goovprintf
  • 链接器从 libtest7_3.a 各个模块的符号表中找不到待决议的符号,则直接跳过。
  • 链接器从 libtest7_1.atest7_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
2
3
// test7_4.cpp
int foo();
int soo(){return foo();}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
clang++ -c test7_4.cpp -o test7_4.o
ld.lld -r test7_1.o test7_4.o -o test7_1_4.o
nm test7_1_4.o

0000000000000000 T _Z3foov
0000000000000010 T _Z3goov
0000000000000020 T _Z3soov

llvm-objcopy --localize-symbol="_Z3foov" ./test7_1_4.o
nm test7_1_4.o

0000000000000000 t _Z3foov
0000000000000010 T _Z3goov
0000000000000020 T _Z3soov

ar -cr libtest7_1_4.a test7_1_4.o
clang++ main7.cpp -L./ -ltest7_2 -ltest7_1_4 -o main7 -static --verbose

TODO

未完待续