Deep EVM #1: Cómo la EVM Ejecuta Tu Código — Opcodes, Stack y Gas
Engineering Team
La EVM es una máquina de pila
La Máquina Virtual de Ethereum no es como el procesador x86 de tu portátil. No tiene registros. En cambio, es una máquina de pila (stack machine) — cada cálculo empuja hacia o extrae de una pila de 1024 elementos donde cada elemento es una palabra de 256 bits (32 bytes).
Cuando llamas a un contrato inteligente, la EVM recibe el bytecode del contrato — una secuencia plana de opcodes de un solo byte — y comienza a ejecutar desde el byte 0. No hay tabla de funciones, ni encabezado ELF, ni paso de enlace. El bytecode es el programa.
// Solidity:
// uint256 result = 2 + 3;
// Compila a bytecode:
// PUSH1 0x02 PUSH1 0x03 ADD
// Traza del stack:
// [] -> PUSH1 0x02 -> [2]
// [2] -> PUSH1 0x03 -> [2, 3]
// [2, 3] -> ADD -> [5]
Cada opcode consume sus operandos desde la parte superior del stack y empuja su resultado de vuelta. El opcode ADD extrae dos valores, los suma y empuja la suma. Esto es fundamentalmente diferente de las arquitecturas basadas en registros donde se especifican registros de origen y destino.
Categorías de opcodes
La EVM define aproximadamente 140 opcodes, agrupados en categorías funcionales:
Aritmética y comparación
- ADD, SUB, MUL, DIV, MOD — Aritmética básica de enteros de 256 bits. Todos cuestan 3 gas (el nivel G_verylow).
- SDIV, SMOD — División y módulo con signo usando complemento a dos.
- ADDMOD, MULMOD — Aritmética modular:
(a + b) % Ny(a * b) % Nen un solo opcode. Son críticos para operaciones de curvas elípticas y cuestan 8 gas. - EXP — Exponenciación. Cuesta 10 gas + 50 por byte en el exponente, siendo uno de los opcodes aritméticos más caros.
- LT, GT, SLT, SGT, EQ, ISZERO — Opcodes de comparación que empujan 1 (verdadero) o 0 (falso).
Operaciones a nivel de bits
- AND, OR, XOR, NOT — Lógica a nivel de bits, 3 gas cada uno.
- SHL, SHR, SAR — Desplazamiento a la izquierda, desplazamiento lógico a la derecha, desplazamiento aritmético a la derecha (añadidos en Constantinople, EIP-145).
- BYTE — Extrae un solo byte de una palabra de 32 bytes.
Manipulación del stack
- POP — Descarta el elemento superior.
- PUSH1 a PUSH32 — Empuja de 1 a 32 bytes de datos inmediatos al stack.
- DUP1 a DUP16 — Duplica el enésimo elemento del stack al tope.
- SWAP1 a SWAP16 — Intercambia el elemento superior con el enésimo elemento inferior.
Información del entorno y del bloque
- CALLER (msg.sender), CALLVALUE (msg.value), CALLDATALOAD, CALLDATASIZE, CALLDATACOPY — Acceso al contexto de la transacción.
- NUMBER, TIMESTAMP, BASEFEE, CHAINID — Información a nivel de bloque.
- BALANCE, EXTCODESIZE, EXTCODECOPY — Consultar otras cuentas.
El calendario de gas
Cada opcode tiene un coste de gas. El gas cumple dos propósitos: previene bucles infinitos (el problema de la detención) y establece precios justos para los recursos computacionales.
Los costes de gas se dividen en niveles:
| Nivel | Gas | Ejemplos |
|---|---|---|
| Cero | 0 | STOP, RETURN, REVERT |
| Base | 2 | ADDRESS, ORIGIN, CALLER |
| Muy bajo | 3 | ADD, SUB, LT, GT, AND, OR, POP |
| Bajo | 5 | MUL, DIV, MOD |
| Medio | 8 | ADDMOD, MULMOD, JUMP |
| Alto | 10 | JUMPI |
| Especial | variable | SLOAD, SSTORE, CALL, CREATE |
Los opcodes caros son los que tocan el estado:
// Costes de gas para acceso al estado (post EIP-2929):
// SLOAD (frío): 2100 gas
// SLOAD (caliente): 100 gas
// SSTORE (frío, 0->no-cero): 22100 gas
// SSTORE (caliente): 100 gas (+ 20000 si 0->no-cero)
// CALL (frío): 2600 gas
// CALL (caliente): 100 gas
Acceso frío vs caliente (EIP-2929)
EIP-2929 (actualización Berlin, abril 2021) introdujo el concepto de una lista de acceso — un conjunto por transacción de direcciones y slots de almacenamiento que han sido accedidos.
La primera vez que accedes a un slot de almacenamiento o dirección externa dentro de una transacción, está “frío” y cuesta gas extra. Los accesos posteriores están “calientes” y son baratos. Por eso el orden en que lees los slots de almacenamiento importa para la optimización de gas.
// En Solidity, este patrón es caro:
function mal() external view returns (uint256) {
uint256 a = myStorage; // Primera lectura: 2100 gas (frío)
// ... alguna lógica ...
uint256 b = myStorage; // Segunda lectura: 100 gas (caliente)
return a + b;
}
// Cachear en memoria:
function bien() external view returns (uint256) {
uint256 cached = myStorage; // 2100 gas (frío), solo una vez
return cached + cached; // 6 gas (ADD + DUP)
}
Flujo de ejecución: Qué sucede en una transacción
Cuando envías una transacción que llama a un contrato, esta es la secuencia completa:
- Validación de la transacción — Verificación de nonce, balance >= value + gas * gasPrice, verificación de firma.
- Deducción de gas intrínseco — 21000 gas por la transacción en sí, más 16 gas por byte de calldata no cero y 4 por byte cero.
- Configuración del contexto — La EVM crea un contexto de ejecución: código, calldata, caller, value, gas restante.
- El contador de programa comienza en 0 — La EVM lee el opcode en la posición 0 y lo ejecuta.
- Ejecución secuencial — Cada opcode se ejecuta, el gas se deduce. JUMP y JUMPI permiten flujo de control no lineal, pero solo a posiciones marcadas con JUMPDEST.
- Terminación — La ejecución termina con STOP (éxito, sin datos de retorno), RETURN (éxito, con datos), REVERT (fallo, estado revertido) o quedándose sin gas.
- Commit o rollback del estado — En caso de éxito, todos los cambios de estado se confirman. En caso de revert, todos los cambios se revierten.
El contador de programa y JUMP
El contador de programa (PC) es un registro implícito que rastrea la posición actual en el bytecode. La mayoría de los opcodes avanzan el PC en 1 (o en 1 + N para opcodes PUSH). Dos opcodes modifican el PC directamente:
- JUMP — Extrae un destino del stack, establece el PC a ese valor. El destino debe contener un opcode JUMPDEST o la transacción se revierte.
- JUMPI — Salto condicional. Extrae destino y condición. Si la condición es distinta de cero, salta; de lo contrario continúa secuencialmente.
Así es como la EVM implementa if/else, bucles y despacho de funciones.
Sub-llamadas: CALL, STATICCALL, DELEGATECALL
Los contratos pueden invocar otros contratos usando tres opcodes de llamada:
- CALL — Llamada estándar. Crea un nuevo contexto de ejecución con su propio stack y memoria.
- STATICCALL — Llamada de solo lectura (EIP-214). Cualquier opcode que modifique el estado dentro del callee causa un revert inmediato.
- DELEGATECALL — Ejecuta el código del callee pero en el contexto de almacenamiento del caller. msg.sender y msg.value se preservan. Así funcionan los patrones de proxy y las bibliotecas.
Implicaciones prácticas para MEV
Si estás construyendo bots de MEV, entender la EVM a nivel de opcode no es opcional — es un requisito competitivo. Cada unidad de gas ahorrada en la ejecución de tu bot es margen de beneficio.
- Simula antes de enviar — Usa
eth_callo una EVM local (revm, EVMONE) para trazar la ejecución. - Minimiza accesos fríos — Pre-calienta slots de almacenamiento vía listas de acceso (EIP-2930).
- Usa STATICCALL para lecturas — Ligeramente más barato y garantiza que no muta estado.
- Conoce tus costes de opcodes — Un solo SLOAD mal ubicado puede costar 2100 gas.
Conclusión
La EVM es elegante en su simplicidad: una máquina de pila con palabras de 256 bits, un formato de bytecode plano y un sistema de medición de gas que pone precio a cada operación. Entender esta base — opcodes, el stack y el gas — es el prerrequisito para todo lo que sigue en esta serie: layout de memoria, optimización de almacenamiento, primitivas de seguridad, y finalmente escribir Yul y Huff crudos.