深入了解以太坊虛擬機第2部分——固定長度數(shù)據(jù)類型的表示方法

Micron MT4C1024

本文由幣乎社區(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

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

構(gòu)建一個通用計算機器需要兩個基本要素:

  • 一種循環(huán)的方式,無論是跳轉(zhuǎn)還是遞歸
  • 無限量的內(nèi)存

EVM的匯編代碼有跳轉(zhuǎn),EVM的存儲器提供無限的內(nèi)存。這對于一切就已經(jīng)足夠了,包括模擬一個運行以太坊的世界,這個世界本身就是一個模擬運行以太坊的世界.........

進(jìn)入Microverse電池

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;
    }
}

存儲器中的布局很簡單。

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

關(guān)鍵問題是:如果我們只使用f,我們需要為ab,cd,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ù)sstoresload指令的條數(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對sloadsstore指令。

快速的看一下匯編代碼,可以發(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)化器。

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

本系列文章其他部分譯文鏈接:

翻譯作者: 許莉
原文地址:Diving Into The Ethereum VM Part Two

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容