Triceraprog
La programmation depuis le Crétacé

VG5000µ, les variables en mémoire ()

Pour terminer cette série sur la gestion de la mémoire par le BASIC sur VG5000µ, j'aborde la manière dont sont stockées les variables en mémoire.

Les variables systèmes

On l'a vu dans l'article sur la cartographie de la mémoire, il y a six variables systèmes intéressantes sur ce sujet :

  • (vartab) est la première adresse d'une variable. C'est ici que sont stockés les noms des variables numériques (avec leur valeur) ou chaînes (avec un pointeur vers leur contenu),
  • (arytab) est la première adresse de stockage du contenu des tableaux dimensionnés par DIM (ou bien les tableaux crées par défaut par le BASIC avec un DIM implicite), avec leur nom, leur taille, et leur contenu (ou pointeurs),
  • (strend) est la première adresse de la zone libre de stockage, tout ce qu'il y a à partir de cette adresse et « au-dessus » jusqu'à la pile (pointée par le registre SP) est la mémoire BASIC « libre » (ce qui n'est pas tout à fait vrai puisque la mémoire pour les chaînes de caractères est séparée).
  • (stktop) est le haut de la pile, l'adresse un octet au-dessus est la première adresse de la zone réservée pour les chaînes de caractères.
  • (fretop) est le pointeur de la zone libre pour les chaînes de caractères. L'adresse juste au-dessus contient le début des données de chaînes de caractères,
  • (memsiz) est l'adresse la plus haute adressable par le BASIC, et aussi le haut de l'espace réservée aux chaînes de caractères (inclus).

Une première constatation est qu'il y a deux mémoires réservées au BASIC, disjointes. La première est celle qui contiendra tous les noms des variables et les contenus numériques. La place restante de cette zone est donnée par la fonction FRE(0) (la valeur du paramètre importe peu, seul son type importe).

La seconde est celle qui contiendra tout le contenu des chaînes de caractères, dans des variables ou dans des tableaux. L'espace restant dans cette zone est donné par la fonction FRE(" ") (la encore, seul le type du paramètre compte). L'espace est fixe, de 50 octets au démarrage de la machine, et déterminé par le premier paramètre de la commande CLEAR. Faire un CLEAR 0 est tout à fait possible, mais alors vous ne pourrez plus stocker de chaîne de caractères.

Création d'une variable

Aparté sur la commande LET

Dans le BASIC tel qu'il a été créé à l'université de Dartmouth, chaque ligne doit contenir une commande et une seule. La définition et l'assignation d'une variable se font avec la commande LET. Cette obligation de commande a un avantage sur l'analyse du programme BASIC par un compilateur ou un interpréteur : s'il n'y a pas de commande, c'est une erreur de syntaxe, et il n'y a pas d'exception.

Le BASIC de Microsoft a relaxé cette obligation en rendant la commande LET optionnelle, et cette exception a été conservée par de nombreux BASIC par la suite. Mais pas partout, sur un ZX81 par exemple, chaque nouvelle ligne demande d'entrer une instruction via la touche du clavier correspondante, et le LET est obligatoire.

Lorsque l'instruction LET est optionnelle, le décodage du BASIC lors de la tokénisation est plus complexe : il faut vérifier que ce qui est trouvé sur la ligne ne correspond à aucune instruction et dans ce cas-ci, faire comme si une instruction LET était présente. C'est du code en plus pris dans la ROM.

Avant tout, que cherche-t-on ?

Lorsque BASIC rencontre une variable, il lui faut toujours en premier lieu vérifier son existence. En effet, la première assignation de valeur à une variable vaut création avec une valeur par défaut.

Et pour savoir si cette variable existe, il faut en connaître le nom. C'est la première partie de la routine qui se trouve en $38da.

getvar:      xor      a,a

             ld       (dimflg),a
             ld       c,(hl)
get_id:      call     a_to_z
             jp       c,stx_err_prt

             xor      a,a
             ld       b,a
             ld       (valtyp),a

             rst      chget
             jr       c,idnum_trail
             call     a_to_z_2
             jr       c,id_end

idnum_trail: ld       b,a
idtrail_skp: rst      chget
             jr       c,idtrail_skp

             call     a_to_z_2
             jr       nc,idtrail_skp

id_end:      sub      a,$24
             jr       nz,num_vrble

             inc      a
             ld       (valtyp),a
             rrca
             add      a,b
             ld       b,a
             rst      chget
num_vrble:   ld       a,(subflg)

En premier lieu, (dimflg) est mis à zéro. Cette variable sert lorsque l'on manipule un tableau. Comme on n'a pas encore cette information, la routine part sur du non tableau.

Note : il se peut que A soit différent de 0, lorsque l'on arrive par l'instruction DIM, qui saute par-dessus le XOR A,A, mais laissons ça de côté, le fonctionnement de DIM est de l'acrobatie en assembleur Z80...

HL pointe, comme souvent lors de décodage d'une ligne, sur l'emplacement de la ligne en cours d'exécution. Si on est arrivé ici, c'est que l'on s'attend à trouver un nom de variable à cet endroit. Le premier caractère est donc lu dans C, puis il est vérifié que ce caractère est une lettre. En effet, chaque identifiant doit commencer par une lettre.

Si ce n'est pas le cas, un saut vers le traitement d'une erreur de syntaxe est fait immédiatement.

Donc le cas contraire, tout comme la routine s'initialise dans un mode non tableau, elle part du principe que la variable est numérique. On place donc 0 dans (valtyp), dans lequel est tenu à jour en tout temps le type de l'expression en cours. 0 dans (valtyp) signifie numérique.

Par RST CHGET, un potentiel second caractère pour le nom de la variable est lu. Cette routine chget renvoie le caractère lu dans A et l'accompagne de quelques informations. Si le flag Carry est à 1, cela signifie que le caractère est un chiffre. La routine en profite pour traiter ce cas en sautant plus loin, un chiffre en deuxième caractère est valide.

Si ce n'était pas un chiffre, on vérifie que c'est une lettre. Note au passage : la première fonction A_TO_Z lit un caractère depuis ce que pointe HL, A_TO_Z_2 fait la même vérification mais depuis le caractère déjà présent dans A.

Si ce second caractère n'est pas une lettre, alors on doit avoir le nom de l'identifiant, on passe à la suite en id_end.

Le LD B,A sauve le second caractère de la variable s'il existe. B avait été initialisé à 0 peut avant.

Entre idtrail_skp et id_end (exclu), une boucle saute tous les caractères qui sont soit des chiffres, soit des lettres. En effet, il est tout à fait valide d'avoir des noms de variables plus long que deux caractères. Même si les caractères surnuméraires sont ignorés.

Arrivé en id_end, on vérifie le dernier caractère lu. Est-ce un $, si non, on saute plus loin en num_vrble, la variable est bien numérique. Dans le cas contraire, 1 est mis dans (valtyp) pour désigner un type chaîne de caractères.

Puis le second caractère de la variable, qui avait été sauvé dans B est augmenté de $80 (A égal à 1 après RCCA vaut $80). C'est comme cela que les variables de types chaînes de caractères sont identifiées : le second octet de leur nom a le bit 7 à 1.

Lorsque l'on arrive dans num_vrble, on a dans C le premier caractère de la variable, dans B le second caractère de la variable, porteur de l'information de type chaîne, et (valtyp) qui contient aussi le type de la variable.

Aparté sur les noms de variables longs

Dans le BASIC original, la simplicité voulu par le langage avait amené les auteurs à ne permette des noms de variables qu'à une seule lettre, éventuellement suivie par un chiffre. Plus tard, les variables de type chaînes ont été ajoutées, et le nombre de caractères étendus.

Dans le monde de la micro-informatique 8 bits, il n'y a pas beaucoup de place en RAM, et cette limite est soit conservée, soit relaxée par l'intermédiaire du système permissif des noms longs, dont seuls les premiers caractères sont significatifs.

Sur VG5000µ, ce sont les deux premiers caractères qui comptent. D'ailleurs, tous les noms de variable en interne ont deux caractères, le second caractère étant éventuellement égal à $00.

Je trouve cette idée de caractères significatifs désastreuse. Si elle part de l'idée que l'on peut utiliser des noms de variables plus expressifs, elle n'en n'offre pas les moyens, car absolument aucun test n'est fait sur ces caractères supplémentaires. D'expérience, il est facile, dans un programme un peu long, de ne plus faire attention au fait que deux noms longs possèdent les deux mêmes premiers caractères.

Et si l'on y fait attention, il faut alors trouver un autre nom pour éviter la collision, et ce nom perd souvent en signification claire, et par la même perd l'intérêt des noms longs.

Pour qui est-ce ?

Il existe quelques contraintes sur les variables, et c'est dans l'aiguillage suivant qu'elles sont traitées.

num_vrble:   ld       a,(subflg)
             dec      a
             jp       z,aryvar

             jp       p,simplevar


             ld       a,(hl)
             sub      a,' ('
             jp       z,subscript

simplevar:   xor      a,a

La variable système (subflg) sert à plusieurs choses. Ici, elle donne une indication sur une contrainte au niveau de la variable attendue. Si (subflg) est à 1, c'est que l'on s'attend à un tableau, le branchement est alors vers la recherche d'une variable tableau.

Si (subflg) est supérieur à 1, alors c'est que les tableaux sont interdits, on saute donc vers simplevar. Les deux cas d'interdictions sont la variable d'index d'un FOR et la variable paramètre d'une fonction DEFFN.

S'il n'y a pas de contrainte sur cette variable, mais que l'on trouve une parenthèse ouvrante à la suite de la variable, alors c'est qu'il y a un index, et l'on va vers cette routine.

Et dans le cas contraire, il s'agit d'une variable simple. Ouf!

Dans le cas simple

Comme cet article va être assez long comme ça, on traitera les tableaux une autre fois. À présent que l'on sait que l'on a affaire à une variable simple (pas un tableau), que l'on a son nom et son type, il est grand temps de vérifier si elle existe !

La première partie de cette recherche consiste à initialiser le domaine de recherche et... de vérifier si par hasard on ne serait pas en train d'évaluer une fonction DEFFN.

simplevar:   xor      a,a
             ld       (subflg),a
             push     hl

             ld       d,b
             ld       e,c

             ld       hl,(prmnam)
             rst      de_compare
             ld       de,prmval
             jp       z,pop_hl_ret

             ld       hl,(arytab)
             ex       de,hl
             ld       hl,(vartab)

Tout d'abord, (subflg) est remis à 0. Le contexte a bien été traité et ne doit plus l'être. Puis le PUSH HL sert à sauver le pointeur vers la ligne tokenisée en court.

Le nom de la variable, présente dans BC est placée dans DE pour utiliser la comparaison entre DE et HL.

HL prend la valeur de la variable système (prmnam) (parameter name), qui contient, s'il y a lieu, le nom de la variable qui sert de paramètre à une fonction DEFFN en train d'être évaluée.

Si la variable que l'on cherche est celle du paramètre d'une fonction que l'on est en train d'évaluer, il faut la traiter spécialement, car le nom de cette variable ne doit pas affecter une variable du même nom hors de la fonction.

Le traitement spécial consiste à, si DE et HL sont égaux, sortir immédiatement de la routine de recherche, en faisant pointer DE sur la variable système prmval. Ce buffer de 4 octets contient la valeur actuelle du paramètre de la fonction, et DE est le pointeur que renvoie la routine de recherche de variable pour identifier la valeur cherchée.

Dans le cas d'une recherche générique, DE est initialisé avec (arytab) et HL avec (vartab), les deux bornes de la mémoire contenant les variables simples.

Cette fois on cherche !

Ça y est, après tous ces préparatifs, on arrive au point de la recherche de la variable dans la mémoire ! Et pour cela, la routine va dérouler une boucle qui va cherche parmi tous les variables existantes une dont le nom correspond.

next_var:    rst      de_compare
             jp       z,no_var_yet

             ld       a,c
             sub      a,(hl)

             inc      hl
             jp       nz,var_diff

             ld       a,b
             sub      a,(hl)
var_diff:    inc      hl
             jp       z,var_found

             inc      hl
             inc      hl
             inc      hl
             inc      hl
             jp       next_var

En début de boucle, une comparaison entre HL et DE est faite. Si les deux sont égaux, c'est qu'on a fini la recherche. En effet, tout au long de la boucle, HL, parti de (vartab), va être incrémenté. DE représente la limite haute, qui ne bouge pas.

Si la recherche est terminée sans avoir trouvé la variable, alors on saute en no_var_yet, il va falloir la créer. En effet, tout accès à une variable en BASIC induit sa création si elle n'existe pas encore.

En se souvenant que BC contient le nom de la variable inversé, le premier caractère, dans C est soustrait de celui pointé par HL. S'ils sont différents, c'est qu'on n'a pas trouvé la variable pour le moment, on saute plus loin.

Si le premier caractère correspond, on fait le même test avec le second caractère. Si ces deux tests passent, alors on a trouvé le nom, on saute en var_found.

Sinon, on saute les quatre octets suivants, qui contiennent la valeur de la variable, et on boucle.

Note : comme le nom de la variable en interne est modifié en fonction de son type, cette recherche montre bien que les variables A et A$ sont deux variables différentes. De même que les fonctions, que l'on n'a fait qu'effleurer. Dans la cas d'une fonction définition par DEF FN A(...), c'est le premier des deux octets qui porte un bit 7 à 1, et qui défini donc une troisième espace de nom.

Le cas où les deux octets aurait un bit 7 à 1 pourrait définir une fonction sur des chaînes de caractères. Mais ce cas n'est pas permis par le BASIC sur VG5000µ.

Création de la variable

De la section précédente, on peut sortir soit en ayant trouvé la variable, soit en ne l'ayant pas trouvé. Si la variable n'est pas trouvé, il faut la créer et l'initialiser puis enchaîner sur la section où la variable est trouvée... si elle a pu être créée bien entendu.

Comme c'est assez long, voyons ça en plusieurs partie. Tout d'abord, la création elle-même, avec un petit plot twist.

no_var_yet:  pop      hl
             ex       (sp),hl

             push     de
             ld       de,from_eval
             rst      de_compare
             pop      de
             jp       z,ret_null

             ex       (sp),hl
             push     hl

             push     bc
             ld       bc,$0006
             ld       hl,(strend)
             push     hl
             add      hl,bc

             pop      bc
             push     hl
             call     mem_move_ckk
             pop      hl

             ld       (strend),hl
             ld       h,b
             ld       l,c
             ld       (arytab),hl

Tout d'abord, on récupère le pointeur sur la ligne évaluée qui était depuis la pile et on l'échange avec la valeur actuellement en haut de la pile. C'est un pas de danse assez classique en Z80 qui permet d'aller récupérer la valeur en deuxième position sur la pile.

Puis on sauve la valeur de DE (qui contient (arytab)) avant d'y mettre une valeur spécifique : l'adresse d'une instruction de retour après un CALL particulier. Cette adresse est le chemin que prend l'appel à la récupération d'une variable lors de l'évaluation d'une expression.

Si on vient de là, alors c'est un cas spécifique, et on saute à la section ret_null qui est un raccourci qui renvoie directement la valeur nulle à l'appelant. Et tout ceci sans créer de variable ! Après tout, pourquoi créer une variable qui a sa valeur par défaut ? Nous verrons ret_null plus loin.

Dans le cas où l'on ne vient par d'une évaluation, alors l'adresse de retour est remise à sa place sur la pile et on y repousse le pointeur sur la ligne exécutée.

Il s'agit maintenant de vérifier s'il y a de la place en mémoire pour créer la variable. Une variable a besoin de 6 octets en mémoire, et c'est donc avec 6 qu'est initialisé BC (après avoir été sauvé sur la pile, car BC contenait une information importante : le nom de la variable).

Ici, il faut suivre... HL est initialisé avec (strend), c'est-à-dire la première adresse libre en mémoire principale et on lui ajoute 6 via BC. Au passage, sur la pile est poussé (strend), qui est récupéré dans BC, puis l'adresse (strend) + 6 est poussée sur la pile.

On résume, par ordre croissant, on a :

  • Dans DE se trouve (arytab)
  • Dans BC se trouve (strend)
  • Dans HL se trouve (strend) + 6

Sur la pile on a (strend) + 6 en première position de POP.

Tout est prêt pour appeler mem_move_chk qui va déplacer la zone comprise entre DE et BC, c'est-à-dire toute la mémoire des tableaux, vers une zone dont l'adresse de fin sera HL. Autrement dit, les tableaux sont poussés de 6 octets pour faire de la place pour la nouvelle variable.

Cette routine de déplacement commence aussi par une vérification que la place nécessaire est disponible. Dans le cas contraire, une erreur est levée et le processus est arrêté.

Après le déplacement de la mémoire pour faire de la place, les variables (strend) et (arytab) sont ajustées à leur nouvelles valeur.

clear_mem:   dec      hl
             ld       (hl),$00
             rst      de_compare
             jr       nz,clear_mem

             pop      de
             ld       (hl),e
             inc      hl
             ld       (hl),d
             inc      hl

À présent qu'un emplacement est libre pour la variable, on parcourt son emplacement pour y placer des $00 avec cette boucle.

Puis on récupère le nom de la variable dans DE et on enregistre ce nom dans les deux premiers octets des 6 octets tout neufs.

La variable est à présent créée. Il ne reste plus qu'à retourner un pointeur avec l'emplacement de sa valeur à l'appelant.

var_found:   ex       de,hl
             pop      hl
             ret

DE prend la valeur de HL qui pointe juste après le nom de la variable, donc sur les 4 octets de sa valeur.

Puis HL récupère sa valeur de pointeur d'exécution et la routine se termine.

Retour de variable non définie

On l'a vu juste avant, si, lors de l'évaluation d'une expression, une variable n'est pas trouvée, le BASIC ne va pas créer cette variable et se contentera de renvoyer la valeur nulle pour le type demandé.

Nous pouvons le confirmer avec cette petite expérience :

10 DEFFNPK(P)=PEEK(P+1)*256+PEEK(P)
20 PRINT FNPK(&"49D8"), FNPK(&"49DC")
30 PRINT A
40 PRINT A$
50 PRINT NPK(&"49D8"), NPK(&"49DC")

Donne :

Aucune variable créée

Le nombre à droite est (vartab) et ne bouge pas, puisque le listing ne bouge pas. La valeur de droite est la valeur de (strend) et reste constante entre le premier et le second affichage. Aucune variable n'a été créée par les accès à A et A$.

La différence de 12 octets correspond à la variable créé par la fonction elle-même et à la variable paramètre de cette fonction. En effet, et c'est assez étrange, alors que la recherche de variable dans une fonction est court-circuitée, comme expliqué plus haut, le BASIC créé tout de même une variable vide, qui ne sera pas utilisée.

Du coup, si vous voulez éviter de perdre 6 octets, prenez comme non de paramètre de vos fonction une variable utilisée ailleurs. Elle ne sera pas modifiée.

Changeons un peu l'expérience et cette fois, donnons les valeurs nulles (0 pour un nombre, "" pour une chaîne) spécifiquement aux deux variables (le LET est optionnel, mais je le laisse pour être clair sur la signification de la création de la variable).

Aucune variable créée

La valeur de (vartab) est différente par rapport au premier test car le programme est un peu plus long. Néanmoins, sa valeur ne change pas avant et après la création des variables, ce qui donne une référence. Il y a toujours 12 octets pour les variables créées par la fonction.

Par contre, (strend) augmente de 12 octets avant et après les assignations. Les variables ont bien été créées.

Et cette valeur nulle ?

ret_null:    ld       (fac),a
             ld       hl,null_str
             ld       (faclo),hl
             pop      hl
             ret

Lors du branchement vers ret_null, A avait été mis à 0, c'est donc cette valeur que l'on met dans l'accumulateur flottant, qui contient la valeur de l'expression évaluée. Lorsqu'on lit une chaîne, ce sont dans les deux premiers octets de ce même accumulateur que se trouve un pointeur vers la chaîne évaluée. On y place un pointeur vers une chaîne nulle (null_str contient un $00).

POP HL récupère le pointeur sur la ligne en exécution. Pas besoin de mettre en place DE, car cette branche de la routine n'est appelée qu'en cas d'évaluation d'expression, et comme l'adresse de retour a été enlevée de la pile pour comparaison et n'y a pas été remis, le retour ne se fait pas à l'appelant direct (qui irait chercher la valeur de la variable retournée) mais à son appelant précédent (l'évaluateur).

Est-ce que cette optimisation acrobatique était bien nécessaire ? Je n'ai pas la réponse.

Reset de la mémoire

Une dernière chose avant de clore cet article. Le BASIC du VG5000µ efface toutes les variables lorsqu'un programme est lancé avec RUN. On est assuré que la mémoire est « effacée » (le contenu est toujours là, seul les pointeurs sont réinitialisés). Les valeurs spécifiées par un CLEAR sont par contre conservées.

De même, les variables sont effacées au moindre changement dans le listing.

Un peu de BASIC

Et pour terminer, un petit listing BASIC qui va afficher les variables présentes en mémoire... et donc du programme en cours d'exécution.

10 DEFFNPK(P)=PEEK(P+1)*256+PEEK(P)
20 VT=FNPK(&"49D8")
30 AT=FNPK(&"49DA")
40 PRINT (AT-VT)/6;" VARIABLE(S)"
50 FOR PT=VT TO AT-1 STEP 6
60 PRINT CHR$(PEEK(PT) AND 127);
70 PRINT CHR$(PEEK(PT+1) AND 127)
80 NEXT PT

Affiche

  4 VARIABLE(S)
 PK
 P
 VT
 AT

Et PT ? Comme la variable est créés après la récupération de AT ((arytab)), cette variable n'est pas vue par le programme, ce qui est tout à fait conforme à ce qui était attendu.