Introduction #
Cela fait quelques mois que je m’intéresse au développement de jeux pour Game Boy Advance.
Il existe de nombreux outils pour construire des ROM pour GBA :
Il existe également de nombreux tutoriels sur la création de ROM pour GBA. TONC est le plus connu (et celui que j’ai personnellement suivi). Mais en général, ces tutoriels supposent l’utilisation d’une bibliothèque qui prend en charge le build et l’initialisation de la ROM. L’utilisateur ne prend pas la main à l’entry point, mais un peu plus tard, dans un état propre qui permet d’abstraire la plupart des problèmes liés à l’initialisation.
Il me semble qu’écrire une ROM pour la Game Boy Advance, en se servant
uniquement de Zig et de son système de build, est une bonne introduction à la
programmation système et embarquée. Sans devoir se plonger dans les complexités
de la mémoire virtuelle, du noyau et de x86
, ce projet permet de comprendre
ces différents sujets :
- comment un CPU simple fonctionne ;
- comment il exécute du code ;
- comment on écrit du code pour ce genre de machine ;
- quelles sont les nouvelles contraintes de cet environnement ;
- à quoi ressemble l’assembleur ARM ;
- comment construire un programme bare-metal.
Un programme assez complet avec un sujet plutôt amusant et motivant : comment programmer les jeux GBA de notre enfance.
Ce tutoriel s’adresse à des lecteurs qui savent déjà programmer, mais qui
n’ont potentiellement jamais fait de programmation bas niveau. Nous allons
reprendre ensemble les concepts un par un pour arriver au final à une très simple
boucle while
, vous permettant ensuite de commencer un tutoriel comme TONC
avec votre propre bibliothèque bas niveau.
Pourquoi <em>Zig</em> ? #
Je n’ai pas beaucoup d’expérience avec Zig. Après avoir programmé quelques
semaines pour la GBA en Rust avec la crate agb
, je voulais tester un langage
plus simple pour mieux comprendre les entrailles de la GBA et pour
éviter de me laisser distraire par un langage qui pousse à l’expérimentation et
à la recherche de la perfection.
Zig est un langage qui me faisait de l’œil depuis quelque temps. La promesse d’un langage système pour le XXIe siècle, simple et transparent. Un C moderne avec des optionals, une gestion d’erreur à la Rust, des allocateurs explicites, et surtout un système de build cohérent, intégré au langage.
J’ai donc regardé quelle bibliothèque j’allais bien pouvoir utiliser. J’ai vite repéré ZigGBA, un projet déjà assez complet, accompagné d’une note de blog que je vous invite à lire.
C’est en lisant le code source de ZigGBA, plus succinct que celui d’agb, que la curiosité m’a saisi. Je lisais le contenu du système de build, tentais de comprendre le linker script, puis je me suis rendu compte que le processus de build de ZigGBA est beaucoup plus facile à comprendre pour moi que celui d’agb.
Je tiens à préciser que je ne suis ni un expert en Rust, ni un expert en Zig. Je ne parle donc que de mes propres connaissances, et j’imagine que pour un développeur habitué au système de build et aux concepts de Rust, tout cela apparaît plus simple. Mais de mon côté, c’est vraiment cette simplicité qui m’a mené à m’intéresser au problème de l’écriture de programmes GBA from scratch.
Tout cela pour dire que ce projet pourrait être fait en C, en Rust ou, j’imagine, tout autre langage compilé avec plus ou moins de complications, mais Zig me paraît être parfait pour découvrir la programmation système et embarquée sans devoir se battre avec les étrangetés datées du C et avec un langage simple et moderne.
La programmation pour <em>Game Boy Advance</em> #
La différence principale entre la programmation sur GBA et la programmation à laquelle vous êtes peut-être habitué (programmation pour Linux, pour le web ou pour smartphone), c’est que d’habitude vous n’êtes pas seul sur la machine où s’exécute votre programme, et vous ne connaissez pas les détails de la machine sur laquelle il s’exécute.
Sur votre machine Linux, votre programme n’est pas le seul à s’exécuter : vous avez aussi un navigateur, des émulateurs de terminaux, peut-être un lecteur de musique. Tous ces programmes ont besoin de partager les ressources de votre machine : un peu de mémoire par-ci, quelques cycles de processeur par-là.
Vous avez peut-être remarqué qu’en tant que développeur, vous n’avez pas eu à vous soucier de ces considérations. De votre point de vue, la mémoire est infinie et vous appartient, le processeur est à vous, et jamais il ne vous sera demandé de penser aux autres programmes s’exécutant sur votre machine. C’est parce que ce rôle est dévolu à votre système d’exploitation.
C’est l’OS qui doit assurer la compartimentation des processus, c’est-à-dire garantir qu’un processus ne puisse pas accéder à l’environnement d’exécution ou à la mémoire d’un autre processus.
C’est aussi l’OS qui se charge du partage des ressources : il distribue la mémoire aux processus qui en font la demande, il orchestre l’exécution des processus, en s’assurant que chacun ait droit à un peu de temps d’exécution.
C’est toujours l’OS qui vous permet d’écrire des logiciels sans avoir à penser au matériel sur lequel vous allez les exécuter. Peu importe la marque de votre écran, que votre souris soit à boule ou Bluetooth, que votre RAM soit en DDR3 ou 4, ou même que vous soyez dans une machine virtuelle, votre programme sera le même. Cela grâce à la couche d’abstraction que votre OS vous offre entre vous et le matériel. Cela permet de pouvoir écrire des programmes portables, de pouvoir distribuer votre logiciel à tous ceux qui utilisent le même OS que vous.
flowchart TD Browser["Browser"] <--> OS Terminal["Terminal"] <--> OS MusicPlayer["Music Player"] <--> OS YourProgram["Your Program"] <--> OS subgraph OS["Operating System"] end OS <--> Hardware subgraph Hardware Memory["Memory"] Display["Display"] Input["Input Devices"] Drive["Hard drives"] end
C’est là que réside la plus grande différence avec la programmation pour GBA : une fois que le BIOS de la console a chargé votre programme, vous êtes seul à bord. Pas d’OS pour vous allouer de la mémoire, pas d’OS pour abstraire les ressources de la console, pas d’OS pour lancer plusieurs programmes, pas d’OS pour rattraper les crashs. Vous êtes lancé sans filet dans le grand bain ! De loin, ça peut paraître compliqué, mais en réalité, l’architecture de la Game Boy Advance est relativement simple. C’est donc la porte d’entrée parfaite vers la programmation bas niveau.
flowchart BT YourProgram["Your Program"] subgraph Hardware RAM["RAM"] VRAM["Screen"] Input["Buttons"] Sound["Sound channels"] end YourProgram --->|Memory Reads and Writes| Hardware Hardware -->|Interruptions| YourProgram
Avant de commencer #
Quels prérequis ? #
Je tiens à préciser que ce tutoriel ne vous demande pas de posséder une Game Boy Advance. Pour être honnête, je ne sais pas où j’ai rangé la mienne, et je n’ai pas de cartouche flash qui me permettrait de faire tourner un programme custom.
Personnellement, j’utilise l’émulateur mGBA. Il en existe d’autres, mais celui-ci fonctionne bien.
Nous allons aussi utiliser readelf, hexdump et gdb dans le cadre de ce tutoriel. Ce sont des outils qui seront utiles pour comprendre le comportement de notre compilateur et de notre programme. Ils ne sont pas obligatoires pour suivre le processus, mais ce sont des outils utiles.
Il vous faudra aussi installer Zig sur votre machine.
Ce tutoriel considère comme acquis :
- les bases de la programmation ;
- l’utilisation d’un langage compilé comme Rust, C ou C++ ;
- une certaine maîtrise de Linux et de sa console.
Nous pouvons commencer !
Par où commencer ? #
Mon objectif était d’arriver à la première étape du tutoriel TONC,
c’est-à-dire afficher trois points de couleur sur l’écran. C’est l’équivalent
d’un Hello, World!
mais pour une console qui dispose de son propre écran.
C’est certes un résultat qui semble basique, mais le chemin pour y arriver est intéressant. Car nous allons devoir comprendre comment le processeur de la GBA accède au code de la cartouche et exécute notre programme, comment construire notre cartouche pour qu’elle soit reconnue par la GBA, et comment afficher des pixels sur un écran.
Mais alors, par quoi commencer ? Je vous propose de commencer par un programme minimal : une boucle infinie.
Commençons par comprendre comment fonctionne la GBA.
La <em>Game Boy Advance</em> #
La Game Boy Advance est une console portable vendue par Nintendo à partir de 2001. Pensée comme l’évolution de la Game Boy, la GBA réalise pourtant un vrai saut technique en passant d’un processeur Sharp SM83 8 bits à un ARM7TDMI 32bits (même si l’ARM7 était déjà en fin de vie). La GBA contient cependant toujours un SM83 pour assurer une rétro-compatibilité avec la Game Boy.
ARM7TDMI a deux modes : un mode d’instruction ARM 32 bits, et un mode d’instruction THUMB compact 16 bits, qui permet à notre programme de prendre moins de place.
La GBA comprend aussi de la mémoire :
- 16 KB de BIOS
- 288 KB de RAM
- 96 KB de mémoire vidéo
- 1 KB de mémoire pour la gestion des sprites
- 1 KB de mémoire pour la palette
Du son :
- 4 canaux analogiques
- 2 canaux numériques
Des boutons :
- le fameux D-Pad, donc 4 boutons de direction
- 6 boutons A, B, Start, Select, L et R
Et surtout un port cartouche :
- Cartouche GBA, max. 32 MB ROM + max. 64 KB SRAM
- Cartouche Game Boy, max 32 KB ROM + 8 KB SRAM (et plus avec du banking)
Il y a aussi un port série (pour le fameux câble link).
Memory map #
Si vous avez un peu programmé, vous avez sûrement une idée de ce qu’est la mémoire d’un ordinateur. C’est une grande table de bytes où l’on peut écrire et lire. Chaque byte a une adresse, qui selon le processeur a une taille définie.
Pour ARM7TDMI, les adresses sont codées sur 32 bits. Ça veut dire
qu’hypothétiquement, notre programme peut accéder aux adresses 0x0000_0000
à
0xFFFF_FFFF
.
Mais attendez, 0xFFFF_FFFF
c’est 4294967295 ! On aurait donc accès à 4 GiB de
RAM ? Mais la fiche technique précédente n’affiche que 288 KB, alors que se
passe-t-il ?
L’astuce est que chaque adresse ne représente pas forcément une cellule mémoire physique. Une plage d’adresses peut être utilisée pour accéder à la RAM, une autre pour accéder à la ROM du BIOS, une autre encore pour accéder à la mémoire vidéo qui permet d’afficher des pixels sur l’écran. Et enfin, la grande majorité des plages d’adresses ne servent… à rien ! Les lire ou écrire par-dessus ne provoquera rien de logique ou utile (et provoquera au pire un crash pur et simple).
Voici donc la carte de la mémoire adressable de la Game Boy Advance (copié-collé depuis GBATEK) :
Addr début | Addr fin | Utilisation |
---|---|---|
0x0000_0000 | 0x0000_3FFF | BIOS - System ROM (16 KBytes) |
0x0000_4000 | 0x01FF_FFFF | Non utilisé |
0x0200_0000 | 0x0203_FFFF | WRAM - On-board Work RAM (256 KBytes, 2 Wait) |
0x0204_0000 | 0x02FF_FFFF | Non utilisé |
0x0300_0000 | 0x0300_7FFF | WRAM - On-chip Work RAM (32 KBytes) |
0x0300_8000 | 0x03FF_FFFF | Non utilisé |
0x0400_0000 | 0x0400_03FE | I/O Registers |
0x0400_0400 | 0x04FF_FFFF | Non utilisé |
0x0500_0000 | 0x0500_03FF | BG/OBJ Palette RAM (1 Kbyte) |
0x0500_0400 | 0x05FF_FFFF | Non utilisé |
0x0600_0000 | 0x0601_7FFF | VRAM - Video RAM (96 KBytes) |
0x0601_8000 | 0x06FF_FFFF | Non utilisé |
0x0700_0000 | 0x0700_03FF | OAM - OBJ Attributes (1 Kbyte) |
0x0700_0400 | 0x07FF_FFFF | Non utilisé |
0x0800_0000 | 0x09FF_FFFF | Game Pak ROM/FlashROM (max 32MB) - Wait State 0 |
0x0A00_0000 | 0x0BFF_FFFF | Game Pak ROM/FlashROM (max 32MB) - Wait State 1 |
0x0C00_0000 | 0x0DFF_FFFF | Game Pak ROM/FlashROM (max 32MB) - Wait State 2 |
0x0E00_0000 | 0x0E00_FFFF | Game Pak SRAM (max 64 KBytes, 8-bit Bus width) |
0x0E01_0000 | 0x0FFF_FFFF | Non utilisé |
0x1000_0000 | 0xFFFF_FFFF | Non utilisé |
Comme vous le voyez, la très grande majorité des adresses n’est pas du tout utilisée.
Mais du coup notre code il est où ? Il est dans la cartouche. Une cartouche
amovible, externe. La plage mémoire allouée à la mémoire externe est
0x0800_0000-0x0E00_FFFF
. Mais qu’est-ce que ça veut dire ? Ça veut dire
que quand le processeur essaye d’accéder à l’adresse 0x0800_0000
, il
accède en fait non pas à sa mémoire interne, mais à la mémoire ROM de la
cartouche à l’adresse 0x0000_0000
.
Exécution #
Maintenant qu’on sait comment fonctionne la mémoire de la GBA, comment le processeur va-t-il exécuter du code ?
Première chose : le code est stocké en mémoire. Oui, le code a une adresse, et c’est comme ça que le processeur y accède.
Pour comprendre, il faut savoir que le processeur a lui-même une toute petite mémoire. Elle est toute petite parce qu’elle doit être très rapide, car c’est celle-ci que le processeur utilise pour effectuer ses diverses opérations. ARM7TDMI a 37 registres de 32 bits chacun, donc littéralement une mémoire de 37 bytes.
Le registre 15 contient le Program Counter (PC). Ce registre contient l’adresse de l’instruction que le processeur est en train d’exécuter.
Ce n’est pas tout à fait vrai. Le registre contient l’adresse de l’instruction actuelle plus deux instructions. Dans d’autres architectures, PC contient l’instruction suivante. Avec un processeur ARM7TDMI, c’est deux instructions. Ce n’est pas très important pour comprendre ce chapitre, mais ça va devenir important après, donc gardez ça en tête.
À l’allumage de la console, le PC est à 0x0000_0000
. Le processeur va donc
lire l’instruction présente à l’adresse 0x0000_0000
, l’exécuter, puis lire
l’instruction suivante, et ainsi de suite. L’instruction elle-même peut
modifier le registre PC, et donc permettre de faire des sauts ou de
revenir en arrière de manière conditionnelle.
Donc il suffit de mettre son code en 0x0000_0000
pour qu’il soit exécuté ?
En théorie oui, mais 0x0000_0000-0x0000_3FFF
est la plage du BIOS de la console.
Le BIOS, c’est un programme qui permet à la console de s’initialiser.
Ce programme est stocké dans une ROM, et n’est donc pas éditable. C’est en
fait le BIOS qui va donner la main à notre programme en sautant à l’adresse
0x0800_0000
.
Il nous suffit donc de placer notre boucle infinie au tout début de notre cartouche, et le BIOS de la Game Boy Advance s’occupera de nous passer la main.
Objectif boucle infinie #
Notre premier exécutable #
Bon, maintenant que c’est clair, on sait ce qui nous reste à faire : écrire notre programme ! Allons-y de manière naïve, et voyons où ça nous mène. D’abord, créons un nouveau projet Zig.
mkdir mygbarom
cd mygbarom
mkdir src
À partir de là, on peut ajouter ./src/main.zig
:
pub fn main() void {
while (true) {}
}
Même si vous ne connaissez pas Zig, ça ne devrait pas vous choquer.
On peut aussi ajouter ./build.zig
:
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const exe_mod = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
const exe = b.addExecutable(.{
.name = "mygbarom",
.root_module = exe_mod,
});
b.installArtifact(exe);
}
C’est le fichier que Zig exécute pour build le programme. C’est l’équivalent
d’un Makefile ou d’une config CMake. J’ai repris le fichier
de base généré par zig init
en retirant le superflux.
Pour détailler un peu, le but de notre fonction
build
est d’installer dans./zig-out/bin/
notre exécutable. Pour cela, il faut d’abord définir la cible de notre compilation, c’est notre variabletarget
. Puis, il faut définir notre niveau d’optimisation avec notre variableoptimize
.exe_mod
contient notre module. Enfin,exe
définit notre exécutable final, qui aura pour nom “mygbarom”. C’est cet exécutable final que nous allons installer dans./zig-out/bin/
grâce àb.installArtifact(exe)
.Petite précision, je ne suis pas un pro de Zig, encore moins un pro de son système de build. Prenez ce que je dis avec des pincettes et n’hésitez pas à me rapporter d’éventuelles erreurs.
On peut donc maintenant build le projet, et lancer l’exécutable.
zig build
./zig-out/bin/mygbarom
Normalement, le programme va juste ne rien faire sans se terminer, vous pouvez le
stopper avec un CTRL+C
.
Super ! On a le comportement que l’on souhaitait : une boucle infinie.
Bon, maintenant on peut tester avec mGBA.
$ mgba zig-out/bin/mygbarom
Could not run game. Are you sure the file exists and is a compatible game?
Oui, ça ne pouvait pas être si simple, vous vous en doutez. Mais c’est un
point de départ intéressant pour nous, car ce chemin entre l’exécutable
que nous venons de générer et la cartouche va nous faire entrer petit à petit
dans le monde du bare-metal, mais va aussi nous en faire comprendre un
peu plus le monde d’où l’on vient (ici, Linux sur x86-64
).
Alors, si notre fichier exécutable n’est pas une cartouche GBA, alors qu’est-ce que c’est ?
$ file zig-out/bin/mygbarom
mygbarom: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, with debug_info, not stripped
Un fichier ELF ? Voyons voir ce que c’est.
Le format de fichier <em>ELF</em> #
ELF (Executable and Linkable Format) est le format standard de fichier exécutable sous Linux et de nombreux UNIX modernes. C’est un format qui stocke non seulement le code exécutable d’un programme, mais aussi les données, les symboles, et des informations sur comment le programme doit être chargé en mémoire.
Un fichier ELF est divisé en plusieurs sections. Les principales sont :
.text
: contient le code exécutable du programme.data
: contient les variables initialisées.bss
: réserve de l’espace pour les variables non initialisées.rodata
: données en lecture seule (comme les string const)
Il existe aussi d’autres sections spécifiques, comme .ARM.exidx
que l’on voit
dans notre fichier, utilisée spécifiquement pour les exceptions sur
architecture ARM.
Quand vous exécutez un programme sous Linux, le système d’exploitation charge le fichier ELF en mémoire, en suivant les instructions contenues dans son en-tête, puis positionne le registre de compteur de programme (PC) au point d’entrée spécifié.
Cependant, la GBA ne comprend pas ce format. Elle s’attend à recevoir du code brut qu’elle peut exécuter directement, sans phase de chargement complexe. C’est pour cela qu’il nous faudra extraire uniquement le code et les données nécessaires de notre fichier ELF pour créer notre ROM GBA.
Comparons ça avec notre objectif : sur une GBA, on a besoin que notre code soit placé directement dans la mémoire ROM de la cartouche, à des adresses précises et dans un format très spécifique. Le format ELF est bien trop complexe avec ses en-têtes et ses sections multiples pour être utilisé directement.
Voyons donc comment transformer notre fichier ELF en un binaire brut adapté à la GBA.
Du fichier <em>ELF</em> au <code>.gba</code> #
Donc qu’est-ce qui ne va pas ?
- Notre exécutable n’utilise pas l’assembleur ARM mais x86_64.
- Notre exécutable utilise ELF, format non reconnu par notre GBA.
Target #
Résolvons d’abord le problème d’assembleur.
Dans ./build.zig
, remplacer target
par :
const target = std.Target.Query{
.cpu_arch = .thumb,
.cpu_model = .{ .explicit = &std.Target.arm.cpu.arm7tdmi },
.os_tag = .freestanding,
};
Maintenant, le compilateur Zig va cibler le processeur ARM7TDMI et écrire en assembleur THUMB, qui pour rappel est un assembleur ARM compact, avec des instructions de 16 bits, très pratique pour la programmation pour GBA.
Ensuite, il faut mettre à jour le module exécutable :
const exe_mod = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = b.resolveTargetQuery(target), // here
.optimize = optimize,
});
Essayons de build :
$ zig build
install
└─ install mygbarom
└─ zig build-exe foo Debug thumb-freestanding failure
error: warning(link): unexpected LLD stderr:
ld.lld: warning: cannot find entry symbol _start; not setting start address
Ah, une erreur. Nous voilà confrontés au premier mensonge du monde
merveilleux de la programmation : main
n’est pas le point d’entrée
du programme. Le point d’entrée, c’est (de manière standard) _start
.
Ce symbole représente l’adresse où commence l’exécution du programme.
Ce symbole, après avoir potentiellement effectué l’initialisation du
programme, laisse la main à la fonction main
.
Ici, le linker n’arrive pas à trouver le symbole _start
dans notre programme.
La raison est simple : on a demandé à Zig d’utiliser le flag freestanding
pour l’OS, ce qui veut dire qu’on ne cible aucun OS. Zig va donc nous laisser
ajouter à la main dans notre programme _start
et l’exporter.
Alors comment ajouter notre point d’entrée ?
export fn _start() noreturn {
while (true) {}
}
On a juste remplacé main
par _start
. On a aussi ajouté export
ce qui permet
de rendre visible ce symbole et pouvoir le considérer comme entry point.
Le type de la fonction est maintenant noreturn
, ça permet au compilateur
Zig de vérifier que notre fonction ne return jamais.
noreturn
nous permet de s’assurer que notre pointeur dans le registre PC ne “s’échappe” pas. Hypothétiquement, PC pourrait continuer à s’incrémenter après notre programme, et donc tomber sur des valeurs aléatoires jusqu’à crash. On doit s’assurer que quoi qu’il arrive, PC reste confiné aux frontières de notre code (par exemple avec une boucle infinie).
Si on zig build
, le build devrait se finir avec succès ! Si on utilise
readelf
, on a :
$ readelf mygbarom -h
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: ARM
Version: 0x1
Entry point address: 0x54261
Start of program headers: 52 (bytes into file)
Start of section headers: 1456952 (bytes into file)
Flags: 0x5000200, Version5 EABI, soft-float ABI
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 6
Size of section headers: 40 (bytes)
Number of section headers: 21
Section header string table index: 19
On peut voir qu’on a bien un fichier en ARM ! Félicitations !
Nettoyer notre fichier <em>ELF</em> #
Regardons maintenant d’un peu plus près la tête de notre fichier ELF.
$ readelf --sections zig-out/bin/mygbarom
There are 21 section headers, starting at offset 0x163b38:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .ARM.exidx ARM_EXIDX 000100f4 0000f4 000d90 00 AL 4 0 4
[ 2] .ARM.extab PROGBITS 00010e84 000e84 000888 00 A 0 0 4
[ 3] .rodata PROGBITS 00011710 001710 0066d8 00 AMS 0 0 8
[ 4] .text PROGBITS 00027de8 007de8 0587f2 00 AX 0 0 4
[ 5] .data PROGBITS 000905dc 0605dc 000004 00 WA 0 0 4
[ 6] .bss NOBITS 00090600 0605e0 001000 00 WA 0 0 64
[ 7] .debug_loc PROGBITS 00000000 0605e0 0775ed 00 0 0 1
[ 8] .debug_abbrev PROGBITS 00000000 0d7bcd 0006e7 00 0 0 1
[ 9] .debug_info PROGBITS 00000000 0d82b4 03434c 00 0 0 1
[10] .debug_ranges PROGBITS 00000000 10c600 008938 00 0 0 1
[11] .debug_str PROGBITS 00000000 114f38 00ee7d 01 MS 0 0 1
[12] .debug_pubnames PROGBITS 00000000 123db5 0051ea 00 0 0 1
[13] .debug_pubtypes PROGBITS 00000000 128f9f 0019d6 00 0 0 1
[14] .ARM.attributes ARM_ATTRIBUTES 00000000 12a975 00003a 00 0 0 1
[15] .debug_frame PROGBITS 00000000 12a9b0 005e70 00 0 0 4
[16] .debug_line PROGBITS 00000000 130820 024313 00 0 0 1
[17] .comment PROGBITS 00000000 154b33 000013 01 MS 0 0 1
[18] .symtab SYMTAB 00000000 154b48 009c00 10 20 1988 4
[19] .shstrtab STRTAB 00000000 15e748 0000da 00 0 0 1
[20] .strtab STRTAB 00000000 15e822 005314 00 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
D (mbind), y (purecode), p (processor specific)
Donc on se souvient bien : la section .text
contient le code, les sections
.data
, .rodata
et .bss
contiennent les données.
Regardons notre section .text
: size = 0x0587f2
? C’est énorme ! Comment ça
se fait que notre section de code soit aussi grosse alors qu’on a juste
implémenté une boucle while
?
La réponse est simple : on a zig build
en mode debug. On a dit à Zig de
garder les symboles de debug, les vérifications, les symboles de bibliothèque,
de ne pas optimiser.
Voici comment dire au compilateur de Zig de retirer tout ça, dans ./build.zig
:
pub fn build(b: *std.Build) void {
// ...
const optimize = .ReleaseSmall;
// ...
}
L’optimisation .ReleaseSmall
permet d’optimiser la taille de notre code,
de minimiser le nombre d’instruction.
J’ai testé l’utilisation d’autres formats d’optimisation (notamment
.ReleaseSafe
), et je suis tombé sur des erreurs et des instructions non reconnues. Je n’ai pas identifié le problème, mais tout fonctionne bien en.ReleaseSmall
, donc je ne change pas pour l’instant. Je ferai des recherches plus tard.
Buildons :
zig build
$ readelf --sections zig-out/bin/mygbarom
There are 6 section headers, starting at offset 0x16c:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .ARM.exidx ARM_EXIDX 000100d4 0000d4 000010 00 AL 2 0 4
[ 2] .text PROGBITS 000200e4 0000e4 000004 00 AX 0 0 4
[ 3] .ARM.attributes ARM_ATTRIBUTES 00000000 0000e8 00003a 00 0 0 1
[ 4] .comment PROGBITS 00000000 000122 000013 01 MS 0 0 1
[ 5] .shstrtab STRTAB 00000000 000135 000035 00 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
D (mbind), y (purecode), p (processor specific)
Et voilà ! On a maintenant un fichier ELF propre !
Du fichier <em>ELF</em> au binaire #
Alors maintenant qu’on a un fichier ELF un peu plus propre, est-ce qu’il va être reconnu par mGBA ?
❯ mgba zig-out/bin/mygbarom.gba
Could not run game. Are you sure the file exists and is a compatible game?
Comme dit précédemment, la Game Boy Advance ne reconnait pas le format ELF. Pourquoi elle le reconnaitrait ? Rappel : la Gameboy Advance n’a pas d’OS. Elle n’a pas besoin d’un format standard portable, elle n’a pas besoin d’une table de section, de table de relocation, de bibliothèques dynamiques, etc. Il nous faut donc enlever tout le superflu du format ELF pour ne garder que la substantifique moelle : notre programme.
Voici comment on fait, dans ./build.zig
:
pub fn build(b: *std.Build) void {
// ...
const objcopy_step = exe.addObjCopy(.{ .format = .bin });
const install_bin_step = b.addInstallBinFile(objcopy_step.getOutput(), "mygbarom.gba");
install_bin_step.step.dependOn(&objcopy_step.step);
b.getInstallStep().dependOn(&install_bin_step.step);
// ...
}
On utilise l’outil objcopy pour extraire notre code du fichier ELF, dans le
format .bin
, c’est-à-dire le code brut sans en-tête de fichier ou sections
non-chargées. Dans notre cas, seules les sections .text
et .ARM.exidx
vont
être copiées dans mygbarom.gba
.
Normalement, on peut trouver le binaire dans ./zig-out/bin/mygbarom.gba
.
Voyons voir ce qu’on trouve là-dedans.
$ hexdump zig-out/bin/mygbarom.gba
0000000 0010 0001 b0b0 80b0 000c 0001 0001 0000
0000010 0000 0000 0000 0000 0000 0000 0000 0000
*
0010010 e7fe 4770
0010014
Pour ceux qui ne sont pas familiers avec hexdump
, c’est un outil qui permet de
voir le contenu d’un fichier en hexadécimal. Alors qu’est-ce qu’on voit ? La
colonne de gauche montre l’offset, un peu comme un numéro de ligne. Les huit
autres colonnes nous montrent les bytes contenus dans le binaire. L’astérisque
représente des lignes remplies de zéros. Mais alors pourquoi notre binaire
ressemble à ça ? Revérifions notre fichier ELF.
$ readelf zig-out/bin/mygbarom -x 1
Hex dump of section '.ARM.exidx':
0x000100d4 10000100 b0b0b080 0c000100 01000000 ................
$ readelf zig-out/bin/mygbarom -x 2
Hex dump of section '.text':
0x000200e4 fee77047 ..pG
Donc ce qu’on voit, c’est que notre binaire est constitué du contenu de .ARM.exidx
,
puis d’un long padding, suivi du contenu de .text
.
Bien ! Nous avons notre binaire !
Le format cartouche #
Voyons voir comment mGBA réagit à notre chef-d’œuvre :
$ mgba zig-out/bin/mygbarom.gba
Could not run game. Are you sure the file exists and is a compatible game?
Encore raté. Alors qu’est-ce quel est le problème ? Encore une fois, j’ai menti en disant que la Gameboy Advance n’attendait pas de format standard. C’est faux. La console, avant de lancer le jeu, va vérifier le contenu d’un header, qui doit précéder notre programme. Voici sa structure :
const Header = extern struct {
entry_point: u32,
nintendo_logo: [156]u8,
game_name: [12]u8,
game_code: [4]u8,
maker_code: [2]u8,
fixed_value: u8,
unit_code: u8,
device_type: u8,
reserved1: [7]u8,
software_version: u8,
complement_check: u8,
reserved2: [2]u8,
};
La première entrée du header, c’est entry_point
: c’est une instruction ARM,
la première qui sera exécutée par le processeur. C’est l’instruction qui doit
être positionnée à 0x0800_0000
dans la mémoire de la GBA. Cette instruction
est souvent un simple “jump”, un saut vers une adresse se trouvant après la fin
du header.
Au démarrage, la GBA va vérifier deux valeurs du header : nintendo_logo
et
complement_check
. nintendo_logo
doit contenir au bit près l’image bitmap du
logo de Nintendo. C’est une manière d’éviter la commercialisation de cartouches
non-officielles, car le logo est copyrighté et donc Nintendo peut poursuivre
les entreprises distribuant leur logo sans autorisation. complement_check
est
un checksum, une valeur calculée en fonction des bytes contenus dans la
cartouche, qui permet de vérifier si la cartouche a été modifiée.
La plupart des émulateurs ne vérifient pas ces valeurs (pour pouvoir lire des ROM non-officielles et par convenance pour ceux qui comme nous veulent juste déboguer leur jeu sans calculer de checksum). Par contre, mGBA vérifie deux valeurs pour déterminer si ce qu’on lui donne est bien une cartouche GBA :
- Le quatrième byte de
entrypoint
, qui devrait être égal à0xEA
. fixed_value
, qui devrait être égal à0x96
.
Essayons déjà d’ajouter ce header dans notre fichier source ./src/main.zig
:
const Header = extern struct {
entry_point: u32 = 0xEA00002E,
nintendo_logo: [156]u8 = @splat(0x00),
game_name: [12]u8 = @splat(0x00),
game_code: [4]u8 = @splat(0x00),
maker_code: [2]u8 = @splat(0x00),
fixed_value: u8 = 0x96,
unit_code: u8 = 0x00,
device_type: u8 = 0x00,
reserved1: [7]u8 = @splat(0x00),
software_version: u8 = 0x00,
complement_check: u8 = 0x00,
reserved2: [2]u8 = @splat(0x00),
};
const header = Header{};
export fn _start() void {
while (true) {}
}
On a donc déclaré une structure Header
, avec le keyword extern
qui permet de
dire au compilateur de bien respecter l’ordre des attributs.
Pour être plus précis,
extern
permet de respecter ce qu’on appelle l’“ABI C”. ABI pour Application Binary Interface. C’est une convension, un standard qui permet de partager (entre autre) une même méthode de construction de structs, ce qui permet d’être sur que deux bibliothèques possiblement compilés séparéments pourront quand même partager les mêmes structures. Quand on utiliseextern
dans Zig, on dit en fait au compilateur de respecter un standard qui permettra à d’autres programmes (potentiellement pas écrits en Zig) d’intéragir avec le notre.
Si on zig build
, on peut se rendre compte qu’absolument rien a changé
dans notre fichier ELF, ni dans notre fichier binaire.
J’imagine que le compilateur de Zig n’inclut pas le header dans le programme car il n’est jamais utilisé.
Il faut donc forcer le compilateur et le linker de placer ce header exactement au tout début du fichier, puis notre code juste après. Pour faire ça, nous allons devoir donner des directives au linker grâce à un linker script.
Alignement #
On a un peu parlé de standard de construction de struct dans la partie précédente, mais nous devons aller plus loin et aborder l’alignement.
Par défaut, en C ABI, les structures ne sont pas nécessairement construites pour optimiser la place occupée. Elles adoptent plutôt un schéma qui optimise la rapidité d’accès.
En général, les accès mémoire pour des données plus grandes que 8 bits doivent être alignés, c’est-à-dire que l’adresse doit être un multiple de la taille de la donnée.
Par exemple, l’écriture d’un int
encodé sur 16 bits doit se faire sur une adresse
divisible par 16 (16, 32, 64, 0x100).
Imaginons la structure suivante :
const Example = extern struct {
a: u8,
b: u16,
c: u32,
}
Comment cette structure sera-t-elle représentée en mémoire ?
Naïvement, on pourrait la construire comme ceci :
block-beta a:2 b:4 c:8
Mais regardons comment cette structure peut être vue en l’alignant sur 32 bits.
block-beta columns 8 a:2 b:4 c1["c"]:2 c2["c"]:6
Imaginons que nous voulions accéder à b
. Comme c’est une donnée de 16 bits,
nous ne pouvons pas y accéder directement : il faut utiliser une adresse alignée. Nous
devons donc lire les 32 bits qui comprennent a
, b
, et un bout de c
.
block-beta columns 8 a:2 b:4 c1["c"]:2
Puis nous devons décaler les bits.
block-beta columns 8 b:4 c:2 z["0"]:2
Enfin, nous devons mettre à zéro (ou ignorer) les 16 bits non significatifs.
block-beta columns 8 b:4 z["0"]:4
Cela représente beaucoup d’étapes pour accéder à une valeur. Et encore, nous n’avons eu besoin que d’un seul
accès mémoire. Pour c
, il en aurait fallu deux.
Comment les compilateurs règlent-ils ce problème ? Ils ajoutent du padding.
block-beta columns 4 a:1 p1["padding"]:1 b:2 c:4
Maintenant, chaque donnée est alignée et accessible en une seule instruction !
En quoi cela nous concerne-t-il ? Cette manière d’ajouter du padding dans les structures pourrait rendre notre header incorrect, non conforme à ce qu’attend la Game Boy Advance.
Il faut donc préciser au compilateur de ne pas modifier l’alignement de nos données.
const Header = extern struct {
entry_point: u32 align(1) = 0xEA00002E,
nintendo_logo: [156]u8 align(1) = @splat(0),
game_name: [12]u8 align(1) = @splat(0),
game_code: [4]u8 align(1) = @splat(0),
maker_code: [2]u8 align(1) = @splat(0),
fixed_value: u8 align(1) = 0x96,
unit_code: u8 align(1) = @splat(0),
device_type: u8 align(1) = @splat(0),
reserved1: [7]u8 align(1) = @splat(0),
software_version: u8 align(1) = @splat(0),
complement_check: u8 align(1) = @splat(0),
reserved2: [2]u8 align(1) = @splat(0),
};
À noter que sur ma machine, les deux versions de
Header
produisent strictement le même résultat. C’est (je pense) dû au fait que le header ne pose pas les problèmes d’alignement que nous avons abordés. Mais c’est un peu par “accident”. Ajouteralign(1)
garantit que notre header restera correct quel que soit le comportement par défaut du compilateur.
Le linker script #
Petit point sur le build.
Quand on appelle zig build
, on appelle d’abord le compilateur sur chaque
fichier source, qui le compile en un fichier objet, un fichier ELF incomplet
qui ne contient que les informations propres au fichier source. Puis on appelle
le linker, qui va prendre tous les fichiers objets et les fusionner en un seul
fichier ELF exécutable.
flowchart LR z1("main.zig") z2("foo.zig") z3("bar.zig") z1 -- "compile" --> o1("main.o") z2 -- "compile" --> o2("foo.o") z3 -- "compile" --> o3("bar.o") o1 -- link --> elf("out.elf") o2 -- link --> elf o3 -- link --> elf
Le linker script, c’est les instructions de ce link final. C’est un fichier qui indique au linker comment construire un fichier ELF à partir des sections des fichiers objets intermédiaires. Un linker script n’est pas obligatoire : dans la plupart des cas, le linker peut se débrouiller sans. Mais dans notre cas, on est obligé d’indiquer au linker que l’on veut notre header en premier.
Notre objectif va être de déclarer une section .gbaheader
contenant une instance
de notre structure Header
, et de placer cette section en premier, avant
la section .text
.
Commençons par dire au compilateur Zig que l’on veut placer notre header dans
notre section .gbaheader
, dans ./src/main.zig
:
export const header linksection(".gbaheader") = Header{};
Ensuite, on crée notre linker script, ./gba.ld
.
SECTIONS {
.gbaheader : {
KEEP(*(.gbaheader))
}
.text : {
*(.text)
}
.ARM.exidx : {
*(.ARM.exidx)
}
}
Alors qu’est-ce qu’on vient de faire ? On déclare le contenu des sections que
notre fichier ELF final va contenir. Par exemple, .text
va contenir toutes les
sections .text
de tous les fichiers sources que nous sommes en train de
compiler. Dans notre cas, il n’y a que main.zig
, mais il pourrait y avoir
d’autres fichiers sources, que le linker assemble en un seul fichier ELF. Même
chose pour .ARM.exidx
que le linker veut absolument placer devant notre
header. Pour .gbaheader
, on a donc bien créé une section custom dans
main.zig
, qui n’existe pour l’instant que dans le fichier objet généré. On
déclare donc une section .gbaheader
dans notre fichier final, qui contient
toutes les sections .gbaheader
que l’on a déclarées (normalement juste une).
Petite subtilité : on utilise la “fonction” KEEP
pour dire au linker de bien
garder cette section dans le fichier final, même s’il semble pas utilisé
ailleurs.
L’ordre de déclaration est important : on déclare bien que .gbaheader
est avant
.text
et .ARM.exidx
.
Dernière étape : dire à Zig que l’on utilise un linker script, dans
./build.zig
:
pub fn build(b: *std.Build) void {
// ...
exe.setLinkerScript(.{ .src_path = .{
.owner = b,
.sub_path = "gba.ld",
} });
// ...
}
Et voilà ! Normalement, après un zig build
, vous devriez pouvoir trouver :
$ readelf zig-out/bin/mygbarom --sections
There are 8 section headers, starting at offset 0x10180:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .gbaheader PROGBITS 00010000 010000 0000c0 00 A 0 0 4
[ 2] .text PROGBITS 000100c0 0100c0 000002 00 AX 0 0 4
[ 3] .text.__aeab[...] PROGBITS 000100c2 0100c2 000002 00 AX 0 0 2
[ 4] .ARM.exidx ARM_EXIDX 000100c4 0100c4 000010 00 AL 2 0 4
[ 5] .ARM.attributes ARM_ATTRIBUTES 00000000 0100d4 00003a 00 0 0 1
[ 6] .comment PROGBITS 00000000 01010e 000013 01 MS 0 0 1
[ 7] .shstrtab STRTAB 00000000 010121 00005d 00 0 0 1
Notre section existe ! Voyons ce qu’elle contient :
$ readelf zig-out/bin/mygbarom -x 1
Hex dump of section '.gbaheader':
0x00010000 2e0000ea 00000000 00000000 00000000 ................
0x00010010 00000000 00000000 00000000 00000000 ................
0x00010020 00000000 00000000 00000000 00000000 ................
0x00010030 00000000 00000000 00000000 00000000 ................
0x00010040 00000000 00000000 00000000 00000000 ................
0x00010050 00000000 00000000 00000000 00000000 ................
0x00010060 00000000 00000000 00000000 00000000 ................
0x00010070 00000000 00000000 00000000 00000000 ................
0x00010080 00000000 00000000 00000000 00000000 ................
0x00010090 00000000 00000000 00000000 00000000 ................
0x000100a0 00000000 00000000 00000000 00000000 ................
0x000100b0 00009600 00000000 00000000 00000000 ................
C’est bien notre header : on reconnaît bien l’instruction d’entrée 0xEA00002E
(ici affiché en litte endian), et 0x96
à la fin (si si, on le voit).
Voyons maintenant notre binaire .gba
:
$ hexdump zig-out/bin/mygbarom.gba
0000000 002e ea00 0000 0000 0000 0000 0000 0000
0000010 0000 0000 0000 0000 0000 0000 0000 0000
*
00000b0 0000 0096 0000 0000 0000 0000 0000 0000
00000c0 e7fe 4770 fffc 7fff b0b0 80b0 fff8 7fff
00000d0 0001 0000
00000d4
Victoire totale ! On voit bien notre fichier commencer par 0xEA00_002E
(toujours en little endian), notre 0x96
, puis à partir de l’offset
0x000_00c0
, notre code !
Dernière étape, la plus cruciale : comment mGBA va réagir ?
mgba zig-out/bin/mygbarom.gba
Si tout se passe bien, vous devriez voir un nouvel écran blanc. C’est
l’émulateur mGBA qui s’est lancé correctement ! Félicitations, mGBA
a bien identifié notre fichier .gba
comme une cartouche !
À première vue, on pourrait se dire que tout s’est bien passé. mGBA bloque parce qu’il est pris dans une boucle infinie.
Mais si on lance :
mgba-qt zig-out/bin/mygbarom.gba
…on tombe sur une erreur mystérieuse.
This game uses a BIOS call that is not implemented.
Please use the official BIOS for best experience.
puis…
The game has crashed with the following error:
Jumped to invalid address: 0E0000C0
Alors là, c’est bizarre. Si vous avez déjà la solution à ce problème, bravo, parce que c’est pas si intuitif.
Passer de ARM à THUMB #
Comme dit précédemment, ARM7TDMI est capable de lire deux types d’assembleurs :
- ARM
- THUMB
Pour rappel, les instructions THUMB sont plus compactes (16 bits au lieu de 32) et donc prennent moins de place et moins de temps à charger.
Pour comprendre ce point, il faut comprendre que la ROM de la cartouche est accessible par un bus 16 bits. Ça veut dire qu’on ne peut la lire que 16 bits par 16 bits. Donc une instruction ARM qui prend 32 bits met deux fois plus de temps qu’une instruction THUMB. Ce n’est pas le cas de la plupart des autres mémoires de la GBA.
Et si on va voir notre fichier ./build.zig
:
pub fn build(b: *std.Build) void {
const target = std.Target.Query{
.cpu_arch = .thumb, // it's thumb!
.cpu_model = .{ .explicit = &std.Target.arm.cpu.arm7tdmi },
.os_tag = .freestanding,
};
// ...
}
Notre code compile en THUMB ! Notre boucle while
infinie est en instruction
THUMB, mais notre processeur initialement lit d’abord les instructions ARM.
On a besoin d’une instruction ARM spécifique pour passer en mode THUMB.
BX
: branch and exchange instruction set.Syntax:
BX Rm
where:
Rm
is a register containing an address to branch to.BX Rm derives the target instruction set from bit[0] of Rm:
- If bit[0] of Rm is 0, the processor changes to, or remains in, ARM state.
- If bit[0] of Rm is 1, the processor changes to, or remains in, Thumb state.
Donc pour bien comprendre, cette instruction sert à sauter à une certaine
adresse tout en changeant de mode d’instruction. BX
se sert du fait
que les adresses doivent être alignées sur 8 pour se servir du dernier
bit de l’adresse donnée pour choisir entre ARM et THUMB.
Exemple :
Imaginons que nous voulions sauter à l’adresse
0xCAFE_0000
en THUMB. On devra donc mettre dans un registre la valeur0xCAFE_0001
. C’est possible car l’adresse0xCAFE_0001
n’est pas alignée, que ce soit avec des instructions de 32 ou 16 bits.
Comment écrire de l’ARM dans notre projet ? Il y a plusieurs moyens, mais nous allons essayer d’aller au plus simple et d’utiliser la fonction d’assembleur inline de Zig.
Donc dans ./src/main.zig
:
export fn _start() noreturn {
asm volatile (
\\.arm
\\.cpu arm7tdmi
\\add r0, pc, #1
\\bx r0
);
while (true) {}
}
asm
permet donc d’écrire de l’assembleur dans Zig. volatile
est une
directive pour le compilateur pour lui dire de ne pas optimiser ou
déplacer ce bout d’assembleur, de le laisser tel quel.
.arm
déclare que l’on écrit de l’assembleur ARM..cpu arm7tdmi
déclare que l’on cible un CPU ARM7TDMI.add r0, pc, #1
est équivalent àr0 = pc + 1
.bx r0
permet de sauter à la valeur contenue dansr0
.
Pour bien comprendre ce qu’il se passe ici, il faut ce souvenir de ce qu’on a vu sur le registre PC. Il ne contient pas l’adresse de l’instruction actuelle, mais l’adresse de l’instruction qui suit l’instruction suivante.
block-beta columns 3 n["Instruction Actuelle N"] n1["N + S"] n2["N + 2S"]
Avec S = 4
pour les instructions ARM et S = 2
pour les instructions THUMB.
On aura donc PC = N + 2S
.
Donc dans notre exemple, on a :
block-beta n["add r0, pc, #1"] n1["bx r0"] n2["notre code..."]
Donc r0
va bien contenir l’adresse du début de notre code THUMB… plus 1.
Pourquoi +1
? Car on veut passer en mode THUMB, donc bit[0]
de r0
doit
être égal à 1.
Voilà ! Il suffit maintenant de zig build
et d’ouvrir mGBA. Normalement,
vous devriez voir le même écran blanc, mais sans erreur et sans crash.
Mais comment être sûr que tout se passe comme on le souhaite ? On peut utiliser le debugger intégré de mgba-qt !
mgba-qt -g zig-out/bin/mygbarom.gba
Le -g
permet de lancer une session GDB, le debogueur GNU que vous connaissez
peut-être. Plus précisément, mGBA va lancer un serveur gdb, auquel
vous pourrez vous connecter avec un client gdb.
$ gdb
(gdb) target remote localhost:2345 # connect to mgba gdb server
(gdb) layout asm # show asm TUI window
(gdb) layout reg # show registers TUI window
(gdb) stepi # go to next assembly instruction
Maintenant, vous allez pouvoir répéter la commande stepi
et voir la GBA
s’exécuter. Comme vous le voyez, elle commence à 0x0000_0000
, saute tout de
suite en 0x0000_0354
(dans le BIOS !), puis après quelques instructions,
sauter en 0x0800_0000
(c’est notre cartouche !) puis en 0x0800_00C0
(après
notre header) et exécuter add r0, pc, #1
puis bx r0
. On peut voir que
r0 = 0x0800_00C9
, ce qui correspond bien à l’adresse 0x0800_00C8
où se trouve
notre code THUMB, plus 1 pour passer en mode THUMB.
Après le saut, on se retrouve donc en 0x0800_00C8
, et on est… bloqué !
L’instruction sur laquelle on est est b.n 0x0800_00C8
, soit “saute à
l’adresse 0x0800_00C8
”. Donc c’est un saut sur place, une boucle infinie.
Exactement ce qu’on voulait.
Victoire !
Conclusion #
On a donc réussi à créer un programme pour Gameboy Advance qui tourne dans émulateur. Bravo si vous avez réussi à lire ou suivre jusque ici !
Je tiens à préciser que nous sommes allés vite. Je n’ai pas abordé toutes les subtilités du format ELF, des linker script, et du fonctionnement de la Gameboy Advance.
Néanmoins, nous avons ensemble pu couvrir des sujets assez pointus, et c’est pas fini ! J’ai l’intention de continuer ce tutoriel pour introduire les sujets des registres mémoire, des interruptions hardware, et de DMA. On entre donc dans la partie qui est aussi couverte par le tutoriel TONC, et je vais beaucoup m’en inspirer.
Mon but n’est pas de vous apprendre à créer un jeu GBA (même si maintenant vous avez une base pour continuer !), mais à comprendre des concepts bas-niveau qui sont souvent cachés derrière des couches d’abstraction mises en place par votre OS.
Mon but est aussi d’apprendre ces concepts plus en profondeur. C’est des sujets que j’ai creusé pendant mes études, et que j’ai plaisir à redécouvrir et à transmettre.
Si vous pensez que j’ai dit des bêtises, n’hésitez pas à me contacter ! De même, si il y a des imprécisions ou des points que vous n’avez pas compris, ça m’aiderait à améliorer cet article.
Pour aller plus loin, je vous invite à aller voir :
- GBATEK (documentation sur la GBA)
- TONC (sur la programation de jeu sur GBA)
- ZigGBA (pour programmer en Zig sur GBA)
Voici le résultat de ce premier (long) chapitre.
$ tree .
.
├── build.zig
├── build.zig.zon
├── gba.ld
├── src
│ └── main.zig
└── zig-out
└── bin
├── mygbarom
├── mygbarom.gba
└── mygbarom.sav
4 directories, 7 files
./src/main.zig
:
const Header = extern struct {
entry_point: u32 align(1) = 0xEA00002E,
nintendo_logo: [156]u8 align(1) = @splat(0x00),
game_name: [12]u8 align(1) = @splat(0x00),
game_code: [4]u8 align(1) = @splat(0x00),
maker_code: [2]u8 align(1) = @splat(0x00),
fixed_value: u8 align(1) = 0x96,
unit_code: u8 align(1) = 0x00,
device_type: u8 align(1) = 0x00,
reserved1: [7]u8 align(1) = @splat(0x00),
software_version: u8 align(1) = 0x00,
complement_check: u8 align(1) = 0x00,
reserved2: [2]u8 align(1) = @splat(0x00),
};
export const header linksection(".gbaheader") = Header{};
export fn _start() noreturn {
asm volatile (
\\.arm
\\.cpu arm7tdmi
\\add r0, pc, #1
\\bx r0
);
while (true) {}
}
./build.zig
:
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = std.Target.Query{
.cpu_arch = .thumb,
.cpu_model = .{ .explicit = &std.Target.arm.cpu.arm7tdmi },
.os_tag = .freestanding,
};
const optimize = .ReleaseSmall;
const exe_mod = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = b.resolveTargetQuery(target),
.optimize = optimize,
});
const exe = b.addExecutable(.{
.name = "mygbarom",
.root_module = exe_mod,
});
exe.setLinkerScript(.{ .src_path = .{
.owner = b,
.sub_path = "gba.ld",
} });
const objcopy_step = exe.addObjCopy(.{ .format = .bin });
const install_bin_step = b.addInstallBinFile(objcopy_step.getOutput(), "mygbarom.gba");
install_bin_step.step.dependOn(&objcopy_step.step);
b.getInstallStep().dependOn(&install_bin_step.step);
b.installArtifact(exe);
}
./gba.ld
:
SECTIONS {
.gbaheader : {
KEEP(*(.gbaheader))
}
.text : {
*(.text)
}
.ARM.exidx : {
*(.ARM.exidx)
}
}