Yul 汇编与 Gas 优化:从 EVM 操作码到存储槽打包

从 EVM 操作码的 Gas 成本表出发,系统讲解 Yul 内联汇编与 Gas 优化的完整方法论:深入 EIP-2929 冷热访问与 EIP-3529 退款上限对 SLOAD 与 SSTORE 成本的重塑、存储槽打包与 slot 布局对部署和读写 Gas 的影响、calldata 与内存布局的读取成本差异,并给出内联汇编在自定义错误、位运算、循环展开、calldata 解码优化上的真实代码。文章包含优化前后 Gas 对比表格、Foundry gas snapshot 测试、Yul 独立合约写法,以及 free memory pointer、slot 冲突、内存模型被破坏等安全边界与踩坑清单。

导语:让每一字节的 Gas 都花在刀刃上

在以太坊上,代码不只是逻辑,它还是账单。同一个功能,写得粗糙的合约可能比写得精细的合约贵上三到五倍;在高峰期,这三五倍就是用户愿不愿意点「确认」的区别。

Gas 优化不是玄学,它建立在一张非常具体的成本表上:哪些操作码是 3 gas,哪些是 2100 gas,哪些是 20000 gas。看懂这张表,你就知道优化的钱花在哪里;再掌握 Yul 内联汇编,你就能把 Solidity 编译器替你「安全但昂贵」地生成的那些指令,换成你亲手写的、刚好够用的那几条。

一句话总结:Gas 优化的第一性原理是「少碰存储、多算算术、数据放 calldata」——存储访问比算术贵三个数量级,这是所有优化技巧的共同起点。


1. EVM 操作码分类与 Gas 表

1.1 操作码分类与基础成本

EVM 的每个操作码都有一个固定基础成本,外加可能的内存扩展、存储访问、动态长度等附加成本。把常用操作码按数量级分成四档——极低(23 gas 的纯算术)、中档(850 gas 的跳转与哈希)、较高(100~2900 gas 的状态访问)、极高(20000+ gas 的状态扩张)——是理解 Gas 优化的第一步。下面是常用操作码速查表:

操作码基础 Gas附加成本
ADD / SUB / NOT3无
MUL / DIV / MOD5无
EXP10指数每字节 +50
KECCAK25630每字(32 字节)+6,加内存扩展
CALLDATALOAD3无
CALLDATACOPY3每字 +3,加内存扩展
MLOAD / MSTORE3加内存扩展
SLOAD100(热)冷访问额外 +2000
SSTORE100 / 2900 / 20000冷槽额外 +2100,退款见下文
LOG1750每字节数据 +8
CALL100(热)冷账户额外 +2600,转账 +9000

记住三个数量级:算术 3 gas,存储读 1002100 gas,存储写 290020000 gas。任何「把存储访问挪到循环外」的技巧,本质都是在这个数量级差上套利。

1.2 EIP-2929 冷热访问:存储访问的两档定价

EIP-2929(Berlin 升级引入)把 SLOAD、SSTORE、CALL 系列的地址与存储槽访问拆成「冷」和「热」两档:

冷访问(cold):该槽/地址在本次交易中第一次被访问
  访问成本 = 基础成本 + COLD_SLOAD_COST(2100)

热访问(warm):该槽/地址在本次交易中已被访问过
  访问成本 = 基础成本(100 或 2900)

作用域:热集合在「单笔交易」内有效,交易结束即清空。

这直接改变了优化策略:

// 优化前:循环里反复读同一个槽
for (uint256 i = 0; i < 10; i++) { total += config; }
// 成本 = 2100(首次冷读)+ 9 × 100(热读)= 3000 gas

// 优化后:缓存到栈变量,只读一次
uint256 c = config;                  // 冷读 2100
for (uint256 i = 0; i < 10; i++) { total += c; }  // 10 × 3 = 30
// 成本 = 2130 gas

反直觉的结论:如果槽已经是热的,缓存反而可能不划算。热读 100 gas,memory 的 MLOAD 是 3 gas 加一次性扩展成本,小循环下差异不明显;但冷读 2100 gas 时收益显著。优化的前提是搞清楚这个槽在这笔交易里是冷还是热。

1.3 EIP-3529 退款上限:为什么「清空存储」不再暴利

EIP-3529(London 升级)把 SSTORE 的清零退款从 15000 降到 4800,并把整笔交易的退款上限从 gas_used / 2 收紧到 gas_used / 5。结合 EIP-2929,SSTORE 的四种情形是:0 变非 0 收 20000 gas;非 0 改非 0 收 2900;非 0 改 0 收 2900 并退款 4800;值未变化只收 100(热)。冷槽还要额外加 2100。

一个常被忽视的细节:同一笔交易内先清零再写回非零,会取消退款。这种「退款抵消」意味着「清空某个槽然后立刻重新赋值」实际成本是 2900 + 2900 且拿不到任何退款。

// 反模式:同一笔交易内先删后建,退款被抵消,白付一次 SSTORE
delete balances[user];        // 2900,理论上产生 4800 退款
balances[user] = newBal;      // 2900,退款被抵消

// 更好的写法:直接赋值,省掉一次 SSTORE
balances[user] = newBal;      // 2900,一次写入

2. 存储槽打包与 slot 布局

2.1 存储槽与打包原理

EVM 的存储是一张 uint256 -> uint256 的映射,每个键是一个 slot,每个 slot 是 32 字节。Solidity 会按声明顺序把小于 32 字节的变量塞进同一个 slot:

slot 0: [ 变量A (16B) | 变量B (8B) | 变量C (8B) ]  ← 打包成功,3 个变量 1 个 slot
slot 1: [ 变量D (32B) ]                            ← 满 32 字节,独占一个 slot

关键规则:

  • 打包按声明顺序进行,一个变量不能跨 slot 边界,放不下就开新 slot
  • mapping 与动态数组永远独占一个 slot(里面只存种子/长度);constant 与 immutable 不占 slot
  • struct 内部同样打包,但在数组与 mapping 中每个元素按 struct 总大小对齐

2.2 打包优化实战

// 优化前:3 个 slot
contract Unpacked {
    uint128 a;      // slot 0(只用了 16 字节,浪费一半)
    uint256 b;      // slot 1(32 字节,占满)
    uint64  c;      // slot 2
    uint64  d;      // slot 2(与 c 打包)
}
// 部署成本:3 × 20000 = 60000 gas(假设都从零写入)

// 优化后:2 个 slot
contract Packed {
    uint128 a;      // slot 0
    uint64  c;      // slot 0(与 a 打包,共 24 字节)
    uint64  d;      // slot 0(共 32 字节,刚好塞满)
    uint256 b;      // slot 1
}
// 部署成本:2 × 20000 = 40000 gas

每次读取的差异同样可观。一次冷读 3 个 slot 需要 2100 × 3 = 6300 gas,打包成 2 个 slot 只要 2100 × 2 = 4200 gas;如果两个变量都在同一个 slot 里,Solidity 甚至可能用一次 SLOAD 加位移取出两者。

对结构体同样适用:

// 差:每个 struct 占 3 个 slot
struct OrderBad {
    uint256 amount;     // 32B,slot 0
    uint64  deadline;   // 8B,slot 1
    address maker;      // 20B,slot 2
    bool    filled;     // 1B,slot 2
}

// 好:每个 struct 占 2 个 slot
struct OrderGood {
    uint256 amount;     // 32B,slot 0
    address maker;      // 20B ┐
    uint64  deadline;   // 8B  ├ slot 1,共 29 字节
    bool    filled;     // 1B  ┘
}

在数组或 mapping 中,这个差异会被放大成 元素数量 × 20000 gas。

2.3 constant 与 immutable:零存储成本的常量

contract Constants {
    uint256 public constant FEE_BPS = 30;      // 编译期常量,读取是 PUSH,约 3 gas
    address public immutable owner;            // 部署期写入字节码,读取约 3 gas
    uint256 public feeBps = 30;                // 占用 slot 0,冷读 2100 gas

    constructor() {
        owner = msg.sender;
    }
}

constant 在编译期就被内联,读取成本等同一次 PUSH;immutable 在构造函数中写入代码段,不占存储 slot,部署时省 20000 gas,运行时读取从 2100 降到 3。代价是 immutable 无法在部署后修改——如果业务上确实需要可变,那就老老实实付存储的钱。


3. calldata 与内存布局

3.1 calldata 的读取成本

calldata 是交易的只读输入区,读取成本极低,且不产生内存扩展成本:

操作成本说明
CALLDATASIZE2 gas常数时间
CALLDATALOAD3 gas读 32 字节,越界补零
CALLDATACOPY3 + 3/字复制到内存,另加内存扩展
calldata 中的参数(external)0直接用,无需复制

结论很明确:能声明为 external 并用 calldata 的参数,就不要声明为 memory。memory 参数会触发一次 CALLDATACOPY 加内存扩展,对数组和 bytes 尤其明显。

// 贵:memory 参数会复制整段数据
function batchTransferBad(address[] memory users, uint256[] memory amounts) external {
    for (uint256 i = 0; i < users.length; i++) {
        // 每次循环都从 memory 读,且函数入口已付过复制成本
    }
}

// 便宜:calldata 参数零复制,循环内每次读 3 gas
function batchTransferGood(address[] calldata users, uint256[] calldata amounts) external {
    uint256 len = users.length;   // 缓存长度,避免每次 CALLDATALOAD
    for (uint256 i = 0; i < len; ++i) {
        // 直接读 calldata
    }
}

3.2 内存布局与 free memory pointer

Solidity 的内存有严格的布局约定,理解它是安全使用内联汇编的前提:

0x00 - 0x3f  暂存区(scratch space):keccak256 的临时输入、函数返回数据
0x40 - 0x5f  free memory pointer:指向下一个可用内存地址,初始值为 0x80
0x60 - 0x7f  零槽(zero slot):动态数组的初始空位,永远保持为 0
0x80 - ...   动态分配区

内存扩展成本公式(words = ceil(bytes / 32)):

memory_cost(words) = 3 × words + words² / 512
内存大小字数扩展成本
32 B13
1 KB3296 + 2 = 98
4 KB128384 + 32 = 416
64 KB20486144 + 8192 = 14336
1 MB3276898304 + 2097152 = 2195456

可以看到内存扩展是二次增长的:小规模几乎免费,但一旦把大数组整体拷进内存,成本会爆炸。这也是「能用 calldata 就别用 memory」的量化依据——1 MB 的数据复制光是内存扩展就要 200 万 gas。

3.3 returndata 与内存复制

外部调用返回的数据落在 returndata 区,CALL 之后需要显式复制到内存。Solidity 生成的代码里,这一步往往是隐藏的成本大头:

// 贵:高级调用会把全部 returndata 复制到内存,并分配动态 bytes
(bool ok, bytes memory data) = target.call(payload);

// 便宜:只取真正需要的 32 字节,直接落到 0x00 暂存区
assembly {
    ok := call(gas(), target, 0, add(payload, 0x20), mload(payload), 0, 0x20)
    value := mload(0x00)
}

这跳过了「复制全部返回数据 + 分配动态 bytes」的流程,对返回大量数据的预言机、聚合器调用常能省下数千 gas。


4. 内联汇编实践

4.1 自定义错误:最便宜的收益

Solidity 0.8.4 引入的自定义错误(custom error),是投入产出比最高的一项优化。

// 贵:字符串错误
require(balance >= amount, "Insufficient balance for transfer");

// 便宜:自定义错误
error InsufficientBalance(uint256 available, uint256 required);

if (balance < amount) {
    revert InsufficientBalance(balance, amount);
}

成本差异来自两处:

  1. 部署时:字符串 "Insufficient balance for transfer" 有 33 字节,写入代码段要 33 × 200 = 6600 gas;自定义错误只有 4 字节选择器加参数编码辅助代码,几乎可忽略
  2. 运行时 revert:自定义错误直接 ABI 编码 4 字节选择器加参数,约 100150 gas;字符串需要构造 ABI 动态类型(偏移 + 长度 + 数据 + 补零),约 250400 gas

单次 revert 省约 150~250 gas 看似不多,但部署时省 6000+ gas 是立竿见影的,且错误信息可以结构化(携带 available 与 required 两个数值),前端可以直接解析展示,体验反而更好。

4.2 位运算与位图

位运算在 EVM 里是最便宜的一档(3 gas),非常适合用来替代布尔数组。

contract Whitelist {
    mapping(address => bool) public allowed;      // 贵:每地址一个槽,首次写入 20000
    mapping(uint256 => uint256) private bitmap;   // 便宜:256 个地址共享一个槽

    function _bucketBit(address user) internal pure returns (uint256 b, uint256 bit) {
        b = uint256(uint160(user)) >> 8;                   // 高 248 位做桶
        bit = 1 << (uint256(uint160(user)) & 0xff);        // 低 8 位做偏移
    }

    function setAllowed(address user, bool ok) external {
        (uint256 b, uint256 bit) = _bucketBit(user);
        uint256 mask = bitmap[b];
        bitmap[b] = ok ? (mask | bit) : (mask & ~bit);     // 设置用或,清除用与加取反
    }

    function isAllowed(address user) external view returns (bool) {
        (uint256 b, uint256 bit) = _bucketBit(user);
        return bitmap[b] & bit != 0;
    }
}

收益量级:白名单前 256 个地址,mapping 方案每次 setAllowed(true) 是 20000 gas(0 → 1),共 512 万 gas;位图方案第一个地址 20000 gas,其余 255 个地址都是改值,每次约 2900 gas,总计约 76 万 gas。存储槽数量也从 256 个压缩到 1 个。

同样的技巧适用于「批量开关」「投票记录」「已领取标记」等场景。用位运算替代 bool 数组,是把 2900 降到 100 的少数几种手段之一。

4.3 循环展开与短路返回

// 展开前:每次迭代有循环变量维护与条件跳转
function sumBad(uint256[] calldata xs) external pure returns (uint256 s) {
    for (uint256 i = 0; i < xs.length; ++i) {
        s += xs[i];
    }
}

// 展开后:手动展开 4 次,减少循环控制开销
function sumUnrolled(uint256[] calldata xs) external pure returns (uint256 s) {
    uint256 len = xs.length;
    uint256 i;
    for (; i + 4 <= len; i += 4) {
        s += xs[i] + xs[i + 1] + xs[i + 2] + xs[i + 3];
    }
    for (; i < len; ++i) {
        s += xs[i];
    }
}

每次迭代省下的是循环变量的自增、比较与 JUMPI(约 2030 gas)。对 100 次迭代的循环,展开 4 次大约省 500800 gas。收益不大但稳定,适合热点路径。

另一个常被忽视的是提前返回:所有条件都写成独立的 if 再统一返回,会让高分档的调用把后续所有比较都跑一遍;改成命中即 return,可以省掉后续全部判断与跳转。

4.4 revert 与 require 的成本差

require、if + revert、assert 三者的成本结构不同:

形式错误数据运行时成本适用场景
require(cond, “string”)Error(string) + 字符串最高,约 250~400 gas需要人类可读信息
require(cond)空低,约 30 gas内部断言,不需要信息
if (!cond) revert CustomError(…)自定义错误选择器约 100~150 gas推荐,兼顾成本与信息
assert(cond)Panic(uint256)约 100 gas只用于不变量,绝不用作输入校验

一个重要的语义差异:assert 在 0.8.0 之后会 revert 并退还剩余 gas(使用 Panic 编码),不再是旧版本的 INVALID 全量消耗;但它表达的是「不可能发生」的不变量,用它做用户输入校验会掩盖真正的 bug。

// 用内联汇编做零成本校验:条件不满足直接 revert(0, 0)
function fastRequire(uint256 a, uint256 b) internal pure {
    assembly {
        if iszero(eq(a, b)) {
            revert(0, 0)   // 无错误数据,成本最低
        }
    }
}

4.5 calldata 解码优化

标准 ABI 解码为动态类型生成偏移量跳转,对固定布局的数据是浪费。批量转账这类「定长数组 + 定长数组」的接口,可以直接在汇编里按固定步长切分:

// calldata 布局:selector(4) + offset(32) + length(32) + data...
function decodeAndSum() external pure returns (uint256 total) {
    assembly {
        let len := calldataload(0x24)            // 数组长度在 offset 之后
        let ptr := add(0x44, calldataload(0x04)) // 数据区起点
        for { let i := 0 } lt(i, len) { i := add(i, 1) } {
            total := add(total, calldataload(ptr))
            ptr := add(ptr, 0x20)
        }
    }
}

⚠️ 警告:手写解码完全放弃了 Solidity 的边界检查。攻击者可以构造长度与实际数据不符的 calldata,你必须自己验证 len 与 calldatasize() 的关系,否则会读到越界补零的数据,造成状态错误。生产环境建议只在 gas 极度敏感、且已经过充分模糊测试的热点函数上使用。


5. 常见优化模式

5.1 unchecked 与自增写法

Solidity 0.8.0 之后所有算术默认带溢出检查,这在循环里是纯开销。

// 贵:i++ 需要临时变量 + 溢出检查,每次约 30~40 gas
for (uint256 i = 0; i < len; i++) { ... }

// 便宜:++i 免去临时变量,unchecked 免去溢出检查
for (uint256 i = 0; i < len; ) {
    // ... 循环体
    unchecked { ++i; }
}

前提是你能证明 i 不会溢出(len 是数组长度,不可能达到 2^256 - 1)。对循环计数器、累加已知有界的数据,unchecked 是安全的;对用户可控的算术,绝对不要用。

5.2 缓存 storage 到 memory

// 差:每次迭代都重新读长度,且重复解析 mapping 槽
for (uint256 i = 0; i < users.length; i++) {
    sum += balances[users[i]];   // 每个用户一次冷读 2100
}

// 好:长度缓存到栈 + storage 指针避免重复解析槽
uint256 len = users.length;
mapping(address => uint256) storage b = balances;
for (uint256 i = 0; i < len; ) {
    sum += b[users[i]];
    unchecked { ++i; }
}

mapping(...) storage b = balances; 这种「storage 指针」写法在多次访问同一 mapping 时能省下重复的槽计算。同理,把在条件表达式里多次读取的状态变量先赋给局部变量(局部变量在栈上,几乎免费),也能避免编译器重复生成 SLOAD。

5.3 位图打包与批量操作

位图不只是省空间,还能把「多次 SSTORE」合并成「一次 SSTORE」:

// 差:10 次独立的 SSTORE(每次改值 2900)
function markAllBad(uint256[] calldata ids) external {
    for (uint256 i = 0; i < ids.length; i++) {
        claimed[ids[i]] = true;   // 2900 × 10 = 29000
    }
}

// 好:位图合并,同一槽内的位操作只需一次 SSTORE
function markAllGood(uint256 bucket, uint8[] calldata bits) external {
    uint256 word = bitmap[bucket];          // 一次冷读 2100
    for (uint256 i = 0; i < bits.length; i++) {
        word |= (1 << bits[i]);             // 3 gas 每次
    }
    bitmap[bucket] = word;                  // 一次改值 2900
}
// 总成本约 2100 + 30 + 2900 = 5030 gas,对比 29000 gas

这是把「N 次存储写」压缩成「1 次存储写 + N 次算术」的典型手法。N 越大收益越明显。


6. 优化前后 Gas 对比与验证

6.1 综合优化对比表

下表是常见优化项的收益量级(solc 0.8.24、optimizer runs=200、单笔交易内首次访问为冷):

优化项优化前 Gas优化后 Gas节省幅度
4 个状态变量打包为 2 个 slot(冷读)8400420050%
部署时状态变量从 3 slot 降到 2 slot600004000033%
自定义错误替代 require 字符串(单次 revert)约 350约 12066%
部署时省去 33 字节错误字符串6600约 0100%
循环内缓存数组长度(100 次迭代)约 4300约 130070%
unchecked 自增(100 次迭代)约 4300约 90079%
calldata 替代 memory 参数(1 KB 数组)约 4100约 10098%
白名单前 256 地址用位图(每次写入)20000290086%
immutable 替代 storage 常量(每次读)2100399.9%
只取 returndata 首字(替代全量复制)约 3000约 40087%

需要强调:这些数字不是叠加的。优化之间会相互影响——把存储读缓存到 memory 之后,SSTORE 的节省就不存在了;位图打包之后,槽已经是热的,再缓存收益就很小。真正的做法是先 profile,再优化最热的那条路径。

6.2 Foundry gas snapshot 与测试

优化不能靠猜,要靠测量。Foundry 提供了 gas snapshot 与 --gas-report 两套工具。

// test/GasBenchmark.t.sol
pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";
import {Packed} from "../src/Packed.sol";
import {Unpacked} from "../src/Unpacked.sol";

contract GasBenchmarkTest is Test {
    // 部署成本对比:断言打包版确实更便宜
    function test_DeployCost() public {
        uint256 g = gasleft();
        new Packed();
        uint256 packedGas = g - gasleft();

        g = gasleft();
        new Unpacked();
        uint256 unpackedGas = g - gasleft();

        assertLt(packedGas, unpackedGas);
    }
}

配合命令行使用:

# 生成 gas 快照基线
forge snapshot

# 修改代码后对比差异(会显示每个测试的 gas 涨跌)
forge snapshot --diff

# 输出函数级 gas 报表
forge test --gas-report

# 只跑某个测试的 gas 明细
forge test --match-test test_ReadCost -vvvv

把 forge snapshot --check 接进 CI,可以在 PR 阶段就拦住「不小心引入的 Gas 回归」。这比事后 review 有效得多——Gas 优化最大的敌人不是不会优化,而是优化了 A 却悄悄让 B 变贵。


7. Yul 独立合约与安全边界

7.1 Yul 独立合约写法

Yul 不只可以内联,还能写成完整合约。这种写法把 gas 压到极致,但可读性与安全性都急剧下降。

object "SimpleStore" {
    // 部署代码:把 runtime 代码复制并返回
    code {
        datacopy(0, dataoffset("runtime"), datasize("runtime"))
        return(0, datasize("runtime"))
    }

    object "runtime" {
        code {
            switch shr(224, calldataload(0))   // 取 calldata 前 4 字节做 selector
            case 0x6057361d {                  // store(uint256)
                sstore(0, calldataload(4))
                stop()
            }
            case 0x2e64cec1 {                  // retrieve()
                mstore(0, sload(0))
                return(0, 32)
            }
            default { revert(0, 0) }
        }
    }
}

部署与调用:

solc --strict-assembly --optimize SimpleStore.yul   # 编译为字节码
forge create SimpleStore --rpc-url $RPC_URL --private-key $PK

独立 Yul 合约适合极致 gas 敏感的场景(如高频调用的小型工具合约),也能帮你理解编译器「本来会生成什么」。代价是没有 ABI 校验、没有溢出检查、没有类型系统,一个笔误就是资金损失。

7.2 内存安全边界

使用内联汇编时,以下四条是必须刻在脑子里的红线:

1) free memory pointer 必须维护:写内存后要更新 0x40 处的指针,
   否则 Solidity 后续分配的内存会覆盖你写的数据。

2) 0x00-0x3f 是暂存区可自由使用;0x40-0x5f 是指针不能乱写;
   0x60-0x7f 是零槽,必须保持为 0(动态空数组会指向它)。

3) 不要修改 Solidity 管理的动态数组长度字段,直接改 length 会让边界检查失效。

4) 手算 mapping 槽必须与 Solidity 布局一致:
   keccak256(abi.encode(key, slot)),数组元素槽是 keccak256(abi.encode(slot)) + index,
   算错就是读写到别的变量上。

一个正确维护 free memory pointer 的写法:

assembly {
    let ptr := mload(0x40)          // 取当前空闲指针
    mstore(ptr, value)              // 写入数据
    mstore(0x40, add(ptr, 0x20))    // 前移 32 字节,告知 Solidity 这块已被占用
}

7.3 踩坑清单

坑后果正确做法
忘记更新 free memory pointerSolidity 后续分配覆盖你的数据,读到脏值每次 mstore 后同步更新 0x40
在汇编里写 0x60 零槽动态空数组行为异常零槽只读不写
手算 mapping 槽漏掉 keccak256读写到错误变量,可能造成资金错配用 keccak256(abi.encode(key, slot)),并用 forge 的 stdStorage 校验
位运算中把 and 写成 or位图设置变成累加,永远清不掉设置用 or,清除用 and 加取反掩码
汇编读取越界 calldata读到补零数据,逻辑静默错误显式校验 calldatasize()
用 assert 校验用户输入掩盖真实 bug,报错信息误导排查输入校验用 require 或自定义错误
在循环里做 SSTORE每次 2900 gas,循环 100 次就是 29 万聚合到 memory,循环结束写一次
用 memory 参数接收大数组1 KB 复制约 4100 gas,1 MB 约 220 万用 external + calldata
immutable 变量在构造函数里条件赋值未赋值的分支编译失败或留下零值确保所有分支都赋值,或用 storage
优化后未跑测试功能静默损坏,Gas 省了但合约废了每次优化后 forge test 加 forge snapshot --diff

一条经验法则:先写正确、清晰的 Solidity,profile 出热点,再只对热点使用内联汇编。在非热点路径上使用汇编,收益微乎其微,却把审计成本和出错概率推高了一个数量级。


8. 总结

  1. Gas 成本的数量级:算术 3 gas、存储读 1002100 gas、存储写 290020000 gas,优化的一切都围绕这个数量级差展开
  2. EIP-2929 冷热访问:冷访问 2100 gas、热访问 100 gas,判断槽是冷是热决定了「缓存到 memory」是否划算
  3. EIP-3529 退款上限:清零退款降到 4800 且上限为 gas_used / 5,同笔交易内「先删后建」会抵消退款
  4. 存储槽打包:按声明顺序把小于 32 字节的变量塞进同一 slot,能把部署成本从 3 slot 降到 2 slot,读写成本同步下降
  5. calldata 优于 memory:external + calldata 零复制,memory 参数要付 CALLDATACOPY 加内存扩展,大数组下差距达两个数量级
  6. 内联汇编的性价比排序:自定义错误(部署省 6000+ gas)> 位图打包(20000 降到 2900)> calldata 解码优化 > 循环展开
  7. Yul 独立合约:把 gas 压到极致,但失去类型系统与安全检查,只适合经过充分验证的小型热点合约
  8. 安全边界:free memory pointer、零槽、mapping 槽计算、calldata 越界,是内联汇编最常出错的四个地方
  9. 必须测量:forge snapshot --diff 加 CI 检查,是防止 Gas 回归的唯一可靠手段

相关阅读


延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「blockchain」更多文章

  1. Rollup 序列器、L3 与应用链
  2. 账户抽象:ERC-4337、智能合约钱包与 Paymaster
  3. 零知识证明:zk-SNARKs、zk-STARKs、电路与隐私应用