
本文由幣乎社區(qū)(bihu.com)內(nèi)容支持計劃贊助
在本系列的第一篇文章中,我們已經(jīng)看到了一個簡單的Solidity合約的匯編代碼:
contract C {
uint256 a;
function C() {
a = 1;
}
}
該合約歸結(jié)于sstore指令的調(diào)用:
// a = 1
sstore(0x0, 0x1)
- EVM將
0x1數(shù)值存儲在0x0的位置上 - 每個存儲槽可以存儲正好32字節(jié)(或256位)
如果你覺得這看起來很陌生,我建議你閱讀本系列的第一篇文章:EVM匯編代碼的介紹
在本文中我們將會開始研究Solidity如何使用32字節(jié)的塊來表示更加復(fù)雜的數(shù)據(jù)類型如結(jié)構(gòu)體和數(shù)組。我們也將會看到存儲是如何被優(yōu)化的,以及優(yōu)化是如何失敗的。
在典型編程語言中理解數(shù)據(jù)類型在底層是如何表示的沒有太大的作用。但是在Solidity(或其他的EVM語言)中,這個知識點是非常重要的,因為存儲的訪問是非常昂貴的:
-
sstore指令成本是20000 gas,或比基本的算術(shù)指令要貴~5000x -
sload指令成本是 200 gas,或比基本的算術(shù)指令要貴~100x
這里說的成本,就是真正的金錢,而不僅僅是毫秒級別的性能。運行和使用合約的成本基本上是由sstore指令和sload指令來主導(dǎo)的!
Parsecs磁帶上的Parsecs

構(gòu)建一個通用計算機器需要兩個基本要素:
- 一種循環(huán)的方式,無論是跳轉(zhuǎn)還是遞歸
- 無限量的內(nèi)存
EVM的匯編代碼有跳轉(zhuǎn),EVM的存儲器提供無限的內(nèi)存。這對于一切就已經(jīng)足夠了,包括模擬一個運行以太坊的世界,這個世界本身就是一個模擬運行以太坊的世界.........

EVM的存儲器對于合約來說就像一個無限的自動收報機磁帶,磁帶上的每個槽都能存儲32個字節(jié),就像這樣:
[32 bytes][32 bytes][32 bytes]...
我們將會看到數(shù)據(jù)是如何在無限的磁帶中生存的。
磁帶的長度是22??,或者每個合約~10??存儲槽??捎^測的宇宙粒子數(shù)是10??。大概1000個合約就可以容納所有的質(zhì)子、中子和電子。不要相信營銷炒作,因為它比無窮大要短的多。
空磁帶
存儲器初始的時候是空白的,默認(rèn)是0。擁有無限的磁帶不需要任何的成本。
以一個簡單的合約來演示一下0值的行為:
pragma solidity ^0.4.11;
contract C {
uint256 a;
uint256 b;
uint256 c;
uint256 d;
uint256 e;
uint256 f;
function C() {
f = 0xc0fefe;
}
}
存儲器中的布局很簡單。
- 變量
a在0x0的位置上 - 變量
b在0x1的位置上 - 以此類推.........
關(guān)鍵問題是:如果我們只使用f,我們需要為a,b,c,d,e支付多少成本?
編譯一下再看:
$ solc --bin --asm --optimize c-many-variables.sol
匯編代碼:
// sstore(0x5, 0xc0fefe)
tag_2:
0xc0fefe
0x5
sstore
所以一個存儲變量的聲明不需要任何成本,因為沒有初始化的必要。Solidity為存儲變量保留了位置,但是只有當(dāng)你存儲數(shù)據(jù)進(jìn)去的時候才需要進(jìn)行付費。
這樣的話,我們只需要為存儲0x5進(jìn)行付費。
如果我們手動編寫匯編代碼的話,我們可以選擇任意的存儲位置,而用不著"擴展"存儲器:
// 編寫一個任意的存儲位置
sstore(0xc0fefe, 0x42)
讀取零
你不僅可以寫在存儲器的任意位置,你還可以立刻讀取任意的位置。從一個未初始化的位置讀取只會返回0x0。
讓我們看看一個合約從一個未初始化的位置a讀取數(shù)據(jù):
pragma solidity ^0.4.11;
contract C {
uint256 a;
function C() {
a = a + 1;
}
}
編譯:
$ solc --bin --asm --optimize c-zero-value.sol
匯編代碼:
tag_2:
// sload(0x0) returning 0x0
0x0
dup1
sload
// a + 1; where a == 0
0x1
add
// sstore(0x0, a + 1)
swap1
sstore
注意生成從一個未初始化的位置sload的代碼是無效的。
然而,我們可以比Solidity編譯器聰明。既然我們知道tag_2是構(gòu)造器,而且a從未被寫入過數(shù)據(jù),那么我們可以用0x0替換掉sload,以此節(jié)省5000 gas。
結(jié)構(gòu)體的表示
來看一下我們的第一個復(fù)雜數(shù)據(jù)類型,一個擁有6個域的結(jié)構(gòu)體:
pragma solidity ^0.4.11;
contract C {
struct Tuple {
uint256 a;
uint256 b;
uint256 c;
uint256 d;
uint256 e;
uint256 f;
}
Tuple t;
function C() {
t.f = 0xC0FEFE;
}
}
存儲器中的布局和狀態(tài)變量是一樣的:
-
t.a域在0x0的位置上 -
t.b域在0x1的位置上 - 以此類推.........
就像之前一樣,我們可以直接寫入t.f而不用為初始化付費。
編譯一下:
$ solc --bin --asm --optimize c-struct-fields.sol
然后我們看見一模一樣的匯編代碼:
tag_2:
0xc0fefe
0x5
sstore
固定長度數(shù)組
讓我們來聲明一個定長數(shù)組:
pragma solidity ^0.4.11;
contract C {
uint256[6] numbers;
function C() {
numbers[5] = 0xC0FEFE;
}
}
因為編譯器知道這里到底有幾個uint256(32字節(jié))類型的數(shù)值,所以它可以很容易讓數(shù)組里面的元素依次存儲起來,就像它存儲變量和結(jié)構(gòu)體一樣。
在這個合約中,我們再次存儲到0x5的位置上。
編譯:
$ solc --bin --asm --optimize c-static-array.sol
匯編代碼:
tag_2:
0xc0fefe
0x0
0x5
tag_4:
add
0x0
tag_5:
pop
sstore
這個稍微長一點,但是如果你仔細(xì)一點,你會看見它們其實是一樣的。我們手動的來優(yōu)化一下:
tag_2:
0xc0fefe
// 0+5. 替換為0x5
0x0
0x5
add
// 壓入棧中然后立刻出棧。沒有作用,只是移除
0x0
pop
sstore
移除掉標(biāo)記和偽指令之后,我們再次得到相同的字節(jié)碼序列:
tag_2:
0xc0fefe
0x5
sstore
數(shù)組邊界檢查
我們看到了定長數(shù)組、結(jié)構(gòu)體和狀態(tài)變量在存儲器中的布局是一樣的,但是產(chǎn)生的匯編代碼是不同的。這是因為Solidity為數(shù)組的訪問產(chǎn)生了邊界檢查代碼。
讓我們再次編譯數(shù)組合約,這次去掉優(yōu)化的選項:
$ solc --bin --asm c-static-array.sol
匯編代碼在下面已經(jīng)注釋了,并且打印出每條指令的機器狀態(tài):
tag_2:
0xc0fefe
[0xc0fefe]
0x5
[0x5 0xc0fefe]
dup1
/* 數(shù)組邊界檢查代碼 */
// 5 < 6
0x6
[0x6 0x5 0xc0fefe]
dup2
[0x5 0x6 0x5 0xc0fefe]
lt
[0x1 0x5 0xc0fefe]
// bound_check_ok = 1 (TRUE)
// if(bound_check_ok) { goto tag5 } else { invalid }
tag_5
[tag_5 0x1 0x5 0xc0fefe]
jumpi
// 測試條件為真,跳轉(zhuǎn)到 tag_5.
// `jumpi` 從棧中消耗兩項數(shù)據(jù)
[0x5 0xc0fefe]
invalid
// 數(shù)據(jù)訪問有效,繼續(xù)執(zhí)行
// stack: [0x5 0xc0fefe]
tag_5:
sstore
[]
storage: { 0x5 => 0xc0fefe }
我們現(xiàn)在已經(jīng)看見了邊界檢查代碼。我們也看見了編譯器可以對這類東西進(jìn)行一些優(yōu)化,但是不是非常完美。
在本文的后面我們將會看到數(shù)組的邊界檢查是如何干擾編譯器優(yōu)化的,比起存儲變量和結(jié)構(gòu)體,定長數(shù)組的效率更低。
打包行為
存儲是非常昂貴的(呀呀呀,這句話我已經(jīng)說了無數(shù)次了)。一個關(guān)鍵的優(yōu)化就是盡可能的將數(shù)據(jù)打包成一個32字節(jié)數(shù)值。
考慮一個有4個存儲變量的合約,每個變量都是64位,全部加起來就是256位(32字節(jié)):
pragma solidity ^0.4.11;
contract C {
uint64 a;
uint64 b;
uint64 c;
uint64 d;
function C() {
a = 0xaaaa;
b = 0xbbbb;
c = 0xcccc;
d = 0xdddd;
}
}
我們期望(希望)編譯器使用一個sstore指令將這些數(shù)據(jù)存放到同一個存儲槽中。
編譯:
$ solc --bin --asm --optimize c-many-variables--packing.sol
匯編代碼:
tag_2:
/* "c-many-variables--packing.sol":121:122 a */
0x0
/* "c-many-variables--packing.sol":121:131 a = 0xaaaa */
dup1
sload
/* "c-many-variables--packing.sol":125:131 0xaaaa */
0xaaaa
not(0xffffffffffffffff)
/* "c-many-variables--packing.sol":121:131 a = 0xaaaa */
swap1
swap2
and
or
not(sub(exp(0x2, 0x80), exp(0x2, 0x40)))
/* "c-many-variables--packing.sol":139:149 b = 0xbbbb */
and
0xbbbb0000000000000000
or
not(sub(exp(0x2, 0xc0), exp(0x2, 0x80)))
/* "c-many-variables--packing.sol":157:167 c = 0xcccc */
and
0xcccc00000000000000000000000000000000
or
sub(exp(0x2, 0xc0), 0x1)
/* "c-many-variables--packing.sol":175:185 d = 0xdddd */
and
0xdddd000000000000000000000000000000000000000000000000
or
swap1
sstore
這里還是有很多的位轉(zhuǎn)移我沒能弄明白,但是無所謂。最關(guān)鍵事情是這里只有一個sstore指令。
這樣優(yōu)化就成功!
干擾優(yōu)化器
優(yōu)化器并不能一直工作的這么好。讓我們來干擾一下優(yōu)化器。唯一的改變就是使用協(xié)助函數(shù)來設(shè)置存儲變量:
pragma solidity ^0.4.11;
contract C {
uint64 a;
uint64 b;
uint64 c;
uint64 d;
function C() {
setAB();
setCD();
}
function setAB() internal {
a = 0xaaaa;
b = 0xbbbb;
}
function setCD() internal {
c = 0xcccc;
d = 0xdddd;
}
}
編譯:
$ solc --bin --asm --optimize c-many-variables--packing-helpers.sol
輸出的匯編代碼太多了,我們忽略了大多數(shù)的細(xì)節(jié),只關(guān)注結(jié)構(gòu)體:
// 構(gòu)造器函數(shù)
tag_2:
// ...
// 通過跳到tag_5來調(diào)用setAB()
jump
tag_4:
// ...
//通過跳到tag_7來調(diào)用setCD()
jump
// setAB()函數(shù)
tag_5:
// 進(jìn)行位轉(zhuǎn)移和設(shè)置a,b
// ...
sstore
tag_9:
jump // 返回到調(diào)用setAB()的地方
//setCD()函數(shù)
tag_7:
// 進(jìn)行位轉(zhuǎn)移和設(shè)置c,d
// ...
sstore
tag_10:
jump // 返回到調(diào)用setCD()的地方
現(xiàn)在這里有兩個sstore指令而不是一個。Solidity編譯器可以優(yōu)化一個標(biāo)簽內(nèi)的東西,但是無法優(yōu)化跨標(biāo)簽的。
調(diào)用函數(shù)會讓你消耗更多的成本,不是因為函數(shù)調(diào)用昂貴(他們只是一個跳轉(zhuǎn)指令),而是因為sstore指令的優(yōu)化可能會失敗。
為了解決這個問題,Solidity編譯器應(yīng)該學(xué)會如何內(nèi)聯(lián)函數(shù),本質(zhì)上就是不用調(diào)用函數(shù)也能得到相同的代碼:
a = 0xaaaa;
b = 0xbbbb;
c = 0xcccc;
d = 0xdddd;
如果我們仔細(xì)閱讀輸出的完整匯編代碼,我們會看見
setAB()和setCD()函數(shù)的匯編代碼被包含了兩次,不僅使代碼變得臃腫了,并且還需要花費額外的gas來部署合約。在學(xué)習(xí)合約的生命周期時我們再來談?wù)勥@個問題。
為什么優(yōu)化器會被干擾?
因為優(yōu)化器不會跨標(biāo)簽進(jìn)行優(yōu)化。思考一下"1+1",在同一個標(biāo)簽下,它會被優(yōu)化成0x2:
// 優(yōu)化成功!
tag_0:
0x1
0x1
add
...
但是如果指令被標(biāo)簽分開的話就不會被優(yōu)化了:
// 優(yōu)化失??!
tag_0:
0x1
0x1
tag_1:
add
...
在0.4.13版本中上面的行為都是真實的。也許未來會改變。
再次干擾優(yōu)化器
讓我們看看優(yōu)化器失敗的另一種方式,打包適用于定長數(shù)組嗎?思考一下:
pragma solidity ^0.4.11;
contract C {
uint64[4] numbers;
function C() {
numbers[0] = 0x0;
numbers[1] = 0x1111;
numbers[2] = 0x2222;
numbers[3] = 0x3333;
}
}
再一次,這里有4個64位的數(shù)值我們希望能打包成一個32位的數(shù)值,只使用一個sstore指令。
編譯的匯編代碼太長了,我們就數(shù)數(shù)sstore和sload指令的條數(shù):
$ solc --bin --asm --optimize c-static-array--packing.sol | grep -E '(sstore|sload)'
sload
sstore
sload
sstore
sload
sstore
sload
sstore
哦,不!即使定長數(shù)組與等效的結(jié)構(gòu)體和存儲變量的存儲布局是一樣的,優(yōu)化也失敗了?,F(xiàn)在需要4對sload和sstore指令。
快速的看一下匯編代碼,可以發(fā)現(xiàn)每個數(shù)組的訪問都有一個邊界檢查代碼,它們在不同的標(biāo)簽下被組織起來。優(yōu)化無法跨標(biāo)簽,所以優(yōu)化失敗。
不過有個小安慰。其他額外的3個sstore指令比第一個要便宜:
-
sstore指令第一次寫入一個新位置需要花費 20000 gas -
sstore指令后續(xù)寫入一個已存在的位置需要花費 5000 gas
所以這個特殊的優(yōu)化失敗會花費我們35000 gas而不是20000 gas,多了額外的75%。
總結(jié)
如果Solidity編譯器能弄清楚存儲變量的大小,它就會將這些變量依次的放入存儲器中。如果可能的話,編譯器會將數(shù)據(jù)緊密的打包成32字節(jié)的塊。
總結(jié)一下目前我們見到的打包行為:
- 存儲變量:打包
- 結(jié)構(gòu)體:打包
- 定長數(shù)組:不打包。在理論上應(yīng)該是打包的
因為存儲器訪問的成本較高,所以你應(yīng)該將存儲變量作為自己的數(shù)據(jù)庫模式。當(dāng)寫一個合約時,做一個小實驗是比較有用的,檢測匯編代碼看看編譯器是否進(jìn)行了正確的優(yōu)化。
我們可以肯定Solidity編譯器在未來肯定會改良。對于現(xiàn)在而言,很不幸,我們不能盲目的相信它的優(yōu)化器。
它需要你真正的理解存儲變量。
本系列文章其他部分譯文鏈接:
- EVM匯編代碼的介紹(第1部分)
- 動態(tài)數(shù)據(jù)類型的表示方法(第3部分)
- ABI編碼外部方法調(diào)用的方式(第4部分)
- 一個新合約被創(chuàng)建后會發(fā)生什么(第5部分)
翻譯作者: 許莉
原文地址:Diving Into The Ethereum VM Part Two