Optimisation d'une routine d'interruption AVR pour l'émulation d'une manette Genesis

Intro

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 pinFonction
5+5 volt
8GND
7Signal de sélection (SELECT)
DB9 pinFonction quand SELECT=0Fonction quand SELECT=1
1D-Pad hautD-Pad haut
2D-Pad basD-Pad bas
30D-Pad gauche
40D-Pad droite
6Bouton ABouton B
9Bouton STARTBouton 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é:
ISR(INT0_vect)
{
	if (PIND & (1<<PIND2)) {
		PORTC = S1_PC;
	} else {
		PORTC = S0_PC;
	}   
}

Temps de réaction: 1.46uS. Beaucoup trop lent, ça ne fonctionne pas!

avr-objdump -d fichier.elf pour déassembler et voir ce que le compilateur a fait:
0000005e <__vector_1>:
5e:   1f 92           push    r1
60:   0f 92           push    r0
62:   0f b6           in      r0, 0x3f        ; 63
64:   0f 92           push    r0
66:   11 24           eor     r1, r1
68:   8f 93           push    r24
6a:   82 9b           sbis    0x10, 2 ; 16
6c:   03 c0           rjmp    .+6             ; 0x74 <__vector_1+0x16>
6e:   80 91 b3 00     lds     r24, 0x00B3
72:   02 c0           rjmp    .+4             ; 0x78 <__vector_1+0x1a>
74:   80 91 b4 00     lds     r24, 0x00B4
78:   85 bb           out     0x15, r24       ; 21  
7a:   8f 91           pop     r24
7c:   0f 90           pop     r0
7e:   0f be           out     0x3f, r0        ; 63
80:   0f 90           pop     r0
82:   1f 90           pop     r1
84:   18 95           reti

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.
ISR(INT0_vect, ISR_NAKED)
{
	asm volatile(
		"   push r16                    \n"
		"   lds r16, S1_PC              \n"
		"   sbis 0x10, 2    ; PIND2     \n"
		"   lds r16, S0_PC              \n"
		"   out 0x15, r16   ; PORTC     \n"
		"   pop r16                     \n"
		"   reti                        \n"
	::);
}

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)
00000000 <__vectors>:
0:   12 c0           rjmp    .+36            ; 0x26 <__ctors_end>
2:   2d c0           rjmp    .+90            ; 0x5e <__vector_1>
4:   2b c0           rjmp    .+86            ; 0x5c <__bad_interrupt>
....

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.

void fastint(void) __attribute__((naked)) __attribute__((section(".boot")));

void fastint(void)
{
	asm volatile(
		"   nop\nnop\n                  \n" // VECTOR 1 : RESET
		"   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"
	::);
}

Temps de réaction: Autour de 760nS. Pas mal!


Préparation de la prochaine transition à l'avance

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.