Day01:從變數看 bytecode


前言

變數宣告在程式語言裡面是再基本不過的功能,同時也是一個非常常用的功能。因此,從宣告變數以及賦值來看 bytecode,應該是個很不錯的切入點。

在進入到 bytecode 之前,有個相當重要的基礎知識,那就是 V8 有一個叫做 acuumulator 的 register(以下簡稱 acc),有些指令的參數只會有一個,就是代表著把某個值放到 acc,或是從 acc 拿出來。

例如說指令:LdaSmi [1],就是 Load small integer into accumulator 的意思,而後面那個[1]就是要載入的值。所以執行完這一行以後,acc 的值就會變成 1。

而像是 Star r0 這種指令,就代表著把目前 acc 的值存到目的地去。因此 Star r0 就是 r0 = acc 的意思。

這個 acc 的概念相當重要,有了這一個基礎知識以後,才能看懂之後 bytecode 想表達的意思。

宣告變數

先來試試看以下程式碼:

function find_me_test() {
  var a = 1
  let b = 2
  const c = 3
}

find_me_test()

產生出來的 bytecode 如下:

[generated bytecode for function: find_me_test]
Parameter count 1
Frame size 24
   21 E> 0x29da1b21dcba @    0 : a5                StackCheck 
   36 S> 0x29da1b21dcbb @    1 : 0c 01             LdaSmi [1]
         0x29da1b21dcbd @    3 : 26 fb             Star r0
   48 S> 0x29da1b21dcbf @    5 : 0c 02             LdaSmi [2]
         0x29da1b21dcc1 @    7 : 26 fa             Star r1
   62 S> 0x29da1b21dcc3 @    9 : 0c 03             LdaSmi [3]
         0x29da1b21dcc5 @   11 : 26 f9             Star r2
         0x29da1b21dcc7 @   13 : 0d                LdaUndefined 
   64 S> 0x29da1b21dcc8 @   14 : a9                Return 
Constant pool (size = 0)
Handler Table (size = 0)

為了方便觀看,我們可以把前面的資訊都拿掉,只留下程式碼:

StackCheck 
LdaSmi [1]
Star r0
LdaSmi [2]
Star r1
LdaSmi [3]
Star r2
LdaUndefined 
Return

StackCheck 是一定會有的指令,是用來檢查 stack 有沒有 overflow 用的。LdaSmi [1] 前面說過了,就是 acc = 1,然後Star r0會把這個值存到 r0 去,而底下的幾行程式碼也類似。

因此可以推測出 r0 就是變數 a,r1 就是 b,r2 就是 c。

最後面兩行的意思也很簡單,LdaUndefined 其實就是 acc = undefined,然後 Return 沒有接任何參數,因為會直接把 acc 裡面的值傳回去。在 function 裡面如果沒有寫任何 return,預設就會回傳 undefined。

因此,可以把上面那一段 bytecode 翻成白話文,一行就是一個指令:

StackCheck   // check stack
LdaSmi [1]   // acc = 1
Star r0      // r0 = acc
LdaSmi [2]   // acc = 2
Star r1      // r1 = acc
LdaSmi [3]   // acc = 3
Star r2      // r2 = acc
LdaUndefined // acc = undefined
Return       // return acc

基本的語法沒有問題之後,我們可以針對各個關鍵字進一步來研究,在varletconst之中,我們直接來看比較多東西可以研究的const

深入 const

如果 const 沒有給值的話會發生什麼事呢?

function find_me_test() {
  const c
}

find_me_test()

產生出來的結果是:

a.js:2: SyntaxError: Missing initializer in const declaration
  const c
        ^
SyntaxError: Missing initializer in const declaration

因為是 SyntaxError,所以在還沒翻譯成 bytecode 之前就知道有錯誤,因此是不會有 bytecode 的。

那如果換成重複賦值呢?

function find_me_test() {
  const c = 1
  c = 2
}

find_me_test()

結果:

[generated bytecode for function: find_me_test]
Parameter count 1
Frame size 8
   21 E> 0x4f21349dc8a @    0 : a5                StackCheck 
   38 S> 0x4f21349dc8b @    1 : 0c 01             LdaSmi [1]
         0x4f21349dc8d @    3 : 26 fb             Star r0
   42 S> 0x4f21349dc8f @    5 : 0c 02             LdaSmi [2]
   44 E> 0x4f21349dc91 @    7 : 61 41 01 fb 00    CallRuntime [ThrowConstAssignError], r0-r0
         0x4f21349dc96 @   12 : 0d                LdaUndefined 
   48 S> 0x4f21349dc97 @   13 : a9                Return 
Constant pool (size = 0)
Handler Table (size = 0)
a.js:3: TypeError: Assignment to constant variable.
  c = 2
    ^
TypeError: Assignment to constant variable.
    at find_me_test (a.js:3:5)
    at a.js:6:1

因為這是TypeError,是運行時才會產生的錯誤,因此還是會有 bytecode 產生,而產生出來的程式碼多了一行:CallRuntime [ThrowConstAssignError], r0-r0 來拋出錯誤。

接著來看一下 TDZ 的部分,不清楚那是什麼的可以參考:我知道你懂 hoisting,可是你了解到多深?

為了方便對照,先來一個正常不會有錯誤的版本:

function find_me_test() {
  const a = 1
  console.log(a)
}

find_me_test()

產生的 bytecode:

[generated bytecode for function: find_me_test]
Parameter count 1
Frame size 24
   21 E> 0x3fce2f39dcaa @    0 : a5                StackCheck 
   38 S> 0x3fce2f39dcab @    1 : 0c 01             LdaSmi [1]
         0x3fce2f39dcad @    3 : 26 fb             Star r0
   42 S> 0x3fce2f39dcaf @    5 : 13 00 00          LdaGlobal [0], [0]
         0x3fce2f39dcb2 @    8 : 26 f9             Star r2
   50 E> 0x3fce2f39dcb4 @   10 : 28 f9 01 02       LdaNamedProperty r2, [1], [2]
         0x3fce2f39dcb8 @   14 : 26 fa             Star r1
   50 E> 0x3fce2f39dcba @   16 : 59 fa f9 fb 04    CallProperty1 r1, r2, r0, [4]
         0x3fce2f39dcbf @   21 : 0d                LdaUndefined 
   57 S> 0x3fce2f39dcc0 @   22 : a9                Return 
Constant pool (size = 2)
0x3fce2f39dc31: [FixedArray] in OldSpace
 - map: 0x3fce709807b1 <Map>
 - length: 2
           0: 0x3fcee30100e9 <String[#7]: console>
           1: 0x3fcee300fbe9 <String[#3]: log>
Handler Table (size = 0)
1

a 一樣會存在 r0,至於console.log則是這一段:

LdaGlobal [0], [0]
Star r2
LdaNamedProperty r2, [1], [2]
Star r1
CallProperty1 r1, r2, r0, [4]

我們一行一行來解釋:

第一行 LdaGlobal [0], [0],只要發現看不懂的語法,都可以去 src/interpreter/interpreter-generator.cc 找解釋:

// LdaGlobal <name_index> <slot>
//
// Load the global with name in constant pool entry <name_index> into the
// accumulator using FeedBackVector slot <slot> outside of a typeof.
IGNITION_HANDLER(LdaGlobal, InterpreterLoadGlobalAssembler) {
  static const int kNameOperandIndex = 0;
  static const int kSlotOperandIndex = 1;

  LdaGlobal(kSlotOperandIndex, kNameOperandIndex, NOT_INSIDE_TYPEOF);
}

會載入 global 裡的東西進去 acc,至於是什麼東西呢?要看傳進去的第一個參數 <name_index> 並且去 constant pool 裡面找;至於第三個參數 FeedBackVector slot 可以先不管它。

constant pool 裡面 0 的位置是:0: 0x3fcee30100e9 <String[#7]: console>,因此 LdaGlobal [0], [0] 其實就是:acc = global.console 的意思。

接著下一行 Star r2,把 global.console 存進去 r2,再下一行:LdaNamedProperty r2, [1], [2] 一樣可以去查意思:

// LdaNamedProperty <object> <name_index> <slot>
//
// Calls the LoadIC at FeedBackVector slot <slot> for <object> and the name at
// constant pool entry <name_index>.

constant pool 1 的位置為:1: 0x3fcee300fbe9 <String[#3]: log>,所以這一行其實就是:acc = r2.log,也就是 acc = console.log

再來 Star r1 把這個存進 r1,最後呼叫 CallProperty1 r1, r2, r0, [4],這一行其實就是 console.log(r0),最後就把我們的變數 a 給印出來了。

然後我們把 log 跟宣告變數調換一下位置,先 log 再宣告變數:

function find_me_test() {
  console.log(a)
  const a = 1
}

find_me_test()

結果為:

[generated bytecode for function: find_me_test]
Parameter count 1
Frame size 24
         0x2ba6d6e9dcb2 @    0 : 0f                LdaTheHole 
         0x2ba6d6e9dcb3 @    1 : 26 fb             Star r0
   21 E> 0x2ba6d6e9dcb5 @    3 : a5                StackCheck 
   28 S> 0x2ba6d6e9dcb6 @    4 : 13 00 00          LdaGlobal [0], [0]
         0x2ba6d6e9dcb9 @    7 : 26 f9             Star r2
   36 E> 0x2ba6d6e9dcbb @    9 : 28 f9 01 02       LdaNamedProperty r2, [1], [2]
         0x2ba6d6e9dcbf @   13 : 26 fa             Star r1
         0x2ba6d6e9dcc1 @   15 : 25 fb             Ldar r0
   40 E> 0x2ba6d6e9dcc3 @   17 : aa 02             ThrowReferenceErrorIfHole [2]
   36 E> 0x2ba6d6e9dcc5 @   19 : 59 fa f9 fb 04    CallProperty1 r1, r2, r0, [4]
   55 S> 0x2ba6d6e9dcca @   24 : 0c 01             LdaSmi [1]
         0x2ba6d6e9dccc @   26 : 26 fb             Star r0
         0x2ba6d6e9dcce @   28 : 0d                LdaUndefined 
   57 S> 0x2ba6d6e9dccf @   29 : a9                Return 
Constant pool (size = 3)
0x2ba6d6e9dc31: [FixedArray] in OldSpace
 - map: 0x2ba611e007b1 <Map>
 - length: 3
           0: 0x2ba6f46900e9 <String[#7]: console>
           1: 0x2ba6f468fbe9 <String[#3]: log>
           2: 0x2ba6d6e9d8e9 <String[#1]: a>
Handler Table (size = 0)
a.js:2: ReferenceError: a is not defined
  console.log(a)
              ^
ReferenceError: a is not defined
    at find_me_test (a.js:2:15)
    at a.js:6:1

仔細觀察就會發現開頭多了這兩行:

LdaTheHole 
Star r0

意思就是 r0 = hole,那這個 hole 是什麼呢?直翻的話可以翻作就是一個「洞」,先讓變數 a 的內容等於一個洞。而這個洞的功用就是:「在被填滿之前,不允許任何人存取」。

所以呼叫 console.log 以前,則是多了這兩行:

Ldar r0
ThrowReferenceErrorIfHole [2]

先把 r0 的值載入到 acc,接著檢查有沒有洞:

// ThrowReferenceErrorIfHole <variable_name>
//
// Throws an exception if the value in the accumulator is TheHole.

因為 r0 還沒賦值,所以的確是一個洞,因此最後就拋出了 ReferenceError。以上這幾組 bytecode 的指令就是 V8 對於 TDZ 的實作。

宣告多個變數

前面宣告變數的時候,會依照順序把值存進 r0, r1...,我想知道這有沒有上限,例如說暫存器是不是用到 r256 之類的就結束了。

為了達成這個目的,可以寫一段程式碼自動來產生宣告變數的程式碼:

var s = 'abcdefghijklmnopqrstuvwxyz'
var n = () => s[Math.floor(Math.random()*s.length)]
for(var i=1; i<=1000; i++){
  console.log('var ' + n() + n() + n() + n() + n()  + ' = 1')
}

接著我們把產生出來的程式碼放到 function 裡面產生 bytecode,可以得到以下結果(只擷取部分):

   21 E> 0x1f6acbe241fa @    0 : a5                StackCheck 
   40 S> 0x1f6acbe241fb @    1 : 0c 01             LdaSmi [1]
         0x1f6acbe241fd @    3 : 26 fb             Star r0
   54 S> 0x1f6acbe241ff @    5 : 0c 01             LdaSmi [1]
         0x1f6acbe24201 @    7 : 26 fa             Star r1
         .....
 1762 S> 0x1f6acbe243e7 @  493 : 0c 01             LdaSmi [1]
         0x1f6acbe243e9 @  495 : 26 80             Star r123
 1776 S> 0x1f6acbe243eb @  497 : 0c 01             LdaSmi [1]
         0x1f6acbe243ed @  499 : 00 26 7f ff       Star.Wide r124
 1790 S> 0x1f6acbe243f1 @  503 : 0c 01             LdaSmi [1]
         .....
14026 S> 0x1f6acbe2586d @ 5747 : 0c 01             LdaSmi [1]
         0x1f6acbe2586f @ 5749 : 00 26 14 fc       Star.Wide r999
         0x1f6acbe25873 @ 5753 : 0d                LdaUndefined 
14029 S> 0x1f6acbe25874 @ 5754 : a9                Return

到 r124 的時候,就從 Star 變成了 Star.Wide,為什麼會有這樣子的差異呢?

可以先看到 V8 中的解釋:

// Wide
//
// Prefix bytecode indicating next bytecode has wide (16-bit) operands.
IGNITION_HANDLER(Wide, InterpreterAssembler) {
  DispatchWide(OperandScale::kDouble);
}

接著觀察指令的 bytecode:

26 fb             Star r0
26 fa             Star r1
26 80             Star r123
00 26 7f ff       Star.Wide r124
00 26 7e ff       Star.Wide r125

很明顯可以看到原本的指令 Star 對應到 26,而後面的參數則是決定要放到第幾個 register,從fb, fa...一直到 80

而到了 80 之後,可能是用來表示位置的數字不夠用了,因此後面那個參數必須從 8 bit 變成 16 bit,就可以表示更多不同的 register。而前面的 26 也變成 00 26,需要多一個資訊來表明 Wide

這個時候我就滿好奇了,那如果不是 1000 個變數,是 100000 個變數呢?

我們可以用同樣的方式產生十萬個變數,產生的部分 bytecode 會變成這樣:

26 fb             Star r0
26 fa             Star r1
26 80             Star r123
00 26 7f ff       Star.Wide r124
00 26 7e ff       Star.Wide r125
00 26 01 80       Star.Wide r32762
00 26 00 80       Star.Wide r32763
01 26 ff 7f ff ff Star.ExtraWide r32764
01 26 fe 7f ff ff Star.ExtraWide r32765

一樣是到了 80 之後數字耗盡,變成 ExtraWide,指令一樣變成 16 bit,然後後面的參數則是從 16 bit 變成 32 bit。

那如果 32 bit 再耗盡呢?那可要宣告幾億個變數,我猜 heap 會先爆掉。

不同的變數型態

剛剛都只有試了正整數,我們可以來試試幾個不同的型態:

function find_me_test() {
  var a = 1 // 正整數
  var b = -1 // 負數
  var c = 0
  var d = 1.1 // 小數
  var e = 1e9 // 很大的正整數
  var f = -1e9 // 很小的負整數
  var g = 'hello'
  var h = [5, 6, 7] // 陣列
  var i = {yo: 1} // 物件
}

find_me_test()

結果如下:

[generated bytecode for function: find_me_test]
Parameter count 1
Frame size 72
   21 E> 0x17de00b1ddea @    0 : a5                StackCheck 
   36 S> 0x17de00b1ddeb @    1 : 0c 01             LdaSmi [1]
         0x17de00b1dded @    3 : 26 fb             Star r0
   55 S> 0x17de00b1ddef @    5 : 0c ff             LdaSmi [-1]
         0x17de00b1ddf1 @    7 : 26 fa             Star r1
   74 S> 0x17de00b1ddf3 @    9 : 0b                LdaZero 
         0x17de00b1ddf4 @   10 : 26 f9             Star r2
   86 S> 0x17de00b1ddf6 @   12 : 12 00             LdaConstant [0]
         0x17de00b1ddf8 @   14 : 26 f8             Star r3
  106 S> 0x17de00b1ddfa @   16 : 01 0c 00 ca 9a 3b LdaSmi.ExtraWide [1000000000]
         0x17de00b1de00 @   22 : 26 f7             Star r4
  130 S> 0x17de00b1de02 @   24 : 01 0c 00 36 65 c4 LdaSmi.ExtraWide [-1000000000]
         0x17de00b1de08 @   30 : 26 f6             Star r5
  155 S> 0x17de00b1de0a @   32 : 12 01             LdaConstant [1]
         0x17de00b1de0c @   34 : 26 f5             Star r6
  173 S> 0x17de00b1de0e @   36 : 7a 02 00 25       CreateArrayLiteral [2], [0], #37
         0x17de00b1de12 @   40 : 26 f4             Star r7
  199 S> 0x17de00b1de14 @   42 : 7d 03 01 29       CreateObjectLiteral [3], [1], #41
         0x17de00b1de18 @   46 : 26 f3             Star r8
         0x17de00b1de1a @   48 : 0d                LdaUndefined 
  213 S> 0x17de00b1de1b @   49 : a9                Return 
Constant pool (size = 4)
0x17de00b1dd49: [FixedArray] in OldSpace
 - map: 0x17de21d007b1 <Map>
 - length: 4
           0: 0x17de00b1dd79 <HeapNumber 1.1>
           1: 0x17de00b1dc91 <String[#5]: hello>
           2: 0x17de00b1dd21 <ArrayBoilerplateDescription 0, 0x17de3618b071 <FixedArray[3]>>
           3: 0x17de00b1dcf9 <ObjectBoilerplateDescription[3]>
Handler Table (size = 0)

底下提供一個簡單對照版本:

function find_me_test() {
  var a = 1         // LdaSmi [1]
  var b = -1        // LdaSmi [-1]
  var c = 0         // LdaZero 
  var d = 1.1       // LdaConstant [0]
  var e = 1e9       // LdaSmi.ExtraWide [1000000000]
  var f = -1e9      // LdaSmi.ExtraWide [-1000000000]
  var g = 'hello'   // LdaConstant [1]
  var h = [5, 6, 7] // CreateArrayLiteral [2], [0], #37
  var i = {yo: 1}   // CreateObjectLiteral [3], [1], #41
}

/*
Constant pool (size = 4)
0x17de00b1dd49: [FixedArray] in OldSpace
 - map: 0x17de21d007b1 <Map>
 - length: 4
           0: 0x17de00b1dd79 <HeapNumber 1.1>
           1: 0x17de00b1dc91 <String[#5]: hello>
           2: 0x17de00b1dd21 <ArrayBoilerplateDescription 0, 0x17de3618b071 <FixedArray[3]>>
           3: 0x17de00b1dcf9 <ObjectBoilerplateDescription[3]>
*/

如果是浮點數跟字串,會先把東西放在 constant pool,再利用 LdaConstant <index> 拿出來。

陣列跟物件一樣會把東西放在 constant pool,前者用 CreateArrayLiteral,後者用 CreateObjectLiteral

變數的運算

來看一下不同運算會用什麼指令:

function find_me_test() {
  var a = 1 // 正整數
  a++
  a+=1
  a--
  a-=1
  a*=1
  a/=1
  a%=1
}

find_me_test()

底下我就直接把 bytecode 對應到操作了:

function find_me_test() {
  var a = 1
  a++   // Inc [0]
  a+=1  // AddSmi [1], [1]
  a--   // Dec [2]
  a-=1  // SubSmi [1], [3]
  a*=1  // MulSmi [1], [4]
  a/=1  // DivSmi [1], [5]
  a%=1  // ModSmi [1], [6]
}

find_me_test()

都是用不同的指令來做不同操作,從指令的名稱大致上就可以推斷出是什麼操作。

全域變數

在 JavaScript 裡面若是不使用任何關鍵字而直接對一個變數賦值,就會變成全域變數,我們來驗證一下這個行為:

function find_me_test() {
  var a = 1
  b = 2
}

find_me_test()

bytecode:

[generated bytecode for function: find_me_test]
Parameter count 1
Frame size 8
   21 E> 0x2a41bc71dcba @    0 : a5                StackCheck 
   36 S> 0x2a41bc71dcbb @    1 : 0c 01             LdaSmi [1]
         0x2a41bc71dcbd @    3 : 26 fb             Star r0
   40 S> 0x2a41bc71dcbf @    5 : 0c 02             LdaSmi [2]
   42 E> 0x2a41bc71dcc1 @    7 : 15 00 00          StaGlobal [0], [0]
         0x2a41bc71dcc4 @   10 : 0d                LdaUndefined 
   46 S> 0x2a41bc71dcc5 @   11 : a9                Return 
Constant pool (size = 1)
0x2a41bc71dc49: [FixedArray] in OldSpace
 - map: 0x2a41cf9807b1 <Map>
 - length: 1
           0: 0x2a41bc71d901 <String[#1]: b>
Handler Table (size = 0)

會使用 StaGlobal 這個指令設置全域的值,跟我們想像的一樣。

結語

這一篇裡面我們嘗試來各種變數相關的指令,讓我們對變數的 bytecode 更熟悉了一些,這一篇所觀察到的一些有趣結論如下:

  1. SyntaxError 在還沒執行程式碼前就知道有錯,所以不會產生 bytecode
  2. 當數值太大時,會加上 .Wide(16 bit) 或是 .ExtraWide(32 bit)
  3. 字串與浮點數都會被放到 constant pool 中,用 LdaConstant 來載入
  4. 陣列與物件類似,但是載入的方法是 CreateArrayLiteralCreateObjectLiteral
#javascript





V8 在處理 JavaScript 的程式碼時,會先將程式碼轉換成中間碼(bytecode)才執行。因此,藉由觀察 V8 bytecode,可以更理解一段 JavaScript 程式碼在 V8 眼裡是什麼樣子。這個系列文會以一系列的簡單程式碼為例,帶大家一起研究 bytecode

留言討論