在 Solidity 编程中,优化合约的 gas 消耗不仅是性能问题,更是经济问题。理解 EVM 的存储机制和 gas 计算规则,是编写高效智能合约的关键。
本文将深入探讨:
在类型一节中,我们已经学习了 Solidity 中的数据位置概念。这里我们深入讨论它们的实现原理和优化策略。
Solidity 中有四种数据位置,它们在 EVM 层面有完全不同的实现:
| 位置 | EVM 实现 | 持久性 | 典型成本 | 适用场景 |
|---|---|---|---|---|
| storage | 合约存储槽 | 永久 | 20,000 gas (写) | 状态变量 |
| memory | 内存 | 函数调用期间 | 3 gas + 扩展成本 | 临时数据 |
| calldata | 交易数据 | 函数调用期间 | 3 gas (只读) | 外部函数参数 |
| transient | 临时存储 | 交易期间 | 100 gas (写) | 重入锁、临时标记 |
核心概念:
操作码成本(基于 EIP-2929):
SLOAD(读):
- 冷访问(首次):2,100 gas
- 热访问(同一交易中再次访问):100 gas
SSTORE(写):
- 从零改为非零:20,000 gas
- 从非零改为另一个非零:2,900 gas(热访问)
- 从非零改为零:-15,000 gas(退还 gas)
成本模型: Memory 的成本是二次增长的:
cost = 3 * word_count + (word_count^2 / 512)
这意味着使用的内存越多,边际成本越高。
示例:
成本:
为什么便宜:
特点(Solidity 0.8.24+):
操作码成本:
TLOAD:100 gas
TSTORE:100 gas
原理:充分利用 32 字节的存储槽。 变量打包规则:
contract StorageLayout {
// ❌ 低效:占用 3 个槽
uint128 a; // slot 0 (前 16 字节)
uint256 b; // slot 1 (完整 32 字节,无法与 a 共享槽)
uint128 c; // slot 2 (前 16 字节)
// ✅ 高效:占用 2 个槽
uint128 d; // slot 3 (前 16 字节)
uint128 e; // slot 3 (后 16 字节) - 与 d 共享槽!
uint256 f; // slot 4 (完整 32 字节)
}
打包规则详解:
uint256、动态类型(数组、mapping、string)总是开始新槽实测对比:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// ❌ 未优化:3 个 SSTORE 操作
contract Unoptimized {
uint64 a; // slot 0
uint128 b; // slot 1
uint64 c; // slot 2
function set(uint64 _a, uint128 _b, uint64 _c) external {
a = _a; // SSTORE to slot 0: 20,000 gas
b = _b; // SSTORE to slot 1: 20,000 gas
c = _c; // SSTORE to slot 2: 20,000 gas
}
}
// ✅ 优化后:1 个 SSTORE 操作
contract Optimized {
uint64 a; // slot 0 (bytes 0-7)
uint64 c; // slot 0 (bytes 8-15)
uint128 b; // slot 0 (bytes 16-31)
function set(uint64 _a, uint128 _b, uint64 _c) external {
a = _a; // SSTORE to slot 0: 20,000 gas
c = _c; // 修改同一个 slot: 2,900 gas
b = _b; // 修改同一个 slot: 100 gas (热访问)
}
}
Gas 对比(使用 forge snapshot):
Unoptimized.set(): 60,000 gas
Optimized.set(): 23,000 gas
节省: 62%
注意事项:
immutable 和 constant原理:
constant:编译时确定,直接嵌入字节码immutable:部署时确定,存储在代码中而非 storage实测对比:
contract StorageCost {
address public owner; // storage: 2,100 gas (冷读)
address public immutable OWNER; // immutable: ~100 gas
uint256 public constant MAX = 100; // constant: ~3 gas (直接使用值)
constructor() {
owner = msg.sender;
OWNER = msg.sender;
}
function getOwner() external view returns (address) {
return owner; // SLOAD: 2,100 gas (首次)
}
function getOwnerImmutable() external view returns (address) {
return OWNER; // 直接从代码读取: ~100 gas
}
function getMax() external pure returns (uint256) {
return MAX; // PUSH: 3 gas
}
}
适用场景:
constant:编译时就知道的值(魔数、配置)immutable:部署时确定、后续不变的值(工厂地址、初始化参数)原理:将存储槽从非零改为零会退还部分 gas(EIP-3529 后为 4,800 gas)。
contract GasRefund {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
// SSTORE from 0 to non-0: 20,000 gas
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// 使用 `delete` 而不是 `= 0` 更清晰(效果相同)
delete balances[msg.sender]; // SSTORE from non-0 to 0: 4,800 gas refund
payable(msg.sender).transfer(amount);
}
}
注意:Gas 退款是有上限的(交易 gas 使用量的 20%)
原理:避免重复的 SLOAD 操作。
contract CachingExample {
uint256[] public data;
// ❌ 未优化:每次循环都 SLOAD
function sumBad() external view returns (uint256) {
uint256 total;
for (uint i = 0; i < data.length; i++) { // 每次都读 data.length
total += data[i];
}
return total;
// Gas: 2,100 + (2,100 * n) for length checks
}
// ✅ 优化:缓存长度到内存
function sumGood() external view returns (uint256) {
uint256 len = data.length; // 一次 SLOAD: 2,100 gas
uint256 total;
for (uint i = 0; i < len; i++) { // 读 memory: 3 gas
total += data[i];
}
return total;
}
// ✅ 更好:缓存整个数组到内存(如果数组不大)
function sumBest() external view returns (uint256) {
uint256[] memory cached = data; // 批量复制到 memory
uint256 total;
for (uint i = 0; i < cached.length; i++) {
total += cached[i]; // 全部从 memory 读取
}
return total;
}
}
权衡:
成本对比:
contract DataStructureComparison {
mapping(uint => uint) public map;
uint[] public array;
// Mapping 写入
function writeToMap(uint key, uint value) external {
map[key] = value;
// Gas: 20,000 (首次) 或 2,900 (更新)
}
// Array 写入
function writeToArray(uint value) external {
array.push(value);
// Gas: 20,000 (存储值) + 20,000 (更新长度) = 40,000
// 如果需要扩展存储,成本更高
}
// Array 读取(通过索引)
function readArray(uint index) external view returns (uint) {
return array[index];
// Gas: 2,100 (冷) + 额外的边界检查
}
// Mapping 读取
function readMap(uint key) external view returns (uint) {
return map[key];
// Gas: 2,100 (冷)
}
}
选择指南:
push 操作成本高(需要更新长度)原理:bytes 比 string 更灵活,且可以优化。
contract BytesVsString {
// ✅ 短字符串(≤31 字节)优化
bytes32 public shortBytes; // 1 个 slot,32 字节
// ❌ string 总是动态类型
string public shortString; // 至少 2 个 slot(长度 + 数据指针)
// 对于短数据,bytes32 更省 gas
function setShortBytes(bytes32 data) external {
shortBytes = data; // 20,000 gas
}
function setShortString(string memory data) external {
shortString = data; // 40,000+ gas
}
}
选择指南:
bytesN(bytes1 到 bytes32)bytesstring问题:Memory 成本是二次增长的,大量使用会导致 gas 爆炸。
contract MemoryExpansion {
// ❌ 创建大数组会触发昂贵的 memory 扩展
function createLargeArray() external pure returns (uint) {
uint[] memory large = new uint[](10000); // 可能消耗几十万 gas
return large.length;
}
// ✅ 使用 calldata 避免复制
function processLargeArray(uint[] calldata data) external pure returns (uint) {
uint sum;
for (uint i = 0; i < data.length; i++) {
sum += data[i]; // 直接从 calldata 读取
}
return sum;
}
}
contract MemoryOptimization {
struct User {
address addr;
uint256 balance;
string name;
}
mapping(address => User) public users;
// ❌ 复制整个结构体到 memory
function getUserNameBad(address addr) external view returns (string memory) {
User memory user = users[addr]; // 复制整个 User
return user.name;
}
// ✅ 直接返回需要的字段
function getUserNameGood(address addr) external view returns (string memory) {
return users[addr].name; // 只复制 name 字段
}
// ✅ 使用 storage 指针(只读时)
function getUserNameBest(address addr) external view returns (string memory) {
User storage user = users[addr]; // storage 指针,不复制
return user.name;
}
}
区别:
external:参数强制为 calldatapublic:参数默认为 memory(需要复制)contract CalldataOptimization {
// ❌ public 会复制数组到 memory
function processBad(uint[] memory data) public pure returns (uint) {
uint sum;
for (uint i = 0; i < data.length; i++) {
sum += data[i];
}
return sum;
}
// ✅ external 直接使用 calldata
function processGood(uint[] calldata data) external pure returns (uint) {
uint sum;
for (uint i = 0; i < data.length; i++) {
sum += data[i];
}
return sum;
}
}
Gas 对比(100 个元素的数组):
processBad(): ~50,000 gas
processGood(): ~30,000 gas
节省: 40%
contract MemoryVsCalldata {
// ✅ 只读:使用 calldata
function sumArray(uint[] calldata data) external pure returns (uint) {
uint sum;
for (uint i = 0; i < data.length; i++) {
sum += data[i];
}
return sum;
}
// ✅ 需要修改:使用 memory
function doubleArray(uint[] calldata data) external pure returns (uint[] memory) {
uint[] memory result = new uint[](data.length);
for (uint i = 0; i < data.length; i++) {
result[i] = data[i] * 2; // 修改数据,必须用 memory
}
return result;
}
// ⚠️ 内部调用:只能用 memory
function internalFunction(uint[] memory data) internal pure returns (uint) {
return data[0];
}
}
如果数据不需要在链上可查询,可以使用事件,Events 比 Storage 便宜得多。
contract EventVsStorage {
// 完全存储:昂贵但可查询
mapping(address => uint256[]) public userTransactions;
function recordTransaction(uint256 amount) external {
userTransactions[msg.sender].push(amount);
// 成本:20,000 + 20,000 (length) + ... = 40,000+ gas
}
// 使用 Event:便宜但需要链下索引
event Transaction(address indexed user, uint256 amount, uint256 timestamp);
function recordTransactionEvent(uint256 amount) external {
emit Transaction(msg.sender, amount, block.timestamp);
// 成本:~2,000 gas
}
}
成本对比:
Storage: 40,000+ gas
Event: ~2,000 gas
节省: 95%
选择指南:
unchecked 要非常谨慎核心原则:
延伸阅读: