静态链接的缺点:生成的可执行文件体积较大,当多个程序引用相同的公共代码时,这些公共代码会多次加载到内存,浪费内存资源。
为了解决这个问题,动态链接对静态链接做了一些优化:对一些公用的代码,如库,在链接期间暂不链接,而是推迟到程序运行时再进行链接。这些在程序运行时才参与链接的库被称为动态链接库。程序运行时,除了可执行文件,这些动态链接库也要跟着一起加载到内存,参与链接和重定位过程,否则程序可能就会报未定义错误,无法运行。
动态链接的好处是节省了内存资源:加载到内存的动态链接库可以被多个运行的程序共享,使用动态链接可以运行更大的程序、更多的程序,升级也更加简单方便。
在 Windows 下解压一个软件安装包,里面的 .dll 后缀的文件就是动态链接库,需要和可执行文件一起安装到系统中。程序在运行前会首先把它们加载到内存,链接成功后程序才能运行。
在 Linux 环境下,动态库文件的后缀为 .so。
gcc -fPIC -shared add.c sub.c mul.c div.c -o libtest.so
gcc main.c libtest.so
cp libtest.so /usr/lib/ # 程序就可以运行了
在上面的程序中,可执行文件 a.out 是动态链接生成的,所以在运行 a.out 之前,libtest.so 这个动态链接库要放到 /lib、/usr/lib 等系统默认的库路径下,否则 a.out 就会链接失败,无法正常运行。
在 Linux 环境下,当我们运行一个程序时,操作系统首先会给程序 fork 一个子进程,接着动态链接器被加载到内存,操作系统将控制权交给动态链接器,让动态链接器完成动态库的加载和重定位操作,最后跳转到要运行的程序。
动态链接器本身也是一个动态库(/lib/ld-linux.so文件),动态链接器被加载到内存后,会首先给自己重定位,然后才能运行。像这种自己给自己重定位然后自动运行的行为,一般称为自举。
动态链接器解析可执行文件中未确定的符号及需要链接的动态库信息,将对应的动态库加载到内存,并进行重定位操作。这个过程其实和静态链接的重定位过程一样,只不过推迟到了运行阶段而已。重定位结束后,程序中要引用的所有符号都有了地址和定位,动态链接器将控制权交给要执行的程序,跳转到该程序运行。
动态链接需要考虑的一个重要问题是加载地址。静态链接时,加载地址等于链接地址,这个地址是固定的。动态链接过程中,类似静态链接的重定位,动态链接库被加载到内存后,目标文件的起始地址也发生了变化,需要重定位。一个可执行文件对动态链接库的符号引用,要等动态链接库加载到内存后地址才能确定,然后对可执行文件中的这些符号修改即可。
main() 函数调用了 add() 函数,但 add() 函数的地址还不能确定,等到 libtest.so 加载到内存后,add() 函数的地址才能确定下来。加载器通过动态链接、重定位操作,更新了符号表中 add() 函数的实际地址,并修正 main() 函数指令中引用 add() 函数的地址,然后程序才可以正常运行。
这种装载时重定位的操作,虽然解决了可执行文件中绝对地址的引用问题,但也带来了另外一个问题:对于每个进程,动态库被加载到了内存的不同地址,也只能被进程自身共享,无法在多个进程间共享,无法节省内存,违背了动态库的设计初衷。如果有一种方法,将动态库设计成无论放到哪里,都可以执行,而且可以被多个进程共享,那么这个问题就迎刃而解了。
与地址无关的代码
如果想让我们的动态库放到内存的任何位置都可以运行,都可以被多个进程共享,一种比较好的方法是将我们的动态库设计成与地址无关的代码。将指令中需要修改的部分(如绝对地址符号的引用)分离出来,剩余的部分就和地址无关了。需要被修改的指令(符号)和数据在每个进程中都有一个副本,互不影响各自的运行。
编译代码时加上 -fPIC
参数(Position-Independent Code)就可以实现代码与地址无关:把这段代码放在内存中的任何位置,都无须重定位,直接运行即可(使用相对跳转指令代替对绝对地址的访问)。
全局偏移表
在动态库的设计中,对于模块内的符号相互引用,通过相对寻址很容易实现代码与地址无关。但是当动态库作为第三方模块被不同的应用程序引用时,库中的一些绝对地址符号(如函数名)将不可避免地被多次调用,需要重定位。动态库中的这些绝对地址符号,如何能做到同时被不同的应用程序引用呢?
每个应用程序将引用的动态库(绝对地址)符号收集起来,保存到一个表中,这个表用来记录各个引用符号的地址。当程序在运行过程中需要引用这些符号时,可以通过这个表查询各个符号的地址。这个表被称为全局偏移表(Global Offset Table,GOT)。
在一个可执行文件中,其引用的动态库中的绝对地址符号会被分离出来,单独保存到 GOT 表中,GOT 表以 section 的形式保存在可执行文件中,这个表的地址在编译阶段已经确定了。当程序运行需要引用动态库中的函数时,会将动态库加载到内存,根据动态库被加载到内存中的具体地址,更新 GOT 表中的各个符号(函数)的地址。等下次该符号被引用时,程序可以直接跳到 GOT 表查询该符号的地址,因为 GOT 表在可执行文件中的位置是固定不变的,所以程序中访问 GOT 表的指令也是固定不变的,唯一需要变化的是:动态库加载到内存后,库中的各个函数的位置确定,在 GOT 表中实时更新各个符号在内存中的真实地址就可以了。
这样做的好处是:在内存中只需要加载一份动态库,当不同的程序运行时,只要修改各自的 GOT 表,它们引用的符号都可以指向同一份动态库。
延迟绑定
动态连接通过使用与地址无关这一技术,加载到内存任意地址都可以运行。与地址无关这一技术在 ARM 平台可以使用相对寻址来实现。ARM 相对寻址的本质其实就是寄存器间接寻址,只不过基址换成了 PC 而已,访问效率还是比较低的,包括程序运行之前的动态链接和重定位操作,也会对程序的及时响应和性能造成一定的影响。可执行文件一般都采用延迟绑定:程序在运行时,并不急着把所有的动态库都加载到内存中并对它们进行重定位。当动态库中的函数第一次被调用时,才会把用到的动态库加载到内存并进行重定位。
C 标准库起始就是以动态库的封装形式保存在 Linux 系统中的,不同的应用程序都会调用 printf() 函数,当它们在内存中运行时,只需要加载一份 printf() 函数代码到内存就可以了。各个应用程序在引用 printf 这个符号时,就会启动链接器,将这份代码映射到各自进程的地址空间,更新各自 GOT 表中 printf() 函数的实际地址,然后通过查询 GOT 表找到 printf() 在内存中的实际地址,就可通过间接访问跳转执行。
共享库
现在大多数软件都是采用动态链接的方式开发的,不仅可以节省内存空间,升级维护也比较方便。在发布软件包时,可执行文件及其以来的动态链接共享库被一起打包发布,如果你依赖的是系统默认自带的共享库,如 C 标准库,则不需要跟软件一起打包。程序安装时,可执行文件会复制到 Linux 系统的默认路径下,如 /bin、/sbin、/usr/bin、/usr/local/bin 等,这些路径由环境变量 PATH 管理和维护。可执行文件依赖的共享库一般要放到库的默认路径下面:如 /lib、/usr/lib 等。当程序运行时,动态链接器首先被加载到内存运行,动态链接器会分析可执行文件,从可执行文件的 .dynamic
段中查询该程序运行需要依赖的动态共享库,然后到库的默认路径下查找这些共享库,加载到内存中并进行动态链接,链接成功后将 CPU 的控制权交给可执行程序,程序就可以正常运行了。
动态链接器在查找共享库的过程中,除了到系统默认的路径下查找,也会到用户指定的一些路径下去查找,用户可以在 /etc/ld.so.conf 文件中添加自己的共享库路径。为了减少每次查找文件的时间消耗,/etc/ld.so.conf 修改后,可以使用 ldconfig 命令生成一个缓存 /etc/ld.so.cache 以提高查找效率。每当我们新增、删除或修改共享库的路径时,使用 ldconfig 更新一下缓存就可以了。