合约执行到一半突然回滚,调试器里最常见的一句抱怨是调得太深。以太坊虚拟机对执行栈设了两道相关的闸门:主网参数把单个执行环境的栈深度钉在 1024 项,每次外部调用又只能带走剩余 Gas 的一小部分。两个数字分别管住两种失控:一张栈无限膨胀,和一条调用链无限套娃。本文把这张栈的结构讲清楚,顺便对照比特币脚本那边的一千项上限,看看两条链对同一结构的不同用法。
一张栈的解剖
EVM 是栈式虚拟机:所有计算先把操作数压栈、弹出、结果再压回。栈项定宽 32 字节,也就是一个 EVM Word——地址是缩到 20 字节的 Word,数值、哈希、布尔都住在同一个格子里,类型只存在于指令语义层面。主流实现里这个上限直接写在参数表:go-ethereum 的 protocol_params 里 StackLimit = 1024。对普通合约来说一千项看似遥远,但把整个 ABI 解码结果、几十个局部缓存、一段数组分片同时摊在栈上的递归工具函数,确实能撞上;撞上的表现是栈溢出错误直接使本次调用回滚,同笔交易里上层调用可以捕获,也可能被继续上抛。

调用不是压栈,是开新栈帧
关键设计常被误解:CALL 指令执行合约调用时,不会把被调方的工作区压在同一张栈上,而是新建一个独立的执行环境与栈帧,只通过一段内存交换区传参数与返回数据。好处是模块隔离:被调方炸栈不会污染调用方的栈内容,各帧生命周期独立。代价是每层帧都要预付一整套执行资源,于是必须有闸门防止无限递归——EIP-150 定下的 63/64 规则规定子调用最多带走当前 Gas 的 63/64,每一层都给父帧留住至少六十四分之一。算一笔几何账:任何一笔交易的 Gas 帽除以这套折减,可嵌套层数是有限数,栈深帽 1024 与 Gas 折减是并联的保险丝,谁先触发取决于单笔 Gas 有多大。
和比特币脚本的一千项对照
比特币的脚本引擎同样给栈设限——Core 源码里 MAX_STACK_SIZE 是一千,另有单脚本一万字节、栈上元素单个不超过五百二十字节等一组合伙常量。差别在哲学:比特币脚本单次执行、没有状态循环,栈深上限防的是精心构造的恶意脚本;EVM 有循环、有外部调用、有状态,栈帽加上 Gas 体系防的是程序自身失控加资源挤兑。相同点是两边都选择小而整的天花板:数字不需要多精确,它只要高到正常程序碰不到,又低到恶意程序先耗尽别的资源。
一条算术直觉
把 1024 乘上每项 32 字节,单帧栈的纸面容量是 32768 字节、约 32 KB——比一次中等大小合约调用的内存用量还小。这个对照说明栈被刻意定位为临时工作台:大对象住内存与存储,栈只倒手操作数。写 Solidity 时把它翻译成习惯:不要在函数里声明需要同时入栈的巨型元组,长表达式拆步执行,编译器优化会处理碎步开销。
一次压弹循环的直觉
想体会栈的作用,可以盯着一条普通加法的手续看:先 PUSH1 3 再 PUSH1 5 再 ADD——没有寄存器的世界里,指令唯一能说的是把什么搬上栈、把栈顶两个数吃掉还一个回去。也正因为一切计算都是弹栈与压栈,栈深度就是执行现场的全部现场资料:深度帽定下每条执行路径的可回退空间上限,调试器能 dump 的也只有这不到一 KB 的格子。理解了这点,就知道 EIP-150 为什么在改 Gas 的同时必须顺手管住调用转发比例。
快速问答
问:栈深 1024 与调用深度上限是一回事吗?答:不是:前者管单层帧栈项数,历史上还有独立的外部调用深度帽,两者由不同参数把守,以当期客户端参数表为准。
问:代理合约链会先撞哪一个?答:取决于链上总 Gas:每跳一层折掉六十四分之一以上,层数够多时 Gas 先归零,之后才轮到深度类错误。
问:为什么不用寄存器机?答:栈机编码更短、状态更少,对以 Gas 计费的确定性执行环境格外友好,这是继承自早期设计的工程选择。
风险提示:本文为虚拟机机制科普;参数常量以当期客户端源码为准,性能优化请以实际测试与计量工具数据为依据。
发表评论
还没有评论,来说两句吧。
评论区为展示样式,提交不会被处理。