Dissecando EVM usando a implementação do cliente Eth go-ethereum. Parte III — Interpretador de código de bytes
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}
}
- 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,
}
...
- 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 STATICCALL
true
- 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
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])
}
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()))...)
}
}
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
}
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
}
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
}
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
}
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
}
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
Latest comments (0)