在 C++ 中,加载动态库通常有两种方式:显式加载和隐式加载。 隐式加载比较简单,只需要设置链接选项即可自动进行,本文主要学习一下显式加载。

概述

在 C++ 中,显式加载和隐式加载动态库的基本介绍如下:

  • 隐式加载是最常用的方式,通常的C++标准库等系统中的库都是采用隐式加载的,程序在编译时需要添加-l选项链接到动态库。在程序启用时,系统会自动查找并加载对应的动态库。 隐式加载的优势是代码简单,不需要在代码中处理加载动态库的各种细节。但缺点是要求在编译时动态库也要参与链接,在编译时和运行时都需要保证动态库是可以找到并且使用的,编译时无法找到则编译失败,运行时无法找到和使用则程序无法启动。

  • 显式加载则是一种更灵活的方式,我们可以在代码中精准地控制加载和卸载动态库的所有细节。 优势是程序在编译链接时完全不需要动态库的参与,程序在运行时可以根据需要有选择性地进行加载或卸载动态库,即使在运行时对某个动态库的加载失败也不会导致程序中止。

基于显式加载我们可以实现动态库的热更新:在使用动态库的程序保持运行的情况下,更新对应的动态库。 这对于一些大型的商业服务是非常普遍的需求,他们不希望因为某些小的依赖库的更新而导致整个服务暂停重启。

显式加载是系统级的需求,不同的系统提供的具体接口是不一样的,通常包括以下步骤:

  • 加载动态库:使用平台特定的 API 加载动态库。在 POSIX 系统上通常使用 dlopen,而在 Windows 上通常使用 LoadLibrary
  • 获取函数指针:使用平台特定的 API 从动态库中获取函数指针。在 POSIX 系统上通常使用 dlsym,而在 Windows 上通常使用 GetProcAddress
  • 调用函数:通过函数指针调用动态库中的函数。
  • 卸载动态库:当不再需要动态库时,可以显式地将其卸载。在 POSIX 系统上通常使用 dlclose,而在 Windows 上通常使用 FreeLibrary

必须注意的是,如果需要使用显式加载,那么动态库提供的接口必须要使用extern "C",即必须提供符合C标准的接口,否则系统的dlopen找不到对应的符号。

下面分别从Linux系统和Windows系统实现简单的显式加载示例。

Linux上的显式加载

动态库如下

hello.cpp
1
2
3
4
5
#include <iostream>

extern "C" void hello(const char *msg) {
std::cout << "hello " << msg << std::endl;
}

这里因为非常简单,并没有提供头文件,实际上在显式加载时,使用者也不需要使用动态库对应的头文件。

主程序代码如下

main.cpp
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 <dlfcn.h>
#include <iostream>

using HelloFunction = void (*)(const char *);

int main() {
// 加载动态库
void *handle = dlopen("./libhello.so", RTLD_LAZY);

if (handle == nullptr) {
std::cerr << "Failed to load dynamic library" << std::endl;
return 1;
}

// 获取函数指针
auto hello = (HelloFunction)dlsym(handle, "hello");

// 加载失败,自动关闭
if (hello == nullptr) {
std::cerr << "Failed to find function in dynamic library" << std::endl;
dlclose(handle);
return 1;
}

// 调用函数
hello("demo");

// 关闭动态库
dlclose(handle);

return 0;
}

这里使用dlopen加载动态库时,需要提供动态库的文件名(含路径),并且需要传递策略参数,有两种常见的选择:

  • RTLD_LAZY:表示“懒加载”策略,只加载动态库的基本部分,不立即解析所有外部符号(即函数和变量)。外部符号的解析会在首次使用时(如第一次调用某个函数时)进行。懒加载策略可以减少加载动态库所需的时间,因为符号的解析是按需进行的。
  • RTLD_NOW:表示立即解析所有外部符号。这可能会增加动态库的加载时间,但可以确保在加载库时立即发现可能出现的链接问题(例如未定义的符号)。

编译的过程如下:

1
2
g++ -fPIC -shared hello.cpp -o libhello.so
g++ main.cpp -o test

注意确保动态库的名称和位置正确,因为它已经写死在使用者的代码中,否则会加载失败。

Windows上的显式加载

动态库如下(Windows需要加入符号导出的标记)

hello.cpp
1
2
3
4
5
#include <iostream>

extern "C" __declspec(dllexport) void hello(const char *msg) {
std::cout << "hello " << msg << std::endl;
}

主程序代码如下(将Linux的API换成Windows的即可)

main.cpp
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 <iostream>
#include <windows.h>

using HelloFunction = void (*)(const char *);

int main() {
// 加载动态库
HMODULE handle = LoadLibrary("hello.dll");

if (handle == nullptr) {
std::cerr << "Failed to load dynamic library" << std::endl;
return 1;
}

// 获取函数指针
auto hello = (HelloFunction)GetProcAddress(handle, "hello");

// 加载失败,自动关闭
if (hello == nullptr) {
std::cerr << "Failed to find function in dynamic library" << std::endl;
FreeLibrary(handle);
return 1;
}

// 调用函数
hello("demo");

// 关闭动态库
FreeLibrary(handle);

return 0;
}

编译的过程如下:

1
2
clang++ -shared hello.cpp -o hello.dll
clang++ main.cpp -o test.exe

注意确保动态库的名称和位置正确,因为它已经写死在使用者的代码中,否则会加载失败。

注意:

  • 在生成动态库时同时生成了三个文件:hello.dll,hello.exp和hello.lib,如果没有自动生成这三个文件,可能是忘记在源码中导出符号。
  • clang++采用的后端还是MSVC,不是Mingw-w64,-fPIC对windows是无效选项。

显式加载的跨平台封装

我们可以通过简单的封装,对Linux和Windows提供统一的调用接口。

dll.h
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#ifndef DLL_H
#define DLL_H

#ifdef _WIN32
#include <windows.h>
#else
#include <dlfcn.h>

#endif

namespace dll {
#ifdef _WIN32
using DynamicLibHandle = HMODULE;
using DynamicLibFunction = FARPROC;
#else
using DynamicLibHandle = void *;
using DynamicLibFunction = void *;
#endif

inline DynamicLibHandle load(const char *libPath) {
#ifdef _WIN32
return LoadLibrary(libPath);
#else
return dlopen(libPath, RTLD_LAZY);
#endif
}

inline void unload(DynamicLibHandle handle) {
#ifdef _WIN32
FreeLibrary(handle);
#else
dlclose(handle);
#endif
}

inline DynamicLibFunction get(DynamicLibHandle handle,
const char *functionName) {
#ifdef _WIN32
return GetProcAddress(handle, functionName);
#else
return dlsym(handle, functionName);
#endif
}
} // namespace dll

#endif // DLL_H

基于这个头文件,可以让我们用统一的代码在Linux平台和Windows平台上正常进行动态库的显式加载,只是编译命令和得到的动态库名称仍然不同,编译命令与前面的保持不变。

动态库实现代码

hello.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>

#ifdef _WIN32
#define MY_EXPORT __declspec(dllexport)
#else
#define MY_EXPORT
#endif

extern "C" {
MY_EXPORT void hello(const char *msg) {
std::cout << "hello " << msg << std::endl;
}
}

主程序代码如下

main.cpp
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
33
34
35
36
37
#include "dll.h"
#include <iostream>

using HelloFunction = void (*)(const char *);

int main() {
#ifdef _WIN32
// 加载动态库
auto *handle = dll::load("./hello.dll");
#else
// 加载动态库
auto *handle = dll::load("./libhello.so");
#endif

if (handle == nullptr) {
std::cerr << "Failed to load dynamic library" << std::endl;
return 1;
}

// 获取函数指针
auto hello = (HelloFunction)dll::get(handle, "hello");

// 加载失败,自动关闭
if (hello == nullptr) {
std::cerr << "Failed to find function in dynamic library" << std::endl;
dll::unload(handle);
return 1;
}

// 调用函数
hello("demo");

// 关闭动态库
dll::unload(handle);

return 0;
}

进一步的封装可以针对具体的动态库而言,参考Github仓库中的replex.h,它不是针对跨平台进行的封装,而是为了进一步隐藏动态库的细节。

动态库热更新

继续前面的例子,我们将动态库的使用方式改为:

  • 每隔一小段时间(1秒)调用一次函数
  • 每隔一大段时间(10秒)重新加载一次动态库

代码如下

main.cpp
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
#include "dll.h"
#include <chrono> // 使用时间间隔
#include <iostream>
#include <thread> // 使用线程休眠

using HelloFunction = void (*)(const char *);

int main() {
const char *libPath = nullptr;

// 根据操作系统设置动态库路径
#ifdef _WIN32
libPath = "./hello.dll";
#else
libPath = "./libhello.so";
#endif

// 定义时间间隔
const std::chrono::seconds callInterval(1); // 每 1 秒调用一次 hello
const std::chrono::seconds reloadInterval(10); // 每 10 秒重新加载动态库

// 初始化变量
dll::DynamicLibHandle handle = nullptr;
HelloFunction hello = nullptr;

// 记录上次重新加载的时间
auto lastReloadTime = std::chrono::steady_clock::now() - reloadInterval;

while (true) {
// 检查是否需要重新加载动态库
auto now = std::chrono::steady_clock::now();
if (now - lastReloadTime >= reloadInterval) {
// 如果动态库已经加载过,先卸载
if (handle != nullptr) { dll::unload(handle); }

// 重新加载动态库
handle = dll::load(libPath);
if (handle == nullptr) {
std::cerr << "Failed to reload dynamic library" << std::endl;
return 1;
}

// 获取新的函数指针
hello = (HelloFunction)dll::get(handle, "hello");
if (hello == nullptr) {
std::cerr << "Failed to find function in new dynamic library"
<< std::endl;
dll::unload(handle);
return 1;
}

// 更新上次重新加载的时间
lastReloadTime = now;
}

// 调用 hello 函数
if (hello != nullptr) { hello("demo"); }

// 等待下一个调用时间
std::this_thread::sleep_for(callInterval);
}

// 程序结束前释放动态库
if (handle != nullptr) { dll::unload(handle); }

return 0;
}

为了简便我们只在Linux系统上实验,提供两个版本的动态库:

  • hello1.cpp,输出hello + <msg>,编译为libhello.so.1
  • hello2.cpp,输出hi + <msg>,编译为libhello.so.2

它们是libhello.so的不同版本,程序实际会加载的是libhello.so,我们通过软链接进行不同版本的切换

1
2
3
ln -sf libhello.so.1 libhello.so
% or
ln -sf libhello.so.2 libhello.so

动态库的源码如下

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
// version 1
// hello1.cpp
#include <iostream>

#ifdef _WIN32
#define MY_EXPORT __declspec(dllexport)
#else
#define MY_EXPORT
#endif

extern "C" {
MY_EXPORT void hello(const char *msg) {
std::cout << "hello " << msg << std::endl;
}
}

// version 2
// hello2.cpp
#include <iostream>

#ifdef _WIN32
#define MY_EXPORT __declspec(dllexport)
#else
#define MY_EXPORT
#endif

extern "C" {
MY_EXPORT void hello(const char *msg) {
std::cout << "hi " << msg << std::endl;
}
}

我们需要开启多个窗口来进行实验:

  • 第一个窗口,启动主程序并输出到日志文件中:./a.out > a.log
  • 第二个窗口,观察日志文件:tail -f a.log,可以看到程序在不断输出,日志在不断增长
  • 第三个窗口,负责切换libhello.so指向的版本

实验可以观察到,切换版本后日志中的输出内容会发生变化(整十秒的时候发生了动态库的重新加载),而程序始终保持正常运行,因此我们成功实现了动态库的热更新。

输出内容如下

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
hello demo
hello demo
hello demo
hello demo
hello demo
hello demo
hello demo
hello demo
hello demo
hello demo // 10
hello demo
hello demo
hello demo
hello demo
hello demo
hello demo
hello demo
hello demo
hello demo
hello demo // 20
hello demo
hello demo
hello demo
hello demo
hello demo
hello demo
hello demo
hello demo
hello demo
hello demo // 30
hello demo
hello demo
hello demo
hello demo
hello demo
hello demo
hello demo
hello demo
hello demo
hello demo // 40
hello demo
hello demo
hello demo
hello demo
hello demo
hello demo
hello demo
hello demo
hello demo
hi demo
hi demo
hi demo
hi demo
hi demo
hi demo
hi demo
hi demo
hi demo
hi demo
hi demo
hi demo
hi demo
hi demo
hi demo
hi demo
hi demo
hi demo
hi demo
hi demo
hello demo
hello demo
hello demo
hello demo