Simple8

Types et variables

Un programme manipule des nombres : un score, une position, un nombre de vies. Ce chapitre montre comment les ranger dans des variables, choisir leur type, calculer avec, et passer d'un type à l'autre.

Les types

Chaque variable a un type, qui fixe sa taille en mémoire et les valeurs qu'elle peut prendre. Simple8 a six types de nombres entiers :

TypeTailleValeursPour…
ubyte8 bits (1 octet)0 à 255un compteur, une couleur, une coordonnée d'écran
byte8 bits (1 octet)-128 à 127une petite vitesse, positive ou négative
uint16 bits (2 octets)0 à 65 535un score, une adresse en mémoire
int16 bits (2 octets)-32 768 à 32 767une position qui peut sortir de l'écran
ulong32 bits (4 octets)0 à 4 294 967 295un très grand nombre
long32 bits (4 octets)environ ± 2 milliardsun très grand nombre, signé

Il n'y a ni char ni bool : un caractère ou un « vrai / faux » se range dans un ubyte. Il existe aussi un type string pour les chaînes de caractères ; les tableaux et les chaînes ont leur propre chapitre, le chapitre 6.

Huit, seize ou trente-deux bits

Les processeurs des machines rétro calculent huit bits à la fois. Un ubyte se traite en une seule opération ; un uint en demande deux, un ulong quatre. Voici ce que donne la même ligne, + 10, sur trois variables de types différents, dans l'assembleur généré pour le C64 :

.petit = .petit + 10; ubyte : 4 instructions

LDA Main_petit
CLC
ADC #<10
STA Main_petit

.grand = .grand + 10; uint : 7 instructions

LDA Main_grand
CLC
ADC #<10
STA Main_grand
LDA Main_grand+1
ADC #>10
STA Main_grand+1

.enorme = .enorme + 10; ulong : 13 instructions

LDA Main_enorme
CLC
ADC #<10
STA Main_enorme
LDA Main_enorme+1
ADC #>10
STA Main_enorme+1
LDA Main_enorme+2
ADC #0
STA Main_enorme+2
LDA Main_enorme+3
ADC #0
STA Main_enorme+3

La règle d'or : le plus petit type qui suffit. Un ubyte chaque fois que la valeur tient entre 0 et 255, un uint ou un int au-delà, un long seulement quand c'est indispensable.

Signé ou non signé

Les types sans u (byte, int, long) sont signés : ils acceptent les nombres négatifs, mais montent deux fois moins haut. Les types en u (unsigned) ne vont que de 0 vers le haut.

Une donnée de la machine (un octet de graphisme, une couleur, une adresse) n'est jamais négative : c'est un ubyte ou un uint. Le compilateur y veille, et refuse une valeur qui ne tient pas dans le type :

byte x = 200;      // erreur : un byte s'arrête à 127
Value out of range for x (-128..127) -> 200: use ubyte for 0..255

Écrire un nombre

FormeExempleValeur
Décimal250, -5250, -5
Hexadécimal0xD02053 280
Binaire0b10100000160
Caractère'A', '0'65, 48 (son code ASCII)

La notation $D020 des assembleurs n'est acceptée que dans un bloc _asm. Dans le code Simple8, on écrit 0xD020.

Déclarer une variable

Une déclaration donne le type, puis le nom, et éventuellement une valeur de départ. Elle se termine par un point-virgule, et ne déclare qu'une variable à la fois :

ubyte vies = 3;      // un type, un nom, une valeur de départ
uint score;          // sans valeur : elle vaut 0 au démarrage
int vx;
int vy;              // int vx, vy; n'est pas accepté

Il n'y a pas de variable globale : une variable appartient toujours à une unit ou à une fonction.

Les variables de la unit

Déclarées en tête de la unit, avant main(), elles existent pendant tout le programme. Dans une fonction, on les écrit avec un point devant : .score.

unit {
    uint score;                 // variable de la unit

    main() {
        .score = 100;           // le point : la variable de la unit
    }
}

Sans le point, le compilateur cherche une variable locale du même nom, ne la trouve pas, et s'arrête : Variable 'score' not found !

Les variables locales

Déclarées dans une fonction, elles n'appartiennent qu'à elle et s'écrivent sans point. Aucune autre fonction ne peut les lire.

main() {
    ubyte gemmes = 7;           // locale à main()
    .score = .score + gemmes;
}

La valeur de départ

Au chapitre précédent, boot.asm préparait la machine avant d'appeler main(). Il s'occupe aussi des variables :

Les constantes

Une valeur qui ne change jamais se déclare const. Elle se place en tête de la unit et s'écrit, elle aussi, avec un point :

unit {
    const uint POINTS_PAR_GEMME = 250;
    const uint BONUS = POINTS_PAR_GEMME * 4;    // calculée à partir d'une autre : 1000

    main() {
        .score = .POINTS_PAR_GEMME * 7;
    }
}

Par convention, les constantes s'écrivent en majuscules, les variables en minuscules.

Les variables rapides : fast

Sur les processeurs 6502 (C64, NES, PC Engine), les 256 premiers octets de la mémoire, la page zéro, s'atteignent plus vite que le reste. Le mot fast y place une variable de la unit :

fast ubyte image;    // lue à chaque image : en page zéro

Cette zone est petite et partagée : 206 octets sur les machines 6502, 127 sur la Game Boy (où fast utilise la mémoire haute). Les bibliothèques y prennent leur part, puis l'optimiseur y range lui-même les variables locales les plus utilisées dans les boucles.

Utiliser fast avec parcimonie. Chaque octet déclaré fast est un octet que l'optimiseur ne pourra plus donner à une variable de boucle. Pour débuter, on peut s'en passer : le compilateur fait déjà ce choix au mieux. Si la zone déborde, la compilation s'arrête sur Zero page overflow.

Calculer

Une affectation, =, range le résultat d'un calcul dans une variable :

.score = .POINTS_PAR_GEMME * gemmes;

Les opérations de calcul

Les quatre opérations, plus le reste de la division. Avec a qui vaut 17 et b qui vaut 5 :

OpérateurOpérationExempleRésultat
+additiona + b22
-soustractiona - b12
*multiplicationa * b85
/division entière : on garde la partie entièrea / b3
%reste de la division entière (on dit « modulo »)a % b2

17 divisé par 5 donne 3, et il reste 2 : 3 × 5 + 2 = 17. Le reste sert souvent à faire tourner une valeur en boucle : .image % 4 passe par 0, 1, 2, 3, puis revient à 0.

Comparer

Une comparaison répond par vrai ou faux :

OpérateurSe litExemple, avec a = 17 et b = 5
==est égal àa == b : faux
!=est différent dea != b : vrai
<est plus petit quea < b : faux
>est plus grand quea > b : vrai
<=est plus petit ou égal àa <= 17 : vrai
>=est plus grand ou égal àb >= 6 : faux

Attention à ne pas confondre ==, qui compare, et =, qui range une valeur. Les comparaisons servent à prendre des décisions et à répéter des instructions ; elles seront détaillées dans le chapitre sur les conditions et les boucles, avec les opérateurs logiques && (et), || (ou) et ! (non), qui les combinent.

Les opérations bit à bit

Dans la mémoire, un nombre est une suite de bits, des 0 et des 1. Un ubyte en a 8. On les numérote de droite à gauche : le bit 0 à droite vaut 1, le bit 1 vaut 2, le bit 2 vaut 4… jusqu'au bit 7, qui vaut 128. Le nombre est la somme des bits à 1 :

bit76543210
vaut1286432168421
1200001100

12 s'écrit 0b00001100 : les bits 2 et 3 sont à 1, et 4 + 8 = 12.

Les opérateurs bit à bit travaillent sur chaque bit séparément. Ils servent surtout à commander le matériel, dont les registres rangent plusieurs réglages dans un seul octet, un par bit. Avec p = 12 et q = 10 :

& et : 1 seulement si les deux bits sont à 1

p       0 0 0 0 1 1 0 0   12
q       0 0 0 0 1 0 1 0   10
p & q   0 0 0 0 1 0 0 0   8

| ou : 1 si l'un des deux bits au moins est à 1

p       0 0 0 0 1 1 0 0   12
q       0 0 0 0 1 0 1 0   10
p | q   0 0 0 0 1 1 1 0   14

^ ou exclusif : 1 si les deux bits sont différents

p       0 0 0 0 1 1 0 0   12
q       0 0 0 0 1 0 1 0   10
p ^ q   0 0 0 0 0 1 1 0   6

~ non : chaque bit est inversé

p    0 0 0 0 1 1 0 0   12
~p   1 1 1 1 0 0 1 1   243

<< décalage à gauche : les bits avancent vers la gauche

3        0 0 0 0 0 0 1 1   3
3 << 2   0 0 0 0 1 1 0 0   12

>> décalage à droite : les bits reculent vers la droite

12        0 0 0 0 1 1 0 0   12
12 >> 2   0 0 0 0 0 0 1 1   3

Décaler d'un bit vers la gauche multiplie par 2, vers la droite divise par 2 : x << 3 multiplie par 8. Le processeur le fait bien plus vite qu'une multiplication.

Les usages les plus courants :

Pour…On écritExemple
garder les 4 bits de droitex & 0x0F0b10110110 & 0x0F donne 0b00000110 (6)
mettre le bit 7 à 1x | 0x800b00000011 | 0x80 donne 0b10000011 (131)
inverser le bit 0x ^ 10b10110110 ^ 1 donne 0b10110111 (183)
multiplier par 8x << 33 << 3 donne 24

Modifier une variable

Plutôt que de répéter son nom, on peut modifier une variable directement :

.vies--;            // .vies = .vies - 1
.image++;           // .image = .image + 1
.score += 250;      // .score = .score + 250
.couleur <<= 1;     // .couleur = .couleur << 1

Tous les opérateurs ont leur forme composée : += -= *= /= %= &= |= ^= <<= >>=.

Le débordement

Une variable ne peut pas dépasser la valeur maximale de son type. Si un calcul la dépasse, elle repart de l'autre bout, comme un compteur kilométrique :

ubyte x = 250;
x = x + 10;         // 4, et non 260 : 255 est le maximum, on repart de 0

int y = 30000;
y = y + 10000;      // -25 536 : le maximum d'un int est 32 767

Aucune erreur ne le signale pendant l'exécution : c'est au développeur de choisir un type assez grand. C'est parfois voulu, pour un compteur d'images qui tourne en boucle par exemple.

Les conversions

Vers un type plus grand

Ranger une valeur dans un type plus grand se fait tout seul, sans perte. Un nombre négatif reste négatif :

byte b = -3;
int a = b;          // -3

Vers un type plus petit : le cast

Dans l'autre sens, une partie de la valeur peut se perdre. Le compilateur demande donc de l'écrire explicitement, avec le type voulu entre parenthèses : c'est un cast.

int a = 300;
byte c = a;         // erreur
byte d = (byte)a;   // accepté : d vaut 44
Cannot assign int to byte without explicit cast. Use: c = (byte)expression;

Le cast garde les bits du bas et jette les autres : 300 s'écrit 0x012C sur deux octets, il ne reste que l'octet bas, 0x2C, soit 44.

Les calculs se font en int

Dans un calcul, les byte et les ubyte sont d'abord convertis en int. Le résultat d'un calcul sur des petits types est donc un int, et peut être négatif :

ubyte u1 = 1;
ubyte u2 = 2;
int d = u1 - u2;            // -1

C'est pour cela qu'il faut un cast pour ranger un tel calcul dans un petit type, comme dans l'exemple ci-dessous : (ubyte)('0' + .vies).

La division est entière. 7 / 2 vaut 3 et 7 % 2 vaut 1. Avec un nombre négatif, le résultat est arrondi vers zéro : -7 / 2 vaut -3, et -7 % 2 vaut -1.

Un exemple : le tableau de score

Ce programme calcule un score et un nombre de vies, puis les affiche. Il n'existe pas encore de fonction pour écrire un nombre : on l'écrit chiffre par chiffre, avec la division et le reste.

// Types et variables : un tableau de score (Commodore 64)
architecture commodore.c64;

addlib vic.base;
addlib vic.screentext;

unit {
    const uint POINTS_PAR_GEMME = 250;  // une constante, dans la ROM
    ubyte vies = 3;                     // une variable de la unit, avec sa valeur de départ
    uint score;                         // une variable de la unit, à 0 au démarrage

    main() {
        ubyte gemmes = 7;               // une variable locale de main()

        // les calculs
        .score = .POINTS_PAR_GEMME * gemmes;    // 1750
        .vies--;                                // 2

        // l'écran
        vic.setBorderColor(0);
        vic.setBackgroundColor(6);
        vic.initScreen();
        vic.clearScreen();
        vic.setFontColor(1);

        // le score, chiffre par chiffre
        vic.setCursor(14, 10);
        vic.print("SCORE ");
        vic.putChar((ubyte)('0' + .score / 10000));
        vic.putChar((ubyte)('0' + (.score / 1000) % 10));
        vic.putChar((ubyte)('0' + (.score / 100) % 10));
        vic.putChar((ubyte)('0' + (.score / 10) % 10));
        vic.putChar((ubyte)('0' + .score % 10));

        // les vies
        vic.setCursor(14, 12);
        vic.print("VIES  ");
        vic.putChar((ubyte)('0' + .vies));
    }
}
Écran du Commodore 64 : SCORE 01750 et VIES 2
Le tableau de score sur Commodore 64.
La version PC Engine

Les calculs sont identiques. Seuls changent la bibliothèque (vdc. au lieu de vic.) et la préparation de l'écran, vue au chapitre 2.

// Types et variables : un tableau de score (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 POINTS_PAR_GEMME = 250;  // une constante, dans la ROM
    ubyte vies = 3;                     // une variable de la unit, avec sa valeur de départ
    uint score;                         // une variable de la unit, à 0 au démarrage

    main() {
        ubyte gemmes = 7;               // une variable locale de main()
        int source;
        int length;

        // les calculs
        .score = .POINTS_PAR_GEMME * gemmes;    // 1750
        .vies--;                                // 2

        // l'écran, la police et les couleurs, comme au chapitre 2
        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, 7));
        vce.setPalette(0, 1, RGB(7, 7, 7));
        vdc.clearScreen();
        vdc.setFontColor(0);

        // le score, chiffre par chiffre
        vdc.setCursor(10, 12);
        vdc.print("SCORE ");
        vdc.putChar((ubyte)('0' + .score / 10000));
        vdc.putChar((ubyte)('0' + (.score / 1000) % 10));
        vdc.putChar((ubyte)('0' + (.score / 100) % 10));
        vdc.putChar((ubyte)('0' + (.score / 10) % 10));
        vdc.putChar((ubyte)('0' + .score % 10));

        // les vies
        vdc.setCursor(10, 14);
        vdc.print("VIES  ");
        vdc.putChar((ubyte)('0' + .vies));
    }
}
Écran de la PC Engine : SCORE 01750 et VIES 2
Le tableau de score sur PC Engine.

À retenir

À vous de jouer

  1. Passez à 300 gemmes : que dit le compilateur, et pourquoi ? Essayez ensuite 200, puis 255.
    Voir la solutionMasquer la solution

    gemmes est un ubyte : il va de 0 à 255. La valeur 300 ne tient pas dedans, et le compilateur refuse le programme dès la déclaration :

    ubyte gemmes = 300;              // une variable locale de main()
    Value out of range for gemmes (0..255) -> 300

    Il ne tronque pas la valeur en silence : il s'arrête, et il dit quel type accepte quelles valeurs. Pour compter 300 gemmes, il faudrait un uint ; mais le score, 250 × 300 = 75 000, ne tiendrait plus non plus dans un uint.

    Avec 200, le programme compile et affiche SCORE 50000. Avec 255, la plus grande valeur d'un ubyte, il affiche SCORE 63750 (250 × 255). Le score est un uint, qui monte jusqu'à 65 535 : il a assez de place. Avec 263 gemmes ou plus, le score dépasserait 65 535 et déborderait ; mais un ubyte ne peut pas valoir 263, le choix des types protège ici le calcul.

    Écran du Commodore 64 : SCORE 63750, VIES 2
    Avec 255 gemmes : 250 × 255 = 63750.
  2. Ajoutez une constante BONUS de 500 points et ajoutez-la au score avec +=.
    Voir la solutionMasquer la solution

    Une constante se déclare en tête de la unit, avec const. Elle s'écrit ensuite avec un point, comme .POINTS_PAR_GEMME. L'opérateur += ajoute une valeur à une variable sans répéter son nom.

    const uint POINTS_PAR_GEMME = 250;  // une constante, dans la ROM
    const uint BONUS = 500;             // le bonus, une autre constante
    ubyte vies = 3;                     // une variable de la unit, avec sa valeur de départ
    uint score;                         // une variable de la unit, à 0 au démarrage
    // les calculs
    .score = .POINTS_PAR_GEMME * gemmes;    // 1750
    .score += .BONUS;                       // 2250

    Le score passe de 1750 à 2250, sur C64 comme sur PC Engine (les lignes sont les mêmes dans les deux programmes). .score += .BONUS; fait exactement la même chose que .score = .score + .BONUS;, en plus court. Comme BONUS est une constante, le compilateur met directement 500 dans le code.

  3. Déclarez vies en byte et retirez-lui 5 vies : que s'affiche-t-il ?
    Voir la solutionMasquer la solution

    Un byte accepte les nombres négatifs, de -128 à 127. On change le type de vies, puis on lui retire 5 avec -=. Après le .vies-- de l'exemple, il valait 2 : il vaut maintenant -3.

    byte vies = 3;                      // un byte : il peut devenir négatif
    // les calculs
    .score = .POINTS_PAR_GEMME * gemmes;    // 1750
    .vies--;                                // 2
    .vies -= 5;                             // -3
    Écran du Commodore 64 : SCORE 01750, VIES suivi d'un tiret
    Avec -3 vies, l'écran affiche un tiret.

    L'écran affiche VIES -. Le calcul '0' + .vies vaut 48 - 3 = 45, et 45 est le code du caractère -. Le programme n'a pas d'erreur, mais l'affichage chiffre par chiffre ne prévoit que les nombres de 0 à 9. Pour afficher un nombre négatif, il faudrait tester s'il est plus petit que 0, écrire un signe moins, puis écrire son opposé : c'est ce que permettront les conditions du chapitre suivant.

  4. Remplacez uint score par ubyte score : quelle erreur donne le compilateur, et comment la corriger ?
    Voir la solutionMasquer la solution

    1750 ne tient pas dans un ubyte, qui s'arrête à 255. Le calcul .POINTS_PAR_GEMME * gemmes donne un uint, et le compilateur refuse de le ranger dans un type plus petit :

    ubyte score;                        // un ubyte : 0 à 255 seulement
    Cannot assign uint to ubyte without explicit cast. Use: score = (ubyte)expression;

    Le message propose un cast. Il fait taire l'erreur, mais le résultat est faux : le cast garde l'octet bas de 1750 (0x06D6), soit 0xD6, c'est-à-dire 214.

    .score = (ubyte)(.POINTS_PAR_GEMME * gemmes);   // 1750 ne tient pas : 214
    Écran du Commodore 64 : SCORE 00214
    Avec le cast, le score affiché est 214 au lieu de 1750.

    La bonne correction est de revenir à uint score. Un cast se justifie quand on sait que la valeur tient dans le petit type, comme (ubyte)('0' + .vies) ; il ne doit pas servir à cacher une valeur trop grande. Ici, l'erreur du compilateur signalait un vrai problème.