深入以太坊虚拟机(EVM)· 上篇:合约创建与消息调用

·

适用人群:已了解 以太坊智能合约EVM 基础概念,希望进一步深入机制细节的开发者。
关键词:EVM、智能合约、合约创建、消息调用、gas、delegatecall、bytecode、storage

在区块链开发中,写出逻辑正确的 智能合约 只是第一步;只有真正读懂 EVM 的行为,我们才能确保 安全性可升级性。本文(上半篇)将围绕三个核心主题展开示例与剖析:

  1. 合约创建——从字节码到链上账户
  2. 消息调用——普通 call 与 delegatecall 的异同
  3. gas 节省机制——如何避免 out-of-gas 风险

👉 跟着实战仓库动手跑一次,你将获得最直观的 EVM 行为理解。


1. 智能合约到底是什么

1.1 Account 模型再回顾

在以太坊里,一切皆是 账户(Account),共两类:

| 类型 | 是否存代码 | 是否有私钥 | 是否有 stateRoot |
|---|---|---|---|
| 外部账户(EOA) | × | √ | × |
| 合约账户 | √ | × | √ |

所有地址共用 160-bit 的地址空间,由 keccak256 驱动。每次部署新合约即向全网广播一笔 to 地址为空的交易data 字段携带 字节码

1.2 字节码 ≠ 存储码

部署阶段真正执行的叫 initialization code(初始化字节码)。它负责:

因此,初始化逻辑只跑一次,完成后任何人都无法再替换 runtime code,使合约“不可篡改”。


2. 第一段实战:从 Truffle 部署看生命周期

2.1 准备环境

git clone https://github.com/facuspagnuolo/ethereum-in-depth.git
cd ethereum-in-depth/1-contract-creation
npm install
truffle develop     # 进入内置测试链

2.2 创建 MyContract

truffle(develop)> compile
truffle(develop)> sender = web3.eth.accounts[0]
truffle(develop)> opts = { from: sender, to: null, data: MyContract.bytecode, gas: 4600000 }
truffle(develop)> txHash = web3.eth.sendTransaction(opts)
truffle(develop)> receipt = web3.eth.getTransactionReceipt(txHash)

当看到 receipt.contractAddress 出现,就代表 合约账户已经诞生。Log 事件会告诉你“这就是我家地址”。

2.3 为什么构造器里不能调用自身函数?

示例 Impossiblethis.test() 写在构造器,导致 部署即 revert。根本原因:构造器执行时,runtime 字节码还没写回账户,所以没有可以跳转的代码。这篇 EVM 规则值得牢记:先有账户地址,后有字节码。


3. 消息调用(Message Calls)全景图

当我们谈到 “合约调用合约” 时,底层其实都在做消息调用,它包含 5 个要素:

Solidity 提供三种途径发起消息调用:

  1. 高层次调用address.call{value:..., gas:...}(_data)
  2. 底层 opcodecall(g, a, v, in, insize, out, outsize)
  3. 特殊代理调用delegatecall

4. 深入 delegatecall:可升级智能合约的核心法器

4.1 与 call 的本质差别

| 维度 | call | delegatecall |
|---|---|---|
| 执行上下文 | 目标合约的 storage & msg.sender | 调用合约的 context 保持不变 |
| 存储写入 | 写入 target | 写入 caller |
| 用例 | 普通交互 | 代理合约、库合约、升级逻辑 |

4.2 快速验证 Greeter & Wallet

实验脚本:

truffle(develop)> someone = web3.eth.accounts[0]
truffle(develop)> ETH_2 = new web3.BigNumber('2e18')
truffle(develop)> Greeter.new().then(i => greeter = i)
truffle(develop)> opts = { from: someone, value: ETH_2 }
truffle(develop)> greeter.thanks(opts)

随后用 Wallet 合约通过 delegatecall 再调一次:

await wallet.sendTransaction(opts)  // msg.sender=someone, 结果完全一致

关键现象:事件中的 msg.sender 仍是外部账户,gas 消耗也表明 context 未切换。

👉 想体验 gas 精准计算?打开实战仓库并给 Implementation 打入 assert(false) 观察 1/64 规则。


5. Calculator 案例:一个存储,多端逻辑

Calculator 只做“路由”,真正加法、乘法逻辑分别委托 Addition 与 Product,而 state 变量 result 仅存在于 Calculator 的 storage 中。因此在 truffle console 中:

calculator.add(5);   // Calculator result=5
calculator.mul(2);    // Calculator result=10
addition.result();    // 仍是 0,它们并未写自己 storage

至此,读者应该豁然开朗:delegatecall 让「数据层」与「逻辑层」完全解耦,这正是现代可升级代理(UUPS/Transparent Proxy)能用到的核心基石。


6. 常见问题 FAQ

1. 问:初始化代码和运行时码存储在哪儿?
答:初始化代码仅执行一次,返回结果即为运行时码;运行时码随后永久写入合约账户的 codeHash 字段。

2. 问:构造器里生成事件会不会 emit?
答:会。就像 MyContract 的例子,emit 在构造器中执行,事后能在链上查到此日志。

3. 问:为什么 call 失败了不自动回滚?
答:低级 .call 默认返回 bool 表示成败,不会自动 revert;高层次外部函数调用则会触发 revert。

4. 问:delegatecall 会不会修改被调用方的 storage?
答:不会。执行环境完全在 caller 侧,target 的 storage 被忽略。

5. 问:怎样在 Etherscan 验证 1/64 规则?
答:在本地 truffle console 用 Oracle 级 gas 预估,对比 revert 前后剩余即可复现。

6. 问:能否用 delegatecall 替代 ERC-1967 proxy?
答:delegatcall 只是底层指令;升级逻辑需额外代理包装层。详见下半篇「数据管理」章节。


7. 写在最后

上半篇我们拆解了从「字节码跳进链」到「代理调用革新架构」的全过程。掌握这些 底层机制,你将在 合约升级、gas 优化与安全审计 三个战场游刃有余。

请收藏本页,下篇我们将聚焦 storage、memory、calldata 与运算栈 的精细管理,帮你写出让 EVM “心服口服”的高级智能合约!