Entendendo as especificações do Yellow Paper da EVM
O Yellow Paper(opens in a new tab) é uma especificação formal do Ethereum. Exceto onde alterado pelo processo EIP, ele contém a descrição exata de como tudo funciona. Ele foi escrito como um documento matemático, que inclui terminologia que programadores podem não achar familiar. Nesse documento você aprende como lê-lo, e por extensão outros documentos matemáticos relacionados.
Qual Yellow Paper?
Como quase tudo mais no Ethereum, o Yellow Paper evolui com o tempo. Para ser capaz de se referir a uma versão específica, eu fiz o upload da versão atual. A seção, página e números de equação que eu uso irão se referir a esta versão. É uma boa ideia tê-lo aberto em uma janela diferente enquanto você lê esse documento.
Por que a EVM?
O yellow paper original foi escrito logo no começo do desenvolvimento do Ethereum. Ele descreve o mecanismo de consenso original baseado em proof-of-work que foi originalmente usado para proteger a rede. Entretanto, o Ethereum desligou o proof-of-work e começou a usar consenso baseado em proof-of-stake em setembro de 2022. Este tutorial focará nas partes do yellow paper que definem a Máquina Virtual Ethereum. A EVM ficou inalterada pela transição para proof-of-stake (exceto pelo valor de retorno do opcode DIFFICULTY).
Modelo de execução 9
Esta seção (pág. 12.14) inclui a maioria da definição da EVM.
O termo estado do sistema inclui tudo que você precisa saber sobre o sistema para rodá-lo. Em um computador comum, isto significa memória, 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 habilidade de executar computações que um computador normal tem (tudo que um computador pode calcular, uma máquina de Turing pode calcular, e vice-versa). Este modelo facilita provar 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 rodar os mesmos cálculos que uma máquina de Turing. Máquinas de Turing pode entrar em laços infinitos, e a EVM não pode porque ela irá ficar sem gas, então é somente quase-Turing-completa.
Básico 9.1
Esta seção fornece o básico sobre EVM e como ela se equipara a outros modelos computacionais.
A máquina de pilha(opens in a new tab) é um computador que armazena dados intermediários não em registros, mas em uma pilha(opens in a new tab). Esta é a arquitetura preferida para máquinas virtuais porque é fácil de implementar, significando que bugs e a vulnerabilidades de segurança são bem menos prováveis. A memória na pilha é dividida em palavras de 256-bit. Esta escolha foi tomada por ser a mais conveniente às operações criptográficas do núcleo do Ethereum, como as computações de hash Keccak-256 e curva elíptica. O tamanho máximo da pilha é 1.024 bytes. Quando opcodes são executados, eles geralmente estão pegando seus parâmetros da pilha. Há opcodes especificamente para reorganizar elementos na pilha, como POP
(remove item do topo da pilha), DUP_N
(N-ésimo item duplicado na pilha), etc.
A EVM também tem um espaço volátil chamado memory que é usado para armazenar dados durante execução. Esta memória é organizada em palavras de 32-byte. Todas as locações de memória são inicializadas em zero. Se você executar este código Yul(opens in a new tab) para adicionar uma palavra na memória, ele irá preencher 32 bytes de memória preenchendo o espaço vazio na palavra com zeros, ou seja, ele cria uma palavra - com zeros nos locais 0-29, 0x60 a 30, e 0xA7 a 31.
1mstore(0, 0x60A7)
mstore
é um dos três opcodes que a EVM fornece para interação 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 separado não volátil chamado storage, que é mantido como parte do estado do sistema - esta memória é organizada em arrays de palavras (ao contrário de arrays de byte endereçáveis por palavra na pilha). Esta storage é quando contratos mantém dados resistentes - um contrato pode somente interagir com o seu própria storage. Storage é organizado em mapeamentos chave-valor.
Apesar de não ser mencionado nessa seção do Yellow Paper, é útil também saber que há um quarto tipo de memória. Calldata é uma memória endereçável por byte, somente de leitura, usada para armazenar o valor passado com o parâmetro data
da transação. A EVM tem opcodes específicos para gerenciamento de calldata
. calldatasize
retorna o tamanho dos dados. calldataload
carrega os dados na pilha. calldatacopy
copia os dados na memória.
A arquitetura Von Neumann(opens in a new tab) armazena código e dados na mesma memória. A EVM não segue este padrão por razões de segurança - compartilhar memória volátil torna possível mudar o código do programa. Ao invés disso, o código é gravado na storage.
Há apenas dois casos em que o código é executado da memória:
- Quando um contrato cria outro contrato (usando
CREATE
(opens in a new tab) ouCREATE2
(opens in a new tab)), o código do construtor do contrato vem da memória. - Durante a criação de qualquer contrato, o código do construtor roda e então retorna com o código do contrato real, também da memória.
O termo execução excepcional significa uma exceção que causa a interrupção da execução do contrato atual.
9.2 Visão geral de taxas
Esta seção explica como as taxas de gas são calculadas. Há três custos:
Custo de opcode
O custo herdado de um opcode específico. Para obter este valor, encontre o grupo de custo do opcode no apêndice H (p. 28, sob equação (327)), e encontre o grupo de custo na equação (324). Isto te dá uma função do 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 de opcode para este grupo é Gverylow+Gcopy×⌈μs[2]÷32⌉. Olhando no apêndice G, nós vemos que ambas constantes são 3, o que nos dá 3+3×⌈μs[2]÷32⌉.
Nós ainda precisamos decifrar a expressão ⌈μs[2]÷32⌉. A parte mais de fora, ⌈ \<valor> ⌉ é a função ceiling, uma função em que é dado valor onde retorna o menor inteiro que ainda não é menor que o valor. Por exemplo, ⌈2.5⌉ = ⌈3⌉ = 3. A parte mais interna é μs[2]÷32. Olhando na 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. Colocando tudo junto, parece que μs[2] é a locação número 2 na pilha. Olhando o opcode(opens in a new tab), a locação número dois na pilha é o tamanho do dado em bytes. Olhando em outros opcodes do grupo Wcopy, CODECOPY
(opens in a new tab) e RETURNDATACOPY
(opens in a new tab), eles também têm um tamanho de dado na mesma locação. Então,⌈μs[2]÷32⌉ é o número de 32 byte necessário para armazenar o dado sendo copiado. Colocando tudo junto, o custo herdado de CALLDATACOPY
(opens in a new tab) é 3 gas mais 3 por palavra de dado sendo copiada.
Custo de execução
O custo de rodar o código que nós estamos chamando.
- No caso do
CREATE
(opens in a new tab) eCREATE2
(opens in a new tab), o construtor para o novo contrato. - No caso do
CALL
(opens in a new tab),CALLCODE
(opens in a new tab),STATICCALL
(opens in a new tab), ouDELEGATECALL
(opens in a new tab), o contrato que nós chamamos.
Expandindo o custo de memória
O custo de expandir a memória (se necessário).
Na equação 324, este valor é escrito como Cmem(μi')-Cmem(μi). Olhando na seção 9.4.1 novamente, nós vemos que μi é o número de palavras na memória. Então, μ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 floor, uma função que dado um valor, retorna o maior inteiro que ainda não é maior que o valor. Por exemplo, ⌊2.5⌋ = ⌊2⌋ = 2. Quando a < √512, a2 < 512, e o resultado da função floor é zero. Então para as primeiras 22 palavras (704 bytes), o custo sobe linearmente com o número de palavras na memória necessárias. Além desse ponto ⌊a2 ÷ 512⌋ é positivo. Quando a memória necessária é grande o suficiente, o custo de gas é proporcional ao quadrado da quantidade de memória.
Note que estes fatores somente influenciam o custo de gas herdado - ele não leva em conta a taxa de mercado ou gorjetas aos validadores que determinam quanto um usuário final precisa pagar - isto é apenas o custo líquido de rodar uma operação particular na EVM.
Ambiente de execução 9.3
O ambiente de execução é uma tupla, I, que inclui informações que não são parte do estado do blockchain ou da EVM.
Parâmetro | Opcode para acessar o dado | Código Solidity para acessar o dado |
---|---|---|
Ia | ADDRESS (opens in a new tab) | address(this) |
Io | ORIGIN (opens in a new tab) | tx.origin |
Ip | GASPRICE (opens in a new tab) | tx.gasprice |
Id | CALLDATALOAD (opens in a new tab), etc. | msg.data |
Is | CALLER (opens in a new tab) | msg.sender |
Iv | CALLVALUE (opens in a new tab) | msg.value |
Ib | CODECOPY (opens in a new tab) | address(this).code |
IH | Campos do cabeçalho do bloco, como NUMBER (opens in a new tab) e DIFFICULTY (opens in a new tab) | block.number , block.difficulty , etc. |
Ie | Profundidade da pilha de chamada para chamadas entre contratos (incluindo criação de contrato) | |
Iw | A EVM tem permissão de mudar de estado, ou está rodando estaticamente |
Alguns outros poucos parâmetros são necessários para entender o resto da seção 9:
Parâmetro | Definido na seção | Significado |
---|---|---|
σ | 2 (p. 2, equação 1) | O estado do blockchain |
g | 9.3 (p. 13) | Gas remanescente |
A | 6.1 (p. 8) | Substrato acumulado (muda agendado para quando a transação termina) |
o | 9.3 (p. 13) | Saída - o resultado retornado no caso de transação interna (quando um contrato chama outro) e chamadas para funções view (quando você está apenas perguntando por informação, então não há necessidade de esperar pela transação) |
Visão geral da execução 9.4
Agora que temos todas as preliminares, nós podemos finalmente começar a trabalhar como a EVM trabalha.
Equações 137-142 nos dá as condições iniciais para rodar a EVM:
Símbolo | Valor inicial | Significado |
---|---|---|
μg | g | Gas remanescente |
μpc | 0 | Contador do programa, o endereço da próxima instrução para executar |
μm | (0, 0, ...) | Memória, inicializada toda com zeros |
μi | 0 | Maior locação de memória usada |
μs | () | A pilha, inicialmente vazia |
μo | ∅ | O resultado, conjunto vazio até e, a não ser que nós paremos ou com os dados de retorno (RETURN (opens in a new tab) ou REVERT (opens in a new tab)) ou sem ele (STOP (opens in a new tab) ou SELFDESTRUCT (opens in a new tab)). |
A equação 143 nos conta que há quatro condições possíveis em cada ponto no tempo durante a execução, e o que fazer com elas:
Z(σ,μ,A,I)
. Z representa uma função que testa se uma operação cria uma transição de estado inválida (veja parada excepcional). Se ele for avaliado para True, o novo estado é idêntico ao antigo (exceto o gas que foi queimado) porque as mudanças não foram implementadas.- Se o the opcode sendo executado é
REVERT
(opens in a new tab), o novo estado é o mesmo que o antigo estado, algum gas é perdido. - Se a sequência de operações for finalizada, como significa um
RETURN
(opens in a new tab)), o estado é atualizado para o novo estado. - Se não estivermos em uma das condições finais 1-3, continua rodando.
Estado da Máquina 9.4
Esta seção explica o estado da máquina em maiores detalhes. Ela especifica que w é o opcode atual. Se μpc é menor que ||Ib||, o tamanho do código, então aquele byte (Ib[μpc]) é o opcode. Caso contrário, o opcode é definido como STOP
(opens in a new tab).
Como esse é uma máquina de pilha(opens in a new tab), nós precisamos rastrear o número de itens que apareceram (δ) e empurraram em (α) por cada opcode.
Interrupção excepcional 9.4.2
Esta seção define a função Z, a qual especifica quando nós temos uma terminação anormal. Isto é 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).
Nós temos uma parada excepcional se qualquer destas condições for verdadeira:
μg < C(σ,μ,A,I) Como vimos na seção 9.2, C é a função que especifica o custo de gas. Não há gas suficiente deixado para cobrir o próximo opcode.
δw=∅ Se o número de itens que apareceram para um opcode é indefinido, então o opcode em si é indefinido.
|| μs || < δw Underflow de pilha, itens não 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 é umJUMPDEST
(opens in a new tab). Saltos são somente válidos quando o destino é umJUMPDEST
(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 (não zero) então o salto pode acontecer, e o endereço não é umJUMPDEST
(opens in a new tab). Saltos são somente válidos quando o destino é umJUMPDEST
(opens in a new tab).w = RETURNDATACOPY ∧ μs[1]+μs[2]>|| μo || O opcode é
RETURNDATACOPY
(opens in a new tab). Neste elemento da pilha de opcode μs[1] é o offset de onde se lê no buffer de retorno de dados, e elemento da pilha μs[2] é o tamanho do dado. A condição ocorre quando você tenta ler além do fim do buffer de dado de retorno. Note que não há uma condição similar para o calldata ou para o código ele mesmo. Quando você tentar ler além do fim destes buffers, você obtém somente zeros.|| μs || - δw + αw > 1024
Overflow de pilha. Se rodando o opcode resultar em uma pilha com mais de 1.024 itens, aborte.
¬Iw ∧ W(w,μ) Estamos rodando estaticamente (¬ é negação(opens in a new tab) eIw é verdade quando nós somos permitidos mudar o estado do blockchain)? Se sim, e nós estamos tentando mudar o estado da operação, ela pode acontecer.
A função W(w,μ) é definida mais tarde na equação 150. W(w,μ) é verdade se uma destas condições for verdadeira:
w ∈ {CREATE, CREATE2, SSTORE, SELFDESTRUCT} Estes opcodes mudando o estado, ou criando um novo contrato, armazenando valor, ou destruindo o contrato atual.
LOG0≤w ∧ w≤LOG4 Se nãos formos chamados estaticamente, nós não podemos emitir entradas de log. Os opcodes de log estão todos na faixa entre
LOG0
(A0)(opens in a new tab) eLOG4
(A4)(opens in a new tab). O número depois do opcode de log especifica quantos tópicos a entrada de log contém.w=CALL ∧ μs[2]≠0 Você pode chamar um outro contrato quando você está estático, mas se você o fizer, você não pode transferir ETH para ele.
w = SSTORE ∧ μg ≤ Gcallstipend Você não pode rodar
SSTORE
(opens in a new tab) a não ser que você tenha mais que Gcallstipend (definido como 2300 no apêndice G) gas.
Validade do Destino do Salto 9.4.3
Aqui nós definimos formalmente quais são os opcodes JUMPDEST
(opens in a new tab). Nós não podemos apenas procurar por valor de byte 0x5B, porque ele pode estar dentro de um PUSH (e, portanto, dado, não um opcode).
Na equação (153) nós definimos a função, N(i,w). O primeiro parâmetro, i, é a localização do opcode. A segunda, w, é o próprio opcode. Se w∈[PUSH1, PUSH32] que significa que o opcode é um PUSH (colchetes definem uma faixa que inclui os endpoints). Se esse caso, o próximo opcode é em i+2+(w−PUSH1). Para PUSH1
(opens in a new tab) nós precisamos avançar dois bytes (o PUSH propriamente dito e o valor de um byte), para PUSH2
(opens in a new tab) nós precisamos avançar três bytes porque é um valor de dois bytes, etc. Todos os outros opcodes EVM são 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), o qual é o conjunto(opens in a new tab) de todos as destinações válidas de salto no código c, começando com a localização do opcode i. Esta função é definida recursivamente. Se i≥||c||, isto significa que nós estamos no fim do código ou depois dele. Nós não vamos descobrir mais nenhuma destinação de salto, então apenas retorna um conjunto vazio.
Em todos os outros casos nós estamos olhando no resto do código indo para o próximo opcode e obtendo o conjunto iniciando dele. c[i] é o opcode atual, então N(i,c[i]) é a localização do próximo opcode. DJ(c,N(i,c[i])) é portanto o conjunto de destinos válidos de jump que começa no próximo opcode. Se o opcode atual não é umJUMPDEST
, apenas retorne aquele conjunto. Se ele é JUMPDEST
, inclua-o no conjunto de resultado e retorne-o.
Parada normal 9.4.4
A função halting H, pode retornar três tipos de valores.
- Se nós não estivermos em um opcode halt, retorne ∅, o conjunto vazio. Por convenção, este valor é interpretado como o falso booleano.
- Se nós temos um opcode halt que não produz saída (seja um
STOP
(opens in a new tab) ouSELFDESTRUCT
(opens in a new tab)), retorna uma sequência de tamanho zero bytes como valor de retorno. Note que isto é muito diferente do conjunto vazio. Este valor significa que a EVM realmente parou, apenas não há dados de retorno para ler. - Se nós tivermos um opcode de halt que produz sim saída (seja
RETURN
(opens in a new tab) ouREVERT
(opens in a new tab)), retorna a sequência de bytes especificada por este opcode. Esta sequência é pega da memória, o valor no topo da pilha (μs[0]) é o primeiro byte, e o valor depois dele (μs[1]) é o comprimento.
Conjunto de instruções H.2
Antes de nós irmos para a subseção final da EVM, 9.5, vamos ver as instruções propriamente ditas. Elas estão definidas no apêndice H.2 que começa na página 29. Qualquer coisa que não esteja especificada como mudança com este específico opcode é esperada que continue o mesmo. Variáveis que realmente mudam são especificadas como \<algumacoisa>′.
Por exemplo, vamos olhar o opcode ADD
(opens in a new tab).
Valor | Mnemônico | δ | α | Descrição |
---|---|---|---|---|
0x01 | ADD | 2 | 1 | Operação adição. |
μ′s[0] ≡ μs[0] + μs[1] |
δ é o número de valores que nós pegamos da pilha. Neste caso dois, porque nós estamos adicionando no topo dois valores.
α é o número de valores que nós retrocedemos. Neste caso um, a soma.
Então o novo topo da pilha (μ′s[0]) é a soma do velho topo da pilha (μs[0]) e o velho valor abaixo dele (μs[1]).
Ao invés de passar por todos os opcodes com olhos vidrados na lista, este artigo explica somente aqueles opcodes que introduzem algo novo.
Valor | Mnemônico | δ | α | Descrição |
---|---|---|---|---|
0x20 | KECCAK256 | 2 | 1 | Computa o hash Keccak-256. |
μ′s[0] ≡ KEC(μm[μs[0] . . . (μs[0] + μs[1] − 1)]) | ||||
μ′i ≡ M(μi,μs[0],μs[1]) |
Este é o primeiro opcode que acessa memória (nesse caso, somente leitura). Entretanto, ele pode expandir além dos limites atuais de memória, portanto nós precisamos atualizar μi. Nós fazemos isso usando a função M definida na equação 328 na pág. 29.
Valor | Mnemônico | δ | α | Descrição |
---|---|---|---|---|
0x31 | BALANCE | 1 | 1 | Obtém o saldo de dada conta. |
... |
O endereço destes saldos que nós precisamos encontrar é μs[0] mod 2160. O topo da pilha é o endereço, mas por endereços serem somente 160 bits, nós calculamos o valor modulo(opens in a new tab) 2160.
Se σ[μs[0] mod 2160] ≠ ∅, isto significa que há informação sobre este endereço. Neste caso, σ[μs[0] mod 2160]b é o saldo para aquele endereço. Se σ[μs[0] mod 2160] = ∅, significa que este endereço não está inicializado e que o saldo é zero. Você pode ver a lista de campos de informações de contas na seção 4.1 na página 4.
A segunda equação, A'a ≡ Aa ∪ {μs[0] mod 2160}, é relacionada com a diferença no custo entre acesso à warm storage (storage que foi recenteimente acessada e é provável que esteja em cache) e cold storage (storage que não tem sido acessada e provavelmente esteja em uma storage mais lenta, que é mais cara para se recuperar). Aa é a lista de endereços previamente acessados pela transação, que deveria, portanto ser de acesso mais barato, como definido na seção 6.1 na página 8. Você pode ler mais sobre este assunto em EIP-2929(opens in a new tab).
Valor | Mnemônico | δ | α | Descrição |
---|---|---|---|---|
0x8F | DUP16 | 16 | 17 | Duplica o 16o item da pilha. |
μ′s[0] ≡ μs[15] |
Note que para usar qualquer item da pilha, nós precisamos pegá-lo, o que significa que nós também precisamos pegar todos os itens da pilha acima dele. No caso de DUP<n>
(opens in a new tab) e SWAP<n>
(opens in a new tab), isto significa ter que pegar e então empurrar os dezesseis valores.
O ciclo de execução 9.5
Agora que nós temos todas as partes, nós podemos finalmente entender como o ciclo de execução da EVM é documentado.
A equação (155) diz que dado o estado:
- σ (estado global do blockchain)
- μ (estado da EVM)
- A (sub-estado, mudanças a acontecer quando a transação terminar)
- I (ambiente de execução)
O novo estado é (σ', μ', A', I').
Equações (156)-(158) definem a pilha e a mudança nela devido a um opcode (μs). Equação (159) é a mudança em gas (μg). Equação (160) é a mudança no contador do programa (μpc). Finalmente, equações (161)-(164) especificam que os outros parâmetros continuam iguais, salvo explicitamente mudados pelo opcode.
Com isto, a EVM está totalmente definida.
Conclusão
Notação matemática é precisa e tem permitido o Yellow Paper especificar cada detalhe do Ethereum. Entretanto, ela tem realmente algumas desvantagens:
- Ela só pode ser entendida por humanos, o que significa que testes de conformidade(opens in a new tab) devem ser escritos manualmente.
- Programadores entendem código de computador. Eles podem ou não entender notação matemática.
Talvez por estas razões, a mais nova especificação da camada de consenso(opens in a new tab) seja escrita em Python. Estas são especificações da camada de execução em Python(opens in a new tab), mas elas não estão completas. Até que, ou a não ser que, o Yellow Paper inteiro esteja também traduzido para Python ou linguagem similar, o Yellow Paper continuará em serviço, e é útil ser capaz de lê-lo.
Última edição: @wackerow(opens in a new tab), 26 de março de 2024