banner
raye~

Raye's Journey

且趁闲身未老,尽放我、些子疏狂。
medium
tg_channel
twitter
github
email
nintendo switch
playstation
steam_profiles

從一個問題引發的JS內存探索

標題其實不太恰當,先這樣吧

Group 1 (1)

從一個內存規律說起#

起因是在研究微信小遊戲的時候,發現網上一直流傳著一個規律,即遊戲內數值 * 2 = 內存數值

比如: CE 遊戲內存修改 - 愛神花園 

DraggedImage

這個兩倍確實有點令人費解,按照我對編譯原理的理解,代碼最後變為匯編代碼的過程中,雖然會經過常量折疊等操作,但代碼中數值是多少,實際內存中的數值也就是那個,比如我們通過 CE 來修改植物大戰殭屍,代碼中並未對陽光數值做加密,因此直接搜索數值就可以定位到:
 [re 入門] ce 對植物大戰殭屍的修改 

如果是遊戲做了數值的變化還好說,但是實際遊戲代碼裡是並未去做這類操作的(要做這個操作也不至於簡單地做個 2 倍乘法吧🤔)

由此作為因子,開始研究 JS 的內存問題

v8 引擎#

本質上微信小程序代碼也是跑在 v8 引擎上的,於是開始好奇 JS 代碼是怎麼一步步變為匯編代碼的

我以為 node.js 就是 v8,畢竟 node.js 也是基於 v8 移植的嘛,但發現兩者還是有區別的。v8 本身是提供了一個 shell 的,這個才是最原汁原味的

本地可以編譯一個 v8 shell

步驟參考:https://gist.github.com/Becavalier/58da63744f255abe5717e23dacc673e5

# 下載二進制工具
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH=/path/depot_tools:$PATH
source ~/.bash_profile

# 新建一個想要安裝v8的目錄
gclient

fetch v8
cd v8
gclient sync
tools/dev/v8gen.py x64.optdebug
ninja -C out.gn/x64.optdebug 

# 最後獲得d8的安裝路徑
/path/chromev8/v8/out.gn/x64.optdebug/d8

v8 是怎麼編譯執行 JS 代碼的?#

基本原理還是那套,詞法解析 -> 語法解析 -> 中間代碼生成等

不過 v8 還做了很多優化,即 ignitionturbofan ,但這些都不在本次討論範圍內

DraggedImage-1

從圖中可以看到,最後的結果就是 bytecode,那麼這個 bytecode 長什麼樣子?

寫一段代碼做測試:

function test( obj ) {
  return obj.money + 10;
}

class UserData {
  constructor( money, power ) {
    this.money = money;
    this.power = power;
  }
}
u = new UserData(50, 100);
console.log(u.money, u.power);
for(let i = 0; i < 100000000; i++){
  test( u );
}

打印字節碼

d8  -print-bytecode test1.js 

我這裡就把完整的字節碼放上來了:

[generated bytecode for function:  (0x35e90011ae01 <SharedFunctionInfo>)]
Bytecode length: 131
Parameter count 1
Register count 7
Frame size 56
         0x35e90011afec @    0 : 13 00             LdaConstant [0]
         0x35e90011afee @    2 : c3                Star2
         0x35e90011afef @    3 : 19 fe f7          Mov <closure>, r3
         0x35e90011aff2 @    6 : 65 68 01 f8 02    CallRuntime [DeclareGlobals], r2-r3
         0x35e90011aff7 @   11 : 82 01             CreateBlockContext [1]
         0x35e90011aff9 @   13 : 1a f8             PushContext r2
         0x35e90011affb @   15 : 10                LdaTheHole
         0x35e90011affc @   16 : bf                Star6
         0x35e90011affd @   17 : 81 03 00 00       CreateClosure [3], [0], #0
         0x35e90011b001 @   21 : c2                Star3
         0x35e90011b002 @   22 : 13 02             LdaConstant [2]
         0x35e90011b004 @   24 : c1                Star4
         0x35e90011b005 @   25 : 19 f7 f5          Mov r3, r5
         0x35e90011b008 @   28 : 65 2a 00 f6 03    CallRuntime [DefineClass], r4-r6
         0x35e90011b00d @   33 : 1b f8             PopContext r2
         0x35e90011b00f @   35 : 0b f5             Ldar r5
         0x35e90011b011 @   37 : 25 02             StaCurrentContextSlot [2]
         0x35e90011b013 @   39 : 16 02             LdaCurrentContextSlot [2]
         0x35e90011b015 @   41 : c3                Star2
         0x35e90011b016 @   42 : 0d 32             LdaSmi [50]
         0x35e90011b018 @   44 : c2                Star3
         0x35e90011b019 @   45 : 0d 64             LdaSmi [100]
         0x35e90011b01b @   47 : c1                Star4
         0x35e90011b01c @   48 : 0b f8             Ldar r2
         0x35e90011b01e @   50 : 69 f8 f7 02 00    Construct r2, r3-r4, [0]
         0x35e90011b023 @   55 : 23 04 02          StaGlobal [4], [2]
         0x35e90011b026 @   58 : 21 05 04          LdaGlobal [5], [4]
         0x35e90011b029 @   61 : c2                Star3
         0x35e90011b02a @   62 : 2d f7 06 06       GetNamedProperty r3, [6], [6]
         0x35e90011b02e @   66 : c3                Star2
         0x35e90011b02f @   67 : 21 04 08          LdaGlobal [4], [8]
         0x35e90011b032 @   70 : c1                Star4
         0x35e90011b033 @   71 : 2d f6 07 0a       GetNamedProperty r4, [7], [10]
         0x35e90011b037 @   75 : c1                Star4
         0x35e90011b038 @   76 : 21 04 08          LdaGlobal [4], [8]
         0x35e90011b03b @   79 : c0                Star5
         0x35e90011b03c @   80 : 2d f5 08 0c       GetNamedProperty r5, [8], [12]
         0x35e90011b040 @   84 : c0                Star5
         0x35e90011b041 @   85 : 5f f8 f7 f6 f5 0e CallProperty2 r2, r3, r4, r5, [14]
         0x35e90011b047 @   91 : 0c                LdaZero
         0x35e90011b048 @   92 : c4                Star1
         0x35e90011b049 @   93 : 0e                LdaUndefined
         0x35e90011b04a @   94 : c5                Star0
         0x35e90011b04b @   95 : 01 0d 00 e1 f5 05 LdaSmi.ExtraWide [100000000]
         0x35e90011b051 @  101 : 6d f9 10          TestLessThan r1, [16]
         0x35e90011b054 @  104 : 9a 18             JumpIfFalse [24] (0x35e90011b06c @ 128)
         0x35e90011b056 @  106 : 21 09 11          LdaGlobal [9], [17]
         0x35e90011b059 @  109 : c3                Star2
         0x35e90011b05a @  110 : 21 04 08          LdaGlobal [4], [8]
         0x35e90011b05d @  113 : c2                Star3
         0x35e90011b05e @  114 : 62 f8 f7 13       CallUndefinedReceiver1 r2, r3, [19]
         0x35e90011b062 @  118 : c5                Star0
         0x35e90011b063 @  119 : 0b f9             Ldar r1
         0x35e90011b065 @  121 : 50 15             Inc [21]
         0x35e90011b067 @  123 : c4                Star1
         0x35e90011b068 @  124 : 8a 1d 00 16       JumpLoop [29], [0], [22] (0x35e90011b04b @ 95)
         0x35e90011b06c @  128 : 0b fa             Ldar r0
         0x35e90011b06e @  130 : aa                Return
Constant pool (size = 10)
0x35e90011af9d: [FixedArray] in OldSpace
 - map: 0x35e900000089 <Map(FIXED_ARRAY_TYPE)>
 - length: 10
           0: 0x35e90011ae71 <FixedArray[2]>
           1: 0x35e90011ae55 <ScopeInfo CLASS_SCOPE>
           2: 0x35e90011af79 <FixedArray[7]>
           3: 0x35e90011aebd <SharedFunctionInfo UserData>
           4: 0x35e900002b85 <String[1]: #u>
           5: 0x35e9000044a9 <String[7]: #console>
           6: 0x35e900311cad <String[3]: #log>
           7: 0x35e90011adc5 <String[5]: #money>
           8: 0x35e90011add9 <String[5]: #power>
           9: 0x35e900310aed <String[4]: #test>
Handler Table (size = 0)
Source Position Table (size = 0)
[generated bytecode for function: UserData (0x35e90011aebd <SharedFunctionInfo UserData>)]
Bytecode length: 14
Parameter count 3
Register count 0
Frame size 0
         0x35e90011b1f8 @    0 : 0b 03             Ldar a0
         0x35e90011b1fa @    2 : 32 02 00 00       SetNamedProperty <this>, [0], [0]
         0x35e90011b1fe @    6 : 0b 04             Ldar a1
         0x35e90011b200 @    8 : 32 02 01 02       SetNamedProperty <this>, [1], [2]
         0x35e90011b204 @   12 : 0e                LdaUndefined
         0x35e90011b205 @   13 : aa                Return
Constant pool (size = 2)
0x35e90011b1c9: [FixedArray] in OldSpace
 - map: 0x35e900000089 <Map(FIXED_ARRAY_TYPE)>
 - length: 2
           0: 0x35e90011adc5 <String[5]: #money>
           1: 0x35e90011add9 <String[5]: #power>
Handler Table (size = 0)
Source Position Table (size = 0)
50 100
[generated bytecode for function: test (0x35e90011ae81 <SharedFunctionInfo test>)]
Bytecode length: 8
Parameter count 2
Register count 0
Frame size 0
         0x35e90011b374 @    0 : 2d 03 00 01       GetNamedProperty a0, [0], [1]
         0x35e90011b378 @    4 : 44 0a 00          AddSmi [10], [0]
         0x35e90011b37b @    7 : aa                Return
Constant pool (size = 1)
0x35e90011b349: [FixedArray] in OldSpace
 - map: 0x35e900000089 <Map(FIXED_ARRAY_TYPE)>
 - length: 1
           0: 0x35e90011adc5 <String[5]: #money>
Handler Table (size = 0)
Source Position Table (size = 0)

對於初次接觸這個字節碼的人來說,肯定是要花點時間才能理解清楚的,所以我們直接丟給chatGPT,看看它能不能復原出原來的代碼:

> 以下回答出自GPT4

這段字節碼表示了一段更複雜的JavaScript代碼。讓我們一步一步地分析它。

### 主函数字节码

#### 常量池:

1. 其他固定數組和元數據
2. ScopeInfo for Class (類的範圍信息)
3. 其他固定數組和元數據
4. `UserData` 的共享函數信息
5. 字符串 `u`
6. 字符串 `console`
7. 字符串 `log`
8. 字符串 `money`
9. 字符串 `power`
10. 字符串 `test`

#### 分析:

1. 定義一個類`UserData`,它有兩個屬性:`money``power`
2. 創建一個`UserData`實例,傳入參數 `50` `100` 作为 `money` `power` 的初始化值。
3. 將這個實例賦值給變量 `u`
4. 調用 `console.log(u.money, u.power)`
5. 創建一個循環,循環次數為`100000000`,在循環內部調用函數 `test`,使用 `u` 作為參數。

### UserData構造函数字节码

#### 分析:

1. 設置`this.money`為傳入的第一個參數。
2. 設置`this.power`為傳入的第二個參數。

### test函数字节码

#### 分析:

1. 從傳入的對象中獲取 `money` 屬性。
2. 將獲取到的值加10。
3. 返回這個結果。

### 復原後的JavaScript代碼:

```javascript
class UserData {
    constructor(money, power) {
        this.money = money;
        this.power = power;
    }
}

let u = new UserData(50, 100);
console.log(u.money, u.power);

function test(obj) {
    return obj.money + 10;
}

for (let i = 0; i < 100000000; i++) {
    test(u);
}

這就是根據給定的字節碼復原的 JavaScript 代碼。

GPT4 也太牛了🥹,完美取代我吧

整體的字節碼還是很好理解的,多看幾遍,結合對應的指令格式不難分析出來。

並且可以看到這兩條指令,這就很類似匯編指令裡去 mov 一個數值,所以這個數值一定是原封不動地保留在內存數據裡

         0x35e90011b016 @   42 : 0d 32             LdaSmi [50]

         0x35e90011b019 @   45 : 0d 64             LdaSmi [100]

因為 v8 運行時並沒有 setInterval (也很神奇了),於是直接在 d8 的 shell 裡做這個實驗:

可以很清晰地定位到,內存數值就是實際運行時數值的兩倍(2474 = 1237 * 2)

DraggedImage-2

修改內存,此時的數值就直接發生變化了

DraggedImage-3

對比 node.js#

既然如此,那就來看看 node.js

自定義的類#

同樣的代碼,在 node.js 的 shell 中運行,實例化一個對象 user,可以看到我設置了 user.money 為一個很奇怪的數值,目的是保證內存中不會出現重複的數值(雖然還是重複了三個)
那就將這三個數值都修改了,效果也很明顯,修改完之後,user.money 的數值就變化了

那也不存在什麼兩倍關係啊?🤔 這不是很正常的,定位到數值後直接修改就好了

DraggedImage-4

DraggedImage-5

內置的類#

自定義的類,對應的數值是可以直接搜索到的,那麼 node.js 提供的類會不會不一樣呢,找個 Map 做下測試

class UserData {
    constructor(money, power) {
        this.test_map =new Map();
        this.test_map.set('money', money);
        this.test_map.set('power', power);
    }
}

通過 CE 還是可以定位到:

DraggedImage-6

並且也能修改:

DraggedImage-7

對比瀏覽器#

後知後覺,才意識到 chrome 的開發者工具中有一個可以拍攝內存快照的工具,主要是用來排查內存泄漏原因的,不過對於內存佈局的研究也很方便

chrome Memory 面板#

我們可以編寫一段示例代碼:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    
</body>
<script>
var _____testArray_____ = [ {value: 'hello'}, {money: 9999} ];
console.info(_____testArray_____);
</script>
</html>

拍攝內存快照,點擊獲取快照即可拍攝下當前的快照

DraggedImage-8

在當前頁面直接按下 cmd + F (對應 Windows 應該是 ctfl+ F ,可以在底部打開搜索窗口

因為我們的代碼中出現了 hello 字符串,因此直接搜索之,搜到之後,右鍵在摘要視圖中顯示,就可以定位到對應的構造函數了

DraggedImage-9

從內存結構上看,

即:

  • hello 字符串 @17037(可以理解為一段內存的標識符)
    • 存儲在 Object@92971 (對應 {value: ;'hello'}
      • Obj 又作為數組,存儲在 Array@92961(對應 [ {value: 'hello'} ])
        • 數組存儲在 testArray這個變量中,由於是全局變量,因此對應到 Window@92867

這也對應了通過 var 聲明的變量是直接掛載在 Window 對象的屬性上

什麼是 smi?#

如果你足夠眼尖,應該就能發現,數字 9999 被標記上了 smi number

DraggedImage-10

以下是 GPT4 給出的回答:
在 JavaScript 的 V8 引擎(主要用於 Google Chrome 和 Node.js)中,SMI(Small Integer)是一種特殊的整數表示方式。這種表示方法主要用於性能優化。SMI 是一種 “標記” 整數類型,其中一部分位用於存儲實際的整數值,而其他位則用於標記這個數字為 SMI 類型。
具體的實現可能依賴於具體的平台和環境,但在一個典型的 64 位系統上,一個 SMI 可能會使用 31 位來表示整數值,而剩下的位則用於其他目的,如垃圾收集標記、類型信息等。因為使用了這些額外的位,所以 SMI 能表示的整數範圍會比通常的 32 位或 64 位整數要小。

從 GPT 給出的回答來看,一個 SMI 會使用 31 位來表示整數值,剩餘的 1 位則用於其他目的,那不正好相當於是數據左移了一位!也就是文章開頭提到的內存規律

但還遺留了一個問題,即 node.js 為什麼沒有用 SMI?至少從上述測試來看,node.js 就是直接存的。

GPT 對此也沒給出很好的回答,只能猜測 node.js 背後對 v8 做了一些魔改吧🤣

參考#

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。