---
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/blog/evm-compatibility-explained)
- 相关阅读：[《Bitroot 乐观并行化机制：检测、重执行与确定性》](/zh/blog/bitrootevm-)
- 下一篇：[《为什么单线程 EVM 会卡住 TPS：从历史拥堵到执行模型》](/zh/blog/evm-single-thread-bottleneck)
