Le petit livre sur le développement d'un OS

Tutoriel pour développer un système d'exploitation

Ce livre est un guide qui vous permettra d'écrire vous-même un système d'exploitation rudimentaire, mais complet pour un processeur d'architecture x86. Il vous guidera pas à pas depuis le démarrage du BIOS et la séquence d'amorçage jusqu'au lancement du noyau et des processus utilisateurs de ce noyau, y compris la gestion de la mémoire virtuelle, les entrées/sorties, le système de fichiers, les interruptions, les appels système et le multitâche.

Les commentaires et les suggestions d'amélioration sont les bienvenus, alors, après votre lecture, n'hésitez pas. 9 commentaires Donner une note à l'article (5)

Article lu   fois.

Les trois auteurs et traducteur

Traducteur :

1. Introduction

Ce texte est un guide pratique pour l'écriture de votre propre système d'exploitation x86. Il est conçu de façon à fournir suffisamment d'aide à travers les détails techniques et en même temps ne pas trop en révéler dans les échantillons et les extraits de code. Nous avons essayé de recueillir des parties de l'abondante (et souvent excellente) richesse de documents et de tutoriels disponibles sur le web et ailleurs, et d'ajouter nos idées personnelles sur les problèmes que nous avons rencontrés et dû résoudre.

Ce livre ne traite pas de la théorie derrière les systèmes d'exploitation ni de la façon dont fonctionne un système d'exploitation (OS) particulier. Pour la théorie des OS, nous vous recommandons le livre Systèmes d'exploitation par Andrew Tanenbaum(1). Des listes et des détails sur les systèmes d'exploitation actuels sont disponibles sur Internet.

Les premiers chapitres sont assez détaillés et explicites, et vous amèneront rapidement à coder. Les chapitres suivants donnent plutôt un aperçu de ce qui est nécessaire, au fur et à mesure que la mise en œuvre et la conception relèvent plus du choix du lecteur, qui devrait maintenant être plus familier avec le domaine du développement du noyau. À la fin de certains chapitres, il y a des liens pour des lectures complémentaires qui pourraient être intéressantes et donner une compréhension plus approfondie des sujets abordés.

Dans les chapitres 2Outils et 3Passage au C, nous mettons en place notre environnement de développement et démarrons le noyau de notre OS dans une machine virtuelle, puis nous avons commencé à écrire du code C. Nous continuons dans le chapitre 4Les sorties avec l'écriture à l'écran et sur le port série, puis nous plongeons dans la segmentation dans le chapitre 5Segmentation et dans les interruptions et la saisie de données en entrée dans le chapitre 6Interruptions et saisie.

Après cela, nous avons un noyau d'OS tout à fait fonctionnel, mais encore rudimentaire. Dans le chapitre 7La route vers le mode utilisateur, nous nous mettons en route vers les applications en mode utilisateur, avec une mémoire virtuelle à travers la pagination (chapitres 8Une courte introduction à la mémoire virtuelle et 9Pagination), l'allocation de mémoire (chapitre 10Allocation de trames de page), et enfin l'exécution d'une application utilisateur dans le chapitre 11Mode utilisateur.

Dans les trois derniers chapitres, nous discutons des sujets plus avancés concernant les systèmes de fichiers (chapitre 12Systèmes de fichiers), les appels système (chapitre 13Appels système) et le multitâche (chapitre 14Multitâche).

1-1. Au sujet de ce livre

Le noyau de l'OS et ce livre ont été produits dans le cadre d'un cours individuel avancé à l'Institut royal de technologie(2) de Stockholm. Les auteurs avaient déjà suivi des cours en théorie système, mais avaient une expérience pratique limitée dans le développement du noyau du système d'exploitation. Afin d'obtenir une expérience personnelle plus détaillée et une compréhension approfondie de la façon dont la théorie des précédents cours système fonctionne dans la pratique, les auteurs ont décidé de créer un nouveau cours, qui a porté sur le développement d'un petit système d'exploitation. Un autre objectif du cours était d'écrire un tutoriel complet sur la façon de développer un petit OS à partir de zéro, et ce petit livre en est le résultat.

L'architecture x86 est depuis longtemps l'une des architectures matérielles les plus courantes. Ce ne fut pas un choix difficile que d'opter pour l'architecture x86, avec sa grande communauté, de nombreux documents de référence et des émulateurs matures, comme cible de l'OS. La documentation et l'information concernant les détails du matériel avec lequel nous avons dû travailler ne furent pas toujours faciles à trouver ou à comprendre, en dépit (ou peut-être à cause) de l'âge vénérable de cette architecture.

Le système d'exploitation a été développé en à peu près six semaines de travail à temps plein. La mise en œuvre a été réalisée en plusieurs petites étapes. Après chaque étape, le système d'exploitation a été testé manuellement. En développant de cette manière incrémentale et itérative, il est souvent plus facile de trouver tout éventuel bogue introduit, puisque seule une petite partie du code a changé depuis le dernier état stable connu du code. Nous encourageons le lecteur à travailler d'une manière similaire.

Pendant les six semaines de développement, presque chaque ligne de code a été écrite par les auteurs ensemble (cette façon de travailler est parfois appelée programmation en binôme). Notre conviction est que nous avons réussi à éviter un grand nombre de bogues grâce à ce style de développement, mais cela est difficile à établir scientifiquement.

1-2. Le lecteur

Le lecteur de ce livre doit être à l'aise avec UNIX/Linux, la programmation système, le langage C et les systèmes informatiques en général (comme la notation hexadécimale(3)). Ce livre pourrait être une façon de commencer l'apprentissage de ces choses, mais ce sera plus difficile, et le développement d'un système d'exploitation est déjà difficile en soi. Les moteurs de recherche et autres tutoriels sont souvent utiles si vous êtes bloqué.

1-3. Références et remerciements

Nous tenons à remercier la communauté OSDev(4) pour leur superbe wiki et ses membres serviables, et James Malloy pour son excellent tutoriel de développement du noyau(5). Nous aimerions également remercier notre superviseur, Torbjörn Granlund, pour ses questions pertinentes et les discussions intéressantes.

La plus grande partie de la mise en forme CSS du livre est basée sur le travail de Scott Chacon pour le livre Pro Git, http://progit.org/.

1-4. Contributeurs

Nous sommes très reconnaissants pour les correctifs que les gens nous envoient. Les utilisateurs suivants ont tous contribué à ce livre :

1-5. Modifications et corrections

Ce livre est hébergé sur Github - si vous avez des suggestions, des commentaires ou des corrections, forkez simplement le livre, rédigez vos changements et envoyez-nous une pull request. Nous serons heureux d'intégrer tout ce qui rend ce livre meilleur.

1-6. Problèmes et recherche d'aide

Si vous rencontrez des problèmes lors de la lecture du livre, veuillez vérifier les problèmes sur Github pour trouver de l'aide : https://github.com/littleosbook/littleosbook/issues.

1-7. Licence

Tout le contenu est sous licence Creative Commons Attribution Non Commercial Share Alike 3.0, http://creativecommons.org/licenses/by-nc-sa/3.0/us/. Les exemples de code sont dans le domaine public - utilisez-les comme vous voulez. Les références à ce livre sont toujours reçues chaleureusement.

2. Premiers pas

Développer un système d'exploitation (OS) n'est pas une tâche facile et vous pourriez vous poser à plusieurs reprises au cours du projet la question « Comment puis-je même commencer à résoudre ce problème ? » lorsque vous rencontrez de différents problèmes. Ce chapitre vous aidera à mettre en place votre environnement de développement et à démarrer un système d'exploitation très petit (et primitif).

2-1. Outils

2-1-1. Configuration rapide

Nous (les auteurs) avons utilisé Ubuntu(6) comme système d'exploitation pour développer le système d'exploitation et l'exécuter à la fois physiquement et virtuellement (en utilisant la machine virtuelle VirtualBox(7)). Un moyen rapide de mettre tout en place et le rendre fonctionnel est d'utiliser la même configuration que nous, puisque nous savons que ces outils fonctionnent avec les échantillons de code fournis dans ce livre.

Une fois Ubuntu, installé sur une machine physique ou virtuelle, les paquetages suivants doivent être installés en utilisant apt-get :

 
Sélectionnez
sudo apt-get install build-essential nasm genisoimage bochs bochs-sdl

2-1-2. Langages de programmation

Le système d'exploitation sera développé en utilisant le langage de programmation C(8)(9) et le compilateur GCC(10). Nous utilisons le C parce que le développement d'un OS nécessite un contrôle très précis du code généré et l'accès direct à la mémoire. Il est possible d'utiliser d'autres langages offrant les mêmes caractéristiques, mais ce livre ne couvrira que le C.

Le code fera usage d'un attribut de type, spécifique pour GCC :

 
Sélectionnez
    __attribute__((packed))

Cet attribut permet de nous assurer que le compilateur utilise une disposition de mémoire pour une struct exactement comme nous le définissons dans le code. Ceci est expliqué plus en détail dans le chapitre suivant.

En raison de cet attribut, les exemples de code pourraient s'avérer difficiles à compiler avec un compilateur C autre que le GCC.

Pour l'écriture du code assembleur, nous avons choisi NASM(11) comme outil, car nous préférons la syntaxe de NASM sur GNU Assembler.

Le shell Bash(12) sera utilisé comme langage de script au long du livre.

2-1-3. Système d'exploitation hôte

Tous les exemples supposent que le code est compilé sur un système d'exploitation de type UNIX. Tous les exemples de code ont été compilés avec succès en utilisant les versions 11.04 et 11.10 d'Ubuntu.

2-1-4. Système de compilation

L'utilitaire make(13) a été utilisé pour la compilation des exemples de Makefile.

2-1-5. Machine virtuelle

Lors du développement d'un système d'exploitation, il est très pratique de pouvoir exécuter votre code dans une machine virtuelle plutôt que sur un ordinateur physique, car le démarrage de votre OS dans une machine virtuelle est beaucoup plus rapide que de transférer votre OS sur un support physique, puis l'exécuter sur une machine physique. Bochs(14) est un émulateur pour les plateformes à architectures x86 (IA-32) qui est bien adapté pour le développement du système d'exploitation en raison de ses fonctions de débogage. D'autres choix populaires sont QEMU(15) et VirtualBox. Ce livre utilise Bochs.

En utilisant une machine virtuelle, nous ne pouvons pas garantir que notre OS fonctionne sur du matériel réel, physique. L'environnement simulé par la machine virtuelle est conçu pour être très similaire à leurs homologues physiques et le système d'exploitation peut être testé sur une machine réelle juste en copiant l'exécutable sur un CD et en recherchant une machine appropriée.

2-2. Amorçage (boot)

L'amorçage ou séquence de boot d'un système d'exploitation consiste à transférer le contrôle au long d'une chaîne de petits programmes, chacun d'entre eux étant plus « puissant » que le précédent, et dans laquelle le système d'exploitation est le dernier « programme » de la liste. La figure suivante donne un exemple de processus d'amorçage :

Image non disponible
Un exemple de processus de démarrage. Chaque boîte est un programme.

2-2-1. BIOS

Lors du démarrage, l'ordinateur va lancer un petit programme qui adhère à la norme du Basic Input Output System ou BIOS(16), le système d'entrée-sortie de base. Ce programme est généralement stocké sur une puce de mémoire en lecture seule sur la carte mère du PC. Le rôle initial des programmes BIOS était d'exporter certaines fonctions de la bibliothèque pour l'impression à l'écran, la lecture de l'entrée au clavier, etc. Les systèmes d'exploitation modernes n'utilisent plus les fonctions du BIOS, ils utilisent des pilotes qui interagissent directement avec le matériel, en contournant le BIOS. Aujourd'hui, le BIOS exécute principalement des diagnostics précoces (l'autotest à l'allumage), puis transfère le contrôle au chargeur d'amorçage.

2-2-2. Le chargeur d'amorçage (bootloader)

Le programme BIOS transfère ensuite le contrôle de l'ordinateur à un programme appelé chargeur d'amorçage (ou bootloader). La tâche de celui-ci est de transférer le contrôle à nous, les développeurs du système d'exploitation, et à notre code. Toutefois, compte tenu de certaines restrictions * du matériel et aussi pour des raisons de compatibilité descendante, le chargeur d'amorçage est souvent divisé en deux parties : la première partie transfère le contrôle à la seconde partie, qui donne enfin le contrôle du PC au système d'exploitation.

L'écriture d'un chargeur d'amorçage consiste à écrire beaucoup de code de bas niveau qui interagit avec le BIOS. Par conséquent, nous allons utiliser un chargeur d'amorçage existant : le chargeur d'amorçage GRand Unified Bootloader (GRUB)(17) du GNU.

En utilisant le GRUB, le système d'exploitation peut être compilé comme un exécutable ELF (Executable and Linkable Format - format exécutable et liable) ordinaire(18), qui sera chargé par le GRUB à l'emplacement mémoire approprié. La compilation du noyau exige que le code soit disposé dans la mémoire d'une manière spécifique (la façon de compiler le noyau sera discutée plus loin dans ce chapitre).

Le chargeur d'amorçage doit tenir dans le secteur d'amorçage (master boot record - MBR) d'un disque dur, dont la taille est de seulement 512 octets.

2-2-3. Le système d'exploitation

Le GRUB cède ensuite le contrôle au système d'exploitation en sautant à un nouvel emplacement mémoire. Avant ce saut mémoire, GRUB recherche un nombre magique pour s'assurer qu'il saute bien à un système d'exploitation et non à du code quelconque. Ce nombre magique fait partie de la spécification multiboot(19) à laquelle le GRUB se conforme. Dès que le GRUB a effectué le saut, c'est le système d'exploitation qui a tout le contrôle de l'ordinateur.

2-3. Bonjour Cafebabe

Cette section décrit comment mettre en œuvre le plus petit OS qui puisse être utilisé avec GRUB. La seule chose que l'OS va faire est d'écrire 0xCAFEBABE dans le registre eax (probablement, la plupart des gens n'appelleraient même pas cela un système d'exploitation).

2-3-1. Compiler le système d'exploitation

Cette partie de l'OS doit être écrite en assembleur, car le C nécessite une pile qui n'est pas disponible (le chapitre Passage au C décrit comment en créer une). Enregistrez le code suivant dans un fichier appelé loader.s :

 
Sélectionnez
global loader                      ; le symbole d'entrée pour ELF

NOMBRE_MAGIQUE equ 0x1BADB002      ; définit la constante nombre magique
DRAPEAUX       equ 0x0             ; drapeaux multiboot
CHECKSUM       equ -NOMBRE_MAGIQUE ; calcule la somme de contrôle
                                   ; (nombre magique + somme de contrôle + drapeaux doit être égal à 0)

section .text:                     ; début de la section texte (code)
align 4                            ; le code doit être aligné à 4 octets
    dd NOMBRE_MAGIQUE              ; écrit le nombre magique en code machine,
    dd DRAPEAUX                    ; les drapeaux,
    dd CHECKSUM                    ; et la somme de contrôle
loader:                            ; l'étiquette du chargeur (définie comme point d'entrée dans le script de l'éditeur de liens)
    mov eax, 0xCAFEBABE            ; place le nombre 0xCAFEBABE dans le registre eax
.loop:
    jmp .loop                      ; boucle infinie

La seule chose que cet OS va faire sera d'écrire le nombre très spécifique 0xCAFEBABE dans le registre eax. Il est très peu probable que le nombre 0xCAFEBABE s'y trouve déjà, si l'OS ne l'a pas écrit là.

Le fichier loader.s peut être compilé dans un fichier objet ELF de 32 bits avec la commande suivante :

 
Sélectionnez
nasm -f elf32 loader.s

2-3-2. Éditer les liens du noyau

Le code doit maintenant subir une édition de liens afin de produire un fichier exécutable, ce qui nécessite une certaine réflexion supplémentaire par rapport à la liaison de la plupart des programmes. Nous voulons que le GRUB charge le noyau à une adresse mémoire supérieure ou égale à 0x00100000 (1 mégaoctet [Mo]), parce que les adresses inférieures à 1 Mo sont utilisées par le GRUB lui-même, par le BIOS et les E/S mappées en mémoire. Par conséquent, le script suivant d'édition de liens est nécessaire (il est écrit pour GNU LD(20)) :

 
Sélectionnez
ENTRY(loader)                /* le nom de l'étiquette du point d'entrée */

SECTIONS {
    . = 0x00100000;          /* le code doit être chargé à 1 Mo */

    .text ALIGN (0x1000) :   /* alignement à 4 ko */
    {
        *(.text)             /* toutes les sections texte de tous les fichiers */
    }

    .rodata ALIGN (0x1000) : /* alignement à 4 ko */
    {
        *(.rodata*)          /* toutes les sections de données en lecture seule, de tous les fichiers */
    }

    .data ALIGN (0x1000) :   /* alignement à 4 ko */
    {
        *(.data)             /* toutes les sections de données, de tous les fichiers */
    }

    .bss ALIGN (0x1000) :    /* alignement à 4 ko */
    {
        *(COMMON)            /* toutes les sections COMMON de tous les fichiers */
        *(.bss)              /* toutes les sections bss de tous les fichiers */
    }
}

Enregistrez le script d'édition de liens dans un fichier appelé link.ld. L'édition des liens de l'exécutable peut désormais être exécutée avec la commande suivante :

 
Sélectionnez
ld -T link.ld -melf_i386 loader.o -o kernel.elf

L'exécutable final s'appellera kernel.elf.

2-3-3. Obtenir GRUB

La version de GRUB que nous allons utiliser est GRUB Legacy, puisque l'image ISO de l'OS peut alors être générée sur les systèmes utilisant tant GRUB Legacy que GRUB 2. Plus précisément, le chargeur d'amorçage stage2_eltorito de GRUB sera utilisé. Ce fichier peut être compilé à partir de GRUB 0.97 en téléchargeant les sources à l'adresse ftp://alpha.gnu.org/gnu/grub/grub-0.97.tar.gz. Cependant, le script configure ne fonctionne pas bien avec Ubuntu(21), alors le fichier binaire peut être téléchargé à partir de l'adresse http://littleosbook.github.com/files/stage2_eltorito. Copiez le fichier stage2_eltorito dans le dossier qui contient déjà les fichiers loader.s et link.ld.

2-3-4. Créer une image ISO

L'exécutable doit être placé sur un support qui peut être chargé par une machine virtuelle ou physique. Dans ce livre, nous allons utiliser comme support média les fichiers image ISO(22), mais on peut également utiliser des images de type disquette, en fonction de ce que la machine virtuelle ou physique prend en charge.

Nous allons créer l'image ISO du noyau avec le programme genisoimage. Il faut d'abord créer un répertoire contenant les fichiers qui seront sur l'image ISO. Les commandes suivantes créent le répertoire et copient les fichiers à l'emplacement voulu :

 
Sélectionnez
mkdir -p iso/boot/grub            # crée la structure du répertoire
cp stage2_eltorito iso/boot/grub/ # copie le chargeur d'amorçage
cp kernel.elf iso/boot/           # copie le noyau

Un fichier de configuration menu.lst pour GRUB doit être créé. Ce fichier indique à GRUB où se trouve le noyau et configure quelques options :

 
Sélectionnez
default=0
timeout=0

title os
kernel /boot/kernel.elf

Placez le fichier menu.lst dans le répertoire iso/boot/grub/. Le contenu du répertoire iso devrait maintenant ressembler à ce qui suit :

 
Sélectionnez
    iso
    |-- boot
      |-- grub
      | |-- menu.lst
      | |-- stage2_eltorito
      |-- kernel.elf

Il est alors possible de générer l'image ISO avec la commande suivante :

 
Sélectionnez
genisoimage -R                              \
            -b boot/grub/stage2_eltorito    \
            -no-emul-boot                   \
            -boot-load-size 4               \
            -A os                           \
            -input-charset utf8             \
            -quiet                          \
            -boot-info-table                \
            -o os.iso                       \
            iso

Pour plus d'informations sur les options utilisées dans la commande, voir le manuel de genisoimage.

L'image ISO os.iso contient maintenant l'exécutable du noyau, le chargeur d'amorçage GRUB et le fichier de configuration.

2-3-5. Exécuter Bochs

Maintenant, nous pouvons exécuter l'OS dans l'émulateur Bochs en utilisant l'image ISO os.iso. Pour démarrer, Bochs a besoin d'un fichier de configuration. Voici un exemple de fichier de configuration simple :

 
Sélectionnez
megs:            32
display_library: sdl
romimage:        file=/usr/share/bochs/BIOS-bochs-latest
vgaromimage:     file=/usr/share/bochs/VGABIOS-lgpl-latest
ata0-master:     type=cdrom, path=os.iso, status=inserted
boot:            cdrom
log:             bochslog.txt
clock:           sync=realtime, time0=local
cpu:             count=1, ips=1000000

Vous devrez peut-être modifier le chemin d'accès romimage et vgaromimage selon la façon dont vous avez installé Bochs. Vous trouverez plus d'informations sur le fichier de configuration de Bochs sur le site de Bochs(23).

Si vous avez enregistré la configuration dans un fichier nommé bochsrc.txt, alors vous pouvez exécuter Bochs avec la commande suivante :

 
Sélectionnez
bochs -f bochsrc.txt -q

L'option -f dit à Bochs d'utiliser le fichier de configuration donné et -q lui dit d'ignorer le menu interactif de démarrage. Vous devriez maintenant voir Bochs démarrer et afficher une console avec quelques informations de GRUB.

Après avoir quitté Bochs, affichez le journal produit par celui-ci :

 
Sélectionnez
cat bochslog.txt

Vous devriez maintenant voir quelque part s'afficher le contenu des registres du processeur simulés par Bochs. Si vous trouvez dans la sortie RAX = 00000000CAFEBABE ou EAX = CAFEBABE (selon si vous utilisez Bochs avec ou sans le support pour 64 bits), alors votre système d'exploitation a démarré avec succès !

2-4. Lectures complémentaires

3. Passage au C

Ce chapitre va vous montrer comment utiliser le C comme langage de programmation pour le système d'exploitation à la place de l'assembleur. L'assembleur est très bon pour interagir avec le processeur et permet un contrôle maximum sur tous les aspects du code. Cependant, du moins pour les auteurs, le C est un langage beaucoup plus pratique à utiliser. Par conséquent, nous aimerions utiliser le C autant que possible et utiliser l'assembleur uniquement là où cela se justifie.

3-1. Configurer une pile

Une condition préalable pour l'utilisation du C est l'existence d'une pile, puisque tous les programmes non triviaux en C en utilisent une. La mise en place d'une pile n'est pas plus difficile que de faire pointer le registre esp vers la fin d'une zone de mémoire libre (rappelez-vous que sur l'architecture x86, la pile se développe vers les adresses inférieures) qui est correctement alignée (l'alignement à 4 octets est recommandé d'un point de vue de la performance).

Nous pourrions faire pointer esp vers une zone aléatoire dans la mémoire puisque, jusqu'à présent, dans la mémoire ne se trouvent que GRUB, BIOS, le noyau du système d'exploitation et certaines E/S mappées en mémoire. Mais ce n'est pas une bonne idée, car nous ignorons la taille de la mémoire disponible, ou si la zone vers laquelle esp pointerait est utilisée par autre chose. Il vaut mieux réserver une partie de mémoire non initialisée dans la section bss du fichier ELF du noyau. Il est préférable d'utiliser la section bss à la place de la section data, afin de réduire la taille de l'exécutable de l'OS. Puisqu'il comprend ELF, GRUB va allouer lors du chargement de l'OS tout espace mémoire réservé dans la section bss.

La pseudo-instruction resb(24) de NASM peut être utilisée pour déclarer des données non initialisées :

 
Sélectionnez
KERNEL_STACK_SIZE equ 4096        ; taille de la pile en octets

section .bss
align 4                           ; alignement à 4 octets
kernel_stack:                     ; étiquette pointant vers le début de la mémoire
    resb KERNEL_STACK_SIZE        ; réserve la pile pour le noyau

Il n'y a pas lieu de se soucier de l'utilisation d'emplacements de mémoire non initialisés pour la pile, car il est impossible de lire un emplacement de la pile qui n'a pas été écrit (à moins de bidouiller manuellement le pointeur de la pile). Un programme (correct) ne peut pas prélever un élément de la pile sans l'y avoir préalablement mis. Par conséquent, les emplacements de mémoire de la pile auront toujours été forcément écrits avant d'être lus.

Le pointeur de la pile est alors configuré en faisant pointer esp vers la fin de la mémoire kernel_stack :

 
Sélectionnez
mov esp, kernel_stack + KERNEL_STACK_SIZE   ; fait pointer esp vers le début de la
                                            ; pile (fin de la zone de mémoire)

3-2. Appeler le code C à partir de l'assembleur

L'étape suivante est d'appeler une fonction C à partir du code en assembleur. Il existe de nombreuses conventions différentes pour faire cela(25). Ce livre utilise la convention d'appel cdecl, puisque c'est celle utilisée par GCC. La convention d'appel cdecl stipule que les arguments d'une fonction doivent être passés via la pile (sur x86). Les arguments de la fonction doivent être déposés sur la pile de droite à gauche, c'est-à-dire que l'on commence par l'argument le plus à droite. La valeur de retour de la fonction est placée dans le registre eax. Le code suivant montre un exemple :

 
Sélectionnez
/* La fonction C */
int somme_de_trois(int arg1, int arg2, int arg3)
{
    return arg1 + arg2 + arg3;
}
 
Sélectionnez
; Le code assembleur 
external somme_de_trois ; la fonction somme_de_trois est définie ailleurs

push dword 3            ; arg3
push dword 2            ; arg2
push dword 1            ; arg1
call somme_de_trois     ; appelle la fonction, le résultat sera en eax

3-2-1. Compacter les structures

Dans le reste de ce livre, vous rencontrerez souvent des « octets de configuration », qui sont des collections de bits dans un ordre très précis. Ci-après, un exemple avec 32 bits :

 
Sélectionnez
Bit:     | 31     24 | 23          8 | 7     0 |
Contenu: | index     | adresse       | config  |

Au lieu d'utiliser un entier non signé, unsigned int, pour le traitement de ces configurations, il est beaucoup plus facile d'utiliser les « structures compactées » :

 
Sélectionnez
struct exemple {
    unsigned char config;   /* bit 0 - 7   */
    unsigned short adresse; /* bit 8 - 23  */
    unsigned char index;    /* bit 24 - 31 */
};

Lors de l'utilisation de la struct de l'exemple précédent, il n'y a aucune garantie que sa taille sera d'exactement 32 bits - le compilateur peut ajouter une marge de quelques bits entre les éléments pour diverses raisons, par exemple pour accélérer l'accès à l'élément ou en raison des exigences fixées par le matériel et/ou le compilateur. Lorsque vous utilisez une struct pour représenter les octets de configuration, il est très important que le compilateur n'ajoute aucune marge, parce que la structure sera finalement traitée par le matériel comme un entier non signé sur 32 bits. L'attribut packed peut être utilisé pour forcer GCC à ne pas ajouter de marge :

 
Sélectionnez
struct exemple {
    unsigned char config;   /* bit 0 - 7   */
    unsigned short adresse; /* bit 8 - 23  */
    unsigned char index;    /* bit 24 - 31 */
} __attribute__((packed));

Notez que __attribute__((packed)) ne fait pas partie de la norme C, alors cela pourrait ne pas fonctionner avec tous les compilateurs C.

3-3. Compiler le code C

Lors de la compilation du code C pour le système d'exploitation, il faut utiliser un certain nombre d'options de compilation spécifiques de GCC, parce que le code C ne doit pas supposer la présence d'une bibliothèque standard, puisqu'il n'y a aucune bibliothèque standard disponible pour notre OS. Pour plus d'informations sur ces options, voir le manuel de GCC.

Les options utilisées pour compiler le code C sont :

 
Sélectionnez
    -m32 -nostdlib -nostdinc -fno-builtin -fno-stack-protector
    -nostartfiles  -nodefaultlibs

Comme toujours lors de l'écriture des programmes en C, nous recommandons d'activer tous les avertissements et de traiter les avertissements comme des erreurs :

 
Sélectionnez
    -Wall -Wextra -Werror

Maintenant, vous pouvez créer dans un fichier appelé kmain.c une fonction kmain que vous appelez à partir de loader.s. À ce stade, kmain n'aura probablement besoin d'aucun argument (mais ce sera le cas dans les chapitres suivants).

3-4. Outils de compilation

C'est probablement aussi le bon moment pour mettre en place des outils de compilation pour faciliter la compilation et les tests d'exécution de l'OS. Nous vous recommandons d'utiliser make du GNU, mais beaucoup d'autres systèmes de compilation sont disponibles. Un simple Makefile pour l'OS pourrait ressembler à l'exemple suivant :

 
Sélectionnez
OBJECTS = loader.o kmain.o
CC = gcc
CFLAGS = -m32 -nostdlib -nostdinc -fno-builtin -fno-stack-protector \
         -nostartfiles -nodefaultlibs -Wall -Wextra -Werror -c
LDFLAGS = -T link.ld -melf_i386
AS = nasm
ASFLAGS = -f elf

all: kernel.elf

kernel.elf: $(OBJECTS)
    ld $(LDFLAGS) $(OBJECTS) -o kernel.elf

os.iso: kernel.elf
    cp kernel.elf iso/boot/kernel.elf
    genisoimage -R                              \
                -b boot/grub/stage2_eltorito    \
                -no-emul-boot                   \
                -boot-load-size 4               \
                -A os                           \
                -input-charset utf8             \
                -quiet                          \
                -boot-info-table                \
                -o os.iso                       \
                iso

run: os.iso
    bochs -f bochsrc.txt -q

%.o: %.c
    $(CC) $(CFLAGS)  $< -o $@

%.o: %.s
    $(AS) $(ASFLAGS) $< -o $@

clean:
    rm -rf *.o kernel.elf os.iso

Le contenu de votre répertoire de travail devrait ressembler à ce qui suit :

 
Sélectionnez
    .
    |-- bochsrc.txt
    |-- iso
    |   |-- boot
    |     |-- grub
    |       |-- menu.lst
    |       |-- stage2_eltorito
    |-- kmain.c
    |-- loader.s
    |-- Makefile

Vous devriez maintenant être en mesure de démarrer le système d'exploitation par une simple commande make run, qui va compiler le noyau et le démarrer dans Bochs (comme défini dans le Makefile ci-dessus).

3-5. Lecture complémentaire

Le livre Le langage C, Deuxième édition (Dunod), de Brian Kernighan et Dennis Ritchie est idéal pour apprendre tous les aspects du C.

4. Les sorties

Ce chapitre présente la façon d'afficher du texte sur la console et d'écriture des données sur le port série. En outre, nous allons créer notre premier pilote ou driver, c'est-à-dire programme qui agit comme une couche intermédiaire entre le noyau et le matériel, en fournissant un niveau d'abstraction supérieur à la communication directe avec le matériel. La première partie de ce chapitre traite de la création d'un pilote pour le tampon de trame ou framebuffer(26), afin d'être en mesure d'afficher du texte sur la console. La seconde partie montre comment créer un pilote pour le port série. Bochs peut stocker la sortie du port série dans un fichier, créant de fait un mécanisme de journalisation pour le système d'exploitation.

4-1. Interagir avec le matériel

Il y a généralement deux façons différentes d'interagir avec le matériel, les E/S mappées en mémoire et les ports d'E/S.

Si le matériel utilise les E/S mappées en mémoire, alors vous pouvez écrire à une adresse mémoire spécifique et le matériel sera mis à jour avec les nouvelles données. Un exemple est représenté par le tampon de trame, qui sera décrit plus en détail plus tard. Par exemple, si vous écrivez la valeur 0x410F à l'adresse 0x000B8000, vous verrez la lettre A en blanc sur un fond noir (voir la section sur le tampon de trameLe tampon de trame pour plus de détails).

Si le matériel utilise des ports E/S, alors les instructions assembleur out et in doivent être utilisées pour communiquer avec le matériel. L'instruction out prend deux paramètres : l'adresse du port d'E/S et les données à envoyer. L'instruction in prend un seul paramètre, l'adresse du port d'E/S, et retourne les données à partir du matériel. On peut penser aux ports d'E/S comme communiquant avec le matériel de la même manière que vous communiquez avec un serveur en utilisant les sockets. Le curseur (le rectangle clignotant) du tampon de trame est un exemple du matériel sur un PC contrôlé via les ports d'E/S.

4-2. Le tampon de trame

Le tampon de trame est un dispositif matériel qui est capable d'afficher à l'écran le contenu de la mémoire tampon. Il dispose de 80 colonnes et 25 lignes, et les indices des lignes et des colonnes commencent à 0 (les lignes sont donc numérotées de 0 à 24).

4-2-1. Écrire du texte

L'écriture d'un texte à la console via le tampon de trame est faite avec des E/S mappées en mémoire. L'adresse de départ des E/S mappées en mémoire pour le tampon de trame est 0x000B8000(27). La mémoire est divisée en cellules de 16 bits, où les 16 bits déterminent à la fois le caractère, la couleur d'avant-plan et la couleur d'arrière-plan. Les huit bits les plus élevés représentent la valeur ASCII(28) du caractère, les bits de 7 à 4, l'arrière-plan et les bits de 3 à 0, le premier plan, comme on peut le voir ci-dessous :

 
Sélectionnez
Bit :     | 15 14 13 12 11 10 9 8 | 7 6 5 4 | 3 2 1 0 |
Contenu : | ASCII                 |Av.-plan | Ar.-plan|

Les couleurs disponibles sont reprises dans le tableau suivant :

Couleur

Valeur

Couleur

Valeur

Couleur

Valeur

Couleur

Valeur

Noir

0

Rouge

4

Gris foncé

8

Rouge clair

12

Bleu

1

Magenta

5

Bleu clair

9

Magenta clair

13

Vert

2

Brun

6

Vert clair

10

Brun clair

14

Cyan

3

Gris clair

7

Cyan clair

11

Blanc

15

La première cellule correspond à la ligne zéro, colonne zéro sur la console. En utilisant une table ASCII, on peut voir que le caractère A correspond à la valeur 65 ou 0x41. Par conséquent, pour écrire le caractère A avec un avant-plan vert (2) et le fond gris foncé (8) à l'endroit (0,0), l'instruction de code assembleur suivante est utilisée :

 
Sélectionnez
mov [0x000B8000], 0x4128

La deuxième cellule correspond alors à la ligne zéro, colonne 1, et son adresse est donc :

 
Sélectionnez
0x000B8000 + 16 = 0x000B8010

L'écriture au tampon de trame peut également être faite en C, en traitant l'adresse 0x000B8000 comme un pointeur de char, char *tt = (char *) 0x000B8000. Donc, l'écriture du caractère A à l'endroit (0,0) avec l'avant-plan vert et sur fond gris foncé devient :

 
Sélectionnez
tt[0] = 'A';
tt[1] = 0x28;

Le code suivant montre comment incorporer cela dans une fonction :

 
Sélectionnez
/** tt_ecrire_cellule :
*  Écrit un caractère avec l'avant-plan et l'arrière-plan donnés à la position i
*  dans le tampon de trame.
*
*  @param i  L'emplacement dans le tampon de trame
*  @param c  Le caractère
*  @param fg La couleur de l'avant-plan
*  @param bg La couleur de l'arrière-plan
*/
void tt_ecrire_cellule(unsigned int i, char c, unsigned char fg, unsigned char bg)
{
    tt[i] = c;
    tt[i + 1] = ((fg & 0x0F) << 4) | (bg & 0x0F)
}

La fonction peut être utilisée comme ceci :

 
Sélectionnez
#define TT_VERT       2
#define TT_GRIS_FONCE 8

tt_ecrire_cellule(0, 'A', TT_VERT, TT_GRIS_FONCE);

4-2-2. Déplacer le curseur

Le déplacement du curseur du tampon de trame se fait via deux ports différents d'E/S. La position du curseur est déterminée par un entier sur 16 bits : 0 signifie ligne zéro, colonne zéro ; 1 signifie ligne zéro, colonne 1 ; 80 signifie ligne 1, colonne zéro et ainsi de suite. Comme la position prend 16 bits alors que l'argument de l'instruction assembleur out a une taille de 8 bits, la position doit être envoyée en deux fois, d'abord les 8 premiers bits puis les 8 bits suivants. Le tampon de trame possède deux ports d'E/S, un pour accepter les données et un pour décrire les données reçues. Le port 0x3D4(29) est celui qui décrit les données et le port 0x3D5 est pour les données mêmes.

Pour régler le curseur sur la ligne 1, colonne zéro (la position 80 = 0x0050), on pourrait utiliser les instructions suivantes en assembleur :

 
Sélectionnez
out 0x3D4, 14      ; 14 dit au tampon de trame d'attendre les 8 bits supérieurs de la position
out 0x3D5, 0x00    ; envoyer les 8 bits supérieurs de 0x0050
out 0x3D4, 15      ; 15 dit au tampon de trame d'attendre les 8 bits inférieurs de la position
out 0x3D5, 0x50    ; envoyer les 8 bits inférieurs de 0x0050

L'instruction en assembleur out ne peut pas être exécutée directement en C. Par conséquent, c'est une bonne idée de l'englober dans une fonction écrite en assembleur qui peut être accédée par le code C via la norme de l'appel cdecl :

 
Sélectionnez
global outb           ; rend l'étiquette outb visible en dehors de ce fichier

; outb - envoie un octet vers un port E/S
; pile : [esp + 8] l'octet de données
;        [esp + 4] le port E/S
;        [esp    ] l'adresse de retour
outb:
    mov al, [esp + 8] ; déplace les données à envoyer dans le registre al
    mov dx, [esp + 4] ; déplace l'adresse du port E/S dans le registre dx 
    out dx, al        ; envoie les données vers le port E/S
    ret               ; retour vers la fonction appelante

En stockant cette fonction dans un fichier nommé io.s et en créant aussi un fichier en-tête io.h, l'instruction assembleur out peut être accédée convenablement à partir du code C :

 
Sélectionnez
#ifndef INCLUDE_IO_H
#define INCLUDE_IO_H

/** outb:
 *  Envoie les données vers le port E/S donné. Défini en io.s
 *
 *  @param port Le port E/S vers lequel envoyer les données
 *  @param data Les données à envoyer vers le port E/S
 */
void outb(unsigned short port, unsigned char data);

#endif /* INCLUDE_IO_H */

Le déplacement du curseur peut maintenant être englobé dans une fonction en C :

 
Sélectionnez
#include "io.h"

/* Les ports d'E/S */
#define TT_PORT_COMMANDE        0x3D4
#define TT_PORT_DATA            0x3D5

/* Les commandes du port d'E/S */
#define TT_COMMANDE_OCTET_SUP    14
#define TT_COMMANDE_OCTET_INF    15

/** tt_deplace_curseur:
 *  Déplace le curseur du tampon de trame à la position donnée
 *
 *  @param pos La nouvelle position du curseur
 */
void tt_deplace_curseur(unsigned short pos)
{
    outb(TT_PORT_COMMANDE, TT_COMMANDE_OCTET_SUP);
    outb(TT_PORT_DATA,    ((pos >> 8) & 0x00FF));
    outb(TT_PORT_COMMANDE, TT_COMMANDE_OCTET_INF);
    outb(TT_PORT_DATA,    pos & 0x00FF);
}

4-2-3. Le pilote

Le pilote doit fournir une interface utilisée par le reste du code de l'OS pour interagir avec le tampon de trame. Il n'y a pas de bon ou de mauvais choix en ce que la fonctionnalité de l'interface doit fournir, mais une suggestion est d'avoir une fonction write, avec la déclaration suivante :

 
Sélectionnez
int write(char *buf, unsigned int len);

La fonction write écrit à l'écran le contenu de longueur len du tampon buf. Elle doit faire avancer automatiquement le curseur après l'écriture d'un caractère et faire défiler l'écran si nécessaire.

4-3. Les ports série

Le port série (30) est une interface pour la communication entre dispositifs matériels. Même s'il est disponible sur la quasi-totalité des cartes mères, il est de nos jours moins souvent disponible à l'utilisateur sous la forme d'un connecteur DE-9 (à 9 broches). Le port série est facile à utiliser et, surtout, peut être utilisé comme un outil de journalisation dans Bochs. Si un ordinateur a un support pour un port série, alors il a généralement le support pour plusieurs ports série ; mais nous en utiliserons un seul, car nous allons utiliser les ports série uniquement pour la journalisation. En outre, nous allons utiliser les ports série uniquement pour la sortie, pas pour l'entrée. Les ports série sont complètement contrôlés via les ports E/S.

4-3-1. Configurer le port série

Les premières données qui doivent être envoyées au port série sont des données de configuration. Pour que deux périphériques matériels soient en mesure de communiquer entre eux, ils doivent se mettre d'accord sur un certain nombre de choses. Ces choses incluent :

  • la vitesse utilisée pour envoyer des données (débit en bits par seconde ou bauds) ;
  • si une vérification d'erreur doit être utilisée pour les données (bit de parité, bits d'arrêt) ;
  • le nombre de bits qui représentent une unité de données (bits de données).

4-3-2. Configurer la ligne

Configurer la ligne signifie configurer la façon dont les données sont envoyées sur la ligne. Le port série dispose d'un port E/S, le port de commande de ligne, qui est utilisé pour la configuration.

La vitesse d'envoi de données sera définie en premier. Le port série possède une horloge interne qui fonctionne à 115 200 Hz. Définir la vitesse signifie envoyer un diviseur vers le port série, par exemple l'envoi de 2 résultats à une vitesse de 115 200/2 = 57 600 Hz.

Le diviseur est un nombre sur 16 bits, mais nous pouvons envoyer seulement 8 bits à la fois. Nous devons donc envoyer une instruction disant au port série d'attendre d'abord les 8 bits les plus élevés, puis les 8 bits inférieurs. Cela se fait par l'envoi de 0x80 au port de commande de ligne. Un exemple est présenté ci-dessous :

 
Sélectionnez
#include "io.h" /* io.h est implémenté dans la section « Déplacer le curseur » */

/* Les ports E/S */

/* Tous les ports E/S sont calculés par rapport au port de données.  Cela, parce que
 * tous les ports série (COM1, COM2, COM3, COM4) ont leurs ports dans le même
 * ordre, mais ils commencent à des valeurs différentes.
 */

#define SERIE_COM1_BASE                0x3F8      /* le port COM1 base */

#define SERIE_DATA_PORT(base)           (base)
#define SERIE_FIFO_COMMAND_PORT(base)   (base + 2)
#define SERIE_LIGNE_COMMAND_PORT(base)  (base + 3)
#define SERIE_MODEM_COMMAND_PORT(base)  (base + 4)
#define SERIE_LIGNE_STATUS_PORT(base)   (base + 5)

/* Les commandes de port E/S */

/* SERIE_LIGNE_ACTIVER_DLAB:
 * Dit au port série d'attendre en premier les 8 bits les plus élevés sur le port de données,
 * puis les 8 bits inférieurs suivront
 */
#define SERIE_LIGNE_ACTIVER_DLAB         0x80

/** serie_configurer_baud_rate:
 *  Définit la vitesse d'envoi des données. La vitesse par défaut d'un port série
 *  est de 115200 bits/s. L'argument est un diviseur de ce nombre, alors la vitesse
 *  résultante devient (115200 / diviseur) bits/s.
 *
 *  @param com       Le port COM à configurer
 *  @param diviseur  Le diviseur
 */
void serie_configurer_baud_rate(unsigned short com, unsigned short diviseur)
{
    outb(SERIE_LIGNE_COMMAND_PORT(com),
         SERIE_LIGNE_ACTIVER_DLAB);
    outb(SERIE_DATA_PORT(com),
         (divisor >> 8) & 0x00FF);
    outb(SERIE_DATA_PORT(com),
         divisor & 0x00FF);
}

Il faut configurer la façon dont les données doivent être envoyées. Cela aussi se fait via le port de commande de la ligne, en envoyant un octet. La disposition des 8 bits se présente comme suit :

 
Sélectionnez
Bit :     | 7 | 6 | 5 4 3 | 2 | 1 0 |
Contenu : | d | b | prty  | s | dl  |

Une description de chaque nom se trouve dans le tableau ci-dessous :

Nom

Description

d

Active (d = 1) ou désactive (d = 0) DLAB

b

Si le contrôle de l'interruption est activé (b = 1) ou désactivé (b = 0)

prty

Le nombre de bits de parité à utiliser

s

Le nombre de bits d'arrêt à utiliser (s = 0 correspond à 1, s = 1 correspond à 1,5 ou 2

dl

Décrit la longueur des données

Nous allons utiliser principalement la valeur 0x03 de la norme(30), qui signifie une longueur de 8 bits, sans bit de parité, un bit d'arrêt et contrôle de l'interruption Break désactivé. Cette valeur est envoyée au port de commande de ligne, comme on le voit dans l'exemple suivant :

 
Sélectionnez
/** configurer_ligne_serie :
 *  Configure la ligne du port série donné. Le port est configuré pour avoir une
 *  longueur des données de 8 bits, aucun bit de parité, un bit d'arrêt et le
 *  contrôle d'interruption désactivé.
 *
 *  @param com  Le port série à configurer
 */
void configurer_ligne_serie(unsigned short com)
{
    /* Bit :     | 7 | 6 | 5 4 3 | 2 | 1 0 |
     * Contenu : | d | b | prty  | s | dl  |
     * Valeur :  | 0 | 0 | 0 0 0 | 0 | 1 1 | = 0x03
     */
    outb(SERIE_LIGNE_COMMAND_PORT(com), 0x03);
}

L'article (31) sur OSDev offre une explication plus approfondie des valeurs.

4-3-3. Configurer les tampons

Lorsque les données sont transmises via le port série, elles sont placées dans des tampons, aussi bien lors de la réception et lors de l'envoi des données. De cette façon, si vous envoyez au port série des données plus vite qu'il ne peut les envoyer sur la ligne, elles seront mises en tampon. Cependant, si vous envoyez trop de données trop vite, la mémoire tampon sera pleine et les données seront perdues. Autrement dit, les tampons sont des files d'attente FIFO (premier entré, premier sorti). L'octet de configuration de file d'attente FIFO ressemble à ce qui suit :

 
Sélectionnez
Bit :     | 7 6 | 5  | 4 | 3   | 2   | 1   | 0 |
Contenu : | lvl | bs | r | dma | clt | clr | e |

Une description de chaque nom se trouve dans le tableau ci-dessous :

Nom

Description

lvl

Le nombre de bits qui doivent être stockés dans les tampons FIFO

bs

Indique si les tampons doivent avoir une taille de 16 ou de 64 octets

r

Réservé pour usage dans le futur

dma

Indique comment il faut accéder au port série de données

clt

Vide le tampon FIFO de transmission

clr

Vide le tampon FIFO du récepteur

e

Indique si le tampon FIFO doit être activé ou pas

Nous utilisons la valeur 0xC7, qui :

  • active FIFO ;
  • vide les deux files FIFO, tant celle de transmission que celle du récepteur ;
  • utilise une taille de file de 14 octets.

Le WikiBook sur la programmation série(31) explique les valeurs plus en détail.

4-3-4. Configurer le modem

Le registre de contrôle du modem est utilisé pour un contrôle matériel très simple du flux via les connecteurs Ready To Transmit - RTS (« prêt à transmettre ») et Data Terminal Ready - DTR (« terminal de données prêt »). Lors de la configuration du port série, nous voulons mettre RTS et DTR à 1, ce qui signifie que nous sommes prêts à envoyer des données.

L'octet de configuration du modem est indiqué ci-dessous :

 
Sélectionnez
Bit :     | 7 | 6 | 5  | 4  | 3   | 2   | 1   | 0   |
Contenu : | r | r | af | lb | ao2 | ao1 | rts | dtr |

Une description de chaque nom se trouve dans le tableau ci-dessous :

Nom

Description

r

Réservé

af

Contrôle du flux automatique activé

lb

Mode loopback ou « diagnostic » (utilisé pour déboguer les ports série)

ao2

Sortie auxiliaire 2, utilisée pour recevoir des interruptions

ao1

Sortie auxiliaire 1

rts

Ready To Transmit

dtr

Data Terminal Ready

Nous n'avons pas besoin d'activer les interruptions, parce que nous ne gérerons aucune donnée reçue. Par conséquent, nous utilisons la valeur de configuration 0x03 = 00000011 (RTS = 1 et DTR = 1).

4-3-5. Écrire des données sur le port série

L'écriture de données sur le port série se fait via le port d'E/S de données. Cependant, avant l'écriture, la file FIFO de transmission doit être vide (toutes les écritures précédentes doivent être finies). La file FIFO de transmission est vide si le bit 5 de l'état de la ligne du port E/S est égal à un.

La lecture du contenu d'un port E/S se fait grâce à l'instruction in en assembleur. Il n'est pas possible d'utiliser cette instruction assembleur à partir du code C, par conséquent, elle doit être enveloppée (de la même manière que l'instruction en assembleur out) :

 
Sélectionnez
global inb

; inb - retourne un octet reçu du port E/S donné
; pile : [esp + 4] L'adresse du port E/S donné
;        [esp    ] L'adresse de retour
inb:
    mov dx, [esp + 4]    ; déplace l'adresse du port E/S donné dans le registre dx
    in  al, dx           ; lit un octet reçu du port E/S et le stocke dans le registre al
    ret                  ; retourne l'octet lu
 
Sélectionnez
/* dans le fichier io.h */

/** inb:
 *  Lit un octet reçu d'un port E/S.
 *
 *  @param  port L'adresse du port E/S
 *  @return      L'octet lu
 */
unsigned char inb(unsigned short port);

Ensuite, la vérification si la file FIFO de transmission est vide peut être faite par le code C :

 
Sélectionnez
#include "io.h"

/** serie_est_transmiss_fifo_vide :
 *  Vérifie si la file FIFO de transmission est vide ou pas pour le   port COM donné.
 *
 *  @param  com  Le port COM
 *  @return      0 si la file FIFO n'est pas vide
 *               1 si la file FIFO de transmission est vide
 */
int serie_is_transmiss_fifo_vide(unsigned int com)
{
    /* 0x20 = 0010 0000 */
    return inb(SERIE_LIGNE_STATUS_PORT(com)) & 0x20;
}

L'écriture sur un port série signifie tourner tant que la file de transmission FIFO n'est pas vide, puis écrire les données sur le port E/S de données.

4-3-6. Configurer Bochs

Pour enregistrer la sortie du premier port série, le fichier de configuration de Bochs, bochsrc.txt, doit être mis à jour. La configuration com1 instruit Bochs afin de gérer le premier port série :

 
Sélectionnez
    com1: enabled=1, mode=file, dev=com1.out

La sortie du premier port série sera maintenant stockée dans le fichier com1.out.

4-3-7. Le pilote

Nous vous recommandons d'implémenter pour le port série une fonction write similaire à la fonction du même nom du pilote pour le tampon de trame. Pour éviter les conflits de noms, il est judicieux de nommer les fonctions tt_write et serie_write pour les distinguer.

Nous recommandons également que vous essayiez d'écrire une fonction similaire à printf, voir la section 7.3 dans (8). Cette fonction printf pourrait prendre un argument supplémentaire pour décider sur quel périphérique écrire la sortie (tampon de trame ou port série).

Une dernière recommandation est de créer un moyen de distinguer le degré de sévérité des messages du journal, par exemple en faisant précéder les messages par DEBUG, INFO ou ERROR.

4-4. Lectures complémentaires

5. Segmentation

La segmentation en x86 signifie l'accès à la mémoire par segments. Les segments sont des zones de l'espace d'adressage, qui peuvent éventuellement se chevaucher, spécifiées par une adresse de base et une limite. Pour accéder à un octet en mémoire segmentée, vous utilisez une adresse logique de 48 bits : 16 bits qui spécifient le segment et 32 bits qui spécifient le décalage souhaité dans ce segment. Le décalage (offset) est ajouté à l'adresse de base du segment, et l'adresse linéaire résultante est vérifiée par rapport à la limite du segment - voir la figure ci-dessous. Si tout se passe bien (y compris les contrôles de droits d'accès, ignorés pour l'instant) le résultat est une adresse linéaire. Lorsque la pagination est désactivée, l'espace d'adressage linéaire est mappé 1:1 à l'espace d'adressage physique, et l'accès à la mémoire physique peut avoir lieu. (Voir le chapitre PaginationPagination pour savoir comment activer la pagination.)

Image non disponible
Traduction des adresses logiques en adresses linéaires

Pour activer la segmentation, vous devez mettre en place une table qui décrit chaque segment - une table de descripteurs de segment. Dans x86, il existe deux types de tables de descripteurs : la table de descripteurs globaux (Global Descriptor Table - GDT) et les tables de descripteurs locaux (Local Descriptor Table - LDT). Une LDT est mise en place et gérée par des processus de l'espace utilisateur, et tous les processus ont leur propre LDT. Les LDT peuvent être utilisées si vous désirez un modèle de segmentation plus complexe, nous ne l'utiliserons pas. La GDT est partagée par tout le monde - elle est globale.

Comme nous le verrons dans les sections sur la mémoire virtuelle et la pagination, la segmentation est rarement utilisée en dehors d'une configuration minimale, semblable à ce que nous mettons en place ci-dessous.

5-1. Accéder à la mémoire

La plupart du temps, lors de l'accès en mémoire, il n'est pas besoin de spécifier explicitement le segment à utiliser. Le processeur dispose de six registres de segment de 16 bits : cs, ss, ds, es, gs et fs. Le registre cs (code segment) est le registre de segment du code et spécifie le segment à utiliser lorsqu'on récupère des instructions. Le registre ss (stack segment) est utilisé chaque fois que l'on accède à la pile (à travers le pointeur de pile esp), et ds (data segment) est utilisé pour d'autres accès aux données. L'OS est libre d'utiliser les registres es, gs et fs comme il veut.

Voici un exemple montrant l'utilisation implicite des registres de segment :

 
Sélectionnez
func:
    mov eax, [esp+4]
    mov ebx, [eax]
    add ebx, 8
    mov [eax], ebx
    ret

L'exemple ci-dessus peut être comparé à l'exemple suivant, qui montre une utilisation explicite des registres de segments :

 
Sélectionnez
func:
    mov eax, [ss:esp+4]
    mov ebx, [ds:eax]
    add ebx, 8
    mov [ds:eax], ebx
    ret

Vous n'avez pas besoin d'utiliser ss pour stocker le sélecteur de segment de la pile, ou ds pour le sélecteur de segment de données. Vous pourriez stocker le sélecteur de segment de la pile dans ds et vice versa. Toutefois, pour pouvoir utiliser le style implicite indiqué ci-dessus, vous devez stocker les sélecteurs de segments dans leurs registres prévus à cette fin.

Les descripteurs de segments et leurs champs sont décrits dans la figure 3-8 dans le manuel Intel(32).

5-2. La table de descripteurs globaux (GDT)

Une GDT/LDT est un « tableau » de descripteurs de segment de 8 octets. Le premier descripteur dans le GDT est toujours un descripteur null et ne peut jamais être utilisé pour accéder à la mémoire. Au moins deux descripteurs de segment (plus le descripteur null) sont nécessaires pour la GDT, parce que le descripteur contient plus d'informations que seulement les champs base et limite. Les plus importants pour nous sont le champ Type et le champ Descripteur de niveau de privilège (Descriptor Privilege Level - DPL).

Le Tableau 3-1 dans le chapitre 3 du manuel Intel (33) spécifie les valeurs pour le champ Type. Il montre que le champ Type ne peut pas être en même temps accessible en écriture et exécutable. Par conséquent, deux segments sont nécessaires : un segment pour l'exécution du code à mettre en cs (Type est en exécution seule ou en exécution et lecture) et un segment pour les données de lecture et d'écriture (Type est en lecture/écriture) à mettre dans les autres registres de segment.

Le DPL précise les niveaux de privilèges nécessaires pour utiliser le segment. Le processeur x86 autorise quatre niveaux de privilèges (PL), de 0 à 3, où PL0 est le plus privilégié. Dans la plupart des systèmes d'exploitation (par ex. Linux et Windows), seulement PL0 et PL3 sont utilisés. Cependant, certains systèmes d'exploitation, tels que MINIX, font usage de tous les niveaux. Le noyau devrait être en mesure de tout faire, donc il utilise des segments avec DPL défini à 0 (également appelé « mode du noyau »). Le niveau de privilège courant (CPL) est déterminé par le sélecteur de segment dans le registre cs.

Les descripteurs de segments nécessaires sont repris ci-dessous.

Les descripteurs de segment nécessaires

Index

Offset

Nom

Plage d'adressage

Type

DPL

0

0x00

descripteur null

     

1

0x08

segment de code du noyau

0x00000000 - 0xFFFFFFFF

RX

PL0

2

0x10

segment de données du noyau

0x00000000 - 0xFFFFFFFF

RW

PL0

Notez que les segments se chevauchent - ils englobent tous deux la totalité de l'espace d'adressage linéaire. Dans notre configuration minimale, nous utiliserons uniquement la segmentation pour obtenir des niveaux de privilèges. Voir le manuel Intel (33), chapitre 3, pour plus de détails sur les autres champs de descripteur.

5-3. Charger la GDT

Le chargement de la GDT dans le processeur se fait avec l'instruction en assembleur lgdt, qui a besoin de l'adresse d'une structure spécifiant le début et la taille de la GDT. Il est plus facile à coder ces informations en utilisant une « structure compactéeCompacter les structures », comme dans l'exemple suivant :

 
Sélectionnez
struct gdt {
    unsigned int address;
    unsigned short size;
} __attribute__((packed));

Si le registre eax contient l'adresse d'une telle structure, la GDT peut être chargée avec le code assembleur ci-dessous :

 
Sélectionnez
lgdt [eax]

Rendre cette instruction disponible à partir de C pourrait faciliter les choses, de la même manière qu'avec les instructions assembleur in et out.

Après le chargement de la GDT, les registres de segment doivent être chargés avec leurs sélecteurs de segment correspondants. Le contenu d'un sélecteur de segment est décrit dans la figure et le tableau ci-dessous :

 
Sélectionnez
Bit :     | 15                                3 | 2  | 1 0 |
Contenu : | offset (index)                      | ti | rpl |
La disposition des sélecteurs de segments

Nom

Description

rpl

Niveau de privilège demandé - pour le moment, nous voulons exécuter en PL0.

ti

Indicateur de table. 0 signifie qu'il s'agit d'un segment de GDT, 1 représente un segment de LTD.

offset (index)

Décalage dans la table des descripteurs.

Le décalage du sélecteur de segment est ajouté au début de la GDT pour obtenir l'adresse du descripteur de segment : 0x08 pour le premier descripteur et 0x10 pour la deuxième, puisque chaque descripteur est de 8 octets. Le niveau de privilège requis (Requested Privilege Level - RPL) doit être 0 puisque le noyau de l'OS doit s'exécuter dans le niveau de privilège 0.

Le chargement des registres de sélecteur de segment est facile pour les registres de données - il suffit de copier les décalages corrects dans les registres :

 
Sélectionnez
mov ds, 0x10
mov ss, 0x10
mov es, 0x10
.
.
.

Pour charger cs, nous devons faire un « saut à distance » :

 
Sélectionnez
; ce code utilise le cs précédent
jmp 0x08:flush_cs   ; spécifiez le cs lors du saut à flush_cs

flush_cs:
    ; maintenant, nous avons modifié cs à 0x08

Un saut à distance est un saut où nous spécifions explicitement l'adresse logique complète de 48 bits : le sélecteur de segment à utiliser et l'adresse absolue où sauter. Cela définira d'abord cs à 0x08, puis sautera à flush_cs en utilisant son adresse absolue.

5-4. Lectures complémentaires

6. Interruptions et saisie

Maintenant que l'OS peut produire des sorties, ce serait bien si l'on pouvait également obtenir quelques entrées. Le système d'exploitation doit être capable de gérer les interruptions pour lire des informations à partir du clavier. Une interruption se produit lorsqu'un dispositif matériel, tel que le clavier, le port série ou l'horloge signale au processeur que l'état du dispositif a changé. Le processeur lui-même peut également envoyer des interruptions dues à des erreurs de programme, par exemple lorsqu'un programme référence de la mémoire à laquelle il n'a pas accès, ou quand un programme divise un nombre par zéro. Enfin, il y a aussi les interruptions logicielles, qui sont les interruptions causées par l'instruction assembleur int ; elles sont souvent utilisées pour les appels système.

6-1. Les gestionnaires d'interruptions

Les interruptions sont traitées via la table de descripteurs d'interruption (Interrupt Descriptor Table - IDT). L'IDT décrit un gestionnaire pour chaque interruption. Les interruptions sont numérotées (0 - 255) et le gestionnaire de l'interruption i est défini à la position i dans la table. Il y a trois types différents de gestionnaires pour interruptions :

  • gestionnaire de tâche ;
  • gestionnaire d'interruption ;
  • gestionnaire d'interception.

Les gestionnaires de tâches utilisent des fonctionnalités spécifiques à la version Intel de x86, ils ne seront pas couverts ici (voir le manuel Intel, chapitre 6, pour plus d'information). La seule différence entre un gestionnaire d'interruption et un gestionnaire d'interception est que le gestionnaire d'interruption désactive les interruptions, ce qui signifie que vous ne pouvez pas avoir une nouvelle interruption lorsque vous en gérez une. Dans ce livre, nous allons utiliser des gestionnaires d'interceptions et désactiver manuellement les interruptions lorsque nous en avons besoin.

6-2. Créer un enregistrement dans l'IDT

Un enregistrement dans l'IDT pour un gestionnaire d'interruption se compose de 64 bits. Les 32 bits supérieurs sont présentés dans la figure ci-dessous :

 
Sélectionnez
Bit :     | 31              16 | 15 | 14 13 | 12 | 11 | 10 9 8 | 7 6 5 | 4 3 2 1 0 |
Contenu : | offset supérieur   | P  | DPL   | 0  | D  | 1  1 0 | 0 0 0 |  réservé  |

Les 32 bits inférieurs sont présentés dans la figure ci-dessous :

 
Sélectionnez
Bit :     | 31              16 | 15              0 |
Contenu : |sélecteur de segment| offset inférieur  |

Une description pour chaque nom se trouve dans le tableau ci-dessous :

Nom

Description

offset supérieur

Les 16 bits les plus élevés de l'adresse de 32 bits dans le segment.

offset inférieur

Les 16 bits les plus bas de l'adresse de 32 bits dans le segment.

p

Si le gestionnaire est présent en mémoire ou pas (1 = présent, 0 = pas présent).

DPL

Descripteur du niveau de privilège, le niveau de privilège à partir duquel le gestionnaire peut être appelé (0, 1, 2, 3).

D

Taille de la porte logique (1 = 32 bits, 0 = 16 bits).

sélecteur de segment

L'offset dans la GDT.

r

Réservé.

L'offset est un pointeur vers du code (de préférence, une étiquette en code assembleur). Par exemple, pour créer une entrée pour un gestionnaire dont le code commence à 0xDEADBEEF et qui fonctionne en niveau de privilège 0 (donc en utilisant le même sélecteur de segment de code que le noyau), les deux octets suivants seront utilisés :

 
Sélectionnez
0xDEAD8E00
0x0008BEEF

Si l'IDT est représentée comme un unsigned integer idt[512], alors pour enregistrer l'exemple ci-dessus en tant que gestionnaire pour interruption 0 (division par zéro), le code suivant serait utilisé :

 
Sélectionnez
idt[0] = 0xDEAD8E00
idt[1] = 0x0008BEEF

Comme écrit dans le chapitre « Passage au CPassage au C », nous vous recommandons d'utiliser les structures compactées à la place des octets (ou entiers non signés), pour rendre le code plus lisible.

6-3. Gérer une interruption

Lorsqu'une interruption se produit, le CPU va déposer certaines informations à propos de celle-ci sur la pile, puis recherchera le gestionnaire d'interruption approprié dans l'IDT et sautera à celui-ci. La pile au moment de l'interruption va ressembler à ceci :

 
Sélectionnez
[esp + 12] eflags
[esp + 8]  cs
[esp + 4]  eip
[esp]      error code?

La raison de la présence du point d'interrogation après error code est que les interruptions ne créent pas toutes un code d'erreur. Les interruptions spécifiques au CPU, qui mettent un code d'erreur sur la pile sont 8, 10, 11, 12, 13, 14 et 17. Le code d'erreur peut être utilisé par le gestionnaire d'interruption pour obtenir plus d'informations sur ce qui est arrivé. En outre, notez que le numéro de l'interruption n'est pas déposé sur la pile. Nous pouvons déterminer quelle interruption a eu lieu uniquement en sachant quel code est exécuté - si le gestionnaire enregistré pour l'interruption 17 est exécuté, alors c'est l'interruption 17 qui a eu lieu.

Une fois que le gestionnaire d'interruption a fini, il utilise l'instruction iret pour retourner. L'instruction iret s'attend à ce que la pile soit la même que juste avant l'interruption (voir la figure ci-dessus). Par conséquent, toutes les valeurs déposées sur la pile par le gestionnaire d'interruption doivent être enlevées. Avant de retourner, iret restaure eflags en éliminant la valeur de la pile, puis finalement saute à cs:eip, comme spécifié par les valeurs sur la pile.

Le gestionnaire d'interruption doit être écrit en code assembleur, puisque tous les registres utilisés par les gestionnaires d'interruption doivent être préservés en les poussant sur la pile. Cela parce que le code qui a été interrompu ignore l'interruption et s'attendra donc à ce que ses registres ne changent pas. L'écriture de toute la logique du gestionnaire d'interruption en code assembleur serait fastidieuse. La création en assembleur d'un gestionnaire qui sauvegarde les registres, appelle une fonction C, restaure les registres et finalement exécute iret est une bonne idée !

Le gestionnaire C devrait obtenir comme arguments l'état des registres, l'état de la pile et le numéro de l'interruption. Les définitions suivantes peuvent par exemple être utilisées :

 
Sélectionnez
struct cpu_state {
    unsigned int eax;
    unsigned int ebx;
    unsigned int ecx;
    .
    .
    .
    unsigned int esp;
} __attribute__((packed));

struct stack_state {
    unsigned int error_code;
    unsigned int eip;
    unsigned int cs;
    unsigned int eflags;
} __attribute__((packed));

void interrupt_handler(struct cpu_state cpu, struct stack_state stack, unsigned int interrupt);

6-4. Créer un gestionnaire d'interruption générique

Puisque le CPU ne dépose pas sur la pile le numéro de l'interruption, l'écriture d'un gestionnaire d'interruption générique est un peu difficile. Cette section utilisera des macros pour montrer comment faire. L'écriture d'une version pour chaque interruption est fastidieuse, il est préférable d'utiliser la fonctionnalité de macro de NASM(33). Et puisque toutes les interruptions ne produisent pas un code d'erreur, la valeur 0 sera ajoutée comme « code d'erreur » pour les interruptions sans aucun code d'erreur. Le code suivant montre un exemple de la façon dont cela peut être fait :

 
Sélectionnez
%macro no_error_code_interrupt_handler %1
global interrupt_handler_%1
interrupt_handler_%1:
    push    dword 0                   ; met 0 comme code d'erreur
    push    dword %1                  ; met le nombre de l'interruption
    jmp     common_interrupt_handler  ; saute au gestionnaire générique
%endmacro

%macro error_code_interrupt_handler %1
global interrupt_handler_%1
interrupt_handler_%1:
    push    dword %1                  ; met le nombre de l'interruption
    jmp     common_interrupt_handler  ; saute au gestionnaire générique
%endmacro

common_interrupt_handler:             ; les parties communes du gestionnaire d'interruption générique
    ; sauvegarder les registres
    push    eax
    push    ebx
    .
    .
    .
    push    ebp

    ; appeler la fonction  C
    call    interrupt_handler

    ; restaurer les registres
    pop     ebp
    .
    .
    .
    pop     ebx
    pop     eax

    ; restaurer le esp
    add     esp, 8

    ; retourner au code qui a été interrompu
    iret

no_error_code_interrupt_handler 0     ; créer gestionnaire pour interruption 0
no_error_code_interrupt_handler 1     ; créer gestionnaire pour interruption 1
.
.
.
error_code_handler              7     ; créer gestionnaire pour interruption 7
.
.
.

Le common_interrupt_handler effectue les opérations suivantes :

  • sauvegarde les registres sur la pile ;
  • appelle la fonction C interrupt_handler ;
  • récupère les registres de la pile ;
  • ajoute 8 à esp (à cause du code d'erreur et du numéro de l'interruption déposé plus tôt) ;
  • exécute iret pour revenir au code interrompu.

Comme les macros déclarent des étiquettes globales, les adresses des gestionnaires d'interruption peuvent être accessibles à partir du code C ou du code assembleur lors de la création de l'IDT.

6-5. Charger l'IDT

L'IDT est chargée avec l'instruction en assembleur lidt, qui prend l'adresse du premier élément dans le tableau. Le plus simple est d'incorporer cette instruction dans du code C :

 
Sélectionnez
global  load_idt

; load_idt - Charge la table de descripteurs d'interruptions (IDT).
; pile : [esp + 4] l'adresse du premier enregistrement dans l'IDT
;        [esp    ] l'adresse de retour
load_idt:
    mov     eax, [esp+4]    ; charge l'adresse de l'IDT dans le registre eax
    lidt    eax             ; charge l'IDT
    ret                     ; retourne à la fonction appelante

6-6. Contrôleur d'interruption programmable

Pour commencer à utiliser les interruptions matérielles, vous devez d'abord configurer le contrôleur d'interruption programmable (Programmable Interrupt Controller - PIC). Le PIC rend possible le mappage des signaux provenant du matériel vers des interruptions. Les raisons pour configurer le PIC sont :

  • modifier le mappage des interruptions. Le PIC utilise par défaut les interruptions 0-15 pour les interruptions matérielles, ce qui entre en conflit avec les interruptions du CPU. Par conséquent, les interruptions PIC doivent être réaffectées à un autre intervalle ;
  • sélectionner les interruptions à recevoir. Vous ne voulez probablement pas recevoir des interruptions de tous les périphériques, puisque de toute façon vous ne disposez pas de code qui gère ces interruptions ;
  • configurer le mode correct pour le PIC.

Au début, il n'y avait qu'un seul PIC (PIC 1) et huit interruptions. Comme on a ajouté plus de matériel, 8 interruptions ne suffisaient pas. La solution retenue a été d'enchaîner un autre PIC (PIC 2) au premier PIC (voir l'interruption 2 sur PIC 1).

Les interruptions matérielles sont présentées dans le tableau ci-dessous :

PIC 1

Matériel

PIC 2

Matériel

0

Timer

8

Horloge temps réel

1

Clavier

9

E/S générales

2

PIC 2

10

E/S générales

3

COM 2

11

E/S générales

4

COM 1

12

E/S générales

5

LPT 2

13

Coprocesseur

6

Disquette

14

Bus IDE

7

LPT 1

15

Bus IDE

Un excellent tutoriel pour configurer le PIC peut être trouvé sur le site web de SigOPS(34). Nous ne répéterons pas cette information ici.

La réception de chaque interruption envoyée par le PIC doit être confirmée, c'est-à-dire qu'il faut envoyer au PIC un message confirmant que l'interruption a été gérée. Si cela n'est pas fait, le PIC ne générera aucune autre interruption.

La confirmation de réception d'une interruption se fait par l'envoi de l'octet 0x20 au PIC qui a déclenché l'interruption. Mettre en œuvre une fonction de pic_acknowledge peut donc se faire comme suit :

 
Sélectionnez
#include "io.h"

#define PIC1_PORT_A 0x20
#define PIC2_PORT_A 0xA0

/* Le mappage des interruptions PIC a été modifié */
#define PIC1_START_INTERRUPT 0x20
#define PIC2_START_INTERRUPT 0x28
#define PIC2_END_INTERRUPT   PIC2_START_INTERRUPT + 7

#define PIC_ACK     0x20

/** pic_acknowledge:
 *  Confirme la réception d'une interruption à partir de PIC 1 ou PIC 2.
 *
 *  @param num Le numéro de l'interruption
 */
void pic_acknowledge(unsigned integer interruption)
{
    if (interruption < PIC1_START_INTERRUPT || interruption > PIC2_END_INTERRUPT) {
        return;
    }

    if (interruption < PIC2_START_INTERRUPT) {
        outb(PIC1_PORT_A, PIC_ACK);
    } else {
        outb(PIC2_PORT_A, PIC_ACK);
    }
}

6-7. Lire la saisie clavier

Le clavier ne génère pas de caractères ASCII, il génère des codes de balayage. Un code de balayage représente un bouton - les deux actions, appuyer et relâcher. Le code de balayage représentant le bouton juste enfoncé peut être lu à partir du port de données d'E/S du clavier, dont l'adresse 0x60. La façon de le faire est présentée dans l'exemple suivant :

 
Sélectionnez
#include "io.h"

#define KBD_DATA_PORT   0x60

/** read_scan_code :
 *  Lit un code de balayage à partir du clavier
 *
 *  @return Le code de balayage (PAS un caractère ASCII !)
 */
unsigned char read_scan_code(void)
{
    return inb(KBD_DATA_PORT);
}

L'étape suivante est d'écrire une fonction qui traduit un code de balayage en caractère ASCII correspondant. Si vous souhaitez mapper les codes de balayage vers des caractères ASCII comme on le fait sur un clavier américain, alors Andries Brouwer a écrit un excellent tutoriel(35) à ce sujet.

Comme l'interruption clavier est levée par le PIC, n'oubliez pas que vous devez appeler pic_acknowledge à la fin du gestionnaire d'interruption du clavier. En outre, le clavier ne vous enverra aucune autre interruption jusqu'à ce que vous lisiez le code de balayage du clavier.

6-8. Lectures complémentaires

  • Le wiki de OSDev contient une page excellente sur les interruptions, http://wiki.osdev.org/Interrupts
  • Le chapitre 6 du manuel Intel 3a (33) décrit tout ce qu'il y a à savoir sur les interruptions.

7. La route vers le mode utilisateur

Maintenant que le noyau démarre, affiche à l'écran et lit à partir du clavier - qu'est-ce qu'on fait ? Habituellement, un noyau n'est pas censé s'occuper lui-même de la logique de l'application, mais laisse cela aux applications. Le noyau crée les abstractions adéquates (pour la mémoire, les fichiers, les périphériques) afin de rendre plus facile le développement d'applications, effectue des tâches pour le compte des applications (appels système) et planifie des processusMultitâche.

Le mode utilisateur, contrairement au mode noyau, est l'environnement dans lequel s'exécutent les programmes de l'utilisateur. Cet environnement est moins privilégié que le noyau, et empêchera les programmes (mal écrits) de l'utilisateur de perturber d'autres programmes ou le noyau. Les noyaux mal écrits sont libres de mettre la pagaille où ils veulent.

Il y a tout un chemin à parcourir jusqu'à ce que l'OS créé dans ce livre arrive à exécuter des programmes en mode utilisateur, mais ce chapitre montrera comment exécuter facilement un petit programme en mode noyau.

7-1. Chargement d'un programme externe

D'où pouvons-nous obtenir le programme externe ? D'une façon ou d'une autre, nous avons besoin de charger dans la mémoire le code que nous voulons exécuter. Les systèmes d'exploitation plus évolués ont généralement des pilotes et des systèmes de fichiers qui leur permettent de charger le logiciel à partir d'un lecteur de CD-ROM, un disque dur ou d'autres médias persistants.

Au lieu de créer tous ces pilotes et systèmes de fichiers, nous allons utiliser la fonctionnalité modules de GRUB pour charger le programme.

7-1-1. Les modules GRUB

GRUB peut charger des fichiers arbitraires dans la mémoire de l'image ISO, et ces fichiers sont généralement appelés modules. Pour faire en sorte que GRUB charge un module, éditez le fichier iso/boot/grub/menu.lst et ajoutez la ligne suivante à la fin du fichier :

 
Sélectionnez
module /modules/program

Créez ensuite le dossier iso/modules :

 
Sélectionnez
mkdir -p iso/modules

L'application program sera créée plus loin dans ce chapitre.

Le code qui appelle kmain doit être mis à jour pour transmettre à kmain des informations à propos de l'endroit où il peut trouver les modules. Nous voulons aussi dire à GRUB qu'il doit aligner tous les modules sur les limites de page lors de leur chargement (voir le chapitre PaginationPagination pour plus de détails au sujet de l'alignement de la page).

Pour dire à GRUB comment charger nos modules, il faut mettre à jour l'en-tête multiamorces - les premiers octets du noyau - comme suit :

 
Sélectionnez
; dans le fichier `loader.s`


NOMBRE_MAGIQUE  equ 0x1BADB002      ; définir la constante nombre magique
ALIGN_MODULES   equ 0x00000001      ; dire à GRUB d'aligner les modules

; calculer la somme de contrôle CHECKSUM (toutes les options + somme de contrôle doit être égal à 0)
CHECKSUM        equ -(NOMBRE_MAGIQUE + ALIGN_MODULES)

section .text:                      ; début de la section de texte (code) 
align 4                             ; le code doit être aligné sur 4 octets
    dd NOMBRE_MAGIQUE               ; écrit le nombre magique
    dd ALIGN_MODULES                ; écrit l'instruction d'alignement des modules
    dd CHECKSUM                     ; écrit la somme de contrôle

GRUB stocke également un pointeur vers une struct dans le registre ebx qui, entre autres choses, décrit à quelles adresses sont chargés les modules. Par conséquent, vous voulez probablement pousser ebx sur la pile avant d'appeler kmain, pour en faire un argument pour kmain.

7-2. Exécuter un programme

7-2-1. Un programme très simple

Un programme écrit à ce stade ne peut effectuer que quelques actions. Par conséquent, un programme très court qui écrit une valeur dans un registre suffit comme programme de test. Interrompre Bochs après un certain temps et vérifier en regardant dans le journal de Bochs que le registre contient le nombre correct, permettra de vérifier que le programme a été exécuté. Ci-dessous, un exemple d'un tel programme court :

 
Sélectionnez
; affecter à eax un nombre facile à distinguer, à lire ensuite dans le journal
mov eax, 0xDEADBEEF

; entrer dans la boucle infinie, rien de plus à faire
; $ signifie "début de ligne", c.-à-d la même instruction
jmp $

7-2-2. Compiler

Puisque notre noyau ne peut pas analyser les formats exécutables avancés, nous avons besoin de compiler le code dans un fichier binaire plat. NASM peut le faire avec l'option -f :

 
Sélectionnez
nasm -f bin program.s -o program

Ceci est tout ce qu'il faut. Vous devez maintenant déplacer le programme de fichier dans le dossier iso/modules.

7-2-3. Trouver le programme en mémoire

Avant de passer au programme, nous devons trouver l'emplacement de la mémoire où il réside. En supposant que le contenu de epx est passé comme argument à kmain, nous pouvons faire cela entièrement à partir du code C.

Le pointeur dans ebx pointe vers une structure multiamorces (19). Téléchargez le fichier multiboot.h, qui décrit la structure, à http://www.gnu.org/software/grub/manual/multiboot/html_node/multiboot.h.html.

Le pointeur passé à kmain dans le registre ebx peut être converti en un pointeur multiboot_info_t. L'adresse du premier module se trouve sur mods_addr. Le code suivant montre un exemple :

 
Sélectionnez
int kmain(/* arguments additionnels */ unsigned int ebx)
{
    multiboot_info_t *mbinfo = (multiboot_info_t *) ebx;
    unsigned int adresse_du_module = mbinfo->mods_addr;
}

Toutefois, avant de simplement suivre aveuglément le pointeur, vous devez vérifier que le module a été chargé correctement par GRUB. Cela peut être fait en vérifiant le champ flags de la structure de multiboot_info_t. Vous devriez également vérifier le champ mods_count, pour vous assurer que sa valeur est exactement 1. Pour plus de détails sur la structure multiamorces, consultez sa documentation (19).

7-2-4. Sauter au code

La seule chose qui reste à faire est de sauter au code chargé par GRUB. Puisqu'il est plus facile d'analyser la structure multiamorces en code C qu'en assembleur, il est plus pratique d'appeler le code à partir du code C (cela peut bien sûr être fait aussi avec jmp ou call en code assembleur). Le code C pourrait ressembler à ceci :

 
Sélectionnez
typedef void (*call_module_t)(void);
/* ... */
call_module_t start_program = (call_module_t) adresse_du_module;
start_program();
/* nous n'arrivons jamais ici, sauf si le code du module retourne */

Si nous lançons le noyau, attendons jusqu'à ce qu'il s'exécute et entre dans la boucle infinie du programme, puis arrêtons Bochs, nous devrions voir via le journal de Bochs la valeur 0xDEADBEEF dans le registre eax. Nous avons lancé avec succès un programme dans notre OS !

7-3. Le début du mode utilisateur

Le programme que nous avons écrit s'exécute maintenant au même niveau de privilège que le noyau - nous y sommes juste entrés d'une manière un peu particulière. Pour permettre aux applications de s'exécuter à un niveau de privilège différent, nous devrons, en plus de la segmentationSegmentation, nous occuper de la paginationPagination et de l'allocation de trame de pageAllocation de trames de page.

Cela représente pas mal de travail et de détails techniques à traverser, mais d'ici quelques chapitres vous aurez des programmes fonctionnant en mode utilisateur.

8. Une courte introduction à la mémoire virtuelle

La mémoire virtuelle est une abstraction de la mémoire physique. Généralement, le but de la mémoire virtuelle est de simplifier le développement d'applications et de mettre à la disposition des processus plus de mémoire qu'il n'en existe réellement physiquement dans la machine. Aussi, pour raisons de sécurité, nous ne voulons pas que les applications touchent à la mémoire allouée au noyau ou aux autres applications.

Dans l'architecture x86, la mémoire virtuelle peut être réalisée de deux façons : par segmentation et par pagination. La pagination est de loin la technique la plus courante et la plus polyvalente, et nous allons la mettre en œuvre dans le chapitre suivant. Certaines utilisations de la segmentation sont encore nécessaires pour permettre au code de s'exécuter à différents niveaux de privilèges.

La gestion de la mémoire représente une grande partie de ce qu'un système d'exploitation fait. Les chapitres PaginationPagination et Allocation de trame de pageAllocation de trames de page traitent de cela.

La segmentation et la pagination sont décrites dans le manuel Intel (33), chapitres 3 et 4.

8-1. Mémoire virtuelle grâce à la segmentation ?

Vous pourriez vous passer complètement de la pagination et utiliser juste la segmentation pour la mémoire virtuelle. Chaque processus en mode utilisateur obtiendrait son propre segment, avec l'adresse de base et la limite correctement mises en place. De cette façon, aucun processus ne peut voir la mémoire d'un autre processus. Dans ce cas-là, un problème est que la mémoire physique pour un processus doit être contiguë (ou du moins, c'est très pratique si elle l'est). Soit nous devons savoir à l'avance combien de mémoire utilisera le programme (c'est peu probable), soit nous pouvons déplacer les segments de mémoire, lorsque la limite est atteinte, dans des endroits où ils peuvent croître (ce qui est coûteux, entraîne une fragmentation et peut déboucher sur une erreur « mémoire insuffisante » même s'il y a assez de mémoire est disponible). La pagination résout ces problèmes.

Il est intéressant de noter que dans x86_64 (la version 64 bits de l'architecture x86), la segmentation est presque complètement éliminée.

8-2. Lectures complémentaires

9. Pagination

La segmentation traduit une adresse logique en une adresse linéaire. La pagination traduit ces adresses linéaires sur l'espace d'adressage physique, et détermine les droits d'accès et comment la mémoire doit être mise en cache.

9-1. Pourquoi pagination ?

La pagination est la technique la plus couramment utilisée dans l'architecture x86 pour permettre la mémoire virtuelle. La mémoire virtuelle par le biais de la pagination signifie que chaque processus aura l'impression que la plage de mémoire disponible est 0x00000000 - 0xFFFFFFFF, même si la taille réelle de la mémoire est peut-être bien moindre. Cela signifie également que lorsqu'un processus accède à un octet de mémoire, il utilisera une adresse virtuelle (linéaire) au lieu d'une adresse physique. Le code dans le processus de l'utilisateur ne remarquera aucune différence (sauf pour les délais d'exécution). L'adresse linéaire est traduite en une adresse physique par la MMU et la table des pages. Si l'adresse virtuelle n'est pas mappée à une adresse physique, le processeur déclenchera une interruption de défaut de page.

La pagination est facultative et certains systèmes d'exploitation n'en font pas usage. Mais si nous voulons marquer certaines zones de mémoire comme accessibles seulement à l'exécution du code ayant un certain niveau de privilège (pour être en mesure d'avoir des processus s'exécutant à différents niveaux de privilège), la pagination est la façon la plus élégante de le faire.

9-2. Pagination en x86

La pagination en x86 (le chapitre 4 du manuel Intel) se compose d'une table répertoire de page (page directory, ou PDT) qui peut contenir des références vers 1024 tables des pages (PT), dont chacune peut pointer vers 1024 sections de mémoire physique appelées trames de page (PF). Chaque trame de page a une taille de 4096 octets. Dans une adresse virtuelle (linéaire), les 10 bits de poids fort spécifient le décalage d'une entrée de répertoire de page (page directory entry - PDE) dans le PDT courant, les 10 bits suivants - le décalage d'une entrée de table des pages (page table entry - PTE) dans la table des pages pointée par ce PDE. Les 12 bits de poids faible de l'adresse représentent le décalage dans le cadre de page à traiter.

Tous les répertoires de pages, tables de pages et trames de pages doivent être alignés sur des adresses de 4096 octets. Cela permet d'adresser une PDT, PT ou PF avec seulement les 20 bits les plus forts d'une adresse de 32 bits, puisque les 12 bits inférieurs doivent être nuls.

La structure des PDE et celle des PTE sont très semblables, chacune ayant 32 bits (4 octets), où les 20 bits les plus hauts pointent vers une PTE ou PF, et les 12 bits les plus faibles contrôlent les droits d'accès et autres configurations. 4 octets fois 1024 est égal à 4096 octets, donc un répertoire de page et une table de pages rentrent tous les deux dans une trame de page.

La traduction des adresses linéaires en adresses physiques est décrite dans la figure ci-dessous.

Bien que les pages aient normalement 4096 octets, il est également possible d'utiliser des pages de 4 Mo. Une PDE pointe ensuite directement vers une trame de page de 4 Mo, qui doit être alignée sur une limite d'adresse de 4 Mo. La traduction d'adresse est presque la même que sur la figure, avec juste l'étape de table de page supprimée. Il est possible de mélanger des pages de 4 Mo et de 4 ko.

Image non disponible
Traduction des adresses virtuelles (adresses linéaires) vers des adresses physiques

Les 20 bits pointant vers la PDT courante sont stockés dans le registre cr3. Les 12 bits inférieurs de cr3 sont utilisés pour la configuration.

Pour plus de détails sur les structures des pages, voir le chapitre 4 dans le manuel Intel (33). Les bits les plus intéressants sont U/S, qui déterminent quels niveaux de privilèges peuvent accéder à cette page (PL0 ou PL3), et R/W, ce qui rend la mémoire dans la page disponible en lecture-écriture ou en lecture seule.

9-2-1. Pagination d'identité

Le type le plus simple de pagination, appelé pagination d'identité, est quand nous mappons chaque adresse virtuelle vers la même adresse physique. Cela peut être fait au moment de la compilation en créant un répertoire de page où chaque entrée pointe vers sa trame de 4 Mo correspondante. En NASM, cela peut être fait avec des macros et des commandes (%rep, times et dd). Cela peut bien sûr être fait également au moment de l'exécution, en utilisant des instructions ordinaires de code assembleur.

9-2-2. Activer la pagination

La pagination est activée en écrivant d'abord l'adresse d'un répertoire de page dans cr3, puis en modifiant le bit 31 (le bit PG, « activer la pagination ») de cr0 à 1. Pour utiliser des pages de 4 Mo, définissez le bit PSE (Page Size Extensions, « extensions de taille de la page », bit 4) dans cr4. Le code assembleur suivant montre un exemple :

 
Sélectionnez
; eax contient l'adresse du répertoire de la page
mov cr3, eax

mov ebx, cr4        ; lire la valeur courante de cr4
or  ebx, 0x00000010 ; définir PSE
mov cr4, ebx        ; mettre à jour cr4

mov ebx, cr0        ; lire la valeur courante de cr0
or  ebx, 0x80000000 ; définir PG
mov cr0, ebx        ; mettre à jour cr0

; la pagination est maintenant activée

9-2-3. Quelques détails

Il est important de noter que toutes les adresses dans le répertoire de page, dans les tables de page et dans cr3 doivent être des adresses physiques des structures, jamais virtuelles. Cela sera plus pertinent dans les sections suivantes, où nous mettrons à jour dynamiquement les structures de pagination (voir le chapitre La route vers le mode utilisateurLa route vers le mode utilisateur).

Une instruction utile lors d'une mise à jour d'une PDT ou d'une PT est invlpg. Elle annule l'entrée d'une adresse virtuelle dans le Translate Lookaside Buffer (TLB). Le TLB est un cache pour les adresses traduites, qui effectue la correspondance entre des adresses physiques et virtuelles. Cela est nécessaire uniquement lors de la modification d'une PDE ou PTE qui a déjà été mappée vers autre chose. Si la PDE ou la PTE avait déjà été marquée comme non présente (le bit 0 a été mis à 0), l'exécution de invlpg est inutile. En modifiant la valeur de cr3, toutes les entrées du TLB seront invalidées.

Voici un exemple d'invalidation d'une entrée du TLB :

 
Sélectionnez
; rend invalide toute référence dans le TLB vers l'adresse virtuelle 0
invlpg [0]

9-3. Pagination et noyau

Cette section décrira comment la pagination affecte le noyau de l'OS. Nous vous encourageons à gérer votre OS en utilisant la pagination d'identité avant d'essayer de mettre en œuvre une configuration de pagination plus avancée, car il peut être difficile de déboguer une table de page dysfonctionnelle mise en place en code assembleur.

9-3-1. Raisons de ne pas utiliser le mappage d'identité pour le noyau

Si le noyau est placé au début de l'espace d'adressage virtuel - c'est-à-dire que l'espace d'adressage virtuel (0x00000000, "taille du noyau") mappe vers l'emplacement du noyau en mémoire - il y aura des problèmes lors de l'édition des liens du code du processus en mode utilisateur. Normalement, pendant l'édition des liens, l'éditeur de liens suppose que le code sera chargé dans la mémoire à la position 0x00000000. Par conséquent, lors de la résolution des références absolues, 0x00000000 sera l'adresse de base pour calculer la position exacte. Mais si le noyau est mappé sur l'espace d'adressage virtuel (0x00000000, "taille du noyau"), le processus en mode utilisateur ne peut pas être chargé à l'adresse virtuelle 0x00000000 - il doit être placé ailleurs. Par conséquent, l'hypothèse de l'éditeur de liens que le processus en mode utilisateur est chargé en mémoire à la position 0x00000000 est erronée. Ce problème peut être corrigé en utilisant un script, qui dit à l'éditeur de liens de prendre une adresse de départ différente, mais c'est une solution très lourde pour les utilisateurs du système d'exploitation.

Cela suppose également que nous voulons que le noyau fasse partie de l'espace d'adressage du processus en mode utilisateur. Comme nous le verrons plus tard, c'est une fonctionnalité intéressante, puisque pendant les appels système, nous ne devons modifier aucune structure de pagination pour avoir accès au code et aux données du noyau. L'accès aux pages du noyau exigera bien sûr le niveau de privilège 0, pour éviter qu'un processus utilisateur lise ou écrive la mémoire du noyau.

9-3-2. L'adresse virtuelle pour le noyau

Le noyau doit être placé de préférence à une adresse très élevée de mémoire virtuelle, par exemple 0xC0000000 (3 Go). Il est peu probable que le processus en mode utilisateur ait une taille de 3 Go, et c'est la seule façon qu'il aurait d'entrer en conflit avec le noyau. Lorsque le noyau utilise des adresses virtuelles à 3 Go et supérieures, on parle d'un noyau mis en moitié supérieure. 0xC0000000 est juste un exemple, le noyau peut être placé à une autre adresse supérieure à 0 pour obtenir les mêmes avantages. Le choix de la bonne adresse dépend de la quantité de mémoire virtuelle qui doit être disponible pour le noyau (c'est plus facile quand toute la mémoire en dessus de l'adresse virtuelle du noyau appartient au noyau) et de la quantité de mémoire virtuelle qui doit être disponible pour le processus.

Si le processus en mode utilisateur est supérieur à 3 Go, certaines pages devront être swappées par le noyau. Ce livre ne couvre pas le swapping de pages.

9-3-3. Placer le noyau à 0xC0000000

Pour commencer, il est préférable de placer le noyau plutôt à 0xC0100000 qu'à 0xC0000000, puisque cela permet de mapper (0x00000000, 0x00100000) à (0xC0000000, 0xC0100000). De cette façon, l'ensemble de la plage de mémoire (0x00000000, "taille du noyau") est mappé vers la gamme (0xC0000000, 0xC0000000 + "taille du noyau").

Placer le noyau à 0xC0100000 n'est pas difficile, mais exige une certaine réflexion, car nous avons une fois de plus un problème d'édition de liens. Lorsque l'éditeur de liens résout toutes les références absolues dans le noyau, il va supposer que notre noyau est chargé à l'emplacement 0x00100000 de la mémoire physique et pas à0x00000000, puisque le déplacement est utilisé dans le script de l'éditeur de liens (voir la section Éditer les liens du noyauÉditer les liens du noyau). Cependant, nous voulons que les sauts soient résolus en utilisant comme adresse de base 0xC0100000, car sinon un saut de noyau sautera directement dans le code du processus en mode utilisateur (rappelez-vous que le processus en mode utilisateur est chargé à l'emplacement 0x00000000 de la mémoire virtuelle).

Cependant, nous ne pouvons pas simplement dire à l'éditeur de liens de supposer que le noyau démarre (est chargé) à 0xC01000000, car nous voulons qu'il soit chargé à l'adresse physique 0x00100000. La raison d'avoir le noyau chargé à 1 Mo est parce qu'il ne peut pas être chargé à 0x00000000, puisque le code de BIOS et de GRUB est chargé en dessous de 1 Mo. En outre, nous ne pouvons pas supposer que nous pouvons charger le noyau à 0xC0100000, puisque la machine pourrait ne pas avoir 3 Go de mémoire physique.

Cela peut être résolu en utilisant à la fois le déplacement (.=0xC0100000) et l'instruction AT dans le script de l'éditeur de liens. Le déplacement précise que les références de mémoire non relatives doivent utiliser l'adresse de relocalisation en tant que base pour les calculs d'adresses. AT spécifie l'endroit où le noyau doit être chargé en mémoire. Le déplacement est effectué par la fonction GNU ld(36) au moment de la liaison, l'adresse de chargement spécifiée par AT est gérée par GRUB lors du chargement du noyau, et fait partie du format ELF (18).

9-3-4. Script d'éditeur de liens de la moitié supérieure

Nous pouvons modifier le premier script d'éditeur de liensÉditer les liens du noyau pour implémenter ceci :

 
Sélectionnez
ENTRY(loader)        /* le nom du symbole en entrée */

. = 0xC0100000       /* le code devrait être be relocalisé à 3Go + 1Mo */

/* alignement à 4 ko et chargement à 1 Mo */
.text ALIGN (0x1000) : AT(ADDR(.text)-0xC0000000)
{
    *(.text)         /* toutes les sections texte, de tous les fichiers */
}

/* alignement à 4 ko et chargement à 1 Mo + . */
.rodata ALIGN (0x1000) : AT(ADDR(.text)-0xC0000000)
{
    *(.rodata*)      /* toutes les sections de données en lecture seule, de tous les fichiers */
}

/* alignement à 4 ko et chargement à 1 Mo + . */
.data ALIGN (0x1000) : AT(ADDR(.text)-0xC0000000)
{
    *(.data)         /* toutes les sections de données, de tous les fichiers */
}

/* alignement à 4 ko et chargement à 1 MB + . */
.bss ALIGN (0x1000) : AT(ADDR(.text)-0xC0000000)
{
    *(COMMON)        /* toutes les sections COMMON, de tous les fichiers */
    *(.bss)          /* toutes les sections bss, de tous les fichiers */
}

9-3-5. Accéder à la moitié supérieure

Lorsque GRUB saute au code du noyau, il n'y a aucune table de pagination. Par conséquent, toutes les références à 0xC0100000 + x ne seront pas mappées vers l'adresse physique correcte, ce qui provoquera au mieux une exception de protection générale (general protection exception - GPE), sinon (si l'ordinateur possède plus de 3 Go de mémoire) l'ordinateur plantera tout simplement.

Par conséquent, il faut utiliser du code assembleur qui n'utilise aucun saut relatif ou adressage relatif de la mémoire pour :

  • mettre en place une table de pages ;
  • ajouter le mappage d'identité pour les 4 premiers Mo de l'espace d'adressage virtuel ;
  • ajouter une entrée pour 0xC0100000 qui correspond à 0x0010000.

Si nous omettons le mappage d'identité pour les 4 premiers Mo, le processeur générera une faute de page immédiatement après l'activation de la pagination, en essayant de chercher dans la mémoire la prochaine instruction. Après la création de la table, un saut à une étiquette peut être fait pour faire pointer eip vers une adresse virtuelle dans la moitié supérieure :

 
Sélectionnez
; code assembleur s'exécutant alentour de 0x00100000
; active la pagination tant pour l'emplacement réel du noyau
; que pour son adresse virtuelle dans la moitié supérieure

lea ebx, [higher_half] ; charge l'adresse de l'étiquette dans ebx
jmp ebx                ; saute à l'étiquette

higher_half:
    ; le code ici s'exécute dans le noyau de la moitié supérieure
    ; eip est plus large que 0xC0000000
    ; peut continuer l'initialisation du noyau, appels au code C, etc.

Le registre eip pointera maintenant vers un emplacement en mémoire quelque part juste après 0xC0100000 - tout le code peut maintenant s'exécuter comme s'il était situé à 0xC0100000, la moitié supérieure. L'entrée qui mappe les 4 premiers Mo de mémoire virtuelle aux 4 premiers Mo de mémoire physique peut maintenant être retirée de la table de pages et son entrée correspondante dans le TLB peut être invalidée avec invlpg [0].

9-3-6. Exécuter dans la moitié supérieure

Il y a encore quelques détails à régler lors de l'utilisation d'un noyau en moitié supérieure. Nous devons être prudents en utilisant les E/S mappées en mémoire qui utilisent des emplacements spécifiques de mémoire. Par exemple, le tampon de trame est situé à 0x000B8000, mais comme l'entrée 0x000B8000 il n'existe plus dans la table de pages, l'adresse 0xC00B8000 doit être utilisée, puisque l'adresse virtuelle 0xC0000000 mappe vers l'adresse physique 0x00000000.

Toute référence explicite à des adresses au sein de la structure multiamorce doit être modifiée pour refléter ainsi les nouvelles adresses virtuelles.

Mapper des pages de 4 Mo pour le noyau est simple, mais gaspille de la mémoire (sauf si vous avez vraiment un gros noyau). La création d'un noyau de la moitié supérieure mappé en tant que pages de 4 ko économise de la mémoire, mais est plus difficile à mettre en place. La mémoire pour le répertoire de page et une table de page peut être réservée dans la section .data, mais on a besoin de configurer le mappage des adresses virtuelles vers des adresses physiques au moment de l'exécution. La taille du noyau peut être déterminée par l'exportation d'étiquettes à partir du script d'édition de liens (37), que nous devons faire de toute façon plus tard, lors de l'écriture de l'allocateur de trame de page (voir le chapitre Allocation de trames de pageAllocation de trames de page).

9-4. Mémoire virtuelle via pagination

La pagination permet deux choses qui sont bonnes pour la mémoire virtuelle. Tout d'abord, elle permet un contrôle à granulation fine de l'accès à la mémoire. Vous pouvez marquer les pages en lecture seule, lecture et écriture, seulement pour PL0, etc. Deuxièmement, il crée l'illusion d'une mémoire contiguë. Les processus en mode utilisateur et le noyau peuvent accéder à la mémoire comme si elle était contiguë et la mémoire contiguë peut être étendue sans avoir besoin de déplacer des données en mémoire. Nous pouvons également permettre aux programmes en mode utilisateur d'accéder à toute la mémoire en dessous de 3 Go, mais à moins qu'ils l'utilisent réellement, nous ne devons pas assigner des trames de page aux pages. Cela permet aux processus d'avoir le code situé près de 0x00000000 et la pile à juste en dessous de 0xC0000000 et de n'avoir toujours pas besoin de plus de deux pages réelles.

9-5. Lectures complémentaires

10. Allocation de trames de page

Lors de l'utilisation de la mémoire virtuelle, comment l'OS sait-il quelles parties de la mémoire peuvent être utilisées ? Tel est le rôle de l'allocateur de trame de page.

10-1. Gérer la mémoire disponible

10-1-1. Combien de mémoire se trouve là ?

Nous devons d'abord savoir combien de mémoire est disponible sur l'ordinateur sur lequel l'OS est exécuté. La meilleure façon d'y arriver est de le lire à partir de la structure multiamorce (19) que GRUB nous passe. GRUB recueille les informations sur la mémoire dont nous avons besoin - ce qui est réservé, mappé en E/S, en lecture seule, etc. Nous devons également nous assurer que nous ne marquons pas comme disponible la partie de la mémoire utilisée par le noyau (puisque GRUB ne marque pas cette mémoire comme réservée). Une façon de savoir combien de mémoire utilise le noyau est d'exporter des étiquettes du début et de la fin du binaire du noyau à partir du script d'éditeur de liens :

 
Sélectionnez
ENTRY(loader)        /* le nom du symbole en entrée */

. = 0xC0100000       /* le code doit être relocalisé à 3 Go + 1 Mo */

/* ces étiquettes seront exportées dans les fichiers de code */
kernel_virtual_start = .;
kernel_physical_start = . - 0xC0000000;

/* alignement à 4 ko et chargement à 1 Mo */
.text ALIGN (0x1000) : AT(ADDR(.text)-0xC0000000)
{
    *(.text)         /* toutes les sections texte, de tous les fichiers */
}

/* alignement à 4 ko et chargement à 1 Mo + . */
.rodata ALIGN (0x1000) : AT(ADDR(.rodata)-0xC0000000)
{
    *(.rodata*)      /* toutes les sections de données en lecture seule, de tous les fichiers */
}

/* alignement à 4 ko et chargement à 1 Mo + . */
.data ALIGN (0x1000) : AT(ADDR(.data)-0xC0000000)
{
    *(.data)         /* toutes les sections de données, de tous les fichiers */
}

/* alignement à 4 ko et chargement à 1 MB + . */
.bss ALIGN (0x1000) : AT(ADDR(.bss)-0xC0000000)
{
    *(COMMON)        /* toutes les sections COMMON, de tous les fichiers */
    *(.bss)          /* toutes les sections bss, de tous les fichiers */
}

kernel_virtual_end = .;
kernel_physical_end = . - 0xC0000000;

Ces étiquettes peuvent être lues directement à partir du code assembleur et déposées sur la pile pour les mettre à la disposition du code C :

 
Sélectionnez
extern kernel_virtual_start
extern kernel_virtual_end
extern kernel_physical_start
extern kernel_physical_end

; ...

push kernel_physical_end
push kernel_physical_start
push kernel_virtual_end
push kernel_virtual_start

call kmain

De cette façon, nous obtenons les étiquettes comme arguments à kmain. Si vous souhaitez utiliser du C au lieu de l'assembleur, une façon de faire est de déclarer les étiquettes comme des fonctions et de prendre les adresses de ces fonctions :

 
Sélectionnez
void kernel_virtual_start(void);

/* ... */

unsigned int vaddr = (unsigned int) &kernel_virtual_start;

Si vous utilisez des modules GRUB, vous devez vous assurer que la mémoire qu'ils utilisent est aussi marquée comme réservée.

Notez que la mémoire disponible n'a pas besoin d'être contiguë. Dans le premier 1 Mo il y a plusieurs sections d'E/S mappées en mémoire, aussi que de la mémoire utilisée par GRUB et le BIOS. D'autres parties de la mémoire pourraient être tout aussi indisponibles.

Il est commode de diviser les sections de mémoire en trames de pages complètes, car nous ne pouvons pas mapper en mémoire des parties de pages.

10-1-2. Gérer la mémoire disponible

Comment savons-nous quelles trames de page sont en cours d'utilisation ? L'allocateur de trame de page doit garder une trace des trames libres et de celles qui ne le sont pas. Il y a plusieurs façons de faire cela : les bitmaps, les listes chaînées, les arbres, le Buddy System (utilisé par Linux), etc. Pour plus d'informations sur les différents algorithmes, voir l'article sur OSDev(37).

Les bitmaps sont assez faciles à mettre en œuvre. Un bit est utilisé pour chaque trame de page et une (ou plusieurs) trame de pages est consacrée à stocker le bitmap. (Notez que ceci est juste une façon de le faire, d'autres dispositifs pourraient être meilleurs et/ou plus amusants à mettre en œuvre.)

10-2. Comment pouvons-nous accéder à une trame de page ?

Nous devons mapper la trame de page dans la mémoire virtuelle en mettant à jour la PDT et/ou la PT utilisée par le noyau. Que se passe-t-il si toutes les tables de pages disponibles sont pleines ? Dans ce cas-là, nous ne pouvons pas mapper la trame de page en mémoire, parce que nous aurions besoin d'une nouvelle table de page - qui occupe toute une trame de page - et pour écrire dans cette trame de page, nous devrions mapper sa trame de page... Il faut rompre d'une façon ou d'une autre ce cercle vicieux.

Une solution consiste à réserver une partie de la première table de page utilisée par le noyau (ou une autre table de page de la moitié supérieure) pour mapper temporairement les trames de page afin de les rendre accessibles. Si le noyau est mappé à 0xC0000000 (enregistrement du répertoire de page avec l'indice 768), et 4 ko de trames de page sont utilisés, alors le noyau a au moins une table de page. Si nous supposons - ou nous limitons à - un noyau de taille d'au plus 4 Mo moins 4 ko, nous pouvons consacrer le dernier enregistrement (enregistrement 1023) de cette table de page pour mappages temporaires. L'adresse virtuelle des pages mappées en utilisant le dernier enregistrement de la PT du noyau sera :

 
Sélectionnez
(768 << 22) | (1023 << 12) | 0x000 = 0xC03FF000

Après avoir mappé temporairement la trame de page que nous voulons utiliser comme table de page et l'avoir configurée pour mapper vers notre première trame de page, nous pouvons l'ajouter au répertoire de pagination et supprimer le mappage temporaire.

10-3. Un tas du noyau

Jusqu'à présent, nous avons été en mesure de travailler seulement avec des données de taille fixe, ou directement avec de la mémoire brute. Maintenant que nous avons un allocateur de trame de page, nous pouvons mettre en œuvre des fonctions malloc et free pour les utiliser dans le noyau.

Kernighan et Ritchie donnent dans leur livre (8)un exemple d'implémentation dont nous pouvons nous inspirer. La seule modification que nous devons faire est de remplacer les appels à sbrk/brk par des appels à l'allocateur de trame de page lorsque plus de mémoire est nécessaire. Nous devons également veiller à mapper vers des adresses virtuelles les trames de page renvoyées par l'allocateur. Une mise en œuvre correcte doit également retourner des trames de page à l'allocateur lors d'appels à la fonction free, chaque fois que des blocs de mémoire suffisamment de grands sont libérés.

10-4. Lecture complémentaire

11. Mode utilisateur

Le mode utilisateur est maintenant presque à notre portée, il reste juste encore quelques mesures nécessaires pour y arriver. Bien que ces mesures puissent sembler faciles comme elles sont présentées dans ce chapitre, leur mise en œuvre peut s'avérer délicate, car il y a beaucoup de cas où de petites erreurs provoqueront des bogues difficiles à trouver.

11-1. Segments pour le mode utilisateur

Pour activer le mode utilisateur, nous devons ajouter deux segments supplémentaires à la GDT. Ceux-ci sont très semblables aux segments du noyau que nous avons ajoutés lorsque nous avons configuré la GDTLa table de descripteurs globaux (GDT), dans le chapitre sur la segmentationSegmentation :

Index

Offset

Nom

Plage d'adressage

Type

DPL

3

0x18

segment de code utilisateur

0x00000000 - 0xFFFFFFFF

RX

PL3

4

0x20

segment de données utilisateur

0x00000000 - 0xFFFFFFFF

RW

PL3

Les descripteurs de segment nécessaires pour le mode utilisateur

La différence est le DPL, qui permet maintenant l'exécution du code en niveau de privilège PL3. Les segments peuvent toujours être utilisés pour accéder à tout l'espace d'adresses, ce qui veut dire que l'utilisation de ces segments pour le code en mode utilisateur ne protégera pas le noyau. Pour cela, nous avons besoin de pagination.

11-2. Configurer le mode utilisateur

Il y a quelques petites choses dont chaque processus en mode utilisateur a besoin :

  • des trames de page pour le code, les données et la pile. Pour le moment, il suffit d'allouer une trame de page pour la pile et suffisamment de trames de page pour contenir le code du programme. Pour l'instant, ne vous préoccupez pas de la mise en place d'une pile qui peut grandir et rétrécir, concentrez-vous d'abord sur l'obtention d'une mise en œuvre simple ;
  • le binaire du module GRUB doit être copié dans les trames de page utilisées pour le code des programmes ;
  • un répertoire de pages et des tables de pages sont nécessaires pour mapper en mémoire les trames de page décrites ci-dessus. Au moins deux tables de pages sont nécessaires, parce que le code et les données doivent être mappés au 0x00000000 et augmenter, et la pile doit commencer juste en dessous du noyau, à 0xBFFFFFFB et croître vers des adresses inférieures. Le drapeau U/S doit être configuré pour permettre l'accès PL3.

Il pourrait être pratique de stocker ces informations dans une struct représentant un processus. Cette struct de processus peut être allouée dynamiquement avec la fonction malloc du noyau.

11-3. Entrer en mode utilisateur

La seule façon d'exécuter du code avec un niveau de privilège inférieur au niveau de privilège courant (current privilege level - CPL) est d'exécuter une instruction iret ou lret - retour ou long retour après interruption, respectivement.

Pour entrer en mode utilisateur, nous mettons en place la pile comme si le processeur avait lancé une interruption de niveau inter-privilège. La pile doit ressembler à ce qui suit :

 
Sélectionnez
[esp + 16]  ss      ; le sélecteur de segment de pile que nous voulons pour le mode utilisateur
[esp + 12]  esp     ; le pointeur de pile en mode utilisateur
[esp +  8]  eflags  ; les options de contrôle que nous voulons utiliser en mode utilisateur
[esp +  4]  cs      ; le sélecteur de segment de code
[esp +  0]  eip     ; le pointeur d'instruction du code du mode utilisateur à exécuter

Voir le manuel Intel (33), section 6.2.1, figure 6-4 pour plus d'informations.

L'instruction iret lira alors ces valeurs de la pile et remplira les registres correspondants. Avant d'exécuter iret, nous devons changer de répertoire vers celui configuré pour le processus en mode utilisateur. Il est important de rappeler que pour poursuivre l'exécution de code du noyau après avoir changé de PDT, le noyau doit être mappé. Une façon d'accomplir cela est d'avoir une PDT séparée pour le noyau, qui mappe toutes les données à 0xC0000000 et au-dessus, et la fusionner avec la PDT de l'utilisateur (qui ne mappe qu'en dessous de 0xC0000000) lors de l'exécution du changement de répertoire. Rappelez-vous que l'adresse physique de la PDT doit être utilisée lors de la configuration du registre cr3.

Le registre eflags contient un ensemble d'indicateurs différents, spécifiés dans la section 2.3 du manuel Intel (33). Le plus important pour nous est celui d'activation d'interruption (interrupt enable flag - IF). L'instruction assembleur sti ne peut pas être utilisée pour activer les interruptions au niveau de privilège 3. Si les interruptions sont désactivées en entrant en mode utilisateur, elles ne peuvent plus être activées une fois en mode utilisateur. Le placement de l'indicateur IF sur la pile, dans l'entrée eflags, activera les interruptions en mode utilisateur, puisque l'instruction en assembleur iret configurera le registre eflags à la valeur correspondante sur la pile.

Pour l'instant, nous devrions avoir désactivé les interruptions, car il faut un peu plus de travail pour faire fonctionner correctement les interruptions de niveau interprivilège (voir la section Appels systèmeAppels système).

La valeur eip sur la pile doit pointer vers le point d'entrée du code utilisateur - 0x00000000 dans notre cas. La valeur esp sur la pile doit être là où la pile commence - 0xBFFFFFFB (0xC0000000 - 4).

Les valeurs cs et ss sur la pile doivent être les sélecteurs de segment pour les segments de code et respectivement de données de l'utilisateur. Comme nous l'avons vu dans le chapitre sur la segmentationSegmentation, les deux bits inférieurs d'un sélecteur de segment définissent le RPL - le niveau de privilège requis. Lors de l'utilisation de iret pour entrer en PL3, le RPL de cs et ss doit être 0x3. Le code suivant montre un exemple :

 
Sélectionnez
USER_MODE_CODE_SEGMENT_SELECTOR equ 0x18
USER_MODE_DATA_SEGMENT_SELECTOR equ 0x20
mov cs, USER_MODE_CODE_SEGMENT_SELECTOR | 0x3
mov ss, USER_MODE_DATA_SEGMENT_SELECTOR | 0x3

Le registre ds et les autres registres de segments de données doivent être configurés sur le même sélecteur de segment que ss. Ils peuvent être définis de la façon habituelle, avec l'instruction assembleur mov.

Nous sommes maintenant prêts à exécuter iret. Si tout a été mis en place correctement, nous devrions maintenant avoir un noyau qui peut entrer en mode utilisateur.

11-4. Utiliser le C pour les programmes en mode utilisateur

Lorsque le C est utilisé comme langage de programmation pour les programmes en mode utilisateur, il est important de réfléchir à la structure du fichier qui résultera de la compilation.

La raison pour laquelle nous pouvons utiliser ELF (18) comme format de fichier pour l'exécutable du noyau est parce que GRUB sait comment analyser et interpréter le format de fichier ELF. Si nous avions mis en œuvre un analyseur de ELF, nous pourrions aussi compiler les programmes en mode utilisateur en binaires ELF. Nous laissons cela comme un exercice pour le lecteur.

Pour faciliter le développement des programmes en mode utilisateur, nous pouvons permettre aux programmes d'être écrits en C, mais les compiler en binaires plats au lieu de binaires ELF. En C, la mise en page du code généré est plus imprévisible et le point d'entrée, main, pourrait ne pas être à l'offset 0 dans le binaire. Une façon courante de contourner cela est d'ajouter quelques lignes de code assembleur placées à l'offset 0, qui appellent main :

 
Sélectionnez
extern main

section .text
    ; push argv
    ; push argc
    call main
    ; main a retourné, eax est la valeur de retour
    jmp  $    ; boucle infinie

Si ce code est enregistré dans un fichier appelé start.s, alors le code suivant montre un exemple de script d'édition de liens qui place ces instructions d'abord en exécutable (rappelez-vous que start.s est compilé en start.o) :

 
Sélectionnez
OUTPUT_FORMAT("binary") /* output binaire plat */

SECTIONS
{
    . = 0;              /* relocaliser à l'adresse 0 */

    .text ALIGN(4):
    {
        start.o(.text)  /* inclure la section .text de start.o */
        *(.text)        /* inclure les autres sections .text */
    }

    .data ALIGN(4):
    {
        *(.data)
    }

    .rodata ALIGN(4):
    {
        *(.rodata*)
    }
}

Remarque : *(.text) n'inclura pas encore une fois la section .text de start.o.

Avec ce script, nous pouvons écrire des programmes en C ou assembleur (ou tout autre langage qui compile en fichiers objet pouvant être liés avec ld), et le noyau les chargera et mappera facilement (cependant , .rodata sera mappé comme étant en écriture).

Lorsque nous compilons des programmes utilisateur, nous voulons les options GCC suivantes :

 
Sélectionnez
    -m32 -nostdlib -nostdinc -fno-builtin -fno-stack-protector
    -nostartfiles  -nodefaultlibs

Pour l'édition des liens, les options suivantes doivent être utilisées :

 
Sélectionnez
    -T link.ld -melf_i386  # émuler ELF 32 bits, la sortie binaire est spécifiée
                           # dans le script d'édition de liens

L'option -T indique à l'éditeur de liens d'utiliser le script d'édition de liens link.ld.

11-4-1. Une bibliothèque C

À ce stade, il pourrait être intéressant de commencer à penser à écrire une petite « bibliothèque standard » pour vos programmes. Certaines fonctionnalités nécessitent des appels systèmeAppels système pour fonctionner, mais certaines, comme les fonctions déclarées dans string.h, n'en ont pas besoin.

11-5. Lecture complémentaire

12. Systèmes de fichiers

Il n'est pas indispensable d'avoir des systèmes de fichiers dans notre système d'exploitation, mais c'est une abstraction très utile, et cela joue souvent un rôle central de nombreux systèmes d'exploitation, en particulier les systèmes d'exploitation de type UNIX. Avant de commencer à mettre en place les éléments nécessaires aux processus multiples et aux appels système, nous pourrions envisager de mettre en œuvre un système de fichiers simple.

12-1. Pourquoi un système de fichiers ?

Comment pouvons-nous préciser quels programmes exécuter dans notre OS ? Quel est le premier programme à exécuter ? Comment les programmes font-ils pour produire des données ou en lire en entrée ?

Dans les systèmes de type UNIX, qui ont pour convention que presque tout est un fichier, ces problèmes sont résolus par le système de fichiers. (Il pourrait également être intéressant à lire un peu sur le projet Plan 9 de Bell Labs, qui pousse l'idée du « tout fichier » un cran plus loin.)

12-2. Un système de fichiers simple, en lecture seule

Le système de fichiers le plus simple pourrait être ce que nous avons déjà - un seul fichier, existant uniquement dans la RAM, chargé par GRUB avant le démarrage du noyau. Lorsque le noyau et le système d'exploitation croissent, cela devient probablement trop limitatif.

Un fichier avec des métadonnées est un système de fichiers légèrement plus avancé que celui représenté seulement par les bits d'un seul fichier. Les métadonnées peuvent décrire le type du fichier, sa taille et ainsi de suite. Un programme utilitaire qui s'exécute à la compilation peut être créé en ajoutant ces métadonnées dans un fichier. De cette façon, un « système de fichiers dans un fichier » peut être construit par la concaténation de plusieurs fichiers avec des métadonnées dans un grand fichier. Le résultat de cette technique est un système de fichiers en lecture seule qui réside dans la mémoire (une fois que GRUB a chargé le fichier).

Le programme qui construit le système de fichiers peut traverser un répertoire sur le système hôte et ajouter tous les sous-répertoires et les fichiers dans le cadre du système de fichiers cible. Chaque objet dans le système de fichiers (répertoire ou fichier) peut consister en un en-tête et un corps, où le corps d'un fichier est le fichier même et le corps d'un répertoire est une liste d'entrées - noms et « adresses » d'autres fichiers et répertoires.

Les objets dans ce système de fichiers deviendront contigus, donc le noyau les lira facilement dans la mémoire. Tous les objets auront également une taille fixe (à l'exception du dernier, qui peut augmenter), il est donc difficile d'ajouter de nouveaux fichiers ou de modifier ceux qui existent.

12-3. Nœuds d'index (inodes) et systèmes de fichiers accessibles en écriture

Lorsque l'on envisage un système de fichiers accessible en écriture, c'est une bonne idée de se pencher sur le concept d'un nœud d'index ou inode. Voir la section Lectures complémentairesLectures complémentaires pour des lectures recommandées.

12-4. Un système virtuel de fichiers

Quelle abstraction utiliser pour la lecture et l'écriture sur les dispositifs tels que l'écran et le clavier ?

Un système virtuel de fichiers (VFS) crée une abstraction au-dessus des systèmes de fichiers concrets. Un VFS fournit principalement le système de chemins et une hiérarchie de fichiers, il délègue les opérations sur les fichiers aux systèmes de fichiers sous-jacents. Le document original sur les VFS est succinct et mérite bien une lecture. Voir la section Lectures complémentairesLectures complémentaires pour une référence.

Avec un VFS, nous pourrions monter un système de fichiers spécial sur le chemin /dev. Ce système de fichiers gérerait tous les périphériques tels que les claviers et la console. Cependant, on pourrait aussi suivre l'approche traditionnelle UNIX, avec numéros majeurs/mineurs de périphériques et mknod pour créer des fichiers spéciaux pour ces périphériques. C'est à vous de choisir l'approche que vous pensez être la plus adaptée, il n'y a pas de chose juste ni de chose incorrecte lors de la construction des couches d'abstraction (bien que certaines abstractions se révèlent beaucoup plus utiles que d'autres).

12-5. Lectures complémentaires

13. Appels système

Les appels système sont le mécanisme par lequel les applications en mode utilisateur interagissent avec le noyau - pour solliciter des ressources ou demander que des opérations soient effectuées, etc. L'API des appels système est la partie du noyau la plus visible aux yeux des utilisateurs, donc sa conception nécessite une certaine réflexion.

13-1. Concevoir des appels système

C'est à nous, les développeurs du noyau, de concevoir les appels système que les développeurs d'applications peuvent utiliser. Nous pouvons nous inspirer des normes POSIX ou, si elles semblent trop de travail, il suffit de regarder ceux pour Linux et de choisir. Voir la section Lecture complémentaireLecture complémentaire à la fin du chapitre 10 pour références.

13-2. Implémenter les appels système

Les appels système sont traditionnellement invoqués avec des interruptions logicielles. Les applications de l'utilisateur mettent les valeurs appropriées dans les registres ou sur la pile puis déclenchent une interruption prédéfinie, qui transfère l'exécution au noyau. Le numéro d'interruption utilisé dépend du noyau, Linux utilise le numéro 0x80 pour identifier une interruption destinée à déclencher un appel système.

Lors de l'exécution des appels système, le niveau de privilège actuel passe généralement de PL3 à PL0 (si l'application s'exécute en mode utilisateur). Il faut donc que le DPL de l'enregistrement de l'IDT correspondant à l'interruption d'appel système permette l'accès PL3.

Chaque fois que des interruptions impliquant un changement de niveau de privilège se produisent, le processeur dépose quelques registres importants sur la pile - les mêmes que nous avons utilisés précédemment pour entrer en mode utilisateur ; voir la figure 6-4, section 6.12.1, dans le manuel Intel (33). Quelle pile utiliser ? La même section dans le manuel Intel précise que si une interruption conduit à l'exécution de code ayant un niveau de privilège numériquement plus faible, un changement de pile se produit. Les nouvelles valeurs pour les registres ss et esp sont chargées à partir du segment d'état de tâche (TSS) courant. La structure du TSS est spécifiée dans la figure 7-2, la section 7.2.1 du manuel Intel.

Pour activer les appels système, nous devons mettre en place un TSS avant d'entrer en mode utilisateur. Sa mise en place peut être faite en C, en définissant les champs ss0 et esp0 d'une « struct compactée » qui représente un TSS. Avant de charger la « struct compactée » dans le processeur, un descripteur TSS doit être ajouté à la GDT. La structure du descripteur TSS est décrite dans la section 7.2.2 du manuel Intel.

Vous spécifiez le sélecteur de segment TSS courant en le chargeant dans le registre tr avec l'instruction assembleur ltr. Si le descripteur de segment TSS a l'index 5, et ainsi le décalage est 5 * 8 = 40 = 0x28, celle-ci est la valeur qui doit être chargée dans le registre tr.

Lorsque nous sommes entrés en mode utilisateur dans le chapitre Entrer en mode utilisateurEntrer en mode utilisateur, nous avons désactivé les interruptions lors de l'exécution en PL3. Puisque les appels système sont mis en œuvre en utilisant les interruptions, les interruptions doivent être activées en mode utilisateur. En définissant le bit indicateur IF à la valeur eflags sur la pile, nous ferons en sorte que iret active les interruptions (puisque la valeur eflags sur la pile sera chargée dans le registre eflags par l'instruction assembleur iret).

13-3. Lectures complémentaires

14. Multitâche

Comment faire pour que de multiples processus semblent fonctionner en même temps ? Aujourd'hui, cette question a deux réponses :

  • avec la disponibilité de processeurs multicœurs ou de systèmes avec de multiples processeurs, deux processus peuvent effectivement s'exécuter en même temps, en les exécutant sur des cœurs ou des processeurs différents ;
  • simuler, c'est-à-dire commuter rapidement (plus vite qu'un humain ne peut s'en apercevoir) entre processus. À tout moment donné, il y a un seul processus en exécution, mais la commutation rapide donne l'impression qu'ils s'exécutent « en même temps ».

Puisque le système d'exploitation créé dans ce livre ne supporte pas les processeurs multicœurs ou plusieurs processeurs, la seule option est de simuler. La partie du système d'exploitation responsable de commuter rapidement entre les processus est appelée l'algorithme d'ordonnancement.

14-1. Créer de nouveaux processus

La création de nouveaux processus est généralement faite par deux appels système différents : fork et exec. fork crée une copie exacte du processus en cours d'exécution, tandis qu'exec remplace le processus courant avec celui spécifié par un chemin d'accès à l'emplacement d'un programme dans le système de fichiers. Entre ces deux options, nous vous recommandons de commencer par la mise en œuvre d'exec, puisque cet appel système va suivre presque exactement les mêmes étapes que celles décrites dans la section Configurer le mode utilisateurConfigurer le mode utilisateur du chapitre Mode utilisateurMode utilisateur.

14-2. Ordonnancement en mode multitâche coopératif

La façon la plus facile d'effectuer une commutation rapide entre processus est de rendre les processus eux-mêmes responsables de la commutation. Les processus sont exécutés pendant un certain temps, puis disent au système d'exploitation (via un appel système) qu'il peut maintenant passer à un autre processus. Rendre le contrôle du CPU à l'autre processus s'appelle rendre la main et lorsque les processus eux-mêmes sont responsables de l'ordonnancement, on appelle cela le mode coopératif, puisque tous les processus doivent coopérer les uns avec les autres.

Quand un processus rend la main, tout l'état du processus doit être enregistré (tous les registres), de préférence sur le tas du noyau, dans une structure qui représente un processus. Lors du passage à un nouveau processus, tous les registres doivent être restaurés à partir des valeurs enregistrées.

L'ordonnancement peut être mis en œuvre en gardant une liste des processus qui sont en cours d'exécution. L'appel système yield doit ensuite donner la main au processus suivant de la liste et mettre celui qui rend la main en dernier (d'autres mécanismes sont possibles, mais celui-ci est le plus simple).

Le transfert du contrôle au nouveau processus se fait via l'instruction assembleur iret, exactement de la même manière que cela a été fait dans la section Entrer en mode utilisateurEntrer en mode utilisateur du chapitre Mode utilisateurMode utilisateur.

Nous recommandons fortement que vous commenciez par mettre en œuvre un mode multitâche coopératif. Nous recommandons en outre que vous ayez mis au point une solution fonctionnelle à la fois pour exec, fork et yield avant de vous lancer dans le mode multitâche préemptif. Comme le mode coopératif est déterministe, il est beaucoup plus facile à déboguer que le mode préemptif.

14-3. Ordonnancement en mode multitâche préemptif avec interruptions

Au lieu de laisser les processus décider eux-mêmes quand rendre la main à un autre processus, l'OS peut commuter automatiquement les processus après une courte période de temps. Le système d'exploitation peut régler la minuterie d'intervalle programmable (PIT) pour lancer une interruption après une courte période de temps, par exemple 20 millisecondes. Dans le gestionnaire d'interruptions pour l'interruption PIT, l'OS va remplacer le processus en cours par un autre. De cette façon, les processus eux-mêmes ne doivent pas se préoccuper de l'ordonnancement. Ce type d'ordonnancement est appelé mode préemptif.

14-3-1. Minuterie d'intervalle programmable (Programmable Interval Timer - PIT)

Pour être en mesure de faire de l'ordonnancement préemptif, la PIT doit d'abord être configurée pour déclencher des interruptions toutes les x millisecondes, où x doit être un intervalle de temps configurable.

La configuration de la PIT est très similaire à la configuration des autres périphériques matériels : un octet est envoyé sur un port d'E/S. Le port de commande de la PIT est 0x43. Pour connaître toutes les options de configuration, veuillez consulter l'article sur la PIT sur OSDev(38). Nous utilisons les options suivantes :

  • déclencher des interruptions (utiliser le canal 0) ;
  • envoyer l'octet inférieur du diviseur, puis son octet supérieur (voir la section suivante pour une explication) ;
  • utiliser une onde carrée ;
  • utiliser le mode binaire.

Cela se traduit par l'octet de configuration 00110110.

La définition de l'intervalle de fréquence à laquelle les interruptions doivent être levées se fait par l'intermédiaire d'un diviseur, de la même manière que pour le port série. Au lieu d'envoyer à la PIT une valeur (par exemple en millisecondes) qui précise tous les combien une interruption doit être déclenchée, vous envoyez un diviseur. La PIT fonctionne par défaut à 1 193 182 Hz. L'envoi du diviseur 10 a pour résultat le fonctionnement de la PIT à 1193182/10 = 119318 Hz. Le diviseur ne peut être que sur 16 bits, il est donc possible de configurer la fréquence de la minuterie entre 1 193 182 Hz et 1193182/65535 = 18,2 Hz. Nous vous recommandons de créer une fonction qui prend un intervalle en millisecondes et calcule le diviseur voulu.

Le diviseur est envoyé au port d'E/S de données du canal 0 de la PIT, mais comme on ne peut envoyer qu'un octet à la fois, les 8 bits inférieurs du diviseur doivent être envoyés en premier, puis les 8 bits les plus hauts du diviseur peuvent être envoyés. Le port d'E/S de données du canal 0 est situé à 0x40. Encore une fois, voir l'article sur OSDev (39) pour plus de détails.

14-3-2. Piles séparées dans le noyau pour les processus

Si tous les processus utilisent la même pile noyau (la pile exposée par le TSS), il y aura des problèmes si un processus est interrompu lorsqu'il est encore en mode noyau. Le processus qui prend la main utilisera alors la même pile noyau et écrasera ce que le processus précédent a écrit sur la pile (rappelez-vous que la structure de données TSS pointe vers le début de la pile).

Pour résoudre ce problème, chaque processus doit avoir sa propre pile dans le noyau, tout comme chaque processus a sa propre pile en mode utilisateur. Lors de la commutation entre processus, le TSS doit être mis à jour pour pointer vers la pile noyau du nouveau processus.

14-3-3. Difficultés de l'ordonnancement préemptif

L'utilisation de l'ordonnancement préemptif pose un problème qui ne se pose pas avec l'ordonnancement coopératif. Avec l'ordonnancement coopératif, chaque fois qu'un processus rend la main, il est forcément en mode utilisateur (niveau de privilège 3), puisque rendre la main est un appel système. Avec l'ordonnancement préemptif, les processus peuvent être interrompus en mode utilisateur ou en mode noyau (niveau de privilège 0), puisque le processus lui-même ne contrôle pas quand il est interrompu.

L'interruption d'un processus en mode noyau est un peu différente de l'interruption d'un processus en mode utilisateur, en raison de la façon dont le processeur met en place la pile lors des interruptions. Si un changement de niveau de privilège a eu lieu (le processus a été interrompu en mode utilisateur), le processeur va déposer les valeurs des registres ss et esp du processus sur la pile. Si aucun changement de niveau de privilège ne se produit (le processus a été interrompu en mode noyau), le processeur ne déposera pas le registre esp sur la pile. En outre, si le niveau de privilège n'a pas changé, le processeur ne va pas basculer vers la pile définie dans le TSS.

Ce problème peut être résolu en calculant la valeur de esp avant l'interruption. Puisque vous savez que le processeur dépose 3 choses sur la pile lorsqu'aucun changement de privilège ne se passe et vous savez combien vous en avez mis sur la pile, vous pouvez calculer la valeur de esp au moment de l'interruption. Ceci est possible, car le processeur ne va pas changer les piles s'il n'y a aucun changement de niveau de privilège, de sorte que le contenu de esp sera le même qu'au moment de l'interruption.

Pour compliquer encore les choses, il faut penser à la façon de traiter le cas lors du passage à un nouveau processus qui doit être exécuté en mode noyau. Puisque iret a été utilisé sans aucun niveau de privilège, le changement du CPU ne mettra pas à jour la valeur de esp avec celle qui est placée sur la pile - vous devez mettre à jour vous-même esp.

14-4. Lectures complémentaires

15. Remerciements Developpez

Nous remercions Erik Helin et Adam Renberg pour la rédaction de ce tutoriel, le texte original peut être trouvé sur https://littleosbook.github.io/The little book about OS development. Nous remercions aussi Mishulyna pour sa traduction, lolo78 pour sa relecture technique ainsi que Claude Leloup pour sa relecture orthographique.

Les commentaires et les suggestions d'amélioration sont les bienvenus, alors, après votre lecture, n'hésitez pas. 9 commentaires Donner une note à l'article (5)


Andrew Tanenbaum, 2008. Systèmes d'exploitation, 3e édition. Pearson.
L'Institut royal de technologie, http://www.kth.se
James Molloy, James m's kernel development tutorial, http://www.jamesmolloy.co.uk/tutorial_html/
Canonical Ltd, Ubuntu, http://www.ubuntu.com/
Oracle, Oracle vM virtualBox, http://www.virtualbox.org/
Dennis M. Ritchie Brian W. Kernighan, 1988. Le langage C - 2e éd - Norme ANSI. Dunod.
Free Software Foundation, GCC, the gNU compiler collection, http://gcc.gnu.org/
NASM, NASM: The netwide assembler, http://www.nasm.us/
Free Software Foundation, GNU make, http://www.gnu.org/software/make/
Volker Ruppert, bochs: The open source iA-32 emulation project, http://bochs.sourceforge.net/
Free Software Foundation, GNU gRUB, http://www.gnu.org/software/grub/
Wikipédia, Executable and Linkable Format, https://fr.wikipedia.org/wiki/Executable_and_Linkable_Format
Free Software Foundation, Multiboot specification version 0.6.96, http://www.gnu.org/software/grub/manual/multiboot/multiboot.html
Lars Nodeen, Bug #426419: configure: error: GRUB requires a working absolute objcopy, https://bugs.launchpad.net/ubuntu/+source/grub/+bug/426419
NASM, RESB and friends: Declaring uninitialized data, http://www.nasm.us/doc/nasmdoc3.htm
Wikipédia, ASCII - American Standard Code for Information Interchange, Image non disponiblehttps://fr.wikipedia.org/wiki/American_Standard_Code_for_Information_Interchange
WikiBooks, Serial programming/8250 uART programming, http://en.wikibooks.org/wiki/Serial_Programming/ 8250_UART_Programming
Andries Brouwer, Keyboard scancodes, https://www.win.tue.nl/~aeb/linux/kbd/scancodes.html (NdT)
Steve Chamberlain, Using ld, the gNU linker, http://www.math.utah.edu/docs/info/ld_toc.html
OSDev, Programmable interval timer, http://wiki.osdev.org/Programmable_Interval_Timer

  

Licence Creative Commons
Le contenu de cet article est rédigé par Erik Helin et est mis à disposition selon les termes de la Licence Creative Commons Attribution - Pas d’Utilisation Commerciale - Partage dans les Mêmes Conditions 3.0 non transposé.
Les logos Developpez.com, en-tête, pied de page, css, et look & feel de l'article sont Copyright © 2013 Developpez.com.