逆工程 JS 对象 (一): 浅谈 V8 对象布局

本文首发于 Github,仓库地址: https://github.com/hyj1991/sourcecode_notes,里面也有更多和本文的相关其它内容,欢迎关注。

逆工程 JS 对象 (一): 浅谈 V8 对象布局

逆工程 JS 对象其实就是根据 V8 设计的对 JS 对象存储结构的描述,开发者可以实现在进程运行内存空间中或者进程崩溃后的 Core 文件还原的内存空间中来反推出当前 JS 代码运行状态和 JS 对象在堆空间的分配状况的一种技术。

这种技术可以帮助开发者分析运行中的 JS 程序,或者根据 JS 程序崩溃生成的 Core 文件来进行事后的崩溃原因分析,本文主要从 V8 对象布局的角度来和大家探讨学习下逆工程背后的一些知识。

I. 伪指针 (Tagged Pointer)

我们先来介绍认识一种特殊类型的指针: Tagged Pointer,这里虽然说是特殊,其实从存储或者占用空间大小来说并没有什么特别的地方:

  • 32 位操作系统: 4 byte 32 bit
  • 64 位操作系统: 8 byte 64 bit

我们可以看到,如果程序被设计为按照 4 byte 或者 8 byte 地址对齐来实现最佳的运行效率,那么指针的最后 2 位 (32 位操作系统) 或者 3 位 (64 位操作系统) 下一定都是 0,这样某种程度上对于昂贵的内存空间来说是一种浪费。

Tagged Pointer 就是利用指针最后相同的几位来实现将传统指针区分为不同数据类型的一种历史悠久的实现,具体到 V8 引擎中,它将最后一位 (bit) 通过 0 或者 1 来区分要将当前的指针解析为小整形 (Smi) 或者一个常规的指针:

  • 最后一位为 0: 小整形 (Smi)
  • 最后一位为 1: 指向堆对象的常规指针 (需要转换)

实际上 V8 中对 Smi Tag 的描述在 include/v8-internal.h 中:

// Tag information for Smi.
const int kSmiTag = 0;
const int kSmiTagSize = 1;
const intptr_t kSmiTagMask = (1 << kSmiTagSize) - 1;

这里和上文的描述是吻合的。

其实引擎之所以这样处理,是因为在 ECMA 规范中,JS 中所有的 Number 类型数据都是被描述要基于 IEEE-754 双精度浮点型,而我们都知道,CPU 操作浮点数的效率远低于整数,而开发者对于整数的使用又是一个非常普遍的需求: 比如循环计数控制或者数组下标索引等,因此为了程序执行效率的提升引擎需要将 一定范围 内的整数直接将原始指针进行转换后读取。

II. 小整形 (Small Integer)

上面一小节中其实也提到,Tagged Pointer 只能用来描述 一定范围 内的整数大小,那么这个范围具体是多大呢,我们可以继续来从源码中找到答案。

include/v8-internal.h 中,首先通过模板定义了 32 位操作系统下的 Smi Tag 信息:

// Smi constants for systems where tagged pointer is a 32-bit value.
template <>
struct SmiTagging<4> {
  enum { kSmiShiftSize = 0, kSmiValueSize = 31 };

  static constexpr intptr_t kSmiMinValue =
      static_cast<intptr_t>(kUintptrAllBitsSet << (kSmiValueSize - 1));
  static constexpr intptr_t kSmiMaxValue = -(kSmiMinValue + 1);
};

这里很显然,32 位操作系统下的 Tagged Pointer,去掉最后一位的标记位,因此用来表示 Smi 值只有 32 位,所以 kSmiValueSize 为 31。

下面的部分则是计算此时能表示的最大整数: 2^(31 - 1) - 1 = 1073741823 (有符号),所以 32 位下 Smi 的表示范围为: -1073741823 ~ 1073741823

接着看下64 位操作系统下的 Smi Tag 信息:

// Smi constants for systems where tagged pointer is a 64-bit value.
template <>
struct SmiTagging<8> {
  enum { kSmiShiftSize = 31, kSmiValueSize = 32 };
};

同样的部分就补贴了,这里可以看到 Smi 的范围增加了一位,所以表示的范围也增加到: -2147483647 ~ 2147483647

当然有意思的是 V8 引擎为了某些场景下需要跨平台程序完全一致性也提供了一个宏 V8_31BIT_SMIS_ON_64BIT_ARCH:

#ifdef V8_31BIT_SMIS_ON_64BIT_ARCH
using PlatformSmiTagging = SmiTagging<kApiInt32Size>;
#else
using PlatformSmiTagging = SmiTagging<kApiTaggedSize>;
#endif

如果定义了这个宏的话,则在 64 位操作系统下会使用和 32 位操作系统下范围完全一致的 Smi。

有了上面的知识,还原给定的一个内存空间地址 p 对应的 Smi 值就很简单了,首先根据掩码判断 p 是否为 Smi:

bool Smi::Check(int64_t p) const {
  return (p & kSmiTagMask) == kSmiTag;
}

如果是 Smi,则将地址右移 kSmiShiftSize + kSmiTagMask 位得到记录的原始 int 值:

int64_t Smi::GetValue(int64_t p) const {
  return p >> (kSmiShiftSize + kSmiTagMask);
}

这样就完成了将一个实际保存了 Smi 的 Tagged Pointer 逆工程为其原始保存的 int 值的过程。

III. 堆对象 (Heap Object)

上一小节中实现的 Smi::Check 方法可以对给定的地址 p 来判断其最终存储 / 指向的是 Smi 还是一个 V8 对象,kSmiTag 目前在引擎中值为 0,显然当 p 的最后一位为 1Check 方法返回 false,这里就意味着所有的值为奇数的地址实际都指向一个 V8 对象,我们也可以称之为 Heap Object

如果开发同学曾经因为 JS 的内存问题在 Node.js 或者浏览器中导出过堆快照,并且在 Chrome devtools 中解析,那么就会发现所有的以 @ 符号开始对象地址都是奇数,这里也从侧面印证了这一论述。

对于这个给定的奇数地址 p,我们要将其逆工程回真正指向的 Heap Object 会复杂一些,我们来看下如何处理。

V8 基于自己的规则实现了一套对象布局方式,用 OOP 的方式来描述,所有的 JS 对象类型布局全部都继承自 Heap Object 的布局方式,其实这个也很好理解,毕竟所有 JS 对象本身就是从 Heap Object 派生出来的。

我们先来看下引擎对 Heap Object 的布局描述:

// src/objects/heap-object.h
// Layout description.
#define HEAP_OBJECT_FIELDS(V) \
  V(kMapOffset, kTaggedSize)  \
  /* Header size. */          \
  V(kHeaderSize, 0)

  DEFINE_FIELD_OFFSET_CONSTANTS(Object::kHeaderSize, HEAP_OBJECT_FIELDS)
#undef HEAP_OBJECT_FIELDS

其中 DEFINE_FIELD_OFFSET_CONSTANTS 宏是一个用来定义枚举类型的宏,其定义为:

// src/utils/utils.h
#define DEFINE_FIELD_OFFSET_CONSTANTS(StartOffset, LIST_MACRO) \
  enum {                                                       \
    LIST_MACRO##_StartOffset = StartOffset - 1,                \
    LIST_MACRO(DEFINE_ONE_FIELD_OFFSET)                        \
  };

里面的 DEFINE_ONE_FIELD_OFFSET 宏定义为:

// src/utils/utils.h
#define DEFINE_ONE_FIELD_OFFSET(Name, Size) Name, Name##End = Name + (Size)-1,

宏嵌套宏看起来比较复杂,但是其实它们的本质还是减少重复代码的编写,这里我们直接将 Heap Object 的布局定义宏全部展开:

enum {
  HEAP_OBJECT_FIELDS_StartOffset = Object::kHeaderSize - 1,
  kMapOffset,
  kMapOffsetEnd = kMapOffset + (kTaggedSize)-1,
  kHeaderSize,
  kHeaderSizeEnd = kHeaderSize + (0) - 1,
};

这样就很清晰了,可以看到宏展开后就是一个描述布局方式的枚举,这里 Heap Object 的布局接在 Object 之后,它的核心只定义了一个指向 Map 的伪指针。

伪指针定义可以参见 Tagged Pointer,它的大小和系统的指针大小完全一致,32 位系统上为 4 byte,64 位系统上为 8 byte。

:::warning MetaMap
这里的 Map 和我们在 JS 代码中使用的 Map 完全不是一个东西,它其实是包含描述指向它的 Heap Object 的结构信息的特殊 Heap Object,即经常被提到的 Hidden Class
:::

IV. 元信息 (Meta Map)

既然所有的 JS 对象都继承自上一小节中提到的 Heap Object,那也意味着这些 JS 对象在 V8 引擎层面的存储对象必然会保存一个伪指针来指向用来描述这个 JS 对象结构的 Meta Map

Layout

我们来看 V8 引擎对这样的 Meta Map 的结构描述:

// Map layout:
// +---------------+---------------------------------------------+
// |   _ Type _    | _ Description _                             |
// +---------------+---------------------------------------------+
// | TaggedPointer | map - Always a pointer to the MetaMap root  |
// +---------------+---------------------------------------------+
// | Int           | The first int field                         |
//  `---+----------+---------------------------------------------+
//      | Byte     | [instance_size]                             |
//      +----------+---------------------------------------------+
//      | Byte     | If Map for a primitive type:                |
//      |          |   native context index for constructor fn   |
//      |          | If Map for an Object type:                  |
//      |          |   inobject properties start offset in words |
//      +----------+---------------------------------------------+
//      | Byte     | [used_or_unused_instance_size_in_words]     |
//      |          | For JSObject in fast mode this byte encodes |
//      |          | the size of the object that includes only   |
//      |          | the used property fields or the slack size  |
//      |          | in properties backing store.                |
//      +----------+---------------------------------------------+
//      | Byte     | [visitor_id]                                |
// +----+----------+---------------------------------------------+
// | Int           | The second int field                        |
//  `---+----------+---------------------------------------------+
//      | Short    | [instance_type]                             |
//      +----------+---------------------------------------------+
//      | Byte     | [bit_field]                                 |
//      |          |   - has_non_instance_prototype (bit 0)      |
//      |          |   - is_callable (bit 1)                     |
//      |          |   - has_named_interceptor (bit 2)           |
//      |          |   - has_indexed_interceptor (bit 3)         |
//      |          |   - is_undetectable (bit 4)                 |
//      |          |   - is_access_check_needed (bit 5)          |
//      |          |   - is_constructor (bit 6)                  |
//      |          |   - has_prototype_slot (bit 7)              |
//      +----------+---------------------------------------------+
//      | Byte     | [bit_field2]                                |
//      |          |   - new_target_is_base (bit 0)              |
//      |          |   - is_immutable_proto (bit 1)              |
//      |          |   - unused bit (bit 2)                      |
//      |          |   - elements_kind (bits 3..7)               |
// +----+----------+---------------------------------------------+
// | Int           | [bit_field3]                                |
// |               |   - enum_length (bit 0..9)                  |
// |               |   - number_of_own_descriptors (bit 10..19)  |
// |               |   - is_prototype_map (bit 20)               |
// |               |   - is_dictionary_map (bit 21)              |
// |               |   - owns_descriptors (bit 22)               |
// |               |   - is_in_retained_map_list (bit 23)        |
// |               |   - is_deprecated (bit 24)                  |
// |               |   - is_unstable (bit 25)                    |
// |               |   - is_migration_target (bit 26)            |
// |               |   - is_extensible (bit 28)                  |
// |               |   - may_have_interesting_symbols (bit 28)   |
// |               |   - construction_counter (bit 29..31)       |
// |               |                                             |
// +*************************************************************+
// | Int           | On systems with 64bit pointer types, there  |
// |               | is an unused 32bits after bit_field3        |
// +*************************************************************+
// | TaggedPointer | [prototype]                                 |
// +---------------+---------------------------------------------+
// | TaggedPointer | [constructor_or_backpointer]                |
// +---------------+---------------------------------------------+
// | TaggedPointer | [instance_descriptors]                      |
// +*************************************************************+
// ! TaggedPointer ! [layout_descriptors]                        !
// !               ! Field is only present if compile-time flag  !
// !               ! FLAG_unbox_double_fields is enabled         !
// !               ! (basically on 64 bit architectures)         !
// +*************************************************************+
// | TaggedPointer | [dependent_code]                            |
// +---------------+---------------------------------------------+
// | TaggedPointer | [prototype_validity_cell]                   |
// +---------------+---------------------------------------------+
// | TaggedPointer | If Map is a prototype map:                  |
// |               |   [prototype_info]                          |
// |               | Else:                                       |
// |               |   [raw_transitions]                         |
// +---------------+---------------------------------------------+

这个 Meta Map 本身也是继承自 Heap Object,所以它的堆地址起始也是一个伪指针,显而易见的是这个 Tagged Pointer 不会再指向另一个用来描述自己的 Meta Map 了 (禁止套娃)。

Instance size

跟在这个 Tagged Pointer 后面的 4 个 byte (即上图中的第一个 Int) 的使用我们来看下其对应的作用:

//  +----------+---------------------------------------------+
//  | Byte     | [instance_size]                             |
//  +----------+---------------------------------------------+
//  | Byte     | If Map for a primitive type:                |
//  |          |   native context index for constructor fn   |
//  |          | If Map for an Object type:                  |
//  |          |   inobject properties start offset in words |
//  +----------+---------------------------------------------+
//  | Byte     | [used_or_unused_instance_size_in_words]     |
//  |          | For JSObject in fast mode this byte encodes |
//  |          | the size of the object that includes only   |
//  |          | the used property fields or the slack size  |
//  |          | in properties backing store.                |
//  +----------+---------------------------------------------+
//  | Byte     | [visitor_id]                                |
//  +----------+---------------------------------------------+

这里有一个比较重要的值是 instance_size,它记录了指向这个 Meta MapHeap Object 的大小,换言之从 Meta Map 的首地址偏移 8 Byte 得到的新指针指向堆空间保存了原始 Heap Object 的实际占据 V8 堆空间的大小。

这样说可能还是比较抽象,所以我们来看下 Heap Object Size 是如何被设置的,可以看到在 Map 类中定义了一个 set_instance_size_in_words 的成员方法:

// src/objects/map-inl.h
void Map::set_instance_size_in_words(int value) {
  RELAXED_WRITE_BYTE_FIELD(*this, kInstanceSizeInWordsOffset,
                           static_cast<byte>(value));
}

将宏 RELAXED_WRITE_BYTE_FIELD 展开最终得到:

// src/objects/map-inl.h
void Map::set_instance_size_in_words(int value) {
  base::Relaxed_Store(
      reinterpret_cast<base::Atomic8*>(
          ((*this).ptr() + kInstanceSizeInWordsOffset - kHeapObjectTag)),
      static_cast<base::Atomic8>(static_cast<byte>(value)));
}

其中 base::Relaxed_Store 的定义如下:

// src/base/atomicops_internals_portable.h
inline void Relaxed_Store(volatile Atomic8* ptr, Atomic8 value) {
  __atomic_store_n(ptr, value, __ATOMIC_RELAXED);
}

__atomic_store_n 是 gcc 定义的多线程下比信号量锁性能更好的原子存储操作操作,引用下 gcc 官方文档 里的说法:

This built-in function implements an atomic store operation. It writes val into *ptr.

其实就是把 value 写入 *ptr 指向的堆空间,__ATOMIC_RELAXED 表示不会对执行写入操作的线程设定优先级:

__ATOMIC_RELAXED: Implies no inter-thread ordering constraints.

回到 set_instance_size_in_words 展开后的方法,它的作用描述下就是:

  • Meta Map 自己的伪指针(首地址
  • 加上 kInstanceSizeInWordsOffset 偏移量(这里是 8byte)
  • 转换成真实地址(伪指针最后一位伪 1)
  • 将 instance size 写入上面得到的真实地址指向的堆空间内

值得注意的是,指针的加减运算和指针类型有关,所以上面代码中计算 instance 偏移量的时候首先将 Meta Map 的指针强转为 base::Atomic8 类型的指针,而 Atomic8 的实际上就是 char 的别名:

// src/base/atomicops.h
using Atomic8 = char;

Instance type

因为每一个不同的 JS 对象类型布局除了头部的指向 Meta Map 的伪指针外都不一样,所以获取原始的 Heap Object 的类型就比较重要。

这部分信息保存在第二个 Int (4 byte) 长度的区域里:

//  +----------+---------------------------------------------+
//  | Short    | [instance_type]                             |
//  +----------+---------------------------------------------+
//  | Byte     | [bit_field]                                 |
//  |          |   - has_non_instance_prototype (bit 0)      |
//  |          |   - is_callable (bit 1)                     |
//  |          |   - has_named_interceptor (bit 2)           |
//  |          |   - has_indexed_interceptor (bit 3)         |
//  |          |   - is_undetectable (bit 4)                 |
//  |          |   - is_access_check_needed (bit 5)          |
//  |          |   - is_constructor (bit 6)                  |
//  |          |   - has_prototype_slot (bit 7)              |
//  +----------+---------------------------------------------+
//  | Byte     | [bit_field2]                                |
//  |          |   - new_target_is_base (bit 0)              |
//  |          |   - is_immutable_proto (bit 1)              |
//  |          |   - unused bit (bit 2)                      |
//  |          |   - elements_kind (bits 3..7)               |
//  +----------+---------------------------------------------+

可以看到这里用了一个 short (2 byte) 的长度来保存原始 Heap Object 的类型信息,换言之从 Meta Map 的首地址偏移 12 Byte 得到新地址转换为 uint16_t 类型的地址后指向的堆空间得到的无符号整数即标识了原始 Heap Object 的类型。

这里还是通过原始 Heap Object 的类型是如何被设置的来理解下这部分内容,Map 类中同样定义了一个 set_instance_type 的成员方法:

void Map::set_instance_type(InstanceType value) {
  WriteField<uint16_t>(kInstanceTypeOffset, value);
}

InstanceType 是一个枚举类型,它定义了完整的 Heap Object 可能的类型:

// src/objects/instance-type.h
enum InstanceType : uint16_t {
  // ....
};

可以看到 InstanceType 继承自 uint16_t,正好是 2 byte,也和 Meta Map 中分配给实例类型一个 short 的长度来表示相吻合。

接着我们看下 WriteField 函数究竟做了些什么:

template <class T, typename std::enable_if<std::is_arithmetic<T>::value,
                                             int>::type = 0>
inline void WriteField(size_t offset, T value) const {
  // Pointer compression causes types larger than kTaggedSize to be unaligned.
#ifdef V8_COMPRESS_POINTERS
  constexpr bool v8_pointer_compression_unaligned = sizeof(T) > kTaggedSize;
#else
  constexpr bool v8_pointer_compression_unaligned = false;
#endif
  if (std::is_same<T, double>::value || v8_pointer_compression_unaligned) {
    // Bug(v8:8875) Double fields may be unaligned.
    base::WriteUnalignedValue<T>(field_address(offset), value);
  } else {
    base::Memory<T>(field_address(offset)) = value;
  }
}

展开 field_address 得到:

inline Address field_address(size_t offset) const {
  return ptr() + offset - kHeapObjectTag;
}

它的作用就是通过指针偏移量来计算出真实的堆空间地址。

最后看下 base::Memory 做了些什么:

template <class T>
inline T& Memory(Address addr) {
  DCHECK(IsAligned(addr, alignof(T)));
  return *reinterpret_cast<T*>(addr);
}

template <class T>
inline T& Memory(byte* addr) {
  return Memory<T>(reinterpret_cast<Address>(addr));
}

有了以上的信息,我们进行下简单转换下可以得到可读性比较好的代码:

inline void WriteField(size_t offset, uint16_t value) const {
  *reinterpret_cast<uint16_t *>(ptr() + offset - kHeapObjectTag) = value
}

这样看就比较明确了,创建 Heap Object 的时候通过调用 set_instance_type 方法确实将一个类型为 uint16_t 的类型值存储到了 Meta Map 起始地址偏移 kInstanceTypeOffset 的堆空间内。

V. 还原 V8 对象

了解了上述的这些内容,我们对于一个给定的地址 p,判断其为堆对象后可以:

  • 根据 kMapOffset 来获取其指向的 Meta Map
  • 根据 Meta MapkInstanceSizeInWordsOffset 获取原始堆对象在 V8 堆上的大小
  • 根据 Meta MapkInstanceTypeOffset 获取原始堆对象类型
  • 根据原始对象类型的布局来还原其各个属性

值得一提的是,按照引擎的定义, Meta Map 的起始伪指针指向一个 Meta Map root (作为 GC root 方便 GC),换言之所有的 JS 对象指向的 Meta Map 都指向了同一个 Meta Map root:

// +---------------+---------------------------------------------+
// | TaggedPointer | map - Always a pointer to the MetaMap root  |
// +---------------+---------------------------------------------+

这一点同样从导出的 HeapSnapshot 堆快照的分析中可以得到证实,这部分内容后面会在解析堆快照的文章中详细说明。

Meta Map 的定义里已经包含了逆工程 V8 对象的几乎绝大部分内容,下面一篇文章中我们会以一个例子来看下如何借助于 Meta Map 还原 JS Object 的原始信息。

VI. 参考资料

上一篇:Node.js 应用故障排查手册 —— 利用 CPU 分析调优吞吐量


下一篇:如何进行 GC 调优提升 Node 应用性能