Code auto-relogeable sur CPC, comment ça marche ?
Après la sortie de mon DZX0, on m’a demandé quelle était la méthode pour écrire un code qui puisse s’exécuter n’importe où en RAM. Je vous explique tout !
La problématique avec le code assembleur, c’est qu’il regorge d’adresses en dur. Les CALLs, les JPs, les tables, tout cela nécessite de stocker des adresses en mémoire. Déplacer le code d’un octet décalera tout. Ça n’est pas un problème si on peut recompiler le code. Mis à part à se passer des instructions problématiques et des tables, on n’a pas de solution toute simple sous la main. Quelle solution ?
Il faut patcher ! En clair, cela revient à modifier le code qu’on veut exécuter de manière modifier chaque CALL, chaque JP, chaque référence à une table avec les bonnes adresses. Une galère ? Oui. Mais sachez que vos exécutables DOS et Windows font la même gymnastique pour pouvoir tourner n’importe où en RAM. Ces OSes possédant un mécanisme intégré, les exécutables n’ont pas à faire toute la gymnastique qu’on s’apprête à faire.
Maintenant qu’on sait ce qu’il faut faire, comment est-ce qu’on procède ? Et bien, encore une fois, ce n’est pas si simple. Pour calculer les décalages d’adresse, on a besoin de savoir où est localisé le code à modifier. Il existe un registre du Z80 qui contient cette valeur, PC. Or, il n’existe aucun opcode qui permette de connaître PC. Pas de LD HL,PC par exemple. Mais il y a une bidouille : Lorsqu’on fait un CALL, l’adresse de retour est mise dans la pile (et donc, en RAM). Facile, non ? Un simple CALL vers une routine qui fait un POP dans la pile récupérera l’adresse de retour. Oui et non :
CALL get_pc
get_pc:
POP bc
Ça ne peut pas fonctionner, car CALL est justement l’une des instructions qui posent problème : Si on met ce code n’importe où en mémoire, l’adresse get_pc devrait être modifiée en conséquence pour que le code fasse ce qu’on attend de lui. Le serpent qui se mord la queue.
Par contre, si on fait un CALL vers une adresse connue dans le firmware contenant un RET, ça pourra fonctionner. Ça tombe bien, on a un #C9 (un RET, donc) à l’adresse 15. Il y a tout de même un hic : RET dépile l’adresse de retour, ce qui fait que la pile ne pointe plus vers l’adresse qu’on veut récupérer. Si on n’a aucun moyen de modifier PC (En fait, CALL, JP, RET, peuvent se résumer à des des PUSH PC, LD PC,nnnn, POP PC, donc on peut le modifier. Mais dans notre cas ça ne nous aide pas), on peut modifier SP. DEC SP est parfait pour ça. Ce code fonctionne :
CALL 15
get_pc:
DEC SP
DEC SP
POP BC
PC étant une donnée 16 bits, on doit donc décrémenter la pile deux fois. POP BC met dans BC l’adresse de retour (soit l’adresse qui suit immédiatement le CALL 15) et ajoute 2 à la pile, ce qui la remet à la bonne valeur. Il nous faut donc 4 Opcodes et un peu de RAM pour effectuer l’équivalent d’un simple LD BC,PC ! Mais il reste un dernier piège : Les interruptions !
En effet, le principe des interruptions consiste dans le CPC à générer un RST #38 à intervalle régulier : 300 fois par seconde quand même. Il est nécessaire pour la gestion du CPC par le firmware, notamment les encres clignotantes, ou les sons. Un RST #38, c’est l’équivalent d’un CALL. Ça va donc poser une adresse de retour dans la pile, exécuter le code en #38, et au retour tout devrait rentrer dans l’ordre. Mais notre bidouille pour récupérer PC utilise une ancienne valeur de la pile, on la décale… Autant dire qu’on ne va pas avoir le résultat espéré si une interruption s’invite à ce moment là. La solution ? DI ! Cet opcode met les interruptions en attente, jusqu’au EI suivant. Notre code devient donc :
DI
CALL 15
get_pc:
DEC SP
DEC SP
POP BC
EI
On pourrait encore pousser le vice jusqu’à mettre le EI avant le POP BC, parce qu’une spécificité de cette instruction fait que l’opcode qui suit le EI ne peut pas être interrompu. La prochaine interruption ne peut pas arriver immédiatement après un EI, mais seulement immédiatement après l’opcode qui suit le EI. Mais là on est au niveau démomaking. Si on veut rester lisibles et compréhensibles, autant laisser le EI après le POP BC.
Et ensuite ? On a l’adresse de get_pc, mais à quoi ça sert ? On a désormais un point de référence ! Chaque adresse de la routine peut être définie comme un décalage par rapport à get_pc. Voici un petit exemple de « Hello World » en version auto-relogeable :
di
call 15
get_pc:
dec sp
dec sp
pop bc
ei
ld hl,text-get_pc ; hl est chargé avec le décalage entre l'adresse du label "text" et "get_pc"
add hl,bc ; hl contient maintenant l'adresse réelle de notre texte en RAM !
loopwriter
ld a,(hl)
inc hl
cp 0
ret z
call #BB5A ; Affiche un caractère, c'est une adresse du FW donc pas besoin de la modifier
jr loopwriter ; Un JR fonctionne avec des décalages, pas besoin de le modifier non plus
text:
db #0D,#0A,"Hello World !",#0D,#0A,#0
J’espère que ce billet aura répondu à toutes les questions concernant le code auto-relogeable sur CPC. S’il y a encore des points nébuleux, n’hésitez pas à m’en faire part, j’apporterai des précisions si nécessaire.