Vai al contenuto principale

Comprendere le Specifiche dell'EVM dello Yellow Paper

evm
Intermedio
qbzzt
15 maggio 2022
19 minuti di lettura

Lo Yellow Paper (opens in a new tab) è la specifica formale di Ethereum. Tranne dove modificato dal processo delle EIP, contiene la descrizione esatta di come funziona tutto. È scritto come un documento matematico, che include una terminologia che i programmatori potrebbero non trovare familiare. In questo documento imparerai come leggerlo e, per estensione, altri documenti matematici correlati.

Quale Yellow Paper?

Come quasi tutto il resto in Ethereum, lo Yellow Paper si evolve nel tempo. Per poter fare riferimento a una versione specifica, ho caricato la versione attuale al momento della stesura. I numeri di sezione, pagina ed equazione che utilizzo faranno riferimento a quella versione. È una buona idea tenerlo aperto in un'altra finestra durante la lettura di questo documento.

Perché l'EVM?

Lo yellow paper originale è stato scritto proprio all'inizio dello sviluppo di Ethereum. Descrive il meccanismo di consenso originale basato sulla prova di lavoro che veniva originariamente utilizzato per proteggere la rete. Tuttavia, Ethereum ha abbandonato la prova di lavoro e ha iniziato a utilizzare il consenso basato sulla prova di stake a settembre 2022. Questo tutorial si concentrerà sulle parti dello yellow paper che definiscono la macchina virtuale di Ethereum. L'EVM è rimasta invariata dalla transizione alla prova di stake (ad eccezione del valore di ritorno dell'opcode DIFFICULTY).

9 Modello di esecuzione

Questa sezione (p. 12-14) include la maggior parte della definizione dell'EVM.

Il termine stato del sistema include tutto ciò che devi sapere sul sistema per eseguirlo. In un computer tipico, questo significa la memoria, il contenuto dei registri, ecc.

Una macchina di Turing (opens in a new tab) è un modello computazionale. Essenzialmente, è una versione semplificata di un computer, che ha dimostrato di avere la stessa capacità di eseguire calcoli di un computer normale (tutto ciò che un computer può calcolare, una macchina di Turing può calcolarlo e viceversa). Questo modello rende più facile dimostrare vari teoremi su cosa è e cosa non è computabile.

Il termine Turing-completo (opens in a new tab) indica un computer che può eseguire gli stessi calcoli di una macchina di Turing. Le macchine di Turing possono entrare in cicli infiniti, e l'EVM non può perché esaurirebbe il gas, quindi è solo quasi-Turing-completa.

9.1 Basi

Questa sezione fornisce le basi dell'EVM e come si confronta con altri modelli computazionali.

Una macchina a stack (opens in a new tab) è un computer che memorizza i dati intermedi non nei registri, ma in uno stack (opens in a new tab) (pila). Questa è l'architettura preferita per le macchine virtuali perché è facile da implementare, il che significa che bug e vulnerabilità di sicurezza sono molto meno probabili. La memoria nello stack è divisa in parole (word) da 256 bit. Questa scelta è stata fatta perché è conveniente per le operazioni crittografiche principali di Ethereum, come l'hashing Keccak-256 e i calcoli sulle curve ellittiche. La dimensione massima dello stack è di 1024 elementi (1024 x 256 bit). Quando gli opcode vengono eseguiti, di solito ottengono i loro parametri dallo stack. Ci sono opcode specifici per riorganizzare gli elementi nello stack come POP (rimuove l'elemento dalla cima dello stack), DUP_N (duplica l'N-esimo elemento nello stack), ecc.

L'EVM ha anche uno spazio volatile chiamato memoria che viene utilizzato per memorizzare i dati durante l'esecuzione. Questa memoria è organizzata in parole da 32 byte. Tutte le posizioni di memoria sono inizializzate a zero. Se esegui questo codice Yul (opens in a new tab) per aggiungere una parola alla memoria, riempirà 32 byte di memoria riempiendo lo spazio vuoto nella parola con zeri, ovvero crea una parola - con zeri nelle posizioni 0-29, 0x60 nella 30 e 0xA7 nella 31.

1mstore(0, 0x60A7)

mstore è uno dei tre opcode che l'EVM fornisce per interagire con la memoria: carica una parola nella memoria. Gli altri due sono mstore8 che carica un singolo byte nella memoria, e mload che sposta una parola dalla memoria allo stack.

L'EVM ha anche un modello di storage (archiviazione) non volatile separato che viene mantenuto come parte dello stato del sistema: questa memoria è organizzata in array di parole (al contrario degli array di byte indirizzabili a parole nello stack). Questo storage è dove i contratti mantengono i dati persistenti: un contratto può interagire solo con il proprio storage. Lo storage è organizzato in mappature chiave-valore.

Sebbene non sia menzionato in questa sezione dello Yellow Paper, è anche utile sapere che esiste un quarto tipo di memoria. I Calldata sono una memoria di sola lettura indirizzabile a byte utilizzata per memorizzare il valore passato con il parametro data di una transazione. L'EVM ha opcode specifici per la gestione dei calldata. calldatasize restituisce la dimensione dei dati. calldataload carica i dati nello stack. calldatacopy copia i dati nella memoria.

L'architettura standard di Von Neumann (opens in a new tab) memorizza codice e dati nella stessa memoria. L'EVM non segue questo standard per motivi di sicurezza: la condivisione della memoria volatile rende possibile modificare il codice del programma. Invece, il codice viene salvato nello storage.

Ci sono solo due casi in cui il codice viene eseguito dalla memoria:

  • Quando un contratto crea un altro contratto (usando CREATE (opens in a new tab) o CREATE2 (opens in a new tab)), il codice per il costruttore del contratto proviene dalla memoria.
  • Durante la creazione di qualsiasi contratto, il codice del costruttore viene eseguito e poi ritorna con il codice del contratto effettivo, anch'esso dalla memoria.

Il termine esecuzione eccezionale indica un'eccezione che causa l'interruzione dell'esecuzione del contratto corrente.

9.2 Panoramica delle commissioni

Questa sezione spiega come vengono calcolate le commissioni del gas. Ci sono tre costi:

Costo dell'opcode

Il costo intrinseco dell'opcode specifico. Per ottenere questo valore, trova il gruppo di costo dell'opcode nell'Appendice H (p. 28, sotto l'equazione (327)) e trova il gruppo di costo nell'equazione (324). Questo ti dà una funzione di costo, che nella maggior parte dei casi utilizza i parametri dell'Appendice G (p. 27).

Ad esempio, l'opcode CALLDATACOPY (opens in a new tab) è un membro del gruppo Wcopy. Il costo dell'opcode per quel gruppo è Gverylow+Gcopy×⌈μs[2]÷32⌉. Guardando l'Appendice G, vediamo che entrambe le costanti sono 3, il che ci dà 3+3×⌈μs[2]÷32⌉.

Dobbiamo ancora decifrare l'espressione ⌈μs[2]÷32⌉. La parte più esterna, ⌈ <valore> ⌉ è la funzione soffitto (ceiling), una funzione che dato un valore restituisce il più piccolo intero che non è comunque minore del valore. Ad esempio, ⌈2.5⌉ = ⌈3⌉ = 3. La parte interna è μs[2]÷32. Guardando la sezione 3 (Convenzioni) a p. 3, μ è lo stato della macchina. Lo stato della macchina è definito nella sezione 9.4.1 a p. 13. Secondo quella sezione, uno dei parametri dello stato della macchina è s per lo stack. Mettendo tutto insieme, sembra che μs[2] sia la posizione #2 nello stack. Guardando l'opcode (opens in a new tab), la posizione #2 nello stack è la dimensione dei dati in byte. Guardando gli altri opcode nel gruppo Wcopy, CODECOPY (opens in a new tab) e RETURNDATACOPY (opens in a new tab), anch'essi hanno una dimensione dei dati nella stessa posizione. Quindi ⌈μs[2]÷32⌉ è il numero di parole da 32 byte necessarie per memorizzare i dati copiati. Mettendo tutto insieme, il costo intrinseco di CALLDATACOPY (opens in a new tab) è di 3 gas più 3 per ogni parola di dati copiata.

Costo di esecuzione

Il costo di esecuzione del codice che stiamo chiamando.

Costo di espansione della memoria

Il costo di espansione della memoria (se necessario).

Nell'equazione 324, questo valore è scritto come Cmemi')-Cmemi). Guardando di nuovo la sezione 9.4.1, vediamo che μi è il numero di parole nella memoria. Quindi μi è il numero di parole nella memoria prima dell'opcode e μi' è il numero di parole nella memoria dopo l'opcode.

La funzione Cmem è definita nell'equazione 326: Cmem(a) = Gmemory × a + ⌊a2 ÷ 512⌋. ⌊x⌋ è la funzione pavimento (floor), una funzione che dato un valore restituisce il più grande intero che non è comunque maggiore del valore. Ad esempio, ⌊2.5⌋ = ⌊2⌋ = 2. Quando a < √512, a2 < 512, e il risultato della funzione pavimento è zero. Quindi per le prime 22 parole (704 byte), il costo aumenta linearmente con il numero di parole di memoria richieste. Oltre quel punto ⌊a2 ÷ 512⌋ è positivo. Quando la memoria richiesta è abbastanza alta, il costo del gas è proporzionale al quadrato della quantità di memoria.

Nota che questi fattori influenzano solo il costo intrinseco del gas: non tengono conto del mercato delle commissioni o delle mance ai validatori che determinano quanto un utente finale è tenuto a pagare; questo è solo il costo grezzo dell'esecuzione di una particolare operazione sull'EVM.

Maggiori informazioni sul gas.

9.3 Ambiente di esecuzione

L'ambiente di esecuzione è una tupla, I, che include informazioni che non fanno parte dello stato della blockchain o dell'EVM.

ParametroOpcode per accedere ai datiCodice Solidity per accedere ai dati
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), ecc.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
IHCampi dell'intestazione del blocco, come NUMBER (opens in a new tab) e DIFFICULTY (opens in a new tab)block.number, block.difficulty, ecc.
IeProfondità dello stack di chiamate per le chiamate tra contratti (inclusa la creazione di contratti)
IwL'EVM è autorizzata a cambiare stato o è in esecuzione in modo statico

Alcuni altri parametri sono necessari per comprendere il resto della sezione 9:

ParametroDefinito nella sezioneSignificato
σ2 (p. 2, equazione 1)Lo stato della blockchain
g9.3 (p. 13)Gas rimanente
A6.1 (p. 8)Sottostato maturato (modifiche programmate per quando termina la transazione)
o9.3 (p. 13)Output: il risultato restituito nel caso di transazione interna (quando un contratto ne chiama un altro) e chiamate a funzioni di visualizzazione (quando si richiedono solo informazioni, quindi non c'è bisogno di aspettare una transazione)

9.4 Panoramica dell'esecuzione

Ora che abbiamo tutti i preliminari, possiamo finalmente iniziare a lavorare su come funziona l'EVM.

Le equazioni 137-142 ci forniscono le condizioni iniziali per l'esecuzione dell'EVM:

SimboloValore inizialeSignificato
μggGas rimanente
μpc0Program counter, l'indirizzo della prossima istruzione da eseguire
μm(0, 0, ...)Memoria, inizializzata tutta a zeri
μi0Posizione di memoria più alta utilizzata
μs()Lo stack, inizialmente vuoto
μoL'output, insieme vuoto fino a quando e a meno che non ci fermiamo con i dati di ritorno (RETURN (opens in a new tab) o REVERT (opens in a new tab)) o senza di essi (STOP (opens in a new tab) o SELFDESTRUCT (opens in a new tab)).

L'equazione 143 ci dice che ci sono quattro possibili condizioni in ogni momento durante l'esecuzione e cosa fare con esse:

  1. Z(σ,μ,A,I). Z rappresenta una funzione che verifica se un'operazione crea una transizione di stato non valida (vedi interruzione eccezionale). Se restituisce Vero, il nuovo stato è identico a quello vecchio (tranne per il gas che viene bruciato) perché le modifiche non sono state implementate.
  2. Se l'opcode in esecuzione è REVERT (opens in a new tab), il nuovo stato è uguale al vecchio stato, un po' di gas viene perso.
  3. Se la sequenza di operazioni è terminata, come indicato da un RETURN (opens in a new tab)), lo stato viene aggiornato al nuovo stato.
  4. Se non ci troviamo in una delle condizioni finali 1-3, continua l'esecuzione.

9.4.1 Stato della macchina

Questa sezione spiega lo stato della macchina in maggiore dettaglio. Specifica che w è l'opcode corrente. Se μpc è minore di ||Ib||, la lunghezza del codice, allora quel byte (Ibpc]) è l'opcode. Altrimenti, l'opcode è definito come STOP (opens in a new tab).

Poiché si tratta di una macchina a stack (opens in a new tab), dobbiamo tenere traccia del numero di elementi estratti (δ) e inseriti (α) da ciascun opcode.

9.4.2 Interruzione eccezionale

Questa sezione definisce la funzione Z, che specifica quando abbiamo una terminazione anomala. Questa è una funzione Booleana (opens in a new tab), quindi utilizza per un OR logico (opens in a new tab) e per un AND logico (opens in a new tab).

Abbiamo un'interruzione eccezionale se una qualsiasi di queste condizioni è vera:

  • μg < C(σ,μ,A,I) Come abbiamo visto nella sezione 9.2, C è la funzione che specifica il costo del gas. Non c'è abbastanza gas rimasto per coprire il prossimo opcode.

  • δw=∅ Se il numero di elementi estratti per un opcode non è definito, allora l'opcode stesso non è definito.

  • || μs || < δw Underflow dello stack, non ci sono abbastanza elementi nello stack per l'opcode corrente.

  • w = JUMP ∧ μs[0]∉D(Ib) L'opcode è JUMP (opens in a new tab) e l'indirizzo non è un JUMPDEST (opens in a new tab). I salti sono validi solo quando la destinazione è un JUMPDEST (opens in a new tab).

  • w = JUMPI ∧ μs[1]≠0 ∧ μs[0] ∉ D(Ib) L'opcode è JUMPI (opens in a new tab), la condizione è vera (diversa da zero) quindi il salto dovrebbe avvenire, e l'indirizzo non è un JUMPDEST (opens in a new tab). I salti sono validi solo quando la destinazione è un JUMPDEST (opens in a new tab).

  • w = RETURNDATACOPY ∧ μs[1]+μs[2]>|| μo || L'opcode è RETURNDATACOPY (opens in a new tab). In questo opcode l'elemento dello stack μs[1] è l'offset da cui leggere nel buffer dei dati di ritorno, e l'elemento dello stack μs[2] è la lunghezza dei dati. Questa condizione si verifica quando si tenta di leggere oltre la fine del buffer dei dati di ritorno. Nota che non esiste una condizione simile per i calldata o per il codice stesso. Quando provi a leggere oltre la fine di quei buffer ottieni semplicemente degli zeri.

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

    Overflow dello stack. Se l'esecuzione dell'opcode comporterà uno stack di oltre 1024 elementi, interrompi.

  • ¬Iw ∧ W(w,μ) Stiamo eseguendo in modo statico (¬ è la negazione (opens in a new tab) e Iw è vero quando ci è permesso cambiare lo stato della blockchain)? Se è così, e stiamo provando un'operazione di modifica dello stato, non può avvenire.

    La funzione W(w,μ) è definita più avanti nell'equazione 150. W(w,μ) è vera se una di queste condizioni è vera:

    • w ∈ {CREATE, CREATE2, SSTORE, SELFDESTRUCT} Questi opcode cambiano lo stato, creando un nuovo contratto, memorizzando un valore o distruggendo il contratto corrente.

    • LOG0≤w ∧ w≤LOG4 Se veniamo chiamati staticamente non possiamo emettere voci di log. Gli opcode di log sono tutti nell'intervallo tra LOG0 (A0) (opens in a new tab) e LOG4 (A4) (opens in a new tab). Il numero dopo l'opcode di log specifica quanti argomenti (topic) contiene la voce di log.

    • w=CALL ∧ μs[2]≠0 Puoi chiamare un altro contratto quando sei statico, ma se lo fai non puoi trasferirgli ETH.

  • w = SSTORE ∧ μg ≤ Gcallstipend Non puoi eseguire SSTORE (opens in a new tab) a meno che tu non abbia più di Gcallstipend (definito come 2300 nell'Appendice G) gas.

9.4.3 Validità della destinazione di salto

Qui definiamo formalmente quali sono gli opcode JUMPDEST (opens in a new tab). Non possiamo semplicemente cercare il valore del byte 0x5B, perché potrebbe trovarsi all'interno di un PUSH (e quindi essere un dato e non un opcode).

Nell'equazione (153) definiamo una funzione, N(i,w). Il primo parametro, i, è la posizione dell'opcode. Il secondo, w, è l'opcode stesso. Se w∈[PUSH1, PUSH32] significa che l'opcode è un PUSH (le parentesi quadre definiscono un intervallo che include gli estremi). In quel caso il prossimo opcode si trova a i+2+(w−PUSH1). Per PUSH1 (opens in a new tab) dobbiamo avanzare di due byte (il PUSH stesso e il valore di un byte), per PUSH2 (opens in a new tab) dobbiamo avanzare di tre byte perché è un valore di due byte, ecc. Tutti gli altri opcode dell'EVM sono lunghi solo un byte, quindi in tutti gli altri casi N(i,w)=i+1.

Questa funzione viene utilizzata nell'equazione (152) per definire DJ(c,i), che è l'insieme (opens in a new tab) di tutte le destinazioni di salto valide nel codice c, a partire dalla posizione dell'opcode i. Questa funzione è definita in modo ricorsivo. Se i≥||c||, significa che siamo alla fine o dopo la fine del codice. Non troveremo altre destinazioni di salto, quindi restituiamo semplicemente l'insieme vuoto.

In tutti gli altri casi guardiamo il resto del codice passando all'opcode successivo e ottenendo l'insieme a partire da esso. c[i] è l'opcode corrente, quindi N(i,c[i]) è la posizione dell'opcode successivo. DJ(c,N(i,c[i])) è quindi l'insieme delle destinazioni di salto valide che inizia all'opcode successivo. Se l'opcode corrente non è un JUMPDEST, restituisci semplicemente quell'insieme. Se è JUMPDEST, includilo nell'insieme dei risultati e restituiscilo.

9.4.4 Interruzione normale

La funzione di interruzione H, può restituire tre tipi di valori.

  • Se non siamo in un opcode di interruzione, restituisci , l'insieme vuoto. Per convenzione, questo valore viene interpretato come falso Booleano.
  • Se abbiamo un opcode di interruzione che non produce output (sia STOP (opens in a new tab) che SELFDESTRUCT (opens in a new tab)), restituisci una sequenza di byte di dimensione zero come valore di ritorno. Nota che questo è molto diverso dall'insieme vuoto. Questo valore significa che l'EVM si è davvero interrotta, solo che non ci sono dati di ritorno da leggere.
  • Se abbiamo un opcode di interruzione che produce output (sia RETURN (opens in a new tab) che REVERT (opens in a new tab)), restituisci la sequenza di byte specificata da quell'opcode. Questa sequenza è presa dalla memoria, il valore in cima allo stack (μs[0]) è il primo byte, e il valore dopo di esso (μs[1]) è la lunghezza.

H.2 Set di istruzioni

Prima di passare alla sottosezione finale dell'EVM, la 9.5, diamo un'occhiata alle istruzioni stesse. Sono definite nell'Appendice H.2 che inizia a p. 29. Tutto ciò che non è specificato come modificato con quello specifico opcode dovrebbe rimanere lo stesso. Le variabili che cambiano sono specificate come <qualcosa>′.

Ad esempio, diamo un'occhiata all'opcode ADD (opens in a new tab).

ValoreMnemonicoδαDescrizione
0x01ADD21Operazione di addizione.
μ′s[0] ≡ μs[0] + μs[1]

δ è il numero di valori che estraiamo dallo stack. In questo caso due, perché stiamo sommando i primi due valori.

α è il numero di valori che reinseriamo. In questo caso uno, la somma.

Quindi la nuova cima dello stack (μ′s[0]) è la somma della vecchia cima dello stack (μs[0]) e del vecchio valore sotto di essa (μs[1]).

Invece di esaminare tutti gli opcode con un "elenco da far incrociare gli occhi", questo articolo spiega solo quegli opcode che introducono qualcosa di nuovo.

ValoreMnemonicoδαDescrizione
0x20KECCAK25621Calcola l'hash Keccak-256.
μ′s[0] ≡ KEC(μms[0] . . . (μs[0] + μs[1] − 1)])
μ′i ≡ M(μis[0],μs[1])

Questo è il primo opcode che accede alla memoria (in questo caso, in sola lettura). Tuttavia, potrebbe espandersi oltre i limiti attuali della memoria, quindi dobbiamo aggiornare μi. Lo facciamo utilizzando la funzione M definita nell'equazione 328 a p. 29.

ValoreMnemonicoδαDescrizione
0x31BALANCE11Ottieni il saldo dell'account specificato.
...

L'indirizzo di cui dobbiamo trovare il saldo è μs[0] mod 2160. La cima dello stack è l'indirizzo, ma poiché gli indirizzi sono solo di 160 bit, calcoliamo il valore modulo (opens in a new tab) 2160.

Se σ[μs[0] mod 2160] ≠ ∅, significa che ci sono informazioni su questo indirizzo. In quel caso, σ[μs[0] mod 2160]b è il saldo per quell'indirizzo. Se σ[μs[0] mod 2160] = ∅, significa che questo indirizzo non è inizializzato e il saldo è zero. Puoi vedere l'elenco dei campi di informazione dell'account nella sezione 4.1 a p. 4.

La seconda equazione, A'a ≡ Aa ∪ {μs[0] mod 2160}, è correlata alla differenza di costo tra l'accesso allo storage caldo (storage a cui si è acceduto di recente ed è probabile che sia nella cache) e allo storage freddo (storage a cui non si è acceduto ed è probabile che si trovi in uno storage più lento e più costoso da recuperare). Aa è l'elenco degli indirizzi a cui la transazione ha precedentemente effettuato l'accesso, che dovrebbero quindi essere più economici da accedere, come definito nella sezione 6.1 a p. 8. Puoi leggere di più su questo argomento nella EIP-2929 (opens in a new tab).

ValoreMnemonicoδαDescrizione
0x8FDUP161617Duplica il 16° elemento dello stack.
μ′s[0] ≡ μs[15]

Nota che per utilizzare qualsiasi elemento dello stack, dobbiamo estrarlo, il che significa che dobbiamo anche estrarre tutti gli elementi dello stack sopra di esso. Nel caso di DUP<n> (opens in a new tab) e SWAP<n> (opens in a new tab), questo significa dover estrarre e poi reinserire fino a sedici valori.

9.5 Il ciclo di esecuzione

Ora che abbiamo tutte le parti, possiamo finalmente capire come è documentato il ciclo di esecuzione dell'EVM.

L'equazione (155) dice che dato lo stato:

  • σ (stato globale della blockchain)
  • μ (stato dell'EVM)
  • A (sottostato, modifiche che avverranno al termine della transazione)
  • I (ambiente di esecuzione)

Il nuovo stato è (σ', μ', A', I').

Le equazioni (156)-(158) definiscono lo stack e la sua modifica dovuta a un opcode (μs). L'equazione (159) è la variazione del gas (μg). L'equazione (160) è la variazione del program counter (μpc). Infine, le equazioni (161)-(164) specificano che gli altri parametri rimangono gli stessi, a meno che non vengano esplicitamente modificati dall'opcode.

Con questo l'EVM è completamente definita.

Conclusione

La notazione matematica è precisa e ha permesso allo Yellow Paper di specificare ogni dettaglio di Ethereum. Tuttavia, presenta alcuni svantaggi:

  • Può essere compresa solo dagli esseri umani, il che significa che i test di conformità (opens in a new tab) devono essere scritti manualmente.
  • I programmatori capiscono il codice informatico. Potrebbero o meno capire la notazione matematica.

Forse per questi motivi, le più recenti specifiche del livello di consenso (opens in a new tab) sono scritte in Python. Ci sono specifiche del livello di esecuzione in Python (opens in a new tab), ma non sono complete. Fino a quando e a meno che l'intero Yellow Paper non venga tradotto anche in Python o in un linguaggio simile, lo Yellow Paper continuerà a essere in servizio, ed è utile saperlo leggere.

Ultimo aggiornamento della pagina: 3 marzo 2026

Questo tutorial è stato utile?