以太坊虛擬機 (The Ethereum Virtual Machine)

以太坊虛擬機,簡稱 EVM,是以太坊協議和運作的心臟。 正如您可能從名稱中猜到的那樣,它是一個計算引擎,與或者是其他字節碼編譯(bytecode-complied)的編程語言(如Java)的直譯器,或是像 Microsoft .NET Framework的虛擬機,並沒有太大差別。 在本章中,我們將會詳細介紹 EVM, 在乙太坊狀態更新的執行脈落之中,介紹包含指令集 (instruction set), 結構以及運作。

什麼是以太坊虛擬機 (EVM) ?

以太坊虛擬機 (EVM) 是以太坊的一部份,用來處理智能合約的部署以及執行。當外部擁有帳戶 (EOA) 送交易至其它接收者時,若是交易中只用到 value 欄位時,就不需要動到以太坊虛擬機的運算。但是實際上來說,大部份的情形都會需要作到狀態 (state) 的更新,而狀態更新就會運用 EVM 的計算。從高階的角度來看,以太坊區塊鏈上所執行的 EVM 可以被想成一個全域的去中心化電腦,此電腦上面包有數以百萬計的可執行物件,每個物件都擁有永久的數據儲存空間。

EVM 是一個類圖靈完備的狀態機; 用“類”來形容是因為EVM上所有執行程序都有計算步數的限制,因為啟動智能合約之執行時,會設定 Gas 的總量限制。有鑒於此,停機問題(halting problem)就被解決了,所有程式文本之執行都有其終點。當惡意或意外的不當程式被執行時,程序本來將會永遠無法停止,但因為有 Gas 的限制,所以能夠避免該狀況;所以說,以太坊平台遇到這些不當程式時,並不會停機。

EVM 具有基於堆疊的體系結構,將所有記憶體內(in-memory) 的值存儲在堆疊中。它的字(word)大小為256位元(主要用於簡化原生散列和橢圓曲線操作),並具有多個可尋址的數據組件:

  • 一個不可變的程序代碼 (program code) ROM,加載了可執行的智能合約字節碼 (bytecode)

  • 揮發性記憶體 (volatile memory) 中每個位置都明確初始化為零

  • 作為以太坊狀態的一部分的永久存儲器,也是零初始化的

在執行期間還有一組可用的環境變量和數據。我們將在本章後面詳細介紹這些內容。

以太坊虛擬機 (EVM) 架構與執行脈落 顯示以太坊虛擬機架構及執行脈落

以太坊虛擬機 (EVM) 架構與執行脈落
Figure 1. 以太坊虛擬機 (EVM) 架構與執行脈落

與現有的虛擬機科技作比較

  • 虛擬機(Virtual Machine)(Virtualbox, QEMU, 雲計算)

  • Java 虛擬機(VM)

虛擬機技術(如Virtualbox和QEMU / KVM)與EVM的不同之處在於它們的目的是提供管理程式功能,或者處理客戶作業系統與底層主機作業系統和硬體之間的系統調用,任務調度和資源管理的軟體抽象。

然而,Java VM(JVM)規範的某些方面確實包含與EVM的相似之處。從高級概述來看,JVM 旨在提供與底層主機作業系統或硬體無關的運行時環境,從而實現各種系統的兼容性。在JVM上運行的高級程式語言(如Java或Scala)被編譯到相應的指令集 Bytecode 中。這與編譯要在EVM上運行的Solidity源檔案相當。

EVM機器語言( Bytecode 操作)

EVM機器語言分為特定的指令集組,例如算術運算,邏輯和比較運算,控制流,系統調用,堆疊操作和儲存器操作。除典型的 Bytecode 操作外,EVM還必須管理帳戶資訊(即地址和餘額),當前gas價格和區塊資訊。

通用堆疊操作

堆疊和記憶體管理的操作碼指令:

POP     // 項目出棧
PUSH    // 項目入棧
MLOAD   // 將項目加載到記憶體中
MSTORE  // 在記憶體中儲存項目
JUMP    // 改變程式計數器的位置
PC      // 程式計數器
MSIZE   // 活動的記憶體大小
GAS     // 交易可用的gas數量
DUP     // 複製棧項目
SWAP    // 交換棧項目
通用系統操作

執行程式的系統的操作碼指令:

CREATE  // 創建新的帳戶
CALL    // 在帳戶間傳遞消息的指令
RETURN  // 執行停機
REVERT  // 執行停機,恢復狀態更改
SELFDESTRUCT // 執行停機,並標記帳戶為刪除的
算術運算

通用算術運算程式碼指令:

添加//添加
MUL //乘法
SUB //減法
DIV //整數除法
SDIV //有符號整數除法
MOD // Modulo(剩餘)操作
SMOD //簽名模運算
ADDMOD //模數加法
MULMOD //模數乘法
EXP //指數運算
STOP //停止操作
環境操作碼

處理執行環境資訊的通用操作碼:

ADDRESS //當前執行帳戶的地址
BALANCE //帳戶餘額
CALLVALUE //執行環境的交易值
ORIGIN //執行環境的原始地址
CALLER //執行調用者的地址
CODESIZE //執行環境程式碼大小
GASPRICE //gas價格狀態
EXTCODESIZE //帳戶的程式碼大小
RETURNDATACOPY //從先前的記憶體調用輸出的數據的副本

狀態

與任何計算系統一樣,狀態概念也很重要。就像CPU跟蹤執行過程一樣,EVM必須跟蹤各種組件的狀態以支持交易。這些組件的狀態最終會推動總體區塊鏈的變化程度。這方面導致將以太坊描述為基於交易的狀態機,包含以下組件:

World State

160位地址識別碼和帳戶狀態之間的映射,在不可變的Merkle Patricia Tree資料結構中維護。

Account State

包含以下四個組件:

  • nonce:表示從該相應帳戶發送的交易數量的值。

  • balance:帳戶地址擁有的wei的數量。

  • storageRoot:Merkle Patricia Tree根節點的256位雜湊值。

  • codeHash:各個帳戶的EVM程式碼的不可變雜湊值。

Storage State

在EVM上運行時維護的帳戶特定狀態資訊。

Block State

交易所需的狀態值包括以下內容:

  • blockhash:最近完成的塊的雜湊值。

  • coinbase:收件人的地址。

  • timestamp:當前塊的時間戳。

  • number:當前塊的編號。

  • difficulty:當前區塊的難度。

  • gaslimit:當前區塊的gas限制。

Runtime Environment Information

用於使用交易的資訊。

  • gasprice:當前gas價格,由交易發起人指定。

  • codesize:交易程式碼庫的大小。

  • caller:執行當前交易的帳戶的地址。

  • origin:當前交易原始發件人的地址。

狀態轉換使用以下函數計算:

以太坊狀態轉換函數

用於計算valid state transition

區塊終結狀態轉換函數

用於確定最終塊的狀態,作為挖礦過程的一部分,包含區塊獎勵。

區塊級狀態轉換函數

應用於交易狀態時的區塊終結狀態轉換函數的結果狀態。

將Solidity編譯為EVM Bytecode

可以通過命令行完成將Solidity源檔案編譯為EVM Bytecode 。有關其他編譯選項的列表,只需運行以下命令:

$ solc --help

使用--opcodes命令行選項可以輕鬆實現生成Solidity源檔案的原始操作碼流。此操作碼流會遺漏一些資訊(--asm選項會生成完整資訊),但這對於第一次介紹是足夠的。例如,編譯範例Solidity檔案Example.sol並將操作碼輸出填充到名為BytecodeDir的目錄中,使用以下命令完成:

$ solc -o BytecodeOutputDir --opcodes Example.sol

$ solc -o BytecodeOutputDir --asm Example.sol

以下命令將為我們的範例程式生成 Bytecode 二進制檔案:

$ solc -o BytecodeOutputDir --bin Example.sol

生成的輸出操作碼檔案將取決於Solidity源檔案中包含的特定合約。我們的簡單Solidity檔案Example.sol [simple_solidity_example]只有一個名為“example”的合約。

pragma solidity ^0.4.19;

contract example {

  address contractOwner;

  function example() {
    contractOwner = msg.sender;
  }
}

如果查看BytecodeDir目錄,你將看到操作碼檔案example.opcode(請參閱[simple_solidity_example]),其中包含“example”合約的EVM機器語言操作碼指令。在文字編輯器中打開example.opcode檔案將顯示以下內容:

PUSH1 0x60 PUSH1 0x40 MSTORE CALLVALUE ISZERO PUSH1 0xE JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST CALLER PUSH1 0x0 DUP1 PUSH2 0x100 EXP DUP2 SLOAD DUP2 PUSH20 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF MUL NOT AND SWAP1 DUP4 PUSH20 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF AND MUL OR SWAP1 SSTORE POP PUSH1 0x35 DUP1 PUSH1 0x5B PUSH1 0x0 CODECOPY PUSH1 0x0 RETURN STOP PUSH1 0x60 PUSH1 0x40 MSTORE PUSH1 0x0 DUP1 REVERT STOP LOG1 PUSH6 0x627A7A723058 KECCAK256 JUMP 0xb9 SWAP14 0xcb 0x1e 0xdd RETURNDATACOPY 0xec 0xe0 0x1f 0x27 0xc9 PUSH5 0x9C5ABCC14A NUMBER 0x5e INVALID EXTCODESIZE 0xdb 0xcf EXTCODESIZE 0x27 EXTCODESIZE 0xe2 0xb8 SWAP10 0xed 0x

使用--asm選項編譯範例會在BytecodeDir目錄中生成一個檔案 example.evm。這包含詳細的EVM機器語言說明:

/* "Example.sol":26:132  contract example {... */
  mstore(0x40, 0x60)
    /* "Example.sol":74:130  function example() {... */
  jumpi(tag_1, iszero(callvalue))
  0x0
  dup1
  revert
tag_1:
    /* "Example.sol":115:125  msg.sender */
  caller
    /* "Example.sol":99:112  contractOwner */
  0x0
  dup1
    /* "Example.sol":99:125  contractOwner = msg.sender */
  0x100
  exp
  dup2
  sload
  dup2
  0xffffffffffffffffffffffffffffffffffffffff
  mul
  not
  and
  swap1
  dup4
  0xffffffffffffffffffffffffffffffffffffffff
  and
  mul
  or
  swap1
  sstore
  pop
    /* "Example.sol":26:132  contract example {... */
  dataSize(sub_0)
  dup1
  dataOffset(sub_0)
  0x0
  codecopy
  0x0
  return
stop

sub_0: assembly {
        /* "Example.sol":26:132  contract example {... */
      mstore(0x40, 0x60)
      0x0
      dup1
      revert

    auxdata: 0xa165627a7a7230582056b99dcb1edd3eece01f27c9649c5abcc14a435efe3bdbcf3b273be2b899eda90029
}

--bin 選項產生以下內容:

60606040523415600e57600080fd5b336000806101000a81548173
ffffffffffffffffffffffffffffffffffffffff
021916908373
ffffffffffffffffffffffffffffffffffffffff
160217905550603580605b6000396000f3006060604052600080fd00a165627a7a7230582056b99dcb1e

讓我們檢查前兩條指令(參考[common_stack_opcodes]):

PUSH1 0x60 PUSH1 0x40

這裡我們有mnemonic“PUSH1”,後跟一個值為“0x60”的原始字節。這對應於EVM指令,該操作將操作碼之後的單字節解釋為文字值並將其推入堆疊。可以將大小最多為32個字節的值壓入堆疊。例如,以下 Bytecode 將4字節值壓入堆疊:

PUSH4 0x7f1baa12

第二個push操作碼將“0x40”儲存到堆疊中(在那裡已存在的“0x60”之上)。

接下來的兩個指令:

MSTORE CALLVALUE

MSTORE是一個堆疊/記憶體操作(參見[common_stack_opcodes]),它將值保存到記憶體中,而CALLVALUE是一個環境操作碼(參見[common_environment_opcodes]),它返回正在執行的消息調用的存放值。

執行EVM Bytecode

Gas,會計

對於每個交易,都有一個關聯的gas-limitgas-price,它們構成了EVM執行的費用。這些費用用於促進交易的必要資源,例如計算和儲存。gas還用於創建帳戶和智能合約。

圖靈完備性和gas

簡單來說,如果系統或程式語言可以解決你輸入的任何問題,它是圖靈完備的。這在以太坊黃皮書中討論過:

It is a quasi-Turing complete machine; the quasi qualification comes from the fact that the computation is intrinsically bounded through a parameter, gas, which limits the total amount of computation done.

— Gavin Wood
ETHEREUM: A SECURE DECENTRALISED GENERALISED TRANSACTION LEDGER

雖然EVM理論上可以解決它收到的任何問題,但gas可能會阻止它這樣做。這可能在以下幾個方面發生:

1)在以太坊開採的塊具有與之相關的gas限制; 也就是說,區塊內所有交易所使用的總gas不能超過一定限度。 2)由於gas和gas價格齊頭並進,即使取消了gas限制,高度複雜的交易也可能在經濟上不可行。

但是,對於大多數用例,EVM可以解決提供給它的任何問題。

Bytecode 與運行時 Bytecode

編譯合約時,你可以獲得合約 Bytecode _或運行時 Bytecode _。

合約 Bytecode 包含實際上最終位於區塊鏈上的 Bytecode 以及將 Bytecode 放在區塊鏈上並運行合約構造函數所需的 Bytecode 。

另一方面,運行時 Bytecode 只是最終位於區塊鏈上的 Bytecode 。這不包括初始化合約並將其放在區塊鏈上所需的 Bytecode 。

讓我們以前面創建的簡單Faucet.sol合約為例。

// Version of Solidity compiler this program was written for
pragma solidity ^0.4.19;

// Our first contract is a faucet!
contract Faucet {

  // Give out ether to anyone who asks
  function withdraw(uint withdraw_amount) public {

      // Limit withdrawal amount
      require(withdraw_amount <= 100000000000000000);

      // Send the amount to the address that requested it
      msg.sender.transfer(withdraw_amount);
    }

  // Accept any incoming amount
  function () public payable {}

}

要獲得合約 Bytecode ,我們將運行solc --bin Faucet.sol。如果我們只想要運行時 Bytecode ,我們將運行solc --bin-runtime Faucet.sol

如果比較這些命令的輸出,你將看到運行時 Bytecode 是合約 Bytecode 的子集。換句話說,運行時 Bytecode 完全包含在合約 Bytecode 中。

反彙編 Bytecode

反彙編EVM Bytecode 是瞭解高級別Solidity在EVM中的作用的好方法。你可以使用一些反彙編程式來執行此操作:

  • Porosity 是一個流行的開源反編譯器:https://github.com/comaeio/porosity

  • Ethersplay 是Binary Ninja的EVM插件,一個反彙編程式:https://github.com/trailofbits/ethersplay

  • IDA-Evm 是IDA的EVM插件,另一個反彙編程式:https://github.com/trailofbits/ida-evm

在本節中,我們將使用 Binary Ninja 的 Ethersplay 插件。

在獲取Faucet.sol的運行時 Bytecode 後,我們可以將其提供給Binary Ninja(在匯入Ethersplay插件之後)以查看EVM指令。

Faucet.sol runtime bytecode disassembled
Figure 2. Disassembling the Faucet runtime bytecode

當你將交易發送到智能合約時,交易首先會與該智能合約的調度員(dispatcher)進行交互。調度程式讀入交易的數據欄位並將其發送到適當的函數。

在熟悉的MSTORE指令之後,我們在編譯的Faucet.sol合約中看到以下創建:

PUSH1 0x4
CALLDATASIZE
LT
PUSH1 0x3f
JUMPI

"PUSH1 0x4" 將0x4置於堆疊頂部,棧初始為空。“CALLDATASIZE”獲取接收到的交易的calldata的大小(以字節為單位)並將其推送到堆疊中。當前堆疊如下所示:

Table 1. Current stack
Stack

length of calldata from tx (msg.data)

0x4

下一條指令是“LT”,是“小於(less than)”的縮寫。LT指令檢查堆疊上的頂部項是否小於堆疊上的下一項。在我們的例子中,它檢查CALLDATASIZE的結果是否小於4個字節。

為什麼EVM會檢查交易的calldata是否至少為4個字節?因為函數識別碼的工作原理。每個函數由其keccak256雜湊的前四個字節標識。通過將函數的名稱和它所採用的參數放入keccak256雜湊函數,我們可以推匯出它的函數識別碼。在我們的合約中,我們有:

keccak256("withdraw(uint256)") = 0x2e1a7d4d...

因此,“withdraw(uint256)”函數的函數識別碼是0x2e1a7d4d,因為它們是結果雜湊的前四個字節。函數識別碼總是4個字節長,所以如果發送給合約的交易的整個數據欄位小於4個字節,那麼除非定義了fallback函數,否則沒有交易可能與之通信的函數。因為我們在Faucet.sol中實現了這樣的fallback函數,所以當calldata的長度小於4個字節時,EVM會跳轉到此函數。

如果msg.data欄位少於4個字節,LT將彈出堆疊的前兩個值並將1推到其上。否則,它會推入0。在我們的例子中,讓我們假設發送給我們的合約的transaciton的msg.data欄位was少於4個字節。

“PUSH1 0x3f”指令將字節“0x3f”壓入堆疊。在此指令之後,堆疊如下所示:

Table 2. Current stack
Stack

0x3f

1

下一條指令是“JUMPI”,代表“jump if”。它的工作原理如下:

jumpi(label, cond) // Jump to "label" if "cond" is true

在我們的例子中,“label”是0x3f,這是我們的fallback函數存在於我們的智能合約中的地方。“cond”參數為1,它來自之前LT指令的結果。要將整個序列放入單詞中,如果交易數據少於4個字節,則合約將跳轉到fallback函數。

JUMPI instruction leading to fallback function
Figure 3. JUMPI instruction leading to fallback function

我們來看一下調度員的核心程式碼塊。假設我們收到的長度大於4個字節的calldata,“JUMPI”指令不會跳轉到回退函數。相反,程式碼執行將遵循下一條指令:

PUSH1 0x0
CALLDATALOAD
PUSH29 0x1000000...
SWAP1
DIV
PUSH4 0xffffffff
AND
DUP1
PUSH4 0x2e1a7d4d
EQ
PUSH1 0x41
JUMPI

“PUSH1 0x0”將0壓入堆疊,否則為空。“CALLDATALOAD”接受發送到智能合約的calldata中的索引作為參數,並從該索引讀取32個字節,如下所示:

calldataload(p) // call data starting from position p (32 bytes)

由於0是從PUSH1 0x0命令傳遞給它的索引,因此CALLDATALOAD從字節0開始讀取32字節的calldata,然後將其推送到堆疊的頂部(在彈出原始0x0之後)。在“PUSH29 0x1000000 …​”指令之後,堆疊如下所示:

Table 3. Current stack
Stack

0x1000000…​ (29 bytes in length)

32 bytes of calldata starting at byte 0

“SWAP1”用它後面的第i個元素交換堆疊頂部元素。在這裡,它與密鑰數據交換0x1000000 …​ 新堆疊如下所示:

Table 4. Current stack
Stack

32 bytes of calldata starting at byte 0

0x1000000…​ (29 bytes in length)

下一條指令是“DIV”,其工作方式如下:

div(x, y) // x / y

在這裡,x = 32字節的calldata從字節0開始,y = 0x100000000 …​(總共29個字節)。你能想到調度員為什麼要進行劃分嗎?這是一個提示:我們從索引0開始從calldata讀取32個字節。該calldata的前四個字節是函數識別碼。

我們之前推送的0x100000000 …​.長度為29個字節,由開頭的1組成,後跟全0。將我們的32字節的calldata除以此0x100000000 …​.將只留下從索引0開始的callataload的topmost 4字節這四個字節 - 從索引0開始的calldataload中的前四個字節 - 是函數識別碼,並且這就是EVM如何提取該欄位。

如果你不清楚這一部分,可以這樣想:在base10,1234000/1000 = 1234。在base16中,這沒有什麼不同。不是每個地方都是10的倍數,它是16的倍數。正如在我們的較小的例子中除以103(1000)只保留最頂部的數字,將我們的32字節基數16值除以1629做同樣的事。

DIV(函數識別碼)的結果被推送到堆疊上,我們的新堆疊如下:

Table 5. Current stack
Stack

function identifier sent in msg.data

由於“PUSH4 0xffffffff”和“AND”指令是冗餘的,我們可以完全忽略它們,因為堆疊在完成後將保持不變。“DUP1”指令複製堆疊上的1st項,這是函數識別碼。下一條指令“PUSH4 0x2e1a7d4d”將抽取(uint256)函數的計算函數識別碼推送到堆疊。堆疊現在看起來如下:

Table 6. Current stack
Stack

0x2e1a7d4d

function identifier sent in msg.data

function identifier sent in msg.data

下一條指令“EQ”彈出堆疊的前兩項並對它們進行比較。這是調度程式完成其主要工作的地方:它比較交易的msg.data欄位中發送的函數識別碼是否與withdraw(uint256)匹配。如果它們相等,則EQ將1推入堆疊,這最終將用於跳轉到fallback函數。否則,EQ將0推入堆疊。

假設發送給我們合約的交易確實以withdraw(uint256)的函數識別碼開頭,我們的新棧看起來如下:

Table 7. Current stack
Stack

1

function identifier sent in msg.data

接下來,我們有“PUSH1 0x41”,這是withdraw(uint256)函數在合約中的地址。在此指令之後,堆疊如下所示:

Table 8. Current stack
Stack

0x41

function identifier sent in msg.data

1

接下來是JUMPI指令,它再次接受堆疊上的前兩個元素作為參數。在這種情況下,我們有“jumpi(0x41,1)”,它告訴EVM執行跳轉到withdraw(uint256)函數的位置。

EVM工具參考

  • [ByteCode To Opcode Disassembler](https://etherscan.io/opcode-tool) (用於檢查/調試編譯是否完整運行,如果源程式碼未發佈則可用於逆向工程)

results matching ""

    No results matching ""