1. 程式人生 > >深入瞭解以太坊虛擬機器第3部分——動態資料型別的表示方法

深入瞭解以太坊虛擬機器第3部分——動態資料型別的表示方法

Solidity提供了在其他程式語言常見的資料型別。除了簡單的值型別比如數字和結構體,還有一些其他資料型別,隨著資料的增加可以進行動態擴充套件的動態型別。動態型別的3大類:

  • 對映(Mappings):mapping(bytes32 => uint256)mapping(address => string)等等
  • 陣列(Arrays):[]uint256[]byte等等
  • 位元組陣列(Byte arrays):只有兩種型別:stringbytes

在本系列的第二篇文章中我們看見了固定大小的簡單型別在記憶體中的表示方式。

  • 基本數值:uint256
    byte等等
  • 定長陣列:[10]uint8[32]bytebytes32
  • 組合了上面型別的結構體

固定大小的儲存變數都是儘可能的打包成32位元組的塊然後依次存放在儲存器中的。(如果這看起來很陌生,請閱讀本系列的第二篇文章: 固定長度資料型別的表示方法

在本文中我們將會研究Solidity是如何支援更加複雜的資料結構的。在表面上看可能Solidity中的陣列和對映比較熟悉,但是從它們的實現方式來看在本質上卻有著不同的效能特徵。

我們會從對映開始,這是三者當中最簡單的。陣列和位元組陣列其實就是擁有更加高階特徵的對映。

對映

讓我們儲存一個數值在uint256 => uint256

對映中:

pragma solidity ^0.4.11;
contract C {
    mapping(uint256 => uint256) items;
    function C() { items[0xC0FEFE] = 0x42; } } 

編譯:

solc --bin --asm --optimize c-mapping.sol

彙編程式碼:

tag_2:
  // 不做任何事情,應該會被優化掉
  0xc0fefe
  0x0
  swap1
  dup2
  mstore
  0x20
  mstore
  // 將0x42 儲存在地址0x798...187c上 0x42 0x79826054ee948a209ff4a6c9064d7398508d2c1909a392f899d301c6d232187c sstore 

我們可以將EVM想成一個鍵-值( key-value)資料庫,不過每個key都限制為32位元組。與其直接使用key0xC0FEFE,不如使用key的雜湊值0x798...187c,並且0x42儲存在這裡。雜湊函式使用的是keccak256(SHA256)函式。

在這個例子中我們沒有看見keccak256指令本身,因為優化器已經提前計算了結果並內聯到了位元組碼中。在沒什麼作用的mstore指令中,我們依然可以看到計算的痕跡。

計算地址

使用一些Python程式碼來把0xC0FEFE雜湊成0x798...187c。如果你想要跟著做下去,你需要安裝Python 3.6,或者安裝pysha3 來獲得keccak_256雜湊函式。

定義兩個協助函式:

import binascii
import sha3
#將數值轉換成32位元組陣列
def bytes32(i): return binascii.unhexlify('%064x' % i) # 計算32位元組陣列的 keccak256 雜湊值 def keccak256(x): return sha3.keccak_256(x).hexdigest() 

將數值轉換成32個位元組:

>>> bytes32(1)
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01'
>>> bytes32(0xC0FEFE) b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xc0\xfe\xfe' 

使用+操作符,將兩個位元組陣列連線起來:

>>> bytes32(1) + bytes32(2)
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02'

計算一些位元組的 keccak256 雜湊值:

>>> keccak256(bytes(1))
'bc36789e7a1e281436464229828f817d6612f7b477d66591ff96a9e064bcc98a'

現在我們可以計算0x798...187c了。

儲存變數items的位置是0x0(因為它是第一個儲存變數)。連線key0xc0fefeitems的位置來獲取地址:

# key = 0xC0FEFE, position = 0
>>> keccak256(bytes32(0xC0FEFE) + bytes32(0))
'79826054ee948a209ff4a6c9064d7398508d2c1909a392f899d301c6d232187c' 

為key計算儲存地址的公式是:

keccak256(bytes32(key) + bytes32(position))

兩個對映

我們先把公式放在這裡,後面數值儲存時需要計算會用到該公式。

假設我們的合約有兩個對映:

pragma solidity ^0.4.11;
contract C {
    mapping(uint256 => uint256) itemsA;
    mapping(uint256 => uint256) itemsB; function C() { itemsA[0xAAAA] = 0xAAAA; itemsB[0xBBBB] = 0xBBBB; } } 
  • itemsA的位置是0,key為0xAAAA
# key = 0xAAAA, position = 0
>>> keccak256(bytes32(0xAAAA) + bytes32(0))
'839613f731613c3a2f728362760f939c8004b5d9066154aab51d6dadf74733f3' 
  • itemsB的位置為1,key為0xBBBB
# key = 0xBBBB, position = 1
>>> keccak256(bytes32(0xBBBB) + bytes32(1))
'34cb23340a4263c995af18b23d9f53b67ff379ccaa3a91b75007b010c489d395' 

用編譯器來驗證一下這些計算:

$ solc --bin --asm --optimize  c-mapping-2.sol

彙編程式碼:

tag_2:
  // ... 忽略可能會被優化掉的記憶體操作
  0xaaaa
  0x839613f731613c3a2f728362760f939c8004b5d9066154aab51d6dadf74733f3
  sstore
  0xbbbb
  0x34cb23340a4263c995af18b23d9f53b67ff379ccaa3a91b75007b010c489d395 sstore 

跟期望的結果一樣。

彙編程式碼中的KECCAK256

編譯器可以提前計算key的地址是因為相關的值是常量。如果key使用的是變數,那麼雜湊就必須要在彙編程式碼中完成。現在我們無效化優化器,來看看在彙編程式碼中雜湊是如何完成的。

事實證明很容易就能讓優化器無效,只要引入一個間接的虛變數i

pragma solidity ^0.4.11;
contract C {
    mapping(uint256 => uint256) items;
    //這個變數會造成常量的優化失敗 uint256 i = 0xC0FEFE; function C() { items[i] = 0x42; } } 

變數items的位置依然是0x0,所以我們應該期待地址與之前是一樣的。

加上優化選項進行編譯,但是這次不會提前計算雜湊值:

$ solc --bin --asm --optimize  c-mapping--no-constant-folding.sol

註釋的彙編程式碼:

tag_2:
  // 載入`i` 到棧中
  sload(0x1)
    [0xC0FEFE]
  // 將key`0xC0FEFE`存放在記憶體中的0x0位置上,為雜湊做準備
  0x0 [0x0 0xC0FEFE] swap1 [0xC0FEFE 0x0] dup2 [0x0 0xC0FEFE 0x0] mstore [0x0] memory: { 0x00 => 0xC0FEFE } // 將位置 `0x0` 儲存在記憶體中的 0x20 (32)位置上,為雜湊做準備 0x20 // 32 [0x20 0x0] dup2 [0x0 0x20 0x0] swap1 [0x20 0x0 0x0] mstore [0x0] memory: { 0x00 => 0xC0FEFE 0x20 => 0x0 } // 從第0個位元組開始,雜湊在記憶體中接下來的0x40(64)個位元組 0x40 // 64 [0x40 0x0] swap1 [0x0 0x40] keccak256 [0x798...187c] // 將0x42 儲存在計算的地址上 0x42 [0x42 0x798...187c] swap1 [0x798...187c 0x42] sstore store: { 0x798...187c => 0x42 } 

mstore指令寫入32個位元組到記憶體中。記憶體操作便宜很多,只需要3 gas就可以讀取和寫入。前半部分的彙編程式碼就是通過將key和位置載入到相鄰的記憶體塊中來進行“連線”的:

 0                   31  32                 63
[    key (32 bytes) ][ position (32 bytes) ] 

然後keccak256指令雜湊記憶體中的資料。成本取決於被雜湊的資料有多少:

  • 每個SHA3操作需要支付 30 gas
  • 每個32位元組的字需要支付 6 gas

對於一個uint256型別key,gas的成本是42:30 + 6 * 2

對映大數值

每個儲存槽只能儲存32位元組。如果我們嘗試儲存一個更大一點的結構體會怎麼樣?

pragma solidity ^0.4.11;
contract C {
    mapping(uint256 => Tuple) tuples;
    struct Tuple {
      uint256 a;
      uint256 b;
      uint256 c;
    }
    function C() { tuples[0x1].a = 0x1A; tuples[0x1].b = 0x1B; tuples[0x1].c = 0x1C; } } 

編譯,你會看見3個sstore指令:

tag_2:
  //忽略未優化的程式碼
  0x1a
  0xada5013122d395ba3c54772283fb069b10426056ef8ca54750cb9bb552a59e7d
  sstore
  0x1b
  0xada5013122d395ba3c54772283fb069b10426056ef8ca54750cb9bb552a59e7e sstore 0x1c 0xada5013122d395ba3c54772283fb069b10426056ef8ca54750cb9bb552a59e7f sstore 

注意計算的地址除了最後一個數字其他都是一樣的。Tulp結構體成員是依次排列的(..7d, ..7e, ..7f)。

對映不會打包

考慮到對映的設計方式,每項需要的最小儲存空間是32位元組,即使你實際只需要儲存1個位元組:

pragma solidity ^0.4.11;
contract C {
    mapping(uint256 => uint8) items;
    function C() { items[0xA] = 0xAA; items[0xB] = 0xBB; } } 

如果一個數值大於32位元組,那麼你需要的儲存空間會以32位元組依次增加。

動態陣列是對映的升級

在典型語言中,陣列只是連續儲存在記憶體中一系列相同型別的元素。假設你有一個包含100個uint8型別的元素陣列,那麼這就會佔用100個位元組的記憶體。這種模式的話,將整個陣列載入到CPU的快取中然後迴圈遍歷每個元素會便宜一點。

對於大多數語言而言,陣列比對映都會便宜一些。不過在Solidity中,陣列是更加昂貴的對映。數組裡面的元素會按照順序排列在儲存器中:

0x290d...e563
0x290d...e564
0x290d...e565 0x290d...e566 

但是請記住,對於這些儲存槽的每次訪問實際上就像資料庫中的key-value的查詢一樣。訪問一個數組的元素跟訪問一個對映的元素是沒什麼區別的。

思考一下[]uint256型別,它本質上與mapping(uint256 => uint256)是一樣的,只不過後者多了一點特徵,讓它看起去就像陣列一樣。

  • length表示一共有多少個元素
  • 邊界檢查。當讀取或寫入時索引值大於length就會報錯
  • 比對映更加複雜的儲存打包行為
  • 當陣列變小時,自動清除未使用的儲存槽
  • bytesstring的特殊優化讓短陣列(小於32位元組)儲存更加高效

簡單陣列

看一下儲存3個元素的陣列:

// c-darray.sol
pragma solidity ^0.4.11;
contract C {
    uint256[] chunks;
    function C() { chunks.push(0xAA); chunks.push(0xBB); chunks.push(0xCC); } } 

陣列訪問的彙編程式碼難以追蹤,使用Remix偵錯程式來執行合約:

   

 

模擬的最後,我們可以看到有4個儲存槽被使用了:

key: 0x0000000000000000000000000000000000000000000000000000000000000000
value: 0x0000000000000000000000000000000000000000000000000000000000000003
key: 0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563
value: 0x00000000000000000000000000000000000000000000000000000000000000aa
key: 0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e564
value: 0x00000000000000000000000000000000000000000000000000000000000000bb
key: 0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e565
value: 0x00000000000000000000000000000000000000000000000000000000000000cc

chunks變數的位置是0x0,用來儲存陣列的長度(0x3),雜湊變數的位置來找到儲存陣列資料的地址:

# position = 0
>>> keccak256(bytes32(0))
'290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563'

在這個地址上陣列的每個元素依次排列(0x29..630x29..640x29..65)。

動態資料打包

所有重要的打包行為是什麼樣的?陣列與對映比較,陣列的一個優勢就是打包。擁有4個元素的uint128[]陣列元素剛剛好需要2個儲存槽(再加1個儲存槽用來儲存長度)。

思考一下:

pragma solidity ^0.4.11;
contract C {
    uint128[] s;
    function C() { s.length = 4; s[0] = 0xAA; s[1] = 0xBB; s[2] = 0xCC; s[3] = 0xDD; } } 

在Remix中執行這個程式碼,儲存器的最後看起來像這樣:

key: 0x0000000000000000000000000000000000000000000000000000000000000000
value: 0x0000000000000000000000000000000000000000000000000000000000000004
key: 0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563
value: 0x000000000000000000000000000000bb000000000000000000000000000000aa
key: 0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e564
value: 0x000000000000000000000000000000dd000000000000000000000000000000cc

只有三個儲存槽被使用了,跟預料的一樣。長度再次儲存在儲存變數的0x0位置上。4個元素被打包放入兩個獨立的儲存槽中。該陣列的開始地址是變數位置的雜湊值:

# position = 0
>>> keccak256(bytes32(0))
'290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563'

現在的地址是每兩個陣列元素增加一次,看起來很好!

但是彙編程式碼本身優化的並不好。因為使用了兩個儲存槽,所以我們會希望優化器使用兩個sstore指令來完成任務。不幸的是,由於邊界檢查(和一些其他因素),所以沒有辦法將sstore指令優化掉。

使用4個sstore指令才能完成任務:

/* "c-bytes--sstore-optimize-fail.sol":105:116  s[0] = 0xAA */
sstore
/* "c-bytes--sstore-optimize-fail.sol":126:137  s[1] = 0xBB */
sstore
/* "c-bytes--sstore-optimize-fail.sol":147:158  s[2] = 0xCC */
sstore
/* "c-bytes--sstore-optimize-fail.sol":168:179  s[3] = 0xDD */
sstore

位元組陣列和字串

bytesstring是為位元組和字元進行優化的特殊陣列型別。如果陣列的長度小於31位元組,只需要1個儲存槽來儲存整個陣列。長一點的位元組陣列跟正常陣列的表示方式差不多。

看看短一點的位元組陣列:

// c-bytes--long.sol
pragma solidity ^0.4.11;
contract C {
    bytes s;
    function C() { s.push(0xAA); s.push(0xBB); s.push(0xCC); } } 

因為陣列只有3個位元組(小於31位元組),所以它只佔用1個儲存槽。在Remix中執行,儲存看起來如下:

key: 0x0000000000000000000000000000000000000000000000000000000000000000
value: 0xaabbcc0000000000000000000000000000000000000000000000000000000006

資料0xaabbcc...從左到右的進行儲存。後面的0是空資料。最後的0x06位元組是陣列的編碼長度。公式是長度=編碼長度/2,在這個例子中實際長度是6/2=3

stringbytes的原理一模一樣。

長位元組陣列

如果資料的長度大於31位元組,位元組陣列就跟[]byte一樣。來看一下長度為128位元組的位元組陣列:

// c-bytes--long.sol
pragma solidity ^0.4.11;
contract C {
    bytes s;
    function C() { s.length = 32 * 4; s[31] = 0x1; s[63] = 0x2; s[95] = 0x3; s[127] = 0x4; } } 

在Remix中執行,可以看見使用了4個儲存槽:

0x0000...0000
0x0000...0101
0x290d...e563 0x0000...0001 0x290d...e564 0x0000...0002 0x290d...e565 0x0000...0003 0x290d...e566 0x0000...0004 

0x0的儲存槽不再用來儲存資料,整個儲存槽現在儲存編碼的陣列長度。要獲得實際長度,使用長度=(編碼長度-1)/2公式。在這個例子中長度是(0x101 - 1)/2=128。實際的位元組被儲存在0x290d...e563,並且儲存槽是連續的。

位元組陣列的彙編程式碼相當多。除了正常的邊界檢查和陣列恢復大小等,它還需要對長度進行編碼/解碼,以及注意長位元組陣列和短位元組陣列之間的轉換。

為什麼要編碼長度?因為編碼之後,可以很容易的測試出來位元組陣列是長還是短。注意對於長陣列而言編碼長度總是奇數,而短陣列的編碼長度總是偶數。彙編程式碼只需要檢視一下最後一位是否為0,為0就是偶數(短陣列),非0就是奇數(長陣列)。

總結

檢視Solidity編譯器的內部工作,可以看見熟悉的資料結構例如對映和陣列與傳統程式語言完全不同。

概括:

  • 陣列跟對映一樣,非高效
  • 比對映的彙編程式碼更加複雜
  • 小型別(byteuint8string)時儲存比對映高效
  • 彙編程式碼優化的不是很好。即使是打包,每個任務都會有一個sstore指令

EVM的儲存器就是一個鍵值資料庫,跟git很像。如果你改變了任一東西,根節點的校驗和也會改變。如果兩個根節點擁有相同的校驗和,儲存的資料就能保證是一樣的。

為了體會Solidity和EVM的奇特,可以想象一下在git倉庫裡數組裡面的每個元素都是它自己的檔案。當你改變數組裡一個元素的值,實際上就相當於建立了一個提交。當你迭代一個數組時,你不能一次性的載入整個陣列,你必須要到倉庫中進行查詢並分別找到每個檔案。

不僅僅這樣,每個檔案都限制到32位元組!因為我們需要將資料結構都分割成32位元組的塊,Solidity編譯器的所有邏輯和優化都是很負責的,全部在彙編的時候完成。

不過32位元組的限制是完全任意的。支援鍵值儲存的可以使用key來儲存任意型別的數值。也許未來我們新增新的EVM指令使用key來儲存任意的位元組陣列。

不過現在,EVM儲存器就是一個偽裝成32位元組陣列的鍵值資料庫。

可以看看ArrayUtils::resizeDynamicArray 來了解一下當恢復陣列大小時編譯器的動作。正常情況下資料結構都會作為語言的標準庫來完成的,但是在Solidity中嵌入到了編譯器裡面。

本系列文章其他部分譯文連結:

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