
Was ist die Solana Virtual Machine (SVM)?
Inhaltsverzeichnis
- Praktische Erkenntnisse
- Einführung
- Eine umstrittene Definition
- Die SVM auf einen Blick
- Virtuelle Maschinen
- Virtuelle Maschinen in Blockchains
- So funktioniert die SVM
- Das Besondere an der SVM: Account-Deklarationen vorab
- Ein Paradigmenwechsel
- Vom Rust-Quellcode zum sBPF-Bytecode: die Kompilierungspipeline
- Rust
- Grundlegender Aufbau eines Programms
- Der Rust-Compiler und LLVM IR
- eBPF
- sBPF
- Die SVM ISA
- Syscalls
- Programmbinärdatei
- So wird Bytecode auf Solana hochgeladen
- Das BPF-Loader-Programm
- Bereitstellungsarchitektur: Account-Modelle
- So werden Solana-Programme bereitgestellt
- So funktioniert die Ausführung in der SVM
- Transaktionen
- Die Transaction Processing Unit (TPU)
- Koordination durch die Bank
- Laden von Programmen
- JIT-Kompilierung
- Bereitstellung der sBPF VM
- Programmausführung
- Prüfung nach der Ausführung
- Ausführungsergebnisse
- Ausblick
- Weitere Ressourcen
Vielen Dank an Lostin, Alessandro, Brian, Brady und Daniel Cumming für die Durchsicht früherer Versionen dieser Arbeit.
Praktische Erkenntnisse
- Die SVM umfasst den gesamten Stack zur Transaktionsausführung. Die EVM bezeichnet dagegen eindeutig einen Bytecode-Executor.
- Da Transaktionen vor der Ausführung angeben müssen, auf welche Accounts sie zugreifen, werden parallele Ausführung über mehrere CPU-Kerne und lokale Gebührenmärkte möglich.
- Rust-Quellcode wird mit rustc zunächst in LLVM IR kompiliert. Anschließend überführt ihn das eBPF-Backend von LLVM, genauer gesagt Solanas Fork sBPF, in sBPF-Bytecode. Somit kann jede Sprache mit einem LLVM-Frontend (z. B. C, C++, Zig) zum Schreiben von Solana-Programmen verwendet werden.
- sBPF ist Solanas Fork von Linux eBPF und praktisch identisch damit, abgesehen von einigen historischen Ergänzungen. Diese Ergänzungen sollen jedoch wieder entfernt werden. Der einzige relevante Unterschied besteht darin, dass Upstream-eBPF-Funktionen maximal 5 Argumente haben, während Solanas Fork mehr zulässt.
- Kompilierter sBPF-Bytecode wird in ELF-Dateien gespeichert. Sie enthalten Abschnitte für Anweisungen und Konstanten sowie Relokationstabellen. Der Linker löst Syscall-Referenzen in deterministische 32-Bit-Murmur3-Hashes auf und schreibt interne Funktionen für bessere Portabilität als relative Sprünge um.
- Der BPF Loader Upgradeable nutzt für direkte Upgrades und Deployments ein Modell mit zwei Accounts. Der kommende Loader V4 vereinfacht dies auf einen einzigen Account mit optionaler Komprimierung. Sämtlicher Bytecode wird statisch geprüft, bevor er als ausführbar markiert wird.
- Transaktionen enthalten ein Array von Account-Adressen mit Lese- und Schreibberechtigungen, Anweisungen und Signaturen. Dieses strukturierte Format ermöglicht es, Konflikte zu erkennen und konfliktfreie Transaktionen parallel einzuplanen.
- Die Banking Stage der TPU plant konfliktfreie Transaktionen parallel ein. Die Bank lädt den Account-Zustand aus AccountsDB. BPF Loaders stellen isolierte sBPF-VMs mit begrenzten Speicherregionen und Compute-Budgets bereit. Erfolgreiche Zustandsänderungen werden atomar übernommen, während Änderungen bei Fehlern vollständig zurückgesetzt werden.
- Die SVM ISA ist die einzige formale Spezifikation. Abgesehen vom Alpenglow-Whitepaper und der ursprünglich von Toly verfassten Artikelserie gibt es keine einzelne „SVM-Spezifikation“. Die Runtime entsteht aus dem Zusammenspiel von Bank, Scheduler, BPF Loaders und der sBPF VM selbst.
Einführung
Die Solana Virtual Machine (SVM) gehört heute zu den am häufigsten missverstandenen Systemen in Blockchains. Die Ethereum Virtual Machine (EVM) bezeichnet eindeutig einen Opcode-Executor. Der Begriff SVM umfasst dagegen eine vollständige Pipeline zur Transaktionsausführung – vom Scheduler der Banking Stage bis zum sBPF-Bytecode-Interpreter selbst. Diese Unschärfe spiegelt einen architektonischen Unterschied von Solana wider: Es gibt keine klassische Spezifikation, die „die SVM“ isoliert definiert. Am nächsten kommt dem die Solana Virtual Machine Instruction Set Architecture (SVM ISA). Sie beschreibt, wie sBPF-Bytecode ausgeführt werden muss, sagt aber nichts über die umfassendere Runtime aus.
Dieser Artikel soll umfassend erklären, was die SVM ist, wie sie funktioniert und warum sie sich aus Sicht der Agave-Validator-Implementierung von Anza grundlegend unterscheidet. Wie der Client von Firedancer und dessen eigene, mit der SVM ISA kompatible Implementierung einer virtuellen Maschine funktionieren, liegt außerhalb des Rahmens dieser Arbeit.
Wir untersuchen die tatsächliche Codebasis statt einer abstrakten Spezifikation. Dazu verfolgen wir die vollständige Ausführungspipeline: wie Rust-Quellcode über LLVM in sBPF-Bytecode kompiliert wird, wie Programme bereitgestellt und geprüft werden, wie die Runtime isolierte Ausführungsumgebungen für die parallele Ausführung bereitstellt und wie Transaktionen mit bereitgestelltem Bytecode interagieren.
Die ersten Abschnitte ordnen die Unschärfe des SVM-Begriffs ein und geben einen allgemeinen Überblick über ihre Funktionsweise. Der Rest des Artikels richtet sich an ein technischeres Publikum, das Solanas Ausführungsschicht fundiert verstehen möchte.
Eine umstrittene Definition
Der Begriff „Solana Virtual Machine“ (SVM) hat in der Community kontroverse Debatten ausgelöst. Das gilt besonders seit dem Aufkommen von Netzwerkerweiterungen und anderen Layer-Blockchains, die auf Solana aufbauen. Umstritten ist der Umfang des Begriffs: Bezeichnet die SVM ausschließlich den Low-Level-sBPF-Interpreter oder umfasst sie den gesamten Stack zur Transaktionsausführung?
Die enge Auslegung betrachtet die SVM analog zu einer klassischen virtuellen Maschine (VM), etwa dem Opcode-Executor der EVM. Gemeint ist genauer gesagt die von eBPF abgeleitete virtuelle Maschine (früher rBPF, heute sBPF), die Bytecode interpretiert und per JIT kompiliert. Aus dieser Perspektive ist die SVM ein isolierter, registerbasierter Executor, der Anweisungen wie ALU-Operationen oder Solana-spezifische Systemaufrufe verarbeitet. Im Wesentlichen orientiert sich die SVM am Sicherheitsmodell von Linux eBPF, ist aber an Blockchain-Infrastruktur angepasst. Dazu passen Bezeichnungen wie SVM ISA (Instruction Set Architecture) im Validator-Code, wo SVM nur die VM-Schicht meint.
Die breite Auslegung definiert die SVM als die gesamte Schicht zur Transaktionsausführung eines Solana-Validators. Sie umfasst nicht nur das Ausführen von Bytecode, sondern auch vorgelagerte Komponenten wie den Scheduler der Banking Stage, die Budgetierung von Compute Units und Zustandsaktualisierungen über die Account-Datenbank, die umgangssprachlich AccountsDB heißt. Es ist die „Runtime“, die rohe Transaktionen in validierte Zustandsänderungen umwandelt.
Die Unschärfe entsteht, weil offizielle Solana-Mitteilungen die Begriffe „Runtime“ und „SVM“ austauschbar verwenden, ohne eine einzelne verbindliche Definition festzulegen. Anza hat dringend benötigte Klarheit in die Debatte gebracht, sie ausdrücklich bestätigt und zugleich eine pragmatische, handlungsorientierte Sichtweise aus der Engineering-Praxis vertreten. Anzas Verständnis der SVM als Bank-gesteuerte Runtime, die die eBPF VM bereitstellt, bietet eine deutlich breitere Sicht auf die gesamte Pipeline. Daraus lässt sich eine geeignete Definition der SVM ableiten.
Dies wird in Anzas offizieller SVM-Spezifikation formalisiert. Sie definiert die SVM als „die für die Transaktionsausführung verantwortlichen Komponenten“, die als eigenständige Bibliothek für Validatoren, Fraud Proofs, Sidecars und weitere Anwendungen bereitgestellt werden.
Für unsere Zwecke können wir die Solana Virtual Machine so definieren:
Die entkoppelte Runtime-Schnittstelle und Pipeline zur Transaktionsverarbeitung innerhalb von Solana-Validatoren. Sie wird von der Bank-Komponente gesteuert, koordiniert die parallele Ausführung von Anweisungen und On-Chain-Programmen und stellt eine angepasste, eBPF-basierte virtuelle Maschine für die sichere Interpretation von Bytecode, JIT-Kompilierung und Ressourcenmessung bereit.
Die SVM auf einen Blick
Die Solana Virtual Machine (SVM) dient als Ausführungsumgebung für Transaktionen, die netzwerkweit mit On-Chain-Programmen interagieren. In dieser Runtime-Schicht trifft Code auf Zustand. Sie wandelt kryptografisch signierte Transaktionen in validierte Zustandsänderungen um.
Um die SVM wirklich zu verstehen, müssen wir zunächst klären, was eine virtuelle Maschine im Kontext von Blockchains ist.
Virtuelle Maschinen
Eine virtuelle Maschine (VM) ist Software, die ein Computersystem virtualisiert oder emuliert. Sie stellt eine isolierte Ausführungsumgebung bereit, die sich wie physische Hardware verhält. Das Konzept entstand in den 1960er-Jahren bei IBMs Arbeit an Mainframe-Systemen. Dadurch konnten mehrere Benutzer unterschiedliche Betriebssysteme auf derselben physischen Maschine ausführen. Es gibt zwei Hauptkategorien von VMs: System-VMs und Prozess-VMs. Erstere ersetzen eine reale Maschine, während Letztere Programme in einer plattformunabhängigen Umgebung ausführen. Für unsere Zwecke interessieren uns virtuelle Systemmaschinen. Im Folgenden nennen wir sie „virtuelle Maschinen“ oder einfach „VMs“.
Virtuelle Maschinen lösen mehrere grundlegende Probleme. Zunächst abstrahieren sie die Hardware. Programme, die für eine VM geschrieben wurden, können auf jeder physischen Hardware ausgeführt werden, die diese VM unterstützt. Das Programm muss dafür nicht neu geschrieben werden. Die Java-Philosophie „einmal schreiben, überall ausführen“ veranschaulicht dies: Java-Bytecode läuft identisch unter Windows, macOS, Linux und anderen Systemen, auf denen eine Java Virtual Machine (JVM) installiert ist.
VMs bieten außerdem Isolation und Sicherheitsgarantien. Jede VM-Instanz arbeitet in einer Sandbox. Sie kann daher nicht auf Ressourcen des Hostsystems oder auf andere VMs zugreifen, sofern dies nicht ausdrücklich erlaubt wurde. Stürzt ein Programm ab oder enthält es schädlichen Code, bleibt der Schaden auf diese VM-Instanz begrenzt. Aufgrund dieses Isolationsprinzips nutzen Cloud-Anbieter wie Google Cloud und AWS VMs, um die Workloads ihrer Kunden voneinander zu trennen.
VMs liefern zudem vorhersehbare Ergebnisse. Sie bieten eine kontrollierte Umgebung, in der dieselbe Eingabe unabhängig von der zugrunde liegenden Hardware immer dieselbe Ausgabe erzeugt. Diese Vorhersagbarkeit ist entscheidend für Debugging, Tests und den Konsens in verteilten Systemen.
VMs können auch extrem leistungsfähig sein. Moderne VMs minimieren den Performance-Overhead durch Just-In-Time-Kompilierung (JIT). Die JIT-Kompilierung übersetzt VM-Bytecode zur Laufzeit in nativen Maschinencode. So erreicht sie nahezu native Performance und wahrt zugleich Portabilität und die genannten Sicherheitsgarantien.
Virtuelle Maschinen in Blockchains
Blockchains haben das VM-Konzept angepasst, um eine besondere Herausforderung zu lösen: Wie können Tausende unabhängige Computer weltweit nicht vertrauenswürdigen Code ausführen und zu identischen Ergebnissen gelangen? VMs dienen als deterministische Runtime-Umgebung. Sie führen Smart Contracts aus, also Programme auf Solana, und verwalten den Zustand des Netzwerks, also den aktuellen Status aller Accounts, Guthaben und sonstigen Daten im Netzwerk.
Wenn eine Transaktion an eine Blockchain übermittelt wird, ist die VM für Folgendes verantwortlich:
- Laden der erforderlichen Account-Daten aus dem Speicher.
- Ausführen des in der Transaktion angegebenen Programm-Bytecodes.
- Messen des Ressourcenverbrauchs, um Endlosschleifen oder Denial-of-Service-Angriffe (DoS) zu verhindern.
- Prüfen, ob alle Zustandsänderungen den vordefinierten Konsensregeln des Netzwerks entsprechen.
- Zurückschreiben des aktualisierten Zustands in den persistenten Speicher, also das Ledger.
Die konkreten Regeln für Zustandsübergänge werden durch die Instruction Set Architecture und die Runtime-Beschränkungen der VM definiert.
So funktioniert die SVM
Die SVM ist eine Pipeline aus Subsystemen, die gemeinsam Transaktionen sicher und effizient ausführen. Die Bank koordiniert die Ausführung für einen bestimmten Slot. Sie verwaltet den Account-Zustand, setzt Konsensregeln durch und koordiniert Banking Stage und persistenten Speicher, also AccountsDB. Jede Bank repräsentiert den Zustand aller Accounts in einem bestimmten Slot und durchläuft drei Lebenszyklen: aktiv, also offen für neue Transaktionen; eingefroren, also nach Abschluss des Slots nicht mehr offen für neue Transaktionen; und verwurzelt, also Teil der kanonischen Chain.
In der Banking Stage werden Transaktionen innerhalb der Transaction Processing Unit (TPU) eines Validators ausgeführt. Sie empfängt verifizierte Transaktionen aus der SigVerify-Stage, puffert sie und plant ihre parallele Ausführung anhand einer Konflikterkennung für Account-Sperren. Worker-Threads in der Banking Stage verarbeiten Batches konfliktfreier Transaktionen. Dazu rufen sie die Ausführungsmethoden der Bank auf, laden Accounts, stellen für jede Anweisung sBPF-VM-Instanzen bereit, führen Programm-Bytecode aus und erfassen die Ergebnisse. Die Banking Stage verarbeitet weitere Batches konfliktfreier Transaktionen, bis die Bank an der Slot-Grenze eingefroren wird. Batches unterscheiden sich von Entries. Entries sind die aufgezeichneten Transaktionseinheiten, die zur Replikation und Konsensfindung in das Ledger geschrieben werden.
Die BPF Loaders verwalten den Programmlebenszyklus: Deployment, JIT-Kompilierung, Upgrades und Ausführung. Wenn eine Anweisung ein bestimmtes Programm adressiert, wird eine sBPF VM mit eigenen Speicherregionen und eigenem Compute-Budget bereitgestellt. Anschließend wird die Ausführung an den Bytecode des Programms übergeben.
Die sBPF VM ist die isolierte Ausführungsumgebung, in der der Programm-Bytecode tatsächlich läuft. Sie basiert auf Linux eBPF und nutzt eine registerbasierte Architektur mit 11 Allzweckregistern. Die VM erzwingt Speicherisolation durch fünf getrennte Speicherregionen mit jeweils expliziten Grenzen und Berechtigungen. Außerdem misst sie den Verbrauch von Compute Units, um unkontrollierte Ausführung zu verhindern. Für privilegierte Operationen wie Kryptografie, Logging oder Cross-Program Invocations (CPIs) ruft sie Systemaufrufe auf.
AccountsDB ist die persistente Zustandsschicht, in der alle Account-Daten gespeichert werden. Vor der Ausführung wird der Account-Zustand geladen. Caches vermeiden wiederholte Festplattenzugriffe auf häufig verwendete Accounts. Nach erfolgreicher Ausführung werden Aktualisierungen in AccountsDB übernommen. Schlägt die Ausführung fehl, werden alle Zustandsänderungen atomar zurückgesetzt.
Zusammen bilden diese Komponenten die SVM, eine entkoppelte, wiederverwendbare Ausführungs-Engine.
Das Besondere an der SVM: Account-Deklarationen vorab
Die entscheidende Architekturentscheidung der SVM lautet: Alle Transaktionen müssen vor Beginn der Ausführung ausdrücklich angeben, aus welchen Accounts sie lesen und in welche sie schreiben werden. Diese einfache, direkt im Transaktionsformat verankerte Anforderung ermöglicht zwei grundlegende Funktionen, die Solana auszeichnen: parallele Ausführung und lokale Gebührenmärkte.
Parallele Ausführung (Sealevel)
Die Ethereum Virtual Machine (EVM) verarbeitet Transaktionen sequenziell: eine nach der anderen, wobei jede abgeschlossen sein muss, bevor die nächste beginnt. Die SVM ermöglicht dagegen horizontale Skalierung, indem sie mehrere Transaktionen gleichzeitig auf mehreren CPU-Kernen ausführt. Diese Parallelisierung ist möglich, weil alle Solana-Transaktionen vor Beginn der Ausführung ausdrücklich angeben, aus welchen Accounts sie lesen und in welche sie schreiben werden.
Durch die Deklaration der Accounts, aus denen eine Transaktion liest und in die sie schreibt, kann die Runtime Account-Abhängigkeiten analysieren, Konflikte erkennen und konfliktfreie Transaktionen einplanen:
- Transaktionen, die völlig unterschiedliche Accounts betreffen, können ohne Koordinationsaufwand parallel ausgeführt werden.
- Transaktionen, die nur aus denselben Accounts lesen, können ebenfalls parallel ausgeführt werden, da Lesezugriffe nicht miteinander in Konflikt stehen.
- Transaktionen, die in dieselben Accounts schreiben wollen, werden sequenziell ausgeführt. Das verhindert Race Conditions und gewährleistet einen konsistenten Zustand.
Lokale Gebührenmärkte
Da die Runtime bereits vor der Ausführung genau weiß, auf welche Accounts jede Transaktion zugreifen wird, lassen sich Gebühren auf bestimmte Accounts begrenzen. Sie konkurrieren nicht global im gesamten Netzwerk. Dieses Konzept heißt lokale Gebührenmärkte.
Auf Ethereum und anderen EVM-Chains konkurriert jede Transaktion in einem einzigen globalen Gebührenmarkt. Ob du ETH an einen Freund sendest, ein NFT mintest oder auf Uniswap handelst: Alle bieten um denselben Blockspace. Steigt die Nachfrage in einem Bereich, erhöhen sich die Gebühren für alle, selbst wenn sie etwas völlig anderes tun möchten.
Auf Solana konkurrieren nur Transaktionen miteinander, die auf dieselben Accounts zugreifen. Wer SOL zwischen zwei Accounts überträgt, sollte sich nicht um einen gleichzeitig stattfindenden, beliebten NFT-Mint sorgen müssen. Die Priority Fee einer Transaktion wird ausschließlich durch die Auslastung der betroffenen Accounts bestimmt. Dank dieser Lokalisierung können Solana-Transaktionen auch bei hoher Aktivität günstig bleiben.
Am 10. Oktober etwa erlebte der Kryptomarkt das bislang größte Liquidationsereignis. Trotz des Rekordanstiegs der Aktivität blieben Solana-Transaktionen relativ günstig. Die mediane Transaktionsgebühr erreichte 0,007 $, die durchschnittlichen Gebühren lagen kurzzeitig bei 0,10 $ und das oberste 1 % der Transaktionen erreichte knapp über 1,00 $. Im selben Zeitraum stiegen die medianen Gebühren bei Ethereum und Arbitrum auf über 100 $, während die Gebühren bei Base einen Spitzenwert von mehr als 3 $ erreichten.
Ein Paradigmenwechsel
| Aspekt | EVM | SVM |
| Architektur | Stack-basierte VM | Registerbasierte VM (von eBPF abgeleitet) |
| Ausführung | Sequenziell | Parallel (Konflikterkennung) |
| Gebührenmarkt | Global | Lokal (Auslastung je Account) |
| Account-Deklarationen | Nicht vorab erforderlich | Vorab erforderlich |
| ISA | ~140 Opcodes, Stack-Operationen | ~100 Opcodes, RISC-ähnliche Register |
| JIT-Kompilierung | Optional (clientabhängig) | Standard (native Performance) |
| Zustandsmodell | Gebühren für Contract-Speicher | Einheitliche Account-Datenbank |
| Sprachen | Solidity/Vyper → EVM-Bytecode | Rust/C/C++ → LLVM → sBPF |
Die SVM steht für einen grundlegend anderen Ansatz zur Blockchain-Ausführung. Bitcoin führte programmierbares Geld ein. Ethereum führte universelle Smart Contracts und beliebige On-Chain-Ausführung ein. Beide sind jedoch durch sequenzielle Ausführung und globale Gebührenmärkte eingeschränkt. Diese Architekturentscheidungen begrenzen Durchsatz und Kosten grundlegend.
Die SVM löst sich von klassischen Einschränkungen. Sie bietet ein Netzwerk, das hohen Durchsatz bewältigt, ohne die Programmierbarkeit zu beeinträchtigen oder Nutzer in unerschwinglich teure Gebührenauktionen zu zwingen. Die Pflicht zur vorzeitigen Deklaration von Accounts ist einfach, aber wirkungsvoll: Sie ermöglicht die parallele Ausführung über mehrere CPU-Kerne und beschränkt Gebühren auf Märkte auf Account-Ebene.
Natürlich sind das nicht die einzigen Optimierungen, die Solana gegenüber anderen Blockchains bietet. Solanas Mantra Bandbreite erhöhen, Latenz reduzieren und der kompromisslose Fokus auf die Verwirklichung der Internet Capital Markets haben zu zahlreichen Performance-Optimierungen, Designentscheidungen und Implementierungen geführt, die ein Netzwerk mit hohem Durchsatz ermöglichen.
Der Rest dieses Artikels zeigt genau, wie das funktioniert: wie Rust-Quellcode in Bytecode kompiliert wird, wie dieser Bytecode bereitgestellt und geprüft wird und wie die Runtime isolierte Ausführungsumgebungen bereitstellt. So kann sie Tausende Programme sicher parallel ausführen und zugleich strikte Deterministik und Sicherheitsgarantien wahren.
Vom Rust-Quellcode zum sBPF-Bytecode: die Kompilierungspipeline
Rust
Rust ist die Lingua franca der Solana-Programmentwicklung. Frameworks wie Anchor bieten Entwicklern einen robusten, klar strukturierten Ansatz, um effizient sichere Programme zu erstellen. solana_program ist als Basisbibliothek für alle On-Chain-Programme vorgesehen. In jüngerer Zeit hat sich Pinocchio, eine hochoptimierte Bibliothek ohne Abhängigkeiten, zur bevorzugten Option für Entwickler entwickelt, die native Solana-Programme erstellen möchten.
Unabhängig vom verwendeten Framework oder der Bibliothek haben alle Programme einen Entrypoint, den die Runtime beim Aufruf des Programms aufruft. Das entrypoint-Makro von solana_program erzeugt den erforderlichen Standardcode, um die Programmausführung zu starten. Dazu gehören die Deserialisierung von Eingaben sowie das Einrichten eines globalen Allocators und eines Panic Handlers. Pinocchio exportiert Entrypoint-Makros, die ähnlich funktionieren, den Entrypoint aber von der Einrichtung des Heap-Allocators und Panic Handlers entkoppeln. Dadurch erhalten Entwickler mehr Wahlmöglichkeiten.
Grundlegender Aufbau eines Programms
Ein Programm ist ein Account-Typ, der Code ausführen kann. Genauer gesagt ist ein Programm ein ausführbarer Account. Es speichert einen Blob aus sBPF-Bytecode in einem Account, der dem BPF Loader gehört und einen eindeutigen öffentlichen Schlüssel besitzt. Programme sind von Grund auf zustandslos: Alle persistenten Daten liegen in separaten Accounts, aus denen Programme beim Aufruf lesen oder in die sie schreiben können.
Die SVM erwartet, dass alle Programme einer bestimmten Grundstruktur folgen: einem Entrypoint, der drei Eingaben akzeptiert:
- Program ID: Die Adresse des Programms selbst. Sie wird für selbstreferenzielle Prüfungen verwendet, etwa der Eigentümerschaft.
- Accounts: Ein Array aus Account-Metadaten, also öffentlichen Schlüsseln, Lamport-Guthaben, Datenpuffern, Eigentümern und Flags. Sie bilden den „Zustand“, den ein Programm lesen und schreiben muss.
- Instruction Data: Ein Byte-Slice mit beliebigen Daten aus der Transaktion.
Ein Programm soll diese Eingaben über seinen Entrypoint verarbeiten, die relevanten beschreibbaren Accounts verändern, Logs oder Events ausgeben und anschließend einen Erfolgsstatus zurückgeben. Dieser zeigt an, ob alle Schritte erfolgreich ausgeführt wurden. Im Kern läuft dies auf eine process_instruction-Funktion hinaus.
Ein einfaches, in Rust mit der solana_program-Crate geschriebenes Programm sieht so aus:
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(())
}Im Hintergrund wird all das durch das Application Binary Interface (ABI) der SVM definiert, das wir später betrachten.
Der Rust-Compiler und LLVM IR
Rust ist wie viele andere Programmiersprachen eine Abstraktion zweiter Ordnung auf Basis von Assembly. Die Sprache soll Menschen ermöglichen, sicheren, nebenläufigen und lesbaren Code zu schreiben, ohne jede noch so kleine Hardwareinteraktion selbst verwalten zu müssen. Computer verstehen jedoch weder Rust noch irgendeine andere Hochsprache.
Computer verstehen Maschinencode: binäre Anweisungen, die auf eine bestimmte Architektur oder virtuelle Maschine zugeschnitten sind. Alle Programme werden letztlich in Binärcode umgewandelt, und Computer führen diese Übersetzung aus. Kompilierung ist ein mehrstufiger Übersetzungsprozess. Er entfernt High-Level-Abstraktionen, optimiert die Effizienz und erzeugt ausführbaren Bytecode.
rustc ist der offizielle Compiler von Rust. Die meisten Entwickler interagieren normalerweise nicht direkt mit rustc. Stattdessen rufen sie ihn über Cargo, den Paketmanager von Rust, auf. Dennoch durchläuft Rust-Quellcode in rustc drei Hauptphasen, bevor ausführbarer Bytecode entsteht. Jede Phase entfernt Abstraktionen, setzt Sicherheitsregeln durch und bereitet den Code auf die nächste Umwandlung vor:
- Parsing und Expansion
- MIR (Mid-level Intermediate Representation)
- LLVM IR (Low Level Virtual Machine Intermediate Representation)
Parsing und Expansion
Der Compiler liest eine Rust-Datei mit der Endung .rs als Klartext. In einem Lexing genannten Prozess sucht er nach bestimmten Tokens, etwa use, fn, None, impl und &[u8]. Bei der lexikalischen Tokenisierung wird Text in aussagekräftige lexikalische Tokens umgewandelt, die zu einer bestimmten Kategorie gehören, etwa Bezeichner, Operatoren, Trennzeichen, Literale oder Schlüsselwörter.
rustc wandelt diese lexikalischen Tokens in eine Datenstruktur um, die Abstract Syntax Tree (AST) heißt. Diese Baumstruktur bildet die verschachtelte, hierarchische Struktur des Rust-Quellcodes ab. Funktionen enthalten Blöcke, Blöcke enthalten Ausdrücke, Ausdrücke enthalten Operatoren und so weiter. Obwohl der AST noch auf einer hohen Abstraktionsebene liegt, bildet er den Quellcode und dessen zugrunde liegende Logik originalgetreu ab.
Sobald der AST erstellt ist, führt der Compiler mehrere zentrale Transformationen durch:
- Makro-Expansion: Makros wie entrypoint! und println! werden zu unverarbeitetem Rust-Code expandiert, sodass alle Makros in einfache AST-Knoten umgewandelt werden.
- Lowering: High-Level-Kurzsyntax, die Code lesbarer macht, wird in einem Lowering genannten Prozess in primitivere Formen umgeschrieben. Eine for-Schleife wird beispielsweise in eine loop-Schleife mit manueller Iteration umgewandelt. Das Ergebnis des Lowerings ist die High-Level Intermediate Representation (HIR).
- Borrow Checking und Sicherheitsanalyse: Rust führt für die HIR Typprüfung, Trait-Auflösung und Typinferenz durch. Das Ergebnis dieses Prozesses ist die Typed High-Level Intermediate Representation (THIR).
In dieser Phase verarbeitet der Compiler unsicheren Code. Unsicherer Code ermöglicht Entwicklern Operationen, die Rusts Sicherheitsgarantien umgehen, etwa das Dereferenzieren roher Pointer, das Aufrufen externer Funktionen oder das Implementieren unsicherer Traits. Er dient als bewusster Ausweg für Low-Level-Kontrolle. Bestimmte Regeln werden gelockert, der Code muss aber weiterhin gemäß Rusts Semantik kompilierbar sein. Das ist für Solana-Programme entscheidend. Dort kann unsicherer Code sparsam eingesetzt werden, um Performance bei kritischen Operationen zu steigern, etwa bei der Zero-Copy-Deserialisierung von Accounts, wie in Pinocchios Wrapper-Struct für einen Account.
Unsicherer Code wird erstmals nach dem Lexing beim Aufbau des AST erkannt. Unsichere Tokens werden identifiziert und als spezielle Knoten markiert. Anschließend wird er nach der AST-Expansion während der Typ- und Borrow-Prüfung verarbeitet. Der Compiler stellt sicher, dass unsichere Operationen auf unsichere Kontexte beschränkt bleiben. Andernfalls meldet er Fehler, etwa „roher Pointer kann außerhalb eines unsicheren Kontexts nicht dereferenziert werden“. Der Compiler prüft beispielsweise nicht, ob unsicherer Code Speicher beschädigt oder falsch verwaltet.
Am Ende dieser Phase ist der expandierte AST, jetzt THIR, eine validierte und abgesenkte Darstellung des Rust-Quellcodes.
MIR
Die THIR wird anschließend in die Mid-Level Intermediate Representation (MIR) abgesenkt. Diese Rust-zentrierte Form stellt den Quellcode als vereinfachten Kontrollflussgraphen (CFG) dar. Sämtliche Rust-spezifische Syntaxvereinfachungen und komplexen Konstrukte, etwa Pattern Matching, Traits und Closures, werden als grundlegende Blöcke ausgedrückt. Diese enthalten Zuweisungen und Verzweigungen. Die grundlegenden Blöcke sind durch Sprünge, genauer gesagt Gotos, und Verzweigungen verbunden. Dadurch lässt sich der Ablauf eines Programms leicht nachvollziehen.
MIR ist nicht zwingend erforderlich. Der Compiler könnte THIR direkt in LLVM IR absenken. MIR stellt jedoch eine Rust-spezifische Schicht bereit. Dort kann der Compiler Rust-spezifische Regeln durchsetzen und Optimierungen vornehmen, bevor die allgemeinen Optimierungen von LLVM angewendet werden. MIR eignet sich daher ideal für Prüfungen und Transformationen, die für LLVM zu abstrakt, für THIR aber zu detailliert sind. Dazu gehören:
- Borrow Checking: Erste semantische Prüfungen erfolgen während der Typanalyse der THIR. Der vereinfachte CFG von MIR ermöglicht jedoch ein vollständiges und präzises Borrow Checking, das alle Regeln für Eigentümerschaft, Borrowing und Lebenszeiten durchsetzt.
- Move- und Drop-Prüfung: Der Compiler stellt sicher, dass alle Werte gemäß Rusts Speichersicherheitsgarantien verschoben und verworfen werden. So verhindert er Use-after-free-Fehler.
- Initialisierungsanalyse: Der Compiler stellt sicher, dass alle Variablen vor ihrer Verwendung initialisiert wurden.
- Inlining und frühe Optimierungen: Der Compiler kann kleine Funktionen inline einfügen, arithmetische Ausdrücke vereinfachen und nicht erreichbaren Code entfernen.
In unserem Rust-Beispielprogramm haben wir zuvor msg!(“Hello, Solana!”) aufgerufen. Dies ist ein in der solana-program-Crate definiertes Makro. Bei einzelnen Ausdrücken wie einem statischen String wird es während der Makro-Expansion so erweitert, dass es direkt sol_log($msg) aufruft, wobei $msg der Ausdruck ist. Der sol_log-Syscall erhält einen Pointer auf die String-Daten und deren Länge. Er schreibt sie ohne Formatierungsaufwand in die Ausgabe der SVM. In MIR könnte dies so vereinfacht werden:
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(());
}
Dabei gilt:
- _0 = const “Hello, Solana!”;—MIR führt temporäre Variablen, also _0, für Zwischenwerte ein. Der String wird als konstantes Slice behandelt und in schreibgeschützten Daten abgelegt.
- _1 = len(_0);—MIR legt eine einfache Längenoperation auf dem Slice offen, die sich potenziell per Constant Folding optimieren lässt.
- sol_log(move _0, move _1);—Der Syscall-Aufruf wird in eine Sequenz von sBPF-Anweisungen übersetzt, die Register lädt und die Syscall-ID aufruft. In späteren Abschnitten erläutern wir genau, was das bedeutet. Wichtig ist hier, dass diese Moves mit Rusts Eigentumssemantik verknüpft sind und sie während der Kompilierung durchsetzen.
- Return = Ok(());—Beendet den Block mit einem Terminator und signalisiert der SVM den Erfolg.
Da MIR auf die Rust-Semantik ausgerichtet ist, eignet es sich ideal, um Ineffizienzen zu erkennen und CU-intensive Muster zu debuggen. Enthält dein Log beispielsweise dynamische Strings, kann MIR zusätzliche Allokationen oder Schleifen identifizieren, die sich optimieren lassen. Entwickler können MIR mit dem Befehl cargo rustc -- -Z dump-mir=all ausgeben.
MIR stellt sicher, dass der Code semantisch korrekt und optimiert ist und keine Rust-spezifischen Regeln mehr enthält, bevor er schließlich in LLVM IR abgesenkt wird.
Diese Phase wird allgemein als Codegenerierung bezeichnet. Dabei muss es sich nicht zwingend um LLVM handeln. LLVM ist jedoch weitverbreitet und für die meisten Menschen der Inbegriff der Rust-Codegenerierung. Der Rust-Compiler enthält auch GCC- und Cranelift-Backends, die GIMPLE beziehungsweise CLIF ausgeben. Im Kontext von Solana konzentrieren wir uns auf LLVM IR. Für Rust im Allgemeinen ist LLVM jedoch nicht immer die einzige Option.
LLVM IR
LLVM, ursprünglich eine Abkürzung für "Low Level Virtual Machine", bezeichnet ein modulares Compiler-Framework. LLVM ist kein einzelner Compiler, sondern ein Toolkit aus wiederverwendbaren Komponenten zum Erstellen von Compilern, Optimierern und Codegeneratoren. Viele Sprachen, darunter Rust, C, C++, Julia, Swift, Brainfuck und Zig, nutzen LLVM, weil es unterschiedlichste Architekturen von x86-CPUs bis hin zu virtuellen ISAs als Ziel unterstützt.
rustc übersetzt MIR in LLVM IR (Low Level Virtual Machine Intermediate Representation). Sie bildet die Brücke zwischen der Rust-Semantik und dem Bytecode, der schließlich auf Solana bereitgestellt wird. LLVM IR ist Maschinencode deutlich ähnlicher und enthält explizite Speicherallokationen wie alloca, Stores, Loads und Funktionsaufrufe. Eigentümerschaft, Lebenszeiten oder Traits kennt sie nicht mehr. Diese Rust-Abstraktionen wurden bereits vollständig expandiert und existieren nicht länger. Die Garantien der vorherigen Phasen bleiben jedoch erhalten.
In dieser Phase werden verschiedene Optimierungen angewendet, darunter:
- Constant Folding (also das Auswerten von Konstanten zur Kompilierzeit).
- Inlining (also das Ersetzen eines Aufrufs durch den Funktionskörper).
- Dead Code Elimination (also das Entfernen von Anweisungen, die das Ergebnis nicht beeinflussen).
- Loop Unrolling und Vektorisierung (also das Umschreiben von Schleifen für eine schnellere Ausführung).
LLVM stellt uns somit Folgendes bereit:
- LLVM IR: Ein portables, Assembly-ähnliches Zwischenformat.
- Optimierungspässe: Zur Erzeugung von LLVM IR nutzt LLVM Static Single Assignment (SSA). Dadurch wird jede Variable genau einmal zugewiesen, was Optimierungen wie Inlining und das Entfernen von Dead Code ermöglicht.
- Codegeneratoren: Ziele, die LLVM IR in tatsächlichen Maschinencode absenken, etwa x86_64, ARM, WebAssembly oder eBPF.
Rust-Programme werden normalerweise für Hardwareziele wie x86_64 oder ARM kompiliert. Solana-Programme laufen jedoch nicht direkt auf Hardware, sondern innerhalb der Solana Virtual Machine. Das LLVM-Backend senkt LLVM IR daher in BPF-Bytecode ab. Auf Solana wird daraus sBPF-Bytecode, also ein Fork von eBPF, der nicht deterministische Funktionen entfernt und Solana-spezifische Syscalls einführt.
Rust ist zwar die Lingua franca der Solana-Programmentwicklung, doch jede Sprache, die das BPF-Backend von LLVM als Ziel unterstützt, kann verwendet werden. Dazu gehören etwa C, Nim, Swift und Zig.
eBPF
LLVM IR wird zu eBPF abgesenkt, der registerbasierten ISA, die Solanas Runtime zugrunde liegt. eBPF (Extended Berkeley Packet Filter) entstand aus dem Berkeley Packet Filter (BPF). Steven McCanne und Van Jacobson entwickelten BPF 1992 am Lawrence Berkeley Laboratory für Unix-Systeme der Berkeley Software Distribution (BSD). Im Wesentlichen ist BPF ein Netzwerk-Tap und Paketfilter. Er ermöglicht es, Netzwerkpakete auf Betriebssystemebene ohne Kopieren der Daten zu erfassen und mithilfe von Qualifizierern zu filtern.
eBPF hat sich seitdem zu einer universellen, isolierten VM innerhalb des Linux-Kernels weiterentwickelt, wurde also erweitert. Seine Möglichkeiten sind vergleichbar mit dem, was JavaScript für die Webentwicklung eröffnet hat: eBPF ist eine sichere Skript-Engine für Kernel. Entwickler können damit kleine, verifizierte Programme direkt im Linux-Kernel ausführen. Ein eingeschränkter Befehlssatz ermöglicht Aufgaben wie Performance-Monitoring, Observability, Sicherheit und Netzwerkverwaltung.
Das ist wichtig, weil Entwickler davon auf folgende Weise profitieren:
- Isolierte Ausführung: eBPF-Programme laufen in einer eingeschränkten virtuellen Maschine innerhalb des Kernels. Daher können sie weder den Kernel zum Absturz bringen noch dessen Speicher beschädigen.
- Sicherheitsgarantien: eBPF-Bytecode wird vor dem Laden statisch geprüft. So wird sichergestellt, dass keine ungültigen Speicherzugriffe, Sprünge außerhalb gültiger Grenzen oder andere privilegierte Operationen auftreten. Dies bietet Sicherheit ohne Runtime-Overhead.
- Effizienz: eBPF ist registerbasiert statt Stack-basiert wie die EVM. Dank seines schlanken Designs ohne den Overhead eines vollständigen Betriebssystems kann es per JIT in Maschinencode kompiliert werden und nahezu native Geschwindigkeiten erreichen.
- Flexibilität: eBPF stellt Systemaufrufe bereit, auch Syscalls genannt. Sie dienen im Wesentlichen als Einstiegspunkte in Kernel-Funktionen. Syscalls lassen sich um neue Funktionen erweitern, ohne den Befehlssatz neu gestalten zu müssen.
Solana benötigte eine deterministische, sichere und leistungsstarke VM, um nicht vertrauenswürdige Programme im gesamten Validator-Set auszuführen. eBPF bietet ein bewährtes Sicherheitsmodell, eine portable und effiziente ISA für Tausende schlanker Programme sowie JIT-Unterstützung für bessere Performance. Statt eine völlig neue VM zu entwickeln, hat Solana daher eBPF geforkt und sBPF geschaffen.
sBPF
Ursprünglich forkten Solana Labs Quentin Monnets rBPF, um Solanas Version von rBPF zu erstellen. Damit sollte sichergestellt werden, dass jeder Validator über ein Bytecode-Format verfügt, das bei der Ausführung eines bestimmten Programms mit derselben Eingabe exakt dieselben Ergebnisse erzeugt.
Man ging davon aus, dass ein Fork von eBPF erforderlich sei, weil Solanas Konsens eine deterministische Ausführung und einen begrenzten Ressourcenverbrauch voraussetzt. eBPF selbst ist zwar deterministisch, doch Solana benötigte zusätzliche Garantien und Blockchain-spezifische Funktionen:
- Feste Laufzeiten und Kosten für Anweisungen.
- Ausführung im Userspace.
- Eine deterministische Runtime.
Die Ausführung erfolgt bewusst im Userspace statt im Kernel. Dadurch sind weder Kernel-Berechtigungen noch Kernel-Modifikationen erforderlich. Dies ermöglicht das Deployment in verschiedenen Betriebssystemumgebungen, ohne Root-Zugriff oder eigene Kernel-Module vorauszusetzen. Der Userspace ist für Solana eine praktische Wahl: Er bietet Portabilität, einfache Tests und leichteres Deployment. Trotz der Ausführung im Userspace erreicht JIT nahezu native Performance. Außerdem ermöglicht die Userspace-Ausführung Tests und Fuzzing ohne Kernel-Zugriff.
rBPF wird nicht mehr verwendet. Nach der Gründung von Anza wurde rBPF stattdessen geforkt, um sBPF (Solana Berkeley Packet Filter) zu erstellen. Das GitHub-Repository von rBPF, das Solana Labs gehört, wurde am 10. Januar 2025 archiviert.
Die SVM ISA
Die SVM ISA (Solana Virtual Machine Instruction Set Architecture) ist die zentrale Spezifikation dafür, wie Solana-kompatible VMs, etwa Agaves sBPF oder Firedancers Neuimplementierung, Programme ausführen müssen. Sie ist nicht die VM selbst, sondern der Standard beziehungsweise Vertrag, der Konsistenz und Protokollkonformität zwischen verschiedenen SVM-Implementierungen gewährleistet. Die SVM ISA legt eBPF diese Sicherheits- und Determinismusbeschränkungen auf. Sie entfernt Kernel-zentrierte Funktionen und fügt Blockchain-spezifische Funktionen hinzu.
Die ISA regelt Register, Kodierung von Anweisungen, Opcodes, Klassen, Prüfregeln, Panic-Bedingungen und das Application Binary Interface (ABI). Änderungen an der SVM ISA müssen über SIMDs implementiert werden. So kann sich der Befehlssatz kontrolliert weiterentwickeln und die deterministische Ausführung über alle Validatoren hinweg bleibt gewährleistet.
Register
Register sind winzige Speicherplätze innerhalb der VM. Sie halten Zahlen oder Adressen, während Anweisungen ausgeführt werden, ähnlich wie Variablen oder beschriftete Kästen auf einer Werkbank. Die SVM ISA definiert eine 64-Bit-Registerarchitektur mit 11 Allzweckregistern (R0-R10) und einem verborgenen Programmzähler. Register sind für Ganzzahlen und Adressen 64 Bit breit, sodass große Werte oder Pointer effizient verarbeitet werden können. R0 enthält Rückgabewerte von Funktionen. R1-R5 übergeben wie Parameter die ersten fünf Funktionsargumente. R6-R9 werden vom Aufgerufenen gesichert und bleiben über Funktionsaufrufe hinweg erhalten. R10 dient als schreibgeschützter Frame Pointer, der den aktuellen Stack Frame markiert. Der verborgene Programmzähler verfolgt die Ausführung und gibt an, welche Anweisung als Nächstes ausgeführt wird.
Anweisungen
Eine Anweisung ist eine einzelne Operation, die die VM ausführen kann, etwa „addiere diese beiden Zahlen“ oder „springe zu dieser Codezeile“. Anweisungen folgen einem RISC-ähnlichen Design mit ungefähr 100 Opcodes. CISC-Architekturen wie x86 haben dagegen Tausende. Dadurch sind schnelle Prüfungen und eine effiziente JIT-Kompilierung möglich.
Anweisungen werden als 64-Bit-Werte im Little-Endian-Format mit folgender Struktur kodiert:
- opcode: 8 Bit
- dst_reg: 4 Bit
- src_reg: 4 Bit
- offset: 16 Bit (vorzeichenbehaftet)
- immediate: 32 Bit (vorzeichenbehaftet)
opcode gibt an, was zu tun ist. dst_reg bestimmt, wohin das Ergebnis geschrieben wird. src_reg gibt an, woher die Eingabe stammt. offset bestimmt den zu prüfenden Speicherversatz. immediate ist eine zusätzliche Konstante, die in der Anweisung enthalten sein kann.
lddw, also „load double word“, ist die einzige breite Anweisung. Sie belegt zwei 64-Bit-Slots, um vollständige unmittelbare 64-Bit-Werte zu unterstützen.
Anweisungen werden in Klassen eingeteilt. Dazu gehören Speicheroperationen, arithmetische oder logische Operationen, bedingte und unbedingte Verzweigungen, Funktionsaufrufe und Rückgaben sowie Endianness-Konvertierungen.
Speicherregionen
Die ISA definiert fünf Speicherregionen. Jede hat explizite Grenzen, also [addr, addr+len], innerhalb derer ein Programm lesen oder schreiben kann:
- Programmcode: die kompilierten Anweisungen selbst (Lesen + Ausführen).
- Stack: ein temporärer Arbeitsbereich für Funktionen (Lesen + Schreiben, normalerweise 4 KB pro Frame).
- Heap: dynamischer Speicher, den ein Programm anfordern kann (Lesen + Schreiben).
- Eingabedaten: schreibgeschützte Bytes, die mit einer Transaktion übergeben werden.
- Schreibgeschützte Daten: Konstanten und unveränderliche Werte.
Programme haben eine vordefinierte virtuelle Speicherzuordnung. Der Programmcode beginnt je nach Kompilierungsversion an Adresse 0x000000000 oder 0x100000000. Stack Frames beginnen bei 0x200000000, der Heap bei 0x300000000 und Eingabedaten bei 0x400000000.
Der Verifier
Der Verifier führt vor der Ausführung eine statische Analyse durch. Er untersucht jeden möglichen Codepfad, ohne das Programm auszuführen. So gewährleistet er die Sicherheit bereits beim Laden statt erst zur Laufzeit. Dabei prüft er unter anderem:
- Keine unbekannten oder nicht unterstützten Anweisungen.
- Alle Sprungziele liegen auf gültigen Anweisungsgrenzen und Rückwärtssprünge werden verarbeitet
- Keine nicht erreichbaren Codepfade.
- Die Grenzen für die Tiefe von Funktionsaufrufen werden durchgesetzt.
- Division oder Modulo durch null werden statisch abgelehnt.
- Programme unterliegen einer maximalen Größe und müssen diese einhalten.
Der Verifier ist hilfreich, verhindert aber nicht, dass Entwickler unerwartetes Verhalten einführen. Beispielsweise können in einem Solana-Programm weiterhin Use-after-free- und Pufferüberlauffehler auftreten.
Panic-Bedingungen
Panic-Bedingungen sind die von der SVM ISA definierten Runtime-Fehlerfälle. Dazu gehören:
- Ungültige oder nicht unterstützte Anweisungen.
- Division oder Modulo durch null.
- Speicherzugriff außerhalb der Grenzen.
- Ungültiger Speicherzugriff für unterschiedliche Speicherregionen, also eine Berechtigungsverletzung.
- Stack Overflow.
- Überschrittene Aufruftiefe.
- Überschrittene maximal zulässige Anzahl von Anweisungen.
- Das Programm hat einen Fehlercode zurückgegeben.
ABI
Das Application Binary Interface (ABI) ist der Formatvertrag zwischen einem Solana-Programm und der SVM. Der vorherige Abschnitt Grundlegender Aufbau eines Programms hat gezeigt, wie dies in Rust funktioniert, also mit process_instruction und drei Eingaben. Das ABI legt fest, wie diese Ein- und Ausgaben im Speicher dargestellt werden. Dadurch kann jeder Validator Programme deterministisch ausführen.
Auf hoher Ebene definiert das ABI drei Dinge: die Entrypoint-Konvention, Aufrufkonventionen und Register sowie das Speicherlayout.
Jedes Solana-Programm muss eine Entrypoint-Funktion bereitstellen. Der Loader serialisiert Programmeingaben in einer kanonischen Reihenfolge in den Speicherbereich der VM: die Program ID, das Accounts-Array und die Instruction Data. Anschließend übergibt die VM Pointer auf diese Regionen an den Entrypoint des Programms.
Die ersten fünf Register, also R1-R5, sind für Entrypoint-Argumente reserviert. Das Rückgaberegister R0 enthält den Exit-Code des Programms. Ein Exit-Code von null gilt als Erfolg. Ein Wert ungleich null bedeutet einen Fehler, der einem bestimmten InstructionError zugeordnet wird. Dadurch geben alle Programme Statuscodes einheitlich zurück. Parameter nach den ersten fünf werden zudem über den Stack übergeben. R6-R9 folgen einer Callee-saved-Konvention. Funktionen müssen diese Werte also erhalten, wenn sie sie verwenden.
Accounts und Daten werden als Byte-Slices in den linearen Speicher der VM serialisiert. Programme müssen sie in höherwertige Rust-Typen deserialisieren (z. B. AccountInfo, Pubkey). Die ABI erzwingt strikte Grenzen, damit kein Programm auf Speicher außerhalb der ihm zugewiesenen Bereiche zugreifen kann.
Zusammen bilden diese Regeln den „Klebstoff“ der ABI, der die abstrakte Entwicklererfahrung mit der Low-Level-ISA verbindet. So wird eine einfache Rust-Funktionssignatur in die korrekte Registernutzung, Speicheranordnung und die korrekten Rückgabecodes kompiliert. Dadurch interpretiert jeder Validator ein bestimmtes Programm jederzeit exakt gleich.
Syscalls
Die ISA ist bewusst minimal gehalten und enthält weder integrierte Accounts noch einen integrierten Zustand. Auch höherwertige Funktionen wie Logging, Hashing oder programmübergreifende Aufrufe stellt sie nicht direkt bereit. Stattdessen bietet sie Systemaufrufe – spezielle, in die VM integrierte Funktionen, mit denen Programme mit der Außenwelt interagieren können. Diese Aufrufe werden üblicherweise als Syscalls bezeichnet.
Syscalls lassen sich als von der VM bereitgestellte APIs verstehen. Statt dass jedes Programm bestimmte kryptografische Primitive oder Account-Logik neu implementieren muss, stellen Syscalls sichere, standardisierte Operationen bereit, die sich auf allen Validatoren garantiert deterministisch verhalten.
Zu den gängigen Syscall-Kategorien gehören:
- Logging und Debugging (z. B. schreibt der Syscall sol_log einen UTF-8-String in die Programmlogs und wird intern von msg! verwendet).
- Programmübergreifender Aufruf (CPI) (z. B. ermöglicht sol_invoke_signed einem Programm, ein anderes Onchain-Programm aufzurufen und dabei Accounts sowie Instruktionsdaten zu übergeben, was für die Komponierbarkeit von Solana entscheidend ist).
- Kryptografie (z. B. stellen die Syscalls sol_sha256, sol_keccak256 und sol_ed25519_verify deterministische, schnelle kryptografische Primitive bereit, ohne dass Entwickler eigene Implementierungen schreiben müssen).
- Speicher- und Account-Hilfsfunktionen (d. h. Syscalls, die Hilfsfunktionen zum Ausleihen von Account-Daten, zur Neuzuweisung von Speicher oder zur Arbeit mit programeigenen Heap-Zuweisungen bereitstellen).
- Compute-Budget und Messung (d. h. jeder Syscall verbraucht CUs, was das Messsystem der Runtime durchsetzt).
Syscalls werden über eine spezielle CALL_IMM-Instruktion mit einer eindeutigen Hash-Kennung aufgerufen. Wenn ein Programm einen Syscall aufruft, unterbricht die sBPF VM die Ausführung, sucht den Hash in ihrer Syscall-Registry und leitet den Aufruf an die native Implementierung weiter, die als privilegierter Runtime-Code ausgeführt wird. Syscalls laufen außerhalb der Sandbox und haben Zugriff auf den Runtime-Zustand. Das unterscheidet sich grundlegend von regulären Funktionsaufrufen innerhalb eines Programms.
Syscalls folgen derselben ABI wie reguläre Funktionen. Die ersten fünf Argumente werden über die Register R1 bis R5 übergeben, der Rückgabewert liegt in R0. Jeder Syscall hat feste Compute-Unit-Kosten, was einen deterministischen Ressourcenverbrauch sicherstellt. Beispielsweise verbrauchen alle Aufrufe des Syscalls secp256k1_recover 25.000 CUs.
Syscalls bilden eine kontrollierte Sicherheitsgrenze, da jeder Syscall seine Eingaben validiert und die relevanten Berechtigungen prüft, bevor er privilegierte Operationen ausführt. Beispielsweise prüfen Syscalls für programmübergreifende Aufrufe (CPI), ob der Aufrufer die erforderlichen Berechtigungen für die übergebenen Accounts besitzt.
Neue Syscalls können über Feature-Gates hinzugefügt werden, ohne die ISA selbst ändern zu müssen. Dadurch kann Solana die Fähigkeiten seiner VM erweitern und etwa neue kryptografische Primitive unterstützen, während die Abwärtskompatibilität mit bestehenden Programmen erhalten bleibt.
Programmbinärdatei
Am Ende der Kompilierung erzeugen alle Phasen – vom Rust-Quellcode über LLVM IR und eBPF bis zu sBPF und dessen Einhaltung der SVM ISA – eine einzige Ausgabe: eine Programmbinärdatei. Diese Binärdatei wird tatsächlich auf Solana bereitgestellt.
ELF
Solana-Programme werden in Dateien im Executable and Linkable Format (ELF) kompiliert, einem Standardbinärformat für Unix-ähnliche Systeme. Das ELF-Format dient als Container, der alles bündelt, was die VM zur Ausführung eines bestimmten Programms benötigt, und dabei plattformunabhängig bleibt.
Eine ELF-Datei enthält normalerweise die folgenden Abschnitte:
- Bytecode-Abschnitt: Enthält die kompilierten sBPF-Instruktionen im Abschnitt .text.
- Abschnitt für schreibgeschützte Daten: Enthält Konstanten, statische Strings und unveränderliche Werte in einem Abschnitt .rodata.
- BSS- und Datenabschnitte: Enthalten globale beziehungsweise statische veränderliche Variablen in den Abschnitten .bss und .data. Beachte, dass Solana keine veränderlichen Daten zulässt. Das ELF kann also .rodata, aber keine BSS- und Datenabschnitte enthalten.
- Symbol- und Relokationstabellen: Definieren, wie Funktionsaufrufe, Syscalls und Speicherreferenzen beim Laden aufgelöst werden. Dazu dienen die Abschnitte .symtab und .strtab für Symbole sowie .rel.dyn und .rela.dyn für Relokationseinträge.
Jede ELF-Datei enthält außerdem einen Header, der die Architektur, die Instruktionsbreite (d. h. 64 Bit), die Byte-Reihenfolge (d. h. Little Endian) und die Adresse des Einstiegspunkts beschreibt.
Linken und Relokation
Um die Compiler-Ausgabe in eine einzelne ausführbare ELF-Datei umzuwandeln, ist eine letzte Komponente erforderlich: der Linker. Der Linker kombiniert mehrere kompilierte Codeeinheiten zu einer zusammenhängenden Binärdatei. Außerdem löst er alle symbolischen Referenzen (d. h. Platzhalter) auf, die der Compiler nicht auflösen konnte. Zum Beispiel:
- Wenn ein Programm eine Funktion wie sol_log aufruft, weiß der Compiler nicht, wo sich diese Funktion im Speicher befindet. Daher verwendet er einen Platzhalter.
- Der Linker ersetzt diese symbolische Referenz durch die eindeutige gehashte Kennung des Syscalls (d. h. einen deterministischen 32-Bit-Murmur3-Hash).
- Ebenso werden Aufrufe zwischen internen Funktionen als relative Sprünge zu Instruktions-Offsets innerhalb des Abschnitts .text umgeschrieben.
Dieser Prozess, bei dem symbolische Referenzen in konkrete Adressen oder gehashte Syscall-IDs umgeschrieben werden, heißt Relokation. Allerdings sind Relokationen größtenteils ein Artefakt der ursprünglichen Tooling-Architektur und keine grundlegende Anforderung. Es gibt sogar Pläne, Relokationen in zukünftigen Versionen der Toolchain vollständig zu entfernen, um den Bereitstellungsprozess zu vereinfachen.
Dieser Relokationsschritt stellt sicher, dass dieselbe ELF-Binärdatei auf allen Validatoren identisch läuft, da sie keine absoluten Speicheradressen oder systemspezifischen Symbole enthält.
Außerdem wird der Bytecode mit den bereits angewendeten Relokationen im Speicher zwischengespeichert. Alle zukünftigen Ausführungen verwenden daher den aktualisierten Bytecode, ohne die Relokationen erneut verarbeiten zu müssen.
Sobald der Linker eine vollständig relokierte ELF-Datei erzeugt hat, ist das Programm zur Bereitstellung bereit. Das Endergebnis ist eine Binärdatei mit folgenden Eigenschaften:
- Portabel: Sie läuft auf jedem Validator und jeder SVM-Implementierung identisch.
- Deterministisch: Sie enthält keine nicht deterministischen Syscalls oder Abhängigkeiten vom Betriebssystem.
- Eigenständig: Sie enthält sämtlichen Bytecode und alle Metadaten, die zur Ausführung erforderlich sind.
So wird Bytecode auf Solana hochgeladen
Sobald ein Solana-Programm kompiliert und zu einer gültigen ELF-Datei gelinkt wurde, muss es in die Blockchain hochgeladen werden, damit Validatoren es ausführen können. Dieser als Programmbereitstellung bezeichnete Prozess umfasst mehrere zusammenarbeitende Komponenten: den BPF Loader, Account-Modelle, die Bytecode-Verifizierung und die Zustandsverwaltung.
Das BPF-Loader-Programm
Der BPF Loader ist ein natives Programm, das ELF-Dateien validiert, relokiert und als ausführbar markiert. Im Wesentlichen verwaltet er den Lebenszyklus bereitgestellter Programme: Er verarbeitet Instruktionen zur Initialisierung von Accounts, schreibt Bytecode, stellt Programme bereit und wickelt Upgrades ab.
Solana hat mehrere Loader-Versionen durchlaufen, die jeweils die vorherige Version verbessert haben:
- BPF Loader: Der ursprüngliche Loader für statische Programme ohne Upgrade-Möglichkeit, der nicht mehr unterstützt wird.
- BPF Loader V2: Ein vereinfachter Loader ohne Verwaltungsinstruktionen.
- BPF Loader Upgradeable: Der aktuelle Loader, der Programm-Upgrades eingeführt hat.
- BPF Loader V4: Die neueste Version mit besseren Bereitstellungsfunktionen, die das aktuelle Modell mit zwei Accounts auf ein Modell mit einem Account vereinfacht.
Bereitstellungsarchitektur: Account-Modelle
Aktuelles Account-Modell
Der aktuelle Loader verwendet eine Architektur mit zwei Accounts, um die Programmlogik von den Programmdaten zu trennen. Dadurch entstehen für ein bestimmtes Programm zwei Accounts: der Program-Account und der ProgramData-Account.
Der Program-Account ist ein kleiner, etwa 36 Byte großer Account, der Metadaten enthält und als ausführbar markiert ist. Er speichert eine Referenz auf ProgramData über UpgradeableLoaderState::Program { programdata_address }.
Der ProgramData-Account ist größer und speichert den eigentlichen ELF-Bytecode zusammen mit Bereitstellungsmetadaten (z. B. Slot und Adresse der Upgrade-Autorität) über UpgradeableLoaderState::ProgramData.
Die Trennung der beiden Accounts ermöglicht direkte Upgrades. Der Program-Account bleibt also unter derselben Adresse, während der Bytecode des ProgramData-Accounts ersetzt werden kann.
Zukünftiges Account-Modell
Loader V4 soll den Bereitstellungsprozess mit einem Modell aus einem einzigen Account optimieren. Dabei speichert der Programm-Account die Metadaten und den Bytecode direkt, sodass kein separater ProgramData-Account mehr erforderlich ist. Entwickler können außerdem ein zstd-komprimiertes Image speichern, um Rent-Kosten zu senken.
So werden Solana-Programme bereitgestellt
Bei der Bereitstellung wird eine kompilierte ELF-Binärdatei hochgeladen. Anschließend verifiziert der BPF Loader sie, speichert sie im Cache und markiert sie als ausführbar. Aufgrund der im vorherigen Abschnitt beschriebenen Bereitstellungsarchitektur unterscheidet sich der Prozess zwischen dem Upgradeable Loader und V4 geringfügig.
BPF Loader Upgradeable
Beim aktuellen Bereitstellungsprozess mit dem Upgradeable Loader wird zunächst ein Buffer-Account zur Zwischenspeicherung des ELF-Bytecodes initialisiert. Der Bereitsteller sendet eine InitializeBuffer-Instruktion an den BPF Loader Upgradeable. Dieser erstellt einen neuen Account, der dem Loader gehört, und setzt dessen Zustand auf UpgradeableLoaderState::Buffer { authority_address }. Dabei zeichnet er die Adresse auf, die zum Schreiben in den Buffer berechtigt ist.
Die kompilierte ELF-Binärdatei wird mithilfe der Instruktion Write { offset, bytes } in Blöcken in den Buffer hochgeladen. Jede Schreibinstruktion prüft, ob der Unterzeichner mit der Autorität des Buffers übereinstimmt und ob der Buffer noch veränderbar, also noch nicht bereitgestellt, ist. Anschließend schreibt sie die Bytes am angegebenen Offset hinter dem Metadaten-Header. Aufgrund der Größenbeschränkungen für Transaktionen sind bei großen Programmen mehrere Write-Instruktionen erforderlich, um die gesamte ELF-Datei hochzuladen.
Sobald der Buffer das vollständige ELF enthält, sendet der Bereitsteller eine Instruktion vom Typ DeployWithMaxDataLen { max_data_len }. Dies ist der komplexeste Schritt des gesamten Bereitstellungsprozesses, da er die eigentliche Bereitstellung von der Account-Validierung bis zum Abschluss des Zustands koordiniert.
Zunächst validiert der Loader alle am Bereitstellungsprozess beteiligten Accounts und prüft Folgendes:
- Der Programm-Account ist nicht initialisiert und von der Rent befreit.
- Der Buffer enthält gültige Daten, und die Autorität, die ihn initialisiert hat, hat die Transaktion signiert.
- max_data_len ist groß genug für die Daten im Buffer.
- Die Gesamtgröße überschreitet MAX_PERMITTED_DATA_LENGTH nicht (d. h. 10 MiB oder 10.485.760 Byte).
Anschließend erstellt der Loader den ProgramData-Account. Dessen Adresse leitet er mithilfe der Programm-ID und der Loader-ID als PDA ab. Danach überträgt er die Lamports des Buffers zurück an den Zahler, da der Buffer-Account nach der Bereitstellung nicht mehr benötigt wird.
Zusätzlich erstellt er den ProgramData-Account über einen CPI an das System Program und weist genügend Speicherplatz für die Metadaten sowie max_data_len Byte zu. Anschließend verwendet der Loader den Bump-Seed des PDA, um den CPI zu signieren.
Das Makro deploy_program! stellt sicher, dass der Bytecode sicher ausgeführt werden kann. Es parst zunächst die Struktur der ELF-Datei, um die ELF-Magic-Bytes (d. h. 0x7f ‘E’ ‘L’ ‘F’) und die Header (d. h. 64 Bit, Little Endian) zu validieren. Danach extrahiert es die Programmabschnitte, verarbeitet die Relokationstabellen und validiert die Abschnittsgrenzen sowie die Ausrichtung. Der Ladevorgang schlägt sofort fehl, wenn das ELF fehlerhaft ist oder nicht unterstützte Funktionen verwendet.
Anschließend führt der RequisiteVerifier (d. h. der Verifier von sBPF) eine statische Analyse aller möglichen Ausführungspfade durch, ohne das Programm auszuführen. So weist er die Sicherheit nach, bevor eine Instruktion ausgeführt wird. Der Verifier setzt außerdem die zuvor genannten Einschränkungen der SVM ISA durch. Schlägt die Verifizierung fehl, wird die Bereitstellung mit InstructionError::InvalidAccountData abgelehnt und das Programm nie als ausführbar markiert.
Nach erfolgreicher Verifizierung wird der Bytecode kompiliert und für die Ausführung zwischengespeichert. Die Funktion load_program_from_bytes erstellt einen ProgramCacheEntry, der Folgendes enthält:
- JIT-kompilierte ausführbare Datei: Der sBPF-Bytecode wird per Just-In-Time-Kompilierung (JIT) in nativen Maschinencode für die CPU-Architektur des Validators übersetzt. Das ermöglicht eine nahezu native Ausführungsgeschwindigkeit bei gleichbleibender Sicherheit.
- Slot-Metadaten: Der Zeitpunkt der Bereitstellung und der Zeitpunkt der Sichtbarkeit des Programms werden als deployment_slot beziehungsweise effective_slot aufgezeichnet. Die Verzögerung verhindert, dass Programme in demselben Slot verwendet werden, in dem sie bereitgestellt wurden.
- Runtime-Umgebung: Referenzen auf die Syscall-Registry, die festlegt, welche Syscalls verfügbar sind, sowie die Ausführungskonfiguration, die bei der Programmausführung verwendet wird.
Der Cache-Eintrag wird in program_cache_for_tx_batch gespeichert, wodurch das Programm für die Ausführung in nachfolgenden Transaktionen verfügbar wird. Nachdem das Programm erfolgreich verifiziert und zwischengespeichert wurde, aktualisiert der Loader die Account-Zustände, um die Bereitstellung abzuschließen. Der Zustand des ProgramData-Accounts wird aktualisiert und zeichnet auf, wann das Programm bereitgestellt wurde und wer zu Upgrades berechtigt ist. Außerdem wird der ELF-Bytecode aus dem Buffer in den Account kopiert. Der Zustand des Program-Accounts wird ebenfalls aktualisiert, mit dem ProgramData-Account verknüpft und als ausführbar markiert. Abschließend wird die Datenlänge des Buffers auf die Größe der Metadaten gesetzt. Dadurch wird der Bytecode praktisch gelöscht und der Speicherplatz freigegeben.
Das Programm ist jetzt vollständig bereitgestellt und kann von Transaktionen aufgerufen werden.
BPF Loader V4
Der BPF Loader V4 optimiert die Bereitstellung, indem er keinen separaten ProgramData-Account mehr benötigt und der Programm-Account den Bytecode direkt speichern kann. Außerdem unterstützt er die zstd-komprimierte Speicherung von ELF-Dateien. Das senkt die Rent-Kosten erheblich, während die Dateien beim Laden bei Bedarf dekomprimiert werden.
Der Bereitsteller ruft SetProgramLength { new_size } auf, um Speicherplatz für die Metadaten und den Bytecode des Programms zuzuweisen. Bei neuen Programmen initialisiert dies den Account mit dem Status LoaderV4State::Retracted, zeichnet die Autorität auf und markiert den Account als ausführbar, obwohl er noch nicht aufgerufen werden kann.
Anschließend schreibt der Bereitsteller die ELF-Binärdatei über Instruktionen vom Typ Write { offset, bytes } direkt in den Programm-Account. Diese Schreibvorgänge sind nur zulässig, wenn sich das Programm im Zustand Retracted befindet. Mit der Instruktion Copy lässt sich außerdem Bytecode aus einem anderen Programm kopieren, unabhängig von der Loader-Version. Das ist bei Migrationen hilfreich.
Anschließend versetzt die Instruktion Deploy das Programm vom Zustand Retracted in den Zustand Deployed. Im Wesentlichen extrahiert diese Instruktion den Bytecode am entsprechenden Offset aus dem Programm-Account und führt dieselbe Verifizierungspipeline wie der BPF Loader Upgradeable aus (d. h. ELF-Parsing, statische Verifizierung, JIT-Kompilierung und Caching). Bei erfolgreicher Verifizierung wird der Programmzustand auf LoaderV4Status::Deployed gesetzt und der Bereitstellungs-Slot aufgezeichnet.
Das Programm ist jetzt vollständig bereitgestellt und kann von Transaktionen aufgerufen werden.
Loader V4 erzwingt außerdem eine Abkühlphase zwischen Zustandsübergängen (d. h. Bereitstellung und Zurückziehen), um Angriffe durch erneute Bereitstellung zu verhindern. Programme können innerhalb eines Slots nach ihrer letzten Bereitstellung weder bereitgestellt noch zurückgezogen werden. Dadurch können böswillige Akteure Programme nicht schnell aktualisieren, um Race Conditions auszunutzen oder Nutzer zu verwirren. Gleichzeitig wird Atomarität pro Slot statt Verzögerungen über mehrere Slots hinweg gewährleistet. Beachte, dass diese Abkühlphase sowohl für die Instruktion Deploy als auch für Retract gilt.
Programme können über die Instruktion Finalize auch unveränderlich gemacht werden. Dadurch wechselt das Programm vom Status Deployed in den Status Finalized und kann nicht mehr zurückgezogen oder aktualisiert werden. Das Autoritätsfeld wird umfunktioniert und verweist auf die Programmadresse einer „nächsten Version“. So sind explizite Upgrade-Pfade möglich, während das Programm unveränderlich bleibt.
So funktioniert die Ausführung in der SVM
Die SVM ist die Engine zur Transaktionsverarbeitung innerhalb von Validatoren. Sie führt Programmaufrufe aus und aktualisiert den Zustand entsprechend.
Wenn eine Transaktion bei einem Validator eintrifft, durchläuft sie eine mehrstufige Pipeline: Validierung, Laden von Accounts, Programmausführung in einer isolierten sBPF VM, Prüfung von Invarianten und Festschreibung des Zustands. Wenn alle Instruktionen erfolgreich sind, werden Account-Änderungen in die AccountsDB geschrieben. Schlägt eine Instruktion fehl, wird die gesamte Transaktion atomar zurückgesetzt.
Die SVM arbeitet als entkoppelte Ausführungs-Engine. Sie verwaltet also weder den Konsens noch das Netzwerk oder die Ledger-Historie. Stattdessen konzentriert sie sich ausschließlich darauf, Programme sicher, deterministisch und effizient auszuführen.
Die Bank koordiniert die Ausführung der SVM, stellt den Runtime-Kontext bereit (z. B. Blockhash, Rent und Feature-Set) und schreibt die Ergebnisse in den persistenten Speicher. Die SVM verwaltet die Programmausführung, vom Laden des Bytecodes bis zur Durchsetzung von Compute-Budgets. Durch diese Aufgabentrennung kann die SVM auch außerhalb von Validatoren wiederverwendet werden.
Transaktionen
Transaktionen sind das Lebenselixier von Solana – und jeder anderen Blockchain. Sie rufen Programme auf, um Zustandsänderungen vorzunehmen.
Eine Transaktion ist ein Bündel von Instruktionen. Sie legen fest, welche Aktionen auf welchen Accounts ausgeführt werden sollen und ob die erforderlichen Berechtigungen dafür vorliegen.
Eine Instruktion ist eine Anweisung für einen einzelnen Programmaufruf. Sie ist die kleinste Einheit der Ausführungslogik und damit die grundlegendste operative Einheit auf Solana.
Programme interpretieren die von einer Instruktion übergebenen Daten, um mit den angegebenen Accounts zu arbeiten. Eine Instruktion enthält eine Programm-ID (d. h. das aufgerufene Programm), eine Liste der zu lesenden und zu beschreibenden Accounts sowie die Eingabe, die an das Programm übergeben wird.
Transaktionen beginnen damit, dass ein Nutzer ein Ziel festlegt, beispielsweise 10 SOL an einen anderen Account zu übertragen. Diese Absicht wird in eine Instruktion übersetzt, die das System Program anweist, 10 SOL von Account A an Account B zu übertragen. Account A würde der Transaktion als beschreibbarer Unterzeichner und Account B als beschreibbarer Account übergeben. Die Instruktion würde anschließend in eine Transaktion verpackt, die außerdem den Gebührenzahler, die Unterzeichner und einen aktuellen Blockhash angibt.
In der Regel wird die Transaktion dann an einen RPC-Anbieter wie Helius gesendet. Der empfangende RPC-Node prüft anschließend, ob alle erforderlichen Signaturen vorhanden und gültig sind, ob die Transaktion noch nicht verarbeitet wurde, ob der angegebene aktuelle Blockhash weiterhin gültig ist und ob die Transaktion die maximale Größe (d. h. 1232 Byte) nicht überschreitet.
Anschließend leitet der RPC die Transaktion an die Transaction Processing Unit (TPU) des aktuellen Leaders weiter.
Die Transaction Processing Unit (TPU)
Die Transaction Processing Unit (TPU) ist die Pipeline für die Annahme und Verarbeitung von Transaktionen innerhalb von Solana-Validatoren. Sie besteht aus mehreren Phasen, die Transaktionen empfangen, verifizieren, planen und ausführen, bevor sie in das Ledger von Solana geschrieben werden.
Für unsere Zwecke untersuchen wir die Fetch Stage, die SigVerify Stage und die Banking Stage ausführlich, bevor wir uns mit der Bank und der Bereitstellung der sBPF VM befassen.
Eine detailliertere Untersuchung der TPU findest du unter Stake-Gewichtete Dienstqualität: Alles, was du wissen musst.
Die Fetch Stage
Die Fetch Stage ist die erste Phase der TPU-Pipeline. Sie empfängt alle eingehenden Transaktionen über QUIC-Verbindungen, die UDP-Sockets als zugrunde liegende Transportschicht verwenden, und fasst sie für die nachgelagerte Verarbeitung zu Batches zusammen.
Es werden drei UDP-Sockets erstellt:
- tpu: Reguläre Transaktionen wie Token-Übertragungen, NFT-Mints und Programminteraktionen.
- tpu_vote: Abstimmungstransaktionen von Validatoren – dies ändert sich mit Alpenglow, wenn Abstimmungstransaktionen entfernt werden.
- tpu_forwards: Weitergeleitete, unverarbeitete Transaktionen des vorherigen Leaders, der sie nicht rechtzeitig verarbeiten konnte.
Diese Sockets werden im Gossip-Dienst des Validators registriert und in der Struktur ContactInfo gespeichert. Dadurch können andere Validatoren und RPC-Nodes erkennen, wohin sie Transaktionen senden müssen.
Die Fetch Stage startet einen Thread pro Socket. Alle Threads führen kontinuierlich folgende Schritte aus:
- Den UDP-Socket nach eingehenden Paketen abfragen.
- Einen Batch aus 64 Paketen erstellen.
- Den Batch über den unbegrenzten Channel des Threads senden.
Derzeit werden unbegrenzte Channels verwendet, um Batches an nachgelagerte Phasen weiterzugeben. Die Channels haben also eine unbegrenzte Kapazität. Dadurch kann die Fetch Stage unabhängig von der Verarbeitungsgeschwindigkeit nachgelagerter Phasen arbeiten.
Das verhindert zwar, dass Pakete bei Lastspitzen sofort verworfen werden, kann aber zu Speicherproblemen führen: Wenn die nachgelagerten Phasen nicht mit der Fetch Stage Schritt halten können, wachsen die Channels unbegrenzt. Das kann Verlangsamungen oder Abstürze wegen unzureichenden Speichers (OOM) verursachen.
Derzeit wird an begrenzten Channels mit korrektem Gegendruck gearbeitet. Damit kann das System Überlastung signalisieren und ein unbegrenztes Speicherwachstum verhindern.
Die Fetch Stage erstellt außerdem einen weiteren Thread, der ausschließlich weitergeleitete Pakete verarbeitet. Diese Pakete werden mit einem FORWARDED-Flag markiert und abhängig vom Leader-Zeitplan entweder behalten oder verworfen:
- Berücksichtigt: Wenn der aktuelle Validator bald Leader wird, werden weitergeleitete Pakete über den regulären TPU-Channel verarbeitet und an die nächste Phase gesendet.
- Verworfen: Wenn der aktuelle Validator in naher Zukunft nicht Leader wird, werden weitergeleitete Pakete verworfen, um unnötige Verarbeitung zu vermeiden.
Die Fetch Stage verwendet einen PacketBatchRecycler, um 1.000 Paket-Batches mit jeweils 1.024 Paketen vorab zuzuweisen. Das reduziert den Aufwand für die Speicherzuweisung, da sich der Speicher für Paket-Batches wiederverwenden lässt, statt jedes Mal neue Batches zuzuweisen. Ursprünglich war der Recycler für das Pinnen von CUDA-Speicher erforderlich. Diese Funktion ist inzwischen nicht mehr aktiv und kann weitgehend als technische Altlast betrachtet werden.
Die SigVerify Stage
Die SigVerify Stage ist die zweite Phase der TPU-Pipeline und, wie der Name schon sagt, für die Verifizierung von Signaturen zuständig. Dies geschieht früh in der Pipeline, weil die Signaturverifizierung für Ed25519 rechenintensiv ist, wenn auch nicht so aufwendig wie die Ausführung von Transaktionen. Indem Validatoren Transaktionen vor der Ausführung verifizieren, können sie betrügerische Transaktionen ablehnen, Denial-of-Service-Angriffe verhindern und sicherstellen, dass nur korrekt formatierte Transaktionen die Banking Stage erreichen.
Die SigVerify Stage läuft als einzelner Thread, der kontinuierlich Pakete aus den Channels der Fetch Stage empfängt und sie durch eine Verifizierungspipeline verarbeitet. Trotz des einzelnen Threads besteht intern ein hohes Maß an Parallelität.
Standardmäßig werden Signaturen auf der CPU mit einem parallelen Iterator verifiziert. Dadurch wird die Verifizierung auf alle verfügbaren CPU-Kerne verteilt, sodass jeder Kern unabhängig einen Teil der Signaturen verifizieren kann.
Die Signaturverifizierung kann außerdem auf die GPU ausgelagert werden, wenn über perf_libs::api() Performance-Bibliotheken erkannt werden. Die GPU kann nur verwendet werden, wenn mindestens 64 Pakete vorliegen und voraussichtlich 90 % davon gültig sind. Der Grund: Einrichtung und Übertragung verursachen bei der GPU einen Overhead von ~15–20 ms, während die CPU 64 Signaturen in ~10–20 ms verifizieren kann. In der Praxis hat sich diese Funktion aufgrund des Latenz-Overheads als ungeeignet erwiesen. Bei realistischen Workloads ist sie deutlich langsamer als die CPU-Verifizierung. Daher soll dieser ungenutzte Codepfad entfernt werden.
Der Verifizierungsprozess ist relativ unkompliziert:
- Paket-Batches aus den unbegrenzten Channels der Fetch Stage empfangen.
- Transaktionen per Lastabwurf zufällig verwerfen, wenn das Paketvolumen 165.000 überschreitet.
- Doppelte Transaktionen entfernen.
- Überschüssige Pakete verwerfen, damit keine einzelne IP die Verifizierungsbandbreite monopolisieren kann.
- Batches vorab verkleinern und neu organisieren, um die Cache-Lokalität zu verbessern und Speicherverschwendung zu reduzieren.
- Signaturen verifizieren.
Alle gültigen Pakete werden an die Banking Stage weitergeleitet.
Die Banking Stage
In der Banking Stage werden Transaktionen ausgeführt. Hier werden sie gepuffert, geplant und von parallelen Worker-Threads ausgeführt.
Es wird ein zentrales Scheduler-Muster verwendet, das Abstimmungstransaktionen und andere Transaktionen getrennt verarbeitet:
- Ein einzelner Worker-Thread verarbeitet Abstimmungs- und Gossip-Abstimmungstransaktionen.
- Vier Worker-Threads verarbeiten alle Transaktionen, die keine Abstimmungen sind.
- Ein einzelner Thread koordiniert die Arbeitsverteilung zwischen den Worker-Threads.
Eingehende Pakete werden deserialisiert und bis zu einer Grenze von 100.000 Transaktionen gepuffert. Der Scheduler empfängt kontinuierlich neue Transaktionen und verwaltet gleichzeitig diesen Buffer. Bei Bereinigungsvorgängen für die Warteschlange, die jeweils bis zu 10.000 Transaktionen prüfen, verwirft er abgelaufene oder ungültige Transaktionen.
Der Scheduler bestimmt die Ausführungsreihenfolge von Transaktionen durch Konflikterkennung (d. h. Transaktionen, die Lese- und Schreibsperren für dieselben Accounts anfordern). Standardmäßig stehen zwei Scheduler-Implementierungen zur Verfügung:
- PrioGraphScheduler: Ein Scheduler, der einen Prioritätsgraphen erstellt, um Account-Konflikte zu erkennen.
- GreedyScheduler: Der Standard-Scheduler mit einem einfacheren FIFO-Ansatz zur Reihenfolge von Transaktionen.
Anschließend wählt der Scheduler konfliktfreie Transaktionen aus und weist sie Worker-Threads zu. Jeder Worker empfängt einen Batch und beginnt mit der Verarbeitung.
Koordination durch die Bank
Die Bank repräsentiert den Zustand aller Accounts in einem bestimmten Slot. Sie ist die zentrale Datenstruktur, die Account-Daten verwaltet, Runtime-Regeln durchsetzt und bei der Transaktionsausführung als Koordinator zwischen den Workern der Banking Stage und der sBPF VM dient.
Lebenszyklus
Jede Bank durchläuft drei Zustände:
- Aktiv: Eine neu erstellte Bank, die für Transaktionen offen ist. Die Worker der Banking Stage wenden Transaktionen an, bis die Bank entweder ihre Zielanzahl an Ticks erreicht hat oder alle Einträge im Slot verarbeitet wurden.
- Eingefroren: Sobald die Tick-Anzahl erreicht oder alle Einträge verarbeitet wurden, wird die Bank eingefroren. Es können keine weiteren Transaktionen angewendet werden. Zu diesem Zeitpunkt werden die Transaktionsgebühren dem Block-Leader gutgeschrieben, Sysvar-Accounts aktualisiert und der endgültige Bank-Hash berechnet.
- Verwurzelt: Sobald die eingefrorene Bank genügend Stimmen von Validatoren erhalten hat, wird sie verwurzelt. Der Zustand ist damit final und wird Teil des Ledgers der Chain.
Mit Ausnahme der Genesis-Bank verweist jede Bank auf eine übergeordnete Bank. So entsteht eine Baumstruktur, die verschiedene Forks des Ledgers repräsentiert.
Ausführungsablauf
Die Worker der Banking Stage arbeiten auf der aktuellen Arbeitsbank (d. h. der aktiven, nicht eingefrorenen Bank, die für den aktuellen Slot aufgebaut wird). Wenn ein Worker einen Transaktions-Batch vom Scheduler empfängt, koordiniert die Bank die Ausführung:
- Sperren von Accounts: Worker rufen die Funktion prepare_sanitized_batch_with_results() auf, um alle in den Transaktionen referenzierten Accounts zu sperren und gleichzeitige Änderungen zu verhindern.
- Laden von Accounts: Die Bank ruft die Account-Daten aller gesperrten Accounts aus der AccountsDB ab.
- Gebührenabzug: Die Bank zieht die Transaktionsgebühren vor der Ausführung vom Account des Gebührenzahlers ab.
- Validierung: Die Bank prüft, ob der Blockhash aktuell ist, kontrolliert den Zustand des Nonce-Accounts und verifiziert die Account-Inhaberschaft.
- Übergabe an die VM: Die Bank ruft load_and_execute_transactions() auf und übergibt die geladenen Accounts an die sBPF VM.
- Ausführung: Die sBPF VM führt den Programm-Bytecode jeder Instruktion aus.
- Ergebnisse: Die sBPF VM gibt die gesamte Ausführungsausgabe zurück (d. h. LoadAndExecuteTransactionOutput).
- Festschreibung: Die Bank schreibt die aktualisierten Account-Zustände über bank.commit_transactions() zurück in die AccountsDB.
Die Ausführung erreicht nun die eigentliche SVM (d. h. die sBPF VM). Nachdem die Bank load_and_execute_transactions() aufgerufen hat, durchlaufen Transaktionen eine mehrstufige Pipeline. Jede Instruktion wird in einer neuen, isolierten Instanz der sBPF VM verarbeitet, die zur Ausführung des Programm-Bytecodes bereitgestellt wird.
Die Instruktionen einer Transaktion werden sequenziell ausgeführt. Jede Instruktion durchläuft folgende Pipeline:
- Programm finden: Den Programm-Account anhand der Programm-ID der Instruktion suchen.
- Aus dem Cache laden: Prüfen, ob das betreffende Programm bereits JIT-kompiliert im Programm-Cache liegt.
- VM bereitstellen: Eine isolierte Instanz der sBPF VM mit bestimmten Speicherbereichen und einem Compute-Budget erstellen.
- Bytecode ausführen: Den Einstiegspunkt des Programms mit den Eingaben der Instruktion ausführen.
- Invarianten prüfen: Sicherstellen, dass die Ausführung keine Runtime-Regeln verletzt.
- Ergebnisse erfassen: Die Ausführungsergebnisse bündeln.
Laden von Programmen
Bevor ein Programm ausgeführt werden kann, muss es aus seinem Onchain-Account geladen, verifiziert und in nativen Maschinencode kompiliert werden. Dies geschieht über den Programm-Cache und die JIT-Kompilierungspipeline.
Der Programm-Cache ist eine Performance-Optimierung, durch die Programme nicht bei jedem Aufruf erneut geladen und kompiliert werden müssen. Er wird auf der Ebene des Transaktions-Batches verwaltet und speichert ProgramCacheEntry-Objekte mit folgendem Inhalt:
- JIT-kompilierte ausführbare Datei: Nativer Maschinencode für die CPU-Architektur des Validators.
- Bereitstellungsmetadaten: Der Slot, in dem das Programm bereitgestellt und wirksam wurde.
- Runtime-Umgebung: Referenzen auf die Syscall-Registry und die Ausführungskonfiguration.
Wenn eine Transaktion eine Programm-ID referenziert, wird die folgende Suchreihenfolge verwendet:
- Cache des Transaktions-Batches prüfen: Nach einer zwischengespeicherten, JIT-kompilierten Version suchen.
- Globalen Programm-Cache prüfen: Wenn das Programm nicht im Batch-Cache liegt, den globalen Cache des Validators prüfen.
- Aus Account laden: Wenn es in keinem Cache gefunden wird, den Programm-Account aus der AccountsDB laden.
- ELF parsen: Den Bytecode aus den Daten des Programm-Accounts extrahieren.
- Bytecode verifizieren: Den statischen Verifier ausführen, um die Sicherheit zu gewährleisten.
- JIT-Kompilierung: Den sBPF-Bytecode in nativen Maschinencode übersetzen.
- Cache-Eintrag: Das kompilierte Programm für zukünftige Aufrufe speichern.
Das bedeutet: Beim ersten Aufruf eines neu bereitgestellten Programms fallen die vollständigen Kosten für Laden, Verifizieren und Kompilieren an. Nachfolgende Aufrufe führen dagegen direkt den zwischengespeicherten nativen Code aus.
Wird das Programm nicht im Cache gefunden, muss es aus seinem Onchain-Account geladen werden. Bei Programmen, die dem BPF Loader Upgradeable gehören, enthält der Programm-Account eine Referenz auf den ProgramData-Account. Dieser wird aus der AccountsDB geladen. Beim Ein-Account-Modell von Loader V4 wird der Bytecode direkt aus dem Programm-Account geladen. Wenn er zstd-komprimiert ist, muss er dabei möglicherweise dekomprimiert werden.
Die extrahierten ELF-Bytes werden geparst, um den ausführbaren Bytecode und die zuvor genannten Abschnitte zu finden (d. h. .text, .rodata, .data / .bss, .symtab / .strtab). Nachdem das ELF erfolgreich geparst wurde, verifiziert der RequisiteVerifier (d. h. der statische Analyzer von sBPF) wie in den vorherigen Abschnitten beschrieben jeden möglichen Ausführungspfad, ohne das Programm tatsächlich auszuführen.
JIT-Kompilierung
Nach erfolgreicher Verifizierung wird der Bytecode per Just-In-Time-Kompilierung (JIT) in nativen Maschinencode übersetzt. Der JIT-Compiler übersetzt jede sBPF-Instruktion in die entsprechenden nativen CPU-Instruktionen für die Architektur des Validators.
Durch die JIT-Kompilierung ist die sBPF VM leistungsfähig genug für den hohen Durchsatz von Solana. Ohne JIT müsste die VM den sBPF-Bytecode Instruktion für Instruktion interpretieren, was erheblichen Overhead verursachen würde.
Jede Bytecode-Instruktion muss abgerufen, dekodiert und an Handler-Code weitergeleitet werden. Das verursacht Interpretations-Overhead. Außerdem kann der interpretierte Code keine Optimierungen auf CPU-Ebene wie Pipelining, Sprungvorhersage oder Out-of-Order-Ausführung nutzen. Daher wird jede sBPF-Instruktion im Interpreter zu einem Funktionsaufruf.
Die JIT-Kompilierung eliminiert diese Kosten vollständig, indem sie nativen Maschinencode erzeugt, der direkt auf der CPU läuft. Das ermöglicht nahezu native Performance, optimierte Grenzprüfungen, eingebettete Compute-Messung, Optimierungen auf Hardwareebene und eine klare Zuordnung der Register.
Der JIT-Compiler übersetzt sBPF-Bytecode in einem einzigen Durchlauf in nativen Maschinencode. Für jede sBPF-Instruktion geschieht Folgendes:
- Die Instruktion wird dekodiert, um Opcode, Register, Offset und unmittelbaren Wert zu extrahieren.
- Der Stack-Frame wird eingerichtet, und vom Aufgerufenen zu sichernde Register werden gespeichert.
- Die sBPF-Operation wird der entsprechenden CPU-Instruktion zugeordnet, sodass jede Operation in native Instruktionen kompiliert wird. Bei der Zuordnung für x86-64 würde beispielsweise rax R0 und rbp R10 entsprechen.
- Grenzprüfungen und softwarebasierte Adressübersetzung für Speicherzugriffe werden ausgegeben. Die Übersetzung von Gastadressen in Hostadressen ist aufgrund ihres Overheads eine der langsamsten Operationen der VM.
- Die Stack-Isolation wird aufrechterhalten (d. h. der Gast-Stack befindet sich in einem auf dem Heap zugewiesenen Buffer statt im Host-Stack).
- CU-Abzüge und Budgetprüfungen werden eingefügt, um die Compute-Messung zu implementieren.
- Register werden wiederhergestellt, und der Stack wird bereinigt.
Übersetzung von Instruktionen
Der JIT-Compiler übersetzt verschiedene Instruktionstypen (d. h. Arithmetik, Speicherzugriffe, Speicheroperationen, bedingte Sprünge und Syscall-Weiterleitungen) auf jeweils spezifische Weise.
Arithmetische Operationen werden ohne Overhead direkt einzelnen nativen CPU-Instruktionen zugeordnet, da sich die sBPF-Register gut auf die entsprechenden Hardware-Register des Validators abbilden lassen.
Speicherzugriffe erfordern Grenzprüfungen, um Lese- und Schreibvorgänge außerhalb des gültigen Bereichs zu verhindern. Der JIT-Compiler erzeugt Validierungscode, der bei jedem Speicherzugriff die untere und obere Grenze mit den gültigen Bereichsgrenzen abgleicht.
Dafür sind im Wesentlichen 3 bis 6 native Instruktionen erforderlich. Sie berechnen die effektive Adresse, prüfen, ob sie innerhalb der erwarteten Grenzen liegt, und führen anschließend den eigentlichen Ladevorgang aus. Die Sprungvorhersage verarbeitet dies relativ effizient, da Grenzverletzungen selten sind.
Speicheroperationen umfassen Grenzprüfungen und die Validierung von Schreibberechtigungen. Der kompilierte Code prüft vor dem Schreiben in den Speicher, ob die Zieladresse innerhalb der Grenzen liegt und ob für den Speicherbereich Schreibberechtigungen aktiviert sind.
Bedingte Sprünge werden in native bedingte Sprunginstruktionen kompiliert. Der JIT-Compiler löst während der Kompilierung alle Sprungziele auf, sodass die relativen Instruktions-Offsets von sBPF in absolute Adressen im nativen Code umgewandelt werden.
Eine Syscall-Weiterleitung erfordert, dass der Zustand der VM – alle 11 Register – gespeichert und der native Syscall-Handler aufgerufen wird. Anschließend wird der VM-Zustand samt Rückgabewert wiederhergestellt. Aufgrund dieses Verwaltungs-Overheads haben Syscalls feste CU-Kosten, die höher als bei regulären Instruktionen sind.
Messung der Compute Units
Der JIT-Compiler bettet die Nachverfolgung der Compute Units direkt in den erzeugten Code ein. Jede sBPF-Instruktion enthält eine Budgetprüfung, um sicherzustellen, dass das Budget nicht aufgebraucht ist, während die Instruktionen von der verbleibenden CU-Anzahl abgezogen werden.
Durch diese Einbettung entfällt der Overhead von Funktionsaufrufen. Sie lässt sich mithilfe von Sprungvorhersage, Out-of-Order-Ausführung (d. h. CU-Prüfung und Operation können parallel ausgeführt werden) und Parallelität auf Instruktionsebene effizient umsetzen.
Bei Syscalls mit variablen Kosten (z. B. dem Syscall sol_sha256, dessen Kosten mit der Datenlänge skalieren) erfolgt die Kostenberechnung innerhalb der nativen Syscall-Implementierung, bevor die Ausführung zur VM zurückkehrt.
Zwischenspeichern des kompilierten Codes
Nach Abschluss der JIT-Kompilierung wird die native ausführbare Datei in einem ProgramCacheEntry gespeichert, der Folgendes enthält:
- Den JIT-kompilierten Code
- Metadaten zum Bereitstellungs-Slot und zum effektiven Slot
- Referenzen auf die Syscall-Registry
- Die Konfiguration der Runtime-Umgebung
Der Cache-Eintrag wird im Cache des Transaktions-Batches und im globalen Programm-Cache abgelegt. Ersterer steht allen Instruktionen im aktuellen Batch zur Verfügung, letzterer allen zukünftigen Transaktionen.
Der Cache-Eintrag kann ungültig werden, wenn das Programm aktualisiert wird (d. h. neuer Bytecode bereitgestellt wird), der Programm-Account geschlossen wird, Feature-Gates die Verfügbarkeit von Syscalls ändern oder ein Validator seinen Cache leert.
Verzögerung bis zum effektiven Slot
Beachte, dass ein in Slot n bereitgestelltes Programm erst in Slot n + 1 aufgerufen werden kann. Diese Verzögerung stellt sicher, dass alle Validatoren die Bereitstellung erkennen, die Programm-Caches im Netzwerk synchronisiert werden und die Atomarität pro Slot durchgesetzt wird.
Bereitstellung der sBPF VM
Sobald das Programm geladen und JIT-kompiliert oder aus dem Cache abgerufen wurde, wird für jede Instruktionsausführung eine neue sBPF VM bereitgestellt. Diese Bereitstellung erfolgt im BPF Loader. Dabei werden fünf separate Speicherbereiche eingerichtet, das Compute-Budget initialisiert und Syscalls registriert.
Speicherbereiche
Wie im Abschnitt zur SVM ISA erwähnt, erstellt die VM fünf separate Speicherbereiche. Zusammen bilden diese Bereiche die isolierte Sandbox, in der Solana-Programme ausgeführt werden. Der Programmspeicherbereich beginnt normalerweise an der Adresse 0x100000000. Er enthält den auszuführenden JIT-kompilierten nativen Code – also abhängig von der CPU-Architektur des Validators den eigentlichen Maschinencode. Wenn JIT zum Debuggen deaktiviert ist, enthält dieser Bereich stattdessen den interpretierten sBPF-Bytecode. Dieser Abschnitt besitzt ausschließlich Lese- und Ausführungsberechtigungen.
Schreibgeschützte Daten befinden sich ebenfalls in 0x100000000. Sie umfassen Konstanten und statische Strings, die beim Laden des Programms aus dem ELF-Abschnitt .rodata extrahiert wurden. Dieser Speicherbereich ermöglicht einen effizienten Zugriff auf Konstanten zur Kompilierzeit, ohne Heap-Speicher zuweisen zu müssen.
Der Stack beginnt bei 0x200000000 und enthält lokale Variablen, Frames für Funktionsaufrufe und Rücksprungadressen. Hier finden während der Ausführung temporäre Berechnungen statt. Er wächst vom oberen Ende des Bereichs nach unten. Register R10 (d. h. der Frame-Pointer) markiert dabei die aktuelle Frame-Grenze. Dieser Bereich erlaubt Lese- und Schreibzugriffe und ist auf 4 KB pro Aufruf-Frame begrenzt. Wird dieses Limit überschritten, tritt ein StackAccessViolation-Fehler auf, der einen Stack-Überlauf anzeigt. Die geringe Größe des Stacks soll Entwickler dazu bewegen, den Heap oder besser noch Accounts zur Datenspeicherung zu verwenden, statt sich auf Stack-basierten Speicher zu verlassen.
Der Heap beginnt bei 0x300000000 und enthält dynamisch zugewiesenen Speicher für Runtime-Datenstrukturen, die nicht auf den Stack oder in einen Account passen. Seine Größe reicht von standardmäßig 32 KB bis maximal 256 KB. Früher konnten Programme den Heap über den Syscall sol_alloc_free erweitern. Der Syscall sol_alloc_free ist jedoch veraltet und für neue Programmbereitstellungen deaktiviert. Programme müssen die benötigte Heap-Größe bei der Bereitstellung angeben, statt sie dynamisch zu erweitern.
Hinweis: Das Wachstum des Heaps verbraucht Compute Units gemäß der Formel (heap_size / 32KB) * 8,000 CUs, wobei die standardmäßigen Heap-Kosten 8 CUs betragen.
Der Speicherbereich für Eingabedaten beginnt an der Adresse 0x400000000. Er enthält die serialisierten Parameter des Einstiegspunkts, die das Programm beim Aufruf empfängt. Dieser Speicherbereich ist schreibgeschützt, und seine tatsächliche Größe hängt von der Transaktion ab. Die drei serialisierten Komponenten sind der 32 Byte große Pubkey des aufgerufenen Programms, ein Array von Accounts und die Instruktionsdaten.
Durchsetzung des Speicherzugriffs
Die VM prüft bei jeder Instruktion zum Laden oder Speichern von Daten die Grenzen. Vor jedem Speicherzugriff prüft sie, ob die Adresse im gültigen Bereich der Region liegt und ob die Art des Zugriffs (d. h. Lesen oder Schreiben) für diese Region zulässig ist.
Wenn eine der Prüfungen fehlschlägt, wird die Ausführung sofort mit einem AccessViolation-Fehler abgebrochen. Die gesamte Transaktion wird zurückgesetzt, und es werden keine Zustandsänderungen festgeschrieben.
Diese Durchsetzung verursacht nahezu keine Runtime-Kosten, da der JIT-Compiler die Prüfungen in effizienten nativen Code kompiliert, den die CPU direkt ausführen kann. Moderne Sprungvorhersage verarbeitet die Prüfungen effizient, da Verstöße selten sind.
Initialisierung des Compute-Budgets
Jede VM-Instanz wird mit einem Compute-Unit-Budget initialisiert, das den Gesamtaufwand begrenzt, den ein bestimmtes Programm verursachen kann. Dieses begrenzte Ausführungsmodell stellt sicher, dass Programme nicht unbegrenzt laufen können und alle Validatoren Transaktionen innerhalb eines vorhersehbaren Zeitfensters ausführen.
Die aktuellen Budgetparameter lauten wie folgt:
- Standardmäßiges Compute-Unit-Limit pro Anweisung: 200.000 CUs.
- Maximales Compute-Unit-Limit pro Transaktion: 1.400.000 CUs.
- Limit für integrierte Anweisungen: 3.000 CUs.
Die standardmäßige Anzahl an CUs pro Transaktion ist min(1_400_00, (200_000*non_reserve_instructions + 3_000*reserve_instructions)). Das ist im Wesentlichen das Minimum aus dem maximalen Compute-Unit-Limit und den Standardkosten pro Anweisungstyp, basierend auf den bereitgestellten Anweisungen.
Das Compute-Budget verfolgt während der gesamten Ausführung die verbleibenden Einheiten. Bei jeder ausgeführten sBPF-Anweisung werden deren CU-Kosten vom verbleibenden Budget abgezogen. Erreicht das Budget null, bevor das Programm abgeschlossen ist, endet die Ausführung sofort mit InstructionError::ComputationalBudgetExceeded. Die Transaktion schlägt fehl und es werden keine Zustandsänderungen übernommen. Dem Gebührenzahler wird jedoch weiterhin die Transaktionsgebühr berechnet, um den Validator für die Verarbeitung der Transaktion zu entschädigen.
Syscall-Registry
Während die VM bereitgestellt wird, entsteht außerdem eine Zuordnung zwischen dem eindeutigen 32-Bit-Murmur-Hashbezeichner jedes Syscalls und seiner nativen Rust-Implementierung. So werden alle verfügbaren Syscalls registriert.
Wenn ein Programm eine CALL_IMM-Anweisung mit einem Syscall-Hash ausführt, geschieht Folgendes:
- Die VM pausiert den sBPF-Anweisungsstream.
- Der Syscall-Dispatcher findet anhand des Hashes die Implementierung in der Registry.
- Der Syscall prüft, ob der Aufrufer die erforderlichen Berechtigungen besitzt, etwa für einen CPU-Aufruf oder den Besitz eines Accounts.
- Die Rust-Implementierung des Syscalls wird außerhalb der Sandbox mit vollständigem Runtime-Zugriff ausgeführt.
- Die festen Kosten des Syscalls werden vom verbleibenden Compute-Budget abgezogen.
- Das Ergebnis wird in Register R0 abgelegt und die sBPF-Ausführung fortgesetzt.
Programmausführung
Sobald die sBPF-VM bereitgestellt ist, beginnt die Ausführung an der Entrypoint-Funktion des Programms. Bei JIT-kompilierten Programmen springt die VM direkt zum nativen Maschinencode und lässt ihn nativ von der CPU des Validators ausführen. Wie im vorherigen Abschnitt beschrieben, enthält der JIT-kompilierte Code die gesamte erforderliche Instrumentierung: Speichergrenzenprüfungen, Compute-Messung und Kontrollflussvalidierung. Für maximale Leistung ist alles inline eingebettet.
Register R1 enthält einen Zeiger auf den Eingabedatenbereich, in dem sich die drei serialisierten Parameter befinden: der Pubkey des aufgerufenen Programms, ein Array von Accounts und die Anweisungsdaten.
Hinweis: Auf Account-Daten wird über Zeiger zugegriffen, statt sie zu kopieren. Mit Zeigern können Programme Account-Daten direkt an Ort und Stelle lesen und ändern. Das ist für die Leistung entscheidend.
Jeder Funktionsaufruf reserviert einen neuen 4-KB-Stack-Frame. Register R10 wird aktualisiert und zeigt auf den neuen Frame. Der Compute-Verbrauch wird anhand des zuvor beschriebenen Compute-Budgets gemessen.
Programmübergreifende Aufrufe (CPI)
Während der Ausführung können Programme über programmübergreifende Aufrufe (CPIs) andere Programme aufrufen. Sie bilden die Grundlage für die Komponierbarkeit der SVM.
Ein CPI wird über den Syscall sol_invoke_signed gestartet. Dieser kostet 1.000 CUs, zuzüglich weiterer Kosten abhängig von den übergebenen serialisierten Account-Daten. Sowohl Account-Daten als auch die Serialisierung von Anweisungsdaten kosten 250 Bytes pro CU.
Wenn ein Programm einen CPI ausführt, wird ein neuer Ausführungskontext mit eigenem Anweisungs-Stack-Frame erstellt. Zum Zeitpunkt der Veröffentlichung beträgt die maximale Tiefe des Anweisungs-Stacks 5 oder 9 bei aktiviertem SIMD-0268. Ein Programm kann also ein weiteres Programm aufrufen, das wiederum ein anderes aufrufen kann, bis das Tiefenlimit erreicht ist. Jeder verschachtelte Aufruf verwaltet einen eigenen Satz beschreibbarer Accounts und eigener Signaturberechtigungen. Ein CPI kann maximal 16 Signierer haben und 128 AccountInfo-Structs übergeben.
Der Aufrufer serialisiert die ID des Zielprogramms, die Accounts und die Anweisungsdaten und ruft dann den Syscall auf. Die Ausführung des aktuellen Programms wird pausiert. Für das aufgerufene Programm wird eine neue sBPF-VM-Instanz nach demselben zuvor beschriebenen Verfahren bereitgestellt. Anschließend beginnt die Ausführung des aufgerufenen Programms. Es nutzt ein eigenes Compute-Budget, das vom verbleibenden Budget des Aufrufers abgezogen wird. CPI-Aufrufe teilen sich somit das gesamte Compute-Budget der Transaktion.
Programme können über Program Derived Addresses (PDAs) im Namen der Accounts signieren, deren Eigentümer sie sind. Bei einem Aufruf mit sol_invoke_signed stellt der Aufrufer Seeds bereit, die den Besitz der PDA belegen. Die PDA-Ableitung wird geprüft, bevor das aufgerufene Programm die Signaturberechtigung erhält.
Wenn das aufgerufene Programm abgeschlossen ist, geht die Kontrolle an den Aufrufer zurück. Änderungen an Accounts durch das aufgerufene Programm sind für den Aufrufer sichtbar. So kann der Zustand die Aufrufkette durchlaufen. Schlägt ein Programm in der CPI-Kette fehl, wird die gesamte Transaktion abgebrochen und alle Zustandsänderungen werden rückgängig gemacht.
Hinweis: sol_invoke ist eine Hilfsfunktion, die sol_invoke_signed ohne Seeds aufruft.
Prüfung nach der Ausführung
Nachdem die Ausführung eines Programms abgeschlossen ist, ob direkt oder als Teil einer CPI-Kette, erfolgen mehrere Prüfungen. Sie stellen die Konsistenz des Zustands und die Sicherheitsinvarianten sicher.
Die Runtime prüft beispielsweise, ob alle als beschreibbar markierten Accounts tatsächlich dem Programm gehörten oder ordnungsgemäß signiert waren. Programme können Accounts, deren Eigentümer sie nicht sind, nur ändern, wenn diese ausdrücklich als beschreibbar markiert wurden und der Eigentümer die Berechtigung erteilt hat. Das verhindert unbefugte Zustandsänderungen.
Die Runtime prüft außerdem, ob die Gesamtsumme der Lamports über alle Accounts der Transaktion hinweg gleich bleibt, sofern Lamports nicht ausdrücklich über Anweisungen des System Program übertragen wurden. Diese Erhaltungsprüfung verhindert, dass Programme Lamports erzeugen oder vernichten. So bleibt der Gesamtbestand von SOL konstant.
Die Runtime prüft, ob alle Accounts mit ausführbaren Flags, also Programme, unverändert geblieben sind. Die Daten eines Programms können während der normalen Ausführung nicht geändert werden. Ein Upgrade ist nur über den Upgrade-Authority-Mechanismus des BPF Loader möglich.
Ausführungsergebnisse
Nach Abschluss der Prüfung wird das Ausführungsergebnis an den Transaktionsprozessor der Bank zurückgegeben. Das Ergebnis enthält den Erfolgs- oder Fehlerstatus, also einen Wert von null beziehungsweise ungleich null, die Anzahl der verbrauchten Compute Units und alle Änderungen am Account-Zustand.
Bei erfolgreicher Ausführung übernimmt die Bank alle Änderungen an Accounts atomar. Die aktualisierten Account-Daten, Lamport-Guthaben und Metadaten werden in die AccountsDB geschrieben und sind in nachfolgenden Transaktionen sichtbar. Die verbrauchten Compute Units werden für die Berechnung der Transaktionsgebühren und für Netzwerkmetriken protokolliert.
Bei fehlgeschlagenen Transaktionen werden keine Zustandsänderungen übernommen. Auf Solana gibt es keine partiellen Rücksetzungen, da die Transaktion vollständig zurückgesetzt wird. Die Transaktionsgebühr wird jedoch weiterhin vom Account des Gebührenzahlers abgezogen, um den Validator für die geleistete Rechenarbeit zu entschädigen. Der Fehlercode und die verbrauchten Compute Units werden für Debugging und Analysen in den Transaktionsmetadaten aufgezeichnet.
Das Ausführungsergebnis fließt durch den Scheduler der Banking Stage zurück. Dieser aktualisiert seine internen Metriken und fährt mit der nächsten Transaktion fort. Sowohl erfolgreiche als auch fehlgeschlagene Transaktionen werden im Proof-of-History-Stream aufgezeichnet und fließen in den aktuell erstellten Block ein. Fehlgeschlagene Transaktionen werden einbezogen, um Replay-Angriffe zu verhindern und einen vollständigen Transaktionsverlauf zu erhalten.
Wenn der Slot abgeschlossen ist und seine maximale Tick-Anzahl erreicht hat, wechselt die Bank in einen eingefrorenen Zustand. Das Einfrieren ist ein unumkehrbarer Vorgang. Es verhindert, dass neue Transaktionen übernommen werden, und berechnet den Hash der Bank. Eingefroren bedeutet nicht finalisiert: Der Slot könnte sich weiterhin auf einem Fork befinden, der verworfen wird.
Die Bank wird zum Root, wenn der Validator BankForks::set_root() aufruft und sie dadurch als Teil der kanonischen Chain festlegt. Das Rooting löst eine Squash-Operation aus. Sie überführt den Account-Zustand der gerooteten Bank in die AccountsDB, führt den gesamten übergeordneten Zustand zusammen und macht ihn aus Sicht des Validators dauerhaft. Forks ohne Root werden bereinigt und verworfen. Wegen der unterschiedlichen Commitment-Level von Solana sind selbst gerootete Banks aus Sicht des Clusters noch nicht finalisiert.
Ausblick
Die Solana Virtual Machine verfolgt einen grundlegend anderen Ansatz für die Blockchain-Ausführung: eine skalierbare Blockchain für alle, ermöglicht durch parallele Verarbeitung, lokale Gebührenmärkte und eine leistungsfähige, deterministische, von eBPF abgeleitete Runtime.
Um die SVM zu verstehen, musst du die gesamte Ausführungspipeline betrachten: von der Kompilierung des Rust-Quellcodes über LLVM und sBPF bis zur Bereitstellung isolierter VM-Instanzen.
Es gibt keine einzelne „Spezifikation“, die die SVM definiert. Stattdessen entsteht sie aus dem Zusammenspiel von Bank, Scheduler, BPF Loaders, sBPF-VM und SVM ISA.
Die Zukunft sieht vielversprechend aus, während sich die SVM weiterentwickelt.
Die Solana-Toolchain wird vollständig überarbeitet, um die benutzerdefinierte LLVM-Infrastruktur abzulösen, die den Einstieg für Entwickler seit Jahren erschwert.
Beim aktuellen Ansatz müssen Entwickler benutzerdefinierte Toolchains über plattformspezifische Skripte installieren. Die Lösung besteht darin, dieselbe Toolchain wie Aya, die Rust-eBPF-Bibliothek, zu verwenden. Entwickler können mit zwei einfachen Befehlen direkt in eBPF-Bytecode kompilieren:
rustup toolchain install nightly
cargo build --target=bpfel-unknown-none
Keine Skripte. Keine benutzerdefinierten LLVM-Forks. Nur standardmäßige Rust-Tools, die über das vorgelagerte Ziel bpfel-unknown-none direkt in eBPF-Bytecode kompilieren und dabei von unzähligen Jahren Linux-Kernel-Entwicklung und Verbesserungen an der LLVM-Infrastruktur profitieren.
Mit SIMD-0377 soll außerdem die SVM ISA aktualisiert werden. Der Vorschlag sieht vor, Solanas eBPF-Implementierung, also sBPF, an moderne eBPF-Standards anzupassen. Dazu gehören JMP32-Anweisungsvarianten, vorzeichenbehaftete Divisions- und Modulo-Operationen, indirekte Sprünge und dynamische Stack-Frames. Diese Änderungen helfen dabei, die Compute-Kosten von Programmen zu senken, die Kompatibilität mit der vorgelagerten LLVM-Infrastruktur zu verbessern und eine effizientere Codegenerierung zu ermöglichen.
Die SVM ist grundsätzlich ein System, das den Verbrauch über Compute-Budgets misst. SIMD-0370 könnte diese Funktionsweise ändern, indem das Compute-Limit auf Blockebene und möglicherweise auch das Transaktionslimit entfernt werden. Ohne diese Compute-Limits könnten Blockproduzenten den Durchsatz anhand ihrer Hardwarekapazitäten statt künstlicher Grenzen maximieren. Zusammen mit dem Timeout-Mechanismus von Alpenglow würden dann Marktkräfte statt Einschränkungen auf Protokollebene die optimale Blockgröße bestimmen. Das liegt natürlich noch in weiter Ferne, da Anza das CU-Limit zunächst auf über 100 Millionen erhöhen möchte, bevor solche Limits entfallen.
All diesen Änderungen liegt die Haltung von Entwicklern zugrunde, die Grenzen des mit Blockchains Machbaren zu verschieben, ohne Sicherheit, Determinismus oder Dezentralisierung zu opfern.
Die SVM ist nicht nur ein Bytecode-Interpreter. Sie ist eine vollständige Ausführungspipeline, die die Möglichkeiten von Blockchains revolutioniert. Sie ist das Ergebnis architektonischer Entscheidungen, die Durchsatz und geringe Latenz priorisieren. Während Solana reift, wird sich die SVM weiterentwickeln und hochleistungsfähige, kapitaleffiziente Anwendungen unterstützen.
Der Traum von Internet-Kapitalmärkten erfordert eine Infrastruktur, die die Anforderungen globaler Finanzsysteme an Durchsatz, Latenz und Kosten erfüllen kann. Die Solana Virtual Machine ist ein entscheidender Schritt, um diese Vision zu verwirklichen.
Weitere Ressourcen
- Anzas neue SVM API
- So schreibst du Solana-Programme mit SBPF-Assembly
- Rust-Compiler für Einsteiger
- Die Solana-eBPF-Virtual-Machine
- Das Solana-Programmiermodell: Eine Einführung in die Entwicklung auf Solana
- Die Wahrheit über lokale Gebührenmärkte auf Solana
- Ein Blick unter die Haube der Solana-Programmausführung: von Rust-Code zu SBPF-Bytecode
- Was ist die SVM – die Solana Virtual Machine
Ähnliche Artikel
Helius abonnieren
Bleib bei der Solana-Entwicklung auf dem Laufenden und erhalte Updates, wenn wir neue Beiträge veröffentlichen


