Simple8

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 :

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 :

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.

MachineL'imageAutres interruptionsÀ prévoir
Commodore 64irq, 50 par secondetimer, nmi (la touche RESTORE), et irq à une ligne choisie (raster)addlib event.irq;
PC Engineirq1, 60 par secondetimer, irq2, nmi, et irq1 à une ligne choisie (raster)addlib event.irq1;, event.timer…
NESnmi, 60 par secondeirq de la cartouche, toutes les n lignesrien
Game Boyvblank, environ 60 par secondestat, timer, serial, joypadl'écran allumé : sinon, pas de vblank
Amstrad CPCint, 50 par secondenmirien
Mega Drivevblank, 60 par seconde (50 en Europe)hblank, ext, les trapaddlib system.system;
Amiga 500vblank (niveau 3), 50 par secondetrap0 à trap15, errorrien

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 ?

UsagePourquoi une interruption ?
Mesurer le tempsl'interruption de l'image tombe à intervalles fixes : compter les images donne une horloge exacte, quelle que soit la charge du programme
Rythmer le jeuattendre l'image suivante avant de calculer la suivante : le jeu tourne à la même vitesse partout
Modifier l'écran au bon momentpendant 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 musiqueune note par image, à la cadence exacte, même quand le jeu ralentit
Lire la manetteune fois par image, toujours au même moment
Les effets rasterune 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++;
        }
    }
}
Écran du Commodore 64 : CHRONOMETRE 10
Le chronomètre sur Commodore 64.

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 :

MachineLe gestionnaireImages par secondeÀ prévoir
Commodore 64interrupt -> irq50addlib event.irq;
PC Engineinterrupt -> irq160addlib event.irq1;
NESinterrupt -> nmi60rien
Game Boyinterrupt -> vblankenviron 60allumer l'écran : writeMem(hw.rLCDC, hw.LCDCF_ON | hw.LCDCF_BGON);
Amstrad CPCinterrupt -> int50rien
Amiga 500interrupt -> vblank50rien
// 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++;
        }
    }
}
Écran de la PC Engine : CHRONOMETRE 35
Le chronomètre sur PC Engine.

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
            }
        }
    }
}
Écran du Commodore 64 : le fond en quatre bandes, bleu, rouge, vert et jaune
Quatre couleurs de fond dans une même image.

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 gestionnaireAppels reçus
une boucle de 10 tours108
une boucle de 600 tours108
une boucle de 1 200 tours66 : plus d'une image sur trois perdue
une boucle de 2 400 tours35 : 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) :

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 :

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èglePourquoi
Un gestionnaire courtsinon, des images sont perdues et main() n'a plus de temps
Le gestionnaire note, main() décidele travail lourd et les changements de contexte restent hors de l'interruption
Jamais d'attente dans un gestionnaireles interruptions y sont masquées : le programme se bloque
Pas de fonction partagéeses variables, à adresse fixe, seraient écrasées
Des variables partagées en ubyteelles se lisent et s'écrivent en une seule fois
Un seul écrivain par variable partagéedeux écrivains se marchent dessus
Masquer, mais brièvementdisableGlobal / enableGlobal protègent un accès, au prix d'un léger retard de l'interruption

À retenir

À vous de jouer

  1. 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. Si main() 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 dans main() par .secondes / 60 ne marche pas non plus : un ubyte repasse à 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 entre irq.disableGlobal() et irq.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.

    Le chronomètre du C64 affiche 01:01
    Après environ 65 secondes : une minute et une seconde.
  2. 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 images va 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.setBorderColor depuis le gestionnaire est sans danger ici : main() ne l'appelle jamais (seul initContext() 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 : 2222200000 se répète, soit 25 images en rouge (2), puis 25 en noir (0).

  3. 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 constante HAUTEUR é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 bandes de couleur sur l'écran du C64
    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.
  4. 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 que somme(20) calcule, l'interruption ne peut plus tomber, donc elle ne peut plus écraser s, k ni n. 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, erreurs vaut 85 pour 3 000 essais ; avec, erreurs vaut 0, et pourIrq vaut 15 (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'un somme(20) au plus.