Chaque langage de programmation, en son coeur, est un jeu d'instructions. La syntaxe et la sémantique avec lesquelles les développeurs interagissent sont une façade sur un ensemble fini d'opérations que la machine exécute réellement. Cet article documente chaque opcode du bytecode de FLIN -- le jeu d'instructions complet qui alimente la machine virtuelle.
Le jeu d'instructions de FLIN couvre 16 catégories et plus de 100 opcodes, allant de la manipulation de pile de base aux requêtes sémantiques alimentées par l'IA. Chaque opcode est un seul octet, suivi de zéro ou plusieurs octets d'opérandes. Cet encodage compact garde les fichiers de bytecode petits et la distribution d'instructions rapide.
Comprendre le jeu d'opcodes est essentiel pour quiconque veut savoir ce que FLIN fait réellement quand vous écrivez count++ ou save user ou {for todo in todos}. C'est le langage sous le langage.
Encodage des instructions
Avant de lister les opcodes, il est utile de comprendre comment ils sont encodés dans le flux de bytecode. FLIN utilise des instructions de longueur variable avec cinq formats :
Format 0 -- Pas d'opérande (1 octet) :
[opcode]
Exemples : Add, Sub, Mul, Pop, Dup, Return, Halt
Format 1 -- Un opérande u8 (2 octets) :
[opcode] [u8]
Exemples : LoadLocal, StoreLocal (emplacements 0-255)
Format 2 -- Un opérande u16 (3 octets) :
[opcode] [u16 little-endian]
Exemples : LoadConst, LoadGlobal, Jump
Format 3 -- Un opérande u32 (5 octets) :
[opcode] [u32 little-endian]
Exemples : JumpFar, CallNative
Format 4 -- Deux opérandes u8 (3 octets) :
[opcode] [u8] [u8]
Exemples : Call (arité, const_idx)Toutes les valeurs multi-octets sont en little-endian. Les instructions les plus courantes (arithmétique, opérations de pile) sont de Format 0 -- un seul octet sans opérandes. Cela signifie que le bytecode FLIN typique fait en moyenne environ 1,5 octet par instruction, ce qui est remarquablement compact.
Catégorie 1 : flux de contrôle (0x00-0x0F)
Les instructions de flux de contrôle gèrent le chemin d'exécution -- arrêt, saut, appel et retour.
| Octet | Mnémonique | Opérandes | Effet de pile | Description |
|---|---|---|---|---|
| 0x00 | Halt | -- | -- | Arrêter l'exécution, retourner le sommet de pile |
| 0x01 | Nop | -- | -- | Pas d'opération (utilisé pour le remplissage) |
| 0x02 | Jump | u16 addr | -- | Saut inconditionnel à l'adresse |
| 0x03 | JumpIfTrue | u16 addr | cond -> | Dépiler et sauter si vrai |
| 0x04 | JumpIfFalse | u16 addr | cond -> | Dépiler et sauter si faux |
| 0x05 | JumpIfNone | u16 addr | val -> | Dépiler et sauter si None |
| 0x06 | Call | u8 arité | fn, args... -> result | Appeler une fonction avec N args |
| 0x07 | CallNative | u16 idx | args... -> result | Appeler une fonction intégrée |
| 0x08 | Return | -- | result -> | Retourner d'une fonction |
| 0x09 | JumpFar | u32 addr | -- | Saut avec adresse 32 bits |
Halt est toujours la dernière instruction d'un programme. Sans elle, la VM dépasserait la fin du tableau de bytecode. Le compilateur s'assure que chaque chemin de code se termine par Halt ou Return.
JumpIfNone existe spécifiquement pour la gestion des valeurs optionnelles. Dans FLIN, de nombreuses opérations peuvent retourner None (une recherche en base de données qui ne trouve rien, un accès à une liste hors limites), et vérifier None est assez courant pour mériter sa propre instruction plutôt qu'une séquence de deux instructions IsNone + JumpIfTrue.
Catégorie 2 : opérations de pile (0x10-0x1F)
Les instructions de pile manipulent directement la pile d'opérandes.
| Octet | Mnémonique | Opérandes | Effet de pile | Description |
|---|---|---|---|---|
| 0x10 | LoadConst | u16 idx | -> value | Pousser une constante du pool |
| 0x11 | Pop | -- | value -> | Jeter le sommet de pile |
| 0x12 | Dup | -- | v -> v, v | Dupliquer le sommet |
| 0x13 | Dup2 | -- | a, b -> a, b, a, b | Dupliquer les deux du sommet |
| 0x14 | Swap | -- | a, b -> b, a | Échanger les deux du sommet |
| 0x15 | Over | -- | a, b -> a, b, a | Copier le deuxième au sommet |
| 0x16 | LoadNone | -- | -> none | Pousser None |
| 0x17 | LoadTrue | -- | -> true | Pousser true |
| 0x18 | LoadFalse | -- | -> false | Pousser false |
| 0x19 | LoadInt0 | -- | -> 0 | Pousser l'entier 0 |
| 0x1A | LoadInt1 | -- | -> 1 | Pousser l'entier 1 |
| 0x1B | LoadIntN1 | -- | -> -1 | Pousser l'entier -1 |
| 0x1C | LoadSmallInt | i8 val | -> val | Pousser un petit entier (-128..127) |
Les instructions de constantes spécialisées (LoadNone, LoadTrue, LoadFalse, LoadInt0, LoadInt1, LoadIntN1, LoadSmallInt) sont des optimisations. Au lieu du LoadConst de trois octets suivi d'une recherche dans le pool de constantes, ces instructions d'un ou deux octets poussent directement les valeurs couramment utilisées. Le compilateur les émet automatiquement quand il détecte une constante qui correspond.
Catégories 3-14 : variables, arithmétique, entités, vues et plus
Les catégories restantes suivent le même pattern systématique :
- Catégorie 3 (0x20-0x2F) : Variables locales --
LoadLocal,StoreLocal,IncrLocal,DecrLocal - Catégorie 4 (0x30-0x3F) : Variables globales --
LoadGlobal,StoreGlobal,LoadGlobalDirect,StoreGlobalDirect - Catégorie 5 (0x40-0x4F) : Arithmétique --
Add,Sub,Mul,Div,Mod,Neg,Incr,Decr,Pow,IntDiv - Catégories 6-7 (0x50-0x6F) : Comparaison et logique --
Eq,NotEq,Lt,Gt,IsNone,And,Or,Not, opérations bit à bit - Catégorie 8 (0x70-0x7F) : Objets et champs --
CreateObject,GetField,SetField,HasField,CreateEntity - Catégorie 9 (0x80-0x8F) : Listes et maps --
CreateList,GetIndex,SetIndex,ListPush,CreateMap,MapGet,MapSet - Catégorie 10 (0x90-0x9F) : Opérations d'entités --
Save,Delete,QueryAll,QueryFind,QueryWhere,QueryFirst,QueryCount - Catégorie 11 (0xA0-0xAF) : Opérations de vues -- couvertes en détail dans l'article précédent
- Catégories 12-13 (0xB0-0xCF) : Opérations d'intent et temporelles --
Ask,Search,Embed,AtVersion,AtTime,History,LoadNow - Catégorie 14 (0xD0-0xEF) : Fonctions intégrées --
Print,ToString,Len,Replace,Abs,Floor,Ceil,Round,Sqrt,ListMap,ListFilter,ListReduce
Un détail critique concernant Eq : il effectue une comparaison profonde pour les objets. Deux chaînes avec le même contenu mais des valeurs ObjectId différentes sont égales. Cela a nécessité un helper values_equal() dédié dans la VM qui déréférence les objets et compare leurs contenus, plutôt que de comparer leurs adresses sur le tas. Se tromper là-dessus était un bogue que nous avons attrapé dans la Session 026 -- "hello" == "hello" retournait false parce qu'il comparait les valeurs ObjectId.
Add fait double emploi : pour les nombres, il effectue l'addition. Pour les chaînes, il effectue la concaténation. La VM vérifie les types des deux opérandes à l'exécution et distribue en conséquence.
IncrLocal et DecrLocal sont des instructions fusionnées. La façon naïve d'incrémenter une variable locale est LoadLocal 0; LoadInt1; Add; StoreLocal 0 -- quatre instructions, quatre cycles de distribution. IncrLocal 0 fait la même chose en une instruction, deux octets. Dans une boucle serrée, cela économise 75 % de la surcharge par itération.
L'espace d'opcodes
L'espace d'opcodes de FLIN est un seul octet (0x00-0xFF), divisé en 16 plages de 16 opcodes chacune. Actuellement, environ 110 des 256 opcodes possibles sont assignés. Cela laisse de la place pour environ 146 instructions futures sans casser l'encodage sur un seul octet.
La plage 0xF0-0xFF est réservée pour les instructions de débogage et spéciales :
| Octet | Mnémonique | Description |
|---|---|---|
| 0xF0 | Breakpoint | Point d'arrêt du débogueur |
| 0xF1 | SourceLoc | Marqueur d'emplacement source (pour les traces de pile) |
| 0xFE | Extended | Préfixe d'opcode étendu (pour les futurs opcodes à 2 octets) |
| 0xFF | Invalid | Instruction invalide (déclenche toujours une erreur) |
Le préfixe Extended (0xFE) est une conception tournée vers l'avenir. Si FLIN épuise un jour l'espace d'opcodes sur un seul octet, Extended suivi d'un deuxième octet donne accès à 256 opcodes supplémentaires sans changer l'encodage des instructions existantes.
Philosophie de conception
Trois principes ont guidé la conception des opcodes :
Orthogonalité. Chaque opcode fait une chose. Add additionne. LoadLocal charge une locale. CreateElement crée un élément. Il n'y a pas d'instructions qui combinent des opérations non liées (avec l'exception délibérée des instructions fusionnées comme IncrLocal, qui combinent un chargement, une addition et un stockage pour la performance).
Discipline de pile. Chaque opcode documente son effet de pile : combien de valeurs il consomme et combien il produit. Le compilateur utilise ces effets de pile pour vérifier que le bytecode généré est équilibré -- chaque chemin de code laisse la pile à la même hauteur. Un bogue dans le compilateur qui produit du bytecode déséquilibré serait attrapé par cette vérification.
Extension sans rupture. Les plages d'opcodes sont regroupées par catégorie, avec des espaces laissés pour les ajouts futurs. Les opérations d'entités ont de la place pour plus de types de requêtes. Les opérations de vues ont de la place pour plus d'instructions de manipulation DOM. Les plages réservées assurent que le format de bytecode de FLIN peut évoluer sans invalider les fichiers compilés existants.
Ceci est la partie 25 de la série « Comment nous avons construit FLIN », documentant comment un CEO à Abidjan et un CTO IA ont construit un langage de programmation à partir de zéro.
Prochain : [26] Rechargement à chaud de modules en 42 ms -- comment les changements de fichiers vont du disque au navigateur en moins de 50 millisecondes.