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-$00FF | la page zéro : accès rapides, variables fast |
$0801-$5FFF | le programme résident |
$6000-$9FFF | le contexte chargé |
$A000-$BFFF | les variables |
$C000-$C3E7 | l'écran de texte : un octet par case |
$D000-$DFFF | le matériel : vidéo ($D000), son ($D400), couleurs de l'écran ($D800) |
| PC Engine · HuC6280 | |
|---|---|
$0000-$1FFF | le matériel : processeur vidéo ($0000), couleurs ($0400), son ($0800) |
$2000-$3FFF | la mémoire vive : page zéro, pile, variables |
$4000-$DFFF | des banques de la cartouche : ressources, contextes |
$E000-$FFFF | le programme résident |
| NES · 2A03 | |
|---|---|
$0000-$07FF | la mémoire vive, 2 Ko : page zéro, pile, sprites ($0200), variables |
$2000-$2007 | les registres du processeur vidéo (PPU) |
$4000-$4017 | le son, et les manettes ($4016) |
$6000-$7FFF | la mémoire vive de la cartouche : les variables en plus |
$8000-$BFFF | le contexte chargé |
$C000-$FFFF | le programme résident |
| Game Boy · SM83 | |
|---|---|
$0000-$3FFF | la banque 0 de la cartouche : le programme résident |
$4000-$7FFF | la banque du contexte chargé |
$8000-$9FFF | la mémoire vidéo : tuiles, et plans de l'écran ($9800) |
$C000-$DFFF | la mémoire vive : variables, pile |
$FF00-$FF7F | les registres du matériel |
$FF80-$FFFE | la mémoire haute : variables fast |
| Amstrad CPC · Z80 | |
|---|---|
$0040-$3DFF | les variables |
$3E00-$3FFF | la pile |
$4100-$6FFF | le programme résident |
$7000-$9CFF | le contexte chargé |
$A600-$BFFF | le système de la machine, qui lit la disquette |
$C000-$FFFF | l'é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 :
- sur la PC Engine et la NES, l'image n'est pas dans la mémoire du processeur : elle est dans une mémoire vidéo à part, qu'on atteint par les registres du processeur vidéo ;
- sur l'Amstrad CPC, le matériel n'a pas d'adresse du tout : le Z80 lui parle par des ports, avec des instructions spéciales (voir ci-dessous).
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 $D020Une 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'écran | Cycles | Durée |
|---|---|---|
| assembleur écrit simplement : un pointeur avancé case par case | 25 663 | 26 ms |
Simple8, quatre pointeurs fast | 8 309 | 8,4 ms |
| assembleur optimisé à la main : quatre bandes, adressage indexé | 6 412 | 6,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'écran | Cycles | Par rapport au meilleur assembleur |
|---|---|---|
compteur ubyte, writeMem(0xC000 + i, …) | 28 767 | 4,5 fois plus lent |
compteur uint, writeMem(0xC000 + i, …) | 49 369 | 7,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 :
- les calculs sur 16 ou 32 bits : chaque opération est refaite 2 ou 4 fois (chapitre 3) ;
- les multiplications et les divisions : le 6502 ne sait pas les faire, elles deviennent de petits programmes de dizaines d'instructions ;
- un appel de fonction dans une boucle serrée : chaque appel copie ses paramètres, saute à la fonction et en revient ;
- un code dont la durée doit être exacte, au cycle près : Simple8 ne garantit pas le nombre de cycles de ce qu'il produit.
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 × 40 | Cycles par calcul |
|---|---|
par une multiplication, y * 40 | 82 |
par une table, .LIGNES[y] | 10 |
par des décalages, ((uint)y << 5) + ((uint)y << 3) | 112 |
- La table est 8 fois plus rapide que la multiplication : un meilleur algorithme gagne plus que n'importe quel optimiseur. Il coûte 50 octets de ROM.
- Les décalages, une astuce classique en assembleur (
40 = 32 + 8), sont ici plus lents que la multiplication : en Simple8, ils portent sur 16 bits et se font bit par bit. Une astuce d'une langue ne se transpose pas toujours dans une autre.
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 :
- un compteur de boucle
ubyte, quand il ne dépasse pas 255 : il tient dans un registre du processeur ; - un pointeur
fastpour parcourir la mémoire, plutôt qu'une adresse recalculée à chaque tour ; - une table constante plutôt qu'un calcul répété, surtout une multiplication ;
- mesurer, et lire le code produit dans
asm/main.asm: c'est le seul moyen de savoir ce qui est réellement rapide.
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 :
- pour utiliser une instruction propre au processeur, comme
ST0ouTIAsur la PC Engine ; - pour un code dont la durée doit être exacte, par exemple un effet synchronisé avec le balayage de l'écran ;
- pour la boucle qui, mesure à l'appui, consomme le plus de temps.
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.
- A, l'accumulateur : c'est là que se font les calculs ;
- X et Y : des compteurs, et des décalages pour parcourir la mémoire.
Quelques instructions
| Instruction | Effet | Exemple |
|---|---|---|
LDA, LDX, LDY | charger une valeur dans A, X ou Y | LDA #2 : A vaut 2 |
STA, STX, STY | ranger A, X ou Y en mémoire | STA $D020 : la bordure prend la couleur A |
INX, DEX | ajouter ou retirer 1 à X | |
CLC puis ADC | additionner à A | CLC ADC #3 : A = A + 3 |
AND | « et » bit à bit avec A | AND #$0F : garder les 4 bits du bas |
TXA | copier X dans A | |
CPX | comparer X à une valeur | CPX #4 |
BNE | sauter si le résultat précédent n'est pas nul | BNE boucle |
Valeur ou adresse ?
#devant un nombre en fait une valeur :LDA #2met 2 dans A.- Sans
#, c'est une adresse :LDA $D020lit la case$D020. $écrit un nombre en hexadécimal. Dans un bloc_asm, on écrit$D020; dans le reste du code Simple8,0xD020.$C000,Xest l'adressage indexé : l'adresse$C000plus la valeur de X. En faisant varier X, une seule instruction parcourt 256 cases.
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
}
}
- Chaque instruction se termine par un point-virgule. Sans lui, la compilation s'arrête.
- Les commentaires s'écrivent avec
//, comme ailleurs. - Une ligne qui finit par
:est une étiquette, un nom donné à un endroit du code, pour y sauter. Elle est propre à sa fonction : deux fonctions peuvent avoir chacune leur étiquetteboucle.
_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 :
| Variable | Dans _asm | Exemple |
|---|---|---|
| locale, ou paramètre de la fonction | \nom | LDA \code; |
| de la unit | son étiquette : Unit_nom | LDA Main_score; |
| d'une autre unit | Unit.nom | LDA Joueur.x; |
octet haut d'un uint | +1 | LDA 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 :
- Le HuC6280 de la PC Engine ajoute des instructions au 6502, comme
STZ(ranger un zéro) ouST0,ST1,ST2(écrire dans le processeur vidéo). Sur C64, elles sont refusées :Unknown mnemonic 'STZ' for architecture MOS 6510 (Commodore 64). - Le Z80 (Amstrad CPC), le SM83 (Game Boy) et le 68000 (Mega Drive, Amiga) ont des
assembleurs entièrement différents. Un bloc
_asmn'est donc jamais portable : c'est pour cela qu'une bibliothèque qui en contient déclare son architecture (chapitre 7).
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;
}
}
}
remplirEcranreçoit le caractère dans son paramètrecode, et le lit avec\code.- L'écran fait 1 000 cases, de
$C000à$C3E7. X va de 0 à 255 : quatre bandes de 256 cases couvrent l'écran, la dernière commençant à$C2E8pour finir pile à$C3E7. BNE bouclerecommence tant que X n'est pas revenu à 0 : après 255,INXle ramène à 0, comme le débordement du chapitre 3.colorierEcranécrit dans la mémoire des couleurs, à partir de$D800: la couleur de chaque case est X modulo 16.- L'écran du C64 est en
$C000dans l'organisation que Simple8 utilise par défaut. Si un programme déplace l'écran, ces adresses changent.
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.
Un cas particulier : la Mega Drive et ses deux processeurs
La Mega Drive a deux processeurs, qui travaillent en même temps :
- un 68000, à 7,6 MHz, qui calcule sur 16 et 32 bits : c'est lui qui fait tourner le jeu ;
- un Z80, à 3,58 MHz, avec sa propre mémoire de 8 Ko : il sert surtout au son.
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;
}
}
}
}
coprocessor Son : zilog.z80 { … }déclare un programme pour le Z80, et ses units. Le compilateur le compile à part, dansasm/coproc_Son/, puis le range dans la cartouche ; au démarrage, il est copié dans la mémoire du Z80 et lancé.- La unit principale est du 68000 : son bloc
_asmutilise ses instructions et ses registres (D0). Ici, il calcule 5 + 2 et range 7 danscompteur. Sonest du Z80 : son bloc_asmutilise le registreAdu Z80, et compte les demandes reçues.- Les deux processeurs ne s'appellent pas l'un l'autre. Ils communiquent par les
variables
shareddu Z80, comme par une boîte aux lettres : le 68000 dépose une demande (Son.demande = 1), le Z80 la traite et la remet à 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 ?
| 68000 | Z80 | |
|---|---|---|
| Vitesse | 7,6 MHz, calculs sur 16 et 32 bits | 3,58 MHz, calculs sur 8 bits |
| Mémoire | la cartouche et 64 Ko de mémoire vive | 8 Ko pour tout : code, données, pile |
| Pour… | le jeu : règles, graphismes, calculs | le son : musique et bruitages, qui jouent sans prendre de temps au jeu |
| Bibliothèques | vdp, 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
- La mémoire est une suite de cases numérotées ; la carte mémoire dit ce qu'il y a à chaque adresse : RAM, programme, ou matériel.
- On commande le matériel en écrivant dans ses registres, à des adresses fixes ; les bibliothèques le font pour nous.
readMemetwriteMemlisent et écrivent un octet à n'importe quelle adresse, sans aucune vérification.- Selon le cas et l'algorithme, Simple8 peut égaler l'assembleur, le dépasser, ou être nettement plus lent : connaître la machine et mesurer aident à bien l'écrire.
- Un bloc
_asmsert pour le matériel, le chronométrage exact et les boucles critiques ; chaque instruction s'y termine par un point-virgule. - Dans
_asm:\nompour une variable locale ou un paramètre,Unit_nompour une variable de la unit,Unit.nompour une autre unit. - Un bloc
_asmest propre à un processeur. Sur la Mega Drive, le processeur se choisit par unit : 68000 pour le programme principal, Z80 pour les units d'un bloccoprocessor.
À vous de jouer
- 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 avecwriteMem. 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'interruptionirq, 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); } } } }
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.
- Modifiez
colorierEcranpour 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 avecORA #$08, le « ou » bit à bit : on obtient 8 à 15. UnCLCsuivi d'unADC #8donnerait le même résultat, maisORAfait 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 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é.
- Écrivez en assembleur une fonction
viderEcran()qui écrit des espaces ($20) dans tout l'écran, et appelez-la à la place deremplirEcran: seul le titre reste.Voir la solutionMasquer la solution
viderEcranest une copie deremplirEcran, à 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 $20lirait 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.
colorierEcrans'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 queremplirEcran, qui n'est plus appelée, n'est pas recopiée dans le programme : le fichierasm/main.asml'indique par la ligne[UNUSED] Function Main_remplirEcran - skipped. - Ouvrez
asm/main.asmet comparez le code deremplirEcranà celui d'une boucle Simple8 qui fait la même chose avecwriteMem.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 unwriteMempar 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.asmdu 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 :
iest unuint: 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, deuxINC;- l'adresse
0xC000 + iest recalculée à chaque tour : une addition sur 16 bits, rangée dans la case de travailt0; - 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'écran Cycles Durée remplirEcran, en assembleur6 423 6,5 ms remplirEcranS8,writeMemet compteuruint51 375 52 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
ubyteet quatre pointeursfast, Simple8 descend à 8 309 cycles. C'est la façon d'écrire la boucle, plus que le langage, qui fait la différence.
