会写一台能跑的 EVM,大约两百行。

然后你会遇到第一个真正的问题:Gas 该在哪一步扣? 扣早了、扣晚了,都会让某些合约的行为和主网对不上。

打个比方

这像自己动手装一台变速箱

看图纸时,“离合器"只是一个部件名字。而当你把它装进去、发现少了它就挂不上档时——那个"为什么需要它"的问题,你不用再问了。

对应第 21–24 讲

写一台 EVM 是理解它的最快方式——每一个"为什么这样设计"的问题,在你实现它的时候都会自己冒出来。

任务一:核心解释器

type EVM struct {
    code      []byte
    stack     []*uint256.Int     // ⭐ 或用 big.Int,但要处理模 2²⁵⁶
    memory    []byte
    storage   map[[32]byte][32]byte
    pc        int
    gas       uint64
    refund    uint64
    jumpdests map[int]bool
    static    bool
    depth     int
}

必须实现的操作码:

算术:   ADD MUL SUB DIV SDIV MOD SMOD ADDMOD MULMOD EXP
比较:   LT GT SLT SGT EQ ISZERO
位运算: AND OR XOR NOT BYTE SHL SHR SAR
哈希:   KECCAK256
栈:     POP PUSH1..PUSH32 DUP1..DUP16 SWAP1..SWAP16
内存:   MLOAD MSTORE MSTORE8 MSIZE
存储:   SLOAD SSTORE
控制流: JUMP JUMPI PC JUMPDEST
终止:   STOP RETURN REVERT INVALID

⚠️ 四个必须处理对的地方:

① ⭐ 所有算术都在【模 2²⁵⁶】上进行,溢出是【静默回绕】而不是报错
② SDIV / SMOD / SLT / SGT 是【有符号】的,
   要按二进制补码解释 —— 且 EVM 规定 x / 0 = 0,不是异常
③ SAR 是算术右移(补符号位),SHR 是逻辑右移(补 0)
④ EXP 的 Gas 与指数的字节长度有关,不是固定值

JUMPDEST 预扫描

func scanJumpDests(code []byte) map[int]bool {
    dests := make(map[int]bool)
    for i := 0; i < len(code); i++ {
        if code[i] == OpJumpDest {
            dests[i] = true
        }
        if code[i] >= 0x60 && code[i] <= 0x7f {
            i += int(code[i]-0x60) + 1   // ⭐ 必须跳过 PUSH 的立即数
        }
    }
    return dests
}

⚠️ 写一个测试:构造一段字节码,其中 PUSH 的立即数恰好是 0x5b(JUMPDEST 的操作码)。断言你的扫描【没有】把它当成合法跳转目标。

这一条如果写错,同一段字节可以被解释成两个不同的程序(第 21 讲第四节)。

任务二:Gas 计量

变速箱里那个离合器就是它。 上一个任务的解释器能跑,但它跑得停不下来;这个任务加进去的东西,才让"任意程序"和"必须终止"这两件事同时成立。

func (e *EVM) useGas(n uint64) error
func (e *EVM) expandMemory(size int) error

内存扩展的二次方收费:

cost(words) = 3×words + words²/512
每次扩展只收【增量】

必须测试:

① 申请 1 KB 内存 vs 1 MB 内存的成本比 —— ⭐ 观察二次项如何主导
② 一个循环不断扩展内存,在给定 Gas 下最多能申请多少?
③ 如果改成线性收费,同样的 Gas 能申请多少?(说明为什么必须是二次的)

SSTORE 的四档定价(第 23 讲第三节):

零 → 非零        20000
非零 → 非零       2900
非零 → 零        2900 + 退款 4800
同一交易内重复写   100

退款上限是 gasUsed / 5(EIP-3529),且必须在交易结束时才结算。

任务三:存储布局计算

实现 第 23 讲 的三个公式:

func MappingSlot(slot uint64, key []byte) [32]byte
func NestedMappingSlot(slot uint64, k1, k2 []byte) [32]byte
func DynamicArraySlot(slot uint64, index uint64) [32]byte

验证方法:找一个真实的以太坊合约(比如任意 ERC-20),用你的公式算出某个地址的 balance 槽,再用 eth_getStorageAt 从公开节点读一次,对比结果。

⚠️ 对得上,说明你的实现正确;对不上,通常是"key 没有左填充到 32 字节”。

任务四:调用语义

func (e *EVM) Call(target Address, code []byte, value uint64, input []byte) ([]byte, error)
func (e *EVM) DelegateCall(code []byte, input []byte) ([]byte, error)
func (e *EVM) StaticCall(target Address, code []byte, input []byte) ([]byte, error)

必须正确的三点:

① ⭐ DelegateCall 保持 msg.sender、msg.value 和【存储】不变,只换代码
② StaticCall 具有【传染性】:一旦进入,整个子调用树都禁止写状态
③ 63/64 规则:子调用最多拿到当前剩余 Gas 的 63/64(EIP-150)

写一个测试证明第 ③ 条让深度攻击失效:

func TestDepthAttackFailsWithGas(t *testing.T) {
    // ⭐ 尝试递归调用 1024 层
    // 断言:在合理的初始 Gas 下,Gas 会先于深度耗尽
    // 算一算:要真的达到 1024 层,需要多少初始 Gas?
}

任务五:三个必须复现的陷阱

① 存储冲突(第 24 讲第三节)

func TestProxyStorageCollision(t *testing.T) {
    // 代理合约把 implementation 地址放在 slot 0
    // 实现合约的第一个变量也在 slot 0
    // ⭐ 调用一次,断言 implementation 地址被覆盖 ⟹ 合约变砖
    // 然后换成 EIP-1967 槽,断言问题消失
}

② 定价错误引发的 DoS(第 21 讲第八节)

func TestUnderpricedStateAccess(t *testing.T) {
    // ⭐ 给 SLOAD 一个错误的低价格(比如 20 Gas,模拟 EIP-150 之前)
    // 写一个循环,用固定 Gas 触发尽可能多次状态访问
    // 统计:每单位 Gas 引发了多少次"磁盘读"
    // 再改成 EIP-2929 的冷热定价,对比这个比值
}

③ REVERT 与异常的 Gas 差别

func TestRevertVsException(t *testing.T) {
    // ⭐ REVERT:状态回滚,剩余 Gas 退回
    // 无效指令:状态回滚,Gas 全部扣光
    // 断言两者的剩余 Gas 不同
}

任务六:跑通一个真实合约

手写(或用 solc 编译)一个最小的计数器合约,用你的 EVM 跑通:

contract Counter {
    uint256 public count;
    function increment() public { count += 1; }
}

需要额外实现:

① calldata 与函数选择器分发(前 4 字节匹配)
② CALLDATALOAD / CALLDATASIZE / CALLDATACOPY
③ RETURN 返回 ABI 编码的结果

验证:连续调用 increment() 三次,然后调用 count(),断言返回 3。并统计每次调用的 Gas——对比真实以太坊上的数值(约 43000 首次、26000 之后),看你的实现差在哪里。

常见错误

① ⭐ 用 int 或 int64 存栈元素 ⟹ 无法表示 256 位
② 忘记 EVM 的除零返回 0 而不是 panic
③ MSTORE 的参数顺序搞反(先弹偏移,再弹值)
④ 内存扩展按"申请量"而不是"触及的最高字节"计费
⑤ StaticCall 的只读标志没有向下传递
⑥ SSTORE 退款立即结算 ⟹ 绕过了 gasUsed/5 的上限

相关第 21 讲第 22 讲第 23 讲第 24 讲