导语:让每一字节的 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 / NOT | 3 | 无 |
| MUL / DIV / MOD | 5 | 无 |
| EXP | 10 | 指数每字节 +50 |
| KECCAK256 | 30 | 每字(32 字节)+6,加内存扩展 |
| CALLDATALOAD | 3 | 无 |
| CALLDATACOPY | 3 | 每字 +3,加内存扩展 |
| MLOAD / MSTORE | 3 | 加内存扩展 |
| SLOAD | 100(热) | 冷访问额外 +2000 |
| SSTORE | 100 / 2900 / 20000 | 冷槽额外 +2100,退款见下文 |
| LOG1 | 750 | 每字节数据 +8 |
| CALL | 100(热) | 冷账户额外 +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不占 slotstruct内部同样打包,但在数组与 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 是交易的只读输入区,读取成本极低,且不产生内存扩展成本:
| 操作 | 成本 | 说明 |
|---|---|---|
| CALLDATASIZE | 2 gas | 常数时间 |
| CALLDATALOAD | 3 gas | 读 32 字节,越界补零 |
| CALLDATACOPY | 3 + 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 B | 1 | 3 |
| 1 KB | 32 | 96 + 2 = 98 |
| 4 KB | 128 | 384 + 32 = 416 |
| 64 KB | 2048 | 6144 + 8192 = 14336 |
| 1 MB | 32768 | 98304 + 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);
}
成本差异来自两处:
- 部署时:字符串
"Insufficient balance for transfer"有 33 字节,写入代码段要33 × 200 = 6600 gas;自定义错误只有 4 字节选择器加参数编码辅助代码,几乎可忽略 - 运行时 revert:自定义错误直接 ABI 编码 4 字节选择器加参数,约 100
150 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(冷读) | 8400 | 4200 | 50% |
| 部署时状态变量从 3 slot 降到 2 slot | 60000 | 40000 | 33% |
| 自定义错误替代 require 字符串(单次 revert) | 约 350 | 约 120 | 66% |
| 部署时省去 33 字节错误字符串 | 6600 | 约 0 | 100% |
| 循环内缓存数组长度(100 次迭代) | 约 4300 | 约 1300 | 70% |
| unchecked 自增(100 次迭代) | 约 4300 | 约 900 | 79% |
| calldata 替代 memory 参数(1 KB 数组) | 约 4100 | 约 100 | 98% |
| 白名单前 256 地址用位图(每次写入) | 20000 | 2900 | 86% |
| immutable 替代 storage 常量(每次读) | 2100 | 3 | 99.9% |
| 只取 returndata 首字(替代全量复制) | 约 3000 | 约 400 | 87% |
需要强调:这些数字不是叠加的。优化之间会相互影响——把存储读缓存到 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 pointer | Solidity 后续分配覆盖你的数据,读到脏值 | 每次 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. 总结
- Gas 成本的数量级:算术 3 gas、存储读 100
2100 gas、存储写 290020000 gas,优化的一切都围绕这个数量级差展开 - EIP-2929 冷热访问:冷访问 2100 gas、热访问 100 gas,判断槽是冷是热决定了「缓存到 memory」是否划算
- EIP-3529 退款上限:清零退款降到 4800 且上限为 gas_used / 5,同笔交易内「先删后建」会抵消退款
- 存储槽打包:按声明顺序把小于 32 字节的变量塞进同一 slot,能把部署成本从 3 slot 降到 2 slot,读写成本同步下降
- calldata 优于 memory:
external + calldata零复制,memory参数要付 CALLDATACOPY 加内存扩展,大数组下差距达两个数量级 - 内联汇编的性价比排序:自定义错误(部署省 6000+ gas)> 位图打包(20000 降到 2900)> calldata 解码优化 > 循环展开
- Yul 独立合约:把 gas 压到极致,但失去类型系统与安全检查,只适合经过充分验证的小型热点合约
- 安全边界:free memory pointer、零槽、mapping 槽计算、calldata 越界,是内联汇编最常出错的四个地方
- 必须测量:
forge snapshot --diff加 CI 检查,是防止 Gas 回归的唯一可靠手段
相关阅读
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。