Lors d'une discussion sur le forum system-cfg à propos de la fonction RND
une question a été posée sur le format des nombres dans le VG5000µ. C'est une question qui revient et que je voulais documenter pour mémoire, me posant régulièrement la question et oubliant juste après...
Différents formats
Distinguons déjà deux choses : les nombres manipulés par le système, et les nombres manipulés par le BASIC. Les premiers sont de diverses formes en fonction des besoins, de type entier, signé ou pas, sur 8 ou 16 bits la plupart du temps. Il n'y a pas grand chose à dire sur eux.
Les seconds sont ceux manipulés par le BASIC, qui est un BASIC Microsoft sur VG5000µ, et ce qui sera valide dans cet article le sera pour d'autres machines avec BASIC Microsoft et un processeur Z80. Au moins dans les grandes lignes, mais pour ce que j'ai vu en comparant une paire d'entre eux, souvent jusque dans les détails. Ces BASIC ne différent que de petites modifications ici ou là, de l'ordre de l'optimisation.
Le BASIC lui-même traite plusieurs types de nombres. On a déjà vu la manière dont il traitant de manière particulière les numéros de lignes après un GOTO
ou un GOSUB
dans l'article sur l'arrangement des lignes.
Le format qui nous intéresse aujourd'hui est celui utilisé pour les calculs, ainsi que celui utilisé dans les variables numériques. Dans l'article sur les variables, j'étudiais comment les variables étaient créées, avec leur nom suivis de 4 octets. Mais que contiennent ces 4 octets ?
Le format flottant
Tous les nombres traités par le BASIC VG5000µ pour les calculs et les variables sont dans un format a virgule flottante. C'était déjà le cas avec le BASIC créé initialement à Dartmouth. L'idée était que les utilisateurs n'avaient pas à se poser de question sur le format interne, et pouvaient utiliser les nombres naturellement.
Plus tard, pour des raisons de performance (vitesse et consommation mémoire), des versions de BASIC ont ajouté un typage sur ces nombres. Comme le suffit %
des variables numériques qui indiquent que la variable contient un nombre entier.
Sur le VG5000µ, pas de typage de nombre. Tout est au même format. Je nommerai ce format le format BCDE
par la suite. BCDE
car il est manipulé la plupart du temps à travers cette paire de registres (BC
et DE
). BCDE
désigne donc aussi l'emplacement du nombre traité dans certaines situations.
Pour les calculs, le BASIC utilise un accumulateur flottant, que je nommerai FAC
par la suite (Floating point ACumulator). L'essentiel du format est le même que lorsqu'il est au format BCDE
.
L'accumulateur FAC
est l'endroit où se situe « le nombre en cours ». À la sortie d'une fonction, le résultat s'y trouve et est donc disponible pour les calculs ou fonctions suivants.
Ainsi, dans une expression comme INT(4 * 0.2)
, le FAC
va d'abord contenir 4
, puis après la multiplication 0.8
, puis après INT()
, contiendra 0
. Des instructions comme PRINT
ou LET
(explicite ou implicite), iront chercher cette valeur pour la traiter.
Les mouvements
Puisque BCDE
et FAC
sont étroitement liés, il existe des fonctions pour transférer le contenu de BCDE
vers FAC
(\$05d2
) et inversement (\$05dd
). Il est possible aussi de récupérer dans BCDE
un nombre pointé par HL
(\$05e0
), et de monter dans FAC
un nombre pointé par HL
(\$05cf
), en utilisant BCDE
au passage.
Le format
Voici en premier lieu un tableau auquel je vais me référer pour expliquer le format.
B | C | D | E |
---|---|---|---|
Exp. | S|MSB | Milieu | LSB |
$49e9 | $49e8 | $49e7 | $49e6 |
- Exp. est l'exposant du nombre (la puissance de 2 par laquelle est multipliée la mantisse).
- MSB (Most Significant Byte), Milieu et LSB (Least Significant Byte) forment la mantisse.
- S est le bit de signe et se trouve en bit 7 du MSB.
- Les adresses représentent les positions dans
FAC
l'accumulateur flottant.
Attention : les adresses indiquées partent de la plus haute à la place basse. Lorsque vous inspectez la mémoire octet par octet, que ce soit dans FAC
ou pour une valeur de variable, le nombre est donc dans l'autre sens EDCB
, avec l'exposant en dernière position.
La mantisse
La mantisse couvre donc 23 bits. Le 24ième bit, le plus significatif, est implicitement à 1. Dans le format codé, il est donc remplacé par un bit de signe. Un bit à 1 indique un nombre négatif, positif sinon.
La mantisse est lue comme 0.1xxxxxx xxxxxxxx xxxxxxxx
en binaire. Les x
étant pris dans les 23 bits formés par MSB, Milieu et LSB.
L'exposant
L'exposant est centré sur 128 (ou $80 en hexadécimal). Cela signifie que 128 est l'exposant nul. Pour 129 l'exposant est 1, 127, et pour l'exposant est -1.
La valeur 0 ($00) pour l'exposant est spéciale : elle représente le nombre 0. Peu importe les autres octets, la valeur est nulle. Dans certains codage de nombre flottants, cet exposant zéro est utilisé pour représenter des valeurs particulières qui viennent compléter celles accessibles depuis un exposant non nul. Ici, ce n'est pas le cas. Un exposant à 0 signifie le nombre 0.
La valeur du nombre
La valeur du nombre représenté est égal à : $-1 . signe . 2^{exp.} . mantisse$.
Ceci mérite quelques exemples.
Exemple 1 : 0
, codé 00 xx xx xx
. Comme je l'ai écrit ci-dessus, peut importe la valeur des autres octets. Si l'exposant est à 0, c'est le nombre 0.
Exemple 2 : 1
, codé 81 00 00 00
. En effet, 1 est égal à $b0.1 . 2^1$ (je préfixe les nombres en binaire par b
, pour faire la différence avec les nombres en base 10 que je ne préfixe pas).
L'exposant est donc 1, codé en 128 + 1 = 129, \$81 en hexadécimal. La mantisse est 'b0.1', codé en 00 00 00
puisque le premier 1
est implicite dans le codage.
Example 3 : 2
, codé 82 00 00 00
. Sur le même principe, 2 est égal à $b0.1 . 2^2$. Donc exposant 2 donne \$82 et la mantisse est là aussi 0 puisque b0.1 est implicite.
Exemple 4 : -2
, codé 82 80 00 00
. Le code est similaire à celui de 2
, mais le bit de signe négatif est placé sur le 7ième bit de l'octet le plus significatif de la mantisse.
Exemple 5 : 0.1', code ``7d 4c cc cc
. Là, c'est un peu plus compliqué. 0.1
n'est pas une valeur qui tombe juste en binaire. Puisque la précision est limitée, il faut bien s'arrêter quelque part ; il faut bien comprendre que le nombre 'retenu' n'est pas vraiment 0.1
, juste un nombre approchant.
Le nombre s'écrit $2^{-3} . b0.110011001100110011001100$... ; 128 - 3 donne \$7d en hex. Une fois enlevé le premier bit de la mantisse, le reste s'écrit comme indiqué.
Aparté 1
Il est possible d'écrire
LET A = 0.1
PRINT A
Et le nombre affiché sera bien 0.1
. Pourtant, ce n'est pas le nombre encodé, qui est plus quelque chose comme : 0.09999999403953552
Mais ce nombre est arrondi lors de l'affichage.
Aparté 2
À noter aussi que chaque opération effectuée par le BASIC, qui en interne utilise 32 bits, se termine par un encodage du nombre, qui est arrondi aux 24 bits disponibles. Si les calculs ont besoins de plus de 24 bits significatifs, alors les résultats ne seront pas exacts. Il faut se méfier des nombres flottants.
Aparté 3
Rappelez-vous qu'en lisant la mémoire octet par octet, les nombres codés vont apparaître dans l'autre sens. Ainsi, si vous assignez à une variable le nombre 1
et que vous regardez dans la section de variable la valeur suivant le nom, vous y verrez 00 00 00 81
.
Aparté 4
En examinant la mémoire de FAC
et en traçant les opérations, vous pourrez constater que l'octet suivant (\$49ea
) bouge aussi. Il s'agit d'un octet utilisé pour stocker le signe des opérations. En effet, afin de faire des opérations sur les mantisses, il faut en extraire le bit de signe et y remettre le bit implicite. Un bit de signe est stocké à cet endroit (attention, il s'agit de son complément).
Une opération
Pour se faire une idée de comment sont traités les nombres, je vous invite à suivre le processus d'addition.
Tout commence en $0705
par la récupération sur la pile du nombre qui vient d'être décodé depuis la ligne du BASIC. Dans FAC
est déjà positionnée l'opérande précédente de l'opération.
eval_add: pop bc
pop de
jp fp_bcde_add
La nouvelle opérande est maintenance dans BCDE
dans le format expliqué dans les paragraphes précédents. Il faut donc maintenant ajouter BCDE
et FAC
.
En $0310
, la routine commence par un test. Si l'exposant, qui est initialement dans B
, est égal à 0
, on peut s’arrêter là. Ajouter 0
à FAC
signifie ne pas le modifier. Le retour est immédiat :
fp_bcde_add: ld a,b
or a,a
ret z
Deuxième cas simple, si la valeur de l'exposant de FAC
est 0, le résultat est le nombre présent dans BCDE
, il suffit donc de le transférer dans FAC
et c'est terminé :
ld a,(fac_exp)
or a,a
jp z,bcde_to_fac
L'étape suivante est de s'assurer que le nombre dans FAC
a un exposant plus grand que celui de BCDE
. Si ça le cas, c'est parfait, sinon, on échange les deux nombres. L'opération utilise la pile temporairement. A
qui contient la différence entre les deux exposants, prend la valeur de son opposée, pour rester cohérente.
sub a,b
jr nc,no_swap
cpl
inc a
ex de,hl
call fac_to_stck
ex de,hl
call bcde_to_fac
pop bc
pop de
À ce niveau, on a :
- dans
FAC
le nombre dont l'exposant est le plus grand, - dans
BCDE
, le nombre dont l'exposant est le plus petit, - dans
A
, la différence entre ces deux exposants.
Si le nombre d'exposant le plus petit est insignifiant par rapport au plus grand, on peut s'arrêter là, le résultat de l'addition sera le plus grand des grands nombres. Puisque les nombres sont codés sur 24 bits significatifs, si la différences entre les exposants est de 25 ou plus, on peut sortir de la fonction :
no_swap: cp a,$19
ret nc
À présent que les nombres sont en place et qu'il est intéressant de les additionner, il faut les préparer. Pour le moment, le bit de signe est toujours codé dans les deux nombres. Ils sont aussi potentiellement à des exposants différents. Deux raisons pour lesquelles on ne peut pas additionner les mantisses pour le moment.
La première opération est d'extraire les signes et de renvoyer une indication sur l'opération à faire. Plus d'explication sur cette indication juste après.
Puis d'aligner les deux mantisses sur l'exposant le plus grand.
push af
call ext_sign
ld h,a
pop af
call div_mant
Je n'entre pas dans la routine ext_sign
, mais voici ce qu'elle fait. Elle prend le bit 7 de l'octet le plus significatif de FAC
et le remplace par le 1 implicite de la mantisse codée. Le bit de signe est inversé et est mis de côté, dans l'octet suivant l'exposant de FAC
.
Puis la même opération est faite sur BCDE
: extraction de signe, remplacement par 1. Le signe n'est pas mis de côté, mais est utilisé pour renvoyer dans A
une indication : 0
si les signes étaient identiques, 1
s'ils étaient opposés.
Cette indication, qui est sauvée dans H
pour pouvoir récupérer la valeur de AF
sauvée sur la pile (A
contient la différence entre les exposants), servira bientôt.
L'opération div_mant
décale vers la droite la mantisse de BCDE
d'autant de positions qu'indiquées par A
. Attention : en sortie de cette fonction, la mantisse est disponible sur CDEB
.
En effet, l'exposant n'est plus nécessaire, puisqu'il est identique à celui de FAC
. On peut donc réutiliser B
pour récupérer les bits résultants du décalage à droite et éviter de perdre trop de précision. La mantisse est temporairement sur 32 bits (même si seuls 24 sont toujours significatifs, puisque issus du nombre initial).
Il reste donc à passer à l'addition... enfin presque.
Dans la partie suivante, on récupère dans A
l'indicateur donné précédemment par les signes. Si les signes étaient identiques, on passe à la suite. S'ils étaient différents, c'est une soustraction qui sera faite, en allant vers min_bcde
.
ld a,h
or a,a
ld hl,fac_lsb
jp p,min_bcde
Pourquoi cette différence de traitement en fonction des signes ?
- Si les signes des deux nombres sont positifs, il faut additionner les mantisses et le nombre sera positif.
- Si les signes des deux nombres sont négatifs, on peut aussi additionner les mantisses comme des nombres positifs, et pendre l'opposée du tout. $(-a) + (-b)$ est la même chose que $-(a+b)$.
- Si les signes des deux nombres sont opposés, on peut soustraire les mantisses et corriger le signe du résultat. En effet $a + (-b)$ est la même chose que $(a - b)$ et $(-a + b)$ est la même chose que $-(a-b)$.
Le cas de l'addition des mantisses est le suivant :
call add_bcde
jr nc,round
inc hl
inc (hl)
jp z,overflow
ld l,$01
call shft_right
jr round
Après l'appel à add_bcde
qui ajoute la mantisse CDE
avec celle de FAC
octet par octet, un test est fait sur la retenue. Si l'addition n'a pas générée de retenue, alors on va vers la routine d'arrondi de FAC
, qui terminera l'opération.
S'il y a eu une retenue, il faut augmenter l'exposant de 1, ce qui est fait en pointant HL
un cran plus loin que la mantisse et augmentant la valeur de l'exposant qui s'y trouve. Si cette incrémentation a ramené l'exposant à 0
, c'est qu'il était déjà à son maximum. Le nombre est trop grand, une erreur de dépassement de capacité est lancée.
Sinon, il faut corriger la mantisse avec un décalage vers la droite de 1 bit (paramètre indiqué par L
) puis brancher vers la routine d'arrondi.
Le cas de la soustraction des mantisses est le suivant :
min_bcde: xor a,a
sub a,b
ld b,a
ld a,(hl)
sbc a,e
ld e,a
inc hl
ld a,(hl)
sbc a,d
ld d,a
inc hl
ld a,(hl)
sbc a,c
ld c,a
call c,compl2
Toute la première partie, jusqu'au call
, est la soustraction de la mantisse de 'FAC' par la mantisse 'CDEB' octet par octet. Le résultat se trouve dans 'CDE'. L'exposant du nombre final est toujours celui de FAC
.
Si le résultat de la soustraction est positif, alors il ne se passe rien de plus. Si une retenue a eue lieu pendant la soustraction, alors on prend son opposée en appelant compl2
, afin de rendre la mantisse positive. Cette routine inverse aussi le bit de signe qui avait était extrait dans l'octet suivant FAC
.
La routine qui suit dans le code étant la normalisation et arrondi d'un nombre, pas besoin de brancher, c'est terminée.
Oui mais le signe ?
Les opérations pour obtenir le signe du résultat final ne sont pas forcément évidente à suivre. Voici ce qu'il se passe :
- Dans le cas où on a utilisé l'addition des mantisses, le signe de
FAC
a été extrait de la première opérande.- Dans le cas $a + b$, $a$ était positif, et le résultat est positif.
- Dans le cas $-a-b$ qui est devenu $-(a+b)$, $a$ était négatif, et ce signe négatif sera hérité par le résultat. Transformant $a+b$ en $-(a+b)$.
- Dans le cas où on a utilisé la soustraction des mantisses, là encore, le signe de
FAC
a été extrait de la première opérande.- Dans le cas $a - b$, le signe du résultat dépend de cette opération. Si $a > b$,
compl2
n'a pas été appelé et le résultat est positif. Dans le cas contraire,compl2
a changé le bit de signe et le résultat est bien négatif, puisque la mantisse est positive. - Dans le cas $-a+b$, qui a été exécutée comme $-(a-b)$, initialement, le signe de
FAC
portait l'indication négatif, et le résultat de la soustraction l'aura, en fonction des deux cas, laissé comme tel, ou inversé.
- Dans le cas $a - b$, le signe du résultat dépend de cette opération. Si $a > b$,
Re-codage final
La routine qui suit, de normalisation, s'assure que le bit de poids fort est toujours 1 (si ce n'est pas possible, c'est que le nombre est 0) et ajuste l'exposant en conséquence, puis arrondi le nombre. L'arrondi peut générer un dépassement de capacité.
L'addition n'a pas besoin de normalisation. Le résultat de l'addition des mantisses assure toujours que le bit de poids fort est 1 (au moins l'un des deux nombres était normalisé avec un bit de poids fort à 1, et si les deux l'étaient, alors cela a provoqué l'augmentation de l'exposant et le bit de poids fort est toujours 1, la retenu de 1 + 1 en binaire).
Le résultat de l'addition est à présent dans FAC
, place à la suite des opérations...