---
id: 22
title: 堆疊機解剖：256-bit 字、1024 堆疊深度與執行迴圈
slug: 0-4-stack-machine-anatomy
date: 2026/09/20
summary: EVM 是一台沒有暫存器的堆疊機：256 位字長、1024 項堆疊深度、一個只能跳到 JUMPDEST 的程式計數器。取指、解碼、執行這一輪迴圈解釋了堆疊下溢與溢位為何燒光 gas，也解釋了堆疊機相對暫存器機買到了什麼、付出了什麼代價。
keywords: EVM堆疊機,256位字長,堆疊溢位,程式計數器,DUP與SWAP
heroImage: /images/articles/photos/0-4-stack-machine-anatomy.jpg
---

`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 退款：

```python
# 簡化版執行迴圈，保留規範中的判定順序
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 與工具鏈》](/zh-Hant/blog/evm-compatibility-explained)
- 相關閱讀：[《Bitroot 樂觀平行化機制：偵測、重新執行與確定性》](/zh-Hant/blog/bitrootevm-)
- 下一篇：[《為什麼單執行緒 EVM 會卡住 TPS：從歷史壅塞到執行模型》](/zh-Hant/blog/evm-single-thread-bottleneck)
