C++ ABI 的稳定性:编程语言的发展


2011 年 3 月更新

作者:Stephen Clamage,Oracle Solaris Studio 工具开发工程

这些年来,随着 C++ 的发展,经常需要对编译器使用的应用程序二进制接口 (ABI) 进行更改以便支持新的或不断发展的语言特性。随之而来的是,需要编程人员使用每个新编译器版本重新编译所有二进制文件。但是,ABI 的易变性与 Oracle Solaris 共享库的理念相互矛盾,这为库和中间件供应商带来了困难。1998 年推出 C++ 标准之后,在 Solaris 平台上使用稳定的 C++ ABI 又有了新的希望。本文将解决 Oracle Solaris Studio C++ 中的这些问题,并使您实现使用 Oracle Solaris Studio C++ 开发程序时所期望的结果。

目录

简介

编程语言实现的 ABI 是一种可以使单独编译的模块协同工作的低级细节的规范。如果没有一个稳定的 ABI,则必须使用同一编译器的同一版本编译程序的所有部分。这种情况对于分布式项目,尤其是对于二进制库供应商会造成维护困难。早期 C++ 编程语言的快速发展妨碍了使用稳定的 ABI。1998 年推出的 C++ 国际标准(ISO/IEC 14882:1998 编程语言 — C++)至少针对给定的 C++ 实现提供了使用稳定 C++ ABI 的基础。本文将探讨 Oracle Solaris Studio C++ 编译器的稳定性问题。

C ABI

Oracle Solaris ABI 也是 C ABI,因为 C 是标准的 UNIX 实现语言。此外,C ABI 还指定:

  • 预定义类型(char、int、float 等)的大小和布局
  • 复合类型(array 和 struct)的布局
  • 编程人员定义的名称的外部(链接器可见)拼写
  • 机器码函数调用序列
  • 堆栈布局
  • 寄存器使用

C++ ABI

C++ ABI 包括 C ABI。此外,它还包括以下特性:

  • 层次结构类对象(即,基类和虚拟基类)的布局
  • 指向成员的指针的布局
  • 传递隐藏函数参数(例如 this
  • 如何调用虚拟函数:
    • Vtable 内容和布局
    • 指向 vtable 的指针在对象中的位置
    • 查找 this 指针调整
  • 查找基类偏移
  • 通过指向成员的指针调用函数
  • 管理模板实例
  • 名称的外部拼写(“名称改编”)
  • 构造和析构静态对象
  • 抛出和捕获异常
  • 标准库的一些细节:
    • 实现定义的细节
    • 类型信息和运行时类型信息
    • 内联函数对成员的访问

名称改编

C++ 允许不同的函数具有相同的名称,并且允许无限数量的作用域,其中可以声明具有相同名称的不同全局实体。示例:

int     f(int);
float   f(float);
class T {
        int f(int);
        int f(char*);
        class U {
                 int f(int);
        };
};
namespace N {
        class T {
                 int f(int);
        };
}

该示例有两个名为 T 的类,六个名为 f 的函数,其中某些函数在同一作用域中。所有函数都具有外部链接。为了区别具有相同名称的实体,C++ 实现必须使这些函数的引用实现唯一性。为了确保可以正确解析对不同模块中相同实体的引用,实现引用唯一性的方法必须可预测。

常用的方案包括使用作用域名称编码以及参数类型和返回类型(如果是函数)对实体名称进行修饰。生成的名称看似经过编码(或者说“改编”)。例如,Oracle Solaris Studio C++ 编译器对上面六个函数的名称进行如下编码。

改编函数名称示例:

函数 改编后的名称
float f(float) __1cBf6Ff_f_
int f(int) __1cBf6Fi_i_
int T::f(int) __1cBTBf6Mi_i_
int T::f(char*) __1cBTBf6Mpc_i_
int T::U::f(int) __1cBTBUBf6Mi_i_
int N::T::f(int) __1cBNBTBf6Mi_i_

C++ 还提供了一种方法,用于指定可从 C 代码访问名称,因此不应改编名称。

名称改编和 ABI

名称改编算法是 ABI 的一部分,因为它定义编译器必须如何生成程序实体的外部引用和定义。如果两个编译器或编译器版本未以相同方式对同等声明进行改编,则包含两个编译器编译的内容的程序将无法正确链接。

层次结构布局

C++ 允许用户定义类类型的层次结构,其中“派生”类隐式包括它继承的类的所有数据和函数。普通基类以类似于类类型成员的方式在对象中排列,与完整对象的开头有固定偏移。示例:

class Base {
      ...
};
class Derived : public Base {
        int i, j;
};
class Composed {
        Base b;
        int i, j;
};

在许多 C++ 实现中,类 DerivedComposed 的布局相同。

指向完整对象的指针可以转换为指向该对象的一个基类的指针,但必须通过基类在完整对象内的偏移调整指针表示的地址。

C++ 类可以有多个直接基类,该特性称为多重继承

如果类 AB 都具有基类 Z,则从 AB 派生的类 C 可能具有两个 Z 副本。有时,C 适合具有两个独立的 Z 副本。但某些时候,Z 表示其中必须只有一个副本的资源。

为了指定基类在层次结构对象中只有一个副本,可以将该基类声明为“虚拟”。

虚拟基类相对于中间类的偏移取决于整个层次结构。示例:

class Z {
        ...
};
class A : virtual public Z { // has one instance of Z
        ...
};
class B : virtual public Z { // has one instance of Z
        ...
};
class C : public A, public B { // has only one instance of Z
        ...
};

假设在 A 对象中,Z 部分的偏移为 OA,而在 B 对象中,该部分的偏移为 OB。C 对象中只有一个 Z 副本。它不能同时与 A 部分的偏移为 OA 而与 B 部分的偏移为 OB。当整个对象的类型为 C 时,其中至少一个偏移必须不同。

因此,如果有指向 A 的指针,则无法在编译时确定 Z 子对象的位置,因为 A 对象反过来可能是某个更复杂类型(例如 C)的子对象。运行时系统必须允许动态确定完整对象的类型,以便找到其他对象的偏移。

通常,C++ 实现将每个对象类型的偏移信息存储在一个辅助表(通常称为 vtable)中。通常,需要一个辅助表的每个类型具有一个 vtable,由该类型的所有对象共享。需要 vtable 的对象则包含指向 vtable 的指针。vtable 还包含虚拟函数的地址,以便允许根据指针或引用所引用的实际对象类型动态调度函数。

C++ 标准库

C++ 标准定义库中类型和函数的名称和属性,以及库的编程接口。因此,写入规范的源代码可在一致性实现之间移植。但二进制接口却是另一回事。

C++ 标准允许实现细节方面有的重大变化,只要编程接口不受影响。因此,其中许多实现细节成为 ABI 的一部分 — 尤其是类对象的大小。

标准库的许多部分通过内联函数最好地实现以提高性能。与 C 中的宏有些类似,对内联函数的调用由函数的主体替代。如果函数访问标准库中定义的类的成员,类成员的位置将内置到使用内联函数的应用程序的代码中。因此,内联函数引用的任何内容都是 C++ ABI 的一部分。

即使标准库的增强或错误修复不影响编程接口,但如果改变了库中定义的类的大小或布局,则更改也会影响 ABI。

ABI 不稳定性的根源

新的或更改的语言特性可能需要对 ABI 进行更改,而不仅仅是扩展。下面是两个示例:

  • C++ 标准允许覆盖虚拟函数以便具有不同于它所覆盖的函数的返回类型。返回类型必须是指针或引用类型,派生类中函数的返回类型必须引用从它所覆盖的函数引用的类型派生的类型。示例:

    class Base {
            virtual Base* clone();
    };
    class Derived : public Base {
            virtual Derived* clone();
    };
    void f(Base* p)
    {
            Base* copy = p->clone();
    }
    

    编译器无法知道对 clone 的调用将返回 Base* 还是指向派生类型的指针。ABI 必须提供一种方法,这样无论返回何种类型都可实现正确的指针调整。所需的机制尚未在预备标准 ABI 中提供。

  • 考虑具有相同名称和类型的模板函数规范和非模板函数:

       template<class T> T min(T, T) { ... }
       int min<int>(int, int); // old specialization syntax
       int min(int, int); // non-template
    

    在旧语言规则下,与模板函数具有相同名称和类型的非模板函数被视为模板的规范。因此,此类函数和相应的规范必须具有相同的改编名称。

    在 C++ 标准规则下,它们是不同的函数并且必须具有不同的改编名称。必须对照预备标准 ABI 更改至少一个函数的外部名称。

修复某些错误需要更改 ABI。下面是两个示例:

  • 在某些情况下,早期 C++ 编译器通常无法支持通过派生类的构造函数或析构函数调用虚拟基类中的函数。最终,针对此问题开发了一个成本合理的解决方案,但该解决方案需要不同的 vtable 组织以及不同的构造函数和析构函数调用方法。

  • 采用 -compat=5 模式的 Solaris Studio C++ 编译器针对某些假设是同等的函数声明生成不同的改编名称。修复错误意味着某些现有函数获得不同的改编名称(ABI 更改)。

ABI 不稳定性的后果

ABI 中的任何不同之处都意味着来自不同编译器的对象文件将无法链接,或者如果可以链接,它们也不会正确运行。(为了帮助防止不同的 ABI 通过意外链接生成代码,不同的编译器实现通常使用不同的名称改编方案。)

在早期 C++ 中,当语言快速发展时,ABI 也频繁地更改。C++ 编程人员已习惯于每当更新编译器时,就重新编译所有内容。

假设应用程序使用供应商 A 提供的 ORB 库和供应商 B 提供的数据库的库。供应商不希望发行源代码,因此他们提供二进制库。两个供应商提供的应用程序代码和库必须使用相同的 ABI。

如果每个编译器版本具有不同的 ABI,则应用程序编程人员不希望频繁地升级编译器。这意味着在项目的所有开发人员之间协调升级,并在官方升级安装日期重新编译所有内容。

如果供应商 A 和供应商 B 必须支持许多客户,每个客户使用不同的编译器版本,则供应商必须针对每个编译器版本发布和支持一个库。这种情况在资源利用上非常昂贵,通常不可行。在这种情况下,供应商可能会发布源代码,而客户将亲自构建库。这反过来会产生新的支持问题,因为不同的客户将使用不同的工具集,因此构建脚本必须配置为符合本地实践。

上述情况不能很好地支持 Oracle Solaris 共享库版本。必须针对每个受支持的 ABI 变体生成不同的 C++ 共享库版本。即使编译器不再受支持,程序也可存在于依赖使用旧共享库的领域中。过时的库版本必须继续提供很长一段时间。

成功地将库(尤其是共享库)作为产品发布依赖于稳定的 ABI。

Oracle Solaris Studio C++ ABI 历史

根据版本号的工程分类:新的主要版本号意味着不兼容的版本,主要版本的 Oracle Solaris Studio C++ 编译器始终使用不兼容的 ABI。

从 C++ 3.0 开始,Sun 便尝试提高 C++ ABI 的稳定性。C++ 运行时支持库成为随 Oracle Solaris 一起提供的共享库:libC.so.3。

但还要意识到 C++ 仍在不断发展,并且 C++ 标准的相关工作正在进行中,这无疑会更改某些重要细节。因此,Sun 的策略是任何 Sun 软件产品都不能导出 C++ 接口。如果不导出 C++ 接口,则 ABI 更改时,可以合理地“重新编译所有内容”。

C++ 4.0 于 1993 年发布,推出了新的不兼容 ABI。该 ABI 旨在具有稳定性。C++ 开发团队就 ABI 设计征求了 Sun 主要客户甚至竞争对手的意见,并且采纳了一些外部建议。ABI 作为公共文档发布。新的 C++ 支持库 libC.so.5 已添加到 Oracle Solaris 产品中。

随着时间的推移,发现了一些需要在名称改编方面稍微有所修改的错误。对这些错误已经作了修正,并且为用户提供了一些方法,以便在需要与较旧的代码链接时恢复先前的行为。C++ 4.2 于 1996 年发布,代表该 ABI 的最终版本。

该 ABI 包含已知错误,例如 ABI 不稳定性的根源部分中所述的虚拟基类问题。此外,C++ 标准的相关工作即将完成,并且已了解到该标准包含需要不同 ABI 的特性。ABI 不稳定性的根源部分中所述的模板语义更改就是几个影响 ABI 的更改之一。

为了避免由于尝试跟踪不断发展的 C++ 标准而导致经常更改 ABI 的情况,C++ 开发团队采取的战略是仅在 C++ 4.2 中实现那些假设保持稳定以及不需要 ABI 更改的特性。Sun 为其客户提供了稳定的 ABI,但在 C++ 特性方面会有些落后。

运行时库

早期 C++ 运行时库包括用于编译器的名为“iostreams”的 I/O 库以及运行时“帮助”函数,其中包括对堆内存分配、异常处理和动态类型信息的支持。

C++ 标准中指定的库包括一组广泛的模板类和函数,包括字符串、输入输出流、数字以及“STL”。

由于时间压力,5.0 版本的 C++ 编译器无法实现 C++ 标准的所有特性。例如,标准库定义的各部分将模板作为类成员包含在内,而 C++ 5.0 不支持该特性。在要随编译器一起提供的库中将缺少这些部分,或者它们的实现稍有不同。

由于这些原因,库实现分为两部分:

  • libCrun,包括编译器帮助函数,其中包括对堆内存分配、异常处理和动态类型信息的支持
  • libCstd,包括 C++ 标准库的其余部分

ABI 各不相同

Sun(现在为 Oracle)的策略认为持续兼容性比符合 C++ 标准更重要,甚至比正确性更重要。即使 libCstd 不符合标准,并且在名称改编中发现一些错误,但这些缺点仍存在于以后的版本中。

由于 libCstd 保持与二进制兼容,因此它可以作为共享库提供。C++ 5.2 附带 libCstd.so.1 作为可选库,C++ 5.3 提供 libCstd.so.1 作为 Oracle Solaris 程序包的一部分。为了支持需要 C++ 标准库的更多特性但不需要二进制兼容性的客户,C++ 5.4 还附带了 C++ 标准库的开源 STLport 实现。

ABI 现状(2011 年 3 月)

当前推出的编译器是 C++ 5.11,它是 Oracle Solaris Studio 12.2 的一个组件。

从 5.0 到 5.11 的 C++ 编译器提供了与 C++ 4.2 兼容的模式以及支持 C++ 标准的默认模式。这两个模式代表两个不同的 ABI。对于给定的模式,所有 5.x 编译器都生成与二进制兼容的代码。

C++ 5.11 在选定的 Linux 平台上也受支持。由于 Linux 开发人员可能要与通过 Gnu C++ (g++) 编译的代码链接,因此 Linux 编译器还提供了一个模式,在该模式中可以生成与 g++ 相同的 ABI(这与默认 Solaris Studio C++ ABI 大不相同)。

无论兼容性策略如何,一些客户都要求修复阻止其程序运行的 ABI 错误。为了满足这些客户的需求,编译器具有一个从未披露过的选项,可生成正确但不兼容的 ABI。对于不依赖于第三方二进制库的客户,只要他们很谨慎地使用该选项编译其所有代码,便可使用已更正的 ABI。随 C++ 编译器一起提供的库不会触发任何 ABI 错误,因此不需要这些库的单独版本。

为了支持需要更符合标准的库但不需要与 libCstd 的兼容性的客户,编译器附带了 STLport 中的开源库。此外,C++ 5.10 和 5.11 直接支持在 Oracle Solaris 上使用 Apache stdcxx(当安装在指定位置时)。最后,编译器还提供了一种相对容易的方法,使用第三方标准库代替编译器附带的库。

未来发展

毫无疑问,现在使用的默认 C++ ABI 必须在几年内继续受支持。这意味着要推出生成该 ABI 以及兼容的库版本的编译器。与 C++ 4.2 兼容的模式已弃用了几年,并且在未来版本中不受支持。

截至本文撰写时,即将发布新的 C++ 标准。现有的 Solaris Studio ABI 不支持增强的 C++ 语言的所有新特性,并且已采用不兼容的方式修改了 C++ 标准库。新标准仍需要另一个 ABI 以及新的库。

g++ 可能还生成不同的 ABI 并具有新的库,因此与 g++ 的持续兼容性需要支持当前和未来的 g++ ABI。

关于作者

Steve Clamage 从 1994 年开始就在 Sun(现在 Oracle)工作。他目前是 C++ 团队的技术领头人,并参与了 Oracle Solaris Studio 产品的全面工作。他从 1995 年开始一直担任 ANSI C++ 委员会的主席。