
O que é a Solana Virtual Machine (SVM)?
Índice
- Insights práticos
- Introdução
- Uma definição controversa
- Visão geral da SVM
- Máquinas virtuais
- Máquinas virtuais em blockchains
- Como a SVM funciona
- O que torna a SVM especial: declarações antecipadas de contas
- Uma mudança de paradigma
- Do código-fonte Rust ao bytecode sBPF: o pipeline de compilação
- Rust
- Anatomia básica de um programa
- O compilador Rust e o LLVM IR
- eBPF
- sBPF
- A SVM ISA
- Syscalls
- Binário do programa
- Como o bytecode é enviado para a Solana
- O programa BPF Loader
- Arquitetura de implantação: modelos de contas
- Como os programas Solana são implantados
- Como a execução funciona na SVM
- Transações
- A Transaction Processing Unit (TPU)
- Coordenação do Bank
- Carregamento do programa
- Compilação JIT
- Provisionamento da VM sBPF
- Execução do programa
- Verificação pós-execução
- Resultados da execução
- Perspectivas futuras
- Recursos adicionais
Agradecemos muito a Lostin, Alessandro, Brian, Brady e Daniel Cumming pela revisão das versões anteriores deste trabalho.
Insights práticos
- A SVM abrange toda a pilha de execução de transações, ao contrário da EVM, que se refere inequivocamente a um executor de bytecode.
- Exigir que as transações declarem quais contas acessarão antes da execução viabiliza a execução paralela entre núcleos de CPU e mercados de taxas localizados.
- O código-fonte Rust é compilado pelo rustc para LLVM IR; depois, o backend eBPF do LLVM, especificamente o fork sBPF da Solana, o converte em bytecode sBPF. Isso significa que qualquer linguagem com um frontend LLVM (por exemplo, C, C++, Zig) pode ser usada para escrever programas da Solana.
- sBPF é o fork da Solana do eBPF do Linux e, para todos os efeitos, é praticamente igual, com algumas adições históricas que foram introduzidas. No entanto, essas adições serão revertidas. A única diferença relevante é que as funções do eBPF upstream têm no máximo 5 argumentos, enquanto o fork da Solana pode ter mais.
- O bytecode sBPF compilado é armazenado em arquivos ELF que contêm seções para instruções e constantes, além de tabelas de relocação. O linker resolve referências de syscall para hashes Murmur3 determinísticos de 32 bits e reescreve funções internas como saltos relativos para garantir portabilidade.
- O BPF Loader Upgradeable usa um modelo de duas contas para upgrades e implantações in-place, enquanto o futuro Loader V4 simplifica isso para uma única conta com compressão opcional. Todo bytecode é verificado estaticamente antes de ser marcado como executável.
- As transações contêm um array de endereços de contas com permissões de leitura e escrita, instruções e assinaturas. Esse formato estruturado permite detectar conflitos e agendar transações sem conflito em paralelo.
- O Banking Stage da TPU é responsável por agendar transações sem conflito em paralelo. O Bank carrega o estado das contas a partir do AccountsDB. Os BPF Loaders provisionam VMs sBPF isoladas com regiões de memória delimitadas e orçamentos de computação. Alterações de estado bem-sucedidas são confirmadas atomicamente, enquanto falhas são revertidas por completo.
- A SVM ISA é a única especificação formal — além do whitepaper do Alpenglow e da série de artigos inicialmente escrita por Toly, não existe uma única “especificação da SVM”. O runtime surge da interação entre o Bank, o agendador, os BPF Loaders e a própria VM sBPF.
Introdução
A Solana Virtual Machine (SVM) é um dos sistemas mais incompreendidos das blockchains atuais. Ao contrário da Ethereum Virtual Machine (EVM), que se refere inequivocamente a um executor de opcodes, o termo SVM abrange todo um pipeline de execução de transações, desde o agendador do Banking Stage até o próprio interpretador de bytecode sBPF. Essa ambiguidade reflete a diferença arquitetural da Solana: não há uma especificação tradicional que defina “a SVM” de forma isolada. A especificação que mais se aproxima disso é a Solana Virtual Machine Instruction Set Architecture (SVM ISA), que descreve como o bytecode sBPF deve ser executado, mas não diz nada sobre o runtime mais amplo.
Este artigo pretende servir como uma referência abrangente sobre o que é a SVM, como ela funciona e por que é fundamentalmente diferente, sob a perspectiva da implementação do validator Agave da Anza. Analisar como o cliente Firedancer funciona, assim como sua implementação personalizada de máquina virtual em conformidade com a SVM ISA, está fora do escopo deste trabalho.
Nosso interesse é examinar a base de código real, e não uma especificação abstrata. Percorremos todo o pipeline de execução: como o código-fonte Rust é compilado pelo LLVM em bytecode sBPF, como os programas são implantados e verificados, como o runtime provisiona ambientes de execução isolados para execução paralela e como as transações interagem com o bytecode implantado.
As primeiras seções contextualizam a ambiguidade da SVM e apresentam uma visão geral de alto nível sobre seu funcionamento. O restante do artigo se destina a um público mais técnico que busca compreender de forma rigorosa a camada de execução da Solana.
Uma definição controversa
O termo “Solana Virtual Machine” (SVM) provocou debates acalorados na comunidade, especialmente desde o surgimento de extensões de rede e outras blockchains de camada construídas sobre a Solana. A controvérsia decorre do escopo do termo: a SVM é estritamente o interpretador sBPF de baixo nível ou abrange toda a pilha de execução de transações?
A visão restrita trata a SVM como análoga a uma máquina virtual (VM) tradicional, como o executor de opcodes da EVM. Mais especificamente, como a máquina virtual derivada do eBPF (rBPF, agora sBPF) que interpreta e compila bytecode via JIT. Essa perspectiva enfatiza a SVM como um executor em sandbox baseado em registradores, que processa instruções como operações de ALU ou chamadas de sistema específicas da Solana. Em essência, a SVM é inspirada no modelo de segurança do eBPF do Linux, mas adaptada à infraestrutura de blockchain. Isso está alinhado a expressões como SVM ISA (Instruction Set Architecture) no código do validator, em que a SVM é somente a camada da VM.
A visão ampla define a SVM como a camada de execução de transações inteira de um validator da Solana. Isso inclui mais do que apenas executar bytecode; também abrange componentes upstream, como o agendador do Banking Stage, o orçamento de unidades de computação e as atualizações de estado por meio do banco de dados de contas, conhecido informalmente como AccountsDB. É o “runtime” que transforma transações brutas em alterações de estado validadas.
A ambiguidade surge porque as comunicações oficiais da Solana usam os termos “runtime” e “SVM” de forma intercambiável, sem uma definição única e estabelecida. A Anza trouxe a clareza tão necessária ao debate, validando-o explicitamente e promovendo uma perspectiva pragmática, orientada à ação e fundamentada em engenharia. O enquadramento da SVM como o runtime comandado pelo Bank que provisiona a VM eBPF oferece uma visão muito mais ampla, que inclui o pipeline e pode ser usada para formular uma definição adequada da SVM.
Isso é formalizado na especificação oficial da SVM da Anza, que define a SVM como “os componentes responsáveis pela execução de transações”, empacotados como uma biblioteca independente para validators, provas de fraude, sidecars e muito mais.
Para nossos propósitos, podemos definir a Solana Virtual Machine como:
A interface de runtime desacoplada e o pipeline de processamento de transações nos validators da Solana, comandados pelo componente Bank, que orquestram a execução paralela de instruções e programas on-chain, provisionando uma máquina virtual personalizada baseada em eBPF para interpretação segura de bytecode, compilação JIT e medição de recursos.
Visão geral da SVM
A Solana Virtual Machine (SVM) funciona como o ambiente de execução para processar transações que interagem com programas on-chain em toda a rede. É a camada de runtime em que o código encontra o estado — o ambiente de execução que transforma transações assinadas criptograficamente em alterações de estado validadas.
Para realmente entender a SVM, primeiro precisamos entender o significado de uma máquina virtual no contexto das blockchains.
Máquinas virtuais
Uma máquina virtual (VM) é um software que virtualiza ou emula um sistema computacional, fornecendo um ambiente de execução isolado que se comporta como hardware físico. O conceito surgiu a partir do trabalho da IBM na década de 1960 com sistemas mainframe, que permitiam a vários usuários executar diferentes sistemas operacionais na mesma máquina física. Há duas categorias principais de VMs: VMs de sistema e VMs de processo. As primeiras substituem uma máquina real, enquanto as últimas são projetadas para executar programas em um ambiente independente de plataforma. Para nossos propósitos, estamos interessados em máquinas virtuais de sistema e, daqui em diante, vamos chamá-las de “máquinas virtuais” ou simplesmente “VMs”.
As máquinas virtuais resolvem vários problemas fundamentais. Para começar, oferecem uma camada de abstração de hardware. Ou seja, programas escritos para uma VM podem ser executados em qualquer hardware físico compatível com a VM, sem que o programa precise ser reescrito. A filosofia do Java de “escreva uma vez, execute em qualquer lugar” exemplifica isso, pois o bytecode Java é executado de forma idêntica no Windows, macOS, Linux e em outros sistemas com uma Java Virtual Machine (JVM) instalada.
As VMs também oferecem garantias de isolamento e segurança. Cada instância de VM opera em uma sandbox, o que significa que não pode acessar os recursos do sistema host nem outras VMs, a menos que isso seja explicitamente permitido. Assim, se um programa falhar ou contiver código malicioso, o dano fica restrito àquela instância da VM. Esse princípio de isolamento explica por que provedores de nuvem como Google Cloud e AWS usam VMs para separar as cargas de trabalho dos clientes.
As VMs também oferecem resultados previsíveis. Isso significa que fornecem um ambiente controlado no qual a mesma entrada sempre produz a mesma saída, independentemente do hardware subjacente. Essa previsibilidade é crucial para depuração, testes e obtenção de consenso entre sistemas distribuídos.
As VMs também podem ter desempenho extremamente alto. VMs modernas usam compilação Just-In-Time (JIT) para minimizar o overhead de desempenho. A compilação JIT traduz o bytecode da VM em código de máquina nativo durante o runtime para alcançar desempenho próximo ao nativo, mantendo a portabilidade e as garantias de segurança mencionadas anteriormente.
Máquinas virtuais em blockchains
As blockchains adaptaram o conceito de VM para resolver um desafio singular: como milhares de computadores independentes em todo o mundo executam código não confiável e chegam a resultados idênticos? As VMs funcionam como o ambiente de runtime determinístico que executa contratos inteligentes (ou seja, programas na Solana) e gerencia o estado da rede (ou seja, a situação atual de todas as contas, saldos e outros dados na rede).
Quando uma transação é enviada a uma blockchain, a VM é responsável por:
- Carregar do armazenamento os dados necessários das contas.
- Executar o bytecode do programa especificado na transação.
- Medir o consumo de recursos para evitar loops infinitos ou ataques de negação de serviço (DoS).
- Validar se todas as alterações de estado seguem as regras de consenso predefinidas da rede.
- Confirmar o estado atualizado de volta no armazenamento permanente (ou seja, o ledger).
As regras específicas sobre como ocorrem as transições de estado são definidas pela arquitetura do conjunto de instruções da VM e pelas restrições do runtime.
Como a SVM funciona
A SVM é um pipeline de subsistemas que trabalham em conjunto para executar transações com segurança e eficiência. O Bank orquestra a execução de um slot específico, gerenciando o estado das contas, aplicando regras de consenso e coordenando o Banking Stage com o armazenamento persistente (ou seja, o AccountsDB). Cada Bank representa o estado de todas as contas em um slot específico e passa por três ciclos de vida: ativo (ou seja, aberto a novas transações), congelado (ou seja, fechado a novas transações porque o slot foi concluído) e enraizado (ou seja, parte da cadeia canônica).
O Banking Stage é onde ocorre a execução de transações dentro da Transaction Processing Unit (TPU) de um validator. Ele recebe transações verificadas do estágio SigVerify, armazena-as em buffer e as agenda para execução paralela usando a detecção de conflitos em bloqueios de contas. As threads de trabalho no Banking Stage processam lotes de transações sem conflito, chamando os métodos de execução do Bank para carregar contas, provisionar instâncias da VM sBPF para cada instrução, executar o bytecode do programa e coletar os resultados. O Banking Stage continua processando lotes de transações sem conflito até que o Bank seja congelado no limite do slot. Observe que lotes são diferentes de entradas, que são as unidades registradas de transações gravadas no ledger para replicação e consenso.
Os BPF Loaders gerenciam o ciclo de vida do programa: implantação, compilação JIT, upgrades e execução. Quando uma instrução tem como destino determinado programa, uma VM sBPF é provisionada com suas próprias regiões de memória e seu próprio orçamento de computação, e a execução é transferida para o bytecode do programa.
A VM sBPF é o ambiente de execução em sandbox no qual o bytecode do programa realmente é executado. Ela deriva do eBPF do Linux e usa uma arquitetura baseada em registradores com 11 registradores de uso geral. A VM impõe isolamento de memória por meio de cinco regiões distintas, cada uma com limites e permissões explícitos. A VM também mede o consumo de unidades de computação para evitar execuções descontroladas e despacha chamadas de sistema para operações privilegiadas, como criptografia, logging ou Cross-Program Invocations (CPIs).
O AccountsDB é a camada de estado persistente em que todos os dados das contas residem. O estado das contas é carregado antes da execução, aproveitando caches para evitar leituras repetidas do disco para contas acessadas com frequência. Após uma execução bem-sucedida, as atualizações são confirmadas no AccountsDB. Se a execução falhar, todas as alterações de estado são revertidas atomicamente.
Juntos, esses componentes formam a SVM, um mecanismo de execução desacoplado e reutilizável.
O que torna a SVM especial: declarações antecipadas de contas
A decisão arquitetural que define a SVM é que todas as transações devem declarar explicitamente quais contas lerão e em quais escreverão antes do início da execução. Esse requisito simples, incorporado ao próprio formato da transação, viabiliza dois recursos transformadores que diferenciam a Solana: execução paralela e mercados de taxas localizados.
Execução paralela (Sealevel)
Ao contrário da Ethereum Virtual Machine (EVM), que processa transações sequencialmente — uma por vez, aguardando que cada uma seja concluída antes de passar à próxima —, a SVM permite escalabilidade horizontal ao executar várias transações simultaneamente em múltiplos núcleos de CPU. Essa paralelização é possível porque todas as transações da Solana declaram explicitamente quais contas lerão e em quais escreverão antes do início da execução.
Declarar quais contas uma transação lerá e em quais escreverá permite que o runtime analise as dependências entre contas para detectar conflitos e agendar transações sem conflito:
- Transações que acessam contas completamente diferentes podem ser executadas em paralelo sem nenhum overhead de coordenação.
- Transações que apenas leem as mesmas contas também podem ser executadas em paralelo, pois as leituras não entram em conflito.
- Transações que tentam escrever nas mesmas contas são executadas sequencialmente para evitar condições de corrida e garantir a consistência do estado.
Mercados de taxas locais
Como o runtime sabe exatamente quais contas cada transação acessará antes da execução, as taxas podem ser localizadas em contas específicas, em vez de competir globalmente em toda a rede. Esse conceito é conhecido como mercados de taxas locais.
Na Ethereum e em outras cadeias EVM, todas as transações competem em um único mercado global de taxas — enviar ETH a um amigo, criar um NFT ou negociar na Uniswap: todas disputam o mesmo espaço de bloco. Um aumento de atividade em um domínio eleva as taxas para todos, independentemente de estarem tentando fazer algo completamente diferente.
Na Solana, apenas as transações que acessam as mesmas contas competem entre si. Um usuário transferindo SOL entre duas contas não deveria precisar se preocupar com o mint de um NFT popular acontecendo ao mesmo tempo. A taxa de prioridade de uma transação é determinada exclusivamente pela contenção de contas. Essa localização explica por que as transações da Solana podem continuar baratas, mesmo durante períodos de alta atividade.
Por exemplo, em 10 de outubro, o mercado de criptoativos passou pelo maior evento de liquidação de sua história. Apesar do aumento recorde na atividade, as transações da Solana continuaram relativamente baratas: a taxa mediana por transação chegou a US$ 0,007, as taxas médias atingiram brevemente US$ 0,10 e o 1% das transações com taxas mais altas alcançou pouco mais de US$ 1,00. No mesmo período, tanto a Ethereum quanto a Arbitrum registraram taxas medianas acima de US$ 100, enquanto as taxas da Base atingiram mais de US$ 3.
Uma mudança de paradigma
| Aspecto | EVM | SVM |
| Arquitetura | VM baseada em pilha | VM baseada em registradores (derivada do eBPF) |
| Execução | Sequencial | Paralela (detecção de conflitos) |
| Mercado de taxas | Global | Localizado (contenção por conta) |
| Declarações de contas | Não exigidas antecipadamente | Exigidas antecipadamente |
| ISA | ~140 opcodes, operações de pilha | ~100 opcodes, registradores semelhantes a RISC |
| Compilação JIT | Opcional (depende do cliente) | Padrão (desempenho nativo) |
| Modelo de estado | Taxas de armazenamento de contratos | Banco de dados simples de contas |
| Linguagens | Solidity/Vyper → bytecode EVM | Rust/C/C++ → LLVM → sBPF |
A SVM representa uma abordagem fundamentalmente diferente para a execução em blockchain. O Bitcoin introduziu o dinheiro programável. A Ethereum introduziu contratos inteligentes de uso geral e execução on-chain arbitrária. No entanto, ambos são limitados pela execução sequencial e por mercados globais de taxas — decisões arquiteturais que impõem limitações fundamentais tanto ao throughput quanto ao custo.
A SVM rompe com as restrições tradicionais ao oferecer uma rede capaz de lidar com alto throughput sem sacrificar a programabilidade nem forçar os usuários a participar de leilões com taxas proibitivas. A decisão de exigir declarações antecipadas de contas é simples, mas poderosa, pois permite execução paralela entre núcleos de CPU e localiza as taxas em mercados no nível das contas.
É claro que essas não são as únicas otimizações que a Solana oferece em comparação com outras blockchains. O lema da Solana, Aumentar a largura de banda, reduzir a latência, e sua obsessão extrema em concretizar o sonho dos Mercados de Capitais da Internet resultaram em diversas otimizações de desempenho, escolhas de design e implementações para cultivar uma rede de alto throughput.
O restante deste artigo explora exatamente como isso funciona: como o código-fonte Rust é compilado em bytecode, como esse bytecode é implantado e verificado e como o runtime provisiona ambientes de execução isolados para executar com segurança milhares de programas em paralelo, mantendo determinismo rigoroso e garantias de segurança.
Do código-fonte Rust ao bytecode sBPF: o pipeline de compilação
Rust
Rust é a lingua franca do desenvolvimento de programas da Solana. Frameworks como o Anchor oferecem uma abordagem robusta e opinativa para que desenvolvedores criem programas seguros com eficiência. solana_program foi projetada para ser a biblioteca básica de todos os programas on-chain. Recentemente, Pinocchio, uma biblioteca altamente otimizada e sem dependências, tornou-se a opção preferida para desenvolvedores que buscam criar programas nativos da Solana.
Independentemente do framework ou da biblioteca usada, todos os programas têm um entrypoint que o runtime chama quando o programa é invocado. A macro entrypoint de solana_program emite o código boilerplate padrão necessário para iniciar a execução do programa. Ou seja, desserializa entradas e configura um alocador global e um manipulador de panic. Pinocchio exporta macros de entrypoint que funcionam de modo semelhante, mas desacoplam o entrypoint da configuração do alocador de heap e do manipulador de panic, oferecendo mais opções aos desenvolvedores.
Anatomia básica de um programa
Um programa é um tipo de conta capaz de executar código. Mais especificamente, um programa é uma conta executável que armazena um blob de bytecode sBPF em uma conta pertencente ao BPF Loader, com uma chave pública exclusiva. Por design, os programas não têm estado: todos os dados persistentes residem em contas separadas, que os programas podem ler ou nas quais podem escrever quando são invocados.
A SVM espera que todos os programas tenham uma estrutura específica — um entrypoint que aceita três entradas:
- ID do programa: o endereço do próprio programa, usado para verificações autorreferenciais (por exemplo, propriedade).
- Contas: um array de metadados de contas (ou seja, pubkeys, saldos em lamports, buffers de dados, proprietários e flags). Elas são o “estado” que um programa precisa ler e modificar.
- Dados da instrução: um slice de bytes com dados arbitrários da transação.
Espera-se que um programa processe essas entradas por meio de seu entrypoint, altere as contas graváveis relevantes, emita logs ou eventos e retorne um status de sucesso indicando se conseguiu fazer tudo isso. Isso se resume a uma função process_instruction.
Um programa simples escrito em Rust usando o crate solana_program se parece com isto:
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(())
}Nos bastidores, tudo isso é definido pela Application Binary Interface (ABI) da SVM, que exploraremos mais adiante.
O compilador Rust e o LLVM IR
Rust, como muitas outras linguagens de programação, é uma abstração de segunda ordem construída sobre assembly. Ela foi projetada para que humanos escrevam código seguro, concorrente e legível sem microgerenciar cada pequena interação com o hardware. No entanto, computadores não entendem Rust nem qualquer outra linguagem de alto nível.
Computadores entendem código de máquina — instruções binárias adaptadas a uma arquitetura ou máquina virtual específica. Todos os programas acabam sendo convertidos em código binário, e essa tradução é, em última instância, feita por computadores. Compilação é um processo de tradução em várias etapas que remove abstrações de alto nível, otimiza a eficiência e gera bytecode executável.
rustc é o compilador oficial do Rust. A maioria dos desenvolvedores normalmente não interage diretamente com rustc; em vez disso, o invoca por meio do Cargo, o gerenciador de pacotes do Rust. Ainda assim, rustc conduz o código-fonte Rust por três etapas principais antes de emitir bytecode executável. Cada etapa remove abstrações, aplica garantias de segurança e prepara o código para a próxima transformação:
- Parsing e expansão
- MIR (Mid-level Intermediate Representation)
- LLVM IR (Low Level Virtual Machine Intermediate Representation)
Parsing e expansão
O compilador lê um determinado arquivo Rust, indicado pela extensão .rs, como texto simples. Ele procura tokens específicos (por exemplo, use, fn, None, impl, &[u8]) em um processo conhecido como análise léxica. A tokenização léxica é o processo de converter texto em tokens léxicos significativos que pertencem a determinada categoria (por exemplo, identificadores, operadores, separadores, literais, palavras-chave).
rustc pega esses tokens léxicos e os converte em uma estrutura de dados conhecida como Abstract Syntax Tree (AST). Essa estrutura em árvore representa a estrutura aninhada e hierárquica do código-fonte Rust. Ou seja, funções contêm blocos, blocos contêm expressões, expressões contêm operadores e assim por diante. Embora ainda esteja em alto nível, a AST fornece uma representação fiel do código-fonte e de sua lógica subjacente.
Depois que a AST é criada, o compilador realiza várias transformações importantes:
- Expansão de macros: macros como entrypoint! e println! são expandidas em código Rust bruto, de modo que todas sejam convertidas em nós simples da AST.
- Lowering: a sintaxe abreviada de alto nível, criada para tornar o código mais legível, é reescrita em formas mais primitivas em um processo chamado lowering. Por exemplo, um loop for é transformado em um loop com iteração manual. O resultado do lowering é a High-Level Intermediate Representation (HIR).
- Verificação de empréstimos e análise de segurança: Rust pega a HIR e realiza verificação de tipos, resolução de traits e inferência de tipos. O resultado desse processo é a Typed High-Level Intermediate Representation (THIR).
Nessa etapa, o compilador processa código unsafe. O código unsafe permite que desenvolvedores realizem operações que contornam as garantias de segurança do Rust (por exemplo, desreferenciar ponteiros brutos, chamar funções externas ou implementar traits unsafe). Ele funciona como uma saída deliberada para obter controle de baixo nível, flexibilizando regras específicas e ainda exigindo que o código seja compilado de acordo com a semântica do Rust. Isso é essencial para programas da Solana, nos quais código unsafe pode ser usado com moderação para melhorar o desempenho de operações críticas, como a desserialização zero-copy de contas (por exemplo, na struct wrapper do Pinocchio para uma conta).
O código unsafe é identificado pela primeira vez após a análise léxica, durante a construção da AST, quando os tokens unsafe são reconhecidos e marcados como nós especiais. Depois, ele é processado após a expansão da AST, durante as verificações de tipos e empréstimos. O compilador garante que as operações unsafe estejam restritas a contextos unsafe e, caso contrário, sinaliza erros (por exemplo, “não é possível desreferenciar um ponteiro bruto fora de unsafe”). O compilador não verifica, por exemplo, se o código unsafe está corrompendo ou gerenciando incorretamente a memória.
Ao final dessa etapa, a AST expandida, agora THIR, é uma representação validada e rebaixada do código-fonte Rust.
MIR
A THIR passa então por lowering para a Mid-Level Intermediate Representation (MIR), uma forma centrada em Rust que representa o código-fonte como um grafo de fluxo de controle (CFG) simplificado. Todo o açúcar sintático e as construções complexas específicas do Rust (por exemplo, correspondência de padrões, traits e closures) são expressos em termos de blocos básicos, que incluem atribuições e ramificações. Esses blocos básicos são conectados por saltos ou, mais especificamente, Gotos, e ramificações, facilitando a compreensão do fluxo de um programa.
A MIR não é estritamente necessária. O compilador poderia simplesmente fazer o lowering da THIR diretamente para LLVM IR. No entanto, a MIR oferece uma camada ciente do Rust que permite ao compilador aplicar regras específicas da linguagem e realizar otimizações antes das otimizações genéricas do LLVM. Isso torna a MIR perfeita para verificações e transformações que são de alto nível demais para o LLVM, mas de baixo nível demais para a THIR, incluindo:
- Verificação de empréstimos: as verificações semânticas iniciais ocorrem durante a análise de tipos na THIR. No entanto, o CFG simplificado da MIR permite uma verificação de empréstimos completa e precisa para aplicar todas as regras de propriedade, empréstimos e tempo de vida.
- Verificação de move e drop: o compilador garante que todos os valores sejam movidos e descartados de acordo com as garantias de segurança de memória do Rust, evitando erros de uso após liberação.
- Análise de inicialização: o compilador garante que todas as variáveis sejam inicializadas antes do uso.
- Inlining e otimizações iniciais: o compilador pode incorporar pequenas funções, simplificar expressões aritméticas e eliminar código inacessível.
Por exemplo, anteriormente invocamos msg!(“Hello, Solana!”) em nosso programa Rust de exemplo. Essa é uma macro definida no crate solana-program que, para expressões únicas como uma string estática, é expandida durante a fase de expansão de macros para chamar diretamente sol_log($msg), em que $msg é a expressão. A syscall sol_log recebe um ponteiro para os dados da string e seu comprimento, registrando-os na saída da SVM sem o overhead de formatação. Isso pode ser simplificado na MIR para:
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(());
}
Aqui,
- _0 = const “Hello, Solana!”; — a MIR introduz valores temporários (ou seja, _0) para valores intermediários. A string é tratada como um slice constante, alocado em dados somente leitura.
- _1 = len(_0); — a MIR expõe uma operação simples de comprimento no slice para uma possível propagação de constantes.
- sol_log(move _0, move _1); — a invocação da syscall, que é traduzida em uma sequência de instruções sBPF que carrega registradores e chama o ID da syscall. Explicaremos exatamente o que isso significa em seções posteriores, mas o importante é observar que esses moves estão ligados à semântica de propriedade do Rust e a aplicam durante a compilação.
- Return = Ok(()); — encerra o bloco com um terminador, sinalizando sucesso para a SVM.
O foco da MIR na semântica do Rust faz dela o lugar ideal para identificar ineficiências e depurar padrões com alto consumo de CU. Por exemplo, se seu log contiver strings dinâmicas, a MIR poderá identificar alocações ou loops adicionais que poderiam ser otimizados. Os desenvolvedores podem usar o comando cargo rustc -- -Z dump-mir=all para gerar um dump da MIR.
A MIR garante que o código seja semanticamente correto, otimizado e livre de todas as regras específicas do Rust antes de finalmente passar por lowering para LLVM IR.
Observe que isso costuma ser chamado de fase de geração de código. Ela não precisa necessariamente usar LLVM. No entanto, LLVM é comum e é o que a maioria das pessoas associa à geração de código Rust. O compilador Rust também inclui backends GCC e Cranelift, que emitem GIMPLE e CLIF, respectivamente. Nosso foco é LLVM IR no contexto da Solana, mas isso nem sempre acontece com Rust em geral.
LLVM IR
LLVM, que originalmente significava "Low Level Virtual Machine", refere-se a um framework modular de compilação. Em vez de ser um único compilador, LLVM é um conjunto de componentes reutilizáveis para criar compiladores, otimizadores e geradores de código. Muitas linguagens (por exemplo, Rust, C, C++, Julia, Swift, Brainfuck, Zig) usam LLVM para aproveitar sua capacidade de atingir diversas arquiteturas, desde CPUs x86 até ISAs virtuais.
rustc traduz a MIR em LLVM IR (Low Level Virtual Machine Intermediate Representation) — a ponte entre a semântica do Rust e o bytecode que será implantado na Solana. Ela está muito mais próxima do código de máquina, com alocações explícitas de memória (ou seja, alloca), armazenamentos, carregamentos e chamadas de função. Ela não tem noção de propriedade, tempos de vida ou traits, pois essas abstrações do Rust já foram expandidas e deixaram de existir — as garantias das etapas anteriores são preservadas daí em diante.
Diversas otimizações são aplicadas nessa etapa, incluindo:
- Propagação de constantes (ou seja, avaliação de constantes durante a compilação).
- Inlining (ou seja, substituição de uma chamada pelo corpo da função).
- Eliminação de código morto (ou seja, remoção de instruções que não afetam os resultados).
- Desenrolamento e vetorização de loops (ou seja, reescrita de loops para execução mais rápida).
Assim, LLVM nos fornece:
- LLVM IR: um formato intermediário portátil semelhante a assembly.
- Passes de otimização: para criar LLVM IR, LLVM usa Static Single Assignment (SSA), que garante que cada variável seja atribuída exatamente uma vez, permitindo otimizações como inlining e eliminação de código morto.
- Geradores de código: destinos que fazem o lowering de LLVM IR para código de máquina real (ou seja, x86_64, ARM, WebAssembly, eBPF).
Programas Rust normalmente seriam compilados para destinos de hardware como x86_64 ou ARM. No entanto, programas da Solana não são executados diretamente no hardware. Em vez disso, são executados dentro da Solana Virtual Machine. Portanto, o backend LLVM faz o lowering de LLVM IR para bytecode BPF, que, na Solana, torna-se bytecode sBPF (ou seja, um fork do eBPF que remove recursos não determinísticos e introduz syscalls específicas da Solana).
Observe que, embora Rust seja a lingua franca do desenvolvimento de programas da Solana, qualquer linguagem que possa usar como destino o backend BPF do LLVM (por exemplo, C, Nim, Swift, Zig) pode ser usada.
eBPF
LLVM IR passa por lowering para eBPF, a ISA baseada em registradores que forma a base do runtime da Solana. eBPF (Extended Berkeley Packet Filter) surgiu a partir do Berkeley Packet Filter (BPF), desenvolvido em 1992 por Steven McCanne e Van Jacobson enquanto trabalhavam no Lawrence Berkeley Laboratory para sistemas Unix Berkeley Software Distribution (BSD). Essencialmente, BPF é um tap de rede e filtro de pacotes que permite capturar e filtrar pacotes de rede no nível do sistema operacional sem copiar dados, utilizando qualificadores.
Desde então, eBPF evoluiu (ou seja, foi estendido) para uma VM de uso geral em sandbox dentro do kernel Linux. O que ele viabiliza é análogo ao que JavaScript possibilitou para o desenvolvimento web — um mecanismo seguro de scripts para kernels. eBPF permite que desenvolvedores executem programas pequenos e verificados diretamente no kernel Linux, com um conjunto restrito de instruções para tarefas como monitoramento de desempenho, observabilidade, segurança e redes.
Isso é importante porque oferece aos desenvolvedores:
- Execução em sandbox: programas eBPF são executados em uma máquina virtual restrita dentro do kernel, o que significa que não podem causar falhas nem corromper a memória do kernel.
- Garantias de segurança: o bytecode eBPF é verificado estaticamente antes do carregamento para garantir que não ocorram acessos inválidos à memória, saltos para fora dos limites ou outras operações privilegiadas, oferecendo segurança sem overhead de runtime.
- Eficiência: eBPF é baseado em registradores (em vez de pilha, como a EVM) e pode ser compilado via JIT em código de máquina para atingir velocidades próximas às nativas graças ao seu design leve (ou seja, sem o overhead de um sistema operacional completo).
- Flexibilidade: eBPF expõe chamadas de sistema, também conhecidas como syscalls, que são essencialmente pontos de acesso a recursos do kernel. As syscalls podem ser ampliadas com novos recursos sem exigir que o conjunto de instruções seja reformulado.
A Solana precisava de uma VM determinística, segura e de alto desempenho para executar programas não confiáveis em todo o seu conjunto de validators. eBPF oferece um modelo de segurança comprovado, uma ISA portátil e eficiente projetada para executar milhares de programas leves e suporte a JIT para melhorar o desempenho. Assim, em vez de inventar uma VM totalmente nova, a Solana criou um fork do eBPF: o sBPF.
sBPF
Inicialmente, a Solana Labs criou um fork do rBPF de Quentin Monnet para desenvolver a versão da Solana do rBPF, garantindo que todos os validators tivessem um formato de bytecode capaz de produzir exatamente os mesmos resultados ao executar determinado programa e entrada.
Acreditava-se que um fork do eBPF era necessário porque o consenso da Solana exigia execução determinística e uso limitado de recursos. Embora o próprio eBPF seja determinístico, a Solana precisava de garantias adicionais e funcionalidades específicas de blockchain:
- Tempos e custos fixos de instruções.
- Execução no espaço do usuário.
- Um runtime determinístico.
Um ponto importante é que ele foi projetado para ser executado no espaço do usuário, e não no kernel, evitando assim a necessidade de privilégios ou modificações no kernel. Isso permite a implantação em diversos ambientes de sistema operacional sem exigir acesso root ou módulos personalizados do kernel. O espaço do usuário é uma escolha prática para a Solana: oferece portabilidade, testes e implantação mais fácil. Apesar de ser executado no espaço do usuário, o JIT ainda alcança desempenho próximo ao nativo. Além disso, a execução no espaço do usuário também permite realizar testes e fuzzing sem acesso ao kernel.
rBPF não é mais usado. Quando a Anza foi criada, ela desenvolveu um fork do rBPF para criar o sBPF (Solana Berkeley Packet Filter). O repositório do rBPF no GitHub, pertencente à Solana Labs, foi arquivado em 10 de janeiro de 2025.
A SVM ISA
A SVM ISA (Solana Virtual Machine Instruction Set Architecture) é a especificação central que define como VMs compatíveis com a Solana (por exemplo, o sBPF da Agave ou a reimplementação da Firedancer) devem executar programas. Ela não é a VM em si, mas o padrão ou contrato que garante consistência e conformidade com o protocolo entre diversas implementações da SVM. É a SVM ISA que impõe essas restrições de segurança e determinismo ao eBPF, removendo recursos voltados ao kernel e adicionando funcionalidades específicas de blockchain.
A ISA rege registradores, codificação de instruções, opcodes, classes, regras de verificação, condições de panic e a Application Binary Interface (ABI). Qualquer alteração na SVM ISA deve ser implementada por meio de SIMDs para permitir a evolução controlada desse conjunto de instruções, garantindo execução determinística entre validators.
Registradores
Registradores são pequenos espaços de armazenamento dentro da VM que guardam números ou endereços enquanto as instruções são executadas, de forma semelhante a variáveis ou caixas etiquetadas em uma bancada. A SVM ISA define uma arquitetura de registradores de 64 bits com 11 registradores de uso geral (R0-R10) e um contador de programa oculto. Os registradores têm 64 bits para inteiros e endereços, permitindo o processamento eficiente de valores grandes ou ponteiros. R0 armazena valores de retorno de funções; R1-R5 passam os primeiros cinco argumentos de função como parâmetros; R6-R9 são preservados pelo chamado e persistem entre chamadas de função; e R10 funciona como um ponteiro de frame somente leitura que marca o frame atual da pilha. O contador de programa oculto acompanha a execução e indica qual instrução deve ser executada em seguida.
Instruções
Uma instrução é uma única operação que a VM sabe realizar, como “somar estes dois números” ou “saltar para esta linha de código”. As instruções têm um design semelhante a RISC, com aproximadamente 100 opcodes, em comparação com os milhares presentes em arquiteturas CISC como x86, o que torna a verificação rápida e a compilação JIT eficiente.
As instruções são codificadas como valores de 64 bits no formato Little Endian, com a seguinte estrutura:
- opcode: 8 bits
- dst_reg: 4 bits
- src_reg: 4 bits
- offset: 16 bits (com sinal)
- immediate: 32 bits (com sinal)
opcode indica o que fazer, dst_reg indica para onde vai o resultado, src_reg indica de onde vem a entrada, offset indica qual deslocamento de memória consultar e immediate é uma constante adicional que pode ser incluída na instrução.
lddw, ou load double word, é a única instrução larga que ocupa dois slots de 64 bits para comportar valores imediatos completos de 64 bits.
As instruções são categorizadas em classes, incluindo operações de memória, operações aritméticas ou lógicas, ramificações condicionais e incondicionais, chamadas e retornos de funções e conversão de Endianness.
Regiões de memória
A ISA identifica cinco regiões de memória, cada uma com limites explícitos (ou seja, [addr, addr+len]) para onde um programa pode ler ou escrever:
- Código do programa: as próprias instruções compiladas (leitura + execução).
- Pilha: uma área de trabalho temporária para funções (leitura + escrita, normalmente 4 KB por frame).
- Heap: memória dinâmica que um programa pode solicitar (leitura + escrita).
- Dados de entrada: bytes somente leitura enviados com uma transação.
- Dados somente leitura: constantes e valores imutáveis.
Os programas têm um mapa de memória virtual predefinido, de modo que o código do programa começa no endereço 0x000000000 ou 0x100000000, dependendo da versão da compilação; os frames da pilha começam em 0x200000000; o heap começa em 0x300000000; e os dados de entrada começam em 0x400000000.
O verificador
O verificador realiza uma análise estática antes da execução — ele examina todos os caminhos de código possíveis sem executar o programa — para garantir a segurança durante o carregamento, e não durante o runtime. Isso inclui verificar:
- Ausência de instruções desconhecidas ou incompatíveis.
- Todos os destinos de salto chegam a limites válidos de instruções, e saltos para trás são processados
- Ausência de caminhos de código inacessíveis.
- Os limites de profundidade das chamadas de função são aplicados.
- Divisão ou módulo por zero são rejeitados estaticamente.
- Os programas têm um limite máximo de tamanho e precisam respeitá-lo.
Embora seja útil, o verificador não impede que um desenvolvedor introduza comportamentos inesperados. Ou seja, os desenvolvedores ainda podem introduzir, por exemplo, erros de uso após liberação e overflow de buffer em um programa da Solana.
Condições de panic
As condições de panic são a lista de casos de erro de runtime definidos pela SVM ISA. Elas incluem:
- Instruções inválidas ou incompatíveis.
- Divisão ou módulo por zero.
- Acesso à memória fora dos limites.
- Acesso inválido à memória em diferentes regiões de memória (ou seja, violação de permissão).
- Overflow da pilha.
- Profundidade de chamada excedida.
- Número máximo permitido de instruções excedido.
- O programa retornou um código de erro.
ABI
A Application Binary Interface (ABI) é o contrato de formato entre um programa da Solana e a SVM. Embora a seção anterior, Anatomia básica de um programa, tenha demonstrado como isso funciona em Rust (ou seja, process_instruction com três entradas), a ABI especifica como essas entradas e saídas são representadas na memória, garantindo que todos os validators possam executar programas de forma determinística.
Em alto nível, a ABI define três coisas: a convenção de entrypoint, as convenções de chamada e os registradores, e o layout da memória.
Todo programa da Solana deve expor uma função de entrypoint. O loader serializa as entradas do programa no espaço de memória da VM em uma ordem canônica: o ID do programa, o array de contas e os dados da instrução. Em seguida, a VM passa ponteiros para essas regiões no entrypoint do programa.
Os cinco primeiros registradores (ou seja, R1-R5) são reservados para os argumentos do entrypoint, enquanto o registrador de retorno (ou seja, R0) armazena o código de saída do programa. Um código de saída igual a zero é considerado sucesso, enquanto um valor diferente de zero representa uma falha mapeada para um InstructionError específico. Isso garante que todos os programas retornem códigos de status de forma consistente. Além disso, os parâmetros após os cinco primeiros são passados na pilha. R6-R9 seguem uma convenção de preservação pelo chamado, o que significa que as funções devem preservar esses valores se os utilizarem.
As contas e os dados são serializados na memória linear da VM como fatias de bytes. Os programas precisam desserializá-los em tipos Rust de nível mais alto (por exemplo, AccountInfo, Pubkey). A ABI impõe limites rigorosos para que nenhum programa possa acessar memória fora das regiões alocadas a ele.
Juntas, essas regras fazem da ABI a “cola” que conecta a experiência de desenvolvimento de alto nível à ISA de baixo nível. Ela garante que uma assinatura simples de função Rust seja compilada com o uso correto de registradores, o layout de memória e os códigos de retorno, para que todos os validadores interpretem determinado programa exatamente da mesma forma em todas as execuções.
Syscalls
A ISA é intencionalmente mínima e não possui contas nem estado integrados. Ela também não oferece diretamente funcionalidades de nível mais alto, como registro de logs, hashing ou chamadas entre programas. Em vez disso, são disponibilizadas chamadas de sistema — funções especiais integradas à VM que permitem aos programas interagir com o mundo externo. Essas chamadas são conhecidas informalmente como syscalls.
As syscalls podem ser vistas como APIs fornecidas pela VM. Em vez de cada programa precisar reimplementar primitivas criptográficas específicas ou a lógica de contas, as syscalls expõem operações seguras e padronizadas, com comportamento determinístico garantido em todos os validadores.
As categorias comuns de syscalls incluem:
- Registro de logs e depuração (por exemplo, a syscall sol_log grava uma string UTF-8 nos logs do programa e é usada internamente por msg!).
- Invocação entre programas (CPI) (por exemplo, sol_invoke_signed permite que um programa chame outro programa on-chain, passando contas e dados de instrução, o que é essencial para a composibilidade da Solana).
- Criptografia (por exemplo, as syscalls sol_sha256, sol_keccak256 e sol_ed25519_verify adicionam primitivas criptográficas determinísticas e rápidas sem exigir que os desenvolvedores criem suas próprias implementações).
- Utilitários de memória e contas (isto é, syscalls que disponibilizam recursos auxiliares para empréstimo de dados de contas, realocação de memória ou uso de alocações no heap pertencentes ao programa).
- Orçamento e medição de computação (isto é, toda syscall consome CUs, conforme imposto pelo sistema de medição do runtime).
As syscalls são invocadas por meio de uma instrução especial CALL_IMM com um identificador hash exclusivo. Quando um programa chama uma syscall, a VM sBPF interrompe a execução, procura o hash em seu registro de syscalls e encaminha a chamada para a implementação nativa em execução no código privilegiado do runtime. As syscalls são executadas fora do sandbox, com acesso ao estado do runtime, o que é totalmente diferente da execução de chamadas de função comuns dentro de um programa.
As syscalls seguem a mesma ABI das funções comuns: os primeiros cinco argumentos são passados para os registradores R1 a R5, e o valor de retorno fica em R0. Cada syscall tem um custo fixo de unidades de computação, garantindo um consumo determinístico de recursos. Por exemplo, todas as chamadas à syscall secp256k1_recover consomem 25.000 CUs.
As syscalls formam um limite de segurança controlado, pois cada uma valida suas entradas e verifica as permissões relevantes antes de realizar qualquer operação privilegiada. Por exemplo, uma syscall de invocação entre programas (CPI) verifica se o chamador tem as permissões adequadas para as contas que estão sendo passadas.
Observe que novas syscalls podem ser adicionadas por meio de feature gates sem que seja necessário modificar a própria ISA. Isso permite que a Solana expanda os recursos de sua VM, incluindo suporte a novas primitivas criptográficas, mantendo a compatibilidade com programas existentes.
Binário do programa
Ao final da compilação, todas as etapas — do código-fonte Rust ao LLVM IR, ao eBPF, ao sBPF e à conformidade com a ISA da SVM — produzem uma única saída: um binário de programa. Esse binário é o que realmente é implantado na Solana.
ELF
Os programas Solana são compilados em arquivos Executable and Linkable Format (ELF), um formato binário padrão usado em sistemas do tipo Unix. O formato ELF funciona como um contêiner que reúne tudo o que a VM precisa para executar determinado programa, mantendo a independência de plataforma.
Um arquivo ELF normalmente contém as seguintes seções:
- Seção de bytecode: contém as instruções sBPF compiladas na seção .text.
- Seção de dados somente leitura: armazena constantes, strings estáticas e valores imutáveis em uma seção .rodata.
- Seções BSS e de dados: contêm variáveis globais ou estáticas mutáveis nas seções .bss e .data, respectivamente. Observe que a Solana não permite dados mutáveis. Ou seja, o ELF pode ter .rodata, mas não as seções BSS e de dados.
- Tabelas de símbolos e realocação: definem como chamadas de função, syscalls e referências de memória são resolvidas durante o carregamento nas seções .symtab e .strtab para símbolos e nas seções .rel.dyn e .rela.dyn para entradas de realocação.
Cada arquivo ELF também inclui um cabeçalho que descreve a arquitetura, a largura das instruções (isto é, 64 bits), a ordem dos bytes (isto é, Little Endian) e o endereço do ponto de entrada.
Vinculação e realocação
O processo de transformar a saída do compilador em um único arquivo ELF executável envolve um componente final: o linker. O linker é responsável por combinar várias unidades de código compilado em um único binário coeso. Ele também resolve todas as referências simbólicas (isto é, placeholders) que o compilador deixou sem resolução. Por exemplo,
- Quando um programa chama uma função como sol_log, o compilador não sabe onde essa função está na memória, então usa um placeholder.
- O linker substitui essa referência simbólica pelo identificador hash exclusivo da syscall (isto é, um hash Murmur3 determinístico de 32 bits).
- Da mesma forma, as chamadas entre funções internas são reescritas como saltos relativos para offsets de instruções dentro da seção .text.
Esse processo de reescrever referências simbólicas como endereços concretos ou IDs de syscall com hash é chamado de realocação. No entanto, vale observar que as realocações são, em grande parte, um artefato da forma como as ferramentas iniciais foram criadas, e não um requisito fundamental. Na verdade, há planos para remover completamente as realocações em versões futuras da toolchain, simplificando o processo de implantação.
Essa etapa de realocação é necessária para garantir que o mesmo binário ELF seja executado de forma idêntica em todos os validadores, pois nenhum endereço absoluto de memória ou símbolo específico do sistema é incorporado.
Além disso, o bytecode com essas realocações já aplicadas é armazenado em cache na memória. Assim, todas as execuções futuras usam o bytecode atualizado sem precisar processar novamente as realocações.
Depois que o linker produz um arquivo ELF totalmente realocado, o programa está pronto para implantação. O resultado final é um binário:
- Portável: é executado de forma idêntica em qualquer validador ou implementação da SVM.
- Determinístico: não contém syscalls não determinísticas nem dependências do sistema operacional.
- Autossuficiente: contém todo o bytecode e os metadados necessários para a execução.
Como o bytecode é enviado para a Solana
Depois que um programa Solana é compilado e vinculado em um arquivo ELF válido, a próxima etapa é enviá-lo à blockchain para que possa ser executado pelos validadores. Esse processo, conhecido como implantação do programa, envolve vários componentes que trabalham em conjunto: o BPF Loader, modelos de contas, verificação de bytecode e gerenciamento de estado.
O programa BPF Loader
O BPF Loader é um programa nativo que valida, realoca e marca arquivos ELF como executáveis. Em essência, ele gerencia o ciclo de vida dos programas implantados: processa instruções para inicializar contas, grava bytecode, implanta programas e gerencia atualizações.
A Solana passou por várias iterações de loaders, e cada uma aprimorou a versão anterior:
- BPF Loader: o loader original para programas estáticos que não podem ser atualizados, que não é mais compatível.
- BPF Loader V2: um loader simplificado sem instruções de gerenciamento.
- BPF Loader Upgradeable: o loader atual, que introduziu a capacidade de atualizar programas.
- BPF Loader V4: a iteração mais recente, com melhores recursos de implantação e a simplificação do atual modelo de duas contas para um modelo de conta única.
Arquitetura de implantação: modelos de contas
Modelo de contas atual
O loader atual usa uma arquitetura de duas contas para separar a lógica do programa dos dados do programa. Isso resulta em duas contas para determinado programa: a conta Program e a conta ProgramData.
A conta Program é pequena, com aproximadamente 36 bytes, armazena metadados e é marcada como executável. Ela armazena uma referência à ProgramData por meio de UpgradeableLoaderState::Program { programdata_address }.
A conta ProgramData é maior e armazena o bytecode ELF propriamente dito, junto com os metadados de implantação (por exemplo, slot e endereço da autoridade de atualização), por meio de UpgradeableLoaderState::ProgramData.
A separação das duas contas permite atualizações no mesmo local. Ou seja, a conta Program permanece no mesmo endereço, enquanto o bytecode da conta ProgramData pode ser substituído.
Modelo de contas futuro
O Loader V4 busca simplificar o processo de implantação com um modelo de conta única. A ideia é que a conta do programa armazene diretamente os metadados e o bytecode, eliminando a necessidade de uma conta ProgramData separada. Ele também oferece aos desenvolvedores a opção de armazenar uma imagem compactada com zstd para reduzir os custos de aluguel.
Como os programas Solana são implantados
O processo de implantação envolve enviar um binário ELF compilado e fazer com que o BPF Loader o verifique, armazene em cache e marque como executável. O processo difere um pouco entre o loader atualizável e o V4 devido à arquitetura de implantação descrita na seção anterior.
BPF Loader Upgradeable
O processo de implantação atual com o loader atualizável envolve inicializar uma conta de buffer para preparar o bytecode ELF. O responsável pela implantação envia uma instrução InitializeBuffer ao BPF Loader Upgradeable, que cria uma nova conta pertencente ao loader e define o estado da conta como UpgradeableLoaderState::Buffer { authority_address }, registrando o endereço autorizado a gravar no buffer.
O binário ELF compilado é enviado ao buffer em partes usando a instrução Write { offset, bytes }. Cada instrução de gravação verifica se o signatário corresponde à autoridade do buffer, confirma que o buffer ainda é mutável (isto é, ainda não foi implantado) e grava os bytes no offset especificado após o cabeçalho de metadados. Observe que programas grandes exigem várias instruções Write para enviar todo o arquivo ELF devido aos limites de tamanho das transações.
Depois que o buffer contém o ELF completo, o responsável pela implantação envia uma instrução DeployWithMaxDataLen { max_data_len }. Essa é a etapa mais complexa de todo o processo, pois coordena a implantação em si, desde a validação das contas até a finalização do estado.
Primeiro, o loader valida todas as contas no processo de implantação e verifica se:
- A conta do programa não está inicializada e está isenta de aluguel.
- O buffer contém dados válidos, e a autoridade que inicializou o buffer assinou a transação.
- max_data_len é grande o suficiente para acomodar os dados do buffer.
- O tamanho total não excede MAX_PERMITTED_DATA_LENGTH (isto é, 10 MiB ou 10.485.760 bytes).
Em seguida, o loader cria a conta ProgramData, derivando o endereço como uma PDA a partir do ID do programa e do ID do loader. Depois, ele devolve os lamports do buffer ao pagador, pois a conta de buffer deixa de ser necessária após a implantação.
Além disso, ele cria a conta ProgramData via CPI para o System Program, alocando espaço suficiente para os metadados e os bytes de max_data_len. Em seguida, o loader usa a bump seed da PDA para assinar a CPI.
A macro deploy_program! garante que seja seguro executar o bytecode. Primeiro, ela analisa a estrutura do arquivo ELF para validar os bytes mágicos do ELF (isto é, 0x7f ‘E’ ‘L’ ‘F’) e os cabeçalhos (isto é, 64 bits, Little Endian), extrai as seções do programa, processa as tabelas de realocação e valida os limites e alinhamentos das seções. O carregamento falhará imediatamente se o ELF estiver malformado ou usar recursos incompatíveis.
O RequisiteVerifier (isto é, o verificador do sBPF) realiza então uma análise estática de todos os caminhos de execução possíveis sem executar o programa, garantindo que ele seja comprovadamente seguro antes da execução de qualquer instrução. O verificador também aplica as restrições da ISA da SVM mencionadas anteriormente. Se a verificação falhar, a implantação será rejeitada com InstructionError::InvalidAccountData, e o programa nunca será marcado como executável.
Depois que a verificação é aprovada, o bytecode é compilado e armazenado em cache para execução. A função load_program_from_bytes cria uma ProgramCacheEntry contendo:
- Executável compilado por JIT: o bytecode sBPF é compilado Just-In-Time (JIT) em código de máquina nativo para a arquitetura de CPU do validador. Isso oferece velocidade de execução próxima à nativa sem comprometer a segurança.
- Metadados de slot: os momentos de implantação e de disponibilidade do programa são registrados como deployment_slot e effective_slot, respectivamente. O atraso impede que os programas sejam usados no mesmo slot em que são implantados.
- Ambiente de runtime: referências ao registro de syscalls para indicar quais syscalls estão disponíveis e à configuração de execução que será usada quando o programa for executado.
A entrada de cache é armazenada em program_cache_for_tx_batch, disponibilizando o programa para execução em transações posteriores. Depois que o programa é verificado e armazenado em cache com sucesso, o loader atualiza os estados das contas para finalizar a implantação. O estado da conta ProgramData é atualizado para registrar quando o programa foi implantado e quem está autorizado a atualizá-lo. O bytecode ELF também é copiado do buffer para a conta. O estado da conta Program também é atualizado para vinculá-la à conta ProgramData, e ela é marcada como executável. Por fim, o comprimento dos dados do buffer é definido como o tamanho dos metadados, o que efetivamente zera o bytecode e recupera espaço.
O programa agora está totalmente implantado e pode ser invocado por transações.
BPF Loader V4
O BPF Loader V4 simplifica a implantação ao eliminar a necessidade de uma conta ProgramData separada, permitindo que a conta do programa armazene o bytecode diretamente. Ele também adiciona suporte ao armazenamento de ELF compactado com zstd, o que reduz significativamente os custos de aluguel, com descompactação sob demanda durante o carregamento.
O responsável pela implantação chama SetProgramLength { new_size } para alocar espaço para os metadados e o bytecode do programa. Para programas novos, isso inicializa a conta com o status LoaderV4State::Retracted, registra a autoridade e marca a conta como executável, embora ela ainda não possa ser invocada.
Em seguida, o responsável pela implantação grava o binário ELF diretamente na conta do programa por meio de instruções Write { offset, bytes }. Essas gravações só são permitidas quando o programa está no estado Retracted. A instrução Copy também pode ser usada para copiar o bytecode de outro programa, independentemente da versão do loader, o que é útil em migrações.
A instrução Deploy é então usada para mover o programa do estado Retracted para o estado Deployed. Essencialmente, essa instrução extrai o bytecode da conta do programa no offset e executa exatamente o mesmo pipeline de verificação do BPF Loader Upgradeable (isto é, análise do ELF, verificação estática, compilação JIT e armazenamento em cache). Se a verificação for bem-sucedida, o estado do programa será atualizado para LoaderV4Status::Deployed, e o slot de implantação será registrado.
O programa agora está totalmente implantado e pode ser invocado por transações.
O Loader V4 também impõe um período de espera entre as transições de estado (isto é, implantação e retração) para evitar ataques de reimplantação. Os programas não podem ser implantados nem retraídos no intervalo de um slot desde a última implantação. Isso ajuda a impedir que agentes mal-intencionados atualizem programas rapidamente para explorar condições de corrida ou confundir usuários, garantindo atomicidade por slot em vez de atrasos de vários slots. Observe que esse período de espera se aplica às instruções Deploy e Retract.
Os programas também podem se tornar imutáveis por meio da instrução Finalize. Isso move o programa do status Deployed para o status Finalized, o que significa que ele não poderá mais ser retraído nem atualizado. O campo de autoridade passa a apontar para o endereço de um programa de “próxima versão”, possibilitando caminhos explícitos de atualização enquanto mantém a imutabilidade do programa.
Como a execução funciona na SVM
A SVM é o mecanismo de processamento de transações nos validadores, responsável por executar invocações de programas e atualizar o estado de acordo com os resultados.
Quando uma transação chega a um validador, ela passa por um pipeline de vários estágios: validação, carregamento de contas, execução do programa em uma VM sBPF isolada, verificação de invariantes e confirmação do estado. Se todas as instruções forem bem-sucedidas, as alterações nas contas serão gravadas no AccountsDB. Se qualquer instrução falhar, toda a transação será revertida de forma atômica.
A SVM opera como um mecanismo de execução desacoplado. Ou seja, ela não gerencia consenso, rede nem histórico do ledger. Em vez disso, concentra-se exclusivamente em executar programas com segurança, determinismo e eficiência.
O Bank coordena a execução da SVM, fornece o contexto do runtime (por exemplo, blockhash, aluguel e conjunto de recursos) e confirma os resultados no armazenamento persistente. A SVM gerencia a execução do programa, desde o carregamento do bytecode até a aplicação dos orçamentos de computação. Essa separação de responsabilidades permite reutilizar a SVM além dos validadores.
Transações
As transações são a força vital da Solana — e de qualquer blockchain —, pois invocam programas para realizar alterações de estado.
Uma transação é um conjunto de instruções que define quais ações devem ser realizadas, em quais contas e se elas têm as permissões necessárias para isso.
Uma instrução é uma diretiva para uma única invocação de programa. Ela é a menor unidade da lógica de execução e funciona como a unidade operacional mais básica da Solana.
Os programas interpretam os dados fornecidos por uma instrução para operar nas contas especificadas. Uma instrução inclui o ID de um programa (isto é, o programa invocado), uma lista de contas das quais ler e nas quais gravar, além da entrada fornecida ao programa.
As transações começam quando um usuário define um objetivo, como transferir 10 SOL para outra conta. Essa intenção é traduzida em uma instrução que orienta o System Program a transferir 10 SOL da conta A para a conta B. A conta A seria incluída na transação como signatária gravável, e a conta B seria incluída como conta gravável. A instrução seria então empacotada em uma transação que também especifica o pagador da taxa, os signatários e um blockhash recente.
Normalmente, a transação seria então enviada a um provedor de RPC, como a Helius. O nó RPC que recebe a transação verificaria se todas as assinaturas obrigatórias estão presentes e são válidas, se a transação ainda não foi processada, se o blockhash recente fornecido ainda é válido e se a transação não excede o tamanho máximo (isto é, 1.232 bytes).
O RPC encaminha então a transação para a Transaction Processing Unit (TPU) do líder atual.
A Transaction Processing Unit (TPU)
A Transaction Processing Unit (TPU) é o pipeline de ingestão e processamento de transações nos validadores da Solana. Ela possui vários estágios que recebem, verificam, agendam e executam transações antes que elas sejam confirmadas no ledger da Solana.
Para nossos objetivos, examinaremos em detalhes o Fetch Stage, o SigVerify Stage e o Banking Stage antes de prosseguir para o Bank e o provisionamento da VM sBPF.
Para uma análise mais detalhada da TPU, consulte Qualidade de serviço ponderada por stake: tudo o que você precisa saber.
O Fetch Stage
O Fetch Stage é o primeiro estágio do pipeline da TPU. Ele recebe todas as transações de entrada por meio de conexões QUIC, que usam sockets UDP como camada de transporte subjacente, e as agrupa em lotes para o processamento posterior.
- tpu: transações comuns, como transferências de tokens, criação de NFTs e interações com programas.
- tpu_vote: transações de voto dos validadores — isso mudará com o Alpenglow, quando as transações de voto forem removidas.
- tpu_forwards: transações não processadas encaminhadas pelo líder anterior, que não conseguiu processá-las a tempo.
Esses sockets são registrados no serviço de gossip do validador e armazenados na struct ContactInfo, permitindo que outros validadores e nós RPC descubram para onde enviar as transações.
O Fetch Stage cria uma thread para cada socket, e todas elas executam continuamente as seguintes tarefas:
- Consultar o socket UDP em busca de pacotes recebidos.
- Criar um lote de 64 pacotes.
- Enviar o lote pelo respectivo canal ilimitado.
Atualmente, canais ilimitados são usados para passar lotes aos estágios posteriores, o que significa que esses canais têm capacidade ilimitada. O uso de canais ilimitados também permite que o Fetch Stage opere independentemente da velocidade de processamento dos estágios posteriores.
Embora isso evite o descarte imediato de pacotes durante picos de tráfego, pode causar problemas de memória: se os estágios posteriores não acompanharem o Fetch Stage, os canais crescerão sem limites, podendo causar lentidão ou falhas por falta de memória (OOM).
Está em andamento a implementação de canais limitados com contrapressão adequada, permitindo que o sistema sinalize congestionamentos e evite o crescimento ilimitado da memória.
O Fetch Stage também cria outra thread dedicada ao processamento de pacotes encaminhados. Esses pacotes são marcados com uma flag FORWARDED e são mantidos ou descartados de acordo com a programação de líderes:
- Aceitos: se o validador atual estiver prestes a se tornar líder, os pacotes encaminhados serão processados pelo canal regular da TPU e enviados ao próximo estágio.
- Descartados: se o validador atual não estiver prestes a se tornar líder, os pacotes encaminhados serão descartados para evitar processamento desnecessário.
O Fetch Stage usa um PacketBatchRecycler para pré-alocar 1.000 lotes de pacotes, cada um contendo 1.024 pacotes. Isso reduz a sobrecarga da alocação de memória, pois a memória dos lotes pode ser reutilizada em vez de novos lotes serem alocados a cada vez. Historicamente, o recycler era necessário para a fixação de memória da CUDA, que não funciona mais e pode ser considerada, em grande parte, dívida técnica.
O SigVerify Stage
O SigVerify Stage é o segundo estágio do pipeline da TPU e, como o nome sugere, é responsável por verificar assinaturas. Isso ocorre no início do pipeline porque a verificação de assinaturas Ed25519 tem um alto custo computacional, embora seja menos custosa que executar transações. Verificar as transações antes da execução permite que os validadores rejeitem transações fraudulentas, evitem ataques de negação de serviço e garantam que apenas transações bem-formadas cheguem ao Banking Stage.
O SigVerify Stage funciona como uma única thread que recebe continuamente pacotes dos canais do Fetch Stage e os processa em um pipeline de verificação. Apesar de usar uma única thread, há um alto grau de paralelismo interno.
Por padrão, as assinaturas são verificadas na CPU usando um iterador paralelo. Isso distribui a verificação entre todos os núcleos de CPU disponíveis, permitindo que cada núcleo verifique de forma independente um subconjunto de assinaturas.
A verificação de assinaturas também pode ser transferida para a GPU se bibliotecas de desempenho forem detectadas por meio de perf_libs::api(). A GPU só pode ser usada se houver pelo menos 64 pacotes e se houver a expectativa de que 90% sejam válidos. A justificativa é que a GPU tem uma sobrecarga de ~15 a 20 ms para configuração e transferência, enquanto a CPU pode verificar 64 assinaturas em ~10 a 20 ms. É preciso admitir que esse recurso se mostrou impraticável em produção devido à sobrecarga de latência, tornando-o muito mais lento que a verificação por CPU para cargas de trabalho realistas. Há planos para remover esse caminho de código não utilizado.
O processo de verificação é relativamente simples:
- Receber lotes de pacotes dos canais ilimitados do Fetch Stage.
- Descartar transações aleatoriamente por meio de redução de carga se o volume de pacotes exceder 165.000.
- Remover transações duplicadas.
- Descartar pacotes excedentes para que nenhum IP monopolize a largura de banda de verificação.
- Reduzir previamente e reorganizar os lotes para melhorar a localidade do cache e diminuir o desperdício de memória.
- Verificar as assinaturas.
Todos os pacotes válidos seguem para o Banking Stage.
O Banking Stage
O Banking Stage é onde ocorre a execução das transações. Aqui, as transações são armazenadas em buffer, agendadas e executadas por threads de trabalho paralelas.
É usado um padrão de scheduler central, que separa o tratamento das transações de voto e sem voto:
- Uma única thread de trabalho processa transações de voto e votos de gossip.
- Quatro threads de trabalho processam todas as transações sem voto.
- Uma única thread coordena a distribuição do trabalho entre as threads de trabalho.
Os pacotes recebidos são desserializados e armazenados em buffer até o limite de 100.000 transações. O scheduler recebe continuamente novas transações enquanto gerencia esse buffer e descarta transações expiradas ou inválidas durante operações de limpeza da fila, que verificam até 10.000 transações por vez.
O scheduler determina a ordem de execução das transações usando a detecção de conflitos (isto é, transações que querem obter bloqueios de leitura e gravação para as mesmas contas). Há duas implementações de scheduler disponíveis por padrão:
- PrioGraphScheduler: um scheduler que cria um grafo de prioridades para detectar conflitos entre contas.
- GreedyScheduler: o scheduler padrão, com uma abordagem FIFO mais simples para ordenar transações.
Em seguida, o scheduler seleciona transações sem conflitos e as envia às threads de trabalho. Cada thread recebe um lote e começa a processá-lo.
Coordenação do Bank
O Bank representa o estado de todas as contas em um slot específico. Ele é a estrutura de dados central que gerencia os dados das contas, aplica as regras do runtime e coordena os workers do Banking Stage e a VM sBPF durante a execução das transações.
Ciclo de vida
Cada Bank passa por três estados:
- Ativo: um Bank recém-criado e aberto a transações. Os workers do Banking Stage aplicam transações até que o Bank atinja sua contagem-alvo de ticks ou todas as entradas do slot tenham sido processadas.
- Congelado: quando a contagem de ticks é atingida ou todas as entradas são processadas, o Bank é congelado. Nenhuma outra transação pode ser aplicada. Nesse ponto, as taxas das transações são acumuladas para o líder do bloco, as contas sysvar são atualizadas e o hash final do Bank é calculado.
- Enraizado: depois que o Bank congelado recebe votos suficientes dos validadores, ele se torna enraizado. O estado é então finalizado e passa a fazer parte do ledger da blockchain.
Cada Bank, exceto o Bank gênese, aponta para um Bank pai, formando uma estrutura em árvore que representa diferentes forks do ledger.
Fluxo de execução
Os workers do Banking Stage operam no Bank de trabalho atual (isto é, o Bank ativo e não congelado que está sendo criado para o slot atual). Quando um worker recebe um lote de transações do scheduler, o Bank coordena a execução:
- Bloqueio de contas: os workers chamam a função prepare_sanitized_batch_with_results() para bloquear todas as contas referenciadas nas transações e evitar modificações simultâneas.
- Carregamento de contas: o Bank busca no AccountsDB os dados de todas as contas bloqueadas.
- Dedução de taxas: o Bank deduz as taxas da transação da conta do pagador antes da execução.
- Validação: o Bank verifica se o blockhash é recente, confere o estado da conta de nonce e valida a propriedade das contas.
- Transferência para a VM: o Bank chama load_and_execute_transactions(), que transfere as contas carregadas para a VM sBPF.
- Execução: a VM sBPF executa o bytecode do programa de cada instrução.
- Resultados: a VM sBPF retorna toda a saída da execução (isto é, LoadAndExecuteTransactionOutput).
- Confirmação: o Bank grava os estados atualizados das contas no AccountsDB por meio de bank.commit_transactions().
A execução agora entra na SVM propriamente dita (isto é, a VM sBPF). Depois que o Bank chama load_and_execute_transactions(), as transações passam por um pipeline de vários estágios, no qual cada instrução é processada usando uma nova instância isolada da VM sBPF, provisionada para executar o bytecode do programa.
As instruções de uma transação são executadas sequencialmente. O pipeline de cada instrução é:
- Localizar o programa: buscar a conta do programa usando o ID do programa da instrução.
- Carregar do cache: verificar se o programa em questão já foi compilado por JIT no cache de programas.
- Provisionar a VM: criar uma instância isolada da VM sBPF com regiões de memória e orçamento de computação específicos.
- Executar o bytecode: executar o ponto de entrada do programa com as entradas da instrução.
- Verificar invariantes: confirmar que a execução não viola nenhuma regra do runtime.
- Coletar resultados: empacotar os resultados da execução.
Carregamento do programa
Antes que um programa possa ser executado, ele precisa ser carregado de sua conta on-chain, verificado e compilado em código de máquina nativo. Isso acontece por meio do cache de programas e do pipeline de compilação JIT.
O cache de programas é uma otimização de desempenho que evita recarregar e recompilar programas a cada invocação. Ele é mantido no nível do lote de transações e armazena objetos ProgramCacheEntry, que contêm:
- Executável compilado por JIT: código de máquina nativo para a arquitetura de CPU do validador.
- Metadados de implantação: o slot em que o programa foi implantado e passou a vigorar.
- Ambiente de runtime: referências ao registro de syscalls e à configuração de execução.
Quando uma transação referencia o ID de um programa, a seguinte sequência de busca é executada:
- Verificar o cache do lote de transações: procurar uma versão compilada por JIT e armazenada em cache.
- Verificar o cache global de programas: se ela não estiver no cache do lote, verificar o cache global do validador.
- Carregar da conta: se não for encontrada em nenhum cache, carregar a conta do programa do AccountsDB.
- Analisar o ELF: extrair o bytecode dos dados da conta do programa.
- Verificar o bytecode: executar o verificador estático para garantir a segurança.
- Compilar por JIT: traduzir o bytecode sBPF em código de máquina nativo.
- Armazenar a entrada em cache: armazenar o programa compilado para invocações futuras.
Isso significa que a primeira invocação de um programa recém-implantado incorre no custo total de carregamento, verificação e compilação, enquanto as invocações seguintes executam diretamente o código nativo armazenado em cache.
Quando o programa não é encontrado no cache, ele precisa ser carregado de sua conta on-chain. No caso de programas pertencentes ao BPF Loader Upgradeable, a conta do programa contém uma referência à conta ProgramData, que é carregada do AccountsDB. No modelo de conta única do Loader V4, o bytecode é carregado diretamente da conta do programa, o que pode exigir descompactação caso esteja compactado com zstd.
Os bytes ELF extraídos são analisados para localizar o bytecode executável e as seções mencionadas anteriormente (isto é, .text, .rodata, .data / .bss, .symtab / .strtab). Depois que o ELF é analisado com sucesso, o RequisiteVerifier (isto é, o analisador estático do sBPF) verifica todos os caminhos de execução possíveis sem realmente executar o programa, conforme descrito nas seções anteriores.
Compilação JIT
Depois que a verificação é aprovada, o bytecode é compilado Just-In-Time (JIT) em código de máquina nativo. O compilador JIT traduz cada instrução sBPF nas instruções de CPU nativas equivalentes para a arquitetura do validador.
A compilação JIT permite que a VM sBPF tenha desempenho suficiente para lidar com a alta taxa de transferência da Solana. Sem JIT, a VM precisaria interpretar o bytecode sBPF instrução por instrução, gerando uma sobrecarga significativa.
Cada instrução de bytecode precisa ser buscada, decodificada e encaminhada ao código de tratamento, o que adiciona a sobrecarga da interpretação. Além disso, o código interpretado não pode aproveitar otimizações no nível da CPU, como pipelining, previsão de desvios ou execução fora de ordem. Assim, cada instrução sBPF se torna uma chamada de função no interpretador.
A compilação JIT elimina completamente esses custos ao produzir código de máquina nativo que é executado diretamente na CPU. Isso oferece desempenho próximo ao nativo, verificações de limites otimizadas, medição de computação incorporada, otimizações no nível do hardware e um mapeamento claro da alocação de registradores.
O compilador JIT realiza uma tradução em uma única passagem do bytecode sBPF para código de máquina nativo. Para cada instrução sBPF:
- A instrução é decodificada para extrair o opcode, os registradores, o offset e o valor imediato.
- Configurar o frame da pilha e salvar os registradores preservados pelo receptor da chamada.
- Mapear a operação sBPF para a instrução de CPU equivalente, de modo que cada operação seja compilada em instruções nativas. Por exemplo, no mapeamento x86-64, rax seria mapeado para R0, e rbp, para R10.
- Emitir verificações de limites e tradução de endereços por software para o acesso à memória — traduzir endereços de guest para endereços de host é uma das operações mais lentas da VM devido à sua sobrecarga.
- Manter o isolamento da pilha (isto é, a pilha do guest reside em um buffer alocado no heap, e não na pilha do host).
- Inserir a dedução de CUs e verificações de orçamento para emitir a medição de computação.
- Restaurar os registradores e limpar a pilha.
Tradução de instruções
O compilador JIT traduz diferentes tipos de instruções (isto é, aritmética, acesso à memória, operações de armazenamento, desvios condicionais e encaminhamento de syscalls) de maneiras específicas.
As operações aritméticas são mapeadas diretamente para instruções de CPU nativas únicas, sem sobrecarga, pois os registradores do sBPF correspondem bem aos registradores de hardware equivalentes do validador.
As operações de acesso à memória exigem verificação de limites para evitar leituras e gravações fora dos limites. O compilador JIT gera código de validação que verifica os limites inferior e superior de cada acesso à memória em relação aos limites válidos da região.
Isso envolve, em essência, de 3 a 6 instruções nativas que calculam o endereço efetivo, verificam se ele está dentro dos limites esperados e realizam o carregamento. Esse processo é tratado com relativa eficiência pela previsão de desvios, pois violações de limites são raras.
As operações de armazenamento incluem verificação de limites e validação da permissão de gravação. Antes de gravar na memória, o código compilado verifica se o endereço de destino está dentro dos limites e se a região de memória tem permissões de gravação habilitadas.
Os desvios condicionais são compilados como instruções nativas de salto condicional. O compilador JIT resolve todos os destinos de salto durante a compilação, convertendo os offsets relativos das instruções sBPF em endereços absolutos no código nativo.
O encaminhamento de uma syscall exige salvar o estado da VM — todos os 11 registradores — e chamar o handler nativo da syscall. Depois, o estado da VM é restaurado com o valor de retorno. Essa sobrecarga do gerenciamento de estado é o motivo pelo qual as syscalls têm custos fixos de CUs, superiores aos das instruções comuns.
Medição de unidades de computação
O compilador JIT incorpora o acompanhamento das unidades de computação diretamente ao código gerado. Cada instrução sBPF inclui uma verificação de orçamento para garantir que ele não se esgote à medida que as instruções diminuem a contagem de CUs restantes.
Essa incorporação evita a sobrecarga das chamadas de função e pode ser executada com eficiência usando previsão de desvios, execução fora de ordem (isto é, a verificação de CUs e a operação podem ser executadas em paralelo) e paralelismo no nível das instruções.
Para syscalls com custo variável (por exemplo, a syscall sol_sha256, que aumenta conforme o tamanho dos dados), o cálculo do custo ocorre dentro da implementação nativa da syscall antes do retorno à VM.
Armazenamento do código compilado em cache
Depois que a compilação JIT é concluída, o executável nativo é armazenado em uma ProgramCacheEntry, que contém:
- O código compilado por JIT
- Os metadados do slot de implantação e do slot efetivo
- Referências ao registro de syscalls
- A configuração do ambiente de runtime
A entrada é adicionada ao cache do lote de transações e ao cache global de programas. O primeiro fica disponível para todas as instruções do lote atual, enquanto o segundo fica disponível em todas as transações futuras.
A entrada de cache pode ser invalidada quando o programa é atualizado (isto é, quando um novo bytecode é implantado), quando a conta do programa é fechada, quando feature gates alteram a disponibilidade de syscalls ou quando um validador decide limpar seu cache.
Atraso do slot efetivo
Observe que um programa implantado no slot n só pode ser invocado a partir do slot n + 1. Esse atraso garante que todos os validadores observem a implantação, que os caches de programas sejam sincronizados na rede e que a atomicidade por slot seja aplicada.
Provisionamento da VM sBPF
Depois que o programa é carregado e compilado por JIT, ou recuperado do cache, uma nova VM sBPF é provisionada para a execução de cada instrução. Esse provisionamento ocorre no BPF Loader e envolve a configuração de cinco regiões de memória distintas, a inicialização do orçamento de computação e o registro das syscalls.
Regiões de memória
Como mencionado em nossa seção sobre a ISA da SVM, a VM cria cinco regiões de memória distintas. Juntas, essas regiões formam o sandbox isolado no qual os programas Solana são executados. A região de memória do programa normalmente começa no endereço 0x100000000. Ela contém o código nativo compilado por JIT que será executado — o código de máquina real, que depende da arquitetura de CPU do validador. Se o JIT estiver desabilitado para depuração, essa região conterá o bytecode sBPF interpretado. As permissões dessa seção são somente leitura e execução.
Os dados somente leitura também estão incluídos em 0x100000000. Eles contêm constantes e strings estáticas extraídas da seção .rodata do ELF durante o carregamento do programa. O objetivo dessa região de memória é oferecer acesso eficiente a constantes definidas em tempo de compilação sem exigir alocação no heap.
A pilha começa em 0x200000000 e contém variáveis locais, frames de chamadas de função e endereços de retorno. É nela que os cálculos temporários ocorrem durante a execução. Ela cresce para baixo a partir do topo da região, com o registrador R10 (isto é, o ponteiro do frame) marcando o limite do frame atual. Essa região permite leitura e gravação, e seu tamanho é fixado em 4 KB por frame de chamada. Exceder esse limite gera um erro StackAccessViolation, indicando um estouro de pilha. O tamanho reduzido da pilha incentiva os desenvolvedores a usar o heap ou, melhor ainda, armazenar dados em contas em vez de depender do armazenamento baseado na pilha.
O heap começa em 0x300000000 e contém memória alocada dinamicamente para estruturas de dados do runtime que não cabem na pilha nem em uma conta. Seu tamanho varia entre o padrão de 32 KB e o máximo de 256 KB. Antes, os programas podiam expandir o heap por meio da syscall sol_alloc_free. No entanto, a syscall sol_alloc_free foi descontinuada e está desabilitada para novas implantações de programas. Os programas precisam especificar o tamanho de heap necessário no momento da implantação, em vez de expandi-lo dinamicamente.
Observação: o crescimento do heap consome unidades de computação de acordo com a fórmula (heap_size / 32KB) * 8,000 CUs, com um custo padrão do heap de 8 CUs.
A região de memória dos dados de entrada começa no endereço 0x400000000. Ela contém os parâmetros serializados do ponto de entrada recebidos pelo programa quando ele é invocado. Essa é uma região de memória somente leitura cujo tamanho real varia conforme a transação. Os três componentes serializados são a pubkey de 32 bytes do programa invocado, um array de contas e os dados da instrução.
Aplicação das regras de acesso à memória
Toda instrução de carregamento e armazenamento na memória tem seus limites verificados pela VM. Antes de cada acesso à memória, a VM verifica se o endereço está dentro do intervalo válido da região e se o tipo de acesso (isto é, leitura ou gravação) é permitido nessa região.
A execução é interrompida imediatamente com um erro AccessViolation se qualquer uma das verificações falhar. Toda a transação é revertida, e nenhuma alteração de estado é confirmada.
Essa aplicação tem um custo de runtime próximo de zero porque o compilador JIT transforma essas verificações em código nativo eficiente, que a CPU pode executar diretamente. A previsão de desvios moderna processa as verificações com eficiência, já que violações são raras.
Inicialização do orçamento de computação
Cada instância da VM é inicializada com um orçamento de unidades de computação que limita a quantidade total de trabalho que determinado programa pode realizar. Esse modelo de execução limitada garante que os programas não possam ser executados indefinidamente e que todos os validadores executem as transações dentro de uma janela previsível.
Os parâmetros atuais do orçamento são os seguintes:
- Limite padrão de unidades de computação por instrução: 200.000 CUs.
- Limite máximo de unidades de computação por transação: 1.400.000 CUs.
- Limite de instruções integradas: 3.000 CUs.
O valor padrão de CUs por transação é min(1_400_00, (200_000*non_reserve_instructions + 3_000*reserve_instructions)). Essencialmente, esse é o menor valor entre o limite máximo de unidades de computação e os custos padrão por tipo de instrução, com base nas instruções fornecidas.
O orçamento de computação acompanha as unidades restantes durante toda a execução. À medida que cada instrução sBPF é executada, seu custo em CUs é deduzido do orçamento restante. Se o orçamento chegar a zero antes que o programa seja concluído, a execução será interrompida imediatamente com InstructionError::ComputationalBudgetExceeded. A transação falha e nenhuma alteração de estado é confirmada, mas a taxa da transação ainda é cobrada do pagador para compensar o validador pelo processamento.
Registro de syscalls
Durante o processo de provisionamento da VM, também é criado um mapeamento entre o identificador hash Murmur exclusivo de 32 bits de cada syscall e sua implementação nativa em Rust, de modo que todas as syscalls disponíveis sejam registradas.
O seguinte ocorre quando um programa executa uma instrução CALL_IMM com um hash de syscall:
- A VM pausa o fluxo de instruções sBPF.
- O dispatcher de syscalls localiza a implementação no registro usando o hash.
- A syscall verifica se o chamador tem as permissões necessárias (por exemplo, para criar uma CPU ou acessar a propriedade da conta).
- A implementação da syscall em Rust é executada com acesso total ao runtime fora do sandbox.
- O custo fixo da syscall é subtraído do orçamento de computação restante.
- O resultado é colocado no registrador R0, e a execução do sBPF é retomada.
Execução do programa
Depois que a VM sBPF é provisionada, a execução começa na função de ponto de entrada do programa. Para programas compilados com JIT, a VM salta diretamente para o código de máquina nativo e permite que a CPU do validador o execute de forma nativa. Conforme descrito na seção anterior, o código compilado com JIT inclui toda a instrumentação necessária — verificações de limites de memória, medição de computação e validação do fluxo de controle — incorporada para maximizar o desempenho.
O registrador R1 contém um ponteiro para a região de dados de entrada onde residem os três parâmetros serializados (ou seja, a pubkey do programa invocado, um array de contas e os dados da instrução).
Observação: os dados da conta são acessados por meio de ponteiros, em vez de serem copiados. O uso de ponteiros permite que os programas leiam e modifiquem os dados da conta no próprio local, o que é essencial para o desempenho.
Cada chamada de função aloca um novo frame de pilha de 4 KB, e o registrador R10 é atualizado para apontar para o novo frame. A computação é medida de acordo com o orçamento de computação mencionado anteriormente.
Invocações entre programas (CPI)
Durante a execução, os programas podem invocar outros programas por meio de invocações entre programas (CPIs), a base da capacidade de composição da SVM.
Uma CPI é iniciada por meio da syscall sol_invoke_signed, que custa 1.000 CUs, além de custos adicionais baseados nos dados serializados das contas que são passados. Tanto os dados das contas quanto a serialização dos dados da instrução custam 250 bytes por CU.
Quando um programa faz uma CPI, é criado um novo contexto de execução com seu próprio frame de pilha de instruções. No momento da publicação, a profundidade máxima da pilha de instruções é 5, ou 9 com o SIMD-0268 habilitado. Isso significa que um programa pode invocar outro programa, que pode invocar outro, até atingir o limite de profundidade. Cada invocação aninhada mantém seu próprio conjunto de contas graváveis e privilégios de signatário. Uma CPI pode ter no máximo 16 signatários e receber 128 structs AccountInfo.
O chamador serializa o ID do programa de destino, as contas e os dados da instrução e, em seguida, invoca a syscall. A execução do programa atual é pausada, e uma nova instância da VM sBPF é provisionada para o programa chamado, seguindo o mesmo processo de provisionamento mencionado anteriormente. A execução do programa chamado então começa. Ele é executado com seu próprio orçamento de computação, retirado do orçamento restante do chamador. Isso significa que as chamadas CPI compartilham o orçamento total de computação da transação.
Os programas podem assinar em nome das contas que possuem por meio de endereços derivados de programa (PDAs). Ao invocar com sol_invoke_signed, o chamador fornece seeds que comprovam a propriedade do PDA. A derivação do PDA é verificada antes que a autoridade de assinatura seja concedida ao programa chamado.
Quando o programa chamado termina, o controle retorna ao chamador. As modificações de conta feitas pelo programa chamado ficam visíveis para o chamador, permitindo que o estado flua pela cadeia de chamadas. Se qualquer programa da cadeia de CPI falhar, toda a transação será abortada e todas as alterações de estado serão revertidas.
Observação: sol_invoke é um helper que chama sol_invoke_signed sem seeds.
Verificação pós-execução
Após a conclusão da execução de um programa, seja diretamente ou como parte de uma cadeia de CPI, várias verificações pós-execução são realizadas para garantir a consistência do estado e as invariantes de segurança.
Por exemplo, o runtime verifica se todas as contas marcadas como graváveis realmente pertenciam ao programa ou foram devidamente assinadas. Os programas não podem modificar contas que não possuem, a menos que essas contas tenham sido explicitamente marcadas como graváveis e que o proprietário tenha concedido permissão, o que impede modificações de estado não autorizadas.
O runtime também verifica se a soma total de lamports em todas as contas da transação permanece a mesma, a menos que os lamports tenham sido explicitamente transferidos por meio de instruções do System Program. Essa verificação de conservação impede que programas criem ou destruam lamports, garantindo que a oferta total de SOL permaneça constante.
O runtime valida que nenhuma conta com flags de execução (ou seja, programas) foi modificada. Os dados de um programa não podem ser alterados durante a execução normal, e o programa só pode ser atualizado por meio do mecanismo de autoridade de atualização do BPF Loader.
Resultados da execução
Quando a verificação pós-execução é concluída, o resultado da execução é retornado ao processador de transações do Bank. O resultado contém o status de sucesso ou erro (ou seja, um resultado zero ou diferente de zero, respectivamente), o número de unidades de computação consumidas e todas as modificações feitas no estado das contas.
Para execuções bem-sucedidas, o Bank confirma todas as modificações de conta de forma atômica. Os dados atualizados das contas, os saldos em lamports e os metadados são gravados no AccountsDB e ficam visíveis nas transações subsequentes. As unidades de computação consumidas são registradas para os cálculos das taxas de transação e para as métricas da rede.
Nenhuma alteração de estado é confirmada em transações com falha — não há reversões parciais na Solana, pois a transação é revertida por completo. No entanto, a taxa da transação ainda é deduzida da conta do pagador para compensar o validador pelo trabalho computacional realizado. O código de erro e as unidades de computação consumidas são registrados nos metadados da transação para fins de depuração e análise.
O resultado da execução retorna pelo agendador do Banking Stage, que atualiza suas métricas internas e passa para a próxima transação. Tanto as transações bem-sucedidas quanto as que falharam são registradas no fluxo de Proof of History e contribuem para o bloco atual em construção. As transações com falha são incluídas para ajudar a evitar ataques de repetição e manter um histórico completo de transações.
Quando o slot é concluído e atinge sua contagem máxima de ticks, o Bank passa para um estado congelado. O congelamento é uma operação irreversível que impede a confirmação de novas transações e calcula o hash do Bank. Observe que congelado não significa finalizado — o slot ainda pode estar em um fork que será descartado.
O Bank se torna enraizado quando o validador chama BankForks::set_root() para designá-lo como parte da cadeia canônica. O enraizamento aciona uma operação de squash que consolida o estado das contas do Bank enraizado no AccountsDB, mesclando todo o estado pai e tornando-o permanente na perspectiva do validador. Os forks não enraizados são removidos e descartados. Mesmo os Banks enraizados ainda não estão finalizados na perspectiva do cluster devido aos diferentes níveis de compromisso da Solana.
Perspectivas futuras
A Máquina Virtual da Solana representa uma abordagem fundamentalmente diferente para a execução em blockchain: uma blockchain escalável e acessível a todos, graças ao processamento paralelo, aos mercados de taxas locais e a um runtime determinístico e de alto desempenho derivado do eBPF.
Entender a SVM exige analisar todo o pipeline de execução, desde a compilação do código-fonte Rust até LLVM e sBPF e, por fim, o provisionamento de instâncias isoladas da VM.
Não existe uma única “especificação” que defina a SVM. Em vez disso, ela surge da interação entre o Bank, o agendador, os BPF Loaders, a VM sBPF e a ISA da SVM.
O futuro parece promissor à medida que a SVM continua evoluindo.
A toolchain da Solana está passando por uma reformulação completa para eliminar a infraestrutura LLVM personalizada que prejudica a integração de desenvolvedores há anos.
A abordagem atual exige que os desenvolvedores instalem toolchains personalizadas por meio de scripts específicos para cada plataforma. A solução é adotar a mesma toolchain usada pelo Aya, a biblioteca eBPF para Rust. Os desenvolvedores poderão executar dois comandos simples para compilar diretamente para bytecode eBPF:
rustup toolchain install nightly
cargo build --target=bpfel-unknown-none
Sem scripts. Sem forks personalizados do LLVM. Apenas ferramentas padrão do Rust compilando diretamente para bytecode eBPF com o target upstream bpfel-unknown-none, aproveitando os inúmeros anos de desenvolvimento do kernel Linux e de melhorias na infraestrutura LLVM.
A ISA da SVM também deverá ser atualizada com o SIMD-0377, que propõe alinhar a implementação eBPF da Solana (ou seja, o sBPF) aos padrões modernos do eBPF. Isso inclui a introdução de variantes da instrução JMP32, operações de divisão com sinal e módulo, saltos indiretos e frames de pilha dinâmicos. Essas mudanças ajudarão a reduzir os custos de computação, melhorar a compatibilidade com a infraestrutura LLVM upstream e permitir uma geração de código mais eficiente.
A SVM é essencialmente um sistema medido por orçamentos de computação. O SIMD-0370 pode mudar seu funcionamento ao remover o limite de computação por bloco e, possivelmente, o limite por transação. A remoção desses limites permitiria que os produtores de blocos maximizassem o throughput com base na capacidade de seu hardware, em vez de limites artificiais. Combinada ao mecanismo de timeout do Alpenglow, essa mudança permitiria que as forças do mercado, em vez de restrições no nível do protocolo, determinassem os tamanhos ideais dos blocos. É claro que essa possibilidade ainda está muito distante, pois a Anza pretende primeiro aumentar o limite de CUs para mais de 100 milhões antes de remover esses limites.
Por trás de todas essas mudanças está a mentalidade de quem constrói e busca expandir os limites do que as blockchains podem alcançar sem sacrificar segurança, determinismo ou descentralização.
A SVM não é apenas um interpretador de bytecode — ela é um pipeline de execução completo que revoluciona os recursos das blockchains. É o resultado de decisões arquitetônicas que priorizam throughput e baixa latência. À medida que a Solana amadurece, a SVM continuará evoluindo para oferecer suporte a aplicações altamente eficientes e com uso otimizado de capital.
O sonho dos Mercados de Capitais da Internet exige uma infraestrutura capaz de atender aos requisitos de throughput, latência e custo dos sistemas financeiros globais. A Máquina Virtual da Solana é um passo essencial para concretizar essa visão.
Recursos adicionais
- A nova API da SVM da Anza
- Como escrever programas da Solana com Assembly SBPF
- Compilador Rust para iniciantes
- A máquina virtual eBPF da Solana
- O modelo de programação da Solana: uma introdução ao desenvolvimento na Solana
- A verdade sobre os mercados de taxas locais da Solana
- Por dentro da execução de programas da Solana: do código Rust ao bytecode SBPF
- O que é a SVM — a Máquina Virtual da Solana
Artigos relacionados
Assine a Helius
Acompanhe as novidades mais recentes do desenvolvimento Solana e receba atualizações quando publicarmos


