Solidity: Gas优化专题 - 存储布局/calldata/打包/不可变量/assembly技巧
### 一、EVM Gas 成本速查表
日期: 2026-07-04 方向: Solidity 阶段: 第四阶段:综合实战 标签: #Gas优化 #存储布局 #Assembly #Yul #SlotPacking #性能
今日目标
Gas 优化是 Solidity 开发者的核心硬技能之一。但优化不是"越省越好"——过度优化会牺牲可读性和安全性。今天系统梳理 Gas 优化的完整知识体系,从 EVM 底层原理到实用技巧,每个优化都附带 Before/After 代码和精确的 Gas 测量数据。
对于 PM 来说,理解 Gas 优化意味着:
- 能评估开发团队的优化方案是否合理
- 能理解为什么某些功能"贵"而某些功能"便宜"
- 能在产品设计中做出对 Gas 友好的架构决策
核心概念
一、EVM Gas 成本速查表
1.1 核心 Opcode Gas 成本
| Opcode | Gas | 说明 | 优化价值 |
|---|---|---|---|
| SSTORE (冷, 0→非0) | 20,000 | 首次写入 storage | 最高 |
| SSTORE (热, 非0→非0) | 2,900 | 修改已有值 | 高 |
| SSTORE (清零) | 退 4,800 | 设置为0,退款 | 中 |
| SLOAD (冷) | 2,100 | 首次读取 storage | 高 |
| SLOAD (热) | 100 | 同一TX内再次读取 | 低 |
| MSTORE/MLOAD | 3 | 内存操作 | 低 |
| CALLDATALOAD | 3 | 读取 calldata | 低 |
| CALL | 2,600 (冷) | 外部调用 | 高 |
| ADD/SUB/MUL | 3-5 | 算术运算 | 极低 |
| Calldata (零字节) | 4/byte | TX数据费 | 中 |
| Calldata (非零字节) | 16/byte | TX数据费 | 中 |
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 Packing | 2,100-20,000/slot | 低 | 强烈推荐 |
| immutable/constant | 2,097/读取 | 极低 | 强烈推荐 |
| calldata 替代 memory | 60/元素 | 极低 | 强烈推荐 |
| 自定义错误 | ~200/次 | 极低 | 强烈推荐 |
| unchecked 循环 | ~57/次 | 低(需确认安全) | 推荐 |
| 缓存 storage 到 memory | 2,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;
}
}
关键要点总结
-
存储操作是最贵的: SSTORE(20,000 gas) 比 ADD(3 gas) 贵 6,666 倍。所有 Gas 优化的第一优先级都是减少存储操作。
-
Slot Packing 是性价比最高的优化: 不需要牺牲可读性,只需要重新排列变量声明顺序,就能节省 50% 的存储成本。
-
immutable 和 constant 是免费的午餐: 几乎没有代价,却能将读取成本从 2,100 gas 降到 3 gas。所有部署后不变的值都应该用 immutable。
-
Assembly 是双刃剑: 能获得最极致的优化(10-30%),但绕过了编译器的安全检查。只在高频核心路径且经过充分测试后使用。
-
优化要有数据支撑: 每次优化前后都用
forge snapshot --diff量化效果。没有数据的优化是主观判断。
常见误区
-
误区: 所有函数都需要优化 — view/pure 函数链下调用免费,只有链上调用(被其他合约调用)才需要优化。
-
误区: 越多 unchecked 越好 — unchecked 只安全用于已经通过其他方式验证不会溢出的场景(如减法前有 require,循环变量增加不超过数组长度)。
-
误区: Assembly 永远更快 — Solidity 编译器(尤其是 via-ir 模式)会做很多优化。简单操作的 assembly 可能反而更慢(因为 Solidity 编译器已经优化过)。
-
误区: Gas 优化和安全审计是独立的 — 某些优化可能引入安全问题(如 unchecked 用错地方)。优化后必须重新运行所有测试。
面试关联
Q: "你会如何优化一个 Gas 很高的智能合约?"
回答:
我会按照以下优先级进行系统性优化:
- 分析热点: 用
forge test --gas-report找出 Gas 最高的函数- 减少存储操作: 检查是否有不必要的 SSTORE/SLOAD,能否缓存到 memory
- Slot Packing: 重新排列结构体和状态变量,将小变量打包到同一 slot
- immutable/constant: 部署后不变的值改用 immutable
- calldata: 只读的外部参数用 calldata 替代 memory
- unchecked/自定义错误: 在安全的位置使用 unchecked,字符串改自定义错误
- 批量操作: 如果有频繁的单次操作,提供批量接口
- Assembly: 仅在极端需要且有充分测试的情况下使用
每一步都用
forge snapshot --diff量化效果,并确保所有测试仍然通过。我的原则是: 可读性和安全性优先,在不牺牲这两者的前提下追求 Gas 效率。
参考资源
- EVM Opcodes Gas Cost — EVM opcode 完整 Gas 表
- RareSkills Gas Optimization — 系统性优化指南
- Solidity Gas Golfing — 极限优化挑战
- Yul Language Specification — Assembly 语法参考
- Foundry Gas Reports — Gas 报告使用
- OpenZeppelin Gas Optimizations — 生产级优化参考