点击小眼睛开启蜘蛛网特效

咱不知道的动态链接库小细节

《咱不知道的动态链接库小细节》

前言

今儿个聊聊动态链接库

动态链接库(又简称动态库)是很多工程项目中不可缺少的一部分。俗称.so文件(姑且就以linux系统为例,在windows中称为dll,在mac中为的dylib),在平时的使用中我们对其察觉可能并不是很深,但其实我们玩电脑的时候无时不刻在使用动态链接库

老潘平时工作中也经常遇到动态链接库,被坑过几次,故总结了一篇防坑指南,分享给大家。

《咱不知道的动态链接库小细节》

举个栗子,window系统中很多系统应用大量使用了dll,游戏中也有很多dll文件。在运行window程序或打游戏的时候如果丢失需要的dll文件可是要出大问题:

《咱不知道的动态链接库小细节》

遇到这个错误提示咋办,在网上搜一个下载下来放到指定位置就可以顺利加载游戏了。其实这个所谓的dx11.dll就是动态链接库,显卡驱动在运行的时候需要加载这个动态链接库,当找不到的时候,当然会报错了…

感受到被动态库支配的恐惧了么。

借用《深入理解计算机系统第3版》中的话来说:

共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程叫做动态链接,是由一个叫做动态链接器的程序来执行的。在Linux系统中通常用.so后缀来表示,在Windows系统中,用.dll后缀来表示,叫做动态链接库。

注意:阅读此文建议对动态链接库有一定的了解和使用经验,本文不是对动态链接库的介绍,有兴趣或者想深入动态链接库细节的可以参阅《深入理解计算机原理》这本书(文末有分享链接)。

本文主要总结平时应用中动态链接库平时注意不到的小细节,如果疑问和其他见解欢迎拍砖头。

《咱不知道的动态链接库小细节》

阅读完本文可以让你少踩巨多坑。

动态链接库和头文件之前的联系

先一句话总结,在编译过程中,链接动态库的时候需要其头文件,而在运行的时候就不需要了。

为什么?让我们回顾下头文件是干嘛的,头文件存在的意义就是告诉编译器这个函数名称或者变量名称(存在于符号表中)在其他.cpp文件中存在,编译器可以根据头文件中的声明信息,去其他.cpp文件中找到具体的函数定义。因此在编译的时候需要头文件告诉我们函数的名称,也就是说编译器需要知道这个函数叫啥,根据名字在符号表中寻找。

不详说啦,可以看这篇文章来具体了解:C++中头文件与源文件的作用详解

写一个小的程序测试下,fun.cppfun.h用于产出动态链接库。

// fun.cpp
#include <iostream>
#include "fun.h"

using namespace std;

int have_fun(){

    cout << "have_fun friends!" << endl;
    return 0;
}

fun.h

// fun.h
#ifndef HAVE_FUN_H
#define HAVE_FUN_H

int have_fun();

#endif

写好之后放到一个目录中,执行下面命令进行编译,然后我们得到了libfun.so,这个是动态链接库!

gcc --shared -fPIC -o libfun.so fun.cc

然后写一个调用libfun.so的小程序main.cpp,放到和fun.cpp同一个目录中:

#include <iostream>
#include "fun.h"

int main(){

    have_fun();
    return 0;
}

然后编译,通过-L./ -lfun链接到这个libfun.so,是可以成功编译的:

g++ main.cc -L./ -lfun 

编译后此时目录中的文件有这些:

a.out  fun.cc  fun.h  libfun.so  main.cc

假如我们把fun.h从目录中移除,再执行g++ main.cc -L./ -lfun则会编译失败:

main.cc:2:17: fatal error: fun.h: No such file or directory
 #include "fun.h"

但是拿之前成功编译产出的可执行文件./a.out去运行,就不再需要fun.h了,即使我们把fun.h删除了也没关系,因为编译程序在链接的时候通过.h文件提供的声明信息找到了函数的具体位置。

了解了头文件的作用,其实不需要头文件也是可以的。只需要把fun.h的内容粘贴到main.cpp原先写#include "fun.h"的位置:

#include <iostream>
// #include "fun.h" 注释掉

int have_fun(); // 从 fun.h 挪到这里

int main(){

    have_fun();

    return 0;
}

也是可以顺利编译链接滴。

小小补充一下:

在Windows系统下的执行文件格式是PE格式,动态库需要一个DllMain函数做出初始化的入口,通常在导出函数的声明时需要有_declspec(dllexport)关键字。Linux下gcc编译的执行文件默认是ELF格式,不需要初始化入口,亦不需要函数做特别的声明,编写比较方便。

因此在上述fun.h中,其声明导出的have_fun函数前面啥也没有加。

动态链接库在寻找的时候有没有顺序

当然是有顺序的!

在linux中,程序运行的时候会通过LD_LIBRARY_PATH这个环境变量寻找除了默认路径之外的其他路径的动态链接库,默认路径就是类似于/usr/lib这种的在系统库中的动态链接库文件。如果我们需要的这个动态链接库系统目录里头没有,而我们现在有的动态链接库放不进去(没有root权限),就需要自己设定这个库文件的路径。

例如

export LD_LIBRARY_PATH=path/to/lib/:$LD_LIBRARY_PATH

然后你的程序就会按照这个路径找需要的动态库。

但要注意,这个路径是可以被覆盖的,如果之后的LD_LIBRARY_PATH地址包含了之前地址的动态库,则之后的动态库就覆盖之前的动态库!

补充下动态链接库的寻找顺序:

  • 1.编译目标代码时指定的动态库搜索路径;
  • 2.环境变量LD_LIBRARY_PATH指定的动态库搜索路径;
  • 3.配置文件/etc/ld.so.conf中指定的动态库搜索路径;
  • 4.默认的动态库搜索路径/lib和/usr/lib;

可以通过ldd命令查看当前的可执行文件或者动态链接库所需要动态链接库的位置,例如我们刚才编译好的./a.out

ldd a.out

linux-vdso.so.1 =>  (0x00007ffefd9da000)
libfun.so (0x00007f4610c80000)  
libstdc++.so.6 => gcc-5/lib/libstdc++.so.6 (0x00007f46108f2000)
libm.so.6 => /lib64/libm.so.6 (0x00007f46105e6000)
libgcc_s.so.1 => gcc-5/lib/libgcc_s.so.1 (0x00007f46103d0000)
libc.so.6 => /lib64/libc.so.6 (0x00007f461000e000)
/lib64/ld-linux-x86-64.so.2 (0x00007f4610e83000)

可以看到libfun.so (0x00007f4610c80000)在当前路径下存在。

然后我们mv libfun.so libfun.sso,替换libfun.so的名字,会有libfun.so => not found:

mv libfun.so libfun.sso

ldd a.out 
    linux-vdso.so.1 =>  (0x00007ffd045fe000)
    libfun.so => not found
    libstdc++.so.6 => /data/yanzong/software/gcc-5/lib/libstdc++.so.6 (0x00007fa4b251b000)
    libm.so.6 => /lib64/libm.so.6 (0x00007fa4b2219000)
    libgcc_s.so.1 => /data/yanzong/software/gcc-5/lib/libgcc_s.so.1 (0x00007fa4b2003000)
    libc.so.6 => /lib64/libc.so.6 (0x00007fa4b1c41000)
    /lib64/ld-linux-x86-64.so.2 (0x00007fa4b28b4000)

同样,如果我们把之前设好的LD_LIBRARY_PATH重置掉,./a.out也会无法正常运行。

$ unset LD_LIBRARY_PATH
$ ./a.out 
./a.out: error while loading shared libraries: libfun.so: cannot open shared object file: No such file or directory

把静态链接库编译到动态链接库中

完全没有问题,其实不管静态还是动态链接库都是一堆代码和数据(code and data)的集合体罢了。只不过两者的使用方式和情况有所不同。

那么什么情况下需要把静态库编译到动态库中呢?举个例子:

假如你有一个库A中的一些图像处理代码是由2.x版本OpenCV实现的,而库B中的一些图像处理代码是由3.X版本的OpenCV实现的,然后你的可执行文件C同时需要调用库A和库B中的代码,放到一起可能会版本冲突,咋办。

最简单的办法是,将不同版本的OpenCV静态库直接编译到库A和库B中,然后库A和库B编译成两个动态链接库供你的可执行文件C使用。

怎么编译库A?

很简单,我们需要在gcc中首先指定需要的opencv库,比如我们需要添加了opencv_imgprocopencv_core,记住-L是指定动态链接库地址,-l(小写L)是指定需要编译进去的静态库(或动态库),而-I(大写i)是指定头文件的路径:

将需要的.cpp直接编译成动态链接库A.so即可。

g++ -o A.so -shared -fPIC *.cpp -L /path/to/opencvlib -I /path/to/opencv/include -l opencv_core -l opencv_imgproc --std=c++11

这样编译出来的.so包含了opencv的两个.a,也包含了我们库A中的一些逻辑代码。

等等,我们还需要将外部暴露的代码前面加上__attribute__ ((visibility ("default"))),然后gcc编译的时候加上-fvisibility=hidden,就可以将其他未标记的函数都隐藏了。同理B也是这样。

CMAKE中也有相应的命令实现类似的功能。

cmake中的命令

截取一段triton-servercmake的代码,重点看target_include_directories指令中的PRIVATE关键词。

add_library(
  backend_utils STATIC
  backend_utils.cc 
)
if(${TRITON_ENABLE_GPU})
  target_include_directories(backend_utils PRIVATE ${CUDA_INCLUDE_DIRS})
endif() # TRITON_ENABLE_GPU

install(
  TARGETS
  backend_utils
  ARCHIVE DESTINATION backends
  LIBRARY DESTINATION backends
)

以上的CMAKE命令适用于这种情况:

只在hello_world.c中包含了hello.hlibhello-world.so对外的头文件——hello_world.h中不包含hello.h。而且main.c不会调用hello.c中的函数,或者说 main.c不知道hello.c的存在,那么在hello-world/CMakeLists.txt中应该写入target_link_libraries(hello-world PRIVATE hello) target_include_directories(hello-world PRIVATE hello)

因此上述triton-server通过PRIVATE参数,禁止backend_utils调用的${CUDA_INCLUDE_DIRS}暴露在外头,有效避免一些冲突问题。

关于外部隐藏,知乎有一篇讲的也不错,有兴趣的可以看看:

加载动态链接库的两种方式

动态链接库两种加载方式:

  • 显式链接
  • 隐式链接

一种叫load time dynamic linking,就是说你的代码里面已经直接调用了库里面的函数,那么在link的时候会把该库的一小段lib link进去(而Linux上直接链接.so即可),这里面包含了这个DLL的相关信息以便在真正运行时能找到那个dll。然后当你exe运行时,windows就会根据那些信息把需要用到的dll载入内存。

另一种叫run time dynamic linking。在编译以及link的时候是并不需要提供这个库的信息的。在程序真正运行的时候,通过自己调用LoadLibrary,GetProcAddress等API手工把DLL载入内存并找到里面的函数来调用。

隐式链接比较常用,我们在编译项目的时候,如果需要其他库的.so。举个例子,比如利用OpenCV实现图像读取,此时就需要链接OpenCV的动态链接库(链接的时候需要这个.so)。链接后编译完成之后,当这个程序运行的时候.so也是要必须在场的(在linux中,例如用LD_LIBRARY_PATH环境变量设置动态链接库的查找地址),要不然程序会找不到这个.so而无法运行。

通过隐式链接引用动态链接库,在程序跑的时候将其所需要的链接库替换一个新版的(cp大法好),就会引发程序崩溃,所以还是小心点不要动它。

而显式链接,编译的时候一般不需要.so,在程序运行的时候可以动态加载或卸载.so。而且在加载之后,如果把.so删除或者替换,不会影响程序使用的.so,除非执行程序主动卸载旧的.so加载新的.so不过这个过程是已知的,不会导致程序中断。

显式链接具体长啥样,我们可以瞅个例子:

直接看一段源码(来源于triton-inference-server)

Status
TritonBackend::LoadBackendLibrary()
{
  void* handle = dlopen(path_.c_str(), RTLD_LAZY);
  if (handle == nullptr) {
    return Status(
        Status::Code::NOT_FOUND,
        "unable to load backend library: " + std::string(dlerror()));
  }

  TritonBackendInitFn_t bifn;
  TritonBackendFiniFn_t bffn;
  TritonModelInitFn_t mifn;
  TritonModelFiniFn_t mffn;
  TritonModelInstanceInitFn_t iifn;
  TritonModelInstanceFiniFn_t iffn;
  TritonModelInstanceExecFn_t iefn;

  // Backend initialize and finalize functions, optional
  RETURN_IF_ERROR(GetEntrypoint(
      handle, "TRITONBACKEND_Initialize", true /* optional */,
      reinterpret_cast<void**>(&bifn)));
  RETURN_IF_ERROR(GetEntrypoint(
      handle, "TRITONBACKEND_Finalize", true /* optional */,
      reinterpret_cast<void**>(&bffn)));

  // Model initialize and finalize functions, optional
  RETURN_IF_ERROR(GetEntrypoint(
      handle, "TRITONBACKEND_ModelInitialize", true /* optional */,
      reinterpret_cast<void**>(&mifn)));
  RETURN_IF_ERROR(GetEntrypoint(
      handle, "TRITONBACKEND_ModelFinalize", true /* optional */,
      reinterpret_cast<void**>(&mffn)));

  // Model instance initialize and finalize functions, optional
  RETURN_IF_ERROR(GetEntrypoint(
      handle, "TRITONBACKEND_ModelInstanceInitialize", true /* optional */,
      reinterpret_cast<void**>(&iifn)));
  RETURN_IF_ERROR(GetEntrypoint(
      handle, "TRITONBACKEND_ModelInstanceFinalize", true /* optional */,
      reinterpret_cast<void**>(&iffn)));

  // Model instance execute function, required
  RETURN_IF_ERROR(GetEntrypoint(
      handle, "TRITONBACKEND_ModelInstanceExecute", false /* optional */,
      reinterpret_cast<void**>(&iefn)));

  dlhandle_ = handle;
  backend_init_fn_ = bifn;
  backend_fini_fn_ = bffn;
  model_init_fn_ = mifn;
  model_fini_fn_ = mffn;
  inst_init_fn_ = iifn;
  inst_fini_fn_ = iffn;
  inst_exec_fn_ = iefn;

  return Status::Success;
}

可以发现显式链接还有个好处,那就是不需要头文件,为啥,因为你已经在代码里指明函数的入口了~

被其他程序占用的动态库是否可以替换

先放出结论,是可以的兄die,而且没有任何警告!

没有任何的警告,覆盖一直爽,一直覆盖一直爽,但是后果就要自个儿承担了

linux系统中其实已经提供了很多种保护机制,当一个可执行文件fun正在运行时,我们是无法覆盖掉这个可执行文件的,linux系统会提示:

cp: cannot create regular file ‘fun’: Text file busy

上述使用cp操作来替换之前的可执行文件,显然是失败了。

但是如果我们使用mv或者cp命令去替换动态库,是可以随便替换的,没有任何警告。

对于隐式链接来说,如果链接库突然被替换了,那个这个程序会在下一次执行的时候奔溃(系统会同步这个动态库),对于显示链接来说(已经加载到内存中),这个操作是没啥影响的。

不同版本的动态链接库是否可以直接使用

只要是你需要的功能函数在这个低版本中存在,那就可以使用。

还是举个例子吧。

我们从官方下载的TensorRT-7.0.0.11.Ubuntu-16.04.x86_64-gnu.cuda-10.2.cudnn7.6.tar依赖libcudnn.so.7.6.0。但我们使用libcudnn.so.7.3.0去跑这个TensorRT去做一些事情时,因为版本不一致就会报错:

TensorRT-7.0.0.11/lib/libmyelin.so.1: undefined reference to `cudnnGetBatchNormalizationBackwardExWorkspaceSize@libcudnn.so.7'
TensorRT-7.0.0.11/lib/libmyelin.so.1: undefined reference to `cudnnGetBatchNormalizationForwardTrainingExWorkspaceSize@libcudnn.so.7'
TensorRT-7.0.0.11/lib/libmyelin.so.1: undefined reference to `cudnnGetBatchNormalizationTrainingExReserveSpaceSize@libcudnn.so.7'
TensorRT-7.0.0.11/lib/libmyelin.so.1: undefined reference to `cudnnBatchNormalizationBackwardEx@libcudnn.so.7'
TensorRT-7.0.0.11/lib/libmyelin.so.1: undefined reference to `cudnnBatchNormalizationForwardTrainingEx@libcudnn.so.7'

显然在链接TensorRT的时候,libmyelin.so.1这个东西需要的符号表在libcudnn.so.7.3.0这里找不到,因此也无法编译成功。

但是如果我们使用libcudnn.so.7.6.0将其编译好得到可执行文件,但跑这个程序时只给它提供libcudnn.so.7.3.0的运行环境。那么运行的时候会有
TensorRT was linked against cuDNN 7.6.3 but loaded cuDNN 7.3.0的提示,不过程序可以正常运行!

原因很简单,libcudnn.so.7.6.0所拥有的cudnnGetBatchNormalizationBackwardExWorkspaceSize虽然libcudnn.so.7.3.0没有,但是我们也不用它,所以程序可以正常运行而不会报错,但如果你需要这个函数调用这个函数那么就没有办法了。

通过strings命令观察可以看到libcudnn.so.7.3.0确实没有cudnnGetBatchNormalizationBackwardExWorkspaceSize这个函数实现:

strings /usr/local/cuda/lib64/libcudnn.so.7.3.0 | grep cudnnGetBatchNormalizationBackwardExWorkspaceSize
而7.6.5中有
strings /usr/local/cuda/lib64/libcudnn.so.7.6.5 | grep cudnnGetBatchNormalizationBackwardExWorkspaceSize
cudnnGetBatchNormalizationBackwardExWorkspaceSize
cudnnGetBatchNormalizationBackwardExWorkspaceSize

总结一句,动态链接库中的函数如果有定义、且功能符合你的预期,而且你知道函数名称以及需要传递的参数,那么就可以随意调用了。

动态链接库是黑盒吗?

动态链接库是黑盒吗?

不一定,我们可以通过一些命令以及一些反编译软件查看这个动态库的一些内部信息。

推荐ghidraRun,比较好用。

之后细说。

编译静态库的顺序

链接静态链接库的时候需要注意顺序特别重要!顺序特别重要!顺序特别重要!

被依赖静态库需要放在后面的位置。

后记

大概先写这么多,动态链接库相关的知识点还有很多,先总结一些,剩下的,以后慢慢聊。

提及到的书籍

还是推荐这两本书,有关编译链接等等基础的知识上面都讲到了,不论是工程还是面试都是必备的:

  • 《深入理解计算机原理》
  • 《程序员的自我修养》

公众号内回复”666“即可获取。

参考链接

  点赞
本篇文章采用 署名-非商业性使用-禁止演绎 4.0 国际 进行许可
转载请务必注明来源: https://oldpan.me/archives/something-about-so-we-dont-know

   关注Oldpan博客微信公众号,你最需要的及时推送给你。