1. 引言
閱讀完本文之后,將能理解一下字節碼含義:
608060405260405160893803806089833981016040819052601e916025565b600055603d565b600060208284031215603657600080fd5b5051919050565b603f80604a6000396000f3fe6080604052600080fdfea26469706673582212204a131c1478e0e7bb29267fd8f6d38a660b40a25888982bd6618b720d4498b6b464736f6c634300080700330000000000000000000000000000000000000000000000000000000000000001
本文解釋了:
- 以太坊智能合約在 字節碼層面 是如何被構造的,
- 以及構造函數參數是如何被解釋的。
本文基本結構為:
- Solidity 中的 creationCode
- 初始化代碼(Init code)
- 可支付(payable)構造函數合約
- 不可支付(non-payable)構造函數合約
- 運行時代碼(Runtime code)
- 運行時代碼解析
- 帶參數的構造函數
從宏觀上看,部署合約的錢包會向 空地址(null address) 發送一筆交易,交易數據分為三部分:
<init code> <runtime code> <constructor parameters>
三者合起來被稱為 創建代碼(creation code)。EVM 會首先執行 init code
。如果 init code
編碼正確,這段執行會把運行時代碼存儲到區塊鏈上。
在 EVM 規范中,并沒有強制要求布局必須是 init code
、runtime code
和 constructor parameters
。理論上可以是 init code
、constructor parameters
然后 runtime code
。這只是 Solidity 的約定。但是 init code
必須在最前面,否則 EVM 不知道從哪里開始執行。
本文假設讀者已經了解以下內容:
- Solidity(可參看 Solidity 免費教程)。
- EVM 操作碼基礎
2. Solidity 中的 creationCode
Solidity 提供了一個機制,可以通過 creationCode
關鍵字獲取合約創建交易中要部署的字節碼。示例如下:
contract ValueStorage { uint256 public value; constructor(uint256 value_) { value = value_; }
}contract GetCreationCode { function get() external returns (bytes memory creationCode) { creationCode = type(Simple).creationCode; }
}
需要注意的是,這 不包含構造函數參數。參數會作為合約部署過程中運行的字節碼一部分附加上去。至于 init code
(creationCode
)和參數是如何組織的,本文將進行解釋。
3. Init code(初始化代碼)
init code
是創建代碼的一部分,負責部署合約。先看一個最簡單的智能合約。稍后會解釋為什么要加上一個 payable
構造函數。
3.1 Init code(初始化代碼)——可支付(Payable)構造函數合約
pragma solidity 0.8.17; // optimizer: 200 runs
contract Minimal { constructor() payable {}
}
要獲得編譯結果,可以在 Remix 部署交易后,復制交易輸入中的 “input” 字段來獲取交易創建字節碼:
復制出高亮部分后,得到:
0x6080604052603f8060116000396000f3fe6080604052600080fdfea2646970667358221220d03248cf82928931c158551724bebac67e407e6f3f324f930c4cf1c36e16328764736f6c63430008110033
當然,這串字節碼很難直接閱讀。但可以把它拆成兩部分。
看起來似乎在隨機分割字節碼,但稍后會更清楚地解釋。
如果把第一部分復制到 EVM Codes,并把字節碼轉換為助記符(mnemonics),就會得到以下輸出(已添加注釋):
// 分配自由內存指針
PUSH1 0x80
PUSH1 0x40
MSTORE// 運行時代碼的長度
PUSH1 0x3f
DUP1// 運行時代碼開始的位置
PUSH1 0x11
PUSH1 0x00 // 從 calldata 復制運行時代碼到內存
CODECOPY// 此步驟會部署運行時代碼
PUSH1 0x00
RETURN
INVALID
在上圖中被高亮的部分就是 運行時代碼,其大小為 63 字節(十六進制 0x3f
)。它從內存的第 17 個索引(十六進制 0x11
)開始。這也解釋了上面助記符解析中 0x3f
和 0x11
的來源。
從宏觀上看,這段 init code
中主要做了三件事:
- 設置自由內存指針(用于記錄下一個可寫入的內存位置)。
- 使用
CODECOPY
操作碼把運行時代碼復制到該內存位置。 - 最后,把包含運行時代碼的內存區域返回給 EVM,EVM 會把它存儲為新合約的運行時字節碼。
3.2 Init code(初始化代碼)——不可支付(Non-payable)構造函數合約
pragma solidity 0.8.17; // optimizer: 200 runs
contract Minimal {constructor() {}
}
來看一下當構造函數不是 payable 時生成的字節碼,并分析其中的區別。以下是編譯器的輸出:
6080604052348015600f57600080fd5b50603f80601d6000396000f3fe6080604052600080fdfea2646970667358221220a6271a05446e269126897aea62fd14e86be796da8d741df53bdefd75ceb4703564736f6c63430008070033
將其拆分為 初始化代碼(init code) 和 運行時代碼(runtime code),如下圖所示:
把 payable 和 nonpayable 的 init code 放在一起對比:
0x6080604052603f8060116000396000f3fe // payable
0x6080604052348015600f57600080fd5b50603f80601d6000396000f3fe // nonpayable
可以注意到:payable 合約的 init code 更短,而 non-payable 的要長一些。
將較長的字節序列(non-payable)放入 EVM playground,得到如下輸出(已加上注釋):
// 初始化自由內存指針
PUSH1 0x80
PUSH1 0x40
MSTORE // 檢查發送的以太數量(wei)
CALLVALUE
DUP1
ISZERO // 跳轉到 0x0f(合約部署步驟)
PUSH1 0x0f
JUMPI // 如果發送了 wei > 0,就 revert
PUSH1 0x00
DUP1
REVERT // 跳轉目標 (0x0f)
JUMPDEST
POP // 運行時代碼長度
PUSH1 0x3f
DUP1 // runtime code 開始位置
PUSH1 0x1d
PUSH1 0x00
CODECOPY
PUSH1 0x00
RETURN
INVALID
接下來,將解釋“Payable 與 Non-payable 構造函數的區別”,以便于深入理解以上初始化代碼的區別。
-
1)非 payable 構造函數會在部署時校驗 callvalue
-
init code 會在 callvalue > 0 時 revert,否則繼續執行。
-
在 non-payable 的合約中,會額外插入一段 12字節 的字節碼序列:
348015600f57 600080fd 5b50
它位于初始化內存指針和返回 runtime code 的字節碼之間:
<init bytecode> <額外12字節序列> <返回runtime字節碼> <runtime字節碼>
這段額外的代碼負責檢查在部署時是否附帶了 value(wei)。
- 如果發送了 wei,就 revert。
- 如果沒發送 wei,就繼續部署 runtime code。
其中:
348015600f57
→ 檢查 value 是否為 0,否則跳轉。600080fd
→ 否則revert。5b50
→ JUMPDEST + POP。POP
用于丟棄 callvalue(因為它還在棧上,不再需要)。
JUMPDEST
是跳轉目標,沒有它的話,JUMP 或 JUMPI 無法落地,會導致 revert。
-
-
2)運行時代碼的內存偏移不同
注意:runtime code 的長度沒有變化,但 拷貝的偏移量 變了。
因為 non-payable 的 init code 更長,所以 runtime code 被推后了。- Non-payable 的偏移量:
0x1d
- Payable 的偏移量:
0x11
差值:
0x1d – 0x11 = 0x0c
(即 12),這正好對應那段額外的檢查字節序列。 - Non-payable 的偏移量:
4. 運行時代碼
4.1 空合約的運行時代碼
即便合約是空的(沒有函數),runtime code 依然不是空的。
原因是 solidity 編譯器會在運行時代碼后附加合約的元數據。
fe
(INVALID) 會被加在元數據之前,防止其被當成可執行字節碼。
更多合約元數據信息,可參看:Solidity metadata.json playground。
(在 Solidity 0.8.18 中,引入了 --no-cbor-metadata
編譯選項,可以不在合約字節碼中附加這些元數據。)
4.1.1 在純 Yul 合約中,編譯器默認不會添加元數據
如果合約是用純 Yul 編寫的,那么它將沒有元數據。
但是,可以通過在全局對象中包含 .metadata
來添加元數據。
// 該合約的編譯輸出默認不會包含元數據
object "Simple" { code { datacopy(0, dataoffset("runtime"), datasize("runtime")) return(0, datasize("runtime")) }object "runtime" { code { mstore(0x00, 2) return(0x00, 0x20) } }
}
編譯器的輸出如下:
6000600d60003960006000f3fe
轉換為助記符(mnemonics)后,得到:
// 將 runtime 代碼復制到內存
PUSH1 00
PUSH1 0d
PUSH1 00
CODECOPY // 返回一個零大小的區域,因為沒有 runtime 代碼
PUSH1 00
PUSH1 00
RETURN
INVALID
在這種情況下,返回的內存區域大小為零,因為既沒有 runtime 代碼,也沒有元數據。
(編譯器從 0x0d
開始,復制 0x00
字節的 runtime 代碼到內存偏移 0x00
的位置,然后返回 0x00
字節。)
4.2 非空合約的 Runtime 代碼
現在給合約加上最簡單的邏輯。
pragma solidity 0.8.7;
contract Runtime { address lastSender; constructor () payable {}receive() external payable { lastSender = msg.sender; }
}
其輸出的創建代碼為:
608060405260578060116000396000f3fe608060405236601c57600080546001600160a01b03191633179055005b600080fdfea2646970667358221220e9b731ab28726d97cbf5219f1e5eaec508f23254c60b15ed1d3456572547c5bf64736f6c63430008070033
可以拆分為:
接下來將詳細看看其中的 runtime 代碼。
由于這是一個 Solidity 合約,可以像之前解釋的那樣,將其分為 可執行字節碼 和 合約元數據。
Runtime code :=
0x608060405236601c57600080546001600160a01b03191633179055005b600080fdfeMetadata :=
0xa2646970667358221220e9b731ab28726d97cbf5219f1e5eaec508f23254c60b15ed1d3456572547c5bf64736f6c63430008070033
使用 evm.codes 輸出 來分析 runtime 代碼。它已被拆分以便于理解。
首先,初始化空閑內存指針:
[00] PUSH1 80
[02] PUSH1 40
[04] MSTORE
接下來檢查交易是否帶有數據,如果有,則跳轉到程序計數器 (PC) 0x1c
并執行回退(revert)。
合約能夠接收數據的合法方式只有兩種:調用函數和 fallback。
這里只有 receive
函數,所以沒有合法方式接收 calldata。
[05] CALLDATASIZE
[06] PUSH1 1c
[08] JUMPI
然后是存儲 msg.sender
的代碼:
[09] PUSH1 00
[0b] DUP1
[0c] SLOAD
[0d] PUSH1 01
[0f] PUSH1 01
[11] PUSH1 a0
[13] SHL
[14] SUB
[15] NOT
[16] AND
[17] CALLER
[18] OR
[19] SWAP1
[1a] SSTORE
[1b] STOP
最后,這是當 calldata 被傳入時跳轉到的 JUMPDEST 0x1c
,交易會回退:
[1c] JUMPDEST
[1d] PUSH1 00
[1f] DUP1
[20] REVERT
[21] INVALID
5. 帶參數的構造函數
帶構造函數參數的合約在編碼時會有一些不同。構造函數的參數會被追加到創建代碼(creation code)的末尾(在運行時代碼 runtime code 之后),并且是以 ABI 編碼 的形式追加的。
Solidity 還會額外增加一個檢查:
- 確保構造函數參數的長度至少等于預期的構造函數參數長度,否則會直接 revert。
來看一個簡單的例子。這里不包含任何運行時代碼(runtime code),只在構造函數中寫邏輯(構造函數代碼不會存在于運行時代碼中):
// optimizer: 200
contract MinimalLogic { uint256 private x; constructor (uint256 _x) payable { x = _x; }
}
生成的創建代碼是:
608060405260405160893803806089833981016040819052601e916025565b600055603d565b600060208284031215603657600080fd5b5051919050565b603f80604a6000396000f3fe6080604052600080fdfea26469706673582212204a131c1478e0e7bb29267fd8f6d38a660b40a25888982bd6618b720d4498b6b464736f6c63430008070033
拆解:
- Init code(初始化代碼):
0x608060405260405160893803806089833981016040819052601e916025565b600055603d565b600060208284031215603657600080fd5b5051919050565b603f80604a6000396000f3fe
- Runtime code(僅包含 metadata):
0x6080604052600080fdfea26469706673582212204a131c1478e0e7bb29267fd8f6d38a660b40a25888982bd6618b720d4498b6b464736f6c63430008070033
- 缺少構造函數參數!
如果直接執行這份創建代碼,會在 init code 中失敗(revert),因為它期望在運行時代碼后至少有 32 字節的數據來作為 uint256 _x
的值。
為了解決這個問題,可以把 ABI 編碼后的 uint256(1)
追加到創建代碼末尾,作為構造函數參數_x
。
修正后的字節碼:
608060405260405160893803806089833981016040819052601e916025565b600055603d565b600060208284031215603657600080fd5b5051919050565b603f80604a6000396000f3fe6080604052600080fdfea26469706673582212204a131c1478e0e7bb29267fd8f6d38a660b40a25888982bd6618b720d4498b6b464736f6c634300080700330000000000000000000000000000000000000000000000000000000000000001
字節碼解析(通過 EVM playground 來演示):
-
1)Step 1: 初始化 free memory pointer
和所有 solidity 合約一樣,先用6080604052
初始化空閑內存指針。 -
2)Step 2: 獲取構造函數參數的長度
// 6040 51 6089 38 03 PC OPCODE[05] PUSH1 40 [07] MLOAD [08] PUSH1 89 [0a] CODESIZE [0b] SUB
這里
PUSH1 40 MLOAD
會將 自由內存指針(free memory pointer)加載到棧上,以便后續使用。接著通過PUSH1 89
將 創建代碼長度(不包含構造函數參數部分) 壓入棧中,然后調用CODESIZE
(它包含了構造函數參數)。通過兩者相減來得到 構造函數參數的長度。 -
3)Step 3: 把構造函數參數復制到內存
// 80 6089 83 39 PC OPCODE[0c] DUP1 [0d] PUSH1 89 [0f] DUP4 [10] CODECOPY
這里為 CODECOPY 做棧準備。首先使用
DUP1
復制前面減法得到的結果,然后通過PUSH1 89
將 創建代碼長度(不包含構造函數參數) 壓入棧中。最后,使用DUP4
將內存偏移量放到棧頂。現在調用 CODECOPY,把構造函數參數復制到內存中的 自由內存指針 位置。 -
4)Step 4:更新自由內存指針
在把代碼寫入內存后,Solidity 會按如下方式更新 自由內存指針:// 81 01 6040 81 90 52 PC OPCODE[11] DUP2 [12] ADD [13] PUSH1 40 [15] DUP2 [16] SWAP1 [17] MSTORE
這里通過把之前復制的 構造函數參數長度 (0x20) 加到 自由內存指針 (0x80) 上來實現更新。然后用
Dup1
和Swap1
來調整棧結構,最后調用MSTORE 40
把新的值 (0xa0) 存儲為新的自由內存指針。接下來會有一系列的 動態操作和 JUMP,這些不會順序執行,而是取決于條件分支。
這些步驟都有編號,可以按編號順序理解,而不需要自己去找對應的 JUMPDEST。也可以用這個 playground 鏈接 來運行這個字節碼。
-
5)Step 5:跳轉到 SSTORE 的 JUMPDEST
// 601e 91 6025 56 PC OPCODE[18] PUSH1 1e [1a] SWAP2 [1b] PUSH1 25 [1d] JUMP // jump to JUMPDEST 0x25
在此,想跳轉到執行存儲構造函數參數的代碼位置。
這里先把1e
壓入棧中。1e
對應的正是 SSTORE 實際執行的位置。但在這之前,需要先檢查構造函數參數是否至少有 32 字節。這個檢查過程從 PC =0x25
(即上面的 JUMPDEST)開始。 -
8)Step 8:把構造函數參數存儲到存儲槽 0
這是JUMPDEST 0x1e
。不過要注意,JUMPDEST 0x25
會先執行(見下面步驟 6)。
另外要注意,這里是 Step 8,而上一個還是 Step 5。它之所以亂序,是因為只有 Step 6 和Step 7 條件滿足后,這里才會真正執行。為了和編譯后的字節碼保持一致,在這里提前引入。// 5b 6000 55 603d 56 PC OPCODE[1e] JUMPDEST [1f] PUSH1 00 [21] SSTORE [22] PUSH1 3d [24] JUMP
這里把
0x00
壓棧,作為存儲槽編號,把_x
存入其中,然后調用 SSTORE。接著壓入0x3d
,作為最后一次 CODECOPY 和 RETURN 的跳轉目標。 -
6)Step 6:檢查構造函數參數是否至少 32 字節
這是JUMPDEST 0x25
:// 5b 6000 6020 82 84 PC OPCODE[25] JUMPDEST [26] PUSH1 00 [28] PUSH1 20 [2a] DUP3 [2b] DUP5// continue // 03 12 15 6036 57 [2c] SUB [2d] SLT [2e] ISZERO [2f] PUSH1 36 [31] JUMPI // Jump to 0x36 if ISZERO returns 1// else continue and revert // 6000 80 fd [32] PUSH1 00 [34] DUP1 [35] REVERT
這里檢查構造函數參數的大小是否至少為 32 字節。
- 先把
0x00
壓入棧(稍后會用到),再壓入最小接受長度0x20 (32 字節)
。 - 然后通過之前壓入棧的 offset 和 自由內存指針 來計算參數實際長度。
DUP3
得到 offsetDUP5
得到當前自由內存指針
SUB
相減得到長度并壓棧。SLT
檢查是否小于 32 字節,返回 0(不小于)或 1(小于)。ISZERO
對結果取反:如果參數長度 >= 32,則結果為 1。- 壓入跳轉目標
0x36
,如果條件滿足就跳轉,否則執行REVERT
來避免無效輸入。
- 先把
-
7)Step 7:加載參數到棧,并準備寫入存儲
這是JUMPDEST 0x36
:// 5b 50 51 91 90 50 56 PC OPCODE[36] JUMPDEST [37] POP [38] MLOAD [39] SWAP2 [3a] SWAP1 [3b] POP [3c] JUMP // jump to 0x1e
這里先把之前壓棧的
0
(來自Step 6 的 [26])彈出,因為已經不需要了。
然后:- 使用
MLOAD
把構造函數參數加載到棧頂 - 清理掉構造函數參數的內存偏移(已無用)
- 最后
JUMP
到0x1e
,進入 Step 8,執行真正的存儲操作。
- 使用
-
9)Step 9:將運行時代碼拷貝到內存并返回
這是JUMPDEST 0x3d
,不過上面 JUMPDEST 0x1e 先執行。// 5b 603f 80 604a 6000 39 6000 f3 fe PC OPCODE[3d] JUMPDEST [3e] PUSH1 3f [40] DUP1 [41] PUSH1 4a [43] PUSH1 00 [45] CODECOPY [46] PUSH1 00 [48] RETURN [49] INVALID
無執行能力的代碼(合約元數據):
0x6080604052600080fdfea26469706673582212204a131c1478e0e7bb29267fd8f6d38a660b40a25888982bd6618b720d4498b6b464736f6c63430008070033
這里按慣例從內存中返回合約的運行時代碼。
RETURN 執行前的內存布局:
0x00
到0x40
:空的運行時代碼和元數據字節碼0x40
:自由內存指針0x80
:構造函數參數uint256(1)
內存詳細分布:
0x00 <- 0x20 = 0x6080604052600080fdfea26469706673582212208f9ffa7a3ab43f0ff61d3033
0x20 <- 0x40 = 0x624bf0e9d398f9a91213656b13d9ffc8fd90fdbc64736f6c63430008070033
0x40 <- 0x60 = 0x0000000000000000000000000000000000000000000000000000000000000a0
0x60 <- 0x80 = 0x00000000000000000000000000000000000000000000000000000000000000
0x80 <- 0xa0 = 0x0000000000000000000000000000000000000000000000000000000000000001
6. 總結
智能合約部署包含了一些低級操作,這些通常被高級語言封裝起來。
本文學習了:
- 智能合約如何通過發送 創建代碼到零地址 來執行
- 創建代碼的不同組成部分及其在部署中的作用
- 各部分如何協同工作完成合約部署
- 構造函數參數如何存儲、驗證,并用于初始化合約
通過這個流程,可以對智能合約的底層執行有更清晰的理解。
參考資料
[1] RareSkills團隊2023年2月博客 Ethereum smart contract creation code