Mon défi était de faire un adaptateur manettes SNES à console Genesis/Megadrive. Une manette standard à 3 boutons
utilise normalement un multiplexeur (74157 par exemple) capable de répondre très rapidement (ex: 27 nanoseconde)
au signal de sélection. Voulant utiliser mon circuit multiuse PCB2 avec un Atmega8 cadencé
à 16 MHz (le maximum), j'ai naturellement câblé le signal de sélection à une broche capable de générer
une interruption. Quand l'interruption survient, l'exécution du code correspondant n'est pas instantané pour
plusieurs raisons, et c'est là que ça devient intéressant.
Je retrace ici le chemin parcouru pour arriver à un temps de réponse assez bas pour un fonctionnement
fiable. Pour commencer, voici une explication rapide du fonctionnement de la manette à 3 boutons.
Le connecteur sur le câble d'une manette Genesis est de style DB9-F. L'alimentation est sous 5 volts.
Il y a 6 signaux de sortie (manette vers console) et un d'entrée (console vers manette) qui permet
de sélectionner quel jeu de boutons est représenté par les 6 signaux de sortie.
DB9 pin
Fonction
5
+5 volt
8
GND
7
Signal de sélection (SELECT)
DB9 pin
Fonction quand SELECT=0
Fonction quand SELECT=1
1
D-Pad haut
D-Pad haut
2
D-Pad bas
D-Pad bas
3
0
D-Pad gauche
4
0
D-Pad droite
6
Bouton A
Bouton B
9
Bouton START
Bouton C
Pour plus d'information, je vous conseille cette lecture:
segasix.txt
Première implémentation en C
Lors du câblage, j'ai fait en sorte que l'ensemble des signaux en sortie
vers la console soient regroupés sur un seul port, et aussi à ce que
ce même port ne serve qu'à ceux-ci. Cela permet de changer tout
le groupe de signaux d'un seul coup par assignation directe plutôt
qu'avec trois opérations (lecture, modification, écriture.).
Les variables globales S0_PC et S1_PC sont mises à jour par la boucle
principale après chaque lecture de la manette SNES. S0_PC contient
la valeur que devra prendre les signaux du PORTC lorsque le signal
de sélection sera à 0, et naturellement, S1_PC contient la valeur
à présenter quand le signal de sélection sera à 1.
INT0 est configuré pour les fronts montant et descendants. La routine
sera donc exécutée à chaque changement.
Voici la routine d'interruption minimale avec laquelle j'ai commencé:
Très inefficace! L'écriture de PORTC (out 0x15) est faite bien tard! Le compilateur ne comprends pas que nous
voulons faire cette écriture dès que possible. Il ne se gêne pas pour utiliser beaucoup
de registres, ce qu'il l'oblige donc à perdre du temps pour les pousser sur la pile. De plus,
r1 est mis à zéro (eor r1,r1) sans raison, ce qui a un effet sur SREG (0x3F) qui doit alors aussi être sauvegardé..
Notez que j'ai compilé avec l'option -Os, mais que -O3 ne donnait pas de meilleurs résultats.
Interruption «Naked» avec assembleur en ligne
Voici une nouvelle routine d'interruption marquée «NAKED», ce qui ordonne au compilateur
de ne pas générer de code avant et après. Cela devient notre responsabilité. C'est très
bien car nous pouvons prendre une approche beaucoup plus minimale.
Avec cette version, le temps de réponse est tombé à 960 nanosecondes! L'adaptateur a commencé à
fonctionner, mais pas de manière fiable. Lorsque je maintenais le bouton pour sauter enfoncé,
le personnage sautait à répétition. C'est donc que le temps de réponse de l'adaptateur
chevauchait la limite acceptable. Il fallait donc réduire le temps de réponse davantage.
Les instructions push et lds s'exécutent en deux cycles chacunes. Si l'ensemble du
code était en assembleur, il serait possible de réserver d'utiliser des registres pour les
variables S0_PC et S1_PC, ce qui permettrait d'y accéder en un seul cycle via l'instruction
mov. De plus, la sauvegarde de r16 pourrait être évitée en lui réservant
un registre qui ne serait pas utilisé nulle part ailleurs.
Le reste du code est, et de préférence devrait rester en C. Bien qu'il soit tentant
de déclarer une variable de registre globale (par exemple,
avec la sytaxe suivante: register unsigned char value asm("r3")), il faut faire
attention de compiler les autres fichiers du même projet et aussi s'assurer que
les librairies, par exemple avr-libc, soient également compilées de manière à ne pas toucher
ce registre, via l'option gcc -ffixed-3 par exemple. Pour ces raisons, j'ai décidé
de ne pas adopter cette approche.
Exploitation des registres de périphériques
Cela dit, il est quand même possible d'accéder aux variables en un seul cycle en
utilisant des registres de périphériques inutilisés en s'assurant qu'il n'y
aura pas d'effets secondaires problématiques.
J'ai donc stocké S0_PC dans UBRRL (baud rate low byte), S1_PC dans OCR2 (output compare 2),
et R16 sera sauvegardé dans EEDR (eeprom data register). Modifier ces registres n'a pas
d'effet sur le reste du programme. Voici une technique qu'il m'impressionnerait beaucoup
de voir un compilateur employer…
Cela donne ceci:
ISR(INT0_vect, ISR_NAKED)
{
asm volatile(
" out 0x1D, r16 ; EEDR \n"
" in r16, 0x23 ; OCR2 \n"
" sbis 0x10, 2 ; PIND2 \n"
" in r16, 0x09 ; UBRLL \n"
" out 0x15, r16 ; PORTC \n"
" in r16, 0x1D ; EEDR \n"
" reti \n"
::);
}
Quelques cycles de sauvés et le temps de réponse tourne autour de 800 nanosecondes.
Et l'adaptateur semble fiable. Mais nous sommes encore trop près du seuil de
fiabilité à mon goût. Parfait car il est possible de faire mieux!
Interruption plus rapide (code directement dans le vecteur)
La table de vecteur est par défaut à l'adresse 0x0000 de la flash. Lorsqu'une
routine d'interruption est implémentée, une instruction rjmp est mise
en place pour aller l'exécuter: (__vecteur_1 correspond ici à la routine pour INT0)
Nous perdons donc le temps d'une
instruction rjmp (2 cycles) avant que notre code soit exécuté. Mais comme
je sais que INT0 est la seule interruption dont j'ai besoin, je sais également
que je peux mettre le code de l'interruption directement à l'adresse 0x0002 pour
qu'elle s'exécute plus tôt!
Il est possible de déplacer le vecteur d'interruption de l'adresse 0x0000 à celle
de la section bootloader. L'adresse exacte dépends de la configuration des « fuses ».
Dans mon cas, l'adresse est 0x1800 (mot 0xc00).
J'ai donc créé une section .boot en ajoutant -Wl,--section-start=.boot=0x1800 lors
du linkage. La fonction destinée à y être stockée est donc marquée
avec __attribute__((section(".boot"))).
Cette même fonction porte l'attribut « naked » et contient
le même code en assembleur que dans la section précédente. La seule différence est que
l'emplacement mémoire du premier vecteur (reset) doit être sauté. J'ai
utilisé une paire d'instructions nop. J'aurais aussi
pu faire commencer la section .boot deux octets plus loin.
Changer l'état de PORTC encore plus rapidement est possible en préparant toujours
la prochaine valeur à l'avance. L'interruption s'exécute à chaque changement
d'état. Si le test de l'état de PIND2 nous indique un niveau bas, la prochaine
exécution de l'interruption sera forcément avec un niveau haut! Je n'ai
pensé à cette optimisation qu'à cette étape-ci! C'est en réflichissant à
l'implémentation 6 boutons que je planifiais faire par la suite que
l'idée m'est venue..
J'ai aussi commencé à abuser du registre ICR1L pour stocker la valeur à présenter
sur PORTC au prochain cycle:
asm volatile(
" nop\nnop\n \n" // VECTOR 1 : RESET
" out 0x1D, r16 ; EEDR \n"
" in r16, 0x26 ; ICR1L \n"
" out 0x15, r16 ; PORTC \n"
// Preparation de ICR1L pour la prochaine transition
" in r16, 0x09 ; UBRLL \n"
" sbis 0x10, 2 ; PIND2 \n"
" in r16, 0x23 ; OCR2 \n"
" out 0x26, r16 ; ICR1L \n"
" in r16, 0x1D ; EEDR \n"
" reti \n"
::);
Dans le code ci-dessus, je trouvais dommage de devoir conserver
r16, bien que l'instruction out ne s'éxécute qu'en un cycle... J'ai
alors eu l'idée d'utiliser le registre r1, aussi connu en tant que
__zero_reg__, un registre que gcc garde toujours à zéro.
Puisqu'il s'agit d'une routine d'interruption s'exécutant sans
que d'autres interruptions puissent survenir, le __zero_reg__ peut
être modifié sans problèmes, pourvu que sa valeur de zéro soit
restaurée à la fin. Comme nous en connaissons la valeur, nul
besoin d'en faire une copie pour la restaurer! Il faut toutefois
faire attention. L'instruction clr a un effet sur les flags,
alors il faudrait sauvegarder SREG.. Il n'est pas non plus possible
d'utiliser ldi pour charger une valeur 0 directement car
ldi demande un registre >16 (__zero_reg__ est r1). J'utilise
donc lds pour mettre __zero_reg__ à zéro à partir de la mémoire.
uint8_t zero = 0;
asm volatile(
" nop\nnop\n \n" // VECTOR 1 : RESET
" in __zero_reg__, 0x26 ; ICR1L \n"
" out 0x15, __zero_reg__ ; PORTC \n"
// Now, let's prepare for the next transition.
" in __zero_reg__, 0x09 ; UBRLL \n"
" sbis 0x10, 2 ; PIND2 \n"
" in __zero_reg__, 0x23 ; OCR2 \n"
" out 0x26, __zero_reg__ ; ICR1L \n"
" lds __zero_reg__, zero \n"
" reti \n"
::);
Temps de réaction: de 490ns à 630ns. Dans le graphique ci-contre, la trace du bas représente le signal SELECT. La trace du haut tombe à zéro pour transmettre l'état du bouton START appuyé après un temps moyen de 560ns. Notez qu'un cycle du CPU correspond à 62.5ns. L'instabilité correspond donc environ à 3 cycles et dépends de la phase de l'horloge et du front sur SELECT, mais aussi de l'instruction présentement en cours d'exécution dans la boucle principale. (Les instructions sous plusieurs cycles doivent finir avant que la routine d'interruption s'exécute).
Il demeurerait toutefois possible de sauver encore un cycle en réservant un registre (r0 à r31)
qui servirait à stocker la valeur suivante. L'instruction in initiale pourrait
simplement être retirée. Mais à ce point-ci, la performance me semble assez bonne pour en
rester là.
Suite et conclusion
À partir de ce point, afin d'apparaître en tant que manette Genesis à 6 boutons aux yeux de
la console, le code a été modifié pour répondre différentes valeurs à une série d'impulsion
sur SELECT. L'accès conditionel à OCR2 et UBRRL pour connaître la prochaine valeur à présenter
a donc été remplacé par des accès mémoire. Mais grâce aux optimisations présentées ici,
le temps de réponse n'a pas augmenté.
Si vous désirez jeter un œil au code final, je vous invite à le télécharger via
la page du projet.