Yul 与内联汇编:EVM 栈机模型、gas 热点改写与安全边界

从 EVM 栈机与操作码出发,系统讲解 Yul 语法、memory/storage/calldata 访问、memory-safe 汇编的安全边界、ABI 编解码手写优化、常见 gas 热点改写案例,以及汇编代码的调试与审计要点。

Solidity 编译器在可读性与优化之间做了大量取舍,而合约的 gas 成本最终由生成的字节码决定。当业务进入 gas 敏感区间——批量转账、AMM 核心循环、清算引擎、代理分发——手写 Yul 往往是唯一能把成本再压一档的手段,代价是把「编译器替你保证的不变量」重新变成自己的责任。

本文从 EVM 栈机模型出发,逐层讲清 Yul 的语法与数据区语义、memory-safe 这一被广泛误解的安全标注、ABI 编解码的手写优化路径、几个高频 gas 热点的改写案例,最后给出汇编代码的调试与审计清单。

前置:EVM 执行模型、存储布局与 Solidity 语言基础,gas 计量规则与常规优化手段。


目录


1. EVM 栈机与操作码回顾

1.1 栈机模型的三个约束

EVM 是一台 256 位字长的栈式虚拟机,所有运算围绕一个深度上限 1024 的栈展开。理解 Yul 之前必须先接受三个约束:

  • 只能访问栈顶附近:DUP1DUP16 与 SWAP1SWAP16 决定了一次最多只能触及栈顶 16 个元素。深层变量必须靠 DUP/SWAP 搬运,这是「栈太深」编译错误的根源。
  • 操作数顺序即语义:SUB 是 a - b 还是 b - a 取决于谁先入栈,Yul 屏蔽了这层细节,但阅读优化器输出时会暴露。
  • 所有值都是 256 位:没有类型,只有字。溢出、符号扩展、地址截断全部由代码自己保证。
常见操作码与 gas 成本(冷/热):
SLOAD    2100 / 100      读取存储槽
SSTORE   20000 / 5000    首次写非零 / 改写非零
MLOAD    3               读内存
CALLDATALOAD  3          读 calldata

1.2 数据区划分

区域可持久可扩容访问成本用途
stack否否极低临时计算
memory否是(按字扩展付费)低动态数据、哈希输入
calldata否否低函数参数(只读)
storage是是极高状态
transient否(交易内)是低(100)交易内临时状态

选择数据区是优化的第一步:把只在一次调用内使用的数据从 storage 搬到 memory,往往能省下 90% 以上的成本。


2. Yul 语言基础

2.1 语法结构

Yul 是一门独立的中间语言,可被内联进 Solidity(assembly { })也可独立编译。它的语法极简:

function sumTo(uint256 n) internal pure returns (uint256 s) {
    assembly {
        let i := 0                       // let 声明,作用域为当前块
        for { } lt(i, n) { i := add(i, 1) } { s := add(s, i) }
    }
}

关键语法点:let x := expr 声明并赋值,未初始化变量值为 0;函数用 function f(a, b) -> c { } 定义,只支持内部调用且可返回多值;控制流只有 if、switch、for(没有 while,用 for { } cond { } { } 表达);leave 提前退出函数;没有隐式类型转换,所有运算按 256 位整数处理。

2.2 与 Solidity 变量互操作

function transfer(address to, uint256 amount) external {
    assembly {
        let ptr := mload(0x40)          // 空闲内存指针
        mstore(ptr, amount)
        mstore(add(ptr, 0x20), 0)
    }
}

一个容易踩的坑:汇编块内部对 Solidity 变量的赋值是直接写栈或内存,若变量在汇编之后被 Solidity 使用,编译器可能已在寄存器中缓存了旧值。安全做法是让汇编块只写、不在外部依赖被写变量的旧值。


3. 存储与内存模型

3.1 存储槽与打包

Solidity 按声明顺序为状态变量分配槽位,小于 32 字节的类型会尝试打包进同一槽:

contract Packed {
    uint128 a;   // slot 0 低 128 位
    uint128 b;   // slot 0 高 128 位
}

// 手写读取同一槽内的两个字段
assembly {
    let packed := sload(0)
    let a := and(packed, 0xffffffffffffffffffffffffffffffff)
    let b := shr(128, packed)
}

mapping 与动态数组的槽位不存数据,而是存「基槽」,实际值通过 keccak256(key . baseSlot) 定位。手写访问时必须复现这套推导。

3.2 内存布局与空闲指针

EVM 内存是线性字节数组,按 32 字节字扩展,扩展成本随字数超线性增长。Solidity 约定:

0x00 - 0x3f   暂存区(scratch space),可自由使用
0x40 - 0x5f   空闲内存指针(free memory pointer)
0x60 - 0x7f   零槽(zero slot),不应写入非零值
0x80 起       动态数据区

任何分配内存的汇编代码都必须更新 0x40 处的空闲指针,否则后续 Solidity 代码会覆盖你的数据:

assembly {
    let ptr := mload(0x40)
    mstore(0x40, add(ptr, 0x40))   // 使用 ptr 处内存后必须手动推进空闲指针
}

4. 汇编中的安全边界

4.1 memory-safe 到底保证什么

assembly ("memory-safe") { } 是给编译器的承诺,不是检查。它声明该汇编块只访问以下内存区域:空闲指针之上、自己分配并推进的区域;0x00~0x3f 暂存区;Solidity 已分配的局部变量内存;以及通过 keccak256 等只读方式访问的临时区域。一旦声明,编译器就假定这块汇编不会破坏自己的内存管理,从而可以在汇编前后自由使用内存做优化。违反承诺的后果不是报错,而是静默的未定义行为。

// 反例:声明了 memory-safe 却篡改空闲指针
assembly ("memory-safe") {
    mstore(0x40, 0x80)        // 破坏编译器假设
    mstore(0x80, 1)           // 覆盖编译器可能正在使用的内存
}

4.2 外部调用与重入

汇编里的 call / staticcall / delegatecall 与高层调用等价,同样会交出控制权。写汇编时最容易忘掉的是 checks-effects-interactions 顺序:因为 Yul 不强制任何结构,开发者很容易在状态更新之前发起外部调用。

assembly {
    // 危险:先转账再更新余额
    let ok := call(gas(), caller(), amount, 0, 0, 0, 0)
    if iszero(ok) { revert(0, 0) }
    sstore(caller().slot, sub(sload(caller().slot), amount))
}

审计时对每个汇编 call 都应追问三个问题:状态是否已更新、返回值是否检查、失败路径是否会留下不一致状态。

4.3 返回值与错误处理

Yul 没有 require,一切靠显式 revert(offset, size) 与 iszero 判断:

assembly {
    let ok := call(gas(), target, 0, inOffset, inSize, outOffset, outSize)
    if iszero(ok) {
        returndatacopy(0, 0, returndatasize())   // 原样冒泡子调用错误
        revert(0, returndatasize())
    }
}

省略 returndatacopy 会让错误信息丢失,调试成本急剧上升;直接 revert(0, 0) 则会让上层无法定位失败原因。


5. ABI 编解码手写优化

5.1 为什么手写更快

Solidity 生成的 ABI 解码代码是通用的:它要处理动态数组、嵌套结构、越界校验、偏移验证,因此包含大量分支与边界检查。当参数结构固定且可信时,手写解码可以跳过大部分检查:

// 参数:address[] recipients, uint256[] amounts(长度已知且相等)
assembly {
    let len := calldataload(recipients.offset)
    let rData := add(recipients.offset, 0x20)
    for { let i := 0 } lt(i, len) { i := add(i, 1) } {
        let to := calldataload(add(rData, shl(5, i)))    // shl(5,i) 即 i*32
    }
}

5.2 编码与 abi.encode 的取舍

手写编码的风险在于:一旦 ABI 规范变化(如新增参数、结构体布局调整),手写代码不会随之更新,可能造成静默的编码错位。

方式gas安全性适用
abi.encode高(拷贝 + 校验)高一般场景
abi.encodePacked中中(需防哈希碰撞)哈希输入
手写 mstore低依赖正确性热路径
calldatacopy 转发最低高(不改动)代理转发

代理合约的转发是手写汇编最经典的用法:delegatecall 直接透传原始 calldata,不做任何解码。


6. 常见 gas 热点改写案例

6.1 案例一:批量 ERC-20 转账

// 改写后:复用选择器与 calldata 缓冲,避免每次调用重复编码
assembly {
    let ptr := mload(0x40)
    mstore(ptr, shl(224, 0x23b872dd))     // transferFrom 选择器
    for { let i := 0 } lt(i, n) { i := add(i, 1) } {
        mstore(add(ptr, 0x04), from)
        mstore(add(ptr, 0x24), calldataload(add(tos, shl(5, i))))
        mstore(add(ptr, 0x44), calldataload(add(amts, shl(5, i))))
        let ok := call(gas(), token, 0, ptr, 0x64, 0, 0x20)
        if iszero(ok) { revert(0, 0) }
    }
}

改写前的写法是在循环里逐次调用 token.transferFrom,每次都重复构造选择器与编码参数;改写后这部分开销被摊薄到一次。

6.2 案例二:存储读改写

// 改写后:手写槽定位,减少重复计算与 SLOAD
assembly {
    let slot := add(balances.slot, keccak256(0x00, 0x40))
    sstore(slot, sub(sload(slot), amount))
}

真正的收益来自减少槽访问次数而非减少指令数:SLOAD 热访问 100 gas、冷访问 2100 gas,SSTORE 改写 5000 gas,都是数量级差异。

6.3 案例三:位运算与条件选择

uint256 x = flag ? a : b;          // 改写前

assembly {                          // 改写后:无分支选择,避免跳转
    let mask := sub(0, flag)        // mask 为全 0 或全 1
    x := or(and(mask, a), and(not(mask), b))
}

跳转本身不贵,但分支会阻碍编译器的常量传播与代码布局优化,在紧循环中收益可观。

6.4 热点识别的正确顺序

优化前必须先测量。顺序应该是:先看 gas 报告(forge test --gas-report),再定位到具体的 SLOAD/SSTORE/CALL 次数,最后才决定是否值得为它写汇编。没有测量就写汇编,几乎一定是在制造风险而不是收益。


7. 函数分发与选择器优化

7.1 分发器的结构

合约入口是一段选择器比较逻辑。Solidity 生成的分发器按选择器二分查找,函数多时会形成可观的部署字节码与运行成本。

assembly {
    let sig := shr(224, calldataload(0))
    switch sig
    case 0xa9059cbb { /* transfer */ }
    case 0x095ea7b3 { /* approve */ }
    default { revert(0, 0) }
}

7.2 部署字节码的权衡

手段运行 gas部署成本适用
Solidity 自动分发中中默认
手写 switch 分发低(高频路径)高函数极多
回退代理转发极低极低可升级合约

注意 EIP-170 的 24KB 合约大小限制:手写分发器增加字节码体积,可能反而触发部署限制。优化运行 gas 与优化部署体积常常互相矛盾,需要按调用频次加权。


8. 调试与审计要点

8.1 调试手段

  • forge debug / cast run:单步执行并查看栈与内存,是排查汇编 bug 的主要工具。
  • verbatim 与 --via-ir 输出:查看优化器对汇编的处理结果,确认没有被意外重排。
  • 差分测试:同一逻辑写两份实现(Solidity 版与 Yul 版),用模糊测试对比结果是否一致。
  • gas 快照:forge snapshot 记录每次改动前后的 gas 差异,防止「优化」反而变慢。
function testFuzz_AssemblyMatchesSolidity(uint96 a, uint96 b) public {
    uint256 expected = a + b;
    uint256 actual;
    assembly { actual := add(a, b) }
    assertEq(actual, expected);
}

8.2 审计清单

检查项风险建议
memory-safe 声明静默 UB只声明确实满足的块
空闲指针更新内存被覆盖每次分配后推进 0x40
外部调用顺序重入状态先更新再调用
返回值检查静默失败每次 call 都判 iszero
错误冒泡调试困难returndatacopy + revert
存储槽推导读错数据与 Solidity 布局对照验证

9. 与 Solidity 的混合使用模式

9.1 分层策略

成熟的代码库通常采用三层结构:业务逻辑用 Solidity 写保证可读与可审计;热路径用带 memory-safe 的汇编块,边界清晰、职责单一;极度热点用独立 Yul 函数或库,配完整差分测试。原则是把汇编限制在尽可能小的表面积内:一个 5 行的汇编块可以被审计员逐字验证,一个 200 行的汇编合约则几乎必然藏有未被发现的问题。

9.2 代码组织

library BytesLib {
    function toAddress(bytes calldata data, uint256 start) internal pure returns (address) {
        require(start + 20 <= data.length, "OUT_OF_BOUNDS");
        address result;
        assembly ("memory-safe") { result := shr(96, calldataload(add(data.offset, start))) }
        return result;
    }
}

把汇编封装成小而纯的函数,让调用方看到的是普通 Solidity 接口,是平衡性能与可维护性的最佳实践。


10. 工程实践与迁移建议

10.1 引入汇编的判断标准

引入汇编前应依次确认:是否已用常规手段(存储打包、减少 SLOAD、自定义错误、unchecked)优化过;该路径是否是 gas 主要来源(用 gas report 量化占比);收益是否值得引入的审计与维护成本;是否有差分测试与模糊测试覆盖。

10.2 迁移路线

  1. 先测量:建立 gas 基线,明确目标(如「批量转账降低 30%」)。
  2. 小步替换:一次只改一个函数,保留 Solidity 版本做对照。
  3. 补齐测试:模糊测试覆盖边界值,不变量测试覆盖跨调用状态。
  4. 审计与上线:汇编部分单独送审并标注 memory-safe 边界,上线后监控失败率与 gas 分布。

10.3 速查表与一句话记忆

概念关键点常见坑
栈机深度 1024,DUP/SWAP 限 16栈太深、操作数顺序
Yul 变量let 声明,作用域块级未初始化默认为 0
memory-safe是承诺不是检查违反导致静默 UB
空闲指针0x40 处,分配后须推进忘记推进致内存覆盖
存储布局按声明顺序打包手写槽推导错误
ABI 手写跳过通用校验结构变化致编码错位
差分测试汇编与 Solidity 对照缺少即无法验证正确性

一句话记忆:汇编是拿可验证性换 gas 的交易,测量在前、表面积最小化、差分测试兜底,三者缺一不可。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「区块链 Web3」更多文章

  1. DeFi 风险管理与清算:抵押率、清算机制与坏账处置
  2. MPC 钱包与密钥管理:门限签名、2-of-3 架构与攻击面分析
  3. 形式化验证实战:Certora、Halmos 与 Echidna 的规约、边界与 CI 集成