想把一个单体桌面程序改成插件化,动机通常很朴素:改一行代码要重编十分钟、几个小组在同一个解决方案里互相踩、客户想加个功能得等我们发版、以及少数时候想不停机换模块。
这些诉求插件化都能解。但在设计第一个接口之前,有一堵墙必须先看清楚,不然写完的东西能在你机器上跑,到别人机器上就是随机崩溃。
这堵墙叫 ABI。
先看一个必崩的例子
假设插件 SDK 里有个接口长这样:
cpp
// hostsdk/iimporter.h
class IImporter
{
public:
virtual ~IImporter() = default;
virtual std::string name() const = 0; // 看着很正常
virtual std::vector<std::string> supportedExts() const = 0;
virtual bool importFile(const std::string &path) = 0;
};
在插件里实现,宿主里调用。Debug 下跑得好好的,Release 下偶发崩溃;或者本机跑得好好的,客户机器上崩。崩溃点通常在 std::string 的析构,或者 vector 的 operator[]。
原因不是代码写错了,是标准库没有 ABI 保证。
C++ 标准只规定 std::string 该有什么行为,没规定它在内存里长什么样。MSVC 的 std::string 和 GCC 的不一样,MSVC 不同版本之间也可能不一样(SSO 缓冲区的大小、_ITERATOR_DEBUG_LEVEL 的开关、调试迭代器的额外成员)。宿主按自己的理解去解释插件返回的那块内存,布局对不上就是未定义行为。
而且它不报错。编译期完全合法,链接期完全合法,运行期看运气。
第二个雷:CRT 与堆
std::string 的内存是从堆上来的。这里有个更基础的问题:哪个堆?
如果插件用 /MT(静态链接 CRT)编译,它就有一份自己独立的 CRT ,包括自己独立的堆。宿主调插件的 name(),字符串的内存在插件的堆上分配;返回到宿主这边,宿主析构它,用的是宿主的堆。
A 模块的堆分配,B 模块的堆释放。
Debug 模式下 CRT 会检测这个,直接弹断言;Release 模式下就是悄悄破坏堆结构,过一会儿在完全不相干的地方崩。
解决办法只有两个:
- 全链路统一
/MD(动态链接 CRT),所有模块共享同一个 CRT,也就共享同一个堆; - 或者,规矩更硬一点:谁分配谁释放,接口上显式提供销毁函数。
我倾向于两个都做。/MD 是前提(这条应该写进构建规范强制检查),但接口上仍然提供 destroy(),这样即使将来某个第三方插件没按规矩来,也不会炸在你脸上。
顺带一提,异常跨模块也是同一类问题:跨 DLL 抛异常要求两边 CRT 一致、异常处理模型一致(/EHsc)。实践中我的做法是接口上不抛异常 ,返回错误码或者 std::optional(后者只在边界内侧用),把异常关在插件自己家里。
第三个雷:编译器版本
好消息:MSVC 从 VS2015 开始,官方承诺 2015 / 2017 / 2019 / 2022 的工具集之间是二进制兼容的,微软有专门一页文档讲这个(标题就是 Binary Compatibility between Visual Studio versions)。所以在我们的环境里,MSVC2017 编的插件给 MSVC2022 编的宿主用,是官方支持的组合。
但前提是:
- 都用
/MD; - 工具集主版本号不小于 v140;
- 不混用不同版本的第三方库(比如插件链 Qt 5.12、宿主链 Qt 5.15,那就回到第一段的坑)。
版本跨度大的时候别偷懒,自己编一对最小 demo 验证一遍,比读文档快。
那 Qt 的类型能不能跨模块传?
能,但有条件。
Qt 官方的兼容性策略是:同一 major 版本内保证二进制兼容 ,也就是 Qt 5.0 到 5.15 之间 ABI 是稳定的。所以在我们这个全栈 Qt 5.12 的环境里,接口上传 QString、QVariant、QVector<T> 都是安全的。
这也是为什么 Qt 程序做插件化比纯 C++ 程序舒服------你有一整套 ABI 稳定的类型可以直接用在边界上。
不过这个"安全"是有代价的:插件和宿主必须链同一个 major 版本的 Qt 。Qt 自己也管这事,QPluginLoader 会主动检查,Qt 文档原话:
QPluginLoader checks that a plugin is linked against the same version of Qt as the application.
版本对不上,load() 直接失败,errorString() 里会给你一句 "The plugin ... uses incompatible Qt library"。这个检查帮我们挡掉了一大堆诡异崩溃,但也意味着插件发布时必须带上 Qt 版本信息,不能随便混。
所以边界上能传什么
我自己用的审查清单,写接口时一条条过:
| 类型 | 能不能跨边界 | 备注 |
|---|---|---|
int / double / bool / enum(显式指定底层类型) |
可以 | enum 要写 enum class X : int,不写底层类型的话各编译器取值可能不同 |
| POD 结构体(只含上述类型,无虚函数) | 可以 | 但要 #pragma pack 或者显式对齐,别让编译器各自发挥 |
QString / QByteArray / QVariant / QVector<T> |
可以 | 前提是同 major Qt 版本 |
QObject* / 纯虚接口指针 |
可以 | 见下条,必须配虚析构且析构要能跨模块 |
const char* |
可以 | 但所有权要讲清楚,指针指向哪、谁释放、生命周期多长 |
| 函数指针 | 可以 | 调用约定要显式写(Windows 上 __cdecl / __stdcall) |
std::string / std::vector / std::map |
不行 | 无 ABI 保证 |
std::shared_ptr / std::unique_ptr |
不行 | 同上,而且跨模块引用计数更危险 |
| 带虚函数的类(按值传) | 不行 | 传指针可以,按值传就是切片 + 布局赌运气 |
| 标准库迭代器、算法返回的东西 | 不行 | 实现定义,且通常带模板实例化 |
| 异常 | 不建议 | 跨模块抛出要求 CRT 与异常模型一致 |
一句话版本:边界上只传 POD、C 风格结构、Qt 类型(同版本)、以及指针。
改一下开头的接口
按上面的清单,IImporter 应该长这样:
cpp
// hostsdk/iimporter.h
#pragma once
#include <QtCore/QString>
#include <QtCore/QStringList>
class IImporter
{
public:
enum class Result : int {
Ok = 0,
BadFormat = 1,
IoError = 2,
Cancelled = 3,
};
virtual ~IImporter() = default;
// 出参用指针,所有权不转移
virtual void name(QString *out) const = 0;
virtual void supportedExtensions(QStringList *out) const = 0;
// 不抛异常,返回错误码
virtual Result importFile(const QString &path, QString *errorText) = 0;
// 谁创建谁销毁,跨模块 delete 是雷
virtual void destroy() noexcept = 0;
};
几个决定都是有理由的:
QString代替std::string------ 同 Qt 版本内 ABI 稳定;- 出参用指针而不是返回值 ------ 返回
QString也没问题(Qt 类型),但用指针口径更统一,将来换成 POD 结构时不用改签名; Result用enum class : int------ 显式底层类型,避免各编译器对枚举底层类型取值不同;destroy()而不是让宿主delete------ 保证释放发生在插件自己的模块里;~IImporter()仍然存在 ------ 但对宿主来说是禁用的,宿主只准调destroy()。虚析构留着是为了插件内部还能用智能指针管理自己。
最后一条容易让人困惑:既然不让 delete,为什么还要虚析构?因为插件内部自己 new 出来的实现类,插件自己也会 delete(或者用 std::unique_ptr 管)。没有虚析构,插件内部通过基类指针删派生类就是 UB。这条只是给插件自己用的。
顺带说一句:别在边界上 inline
这条跟 ABI 相关但经常被忽略。
cpp
// 危险:inline 函数会被编进调用方
class IImporter {
public:
inline bool isOk(Result r) const { return r == Result::Ok; } // 编进宿主
};
inline 函数的代码会被复制到每一个调用它的模块里。宿主编进去一份,插件编进去一份。哪天你改了这个函数的实现,只重编插件------宿主里那份还是旧的。
这种 bug 极难查:行为不一致,但代码看起来明明只有一份。
规则:接口头文件里的函数一律不 inline 。要写工具函数就放 .cpp 里导出,或者做成静态函数放在 SDK 的某个导出类上。
明天写 Day 02:接口定下来之后,怎么保证一年后加功能不用把所有插件重编一遍------重点是虚函数表的布局和版本号的两种分法。