1. 程式人生 > >深入了解以太坊虛擬機第3部分——動態數據類型的表示方法

深入了解以太坊虛擬機第3部分——動態數據類型的表示方法

值類型 大於 tor a20 UNC 很多 posit res link

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

  • 映射(Mappings):mapping(bytes32 => uint256)mapping(address => string)等等
  • 數組(Arrays):[]uint256[]byte等等
  • 字節數組(Byte arrays):只有兩種類型:stringbytes

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

  • 基本數值:uint256byte等等
  • 定長數組:[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中嵌入到了編譯器裏面。

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

  • EVM匯編代碼的介紹(第1部分)
  • 固定長度數據類型的表示方法(第2部分)
  • ABI編碼外部方法調用的方式(第4部分)
  • 一個新合約被創建後會發生什麽(第5部分)

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


深入了解以太坊虛擬機第3部分——動態數據類型的表示方法