链接动态库在不同操作系统上的行为

想必很多人已经了解了动态库与静态库,在实际开发中也经常使用. 但是,有必要了解在windows和Linux上开发c++程序生成和链接动态库的不同行为,因为经常混淆或者自以为找到了动态库,这里简单学习并澄清一下.其中许多内容来自官方文档

在linux上静态库常常以.a结尾,动态库以.so结尾,而windows上分别以.lib与.dll结尾. 于是很多人就把.dll等同于.so使用了,但其实并不一样.

编译与链接静态库

生成动态库

1
gcc -shared -fPIC -o libfoo.so foo.c

生成静态库

1
2
gcc -o libfoo.o foo.c -I include 
ar cr libfoo.a libfoo.o
选项作用
-ggdb此选项将尽可能的生成 gdb 的可以使用的调试信息.
-l [lib](这里是小写的L,命令无中括号,下同)指定程序要链接的库,[lib]为库文件名称.如果gcc编译选项中加入了“-static”表示寻找静态库文件
-L [dir]指定-l(小写-L)所使用到的库文件所在路径(链接时而非动态查找),不然编译器将只在标准库的目录找
-I [dir](这里是大写的I)增加 include 头文件路径
-static链接静态库生成目标文件,禁止使用动态库(在支持动态链接的系统上) 所以编译出来的东西一般都很大,也不需要什么动态连接库就可以运行.
-shared生成共享文件,可以与其它文件链接生成可执行文件
-fpic生成适用于共享库的与地址无关的代码(PIC)(如果机器支持的话)
-fPIC生成与位置无关的的代码,适用于使用动态库,与“-fpic”的区别在于去除去全局偏移表的任何限制(如果机器支持的话)

链接动态库和链接静态库差别不大,但是需要设置一些路径方便linux查找.

1
gcc -o main main.c -I/home/alice/foo -lfoo

如果静态库和动态库在同一目录并且前缀相同,e.g. libxx.so和libxx.a,使用g++ -o main main.cpp -L build/lib -l xx -I lib/include会默认链接动态库,添加-static可解决(不过一般也不会同时把动态库和静态库同名放一个目录吧😅)

gcc -L -l含义是什么,不管是动态库还是链接库,如果使用了库,都需要使用-L-l进行编译时链接,如果是静态库,-l往往就够了,但如果是动态库,-l作用是在编译时让编译器直到用到了库中的某些东西存在,但是运行时还需要另外设置,如果链接动态库不使用-l也会报错

image-20241004132437629

1
g++ -o main main.cpp   ./build/lib/libdy_lib.so -I lib/include

或者直接使用.so.cpp编译链接,注意这在windows上行不通,根本原因是动态库的路径搜索方式不同

文件一多,项目一大肯定需要使用构建系统的,包括make,Ninja,MSBuilg等等,而cmake就是生成这些构建系统的,当使用cmake时就没有太大必要考虑编译器细节了.

1
2
3
4
5
# 生成
add_library(dy_lib STATIC test.cpp)
# add_library(dy_lib SHARED test.cpp)
# 链接
target_link_libraries(cpp_test PRIVATE dy_lib)

链接动态库在cmake中的行为

RPATH在开发过程中很有用,因为可以将构建树中的库链接到可执行文件中.CMake提供了相当多的选项来优化构建树链接和安装链接期间的行为

我们知道要使用动态库,光是-l是不行的,在windows上需要看链接方式(下面详细介绍),在linux上也要设置动态库搜索路径. 使用cmake时链接动态库,cmake会默认设置buil_rpath,但安装时使用install_rpathBUILD_RPATH — CMake 3.30.4 Documentation

BUILD_PATH

一个分号分隔的列表,指定要添加到构建树中链接的二进制文件中的运行时路径(RPATH)条目(对于支持它的平台).默认情况下,CMake在构建树中设置二进制文件的运行时路径,以包含它知道需要查找它们链接的共享库的搜索路径.项目可以设置BUILD_RPATH来指定额外的搜索路径.

  • The CMAKE_SKIP_RPATH variable completely disables runtime paths in both the build tree and install tree.
  • The SKIP_BUILD_RPATH target property disables setting any runtime path in the build tree.
  • The BUILD_RPATH_USE_ORIGIN target property causes the automatically-generated runtime path to use entries relative to $ORIGIN.
  • The BUILD_WITH_INSTALL_RPATH target property causes binaries in the build tree to be built with the install-tree runtime path.

下面是默认设置.默认情况下,如果不更改任何 RPATH 相关设置,CMake 将以完整的 RPATH 连接可执行文件和共享库,并将其连接到联编树中所有使用过的库.安装时,它会清除这些目标的 RPATH,因此它们在安装时的 RPATH 为空

1
2
3
4
5
6
7
8
9
10
11
12
13
# use, i.e. don't skip the full RPATH for the build tree
set(CMAKE_SKIP_BUILD_RPATH FALSE)

# when building, don't use the install RPATH already
# (but later on when installing)
set(CMAKE_BUILD_WITH_INSTALL_RPATH FALSE)

# the RPATH to be used when installing
set(CMAKE_INSTALL_RPATH "")

# don't add the automatically determined parts of the RPATH
# which point to directories outside the build tree to the install RPATH
set(CMAKE_INSTALL_RPATH_USE_LINK_PATH FALSE)

也就是说cmake在build时默认添加在build目录下使用的动态库路径,在安装库时rpath默认为空

Linux

在Linux系统中,动态链接器(如ld-linux.so)负责在应用程序启动时解析其依赖的共享库.动态链接器根据一定的搜索顺序来查找这些共享库,这个顺序通常包括:

  1. RPATH:如果可执行文件中指定了RPATH,动态链接器会首先在这个路径下搜索共享库.
  2. LD_LIBRARY_PATH:如果未找到所需的库,动态链接器会继续在由环境变量LD_LIBRARY_PATH指定的目录中搜索.
  3. 配置文件/etc/ld.so.conf
  4. 系统默认路径:如果仍未找到,动态链接器会在系统默认的库路径(如/lib/usr/lib)中搜索.

理解这个搜索机制有助于我们更好地掌握如何通过调整RPATH来控制应用程序的动态链接行为

1
2
3
4
5
6
7
8
9
10
11
Unless loading object has RUNPATH:
RPATH of the loading object,
then the RPATH of its loader (unless it has a RUNPATH), ...,
until the end of the chain, which is either the executable
or an object loaded by dlopen
Unless executable has RUNPATH:
RPATH of the executable
LD_LIBRARY_PATH
RUNPATH of the loading object
ld.so.cache
default dirs

RPATH

rpath优先级最高,会优先让执行档去寻找相应的动态库(如果设置了RUNPATH就会忽略RPATHc - use RPATH but not RUNPATH? - Stack Overflow,简单来说,如果设置了RUN_PATH,那么RPATH会被忽略,但是RUNPATH优先级又低于LD_LIBRAY_PATH

作者给的建议是当您发布二进制文件时,要么使用RPATH而不是RUNPATH,要么确保在运行它们之前设置了LD_LIBRARY_PATH,当然也有推荐只使用LIBRARY_PATH的.

注意,runpath和rpath也许操作系统支持有关,新版本的os应该默认使用runpath了,也就是使用gcc -rpath时默认设置runpath

设置rpath,告诉新系统使用老行为

1
gcc ... -Wl --disable-new-dtags -rpath=""

设置runpath,告诉旧系统使用新行为

1
gcc ... -Wl --enable-new-dtags -rpath=""

查看一个elf文件的PATH,可以看到目前默认是runpath,rpath是depreacated了,这两者最大差异就是优先级

1
readelf --dynamic obj | grep PATH

image-20241004140806996

使用cmake开发时,默认就是这样使用动态库的

LIBRAY_PATH

LIBRAY_PATH不是运行时搜索动态库,其效果类似于gcc -L,设置编译时查找路径

1
2
3
4
export LIBRARY_PATH=/home/foo
gcc -o main main.c -I/home/foo -lfoo
ls
main main.c

推荐使用gcc -L即可

事实上实践中直接使用cmake

1
2
link_directories()
target_link_libraries()

LD_LIBRAY_PATH

你会发现在链接动态库后执行程序也无法成功,因为linux搜索动态库的路径并没有包括动态库的路径,道理同rpath

1
2
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/home/work
./main

/etc/ld.so.conf

将非标准路经加入 /etc/ld.so.conf,然后运行 ldconfig 生成 /etc/ld.so.cache. ld.so 加载共享库的时候,会从 ld.so.cache 查找

1
2
3
vim /etc/ld.so.conf
# 在文件中添加库路径 e.g. /project/build/libdy_lib.so
sudo ldconfig

原理是ldconfig这个程序,程序运行时会通过这个程序查找库.

默认搜索路径

可执行程序动态库默认搜索路径包括/usr/lib和/lib

1
2
3
4
cp libdy_lib.so /lib
# 以下命令均可
cp libdy_lib.so /usr/lib
ln -s libdy_lib.so /usr/lib

Windows

windows并没有类似linux的rpath机制dll - Is there a Windows/MSVC equivalent to the -rpath linker flag? - Stack Overflow

当你使用visual studio开发使用了动态库时,也许你在vs上执行并没有问题,但直接点击可执行程序执行就报错了(即使你给可执行程序添加了相关引用). 主要原因是vs在编译链接时会去引用生成的目录找相关.dll和.lib(即使是动态库,vs也会生成DLL导入库,这类似一个查找表,方便获取DLL中的函数、变量等)

而在运行时,动态库的查找机制就不一样了

链接时加载了.dll库,成功执行

链接方法

可执行文件通过以下两种方式之一链接到(或加载)DLL:

  • 隐式链接,其中操作系统会与使用 DLL 的可执行文件同时加载它. 客户端可执行文件调用 DLL 的导出函数的方式与函数进行静态链接并包含在可执行文件中时的方式相同. 隐式链接有时称为静态加载或加载时动态链接.
  • 显式链接,其中操作系统会在运行时按需加载 DLL. 通过显式链接使用 DLL 的可执行文件必须显式加载和卸载 DLL. 它还必须设置函数指针,用于访问它从 DLL 使用的每个函数. 与静态链接的库或隐式链接 DLL 中的函数调用不同,客户端可执行文件必须通过函数指针调用显式链接 DLL 中的导出函数. 显式链接有时称为动态加载或运行时动态链接.

隐式链接

当应用程序的代码调用导出 DLL 函数时,会进行隐式链接. 当编译或汇编调用可执行文件的源代码时,DLL 函数调用会在对象代码中生成外部函数引用.

若要解析此外部引用,应用程序必须与 DLL 创建者提供的导入库(.lib 文件)链接.

导入库包含的代码仅用于加载 DLL 和实现对 DLL 中函数的调用. 在导入库中查找外部函数会告知链接器该函数的代码处于 DLL 中. 若要解析对 DLL 的外部引用,链接器只需将信息添加到可执行文件,告知系统在进程启动时查找 DLL 代码的位置.

当系统启动包含动态链接引用的程序时,它将使用该程序可执行文件中的信息查找所需 DLL. 如果找不到 DLL,则系统将终止进程,并显示报告错误的对话框. 否则,系统会将 DLL 模块映射到进程地址空间中.

所以我们需要一个.lib文件方便静态加载,也就是程序在编译时就知道了动态库的位置(通过.lib),这样方便查找,而不是像上面提到的linux再通过rpath等路径去看. 那这样做需要什么呢? 那就是经典的__declspec(dllexport)dllexport、dllimport | Microsoft Learn

1
2
3
4
5
6
7
#define DllImport   __declspec( dllimport )
#define DllExport __declspec( dllexport )

DllExport void func();
DllExport int i = 10;
DllImport int j;
DllExport int n;

使用 dllexport 意味着定义,而使用 dllimport 则意味着声明. 必须使用带 externdllexport 关键字来强制进行声明;否则,会进行隐式定义.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static __declspec( dllimport ) int l; // Error; not declared extern.

void func() {
static __declspec( dllimport ) int s; // Error; not declared
// extern.
__declspec( dllimport ) int m; // Okay; this is a
// declaration.
__declspec( dllexport ) int n; // Error; implies external
// definition in local scope.
extern __declspec( dllimport ) int i; // Okay; this is a
// declaration.
extern __declspec( dllexport ) int k; // Okay; extern implies
// declaration.
__declspec( dllexport ) int x = 5; // Error; implies external
// definition in local scope.
}

当声明 dllexport 类时,它的所有成员函数和静态数据成员都会导出. 必须在同一程序中提供所有此类成员的定义. 否则,将生成链接器错误. 此规则有一个例外情况,即对于纯虚函数,无需为其提供显式定义. 但是,由于基类的析构函数始终在调用继承类的析构函数,因此纯虚析构函数必须始终提供定义

当声明 dllimport 类时,它的所有成员函数和静态数据成员都会导入. 与非类类型上的 dllimportdllexport 的行为不同,静态数据成员无法在定义 dllimport 类的同一程序中指定定义. 如果整个类都已导入或导出,则禁止将成员函数和数据显式声明为 dllimportdllexport

1
2
3
4
5
6
#define DllExport   __declspec( dllexport )

class DllExport C {
int i;
virtual int func( void ) { return 1; }
};
1
2
3
4
5
6
7
// lib_link_input_2.cpp
// compile by using: cl /EHsc lib_link_input_1.lib lib_link_input_2.cpp
__declspec(dllimport) int Test();
#include <iostream>
int main() {
std::cout << Test() << std::endl;
}

在windows上,这几乎成了常用的方式,不管你使用的什么编译器,即便是mingw,clang

如果使用vc++,那会生成xx.dll与libxx.lib,后者用于找动态库,而使用mingw,clang会生成xx.dll和xx.dll.a,效果一样.

若要通过隐式链接使用 DLL,客户端可执行文件必须从 DLL 的提供程序获取以下文件:

  • 一个或多个头文件(.h 文件),其中包含 DLL 中的导出数据、函数和 C++ 类的声明. DLL 导出的类、函数和数据全都必须在头文件中标记为 __declspec(dllimport)
  • 要链接到可执行文件中的导入库. 生成 DLL 时,链接器会创建导入库
  • 实际 DLL 文件.

我们在windows上默认都是使用的隐式链接,如果你要使用动态库,还挺麻烦的.

显式链接

有时需要显式链接. 下面是使用显式链接的一些常见原因:

  • 应用程序直到运行时才知道它所加载的 DLL 的名称. 例如,应用程序可能会在启动时从配置文件获取 DLL 的名称和导出函数.
  • 如果在使用隐式链接的进程启动时找不到 DLL,则操作系统会终止进程. 使用显式链接的进程在这种情况下不会终止,可以尝试从错误中恢复. 例如,进程可以向用户通知错误,并让用户指定 DLL 的其他路径.
  • 如果使用隐式链接的进程所链接到的任何 DLL 的 DllMain 函数失败,则进程也会终止. 使用显式链接的进程在这种情况下不会终止.
  • 隐式链接到许多 DLL 的应用程序可能会速度较慢,因为 Windows 会在应用程序加载时加载所有 DLL. 若要提高启动性能,应用程序可以只对在加载之后立即需要的 DLL 使用隐式链接. 它可以仅在需要时才使用显式链接加载其他 DLL.
  • 显式链接无需使用导入库链接应用程序. 如果 DLL 中的更改导致导出序号发生更改,则在使用函数名称而不是序号值调用 GetProcAddress 时,应用程序无需重新链接. 使用隐式链接的应用程序仍必须重新链接到更改的导入库.

若要通过显式链接使用 DLL,应用程序必须在运行时进行函数调用以显式加载 DLL. 若要显式链接到 DLL,应用程序必须:

  • 调用LoadLibraryEx或类似函数以加载 DLL 并获取模块句柄.
  • 调用 GetProcAddress以获取应用程序调用的每个导出函数的函数指针. 由于应用程序通过指针调用 DLL 函数,因此编译器不生成外部引用,从而不需要与导入库链接. 不过必须有 typedefusing 语句,此语句定义调用的已导出函数的调用签名.
  • 处理完 DLL 时,调用 FreeLibrary
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include "windows.h"

typedef HRESULT (CALLBACK* LPFNDLLFUNC1)(DWORD,UINT*);

HRESULT LoadAndCallSomeFunction(DWORD dwParam1, UINT * puParam2)
{
HINSTANCE hDLL; // Handle to DLL
LPFNDLLFUNC1 lpfnDllFunc1; // Function pointer
HRESULT hrReturnVal;

hDLL = LoadLibrary("MyDLL");
if (NULL != hDLL)
{
lpfnDllFunc1 = (LPFNDLLFUNC1)GetProcAddress(hDLL, "DLLFunc1");
if (NULL != lpfnDllFunc1)
{
// call the function
hrReturnVal = lpfnDllFunc1(dwParam1, puParam2);
}
else
{
// report the error
hrReturnVal = ERROR_DELAY_LOAD_FAILED;
}
FreeLibrary(hDLL);
}
else
{
hrReturnVal = ERROR_DELAY_LOAD_FAILED;
}
return hrReturnVal;
}

动态库查找路径

查找路径不仅针对显式链接,隐式链接也能用. 比较方便的就是可执行程序文件、或环境变量PATH

当应用程序调用 LoadLibrary或 LoadLibraryEx函数时,系统会尝试查找 DLL . 如果搜索成功,系统会将 DLL 模块映射到进程的虚拟地址空间,并递增引用计数.

Dynamic-link library search order - Win32 apps | Microsoft Learn

windows搜索dll路径顺序比较麻烦,需要看是否是打包应用(loadPackagedLibrary ),是否开启了安全DLL搜索模式(默认开启)

若要禁用安全 DLL 搜索模式,将HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Session Manager\SafeDllSearchMode 创建注册表值并将其设置为 0

如果是未打包并且是安全搜索模式,那么搜索顺序如下,前六个感觉不用看

  1. DLL 重定向.
  2. API sets.
  3. SxS manifest redirection.
  4. Loaded-module list.
  5. Known DLLs.
  6. Windows 11,版本 21H2 (10.0;内部版本 22000) 及更高版本. The package dependency graph of the process. This is the application’s package plus any dependencies specified as <PackageDependency> in the <Dependencies> section of the application’s package manifest. Dependencies are searched in the order they appear in the manifest.
  7. 从中加载应用程序的文件夹.
  8. 系统文件夹. 使用GetSystemDirectory函数检索此文件夹的路径.
  9. 16 位系统文件夹. 没有获取此文件夹路径的函数,但会对其进行搜索.
  10. Windows 文件夹. 使用GetWindowsDirectory函数获取此文件夹的路径.
  11. 当前文件夹.
  12. 环境变量中列出的 PATH 目录. 这不包括由应用路径注册表项指定的App Paths . 计算 DLL 搜索路径时,不使用 App Paths 变量

如果禁用安全DLL 搜索模式,则搜索顺序基本相同,只是位置11和8交换顺序

总结

总结一下,在linux使用cmake开发c/c++程序链接动态库时使用rpath添加搜索目录,使用windows开发开发动态库实在麻烦,一般默认隐式链接然后使用__declspec( dllexport)导出(因为默认不导出),如果使用现成的xx.dll和libxx.lib就不需要声明__declspec(__dllimport)宏了,因为链接了DLL导入库(也就是libxx.lib) Using Dynamic Libraries in C++ (youtube.com)

Screenshot of the Property Pages dialog showing the Edit command in the Linker > Input > Additional Dependencies property drop-down.

至于生成的DLL放哪,连微软自己都说放在可执行文件同一目录中,在vs可将“后期生成事件”添加到项目中,以此添加一条命令,将 DLL 复制到生成输出目录.

1
xcopy /y /d "..\..\MathLibrary\$(IntDir)MathLibrary.dll" "$(OutDir)"

Screenshot of the Property Pages dialog showing the post build event command line property.

我的配置如下

image-20241004164950315

1
xcopy /y /d "$(OutDir)$(TargetFileName)" "$(SolutionDir)bin\$(Platform)\$(Configuration)\"

在cmake中添加自定义command,道理相同,使用了cmake -E

1
2
3
4
add_custom_command(TARGET MyTest POST_BUILD        
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${PROJECT_SOURCE_DIR}/libs/test.dll"
$<TARGET_FILE_DIR:MyTest>)

或者类似的使用更好的生成器表达式$<TARGET_RUNTIME_DLLS:MyTest>

1
2
3
4
5
6
7
8
find_package(foo CONFIG REQUIRED) # package generated by install(EXPORT)

add_executable(exe main.c)
target_link_libraries(exe PRIVATE foo::foo foo::bar)
add_custom_command(TARGET exe POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy -t $<TARGET_FILE_DIR:exe> $<TARGET_RUNTIME_DLLS:exe>
COMMAND_EXPAND_LISTS
)

cmake -E copy_if_different

1
2
3
4
copy <file>... destination  - copy files to destination (either file or directory)
copy_directory <dir>... destination - copy content of <dir>... directories to 'destination' directory
copy_directory_if_different <dir>... destination - copy changed content of <dir>... directories to 'destination' directory
copy_if_different <file>... destination - copy files if it has changed

参考资料

  1. 将可执行文件链接到 DLL | Microsoft Learn
  2. 演练:创建和使用自己的动态链接库 (C++) | Microsoft Learn
  3. gcc/g++ 动态库和静态库,编译与链接(含示例)_g++ 链接静态库-CSDN博客
  4. LIBRARY_PATH vs LD_LIBRARY_PATH | Baeldung on Linux
  5. 【Linux 应用开发 】Linux环境下动态链接库路径(RPATH)的调整策略-阿里云开发者社区 (aliyun.com)
  6. Linux动态库(.so)搜索路径 - AndyJee - 博客园 (cnblogs.com)
  7. RPATH and RUNPATH (archive.org)
  8. RPATH, RUNPATH, and dynamic linking (tremily.us)
  9. RPATH handling · Wiki · CMake / Community · GitLab (kitware.com)
  10. How to copy DLL files into the same folder as the executable using CMake? - Stack Overflow
  11. cmake-generator-expressions(7) — CMake 3.30.4 Documentation
-------------本文结束感谢您的阅读-------------
感谢阅读.

欢迎关注我的其它发布渠道