深刻了解以太坊虛擬機第2部分——固定長度數據類型的表示方法

在本系列的第一篇文章中,咱們已經看到了一個簡單的Solidity合約的彙編代碼:javascript

contract C {
    uint256 a;
    function C() { a = 1; } } 

該合約歸結於sstore指令的調用:php

// a = 1 sstore(0x0, 0x1) 
  • EVM將0x1數值存儲在0x0的位置上
  • 每一個存儲槽能夠存儲正好32字節(或256位)

若是你以爲這看起來很陌生,我建議你閱讀本系列的第一篇文章:EVM彙編代碼的介紹css

在本文中咱們將會開始研究Solidity如何使用32字節的塊來表示更加複雜的數據類型如結構體和數組。咱們也將會看到存儲是如何被優化的,以及優化是如何失敗的。java

在典型編程語言中理解數據類型在底層是如何表示的沒有太大的做用。可是在Solidity(或其餘的EVM語言)中,這個知識點是很是重要的,由於存儲的訪問是很是昂貴的:數據庫

  • sstore指令成本是20000 gas,或比基本的算術指令要貴~5000x
  • sload指令成本是 200 gas,或比基本的算術指令要貴~100x

這裏說的成本,就是真正的金錢,而不只僅是毫秒級別的性能。運行和使用合約的成本基本上是由sstore指令和sload指令來主導的!編程

Parsecs磁帶上的Parsecs

 
圖林機器,來源:http://raganwald.com/

構建一個通用計算機器須要兩個基本要素:數組

  • 一種循環的方式,不管是跳轉仍是遞歸
  • 無限量的內存

EVM的彙編代碼有跳轉,EVM的存儲器提供無限的內存。這對於一切就已經足夠了,包括模擬一個運行以太坊的世界,這個世界自己就是一個模擬運行以太坊的世界.........編程語言

 
進入Microverse電池

EVM的存儲器對於合約來講就像一個無限的自動收報機磁帶,磁帶上的每一個槽都能存儲32個字節,就像這樣:函數

[32 bytes][32 bytes][32 bytes]... 

咱們將會看到數據是如何在無限的磁帶中生存的。佈局

磁帶的長度是2²⁵⁶,或者每一個合約~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; } } 

存儲器中的佈局很簡單。

  • 變量a0x0的位置上
  • 變量b0x1的位置上
  • 以此類推.........

關鍵問題是:若是咱們只使用f,咱們須要爲abcde支付多少成本?

編譯一下再看:

$ solc --bin --asm --optimize c-many-variables.sol 

彙編代碼:

// sstore(0x5, 0xc0fefe) tag_2: 0xc0fefe 0x5 sstore 

因此一個存儲變量的聲明不須要任何成本,由於沒有初始化的必要。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 0x0 0x0 dup1 sload // a + 1; where a == 0 0x1 add // sstore(0x0, a + 1) swap1 sstore 

注意生成從一個未初始化的位置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:
  0xc0fefe
  0x5
  sstore

固定長度數組

讓咱們來聲明一個定長數組:

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:
  0xc0fefe
  0x0
  0x5
tag_4:
  add
  0x0
tag_5:
  pop
  sstore

這個稍微長一點,可是若是你仔細一點,你會看見它們實際上是同樣的。咱們手動的來優化一下:

tag_2:
  0xc0fefe // 0+5. 替換爲0x5 0x0 0x5 add // 壓入棧中而後馬上出棧。沒有做用,只是移除 0x0 pop sstore 

移除掉標記和僞指令以後,咱們再次獲得相同的字節碼序列:

tag_2:
  0xc0fefe
  0x5
  sstore

數組邊界檢查

咱們看到了定長數組、結構體和狀態變量在存儲器中的佈局是同樣的,可是產生的彙編代碼是不一樣的。這是由於Solidity爲數組的訪問產生了邊界檢查代碼。

讓咱們再次編譯數組合約,此次去掉優化的選項:

$ solc --bin --asm c-static-array.sol 

彙編代碼在下面已經註釋了,而且打印出每條指令的機器狀態:

tag_2:
  0xc0fefe [0xc0fefe] 0x5 [0x5 0xc0fefe] dup1 /* 數組邊界檢查代碼 */ // 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 // 測試條件爲真,跳轉到 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 */ 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 

這裏仍是有不少的位轉移我沒能弄明白,可是無所謂。最關鍵事情是這裏只有一個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: 0x1 0x1 add ... 

可是若是指令被標籤分開的話就不會被優化了:

// 優化失敗! tag_0: 0x1 0x1 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指令。

編譯的彙編代碼太長了,咱們就數數sstoresload指令的條數:

$ solc --bin --asm --optimize c-static-array--packing.sol | grep -E '(sstore|sload)' sload sstore sload sstore sload sstore sload sstore 

哦,不!即便定長數組與等效的結構體和存儲變量的存儲佈局是同樣的,優化也失敗了。如今須要4對sloadsstore指令。

快速的看一下彙編代碼,能夠發現每一個數組的訪問都有一個邊界檢查代碼,它們在不一樣的標籤下被組織起來。優化沒法跨標籤,因此優化失敗。

不過有個小安慰。其餘額外的3個sstore指令比第一個要便宜:

  • sstore指令第一次寫入一個新位置須要花費 20000 gas
  • sstore指令後續寫入一個已存在的位置須要花費 5000 gas

因此這個特殊的優化失敗會花費咱們35000 gas而不是20000 gas,多了額外的75%。

總結

若是Solidity編譯器能弄清楚存儲變量的大小,它就會將這些變量依次的放入存儲器中。若是可能的話,編譯器會將數據緊密的打包成32字節的塊。

總結一下目前咱們見到的打包行爲:

  • 存儲變量:打包
  • 結構體:打包
  • 定長數組:不打包。在理論上應該是打包的

由於存儲器訪問的成本較高,因此你應該將存儲變量做爲本身的數據庫模式。當寫一個合約時,作一個小實驗是比較有用的,檢測彙編代碼看看編譯器是否進行了正確的優化。

咱們能夠確定Solidity編譯器在將來確定會改良。對於如今而言,很不幸,咱們不能盲目的相信它的優化器。

它須要你真正的理解存儲變量。

本系列文章其餘部分譯文連接:

原文地址:Diving Into The Ethereum VM Part Two

相關文章
相關標籤/搜索