Bitroot部落格
返回官網 ↗
© 2026 Bitroot · 本站內容僅供一般參考,不構成財務、投資、法律或稅務建議。
編輯標準返回官網
← 全部文章
EVM 基礎·2026/10/02·約 11 分鐘

ABI 精讀:selector、靜態參數與動態類型

從裸 calldata 出發拆解 ABI:4 位元組 selector 由規範簽章截斷而來,靜態類型直接佔滿一個字,動態類型靠偏移量與長度二級定址;事件把 indexed 參數寫進 topics,換來可檢索性。ABI 不自描述,解碼與升級因此都受約束。

一筆 ERC-20 轉帳的 calldata 通常以 0xa9059cbb 開頭,後面跟著兩個 32 位元組的字,第一個是收款地址,第二個是金額。鏈上沒有任何欄位說明這串位元組對應 transfer(address,uint256),EVM(Ethereum Virtual Machine,以太坊虛擬機)也不認識函式名。它只按部署時編譯進位元組碼的分發邏輯,比較 calldata 的前 4 個位元組,跳進對應的程式碼段。

這 4 個位元組從哪來,參數為什麼有的直接擺在開頭、有的要繞到後面去找,以及為什麼拿到一段 calldata 卻沒有介面定義就解不開,是理解合約互動的三件事。本文先講清 ABI(Application Binary Interface,應用二進位介面)的編碼規則,再按規範文件給出的範例 calldata 手工走一遍解碼,最後說明事件日誌為什麼把參數拆進 topics 與 data 兩個位置。

selector 是規範簽章的雜湊截斷,不是函式名本身

Solidity ABI 規範對函式選擇器(function selector)的定義很短:calldata 的前 4 個位元組,取自函式簽章 Keccak-256 雜湊的最高 4 個位元組。這裡的簽章取規範形式,與原始碼裡的寫法並不等同:函式名加上括號包裹的參數類型清單,類型之間用單個逗號分隔,不帶空格,不帶參數名,也不帶 memory、calldata、storage 這類資料位置修飾符。

規範形式還會做類型正規化。uint 與 int 必須寫成 uint256 與 int256,address payable 與合約類型都寫成 address,列舉(enum)寫成 uint8,使用者定義值類型寫成它的底層類型。結構體(struct)寫成括號包裹的元件類型,也就是元組(tuple):若 S 有兩個 uint256 欄位,function f(S memory s) 的簽章是 f((uint256,uint256)),選擇器取自 keccak256("f((uint256,uint256))") 的前 4 位元組。回傳值不參與簽章,這與 Solidity 的多載解析一致:呼叫方只按參數區分目標函式,回傳值差異無法用來消歧。簽章裡每個字元都會影響結果,多一個空格或把 uint 寫成 uint256,選擇器就完全不同。

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

contract SelectorDemo {
    function selectorOfTransfer() external pure returns (bytes4) {
        // 引號內是規範簽章:無空格、無參數名、uint 正規化為 uint256
        return bytes4(keccak256("transfer(address,uint256)"));
    }
}

32 位空間帶來的直接後果是碰撞並非理論假設。截至 2026 年 9 月,4byte.directory 上 0xa9059cbb 這一個選擇器列出 6 條候選簽章,除 transfer(address,uint256),還有 many_msg_babbage(bytes1)、workMyDirefulOwner(uint256,uint256) 以及 func_2093253501(bytes) 這類佔位名;該庫的條目可以自行提交,數量與內容會隨時間變化。兩個不同函式共用選擇器時,calldata 前綴本身無法區分呼叫意圖,只能靠目標合約的程式碼或介面定義來判斷。

Solidity 官方文件沒有單列這條限制,但 solc 在實作層對同一合約(含繼承鏈)內的選擇器重複有檢查:兩個函式選擇器相同時,編譯會以 Function signature hash collision 失敗(可用一段最小合約重現),跨合約與跨版本不存在這層檢查。這也說明只靠 4byte 類資料庫反查函式名並不可靠:資料庫給出的是候選集合,碰撞時無法確定唯一答案。

靜態類型直接佔滿一個字

編碼從第 5 個位元組開始。規則先把參數分成靜態與動態兩類:靜態類型就地編碼,直接寫進參數位元組流;動態類型把實際內容放到後面,原位只留一個偏移量。

EVM 的字長是 32 位元組,靜態類型一律佔滿一個字。uint<M> 與 int<M> 按大端寫入,高位填充:無號數補零,有號數按符號擴展補 0xff 或 0x00。bool 等價於 uint8,true 編碼為 1,false 為 0。address 等價於 uint160,20 位元組的地址因此落在 32 位元組字的低位,前面補 12 個零位元組。定長位元組類型 bytes<M> 的填充方向相反:值靠左對齊,右側補零到 32 位元組。同為靜態類型,uint32(1) 與 bytes4(0x00000001) 落在同一個字裡但位置不同,前者在最後 4 個位元組,後者在最前 4 個位元組,這是讀十六進位傾印時最容易看錯的地方。

定長陣列 <type>[M] 在元素類型也是靜態時同樣歸為靜態,編碼時按元組展開,M 個元素依次排開,不寫長度。結構與元組同理遞迴展開。規範把靜態類型定義為「除 bytes、string、T[]、元素為動態類型的 T[k] 以及含動態成員元組之外的全部類型」,這條定義是判斷「直接讀值還是跟指標」的唯一依據。

calldata 本身也有 gas 成本,且與 ABI 的 32 位元組對齊並不一致。EIP-2028 把交易資料裡非零位元組的代價從 68 gas 降到 16 gas,零位元組仍為 4 gas,這段成本屬於交易固有 gas。uint256 傳一個很小的數值仍會佔滿 32 位元組,其中絕大多數是零位元組,成本低但並非為零;反過來,把多個小整數按 bytes32 緊湊打包能省 gas,代價是鏈上失去了可讀的參數邊界,解碼側必須知道這份自訂布局。

動態類型靠偏移量與長度前綴二級定址

bytes、string、T[],以及元素為動態類型的定長陣列與元組,都屬於動態類型。它們的長度取決於執行時的值,無法在編譯期固定,所以不能在參數位元組流裡佔據固定寬度。

ABI 採用頭尾分離(head/tail)布局。所有參數的「頭」先依次排開:靜態類型的頭就是它自己的編碼,動態類型的頭是一個 32 位元組偏移量。偏移量的基準是參數區塊起點,也就是選擇器之後的第一個位元組,而不是整段 calldata 的起點,這是手工解碼時最常見的錯位來源。頭的長度只取決於參數類型,與參數值無關。頭之後依次放置各動態參數的「尾」,尾的第一項是長度,單位按類型區分:bytes 與 string 是位元組數,陣列是元素個數,長度之後才是內容。

string 的長度寫的是 UTF-8 編碼後的位元組數,不是字元數,中文與 emoji 因此會佔多個位元組。內容之後補零到 32 位元組的整數倍。陣列元素遞迴套用同一套規則,巢狀陣列(如 uint256[][])裡每一層都有自己的長度與偏移量,偏移量始終相對於它所在那一層編碼區塊的起點。規範把這條設計的目標寫得很明確:最壞情況下存取某個值的讀取次數不超過它在參數結構中的巢狀深度,並且任一元素的資料可以整體搬運而不失效,因為它只依賴相對地址。

一個容易被忽略的細節是偏移量並不要求最小,也不要求資料區互不重疊。規範定義的嚴格編碼模式(strict encoding mode)要求頭部緊湊、無空洞,Solidity 編碼器始終輸出嚴格模式,但解碼器不強制檢查。換言之,同一段語意相同的呼叫可能存在多種位元組布局,解碼實作要麼接受帶空洞的偏移量,要麼顯式拒絕;兩種選擇在跨客戶端對比時會產生差異。

手工解碼一段 calldata

解碼範例直接取自《Contract ABI Specification》的 Examples 一節:呼叫 sam(bytes,bool,uint256[]),參數為 "dave"、true、[1,2,3],共 292 位元組,按 32 位元組換行如下。

0xa5643bf2
0000000000000000000000000000000000000000000000000000000000000060
0000000000000000000000000000000000000000000000000000000000000001
00000000000000000000000000000000000000000000000000000000000000a0
0000000000000000000000000000000000000000000000000000000000000004
6461766500000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000003
0000000000000000000000000000000000000000000000000000000000000001
0000000000000000000000000000000000000000000000000000000000000002
0000000000000000000000000000000000000000000000000000000000000003

解碼步驟可以嚴格按規範定義重現:

  1. 前 4 位元組 0xa5643bf2 是選擇器,對應簽章 sam(bytes,bool,uint256[])。注意簽章裡的 uint 必須寫成 uint256。
  2. 參數區塊共有 3 個頭,每個 32 位元組。第 1 個字是 0x60,即 96,是第 1 個參數 bytes 的偏移量;第 2 個字是 0x01,bool 為 true;第 3 個字是 0xa0,即 160,指向第 3 個參數 uint256[]。
  3. 頭共 96 位元組,所以第 1 個參數的尾從參數區塊第 96 位元組開始,0x60 自洽。尾的第 1 個字是 0x04,表示 bytes 有 4 位元組;隨後 4 位元組是 64617665,按 ASCII 讀作 dave;再補零到 32 位元組。
  4. 第 1 個參數的尾佔兩個字共 64 位元組,從 96 到 160,於是第 2 個動態參數的偏移量為 0xa0。該處第 1 個字是 0x03,說明陣列有 3 個元素;隨後 3 個字分別是 1、2、3。

同樣的過程可以寫成一個最小解碼器。下面這段 Python 只處理上面這一種簽章,用來說明偏移量的單位是位元組,以及長度前綴的位置。

# 輸入為選擇器之後的參數區塊,按 32 位元組切字
def word(data, i):
    return int.from_bytes(data[i * 32:(i + 1) * 32], "big")

def decode_sam(args: bytes):
    bytes_off = word(args, 0)          # 第 1 個字:bytes 參數相對參數區塊起點的位元組偏移
    flag = word(args, 1) != 0          # 第 2 個字:bool
    arr_off = word(args, 2)            # 第 3 個字:uint256[] 的偏移

    n = word(args, bytes_off // 32)    # 偏移換算成字索引後,讀長度前綴
    s = args[bytes_off + 32: bytes_off + 32 + n].decode()

    k = word(args, arr_off // 32)      # 陣列同樣先讀元素個數
    items = [word(args, arr_off // 32 + 1 + j) for j in range(k)]
    return s, flag, items              # ("dave", True, [1, 2, 3])

ABI 不自描述,schema 是鏈下必需件

規範的開篇就寫明:這套編碼不是自描述的,解碼必須有 schema。這句話的工程含義比它讀起來要重。

EVM 收到的只有位元組,函式名、參數名、類型資訊在編譯後全部消失。分發靠比較 4 位元組選擇器,跳轉目標是編譯器生成的 switch;回傳資料同樣只是位元組流,呼叫方要讀取動態長度回傳值,需要 RETURNDATASIZE 與 RETURNDATACOPY 這類操作(EIP-211 提出,拜占庭升級啟用)。鏈上沒有任何位置存放 ABI JSON,它只能作為編譯產物隨原始碼一起發布。由此產生幾條具體約束。

區塊瀏覽器要顯示「呼叫了哪個函式、參數是什麼」,必須先拿到已驗證的原始碼與 ABI。未驗證的合約只能顯示原始 calldata,或者退而用 4byte 類資料庫反查,而反查得到的是候選簽章集合,碰撞時無法確定。索引器與資料管道必須在部署時就知道 ABI,否則無法為事件和呼叫建索引。介面演進也沒有欄位編號這類相容機制:給函式增加、刪除或重排參數都會改變選擇器,對呼叫方是徹底的介面破壞,因此升級通常只能新增函式,而不是修改舊函式。代理合約可以在實作層新增函式,但選擇器必須保持穩定,任何改變簽章的「重構」都會讓既有呼叫落到 fallback 分支,而 fallback 只能拿到原始 calldata,無法還原呼叫方的意圖。

另外兩個點常被誤解。選擇器相同不等於編碼可互換:transfer(address,uint256) 與 many_msg_babbage(bytes1) 共享 0xa9059cbb,但參數長度與語意完全不同,工具在碰撞時必須結合合約程式碼或 calldata 長度判斷。自訂錯誤(custom error)的選擇器同樣取自錯誤簽章的前 4 位元組,任意合約都可以回傳一段匹配某錯誤簽章的資料,規範因此明確提醒呼叫方不要把錯誤資料當成來源可靠的資訊,它只適合做為提示。

事件把可檢索性放進 topics,把可讀性留在 data

事件的編碼規則與函式呼叫同源,落點卻不同。一條日誌由合約地址、最多 4 個主題(topic)和一段任意長度資料組成。非匿名事件的 topics[0] 固定是事件簽章的 Keccak-256 雜湊,不做 4 位元組截斷,這也是用 eth_getLogs 過濾事件時條件寫的是簽章雜湊而不是事件名的原因。事件簽章同樣使用規範形式,uint 會正規化為 uint256。

其餘參數按 indexed 與否分成兩路。帶 indexed 的值類型參數直接編碼進 topics[1] 到 topics[3]:整數高位填充,定長位元組右側填充,地址取低 20 位元組。非匿名事件的 indexed 參數最多 3 個,加上簽章主題正好用滿 4 個;宣告為 anonymous 的事件不寫簽章主題,indexed 參數最多 4 個,代價是失去按簽章過濾的能力。不帶 indexed 的參數按 ABI 規則編碼進 data,這段資料內部同樣有頭尾結構,動態參數在裡面依舊靠偏移量定址。

差異集中體現在 indexed 的複雜類型上。陣列、string、bytes 與結構體一旦被標記為 indexed,寫入 topic 的是這段編碼的 Keccak-256 雜湊。雜湊不可逆,所以這類參數可以被精確檢索:預先算出目標值的雜湊,就能用它做為過濾條件命中日誌;但無法從日誌裡還原原值,只能確認匹配與否。規範給出的對策是同時宣告兩個參數保存同一個值,一個 indexed 用於檢索,一個不 indexed 用於讀取,代價是日誌體積與 gas 上升。雜湊編碼規則本身也留了歧義:結構體包含多個動態陣列時,拼接後的位元組序列可能不唯一,規範提醒不要僅憑 indexed 參數的檢索結果斷定事件含義。

最後要區分「可檢索」與「可解碼」。值類型 indexed 參數能直接讀出,動態類型 indexed 參數只能命中不能讀出;不 indexed 參數能讀出但不能按值檢索。日誌把 topics 與 data 拆開,是在日誌體積、檢索能力與解碼能力之間取折衷,任何一類參數都無法同時拿到全部屬性。

資料來源

  • Solidity 文件《Contract ABI Specification》,函式選擇器、頭尾布局、嚴格編碼模式、事件與 indexed 參數編碼規範、sam 解碼範例:https://docs.soliditylang.org/en/latest/abi-spec.html
  • 4byte.directory 上 0xa9059cbb 的候選簽章清單(查詢於 2026 年 9 月,條目可自行提交):https://www.4byte.directory/signatures/?bytes4_signature=0xa9059cbb
  • EIP-609《Hardfork Meta: Byzantium》,把 EIP-211 與 EIP-214 列入拜占庭包含清單的元提案:https://eips.ethereum.org/EIPS/eip-609
  • EIP-2028《Transaction data gas cost reduction》,非零 calldata 位元組從 68 gas 降到 16 gas:https://eips.ethereum.org/EIPS/eip-2028
  • EIP-211《New opcodes: RETURNDATASIZE and RETURNDATACOPY》,動態回傳資料讀取與 BYZANTIUM_FORK_BLKNUM:https://eips.ethereum.org/EIPS/eip-211
  • Ethereum Yellow Paper,附錄 G 費率表中 G_txdatazero 4 gas 與 G_txdatanonzero 16 gas:https://ethereum.github.io/yellowpaper/paper.pdf
  • ethereum.org EVM 操作碼參考與 wolflo/evm-opcodes 動態 gas 表,交易固有 gas 的零 / 非零位元組計價:https://ethereum.org/en/developers/docs/evm/opcodes/ 、https://github.com/wolflo/evm-opcodes/blob/main/gas.md

延伸閱讀

EVM 相容意味著什麼:位元組碼、預編譯、JSON-RPC 與工具鏈Bitroot 樂觀平行化機制:偵測、重新執行與確定性

呼叫指令與上下文的關係,參見 0.17《CALL 族對照:CALL、CALLCODE、DELEGATECALL 與 STATICCALL》。

← 上一篇單執行緒全域狀態:EVM 效能瓶頸的設計原點
本文目錄
selector 是規範簽章的雜湊截斷,不是函式名本身靜態類型直接佔滿一個字動態類型靠偏移量與長度前綴二級定址手工解碼一段 calldataABI 不自描述,schema 是鏈下必需件事件把可檢索性放進 topics,把可讀性留在 data資料來源延伸閱讀
閱讀設定