上链ON-CHAIN BLOCK 10 / 26
卷三 · 合约工程BLOCK 10

存储、内存与数据布局


链上存储是全书最贵的资源——写一格 storage 要 2 万 gas,是加法的六千多倍。想写出省钱、安全的合约,你必须知道每个变量究竟躺在哪个 slot。这一章讲清 storage / memory / calldata 三个数据位置,并让你亲手算出任意变量(包括那些"private"的)的存储位置。

三个数据位置

Solidity 里每个变量都活在三种位置之一,gas 成本天差地别:

位置是什么成本Android 类比
storage合约的永久存储,写进世界状态极贵(写一格 ~20000 gas)持久化数据库(Room)
memory函数执行期间的临时内存,调用结束即清便宜(线性增长)方法内的局部变量/堆
calldata只读的输入数据区,存放外部调用参数最便宜(只读不拷贝)只读的入参 Bundle

状态变量默认在 storage;函数里的引用类型参数你要显式标 memorycalldata。经验法则:external 函数的只读数组/结构参数尽量用 calldata(省掉一次拷贝),需要修改的临时数据用 memory

Android 类比

把 storage 想成"每次读写都命中磁盘 IO 的数据库"——你绝不会在热点循环里疯狂读写 Room。链上更极端:一次 SSTORE 是 20000 gas,而一次内存运算个位数 gas。所以链上性能优化的第一原则就是把 storage 读进 memory 处理,最后一次性写回,而不是循环里反复碰 storage。

Storage 是一个巨大的键值数组

每个合约拥有 2^256 个存储槽(slot),每个 slot 32 字节,初始全为 0。状态变量按声明顺序从 slot 0 开始依次排布:

Solidity · 布局示例
contract Layout {
    uint256 a;      // slot 0
    uint128 b;      // slot 1(低 16 字节)
    uint128 c;      // slot 1(高 16 字节)← 和 b 打包进同一个 slot!
    address owner;  // slot 2(20 字节)
    bool flag;      // slot 2(和 owner 挤在一起,共 21 字节 ≤ 32)
    mapping(address => uint256) bal; // slot 3(但值散列存放,见下)
}

变量打包:省 gas 的第一课

注意上面 bc 都是 uint128,加起来正好 32 字节,编译器把它们塞进同一个 slot。这就是"变量打包"——把小于 32 字节的变量相邻声明,能省下整格 storage 的钱。反例:如果在 uint128 buint128 c 之间插一个 uint256,打包就被打断,平白多用一个 slot。声明顺序直接影响 gas 账单,这是 Solidity 独有的、和 Kotlin 完全不同的考量。

动态类型的散列布局

数组和 mapping 没法顺序排(长度不定),于是用哈希确定位置。规则要背下来,因为它同时是"读取 private 变量"和"存储碰撞攻击"的基础:

存储位置公式
// mapping 在 slot p,键 k 对应的值存在:
slot(mapping[k]) = keccak256(pad32(k) ++ pad32(p))

// 动态数组在 slot p,元素 i 存在:
slot(array[i]) = keccak256(pad32(p)) + i
// (数组长度本身存在 slot p)
Storage Slot 计算器DEMO
这正是区块浏览器/调试器读取 bal[某地址] 时用的公式(keccak256 由本页纯 JS 计算)。任何人都能用它读出合约里任意 mapping 项——包括被标成 private 的。
风险记账

再强调第 9 章那条:private 挡不住任何人。有了上面的公式,配合 eth_getStorageAt(合约地址, slot) 这个 RPC 方法,任何人都能读出合约里每一格数据,不管它标了什么可见性。链上没有秘密。要保密的东西根本不该上链,或只上链它的哈希/承诺(commitment)。

不可变量与常量:根本不占 storage

两个省 gas 利器:constant(编译期定死,直接内联进字节码)和 immutable(部署时在构造函数里定一次,之后不可改)。它们都不占 storage slot,读取几乎免费。凡是"部署后就不变"的配置(如某个固定的代币地址、管理员),优先用它们。

Solidity
uint256 public constant MAX_SUPPLY = 10000;  // 编译期常量,不占 slot
address public immutable deployer;         // 部署时定,不占 slot
constructor() { deployer = msg.sender; }
要点入账

三句话总结:①storage 极贵、按声明顺序排 slot,把小变量相邻声明可打包省钱;②mapping/数组用 keccak 公式散列定位,你能亲手算出任意 slot、任何人都能读出"private"数据;③不变的配置用 constant/immutable,不占 storage。这些是 gas 优化和安全审计的共同地基。下一章我们再往下一层,直接看这些 SSTORE/SLOAD 指令在 EVM 字节码里长什么样,并跑一台你浏览器里的 mini-EVM。

本章小结

◆ 返回目录