N 份 gas 最多用 N 字节内存:EIP-7686 的线性内存上限 图 1
N 份 gas 最多用 N 字节内存:EIP-7686 的线性内存上限 · 图 1

EVM 里看起来最平平无奇的资源——内存——其实有一套挺别扭的定价规则:扩容内存要按字数的平方交钱,子调用又受”最多带走当前 gas 的六十三分之六十四”这条老规矩约束。规则叠规则,结果是谁也说不清一笔交易最坏情况下到底能吃掉多少内存。EIP-7686(2024 年 4 月创建,作者 Vitalik Buterin,目前状态 Stagnant)提出换一种思路:与其修修补补,不如直接给内存设一条线性的硬上限——一笔花 N gas 的交易,最多只能用到 N 字节内存。

现在的规则哪里别扭

现行内存计价是两段式:每多用 32 字节的一”字”,付 3 gas 的线性部分,外加字数平方除以 512 的二次方部分。二次方项的用意是让大额内存占用越来越贵,防止低价撑爆节点。但它带来两个副作用:一是内存用量与 gas 消耗不再成比例,二是算”处理一笔交易最多多需要多少内存”变得非常困难——因为子调用还能通过 63/64 规则把剩余 gas 递归带下去,每层再各自展开内存。对写客户端的人来说这不算什么大问题(真实机器上按需分配就行),但对在受限环境里实现 EVM 的人——比如需要把整段执行装进约束系统里做 ZK-SNARK 证明的实现——一个算不出上界的内存需求是真正的麻烦。

两条新规则

提案的规范部分只有两条。第一条,把 memory_cost 里的平方项去掉,只保留每字 3 gas;并且任何内存展开只要让内存字节数严格超过当前调用的初始 gas 上限,就回滚。第二条,子调用的最大 gas 从”gas 减去 gas 整除 64”改为”gas 减去 max(gas 整除 64, 当前内存字节数)“。两条合起来的效果:这一层用了多少字节内存,就能从可传给子调用的 gas 里被扣掉多少,递归展开的总量因此被最初的 gas 死死封顶。提案特别强调这条界是紧的——有简单写法能让 N gas 的调用用到接近 N 字节的内存,不是虚高的安全余量。

为什么保留 63/64 那一项?为了维持现有的调用栈深度上限(提案给出的估算约为 537 层),避免顺手改掉一个与本意无关的参数。为什么每字 3 gas 不变?提案给的理由有意思:3 gas 每字恰好相当于 MCOPY 每个字的成本,于是”子调用结束时清空内存”这个动作可以直接复用 MCOPY 的实现逻辑——这在 ZK-SNARK 这类不寻常环境里是有价值的工程统一。

谁会碰到新线

理论上,今天能跑的代码如果在很低的 gas 预算下访问很高的内存下标,会在新规则下失败。但提案算了笔账:几乎每一次实际执行消耗的 gas 都远高于其用到的字节数——哪怕只做一次状态改变也要 5000 gas 起,对应 5000 字节的内存额度,超过几乎所有应用的用量;更复杂的应用本来就带着更高的 gas 来。所以撞上这条线的情形被认为极少,而换来的收益是每个实现都能事先声明一个确定的内存分配方案:给当前上下文分满 N 字节,子调用从 memory_byte_size 位置接着往下分,如此递归,没有意外。

一笔直觉账

把两条规则翻译成一句可以带走的口诀:在现行规则里,gas 是时间预算,内存是另一本没有明码上限的账;在提案规则里,两者合并成一本书——你买了多少 gas 的内存额度,就只能写多少字节的内存,一分钱一分货,没有借来的空间。判断一笔交易的最坏内存需求从”要模拟整棵调用树”降级成”读一个数字”,这是整个提案真正的卖点。

快速问答

问:二次方计价取消后,低价撑内存的攻击回来了吗? 答:没有空间可撑——线性价加硬上限意味着内存需求不可能超过 gas 本身,攻击上限只是从”算不出”变成”等于 N”。

问:这条提案会让现有合约大面积出问题吗? 答:提案的兼容性分析认为只有”低 gas 高内存下标”这种罕见写法可能受影响,绝大多数执行远达不到这条线。

问:它现在生效了吗? 答:没有,状态 Stagnant;主网仍是二次方计价加 63/64 的组合。

风险提示:本文仅作机制科普,不构成投资建议;gas 与参数事实以提案原文及当期客户端文档为准。