编辑
2025-05-30
记录知识
0

目录

什么是动态链接
动态链接文件
动态链接的作用
PSS
COW
PLT
GOTPLT
延迟绑定
找到xxx@plt
RELA回填
计算kylin函数符号地址
libkylin.so的kylin符号偏移
计算实际加载函数地址
回填kylin@got.plt
总结
参考资料

不是从事操作系统相关的研发人员,我发现基本上对动态链接等基础概念非常薄弱,哪怕有了非常丰富的代码编写经验,也说不清楚动态链接这回事。
本人基于工作环境中遇到的以及和其他研发人员沟通下来的情况,个人认为有必要将自己关于对动态链接的理解记录成文字,用作巩固和说明。

巩固是对于自己,而说明是对于其他不清楚动态链接本身的研发。

什么是动态链接

动态链接文件

在linux中,动态链接是通过so为结尾的文件名字。我们知道linux的文件属性不是依赖于名字后缀,所以实际上动态链接文件是来源于ELF文件的文件类型定义,在linux中,动态链接库文件是DYN的属性文件,如下示例

root@kylin:~# readelf -h /usr/lib/aarch64-linux-gnu/libc.so.6 ELF 头: Magic: 7f 45 4c 46 02 01 01 03 00 00 00 00 00 00 00 00 类别: ELF64 数据: 2 补码,小端序 (little endian) Version: 1 (current) OS/ABI: UNIX - GNU ABI 版本: 0 类型: DYN (共享目标文件) 系统架构: AArch64 版本: 0x1 入口点地址: 0x20ee8 程序头起点: 64 (bytes into file) Start of section headers: 1442224 (bytes into file) 标志: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 10 Size of section headers: 64 (bytes) Number of section headers: 69 Section header string table index: 68

动态链接的作用

动态链接的作用是让一个动态库真正的实现共享,其根本的作用是减少操作系统中的内存占用,如果所有的程序在运行的时候都链接libc.so,如果使用静态链接,那么如果有N个应用程序,那么就有N个libc.so的重复引用,也就是N个libc.so的内存占用。

而对于动态链接而言,如果N个应用程序都依赖libc.so,我们假设为libc.so分配了4k物理页面,如果N个程序都动态链接libc.so,那么最大使用物理内存大小就是一份libc.so的大小4k。

对于操作系统,其在运行的过程中,动态链接会将每个程序对libc内成员访问的地址进行重定位,最终指向同一个物理内存区域。

根据上面的信息,我们可以看到,通过动态链接,只要操作系统在每个应用程序运行的时候对地址重定位,那么就可以让so在操作系统内存中只存在一份。大大节省了操作系统的内存空间

PSS

操作系统内存PSS指的是按照比例计算内存的占用情况,这里最为突出的就是动态链接的so文件,对于一个进程如果依赖libkylin.so,假设libkylin.so占用4k内存,那么如果有两个程序依赖libkylin.so,那么每个程序计算值占用2k页面,如果四个程序依赖libkylin.so,那么每个程序计算值占用1k页面。

这也意味着我们在查看系统物理内存的时候,PSS可以作为一个参考值统计。下面做个简单的实验
假设我们有程序kylin.c,如下

#include <stdio.h> void kylin() { printf("hello kylin\n"); }

再编写一个简单的mian.c使用它

#include <unistd.h> void kylin(); int main() { while(1){ sleep(2); kylin(); }; }

然后编译运行,此时如果我们运行第一个实例,那么我们可以查看libkylin.so的Pss如下

root@kylin:~# for pid in $(pidof main) ; do cat /proc/${pid}/smaps | grep libkylin.so -A 6 | grep r-x -A 6 | grep Pss; done Pss: 4 kB

如果运行两个实例,那么Pss如下

root@kylin:~# for pid in $(pidof main) ; do cat /proc/${pid}/smaps | grep libkylin.so -A 6 | grep r-x -A 6 | grep Pss; done Pss: 2 kB Pss: 2 kB

如果运行四个实例,那么Pss如下

root@kylin:~# for pid in $(pidof main) ; do cat /proc/${pid}/smaps | grep libkylin.so -A 6 | grep r-x -A 6 | grep Pss; done Pss: 1 kB Pss: 1 kB Pss: 1 kB Pss: 1 kB

COW

根据上面的信息,我们知道PSS是用来反应每个进程占用动态库的实际物理内存大小的值,实际上这只是一个统计值,而不是真实值。在实际内存使用情况下,应该如下

  • 第一次映射动态库到进程中,内核触发缺页异常,分配内存地址
  • 动态库的代码段只读
  • 内核对于内存的策略是写时复制(COW)
  • 因COW机制,后续不会再分配物理页

根据上面的情况,我们知道动态库的代码段因为只读,只会在第一次映射,当映射完成之后,永远不会触发COW,也就是不会再额外分配内存。所以保证了动态库只存在于操作系统中一份内容。而不是多份内存。

PLT

我们从内存COW机制可以知道动态库只存在于一份内存,但是每个进程使用的时候动态链接到的函数地址是不固定的(根据加载情况决定动态库的加载地址),而程序运行的时候,我们期望其调用地址是固定的。

面对这样的问题,需要新加一层中间层,其在主程序和动态库函数地址之间。

也就是说代码假设调用kylin函数,kylin函数可能地址不固定,为了解决这个问题,代码先调用一个固定的kylin@plt函数,然后由kylin@plt函数自己去dl_fixup一下kylin函数的地址。那么PLT就应运而生了。

Procedure linkage Table(PLT)过程链接表是为了服务动态链接而产生的表,其主要作用是代码段的函数,那么怎么将每个进程的函数调用(固定地址的)都转换成非固定的虚拟地址调用呢?其步骤如下:

  1. 调用函数kylin
  2. 首先找到kylin@plt
  3. 然后在kylin@plt中再跳转到.plt
  4. 找到真实函数kylin

根据上面的步骤,通过PLT的机制保证了每个进程对库的调用是在同一个位置上(kylin@plt),再由(kylin@plt)去找真实的kylin函数地址

也就是说,只要对于kylin@plt的代码片段是固定的。那么kylin如果不固定就没有关系了,只要kylin@plt能够最终跳转到kylin函数即可。

GOTPLT

根据上面描述的,代码先找到kylin@plt,然后再跳转.plt中找到kylin函数

可是上面又会带来一个问题,kylin@plt的代码片段是固定的,.plt的代码片段也是固定的,那怎么保证找到的是kylin呢。

这里服务于plt的got出现了,我们知道程序默认存在一个got段,用作存放全局变量,然而这里为了动态链接过程PLT,需要为其新增一个got.plt段,这个段的含义就是

当代码从kylin@plt中运行的时候,默认寻找got.plt[3]存放的执行地址,此时got.plt[3]存放了.plt的代码地址,它负责跳转到dl的跳板函数,最终运行到__dl_runtime_resolve,此时当代码运行完毕,__dl_runtime_resolve会修改got.plt[3]存放的执行地址为kylin的真实地址后直接运行got.plt[3]

关于got.plt描述如下:

  1. got.plt[0]保留
  2. got.plt[1]作为参数,实际上是结构体指针
  3. got.plt[2]作为函数跳板运行__dl_runtime_resolve函数
  4. got.plt[3]第一次寻找到.plt,第二次寻找到kylin函数。

got.plt[3]正确找到kylin函数之后,每次运行的时候就直接通过kylin@plt函数,在kylin@plt中直接跳转kylin函数。

  • 补充:got.plt[2+N]这里的N对应所有函数的实际地址,未重定位前是.plt的代码地址

后面每个PLT的跳转,都是通过got.plt[2+N]这里n是偏移量,来实现的。gdb可以看到信息如下

(gdb) x/8xg 0x410fe8 0x410fe8: 0x0000000000000000 0x0000007ff7fff210 0x410ff8: 0x0000007ff7fe02c0 0x0000007ff7f815c4 0x411008: 0x0000007ff7e10ca8 0x0000007ff7e94398 0x411018: 0x00000000004004e0 0x00000000004004e0

假设我们的函数是kylin,那么其在kylin@plt中跳转的是got.plt[2+1],其值是0x0000007ff7f815c4,这里就是kylin的地址

(gdb) x/xg 0x410fe8+24 0x411000 <kylin@got.plt>: 0x0000007ff7f815c4

延迟绑定

根据上面提到的,其实已经把延迟绑定介绍出来了,这里在aarch64体系平台上整理复述一下步骤如下:

  1. 第一次访问kylin函数
  2. 找到kylin@plt运行
  3. kylin@plt会跳转到.plt
  4. .plt会跳转到跳板函数,最终运行__dl_runtime_resolve
  5. __dl_runtime_resolvedl_fixup实际上就是回填got.plt[3]kylin的函数地址
  6. 运行got.plt[3]存放的函数指针

这样我们将got.plt[3]从最开始保存.plt转而保存kylin,下一次调用的时候,步骤如下:

  1. 访问kylin函数
  2. 找到kylin@plt运行
  3. 运行got.plt[3]存放的函数指针

对于上面的整个过程,我们就把他叫做延迟绑定(Lazy binding)

延迟绑定就是解决动态链接过程中出现的地址不固定问题,其根本解决思路就是新加一个抽象层,让抽象层只解决地址不固定的问题。

找到xxx@plt

这里我们肯定有一个问题就是,为什么一开始就调用xxx@plt

这里原因其实就是链接的时候解析符号后新建的,当所有的.o通过ld进行链接成可执行文件的时候,默认会先通过.plt模板生成.plt的代码片段,将其放在.plt的section中,然后其他的xxx@plt,会在.plt后继续追加。

其注意动作是解析了函数名字后,按照模板新建plt条目,然后在first_plt_entry也就是.plt代码片段后面排布,也就是说实际上xxx@plt就是在.plt段中。可以如下查看

# objdump -d main Disassembly of section .plt: 00000000004004e0 <.plt>: 4004e0: a9bf7bf0 stp x16, x30, [sp, #-16]! 4004e4: 90000090 adrp x16, 410000 <__FRAME_END__+0xf7b8> 4004e8: f947fe11 ldr x17, [x16, #4088] 4004ec: 913fe210 add x16, x16, #0xff8 4004f0: d61f0220 br x17 4004f4: d503201f nop 4004f8: d503201f nop 4004fc: d503201f nop 0000000000400500 <kylin@plt>: 400500: b0000090 adrp x16, 411000 <kylin> 400504: f9400211 ldr x17, [x16] 400508: 91000210 add x16, x16, #0x0 40050c: d61f0220 br x17 0000000000400510 <__libc_start_main@plt>: 400510: b0000090 adrp x16, 411000 <kylin> 400514: f9400611 ldr x17, [x16, #8] 400518: 91002210 add x16, x16, #0x8 40051c: d61f0220 br x17 0000000000400520 <sleep@plt>: 400520: b0000090 adrp x16, 411000 <kylin> 400524: f9400a11 ldr x17, [x16, #16] 400528: 91004210 add x16, x16, #0x10 40052c: d61f0220 br x17 0000000000400530 <__gmon_start__@plt>: 400530: b0000090 adrp x16, 411000 <kylin> 400534: f9400e11 ldr x17, [x16, #24] 400538: 91006210 add x16, x16, #0x18 40053c: d61f0220 br x17 0000000000400540 <abort@plt>: 400540: b0000090 adrp x16, 411000 <kylin> 400544: f9401211 ldr x17, [x16, #32] 400548: 91008210 add x16, x16, #0x20 40054c: d61f0220 br x17

我们可以通过如下查看.plt的节大小,如下

# objdump -j .plt -x main Idx Name Size VMA LMA File off Algn 11 .plt 00000070 00000000004004e0 00000000004004e0 000004e0 2**4 CONTENTS, ALLOC, LOAD, READONLY, CODE

可以看到Size正好是0x70=0x400550-0x4004e0。回到GDB也可以验证此问题如下,注意这里28=0x70/4

(gdb) x/28x 0x4004e0 0x4004e0: 0xa9bf7bf0 0x90000090 0xf947fe11 0x913fe210 0x4004f0: 0xd61f0220 0xd503201f 0xd503201f 0xd503201f 0x400500 <kylin@plt>: 0xb0000090 0xf9400211 0x91000210 0xd61f0220 0x400510 <__libc_start_main@plt>: 0xb0000090 0xf9400611 0x91002210 0xd61f0220 0x400520 <sleep@plt>: 0xb0000090 0xf9400a11 0x91004210 0xd61f0220 0x400530 <__gmon_start__@plt>: 0xb0000090 0xf9400e11 0x91006210 0xd61f0220 0x400540 <abort@plt>: 0xb0000090 0xf9401211 0x91008210 0xd61f0220

RELA回填

上面基本上把动态链接的过程描述清楚了,这里会还会存在一个问题:

  • 如何计算函数kylin的地址回填got.plt呢?

首先我们要计算出来kylin的函数地址,其公式如下

  • 从dynsym地址获取到st_name
  • 将dynstr地址+st_name 求得函数符号

然后我们要根据函数符号找到在动态库的偏移,其公式如下

  • 在动态库的.dynsym上找到符号kylin对应的函数偏移

最后根据动态库的加载地址计算出实际函数地址,其公式如下

  • 将kylin对应加载的偏移信息加上libkylin动态库加载地址,回填到got.plt表上

下面逐个演示。

计算kylin函数符号地址

首先拿到.dynstr、.dynsym、.rel.plt三个加载地址

# readelf -S main [ 5] .dynsym DYNSYM 00000000004002b8 000002b8 00000000000000c0 0000000000000018 A 6 1 8 [ 6] .dynstr STRTAB 0000000000400378 00000378 000000000000008b 0000000000000000 A 0 0 1 [10] .rela.plt RELA 0000000000400450 00000450 0000000000000078 0000000000000018 AI 5 22 8

可以知道信息如下

.dynstr 0000000000400378 .dynsym 00000000004002b8 .rela.plt 0000000000400450

我们先要找到st_name的偏移值,readelf会帮我们解析,如下

# readelf -s main Symbol table '.dynsym' contains 8 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterTMCloneTab 2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND kylin 3: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.17 (2) 4: 0000000000000000 0 FUNC GLOBAL DEFAULT UND sleep@GLIBC_2.17 (2) 5: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__ 6: 0000000000000000 0 FUNC GLOBAL DEFAULT UND abort@GLIBC_2.17 (2) 7: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMCloneTable

此时留意Num列,找到kylin的Num是2,那么可以知道其index是2.
同样的,根据.dynsym的size是0x18,可以知道kylin函数的st_name偏移的值是

st_name = struct_size * Num 0x30 = 0x18 * 2

然后我们拿这个st_name加上dynstr的首地址,如下

0000000000400378 + 82 = 0x4003ca

此时我们gdb验证一下此地址是否就是kylin的符号地址

(gdb) x/6c 0x4003ca 0x4003ca: 107 'k' 121 'y' 108 'l' 105 'i' 110 'n' 0 '\000'

到这里我们找到了kylin符号存放的地方

libkylin.so的kylin符号偏移

这里同样可以借助readelf直接获取答案

# readelf -s libkylin.so Symbol table '.dynsym' contains 9 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000488 0 SECTION LOCAL DEFAULT 9 2: 0000000000011018 0 SECTION LOCAL DEFAULT 21 3: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterTMCloneTab 4: 0000000000000000 0 FUNC WEAK DEFAULT UND __cxa_finalize@GLIBC_2.17 (2) 5: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__ 6: 0000000000000000 0 FUNC GLOBAL DEFAULT UND puts@GLIBC_2.17 (2) 7: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMCloneTable 8: 00000000000005c4 32 FUNC GLOBAL DEFAULT 11 kylin

这里给解析了Value,实际上就是so的偏移地址0x5c4

计算实际加载函数地址

对于so的加载地址,我们要运行过程中查看,可以查看/proc/pid/maps如下

root@kylin:~/dso# cat /proc/$(pidof main)/maps | grep libkylin.so | grep "r-xp" 7ff7f81000-7ff7f82000 r-xp 00000000 b3:05 131921 /root/dso/libkylin.so

可以看到动态库加载的地址是0x7ff7f81000,那么我们加上kylin的偏移

0x7ff7f815c4 = 0x7ff7f81000 + 0x5c4

此时我们gdb验证一下

(gdb) x 0x0000007ff7f815c4 0x7ff7f815c4 <kylin>: 0x910003fda9bf7bfd

到这里,我们拿到了kylin函数的实际加载地址,但是还没有开始回填

回填kylin@got.plt

上面计算出来了kylin函数的加载地址是0x0000007ff7f815c4,最终其实要回填到got.plt上,为了实现这一点,提供了rela.plt的section,它一一对应了plt的section,最终填写的地方是got.plt的section地址上。

根据.rela.plt的上面信息,我们知道其size是0x18,总size是0x78。也就是

0x78 = 0x18 * 5

这里的5就是.plt中函数xxx@plt的个数(objdump -d main 可以看.plt的section共5个)我们获取其r_offset即可,对于kylin函数,它是第一个plt,那么如下

(gdb) x/3xg 0x0000000000400450 0x400450: 0x0000000000411000 0x0000000200000402 0x400460: 0x0000000000000000

我们找结构体第一个成员,值为0x0000000000411000。这里存放了kylin@got.plt的地址,也就是got.plt[3]

可以看到,地址回填就是借助了rela.plt结构成员,里面的indexplt对应,内容是got.plt的地址。回填就是回填到r_offset上。 也就是0x411000地址上。

(gdb) x/xg 0x411000 0x411000 <kylin@got.plt>: 0x0000007ff7f815c4

至此,整个动态链接过程就讲完了。

总结

根据整个文章关于动态链接的解读,可以了解到动态链接与如下技术点相关

  • COW:保证so只有一份内存
  • PLT:让加载不固定的地址固定
  • GOTPLT:存放真实函数加载地址
  • RELAPLT:动态链接时__dl_runtime_resolve通过其找到GOTPLT地址,用于回填真实虚拟地址

最后,关于整个流程,下面再以更通俗的话总结一下:

为了节省内存占用,通过延迟绑定的技术,第一次调用时先让函数调用时跳进同名的xxx@plt函数,然后通过.plt的固定代码片段,找到函数的加载地址,然后通过.rela.plt的信息回填到xxx@got.plt上。等第二次调用时,就直接从xxx@plt函数直接跳转到xxx@got.plt描述的真实函数地址上。

参考资料

《程序员的自我修养》