死磕以太坊源碼分析之EVM如何調用ABI編碼的外部方法

死磕以太坊源碼分析之EVM如何調用ABI編碼的外部方法前端

配合如下代碼進行閱讀:https://github.com/blockchainGuide/java

寫文不易,給個小關注,有什麼問題能夠指出,便於你們交流學習。git

image-20210113191423657

前言

abi是什麼?
前面咱們認識到的是智能合約直接在EVM上的表示方式,可是,好比我想用java端程序去訪問智能合約的某個方法,難道讓java開發人員琢磨透彙編和二進制的表示,再去對接?
這明顯是不可能的,爲此abi產生了。這是一個通用可讀的json格式的數據,任何別的客戶端開發人員或者別的以太坊節點只要指定要調用的方法,經過abi將其解析爲字節碼並傳遞給evm,evm來計算處理該字節碼並返回結果給前端。abi就起到這麼一個做用,相似於傳統的客戶端和服務器端地址好交互規則,好比json格式的數據,而後進行交互。github

在本系列的上一篇文章中咱們看到了Solidity是如何在EVM存儲器中表示覆雜數據結構的。可是若是沒法交互,數據就是沒有意義的。智能合約就是數據和外界的中間體。web

在這篇文章中咱們將會看到SolidityEVM可讓外部程序來調用合約的方法並改變它的狀態。數據庫

「外部程序」不限於DApp/JavaScript。任何可使用HTTP RPC與以太坊節點通訊的程序,均可以經過建立一個交易與部署在區塊鏈上的任何合約進行交互。json

建立一個交易就像發送一個HTTP請求。Web的服務器會接收你的HTTP請求,而後改變數據庫。交易會被網絡接收,底層的區塊鏈會擴展到包含改變的狀態。數組

交易對於智能合約就像HTTP請求對於Web服務器。緩存

合約交易

讓咱們來看一下將狀態變量設置在0x1位置上的交易。咱們想要交互的合約有一個對變量a的設置者和獲取者:服務器

pragma solidity ^0.4.11;
contract C {
  uint256 a;
  function setA(uint256 _a) {
    a = _a;
  }
  function getA() returns(uint256) {
    return a;
  }
}

這個合約部署在Rinkeby測試網上。能夠隨意使用Etherscan,並搜索地址 0x62650ae5…進行查看。

我建立了一個能夠調用setA(1)的交易,能夠在地址0x7db471e5…上查看該交易。

交易的輸出數據是:

0xee919d500000000000000000000000000000000000000000000000000000000000000001

對於EVM而言,這只是36字節的元數據。它對元數據不會進行處理,會直接將元數據做爲calldata傳遞給智能合約。若是智能合約是個Solidity程序,那麼它會將這些輸入字節解釋爲方法調用,併爲setA(1)執行適當的彙編代碼。

輸入數據能夠分紅兩個子部分:

# 方法選擇器(4字節)
0xee919d5
#第一個參數(32字節)
00000000000000000000000000000000000000000000000000000000000000001

前面的4個字節是方法選擇器,剩下的輸入數據是方法的參數,32個字節的塊。在這個例子中,只有一個參數,值是0x1

方法選擇器是方法簽名的 kecccak256 哈希值。在這個例子中方法的簽名是setA(uint256),也就是方法名稱和參數的類型。

讓咱們用Python來計算方法選擇器。首先,哈希方法簽名:

# 安裝pyethereum [https://github.com/ethereum/pyethereum/#installation](https://github.com/ethereum/pyethereum/#installation)> from ethereum.utils import sha3> sha3("setA(uint256)").hex()'ee919d50445cd9f463621849366a537968fe1ce096894b0d0c001528383d4769'

而後獲取哈希值的前4字節:

> sha3("setA(uint256)")[0:4].hex()
'ee919d50'

應用二進制接口(ABI)

對於EVM而言,交易的輸入數據(calldata)只是一個字節序列。EVM內部不支持調用方法。

智能合約能夠選擇經過以結構化的方式處理輸入數據來模擬方法調用,就像前面所說的那樣。

若是EVM上的全部語言都贊成相同的方式解釋輸入數據,那麼它們就能夠很容易進行交互。 合約應用二進制接口(ABI)指定了一個通用的編碼模式。

咱們已經看到了ABI是如何編碼一個簡單的方法調用,例如SetA(1)。在後面章節中咱們將會看到方法調用和更復雜的參數是如何編碼的。

調用一個獲取者

若是你調用的方法改變了狀態,那麼整個網絡必需要贊成。這就須要有交易,並消耗gas。

一個獲取者如getA()不會改變任何東西。咱們能夠將方法調用發送到本地的以太坊節點,而不用請求整個網絡來執行計算。一個eth_callRPC請求能夠容許你在本地模擬交易。這對於只讀方法或gas使用評估比較有幫助。

一個eth_call就像一個緩存的HTTP GET請求。

  • 它不改變全球的共識狀態
  • 本地區塊鏈(「緩存」)可能會有點稍微過期

製做一個eth_call來調用 getA方法,經過返回值來獲取狀態a。首先,計算方法選擇器:

>>> sha3("getA()")[0:4].hex()
'd46300fd'

因爲沒有參數,輸入數據就只有方法選擇器了。咱們能夠發送一個eth_call請求給任意的以太坊節點。對於這個例子,咱們依然將請求發送給 infura.io的公共以太坊節點:

$ curl -X POST \-H "Content-Type: application/json" \"[https://rinkeby.infura.io/YOUR_INFURA_TOKEN](https://rinkeby.infura.io/YOUR_INFURA_TOKEN)" \--data '{"jsonrpc": "2.0","id": 1,"method": "eth_call","params": [{"to": "0x62650ae5c5777d1660cc17fcd4f48f6a66b9a4c2","data": "0xd46300fd"},"latest"]}'

根據ABI,該字節應該會解釋爲0x1數值。

外部方法調用的彙編

如今來看看編譯的合約是如何處理源輸入數據的,並以此來製做一個方法調用。思考一個定義了setA(uint256)的合約:

pragma solidity ^0.4.11;
contract C {
  uint256 a;
  // 注意: `payable` 讓彙編簡單一點點
  function setA(uint256 _a) payable {
    a = _a;
  }
}

編譯:

solc --bin --asm --optimize call.sol

調用方法的彙編代碼在合約內部,在sub_0標籤下:

sub_0: assembly {
    mstore(0x40, 0x60)
    and(div(calldataload(0x0), 0x100000000000000000000000000000000000000000000000000000000), 0xffffffff)
    0xee919d50
    dup2
    eq
    tag_2
    jumpi
  tag_1:
    0x0
    dup1
    revert
  tag_2:
    tag_3
    calldataload(0x4)
    jump(tag_4)
  tag_3:
    stop
  tag_4:
      /* "call.sol":95:96  a */
    0x0
      /* "call.sol":95:101  a = _a */
    dup2
    swap1
    sstore
  tag_5:
    pop
    jump // 跳出
auxdata: 0xa165627a7a7230582016353b5ec133c89560dea787de20e25e96284d67a632e9df74dd981cc4db7a0a0029
}

這裏有兩個樣板代碼與此討論是無關的,可是僅供參考:

  • 最上面的mstore(0x40, 0x60)爲sha3哈希保留了內存中的前64個字節。無論合約是否須要,這個都會存在的。
  • 最下面的auxdata用來驗證發佈的源碼與部署的字節碼是否相同的。這個是可選擇的,可是嵌入到了編譯器中

將剩下的彙編代碼分紅兩個部分,這樣容易分析一點:

  • 匹配選擇器並跳掉方法處
  • 加載參數、執行方法,並從方法返回

首先,匹配選擇器的註釋彙編代碼:

// 加載前4個字節做爲方法選擇器
and(div(calldataload(0x0), 0x100000000000000000000000000000000000000000000000000000000), 0xffffffff)
//  若是選擇器匹配`0xee919d50`, 跳轉到 setA
0xee919d50
dup2
eq
tag_2
jumpi
// 匹配失敗,返回並還原
tag_1:
  0x0
  dup1
  revert
// setA函數
tag_2:
  ...

除了開始從調用數據裏面加載4字節時的位轉移,其餘的都是很是清晰明朗的。爲了清晰可見,給出了彙編邏輯的低級僞代碼:

methodSelector = calldata[0:4]
if methodSelector == "0xee919d50":
  goto tag_2 // 跳轉到setA
else:
  // 匹配失敗,返回並還原
  revert

實際方法調用的註釋彙編代碼:

// setA
tag_2:
  // 方法調用以後跳轉的地方
  tag_3
  // 加載第一個參數(數值0x1).
  calldataload(0x4)
  // 執行方法
  jump(tag_4)
tag_4:
  // sstore(0x0, 0x1)
  0x0
  dup2
  swap1
  sstore
tag_5:
  pop
  //程序的結尾,將會跳轉到 tag_3並中止
  jump
tag_3:
  // 程序結尾
  stop

在進入方法體以前,彙編代碼作了兩件事情:

  1. 保存了一個位置,方法調用以後返回此位置
  2. 從調用數據裏面加載參數到棧中

低級的僞代碼:

// 保存位置,方法調用結束後返回此位置
@returnTo = tag_3
tag_2: // setA
  // 從調用數據裏面加載參數到棧中
  @arg1 = calldata[4:4+32]
tag_4: // a = _a
  sstore(0x0, @arg1)
tag_5 // 返回
  jump(@returnTo)
tag_3:
  stop

將這兩部分組合起來:

methodSelector = calldata[0:4]
if methodSelector == "0xee919d50":
  goto tag_2 // goto setA
else:
  // 無匹配方法。失敗
  revert
@returnTo = tag_3
tag_2: // setA(uint256 _a)
  @arg1 = calldata[4:36]
tag_4: // a = _a
  sstore(0x0, @arg1)
tag_5 // 返回
  jump(@returnTo)
tag_3:
  stop

有趣的小細節:revert的操做碼是fd。可是在黃皮書中你不會找到它的詳細說明,或者在代碼中找到它的實現。實際上,fd不是確實存在的!這是個無效的操做。當EVM遇到了一個無效的操做,它會放棄而且會有還原狀態的反作用。

處理多個方法

Solidity編譯器是如何爲有多個方法的合約產生彙編代碼的?

pragma solidity ^0.4.11;
contract C {
    uint256 a;
    uint256 b;
    function setA(uint256 _a) {
      a = _a;
    }
    function setB(uint256 _b) {
      b = _b;
    }
}

簡單,只要一些if-else分支就能夠了:

// methodSelector = calldata[0:4]
and(div(calldataload(0x0), 0x100000000000000000000000000000000000000000000000000000000), 0xffffffff)
// if methodSelector == 0x9cdcf9b
0x9cdcf9b
dup2
eq
tag_2 // SetB
jumpi
// elsif methodSelector == 0xee919d50
dup1
0xee919d50
eq
tag_3 // SetA
jumpi

僞代碼:

methodSelector = calldata[0:4]
if methodSelector == "0x9cdcf9b":
  goto tag_2
elsif methodSelector == "0xee919d50":
  goto tag_3
else:
  // Cannot find a matching method. Fail.
  revert

ABI爲複雜方法調用進行編碼

對於一個方法調用,交易輸入數據的前4個字節老是方法選擇器。跟在後面的32字節塊就是方法參數。 ABI編碼規範顯示了更加複雜的參數類型是如何被編碼的,可是閱讀起來很是的痛苦。

另外一個學習ABI編碼的方式是使用 pyethereum的ABI編碼函數 來研究不一樣數據類型是如何編碼的。咱們會從簡單的例子開始,而後創建更復雜的類型。

首先,導出encode_abi函數:

from ethereum.abi import encode_abi

對於一個有3個uint256類型參數的方法(例如foo(uint256 a, uint256 b, uint256 c)),編碼參數只是簡單的依次對uint256數值進行編碼:

# 第一個數組列出了參數的類型
# 第二個數組列出了參數的值
> encode_abi(["uint256", "uint256", "uint256"],[1, 2, 3]).hex()
0000000000000000000000000000000000000000000000000000000000000001
0000000000000000000000000000000000000000000000000000000000000002
0000000000000000000000000000000000000000000000000000000000000003

小於32字節的類型會被填充到32字節:

> encode_abi(["int8", "uint32", "uint64"],[1, 2, 3]).hex()
0000000000000000000000000000000000000000000000000000000000000001
0000000000000000000000000000000000000000000000000000000000000002
0000000000000000000000000000000000000000000000000000000000000003

對於定長數組,元素仍是32字節的塊(若是必要的話會填充0),依次排列:

> encode_abi(
   ["int8[3]", "int256[3]"],
   [[1, 2, 3], [4, 5, 6]]
).hex()
// int8[3]. Zero-padded to 32 bytes.
0000000000000000000000000000000000000000000000000000000000000001
0000000000000000000000000000000000000000000000000000000000000002
0000000000000000000000000000000000000000000000000000000000000003
// int256[3].
0000000000000000000000000000000000000000000000000000000000000004
0000000000000000000000000000000000000000000000000000000000000005
0000000000000000000000000000000000000000000000000000000000000006

ABI爲動態數組編碼

ABI介紹了一種間接的編碼動態數組的方法,遵循一個叫作頭尾編碼的模式。

該模式其實就是動態數組的元素被打包到交易的調用數據尾部,參數(「頭」)會被引用到調用數據裏,這裏就是數組元素。

若是咱們調用的方法有3個動態數組,參數的編碼就會像這樣(添加註釋和換行爲了更加的清晰):

> encode_abi(
  ["uint256[]", "uint256[]", "uint256[]"],
  [[0xa1, 0xa2, 0xa3], [0xb1, 0xb2, 0xb3], [0xc1, 0xc2, 0xc3]]
).hex()
/************* HEAD (32*3 bytes) *************/
// 參數1: 數組數據在0x60位置
0000000000000000000000000000000000000000000000000000000000000060
// 參數2:數組數據在0xe0位置
00000000000000000000000000000000000000000000000000000000000000e0
// 參數3: 數組數據在0x160位置 
0000000000000000000000000000000000000000000000000000000000000160
/************* TAIL (128**3 bytes) *************/
//  0x60位置。參數1的數據
// 長度後跟這元素
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000a1
00000000000000000000000000000000000000000000000000000000000000a2
00000000000000000000000000000000000000000000000000000000000000a3
// 0xe0位置。參數2的數據
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000b1
00000000000000000000000000000000000000000000000000000000000000b2
00000000000000000000000000000000000000000000000000000000000000b3
//0x160位置。參數3的數據
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000c1
00000000000000000000000000000000000000000000000000000000000000c2
00000000000000000000000000000000000000000000000000000000000000c3

HEAD部分有32字節參數,指出TAIL部分的位置,TAIL部分包含了3個動態數組的實際數據。

舉個例子,第一個參數是0x60,指出調用數據的第96個(0x60)字節。若是你看一下第96個字節,它是數組的開始地方。前32字節是長度,後面跟着的是3個元素。

混合動態和靜態參數是可能的。這裏有個(staticdynamicstatic)參數。靜態參數按原樣編碼,而第二個動態數組的數據放到了尾部:

> encode_abi(
  ["uint256", "uint256[]", "uint256"],
  [0xaaaa, [0xb1, 0xb2, 0xb3], 0xbbbb]
).hex()
/************* HEAD (32*3 bytes) *************/
// 參數1: 0xaaaa
000000000000000000000000000000000000000000000000000000000000aaaa
// 參數2:數組數據在0x60位置
0000000000000000000000000000000000000000000000000000000000000060
// 參數3: 0xbbbb
000000000000000000000000000000000000000000000000000000000000bbbb
/************* TAIL (128 bytes) *************/
// 0x60位置。參數2的數據
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000b1
00000000000000000000000000000000000000000000000000000000000000b2
00000000000000000000000000000000000000000000000000000000000000b3

編碼字節數組

字符串和字節數組一樣是頭尾編碼。惟一的區別是字節數組會被緊密的打包成一個32字節的塊,就像:

> encode_abi(
  ["string", "string", "string"],
  ["aaaa", "bbbb", "cccc"]
).hex()
// 參數1: 字符串數據在0x60位置
0000000000000000000000000000000000000000000000000000000000000060
// 參數2:字符串數據在0xa0位置
00000000000000000000000000000000000000000000000000000000000000a0
// 參數3:字符串數據在0xe0位置
00000000000000000000000000000000000000000000000000000000000000e0
// 0x60 (96)。 參數1的數據
0000000000000000000000000000000000000000000000000000000000000004
6161616100000000000000000000000000000000000000000000000000000000
// 0xa0 (160)。參數2的數據
0000000000000000000000000000000000000000000000000000000000000004
6262626200000000000000000000000000000000000000000000000000000000
// 0xe0 (224)。參數3的數據
0000000000000000000000000000000000000000000000000000000000000004
6363636300000000000000000000000000000000000000000000000000000000

對於每一個字符串/字節數組,前面的32字節是編碼長度,後面跟着纔是字符串/字節數組的內容。

若是字符串大於32字節,那麼多個32字節塊就會被使用:

// 編碼字符串的48字節
ethereum.abi.encode_abi(
  ["string"],
  ["a" * (32+16)]
).hex()

0000000000000000000000000000000000000000000000000000000000000020
//字符串的長度爲0x30 (48)
0000000000000000000000000000000000000000000000000000000000000030
6161616161616161616161616161616161616161616161616161616161616161
6161616161616161616161616161616100000000000000000000000000000000

嵌套數組

嵌套數組中每一個嵌套有一個間接尋址。

> encode_abi(
  ["uint256[][]"],
  [[[0xa1, 0xa2, 0xa3], [0xb1, 0xb2, 0xb3], [0xc1, 0xc2, 0xc3]]]
).hex()
//參數1:外層數組在0x20位置上
0000000000000000000000000000000000000000000000000000000000000020
// 0x20。每一個元素都是裏層數組的位置
0000000000000000000000000000000000000000000000000000000000000003
0000000000000000000000000000000000000000000000000000000000000060
00000000000000000000000000000000000000000000000000000000000000e0
0000000000000000000000000000000000000000000000000000000000000160
// array[0]在0x60位置上
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000a1
00000000000000000000000000000000000000000000000000000000000000a2
00000000000000000000000000000000000000000000000000000000000000a3
// array[1] 在0xe0位置上
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000b1
00000000000000000000000000000000000000000000000000000000000000b2
00000000000000000000000000000000000000000000000000000000000000b3
// array[2]在0x160位置上
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000c1
00000000000000000000000000000000000000000000000000000000000000c2
00000000000000000000000000000000000000000000000000000000000000c3

Gas成本和ABI編碼設計

爲何ABI將方法選擇器截斷到4個字節?若是咱們不使用sha256的整個32字節,會不會不幸的碰到不一樣方法發生衝突的狀況? 若是這個截斷是爲了節省成本,那麼爲何在用更多的0來進行填充時,而僅僅只爲了節省方法選擇器中的28字節而截斷呢?

這種設計看起來互相矛盾……直到咱們考慮到一個交易的gas成本。

  • 每筆交易須要支付 21000 gas
  • 每筆交易的0字節或代碼須要支付 4 gas
  • 每筆交易的非0字節或代碼須要支付 68 gas

啊哈!0要便宜17倍,0填充如今看起來沒有那麼不合理了。

方法選擇器是一個加密哈希值,是個僞隨機。一個隨機的字符串傾向於擁有不少的非0字節,由於每一個字節只有0.3%(1/255)的機率是0。

  • 0x1填充到32字節成本是192 gas
    4*31 (0字節) + 68 (1個非0字節)
  • sha256可能有32個非0字節,成本大概2176 gas
    32 * 68
  • sha256截斷到4字節,成本大概272 gas
    32*4

ABI展現了另一個底層設計的奇特例子,經過gas成本結構進行激勵。

負整數….

通常使用叫作 補碼的方式來表達負整數。int8類型-1的數值編碼會都是1。1111 1111

ABI用1來填充負整數,因此-1會被填充爲:

ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff

越大的負整數(-1大於-2)1越多,會花費至關多的gas。

總結

與智能合約交互,你須要發送原始字節。它會進行一些計算,可能會改變本身的狀態,而後會返回給你原始字節。方法調用實際上不存在,這是ABI創造的集體假象。

ABI被指定爲一個低級格式,可是在功能上更像一個跨語言RPC框架的序列化格式。

咱們能夠在DApp和Web App的架構層面之間進行類比:

  • 區塊鏈就是一個備份數據庫
  • 合約就像web服務器
  • 交易就像請求
  • ABI是數據交換格式,就像Protocol Buffer

翻譯自 https://medium.com/@hayeah/diving-into-the-ethereum-vm-part-2-storage-layout-bc5349cb11b7

相關文章
相關標籤/搜索