综合实战:版本化 C ABI 插件系统 / Full Project: Versioned C ABI Plugin System
1. 项目目标
这一篇把前面的插件工程知识收束成一个更稳的实战项目:数据处理宿主 datahost 可以从 plugins/ 目录发现插件,但不会盲目加载任意动态库。每个插件必须携带 manifest,通过签名或 hash 策略,导出 plugin_query_api_v1,并返回版本化函数表。
目标不是写一个最大功能 demo,而是写一条完整链路:
discover -> manifest precheck -> load -> query api -> negotiate
-> init -> register -> run -> cancel -> shutdown -> unload2. 为什么不用裸 C++ 接口作为收束项目
如果综合项目最后仍然返回 IDataProcessor*,读者会误以为纯虚接口是默认安全边界。它在受控 ABI 家族里可以使用,但不适合作为跨编译器、跨供应商、长期兼容的默认方案。
本项目使用 C ABI 函数表,因为它能把版本、结构体大小、能力、错误码和所有权写进接口本身。
3. 项目文件结构
datahost/
CMakeLists.txt
include/
plugin_api.h
plugin_manifest.h
plugin_loader.h
src/
main.cpp
plugin_loader.cpp
manifest_reader.cpp
plugins/
uppercase/
plugin.json
uppercase.cpp
linecount/
plugin.json
linecount.cpp
tests/
old_plugin/
bad_manifest/
missing_symbol/4. 公共 ABI 头文件
#pragma once
#include <stddef.h>
#include <stdint.h>
#ifdef _WIN32
#define DP_EXPORT extern "C" __declspec(dllexport)
#else
#define DP_EXPORT extern "C" __attribute__((visibility("default")))
#endif
enum dp_status {
DP_OK = 0,
DP_INVALID_ARGUMENT = 1,
DP_UNSUPPORTED_API = 2,
DP_BUFFER_TOO_SMALL = 3,
DP_CANCELLED = 4,
DP_INTERNAL_ERROR = 5
};
enum dp_capability : uint64_t {
DP_CAP_STREAMING = 1ull << 0,
DP_CAP_CANCEL = 1ull << 1,
DP_CAP_STATEFUL = 1ull << 2
};
struct dp_host_services_v1 {
uint32_t api_version;
size_t struct_size;
void (*log)(int level, const char* message);
void* (*alloc)(size_t bytes);
void (*free)(void* ptr);
};
struct dp_plugin_descriptor_v1 {
uint32_t api_version;
size_t struct_size;
const char* id;
const char* name;
const char* version;
uint64_t capabilities;
};
struct dp_plugin_api_v1 {
uint32_t api_version;
size_t struct_size;
dp_status (*init)(const dp_host_services_v1* host);
dp_status (*describe)(dp_plugin_descriptor_v1* out);
dp_status (*process)(const char* input, char* output, size_t output_size);
dp_status (*cancel)(uint64_t request_id);
dp_status (*shutdown)();
};
DP_EXPORT dp_status plugin_query_api_v1(
const dp_host_services_v1* host,
dp_plugin_api_v1* out_api);5. manifest 预校验
{
"id": "com.example.uppercase",
"name": "Uppercase Processor",
"version": "1.2.0",
"api": 1,
"library": "uppercase_plugin",
"sha256": "filled-by-release-pipeline",
"capabilities": ["cancel"],
"permissions": ["read-input", "write-output"]
}宿主在加载动态库之前读取 manifest。至少检查:
- 插件 id 是否在允许目录策略内;
- api 是否在宿主支持范围内;
- library 文件名是否没有路径穿越;
- sha256 是否匹配;
- permissions 是否被当前工作流允许;
- version 是否满足依赖或黑名单策略。
6. 加载器状态机
Discovered
-> ManifestAccepted
-> LibraryLoaded
-> ApiQueried
-> Initialized
-> Registered
-> Running
-> Quiescing
-> Shutdown
-> Unloaded每个状态失败都要有恢复策略。LibraryLoaded 后失败必须关闭句柄;Initialized 后失败必须调用 shutdown;Running 中取消必须先进入 quiescing,不能直接卸载。
7. 宿主加载伪代码
loaded_plugin load_one(const manifest& m) {
validate_manifest_before_load(m);
auto lib = open_library(m.resolved_library_path);
auto query = lib.symbol<query_api_fn>("plugin_query_api_v1");
dp_host_services_v1 host{
.api_version = 1,
.struct_size = sizeof(dp_host_services_v1),
.log = host_log,
.alloc = host_alloc,
.free = host_free
};
dp_plugin_api_v1 api{};
api.api_version = 1;
api.struct_size = sizeof(dp_plugin_api_v1);
if (query(&host, &api) != DP_OK) {
throw load_error("api negotiation failed");
}
if (api.api_version != 1 || api.struct_size < offsetof(dp_plugin_api_v1, shutdown) + sizeof(api.shutdown)) {
throw load_error("incomplete api table");
}
if (api.init(&host) != DP_OK) {
throw load_error("plugin init failed");
}
return loaded_plugin{std::move(lib), api, m};
}8. 示例插件实现
static dp_status uppercase_process(const char* input, char* output, size_t output_size) {
if (!input || !output || output_size == 0) return DP_INVALID_ARGUMENT;
size_t n = 0;
for (; input[n] != '\0'; ++n) {
if (n + 1 >= output_size) return DP_BUFFER_TOO_SMALL;
char c = input[n];
output[n] = (c >= 'a' && c <= 'z') ? char(c - 'a' + 'A') : c;
}
output[n] = '\0';
return DP_OK;
}
DP_EXPORT dp_status plugin_query_api_v1(
const dp_host_services_v1* host,
dp_plugin_api_v1* out_api) {
if (!host || !out_api) return DP_INVALID_ARGUMENT;
if (host->api_version != 1) return DP_UNSUPPORTED_API;
if (out_api->struct_size < sizeof(dp_plugin_api_v1)) return DP_UNSUPPORTED_API;
out_api->api_version = 1;
out_api->struct_size = sizeof(dp_plugin_api_v1);
out_api->init = uppercase_init;
out_api->describe = uppercase_describe;
out_api->process = uppercase_process;
out_api->cancel = uppercase_cancel;
out_api->shutdown = uppercase_shutdown;
return DP_OK;
}9. 取消、关闭与卸载
卸载前必须满足:
- 不再接受新请求;
- 已发出 cancel 或 deadline;
- 活动调用引用计数归零;
- 插件撤销所有宿主回调;
- 插件线程已经 join;
- shutdown 返回成功或进入隔离失败状态;
- 最后才关闭动态库句柄。
Windows 上已加载 DLL 通常不能被覆盖,因此热更新要复制到版本化文件名,例如 uppercase-1.2.0-<hash>.dll,新版本加载成功后再切换路由,旧版本等引用归零后释放。
10. 测试矩阵
| 测试 | 做什么 | 应该失败的反例 |
|---|---|---|
| bad_manifest | 加载前拒绝非法 manifest | 路径穿越、hash 不匹配 |
| missing_symbol | 缺少 plugin_query_api_v1 | 返回清晰加载错误 |
| old_plugin | v1 插件在新宿主运行 | API 协商成功 |
| too_new_plugin | 插件要求 api v99 | 拒绝并提示版本 |
| cancel_running | 运行中取消 | 不卸载仍在执行的库 |
| shutdown_failure | shutdown 返回错误 | 标记隔离,不继续热替换 |
11. CMake 边界
插件 target 应使用 MODULE 或平台明确的 SHARED 策略,输出到版本化插件目录。基础 CMake 语法请回到 11 和 08;这里关注插件边界:
add_library(uppercase MODULE plugins/uppercase/uppercase.cpp)
target_include_directories(uppercase PRIVATE include)
target_compile_definitions(uppercase PRIVATE DP_PLUGIN_BUILD=1)
set_target_properties(uppercase PROPERTIES
PREFIX ""
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins/uppercase-1.2.0")12. 发布证据
每个插件包至少保留:
- manifest;
- 动态库;
- sha256;
- 符号导出报告;
- ABI/API 版本;
- 权限声明;
- 最小宿主 smoke test;
- shutdown/unload 测试日志;
- 回滚版本。
13. 练习
- 给
dp_plugin_api_v1增加可选 streaming 能力,要求老插件仍可加载。 - 写一个 manifest hash 不匹配的插件,确认宿主不会加载动态库。
- 模拟插件
process卡住,验证 cancel 和 unload 顺序。 - 在 Windows 上用版本化 DLL 文件名实现热替换。
- 把
plugin_query_api_v1缺失的错误写成用户可理解的诊断。
14. 总结
一个完整插件系统不是“扫描目录 + dlopen + 调函数”这么简单。真正重要的是发现前校验、接口协商、生命周期、取消、关闭、卸载、安全和发布证据。C ABI 函数表让这些约束都能写进稳定边界;C++ 复杂性留在插件内部,边界保持简单。
15. 工程深化:从示例走向可维护插件生态
《版本化 C ABI 插件系统》真正要训练的不是记住某个 API 名字,而是能把插件边界写成长期可执行的工程契约。下面这些切片可以作为 code review、实验和发布检查的素材。
design contract -> implementation -> failure injection -> CI evidence -> release rule深化切片 1:发现阶段
**工程问题。**扫描目录时只收集候选路径,不加载库。先做扩展名、目录白名单、manifest 名称和路径规范化检查。
**最小实验。**建立一个只包含宿主、一个插件、一个 manifest 和一条构建命令的小目录。先让正确路径通过,再故意破坏一个变量,例如版本号、结构体大小、入口符号、权限声明、线程关闭或输出缓冲区。
**观察证据。**记录宿主日志、动态加载错误、返回状态码、插件 id、api version、capabilities 和当前生命周期状态。错误消息应能让维护者知道失败发生在发现、加载、协商、初始化、运行、关闭还是卸载阶段。
candidate plugin
-> check one boundary
-> inject one failure
-> record one reproducible proof**复盘问题。**如果这个插件由第三方供应商构建、两年后升级、或在 Windows/Linux 两个平台发布,这条规则是否仍然能保护宿主?如果不能,应把它升级为 manifest 字段、ABI policy、CI 测试或加载器状态检查。
深化切片 2:manifest 阶段
**工程问题。**读取 id、version、api、library、sha256、permissions、capabilities。任何字段缺失都应在加载前失败。
**最小实验。**建立一个只包含宿主、一个插件、一个 manifest 和一条构建命令的小目录。先让正确路径通过,再故意破坏一个变量,例如版本号、结构体大小、入口符号、权限声明、线程关闭或输出缓冲区。
**观察证据。**记录宿主日志、动态加载错误、返回状态码、插件 id、api version、capabilities 和当前生命周期状态。错误消息应能让维护者知道失败发生在发现、加载、协商、初始化、运行、关闭还是卸载阶段。
candidate plugin
-> check one boundary
-> inject one failure
-> record one reproducible proof**复盘问题。**如果这个插件由第三方供应商构建、两年后升级、或在 Windows/Linux 两个平台发布,这条规则是否仍然能保护宿主?如果不能,应把它升级为 manifest 字段、ABI policy、CI 测试或加载器状态检查。
深化切片 3:信任阶段
**工程问题。**校验 hash、签名或来源策略。企业插件生态还要记录发布者、证书、吊销列表和允许加载的 channel。
**最小实验。**建立一个只包含宿主、一个插件、一个 manifest 和一条构建命令的小目录。先让正确路径通过,再故意破坏一个变量,例如版本号、结构体大小、入口符号、权限声明、线程关闭或输出缓冲区。
**观察证据。**记录宿主日志、动态加载错误、返回状态码、插件 id、api version、capabilities 和当前生命周期状态。错误消息应能让维护者知道失败发生在发现、加载、协商、初始化、运行、关闭还是卸载阶段。
candidate plugin
-> check one boundary
-> inject one failure
-> record one reproducible proof**复盘问题。**如果这个插件由第三方供应商构建、两年后升级、或在 Windows/Linux 两个平台发布,这条规则是否仍然能保护宿主?如果不能,应把它升级为 manifest 字段、ABI policy、CI 测试或加载器状态检查。
深化切片 4:加载阶段
**工程问题。**使用 RTLD_LOCAL 或平台等价策略减少符号污染;Windows 上记录 GetLastError,Linux 上记录 dlerror。
**最小实验。**建立一个只包含宿主、一个插件、一个 manifest 和一条构建命令的小目录。先让正确路径通过,再故意破坏一个变量,例如版本号、结构体大小、入口符号、权限声明、线程关闭或输出缓冲区。
**观察证据。**记录宿主日志、动态加载错误、返回状态码、插件 id、api version、capabilities 和当前生命周期状态。错误消息应能让维护者知道失败发生在发现、加载、协商、初始化、运行、关闭还是卸载阶段。
candidate plugin
-> check one boundary
-> inject one failure
-> record one reproducible proof**复盘问题。**如果这个插件由第三方供应商构建、两年后升级、或在 Windows/Linux 两个平台发布,这条规则是否仍然能保护宿主?如果不能,应把它升级为 manifest 字段、ABI policy、CI 测试或加载器状态检查。
深化切片 5:查询阶段
**工程问题。**只查找一个入口:plugin_query_api_v1。入口缺失说明这不是本宿主支持的插件,不应继续猜其他符号。
**最小实验。**建立一个只包含宿主、一个插件、一个 manifest 和一条构建命令的小目录。先让正确路径通过,再故意破坏一个变量,例如版本号、结构体大小、入口符号、权限声明、线程关闭或输出缓冲区。
**观察证据。**记录宿主日志、动态加载错误、返回状态码、插件 id、api version、capabilities 和当前生命周期状态。错误消息应能让维护者知道失败发生在发现、加载、协商、初始化、运行、关闭还是卸载阶段。
candidate plugin
-> check one boundary
-> inject one failure
-> record one reproducible proof**复盘问题。**如果这个插件由第三方供应商构建、两年后升级、或在 Windows/Linux 两个平台发布,这条规则是否仍然能保护宿主?如果不能,应把它升级为 manifest 字段、ABI policy、CI 测试或加载器状态检查。
深化切片 6:协商阶段
**工程问题。**检查 api_version、struct_size、capabilities 和 required host services。失败要能告诉用户是宿主太旧还是插件太旧。
**最小实验。**建立一个只包含宿主、一个插件、一个 manifest 和一条构建命令的小目录。先让正确路径通过,再故意破坏一个变量,例如版本号、结构体大小、入口符号、权限声明、线程关闭或输出缓冲区。
**观察证据。**记录宿主日志、动态加载错误、返回状态码、插件 id、api version、capabilities 和当前生命周期状态。错误消息应能让维护者知道失败发生在发现、加载、协商、初始化、运行、关闭还是卸载阶段。
candidate plugin
-> check one boundary
-> inject one failure
-> record one reproducible proof**复盘问题。**如果这个插件由第三方供应商构建、两年后升级、或在 Windows/Linux 两个平台发布,这条规则是否仍然能保护宿主?如果不能,应把它升级为 manifest 字段、ABI policy、CI 测试或加载器状态检查。
深化切片 7:初始化阶段
**工程问题。**init 只能注册资源和读取配置,不应启动不可取消的后台线程。启动线程必须登记到生命周期管理器。
**最小实验。**建立一个只包含宿主、一个插件、一个 manifest 和一条构建命令的小目录。先让正确路径通过,再故意破坏一个变量,例如版本号、结构体大小、入口符号、权限声明、线程关闭或输出缓冲区。
**观察证据。**记录宿主日志、动态加载错误、返回状态码、插件 id、api version、capabilities 和当前生命周期状态。错误消息应能让维护者知道失败发生在发现、加载、协商、初始化、运行、关闭还是卸载阶段。
candidate plugin
-> check one boundary
-> inject one failure
-> record one reproducible proof**复盘问题。**如果这个插件由第三方供应商构建、两年后升级、或在 Windows/Linux 两个平台发布,这条规则是否仍然能保护宿主?如果不能,应把它升级为 manifest 字段、ABI policy、CI 测试或加载器状态检查。
深化切片 8:注册阶段
**工程问题。**插件向宿主注册 command、processor、view 或 importer 时,宿主保存的是描述符和函数表,不保存插件内部对象布局。
**最小实验。**建立一个只包含宿主、一个插件、一个 manifest 和一条构建命令的小目录。先让正确路径通过,再故意破坏一个变量,例如版本号、结构体大小、入口符号、权限声明、线程关闭或输出缓冲区。
**观察证据。**记录宿主日志、动态加载错误、返回状态码、插件 id、api version、capabilities 和当前生命周期状态。错误消息应能让维护者知道失败发生在发现、加载、协商、初始化、运行、关闭还是卸载阶段。
candidate plugin
-> check one boundary
-> inject one failure
-> record one reproducible proof**复盘问题。**如果这个插件由第三方供应商构建、两年后升级、或在 Windows/Linux 两个平台发布,这条规则是否仍然能保护宿主?如果不能,应把它升级为 manifest 字段、ABI policy、CI 测试或加载器状态检查。
深化切片 9:运行阶段
**工程问题。**每次调用都带 request id、取消令牌或上下文,便于日志关联和 shutdown 时排空。
**最小实验。**建立一个只包含宿主、一个插件、一个 manifest 和一条构建命令的小目录。先让正确路径通过,再故意破坏一个变量,例如版本号、结构体大小、入口符号、权限声明、线程关闭或输出缓冲区。
**观察证据。**记录宿主日志、动态加载错误、返回状态码、插件 id、api version、capabilities 和当前生命周期状态。错误消息应能让维护者知道失败发生在发现、加载、协商、初始化、运行、关闭还是卸载阶段。
candidate plugin
-> check one boundary
-> inject one failure
-> record one reproducible proof**复盘问题。**如果这个插件由第三方供应商构建、两年后升级、或在 Windows/Linux 两个平台发布,这条规则是否仍然能保护宿主?如果不能,应把它升级为 manifest 字段、ABI policy、CI 测试或加载器状态检查。
深化切片 10:取消阶段
**工程问题。**取消是请求,不是强杀。插件应周期性检查取消标志,宿主设置 deadline,超时进入隔离或进程外终止。
**最小实验。**建立一个只包含宿主、一个插件、一个 manifest 和一条构建命令的小目录。先让正确路径通过,再故意破坏一个变量,例如版本号、结构体大小、入口符号、权限声明、线程关闭或输出缓冲区。
**观察证据。**记录宿主日志、动态加载错误、返回状态码、插件 id、api version、capabilities 和当前生命周期状态。错误消息应能让维护者知道失败发生在发现、加载、协商、初始化、运行、关闭还是卸载阶段。
candidate plugin
-> check one boundary
-> inject one failure
-> record one reproducible proof**复盘问题。**如果这个插件由第三方供应商构建、两年后升级、或在 Windows/Linux 两个平台发布,这条规则是否仍然能保护宿主?如果不能,应把它升级为 manifest 字段、ABI policy、CI 测试或加载器状态检查。
深化切片 11:关闭阶段
**工程问题。**shutdown 后插件不得再调用宿主回调,不得再提交 UI/事件/后台任务。宿主应拒绝 late callback 并记录。
**最小实验。**建立一个只包含宿主、一个插件、一个 manifest 和一条构建命令的小目录。先让正确路径通过,再故意破坏一个变量,例如版本号、结构体大小、入口符号、权限声明、线程关闭或输出缓冲区。
**观察证据。**记录宿主日志、动态加载错误、返回状态码、插件 id、api version、capabilities 和当前生命周期状态。错误消息应能让维护者知道失败发生在发现、加载、协商、初始化、运行、关闭还是卸载阶段。
candidate plugin
-> check one boundary
-> inject one failure
-> record one reproducible proof**复盘问题。**如果这个插件由第三方供应商构建、两年后升级、或在 Windows/Linux 两个平台发布,这条规则是否仍然能保护宿主?如果不能,应把它升级为 manifest 字段、ABI policy、CI 测试或加载器状态检查。
深化切片 12:卸载阶段
**工程问题。**引用计数归零、线程停止、回调撤销、资源释放后才能关闭库句柄。Windows 热更新使用版本化文件名避免覆盖已加载 DLL。
**最小实验。**建立一个只包含宿主、一个插件、一个 manifest 和一条构建命令的小目录。先让正确路径通过,再故意破坏一个变量,例如版本号、结构体大小、入口符号、权限声明、线程关闭或输出缓冲区。
**观察证据。**记录宿主日志、动态加载错误、返回状态码、插件 id、api version、capabilities 和当前生命周期状态。错误消息应能让维护者知道失败发生在发现、加载、协商、初始化、运行、关闭还是卸载阶段。
candidate plugin
-> check one boundary
-> inject one failure
-> record one reproducible proof**复盘问题。**如果这个插件由第三方供应商构建、两年后升级、或在 Windows/Linux 两个平台发布,这条规则是否仍然能保护宿主?如果不能,应把它升级为 manifest 字段、ABI policy、CI 测试或加载器状态检查。
深化切片 13:回滚阶段
**工程问题。**保留上一版 manifest、库、符号和 hash。新插件加载失败时恢复路由,不让半初始化插件留在 registry。
**最小实验。**建立一个只包含宿主、一个插件、一个 manifest 和一条构建命令的小目录。先让正确路径通过,再故意破坏一个变量,例如版本号、结构体大小、入口符号、权限声明、线程关闭或输出缓冲区。
**观察证据。**记录宿主日志、动态加载错误、返回状态码、插件 id、api version、capabilities 和当前生命周期状态。错误消息应能让维护者知道失败发生在发现、加载、协商、初始化、运行、关闭还是卸载阶段。
candidate plugin
-> check one boundary
-> inject one failure
-> record one reproducible proof**复盘问题。**如果这个插件由第三方供应商构建、两年后升级、或在 Windows/Linux 两个平台发布,这条规则是否仍然能保护宿主?如果不能,应把它升级为 manifest 字段、ABI policy、CI 测试或加载器状态检查。
深化切片 14:测试阶段
**工程问题。**测试坏 manifest、缺入口符号、api 过新、struct_size 过小、process 超时、shutdown 失败和卸载后 late callback。
**最小实验。**建立一个只包含宿主、一个插件、一个 manifest 和一条构建命令的小目录。先让正确路径通过,再故意破坏一个变量,例如版本号、结构体大小、入口符号、权限声明、线程关闭或输出缓冲区。
**观察证据。**记录宿主日志、动态加载错误、返回状态码、插件 id、api version、capabilities 和当前生命周期状态。错误消息应能让维护者知道失败发生在发现、加载、协商、初始化、运行、关闭还是卸载阶段。
candidate plugin
-> check one boundary
-> inject one failure
-> record one reproducible proof**复盘问题。**如果这个插件由第三方供应商构建、两年后升级、或在 Windows/Linux 两个平台发布,这条规则是否仍然能保护宿主?如果不能,应把它升级为 manifest 字段、ABI policy、CI 测试或加载器状态检查。
深化切片 15:发现阶段
**工程问题。**扫描目录时只收集候选路径,不加载库。先做扩展名、目录白名单、manifest 名称和路径规范化检查。
**最小实验。**建立一个只包含宿主、一个插件、一个 manifest 和一条构建命令的小目录。先让正确路径通过,再故意破坏一个变量,例如版本号、结构体大小、入口符号、权限声明、线程关闭或输出缓冲区。
**观察证据。**记录宿主日志、动态加载错误、返回状态码、插件 id、api version、capabilities 和当前生命周期状态。错误消息应能让维护者知道失败发生在发现、加载、协商、初始化、运行、关闭还是卸载阶段。
candidate plugin
-> check one boundary
-> inject one failure
-> record one reproducible proof**复盘问题。**如果这个插件由第三方供应商构建、两年后升级、或在 Windows/Linux 两个平台发布,这条规则是否仍然能保护宿主?如果不能,应把它升级为 manifest 字段、ABI policy、CI 测试或加载器状态检查。
16. 收束检查
- 是否存在一个唯一、稳定、可查找的 C ABI 入口?
- 是否在加载前完成 manifest、路径、版本和信任检查?
- 是否用
api_version、struct_size和 capabilities 完成协商? - 是否明确内存、错误、异常、线程、回调和卸载责任?
- 是否有坏插件、旧插件、新宿主、取消、shutdown 失败和回滚测试?