• C++ 动态库热加载


    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,目录结构如下

    .
    ├── hello.cpp
    ├── main.cpp
    ├── replex.h
    └── run.sh

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

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

    #pragma once
    #include
    #include
    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
    extern "C" {
    void foo() {
    printf("Hi\n");
    }
    int bar = 200;
    }

    其中使用 extern "C"foobar 声明为 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
    #include
    #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
    #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 的公开接口仅有两个,分别为 LoadLibraryReloadLibrary,代码如下

    #pragma once
    #include
    #include
    #include
    #include
    #include
    #include
    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(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_mapvoid*> m_symbols;
    };

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

    值得讲解的是 ExecuteGetVar 函数,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(symbol->second);
    }
    // We didn't find the variable. Return an empty pointer
    return nullptr;
    }

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

    hello.cpp 的内容保持不变:

    #include
    extern "C" {
    void foo() {
    printf("Hi\n");
    }
    int bar = 200;
    }

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

    #pragma once
    #include
    #include "replex.h"
    inline std::array<const char*, 2> g_exports = {"foo", "bar"};
    class HelloModule : public ReplexModulesize()> {
    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 中定义了两个静态函数,分别为 FooGetBar,这两个函数用于调用动态库中的函数和获取动态库中的变量。

    运行脚本的内容基本不变,添加了 -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
    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
    #include
    #include
    #include
    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,需要修改的文件有:

    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 热更新 ↩︎

  • 相关阅读:
    PyTorch学习笔记(一)
    vue3 导出excl文件
    【深入浅出玩转FPGA9------经验点滴】
    Node.js 做 Web 后端的优势在哪?为什么是明智的选择?
    Vue2电商前台项目——完成Home首页模块业务
    一文掌握 Go 文件的写入操作
    Hadoop-HDFS分布式文件系统基础
    【web-避开客户端控件】(2.2.1)收集使用数据: 长度限制、资源副本、基于脚本的确认、禁用的元素
    Redis的三种持久化策略及选取建议
    【PTE-day05 宽字节注入】
  • 原文地址:https://www.cnblogs.com/zhangyi1357/p/17945251