以太坊在2019年2月的拜占庭升级里给EVM加了一对不起眼的指令:RETURNDATASIZE和RETURNDATACOPY,出处是Christian Reitwiessner在2017年2月写的EIP-211,状态Final。它们解决一个非常具体的老问题:合约A调用合约B,B的返回数据长度可能事先根本不知道,A却必须在发起调用前就决定往内存的哪个位置、按多大尺寸接数据。EIP-211的答案是先把返回数据放进一块虚拟缓冲区,调用结束后再量、再搬。
加指令之前,接不定长度的数据要多花钱
在EVM的设计里,调用指令的参数包括一段内存区间,被调用者的输出要写进这个区间。输出长度已知时一切顺畅;长度未知时,只能玩花活——比如拆成两次调用,第一次只问长度,第二次再取数据,费用直接翻倍。规范文本举的最坏例子是通用转发合约:它收下调用数据,做点检查,原样转给另一个合约,再原样把结果带回去。它根本不知道对面返回多长,不改造虚拟机就只能按对数次数反复试探调用。这类绕路在费用上都非常难看,而它们出现的原因只是“输出没地方临时放”。
虚拟缓冲区:像calldata一样记账
EIP-211的机制和calldata的处理方式刻意保持同构。calldata的特征是:数据存在当前调用帧之外,不占普通内存,想看先量尺寸再按需复制进内存。EIP-211给返回数据同样待遇:每次调用结束,输出进一个虚拟缓冲区;RETURNDATASIZE读出这块缓冲的长度,RETURNDATACOPY把整段或其中一部分复制进内存。缓冲区不在内存里,读它不产生内存扩容费用,只有真正复制进内存的字节才按复制计费,Gas记账简单是这条提案最大的卖点。规则里还有一条容易被忽略:下一次调用会直接覆写这块缓冲。所以编译器生成的代码要在每次调用后立刻把需要的返回数据搬走,攒着以后统一处理是行不通的。越界复制会被判执行失败,这一条也写进了规范,防止拿它去窥探缓冲区之外的东西。
和REVERT拼成完整的返回值故事
EIP-211在动机部分特别提到EIP-140:让失败也能带数据返回的那条提案。两条拼起来才完整——成功时有缓冲区可量可搬,失败时同样能把错误详情放进缓冲区,即使调用方预留的输出区根本装不下,也能事后用这两条指令取回。今天用高级语言写合约的人早就感觉不到这套机制的存在,编译器自动安排缓冲区与复制;但只要手写过一次底层的call加returndata组合,就会明白为什么规范作者坚持“它百分之百向后兼容”:不改任何旧指令,只补存放位置和读取工具。
一块缓冲区的三次转手
用一条转账路由链感受时序:用户调用聚合器,聚合器调用交易所适配器。适配器返回一段不定长的报价数据,进入聚合器帧的缓冲区;聚合器量出长度,把需要的字段复制进内存做检查;随后聚合器调用结算合约——上一次调用留下的缓冲此刻被新调用的输出覆写。如果聚合器到这时才想起来搬报价数据,拿到的已是结算合约的返回值。这个陷阱的唯一解法就是规范建议的模式:调用一结束立刻处理返回值,或者先复制进内存存档。虚拟缓冲区是每帧一次性的驿站,不是仓库。
快速问答
问:缓冲区会占用内存费用吗? 答:放在缓冲区本身不产生内存费,只有RETURNDATACOPY复制进内存的字节才进入内存计价。 问:为什么需要两条指令而不是一条? 答:量尺寸和搬数据的费用结构不同,先量后搬让调用方只为真正需要的部分付复制费,这也是和calldata一致的读法。 问:连续两次调用后第一次的返回值还能找回吗? 答:不能,缓冲区被后一次调用覆写,没有事先复制进内存的数据就是丢了。
风险提示:本文描述虚拟机机制,不构成任何投资建议,也不涉及任何资产的买卖时机判断。指令语义以EIP原文与EVM规范为准。
发表评论
还没有评论,来说两句吧。
评论区为展示样式,提交不会被处理。