Les interruptions
Au chapitre 8, une interruption nous a servi d'horloge. Ce chapitre va plus loin : les interruptions de chaque machine, ce qu'on en fait dans un jeu, et surtout les erreurs qui les rendent dangereuses. Chaque erreur a été reproduite et mesurée.
Rappel : qu'est-ce qu'une interruption ?
Une interruption est un signal du matériel qui suspend le programme à n'importe quel moment, pour exécuter un petit morceau de code, le gestionnaire. Le programme reprend ensuite exactement là où il en était, sans s'apercevoir de rien.
unit {
ubyte images;
main() {
while (1) {
// le programme principal
}
}
interrupt -> irq { // le gestionnaire : appelé à chaque image
.images++;
}
}
Ce « n'importe quel moment » fait toute la puissance des interruptions, et tous leurs
pièges : le gestionnaire peut tomber au milieu d'une ligne de main(), entre
deux instructions du processeur.
Ce que fait interrupt -> pour vous
En assembleur, mettre en place une interruption est un travail minutieux, différent sur chaque machine. Il faut :
- placer l'adresse du gestionnaire là où le processeur la cherche (un vecteur), ou derrière celle du système ;
- sauvegarder les registres du processeur à l'entrée, et les rétablir à la sortie ;
- dire au circuit qui a déclenché l'interruption qu'elle a été vue (l'acquitter), sinon elle se redéclenche aussitôt ;
- autoriser la source dans le circuit (le circuit vidéo, le minuteur…), puis dans le processeur ;
- et tout cela dans le bon ordre : une interruption autorisée trop tôt peut tomber sur un gestionnaire qui n'est pas encore prêt.
En Simple8, il suffit d'écrire interrupt -> irq { … }. Le compilateur et
le code de démarrage de la machine (boot.asm) se chargent du reste :
- ils branchent le gestionnaire, après
initContext(), et n'autorisent que les interruptions qui en ont un ; - ils sauvegardent les registres, acquittent la source, puis appellent le gestionnaire. Sur C64, par exemple, le démarrage sauve A, X et Y, lit et acquitte le circuit vidéo et le minuteur, compte les images, puis appelle le gestionnaire ;
- ils s'adaptent à chaque machine : sur NES, le démarrage met aussi l'écran à jour (sprites,
palettes, défilement) avant d'appeler
nmi; sur Game Boy,main()autorise elle-même dans le registrerIEles interruptions qui ont un gestionnaire ; avec des contextes, chaque contexte installe les siens.
Le programmeur n'écrit que ce qui le concerne : ce que doit faire le gestionnaire. C'est l'un des aspects où Simple8 simplifie le plus la programmation de ces machines.
Les interruptions de chaque machine
Chaque machine a ses propres sources d'interruption, et Simple8 leur garde leur nom. La plus utile est partout celle de l'image, envoyée par le circuit vidéo à chaque image affichée.
| Machine | L'image | Autres interruptions | À prévoir |
|---|---|---|---|
| Commodore 64 | irq, 50 par seconde | timer, nmi (la touche RESTORE), et irq à une ligne choisie (raster) | addlib event.irq; |
| PC Engine | irq1, 60 par seconde | timer, irq2, nmi, et irq1 à une ligne choisie (raster) | addlib event.irq1;, event.timer… |
| NES | nmi, 60 par seconde | irq de la cartouche, toutes les n lignes | rien |
| Game Boy | vblank, environ 60 par seconde | stat, timer, serial, joypad | l'écran allumé : sinon, pas de vblank |
| Amstrad CPC | int, 50 par seconde | nmi | rien |
| Mega Drive | vblank, 60 par seconde (50 en Europe) | hblank, ext, les trap | addlib system.system; |
| Amiga 500 | vblank (niveau 3), 50 par seconde | trap0 à trap15, error | rien |
Un nom qui n'existe pas sur la machine est refusé : sur Amstrad CPC, par exemple,
interrupt -> irq donne Valid interrupts: INT, NMI.
À quoi servent-elles ?
| Usage | Pourquoi une interruption ? |
|---|---|
| Mesurer le temps | l'interruption de l'image tombe à intervalles fixes : compter les images donne une horloge exacte, quelle que soit la charge du programme |
| Rythmer le jeu | attendre l'image suivante avant de calculer la suivante : le jeu tourne à la même vitesse partout |
| Modifier l'écran au bon moment | pendant le retour du faisceau en haut de l'écran, rien n'est affiché : on peut changer les couleurs ou déplacer les sprites sans que cela se voie |
| Jouer la musique | une note par image, à la cadence exacte, même quand le jeu ralentit |
| Lire la manette | une fois par image, toujours au même moment |
| Les effets raster | une interruption à une ligne précise de l'écran permet de changer une couleur ou un défilement en cours d'image |
Un chronomètre sur six machines
Le premier usage, mesurer le temps, s'écrit de la même façon partout. Le partage des rôles
est la clé : le gestionnaire compte, main()
affiche.
// Interruptions : un chronomètre (Commodore 64)
architecture commodore.c64;
addlib event.irq; // l'interruption de l'image
addlib vic.base;
addlib vic.screentext;
unit {
const ubyte IMAGES_PAR_SECONDE = 50; // un C64 européen (PAL)
ubyte images; // écrit par l'interruption
ubyte secondes; // écrit par l'interruption, lu par main()
initContext() {
vic.initScreen();
vic.setBorderColor(vic.BLACK);
vic.setBackgroundColor(vic.BLUE);
vic.clearScreen();
vic.setFontColor(vic.WHITE);
vic.setCursor(13, 10);
vic.print("CHRONOMETRE");
}
main() {
ubyte affichees = 255; // les secondes déjà affichées
while (1) {
if (.secondes != affichees) { // seulement quand elles ont changé
affichees = .secondes;
vic.setCursor(18, 12);
vic.putChar((ubyte)('0' + affichees / 10));
vic.putChar((ubyte)('0' + affichees % 10));
}
}
}
// À chaque image : compter, et rien d'autre
interrupt -> irq {
.images++;
if (.images == .IMAGES_PAR_SECONDE) {
.images = 0;
.secondes++;
}
}
}
- Le gestionnaire ne fait que deux choses : compter les images et, toutes les 50, les secondes. Il est très court.
main()ne réécrit les secondes à l'écran que lorsqu'elles ont changé. L'affichage, plus lent, reste hors de l'interruption.- Les deux variables partagées sont des
ubyte: nous verrons plus bas pourquoi c'est important.
Sur les autres machines
Seuls changent le nom de l'interruption, le nombre d'images par seconde, et ce qu'il faut prévoir pour qu'elle soit appelée :
| Machine | Le gestionnaire | Images par seconde | À prévoir |
|---|---|---|---|
| Commodore 64 | interrupt -> irq | 50 | addlib event.irq; |
| PC Engine | interrupt -> irq1 | 60 | addlib event.irq1; |
| NES | interrupt -> nmi | 60 | rien |
| Game Boy | interrupt -> vblank | environ 60 | allumer l'écran : writeMem(hw.rLCDC, hw.LCDCF_ON | hw.LCDCF_BGON); |
| Amstrad CPC | interrupt -> int | 50 | rien |
| Amiga 500 | interrupt -> vblank | 50 | rien |
// Game Boy : le même gestionnaire, et l'écran à allumer
main() {
writeMem(hw.rLCDC, hw.LCDCF_ON | hw.LCDCF_BGON);
while (1) {
}
}
interrupt -> vblank {
.images++;
if (.images == 60) {
.images = 0;
.secondes++;
}
}
Les six versions ont tourné dans les émulateurs du kit de test de Simple8. Après 5 secondes d'images (250 sur C64, CPC et Amiga, 300 sur les autres), toutes affichent 4 : les toutes premières images passent pendant le démarrage, avant que le gestionnaire soit branché. Sur Game Boy, sans l'écran allumé, le compteur reste à 0.
La version PC Engine complète
// Interruptions : un chronomètre (PC Engine)
architecture nec.pce;
addlib event.irq1; // l'interruption de l'image
addlib sys.resource;
addlib vce.base;
addlib vdc.base;
addlib vdc.screentext;
unit {
const int POLICE = addResource("font.bin");
const ubyte IMAGES_PAR_SECONDE = 60;
ubyte images; // écrit par l'interruption
ubyte secondes; // écrit par l'interruption, lu par main()
initContext() {
int source;
int length;
vdc.setBATSize(vdc.BAT_32x32);
vdc.initScreen();
resource.mapToMPR2(.POLICE);
source = 0x4000 + resource.getOffset(.POLICE);
length = resource.getLength(.POLICE);
vdc.writeToVRAM(source, 0x1000, length);
vce.setPalette(0, 0, RGB(0, 0, 7));
vce.setPalette(0, 1, RGB(7, 7, 7));
vdc.clearScreen();
vdc.setFontColor(0);
vdc.setCursor(10, 12);
vdc.print("CHRONOMETRE");
}
main() {
ubyte affichees = 255;
while (1) {
if (.secondes != affichees) {
affichees = .secondes;
vdc.setCursor(15, 14);
vdc.putChar((ubyte)('0' + affichees / 10));
vdc.putChar((ubyte)('0' + affichees % 10));
}
}
}
// À chaque image : compter, et rien d'autre
interrupt -> irq1 {
.images++;
if (.images == .IMAGES_PAR_SECONDE) {
.images = 0;
.secondes++;
}
}
}
Un effet raster : plusieurs couleurs dans une image
L'écran se dessine ligne après ligne, de haut en bas. Une interruption à une ligne
précise permet donc de changer une couleur en cours d'image : le haut de
l'écran garde l'ancienne, le bas prend la nouvelle. Sur C64, la bibliothèque
vic.raster demande une interruption à une ligne, et
irq.getType() distingue celle de l'image de celle d'une ligne.
// Interruptions : quatre bandes de couleur (Commodore 64)
architecture commodore.c64;
addlib event.irq;
addlib vic.base;
addlib vic.raster;
addlib vic.screentext;
unit {
const ubyte COULEURS[] = { 6, 2, 5, 7 }; // bleu, rouge, vert, jaune
ubyte bande;
initContext() {
vic.initScreen();
vic.setBorderColor(vic.BLACK);
vic.clearScreen();
vic.setFontColor(vic.WHITE);
vic.setCursor(11, 12);
vic.print("UNE IMAGE, 4 FONDS");
irq.setRasterInt(50); // la première ligne à surveiller
}
main() {
while (1) {
}
}
interrupt -> irq {
if (irq.getType() == irq.VBLANK_INT) {
// le bas de l'écran : on prépare l'image suivante
.bande = 0;
vic.setBackgroundColor(.COULEURS[0]);
irq.setRasterInt(50);
} else {
// une ligne de l'écran atteinte : la couleur suivante
.bande++;
vic.setBackgroundColor(.COULEURS[.bande]);
if (.bande < 3) {
irq.setRasterInt((int)(50 + .bande * 50)); // la ligne suivante
}
}
}
}
- L'interruption de l'image (en bas de l'écran) remet la première couleur, et demande la ligne 50.
- À chaque ligne atteinte, le gestionnaire passe à la couleur suivante, et demande la ligne d'après.
- Le C64 n'a qu'une seule ligne de comparaison : c'est le gestionnaire qui la reprogramme à chaque bande.
Au pixel près. Le changement se produit quelques pixels après le début de la ligne : le temps que le processeur finisse son instruction et entre dans le gestionnaire. Les effets raster sont sensibles au moindre cycle, et une machine réelle ne se comporte pas toujours exactement comme un émulateur : sur PC Engine, par exemple, changer une couleur pendant l'affichage peut laisser des traces visibles sur la console, et pas dans les émulateurs. Un effet raster se vérifie donc sur la vraie machine.
Ce qu'il ne faut pas faire
Les erreurs d'interruption sont les plus traîtresses : le programme compile, marche presque toujours, et se trompe de temps en temps. Voici les principales, reproduites sur C64.
1. Un gestionnaire trop long
Si le gestionnaire dure plus d'une image, l'interruption suivante arrive alors qu'il n'a pas fini : elle est perdue. Mesure : un gestionnaire qui compte ses appels, avec une boucle de plus en plus longue, pendant le même temps (environ 115 images).
| Travail dans le gestionnaire | Appels reçus |
|---|---|
| une boucle de 10 tours | 108 |
| une boucle de 600 tours | 108 |
| une boucle de 1 200 tours | 66 : plus d'une image sur trois perdue |
| une boucle de 2 400 tours | 35 : deux images sur trois perdues |
Un chronomètre écrit ainsi retarderait, une musique ralentirait. Et pendant le
gestionnaire, main() ne tourne pas : il ne lui reste presque plus de temps. Le
gestionnaire doit rester court ; le travail lourd va dans main().
2. Attendre dans un gestionnaire
Pendant qu'un gestionnaire s'exécute, les interruptions sont masquées. Attendre l'image suivante depuis le gestionnaire, c'est donc attendre un événement qui ne peut plus arriver :
interrupt -> irq {
.appels++;
irq.waitFrame(); // ne revient jamais : le programme est bloqué
}
Mesuré : le gestionnaire est appelé une seule fois, puis plus rien ne
tourne, ni lui ni main(). Un gestionnaire ne doit jamais attendre : ni
waitFrame, ni boucle qui guette une variable, ni lecture de disquette.
3. Une fonction appelée à la fois par main() et par le gestionnaire
Au chapitre 5, nous avons vu que les paramètres et les variables locales d'une fonction
ont une adresse fixe. Si le gestionnaire appelle une fonction pendant que
main() est au milieu de la même fonction, il écrase ses variables.
uint somme(ubyte n) { // la somme de 1 à n
uint s = 0;
for (ubyte k = 1; k <= n; k++) {
s = s + k;
}
return s;
}
main() {
while (.essais < 3000) {
if (somme(20) != 210) { // 1 + 2 + … + 20 = 210
.erreurs++;
}
.essais++;
}
}
interrupt -> irq {
.pourIrq = somme(5); // la même fonction
}
Mesuré : 85 résultats faux sur 3 000, sans aucun message. Deux remèdes, vérifiés tous les deux (0 erreur) :
- une fonction à part pour le gestionnaire, avec ses propres
variables :
sommeIrq(); - masquer les interruptions le temps de l'appel dans
main():
irq.disableGlobal(); // plus d'interruption...
uint r = somme(20);
irq.enableGlobal(); // ... puis elles reprennent
Le masquage retarde l'interruption de quelques instants : il doit rester très court.
Attention aussi aux fonctions des bibliothèques : appeler print depuis le
gestionnaire et depuis main() pose le même problème.
4. Une variable de plusieurs octets
Sur une machine 8 bits, un uint se lit en deux fois : l'octet bas, puis
l'octet haut. Si l'interruption le modifie entre les deux, main() lit un
mélange de l'ancienne et de la nouvelle valeur. Avec un compteur qui passe de 255 à 256,
main() peut lire l'octet bas de 255 ($FF) puis l'octet haut de 256
($01) : il obtient 511.
Cela arrive rarement, ce qui rend l'erreur difficile à trouver. Les remèdes :
- des variables partagées en
ubyte, qui se lisent en une fois : c'est le choix du chronomètre ; - sinon, lire la variable entre
irq.disableGlobal()etirq.enableGlobal().
5. Modifier dans main() ce que modifie le gestionnaire
.images++ n'est pas une seule opération : lire, ajouter, écrire. Si
main() remet .images à 0 pendant que le gestionnaire est entre sa
lecture et son écriture, ou l'inverse, une des deux modifications est perdue. Règle
simple : une variable partagée a un seul écrivain. Dans le chronomètre,
seul le gestionnaire écrit images et secondes ; main()
ne fait que les lire.
6. Changer de contexte depuis un gestionnaire sur C64
Comme vu au chapitre 8, un sys.loadContext appelé depuis un gestionnaire
laisse, pour l'instant, le contexte suivant sans interruptions sur C64. Le gestionnaire
note la demande, main() change de contexte.
Les bonnes pratiques
| Règle | Pourquoi |
|---|---|
| Un gestionnaire court | sinon, des images sont perdues et main() n'a plus de temps |
Le gestionnaire note, main() décide | le travail lourd et les changements de contexte restent hors de l'interruption |
| Jamais d'attente dans un gestionnaire | les interruptions y sont masquées : le programme se bloque |
| Pas de fonction partagée | ses variables, à adresse fixe, seraient écrasées |
Des variables partagées en ubyte | elles se lisent et s'écrivent en une seule fois |
| Un seul écrivain par variable partagée | deux écrivains se marchent dessus |
| Masquer, mais brièvement | disableGlobal / enableGlobal protègent un accès, au prix d'un léger retard de l'interruption |
À retenir
- Une interruption suspend le programme à n'importe quel moment pour appeler un gestionnaire, puis le laisse reprendre.
- Chaque machine a ses interruptions :
irq(C64),irq1(PC Engine),nmi(NES),vblank(Game Boy, Mega Drive, Amiga),int(CPC). Certaines demandent une bibliothèque, ou l'écran allumé. - Elles servent à mesurer le temps, rythmer le jeu, modifier l'écran au bon moment, jouer la musique, lire la manette, et créer des effets raster.
- Un gestionnaire court, qui n'attend jamais, ne partage aucune fonction avec
main(), et des variables partagées enubyte, avec un seul écrivain.
À vous de jouer
- Ajoutez les minutes au chronomètre : qui doit les compter, le gestionnaire ou
main()?Voir la solutionMasquer la solution
C'est le gestionnaire qui doit compter les minutes. Il est le seul à écrire
secondes; pour passer à la minute suivante, il faut remettre les secondes à 0. Simain()le faisait, la variable aurait deux écrivains, et une remise à zéro pourrait se perdre au milieu d'un.secondes++(règle 5). Calculer les minutes dansmain()par.secondes / 60ne marche pas non plus : unubyterepasse à 0 après 255 secondes. Le gestionnaire compte donc les secondes de 0 à 59, puis les minutes ;main()ne fait toujours que lire et afficher.Une précaution de plus :
main()lit maintenant deux variables. Si l'interruption tombe entre les deux lectures, au passage de 0:59 à 1:00,main()peut lire 59 secondes puis 1 minute, et afficher 01:59 pendant une seconde. On lit donc les deux entreirq.disableGlobal()etirq.enableGlobal(): deux copies d'octets, le masquage est très bref.const ubyte IMAGES_PAR_SECONDE = 50; // un C64 européen (PAL) ubyte images; // écrit par l'interruption ubyte secondes; // écrit par l'interruption, lu par main() ubyte minutes; // écrit par l'interruption, lu par main() // … main() { ubyte affichees = 255; // les secondes déjà affichées ubyte s; ubyte m; while (1) { irq.disableGlobal(); // lire les deux d'un coup s = .secondes; m = .minutes; irq.enableGlobal(); if (s != affichees) { // seulement quand elles ont changé affichees = s; vic.setCursor(16, 12); vic.putChar((ubyte)('0' + m / 10)); vic.putChar((ubyte)('0' + m % 10)); vic.putChar(':'); vic.putChar((ubyte)('0' + s / 10)); vic.putChar((ubyte)('0' + s % 10)); } } } // À chaque image : compter, et rien d'autre interrupt -> irq { .images++; if (.images == .IMAGES_PAR_SECONDE) { .images = 0; .secondes++; if (.secondes == 60) { .secondes = 0; .minutes++; } } }Lancé dans VICE pendant 64 millions de cycles (environ 65 secondes), le chronomètre affiche
01:01. Sur PC Engine, seule la constante change : 60 images par seconde.
Après environ 65 secondes : une minute et une seconde. - Faites clignoter la bordure du C64 une fois par seconde, sans rien changer à
main().Voir la solutionMasquer la solution
Le gestionnaire est appelé à chaque image et sait déjà où en est la seconde : la variable
imagesva de 0 à 49. Il suffit d'allumer la bordure quand elle vaut 0, et de l'éteindre quand elle vaut 25, une demi-seconde plus tard.main()ne change pas. L'ajout reste très court : deux comparaisons et, deux fois par seconde, l'écriture d'un registre du circuit vidéo.Appeler
vic.setBorderColordepuis le gestionnaire est sans danger ici :main()ne l'appelle jamais (seulinitContext()l'utilise, avant que le gestionnaire soit branché). Ce n'est donc pas une fonction partagée.// À chaque image : compter, et allumer ou éteindre la bordure interrupt -> irq { if (.images == 0) { vic.setBorderColor(vic.RED); // début de la seconde : allumée } if (.images == 25) { vic.setBorderColor(vic.BLACK); // une demi-seconde plus tard : éteinte } .images++; if (.images == .IMAGES_PAR_SECONDE) { .images = 0; .secondes++; } }Vérifié dans l'émulateur du kit de test, en relevant la couleur de la bordure toutes les cinq images :
2222200000se répète, soit 25 images en rouge (2), puis 25 en noir (0). - Passez à huit bandes de couleur dans l'effet raster.
Voir la solutionMasquer la solution
Trois choses changent : la table des couleurs passe à huit valeurs, chaque bande ne fait plus que 25 lignes (les 200 lignes de la zone de texte divisées par 8), et le gestionnaire reprogramme la ligne suivante tant qu'il n'a pas atteint la huitième bande (
.bande < 7). La constanteHAUTEURévite de répéter le 25 à trois endroits : la ligne de la bande n + 1 est(n + 1) * HAUTEUR.// bleu, rouge, vert, jaune, violet, cyan, orange, bleu clair const ubyte COULEURS[] = { 6, 2, 5, 7, 4, 3, 8, 14 }; const ubyte HAUTEUR = 25; // 200 lignes / 8 bandes ubyte bande; // … interrupt -> irq { if (irq.getType() == irq.VBLANK_INT) { // le bas de l'écran : on prépare l'image suivante .bande = 0; vic.setBackgroundColor(.COULEURS[0]); irq.setRasterInt(.HAUTEUR); } else { // une ligne de l'écran atteinte : la couleur suivante .bande++; vic.setBackgroundColor(.COULEURS[.bande]); if (.bande < 7) { irq.setRasterInt((int)((.bande + 1) * .HAUTEUR)); // la ligne suivante } } }Le C64 n'ayant qu'une ligne de comparaison, le gestionnaire est maintenant appelé huit fois par image au lieu de quatre : c'est encore très peu.

Huit fonds dans une même image (VICE). Le petit décalage à gauche de certaines bandes est le temps d'entrée dans le gestionnaire. - Reprenez l'exemple de la fonction partagée, et vérifiez qu'avec
irq.disableGlobal()autour de l'appel, le nombre d'erreurs tombe à 0.Voir la solutionMasquer la solution
On reprend le programme tel quel, et on n'entoure que l'appel de
main(): pendant quesomme(20)calcule, l'interruption ne peut plus tomber, donc elle ne peut plus écrasers,knin. Le résultat est rangé dans une variable,r, puis testé une fois les interruptions rétablies : le test n'a pas besoin d'être protégé. Le gestionnaire, lui, ne change pas.main() { while (.essais < 3000) { irq.disableGlobal(); uint r = somme(20); irq.enableGlobal(); if (r != 210) { .erreurs++; } .essais++; } } interrupt -> irq { .pourIrq = somme(5); // la même fonction, appelée par l'interruption }Mesuré dans l'émulateur du kit de test : sans le masquage,
erreursvaut85pour 3 000 essais ; avec,erreursvaut0, etpourIrqvaut15(1 + 2 + … + 5). Le gestionnaire a bien continué à tourner : un compteur ajouté pour la mesure montre 127 appels pendant les 3 000 essais. Il a seulement été retardé, de la durée d'unsomme(20)au plus.
