Après cette implémentation en assembleur Z80 d'une fonction setpoint
qui affiche, de manière assez basique, un point à l'écran, je me pose la question d'utiliser un langage de plus haut niveau... mais pas trop.
J'ai une assez longue expérience du C et la question que je me pose est : qu'est-ce que ça donne de programmer en C pour générer du code sur Z80.
Programmer en C a quelques avantages a priori : c'est nettement plus concis et lisible que de l'assembleur, j'y suis plus habitué et c'est portable sur de nombreuses plateformes. C'est le cas d'autres langages, mais le choix naturel pour moi puisque écrire du C m'est habituel. En tout cas bien plus habituel que d'écrire directement de l'assembleur Z80.
Premier essai
Voici le code C d'un premier essai :
#include <stdint.h> // Afin d'utiliser les types standards
void setpoint(uint16_t x, uint8_t y) // Définition de la fonction
// En entrée, les coordonnées
// Pas de valeur de retour (void)
{
const uint8_t zx = x / 2; // Les mêmes calculs qu'en BASIC
const uint8_t rx = x & 2;
const uint8_t zy = y / 3;
const uint8_t ry = y - (zy * 3);
const uint8_t ch = 1 << (ry * 2 + rx);
const uint16_t address = 0x4000 + zy * 80 + zx * 2;
const uint8_t at = *((uint8_t*)address + 1);
uint8_t old = 64;
if (at & 0x80 == 0x80) {
old = *((uint8_t*)address);
}
if (old & 0x80 == 0x80) {
old = 64;
}
uint8_t new = ch | old;
*((uint8_t*)address) = new;
*((uint8_t*)address+1) = 224;
}
int main() // Je dois ajouter une fonction `main` pour que le fichier CRT puisse en trouver une.
{
return 0;
}
J'ai globalement laissé les mêmes noms de variable que j'ai utilisé dans la version BASIC, et les mêmes calculs. C'est un portage simple, sans trop se poser de questions.
Le compilateur SCCZ80
Afin de transformer le code C en code machine, il me faut un compilateur qui sache sortir du langage machine Z80. J'en connais deux, mais il en existe d'autres. Le premier fait parti de l'environnement complet z88dk et se nomme SCCZ80
. L'autre est SDCC, pour Small Device C Compiler. Ce dernier peut générer du code pour de nombreux microprocesseurs. Il peut être aussi utilisé par l'environnement spécialisé z80 qu'est z88dk.
Je commence avec SCCZ80 : zcc +vg5k -O3 -m c_setpoint_basic.c
- +vg5k indique que à la suite que ma plateforme cible est un VG5000µ,
- -O3 indique que je veux optimiser le code au niveau maximum,
- -m indique que je veux générer un fichier avec des informations sur le résultat,
- et enfin le nom du fichier en C.
Pour que la compilation fonctionne, je dois ajouter une fonction main()
. En effet, le CRT (C Run Time) va chercher cette fonction main()
pour l'appeler au démarrage. Or pour utiliser la fonction -m
du compilateur, je dois construire une application complète.
Je pourrais tenter de compiler seulement le fichier .c en fichier objet (.o), malheureusement, comme on va le voir juste après, SCCZ80 appelle de nombreuses fonctions... Bref, c'est plus simple de faire une application complète.
Et cela me permet d'aller voir dans le fichier .map
la taille de la fonction setpoint
telle que compilée pour ce compilateur.
251 octets.
La fonction écrite à la main fait 196 octets, fonctions de multiplication et division comprises. Ici, c'est 251 octets sans les appels aux nombreuses fonctions donc le compilateur se sert.
Le compilateur SDCC
Avant d'aller plus loin, un petit essai avec SDCC donne 125 octets, auquel il faut ajouter la fonction de division, 42 octets, pour un total de 167 octets. C'est beaucoup mieux. C'est même mieux que le code écrit directement en assembleur, qui ne l'oublions pas utilise une table assez importante pour la division. Le code manuel du setpoint
hors appels de fonction reste plus petit.
Par contre, SDCC fait grand usage des registres IX
et IY
. Et IX
est strictement réservé par la ROM du VG5000µ, il faut l'utiliser avec des grandes précautions. Le fichier CRT du VG5000µ le préserve en sauvant sa valeur, et part du principe que l'on configure l'assembleur pour échanger l'utilisation de IY
et IX
(pour au final ne pas utiliser IX
).
Pourquoi c'est si gros ?
Je reviens en premier lieu sur la compilation en SCCZ80. Ce compilateur part sur une stratégie : la compilation d'un programme entier doit donner un exécutable petit. Pour cela, les fonctions les plus courantes sont factorisées dans des routines assembleurs utilisées par le compilateur.
C'est le cas par exemple pour les fonctions de division et de multiplication. Mais c'est aussi le cas pour des services comme « aller chercher un entier sur la pile », utile dans le modèle C pour le passage de paramètres aux fonctions.
En regardant le code généré, on peut voir aussi beaucoup de manipulations de registres pour essayer de faire les calculs sur 8 bits que je demande. Ça à l'air un peu trop complexe. Et si on essayait de partir sur une base de calculs en 16 bits ?
Pour cela, je change les types uint8_t
du code source ci-dessus en uint16_t
.
216 octets ! Voici qui est beaucoup mieux.
Le compilateur SCCZ80 a donc l'air de mieux travailler avec des entiers de 16 bits. Cela est en partie du à l'utilisation de ces routines annexes, qui sont écrites avec un modèle sur 16 bits en tête. En demandant des calculs sur 8 bits, j'oblige le compilateur à sans cesse passer de 8 à 16 bits et inversement.
Et SDCC ? ... 183 octets. Là, ce n'est pas bon. De son côté, SDCC était plus efficace à faire ses opérations sur du 8 bits.
SDCC contrairement à SCCZ80, a pour objectif principal la vitesse d'exécution, quitte à avoir du code machine plus gros. Pour cela, certaines stratégies sont employées. Ici, tous les calculs sont faits sur place, avec des manipulations de registres, à l'exception notable de la division.
Du coup, travailler sur les registres 16 bits est un peu plus compliqué et cela a un impact direct sur la taille du code.
Est-ce qu'on peut aider ?
Première idée, puisque SDCC passe beaucoup de temps à récupérer les arguments de la fonctions, c'est de repasser ceux-ci sur 8 bits.
void setpoint(uint8_t x, uint8_t y)
Avec moins d'instruction d'accès à la pile, on fait gagner 10 octets à SDCC. Pas négligeable. Côté SCCZ80, c'est un gain de 3 octets. C'est toujours ça.
On peut aussi aider en utilisant une fonction de division par 3 spécialisé, comme en assembleur, sous cette forme :
static uint8_t div3_table[] = {
0,0,0,1,1,1,2,2,2,3,3,3,4,4,4,5,5,5,
6,6,6,7,7,7,8,8,8,9,9,9,10,10,10,11,11,11,
12,12,12,13,13,13,14,14,14,15,15,15,16,16,16,17,17,17,
18,18,18,19,19,19,20,20,20,21,21,21,22,22,22,23,23,23
};
uint8_t div3(uint8_t dividend)
{
return div3_table[dividend];
}
La fonction de division générique utilisée pour SCCZ80 fait 37 octets et pour SDCC, 42 octets. La fonction par tableau utilisée ici est plus grosse, mais plus rapide.
Côté SCCZ80, on descend à 212 octets... Soit 1 octet de gain. Pas top. Côté SDCC, ça remonte même à 177 octets (ou 176 en jouant sur les tailles des types entiers).
Conventions d'appel
Lorsque l'on appelle une fonction avec des paramètres, il y a plusieurs moyens de les transmettre. On peut passer par une convention basée sur l'utilisation de registres, ou bien passer par la pile par exemple. Par défaut, les deux compilateurs passent pas la pile. Cependant, on peut indiquer à la fonction que l'on préfère passer par une autre convention lorsqu'il n'y a qu'un paramètre à la fonction.
Pour cela, on modifie la fonction div3
comme ceci :
uint8_t div3(uint8_t dividend) __z88dk_fastcall
{
return div3_table[dividend];
}
Côté SDCC, la fonction div3
fond grâce au passage du paramètre et de la valeur de retour par le registre l
. Plus besoin de mécanisme pour aller chercher la valeur dans la pile, calcul qui était généré de manière assez complexe par SDCC. La fonction passe de 27 octets à... 10 octets !
Le code généré est tout de même étrange :
_div3:
ld c, l ; Sauvegarde de L dans C
ld de,_div3_table+0
ld l,c ; Récupération de C dans L... mais pourquoi ?
ld h,0x00
add hl, de
ld l, (hl)
l_div3_00101:
ret
Ce petit tour de passe-passe entre les registres C
et L
est inutile. L
n'est pas utilisé entre temps, et C
n'est pas utilisé ensuite. Voilà de quoi gagner 2 octets supplémentaire en réécrivant la fonction à la main.
Côté appelant, setpoint
est aussi un peu simplifié et tombe à 174 octets.
Utilisons SCCZ80 à présent avec la même convention d'appel. La fonction initiale n'était pas très grosse, avec ses 14 octets, elle passe à 14 octets. Mais là encore les compilateur génère des choses étranges :
._div3
push hl ; HL est poussé sur la pile
ld de,_div3_table
pop hl ; HL est récupéré de la pile
push hl ; pour y être immédiatement remis
ld h,0
add hl,de
ld l,(hl)
ld h,0
pop bc ; pour que la valeur soit ignorée...
ret
Ces push
et pop
du registre HL
sont rigoureusement inutiles. Il n'y a pas de valeur à préserver. Et au final, le contenu de la pile est extrait dans un registre dont on ne s'est pas servi BC
mais qui du coup est invalidé. Ce sont donc 4 octets à économiser (et pas mal de cycles d'exécutions) en enlevant ces instructions. Cela nous amène à 10 octets.
La différence, si on oubli ces instructions inutiles, entre les deux codes générés est le LD H,0
(2 octets) pour s'assurer que la valeur de retour sur 16 bits est valide avec SCCZ80. SDCC ne prend pas cette précaution, à la charge de l'appelant de n'utiliser que la valeur du registre 8 bits L
.
Et l'inlining ?
Une possibilité de compilation de l'appel d'une fonction est... de ne pas l'appeler, mais plutôt de faire comme si son code était « sur place ». C'est le mot-clé anglais « inline » qui nous permet de spécifier cela.
Côté positif, si le code de la fonction lui-même est petit par rapport au code nécessaire au passage des paramètres et code de retour, cela peut-être gagnant d'injecter le code sur place. Côté négatif, il est possible que ce code injecté un peu partout fasse augmenter la taille du code final.
inline uint8_t div3(uint8_t dividend)
{
return div3_table[dividend];
}
Avec SCCZ80, je ne suis pas allé bien loin. L'utilisation de « inline » fait émettre des erreurs au compilateur. Je ne me suis pas penché plus sur le problème.
Avec SDCC par contre, non seulement le mot clé est pris en compte, mais le code est réduit de pas mal : 161 octets. Et forcément, pas de code généré pour div3
. C'est plus petit que ce que j'avais écrit à la main (ce qui peut aussi s'expliquer par mes compétences limitées en assembleur Z80).
Conclusion
Le C est utilisable pour générer du code Z80. Les compilateurs utilisent des versions réduites du C actuel, mais néanmoins très corrects. Le bon côté est que le temps d'écriture du code, pour certaines opérations, est réduit, en tout cas pour moi.
Par contre, il est nécessaire de garder le code généré à l’œil. Celui-ci peut rapidement être gourmand ou générer des choses inutiles. Il faut alors se poser la question d'adapter se code et de le passer en assembleur, en perdant au passage la portabilité.
Cette portabilité reste limitée, puisqu'il est nécessaire d'aider fortement le compilateur.