EIP-7979 的 Yul 子集编译器:以代码回答"编译器会用 CALLSUB/CALLDEST/RETURNSUB 吗"
【免费下载链接】EIPsThe Ethereum Improvement Proposal repository项目地址: https://gitcode.com/GitHub_Trending/ei/EIPs
EIP-7979(Call and Return Opcodes for the EVM)为 EVM 引入CALLSUB、CALLDEST、RETURNSUB三条控制流指令。本仓库中的 yul-compiler 是这一提案的代码级回答——"will compilers use it?"——一个把 Yul 子集编译为上述三条指令的编译器,并配有 legacy 后端做同口径对比。读完本文,你将掌握该编译器的子集语法、双后端编译原理、验证/执行/对比的测试闭环,以及实测的字节与 gas 节省数据。
一句话定位:把"函数"翻译成三条指令
Yul 语言本身就有函数概念,但今天的 EVM 没有调用/返回指令,编译器只能把函数调用翻译成"压返回地址 + 动态 JUMP",把函数返回翻译成"经数据栈跳回"。这正是 EIP-7979 想解决的问题——动态跳转让控制流对人和工具都不可见。
这个仓库的编译器证明了替换方案可行:函数定义近一对一地映射为CALLDEST … RETURNSUB,函数调用映射为PUSH CALLSUB,并且编译产物能通过配套验证 EIP(validator.py)的参考验证器。同时它保留了一个 legacy 后端,用今天编译器必须用的方式(动态跳转合成调用与返回)生成同一批程序,用于同口径对比代码大小和 gas。
支持的 Yul 子集:刻意"不聪明"
compile.py 的模块文档明确划定了子集边界:
- 支持:零或一个返回值的函数定义、
let、赋值、if、带break/continue的for、leave、函数调用,以及日常内建函数(add/mul/sub/div/sdiv/mod/smod/exp/lt/gt/slt/sgt/eq/iszero/and/or/xor/not/byte/shl/shr/sar/calldataload/calldatasize/pop/mload/mstore/mstore8/stop/return/revert); - 不支持:
switch、多返回值、优化器; - 约束:局部变量必须在
DUP16可达深度内(即栈上槽位深度超过 15 时,compile.py 的dup_of会直接报outside the subset)。
设计意图在 README 里说得很直白:"The point is not coverage; it is that nothing about the translation is clever."——重点不是覆盖面,而是证明翻译过程毫无取巧。代码生成器是同一套朴素的栈调度器,两个后端共享,唯一区别只在于调用与返回的发射方式;而 7979 后端反而是更简单的那一半:一次调用只有两条指令(替代四条指令加一个标签),一次返回完全不需要地址传递。
调用约定的本质差异
两个后端的核心分歧在 compile.py 的user_call与 epilogue:
- 7979 后端:调用方从左到右压参数,然后执行
CALLSUB;被调方的RETURNSUB把返回值留在参数原来所在的位置。 - legacy 后端:调用方先把返回标签压到参数之下,再跳转;被调方通过这个地址跳回。返回地址和数据混在同一根数据栈上,返回必须
SWAP+JUMP。
对照 EIP-7979 主文档 eip-7979.md 中"为什么允许 JUMP 落在 CALLDEST"一节:把调用换成跳转可以消除尾调用,g的RETURNSUB直接返回给f的调用者,少一条指令、少一个返回地址,递归还能在恒定返回栈深度下运行——这正是编译器依赖的尾调用、互递归、状态机与共享尾声(shared epilogues)变换。这个编译器用实测数据印证了该设计。
双端对比的真实数据:约 13% 字节、5% gas
README 的 "Measured" 一节给出 8 个程序的双端对比表,我在当前仓库实际运行python3 test_translator.py得到的输出与此完全一致(详见下节测试闭环):
| program | bytes saved | gas saved |
|---|---|---|
| square | 18% | 14% |
| sum of squares | 24% | 16% |
| abs | 12% | 10% |
| fib (loop-heavy) | 7% | 1% |
| factorial (recursive) | 17% | 11% |
| sum words | 8% | 4% |
| find (break) | 6% | 4% |
| guard (leave) | 13% | 11% |
| total | 13% | 5% |
节省量与调用密度强相关,符合预期:以循环为主的fib几乎无收益,调用密集的sum of squares收益最大。README 特别强调这是下限数字(floor numbers)——如果后端做尾调用消除和共享尾声优化,收益还会更高。这也与主文档中 square 例子 手算的 29% 更少字节、32% 更少 gas 相互印证:程序越大、优化越充分,相对差距越小,但使用CALLSUB的代码在字节与 gas 上始终不劣于等价旧代码。
测试闭环:编译→验证→执行→对比
README 的 "The loop" 一节给出入口命令:
python3 test_translator.pytest_translator.py 对每个示例程序(square、sum of squares、abs、fib、factorial (recursive)、sum words、find (break)、guard (leave))同时编译两种后端,并断言四件事:
- 7979 输出必须通过验证器(
validator.validate(new)为真); - legacy 输出必须通不过验证器——因为它的动态返回跳转无法通过静态验证,这正是 EIP 要解决的痛点(test_translator.py);
- 两套字节码在每个测试输入上执行结果完全一致(用
run7979.py这个内置三条指令的小型 EVM 执行,逐一比对(status, returndata)); - 逐行打印字节数与 gas 的并排对比表。
每个程序的 calldata 用例也很讲究,例如abs同时测正数与负数(2**256 - 5的补码表示)、factorial覆盖1/5/12、find (break)同时验证"找到即 break"与"找不到返回0xff…ff"。我实际运行该脚本,8 个程序全部PASS,汇总行显示总字节 359 vs 413(-13%)、总 gas 5507 vs 5847(-5%),与 README 表完全吻合。
解释器:带 EIP-7979 运行时语义的最小 EVM
run7979.py 是一个足够执行编译器产物的 EVM 解释器,其语义严格对齐 EIP 规范:
CALLSUB:弹出目的地址;目的必须是CALLDEST(code[dest] != CALLDEST即返回CALLSUB to non-CALLDEST停机);返回栈已达 1024 则return stack overflow;否则压入pc + 1并跳转(run7979.py);RETURNSUB:返回栈为空则return stack underflow,否则弹栈设 PC(run7979.py);- gas 采用 Yellow Paper 分层(very low 3、low 5、mid 8、high 10、jumpdest 1)加 EIP 提案价(
CALLSUB8、CALLDEST1、RETURNSUB5,见 GAS 表); - 内存扩展不收费——这是刻意的:对比发生在同一程序的两次编译之间,两者内存使用完全相同,去掉该收费才能让差距只反映调用/返回机制的差异(run7979.py)。
它还保留了对 legacy 代码的支持:jumpdest_analysis像今天的客户端一样做 JUMPDEST 分析,标记动态跳转的合法目的地——"The runtime scan validated code no longer needs"(验证过的代码不再需要的运行时扫描)。
验证器与操作码表:独立成文、逐字节一致
validator.py 是配套验证 EIP 的参考验证器。它验证五条约束:合法操作码、目的地可证明、返回被正确封装(framed returns)、无栈下溢、每个指令只到达一个静态栈偏移。其关键设计是:
- 遍历被限制在 JUMPDEST 分析找到的指令集合内——PUSH 立即数无论内容如何都不可执行、不可跳转(validator.py);
- 数据栈深度以
CALLDEST为基准相对测量,返回点等待被调方的净栈效果,跳入/落入CALLDEST则把两个子程序的净效果链接起来; - 需求(demand)只增不减且以栈上限封顶,最坏情况是
O(1024 * n)——对代码线性,因为 1024 是协议常量(validator.py); - 它不证明溢出(递归存在时不可判定),运行时边界检查保留——这与 EIP-7979 的运行时停机条件一致。
opcodes.py 提供共享的操作码表:opcode_info()给出每个操作码的 size/pops/pushes/是否终止基本块,push_value()读取 PUSH 立即数。表中明确标注CALLSUB: (1, 0, True), CALLDEST: (0, 0, False), RETURNSUB: (0, 0, True)——即CALLSUB弹 1 压 0 且终止基本块,CALLDEST是纯标签,RETURNSUB不碰数据栈但终止基本块。
一个值得注意的工程决策:README 的 Files 一节说明,validator.py与opcodes.py是从验证 EIP 资产中逐字节复制的精确副本("Exact copies: these assets must stand alone"),必须与原件保持 byte-identical——这样每个资产目录都能独立工作。
与主文档的呼应:从规范到实现的完整链条
把这条资产链路放回 EIP-7979 的整体叙事中看:
- 主文档 eip-7979.md 定义语义:
CALLSUB(mid/8 gas)弹目的地、压PC+1进返回栈、跳转,目的地非CALLDEST或返回栈满 1024 则异常停机;CALLDEST(jumpdest/1 gas)纯标签、同时也是合法JUMP/JUMPI目的地;RETURNSUB(low/5 gas)返回栈空则停机(eip-7979.md)。占位操作码CALLSUB=0xB0、CALLDEST=0xB1、RETURNSUB=0xB2在编译器、解释器、验证器三处完全一致。 - 主文档的 EELS 参考实现(
callsub/calldest/returnsub)展示了机器状态新增return_stack字段、RETURN_STACK_LIMIT = 1024,以及单趟get_valid_destinations扫描——本仓库解释器与验证器用可运行代码复现了同一套语义。 - 主文档的 实时性能与 ZK 部分 论述静态控制流让 AOT/JIT 在线性时间内完成分析,并用 RISC-V 内核测量给出 5x–51x 的证明成本下降——编译器产出的"验证通过"代码正是这种分析的前置条件。
一句话总结:这个编译器仓库不是 EIP-7979 的附庸,而是它的试金石——它用真实可运行的 Yul 程序证明,一旦有了三条指令,编译器可以近乎一对一地发射它们,产物可通过静态验证,且在字节与 gas 上稳定优于今天的动态跳转合成方案。
复现与深入阅读
# 在仓库根目录进入资产目录后运行测试闭环 cd assets/eip-7979/yul-compiler && python3 test_translator.py关键文件索引:
- yul-compiler/README.md — 编译器设计文档与测量表
- compile.py — tokenizer、递归下降解析器、双后端代码生成器
- test_translator.py — 8 个示例程序的编译/验证/执行/对比闭环
- run7979.py — 带 EIP-7979 语义与 Yellow Paper gas 的小型 EVM
- validator.py / opcodes.py — 验证 EIP 的参考验证器与其操作码表
- EIPS/eip-7979.md — EIP-7979 完整规范、测试用例与 EELS 参考实现
- riscv 资产目录 — RISC-V AOT/JIT 与 ZK 证明成本的测量复现
【免费下载链接】EIPsThe Ethereum Improvement Proposal repository项目地址: https://gitcode.com/GitHub_Trending/ei/EIPs
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考