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 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 (PoW) che veniva originariamente utilizzato per proteggere la rete. Tuttavia, Ethereum ha disattivato la Prova di lavoro e ha iniziato a utilizzare il consenso basato sulla Proof-of-Stake (PoS) a settembre 2022. Questo tutorial si concentrerà sulle parti dello yellow paper che definiscono la Macchina Virtuale di Ethereum (EVM). L'EVM è rimasta invariata dal passaggio alla Proof-of-Stake (ad eccezione del valore di ritorno del codice operativo (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, mentre 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). 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 da 256 bit. Questo è stato scelto perché è conveniente per le operazioni crittografiche principali di Ethereum, come l'hashing Keccak-256 e i calcoli sulla curva ellittica. La dimensione massima dello stack è di 1024 elementi (1024 x 256 bit). Quando i codici operativi (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.

mstore(0, 0x60A7)

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

L'EVM ha anche un modello di archiviazione (storage) 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). Questa archiviazione è dove i contratti conservano i dati persistenti: un contratto può interagire solo con la propria archiviazione. L'archiviazione è organizzata 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 dati di chiamata (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 gestire i calldata. calldatasize restituisce la dimensione dei dati. calldataload carica i dati nello stack. calldatacopy copia i dati in 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 la modifica del codice del programma. Invece, il codice viene salvato nell'archiviazione.

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 dello specifico opcode. 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 inferiore al 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) è 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 in memoria. Quindi μi è il numero di parole in memoria prima dell'opcode e μi' è il numero di parole in 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 superiore al 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 è sufficientemente 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.

Scopri di più 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 accumulato (modifiche programmate per quando termina la transazione)
o9.3 (p. 13)Output: il risultato restituito nel caso di una 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 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 arresto 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, si perde un po' di gas.
  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 è inferiore a ||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 (popped) (δ) e inseriti (pushed) (α) da ciascun opcode.

9.4.2 Arresto 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 arresto 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 (jump) 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 dati di chiamata (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,μ) Siamo in esecuzione 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 del 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 Arresto normale

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

  • Se non siamo in un opcode di arresto, restituisci , l'insieme vuoto. Per convenzione, questo valore viene interpretato come falso booleano.
  • Se abbiamo un opcode di arresto 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 fermata, solo che non ci sono dati di ritorno da leggere.
  • Se abbiamo un opcode di arresto 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 successivo (μ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 in fase di modifica con quello specifico opcode dovrebbe rimanere lo stesso. Le variabili che cambiano sono specificate con un <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 sottostante (μ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 di soli 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 all'archiviazione calda (warm storage, archiviazione a cui si è acceduto di recente e che probabilmente è nella cache) e all'archiviazione fredda (cold storage, archiviazione a cui non si è acceduto e che probabilmente si trova in un'archiviazione più lenta e più costosa 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 in 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 (pop), 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 (push) 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.