编程 用 Yul 读懂 EVM:从存储槽到事件的底层视角

2026-09-07 07:12:42

用 Yul 读懂 EVM:从存储槽到事件的底层视角

一篇 Dev.to 技术文章(作者 Rafael Abuawad)从第一性原理讲解 EVM(以太坊虚拟机)的 Yul 语言:Yul 是什么、如何用存储槽(slots)思维替代变量思维、mapping 的哈希计算、内存管理、事件与自定义错误的手动实现。核心观点:Yul 是理解 EVM 的最佳途径,但不建议把它作为智能合约的默认编写方式。本文基于该文章,梳理 Yul 的关键模式。

Yul 是什么

Yul 是一种"类类型"的低级中间表示:EVM 操作码以简单函数调用的形式呈现,给开发者更多内存管理控制权。

  • 可以直接在 Solidity 的 assembly 块中使用,与 Solidity 互操作性高
  • 直接管理底层操作码,用得好时更快
  • 本质上是一种 EVM 方言,不是完整的编程语言:所有数据类型都用 u256 表示(包括字符串、布尔、枚举)
  • 所有事情手动完成:存储管理、内存管理、事件发射、手动 abi.encode/encodePacked

作者提醒:Yul 增加大量心智负担,不建议所有合约都用 Yul 写——Solidity 和 Vyper 已有大量抽象完成同样的任务。但理解 Yul 是理解 EVM 的捷径。

核心思维转变:从变量到槽

在 Yul 中,停止用变量思考,开始用槽思考。合约存储中的一切都是键值对:一个槽持有 32 字节的字。

模式 A:读写单个键

只需两个操作码:sload 读、sstore 写。

function totalSupply() external view returns (uint256 result) {
    assembly {
        result := sload(_TOTAL_SUPPLY_SLOT)
    }
}

模式 B:读取 mapping

EVM 字节码中没有真正的 mapping,只有指针:一个哈希键指向一个值。做法是存一个槽种子(slot seed),与 mapping 中的键结合算出真实槽位置。

function balanceOf(address owner) external view returns (uint256 result) {
    assembly {
        mstore(0x0c, _BALANCE_SLOT_SEED)
        mstore(0x00, owner)
        let balanceSlot := keccak256(0x0c, 0x20)
        result := sload(balanceSlot)
    }
}

为什么 mstore(0x0c, seed) 有效

  • mstore 是大端序:32 字节字写入时最高有效字节落在偏移处
  • 地址只有 20 字节,所以 mstore(0x00, owner) 后地址实际在 mem[0x0c..0x1f](前面 12 字节零填充)
  • Solady 的重叠技巧:mstore(0x0c, seed) 把 4 字节种子放到 0x28..0x2b;keccak256(0x0c, 0x20) 恰好哈希 32 字节:[地址 20B | 填充 8B | 种子 4B]
  • 这就是为什么从 0x0c 而非 0x00 开始哈希——跳过地址字左填充,把种子拉进同一个 32 字节 preimage
  • 压缩形式:mstore(0x0c, or(shl(96, owner), seed)) 一次 mstore 构建相同 preimage

模式 C:mapping 的 mapping

哈希长度是 0x34(52 字节)而非 0x20:哈希 owner + seed + spender 跨多个字。

种子模式的意义

Solady 用命名空间种子(如 _BALANCE_SLOT_SEED)而非编译器的 0、1、2 槽号,这样继承的合约不会在存储上冲突。

内存管理心智图

  • 0x00 到 0x3f:scratch 空间,可安全用于哈希和临时值(在仔细限定的 assembly 中)
  • 0x40:空闲内存指针(free memory pointer)
  • 0x60:零槽,碰了要恢复为 0(Solidity 假设该字保持为零)
  • 需要真实分配内存(动态返回、更大 ABI 载荷、在内存中构建结构体)时:先 mload(0x40) 读指针,从指针处写,再推进指针

两个容易混淆的模式:清理地址 vs 打包

  • 清理:把地址字的高 12 字节清零再比较或作为 topic 发射。跳过时上区位可能脏,eq 对干净地址失败,或发出索引器无法匹配的怪异 topic
  • 打包:把地址左移后与种子 or 起来,一次 mstore 构建存储键 preimage

作者提醒:清理关乎地址作为地址的正确性,打包关乎廉价构建存储键——不要混为一谈。

事件与自定义错误

  • 事件最多 4 个索引 topic,事件名(签名哈希)本身也是一个 topic;最多 3 个索引 topic + 签名 topic
  • 发射用 log0 到 log4 操作码。例如 Transfer 事件(from、to 索引,amount 非索引)用 log3(0x20, 0x20, 签名, from, to)
  • 与种子同思路:事件哈希只算一次,运行时绝不 keccak 字符串。用 chisel/cast 预计算:cast keccak "Transfer(address,address,uint256)"
  • 自定义错误:预计算错误选择器(keccak256("ErrorName()") 的前 4 字节),存入内存,用正确偏移和大小 revert。例如 InsufficientBalance() 选择器 0xf4d678b8:revert(0x1c, 0x04)——因为大端序,4 字节选择器落在 32 字节字末尾 mem[0x1c..0x1f]

预计算原则

许多值可以在编译期预计算以省运行时计算:选择器、事件签名、槽种子、位掩码——运行时不变的值就不在运行时算。Solady 大量使用这一原则,其 ERC20 成为交互成本最低的 ERC20 之一。

总结

Yul 的本质是用操作码思维替代抽象思维:存储是槽的键值对、mapping 是哈希指针、内存分 scratch/自由指针/零槽三区、事件和错误是预计算签名的操作码调用。作者认为不必默认用 Yul 写合约(心智负担高、Solidity/Vyper 的高层抽象更合适),但读懂 Yul 代码依然值得——Solady、OpenSea 等顶级协议和库用它压榨最后一点性能。对想理解 EVM 底层、或需要审查高性能合约代码的开发者,这篇文章的四个模式(单键读写、mapping、嵌套 mapping、内存管理)是实用的入门地图。

来源:https://dev.to/rabuawad/yul-dark-arts-understanding-the-evm-from-first-principles-11ja

复制全文 生成海报 EVM Yul Solidity 智能合约 区块链 存储槽

推荐文章

程序员茄子在线接单