實際處理內部狀態和計算的協議部分稱為以太坊虛擬機(EVM)。從實際角度來看,EVM可以被認為是包含數百萬個物件的大型去中心化計算機。
-
虛擬機(Virtual Machine)(Virtualbox, QEMU, 雲計算)
-
Java 虛擬機(VM)
虛擬機技術(如Virtualbox和QEMU / KVM)與EVM的不同之處在於它們的目的是提供管理程式功能,或者處理客戶操作系統與底層主機操作系統和硬體之間的系統調用,任務調度和資源管理的軟體抽象。
然而,Java VM(JVM)規範的某些方面確實包含與EVM的相似之處。從高級概述來看,JVM旨在提供與底層主機操作系統或硬體無關的運行時環境,從而實現各種系統的兼容性。在JVM上運行的高級程式語言(如Java或Scala)被編譯到相應的指令集字節碼中。這與編譯要在EVM上運行的Solidity源檔案相當。
EVM機器語言分為特定的指令集組,例如算術運算,邏輯和比較運算,控制流,系統調用,堆棧操作和儲存器操作。除典型的字節碼操作外,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:當前汽油價格,由交易發起人指定。
-
codesize:交易程式碼庫的大小。
-
caller:執行當前交易的賬戶的地址。
-
origin:當前交易原始發件人的地址。
-
狀態轉換使用以下函數計算:
- 以太坊狀態轉換函數
-
用於計算_valid state transition_。
- 區塊終結狀態轉換函數
-
用於確定最終塊的狀態,作為挖礦過程的一部分,包含區塊獎勵。
- 區塊級狀態轉換函數
-
應用於交易狀態時的區塊終結狀態轉換函數的結果狀態。
可以通過命令行完成將Solidity源檔案編譯為EVM字節碼。有關其他編譯選項的列表,只需運行以下命令:
$ solc --help
使用_—opcodes_命令行選項可以輕鬆實現生成Solidity源檔案的原始操作碼流。此操作碼流會遺漏一些信息(_—asm_選項會生成完整信息),但這對於第一次介紹是足夠的。例如,編譯示例Solidity檔案_Example.sol_並將操作碼輸出填充到名為_BytecodeDir_的目錄中,使用以下命令完成:
$ solc -o BytecodeOutputDir --opcodes Example.sol
或
$ solc -o BytecodeOutputDir --asm Example.sol
以下命令將為我們的示例程式生成字節碼二進制檔案:
$ 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個字節的值壓入堆棧。例如,以下字節碼將4字節值壓入堆棧:
PUSH4 0x7f1baa12
第二個push操作碼將“0x40”儲存到堆棧中(在那裡已存在的“0x60”之上)。
接下來的兩個指令:
MSTORE CALLVALUE
MSTORE是一個堆棧/內存操作(參見[common_stack_opcodes]),它將值保存到內存中,而CALLVALUE是一個環境操作碼(參見[common_environment_opcodes]),它返回正在執行的消息調用的存放值。
簡單來說,如果系統或程式語言可以解決你輸入的任何問題,它是_圖靈完備的_。這在以太坊黃皮書中討論過:
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.
ETHEREUM: A SECURE DECENTRALISED GENERALISED TRANSACTION LEDGER
雖然EVM理論上可以解決它收到的任何問題,但gas可能會阻止它這樣做。這可能在以下幾個方面發生:
1)在以太坊開採的塊具有與之相關的gas限制; 也就是說,區塊內所有交易所使用的總gas不能超過一定限度。 2)由於gas和gas價格齊頭並進,即使取消了gas限制,高度複雜的交易也可能在經濟上不可行。
但是,對於大多數用例,EVM可以解決提供給它的任何問題。
編譯合約時,你可以獲得_合約字節碼_或_運行時字節碼_。
合約字節碼包含實際上最終位於區塊鏈上的字節碼_以及_將字節碼放在區塊鏈上並運行合約構造函數所需的字節碼。
另一方面,運行時字節碼只是最終位於區塊鏈上的字節碼。這不包括初始化合約並將其放在區塊鏈上所需的字節碼。
讓我們以前面創建的簡單`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 {} }
要獲得合約字節碼,我們將運行`solc --bin Faucet.sol`。如果我們只想要運行時字節碼,我們將運行`solc --bin-runtime Faucet.sol`。
如果比較這些命令的輸出,你將看到運行時字節碼是合約字節碼的子集。換句話說,運行時字節碼完全包含在合約字節碼中。
反彙編EVM字節碼是瞭解高級別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的運行時字節碼後,我們可以將其提供給Binary Ninja(在匯入Ethersplay插件之後)以查看EVM指令。
當你將交易發送到智能合約時,交易首先會與該智能合約的調度員(dispatcher)進行交互。調度程式讀入交易的數據欄位並將其發送到適當的函數。
在熟悉的MSTORE指令之後,我們在編譯的Faucet.sol合約中看到以下創建:
PUSH1 0x4 CALLDATASIZE LT PUSH1 0x3f JUMPI
"PUSH1 0x4" 將0x4置於堆棧頂部,棧初始為空。“CALLDATASIZE”獲取接收到的交易的calldata的大小(以字節為單位)並將其推送到堆棧中。當前堆棧如下所示:
Stack |
---|
0x4 |
length of calldata from tx (msg.data) |
下一條指令是“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”壓入堆棧。在此指令之後,堆棧如下所示:
Stack |
---|
1 |
0x3f |
下一條指令是“JUMPI”,代表“jump if”。它的工作原理如下:
jumpi(label, cond) // Jump to "label" if "cond" is true
在我們的例子中,“label”是0x3f,這是我們的fallback函數存在於我們的智能合約中的地方。“cond”參數為1,它來自之前LT指令的結果。要將整個序列放入單詞中,如果交易數據少於4個字節,則合約將跳轉到fallback函數。
我們來看一下調度員的核心程式碼塊。假設我們收到的長度大於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 …”指令之後,堆棧如下所示:
Stack |
---|
32 bytes of calldata starting at byte 0 |
0x1000000… (29 bytes in length) |
“SWAP1”用它後面的_第i個_元素交換堆棧頂部元素。在這裡,它與密鑰數據交換0x1000000 … 新堆棧如下所示:
Stack |
---|
0x1000000… (29 bytes in length) |
32 bytes of calldata starting at byte 0 |
下一條指令是“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(函數標識符)的結果被推送到堆棧上,我們的新堆棧如下:
Stack |
---|
function identifier sent in msg.data |
由於“PUSH4 0xffffffff”和“AND”指令是冗餘的,我們可以完全忽略它們,因為堆棧在完成後將保持不變。“DUP1”指令複製堆棧上的1st項,這是函數標識符。下一條指令“PUSH4 0x2e1a7d4d”將抽取(uint256)函數的計算函數標識符推送到堆棧。堆棧現在看起來如下:
Stack |
---|
function identifier sent in msg.data |
function identifier sent in msg.data |
0x2e1a7d4d |
下一條指令“EQ”彈出堆棧的前兩項並對它們進行比較。這是調度程式完成其主要工作的地方:它比較交易的msg.data欄位中發送的函數標識符是否與withdraw(uint256)匹配。如果它們相等,則EQ將1推入堆棧,這最終將用於跳轉到fallback函數。否則,EQ將0推入堆棧。
假設發送給我們合約的交易確實以withdraw(uint256)的函數標識符開頭,我們的新棧看起來如下:
Stack |
---|
function identifier sent in msg.data |
1 |
接下來,我們有“PUSH1 0x41”,這是withdraw(uint256)函數在合約中的地址。在此指令之後,堆棧如下所示:
Stack |
---|
function identifier sent in msg.data |
1 |
0x41 |
接下來是JUMPI指令,它再次接受堆棧上的前兩個元素作為參數。在這種情況下,我們有“jumpi(0x41,1)”,它告訴EVM執行跳轉到withdraw(uint256)函數的位置。
-
[ByteCode To Opcode Disassembler](https://etherscan.io/opcode-tool) (用於檢查/調試編譯是否完整運行,如果源程式碼未發佈則可用於逆向工程)