WEB3DEV

Cover image for Dissecando EVM usando a implementação do cliente Eth go-ethereum. Parte III — Interpretador de código de bytes
Rafael Ojeda
Rafael Ojeda

Posted on

Dissecando EVM usando a implementação do cliente Eth go-ethereum. Parte III — Interpretador de código de bytes

Dissecando EVM usando a implementação do cliente Eth go-ethereum. Parte III — Interpretador de código de bytes

Image description

Foto de Shubham Dhage no Unsplash

Eu planejava fazer apenas 2 partes, mas a segunda parte cresceu tanto, que tive que dividi-la em 2. Então, aproveite a última parte.

A propósito, se você perdeu as partes anteriores, aqui estão:

Executando o interpretador de código de bytes

Finalmente chegamos ao core/vm/interpreter.go, que interpreta bytes brutos em código executável. Gostaria de começar com a explicação de algumas estruturas, para entender melhor o que ela tem a oferecer.

// O ScopeContext contém as coisas que são por chamada, como pilha e memória,
// mas não transientes como pc e gas
type ScopeContext struct {
 Memória *Memória
 Stack *Stack
 Contract *Contract
}
// EVMInterpreter representa um interpretador EVM
type EVMInterpreter struct {
 evm *EVM
 tabela *JumpTable
 hasher crypto.KeccakState // Instância de hasher Keccak256 compartilhada entre opcodes
 hasherBuf common.Hash // Matriz de resultados do hasher Keccak256 compartilhada entre os códigos de operação
 readOnly bool // Se deve ser lançado em modificações com estado
 returnData []byte // Dados de retorno da última CALL para reutilização subsequente
}
// NewEVMInterpreter retorna uma nova instância do Interpreter.
func NewEVMInterpreter(evm *EVM) *EVMInterpreter {
 // Se a tabela de saltos não foi inicializada, definimos a tabela padrão.
 var table *JumpTable
 switch {
 case evm.chainRules.IsShanghai:
  table = &shanghaiInstructionSet
 ...
  table = &homesteadInstructionSet
 default:
  table = &frontierInstructionSet
 }
 var extraEips []int
 if len(evm.Config.ExtraEips) > 0 
  // Cópia profunda da jumptable para evitar a modificação de códigos de operação em outras tabelas
  table = copyJumpTable(table)
 }
 for _, eip := range evm.Config.ExtraEips {
  if err := EnableEIP(eip, table); err != nil {
   // Desativar, para que o chamador possa verificar se está ativado ou não
   log.Error("EIP activation failed", "eip", eip, "error", err)
  } else {
   extraEips = append(extraEips, eip)
  }
 }
 evm.Config.ExtraEips = extraEips
 return &EVMInterpreter{evm: evm, table: table}
}
Enter fullscreen mode Exit fullscreen mode
  • ScopeContext é, em poucas palavras, apenas memória alocada e pilha para um contrato. Isso é importante, pois é o que é chamado de "contexto de chamada" - olhando para ele, você pode adivinhar que é apenas para um contrato em que você está atualmente durante a interpretação do código de bytes. Cada vez que você faz chamada, delega, staticcall ou código de chamada (agora preterido, mas ainda suportado pelo EVM), você obtém novo ScopeContext e nova memória e pilha junto com ele.
  • EVMInterpreter contém referência a EVM, tabela de saltos, que é apenas um mapeamento entre o código opcode uint8 e os dados de operação subjacentes, por exemplo. *Os detalhes da operação podem ser encontrados em *core/vm/jump_table.go. Vejamos como o elemento jumpTable exemplar é adicionado:**table[0xF1] -> CALL
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,
}
...
Enter fullscreen mode Exit fullscreen mode
  • hasher _e hasherBuf_ não são realmente interessantes aqui. Mas seu único uso, até onde pude verificar, é em operações relacionadas ao keccak256
  • readOnly define se quaisquer alterações no StateDB são permitidas. Somente definido como , se chamado via STATICCALLtrue
  • returnData é exatamente os dados que são retornados por meio do último opcode RETURN

Interpretador construtor também é interessante. Você pode ver aqui, que ele aplica um conjunto de instruções diferente, com base no fork atual. No final, todos os EIPs que modificam o funcionamento de opcodes são aplicados.

Senhoras e senhores, finalmente o momento em que todos estavam esperando pela função -**Run(), **aquela que está no cerne do EVM:

// Executa loops e avalia o código do contrato com os dados de entrada fornecidos e retorna
// o byte-slice de retorno e um erro, se houver.
//
// É importante observar que qualquer erro retornado pelo intérprete deve ser
// considerados uma operação de reverter e consumir todo o gás, exceto para
// ErrExecutionReverted, que significa reverter e manter o gás restante.
func (in *EVMInterpreter) Run(contract *Contract, input []byte, readOnly bool) (ret []byte, err error) {
 // Aumenta a profundidade da chamada, que é restrita a 1024
 in.evm.depth++
 defer func() { in.evm.depth-- }()
 // Certifique-se de que o readOnly seja definido somente se ainda não estivermos no readOnly.
 // Isso também garante que o sinalizador readOnly não seja removido para chamadas secundárias.
 if readOnly && !in.readOnly {
  in.readOnly = true
  defer func() { in.readOnly = false }()
 }
 // Redefinir os dados de retorno da chamada anterior. Não é importante preservar o buffer antigo
 // pois cada chamada retornada retornará novos dados de qualquer forma.
 in.returnData = nil
 // Não se preocupe com a execução se não houver código.
 se len(contract.Code) == 0 {
  return nil, nil
 }
 var (
  op OpCode // código de operação atual
  mem = NewMemory() // memória vinculada
  stack = newstack() // pilha local
  callContext = &ScopeContext{
   Memória: mem,
   Stack: stack,
   Contract: contract,
  }
// Por motivo de otimização, estamos usando uint64 como contador de programa.
  // Teoricamente, é possível ir além de 2^64. O YP define o PC
  // para ser uint256. Na prática, é muito menos viável.
  pc = uint64(0) // contador de programa
  custo uint64
  // cópias usadas pelo rastreador
  pcCopy uint64 // necessário para o EVMLogger diferido
  gasCopy uint64 // para o EVMLogger registrar o gás restante antes da execução
  logged bool // o EVMLogger diferido deve ignorar as etapas já registrada
  res []byte // resultado da função de execução do opcode
 )
 // Não mova essa função diferida, ela é colocada antes do método capturestate-deferred,
 // para que ela seja executada _depois_: o capturestate precisa das pilhas antes de
 // elas sejam devolvidas aos pools
 defer func() {
  returnStack(stack)
 }()
 contract.Input = input
Enter fullscreen mode Exit fullscreen mode

A primeira coisa que fazemos, aumentamos a profundidade da chamada em um e prometemos diminuí-la no final. Depois de gerenciar o sinalizador readOnly e zerar returnData desnecessário. Então, se não houver código para invocar, apenas fazemos um retorno antecipado. Então você pode ver claramente como um novo contexto de chamada está sendo criado, contador de processo (pc), que aumentará a cada operação executada, alguns parâmetros de utilidade adicionais. Finalmente, o interpretador adia a pilha de limpeza e atribui entrada, que é apenas um dados de chamada de matriz de bytes. Vamos rapidamente analisar core/vm/stack.go e core/vm/memory.go.

// Stack é um objeto para operações básicas de pilha. Os itens colocados na pilha são
// Espera-se que sejam alterados e modificados. A pilha não se encarrega de adicionar objetos recém-inicializados.
// inicializados.
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) {
 // OBSERVAÇÃO: o limite de push (1024) é verificado em 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])
}
Enter fullscreen mode Exit fullscreen mode

Apenas uma implementação de pilha simples. Mas o que é interessante é que você pode e elemento de qualquer profundidade que você quiser aqui. É apenas uma limitação EVM desnecessária, que suporta 1 a 16 swaps profundos e dups, o que resulta em erros "muito profundos", se você estiver escrevendo algo mais interessante do que um simples contrato de caução... E na verdade você tem um pool de pilhas, que estão sendo zeradas após o uso.swapdup

// Memory implementa um modelo de memória simples para a máquina virtual ethereum.
type Memory struct {
 store []byte
 lastGasCost uint64
}
// NewMemory retorna um novo modelo de memória.
func NewMemory() *Memory {
 return &Memory{}
}
// Set define offset + tamanho como valor
func (m *Memory) Set(offset, size uint64, value []byte) {
 // É possível que o deslocamento seja maior que 0 e o tamanho seja igual a 0. Isso ocorre porqu
 // o calcMemSize (common.go) pode potencialmente retornar 0 quando o tamanho for zero (NO-OP)
 se tamanho > 0 {
  // o comprimento do store nunca pode ser menor que offset + size.
  // O armazenamento deve ser redimensionado ANTES da definição da memória
  se offset+size > uint64(len(m.store)) {
   panic("memória inválida: store vazio")
  }
  copy(m.store[offset:offset+size], value)
 }
}
// Set32 define os 32 bytes que começam em offset para o valor de val, com zeros à esquerda para
// 32 bytes.
func (m *Memory) Set32(offset uint64, val *uint256.Int) {
 // o comprimento do armazenamento nunca pode ser menor que offset + size.
 // O armazenamento deve ser redimensionado ANTES da definição da memória
 se offset+32 > uint64(len(m.store)) {
  panic("memória inválida: store vazio")
 }
 // Preencher os bits relevantes
 b32 := val.Bytes32()
 copy(m.store[offset:], b32[:])
}
// Redimensionar redimensiona a memória para o tamanho
func (m *Memory) Resize(size uint64) {
 if uint64(m.Len()) < size {
  m.store = append(m.store, make([]byte, size-uint64(m.Len()))...)
 }
}
Enter fullscreen mode Exit fullscreen mode

Mais uma vez, nada de interessante aqui. Ele difere da pilha, porque não é feito para encolher e crescer, mas apenas expandir. Além dos bytes que detém, ele também contém informações sobre o lastGasCost, que cresce de forma quadrática no preço do gás depois de atingir um tamanho específico. Você pode ler mais sobre isso AQUI.

Em seguida, analisarei o loop de execução real. Desta vez, vou descrevê-lo antes de mostrar o código. Assim, o loop itera até que um erro seja lançado. Trata o erro como um sucesso real, caso contrário, significa que o Estado tem que ser revertido. Em seguida, obtemos o opcode no contador de processos específico, pesquisamos na tabela de saltos, verificamos se a pilha não vai transbordar ou transbordar depois de chamar esse opcode, verificar se é suficiente para o cálculo estático e dinâmico, e a memória não vai transbordar, o que não é possível no momento, pois devido ao custo de expansão de memória quadrática, você ficará sem gás mais cedo, que chegar a 2²⁵⁶ memória, aaae ainda não temos uma memória RAM tão grande. Se a memória precisar ser expandida, ela está sendo redimensionada e, finalmente, a operação está sendo executada com calldata e callContext.errStopTokengasleft

// O loop de execução principal do interpretador (contextual). Esse loop é executado até que um
 // até que um STOP, RETURN ou SELFDESTRUCT explícito seja executado, um erro tenha ocorrido durante
 // um erro durante a execução de uma das operações ou até que o sinalizador done seja definido pelo
 // contexto pai.
 for {
  ...
  // Obtenha a operação da tabela de saltos e valide a pilha para garantir que há
  // itens de pilha suficientes disponíveis para executar a operação.
  op = contract.GetOp(pc)
  operation := in.table[op]
  cost = operation.constantGas // Para rastreamento
  // Validar a pilha
  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 {
   // Todas as operações com um uso dinâmico de memória também têm um custo dinâmico de gás.
   var memorySize uint64
   // calcular o novo tamanho da memória e expandir a memória para caber
   // a operação
   // A verificação da memória precisa ser feita antes da avaliação da parte do gás dinâmico,
   // para detectar estouros de cálculo
   if operation.memorySize != nil {
    memSize, overflow := operation.memorySize(stack)
    se overflow {
     return nil, ErrGasUintOverflow
    }
    // A memória é expandida em palavras de 32 bytes. Gás
    // também é calculado em palavras.
    if memorySize, overflow = math.SafeMul(toWordSize(memSize), 32); overflow {
     return nil, ErrGasUintOverflow
    }
   }
   // Consumir o gás e retornar um erro se não houver gás suficiente disponível.
   // O custo é explicitamente definido para que o método de adiamento do estado de captura possa obter o custo adequado
   var dynamicCost uint64
   dynamicCost, err = operation.dynamicGas(in.evm, contract, stack, mem, memorySize)
   cost += dynamicCost // para rastreamento
   se err != nil || !contract.UseGas(dynamicCost) {
    return nil, ErrOutOfGas
   }
   ...
   se memorySize > 0 {
    mem.Resize(memorySize)
   }
  }
  ...
  // executar a operação
  res, err = operation.execute(&pc, in, callContext)
  se err != nil {
   break
  }
  pc++
 }
se err == errStopToken {
  err = nil // limpar o erro do token de parada
 }
 return res, err
}
Enter fullscreen mode Exit fullscreen mode

E agora, para a implementação real das operações. Todos eles são implementados em core/vm/instructions.go. Vamos primeiro passar pelo mais simples — 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
}
Enter fullscreen mode Exit fullscreen mode

Como você pode ver, o intérprete primeiro aparece o primeiro elemento, e apenas espia o segundo (sem excluí-lo). Em seguida, ele substitui o elemento de pilha superior atual pela soma do e . Não devolve nada. A maioria dos opcodes são assim, não há mágica aqui. Quando você vê como um é feito, você basicamente conhece todos eles. Gostaria de mencionar aqui alguns opcodes adicionais — CREATE, CALL e DELEGATECALL, para ter uma visão completa de como os opcodes funcionam em todo o contexto de chamada:xy

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
 )
 se interpreter.evm.chainRules.IsEIP150 {
  gas -= gas / 64
 }
 // reutilizar o tamanho int para o valor da pilha
 valor da pilha := tamanho
 scope.Contract.UseGas(gas)
 //TODO: use uint256.Int em vez de converter com toBig()
 var bigVal = big0
 se !value.IsZero() {
  bigVal = value.ToBig()
 }
 res, addr, returnGas, suberr := interpreter.evm.Create(scope.Contract, input, gas, bigVal)
 // Empurrar item na pilha com base no erro retornado. Se o conjunto de regras for
 // homestead, devemos verificar se há CodeStoreOutOfGasError (regra somente para homestead
 // (regra somente para homestead) e tratar como um erro; se o conjunto de regras for frontier, devemos
 // ignorar esse erro e fingir que a operação foi bem-sucedida.
 if interpreter.evm.chainRules.IsHomestead && suberr == ErrCodeStoreOutOfGas {
  stackvalue.Clear()
 } else if suberr != nil && suberr != ErrCodeStoreOutOfGas {
  stackvalue.Clear()
 } else {
  stackvalue.SetBytes(addr.Bytes())
 }
 escopo.Stack.push(&stackvalue)
 scope.Contract.Gas += returnGas
 se suberr == ErrExecutionReverted {
  interpreter.returnData = res // definir dados REVERT no buffer de dados de retorno
  return res, nil
 }
 interpreter.returnData = nil // limpar o buffer de dados de retorno sujo
 return nil, nil
}
Enter fullscreen mode Exit fullscreen mode

Em suma, como você pode ver, primeiro obtemos os elementos necessários da pilha, deduzimos o gás e ... recursivamente chamamos , o caminho que descrevemos no início. Isso criará um novo contexto de chamada (pilha e memória) e iniciará a interpretação do código novamente. Interessante! É como começar uma nova transação novamente. Agora vamos ver como o CALL op é implementado:evm.Create

func opCall(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) {
 stack := scope.Stack
 // Pop gas. O gás real em interpreter.evm.callGasTemp.
 // Podemos usar isso como um valor temporário
 temp := stack.pop()
 gas := interpreter.evm.callGasTemp
 // Pop outros parâmetros de chamada.
 addr, value, inOffset, inSize, retOffset, retSize := stack.pop(), stack.pop(), stack.pop(), stack.pop(), stack.pop(), stack.pop(), stack.pop()
 toAddr := common.Address(addr.Bytes20())
 // Obter os argumentos da memória.
 args := scope.Memory.GetPtr(int64(inOffset.Uint64()), int64(inSize.Uint64())

 se interpreter.readOnly && !value.IsZero() {
  return nil, ErrWriteProtection
 }
 var bigVal = big0
 //TODO: use uint256.Int em vez de converter com toBig()
 // Ao usar big0 aqui, economizamos um alocador para o caso mais comum (chamadas de contrato sem transferência de tempo),
 // mas faria mais sentido estender o uso de uint256.Int
 se !value.IsZero() {
  gas += params.CallStipend
  bigVal = value.ToBig()
 }
 ret, returnGas, err := interpreter.evm.Call(scope.Contract, toAddr, args, gas, bigVal)
 se err != nil {
  temp.Clear()
 } else {
  temp.SetOne()
 }
 stack.push(&temp)
 se err == nil || err == ErrExecutionReverted {
  scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret)
 }
 scope.Contract.Gas += returnGas
 interpreter.returnData = ret
 return ret, nil
}
Enter fullscreen mode Exit fullscreen mode

Na verdade, não há tantas mudanças aqui... E o DELEGATECALL?

func opDelegateCall(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) 
 stack := scope.Stack
 // Pop gas. O gás real está em interpreter.evm.callGasTemp.
 // Usamos isso como um valor temporário
 temp := stack.pop()
 gas := interpreter.evm.callGasTemp
 // Pop outros parâmetros de chamada.
 addr, inOffset, inSize, retOffset, retSize := stack.pop(), stack.pop(), stack.pop(), stack.pop(), stack.pop(), stack.pop()
 toAddr := common.Address(addr.Bytes20())
 // Obter argumentos da memória.
 args := scope.Memory.GetPtr(int64(inOffset.Uint64()), int64(inSize.Uint64()))
 ret, returnGas, err := interpreter.evm.DelegateCall(scope.Contract, toAddr, args, gas)
 se err != nil {
  temp.Clear()
 } else {
  temp.SetOne()
 }
 stack.push(&temp)
 se err == nil || err == ErrExecutionReverted {
  scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret)
 }
 scope.Contract.Gas += returnGas
 interpreter.returnData = ret
 return ret, nil
}
Enter fullscreen mode Exit fullscreen mode

Hmm... Quase o mesmo. Onde está a diferença entre os dois? Está em core/vm/evm, especificamente aqueles links:** DELEGATECALL vs **CALL. Vou deixar de me aprofundar nesse assunto para você, caro leitor :-)

É quase o fim. Não vou descrever o STATICCALL, pois ele não difere do resto. Gostaria de gastar um pouco de tempo para resumir a diferença entre armazenamento, memória, pilha e pilha de chamadas:

  • storage — o armazenamento é o mais caro, pois cada operação nele requer a chamada do StateDB, que, por sua vez, lê/grava valores no armazenamento real no sistema de arquivos
  • memória — muito mais barata do que o armazenamento, porque existe apenas na memória RAM durante a duração de uma chamada. Não diminui com o tempo, pelo que os custos têm de ser consideráveis, de modo a punir as tentativas de exploração. Teoricamente tem ²²⁵⁶ bytes, mas por causa da expansão quadrática, é realmente limitado.
  • stack — de longe o mais barato para se trabalhar, mas permite apenas 1024 elementos. Destina-se a encolher e crescer ao longo do tempo, e é o mais volátil, daí o baixo preço de usá-lo.
  • pilha de chamadas — não é algo que você possa modificar. Ele contém o contexto atual da subchamada — endereço e índice. Ele cresce quando uma nova chamada é feita durante a execução do EVM e diminui com o retorno da chamada.

Ufa, que passeio. Espero que depois de ler o material aqui, você seja capaz de obter alguns insights profundos sobre EVM e, finalmente, entender por que algo é, não apenas que existe. Tudo aqui tem uma boa razão para estar aqui, e sem entender a tecnologia subjacente, você correrá em círculos, batendo a cabeça contra a parede.

Isso é tudo por hoje. Se você quiser ler mais das minhas coisas, por favor, siga-me no Twitter. Se você precisa de revisão de segurança de alta qualidade (também conhecida como auditoria) de seus contratos inteligentes, consultor de segurança de contrato inteligente ou desenvolvedor de contratos inteligentes, sinta-se à vontade para entrar em contato!

Este Artigo foi escrito por
deliriusz.eth
e traduzido para o português por Rafael Ojeda

Top comments (0)