静态库是一组二进制的代码, 在编译的时候, 我们引用的静态库函数会直接复制到我们的程序之中. 动态库也是一组二进制代码, 但是编译的时候我们的程序并不包含相关的代码, 而是在运行的时候动态的去获得需要的代码.

本文介绍动态链接的原理以及在各个平台上创建和使用动态链接库的方法. 附带地, 本文也会涉及静态库的原理以及如何创建和使用静态库.

静态链接过程

静态链接过程的核心是将多个代码片段合并为一个代码片段, 并且适当的重写其中的符号引用地址. 由于最终的文件包含所有的代码, 因此所以的符号最终的位置都是可以确定的, 只需要通过适当的计算即可得到对应的地址.

在普通的文件链接过程中, 文件中符号出现的先后顺序并不会影响链接的逻辑. 但在静态库的链接过程中, 如果静态库中的一个符号没有被引用, 则该符号会直接被抛弃.

因此通常需要将静态库放置待链接列表的最后. 在必要时可能还需要多次重复一个静态链接库.

动态链接库的加载

使用动态链接库的程序相比于静态链接的程序, 由于有动态链接的内容, 因此显然并不能像静态链接的程序一样直接复制text段和data段带内存中即可执行. 假设当前的用户程序A动态链接了一个库lib, 则在启动A的过程中需要执行如下的一些操作:

  1. 加载A的代码和数据到内存之中
  2. 根据A中描述的动态链接库列表, 使用动态链接器加载lib的代码和数据到内存之中
  3. 重写A中关于lib的符号, 使其指向lib的实际地址

之后使用了动态链接库的的用户程序A就可以像静态链接的程序一样运行了. 而且通过虚拟内存技术可以使得动态链接器的代码段在不同的进程中共享, 从而减少应用程序的体积, 减少运行时的内存消耗.

与位置无关代码

动态链接库和静态链接库的一个重要区别就是动态链接库的代码要求是位置无关代码(Position Independent Code, PIC). 这主要有两个原因

  1. 动态链接库中的代码不和用户代码合并到一个文件之中, 因此不能知道最终会被加载到内存的那个位置, 不能假定绝对地址
  2. 动态链接库中的代码可能被多个进程共享, 因此不能保证总是加载到同一个内存位置

即使使用了虚拟地址技术, 也不能假定一定加载到某个位置, 否则在位置分配上容易产生空间浪费或者冲突等问题, 带来管理上的负担.

动态链接库中需要处理位置的地方主要是对全局变量和外部函数的访问. 对于模块内部的变量和函数, 显然在编译过程中就可以确定相对位置了, 只有外部的变量和函数由于也可能是动态加载的而不确定位置.

为了实现对其他模块的符号的访问, 动态链接库的代码中再data段的开头设置了一个表格(Global Offset Table, GOT), 表格中记录了外部符号的绝对地址. 其中的绝对地址在程序加载的时候由动态链接器更新. 而在代码中所有需要访问外部符号的地方, 都改为先访问GOT中的条目, 再通过其中的记录得到对应的符号.

由于库中的代码和GOT表的位置是可以编译时确定的, 因此只需要在代码中设置对应的偏移地址即可. 从而保证了动态链接库无论加载到那个位置都只需要修改GOT, 而不需要修改代码中的引用.

1
2
mov 0x2008b9(%rip), %rax  ; rax =  GDT[3], 当前指令与其距离为 0x2008b9
addl $0x1, (%rax)

对于动态链接库和静态链接库, 可以看到两者本质上都是解决重定位问题, 只不过静态链接库因为具有所有的代码, 可以在链接过程中直接完成计算, 而动态链接库在程序加载以后才具有对应的代码, 才完成对应的计算.

动态链接库方法的延迟绑定

对于动态链接库中的变量, 在加载的时候就完成GOT表的计算, 实现绑定效果. 对于函数, 动态链接库并没有采用同样的方式, 而是采取了延迟绑定的方法.

延迟绑定的核心还是利用GOT表, 代码中直接call GOT表对应条目中存储的地址. 当此函数是第一次调用的时候, GOT表格中的地址指向启动动态链接器的代码, 使得动态链接器加载对应的代码, 并修改GOT中的对应条目, 使其直接指向加载的函数. 最后动态链接器将控制权转移到对应的函数.

第二次访问时, 再次call GOT表对应条目中存储的地址时, 就可以直接跳转到对应的函数.

相比于变量的两次访问, 通过延迟加载可以使的方法调用除了第一次需要额外逻辑后只需要一次额外的GOT表访问.

Linux下的创建和使用静态库

Linux平台下可以使用gcc创建静态库和动态库. 创建两种库都只需要设置不同的编译参数, 对于源代码不需要做任何的调整, 因此相比于在Windows平台编译, 相对来说更加简洁.

在Linux平台上, 静态库以.a结尾, 而动态库以.so结尾. 需要将一个文件编译为静态库时, 只需要执行

1
2
gcc -c stack/stack.c stack/push.c stack/pop.c stack/is_empty.c
ar rs libstack.a stack.o push.o pop.o is_empty.o

按照约定, 静态库应该以lib开头, 以.a结尾, 使用ar指令将.o文件压缩为对应的静态库文件.


当需要使用静态库时, 使用如下的参数指定

1
gcc main.c -L. -lstack -Istack -o main

其中-L.表示除了去规定的目录寻找库文件以外, 还需要在当前路径寻找库文件. -lstack表示需要连接libstack.a文件.

Linux下创建和使用动态库

对于动态库, 使用如下的编译参数

1
gcc -shared -fpic -o libvector.so addvec.c mulvec.c

其中-fpic表示生成与位置无关的代码, -shared表示生成动态库(share object).


当需要使用动态库时, 使用如下的参数

1
gcc main.c ./libvector.so

执行上述操作并不会将动态库复制到最终的目标文件之中, 在运行时需要将目标文件和动态库放到一起执行.

Windows下的创建方法

在Windows下, 对于静态库, 通常只需要发布一个.lib文件. 其中就包含了全部的代码. 而对于动态库, 则需要同时发布.lib文件和.dll文件. 其中.lib文件仅包含一些链接的信息, 仅在程序的链接阶段使用. 而.dll文件包含实际的代码, 在程序运行的时候使用.

如果使用Visual Studio进行开发, VS在创建Win32项目时, 可以直接选择创建DLL或者静态库. 选择相应的项目后直接编译就可以产生相应的库文件.

其中的一些细节可以参考一下的文章

库打桩机制

打桩是指将特定的函数调用替换为指定的函数, 在指定的函数中可以对函数的数据和输出进行记录并调用原有的函数, 或者将实现替换为完全不同的逻辑.

打桩操作可以在编译时进行(替换目标函数), 链接时进行(替换符号地址)和运行时进行(修改搜索路径使得链接器加载特定的动态库). 前两种方式需要源码或者目标文件, 不便于操作. 运行时修改仅需要最终的应用程序, 因此更方便使用.

在Linux平台, 通过设置LD_PRELOAD变量实现指定的动态库优先加载, 例如

1
LD_PRELOAD="./mymalloc.so" ./a.out

Java调用C++

本节介绍如何通过Java调用C++编译出来的DLL. 步骤如下:

  1. 创建Java类, 使用native关键字声明相关的函数
  2. 编译Java文件, 产生class文件
  3. 使用JDK中的javah工具指定相应的class文件来产生需要的头文件
  4. 使用VS创建DLL项目, 引入第三步产生的头文件,并且导入JDK中的jni.h和jni_md.h
  5. 根据头文件实现相应的函数, 然后编译C++程序, 产生相应的DLL
  6. 将编译产生的DLL放置到PATH变量包含的路径之中
  7. Java中使用loadLibrary加载DLL

更细致的步骤可以参考以下的内容

最后更新: 2024年07月17日 13:33

版权声明:本文为原创文章,转载请注明出处

原始链接: https://lizec.top/2019/01/21/%E5%8A%A8%E6%80%81%E9%93%BE%E6%8E%A5%E5%8E%9F%E7%90%86%E5%92%8C%E4%BD%BF%E7%94%A8%E6%96%B9%E6%B3%95/