Solidity提供了很多高級語言的抽象概念,但是這些特性讓人很難明白在運行程序的時候到底發生了什麼。我閱讀了Solidity的文檔,但依舊存在着幾個基本的問題沒有弄明白。
string, bytes32, byte[], bytes之間的區別是什麼?
- 該在什麼地方使用哪個類型?
- 將 string 轉換成bytes時會怎麼樣?可以轉換成byte[]嗎?
- 它們的存儲成本是多少?
EVM是如何存儲映射( mappings)的?
- 爲什麼不能刪除一個映射?
- 可以有映射的映射嗎?(可以,但是怎樣映射?)
- 爲什麼存在存儲映射,但是卻沒有內存映射?
編譯的合約在EVM看來是什麼樣子的?
- 合約是如何創建的?
- 到底什麼是構造器?
- 什麼是 fallback 函數?
我覺得學習在以太坊虛擬機(EVM)上運行的類似Solidity 高級語言是一種很好的投資,有幾個原因:
- Solidity不是最後一種語言。更好的EVM語言正在到來。(拜託?)
- EVM是一個數據庫引擎。要理解智能合約是如何以任意EVM語言來工作的,就必須要明白數據是如何被組織的,被存儲的,以及如何被操作的。
- 知道如何成爲貢獻者。以太坊的工具鏈還處於早期,理解EVM可以幫助你實現一個超棒的工具給自己和其他人使用。
- 智力的挑戰。EVM可以讓你有個很好的理由在密碼學、數據結構、編程語言設計的交集之間進行翱翔。
在這個系列的文章中,我會拆開一個簡單的Solidity合約,來讓大家明白它是如何以EVM字節碼(bytecode)來運行的。
我希望能夠學習以及會書寫的文章大綱:
- EVM字節碼的基礎認識
- 不同類型(映射,數組)是如何表示的
- 當一個新合約創建之後會發生什麼
- 當一個方法被調用時會發生什麼
- ABI如何橋接不同的EVM語言
我的最終目標是整體的理解一個編譯的Solidity合約。讓我們從閱讀一些基本的EVM字節碼開始。
EVM指令集將是一個比較有幫助的參考。
一個簡單的合約
我們的第一個合約有一個構造器和一個狀態變量:
// c1.sol
pragma solidity ^0.4.11;
contract C {
uint256 a;
function C() {
a = 1;
}
}
用solc
來編譯此合約:
$ solc --bin --asm c1.sol
======= c1.sol:C =======
EVM assembly:
/* "c1.sol":26:94 contract C {... */
mstore(0x40, 0x60)
/* "c1.sol":59:92 function C() {... */
jumpi(tag_1, iszero(callvalue))
0x0
dup1
revert
tag_1:
tag_2:
/* "c1.sol":84:85 1 */
0x1
/* "c1.sol":80:81 a */
0x0
/* "c1.sol":80:85 a = 1 */
dup2
swap1
sstore
pop
/* "c1.sol":59:92 function C() {... */
tag_3:
/* "c1.sol":26:94 contract C {... */
tag_4:
dataSize(sub_0)
dup1
dataOffset(sub_0)
0x0
codecopy
0x0
return
stop
sub_0: assembly {
/* "c1.sol":26:94 contract C {... */
mstore(0x40, 0x60)
tag_1:
0x0
dup1
revert
auxdata: 0xa165627a7a72305820af3193f6fd31031a0e0d2de1ad2c27352b1ce081b4f3c92b5650ca4dd542bb770029
}
Binary:
60606040523415600e57600080fd5b5b60016000819055505b5b60368060266000396000f30060606040525b600080fd00a165627a7a72305820af3193f6fd31031a0e0d2de1ad2c27352b1ce081b4f3c92b5650ca4dd542bb770029
6060604052...
這串數字就是EVM實際運行的字節碼。
一小步一小步的來
上面一半的編譯彙編是大多數Solidity程序中都會存在的樣板語句。我們稍後再來看這些。現在,我們來看看合約中獨特的部分,簡單的存儲變量賦值:
a = 1
代表這個賦值的字節碼是6001600081905550
。我們把它拆成一行一條指令:
60 01
60 00
81
90
55
50
EVM本質上就是一個循環,從上到下的執行每一條命令。讓我們用相應的字節碼來註釋彙編代碼(縮進到標籤tag_2
下),來更好的看看他們之間的關聯:
tag_2:
// 60 01
0x1
// 60 00
0x0
// 81
dup2
// 90
swap1
// 55
sstore
// 50
pop
注意0x1
在彙編代碼中實際上是push(0x1)
的速記。這條指令將數值1壓入棧中。
只是盯着它依然很難明白到底發生了什麼,不過不用擔心,一行一行的模擬EVM是比較簡單的。
模擬EVM
EVM是個堆棧機器。指令可能會使用棧上的數值作爲參數,也會將值作爲結果壓入棧中。讓我們來思考一下add
操作。
假設棧上有兩個值:
[1 2]
當EVM看見了add
,它會將棧頂的2項相加,然後將答案壓入棧中,結果是:
[3]
接下來,我們用[]
符號來標識棧:
// 空棧
stack: []
// 有3個數據的棧,棧頂項爲3,棧底項爲1
stack: [3 2 1]
用{}
符號來標識合約存儲器:
// 空存儲
store: {}
// 數值0x1被保存在0x0的位置上
store: { 0x0 => 0x1 }
現在讓我們來看看真正的字節碼。我們將會像EVM那樣來模擬6001600081905550
字節序列,並打印出每條指令的機器狀態:
// 60 01:將1壓入棧中
0x1
stack: [0x1]
// 60 00: 將0壓入棧中
0x0
stack: [0x0 0x1]
// 81: 複製棧中的第二項
dup2
stack: [0x1 0x0 0x1]
// 90: 交換棧頂的兩項數據
swap1
stack: [0x0 0x1 0x1]
// 55: 將數值0x01存儲在0x0的位置上
// 這個操作會消耗棧頂兩項數據
sstore
stack: [0x1]
store: { 0x0 => 0x1 }
// 50: pop (丟棄棧頂數據)
pop
stack: []
store: { 0x0 => 0x1 }
最後,棧就爲空棧,而存儲器裏面有一項數據。
值得注意的是Solidity已經決定將狀態變量uint256 a
保存在0x0
的位置上。其他語言完全可以選擇將狀態變量存儲在其他的任何位置上。
6001600081905550
字節序列在本質上用EVM的操作僞代碼來表示就是:
// a = 1
sstore(0x0, 0x1)
仔細觀察,你就會發現dup2
,swap1
,pop
都是多餘的,彙編代碼可以更簡單一些:
0x1
0x0
sstore
你可以模擬上面的3條指令,然後會發現他們的機器狀態結果都是一樣的:
stack: []
store: { 0x0 => 0x1 }
兩個存儲變量
讓我們再額外的增加一個相同類型的存儲變量:
// c2.sol
pragma solidity ^0.4.11;
contract C {
uint256 a;
uint256 b;
function C() {
a = 1;
b = 2;
}
}
編譯之後,主要來看tag_2
:
$ solc --bin --asm c2.sol
//前面的代碼忽略了
tag_2:
/* "c2.sol":99:100 1 */
0x1
/* "c2.sol":95:96 a */
0x0
/* "c2.sol":95:100 a = 1 */
dup2
swap1
sstore
pop
/* "c2.sol":112:113 2 */
0x2
/* "c2.sol":108:109 b */
0x1
/* "c2.sol":108:113 b = 2 */
dup2
swap1
sstore
pop
彙編的僞代碼:
// a = 1
sstore(0x0, 0x1)
// b = 2
sstore(0x1, 0x2)
我們可以看到兩個存儲變量的存儲位置是依次排列的,a
在0x0
的位置而b
在0x1
的位置。
存儲打包
每個存儲槽都可以存儲32個字節。如果一個變量只需要16個字節但是使用全部的32個字節會很浪費。Solidity爲了高效存儲,提供了一個優化方案:如果可以的話,就將兩個小一點的數據類型進行打包然後存儲在一個存儲槽中。
我們將a
和b
修改成16字節的變量:
pragma solidity ^0.4.11;
contract C {
uint128 a;
uint128 b;
function C() {
a = 1;
b = 2;
}
}
編譯此合約:
$ solc --bin --asm c3.sol
產生的彙編代碼現在更加的複雜一些:
tag_2:
// a = 1
0x1
0x0
dup1
0x100
exp
dup2
sload
dup2
0xffffffffffffffffffffffffffffffff
mul
not
and
swap1
dup4
0xffffffffffffffffffffffffffffffff
and
mul
or
swap1
sstore
pop
// b = 2
0x2
0x0
0x10
0x100
exp
dup2
sload
dup2
0xffffffffffffffffffffffffffffffff
mul
not
and
swap1
dup4
0xffffffffffffffffffffffffffffffff
and
mul
or
swap1
sstore
pop
上面的彙編代碼將這兩個變量打包放在一個存儲位置(0x0
)上,就像這樣:
[ b ][ a ]
[16 bytes / 128 bits][16 bytes / 128 bits]
進行打包的原因是因爲目前最昂貴的操作就是存儲的使用:
sstore
指令第一次寫入一個新位置需要花費20000 gassstore
指令後續寫入一個已存在的位置需要花費5000 gassload
指令的成本是500 gas- 大多數的指令成本是3~10 gas
通過使用相同的存儲位置,Solidity爲存儲第二個變量支付5000 gas,而不是20000 gas,節約了15000 gas。
更多優化
應該可以將兩個128位的數打包成一個數放入內存中,然後使用一個'sstore'指令進行存儲操作,而不是使用兩個單獨的sstore
命令來存儲變量a
和b
,這樣就額外的又省了5000 gas。
你可以通過添加optimize
選項來讓Solidity實現上面的優化:
$ solc --bin --asm --optimize c3.sol
這樣產生的彙編代碼只有一個sload
指令和一個sstore
指令:
tag_2:
/* "c3.sol":95:96 a */
0x0
/* "c3.sol":95:100 a = 1 */
dup1
sload
/* "c3.sol":108:113 b = 2 */
0x200000000000000000000000000000000
not(sub(exp(0x2, 0x80), 0x1))
/* "c3.sol":95:100 a = 1 */
swap1
swap2
and
/* "c3.sol":99:100 1 */
0x1
/* "c3.sol":95:100 a = 1 */
or
sub(exp(0x2, 0x80), 0x1)
/* "c3.sol":108:113 b = 2 */
and
or
swap1
sstore
字節碼是:
600080547002000000000000000000000000000000006001608060020a03199091166001176001608060020a0316179055
將字節碼解析成一行一指令:
// push 0x0
60 00
// dup1
80
// sload
54
// push17 將下面17個字節作爲一個32個字的數值壓入棧中
70 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
/* not(sub(exp(0x2, 0x80), 0x1)) */
// push 0x1
60 01
// push 0x80 (32)
60 80
// push 0x80 (2)
60 02
// exp
0a
// sub
03
// not
19
// swap1
90
// swap2
91
// and
16
// push 0x1
60 01
// or
17
/* sub(exp(0x2, 0x80), 0x1) */
// push 0x1
60 01
// push 0x80
60 80
// push 0x02
60 02
// exp
0a
// sub
03
// and
16
// or
17
// swap1
90
// sstore
55
上面的彙編代碼中使用了4個神奇的數值:
- 0x1(16字節),使用低16字節
// 在字節碼中表示爲0x01
16:32 0x00000000000000000000000000000000
00:16 0x00000000000000000000000000000001
- 0x2(16字節),使用高16字節
//在字節碼中表示爲0x200000000000000000000000000000000
16:32 0x00000000000000000000000000000002
00:16 0x00000000000000000000000000000000
- not(sub(exp(0x2, 0x80), 0x1))
// 高16字節的掩碼
16:32 0x00000000000000000000000000000000
00:16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
- sub(exp(0x2, 0x80), 0x1)
// 低16字節的掩碼
16:32 0x00000000000000000000000000000000
00:16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
代碼將這些數值進行了一些位的轉換來達到想要的結果:
16:32 0x00000000000000000000000000000002
00:16 0x00000000000000000000000000000001
最後,該32字節的數值被保存在了0x0
的位置上。
Gas 的使用
600080547002000000000000000000000000000000006001608060020a03199091166001176001608060020a0316179055
注意0x200000000000000000000000000000000
被嵌入到了字節碼中。但是編譯器也可能選擇使用exp(0x2, 0x81)
指令來計算數值,這會導致更短的字節碼序列。
但結果是0x200000000000000000000000000000000
比exp(0x2, 0x81)
更便宜。讓我們看看與gas費用相關的信息:
- 一筆交易的每個零字節的數據或代碼費用爲 4 gas
- 一筆交易的每個非零字節的數據或代碼的費用爲 68 gas
來計算下兩個表示方式所花費的gas成本:
-
0x200000000000000000000000000000000
字節碼包含了很多的0,更加的便宜。
(1 * 68) + (32 * 4) = 196 -
608160020a
字節碼更短,但是沒有0。
5 * 68 = 340
更長的字節碼序列有很多的0,所以實際上更加的便宜!
總結
EVM的編譯器實際上不會爲字節碼的大小、速度或內存高效性進行優化。相反,它會爲gas的使用進行優化,這間接鼓勵了計算的排序,讓以太坊區塊鏈可以更高效一點。
我們也看到了EVM一些奇特的地方:
- EVM是一個256位的機器。以32字節來處理數據是最自然的
- 持久存儲是相當昂貴的
- Solidity編譯器會爲了減少gas的使用而做出相應的優化選擇
Gas成本的設置有一點武斷,也許未來會改變。當成本改變的時候,編譯器也會做出不同的優化選擇。
本系列文章其他部分譯文鏈接:
翻譯作者: 許莉
原文地址:Diving Into The Ethereum VM Part One