使用 Geth 解剖 EVM 实现 3
- 原文链接:medium.com/@deliriusz...
- 译者:AI翻译官,校对:翻译小组
- 本文链接:learnblockchain.cn/article…
照片由 Shubham Dhage 提供,于 Unsplash
我原本只计划做两部分,但第二部分写得太长,因此不得不拆分成两部分。所以,享受最后一部分吧。
顺便说一下,如果你错过了之前的部分,这里有它们:
我们终于来到了 core/vm/interpreter.go,它将原始字节解释为可运行的代码。我想先解释一些结构体,以更好地理解它所提供的内容。
// ScopeContext 包含每次调用的相关内容,如堆栈和内存,
// 而不是像 pc 和 gas 这样的瞬态信息
type ScopeContext struct {
Memory *Memory
Stack *Stack
Contract *Contract
}
// EVMInterpreter 表示一个 EVM 解释器
type EVMInterpreter struct {
evm *EVM
table *JumpTable
hasher crypto.KeccakState // 在操作码之间共享的 Keccak256 哈希实例
hasherBuf common.Hash // 在操作码之间共享的 Keccak256 哈希结果数组
readOnly bool // 是否在状态修改时抛出错误
returnData []byte // 上一个 CALL 的返回数据,供后续重用
}
// NewEVMInterpreter 返回一个新的解释器实例。
func NewEVMInterpreter(evm *EVM) *EVMInterpreter {
// 如果跳转表未初始化,则设置默认的跳转表。
var table *JumpTable
switch {
case evm.chainRules.IsShanghai:
table = &shanghaiInstructionSet
...
table = &homesteadInstructionSet
default:
table = &frontierInstructionSet
}
var extraEips []int
if len(evm.Config.ExtraEips) > 0 {
// 深拷贝跳转表,以防止其他表中操作码的修改
table = copyJumpTable(table)
}
for _, eip := range evm.Config.ExtraEips {
if err := EnableEIP(eip, table); err != nil {
// 禁用它,以便调用者可以检查其是否已激活
log.Error("EIP 激活失败", "eip", eip, "错误", err)
} else {
extraEips = append(extraEips, eip)
}
}
evm.Config.ExtraEips = extraEips
return &EVMInterpreter{evm: evm, table: table}
}
**table[0xF1] -> CALL**
. 操作详情可以在 core/vm/jump_table.go 中找到。我们来看看示例跳转表元素是如何添加的:func newByzantiumInstructionSet() JumpTable {
instructionSet := newSpuriousDragonInstructionSet()
instructionSet[STATICCALL] = &operation{
execute: opStaticCall,
constantGas: params.CallGasEIP150,
dynamicGas: gasStaticCall,
minStack: minStack(6, 1),
maxStack: maxStack(6, 1),
memorySize: memoryStaticCall,
}
...
true
。解释器构造函数也很有趣。你可以看到它根据当前 fork 应用不同的指令集。在最后,任何修改操作码工作的 EIPs 都会被应用。
女士们、先生们,终于到了大家期待的时刻 - Run() 函数,这是 EVM 核心部分:
// Run 循环并评估合约的代码,给定输入数据并返回
// 返回的字节切片以及如果发生错误的错误。
//
// 重要的是要注意,解释器返回的任何错误都应被
// 视为回退并消费所有 gas 操作,除非
// ErrExecutionReverted 意味着回退并保留剩余 gas。
func (in *EVMInterpreter) Run(contract *Contract, input []byte, readOnly bool) (ret []byte, err error) {
// 增加调用深度,限制在 1024
in.evm.depth++
defer func() { in.evm.depth-- }()
// 确保只在不处于只读状态时设置 readOnly。
// 这也确保了只读标志不会在子调用中被移除。
if readOnly && !in.readOnly {
in.readOnly = true
defer func() { in.readOnly = false }()
}
// 重置上一个调用的返回数据。保留旧的缓冲区不重要
// 因为每次返回调用都会返回新的数据。
in.returnData = nil
// 如果没有代码,则不必担心执行。
if len(contract.Code) == 0 {
return nil, nil
}
var (
op OpCode // 当前操作码
mem = NewMemory() // 绑定的内存
stack = newstack() // 本地堆栈
callContext = &ScopeContext{
Memory: mem,
Stack: stack,
Contract: contract,
}
// 出于优化原因,我们使用 uint64 作为程序计数器。
// 理论上可以超过 2^64。YP 将 PC 定义为
// uint256。实际上不太可行。
pc = uint64(0) // 程序计数器
cost uint64
// trace 使用的副本
pcCopy uint64 // 需要用于延迟的 EVMLogger
gasCopy uint64 // 用于 EVMLogger 记录执行前的剩余 gas
logged bool // 延迟的 EVMLogger 应忽略已记录的步骤
res []byte // 操作码执行函数的结果
)
// 不要移动这个延迟函数,它被放置在捕获状态延迟方法之前,
// 以便在捕获状态之前被执行:需要在返回池之前
// 使用堆栈
defer func() {
returnStack(stack)
}()
contract.Input = input
我们首先增加调用深度,然后保证在结束时减少它。在管理 readOnly 标志并清空不必要的 returnData 后。如果没有代码来调用,我们只是提前返回。然后你可以清楚地看到如何创建新的调用上下文,处理计数器(pc),随着每个执行的操作而增加,还有一些附加的实用参数。最后,解释器延迟清除堆栈并指定输入,这只是一个字节数组的 calldata。让我们快速浏览一下 core/vm/stack.go 和 core/vm/memory.go 的内容。
// Stack 是一个用于基本栈操作的对象。弹出到栈中的项目
// 预计会被更改和修改。栈不负责添加新初始化的对象。
type Stack struct {
data []uint256.Int
}
func newstack() *Stack {
return stackPool.Get().(*Stack)
}
func returnStack(s *Stack) {
s.data = s.data[:0]
stackPool.Put(s)
}
...
func (st *Stack) push(d *uint256.Int) {
// 注意 push 限制 (1024) 在 baseCheck 中检查
st.data = append(st.data, *d)
}
func (st *Stack) pop() (ret uint256.Int) {
ret = st.data[len(st.data)-1]
st.data = st.data[:len(st.data)-1]
return
}
func (st *Stack) swap(n int) {
st.data[st.len()-n], st.data[st.len()-1] = st.data[st.len()-1], st.data[st.len()-n]
}
func (st *Stack) dup(n int) {
st.push(&st.data[st.len()-n])
}
这只是一个简单的栈实现。但有趣的是,你可以在这里 swap
和 dup
任何深度的元素。这只是 EVM 的不必要限制,支持 1 到 16 深的交换和复制,如果你写的东西比简单的托管合约更复杂,就会导致“栈太深”的错误……实际上你有一个栈池,使用后会被清零。
// Memory 实现了以太坊虚拟机的简单内存模型。
type Memory struct {
store []byte
lastGasCost uint64
}
// NewMemory 返回一个新的内存模型。
func NewMemory() *Memory {
return &Memory{}
}
// Set 将偏移量 + 大小设置为值
func (m *Memory) Set(offset, size uint64, value []byte) {
// 偏移量可能大于 0 而大小等于 0。这是因为
// calcMemSize (common.go) 在大小为零时可能返回 0 (无操作)
if size > 0 {
// 存储的长度永远不能小于偏移量 + 大小。
// 在设置内存之前,存储应该被调整大小
if offset+size > uint64(len(m.store)) {
panic("无效内存:存储为空")
}
copy(m.store[offset:offset+size], value)
}
}
// Set32 将从偏移量开始的 32 字节设置为 val 的值,左侧用零填充到
// 32 字节。
func (m *Memory) Set32(offset uint64, val *uint256.Int) {
// 存储的长度永远不能小于偏移量 + 大小。
// 在设置内存之前,存储应该被调整大小
if offset+32 > uint64(len(m.store)) {
panic("无效内存:存储为空")
}
// 填充相关位
b32 := val.Bytes32()
copy(m.store[offset:], b32[:])
}
// Resize 将内存调整为大小
func (m *Memory) Resize(size uint64) {
if uint64(m.Len()) < size {
m.store = append(m.store, make([]byte, size-uint64(m.Len()))...)
}
}
同样,这里没有什么有趣的。它与栈的不同之处在于,它不打算收缩和增长,而只是扩展。除了它所持有的字节外,它还包含有关 lastGasCost 的信息,在达到特定大小后,gas 成本呈平方增长。你可以在 这里 阅读更多信息。
接下来,我将介绍实际的执行循环。这次,我将在展示代码之前描述它。因此,循环会迭代,直到抛出错误。它将 errStopToken
错误视为实际成功,否则意味着状态必须被回滚。接下来,我们在特定的处理计数器处获取操作码,在跳转表中搜索它,验证在调用此操作码后栈不会下溢或上溢,检查 gasleft
是否足够进行静态和动态计算,并且内存不会溢出,目前这是不可能的,因为由于平方内存扩展成本,你会比达到 2²⁵⁶ 内存更早耗尽 gas,啊,我们还没有构建如此大的 RAM。如果内存需要扩展,它会被调整大小,最后操作会使用 calldata 和 callContext 执行。
// 解释器主运行循环(上下文)。这个循环会一直运行,直到执行
// 显式的 STOP、RETURN 或 SELFDESTRUCT,或者在执行某个操作时发生错误,
// 或者直到父上下文设置了完成标志。
for {
...
// 从跳转表中获取操作并验证栈,以确保有
// 足够的栈项可用于执行操作。
op = contract.GetOp(pc)
operation := in.table[op]
cost = operation.constantGas // 用于追踪
// 验证栈
if sLen := stack.len(); sLen < operation.minStack {
return nil, &ErrStackUnderflow{stackLen: sLen, required: operation.minStack}
} else if sLen > operation.maxStack {
return nil, &ErrStackOverflow{stackLen: sLen, limit: operation.maxStack}
}
if !contract.UseGas(cost) {
return nil, ErrOutOfGas
}
if operation.dynamicGas != nil {
// 所有具有动态内存使用的操作也具有动态 gas 成本。
var memorySize uint64
// 计算新的内存大小并扩展内存以适应
// 操作
// 在评估动态 gas 部分之前需要进行内存检查,
// 以检测计算溢出
if operation.memorySize != nil {
memSize, overflow := operation.memorySize(stack)
if overflow {
return nil, ErrGasUintOverflow
}
// 内存以 32 字节为单位扩展。Gas
// 也以字为单位计算。
if memorySize, overflow = math.SafeMul(toWordSize(memSize), 32); overflow {
return nil, ErrGasUintOverflow
}
}
// 消耗 gas,如果没有足够的 gas 可用则返回错误。
// 成本被显式设置,以便捕获状态延迟方法可以获取正确的成本
var dynamicCost uint64
dynamicCost, err = operation.dynamicGas(in.evm, contract, stack, mem, memorySize)
cost += dynamicCost // 用于追踪
if err != nil || !contract.UseGas(dynamicCost) {
return nil, ErrOutOfGas
}
...
if memorySize > 0 {
mem.Resize(memorySize)
}
}
...
// 执行操作
res, err = operation.execute(&pc, in, callContext)
if err != nil {
break
}
pc++
}
if err == errStopToken {
err = nil // 清除停止令牌错误
}
return res, err
}
现在,来看看实际的操作实现。所有操作都在 core/vm/instructions.go 中实现。让我们先看看最简单的一个 — ADD:
func opAdd(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) {
x, y := scope.Stack.pop(), scope.Stack.peek()
y.Add(&x, y)
return nil, nil
}
如你所见,解释器首先弹出第一个元素,然后只是窥视第二个元素(不删除它)。然后它用 x
和 y
的和覆盖当前最上面的栈元素。它不返回任何内容。大多数操作码都是这样的,这里没有什么魔法。当你看到一个是如何完成的,你基本上就知道它们都是如何工作的。我想在这里提到一些额外的操作码 — CREATE、CALL 和 DELEGATECALL,以便全面了解操作码在调用上下文中的工作方式:
func opCreate(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) {
if interpreter.readOnly {
return nil, ErrWriteProtection
}
var (
value = scope.Stack.pop()
offset, size = scope.Stack.pop(), scope.Stack.pop()
input = scope.Memory.GetCopy(int64(offset.Uint64()), int64(size.Uint64()))
gas = scope.Contract.Gas
)
if interpreter.evm.chainRules.IsEIP150 {
gas -= gas / 64
}
// 重用大小整数作为栈值
stackvalue := size
scope.Contract.UseGas(gas)
//TODO: 使用 uint256.Int 而不是通过 toBig() 转换
var bigVal = big0
if !value.IsZero() {
bigVal = value.ToBig()
}
res, addr, returnGas, suberr := interpreter.evm.Create(scope.Contract, input, gas, bigVal)
// 根据返回的错误将项目推送到栈上。如果规则集是
// homestead,我们必须检查 CodeStoreOutOfGasError (仅限 homestead
// 规则) 并将其视为错误,如果规则集是 frontier,我们必须
// 忽略此错误并假装操作成功。
if interpreter.evm.chainRules.IsHomestead && suberr == ErrCodeStoreOutOfGas {
stackvalue.Clear()
} else if suberr != nil && suberr != ErrCodeStoreOutOfGas {
stackvalue.Clear()
} else {
stackvalue.SetBytes(addr.Bytes())
}
scope.Stack.push(&stackvalue)
scope.Contract.Gas += returnGas
if suberr == ErrExecutionReverted {
interpreter.returnData = res // 将 REVERT 数据设置为返回数据缓冲区
return res, nil
}
interpreter.returnData = nil // 清除脏的返回数据缓冲区
return nil, nil
}
简而言之,正如你所看到的,首先我们从堆栈中获取所需的元素,扣除 gas 然后… 递归调用 evm.Create
,这是我们一开始描述的路径。这将创建一个新的调用上下文(堆栈和内存),并重新开始代码解释。很有趣!就像重新开始一笔新的交易。现在让我们看看 CALL 操作是如何实现的:
func opCall(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) {
stack := scope.Stack
// Pop gas. The actual gas in interpreter.evm.callGasTemp.
// We can use this as a temporary value
temp := stack.pop()
gas := interpreter.evm.callGasTemp
// Pop other call parameters.
addr, value, inOffset, inSize, retOffset, retSize := stack.pop(), stack.pop(), stack.pop(), stack.pop(), stack.pop(), stack.pop()
toAddr := common.Address(addr.Bytes20())
// Get the arguments from the memory.
args := scope.Memory.GetPtr(int64(inOffset.Uint64()), int64(inSize.Uint64()))
if interpreter.readOnly && !value.IsZero() {
return nil, ErrWriteProtection
}
var bigVal = big0
//TODO: use uint256.Int instead of converting with toBig()
// By using big0 here, we save an alloc for the most common case (non-ether-transferring contract calls),
// but it would make more sense to extend the usage of uint256.Int
if !value.IsZero() {
gas += params.CallStipend
bigVal = value.ToBig()
}
ret, returnGas, err := interpreter.evm.Call(scope.Contract, toAddr, args, gas, bigVal)
if err != nil {
temp.Clear()
} else {
temp.SetOne()
}
stack.push(&temp)
if err == nil || err == ErrExecutionReverted {
scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret)
}
scope.Contract.Gas += returnGas
interpreter.returnData = ret
return ret, nil
}
实际上,这里没有太多变化……那么 DELEGATECALL 呢?
func opDelegateCall(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) {
stack := scope.Stack
// Pop gas. The actual gas is in interpreter.evm.callGasTemp.
// We use it as a temporary value
temp := stack.pop()
gas := interpreter.evm.callGasTemp
// Pop other call parameters.
addr, inOffset, inSize, retOffset, retSize := stack.pop(), stack.pop(), stack.pop(), stack.pop(), stack.pop()
toAddr := common.Address(addr.Bytes20())
// Get arguments from the memory.
args := scope.Memory.GetPtr(int64(inOffset.Uint64()), int64(inSize.Uint64()))
ret, returnGas, err := interpreter.evm.DelegateCall(scope.Contract, toAddr, args, gas)
if err != nil {
temp.Clear()
} else {
temp.SetOne()
}
stack.push(&temp)
if err == nil || err == ErrExecutionReverted {
scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret)
}
scope.Contract.Gas += returnGas
interpreter.returnData = ret
return ret, nil
}
嗯……几乎一样。这两者之间有什么不同?在 core/vm/evm 中,具体是这些链接:DELEGATECALL vs CALL。亲爱的读者,我将把深入讨论这个话题的任务留给你 :-)
快要结束了。我不会描述 STATICCALL,因为它与其他的并没有不同。我想花一点时间总结存储、内存、堆栈和调用堆栈之间的区别:
哇,真是一段旅程。希望在阅读完这篇材料后,你能够深入理解 EVM,并最终明白为什么某些东西存在,而不仅仅是它存在的事实。这里的一切都有充分的理由存在,若不理解其基础技术,你会形成闭环,无休止地自我纠缠。
今天就到这里。如果你想阅读更多我的内容,请关注我在 Twitter 上。如果你需要高质量的安全审查(即审计)你的智能合约,智能合约安全顾问或智能合约开发者,请随时联系我!
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!