Solidity Gas 优化技巧终极清单

系统梳理存储、部署、跨合约调用、calldata、汇编与编译器层面的全部 Gas 优化手段

12 分钟阅读
Solidity Gas 优化技巧终极清单

Solidity Gas 优化技巧终极清单

目录


一、为什么 Gas 优化重要 + 核心原则

EVM 上每个操作都明码标价,存储操作尤其昂贵。优化的本质是:减少存储写入、减少存储读取、减少不必要的计算和调用

四条贯穿全文的核心原则:

  1. 永远先基准测试(benchmark)——编译器行为依版本和上下文而变,很多”技巧”在新版编译器里已自动完成甚至适得其反;
  2. 避免零到非零的存储写入(22,100 gas,最贵的操作);
  3. 缓存存储读取——每个值做到恰好一次 SLOAD、一次 SSTORE;
  4. 谨慎用复杂度换 Gas——可读性和安全性同样重要。

二、存储优化

#技巧要点与数字
1避免零到一的存储写入零→非零写入 22,100 gas(20,000 写 + 2,100 冷访问),非零→非零仅 5,000。OZ 重入锁用 1/2 而非 0/1 正是为此
2缓存存储变量读存储每次 ≥100 gas,写更贵。uint256 _n = number; 后多次用 _n,做到一读一写
3打包相关变量用位移把多个变量塞进一个 32 字节槽。手动用 uint160 存两个 uint80 比依赖 EVM 自动打包更高效
4打包结构体结构体成员按声明顺序连续存储。按大小排序(大的在前)减少占用槽数。uint64 + address(160 位)= 224 位可共享一槽
5字符串保持 <32 字节<32 字节的字符串把 length*2 存在槽的低位字节;≥32 字节存 length*2+1 且数据另存(keccak256 定位),多一次读
6不变量用 immutable/constant直接嵌入字节码,不占存储,读取零 SLOAD 成本
7用 mapping 代替数组数组读取有长度边界检查(约 2,102 gas)。get(0) 数组 4,860 gas vs mapping 2,758 gas
8数组用 unsafeAccessOZ Arrays.sol 的 unsafeAccess() 跳过冗余长度检查(确保索引有效时)
9用位图代替 bool一个槽存 256 个标志位。空投/白名单标记”已用”的最佳实践
10大数据用 SSTORE2/SSTORE3SSTORE 约 690 gas/字节,合约字节码仅 200 gas/字节。SSTORE2 把数据当不可变字节码部署,EXTCODECOPY 读取
11用存储指针而非 memoryUser storage _u = users[id]User memory 省约 5,000 gas,只读用到的字段,不拷整个结构体
12避免 ERC20 余额归零频繁清空再充值触发重复的零→一写入。留一点”灰尘”保持非零
13从 n 倒数到 0最终存储状态为 0 时有退款,倒数触发退款机制抵消 Gas
14时间戳/区块号用小类型uint48 时间戳够用几百万年,uint32 区块号也够,与其他变量打包

三、部署 Gas 优化

#技巧要点
1用 nonce 预测合约地址地址可由部署者地址 + nonce 确定性计算(LibRLP)。互相依赖的合约在构造函数里直接引用预测地址,省 setter。47,158 vs 49,291 gas
2构造函数设为 payable非 payable 构造函数含隐式 require(msg.value==0),payable 省约 200 部署 gas
3优化 IPFS 元数据哈希Solidity 追加 51 字节元数据,挖更多零字节或用 --no-cbor-metadata 省 10,000+ gas
4一次性合约在构造函数 selfdestruct多部署器等一次性合约可在构造函数末尾自毁(EIP-6780 保留构造函数内 selfdestruct)
5modifier vs 内部函数权衡modifier 内联字节码(运行时更大、调用更便宜);内部函数跳转共享代码(运行时更小、调用更贵)。modifier 每次省约 24 gas 但复用时多 35,000+ 部署 gas
6用 Clone/Metaproxy 部署EIP-1167/3448 把实现地址存字节码,省部署。代价:每次调用的 delegatecall 开销
7admin 函数设为 payable同样省 msg.value 检查
8自定义错误比 require 字符串小自定义错误只存错误签名哈希的前 4 字节,require 字符串要 64+ 字节
9用现成的 CREATE2 工厂省去自定义部署代码开销

四、跨合约调用优化

#技巧要点
1用转账钩子代替”先授权后拉取”ERC1155 回调、ERC721 safeTransfer、ERC1363 transferAndCall,用 data 字段传参,省一次合约交互
2用 fallback/receive 代替 deposit()payable 的 receive/fallback 处理 ETH,fallback 可 abi.decode 解参数
3用 EIP-2930 访问列表预热存储槽和地址,每槽省 200 gas,跨合约/代理必备(上一篇详解)
4缓存外部调用结果避免重复调昂贵的预言机(如 Chainlink),结果存内存/存储复用
5Router 里实现 multicallUniswap Router 模式:multicall(bytes[] calldata) 用 delegatecall 批量调用,保留 msg.sender/value
6单体架构避免合约调用合约调用昂贵,能单合约就别拆多合约

五、设计模式优化

  1. 用 multidelegatecall 批量交易:保留环境变量(msg.sender、msg.value)地批量调本合约多个函数;
  2. 用 ECDSA 签名代替默克尔树:默克尔证明随规模增大耗 calldata,签名更便宜(白名单/空投);
  3. 用 ERC20Permit 合并授权和转账:签名授权,接收方一笔交易 permit + transferFrom;
  4. 高吞吐应用用 L2 消息传递:桥到 Polygon/Optimism/Arbitrum;
  5. 适用时用状态通道:链下签名更新状态,只把最终结果上链;
  6. 投票委托省 Gas:ERC20Votes,只有受托人投票,减少投票交易数;
  7. NFT 用 ERC1155 比 ERC721 便宜:ERC721 的 balanceOf 每次铸/转加存储开销,ERC1155 单 ID 最大供应 1 实现非同质化而无此开销;
  8. 多个 ERC20 用一个 ERC1155/ERC6909:单合约管理多种类代币(ERC6909 无回调);
  9. UUPS 比透明代理高效:透明代理每次调用检查 msg.sender==admin,UUPS 只在升级函数检查;
  10. 考虑 OZ 的替代品:Solmate、Solady(Solady 强调汇编优化)。

六、Calldata 优化

  1. (安全地)用虚荣地址:前导零的地址省 calldata(如 Seaport 0x0000...14dC),仅当地址是函数参数时有效。用 CREATE2 + 足够随机性安全生成(避免 Profanity 漏洞);
  2. 避免 calldata 里用有符号整数:补码表示让小负数(-1 = 0xffff…)calldata 昂贵,尽量用无符号;
  3. calldata 通常比 memory 便宜:直接从 calldata 读函数输入比拷到 memory 便宜,只在需要修改时才用 memory;
  4. L2 上考虑打包 calldata:Solidity 不自动打包 calldata,应用层打包省 L2 calldata 成本(Dencun blobs 后尤其值得)。

七、汇编技巧

#技巧节省
1汇编 revert 带错误信息避免内存扩展和类型检查,省约 300 gas
2汇编做跨合约调用复用内存用 scratch space(0x00-0x40)避免内存扩展,省约 220 gas
3无分支的 min/maxz := xor(x, mul(xor(x, y), gt(y, x))) 代替三元运算符(条件跳转贵)
4用 SUB 或 XOR 做不等检查if sub(caller(), owner) {revert()} 代替 eq,某些上下文省操作码(测两者)
5汇编做 address(0) 检查if iszero(_caller) 省约 90 gas
6selfbalance 比 address(this).balance 便宜selfbalance() 有时更便宜(编译器可能已优化)
7≤96 字节的哈希和事件用汇编用 scratch space 不扩展内存。记录三个 uint256 省约 3,355 gas,哈希省约 1,048 gas
8多次外部调用复用内存第一次调用的参数编码在 scratch space,第二次复用,省约 2,000 gas
9创建多个合约复用内存把创建码存内存一次,多次 create() 复用,省约 1,000 gas
10用位判断奇偶而非取模x & 1 == 0 代替 x % 2 == 0,位运算比取模便宜

八、编译器与代码层面优化

  1. 优先严格不等号(但要测):</> 一般比 <=/>= 便宜(EVM 无 ≤/≥ 操作码);
  2. 拆分 require 的布尔条件require(x>0 && y>0)require(x>0); require(y>0); 早退避免第二次求值;
  3. 拆分 revert 的或条件:同理,分开 if 避免无谓求值;
  4. 总是用命名返回值:编译器对在 return 语句声明的变量生成更高效代码;
  5. 反转被否定的 if-elseif(!cond) a() else b()if(cond) b() else a() 省取反操作码;
  6. 用 ++i 而非 i++:i++ 返回旧值(两个栈值),++i 省 gas(循环里明显);
  7. 适当用 unchecked 数学:默认检查溢出,循环计数器/已校验输入用 unchecked 跳过检查;
  8. 写 Gas 最优 for 循环for(uint i; i<n;){...; unchecked{++i;}}(注:0.8.22 起编译器自动做);
  9. do-while 比 for 略便宜
  10. 避免不必要的类型转换:<uint256 的类型(含 bool、address)除非打包否则更低效;
  11. 短路布尔&&/|| 短路,把更可能为 false 的条件放前面;
  12. 非必要不用 public 变量:public 自动生成 getter,增字节码,用 private + 手动 getter;
  13. 优先很大的 optimizer runs 值:如 --optimize-runs 100000,用编译时间换运行时效率;
  14. 高频函数取最优名字:函数选择器是签名 keccak256 前 4 字节,前导零多的选择器(如 0x06fdde03)省 calldata,给高频函数挑这样的名字;
  15. 位移比乘除 2 的幂便宜x<<1 代替 x*2x>>1 代替 x/2
  16. 有时缓存 calldata 更便宜:复用同一值多次时;
  17. 用无分支算法:避免条件跳转,用数学公式;
  18. 只用一次的内部函数内联:省函数分派的跳转开销;
  19. >32 字节的数组/字符串用哈希比较相等keccak256(a)==keccak256(b)
  20. 幂和对数用查找表:预计算存数组,以存储换计算;
  21. 乘法/内存操作用预编译合约:0x01-0x08 预编译(如 ECRECOVER、SHA256)更便宜;
  22. n*n*n 可能比 n**3 便宜:显式连乘让编译器优化中间结果。

九、危险技巧(不推荐)

  1. 用 gasprice() 或 msg.value 传信息(非标准、易错);
  2. 操纵环境变量 coinbase()/block.number(仅测试,不可production);
  3. 用 gasleft() 做分支逻辑(不可预测、不安全);
  4. send() 不检查成功(静默丢 ETH);
  5. 所有函数都 payable(巨大安全风险,用户误转 ETH);
  6. 外部库跳转(绕过正常调用机制,脆弱);
  7. 追加字节码做子程序(极脆弱,升级/分析时崩)。

十、已过时的技巧

  1. external 比 public 便宜——现代编译器已优化掉差异;
  2. != 0> 0 便宜——编译器优化已消除差异(仍可测)。

十一、关键提醒

  • 永远基准测试替代方案——编译器行为依上下文和版本而变;
  • 首要避免零到一存储写入(22,100 gas);
  • 缓存存储读取,每个值恰好一次 SLOAD 一次 SSTORE;
  • 谨慎用复杂度换 Gas——可读性和安全性优先;
  • --via-ir 测试——某些优化在 IR 管线下行为不同甚至适得其反。

十二、动手练习项目:Gas 优化对拍器 GasGolf

项目目标

实现同一个功能的”朴素版”和”优化版”两套合约,用 Foundry 的 gas report 量化每一项优化的真实节省,建立”基准测试驱动优化”的肌肉记忆。部署到 Sepolia。

合约要求

实现一个简化的空投/白名单领取系统,分两版对拍:

1. NaiveAirdrop.sol(朴素版,故意低效)

  • bool[] public claimed;(每个 bool 占一槽);
  • mapping(address => uint256) public allowlist;(数值额度);
  • require(condition, "long error string here"); 字符串错误;
  • 用数组 + 长度检查;
  • 普通 for 循环(i++、checked 数学);
  • 状态变量都 public。

2. OptimizedAirdrop.sol(优化版,应用清单技巧)

  • 位图记录已领取(mapping(uint256 => uint256) bitmap,1 槽 256 标志);
  • 自定义错误代替字符串;
  • 打包结构体存活动配置(uint128 amount + uint64 deadline + address,按大小排序);
  • immutable 存代币地址、merkle root/签名者;
  • 缓存存储变量到 memory 后运算;
  • unchecked 循环计数器,++i
  • ECDSA 签名白名单代替数组遍历;
  • private 变量 + 必要的手动 getter;
  • 构造函数 payable。

测试与基准要求(Foundry)

  1. test_BothCorrect:两版功能完全一致(同样的领取结果);
  2. test_GasReport:用 forge test --gas-reportvm.snapshotGas逐项对比:
    • 位图 vs bool 数组的领取标记 Gas;
    • 自定义错误 vs 字符串 revert 的部署和触发 Gas;
    • 打包结构体 vs 未打包的读写 Gas;
    • 缓存存储 vs 重复 SLOAD;
    • unchecked 循环 vs checked;
  3. test_ZeroToOneWrite:测量首次写入(零→非零)22,100 gas,对比保持非零(用 1/2 技巧);
  4. test_BitmapPacking:256 个地址的标志只占用 1 个存储槽;
  5. test_SignatureVsArray:ECDSA 验证 vs 数组遍历白名单的 Gas 对比;
  6. 输出一份 Markdown 表格记录每项优化的节省量。

Sepolia 部署与验证步骤

  1. 部署 NaiveAirdrop 和 OptimizedAirdrop;
  2. 对比两者的部署 Gas(Etherscan 上看 deployment cost);
  3. 各自执行一次领取,对比交易 Gas;
  4. forge snapshot 生成 .gas-snapshot 文件,记录基线;
  5. 把每项优化的节省量整理成表,验证”位图""自定义错误""打包”是大头。

进阶挑战(可选)

  • 挑 3 个汇编技巧(如 ≤96 字节事件、无分支 max、汇编 revert)各写一个对拍函数,量化节省;
  • --via-ir 重新跑基准,找出哪些手动优化被 IR 管线”反向优化”了(变慢或无差异),理解”先基准测试”的真谛;
  • 写注释回答:为什么”零到一存储写入”是 22,100 gas?这 22,100 由哪两部分构成,倒数到 0 的退款机制如何抵消它?

💬 评论