返回专辑
·Johan·4 分钟阅读

ABI 与插件边界:算法库如何安全热插拔

跨 .so 边界时 C API、类型擦除与版本校验怎么落地,以及我踩过的静默崩溃。

ABI 与插件边界:算法库如何安全热插拔

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 安全。

cpp
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)

cpp
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 刷新会留下悬空函数指针。
bash
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