常量解释器

简介

constexpr 解释器的目标是替换 clang 中现有的树求值器,提高对那些在求值器中执行效率低下的构造的性能。解释器通过以下标志激活

  • -fexperimental-new-constant-interpreter 启用解释器,如果遇到不支持的特性则发出错误

字节码编译

字节码编译在 ByteCodeStmtGen.h 中处理语句,在 ByteCodeExprGen.h 中处理表达式。编译器有两个不同的后端:一个用于生成函数的字节码 (ByteCodeEmitter),另一个用于在编译时直接评估表达式,而不生成字节码 (EvalEmitter)。所有函数都编译为字节码,而用在常量上下文中的顶级表达式则直接评估,因为字节码永远不会被重用。这种机制旨在为替换求值器铺平道路,提高其在函数和循环中的性能,同时在单次使用顶级表达式时保持同样快。

解释器依赖于基于堆栈的强类型操作码。代码生成器之间的粘合逻辑,以及操作码的枚举和描述,可以在 Opcodes.td 中找到。操作码在 Interp.h 中实现为通用模板方法,并由解释器循环或评估发射器使用相关的基本类型实例化。

基本类型

  • PT_{U|S}int{8|16|32|64}

    特定位宽的有符号或无符号整数,使用 `Integral` 类型实现。

  • PT_{U|S}intFP

    任意但固定宽度(用于实现目标所需的但主机不支持的整型)的有符号或无符号整数。在底层,它们依赖于 APValue。这些类型的 Integral 特化是操作码要求与固定整数共享实现。

  • PT_Bool

    布尔类型的表示,本质上是一个 1 位无符号 Integral

  • PT_RealFP

    任意但固定精度的浮点数。将来可以像整数一样专门化,以提高浮点性能。

  • PT_Ptr

    指针类型,在 "Pointer.h" 中定义。指针可以是空指针、引用解释器分配的内存 (BlockPointer) 或指向可以推导但无法访问的地址 (ExternPointer)。

  • PT_FnPtr

    函数指针类型,也可以是空函数指针。在 "FnPointer.h" 中定义。

  • PT_MemPtr

    成员指针类型,也可以是空成员指针。在 "MemberPointer.h" 中定义。

  • PT_VoidPtr

    空指针类型,可用于循环转换。表示为所有可以转换为空的指针的并集。在 "VoidPointer.h" 中定义。

  • PT_ObjCBlockPtr

    ObjC 块的指针类型。在 "ObjCBlockPointer.h" 中定义。

复合类型

解释器区分两种复合类型:数组和记录(结构体和类)。联合体表示为记录,但最多只能标记一个字段为活动字段。非活动字段的内容保留到它们被重新激活并覆盖为止。复数 (_Complex) 和向量 (__attribute((vector_size(16)))) 被视为数组。

字节码执行

字节码使用基于堆栈的解释器执行。执行上下文由一个 InterpStack 以及一串存储调用帧的 InterpFrame 对象组成。帧由调用指令构建,由返回指令销毁。它们执行一次分配,为单个块中的所有局部变量保留空间。这些对象存储所有必要的信息,以便在评估失败时发出堆栈跟踪。

内存组织

解释器中的内存管理依赖于 3 种数据结构:Block 对象存储数据和关联的内联元数据;Pointer 对象引用块或进入块;Descriptor 结构描述块和嵌套在块中的子对象。

块包含与元数据交错的数据。它们在代码生成器中静态分配(全局变量、静态成员、虚拟参数值等)或在解释器中动态分配,在创建包含函数局部变量的帧时。块与描述符相关联,描述符表征整个分配以及一些附加属性。

  • IsStatic 指示块在解释器中是否具有静态持续时间,即它不是帧中的局部变量。

  • DeclID 标识每个全局声明(对于局部变量,它被设置为一个无效且无关的值),以防止涉及全局变量和具有静态存储持续时间的临时变量的非法写入和读取。

静态块永远不会被释放,但局部块可能会被释放,即使存在指向它们的活动指针。指针仅在它们指向的块有效时才有效,因此具有指向它的指针且寿命结束的块会保持活动状态,直到所有指向它的指针都超出范围。由于帧在函数退出时被销毁,因此此类块被转换为 DeadBlock 并复制到解释器本身而不是帧管理的存储中。对这些块的读写是非法的,会导致发出相应的诊断信息。当最后一个指针超出范围时,死块也会被释放。

块的寿命通过存储在块描述符中的 3 种方法管理

  • CtorFn:初始化存储在块中的元数据以及实际数据。调用非平凡对象的默认构造函数 (PointerRealFP 等)。

  • DtorFn:调用非平凡对象的析构函数。

  • MoveFn:将块移动到死存储区。

非静态块通过一个侵入式双向链表跟踪所有指向它们的指针,这些指针在将块转换为死块时需要调整和使所有指针失效。如果对象的寿命结束,所有指向它的指针将失效,并在取消引用时发出相应的诊断信息。

解释器区分 3 种不同的块

  • 基元

    包含单个基元且没有其他元数据的块。

  • 基元数组

    基元数组在其第一个字段中包含指向 InitMap 存储区的指针:初始化映射是一个位图,指示数组中所有已初始化的元素。如果指针为空,则没有元素被初始化,而值为 (InitMap*)-1 则表示该对象已完全初始化。当所有字段都初始化后,映射将被释放并替换为该标记。

    数组元素在指针之后顺序存储,不填充。

  • 复合数组和记录

    复合数组中的每个元素前面都带有一个 InlineDescriptor,该描述符存储特定于字段而不是整个分配位置的属性。描述符和元素在块中顺序存储。记录的布局与复合数组相同:每个字段和基类前面都带有一个内联描述符。InlineDescriptor 具有以下字段

    • Offset:数组或记录中的字节偏移量,用于返回到父数组或记录。

    • IsConst:表示字段是否为 const 限定的标志。

    • IsInitialized:表示字段或元素是否已初始化的标志。对于非基元字段,这仅与在构造期间确定对象的动态类型有关。

    • IsBase:表示记录是否为基类的标志。在这种情况下,可以使用偏移量来识别派生类。

    • IsActive:表示字段是否是联合体的活动字段的标志。

    • IsMutable:表示字段是否标记为可变的标志。

内联描述符由块的 CtorFn 填充,该函数使存储区处于未初始化但有效的状态。

描述符

描述符在字节码编译时生成,包含确定特定内存访问是否在 constexpr 中允许所需的信息。它们还携带所有必要的信息来发出涉及内存访问的诊断信息,例如源于该块的声明。目前,只有一种描述符类型,它对所有块类型编码信息。

指针

指针在 `Pointer.h 中实现,表示为带标签的联合体。其中一些可能尚未在上游 `clang 中提供。

  • **BlockPointer**: 用于引用由解释器分配和管理的内存,是唯一一种允许在解释器中解引用的指针类型

  • **ExternPointer**: 指向可以寻址但不能被解释器读取的内存。它等效于 APValue,跟踪一个声明和一个指向该分配的字段和索引路径。

  • **TargetPointer**: 表示通过指针运算从基地址派生的目标地址,例如 `((int *)0x100)[20]。空指针是偏移量为零的目标指针。

  • **TypeInfoPointer**: 跟踪由 `typeid 返回的不透明类型的信息

  • **InvalidPointer**: 是由无效操作创建的虚拟指针,允许解释器继续执行。不允许指针运算或解引用。

除了前面提到的联合体之外,还有许多其他类似指针的类型具有自己的类型

  • **ObjCBlockPointer** 跟踪 Objective-C 块

  • **FnPointer** 跟踪函数并延迟缓存它们的编译版本

  • **MemberPointer** 跟踪 C++ 对象成员

空指针可以通过将任何上述指针进行强制转换来构建,实现为所有指针类型的联合体。`BitCast 操作码负责执行这些类型与基本整数之间的所有合法转换。

BlockPointer

块指针跟踪一个 `Pointee,即它们指向的块,以及一个 `Base 和一个 `Offset。基标识最里面的字段,而偏移量指向相对于基的数组元素(包括超出范围的指针)。偏移量标识被引用的数组元素或字段,而基指向包含该字段的外部对象或数组。这两个字段允许所有指针被唯一标识、区分和表征。

例如,考虑以下结构

struct A {
    struct B {
        int x;
        int y;
    } b;
    struct C {
        int a;
        int b;
    } c[2];
    int z;
};
constexpr A a;

在目标上,`&a 和 `&a.b.x 相等。`&a.c[0] 和 `&a.c[0].a 也一样。在解释器中,所有这些指针都必须区分,因为它们都允许寻址不同的内存范围。

在解释器中,该对象需要 240 字节的存储空间,并且其字段与元数据交织在一起。可以从该对象派生的指针在下图中说明

    0   16  32  40  56  64  80  96  112 120 136 144 160 176 184 200 208 224 240
+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
+ B | D | D | x | D | y | D | D | D | a | D | b | D | D | a | D | b | D | z |
+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
    ^   ^   ^       ^       ^   ^   ^       ^       ^   ^       ^       ^
    |   |   |       |       |   |   |   &a.c[0].b   |   |   &a.c[1].b   |
    a   |&a.b.x   &a.y    &a.c  |&a.c[0].a          |&a.c[1].a          |
      &a.b                   &a.c[0]            &a.c[1]               &a.z

所有指针的 `Base 偏移量指向字段或数组的开头,并且前面有一个内联描述符(除非 `Base 为零,指向根)。所有相关属性都可以从内联描述符或块的描述符中读取。

数组元素由指针的 `Offset 字段标识,指向复合类型的内联描述符之后,在基本数组的情况下指向实际数据之前。`Offset 指向可以从其读取基本类型的偏移量。例如,`a.c + 1 将具有与 `a.c 相同的基,因为它属于 `a.c,但其偏移量将指向 `&a.c[1]。数组到指针的衰减操作将指向数组(其中偏移量等于基)的指针调整为指向第一个元素的指针。

ExternPointer

可以派生外部指针,指向无法从 constexpr 读取的符号。外部指针包含一个基声明,以及一个指定子对象的路径,类似于 APValue 的 `LValuePath。如果底层变量在指针创建后定义,则外部指针可以转换为块指针,如下面的示例所示

extern const int a;
constexpr const int *p = &a;
const int a = 5;
static_assert(*p == 5, "x");

TargetPointer

虽然在 constexpr 中禁止空指针运算或整数到指针的转换,但必须折叠目标偏移量上的一些表达式,复制 `offsetof 内置函数的行为。目标指针的特征在于 3 个偏移量:字段偏移量、数组偏移量和基偏移量,以及一个描述符,指定指针应该引用的类型。数组索引调整数组偏移量,而字段偏移量在创建指向成员的指针时调整。将整数强制转换为指针会设置基偏移量的值。作为特殊情况,空指针是所有偏移量都设置为 0 的目标指针。

TypeInfoPointer

TypeInfoPointer 跟踪两种类型:分配给 `std::type_info 的类型和传递给 `typeinfo 的类型。

InvalidPointer

此类指针由无法生成有效指针的操作构建,允许解释器在发出警告后继续执行。检查此类指针将停止执行。

TODO

缺少的语言特性

  • 更改联合体的活动字段

  • volatile

  • __builtin_constant_p

  • dynamic_cast

  • new 和 `delete

  • 定点数字和复数运算

  • 几个内置方法,包括字符串操作和 `__builtin_bit_cast

  • 失败后继续:应该实现字节码级别的异常处理形式,以允许执行恢复。例如,参数计算应该在参数计算失败后恢复。

  • 指针到整数的转换

  • 延迟描述符:解释器在遇到类型时创建 `Record 和 `Descriptor:尚未定义的类型应该在需要时延迟创建

已知错误

  • 如果执行失败,在清除堆栈时存储 APInts 和 APFloats 的内存会泄漏