Pular para o conteúdo principal

Compreendendo as especificações da EVM do Yellow Paper

evm
Intermediário
qbzzt
15 de maio de 2022
19 minutos de leitura

O Yellow Paper (opens in a new tab) é a especificação formal para o Ethereum. Exceto onde alterado pelo processo EIP, ele contém a descrição exata de como tudo funciona. Ele é escrito como um artigo matemático, que inclui terminologia com a qual os programadores podem não estar familiarizados. Neste artigo, você aprenderá a lê-lo e, por extensão, outros artigos matemáticos relacionados.

Qual Yellow Paper?

Como quase tudo no Ethereum, o Yellow Paper evolui com o tempo. Para poder me referir a uma versão específica, fiz o upload da versão atual no momento da escrita. Os números de seção, página e equação que uso se referirão a essa versão. É uma boa ideia mantê-lo aberto em uma janela diferente enquanto lê este documento.

Por que a EVM?

O yellow paper original foi escrito logo no início do desenvolvimento do Ethereum. Ele descreve o mecanismo de consenso original baseado em prova de trabalho que foi originalmente usado para proteger a rede. No entanto, o Ethereum desativou a prova de trabalho e começou a usar o consenso baseado em prova de participação em setembro de 2022. Este tutorial se concentrará nas partes do yellow paper que definem a máquina virtual Ethereum. A EVM permaneceu inalterada com a transição para a prova de participação (exceto pelo valor de retorno do opcode DIFFICULTY).

9 Modelo de execução

Esta seção (p. 12-14) inclui a maior parte da definição da EVM.

O termo estado do sistema inclui tudo que você precisa saber sobre o sistema para executá-lo. Em um computador típico, isso significa a memória, o conteúdo dos registradores, etc.

Uma máquina de Turing (opens in a new tab) é um modelo computacional. Essencialmente, é uma versão simplificada de um computador, que comprovadamente tem a mesma capacidade de executar computações que um computador normal pode (tudo o que um computador pode calcular, uma máquina de Turing também pode, e vice-versa). Este modelo facilita a prova de vários teoremas sobre o que é e o que não é computável.

O termo Turing-completo (opens in a new tab) significa um computador que pode executar os mesmos cálculos que uma máquina de Turing. As máquinas de Turing podem entrar em loops infinitos, mas a EVM não pode porque ficaria sem gás, então ela é apenas quasi-Turing-completa.

9.1 Conceitos básicos

Esta seção apresenta os conceitos básicos da EVM e como ela se compara a outros modelos computacionais.

Uma máquina de pilha (opens in a new tab) é um computador que armazena dados intermediários não em registradores, mas em uma pilha (opens in a new tab). Essa é a arquitetura preferida para máquinas virtuais porque é fácil de implementar, o que significa que bugs e vulnerabilidades de segurança são muito menos prováveis. A memória na pilha é dividida em palavras de 256 bits. Isso foi escolhido por ser conveniente para as principais operações criptográficas do Ethereum, como o hashing Keccak-256 e os cálculos de curva elíptica. O tamanho máximo da pilha é de 1024 itens (1024 x 256 bits). Quando os códigos de operação são executados, eles geralmente obtêm seus parâmetros da pilha. Existem códigos de operação especificamente para reorganizar elementos na pilha, como POP (remove o item do topo da pilha), DUP_N (duplica o N-ésimo item na pilha), etc.

A EVM também tem um espaço volátil chamado memória que é usado para armazenar dados durante a execução. Esta memória é organizada em palavras de 32 bytes. Todas as posições de memória são inicializadas com zero. Se você executar este código Yul (opens in a new tab) para adicionar uma palavra à memória, ele preencherá 32 bytes de memória preenchendo o espaço vazio na palavra com zeros, ou seja, ele cria uma palavra - com zeros nas posições 0-29, 0x60 na 30, e 0xA7 na 31.

1mstore(0, 0x60A7)

mstore é um dos três códigos de operação que a EVM fornece para interagir com a memória - ele carrega uma palavra na memória. Os outros dois são mstore8, que carrega um único byte na memória, e mload, que move uma palavra da memória para a pilha.

A EVM também tem um modelo de armazenamento não volátil separado que é mantido como parte do estado do sistema - essa memória é organizada em matrizes de palavras (em oposição a matrizes de bytes endereçáveis por palavra na pilha). Este armazenamento é onde os contratos mantêm dados persistentes - um contrato só pode interagir com seu próprio armazenamento. O armazenamento é organizado em mapeamentos de chave-valor.

Embora não seja mencionado nesta seção do Yellow Paper, também é útil saber que existe um quarto tipo de memória. Calldata é uma memória somente de leitura endereçável por byte, usada para armazenar o valor passado com o parâmetro data de uma transação. A EVM tem códigos de operação específicos para gerenciar calldata. calldatasize retorna o tamanho dos dados. calldataload carrega os dados na pilha. calldatacopy copia os dados para a memória.

A arquitetura de Von Neumann (opens in a new tab) padrão armazena código e dados na mesma memória. A EVM não segue este padrão por razões de segurança - o compartilhamento de memória volátil torna possível alterar o código do programa. Em vez disso, o código é salvo no armazenamento.

Há apenas dois casos em que o código é executado a partir da memória:

  • Quando um contrato cria outro contrato (usando CREATE (opens in a new tab) ou CREATE2 (opens in a new tab)), o código para o construtor do contrato vem da memória.
  • Durante a criação de qualquer contrato, o código do construtor é executado e, em seguida, retorna com o código do contrato real, também da memória.

O termo execução excepcional significa uma exceção que faz com que a execução do contrato atual seja interrompida.

9.2 Visão geral das taxas

Esta seção explica como as taxas de gás são calculadas. Existem três custos:

Custo do opcode

O custo inerente do opcode específico. Para obter este valor, encontre o grupo de custo do opcode no Apêndice H (p. 28, sob a equação (327)) e encontre o grupo de custo na equação (324). Isso fornece uma função de custo que, na maioria dos casos, usa parâmetros do Apêndice G (p. 27).

Por exemplo, o opcode CALLDATACOPY (opens in a new tab) é um membro do grupo Wcopy. O custo do opcode para esse grupo é Gverylow+Gcopy×⌈μs[2]÷32⌉. Olhando para o Apêndice G, vemos que ambas as constantes são 3, o que nos dá 3+3×⌈μs[2]÷32⌉.

Ainda precisamos decifrar a expressão ⌈μs[2]÷32⌉. A parte mais externa, ⌈ <value> ⌉, é a função teto, uma função que, dado um valor, retorna o menor inteiro que não seja menor que o valor. Por exemplo, ⌈2.5⌉ = ⌈3⌉ = 3. A parte interna é μs[2]÷32. Observando a seção 3 (Convenções) na p. 3, μ é o estado da máquina. O estado da máquina é definido na seção 9.4.1 na p. 13. De acordo com essa seção, um dos parâmetros do estado da máquina é s para a pilha. Juntando tudo, parece que μs[2] é a posição nº 2 na pilha. Olhando para o opcode (opens in a new tab), a posição nº 2 na pilha é o tamanho dos dados em bytes. Olhando para os outros códigos de operação no grupo Wcopy, CODECOPY (opens in a new tab) e RETURNDATACOPY (opens in a new tab), eles também têm um tamanho de dados na mesma posição. Portanto, ⌈μs[2]÷32⌉ é o número de palavras de 32 bytes necessárias para armazenar os dados que estão sendo copiados. Juntando tudo, o custo inerente do CALLDATACOPY (opens in a new tab) é de 3 de gás mais 3 por palavra de dados sendo copiada.

Custo de execução

O custo de executar o código que estamos chamando.

Custo de expansão da memória

O custo de expandir a memória (se necessário).

Na equação 324, esse valor é escrito como Cmemi')-Cmemi). Olhando novamente para a seção 9.4.1, vemos que μi é o número de palavras na memória. Portanto, μi é o número de palavras na memória antes do opcode e μi' é o número de palavras na memória depois do opcode.

A função Cmem é definida na equação 326: Cmem(a) = Gmemory × a + ⌊a2 ÷ 512⌋. ⌊x⌋ é a função piso, uma função que, dado um valor, retorna o maior inteiro que não seja maior que o valor. Por exemplo, ⌊2.5⌋ = ⌊2⌋ = 2. Quando a < √512, a2 < 512, e o resultado da função piso é zero. Portanto, para as primeiras 22 palavras (704 bytes), o custo aumenta linearmente com o número de palavras de memória necessárias. Além desse ponto, ⌊a2 ÷ 512⌋ é positivo. Quando a memória necessária é alta o suficiente, o custo do gás é proporcional ao quadrado da quantidade de memória.

Observação: esses fatores influenciam apenas o custo de gás inerente - não leva em conta o mercado de taxas ou gorjetas para validadores que determinam quanto um usuário final deve pagar - este é apenas o custo bruto de executar uma operação específica na EVM.

Leia mais sobre gás.

9.3 Ambiente de execução

O ambiente de execução é uma tupla, I, que inclui informações que não fazem parte do estado da blockchain ou da EVM.

ParâmetroOpcode para acessar os dadosCódigo Solidity para acessar os dados
IaADDRESS (opens in a new tab)address(this)
IoORIGIN (opens in a new tab)tx.origin
IpGASPRICE (opens in a new tab)tx.gasprice
IdCALLDATALOAD (opens in a new tab), etc.msg.data
IsCALLER (opens in a new tab)msg.sender
IvCALLVALUE (opens in a new tab)msg.value
IbCODECOPY (opens in a new tab)address(this).code
IHCampos do cabeçalho do bloco, como NUMBER (opens in a new tab) e DIFFICULTY (opens in a new tab)block.number, block.difficulty, etc.
IeProfundidade da pilha de chamadas para chamadas entre contratos (incluindo a criação de contratos)
IwA EVM tem permissão para alterar o estado ou está sendo executada estaticamente

Alguns outros parâmetros são necessários para entender o resto da seção 9:

ParâmetroDefinido na seçãoSignificado
σ2 (p. 2, equação 1)O estado da blockchain
g9.3 (p. 13)Gás restante
A6.1 (p. 8)Sub-estado acumulado (alterações agendadas para quando a transação terminar)
o9.3 (p. 13)Saída - o resultado retornado no caso de transação interna (quando um contrato chama outro) e chamadas para funções de visualização (quando você está apenas solicitando informações, então não há necessidade de esperar por uma transação)

9.4 Visão geral da execução

Agora que temos todos os preliminares, podemos finalmente começar a trabalhar em como a EVM funciona.

As equações 137-142 nos dão as condições iniciais para executar a EVM:

SímboloValor inicialSignificado
μggGás restante
μpc0Contador de programa, o endereço da próxima instrução a ser executada
μm(0, 0, ...)Memória, inicializada com todos os zeros
μi0Local de memória mais alto usado
μs()A pilha, inicialmente vazia
μoA saída, conjunto vazio até pararmos com dados de retorno (RETURN (opens in a new tab) ou REVERT (opens in a new tab)) ou sem eles (STOP (opens in a new tab) ou SELFDESTRUCT (opens in a new tab)).

A equação 143 nos diz que existem quatro condições possíveis em cada ponto no tempo durante a execução e o que fazer com elas:

  1. Z(σ,μ,A,I). Z representa uma função que testa se uma operação cria uma transição de estado inválida (consulte a interrupção excepcional). Se for avaliado como True, o novo estado é idêntico ao antigo (exceto que o gás é queimado) porque as alterações não foram implementadas.
  2. Se o opcode que está sendo executado for REVERT (opens in a new tab), o novo estado é o mesmo que o estado antigo, e um pouco de gás é perdido.
  3. Se a sequência de operações terminar, conforme sinalizado por um RETURN (opens in a new tab)), o estado é atualizado para o novo estado.
  4. Se não estivermos em uma das condições finais 1-3, continue a execução.

9.4.1 Estado da máquina

Esta seção explica o estado da máquina com mais detalhes. Isso especifica que w é o opcode atual. Se μpc for menor que ||Ib||, o comprimento do código, então esse byte (Ibpc]) é o opcode. Caso contrário, o opcode é definido como STOP (opens in a new tab).

Como esta é uma máquina de pilha (opens in a new tab), precisamos rastrear o número de itens retirados (δ) e inseridos (α) por cada opcode.

9.4.2 Interrupção Excepcional

Esta seção define a função Z, que especifica quando temos uma terminação anormal. Esta é uma função booleana (opens in a new tab), então ela usa para um ou lógico (opens in a new tab) e para um e lógico (opens in a new tab).

Temos uma interrupção excepcional se qualquer uma dessas condições for verdadeira:

  • μg < C(σ,μ,A,I) Como vimos na seção 9.2, C é a função que especifica o custo do gás. Não há gás suficiente para cobrir o próximo opcode.

  • δw=∅ Se o número de itens removidos para um opcode for indefinido, o próprio opcode será indefinido.

  • || μs || < δw Stack underflow, não há itens suficientes na pilha para o opcode atual.

  • w = JUMP ∧ μs[0]∉D(Ib) O opcode é JUMP (opens in a new tab) e o endereço não é um JUMPDEST (opens in a new tab). Os saltos são válidos apenas quando o destino é um JUMPDEST (opens in a new tab).

  • w = JUMPI ∧ μs[1]≠0 ∧ μs[0] ∉ D(Ib) O opcode é JUMPI (opens in a new tab), a condição é verdadeira (diferente de zero), então o salto deve acontecer, e o endereço não é um JUMPDEST (opens in a new tab). Os saltos são válidos apenas quando o destino é um JUMPDEST (opens in a new tab).

  • w = RETURNDATACOPY ∧ μs[1]+μs[2]>|| μo || O opcode é RETURNDATACOPY (opens in a new tab). Neste elemento da pilha do opcode μs[1] está o deslocamento para ler no buffer de dados de retorno, e o elemento da pilha μs[2] é o comprimento dos dados. Essa condição ocorre quando você tenta ler além do final do buffer de dados de retorno. Observe que não há uma condição semelhante para os calldata ou para o próprio código. Quando você tenta ler além do final desses buffers, você obtém apenas zeros.

  • || μs || - δw + αw > 1024

    Estouro de pilha. Se a execução do opcode resultar em uma pilha de mais de 1024 itens, aborte.

  • ¬Iw ∧ W(w,μ) Estamos executando estaticamente (¬ é negação (opens in a new tab) e Iw é verdadeiro quando temos permissão para alterar o estado da blockchain)? Em caso afirmativo, e se estivermos tentando uma operação de alteração de estado, ela não poderá acontecer.

    A função W(w,μ) é definida posteriormente na equação 150. W(w,μ) é verdadeiro se uma dessas condições for verdadeira:

    • w ∈ {CREATE, CREATE2, SSTORE, SELFDESTRUCT} Esses códigos de operação alteram o estado, seja criando um novo contrato, armazenando um valor ou destruindo o contrato atual.

    • LOG0≤w ∧ w≤LOG4 Se formos chamados estaticamente, não poderemos emitir entradas de log. Os códigos de operação de log estão todos no intervalo entre LOG0 (A0) (opens in a new tab) e LOG4 (A4) (opens in a new tab). O número após o opcode de log especifica quantos tópicos a entrada de log contém.

    • w=CALL ∧ μs[2]≠0 Você pode chamar outro contrato quando estiver estático, mas se o fizer, não poderá transferir ETH para ele.

  • w = SSTORE ∧ μg ≤ Gcallstipend Você não pode executar SSTORE (opens in a new tab) a menos que tenha mais do que Gcallstipend (definido como 2300 no Apêndice G) de gás.

9.4.3 Validade do destino do salto

Aqui definimos formalmente quais são os opcodes JUMPDEST (opens in a new tab). Não podemos apenas procurar pelo valor de byte 0x5B, porque ele pode estar dentro de um PUSH (e, portanto, ser um dado e não um opcode).

Na equação (153), definimos uma função, N(i,w). O primeiro parâmetro, i, é a localização do opcode. O segundo, w, é o próprio opcode. Se w∈[PUSH1, PUSH32], isso significa que o opcode é um PUSH (colchetes definem um intervalo que inclui os pontos de extremidade). Nesse caso, o próximo opcode está em i+2+(w−PUSH1). Para PUSH1 (opens in a new tab) precisamos avançar dois bytes (o próprio PUSH e o valor de um byte), para PUSH2 (opens in a new tab) precisamos avançar três bytes porque é um valor de dois bytes, etc. Todos os outros opcodes da EVM têm apenas um byte de comprimento, então, em todos os outros casos, N(i,w)=i+1.

Esta função é usada na equação (152) para definir DJ(c,i), que é o conjunto (opens in a new tab) de todos os destinos de salto válidos no código c, começando com a localização do opcode i. Esta função é definida recursivamente. Se i≥||c||, isso significa que estamos no final do código ou depois dele. Não vamos encontrar mais nenhum destino de salto, então apenas retorne o conjunto vazio.

Em todos os outros casos, olhamos para o resto do código, indo para o próximo opcode e obtendo o conjunto a partir dele. c[i] é o opcode atual, portanto N(i,c[i]) é a localização do próximo opcode. DJ(c,N(i,c[i])) é, portanto, o conjunto de destinos de salto válidos que começa no próximo opcode. Se o opcode atual não for um JUMPDEST, apenas retorne esse conjunto. Se for JUMPDEST, inclua-o no conjunto de resultados e retorne-o.

9.4.4 Parada normal

A função de parada H pode retornar três tipos de valores.

  • Se não estivermos em um opcode de parada, retorne , o conjunto vazio. Por convenção, este valor é interpretado como booleano falso.
  • Se tivermos um opcode de parada que não produz saída (seja STOP (opens in a new tab) ou SELFDESTRUCT (opens in a new tab)), retorne uma sequência de bytes de tamanho zero como o valor de retorno. Note que isso é muito diferente do conjunto vazio. Esse valor significa que a EVM realmente parou, apenas não há dados de retorno para ler.
  • Se tivermos um opcode de parada que produz saída (seja RETURN (opens in a new tab) ou REVERT (opens in a new tab)), retorne a sequência de bytes especificada por esse opcode. Esta sequência é retirada da memória, o valor no topo da pilha (μs[0]) é o primeiro byte e o valor depois dele (μs[1]) é o comprimento.

H.2 Conjunto de instruções

Antes de irmos para a subseção final da EVM, 9.5, vamos olhar para as próprias instruções. Elas são definidas no Apêndice H.2, que começa na p. 29. Qualquer coisa que não for especificada como alterada por esse opcode específico deverá permanecer a mesma. As variáveis que mudam são especificadas com <algo>′.

Por exemplo, vamos olhar para o opcode ADD (opens in a new tab).

ValorMnemônicoδαDescrição
0x01ADD21Operação de adição.
μ′s[0] ≡ μs[0] + μs[1]

δ é o número de valores que retiramos da pilha. Nesse caso, dois, porque estamos adicionando os dois valores superiores.

α é o número de valores que empurramos de volta. Nesse caso, um, a soma.

Portanto, o novo topo da pilha (μ′s[0]) é a soma do topo da pilha antigo (μs[0]) e o valor antigo abaixo dele (μs[1]).

Em vez de percorrer todos os códigos de operação com uma lista "de deixar os olhos vidrados", este artigo explica apenas os códigos de operação que introduzem algo novo.

ValorMnemônicoδαDescrição
0x20KECCAK25621Computa o hash Keccak-256.
μ′s[0] ≡ KEC(μms[0] . . . (μs[0] + μs[1] − 1)])
μ′i ≡ M(μis[0],μs[1])

Este é o primeiro opcode que acessa a memória (neste caso, apenas para leitura). No entanto, ele pode se expandir para além dos limites atuais da memória, portanto, precisamos atualizar μi. Fazemos isso usando a função M definida na equação 328 na p. 29.

ValorMnemônicoδαDescrição
0x31BALANCE11Obtenha o saldo da conta informada.
...

O endereço cujo saldo precisamos encontrar é μs[0] mod 2160. O topo da pilha é o endereço, mas como os endereços têm apenas 160 bits, calculamos o valor módulo (opens in a new tab) 2160.

Se σ[μs[0] mod 2160] ≠ ∅, isso significa que há informações sobre este endereço. Nesse caso, σ[μs[0] mod 2160]b é o saldo para esse endereço. Se σ[μs[0] mod 2160] = ∅, isso significa que este endereço não foi inicializado e o saldo é zero. Você pode ver a lista de campos de informações da conta na seção 4.1 na p. 4.

A segunda equação, A'a ≡ Aa ∪ {μs[0] mod 2160}, está relacionada à diferença de custo entre o acesso ao armazenamento quente (armazenamento que foi acessado recentemente e provavelmente está em cache) e o armazenamento frio (armazenamento que não foi acessado e provavelmente está em um armazenamento mais lento que é mais caro para recuperar). Aa é a lista de endereços acessados anteriormente pela transação, que, portanto, deve ser mais barata para acessar, conforme definido na seção 6.1 na p. 8. Você pode ler mais sobre este assunto em EIP-2929 (opens in a new tab).

ValorMnemônicoδαDescrição
0x8FDUP161617Duplicar o 16º item da pilha.
μ′s[0] ≡ μs[15]

Observe que para usar qualquer item da pilha, precisamos retirá-lo, o que significa que também precisamos retirar todos os itens da pilha sobre ele. No caso de DUP<n> (opens in a new tab) e SWAP<n> (opens in a new tab), isso significa ter que remover e, em seguida, empurrar até dezesseis valores.

9.5 O ciclo de execução

Agora que temos todas as partes, podemos finalmente entender como o ciclo de execução da EVM é documentado.

A equação (155) diz que, dado o estado:

  • σ (estado global da blockchain)
  • μ (estado da EVM)
  • A (subestado, alterações que ocorrerão quando a transação terminar)
  • I (ambiente de execução)

O novo estado é (σ', μ', A', I').

As equações (156)-(158) definem a pilha e a mudança nela devido a um opcode (μs). A equação (159) é a alteração no gás (μg). A equação (160) é a alteração no contador do programa (μpc). Finalmente, as equações (161)-(164) especificam que os outros parâmetros permanecem os mesmos, a menos que explicitamente alterados pelo opcode.

Com isso, a EVM está totalmente definida.

Conclusão

A notação matemática é precisa e permitiu que o Yellow Paper especificasse cada detalhe do Ethereum. No entanto, ela tem algumas desvantagens:

  • Ela só pode ser entendida por humanos, o que significa que os testes de conformidade (opens in a new tab) devem ser escritos manualmente.
  • Programadores entendem código de computador. Eles podem ou não entender a notação matemática.

Talvez por essas razões, as mais recentes especificações da camada de consenso (opens in a new tab) são escritas em Python. Existem especificações da camada de execução em Python (opens in a new tab), mas elas não estão completas. Até que todo o Yellow Paper seja também traduzido para Python ou uma linguagem semelhante, o Yellow Paper continuará em serviço, e é útil ser capaz de lê-lo.

Última atualização da página: 1 de fevereiro de 2026

Este tutorial foi útil?