Triceraprog
La programmation depuis le Crétacé

VG5000µ, SetPoint en ASM, vérifier les résultats ()

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 registres HL, BC et A, qui sont spécialisés ainsi (HL pour un pointeur de mémoire, BC comme compteur et A pour l'accumulateur).
  • inc : incrémente la valeur du registre en paramètre, c'est-à-dire lui ajoute 1.
  • jp : saut (jump) au label indiqué. La différence avec l'utilisation de jr est dans l'encodage de l'adresse de destination. Sans entrer dans le détail, jr est plus condensé que jp, 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 avec jr.


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.

Affichage des résultats de tests dans MAME