C++ 标准只规定了源码层面的语义,从未规定编译产物层面的接口。函数名如何修饰、虚表如何排布、异常如何传播、std::string 有几个指针,全由实现自行决定。这带来一个工程硬约束:动态库一旦发生 ABI 变化,所有依赖它的程序与库都必须重新编译,否则轻则符号找不到,重则内存布局错位导致崩溃。理解 ABI 是发布共享库、编写插件系统、维护跨版本 SDK 的前提。
一、ABI 是什么
1.1 ABI 的组成要素
应用二进制接口(Application Binary Interface)是「编译产物之间的契约」,涵盖以下层面:
- 名字修饰:
void Foo::bar(int)在目标文件里到底叫什么名字 - 调用约定与对象布局:参数如何入寄存器、类的大小、成员偏移、对齐
- 虚表与 RTTI 布局:虚函数槽位顺序、
type_info结构 - 异常处理与运行时支持:展开元数据格式、
new/delete实现、TLS 访问方式 - 标准库类型的布局:
std::string、std::vector、std::function的内部表示
前四项在同一次工具链编译中通常一致,最后一项才是跨编译器、跨标准库版本时的重灾区。
1.2 Itanium C++ ABI 与 MSVC ABI 的分野
Linux、macOS、BSD 以及绝大多数嵌入式平台使用 Itanium C++ ABI(GCC 提出,Clang 完全兼容);Windows 上的 MSVC 使用独立的 MSVC ABI。两者在名字修饰、虚表布局、异常处理三处都不兼容。
| 维度 | Itanium C++ ABI | MSVC ABI |
|---|---|---|
| 名字修饰 | 以 _Z 开头,长度前缀编码 | 以 ? 开头,字符后缀编码 |
| 虚表内容 | 虚函数指针 + RTTI 指针前置 | 虚函数指针 + RTTI 完整结构指针 |
| 异常处理 | DWARF / Itanium unwinding | SEH 结构化异常处理 |
| 典型工具链 | GCC、Clang、MinGW-w64 | MSVC、clang-cl |
在 Windows 上,MinGW-w64 编译的 DLL 与 MSVC 编译的 EXE 无法互相链接,这是跨平台 C++ 项目最常见的坑之一。同一平台想混用不同 ABI,只能把接口收敛到 extern "C"。
二、名字修饰实测
2.1 nm 与 c++filt
编译器把函数名、参数类型、命名空间编码成一个唯一符号名,这个过程叫名字修饰(name mangling)。nm 列出符号表,c++filt 反向还原。
# foo.cpp 中定义了 demo::add(int,int) 与 demo::scale(Point&,double)
g++ -std=c++20 -fPIC -c foo.cpp -o foo.o
nm foo.o | grep -E ' T | t '
# 0000000000000000 T _ZN4demo3addEii
# 0000000000000000 T _ZN4demo5scaleERNS_5PointEd
nm -C foo.o | grep -E ' T | t '
# 0000000000000000 T demo::add(int, int)
# 0000000000000000 T demo::scale(demo::Point&, double)
c++filt _ZN4demo3addEii # demo::add(int, int)
2.2 修饰规则速览
以 _ZN4demo5scaleERNS_5PointEd 为例逐段拆解:
_Z 名字修饰前缀
N ... E 嵌套名(namespace/class 限定),以 E 结束
4demo / 5scale 长度前缀标识符,即 demo::scale
R 引用(reference)
N S_ 5Point E S_ 表示「与前面相同的命名空间」,即 demo::Point
d double
关键在于参数类型被完整编码进符号名。把 void f(int) 改成 void f(long) 后修饰名随之改变,旧程序调用旧符号会得到 undefined symbol——这是最容易被发现的一类 ABI 破坏。const/volatile 只影响成员函数的修饰(后缀 K/V),不影响返回类型;而 noexcept 在 C++17 之后会进入修饰(_Z1fv 与 _Z1fvn 是两个不同符号),这是非常隐蔽的陷阱。
2.3 extern “C” 边界
extern "C" 关闭名字修饰,是跨编译器、跨语言、跨 ABI 的唯一稳定通道,代价是失去重载、命名空间与类型信息。
// plugin_api.h
#ifdef __cplusplus
extern "C" {
#endif
typedef struct plugin_handle plugin_handle;
plugin_handle* plugin_create(void);
int plugin_run(plugin_handle* h, int argc, const char* const* argv);
void plugin_destroy(plugin_handle* h);
#ifdef __cplusplus
}
#endif
符号名就是函数名本身(nm 中直接看到 T plugin_run)。把 plugin_run 的参数从 int 改成 long 不会改变符号名——这正是 extern "C" 的危险之处:ABI 破坏不会在链接期暴露,而是在运行期以错误的内存解释悄悄发生。因此 C 边界必须配版本号或能力查询函数。
三、虚表布局与对象模型
3.1 vtable 结构与 RTTI
带虚函数的类,其对象开头存放一个指向虚表的指针;虚表是一组函数指针,其前置负偏移处存放 type_info 指针。
struct Shape {
virtual ~Shape() = default;
virtual double area() const = 0;
virtual void draw() const {}
};
struct Circle : Shape {
double r = 1.0;
double area() const override { return 3.141592653589793 * r * r; }
void draw() const override {}
};
用 nm -C shape.o | grep -E 'vtable|typeinfo' 可以看到 V vtable for Circle、V vtable for Shape、V typeinfo for Circle 三行输出。_ZTV6Circle 是虚表符号(V 表示弱符号),_ZTI6Circle 是类型信息。槽位顺序由基类中虚函数的声明顺序决定:析构函数占两个槽(complete 与 deleting),随后才是 area、draw。关键约束:在已有虚表中间插入虚函数会让后续槽位错位,旧二进制调用 draw() 可能跳到 area()。安全做法是在虚表末尾追加。
3.2 多继承与 this 指针调整
多继承下派生对象包含多个基类子对象,每个子对象各有虚表指针。通过第二个基类指针调用虚函数时,编译器插入 thunk 做 this 调整。
struct A { virtual void fa(); int a; };
struct B { virtual void fb(); int b; };
struct C : A, B { void fa() override; void fb() override; int c; };
void call_b(B* p) { p->fb(); } // 需要把 B* 调整回 C*
nm -C multi.o | grep -i thunk # T non-virtual thunk to C::fb()
Thunk 的存在意味着:改变基类顺序、增删基类、改变基类是否含虚函数,都会破坏 ABI。此外 dynamic_cast 与 type_info 的跨模块比较依赖 RTTI 指针唯一性——若动态库与主程序各自静态链接了一份 libstdc++,同一个类会有两份 type_info,dynamic_cast 可能失败。
四、标准库类型跨边界传递的风险
4.1 std::string 与 C++11 ABI 断裂
GCC 5 切换到 C++11 要求的新 std::string 实现后,libstdc++ 同时保留新旧两套符号,由宏 _GLIBCXX_USE_CXX11_ABI 控制。
c++filt _ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEC1EPKcRKS3_
# std::__cxx11::basic_string<char, ...>::basic_string(char const*, ...)
c++filt _ZNSsC1EPKcRKSaIcE
# std::basic_string<char, ...>::basic_string(char const*, ...)
差异在于新实现把 basic_string 放进 std::__cxx11 内联命名空间,符号里出现 St7__cxx1112basic_string,旧实现用 Ss 缩写;两者内存布局也不同(新实现是 SSO 加指针,旧实现是 COW 引用计数)。用 -D_GLIBCXX_USE_CXX11_ABI=0 编译的库无法与默认 =1 的库互相传递 std::string,若通过 extern "C" 传 std::string*,会在运行期静默崩溃。工程结论:不要把标准库类型放进动态库的公开接口。
4.2 std::vector 与异常
std::vector 内部持有分配器,allocate 与 deallocate 必须来自同一份堆实现。若库用 operator new 分配、主程序用另一个运行时释放,就是经典的跨模块堆不匹配。异常同理:Itanium ABI 规定异常类型匹配靠 type_info 指针比较,跨模块抛接需要双方共享同一套 RTTI 与 unwinder。因此很多 SDK 在接口上标注 noexcept 或用错误码替代异常。
// 反面示例:接口里直接暴露容器
struct Bad { std::vector<int> data; std::string name; };
// 正面示例:只传 POD 或自描述缓冲区
struct Good { const int* data; std::size_t size; };
五、稳定 ABI 的工程手段
5.1 PIMPL
PIMPL 把实现细节完全移出头文件,公开类只保留一个指向不透明实现的指针。新增或修改私有成员不会改变公开类的大小。
// widget.h —— 对外发布,布局永不改变
#pragma once
#include <memory>
#include <string_view>
class Widget {
public:
Widget();
~Widget(); // 必须声明,让 unique_ptr 删除器在 .cpp 中实例化
Widget(Widget&&) noexcept;
Widget& operator=(Widget&&) noexcept;
Widget(const Widget&);
Widget& operator=(const Widget&);
void draw() const;
void set_title(std::string_view title);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
// widget.cpp
#include "widget.h"
#include <string>
#include <vector>
struct Widget::Impl {
std::string title;
std::vector<int> layers;
int dirty = 0;
};
Widget::Widget() : impl_(std::make_unique<Impl>()) {}
Widget::~Widget() = default; // Impl 在此处才完整
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;
void Widget::set_title(std::string_view t) { impl_->title.assign(t); }
析构函数、移动构造、移动赋值必须在 .cpp 中定义:std::unique_ptr<Impl> 的删除器需要完整类型,若在头文件里写 = default,调用方 TU 会报 invalid application of sizeof to incomplete type。代价是每次访问多一次指针间接寻址与一次堆分配。
5.2 纯虚接口与工厂
插件系统更适合纯虚接口加 extern "C" 工厂。接口类只含虚函数,不含数据成员,也不含标准库类型。
// plugin.h
class IPlugin {
public:
virtual ~IPlugin() = default;
virtual const char* name() const noexcept = 0;
virtual int abi_version() const noexcept = 0;
virtual int run(int argc, const char* const* argv) = 0;
};
extern "C" IPlugin* plugin_create();
extern "C" void plugin_destroy(IPlugin* p);
接口演化规则是:只能在虚表末尾追加纯虚函数,且新函数应带默认实现,否则旧插件加载后会跳转到空槽。更稳妥的做法是提供 virtual int abi_version() const noexcept,宿主据此决定调用路径。
六、SO 版本化
6.1 soname
soname 是动态链接器运行期查找库时使用的名字,与文件名解耦,是「升级库而不重链接」的基础。
g++ -std=c++20 -fPIC -shared -fvisibility=hidden \
-Wl,-soname,libfoo.so.1 -Wl,--version-script=libfoo.map \
-o libfoo.so.1.0.0 foo.cpp
ln -sf libfoo.so.1.0.0 libfoo.so.1 # 运行期链接名(soname)
readelf -d libfoo.so.1.0.0 | grep SONAME
# 0x000000000000000e (SONAME) Library soname: [libfoo.so.1]
版本号约定为 libfoo.so.MAJOR.MINOR.PATCH。MAJOR 递增表示 ABI 不兼容,soname 随之改变;MINOR/PATCH 递增只表示新增接口或修 bug,soname 不变,可直接替换 .so 文件。
6.2 符号版本脚本
版本脚本给每个导出符号打上版本标签,让同一个库同时提供新旧两套接口。
# libfoo.map
LIBFOO_1.0 {
global: foo_init; foo_process; foo_destroy;
local: *;
};
LIBFOO_1.1 {
global: foo_process_v2;
} LIBFOO_1.0;
用 readelf --dyn-syms --wide libfoo.so.1.0.0 | grep foo_ 可看到 foo_init@@LIBFOO_1.0 与 foo_process@@LIBFOO_1.0。@@ 表示默认版本,@ 表示非默认版本。旧程序链接时记录 foo_process@LIBFOO_1.0,新程序记录 foo_process_v2@@LIBFOO_1.1,两者共存于同一个 .so 中。
6.3 符号可见性
默认情况下 GCC 导出所有非静态符号,包括内联函数、模板实例与辅助函数,既让符号表膨胀,也让内部实现意外成为 ABI 的一部分。
g++ -std=c++20 -fPIC -shared -fvisibility=hidden -fvisibility-inlines-hidden \
-o libfoo.so foo.cpp
#define FOO_API __attribute__((visibility("default")))
class FOO_API FooService { public: void start(); };
FOO_API int foo_init(void); // 显式导出
Windows 上对应 __declspec(dllexport) 与 __declspec(dllimport),通常用 FOO_API 宏统一封装。隐藏符号后导出符号数常能从数千降到数十,链接更快,ABI 边界也变得显式可控。
七、ABI 检查工具实测
7.1 abi-compliance-checker
该工具需要先用 abi-dumper 生成 ABI 快照,再对比两份快照,能检测函数签名、类布局、虚表槽位等变化。
sudo apt install abi-dumper abi-compliance-checker # Debian/Ubuntu
abi-dumper libfoo.so.1.0.0 -o abi-1.0.dump -lver 1.0
abi-dumper libfoo.so.1.1.0 -o abi-1.1.dump -lver 1.1
abi-compliance-checker -lib foo -old abi-1.0.dump -new abi-1.1.dump
# Total binary compatibility problems: 2, warnings: 1
# Binary compatibility: 96.3%
报告落在 compat_reports/foo/1.0_to_1.1/ 目录下,用 HTML 列出每个问题符号及其源码位置。
7.2 libabigail
libabigail 提供 abidiff,直接比较两个 ELF 文件,无需快照,输出是文本 diff 风格。
sudo apt install abigail-tools # Debian/Ubuntu
abidiff libfoo.so.1.0.0 libfoo.so.1.1.0
# Functions changes summary: 0 Removed, 1 Changed, 0 Added function
# 1 function with some indirect sub-type change:
# [C] 'function int foo_process(Config*)' at foo.cpp:24:1
# parameter 1 of type 'Config*' changed:
# type size changed from 32 to 40 bits
# 'int new_field' was added at offset 32
abidiff 的退出码 0 表示兼容,非 0 表示存在不兼容变更,非常适合放进 CI:
abidiff --suppressions suppr.txt old/libfoo.so new/libfoo.so || {
echo "检测到 ABI 破坏,请递增 soname 主版本号"; exit 1;
}
八、破坏 ABI 的常见改动清单
以下改动都应视为「必须递增 MAJOR 版本」:
- 类布局与大小:增删非静态数据成员、改变成员顺序或类型、增删基类、改变继承顺序、改变
#pragma pack、给原本无虚函数的类加虚函数 - 虚表:在中间插入虚函数、改变虚函数声明顺序、把非虚函数改成虚函数
- 函数签名:改变参数类型或个数、增删
const/volatile成员限定、增删noexcept - 模板与枚举:改变模板参数列表、改变枚举底层类型(
enum变enum class) - 标准库与可见性:在接口中增删标准库类型、改变
_GLIBCXX_USE_CXX11_ABI、改变符号导出状态 - 内联函数:修改内联函数体(调用方已内联旧实现)
相对安全的改动包括:新增非虚成员函数、新增静态成员函数、在类末尾新增虚函数(且所有子类可重编译)、在函数末尾追加带默认值的参数(仅对 C++ 调用方安全)。
相关阅读
- https://plumephp.com/cpp-compilation-linking/ — 符号解析、静态链接与动态链接的基本流程
- https://plumephp.com/cpp-cmake-project/ — 用 CMake 的 target 模型管理共享库与导出符号
- https://plumephp.com/cpp-engineering-practices/ — 接口设计规范与依赖管理
延伸阅读
- https://plumephp.com/cpp-cross-platform-build-matrix/ — 多平台构建矩阵如何避免 ABI 分裂
- https://plumephp.com/cpp-memory-model/ — 对象布局、对齐与生命周期规则
- https://plumephp.com/cpp-package-management-vcpkg-conan/ — 包管理器如何处理 ABI 变体
文末完整示例
// 完整可编译示例:稳定 ABI 的插件接口 + PIMPL + extern "C" 工厂
// 编译:g++ -std=c++20 -O2 -fvisibility=hidden -o abi_demo abi_demo.cpp
#include <iostream>
#include <memory>
#include <vector>
#define PLUGIN_API __attribute__((visibility("default")))
// ====== 1. 纯虚接口:只含虚函数,不含标准库类型 ======
class IPlugin {
public:
virtual ~IPlugin() = default;
virtual const char* name() const noexcept = 0;
virtual int abi_version() const noexcept = 0;
virtual int run(int argc, const char* const* argv) = 0;
};
// ====== 2. PIMPL:公开类布局固定 ======
class PLUGIN_API Engine {
public:
Engine();
~Engine();
Engine(Engine&&) noexcept;
Engine& operator=(Engine&&) noexcept;
void add(IPlugin* p);
std::size_t plugin_count() const;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
struct Engine::Impl {
std::vector<std::unique_ptr<IPlugin, void (*)(IPlugin*)>> plugins;
};
Engine::Engine() : impl_(std::make_unique<Impl>()) {}
Engine::~Engine() = default; // Impl 在此处才完整
Engine::Engine(Engine&&) noexcept = default;
Engine& Engine::operator=(Engine&&) noexcept = default;
void Engine::add(IPlugin* p) {
impl_->plugins.emplace_back(p, [](IPlugin* q) { delete q; });
}
std::size_t Engine::plugin_count() const { return impl_->plugins.size(); }
// ====== 3. C 链接工厂:符号名不含类型编码 ======
namespace {
class EchoPlugin final : public IPlugin {
public:
const char* name() const noexcept override { return "echo"; }
int abi_version() const noexcept override { return 1; }
int run(int, const char* const*) override {
std::cout << "echo run\n";
return 0;
}
};
} // namespace
extern "C" PLUGIN_API IPlugin* plugin_create() { return new EchoPlugin(); }
extern "C" PLUGIN_API void plugin_destroy(IPlugin* p) { delete p; }
int main() {
Engine e;
IPlugin* p = plugin_create();
std::cout << "plugin name=" << p->name() << " abi=" << p->abi_version()
<< "\n";
e.add(p);
std::cout << "engine plugins=" << e.plugin_count() << "\n";
return 0;
}
配套版本脚本 plugin.map 可写成一行:PLUGIN_1.0 { global: plugin_create; plugin_destroy; local: *; };,编译为共享库时用 -Wl,--version-script=plugin.map 传入。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。