Simple8

Assembleur inline et mémoire

Jusqu'ici, les bibliothèques faisaient le lien avec le matériel. Ce chapitre ouvre le capot : comment la mémoire de la machine est organisée, comment on commande le matériel en écrivant à des adresses précises, et comment glisser de l'assembleur dans un programme Simple8 quand il faut aller plus vite.

La mémoire de la machine

Au chapitre 6, nous avons vu que la mémoire est une suite de cases d'un octet, numérotées : chaque case a une adresse. Sur une machine 8 bits, les adresses vont de $0000 à $FFFF, soit 65 536 cases.

Ces cases ne sont pas toutes de la même nature. Selon l'adresse, on trouve de la mémoire vive (RAM), la mémoire du programme (ROM), ou… le matériel lui-même. Le plan de ces zones s'appelle la carte mémoire. Voici celles des machines 8 bits que Simple8 sait programmer, telles qu'il les organise :

Commodore 64 · 6510
$0000-$00FFla page zéro : accès rapides, variables fast
$0801-$5FFFle programme résident
$6000-$9FFFle contexte chargé
$A000-$BFFFles variables
$C000-$C3E7l'écran de texte : un octet par case
$D000-$DFFFle matériel : vidéo ($D000), son ($D400), couleurs de l'écran ($D800)
PC Engine · HuC6280
$0000-$1FFFle matériel : processeur vidéo ($0000), couleurs ($0400), son ($0800)
$2000-$3FFFla mémoire vive : page zéro, pile, variables
$4000-$DFFFdes banques de la cartouche : ressources, contextes
$E000-$FFFFle programme résident
NES · 2A03
$0000-$07FFla mémoire vive, 2 Ko : page zéro, pile, sprites ($0200), variables
$2000-$2007les registres du processeur vidéo (PPU)
$4000-$4017le son, et les manettes ($4016)
$6000-$7FFFla mémoire vive de la cartouche : les variables en plus
$8000-$BFFFle contexte chargé
$C000-$FFFFle programme résident
Game Boy · SM83
$0000-$3FFFla banque 0 de la cartouche : le programme résident
$4000-$7FFFla banque du contexte chargé
$8000-$9FFFla mémoire vidéo : tuiles, et plans de l'écran ($9800)
$C000-$DFFFla mémoire vive : variables, pile
$FF00-$FF7Fles registres du matériel
$FF80-$FFFEla mémoire haute : variables fast
Amstrad CPC · Z80
$0040-$3DFFles variables
$3E00-$3FFFla pile
$4100-$6FFFle programme résident
$7000-$9CFFle contexte chargé
$A600-$BFFFle système de la machine, qui lit la disquette
$C000-$FFFFl'écran : 16 Ko de pixels

D'une machine à l'autre, on retrouve les mêmes zones : la mémoire vive pour les variables, le programme résident, la place du contexte chargé, et le matériel. Seules leurs adresses changent. Deux différences méritent d'être retenues :

Ces cartes sont simplifiées : la documentation de chaque machine les détaillera.

Le matériel se commande par la mémoire

Sur ces machines, on ne parle pas au matériel par des fonctions : on écrit des nombres à certaines adresses. Ces cases spéciales s'appellent des registres. Sur C64, la case $D020 n'est pas de la mémoire ordinaire : le nombre qu'on y écrit devient la couleur de la bordure de l'écran.

C'est exactement ce que fait vic.setBorderColor, vue au chapitre 2. Voici son code, dans la bibliothèque vic.base :

void setBorderColor(byte color) {
    _asm {
        LDA _params;        // le paramètre color
        STA $D020;          // écrit dans le registre de la bordure
    }
}

Les bibliothèques ne font rien d'autre : elles donnent des noms lisibles aux registres du matériel.

Le cas de l'Amstrad CPC. Le Z80 a deux espaces séparés : la mémoire, et des ports d'entrée-sortie, réservés au matériel. Les circuits du CPC (vidéo, couleurs, son) sont sur des ports, qu'on atteint par les instructions IN et OUT du Z80, dans un bloc _asm ou par ses bibliothèques. readMem et writeMem, eux, n'atteignent que la mémoire.

Lire et écrire en mémoire : readMem et writeMem

Sans quitter Simple8, deux fonctions lisent et écrivent un octet à n'importe quelle adresse :

writeMem(0xD020, 2);                    // C64 : la bordure en rouge
ubyte b = readMem(0xD020) & 0x0F;       // relire sa couleur : 2

Certains registres demandent plusieurs écritures. Sur PC Engine, pour changer une couleur, on indique d'abord son numéro au circuit des couleurs (adresses $0402 et $0403), puis sa valeur ($0404 et $0405) :

uint bleu = RGB(0, 0, 7);
writeMem(0x0402, 0);                    // la couleur numéro 0...
writeMem(0x0403, 0);
writeMem(0x0404, (ubyte)(bleu & 0xFF)); // ... reçoit l'octet bas
writeMem(0x0405, (ubyte)(bleu >> 8));   // ... puis l'octet haut : le fond devient bleu

Avec prudence. writeMem écrit où on le lui demande, sans vérifier : une adresse fausse peut écraser une variable, le programme, ou dérégler le matériel. On s'en sert pour un registre précis, en connaissant la carte mémoire.

Simple8 face à l'assembleur

Simple8 libère le programmeur du langage machine : on écrit des boucles, des variables et des fonctions, le compilateur choisit les instructions. Ses optimiseurs retravaillent ensuite le résultat : ils placent les compteurs dans les registres du processeur, évitent les calculs répétés, regroupent les instructions.

Mais les machines 8 bits sont pleines de contraintes : peu de registres, pas de multiplication, des calculs qui se font 8 bits à la fois. Simple8 fait de son mieux avec elles. Selon les cas, son code est aussi rapide que l'assembleur, plus rapide qu'un assembleur écrit simplement, ou nettement plus lent qu'un assembleur écrit par quelqu'un qui connaît la machine sur le bout des doigts. Voici des exemples de chaque cas, tous mesurés sur C64.

Ce qui marche bien

Les cas simples. Écrire une couleur dans le registre de la bordure, en Simple8 ou en assembleur, donne exactement les mêmes instructions :

writeMem(0xD020, 2); en Simple8

LDA #2
STA $D020

_asm { LDA #2; STA $D020; } en assembleur

LDA #2
STA $D020

Une boucle bien écrite. Remplir les 1 000 cases de l'écran avec un compteur ubyte et des pointeurs fast :

fast ubyte *p0;                     // quatre pointeurs en page zéro,
fast ubyte *p1;                     // un par quart de l'écran
fast ubyte *p2;
fast ubyte *p3;

.p0 = 0xC000;
.p1 = 0xC0FA;
.p2 = 0xC1F4;
.p3 = 0xC2EE;
for (ubyte i = 0; i < 250; i++) {
    .p0[i] = 0x66;
    .p1[i] = 0x66;
    .p2[i] = 0x66;
    .p3[i] = 0x66;
}

L'optimiseur en tire une boucle que ne renierait pas un programmeur en assembleur : le compteur i devient le registre Y, la valeur n'est chargée qu'une fois par tour, et les quatre écritures passent directement par les pointeurs :

boucle:
    LDA #102
    STA (Main_p0),Y
    STA (Main_p1),Y
    STA (Main_p2),Y
    STA (Main_p3),Y
    INY
    CPY #250
    BCC boucle
Remplir l'écranCyclesDurée
assembleur écrit simplement : un pointeur avancé case par case25 66326 ms
Simple8, quatre pointeurs fast8 3098,4 ms
assembleur optimisé à la main : quatre bandes, adressage indexé6 4126,5 ms

Ici, Simple8 est trois fois plus rapide qu'un assembleur écrit sans astuce, et 1,3 fois plus lent que le meilleur assembleur, qui exploite l'adressage indexé présenté plus bas.

Ce qui marche moins bien

La même boucle, écrite sans penser à la machine, est beaucoup plus lente :

Remplir l'écranCyclesPar rapport au meilleur assembleur
compteur ubyte, writeMem(0xC000 + i, …)28 7674,5 fois plus lent
compteur uint, writeMem(0xC000 + i, …)49 3697,7 fois plus lent

Rien n'y est faux : le compilateur recalcule simplement l'adresse sur 16 bits à chaque tour, parce que c'est ce que demande le programme. D'autres situations coûtent cher sur une machine 8 bits, quel que soit le compilateur :

L'algorithme d'abord

Le choix du langage n'est souvent pas le plus important : la façon de calculer compte davantage. Exemple : l'adresse de la ligne y de l'écran, $C000 + y × 40.

// par une multiplication : le 6502 ne sait pas multiplier, il faut la calculer
.adresse = 0xC000 + y * 40;

// par une table : les 25 résultats, calculés une fois pour toutes
const uint LIGNES[] = { 0, 40, 80, 120, 160, 200, 240, 280, 320, 360, 400, 440, 480,
                        520, 560, 600, 640, 680, 720, 760, 800, 840, 880, 920, 960 };
.adresse = 0xC000 + .LIGNES[y];
Calculer y × 40Cycles par calcul
par une multiplication, y * 4082
par une table, .LIGNES[y]10
par des décalages, ((uint)y << 5) + ((uint)y << 3)112

Connaître la machine

Simple8 dispense d'écrire en langage machine, pas de comprendre la machine. Savoir ce qu'elle fait vite (8 bits, page zéro, adressage indexé, tables) et ce qu'elle fait mal (16 et 32 bits, multiplications) aide à écrire du Simple8 efficace. Quelques règles :

Quand écrire de l'assembleur ?

Pour l'essentiel d'un programme, Simple8 suffit, et sa souplesse fait gagner bien plus de temps qu'elle n'en coûte. L'assembleur devient utile dans quelques cas :

C'est ce que font les bibliothèques de libs8/ : elles concentrent l'assembleur là où il compte, et le programme reste en Simple8.

Premiers pas en assembleur 6502

Le C64 a un processeur 6502 (sa variante, le 6510) ; la PC Engine, un HuC6280, qui en est une version enrichie. Leur assembleur est le même à la base.

Les registres du processeur

Le processeur ne calcule pas directement dans la mémoire : il travaille dans quelques cases internes, ses registres, de 8 bits chacun.

Quelques instructions

InstructionEffetExemple
LDA, LDX, LDYcharger une valeur dans A, X ou YLDA #2 : A vaut 2
STA, STX, STYranger A, X ou Y en mémoireSTA $D020 : la bordure prend la couleur A
INX, DEXajouter ou retirer 1 à X
CLC puis ADCadditionner à ACLC ADC #3 : A = A + 3
AND« et » bit à bit avec AAND #$0F : garder les 4 bits du bas
TXAcopier X dans A
CPXcomparer X à une valeurCPX #4
BNEsauter si le résultat précédent n'est pas nulBNE boucle

Valeur ou adresse ?

Le bloc _asm

Dans une fonction, un bloc _asm { … } contient des instructions du processeur, insérées telles quelles dans le programme :

void bordureRouge() {
    _asm {
        LDA #2;             // A = 2, le rouge
        STA $D020;          // la bordure
    }
}
_asm {
    LDX #0;
    LDA #0;
boucle:                     // une étiquette
    CLC;
    ADC #3;                 // A = A + 3
    INX;
    CPX #4;
    BNE boucle;             // recommencer tant que X n'est pas 4 : A vaut 12
}

Les variables dans un bloc _asm

Un bloc _asm peut lire et écrire les variables du programme. Chaque sorte de variable s'y écrit à sa façon :

VariableDans _asmExemple
locale, ou paramètre de la fonction\nomLDA \code;
de la unitson étiquette : Unit_nomLDA Main_score;
d'une autre unitUnit.nomLDA Joueur.x;
octet haut d'un uint+1LDA Main_grand+1;
octet bas, octet haut de son adresse#<, #>LDA #<Main_score;
unit {
    ubyte score = 5;
    ubyte a;
    ubyte b;

    main() {
        ubyte locale = 9;
        _asm {
            LDA Main_score;         // la variable score de la unit Main...
            STA Main_a;             // ... copiée dans a : 5
            LDA \locale;            // la variable locale...
            STA Main_b;             // ... copiée dans b : 9
        }
    }
}

Dans une unit, le nom seul ne suffit pas. LDA score; est laissé tel quel, et l'assembleur ne trouve rien : Undefined symbol: score. Écrivez l'étiquette complète, le nom de la unit, un souligné, puis le nom de la variable : Main_score. Ce sont aussi les noms visibles dans l'assembleur généré et dans le fichier .sym.

Un assembleur par processeur

Chaque processeur a son propre jeu d'instructions, et Simple8 vérifie chaque bloc _asm pour le processeur de la machine choisie :

Les cases de travail du compilateur. Pour ses calculs, Simple8 utilise des cases de la page zéro nommées t0 à t7. Un bloc _asm ne doit pas les modifier, sauf à savoir exactement ce qu'on fait.

Un exemple : un écran en damier

Ce programme remplit tout l'écran du C64 avec un caractère en damier, puis donne à chaque case une couleur, le tout par deux fonctions en assembleur. Simple8 prépare l'écran et écrit le titre.

// Assembleur inline et mémoire : un écran en damier (Commodore 64)
architecture commodore.c64;

addlib vic.base;
addlib vic.screentext;

unit {
    const ubyte DAMIER = 0x66;          // le code d'écran du caractère en damier

    main() {
        vic.initScreen();
        vic.setBorderColor(vic.BLACK);
        vic.setBackgroundColor(vic.BLACK);

        remplirEcran(.DAMIER);
        colorierEcran();

        vic.setFontColor(vic.WHITE);
        vic.setCursor(14, 12);
        vic.print(" ASSEMBLEUR ");
    }

    // Écrit le même caractère dans les 1 000 cases de l'écran ($C000 à $C3E7)
    void remplirEcran(ubyte code) {
        _asm {
            LDA \code;                  // le paramètre code
            LDX #0;
        boucle:
            STA $C000,X;                // 4 bandes de 256 cases, qui se chevauchent
            STA $C100,X;                // un peu pour finir pile à $C3E7
            STA $C200,X;
            STA $C2E8,X;
            INX;
            BNE boucle;                 // 256 tours : X revient à 0
        }
    }

    // Donne à chaque case une couleur de 0 à 15 ($D800, la mémoire des couleurs)
    void colorierEcran() {
        _asm {
            LDX #0;
        boucle:
            TXA;
            AND #$0F;                   // la couleur : X modulo 16
            STA $D800,X;
            STA $D900,X;
            STA $DA00,X;
            STA $DAE8,X;
            INX;
            BNE boucle;
        }
    }
}
Écran du Commodore 64 rempli d'un damier en 16 couleurs, avec ASSEMBLEUR au centre
L'écran rempli et colorié en assembleur.
La version PC Engine

Sur PC Engine, l'écran n'est pas dans la mémoire du processeur, mais dans la mémoire vidéo, qu'on atteint par les registres du processeur vidéo (VDC). Le HuC6280 a des instructions faites pour cela : ST0 choisit un registre du VDC, ST1 et ST2 y écrivent l'octet bas et l'octet haut.

// Assembleur inline et mémoire : un écran en damier (PC Engine)
architecture nec.pce;

addlib sys.resource;
addlib vce.base;
addlib vdc.base;
addlib vdc.screentext;

unit {
    const int font = addResource("font.bin");
    const uint COULEURS[] = {
        RGB(7, 0, 0), RGB(7, 4, 0), RGB(7, 7, 0), RGB(0, 6, 0),
        RGB(0, 6, 6), RGB(0, 2, 7), RGB(5, 0, 7), RGB(7, 7, 7)
    };

    main() {
        int source;
        int length;

        vdc.setBATSize(vdc.BAT_32x32);
        vdc.initScreen();
        resource.mapToMPR2(.font);
        source = 0x4000 + resource.getOffset(.font);
        length = resource.getLength(.font);
        vdc.writeToVRAM(source, 0x1000, length);
        vce.setPalette(0, 0, RGB(0, 0, 0));
        for (ubyte p = 0; p < 8; p++) {
            vce.setPalette(p, 1, .COULEURS[p]);     // palettes 0 à 7 : 8 couleurs
        }

        remplirEcran();

        vdc.setFontColor(7);
        vdc.setCursor(10, 13);
        vdc.print(" ASSEMBLEUR ");
    }

    // Remplit les 1 024 cases de la carte de l'écran (BAT, en mémoire vidéo à l'adresse 0)
    // avec le caractère '+', dans les palettes 0 à 7 tour à tour
    void remplirEcran() {
        _asm {
            ST0 #0;                     // registre 0 du VDC : l'adresse d'écriture...
            ST1 #0;
            ST2 #0;                     // ... 0, le début de la carte
            ST0 #2;                     // registre 2 : les données à écrire
            LDY #4;                     // 4 fois 256 cases
            LDX #0;
        boucle:
            ST1 #$2B;                   // octet bas de la case : la tuile du '+'
            TXA;
            AND #$07;                   // la palette : X modulo 8...
            ASL A;
            ASL A;
            ASL A;
            ASL A;                      // ... dans les 4 bits hauts
            ORA #$01;                   // + le haut du numéro de tuile ($12B)
            STA $0003;                  // octet haut : la case est écrite
            INX;
            BNE boucle;
            DEY;
            BNE boucle;
        }
    }
}
  • Chaque case de la carte de l'écran est un mot de 16 bits : le numéro de la tuile (12 bits) et celui de la palette (4 bits).
  • Le registre 0 du VDC reçoit l'adresse où écrire, le registre 2 les données. Après chaque mot écrit, l'adresse avance toute seule.
  • Deux compteurs, X et Y, font 4 × 256 = 1 024 tours : les 32 × 32 cases.
Écran de la PC Engine rempli de + en 8 couleurs, avec ASSEMBLEUR au centre
La carte de l'écran remplie en assembleur, sur PC Engine.

Un cas particulier : la Mega Drive et ses deux processeurs

La Mega Drive a deux processeurs, qui travaillent en même temps :

Leurs langages machine n'ont rien en commun : MOVE.W #5, D0 pour le 68000, LD A, 5 pour le Z80. Un même programme Simple8 contient pourtant du code pour les deux. Comment le compilateur sait-il quel assembleur utiliser pour un bloc _asm ?

Le processeur se choisit par unit

La réponse est dans main.s8p : les units du programme principal tournent sur le 68000 ; celles que l'on déclare dans un bloc coprocessor tournent sur le Z80. Chaque bloc _asm est donc écrit dans le langage du processeur de sa unit.

main.s8p

// Deux processeurs, deux assembleurs (Mega Drive)
architecture sega.megadrive;

coprocessor Son : zilog.z80 {
    addunit("Son.s8u");             // ce fichier est du Z80
}

unit {
    uint compteur;

    main() {
        _asm {
            MOVE.W #5, D0;          // du 68000 : la unit principale tourne sur le 68000
            ADD.W #2, D0;
            MOVE.W D0, Main_compteur;
        }
        Son.demande = 1;            // une variable partagée du Z80
        while (1) {
        }
    }
}

Son.s8u

// Son.s8u : le programme du Z80
unit Son {
    shared {
        ubyte demande;              // écrite par le 68000
        ubyte faites;               // lue par le 68000
    }

    main() {
        while (1) {
            if (.demande != 0) {
                _asm {
                    LD A, (Son_faites);     // du Z80 : cette unit tourne sur le Z80
                    INC A;
                    LD (Son_faites), A;
                }
                .demande = 0;
            }
        }
    }
}

Le compilateur vérifie chaque bloc avec le bon assembleur. Une instruction du Z80 dans la unit principale, ou du 68000 dans Son, est refusée :

Unknown mnemonic 'LD' for architecture Motorola 68000 (Mega Drive)
coprocessor Son: Unknown mnemonic 'MOVE.W' for architecture Zilog Z80 (Mega Drive)

Quel processeur pour quelle tâche ?

68000Z80
Vitesse7,6 MHz, calculs sur 16 et 32 bits3,58 MHz, calculs sur 8 bits
Mémoirela cartouche et 64 Ko de mémoire vive8 Ko pour tout : code, données, pile
Pour…le jeu : règles, graphismes, calculsle son : musique et bruitages, qui jouent sans prendre de temps au jeu
Bibliothèquesvdp, pad…sound.psg, sound.player, sound.mixer…

Le choix suit donc la répartition des tâches, plus que la vitesse : le Z80 est plus lent, mais il travaille en plus du 68000. Lui confier la musique, c'est libérer le 68000 pour le jeu. La documentation de la Mega Drive détaille la mise en route du Z80 et ses bibliothèques.

À retenir

À vous de jouer

  1. Avec writeMem, faites passer la bordure du C64 par ses 16 couleurs dans une boucle.
    Voir la solutionMasquer la solution

    Le registre de la bordure est à l'adresse $D020 : une boucle de 0 à 15 y écrit tour à tour chaque numéro de couleur avec writeMem. Mais une boucle seule ne suffit pas : elle irait si vite que l'œil ne verrait rien. Il faut attendre un peu entre deux couleurs, et le chapitre 8 nous a donné une vraie horloge : l'interruption irq, 50 fois par seconde. On attend ici 25 images, une demi-seconde, par couleur.

    // La bordure passe par les 16 couleurs, avec writeMem (Commodore 64)
    architecture commodore.c64;
    
    addlib event.irq;
    
    unit {
        ubyte images;                       // compté par l'interruption, 50 fois par seconde
    
        main() {
            while (1) {
                for (ubyte c = 0; c < 16; c++) {
                    writeMem(0xD020, c);    // le registre de la bordure
                    .images = 0;
                    while (.images < 25) {  // une demi-seconde par couleur
                    }
                }
            }
        }
    
        interrupt -> irq {
            .images++;
        }
    }

    Lancé dans l'émulateur, le programme montre bien la bordure en blanc, puis en rouge, en cyan, en violet… une couleur toutes les demi-secondes, puis le cycle recommence.

    Et sans l'attente ? Le résultat est surprenant, et instructif : la boucle change la couleur plusieurs fois pendant que l'image se dessine. La bordure se couvre alors de fines rayures, chaque couleur ne durant que quelques lignes de l'écran.

    architecture commodore.c64;
    unit {
        main() {
            while (1) {
                for (ubyte c = 0; c < 16; c++) {
                    writeMem(0xD020, c);
                }
            }
        }
    }
    La bordure du C64 couverte de fines rayures de toutes les couleurs
    Sans attente : la bordure change de couleur pendant que l'image se dessine.

    C'est le principe des effets de « barres de couleur » des démos, qui changent une couleur à une ligne précise de l'écran : le chapitre 12 y reviendra.

  2. Modifiez colorierEcran pour n'utiliser que les couleurs 8 à 15.
    Voir la solutionMasquer la solution

    Les couleurs 8 à 15 sont celles dont le bit 3 (la valeur 8) vaut 1. On garde donc les 3 bits du bas de X avec AND #$07, ce qui donne 0 à 7, puis on force le bit 3 avec ORA #$08, le « ou » bit à bit : on obtient 8 à 15. Un CLC suivi d'un ADC #8 donnerait le même résultat, mais ORA fait en une instruction ce que l'addition fait en deux.

        // Donne à chaque case une couleur de 8 à 15 ($D800, la mémoire des couleurs)
        void colorierEcran() {
            _asm {
                LDX #0;
            boucle:
                TXA;
                AND #$07;                   // X modulo 8 : de 0 à 7...
                ORA #$08;                   // ... plus 8 : de 8 à 15
                STA $D800,X;
                STA $D900,X;
                STA $DA00,X;
                STA $DAE8,X;
                INX;
                BNE boucle;
            }
        }
    Le damier du C64 en colonnes de couleurs plus pâles : orange, marron, rose, gris, vert clair, bleu clair
    Le damier en couleurs 8 à 15 : les teintes claires du C64.

    Le noir et le blanc ont disparu, et le motif a changé d'allure : les couleurs se répètent maintenant toutes les 8 cases. Une ligne de l'écran en compte 40, un multiple de 8 : chaque ligne commence donc par la même couleur, et les colonnes forment des bandes bien droites. Dans l'exemple du chapitre, le cycle de 16 couleurs se décalait de 8 d'une ligne à l'autre (40 = 2 × 16 + 8), d'où son aspect entrelacé.

  3. Écrivez en assembleur une fonction viderEcran() qui écrit des espaces ($20) dans tout l'écran, et appelez-la à la place de remplirEcran : seul le titre reste.
    Voir la solutionMasquer la solution

    viderEcran est une copie de remplirEcran, à une différence près : le caractère n'est plus un paramètre, mais la valeur fixe #$20, le code d'écran de l'espace. Le # est indispensable : sans lui, LDA $20 lirait la case mémoire $20, en page zéro, au lieu de charger la valeur 32.

        main() {
            vic.initScreen();
            vic.setBorderColor(vic.BLACK);
            vic.setBackgroundColor(vic.BLACK);
    
            viderEcran();                   // à la place de remplirEcran(.DAMIER)
            colorierEcran();
    
            vic.setFontColor(vic.WHITE);
            vic.setCursor(14, 12);
            vic.print(" ASSEMBLEUR ");
        }
        // Écrit une espace ($20) dans les 1 000 cases de l'écran ($C000 à $C3E7)
        void viderEcran() {
            _asm {
                LDA #$20;                   // le code d'écran de l'espace
                LDX #0;
            boucle:
                STA $C000,X;                // les mêmes 4 bandes que remplirEcran
                STA $C100,X;
                STA $C200,X;
                STA $C2E8,X;
                INX;
                BNE boucle;
            }
        }

    L'écran reste noir, avec seulement « ASSEMBLEUR » au milieu. colorierEcran s'exécute toujours, mais une espace n'a aucun pixel allumé : sa couleur ne se voit pas. On pourrait retirer cet appel sans rien changer à l'écran. Remarquez aussi que remplirEcran, qui n'est plus appelée, n'est pas recopiée dans le programme : le fichier asm/main.asm l'indique par la ligne [UNUSED] Function Main_remplirEcran - skipped.

  4. Ouvrez asm/main.asm et comparez le code de remplirEcran à celui d'une boucle Simple8 qui fait la même chose avec writeMem.
    Voir la solutionMasquer la solution

    Pour comparer, on ajoute au programme une version Simple8 de remplirEcran, la plus naturelle : un compteur de 0 à 999 et un writeMem par case. On l'appelle à la place de la version en assembleur.

        // La même chose en Simple8, avec writeMem
        void remplirEcranS8(ubyte code) {
            for (uint i = 0; i < 1000; i++) {
                writeMem(0xC000 + i, code);
            }
        }

    La version en assembleur, dans le asm/main.asm du programme du chapitre, est recopiée telle quelle : 6 instructions dans la boucle, qui tourne 256 fois.

    ; === Function: Main_remplirEcran ===
    Main_remplirEcran:
    ; <asm>
    	LDA _params+0
    	LDX #0
    Main_remplirEcran_boucle_1:
    	STA $C000,X
    	STA $C100,X
    	STA $C200,X
    	STA $C2E8,X
    	INX
    	BNE Main_remplirEcran_boucle_1
    ; </asm>
    	RTS
    ; === End: Main_remplirEcran ===

    La version Simple8 produit ceci (le compilateur l'a aussi recopiée directement dans main(), à l'endroit de l'appel : c'est la même boucle) :

    ; === Function: Main_remplirEcranS8 ===
    Main_remplirEcranS8:
    ; [Main:L40] Main_remplirEcranS8__FOR_1_i = #0
    	LDA #<0
    	STA Main_remplirEcranS8__FOR_1_i
    	STA Main_remplirEcranS8__FOR_1_i+1
    ; === Loop: _FOR_1 ===
    _FOR_1_start:
    ; Loop condition: Main_remplirEcranS8__FOR_1_i < #1000
    	LDA Main_remplirEcranS8__FOR_1_i+1
    	CMP #>1000
    	BCC _FOR_1_cond_ok
    	BEQ _FOR_1_cond_ok_skip1
    	JMP _FOR_1_end
    _FOR_1_cond_ok_skip1:
    	LDA Main_remplirEcranS8__FOR_1_i
    	CMP #<1000
    	BCC _FOR_1_cond_ok
    	JMP _FOR_1_end
    _FOR_1_cond_ok:
    ; [Main:L41] t0 = #49152 + Main_remplirEcranS8__FOR_1_i
    	LDA #<49152
    	CLC
    	ADC Main_remplirEcranS8__FOR_1_i
    	STA t0
    	LDA #>49152
    	ADC Main_remplirEcranS8__FOR_1_i+1
    	STA t0+1
    ; [L41] writeMem(t0, Main_remplirEcranS8_code)
    	LDA _params+0
    	LDY #0
    	STA (t0),Y
    ; Loop increment: Main_remplirEcranS8__FOR_1_i++
    	INC Main_remplirEcranS8__FOR_1_i
    	BNE _FOR_1_incr_skip
    	INC Main_remplirEcranS8__FOR_1_i+1
    _FOR_1_incr_skip:
    ; Loop end: _FOR_1
    	JMP _FOR_1_start
    _FOR_1_end:
    	RTS
    ; === End: Main_remplirEcranS8 ===

    Rien n'y est faux, mais chaque tour fait beaucoup plus de travail :

    • i est un uint : il vit en mémoire, sur deux octets, et non dans le registre X. Le comparer à 1000 demande deux comparaisons et plusieurs sauts, et l'augmenter, deux INC ;
    • l'adresse 0xC000 + i est recalculée à chaque tour : une addition sur 16 bits, rangée dans la case de travail t0 ;
    • l'écriture passe par ce pointeur, STA (t0),Y, alors que l'assembleur utilise l'adressage indexé STA $C000,X, qui fait le calcul d'adresse gratuitement ;
    • la boucle fait 1 000 tours, contre 256 pour l'assembleur, qui écrit 4 cases par tour.

    Mesuré sur le C64 (en retranchant le temps d'un programme qui ne fait que vic.initScreen()) :

    Remplir l'écranCyclesDurée
    remplirEcran, en assembleur6 4236,5 ms
    remplirEcranS8, writeMem et compteur uint51 37552 ms

    La version Simple8 est 8 fois plus lente : environ 51 cycles par case, contre 6,4. Ce n'est pas une fatalité : le chapitre montre qu'avec un compteur ubyte et quatre pointeurs fast, Simple8 descend à 8 309 cycles. C'est la façon d'écrire la boucle, plus que le langage, qui fait la différence.