Explicación de las especificaciones de la EVM del Yellow Paper
El Yellow Paper(opens in a new tab) es la especificación formal de Ethereum. Excepto donde esté modificado por el proceso de EIP, contiene la descripción exacta de cómo funciona todo. Está escrito como un papel matemático que incluye términos que podrían no ser tan familiares para los programadores. En este papel aprenderá cómo leerlo y, por extensión, otros papeles matemáticos relacionados.
¿Qué Yellow Paper?
Como sucede con casi todo en Ethereum, el Yellow Paper evoluciona conforme avanza el tiempo. Para hacer referencia a una versión específica, he publicado la versión actual al momento de escribir este artículo. Los números de sección, página y ecuación que utilizo se referirán a esa versión. Es una buena idea tenerlo abierto en una ventana diferente mientras le este documento.
¿Por qué la EVM?
La versión original del Yellow Paper se escribió al inicio del desarrollo de Ethereum. Describe el mecanismo de consenso original basado en prueba de trabajo que se usaba originalmente para asegurar la red. Sin embargo, Ethereum acabó con la prueba de trabajo y comenzó a utilizar el consenso basado en prueba de participación en septiembre de 2022. Este tutorial se enfocará en las partes del Yellow Paper que definen la Máquina Virtual de Ethereum (EVM). La EVM no resultó modificada por el cambio a la prueba de participación (a excepción del valor de retorno del código de operación DIFFICULTY).
9. Modelo de ejecución
Esta sección (p. 12-14) incluye la mayor parte de la definición de la EVM.
El término estado de sistema incluye todo lo que necesita saber sobre el sistema para ejecutarlo. En una computadora típica, esto significa la memoria, los registros de contenido, etc.
Una máquina de Turing(opens in a new tab) es un modelo computacional. Esencialmente, es una versión simplificada de una computadora que, según se ha probado, cuenta con la misma capacidad de realizar cálculos que una computadora normal (todo lo que una computadora puede calcular una máquina de Turing puede calcular y viceversa). Este modelo facilita probar varios teoremas sobre qué es y qué no es computable.
El término Turing-complete(opens in a new tab) hace referencia a una computadora que puede realizar los mismos cálculos que una máquina de Turing. Las máquinas de Turing pueden entrar en bucles infinitos, y la EVM no, porque el gas se agotaría, por lo que sería solo quasi-Turing-complete.
9.1. Fundamentos básicos
Esta sección proporciona los fundamentos básicos de la Máquina Virtual de Ethereum (EVM) y cómo se compara con otros modelos computacionales.
Una máquina apiladora(opens in a new tab), o stack machine, es una computadora que almacena datos intermedios no en registros, sino en una pila(opens in a new tab). Esta es la arquitectura preferida para máquinas virtuales porque es sencilla de implementar, lo que significa que los errores y las vulnerabilidades de seguridad son menos probables. La memoria en la pila se divide en palabras de 256 bits. Esto se eligió porque es conveniente para las operaciones criptográficas centrales de Ethereum como el hash Keccak-256 y los cómputos de curva elíptica. El tamaño máximo de una pila es de 1024 bytes. Cuando se ejecutan códigos de operación (opcodes), estos usualmente reciben sus parámetros de la pila. Hay códigos de operación específicos para reorganizar elementos en la pila, tales como POP
(elimina un objeto de la parte superior de la pila), DUP_N
(elemento enésimo duplicado en la pila), etc.
La EVM también cuenta con un espacio volátil llamado memoria que es utilizado para almacenar datos durante la ejecución. Esta memoria está organizada en palabras de 32 bytes. Todas las ubicaciones de memoria están inicializadas en cero. Si ejecuta este código Yul(opens in a new tab) para agregar una palabra en la memoria, este completará 32 bytes de memoria rellenando el espacio vacío en la palabra con ceros, es decir, crea una palabra con ceros en las ubicaciones 0-29, 0x60 a 30 y 0xA7 a 31.
1mstore(0, 0x60A7)
mstore
es uno de los tres códigos de operación proporcionados por la EVM para interactuar con la memoria: carga una palabra en la memoria. Los otros dos son mstore8
, que carga un único byte en la memoria, y mload
, que mueve una palabra de la memoria a la pila.
La EVM también tiene un modelo de almacenamiento no volátil que es mantenido como parte del estado del sistema; esta memoria se organiza en conjuntos de palabras (a diferencia de las matrices de bytes direccionables por palabras en la pila). Este almacenamiento es donde los contratos guardan datos persistentes; un contrato solo puede interactuar con su propio almacenamiento. El almacenamiento se organiza en asignaciones o mapeos clave-valor.
Aunque no se menciona en esta sección del Yellow Paper, también es útil conocer que hay un cuarto tipo de memoria. Calldata es una memoria de solo lectura direccionable por bytes utilizada para almacenar el valor transmitido con el parámetro data
de una transacción. La EVM tiene códigos de operación específicos para gestionar calldata
. calldatasize
devuelve el tamaño de los datos. calldataload
carga los datos en la pila. calldatacopy
copia los datos en la memoria.
La arquitectura Von Neumann(opens in a new tab) estándar almacena código y datos en la misma memoria. La EVM no sigue este estándar por razones de seguridad: compartir memoria volátil hace posible el cambio del código del programa. En vez de eso, el código se guarda en el almacenamiento.
Solo hay dos casos donde el código es ejecutado desde la memoria:
- Cuando un contrato crea otro contrato (utilizando
CREATE
(opens in a new tab) oCREATE2
(opens in a new tab)), el código para el constructor del contrato viene de la memoria. - Durante la cración de cualquier contrato, el código del constructor se ejecuta y luego devuelve el código del contrato real, también desde la memoria.
El término ejecución excepcional significa una excepción que hace que la ejecución del contrato actual se detenga.
9.2. Resumen de las tarifas
Esta sección explica cómo se calculan las tarifas de gas. Hay tres costos:
Costo de códigos de operación
El costo inherente del código de operación específico. Para obtener este valor, busque el grupo de costo del código de operación en el Apéndice H (p. 28, debajo de la ecuación (327)) y busque el grupo de costo en la ecuación (324). Esto le proporcionará una función de costo, que en la mayoría de los casos utiliza parámetros del Apéndice G (p. 27).
Por ejemplo, el código de operación CALLDATACOPY
(opens in a new tab) es miembro del grupo Wcopy. El costo del código de operación para ese grupo es Gverylow+Gcopy×⌈μs[2]÷32]. Revisando el Apéndice G, podemos ver que ambas constantes son 3, lo que nos da 3+3×⌈μs[2]÷32⌉.
Todavía necesitamos descifrar la expresión ⌈μs[2]÷32⌉. La parte más externa, ⌈ \<value> ⌉, es la función de techo, una función que, al darle un valor, devuelve el entero más pequeño que no sea más pequeño que el valor. Por ejemplo, ⌈2.5⌉ = ⌈3⌉ = 3. La parte interior es μs[2]÷32. Viendo la sección 3 (Convenciones) en la p. 3, μ es el estado de la máquina. El estado de la máquina es definido en la sección 9.4.1 de la p. 13. De acuerdo con esa sección, uno de los parámetros de estado de la máquina es s para la pila. Al colocar todos juntos, parece que μs[2] es la posición n.º 2 en la pila. Viendo el código de operación(opens in a new tab), la posición 2 en la pila es el tamaño de los datos en bytes. Viendo los otros códigos de operación en el grupo Wcopy, CODECOPY
(opens in a new tab) y RETURNDATACOPY
(opens in a new tab), también tienen un tamaño de datos en la misma ubicación. Entonces, ⌈μs[2]÷32⌉ es la cantidad de palabras de 32 bytes requerida para almacenar la información copiada. Colocando todo junto, el costo inherente de CALLDATACOPY
(opens in a new tab) es de 3 gas más 3 por palabra de datos copiados.
Costo de ejecución
El costo de ejecutar el código que estamos llamando.
- En el caso de
CREATE
(opens in a new tab) yCREATE2
(opens in a new tab), el constructor para el nuevo contrato. - En el caso de
CALL
(opens in a new tab),CALLCODE
(opens in a new tab),STATICCALL
(opens in a new tab) oDELEGATECALL
(opens in a new tab), el contrato que llamamos.
Costo de expandir la memoria
El costo de expandir la memoria (si es necesario).
En la ecuación 324, este valor se escribe como Cmem(μi')-Cmem(μi). Mirando la sección 9.4.1 nuevamente, vemos que μi es la cantidad de palabras en la memoria. Así que μi es la cantidad de palabras en la memoria antes del código de operación, y μi' es la cantidad de palabras en la memoria luego del código de operación.
La función Cmem es definida en la ecuación 326: Cmem(a) = Gmemory × a + ⌊a2 ÷ 512⌋. ⌊x⌋ es la función de piso, una función que, al darle un valor, devuelve el entero más grande que no sea más grande que el valor. Por ejemplo, ⌊2.5⌋ = ⌊2⌋ = 2. Cuando a < √512, a2 < 512 y el resultado de la función de piso es cero. Así, para las primeras 22 palabras (704 bytes), el costo aumenta de manera lineal con la candidad requerida de palabras en la memoria. Más allá de ese punto, ⌊a2 ÷ 512⌋ es positivo. Cuando la memoria requerida es suficientemente alta, el costo del gas es proporcional a la cantidad de memoria elevada al cuadrado.
Note que estos factores solo influyen en el costo inherente del gas; no se toman en cuenta el mercado de tarifas o las propinas a los validadores que determinan cuánto debe pagar el usuario final; esto es solo el costo bruto de ejecutar una operación en particular en la EVM.
9.3. Entorno de ejecución
El entorno de ejecución es una tupla, I, que incluye información que no es parte del estado de la cadena de bloques o la EVM.
Parámetro | Código de operación para acceder a los datos | Código de Solidity para acceder a los datos |
---|---|---|
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 de encabezado de bloque, como NUMBER (opens in a new tab) y DIFFICULTY (opens in a new tab) | block.number , block.difficulty , etc. |
Ie | Profundidad de la pila de llamadas para llamadas entre contratos (incluida la creación de contratos) | |
Iw | ¿La EVM tiene permitido cambiar de estado o se está ejecutando estáticamente? |
Algunos otros parámetros son necesarios para comprender el resto de la sección 9:
Parámetro | Definido en la sección | Significado |
---|---|---|
σ | 2 (p. 2, ecuación 1) | El estado de la cadena de bloques |
g | 9.3 (p. 13) | Gas restante |
A | 6.1 (p. 8) | Subestado acumulado (cambios programados para cuando la transacción finalice) |
o | 9.3 (p. 13) | Salida: el resultado devuelto en caso de transacción interna (cuando un contrato llama a otro) y llamadas a funciones de visualización (cuando simplemente pregunta por información, por lo que no hay necesidad de esperar por una transacción) |
9.4. Descripción general de ejecución
Ahora que tenemos todas las cuestiones preliminares, finalmente podemos empezar a trabajar en cómo funciona la EVM.
Las ecuaciones 137-142 nos brindan las condiciones iniciales para ejecutar la EVM:
Símbolo | Valor inicial | Significado |
---|---|---|
μg | g | Gas restante |
μpc | 0 | Contador del programa, la dirección de la siguiente instrucción a ejecutar |
μm | (0, 0, ...) | Memoria, inicializada en todos ceros |
μi | 0 | Ubicación más alta de memoria usada |
μs | () | La pila, inicialmente vacía |
μo | ∅ | La salida, vacía hasta y a menos que nos detengamos con datos de devolución (RETURN (opens in a new tab) o REVERT (opens in a new tab)) o sin ellos (STOP (opens in a new tab) o SELFDESTRUCT (opens in a new tab)). |
La ecuación 143 nos dice que hay cuatro posibles condiciones en cada momento específico durante la ejecución y qué se debe hacer con ellas:
Z(σ,μ,A,I)
. Z representa una función que prueba si una operación crea una transición de estado no válida (ver detención excepcional). Si se evalúa como True, el nuevo estado es idéntico al anterior (excepto que se quema/usa gas) porque los cambios no se han implementado.- Si el código de operación ejecutado es
REVERT
(opens in a new tab), el nuevo estado es el mismo que el anterior, se pierde algo de gas. - Si la secuencia de operaciones es finalizada, como lo indica un
RETURN
(opens in a new tab)), el estado es actualizado al nuevo. - Si no estamos en una de las condiciones finales 1-3, continuar con la ejecución.
9.4.1. Estado de la máquina
Esta sección explica el estado de la máquina con mayor detalle. Especifica que w es el actual código de operación. Si μpc es menor que ||Ib||, la longitud del código, entonces ese byte (Ib[μpc]) es el código de operación. De lo contrario, el código de operación es definido como STOP
(opens in a new tab).
Como esta es una máquina de pila(opens in a new tab), necesitamos mantener un registro de la cantidad de objetos que salieron (δ) y entraron (α) a causa de cada código de operación.
9.4.2. Detención excepcional
Esta sección define la función Z, que especifica cuando tenemos una finalización anormal. Se trata de una función booleana(opens in a new tab), así que usa ∨ para un o lógico(opens in a new tab) y ∧ para un y lógico(opens in a new tab).
Tenemos una detención excepcional si cualquiera de estas condiciones es verdadera:
μg < C(σ,μ,A,I) Como vimos en la sección 9.2, C es la función que especifica el costo del gas. No hay suficiente gas para cubrir el siguiente código de operación.
δw=∅ Si el número de elementos que aparecen para un código de operación no está definido, entonces el código de operación en sí no está definido.
|| μs || < δw Desbordamiento de la pila, no hay suficientes elementos en la pila para el actual código de operación.
w = JUMP ∧ μs[0]∉D(Ib) El código de operación es
JUMP
(opens in a new tab) y la dirección no es unJUMPDEST
(opens in a new tab). Los saltos solo son válidos cuando el destino es unJUMPDEST
(opens in a new tab).w = JUMPI ∧ μs[1]≠0 ∧ μs[0] ∉ D(Ib) El código de operación es
JUMPI
(opens in a new tab), la condición es verdadera (no es cero), por lo que el salto debería ocurrir, y la dirección no es unJUMPDEST
(opens in a new tab). Los saltos solo son válidos cuando el destino es unJUMPDEST
(opens in a new tab).w = RETURNDATACOPY ∧ μs[1]+μs[2]>|| μo || El código de operación es
RETURNDATACOPY
(opens in a new tab). En este código de operación, el elemento de pila μs[1] es el desplazamiento desde donde leer en el búfer de datos de retorno, y el elemento de pila μs[2] es la longitud de datos. Esta condición ocurre cuando intenta leer más allá del fin del búfer de datos de retorno. Note que no hay una condición similar para la llamada de datos o para el código en sí. Cuando trata de leer más allá del final de esos búferes obtiene ceros.|| μs || - δw + αw > 1024
Desbordameinto de pila. Si la ejecución del código de operación resultará en una pila con más de 1024 elementos, abortar.
¬Iw ∧ W(w,μ) ¿Estamos corriendo estáticamente (¬ es negación(opens in a new tab) y Iw es verdadero cuando tenemos permitido cambiar el estado de la cadena de bloques)? Si es así y estamos intentando una operación de cambio de estado, esto no puede suceder.
La función W(w,μ) es definida más tarde en la ecuación 150. W(w,μ) es verdadero si una de estas condiciones es verdadera:
w ∈ {CREATE, CREATE2, SSTORE, SELFDESTRUCT} Estos códigos de operación cambian el estado, ya sea creando un nuevo contrato, almacenando un valor o destruyendo el contrato actual.
LOG0≤w ∧ w≤LOG4 Si somos llamados estáticamente, no podemos emitir entradas de registro. Los código de operación del registro están todos en un rango entre
LOG0
(A0)(opens in a new tab) yLOG4
(A4)(opens in a new tab). El número que figura luego del código de operación del registro especifica cuántos temas contiene la entrada de registro.w=CALL ∧ μs[2]≠0 Puede invocar otro contrato cuando está estático, pero, si lo hace, no puede transferir ETH a este.
w = SSTORE ∧ μg ≤ Gcallstipend No puede correr
SSTORE
(opens in a new tab), a menos que tenga más que Gcallstipend (definido como 2300 en el Apéndice G) gas.
9.4.3. Validez de destino de salto
Aquí definimos formalmente qué son los códigos de operación JUMPDEST
(opens in a new tab). No podemos simplemente buscar el valor de byte 0x5B, porque podría estar dentro de un PUSH (y, por lo tanto, datos y no un código de operación).
En la ecuación (153) definimos una función, N(i,w). El primer parámetro, i, es la ubicación del código de operación. El segundo, w, es el código de operación en sí. Si w∈[PUSH1, PUSH32], eso significa que el código de operación es un PUSH (los corchetes definen un rango que incluye los extremos). En ese caso el siguiente código de operación está en i+2+(w−PUSH1). Para PUSH1
(opens in a new tab) necesitamos avancar de a dos bytes (el propio PUSH y el valor de un byte), para PUSH2
(opens in a new tab) necesitamos avanzar de a tres bytes, porque es un valor de dos bytes, etc. Todos los demás códigos de operación de la EVM son solo de un byte de longitud, así que en todos los otros casos N(i,w)=i+1.
Esta función se usa en la ecuación (152) para definir DJ(c,i), que corresponde al conjunto(opens in a new tab) de todos los destinos de salto válidos en el código c, comenzando con la ubicación del código de operación i. Esta función es definida de manera recursiva. En caso de ser i≥||c||, significa que nos encontramos en o después del final del código. No encontraremos más destinos de salto, por lo que solo devolvemos el conjunto vacío.
En todos los otros casos, nos fijamos en el resto del código dirigiéndonos al siguiente código de operación y obteniendo el conjunto que se inicia desde este. c[i] es el actual código de operación, así que N(i,c[i]) es la ubicación del siguiente código de operación. DJ(c,N(i,c[i])) es, por lo tanto, el conjunto de destinos de salto válidos que inicia en el siguiente código de operación. Si el actual código de operación no es un JUMPDEST
, solo devolvemos ese conjunto. Si es JUMPDEST
, debemos incluirlo en el conjunto de resultados y devolverlo.
9.4.4. Detención normal
La función de detención H puede devolver tres tipos de valores.
- Si no estamos en un código de operación de detención, devolver ∅, el conjunto vacío. Por costumbre, este valor es interpretado como un Booleano falso.
- Si tenemos un código de operación de detención que no produce una salida (ya sea
STOP
(opens in a new tab) oSELFDESTRUCT
(opens in a new tab)), devolver una secuencia con tamaño de cero bytes como el valor de devolución. Note que esto es muy diferente al conjunto vacío. Este valor significa que la EVM realmente se ha detenido, solo que no hay datos de devolución para leer. - Si tenemos un código de operación de detención que produce una salida (ya sea
RETURN
(opens in a new tab) oREVERT
(opens in a new tab)), devolver la secuencia de bytes especificada por ese código de operación. Esta secuencia es tomada de la memoria, el valor en la parte superior de la pila (μs[0]) es el primer byte, y el valor luego de este (μs[1]) es la longitud.
H.2. Conjunto de instrucciones
Antes de ir a la subsección final de la EVM, 9.5, veamos las instrucciones en sí. Están definidas en el Apéndice H.2 que comienza en la p. 29. Todo lo que no esté especificado que debe cambiar con ese código de operación debe continuar igual. Las variables que sí cambian están especificadas como \<algo>'.
Por ejemplo, veamos el código de operación ADD
(opens in a new tab).
Valor | Nemotecnia | δ | α | Descripción |
---|---|---|---|---|
0x01 | ADD | 2 | 1 | Operación de suma. |
μ′s[0] ≡ μs[0] + μs[1] |
δ es la cantidad de valores que resaltamos de la pila. En este caso dos, porque estamos agregando los dos valores de la parte superior.
α es la cantidad de valores que enviamos de regreso. En este caso uno, la suma.
Entonces la nueva parte superior de la pila (μ′s[0]) es la suma de la anterior parte superior de la pila (μs[0]) y el valor anterior debajo de esta (μs[1]).
En lugar de repasar todos los códigos de operación con una "lista de ojos vidriosos", este artículo explica solo aquellos códigos de operación que introducen algo nuevo.
Valor | Nemotecnia | δ | α | Descripción |
---|---|---|---|---|
0x20 | KECCAK256 | 2 | 1 | Computación del hash Keccak-256. |
μ′s[0] ≡ KEC(μm[μs[0] . . . (μs[0] + μs[1] − 1)]) | ||||
μ′i ≡ M(μi,μs[0],μs[1]) |
Este es el primer código de operación que accede a la memoria (en este caso, solo lectura). Sin embargo, podría expandirse más allá de los límites actuales de la memoria, por lo que necesitamos actualizar μi. Esto lo hacemos usando la función M, definida en la ecuación 328 de la p. 29.
Valor | Nemotecnia | δ | α | Descripción |
---|---|---|---|---|
0x31 | BALANCE | 1 | 1 | Obtener el saldo de la cuenta proporcionada. |
... |
La dirección cuyo saldo necesitamos encontrar es μs[0] mod 2160. La parte superior de la pila es la dirección, pero, debido a que las direcciones solo son de 160 bits, calculamos el valor modulo(opens in a new tab)2160.
Si σ[μs[0] mod 2160] ≠ ∅, significa que hay información sobre esta dirección. En ese caso, σ[μs[0] mod 2160]b es el saldo de esa dirección. Si σ[μs[0] mod 2160] = ∅, significa que esta dirección no está inicializada y el saldo es cero. Puede ver el listado de campos de información de la cuenta en la sección 4.1 de la p. 4.
La segunda ecuación, A'a ≡ Aa ∪ {μs[0] mod 2160}, está relacionada con la diferencia en costo entre el acceso al almacenamiento en caliente (almacenamiento al que se ha accedido recientemente y es probable que esté almacenado en caché) y el almacenamiento en frío (almacenamiento al que no se ha accedido y es probable que esté en almacenamiento más lento que es más caro de recuperar). Aa es el listado de direcciones accesadas previamente por la transacción, que deberían por lo tanto ser más baratas de acceder, como se define en la sección 6.1 de la p. 8. Puede leer más sobre este tema en EIP-2929(opens in a new tab).
Valor | Nemotecnia | δ | α | Descripción |
---|---|---|---|---|
0x8F | DUP16 | 16 | 17 | Duplicar el decimosexto elemento de la pila. |
μ′s[0] ≡ μs[15] |
Note que para usar cualquier elemento de la pila, necesitamos resaltarlo (pop), lo que significa que también necesitamos resaltar todos los elementos de la pila arriba de este. En el caso de DUP<n>
(opens in a new tab) y SWAP<n>
(opens in a new tab), esto significa tener que resaltar y después empujar hasta dieciséis valores.
9.5. El ciclo de ejecución
Ahora que tenemos todas las partes, finalmente podemos comprender cómo el ciclo de ejecución de la EVM es documentado.
La ecuación (155) dice que dado el estado:
- σ (estado de la cadena de bloques global)
- μ (estado de la EVM)
- A (subestado, cambios que sucederán cuando la transacción finaliza)
- I (entorno de ejecución)
El nuevo estado es (σ', μ', A', I').
Las ecuaciones (156)-(158) definen la pila y el cambio en esta debido a un código de operación (μs). La ecuación (159) es el cambio en el gas (μg). La ecuación (160) es el cambio en el contador del programa (μpc). Finalmente, las ecuaciones (161)-(164) especifican que los otros parámetros permanecen iguales, a menos que sean explícitamente cambiados por el código de operación.
Con esto, la EVM está completamente definida.
Conclusión
La notación matemática es precisa y permite que el Yellow Paper especifique cada detalle de Ethereum. Sin embargo, tiene algunas desventajas:
- Solo puede ser comprendida por humanos, lo que implica que las pruebas de cumplimiento(opens in a new tab) se deben escribir manualmente.
- Los programadores comprenden el código computacional. Pueden comprender o no la notación matemática.
Quizá por estas razones, las nuevas especificaciones de capas de consenso(opens in a new tab) están escritas en Python. Hay especificaciones de capas de ejecución en Python(opens in a new tab), pero no están completas. Hasta y a menos que todo el Yellow Paper también se traduzca a Python o un lenguaje similar, el Yellow Paper continuará en servicio y es útil saber leerlo.
Última edición: @wackerow(opens in a new tab), 26 de marzo de 2024