Projet système PC : 2015 - KLINGELSCHMIDT Elise, LE Francois

De Ensiwiki
Aller à : navigation, rechercher
OS Sys'thematic
Ecran de chargement de notre OS
Projet Système PC 2015

Développeurs Elise Klingelschmidt
François Lê

Présentation

Bienvenue sur la page de présentation de notre système d'exploitation " Sys'thematic " qui a été développé dans le cadre du Projet Système, que nous avons choisi comme Projet de spécialité 2A.
Cette page a pour but de présenter succinctement nos réalisations ainsi que les difficultés que nous avons rencontrées (et surmontées !) pendant le projet.

Binaire de l'application

Avant toute chose, voici le binaire de notre noyau qui est bootable sur machine nue (IA-32), Qemu et Bochs.
Instructions pour créer une clé bootable pour machine nue :[1].

Équipe

Elise Klingelschmidt (filière ISI)
François Lê (filière ISI)

Le projet système

Le projet système a pour objectif l'implémentation d'un système d'exploitation bootable sur machine nue d'architecture Intel i386.
Il fait suite aux cours de Pratique du Système [2] suivis pendant le premier semestre de deuxième année.
La page d'accueil du projet est ici : [3], et les spécifications techniques sont ici : [4].

La conduite du projet

Nous avons réalisé l'ensemble des sept phases décrites dans la page roadmap [5]. Etant un groupe de deux, les extensions étaient optionelles mais nous avons pû en réaliser deux : amélioration du shell et système de fichiers dans la mémoire physique : voir les extensions.

Nous avons largement testé notre implémentation à l'aide de nos tests et des tests fournis par les enseignants. Nous avons fait le choix de tester exclusivement sur Qemu et Bochs jusqu'à la fin de la phase 7. A la fin de l'implémentation des sept phases obligatoires, nous avons relancé tous les tests sur machine nue. Cela a été réalisé grâce à notre commande shell test_all et la phase d'expérimentation sur machine nue s'est déroulée sans problème. En ce qui concerne la validation des extensions, nous testons d'abord sur Qemu puis sur machine réelle. La encore, il n'y avait pas de différence entre le comportement sur Qemu et le comportement sur machine nue : tout fonctionne correctement ! :-)

Organisation

Nous n'avons pas eu de difficulté particulière liée à la gestion d'équipe. Nous avons fait le choix de travailler ensemble dans des salles de l'école la plupart du temps, principalement dans le but d'avoir une vision globale du projet, mais aussi pour pouvoir réfléchir ensemble sur les questions délicates ou sur les structures de données à mettre en place (entre autres : processus, buffer du clavier, système de fichier, etc).

Pour le partage de fichiers, nous avons utilisé le gestionnaire de version Git.
Nous avons également utilisé Trello [6], un gestionnaire de projet en ligne. Cet outil nous a été très utile pour noter toutes les erreurs, améliorations, idées et choses à faire à un seul endroit. Cet outil nous a aussi permis de nous répartir le travail le soir et le week-end et de visualiser notre avancement au sein même d'une phase.

Trello de Sys'thematic

Planning

Nous avons réalisé un planning prévisionnel au début du projet grâce aux informations des années précédentes. Ce planning s'est avéré très proche de la réalité.
Voici le planning que nous avons mis en place :

Planning et difficulté des phases

Phase 1 : Difficulté 1, 1/2 journée

Phase 2 : Difficulté 1, 1/2 journée

Phase 3 : Difficulté 2, 2 jours

Phase 4 : Difficulté 3, 3 jours

Tests  : 1 jour

Phase 5 : Difficulté 5, 7 jours

Tests  : 1/2 journée

Phase 6 : Difficulté 3, 1 jours

Phase 7 : Difficulté 3, 2 jours

Les différentes phases du projet

Le projet est découpé en sept phases qui correspondent chacune à un aspect du système. Ces phases, de difficulté et de longueur inégales, sont dépendantes des phases précédentes et il est donc conseillé de respecter l'ordre dans lequel les effectuer. Ces phases sont décrites de façon détaillée dans le document de l'ensiwiki suivant : [7].

Phase 1 : Gestion de l'affichage à l'écran

On affiche des caractères à l'écran en écrivant directement dans une partie de la mémoire couplée avec la carte graphique, ce qui permet d'afficher des traces d'exécution du noyau dès le début du projet.

Difficultés rencontrées : Cette phase ne nous a pas posé de difficultés, nous avons réutilisé le travail fait en Pratique du Système.

Phase 2 : Mise en place de la notion de processus et changement de contexte

Dans cette phase, on met en place la notion de processus : c'est la structure de données qui permet au noyau de pouvoir reprendre l'exécution d'un programme qui a été interrompue.
C'est également dans cette phase que sont mises en place les interruptions horloge qui vont déclencher le changement de contexte entre les processus.

Difficultés rencontrées : Cette phase ne nous a pas posé de difficultés, le travail demandé ressemble beaucoup à celui réalisé en Pratique du Système.

Conseil : La structure du processus sera amenée à évoluer, ne pas chercher à tout gérer dans cette phase.

Phase 3 : Cycle de vie des processus

Dans cette phase, on s'intéresse au cycle de vie des processus :

1) Ordonnancement
Les processus doivent maintenant être ordonnancés suivant leur priorité. Nous avons utilisé pour cela les primitives de files de priorité fournies dans les sources de départ.
L'ordonnancement des processus peut avoir lieu dans trois cas :

  • soit une interruption horloge a eu lieu : le nouvel élu est choisi parmi les processus les plus prioritaires selon un ordre FIFO.
  • soit un processus auparavant inactif (endormi, en attente) redevient actif : s'il est strictement plus prioritaire que le processus actif courant, on fait appel à la fonction d'ordonnancement qui va élire ce processus comme nouveau processus actif.
  • soit le processus actif se bloque ou s'endort : on fait appel à la fonction d'ordonnancement qui élira le prochain processus actif parmi les plus prioritaires.


2) Création dynamique
A partir de cette phase, les processus peuvent être créés de façon dynamique, grâce à un appel à start : leurs structures (contexte d'exécution, pile, ...) doivent donc être allouées au moment de la création et libérées au moment de la terminaison.

3) Terminaison
Il y a trois façon pour un processus de se terminer :

  • soit en arrivant à la fin de sa fonction avec un return
  • soit en faisant un appel explicite à la primitive exit
  • soit en étant tué par un autre processus (primitive kill)

Lorsque le processus qui se termine est actif, on ne peut pas libérer immédiatement ses structures (contexte d'exécution, pile, ...) car on a besoin de pouvoir l'ordonnancer une dernière fois afin de pouvoir passer la main à un autre processus. Pour cela, on introduit un état MOURANT : les processus mourants ne peuvent plus être actifs et seront libérés au prochain appel à la fonction d'ordonnancement.

4) Filiation
Les processus sont reliés entre eux par des liens filiaux. Chaque processus connait son père ainsi que la liste de ses fils.
Un processus père peut attendre la terminaison de son fils grâce à un appel à la primitive waitpid. Il est donc nécessaire de pouvoir récupérer la valeur de terminaison du fils.
Pour cela, lorsqu'un fils se termine, il devient zombie et on sauvegarde sa valeur de retour dans sa structure de processus.
Lorsque le père va faire un waitpid de ce fils zombie, il pourra lire sa valeur de terminaison avant de le libérer.
On ajoute un champ à la structure du processus précisant le pid bloquant, c'est à dire le pid du fils que le père attend. Cela permet à un fils qui se termine de réveiller son père en attente de sa terminaison.

Exemple en image :
Etat processus psys2015.pngFiliationProcessus psys2015.png

Difficultés rencontrées : Cette phase n'est pas particulièrement complexe, mais nécessite de bien comprendre le comportement attendu des primitives à implémenter.

Conseils : On peut commencer par implémenter et tester les trois premiers points avant de se lancer dans la filiation.
Il est également conseillé de vérifier que votre implémentation de la filiation est cohérente avec celle décrite dans les spécifications : pas d'appels système sur un processus zombie, etc.

Phase 4 : Files de messages et endormissement

L'endormissement des processus ne comporte pas de grandes difficultés : les processus sont stockés dans une file de priorité ordonnée en fonction de leur heure de réveil.

La plus grande partie de cette phase concerne l'implémentation des files de messages. Une file de message est comme une boite aux lettres dans laquelle un processus peut déposer ou récupérer un message.
L'intérêt des files de messages est la possibilité de synchronisation entre processus : on peut notamment exploiter le fait qu'un processus lisant dans une boite vide se retrouve bloqué jusqu'à ce qu'un autre processus dépose un message et de même si un processus veut déposer un message dans une boîte pleine.
Les files de messages constituent également un premier moyen de communication entre processus.

Difficultés rencontrées : La synchronisation des échanges dans une file mérite qu'on y prête attention :
En effet, si un processus est bloqué sur une file vide en attente d'un message, le prochain message reçu doit lui être transmis directement avant d'être débloqué.
De même, si un processus est bloqué sur une file pleine en attente d'un emplacement où écrire, c'est le premier processus qui libère un emplacement dans la file qui va y écrire le message à sa place.
On utilise la structure des processus pour stocker les informations nécessaires à cette synchronisation.

Conseil : A la fin de cette phase, il est primordial de vérifier que tous les tests fournis pouvant être exécutés à ce stade ont bien le comportement attendu.

Phase 5 : Séparation mémoire noyau/utilisateur et mémoire virtuelle

Cette phase est la plus longue (en temps, pas en quantité de code) et la plus difficile du projet. Elle nécessite une bonne compréhension de ce qui est attendu.
Il s'agit en effet de mettre en place deux notions fondamentales d'un système d'exploitation : la séparation mémoire et la mémoire virtuelle.
Nous avons suivi l'ordre suivant pour implémenter cette phase :

  • Mise en place de la pagination :

Création d'un allocateur mémoire sous la forme d'une liste chaînée de pages vides.
Chaque page fait 4Ko et l'allocateur mémoire travaille sur tout l'espace de mémoire physique libre (entre 64MB et 256MB). [8]

  • Création d'une fonction de mapping mémoire qui mappe dans une table des pages une adresse virtuelle avec une adresse physique. [9] [10]
  • Création d'une table des pages par processus : chaque processus possède sa propre table des pages et le registre cr3 (qui pointe sur la table des pages du processus actif) est mis à jour à chaque changement de contexte. On sauvegarde également une copie de cr3 dans la TSS (à l'adresse 28(TSS) ). La table des pages est initialisée avec un mapping identitaire sur 0 - 256 MB (comprendre les grandes lignes du fichier kernel/boot/early_mm.c peut aider... [11]).
  • Création d'une table de hashage associant à chaque processus son code (la table de hashage permet de ne pas parcourir la table symbols_table à chaque création de processus). [12]
  • Ajout d'une fonction start permettant la création d'un processus utilisateur : à partir de ce moment, les processus possèdent deux piles, une pile kernel comme précédemment et une pile user mappée (dans notre cas) pour que l'adresse virtuelle de la tête de pile se situe à 2GB - 4B. Nous avons fait le choix de limiter la taille de la pile user à 2 pages. Le code du processus utilisateur est retrouvé grâce à la table de hashage citée ci-dessus, copié et mappé à l'adresse 1GB.
  • Le sommet de la pile kernel du processus courant doit être sauvegardé dans la TSS lors du passage du mode kernel vers le mode user (à l'adresse 4(TSS) ). Attention, il s'agit bien du sommet de pile et non pas du sommet courant (registre esp) de la pile.
  • Passage vers le mode utilisateur : pour cela, on appelle une fonction qui simule un retour d'interruption iret [13] en empilant, dans l'ordre, le registre ss (0x4b), l'adresse de la pile user (2GB - 4B pour nous), les flags (registre eflags de valeur 0x202), le registre cs (0x43) et la prochaine instruction à exécuter (registre eip : début du code user : adresse 1GB). Ces valeurs seront dépilées par iret. On positionne également les registres ds, es, fs et gs avec les bonnes valeurs (ici, on veut passer en mode user et ces registres valent donc 0x4b). [14] Cette fonction se termine par un appel à iret.
  • Une fois le premier passage vers le mode utilisateur réussi, il faut pouvoir revenir en mode kernel grâce à un appel système. Le mode user dispose d'une bibliothèque d'appels système qui ont tous le même comportement : sauvegarde des registres pouvant être écrasés, positionnement de ces registres aux valeurs souhaitées, appel à l'interruption int 49, restauration des registres. Nous avons choisi d'utiliser le registre eax pour sauvegarder le numéro de l'appel système et les registres ebx, ecx, edx, esi et edi pour sauvegarder les arguments de la fonction. Un appel système est donc limité à 5 arguments mais cette limitation n'est pas bloquante.
  • Du côté kernel, l'interruption int 49 doit être rattrapée par un traitant d'interruption. Ce traitant d'interruption réalise un switch sur le registre eax pour savoir quel est l'appel système demandé. Une fois cet appel système identifié, les arguments de la fonction sont vérifiés (voir conseils) et empilés, puis la fonction est appelée (instruction call). Attention, l'initialisation du traitant d'interruption diffère légèrement du traitant horloge (les flags à appliquer sont légèrement différents [15]).
  • Lorsque le processus passe sans problème du mode utilisateur ou mode superviseur, celui-ci doit pouvoir se terminer correctement avec la bonne valeur de retour. Pour notre part, nous avons choisi de récupérer sa valeur de retour dans la fonction _start (fichier user/lib/crt0.c) puis de faire un appel système à la fonction exit.
  • Mise en place des primitives de mémoire partagée afin de permettre aux processus de communiquer entre eux. Nous avons choisi de limiter le nombre de pages pouvant être partagées et de les mapper toujours à la même adresse virtuelle (entre 3GB et 3GB + x*4Ko si x est le nombre de pages partagées).
  • Écriture d'un gestionnaire de défaut de page. Il suffit d'initialiser l'entrée 14 de la table des vecteurs d'interruption avec l'adresse d'un traitant à écrire. Dans un premier temps, ce traitant peut se contenter de tuer sans sommation le processus actif (car c'est lui qui a provoqué le défaut de page). Dans une seconde version, on peut essayer de récupérer les informations fournies par le processeur pour analyser les circonstances de l'erreur : cr2 contient l'adresse que le processus courant a tenté d'accéder et le sommet de pile contient un code d'erreur décrit ici [16]. Une fois ceci fait un défaut de page ne plantera plus tout le système mais tuera seulement le processus actif. C'est pour cela qu'il est conseillé d'initialiser la table des interruptions au dernier moment : le noyau n'a pas le droit de causer des défauts de page !


Difficultés rencontrées :

  • La table de hashage semblait bien initialisée à la création mais ne l'était plus lors du premier passage en mode utilisateur (on ne retrouvait pas le bon code utilisateur). Il s'agissait d'une erreur d'allocation.
  • Dans le code assembleur utilisé, toutes les constantes doivent être précédées d'un $ !! (on écrit $0x4b par exemple)[17]. Le compilateur ne signale pas cette erreur et il nous a été très difficile de remonter jusqu'à elle !
  • Lors d'un appel système du côté utilisateur, en empilant les registres pouvant être écrasés, on décale par la même occasion les arguments de la fonction. Nous avions de prime abord mal récupéré ces arguments au moment de les sauvegarder dans des registres avant un appel à int 49.
  • Les registres de rang (ds, es, fs et gs) doivent TOUJOURS être positionnés avec la bonne valeur (0x4b en mode user, 0x18 en mode kernel). Ils ne sont pas modifiés automatiquement par les appels à int 49 ou à iret contrairement à cs et ss. Il faut donc les modifier manuellement dans CHAQUE traitant d'interruption (même le traitant horloge !).
  • Sur une machine personnel où le compilateur est gcc-4.9.1, nous nous sommes aperçus après de longues recherches que le code généré était cassé!![18][19] Il provoquait une exception 3 (interruption utilisée comme point d'arrêt par les débuggers) qui plantait le système. La solution a été de changer de version de compilateur en utilisant à la place gcc-4.7 ou gcc-4.8.

Conseils :

  • La table des pages et toutes les pages mappées dedans doivent être alignées sur 4Ko (utilisez votre allocateur !)
  • Ne pas oublier de tenir compte du sens de la pile : le sommet de pile est l'adresse la plus haute de la pile. Nous vous conseillons de dessiner votre pile (plusieurs fois) !
  • Si votre appel système doit vous retourner une valeur, ne pas dépiler le registre eax après le retour de int 49 !
  • Rajouter le flag -Wno-name dans les CFLAGS du Makefile fourni afin d'éviter des erreurs de compilation concernant la syntaxe de la fonction main.
  • Vérifiez les arguments (adresses) lors des appels système afin d'empêcher un utilisateur d'écrire n'importe où!
  • Informations sur la gestion des privilèges et les appels systèmes : [20].
  • Informations sur les codes erreurs : [21].

Phase 6 : Pilote pour clavier

Cette phase a pour but l'écriture d'un pilote de clavier. Cela consiste en fait à récupérer et à traiter les interruptions matérielles envoyées par le clavier au processeur qui délègue le traitement au système d'exploitation. Nous avons choisi d'utiliser un unique buffer de taille assez grande.

Difficultés rencontrées : Il n'y a pas particulièrement de difficulté dans cette partie, la notion d'interruption étant particulièrement bien acquise à ce stade. La gestion du buffer devient plus délicate si on décide de gérer les flèches gauches/droites (dans le but de faciliter l'édition de commande pour le shell).

Conseil : Faire une implémentation du pilote aussi simple que possible pour pouvoir ensuite l'améliorer de façon cohérente avec l'implémentation et les besoins du shell.

Phase 7 : Interpréteur de commande

Un interpréteur de commande ne fait pas vraiment partie du noyau. Il est plus à voir comme un processus utilisateur permettant d'interagir avec le noyau. Néanmoins cette partie est justifiée par le fait qu'un interpréteur de commande permet d'utiliser et de mettre en évidence le comportement du système de façon interactive : le noyau ne se contente plus d'exécuter du code pré-défini mais il fait ce que l'utilisateur lui demande.

Voici une capture d'écran après avoir lancé la commande help (commande générale d'information sur les commandes gérées par le shell) :

Commandes disponibles sur l'OS Sys'thematic

Voici une capture d'écran présentant le résultat d'une commande ps après quelques actions sur notre shell.

Commande ps


Difficultés rencontrées : La principale difficulté était la gestion de l'echo (pour ne pas réécrire sur le prompt) et de l'effacement (pour ne pas pouvoir effacer le prompt du shell par exemple).

Conseil : Dans un premier temps, se focaliser sur un shell de base sans extension. Il peut être intéressant de doter le shell d'un historique de commandes pour rendre plus agréable l'édition de commande.

Les extensions réalisées

Shell amélioré

  • Gestion des flèches gauche/droite pour faciliter l'édition de commandes. L'édition est possible par remplacement (et non par insertion).
  • Gestion des flèches haut/bas pour naviguer dans un historique de commandes.
  • Possibilité de stopper un processus endormi ou bloqué sur une E/S avec ctrl + C.
  • Raccourci ctrl + L pour effacer l'écran.

Difficultés :
La gestion des flèches directionnelles nous a pris un peu de temps et de très nombreux tests avant d'être opérationnelle.

Limites :

  • L'effacement ou le déplacement gauche/droite du curseur ne se font que sur la ligne courante.
  • Les processus actifs ne peuvent pas être tués par un ctrl + C (dans le cas d'une boucle infinie par exemple).

Système de fichier

Nous avons choisi d'implémenter notre propre système de fichier dans la RAM. Cela évite de devoir se plonger une nouvelle fois dans une documentation complexe pour reprendre un système de fichier existant et cela évite également d'avoir à gérer les interactions avec un vrai disque. En contrepartie, notre système de fichier n'est pas persistant (il est réinitialisé à chaque fois que l'on éteint ou que l'on redémarre le système). Il permet en revanche de simuler la gestion de fichiers comme sur un système Linux, à l'exception de la gestion des répertoires et des droits des fichiers. Nous avons fait ce choix en raison du temps limité qu'il nous restait pour implémenter le système de fichiers.

Nous avons choisi de limiter le nombre maximal de fichiers à 1022 et la somme des tailles de tous les fichiers ne peut être supérieure à 64 Mo.
Ces contraintes étant respectées, la taille d'un fichier peut être quelconque.

Voici une liste des commandes shell relatives à la gestion de fichier :

Commandes disponibles sur l'OS Sys'thematic

Puis un exemple de manipulation de fichier :

Manipulation d'un fichier

Nous avons créé un petit éditeur de fichier qui permet d'ajouter plusieurs lignes à la fin d'un fichier (il crée également le fichier s'il n'existe pas), en voici un exemple d'utilisation :

Petit éditeur de texte