NOUVEAU : Helius acquiert Light Protocol
comment créer des programmes Solana avec l’assembleur Solana Berkeley Packet Filter (sBPF)
Blog/Développement

Comment écrire des programmes Solana en assembleur sBPF

Cofondateur et responsable du contenu et de la stratégie chez BlueshiftLeonardo Donatacci sur XLeonardo Donatacci sur LinkedIn
11 min de lecture

Une course parallèle à l’optimisation extrême des programmes se déroule actuellement dans l’écosystème Solana. 

À haut niveau, des bibliothèques comme Pinocchio révolutionnent le développement en Rust et améliorent l’efficacité de calcul de plusieurs ordres de grandeur. Pendant ce temps, au niveau le plus bas, un groupe de développeurs passionnés, unis par leur mépris commun pour le compilateur, va encore plus loin. Au lieu d’écrire des programmes Solana dans des langages compilés comme Rust ou C, ils créent méticuleusement leur bytecode à la main afin d’extraire un maximum de performances de chaque instruction.

Ces gains de bas niveau ne sont possibles que lorsque nous donnons directement des instructions à la VM dans son langage natif : l’assembleur sBPF, la propre variante de Solana de l’extended Berkeley Packet Filter (eBPF), le bytecode utilisé et exécuté dans chaque programme on-chain.

Écrire en assembleur sBPF donne aux développeurs un accès direct à l’interface de plus bas niveau de la machine virtuelle Solana. Le compilateur Rust et LLVM tentent d’effectuer des optimisations, mais la syntaxe du langage manque parfois d’expressivité ou le contexte disponible ne suffit pas pour effectuer de meilleurs choix de compilation. Ils finissent donc souvent par générer un bytecode sous-optimal par rapport à celui d’un développeur expérimenté disposant du contrôle complet, instruction par instruction, qu’offre l’assembleur.

Ce niveau de contrôle supplémentaire se fait au détriment de l’ergonomie, mais les économies en unités de calcul et en taille de binaire, et donc en loyer, sont considérables. Elles deviennent particulièrement importantes pour les opérations très disputées, concurrentielles et critiques en matière de performances.

En parallèle, on peut également avancer que tous les programmes ne devraient pas être écrits en assembleur.

Bien que la situation se soit considérablement améliorée, les outils ont longtemps été limités. Plus important encore, les gains de performances impliquent souvent des compromis majeurs : vérification manuelle de l’exactitude et coûts d’audit accrus. Cela s’explique par le manque d’outils automatisés et par une syntaxe plus difficile à lire, à écrire et à comprendre. 

À l’inverse, on peut aussi considérer les langages compilés comme des boîtes noires qui masquent les choix effectués par le compilateur. La transparence et le contrôle supplémentaires offerts par l’assembleur peuvent donc révéler des éléments difficilement visibles avec les langages compilés. En réalité, la grande majorité des récentes avancées en matière de performances dans nos SDK Rust ont été découvertes et guidées par le constat que nous pouvions créer à la main un bytecode plus efficace que celui du compilateur.

Dans cet article, vous découvrirez :

  • Ce qu’est l’assembleur sBPF et comment il permet de contrôler directement la machine virtuelle
  • L’évolution du Berkeley Packet Filter vers eBPF et les raisons de son adoption par Solana
  • L’architecture de la machine virtuelle sBPF, son jeu d’instructions et son modèle mémoire
  • Comment configurer votre environnement de développement et compiler des programmes sBPF
  • La programmation pas à pas en assembleur à travers un exemple pratique de mémo
  • Les considérations de sécurité essentielles pour écrire du code de bas niveau

Qu’est-ce que l’assembleur ?

L’assembleur est une variante lisible par l’humain du code machine : le langage de programmation de plus bas niveau, qui correspond directement au jeu d’instructions d’un CPU ou d’une VM.

Au lieu de variables et de fonctions, l’assembleur utilise des registres (emplacements de stockage rapides et temporaires dans le CPU), des adresses mémoire (emplacements physiques dans la RAM ou sur le disque) et des opérations fondamentales comme le chargement (lecture depuis la mémoire), le stockage (écriture persistante en mémoire), l’arithmétique et les sauts (flux de contrôle).

Chaque instruction en assembleur correspond individuellement à une instruction équivalente en code machine. 

Cette correspondance directe permet aux programmeurs de contrôler précisément ce que le processeur exécute, notamment les registres qui contiennent les données, la manière dont la mémoire est consultée et l’ordre exact des opérations. 

Contrairement aux langages de haut niveau, dans lesquels une seule fonction peut générer des dizaines d’instructions, l’assembleur offre une transparence et un contrôle complets sur le comportement de la machine, sans abstractions opaques.

Que sont le Berkeley Packet Filter (BPF) et eBPF ?

Le Berkeley Packet Filter (BPF) est apparu en 1992 comme une machine virtuelle destinée à filtrer efficacement les paquets réseau dans les noyaux Unix. Le BPF d’origine utilisait un jeu d’instructions simple et une architecture fondée sur des registres, capables d’exécuter du code en toute sécurité dans un environnement isolé au sein du noyau.

L’extended Berkeley Packet Filter (eBPF) a modernisé ce concept, passant d’un filtre de paquets à une machine virtuelle polyvalente. eBPF a introduit une architecture 64 bits, davantage de registres et des jeux d’instructions plus riches, ce qui permet d’exécuter en toute sécurité des programmes complexes dans l’espace du noyau pour le réseau, la sécurité et la surveillance des systèmes.

Solana a adopté eBPF parce qu’il fournissait un environnement d’exécution éprouvé et sécurisé avec une isolation intégrée. Cette isolation empêche les programmes d’accéder aux ressources système, de faire planter les nœuds ou d’interférer avec d’autres programmes, tandis que l’exécution déterministe garantit que tous les validateurs produisent des résultats identiques. 

De plus, son architecture fondée sur des registres et sa chaîne d’outils éprouvée le rendaient idéal pour une exécution on-chain hautes performances, tandis que le backend LLVM existant permettait aux développeurs de compiler depuis des langages de haut niveau comme Rust.

Architecture de la machine virtuelle sBPF

Lorsqu’un programme Solana s’exécute, le runtime charge le bytecode sBPF en mémoire, effectue une vérification statique pour garantir sa sécurité, notamment en recherchant les boucles infinies, les accès mémoire non valides et l’utilisation correcte des instructions, puis l’exécute dans la machine virtuelle. 

La VM fournit un environnement d’exécution 64 bits contrôlé dans lequel les programmes s’exécutent en étant totalement isolés du système hôte et des autres programmes. Tous les accès aux ressources passent par le runtime.

Architecture du jeu d’instructions sBPF

sBPF utilise onze registres 64 bits (r0-r10), r10 servant de pointeur de trame en lecture seule et r0 de registre de retour. 

Les instructions suivent un format cohérent, avec des codes opération qui définissent les opérations (arithmétique, logique, accès mémoire, sauts) et des opérandes qui indiquent les registres source et de destination, les décalages et/ou les valeurs immédiates. 

Les principales catégories d’instructions comprennent les opérations ALU (addition, soustraction, opérations bit à bit), les opérations mémoire (chargement/stockage) et le flux de contrôle (sauts conditionnels et inconditionnels).

Modèle mémoire de sBPF

Les programmes sBPF fonctionnent dans une organisation structurée de la mémoire : une pile de 4 Ko pour les variables locales et les appels de fonctions, un tas pour les allocations dynamiques, des données de programme en lecture seule contenant le bytecode et les constantes, ainsi que des régions de données de comptes associées aux comptes Solana auxquelles le programme peut accéder pendant son exécution.

Tous les accès mémoire font l’objet d’un contrôle des limites, et les programmes ne peuvent pas accéder à la mémoire située hors des régions qui leur sont attribuées.

Appels système Solana dans sBPF

Les programmes sBPF ne peuvent pas accéder directement aux ressources système ni effectuer d’opérations d’E/S. Ils demandent plutôt des services au moyen d’appels système, des instructions spéciales qui transfèrent le contrôle au runtime Solana. 

Dans l’assembleur sBPF, les appels système sont invoqués à l’aide de l’instruction call et d’un symbole d’appel que le compilateur transforme en cible d’appel au moment de l’assemblage. Actuellement, les appels système sont invoqués par des relocalisations dynamiques textuelles : un système complexe de table de recherche de chaînes qui associe les symboles à un hachage Mumur3 de 32 bits lors de la compilation JIT. Il existe toutefois une proposition active visant à les remplacer par des appels système statiques, ce qui simplifierait radicalement les conventions d’appel. Lorsqu’un appel système est invoqué, les arguments sont transmis par les registres 1 à 5, le registre 5 servant parfois de débordement de pile, et les valeurs de retour sont réécrites dans r0.

Les appels système courants comprennent les opérations mémoire (sol_memcpy, sol_memcmp), les fonctions cryptographiques (hachage, vérification de signature), la journalisation et les invocations inter-programmes.

Tutoriel sur l’assembleur sBPF 

Configurez votre environnement 

Écrire en assembleur sBPF nécessitait traditionnellement toute la chaîne d’outils Solana : un processus lourd, complexe et dépendant de la plateforme.

C’est pourquoi Dean Little a créé le SDK sBPF, qui fournit une solution complète de bout en bout pour initialiser, construire, compiler, tester et déployer des programmes sBPF.

Vous pouvez installer le SDK sur n’importe quel système d’exploitation à l’aide de Cargo :

Code
cargo install --git https://github.com/blueshift-gg/sbpf.git

Avant de vous plonger dans le code, il est également recommandé d’installer l’extension VS Code pour l’assembleur sBPF, qui fournit la coloration syntaxique, l’autocomplétion et la détection des erreurs.

Configurez votre projet 

Créez la structure d’un nouveau projet avec :

Code
sbpf init <name_of_the_project>

Cela crée un projet avec des tests Rust utilisant Mollusk.

Si vous préférez créer une structure avec des tests TypeScript, vous pouvez utiliser cette commande pour initialiser une nouvelle structure avec des tests TypeScript :

Code
sbpf init <name_of_the_project> --ts-tests 

Exemple de mémo en assembleur sBPF

Il est difficile d’imaginer un programme plus simple qu’un mémo. C’est précisément pour cette raison qu’il constitue une introduction idéale à l’assembleur sBPF. 

Le programme ne fait qu’une chose : il reçoit les données d’instruction que vous lui envoyez et les journalise sur la blockchain. Aucun compte, aucune logique complexe, seulement un contrôle direct, instruction par instruction, de la machine virtuelle Solana.

Commençons par examiner le programme complet :

Code
.equ NUM_ACCOUNTS, 0x00
.equ DATA_LEN, 0x08
.equ DATA, 0x10
.globl entrypoint
entrypoint:
  ldxdw r0, [r1+NUM_ACCOUNTS]
  ldxdw r2, [r1+DATA_LEN]
  add64 r1, DATA
  call sol_log_
  exit

Définissez vos constantes

Le programme commence par définir trois constantes qui représentent la structure de la région d’entrée sérialisée du runtime Solana :

Code
.equ NUM_ACCOUNTS, 0x00 // Account count offset
.equ DATA_LEN, 0x08     // Data length offset  
.equ DATA, 0x10          // Data start offset

Ces décalages correspondent aux emplacements où le runtime Solana place les données d’instruction en mémoire.

Lorsque la VM appelle notre programme, elle lui transmet un tampon structuré dans le registre r1, et ces constantes nous permettent de parcourir cette structure. 

Des outils comme sbpf.xyz peuvent calculer automatiquement ces décalages en fonction de la disposition des données de vos comptes et de vos instructions.

Créez le point d’entrée

Nous créons ensuite le point d’entrée et la validation de notre programme.

Code
.globl entrypoint
entrypoint:
  ldxdw r0, [r1+NUM_ACCOUNTS]

La directive de point d’entrée .globl indique à l’éditeur de liens de rendre le symbole du point d’entrée globalement visible. Le runtime Solana recherche ce symbole pour savoir où commencer l’exécution de votre programme. Anecdote : bien que nous l’appelions généralement entrypoint, il peut en réalité porter n’importe quel nom !

La première instruction utilise une technique de validation ingénieuse propre à l’assembleur : puisque r0 est notre registre de retour et que toute valeur de retour autre que 0 sert de code d’erreur, charger directement le nombre de comptes dans r0 force le programme à se terminer avec un code d’erreur non nul si plus de 0 compte lui est transmis. 

Cela nous évite de parcourir manuellement les comptes fournis pour valider le décalage de nos données d’instruction dans la VM.

Invoquez l’appel système sol_log

Nous pouvons enfin effectuer l’appel système sol_log_ :

Code
ldxdw r2, [r1+DATA_LEN]   ; Load memo length
add64  r1, DATA           ; Point r1 to memo data
call sol_log_             ; Log the memo
exit                      ; Exit with r0 value

L’appel système sol_log_ attend la longueur du message dans r2 et un pointeur vers le message à journaliser dans r1. Heureusement, dans la région d’entrée sérialisée, le runtime ajoute automatiquement aux données d’instruction un compteur de longueur 64 bits. Nous pouvons donc invoquer l’appel système sol_log_ simplement en :

  • Chargeant la valeur située au décalage DATA_LEN dans r2
  • Faisant pointer r1 vers le décalage du début de nos données d’instruction

Une fois que nos deux registres pointent vers les bonnes valeurs, il suffit d’invoquer l’appel système, puis de quitter avec la valeur présente dans r0.

Compilez et déployez votre programme

Vous pouvez compiler votre programme à l’aide de l’assembleur intégré de SBPF, écrit par Claire Fan. Ce binaire Rust de 5 Mo remplace la chaîne d’outils LLVM de plus de 2 Go que vous devriez utiliser avec les outils de la plateforme Solana. 

Pour lancer une compilation, exécutez simplement :

Code
sbpf build

Après avoir compilé votre programme, vous pouvez le déployer avec :

Code
sbpf deploy

Interagissez avec votre programme

Pour tester votre programme à l’aide des tests générés, vous pouvez exécuter sbpf test. Vous pouvez aussi exécuter le pipeline complet avec sbpf e2e afin de compiler, déployer et tester en une seule commande.

Considérations de sécurité pour l’assembleur sBPF

Écrire du code assembleur signifie assumer l’entière responsabilité de la sécurité : aucun compilateur ne détectera vos erreurs. Chaque instruction affecte directement la sécurité du programme, ce qui rend ces principes fondamentaux indispensables.

Validation des entrées

Les programmes en assembleur doivent valider manuellement toutes les entrées. Vérifiez toujours le nombre de comptes, la longueur des données et la taille des tampons avant de les utiliser.

Par exemple, dans notre programme de mémo, le chargement du nombre de comptes dans r0 a créé une validation automatique : si des comptes étaient transmis par erreur, le programme échouait. Pour traiter des données, vérifiez que leur longueur se situe dans les plages attendues avant d’accéder à la mémoire.

Vérification des limites de la mémoire

sBPF ne fournit aucune vérification automatique des limites. Avant d’accéder à des tableaux ou à des tampons, vérifiez manuellement que vos opérations de lecture et d’écriture restent dans les limites allouées. Une simple vérification des limites avant un accès mémoire peut éviter les plantages et la corruption des données :

Code
jgt r2, MAX_LENGTH, error    # Check if length exceeds limit
ldxb r3, [r1+r2]            # Safe to load if check passes

Gestion des registres

Les registres contiennent des états critiques du programme. Lorsque vous appelez des fonctions ou des appels système, conservez les valeurs importantes en les enregistrant dans la pile ou dans d’autres registres. 

Le pointeur de trame (r10) et les valeurs de retour dans r0 exigent une attention particulière : leur corruption peut faire planter votre programme ou créer des vulnérabilités de sécurité.

Sécurité arithmétique

La détection manuelle des dépassements est essentielle pour les opérations arithmétiques. Avant d’additionner de grandes valeurs, vérifiez si le résultat risque de dépasser les limites de 64 bits. 

Les opérations de division nécessitent une vérification explicite contre zéro afin d’éviter les erreurs d’exécution.

Validation des paramètres des appels système

Les appels système attendent des paramètres valides et échouent si les entrées ne le sont pas. Avant d’appeler des appels système, assurez-vous que les registres contiennent des pointeurs, des longueurs et des valeurs corrects. Les paramètres non valides provoquent non seulement des échecs, mais consomment également des unités de calcul inutilement.

Conclusion

L’assembleur sBPF ne convient pas à tout le monde, et c’est précisément le but. 

La plupart des développeurs devraient s’en tenir à Rust et laisser le compilateur gérer les optimisations. Mais pour ceux qui cherchent à optimiser jusqu’à la dernière unité de calcul ou qui développent une infrastructure critique en matière de performances, l’assembleur offre une chose qu’aucun langage de haut niveau ne peut fournir : un contrôle complet.

Nous avons abordé ici les principes de base, depuis la place de sBPF dans l’architecture de Solana jusqu’à la création de votre premier programme de mémo. 

L’exemple du mémo peut sembler trivial, mais il illustre les principes fondamentaux que vous utiliserez dans des programmes plus complexes :

  • Manipulation directe des registres
  • Gestion manuelle de la mémoire
  • Gestion explicite des appels système

Ces gains ont un coût : vous échangez des garde-fous contre de la vitesse et des abstractions contre du contrôle. Vous devriez choisir l’assembleur lorsque vous avez déjà optimisé votre code Rust et avez encore besoin de meilleures performances, lorsque vous développez une infrastructure où chaque microseconde compte ou lorsque vous devez faire quelque chose que le compilateur ne peut tout simplement pas optimiser correctement. 

Dans tous les autres cas, épargnez-vous des complications et restez avec Rust.

Les outils s’améliorent, la communauté grandit et les gains de performances parlent d’eux-mêmes. Mais souvenez-vous : « un grand pouvoir implique une grande responsabilité, celle de ne rien casser ».

Si vous souhaitez approfondir l’utilisation de l’assembleur sBPF, consultez le cours Introduction à l’assembleur sur Blueshift et testez vos compétences avec certains des défis qui y sont proposés !

Abonnez-vous à Helius

Suivez les dernières actualités du développement sur Solana et recevez une notification à chaque publication