本文旨在帮助开发者深入理解以太坊虚拟机的核心工作原理。内容涵盖合约创建、消息调用机制以及数据存储等关键概念。
以太坊合约基础
智能合约的本质
智能合约本质上是运行在以太坊虚拟机中的计算机程序。EVM 为智能合约提供了一个沙盒化的运行时环境,完全隔离了网络、文件系统及其他主机进程的访问权限。
以太坊账户分为两种类型:合约账户和外部账户。所有账户都由 160 位长度的地址标识,并共享同一个地址空间。每个账户都包含余额、随机数、字节码和存储数据,但两者存在关键差异:
- 外部账户的代码和存储空间始终为空
- 合约账户存储其字节码和完整状态树的默克尔根哈希
- 外部账户拥有对应的私钥,而合约账户没有
- 合约账户的行为由其所承载的代码控制,且每个交易都需要密码学签名
合约创建机制
合约创建本质上是一个接收方地址为空、数据字段包含待创建合约编译字节码的特殊交易。让我们通过一个简单示例来理解这个过程:
pragma solidity ^0.4.21;
contract MyContract {
event Log(address addr);
function MyContract() public {
emit Log(this);
}
function add(uint256 a, uint256 b) public pure returns(uint256) {
return a + b;
}
}部署合约时,首先会创建合约账户,然后执行交易中的数据作为字节码。这个过程会初始化存储中的状态变量,并确定合约的主体内容。需要注意的是:
- 初始化代码执行后返回的字节码才是最终存储在合约中的代码
- 合约账户创建后,其代码将无法更改
- 构造函数中无法调用尚未存储的合约代码
消息调用机制详解
基本调用原理
合约通过消息调用与其他合约交互。每次 Solidity 合约调用其他合约函数时,都会产生一个消息调用。每个调用都包含发送方、接收方、载荷数据、转移价值和燃料量。
Solidity 为地址类型提供了原生调用方法:
address.call.gas(gas).value(value)(data)其中 gas 是要转发的燃料量,value 是以 wei 为单位的以太币数量,data 是要发送的载荷数据。需要注意的是,默认情况下几乎所有的剩余燃料都会被发送,这可能带来安全风险。
燃料管理策略
为防止内部调用耗尽所有燃料,EVM 设计了保护机制:每次调用至少会保留发送方剩余燃料的 1/64。这样即使内部调用发生燃料不足异常,外部调用仍然能够正常完成执行。
通过以下示例可以验证这一机制:
contract Implementation {
event ImplementationLog(uint256 gas);
function () public payable {
emit ImplementationLog(gasleft());
assert(false);
}
}
contract Caller {
event CallerLog(uint256 gas);
Implementation public implementation;
function Caller() public {
implementation = new Implementation();
}
function () public payable {
emit CallerLog(gasleft());
implementation.call.gas(gasleft()).value(msg.value)(msg.data);
emit CallerLog(gasleft());
}
}测试结果显示,第二次记录的燃料量确实约为第一次的 1/64,证实了燃料保留机制的有效性。
委托调用特性
EVM 支持一种特殊的消息调用变体:delegatecall。与普通调用不同,委托调用具有以下特点:
- 目标代码在调用合约的上下文中执行
msg.sender和msg.value保持不变- 可以动态加载不同地址的代码
- 能够读写调用合约的存储空间
考虑以下示例:
contract Greeter {
event Thanks(address sender, uint256 value);
function thanks() public payable {
emit Thanks(msg.sender, msg.value);
}
}
contract Wallet {
Greeter internal greeter;
function Wallet() public {
greeter = new Greeter();
}
function () public payable {
bytes4 methodId = Greeter(0).thanks.selector;
require(greeter.delegatecall(methodId));
}
}通过测试验证,即使通过 Wallet 合约调用,记录的发送方和价值信息仍然保持原始值,证明了 delegatecall 的特性。
存储共享模式
委托调用的一个重要应用是实现存储共享。以下示例展示了多个合约共享同一存储结构的模式:
contract ResultStorage {
uint256 public result;
}
contract Calculator is ResultStorage {
Product internal product;
Addition internal addition;
function Calculator() public {
product = new Product();
addition = new Addition();
}
function add(uint256 x) public {
bytes4 methodId = Addition(0).calculate.selector;
require(addition.delegatecall(methodId, x));
}
function mul(uint256 x) public {
bytes4 methodId = Product(0).calculate.selector;
require(product.delegatecall(methodId, x));
}
}
contract Addition is ResultStorage {
function calculate(uint256 x) public returns(uint256) {
uint256 temp = result + x;
assert(temp >= result);
result = temp;
return result;
}
}
contract Product is ResultStorage {
function calculate(uint256 x) public returns(uint256) {
if (x == 0) result = 0;
else {
uint256 temp = result * x;
assert(temp / result == x);
result = temp;
}
return result;
}
}测试结果表明,所有计算操作都使用 Calculator 合约的存储空间,而执行代码来自不同的合约地址。
常见问题
合约创建后可以修改代码吗?
不可以。合约账户创建后,其代码将永久存储在区块链上且无法更改。这是以太坊设计的一个重要特性,确保了合约的不可篡改性。不过可以通过代理模式等高级技术实现可升级合约。
委托调用和普通调用有什么区别?
主要区别在于执行上下文:委托调用在调用者的上下文中执行目标代码,保持原始的 msg.sender 和 msg.value,并能访问调用者的存储空间。而普通调用则在使用被调用合约的上下文。
如何防止内部调用耗尽所有燃料?
EVM 自动保留至少 1/64 的剩余燃料,确保外部调用能够正常处理内部调用的异常。开发者也可以通过显式指定 gas 参数来精细控制燃料分配。
什么情况下适合使用委托调用?
委托调用适合需要代码复用和可升级合约的场景。特别是当多个合约需要共享相同逻辑但维护独立状态时,委托调用提供了一种高效的解决方案。
合约构造函数中能调用自身函数吗?
不能。在构造函数执行期间,合约代码尚未存储到区块链上,因此无法调用合约自身的函数。尝试这样做会导致交易回滚。
如何安全地进行低级调用?
进行低级调用时应该:始终检查返回值、显式指定 gas 限制、验证目标地址的合约存在性,并考虑使用撤回模式来防止重入攻击。
通过本文的深入分析,我们希望帮助开发者更好地理解以太坊虚拟机的核心工作机制。这些基础知识对于构建安全、高效的智能合约应用至关重要。在后续内容中,我们将继续探讨数据管理的详细机制。