Nous écrivons notre langage de programmation, partie 2: représentation intermédiaire des programmes

image

Présentation


Salutations à tous ceux qui sont passés par ici pour lire mon prochain article.

Je le répète, je décris la création d'un langage de langage de programmation basé sur des travaux antérieurs, dont les résultats sont décrits dans cet article .

Dans la première partie (lien: habr.com/post/435202 ), j'ai décrit les étapes de conception et d'écriture d'une machine virtuelle de langue qui exécutera nos futures applications dans notre future langue.
Dans cet article, j'ai l'intention de décrire les principales étapes de la création d'un langage de programmation intermédiaire qui sera assemblé en un bytecode abstrait pour une exécution directe sur notre machine virtuelle.

Je pense que cela ne fera pas de mal de fournir immédiatement des liens vers le site Web du projet et son référentiel.

Le site
Dépôt

Je dois dire tout de suite que tout le code est écrit en FPC et je vais en donner des exemples.

Alors, nous commençons notre illumination.

Pourquoi avons-nous abandonné le langage intermédiaire?


Il vaut la peine de comprendre que la conversion directe d'un programme d'un langage de haut niveau en un bytecode exécutable, qui se compose d'un ensemble limité d'instructions, est si triviale qu'il vaut mieux le simplifier d'un ordre de grandeur en ajoutant un langage intermédiaire au projet. Il vaut bien mieux simplifier le code progressivement que de présenter immédiatement des expressions mathématiques, des structures et des classes avec un ensemble d'opcodes. À propos, c'est la façon dont la plupart des traducteurs et compilateurs tiers travaillent.

Dans mon article précédent, j'ai écrit sur la façon d'implémenter une machine virtuelle de langue. Maintenant, nous devons implémenter un langage de type assembleur et des fonctionnalités pour écrire davantage le traducteur. À ces étapes, nous jetons les bases du futur projet. Il vaut la peine de comprendre que plus la fondation est bonne, plus le bâtiment est pentu.

Nous faisons le premier pas pour réaliser ce miracle


Pour commencer, cela vaut la peine de se fixer un objectif. Qu'écrirons-nous réellement? Quelles caractéristiques le code final devrait-il avoir et que devrait-il faire?

Je peux créer une liste des principales parties fonctionnelles dont cette partie du projet devrait comprendre:

  • Assembleur simple. Convertit des instructions simples en un ensemble d'opcodes pour les machines virtuelles.
  • L'implémentation de base de la fonctionnalité d'implémentation de variables.
  • L'implémentation de base de la fonctionnalité pour travailler avec des constantes.
  • Fonctionnalité de prise en charge des points d'entrée aux méthodes et de calcul de leurs adresses au stade de la traduction.
  • Peut-être quelques petits pains plus fonctionnels.

L'illustration ci-dessus montre un fragment de code dans un langage intermédiaire qui est converti en code pour une machine virtuelle par un traducteur primitif, qui sera discuté.

Donc, les objectifs sont fixés, passons à la mise en œuvre.

Écrire un simple assembleur


Nous nous demandons ce qu'est l'assembleur?

En fait, il s'agit d'un programme qui effectue la substitution des opcodes au lieu de leurs descriptions textuelles.

Considérez ce code:

push 0 push 1 add peek 2 pop 

Après avoir traité le code assembleur, nous obtenons le code exécutable pour la machine virtuelle.

On voit que les instructions peuvent être monosyllabiques et bisyllabiques. Plus d'instructions compliquées pour la machine virtuelle empilée.

Nous avons besoin d'un code qui pourrait extraire des jetons d'une chaîne (nous tenons compte du fait qu'il peut y avoir des chaînes entre elles).

Nous l'écrivons:

 function Tk(s: string; w: word): string; begin Result := ''; while (length(s) > 0) and (w > 0) do begin if s[1] = '"' then begin Delete(s, 1, 1); Result := copy(s, 1, pos('"', s) - 1); Delete(s, 1, pos('"', s)); s := trim(s); end else if Pos(' ', s) > 0 then begin Result := copy(s, 1, pos(' ', s) - 1); Delete(s, 1, pos(' ', s)); s := trim(s); end else begin Result := s; s := ''; end; Dec(w); end; end; 

Ok, maintenant nous devons implémenter quelque chose comme une construction switch-case pour chaque instruction, et notre simple assembleur est prêt.

Variables


Rappelez-vous que notre machine virtuelle possède un tableau de pointeurs pour prendre en charge les variables et, par conséquent, l'adressage statique. Cela signifie que la fonctionnalité pour travailler avec des variables peut être représentée sous la forme d'un TStringList, dans lequel les chaînes sont les noms des variables et leurs indices sont leurs adresses statiques. Il faut comprendre que la duplication de noms de variables dans cette liste est inacceptable. Je pense que vous pouvez imaginer le code nécessaire et / ou même l'écrire vous-même.

Si vous voulez regarder l'implémentation terminée, alors vous êtes les bienvenus: /lang/u_variables.pas

Constantes


Le principe ici est le même que pour les variables, mais il y a une chose. Afin d'optimiser, il vaut mieux ne pas se lier aux noms des constantes, mais à leurs valeurs. C'est-à-dire chaque valeur constante peut avoir une TStringList, qui servira à stocker les noms des constantes avec cette valeur.
Pour les constantes, vous devez spécifier le type de données et, en conséquence, pour les ajouter à la langue, vous devrez écrire un petit analyseur.

Implémentation: /lang/u_consts.pas

Points d'entrée de méthode


Pour implémenter le blocage de code, la prise en charge de différentes conceptions, etc. la prise en charge de cette fonctionnalité doit être implémentée au niveau de l'assembleur.

Prenons un exemple de code:

 Summ: peek 0 pop peek 1 pop push 0 new peek 2 mov push 2 push 0 add jr 

Ce qui précède est un exemple de traduction de la méthode Summ:

 func Summ(a, b): return a + b end 

Il faut comprendre qu'il n'y a pas d'opcodes pour les points d'entrée. Qu'est-ce qu'un point d'entrée à la méthode Summ? Ce nombre premier est le décalage du prochain point d'entrée de l'opcode. (le décalage de l'opcode est le numéro de l'opcode par rapport au début du bytecode abstrait exécutable). Nous avons maintenant une tâche - nous devons calculer ce décalage au stade de la compilation et, en option, déclarer la constante Summ comme ce nombre.

Nous écrivons pour cela un certain compteur de poids pour chaque opérateur. Nous avons de simples opérateurs monosyllabiques, par exemple «pop». Ils occupent 1 octet. Il existe des plus complexes, par exemple, «push 123» - ils occupent 5 octets, 1 pour l'opcode et 4 pour le type int non signé.

L'essence du code pour ajouter la prise en charge de l'assembleur de points d'entrée:

  1. Nous avons un compteur, disons i = 0.
  2. Nous parcourons le code, si nous avons une construction de type «push 123», puis y ajoutons 5, si l'opcode simple est 1. Si nous avons un point d'entrée, supprimez-le du code et déclarez la constante correspondante avec la valeur du compteur et le nom du point d'entrée.

Autres fonctionnalités


Il s'agit, par exemple, d'une simple conversion de code avant le traitement.

Résumé


Nous avons implémenté notre petit assembleur. Nous en aurons besoin pour implémenter un traducteur plus complexe basé sur lui. Nous pouvons maintenant écrire de petits programmes pour notre VM. En conséquence, dans d'autres articles, le processus d'écriture d'un traducteur plus complexe sera décrit.

Merci d'avoir lu jusqu'à la fin si vous l'avez fait.

Si quelque chose ne vous est pas clair, j'attends vos commentaires.

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


All Articles