在鏈上,你的 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;
}
}$ 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"}, ... ]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 = 0xa9059cbbcall: 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
分派器:合約如何把呼叫導向正確的函式
現在執行期位元組碼的任務一清二楚了。它在每一次呼叫時做的第一件事,就是讀取那 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
要是沒有任何選擇器相符呢?控制權會落到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
最後一個細節能解開一個反覆出現的謎團:為什麼兩份邏輯完全相同的合約,已部署的位元組碼有時卻不一樣?因為 `solc` 會在執行期位元組碼的尾端附上一段中繼資料尾巴(metadata tail)——一段 CBOR 編碼的資料,內含編譯器版本與完整中繼資料 JSON 的 IPFS 雜湊,末尾以一個兩位元組的長度標記收尾。它開頭是拼出 `ipfs` 的位元組,而且永遠不會被執行(它位在每一個 `RETURN` 之後)。改一個註解、一個檔名、或編譯器的修補版號,那個雜湊就會變,於是位元組就變了,儘管行為一模一樣。這也是為什麼 `CREATE2` 部署——它由確切的 init 碼推導出位址——對中繼資料敏感:邏輯相同、中繼資料不同,位址就不同。
至此,EVM 的巡禮就完整了。你現在擁有一個能從頭跑到尾的心智模型:Solidity 被 `solc` 編譯成位元組碼,由 EVM 一次一個操作碼地執行;ABI 是把函式呼叫化為 calldata 的共同慣例;一個 4 位元組的 Keccak-256 選擇器與一個分派器,把那次呼叫導向正確的程式碼;而 gas 為沿途的每一步計量。有了這些,下一階——用 Solidity 寫真正的合約——就不再是個黑盒子了。你已經知道你的程式碼會變成什麼。