C++ 动态库热加载
C++ 动态库热加载
本文参考自 project-based-learning 中的 Build a Live Code-reloader Library for C++,主要内容都来自于其中,但是对代码进行了一点修改,并且改用 CMake 进行构建。
文章整体比较基础,适合初学者,通过本文可以学习到以下知识点
- 关于 C++ 程序如何编译运行,如何运行时加载动态库(使用
dl*API)。 - 如何设计简洁易用的库 API 供用户使用。
- 如何使用 CMake 组织并构建一个包含可执行程序、动态库和头文件库的项目。
- 如何使用 GoogleTest 进行测试。
动态库热加载原理
动态库热加载指的是在程序运行时,动态地加载动态库,从而达到不停止程序的情况下,更新程序的功能。
C++ 程序在运行时有两种方式加载动态连接库:隐式链接和显式链接 [1]。
- 隐式链接就是在编译的时候使用
-l参数链接的动态库,进程在开始执行时就将动态库文件映射到内存空间中。 - 显式链接使用
libdl.so库的 API 接口在运行中加载和卸载动态库,主要的 API 有dlopen、dlclose、dlsym、dlerror。
隐式链接的方式要进行热加载需要不少 Hack,难度较大,本文主要讲解第二种方式。
简单版本
首先我们快速实现一个能够完成最小功能可运行的版本,熟悉相关 API 的使用。我们简单编写三个文件,分别为main.cpp, replex.h,hello.cpp,另外还编写一个快速编译运行代码的脚本 run.sh,目录结构如下
.
├── hello.cpp
├── main.cpp
├── replex.h
└── run.sh
代码的完整版本见 projects/replex-1。
replex.h 中对 dl* API 进行了简单的封装,使用一个 namespace 将 API 进行了包装,代码如下
#pragma once
#include <dlfcn.h>
#include <cstdio>
namespace Replex {
inline void* Load(const char* filepath) {
return dlopen(filepath, RTLD_LAZY);
}
inline void* LoadSymbol(void* library, const char* symbol) {
return dlsym(library, symbol);
}
inline void Reload(void*& library, const char* filepath) {
if (library) {
dlclose(library);
}
library = Load(filepath);
}
inline void PrintError() {
fprintf(stderr, "%s\n", dlerror());
}
} // namespace Replex
hello.cpp 是我们需要热加载的动态库,代码如下
#include <cstdio>
extern "C" {
void foo() {
printf("Hi\n");
}
int bar = 200;
}
其中使用 extern "C" 将 foo 和 bar 声明为 C 语言的函数和变量,这样在编译时就不会对函数名进行修饰,否则在 main.cpp 中使用 dlsym 时会找不到 foo 对应的符号。
不加 extern "C"时,使用 nm 命令查看 hello.so 中的符号如下
$ nm libhello.so | grep foo
0000000000001119 T _Z3foov
加上后
$ nm libhello.so | grep foo
0000000000001119 T foo
main.cpp 是主程序,代码如下
#include <cstdio>
#include <string>
#include "replex.h"
const char* g_libPath = "libhello.so";
int main() {
void* handle;
void (*foo)();
int bar;
handle = Replex::Load(g_libPath);
if (!handle) {
Replex::PrintError();
return -1;
}
foo = reinterpret_cast<void (*)()>(Replex::LoadSymbol(handle, "foo"));
foo();
bar = *reinterpret_cast<int*>(Replex::LoadSymbol(handle, "bar"));
printf("bar == %d\n", bar);
// Modify the source code and recompile the library.
std::string filename = "hello.cpp";
std::string command = std::string("sed -i ") +
(bar == 200 ? "'s/200/300/'" : "'s/300/200/'") + " " +
filename;
system(command.c_str());
command = std::string("sed -i ") +
(bar == 200 ? "'s/Hi/Hello/'" : "'s/Hello/Hi/'") + " " + filename;
system(command.c_str());
system("g++ -shared -fPIC -o libhello.so hello.cpp");
Replex::Reload(handle, g_libPath);
if (!handle) {
Replex::PrintError();
return -1;
}
foo = reinterpret_cast<void (*)()>(Replex::LoadSymbol(handle, "foo"));
foo();
bar = *reinterpret_cast<int*>(Replex::LoadSymbol(handle, "bar"));
printf("bar == %d\n", bar);
return 0;
}
整体代码逻辑比较好懂,首先加载动态库,然后获取动态库中的函数和变量,调用函数和打印变量,然后修改 hello.cpp 中的代码,重新编译动态库,再次加载动态库,调用函数和打印变量。
reinterpret_cast 是 C++ 中的强制类型转换,将 void* 指针转换为函数指针和变量指针。
run.sh 的内容如下
#!/bin/bash
set -e # stop the script on errors
g++ -fPIC -shared -o libhello.so hello.cpp
g++ -o main.out main.cpp -ldl
./main.out
脚本中 -shared -fPIC 参数用于生成位置无关的动态库,-ldl 参数用于链接 libdl.so 库(dl* API),-o 参数用于指定输出文件名。
运行脚本后,输出如下
Hi
bar == 200
Hello
bar == 300
当前程序能够完成基本功能,但是对于使用者来说我们的库不够好用,使用者(main.cpp)需要自己定义相应的函数指针和类型,还需要自己进行类型转换,动态库的导出符号也需要自己定义,对于使用者来说也相当麻烦。
改进版本
我们考虑提供更简单的接口供用户使用,我们将在 replex.h 中创建一个 ReplexModule 类,这个类将用于给动态库的继承使用,然后由动态库的作者提供更加简明的接口供用户使用。
这一版本代码的完整实现见 GitHub。
最终的使用效果见如下 main.cpp 文件
#include <iostream>
#include "hello.h"
int main() {
HelloModule::LoadLibrary();
HelloModule::Foo();
int bar = HelloModule::GetBar();
std::cout << "bar == " << bar << std::endl;
// Modify the source code and recompile the library.
// ...
HelloModule::ReloadLibrary();
HelloModule::Foo();
std::cout << "bar == " << HelloModule::GetBar() << std::endl;
return 0;
}
我们忽略中间的修改源码和重新编译的过程,这里只关注 HelloModule 的使用,相比于前一版本,这里的使用更加简单,不需要自己定义函数指针和变量,也不需要自己进行类型转换,只需要调用 HelloModule 中的接口即可。同时注意到我们包含的头文件也变成了 hello.h,这个头文件是动态库作者提供的,我们在 main.cpp 中只需要包含这个头文件即可。
针对于上述需求,ReplexModule 需要公开两个公共接口,一个用于发布可热加载库,另一个用于加载和重新加载这些可热加载库。
ReplexModule 的公开接口仅有两个,分别为 LoadLibrary 和 ReloadLibrary,代码如下
#pragma once
#include <dlfcn.h>
#include <array>
#include <iostream>
#include <stdexcept>
#include <string>
#include <unordered_map>
template <typename E, size_t NumSymbols>
class ReplexModule {
public:
static void LoadLibrary() { GetInstance().Load(); }
static void ReloadLibrary() { GetInstance().Reload(); }
protected:
static E& GetInstance() {
static E instance;
return instance;
}
// ...
// ... continued later
}
这两个函数都依赖于 GetInstance 函数,这个函数是一个模板函数,用于返回 ReplexModule 的子类的单例,这样可以保证每个子类只有一个实例。另外,ReplexModule 是一个模板类,模板参数 E 是一个枚举类型,用于指定动态库中的符号,NumSymbols 是一个常量,用于指定动态库中的符号个数。
接下来关注 ReplexModule 向动态库作者也就是集成该类的子类提供的接口,代码如下:
// ... continued above
// Should return the path to the library on disk
virtual const char* GetPath() const = 0;
// Should return a reference to an array of C-strings of size NumSymbols
// Used when loading or reloading the library to lookup the address of
// all exported symbols
virtual std::array<const char*, NumSymbols>& GetSymbolNames() const = 0;
template <typename Ret, typename... Args>
Ret Execute(const char* name, Args... args) {
// Lookup the function address
auto symbol = m_symbols.find(name);
if (symbol != m_symbols.end()) {
// Cast the address to the appropriate function type and call it,
// forwarding all arguments
return reinterpret_cast<Ret (*)(Args...)>(symbol->second)(args...);
}
throw std::runtime_error(std::string("Function not found: ") + name);
}
template <typename T>
T* GetVar(const char* name) {
auto symbol = m_symbols.find(name);
if (symbol != m_symbols.end()) {
return static_cast<T*>(symbol->second);
}
// We didn't find the variable. Return an empty pointer
return nullptr;
}
private:
void Load() {
m_libHandle = dlopen(GetPath(), RTLD_NOW);
LoadSymbols();
}
void Reload() {
auto ret = dlclose(m_libHandle);
m_symbols.clear();
Load();
}
void LoadSymbols() {
for (const char* symbol : GetSymbolNames()) {
auto* sym = dlsym(m_libHandle, symbol);
m_symbols[symbol] = sym;
}
}
void* m_libHandle;
std::unordered_map<std::string, void*> m_symbols;
};
首先关注最底部的数据成员,m_libHandle 是动态库的句柄,m_symbols 是一个哈希表,用于存储动态库中的符号和符号对应的地址。 Load 函数用于加载动态库,Reload 函数用于重新加载动态库,LoadSymbols 函数用于加载动态库中的符号,这几个函数的逻辑相当清晰无需赘述。
值得讲解的是 Execute 和 GetVar 函数,Execute 函数用于调用动态库中的函数,GetVar 函数用于获取动态库中的变量,让我们先看看 Execute 函数的实现,代码如下
template <typename Ret, typename... Args>
Ret Execute(const char* name, Args... args) {
// Lookup the function address
auto symbol = m_symbols.find(name);
if (symbol != m_symbols.end()) {
// Cast the address to the appropriate function type and call it,
// forwarding all arguments
return reinterpret_cast<Ret (*)(Args...)>(symbol->second)(args...);
}
throw std::runtime_error(std::string("Function not found: ") + name);
}
这是一个模板函数,模板参数 Ret 是返回值类型,Args... 是参数类型,这里的 Args... 表示可以接受任意多个参数,Args... args 表示将参数包 args 展开,然后将展开后的参数作为参数传递给 Execute 函数。
该函数首先在 m_symbols 中查找 name 对应的符号,如果找到了,就将符号地址转换为类型为 Ret (*)(Args...) 的函数指针,然后调用该函数,传递参数 args...,如果没有找到,就抛出异常。
GetVar 函数的实现如下
template <typename T>
T* GetVar(const char* name) {
auto symbol = m_symbols.find(name);
if (symbol != m_symbols.end()) {
return static_cast<T*>(symbol->second);
}
// We didn't find the variable. Return an empty pointer
return nullptr;
}
该函数的实现和 Execute 函数类似,只是将函数指针转换为变量指针,然后返回。
hello.cpp 的内容保持不变:
#include <cstdio>
extern "C" {
void foo() {
printf("Hi\n");
}
int bar = 200;
}
hello.h 中定义类 HelloModule 继承自 ReplexModule,代码如下
#pragma once
#include <array>
#include "replex.h"
inline std::array<const char*, 2> g_exports = {"foo", "bar"};
class HelloModule : public ReplexModule<HelloModule, g_exports.size()> {
public:
static void Foo() { GetInstance().Execute<void>("foo"); }
static int GetBar() { return *GetInstance().GetVar<int>("bar"); }
protected:
virtual const char* GetPath() const override { return "libhello.so"; }
virtual std::array<const char*, g_exports.size()>& GetSymbolNames()
const override {
return g_exports;
}
};
变量 g_exports 用于存储动态库中需要导出的符号,其采用 inline 修饰,这样就可以在头文件中定义,而不会出现重复定义的错误。
HelloModule 中定义了两个静态函数,分别为 Foo 和 GetBar,这两个函数用于调用动态库中的函数和获取动态库中的变量。
运行脚本的内容基本不变,添加了 -std=c++17 的标志保证可以使用 inline 变量的用法。
#!/bin/bash
set -e # stop the script on errors
g++ -fPIC -shared -o libhello.so hello.cpp -std=c++17
g++ -o main.out main.cpp -ldl -std=c++17
./main.out
运行效果与前一版本一致,如下
Hi
bar == 200
Hello
bar == 300
现在我们可以认为我们所编写的 replex.h 库足方便使用,动态库作者只需要继承 ReplexModule 类,然后实现两个虚函数即可,使用者只需要包含动态库作者提供的头文件,然后调用相应的接口即可。
CMake 版本
前面两个版本的代码都是写个脚本直接使用 g++ 编译,这样的方式不够灵活,不利于项目的管理,正好这个项目涉及到几个不同的模块,可以尝试使用 CMake 进行管理,学习一下项目的组织构建。
完整代码见 projects/replex-3,采用 现代 CMake 模块化项目管理指南 中推荐的方式进行项目组织,但是略微进行了一点简化,目录结构如下
.
├── CMakeLists.txt
├── hello
│ ├── CMakeLists.txt
│ ├── include
│ │ └── hello.h
│ └── src
│ └── hello.cpp
├── main
│ ├── CMakeLists.txt
│ └── src
│ └── main.cpp
└── replex
├── CMakeLists.txt
└── include
└── replex.h
首先梳理一下整个项目的依赖关系,如下所示
main (exe)
├── hello_interface (interface)
│ └── replex (interface)
└── hello (shared lib)
main 模块依赖于头文件库 hello_interface,hello_interface 依赖于头文件库 replex,动态库 hello 不依赖于任何库,用于提供给 main 模块使用。
CMakeLists.txt 为根目录的 CMakeLists.txt,内容如下
cmake_minimum_required(VERSION 3.15)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
project(replex LANGUAGES CXX)
if (NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release)
endif ()
add_subdirectory(replex)
add_subdirectory(main)
add_subdirectory(hello)
首先设置 C++ 标准,然后设置项目名称,然后判断是否设置了构建类型,如果没有设置,则设置为 Release 模式,然后添加子目录,分别为 replex、main 和 hello。
replex/CMakeLists.txt 的内容如下
add_library(replex INTERFACE include/replex.h)
target_include_directories(replex INTERFACE include)
replex 为头文件库,使用 add_library 添加,类型为 INTERFACE,表示这是一个接口库,不会生成任何文件,只会导出头文件,使用 target_include_directories 添加头文件路径。
hello/CMakeLists.txt 的内容如下
add_library(hello SHARED src/hello.cpp)
add_library(hello_interface INTERFACE include/hello.h)
target_include_directories(hello_interface INTERFACE include)
target_link_libraries(hello_interface INTERFACE replex)
其中定义了两个库,一个为动态库 hello,一个为头文件库 hello_interface 用于导出 动态库 hello 中的符号以供使用, hello_interface 依赖于 replex,使用 target_link_libraries 添加依赖。
main/CMakeLists.txt 的内容如下
add_executable(main src/main.cpp)
target_link_libraries(main PRIVATE hello_interface)
main 为可执行文件,使用 add_executable 添加,使用 target_link_libraries 添加依赖 hello_interface。
最后运行脚本 run.sh,内容如下
#!/bin/bash
set -e # stop the script on errors
cmake -B build
cmake --build build
./build/main/main
运行的效果如下
Hi
bar == 200
[ 0%] Built target replex
[ 0%] Built target hello_interface
[ 50%] Built target main
[ 75%] Building CXX object hello/CMakeFiles/hello.dir/src/hello.cpp.o
[100%] Linking CXX shared library libhello.so
[100%] Built target hello
Hello
bar == 300
添加测试 (GoogleTest)
这部分的完整代码见 projects/replex-4。
一个好的项目,测试是必不可少的,前面我们实现的 main.cpp 中其实已经有了一点自动化测试的影子,但是这种方式不够好,我们可以使用 GoogleTest 来进行测试。
首先演示一个最基本的 gtest 用法,首先使用 git 的 submodule 命令添加 googletest 到我们的项目中
git submodule add git@github.com:google/googletest.git
然后修改我们根目录下的 CMakeLists.txt,添加如下内容
add_subdirectory(googletest)
enable_testing()
include_directories(${gtest_SOURCE_DIR}/include ${gtest_SOURCE_DIR})
add_subdirectory(test)
创建 test 目录,结构如下
test
├── CMakeLists.txt
└── src
└── test.cpp
test/CMakeLists.txt 的内容如下
add_executable(tests src/test.cpp)
target_link_libraries(tests PUBLIC gtest gtest_main)
test/src/test.cpp 的内容如下
#include <gtest/gtest.h>
TEST(SillyTest, IsFourPositive) {
EXPECT_GT(4, 0);
}
TEST(SillyTest, IsFourTimesFourSixteen) {
int x = 4;
EXPECT_EQ(x * x, 16);
}
int main(int argc, char** argv) {
// This allows us to call this executable with various command line
// arguments which get parsed in InitGoogleTest
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
OK,到现在我们已经成功添加了 GoogleTest 到我们的项目中并且可以运行测试了,现在我们要编写一些测试来测试我们的项目。
我们编写一个 replex 的测试,测试内容如下
#include <gtest/gtest.h>
#include <hello.h>
#include <cstdlib>
#include <fstream>
const char* g_Test_v1 = R"delimiter(
extern "C" {
int foo(int x) {
return x + 5;
}
int bar = 3;
}
)delimiter";
const char* g_Test_v2 = R"delimiter(
extern "C" {
int foo(int x) {
return x - 5;
}
int bar = -2;
}
)delimiter";
class ReplexTest : public ::testing::Test {
public:
// Called automatically at the start of each test case.
virtual void SetUp() {
WriteFile("hello/src/hello.cpp", g_Test_v1);
Compile(1);
HelloModule::LoadLibrary();
}
// We'll invoke this function manually in the middle of each test case
void ChangeAndReload() {
WriteFile("hello/src/hello.cpp", g_Test_v2);
Compile(2);
HelloModule::ReloadLibrary();
}
// Called automatically at the end of each test case.
virtual void TearDown() {
HelloModule::UnloadLibrary();
WriteFile("hello/src/hello.cpp", g_Test_v1);
Compile(1);
}
private:
void WriteFile(const char* path, const char* text) {
// Open an output filetream, deleting existing contents
std::ofstream out(path, std::ios_base::trunc | std::ios_base::out);
out << text;
}
void Compile(int version) {
if (version == m_version) {
return;
}
m_version = version;
EXPECT_EQ(std::system("cmake --build build"), 0);
// Super unfortunate sleep due to the result of cmake not being fully
// flushed by the time the command returns (there are more elegant ways
// to solve this)
sleep(1);
}
int m_version = 1;
};
TEST_F(ReplexTest, VariableReload) {
EXPECT_EQ(HelloModule::GetBar(), 3);
ChangeAndReload();
EXPECT_EQ(HelloModule::GetBar(), -2);
}
TEST_F(ReplexTest, FunctionReload) {
EXPECT_EQ(HelloModule::Foo(4), 9);
ChangeAndReload();
EXPECT_EQ(HelloModule::Foo(4), -1);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
要使得这个测试运行起来,还需要对 CMake 文件进行一些修改,这部分留作练习吧,动手试试会对 CMake 等有更深的理解。
相比较于 projects/replex-3,需要修改的文件有:
- 移除 main 文件夹
- 根目录下的 CMakeLists.txt
- hello/CMakeLists.txt
- hello/include/hello.h
- test/src/test.cpp
完整代码见 projects/replex-4
C++ 动态库热加载的更多相关文章
- c++ 动态库的加载
转载:https://blog.csdn.net/ztq_12345/article/details/99677769 使用ide是vs, 使用Windows.h下的3个函数对动态库进行加载第一个:H ...
- 动态库DLL加载方式-静态加载和动态加载
静态加载: 如果你有a.dll和a.lib,两个文件都有的话可以用静态加载的方式: message函数的声明你应该知道吧,把它的声明和下面的语句写到一个头文件中 #pragma comment(lib ...
- 关于Linux动态库的加载路径
问题 按如下步骤在Ubuntu上编译安装Google Protocol Buffers $ ./configure $ make $ make check $ sudo make install 运行 ...
- qt在动态库里面加载widget的例子
testDll和testExe项目 备注:windows下dll内不需要new QApplication, linux和mac下面需要在动态库里面new QApplication testdll.h ...
- c#生成动态库并加载
下面这段代码生成dll文件,不能编译运行.点击项目右键,点击生成,这时会在debuge文件中生成相应的配置文件. using System; using System.Collections.Gene ...
- solr6.3.0升级与IK动态词库自动加载
摘要:对于中文的搜索来说,词库系统是一个很比较重要的模块,本篇以IK分词器为例子,介绍如何让分词器从缓存或文件系统中自动按照一定频次进行加载扩展词库 Lucene.Solr或ElasticStack如 ...
- elasticsearch中ik词库配置远程热加载
1. 修改 IKAnalyzer.cfg.xml 配置文件中的<entry key="remote_ext_dict">http://127.0.0.1/xxx.txt ...
- dubbo的jmeter压测时jar包的热加载/动态加载
在做dubbo的jmeter压测时,需要把jar包放入jmeter的lib/ext目录下,但是jmeter启动的时候会自动加载这个目录lib目录及lib/ext目录,这样启动后放入这些目录下的jar包 ...
- Spring Boot 如何热加载jar实现动态插件?
一.背景 动态插件化编程是一件很酷的事情,能实现业务功能的 解耦 便于维护,另外也可以提升 可扩展性 随时可以在不停服务器的情况下扩展功能,也具有非常好的 开放性 除了自己的研发人员可以开发功能之外, ...
- SpringBoot开发 - 什么是热部署和热加载?devtool的原理是什么?
在SpringBoot开发调试中,如果我每行代码的修改都需要重启启动再调试,可能比较费时间:SpringBoot团队针对此问题提供了spring-boot-devtools(简称devtools)插件 ...
随机推荐
- 为什么 Python 代码在函数中运行得更快?
哈喽大家好,我是咸鱼 当谈到编程效率和性能优化时,Python 常常被调侃为"慢如蜗牛" 有趣的是,Python 代码在函数中运行往往比在全局范围内运行要快得多 小伙伴们可能会有这 ...
- 「にちじょう記録」MTIDnWtMOA
Mistakes That I Don't Want to Make Once Again. // Caution // 差分 / 前缀和后注意询问区间端点有变化-- 不要考虑了右边界就不考虑左边界 ...
- 10.0 探索API调试事件原理
本章笔者将通过Windows平台下自带的调试API接口实现对特定进程的动态转存功能,首先简单介绍一下关于调试事件的相关信息,调试事件的建立需要依赖于DEBUG_EVENT这个特有的数据结构,该结构用于 ...
- C++ 对拍详解 和解读
对拍是什么# 对拍,是一个比较实用的工具.它能够非常方便地对于两个程序的输出文件进行比较,可以帮助我们实现一些自动化的比较输出结果的问题. 众所周知,几乎每一道编程题目,都会有某种正解能拿到满分: ...
- destoon关于archiver归档的性能优化
今天在处理一个项目时候发现archiver单个模块归档超过百万数据,打开速度就特慢,所以打开archiver下index.php文件进行分析,发现有句sql作怪 1 $result = $db-> ...
- android开发阶段性技能
一.初级 1. 拥有娴熟的Java基础,理解设计模式,比如OOP语言的工厂模式要懂得. 2. 掌握Android UI控件.Android Java层API相关使用. 迈向中级,最好再次更新下Java ...
- Android news Display Owner Info on Your Android Device in Case It Gets Lost
Display Owner Info on Your Android Device in Case It Gets Lost The latest versions of Android includ ...
- 第五周单元测验题英语教学与互联网 mooc
第五周单元测验题 返回 本次得分为:16.00/20.00, 本次测试的提交时间为:2020-08-30, 如果你认为本次测试成绩不理想,你可以选择 再做一次 . 1 单选(2分) 从评价的主体来看, ...
- Splay 详细图解 & 轻量级代码实现
学 LCT 发现有点记不得 Splay 怎么写,又实在不知道这篇博客当时写了些什么东西(分段粘代码?),决定推倒重写. 好像高一学弟也在学平衡树,但相信大家都比樱雪喵强,都能一遍学会!/kel 写在前 ...
- Unity3D 选择焦点切换
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.T ...