JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

從 Solidity 到位元組碼:ABI 與四位元組選擇器

鏈上沒有 Solidity,只有位元組碼。跟著一份合約從原始碼走過編譯器、抵達真正執行的位元組,並拆解一次函式呼叫如何化為合約據以分派的四位元組選擇器。

在鏈上,你的 Solidity 並不存在

在 Etherscan 之類的區塊瀏覽器上打開任何一份合約,你會看到「Contract Source Code Verified(原始碼已驗證)」旁邊的綠色勾勾——一段可讀、排版整齊的 Solidity。人們很容易以為鏈上存的就是那段原始碼。並不是。真正住在合約位址處的,是一串原始的位元組碼:幾千個位元組,長得像 `0x6080604052348015...`。你在 Etherscan 上讀到的 Solidity,是部署者另外上傳的;Etherscan 之所以給出那個綠勾,是因為它把這段原始碼重新編譯一次,確認結果與鏈上既有的位元組相符。原始碼是文件;位元組碼才是合約。

本篇為這一階收尾,跨過最後一道鴻溝:從你將會書寫的高階語言,走到你早已見過的底層機器。你追蹤過堆疊上的操作碼、看過合約把資料放在哪、盯著 gas 為每一步計量、也跟著 CALL 在合約之間穿梭。現在我們要回答前面一切都默默假設了的兩個實際問題:Solidity 究竟如何變成那些位元組?而當有人呼叫 `transfer(...)`,一堆操作碼又是怎麼知道該執行哪個函式的?

編譯流水線:從原始碼到操作碼

Solidity 的編譯器 `solc` 接過原始碼,讓它走過幾個階段:把文字解析成抽象語法樹、做型別檢查、降階(在現代流水線中降成 Yul——一種可讀的中介組合語言)、最佳化,最後產出操作碼,再把每個操作碼以一個位元組封裝進位元組碼。這條流水線的最底端,正是本階第一篇那台機器——`PUSH1`、`ADD`、`JUMPI`、`SSTORE`。Solidity 是糖衣;EVM 從頭到尾只吃位元組。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Vault {
    mapping(address => uint256) public balanceOf;

    function deposit() external payable {
        balanceOf[msg.sender] += msg.value;
    }

    function transfer(address to, uint256 amount) external {
        require(balanceOf[msg.sender] >= amount, "insufficient");
        balanceOf[msg.sender] -= amount;
        balanceOf[to]        += amount;
    }
}
一份小小的金庫合約。我們會編譯它,並一路追著 transfer 函式直抵位元組。
$ solc --bin --bin-runtime --abi --optimize Vault.sol

======= Vault.sol:Vault =======
Binary:                       <- CREATION (init) code: the tx 'data' that deploys
60806040523480156100...          runs the constructor, then RETURNs the runtime code

Binary of the runtime part:   <- RUNTIME code: what ends up STORED at the address
6080604052600436106100...        this is exactly what eth_getCode returns later

Contract JSON ABI:            <- the calling convention (used OFF-chain by callers)
[{"type":"function","name":"transfer",
  "inputs":[{"name":"to","type":"address"},
            {"name":"amount","type":"uint256"}],
  "outputs":[],"stateMutability":"nonpayable"}, ... ]
solc 產出三樣東西:建立期位元組碼、執行期位元組碼,以及 ABI 的 JSON。

ABI:人人都同意的呼叫慣例

難的地方來了。執行期位元組碼就只是操作碼——它完全不知道你的函式叫 `transfer`、不知道 `to` 是個位址、也不知道 `amount` 是 `uint256`。名稱與型別在編譯時就被抹除了;鏈上不存任何反射(reflection)資訊。那麼地球另一端的錢包,要怎麼組出一則這份合約聽得懂的訊息?雙方事先就一套固定的編碼達成共識:這就是應用程式二進位介面,即 ABI。你看到 `solc` 產出的那份 ABI JSON,正是合約對外公布的「要跟我講話請照這個格式」。它存在於鏈下——在你的前端、在 Etherscan、在你的測試裡。

ABI 的編碼規則很死板,但值得弄懂,因為它能解釋你將來會除錯的每一種 calldata 排版。基本單位是 32 位元組字組。靜態型別(`uint256`、`address`、`bool`、定長陣列)各自被補滿成完整的 32 位元組——數字和布林值靠右對齊(左側補零),位址也是如此。動態型別(`bytes`、`string`、`T[]`)塞不進原位,於是採用頭/尾(head/tail)機制:頭部放一個 32 位元組的偏移量,指向資料較後段(尾部)儲存該值「長度+內容」的位置。這就是為什麼一個 `string` 引數會先出現一個看起來像 `0x20` 的數字,然後在更後面才接上它的長度與真正的字元。

四位元組選擇器:函式簽章的指紋

引數編碼只是工作的一半。另一半是告訴合約你想呼叫哪個函式。ABI 的答案是函式選擇器(function selector):取函式的標準簽章,用 Keccak-256 雜湊它,只保留前 4 個位元組。這四個位元組擺在 calldata 的最前面、所有引數之前。四個位元組很短——它是簽章的指紋,而不是函式名本身——但已足夠讓合約認出這次呼叫。

canonical signature = "transfer(address,uint256)"
      rules: function name + '(' + arg types, comma-separated, ')'
             NO parameter names, NO spaces
             types are normalized: uint -> uint256, address payable -> address,
                                   a contract type -> address, an enum -> uint8,
                                   a struct -> a tuple (...)

keccak256("transfer(address,uint256)") = 0xa9059cbb...   (the full 32-byte digest)
                                          \________/
                                          keep the first 4 bytes

function selector = 0xa9059cbb
推導出 ERC-20 標準的 transfer 選擇器。其他眾所周知的選擇器:balanceOf(address) = 0x70a08231、approve(address,uint256) = 0x095ea7b3。
call:  transfer(0x00000000000000000000000000000000DeaDBeef, 1000000)

  a9059cbb                                                          # selector (4 bytes)
  000000000000000000000000 00000000000000000000000000000000deadbeef  # arg 0: address, left-padded to 32 bytes
  00000000000000000000000000000000000000000000000000000000000f4240  # arg 1: uint256 = 1,000,000 = 0xf4240

  total calldata = 4 + 32 + 32 = 68 bytes
一次 transfer 呼叫的完整 calldata:4 位元組的選擇器,後面接著每個被補滿成 32 位元組字組的引數。

分派器:合約如何把呼叫導向正確的函式

現在執行期位元組碼的任務一清二楚了。它在每一次呼叫時做的第一件事,就是讀取那 4 個選擇器位元組、決定要往哪裡跳。編譯器在進入點建了一個小區塊——非正式地稱為分派器(dispatcher)(或函式選擇器路由)。它載入 calldata 的前 32 位元組,右移 224 位元以分離出最高的 4 個位元組,接著依序把這個選擇器和每個函式已知的選擇器比對,用的正是你在控制流那篇看過的 `EQ` / `JUMPI` 模式。第一個相符的就跳進那個函式的程式碼。

; ---- runtime bytecode entry: the function dispatcher ----
  PUSH1 0x04
  CALLDATASIZE        ; how many bytes of calldata were sent?
  LT                  ; calldatasize < 4 ?
  PUSH2 <fallback>
  JUMPI               ; if too short to hold a selector, go to fallback

  PUSH1 0x00
  CALLDATALOAD        ; load the first 32 bytes of calldata onto the stack
  PUSH1 0xE0          ; 224  (= 256 - 32)
  SHR                 ; >> 224  => isolates the top 4 bytes: the selector

  DUP1
  PUSH4 0xa9059cbb    ; transfer(address,uint256)
  EQ
  PUSH2 <transfer>
  JUMPI               ; selector matches -> jump into transfer's code

  DUP1
  PUSH4 0x70a08231    ; balanceOf(address)
  EQ
  PUSH2 <balanceOf>
  JUMPI

  ; ---- no selector matched ----
  JUMPDEST            ; <fallback>
  ; run fallback()/receive() if the contract defines one, else REVERT
用 EVM 組合語言寫的分派器:先分離出選擇器,再接一串 EQ/JUMPI 比對。經最佳化的編譯器可能改用對選擇器做二元搜尋,而非線性逐一比對。

要是沒有任何選擇器相符呢?控制權會落到fallback 函式。若合約宣告了 `fallback()`(或為「空 calldata 的純 ETH 轉入」而設的 `receive()`),它就會執行;否則這次呼叫直接回滾。這就是為什麼向一個沒有 payable fallback 的合約轉 ETH 會失敗,也正是代理合約所利用的那道接縫:代理定義一個 fallback,攔下每一個比對不到的呼叫,再用 `DELEGATECALL` 轉交給實作合約——也就是你在上一篇遇到的那一招。

為何這很重要:驗證、原始呼叫與中繼資料尾巴

弄懂這條鏈——原始碼 → 位元組碼 → ABI → 選擇器 → 分派器——正是讓你能信任那些不是你寫的合約的關鍵。當 Etherscan 「驗證」一份合約時,它用一模一樣的編譯器版本與設定重新編譯提交的原始碼,並檢查產出的執行期位元組碼是否與已部署的逐位元組相符。不符就代表原始碼在說謊。你也可以完全略過工具、徒手從 ABI 呼叫合約:一次原始的 `eth_call` 不過就是「合約位址+我們上面組出的 calldata 位元組」而已。

# call balanceOf(0xDeaDBeef...) with no tooling, just the raw calldata:
#   selector 70a08231 ++ the address, left-padded to 32 bytes
cast call 0xVaultAddress \
  0x70a0823100000000000000000000000000000000000000000000000deadbeef

# Foundry's cast can also compute selectors and encode for you:
cast sig    "transfer(address,uint256)"        # -> 0xa9059cbb
cast calldata "transfer(address,uint256)" 0xDeaDBeef 1000000
用原始 calldata 呼叫合約,並用 Foundry 的 cast 推導選擇器、編碼引數。

最後一個細節能解開一個反覆出現的謎團:為什麼兩份邏輯完全相同的合約,已部署的位元組碼有時卻不一樣?因為 `solc` 會在執行期位元組碼的尾端附上一段中繼資料尾巴(metadata tail)——一段 CBOR 編碼的資料,內含編譯器版本與完整中繼資料 JSON 的 IPFS 雜湊,末尾以一個兩位元組的長度標記收尾。它開頭是拼出 `ipfs` 的位元組,而且永遠不會被執行(它位在每一個 `RETURN` 之後)。改一個註解、一個檔名、或編譯器的修補版號,那個雜湊就會變,於是位元組就變了,儘管行為一模一樣。這也是為什麼 `CREATE2` 部署——它由確切的 init 碼推導出位址——對中繼資料敏感:邏輯相同、中繼資料不同,位址就不同。

至此,EVM 的巡禮就完整了。你現在擁有一個能從頭跑到尾的心智模型:Solidity 被 `solc` 編譯成位元組碼,由 EVM 一次一個操作碼地執行;ABI 是把函式呼叫化為 calldata 的共同慣例;一個 4 位元組的 Keccak-256 選擇器與一個分派器,把那次呼叫導向正確的程式碼;而 gas 為沿途的每一步計量。有了這些,下一階——用 Solidity 寫真正的合約——就不再是個黑盒子了。你已經知道你的程式碼會變成什麼。