译:Swift ABI (二)

  • By 11
  • In Swift
  • Posted March 5, 2017


泊学翻译自Swift在Github上发布的Swift ABI Manifesto

Swift ABI的构成

在实践中,ABI关注的内容是紧密耦合在一起的。但是,作为一个概念模型。我更愿意把它分成6个独立的分类:

  1. 和类型相关的,例如:所有的结构和类对象应该有确定的内存布局。为了达成二进制层次上的交互(这里应该指的是不同版本Swift编译器生成的结果在二进制上兼容),它们必须共享相同的布局协议。这部分内容会在数据布局的章节进行讨论。

  2. Swift可执行程序,运行时、反射机制、调试器以及可视化工具都和类型的metadata息息相关。因此,metadata应该有一种更稳定的读取方式,要不为类型的metadata设计确定的内存布局,要不为访问类型的metadata提供一套稳定的APIs。这部分内容会在类型的metadata章节继续讨论。

  3. 程序库中每一个被导出或来自外部的符号都需要一个唯一的名称,这个名称的识别应该在所有的二进制实体之间达成一致。由于Swift提供了函数重载以及上下文相关的名字空间(这里应该指的是通过module引入的名字空间),因此,在代码中出现的任何名称可能都不是全局唯一的。为了把它们表达成一个全局唯一的名字,Swift使用了一种叫做name mangling的技术。具体的name mangling方案会在Mangling章节中讨论。

  4. 函数必须知道如何相互调用,因此我们需要约定调用栈在内存中是如何布局的,哪些寄存器会在调用间被保留。这些内容统称为函数的调用约定。这部分内容会在Calling Convention章节中讨论。

  5. Swift发布的时候自带了一个运行时库,用来处理诸如动态类型转换、引用计数、类型反射等相关的工作。Swift程序在编译的时候会调用这些运行时中的API。因此,Swift运行时API也是Swift ABI的一部分。运行时API的稳定性会在运行时的章节中讨论。

  6. 除此之外,Swift还自带了一个标准库,其中定义了很多公共的类型、结构和基于这些结构的方法。为了让这份自带的标准库可以被用不同版本Swift编写的程序调用,标准库也需要对外暴露一份稳定的API。因此,和标准库中定义的类型需要有确定的内存布局一样,Swift标准库的API也是Swift ABI的一部分。关于标准库ABI的稳定性会在标准库的章节中进行讨论。

数据布局

背景

首先,我们来定义一些术语。

  • 对象(object)是指某个类型的存储实体,它可以存储在内存中的某个位置,或存储在寄存器里。对象可以是struct/enum类型的值、class的实例、class实例的引用、protocol类型的值,甚至是closures。而在那些视class为全部的面向对象编程语言中,对象则就是指的class的一个实例,这是Swift有别于它们的地方;
  • 对象的数据成员(data member)是指任意需要存放在类对象内存布局内的值。数据成员包括了一个对象所有的stored properties和associated values;
  • 闲置位(spare bit)是某种类型对象(的内存布局)中,没有被使用的部分。这些部分通常是为了对齐内存地址而填充的地址空间。稍后,会更深入讨论这个话题;
  • 在对象的布局中,除了那些表示对象值的bit之外,还有一类bit并没有实际的意义。例如,对于一个包含3个case的传统C风格enum来说,它的值用两个bit就可以表示了(数字0,1,2分别对应三个case,它们对应二进制的00,01和10)。这时,这两个bit可以表示的第4个值3,它的二进制表示中的第一个1就是无意义的;

数据布局,也被称作类型布局,定义了一个对象的数据在内存中的布局。这包括了对象在内存中的大小,对象的对齐(稍后会定义)以及如何在对象中找到每一个数据成员。

如果编译器可以在编译期确定一个对象的布局,这个对象的布局就是静态的。如果对象的布局只有在运行时在可以确定,这类对象的布局就是不透明的(opaque layout)。我们会在opaque layout章节中深入讨论这类对象。

布局和类型的属性

在Swift里,对于每一个静态布局的类型T,ABI指定了计算以下内容的方式:

  • 关于类型的对齐:对于x: T来说,对象x的起始地址对当前硬件平台的内存对齐值取模一定是0(也就是说总在内存地址对齐的位置开始);
  • 关于类型的尺寸:一个类型对象的大小,按对象占用的字节数计算(可以是0),但是不包含填充在对象结尾的字节;
  • 关于每个数据成员的偏移(如果可行):每一个数据成员的地址,都是从对象起始地址开始计算的;

把计算对齐和对象大小的方式结合在一起,就是这个类型的对象占用内存时的步进计算方式,它等于把对象的尺寸按照内存对齐的大小向上取整一个单位(最小是1单位。例如,对象的大小是7,内存要求8字节对齐,那么对象占用的内存就向上调整到8)。这种计算方式对于在连续内存地址空间中排列对象(例如:数组)时很有帮助。

一些类型有以下两种有趣的属性:

  • 如果一个类型仅仅用来存储数据(注:很多struct就是如此,但不是全部),在拷贝、移动或销毁这类对象时,就不会有额外的复杂语义。我们管这种类型叫做POD(Plain of Data),也叫做trivial type。这种类型的对象在拷贝时,直接复制它的值即可,销毁时,可以直接回收分配给它的存储资源。只有在一个类型的所有数据成员都是trivial type时,这个类型才是一个trivial type。

  • 如果一个对象的地址没有被其它辅助设计的表结构(注:这里应该是指为了实现对象布局而引入的额外数据结构)引用,这种类型的对象就是可以按位移动的(bitwise ovable)。当一个对象要从一个地址拷贝到另外一个地址,并且原地址的对象已经不再需要的时候,就可以把原地址的对象按位拷贝到新地址,然后把原地址对象标记为不可用的状态。如果一个类型所有数据成员都是可以按位移动的,则这个类型的对象也是可以按位移动的。并且,所有的trivial type的对象都是可以按位移动的。

例如,一个struct Point,它有两个Double类型的属性xy,表示平面上X轴和Y轴的坐标。此时,Point就是一个trivial type。复制Point对象的时候,我们只要按位拷贝对象的内容就可以,销毁的时候我们也无需做任何额外的工作。

再来看一个可以按位移动的非POD类型的例子,就是包含类对象引用的struct。在拷贝这类对象时,我们不能只是简单拷贝struct对象的值,还要retain其包含的类对象。而在销毁这类struct对象的时候,我们也要release其引用的类对象。但是,这类对象却是可以在不同的内存地址间移动的,只要每次移动后,我们都把原地址的对象标记为不可用,保持其包含的类对象总体引用计数不变就好了。

最后,来看一个不可按位移动的非POD类型的例子,就是一个包含weak引用的struct。所有的weak引用都是通过一个辅助表格维护的。因此,当它们引用的对象被销毁的时候,这些weak references才可以被设置成nil。当移动这类对象的时候,必须要更新表格中的weak reference,让它引用到新的对象地址。

不透明布局

当一个对象的布局只有在运行时才可以确定时,这类对象的布局就叫做不透明的。例如,一个泛型对象,我们就无法在编译期确定对象的布局。再有,就是一种更具适应性的类型(resilient type),我们会在下一节描述这个概念。

对于一个具有不透明布局的对象来说,它的大小、对齐,是否是一个POD类型或者是否可以按位移动都是通过查询它的value witness table来确定的。我们会在value witness table这一节中深入讨论这个话题。数据成员的偏移是通过查询类型的metadata得到的,我们会在value metadata的章节中讨论。拥有不透明布局的对象必须通过间接的方式传递,我们会在函数底层签名(Function Signature Lowering)的章节中讨论。Swift运行时,通过一些指针和拥有不透明布局的对象进行交互,因此,这类对象必须是可以取地址的。我们会在抽象级别的章节中进一步进行描述。

在实践中,编译器可以在编译期对布局有部分的了解。例如,对于下面这样的struct

struct Type<T> {
    var number: Int
    var object: T
}

在这种情况下,根据特定的布局算法,整数number的布局以及它在struct对象中的位置都是可以确定的。但是,泛型属性的存储却是不透明布局,因此,整个结构的大小和对齐都是不确定的。我们正在调研如何用更高效的方式布局这种“半透明”形式的组合SR-3722。这很可能会导致把不透明的部分放到布局的末尾以保证所有静态布局的部分可以正常计算偏移。

(To be continue...)

回到黑板报