Ethereum 与 EVM:账户、Gas、执行与调试

# Ethereum 与 EVM:账户、Gas、执行与调试

Ethereum 是一套网络与协议,EVM 是其中执行合约字节码的计算规则。Solidity 是编写合约的语言,编译后交给 EVM 执行;RPC 是外部程序向节点查询或提交交易的接口。

# 账户里到底保存什么

以太坊账户状态的核心字段包括 nonce、ETH 余额、存储根和代码哈希。ERC-20 余额通常保存在代币合约的 mapping 中,不是账户原生余额字段。

EOA、合约账户与 EIP-7702 的区别统一见 钱包、签名与身份:账户类型。权限应检查真实授权规则,不能仅凭地址有无代码判断账户类型。

合约不会因为现实时间到了就自己醒来。定时结算需要外部交易触发,例如用户、Relayer(代为广播交易的服务)或自动化执行服务。

# 调用从函数名变成字节码

purchase(7)
  → ABI 编码:函数选择器 + 参数
  → 交易 data
  → EVM 找到对应函数并执行
  → 修改 storage / 调用其他合约 / 产生日志
  → 回执和返回数据
1
2
3
4
5
6

ABI(Application Binary Interface,应用二进制接口)约定函数、参数、返回值、错误和事件怎样编码。函数选择器通常是函数签名 Keccak-256 的前 4 字节,例如 transfer(address,uint256);参数名不参与这个签名。

eth_call 在某个状态上模拟执行,不把结果提交到链上;即使模拟的是写函数,也不会保存状态。eth_estimateGas 估计所需 Gas,不保证正式执行成功,因为状态、区块条件和调用者余额都可能变化。

# storage、memory、calldata 与 stack

区域 存放什么 生命周期与常见误区
storage 合约长期状态,如余额和权限 跨交易保留,写入昂贵;代理升级不能随意改变布局
memory 本次调用的临时数据 调用结束即消失;从 storage 拷贝后修改,不会自动写回
calldata 本次调用输入 只读;外部函数处理数组时常用它减少拷贝
stack 执行指令时的操作数 是 EVM 执行机制,不是 Solidity 的持久变量容器

storage 可以把多个较小的值打包到一个 32 字节槽位。mapping 不存 key 列表,而是根据 key 和声明槽位计算实际位置,所以无法原生枚举所有 key。需要查询列表时,额外维护数组或用事件建立链下索引。

# Gas 是工作量,不是 ETH 金额

Gas 计量执行工作量,Gas price 表示每单位工作量的价格。普通 EIP-1559 交易中,实际每 Gas 价格受 baseFee + maxPriorityFeePerGas 与 maxFeePerGas 共同约束;费用上限不足时可能无法被纳入区块。

例如实际用了 50,000 Gas,实际单价为 20 gwei:费用为 1,000,000 gwei,也就是 0.001 ETH。gasLimit 是允许消耗的上限,不意味着一定花完。L2 的总费用还可能包含 L1 数据费用,不能只套这条公式。

revert 会撤销失败调用范围内的状态与日志,但已经执行的工作不会免费。如果顶层交易失败,通常业务状态回滚,发送者的 nonce 和手续费结算仍然发生。try/catch 捕获的子调用失败,则不一定让外层交易整体失败。

# call 与 delegatecall:同一段代码到底改谁的数据

call 和 delegatecall 都是 EVM 提供的合约调用方式,不是项目自己写的方法。它们都能执行另一个合约的代码,关键区别是以谁的身份执行、读写谁的数据:

  • call:让另一个合约办事,操作它的数据。A 调用 B,B 的代码在 B 自己那里执行。
  • delegatecall:借另一个合约的代码办事,操作自己的数据。A 借用 B 的代码,但执行身份和存储仍属于 A。

例如,A、B 各自保存一个数字,B 有一段把数字改成 100 的代码。假设两者保存数字的槽位和类型一致,用户通过 A 执行这段代码:

  • A 用 call 调用 B:B 的数字变成 100,A 不变。
  • A 用 delegatecall 执行 B 的代码:A 的数字变成 100,B 不变。

再看代码里几个常见变量。以下都以用户 U 直接调用 A,A 再执行 B 的代码为前提:

变量或数据及其含义 A 用 call 调 B A 用 delegatecall 执行 B
address(this):当前执行身份 B 的地址 A 的地址
msg.sender:本次调用者 A,因为是 A 调用 B 用户 U,保留 A 收到的调用者
storage:长期保存的数据 读写 B 的数据 读写 A 的数据
msg.value:调用附带的 ETH 数量 A 在这次 call 中转给 B 的金额,不附带则为 0 保留 U 调用 A 时附带的金额,不是又向 B 转一笔 ETH

代理升级用的就是 delegatecall:A 是代理,B 是实现合约。升级时换一个 B 提供业务代码,用户使用的地址和已有数据仍留在 A。

但借来的代码会按自己的存储布局读写 A 的数据,所以不能随意换成一个布局不兼容的实现,更不能使用不可信的代码。具体例子见 合约升级的风险与测试。

# 为什么 Solidity 的 private 不是隐私保护

private 主要限制其他 Solidity 代码直接按变量名访问,不会加密区块链状态。节点参与者可以读取存储,交易输入与日志也可能公开。密码、身份证号或私钥不能因为变量声明 private 就写上链。

mapping 的 key 无法在合约里自动枚举,不代表其值保密。知道 key 和声明槽位,就可以按存储规则计算位置;链下还可能从交易和事件推断 key。权限控制限制谁能改,隐私保护解决谁能看,必须分别设计。

# 一次失败怎样查

  1. 核对 chainId、目标地址、该地址代码和 ABI,先排除调错链或调错版本。
  2. 读取 receipt,区分未打包和已经失败;再读取交易的 from、value、data。
  3. 用同样调用者和接近的区块状态重放,解析自定义错误,不要只显示通用的 execution reverted。
  4. 使用 Foundry trace 或节点支持的调试接口,看是哪个外部调用失败、谁在那一层成为 msg.sender。
  5. 回到业务条件核对余额、allowance、权限和截止时间,而不是一律增大 Gas。

本地实践见 合约测试与调试。不同 RPC 提供商对 trace、历史状态和速率的支持不同,普通 RPC 地址不一定提供全部调试功能。

# 面试时可以这样回答

call 和 delegatecall 有什么区别?

建议回答:call 在目标合约自己的上下文里执行;delegatecall 借用目标代码,却操作调用方的存储,并保留当前调用的 msg.sender 和 msg.value。代理升级就是利用这一点保持地址和状态不变,但如果新实现的存储布局或权限有问题,就可能破坏原有数据甚至资产。

为什么读合约通常不用付 Gas,写失败却要付?

建议回答:普通读取通过 eth_call 在节点上模拟,不进入共识历史,所以不收链上交易费,但 RPC 服务可能收费。写交易需要被网络执行,失败前已经消耗计算资源,所以业务状态可以回滚,已消耗的 Gas 仍然收费。

模拟成功后,为什么真实交易还是失败?

建议回答:模拟是在某个已有状态上执行,真正打包前状态可能变化。例如余额已被另一笔交易花掉、allowance 被减少或截止时间已过。模拟还必须带正确调用者和 value,否则测试的根本不是同一条件。我把模拟用于提前发现问题,最终仍检查真实回执和自定义错误,不承诺模拟成功就必然成交。

# 官方参考

上次更新时间: 2026年10月06日 19:34:07