Vai al contenuto principale

Comprendere le specifiche EVM dello Yellow Paper

evm
Intermedio
qbzzt
15 maggio 2022
19 minuti letti minute read

Lo Yellow Paper(opens in a new tab) è la specifica formale di Ethereum. Tranne che per le modifiche apportate dal processo EIP, contiene la descrizione esatta di come funziona il tutto. È scritto come un documento matematico, che include una terminologia con cui i programmatori potrebbero non avere familiarità. In questo documento si impara a leggerlo e, per estensione, a leggere altri documenti matematici correlati.

Quale Yellow Paper?

Come quasi tutto in Ethereum, lo Yellow Paper evolve nel tempo. Per poter fare riferimento a una versione specifica, ho caricato la versione corrente al momento della redazione. I numeri di sezione, pagina ed equazione che utilizzerò si riferiranno a quella versione. Sarebbe buona norma aprirla in un'altra finestra durante la lettura di questo documento.

Perché la EVM?

Lo Yellow Paper originale è stato scritto subito all'inizio dello sviluppo di Ethereum. Descrive il meccanismo di consenso basato sul proof-of-work originariamente utilizzato per proteggere la rete. Tuttavia, Ethereum ha abbandonato il proof-of-work e ha iniziato a utilizzare il consenso basato sul proof-of-stake nel settembre 2022. Questo tutorial si concentra sulle parti dello Yellow Paper che definiscono la macchina virtuale di Ethereum. La EVM è rimasta invariata con il passaggio al proof-of-stake (ad eccezione del valore di restituzione dell’opcode DIFFICULTY).

9 Modello di esecuzione

Questa sezione (pagg. 12-14) comprende la maggior parte della definizione della EVM.

Il termine stato del sistema comprende tutto ciò che è necessario sapere sul sistema per farlo funzionare. In un tipico computer, ciò significa la memoria, il contenuto dei registri, ecc.

Una macchina di Turing(opens in a new tab) è un modello computazionale. In sostanza, si tratta di una versione semplificata di un computer, che ha dimostrato di avere la stessa capacità di eseguire calcoli di un normale computer (una macchina di Turing può calcolare tutto ciò che può calcolare un computer e viceversa). Questo modello facilita la dimostrazione di vari teoremi su cosa è e cosa non è computabile.

Il termine

Turing equivalente/a> indica un computer che può eseguire gli stessi calcoli di una macchina di Turing. Le macchine di Turing possono entrare in loop infiniti, mentre l'EVM non può farlo perché finirebbe il carburante, quindi è solo quasi Turing equivalente.

9.1 Nozioni di base

Questa sezione fornisce le nozioni di base della EVM e come si compara ad altri modelli computazionali.

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

L'EVM dispone anche di 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 si esegue questo codice Yul(opens in a new tab) per aggiungere una parola alla memoria, esso riempirà 32 byte di memoria riempiendo lo spazio vuoto della parola con degli zeri, ossia creando una parola con degli zeri nelle posizioni 0-29, 0x60-30 e 0xA7-31.

1mstore(0, 0x60A7)

mstore è uno dei tre opcode che la EVM mette a disposizione 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.

La EVM ha anche un modello separato di archiviazione non volatile che viene mantenuta come parte dello stato del sistema; questa memoria è organizzata in un array di parole (al contrario degli array di byte con indirizzamento a parola nello stack). Questa archiviazione è il luogo in cui i contratti conservano i dati permanenti. 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, è utile sapere che esiste un quarto tipo di memoria. Calldata è una memoria di sola lettura con indirizzamento a byte utilizzata per memorizzare il valore trasmesso con il parametro data di una transazione. L'EVM dispone di opcode specifici per la gestione di calldata. calldatasize restituisce la dimensione dei dati. calldataload carica i dati nello stack. calldatacopy copia i dati in memoria.

L'architettura Von Neumann(opens in a new tab) standard 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. Il codice viene invece salvato nell’archiviazione.

Esistono 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 viene dalla memoria.
  • Durante la creazione di qualsiasi contratto, il codice del costruttore viene eseguito e poi restituisce il codice del contratto vero e proprio, anch'esso dalla memoria.

Con il termine esecuzione eccezionale si intende un'eccezione che causa l'interruzione dell'esecuzione del contratto attivo.

9.2 Panoramica delle commissioni

Questa sezione spiega come vengono calcolate le commissioni del carburante. I costi sono tre:

Costo dell’opcode

Il costo intrinseco dello specifico opcode. Per ottenere questo valore, trova il gruppo di costo dell’opcode nell'Appendice H (pag. 28, sotto l'equazione (327)) e trova il gruppo di costo nell'equazione (324). Si ottiene così una funzione di costo, che nella maggior parte dei casi utilizza i parametri dell'Appendice G (pag. 27).

Ad esempio, l'opcode CALLDATACOPY(opens in a new tab) fa parte del gruppo Wcopy. Il costo dell'opcode per questo 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 ceiling: una funzione che, dato un valore, restituisce il più piccolo numero intero che non è ancora minore del valore stesso. Ad esempio, ⌈2,5⌉ = ⌈3⌉ = 3. La parte interna è μs[2]÷32. Guardando la sezione 3 (Convenzioni) a pag. 3, μ è lo stato della macchina. Lo stato della macchina è definito nella sezione 9.4.1 a pag. 13. Secondo questa sezione, uno dei parametri di stato della macchina è s per lo stack. Mettendo tutto insieme, sembra che μs[2] sia la posizione #2 dello stack. Guardando l'opcode(opens in a new tab), la posizione #2 dello stack è la dimensione dei dati in byte. Osservando gli altri opcode del gruppo Wcopy, CODECOPY(opens in a new tab) e RETURNDATACOPY(opens in a new tab), anch'essi hanno una dimensione di 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 unità di carburante più 3 per ogni parola di dati copiata.

Costi di esecuzione

Il costo dell'esecuzione del codice che stiamo chiamando.

Costo di espansione della memoria

Il costo per espandere la memoria (se necessario).

Nell'equazione 324, questo valore è scritto come Cmemi')-Cmemi). Se guardiamo 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) = Gmemoria × a + ⌊a2 ÷ 512⌋. ⌊x⌋ è la funzione floor; una funzione che, dato un valore, restituisce il più grande numero intero che non è ancora maggiore del valore stesso. Ad esempio, ⌊2,5⌋ = ⌊2⌋ = 2, Quando a < √512, a2 < 512, il risultato della funzione floor è 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 carburante è proporzionale al quadrato della quantità di memoria.

Si noti che questi fattori influenzano solo il costo intrinseco del carburante - 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 specifica operazione sulla EVM.

Maggiori informazioni sul carburante.

9.3 Ambiente di esecuzione

L'ambiente di esecuzione è una tupla, I, che include informazioni che non fanno parte dello stato della blockchain o della 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 di 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 (compresa la creazione del contratto)
IwLa EVM può cambiare stato o funziona in modo statico?

Per comprendere il resto della sezione 9 sono necessari alcuni altri parametri:

ParametroDefinito nella sezioneSignificato
σ2 (pag. 2, equazione 1)Lo stato della blockchain
g9.3 (pag. 13)Carburante rimanente
A6.1 (pag. 8)Sottostato accumulato (modifiche previste per la fine della transazione)
o9.3 (pag. 13)Output: il risultato restituito in caso di transazione interna (quando un contratto ne invoca un altro) e di chiamate a funzioni di visualizzazione (quando si chiedono solo informazioni, quindi non è necessario attendere una transazione)

9.4 Panoramica dell'esecuzione

Ora che possediamo tutte le conoscenze preliminari, possiamo finalmente iniziare a lavorare sul funzionamento della EVM.

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

SymbolValore inizialeSignificato
μggCarburante rimanente
μpc0Contatore del programma, l'indirizzo della prossima istruzione da eseguire
μm(0, 0, ...)Memoria, inizializzata a tutti zero
μi0Posizione di memoria più alta utilizzata
μs()Lo stack, inizialmente vuoto
μoL'output, un insieme vuoto fino a quando e a meno che ci si fermi, con i dati restituiti (RETURN(opens in a new tab) o REVERT(opens in a new tab)) o senza (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 dell'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 (vedere arresto eccezionale). Se la valutazione è True, il nuovo stato è identico al vecchio (tranne per il carburante che viene bruciato), perché le modifiche non sono state implementate.
  2. Se l'opcode eseguito è REVERT(opens in a new tab), il nuovo stato è uguale a quello vecchio, ma si perde un po' di carburante.
  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 di interruzione 1-3, continua a eseguire il codice.

9.4.1 Stato della macchina

Questa sezione spiega in modo più dettagliato lo stato della macchina. Specifica che w è l'opcode attuale. 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 (δ) e introdotti (α) da ciascun opcode.

9.4.2 Arresto eccezionale

Questa sezione definisce la funzione Z, che specifica quando si ha un'interruzione anomala. Si tratta di 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 di queste condizioni è True:

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

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

  • || μs || < δw Sottoeccedenze 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 è True (non 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 il buffer dei dati restituiti e l'elemento dello stack μs[2] è la lunghezza dei dati. Questa condizione si verifica quando si cerca di leggere oltre la fine del buffer dei dati restituiti. Si noti che non esiste una condizione simile per il calldata o per il codice stesso. Quando si cerca di leggere oltre la fine di quei buffer, si ottengono solo degli zeri.

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

    Sottoeccedenza dello stack. Se l'esecuzione dell'opcode comporta uno stack di oltre 1024 elementi, interrompere l'operazione.

  • ¬Iw ∧ W(w,μ) Stiamo eseguendo staticamente (¬ è la negazione(opens in a new tab) e Iw è True quando ci è permesso di cambiare lo stato della blockchain)? Se è così, e stiamo tentando un'operazione di cambio di stato, essa non può verificarsi.

    La funzione W(w,μ) è definita successivamente nell'equazione 150. W(w,μ) è True se una di queste condizioni è True:

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

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

    • w=CALL ∧ μs[2]≠0 È possibile chiamare un altro contratto quando si è statici, ma in tal caso non è possibile trasferirvi ETH.

  • w = SSTORE ∧ μg ≤ Gcallstipend Non è possibile eseguire SSTORE(opens in a new tab) se non si ha più di Gcallstipend (definito come 2300 nell'Appendice G) carburante.

9.4.3 Validità della destinazione del salto

Qui definiamo formalmente cosa sono gli opcode JUMPDEST(opens in a new tab). Non possiamo limitarci a cercare il valore del byte 0x5B, perché potrebbe trovarsi all'interno di un PUSH (e quindi di dati e non di 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 endpoint). In questo caso l'opcode successivo 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 da due byte, ecc. Tutti gli altri opcode dell'EVM sono lunghi un solo 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 del codice oppure oltre. Non troveremo altre destinazioni di salto, quindi verrà restituito semplicemente l'insieme vuoto.

In tutti gli altri casi si guarda al resto del codice andando all'opcode successivo e ottenendo l'insieme a partire da esso. c[i] è l'opcode corrente, quindi N(i,c[i]) è la posizione del prossimo opcode. DJ(c,N(i,c[i])) è quindi l'insieme delle destinazioni di salto valide che inizia dall'opcode successivo. Se l'opcode corrente non è un JUMPDEST, restituisce semplicemente l'insieme. Se è JUMPDEST, viene incluso nell'insieme dei risultati e restituito.

9.4.4 Arresto normale

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

  • Se non siamo in un opcode di arresto, restituisce , l'insieme vuoto. Per convenzione, questo valore viene interpretato come un False booleano.
  • Se abbiamo un opcode di arresto che non produce un output (o STOP(opens in a new tab) o SELFDESTRUCT(opens in a new tab)), restituisce una sequenza di byte di dimensione zero come valore restituito. Si noti che questo è molto diverso dall'insieme vuoto. Questo valore significa che l'EVM si è davvero arrestata, ma non ci sono dati restituiti da leggere.
  • Se abbiamo un opcode di arresto che produce un output (o RETURN(opens in a new tab) o REVERT(opens in a new tab)), restituisce la sequenza di byte specificata da quell'opcode. Questa sequenza viene presa dalla memoria, il valore in cima allo stack(μs[0]) è il primo byte e il valore successivo (μs[1]) è la lunghezza.

H.2 Insieme di istruzioni

Prima di passare alla sottosezione finale della EVM, la 9.5, esaminiamo le istruzioni stesse. Sono definite nell'Appendice H.2 che inizia a pag. 29. Tutto ciò che non è specificato come variabile con quello specifico opcode deve rimanere inalterato. Le variabili che cambiano sono specificate con \<something>′.

Ad esempio, esaminiamo l'opcode ADD(opens in a new tab).

ValoreMnemonicaδα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 a quest'ultima (μs[1]).

Invece di passare in rassegna tutti gli opcode con una "noiosissima lista", questo articolo spiega solo gli opcode che introducono qualcosa di nuovo.

ValoreMnemonicaδα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, di sola lettura). Tuttavia, potrebbe espandersi oltre i limiti attuali della memoria, quindi è necessario aggiornare μi. Per farlo, si utilizza la funzione M definita nell'equazione 328 a pag. 29.

ValoreMnemonicaδαDescrizione
0x31BALANCE11Ottiene il saldo del conto indicato.
...

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

Se σ[μs[0] mod 2160] ≠ ∅, significa che esistono informazioni su questo indirizzo. In questo 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. L'elenco dei campi informativi del conto è riportato nella sezione 4.1 a pag. 4.

La seconda equazione, A'a ≡ Aa ∪ {μs[0] mod 2160}, è relativa alla differenza di costo tra l'accesso all'archiviazione calda (archiviazione a cui si è acceduto di recente e che probabilmente è memorizzata nella cache) e all'archiviazione fredda (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 precedentemente consultati dalla transazione, che quindi dovrebbero essere meno costosi da raggiungere, come definito nella sezione 6.1 a pag. 8. Per ulteriori informazioni su questo argomento, puoi consultare EIP-2929(opens in a new tab).

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

Si noti che per utilizzare un elemento dello stack è necessario estrarlo, il che significa che è necessario estrarre anche tutti gli elementi dello stack che si trovano sopra di esso. Nel caso di DUP<n>(opens in a new tab) e SWAP<n>(opens in a new tab), ciò 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 viene documentato il ciclo di esecuzione della EVM.

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

  • σ (stato globale della blockchain)
  • μ (stato della EVM)
  • A (sottostato, modifiche da apportare al termine della transazione)
  • I (ambiente di esecuzione)

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

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

Con ciò la EVM è completamente definita.

Conclusioni

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

  • Può essere compreso 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. Non è detto che comprendano la notazione matematica.

Forse per queste ragioni, le più recenti specifiche del livello di consenso(opens in a new tab) sono scritte in Python. Esistono 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à ad essere utilizzato ed è utile saperlo leggere.

Ultima modifica: @Fuliggine(opens in a new tab), 12 febbraio 2024

Questo tutorial è stato utile?