Wiki DEVSE
Ce guide est disponible à l'adresse https://devse.wiki.
Documentation
Ce répertoire GitHub a été créé pour fournir une documentation sur le développement de systèmes d'exploitation en Français. N'hésitez pas à contribuer à la documentation, rajouter des exemples, etc !
Nous ne sommes pas affiliés au site Internet OSDEV, mais au serveur Discord Français DEVSE.
Licence
Cette œuvre est mise à disposition selon les termes de la Licence Creative Commons Attribution 2.0 France.
Introduction
BootLoader
Types de noyaux
Les kernels sont classés en plusieurs catégories, certaines sont plus complexes que d'autres...
Les micro-kernels
Les micro-kernel, sont minimalistes, élégants et résilients aux crashs. Les systèmes basés sur un microkernel sont composés d'une collection de services exécutés dans l'userspace qui communiquent entre eux. Si un service crash il peut être redémarré sans reboot la machine entière. Les premières générations avaient l'inconvénient d'être plus lentes que les kernels monolithiques. Mais cela n'est plus vrai de nos jours: les kernels de la famille L4 n'ont rien à envier en terme de rapidité à leurs homologues monolithiques.
Exemples: Minix, L4, march, fushia
Les exo-kernels
Les exo-kernels, sont une forme plus poussée de micro-kernels, en effet, les exo-kernels ont pour but de placer le noyau dans l'espace utilisateur, essayant de supprimer toutes abstraction entre le kernel et la machine. Générallement le lien entre la machine et l'application et faite à travers une librairie lié dès le démarrage de l'application (par exemple LibOSes). Les applications peuvent donc gérer elles mêmes certaines parties bas niveau et donc avoir de meilleure performance. Cependant le développement d'exo-kernel est très dur.
Exemples: Xen, Glaze
Les kernels monolithiques
La méthode monolithique est la manière classique de structurer un kernel. Un kernel monolithique contient les drivers, le système de fichier, etc, dans l'espace superviseur. Contrairement aux microkernels ils sont gros et lourds, si il y a un crash dans le kernel ou si un service crash, tout crash et il faut reboot la machine. Les kernels monolithiques peuvent être modulaire (comme Linux), cependant les modules sont directements intégrés au noyaux et non à l'espace utilisateur comme le ferrait un micro kernel.
Exemples: Linux, BSDs
Les unikernels
Les unikernels sont spéciaux car ils n'ont pas comme but d'être utilisés sur une machine de travail, mais plutôt sur un serveur. Les unikernels sont souvent utilisés à des fins de virtualisation, comme Docker.
Exemples: IncludeOS, MirageOS, HaLVM, Runtime.js
Références
Créer un cross compilateur GCC (C/C++)
Pourquoi faire un cross compilateur ?
Il faut faire un cross compilateur car le compilateur fournis avec votre système est configuré pour une plateforme cible (CPU, système d'exploitation etc).
Par exemple, comparons deux platformes différentes (Ubuntu x64 et Debian GNU/Hurd i386).
La commandegcc -dumpmachine
nous indique la platforme que cible le compilateur, sur Ubuntu GNU/Linux la commande me retourne x86_64-linux-gnu
tandis que sur Debian GNU/Hurd nous avons i686-gnu
.
Le resultat obtenu n'est pas surprennant, nous avons deux systèmes d'exploitation différent sur du materiel différent.
Ne pas faire un cross compilateur et utiliser le compilateur fournis avec le système c'est allez au devant de toute une série de problèmes.
Quel plateforme cible ?
Tout cela va dépendre de l'architecture que vous ciblez (x86, risc-v) et du format de vos binaires (ELF, mach-o, PE).
Par exemple pour un système x86-64 en utilisant le format ELF: x86_64-elf
Ou encore i686-elf
pour x86 (32bit)
Bien sur en attendant d'avoir notre propre toolchain.
Compiler GCC et les binutils
Maintenant que la théorie à été rapidement esquissée nous allons pouvoir passer à la pratique.
créons un dossier toolchain/local à la racine de notre projet. C'est dans ce dossier que sera notre cross compilateur une fois compilé.
créons donc une variable $prefix
:
prefix="<chemin vers votre projet>/toolchain/local"
Profitons en pour modifier notre $PATH
:
export PATH="$PATH:$prefix/bin"
Puis nous allons définir une variable $target
(qui contiendra notre platforme cible).
Comme dans notre guide nous nous concentrons sur x86-64 notre variable sera définis comme ceci:
target="x86_64-elf"
Nos variables d'environment étant définis nous pouvons passer à l'installation des dépendances.
Dépendance
Pour pouvoir compiler gcc et binutils sous Debian GNU/Linux il nous faut les paquets suivant:
- build-essential
- bison
- flex
- texinfo
- libgmp3-dev
- libmpc-dev
- libmpfr-dev
Que l'on peut les installer simplement comme ceci:
sudo apt install build-essential bison flex libgmp3-dev \
libmpc-dev libmpfr-dev texinfo
Nous allons pouvoir passer à la compilation.
binutils
Commençons par télécharger et décompresser les sources de binutils.
Ici dans ce tutoriel nous compilerons binutils 2.35
.
binutils_version="2.35"
wget "https://ftp.gnu.org/gnu/binutils/binutils-$binutils_version.tar.xz"
tar -xf "binutils-$binutils_version.tar.xz"
Maintenant que l'archive est décompressé nous allons passer à la compilation.
cd "binutils-$binutils_version"
mkdir build && cd build
../configure --prefix="$prefix" --target="$target" \
--with-sysroot --disable-nls --disable-werror
make all -j $(nproc)
make install -j $(nproc)
Comme la compilation risque de prendre un moment, vous pouvez en profiter pour vous faire un café.
gcc
Maintenant les binutils sont compilé, nous allons pouvoir passer à gcc.
Ici nous compilerons gcc 10.2.0
.
gcc_version="10.2.0"
wget http://ftp.gnu.org/gnu/gcc/gcc-$gcc_version/gcc-$gcc_version.tar.xz
tar -xf gcc-$gcc_version.tar.xz
Puis on passe à la compilation:
cd "gcc-$gcc_version"
mkdir build && cd build
../configure --prefix="$prefix" --target="$target" --with-sysroot \
--disable-nls --enable-languages=c,c++ --with-newlib
make -j all-gcc
make -j all-target-libgcc
make -j install-gcc
make -j install-target-libgcc
La encore ça va prendre un certain temps, on peut donc s'accorder une deuxième pause café.
Une fois la compilation terminée vous pouvez utilisez votre cross compilateur, dans le cas de ce tutoriel x86_64-elf-gcc
.
Cependant il faudrait plus tard implémenter une toolchain spécifique pour votre os. C'est une toolchain modifiée pour votre système d'exploitation.
Allocateur de mémoire physique
Un allocateur de mémoire physique
est un algorithme d'allocation 'basique' qui est généralement utilisé par le kernel pour allouer et libérer des pages.
Note : tout au long de ce document, le terme
page
est utilisé comme zone de mémoire qui à pour taille 4096 byte Cette taille peut changer mais pour l'instant il est mieux d'utiliser la même taille de page entre le paging et l'allocateur de mémoire physique
Il doit pouvoir :
- Allouer une/plusieurs page libre
- Libérer une page allouée
- Gérer quelle zone de la mémoire est utilisable ou non
Voici un code C basique présentant les fonctions de base à implémenter pour un allocateur de mémoire physique:
void* alloc_page(uint64_t page_count);
void free_page(void* page_addr, uint64_t page_count);
void init_pmm(memory_map_t memory_map); // PMM = Physical Memory Manager
L'Allocateur de mémoire physique avec une bitmap
Cette partie du document explique comment mettre en place un allocateur de mémoire physique avec une bitmap.
La bitmap est une table de uint64/32/16 ou uint8_t avec chaque bit qui représente une page libre (quand le bit est à 0) ou utilisée (quand le bit est à 1).
Vous pouvez facilement convertir une adresse en index/bit de la table, par exemple :
static inline uint64_t get_bitmap_array_index(uint64_t page_addr)
{
return page_addr/8; // ici c'est 8 car c'est une bitmap avec des uint8_t (soit 8bit)
}
static inline uint64_t get_bitmap_bit_index(uint64_t page_addr)
{
return page_addr%8;
}
La bitmap a l'avantage d'être petite. Par exemple, pour une mémoire de 4Go on a :
((2^32 / 4096) / 8)
= 131 072 byte soit
une bitmap de 128 kb
Il faut aussi savoir que la bitmap à l'avantage d'être très rapide, on peut facilement libérer/allouer une page.
Changer l'état d'une page dans la bitmap
Pour cette partie vous devez placer une variable temporairement nulle... Cette variable est la bitmap qui serra initialisée plus tard, mais vous devez tout d'abord savoir comment changer l'état d'une page.
ici la variable est :
uint8_t* bitmap = NULL;
Avant d'allouer/libérer des pages, il faut les changer d'état, donc mettre un bit précis de la bitmap à 0 ou à 1.
Il suffit de 2 fonctions qui permettent de soit mettre un bit de la bitmap à 0 soit de le mettre à 1 par rapport à une page.
static inline void bitmap_set_bit(uint64_t page_addr)
{
uint64_t bit = get_bitmap_bit_index(page_addr);
uint64_t byte = get_bitmap_array_index(page_addr);
bitmap[byte] |= (1 << bit);
}
static inline void bitmap_clear_bit(uint64_t page_addr)
{
uint64_t bit = get_bitmap_bit_index(page_addr);
uint64_t byte = get_bitmap_array_index(page_addr);
bitmap[byte] &= ~(1 << bit);
}
Initialiser l'allocateur de mémoire physique
L'allocateur de mémoire physique doit être initialisé le plus tôt possible, vous devez avoir au moins la carte de la mémoire (quelle zone est libre et quelle zone ne l'est pas) généralement fournie par le bootloader.
cependant vous devez calculer avant la future taille de la bitmap, générallement la taille de la mémoire est la fin de la dernière entrée de la carte de la mémoire.
uint64_t memory_end = memory_map[memory_map_size].end;
uint64_t bitmap_size = memory_end / (PAGE_SIZE*8);
Après avoir obtenu la taille de la future bitmap vous devez trouver une place pour la positionner.
Vous devez trouver une entrée valide de la carte de la mémoire et placer la bitmap au début de cette entrée.
for(int i = 0; i < mem_map.size && bitmap==NULL; i++)
{
mem_map_entry_t entry = mem_map.entry[i];
if(entry.is_free && entry.size >= bitmap_size)
{
bitmap = entry.start;
}
}
Ensuite, pour chaque entrée de la carte de la mémoire vous devez mettre la région de la bitmap en utilisée ou libre. On peut mettre par défaut toute la bitmap comme utilisée ainsi que la mettre libre seulement quand c'est nécessaire.
uint64_t free_memory = 0;
memset(bitmap, 0xff, bitmap_size); // mettre toutes les pages comme utilisées
for(int i = 0; i < mem_map.size; i++)
{
mem_map_entry_t entry = mem_map.entry[i];
// en espérant ici que entry.start et entry.end sont déjà aligné par rapport à une page
if(entry.is_free)
{
for(uint64_t j = entry.start; j < entry.end; j+=PAGE_SIZE)
{
bitmap_clear_bit(j/PAGE_SIZE);
free_memory += PAGE_SIZE;
}
}
}
Cependant, la zone où est placée la bitmap est marquée comme libre. Une tâche peut donc écraser cette zone et causer des problèmes... Vous devez par conséquent marquer la zone de la bitmap comme utilisée :
uint64_t bitmap_start = (uint64_t)bitmap;
uint64_t bitmap_end = bitmap_start + bitmap_size;
for (uint64_t i = bitmap_start; i <= bitmap_end; i+= PAGE_SIZE)
{
bitmap_set_bit(i/PAGE_SIZE);
}
L'allocation, la recherche et la libération de pages
Une fois votre bitmap initialisée vous pouvez mettre une page comme libre ou utilisée. Ainsi, vous pouvez commencer à implémenter des fonction d'allocation et de libération de pages. Cependant, vous devez commencer par vérifier si une page est utilisée ou libérée (ou si le bit d'une page est à 0 où à 1) :
static inline bool bitmap_is_bit_set(uint64_t page_addr)
{
uint64_t bit = get_bitmap_bit_index(page_addr);
uint64_t byte = get_bitmap_array_index(page_addr);
return bitmap[byte] & (1 << bit);
}
L'allocation de page
Une fonction d'allocation de page doit avoir comme argument le nombre de pages allouées et doit retourner des pages qui seront marquées comme utilisées.
Pour commencer, vous devez mettre en place une fonction qui cherche et trouve de nouvelles pages:
// note ici c'est la fonction brut, il y a plusieurs optimizations possiblent qui serront abordés plus tard
uint64_t find_free_pages(uint64_t count)
{
uint64_t free_count = 0; // le nombre de pages libres de suite
for(int i = 0; i < (mem_size/PAGE_SIZE); i++)
{
if(!bitmap_is_bit_set(i))
{
free_count++; // on augmente le nombre de page trouvées d'affilée de 1
if(free_count == count)
{
return i;
}
}
else
{
free_count = 0;
}
}
return -1; // il n'y a pas de page libres
}
find_free_page
donne donc count
pages libre
Après avoir trouvé les pages, vous devrez les mettre comme utilisées:
void* alloc_page(uint64_t count)
{
uint64_t page = find_free_pages(count); // ici pas de gestion d'erreur mais vous pouvez vérifier si il n'y a plus de pages disponibles
for(int i = page; i < count+page; i++)
{
bitmap_set_bit(i);
}
return (void*)(page*PAGE_SIZE);
}
Vous avez désormais un allocateur de mémoire physique fonctionnel !
La libération de page
Après avoir alloué des pages vous devez pouvoir les libérer.
Le fonctionnement est plus simple que l'allocation, vous devez juste mettres les bits des pages à 0.
Note : Ici il n'y a pas de vérification d'erreur car c'est un exemple.
void free_page(void* addr, uint64_t page_count)
{
uint64_t target= ((uint64_t)addr) / PAGE_SIZE;
for(int i = target; i<= target+page_count; i++)
{
bitmap_clear_bit(i);
}
}
Cette fonction met juste les bit de la bitmap à 0.
Les optimisations
L'allocation de pages comme ici est très lente, à chaque fois on revient à 0 pour chercher une page et cela peut ralentir énormément le système. On peut donc mettre en place plusieurs optimizations:
Une optimisation basique serait de créer une variable last_free_page qui donne la dernière page libre à la place de toujours revenir à la page 0 pour en chercher une nouvelle. Cela améliore largement les performances et est relativement simple à mettre en place:
uint64_t last_free_page = 0;
uint64_t find_free_pages(uint64_t count)
{
uint64_t free_count = 0;
for(int i = last_free_page; i < (mem_size/PAGE_SIZE); i++)
{
if(!bitmap_is_bit_set(i))
{
free_count++; trouvées d'affilée de 1
if(free_count == count)
{
last_free_page = i; // la dernière page libre
return i;
}
}
else
{
free_count = 0;
}
}
return -1; // il n'y a pas de page libres
}
Cependant, si on ne trouve pas de page libre à partir de la dernière page libre, il peut en avoir avant. Il faut donc réésayer en mettant le nombre de page libre à zéro.
// à la fin de la fonction find_free_pages()
if(last_free_page != 0)
{
last_free_page = 0;
return find_free_pages(count); // juste réésayer mais avec la dernière page libre en 0x0
}
return -1;
Vous pouvez aussi faire en sorte que la dernière page libre soit automatiquement remise à la dernière page libérée dans free_page:
// free_page()
last_free_page = page_addr;
une autre optimisation serait dans find_free_page; on peut utiliser la capacité du processeur à faire des vérification avec des nombres 64, 32, 16 et 8 bits pour que cela soit plus rapide. En sachant que dans une bitmap, quand il y a une entrée de la table totallement pleine, tous les bits sont à 1 donc ils sont donc à 0b11111111
= 0xff
On peut donc rajouter (sans le code pour last_free_page pour que cela soit plus compréhensible)
uint64_t find_free_pages(uint64_t count){
int i = 0;
for(int i = 0; i < (mem_size/PAGE_SIZE); i++)
{
// vous pouvez aussi utiliser des uint64_t ou n'importe quel autres types
while(bitmap[i/8] == 0xff && i < (mem_size/PAGE_SIZE)-8)
{
free_count = 0; // en sachant que les pages sont utilisées, alors on reset le nombre de page libres de suite
i += 8- (i % 8); // rajouter mettre i au prochain index de la bitmap
}
if(!bitmap_is_bit_set(i))
{
free_count++; // trouvées d'affilée de 1
if(free_count == count)
{
return i;
}
}
else
{
free_count = 0;
}
}
return -1;
}
Maintenant vous pouvez utiliser votre allocateur de mémoire physique principalement pour le paging ou pour un allocateur plus 'intelligent' (malloc/free/realloc) !
Advanced Programmable Interrupt Controller
Local APIC
Le local apic est une entrée de la MADT, son type est 0.
Le nombre d'entrées locales APIC dans la MADT équivaut au nombre de CPUs, chaque CPU a son local APIC.
La structure de l'entrée du local APIC est:
offset/taille (en byte) | nom |
---|---|
2 / 1 | identifiant ACPI |
3 / 1 | identifiant APIC |
4 / 4 | flag du cpu |
Les framebuffer
Le framebuffer est fourni par le bootloader, le bootloader doit fournir aussi la taille de ce frambuffer, en largeur et en hauteur, il fournit aussi le nombre de bit par pixel. Ces framebuffers utilisent le VGA (ou le vbe).
Il y a deux type de framebuffers:
- Les framebuffers de textes: l'écran est une grille de caractère, on peut seulement faire du texte, on le nomme aussi le vga en mode texte.
- Les framebuffers de pixels: l'écran est une grille de pixels, on peut éditer pixels par pixels.
C'est un moyen basique de dessiner l'écran dans un kernel, cependant pour faire certaines choses plus compliqués nous sommes obligé d'utiliser, soit un driver gpu, soit un driver de gpu virtuel (seulement utile dans une machine virtuelle, comme qemu
). C'est donc au CPU de faire le rendu.
Les framebuffers textes
Les framebuffers de textes utilisent 16bit pour chaque caractères: 8 pour la couleur, et 8 pour le caractère:
bits | significations |
---|---|
0-7 | caractère ASCII |
8-11 | couleur du texte |
12-15 | couleur de fond |
Les couleurs sont formées comme ceci:
valeur | couleur |
---|---|
0 | noir |
1 | bleu |
2 | vert |
3 | cyan |
4 | rouge |
5 | magenta |
6 | marron |
7 | gris clair |
8 | gris |
9 | bleu clair |
10 | vert clair |
11 | cyan clair |
12 | rouge clair/rose |
13 | magenta clair |
14 | jaune |
15 | blanc |
Les framebuffers de pixels
Les framebuffers de pixels sont généralement plus simple, cependant ici nous prenons en compte que si le nombre de bit par pixels sont à 24 ou a 32, car les autres valeurs ne sont plus utilisés. Il est plus facile d'utiliser un framebuffer de 32 bit de pixels car l'alignement est automatique, mais il utilise 33% plus de mémoire, contre celui à 24 bit par pixels qui économise de la mémoire mais l'accès aux pixels est plus compliquée.
byte | couleur |
---|---|
0 | valeur du bleu (0-255) |
1 | valeur du vert (0-255) |
2 | valeur du rouge (0-255) |
3 | byte utilisé pour l'alignement (seulement quand bpp = 32) |
L'utilisation d'un framebuffer 32bpp est plus rapide car nous pouvons utiliser le framebuffer comme une table de uint32_t
, contre le 24bpp ou nous sommes obligé de le convertir en table de uint8_t
pour ensuite accéder aux couleurs.
Le port
Introduction
Les ports COM étaient, à l'époque, couramment utilisés comme ports de communication. Même si aujourd'hui, l'USB a remplacé le port COM, il reste néanmoins très utile et toujours supporté par nos machines.
Même s'ils sont obsolètes, les ports COM sont encore beaucoup utilisés pour le développement de systèmes d'exploitation. Ils sont très simples à implémenter et sont très utiles pour le débogage, car, dans presque toutes les machines virtuelles, on peut obtenir la sortie d'un port COM vers un fichier, un terminal ou autre. Ils sont aussi très utiles car on peut les initialiser très tôt et donc avoir des informations de débogage efficacement.
Par exemple, les ports série peuvent envoyer des données et en recevoir, ce qui pourrait, par exemple nous permettre de faire un terminal externe en utilisant uniquement ce port.
La norme RS-232 (qui a été révisée maintes et maintes fois) est une norme qui standardise les ports série. Existant depuis 1981, elle standardise les noms (COM1, COM2, COM3, etc), limite la vitesse à 19200 Baud (cela représente théoriquement un débit de 19200 bits par seconde), ce qui pourrait être largement assez pour un petit terminal.
la limite étant calculée en Baud, celui-ci s'exprimant en bit/s, 1 baud correspond donc à 1 bit par seconde. La limite dépend également de la distance du raccord avec le fil, un fil long a une capacité moindre qu'un fil court.
Initialisation
Chaque port a besoin d'être initialisé avant son utilisation.
Pour commencer, il y a quelques valeurs constantes à connaître pour chaque port COM.
Le port Com | L'id du port | Son IRQ |
---|---|---|
COM1 | 0x3F8 | 4 |
COM2 | 0x2F8 | 3 |
COM3 | 0x3E8 | 4 |
COM4 | 0x2E8 | 3 |
Puis, il y a l'offset. Chaque offset a certaines particularités. (= ID DU PORT + OFFSET)
offset | action |
---|---|
0 | Le port Data du COM, il est utilisé pour envoyer et recevoir des données, si le bit DLAB = 1 alors c'est pour mettre le diviseur du Baud (les bits inférieurs) |
1 | Le port Interrupt du COM, il est utilisé pour activer les Interrupt du port, si le bit DLAB = 1 alors c'est pour mettre la valeur du diviseur (du Baud aussi mais pour les bits supérieurs) |
2 | L'identificateur d'Interrupt ou le controleur FIFO |
3 | le control de ligne (Le bit le plus haut est celui pour DLAB) |
4 | Le control de Modem |
5 | Le status de la ligne |
6 | Le status de Modem |
7 | Le scratch register |
Pour mettre DLAB il faut mettre le port comme indiqué :
PORT + 3 = 0x80 = 128 = 0b10000000
outb(COM_PORT + 3, 0x80);
Pour le désactiver, il faut juste remettre le bit 8 à 0.
Les Baud
Le port COM se met à jour 115200 fois par seconde. Pour controller la vitesse, il faut mettre en place un diviseur, que l'on peut utiliser en activant le DLAB.
Ensuite, il faut passer la valeur par l'offset 0 (les bits inférieurs) et 1 (les bits supérieurs).
Exemple permettant de mettre un diviseur de 5 (alors le port auras un 'rate' de 115200 / 5) :
outb(COM_PORT + 3, 0x80); // activer le DLAB
outb(COM_PORT + 0, 5); // les bits les plus petits
outb(COM_PORT + 1, 0); // les bits les plus hauts
La taille des données
On peut mettre la taille des données envoyées au port COM par update. Celle-ci peut aller de 5 bits à 8 bits
5bits = 0 0 (0x0)
6bits = 0 1 (0x1)
7bits = 1 0 (0x2)
8bits = 1 1 (0x3)
Pour définir la taille des données, vous devez l'écrire dans le port de contrôle de ligne (les bits les plus petits) avoir configuré le rate du port (et donc d'avoir activé le DLAB).
outb(COM_PORT + 3, 0x3); // désactiver le DLAB + mettre la taille de donnée à 8 donc un char/unsigned char en c++
Références
Codes d'erreur d'interruption
Toutes les interruptions entre 0 et 32 sont des interruptions d'erreur.
Certains codes d'erreur peuvent être corrigés après le retour de l'interruption. Cependant, d'autres ne peuvent pas l'être.
Id | Nom | contient un code d'erreur ? | Descriptions |
---|---|---|---|
0 | Division par 0 | non | Cette erreur est produite quand l'instruction DIV/IDIV est utilisée avec un 0 |
1 | Debug | non | Cette erreur intentionnelle est généralement utilisée pour déboguer |
2 | Interruption NMI | non | L'interruption NMI est une interruption causée par des éléments externes comme la RAM |
3 | Breakpoint | non | Cette erreur intentionnelle est généralement utilisée pour le débogage |
4 | Dépassement | non | L'interruption 4 est causée lorsque l'instruction INTO est éxécuté alors que le bit 11 de RFLAGS est mis à 1.Note : l'erreur n'est pas possible en 64 bits car l'instruction INTO n'est pas disponible en mode long . |
5 | Dépassement de table | non | L'interruption 5 est causée lorsque l'instruction BOUND est exécutée quand opérateur 1 n'est pas dans la taille de table définie dans l'opérateur 2.Note : l'erreur n'est pas possible en 64 bits car l'instruction BOUND n'est pas disponible en mode long . |
6 | Instruction non valide | non | L'interruption 6 est causée lorsque : - On essaye d'accéder à un registre non existant - On essaye d'exécuter une instruction non disponible - UD est exécuté |
7 | Appareil non disponible | non | L'interruption 7 est appelée lorsqu'on essaye d'initialiser le FPU alors qu'il n'existe pas |
8 | Faute Double | oui | La faute double est appelée lorsqu'il y a une erreur pendant que l'interruption d'erreur est appelée (une erreur dans une erreur) |
9 | Erreur de Segment de coprocesseur | non | Cette erreur n'est plus utilisée. |
10 | TSS invalide | oui (code d'erreur de segment) | L'interruption TSS invalide est exécutée lorsque le sélecteur de segment pour la TSS est invalide. Causée pendant un changement de tâche ou pendant l'accès de la TSS |
11 | Segment non présent | oui (code d'erreur de segment) | L'interruption "Segment non présent" est exécutée lorsqu'on essaye de charger un segment qui a son bit présent à 0 |
12 | Segment de pile invalide | oui (code d'erreur de segment) | L'interruption "Segment de pile" invalide est causée lorsque : - On charge un segment de pile qui n'est pas présent - La vérification de la limite de pile n'est pas possible - (64bit) On essaye de faire une opération qui fait une référence à la mémoire en utilisant le pointeur de pile (RSP) qui contient une adresse mémoire non canonique - Le segment de pile n'est pas présent pendant une opération qui fait référence au registre SS , (comme pop , push , iret ...) |
13 | Faute générale de protection | oui (code d'erreur de segment) | L'interruption n°13 peut être causée par beaucoup de raisons, comme : - L'écriture d'un 1 dans une zone du registre CR4 réservée - L'utilisation une instruction SSE qui essaye d'accéder une zone de la mémoire 128 bits qui n'est pas alignée en 16bit - Une pile de mémoire non alignée en 16bit [...]. Voir le manuel Intel pour plus d'informations (chap 3 6.15.13) |
14 | Faute de page | oui (code d'erreur de page) | L'interruption n°14 peut être causée lorsque : - Il y a une erreur en relation avec le paging - On essaye d'accéder à une zone de la mémoire qui n'a pas de table présente - On essaye de charger une table et que la zone ou on éxécute le code n'est pas exécutable dans la page - Un problème d'autorisation est causé (ex: écrire dans une zone de la mémoire qui ne peut pas être écrite) [...] Voir le manuel Intel pour plus d'informations (chap 3 6.15.14) |
15 | Réservé | non | // |
16 | Faute du FPU x87 | non | L'interruption n°16 est causée lorsqu'il y a une erreur pendant une instruction du FPU, une opération invalide, une division par 0, un dépassement numérique, un résultat non exact, ou lorsque le bit 5 du registre CR0 = 1 |
17 | Faute d'alignement | oui | Produite lorsque le bit 18 de CR0 et RFLAGS sont égaux à 1. L'erreur est causée lorsqu'une référence de mémoire est non alignée. |
18 | Faute de vérification de machine | non | Produite lorsque le bit 6 du CR4 est égal à 1. L'erreur est causée lorsque le CPU détecte une erreur de machine, comme un problème de bus, cache, mémoire, ou une erreur interne. |
19 | Exception de variable a virgule SIMD | non | L'interruption n°19 est appelé lorsqu'il y a une erreur avec les nombres à virgule pendant une opération SSE : division par 0, dépassement numérique, résultat non exact [...] |
20 | Exception de virtualisation | non | L'exception de virtualisation est appelée lorsqu'il y a une violation de droits avec une instruction EPT |
21 à 31 | réservé | non | // |
/// | Faute triple | non | L'exception faute triple est exécutée lorsqu'il y a une erreur pendant l'interruption de faute double (une erreur dans une erreur dans une erreur). L'interruption faute triple cause un redémarrage de la machine. |
Codes d'erreur d'une faute de page
BIT | NOM | DESCRIPTION |
---|---|---|
0 | P | (p=1) Violation de protection (p=0) la page n'est pas présente |
1 | W | (W=0) Causée par une lecture (W=1) Causée par une écriture |
2 | U | (U=1) La page n'est pas utilisateur alors que CPL = 3 |
3 | R | (R=1) La page contient un bit réservé |
4 | I | (I=1) Lecture à cause d'une instruction |
5 | PK | (PK=1) Violation de droit de clé |
6 | SS | (SS=1) Accès à "l'ombre de la pile" |
7-31 | // | Réservé |
Lors d'une faute de page, l'addresse qui a causé l'exception est stockée dans CR2
.
Codes d'erreur d'une faute générale de protection
BIT | NOM | TAILLE | DESCRIPTION |
---|---|---|---|
0 | E | 1 | (E=1) Provient d'un appareil externe au processeur |
1 | TBL | 2 | (TBL=0) Provient de la GDT (TBL=1) Provient de l' IDT (TBL=2 & TBL=3) Provient de la LDT |
3 | Index | 13 | Index de la table sélectionnée dans TBL |
Symmetric Multiprocessing
Un peu de vocabulaire
Les termes "coeurs" et "CPU" seront utilisés tout au long de ce tutoriel. Ils représentent tous deux la même entité, à savoir, une unité centrale de traitement. Vous aurez remarqué que ce groupe nominal barbare peut être littéralement traduit par "Central Processing Unit", ou CPU.
Le terme "thread" désigne un fil d'instructions, exécuté en parallèle à d'autres threads ; ou, autrement dit, un flot d'instructions dont l'exécution n'interfère généralement pas avec l'exécution d'un autre flot d'instructions.
Prérequis
Dans ce tutoriel, pour implémenter le SMP, nous prenons en compte que vous avez déjà implémenté la base de votre noyau :
On considère aussi que la structure de votre noyau est composée de ces caractéristiques :
- Une architecture higher-half
- Un support du 64 bits
- Un système de temporisation
Introduction
Qu'est ce que le SMP ?
SMP est un sigle signifiant "Symetric Multi Processing", que l'on pourrait littéralement traduire par "Multi-traîtement symétrique". On utilise ce terme pour parler d'un système multiprocesseur, qui exploite plusieurs CPUs de façon parallèle. Un noyau qui supporte le SMP peut bénéficier d'énormes améliorations de performances.
En sachant que - généralement - un processeur possède 2 threads par coeur, pour un processeur de 8 coeurs il y aura 16 threads exploitables.
Le SMP est différent de NUMA, les processeurs NUMA sont des processeurs dont certains de leurs coeurs n'ont pas accès à toute la mémoire.
Il est utile de savoir qu'il faudra implémenter les interruptions APIC pour les autres CPUs, ce qui n'est pas abordé dans ce tutoriel (pour l'instant).
Obtenir le numéro du coeur actuel
Obtenir le numero du coeur actuel est très important pour plus tard, il permet d'identifier le CPU sur lequel on travaille.
Pour obtenir l'identifiant du CPU actuel on doit utiliser l'APIC. Le numéro du CPU est contenu dans le registre 20 de l'APIC, et il est situé du 24ème au 32ème bit, il faut donc décaler à droite la valeur lue de 24 bits.
#define LAPIC_REGISTER 20
uint32_t get_current_processor_id()
{
return apic_read(LAPIC_REGISTER) >> 24;
}
Obtenir les entrées Local APIC
Voir : LAPIC
Pour commencer à utiliser le SMP, il faut obtenir les entrées LAPIC de la table MADT. Chaque CPU posède une entrée LAPIC.
Pour connaitre le nombre total de CPUs il suffit donc de compter le nombre de LAPIC dans la MADT.
Ces entrées LAPIC ont deux valeurs importantes:
ACPI_ID
: un identifiant utilisé par l'ACPI,ACIC_ID
: un identifiant utilisé par l'APIC pendant l'initialisation.
Généralement, sur les processeurs modernes, ACPI_ID
et APIC_ID
sont égaux, mais ce n'est pas toujours le cas.
Pour utiliser les autres CPU, il faudra faire attention : le CPU principal (celui sur lequel votre kernel démarre) est aussi dans la liste. Il faut donc vérifier que le CPU que l'on souhaite utiliser est libre. Pour cela, il suffit de comparer l'identifiant du CPU actuel avec l'identifiant du CPU de l'entrée LAPIC
.
// lapic_entry : entrée LAPIC que l'on est en train de manipuler
if (get_current_processor_id() == lapic_entry.apic_id) {
// On est actuellement en train de traiter le CPU principal, attention à ne pas faire planter votre kernel!
} else {
// Ce CPU n'est pas le CPU principal, on peut donc s'en servir librement.
}
Pre-Initialisation
Pour utiliser les CPUs, il faut d'abord les préparer, en particulier préparer l'IDT, la table de page, la GDT, le code d'initialisation...
On place donc tout ceci de cette façon :
Entrée | Adresse |
---|---|
Code du trampoline | 0x1000 |
Pile | 0x570 |
GDT | 0x580 |
IDT | 0x590 |
Table de page | 0x600 |
Adresse de saut | 0x610 |
Il faut savoir que tout ceci est temporaire, tout devra être remplacé plus tard.
GDT + IDT
Pour stocker la GDT et l'IDT, c'est assez simple. Il existe deux instructions en 64 bits qui sont dédiées:
sgdt [adresse]
pour stocker la GDT à une adresse précise,sidt [adresse]
pour stocker l'IDT à une adresse précise.
Dans notre cas on a donc:
sgdt [0x580] ; stockage de la GDT
sidt [0x590] ; stockage de l'IDT
Pile
Pour initialiser la pile on doit stocker une adresse valide à l'adresse 0x570
:
POKE(570) = stack_address + stack_size;
Code du trampoline
Pour le trampoline nous avons besoin d'un code écrit en assembleur, délimité par trampoline_start
et trampoline_end
.
Le code trampoline doit être chargé à partir de l'adresse 0x1000
, ce qui donne pour la partie cpp :
#define TRAMPOLINE_START 0x1000
// On calcule la taille du programme trampoline pour copier son contenu
uint64_t trampoline_len = (uint64_t)&trampoline_end - (uint64_t)&trampoline_start;
// On copie le code trampoline au bon endroit
memcpy((void *)TRAMPOLINE_START, &trampoline_start, trampoline_len);
et dans le code assembleur, on spécifie le code trampoline avec :
trampoline_start:
; code du trampoline
trampoline_end:
Addresse de saut
L'addresse de saut est l'adresse à laquelle va se rendre le CPU juste après son initialisaiton, on y met donc le programme principal.
Table de page pour le futur CPU
Pour le futur CPU on peut choisir de prende une copie de la table de page actuelle, mais attention il faut effectuer une copie, et pas simplement une référence à l'ancienne, sinon des évènements étranges peuvent avoir lieu.
Chargement du CPU
Pour initialiser le nouveau CPU, il faut demander à l'APIC de le charger.
Pour ce faire, on utilise les deux registres de commande d'interuptions ICR1
(registre 0x0300
) et ICR2
.
Pour initialiser le nouveau CPU il faut envoyer à l'APIC l'identifiant du nouveau CPU dans ICR2
et l'interuption d'initialisation dans ICR1
:
// On écrit l'identifiant du nouveau CPU dans ICR2, attention à bien utiliser son identifiant APIC
write(icr2, (apic_id << 24));
// On envoie la demande d'initialisation
write(icr1, 0x500);
L'initialisation peut être un peu longue, il faut donc attendre au moins 10 millisecondes avant de l'utiliser.
On commence par envoyer le nouveau CPU à l'adresse trampoline, là encore à travers l'APIC. L'identifiant du CPU va encore dans ICR2
, et l'instruction à écrire dans ICR1
devient 0x0600 | (trampoline_addr >> 12)
:
// Chargement de l'identifiant du nouveau CPU
write(icr2, (apic_id << 24));
// Chargement de l'adresse trampoline
write(icr1, 0x600 | ((uint32_t)trampoline_addr / 4096));
Le code du trampoline
Pour commencer, on peut simplement utiliser le code suivant, qui envoie le caractère a
sur le port COM0
.
Ce code est bien sûr temporaire, mais permet de vérifier que le nouveau CPU démarre correctement.
mov al, 'a'
mov dx, 0x3F8
out dx, al
Lorsque le CPU est initialisé il est en 16 bits, il le sera donc aussi lors de l'exécution du trampoline. Il faut donc penser à modifier la configuration du CPU pour le passer en 64 bits. On aura donc 3 parties dans le trampoline : pour passer de 16 à 32 bits, puis de 32 à 64 bits et enfin le trampoline final en 64 bits :
[16 bits]
trampoline_start:
trampoline_16:
;...
[32 bits]
trampoline_32:
;...
[64 bits]
trampoline_64:
;...
trampoline_end:
Le code 16 bits
Note : trampoline_addr est l'addresse ou vous avez placé votre trampoline, dans ce cas, 0x1000
.
On commence par passer de 16 bits à 32 bits.
Pour cela, il faut initialiser une nouvelle GDT et mettre le bit 0 du cr0
à 1 pour activer le mode protégé :
cli ; On désactive les interrupt, c'est important pendant le passage de 16 à 32 bits
mov ax, 0x0 ; On initialise tous les registres à 0
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
On doit créer une GDT 32 bits pour le 32 bit, on procède donc ainsi :
align 16
gdt_32:
dw gdt_32_end - gdt_32_start - 1
dd gdt_32_start - trampoline_start + trampoline_addr
align 16
gdt_32_start:
; descripteur NULL
dq 0
; descripteur de code
dq 0x00CF9A000000FFFF
; descripteur de donné
dq 0x00CF92000000FFFF
gdt_32_end:
Et on doit maintenant charger cette GDT :
lgdt [gdt_32 - trampoline_start + trampoline_addr]
On peut donc activer le mode protégé :
mov eax, cr0
or al, 0x1
mov cr0, eax
...Puis sauter en changeant le segment code vers l'entrée 0x8
de la GDT :
jmp 0x8:(trampoline32 - trampoline_start + trampoline_addr)
Le code 32 bits
On doit dans un premier temps charger la table de page dans le cr3
, puis activer le paging et le PAE du cr4
en activant les bits 5 et 7 du registre cr4
:
; Chargement de la table de page :
mov eax, dword [0x600]
mov cr3, eax
; Activation du paging et du PAE
mov eax, cr4
or eax, 1 << 5
or eax, 1 << 7
mov cr4, eax
On active maintenant le mode long, en activant le 8ème bit de l'EFER (Extended Feature Enable Register) :
mov ecx, 0xc0000080 ; registre efer
rdmsr
or eax,1 << 8
wrmsr
On active ensuite le paging en écrivant le 31ème bit du registre cr0
:
mov eax, cr0
or eax, 1 << 31
mov cr0, eax
Et pour finir il faut créer puis charger une GDT 64 bits :
align 16
gdt_64:
dw gdt_64_end - gdt_64_start - 1
dd gdt_64_start - trampoline_start + trampoline_addr
align 16
gdt_64_start:
; null selector 0x0
dq 0
; cs selector 8
dq 0x00AF98000000FFFF
; ds selector 16
dq 0x00CF92000000FFFF
gdt_64_end:
; Chargement de la nouvelle GDT
lgdt [gdt_64 - trampoline_start + trampoline_addr]
On peut ensuite passer à la section 64 bits, en utilisant l'instruction jmp
comme précédement :
; jmp 0x8 : permet de charger le segment de code de la GDT
jmp 0x8:(trampoline64 - trampoline_start + trampoline_addr)
Le code 64 bits
On commence par définir les valeurs des registre ds
, ss
et es
en fonction de la nouvelle GDT :
mov ax, 0x10
mov ds, ax
mov es, ax
mov ss, ax
mov ax, 0x0
mov fs, ax
mov gs, ax
Et on charge ensuite la GDT, l'IDT et la stack au bon endroit :
; Chargement de la GDT
lgdt [0x580]
; Chargement de l'IDT
lidt [0x590]
; Chargement de la stack
mov rsp, [0x570]
mov rbp, 0x0
On doit ensuite passer du code trampoline au code physique à exécuter sur ce nouveau CPU.
C'est à ce moment que on doit activer certains bits de cr4
et cr0
et surtout le SSE !
jmp virtual_code
virtual_code:
mov rax, cr0
; Activation du monitoring de multi-processeur et de l'émulation
btr eax, 2
bts eax, 1
mov cr0, rax
Enfin, pour terminer l'initialisation de ce nouveau CPU il faut finir par :
mov rax, [0x610]
jmp rax
Note de fin
Le nouveau CPU est maintenant fonctionnel, mais ce n'est pas encore fini. Il faut mettre en place un système de lock pour la communication inter-CPU, mettre à jour le multitasking pour utiliser ce nouveau CPU, charger une GDT, un IDT et une stack unique...
Références
Verrou
Le verrou est utilisé pour qu'un même code soit exécuté par un thread à la fois.
On peut, par exemple, utiliser un verrou pour un driver ATA, afin qu'il n'y ait plusieurs écritures en même temps. On utilise alors un verrou au début de l'opération que l'on débloque à la fin.
Un équivalent en code serait:
struct Lock lock;
void ata_read(/* ... */)
{
acquire(&lock);
/* ... */
release(&lock);
};
Prérequis
Même si le verrou utilise l'instruction lock
il peut être utilisé même si la machine ne possède qu'un seul processeur.
Pour comprendre le verrou il faut avoir un minimum de base en assembleur.
L'instruction LOCK
l'instruction lock
est utilisée juste avant une autre instruction qui accède / écrit dans la mémoire.
Elle permet d'obtenir la possession exclusive de la partie du cache concernée le temps que l'instruction s'exécute. Un seul CPU à la fois peut exécuter l'instruction.
Exemple de code utilisant le lock :
lock bts dword [rdi], 0
Verrouillage & Déverrouillage
Code assembleur
pour verrouiller on doit implémenter une fonction qui vérifie le vérrou, si il est à 1, alors le verrou est bloqué, on doit attendre. si il est à 0, alors le verrou est débloqué, c'est notre tour.
pour le déverrouiller on doit juste mettre le vérou à 0.
pour le verrouillage le code pourrait ressembler à ceci :
locker:
lock bts dword [rdi], 0
jc spin
ret
spin:
pause ; pour éviter au processeur de surchauffer
test dword [rdi], 0
jnz spin
jmp locker
Ce code test le bit 0 de l'addresse contenu dans le registre rdi
(registre utilisé pour les arguments de fonctions en 64bit)
lock bts dword [rdi], 0
jc spin
si le bit est à 0 il le met à 1 et CF à 0 si le bit est à 1 il met CF à 1
jc spin jump à spin seulement si CF == 1
pour le déverrouillage le code pourrait ressembler à ceci :
unlock:
lock btr dword [rdi], 0
ret
il réinitialise juste le bit contenu dans rdi
Maintenant on doit rajouter un temps mort
parfois si un CPU a crash ou a oublié de déverrouiller un verrou il peut arriver que les autres CPU soient bloqués? Il est donc recommandé de rajouter un temps mort pour signaler l'erreur.
locker:
mov rax, 0
lock bts dword [rdi], 0
jc spin
ret
spin:
inc rax
cmp rax, 0xfffffff
je timed_out
pause ; pour gagner des performances
test dword [rdi], 0
jnz spin
jmp locker
timed_out:
; code du time out
Le temps pris ici est stocké dans le registre rax
.
Il incrémente chaque fois et si il est égal à 0xfffffff
alors il saute à timed_out
On peut utiliser une fonction C/C++ dans timed_out
Code C
Dans le code C on peut se permettre de rajouter des informations au verrou. On peut rajouter le fichier, la ligne, le cpu etc... cela permet de mieux débugger si il y a une erreur dans le code
Les fonction en c doivent être utilisées comme ceci :
void lock(volatile uint32_t* lock);
void unlock(volatile uint32_t* lock);
Si on veut rajouter plus d'informations au lock on doit faire une structure contenant un membre 32bit
struct verrou
{
uint32_t data; // ne doit pas être changé
const char* fichier;
uint64_t line;
uint64_t cpu;
} __attribute__(packed);
Vous devez maintenant rajouter des fonction verrouiller et déverrouiller qui appelleront respectivement lock et unlock
Note : si vous voulez avoir la ligne/le fichier, vous devez utiliser des #define et non des fonction
void verrouiller(verrou* v)
{
// code pour remplir les données du vérrou
lock(&(v->data));
}
void deverrouiller(verrou* v)
{
unlock(&(v->data));
}
Maintenant vous devez implementer la fonction qui serra appelé dans timed_out
void crocheter_le_verrou(verrou* v)
{
// vous pouvez log des informations importantes ici
}
maintenant vous pouvez choisir entre 2 possibilité :
-
dans la fonction crocheter_le_verrou vous continuez en attandant jusqu'à ce que le verrou soit déverrouillé
-
dans la fonction crocheter_le_verrou vous devez mettre le membre
data
du vérou v à 0, ce qui forcera le verrou à être déverrouiller
Utilisation
Maintenant, pour utiliser votre verrou, vous pouvez juste faire
struct Lock lock;
void ata_read(/* ... */)
{
acquire(&lock);
/* ... */
release(&lock);
}
Le code sera désormais exécuté seulement sur 1 cpu à la fois !
Il est important d'utiliser les verrou quand il le faut, dans un allocateur de frame, le changement de contexte, l'utilisation d'appareils...
Global Descriptor Table
La table de descripteur globale à été introduite avec le processeur 16bit d'intel (le 80286) pour gérer la mémoire sous forme de segments.
La segmentation ne devrais plus être utilisé, elle a été remplacé par le paging. Le paging est toujours obligatoire pour passer du 32 au 64 bit avec l'architecture x86.
Cependant la GDT
est aussi utilisée pour contenir la tss. La structure est différente entre le 32 et 64 bit.
La table globale de descripteur est principalement formée de 2 structures:
- la gdtr (le registre de segments)
- le segment
Le registre de segments
le registre de segments en mode long (x86_64) doit être construit comme ceci:
nom | taille |
---|---|
taille | 16 bit |
adresse de la table | 64 bit |
taille: Le registre taille doit contenir la taille de la table de segment, soit le nombre de segment multiplié par la taille du segment, cependant en 64bit la taille du segment de la TSS est doublé, il faut alors compter le double.
adresse de la table: L'adresse de la table doit pointer directement vers la table de segments.
Les segments
Un segment en x86_64 est formé comme ceci:
nom | taille |
---|---|
limite basse (0-15) | 16 bit |
base basse (0-15) | 16 bit |
base milieu (16-23) | 8 bit |
flag | 8 bit |
limite haute (16-19) | 4 bit |
granularité | 4 bit |
base haute (24-31) | 8 bit |
Les registres base
Le registre base est le début du segment, en mode long il faut le mettre à 0.
Les registres limite
Le registre limite est une adresse 20bit, il représente la fin du segment.
Il est multiplié par 4096 si le bit granularité
est à 1.
En mode long (64 bit) il faut le mettre à 0xfffff pour demander à ce que le segment prenne toute la mémoire.
Le registre flag
Les flags d'un segment est formé comme ceci:
nom | taille |
---|---|
accédé | 1 bit |
écriture/lisible | 1 bit |
direction/conformité | 1 bit |
executable | 1 bit |
type de descripteur | 1 bit |
niveau de privilège | 2 bit |
segment présent | 1 bit |
accédé : Doit être à 0, il est mit à 1 quand le processeur l'utilise.
écriture/lisible:
- Si c'est un segment de donnée: si le bit est à 1 alors l'écriture est autorisé avec le segment, si le bit est à 0 alors le segment est seulement lisible.
- Si c'est un segment de code: si le bit est à 1 alors on peut lire le segment sinon le segment ne peut pas être lu.
direction/conformité:
-
Pour les descripteurs de données:
- Le bit défini le sens du segment, si il est mit alors le sens du segment est vers le bas, il doit être à 0 pour le 64 bit.
-
Pour les descripteurs de code:
- Si le bit est à 1 alors le code peut être éxécuté par un niveau de privilège plus bas ou égal au registre
niveau de privilège
. - Si le bit est à 0 alors le code peut seulement être éxecuté par le registre
niveau de privilège
.
- Si le bit est à 1 alors le code peut être éxécuté par un niveau de privilège plus bas ou égal au registre
executable: Définis si le segment est éxécutable ou non, si il est à 0 alors le segment ne peut pas être exécuté (c'est un segment de donné data
) mais s'il est à 1 alors c'est un segment qui peut être exécuté (c'est un segment de code code
).
type de descripteur: Doit être mit à 1 pour les segment de code/data et il doit être à 0 pour la tss.
niveau de privilège: Représente le niveau de privilège du descripteur (de 0 à 3).
segment présent: Doit être mit à 1 pour tout descripteur (sauf pour le descripteur null).
Le registre granularité
Le registre granularité d'un segment est formé comme ceci:
nom | taille |
---|---|
granularité | 1 bit |
taille | 1 bit |
mode long | 1 bit |
zéro | 1 bit |
granularité: Le bit granularité doit être mit quand la limite est fixe, cependant si le bit est à 1 alors la limite est multipliée par 4096.
taille: Le bit taille doit être mit à 0 pour le 16bit/64bit, 1 pour le 32bit.
mode long: Le bit doit être à 1 pour les descripteur de code en 64bit sinon il reste à 0.
Types de segment
Il y a différents type de segments:
Le segment null
L'entrée 0 d'une gdt est une entrée nulle, tout le segment est à 0.
Le segment code du kernel
La première entrée doit être un segment pour le kernel éxecutable soit un segment de code:
- Dans le type il faut que le bit 'type de descripteur' soit à 1.
- Il faut que le segment ait l'accès en écriture.
- Il faut que le bit executable soit mit.
- Le niveau de privilège doit être à 0.
Cela produit un type pour le mode x86_64:
0b10011010
La granularité doit être à 0b10
Le segment data du kernel
La seconde entrée doit être un segment de donnée pour le kernel.
- Il faut utiliser la même démarche que le segment de code sauf qu'il faut mettre le bit executable à 0.
Cela produit un type pour le mode x86_64:
0b10010010
La granularité doit être à 0
Le segment code des utilisateurs
La troisième entrée doit être un segment pour les applications éxecutable depuis l'anneau (niveau de privilège) 3.
- Il faut reproduire la même démarche que pour le segment code du kernel sauf que le niveau de privilège doit être à 3 pour le segment.
Cela produit un type pour le mode x86_64:
0b11111010
La granularité doit être à 0b10
.
Le segment données des utilisateurs
La quatrième entrée doit être un segment pour les données d'applications depuis l'anneau (niveau de privilège) 3. Il faut reproduire la même démarche que pour le segment data du kernel sauf que le niveau de privilège doit être à 3.
Cela produit un type pour le mode x86_64:
0b11110010
.
La granularité doit être à 0
.
Le chargement d'une gdt
Pour charger un registre d'une gdt il faut utiliser l'instruction:
lgdt [registre]
Avec le registre contenant l'adresse du registre de la gdt.
Cependant en 64bit il faut charger les registre du segment de code et de donnée. Ici nous allons utiliser l'instruction retf
qui permet de charger un segment de code:
gdtr_install:
lgdt [rdi]
; met tout les segments avec leurs valeurs ciblants le segment de données
mov ax, 0x10
mov ds, ax
mov es, ax
mov ss, ax
mov rax, qword .trampoline ; addresse de retour
push qword 0x8 ; segment de code
push rax
o64 retf ; fait un far return
.trampoline:
pop rbp
ret
Références
Interrupt Descriptor Table
La table de description des interruptions est une table qui permet au cpu de pouvoir savoir ou aller (jump) quand il y a une interruption.
Il y a deux structures utilisées (en 64bit) :
- La table d'entrée d'interruptions
- L'entrée de la table d'interruptions
Table d'entrée
La table d'entrée contient une adresse qui situe une table d'entrée d'IDT et la taille de la table (en mémoire).
Pour la table d'entrée la structure est comme ceci :
nom | taille |
---|---|
taille | 16 bit |
adresse de la table | 64 bit |
La table d'entrée peut être définie comme ceci:
IDT_entry_count = 64;
IDT_Entry_t ent[IDT_entry_count];
IDT_table.addr = (uint64_t)ent;
IDT_table.size = sizeof(IDT_Entry_t) * IDT_entry_count;
entrée d'IDT
l'entrée d'une IDT en mode long doit être structurée comme ceci :
nom | taille |
---|---|
offset (0-16) | 16 bit |
segment de code | 16 bit |
index de l'ist | 8 bit |
attributs | 8 bit |
offset (16-32) | 16 bit |
offset (32-64) | 32 bit |
zéro | 32 bit |
Le segment de code
étant le segment de code utilisé pendant l'interruption.
L'offset
est l'adresse où le CPU va jump si il y a une interruption.
Les attributs
l'attribut d'une entrée d'une IDT est formée comme ceci :
nom | bit |
---|---|
type d'interruption | 0 - 3 |
zéro | 4 |
niveau de privilège | 5 - 6 |
présent | 7 |
Le niveau de privilège
(aka DPL) est le niveau de privilège requis pour que l'interruption soit appelée.
Il est utilisé pour éviter à ce que une application utilisatrice puisse appellée une interruption qui est réservée au kernel
Types d'interruptions
Les types d'interruptions sont les mêmes que cela soit en 64bit ou en 32bit.
valeur | signification |
---|---|
0b0111 (7) | trappe d'interruption 16bit |
0b0110 (6) | porte d'interruption 16bit |
0b1110 (14) | porte d'interruption 32bit |
0b1111 (15) | trappe d'interruption 32bit |
La différence entre une trappe
(aka trap) et une porte
(aka gate) est que la gate désactive IF
, ce qui veut dire que vous devrez réactiver les interruptions à la fin de l'ISR.
La trappe ne désactive pas IF
donc vous pouvez désactiver / réactiver vous même dans l'isr les interrupts.
Index de l'IST
L'ist (Interrupt Stack Table
) est utile au changement de stack avant une interrupt:
nom | bit |
---|---|
index de l'ist | 0 - 3 |
zéro | 4 - 7 |
Si l'index de l'ist est à 0 alors l'ist n'est pas actif.
Si il n'est pas à 0 il chargeras alors la stack (RSP
) à partir de l'ist correspondant dans la tss.
0 - Introduction
Préface
Ce tutoriel vous expliquera les bases du fonctionnement d'un système d'exploitation par la réalisation pas à pas d'un kernel minimaliste.
⚠️ Pour suivre ce tutoriel, il vous est recommandé d'utiliser un système UNIX-Like tel que GNU/Linux. Bien que vous puissiez utiliser Windows celà demande un peux plus de travail et nous n'aborderons pas les étapes necessaires à l'instalation d'un environement de developpement sous Windows.
Avant de se lancer il faut garder en tête que le developpement de système d'exploitation est très long. Il faut donc être conscient qu'il ne s'agit pas d'un petit projet de quelques jours. Beaucoup de systèmes d'exploitation sont abandonnés faute de motivation dans la durée. Aussi n'ayez pas les yeux plus gros que le ventre: vous n'inventerez pas le nouveau Windows ou OS X.
Pour pouvoir mener a bien ce type de projet il faut déjà posseder des bases en programmation, pas besoin d'être un expert avec 30ans d'expérience en C rassurez vous.
Une erreur commune est de se lancer dans de gros projet tels qu'un MMORPG ou dans le cas présent un kernel sans toutefois connaitre la programmation
Bien que dans ce tutoriel nous utiliserons assez peu l'assembleur, en connaitre les bases est un sérieux plus.
Bref. Vous l'aurez compris. Ne vous lancez pas dans un tel projet si vous n'avez pas un minimum de base. (N'essayez pas d'apprendre sur le tas, prennez du recul, apprennez a programmer et revennez)
Aussi gardez en tête que vous ne pouvez pas programmer un système d'exploitation dans n'importe quel langage et la majorité des ressources que vous trouverez sur le net tournent autours du C, C++ voire du Rust.
Il est important que vous prenniez le temps de bien lire les explications plutôt de vous jeter directement sur le code et faire de bêtes copier/coller. Si vous ne comprennez pas du premier coup ce n'est pas grave, pensez a faire vos propres recherches et à relire plus tard à tête reposée.
Introduction
Qu'est ce qu'un kernel (ou noyau) ?
Le Kernel est l'élément central d'un système d'exploitation, il est chargé par le boot loader.
Le kernel a plusieurs responsabilités comme celle de gérer la mémoire, le multitaches etc. Il existe plusieurs types de noyeaux qui change grandement la manière d'aborder les systèmes d'exploitations.
La conception du kernel et ses responsabilités changent en fonction du type de kernel et du point de vue de l'auteur.
Qu'est ce qu'un bootloader ?
Un bootloader un programme permettant de démarrer votre kernel.
Un bootloader peut aussi charger des éléments important pour le kernel, comme des modules chargé dans le disques, l'A20 etc...
Dans ce tutoriel nous utiliserons Limine
L'architecture
L'architecture c'est la façon dont un processeur est structuré, sa façon de fonctionner, son ISA. Il y a plusieurs architecture et un kernel peut en supporter plusieurs en même temps :
- x86
- RISC-V
- ARM
- PowerPC
- Et bien d'autres...
L'architecture est importante, ici nous prenons le x86 car c'est l'architecture la plus utilisée.
Le x86 est divisé en modes :
nom anglais | nom français | taille de registre |
---|---|---|
real mode | mode réel | 16/20 bit |
protected mode | mode protégé | 32bit |
long mode | mode long | 64bit |
Nous utiliserons ici le mode long, car il est le plus récent, même si il a moins de documentation que le mode protégé.
Comment ?
Comment coder un kernel ?
Il faut prendre la route que l'on veut, mais il y a des éléments importants qu'il faut faire dans un ordre assez précis.
Vous pouvez dans certains cas le faire dans l'ordre que vous voulez mais il faut quand même une route... car parfois on se pose la question : que faire ensuite ?
La route ci-dessous est recommandée mais vous pouvez le faire de la manière dont vous l'entendez:
- démarrage
- com // pour le debugging
- GDT (Global Descriptor Table) utilisée à l'époque pour la segmentation de la mémoire
- IDT (Interrupt Descriptor Table) utilisée pour gérer les interruptions
- Interruption // pour le debugging d'erreur
- PIT
- Gestion de mémoire physique
- Pagination
- Multitâche
À partir d'ici, tout devient très subjectif vous pouvez enchainer sur le SMP, le système de fichiers, les tâches utilisateur, etc...
Références
01 - Hello world
résultat à la fin de ce tutoriel
Dans cette partie vous allez faire un "hello world !" en 64bit.
Pour ce projet vous utiliserez donc :
- un cross compilateur
- Limine comme bootloader
- Echfs comme système de fichier
Pour commencer vous devez mettre un place un cross compilateur dans votre projet.
Vous utiliserez echfs comme système de fichier il est assez simple d'utilisation pour les débutants, normalement sans echfs, il faut créer un disque, le partitionner, le monter, installer un système de fichier, ajouter nos fichier... En utilisant echfs avec son outil echfs-utils
, c'est bien plus simple.
Vous devez donc cloner limine dans la source de votre projet (ou en le rajoutant en sous module git), il est fortement recommandé d'utiliser la branche qui contient les binaires.
Le Fichier Makefile
Note: vous pouvez utiliser d'autres système de build, il faut juste suivre les même commandes et arguments pour gcc/ld.
Compilation
Pour commencer vous devez obtenir tout les fichier '.c' avec find et obtenir le fichier objet '.o' équivalent à ce fichier c.
Ici le dossier "src" est là où vous mettez le code de votre kernel.
SRCS := $(wildcard ./src/**.c)
OBJS := $(SRCS:.c=.o)
Ensuite, juste avant de compiler les fichiers .c
, il faut changer certains flags du compilateur:
-ffreestanding
: Active l'environnement freestanding, cela signifie que le compilateur désactive les librairies standards du C (faites pour GNU/linux). Il signifie aussi que les programmes ne commencent pas forcément àmain
.-O1
: Vous pouvez utiliser -O2 ou même -O3 même si rarement le compilateur peut retirer des bouts de code qui ne devraient pas être retiré.-m64
: Active le 64bit.-mno-red-zone
: Désactive la red-zone (en mode 64bit).-mno-sse
: Désactive l'utilisation de l'sse.-mno-avx
: Désactive l'utilisation de l'avx.-fno-stack-protector
: Désactive la protection de la stack.-fno-pic
: produit un code qui n'est pas 'indépendant de la position'.-no-pie
: Ne produit pas un executable avec une position indépendante.-masm=intel
: Utilise l'asm intel pour la génération de code.
CFLAGS := \
-Isrc \
-std=c11 \
-ffreestanding \
-fno-stack-protector \
-fno-pic \
-no-pie \
-O1 \
-m64 \
-g \
-masm=intel \
-mno-red-zone \
-mno-sse \
-mno-avx
Maintenant vous pouvez rajouter une target a votre makefile pour compiler vos fichier C en objet:
Ici, vous utiliserez la variable make CC qui aura le path de votre cross-compilateur.
.SUFFIXE: .c
.o: $(SRCS)
$(CC) $(CFLAGS) -c $< -o $@
Linking
Après avoir compilé tout les fichier C en fichier objet, vous devez les lier pour créer le fichier du kernel.
Vous utiliserez ld
(celui fourni par le binutils de votre cross-compilateur).
Avant il vous faut un fichier de linking, qui définit la position de certaines parties du code. Vous le mettrez dans le chemins src/link.ld
.
Il faut commencer par définir le point d'entrée, où commence le code... Ici la fonction: kernel_start
pour commencer, donc :
ENTRY(kernel_start)
Il faut ensuite définir la position des sections du code (pour les données (data/rodata/bss) et le code (text)), soit la position 0xffffffff80100000. Étant donné que c'est un kernel "higher-half", il est donc placé dans la moitié haute de la mémoire : 0xffffffff80000000. Ici, vous rajoutez un décalage de 1M (0x100000) pour éviter de toucher l'adresse 0 en physique.
Vous devez aussi positionner le header pour le bootloader (ici dans la section stivale2hdr
), il permet de donner des informations importantes quand le bootloader lit le kernel. Le bootloader demande à cette section d'être la première dans le kernel.
Pour finir vous devez avoir :
ENTRY(kernel_start)
SECTIONS
{
kernel_phys_offset = 0xffffffff80100000;
. = kernel_phys_offset;
.stivale2hdr ALIGN(4K):
{
KEEP(*(.stivale2hdr))
}
.text ALIGN(4K):
{
*(.text*)
}
.rodata ALIGN(4K):
{
*(.rodata*)
}
.data ALIGN(4K):
{
*(.data*)
}
.bss ALIGN(4K) :
{
*(COMMON)
*(.bss*)
}
}
Comme pour la compilation des fichiers C, vous devez passer des arguments spécifiques :
-z max-page-size=0x1000
: Signifie que la taille max d'une page ne peut pas dépasser0x1000
(4096).-nostdlib
Demande à ne pas utiliser la librairie standard.-T{CHEMIN_DU_FICHIER_DE_LINKING}
: Demande à utiliser le fichier de linking.
Donc ici :
LD_FLAGS := \
-nostdlib \
-Tsrc/link.ld \
-z max-page-size=0x1000
En utilisant une nouvelle target dans le fichier Makefile, vous pouvez désormais lier les fichiers objets en un kernel.elf :
kernel.elf: $(OBJS)
$(LD) $(LD_FLAGS) $(OBJS) -o $@
Création Du Fichier De Configuration Du Bootloader
Avant de continuer, vous devez créer un fichier limine.cfg
. C'est un fichier lu par le bootloader qui paramètre certaines options et permet de pointer où se trouve le kernel dans le disque :
:mykernel
PROTOCOL=stivale2
KERNEL_PATH=boot:///kernel.elf
Ici vous voulez définir l'entrée mykernel
qui a le protocole stivale2
et qui a comme fichier elf pour le kernel: /kernel.elf
dans la partition de boot
.
Ensuite, vous pouvez mettre en place la création du disque:
Création Du Disque
Pour commencer il faut créer un path pour le disk, (ici disk.hdd
).
KERNEL_DISK := disk.hdd
Ensuite dans la target de création du disque du makefile:
Vous créez un fichier disk.hdd vide de taille 8M (avec dd
).
dd if=/dev/zero bs=8M count=0 seek=64 of=$(KERNEL_DISK)
Vous formatez le disque pour utiliser un système de partition MBR
avec 1 seule partition (qui prend tout le disque).
parted -s $(KERNEL_DISK) mklabel msdos
parted -s $(KERNEL_DISK) mkpart primary 1 100%
Vous utilisez echfs-utils pour formater la partition en echfs et pour rajouter le fichier kernel, le fichier config pour limine, et un fichier système pour limine (limine.sys
).
echfs-utils -m -p0 $(KERNEL_DISK) quick-format 4096 # taille de block de 4096
echfs-utils -m -p0 $(KERNEL_DISK) import kernel.elf kernel.elf
echfs-utils -m -p0 $(KERNEL_DISK) import limine.cfg limine.cfg
echfs-utils -m -p0 $(KERNEL_DISK) import ./limine/limine.sys limine.sys
Puis vous installez limine sur la partition echfs:
./limine/limine-install-linux-x86_64 $(KERNEL_DISK)
Ce qui donne comme résultat:
$(KERNEL_DISK): kernel.elf
rm -f $(KERNEL_DISK)
dd if=/dev/zero bs=8M count=0 seek=64 of=$(KERNEL_DISK)
parted -s $(KERNEL_DISK) mklabel msdos
parted -s $(KERNEL_DISK) mkpart primary 1 100%
echfs-utils -g -p0 $(KERNEL_DISK) quick-format 4096
echfs-utils -g -p0 $(KERNEL_DISK) import kernel.elf kernel.elf
echfs-utils -g -p0 $(KERNEL_DISK) import limine.cfg limine.cfg
echfs-utils -m -p0 $(KERNEL_DISK) import ./limine/limine.sys limine.sys
./limine/limine-install-linux-x86_64 $(KERNEL_DISK)
L'Execution
Une fois le disque créé, vous allez faire une cible : run
. Elle servira plus tard quand vous pourrez enfin tester votre kernel.
Elle est assez simple: vous lançez qemu-system-x86_64, avec une mémoire de 512M
, on active kvm
(une accélération pour l'émulation), on utilise le disque disk.hdd
, et des options de debug, comme :
-serial stdio
: Redirige la sortie de qemu dansstdio
.-d cpu_reset
: Signale dans la console quand le cpu se réinitialise après une erreur.-device pvpanic
: signale quand il y a des évenements de panic.-s
: Permet de debug avec gdb.
run: $(KERNEL_DISK)
qemu-system-x86_64 -m 512M -s -device pvpanic -serial stdio -enable-kvm -d cpu_reset -hda ./disk.hdd
Le Code
Après avoir tout configuré avec le makefile, vous pouvez commencer à coder !
Vous commencerez par créer un fichier kernel.c dans le dossier src (le nom du fichier n'est pas obligé d'être kernel.c).
Mais avant vous devez rajouter le header du bootloader, qui permet de donner des informations/configurer le bootloader quand il charge le kernel, ici nous utilisons le protocole stivale 2, nous recommandons d'utiliser le code/header fournis par stivale2 qui facilite la création du header.
Vous allez créer une variable dans le kernel.c
du type stivale2_header
, vous demandez au linker de la positioner dans la section ".stivale2hdr
" et de forcer le fait qu'elle soit utilisée (pour éviter que le compilateur vire l'entrée automatiquement).
__attribute__((section(".stivale2hdr"), used))
struct stivale2_header header = { /* entrées */ };
Puis vous remplissez toutes les entrées du header: Il faut commencer par créer une variable pour définir la stack du kernel. Vous utiliserez une stack de taille 32768 (32K) soit :
#define STACK_SIZE 32768
char kernel_stack[STACK_SIZE];
Et :
struct stivale2_header header = {.stack = (uintptr_t)kernel_stack + (STACK_SIZE) }// la stack tend vers le bas, donc vous voulez donner le dessus de cette stack
Le header doit spécifier le point d'entrée du kernel par la variable entry_point
, il faut le mettre à 0 pour demander au bootloader d'utiliser le point d'entrée spécifié par le fichier elf.
La spécification de stivale2 demande pour l'instant à mettre flags
à 0 car il n'y a aucun flag implémenté.
__attribute__((section(".stivale2hdr"), used))
static struct stivale2_header stivale_hdr = {
.stack = (uintptr_t)kernel_stack + STACK_SIZE,
.entry_point = 0,
.flags = 0,
};
Maintenant il faut mettre en place des tags pour le bootloader, les tags sont une liste liée, c'est à dire que chaque entrée doit indiquer où est la prochaine entrée :
Il y a plusieurs valeurs valides pour l'identifier
qui identifie l'entrée et vous pouvez avoir plusieurs tags. Pour l'instant vous allez en utiliser qu'un seul : celui pour définir le framebuffer.
Il faut créer une nouvelle variable statique qui contient le premier (et le seul pour l'instant )tag de la liste qui aura comme type stivale2_header_tag_framebuffer
:
static struct stivale2_header_tag_framebuffer framebuffer_header_tag =
{
.tag =
{
},
};
Ici, la valeur de la variable .tag.identifier
doit être STIVALE2_HEADER_TAG_FRAMEBUFFER_ID
. Cela signifie que ce tag donne des informations au bootloader à propos du framebuffer (taille en largeur/hauter, ...).
La variable .tag.next
est à 0
pour le moment, car vous utilisez qu'une seule entrée dans la liste.
Ce qui donne:
static struct stivale2_header_tag_framebuffer framebuffer_header_tag =
{
.tag =
{
.identifier = STIVALE2_HEADER_TAG_FRAMEBUFFER_ID,
.next = 0 // fin de la liste
},
};
Maintenant vous allez configurer le framebuffer. Pour le moment, vous voulez le mettre en pixel et non en texte : car vous allez essayez de remplir l'écran en bleu.
Vous devez définir la longueur et largeur du framebuffer (ici vous utiliserez une résolution de: 1440
x900
) et 32 bit par pixel (donc ̀framebuffer_bpp=32
).
static struct stivale2_header_tag_framebuffer framebuffer_header_tag =
{
.tag =
{
.identifier = STIVALE2_HEADER_TAG_FRAMEBUFFER_ID,
.next = 0 // fin de la liste
},
.framebuffer_width = 1440,
.framebuffer_height = 900,
.framebuffer_bpp = 32
};
Ensuite, initialisez variable tags
du stivale2_header
à l'adresse du tag du framebuffer soit :
__attribute__((section(".stivale2hdr"), used))
static struct stivale2_header stivale_hdr =
{
.stack = (uintptr_t)kernel_stack + STACK_SIZE,
.entry_point = 0,
.flags = 0,
.tags = (uintptr_t)&framebuffer_header_tag
};
Pour finir vous devriez avoir ceci :
#define STACK_SIZE 32768
char kernel_stack[STACK_SIZE];
static struct stivale2_header_tag_framebuffer framebuffer_header_tag =
{
.tag = {
.identifier = STIVALE2_HEADER_TAG_FRAMEBUFFER_ID,
.next = 0 // fin de la liste
},
.framebuffer_width = 1440,
.framebuffer_height = 900,
.framebuffer_bpp = 32
};
__attribute__((section(".stivale2hdr"), used))
static struct stivale2_header stivale_hdr = {
.stack = (uintptr_t)kernel_stack + STACK_SIZE,
.entry_point = 0,
.flags = 0,
.tags = (uintptr_t)&framebuffer_header_tag
};
L'Entrée
Après la mise en place du header pour le bootloader vous devez programmer le point d'entrée, kernel_start
, c'est une fonction qui ne retourne rien mais qui a un struct stivale2_struct*
comme argument. Cet argument (ici bootloader_data) représente les informations passées par le bootloader.
void kernel_start(struct stivale2_struct *bootloader_data)
{
while(1); // vous ne voulez pas sortir de kernel_start
}
Maintenant il est conseillé de compiler et de tester le kernel, avant de continuer. Faites un make run
, il faut qu'il n'y ait aucune erreur ; ni du bootloader, ni de Qemu.
Lire Le Bootloader_data
Il est important avant de continuer de mettre en place quelques fonctions utilitaires qui permettent de lire le bootloader_data
car il doit être lu comme une liste lié (comme le header stivale2). Par exemple si on veut obtenir l'entrée qui contient des informations à propos du framebuffer, vous devez regarder toutes les entrées et trouver celle qui a un identifiant pareil à celle du framebuffer.
void *stivale2_find_tag(struct stivale2_struct *bootloader_data, uint64_t tag_id)
{
struct stivale2_tag *current = (void *)bootloader_data->tags;
while(current != NULL)
{
if (current->identifier == tag_id) // est ce que cette entrée est bien celle que l'on cherche ?
{
return current;
}
current = (void *)current->next; // avance d'une entrée dans la liste
}
return NULL; // aucune entrée trouvé
}
Ce qui permettra plus tard d'obtenir le tag contenant des informations à propos du framebuffer comme ceci:
stivale2_find_tag(bootloader_data, STIVALE2_STRUCT_TAG_FRAMEBUFFER_ID);
Le Framebuffer
Vous allez remplir l'écran en bleu pour essayer de debug, le framebuffer est structuré comme ceci:
struct framebuffer_pixel
{
uint8_t blue;
uint8_t green;
uint8_t red;
uint8_t __unused;
} __attribute__((packed));
voir: framebuffer pour plus d'information
Vous rajoutez ensuite dans kernel_start du code pour remplir le framebuffer en bleu.
Pour commencer il faut obtenir le tag du framebuffer, il est passé dans le tag STIVALE2_STRUCT_TAG_FRAMEBUFFER_ID
du bootloader_data
Il faut utiliser stivale2_find_tag
:
struct stivale2_struct_tag_framebuffer *framebuffer_tag;
framebuffer_tag = stivale2_find_tag(bootloader_data, STIVALE2_STRUCT_TAG_FRAMEBUFFER_ID);
Maintenant le tag contient la taille du framebuffer, et son adresse.
Pour utiliser l'adresse il faut la convertir en un pointeur framebuffer_pixel
:
struct framebuffer_pixel* framebuffer = framebuffer_tag->framebuffer_addr;
Nous avons une table qui contient chaque pixel de framebuffer_tag->framebuffer_width
de longueur et de framebuffer_tag->framebuffer_height
de hauteur, donc vous allez faire une boucle :
for(size_t x = 0; x < framebuffer_tag->framebuffer_width; x++)
{
for(size_t y = 0; y < framebuffer_tag->framebuffer_height; y++)
{
size_t raw_position = x + y*framebuffer_tag->framebuffer_width; // convertit les valeurs x et y en position 'brute' dans la table
framebuffer[raw_position].blue = 255; // met la couleur à bleu
}
}
Si vous le voulez vous pouvez faire quelque chose de plus compliqué :
for(size_t x = 0; x < framebuffer_tag->framebuffer_width; x++)
{
for(size_t y = 0; y < framebuffer_tag->framebuffer_height; y++)
{
size_t raw_position = x + y * framebuffer_tag->framebuffer_width;
framebuffer[raw_position].blue = x ^ y;
framebuffer[raw_position].red = (y * 2) ^ (x * 2);
framebuffer[raw_position].green = (y * 4) ^ (x * 4);
}
}
Qui donneras ce motif si tout fonctionne:
Conclusion
Cette partie du tutoriel est terminée ! vous avez maintenant un kernel qui boot, cependant dans le prochain tutoriel vous implémenterez un driver COM, qui donnera la possibilité d'écrire des informations dans la console, ce qui est très pratique pour debugger.
Références
- wiki.osdev.org
- wiki.osdev.org barebones
- wiki.osdev.org stivale-barebones
- gnu/make documentation
- specification/headers de stivale
- barebones limine
- gcc manpage
- ld manpage
- qemu manpage
- echfs-utils information
- wikipedia la stack