Ethereum 与 EVM:账户、Gas、执行与调试
# Ethereum 与 EVM:账户、Gas、执行与调试
Ethereum 是一套网络与协议,EVM 是其中执行合约字节码的计算规则。Solidity 是编写合约的语言,编译后交给 EVM 执行;RPC 是外部程序向节点查询或提交交易的接口。
# 账户里到底保存什么
以太坊账户状态的核心字段包括 nonce、ETH 余额、存储根和代码哈希。ERC-20 余额通常保存在代币合约的 mapping 中,不是账户原生余额字段。
传统 EOA(Externally Owned Account,外部拥有账户)由私钥控制,传统合约账户由代码定义行为。不能再用 “地址有代码,所以一定不是 EOA” 作为通用安全判断。EIP-7702 (opens new window) 允许 EOA 设置代码委托;账户抽象也让签名、代付和批量操作更加灵活。权限应检查真实授权规则,而不是猜账户类型。
合约不会因为现实时间到了就自己醒来。定时结算需要外部交易触发,例如用户、Relayer(代为广播交易的服务)或自动化执行服务。
# 调用从函数名变成字节码
purchase(7)
→ ABI 编码:函数选择器 + 参数
→ 交易 data
→ EVM 找到对应函数并执行
→ 修改 storage / 调用其他合约 / 产生日志
→ 回执和返回数据
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:同一段代码到底改谁的数据
假设用户 U 调用合约 A,A 再执行合约 B 的代码:
| B 的代码执行时 | A 用 call 调 B | A 用 delegatecall 执行 B |
|---|---|---|
address(this) | B | A |
msg.sender | A | 保留 A 当前调用中的 U |
| 使用的 storage | B 的存储 | A 的存储 |
msg.value | 本次 call 显式附带的金额 | 保留 A 当前调用中的金额 |
因此代理不是把数据搬到新合约,而是让同一个代理使用另一份代码解释自己的存储。如果旧代码把槽位 0 当管理员地址,新代码却把槽位 0 当金额,即使代码能正常编译,也会把旧数据解释错。升级示例见 存储布局与升级测试。
# 为什么 Solidity 的 private 不是隐私保护
private 主要限制其他 Solidity 代码直接按变量名访问,不会加密区块链状态。节点参与者可以读取存储,交易输入与日志也可能公开。密码、身份证号或私钥不能因为变量声明 private 就写上链。
mapping 的 key 无法在合约里自动枚举,不代表其值保密。知道 key 和声明槽位,就可以按存储规则计算位置;链下还可能从交易和事件推断 key。权限控制限制谁能改,隐私保护解决谁能看,必须分别设计。
# 一次失败怎样查
- 核对 chainId、目标地址、该地址代码和 ABI,先排除调错链或调错版本。
- 读取 receipt,区分未打包和已经失败;再读取交易的 from、value、data。
- 用同样调用者和接近的区块状态重放,解析自定义错误,不要只显示通用的 execution reverted。
- 使用 Foundry trace 或节点支持的调试接口,看是哪个外部调用失败、谁在那一层成为
msg.sender。 - 回到业务条件核对余额、allowance、权限和截止时间,而不是一律增大 Gas。
本地实践见 合约测试与调试。不同 RPC 提供商对 trace、历史状态和速率的支持不同,普通 RPC 地址不一定提供全部调试功能。
# 面试时可以这样回答
call 和 delegatecall 有什么区别?参考答案
call 在目标合约自己的上下文里执行;delegatecall 借用目标代码,却操作调用方的存储,并保留当前调用的 msg.sender 和 msg.value。代理升级就是利用这一点保持地址和状态不变,但如果新实现的存储布局或权限有问题,就可能破坏原有数据甚至资产。
为什么读合约通常不用付 Gas,写失败却要付?参考答案
普通读取通过 eth_call 在节点上模拟,不进入共识历史,所以不收链上交易费,但 RPC 服务可能收费。写交易需要被网络执行,失败前已经消耗计算资源,所以业务状态可以回滚,已消耗的 Gas 仍然收费。
模拟成功后,为什么真实交易还是失败?参考答案
模拟是在某个已有状态上执行,真正打包前状态可能变化。例如余额已被另一笔交易花掉、allowance 被减少或截止时间已过。模拟还必须带正确调用者和 value,否则测试的根本不是同一条件。我把模拟用于提前发现问题,最终仍检查真实回执和自定义错误,不承诺模拟成功就必然成交。