深入探索EVM : 編譯和部署智能合約

作者: 丁沛靈(ArcBlock 軟體工程師)
導讀
以太坊虛擬機器(Ethereum Virtual Machine)是以太坊的基礎,它負責執行所有的交易(Transaction),並且根據這些 Transaction 來維護整個以太坊的帳戶狀態,或者更準確的稱之為 World State。 Transaction 分很多種,有最簡單的以太幣(Ether)交易,有部署或呼叫智能合約的交易。智能合約(Smart Contract)是由虛擬機器執行的程式碼,用以完成複雜的業務邏輯。 Solidity 是目前最受歡迎的編寫智慧合約的高階語言。由 Solidity 編寫的智能合約會先被編譯成可被虛擬機器直接接受的字節碼,然後會被用戶以 Transaction 的方式發送給以太坊從而進行智能合約部署。在這之後,使用者便可以呼叫智能合約的函數來完成業務邏輯。那麼在整個流程中,Solidity 程式碼是如何被編譯成字節碼的呢?字節碼在虛擬機器中又是如何運作的?編譯字節碼的時候,虛擬機器如何優化?本期 ArcBlock 工程部落格將帶你一起,詳細剖析這些問題。
從一個例子開始
讓我們從一個最簡單的智能合約範例開始。
pragma solidity ^0.4.11;
contract C {
uint256 a;
function C() {
a = 1;
}
}這段程式碼非常類似 Java,為了簡單起見,這裡我就藉用 Java 的術語。這段智能合約有一個成員變數a,其型別是一個 256 位元的無符號整數數。另外,它還有一個建構函數,在其中我們將成員變數a賦值為 1。下面讓我們來編譯這段程式碼,我們有兩個工具可以用來編譯程式碼:
solc --bin --asm file_name.sol- http://remix.ethereum.org
第一個是命令列工具,大家需要先自行安裝。第二個是一個強大的網頁版 IDE,它可以快速的編譯,部署以及調試智能合約。編譯後的程式碼我們稱之為字節碼(bytecode),如下圖所示:
60606040523415600e57600080fd5b600160008190555060358060236000396000f3006060604052600080fd00a165627a7a72305820d315875f56b532ab371cf9aa86a62850e13eb6ab194847011dcd641b9a9d2f8d0029在這段字節碼中,每個字元代表一個 16 進制數,每兩個字元代表一個位元組。這段字節碼就是直接運行在虛擬機器上的程式碼,虛擬機器只需要按照事先定義好的規則,解釋並且執行每個位元組即可。但是對人類來說,直接閱讀這些字節碼太過繁瑣,所以我們可以將其轉換成對人類更友好的形式,操作碼(OpCodes),如下所示:
PUSH1 0x60 PUSH1 0x40 MSTORE CALLVALUE ISZERO PUSH1 0xE JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST PUSH1 0x1 PUSH1 0x0 DUP2 SWAP1 SSTORE POP PUSH1 0x35 DUP1 PUSH1 0x23 PUSH1 0x0 CODECOPY PUSH1 0x0 RETURN STOP PUSH1 0x60 PUSH1 0x40 MSTORE PUSH1 0x0 DUP1 REVERT STOP LOG1 PUSH6 0x627A7A723058 KECCAK256 0xd3 ISZERO DUP8 0x5f JUMP 0xb5 ORIGIN 0xab CALLDATACOPY SHR 0xf9 0xaa DUP7 0xa6 0x28 POP 0xe1 RETURNDATACOPY 0xb6 0xab NOT 0x48 0x47 ADD SAR 0xcd PUSH5 0x1B9A9D2F8D STOP 0x29上面的字節碼或操作碼是等價的,它們都可以被分成三個部分:
- 部署智能合約的程式碼
60606040523415600e57600080fd5b600160008190555060358060236000396000f300
PUSH1 0x60 PUSH1 0x40 MSTORE CALLVALUE ISZERO PUSH1 0xE JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST PUSH1 0x1 PUSH1 0x0 DUP2 SWAP1 SSTORE POP PUSH1 0x35 DUP1 PUSH1 0x23 PUSH1 0x0 CODECOPY PUSH1 0x0 RETURN STOP- 智能合約本身的程式碼
6060604052600080fd00
PUSH1 0x60 PUSH1 0x40 MSTORE PUSH1 0x0 DUP1 REVERT STOP- Auxdata
a165627a7a72305820d315875f56b532ab371cf9aa86a62850e13eb6ab194847011dcd641b9a9d2f8d0029
LOG1 PUSH6 0x627A7A723058 KECCAK256 0xd3 ISZERO DUP8 0x5f JUMP 0xb5 ORIGIN 0xab CALLDATACOPY SHR 0xf9 0xaa DUP7 0xa6 0x28 POP 0xe1 RETURNDATACOPY 0xb6 0xab NOT 0x48 0x47 ADD SAR 0xcd PUSH5 0x1B9A9D2F8D STOP 0x29下面讓我們來逐步解說每個部分,看看它們都是怎麼運作的。
1. 部署智能合約的程式碼
第一部分程式碼是事實上把智慧合約部署到以太坊上的程式碼,也是我們重點討論的部分。這段程式碼又可以被分成三個部分:
- Payable 檢查
60606040523415600e57600080fd
PUSH1 0x60 PUSH1 0x40 MSTORE CALLVALUE ISZERO PUSH1 0xE JUMPI PUSH1 0x0 DUP1 REVERT- 執行構造函數
5b6001600081905550
JUMPDEST PUSH1 0x1 PUSH1 0x0 DUP2 SWAP1 SSTORE POP- 複製程式碼,並將其傳回內存
60358060236000396000f300
PUSH1 0x35 DUP1 PUSH1 0x23 PUSH1 0x0 CODECOPY PUSH1 0x0 RETURN STOP1.1 Payable 檢查
payable是 Solidity 的一個關鍵字,如果一個函數被其標記,那麼用戶在調用該函數的同時還可以發送以太幣到該智能合約。而這部分字節碼的意義就在於阻止用戶在呼叫沒有被 payable 標記的函數時,向該智能合約發送以太幣。下面這張圖是對這段程式碼進一步演算,左邊兩列分別是字節碼和操作碼,最右邊一列是執行完該條語句之後棧的狀態。

在上圖中,前三句是將記憶體中從0x40開始往後 32 個位元組的位址賦上0x60這個值,這是虛擬機器保留的記憶體位址。後面的幾句就是在透過查看發送的以太幣是否為 0 來做 payable 檢查。如果是 0 的話,那麼虛擬機器程式計數器(PC)跳到0xe的位置繼續執行,如果不是的話,終止程式。
這裡要說明一下,stack 裡面的每一個元素都是 32 個位元組長度,在這裡為了方便,省略了高位元的 0。
1.2 執行建構函數
智能合約部署程式碼的第二部分是用來執行合約的建構子的。如下圖所示,在執行完這段字節碼之後,heap 裡面0x0的位址就被賦上了值0x1。 0x0既是虛擬機器為變數a在其 Wolrd State 裡分配的位址。

在上圖中,JUMPDEST對應上面的0xe,它代表如果透過上面的 payable 檢查,我們應該跳到這裡繼續執行程式碼。 SSTORE指令是用來將堆疊上的值儲存到 World State 上的。圖中我用了 heap 來代表 World State 是因為它們兩個有許多相似之處。我們知道在 Java 裡面,棧是用來儲存函數運行時的臨時變數的,而堆是用來儲存生命週期更長的變量,例如成員變數。堆疊上的資料會隨著方法的執行完畢而即時清空,而堆疊上的資料會在整個類別實例的生命週期裡面始終有效。 Java 虛擬機器不會將堆中的成員變數清空,除非該類別的實例被回收。而一個部署到以太坊上的智能合約可以被認為是永遠活著的合約實例(當然一個合約也可以被殺死)。所以用來存放智慧合約狀態的 World State 就可以被看做是以太坊的 heap。這裡我之所以用 heap 來代指 World State,第一是希望跟 stack 做一個呼應,第二是希望從另一個方面描述以太坊的本質:以太坊是一個計算機網絡,它將整個網絡裡面的所有計算機連接起來形成一個單一計算機。在這個計算機中,它使用資料結構來模擬記憶體的工作機制從而實現圖靈完備的程式語言。
在以太坊中,World State 是一個 key-value pair。每一個 key 對應一個 32 個位元組長的資料塊。所以在上圖所示的情況裡面,0x0 這個 key 所對應的資料塊裡面儲存了 0x1 這個數(32 字節,高位補 0)。
1.3 複製程式碼
智能合約部署程式碼的第三部分是將剩餘的程式碼,既智能合約本身的程式碼和 Auxdata 從 Transaction 複製到記憶體裡面並返回之。

從上圖可知,我們將0x23到0x58的字節碼(總共 0x35 個字節碼)複製到了內存中0x0到0x35的地址上。
2. 智能合約本身的程式碼
整個字節碼的第二部分是智能合約本身的程式碼,它們會在智能合約的函數被呼叫的時候執行。因為在我們目前的例子中,智能合約只有一個建構函數,而沒有其他方法,所以下圖所示的程式碼並沒有做什麼有實際意義的操作。

3. Auxdata
第三部分 Auxdata 是有固定模板的:
0xa1 0x65 'b' 'z' 'z' 'r' '0' 0x58 0x20 <32 bytes swarm hash> 0x00 0x29我們將上述的字節碼a165627a7a72305820d315875f56b532ab371cf9aa86a62850e13eb6ab194847011dcd641b9a9d2f8d0029帶入該模板中,可以得到 swarm hash 為d315875f56b532ab371cf9aa86a62850e13eb6ab194847011dcd641b9a9d2f8d,這個 Swarm Hash 可以用來校驗智能合約的程式碼,也可以用來取得智慧合約的元資料。
創建合約的合約
我們已經透過上面的講解,了解了部署智能合約的整個流程。在這個流程中,字節碼以 Transaction 的方式發送給以太坊從而完成對其的部署,不過智能合約不僅能被手動創建,也可以被其他已有的智能合約創建。
pragma solidity ^0.4.11;
contract Foo {
}
contract FooFactory {
address fooInstance;
function makeNewFoo() {
fooInstance = new Foo();
}
}在上面的程式碼裡面我們可以看到兩個合約,一個是 Foo,一個是用來創建 Foo 的 FooFactory。如果我們把上面的程式碼編譯之後會得到如下的字節碼:
FooFactoryDeployCode
FooFactoryContractCode
FooDeployCode
FooContractCode
FooAUXData
FooFactoryAUXData不難看出,整個字節碼分兩層,每一層又和之前描述的一樣,分成三個部分。最外層的字節碼用來部署 FooFactory,它的 Contract Code 部分是用來創建合約 Foo 的,所以在這一部分裡面又嵌套了一套完整的用來部署合約的程式碼。
增加一個成員變數
在第一個例子中,我們在整個合約裡面只創建了一個成員變數。現在讓我們來把合約變的複雜一點,再增加一個成員變量,看看對應的字節碼有什麼變化。
pragma solidity ^0.4.11;
contract C {
uint256 a;
uint256 b;
function C() {
a = 1;
b = 2;
}
}在省略掉其餘部分之後,執行建構函式的部分如下所示:
5b JUMPDEST
60 01 PUSH1 0x1
60 00 PUSH1 0x0
81 DUP2
90 SWAP1
55 SSTORE // heap {0x0 => 0x1}
50 POP
60 02 PUSH1 0x2
60 01 PUSH1 0x1
81 DUP2
90 SWAP1
55 SSTORE // heap {0x0 => 0x1} {0x1 => 0x2}
50 POP很容易看出,虛擬機器依序為變數a和b在 World State 中分配了兩個位址0x0和0x1,並且賦上了對應的值 1 和 2。事實上如果有更多的成員變量,虛擬機會依次的為它們分配儲存位址。這裡我們分配的儲存位址對應到該RPC裡面的第二個參數。
從 256 位到 128 位
在上面的例子中我們聲明了兩個 256 位元(32 位元組)的無符號整數數。在實際運用中我們可能根本不需要那麼多的空間,例如在其他語言中常用的整數型數只有 4 個位元組。所以現在讓我們來做一點優化,把這兩個 32 位元組的數變成兩個 16 位元組的整數數,看看會發生什麼變化。
pragma solidity ^0.4.11;
contract C {
uint128 a;
uint128 b;
function C() {
a = 1;
b = 2;
}
}同樣的,將其餘部分省略,運行建構函數的部分如下所示:
/********************** a = 1 ********************************/
60 01 PUSH1 0x1 stack: [0x1]
60 00 PUSH1 0x0 stack: [0x0 0x1]
80 DUP1 stack: [0x0 0x0 0x1]
61 0100 PUSH2 0x100 stack: [0x100 0x0 0x0 0x1]
0a EXP(base, exponent) stack: [0x01 0x0 0x1]
81 DUP2 stack: [0x0 0x1 0x0 0x1]
54 SLOAD(location) stack: [0x0 0x1 0x0 0x1]
81 DUP2 stack: [0x1 0x0 0x1 0x0 0x1]
6f ffffffffffffffffffffffffffffffff PUSH16 stack: [0xffffffffffffffffffffffffffffffff 0x1 0x0 0x1 0x0 0x1]
02 MUL(x, y) stack: [0xffffffffffffffffffffffffffffffff 0x0 0x1 0x0 0x1]
19 NOT stack: [0xffffffffffffffffffffffffffffffff00000000000000000000000000000000 0x0 0x1 0x0 0x1]
16 AND(x, y) stack: [0x0 0x1 0x0 0x1]
90 SWAP1 stack: [0x1 0x0 0x0 0x1]
83 DUP4 stack: [0x1 0x1 0x0 0x0 0x1]
6f ffffffffffffffffffffffffffffffff PUSH16 stack: [0xffffffffffffffffffffffffffffffff 0x1 0x1 0x0 0x0 0x1]
16 AND stack: [0x1 0x1 0x0 0x0 0x1]
02 MUL stack: [0x1 0x0 0x0 0x1]
17 OR stack: [0x1 0x0 0x1]
90 SWAP1 stack: [0x0 0x1 0x1]
55 SSTORE(pos, val) stack: [0x1]
heap: {0x0 => 0x1}
50 POP stack: []
/********************** b = 2 ********************************/
60 02 PUSH1 0x2 stack: [0x2]
60 00 PUSH1 0x0 stack: [0x0 0x2]
60 10 PUSH1 0x10 stack: [0x10 0x0 0x2]
61 0100 PUSH2 0x100 stack: [0x100 0x10 0x0 0x2]
0a EXP stack: [0x100000000000000000000000000000000 0x0 0x2]
81 DUP2 stack: [0x0 0x100000000000000000000000000000000 0x0 0x2]
54 SLOAD(location) stack: [0x1 0x100000000000000000000000000000000 0x0 0x2]
81 DUP2 stack: [0x100000000000000000000000000000000 0x1 0x100000000000000000000000000000000 0x0 0x2]
6f ffffffffffffffffffffffffffffffff PUSH16 stack: [0xffffffffffffffffffffffffffffffff 0x100000000000000000000000000000000 0x1 0x100000000000000000000000000000000 0x0 0x2]
02 MUL stack: [0xffffffffffffffffffffffffffffffff00000000000000000000000000000000 0x1 0x100000000000000000000000000000000 0x0 0x2]
19 NOT stack: [0x00000000000000000000000000000000ffffffffffffffffffffffffffffffff 0x1 0x100000000000000000000000000000000 0x0 0x2]
16 AND stack: [0x1 0x100000000000000000000000000000000 0x0 0x2]
90 SWAP1 stack: [0x100000000000000000000000000000000 0x1 0x0 0x2]
83 DUP4 stack: [0x2 0x100000000000000000000000000000000 0x1 0x0 0x2]
6f ffffffffffffffffffffffffffffffff PUSH16 stack: [0xffffffffffffffffffffffffffffffff 0x2 0x100000000000000000000000000000000 0x1 0x0 0x2]
16 AND stack: [0x2 0x100000000000000000000000000000000 0x1 0x0 0x2]
02 MUL stack: [0x200000000000000000000000000000000 0x1 0x0 0x2]
17 OR stack: [0x200000000000000000000000000000001 0x0 0x2]
90 SWAP1 stack: [0x0 0x200000000000000000000000000000001 0x2]
55 SSTORE stack: [0x2]
heap: {0x0 => 0x200000000000000000000000000000001}
50 POP stack: []總得來講上面的程式碼分成兩個部分,第一部分對應a = 1,這部分程式碼在位址0x0的低 16 位元組裡存入0x1;第二部分對應b = 2,它表示在 0x0 的高 16 位元組裡面使用 0x2. 所以在 0x2. 所以我們已經使用了上面的一個key,即0x0,完成了兩個變數的保存。 用更形象的方式可以表示成:
[ b ][ a ]
[16 bytes / 128 bits][16 bytes / 128 bits]打包儲存
那麼問題來了,為什麼虛擬機器要做這個變動?這兩個例子的 Solidity 程式碼幾乎一樣,我們只是改變了變數的類型而已,然而虛擬機器為第二個例子編譯出的字節碼比之前例子的字節碼長了不止一倍。要知道,這些增加的字節碼可是會直接影響 Transaction 的大小的。所以虛擬機器到底是出於何種目的來產生瞭如此多的字節碼的呢?
其實對於上面的問題有一個簡單的答案,那就是 gas。我們知道執行、部署合約是需要消耗 gas 的,而具體到 EVM 的層面,那就是每個操作碼都有其對應的需要消耗的 gas。以下是一些操作碼消耗 gas 的說明:
sstore當使用這個操作碼在一個新的位址中存入資料時消耗 20000 gassstore當使用這個操作碼在一個已有的位址中存入資料時消耗 5000 gassload當使用這個操作碼從 World State 讀取數據,消耗 500 gas- 其餘的操作碼消耗 3 到 10 gas
所以在兩個例子中我們消耗的 gas 分別為:
- 20000 + 20000 = 40000
- 500 + 20000 + 5000 + 500 = 26000
在打包儲存的情況下,因為我們第二次使用sstore時,只是往已有的位址中再次寫入數據,所以我們省掉了 15000 的 gas。正是因為這個原因,虛擬機器寧願編譯出如此複雜的字節碼,也不願意直接使用來個儲存位址。
編譯最佳化
其實上述字節碼還是略顯冗長,因為很容易想到,我們其實可以在內存裡面先準備好a和b對應的數據,然後在一次性的存到 World State 裡面,這樣一來我們還可以再節省掉第二個sstore所消耗的 5000gas。我們可以透過指示編譯器優化字節碼的方式來達到這個目的。在之前講到的編譯工具裡面,讓編譯器優化程式碼的方法分別為:
solc --bin --asm --optimize file_name.sol- http://remix.ethereum.org 勾選
enable-Optimization選項
我們現在再進行一次編譯,看看結果會如何
60 00 PUSH 0x0
80 DUP1
54 SLOAD
70 0200000000000000000000000000000000 PUSH17
/* not(sub(exp(0x2, 0x80), 0x1)) 高16字节bitmask */
60 01 PUSH 0x1
60 80 PUSH 0x80
60 02 PUSH 0x2
0a EXP
03 SUB
19 NOT
90 SWAP1
91 SWAP2
16 AND
60 01 PUSH 0x1
17 OR
/* sub(exp(0x2, 0x80), 0x1) 低16字节bitmask */
60 01 PUSH 0x1
60 80 PUSH 0x80
60 02 PUSH 0x02
0a EXP
03 SUB
16 AND
17 OR
90 SWAP1
55 SSTORE從上面我們可以看出,虛擬機器透過使用 bitmask 分別將高 16 位元組和低 16 位元組賦值,而且只使用了一個sstore指令就像資料存入了 Worl State 裡面。優化目的達成!
但是,等等,為什麼要在字節碼中直接嵌入0200000000000000000000000000000000這 17 個位元組?要知道我們只要要做一個簡單運算就能得到這個數值:exp(0x2, 0x81)。 換句話說,我們其實只需要用 3 個位元組就能代表這 17 個位元組,但虛擬機器為什麼沒有這麼做呢?答案很簡單,還是 gas。讓我們來看看每個位元組消耗 gas 的規則:
- 每一個 0 位元組消耗 4 個 gas
- 每一個非 0 位元組消耗 68gas
根據這個規則,我們很容易計算出兩種情況下消耗的 gas 的值:
- 68 + 16 x 4 = 132
- 68 x 3 = 204
所以直接嵌入0200000000000000000000000000000000雖然顯得笨拙,但貴在便宜。虛擬機器寧願增加字節碼的大小也想為使用者節約每一個 gas。
總結
好了,講了這麼多,讓我們來回顧一下,做個總結。
- 智慧合約的生命週期被嚴格的劃分為兩個階段:部署時和運行時。
- 智慧合約的建構函式在且僅在部署時運行,一旦部署就不可能再次運行建構函式了。
- World State 是一個鍵值對,每一個鍵對應一個 32 個位元組長的資料塊。
- 因為上面一點,以太坊虛擬機是一個 256 位元機,其天生就是用來對 32 位元組長的資料做運算的。
- 往 World State 裡面存資料是非常昂貴的。
- 以太坊虛擬機器一切向金錢看,所有的優化都是圍繞著減少所需 gas 而進行的。
下期預告
我們已經知道了沒有參數的建構子是怎麼以字節碼的形式執行的了,那麼有參數的建構子呢?部署完一個合約之後,怎麼呼叫其上的函數呢?什麼是 ABI Encoding?在下一期深入解說 EVM 的部落格中,我們會一一為你解答這些問題。