使用 Geth 解剖 EVM 实现 2 - EVM
- 原文链接:medium.com/@deliriusz...
- 译者:AI翻译官,校对:翻译小组
- 本文链接:learnblockchain.cn/article…
照片由 Shubham Dhage 在 Unsplash 提供
在 第一部分 中,我们讨论了交易执行流程,现在让我们进入以太坊真正的主角 — EVM。几乎所有会吸引我们注意的内容都位于 core/vm 文件夹中。让我们从实际的 evm.go 开始,看看 一些重要的 EVM 结构是如何定义的:
type BlockContext struct {
// CanTransfer 返回账户是否有足够的以太来转移该值
CanTransfer CanTransferFunc
// Transfer 从一个账户转移以太到另一个账户
Transfer TransferFunc
// GetHash 返回与 n 相对应的哈希
GetHash GetHashFunc
// 区块信息
Coinbase common.Address // 提供 COINBASE 的信息
GasLimit uint64 // 提供 GASLIMIT 的信息
BlockNumber *big.Int // 提供 NUMBER 的信息
Time uint64 // 提供 TIME 的信息
Difficulty *big.Int // 提供 DIFFICULTY 的信息
BaseFee *big.Int // 提供 BASEFEE 的信息
Random *common.Hash // 提供 PREVRANDAO 的信息
}
// TxContext 提供 EVM 有关交易的信息。
// 所有字段在交易之间可以变化。
type TxContext struct {
// 消息信息
Origin common.Address // 提供 ORIGIN 的信息
GasPrice *big.Int // 提供 GASPRICE 的信息
}
type EVM struct {
// Context 提供辅助区块链相关信息
Context BlockContext
TxContext
// StateDB 访问底层状态
StateDB StateDB
// Depth 是当前调用堆栈
depth int
// chainConfig 包含当前链的信息
chainConfig *params.ChainConfig
// chain rules 包含当前时期的链规则
chainRules params.Rules
// 用于初始化 EVM 的虚拟机配置选项
Config Config
// 全局(在此上下文中)以太坊虚拟机
// 在交易执行过程中使用。
interpreter *EVMInterpreter
// abort 用于中止 EVM 调用操作
// 注意:必须原子设置
abort int32
// callGasTemp 保存当前调用可用的 gas。这是必要的因为
// 可用 gas 在 gasCall* 中根据 63/64 规则计算,随后
// 应用在 opCall* 中。
callGasTemp uint64
}
你可以从中推断出以太坊中的“上下文”是什么。无论是交易上下文、区块上下文、调用上下文,这些都只是元数据,用于定义当前在不同抽象层次(调用/跟踪等)上执行的一些有用信息。
如果你还记得 第一部分,合约创建的代码路径与调用执行的路径略有不同。但实际上这两条路径在一个共同点上交汇 — 执行字节码—这是因为智能合约构造函数实际上是一段可执行的代码,它最终返回两个元素:要部署的代码的偏移量和长度,这些值用于将特定字节放置在新创建的智能合约地址下。因此,这意味着你可以以程序化的方式创建智能合约,将其保存到内存中,然后返回其内存位置。我没有在实际应用中见过,但这实际上可能是一个相当有趣的用法 — 根据链上状态动态创建智能合约代码。
在介绍完代码创建后,让我们看看 代码 是如何实现的:
func (evm *EVM) Create(caller ContractRef, code []byte, gas uint64, value *big.Int) (ret []byte, contractAddr common.Address, leftOverGas uint64, err error) {
contractAddr = crypto.CreateAddress(caller.Address(), evm.StateDB.GetNonce(caller.Address()))
return evm.create(caller, &codeAndHash{code: code}, gas, value, contractAddr, CREATE)
}
func (evm *EVM) Create2(caller ContractRef, code []byte, gas uint64, endowment *big.Int, salt *uint256.Int) (ret []byte, contractAddr common.Address, leftOverGas uint64, err error) {
codeAndHash := &codeAndHash{code: code}
contractAddr = crypto.CreateAddress2(caller.Address(), salt.Bytes32(), codeAndHash.Hash().Bytes())
return evm.create(caller, codeAndHash, gas, endowment, contractAddr, CREATE2)
}
我们只是从调用者地址和 nonce 创建合约地址,然后调用 create 函数。我还添加了 Create2 函数,以查看地址创建的不同。在第二个函数中,地址是从调用者地址、salt 和 codeHash 创建的。现在,让我们深入细节:
func (evm *EVM) create(caller ContractRef, codeAndHash *codeAndHash, gas uint64, value *big.Int, address common.Address, typ OpCode) ([]byte, common.Address, uint64, error) {
// 深度检查执行。如果我们试图在限制以上进行执行则失败。
if evm.depth > int(params.CallCreateDepth) {
return nil, common.Address{}, gas, ErrDepth
}
if !evm.Context.CanTransfer(evm.StateDB, caller.Address(), value) {
return nil, common.Address{}, gas, ErrInsufficientBalance
}
nonce := evm.StateDB.GetNonce(caller.Address())
if nonce+1 < nonce {
return nil, common.Address{}, gas, ErrNonceUintOverflow
}
evm.StateDB.SetNonce(caller.Address(), nonce+1)
// 我们在拍摄快照之前将其添加到访问列表中。即使创建失败,
// 访问列表的更改也不应回滚
if evm.chainRules.IsBerlin {
evm.StateDB.AddAddressToAccessList(address)
}
// 确保在指定地址没有现有合约
contractHash := evm.StateDB.GetCodeHash(address)
if evm.StateDB.GetNonce(address) != 0 || (contractHash != (common.Hash{}) && contractHash != emptyCodeHash) {
return nil, common.Address{}, 0, ErrContractAddressCollision
}
首先,检查调用深度。如果超过每个调用会增加的限制,则将失败。这里允许的最大值为 1024,超出这个限制将使执行回退。然后我们检查账户是否有足够的资金发送给合约构造函数。如果发送的值为 0,则返回 true。获取 nonce 后从 StateDB 进行溢出检查。我无法想象 nonce 会溢出的情况,因为 2²⁵⁶ * 21000 = 2.43163387398364e+81 的最小 gas 要求会导致溢出这个值,在极便宜的链上,如 Celo,费用可能超过世界上任何人能承受的成本,但安全起见,还是要谨慎。
然后,如果是 Berlin 硬分叉,就调用 AddAddressToAccessList()。让我们在这里停下,因为这个函数与 AddSlotToAccessList() 一起,对于理解非常重要。EVM 有冷存储和热存储的概念。这意味着如果你在交易中第一次访问一个存储插槽或与特定地址交互(称为“触摸”它),你需要支付比每次后续交互更多的费用。这是为了防止恶性行为者通过包含多个随机存储的读/写事务来对网络进行拒绝服务攻击,因为每次第一次读取都需要在文件系统上执行 IO 操作以从节点存储中检索值。
最后,EVM 确保该地址没有部署代码,并且该地址没有发生任何交易——nonce 为 0。
// Create a new account on the state
snapshot := evm.StateDB.Snapshot()
evm.StateDB.CreateAccount(address)
if evm.chainRules.IsEIP158 {
evm.StateDB.SetNonce(address, 1)
}
evm.Context.Transfer(evm.StateDB, caller.Address(), address, value)
// Initialise a new contract and set the code that is to be used by the EVM.
// The contract is a scoped environment for this execution context only.
contract := NewContract(caller, AccountRef(address), value, gas)
contract.SetCodeOptionalHash(&address, codeAndHash)
...
ret, err := evm.interpreter.Run(contract, nil, false)
// Check whether the max code size has been exceeded, assign err if the case.
if err == nil && evm.chainRules.IsEIP158 && len(ret) > params.MaxCodeSize {
err = ErrMaxCodeSizeExceeded
}
// Reject code starting with 0xEF if EIP-3541 is enabled.
if err == nil && len(ret) >= 1 && ret[0] == 0xEF && evm.chainRules.IsLondon {
err = ErrInvalidCode
}
// if the contract creation ran successfully and no errors were returned
// calculate the gas required to store the code. If the code could not
// be stored due to not enough gas set an error and let it be handled
// by the error checking condition below.
if err == nil {
createDataGas := uint64(len(ret)) * params.CreateDataGas
if contract.UseGas(createDataGas) {
evm.StateDB.SetCode(address, ret)
} else {
err = ErrCodeStoreOutOfGas
}
}
// When an error was returned by the EVM or when setting the creation code
// above we revert to the snapshot and consume any gas remaining. Additionally
// when we're in homestead this also counts for code storage gas errors.
if err != nil && (evm.chainRules.IsHomestead || err != ErrCodeStoreOutOfGas) {
evm.StateDB.RevertToSnapshot(snapshot)
if err != ErrExecutionReverted {
contract.UseGas(contract.Gas)
}
}
...
return ret, address, contract.Gas, err
这一部分实际上非常有趣。首先,状态被快照。这使得 EVM 事务具有原子性——每当你调用一个调用、创建智能合约或调用外部合约时,运行前要做的第一件事是保存 StateDB 的状态。然后,如果出现任何错误,状态会被还原到之前保存的状态。多亏了这一点,不可能有一个回滚的调用会修改数据库。
接下来,EVM 将地址保存到数据库并将 nonce 设置为 1——智能合约的 nonce 始终从 1 开始,仅在智能合约通过 CREATE 或 CREATE2 操作码创建新智能合约时才会增加。
在 msg.value 被转移到新创建的智能合约后,EVM 初始化新的合约,并将其传递给字节码解释器以运行合约代码。这部分非常重要,但我会故意推迟描述,直到稍后讨论正常调用。如我之前提到的,这里运行的是构造函数。即使你自己不定义一个,Solidity 编译器也会为你做这件事。毕竟,你必须在链上运行它,并在最后使用 RETURN 操作码返回代码位置。请记住,此时并没有部署代码。构造函数只是返回即将保存为智能合约代码的内容。因此,你不应依赖地址代码大小,因为构造函数是唯一可以运行任意操作码而不必部署的地方。完成后,返回两个变量:ret 和 err。第一个是包含要部署的智能合约的字节数组,后者仅表示是否发生了错误。之后我们检查代码大小是否小于当前限制,并且不以 0xEF 开头。这个要求在这里为将来的 EOF 添加。你可以在 此处 找到更多细节。
最后,EVM 根据每个智能合约字节消耗相应的 gas,此时才将代码保存到 StateDB,或者如果发生错误则还原状态并将错误传播到更高层。
现在让我们关注第一部分提到的第二条路径——正常调用执行。有些部分在这里类似:
// Call executes the contract associated with the addr with the given input as
// parameters. It also handles any necessary value transfer required and takes
// the necessary steps to create accounts and reverses the state in case of an
// execution error or failed value transfer.
func (evm *EVM) Call(caller ContractRef, addr common.Address, input []byte, gas uint64, value *big.Int) (ret []byte, leftOverGas uint64, err error) {
// Fail if we're trying to execute above the call depth limit
if evm.depth > int(params.CallCreateDepth) {
return nil, gas, ErrDepth
}
// Fail if we're trying to transfer more than the available balance
if value.Sign() != 0 && !evm.Context.CanTransfer(evm.StateDB, caller.Address(), value) {
return nil, gas, ErrInsufficientBalance
}
snapshot := evm.StateDB.Snapshot()
p, isPrecompile := evm.precompile(addr)
if !evm.StateDB.Exist(addr) {
if !isPrecompile && evm.chainRules.IsEIP158 && value.Sign() == 0 {
// Calling a non existing account, don't do anything, but ping the tracer
if evm.Config.Debug {
if evm.depth == 0 {
evm.Config.Tracer.CaptureStart(evm, caller.Address(), addr, false, input, gas, value)
evm.Config.Tracer.CaptureEnd(ret, 0, nil)
} else {
evm.Config.Tracer.CaptureEnter(CALL, caller.Address(), addr, input, gas, value)
evm.Config.Tracer.CaptureExit(ret, 0, nil)
}
}
return nil, gas, nil
}
evm.StateDB.CreateAccount(addr)
}
evm.Context.Transfer(evm.StateDB, caller.Address(), addr, value)
首先,我们检查是否超过了 1024 的调用栈深度,并且有足够的值转移到交易接收方。然后 EVM 快照当前状态并检查接收者是否是预编译合约,如果是,则返回指针。如果不是,并且地址还不存在,它将创建它,但仅在发送给该地址的值大于 0 的情况下。否则,它将提前返回。让我们进一步深入:
if isPrecompile {
ret, gas, err = RunPrecompiledContract(p, input, gas)
} else {
// Initialise a new contract and set the code that is to be used by the EVM.
// The contract is a scoped environment for this execution context only.
code := evm.StateDB.GetCode(addr)
if len(code) == 0 {
ret, err = nil, nil // gas is unchanged
} else {
addrCopy := addr
// If the account has no code, we can abort here
// The depth-check is already done, and precompiles handled above
contract := NewContract(caller, AccountRef(addrCopy), value, gas)
contract.SetCallCode(&addrCopy, evm.StateDB.GetCodeHash(addrCopy), code)
ret, err = evm.interpreter.Run(contract, input, false)
gas = contract.Gas
}
}
// When an error was returned by the EVM or when setting the creation code
// above we revert to the snapshot and consume any gas remaining. Additionally
// when we're in homestead this also counts for code storage gas errors.
if err != nil {
evm.StateDB.RevertToSnapshot(snapshot)
if err != ErrExecutionReverted {
gas = 0
}
// TODO: consider clearing up unused snapshots:
//} else {
// evm.StateDB.DiscardSnapshot(snapshot)
}
return ret, gas, err
}
从这里开始,现在有多条路径可供选择。 如果它是预编译的,则使用来自 core/vm/contracts.go 的特殊函数。 这是因为预编译合约是特殊的。 它们不是在链上部署,而是直接存在于执行客户端代码中。 因此,即使它们的代码大小为 0,它们仍然执行特定的函数,因为这些函数是本地执行的。 每个预编译函数都有自己的地址。 你可以在这个 LINK 找到更多关于预编译的信息。 让我们看一下 ecrecover(地址 0x01)的实现代码,你可以在 HERE 找到:
// ECRECOVER implemented as a native contract.
type ecrecover struct{}
func (c *ecrecover) RequiredGas(input []byte) uint64 {
return params.EcrecoverGas
}
func (c *ecrecover) Run(input []byte) ([]byte, error) {
const ecRecoverInputLength = 128
input = common.RightPadBytes(input, ecRecoverInputLength)
// "input" is (hash, v, r, s), each 32 bytes
// but for ecrecover we want (r, s, v)
r := new(big.Int).SetBytes(input[64:96])
s := new(big.Int).SetBytes(input[96:128])
v := input[63] - 27
// tighter sig s values input homestead only apply to tx sigs
if !allZero(input[32:63]) || !crypto.ValidateSignatureValues(v, r, s, false) {
return nil, nil
}
// We must make sure not to modify the 'input', so placing the 'v' along with
// the signature needs to be done on a new allocation
sig := make([]byte, 65)
copy(sig, input[64:128])
sig[64] = v
// v needs to be at the end for libsecp256k1
pubKey, err := crypto.Ecrecover(input[:32], sig)
// make sure the public key is a valid one
if err != nil {
return nil, nil
}
// the first byte of pubkey is bitcoin heritage
return common.LeftPadBytes(crypto.Keccak256(pubKey[1:])[12:], 32), nil
}
如果它不是预编译的,EVM 将获取代码字节数组。 如果它的长度为 0,调用在这里返回 nil 返回值。 这就是为什么 Solidity 中的低级调用返回成功,你必须自己验证地址是否是智能合约。 接下来,我们填充新的 Contract 结构并将其传递给解释器以运行智能合约。 最后,EVM 检查是否发生了任何错误,如果发生了错误,则回滚状态,并返回运行结果以及剩余的 gas 和错误(如果有)。
就这些了。 在 第三篇 中,我们将深入了解字节码解释器的工作原理。 如果你想阅读我的更多内容,请在 Twitter 上关注我。 如果你需要高质量的安全审查(即审计)你的智能合约,智能合约安全顾问或智能合约开发人员,请随时联系我!
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!