Bitroot博客
返回官网 ↗
© 2026 Bitroot · 本站内容仅供一般性参考,不构成财务、投资、法律或税务建议。
编辑标准返回官网
← 全部文章
EVM 基础·2026/09/20·约 11 分钟

栈机解剖: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 与不可随机访问这套设计在什么情况下会成为负担资料来源延伸阅读
阅读设置