程序装载与动态链接

装载与动态链接

可执行文件的装载与系统进程

进程虚拟地址空间

操作系统中的每个进程都有自己的虚拟地址空间,虚拟地址空间的大小由硬件位数决定。

  • 32位linux系统下,虚拟地址空间总共4GB

    • 页表大小为4KB,每个页表有1024项,两级页表,指向的页面大小为4KB
      • 1024 * 1024 * 4KB => 10 + 10 + 12 = 32bit
    • 内核占据1GB:0xC0000000 到 0xFFFFFFFF
    • 用户空间3GB:0x00000000 到 0xBFFFFFFF
    • 此外,有一种称为PAE扩展(Physical Address Extension,自1995年起被使用)的技术,可以支持到36位的物理地址,但是应用程序的虚拟地址空间仍是32位的。
  • 64位linux系统下,并不是所有的地址位都被使用了

    使用cat /proc/cpuinfo命令可以查看支持的物理地址位数与虚拟地址位数

    address sizes   : 39 bits physical, 48 bits virtual
    // 48位地址即256Tib
    

    目前的linux支持128TB的内核地址空间与128TB的用户地址空间。48位虚拟地址的最高位为0表示用户地址空间,为1表示内核地址空间。49到64位保持与48位相同。高16位非全0或者全1的地址未被使用。

    • 用户地址空间:0x0000_0000_0000_0000 到0x0000_7FFF_FFFF_FFFF
    • 内核地址空间:0xFFFF_8000_0000_0000 到 0xFFFF_FFFF_FFFF_FFFF

可执行文件的装载方式

程序要在计算机上运行,必须从外存(硬盘)加载到主存(RAM)中。一个程序在运行时并不一定需要全部装入到内存,只需要动态地加载当前需要使用的部分即可。

早期的动态装载是使用覆盖装入的方式实现的,而现在一般是使用虚拟内存页面映射的方式。

覆盖装入

以上图为例,一个程序的各个模块之间会存在一种树状的依赖关系(需要程序员手工进行组织)。在运行到模块E时,只需要保证主模块和B模块都在内存中即可,因此就可以将其他模块所使用的内存区域覆盖掉。

尽管能够完成动态装载,这样做的缺点是:1.需要程序员手工划分模块的树状依赖;2.跨树间调用是不被允许的。

页映射(Paging)

页面映射属于虚拟存储机制的一部分。将内存按照4kb(或者其他大小)进行划分,通过CPU的MMU模块进行虚拟地址到物理地址的转换。当一个虚拟内存页实际上没有映射到一个物理内存页时,对它的访问就会触发一个Pagefault缺页中断,在中断处理程序中使用LRU算法将其换入到物理内存中,之后再返回到触发缺页的地方继续执行。

操作系统装载可执行文件的过程

创建进程

对于操作系统而言,进程最关键的特性是:进程拥有独立的虚拟地址空间。

多数情况下执行一个可执行文件都伴随着新进程的建立,在使用了虚拟内存的情况下,可以将这一过程分为三步:

  1. 创建一个独立的虚拟地址空间

    对于Linux系统而言,只需要分配一个页目录给该进程即可,虚拟页与物理页的映射关系则在pagefault时再进行设置。

  2. 读取可执行文件(ELF文件头),建立虚拟地址空间与可执行文件的映射关系

    这一关系保存在task_struct中所保存的vm_area_struct结构体中(一般简称为VMA)。

    vm_area_struct构成一个双向链表,按照地址空间排序,同时系统会创建一个红黑树用于快速进行区间查找。

    vm_area_struct保存了虚拟地址的起止位置,内存标志等信息,以及对应的文件与文件offset映射关系。

    关于VMA的具体细节,可以参考VMA@百度百科

  3. 内存映射建立后,就可以将PC寄存器设置为程序入口地址,转移控制流。

    在系统层面上,需要进行内核栈到用户栈的切换,CPU的保护权限和虚拟地址空间也要进行相应的切换。

Page Fault

当运行环境(虚拟内存、栈空间等)设置好后,CPU从该程序的入口地址开始执行。此时由于该地址的数据还没有加载到内存中来,就会触发一个缺页中断。

操作系统的中断处理程序会通过该进程的task_struct找到对应的VMA,然后根据该VMA中保存的信息,分配物理页面并加载内容,然后建立虚拟页面到物理页面的映射关系,中断返回后该程序就可以正常运行了。

进程的虚拟内存空间分布

ELF的两种视图与虚拟内存映射

  • Linking View

    在读取ELF文件的时候,通过其Section Header Table可以获取其中所有的Section(例如.text,.init,.data等)信息,这些信息用于程序的链接过程。

  • Executing View

    通过Program Header Table可以获取程序中所有Segment的信息。与Section相比,Segment可以认为是按照装载的角度重新划分了各个section的内容,并且会拥有RWX类型的权限。例如section视图下的.text和.init以及.fini都是只读可执行的数据,它们就可以并入同一个Segment。

    这样做的优势是相比于直接按section装载,极大的减少了内存碎片。

操作系统装载ELF可执行文件的时候是按照Segment的划分来进行的。一个Segment会被映射一块连续的,与虚拟内存页面对齐的内存区域,并将映射关系保存到一个VMA结构中。并不是所有的Segment都需要被装载,ELF文件也包含一些用于保存辅助装载信息等用途的Segment。

一个ELF文件与装载时的虚拟内存空间映射关系的例子如下图所示。

堆与栈

用户进程所使用的虚拟内存区域VMA映射可以分为两类:

  • File/device backed mappings
    • ELF file
    • Data files
    • Shared Memory
    • Devices
  • Anonymous mappings
    • Stack
    • Heap
    • CoW page

(关于VMA与内存映射的详细说明,可以参考 Linux memory management

使用cat /proc/$pid/maps可以查看指定进程的内存映射,一个例子如下所示

7f838edd4000-7f838edde000 r--p 00000000 00:00 142482             /usr/bin/htop
7f838edde000-7f838eddf000 r--p 0000a000 00:00 142482             /usr/bin/htop
7f838eddf000-7f838edf4000 r-xp 0000b000 00:00 142482             /usr/bin/htop
7f838edf4000-7f838edf5000 r-xp 00020000 00:00 142482             /usr/bin/htop
7f838edf5000-7f838edfd000 r--p 00021000 00:00 142482             /usr/bin/htop
7f838edfd000-7f838edfe000 r--p 00029000 00:00 142482             /usr/bin/htop
7f838edfe000-7f838ee00000 r--p 00029000 00:00 142482             /usr/bin/htop
7f838ee00000-7f838ee03000 rw-p 0002b000 00:00 142482             /usr/bin/htop
7f838ee03000-7f838ee04000 rw-p 00000000 00:00 0
7fffdaf35000-7fffdafe1000 rw-p 00000000 00:00 0                  [heap]
7fffe1397000-7fffe1b97000 rw-p 00000000 00:00 0                  [stack]
7fffe1fb4000-7fffe1fb5000 r-xp 00000000 00:00 0                  [vdso] 

可以看到一部分内存区域映射到了文件,另一部分内存(heap/stack)则没有映射到实际的文件或设备。

一些说明:

  1. rwxp中的p代表私有
  2. 权限后面的列表示文件中的偏移量
  3. 142482是文件节点编号,前面的00:00表示主次设备号
  4. vdso是用于与内核通信的模块

将堆和栈的内存补充到进程的虚拟内存空间后,可以得到如下的图示(下图示例是32位虚拟地址分布):

可以看到,堆的地址空间是由低到高生长,而栈的地址空间则从高地址向低地址生长。

堆栈的大小限制:

在linux系统中,可以使用ulimit -a查看所有的系统限制,在其中可以找到stack大小限制,一般来说是8192kb。而堆区的大小通常没有系统层面的限制。

段地址对齐

在ELF映射到虚拟内存空间的过程中,映射的最小单位是页面,即segment必须与页面大小(通常是4kb)对齐。

  • 最简单的方案是每个segment分开映射,例如有3个大小分别为127b, 9899b和1924b的三个段,那么它们将占用1+3+1个虚拟/物理页面。并且每个段的首地址都将与4kb对齐。
  • 可以通过重用物理页面中空闲的部分来节省内存碎片。
    • 例如段0只占用了127个字节,那么让段1的虚拟地址从xxxx128开始,就可以将它们的虚拟页面映射到同一个物理页面而不发生冲突。(它们的虚拟地址位于不同的页,但是使用的是页面内不同偏移位置处的内存,因此可以映射到同一个物理页面)
栈的初始化

操作系统在启动一个进程时,通常会需要传递环境变量和运行参数。

一个刚启动的程序的栈空间可能包含如下内容:

--栈底(高地址)--
字符串表
环境变量指针表[以0结尾]
参数指针表[以0结尾]
参数数量  ---> sp(栈顶)

main函数通过栈上的argc和argv就可以获取传入的参数。

Linux系统装载ELF文件的过程

在用户层面上,如果我们想要从可执行elf文件启动一个新的进程并运行,可以通过如下两个步骤实现:

  1. fork()
  2. execve()

fork将当前进程分叉,execve则加载指定程序并替换掉当前进程的内容。

其中操作系统装载ELF文件的过程就发生在execve这个系统调用中。

  • 操作系统内核中,execve系统调用对应的入口函数是sys_execve,它将执行参数复制和检查的工作,然后调用do_execve
  • do_execve会查找被执行的文件,然后检查前128个字节,并通过search_binary_handle函数确定具体的处理程序(例如对elf可执行文件调用load_elf_binary,对脚本调用load_script进行处理等)
  • load_elf_binary函数会完成对ELF文件的装载过程:
    • 检查ELF文件有效性
    • 寻找.interp段,确定动态链接器的路径
    • 根据program header table把文件内容映射到VMA
    • 初始化ELF进程环境
    • 设置系统调用返回地址(RIP寄存器),程序将返回到该入口地址处执行

动态链接

为什么需要动态链接

如果对所有的库都采用静态链接:

  • 以C语言程序为例,几乎每个C程序都会链接到libc库,如果采用静态链接,那么同一份代码将在内存和磁盘中保存多次,浪费大量空间。
  • 程序库更新(或bug修正)的时候,需要重新静态链接整个可执行程序文件并打包发布。

动态链接的基本思想:

多个程序可以共享内存[物理内存]中的同一个共享模块,即动态链接库的代码段部分。数据段部分对于每个进程而言还是独立存在的。

采用动态链接的方式除了解决以上静态链接的缺点外,还可以提高兼容性。例如不同的平台可能有不同的printf函数内部实现,但采用动态链接,只要接口相同,同一份可执行文件就可以在不同的平台上运行。

动态链接的基本实现

在Linux系统下,动态链接库也是ELF文件的一种,使用gcc -fPIC -shared就可以将目标库编译成动态链接库(.so文件)。而在Windows下,动态链接库以.dll的格式保存。

使用了动态链接库的可执行程序,其函数入口不再是glibc中所定义的_start函数,在其elf文件中会有一个段保存动态链接器ld程序的路径,通过该ld程序启动整个程序。

相比于静态链接而言,对于模块的重定位在程序运行时完成,会有一定的性能损失,但是这一损失并不算多,大约在1%到5%左右。

一个动态链接程序的例子

编写如下4个文件:

  • 可执行程序prog1.c和prog2.c

    /* prog1.c */
    #include "Lib.h"
    int main(){
        foobar(1);
    }
    
    /* prog2.c */
    int main(){
        foobar(2);
    }
    
  • Lib库Lib.h与Lib.c

    /* Lib.h */
    void foobar(int);
    
    /* Lib.c */
    #include <stdio.h>
    void foobar(int i){
        printf("Printing from Lib.so: %d\n", i);
    }
    

然后使用如下的命令分别编译:

gcc -fPIC -shared -o Lib.so Lib.c
gcc -o prog1 prog1.c ./Lib.so   # ./Lib.so是必须的,不能使用Lib.so
gcc -o prog2 prog2.c ./Lib.so

再分别运行prog1和prog2就可以得到Printing from Lib.so: 1这样的输出。

在编译的时候将Lib.so作为输入是为了使得链接器了解foobar这个符号是一个动态连接符号,从而在最终的输出程序中将foobar作为一个动态符号的引用。

如果在prog1中加入sleep函数调用或者while循环,使其长时间运行,我们就可以通过cat /proc/$pid/maps命令查看其内存映射状况。通常来说会包含如下几个部分:

  • prog1 #当前可执行程序
  • [heap]
  • libc.so #c语言运行库
  • Lib.so #我们自定义的Lib.so动态库
  • ld-linux-x86-64.so.2 #动态链接库ld.so,程序就是通过它启动的
  • [stack]
  • 其他系统模块

地址无关代码

上一节中使用了gcc -fPIC -shared这样的gcc选项来编译动态库Lib.so,其中-fPIC的作用就是让gcc生成地址无关代码。

早期的系统使用了固定地址加载动态库的方式

我们知道,可执行文件的起始地址几乎总是固定的,在32位linux系统下是0x08040000,而64位系统下则是另一个固定的位置,这是因为在虚拟内存的帮助下,每个进程都拥有自己的内存地址空间,所以总是可以加载到固定的位置而不用担心冲突。

而早期的一些操作系统处理动态库的方式也是类似的,系统将动态库加载到内存的固定区域,这样不同程序共享动态库的代码时,可以使用相同的地址。此外,对于动态库的指令和数据所包含的一些绝对地址引用,也不需要进行重定位操作。当然,这种做法的缺点显而易见,容易造成冲突,升级动态库时也必须重新链接可执行程序等等,因此现在已经没有系统使用这种做法了。

为了让动态库可以加载到虚拟内存的任意位置,首先要解决其代码和数据中所使用的绝对地址的重定位问题。

装载时重定位

为了让共享对象可以加载到任意内存位置,可以使用装载时重定位。

总体而言,装载时重定位就是把静态链接过程中的重定位操作推迟到了程序启动时执行,对于所有需要重定位的绝对地址,整体加上一个偏移量即可。这种方式也称为基址重置(Rebase)。

在使用gcc编译动态库时,如果去掉-fPIC选项而只使用-shared选项,那么默认使用的方式就是装载时重定位。

装载时重定位可以解决动态库的更新问题,但是却无法在多个进程间共享同一份动态库代码,不同进程所需要的重定位偏移量显然是不同的,因此一个进程所使用的动态库代码段无法让另一个进程同时使用。

地址无关代码

使用地址无关代码(PIC,Position Independence Code)就可以解决装载时重定位无法共享内存的问题。

对于程序中所引用的地址,可以根据模块内外和地址类型分为如下四种类型:

  • 模块内部引用 + 函数调用/地址跳转

    这是最简单的情况,因为代码段的所有代码之间的相对位置都是固定不变的,所以只要编译器产生相对寻址的call和jmp指令,那么就不需要额外的重定位操作。

  • 模块内部引用 + 数据访问

    尽管数据段(.data/.bss)的地址相对于代码段(.text)的地址也是固定的,但是不存在一个通过当前pc位置来相对寻址访问数据的指令。

    我们知道函数调用时,其返回地址(当前指令的下一条指令)会被保存到stack,因此可以通过一个返回当前栈顶数据的简单函数来获取当前pc并保存到通用寄存器,然后可以通过该地址进行相对寻址操作。

  • 模块外部引用 + 数据访问

    在数据段中建立一个GOT(Global Object Table),那么GOT表本身的相对位置是固定的,可以用上述访问内部数据的方式访问。而其中所保存的外部符号地址表项,则由动态链接器在加载模块时向GOT表填入外部变量的地址。

  • 模块外部引用 + 函数调用/地址跳转

    同样可以通过GOT表的方式来实现。

总的来说,对于模块内部的地址,可以通过采用相对寻址的方式来生成位置无关代码。而对于模块外部的地址,则可以通过在数据段增加一个GOT表,编译器产生通过GOT表访问的代码。而GOT表则由动态链接器在启动时进行符号查找并填充(一次填充所有表项会减慢程序的加载速度,实际中一般会采用延迟绑定的技术)。

共享模块的全局变量问题

在编译共享对象时,如果某一个文件使用了一个外部变量,那么在编译期间编译器无法确定该变量属于同一个模块的其他目标文件,或者属于一个另一个动态库文件。而这关乎到变量访问代码的生成。

解决方法也很简单粗暴,就是在编译共享对象时,对于所有的全局变量,都会采用GOT的形式来访问,这样如果该变量在主模块中存在副本,就让GOT表项指向该副本,如果是位于模块内部的,则指向模块内部的地址。

数据段地址无关性

尽管每共享模块的数据段是不共享的,但是数据段中仍可能存在绝对地址引用的问题:

static int a;
static int* p = a;

上述代码中p的值取决于a的地址。

由于数据段不需要在多个进程间共享,所以可以采用装载时重定位的方式来解决问题,编译器在编译时产生一个重定位表,动态链接器在装载时就可以通过该表完成数据的重定位。

延迟绑定与PLT

相比于静态链接,动态链接极大地提升了灵活性。但是相比于静态链接,动态链接存在额外的性能开销,主要来源于以下两部分:

  • 对于全局和静态数据,模块外部符号的访问都需要通过GOT表进行间接寻址。
  • 程序在启动时,需要让动态链接器解析所有动态连接符号并填充GOT表。

对于第二部分,可以通过延迟绑定外部函数的方式来减少程序启动时的额外开销。即在程序启动时,其所有的模块间函数调用都不进行地址绑定,而是等到真正调用该函数的时候再进行查找。

Linux系统的ELF格式使用PLT(Procedure Linkage Table)来实现延迟绑定,即在elf文件中有一个名为.plt的段,用于进行延迟绑定。

在运行时将函数调用绑定到具体的地址,需要知道两项信息:1.函数所属的模块 2.函数在所属模块内部的位置,用来进行动态链接函数查找的_dl_runtime_resolve函数的参数正是这两项。

PLT的基本原理

按照前面的描述,当我们调用动态链接模块的函数时,应该通过GOT表项进行间接跳转。而延迟绑定的做法则是在在GOT表前增加了一层PLT表的间接跳转,即 PLT->GOT->Function。

假定有一个bar函数,那么它在PLT表中的表项内容如下

bar@plt:
jmp *(bar@GOT)
push n
push moduleID
jmp _dl_runtime_resolve

第一条是跳转到bar在GOT表中的项所指向的位置,如果该函数已经完成了地址绑定,那么GOT表中保存的就是该函数的实际地址,否则,GOT表中保存的是下一条指令,即push n的位置。

接下来的两条push指令和一条jmp指令所做的工作就是将函数的编号,所属模块编号压栈,然后调用_dl_runtime_resolve函数来查找该函数的真实地址,一旦查找成功,那么它会将该bar()的真实地址填入到GOT表中,这样下一次运行时,jmp *(bar@GOT)就可以直接跳转到bar()函数。

实际的PLT表比上述结构要更精巧一些,模块id和_dl_runtime_resolve等共用的部分会保存在PLT表开头的位置,余下表项中只包含函数编号信息,以减少代码重复。此外.plt段一般会与.text段一起,作为rx的segment加载到内存中。

动态链接相关结构

对于静态链接的可执行文件而言,程序的入口就是该ELF文件本身所指定的entry项。而动态链接的可执行文件,则是会由操作系统启动动态链接器,再由动态链接器ld.so完成一系列的初始化工作。

.interp段

对于一个动态链接的可执行文件而言,其所使用的动态链接器是由ELF文件中的.interp段所指定的。.interp段所包含的内容很简单,就是一个指向动态链接器ld.so的字符串。

使用objdump -s指令可以查看各个section的内容,可以看到.interp段就包含了一个字符串:

Contents of section .interp:
 0318 2f6c6962 36342f6c 642d6c69 6e75782d  /lib64/ld-linux-
 0328 7838362d 36342e73 6f2e3200           x86-64.so.2.

也可以使用readelf -l $elf_file | grep interpreter这样一个命令来查看elf文件所指定的动态链接器的路径。

.dynamic段

.dynamic段所保存的是用于动态连接过程的一些基本信息,其表项结构如下:

typedef struct{
    Elf32_Sword d_tag;
    union (
    	Elf32_Word d_val;
        Elf32_Addr d_ptr;
    )d_un;
}Elf32_Dyn;

可以看到仅有两项内容,第一项是类型tag,第二项用于保存一个值或者一个指针。

可以使用的d_tag包括如下一些

  • DT_SYMTAB: 动态连接符号表的地址
  • DT_STRTAB:动态链接字符串表的地址
  • DT_STRSZ:动态链接字符串表的大小
  • DT_HASH:动态链接的哈希表地址
  • DT_INIT:初始化代码地址
  • DT_NEED:依赖的共享对象文件,d_ptr指向一个表示共享对象文件的字符串(使用ldd程序可以查看一个文件所依赖的共享对象)
  • ...

.dynamic段所保存的信息,有点类似于ELF_Header或者Program_Header中的信息,包括动态符号表的位置,动态链接字符串表的位置等等基本信息。

.dynsym段

类似于静态链接中所使用的符号表.symtab段,.dynsym段用于保存动态链接相关的符号的信息。一般来说,一个动态链接的模块会同时拥有.symtab段和.dynsym段,前者保存所有的符号信息,而后者只包含动态链接相关的符号。

动态符号表和符号表一样,需要使用一些辅助表来表示信息。例如用于保存符号名的字符串表,动态符号所用的字符串都保存在.dynstr段中。此外,由于动态符号表会在运行时被查找,还有一个辅助用的.hash段保存了符号哈希表。

动态链接重定位表

动态链接对象中,需要重定位的地址有:

  • 非地址无关的共享对象
  • PIC共享对象的数据段

在静态链接过程中,.rel.text段用来保存.text段的重定位信息,.rel.data段用来保存.data段的重定位信息。而动态链接中类似的表段是.rel.dyn.rel.plt,前者是对数据引用的修正,后者则是对函数引用的修正。

动态链接时的进程堆栈初始化信息

对于静态链接的程序而言,操作系统在转移控制权时,会预先在进程的堆栈中保存命令行参数和环境变量信息。而对于动态链接程序,操作系统会将控制权转交给动态链接器ld.so,那么除了上述信息之外,还需传入一些动态链接器所需要的辅助信息。

这些辅助信息用Elf32_auxv_t类型的结构体数组保存 ,该结构体有两个字段,一个表示该项的类型,另一个字段是值或者地址。这一aux数组中保存了程序elf文件的句柄(或者是elf文件在内存中映射的位置),程序入口地址等。

动态链接的步骤与实现

总的来说,动态链接器执行动态链接的过程可以分为三步:1.启动动态链接器本身;2.装载所需的共享对象;3.进行重定位和初始化。

动态链接器自举

动态链接器自身也是一个共享对象,但是由于其特殊性,它需要满足:

  1. 不能依赖任何其他的共享对象;
  2. 它自身所使用的全局和静态变量的重定位工作要由它自身来完成。

第一点只要不使用任何系统库或运行库即可保证,第二点则需要通过一段精巧的自举代码来完成。

(动态链接器可以根据它自身的入口地址,加上一个偏移量找到它的GOT表,通过GOT表中所保存的.dynamic段的偏移量,查找到.dynamic段,进一步确定动态符号表的位置,从而完成自举)

共享对象装载

共享对象elf文件中的.dynamic段会指出该对象所依赖的其他共享对象名,这些依赖我们在终端中也可以使用ldd命令来查看。

动态链接器会使用广度优先遍历的方式,先加载依赖对象所依赖的共享对象,直到所有依赖的共享对象都完成加载。 每次装载一个新的共享对象时,它的符号表就会被合并到全局的符号表当中,当所有共享对象加载完毕时,全局符号表中就包含了进程中中所有动态链接所需要的符号信息了。

存在的问题与解决方案:

  • 动态装载共享对象时,可能会存在同名符号

    不同于静态链接强/弱符号的方案,动态链接器装载符号时,会采用先加载符号的定义。即如果一个模块加载顺序靠后,那么其中的同名符号就会被加载顺序靠前模块的同名符号所覆盖。

  • 模块内部符号

在前面地址无关代码的部分描述了,对于所有的全局符号,生成地址无关代码时都会采用GOT的方式。那么就会导致模块内部的符号被其他模块中的同名符号覆盖的问题。解决方式是使用static关键字来声明一个符号,那么该符号就一定是模块内的,进而可以使用pc+偏移量的方式来产生地址无关代码。

重定位与初始化

  • 重定位

    当上述步骤完成后,动态链接器就已经得到了一个包含所有动态连接符号的全局符号表。因此,它可以重新遍历可执行文件和每个共享对象的重定位表,并根据重定位表和全局符号表来对共享地址进行修正。

  • 初始化

    重定位完成后,如果共享对象中包含.init段,那么动态链接器还会执行该段的代码来完成初始化的过程。类似的,在退出时也会执行对应的.fini段的内容。此外,对于可执行程序的.init.fini段,还是会由程序自身的初始化代码来执行,动态链接器ld.so并不会执行这部分代码。

运行时加载

支持动态链接的系统往往也支持运行时加载或者称为显式运行时链接的方式,即让程序在运行过程中自行控制加载指定模块,并且在使用完成后可以卸载该模块。

在linux系统的/lib/libdl.so.2中,提供了4个动态库装载相关的API:

  • dlopen

    void* dlopen(const char* filename, int flag);

    返回值是对应模块的句柄(handle)。第一个参数是动态库的路径,第二个参数可以选择RTLD_LAZY(延迟绑定)或者RTLD_NOW(加载时立即绑定)。

    相比于动态链接器所进行的加载,使用dlopen必须手动加载动态链接库的依赖;此外,将filename设置为NULL可以得到当前的全局符号表的句柄。

  • dlsym

    void* dlsym(void* handle, char* symbol);

    根据给出的symbol名字,在handle所指向的动态库中查找符号。

  • dlclose

    卸载一个已经加载的模块。dlopen和dlclose会为指定的模块维护一个引用计数,当计数器减到0时才会发生真正的卸载(执行.fini、从符号表中删除符号、取消模块的内存映射关系、关闭模块文件)。

  • dlerror

    在调用了以上三个函数之后,可以通过dlerror()函数来确认上一次调用是否成功。

Linux共享库的组织

共享库的版本

版本兼容性

共享库的开发者会不断更新共享库的版本,以修正bug,改进性能或者提供新的功能等等。利用动态链接的灵活性,程序和程序所依赖的共享库可以分别独立开发和更新,但如果共享库的更新破坏了原有的接口,那么可能导致程序无法正常运行。因此,可以将共享库的更新简单地分为两类:

  • 兼容更新:原有接口保持不变
  • 不兼容更新:改变了原有的接口

这里所说的接口特指共享库的二进制接口ABI,对于C语言共享库而言,一般有4种行为会导致ABI的改变:

  1. 导出函数的行为发生了改变,即调用该函数产生的结果与之前不一样。
  2. 导出函数被移除。
  3. 导出的数据结构发生了变化(数据结构的内存布局发生了改变)
  4. 导出函数接口发生了变化,例如函数参数和返回值的变更。

对于C++而言,由于模板、虚函数、重载等特性的存在,情况要比C语言复杂一些。

共享库版本命名

在Linux下,共享库的文件规则如下所示:

libname.so.x.y.z

其中lib是固定前缀,其后是库的名字和固定后缀.so,最后则是共享库的版本号,依次是主版本号,次版本号和发布版本号。

  • 主版本号:进行了重大升级,不同主版本号之间不兼容。
  • 次版本号:增量式升级,新增了接口,而原有的符号保持不变。
  • 发布版本号:错误修正,性能改进等,接口没有发生变化。

当然,并不是所有的共享库命名都完全遵循该规则,例如C语言运行库libc-x.y.z.so就不遵守该规则。

SO-NAME

对于一个使用了共享库的程序而言,它必须至少记录共享库的名字和主版本号才能找到正确的共享库。Unix和Linux使用SO-NAME的命名机制来记录共享库的依赖关系。

具体而言,操作系统会为libname.so.x.y.z的共享库在相同的路径下创建一个libname.so.x的软链接指向它,在共享库进行了次版本号或者发布版本号的更新后,系统会更新软链接,使其指向相同主版本下最新的共享库文件。这一更新过程也可以通过调用ldconfig程序来完成。

符号版本

对于使用SO-NAME机制的系统,尽管可以保证主版本号的兼容,但是如果系统中只保有次版本号较旧的库,那么使用了次版本号较新的库的程序就可能无法正确运行,并且这种次版本号交会问题(Minor-revision Rendezvous Problem)无法通过SO-NAME来解决。

Linux通过符号版本机制来解决这一问题。具体而言,通过一种类似于符号修饰的方式,使用链接脚本在链接共享库的时候给导入导出的符号都加上版本号。例如我们编译得到的lib.so中的foo的符号版本是VERS_1.2,那么对应的程序中所使用的foo的符号版本也相同。如果在lib.so库的foo符号版本低于VERS_1.2的环境下运行,那么动态链接器就会报错并中止程序的运行,以避免潜在的错误。

共享库在系统中的位置

包括Linux在内的许多开源操作系统都遵循一个叫做FHS(File Hierarchy Standard)的标准,该标准所规定的共享库位置如下:

  • /lib

    系统中的关键和基础共享库存放位置,例如动态链接器ld.so,C语言运行库等。

  • /usr/lib

    非系统运行时所需要的关键共享库,包含一些开发时需要用到的共享库等。

  • /usr/local/lib

    用来存放和操作系统本身无关的第三方应用程序的共享库。

共享库查找过程

在共享对象的elf文件中,.dynamic段会保存该对象所依赖的共享库路径。如果这一路径是绝对路径,那么动态链接器将按照绝对路径进行查找。如果是相对路径,那么动态链接器会在/lib/usr/lib以及在/etc/ld.so.conf配制文件中配置的目录(一般包括/usr/local/lib等路径)中查找共享库。

当然,每次都去遍历所有的路径去查找是很耗时的。在Linux中有一个ldconfig程序,它会在/etc/ld.so.cache文件中维护一个SO-NAME的缓存,当在该缓存中没有找到目标共享库时,再去指定目录中遍历查找。

使用环境变量

Linux系统提供了一些与动态链接相关的环境变量,通过这些变量可以改变动态链接器的默认行为:

  • LD_LIBARAY_PATH

    可以临时改变某个应用程序查找共享库的路径,动态链接器会优先在该路径中进行查找。

    这一环境变量通常用于测试新的共享库或者使用非标准的共享库,可以在测试和开发过程中使用而不应该让普通用户使用该环境变量。

  • LD_PRELOAD

    可以指定预装载的一些共享库或者目标文件,这一环境变量的优先级比LD_LIBRARY_PATH所指定的路径要高,并且无论程序是否依赖,LD_PRELOAD所指定的目标文件都会被装载。借助于全局符号介入机制(即动态链接器以先加载的符号为准),可以改写标准库的某几个函数而不影响其他函数,对于程序的调试或者测试很有帮助。

  • LD_DEBUG

    将其设置为指定的值,例如filesbindingslibs等等可以让动态链接器在运行时打印出指定的调试用心。

创建和安装共享库

  • 创建共享库

    使用gcc -shared -fPIC选项来编一个共享对象。

    使用-Wl,-soname,$soname来传递参数给链接器,设置共享对象的SO-NAME。

    在使用Cmake时,可以通过add_library($libname SHARED)来添加一个共享库对象。通过target_property的设置可以为其增加版本号的信息。

  • 清除符号信息

    使用strip libfoo.so来去除共享库libfoo.so中包含的所有调试信息和符号信息,减少共享库的文件大小。

  • 安装共享库

    将共享库文件libfoo.so复制到系统的共享库路径usr/lib或者/usr/local/lib下,然后执行ldconfig更新缓存即可。

  • 共享库的构造和析构函数

    如果需要额外的构造和析构函数,可以使用gcc扩展__attribute__((constructor))标记。使用了该标记的函数会在初始化时被运行,类似的用于析构的gcc扩展是__attribute__((destructor))

posted on   xiaomingcc  阅读(420)  评论(0编辑  收藏  举报

相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具
点击右上角即可分享
微信分享提示