Bun

Bun 如何在不使用 V8 的情况下支持 V8 API(第一部分)


Ben Grant · 2024 年 9 月 30 日

我们正在旧金山招聘系统工程师,共同构建 JavaScript 的未来!

如果软件包在 Node.js 中可以工作,但在 Bun 中无法工作,我们将其视为 Bun 中的错误。

原生 C 和 C++ API 经常在 JavaScript 生态系统中使用,用于性能关键型库,例如 2D Canvas、数据库驱动程序、CPU 检测等等。Bun 和 Node.js 都实现了 Node-API (napi),这是推荐的引擎独立的 C API,用于与 JavaScript 交互。

但是,许多流行的原生模块直接使用 Node.js 公开的 V8 引擎 API。在撰写本文时,请求 V8 API 支持的问题 在 Bun 的问题跟踪器上按赞数排序是第 11 位最高的未解决问题。

这对 Bun 来说是一个挑战,因为我们使用 JavaScriptCore 作为 JavaScript 引擎(Safari 中使用),这与使用 V8(Chrome 中使用)的 Node.js 不同。JavaScriptCore 是一个完全不同的 JavaScript 引擎实现,具有不同的设计选择。

JavaScriptCoreV8
垃圾回收器非移动式,保守式移动式,精确式
值表示JSC::JSValuev8::Local<T>
值生命周期堆栈扫描句柄作用域
...还有很多很多.........

以前,当在 Bun 中加载依赖于 V8 C++ API 的软件包之一时,您有时会看到这样的错误

bun ./index.js
dyld[26946]: missing symbol called

自 Bun v1.1.25 以来,我们一直在使用 JavaScriptCore 实现不断增长的 V8 C++ API 列表。这为 Bun 中流行的 Node.js 原生模块(如 cpu-features)解除阻塞。以下是我们正在这样做的方式。

JavaScriptCore 与 V8 API 示例

首先,为了获得一个高层次的概述,让我们看一下使用 JSC API 的一些示例代码及其在 V8 API 中的等效代码。我们将编写一个函数,该函数从 JavaScript 中接受两个数字并返回它们的乘积。如果用户提供的参数少于或多于两个,或者任何一个参数不是数字,我们将返回 undefined。请注意,我们仅显示 C++ 函数实现,但实际上需要更多的粘合代码才能将其注册为可以从 JavaScript 调用的函数。

JavaScriptCore

jsc-multiply.cpp
EncodedJSValue JSC_HOST_CALL_ATTRIBUTES multiply(JSGlobalObject* globalObject, CallFrame* callFrame) {
    if (callFrame->argumentCount() != 2) {
        return JSValue::encode(jsUndefined());
    }

    JSValue arg1 = callFrame->argument(0);
    JSValue arg2 = callFrame->argument(1);

    if (!arg1.isNumber() || !arg2.isNumber()) {
        return JSValue::encode(jsUndefined());
    }

    double number1 = arg1.asNumber();
    double number2 = arg2.asNumber();
    EncodedJSValue returnValue = JSValue::encode(jsNumber(number1 * number2));

    return returnValue;
}

V8 版本

v8-multiply.cc
void multiply(const FunctionCallbackInfo<Value>& info) {
    Isolate* isolate = info.GetIsolate();
    if (info.Length() != 2) {
        return;
    }

    Local<Value> arg1 = info[0];
    Local<Value> arg2 = info[1];

    if (!arg1->IsNumber() || !arg2->IsNumber()) {
        return;
    }

    double number1 = arg1.As<Number>()->Value();
    double number2 = arg2.As<Number>()->Value();
    Local<Number> returnValue = Number::New(isolate, number1 * number2);

    info.GetReturnValue().Set(returnValue);
}

这两者都执行相同的基本操作

  • 检查参数的数量
  • 检查参数的类型
  • 将两个参数都转换为 C++ double
  • 将参数相乘
  • 将结果转换为 JavaScript 数字
  • 返回结果

但它们以不同的方式执行。

值表示,第一部分

首要的主要区别之一是 JavaScript 值在两个引擎中的表示方式。JSC 使用 JSValue,这是一个 8 字节(Bun 仅支持 64 位 CPU)的类,可以表示任何 JavaScript 类型。我们还看到了 EncodedJSValue,它是一个与 JSValue 大小相同的整数,用于跨 ABI 边界传递 (JSValue::encode 只是将相同的位重新解释为不同的类型)。

另一方面,在 V8 中,您通常会看到模板类 Local<T>,它也是 8 个字节。Local<T> 重载了 T* operator*()T* operator->(),这使得本机代码可以像对待指向 T 的指针一样对待它。请注意 V8 代码中 .-> 的用法:. 表示我们正在调用 Local 上的函数,而 -> 表示我们正在调用包装类型上的函数。Local<Value> 是表示 JavaScript 可以使用的任何值最通用的形式。在对 Local<Value> 执行几乎任何操作之前,您都需要将其转换为某些特定类型的 Local。在这里,我们调用

  • bool Value::IsNumber() 来检查两个参数的类型
  • Local<S> Local<T>::As<S>() 将它们强制转换为 Local<Number>(如果它们不是数字,这将是未定义的行为)
  • double Number::Value() 从 JavaScript 数字中提取 C++ double

API 比较

我们的 V8 值是 Local 这一事实具有重要的生命周期含义,但稍后我们将深入探讨这一点,以及 JSValueLocal 是如何实现的细节。现在,让我们回顾一下 multiply 函数,并回顾一下 V8 和 JSC 之间的一些其他相似之处和不同之处。

  • 两者都接受一个对象(CallFrameFunctionCallbackInfo)来表示从 JavaScript 传递到我们函数的信息。我们都使用它们来获取参数的数量,然后获取参数本身(V8 对第二步使用 operator[],这就是为什么它看起来像数组索引)。如果我们需要,该对象也是我们在两个 API 中访问 this 值的方式。
  • 我们的 JSC 函数返回 EncodedJSValue,这是我们指示返回给 JavaScript 的内容的方式。在 V8 中,我们的函数本身返回 void,我们使用 info.GetReturnValue().Set(...) 返回一个值(如果我们不调用它,则默认为 undefined)。
  • 我们的 JSC 函数使用 JSC_HOST_CALL_ATTRIBUTES 注释。此宏展开为编译器特定的属性,该属性确保我们函数的调用约定是正确的。JSC 对 JIT 编译的机器代码使用 Unix 的 System V 调用约定。这使得他们的代码生成更简单,因为他们不必生成使用两种不同调用约定的代码,但这也意味着 JavaScript 调用的任何本机函数都需要使用正确的调用约定。否则,本机函数会在 JIT 编译的代码放置它们的寄存器之外的其他寄存器中查找其参数。V8 没有与此等效的功能,因为它始终对给定的平台使用标准调用约定。
  • 我们的 JSC 函数被赋予一个指向 JSGlobalObject 的指针。这是一个类,它封装了 JavaScript 可访问的全局作用域,以及与 JSC 交互的本机函数使用的全局状态。Bun 对 JSC 的通用版本进行了子类化,以添加我们自己的全局可访问功能,例如 Bun 对象 (Zig::GlobalObject 为 7,528 字节!)。我们还将看到 JSC::VM 的用法,您可以从全局对象访问它,并且它封装了更多 JavaScript 代码的执行状态。虽然两者都不完全等效,但我们看到的两个类似的 V8 类型是 IsolateContext。对我们来说,重要的细节是,一个 isolate 可以包含多个上下文,但只有一个上下文在给定的时间运行 JavaScript(因为两个线程不能共享一个 isolate)。V8 的这篇文章 提供了关于区别的更多详细信息。isolate 不会直接传递到我们的 V8 本机函数中,但我们可以很容易地从 FunctionCallbackInfo 中获取它,如果需要,我们也可以要求 isolate 告诉我们当前的上下文。
  • 在 V8 中,我们必须传递 isolate 才能将乘法结果转换为 Local<Number>,而在 JSC 中,我们没有为 jsNumber 提供任何额外的参数。在 V8 中,我们甚至必须对布尔值、nullundefined 等值执行此操作,而在 JSC 中,这些值都具有直接返回 JSValue 的简单函数,并且不依赖于 JSGlobalObjectVM。稍后我们将看到为什么这些看似简单的函数需要访问当前的 isolate。

实现 V8 函数,首次尝试

由于 V8 的 Local 和 JSC 的 JSValue 都是 8 个字节,因此我首先尝试在两者之间简单地重新解释。Local<T>::operator*()Local<T>::operator->() 只是将内容作为指针返回(我们实际上无法更改这些函数的实现,因为它们在 V8 标头中声明为内联函数,但将实现复制到 Bun 的代码中将帮助我们编写其他在 Local 上工作的 V8 函数)

v8.h
namespace v8 {

template<class T>
class Local final {
public:
    T* ptr;

    T* operator*() const { return ptr; }
};

}

在我们的实现中,T* 实际上不会是指向 T 的指针。相反,它将被重新解释为 JSValue。这意味着我们代表 V8 值的类(例如 Number)实际上不包含任何字段(V8 中也是如此,这当时应该让我感到担忧)。相反,它们将接收一个 this 指针,该指针实际上只是一个 JSValue

v8.h
class Number {
public:
    BUN_EXPORT static Local<Number> New(Isolate* isolate, double value);

    BUN_EXPORT double Value() const;
};

Local<Number> Number::New(Isolate* isolate, double value)
{
    JSC::JSValue jsv = JSC::jsDoubleNumber(value);
    JSC::EncodedJSValue encoded = JSC::JSValue::encode(jsv);
    auto ptr = reinterpret_cast<Number*>(encoded);
    return Local<Number> { ptr };
}

double Number::Value() const
{
    auto encoded = reinterpret_cast<JSC::EncodedJSValue>(this);
    JSC::JSValue jsv = JSC::JSValue::decode(encoded);
    return jsv.asNumber();
}

也许令人惊讶的是,这实际上奏效了!并且持续工作了一段时间。我为这些 API 编写了测试,这些测试在 Node.js 和 Bun 中打印了相同的结果(尽管它们必须包装在 Node-API 函数中,因为我尚未实现 Node.js 加载本机模块的方式)。可悲的是,当我尝试实现对象时,这个设计的基本缺陷变得显而易见。

问题

V8 有一个名为 ObjectTemplate 的类,该类可以由本机代码创建,然后用于构造多个对象,这些对象可以传递给 JavaScript。这些对象支持一项称为内部字段的功能,这些字段由数字索引,可以由本机代码访问,但不能由 JavaScript 访问。您可以使用 ObjectTemplate 来配置它创建的对象上应存在的内部字段的数量。本机模块可以使用内部字段将“句柄”对象返回给 JavaScript,这些对象表示本机资源,而无需让 JavaScript 干扰这些资源的内部状态。

当我第一次尝试测试访问对象上的内部字段时,问题变得显而易见(好吧,有点显而易见)。我已经注意到 v8::Object::GetInternalField 是一个内联函数,它执行一些检查,然后在检查失败时调用 v8::Object::SlowGetInternalField。由于内联函数将从 V8 头文件中获取其定义,并且该代码被编译到任何本机模块中,因此我们无法控制它做什么。我曾希望通常会采用慢速路径,并且它会调用我们可以控制的 SlowGetInternalField 函数(不是内联函数)。但是,当我第一次测试内部字段时,我在 V8 的内联函数内部遇到了段错误。

让我们看一下 V8 的实现

v8-object.h
namespace v8 {

class V8_EXPORT Object : public Value {
 public:
  // ...
  V8_INLINE Local<Data> GetInternalField(int index);
  // ...
};

// ...
Local<Data> Object::GetInternalField(int index) {
#ifndef V8_ENABLE_CHECKS
  using A = internal::Address;
  using I = internal::Internals;
  A obj = internal::ValueHelper::ValueAsAddress(this);
  // Fast path: If the object is a plain JSObject, which is the common case, we
  // know where to find the internal fields and can return the value directly.
  int instance_type = I::GetInstanceType(obj);
  if (I::CanHaveInternalField(instance_type)) {
    int offset = I::kJSAPIObjectWithEmbedderSlotsHeaderSize +
                 (I::kEmbedderDataSlotSize * index);
    A value = I::ReadRawField<A>(obj, offset);
#ifdef V8_COMPRESS_POINTERS
    // We read the full pointer value and then decompress it in order to avoid
    // dealing with potential endiannes issues.
    value = I::DecompressTaggedField(obj, static_cast<uint32_t>(value));
#endif

    auto isolate = reinterpret_cast<v8::Isolate*>(
        internal::IsolateFromNeverReadOnlySpaceObject(obj));
    return Local<Data>::New(isolate, value);
  }
#endif
  return SlowGetInternalField(index);
}

}

幸运的是,在调试模式下构建的本机模块中,我仍然可以步入和单步执行内联函数,并看到正确的堆栈跟踪,就好像该函数不是内联函数一样。崩溃发生在 ValueAsAddress 内部,它也是内联的。让我们稍微重构一下此函数,以便更容易理解发生了什么。在 Node.js 的标头中,既未定义 V8_ENABLE_CHECKS 也未定义 V8_COMPRESS_POINTERS,因此将展开外部 #ifndef 中的代码,但不会展开内部 #ifdef 中的代码。internal::Addressuintptr_t 的类型别名。以下是一些它调用的内联函数的定义

ValueAsAddress

v8-internal.h
  template <typename T>
  V8_INLINE static Address ValueAsAddress(const T* value) {
    return *reinterpret_cast<const Address*>(value);
  }

GetInstanceType

v8-internal.h
  V8_INLINE static int GetInstanceType(Address obj) {
    Address map = ReadTaggedPointerField(obj, kHeapObjectMapOffset);
#ifdef V8_MAP_PACKING
    // omitted
#endif
    return ReadRawField<uint16_t>(map, kMapInstanceTypeOffset);
  }

ReadRawFieldReadTaggedPointerField

v8-internal.h
  template <typename T>
  V8_INLINE static T ReadRawField(Address heap_object_ptr, int offset) {
    Address addr = heap_object_ptr + offset - kHeapObjectTag;
#ifdef V8_COMPRESS_POINTERS
    // omitted
#endif
    return *reinterpret_cast<const T*>(addr);
  }

  V8_INLINE static Address ReadTaggedPointerField(Address heap_object_ptr,
                                                  int offset) {
#ifdef V8_COMPRESS_POINTERS
    // omitted
#else
    return ReadRawField<Address>(heap_object_ptr, offset);
#endif
  }

在 Bun 支持的平台上,kHeapObjectTag 为 1,kHeapObjectMapOffset 为 0,kMapInstanceTypeOffset 为 12。

让我们首先重写 GetInternalField 以直接使用 ReadRawField

v8-object.h
Local<Data> Object::GetInternalField(int index) {
  using A = internal::Address;
  using I = internal::Internals;
  A obj = internal::ValueHelper::ValueAsAddress(this);
  int instance_type = I::GetInstanceType(obj);
  A obj = *reinterpret_cast<const A*>(this);
  A map = ReadRawField<Address>(obj, 0);
  int instance_type = ReadRawField<uint16_t>(map, 12);

  if (I::CanHaveInternalField(instance_type)) {
    // omitted
  }
  return SlowGetInternalField(index);
}

ReadRawField 所做的只是从传入的地址中减去 kHeapObjectTag,添加偏移量,然后将结果地址转换为指针并取消引用它。因此,我们可以将其进一步重写为

v8-object.h
Local<Data> Object::GetInternalField(int index) {
  using A = internal::Address;
  using I = internal::Internals;

  A obj = *reinterpret_cast<const A*>(this);
  A map = ReadRawField<Address>(obj, 0);
  int instance_type = I::GetInstanceType(obj);
  A map = *reinterpret_cast<const A*>(obj - 1 + 0);
  int instance_type = *reinterpret_cast<const uint16_t*>(map - 1 + 12);

  if (I::CanHaveInternalField(instance_type)) {
    // omitted
  }
  return SlowGetInternalField(index);
}

所以,我们...

  • this(类型为 Object*,尽管实际上不是)重新解释为指向 Address 的指针,并取消引用该指针以获取 obj
  • obj 中减去 1,并从该位置读取地址以获取 map
  • 将 11 添加到 map,并从该位置读取 2 个字节以获取实例类型

请记住,在当前实现中,this 根本不是指向 Address 的指针,而是一个 JSValue。因此,V8 将盲目地取消引用 JSValue 看起来像指针的任何内容,再次取消引用它以找到 map 是什么,然后取消引用内容以获取实例类型。请记住,所有这些代码都被编译到每个本机模块中。我们无法更改任何内容。

此时,我认为我最好的希望是设法设置这些指针,使 V8 找到一个 instance_type,该类型使 CanHaveInternalField 返回 false(如果该函数返回 true,则 GetInternalField 在直接指针偏移处读取内部字段,这将比我们看到的所有其他内容更难在 JSC 下工作)。然后,它将始终调用 SlowGetInternalField,我可以控制其实现,因为它不是内联的。但是,即使达到这一点也很困难,并且需要放弃我们最初将 JSValue 直接存储在 Local 中的方案。

让我们回到基础知识,更仔细地研究 JSValueLocal 的表示方式。

值表示,第二部分

JavaScript 引擎面临着表示动态类型系统的挑战,在动态类型系统中,值可能随时更改其类型,同时在静态类型系统中实现,在静态类型系统中,编译器必须知道每个变量的类型。

数字的表示也是 JavaScript 引擎中的一个重要选择。根据规范,JavaScript 数字的行为类似于 IEEE 754 双精度浮点数(C 或 C++ 中的 double,Zig 或 Rust 中的 f64)。位运算确实需要将数字转换为整数以操纵其位,但是此类运算的结果仍然需要像双精度浮点数一样运行((5 << 1) / 4 必须是 2.5)。因此,最简单的实现是将 JavaScript 数字始终存储为双精度浮点数,并且仅短暂地将其转换为整数,然后再转换回双精度浮点数。

但是,将所有数字存储为双精度浮点数会带来严重的性能成本。双精度浮点数的运算速度比整数慢得多,并且典型程序使用的大多数数字无论如何都是整数(例如数组索引)。因此,几乎每个 JavaScript 引擎都包含一个优化,以便在可能的情况下将数字表示为整数。只要您小心在必要时将数字转换回双精度浮点数,就可以在不违反规范的情况下完成此操作。您还需要确保不允许表示 double 无法表示的值。例如,使用 64 位整数是不合适的,因为对于非常大的整数,这些整数的精度高于 double。在 64 位平台上,并且当 V8 的指针压缩被禁用时,JSC 和 V8 都使用有符号 32 位整数进行此优化。

V8 和 JSC 都提出了值表示,该表示可以使用仅 8 个字节,同时区分常见的值类型。让我们首先看一下 JSC 的解决方案。

JSValue

JSValue 占用 64 位,可以表示以下任何一种

  • 64 位双精度浮点数
  • 48 位 JSCell 指针。JSCell 是在堆上分配的任何 JavaScript 值的基类。
  • 32 位有符号整数
  • falsetrueundefinednull

他们是如何做到这一点的?JSC 使用一种称为 NaN 装箱 的技术。这利用了 NaN 的双精度表示形式中存在 51 个未使用的位这一事实。库和硬件浮点实现通常将这些位设置为零,因此这些位中的任何非零值都可以用于编码非浮点值。JSC 为非浮点表示形式设置了这些位中的上两位,以便可以将较低的 49 位设置为任何值,而不会与真正的 NaN 混淆。JSC 使用这 49 位中的最高位来区分 48 位指针(我们支持的任何 64 位平台实际上都没有使用超过 48 位的内存寻址)和 32 位整数。falsetrueundefinednull 表示为特定的无效指针。

我上面描述的内容实现了 NaN 装箱,其中非双精度值表示为 NaN 的有效双精度编码。但是,这样做会强制在所有非双精度值中设置一些高位,这对于指针来说是不方便的,因为我们必须在取消引用指针之前将这些位归零。JSC 的最后一个技巧是从如上所述编码的双精度值中减去 2^49。这使得我们讨论过的值类型落入以下范围

Pointer {  0000:PPPP:PPPP:PPPP

         / 0002:****:****:****
Double  {         ...
         \ FFFC:****:****:****

Integer {  FFFE:0000:IIII:IIII

由于指针现在将其高位设置为零,因此在检查 JSValue 是否为指针之后,可以直接使用它。双精度浮点数和整数仅需要简单的整数数学运算即可转换为其真实值。

如果这没有太多意义(或者即使有意义),我建议查阅 定义 JSValue 的标头中的此注释,其中很好地解释了该方案。

标记指针

V8 使用一种称为指针标记的方案。指针的低 2 位用于指示它是什么类型的值。这没问题,因为 V8 的垃圾回收器分配的所有对象都至少是 4 字节对齐的,因此有效地址中的低 2 位将始终为零。在 64 位平台(Bun 唯一支持的平台)上,如果值是指针,则最低位设置为 1,如果指针是弱指针,则次低位设置为 1。如果该值不是指针,则低 32 位全部设置为零,而高 32 位存储一个有符号整数值(V8 称之为“小整数”或“Smi”)

            |----- 32 bits -----|----- 32 bits -----|
Pointer:    |________________address______________w1|
Smi:        |____int32_value____|0000000000000000000|

您可能会注意到双精度浮点数不适合此方案。V8 将双精度浮点数存储在一个单独的分配中,称为 HeapNumber。我们将在第 3 部分中介绍 V8 如何处理布尔值、nullundefined

要了解有关 V8 值表示的更多信息,请参阅 tagged.h 以及 Igor Sheludko 和 Santiago Aboy Solanes 的博客文章,其中解释了指针压缩。指针压缩是 V8 的一个可选构建时功能,即使在 64 位平台上,它也可以将标记指针的大小更改为 32 位。Node.js 未启用指针压缩,因此 Bun 无需模仿该表示形式,但是该文章仍然包含对非压缩方案的良好解释,以及有关指针压缩权衡的有趣细节。

Local 和句柄作用域

现在我们了解了标记指针,但是 Local 中有什么呢?

V8(与 JSC 不同)使用移动垃圾回收器。这意味着垃圾回收器可能需要更改对象在内存中的位置。为此,它需要找到对该对象的所有引用,并将地址更改为对象的新位置。对于使用原始指针的 C 代码,这是不可行的。相反,V8 将我们上面讨论的标记指针存储在句柄作用域中。HandleScope 管理一组对象引用或句柄。它维护所有地址的存储,垃圾回收器可以看到这些地址。反过来,Local 存储指向句柄作用域中存储的地址的指针。当本机函数需要访问 Local 中的数据时,它必须取消引用指向地址的指针,然后取消引用该地址以转到实际对象。当垃圾回收器需要移动对象时,它只需查找所有句柄作用域中与对象的旧地址匹配的指针,并将这些指针替换为新地址。

那么整数呢?这些仍然使用句柄作用域,但是句柄作用域中存储的标记指针的低位将设置为零,指示它是 32 位有符号整数而不是指针。GC 知道它可以跳过这些句柄。

句柄作用域始终存储在堆栈内存中,并且它们本身也构成一个堆栈:当您创建 HandleScope 时,它会保留对上一个句柄作用域的引用,并且当您退出创建 HandleScope 的作用域时,它会释放它包含的所有句柄并使上一个句柄作用域再次处于活动状态。但是,句柄作用域的堆栈并不总是与本机调用堆栈匹配,因为即使您未在当前函数中创建自己的句柄作用域,您也始终可以在当前活动句柄作用域中创建句柄。

映射

V8 使用名为 Map 的对象(不要与 JavaScript 的 map 混淆)来表示堆分配的 JavaScript 类型的布局。JSC 有一个类似的概念,称为 Structure。幸运的是,我们不需要担心太多实现细节;只需足以使 GetInstanceType 函数工作即可。从该函数的代码中,我们知道

  • 每个对象的第一个字段都是指向其 Map 的标记指针
  • Map 在偏移量 12 处有一个两字节的整数来表示实例类型

由于 Map 是堆对象,因此每个 Map 的第一个字段也都有一个指向其 Map 的指针。这些指针都指向同一个 Map,即描述 Map 布局的 map。我还没有看到内联 V8 函数依赖于这种情况的场景,但我仍然实现了该字段,因为它并不困难。

总结:V8 内存布局

例如,让我们看看在 Node.js 中运行以下代码后(在 baz 返回之前)内存是什么样子的

void foo(const FunctionCallbackInfo<Value>& info) {
    Isolate* isolate = info.GetIsolate();
    HandleScope foo_scope(isolate);
    Local<String> foo_string = String::NewFromUtf8(isolate, "hello").ToLocalChecked();
    bar(isolate);
}

void bar(Isolate* isolate) {
    Local<Number> bar_number = Number::New(isolate, 2.0);
    baz(isolate);
}

void baz(Isolate* isolate) {
    HandleScope baz_scope(isolate);
    Local<Number> baz_number = Number::New(isolate, 3.5);
}

Diagram of memory layout in the previous C++ program. Each function's local variables hold the addresses of handles in a handle scope. The handle for foo_string contains a strong pointer to a string on the heap. The handle for bar_number contains an integer. The handle for baz_number contains a strong pointer to a heap number. Each heap object starts with a pointer to its map, one for each type. Each map has a pointer to the map used by maps, which points to itself.

请注意,句柄作用域中存储的所有内容都使用了上一节讨论的标记指针表示,这就是为什么堆对象的内存地址存储为奇数。foo_stringbaz_number 的句柄的最低 2 位设置为 01,以指示它们是强指针。但是要获得它们引用的实际地址,我们必须将这些位更改为零,从而得到在 "@." 之后写入的对象实际位置。

在“堆”下,我们可以看到每个对象指向哪个 Map。这些指向 Map 的指针当然是标记指针,因此它们也设置了最低有效位。字符串和堆数字当然在 map 指针之后还有更多字段,但这些字段(幸运的是)不是我们必须匹配的布局的一部分,因此此处未显示。

在我们能够使用 V8 API 做任何有用的事情之前,我们需要找到一种方法,使 JSC 的类型以 V8 期望的方式适应此图表。我们还需要确保 JSC 的垃圾回收器可以找到原生插件创建的所有 V8 值,以便在仍然可以访问它们时不会删除它们。最后,我们将需要为 JSC 没有精确公开的 V8 概念实现许多额外的类型。

接下来

在本系列的下一部分中,我们将研究 Bun 如何操作 JSC 类型以匹配 V8 期望的布局,同时尽量避免不必要的减速或内存浪费。

  • 开始嵌入 V8:从用户的角度概述了许多 V8 API,并提供了一些内部细节
  • V8 中的指针压缩:解释了 V8 中有和没有指针压缩(即使在 64 位系统上,标记指针也仅占用 32 位)的值表示,以及压缩和解压缩指针的优化例程的实现细节。Node.js 尚未启用指针压缩,但 Chromium 和 Electron 已启用。
  • tagged.h:V8 指针标记系统的实现
  • V8 绑定的设计:从 V8 在 Chromium 中的使用的角度描述了诸如 Isolates 和 Contexts 等概念之间的差异
  • JSCJSValue.h:详细介绍了 JSC 基本值类型的 32 位和 64 位编码(仅后者与 Bun 相关)
  • Crafting Interpreters 关于 NaN boxing:更详细地论证和解释了 JSC 使用的一种紧凑表示不同类型值的方法
  • javascript 实现中的值表示:调查了生产 JavaScript 解释器使用的不同值表示及其权衡