Nous écrivons notre langage de programmation, partie 1: nous écrivons un langage VM

Présentation


Bonne journée à tous les habrachitateli!

Donc, il vaut peut-être la peine de dire que le but de mon travail, sur la base duquel un certain nombre de statues seront écrites, était de créer moi-même un PJ pleinement fonctionnel à partir de 0, puis de partager mes connaissances, mes meilleures pratiques et mon expérience avec ceux qui sont intéressés.

Je décrirai la création du langage que j'ai décrit plus haut ici .

Il a intéressé de nombreuses personnes et provoqué une discussion animée dans les commentaires. Par conséquent - le sujet est intéressant pour beaucoup.

Je pense que cela vaut la peine de publier immédiatement des informations sur le projet:

Site (sera rempli de documentation un peu plus tard).
Dépôt

Pour toucher le projet vous-même et voir tout en action, il est préférable de télécharger le référentiel et d'exécuter tout depuis le dossier bin. Dans la version, je ne suis pas pressé de télécharger les dernières versions de la langue et du runtime, car parfois c'est trop paresseux pour moi de le faire.

Je peux coder en C / C ++ et Object Pascal. J'ai écrit le projet sur FPC depuis à mon avis, cette langue est beaucoup plus simple et mieux adaptée pour écrire comme ça. Le deuxième facteur déterminant était que FPC prend en charge un grand nombre de plateformes cibles et qu'il est possible de reconstruire un projet pour la plateforme souhaitée avec un minimum de modifications. Si pour une raison quelconque je n'aime pas Object Pascal, alors ne vous précipitez pas pour fermer le message et courez pour jeter des pierres au commentaire. Ce langage est très beau et intuitif, mais je ne fournirai pas autant de code. Exactement ce dont vous avez besoin.

Alors, je vais peut-être commencer mon histoire.

Nous nous fixons des objectifs


Tout d'abord, tout projet a besoin de ses objectifs et de ses savoirs traditionnels, qui devront être mis en œuvre à l'avenir. Il est nécessaire de décider à l'avance quel type de langage sera créé afin d'écrire la VM principale pour celui-ci.

Les points clés qui ont déterminé le développement futur de ma machine virtuelle sont les suivants:

  • Saisie et transtypage dynamiques. J'ai décidé d'organiser son accompagnement au stade de développement de la VM.
  • Prise en charge du multithreading. J'ai inclus cet élément dans cette liste à l'avance afin de bien concevoir l'architecture de la machine virtuelle et d'organiser la prise en charge du multithreading au niveau principal de la machine virtuelle, et pas plus tard avec des béquilles.
  • Exportation de méthodes externes. Sans cela, la langue sera inutile. À moins de l'intégrer dans n'importe quel projet.
  • Compilation du langage (dans un seul fichier exécutable abstrait). Partiellement compilé ou interprété? Cela dépend beaucoup de cela.
  • Architecture générale des VM. Est-ce que la pile ou l'enregistrement sera notre VM? J'ai essayé d'implémenter ceci et cela. J'ai choisi une machine virtuelle empilée pour le support.
  • Comment voyez-vous travailler avec des variables, des tableaux, des structures? Personnellement, à ce moment-là, je voulais implémenter un langage dans lequel presque tout est lié à des pointeurs implicites, car une telle approche économiserait considérablement de la mémoire et simplifierait la vie du développeur. Si nous permettons à quelque chose de grand d'être passé dans les méthodes, seul un pointeur vers ce gros sera automatiquement transféré.

J'ai donc choisi les priorités ci-dessus et j'ai commencé à implémenter la machine virtuelle du langage. C'est étrange bien sûr, généralement tous les analyseurs / traducteurs sont écrits en premier, puis les VM. Eh bien, j'ai commencé à développer le projet dans cet ordre et je vais le décrire plus en détail dans l'ordre dans lequel je l'ai développé.

Je dois dire tout de suite que j'ai nommé VM aussi éloquemment que possible - SVM (Stack-based Virtual Machine).

Commençons par l'implémentation de la classe variable


Au départ, j'ai simplement utilisé un type de variante, car il est plus simple et plus rapide. C'était une béquille, mais cela a soutenu le projet et m'a permis d'implémenter rapidement la première version de VM et du langage. Plus tard, je me suis assis sur le code et j'ai écrit une implémentation de ma «variante». En substance, vous devez écrire une classe qui stocke un pointeur vers une valeur en mémoire, dans mon implémentation, elle est null/cardinal/int64/double/string/array . On pourrait utiliser le typage de casse, mais je me suis dit qu'il serait préférable d'implémenter la façon dont j'ai implémenté.

Avant de commencer à écrire du code de classe, j'ai décidé de mettre immédiatement la directive {$ H +} dans l'en-tête du module pour une prise en charge plus flexible des chaînes dans le futur langage.
P.S. pour ceux qui ne connaissent pas la différence entre les modes H- et H + FPC.

Lors de l'assemblage de code en mode H, les chaînes seront présentées comme un tableau de caractères. Lorsque H + - comme un pointeur vers un morceau de mémoire. Dans le premier cas, les lignes seront initialement de longueur fixe et limitées par défaut à 256 caractères. Dans le second cas, les lignes seront extensibles dynamiquement et beaucoup plus de caractères pourront y être entassés. Ils fonctionneront un peu plus lentement, mais de façon plus fonctionnelle. Avec H +, vous pouvez également déclarer des chaînes comme un tableau de caractères, par exemple de cette manière:

 var s:string[256]; 
Donc, pour commencer, nous déclarerons Enum un type, que nous utiliserons comme un certain indicateur pour déterminer le type de données par pointeur:

 type TSVMType = (svmtNull, svmtWord, svmtInt, svmtReal, svmtStr, svmtArr); 

Ensuite, nous décrivons la structure de base de notre type de variable et quelques méthodes:

  TSVMMem = class m_val: pointer; m_type: TSVMType; constructor Create; destructor Destroy; procedure Clear; end; ... constructor TSVMMem.Create; begin m_val := nil; m_type := svmtNull; end; destructor TSVMMem.Destroy; begin Clear; end; procedure TSVMMem.Clear; inline; begin case m_type of svmtNull: { nop }; svmtWord: Dispose(PCardinal(m_val)); svmtInt: Dispose(PInt64(m_val)); svmtReal: Dispose(PDouble(m_val)); svmtStr: Dispose(PString(m_val)); svmtArr: begin SetLength(PMemArray(m_val)^, 0); Dispose(PMemArray(m_val)); end; else Error(reVarInvalidOp); end; end; 

La classe n'hérite de rien, les appels hérités dans le constructeur et le destructeur peuvent donc être omis. Je ferai attention à la directive inline. Il est préférable d'ajouter {$ inline on} à l'en-tête du fichier, c'est sûr. Son utilisation active dans les machines virtuelles a considérablement augmenté la productivité (Mb quelque part de 15 à 20%!). Elle indique au compilateur que le corps de la méthode est mieux intégré à la place de son invocation. Le code de sortie sera légèrement plus grand à la fin, mais fonctionnera plus rapidement. Dans ce cas, l'utilisation de l'inline est recommandée.

Ok, à ce stade, nous avons lavé les fondations de notre classe. Maintenant, nous devons décrire un certain nombre de setters et getters (setter & getter) dans notre classe.

La tâche consiste à écrire quelques méthodes qui vous permettront d'enchaîner et de récupérer plus tard les valeurs de notre classe.

Tout d'abord, découvrons l'affectation d'une valeur pour notre classe. Vous pouvez d'abord écrire un setter généralisé, puis, pour les types de données individuels:

 procedure TSVMMem.SetV(const value; t:TSVMType); inline; begin if (m_val <> nil) and (m_type = t) then begin case t of svmtWord: PCardinal(m_val)^ := Cardinal(value); svmtInt: PInt64(m_val)^ := Int64(value); svmtReal: PDouble(m_val)^ := Double(value); svmtStr: PString(m_val)^ := String(value); end; end else begin if m_val <> nil then FreeMem(m_val); m_type := t; case t of svmtWord: begin New(PCardinal(m_val)); PCardinal(m_val)^ := Cardinal(value); end; svmtInt: begin New(PInt64(m_val)); PInt64(m_val)^ := Int64(value); end; svmtReal: begin New(PDouble(m_val)); PDouble(m_val)^ := Double(value); end; svmtStr: begin New(PString(m_val)); PString(m_val)^ := String(value); end; else Error(reVarTypeCast); end; end; end; ... procedure TSVMMem.SetW(value:cardinal); inline; begin if (m_val <> nil) and (m_type = svmtWord) then PCardinal(m_val)^ := value else begin if m_val <> nil then FreeMem(m_val); m_type := svmtWord; New(PCardinal(m_val)); PCardinal(m_val)^ := value; end; end; 

Vous pouvez maintenant écrire du code pour quelques getters:

 function TSVMMem.GetW:cardinal; inline; begin Result := 0; case m_type of svmtWord: Result := PCardinal(m_val)^; svmtInt: Result := PInt64(m_val)^; svmtReal: Result := Trunc(PDouble(m_val)^); svmtStr: Result := StrToQWord(PString(m_val)^); else Error(reVarTypeCast); end; end; 

Ok, super, maintenant que vous avez passé un certain temps à regarder l'IDE et à taper avec enthousiasme le code pour les setters et les getters, nous sommes confrontés à la tâche de mettre en œuvre le support de notre type d'opérations mathématiques et logiques. A titre d'exemple, je vais vous donner l'implémentation de l'opération d'ajout:

 procedure TSVMMem.OpAdd(m:TSVMMem); inline; begin case m_type of svmtWord: case m.m_type of svmtWord: SetW(GetW + m.GetW); svmtInt: SetI(GetW + m.GetI); svmtReal: SetD(GetW + m.GetD); svmtStr: SetD(GetW + StrToFloat(m.GetS)); else Error(reVarInvalidOp); end; svmtInt: case m.m_type of svmtWord: SetI(GetI + m.GetW); svmtInt: SetI(GetI + m.GetI); svmtReal: SetD(GetI + m.GetD); svmtStr: SetD(GetI + StrToFloat(m.GetS)); else Error(reVarInvalidOp); end; svmtReal: case m.m_type of svmtWord: SetD(GetD + m.GetW); svmtInt: SetD(GetD + m.GetI); svmtReal: SetD(GetD + m.GetD); svmtStr: SetD(GetD + StrToFloat(m.GetS)); else Error(reVarInvalidOp); end; svmtStr: case m.m_type of svmtWord: SetS(GetS + IntToStr(m.GetW)); svmtInt: SetS(GetS + IntToStr(m.GetI)); svmtReal: SetS(GetS + FloatToStr(m.GetD)); svmtStr: SetS(GetS + m.GetS); else Error(reVarInvalidOp); end; else Error(reVarInvalidOp); end; end; 

Tout est simple. D'autres opérations peuvent être décrites de la même manière, et maintenant notre classe est prête.
Pour les tableaux, bien sûr, vous avez encore besoin de quelques méthodes, un exemple d'obtention d'un élément par index:

 function TSVMMem.ArrGet(index: cardinal; grabber:PGrabber): pointer; inline; begin Result := nil; case m_type of svmtArr: Result := PMemArray(m_val)^[index]; svmtStr: begin Result := TSVMMem.CreateFW(Ord(PString(m_val)^[index])); grabber^.AddTask(Result); end; else Error(reInvalidOp); end; end; 

Super. Nous pouvons maintenant passer à autre chose.

Nous réalisons une pile


Après un certain temps, je suis venu à de telles pensées. La pile doit être à la fois statique (pour la vitesse) et dynamique (pour la flexibilité) en même temps.

Par conséquent, la pile est implémentée en blocs. C'est-à-dire comment cela devrait fonctionner - au départ, le tableau de la pile a une certaine taille (j'ai décidé de définir la taille du bloc à 256 éléments afin qu'il soit beau et pas petit). Par conséquent, un compteur est inclus avec la matrice, indiquant le sommet actuel de la pile. La réallocation de mémoire est une opération extra longue, qui peut être effectuée moins fréquemment. Si plus de valeurs sont poussées sur la pile, sa taille peut toujours être étendue à la taille d'un autre bloc.

J'apporte toute l'implémentation de la pile:

 type TStack = object public items: array of pointer; size, i_pos: cardinal; parent_vm: pointer; procedure init(vm: pointer); procedure push(p: pointer); function peek: pointer; procedure pop; function popv: pointer; procedure swp; procedure drop; end; PStack = ^TStack; procedure TStack.init(vm: pointer); begin SetLength(items, StackBlockSize); i_pos := 0; size := StackBlockSize; parent_vm := vm; end; procedure TStack.push(p: pointer); inline; begin items[i_pos] := p; inc(i_pos); if i_pos >= size then begin size := size + StackBlockSize; SetLength(items, size) end; end; function TStack.peek: pointer; inline; begin Result := items[i_pos - 1]; end; procedure TStack.pop; inline; begin dec(i_pos); if size - i_pos > StackBlockSize then begin size := size - StackBlockSize; SetLength(items, size); end; end; function TStack.popv: pointer; inline; begin dec(i_pos); Result := items[i_pos]; if size - i_pos > StackBlockSize then begin size := size - StackBlockSize; SetLength(items, size); end; end; procedure TStack.swp; inline; var p: pointer; begin p := items[i_pos - 2]; items[i_pos - 2] := items[i_pos - 1]; items[i_pos - 1] := p; end; procedure TStack.drop; inline; begin SetLength(items, StackBlockSize); size := StackBlockSize; i_pos := 0; end; 

Dans les méthodes externes, la machine virtuelle passera un pointeur sur la pile afin de pouvoir y prendre les arguments nécessaires. Un pointeur vers le flux VM a été ajouté plus tard, afin que les appels de rappel à partir de méthodes externes puissent être implémentés et, en général, pour transférer plus de puissance sur les méthodes VM.

Alors, comment vous êtes-vous familiarisé avec la disposition de la pile? La pile de rappel est organisée de la même manière, pour la simplicité et la commodité des opérations d'appel et de retour et de la pile de ramasse-miettes. La seule chose est les autres tailles des blocs.

Parlez de poubelle


C'est généralement beaucoup, beaucoup. Et vous devez en faire quelque chose.

Tout d'abord, je veux parler de la façon dont les récupérateurs sont organisés dans d'autres langages, par exemple, en Lua, Ruby, Java, Perl, PHP, etc. Ils fonctionnent sur le principe du comptage des pointeurs vers les objets en mémoire.

C'est-à-dire donc nous avons alloué de la mémoire pour quelque chose, c'est logique - le pointeur a été immédiatement placé dans une variable / tableau / ailleurs. Le garbage collector d'exécution ajoute immédiatement ce pointeur à lui-même avec une liste des objets garbage possibles. En arrière-plan, le garbage collector surveille en permanence toutes les variables, tableaux, etc. S'il n'y a pas de pointeur vers quelque chose de la liste des ordures possibles, cela signifie que les ordures et la mémoire d'en dessous doivent être supprimées.

J'ai décidé de vendre mon vélo. Je suis plus habitué à travailler avec la mémoire sur le principe de Taras Bulba. Je vous ai donné naissance - je vais vous tuer, je veux dire, quand j'appellerai le prochain Free dans le prochain cours. Par conséquent, le garbage collector de ma machine virtuelle est semi-automatique. C'est-à-dire il doit être appelé en mode manuel et fonctionner en conséquence. À son tour, des pointeurs vers des objets temporaires déclarés sont ajoutés (ce rôle incombe principalement au traducteur et un peu au développeur). Pour libérer de la mémoire sous d'autres objets, vous pouvez utiliser un opcode séparé.

C'est-à-dire le garbage collector au moment de l'appel a une liste de pointeurs que vous devez parcourir et libérer de la mémoire.

Donc, maintenant, nous allons traiter de la compilation dans un fichier exécutable abstrait


L'idée était à l'origine que les applications écrites dans ma langue pouvaient fonctionner sans source, comme c'est le cas avec de nombreuses langues similaires. C'est-à-dire il peut être utilisé à des fins commerciales.

Pour ce faire, vous devez déterminer le format des fichiers exécutables. J'ai obtenu ce qui suit:

  1. En-tête, par exemple "SVMEXE_CNS".
  2. Une section contenant une liste de bibliothèques à partir desquelles les méthodes seront importées.
  3. La section d'importation des méthodes requises, les bibliothèques à partir desquelles les méthodes sont importées sont indiquées par leur numéro dans la section ci-dessus.
  4. Section de constantes.
  5. Section de code

Je ne pense pas qu'il soit utile de présenter les étapes détaillées de la mise en œuvre des analyseurs pour ces sections, car vous pouvez tout voir par vous-même dans mon référentiel.

Exécution de code


Après avoir analysé les sections ci-dessus et initialisé la machine virtuelle, nous avons une section avec le code. Dans ma machine virtuelle, un bytecode non aligné est exécuté, c'est-à-dire les instructions peuvent être de longueur arbitraire.

Un ensemble d'opcodes - instructions pour une machine virtuelle avec de petits commentaires que je montre à l'avance ci-dessous:

 type TComand = ( {** for stack **} bcPH, // [top] = [var] bcPK, // [var] = [top] bcPP, // pop bcSDP, // stkdrop bcSWP, // [top] <-> [top-1] {** jump's **} bcJP, // jump [top] bcJZ, // [top] == 0 ? jp [top-1] bcJN, // [top] <> 0 ? jp [top-1] bcJC, // jp [top] & push callback point as ip+1 bcJR, // jp to last callback point & rem last callback point {** for untyped's **} bcEQ, // [top] == [top-1] ? [top] = 1 : [top] = 0 bcBG, // [top] > [top-1] ? [top] = 1 : [top] = 0 bcBE, // [top] >= [top-1] ? [top] = 1 : [top] = 0 bcNOT, // [top] = ![top] bcAND, // [top] = [top] and [top-1] bcOR, // [top] = [top] or [top-1] bcXOR, // [top] = [top] xor [top-1] bcSHR, // [top] = [top] shr [top-1] bcSHL, // [top] = [top] shl [top-1] bcNEG, // [top] = -[top] bcINC, // [top]++ bcDEC, // [top]-- bcADD, // [top] = [top] + [top-1] bcSUB, // [top] = [top] - [top-1] bcMUL, // [top] = [top] * [top-1] bcDIV, // [top] = [top] / [top-1] bcMOD, // [top] = [top] % [top-1] bcIDIV, // [top] = [top] \ [top-1] bcMV, // [top]^ = [top-1]^ bcMVBP, // [top]^^ = [top-1]^ bcGVBP, // [top]^ = [top-1]^^ bcMVP, // [top]^ = [top-1] {** memory operation's **} bcMS, // memory map size = [top] bcNW, // [top] = @new bcMC, // copy [top] bcMD, // double [top] bcRM, // rem @[top] bcNA, // [top] = @new array[ [top] ] of pointer bcTF, // [top] = typeof( [top] ) bcSF, // [top] = sizeof( [top] ) {** array's **} bcAL, // length( [top] as array ) bcSL, // setlength( [top] as array, {stack} ) bcPA, // push ([top] as array)[top-1] bcSA, // peek [top-2] -> ([top] as array)[top-1] {** memory grabber **} bcGPM, // add pointer to TMem to grabber task-list bcGC, // run grabber {** constant's **} bcPHC, // push copy of const bcPHCP, // push pointer to original const {** external call's **} bcPHEXMP, // push pointer to external method bcINV, // call external method bcINVBP, // call external method by pointer [top] {** for thread's **} bcPHN, // push null bcCTHR, // [top] = thread(method = [top], arg = [top+1]):id bcSTHR, // suspendthread(id = [top]) bcRTHR, // resumethread(id = [top]) bcTTHR, // terminatethread(id = [top]) {** for try..catch..finally block's **} bcTR, // try @block_catch = [top], @block_end = [top+1] bcTRS, // success exit from try/catch block bcTRR, // raise exception, message = [top] {** for string's **} bcSTRD, // strdel bcCHORD, bcORDCH, {** [!] directly memory operations **} bcALLC, //alloc memory bcRALLC, //realloc memory bcDISP, //dispose memory bcGTB, //get byte bcSTB, //set byte bcCBP, //mem copy bcRWBP, //read word bcWWBP, //write word bcRIBP, //read int bcWIBP, //write int bcRFBP, //read float bcWFBP, //write float bcRSBP, //read string bcWSBP, //write string bcTHREXT,//stop code execution bcDBP //debug method call ); 

Ainsi, vous vous êtes familiarisé avec les opérations que la machine virtuelle écrite par moi peut effectuer. Maintenant, je veux parler de la façon dont tout cela fonctionne.

Une machine virtuelle est implémentée en tant qu'objet, vous pouvez donc facilement implémenter la prise en charge du multithreading.

Il a un pointeur vers un tableau avec des opcodes, IP (Instruction Pointer) - décalage de l'instruction exécutée et des pointeurs vers d'autres structures de VM.

L'exécution de code est un gros casse-tête.

Donnez simplement une description de la VM:

 type TSVM = object public ip, end_ip: TInstructionPointer; mainclasspath: string; mem: PMemory; stack: TStack; cbstack: TCallBackStack; bytes: PByteArr; grabber: TGrabber; consts: PConstSection; extern_methods: PImportSection; try_blocks: TTRBlocks; procedure Run; procedure RunThread; procedure LoadByteCodeFromFile(fn: string); procedure LoadByteCodeFromArray(b: TByteArr); end; 

Un peu sur la gestion des exceptions


Pour ce faire, la machine virtuelle possède une pile de gestionnaires d'exceptions et un grand bloc try / catch dans lequel l'exécution de code est encapsulée. À partir de la pile, vous pouvez placer une structure qui a un décalage de point d'entrée sur le bloc de gestion des exceptions catch et finalement / end. J'ai également fourni l'opcode trs, qui est placé avant catch et jette le code pour finalement / end s'il réussit, en supprimant simultanément le bloc avec des informations sur les gestionnaires d'exceptions du haut de la pile correspondante. C'est simple? C'est simple. Est-ce pratique? Idéalement.

Parlons des méthodes externes et des bibliothèques


Je les ai déjà mentionnés auparavant. Importations, bibliothèques ... Sans eux, le langage n'aura pas la flexibilité et les fonctionnalités souhaitées.

Tout d'abord, dans l'implémentation de la VM, nous déclarerons le type de la méthode externe et le protocole pour l'appeler.

 type TExternalFunction = procedure(PStack: pointer); cdecl; PExternalFunction = ^TExternalFunction; 

Lors de l'importation d'une machine virtuelle, l'analyseur de la section d'importation remplit un tableau de pointeurs vers des méthodes externes. Par conséquent, chaque méthode a une adresse statique, qui est calculée au stade de l'assemblage de l'application sous la machine virtuelle et par laquelle la méthode souhaitée peut être appelée.

L'appel se produit plus tard de cette manière lors de l'exécution du code:

 TExternalFunction(self.extern_methods^.GetFunc(TSVMMem(self.stack.popv).GetW))(@self.stack); 

Écrivons une bibliothèque simple pour notre VM


Et laissez-la d'abord mettre en œuvre la méthode Sleep:

 library bf; {$mode objfpc}{$H+} uses SysUtils, svm_api in '..\svm_api.pas'; procedure DSleep(Stack:PStack); cdecl; begin sleep(TSVMMem(Stack^.popv).GetW); end; exports DSleep name 'SLEEP'; end. 

Résumé


Sur ce, je terminerai probablement mon premier article à partir d'un cycle conçu.

Aujourd'hui, j'ai décrit en détail la création du langage d'exécution. Je crois que cet article sera très utile pour les personnes qui décident d'essayer d'écrire leur propre langage ou de comprendre comment fonctionnent des langages de programmation similaires.

Le code VM complet est disponible dans le référentiel, dans la branche / runtime / svm.

Si vous avez aimé cet article, alors ne soyez pas paresseux pour jeter un plus dans le karma et l'augmenter en haut, j'ai essayé et j'essaierai pour vous.

Si quelque chose n'est pas clair pour vous, alors bienvenue dans les commentaires ou sur le forum .

Peut-être que vos questions et vos réponses seront intéressantes non seulement pour vous.

Source: https://habr.com/ru/post/fr435202/


All Articles