Plugin System in C++
C++ plugin architecture — dynamic loading with dlopen/LoadLibrary, plugin interfaces, versioning, hot reload, and CMake shared library setup.
TL;DR
C++ plugin systems use shared libraries loaded at runtime via dlopen (POSIX) or LoadLibrary (Windows). Define a stable C interface or versioned ABI for plugins, and use a factory function (create_plugin) to avoid C++ name mangling issues.
Plugin Interface Design
// plugin_api.h — shared between host and all plugins
// Use only C++ features with stable ABI: vtables, C linkage
#pragma once
#include <cstdint>
// Version the API so plugins can check compatibility
struct PluginVersion {
uint16_t major; // breaking changes
uint16_t minor; // backward-compatible additions
};
constexpr PluginVersion kPluginApiVersion{2, 0};
// Plugin interface — pure virtual, no data members
struct Plugin {
virtual const char* name() const = 0;
virtual const char* version() const = 0;
virtual PluginVersion api_version() const = 0;
virtual bool initialize(const char* config) = 0;
virtual void process(const void* data, size_t size) = 0;
virtual void shutdown() = 0;
virtual ~Plugin() = default;
};
// C linkage factory functions — exported from the shared library
extern "C" {
Plugin* create_plugin();
void destroy_plugin(Plugin*);
PluginVersion plugin_api_version(); // pre-load version check
}Plugin Implementation (in .so/.dll)
// my_plugin.cpp — compiled as a shared library
#include "plugin_api.h"
#include <cstring>
class MyPlugin : public Plugin {
bool initialized_ = false;
public:
const char* name() const override { return "MyPlugin"; }
const char* version() const override { return "1.0.0"; }
PluginVersion api_version() const override { return {2, 0}; }
bool initialize(const char* config) override {
// parse config, set up resources
initialized_ = true;
return true;
}
void process(const void* data, size_t size) override {
if (!initialized_) return;
// process data...
}
void shutdown() override { initialized_ = false; }
};
extern "C" {
Plugin* create_plugin() { return new MyPlugin; }
void destroy_plugin(Plugin* p){ delete p; }
PluginVersion plugin_api_version(){ return {2, 0}; }
}Plugin Loader (Host Side)
// plugin_loader.h — POSIX version
#include "plugin_api.h"
#include <dlfcn.h>
#include <stdexcept>
#include <memory>
#include <filesystem>
class PluginLoader {
void* handle_ = nullptr;
Plugin* plugin_ = nullptr;
void (*destroy_)(Plugin*) = nullptr;
public:
explicit PluginLoader(const std::filesystem::path& path) {
// RTLD_LAZY: resolve symbols on use; RTLD_LOCAL: don't pollute global namespace
handle_ = dlopen(path.c_str(), RTLD_LAZY | RTLD_LOCAL);
if (!handle_)
throw std::runtime_error(std::string("dlopen failed: ") + dlerror());
// Check API version before constructing plugin
using VersionFn = PluginVersion(*)();
auto version_fn = reinterpret_cast<VersionFn>(dlsym(handle_, "plugin_api_version"));
if (!version_fn)
throw std::runtime_error("missing plugin_api_version symbol");
auto ver = version_fn();
if (ver.major != kPluginApiVersion.major)
throw std::runtime_error("plugin API major version mismatch");
// Get factory and destructor
using CreateFn = Plugin*(*)();
using DestroyFn = void(*)(Plugin*);
auto create = reinterpret_cast<CreateFn>(dlsym(handle_, "create_plugin"));
destroy_ = reinterpret_cast<DestroyFn>(dlsym(handle_, "destroy_plugin"));
if (!create || !destroy_)
throw std::runtime_error("missing create/destroy symbols");
plugin_ = create();
if (!plugin_) throw std::runtime_error("create_plugin returned null");
}
~PluginLoader() {
if (plugin_ && destroy_) destroy_(plugin_);
if (handle_) dlclose(handle_);
}
// Non-copyable, movable
PluginLoader(const PluginLoader&) = delete;
PluginLoader& operator=(const PluginLoader&) = delete;
Plugin* get() { return plugin_; }
Plugin* operator->() { return plugin_; }
};Plugin Registry
class PluginRegistry {
std::vector<std::unique_ptr<PluginLoader>> loaders_;
std::unordered_map<std::string, Plugin*> by_name_;
public:
void load_dir(const std::filesystem::path& dir) {
for (const auto& entry : std::filesystem::directory_iterator(dir)) {
if (entry.path().extension() != ".so") continue;
try {
auto loader = std::make_unique<PluginLoader>(entry.path());
Plugin* p = loader->get();
by_name_[p->name()] = p;
loaders_.push_back(std::move(loader));
std::println("loaded plugin: {} v{}", p->name(), p->version());
} catch (const std::exception& e) {
std::println(stderr, "failed to load {}: {}", entry.path().string(), e.what());
}
}
}
Plugin* find(std::string_view name) const {
auto it = by_name_.find(std::string{name});
return it != by_name_.end() ? it->second : nullptr;
}
size_t size() const { return loaders_.size(); }
};
// Usage
PluginRegistry registry;
registry.load_dir("plugins/");
if (auto* p = registry.find("MyPlugin")) {
p->initialize("{}");
p->process(data.data(), data.size());
p->shutdown();
}Hot Reload Pattern
class ReloadablePlugin {
std::filesystem::path path_;
std::filesystem::file_time_type last_modified_;
std::unique_ptr<PluginLoader> loader_;
public:
explicit ReloadablePlugin(std::filesystem::path p) : path_{std::move(p)} {
reload();
}
bool check_reload() {
auto mtime = std::filesystem::last_write_time(path_);
if (mtime != last_modified_) {
reload();
return true;
}
return false;
}
Plugin* get() { return loader_ ? loader_->get() : nullptr; }
private:
void reload() {
loader_ = std::make_unique<PluginLoader>(path_);
last_modified_ = std::filesystem::last_write_time(path_);
loader_->get()->initialize("{}");
std::println("reloaded {}", path_.filename().string());
}
};
// In main loop:
ReloadablePlugin rp{"plugins/renderer.so"};
while (running) {
rp.check_reload(); // reload if .so changed on disk
if (auto* p = rp.get())
p->process(frame_data.data(), frame_data.size());
}CMake: Building a Plugin
# Host application
add_executable(host main.cpp plugin_loader.cpp)
target_link_libraries(host PRIVATE dl) # link libdl on Linux
# Plugin (shared library)
add_library(my_plugin SHARED my_plugin.cpp)
target_include_directories(my_plugin PRIVATE ${CMAKE_SOURCE_DIR}/include)
# Remove "lib" prefix on Linux so plugin can be named "my_plugin.so"
set_target_properties(my_plugin PROPERTIES PREFIX "")
# Copy plugin to output directory
add_custom_command(TARGET my_plugin POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy $<TARGET_FILE:my_plugin>
${CMAKE_BINARY_DIR}/plugins/)ABI Stability Tips
- Avoid
std::string,std::vectorin plugin interfaces — their layout can differ between compilers and standard library versions - Use
const char*, raw pointers, and sizes for data passing - Never return C++ exceptions across the plugin boundary — catch all in plugin, return error codes
- Version the API struct explicitly; bump major version on any breaking change
Plugin Discovery
Scanning a directory for plugins is the most common discovery strategy. A manifest file alongside each .so gives the host metadata (name, version, dependencies) before loading the library, which avoids paying the dlopen cost for incompatible plugins.
// plugin_manifest.json — sits next to my_plugin.so
// {
// "name": "MyPlugin",
// "api_major": 2,
// "provides": ["renderer"],
// "depends": []
// }
#include <nlohmann/json.hpp>
#include <filesystem>
#include <fstream>
struct PluginManifest {
std::string name;
int api_major;
std::vector<std::string> provides;
std::vector<std::string> depends;
};
std::optional<PluginManifest> load_manifest(const std::filesystem::path& so_path) {
auto json_path = so_path;
json_path.replace_extension(".json");
if (!std::filesystem::exists(json_path)) return std::nullopt;
std::ifstream f{json_path};
auto j = nlohmann::json::parse(f, nullptr, /*exceptions=*/false);
if (j.is_discarded()) return std::nullopt;
return PluginManifest{
j.value("name", ""),
j.value("api_major", 0),
j.value("provides", std::vector<std::string>{}),
j.value("depends", std::vector<std::string>{}),
};
}
void discover_plugins(const std::filesystem::path& dir, int required_api_major) {
for (const auto& entry : std::filesystem::directory_iterator(dir)) {
if (entry.path().extension() != ".so") continue;
auto manifest = load_manifest(entry.path());
if (!manifest) continue;
if (manifest->api_major != required_api_major) {
std::println(stderr, "skip {}: api {} != {}", manifest->name,
manifest->api_major, required_api_major);
continue;
}
// safe to dlopen now
}
}Error Handling Across Plugin Boundaries
C++ exceptions must never cross a plugin boundary — the plugin and host may have been compiled with different compilers or RTTI settings. The safest contract is a C-style error-code return plus a last_error() accessor that returns a static string.
// In plugin_api.h
enum class PluginError : int32_t {
Ok = 0,
InvalidConfig= -1,
OutOfMemory = -2,
Unsupported = -3,
Internal = -99,
};
struct Plugin {
// ... existing methods ...
// All operations return an error code; never throw
virtual PluginError initialize(const char* config_json) noexcept = 0;
virtual PluginError process(const void* data, size_t size) noexcept = 0;
// Thread-local last-error detail string (valid until next call)
virtual const char* last_error() const noexcept = 0;
virtual ~Plugin() = default;
};
// Plugin implementation wraps internals with try/catch
PluginError MyPlugin::initialize(const char* cfg) noexcept {
try {
parse_config(cfg);
return PluginError::Ok;
} catch (const std::bad_alloc&) {
last_error_ = "out of memory during init";
return PluginError::OutOfMemory;
} catch (const std::exception& e) {
last_error_ = e.what();
return PluginError::Internal;
}
}
// Host checks result
if (auto err = plugin->initialize(cfg); err != PluginError::Ok)
std::println(stderr, "plugin init failed ({}): {}", int(err), plugin->last_error());Thread Safety in Plugin Systems
If multiple threads call plugin methods concurrently, each plugin must document its threading contract explicitly. A simple capability flag in the manifest prevents the host from scheduling parallel calls to plugins that are not thread-safe.
// Extend PluginVersion or add a capabilities bitmap
enum class PluginCaps : uint32_t {
None = 0,
ThreadSafe = 1 << 0, // process() safe to call from multiple threads
Reentrant = 1 << 1, // safe to call from signal/callback within process()
};
struct Plugin {
virtual PluginCaps capabilities() const noexcept = 0;
// ...
};
// Host dispatch — serialise calls for non-thread-safe plugins
class SafePluginProxy {
Plugin* plugin_;
std::mutex mu_; // only used when not thread-safe
public:
explicit SafePluginProxy(Plugin* p) : plugin_{p} {}
PluginError process(const void* data, size_t size) {
bool ts = (plugin_->capabilities() & PluginCaps::ThreadSafe) != PluginCaps::None;
if (ts) return plugin_->process(data, size);
std::scoped_lock lock{mu_};
return plugin_->process(data, size);
}
};Hot Reload Workflow
A clean hot-reload sequence is: drain in-flight work → destroy the old plugin → unload the library → load the new library → re-initialize. Skipping the drain step causes use-after-free if a thread is still inside process() when the library is unmapped.
class HotReloadManager {
std::atomic<bool> draining_{false};
std::atomic<int> in_flight_{0};
std::mutex reload_mu_;
std::unique_ptr<PluginLoader> loader_;
std::filesystem::path path_;
public:
// Called by worker threads before/after using the plugin
struct Guard {
HotReloadManager& mgr;
Guard(HotReloadManager& m) : mgr{m} { ++mgr.in_flight_; }
~Guard() { --mgr.in_flight_; }
};
Guard acquire() { return Guard{*this}; }
Plugin* get() { return loader_ ? loader_->get() : nullptr; }
void reload() {
// 1. Signal drain and spin-wait for workers to finish
draining_ = true;
while (in_flight_.load() > 0) std::this_thread::yield();
// 2. Replace loader under lock (no other thread holds plugin ref now)
std::scoped_lock lock{reload_mu_};
loader_ = std::make_unique<PluginLoader>(path_);
loader_->get()->initialize("{}");
draining_ = false;
std::println("hot-reloaded {}", path_.filename().string());
}
};
// Worker thread:
// auto g = mgr.acquire(); // bump in-flight counter
// mgr.get()->process(data, sz); // safe — reload will wait for usCross-Platform Portability
dlopen/dlsym/dlclose on POSIX and LoadLibrary/GetProcAddress/FreeLibrary on Windows have the same logical model but differ in error reporting and symbol visibility defaults. A thin abstraction removes the #ifdef clutter from loader code.
// dynlib.h — portable dynamic library handle
#pragma once
#include <string>
#include <stdexcept>
#ifdef _WIN32
# define WIN32_LEAN_AND_MEAN
# include <windows.h>
using DynHandle = HMODULE;
inline DynHandle dynlib_open(const char* path) { return LoadLibraryA(path); }
inline void* dynlib_sym (DynHandle h, const char* sym) {
return reinterpret_cast<void*>(GetProcAddress(h, sym)); }
inline void dynlib_close(DynHandle h) { FreeLibrary(h); }
inline std::string dynlib_error() {
char buf[256]; FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM,nullptr,
GetLastError(),0,buf,sizeof(buf),nullptr); return buf; }
#else
# include <dlfcn.h>
using DynHandle = void*;
inline DynHandle dynlib_open(const char* path) {
return dlopen(path, RTLD_LAZY | RTLD_LOCAL); }
inline void* dynlib_sym (DynHandle h, const char* sym) { return dlsym(h,sym); }
inline void dynlib_close(DynHandle h) { dlclose(h); }
inline std::string dynlib_error() { return dlerror() ?: "unknown"; }
#endif| Concern | POSIX (dlopen) | Windows (LoadLibrary) |
|---|---|---|
| Default symbol visibility | All exported | __declspec(dllexport) required |
| Search path env var | LD_LIBRARY_PATH | PATH or SetDllDirectory |
| Extension | .so (Linux) / .dylib (macOS) | .dll |
| Debug suffix convention | None | Often -d.dll |
| Thread-safe close | Yes | Yes |
Security Considerations
Loading arbitrary shared libraries is equivalent to running arbitrary code — a malicious .so executes immediately inside your process with your privileges. Practical mitigations layer code signing, path allowlisting, and capability-limited interfaces.
// 1. Allowlist plugin directories — never load from user-writable paths
void validate_plugin_path(const std::filesystem::path& p) {
const std::filesystem::path allowed{"/opt/myapp/plugins"};
if (!p.string().starts_with(allowed.string()))
throw std::runtime_error("plugin outside allowed directory: " + p.string());
}
// 2. Expose only the minimum interface to plugins
// Good: plugin receives a narrow "context" object
struct HostContext {
virtual void log(const char* msg) noexcept = 0;
virtual bool read_config(const char* key, char* out, size_t len) noexcept = 0;
// NOT: a raw file handle, socket, or memory allocator
};
// 3. Capability flags in manifest (defence-in-depth before load)
// If manifest claims Networking but your app doesn't need it, reject load.On Linux, additional hardening options include running plugins in a child process connected via a Unix socket (true isolation), or using seccomp to restrict syscalls available to plugin code.
Testing Plugin Systems
Test two concerns separately: the plugin loader mechanism itself, and each plugin's behaviour against the interface contract. A mock plugin compiled directly (not via dlopen) lets you test host logic without filesystem dependencies.
// test_host.cpp — mock plugin, linked directly, no dlopen needed
#include "plugin_api.h"
#include <catch2/catch_test_macros.hpp>
class MockPlugin : public Plugin {
public:
const char* name() const noexcept override { return "MockPlugin"; }
const char* version() const noexcept override { return "0.0.1"; }
PluginVersion api_version() const noexcept override { return {2, 0}; }
PluginError initialize(const char*) noexcept override { return PluginError::Ok; }
PluginError process(const void*, size_t) noexcept override { return PluginError::Ok; }
void shutdown() noexcept override {}
const char* last_error() const noexcept override { return ""; }
};
TEST_CASE("registry finds plugin by name") {
PluginRegistry reg;
reg.register_direct(std::make_unique<MockPlugin>());
REQUIRE(reg.find("MockPlugin") != nullptr);
}
TEST_CASE("SafePluginProxy serialises calls for non-thread-safe plugin") {
MockPlugin mp;
SafePluginProxy proxy{&mp};
// Call from two threads simultaneously — should not crash
std::thread t1{[&]{ proxy.process(nullptr, 0); }};
std::thread t2{[&]{ proxy.process(nullptr, 0); }};
t1.join(); t2.join();
}For interface contract tests, run the same test suite against every plugin binary in the plugins directory to verify ABI compliance.
See Also
- Dynamic Loading Reference —
dlopenflags, symbol lookup, RTLD_GLOBAL pitfalls - ABI Stability — vtable layout, name mangling,
-fvisibility=hidden - CMake Shared Libraries —
add_library(SHARED),POSITION_INDEPENDENT_CODE, install rules - Error Handling — when to use error codes vs exceptions vs
std::expected