T8 · EVM 深入
同样一行赋值,为什么 Gas 能差一百倍?
- 练习的能力
- BuilderProtocol Literacy
- 动手
- 写三个功能相同但存储布局不同的函数,实测它们的 Gas 差异。
- AI Lab
- 让 AI 解释一段 Delegatecall 代码的存储冲突风险,自己构造一个能证明风险的测试。
一个现实问题
你写了一个批量发放的函数:传进来一组地址和一组数量,挨个记账,最后更新一个累计总额。
function airdrop(address[] memory to, uint256[] memory amounts) external {
for (uint256 i = 0; i < to.length; i++) {
balanceOf[to[i]] += amounts[i];
totalDistributed += amounts[i]; // 这一行
}
}测试网上跑得好好的。上主网那天,运营传了 200 个地址进去,交易直接超出区块上限,发不出去。
你把 totalDistributed 改成循环外面加一次,参数从 memory 改成 calldata,逻辑一个字没动,交易就过去了,而且费用掉了一大截。
同样一行赋值,代价可以差出一两个数量级。这不是编译器优化不够,是你在为不同的资源付费。这一章要搞清楚的就是:你到底在为什么付钱。
思想实验
把 EVM 想成一台只有三种储物空间的机器。
第一种:一个全球共享的永久档案柜。 你往里放一页纸,全世界每一个节点都要永远保存这一页的副本,未来每一个新同步的节点也要下载它。你交的那笔钱,买的是「全网所有人,永远」。
第二种:一张草稿纸。 执行期间随便写,交易结束就撕掉,没有人需要长期保存。便宜,但纸张变大时价格不是线性涨的——纸越大,扩纸的边际成本越高。
第三种:一封已经寄到的信。 调用者把参数随交易一起发过来,这些字节本来就得跟着交易走、本来就要被全网传播。你只是读它,不需要额外分配任何空间。最便宜,而且只读。
现在回看开头那段代码:totalDistributed += amounts[i] 放在循环里,意味着每一轮都往档案柜写一次;挪到循环外,就只写一次。memory 参数意味着把整封信原样抄到草稿纸上再用,calldata 意味着直接读信。
定价逻辑其实非常朴素:你占用了谁的资源、占用多久,就付多少钱。 记住这一句,下面所有的 Gas 差异都不需要背。
再加第二个思想实验,它是这一章后半段的全部内容。
假设你手上有一个剧本(另一份合约的代码),你在自己家里照着它演。剧本里写着「把第 0 号抽屉里的东西换成 X」。问题是,第 0 号抽屉指的是谁家的抽屉?
答案是:你家的。剧本是别人的,房子是你的,抽屉也是你的。于是**「第 0 号抽屉里装的是什么」这件事,由剧本作者的想象决定,而不是由你的实际摆放决定**。他以为第 0 号放的是管理员名字,你实际放的是保险柜钥匙,事故就这么发生了。
你来决定
回到那个批量发放函数。只能改一处,你改哪一处?
观察结果
四个选项都在同一张表上移动:
| 数据放在哪 | 活多久 | 谁在承担成本 | 相对代价 |
|---|---|---|---|
| 持久存储 | 永远 | 全网每一个节点,永远 | 最高 |
| 事件日志 | 永远存在于历史里 | 节点保存历史,但不进状态树 | 中,远低于存储 |
| 执行期内存 | 这次执行 | 当前执行的节点,一瞬间 | 低,但随体积超线性增长 |
| 调用数据 | 这次执行,只读 | 本来就要传播的字节 | 最低 |
一条结论,值得写在便签上贴显示器:
Gas 不是代码行数的函数,是「你占用了谁的资源多久」的函数。
顺着这句话,几个经常被问到的现象立刻就有答案了:为什么读一个状态变量比读一个局部变量贵得多(要去摸档案柜);为什么把一个值清零会退钱(你腾出了档案柜的空间);为什么 view 函数在链下调用不花钱(没有人需要为它达成共识);为什么大数组的循环是定时炸弹(成本随数据增长,而区块上限是固定的)。
建立模型
三块空间
| 关键字 | 位置 | 可写 | 生命周期 | 典型用途 |
|---|---|---|---|---|
storage | 状态树 | 是 | 永久 | 余额、配置、权限 |
memory | 执行期内存 | 是 | 一次外部调用 | 中间结果、需要修改的数组 |
calldata | 调用数据 | 否 | 一次外部调用 | 只读参数,尤其是数组和 bytes |
一条实用规则:外部函数的引用类型参数默认写 calldata,只有在真的需要修改它时才退回 memory。
存储的布局
状态变量按声明顺序占用 32 字节一个的槽,能塞进同一个槽的连续小类型会被打包:
contract Layout {
uint128 a; // slot 0 的低 16 字节
uint128 b; // slot 0 的高 16 字节,和 a 共用
uint256 c; // slot 1
address owner; // slot 2 的低 20 字节
bool paused; // slot 2,紧挨着 owner
mapping(address => uint256) bal; // slot 3 被「占位」,但槽里不存东西
}映射和动态数组不把数据放在自己的槽里,而是用那个槽号参与哈希计算,算出每个元素的真实位置。对于 mapping(address => uint256),键 k 的值放在 keccak256(abi.encode(k, slotOfMapping)) 这个位置。
知道这件事有两个实际用处:你能直接从链上读出任意一个人的余额,不需要合约提供接口;以及你能在测试里直接改写任意槽,这是 T12 的 Fork 测试能「给自己发钱」的原理。
写入还分三档价格:从零写成非零最贵(新增一份全网要永久保存的数据),非零改非零便宜不少(数据量没变),非零写回零会退一部分(腾出了空间)。具体数值会随协议升级变化,不要背,但这个三档结构很稳定,它解释了为什么「第一次操作总是特别贵」。
一次调用长什么样
外部调用的输入就是一段字节:前 4 个字节是函数选择器,等于函数签名字符串哈希后的前四字节;后面是参数,每个基础类型都补齐到 32 字节;动态类型(数组、bytes、string)先放一个偏移量,内容拼在后面。
0xa9059cbb ← 选择器,4 字节
000000000000000000000000abcdef…0001 ← 第一个参数:地址,左补零到 32 字节
00000000000000000000000000000000000000000000000000000000000f4240 ← 第二个参数:数量三件事因此成立:
- 函数名加参数类型才唯一确定一个函数。 同名不同参在 ABI 层面是两个完全不同的函数。
- 选择器只有 4 字节,会碰撞。 这个事实在代理合约里会变成一个真实的坑。
- calldata 的体积直接是钱。 参数里每多一个 32 字节,用户就多付一份传播成本。在 L2 上这一项往往是费用的大头。
Delegatecall:改写「这是谁的存储」
普通调用是「去你家,用你的代码,动你的存储」。delegatecall 是「把你的代码搬到我家来跑,动的是我的存储,而且 msg.sender 和 msg.value 保持不变」。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
// 被借用的剧本
contract Logic {
address public owner; // 它以为自己是 slot 0
function setOwner(address a) external {
owner = a; // 实际上写的是「调用者的 slot 0」
}
}
// 自己的房子
contract Proxy {
address public implementation; // slot 0 ← 和 Logic 的 owner 撞在一起
address public admin; // slot 1
constructor(address impl) {
implementation = impl;
admin = msg.sender;
}
fallback() external payable {
// 真正的代理还要把返回数据原样转发回去,那一步需要内联汇编,这里省略
(bool ok, ) = implementation.delegatecall(msg.data);
require(ok, "delegatecall failed");
}
}任何人对 Proxy 调用 setOwner(0x…),落地的效果是把 implementation 改成了攻击者给的地址。下一次调用,整个代理就跑攻击者的代码了。
这不是 bug,是 delegatecall 的定义。 代码和存储被拆开了,而存储的含义写在代码里。两边对不上,就是灾难。
由此推出代理模式的三条铁律,T28 会展开每一条:
- 代理自己的变量必须放在不会和逻辑合约冲突的位置,实践中是放在伪随机的固定槽里,而不是从 slot 0 开始声明。
- 升级逻辑合约时,新版本的存储布局只能在尾部追加,不能插入、不能改类型、不能调顺序。
- 逻辑合约里绝对不能有能把自己销毁的指令,也不能有能让任意人初始化的函数。
它叫什么
执行每一个操作要支付的计量单位。它衡量的是「这次执行给网络带来多少负担」,而不是「代码写了多少行」。
最终花的钱等于 Gas 用量乘以单价。用量由你的代码决定,单价由市场决定——你能优化的只有前者。
写进状态树、被全网每个节点永久保存的数据。最贵的一类操作。
三档价格:新写入最贵、覆盖次之、清零有退款。优化合约成本,九成的功夫花在减少存储访问次数上。
一次外部调用期间的临时空间,执行结束即释放。
便宜,但注意它的扩展成本是超线性的:把一个很大的数组整体拷进内存,代价会比你预期的高得多。
随交易发送过来的只读输入字节。
不能修改,也正因为不能修改,它不需要分配任何空间,是读取参数最便宜的方式。在 L2 上它同时还是费用的主要构成。
函数与参数如何被编码成字节的规则:4 字节选择器加上 32 字节对齐的参数。
它是合约之间、以及链下与合约之间唯一的共同语言。理解它你才能读懂一笔交易的 input 字段到底在调用什么。
借用另一份合约的代码,在当前合约的存储上执行,并保持原本的调用者与金额上下文。
它是所有可升级合约的地基,也是存储冲突、初始化劫持这一整类事故的根源。用它之前先问一句:这两份代码对「第 0 号槽是什么」的理解一致吗。
动手
这个 Lab 的产出不是一个结论,是一张你自己测出来的表。课程里不给具体数值,因为它会随协议升级变化;数量级的差异不会变,你要亲眼看到它。
写下这三个版本。 功能完全相同:把一个数组求和,累加到一个状态变量上。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
contract GasLab {
uint256 public total;
// A:每一轮循环都写一次持久存储
function sumA(uint256[] memory xs) external {
for (uint256 i = 0; i < xs.length; i++) {
total += xs[i];
}
}
// B:循环内只用局部变量,结束后写一次
function sumB(uint256[] memory xs) external {
uint256 acc = total;
for (uint256 i = 0; i < xs.length; i++) {
acc += xs[i];
}
total = acc;
}
// C:参数不拷贝到内存,直接从调用数据读
function sumC(uint256[] calldata xs) external {
uint256 acc = total;
for (uint256 i = 0; i < xs.length; i++) {
acc += xs[i];
}
total = acc;
}
}用同一组输入分别调用,记下三次的实际 Gas 用量。 用测试框架的 Gas 报告,或者直接读交易回执里的 gasUsed。
数组长度取 5、50、200 各跑一遍,做成一张 3 乘 3 的表。重点看的不是绝对值,是三条曲线的斜率。
做一次「第一次写入」的对比。 让 total 从 0 开始跑一次,再从一个非零值开始跑一次,比较同一个函数两次的 Gas。
这就是上面说的三档价格。很多人第一次看到这个差异时会以为自己测错了。
验证存储布局。 给合约加上一个 mapping(address => uint256) public bal;,写入一个值,然后用 eth_getStorageAt 直接把它读出来——你需要自己算 keccak256(abi.encode(地址, 槽号))。
算对了,你就掌握了一个很硬的能力:读任何合约的任何内部状态,不管它有没有提供接口。
亲手触发一次存储冲突。 把上面的 Proxy 和 Logic 部署到本地,通过代理调用 setOwner,然后读 implementation。
看到它变成了你传进去的地址那一刻,delegatecall 的风险就不再是一个需要背诵的知识点了。
全程在本地节点或测试网上做,不需要任何真实资产。
AI Lab
把本章的 Proxy 和 Logic 原样交给模型,分三步。
第一步:
这两份合约通过 delegatecall 组合在一起。请逐个存储槽列出:
Proxy 认为每个槽存的是什么,Logic 认为每个槽存的是什么。指出所有不一致的位置。
第二步:
基于这些不一致,构造一个具体的攻击:攻击者发什么交易、按什么顺序、
最终获得什么。用实际的函数调用描述,不要用抽象的说法。
第三步:
写一个测试,它在当前代码上必须失败,在修好之后必须通过。第一步模型通常做得很好,因为它本质上是一个对齐表格的任务。
第二步开始出现分化:它很容易给出一个听起来合理、但实际上执行不通的攻击路径,比如调用一个不存在的函数,或者忽略了 fallback 不会被某种调用方式触发。
第三步是这个 Lab 的价值所在。一个「在坏代码上会失败」的测试,是唯一能证明风险真实存在的东西。 模型写不出这个测试,或者写出来的测试在坏代码上居然通过了,那就说明前两步里至少有一步是编的。
做完之后再加一轮:让它给出修复方案,改完之后把第三步的测试再跑一遍。测试从红变绿,才叫修好了;编译通过什么也不能说明。
AI 说完之后,你必须自己验证
- 它说的槽位编号,你用 eth_getStorageAt 自己读过,编号对得上
- 它指出的每一个风险,你都写出了一个会失败的测试来证明;证不出来的,说明它在编
- 它有没有引用不存在的库、合约名或标准编号,任何一个都要自己去查而不是相信
- 它有没有把「加上某个库就安全了」当成结论,却说不清那个库具体做了什么
- 它给的修复方案改完之后,原来那个证明风险的测试确实从通过变成了失败
- 编译通过不等于修好了:每一条修复都要有对应的测试,没有测试的修复不算数
真实案例
大量钱包通过 delegatecall 共用同一份库合约。那份库里有一个设置所有者的函数,没有限制只能被调一次,也没有限制只能被代理调用。
有人直接对库本身调用它,成了库的所有者,然后执行了销毁。所有依赖它的钱包在同一秒变成空壳:代码没了,delegatecall 落到一个不存在的地址上,钱永远取不出来。
T7 讲过「对外入口必须假设任何人都会调它」,这里补上另一半:逻辑合约自己也是一个可以被直接调用的地址,不要以为它只会被代理调用。
一个协议在新版本的逻辑合约里,为了代码整洁,把一个新变量加在了已有变量的前面。
升级之后所有存储全部错位:余额读成了配置,配置读成了地址。资金没有被偷,但账本彻底乱了,只能停机修复。
规则很死:只能往尾部追加,不能插入、不能改类型、不能换顺序、不能删除。 写一个布局比对脚本放进 CI,比依赖 code review 可靠。
某个合约在分红时遍历所有持有人。上线初期几十个地址,跑得很快。
持有人涨到几千个之后,这个函数的 Gas 消耗超过了单个区块的上限,永久无法执行。任何人只要不断创建新地址持有极小额度,就能主动把它推过这条线。
这类问题的通用解法是把「推」改成「拉」:不由合约挨个发放,而是让每个人自己来领,成本由领的人自己付。
在数据要回传主网的二层网络上,一笔交易的费用里,执行本身往往只占很小一部分,大头是 calldata 的数据成本。
同一个函数,把参数从冗长的结构体压成紧凑的编码,费用可以差出一截;而在主网上做同样的优化,收益要小得多。
这印证了那条总结:优化的对象不是「代码」,是「你占用了谁的资源」。换一条链,占用的对象变了,优化重点就跟着变。
改一个变量
如果这两个字段总是被一起读写,你少了一次存储访问,收益明显。
如果它们总是被分开访问,编译器每次都要读出整个槽再做位移和掩码,反而更贵。
打包的前提是访问模式,不是类型大小。 改之前先看调用路径,改之后用上面那个 Lab 的方法实测一遍。
只要函数内部不修改参数,这是一次几乎无副作用的优化,数组越大收益越明显。
但它会传染:如果这个参数要传给一个接收 memory 的内部函数,编译器会在那里插入一次拷贝,你只是把成本挪了个位置。真正的优化是让整条调用路径都用只读视图。
在正常路径上它什么也不做,因为通过代理调用时销毁的是代理还是逻辑,取决于调用方式。
但只要有人能直接调到逻辑合约,并且逻辑合约里有一条没被保护的初始化路径,上面那个 2017 年的案例就会重演一次。
结论:逻辑合约的构造函数里应当直接把它自己标成「已初始化」,让任何人都无法再初始化它。
具体数值会变,模型不会变:持久化最贵、内存次之、只读输入最便宜,代价始终对应「谁为它承担长期成本」。
这也是这一章不给你任何具体数字的原因。数字会过期,Lab 里那张你自己测出来的表不会——因为你随时能再测一次。
带走的问题
为什么需要 Blockchain?Gas 定价是这个问题最诚实的回答:每一次持久化写入,都要求全世界所有节点永远保存一份副本。贵不是设计缺陷,是「全网可独立验证」这件事的账单。看懂这一点,你就不会再问「为什么不能便宜点」。
谁在支付?这一章里答案是调用者。所以任何「循环长度由用户输入决定」的设计,都是在把一笔不确定的账单甩给用户——而当这笔账单超过区块上限时,甩出去的就不只是钱,是这个功能能不能用。
谁承担风险?delegatecall 把风险转移到了一个不直观的地方:用户面对的是代理地址,但真正决定行为的是另一份可被替换的代码。 研究任何协议时,读完合约还要多问一句:这个地址是代理吗,谁能换掉它的逻辑。
本章自测
因为每一次持久化写入都要求全网节点更新并永久保存一份数据,而局部变量的累加只发生在执行期的临时空间里。
循环里写 N 次,就付 N 份长期成本;循环外写 1 次,只付 1 份。差距随 N 线性放大,这就是开头那笔交易从「超出上限」变成「能发出去」的原因。
默认选 calldata,只有在需要修改参数内容时才用 memory。
calldata 是随交易一起到达的只读字节,读它不需要额外分配空间;memory 意味着先把它整体拷贝一份。数组越大,这次拷贝越贵。
注意 calldata 只能用于外部可见的函数参数,内部函数之间传递会退化成拷贝。
因为逻辑合约也从 slot 0 开始声明它自己的变量,两边对同一个槽的理解会冲突。逻辑合约以为在写它的第一个变量,实际写的是代理的第一个变量。
实践中的做法是把代理自己的变量放在一个由固定字符串哈希出来的槽里,那个位置和任何正常声明的变量都不会撞上。
三条线索:
- 读那几个约定俗成的固定槽,看里面是不是一个合法的合约地址。
- 看它的代码长度——纯代理的字节码非常短。
- 看历史交易里有没有「更换实现」这类事件。
区块浏览器通常会直接标出来并给你一个跳转入口,但它是根据启发式规则猜的,遇到非标准代理会漏判。涉及资金判断时自己读一遍槽。
没有标准答案,检查这几件事:
- 有没有在循环里访问持久存储?这通常是最大的一刀。
- 外部函数的数组和 bytes 参数,有没有还留在
memory? - 有没有「一定会一起读写」的小字段,却各占一个槽?
- 有没有链上并不需要、只是为了方便查询而存的数据?那些应该变成事件。
- 有没有长度由用户决定的循环?那不是优化问题,是可用性风险。
先预测,再实测。 预测和实测差得越远,说明你对代价模型的理解还越模糊——这比省下来的那点 Gas 有价值得多。
一句话带走
Storage 贵、Memory 便宜、Calldata 最便宜;Delegatecall 会改写「这是谁的存储」。