
1. 升级后 bag 回放全崩
上次把 SLAM 后端做成 .so 热插拔,升级后旧 bag 回放全崩——根因是 std::vector 跨模块边界传递,两边 libstdc++ minor 版本不一致。崩溃往往没有友好报错,只有 free(): invalid pointer 或段错误,回溯指向 std::vector::~vector()。
C++ 标准不保证带模板的 STL 容器有跨编译器 ABI——std::vector<T> 的内存布局随 libstdc++ 版本变化,不能当作插件接口的往返参数。边界偷懒,现场升级就是抽奖。热插拔的价值在运维,代价在边界设计——边界设计一次到位,比 field 上 gdb 一周划算。
2. 场景:算法库为什么要插件化
感知/规划团队常把算法封成 .so,主程序负责 IO、调度与参数。好处是算法可独立发版、A/B 对比后端;代价是跨动态库边界的类型布局必须稳定。算法团队发 .so 补丁不动主程序,前提是 API 面不含 template 和 STL 容器。
实现细节、STL 容器、Eigen::Matrix4d 全部藏在 .cpp。若必须传大块数据,用调用方分配的 plain buffer + 长度,或 extern "C" 导出函数。别让异常穿越 .so 边界——插件内 catch 后转成错误码。
3. Pimpl 与 opaque pointer
公开头里只留稳定表面。Eigen 矩阵不要按值穿越 .so——用 row-major double* + 行/列描述,或固定大小 double[16] 传 4×4 位姿。结构体只追加字段、不改偏移;跨版本消息用 flatbuffers/protobuf,比裸 struct 穿越 .so 安全。
class SlamBackend {
public:
explicit SlamBackend(const char* config_path);
~SlamBackend();
void feedImu(double t, const double accel[3], const double gyro[3]);
bool getPose(double t, double out_T[16]) const;
private:
class Impl;
std::unique_ptr<Impl> impl_;
};4. 导出符号与版本握手
Linux 编译加 -fvisibility=hidden,仅 API 标 __attribute__((visibility("default")))。若输出大量 std:: 符号,说明头文件把 STL 类型暴露到了 API 面,迟早 ABI 裂。现场热升级时,先 dlsym(handle, "plugin_api_version") 再 dlsym(handle, "create_backend")。版本不匹配直接 dlclose,别等第一次 feedImu 才崩。
导出面用 extern "C" 固定名字,C++ 名字改编(name mangling)会让运维脚本对不上。Windows 对称使用 __declspec(dllexport/dllimport)。
extern "C" {
uint32_t plugin_api_version();
SlamBackend* create_backend(const char* yaml_path);
void destroy_backend(SlamBackend* p);
}5. 结构体布局与共享运行时
跨 .so 传 POD struct 时,两边必须同一 pack 策略(通常不要乱开 -fpack-struct)。用 static_assert(sizeof(PluginFrameHeader) == 64) 锁死布局;字段只追加、不改序。Eigen 对象禁止按值出现在 exported struct 里——对齐与 ABI 都不稳定。
RTLD_LOCAL 加载插件,避免插件自带第二份 libglog.so 静态初始化打架。ldd libslam_plugin.so 里若出现与 host 不同 minor 的 libstdc++.so.6,统一 Docker 基础镜像比事后 gdb 省一周。插件若静态链了另一份 OpenSSL,符号冲突表现为加载成功、第一次 TLS 握手随机失败。
6. 失败长什么样
- 主程序 GCC 11、插件 GCC 12:
dlopen成功,第一次调用带std::string参数的函数即崩溃。 - ODR 违规:插件和主程序各编译一份同名 header-only 工具,行为随机。
- 静默数值错:两边
Eigen默认列主序/对齐选项不同,位姿差一个转置。
7. 验收
- 同一套单元测试分别链静态库和
.so跑;CI 矩阵覆盖「主程序 GCC 11 + 插件 GCC 12」。 - 加载失败时把
dlerror()字符串打进 structured log(含.so路径与plugin_api_version)。 - 热升级 runbook 固定顺序:停 host 订阅 → dlclose 旧插件 → 替换
.so→ 重新 dlsym 工厂 → 恢复订阅。跳过 dlsym 刷新会留下悬空函数指针。
ldd build/libslam_plugin.so
objdump -T build/libslam_plugin.so | c++filt | rg 'plugin_api'8. 案例:libstdc++ minor 版本不一致
主程序 GCC 11、插件 GCC 12,dlopen 成功,第一次调用带 std::string 参数的函数即崩溃——回溯指向 std::vector::~vector()。之后统一 Docker 基础镜像,CI 矩阵覆盖跨编译器组合。加载失败时把 dlerror() 字符串打进 structured log,field 上比 gdb 回溯省事。
9. 与运维 runbook 对齐
热升级 runbook 固定顺序:停 host 订阅 → dlclose 旧插件 → 替换 .so → 重新 dlsym 工厂 → 恢复订阅。热插拔的价值在运维,代价在边界设计。结构体只追加字段、不改偏移;跨版本消息用 flatbuffers/protobuf,比裸 struct 穿越 .so 安全。运维脚本必须检查 plugin_api_version 匹配后再调用工厂函数,版本不对直接 abort 并打 structured log。
相关
也可以看看
johan's blog