关键词:Ethereum gas、gasUsed、EVM、智能合约、Geth、静态 gas、动态 gas、交易手续费
本文是“彻底吃透 Ethereum gas 机制”系列的第二篇,聚焦 gasUsed 的计算细节。读完你将能预测任意一笔交易到底会烧掉多少 ETH,并在优化智能合约或节省手续费时有的放矢。
一、为何 gasUsed 决定钱包里的 ETH
在为用户签名一笔交易前,钱包会先设定 gasLimit。如果最终消耗的 gasUsed超过这个上限,交易会抛出 out of gas 错误,已经划拨的 ETH 不会退还,状态也会回滚。以太坊的 矿工费公式 为:
手续费 = gasUsed × gasPrice因此,准确预估 gasUsed 不仅影响能否执行成功,还直接影响矿工费高低。
二、gas 的角色与基本原理
2.1 限制无限循环的“燃料”
EVM 是图灵完备的,理论上可运行死循环。为防止滥用,所有指令都标明了固定的“燃料”,即 gas。当 账户 ETH 不足以支付所需 gas 时,循环将被迫终止。
2.2 动态调节网络拥堵
gasPrice 由用户自由竞价,拥堵时提高 gasPrice 就能排在靠前的位置。区块 gasLimit 则由矿工投票决定,确保链不会过载。
三、计算 gasUsed 的整体流程
我们可以把一次交易拆成三步:
- Pre-check:深度、余额、地址验证;
- Execute:扣减余额、执行字节码;
- Post-handle:回滚 or 提交状态,退还剩余 gas。
下面从 Geth 客户端源码视角 逐层展开。
四、Call():交易的“外界”入口
在文件 core/vm/evm.go,大写 Call() 是黄皮书 Θ 函数的直接实现。它的主要步骤如下:
- 验证调用栈深度 ≤ 1024;
- 快照 stateDB,便于随时回滚;
- 若目标地址不存在,就新建账户;
- 转账 ETH:调用
Context.Transfer(); - 遇到 预编译合约,直接运行
RunPrecompiledContract(),否则 初始化合约上下文并进入Run()。
contract := NewContract(caller, AccountRef(addrCopy), value, gas)
ret, err = evm.interpreter.Run(contract, input, false)
gas = contract.Gas // ← 这一步确定 gasUsed当 Run() 完成后,leftOverGas 会退还给用户,例外只发生在 ErrExecutionReverted 以外的错误 —— 这时全部 gas 被“罚没”。
五、Run():指令级 gas 统计
5.1 四大环境变量
| 变量 | 含义 |
|---|---|
Stack | 1024 槽位计算栈,gas 最便宜 |
Memory | 临时内存,扩容成本呈二次函数 ↑ |
Storage | 链上状态,单位 gas 开销最高 |
Contract | 当前执行的代码段及剩余 gas |
5.2 单次循环:一次 opcode 的 gas 扣减公式
- 静态 gas constantGas:直接从 JumpTable 读出,固定值。
典型值:ADD/SUB = 3 gas,SHA3 = 30 gas,BALANCE = 700 gas。 动态 gas dynamicGas:计算式大多位于
gas_table.go。受 3 个参数驱动:- memorySize:本次操作需扩展多少字(32 字节)——扩容成本 =
words^2 * 3 + words * 512 - storageAccess:第一次写 slot(cold)扣 22100 gas,后续(hot)只需 5000 gas。
- call datasize:
CALLDATACOPY、RETURNDATACOPY按字节收线性费。
- memorySize:本次操作需扩展多少字(32 字节)——扩容成本 =
示例:当 MSTORE(0x40, 0x1234) 需要把内存从 64 字节扩展到 96 字节时,会触发:
- constantGas = 3
- dynamicGas ≈ 12(二次内存费)
gasUsed += 15
UseGas() 最终判断账户余额是否足够支付 合计 gas 值,不足即 ErrOutOfGas。
👉 想看真实交易如何一步步扣除 gas?一键查看链上细节演示!
六、静态 vs 动态 gas 速查表
| opcode | constantGas | 是否触发 dynamicGas | 备注 |
|---|---|---|---|
| ADD/SUB | 3 | 否 | |
| MLOAD/MSTORE | 3 | 是(存储位) | words 3 + words^2 512 |
| SLOAD | 100 | 是(冷热 slot) | cold +2100,hot 0 |
| SSTORE | 0 | 是 | 写入 0->非 0 扣 22100 等复杂规则 |
| CALL | 700 | 是 | memory、value、new account |
七、实战:估算一笔 ERC-20 转账的 gasUsed
假设 value = 100 USDT, 目标地址无代码:
- base transaction fee = 21000
- memory expansion ≈ +64 → + 384
- CALL to token contract → 700
balanceOf+transfer→ ~34071
预估 gasUsed = 21000 + 384 + 700 + 34071 ≈ 56155
为防止滑点,开发者们在 web3.eth.estimateGas 返回值基础上再加 15%,并将 gasPrice 设为当前 baseFee + priorityFee 区间。
八、常见问题 FAQ
Q1:为何链上显示的 gasUsed 会比 Remix 模拟多几千?
A:Remix 默认地址未重置 storage cache,实际更新 dirty slot 会触发冷/热差值,链上必然收取 cold gas。另外调用深度不同也会产生差异。
Q2:如何查看 一笔失败交易 实际消耗多少 gas?
A:在区块浏览器失败交易的页面,gasUsed × gasPrice 即实际烧掉的 ETH。也可用 debug_traceTransaction 获取每一步 gas 变化。
Q3:staticcall、delegatecall 与普通 call gas 规则有何不同?
A:STATICCALL 禁止状态变更,减少写 storage 的开销,DELEGATECALL 不切换 msg.sender 但计算对照正常逻辑,内存费+700 基本费一致。
Q4:调用 precompiled 合约为什么 gasUsed 低很多?
A:预编译函数在 Geth 的本地 Go 代码 中运行,无 EVM 指令消耗,例如 ecrecover 固定收 3000 gas,无论复杂度。
Q5:怎么把一笔交易的 gasUsed 降到理论最小值?
A:
- 优化合约逻辑,减少 storage 写入次数;
- 用
unchecked {}包加减,输回给内存而非 storage; - 提前计算跳出循环条件,避免多余 opcode。
Q6:调用 estimateGas 老是返回大幅度低估,怎么办?
A:把 from 改为真正的 发送者地址,并在合约状态与高峰期相同的环境(如高 slot 编号、复杂 Merkle 路径)做估算,可提高精度。
九、下章预告
在即将到来的第 3 篇,我们将深入探索:
- SSTORE 的内部计价(dirty map、热冷键值)
- Intrinsic gas 结构与 EIP-2028 压缩字节节省诀窍
- 如何用 Hardhat 或 Foundry 一键测 gas 差值并生成报告
如果你对某一步细节仍有疑问,欢迎在评论区留言;我也会把热点案例贴在下期更新中。提前掌握 gas 预估公式,才能在链上冲浪时少踩坑!