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++,那么:
- 不要在您的插件中使用任何标准库类型API。
例如,
Character::name()
return 是 std::string
。如果 std::string
的实现发生变化(and it has in the past in GCC),那么这将导致未定义的行为。真的,任何你无法控制的东西(任何 third-party 库)都不应该在 API. 中使用
- 不要跨插件边界使用异常或 RTTI。如果您使用
RTLD_GLOBAL
加载插件(这对插件来说不是一个好主意)并且主机和插件使用相同的 运行 时间,则 Linux 异常和 RTTI 可能会起作用。但一般来说,您要么无法捕获来自另一个模块的异常,要么它们甚至可能导致堆损坏(如果它们由不同的 运行 次分配)。
- 只在摘要的末尾添加函数 类,否则一切都会因为 vtable 布局变化而无声无息地崩溃(这真的很难诊断)。
- 始终从同一模块分配和释放 object。我注意到您没有
destroyCharacter()
函数(main()
实际上会泄漏字符,但这是另一个问题)。始终为由不同模块(共享库或插件)创建的资源提供对称的 create
和 destroy
函数。
我相信 Linux 与 GCC 主机应用程序的 operator new
和 operator delete
正确传播到加载的插件(通过弱符号),但如果你想让它在 Windows 上工作那么不要假设主机应用程序和插件中的 operator new
和 operator 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 类型(short
、int
、long
、...)或枚举。例如。 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"。
当然还有其他关于此事的精彩演讲和文章(我不记得了)。
我正在开发一个插件系统来替换共享库。
我知道在为共享库和库中的入口点设计 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++,那么:
- 不要在您的插件中使用任何标准库类型API。
例如,
Character::name()
return 是std::string
。如果std::string
的实现发生变化(and it has in the past in GCC),那么这将导致未定义的行为。真的,任何你无法控制的东西(任何 third-party 库)都不应该在 API. 中使用
- 不要跨插件边界使用异常或 RTTI。如果您使用
RTLD_GLOBAL
加载插件(这对插件来说不是一个好主意)并且主机和插件使用相同的 运行 时间,则 Linux 异常和 RTTI 可能会起作用。但一般来说,您要么无法捕获来自另一个模块的异常,要么它们甚至可能导致堆损坏(如果它们由不同的 运行 次分配)。 - 只在摘要的末尾添加函数 类,否则一切都会因为 vtable 布局变化而无声无息地崩溃(这真的很难诊断)。
- 始终从同一模块分配和释放 object。我注意到您没有
destroyCharacter()
函数(main()
实际上会泄漏字符,但这是另一个问题)。始终为由不同模块(共享库或插件)创建的资源提供对称的create
和destroy
函数。 我相信 Linux 与 GCC 主机应用程序的operator new
和operator delete
正确传播到加载的插件(通过弱符号),但如果你想让它在 Windows 上工作那么不要假设主机应用程序和插件中的operator new
和operator 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 类型(short
、int
、long
、...)或枚举。例如。 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"。 当然还有其他关于此事的精彩演讲和文章(我不记得了)。