Appearance
L2-8: Gas Golf(Gas 优化)
1. 问题
在以太坊上,每一笔交易的执行都需要消耗 Gas(以 gwei 计价的 EVM 计算资源)。一个未经优化的合约可能消耗 200,000 gas,而经过专业优化后可能仅需 80,000 gas——超过 2.5 倍的差异直接转化为用户的交易成本。对于高频调用场景(如 DEX 兑换、NFT 铸造),gas 优化不是锦上添花,而是产品竞争力的核心。
本挑战(参考 src/level2/GasGolf.sol)要求系统性地掌握多种 gas 优化技术,并通过对比函数直观展示每种技术的 gas 节省效果。挑战涵盖六大技术维度:immutable/constant vs storage、变量打包、calldata vs memory、unchecked 算术、自定义错误 vs require 字符串、以及短路评估。
2. 原因
Gas 优化本质上是 EVM 操作码级别的成本控制。每一个 SSTORE(存储写入)消耗 20,000 gas(冷写入),而 immutable 变量仅在构造函数写入一次、后续每次读取仅约 5 gas。这 4,000 倍的差异说明:理解数据存储机制比任何代码技巧都重要。
Gas Golf 不仅教你怎么省 gas,更教你"gas 的代价是什么"。例如,calldata 的每个非零字节花费 16 gas,零字节花费 4 gas——这解释了为什么 address(0) 作为参数反而更便宜,以及为什么压缩参数类型的尺寸(uint256 → uint128)能省 gas。同样,unchecked 块省掉了每个算术操作的溢出检查 opcode(ADD vs unchecked_add),在循环累加场景省 gas 尤为显著。
更重要的是,Gas Golf 培养"gas 感知"的编程习惯——在写每一行 Solidity 代码时,潜意识里思考"这行代码花多少 gas?有没有更便宜的方式?"。这是从"能跑就行"到"专业合约开发"的质变。
3. 方案
GasGolf 合约(参考 src/level2/GasGolf.sol)展示了六种 gas 优化技术的对比实现:
技术 1:immutable 和 constant vs storage
solidity
uint256 public constant MAX_SUPPLY = 10_000; // 编译时内联,~5 gas/读
uint256 public immutable i_ownerFee; // 构造时设定,~5 gas/读
uint256 public storageFee = 100; // SSTORE存储,2100+ gas/读
function readImmutable() public view returns (uint256) {
return i_ownerFee + MAX_SUPPLY; // 两个都是 ~5 gas
}
function readStorage() public view returns (uint256) {
return storageFee + MAX_SUPPLY; // storageFee 需 SLOAD (2100 gas)
}
原理:constant 在编译时直接替换为字面量(内联到字节码中),immutable 在合约部署时写入合约代码的特殊段(同样只需要 ~5 gas 读取),而 storage 变量需要执行 SLOAD 操作码(冷读取 2100 gas)。
技术 2:变量打包
solidity
struct PackedData {
uint128 valueA;
uint128 valueB;
} // 两个 uint128 共享一个 32 字节 slot → 一次 SSTORE
struct UnpackedData {
uint256 valueA;
uint256 valueB;
} // 两个独立 slot → 两次 SSTORE
function writePacked(uint128 _a, uint128 _b) public {
packed = PackedData(_a, _b); // 1 × 20000 gas (cold)
}
function writeUnpacked(uint256 _a, uint256 _b) public {
unpacked = UnpackedData(_a, _b); // 2 × 20000 gas (cold)
}
原理:EVM 的存储槽是 32 字节(256 位)对齐的。两个 uint128(各 16 字节)可以并排放入同一个 32 字节槽,EVM 编译器会将它们合并为一次 SSTORE。而两个 uint256 各自占满一个槽,需要两次 SSTORE。
技术 3:calldata vs memory
solidity
function sumCalldata(uint256[] calldata _data) public pure returns (uint256) {
uint256 total;
for (uint256 i; i < _data.length; ++i) {
total += _data[i]; // 直接从 calldata 读取
}
return total;
}
function sumMemory(uint256[] memory _data) public pure returns (uint256) {
uint256 total;
for (uint256 i; i < _data.length; ++i) {
total += _data[i]; // 从 memory 读取(需要先拷贝)
}
return total;
}
原理:memory 参数在函数入口处会从 calldata 完整拷贝到 memory 中——数组越大,拷贝成本越高。calldata 是只读的,指向交易原始输入数据,完全不需要拷贝。
技术 4:unchecked 算术
solidity
function sumUnchecked(uint256[] calldata _data) public pure returns (uint256) {
uint256 total;
unchecked {
for (uint256 i; i < _data.length; ++i) {
total += _data[i]; // 跳过溢出检查
}
}
return total;
}
原理:Solidity 0.8+ 默认对每次 +、-、* 操作插入溢出检查。在循环内部可以使用 unchecked 包裹来跳过这些检查——前提是你确信不会溢出(例如 i 不可能达到 2^256)。
技术 5:自定义错误 vs require 字符串
solidity
error InvalidValue(uint256 value);
function validateWithCustomError(uint256 _value) public pure returns (bool) {
if (_value == 0) revert InvalidValue(_value); // 仅 4 字节 selector
return true;
}
function validateWithRequire(uint256 _value) public pure returns (bool) {
require(_value != 0, "Value cannot be zero"); // 完整字符串存于字节码
return true;
}
原理:require 的 error string 完整存储在合约字节码中(每个字符都是一个字节,长信息可能上百字节),revert 时全部返回给调用者。自定义错误仅存储 4 字节的函数选择器 + ABI 编码参数,大幅减少字节码大小和返回数据量。
技术 6:短路评估
solidity
// 先检查便宜的(constant 读取),再检查贵的(storage 读取)
function checkShortCircuit(address _addr, uint256 _amount) public view returns (bool) {
if (_amount <= MAX_SUPPLY && storageFee > 0 && _addr != address(0)) {
return true;
}
return false;
}
// 不良实践:先读 storage
function checkNoShortCircuit(address _addr, uint256 _amount) public view returns (bool) {
if (storageFee > 0 && _amount <= MAX_SUPPLY && _addr != address(0)) {
return true;
}
return false;
}
原理:Solidity 的 && 是短路操作符——如果第一个条件为 false,后续条件不会执行。将最便宜的条件(constant 读取、address(0) 比较)放在前面,最贵(storage 读取)放在后面。
4. 遭遇的陷阱
- constant 不等于 immutable:在声明
constant时给的值必须在编译时确定(不能依赖构造函数参数),否则编译失败;而immutable可以在构造函数中赋值 - 打包结构体的读取成本:打包节省了写 gas,但读取其中某个字段时需要额外的移位操作(与 mask + shift),这在某些场景下可能增加读取成本
- unchecked 导致静默溢出:在
unchecked块中如果发生了溢出,不会 revert 而是静默绕回——你的合约可能会以错误的状态继续执行 - calldata 数组不能修改:在 calldata 数组上尝试写操作(如
arr[0] = 5)会编译失败,因为它是指向只读数据的指针 - 过度使用
unchecked在非循环场景:对于单次a + b运算,节省的 gas 极少(约 3-5 gas),但引入的溢出风险可能很大
5. 陷阱的原因
constant vs immutable 的根本区别在于赋值时机:constant 在编译时求值,必须是编译期常量表达式;immutable 在构造时求值,可以依赖构造函数参数。编译时求值意味着 constant 变量在字节码中被直接替换为字面量;构造时求值意味着 immutable 变量被写入合约代码段(类似 ROM),不可再修改。因此 constant 不能是 address(this) 或 block.chainid 这些运行时才确定的值。
打包结构体的读取开销来源于 EVM 的字长:32 字节。当你读取打包在同一个 slot 中的 uint128 时,EVM 先读取整个 32 字节(SLOAD),然后用 mask 提取低 128 位并用右移提取高 128 位。虽然 SLOAD 的成本远高于 AND/SHR 操作,但如果你频繁读取打包变量且从不写入,打包反而不带来收益。
unchecked 溢出不 revert 的原因是 Solidity 编译器在 unchecked 中直接使用 EVM 的 ADD 操作码(带有自然溢出回绕行为),而非 ADD + 溢出检查组合。这是有意为之的性能设计,但要求开发者自己保证安全性。
6. 如何解决陷阱
使用 immutable 处理构造时才确定的固定值(如 owner fee rate),使用 constant 处理完全在编译时确定的值(如 fixed supply cap)。它们的声明语法不同:
solidity
uint256 public constant MAX_SUPPLY = 10_000; // 字面量
uint256 public immutable i_ownerFee; // 在构造函数赋值
constructor(uint256 _ownerFee) { i_ownerFee = _ownerFee; }
打包结构体时权衡读写频率:如果结构体频繁读取单个字段,考虑是否值得打包;如果是批量初始化后偶尔读取,打包总是划算的。可以使用 memory 缓存来摊销读取成本:
solidity
function readPacked() public view returns (uint128 a, uint128 b) {
PackedData memory p = packed; // 一次 SLOAD,之后在 memory 中操作
return (p.valueA, p.valueB);
}
在 unchecked 块中,可以使用注释标注安全原因,并使用 Foundry 的 fuzz 测试验证边界条件不溢出。对于循环计数器,几乎总是安全的(不可能达到 2^256),但对于累加器,需要确保最大输入不会溢出。
始终使用 calldata 处理外部函数的引用类型参数,除非你确实需要修改参数。对于需要修改的场景,在函数内部创建 memory 副本后再修改。
7. 技术要点
| 要点 | 说明 |
|---|---|
| SSTORE (cold 0→非0) | 20,000 gas — 首次写入最高成本 |
| SSTORE (warm) | 5,000 gas — 非零修改非零 |
| SLOAD (cold) | 2,100 gas — 首次读取 |
| immutable/constant | ~5 gas — 内联到字节码,不经过 storage |
| calldata 每非零字节 | 16 gas |
| calldata 每零字节 | 4 gas |
| 变量打包 | 两个 uint128 = 一个 32 字节槽 → 一次 SSTORE |
| unchecked 算术 | 跳过 ADD/MUL/SUB 的溢出检查 opcode |
| 自定义 error | 4 字节 selector vs require string 的完整文本 |
| 短路评估 | && 和 ` |