世界電腦也需要一顆 CPU
在前面幾階你已經學到,以太坊常被稱作「世界電腦」:一台共享的機器,它的記憶體是全域狀態,它的程式是智慧合約。但合約究竟在哪裡「執行」?當你部署一份合約時,你上傳的並不是 Solidity 原始碼——而是一串位元組。網路上每個節點都必須執行這串位元組,並得到完全一致的結果,否則鏈就會分叉。負責在數千台機器上以同樣方式完成這項執行的元件,就是以太坊虛擬機(EVM)。
EVM 是一台「虛擬」機——它並不以矽晶片的形式存在,而是一份規格,由軟體實作(Geth、Reth、Nethermind 等等)。而且它刻意被設計成你能想像中最簡單的 CPU 之一:一台堆疊機(stack machine)。如果你曾用過老式 HP 計算機的「反向波蘭表示法」,按 `2`、`3`、`+` 而不是 `2 + 3`,那你心裡的模型就已經對了。EVM 沒有暫存器,做算術時也沒有可隨機存取的便箋;它只有一個堆疊,幾乎每個運算不是把值推上去,就是把值彈下來。
機器的解剖:堆疊、字組與程式計數器
一次 EVM 執行有幾個會動的零件。主角是堆疊:一個後進先出(LIFO)的清單,最多容納 1024 個項目。每個項目都是一個 256 位元字組——也就是 32 個位元組寬。其他零件(後續幾篇會深入講)包括:memory(一段暫時的位元組陣列,呼叫結束就清空)、storage(合約的永久鍵–值映射)、calldata(唯讀的輸入),以及程式計數器(PC),一個指出我們接下來要執行程式碼中哪一個位元組的索引。
明明多數 CPU 用 32 或 64 位元,為什麼 EVM 要用 256 位元?因為 EVM 的整個世界都建立在密碼學之上。一個 Keccak-256 雜湊是 256 位元。位址背後曲線運算的輸出是 256 位元。Solidity 的原生整數是 `uint256`。把字組大小設成與雜湊大小一致,EVM 就能把一個完整的雜湊、一把完整的 storage 鍵、或一筆完整的餘額,放進單一堆疊格而不必拆開——代價是對小數字很浪費,因為 256 個位元裡有 255 個都是 0。
EVM execution context (simplified) code: the contract's bytecode, a byte array e.g. 60 02 60 03 01 pc: program counter, index into code starts at 0 stack: up to 1024 words, each 256 bits [ ] (empty at start) memory: volatile byte array, grows as needed (cleared each call) storage: persistent map slot(256b) -> word(256b) (survives the call) gas: remaining gas, decremented per opcode
執行其實是一個緊湊的迴圈:讀取 `pc` 處的操作碼、照它說的做(碰觸堆疊、也許還有 memory/storage)、扣掉它的 gas、把 `pc` 往前推、再重來。當程式碼跑完、或碰到 `STOP`/`RETURN`、或遇到錯誤(例如 gas 用盡)時,迴圈結束——後者會讓 EVM 進行一次回滾(revert),把這次呼叫期間做過的每一項狀態變更全部丟棄。
操作碼:一次一個位元組
合約的程式碼是一連串操作碼——而因為每個操作碼只佔一個位元組,最多就只能有 256 種。EVM 定義了大約 140 種,可分成幾個家族:算術(`ADD`、`MUL`、`SUB`、`DIV`、`MOD`、`EXP`)、比較與位元運算(`LT`、`GT`、`EQ`、`AND`、`OR`、`XOR`、`NOT`)、雜湊(`KECCAK256`)、讀取環境(`CALLER`、`CALLVALUE`、`NUMBER`、`TIMESTAMP`)、資料位置運算(`MLOAD`/`MSTORE`、`SLOAD`/`SSTORE`、`CALLDATALOAD`)、流程控制(`JUMP`、`JUMPI`、`JUMPDEST`、`PC`),以及系統運算(`CALL`、`CREATE`、`RETURN`、`REVERT`)。
那值一開始是怎麼被放上堆疊的?靠 `PUSH` 家族。`PUSH1` 把程式碼接下來的 1 個位元組當成 256 位元字組推上去;`PUSH2` 推接下來的 2 個位元組;一路到 `PUSH32` 推一整個字組。這些是唯一會直接從程式碼串流讀取運算元的操作碼——其餘每個操作碼都從堆疊取得輸入。這就是為什麼位元組碼看起來像資料與指令交錯在一起:一個 `PUSH1` 後面一定跟著它要推上去的那個字面位元組。
因為堆疊嚴格遵守後進先出,你沒辦法伸手到下面去抓出從底部數來第三個項目。正好有兩個家族專門用來繞過這點:`DUP1`…`DUP16` 把從頂端數來第 n 個項目複製一份放回頂端,而 `SWAP1`…`SWAP16` 把頂端與其下方第 n 個項目對調。編譯後的 Solidity 有出乎意料多的部分都在做 `DUP`/`SWAP` 的搬移,因為要把對的值維持在堆疊頂端伸手可及之處,本身就是一件實實在在的工作。
PUSH1 0x60 push the next 1 byte as a 256-bit word ADD 0x01 pop a, pop b, push (a + b) mod 2^256 MUL 0x02 pop a, pop b, push (a * b) mod 2^256 SUB 0x03 pop a, pop b, push (a - b) mod 2^256 <- order matters: top - next POP 0x50 pop one item and discard it DUP1 0x80 duplicate the top item SWAP1 0x90 swap the top two items JUMPDEST 0x5b a legal jump target (does nothing else)
逐步追蹤一支程式:在堆疊上算 2 + 3
我們來跑那支經典的第一支程式。化成位元組碼它只有五個位元組:`0x6002600301`。由左讀到右,就是 `PUSH1 0x02`、`PUSH1 0x03`、`ADD`。我們會讓程式計數器一步步走過它,並觀察堆疊如何變化。下面把堆疊畫成頂端在右邊,那正是操作碼動手的那一端。
bytecode: 60 02 60 03 01
^pc=0 ^pc=2 ^pc=4
--- step 1 --- pc=0 read 0x60 = PUSH1, operand 0x02
action: push 2
stack: [ 2 ] gas: -3
--- step 2 --- pc=2 read 0x60 = PUSH1, operand 0x03
action: push 3
stack: [ 2, 3 ] <- top is 3 gas: -3
--- step 3 --- pc=4 read 0x01 = ADD
action: pop 3, pop 2, push (2 + 3)
stack: [ 5 ] gas: -3
pc=5: end of code -> halt. Result 5 sits on top of the stack.
Total gas for computation: 3 + 3 + 3 = 9.留意第 1 步和第 2 步的細節:`PUSH1` 是操作碼一個位元組,再加上它攜帶的字面值一個位元組,所以第一次 push 之後,程式計數器是從 0 跳到 2,而不是 0 到 1。那個運算元位元組 `0x02` 是坐在程式碼裡的資料,永遠不會被當成指令執行。相對地,`ADD` 不從程式碼取運算元——它的兩個輸入都從堆疊讀取——所以它只佔一個位元組,計數器只前進 1。
控制流:跳躍、JUMPDEST 與迴圈
對一支真正的程式來說,直線型的算術還不夠——你需要 `if` 和 `while`。EVM 沒有高階控制結構;它就只有兩個會移動程式計數器的跳躍操作碼。`JUMP` 從堆疊彈出一個目的地,把 `pc` 設成它。`JUMPI`(條件跳躍)彈出一個目的地以及一個條件,只有在條件非零時才跳——每一個 `if`、`require` 與迴圈,都是用這一個操作碼搭出來的。
這裡有一條關鍵的安全規則:你只能跳到一個內容為 `JUMPDEST` 操作碼(`0x5b`)的位元組。`JUMPDEST` 本身什麼也不做——它是一個標籤,標記一個合法的著陸點。少了這條規則,攻擊者就能跳進某個 `PUSH` 的運算元位元組中間,騙 EVM 把資料重新詮釋成指令。EVM 會掃描程式碼找出有效的 `JUMPDEST`,並對任何跳到非著陸點的跳躍以回滾拒絕之。
// goal: if (x == 0) return 99; else return x; (x already on the stack) DUP1 // [x, x] keep a copy to return later ISZERO // [x, x==0 ? 1 : 0] PUSH1 0x09 // [x, cond, 0x09] address of the JUMPDEST below JUMPI // [x] pop dest 0x09 and cond; jump there iff cond != 0 // ---- fall-through path: x was non-zero, x is on top, return it ---- // (RETURN logic would go here) // ---- pc 0x09: the zero case ---- JUMPDEST // legal landing pad at byte 0x09 POP // discard x PUSH1 0x63 // push 99 // (RETURN logic would go here)
迴圈就是同一招、只是把方向往回指:做一些工作、測試一個條件,若該再跑一輪就用 `JUMPI` 跳回先前的某個 `JUMPDEST`。沒有獨立的迴圈指令。這也是為什麼無窮迴圈在以太坊上不像在一般電腦上那樣是場災難:每一輪都在燒 gas,gas 用盡時 EVM 就停下並回滾。Gas——本階第三篇會講——正是那條牽繩,讓再有錯或再惡意的程式碼也無法永遠跑下去。
為什麼是堆疊機?決定性、gas 與取捨
現在這些設計取捨整體看來就說得通了。EVM 之所以是堆疊機,是因為堆疊機小而無歧義:沒有暫存器要配置、指令集很精簡,而且同一份位元組碼在每個實作上都產生相同的堆疊。這種決定性沒有商量餘地——共識要求東京的節點和聖保羅的節點算出逐位元組完全相同的結果,所以 EVM 禁止一切非決定性的東西(沒有真正的隨機、沒有浮點數、除了區塊本身的欄位以外沒有牆上時鐘的時間)。
堆疊機也讓 gas 計量變得乾淨俐落:因為每個操作碼對堆疊的作用都是固定且已知的,你就能給每個操作碼指定一個固定的 gas 價格,並一邊執行一邊以決定性的方式累加成本。`ADD` 與 `PUSH1` 各花 3 gas;`JUMPDEST` 花 1;`JUMP` 花 8;一次全新的 storage 寫入要花 20,000。定價之所以以操作碼為單位,正是因為這台機器本身就是以操作碼為單位運作的。
這些取捨是真實存在的,值得誠實點名。256 位元的字組大小對一般的值來說大得離譜——就算只是讓一個計數器從 0 跑到 100,搬動的還是 32 位元組的字組——這浪費了運算,也是 256 位元運算相對於原生硬體昂貴的原因之一。只有堆疊的模型意味著大量 `DUP`/`SWAP` 開銷,這是暫存器機器所沒有的。正因如此,像 eWASM 這樣的提案才曾探索改把合約編譯成 WebAssembly。不過就目前而言,是 EVM 的簡單性勝出了:它那微小、具決定性的核心,正是讓人能對它做形式化推理、能在眾多用戶端重新實作、並能在整個網路上信任它的關鍵。