《程序员的自我修养》读书笔记——动态链接

之前介绍过静态链接,动态链接相对于静态链接稍微要麻烦一些。总体来说,两者的过程都复杂,步骤太多,涉及到重定位,符号修正,地址修正等等。——复杂

动态链接

静态链接在计算机早期还是比较流行的,但是到了后面,其缺点也非常明显。比如浪费内存和磁盘空间,更新模块困难等。

举个例子,每个程序内部除了都保留了printf()、scanf()等这样的公共函数库,还有相当一部分的其他函数库及辅助数据结构都会包含在其中。现在Linux中,一个程序用到C语言静态库至少1MB以上,那么100个程序就会浪费掉100MB空间。

如下图,Program1、Program2都包含了Lib.o这个模块,所以在连接输出可执行文件Program1、Program2的时候就会有两个相同的副本。


除了浪费空间,动态更新也很麻烦。比如在iOS中,你用到了第三方的一个SDK,如果SDK出现了什么Bug,只有等着SDK厂商修复完bug,将新版的SDK给用户,用户再去更新自己的APP,才能修复线上问题。这套流程对于一般的公司来讲代价是非常大的,时间周期太长。

动态链接的出现解决了上面的问题。将程序模块相互独立的分隔开来,形成独立的文件,不再将它们静态地链接到一起。简单而言就是对那些组成程序目标文件的链接,等到程序运行时才进行链接,也就是把链接的过程推迟到运行时才进行,这就是动态链接的基本思想。

如上面的例子,假如现在保留了Program1.o、Program2.o和Lib.o,当运行Program1这个程序的时候,系统首先加载Program1.o,当系统发现Program1.o依赖Lib.o的时候,那么系统再去加载Lib.o,如果还依赖其他目标文件,则同样以类似于懒加载的方式去加载其他目标文件。

当所有的目标文件加载完之后,依赖关系也得到了满足,则系统才开始进行链接,这个链接过程和现在链接非常相似。之前介绍过静态链接的过程,包含符号解析,重定向等。完成这些之后,系统再把控制权交过Program1.o的执行入口,开始执行。如果这个时候Program2需要运行,则会发现系统中已经存在了Lib.o的副本所以就不需要重新加载Lib.o,直接将Lib.o链接起来就可以了。

根据前面介绍的,这样的方式不仅仅减少了内存、磁盘空间的浪费,还减少了物理页面的换入换出,也可以增加CPU缓存的命中率,因为不同进程的数据和指令偶读集中在了一个共享模块上。

至于更新也就更加简单了,只需要简单的将旧的目标文件覆盖掉。无需从先将程序链接一遍,下次程序运行的时候,新的目标文件就会自动装载到内存中。

扩展性及兼容性

动态链接还有一个特点就是让程序可以动态的选择加载程序模块,有点像插件的含义。只要规定了好了程序的几口,那么只需要安装这个接口来编写动态链接文件,就可以实现那动态的添加,扩展程序的功能。——如果iOS中不上架AppStore是可以实现动态更新的,比如用企业证书发布。

其次兼容性也是动态链接的一个优点。动态链接相当于在程序和操作系统之间增加了一个中间层,从而消除了程序对不同平台之间的依赖关系。

动态链接基本实现

基本思想上面介绍过,就是把程序按照各个模块划分为相对独立的模块,在程序运行的时候才将他们链接在一起形成完成的程序。

动态链接需要得到操作系统的支持,因为在动态链接的情况下,进程的虚拟地址空间分布会比静态链接情况更为复杂。比如一些存储管理、内存共享、进程线程等机制都会相对于静态链接不同。

使用动态链接库的情况下,程序被分为程序主模块和动态链接库,实际上它们都可以看成程序的一个模块,都包含了程序指令和数据。比如C语言的运行库glibc就是以动态链接形式保存在lib下面:

-> # ls | grep libc
libc-2.17.so
libcap-ng.so.0
libcap-ng.so.0.0.0
libcap.so.2
libcap.so.2.22
libcidn-2.17.so
libcidn.so.1
libcrack.so.2
libcrack.so.2.9.0
libcrypt-2.17.so
libcrypt.so.1
libc.so.6

系统只保留了一份libc.so。而所有用c语言编写的、动态链接程序都可以在运行时使用它。当程序被装载的时候,系统的动态链接器会将程序所有用到的动态链接库装载到进程的地址空间。

动态链接的例子

用上面的Program1.c、Program2.c及Lib.c作为例子

Lib.c

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

Lib.h

#ifndef LIB_H
#define LIB_H 
void foobar(int i);
#endif

Program1.c


#include "Lib.h"

int main()
{
    foobar(1);
    return 0;
}

Program2.c


#include "Lib.h"

int main()
{
    foobar(2);
    return 0;
}

现在将Lib.c编译为一个.so文件

gcc -fPIC -shared -o Lib.so Lib.c

-share表示生成.so文件,这样就会在当前目录下生成Lib.so文件。其中包含了foobar()函数,然后分别编译连接Program1.c和Program2.c。

-> # gcc -o Program1 Program1.c ./Lib.so
-> # gcc -o Program2 Program2.c ./Lib.so

一共有如下文件

ls
Lib.c  Lib.h  Lib.so  Program1  Program1.c  Program2  Program2.c

现在从Program1的角度来看整个编译及连接过程如下:

执行一下Program1、Program2

-> # ./Program1
Printint from Lib.so 1
-> # ./Program2
Printint from Lib.so 2
-> #

注意上面的图,在链接器这步,Lib.o并没有被链接进来,链接的输入目标文件只有Program1.o,当时从可执行文件执行的结果来看,确实Lib.so参与了。

当程序模块Program1.c被编译为Program1.o的时候,编译器还不知道foobar函数地址,静态链接也讲过,对于弱符号,如果链接器必须确定所引用的函数,那么链接器会根据链接的规则将foobr函数重定位。如果foorbar定义在一个动态共享库中,那么链接器会将这个符号引用标记为一个动态链接符号,不对他进行重定位,而是把重定位的实际留到装载的时候再进行。

Lib.so中保存了完整的符号信息,链接器在解析符号时就知道,foorbar是一个定义在Lib.so的动态符号,从而对foobar特殊处理使得它成为一个对动态符号的引用。

动态链接程序运行时地址空间分布

静态链接而言,整个进程只有一个可执行文件被映射,之前介绍过静态的内存分布。动态链接而言除了可执行文件外还有其他共享目标文件。

以Program1为例。在其中加入sleep函数防止一运行程序就结束了。

#include "Lib.h"

int main()
{
        foobar(1);
        sleep(-1);
        return 0;
}

直接看打印的结果

-> # ./Program1 &
[2] 7847
Printint from Lib.so 1
-> # cat /proc/7847/maps
00400000-00401000 r-xp 00000000 fd:00 608267                             /root/CodeDir/dym_linckTest/Program1
00600000-00601000 r--p 00000000 fd:00 608267                             /root/CodeDir/dym_linckTest/Program1
00601000-00602000 rw-p 00001000 fd:00 608267                             /root/CodeDir/dym_linckTest/Program1
7f5eaac3d000-7f5eaadf5000 r-xp 00000000 fd:00 17332206                   /usr/lib64/libc-2.17.so
7f5eaadf5000-7f5eaaff5000 ---p 001b8000 fd:00 17332206                   /usr/lib64/libc-2.17.so
7f5eaaff5000-7f5eaaff9000 r--p 001b8000 fd:00 17332206                   /usr/lib64/libc-2.17.so
7f5eaaff9000-7f5eaaffb000 rw-p 001bc000 fd:00 17332206                   /usr/lib64/libc-2.17.so
7f5eaaffb000-7f5eab000000 rw-p 00000000 00:00 0
7f5eab000000-7f5eab001000 r-xp 00000000 fd:00 608263                     /root/CodeDir/dym_linckTest/Lib.so
7f5eab001000-7f5eab200000 ---p 00001000 fd:00 608263                     /root/CodeDir/dym_linckTest/Lib.so
7f5eab200000-7f5eab201000 r--p 00000000 fd:00 608263                     /root/CodeDir/dym_linckTest/Lib.so
7f5eab201000-7f5eab202000 rw-p 00001000 fd:00 608263                     /root/CodeDir/dym_linckTest/Lib.so
7f5eab202000-7f5eab223000 r-xp 00000000 fd:00 17332199                   /usr/lib64/ld-2.17.so
7f5eab416000-7f5eab419000 rw-p 00000000 00:00 0
7f5eab421000-7f5eab423000 rw-p 00000000 00:00 0
7f5eab423000-7f5eab424000 r--p 00021000 fd:00 17332199                   /usr/lib64/ld-2.17.so
7f5eab424000-7f5eab425000 rw-p 00022000 fd:00 17332199                   /usr/lib64/ld-2.17.so
7f5eab425000-7f5eab426000 rw-p 00000000 00:00 0
7fff0c0fe000-7fff0c11f000 rw-p 00000000 00:00 0                          [stack]
7fff0c16e000-7fff0c170000 r-xp 00000000 00:00 0                          [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]

整个进程调度虚拟地址空间多出了几个文件的映射。Lib.so与Program1一样,被操作系统已同样的方式映射到虚拟地址空间,知识占据的虚拟地址范围不同。

其中还用到了C语言运行库libc-2.17.so,还有一个非常重要的共享对象ld-2.17.so,其实ld-2.17.so就是Linux下的动态链接器。动态链接器和普通的共享对象一样被映射到了进程的地址空间,系统开始运行程序之前,会把控制权给动态链接器,由动态链接器完成链接工作,之后再把控制权给Program1

可以使用readelf -l Lib.so查看Lib.so的装载属性

readelf -l Lib.so

Elf 文件类型为 DYN (共享目标文件)
入口点 0x5e0
共有 7 个程序头,开始于偏移量64

程序头:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x000000000000078c 0x000000000000078c  R E    200000
  LOAD           0x0000000000000df8 0x0000000000200df8 0x0000000000200df8
                 0x0000000000000238 0x0000000000000240  RW     200000
  DYNAMIC        0x0000000000000e18 0x0000000000200e18 0x0000000000200e18
                 0x00000000000001c0 0x00000000000001c0  RW     8
  NOTE           0x00000000000001c8 0x00000000000001c8 0x00000000000001c8
                 0x0000000000000024 0x0000000000000024  R      4
  GNU_EH_FRAME   0x000000000000070c 0x000000000000070c 0x000000000000070c
                 0x000000000000001c 0x000000000000001c  R      4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     10
  GNU_RELRO      0x0000000000000df8 0x0000000000200df8 0x0000000000200df8
                 0x0000000000000208 0x0000000000000208  R      1

 Section to Segment mapping:
  段节...
   00     .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .text .fini .rodata .eh_frame_hdr .eh_frame
   01     .init_array .fini_array .jcr .data.rel.ro .dynamic .got .got.plt .bss
   02     .dynamic
   03     .note.gnu.build-id
   04     .eh_frame_hdr
   05
   06     .init_array .fini_array .jcr .data.rel.ro .dynamic .got

可以看到除了文件类型和可执行文件不同与装载地址从0x0000 0000开始之外,其余基本上都一样。很明显这个装载地址是无效地址。共享对象最终的装载地址在编译时是不确定的,而是在装载的时候,装载器更加当前地址空间的空闲状体,动态分配一块足够大小的虚拟地址空间给相应的共享对象。

地址无关代码

地址无关代码是为了解决指令部分在多个进程之间共享问题。重定位解决的是动态模块中有绝对地址引用的问题。

旧方案

  • 共享对象在被装载时,如何确定它在进程虚拟空间的地址?

相对于动态库共享还有一种叫做静态共享库,静态共享库和静态库有很明显的区别。静态库是在链接的时候就确定了符号地址,而静态共享库是吧程序各个模块统一交给操作系统来管理,操作系统在某个特定的地址划分出一个地址块,为已知的模块预留足够的空间。

静态共享库有很多问题,比如地址冲突;还有就是升级之后共享库必须保持共享库中的全局函数和变量地址不变,一旦在链接的时候绑定了这些地址,更改之后就需要重新链接整个程序。

新方案——装载时重定位

为了让共享对象在任意地址装载,所以对所有绝对地址的引用不做重定位,而是把这步推迟到装载的时候再完成,比如一旦模块的装载地址确定了也就是目标地址确定,那么系统对程序所有的绝对地址引用进行重定位,来实现任意地址装载。

比如前面的例子foorbar相对于代码段的其实位置是0x100,当模块被装载到0x10000000时,假设代码段在模块最开始的位置,则foobar的地址就是0x10000100。这个时候遍历所有模块中的重定位表,把所有对foorbar的地址引用都重定位为0x10000100

静态链接的重定位叫做链接时重定位,而上面这种方式叫做装载时重定位。

虽然能够解决动态模块中有绝对地址引用的情况,还是没能解决上面的多个模块依赖一个共享库的指令,变量地址的问题(如何共用?)。动态链接模块被装载映射到虚拟空间,指令部分大部分都是进程之间共享的,因为指令被重定位后对每个进程来讲是不同的。——问题?

动态链接库中可以修改数据的部分对于不同进程来讲是由多个副本的,所以可以用装载时重定位的方法来解决。

Linux和GCC支持这种装载时重定位。GCC有两个参数-shared表示输出的共享对象就是使用装载时重定位的方式.

地址无关代码的实现

上面在生成可执行文件Program1的时候除了shared参数还用到了-fPIC。fPIC就是表示生成地址无关代码的。

上面虽然装载时重定位解决了动态模块中有绝对地址引用的情况,但是指令部分还是无法在多个进程之间共享。

归根结底希望程序模块中共享的指令部分在装载时不需要因为装载地址改变而改变。

思路就是把这些指令按照需要被修改的部分剥离处理,需要修改数据部分放在一起,不修改的指令放一起。这里指令部分就保持不变了,数据部分就可以在每个进程中有一个副本。这就是地址无关代码(PIC Positon-independent Code)。

按照两个维度分析共享对象模块。一个是地址引用方式,分为模块内、模块外;一种是引用方式,分为指令引用和数据访问。一共就4中情况。

模块间的指令引用和数据访问会通过一张全局偏移表(GOT Global Offset Table)来实现,这个表里面建立了指向这些变量或者指令的指针数组。需要用到这些变量的时候,从这个表中去查找


模块内的指令引用和访问数据则会根据偏移计算出来。


遗留问题

共享模块全局变量

定义在模块内的全局变量?当一个模块医用了一个定义在全局变量的时候,编译器无法判断这个变量在定义同一模块还是定义在另一个共享对象之中

如果是可执行文件的一部分,则程序主模块就不是地址五官代码,不会使用PIC机制。在连接的时候就会重定位。具体过程之前介绍过,会在bbs段创建一个改变量的副本。

如果是共享库,还是按照刚才那种方式,因为bbs段在共享库中只有一份副本,因为是全局共享,那么一个变量同时存在多个位置,肯定不行的。

解决办法就是将所有使用这个变量的指令都指向位于可执行文件中的那个副本。ELF共享库在编译时,默认把定义在模块内部的全局变量当作定义在其他模块的全局变量,如果某个全局变量在可执行文件中有副本,那么动听库就会把GOT中相应地址指向该副本。这样变量在运行时就只有一个实例了。

总结一下:如果变量在共享模块中被初始化,则动态链接器会将改初始化值复制到程序主模块中的变量副本;如果变量在主模块中没有副本,那么GOT中就直接指向模块内部的改变量副本。

特别注意共享库的数据段在每个进程中都有独立的副本,所以不同进程之间的全局共享变量不会彼此影响。

数据段地址无关性

始终记住,数据端在每个进程中都有一份独立的副本,所以并不会担心因为进程而改变。可以选择在装载时重定位的方法来解决数据端中绝对地址应用问题。

对于共享对象来讲,如果数据段有绝对地址的引用,那么链接器就会产生一个重定位表。这个重定位表里面包含了R_386_RELATIVE类型的重定位入口。当在装载的时候发现该共吸纳过对象有这样的重定位入口,动态链接器就会对该共享对象进行重定位。

其实代码段也可以使用这种装载重定位,但是这样就不是地址无关了,就会造成多个副本,不能多个进程之间共享,于是就失去了节省内存的特点,因为没访问全局变量和函数的时候都需要做一次计算当期那地址及简介地址寻址的过程。。所以说地址无关根本目的是在于共享,在于节省内存。

延迟绑定

动态链接链接很多优势,但是相对之下性能比静态库要差一些。

主要原因是动态链接对群架和静态数据的访问都需要复杂的GOT定位,然后间接寻址,对于模块间的调用也要先GOT定位,再进行跳转,所以程序运行的速度回慢一些。而且在启动的时候动态链接器需要进行一次链接工作进行符号查找及重定位,所以启动速度也会慢下俩。

优化方式:用懒加载的方式,也就是函数第一次用到才进行绑定(符号查找,重定位等),如果没有用到则不进行绑定。

ELF使用PLT(Procedure Linkage Table)的方式实现,是一写很精巧的汇编指令实现。也是通过一个表来保存需要跳转的信息。

PLT基本原理:ELF将GOT拆为两个表.got.got.plt。其中.got用来保存全局变量的引用地址,.got.ptl用来保存函数引用的地址,所有对于外部函数的引用全部放到.got.plt中。.got.plt的前三项有特殊含义。

  • 第一项保存的是.dynamic段的地址,这个段秒速了本模块动态链接的相关信息
  • 第二项保存的是本模块的ID
  • 第三项保存的是_dl_runtime_reslove的地址

第二项和第三项由动态链接器在装载共享模块的时候初始化。

动态链接相关结构

在动态链接情况下,操作系统不能再装载完可执行文件之后就把控制权交给可执行文件,因为可执行文件可能依赖很多共享对象,里面的很多外部符号还是无效地址,还没有跟相应的共享对象实际位置链接起来。在映射玩可执行文件之后,操作系统会先启动一个动态链接器。

操作系统将控制权交给动态链接器的入口,开始执行一系列自身的初始化操作,然后根据当前的环境参数对可执行文件进行动态链接工作,所有链接操作完成之后,动态链接器将控制权限转交给可执行文件的入口地址,程序开始执行。

interp

动态链接器不是由系统配置的,而是由ELF文件自己决定的,在动态链接的ELF可执行文件中,有一个专门的段.interp段(解释器)。保存的就是动态链接器的路径。

.dynamic

这个段里面保存了动态链接器的基本所需要的信息,比如依赖于哪些共享对象,动态连接符号表的位置,动态链接重定位表的位置,共享对象初始化代码的地址段。

typedef struct {
  Elf32_Sword d_tag;
  union {
    Elf32_Word d_val;
    Elf32_Addr d_ptr;
  } d_un;
} Elf32_Dyn;
extern Elf32_Dyn _DYNAMIC[];

字段的说明

动态符号表(dynsym)

类比静态链接中的符号表.symtab,里面保持了所有关与改目标文件的符号定义和引用。动态链接的符号表和静态链接非常相似。

这个段的段名叫.dynsym,简称动态符号表。只保存了与动态链接相关的符号。很多动态链接模块同时又dynsymsymtab两个表,后者包含了所有符号包含了dynsym中的符号。同样也有一些辅助表,比如字符串表.strtab,这里叫做.dynstr

动态链接重定位表

动态链接的可执行文件使用PIC方法,虽然其代码段不需要重定位(因为地址无关),但是数据端还是包含了绝对地址的引用,因为代码段中绝对地址相关部分被分离了出来,编程了GOT(全局偏移表),而GOT实际上是数据端的一部分,除了GOT,数据端还可以能包含绝对地址引用。

重定位相关数据结构

和静态链接类似,动态链接重定位表分为.rel.dyn.rel.plt他们分别相当于.rel.text.rel.data.rel.dyn是对数据的修真,位于.got段,.rel.plt是对函数的修正位于.got.plt段。

堆栈信息初始化

进程初始化的时候,堆栈里面保持了关于进程执行的环境何明亮行参数 等信息,还保持了动态链接所需要的一些辅助信息数据,辅助信息的格式是一个结构体数组。

其中的字段含义


辅助信息数组位于环境变量指针的后面。假设操作系统传递给动态链接器的辅助信息有4个



那么堆栈信息如下


装载共享对象

动态链接基本上分为3步:

  • 显示启动链接器本生
  • 装载所需要的共享对象
  • 重定位和初始化

动态链接器本身也是个共享对象,如果按照上面的逻辑,需要一个另一个动态链接库来链接这个库。为了解决这个问题动态链接库比较特殊,首先动态链接器本身不会依赖其他任何共享对象,其次他的全局和静态变量的重定位工作由自身完成。——具有一定限制条件的启动代码往往被称作为自举(BootStrap)

装载共享对象

完成自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个全局符号表中。然后连机器开始寻找可执行文件所依赖的共享对象。

.dynamic段中有一种类型入口为DT_NEEDED,它所指出是可执行文件所依赖的共享对象。然后将这些共享对象的名字放入到一个装载集合中。然后在链接器在一个一个取出里面的共享对门名字,找到对应的文件,读取ELF文件头和.dynamic段内容,将数据端、代码段映射到进程空间。如果还有依赖的共享库,则一个一个遍历,重复这个过程。

整个过程可以看做是一个广度优先的遍历过程。

符号优先级

如果两个共享对象都定义了同一符号,会出现什么情况?如果在静态库中如果有相同的符号根本就不会链接成功。

在Linux中的动态链接器,它定义了一个规则,当一个符号需要加入全局符号表时,如果相同名字的符号已经存在,则后加入的符号会被忽略。从动态链接器加载的顺序可以看到,是按照广度优先的顺序就行装载的.

由于存在这种直接忽略符号的现象,所以当程序使用大量的共享对象对象的时候,需要非常小心重名的问题。名字相同的符号,却执行了不同的功能,那么程序运行出现莫名其妙的问题。

总结

总算是动态链接部分介绍完了。有好多好多细节的地方没介绍,比如根据一个实例来计算符号偏移地址。感觉内容太多了。这里只是把动态链接主要部分介绍了下。

动态链接相对于静态链接过程不同之处有很多。重点提一下符号重定位。动态链接中对于不变的代码段是通过抵制无关代码实现的(PLT),里面涉及到一个全局偏移表(GOT),里面记录指令对应的地址。而对于需要改变的数据段是通过重定向来实现的,根据重定向表实现,这点和静态链接类似。

推荐阅读更多精彩内容