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.

Discord Banner 3

Licence

Licence Creative Commons
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 / 1identifiant ACPI
3 / 1identifiant APIC
4 / 4flag 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:

bitssignifications
0-7caractère ASCII
8-11couleur du texte
12-15couleur de fond

Les couleurs sont formées comme ceci:

valeurcouleur
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.

bytecouleur
0valeur du bleu (0-255)
1valeur du vert (0-255)
2valeur du rouge (0-255)
3byte 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 ComL'id du portSon IRQ
COM10x3F84
COM20x2F83
COM30x3E84
COM40x2E83

Puis, il y a l'offset. Chaque offset a certaines particularités. (= ID DU PORT + OFFSET)

offsetaction
0Le 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)
1Le 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)
2L'identificateur d'Interrupt ou le controleur FIFO
3le control de ligne (Le bit le plus haut est celui pour DLAB)
4Le control de Modem
5Le status de la ligne
6Le status de Modem
7Le 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.

IdNomcontient un code d'erreur ?Descriptions
0Division par 0nonCette erreur est produite quand l'instruction DIV/IDIV est utilisée avec un 0
1DebugnonCette erreur intentionnelle est généralement utilisée pour déboguer
2Interruption NMInonL'interruption NMI est une interruption causée par des éléments externes comme la RAM
3BreakpointnonCette erreur intentionnelle est généralement utilisée pour le débogage
4DépassementnonL'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.
5Dépassement de tablenonL'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.
6Instruction non validenonL'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é
7Appareil non disponiblenonL'interruption 7 est appelée lorsqu'on essaye d'initialiser le FPU alors qu'il n'existe pas
8Faute DoubleouiLa 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)
9Erreur de Segment de coprocesseurnonCette erreur n'est plus utilisée.
10TSS invalideoui (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
11Segment non présentoui (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
12Segment de pile invalideoui (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 ...)
13Faute générale de protectionoui (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)
14Faute de pageoui (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)
15Réservénon//
16Faute du FPU x87nonL'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
17Faute d'alignementouiProduite 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.
18Faute de vérification de machinenonProduite 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.
19Exception de variable a virgule SIMDnonL'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 [...]
20Exception de virtualisationnonL'exception de virtualisation est appelée lorsqu'il y a une violation de droits avec une instruction EPT
21 à 31réservénon//
///Faute triplenonL'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

BITNOMDESCRIPTION
0P(p=1)
Violation de protection

(p=0)
la page n'est pas présente
1W(W=0)
Causée par une lecture

(W=1)
Causée par une écriture
2U(U=1)
La page n'est pas utilisateur alors que CPL = 3
3R(R=1)
La page contient un bit réservé
4I(I=1)
Lecture à cause d'une instruction
5PK(PK=1)
Violation de droit de clé
6SS(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

BITNOMTAILLEDESCRIPTION
0E1(E=1)
Provient d'un appareil externe au processeur
1TBL2(TBL=0)
Provient de la GDT

(TBL=1)
Provient de l'IDT

(TBL=2 & TBL=3)
Provient de la LDT
3Index13Index 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éeAdresse
Code du trampoline0x1000
Pile0x570
GDT0x580
IDT0x590
Table de page0x600
Adresse de saut0x610

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:

nomtaille
taille16 bit
adresse de la table64 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:

nomtaille
limite basse (0-15)16 bit
base basse (0-15)16 bit
base milieu (16-23)8 bit
flag8 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:

nomtaille
accédé1 bit
écriture/lisible1 bit
direction/conformité1 bit
executable1 bit
type de descripteur1 bit
niveau de privilège2 bit
segment présent1 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.

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:

nomtaille
granularité1 bit
taille1 bit
mode long1 bit
zéro1 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 :

nomtaille
taille16 bit
adresse de la table64 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 :

nomtaille
offset (0-16)16 bit
segment de code16 bit
index de l'ist8 bit
attributs8 bit
offset (16-32)16 bit
offset (32-64)32 bit
zéro32 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 :

nombit
type d'interruption0 - 3
zéro4
niveau de privilège5 - 6
présent7

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.

valeursignification
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:

nombit
index de l'ist0 - 3
zéro4 - 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 anglaisnom françaistaille de registre
real modemode réel16/20 bit
protected modemode protégé32bit
long modemode long64bit

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 :

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épasser 0x1000 (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 dans stdio .
  • -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: 1440x900) 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

Interruptions

Memoire

Paging

Tâches utilisateur

Epilogue