Linux 上的 C++ 插件 ABI 问题

Sca*_*cab 1 c++ linux plugins gcc abi

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

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

例如,添加、删除或重新排序导出类的私有成员变量可能会导致不同的内存布局和运行时错误(根据我的理解,这就是 Pimpl 模式可能有用的原因)。当然,在修改导出的类时,还有许多其他的陷阱需要避免。

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

首先,我为插件开发人员提供以下标头:

// 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
Run Code Online (Sandbox Code Playgroud)

然后将插件构建为共享库“ 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;
    }
}
Run Code Online (Sandbox Code Playgroud)

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

#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;
}
Run Code Online (Sandbox Code Playgroud)

定义一个抽象类来摆脱所有 ABI 问题就足够了吗?

and*_*rey 5

定义一个抽象类来摆脱所有 ABI 问题就足够了吗?

简短的回答:

不。

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

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

更长的答案:

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

关于插件的普遍智慧是使用纯 C89 API,因为所有通用平台上的 C ABI 都非常稳定。保持 C89 和 C++ 的公共子集意味着主机和插件可以使用不同的语言标准、标准库等。除非主机或插件是用一些奇怪的(可能不符合标准的)API 构建的,否则这应该是相当安全的。显然,您仍然必须小心数据结构布局。

然后,您可以为处理生命周期和错误代码/异常转换等的 C API 提供丰富的 C++ 仅标头包装器。作为一个不错的奖励,C API 可以被大多数语言生产和使用,这可以允许插件作者使用不只是 C++。

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

其他注意事项:

这些与问题并不严格相关,但在提交特定风格的 API 之前绝对应该考虑。

错误处理:无论您是否使用 C++,您都需要替代异常。这可能是某种形式的错误代码。std::error_code一旦您处于 C++ 领域,就可以使用 C++ 中的 来包装原始枚举/整数,如果 API 使用 C++,则可以使用具有稳定 ABI 的-likestd::expectedBoost.Outcome -like类型。

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

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;

Run Code Online (Sandbox Code Playgroud)

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

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

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


以上所有内容绝不是广泛的。我建议将演讲“C++ API 的沙漏接口”作为进一步的“阅读”。当然还有其他关于此事的精彩演讲和文章(我不记得我的头顶)。