Triceraprog
La programmation depuis le Crétacé

VG5000µ, les chaînes de caractères ()

Dans l'article précédent, on avait vu la création d'une variable dans la zone principale de la mémoire. Cette variable a par défaut un contenu nul, et ne s'occupe pas de savoir si ce contenu est un nombre ou une chaîne de caractères. Les quatre octets de contenus qui suivent les deux octets du nom sont donc tous les quatre à $00.

Pour qu'une valeur soit associée à une variable, il faut une instruction d'assignation, directement via LET (éventuellement de manière implicite), plus indirectement avec une instruction FOR, ou encore plus indirectement par un couple READ/DATA.

Dans tous les cas, la valeur à assigner à la variable est le résultat de l'évaluation d'une expression, c'est-à-dire le résultat d'un calcul numérique ou d'une opération à partir de chaînes.

Afin de comprendre comment sont créées et stockées les chaînes de caractères, c'est donc du côté de l'évaluation d'expression qu'il faut commencer.

Évaluation d'expression

L'évaluation d'une expression commence en $2861 et nous n'allons pas nous y attarder. Nous suivons la piste immédiatement vers la routine de lecture d'une valeur depuis le buffer d'entrée. Cette routine se situe en $28d8 et commence comme suit :

parse_value: xor      a,a
             ld       (valtyp),a

             rst      chget

             jp       z,missing_op
             jp       c,str_to_num

             cp       a,'&'
             jp       z,str_hex_dec

             call     a_to_z_2
             jr       nc,str_to_var

             cp       a,'+'
             jr       z,parse_value

             cp       a,'.'
             jp       z,str_to_num

             cp       a,'-'
             jr       z,str_to_min

             cp       a,'"'
             jp       z,str_to_str

             cp       a,$b7 ; 'NOT'
             jp       z,str_to_not

             cp       a,$b4 ; 'FN'
             jp       z,str_to_fn

             sub      a,$c3 ; 'SGN'
             jr       nc,str_to_func

Voici toute une série de tests pour déterminer ce que contient l'opérande pointée actuellement par HL.

On remarque au tout début que la valeur par défaut de l'expression en cours est mis à 0 (c'est-à-dire : valeur numérique).

Puis le premier caractère est lu et la suite de tests ressemble à ceci :

  • Est-ce qu'on est à la fin de la ligne ? Alors il manque quelque chose...
  • Est-ce que c'est un chiffre ? Alors on commence à convertir l'entrée en nombre
  • Est-ce que ça commence par & ? Alors on commence à décoder un nombre hexa
  • Est-ce que c'est une lettre ? Alors on va lire une variable
  • Est-ce que c'est un '+' ? On l'ignore et on boucle un caractère plus loin
  • Est-ce que c'est un '.' ? Alors on commence à convertir l'entrée en nombre
  • Est-ce que c'est un '-' ? Alors on démarre une sous-expression qui sera inversée
  • Est-ce que c'est un '"' ? Alors on décode une chaîne !
  • Etc... (les trois derniers cas sont pour NOT, une fonction utilisateur, ou une fonction prédéfinie, puis on continue avec le traitement des parenthèses)

D'après cette liste, on part donc vers str_to_str.

Les chaînes à la chaîne

Arrivée dans str_to_str, on a HLqui pointe vers une chaîne qui commence avec des guillemets. La première étape va être de chercher la fin de la chaîne et de compter le nombre de caractères.

str_to_str:  ld       b,'"'

             ld       d,b
direct_str:  push     hl

             ld       c,$ff
loop_str:    inc      hl
             ld       a,(hl)
             inc      c

             or       a,a
             jr       z,create_str
             cp       a,d
             jr       z,create_str
             cp       a,b
             jr       nz,loop_str

create_str:  cp       a,'"'
             call     z,skipch

             ex       (sp),hl
             inc      hl
             ex       de,hl

             ld       a,c

Cette routine commence par placer le caractère guillemets dans les registres B et D. Les caractères présents dans B et D sont des terminateurs potentiels. Cette routine est en effet appelée par un autre chemin directement en direct_str avec d'autres terminateurs possibles.

Note : ces autres terminateurs possibles sont : et ',' dans le cas où la chaîne est lue par une instruction READ depuis une séquence de DATA.

La suite du préambule de la routine se fait en poussant sur la pile le pointeur sur la ligne en exécution et en initialisant C avec -1. C est le compteur de caractères. Au passage, on peut en déduire que les chaînes de caractères auront donc comme longueur maximale 255.

Puis débute la boucle loop_str, qui commence par avancer le pointeur HL sur le caractère suivant, récupère la valeur de ce caractère dans A et incrémente le nombre de caractères.

Le premier test vérifie si A est nul. Si c'est le cas, on a atteint la fin de la chaîne et il est temps de la créer. De même que si le caractère est égal à l'un des deux terminateurs. Dans le cas contraire, la boucle est bouclée et le caractère suivant traité.

Note : mais et s'il y a plus de 255 caractères avant de trouver un terminateur ? Ça ne se passe pas très bien... Il n'y a pas de tests et vous pouvez vérifier (c'est un peu long) qu'il peut se passer des choses étranges.

Avant de créer la chaîne, il faut mettre les choses en place. Si le dernier caractère sont des guillemets, une routine va les consommer et ignorer tout ce qui est inintéressant, pour recaler HL sur la prochaine valeur ou instruction.

Ce pointeur est échangé avec le haut de la pile, qui contenait le début de la chaîne. Ce début de chaîne est avancé de 1 pour ignorer les premier guillemets (le chemin READ s'arrange pour mettre HL au bon endroit en sachant qu'il sera incrémenté ici).

Puis le pointeur de début de chaîne est transféré dans DE et le nombre de caractères lus dans A.

Création de la chaîne temporaire

À présent que l'on sait où est la chaîne (pointée par DE) et combien de caractères elle contient, l'étape suivant consiste à l'extraire dans un endroit où l'évaluation de l'expression ou l'assignation pourra la trouver.

             call     crt_tmp_str
cpy_to_pool: ld       de,dsctmp

             ld       hl,(temppt)
             ld       (faclo),hl

             ld       a,$01
             ld       (valtyp),a

             call     cpy_detohl_4
             rst      de_compare

             ld       (temppt),hl
             pop      hl
             ld       a,(hl)
             ret      nz

             ld       de,$001e
             jp       error_out

Tout commence par un appel à crt_str_dsc qui créé un descripteur temporaire de chaîne à l'adresse dsctmp ($499b). Dans ce buffer qui sert aux opérations sur les chaînes, la routine placera en premier octet la taille de la chaîne, puis rien de spécial, puis la valeur de 'DE' sur les deux derniers octets.

Les descripteurs de chaînes font donc 4 octets, dont le deuxième est inutilisé.

Puis, DE prend la valeur de dsctmp, le buffer temporaire qui vient d'être initialisé, et HL la valeur contenue dans la variable système temppt. Ce pointeur est initialisé par le BASIC vers le buffer tempst, qui est un buffer de 120 octets réservé.

Ce pointeur est placé dans l'accumulateur flottant, qui maintient en fait toute valeur courante d'une expression, qu'elle soit numérique (lorsque (valtyp) vaut 0) ou chaîne (lorsque (valtyp) vaut 1).

Et d'ailleurs, (valtyp) passe à 1 pour indiquer la nature du contenu de l'accumulateur flottant.

L'appel suivant est une routine qui copie 4 octets pointés par DE vers ce qui est pointé par HL. Autrement dit, le descripteur de chaîne qui vient d'être créé est copié vers le buffer temporaire pointé par HL.

Comme dsctmp est placé astucieusement après le buffer tempst, si jamais, après copie, HL est égal DE, alors c'est qu'on a atteint la fin de l'espace de travail, une erreur est latente, traitée un peu plus loin. Comme 120 (la taille du buffer) est divisible par 4 (la taille des descripteurs) on est assuré de tomber juste, et que HL ne dépasse jamais DE.

En attendant de traiter l'erreur, il s'agit de mettre les choses en ordre. La variable système (temppt) est mise à jour avec la nouvelle valeur de HL, puis on récupère le pointeur sur la ligne depuis la pile, et le caractère pointé par HL est placé dans A, tout est prêt pour continuer le décodage.

Enfin, on sort de la routine si la dernière comparaison n'était pas nulle (il reste de la place dans le buffer temporaire) ou bien on saute vers une erreur indiquant à l'utilisateur que l'opération sur les chaînes de caractères était trop complexe.

Note : il existe 30 emplacements de descripteurs de chaîne dans le buffer temporaire avant que la routine ne laisse tomber avec un message d'erreur. En sachant qu'une expression comme PRINT "ABC" + "CDE" + "DEF" en consomme 2, ça laisse de la marge...

Association de la variable

Pour l'association de la variable avec sa valeur, voyons le cas de l'instruction LET (qui est de toute façon appelée par FOR et READ).

Passons rapidement sur le début de l'instruction LET qui récupère l'adresse de la variable à gauche du signe égal selon la méthode décrite dans l'article précédent, appel l'évaluation de ce qui est à droite du signe égal, et vérifie que les types sont cohérents des deux côtés (soit numérique, soit chaîne de caractères).

Une fois tout ceci en place, l'association de la chaîne elle-même a lieu :

let_string:  push     hl
             ld       hl,(faclo)
             push     hl
             inc      hl
             inc      hl
             ld       e,(hl)
             inc      hl
             ld       d,(hl)

             ld       hl,(txttab)
             rst      de_compare
             jr       nc,crtstrentry

             ld       hl,(strend)
             rst      de_compare
             pop      de
             jr       nc,pop_string

             ld       hl,dsctmp
             rst      de_compare
             jr       nc,pop_string

             defb     $3e
crtstrentry: pop      de
             call     bc_from_tmp
             ex       de,hl
             call     save_str
pop_string:  call     bc_from_tmp
             pop      hl
             call     cpy_detohl_4
             pop      hl
             ret

En début de routine, HL pointe vers la variable à gauche du signe =, on sauve cette adresse sur la pile pour plus tard.

Puis on récupère dans HL la valeur de la dernière expression évaluée, qui est dans l'accumulateur flottant. On pousse aussi cette valeur sur la pile et on va chercher deux octets plus loin le pointeur vers la chaîne de caractère elle-même, qui est placée dans DE.

À présent, il s'agit de savoir où sont situés ces octets de chaînes. Le premier cas est une comparaison avec (txttab). Si les caractères sont avant, c'est qu'ils sont dans les variables systèmes, et donc dans un endroit volatile, il va donc falloir les copier ailleurs et c'est ce que va faire le saut en crtstrentry.

Note : si vous vous amusez avec les pointeurs de zones mémoire du BASIC pour déplacer le contenu du code, gardez en tête que pour le BASIC, une chaîne située avant le code est volatile.

Le second test vérifie si la chaîne se situe avant (strend). Si c'est le cas, c'est que la chaîne se trouve dans le programme.

Note : en toute rigueur, la comparaison aurait du être faite avec (vartab), car il n'y a pas de contenu de chaînes entre (vartab) et (strend). En regardant d'autres dérivés du BASIC-80, je pense qu'il s'agit d'une adaptation un peu hâtive, car d'autres BASIC-80 semblent placer leurs chaînes différemment. Même si le pointeur de comparaison n'est pas exactement le bon, le test fonctionne néanmoins, et ce n'est pas plus lent.

Si la chaîne se trouve dans le programme, on va pouvoir conserver ce pointeur sans dupliquer les octets ailleurs. En effet, un programme n'est pas volatile et le moindre changement dans le listing efface toutes les variables. On est donc assuré que les chaînes de caractères présentes dans le programme lorsque celui-ci tourne restent en place.

Note : cela donne quelques contraintes si vous vous amusez à modifier le listing en cours de route depuis le programme qui tourne...

Le troisième test, enfin, vérifie si le contenu de la chaîne ne serait pas par hasard dans un autre buffer temporaire, celui où l'on met le descripteur temporaire (et qui se situe juste avant dsctmp)

Note : ce troisième test est étrange. Ce buffer est situé dans les variables système et donc est déjà avant le code BASIC. Je ne vois donc pas comment on peut arriver ici. Je pense que c'est un reliquat d’adaptation du BASIC-80 où le buffer temporaire se situe après le code BASIC.

Le defb $3e est un instruction morte permettant d'éviter l'exécution du POP DE qui suit. En effet, ce POP DE pour récupérer le pointeur sur le descripteur de chaîne a déjà été fait lorsqu'on arrive par là.

crtstrentry replace le pointeur HL sur les informations de la chaîne temporaire la plus récente (la plus en haut du buffer temporaire), puis cette adresse et échangée avec celle tenue dans DE qui est aussi le pointeur vers ce même descripteur.

Note : ici, je ne sais pas dans quel cas HL et DE peuvent être différent. L'idée est d'enlever le descripteur de chaîne du buffer temporaire en ajustant le pointeur sur ce buffer (temppt), le buffer temporaire étant manipulé comme une pile, la routine bc_from_tmp est en quelque sorte le pop de cette pile, dont la valeur part dans BC, mais avec une sécurité. Si le pointeur HL n'est pas celui qui était attendu DE alors le pop n'a pas lieu, c'est juste une récupération de la valeur en haut de la pile.

Avec les informations récupérées, un appel à save_str est effectué, et nous verrons ça juste après.

Dans tous les cas, la description de chaîne la plus récente du buffer temporaire est à nouveau récupérée, l'adresse de la variable popée de la pile dans HL et le descripteur temporaire copié vers la valeur de cette variable.

Après une remise en ordre de la pile, on rend la main, la variable est maintenant associée à la valeur de la chaîne.

Et la création ?

Dans le cas où la chaîne de caractères doit être sauvée quelque part, alors un appel à save_str est fait.

save_str se situe en $3646 et est comme suit :

save_str:    ld       a,(hl)
             inc      hl
             inc      hl
             push     hl

             call     alloc_str_mem

             pop      hl

             ld       c,(hl)
             inc      hl
             ld       b,(hl)

             call     crt_str_dsc

             push     hl
             ld       l,a
             call     copy_str
             pop      de

             ret

En entrée, HL pointe vers un descripteur de variable de type chaîne. Le premier des 4 octets contient donc le nombre de caractères, qui est récupéré dans A. Puis HL est positionné sur le premier octet de l'adresse du contenu et cette adresse est poussée sur la pile.

L'appel à alloc_str_mem vérifie ensuite s'il reste assez de place dans la mémoire dédiée aux chaînes pour ajouter A octets.

Si la routine de vérification ressort, c'est qu'il y a de la place (une erreur aurait été immédiatement émise sinon) et une chaîne de A caractères a été allouée, (fretop) ajusté, et DE pointe vers cette nouvelle allocation.

On récupère alors HL pour obtenir dans BC l'adresse actuelle du contenu de la chaîne.

Un appel à crt_str_dsc crée une nouveau descripteur dans le buffer temporaire avec DE comme pointeur de contenu.

Puis copy_str est appelé après avoir sauvé le pointeur vers le nouveau descripteur dans la pile et mis la taille de la chaîne dans L.

Je ne copie pas le code de copy_str ici. Il est extrêmement simple et copie L caractères de la zone pointée par BC vers la zone pointée par DE. Autrement dit, de la chaîne source vers l'emplacement nouvellement alloué.

Au retour, DE prend la valeur du nouveau descripteur de chaîne, qui est actuellement dans le buffer temporaire et sera récupéré par la fin de la routine de l'instruction LET.

Ouf!

Ramasse miettes

En fait... ce n'est pas tout à fait complet. Lors de la tentative d'allocation de chaîne alloc_str_mem, s'il n'y a plus de place dans la mémoire dédiée, un ramasse miettes est lancé (garbage collection). Cette routine va compacter la mémoire des chaînes de caractères en comblant les trous des données qui ne sont plus valides, d'anciennes valeurs de chaînes qui ne sont plus pointées par aucune variable.

C'est un gros morceau qui doit parcourir les variables mais aussi les tableaux, je laisse ça de côté (pour le moment ?).

À la fin de cette routine, l'allocation est tentée à nouveau. Si lors de cette nouvelle tentative, il n'y a toujours pas assez de mémoire, alors l'erreur est vraiment lancée.

Un peu de BASIC

C'est un peu la tradition de ces articles, voyons maintenant un programme en BASIC qui affiche la valeur des variables. Puisque toutes les variables sont effacées au démarrage d'un programme, il est nécessaire d'en initialiser dans le programme.

10 DEFFNPK(P)=PEEK(P+1)*256+PEEK(P)
20 PRINT"1":A$="ABC"
30 GOSUB 1000
40 PRINT"2":B$="DEF"
50 GOSUB 1000
60 PRINT"3":A$=""
70 GOSUB 1000
100 END
1000 VT=FNPK(&"49D8")
1010 AT=FNPK(&"49DA")
1020 FOR PT=VT TO AT-1 STEP 6
1030 T1=PEEK(PT)
1040 T2=PEEK(PT+1)
1050 IF (T2 AND 128)=0 THEN 1100
1060 PRINT CHR$(T1 AND 127);
1070 PRINT CHR$(t2 AND 127);
1080 PRINT "$="+CHR$(34);
1090 GOSUB 2000:PRINT CHR$(34)
1100 NEXT PT
1200 RETURN

2000 V=FNPK(PT+4)
2000 C=PEEK(PT+2)
2010 IF C=0 THEN RETURN
2020 V=FNPK(PT+4)
2030 FOR I=1 to C
2040 PRINT CHR$(PEEK(V+I-1));
2050 NEXT I
2060 RETURN

Va afficher

1
A$="ABC"
2
A$="ABC"
B$="DEF"
3
B$="DEF"

La partie du programme entre 10 et 100 s'occupe de manipuler des variables et d'appeler l'affichage de leur contenu.

La partie entre 1000 et 1200 regarde la liste des variables comme dans l'article précédent, mais ne sélectionne que celles de types chaînes de caractères.

La partie à partir de 2000 va chercher le nombre de caractères et le pointeur vers les données pour afficher le tout, caractère par caractère.