Après avoir mis en place une vérification (légère) de l'intégrité de la pile, je passe à la vérification de la validité de l'appel d'une fonction.
Le fonctionnement du test est assez simple : je prends une suite de nombres, j'appelle une fonction avec en paramètre chacun de ces nombres, je vérifie que le résultat est conforme à ce que j'attendais.
Par exemple, si je veux tester une fonction diviser par 2 (division entière), je peux utiliser la suite de nombre 0, 10, 32, 255
et comparer les résultats respectifs avec 0, 5, 16, 127
(255 étant impair, le résultat de la division entière est 127, avec un reste égal à 1).
Encore plus simple qu'une division par 2, il y a la fonction identité : celle qui renvoie le paramètre sans le toucher. Tester cette fonction permet de se concentrer sur le développement du test.
La fonction en elle-même est très simple :
identity: ; Entrée: registre A, Sortie : registre A, inchangé
ret ; Retour immédiat, on ne touche à rien
La boucle de test
La boucle de test initialise deux pointeurs de données qui vont être augmentés en parallèle. La donnée source sera envoyée à la fonction, via le registre A
, puis le résultat, mis dans le registre A
aussi, sera comparé à la valeur attendue.
Il nous faut donc en premier lieu la liste de ces valeurs :
identity_input_data:
defb 0,10,32,255
identity_reference_data:
defb 0,10,32,255
La boucle en elle-même ressemble à cela :
; Fonction de test
test:
ld hl,identity_reference_data ; HL pointe sur les résultats de références
ld de,identity_input_data ; DE pointe sur les données en entrées
or a,a ; Effacement du drapeau de retenue (voir article précédent)
sbc hl,de ; Par soustraction des deux valeurs, on obtient le nombre de valeurs
; de la série.
ld b,h
ld c,l ; BC contient le nombre de valeurs à tester
ld hl,identity_reference_data ; HL est pointe à nouveau sur le résultat de référence
test_loop:
ld a,(de) ; Chargement dans l'accumulateur de la valeur pointée par DE
call identity ; Appel de la fonction
; Au retour de la fonction, A contient le résultat de la fonction
cpi ; Compare A avec (HL), incrémente HL et décrémente BC
; Si BC passe à 0, le bit d'overflow (V) est mis à 0 ; 1 sinon
; Si A et (HL) sont identique, le flag Zero est mis à 1
jr nz,test_failed ; Si A et (HL) étaient différent, saute à test_failed
inc de ; Sinon, incrémente DE manuellement
jp v,test_loop ; S'il reste des valeurs (BC > 0), on boucle
; DE et HL pointant à présent sur la paire de valeurs suivantes
ld hl,test_pass_msg ; Arrivée ici, toutes les paires de valeurs ont été
; vérifiée avec succès. HL pointe donc sur le message
; de succès.
jr print_test_result_msg ; Et on saute à l'affichage.
test_failed:
ld hl,test_fail_msg ; Arrivée ici, une comparaison a échouée, HL pointe
; donc sur le message d'échec.
print_test_result_msg:
call print_str ; On affiche le message contenu dans HL
ret ; Le test est fini !
test_pass_msg:
defm "Pass!\r\0"
test_fail_msg:
defm "Fail!\r\0"
Les instructions utilisées
Les instructions déjà utilisées dans l'article précédent ne sont pas répétées ici, les nouvelles sont :
defb
: qui est une directive pour l'assembleur, indiquant de réserver de la place mémoire et de l'initialiser avec les octets qui suivent,cpi
: instruction de comparaison qui effectue plusieurs actions d'un coup, comme indiqué dans le commentaire ci-dessus. Cela contraint l'utilisation des registresHL
,BC
etA
, qui sont spécialisés ainsi (HL
pour un pointeur de mémoire,BC
comme compteur etA
pour l'accumulateur).inc
: incrémente la valeur du registre en paramètre, c'est-à-dire lui ajoute1
.jp
: saut (jump) au label indiqué. La différence avec l'utilisation dejr
est dans l'encodage de l'adresse de destination. Sans entrer dans le détail,jr
est plus condensé quejp
, car il n'encode pas l'adresse complète mais seulement un déplacement court. Cependant, il n'est pas possible d'utiliser le drapeau de dépassement de capacité (V
) n'est pas utilisable avecjr
.
Et la division par 2 ?
À présent, il devient facile de tester différentes fonctions. Il suffit de la fonction elle-même, de la paire de liste de valeurs, et de remplacer l'appel de le fonction dans le test.
div2: ; Entrée: registre A, Sortie : valeur divisée par 2, dans A
or a ; Effacement du drapeau de retenue
rra ; Rotation du registre A vers la droite, en passant par la retenue
ret
div2_input_data:
defb 0,10,32,255
div2_reference_data:
defb 0,5,16,127
Et par exemple, si vous aviez, par étourderie comme moi, utilisé rrca
plutôt que rra
, le test échoue sur la division par 255.
Généralisation
Mais changer les pointeurs à chaque test de fonction, ça n'est pas pratique. C'est la grande différence entre des tests automatisés, qui peuvent rester à demeure et que l'on peut lancer régulièrement pour s'assurer que l'on construit un programme sur des fondations solides, et le test manuel, de temps en temps, pour s'assurer du fonctionnement en un point donné, et que l'on doit remettre en place manuellement à chaque fois.
Bref, il me faut généraliser ça avec, par exemple, la boucle de test qui prendrait en entrée les pointeurs nécessaires. Et pourquoi pas, même, un nom explicatif de la fonction testée sur le moment.
Ce que je voudrais, c'est quelque chose comme ceci :
test_suite:
ld hl,id_params
call prepare_test
ld hl,div2_params
call prepare_test
ret
id_params:
defw identity_input_data
defw identity_reference_data
defm "IDENTITY\0"
div2_params:
defw div2_input_data
defw div2_reference_data
defm "DIV2\0"
Il faut pour cela adapter un peut la routine test
pour aller piocher les valeurs depuis HL
, qui devient le paramètre d'entrée.
Tout d'abord, la préparation des paramètres du test va mettre sur la pile les paramètres indiqués.
Note : il y a de multiples choix pour passer les paramètres des tests à la fonction. Mais aussi beaucoup de contraintes sur les instructions disponibles. Passer par la pile grâce à une fonction d'aide est assez simple à implémenter et lisible. Mais loin d'être le plus rapide.
prepare_test:
ld b,3 ; B sert de compteur, on va mettre les trois premières adresses sur la pile
prepare_test_loop:
ld e,(hl) ; Récupération de la première partie de l'adresse
inc hl
ld d,(hl) ; Récupération de la seconde partie de l'adresse
inc hl
push de ; DE contient l'adresse, qui est poussée sur la pile
djnz prepare_test_loop ; DJNZ décrémente B et, si B n'est pas égal à zéro, retourne au label indiqué
; C'est la manière canonique d'effectuer des boucles
push hl ; La dernière adresse est poussée directement, car elle pointe sur la chaîne de caractères,
; sans indirection.
jp test ; ici, on devrait faire un CALL à la routine de test. Mais ce CALL serait immédiatement
; suivi d'un RET. Dans ce cas-ci, on peut remplacer le CALL par un JP.
; Si vous avez bien compris ce que font CALL, RET et JP, alors vous devriez comprendre
; pourquoi.
À présent, à l'appel de la routine test, il y a sur la pile, dans l'autre du plus « haut » vers le plus « bas » : l'identifiant sous forme de chaîne de caractères, l'adresse de la fonction à appeler, le pointeur de données de références, le pointeur de données en entrée.
Il s'agit de récupérer tout cela.
Voici le début de la routine modifiée, le reste ne change pas :
test_sep_msg:
defm ": \0" ; Une chaîne de caractère, voir plus loin
test:
pop hl ; La première opération consiste à afficher l'identifiant
call print_str
ld hl,test_sep_msg ; Suivi de la nouvelle chaîne de caractère, pour afficher les deux points
call print_str
pop hl ; La valeur suivante récupérée est l'adresse d'appel de la fonction
; Les appels indirects sur un Z80 ne sont pas naturels, il n'existe pas de CALL
; à une adresse non préalablement fixée.
ld (call_func+1), hl ; Du coup, on profite du fait d'être en RAM pour modifier le code à la volée
; en modifiant directement l'adresse du CALL à la fonction.
; Cela ne serait pas possible avec un programme en ROM par exemple, mais il
; existe plusieurs autres possibilités (utilisation de vecteurs et
; modification manuelle de la pile par exemple)
; Ce genre de manipulation vient avec des contraintes, mais qui dans notre cas
; sont tout à fait acceptables.
pop hl ; Récupération de l'adresse des données de référence
pop de ; Récupération de l'adresse des données en entrée
push hl ; Sauvegarde temporaire de HL
or a,a ; Le calcul du nombre de données, comment avant
sbc hl,de
ld b,h
ld c,l
pop hl ; Récupération de la sauvegarde temporaire de HL
test_loop:
ld a,(de)
call_func:
call $0000 ; Ici, le CALL à l'adresse $0000 sera modifié dynamiquement par
; la manipulation décrite ci-dessus. Lors de l'exécution de cette instruction,
; c'est donc bien la fonction spécifiée qui sera appelée.
Résultats
En situation réelle, il est très peu probable que j'utilise des fonctions identité ou division par 2. Les calculs seront faits sur place. Cependant, tester ces fonctions m'ont permis de développer mon petit framework de tests, assez minimaliste, et cela va m'être bien utile pour la suite, pour attaquer la division.