Linux 上的 C++ 插件 ABI 问题

C++ Plugins ABI issues on Linux

我正在开发一个插件系统来替换共享库。

我知道在为共享库和库中的入口点设计 API 时存在 ABI 问题,例如导出的 classes,应该仔细设计。

例如,添加、删除或重新排序导出的 class 的私有成员变量可能会导致不同的内存布局和运行时错误(据我了解,这就是 Pimpl 模式可能有用的原因)。当然,在修改导出的 classes.

时,还有许多其他陷阱需要避免

我在这里建立了一个小例子来说明我的问题。

首先,我为插件开发者提供以下header:

// character.h
#ifndef CHARACTER_H
#define CHARACTER_H

#include <iostream>

class Character
{
public:
    virtual std::string name() = 0;
    virtual ~Character() = 0;
};

inline Character::~Character() {}

#endif

然后插件被构建为共享库“libcharacter.so”:

#include "character.h"
#include <iostream>

class Wizard : public Character
{
public:
    virtual std::string name() {
        return "wizard";
    }
};

extern "C"
{
    Wizard *createCharacter()
    {
        return new Wizard;
    }
}

最后是使用插件的主要应用程序:

#include "character.h"
#include <iostream>
#include <dlfcn.h>

int main(int argc, char *argv[])
{
    (void)argc, (void)argv;

    using namespace std;

    Character *(*creator)();

    void *handle = dlopen("../character/libcharacter.so", RTLD_NOW);

    if (handle == nullptr) {
        cerr << dlerror() << endl;
        exit(1);
    }

    void *f = dlsym(handle, "createCharacter");
    creator = (Character *(*)())f;

    Character *character = creator();
    cout << character->name() << endl;

    dlclose(handle);

    return 0;
}

定义一个抽象 class 是否足以消除所有 ABI 问题?

Is it sufficient to define an abstract class to get rid of all ABI issues?

简答:

没有

我不建议将 C++ 用于插件 API(请参阅下面更长的答案),但如果您决定坚持使用 C++,那么:

  1. 不要在您的插件中使用任何标准库类型API。 例如,Character::name() return 是 std::string。如果 std::string 的实现发生变化(and it has in the past in GCC),那么这将导致未定义的行为。真的,任何你无法控制的东西(任何 third-party 库)都不应该在 API.
  2. 中使用
  3. 不要跨插件边界使用异常或 RTTI。如果您使用 RTLD_GLOBAL 加载插件(这对插件来说不是一个好主意)并且主机和插件使用相同的 运行 时间,则 Linux 异常和 RTTI 可能会起作用。但一般来说,您要么无法捕获来自另一个模块的异常,要么它们甚至可能导致堆损坏(如果它们由不同的 运行 次分配)。
  4. 只在摘要的末尾添加函数 类,否则一切都会因为 vtable 布局变化而无声无息地崩溃(这真的很难诊断)。
  5. 始终从同一模块分配和释放 object。我注意到您没有 destroyCharacter() 函数(main() 实际上会泄漏字符,但这是另一个问题)。始终为由不同模块(共享库或插件)创建的资源提供对称的 createdestroy 函数。 我相信 Linux 与 GCC 主机应用程序的 operator newoperator delete 正确传播到加载的插件(通过弱符号),但如果你想让它在 Windows 上工作那么不要假设主机应用程序和插件中的 operator newoperator delete 是相同的。静态链接 运行 时间,尤其是使用 LTO 构建的时间,也可能与此混淆。

更长的答案:

从插件导出 C++ API 时可能会出现很多问题。 一般来说,如果用于构建主机应用程序和插件的工具链任何不同,则无法保证它的工作。这可以包括(但不限于)编译器、语言版本、编译器标志、预处理器定义等。

关于插件的常识是使用纯 C89 API,因为所有常见平台上的 C ABI 非常 stable。 保持 C89 和 C++ 的公共子集将意味着宿主和插件可以使用不同的语言标准、标准库等。除非宿主或插件是用一些奇怪的(可能 non-standard-conforming)构建的APIs,这应该是相当安全的。显然,你还是要小心数据结构布局。

然后您可以为 C API 提供一个丰富的 C++ header-only 包装器来处理生命周期和错误 code/exception 转换等。 作为一个很好的奖励,C APIs 可以被大多数语言生产和使用,这可以让插件作者不仅仅使用 C++。

实际上即使在 C API 中也有不少陷阱。如果我们学究气,那么唯一安全的东西就是带有 fixed-size 参数和 return 类型(指针、size_t[u]intN_t)的函数——甚至不一定 built-in 类型(shortintlong、...)或枚举。例如。 in GCC-fshort-enums 可以更改枚举的大小,-fpack-struct[=n] 可以更改结构内的填充。 所以,如果你真的想要安全,那么不要使用枚举,要么打包你的所有结构,要么不要直接公开它们(而是公开访问函数)。

其他注意事项:

这些与问题没有严格的关系,但在提交 API 的特定样式之前绝对应该考虑。

错误处理:无论您是否使用 C++,您都需要一种替代异常的方法。 这可能是某种形式的错误代码。一旦进入 C++ 领域,C++ 中的 std::error_code 就可以用于包装原始 enum/int,如果 API 使用 C++,则可以使用类似 std::expected-like or Boost.Outcome 的类型可以使用 stable ABI。

加载插件和导入符号: 使用抽象 类 这很容易 - 一个简单的工厂函数就是您所需要的。使用传统的 C API,您可能最终需要导入数百个符号。处理这个问题的一种方法是在 C 中模拟 vtable。使每个具有关联函数的 object 都以指向调度 table 的指针开头,例如

typedef struct game_string_view { const char *data; size_t size; } game_string_view;

typedef enum game_plugin_error_code { game_plugin_success = 0, /* ... */ } game_plugin_error_code;

typedef struct game_plugin_character_impl *GamePluginCharacter; // handle to a Character

typedef struct game_plugin_character_dispatch_table { // basically a vtable
    void (*destroy)(GamePluginCharacter character); // you could even put destroy() here
    game_string_view (*name)(GamePluginCharacter character);
    void (*update)(GamePluginCharacter character, /*...*/, game_plugin_error_code *ec); // might fail
} game_plugin_character_dispatch_table;

typedef struct game_plugin_character_impl {
    // every call goes through this table and takes GamePluginCharacter as it's first argument
    const game_plugin_character_dispatch_table *dispatch;
} game_plugin_character_impl;

未来的可扩展性和兼容性:你应该设计API,知道你将来会想要改变它并保持兼容性。 IMO,C API 很适合这种情况,因为它迫使您对所公开的内容非常精确。该插件应该能够以向前和向后兼容的方式向主机公开它的 API 版本。

在设计每个函数签名时考虑可扩展性是个好主意。例如。如果结构通过指针(而不是值)传递,那么它的大小可以是 ext在不破坏兼容性的情况下找到(只要在 运行 时间调用者和被调用者都同意它的大小)。

可见度: 也许可以在 Linux 和其他平台上查看 visibility。这实际上不是 API 设计的问题,只是有助于清理从共享库导出的符号。


以上所有内容绝不是广泛的。 我建议谈话 "Hourglass Interfaces for C++ APIs" 进一步 "reading"。 当然还有其他关于此事的精彩演讲和文章(我不记得了)。