适用人群:已了解 以太坊、智能合约 与 EVM 基础概念,希望进一步深入机制细节的开发者。
关键词:EVM、智能合约、合约创建、消息调用、gas、delegatecall、bytecode、storage
在区块链开发中,写出逻辑正确的 智能合约 只是第一步;只有真正读懂 EVM 的行为,我们才能确保 安全性 与 可升级性。本文(上半篇)将围绕三个核心主题展开示例与剖析:
- 合约创建——从字节码到链上账户
- 消息调用——普通 call 与 delegatecall 的异同
- gas 节省机制——如何避免 out-of-gas 风险
👉 跟着实战仓库动手跑一次,你将获得最直观的 EVM 行为理解。
1. 智能合约到底是什么
1.1 Account 模型再回顾
在以太坊里,一切皆是 账户(Account),共两类:
| 类型 | 是否存代码 | 是否有私钥 | 是否有 stateRoot |
|---|---|---|---|
| 外部账户(EOA) | × | √ | × |
| 合约账户 | √ | × | √ |
所有地址共用 160-bit 的地址空间,由 keccak256
驱动。每次部署新合约即向全网广播一笔 to 地址为空的交易,data
字段携带 字节码。
1.2 字节码 ≠ 存储码
部署阶段真正执行的叫 initialization code(初始化字节码)。它负责:
- 为状态变量向 storage 写初值
- 返回真正存到链上的 runtime bytecode——之后任何人调用的都是这段常驻代码
因此,初始化逻辑只跑一次,完成后任何人都无法再替换 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 为什么构造器里不能调用自身函数?
示例 Impossible
把 this.test()
写在构造器,导致 部署即 revert。根本原因:构造器执行时,runtime 字节码还没写回账户,所以没有可以跳转的代码。这篇 EVM 规则值得牢记:先有账户地址,后有字节码。
3. 消息调用(Message Calls)全景图
当我们谈到 “合约调用合约” 时,底层其实都在做消息调用,它包含 5 个要素:
- sender:调用的源地址
- recipient:目标合约地址
- payload:函数选择器 + 参数
- value:随调用发送的以太币 (单位 wei)
- gas:愿意消耗的上限
Solidity 提供三种途径发起消息调用:
- 高层次调用:
address.call{value:..., gas:...}(_data)
- 底层 opcode:
call(g, a, v, in, insize, out, outsize)
- 特殊代理调用:
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 “心服口服”的高级智能合约!