NUEVO: Helius adquiere Light Protocol
¿Qué es la Solana Virtual Machine (SVM)?
Blog/Fundamentos

¿Qué es la Solana Virtual Machine (SVM)?

Developer Experience Engineer0xIchigo en X0xIchigo en LinkedIn0xIchigo en GitHub
67 min de lectura

Muchas gracias a Lostin, Alessandro, Brian, Brady y Daniel Cumming por revisar versiones anteriores de este trabajo. 

Conclusiones prácticas

  • La SVM abarca toda la pila de ejecución de transacciones, a diferencia de la EVM, que se refiere inequívocamente a un ejecutor de bytecode.
  • Exigir que las transacciones declaren antes de ejecutarse a qué cuentas accederán permite la ejecución paralela entre núcleos de CPU y los mercados de comisiones locales.
  • El código fuente de Rust se compila mediante rustc a LLVM IR. Después, el backend eBPF de LLVM, específicamente el fork sBPF de Solana, lo convierte en bytecode sBPF. Esto significa que cualquier lenguaje con un frontend de LLVM (p. ej., C, C++, Zig) puede usarse para escribir programas de Solana.
  • sBPF es el fork de eBPF de Linux creado por Solana. A efectos prácticos, ambos son iguales, salvo por algunas adiciones históricas que se introdujeron. Sin embargo, está previsto revertirlas. La única diferencia relevante es que las funciones de eBPF upstream admiten un máximo de 5 argumentos, mientras que el fork de Solana puede admitir más.
  • El bytecode sBPF compilado se almacena en archivos ELF que contienen secciones para instrucciones y constantes, además de tablas de reubicación. El enlazador resuelve las referencias a syscalls como hashes Murmur3 deterministas de 32 bits y reescribe las funciones internas como saltos relativos para garantizar la portabilidad. 
  • BPF Loader Upgradeable usa un modelo de dos cuentas para actualizaciones y despliegues en el mismo lugar, mientras que el próximo Loader V4 lo simplifica a una sola cuenta con compresión opcional. Todo el bytecode se verifica estáticamente antes de marcarse como ejecutable.
  • Las transacciones contienen un arreglo de direcciones de cuentas con permisos de lectura y escritura, instrucciones y firmas. Este formato estructurado permite detectar conflictos y programar en paralelo las transacciones que no entren en conflicto.
  • Banking Stage de la TPU se encarga de programar en paralelo las transacciones que no entren en conflicto. Bank carga el estado de las cuentas desde AccountsDB. Los BPF Loaders aprovisionan VM sBPF aisladas con regiones de memoria delimitadas y presupuestos de cómputo. Los cambios de estado exitosos se confirman atómicamente, mientras que los fallos se revierten por completo.
  • La SVM ISA es la única especificación formal. Aparte del whitepaper de Alpenglow y la serie de artículos escritos inicialmente por Toly, no existe una única «especificación de la SVM». El entorno de ejecución surge de la interacción entre Bank, el programador, los BPF Loaders y la propia VM sBPF.

Introducción

La Solana Virtual Machine (SVM) es uno de los sistemas peor comprendidos en las blockchains actuales. A diferencia de la Ethereum Virtual Machine (EVM), que se refiere inequívocamente a un ejecutor de opcodes, el término SVM abarca toda una canalización de ejecución de transacciones, desde el programador de Banking Stage hasta el propio intérprete de bytecode sBPF. Esta ambigüedad refleja la diferencia arquitectónica de Solana: no existe una especificación tradicional que defina «la SVM» de forma aislada. La única especificación que se aproxima corresponde a la Solana Virtual Machine Instruction Set Architecture (SVM ISA), que describe cómo debe ejecutarse el bytecode sBPF, pero no dice nada sobre el entorno de ejecución más amplio.

Este artículo busca servir como referencia completa sobre qué es la SVM, cómo funciona y por qué es fundamentalmente diferente desde la perspectiva de la implementación del validador Agave de Anza. El análisis del funcionamiento del cliente de Firedancer y de su implementación personalizada de máquina virtual, que cumple con la SVM ISA, queda fuera del alcance de este trabajo.

Nos interesa examinar el código real, no una especificación abstracta. Seguimos toda la canalización de ejecución: cómo el código fuente de Rust se compila mediante LLVM a bytecode sBPF, cómo se despliegan y verifican los programas, cómo el entorno de ejecución aprovisiona entornos aislados para la ejecución paralela y cómo interactúan las transacciones con el bytecode desplegado.

Las primeras secciones contextualizan la ambigüedad de la SVM y ofrecen una descripción general de su funcionamiento. El resto del artículo está dirigido a un público más técnico que busca comprender rigurosamente la capa de ejecución de Solana.

Una definición controvertida

El término «Solana Virtual Machine» (SVM) ha provocado un intenso debate dentro de la comunidad, especialmente desde la aparición de extensiones de red y otras blockchains de capa superior construidas sobre Solana. La controversia surge del alcance del término: ¿la SVM es estrictamente el intérprete sBPF de bajo nivel o abarca toda la pila de ejecución de transacciones? 

La perspectiva estricta considera que la SVM es análoga a una máquina virtual (VM) tradicional, como el ejecutor de opcodes de la EVM. Más concretamente, es la máquina virtual derivada de eBPF (rBPF, ahora sBPF) que interpreta y compila bytecode mediante JIT. Esta perspectiva destaca a la SVM como un ejecutor aislado y basado en registros que procesa instrucciones, como operaciones de ALU o llamadas al sistema específicas de Solana. En esencia, la SVM se inspira en el modelo de seguridad de eBPF de Linux, pero está adaptada para la infraestructura blockchain. Esto concuerda con expresiones como SVM ISA (Instruction Set Architecture) en el código del validador, donde la SVM es únicamente la capa de VM.

La perspectiva amplia define la SVM como toda la capa de ejecución de transacciones de un validador de Solana. Esto incluye más que la ejecución de bytecode; también abarca componentes previos, como el programador de Banking Stage, la gestión del presupuesto de unidades de cómputo y las actualizaciones de estado mediante la base de datos de cuentas, conocida coloquialmente como AccountsDB. Es el «entorno de ejecución» que convierte transacciones sin procesar en cambios de estado validados.

La ambigüedad surge porque las comunicaciones oficiales de Solana usan los términos «entorno de ejecución» y «SVM» indistintamente, sin una definición única establecida. Anza ha aportado la claridad que tanto necesitaba el debate, al validar explícitamente esta ambigüedad y promover una perspectiva pragmática, orientada a la acción y basada en la ingeniería. Su planteamiento de la SVM como el entorno de ejecución controlado por Bank que aprovisiona la VM eBPF ofrece una visión mucho más amplia e inclusiva de toda la canalización, con la que puede formularse una definición adecuada de la SVM.

Esto se formaliza en la especificación oficial de la SVM de Anza, que define la SVM como «los componentes responsables de ejecutar transacciones», empaquetados como una biblioteca independiente para validadores, pruebas de fraude, sidecars y más.

Para nuestros fines, podemos definir la Solana Virtual Machine como:

La interfaz desacoplada del entorno de ejecución y la canalización de procesamiento de transacciones dentro de los validadores de Solana, controlada por el componente Bank, que coordina la ejecución paralela de instrucciones y programas onchain y aprovisiona una máquina virtual personalizada basada en eBPF para interpretar bytecode de forma segura, compilar mediante JIT y medir recursos.

La SVM de un vistazo

La Solana Virtual Machine (SVM) funciona como el entorno de ejecución para procesar transacciones que interactúan con programas onchain en toda la red. Es la capa del entorno de ejecución donde el código se encuentra con el estado: el entorno que transforma transacciones firmadas criptográficamente en cambios de estado validados. 

Para comprender realmente la SVM, primero debemos entender qué significa una máquina virtual en el contexto de las blockchains.

Máquinas virtuales

Una máquina virtual (VM) es un software que virtualiza o emula un sistema informático y proporciona un entorno de ejecución aislado que se comporta como hardware físico. El concepto surgió del trabajo de IBM en la década de 1960 sobre sistemas mainframe, que permitió a varios usuarios ejecutar distintos sistemas operativos en la misma máquina física. Hay dos categorías principales de VM: las VM de sistema y las VM de proceso. Las primeras sustituyen a una máquina real, mientras que las segundas están diseñadas para ejecutar programas en un entorno independiente de la plataforma. Para nuestros fines, nos interesan las máquinas virtuales de sistema. En adelante, las llamaremos «máquinas virtuales» o simplemente «VM».

Las máquinas virtuales resuelven varios problemas fundamentales. Para empezar, ofrecen un nivel de abstracción del hardware. Es decir, los programas escritos para una VM pueden ejecutarse en cualquier hardware físico compatible con ella, sin tener que reescribirlos. La filosofía de Java de «escribir una vez, ejecutar en cualquier lugar» ejemplifica esto, ya que el bytecode de Java se ejecuta de forma idéntica en Windows, macOS, Linux y otros sistemas que tengan instalada una Java Virtual Machine (JVM).

Las VM también ofrecen garantías de aislamiento y seguridad. Cada instancia de VM funciona en un entorno aislado, por lo que no puede acceder a los recursos del sistema host ni a otras VM, salvo que se le permita explícitamente. Por tanto, si un programa falla o contiene código malicioso, el daño queda limitado a esa instancia de VM. Este principio de aislamiento explica por qué proveedores de nube como Google Cloud y AWS usan VM para separar las cargas de trabajo de sus clientes.

Las VM también ofrecen resultados predecibles. Proporcionan un entorno controlado en el que la misma entrada siempre produce la misma salida, sin importar el hardware subyacente. Esta previsibilidad es crucial para depurar, probar y alcanzar consenso entre sistemas distribuidos.

Las VM también pueden ofrecer un rendimiento extremadamente alto. Las VM modernas usan compilación Just-In-Time (JIT) para minimizar la sobrecarga de rendimiento. La compilación JIT traduce el bytecode de la VM a código máquina nativo durante la ejecución para lograr un rendimiento casi nativo, a la vez que conserva la portabilidad y las garantías de seguridad mencionadas.

Máquinas virtuales en blockchains

Las blockchains adaptaron el concepto de VM para resolver un desafío único: ¿cómo pueden miles de computadoras independientes de todo el mundo ejecutar código que no es de confianza y obtener resultados idénticos? Las VM funcionan como el entorno de ejecución determinista que ejecuta contratos inteligentes (es decir, programas en Solana) y administra el estado de la red (es decir, el estado actual de todas las cuentas, saldos y demás datos de la red).

Cuando se envía una transacción a una blockchain, la VM se encarga de:

  • Cargar desde el almacenamiento los datos necesarios de las cuentas.
  • Ejecutar el bytecode del programa especificado en la transacción.
  • Medir el consumo de recursos para evitar bucles infinitos o ataques de denegación de servicio (DoS).
  • Validar que todos los cambios de estado cumplan las reglas de consenso predefinidas de la red.
  • Confirmar el estado actualizado en el almacenamiento permanente (es decir, el ledger).

Las reglas específicas que determinan cómo ocurren las transiciones de estado están definidas por la arquitectura del conjunto de instrucciones de la VM y las restricciones del entorno de ejecución.

Cómo funciona la SVM

La SVM es una canalización de subsistemas que colaboran para ejecutar transacciones de forma segura y eficiente. Bank coordina la ejecución de un slot específico, administra el estado de las cuentas, aplica las reglas de consenso y coordina Banking Stage con el almacenamiento persistente (es decir, AccountsDB). Cada Bank representa el estado de todas las cuentas en un slot específico y atraviesa tres ciclos de vida: activo (es decir, abierto a nuevas transacciones), congelado (es decir, no admite nuevas transacciones porque el slot está completo) y arraigado (es decir, forma parte de la cadena canónica).

Banking Stage es donde se ejecutan las transacciones dentro de la Transaction Processing Unit (TPU) de un validador. Recibe transacciones verificadas de la etapa SigVerify, las almacena temporalmente y las programa para su ejecución paralela mediante la detección de conflictos en los bloqueos de cuentas. Los hilos de trabajo de Banking Stage procesan lotes de transacciones que no entran en conflicto. Para ello, llaman a los métodos de ejecución de Bank para cargar cuentas, aprovisionar instancias de VM sBPF para cada instrucción, ejecutar el bytecode del programa y recopilar los resultados. Banking Stage continúa procesando lotes de transacciones sin conflictos hasta que Bank se congela en el límite del slot. Ten en cuenta que los lotes son distintos de las entradas, que son las unidades registradas de transacciones escritas en el ledger para su replicación y consenso.

Los BPF Loaders administran el ciclo de vida de los programas: despliegue, compilación JIT, actualizaciones y ejecución. Cuando una instrucción apunta a un programa determinado, se aprovisiona una VM sBPF con sus propias regiones de memoria y presupuesto de cómputo, y la ejecución se transfiere al bytecode del programa.

La VM sBPF es el entorno de ejecución aislado donde realmente se ejecuta el bytecode del programa. Deriva de eBPF de Linux y usa una arquitectura basada en registros con 11 registros de uso general. La VM aplica el aislamiento de memoria mediante cinco regiones de memoria distintas, cada una con límites y permisos explícitos. También mide el consumo de unidades de cómputo para evitar una ejecución descontrolada y despacha llamadas al sistema para operaciones privilegiadas, como criptografía, registros o invocaciones entre programas (CPI).

AccountsDB es la capa de estado persistente donde residen todos los datos de las cuentas. El estado de las cuentas se carga antes de la ejecución y se usan cachés para evitar lecturas repetidas del disco en las cuentas de acceso frecuente. Después de una ejecución exitosa, las actualizaciones se confirman en AccountsDB. Si la ejecución falla, todos los cambios de estado se revierten atómicamente.

En conjunto, estos componentes forman la SVM, un motor de ejecución desacoplado y reutilizable.

Lo que hace especial a la SVM: declaraciones anticipadas de cuentas

La decisión arquitectónica que define a la SVM es que todas las transacciones deben declarar explícitamente de qué cuentas leerán y en cuáles escribirán antes de comenzar la ejecución. Este sencillo requisito, integrado en el propio formato de transacción, habilita dos capacidades transformadoras que distinguen a Solana: la ejecución paralela y los mercados de comisiones locales.

Ejecución paralela (Sealevel)

A diferencia de la Ethereum Virtual Machine (EVM), que procesa las transacciones secuencialmente —una por una y espera a que cada una termine antes de pasar a la siguiente—, la SVM permite el escalamiento horizontal al ejecutar varias transacciones simultáneamente en múltiples núcleos de CPU. Esta paralelización es posible porque todas las transacciones de Solana declaran explícitamente de qué cuentas leerán y en cuáles escribirán antes de comenzar la ejecución. 

Declarar las cuentas que una transacción leerá y modificará permite al entorno de ejecución analizar las dependencias entre cuentas para detectar conflictos y programar transacciones que no entren en conflicto:

  • Las transacciones que afectan cuentas completamente diferentes pueden ejecutarse en paralelo sin ninguna sobrecarga de coordinación.
  • Las transacciones que solo leen de las mismas cuentas también pueden ejecutarse en paralelo, ya que las lecturas no entran en conflicto.
  • Las transacciones que intentan escribir en las mismas cuentas se ejecutan secuencialmente para evitar condiciones de carrera y garantizar la coherencia del estado. 

Mercados de comisiones locales

Como el entorno de ejecución sabe exactamente a qué cuentas accederá cada transacción antes de ejecutarla, las comisiones pueden localizarse en cuentas específicas en lugar de competir globalmente en toda la red. Este concepto se conoce como mercados de comisiones locales. 

En Ethereum y otras cadenas EVM, todas las transacciones compiten en un único mercado global de comisiones. Enviar ETH a un amigo, acuñar un NFT o comerciar en Uniswap son acciones que compiten por el mismo espacio de bloque. Un aumento de actividad en un ámbito eleva las comisiones para todos, aunque intenten hacer algo completamente diferente. 

En Solana, solo compiten entre sí las transacciones que acceden a las mismas cuentas. Un usuario que transfiere SOL entre dos cuentas no debería preocuparse por la acuñación simultánea de una colección popular de NFT. La comisión de prioridad de una transacción depende únicamente de la contención de cuentas. Esta localización permite que las transacciones de Solana sigan siendo económicas, incluso durante periodos de alta actividad.

Por ejemplo, el 10 de octubre, el mercado de las criptomonedas experimentó el mayor evento de liquidación de su historia. A pesar del aumento récord de actividad, las transacciones de Solana siguieron siendo relativamente económicas: la comisión mediana alcanzó $0.007, las comisiones promedio llegaron brevemente a $0.10 y el 1 % de las transacciones más caras apenas superó $1.00. Durante el mismo periodo, las comisiones medianas de Ethereum y Arbitrum superaron los $100, mientras que las de Base alcanzaron un máximo superior a $3.

Un cambio de paradigma

AspectoEVMSVM
ArquitecturaVM basada en pilaVM basada en registros (derivada de eBPF)
EjecuciónSecuencialParalela (detección de conflictos)
Mercado de comisionesGlobalLocalizado (contención por cuenta)
Declaraciones de cuentasNo se requieren de antemanoSe requieren de antemano
ISA~140 opcodes, operaciones de pila~100 opcodes, registros similares a RISC
Compilación JITOpcional (depende del cliente)Estándar (rendimiento nativo)
Modelo de estadoComisiones de almacenamiento de contratosBase de datos plana de cuentas
LenguajesSolidity/Vyper → bytecode EVMRust/C/C++ → LLVM → sBPF

La SVM representa un enfoque fundamentalmente diferente para la ejecución en blockchain. Bitcoin introdujo el dinero programable. Ethereum introdujo los contratos inteligentes de propósito general y la ejecución arbitraria onchain. Sin embargo, ambos están limitados por la ejecución secuencial y los mercados globales de comisiones, decisiones arquitectónicas que imponen límites fundamentales tanto al rendimiento como al costo.

La SVM se aparta de las restricciones tradicionales y ofrece una red capaz de manejar un alto rendimiento sin sacrificar la programabilidad ni obligar a los usuarios a participar en subastas de comisiones prohibitivamente caras. La decisión de exigir declaraciones anticipadas de cuentas es sencilla pero potente, ya que permite la ejecución paralela entre núcleos de CPU y localiza las comisiones en mercados a nivel de cuenta.

Por supuesto, estas no son las únicas optimizaciones que Solana ofrece frente a otras blockchains. El lema de Solana, Aumentar el ancho de banda, reducir la latencia, y su obsesión extrema por hacer realidad el sueño de los mercados de capital de Internet han dado lugar a diversas optimizaciones de rendimiento, decisiones de diseño e implementaciones para desarrollar una red de alto rendimiento.

El resto de este artículo analiza exactamente cómo funciona: cómo el código fuente de Rust se compila a bytecode, cómo se despliega y verifica ese bytecode y cómo el entorno de ejecución aprovisiona entornos aislados para ejecutar de forma segura miles de programas en paralelo, a la vez que mantiene un determinismo estricto y garantías de seguridad. 

Del código fuente de Rust al bytecode sBPF: la canalización de compilación

Rust

Rust es la lingua franca del desarrollo de programas de Solana. Frameworks como Anchor ofrecen a los desarrolladores un enfoque sólido y estructurado para crear programas seguros de forma eficiente. solana_program está diseñada como la biblioteca base de todos los programas onchain. Recientemente, Pinocchio, una biblioteca altamente optimizada y sin dependencias, se ha convertido en la opción preferida de los desarrolladores que buscan crear programas nativos de Solana. 

Sin importar el framework o la biblioteca utilizados, todos los programas tienen un punto de entrada al que llama el entorno de ejecución cuando se invoca el programa. La macro entrypoint de solana_program emite el código repetitivo estándar necesario para iniciar la ejecución del programa. Esto incluye deserializar las entradas y configurar un asignador global y un controlador de pánicos. Pinocchio exporta macros de punto de entrada que funcionan de forma similar, pero desacoplan el punto de entrada de la configuración del asignador del heap y del controlador de pánicos, lo que ofrece más opciones a los desarrolladores. 

Anatomía básica de un programa 

Un programa es un tipo de cuenta capaz de ejecutar código. Más concretamente, es una cuenta ejecutable que almacena un blob de bytecode sBPF en una cuenta propiedad de BPF Loader con una clave pública única. Los programas no tienen estado por diseño: todos los datos persistentes residen en cuentas separadas que los programas pueden leer o modificar cuando se invocan.

La SVM espera que todos los programas tengan una estructura específica: un punto de entrada que acepta tres entradas:

  • ID del programa: la dirección del propio programa, utilizada para comprobaciones autorreferenciales (p. ej., la propiedad).
  • Cuentas: un arreglo de metadatos de cuentas (es decir, claves públicas, saldos de lamports, búferes de datos, propietarios y marcas). Estos son el «estado» que un programa necesita leer y modificar.
  • Datos de la instrucción: una secuencia de bytes de datos arbitrarios procedentes de la transacción.

Se espera que un programa procese estas entradas mediante su punto de entrada, modifique las cuentas relevantes con permiso de escritura, emita registros o eventos y devuelva un estado de éxito que indique si pudo completar todo correctamente. Esto se reduce a una función process_instruction.

Un programa sencillo escrito en Rust con el crate solana_program se ve así:

simple_rust_program.rs
use solana_program::{
    account_info::AccountInfo,
    entrypoint,
    entrypoint::ProgramResult,
    msg,
    pubkey::Pubkey,
};

entrypoint!(process_instruction);

pub fn process_instruction(
    _program_id: &Pubkey,
    _accounts: &[AccountInfo],
    _instruction_data: &[u8],
) -> ProgramResult {
    msg!("Hello, Solana!");

    Ok(())
}

En segundo plano, todo esto está definido por la Application Binary Interface (ABI) de la SVM, que analizaremos más adelante.

El compilador de Rust y LLVM IR

Rust, como muchos otros lenguajes de programación, es una abstracción de segundo orden construida sobre lenguaje ensamblador. Está diseñado para que las personas escriban código seguro, concurrente y legible sin tener que gestionar cada mínima interacción con el hardware. Sin embargo, las computadoras no entienden Rust ni ningún otro lenguaje de alto nivel. 

Las computadoras entienden código máquina: instrucciones binarias adaptadas a una arquitectura o máquina virtual específica. Todos los programas terminan convirtiéndose en código binario y, en última instancia, las computadoras realizan esta traducción. La compilación es un proceso de traducción de varios pasos que elimina las abstracciones de alto nivel, optimiza la eficiencia y genera bytecode ejecutable.

rustc es el compilador oficial de Rust. La mayoría de los desarrolladores no suelen interactuar directamente con rustc. En su lugar, lo invocan mediante Cargo, el administrador de paquetes de Rust. Aun así, rustc hace pasar el código fuente de Rust por tres etapas principales antes de emitir bytecode ejecutable. Cada etapa elimina abstracciones, aplica medidas de seguridad y prepara el código para la siguiente transformación:

  • Análisis sintáctico y expansión
  • MIR (Mid-level Intermediate Representation)
  • LLVM IR (Low Level Virtual Machine Intermediate Representation)

Análisis sintáctico y expansión

El compilador lee un archivo de Rust, identificado por la extensión .rs, como texto sin formato. Busca tokens específicos (p. ej., use, fn, None, impl, &[u8]) mediante un proceso conocido como análisis léxico. La tokenización léxica es el proceso de convertir texto en tokens léxicos significativos que pertenecen a una categoría determinada (p. ej., identificadores, operadores, separadores, literales y palabras clave). 

rustc toma estos tokens léxicos y los convierte en una estructura de datos conocida como árbol de sintaxis abstracta (AST). Esta estructura arborescente representa la estructura anidada y jerárquica del código fuente de Rust. Es decir, las funciones contienen bloques, los bloques contienen expresiones, las expresiones contienen operadores, etc. Aunque sigue siendo de alto nivel, el AST ofrece una representación fiel del código fuente y de su lógica subyacente.

Una vez creado el AST, el compilador realiza varias transformaciones clave:

  • Expansión de macros: las macros como entrypoint! y println! se expanden a código Rust sin procesar, de modo que todas las macros se convierten en nodos AST simples.
  • Reducción: la sintaxis abreviada de alto nivel, diseñada para facilitar la lectura del código, se reescribe en formas más primitivas mediante un proceso denominado reducción. Por ejemplo, un bucle for se transforma en un loop con iteración manual. El resultado de la reducción es High-Level Intermediate Representation (HIR).
  • Comprobación de préstamos y análisis de seguridad: Rust toma la HIR y realiza la comprobación de tipos, la resolución de traits y la inferencia de tipos. El resultado de este proceso es Typed High-Level Intermediate Representation (THIR).

En esta etapa, el compilador procesa el código inseguro. Este permite a los desarrolladores realizar operaciones que eluden las garantías de seguridad de Rust (p. ej., desreferenciar punteros sin procesar, llamar funciones externas e implementar traits inseguros). Funciona como una vía de escape deliberada para obtener control de bajo nivel: relaja reglas específicas, pero sigue exigiendo que el código compile según la semántica de Rust. Esto es clave para los programas de Solana, donde el código inseguro puede usarse con moderación para mejorar operaciones críticas para el rendimiento, como la deserialización de cuentas sin copias (p. ej., en la estructura contenedora de Pinocchio para una cuenta).    

El código inseguro se identifica por primera vez después del análisis léxico, durante la creación del AST, ya que los tokens inseguros se reconocen y marcan como nodos especiales. Después, se procesa tras la expansión del AST durante la comprobación de tipos y préstamos. El compilador garantiza que las operaciones inseguras se limiten a contextos inseguros y, de lo contrario, marca errores (p. ej., «no se puede desreferenciar un puntero sin procesar fuera de un contexto inseguro»). Por ejemplo, el compilador no comprueba que el código inseguro no corrompa ni gestione incorrectamente la memoria.

Al final de esta etapa, el AST expandido, ahora THIR, es una representación validada y reducida del código fuente de Rust.

MIR

Después, la THIR se reduce a Mid-Level Intermediate Representation (MIR), un formato centrado en Rust que representa el código fuente como un gráfico de flujo de control (CFG) simplificado. Todo el azúcar sintáctico y las estructuras complejas específicas de Rust (p. ej., coincidencia de patrones, traits y clausuras) se expresan mediante bloques básicos que incluyen asignaciones y bifurcaciones. Estos bloques básicos se conectan mediante saltos o, más concretamente, Gotos, y bifurcaciones, lo que facilita razonar sobre el flujo de un programa. 

MIR no es estrictamente necesario. El compilador podría reducir la THIR directamente a LLVM IR. Sin embargo, MIR ofrece una capa que comprende Rust y permite al compilador aplicar reglas específicas de Rust y realizar optimizaciones antes de aplicar las optimizaciones genéricas de LLVM. Esto hace que MIR sea ideal para comprobaciones y transformaciones que tienen un nivel demasiado alto para LLVM, pero demasiado bajo para THIR, entre ellas:

  • Comprobación de préstamos: las comprobaciones semánticas iniciales se realizan durante el análisis de tipos en THIR. Sin embargo, el CFG simplificado de MIR permite una comprobación completa y precisa de los préstamos para aplicar todas las reglas de propiedad, préstamo y tiempo de vida.
  • Comprobación de movimientos y liberaciones: el compilador garantiza que todos los valores se muevan y liberen de acuerdo con las garantías de seguridad de memoria de Rust, lo que evita errores de uso después de liberar.
  • Análisis de inicialización: el compilador garantiza que todas las variables se inicialicen antes de usarlas.
  • Inlining y optimizaciones tempranas: el compilador puede insertar funciones pequeñas, simplificar expresiones aritméticas y eliminar código inalcanzable.

Por ejemplo, antes invocamos msg!(“Hello, Solana!”) en nuestro programa de muestra escrito en Rust. Esta es una macro definida en el crate solana-program que, para expresiones individuales como una cadena estática, se expande durante la fase de expansión de macros para llamar directamente a sol_log($msg), donde $msg es la expresión. La syscall sol_log recibe un puntero a los datos de la cadena y su longitud, y los registra en la salida de la SVM sin la sobrecarga del formateo. Esto podría simplificarse en MIR de la siguiente forma:

Código
bb0: {
  _0 = const "Hello, Solana!"; // Constant string allocation
  _1 = len(_0); // Compute length
  sol_log(move _0, move _1); // Syscall invocation with explicit moves for ownership
  return = Ok(());
}

Aquí:

  • _0 = const “Hello, Solana!”;—MIR introduce valores temporales (es decir, _0) para los valores intermedios. La cadena se trata como una porción constante asignada en datos de solo lectura.
  • _1 = len(_0);—MIR expone una operación sencilla de longitud sobre la porción para permitir una posible evaluación de constantes.
  • sol_log(move _0, move _1);—La invocación de la syscall, que se traduce en una secuencia de instrucciones sBPF que carga registros y llama al ID de la syscall. En secciones posteriores analizaremos exactamente qué significa esto, pero lo importante es que estos movimientos se conectan con la semántica de propiedad de Rust y la aplican durante la compilación.
  • Return = Ok(());—Finaliza el bloque con un terminador e indica a la SVM que la operación fue exitosa.

El enfoque de MIR en la semántica de Rust lo convierte en el lugar ideal para detectar ineficiencias y depurar patrones que consumen muchas CU. Por ejemplo, si tu registro contiene cadenas dinámicas, MIR puede identificar asignaciones o bucles adicionales que podrían optimizarse. Los desarrolladores pueden usar el comando cargo rustc -- -Z dump-mir=all para volcar MIR.

MIR garantiza que el código sea semánticamente correcto, esté optimizado y se haya despojado de todas las reglas específicas de Rust antes de reducirlo finalmente a LLVM IR.

Ten en cuenta que esto suele denominarse fase de generación de código. No necesariamente implica LLVM. Sin embargo, LLVM es habitual y es lo que la mayoría de las personas asocia con la generación de código de Rust. El compilador de Rust también incluye backends de GCC y Cranelift que emiten GIMPLE y CLIF, respectivamente. Nos centramos en LLVM IR dentro del contexto de Solana, pero ten en cuenta que no siempre se usa con Rust en general.

LLVM IR

LLVM, que originalmente significaba "Low Level Virtual Machine", se refiere a un framework modular de compilación. En lugar de ser un solo compilador, LLVM es un conjunto de componentes reutilizables para crear compiladores, optimizadores y generadores de código. Muchos lenguajes (p. ej., Rust, C, C++, Julia, Swift, Brainfuck y Zig) usan LLVM para aprovechar su capacidad de apuntar a diversas arquitecturas, desde CPU x86 hasta ISA virtuales. 

rustc traduce MIR a LLVM IR (Low Level Virtual Machine Intermediate Representation), el puente entre la semántica de Rust y el bytecode que finalmente se despliega en Solana. Está mucho más cerca del código máquina, con asignaciones de memoria explícitas (es decir, alloca), almacenamientos, cargas y llamadas a funciones. No tiene noción de propiedad, tiempos de vida ni traits, porque esas abstracciones de Rust ya se expandieron hasta dejar de existir. Las garantías de las etapas anteriores se conservan en las siguientes.

En esta etapa se aplican varias optimizaciones, entre ellas:

  • Evaluación de constantes (es decir, evaluar constantes durante la compilación).
  • Inlining (es decir, sustituir una llamada por el cuerpo de la función).
  • Eliminación de código muerto (es decir, eliminar instrucciones que no afectan los resultados).
  • Desenrollado y vectorización de bucles (es decir, reescribir bucles para que se ejecuten más rápido).

Por tanto, LLVM nos proporciona:

  • LLVM IR: un formato intermedio portátil similar al lenguaje ensamblador.
  • Pasadas de optimización: para crear LLVM IR, LLVM usa Static Single Assignment (SSA), que garantiza que cada variable se asigne exactamente una vez y permite optimizaciones como el inlining y la eliminación de código muerto.
  • Generadores de código: objetivos que reducen LLVM IR a código máquina real (es decir, x86_64, ARM, WebAssembly y eBPF).

Normalmente, los programas de Rust se compilarían para objetivos de hardware como x86_64 o ARM. Sin embargo, los programas de Solana no se ejecutan directamente en hardware. Se ejecutan dentro de la Solana Virtual Machine. Por tanto, el backend de LLVM reduce LLVM IR a bytecode BPF, que en Solana se convierte en bytecode sBPF (es decir, un fork de eBPF que elimina funciones no deterministas e introduce syscalls específicas de Solana).

Ten en cuenta que, aunque Rust es la lingua franca del desarrollo de programas de Solana, puede usarse cualquier lenguaje capaz de apuntar al backend BPF de LLVM (p. ej., C, Nim, Swift y Zig).

eBPF

LLVM IR se reduce a eBPF, la ISA basada en registros que constituye la base del entorno de ejecución de Solana. eBPF (Extended Berkeley Packet Filter) se originó a partir de Berkeley Packet Filter (BPF), desarrollado en 1992 por Steven McCanne y Van Jacobson mientras trabajaban en Lawrence Berkeley Laboratory para sistemas Unix Berkeley Software Distribution (BSD). En esencia, BPF es un punto de captura y filtro de paquetes de red que permite capturar y filtrar paquetes a nivel del sistema operativo sin copiar datos, mediante el uso de calificadores. 

Desde entonces, eBPF evolucionó —es decir, se amplió— hasta convertirse en una VM de propósito general y aislada dentro del kernel de Linux. Lo que habilita es análogo a lo que JavaScript hizo posible para el desarrollo web: es un motor de scripts seguro para kernels. eBPF permite a los desarrolladores ejecutar programas pequeños y verificados directamente en el kernel de Linux, con un conjunto limitado de instrucciones para tareas como supervisión del rendimiento, observabilidad, seguridad y redes.

Esto es importante porque los desarrolladores obtienen:

  • Ejecución aislada: los programas eBPF se ejecutan en una máquina virtual restringida dentro del kernel, por lo que no pueden bloquearlo ni corromper su memoria.
  • Garantías de seguridad: el bytecode eBPF se verifica estáticamente antes de cargarse para garantizar que no se produzcan accesos inválidos a memoria, saltos fuera de los límites ni otras operaciones privilegiadas. Esto ofrece seguridad sin sobrecarga durante la ejecución.
  • Eficiencia: eBPF se basa en registros, en lugar de una pila como la EVM, y puede compilarse mediante JIT a código máquina para alcanzar velocidades casi nativas gracias a su diseño ligero (es decir, sin la sobrecarga de un sistema operativo completo).
  • Flexibilidad: eBPF expone llamadas al sistema, también conocidas como syscalls, que son básicamente puntos de acceso a funciones del kernel. Las syscalls pueden ampliarse con nuevas capacidades sin tener que rediseñar el conjunto de instrucciones.

Solana necesitaba una VM determinista, segura y de alto rendimiento para ejecutar programas que no son de confianza en todo su conjunto de validadores. eBPF ofrece un modelo de seguridad probado, una ISA portátil y eficiente diseñada para ejecutar miles de programas ligeros y compatibilidad con JIT para mejorar el rendimiento. Por eso, en lugar de inventar una VM completamente nueva, Solana creó un fork de eBPF llamado sBPF.

sBPF 

Inicialmente, Solana Labs creó un fork del rBPF de Quentin Monnet para desarrollar la versión de rBPF de Solana y garantizar que cada validador tuviera un formato de bytecode que produjera exactamente los mismos resultados al ejecutar un programa y una entrada determinados. 

Se consideró necesario crear un fork de eBPF porque el consenso de Solana requería una ejecución determinista y un uso limitado de recursos. Aunque eBPF es determinista, Solana necesitaba garantías adicionales y funciones específicas para blockchains:

  • Tiempos y costos fijos de las instrucciones.
  • Ejecución en el espacio de usuario.
  • Un entorno de ejecución determinista.

En particular, está diseñado para ejecutarse en el espacio de usuario y no en el kernel, lo que evita necesitar privilegios o modificaciones del kernel. Esto permite desplegarlo en diversos entornos de sistemas operativos sin requerir acceso root ni módulos personalizados del kernel. El espacio de usuario es una opción práctica para Solana: ofrece portabilidad, pruebas y un despliegue más sencillo. A pesar de ejecutarse en el espacio de usuario, JIT sigue logrando un rendimiento casi nativo. Además, la ejecución en el espacio de usuario permite realizar pruebas y fuzzing sin acceso al kernel.

rBPF ya no se utiliza. Cuando se creó Anza, desarrollaron un fork de rBPF llamado sBPF (Solana Berkeley Packet Filter). El repositorio de rBPF en GitHub, propiedad de Solana Labs, se archivó el 10 de enero de 2025.

La SVM ISA

La SVM ISA (Solana Virtual Machine Instruction Set Architecture) es la especificación central que define cómo deben ejecutar programas las VM compatibles con Solana (p. ej., sBPF de Agave o la reimplementación de Firedancer). No es la VM en sí, sino el estándar o contrato que garantiza la coherencia y el cumplimiento del protocolo entre las distintas implementaciones de la SVM. La SVM ISA impone estas restricciones de seguridad y determinismo sobre eBPF, elimina funciones centradas en el kernel y añade funciones específicas para blockchains.

La ISA rige los registros, la codificación de instrucciones, los opcodes, las clases, las reglas de verificación, las condiciones de pánico y la Application Binary Interface (ABI). Cualquier cambio en la SVM ISA debe implementarse mediante SIMD para permitir la evolución controlada de este conjunto de instrucciones y garantizar una ejecución determinista entre validadores.

Registros

Los registros son pequeños espacios de almacenamiento dentro de la VM que contienen números o direcciones mientras se ejecutan las instrucciones, como si fueran variables o cajas etiquetadas sobre una mesa de trabajo. La SVM ISA define una arquitectura de registros de 64 bits con 11 registros de uso general (R0-R10) y un contador de programa oculto. Los registros tienen 64 bits de ancho para enteros y direcciones, lo que permite manejar eficientemente valores grandes o punteros. R0 contiene los valores devueltos por las funciones; R1-R5 pasan los primeros cinco argumentos de una función como parámetros; R6-R9 son preservados por la función llamada y persisten entre llamadas; y R10 funciona como un puntero de marco de solo lectura que marca el marco actual de la pila. El contador de programa oculto sigue la ejecución e indica qué instrucción debe ejecutarse a continuación.

Instrucciones

Una instrucción es una sola operación que la VM sabe realizar, como «sumar estos dos números» o «saltar a esta línea de código». Las instrucciones tienen un diseño similar a RISC con aproximadamente 100 opcodes, frente a los miles de arquitecturas CISC como x86. Esto permite verificarlas rápidamente y compilarlas eficientemente mediante JIT.

Las instrucciones se codifican como valores de 64 bits en formato Little Endian con la siguiente estructura:

  • opcode: 8 bits
  • dst_reg: 4 bits
  • src_reg: 4 bits
  • offset: 16 bits (con signo)
  • immediate: 32 bits (con signo)

opcode indica qué hacer, dst_reg indica dónde se guarda el resultado, src_reg indica de dónde proviene la entrada, offset indica qué desplazamiento de memoria consultar e immediate es una constante adicional que puede incluirse en la instrucción.

lddw, o load double word, es la única instrucción ancha que ocupa dos espacios de 64 bits para admitir valores inmediatos completos de 64 bits. 

Las instrucciones se agrupan en clases, como operaciones de memoria, operaciones aritméticas o lógicas, bifurcaciones condicionales e incondicionales, llamadas y retornos de funciones y conversión de Endianness.

Regiones de memoria

La ISA identifica cinco regiones de memoria, cada una con límites explícitos (es decir, [addr, addr+len]) que determinan dónde puede leer o escribir un programa:

  • Código del programa: las propias instrucciones compiladas (lectura y ejecución).
  • Pila: un espacio de trabajo temporal para funciones (lectura y escritura, normalmente 4 KB por marco).
  • Heap: memoria dinámica que un programa puede solicitar (lectura y escritura).
  • Datos de entrada: bytes de solo lectura enviados con una transacción.
  • Datos de solo lectura: constantes y valores inmutables.

Los programas tienen un mapa de memoria virtual predefinido: el código del programa comienza en la dirección 0x000000000 o 0x100000000, según la versión de compilación; los marcos de la pila comienzan en 0x200000000; el heap comienza en 0x300000000; y los datos de entrada comienzan en 0x400000000.

El verificador

El verificador realiza un análisis estático antes de la ejecución: examina todas las rutas posibles del código sin ejecutar el programa para garantizar la seguridad durante la carga, en lugar de hacerlo durante la ejecución. Esto incluye comprobar que:

  • No haya instrucciones desconocidas o incompatibles.
  • Todos los destinos de salto correspondan a límites válidos de instrucciones y se gestionen los saltos hacia atrás.
  • No haya rutas de código inalcanzables.
  • Se apliquen los límites de profundidad de las llamadas a funciones.
  • La división o el módulo por cero se rechacen estáticamente.
  • Los programas tengan un límite máximo de tamaño y no lo superen.

Aunque es útil, el verificador no impide que un desarrollador introduzca comportamientos inesperados. Por ejemplo, los desarrolladores aún pueden introducir errores de uso después de liberar y desbordamiento de búfer en un programa de Solana. 

Condiciones de pánico

Las condiciones de pánico son la lista de errores durante la ejecución definidos por la SVM ISA. Entre ellos se incluyen:

  • Instrucciones inválidas o incompatibles.
  • División o módulo por cero.
  • Acceso a memoria fuera de los límites.
  • Acceso inválido a distintas regiones de memoria (es decir, infracción de permisos).
  • Desbordamiento de pila.
  • Profundidad de llamadas excedida.
  • Superación del número máximo permitido de instrucciones.
  • El programa devolvió un código de error.

ABI

La Application Binary Interface (ABI) es el contrato de formato entre un programa de Solana y la SVM. Aunque la sección anterior Anatomía básica de un programa mostró cómo funciona en Rust (es decir, process_instruction con tres entradas), la ABI especifica cómo se representan esas entradas y salidas en la memoria, lo que garantiza que todos los validadores puedan ejecutar programas de forma determinista.

A grandes rasgos, la ABI define tres elementos: la convención del punto de entrada, las convenciones de llamada y los registros, y la disposición de la memoria.

Todos los programas de Solana deben exponer una función de punto de entrada. El loader serializa las entradas del programa en el espacio de memoria de la VM siguiendo un orden canónico: el ID del programa, el arreglo de cuentas y los datos de la instrucción. Después, la VM pasa punteros a estas regiones en el punto de entrada del programa.

Los primeros cinco registros (es decir, R1-R5) se reservan para los argumentos del punto de entrada, mientras que el registro de retorno (es decir, R0) contiene el código de salida del programa. Un código de salida de cero se considera un éxito, mientras que un valor distinto de cero indica un fallo asignado a un InstructionError específico. Esto garantiza que todos los programas devuelvan códigos de estado de forma coherente. Además, los parámetros posteriores a los primeros cinco se pasan por la pila. R6-R9 siguen una convención de preservación por la función llamada, lo que significa que las funciones deben conservar estos valores si los usan.

Las cuentas y los datos se serializan en la memoria lineal de la VM como segmentos de bytes. Los programas deben deserializarlos en tipos de Rust de mayor nivel (por ejemplo, AccountInfo, Pubkey). La ABI impone límites estrictos para que ningún programa pueda acceder a memoria fuera de las regiones que tiene asignadas.

En conjunto, estas reglas convierten a la ABI en el «pegamento» que conecta la experiencia de desarrollo de alto nivel con la ISA de bajo nivel. Garantiza que una firma simple de función de Rust se compile con el uso correcto de registros, la disposición de memoria y los códigos de retorno, para que cada validador interprete un programa exactamente de la misma forma en cada ocasión.

Llamadas al sistema

La ISA es intencionalmente mínima y no incluye cuentas ni estado integrados. Tampoco ofrece directamente funciones de mayor nivel, como registro de eventos, hashing o llamadas entre programas. En su lugar, expone llamadas al sistema: funciones especiales integradas en la VM que permiten a los programas interactuar con el mundo exterior. Estas llamadas se conocen coloquialmente como syscalls.

Las syscalls pueden entenderse como API proporcionadas por la VM. En lugar de que cada programa tenga que volver a implementar primitivas criptográficas o lógica de cuentas específicas, las syscalls exponen operaciones seguras y estandarizadas cuyo comportamiento determinista está garantizado en todos los validadores.

Las categorías populares de syscalls incluyen:

  • Registro y depuración (por ejemplo, la syscall sol_log escribe una cadena UTF-8 en los registros del programa y msg! la usa internamente).
  • Invocación entre programas (CPI) (por ejemplo, sol_invoke_signed permite que un programa llame a otro programa onchain y le pase cuentas y datos de instrucciones, algo esencial para la composabilidad de Solana).
  • Criptografía (por ejemplo, las syscalls sol_sha256, sol_keccak256 y sol_ed25519_verify agregan primitivas criptográficas deterministas y rápidas sin que los desarrolladores tengan que escribir sus propias implementaciones).
  • Utilidades de memoria y cuentas (es decir, syscalls que exponen funciones auxiliares para tomar prestados datos de cuentas, reasignar memoria o trabajar con asignaciones del heap propiedad del programa).
  • Presupuesto y medición de cómputo (es decir, cada syscall consume CU, según lo aplica el sistema de medición del runtime).

Las syscalls se invocan mediante una instrucción especial CALL_IMM con un identificador hash único. Cuando un programa llama a una syscall, la VM sBPF interrumpe la ejecución, busca el hash en su registro de syscalls y deriva la llamada a la implementación nativa que se ejecuta en código privilegiado del runtime. Las syscalls se ejecutan fuera del sandbox y tienen acceso al estado del runtime, algo completamente distinto de la ejecución de las llamadas normales a funciones dentro de un programa.

Las syscalls siguen la misma ABI que las funciones normales: los primeros cinco argumentos se pasan a los registros R1 a R5 y el valor de retorno se coloca en R0. Cada syscall tiene un costo fijo de unidades de cómputo, lo que garantiza un consumo determinista de recursos. Por ejemplo, todas las llamadas a la syscall secp256k1_recover consumen 25,000 CU.

Las syscalls forman un límite de seguridad controlado, ya que cada una valida sus entradas y comprueba los permisos pertinentes antes de realizar operaciones privilegiadas. Por ejemplo, las syscalls de invocación entre programas (CPI) verifican que el emisor tenga los permisos adecuados sobre las cuentas que se pasan.

Ten en cuenta que se pueden agregar nuevas syscalls mediante feature gates sin modificar la propia ISA. Esto permite que Solana amplíe las capacidades de su VM, incluido el soporte para nuevas primitivas criptográficas, mientras mantiene la compatibilidad con programas existentes.  

Binario del programa

Al terminar la compilación, todas las etapas —desde el código fuente de Rust hasta LLVM IR, eBPF, sBPF y su conformidad con la ISA de SVM— producen un único resultado: un binario del programa. Este binario es lo que realmente se despliega en Solana. 

ELF

Los programas de Solana se compilan en archivos con formato ejecutable y enlazable (ELF), un formato binario estándar utilizado en sistemas similares a Unix. El formato ELF funciona como un contenedor que agrupa todo lo que la VM necesita para ejecutar un programa determinado sin perder la independencia de la plataforma.

Un archivo ELF suele contener las siguientes secciones:

  • Sección de bytecode: Contiene las instrucciones sBPF compiladas en la sección .text.
  • Sección de datos de solo lectura: Contiene constantes, cadenas estáticas y valores inmutables en una sección .rodata.
  • Secciones BSS y de datos: Contienen variables globales o estáticas mutables en las secciones .bss y .data, respectivamente. Ten en cuenta que Solana no permite datos mutables. Es decir, el ELF puede tener .rodata, pero no secciones BSS ni de datos. 
  • Tablas de símbolos y reubicación: Definen cómo se resuelven las llamadas a funciones, las syscalls y las referencias de memoria durante la carga: las secciones .symtab y .strtab para los símbolos, y las secciones .rel.dyn y .rela.dyn para las entradas de reubicación.

Cada archivo ELF también incluye un encabezado que describe la arquitectura, el ancho de instrucción (es decir, 64 bits), el orden de bytes (es decir, Little Endian) y la dirección del punto de entrada.

Enlazado y reubicación

El proceso de convertir la salida del compilador en un único archivo ELF ejecutable incluye un componente final: el enlazador. El enlazador combina varias unidades de código compiladas en un solo binario coherente. También resuelve todas las referencias simbólicas (es decir, marcadores de posición) que el compilador dejó sin resolver. Por ejemplo:

  • Cuando un programa llama a una función como sol_log, el compilador no sabe dónde se encuentra esa función en la memoria, por lo que usa un marcador de posición.
  • El enlazador reemplaza esta referencia simbólica por el identificador hash único de la syscall (es decir, un hash Murmur3 determinista de 32 bits).
  • Del mismo modo, las llamadas entre funciones internas se reescriben como saltos relativos a desplazamientos de instrucciones dentro de la sección .text.

Este proceso de reescribir referencias simbólicas como direcciones concretas o identificadores hash de syscalls se denomina reubicación. Sin embargo, cabe señalar que las reubicaciones son, en gran medida, un artefacto de cómo se crearon las herramientas iniciales, no un requisito fundamental. De hecho, existen planes para eliminar por completo las reubicaciones en futuras versiones de la cadena de herramientas y simplificar así el proceso de despliegue.

Este paso de reubicación es necesario para garantizar que el mismo binario ELF se ejecute de forma idéntica en todos los validadores, ya que no incorpora direcciones de memoria absolutas ni símbolos específicos del sistema. 

Además, el bytecode con estas reubicaciones ya aplicadas se almacena en caché en la memoria. Por eso, todas las ejecuciones futuras usan el bytecode actualizado sin tener que volver a procesar las reubicaciones.

Una vez que el enlazador produce un archivo ELF completamente reubicado, el programa está listo para desplegarse. El resultado final es un binario:

  • Portable: Se ejecuta de forma idéntica en cualquier validador o implementación de SVM.
  • Determinista: No contiene syscalls no deterministas ni dependencias del SO.
  • Autocontenido: Incluye todo el bytecode y los metadatos necesarios para la ejecución.

Cómo se carga el bytecode en Solana

Una vez que un programa de Solana se compila y enlaza en un archivo ELF válido, el siguiente paso es cargarlo en la blockchain para que los validadores puedan ejecutarlo. Este proceso, conocido como despliegue del programa, incluye varios componentes que trabajan en conjunto: BPF Loader, modelos de cuentas, verificación de bytecode y administración del estado.

El programa BPF Loader

BPF Loader es un programa nativo que valida y reubica archivos ELF, y los marca como ejecutables. En esencia, administra el ciclo de vida de los programas desplegados: procesa instrucciones para inicializar cuentas, escribe bytecode, despliega programas y gestiona actualizaciones.

Solana ha evolucionado a través de varias versiones de loaders, cada una mejorando la anterior:

  • BPF Loader: El loader original para programas estáticos que no se pueden actualizar y que ya no es compatible. 
  • BPF Loader V2: Un loader simplificado sin instrucciones de administración.
  • BPF Loader Upgradeable: El loader actual, que incorporó la capacidad de actualizar programas.
  • BPF Loader V4: La versión más reciente, con mejores funciones de despliegue, que simplifica el modelo actual de dos cuentas a uno de una sola cuenta.

Arquitectura de despliegue: modelos de cuentas

Modelo de cuentas actual

El loader actual utiliza una arquitectura de dos cuentas para separar la lógica del programa de sus datos. Esto produce dos cuentas para un programa determinado: la cuenta Program y la cuenta ProgramData.

La cuenta Program es pequeña, de aproximadamente 36 bytes, contiene metadatos y está marcada como ejecutable. Almacena una referencia a ProgramData mediante UpgradeableLoaderState::Program { programdata_address }.

La cuenta ProgramData es más grande y almacena el bytecode ELF real junto con los metadatos del despliegue (por ejemplo, el slot y la dirección de la autoridad de actualización) mediante UpgradeableLoaderState::ProgramData.

La separación de ambas cuentas permite actualizaciones in situ. Es decir, la cuenta Program permanece en la misma dirección, mientras que el bytecode de la cuenta ProgramData se puede reemplazar.

Modelo de cuentas futuro

Loader V4 busca optimizar el proceso de despliegue con un modelo de una sola cuenta. La idea es que la cuenta del programa almacene directamente los metadatos y el bytecode, eliminando la necesidad de una cuenta ProgramData separada. También permite a los desarrolladores almacenar una imagen comprimida con zstd para reducir los costos de renta.

Cómo se despliegan los programas de Solana

El proceso de despliegue consiste en cargar un binario ELF compilado y hacer que BPF Loader lo verifique, almacene en caché y marque como ejecutable. El proceso difiere ligeramente entre el loader actualizable y V4 debido a la arquitectura de despliegue descrita en la sección anterior.

BPF Loader Upgradeable

El proceso de despliegue actual con el loader actualizable comienza por inicializar una cuenta de búfer para preparar el bytecode ELF. Quien realiza el despliegue envía una instrucción InitializeBuffer a BPF Loader Upgradeable. Este crea una cuenta nueva propiedad del loader y establece su estado en UpgradeableLoaderState::Buffer { authority_address }, registrando la dirección autorizada para escribir en el búfer. 

El binario ELF compilado se carga en el búfer por fragmentos mediante la instrucción Write { offset, bytes }. Cada instrucción de escritura verifica que el firmante coincida con la autoridad del búfer, comprueba que el búfer siga siendo mutable (es decir, que aún no se haya desplegado) y escribe los bytes en el desplazamiento especificado después del encabezado de metadatos. Ten en cuenta que los programas grandes requieren varias instrucciones Write para cargar el archivo ELF completo debido a los límites de tamaño de las transacciones.

Cuando el búfer contiene el ELF completo, quien realiza el despliegue envía una instrucción DeployWithMaxDataLen { max_data_len }. Este es el paso más complejo de todo el proceso, ya que coordina el despliegue real desde la validación de cuentas hasta la finalización del estado.

Primero, el loader valida todas las cuentas del proceso de despliegue y verifica lo siguiente:

  • La cuenta del programa no está inicializada y está exenta de renta.
  • El búfer contiene datos válidos y la autoridad que lo inicializó firmó la transacción.
  • max_data_len tiene el tamaño suficiente para alojar los datos del búfer.
  • El tamaño total no supera MAX_PERMITTED_DATA_LENGTH (es decir, 10 MiB o 10,485,760 bytes). 

Después, el loader crea la cuenta ProgramData y deriva la dirección como una PDA mediante el ID del programa y el ID del loader. Luego devuelve los lamports del búfer al pagador, ya que la cuenta de búfer deja de ser necesaria después del despliegue. 

Además, crea la cuenta ProgramData mediante CPI al System Program y asigna espacio suficiente para los metadatos y los bytes de max_data_len. A continuación, el loader usa el bump seed de la PDA para firmar la CPI. 

La macro deploy_program! garantiza que sea seguro ejecutar el bytecode. Primero analiza la estructura del archivo ELF para validar los bytes mágicos ELF (es decir, 0x7f ‘E’ ‘L’ ‘F’) y los encabezados (es decir, 64 bits, Little Endian), extrae las secciones del programa, procesa las tablas de reubicación y valida los límites y la alineación de las secciones. La carga falla de inmediato si el ELF está mal formado o usa funciones no compatibles.

El RequisiteVerifier (es decir, el verificador de sBPF) realiza después un análisis estático de todas las rutas de ejecución posibles sin ejecutar el programa. Así garantiza que sea demostrablemente seguro antes de ejecutar cualquier instrucción. El verificador también aplica las restricciones de la ISA de SVM mencionadas antes. Si la verificación falla, el despliegue se rechaza con InstructionError::InvalidAccountData y el programa nunca se marca como ejecutable.

Cuando la verificación se completa correctamente, el bytecode se compila y almacena en caché para su ejecución. La función load_program_from_bytes crea un ProgramCacheEntry que contiene:

  • Ejecutable compilado con JIT: El bytecode sBPF se compila Just-In-Time (JIT) a código máquina nativo para la arquitectura de CPU del validador. Esto ofrece una velocidad de ejecución casi nativa sin perder seguridad.
  • Metadatos de slot: El momento del despliegue y el momento de visibilidad del programa se registran como deployment_slot y effective_slot, respectivamente. La demora impide que los programas se usen en el mismo slot en el que se despliegan.
  • Entorno del runtime: Referencias al registro de syscalls para definir cuáles están disponibles y la configuración de ejecución que se utilizará cuando se ejecute el programa. 

La entrada de caché se almacena en program_cache_for_tx_batch, lo que permite ejecutar el programa en transacciones posteriores. Cuando el programa se verifica y almacena correctamente en caché, el loader actualiza los estados de las cuentas para finalizar el despliegue. El estado de la cuenta ProgramData se actualiza para registrar cuándo se desplegó el programa y quién tiene autorización para actualizarlo. El bytecode ELF también se copia del búfer a la cuenta. Asimismo, se actualiza el estado de la cuenta Program para vincularla con la cuenta ProgramData y se marca como ejecutable. Por último, la longitud de datos del búfer se establece en el tamaño de los metadatos, lo que elimina el bytecode y recupera espacio.

El programa ya está completamente desplegado y las transacciones pueden invocarlo.

BPF Loader V4

BPF Loader V4 optimiza el despliegue al eliminar la necesidad de una cuenta ProgramData separada y permitir que la cuenta del programa almacene el bytecode directamente. También incorpora soporte para almacenar ELF comprimidos con zstd, lo que reduce significativamente los costos de renta y permite descomprimirlos bajo demanda durante la carga.

Quien realiza el despliegue llama a SetProgramLength { new_size } para asignar espacio a los metadatos y el bytecode del programa. En programas nuevos, esto inicializa la cuenta con el estado LoaderV4State::Retracted, registra la autoridad y marca la cuenta como ejecutable, aunque todavía no puede invocarse.

Después, quien realiza el despliegue escribe el binario ELF directamente en la cuenta del programa mediante instrucciones Write { offset, bytes }. Estas escrituras solo se permiten cuando el programa se encuentra en el estado Retracted. La instrucción Copy también puede utilizarse para copiar el bytecode de otro programa, sin importar la versión del loader, lo que resulta útil para las migraciones.

Luego se usa la instrucción Deploy para pasar el programa del estado Retracted a Deployed. En esencia, esta instrucción extrae el bytecode de la cuenta del programa en el desplazamiento y ejecuta exactamente el mismo proceso de verificación que BPF Loader Upgradeable (es decir, análisis del ELF, verificación estática, compilación JIT y almacenamiento en caché). Si la verificación tiene éxito, el estado del programa se actualiza a LoaderV4Status::Deployed y se registra el slot de despliegue.

El programa ya está completamente desplegado y las transacciones pueden invocarlo.

Loader V4 también aplica un periodo de espera entre transiciones de estado (es decir, desplegado y retirado) para evitar ataques de redespliegue. Los programas no pueden desplegarse ni retirarse durante el slot siguiente a su último despliegue. Esto evita que actores maliciosos actualicen programas rápidamente para aprovechar condiciones de carrera o confundir a los usuarios, y garantiza atomicidad por slot en lugar de demoras de varios slots. Ten en cuenta que este periodo de espera se aplica tanto a la instrucción Deploy como a Retract.

Los programas también pueden hacerse inmutables mediante la instrucción Finalize. Esto cambia el programa del estado Deployed a Finalized, lo que significa que ya no puede retirarse ni actualizarse. El campo de autoridad se reutiliza para apuntar a la dirección de un programa de «siguiente versión». Esto permite rutas de actualización explícitas y mantiene la inmutabilidad del programa.

Cómo funciona la ejecución en la SVM

La SVM es el motor de procesamiento de transacciones dentro de los validadores. Se encarga de ejecutar invocaciones de programas y actualizar el estado según corresponda. 

Cuando una transacción llega a un validador, pasa por un proceso de varias etapas: validación, carga de cuentas, ejecución del programa en una VM sBPF aislada, verificación de invariantes y confirmación del estado. Si todas las instrucciones tienen éxito, los cambios de las cuentas se escriben en AccountsDB. Si alguna instrucción falla, toda la transacción se revierte de forma atómica.

La SVM funciona como un motor de ejecución desacoplado. Es decir, no administra el consenso, las redes ni el historial del ledger. Se centra exclusivamente en ejecutar programas de forma segura, determinista y eficiente. 

El Bank coordina la ejecución de la SVM, proporciona el contexto del runtime (por ejemplo, blockhash, renta y conjunto de funciones) y confirma los resultados en el almacenamiento persistente. La SVM administra la ejecución de programas, desde cargar el bytecode hasta aplicar los presupuestos de cómputo. Esta separación de responsabilidades permite reutilizar la SVM fuera de los validadores.

Transacciones 

Las transacciones son el alma de Solana, y de cualquier blockchain: invocan programas para efectuar cambios de estado. 

Una transacción es un conjunto de instrucciones que especifica qué acciones deben realizarse, sobre qué cuentas y si tienen los permisos necesarios para hacerlo. 

Una instrucción es una directiva para invocar un solo programa. Es la unidad más pequeña de lógica de ejecución y la unidad operativa más básica de Solana.

Los programas interpretan los datos que reciben de una instrucción para operar sobre las cuentas especificadas. Una instrucción incluye un ID de programa (es decir, el programa que se invoca), una lista de cuentas de las que leer y en las que escribir, y la entrada que se pasa al programa.

Las transacciones comienzan cuando un usuario define un objetivo, como transferir 10 SOL a otra cuenta. Esta intención se convierte en una instrucción que indica al System Program que transfiera 10 SOL de la cuenta A a la cuenta B. La cuenta A se pasa a la transacción como firmante con permiso de escritura y la cuenta B, como cuenta con permiso de escritura. Luego, la instrucción se agrupa en una transacción que también especifica el pagador de la comisión, los firmantes y un blockhash reciente.

Normalmente, la transacción se envía a un proveedor de RPC como Helius. El nodo RPC que la recibe verifica que todas las firmas requeridas estén presentes y sean válidas, que la transacción no se haya procesado antes, que el blockhash reciente proporcionado siga siendo válido y que la transacción no supere el tamaño máximo (es decir, 1232 bytes).

Después, el RPC reenvía la transacción a la Unidad de Procesamiento de Transacciones (TPU) del líder actual. 

La Unidad de Procesamiento de Transacciones (TPU)

La Unidad de Procesamiento de Transacciones (TPU) es el proceso de recepción y procesamiento de transacciones dentro de los validadores de Solana. Incluye varias etapas que reciben, verifican, programan y ejecutan transacciones antes de confirmarlas en el ledger de Solana.

Para nuestros fines, examinaremos con bastante detalle Fetch Stage, SigVerify Stage y Banking Stage antes de continuar con el Bank y el aprovisionamiento de la VM sBPF. 

Para ver un análisis más detallado de la TPU, consulta Calidad de servicio ponderada por stake: todo lo que necesitas saber.

Fetch Stage

Fetch Stage es la primera etapa del proceso de la TPU. Recibe todas las transacciones entrantes mediante conexiones QUIC, que utilizan sockets UDP como capa de transporte subyacente, y las agrupa en lotes para su procesamiento en etapas posteriores.

Se crean tres sockets UDP:

  • tpu: Transacciones normales, como transferencias de tokens, acuñaciones de NFT e interacciones con programas.
  • tpu_vote: Transacciones de voto de los validadores. Esto cambiará con Alpenglow, cuando se eliminen las transacciones de voto.
  • tpu_forwards: Transacciones sin procesar reenviadas por el líder anterior, que no pudo procesarlas a tiempo.

Estos sockets se registran en el servicio de gossip del validador y se almacenan en la estructura ContactInfo, que permite que otros validadores y nodos RPC descubran adónde enviar transacciones.

Fetch Stage genera un hilo por socket. Todos realizan continuamente estas tareas:

  • Consultar el socket UDP en busca de paquetes entrantes.
  • Crear un lote de 64 paquetes.
  • Enviar el lote a través de su canal sin límite.

Actualmente se usan canales sin límite para pasar lotes a etapas posteriores, lo que significa que los canales tienen capacidad ilimitada. El uso de estos canales también permite que Fetch Stage funcione de forma independiente de la velocidad de procesamiento de las etapas posteriores. 

Aunque esto evita descartar paquetes de inmediato durante picos de tráfico, puede causar problemas de memoria. Si las etapas posteriores no logran seguir el ritmo de Fetch Stage, los canales crecen sin límite, lo que puede provocar ralentizaciones o cierres por falta de memoria (OOM). 

Se está trabajando para implementar canales limitados con una contrapresión adecuada. Esto permitirá que el sistema señale la congestión y evite el crecimiento ilimitado de la memoria.

Fetch Stage también crea otro hilo dedicado a gestionar paquetes reenviados. Estos paquetes se marcan con una marca FORWARDED y se conservan o descartan según el calendario de líderes:

  • Aceptados: Si el validador actual será líder pronto, los paquetes reenviados se procesan mediante el canal normal de la TPU y se envían a la siguiente etapa.
  • Descartados: Si el validador actual no será líder pronto, los paquetes reenviados se descartan para evitar procesamiento innecesario. 

Fetch Stage utiliza un PacketBatchRecycler para preasignar 1,000 lotes de paquetes, cada uno con 1,024 paquetes. Esto reduce la sobrecarga de asignación de memoria, ya que la memoria del lote puede reutilizarse en lugar de asignar lotes nuevos cada vez. Históricamente, el reciclador era necesario para fijar memoria de CUDA, pero esa función ya no opera y puede considerarse, en gran medida, deuda técnica. 

SigVerify Stage

SigVerify Stage es la segunda etapa del proceso de la TPU y, como su nombre indica, verifica firmas. Esto se hace al principio porque verificar firmas Ed25519 exige muchos recursos de cómputo, aunque no tantos como ejecutar transacciones. Verificar las transacciones antes de ejecutarlas permite que los validadores rechacen transacciones fraudulentas, eviten ataques de denegación de servicio y garanticen que solo lleguen a Banking Stage transacciones con el formato correcto.

SigVerify Stage funciona como un solo hilo que recibe continuamente paquetes de los canales de Fetch Stage y los procesa mediante un proceso de verificación. Aunque es un solo hilo, internamente existe mucho paralelismo.

De forma predeterminada, las firmas se verifican en la CPU mediante un iterador paralelo. Esto distribuye la verificación entre todos los núcleos de CPU disponibles y permite que cada uno verifique de forma independiente un subconjunto de firmas. 

La verificación de firmas también puede transferirse a la GPU si se detectan bibliotecas de rendimiento mediante perf_libs::api(). La GPU solo se puede usar si hay al menos 64 paquetes y se espera que el 90% sea válido. La razón es que la GPU tiene una sobrecarga de ~15-20 ms para configuración y transferencia, mientras que la CPU puede verificar 64 firmas en ~10-20 ms. En la práctica, esta función ha resultado poco viable en producción por la sobrecarga de latencia, que la hace mucho más lenta que la verificación por CPU con cargas de trabajo reales. Existen planes para eliminar esta ruta de código sin usar.

El proceso de verificación es relativamente sencillo:

  • Recibir lotes de paquetes de los canales sin límite de Fetch Stage.
  • Descartar transacciones al azar mediante reducción de carga si el volumen de paquetes supera los 165,000.
  • Eliminar transacciones duplicadas.
  • Descartar paquetes excedentes para que ninguna IP pueda monopolizar el ancho de banda de verificación.
  • Reducir y reorganizar previamente los lotes para mejorar la localidad de caché y disminuir el desperdicio de memoria.
  • Verificar las firmas.

Todos los paquetes válidos continúan a Banking Stage. 

Banking Stage

Banking Stage es donde se ejecutan las transacciones. Aquí, las transacciones se almacenan en búfer, se programan y se ejecutan mediante hilos de trabajo paralelos.

Se usa un patrón de programador central que separa la gestión de transacciones de voto y sin voto:

  • Un solo hilo de trabajo gestiona las transacciones de voto y de voto por gossip.
  • Cuatro hilos de trabajo procesan todas las transacciones sin voto.
  • Un solo hilo coordina la distribución del trabajo entre los hilos de trabajo.

Los paquetes entrantes se deserializan y se almacenan en búfer hasta alcanzar 100,000 transacciones. El programador recibe continuamente nuevas transacciones mientras administra este búfer y descarta las vencidas o no válidas durante operaciones de limpieza de la cola que revisan hasta 10,000 transacciones a la vez.

El programador determina el orden de ejecución de las transacciones mediante la detección de conflictos (es decir, transacciones que buscan obtener bloqueos de lectura y escritura para las mismas cuentas). Hay dos implementaciones de programador disponibles de forma predeterminada:

  • PrioGraphScheduler: Un programador que crea un grafo de prioridades para detectar conflictos entre cuentas.
  • GreedyScheduler: El programador predeterminado, con un método FIFO más directo para ordenar transacciones.

Después, el programador selecciona transacciones sin conflictos y las envía a los hilos de trabajo. Cada hilo recibe un lote y comienza a procesarlo.

Coordinación del Bank

El Bank representa el estado de todas las cuentas en un slot específico. Es la estructura de datos central que administra los datos de las cuentas, aplica las reglas del runtime y coordina a los trabajadores de Banking Stage con la VM sBPF para ejecutar transacciones. 

Ciclo de vida

Cada Bank avanza por tres estados:

  • Activo: Un Bank recién creado y abierto a transacciones. Los trabajadores de Banking Stage aplican transacciones hasta que el Bank alcanza el número objetivo de ticks o se procesan todas las entradas del slot.
  • Congelado: Cuando se alcanza el número de ticks o se procesan todas las entradas, el Bank se congela. Ya no se pueden aplicar más transacciones. En este punto, las comisiones de transacción se acumulan para el líder del bloque, se actualizan las cuentas sysvar y se calcula el hash final del Bank.
  • Arraigado: Cuando el Bank congelado recibe suficientes votos de los validadores, queda arraigado. El estado se finaliza y pasa a formar parte del ledger de la cadena.

Cada Bank, excepto el Bank de génesis, apunta a un Bank principal, lo que forma una estructura de árbol que representa distintas bifurcaciones del ledger. 

Flujo de ejecución

Los trabajadores de Banking Stage operan sobre el Bank de trabajo actual (es decir, el Bank activo y no congelado que se crea para el slot actual). Cuando un trabajador recibe un lote de transacciones del programador, el Bank coordina la ejecución:

  • Bloqueo de cuentas: Los trabajadores llaman a la función prepare_sanitized_batch_with_results() para bloquear todas las cuentas referenciadas en las transacciones y evitar modificaciones simultáneas.
  • Carga de cuentas: El Bank obtiene de AccountsDB los datos de todas las cuentas bloqueadas.
  • Deducción de comisiones: El Bank deduce las comisiones de transacción de la cuenta del pagador antes de la ejecución.
  • Validación: El Bank valida que el blockhash sea reciente, comprueba el estado de la cuenta nonce y verifica la propiedad de las cuentas.
  • Transferencia a la VM: El Bank llama a load_and_execute_transactions(), que pasa las cuentas cargadas a la VM sBPF.
  • Ejecución: La VM sBPF ejecuta el bytecode del programa de cada instrucción.
  • Resultados: La VM sBPF devuelve toda la salida de la ejecución (es decir, LoadAndExecuteTransactionOutput).
  • Confirmación: El Bank vuelve a escribir los estados actualizados de las cuentas en AccountsDB mediante bank.commit_transactions().

Ahora, la ejecución entra en la SVM propiamente dicha (es decir, la VM sBPF). Después de que el Bank llama a load_and_execute_transactions(), las transacciones pasan por un proceso de varias etapas. Cada instrucción se procesa con una instancia nueva y aislada de la VM sBPF, aprovisionada para ejecutar el bytecode del programa.

Las instrucciones de una transacción se ejecutan de forma secuencial. El proceso de cada instrucción es:

  • Localizar el programa: Buscar la cuenta del programa mediante el ID de programa de la instrucción.
  • Cargar desde la caché: Comprobar si el programa en cuestión ya está compilado con JIT en la caché de programas. 
  • Aprovisionar la VM: Crear una instancia aislada de la VM sBPF con regiones de memoria y un presupuesto de cómputo específicos.
  • Ejecutar el bytecode: Ejecutar el punto de entrada del programa con las entradas de la instrucción.
  • Verificar invariantes: Comprobar que la ejecución no infrinja ninguna regla del runtime.
  • Recopilar resultados: Empaquetar los resultados de la ejecución.

Carga de programas

Antes de que un programa pueda ejecutarse, debe cargarse desde su cuenta onchain, verificarse y compilarse en código máquina nativo. Esto sucede mediante la caché de programas y el proceso de compilación JIT.

La caché de programas es una optimización de rendimiento que evita volver a cargar y compilar los programas en cada invocación. Se mantiene a nivel del lote de transacciones y almacena objetos ProgramCacheEntry, que contienen:

  • Ejecutable compilado con JIT: Código máquina nativo para la arquitectura de CPU del validador.
  • Metadatos de despliegue: El slot en el que el programa se desplegó y entró en vigor.
  • Entorno del runtime: Referencias al registro de syscalls y la configuración de ejecución.

Cuando una transacción hace referencia a un ID de programa, se sigue esta secuencia de búsqueda:

  • Comprobar la caché del lote de transacciones: Buscar una versión compilada con JIT y almacenada en caché.
  • Comprobar la caché global de programas: Si no está en la caché del lote, comprobar la caché global del validador.
  • Cargar desde la cuenta: Si no aparece en ninguna caché, cargar la cuenta del programa desde AccountsDB.
  • Analizar el ELF: Extraer el bytecode de los datos de la cuenta del programa.
  • Verificar el bytecode: Ejecutar el verificador estático para garantizar la seguridad.
  • Compilar con JIT: Traducir el bytecode sBPF a código máquina nativo.
  • Almacenar la entrada en caché: Guardar el programa compilado para futuras invocaciones.

Esto significa que la primera invocación de un programa recién desplegado asume el costo total de cargarlo, verificarlo y compilarlo. En cambio, las invocaciones posteriores ejecutan directamente el código nativo almacenado en caché. 

Cuando el programa no está en la caché, debe cargarse desde su cuenta onchain. En el caso de programas propiedad de BPF Loader Upgradeable, la cuenta del programa contiene una referencia a la cuenta ProgramData, que es la que se carga desde AccountsDB. En el modelo de una sola cuenta de Loader V4, el bytecode se carga directamente desde la cuenta del programa, lo que podría requerir descompresión si está comprimido con zstd.

Los bytes ELF extraídos se analizan para localizar el bytecode ejecutable y las secciones antes mencionadas (es decir, .text, .rodata, .data / .bss, .symtab / .strtab). Después de analizar correctamente el ELF, RequisiteVerifier (es decir, el analizador estático de sBPF) verifica todas las rutas de ejecución posibles sin ejecutar realmente el programa, como se explicó en las secciones anteriores.

Compilación JIT

Cuando la verificación se completa correctamente, el bytecode se compila Just-In-Time (JIT) a código máquina nativo. El compilador JIT traduce cada instrucción sBPF a las instrucciones de CPU nativas equivalentes para la arquitectura del validador. 

La compilación JIT permite que la VM sBPF alcance el rendimiento necesario para gestionar el alto throughput de Solana. Sin JIT, la VM tendría que interpretar el bytecode sBPF instrucción por instrucción, lo que generaría una sobrecarga considerable. 

Cada instrucción de bytecode debe obtenerse, decodificarse y enviarse al código gestor, lo que agrega sobrecarga de interpretación. Además, el código interpretado no puede aprovechar optimizaciones de CPU como el procesamiento segmentado, la predicción de bifurcaciones o la ejecución fuera de orden. Por lo tanto, cada instrucción sBPF se convierte en una llamada a función dentro del intérprete.

La compilación JIT elimina por completo estos costos al generar código máquina nativo que se ejecuta directamente en la CPU. Esto ofrece un rendimiento casi nativo, comprobaciones de límites optimizadas, medición de cómputo integrada, optimizaciones a nivel de hardware y una asignación clara de registros.

El compilador JIT realiza una traducción de una sola pasada del bytecode sBPF al código máquina nativo. Para cada instrucción sBPF:

  • La instrucción se decodifica para extraer el opcode, los registros, el desplazamiento y el valor inmediato.
  • Se configura el frame de la pila y se guardan los registros preservados por la función llamada.
  • La operación sBPF se asigna a la instrucción de CPU equivalente para que cada operación se compile en instrucciones nativas. Por ejemplo, en la asignación x86-64, rax se asignaría a R0 y rbp a R10.
  • Se emiten comprobaciones de límites y traducción de direcciones por software para el acceso a memoria. Traducir direcciones del guest a direcciones del host es una de las operaciones más lentas de la VM debido a su sobrecarga.
  • Se mantiene el aislamiento de la pila (es decir, la pila del guest reside en un búfer asignado en el heap, no en la pila del host).
  • Se insertan la deducción de CU y las comprobaciones del presupuesto para emitir la medición de cómputo.
  • Se restauran los registros y se limpia la pila.

Traducción de instrucciones

El compilador JIT traduce distintos tipos de instrucciones (es decir, aritmética, acceso a memoria, operaciones de almacenamiento, bifurcaciones condicionales y derivaciones de syscalls) de maneras específicas. 

Las operaciones aritméticas se asignan directamente a instrucciones nativas individuales de la CPU sin sobrecarga, ya que los registros de sBPF se asignan correctamente a los registros de hardware correspondientes del validador. 

Las operaciones de acceso a memoria requieren comprobaciones de límites para evitar lecturas y escrituras fuera de rango. El compilador JIT genera código de validación que compara los límites inferior y superior de cada acceso a memoria con los límites de la región válida. 

Esto implica, en esencia, de 3 a 6 instrucciones nativas que calculan la dirección efectiva, verifican que esté dentro de los límites esperados y realizan la carga real. La predicción de bifurcaciones gestiona esto con bastante eficiencia, ya que las infracciones de límites son poco frecuentes. 

Las operaciones de almacenamiento incluyen comprobaciones de límites y validación de permisos de escritura. Antes de escribir en la memoria, el código compilado verifica que la dirección de destino esté dentro de los límites y que la región de memoria tenga habilitados los permisos de escritura.

Las bifurcaciones condicionales se compilan como instrucciones nativas de salto condicional. El compilador JIT resuelve todos los destinos de salto durante la compilación para convertir los desplazamientos relativos de las instrucciones sBPF en direcciones absolutas dentro del código nativo. 

La derivación de una syscall requiere guardar el estado de la VM —los 11 registros— y llamar al gestor nativo de la syscall. Después, se restaura el estado de la VM con el valor de retorno. Esta sobrecarga de administración del estado explica por qué las syscalls tienen costos fijos de CU, superiores a los de las instrucciones normales.

Medición de unidades de cómputo

El compilador JIT integra el seguimiento de unidades de cómputo directamente en el código generado. Cada instrucción sBPF incluye una comprobación del presupuesto para garantizar que no se agote a medida que las instrucciones reducen el número de CU restantes. 

Esta integración evita la sobrecarga de las llamadas a funciones y puede realizarse de manera eficiente mediante predicción de bifurcaciones, ejecución fuera de orden (es decir, la comprobación de CU y la operación pueden ejecutarse en paralelo) y paralelismo a nivel de instrucción.

Para las syscalls con costo variable (por ejemplo, la syscall sol_sha256, que se escala según la longitud de los datos), el cálculo del costo se realiza dentro de la implementación nativa de la syscall antes de volver a la VM.

Almacenamiento en caché del código compilado

Cuando termina la compilación JIT, el ejecutable nativo se almacena en un ProgramCacheEntry, que contiene:

  • El código compilado con JIT
  • Los metadatos del slot de despliegue y el slot efectivo
  • Referencias al registro de syscalls
  • La configuración del entorno del runtime

La entrada de caché se coloca en la caché del lote de transacciones y en la caché global de programas. La primera está disponible para todas las instrucciones del lote actual, mientras que la segunda está disponible en todas las transacciones futuras. 

La entrada de caché puede invalidarse cuando se actualiza el programa (es decir, se despliega bytecode nuevo), se cierra la cuenta del programa, los feature gates cambian la disponibilidad de las syscalls o un validador decide vaciar su caché.

Demora del slot efectivo

Ten en cuenta que un programa desplegado en el slot n no puede invocarse hasta el slot n + 1. Esta demora garantiza que todos los validadores observen el despliegue, que las cachés de programas se sincronicen en toda la red y que se aplique la atomicidad por slot. 

Aprovisionamiento de la VM sBPF

Cuando el programa se carga y compila con JIT, o se recupera de la caché, se aprovisiona una nueva VM sBPF para ejecutar cada instrucción. Este aprovisionamiento se realiza en BPF Loader e incluye configurar cinco regiones de memoria distintas, inicializar el presupuesto de cómputo y registrar las syscalls.

Regiones de memoria

Como mencionamos en la sección sobre la ISA de SVM, la VM crea cinco regiones de memoria distintas. En conjunto, forman el sandbox aislado en el que se ejecutan los programas de Solana. La región de memoria del programa suele comenzar en la dirección 0x100000000. Contiene el código nativo compilado con JIT que se ejecutará: el código máquina real, que depende de la arquitectura de CPU del validador. Si JIT está deshabilitado para la depuración, esta región contiene en su lugar el bytecode sBPF interpretado. Los permisos de esta sección son solo de lectura y ejecución.

Los datos de solo lectura también se incluyen en 0x100000000. Contienen constantes y cadenas estáticas extraídas de la sección ELF .rodata durante la carga del programa. El propósito de esta región de memoria es ofrecer acceso eficiente a constantes de tiempo de compilación sin requerir asignación en el heap. 

La pila comienza en 0x200000000 y contiene variables locales, frames de llamadas a funciones y direcciones de retorno. Aquí se realizan los cálculos temporales durante la ejecución. Crece hacia abajo desde la parte superior de la región y el registro R10 (es decir, el puntero del frame) marca el límite del frame actual. Esta región permite lectura y escritura, y su tamaño está limitado a 4 KB por frame de llamada. Superar este límite genera un error StackAccessViolation, que indica un desbordamiento de pila. El tamaño reducido de la pila motiva a los desarrolladores a usar el heap o, mejor aún, a almacenar los datos en cuentas, en lugar de depender del almacenamiento en la pila.

El heap comienza en 0x300000000 y contiene memoria asignada dinámicamente para estructuras de datos del runtime que no caben en la pila ni en una cuenta. Su tamaño va desde los 32 KB predeterminados hasta un máximo de 256 KB. Antes, los programas podían ampliar el heap mediante la syscall sol_alloc_free. Sin embargo, la syscall sol_alloc_free está obsoleta y deshabilitada para despliegues de programas nuevos. Los programas deben especificar el tamaño requerido del heap al desplegarse, en lugar de ampliarlo dinámicamente. 

Nota: el crecimiento del heap consume unidades de cómputo según la fórmula (heap_size / 32KB) * 8,000 CUs, con un costo predeterminado de heap de 8 CU.

La región de memoria de datos de entrada comienza en la dirección 0x400000000. Contiene los parámetros serializados del punto de entrada que recibe el programa cuando se invoca. Es una región de memoria de solo lectura cuyo tamaño real varía según la transacción. Los tres componentes serializados son la pubkey de 32 bytes del programa invocado, un arreglo de cuentas y los datos de la instrucción.

Aplicación de las reglas de acceso a memoria

La VM comprueba los límites de cada instrucción de carga y almacenamiento en memoria. Antes de cada acceso, verifica que la dirección se encuentre dentro del rango válido de la región y que el tipo de acceso (es decir, lectura o escritura) esté permitido en esa región. 

La ejecución se interrumpe de inmediato con un error AccessViolation si alguna de las comprobaciones falla. Toda la transacción se revierte y no se confirma ningún cambio de estado. 

Esta aplicación tiene un costo de runtime casi nulo porque el compilador JIT convierte las comprobaciones en código nativo eficiente que la CPU puede ejecutar directamente. La predicción de bifurcaciones moderna gestiona las comprobaciones de forma eficiente, ya que las infracciones son poco frecuentes. 

Inicialización del presupuesto de cómputo

Cada instancia de la VM se inicializa con un presupuesto de unidades de cómputo que limita la cantidad total de trabajo que puede realizar un programa determinado. Este modelo de ejecución limitada garantiza que los programas no puedan ejecutarse indefinidamente y que todos los validadores ejecuten las transacciones dentro de un periodo predecible.

Los parámetros actuales del presupuesto son los siguientes:

  • Límite predeterminado de unidades de cómputo por instrucción: 200,000 CUs.
  • Límite máximo de unidades de cómputo por transacción: 1,400,000 CUs.
  • Límite de instrucciones integradas: 3,000 CUs.

La cantidad predeterminada de CUs por transacción es min(1_400_00, (200_000*non_reserve_instructions + 3_000*reserve_instructions)). En esencia, es el mínimo entre el límite máximo de unidades de cómputo y los costos predeterminados por tipo de instrucción según las instrucciones proporcionadas.

El presupuesto de cómputo lleva un registro de las unidades restantes durante toda la ejecución. A medida que se ejecuta cada instrucción sBPF, su costo en CUs se resta del presupuesto restante. Si el presupuesto llega a cero antes de que finalice el programa, la ejecución se detiene de inmediato con InstructionError::ComputationalBudgetExceeded. La transacción falla y no se confirma ningún cambio de estado, pero al pagador de la comisión se le sigue cobrando la comisión de la transacción para compensar al validador por procesarla. 

Registro de llamadas al sistema

Durante el proceso de aprovisionamiento de la VM, también se crea una correspondencia entre el identificador hash Murmur único de 32 bits de cada llamada al sistema y su implementación nativa en Rust. Así se registran todas las llamadas al sistema disponibles. 

Cuando un programa ejecuta una instrucción CALL_IMM con el hash de una llamada al sistema, ocurre lo siguiente:

  • La VM pausa el flujo de instrucciones sBPF.
  • El despachador de llamadas al sistema usa el hash para encontrar la implementación en el registro.
  • La llamada al sistema verifica que el invocador tenga los permisos necesarios (por ejemplo, para realizar una CPU o comprobar la propiedad de una cuenta).
  • La implementación en Rust de la llamada al sistema se ejecuta fuera del entorno aislado con acceso completo al entorno de ejecución.
  • El costo fijo de la llamada al sistema se resta del presupuesto de cómputo restante.
  • El resultado se coloca en el registro R0 y se reanuda la ejecución de sBPF. 

Ejecución de programas

Una vez aprovisionada la VM de sBPF, la ejecución comienza en la función de punto de entrada del programa. En los programas compilados mediante JIT, la VM salta directamente al código máquina nativo y permite que la CPU del validador lo ejecute de forma nativa. Como se explicó en la sección anterior, el código compilado mediante JIT incluye toda la instrumentación necesaria: comprobaciones de límites de memoria, medición de cómputo y validación del flujo de control, todo insertado en línea para maximizar el rendimiento.

El registro R1 contiene un puntero a la región de datos de entrada donde residen los tres parámetros serializados (es decir, la pubkey del programa invocado, un arreglo de cuentas y los datos de la instrucción). 

Nota: se accede a los datos de las cuentas mediante punteros en lugar de copiarlos. El uso de punteros permite que los programas lean y modifiquen los datos de las cuentas directamente, lo cual es fundamental para el rendimiento.

Cada llamada a una función asigna un nuevo marco de pila de 4 KB y actualiza el registro R10 para que apunte al nuevo marco. El cómputo se mide de acuerdo con el presupuesto de cómputo mencionado anteriormente.

Invocaciones entre programas (CPI)

Durante la ejecución, los programas pueden invocar otros programas mediante invocaciones entre programas (CPI), la base de la composabilidad de la SVM. 

Una CPI se inicia mediante la llamada al sistema sol_invoke_signed, que cuesta 1,000 CUs, además de costos adicionales basados en los datos serializados de las cuentas que se transfieren. Tanto los datos de las cuentas como la serialización de los datos de las instrucciones cuestan 250 bytes por CU.

Cuando un programa realiza una CPI, se crea un nuevo contexto de ejecución con su propio marco de pila de instrucciones. Al momento de escribir este artículo, la profundidad máxima de la pila de instrucciones es 5, o 9 con SIMD-0268 habilitado. Esto significa que un programa puede invocar otro programa, que a su vez puede invocar otro, hasta alcanzar el límite de profundidad. Cada invocación anidada mantiene su propio conjunto de cuentas modificables y privilegios de firmante. Una CPI puede tener un máximo de 16 firmantes y recibir 128 estructuras AccountInfo. 

El invocador serializa el ID del programa de destino, las cuentas y los datos de la instrucción, y luego ejecuta la llamada al sistema. Se pausa la ejecución del programa actual y se aprovisiona una nueva instancia de la VM de sBPF para el programa invocado mediante el mismo proceso de aprovisionamiento descrito anteriormente. Después comienza la ejecución del programa invocado. Este se ejecuta con su propio presupuesto de cómputo, obtenido del presupuesto restante del invocador. Esto significa que las llamadas CPI comparten el presupuesto de cómputo total de la transacción.

Los programas pueden firmar en nombre de las cuentas que poseen mediante direcciones derivadas de programas (PDA). Al invocar con sol_invoke_signed, el invocador proporciona semillas que demuestran la propiedad de la PDA. La derivación de la PDA se verifica antes de otorgar autoridad de firma al programa invocado. 

Cuando finaliza el programa invocado, el control vuelve al invocador. Las modificaciones de las cuentas realizadas por el programa invocado son visibles para el invocador, lo que permite que el estado fluya por la cadena de llamadas. Si cualquier programa de la cadena de CPI falla, se cancela toda la transacción y se revierten todos los cambios de estado.

Nota: sol_invoke es una función auxiliar que llama a sol_invoke_signed sin semillas.

Verificación posterior a la ejecución

Cuando finaliza la ejecución de un programa, ya sea directamente o como parte de una cadena de CPI, se realizan varias comprobaciones posteriores para garantizar la coherencia del estado y las invariantes de seguridad. 

Por ejemplo, el entorno de ejecución verifica que todas las cuentas marcadas como modificables sean propiedad del programa o cuenten con la firma correspondiente. Los programas no pueden modificar cuentas que no les pertenecen, a menos que esas cuentas se hayan marcado explícitamente como modificables y el propietario haya concedido permiso. Esto evita modificaciones de estado no autorizadas.

El entorno de ejecución también comprueba que la suma total de lamports de todas las cuentas de la transacción permanezca igual, a menos que los lamports se hayan transferido explícitamente mediante instrucciones del System Program. Esta comprobación de conservación evita que los programas creen o destruyan lamports, por lo que el suministro total de SOL permanece constante.

El entorno de ejecución valida que no se haya modificado ninguna cuenta con indicadores de ejecución (es decir, programas). Los datos de un programa no pueden cambiar durante la ejecución normal. Solo pueden actualizarse mediante el mecanismo de autoridad de actualización del BPF Loader.

Resultados de la ejecución

Una vez finalizada la verificación posterior a la ejecución, el resultado se devuelve al procesador de transacciones del Bank. El resultado contiene el estado de éxito o error (es decir, un resultado cero o distinto de cero, respectivamente), la cantidad de unidades de cómputo consumidas y cualquier modificación realizada en el estado de las cuentas. 

El Bank confirma de forma atómica todas las modificaciones de cuentas de las ejecuciones exitosas. Los datos actualizados de las cuentas, los saldos de lamports y los metadatos se escriben en AccountsDB y quedan visibles para las transacciones posteriores. Las unidades de cómputo consumidas se registran para calcular las comisiones de transacción y las métricas de la red.

En las transacciones fallidas no se confirma ningún cambio de estado: en Solana no hay reversiones parciales, ya que toda la transacción se revierte. Sin embargo, la comisión de la transacción se descuenta de la cuenta del pagador para compensar al validador por el trabajo computacional realizado. El código de error y las unidades de cómputo consumidas se registran en los metadatos de la transacción para fines de depuración y análisis.

El resultado de la ejecución regresa a través del planificador de Banking Stage, que actualiza sus métricas internas y continúa con la siguiente transacción. Tanto las transacciones exitosas como las fallidas se registran en el flujo de prueba de historial y contribuyen al bloque actual en construcción. Las transacciones fallidas se incluyen para ayudar a prevenir ataques de repetición y mantener un historial completo de transacciones.

Cuando el slot finaliza y alcanza su cantidad máxima de ticks, el Bank pasa a un estado congelado. La congelación es una operación unidireccional que impide confirmar nuevas transacciones y calcula el hash del Bank. Ten en cuenta que congelado no significa finalizado: el slot aún podría estar en una bifurcación que termine descartándose. 

El Bank se convierte en raíz cuando el validador llama a BankForks::set_root() para designarlo como parte de la cadena canónica. El enraizamiento activa una operación de consolidación que aplana el estado de las cuentas del Bank raíz en AccountsDB. Así combina todo el estado de los Banks principales y lo vuelve permanente desde la perspectiva del validador. Las bifurcaciones sin raíz se podan y descartan. Incluso los Banks con raíz aún no están finalizados desde la perspectiva del clúster debido a los distintos niveles de compromiso de Solana.

De cara al futuro

La máquina virtual de Solana representa un enfoque radicalmente distinto para la ejecución de blockchains: una blockchain escalable y accesible para todos gracias al procesamiento paralelo, los mercados locales de comisiones y un entorno de ejecución eficiente, determinista y derivado de eBPF. 

Para entender la SVM, es necesario examinar todo el proceso de ejecución, desde la compilación del código fuente de Rust hasta LLVM y sBPF, y finalmente el aprovisionamiento de instancias aisladas de la VM. 

No existe una única «especificación» que defina la SVM. En cambio, esta surge de la interacción entre el Bank, el planificador, los BPF Loaders, la VM de sBPF y la ISA de la SVM.

El futuro parece prometedor a medida que la SVM sigue evolucionando. 

La cadena de herramientas de Solana se está renovando por completo para eliminar la infraestructura personalizada de LLVM que ha dificultado la incorporación de desarrolladores durante años. 

El enfoque actual exige que los desarrolladores instalen cadenas de herramientas personalizadas mediante scripts específicos para cada plataforma. La solución es adoptar la misma cadena de herramientas que utiliza Aya, la biblioteca eBPF de Rust. Los desarrolladores podrán ejecutar dos comandos sencillos para compilar directamente a bytecode de eBPF:

Código
rustup toolchain install nightly
cargo build --target=bpfel-unknown-none

Sin scripts. Sin bifurcaciones personalizadas de LLVM. Solo las herramientas estándar de Rust compilando directamente a bytecode de eBPF mediante el objetivo principal bpfel-unknown-none, aprovechando los innumerables años de desarrollo del kernel de Linux y las mejoras en la infraestructura de LLVM.

La ISA de la SVM también se actualizará con SIMD-0377, que propone alinear la implementación de eBPF de Solana (es decir, sBPF) con los estándares modernos de eBPF. Esto incluye introducir variantes de instrucciones JMP32, operaciones de división y módulo con signo, saltos indirectos y marcos de pila dinámicos. Estos cambios ayudarán a reducir los costos de cómputo de los programas, mejorar la compatibilidad con la infraestructura principal de LLVM y permitir una generación de código más eficiente. 

La SVM es, en esencia, un sistema medido mediante presupuestos de cómputo. SIMD-0370 podría cambiar su funcionamiento al eliminar el límite de cómputo por bloque y, posiblemente, el límite por transacción. Eliminar estos límites de cómputo permitiría a los productores de bloques maximizar el rendimiento según las capacidades de su hardware en lugar de estar sujetos a límites artificiales. En combinación con el mecanismo de tiempo de espera de Alpenglow, este cambio permitiría que las fuerzas del mercado, en lugar de las restricciones del protocolo, determinen el tamaño óptimo de los bloques. Por supuesto, esto es una propuesta muy a futuro, ya que Anza primero quiere aumentar el límite de CUs a más de 100 millones antes de eliminar esos límites.

Todos estos cambios se sustentan en una filosofía de creación que busca ampliar los límites de lo que pueden lograr las blockchains sin sacrificar la seguridad, el determinismo ni la descentralización. 

La SVM no es simplemente un intérprete de bytecode: es un proceso de ejecución completo que revoluciona las capacidades de las blockchains. Es la culminación de decisiones arquitectónicas que priorizan el rendimiento y la baja latencia. A medida que Solana madure, la SVM seguirá evolucionando para admitir aplicaciones de alto rendimiento y con uso eficiente del capital.

El sueño de los mercados de capitales de Internet requiere una infraestructura capaz de satisfacer las necesidades de rendimiento, latencia y costos de los sistemas financieros globales. La máquina virtual de Solana es un paso fundamental para hacer realidad esa visión.

Recursos adicionales

Suscríbete a Helius

Mantente al día con las novedades del desarrollo en Solana y recibe actualizaciones cuando publiquemos

Imagen ampliada