WTF-Solidity 深入以太坊虚拟机 Part2 — 固定长度数据类型的存储表示与 Gas 优化
【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程,供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity
本文为 WTF-Solidity 仓库中《深入以太坊虚拟机》系列译文之一,对应 DiveEVM2017-Part2.md,原文出自 Howard 于 2017 年撰写的 "Diving Into The Ethereum VM Part 2"。文中代码与汇编沿用原文(编译器版本为 Solidity 0.4.x),但其揭示的 EVM 存储布局原理至今仍然适用。
本篇文章聚焦 EVM 最昂贵也是最核心的资源——持久化存储(storage)。我们将通过逐一拆解 Solidity 编译器生成的汇编代码,弄清楚状态变量、结构体、定长数组在存储槽(storage slot)中究竟如何排布,为什么存储访问如此昂贵,以及 Solidity 的存储打包(packing)优化在什么情况下会成功、又会在什么情况下悄然失效。读完本文,你将能像阅读数据库表结构一样阅读合约的存储布局,并据此写出更省 gas 的 Solidity 代码。
从一个sstore说起:存储为什么值得关心
在系列第一部分中,我们看到了一个最简单的 Solidity 合约:
contract C { uint256 a; function C() { a = 1; } }它最终归结为一条sstore指令:
// a = 1 sstore(0x0, 0x1)由此我们可以建立两个基本事实:
- EVM 将值
0x1存储在存储位置0x0; - 每个存储位置可以存储 32 个字节(即 256 位)。
在典型的编程语言中,理解数据类型在如此低级别的表示通常没什么用。但在 Solidity(或任何 EVM 语言)中,这些知识至关重要,因为访问存储极其昂贵:
sstore花费 20000 gas,约比基本算术指令贵 5000 倍;sload需要 200 gas,约比基本算术指令贵 100 倍。
这里的"成本"是真实货币,而不只是毫秒级的性能差异。运行和使用合约的成本,很可能完全由sstore和sload主导。
无限收报机磁带:EVM 存储模型
构建通用计算机需要两个基本要素:
- 一种循环的方式——跳转(jump)或递归(recursion);
- 无限的内存数量。
EVM 汇编有跳转指令,EVM 存储则提供"无限"内存。合约的 EVM 存储就像一个无限的收报机磁带,每个插槽保存 32 个字节:
[32 bytes][32 bytes][32 bytes]...一个有趣的量级对比(原文引用):磁带的长度为 $2^{256}$(32 字节),即每个合约约 $10^{77}$ 个存储槽。可观测宇宙的粒子数约为 $10^{80}$,大约 1000 份合约就足以容纳所有这些质子、中子和电子——尽管这比无穷大要短得多。
空白磁带:声明存储变量不花钱
存储最初是空白的,默认为 0,拥有无限磁带不会花费任何费用。看一个简单的合约来验证零值行为:
// c-many-variables.sol pragma solidity ^0.4.11; contract C { uint256 a; uint256 b; uint256 c; uint256 d; uint256 e; uint256 f; function C() { f = 0xc0fefe; } }存储布局很简单:
- 变量
a在位置0x0; - 变量
b在位置0x1; - 依此类推……
关键问题:如果只使用f,我们要为a、b、c、d、e支付多少费用?编译看看:
$ solc --bin --asm --optimize c-many-variables.sol汇编为:
// sstore(0x5, 0xc0fefe) tag_2: 0xc0fefe 0x5 sstore结论:声明存储变量不需要任何费用,因为不需要初始化。Solidity 仅为该存储变量保留一个位置,只有真正存入某些内容时才需要付费。本例中只需支付写入0x5的费用。
如果手动编写汇编,我们甚至可以选择任意存储位置而无需"扩展"存储:
// Writing to an arbitrary position sstore(0xc0fefe, 0x42)读取零:从未初始化位置sload是合法的
不仅可以在存储的任何位置写入,还可以立即从任何位置读取。从未初始化的位置读取只会返回0x0。看一个从a(未初始化位置)读取的合约:
// c-zero-value.sol pragma solidity ^0.4.11; contract C { uint256 a; function C() { a = a + 1; } }编译:
$ solc --bin --asm --optimize c-zero-value.sol汇编:
tag_2: // sload(0x0) returning 0x0 0x0 dup1 sload // a + 1; where a == 0 0x1 add // sstore(0x0, a + 1) swap1 sstore注意,生成对从未初始化位置执行sload的代码是合法的。然而我们可以比编译器更聪明:由于tag_2是构造函数,且a从未被写入,我们可以把整个sload序列替换为0x0,从而节省 5000 gas——这正是后续讨论的优化空间之一。
结构体:与状态变量相同的布局
来看第一个复杂数据类型——一个有 6 个字段的结构体:
// c-struct-fields.sol pragma solidity ^0.4.11; contract C { struct Tuple { uint256 a; uint256 b; uint256 c; uint256 d; uint256 e; uint256 f; } Tuple t; function C() { t.f = 0xC0FEFE; } }存储中的布局与状态变量完全相同:
- 字段
t.a在位置0x0; - 字段
t.b在位置0x1; - 依此类推……
和前面一样,可以直接写入t.f而无需初始化。编译后可以看到与之前完全相同的汇编:
$ solc --bin --asm --optimize c-struct-fields.sol tag_2: 0xc0fefe 0x5 sstore定长数组:编译器"知道"每个元素的尺寸
现在声明一个固定长度的数组:
// c-static-array.sol pragma solidity ^0.4.11; contract C { uint256[6] numbers; function C() { numbers[5] = 0xC0FEFE; } }由于编译器确切地知道有多少个uint256(每个 32 字节),它可以简单地将数组元素一个接一个地放在存储中,就像对存储变量和结构体所做的那样。本例中我们再次存储到位置0x5。
编译:
$ solc --bin --asm --optimize c-static-array.sol汇编:
tag_2: 0xc0fefe 0x0 0x5 tag_4: add 0x0 tag_5: pop sstore它稍微长一点,但仔细看其实是一样的。手动进一步优化:0x0 + 0x5可直接替换为0x5;push 后立即 pop的指令毫无用处,直接删除。去除标签和虚假指令后,我们又得到相同的字节码序列:
tag_2: 0xc0fefe 0x5 sstore数组边界检查:编译器优化不完美的原因之一
定长数组与 struct、状态变量具有相同的存储布局,但生成的汇编代码不同——原因是 Solidity 为数组访问生成了边界检查。再次编译数组合约,这次关闭优化:
$ solc --bin --asm c-static-array.sol下面给出带注释的汇编,并在每条指令后打印机器状态:
tag_2: 0xc0fefe [0xc0fefe] 0x5 [0x5 0xc0fefe] dup1 /* array bound checking code */ // 5 < 6 0x6 [0x6 0x5 0xc0fefe] dup2 [0x5 0x6 0x5 0xc0fefe] lt [0x1 0x5 0xc0fefe] // bound_check_ok = 1 (TRUE) // if(bound_check_ok) { goto tag5 } else { invalid } tag_5 [tag_5 0x1 0x5 0xc0fefe] jumpi // Test condition is true. Will goto tag_5. // And `jumpi` consumes two items from stack. [0x5 0xc0fefe] invalid // Array access is valid. Do it. // stack: [0x5 0xc0fefe] tag_5: sstore [] storage: { 0x5 => 0xc0fefe }边界检查的流程是:比较索引(5 < 6),结果为真则跳转到tag_5执行写入,否则触发invalid回滚。编译器能够优化掉其中一部分检查,但并不完美——在本文后面我们会看到,边界检查会干扰编译器优化,从而使定长数组的效率远低于存储变量或结构体。
打包行为(Packing):4 个 uint64 塞进一个槽
存储很昂贵,一项关键优化就是把尽可能多的数据打包到一个 32 字节的存储槽中。考虑一个有四个存储变量、每个 64 位的合约,它们加起来正好 256 位(32 字节):
// c-many-variables--packing.sol pragma solidity ^0.4.11; contract C { uint64 a; uint64 b; uint64 c; uint64 d; function C() { a = 0xaaaa; b = 0xbbbb; c = 0xcccc; d = 0xdddd; } }我们期望编译器用一个sstore把它们放进同一个存储槽。编译并观察汇编(节选):
$ solc --bin --asm --optimize c-many-variables--packing.sol tag_2: /* "c-many-variables--packing.sol":121:122 a */ 0x0 /* "c-many-variables--packing.sol":121:131 a = 0xaaaa */ dup1 sload /* "c-many-variables--packing.sol":125:131 0xaaaa */ 0xaaaa not(0xffffffffffffffff) /* "c-many-variables--packing.sol":121:131 a = 0xaaaa */ swap1 swap2 and or not(sub(exp(0x2, 0x80), exp(0x2, 0x40))) /* "c-many-variables--packing.sol":139:149 b = 0xbbbb */ and 0xbbbb0000000000000000000000000000 or not(sub(exp(0x2, 0xc0), exp(0x2, 0x80))) /* "c-many-variables--packing.sol":157:167 c = 0xcccc */ and 0xcccc00000000000000000000000000000000 or sub(exp(0x2, 0xc0), 0x1) /* "c-many-variables--packing.sol":175:185 d = 0xdddd */ and 0xdddd000000000000000000000000000000000000000000000000 or swap1 sstore中间有大量难以人工破解的位运算,但关键点非常清晰:这里只有一个sstore。优化成功!
这一原理与仓库中 WTF 入门教程的存储成本思想一脉相承:在 05_DataStorage/readme.md 中同样强调storage类型的变量存储在链上、gas 消耗最高,而打包正是为了节省链上有限的存储空间。本系列第一部分中uint128 a; uint128 b;被编译器用一次sload+ 一次sstore写入同一槽位(0x0)的实例,正是同一机制的另一种验证:通过共用一个存储位置,Solidity 为第二个变量支付 5000 而不是 20000 gas,节省 15000 gas。
打破优化器:辅助函数让一个sstore变成两个
要是优化器能一直工作得这么好就好了。现在唯一的变化是使用辅助函数来设置存储变量:
// c-many-variables--packing-helpers.sol pragma solidity ^0.4.11; contract C { uint64 a; uint64 b; uint64 c; uint64 d; function C() { setAB(); setCD(); } function setAB() internal { a = 0xaaaa; b = 0xbbbb; } function setCD() internal { c = 0xcccc; d = 0xdddd; } }编译:
$ solc --bin --asm --optimize c-many-variables--packing-helpers.sol汇编输出太多,忽略细节只关注结构:
// Constructor function tag_2: // ... // call setAB() by jumping to tag_5 jump tag_4: // ... // call setCD() by jumping to tag_7 jump // function setAB() tag_5: // Bit-shuffle and set a, b // ... sstore tag_9: jump // return to caller of setAB() // function setCD() tag_7: // Bit-shuffle and set c, d // ... sstore tag_10: jump // return to caller of setCD()现在有两个sstore而不是一个。Solidity 编译器可以在标签(tag)内部进行优化,但不能跨标签优化。
调用函数本身并不昂贵——函数调用只是跳转指令——但sstore优化可能会因此失败。要解决这个问题,编译器需要学会内联函数,从本质上得到与不调用函数相同的代码:
a = 0xaaaa; b = 0xbbbb; c = 0xcccc; d = 0xdddd;如果仔细阅读完整汇编输出,还会发现
setAB()和setCD()的汇编代码被包含了两次,这会增加代码体积,从而在部署合约时花费额外的 gas。这个问题会在系列后续讨论合约生命周期时涉及(见 DiveEVM2017-Part5.md)。
优化器为什么失效:标签边界阻断了常量子表达式传播
优化器不会跨标签进行优化。考虑1 + 1:如果在同一个标签下,可以优化为0x2:
// Optimize OK! tag_0: 0x1 0x1 add ...但如果指令被标签分隔,就做不到了:
// Optimize Fail! tag_0: 0x1 0x1 tag_1: add ...从编译器版本 0.4.13 开始,此行为是正确的,将来可能会改变。这正是理解"为什么简单重构会导致 gas 暴涨"的关键:任何引入函数调用、控制流分支的写法,都可能把原本可合并的存储操作切割到不同标签下。
再次打破优化器:定长数组的打包失败
优化器失败的另一种方式与数组有关。打包是否适用于固定长度的数组?考虑:
// c-static-array--packing.sol pragma solidity ^0.4.11; contract C { uint64[4] numbers; function C() { numbers[0] = 0x0; numbers[1] = 0x1111; numbers[2] = 0x2222; numbers[3] = 0x3333; } }同样,我们期望用一个sstore把四个 64 位数字打包进一个 32 字节的存储槽。编译的汇编太长,直接统计sstore和sload指令数量:
$ solc --bin --asm --optimize c-static-array--packing.sol | grep -E '(sstore|sload)' sload sstore sload sstore sload sstore sload sstore即使这个定长数组与等效结构体或存储变量具有完全相同的存储布局,优化仍然失败——现在需要四对sload+sstore。快速浏览汇编会发现,每个数组访问都带有边界检查代码,并被组织在不同的标签下,而标签边界破坏了优化。
不过有一个小小的安慰:3 个额外的sstore比第一个便宜:
sstore首次写入新位置需要 20000 gas;sstore后续写入现有位置需要 5000 gas。
因此这个特殊的优化失败让我们花费 35k 而不是 20k,额外增加了 75%。
结论:把存储变量当作数据库模式来设计
如果 Solidity 编译器可以计算出存储变量的大小,它就会把它们一个接一个地放在存储中;如果可能,编译器会将数据紧密打包成 32 字节的块。总结目前看到的打包行为:
| 数据类型 | 是否打包 |
|---|---|
| 存储变量 | 是 |
| 结构体字段 | 是 |
| 定长数组 | 没有(理论上:是) |
因为存储访问成本很高,你应该将存储变量视为数据库模式(schema)来设计。编写合约时,做小型实验并检查汇编,以确认编译器是否正确优化,是很有用的实践。
可以确定的是,Solidity 编译器将来会不断改进;但当下,我们不能盲目相信它的优化器。字面意义上,理解你的存储变量是值得的——它直接决定合约的部署与调用成本。
延伸:在现代 Solidity 与 Foundry 中继续深化
本系列后续文章继续深入这一主题:
- Part3 — 动态数据类型的表示 讲解
mapping的keccak256(bytes32(key) + bytes32(position))寻址公式、动态数组、bytes/string短字节数组的单槽编码技巧(encodedLength / 2 = length)以及数组"是更贵版本的映射"这一反直觉结论; - Part1 — 汇编与字节码 提供了本系列的第一性原理基础。
同时,仓库中也提供了与现代工具链对应的实践素材:05_DataStorage/DataStorage.sol 演示了storage/memory/calldata三种数据位置的赋值语义差异(引用 vs 副本);仓库根目录的 foundry.toml 使用solc = "0.8.34"与forge-std、OpenZeppelin 依赖,当前 0.8.x 编译器已支持通过solc --storage-layout输出标准化的存储布局 JSON,也可在 Foundry 工具教程 中找到基于 Foundry 的调试与测试方式——两者都是验证本文所述打包与布局行为的现代化手段。理解 2017 年汇编层面的这些"手工"证据,恰恰是读懂今天编译产物与工具输出的最好起点。
【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程,供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考