Site logo

Triceraprog
La programmation depuis le Crétacé

VG5000µ, SetPoint en ASM, vérifier la pile ()

Il y a maintenant pas mal de temps, j'avais implémenté, en BASIC, une routine pour afficher un point à l'écran. Puis de là, une routine pour tracer une ligne, puis un cercle. Le constat était que c'était très lent. Le BASIC interprété est déjà plutôt lent de manière générale, et celui du VG5000µ n'est pas particulièrement rapide.

Il y a plusieurs raisons à cela, et ce sera peut-être le contenu d'articles futurs.

Mais en attendant, et après tous les efforts pour se confectionner un environnement de travail pour développer en assembleur, je repars sur l'implémentation du tracé d'un point à l'écran, et cette fois-ci, en assembleur.

L'avantage d'un programme écrit dans un langage de « haut niveau », comme le BASIC, est de simplifier bien des choses. Faire une division d'une variable A par 3 par exemple, peut s'écrire B = A/3. C'est simple, concis, lisible.

Traduire cela en assembleur n'est pas toujours simple. Diviser par un nombre quelconque n'est pas direct car le processeur Z80 n'a pas d'instruction de division. Le processeur possède des instructions pour additionner et soustraire, mais pas pour multiplier, ni de diviser.

Diviser par des nombres en particuliers est parfois simple, comme diviser par 2, qui consiste à décaler tous les bits d'un nombre binaire « vers la droite », tout comme diviser par 10 dans notre arithmétique courante consiste à décaler tous les chiffres vers la droite (ou supprimer l'unité, si vous préférez).

Pour 30 par exemple, en décimal, une vision par 10 donne 3. En binaire, le même nombre s'écrit 00011110 (sur 8 bits) et sa division par 2 donne 00001111, c'est-à-dire 15 en décimal. Diviser (ou multiplier) par des multiples de la base dans laquelle on représente les nombres est simple.

Cependant, dans le calcul du pixel à afficher, j'avais une division par 3. Et là... c'est plus compliqué.

La première étape, puisque je pars de zéro est d'implémenter une division. Puisque le calcul n'a besoin que de diviser par 2 et par 3, et que la division par 2 est simple, je vais me contenter d'une division par 3.

Mais STOP. Si vous suivez ce blog, vous avez peut-être vu que j'aime essayer de transposer des techniques modernes sur d'anciennes machines. C'est de la rétro-programmation anachronique, mais qui peut convenir au fait que, de toute façon, programmer ce genre de machines depuis un ordinateur actuel est anachronique.

Une technique moderne de développement (qui a ses détracteurs) est de guider sa programmation à travers des tests qui peuvent, à chaque instant, indiquer si une erreur apparaît. La méthode est globalement de : 1/ écrire un test... qui échoue 2/ écrire le minimum pour faire passer ce test 3/ améliorer (sans ajouter de nouvelle fonctionnalité).

Le point 3/ en particulier, permet de tester des choses en étant certain que ce que l'on a programmé ne « casse » pas. Suite à une optimisation un peu trop cavalière par exemple. Je ne suis pas un spécialiste de l'écriture de programmes en assembleur Z80, et un outil qui me permet de vérifier que mon changement ne casse pas tout m'intéresse.

Puisque je vais implémenter une fonction, mon environnement de test prendra en entrée une suite de nombres, y appliquera la fonction puis, testera que les résultats sont ceux que j'attends. J'affiche ensuite le résultat de la comparaison.

Techniquement, un tel système de tests devrait lui-même être testé... mais à si bas niveau, c'est aller un peu trop loin pour ce que je veux faire.

Le premier test

La première chose dont je veux m'assurer, c'est que la pile à la sortie de mon traitement soit dans le même état qu'au début. En effet, si ce n'est pas le cas, il va se passer des choses probablement à classer dans le domaine du « mal ». Dans le meilleur des cas une erreur bizarre, dans le pire (et souvent), un reboot de la machine.

Aparté: je crois n'avoir jamais parlé de la pile dans un article précédent. Très rapidement, c'est un endroit en mémoire où l'on peut stocker des informations sous forme de pile (imaginez une pile d'assiettes). La dernière donnée mise sur le pile est aussi la première que l'on lira par la suite. Cette pile est entre autre la moyen lors de l'appelle d'une routine d'en revenir. À l'appel, l'adresse du code appelant est mis sur la pile. Pour retrouver cette adresse, il faut donc que l'état de la pile soit le même en entrée et en sortie de fonction.

Voilà la première partie de la vérification de l'état de la pile.

        ld      hl,0        ; Il n'est pas possible sur Z80 de prendre le pointeur de pile pour le mettre dans un registre
        add     hl,sp       ; l'astuce est donc d'ajouter à 0 la valeur du pointeur de pile, en deux étapes.
        push    hl          ; Et je pousse la valeur du pointeur de pile dans la pile

Il y a à présent en haut de la pile une valeur arbitraire suivi de l'adresse de la pile en début de fonction.

Voici ensuite la seconde partie de la vérification de l'état de la pile. C'est un peu plus long car il y a la vérification ainsi que l'affichage du résultat.

        pop     hl                  ; Récupération de la valeur depuis la pile
        or      a,a                 ; Reset de la retenue
        sbc     hl,sp               ; Soustraction de cette valeur avec le pointeur de pile
        jr      nz,print_stk_fail   ; Si le résultat n'est pas zéro, c'est qu'on n'a pas trouvé la bonne valeur dans la pile
                                    ; Dans ce cas, saut à print_stk_fail

                                    ; Si tout ce passe bien, c'est à dire que la pile n'a pas été corrompue et qu'elle
                                    ; est au même « niveau » qu'au début, alors...
        ld      hl,stack_ok         ; on charge dans HL le message de succès
        call    $36aa               ; et on l'affiche

        ; [...]                     ; On verra plus tard ce qui est ici.

        ret

print_stk_fail:                     ; On arrive ici en cas d'échec
        ld      hl,stack_fail       ; On charge dans HL le message d’échec
        call    $36aa               ; et on l'affiche

                                    ; Mais on ne peut pas sortir comme ça de la fonction. Puisque la pile n'est pas dans
                                    ; le même état qu'au début, l'instruction `RET` ne va pas trouver l'adresse ramenant
                                    ; au programme appelant, et cela ne va rien amener de bon (un reboot très souvent)
endless_loop:
        halt                        ; Alors on arrête tout... ou presque. L'instruction HALT va arrêter le système jusqu'à
                                    ; la prochaine interruption, qui est, sur VG5000µ, l'interruption d'affichage.
        jr      endless_loop        ; Puis à la fin de l'affichage, ou boucle à l'infinie.
                                    ; Cela permet de voir le message d'erreur.

stack_ok:
    defm "Stack Pass!\0"

stack_fail:
    defm "Stack Fail!\0"

Les instructions utilisées

Lire de l'assembleur peut être un peu déroutant au premier abord. Les explications fonctionnelles sont disponibles en commentaire dans le code ci-dessous, voici à présent les instructions utilisées dans l'ordre de leur apparition :

  • ld : abréviation de load, c'est-à-dire charge. La valeur à droite de la virgule est chargée dans le registre à gauche. Par exemple, après ld hl,0, le registre HL contiendra la valeur 0,
  • add : la valeur de droite (ou le contenu du registre à droite) de la virgule est additionné (add) dans le registre à gauche. Arès add hl,sp, le contenu de HL sera augmenté de la valeur de SP,
  • push : pousse la valeur contenu dans le registre sur la pile, après push hl, le contenu de HL est en haut de la pile, HL n'est pas modifié,
  • pop : est le contraire de push. La valeur en haut de la pile est chargée dans le registre en paramètre, puis la pile est positionné sur l'élément au-dessous. Après pop hl, HL contient le contenu qui était en haut de la pile,
  • or : permet d'effectuer une opération ou. Ici, cependant, cette instruction est utilisée uniquement pour mettre le drapeau de retenue à zéro, à cause de l'instruction suivante,
  • sbc : soustraction avec retenue, la valeur du registre de droite est soustraite de la valeur du registre de gauche. Il n'est pas possible sur Z80 de faire une soustraction sans retenue entre deux registres 16 bits. De là vient la nécessité d'effacer la retenue, au cas où, avec le or précédent. Le résultat est chargé dans le registre de gauche.
  • jr : saut relatif, un branchement, c'est-à-dire une modification d'ordre d'exécution, va être effectué au label indiqué. Dans le code ci-dessus, nz à gauche de la virgule indique que le saut sera conditionné par le résultat du calcul précédent si celui-ci n'était pas nul (not zero). Après jr nz,print_stk_fail, le processeur continuera son exécution à l'emplacement indiqué par print_stk_fail si le résultat n'est pas 0. Dans le cas contraire, le branchement n'a pas lieu et l'exécution continue à l'instruction suivante,
  • call : est un appel de sous-routine. L'emplacement de l'instruction suivante est mise sur la pile (équivalent d'un push) puis un branchement est fait au label indiqué,
  • ret : est le pendant de call, la valeur en haut de la pile est prise pour prochaine instruction grâce à l'équivalent d'un pop. L'exécution continue donc là d'où avait été appelée la routine,
  • halt : arrête l'exécution du processeur. Lorsqu'une interruption matérielle est reçue, le processeur se remet en route.
  • defm : ce n'est pas une instruction du processeur mais une directive pour l'assembleur. Une zone mémoire est réservée et initialisée avec la chaîne de caractères qui suit.

Garder le contexte

Cette routine de vérification de la pile modifie quelques registres. Pour laisser les choses dans l'était où elles étaient lorsque ce code sera appelé, il est de bon ton de sauvegarder l'état des registres et de les restituer à la fin. Cela peut se faire par une série de push et de pop. Ce qui donne au final :

        ; Vérification de pile
        org     $7000

        defc    print_str = $36aa

        ; Sauve le contexte
        push    hl
        push    bc
        push    af
        push    de

        ; Enregistre la pile
        ld      hl,$0
        add     hl,sp
        push    hl
        ;

        ; Opérations futures...

        ; Vérification de la pile
        pop     hl
        or      a,a
        sbc     hl,sp
        jr      nz,print_stk_fail

        ; Message en cas de succès
        ld      hl,stack_ok
        call    print_str

        ; Restitution du contexte
        pop de
        pop af
        pop bc
        pop hl

        ret

print_stk_fail:
        ld      hl,stack_fail
        call    print_str
loop:
        halt
        jr      loop

stack_ok:
    defm "Stack Pass!\0"

stack_fail:
    defm "Stack Fail!\0"

Résultat

Pour le moment, pas grand chose, tout se passe bien et un message est affiché indiquant que... la pile est ok.

Affichage du test de pile dans MAME