Crypto OS
Technical Crypto OS第二阶段 · Smart Contract Engineering

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  ← 第二个参数:数量

三件事因此成立:

  1. 函数名加参数类型才唯一确定一个函数。 同名不同参在 ABI 层面是两个完全不同的函数。
  2. 选择器只有 4 字节,会碰撞。 这个事实在代理合约里会变成一个真实的坑。
  3. calldata 的体积直接是钱。 参数里每多一个 32 字节,用户就多付一份传播成本。在 L2 上这一项往往是费用的大头。

Delegatecall:改写「这是谁的存储」

普通调用是「去你家,用你的代码,动你的存储」。delegatecall 是「把你的代码搬到我家来跑,动的是我的存储,而且 msg.sendermsg.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燃料

执行每一个操作要支付的计量单位。它衡量的是「这次执行给网络带来多少负担」,而不是「代码写了多少行」。

最终花的钱等于 Gas 用量乘以单价。用量由你的代码决定,单价由市场决定——你能优化的只有前者

Storage持久存储

写进状态树、被全网每个节点永久保存的数据。最贵的一类操作。

三档价格:新写入最贵、覆盖次之、清零有退款。优化合约成本,九成的功夫花在减少存储访问次数上。

Memory内存

一次外部调用期间的临时空间,执行结束即释放。

便宜,但注意它的扩展成本是超线性的:把一个很大的数组整体拷进内存,代价会比你预期的高得多。

Calldata调用数据

随交易发送过来的只读输入字节。

不能修改,也正因为不能修改,它不需要分配任何空间,是读取参数最便宜的方式。在 L2 上它同时还是费用的主要构成。

ABI应用二进制接口

函数与参数如何被编码成字节的规则:4 字节选择器加上 32 字节对齐的参数。

它是合约之间、以及链下与合约之间唯一的共同语言。理解它你才能读懂一笔交易的 input 字段到底在调用什么。

Delegatecall委托调用

借用另一份合约的代码,在当前合约的存储上执行,并保持原本的调用者与金额上下文。

它是所有可升级合约的地基,也是存储冲突、初始化劫持这一整类事故的根源。用它之前先问一句:这两份代码对「第 0 号槽是什么」的理解一致吗。

动手

动手写三个功能相同但存储布局不同的函数,实测 Gas 差异任意 Solidity 开发框架 + 本地节点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(地址, 槽号))

算对了,你就掌握了一个很硬的能力:读任何合约的任何内部状态,不管它有没有提供接口。

亲手触发一次存储冲突。 把上面的 ProxyLogic 部署到本地,通过代理调用 setOwner,然后读 implementation

看到它变成了你传进去的地址那一刻,delegatecall 的风险就不再是一个需要背诵的知识点了。

全程在本地节点或测试网上做,不需要任何真实资产。

AI Lab

AI Lab让 AI 解释一段 Delegatecall 代码的存储冲突风险,你来构造一个能证明它的测试Level 2 · AI Copilot

把本章的 ProxyLogic 原样交给模型,分三步。

第一步:
这两份合约通过 delegatecall 组合在一起。请逐个存储槽列出:
Proxy 认为每个槽存的是什么,Logic 认为每个槽存的是什么。指出所有不一致的位置。

第二步:
基于这些不一致,构造一个具体的攻击:攻击者发什么交易、按什么顺序、
最终获得什么。用实际的函数调用描述,不要用抽象的说法。

第三步:
写一个测试,它在当前代码上必须失败,在修好之后必须通过。

第一步模型通常做得很好,因为它本质上是一个对齐表格的任务。

第二步开始出现分化:它很容易给出一个听起来合理、但实际上执行不通的攻击路径,比如调用一个不存在的函数,或者忽略了 fallback 不会被某种调用方式触发。

第三步是这个 Lab 的价值所在。一个「在坏代码上会失败」的测试,是唯一能证明风险真实存在的东西。 模型写不出这个测试,或者写出来的测试在坏代码上居然通过了,那就说明前两步里至少有一步是编的。

做完之后再加一轮:让它给出修复方案,改完之后把第三步的测试再跑一遍。测试从红变绿,才叫修好了;编译通过什么也不能说明。

AI 说完之后,你必须自己验证

  • 它说的槽位编号,你用 eth_getStorageAt 自己读过,编号对得上
  • 它指出的每一个风险,你都写出了一个会失败的测试来证明;证不出来的,说明它在编
  • 它有没有引用不存在的库、合约名或标准编号,任何一个都要自己去查而不是相信
  • 它有没有把「加上某个库就安全了」当成结论,却说不清那个库具体做了什么
  • 它给的修复方案改完之后,原来那个证明风险的测试确实从通过变成了失败
  • 编译通过不等于修好了:每一条修复都要有对应的测试,没有测试的修复不算数

真实案例

共用的库合约被人初始化然后销毁2017 年,某多签钱包库

大量钱包通过 delegatecall 共用同一份库合约。那份库里有一个设置所有者的函数,没有限制只能被调一次,也没有限制只能被代理调用。

有人直接对库本身调用它,成了库的所有者,然后执行了销毁。所有依赖它的钱包在同一秒变成空壳:代码没了,delegatecall 落到一个不存在的地址上,钱永远取不出来。

T7 讲过「对外入口必须假设任何人都会调它」,这里补上另一半:逻辑合约自己也是一个可以被直接调用的地址,不要以为它只会被代理调用。

升级时在中间插入了一个变量

一个协议在新版本的逻辑合约里,为了代码整洁,把一个新变量加在了已有变量的前面。

升级之后所有存储全部错位:余额读成了配置,配置读成了地址。资金没有被偷,但账本彻底乱了,只能停机修复。

规则很死:只能往尾部追加,不能插入、不能改类型、不能换顺序、不能删除。 写一个布局比对脚本放进 CI,比依赖 code review 可靠。

遍历持有人数组做分红

某个合约在分红时遍历所有持有人。上线初期几十个地址,跑得很快。

持有人涨到几千个之后,这个函数的 Gas 消耗超过了单个区块的上限,永久无法执行。任何人只要不断创建新地址持有极小额度,就能主动把它推过这条线。

这类问题的通用解法是把「推」改成「拉」:不由合约挨个发放,而是让每个人自己来领,成本由领的人自己付。

L2 上真正贵的不是计算

在数据要回传主网的二层网络上,一笔交易的费用里,执行本身往往只占很小一部分,大头是 calldata 的数据成本

同一个函数,把参数从冗长的结构体压成紧凑的编码,费用可以差出一截;而在主网上做同样的优化,收益要小得多。

这印证了那条总结:优化的对象不是「代码」,是「你占用了谁的资源」。换一条链,占用的对象变了,优化重点就跟着变。

改一个变量

如果把两个 uint128 打包进同一个槽

如果这两个字段总是被一起读写,你少了一次存储访问,收益明显。

如果它们总是被分开访问,编译器每次都要读出整个槽再做位移和掩码,反而更贵。

打包的前提是访问模式,不是类型大小。 改之前先看调用路径,改之后用上面那个 Lab 的方法实测一遍。

如果把所有 memory 参数改成 calldata

只要函数内部不修改参数,这是一次几乎无副作用的优化,数组越大收益越明显。

但它会传染:如果这个参数要传给一个接收 memory 的内部函数,编译器会在那里插入一次拷贝,你只是把成本挪了个位置。真正的优化是让整条调用路径都用只读视图。

如果逻辑合约里留了一个能自毁的函数

在正常路径上它什么也不做,因为通过代理调用时销毁的是代理还是逻辑,取决于调用方式。

但只要有人能直接调到逻辑合约,并且逻辑合约里有一条没被保护的初始化路径,上面那个 2017 年的案例就会重演一次。

结论:逻辑合约的构造函数里应当直接把它自己标成「已初始化」,让任何人都无法再初始化它。

如果协议升级改了 Gas 定价

具体数值会变,模型不会变:持久化最贵、内存次之、只读输入最便宜,代价始终对应「谁为它承担长期成本」。

这也是这一章不给你任何具体数字的原因。数字会过期,Lab 里那张你自己测出来的表不会——因为你随时能再测一次。

带走的问题

2
为什么需要 Blockchain?

为什么需要 Blockchain?Gas 定价是这个问题最诚实的回答:每一次持久化写入,都要求全世界所有节点永远保存一份副本。贵不是设计缺陷,是「全网可独立验证」这件事的账单。看懂这一点,你就不会再问「为什么不能便宜点」。

5
谁在支付?

谁在支付?这一章里答案是调用者。所以任何「循环长度由用户输入决定」的设计,都是在把一笔不确定的账单甩给用户——而当这笔账单超过区块上限时,甩出去的就不只是钱,是这个功能能不能用。

9
谁承担风险?

谁承担风险?delegatecall 把风险转移到了一个不直观的地方:用户面对的是代理地址,但真正决定行为的是另一份可被替换的代码。 研究任何协议时,读完合约还要多问一句:这个地址是代理吗,谁能换掉它的逻辑。

本章自测

一句话带走

Storage 贵、Memory 便宜、Calldata 最便宜;Delegatecall 会改写「这是谁的存储」。

做完这一章的动手环节了?勾上它查看全部进度

本页目录