存储、内存与数据布局
链上存储是全书最贵的资源——写一格 storage 要 2 万 gas,是加法的六千多倍。想写出省钱、安全的合约,你必须知道每个变量究竟躺在哪个 slot。这一章讲清 storage / memory / calldata 三个数据位置,并让你亲手算出任意变量(包括那些"private"的)的存储位置。
三个数据位置
Solidity 里每个变量都活在三种位置之一,gas 成本天差地别:
| 位置 | 是什么 | 成本 | Android 类比 |
|---|---|---|---|
storage | 合约的永久存储,写进世界状态 | 极贵(写一格 ~20000 gas) | 持久化数据库(Room) |
memory | 函数执行期间的临时内存,调用结束即清 | 便宜(线性增长) | 方法内的局部变量/堆 |
calldata | 只读的输入数据区,存放外部调用参数 | 最便宜(只读不拷贝) | 只读的入参 Bundle |
状态变量默认在 storage;函数里的引用类型参数你要显式标 memory 或 calldata。经验法则:external 函数的只读数组/结构参数尽量用 calldata(省掉一次拷贝),需要修改的临时数据用 memory。
把 storage 想成"每次读写都命中磁盘 IO 的数据库"——你绝不会在热点循环里疯狂读写 Room。链上更极端:一次 SSTORE 是 20000 gas,而一次内存运算个位数 gas。所以链上性能优化的第一原则就是把 storage 读进 memory 处理,最后一次性写回,而不是循环里反复碰 storage。
Storage 是一个巨大的键值数组
每个合约拥有 2^256 个存储槽(slot),每个 slot 32 字节,初始全为 0。状态变量按声明顺序从 slot 0 开始依次排布:
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 的第一课
注意上面 b 和 c 都是 uint128,加起来正好 32 字节,编译器把它们塞进同一个 slot。这就是"变量打包"——把小于 32 字节的变量相邻声明,能省下整格 storage 的钱。反例:如果在 uint128 b 和 uint128 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)
bal[某地址] 时用的公式(keccak256 由本页纯 JS 计算)。任何人都能用它读出合约里任意 mapping 项——包括被标成 private 的。再强调第 9 章那条:private 挡不住任何人。有了上面的公式,配合 eth_getStorageAt(合约地址, slot) 这个 RPC 方法,任何人都能读出合约里每一格数据,不管它标了什么可见性。链上没有秘密。要保密的东西根本不该上链,或只上链它的哈希/承诺(commitment)。
不可变量与常量:根本不占 storage
两个省 gas 利器:constant(编译期定死,直接内联进字节码)和 immutable(部署时在构造函数里定一次,之后不可改)。它们都不占 storage slot,读取几乎免费。凡是"部署后就不变"的配置(如某个固定的代币地址、管理员),优先用它们。
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。
本章小结
- storage(永久、极贵)/ memory(临时、便宜)/ calldata(只读、最省);热逻辑先读进 memory 再一次写回。
- 状态变量按声明顺序占 slot,小于 32 字节的相邻变量会被打包进同一 slot——声明顺序影响 gas。
- mapping/数组按 keccak 公式散列定位;配合 eth_getStorageAt,任何人可读任意变量,private 不保密。
- constant/immutable 不占 storage,是免费读取的最佳配置载体。