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
-
Linux 下 C++so 熱更新 ??
總結
以上是生活随笔為你收集整理的C++ 动态库热加载的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 【OpenCV】在 Mac OS 上使用
- 下一篇: Socket.D 替代 Http 协议像