今天写智能合约的人大概不会想到,「被调用的合约可以返回一条任意长的字符串」这件事,在 2015 年差点因为计费方式而做不到。EVM 最初的 CALL 系列指令(CALL、CALLCODE,当时还没有 DELEGATECALL)要求调用方在执行前就声明输出缓冲区的位置和大小,并且这段预留区间的内存 Gas 要照付——哪怕被调方最终只写了一点点。这意味着:不知道返回数据有多长,就得按最大可能的缓冲区位置付钱;想返回一条长字符串,调用方要为根本没被写过的内存字节掏 Gas——实际效果是动态长度返回贵到不可用。EIP-5 由 Christian Reitwiessner 起草,2015 年 11 月 22 日立项,是以太坊最早一批以编号提案形式记录的规则调整之一,状态 Final,在网络上线的最初阶段就已进入实现。
它的修法在原文里只有一段,但每条都落在关节上。其一,CALL 开始时,内存扩展费只按 输入起点 + 输入长度 计,输出区间不再预付。其二,被调方返回长度为 n 的数据时,调用方的内存扩展到「输出起点加 min(输出长度, n)」并按此计费,数据写入这段实际存在的区域——付多少内存,取决于对方真写了多少。其三,调用结束后 MSIZE 指令返回内存被实际撑到的尺寸。第四,原文明确 CREATE 不受影响,因为它不写调用方内存;并提醒调用方在指令开头和结尾两个时点都可能耗尽 Gas。
第三条看似平淡,其实是被广泛利用的技巧的种子。原提案特意举了一个玩法:调用方把输出起点设为当时的 MSIZE,把声明的输出长度设成 2 的 256 次方减一这个天文数字,于是「返回数据写到了哪」与「数据有多长」被 MSIZE 的前后差值精确定位——不需要事先知道接口返回值的尺寸,也不需要为巨大的假想缓冲区付费。这让代理合约第一次能转发自己完全不了解签名的调用:探测返回长度、原样转交、事后按实际尺寸计费。以太坊早期模式库里的 delegate 代理与后来的通用转发器,都能看出这段机制的影响。
站在今天的价目表回看,EIP-5 的具体数字大多已被后续规则覆盖——内存计价在柏林升级的 EIP-150/2929 系列、伦敦升级之后的调整里反复重算,DELEGATECALL(EIP-7)与 STATICCALL(EIP-214)也为 CALL 家族添了新成员并沿用这套「按实际写出付内存」的语义骨架。但它确立的原则没有变过:调用指令的资源计费跟着数据流走,而不是跟着调用方的恐惧走。读早期 EIP 的价值正在于此——很多被当作「EVM 天性」的行为,其实都是一份两页纸的提案在某个具体日期做的一次具体选择。
用一个小剧场把新旧规矩演完:合约 A 调用 B,B 返回一条 32 字节的哈希。旧规矩下,A 若把输出缓冲区设在内存 5000 字节的位置,就要为撑到 5032 的内存全额付费,哪怕 B 其实只写了 32 字节;新规矩下,A 先把缓冲区「预订」在自己内存末尾(用当时的 MSIZE 当起点、声明长度拉到天文数字),B 返回后实际写多少、内存就长多少,费用与数据等量。多出来的那笔「探测」费用为零,因为 MSIZE 本来就免费。一个计费口径的改动,就是一次接口能力的开闸——这句话在今天的 L2 与账户抽象讨论里仍然天天应验。
快速问答
问:现在读 EIP-5 还有意义吗,规则不是改了很多轮? 答:有。今天 CALL 家族的内存语义主线——输出按实际返回长度扩展、MSIZE 反映真实水位——仍是 EIP-5 定的框架,后续提案改的是单价与边界条件,不是这个骨架。
问:静态调用会被这条规则影响吗? 答:STATICCALL 继承同一套输出语义,区别只在禁止状态写入;EIP-5 讨论的「按写出的内存收费」对它同样成立。
风险提示:Gas 与内存价格表随网络升级频繁调整,做成本估算时以当期客户端实现为准,本文不复述任何具体单价现值。

发表评论
还没有评论,来说两句吧。
评论区为展示样式,提交不会被处理。