使用 Geth 解剖 EVM 实现 #3 — 字节码解释器

使用 Geth 解剖 EVM 实现 3

照片由 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}  
}
  • ScopeContext 本质上只是一个为合约分配的内存和堆栈。这一点很重要,因为这被称为“调用上下文”——通过查看它,你可以推测出它仅用于字节码解释期间的当前合约。每次你进行 call、delegatecall、staticcall 或 callcode(现在已弃用,但 EVM 仍然支持)时,你会获得新的 ScopeContext,以及新的内存和堆栈。
  • EVMInterpreter 包含对 EVM 的引用,跳转表,它只是 uint8 操作码与底层操作数据之间的映射,例如 **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,  
 }  
...
  • hasherhasherBuf 在这里并没有什么特别值得关注的。但是据我所能验证的,它们的唯一用法是在 keccak256 相关操作中。
  • readOnly 定义了是否允许对 StateDB 进行任何更改。仅在通过 STATICCALL 时设置为 true
  • returnData 正是通过最后一个 RETURN 操作码返回的数据。

解释器构造函数也很有趣。你可以看到它根据当前 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.gocore/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])  
}

这只是一个简单的栈实现。但有趣的是,你可以在这里 swapdup 任何深度的元素。这只是 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。如果内存需要扩展,它会被调整大小,最后操作会使用 calldatacallContext 执行。

// 解释器主运行循环(上下文)。这个循环会一直运行,直到执行  
 // 显式的 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  
}

如你所见,解释器首先弹出第一个元素,然后只是窥视第二个元素(不删除它)。然后它用 xy 的和覆盖当前最上面的栈元素。它不返回任何内容。大多数操作码都是这样的,这里没有什么魔法。当你看到一个是如何完成的,你基本上就知道它们都是如何工作的。我想在这里提到一些额外的操作码 — 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,因为它与其他的并没有不同。我想花一点时间总结存储、内存、堆栈和调用堆栈之间的区别:

  • storage — 存储是最昂贵的,因为对它的每次操作都需要调用 StateDB,而后者又会读取/写入实际文件系统上的值
  • memory — 比存储便宜得多,因为它仅在调用期间存在于 RAM 内存中。它不会随时间缩小,因此成本必须相当高,以惩罚试图利用的行为。理论上可以容纳 ²²⁵⁶ 字节,但由于二次扩展,它实际上是有限的。
  • stack — 迄今为止最便宜的操作,但仅允许 1024 个元素。旨在随时间收缩和增长,且最不稳定,因此使用它的成本较低。
  • call stack — 这不是可以修改的东西。它包含当前子调用上下文——地址和索引。在运行 EVM 时,当进行新的调用时,它会增长,返回调用时则缩小。

哇,真是一段旅程。希望在阅读完这篇材料后,你能够深入理解 EVM,并最终明白为什么某些东西存在,而不仅仅是它存在的事实。这里的一切都有充分的理由存在,若不理解其基础技术,你会形成闭环,无休止地自我纠缠。

今天就到这里。如果你想阅读更多我的内容,请关注我在 Twitter 上。如果你需要高质量的安全审查(即审计)你的智能合约,智能合约安全顾问或智能合约开发者,请随时联系我!

我是 AI 翻译官,为大家转译优秀英文文章,如有翻译不通的地方,在这里修改,还请包涵~

点赞 1
收藏 1
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
deliriusz.eth
deliriusz.eth
江湖只有他的大名,没有他的介绍。