经验首页 前端设计 程序设计 Java相关 移动开发 数据库/运维 软件/图像 大数据/云计算 其他经验
当前位置:技术经验 » 程序设计 » C++ » 查看文章
C++ 动态库热加载
来源:cnblogs  作者:zhangyi1357  时间:2024/1/5 9:10:16  对本文有异议

C++ 动态库热加载

本文参考自 project-based-learning 中的 Build a Live Code-reloader Library for C++,主要内容都来自于其中,但是对代码进行了一点修改,并且改用 CMake 进行构建。

文章整体比较基础,适合初学者,通过本文可以学习到以下知识点

  1. 关于 C++ 程序如何编译运行,如何运行时加载动态库(使用 dl* API)。
  2. 如何设计简洁易用的库 API 供用户使用。
  3. 如何使用 CMake 组织并构建一个包含可执行程序、动态库和头文件库的项目。
  4. 如何使用 GoogleTest 进行测试。

动态库热加载原理

动态库热加载指的是在程序运行时,动态地加载动态库,从而达到不停止程序的情况下,更新程序的功能。

C++ 程序在运行时有两种方式加载动态连接库:隐式链接和显式链接 [1]

  1. 隐式链接就是在编译的时候使用 -l 参数链接的动态库,进程在开始执行时就将动态库文件映射到内存空间中。
  2. 显式链接使用 libdl.so 库的 API 接口在运行中加载和卸载动态库,主要的 API 有 dlopen、dlclose、dlsym、dlerror

隐式链接的方式要进行热加载需要不少 Hack,难度较大,本文主要讲解第二种方式。

简单版本

首先我们快速实现一个能够完成最小功能可运行的版本,熟悉相关 API 的使用。我们简单编写三个文件,分别为main.cppreplex.hhello.cpp,另外还编写一个快速编译运行代码的脚本 run.sh,目录结构如下

  1. .
  2. ├── hello.cpp
  3. ├── main.cpp
  4. ├── replex.h
  5. └── run.sh

代码的完整版本见 projects/replex-1

replex.h 中对 dl* API 进行了简单的封装,使用一个 namespace 将 API 进行了包装,代码如下

  1. #pragma once
  2. #include <dlfcn.h>
  3. #include <cstdio>
  4. namespace Replex {
  5. inline void* Load(const char* filepath) {
  6. return dlopen(filepath, RTLD_LAZY);
  7. }
  8. inline void* LoadSymbol(void* library, const char* symbol) {
  9. return dlsym(library, symbol);
  10. }
  11. inline void Reload(void*& library, const char* filepath) {
  12. if (library) {
  13. dlclose(library);
  14. }
  15. library = Load(filepath);
  16. }
  17. inline void PrintError() {
  18. fprintf(stderr, "%s\n", dlerror());
  19. }
  20. } // namespace Replex

hello.cpp 是我们需要热加载的动态库,代码如下

  1. #include <cstdio>
  2. extern "C" {
  3. void foo() {
  4. printf("Hi\n");
  5. }
  6. int bar = 200;
  7. }

其中使用 extern "C"foobar 声明为 C 语言的函数和变量,这样在编译时就不会对函数名进行修饰,否则在 main.cpp 中使用 dlsym 时会找不到 foo 对应的符号。

不加 extern "C"时,使用 nm 命令查看 hello.so 中的符号如下

  1. $ nm libhello.so | grep foo
  2. 0000000000001119 T _Z3foov

加上后

  1. $ nm libhello.so | grep foo
  2. 0000000000001119 T foo

main.cpp 是主程序,代码如下

  1. #include <cstdio>
  2. #include <string>
  3. #include "replex.h"
  4. const char* g_libPath = "libhello.so";
  5. int main() {
  6. void* handle;
  7. void (*foo)();
  8. int bar;
  9. handle = Replex::Load(g_libPath);
  10. if (!handle) {
  11. Replex::PrintError();
  12. return -1;
  13. }
  14. foo = reinterpret_cast<void (*)()>(Replex::LoadSymbol(handle, "foo"));
  15. foo();
  16. bar = *reinterpret_cast<int*>(Replex::LoadSymbol(handle, "bar"));
  17. printf("bar == %d\n", bar);
  18. // Modify the source code and recompile the library.
  19. std::string filename = "hello.cpp";
  20. std::string command = std::string("sed -i ") +
  21. (bar == 200 ? "'s/200/300/'" : "'s/300/200/'") + " " +
  22. filename;
  23. system(command.c_str());
  24. command = std::string("sed -i ") +
  25. (bar == 200 ? "'s/Hi/Hello/'" : "'s/Hello/Hi/'") + " " + filename;
  26. system(command.c_str());
  27. system("g++ -shared -fPIC -o libhello.so hello.cpp");
  28. Replex::Reload(handle, g_libPath);
  29. if (!handle) {
  30. Replex::PrintError();
  31. return -1;
  32. }
  33. foo = reinterpret_cast<void (*)()>(Replex::LoadSymbol(handle, "foo"));
  34. foo();
  35. bar = *reinterpret_cast<int*>(Replex::LoadSymbol(handle, "bar"));
  36. printf("bar == %d\n", bar);
  37. return 0;
  38. }

整体代码逻辑比较好懂,首先加载动态库,然后获取动态库中的函数和变量,调用函数和打印变量,然后修改 hello.cpp 中的代码,重新编译动态库,再次加载动态库,调用函数和打印变量。

reinterpret_cast 是 C++ 中的强制类型转换,将 void* 指针转换为函数指针和变量指针。

run.sh 的内容如下

  1. #!/bin/bash
  2. set -e # stop the script on errors
  3. g++ -fPIC -shared -o libhello.so hello.cpp
  4. g++ -o main.out main.cpp -ldl
  5. ./main.out

脚本中 -shared -fPIC 参数用于生成位置无关的动态库,-ldl 参数用于链接 libdl.so 库(dl* API),-o 参数用于指定输出文件名。

运行脚本后,输出如下

  1. Hi
  2. bar == 200
  3. Hello
  4. bar == 300

当前程序能够完成基本功能,但是对于使用者来说我们的库不够好用,使用者(main.cpp)需要自己定义相应的函数指针和类型,还需要自己进行类型转换,动态库的导出符号也需要自己定义,对于使用者来说也相当麻烦。

改进版本

我们考虑提供更简单的接口供用户使用,我们将在 replex.h 中创建一个 ReplexModule 类,这个类将用于给动态库的继承使用,然后由动态库的作者提供更加简明的接口供用户使用。

这一版本代码的完整实现见 GitHub

最终的使用效果见如下 main.cpp 文件

  1. #include <iostream>
  2. #include "hello.h"
  3. int main() {
  4. HelloModule::LoadLibrary();
  5. HelloModule::Foo();
  6. int bar = HelloModule::GetBar();
  7. std::cout << "bar == " << bar << std::endl;
  8. // Modify the source code and recompile the library.
  9. // ...
  10. HelloModule::ReloadLibrary();
  11. HelloModule::Foo();
  12. std::cout << "bar == " << HelloModule::GetBar() << std::endl;
  13. return 0;
  14. }

我们忽略中间的修改源码和重新编译的过程,这里只关注 HelloModule 的使用,相比于前一版本,这里的使用更加简单,不需要自己定义函数指针和变量,也不需要自己进行类型转换,只需要调用 HelloModule 中的接口即可。同时注意到我们包含的头文件也变成了 hello.h,这个头文件是动态库作者提供的,我们在 main.cpp 中只需要包含这个头文件即可。

针对于上述需求,ReplexModule 需要公开两个公共接口,一个用于发布可热加载库,另一个用于加载和重新加载这些可热加载库。

ReplexModule 的公开接口仅有两个,分别为 LoadLibraryReloadLibrary,代码如下

  1. #pragma once
  2. #include <dlfcn.h>
  3. #include <array>
  4. #include <iostream>
  5. #include <stdexcept>
  6. #include <string>
  7. #include <unordered_map>
  8. template <typename E, size_t NumSymbols>
  9. class ReplexModule {
  10. public:
  11. static void LoadLibrary() { GetInstance().Load(); }
  12. static void ReloadLibrary() { GetInstance().Reload(); }
  13. protected:
  14. static E& GetInstance() {
  15. static E instance;
  16. return instance;
  17. }
  18. // ...
  19. // ... continued later
  20. }

这两个函数都依赖于 GetInstance 函数,这个函数是一个模板函数,用于返回 ReplexModule 的子类的单例,这样可以保证每个子类只有一个实例。另外,ReplexModule 是一个模板类,模板参数 E 是一个枚举类型,用于指定动态库中的符号,NumSymbols 是一个常量,用于指定动态库中的符号个数。

接下来关注 ReplexModule 向动态库作者也就是集成该类的子类提供的接口,代码如下:

  1. // ... continued above
  2. // Should return the path to the library on disk
  3. virtual const char* GetPath() const = 0;
  4. // Should return a reference to an array of C-strings of size NumSymbols
  5. // Used when loading or reloading the library to lookup the address of
  6. // all exported symbols
  7. virtual std::array<const char*, NumSymbols>& GetSymbolNames() const = 0;
  8. template <typename Ret, typename... Args>
  9. Ret Execute(const char* name, Args... args) {
  10. // Lookup the function address
  11. auto symbol = m_symbols.find(name);
  12. if (symbol != m_symbols.end()) {
  13. // Cast the address to the appropriate function type and call it,
  14. // forwarding all arguments
  15. return reinterpret_cast<Ret (*)(Args...)>(symbol->second)(args...);
  16. }
  17. throw std::runtime_error(std::string("Function not found: ") + name);
  18. }
  19. template <typename T>
  20. T* GetVar(const char* name) {
  21. auto symbol = m_symbols.find(name);
  22. if (symbol != m_symbols.end()) {
  23. return static_cast<T*>(symbol->second);
  24. }
  25. // We didn't find the variable. Return an empty pointer
  26. return nullptr;
  27. }
  28. private:
  29. void Load() {
  30. m_libHandle = dlopen(GetPath(), RTLD_NOW);
  31. LoadSymbols();
  32. }
  33. void Reload() {
  34. auto ret = dlclose(m_libHandle);
  35. m_symbols.clear();
  36. Load();
  37. }
  38. void LoadSymbols() {
  39. for (const char* symbol : GetSymbolNames()) {
  40. auto* sym = dlsym(m_libHandle, symbol);
  41. m_symbols[symbol] = sym;
  42. }
  43. }
  44. void* m_libHandle;
  45. std::unordered_map<std::string, void*> m_symbols;
  46. };

首先关注最底部的数据成员,m_libHandle 是动态库的句柄,m_symbols 是一个哈希表,用于存储动态库中的符号和符号对应的地址。 Load 函数用于加载动态库,Reload 函数用于重新加载动态库,LoadSymbols 函数用于加载动态库中的符号,这几个函数的逻辑相当清晰无需赘述。

值得讲解的是 ExecuteGetVar 函数,Execute 函数用于调用动态库中的函数,GetVar 函数用于获取动态库中的变量,让我们先看看 Execute 函数的实现,代码如下

  1. template <typename Ret, typename... Args>
  2. Ret Execute(const char* name, Args... args) {
  3. // Lookup the function address
  4. auto symbol = m_symbols.find(name);
  5. if (symbol != m_symbols.end()) {
  6. // Cast the address to the appropriate function type and call it,
  7. // forwarding all arguments
  8. return reinterpret_cast<Ret (*)(Args...)>(symbol->second)(args...);
  9. }
  10. throw std::runtime_error(std::string("Function not found: ") + name);
  11. }

这是一个模板函数,模板参数 Ret 是返回值类型,Args... 是参数类型,这里的 Args... 表示可以接受任意多个参数,Args... args 表示将参数包 args 展开,然后将展开后的参数作为参数传递给 Execute 函数。

该函数首先在 m_symbols 中查找 name 对应的符号,如果找到了,就将符号地址转换为类型为 Ret (*)(Args...) 的函数指针,然后调用该函数,传递参数 args...,如果没有找到,就抛出异常。

GetVar 函数的实现如下

  1. template <typename T>
  2. T* GetVar(const char* name) {
  3. auto symbol = m_symbols.find(name);
  4. if (symbol != m_symbols.end()) {
  5. return static_cast<T*>(symbol->second);
  6. }
  7. // We didn't find the variable. Return an empty pointer
  8. return nullptr;
  9. }

该函数的实现和 Execute 函数类似,只是将函数指针转换为变量指针,然后返回。

hello.cpp 的内容保持不变:

  1. #include <cstdio>
  2. extern "C" {
  3. void foo() {
  4. printf("Hi\n");
  5. }
  6. int bar = 200;
  7. }

hello.h 中定义类 HelloModule 继承自 ReplexModule,代码如下

  1. #pragma once
  2. #include <array>
  3. #include "replex.h"
  4. inline std::array<const char*, 2> g_exports = {"foo", "bar"};
  5. class HelloModule : public ReplexModule<HelloModule, g_exports.size()> {
  6. public:
  7. static void Foo() { GetInstance().Execute<void>("foo"); }
  8. static int GetBar() { return *GetInstance().GetVar<int>("bar"); }
  9. protected:
  10. virtual const char* GetPath() const override { return "libhello.so"; }
  11. virtual std::array<const char*, g_exports.size()>& GetSymbolNames()
  12. const override {
  13. return g_exports;
  14. }
  15. };

变量 g_exports 用于存储动态库中需要导出的符号,其采用 inline 修饰,这样就可以在头文件中定义,而不会出现重复定义的错误。

HelloModule 中定义了两个静态函数,分别为 FooGetBar,这两个函数用于调用动态库中的函数和获取动态库中的变量。

运行脚本的内容基本不变,添加了 -std=c++17 的标志保证可以使用 inline 变量的用法。

  1. #!/bin/bash
  2. set -e # stop the script on errors
  3. g++ -fPIC -shared -o libhello.so hello.cpp -std=c++17
  4. g++ -o main.out main.cpp -ldl -std=c++17
  5. ./main.out

运行效果与前一版本一致,如下

  1. Hi
  2. bar == 200
  3. Hello
  4. bar == 300

现在我们可以认为我们所编写的 replex.h 库足方便使用,动态库作者只需要继承 ReplexModule 类,然后实现两个虚函数即可,使用者只需要包含动态库作者提供的头文件,然后调用相应的接口即可。

CMake 版本

前面两个版本的代码都是写个脚本直接使用 g++ 编译,这样的方式不够灵活,不利于项目的管理,正好这个项目涉及到几个不同的模块,可以尝试使用 CMake 进行管理,学习一下项目的组织构建。

完整代码见 projects/replex-3,采用 现代 CMake 模块化项目管理指南 中推荐的方式进行项目组织,但是略微进行了一点简化,目录结构如下

  1. .
  2. ├── CMakeLists.txt
  3. ├── hello
  4. ├── CMakeLists.txt
  5. ├── include
  6. └── hello.h
  7. └── src
  8. └── hello.cpp
  9. ├── main
  10. ├── CMakeLists.txt
  11. └── src
  12. └── main.cpp
  13. └── replex
  14. ├── CMakeLists.txt
  15. └── include
  16. └── replex.h

首先梳理一下整个项目的依赖关系,如下所示

  1. main (exe)
  2. ├── hello_interface (interface)
  3. └── replex (interface)
  4. └── hello (shared lib)

main 模块依赖于头文件库 hello_interface,hello_interface 依赖于头文件库 replex,动态库 hello 不依赖于任何库,用于提供给 main 模块使用。

CMakeLists.txt 为根目录的 CMakeLists.txt,内容如下

  1. cmake_minimum_required(VERSION 3.15)
  2. set(CMAKE_CXX_STANDARD 17)
  3. set(CMAKE_CXX_STANDARD_REQUIRED ON)
  4. set(CMAKE_CXX_EXTENSIONS OFF)
  5. project(replex LANGUAGES CXX)
  6. if (NOT CMAKE_BUILD_TYPE)
  7. set(CMAKE_BUILD_TYPE Release)
  8. endif ()
  9. add_subdirectory(replex)
  10. add_subdirectory(main)
  11. add_subdirectory(hello)

首先设置 C++ 标准,然后设置项目名称,然后判断是否设置了构建类型,如果没有设置,则设置为 Release 模式,然后添加子目录,分别为 replex、main 和 hello。

replex/CMakeLists.txt 的内容如下

  1. add_library(replex INTERFACE include/replex.h)
  2. target_include_directories(replex INTERFACE include)

replex 为头文件库,使用 add_library 添加,类型为 INTERFACE,表示这是一个接口库,不会生成任何文件,只会导出头文件,使用 target_include_directories 添加头文件路径。

hello/CMakeLists.txt 的内容如下

  1. add_library(hello SHARED src/hello.cpp)
  2. add_library(hello_interface INTERFACE include/hello.h)
  3. target_include_directories(hello_interface INTERFACE include)
  4. target_link_libraries(hello_interface INTERFACE replex)

其中定义了两个库,一个为动态库 hello,一个为头文件库 hello_interface 用于导出 动态库 hello 中的符号以供使用, hello_interface 依赖于 replex,使用 target_link_libraries 添加依赖。

main/CMakeLists.txt 的内容如下

  1. add_executable(main src/main.cpp)
  2. target_link_libraries(main PRIVATE hello_interface)

main 为可执行文件,使用 add_executable 添加,使用 target_link_libraries 添加依赖 hello_interface

最后运行脚本 run.sh,内容如下

  1. #!/bin/bash
  2. set -e # stop the script on errors
  3. cmake -B build
  4. cmake --build build
  5. ./build/main/main

运行的效果如下

  1. Hi
  2. bar == 200
  3. [ 0%] Built target replex
  4. [ 0%] Built target hello_interface
  5. [ 50%] Built target main
  6. [ 75%] Building CXX object hello/CMakeFiles/hello.dir/src/hello.cpp.o
  7. [100%] Linking CXX shared library libhello.so
  8. [100%] Built target hello
  9. Hello
  10. bar == 300

添加测试 (GoogleTest

这部分的完整代码见 projects/replex-4

一个好的项目,测试是必不可少的,前面我们实现的 main.cpp 中其实已经有了一点自动化测试的影子,但是这种方式不够好,我们可以使用 GoogleTest 来进行测试。

首先演示一个最基本的 gtest 用法,首先使用 git 的 submodule 命令添加 googletest 到我们的项目中

  1. git submodule add git@github.com:google/googletest.git

然后修改我们根目录下的 CMakeLists.txt,添加如下内容

  1. add_subdirectory(googletest)
  2. enable_testing()
  3. include_directories(${gtest_SOURCE_DIR}/include ${gtest_SOURCE_DIR})
  4. add_subdirectory(test)

创建 test 目录,结构如下

  1. test
  2. ├── CMakeLists.txt
  3. └── src
  4. └── test.cpp

test/CMakeLists.txt 的内容如下

  1. add_executable(tests src/test.cpp)
  2. target_link_libraries(tests PUBLIC gtest gtest_main)

test/src/test.cpp 的内容如下

  1. #include <gtest/gtest.h>
  2. TEST(SillyTest, IsFourPositive) {
  3. EXPECT_GT(4, 0);
  4. }
  5. TEST(SillyTest, IsFourTimesFourSixteen) {
  6. int x = 4;
  7. EXPECT_EQ(x * x, 16);
  8. }
  9. int main(int argc, char** argv) {
  10. // This allows us to call this executable with various command line
  11. // arguments which get parsed in InitGoogleTest
  12. ::testing::InitGoogleTest(&argc, argv);
  13. return RUN_ALL_TESTS();
  14. }

OK,到现在我们已经成功添加了 GoogleTest 到我们的项目中并且可以运行测试了,现在我们要编写一些测试来测试我们的项目。

我们编写一个 replex 的测试,测试内容如下

  1. #include <gtest/gtest.h>
  2. #include <hello.h>
  3. #include <cstdlib>
  4. #include <fstream>
  5. const char* g_Test_v1 = R"delimiter(
  6. extern "C" {
  7. int foo(int x) {
  8. return x + 5;
  9. }
  10. int bar = 3;
  11. }
  12. )delimiter";
  13. const char* g_Test_v2 = R"delimiter(
  14. extern "C" {
  15. int foo(int x) {
  16. return x - 5;
  17. }
  18. int bar = -2;
  19. }
  20. )delimiter";
  21. class ReplexTest : public ::testing::Test {
  22. public:
  23. // Called automatically at the start of each test case.
  24. virtual void SetUp() {
  25. WriteFile("hello/src/hello.cpp", g_Test_v1);
  26. Compile(1);
  27. HelloModule::LoadLibrary();
  28. }
  29. // We'll invoke this function manually in the middle of each test case
  30. void ChangeAndReload() {
  31. WriteFile("hello/src/hello.cpp", g_Test_v2);
  32. Compile(2);
  33. HelloModule::ReloadLibrary();
  34. }
  35. // Called automatically at the end of each test case.
  36. virtual void TearDown() {
  37. HelloModule::UnloadLibrary();
  38. WriteFile("hello/src/hello.cpp", g_Test_v1);
  39. Compile(1);
  40. }
  41. private:
  42. void WriteFile(const char* path, const char* text) {
  43. // Open an output filetream, deleting existing contents
  44. std::ofstream out(path, std::ios_base::trunc | std::ios_base::out);
  45. out << text;
  46. }
  47. void Compile(int version) {
  48. if (version == m_version) {
  49. return;
  50. }
  51. m_version = version;
  52. EXPECT_EQ(std::system("cmake --build build"), 0);
  53. // Super unfortunate sleep due to the result of cmake not being fully
  54. // flushed by the time the command returns (there are more elegant ways
  55. // to solve this)
  56. sleep(1);
  57. }
  58. int m_version = 1;
  59. };
  60. TEST_F(ReplexTest, VariableReload) {
  61. EXPECT_EQ(HelloModule::GetBar(), 3);
  62. ChangeAndReload();
  63. EXPECT_EQ(HelloModule::GetBar(), -2);
  64. }
  65. TEST_F(ReplexTest, FunctionReload) {
  66. EXPECT_EQ(HelloModule::Foo(4), 9);
  67. ChangeAndReload();
  68. EXPECT_EQ(HelloModule::Foo(4), -1);
  69. }
  70. int main(int argc, char** argv) {
  71. ::testing::InitGoogleTest(&argc, argv);
  72. return RUN_ALL_TESTS();
  73. }

要使得这个测试运行起来,还需要对 CMake 文件进行一些修改,这部分留作练习吧,动手试试会对 CMake 等有更深的理解。

相比较于 projects/replex-3,需要修改的文件有:

  1. 移除 main 文件夹
  2. 根目录下的 CMakeLists.txt
  3. hello/CMakeLists.txt
  4. hello/include/hello.h
  5. test/src/test.cpp

完整代码见 projects/replex-4


  1. Linux 下 C++so 热更新 ??

原文链接:https://www.cnblogs.com/zhangyi1357/p/17945251

 友情链接:直通硅谷  点职佳  北美留学生论坛

本站QQ群:前端 618073944 | Java 606181507 | Python 626812652 | C/C++ 612253063 | 微信 634508462 | 苹果 692586424 | C#/.net 182808419 | PHP 305140648 | 运维 608723728

W3xue 的所有内容仅供测试,对任何法律问题及风险不承担任何责任。通过使用本站内容随之而来的风险与本站无关。
关于我们  |  意见建议  |  捐助我们  |  报错有奖  |  广告合作、友情链接(目前9元/月)请联系QQ:27243702 沸活量
皖ICP备17017327号-2 皖公网安备34020702000426号