Solidity 编译器在可读性与优化之间做了大量取舍,而合约的 gas 成本最终由生成的字节码决定。当业务进入 gas 敏感区间——批量转账、AMM 核心循环、清算引擎、代理分发——手写 Yul 往往是唯一能把成本再压一档的手段,代价是把「编译器替你保证的不变量」重新变成自己的责任。
本文从 EVM 栈机模型出发,逐层讲清 Yul 的语法与数据区语义、memory-safe 这一被广泛误解的安全标注、ABI 编解码的手写优化路径、几个高频 gas 热点的改写案例,最后给出汇编代码的调试与审计清单。
目录
- 1. EVM 栈机与操作码回顾
- 2. Yul 语言基础
- 3. 存储与内存模型
- 4. 汇编中的安全边界
- 5. ABI 编解码手写优化
- 6. 常见 gas 热点改写案例
- 7. 函数分发与选择器优化
- 8. 调试与审计要点
- 9. 与 Solidity 的混合使用模式
- 10. 工程实践与迁移建议
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 迁移路线
- 先测量:建立 gas 基线,明确目标(如「批量转账降低 30%」)。
- 小步替换:一次只改一个函数,保留 Solidity 版本做对照。
- 补齐测试:模糊测试覆盖边界值,不变量测试覆盖跨调用状态。
- 审计与上线:汇编部分单独送审并标注 memory-safe 边界,上线后监控失败率与 gas 分布。
10.3 速查表与一句话记忆
| 概念 | 关键点 | 常见坑 |
|---|---|---|
| 栈机 | 深度 1024,DUP/SWAP 限 16 | 栈太深、操作数顺序 |
| Yul 变量 | let 声明,作用域块级 | 未初始化默认为 0 |
| memory-safe | 是承诺不是检查 | 违反导致静默 UB |
| 空闲指针 | 0x40 处,分配后须推进 | 忘记推进致内存覆盖 |
| 存储布局 | 按声明顺序打包 | 手写槽推导错误 |
| ABI 手写 | 跳过通用校验 | 结构变化致编码错位 |
| 差分测试 | 汇编与 Solidity 对照 | 缺少即无法验证正确性 |
一句话记忆:汇编是拿可验证性换 gas 的交易,测量在前、表面积最小化、差分测试兜底,三者缺一不可。
延伸阅读
- gas 计量规则、存储打包与常规优化手段
- EVM 执行模型与 Solidity 语言基础
- 智能合约常见攻击面与防御总览
- Foundry 单元测试、模糊测试与不变量测试
- 合约开发流程与工程化实践
- Web3 区块链专题 — 区块链 Web3 专题
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。