本文首發于 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
目前在引擎中值為
,顯然當
p
的最後一位為
1
時
Check
方法傳回
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 Map
的
Heap 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
- 加上
偏移量(這裡是 8byte)kInstanceSizeInWordsOffset
- 轉換成真實位址(僞指針最後一位僞 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 Map
擷取原始堆對象在 V8 堆上的大小kInstanceSizeInWordsOffset
-
Meta Map
擷取原始堆對象類型kInstanceTypeOffset
- 根據原始對象類型的布局來還原其各個屬性
值得一提的是,按照引擎的定義,
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 的原始資訊。