返回 SC 笔记
SC Day 87

Solidity: Gas优化专题 - 存储布局/calldata/打包/不可变量/assembly技巧

### 一、EVM Gas 成本速查表

2026-07-04
第四阶段:综合实战
Gas优化存储布局AssemblyYulSlotPacking性能

日期: 2026-07-04 方向: Solidity 阶段: 第四阶段:综合实战 标签: #Gas优化 #存储布局 #Assembly #Yul #SlotPacking #性能


今日目标

Gas 优化是 Solidity 开发者的核心硬技能之一。但优化不是"越省越好"——过度优化会牺牲可读性和安全性。今天系统梳理 Gas 优化的完整知识体系,从 EVM 底层原理到实用技巧,每个优化都附带 Before/After 代码和精确的 Gas 测量数据。

对于 PM 来说,理解 Gas 优化意味着:

  • 能评估开发团队的优化方案是否合理
  • 能理解为什么某些功能"贵"而某些功能"便宜"
  • 能在产品设计中做出对 Gas 友好的架构决策

核心概念

一、EVM Gas 成本速查表

1.1 核心 Opcode Gas 成本

OpcodeGas说明优化价值
SSTORE (冷, 0→非0)20,000首次写入 storage最高
SSTORE (热, 非0→非0)2,900修改已有值
SSTORE (清零)退 4,800设置为0,退款
SLOAD (冷)2,100首次读取 storage
SLOAD (热)100同一TX内再次读取
MSTORE/MLOAD3内存操作
CALLDATALOAD3读取 calldata
CALL2,600 (冷)外部调用
ADD/SUB/MUL3-5算术运算极低
Calldata (零字节)4/byteTX数据费
Calldata (非零字节)16/byteTX数据费

1.2 Gas 优化优先级金字塔

                ┌────────────────────┐
                │ 减少 SSTORE/SLOAD  │ ← 最高优先级 (节省 2,000-20,000 gas)
               ─┤                    ├─
              / └────────────────────┘ \
             /                          \
            ┌────────────────────────────┐
            │ Storage Slot Packing       │ ← 高优先级 (节省 2,100 gas/slot)
           ─┤                            ├─
          / └────────────────────────────┘ \
         /                                  \
        ┌────────────────────────────────────┐
        │ immutable/constant/calldata/memory │ ← 中优先级 (节省 100-2,100 gas)
       ─┤                                    ├─
      / └────────────────────────────────────┘ \
     /                                          \
    ┌────────────────────────────────────────────┐
    │ unchecked/自定义error/短路/assembly         │ ← 低优先级 (节省 10-200 gas)
    └────────────────────────────────────────────┘

二、存储布局优化 (Slot Packing)

2.1 EVM Storage 布局原理

EVM Storage: 2^256 个 slot,每个 slot 32 bytes (256 bits)

单个 slot 的结构:
┌──────────────────────────────────────────┐
│                32 bytes                   │
│  [byte31][byte30]...[byte1][byte0]       │
│  高位 ◄──────────────────────── 低位     │
└──────────────────────────────────────────┘

变量从低位开始填充,同一 slot 内按声明顺序排列

2.2 Slot Packing 实战

// ===== 优化前: 6个 slot (6 * 2,100 = 12,600 gas 冷读取) =====
contract BeforePacking {
    uint256 public totalDeposits;    // slot 0: 32 bytes
    address public owner;            // slot 1: 20 bytes (浪费 12 bytes!)
    uint256 public totalBorrows;     // slot 2: 32 bytes
    bool public paused;              // slot 3: 1 byte  (浪费 31 bytes!)
    uint256 public lastUpdateTime;   // slot 4: 32 bytes
    uint8 public decimals;           // slot 5: 1 byte  (浪费 31 bytes!)

    // 读取所有变量: 6 * 2100 = 12,600 gas (冷)
}

// ===== 优化后: 3个 slot (3 * 2,100 = 6,300 gas 冷读取) =====
contract AfterPacking {
    // Slot 0: 32 bytes (完整使用)
    uint256 public totalDeposits;

    // Slot 1: 32 bytes (完整使用)
    uint256 public totalBorrows;

    // Slot 2: 20 + 1 + 1 + 8 = 30 bytes (打包到一个 slot)
    address public owner;             // 20 bytes ┐
    bool public paused;               //  1 byte  ├── 同一 slot
    uint8 public decimals;            //  1 byte  │
    uint64 public lastUpdateTime;     //  8 bytes ┘

    // 读取所有变量: 3 * 2100 = 6,300 gas (冷)
    // 节省: 6,300 gas (50%!)
}

2.3 Struct Packing

// ===== 优化前: 4个 slot / struct =====
struct PositionBefore {
    address user;         // slot 0: 20 bytes
    uint256 collateral;   // slot 1: 32 bytes
    uint256 debt;         // slot 2: 32 bytes
    uint256 lastUpdate;   // slot 3: 32 bytes
}
// 总计: 4 slots × N positions

// ===== 优化后: 2个 slot / struct =====
struct PositionAfter {
    // Slot 0:
    address user;          // 20 bytes ┐
    uint48 lastUpdate;     //  6 bytes ├── 26 bytes
    uint48 healthFactor;   //  6 bytes ┘

    // Slot 1:
    uint128 collateral;    // 16 bytes ┐
    uint128 debt;          // 16 bytes ┘── 32 bytes
}
// 总计: 2 slots × N positions
// uint128 最大值: 3.4 × 10^38 (用 18 decimals 也能表示 3.4 × 10^20 个代币)
// uint48 最大值: 2.8 × 10^14 (时间戳够用到公元 8,919,000 年)

Gas 测量:

Position 写入:
  Before: 4 × 20,000 = 80,000 gas (冷写)
  After:  2 × 20,000 = 40,000 gas (冷写)
  节省: 40,000 gas (50%)

Position 读取:
  Before: 4 × 2,100 = 8,400 gas (冷读)
  After:  2 × 2,100 = 4,200 gas (冷读)
  节省: 4,200 gas (50%)

三、immutable 和 constant

contract GasComparison {
    // ===== constant: 编译时确定,内联到 bytecode =====
    uint256 public constant MAX_FEE = 1000;          // 读取: 3 gas
    address public constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;

    // ===== immutable: 部署时确定,编译进 runtime bytecode =====
    address public immutable oracle;                   // 读取: 3 gas
    uint256 public immutable deployTimestamp;

    // ===== storage: 运行时读取 =====
    address public admin;                              // 读取: 2,100 gas (冷)

    constructor(address _oracle) {
        oracle = _oracle;                  // immutable 只能在 constructor 设置
        deployTimestamp = block.timestamp;
        admin = msg.sender;
    }

    // 对比函数
    function readConstant() external pure returns (uint256) {
        return MAX_FEE;  // 3 gas — 值直接内联
    }

    function readImmutable() external view returns (address) {
        return oracle;   // 3 gas — 从 bytecode 中读取
    }

    function readStorage() external view returns (address) {
        return admin;    // 2,100 gas (冷) — 从 storage 读取
    }
    // 差距: 2,100 vs 3 = 700倍!
}

四、calldata vs memory

contract CalldataDemo {
    // ===== 优化前: memory =====
    // 数组被复制到内存,每个元素约 ~60 gas
    function sumMemory(uint256[] memory values) external pure returns (uint256) {
        uint256 total;
        for (uint256 i; i < values.length; i++) {
            total += values[i];
        }
        return total;
    }
    // 10个元素: ~2,800 gas

    // ===== 优化后: calldata =====
    // 直接从 calldata 读取,不复制
    function sumCalldata(uint256[] calldata values) external pure returns (uint256) {
        uint256 total;
        for (uint256 i; i < values.length;) {
            total += values[i];
            unchecked { ++i; }
        }
        return total;
    }
    // 10个元素: ~1,900 gas (节省 ~32%)

    // ===== 注意: calldata 不能修改 =====
    // function modifyCalldata(uint256[] calldata values) external {
    //     values[0] = 100;  // 编译错误! calldata 是只读的
    // }
}

五、unchecked 与安全边界

contract UncheckedDemo {
    // ===== 循环计数器 (最常见用法) =====
    function loopBefore(uint256[] calldata data) external pure returns (uint256) {
        uint256 total;
        for (uint256 i = 0; i < data.length; i++) {
            // i++ 包含溢出检查: ~60 gas
            total += data[i];
        }
        return total;
    }

    function loopAfter(uint256[] calldata data) external pure returns (uint256) {
        uint256 total;
        uint256 len = data.length;  // 缓存 length
        for (uint256 i; i < len;) {
            total += data[i];
            unchecked { ++i; }  // 跳过溢出检查: ~3 gas
            // 安全因为: i < len, len ≤ 2^256-1, 不会溢出
        }
        return total;
    }
    // 100次循环节省: ~5,700 gas

    // ===== 已知安全的减法 =====
    function withdrawBefore(uint256 balance, uint256 amount) external pure returns (uint256) {
        require(balance >= amount, "Insufficient"); // 先检查
        return balance - amount;  // 包含溢出检查
    }

    function withdrawAfter(uint256 balance, uint256 amount) external pure returns (uint256) {
        require(balance >= amount, "Insufficient"); // 先检查
        unchecked {
            return balance - amount;  // 已经 require 过,不会下溢
        }
    }
    // 节省: ~20 gas (较小,但高频函数累积可观)
}

六、自定义错误 vs 字符串

contract ErrorDemo {
    // ===== 优化前: 字符串错误 =====
    function depositBefore(uint256 amount) external {
        require(amount > 0, "Amount must be greater than zero");
        // 字符串 "Amount must be greater than zero" 占 35 bytes
        // 存储在 bytecode 中 (增加部署成本)
        // revert 时返回字符串 (增加运行时成本)
    }

    // ===== 优化后: 自定义错误 =====
    error AmountZero();
    error InsufficientBalance(uint256 required, uint256 available);

    function depositAfter(uint256 amount) external {
        if (amount == 0) revert AmountZero();
        // 自定义错误: 只有 4 bytes selector
        // 部署成本: 更少 bytecode
        // 运行时: ~200 gas 节省
        // 额外好处: 可以携带参数,debug更方便
    }

    function withdrawAfter(uint256 balance, uint256 amount) external {
        if (balance < amount) {
            revert InsufficientBalance(amount, balance);
            // 错误信息包含具体数值,比字符串更有用
        }
    }
}

七、Assembly (Yul) 优化

7.1 何时使用 Assembly

Assembly 使用决策:
├── 高频调用函数 (如 DEX swap) → 考虑
├── 已经优化到极限仍需更省 → 考虑
├── 需要直接操作 storage slot → 考虑
├── 一般业务逻辑 → 不推荐 (可读性 > 性能)
└── 安全关键代码 → 谨慎 (Assembly 绕过编译器检查)

7.2 常见 Assembly 优化技巧

contract AssemblyDemo {
    mapping(address => uint256) public balances;

    // ===== 技巧1: 直接读取 mapping slot =====
    function getBalanceOptimized(address user) external view returns (uint256 bal) {
        assembly {
            // mapping slot 计算: keccak256(key . slot)
            mstore(0x00, user)
            mstore(0x20, balances.slot)
            bal := sload(keccak256(0x00, 0x40))
        }
        // 节省: ~50 gas (跳过 Solidity 的额外检查)
    }

    // ===== 技巧2: 高效的地址零检查 =====
    function isZeroAddress(address addr) external pure returns (bool result) {
        assembly {
            result := iszero(addr)
        }
        // vs: return addr == address(0);  // 多几个 opcode
    }

    // ===== 技巧3: 批量 sload (读取连续 slot) =====
    struct PackedData {
        uint128 valueA;  // slot N, 低 128 bits
        uint128 valueB;  // slot N, 高 128 bits
    }

    PackedData public data;

    function readPacked() external view returns (uint128 a, uint128 b) {
        assembly {
            let packed := sload(data.slot)  // 一次 SLOAD 读取两个值
            a := and(packed, 0xffffffffffffffffffffffffffffffff)  // 低 128 bits
            b := shr(128, packed)  // 高 128 bits
        }
        // 1次 SLOAD vs 可能的2次,节省 2,100 gas
    }

    // ===== 技巧4: 高效的 ETH 转账 =====
    function sendETH(address to, uint256 amount) external {
        assembly {
            let success := call(gas(), to, amount, 0, 0, 0, 0)
            if iszero(success) {
                revert(0, 0)
            }
        }
        // vs: payable(to).transfer(amount) 或 (bool s,) = to.call{value: amount}("")
        // Assembly 版本更省因为跳过了 Solidity 的额外检查和 memory 操作
    }

    // ===== 技巧5: 高效的事件发送 =====
    event Transfer(address indexed from, address indexed to, uint256 amount);

    function emitTransferOptimized(address from, address to, uint256 amount) internal {
        assembly {
            // Transfer(address,address,uint256) 的 topic
            let sig := 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
            mstore(0x00, amount)
            log3(0x00, 0x20, sig, from, to)
        }
        // 节省: ~100 gas (跳过 Solidity 的 ABI 编码)
    }
}

八、批量操作优化

contract BatchDemo {
    mapping(address => uint256) public rewards;

    // ===== 优化前: 逐个分发 =====
    // 每次调用: 21000 (base) + 20000 (SSTORE) = ~41000 gas
    // N个用户: N * 41000 gas (N笔交易)
    function distributeOne(address user, uint256 amount) external {
        rewards[user] += amount;
    }

    // ===== 优化后: 批量分发 =====
    // 一笔交易: 21000 (base) + N * 5000 (热SSTORE) = ~21000 + N*5000
    // 节省: (N-1) * 21000 base gas + SSTORE 从冷变热
    function distributeBatch(
        address[] calldata users,
        uint256[] calldata amounts
    ) external {
        if (users.length != amounts.length) revert LengthMismatch();

        uint256 len = users.length;
        for (uint256 i; i < len;) {
            rewards[users[i]] += amounts[i];
            unchecked { ++i; }
        }
    }

    // 100个用户对比:
    // 逐个: 100 * 41,000 = 4,100,000 gas
    // 批量: 21,000 + 100 * 5,000 = 521,000 gas
    // 节省: 3,579,000 gas (87%!)
}

九、优化效果汇总

优化技术典型节省风险等级推荐度
Storage Slot Packing2,100-20,000/slot强烈推荐
immutable/constant2,097/读取极低强烈推荐
calldata 替代 memory60/元素极低强烈推荐
自定义错误~200/次极低强烈推荐
unchecked 循环~57/次低(需确认安全)推荐
缓存 storage 到 memory2,000/次推荐
短路评估变化大极低推荐
批量操作21,000/批次场景推荐
Assembly 优化50-500/次高(绕过检查)谨慎

十、何时不应该优化

不要优化的场景:
├── 只调用一次的函数 (constructor, 初始化)
│   └── 节省的 Gas 不值得降低可读性
├── view/pure 函数 (链下调用免费)
│   └── 除非在链上被其他合约调用
├── 低频管理函数 (setConfig, pause)
│   └── 一年调用几次,优化没意义
├── 安全关键路径
│   └── 宁可多花 Gas 也要确保安全
└── 代码审计期间
    └── 可读性 > 性能,审计师看不懂 = 审计失败

代码实战

综合优化示例: ERC20 代币合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract OptimizedERC20 {
    // ===== 自定义错误 =====
    error InsufficientBalance();
    error InsufficientAllowance();
    error ZeroAddress();

    // ===== immutable =====
    string public immutable name;
    string public immutable symbol;
    uint8 public constant decimals = 18;  // constant

    // ===== Storage =====
    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    // ===== Events (用 assembly 优化高频事件) =====
    event Transfer(address indexed from, address indexed to, uint256 amount);
    event Approval(address indexed owner, address indexed spender, uint256 amount);

    constructor(string memory _name, string memory _symbol, uint256 _initialSupply) {
        name = _name;
        symbol = _symbol;
        totalSupply = _initialSupply;
        balanceOf[msg.sender] = _initialSupply;
    }

    function transfer(address to, uint256 amount) external returns (bool) {
        if (to == address(0)) revert ZeroAddress();

        uint256 senderBalance = balanceOf[msg.sender];
        if (senderBalance < amount) revert InsufficientBalance();

        unchecked {
            balanceOf[msg.sender] = senderBalance - amount;
            // totalSupply 不变,所以 to 的余额增加不会溢出
            balanceOf[to] += amount;
        }

        emit Transfer(msg.sender, to, amount);
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        if (to == address(0)) revert ZeroAddress();

        uint256 allowed = allowance[from][msg.sender];
        if (allowed != type(uint256).max) {
            // 不是无限授权才需要减少
            if (allowed < amount) revert InsufficientAllowance();
            unchecked {
                allowance[from][msg.sender] = allowed - amount;
            }
        }

        uint256 fromBalance = balanceOf[from];
        if (fromBalance < amount) revert InsufficientBalance();

        unchecked {
            balanceOf[from] = fromBalance - amount;
            balanceOf[to] += amount;
        }

        emit Transfer(from, to, amount);
        return true;
    }

    function approve(address spender, uint256 amount) external returns (bool) {
        allowance[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        return true;
    }
}

关键要点总结

  1. 存储操作是最贵的: SSTORE(20,000 gas) 比 ADD(3 gas) 贵 6,666 倍。所有 Gas 优化的第一优先级都是减少存储操作。

  2. Slot Packing 是性价比最高的优化: 不需要牺牲可读性,只需要重新排列变量声明顺序,就能节省 50% 的存储成本。

  3. immutable 和 constant 是免费的午餐: 几乎没有代价,却能将读取成本从 2,100 gas 降到 3 gas。所有部署后不变的值都应该用 immutable。

  4. Assembly 是双刃剑: 能获得最极致的优化(10-30%),但绕过了编译器的安全检查。只在高频核心路径且经过充分测试后使用。

  5. 优化要有数据支撑: 每次优化前后都用 forge snapshot --diff 量化效果。没有数据的优化是主观判断。


常见误区

  1. 误区: 所有函数都需要优化 — view/pure 函数链下调用免费,只有链上调用(被其他合约调用)才需要优化。

  2. 误区: 越多 unchecked 越好 — unchecked 只安全用于已经通过其他方式验证不会溢出的场景(如减法前有 require,循环变量增加不超过数组长度)。

  3. 误区: Assembly 永远更快 — Solidity 编译器(尤其是 via-ir 模式)会做很多优化。简单操作的 assembly 可能反而更慢(因为 Solidity 编译器已经优化过)。

  4. 误区: Gas 优化和安全审计是独立的 — 某些优化可能引入安全问题(如 unchecked 用错地方)。优化后必须重新运行所有测试。


面试关联

Q: "你会如何优化一个 Gas 很高的智能合约?"

回答:

我会按照以下优先级进行系统性优化:

  1. 分析热点: 用 forge test --gas-report 找出 Gas 最高的函数
  2. 减少存储操作: 检查是否有不必要的 SSTORE/SLOAD,能否缓存到 memory
  3. Slot Packing: 重新排列结构体和状态变量,将小变量打包到同一 slot
  4. immutable/constant: 部署后不变的值改用 immutable
  5. calldata: 只读的外部参数用 calldata 替代 memory
  6. unchecked/自定义错误: 在安全的位置使用 unchecked,字符串改自定义错误
  7. 批量操作: 如果有频繁的单次操作,提供批量接口
  8. Assembly: 仅在极端需要且有充分测试的情况下使用

每一步都用 forge snapshot --diff 量化效果,并确保所有测试仍然通过。我的原则是: 可读性和安全性优先,在不牺牲这两者的前提下追求 Gas 效率。


参考资源

  1. EVM Opcodes Gas Cost — EVM opcode 完整 Gas 表
  2. RareSkills Gas Optimization — 系统性优化指南
  3. Solidity Gas Golfing — 极限优化挑战
  4. Yul Language Specification — Assembly 语法参考
  5. Foundry Gas Reports — Gas 报告使用
  6. OpenZeppelin Gas Optimizations — 生产级优化参考