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

堆疊機解剖:256-bit 字、1024 堆疊深度與執行迴圈

EVM 是一台沒有暫存器的堆疊機:256 位字長、1024 項堆疊深度、一個只能跳到 JUMPDEST 的程式計數器。取指、解碼、執行這一輪迴圈解釋了堆疊下溢與溢位為何燒光 gas,也解釋了堆疊機相對暫存器機買到了什麼、付出了什麼代價。

0x6001600201 這五個位元組在 EVM 裡做了一件事:把 1 和 2 相加,堆疊頂端留下 3。它沒有引用任何暫存器,也沒有寫出結果的存放位置,兩個運算元在堆疊上隱式就位,結果也回到堆疊上。EVM 的全部算術與控制流都建立在這個模型上。

前幾篇一直在講狀態機與帳戶,這一篇向內看一層:這台機器由什麼組成,一輪執行迴圈如何運轉,256 位字長與 1024 項堆疊深度從何而來,以及這套堆疊機設計買到了什麼、又付了什麼代價。

256 位字長:為密碼學配套,而不是為算術配套

黃皮書對字長的解釋只有一句:機器字長(也就是堆疊元素的寬度)為 256 位,這個選擇是為了方便 Keccak-256 雜湊與橢圓曲線運算。展開來看有三個具體理由。

Keccak-256 的輸出是 256 位,雜湊值與儲存鍵都不必截斷或擴展。以太坊用 secp256k1 上的 ECDSA 做簽章,私鑰與簽章成分落在 256 位空間裡,EVM 側的對應能力由 ecrecover 提供,它是 0x01 地址上的預編譯合約,定價 3000 gas。地址是 160 位,放進 256 位的字裡天然對齊。

這裡有一個常被忽略的精度問題:以太坊用的 Keccak-256 並不是 NIST 標準化之後的 SHA3-256。兩者輸出位寬相同、置換函數相同,但填充的域分隔位元組不同:原始 Keccak 用 0x01,SHA3-256 用 0x06。做雜湊校驗時必須選明確標註 Keccak-256 的函式庫,用 SHA3-256 會得到完全不同的結果。

字寬的代價同樣明確。所有算術都在模 2^256 下進行,溢位靜默環繞,不產生異常;一個布林值也佔用完整的 32 位元組;calldata 與儲存槽按 32 位元組對齊,短類型需要在編碼層打包,Solidity 的 storage packing 做的就是這件事(參見同系列的 0.19《Storage Layout:Solidity 狀態變數如何落到 slot》)。環繞語意還是歷史上大量整數溢位漏洞的根源。Solidity 0.8 起預設插入檢查並以 Panic(0x11) 回滾,屬於語言層補救,EVM 本身沒有改。

機器狀態:堆疊、記憶體、程式計數器各管什麼

黃皮書把機器狀態寫成六元組 μ = (g, pc, m, i, s, o):可用 gas、程式計數器、記憶體內容、當前活躍記憶體字數、堆疊內容,以及回傳資料緩衝區。四類組件的分工可以並排看:

組件定址與單位存活範圍相關 gas 成本
堆疊 stack只可見頂端,每項 256 位,上限 1024 項單個呼叫框架操作碼自身的 2 到 3 gas
記憶體 memory按位元組定址,擴容以 32 位元組字為單位單個呼叫框架,新框架從全零開始3a + ⌊a²/512⌋,a 為活躍字數
程式計數器 PC程式碼的位元組偏移單個呼叫框架JUMP 8 gas、JUMPI 10 gas、JUMPDEST 1 gas
gas 計數器可用 gas,一個非負整數整筆交易,呼叫框架之間按呼叫參數轉移剩餘 gas每條指令按費用表扣減

堆疊(stack)是唯一的隱式運算元區。它只暴露頂端:絕大多數指令從頂端彈出參數、把結果壓回頂端,中間元素對指令不可見。上限 1024 項,每項 256 位。

記憶體(memory)按位元組定址,所有位置初始為零,可以被 MLOAD/MSTORE(按 32 位元組字讀寫)和 MSTORE8(寫一個位元組)存取。它的成本是動態的:擴容按活躍字數計費,黃皮書給出的公式是佔用 a 個字時總記憶體成本為 3a + ⌊a²/512⌋ gas,二次項使得存取一個極大的偏移會直接耗盡 gas。所以 EVM 記憶體裡不存在免費的大型陣列,也不存在「讀到未初始化資料」的問題,沒寫過的位置恆為 0。

程式計數器(program counter,PC)是下一條指令在程式碼裡的位元組偏移。控制流只能透過 JUMP/JUMPI 改變,而黃皮書把合法跳轉目標集合定義為程式碼中 JUMPDEST 指令出現的位置。目標集合因此是靜態可列舉的,執行時無法算出任意地址跳過去。這條約束是離線的控制流分析能夠成立的基礎。

程式碼本身不放在堆疊、記憶體或儲存裡。黃皮書明確指出機器不遵循馮·諾伊曼結構,程式碼單獨保存在一段只能透過專用指令存取的虛擬 ROM 中。因此已部署的合約程式碼不會被自身改寫,也不存在執行中途替換指令流的可能。儲存與記憶體是可變的,程式碼不是。

取指、解碼、執行:一輪迴圈

黃皮書把「當前要執行的指令」定義為一段分段函數:若程式計數器小於程式碼長度,指令就是該位置的位元組;否則指令等價於 STOP。也就是說,讀越過程式碼末尾不構成錯誤,規範用這種方式定義了自然結束。

一條指令要被執行,先要知道三件事:它會彈出多少項(δ)、壓入多少項(α),以及花多少 gas(成本函數 C)。這三個量由指令自己決定,寫在操作碼表的行裡。於是迴圈可以寫成下面這樣,省略了 substate、access list 與 gas 退款:

# 簡化版執行迴圈,保留規範中的判定順序
pc, gas, stack, memory = 0, gas_limit, [], bytearray()

while True:
    # 取指:越過程式碼末尾等價於 STOP
    op = code[pc] if pc < len(code) else STOP
    # 解碼:查表得到彈出數、壓入數與成本
    delta, alpha, cost = OPCODE_TABLE[op]
    # 校驗發生在執行之前:gas 不足、堆疊不夠、堆疊溢位都是異常停止
    if gas < cost or len(stack) < delta or len(stack) - delta + alpha > 1024:
        raise ExceptionalHalt()
    gas -= cost
    # 執行:從堆疊頂端取運算元,把結果壓回堆疊頂端
    args = [stack.pop() for _ in range(delta)]
    stack.extend(dispatch(op, args, memory, pc))
    # PC 前移;PUSH 類指令還要跳過緊隨其後的立即值
    pc += 1 + immediates(op)

註解對應三處容易寫錯的地方:越界的取指語意、校驗必須先於執行、PUSH 的立即值佔用程式碼空間。

最後一點需要展開。PUSH1 到 PUSH32 把常量直接編碼在操作碼之後,例如 PUSH1 0x2a 佔兩個位元組。這帶來一個反直覺的後果:位元組碼裡不是每個位元組都是指令,跳轉目標掃描必須識別並跳過立即資料,否則可能把常量裡的某個位元組誤判成 JUMPDEST。

把 60 2a 60 5b 56 這五個位元組攤開看會更清楚。60 2a 是一條帶立即值的指令;60 5b 的第二個位元組是常量 0x5b,它的數值和 JUMPDEST 的操作碼完全一樣,但它不是指令;56 才是 JUMP。掃描合法跳轉目標時,必須先識別 0x60 再跳過緊隨其後的一個位元組。規則本身不複雜,只是要求任何做控制流分析的實作把 PUSH 的長度算準,否則目標集合會被常量位元組污染。

這也解釋了為什麼需要一個專門的 0x5b 作為跳轉標記,而不是允許跳到任意偏移。

堆疊下溢與溢位:兩種異常,同一個結局

黃皮書的異常停止判定函數 Z 列出了所有會讓執行立即中止的條件,其中與堆疊有關的兩個是:堆疊內元素少於該指令要彈出的數量,即下溢(stack underflow);執行後堆疊高度超過 1024,即溢位(stack overflow)。

兩者走同一條路:異常停止,剩餘 gas 全部消耗,本次呼叫框架內的狀態變更全部丟棄。EIP-3855 的規範測試向量給了一個乾淨的對照:連續 1024 條 PUSH0 執行成功,連續 1025 條因堆疊溢位而中止。

需要與另一種「失敗」區分開。REVERT 指令(0xfd,Byzantium 起,EIP-140)同樣回滾狀態,但不消耗剩餘 gas,還能將記憶體中的一段位元組作為錯誤資料回傳給呼叫方。Solidity 的 require 失敗通常編譯成 REVERT,錯誤訊息能帶回呼叫方,剩餘 gas 也不會被消耗;堆疊下溢是硬異常,除錯時表現為 gas 耗盡且無回傳資料。除錯經驗裡,一筆交易燒光 gas 且拿不到回傳資料,常見原因是位元組碼踩到硬異常,計算量本身未必大。

邊界也要寫清楚:如果 REVERT 自己 gas 不夠,或者它執行時遇到堆疊下溢,它會退化成普通異常,同樣消耗全部 gas。

堆疊機的收益:把實作複雜度壓到最小

ethereum.org 的解釋是,堆疊結構是虛擬機的首選架構,因為它易於實作,出錯與產生安全漏洞的可能性因此更低。這個收益可以拆成幾條。

解碼器極簡。操作碼是一個位元組,運算元的位置由堆疊順序隱含決定,位元組碼裡不需要為每條指令編碼暫存器編號。除了 PUSH 類指令帶立即值,指令長度基本固定,解碼就是一次查表。

規範裡沒有暫存器分配這一層。分配策略本來是編譯器的自由度,一旦進入虛擬機規範,就會變成所有實作必須逐位一致的行為。堆疊機把「值放在哪」從規範中刪掉,共識需要固定的狀態描述因此變小。

堆疊上操作的語意幾乎逐條獨立定義,gas 成本可以直接掛在操作碼上,靜態分析、模糊測試與形式化驗證的搜尋空間都更小。EVM 能有多個獨立實作(geth、revm、evmone 等)並保持位元組級一致,這是原因之一。

堆疊機的代價:DUP、SWAP 與不可隨機存取

堆疊只暴露頂端的代價,體現在位元組碼的指令數量上。

要使用靠近頂端的元素,可以靠 DUP1 到 DUP16 把它複製到頂端,或靠 SWAP1 到 SWAP16 把它換到頂端。十六是硬上限:想複製的元素位於第 17 層,就必須先用 SWAP16 之類把它換到可複製的範圍內再繼續操作,堆疊越深,搬運鏈條越長。同一份計算在暫存器機上往往一條指令就能完成,在這裡會展開成壓入堆疊、複製、交換、彈出的一串。

舉個具體場景。合約要計算 f(a, b, c, d),而 d 位於堆疊的第 4 層。暫存器機可以直接引用 d 所在的暫存器;堆疊機上編譯器要麼提前把 d 複製一份放在更靠近頂端的位置,要麼在呼叫前用 SWAP 系列把它換上來、用完再換回去。Solidity 編譯器在函式參數較多時產生大量這類重排指令,原因就在這裡。

編譯器還必須維持堆疊平衡。每個基本區塊結束時堆疊高度應當可預測,否則跳轉之後的程式碼無法定位運算元。Solidity 編譯期因此要專門做堆疊排程,在參數與區域變數較多時插入 DUP、SWAP 與 POP 來搬運運算元。這些指令本身不貴,多數屬於 3 gas 一檔,但會拉長解譯器的執行路徑。

堆疊機相對暫存器機要多付出什麼,可以在 JVM 上找到間接參照。Davis 等人把 JVM 位元組碼翻譯到虛擬暫存器機,報告執行指令數下降、位元組碼取指次數上升的取捨(Davis 等,2003)。這份對照來自 JVM,不能直接搬到 EVM:它測的是解譯執行的派發開銷,而 EVM 的 gas 定價已經把大部分解譯開銷外部化。能支持的方向性判斷只有一條:同樣一段計算,堆疊機通常需要更多條執行指令,暫存器機用更多的取指換更少的執行指令。

EVM 的設計者接受了這個代價,換回實作簡單與規範確定。近年也有小的增量改進,例如 PUSH0(EIP-3855,Shanghai)用一條 1 位元組、2 gas 的指令替代了 2 位元組、3 gas 的 PUSH1 0x00。

這套設計在什麼情況下會成為負擔

表達式嵌套很深、函式參數很多時,堆疊重排指令的佔比會上升,合約執行的 gas 消耗隨之抬高。位寬處理上,EVM 沒有天然的 8 位、32 位、64 位類型,一切都要顯式截斷或遮罩,跨語言移植時需要格外注意。

堆疊深度是 1024,但這裡的 1024 與另一個常被混淆的 1024 不是一回事。黃皮書在 CALL/CREATE 的定義裡把呼叫深度同樣限制在 1024。前者限制單一框架內的運算元數量,後者限制呼叫鏈長度;踩到前者是程式錯誤,踩到後者通常意味著遞迴寫得不對。

還要劃一條邊界:堆疊機不是平行執行的障礙。平行要處理的是全局可變狀態下的讀寫衝突,而運算元在堆疊上的哪個位置由堆疊順序隱含決定,這件事與衝突偵測、執行確定性都沒有關係。真正的約束在狀態層,屬於後面幾篇的範圍。

資料來源

  • Ethereum Yellow Paper(機器狀態六元組、堆疊上限 1024、記憶體成本公式、JUMPDEST 目標集合、異常停止函數 Z、gas 費用表與操作碼表、ECREC 預編譯定價):https://ethereum.github.io/yellowpaper/paper.pdf
  • ethereum.org,Ethereum Virtual Machine (EVM)(堆疊深度 1024、256 位字長與 Keccak-256 / secp256k1 的關係):https://ethereum.org/developers/docs/evm/
  • ethereum.org,Understanding the Yellow Paper's EVM Specifications(堆疊機易於實作故缺陷更少、256 位字的選取理由):https://ethereum.org/developers/tutorials/yellow-paper-evm/
  • Keccak Team,Keccak specifications summary(SHA3-256 的後綴位 0x06 與填充過程):https://keccak.team/keccak_specs_summary.html
  • Keccak Team,The Keccak reference, version 3.0(原始 Keccak 的多速率填充 pad10*1):https://keccak.team/files/Keccak-reference-3.0.pdf
  • NIST,FIPS 202(SHA-3 填充 0x06):https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.202.pdf
  • EIP-3855,PUSH0 instruction(0x5f、2 gas、1024 與 1025 條 PUSH0 的測試向量):https://eips.ethereum.org/EIPS/eip-3855
  • EIP-140,REVERT instruction(回滾但不消耗全部剩餘 gas,以及退化為異常的邊界):https://eips.ethereum.org/EIPS/eip-140
  • Davis、Beatty、Casey、Gregg、Waldron,The Case for Virtual Register Machines,2003(把 JVM 位元組碼翻譯到虛擬暫存器機,報告執行指令數下降、位元組碼取指次數上升):https://mural.maynoothuniversity.ie/id/eprint/10191/1/KC-Case-2003.pdf
  • Solidity 0.8.0 Release Announcement(算術運算預設檢查、以 Panic(0x11) 回滾):https://www.soliditylang.org/blog/2020/12/16/solidity-v0.8.0-release-announcement/
  • ethereum/execution-spec-tests(PUSH0 與堆疊溢位的規範測試):https://github.com/ethereum/execution-spec-tests

延伸閱讀

EVM 相容意味著什麼:位元組碼、預編譯、JSON-RPC 與工具鏈相關閱讀Bitroot 樂觀平行化機制:偵測、重新執行與確定性相關閱讀為什麼單執行緒 EVM 會卡住 TPS:從歷史壅塞到執行模型下一篇
← 上一篇EVM 到底是什麼:從分散式帳本到分散式狀態機下一篇 →三類儲存別搞混:Memory、Storage 與 Transient Storage
本文目錄
256 位字長:為密碼學配套,而不是為算術配套機器狀態:堆疊、記憶體、程式計數器各管什麼取指、解碼、執行:一輪迴圈堆疊下溢與溢位:兩種異常,同一個結局堆疊機的收益:把實作複雜度壓到最小堆疊機的代價:DUP、SWAP 與不可隨機存取這套設計在什麼情況下會成為負擔資料來源延伸閱讀
閱讀設定