Professional Documents
Culture Documents
Résumé du projet
L’architecture Intel x86 est probablement la plus répandue dans les ordinateurs de type PC. Elle est
cependant progressivement remplacée depuis quelques années par l’architecture amd-64. Néanmoins, du
fait de la rétro compatibilité entre les deux, la majorité des systèmes d’exploitation modernes est encore
conçue pour tourner sur x86. L’objectif de ce projet, était de réaliser un système d’exploitation basé sur
un noyau de type micro-noyau pour l’architecture x86. En dehors de l’aspect purement pédagogique, nous
espérons que ce système d’exploitation puisse servir de base pour de futurs projets de bas niveau (écriture
de pilotes matériel, recherche sur des algorithmes d’ordonnancement, ajout de fonctionnalités...).
Ce projet nous a permis de découvrir de manière concrète les mécanismes de bas niveau mis en
œuvre dans nos ordinateurs, et en particulier les spécificités de cette architecture. Nous avons pu faire
la distinction entre ce qui est pris en charge par le processeur et ce qui est du domaine du système
d’exploitation. Les notions développées en cours de Système d’exploitation en 3ème année ont pris une
toute autre dimension lorsqu’il a s’agit de les mettre en œuvre. Il est beaucoup plus simple de comprendre
le fonctionnement des différents mécanismes que de les mettre réellement en pratique puisqu’un infime
bug peut mettre en péril la stabilité du système tout entier. De plus, de par le caractère bas niveau
de notre projet nous avons été confrontés à des problèmes auxquels nous n’étions pas habitués et le
débuggage s’est avéré être bien plus compliqué.
Nous avons été encadré dans ce travail par Pierre-Emmanuel Hladik et Sébastien Di Mercurio que nous
tenons à remercier, en particulier pour avoir proposé un sujet si intéressant. Dans ce présent rapport,
nous vous proposons un tour d’horizon des différents éléments que nous avons mis en place dans notre
système d’exploitation mais aussi une présentation du déroulement du projet et des problèmes rencontrés.
Enfin, nous finirons par quelques pistes pour la suite de ce projet.
Table des matières
1 Conduite de projet 1
1.1 Organisation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
1.2 Réalisation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
1.3 Avancement / Répartition du travail . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
2 Technique 3
2.1 Mémoire . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
2.1.1 Pagination . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
2.1.2 Malloc . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
2.2 Gestion des interruptions et Exceptions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
2.2.1 Mise en place . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
2.2.2 Rôle du wrapper . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
2.2.3 Mapping des exceptions et interruptions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
2.2.4 Liste des interruptions matérielles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
2.3 Gestion de l’horloge . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
2.3.1 Fonctionnement de l’horloge . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
2.3.2 Conversions de dates et calendriers . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
2.3.3 Planifications d’évènements . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
2.4 Gestion des processus . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
2.4.1 Changement de contexte . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
2.4.2 Ordonnancement . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
2.4.3 Appels systèmes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
2.5 Pilotes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12
2.5.1 Disquette . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12
2.5.2 Gestion du clavier . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
2.5.3 Pilote souris . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
2.6 Système de fichier . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
2.6.1 Système de fichier FAT . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
2.6.2 Formatage de la Partition . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
2.6.3 Fragmentation : Clusters/Chainage . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
2.6.4 Lecture de l’arborescence et des fichiers . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19
2.7 Entrées / Sorties . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
2.8 IPC . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
2.8.1 Sémaphores . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
3 Problèmes 22
3.1 Logiciels de virtualisation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
3.2 Makefile . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
3.3 Préemptibilité . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
3.4 Malloc . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
5 Annexes 26
5.1 Arborescence des fichiers de compilation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
5.2 Diagramme de Gantt . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
5.3 Compilation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29
5.4 Déboguage avec gdb . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30
1 Conduite de projet
1.1 Organisation
Afin de travailler sur ce projet, de se répartir le travail, et de décider des choix techniques, nous avons fait des
réunions de travail d’environ une heure toutes les deux semaines en moyenne.
Lors de ces réunions, nous commencions systématiquement par un bilan de l’avancement du système d’exploita-
tion. C’était l’occasion de faire remonter les difficultés rencontrées et de réévaluer le temps nécessaire à la réalisation
des tâches que nous avions attribués à chacun. Nous avons aussi profité de ces réunions pour donner des explications
sur le fonctionnement du système d’exploitation, par exemple, comment fonctionne la pagination, les interruptions
ou encore les appels systèmes. Enfin, nous décidions ensemble de ce qu’il serait intéressant de développer et qui s’en
occuperait.
En dehors de ces réunions, nous parlions énormément du projet, que ce soit lors des pauses entre les cours ou
sur internet par messagerie instantanée (salons de discussion Jabber).
1.2 Réalisation
Pour pouvoir travailler ensemble sur ce projet, nous avons commencé par créer un dépôt Subversion accessible
sur Internet. Subversion est un outil de gestion de version qui nous permet de travailler ensemble mais chacun de
notre côté. Cela évite d’avoir un dossier partagé et de gérer à la main la fusion de nos travaux.
Nous avons essayé de commenter chacun de nos commits afin de savoir sur quoi porte chacune des modifications
et nous avons fait en sorte que la dernière version commitée n’entraine pas trop de régressions. L’utilisation de cet
outil nous a permit de déterminer à quel moment nous avons introduit certains bugs et ainsi de mieux les localiser.
De part la complexité de ce projet, ceci nous a été très précieux.
Pour développer ce projet, nous avons eu recours à plusieurs logiciels dont en particulier :
– GCC : Le compilateur C.
– GDB : Le debugger, interfaçable avec qemu et bochs.
– QEMU : Une machine virtuelle pour exécuter notre système d’exploitation.
– Bochs : Une autre machine virtuelle.
9
17000
8
16000
15000 7
14000 6
13000
12000
11000
5
10000
9000 4
8000 3
7000 2
6000
5000
4000
3000 1
2000
1000
9
Février Mars Avril Mai
5. Pagination activée en identity mapping. Mise en place de la lecture sur disquette. Gestion d’évènements
programmés à l’aide du timer. Début kmalloc.
6. Création des appels systèmes. Premier ordonnancement entre deux processus. La souris est gérée. Mise en
place des entrées/sorties avec les streams stdin, stdout et stderr. Le scanf permet maintenant de taper du
texte et de supprimer en cas d’erreur. La prise en charge du FAT permet de parcourir les fichiers et dossiers
à l’intérieur de la disquette.
7. Vacances scolaires.
8. Amélioration des Makefile (makedepend). Correction de bugs principalement.
9. Correction de bugs. Ajout d’une API pour créer une IHM en console. La sortie écran est améliorée pour mieux
gérer le changement entre applications avec Alt+Tab.
Nous avons décidé d’activer la pagination dans notre système d’exploitation pour gérer la mémoire. En effet,
suite à nos recherches bibliographiques, nous avons jugé que la pagination était préférable à la segmentation et c’est
le système utilisé par les principaux systèmes d’exploitation du marché.
Pour rappel, la pagination consiste à découper la mémoire en pages de taille fixe et les placer dans des cadres
de page. Puisque nous travaillons sur une architecture 32 bits, l’espace d’adressage est limité à 4 Gio.
La première étape consiste à découper la mémoire physique en cadres de page de taille 4 Kio. Nous avons eu le
choix entre une taille de 4 Kio, 2 Mio et 4 Mio mais nous avons préféré utiliser la taille la plus fine pour augmenter
la granularité.
Afin de découper la mémoire physique et d’obtenir une liste de cadres utilisés et libres, nous avons simplement
fait une boucle qui itérait sur l’ensemble de la RAM et qui tous les 4 Kio vérifiait si le cadre courant est réservé ou
non. Typiquement, les cadres réservés sont ceux de la mémoire BIOS et du noyau.
Les cadres sont stockés dans deux listes chaı̂nées : used frame pages et free frame pages et remplis initialement
par la fonction memory setup().
Deux fonctions permettent de réserver et de libérer un cadre de page pour y stocker une page. D’autres fonctions
servent à pouvoir itérer sur les cadres de la mémoire physique mais elles sont moins importantes.
Une fois la mémoire physique découpée en cadres, nous avons pu activer la pagination. Comme la segmentation,
c’est la MMU qui s’occupe de la traduction des adresses mais il faut pour cela lui donner l’adresse du répertoire de
pages. Comme nous pouvons le voir sur la figure 2, la MMU du x86 travaille sur plusieurs niveaux de traduction.
Il y a tout d’abord un répertoire contenant les adresses des tables de pages et c’est dans ces dernières que nous
retrouvons les adresses des pages. On peut remarquer que le répertoire et les tables sont des tableaux de 1024 entrées
de chacune 4 octets ce qui fait des blocs de 4 Kio, soit la taille d’une page. Ces tableaux doivent être alignés sur un
cadre de page ce qui permet de les adresser en seulement 20 bits (4 Gio c’est 1024 × 1024 × 4 Kio soit 220 × 4 Kio).
Pour activer la pagination, il faut faire deux choses :
– Charger l’adresse du répertoire dans le registre CR3
– Passer le bit ”paging enable” du registre CR0 à 1.
Une fois la pagination activée, il n’est plus possible de manipuler des adresses physiques, il convient alors de
manipuler des adresses virtuelles. Cela peut avoir des conséquences inattendues, et c’est pour cette raison que nous
avons mis en place un système parfois appelé ”Identity mapping” et qui consiste à placer les pages dans les cadres
tel qu’une adresse physique soit identique à une adresse virtuelle. Ensuite il sera bien sûr possible de casser cette
égalité.
Une fois la mémoire mappée, nous donnons au répertoire des pages une adresse particulière afin d’y accéder et
pouvoir modifier les tables de pages facilement. L’idée est la suivante : nous avons choisi une entrée du répertoire
(arbitrairement la dernière) et nous l’avons fait pointer vers l’adresse du répertoire de page. Ainsi l’adresse virtuelle
0xFFFFF00 est l’adresse du répertoire et 0xFFC + 1024 * index page table est l’adresse d’une table de page.
Nous n’avons pas eu le temps de finir la mise en place de la pagination. Il nous reste seulement une dernière
étape qui consiste à créer un répertoire de page par processus et recharger le répertoire de page du processus à
chaque changement de contexte. Actuellement, tous les processus partagent le même répertoire ce qui réduit la
sécurité du système (possibilité pour un processus de taper dans la mémoire d’un autre).
Pour la suite, il est important de garder à l’esprit que c’est la MMU qui se charge de la traduction d’adresse.
Ainsi, des zones mémoires peuvent être contigües dans la mémoire virtuelle alors que dans la mémoire physique il
en est tout autrement.
2.1.2 Malloc
Après avoir mis en place la pagination, on peut maintenant se lancer dans l’écriture de fonctions permettant l’al-
location dynamique de la mémoire virtuelle. Cette allocation dynamique est découpée en deux parties. La première
CR3
Figure 2 – Traduction d’une adresse linéaire en une adresse physique par la MMU
est le gestionnaires des pages libres et utilisées en mémoire virtuelle (c’est-à-dire dans le répertoire de page courant)
que l’on appellera la VMM (Virtual Memory Manager). La seconde partie (une seconde couche) s’occupe de gérer
les blocs de mémoire allouées ou libres (à l’octet près cette fois). On appellera cette seconde partie : kmalloc.
Virtual Memory Manager La VMM s’occupe donc de savoir quelles sont les pages libres de la mémoire virtuelle
(c’est-à-dire les pages non mappées à des pages physiques) et quelles sont celles déjà utilisées. Pour cela, on utilise
deux listes doublements chainées : une pour les pages libres, et une pour les pages utilisées. Chaque nœud de ces
listes décrit un bloc de page (en fait il contient simplement le nombre de pages du bloc). Chacun de ces nœuds se
trouve au début du bloc de page qu’il décrit. L’adresse (virtuelle) d’un nœud est donc aussi le début du bloc, il
suffit d’ajouter la taille d’un nœud (sizeof(struct noeud) = 16 octets) pour obtenir l’adresse du début du bloc. Les
deux listes sont ordonnées par adresses de blocs de pages croissantes.
Nous allons maintenant décrire le processus d’allocation de pages par la VMM. Lorsque la fonction allo-
cate new pages(nb pages) est appelée, la VMM parcourt la liste des pages libres pour voir s’il existe un bloc libre
de taille assez importante (c’est-à-dire possédant au moins nb pages libres). S’il n’existe pas de tel bloc, la VMM
va créer une nouvelle zone libre en haut du tas. Ensuite la VMM va réserver nb pages pages physique et mapper
chacune de ces pages physiques aux pages virtuelles libres du bloc en question. Par la suite, la VMM va créer un
nouveau nœud descripteur de bloc de pages et le placer au début du bloc (comme vu dans le paragraphe précèdent).
Elle va ensuite rajouter ce nœud dans la liste des blocs de pages utilisées (et éventuellement le retirer de la liste des
blocs de pages libres). Cet algorithme est appelé First Fit, c’est l’un des plus simples à mettre en place et il a pour
avantage d’être très rapide. Il est particulièrement adapté pour les systèmes qui disposent de plus de mémoire que
nécessaire car il ne parcourt pas de longues structures afin de trouver l’emplacement optimal.
Pour mettre cette gestion des pages un peu plus au clair, on peut voir un exemple de ce à quoi peut ressembler
la mémoire virtuelle sur la figure 3. En bleu, les structures décrivant les blocs de pages, en gris les blocs de pages
utilisées, et en blanc les blocs de pages libres. Sur ce schéma, on peut voir trois blocs de pages. La première (en
partant du bas de la mémoire) est donc un bloc de pages utilisées, la structure au début de ce bloc est donc le
premier nœud de la liste des blocs de pages utilisées. Ce nœud pointe donc vers le prochain bloc de pages utilisées.
Le bloc de pages libres au milieu est le seul de sa liste et ne pointe donc vers rien.
Dans cet exemple, si le noyau demande l’allocation de 3 pages, celles-ci seront placées dans le bloc libre qui sera
coupé en deux. Un nouveau bloc de pages utilisées (contenant 3 pages) sera créé et le bloc libre sera remplacé par
Bloc de
2 pages
Début de la zone
gérée par vmm.c
Noyau - Identity Mapped
un nouveau bloc de taille 1. Si le noyau demande l’allocation de 5 pages, la VMM va devoir agrandir le tas, en
créant une nouvelle zone libre en haut du tas.
kmalloc Il n’y a pas lieu de s’étendre beaucoup sur kmalloc, vu qu’il marche sur le même principe que la VMM :
deux listes doublements chainées représentant les zones de mémoires libres et utilisées. Des structures décrivant les
zones de mémoires au début de ces zones mémoires.
Les deux différences entre kmalloc et la VMM sont que kmalloc travaille au niveau de l’octet (au lieu des pages)
et que kmalloc n’a pas besoin de mapper les pages virtuelles aux pages physiques.
Pour gérer les interruptions et les exceptions, nous avons eu besoin de configurer l’Interrupt Descriptor Table
(IDT). L’IDT est un tableau qui contient en particulier l’adresse des Interrupt Service Routines (ISR). Ces routines
sont simplement les fonctions à exécuter lorsqu’une interruption est levée.
Avant toute chose, pour pouvoir supporter les interruptions matérielles, il est aussi nécessaire de configurer le
Programmable Interrupt Controller (PIC). Sur l’architecture x86, il s’agit du i8259 et ils sont au nombre de deux.
L’un est le maı̂tre et l’autre est l’esclave et ils sont mis en cascade. Chacun d’eux possède 8 entrées ce qui fait 15
IRQ (et non 16 puisqu’ils sont en cascade).
Le rôle du PIC est de gérer les interruptions matérielles, et l’un des points important de la configuration est le
mapping des interruptions. C’est en effet lors de cette configuration que l’on va lui dire d’utiliser les interruptions
processeur entre 32 et 47.
Le wrapper va commencer par faire une copie des registres avant d’exécuter le handler. Une fois que le handler
a terminé son exécution, le wrapper va restaurer l’état des registres et pouvoir sortir de l’interruption. Selon le type
d’interruption, le wrapper va faire d’autres actions. Dans le cas des exceptions, il faut appeler la handler avec le
code d’erreur. Ce code a été placé dans la pile juste avant que le wrapper soit executé. Dans le cas des interruptions
matérielles, il faut envoyer le signal End Of Interrupt au PIC.
Le processeur x86 permet de gérer jusqu’à 256 interruptions. Nous avons décidé de réserver les 32 premières
entrées pour les exceptions et les 32 suivantes pour les interruptions dont les 16 premières sont des interruptions
matérielles. Le mapping des interruptions est présenté dans la figure 4.
Principe de fonctionnement : La gestion du temps s’appuie sur une horloge périphérique appelée RTC (Real
Time Clock) ainsi que sur le compteur programmable PIT (Programmable Interval Timer) I8254. À l’initialisation
nous allons interroger l’horloge afin d’obtenir la date et l’heure actuelle, à partir de ce moment nous n’aurons plus
besoin de consulter la RTC. En effet, une fois l’heure et la date obtenues, la nouvelle heure est extrapolée grâce au
PIT. Pour cela nous paramétrons le timer pour lancer une interruption au bout d’un temps T correspondant à un
tick système, à l’issue de cette interruption le temps système est incrémenté et le PIT est reconfiguré pour un autre
tick. Le temps écoulé en secondes est calculé à partir de la durée d’un tick.
Lecture de l’horloge : La communication avec la RTC se fait en écrivant une requête sur le port 0x70 et en
lisant la réponse sur le port 0x71, les codes de requête sont décrits dans la table 1
La requête ”jour de la semaine” demande une réponse du type Lundi, Mardi..., Samedi, Dimanche. Tandis que la
Le champ alarme dans la table indique les requêtes concernant, non pas la valeur de l’horloge mais la fonction
alarme de la RTC, en effet l’horloge peut être configurée pour allumer automatiquement l’ordinateur à une heure
donnée.
Configuration du compteur : Le fonctionnement d’un PIT est le suivant, un PIT contient généralement un
registre incrémenté à intervalle régulier. Lorsque la valeur du registre provoque un overflow, le PIT génère une
interruption (ici IRQ0). Configurer un compteur consiste donc à mettre en place un handler sur l’interruption qui
sera levée puis à paramétrer le registre de manière à ce que l’interruption arrive au moment voulu. La configuration
de l’I8254 se fait à travers le port 0x40 pour positionner le registre de comparaison et le port 0x43 pour régler plus
précisément le fonctionnement du compteur.
Afin d’implémenter la fonction sleep et l’ordonnanceur de la manière la moins scrutative possible nous avons
inclus un gestionnaire de planification d’évènements dans l’horloge. Le fonctionnement repose sur l’utilisation d’un
tas, celui-ci stocke les différents évènements planifiés. A chaque tick d’horloge il suffit alors de vérifier s’il y a un
évènement à déclencher et le déclencher le cas échéant. La gestion des ticks s’exécutant en ring 0, le code à exécuter
doit pour des mesures de sécurité être du code kernel, il est donc proscrit de permettre à une application en user
space de planifier directement l’appel d’une de ses fonctions, la planification d’événements se doit donc d’être utilisée
par le kernel ou utilisée indirectement au travers des appels systèmes. Une autre exigence à prendre en compte est
la rapidité d’exécution du handler, en effet, celui-ci s’exécute dans le cadre d’une interruption d’horloge.
Une fois la planification d’évènements en place, il devient dès lors aisé d’implémenter la fonction sleep, cela revient
à planifier une fonction qui va réveiller le programme puis d’endormir le programme en le passant en statut idle.
Dans notre système d’exploitation, chaque processus est représenté à travers une structure contenant un certain
nombre d’informations :
– Des identifiants (Pid, Nom )
– État du processus (Idle, running, terminated...)
– État des registres (permettant le changement de contexte)
– Différents descripteurs pour les entrées/sorties
– Informations diverses (temps d’exécution par exemple).
La création d’un processus revient donc à initialiser une telle structure, et à l’ajouter à la liste des processus. Il
faut également, durant cette initialisation, réserver la mémoire pour les piles utilisateur et noyau du processus.
Une fois qu’au moins un processus a été créé, il est possible d’activer l’ordonnanceur afin d’exécuter les processus
contenus dans la liste, nous allons donc voir les différents mécanismes liés à cette exécution.
Le changement de contexte est un mécanisme clé dans l’exécution et l’ordonnancement des tâches. Il permet d’une
part, à partir de l’état d’exécution d’un processus (c’est à dire des valeurs de ces registres), rétablir son exécution,
tout en sauvegardant l’état d’exécution actuel. Il doit permettre également de changer le niveau de privilèges. En
effet, l’ordonnanceur étant exécuté avec les privilèges du noyau (ring 0), le changement de contexte doit permettre
de repasser en privilège utilisateur (ring 3).
SS
ESP
EFLAGS
CS
EIP
ESP
Imaginons maintenant que nous sommes en ring 0, et que nous voulons exécuter un processus en ring 3. Il suffit
simplement de mettre les registres eax, ebx, ecx, et edx au valeurs correspondantes pour le processus, puis d’empiler,
de manière logicielle, SS, ESP, EFLAGS, CS et EIP correspondant au processus, pour imiter une interruption. Ceci
fait, il ne reste plus qu’à exécuter une instruction iret, qui dépilera les valeurs comme si une interruption avait eu
lieu, et changera le privilège en ring 3.
2.4.2 Ordonnancement
Afin de pouvoir exécuter plusieurs tâches en parallèle il faut ce que l’on appelle un ordonnanceur, l’ordonnanceur
est chargé de distribuer le temps d’exécution sur le processeur des différents processus. Il s’agit d’un des éléments
les plus importants d’un système d’exploitation, à terme, un mauvais ordonnanceur implique systématiquement de
mauvaises performances.
Nous n’avons pas eu le temps d’implémenter un ordonnanceur complexe, nous avons simplement opté pour
un ordonnancement de type tourniquet. Cet ordonnanceur ne gère pas les priorités, néanmoins il est capable de
n’exécuter que les processus actifs et non les processus en état d’attente. Le principe de fonctionnement (Voir Figure
6) est relativement simple : les processus étant placés dans une liste, à chaque exécution de l’ordonnanceur on prend
le prochain élément de la liste en ignorant les processus non exécutables (en pause, terminé...). Avant d’exécuter
le processus sélectionné, l’ordonnanceur prend soin de faire en sorte qu’il sera bien rappelé au bout du quantum
de temps alloué au processus. Le mécanisme assurant l’appel régulier de l’ordonnanceur utilise le gestionnaire de
planification d’événements vu précédemment.
Début
non
Courant à
l’etat running
oui
Sauvegarde du contexte
Recherche du
prochain processus à
executer
non
Aucune tâche
à executer
Changement de contexte
ss
esp
cs
eip
eax
ecx
edx
ebx
esp kernel
ebp
edi
ds
es
fs
gs
fs
eip kernel
Empilé à l’appel de l’ordonnanceur
ebp kernel
EBP
Comme nous l’avons expliqué plus haut, les processus sont exécutés avec un privilège utilisateur, grâce au
mécanisme de changement de contexte logiciel que nous avons mis en œuvre. Cependant il peut être utile par
moment d’exécuter certaines portions de code avec un privilège système, dans le cas par exemple de certains accès
bas niveau. Pour réaliser cela, nous avons mis en place ce que l’on nomme des appels systèmes.
Pour faire cela, nous utilisons les interruptions logicielles. A priori, on pourrait créer une interruption par appel
système. Ceci pose cependant un gros problème de scalabilité, car ceci nous limite directement dans le nombre
d’appels systèmes possibles.
Dans la pratique, on maintient coté noyau une liste des différents appels systèmes. Quand le handler reçoit un
appel système, il vérifie si celui-ci est dans la liste : si c’est le cas, il l’exécute, sinon il renvoit une erreur.
Coté espace utilisateur, il est conseillé de fournir une interface pour chaque appel système, afin d’en simplifier
l’utilisation.
2.5 Pilotes
2.5.1 Disquette
Notre système d’exploitation est, pour le moment, stocké sur un support de type disquette. Le pilote disquette
est donc primordial pour pouvoir accéder depuis l’OS aux fichiers présents sur ce type de support si l’on veut par
la suite, par exemple, lancer des exécutables. Nous allons donc détailler ici le fonctionnement du pilote permettant
d’utiliser le lecteur de disquette.
Adressage sur les supports de stockage Nous allons tout d’abord commencer par un peu de théorie sur la
manière dont sont adressées les données dans les supports de stockage de type disquette ou disque dur.
Le secteur de donnée et la plus petite unité de données transférable (en lecture ou en écriture) par un média de
stockage. La taille d’un secteur est le plus souvent de 512 octets mais certains supports, comme les disques optiques,
emploient d’autres valeurs comme 1024 octets ou 2048 octets. Un adressage permet de désigner de façon unique
un secteur du média de stockage. Il existe plusieurs types d’adressage de secteur de données dont notamment ceux
décrits si après.
Cylinder/Head/Sector CHS L’adressage en CHS est un moyen d’adresser les secteurs de données stockés
sur une disquette ou un disque dur. Il est basé en fait sur le fonctionnement général de ce type de support et dépend
de la géométrie physique du support en question.
Les données sont physiquement stockées sur un ensemble de plateaux en rotation (un seul pour la disquette).
Les têtes de lecture sont au nombre de 2 par plateau (une de chaque coté), toutes les têtes se déplacent en même
temps suivant le même axe de rotation. Une piste correspond à l’ensemble des secteurs parcourus par une tête à une
position donnée. Un cylindre correspond en fait à l’ensemble des pistes lues par les têtes pour une position donnée.
Voir Figure 8 pour une illustration.
Secteur
Tête
Cylindre
Un secteur est donc adressé de la manière suivante : le numéro du cylindre sur lequel il se trouve C, le numéro
de la tête de lecture qui permet d’accéder à la piste sur laquelle il se trouve H et le numéro du secteur sur cette
piste S.
Ce type d’adressage dépend donc des caractéristiques du support à savoir le nombre de têtes, de cylindres et de
secteurs par piste.
Logical Block Addressing LBA Ce type d’adressage ne dépend pas du média de stockage, en particulier
de sa géométrie physique. L’adresse LBA d’un secteur de données est simplement un numéro unique pris dans
l’intervalle [0..N [, où N est le nombre total de secteurs du support.
Conversion LBA/CHS Comme dit précédemment l’adressage CHS est peu pratique lorsqu’on veut adresser
un secteur de manière générale sur un support sans avoir les informations sur ses caractéristiques. Le LBA est
l’adressage qui est utilisé notamment dans les systèmes de fichiers pour désigner un secteur de donnée sur le
support. Il est donc nécessaire ensuite d’effectuer une conversion (voir Figure 9) pour pouvoir communiquer avec le
média de stockage si c’est un disque dur ou une disquette.
Nous allons maintenant voir de quelle manière le pilote communique avec le contrôleur disquette.
L’intégralité des communications avec le contrôleur se fait en écrivant et en lisant dans des registres, accessibles via
le port I/O.
Voici les registres mis à disposition par le contrôleur :
Les adresses de ces registres sont exprimées relativement à une adresse de base qui est est 03f0h pour le contrôleur
principal.
Nous n’utiliserons en fait que quelques un de ces registres, en particulier :
– Data FIFO : permet d’envoyer des commandes au contrôleur.
– Main status register : permet de récupérer des informations générales sur le status du contrôleur
– Digital output register : contrôle du moteur, activation du DMA et de l’IRQ.
– Configuration control register : définir le débit de transfert.
File de commandes :
Alors que la plupart des registres ne permettent que de configurer ou lire des flags de contrôle, le registre Data
FIFO a un rôle beaucoup plus important dans le fonctionnement du contrôleur. Il permet en effet de transmettre
au contrôleur un certain nombre de commandes, également des paramètre pour ces commandes, mais également de
recevoir des réponses à ces commandes. La documentation fournit un grand nombre de commandes. Notre pilote
étant plutôt minimaliste, nous n’avons utilisé que les commandes suivantes :
La majorité des commandes envoyées au contrôleur mettent un certain délai avant de s’exécuter (à cause
des contraintes matérielles intrinsèques). Afin de savoir quand une commande a bien été traitée, et donc savoir
quand il est possible de récupérer les données dans la FIFO, le contrôleur possède une interruption dédiée (IRQ 6),
qu’il lève dès qu’une commande est exécutée. Quand cette interruption est levée, il est possible de récupérer des
informations supplémentaires via la commande Sense Interrupt, pour savoir par exemple à quelle commande répond
l’interruption levée.
Maintenant que nous savons communiquer avec le contrôleur, nous allons maintenant voir les différentes phases
dans l’utilisation du lecteur disquette.
Initialisation du lecteur :
L’initialisation du lecteur doit réaliser les opérations suivantes :
– Activer l’IRQ.
– Régler les caractéristiques du lecteur via le Configuration Control Register et la commande Specify.
– Calibrer le lecteur (activation du moteur + commande Recalibrate)
Ceci permet d’initialiser le lecteur lui même, mais ce n’est pas encore suffisant dans notre cas. En effet, pour pouvoir
transfert des données vers et depuis la disquette avec un débit respectable, nous allons utiliser un DMA (Direct
Memory Access) dédié au lecteur disquette, appelé ISA DMA. L’initialisation de ce DMA sortant un peu du cadre
de la description du pilote pour lecteur disquette, nous ne la détaillerons pas ici.
lire_secteur( secteur )
si secteur est dans cylindre_actuel
retourner valeur du secteur se trouvant dans le tampon en RAM
sinon
lire_cylindre(cylindre contenant secteur)
retourner valeur du secteur se trouvant dans le tampon en RAM
fin si
fin lire_secteur
Ainsi la lecture de secteurs adjacents en mémoire devient très rapide, puisque cela revient à faire un accès disque,
puis que des accès mémoire.
Lors de l’appui d’une touche sur le clavier, l’IRQ 1 est déclenchée. La routine d’interruption se charge de lire
le code de la touche tapée sur le port 0x60. Le code la touche est alors interprétée en un caractère qui est ensuite
empilé dans le buffer du processus au premier plan.
Le code des touches est appelé ”Scan Code” et il existe trois modes possibles (appelés Set 1, Set 2 et Set 3). Le
set 1 est utilisé par les IBM PC XT et les plus anciens, et le set 2 est utilisé par les IBM PC AT et plus récent. Le
set 3 n’est que rarement utilisé. Pour des raisons de compatibilité, sur tous les PC avec des claviers IBM PC AT
ou PS/2, par défaut l’ordinateur traduit le code set 2 en set 1 sauf si on lui dit de se comporter autrement. Nous
avons donc décidé d’utiliser le set 1.
Lors de l’appui d’une touche, de un à trois octets sont générés. Les scancodes de deux octets commencent par
0xE0 et les scancodes de trois octets commencent par 0xE1. Ceci nous permet donc de savoir s’il y a d’autres octets
à lire avant d’interpréter le code. Lorsque la touche est relâchée, un code similaire est envoyé qui a pour particularité
d’avoir un certain bit à 1 ce qui ajoute la valeur 0x80 au code.
Pour décoder une touche, nous utilisons une table de correspondance pour les caractères qui doivent être placés
dans le buffer du stream stdin du processus au premier plan. Pour les touches modificatrices, nous tenons à jour des
variables pour connaitre l’état actuel du clavier. Cela permet, par exemple de savoir si nous sommes en majuscule
ou en minuscule.
Le fonctionnement de la souris est globalement très proche de celui du clavier, lorsqu’un nouveau paquet est
disponible sur le port 0x60, l’IRQ 12 est lancée. Il s’agit du même port que le clavier, néanmoins, grâce au numéro
d’interruption il est possible de savoir que les données proviennent bien de la souris.
Afin de faire fonctionner la souris il faut la régler, pour cela nous envoyons sur le port 0x64 les codes 0xF6
(Utiliser les options par défaut) et 0xF4 (Activer l’envoi des informations). La souris va donc commencer à envoyer
régulièrement des paquets contenant des informations sur son état.
7 6 5 4 3 2 1 0
overflow overflow bit de bit de Toujours 1 Bouton Bouton Bouton
Y X signe Y signe X milieu Droit Gauche
Table 5 – Paquet 1
Les deux paquets suivants donnent le mouvement effectué par la souris selon les 2 axes depuis la dernière mesure.
Grâce au bit de signe envoyé dans le premier paquet on obtient une valeur allant de -255 à +255.
7-0 7-0
Mouvement X Mouvement Y
Table 6 – Paquets 2 et 3
Parmi les améliorations que l’on pourrait apporter à ce driver souris on peut citer entre autres le support USB.
Actuellement nous ne pouvons utiliser que des souris branchées sur le port PS2, le support des souris USB nécessite
une gestion préalable de la pile USB, or nous n’avons pas implémenté cette pile. Néanmoins, en supposant que nous
ayons cette pile il serait facile de supporter les souris USB. En effet celles-ci émulent le fonctionnement des souris
PS2, des lors, la seule différence entre le driver PS2 et le driver USB est la ligne d’IRQ utilisée, les souris USB
déclenchant une IRQ depuis le bus USB au lieu de déclencher l’IRQ 12. Il serait également bienvenu de supporter
les boutons additionnels ainsi que la molette.
Problèmes rencontrés Après initialisation, le premier paquet envoyé par la souris ne correspond pas à la doc-
umentation que nous avons trouvé, de plus nous n’avons trouvé mention de ce problème nul part. Étant donné que
nous pouvons faire fonctionner la souris sans ce paquet nous avons décidé de l’ignorer tout simplement.
FAT, acronyme anglais de File Allocation Table (table d’allocation de fichiers), est un système de fichiers conçu
par Microsoft. Il fut utilisé notamment sous MS-DOS puis dans la branche 9x de Windows. Les systèmes Windows
actuels, dérivés de la branche NT, utilisent généralement le NTFS pour les disques durs, mais reconnaissent toujours
le FAT car les cartes mémoire des appareils photos numériques ou des baladeurs, de même que leurs mémoires
internes, sont presque toujours au format FAT ainsi que certains supports externes (disques durs USB, clef USB ..).
L’espace mémoire couvert par la partition et divisé en clusters de taille fixe. La taille d’un cluster pour une
partition et choisie au formatage en fonction de la taille de la partition et du type de FAT (FAT12, FAT16 ou
FAT32) la taille minimum étant la taille d’un secteur soit généralement 512 octets.
L’intérêt de diviser la mémoire en clusters et de pouvoir fragmenter l’espace occupé par un fichier ou dossier.
Ainsi un fichier de taille supérieure à un cluster (supérieure à 512o par ex) ne va pas être écrit sur une zone mémoire
continue mais sur plusieurs clusters à différents endroits de la partition. Voir figure 13 pour un exemple.
Cela permet d’optimiser l’utilisation de l’espace mémoire lors d’écritures et d’effacements de fichiers de tailles
différentes. En effet il n’est pas nécessaire de chercher un espace mémoire continue suffisant pour écrire les données
mais il suffit d’utiliser un ensemble de clusters libres, sans se soucier de leur emplacement sur la partition.
L’inconvénient d’un tel système de fichier, mis à part le fait qu’il ne soit pas optimisé pour les petits fichiers
(inférieurs à 512octets), est que les données peuvent devenir très fragmentées. L’inconvénient de la fragmentation
est que cela entraı̂ne des accès successifs à des clusters situés à des endroits éloignés les uns des autres sur le disque
entraı̂nant de nombreux déplacements de têtes qui ralentissent considérablement la lecture ou l’écriture.
Partition FAT Une partition FAT est divisée en plusieurs zones comme illustré en Figure 10. Après le Secteur de
Boot se trouve la File Allocation Table (FAT) dont l’utilisation est décrite à la section suivante, ensuite une copie de
cette même FAT pour une question de sécurité en cas d’altération de la première, puis une zone réservée au répertoire
racine (de taille fixe en FAT12 et FAT16) et enfin une zone de données avec des fichiers et des sous-répertoires.
Partition FAT
Secteur de Boot
Répertoire Racine
Fichiers et Sous-répertoires
Zone réservée
Zone des données
Secteur de Boot Au montage de la partition il est nécessaire de récupérer toutes les informations permettant
de déduire les adresses (LBA du premier secteur) de ces différentes zones. Ces informations sont contenues dans Le
Secteur de Boot décrit Figure 11.
Chaı̂nage des clusters Pour reconstituer un fichier il est nécessaire de lire dans le bon ordre l’ensemble des
clusters sur lesquels il a été écrit. La technique utilisée est un ”chaı̂nage” des clusters qui est obtenu grâce à la File
Allocation Table (FAT) située en début de partition après le secteur de Boot.
La FAT est un tableau de 1 colonne, N lignes où N est le nombre de clusters disponibles. Le numéro de la ligne
où on lit le tableau correspond au numéro du cluster dont on cherche des informations. Par exemple :
Cluster_Suivant = File_Allocation_Table[Cluster_Courant];
Le tableau Figure 12 décrit comment interpréter une valeur contenue dans la FAT. Suivant la valeur on obtient
une information sur l’état du cluster, s’il est vide, utilisé ou inutilisable (réservé ou défectueux). Dans le cas où le
secteur est utilisé, la valeur correspond en fait directement au prochain cluster du fichier. Dans le cas où il n’y a pas
de secteur suivant, des valeurs spéciales sont réservées pour indiquer que le secteur est le dernier du fichier. Voir
Figure 13 pour un exemple de chaı̂nage de clusters.
Fichier 2
Zone réservée
File Allocation Table
1 0xFF0
2 0x003 Cluster 2
3 0x005 Cluster 3
4 0x009 Cluster 4
5 0x006 Cluster 5
6 0xFFF Cluster 6
7 0x000 Cluster 7
8 0x000 Cluster 8
9 0xFFF Cluster 9
Différences entre FAT12/16/32 Ce qui différencie les systèmes de fichiers FAT12, FAT16 et FAT32 est en fait
le nombre de bits sur lesquels sont codées ces valeurs. Cela a un impact direct sur le nombre de clusters que l’on
peut considérer.
– FAT12 : 212 − 18 = 4078 clusters possibles
– FAT16 : 216 − 18 = 65, 518 clusters possibles
– FAT16 : 232 − 18 = 268, 435, 438 clusters possibles
Plus le support de stockage est grand, plus on a intérêt d’utiliser un nombre de clusters important pour réduire
leur taille. Dans le cas de la disquette, le FAT12 permet un nombre suffisant de clusters pour que leur taille soit
ramenée à celle d’un secteur.
LBA d’un cluster On remarque que c’est la zone des données où sont stockés les fichiers et sous répertoires
qui est divisée en clusters et référencée dans la FAT. De plus le premier cluster de cette zone est le cluster No 2.
Car comme indiqué dans la Figure 12 les valeurs 0x0 et 0x1 sont des valeurs réservées dans la FAT. Le LBA de ce
cluster est en fait le LBA de la zone des données qui est déduit des informations situées dans le Secteur de Boot.
Pour retrouver l’adresse (LBA) du cluster sur la partition à partir de son numéro, on effectue l’opération suivante :
En FAT, tout est considéré comme étant un fichier. Ainsi un répertoire n’est rien d’autre qu’un fichier, il peut
être de taille variable et peut être contenu sur plusieurs clusters, mais va être interprété différemment. Un répertoire
contient des blocs de 32 octets appelés entrées. Chacune de ces entrées décrit un fichier contenu dans ce répertoire,
voir Figure 14 pour un descriptif du codage de ces entrées.
Une entrée donne des informations sur le type de fichier (Attributs du fichiers), notamment pour savoir si c’est
un fichier ou un sous-dossier, si c’est un fichier caché, s’il est en lecture seule etc.. Elle donne aussi des informations
sur les dates et heures de création ainsi que du dernier accès. Et enfin, chaque entrée contient le numéro du premier
cluster du fichier, ce qui va permettre d’y accéder, ainsi que sa taille en nombre de clusters.
Attributs Bit : 0 Lecture seule, 1 Fichier caché, 2 Fichier système, 3 Nom du volume, 4 Sous-répertoire, 5 Archive, 6 Device, 7 Inutilisé
Date Bits : 15-9 Année - 1980 (0 = 1980, 127 = 2107), 8-5 Mois (1 = Janvier, 12 = Décembre), 4-0 Jour (1 - 31)
Heure Bits : 15-11 Heures (0-23), 10-5 Minutes (0-59), 4-0 Secondes/2 (0-29)
Process
flags flags
read pointer buffer
read base current octet buf
used ofd
read end current cluster
write pointer current octet
Adresse
write base first cluster
Indice
write end dans file size
buffer base la table extra data
buffer end write()
chain read()
fileno
open file descriptor
FILE
L’application utilise un stream de type FILE pour lire ou écrire. À la création du processus, les streams stdin,
stdout et stderr sont créés. Pour en créer d’autres, il faut utiliser fopen. Ces streams sont appelés ainsi puisqu’ils
2.8 IPC
2.8.1 Sémaphores
Les sémaphores permettent de gérer l’exécution de plusieurs processus en concurrence. Le fonctionnement d’un
sémaphore est le suivant : il s’agit d’une primitive manipulable par deux fonctions, P permet de prendre le sémaphore
et V permet de le relâcher. Un sémaphore contient une valeur entière, cette valeur est décrémentée dès que l’on
prend le sémaphore et incrémentée dès qu’on le relâche. Il est impossible pour un sémaphore de prendre une valeur
négative, la fonction P est par conséquent bloquante le temps que le sémaphore soit relâché.
Afin d’utiliser les sémaphores il faut préalablement les créer avec la fonction sem create, pour cela il faut une
clef qui désigne le sémaphore au niveau du système, le système renvoie alors au processus un identifiant semid afin
de manipuler le sémaphore. Une autre fonction, sem get permet de récupérer un sémaphore créé précédemment.
Enfin, il est possible de supprimer un sémaphore avec la fonction sem del.
L’implémentation des sémaphores repose sur une liste de sémaphores stockée dans le noyau. Lors de l’appel à
la fonction P on décrémente si possible le sémaphore, dans le cas ou c’est impossible la demande est stockée et le
processus est mis en pause. Lors de l’appel à la fonction V on incrémente le sémaphore, si cela débloque un processus
alors on le réveille. Afin de choisir quel processus sera débloqué lorsque le sémaphore est à nouveau disponible les
demandes de sémaphore sont stockées dans une file, les processus obtiennent donc le sémaphore dans l’ordre dans
lequel ils l’ont demandé.
3.2 Makefile
Pour gérer la compilation et l’édition des liens, nous avons utilisé gcc, ld et surtout make pour automatiser la
génération de l’image. Initialement, nous éditions les fichiers Makefile manuellement ce qui était un travail long et
parfois incomplet. Au début cela n’était pas un gros problème mais de temps en temps, nous avons eu des bugs
incompréhensibles qui nous ont fait perdre beaucoup de temps inutilement. Nous nous sommes rendu compte que
faire un make clean pouvait résoudre le problème, c’est alors que nous avons compris que le problème venait de
dépendances manquantes.
Nous avons alors réfléchi à comment nous pourrions rendre la compilation plus fiable. Nous avons tout d’abord
fait la liste des outils à notre disposition (Make, SCons, CMake, Automake...). Finalement Make avait pour avantage
d’être disponible sur beaucoup plus de machines que les autres outils et de plus il était déjà en place dans notre projet.
Alors nous avons décidé de voir comment pourrait-on améliorer nos Makefile. C’est là que nous avons découvert
makedepend qui est capable de déterminer les dépendances et de modifier le fichier Makefile en conséquent. Plus
tard nous avons aussi découvert que gcc est capable de faire exactement la même chose mais nous avions déjà
modifié nos Makefile pour utiliser makedepend.
Depuis, nous avons moins de problèmes liés à la compilation. Nous avons aussi profité de ces modifications pour
générer des sorties plus lisibles pour mieux voir les erreurs et avertissements lors de la compilation.
3.3 Préemptibilité
Vers la fin du projet, un fois l’ordonnancement mis en place et avec l’arrivée de l’utilisation du système de fichier
FAT, nous avons rencontré des problèmes assez étranges. Par exemple, certains accès à la disquette sont devenus
totalement bloquants (programme bloqué et plus d’ordonnancement). En analysant l’origine du bug en détail, voici
les étapes qui menaient à ce type de blocage :
2. Le secteur à lire ne ce situe pas sur le cylindre courant, il faut donc lire un autre cylindre.
3. Le pilote du lecteur disquette envois une requète de type SEEK pour déplacer la tête de lecture.
4. Le pilote attend une interruption pour confirmer la bonne exécution de la commande - BLOCAGE.
La solution que nous avons trouvé pour cela conciste à ne pas utiliser des interruptions classiques pour effectuer
nos appels systèmes. Nous utilisons donc à la place ce que nous appelons une trap gate. La trap gate est un
mécanisme qui fonctionne de la même manière que l’interruption (son descripteur se trouve également dans l’IDT,
et n’est different de celui des interruption qu’à un flag près). Quand une trap gate est appelée, c’est donc le handler
associé qui reprend la main, mais au le flag d’interruption présent dans le registre EFLAGS reste inchangé, et il est
donc possible d’interromptre un handler de trap gate, et le problème se trouve ainsi réglé.
3.4 Malloc
Une chose est primordiale avant de se lancer dans la conception et l’implémentation d’un module de gestion
dynamique de la mémoire, c’est de bien comprendre les différents mécanismes de gestion de la mémoire au niveau
du processeur : différents types d’adresses (virtuelles, linéaires et physiques), les liens entre ces types et la façon
dont le processeur et la MMU décodent ces adresses (répertoire de pages, registre CR3), et enfin comment effectuer
un mapping mémoire entre adresse virtuelle et physique. Effectuer ce mapping mémoire fut le premier problème
rencontré lors de la création de la gestion dynamique de la mémoire.
Une fois un mapping effectué entre une page physique et une page virtuelle, il faut aussi garder cette information
en mémoire, savoir quelle page virtuelle est allouée, quelles sont celles qui ne sont pas encore mappées. Ici, nous
avons donc eu un second problème, qui est de savoir où stocker ces informations. Comme nous ne pouvons pas faire
(encore) d’allocation dynamique, il semble difficile d’avoir une liste permettant de connaı̂tre les pages utilisées. On
pourrait avoir un tableau statique pour l’ensemble des pages, mais ce système semble d’emblée peu efficace. Comme
vu précédemment dans la section mémoire, nous avons opté pour un système de listes doublements chaı̂nées, en
plaçant chaque nœud au début des blocs de pages.
Enfin, un dernier problème s’est posé. C’est qu’il est assez difficile de débugger des problèmes de mémoire
virtuelle puisque lorsque le processeur lance des exceptions de défaut de pages, ces défauts de pages se produisent
après l’allocation, pendant l’utilisation des zones mémoires fraı̂chement allouées. Il est donc difficile de savoir si
c’est le module d’allocation dynamique qui est en cause, ou s’il s’agit d’autre chose.
4.3 IPC
Outre les sémaphores, il existe de nombreux moyens de communication entre processus que nous n’avons pas eu
le temps d’implémenter, néanmoins, nous avons pris le temps de réfléchir à comment nous pourrions le faire.
Pipes Les pipes sont des canaux de communication bidirectionnels entre deux processus, ces canaux fonctionnent
comme une association de deux files, une pour chaque sens de communication, les processus étant libres de lire ou
d’écrire dans n’importe lequel de ces ”tuyaux”. Afin d’implémenter les pipes, nous pourrions faire appel aux flux
qui ont déjà été développés, en stockant deux flux dans l’espace noyau et en permettant d’y insérer/lire des données
au travers des appels systèmes. Une autre approche, utilisant indirectement les flux et plus proche de l’esprit Unix
est d’utiliser des fichiers pour implémenter les pipes.
Mémoire partagée Le fonctionnement de la mémoire partagée repose sur la possibilité d’autoriser plusieurs
processus à écrire dans la même zone mémoire, ceci est relativement facile à réaliser en modifiant le répertoire de
page des deux processus communicants. Le principe consiste à mapper une même zone mémoire physique dans les
repertoires de pages respectifs des processus. Cela n’implique pas d’avoir des adresses virtuelles identiques mais
seulement de pointer vers la même adresse physique. On peut aussi régler les droits pour faire en sorte qu’un seul
des deux processus puisse avoir le droit d’écriture. Actuellement il nous est impossible d’implémenter la mémoire
partagée pour la simple raison que nous n’avons pas fini d’activer la pagination.
include libc apps utils kernel system clock pci filesystem drivers
Utilisation de gdb Voici une liste des commandes utiles pour manipuler gdb.
– c : reprend l’exécution
– s : avance d’un pas l’exécution
– si : avance d’une instruction assembleur
– b location : place un point d’arrêt (breakpoint), location peut être le nom d’une fonction, un numéro de ligne
ou fichier :numéro ligne
– bt : affiche la pile, attention étant donné que nous modifions la pile en fonction de nos besoins le résultat peut
parfois s’avérer surprenant.
– frame num : passe à la frame num, num est celui donné par la commande bt, ceci permet de changer le
contexte pour parcourir les différents niveaux d’appels de fonction
– layout [src-asm-split-cmd] : permet de diviser la fenêtre de gdb en deux ou trois parties pour afficher en plus
de la zone de commande, le code source, le code assembleur ou les deux
– focus [src-asm-cmd] : utile lorsque l’on utilise layout, cette commande permet de changer le focus des flèches
directionnelles.
La figure suivante illustre une session de déboguage en mode layout src, avec un point d’arrêt sur la fonction
cmain. On peut voir que la ligne courante est surlignée, une flèche indique également l’instruction actuelle. Le point
d’arrêt est indiqué par un B dans la marge, la zone de commandes est en bas de l’écran. Pour débuter une session
de débogage il est conseillé le placer un point d’arrêt sur cmain puis d’appuyer sur c pour lancer grub, en effet qemu
ne lance pas l’exécution automatiquement au démarrage.