hello 大家好,我是升哥。
最近在读《程序员的自我修养》,把重要主题笔记分享给大家。
这次聊聊动态链接。
要解决静态链接空间浪费和更新困难这两个问题最简单的办法就是把程序的模块相互分割开来,形成独立的文件,而不再将它们静态地链接在一起。
简单地讲,就是不对那些组成程序的目标文件进行链接,等到程序要运行时才进行链接。也就是说,把链接这个过程推迟到了运行时再进行,这就是动态链接(Dynamic Linking ) 的基本思想。
在 Linux 系统中,ELF 动态链接文件被称为动态共享对象(DSO, Dynamic Shared Objects ),简称共享对象,它们一般都是以 “ .so” 为扩展名的一些文件;而在 Windows 系统中,动态链接文件被称为动态链接库(Dynamical Linking Library),它们通常就是我们平时很常见的以 “.dll” 为扩展名的文件。
有四个源代码文件:
// Lib.h
#ifndef LIB_H
#define LIB_H
void foobar(int i);
#endif
// Lib.c
#include<stdio.h>
void foobar(int i){
printf("Printing from Lib.so: %d\n", i);
}
// Program1.c
#include"Lib.h"
int main(){
foobar(1);
return 0;
}
// Program2.c
#include"Lib.h"
int main(){
foobar(2);
return 0;
}
将Lib.c编译成一个共享对象文件:
gcc -fPIC -shared -o Lib.so Lib.c
再链接:
gcc -o Program1 Program1.c ./Lib.so
gcc -o Program2 Program2.c ./Lib.so
当程序模块 Program1 .c 被编译成为 Program1.o 时, 编译器还不不知道 foobar()函数的地址。当链接器将 Program1.c 链接成可执行文件时,这时候链接器必须确定 Program1.o 中所引用的 foobar() 函数的性质。
链接器如何知道 foobar 的引用是一个静态符号还是一个动态符号?Lib.so 中保存了完整的符号信息。把 Lib.so 也作为链接的输入文件之一,链接器在解析符号时就可以知道:foobar 是一个定义在 Lib.so 的动态符号。这样链接器就可以对 foobar 的引用做特殊的处理,使它成为一个对动态符号的引用。
回顾一下之前提到的,程序模块的指令和数据中可能会包含一些绝对地址的引用,我们在 链接产生输出文件的时候,就要假设模块被装栽的目标地址。
可执行文件基本可以确定自己在进程虚拟空间中的起始位置,因为可执行文件往往是第一个被加载的文件,它可以选择一个固定空闲的地址,比如 Linux 下一般都是 0x08040000, Windows 下一般都是 0x0040000。
但是在动态链接的情况下,如果不同的模块目标装载地址都一样是不行的。共享对象在编译时不能假设自己在进程虚拟地址空间中的位置。共享对象的最终装载地址在编译时是不确定的,而是在装载时,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象。
为了能够使共享对象在任意地址装载,我们首先能想到的方法就是静态链接中的重定位。这个想法的基本思路就是,在链接时,对所有绝对地址的引用不作重定位,而把这一步推迟到装载时再完成。一旦模块装载地址确定,即目标地址确定,那么系统就对程序中所有的绝对地址引用进行重定位。
我们前面在静态链接时提到过重定位,那时的重定位叫做链接时重定位(Link Time Relocation ), 而现在这种情况经常被称为装载时重定位(Load Time Relocation ), 在 Windows 中,这种装载时重定位又被叫做基址重置(Rebasing)。
但是它有一个很大的缺点是(重定位后的)指令部分无法在多个进程之间共享,这样就失去了动态链接节省内存的一大优势。
我们的目的是希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实 的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为地址无关代码(PIC,Position-independent Code ) 的技术。
模块中各种类型的地址引用方式可以看作4种:
static int a;
extern int b;
extern void ext();
void bar(){
a = 1;//模块内部的数据访问,比如模块中定义的全局变量、 静态变量。
b = 2;//是模块外部的数据访问,比如其他模块中定义的全局变量。
}
void foo(){
bar();//模块内部的函数调用、跳转等。
ext();//模块外部的函数调用、跳转等。
}
类型一:模块内部调用或跳转
因为被调用的函数与调用者都处于同一个模块,它们之间的相对位置是固定的,所以这种情况比较简单。对于现代的系统来讲,模块内部的跳转、函数调用都可以是相对地址调用,或者是基于寄存器的相对调用,所以对于这种指令是不需要重定位的。
类型二:模块内部数据访问
我们知道,一个模块前面一般是若干个页的代码, 后面紧跟着若干个页的数据,这些页之间的相对位置是固定的,也就是说,任何一条指令与 它需要访问的模块内部数据之间的相对位置是固定的,那么只需要相对于当前指令加上固定 的偏移量就可以访问模块内部数据了。
类型三:模块间数据访问
模块间的数据访问比模块内部稍微麻烦一点,因为模块间的数据访问目标地址要等到装载时才决定,比如上面例子中的变量 b,它被定义在其他模块中,并且该地址在装载时才能确定。
要使得代码地址无关,基本的思想就是把跟地址相关的部分放到数据段里面。
ELF 的做法是在 数据段里曲建立一个指向这些变量的指针数组。也被称为全局偏移表 (Global Offset Table, GOT),当代码需要引用该全局变量时,可以通过 GOT 中相对应的项间接引用。
当指令中需要访问变量 b 时,程序会先找到 GOT,然后根据 GOT 中变量所对应的项找 到变量的目标地址。
由于 GOT 本身是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响。
类型四:模块间调用、跳转
与上面的方法类似,GOT 中相应的项保存的是目标函数的地址,当模块需要调用目标函数时,可以通过 GOT 中的项进行间接跳转。
在动态链接下,在程序开始执行前,动态链接会耗费不少时 间用于解决模块之间的函数引用的符号査找以及重定位,而且在一个程序运行过程中,可能很多函数在程序执行完时都不会被用到,因此如果一开始就把所有函数都链接好实际上是一种时间与CPU上的浪费。
ELF 采用了一种叫做延迟绑定( Lazy Binding ) 的做法,基本的思想就是当函数第一次被用到时才进行绑定(符号査找、重定位等),如果没有用到则不进行绑定。
现在我们看看可执行文件中与动态链接相关的结构:
section .interp:可执行文件所需要的动态链接器的路径。
qmmms@qmmms-virtual-machine:~/shared/SimpleDynamicLinking$ objdump -s Program1
Contents of section .interp:
0318 2f6c6962 36342f6c 642d6c69 6e75782d /lib64/ld-linux-
0328 7838362d 36342e73 6f2e3200 x86-64.so.2.
section .dynamic:保存了动态链接器所需要的基本信息,比如依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等。“ .dynamic ” 段可以看成是动态链接下 ELF 文件的“文件头”。使用 readelf 工具可以查看 “ .dynamic ” 段的内容:
qmmms@qmmms-virtual-machine:~/shared/SimpleDynamicLinking$ readelf -d Lib.so
Dynamic section at offset 0x2e20 contains 24 entries:
标记 类型 名称/值
0x0000000000000001 (NEEDED) 共享库:[libc.so.6]
0x000000000000000c (INIT) 0x1000
0x000000000000000d (FINI) 0x1144
0x0000000000000019 (INIT_ARRAY) 0x3e10
0x000000000000001b (INIT_ARRAYSZ) 8 (bytes)
0x000000000000001a (FINI_ARRAY) 0x3e18
0x000000000000001c (FINI_ARRAYSZ) 8 (bytes)
0x000000006ffffef5 (GNU_HASH) 0x2f0
0x0000000000000005 (STRTAB) 0x3c0
0x0000000000000006 (SYMTAB) 0x318
0x000000000000000a (STRSZ) 121 (bytes)
0x000000000000000b (SYMENT) 24 (bytes)
0x0000000000000003 (PLTGOT) 0x4000
0x0000000000000002 (PLTRELSZ) 24 (bytes)
0x0000000000000014 (PLTREL) RELA
0x0000000000000017 (JMPREL) 0x510
0x0000000000000007 (RELA) 0x468
0x0000000000000008 (RELASZ) 168 (bytes)
0x0000000000000009 (RELAENT) 24 (bytes)
0x000000006ffffffe (VERNEED) 0x448
0x000000006fffffff (VERNEEDNUM) 1
0x000000006ffffff0 (VERSYM) 0x43a
0x000000006ffffff9 (RELACOUNT) 3
0x0000000000000000 (NULL) 0x0
另外 Linux 还提供了一个命令用来査看一个程序主模块或一个共享库依赖于哪些共享 库:
qmmms@qmmms-virtual-machine:~/shared/SimpleDynamicLinking$ ldd Program1
linux-vdso.so.1 (0x00007ffc5d380000)
./Lib.so (0x00007f60a663e000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f60a6400000)
/lib64/ld-linux-x86-64.so.2 (0x00007f60a664a000)
section .dynsym:动态符号表,表示动态链接这些模块之间的符号导入导出关系。只保存了与动态链接相关的符号,对于那些模块内部的符号,比如模块私有变量则不保存。
Contents of section .dynsym:
03d8 00000000 00000000 00000000 00000000 ................
03e8 00000000 00000000 5c000000 12000000 ........\.......
03f8 00000000 00000000 00000000 00000000 ................
0408 01000000 20000000 00000000 00000000 .... ...........
0418 00000000 00000000 46000000 12000000 ........F.......
0428 00000000 00000000 00000000 00000000 ................
0438 1d000000 20000000 00000000 00000000 .... ...........
0448 00000000 00000000 2c000000 20000000 ........,... ...
0458 00000000 00000000 00000000 00000000 ................
0468 4d000000 22000000 00000000 00000000 M..."...........
0478 00000000 00000000 ........
section .dynstr:动态符号表也需要一些辅助的表,比如用于保存符号名的字符串表。在这里就是动态符号字符串表 “.dynstr”(Dynamic String Table )
Contents of section .dynstr:
0480 005f4954 4d5f6465 72656769 73746572 ._ITM_deregister
0490 544d436c 6f6e6554 61626c65 005f5f67 TMCloneTable.__g
04a0 6d6f6e5f 73746172 745f5f00 5f49544d mon_start__._ITM
04b0 5f726567 69737465 72544d43 6c6f6e65 _registerTMClone
04c0 5461626c 6500666f 6f626172 005f5f63 Table.foobar.__c
04d0 78615f66 696e616c 697a6500 5f5f6c69 xa_finalize.__li
04e0 62635f73 74617274 5f6d6169 6e002e2f bc_start_main../
04f0 4c69622e 736f006c 6962632e 736f2e36 Lib.so.libc.so.6
0500 00474c49 42435f32 2e322e35 00474c49 .GLIBC_2.2.5.GLI
0510 42435f32 2e333400 BC_2.34.
section .rela.dyn:对数据引用的修正,它所修正的位置 位于“.got”以及数据段。
section .rela.plt:对函数引用的修正,它所修正的位置位于 “ .got.plt”。使用 readelf 來査看一个动态链接的文件的重定位表:
qmmms@qmmms-virtual-machine:~/shared/SimpleDynamicLinking$ readelf -r Lib.so
重定位节 '.rela.dyn' at offset 0x468 contains 7 entries:
偏移量 信息 类型 符号值 符号名称 + 加数
000000003e10 000000000008 R_X86_64_RELATIVE 1110
000000003e18 000000000008 R_X86_64_RELATIVE 10d0
000000004020 000000000008 R_X86_64_RELATIVE 4020
000000003fe0 000100000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTM[...] + 0
000000003fe8 000300000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
000000003ff0 000400000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCl[...] + 0
000000003ff8 000500000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize@GLIBC_2.2.5 + 0
重定位节 '.rela.plt' at offset 0x510 contains 1 entry:
偏移量 信息 类型 符号值 符号名称 + 加数
000000004018 000200000007 R_X86_64_JUMP_SLO 0000000000000000 printf@GLIBC_2.2.5 + 0
动态链接情况可执行文件的装载与静态链接情况类似:
显式运行时链接 (Explicit Run-time Linking), 有时候也叫做运行时加载,就是让程序自己在运行时控制加载指定的模块,并且可以在不需要该模块时将其卸载。
这种共享对象往往被叫做动态装载库(Dynamic Loading Library ), 其实本质上它跟一般的共享对象没什么区别,只是程序开发者使用它的角度不同。
这种运行时加载使得程序的模块组织变得很灵活,可以用来实现一些诸如插件、驱动等 功能。当程序需要用到某个插件或者驱动的时候,才将相应的模块装载进来,而不需要从一开始就将他们全部装载进来,从而减少了程序启动时间和内存使用。
在 Linux 中,从文件本身的格式上来看,动态库实际上跟一般的共享对象没有区别,主要的区别是共享对象是由动态链接器在程序启动之前负责装载和链接的,这一系列步骤都由动态连接器自动完成,对于程序本身是透明的;而动态库的装载则是通过一系列由动态链接器提供的 API, 具体地讲共有 4 个函数:打开动态库(dlopen )、 查找符号(dlsym )、错误处理 ( dleiror ) 以及关闭动态库(dlclose ),程序可以通过这几个API对动态库进行操作。
Q1:动态链接器本身是动态链接的还是静态链接的?
A1:动态链接器本身应该是静态链接的,它不能依赖于其他共享对象,动态链接器本身是用 来帮助其他 ELF 文件解决共享对象依赖问题的,如果它也依赖于其他共享对象,那么谁来帮它解决依赖问题?所以它本身必须不依赖于其他共享对象。这一点可以使用 Idd 来判断:
qmmms@qmmms-virtual-machine:~/shared/SimpleDynamicLinking$ ldd /lib64/ld-linux-x86-64.so.2
statically linked
Q2:动态链接器本身必须是 PIC 的吗?
A2:是不是 PIC 对于动态链接器来说并不关键,动态链接器可以是 PIC 的也可以不是,但往往使用 PIC 会更加简单一些。一方面,如果不是 PIC 的话,会使得代码段无法共享,浪 费内存;另一方面也会使 ld.so 本身初始化更加复杂,因为自举时还需要对代码段进行 重定位。实际上的 ld - linux.so.2 是 PIC 的。
Q3:动态链接器可以被当作可执行文件运行,那么的装载地址应该是多少?
A3:ld.so 的装载地址跟一般的共享对象没区别,即为 0x00000000。这个装载地址是一个无 效的装载地址,作为一个共享库,内核在装载它时会为其选择一个合适的装载地址。
由于动态链接的诸多优点,大量的程序开始使用动态链接机制,导致系统里面存在数最极为庞大的共享对象。如果没有很好的方法将这些共享对象组织起来,整个系统中的共享对象文件则会散落在各个目录下,给长期的维护、升级造成了很大的问题。
操作系统一般会对共享对象的目录组织和使用方法有,这里介绍 Linux 下共享库的管理问题。
从文件结构上来讲, 共享库和共享对象没什么区别,Linux 下的共享库就是普通的 ELF 共享对象。由于共享对象 可以被各个程序之间共享,所以它也就成为了库的很好的存在形式,很多库的开发者都以共 享对象的形式让程序来使用,久而久之,共享对象和共享库这两个概念己经很模糊了,所以 广义上我们可以将它们看作是同 —个概念。
Linux 有一套规则来命名系统中的每一 个共享库,它规定共享库的文件名规则必须如下:
libname.so.x.y.z
程序中有一个它所依赖的共享库的列表,其中每一项对应于它所依赖的一个共享库。
Linux采用一种叫做 SO-NAME 的命名机制来 记录共享库的依赖关系。
每个共享库都有一个对应的 “SO-NAME”,这个 SO-NAME 即共享库的文件名去掉次版本号和发布版本号,保留主版本号。比如一个共享库叫做 libfoo.so.2.6.1 , 那么它的 SO-NAME 即 libfoo.so.2。
在 Linux 系统中, 系统会为每个共享库在它所在的目录创建一个跟 “SO-NAME” 相同的并且指向它的软链接 (Symbol Link )。比如系统中有存在一个共享库 “ /lib/libfoo.so.2.6.1”,那么 Linux 中的共享库管理程序就会为它产生一个软链接 “ /lib/Iibfoo.so.2”,例如:
qmmms@qmmms-virtual-machine:/lib/x86_64-linux-gnu$ ls -l libc*
-rw-r--r-- 1 root root 6027298 7月 7 2022 libc.a
lrwxrwxrwx 1 root root 20 7月 12 22:22 libcaca++.so.0 -> libcaca++.so.0.99.19
lrwxrwxrwx 1 root root 18 7月 12 22:22 libcaca.so.0 -> libcaca.so.0.99.19
这样保证了所有的以 SO-NAME 为名的软链接都指向系统中最新版的共 享库.
如果某文件 A 依赖于某文件 B, 那么 A 的 “.dynamic ” 段中会有 DT_NEED 类型的字段,字段的值就是 B。
qmmms@qmmms-virtual-machine:~/shared/SimpleDynamicLinking$ readelf -d Lib.so
Dynamic section at offset 0x2e20 contains 24 entries:
标记 类型 名称/值
0x0000000000000001 (NEEDED) 共享库:[libc.so.6]
当共享库进行升级的时候,如果保持主版本号不变,只改变次版 本号或发布版本号,那么我们修改 SO-NAME 的软链接指向新版本共享库,即可实现升级。
然而,当某个程序依赖于较高的次版本号的共享库,而运行于较低次版本号的共享库系统时,就吋能产生缺少某些符号的错误。因为次版本号只保证向后兼容,并不保证向前兼容。
这个问题叫做次版本号交会问题(Minor-revision Rendezvous Problem)。现代的系统通过-一种更加精巧的方式来解决,那就是符号版本机制。
Linux 下的 Glibc 从版本 2.1 之后开始支持一种叫做基于符号的版本机制 (Symbol Versioning)的方案。这个方案的基本思路是让每个导出和导入的符号都有一个相关联的版 本号,它的实际做法类似于名称修饰的方法。
例如,当我们将 Iibfoo.so.1.2 升级至 1.3 时,仍然 保持 libfoo.so.1 这个 SO-NAME, 但是给在 1.3 这个新版中添加的那些全局符号打上一个标记,比如 “ VERS_1.3”。
我们现在实操一下,准备要编译成共享库的lib.c:
int foo()
{
return 10086;
}
lib.h
#ifndef LIB_H
#define LIB_H
int foo();
#endif
main.c
#include"lib.h"
int main(){
return foo();
}
符号脚本版本文件lib.ver:
VERS_1.2{
global:
foo;
local:
*;
};
现在根据符号脚本版本文件编译共享库lib.so,注意现在符号版本是1.2
gcc -shared -fPIC lib.c -Xlinker --version-script lib.ver -o lib.so
编译main.c,现在main使用的lib.so符号版本是1.2
gcc main.c ./lib.so -o main
现在我们悄咪咪地把lib.so删了,更改符号脚本版本文件lib.ver的版本为1.1,重新编译共享库lib.so,注意现在lib.so符号版本是1.1,落后于main使用的lib.so符号版本
VERS_1.1{
global:
foo;
local:
*;
};
运行main,不出意外的话报错:
qmmms@qmmms-virtual-machine:/mnt/hgfs/shared/SimpleSymbolVersioning$ ./main
./main: ./lib.so: version `VERS_1.2' not found (required by ./main)
比如我们有 libfoo1.c 和 libfoo2.c 两个源代码文件,希镇产生一个 libfoo.so.1.0.0 的共享 库,这个共享库依赖于 libfoo1.c 和 libfoo2.c 这两个共享库,我们可以使用如下命令行:
gcc -c -g -Wall -o libfoo1.o libfoo1.c
gcc -c -g -Wall -o libfoo2.o libfoo2.c
ld -shared -soname libfoo.so.1 -o libfoo.so.1.0.0 libfoo1.o libfoo2.o
正常情况下编译出来的共享库或可执行文件里面带有符号信息和调试信息,这些信息在 调试时非常有用,但是对于最终发布的版本来说.这些符号信息用处并不大,并且使得文件 尺寸变大。我们可以使用一个叫 “ strip"的工具清除掉共享厍或可执行文件的所有符号和调 试信息(“strip” 是 binutils 的一部分):
strip libfoo.so.1.0.0
最简单的安装办法就是将共享库复制到某个标准的共享库目录,如/lib、/usr/lib 等,然后运行 ldconfig 即可。
sudo cp libfoo.so.1.0.0 /lib/libfoo.so.1.0.0
sudo ldconfig
执行ldconfig时,它会读取默认的共享库路径(例如/etc/ld.so.conf
文件中列出的路径),检查这些路径下的共享库文件,并更新共享库缓存。
ldconfig通常在共享库的安装或删除之后使用,以确保系统能够正确地加载和链接新的或已删除的共享库。在绝大多数情况下,不需要手动运行ldconfig,因为安装或卸载共享库的工具会自动调用它。
- END -想要更多相关代码和笔记?找找这个仓库:
https://gitee.com/QMMMS/reading-notes