Accueil > Bienvenue > Journal de bord > Première rencontre avec le processeur principal

Première rencontre avec le processeur principal

samedi 4 février 2023, par Mathieu Brèthes

Toutes les versions de cet article : [Deutsch] [English] [français]

La Neo Geo Pocket utilise un processeur Toshiba, le TLCS900H, comme processeur principal. Pour le son, elle utilise un Z80 (le même processeur que celui du Game Boy), mais ce n’est pas la question de cet article.

Je me suis donc mis en tête de faire un programme en assembleur pour cette console, afin de me familiariser avec l’architecture du processeur, dans l’objectif in fine de porter un compilateur C vers cette architecture. Pour cela, je dois donc penser mon programme en assembleur un peu comme s’il était un programme en C. Il serait facile de bricoler, mais si je définis dès maintenant avec rigueur la façon dont, par exemple, les fonctions s’appellent les unes les autres, je pourrai ensuite plus facilement voir si ma méthodologie fonctionne pour quand je voudrais l’implémenter sous forme de compilateur.

En assembleur, on travaille avec ce que l’architecture du processeur met à notre disposition. Dans notre cas, le TLCS900H est assez original en ce qu’il dispose de "fenêtres" de registres, à savoir qu’il a 4 fenêtres de 4 registres 32-bit, plus 4 registres accessibles en permanence (dont un qui sert spécifiquement pour le pointeur de pile). Dans la documentation de Toshiba ils appellent ça des banques, mais je reprends la façon dont GCC appelle ce type d’architecture (pour le futur !). Les instructions assembleur ne peuvent s’exécuter que sur une seule fenêtre à la fois (ce n’est pas totalement vrai mais je simplifie), plus sur les 4 registres permanents. Et la documentation de la Neo Geo Pocket indique que la fenêtre numéro 3 (elles sont numérotées de 0 à 3...) est réservée aux fonctions système.

On a donc 3 fenêtres disponibles, et c’est tentant d’essayer de les exploiter pour optimiser le code, et en particulier les appels de fonctions. Dans la documentation de la Neo Geo Pocket, ils proposent de répartir les fenêtres en fonction des besoins - par exemple le code normal en a une, les interruptions en utilise une autre, etc. Mais on pourrait aussi se dire que ces fenêtres servent à limiter la quantité de données à faire transiter entre le processeur et la mémoire de travail.

Explication. Imaginons un processeur qui aurait 2 registres A et B. Tout le calcul fait par le processeur utilise ces 2 registres. Lorsqu’une fonction est appelée, le contenu des 2 registres doit être enregistré quelque part, puisque la fonction a elle aussi besoin de ces 2 registres pour faire ses calculs ! Les valeurs originales seront ensuite restaurées lorsque la fonction est terminée et que l’exécution revient à l’appelant. Habituellement, on se sert de ce qu’on appelle une pile (stack, en anglais) pour stocker les valeurs. Mais le transfert d’informations entre le processeur et la mémoire est très lent - l’idéal serait de ne pas avoir à le faire...

Et c’est ici que nos fenêtres peuvent être intéressantes. Si la fonction principale utilise la fenêtre 0, elle pourrait appeler une sous-fonction, et au moment de l’appel, on bascule sur la fenêtre 1. Aussi les registres de la fenêtre 0 n’ont pas besoin d’être sauvés ! Et si la sous-fonction appelle elle-même une sous-sous-fonction, on bascule sur la fenêtre 2.

On voit vite venir le problème : qu’est-ce qui se passe si on a 3 niveaux de sous-fonctions ? Ou 4, ou 5... ?

On peut imaginer trois types de réponses :

  1. oh finalement c’est assez chiant ces histoires de fenêtre, ignorons-les et contentons-nous d’utiliser la fenêtre 0 et on se servira éventuellement des autres fenêtres pour des fonctions spéciales
  2. quand on est sur la fenêtre 2, les appels de fonctions suivants basculent dans un mode "pile" comme sur un processeur standard
  3. quand on est sur la fenêtre 2, l’appel de fonction suivant sauvegarde les registres de la fenêtre 0 ; et celui-ci sera restauré uniquement au moment de revenir à la fonction principale.

Chaque réponse a son avantage et inconvénient :

  1. c’est le plus simple, mais ce n’est pas optimal
  2. c’est le plus élégant, mais ce n’est pas optimal
  3. ça semble le plus optimal, mais ça nécessite de stocker les valeurs ailleurs que dans une pile (en effet on ne sait pas quand on va redescendre, les sous-fonctions peuvent aussi stocker des valeurs dans la pile, etc.)

J’ai envie d’implémenter la solution 2. Mais aussi, de trouver une façon de la rendre optimale. Quel est le problème de la solution 2 ? Le problème, c’est que ce sont les fonctions les plus profondes dans l’arborescence qui font appel au mode "pile", c’est à dire, les fonctions qui sont les plus petites et qui sont appelées le plus souvent.

Une solution 2b serait que le mode "pile" soit utilisé pour les appels de fonction jusqu’au niveau N-3, et que les 3 derniers niveaux d’appels de fonction utilisent le mode "fenêtre".

Cette solution impose une contrainte que peut-être le compilateur saura gérer : il faut avoir évalué l’ensemble de l’arborescence des fonctions avant de décider quel mode utiliser pour un appel de fonction. Une fonction "feuille" (qui n’appelle aucune autre fonction) sera toujours appelée avec le mode "fenêtre", idem pour une fonction qui appelle juste des fonctions feuilles, et idem pour les fonctions qui appellent juste des fonctions qui appellent des fonctions feuilles ainsi que des fonctions feuilles.

Bien sûr, quid du cas où les fonctions s’appellent elles-mêmes ? Les fonctions récursives ? On ne peut rarement déterminer à l’avance combien il y aura d’appels imbriqués dans le cas où une fonction s’appelle elle-même. Dans ce cas, le compilateur, je suppose, doit être capable de détecter qu’il y a une boucle d’appels (et l’arborescence n’est alors plus une arborescence mais un graphe avec un cycle). Et la solution consiste à dire que pour ce cas, le mode "pile" sera utilisé pour tous les appels.

Dans mon programme en assemble, je vais écrire différents types de macros pour standardiser les appels de fonctions et faire en sorte que ça "ressemble" à ça.

Pour le reste, les 3 registres accessibles en permanence serviront à passer les 3 premiers paramètres à la fonction appelée, les autres paramètres iront sur la pile. Et la fonction appelée mettra ses retours dans ces mêmes trois registres avant de rendre la main à l’appelante. C’est l’appelant qui sera en charge de sauvegarder (éventuellement) les registres avant l’appel (cas du mode "pile") et de les restaurer après, plus de nettoyer la pile retour si besoin. L’appelé sera chargé de nettoyer la pile d’entrée (les paramètres si > 3) avant de rendre la main à l’appelant.