深入了解以太坊虚拟机第2部分——固定长度数据类型的表示方法
本文由幣乎社區(bihu.com)內容支持計劃贊助
在本系列的第一篇文章中,我們已經看到了一個簡單的Solidity合約的匯編代碼:
contract C {uint256 a;function C() {a = 1;} }該合約歸結于sstore指令的調用:
// a = 1 sstore(0x0, 0x1)- EVM將0x1數值存儲在0x0的位置上
- 每個存儲槽可以存儲正好32字節(或256位)
如果你覺得這看起來很陌生,我建議你閱讀本系列的第一篇文章:EVM匯編代碼的介紹
在本文中我們將會開始研究Solidity如何使用32字節的塊來表示更加復雜的數據類型如結構體和數組。我們也將會看到存儲是如何被優化的,以及優化是如何失敗的。
在典型編程語言中理解數據類型在底層是如何表示的沒有太大的作用。但是在Solidity(或其他的EVM語言)中,這個知識點是非常重要的,因為存儲的訪問是非常昂貴的:
- sstore指令成本是20000 gas,或比基本的算術指令要貴~5000x
- sload指令成本是5000 gas,或比基本的算術指令要貴~1600x
這里說的成本,就是真正的金錢,而不僅僅是毫秒級別的性能。運行和使用合約的成本基本上是由sstore指令和sload指令來主導的!
Parsecs磁帶上的Parsecs
圖林機器,來源:http://raganwald.com/構建一個通用計算機器需要兩個基本要素:
- 一種循環的方式,無論是跳轉還是遞歸
- 無限量的內存
EVM的匯編代碼有跳轉,EVM的存儲器提供無限的內存。這對于一切就已經足夠了,包括模擬一個運行以太坊的世界,這個世界本身就是一個模擬運行以太坊的世界.........
進入Microverse電池
EVM的存儲器對于合約來說就像一個無限的自動收報機磁帶,磁帶上的每個槽都能存儲32個字節,就像這樣:
[32 bytes][32 bytes][32 bytes]...我們將會看到數據是如何在無限的磁帶中生存的。
磁帶的長度是22??,或者每個合約~10??存儲槽。可觀測的宇宙粒子數是10??。大概1000個合約就可以容納所有的質子、中子和電子。不要相信營銷炒作,因為它比無窮大要短的多。
空磁帶
存儲器初始的時候是空白的,默認是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的位置上
- 以此類推.........
關鍵問題是:如果我們只使用f,我們需要為a,b,c,d,e支付多少成本?
編譯一下再看:
$ solc --bin --asm --optimize c-many-variables.sol匯編代碼:
// sstore(0x5, 0xc0fefe) tag_2:0xc0fefe0x5sstore所以一個存儲變量的聲明不需要任何成本,因為沒有初始化的必要。Solidity為存儲變量保留了位置,但是只有當你存儲數據進去的時候才需要進行付費。
這樣的話,我們只需要為存儲0x5進行付費。
如果我們手動編寫匯編代碼的話,我們可以選擇任意的存儲位置,而用不著"擴展"存儲器:
// 編寫一個任意的存儲位置 sstore(0xc0fefe, 0x42)讀取零
你不僅可以寫在存儲器的任意位置,你還可以立刻讀取任意的位置。從一個未初始化的位置讀取只會返回0x0。
讓我們看看一個合約從一個未初始化的位置a讀取數據:
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 0x00x0dup1sload// a + 1; where a == 00x1add// sstore(0x0, a + 1)swap1sstore注意生成從一個未初始化的位置sload的代碼是無效的。
然而,我們可以比Solidity編譯器聰明。既然我們知道tag_2是構造器,而且a從未被寫入過數據,那么我們可以用0x0替換掉sload,以此節省5000 gas。
結構體的表示
來看一下我們的第一個復雜數據類型,一個擁有6個域的結構體:
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.a域在0x0的位置上
- t.b域在0x1的位置上
- 以此類推.........
就像之前一樣,我們可以直接寫入t.f而不用為初始化付費。
編譯一下:
$ solc --bin --asm --optimize c-struct-fields.sol然后我們看見一模一樣的匯編代碼:
tag_2:0xc0fefe0x5sstore固定長度數組
讓我們來聲明一個定長數組:
pragma solidity ^0.4.11; contract C {uint256[6] numbers;function C() {numbers[5] = 0xC0FEFE;} }因為編譯器知道這里到底有幾個uint256(32字節)類型的數值,所以它可以很容易讓數組里面的元素依次存儲起來,就像它存儲變量和結構體一樣。
在這個合約中,我們再次存儲到0x5的位置上。
編譯:
$ solc --bin --asm --optimize c-static-array.sol匯編代碼:
tag_2:0xc0fefe0x00x5 tag_4:add0x0 tag_5:popsstore這個稍微長一點,但是如果你仔細一點,你會看見它們其實是一樣的。我們手動的來優化一下:
tag_2:0xc0fefe// 0+5. 替換為0x50x00x5add// 壓入棧中然后立刻出棧。沒有作用,只是移除0x0popsstore移除掉標記和偽指令之后,我們再次得到相同的字節碼序列:
tag_2:0xc0fefe0x5sstore數組邊界檢查
我們看到了定長數組、結構體和狀態變量在存儲器中的布局是一樣的,但是產生的匯編代碼是不同的。這是因為Solidity為數組的訪問產生了邊界檢查代碼。
讓我們再次編譯數組合約,這次去掉優化的選項:
$ solc --bin --asm c-static-array.sol匯編代碼在下面已經注釋了,并且打印出每條指令的機器狀態:
tag_2:0xc0fefe[0xc0fefe]0x5[0x5 0xc0fefe]dup1/* 數組邊界檢查代碼 */// 5 < 60x6[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// 測試條件為真,跳轉到 tag_5.// `jumpi` 從棧中消耗兩項數據[0x5 0xc0fefe]invalid // 數據訪問有效,繼續執行 // stack: [0x5 0xc0fefe] tag_5:sstore[]storage: { 0x5 => 0xc0fefe }我們現在已經看見了邊界檢查代碼。我們也看見了編譯器可以對這類東西進行一些優化,但是不是非常完美。
在本文的后面我們將會看到數組的邊界檢查是如何干擾編譯器優化的,比起存儲變量和結構體,定長數組的效率更低。
打包行為
存儲是非常昂貴的(呀呀呀,這句話我已經說了無數次了)。一個關鍵的優化就是盡可能的將數據打包成一個32字節數值。
考慮一個有4個存儲變量的合約,每個變量都是64位,全部加起來就是256位(32字節):
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指令將這些數據存放到同一個存儲槽中。
編譯:
$ 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 */dup1sload/* "c-many-variables--packing.sol":125:131 0xaaaa */0xaaaanot(0xffffffffffffffff)/* "c-many-variables--packing.sol":121:131 a = 0xaaaa */swap1swap2andornot(sub(exp(0x2, 0x80), exp(0x2, 0x40)))/* "c-many-variables--packing.sol":139:149 b = 0xbbbb */and0xbbbb0000000000000000ornot(sub(exp(0x2, 0xc0), exp(0x2, 0x80)))/* "c-many-variables--packing.sol":157:167 c = 0xcccc */and0xcccc00000000000000000000000000000000orsub(exp(0x2, 0xc0), 0x1)/* "c-many-variables--packing.sol":175:185 d = 0xdddd */and0xdddd000000000000000000000000000000000000000000000000orswap1sstore這里還是有很多的位轉移我沒能弄明白,但是無所謂。最關鍵事情是這里只有一個sstore指令。
這樣優化就成功!
干擾優化器
優化器并不能一直工作的這么好。讓我們來干擾一下優化器。唯一的改變就是使用協助函數來設置存儲變量:
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輸出的匯編代碼太多了,我們忽略了大多數的細節,只關注結構體:
// 構造器函數 tag_2:// ...// 通過跳到tag_5來調用setAB()jump tag_4:// ...//通過跳到tag_7來調用setCD() jump // setAB()函數 tag_5:// 進行位轉移和設置a,b// ...sstore tag_9:jump // 返回到調用setAB()的地方 //setCD()函數 tag_7:// 進行位轉移和設置c,d// ...sstore tag_10:jump // 返回到調用setCD()的地方現在這里有兩個sstore指令而不是一個。Solidity編譯器可以優化一個標簽內的東西,但是無法優化跨標簽的。
調用函數會讓你消耗更多的成本,不是因為函數調用昂貴(他們只是一個跳轉指令),而是因為sstore指令的優化可能會失敗。
為了解決這個問題,Solidity編譯器應該學會如何內聯函數,本質上就是不用調用函數也能得到相同的代碼:
a = 0xaaaa; b = 0xbbbb; c = 0xcccc; d = 0xdddd;如果我們仔細閱讀輸出的完整匯編代碼,我們會看見setAB()和setCD()函數的匯編代碼被包含了兩次,不僅使代碼變得臃腫了,并且還需要花費額外的gas來部署合約。在學習合約的生命周期時我們再來談談這個問題。
為什么優化器會被干擾?
因為優化器不會跨標簽進行優化。思考一下"1+1",在同一個標簽下,它會被優化成0x2:
// 優化成功! tag_0:0x10x1add...但是如果指令被標簽分開的話就不會被優化了:
// 優化失敗! tag_0:0x10x1 tag_1:add...在0.4.13版本中上面的行為都是真實的。也許未來會改變。
再次干擾優化器
讓我們看看優化器失敗的另一種方式,打包適用于定長數組嗎?思考一下:
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位的數值我們希望能打包成一個32位的數值,只使用一個sstore指令。
編譯的匯編代碼太長了,我們就數數sstore和sload指令的條數:
$ solc --bin --asm --optimize c-static-array--packing.sol | grep -E '(sstore|sload)'sloadsstoresloadsstoresloadsstoresloadsstore哦,不!即使定長數組與等效的結構體和存儲變量的存儲布局是一樣的,優化也失敗了。現在需要4對sload和sstore指令。
快速的看一下匯編代碼,可以發現每個數組的訪問都有一個邊界檢查代碼,它們在不同的標簽下被組織起來。優化無法跨標簽,所以優化失敗。
不過有個小安慰。其他額外的3個sstore指令比第一個要便宜:
- sstore指令第一次寫入一個新位置需要花費 20000 gas
- sstore指令后續寫入一個已存在的位置需要花費 5000 gas
所以這個特殊的優化失敗會花費我們35000 gas而不是20000 gas,多了額外的75%。
總結
如果Solidity編譯器能弄清楚存儲變量的大小,它就會將這些變量依次的放入存儲器中。如果可能的話,編譯器會將數據緊密的打包成32字節的塊。
總結一下目前我們見到的打包行為:
- 存儲變量:打包
- 結構體:打包
- 定長數組:不打包。在理論上應該是打包的
因為存儲器訪問的成本較高,所以你應該將存儲變量作為自己的數據庫模式。當寫一個合約時,做一個小實驗是比較有用的,檢測匯編代碼看看編譯器是否進行了正確的優化。
我們可以肯定Solidity編譯器在未來肯定會改良。對于現在而言,很不幸,我們不能盲目的相信它的優化器。
它需要你真正的理解存儲變量。
本系列文章其他部分譯文鏈接:
- EVM匯編代碼的介紹(第1部分)
- 動態數據類型的表示方法(第3部分)
- ABI編碼外部方法調用的方式(第4部分)
- 一個新合約被創建后會發生什么(第5部分)
翻譯作者: 許莉
原文地址:Diving Into The Ethereum VM Part Two
作者:Lilymoana
鏈接:https://www.jianshu.com/p/9df8d15418ed
來源:簡書
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。
總結
以上是生活随笔為你收集整理的深入了解以太坊虚拟机第2部分——固定长度数据类型的表示方法的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 以太坊的工作原理
- 下一篇: 深入了解以太坊虚拟机第3部分——动态数据