Projet système PC : 2015 - HODY Shirley, PHILIPPON Gautier, SCHER Bastien

De Ensiwiki
Révision de 12 juin 2015 à 09:02 par Scherb (discussion | contributions) (Système de fichier FAT)

(diff) ← Version précédente | Voir la version courante (diff) | Version suivante → (diff)
Aller à : navigation, rechercher


TetanOS
TetanOS logo.png
Projet Système PC 2015

Développeurs Shirley Hody
Gautier Philippon
Bastien Scher

Le Jeu - Vous avez perdu...


Cette page présente la réalisation de notre système d'exploitation TetanOS, de sa création à son aboutissement, ainsi que les fonctionnalités offertes par celui-ci.

Présentation générale

Le projet

Nous avons choisi le projet système, qui consiste en la réalisation d'un système d'exploitation, c'est à dire d'une interface entre l'utilisateur (ou le développeur) et le matériel, qui s'occupe de gérer les différents périphériques et de mettre à disposition des programmes afin d'exploiter l'ordinateur. C'est un projet très pointu qui requière la lecture d'une documentation complexe et la compréhension des ordinateurs au plus bas niveau. Pour tester ce système sur un ordinateur, installez Grub sur une clef USB, et copiez le kernel de TetanOS dessus.

Notre équipe

Notre équipe est composée de 3 étudiants :

Les encadrants qui nous ont accompagnés pour la réalisation de ce projet sont Gaëtan Harter et Grégory Mounié.

Motivations

Nous venons tous les trois de la filière SLE et le projet système permet de mettre en application tous les compétences acquises au cours de ces deux années sur les systèmes d'exploitation. Le but est de réaliser un projet de plus grande envergure que les TP et les petits projets, qui regroupe ceux-ci en les améliorant. Le but est donc de concevoir un système stable qui regroupe plusieurs fonctionnalités.

Nous avions également très envie de voir des écrans bleus.

La gestion du projet

Les phases différentes phases du projet

Le projet était initialement proposé en 7 étapes, composant le cahier des charges de base du projet, suivies d'une phase d'extension. Ce déroulement séquentiel et logique des phases laisse toutefois la liberté sur les choix d'implémentation et la répartition du travail. Ces 7 étapes sont les suivantes :

  • Phase 1 : prendre en main l'environnement de développement et gérer l'affichage à l'écran,
  • Phase 2 : gérer la notion de processus, et le changement de contexte entre deux processus,
  • Phase 3 : gérer l'ordonnancement, la création dynamique, la terminaison et la filiation des processus,
  • Phase 4 : gérer la communication et l'endormissement des processus,
  • Phase 5 : séparer les espaces mémoires du noyau et des processus en utilisant la mémoire virtuelle, et ajouter le mode utilisateur,
  • Phase 6 : développer un pilote de console,
  • Phase 7 : développer un interprète de commandes, shell.

Le planning prévisionnel

Dans le cadre du suivi SCHEME nous avons effectué une estimation, au début du projet, du temps qui nous serait nécessaire à chaque phase, compte tenu de nos connaissances sur le sujet.

Phase Temps estimé
Phase 1 1 jour
Phase 2 1 jour
Phase 3 1 jour
Phase 4 2 jours
Phase 5 4 jours
Phase 6 1 jour
Phase 7 2 jours

En gardant des horaires souples et nos week ends intacts, nous avions décidé de consacrer les 15 derniers jours à la réalisation d'extensions (un gestionnaire de fichier entre autre).

Le planning effectif et la répartition des tâches

Finalement, après avoir buté sur quelques problèmes, certaines phases ont duré plus longtemps que d'autres, raccourcissant le temps dédié aux extensions et nous empêchant donc de bien finir celles-ci. Le planning suivant, réalisé grâce à l'outil Tom's planner, montre la réalisation du système au cours des 4 semaines de projet.


Tetanos planning.png


La plupart des tâches incluent une longue période de tests et débuggage, qui ne sont pas détaillées ici. C'est pourquoi certaines tâches paraissent très longues. Nous n'avons au final eu qu'une seule semaine pour réaliser des extensions (en plus de la préparation du rendu).

La réalisation du projet

La gestion de l'écran

Pour cette étape (phase 1), nous avons repris le travail effectué en Pratique du Système. Si vous n'avez pas réalisé ces TPs, nous vous recommandons vivement de le faire. En effet cette étape est très simple et ne demande aucune adaptation du code déjà réalisé.

Les processus

La structure des processus est principalement reprise de la Pratique du Système avec cependant quelques ajouts pour l'ordonnancement, la gestion de priorité, les queues d'attente et les nouveaux états des processus. Ces états peuvent être :

  • Ready (activable)
  • Running (actif)
  • Waiting_MSG (bloqué sur file, vide ou pleine)
  • Waiting_IO (bloqué en attente de saisie clavier)
  • Waiting_clock (en attente de l'horloge)
  • Waiting_child (en attente d'un processus fils)
  • Dead (mort)
  • Zombie (mort dont le parent est toujours vivant)

Les processus (phase 2) sont conservés dans un tableau, et sont limités à un nombre prédéfini par une constante NB_PROC (pas d'allocation dynamique). Les emplacements libres (état "DEAD") sont conservés grâce à une liste chaînée, un attribut contenant le numéro de la prochaine case libre.

Un certain nombre d'opérations ont été définies sur les processus :

  • la création (dynamique et statique),
  • le changement de processus (context_switch)
  • l'ordonnancement(phase 3) :

Les processus ont une priorité de 1 à 256 (le plus élevé étant le plus prioritaire). L'ordonnanceur est appelé à chaque interruption d'horloge, le temps processeur est partagé entre chaque processus de même priorité. Cette politique "imposée" dans le cahier des charges peut introduire un problème de famine pour des processus de priorité inférieure.

  • la terminaison d'un processus (fin d'exécution, kill, sortie forcée exit),
  • la filiation des processus, avec la gestion des processus enfants,
  • l'endormissement d'un processus (phase 4) : attente de l'horloge ou d'un fils.

Nous gérons un processus spécifique, côté noyau, de priorité minimale appelé Idle, qui est ajouté dès le départ dans la queue de processus en état Ready.

La communication inter-processus

La gestion de la communication inter-processus (phase 4) a été réalisée via des files de messages et la mémoire partagée.

Les files de messages

Les files de messages illustrent le modèle producteur/consommateur et permettent à plusieurs processus de s'échanger des messages (en l'occurrence des valeurs entières ici).

C'est une étape simple, à condition de lire et de suivre soigneusement la spécification (ou vous risquez de passer de nombreuses heures à modifier et débugger votre code). Nous stockons simplement un nombre maximal de files dans un tableau, en gérant une liste de files libres, comme pour les processus. Nous utilisons également la structure des processus, en la modifiant au besoin, afin de conserver des informations relatives à ces listes. Une erreur commise, nous ayant fait perdre beaucoup de temps, a été de ne pas correctement suivre la spécification (notamment la notion d'accès immédiat aux données). Les algorithmes de dépôt, réception des messages, et blocage/déblocage des processus ont été modifiés à de nombreuses reprises alors que cela se fait très simplement.

La mémoire partagée

La mémoire partagée consiste à allouer des pages mémoire et à les mapper dans l'espace mémoire de tous les processus qui le souhaitent. Ils peuvent ainsi s'échanger des informations directement via la RAM.

Lorsque les primitives d'allocation et de mapping sont déjà implémentées, cette partie est très facile. Il suffit de garder une table de hachage des pages partagées avec leur compteur de références, et de se souvenir des pages mappées pour chaque processus. Il faut aussi décider d'un endroit en mémoire où mapper les pages : nous avons décidé de les mettre juste avant la pile et d'aller vers les adresses basses afin d'en avoir le plus possible (profitant du fait que la pile a une taille fixe dans le cahier des charges initial).

Attention à la gestion des clés dans la table de hachage : les noms des segments étant passés depuis l'espace utilisateur, il faut veiller à les copier en mémoire noyau avant de les insérer, sinon les adresses vont devenir incohérentes d'un processus à l'autre.

Les appels systèmes

Les appels systèmes ont été implémentés lors de la phase 5. Les appels systèmes fournissent une bibliothèque qui fait le lien entre les appels de fonctions côté user et l'exécution de ces fonctions système en mode kernel. Le saut et le changement de privilège s'effectuent via l'interruption 49, avec le numéro et les paramètres passés via les registres.

Concrètement, pour chaque appel système, il s'agit de lui allouer un numéro (placé dans une table de defines accessible à la fois côté kernel et côté user), de créer la fonction dans la bibliothèque utilisateur (qui en pratique ne fera qu'appeler une fonction syscall en assembleur avec les paramètres), et de réaliser l'appel côté kernel. Pour ce dernier point, il faut veiller à vérifier les pointeurs passés par l'utilisateur, c'est-à-dire qu'il faut vérifier que la zone mémoire correspondante est accessible pour l'utilisateur en parcourant son page directory. Il doit avoir l'accès au moins en lecture, parfois en écriture lorsque le noyau doit retourner des informations via le tampon.

La mémoire

Nous avons réalisé cette étape (phase 5) un peu plus tôt que prévu, en même temps que les files de message et avant la mémoire partagée (phase 4) (car celle-ci s'appuie sur les mécanismes d'allocation et de mapping réalisés à cette étape).

La première chose à faire est de réaliser un allocateur de pages. Avec le fichier de configuration de ld fourni, les pages sont situées entre mem_heap_end et mem_end. Une simple liste chaînée de pages libres suffit.

Puis, pour chaque processus, il faut allouer une page pour le page directory et réaliser le mapping 1:1 de l'espace noyau. Pour cela il suffit de copier le mapping déjà existant dans le page directory initial (pgdir). Les fonctions d'initialisation de processus devront se charger d'allouer et de mapper de l'espace pour le code et les deux piles. Nous avons implémenté pour aider ceci une fonction vmalloc, qui alloue et mappe autant de pages que nécessaire à l'endroit demandé. Le code d'un processus est récupéré grâce à la table des symboles compilée en dur avec le noyau : quelques fonctions ont dû être ajoutées pour créer une table de hachage à partir des données fournies et pour rechercher les programmes demandés. Le code démarrant désormais à l'adresse 0x4000000, il ne faut pas oublier d'adapter l'initialisation de la pile.


La dernière étape, qui n'est pas des moindres, consiste à passer le code utilisateur en ring 3. Pour ce faire, il faut comprendre comment fonctionne l'instruction iret : celle-ci récupère sur la pile l'adresse à laquelle retourner, puis l'indice du segment CS à utiliser et la valeur d'EFLAGS à recharger (celle-ci permet en particulier de démasquer les interruptions sitôt revenu en mode utilisateur). Si jamais CS pointe sur un segment de niveau de protection supérieur, iret va aussi récupérer sur la pile la valeur d'ESP et l'indice du nouveau segment SS.

A l'initialisation d'un processus, il faut donc mettre sur la pile tous ces éléments, ainsi que l'adresse d'une fonction (enter_proc dans notre cas) qui va charger les segments manquants (DS, ES, FS, GS) et réaliser un iret. Les segments à utiliser sont bien sûr ceux du mode utilisateur, et c'est cela qui activera la protection. A partir de là, lorsqu'une interruption arrivera, les bonnes valeurs seront mises sur la pile et le processeur repassera en ring 0. Un iret reviendra naturellement en ring 3 là où l'utilisateur était resté.

Afin que le code fonctionne sur machine réelle, il ne faut pas oublier de charger DS (et peut-être aussi ES, FS et GS) à chaque entrée et sortie de noyau (mettre le segment "noyau" à l'entrée, remettre le segment "user" en sortie), c'est-à-dire concrètement dans tous les traitants d'interruption.

La console clavier

La première étape de la gestion de la console (phase 6) est l'ajout et le démasquage des interruptions clavier, puis la gestion d'un buffer clavier à la réception de chaque évènement. C'est une partie relativement simple, mais toutefois longue et nécessitant beaucoup d'attention et de tests. Le fonctionnement d'un buffer peut sembler non intuitive et prêter à confusion et nous conseillons donc de faire des essais dans votre éditeur de texte et dans votre terminal sous Linux pour observer et essayer d'imiter ce fonctionnement.

Nous avons étendu la console avec la gestion d'autres touches de contrôle, en ajoutant notamment la gestion de lignes multiples (pour les lignes très longues), la navigation dans une ligne (avec les fléchées, début et fin) et l'édition en milieu de ligne (suppression d'un caractère, mode insertion/remplacement). Le tableau suivant présente les possibilités offertes par la console de TetanOS :


Touche / caractère d'échappement Fonctionnalité
Entrée, ctrl+M (\n) Valide la ligne courante et passe à la ligne suivante.
Backspace Efface le caractère à gauche du curseur (ou déplace le curseur à gauche en mode remplacement).
tab, ctrl+i (\t) Avance à la prochaine tabulation
ctrl+L (\f) Efface l'écran
Flèches gauche et droite (touches 4 et 6 du pavé numérique) Déplace le curseur à gauche ou à droite
Début et Fin (touches 7 et 1 du pavé numérique) Déplace le curseur au début ou à la fin de la ligne.
Suppr Supprime le caractère sous le curseur.
Insert Switch entre les modes Insertion et Remplacement.

Une primitive système nous permet de passer en mode commande qui permet de gérer ces touches plutôt que de les afficher. Typiquement le shell utilisera ce mode, alors qu'un simple cat ne le gèrera pas.

L'interprète de commandes

La dernière étape du projet (phase 7) consiste à implémente un shell minimaliste supportant un minimum d'opérations. C'est entre autre le moment d'implémenter un parser et de créer les différents processus correspondant aux commandes. Encore une fois, cette étape reste simple, mais peut nécessité beaucoup de temps. Le tableau suivant présente toutes les commandes internes de TetanOS :

Commande Description
autotest Lance tous les tests (test1 à test 22).
cat <fichier> Affiche à l'écran les caractères lus sur l'entrée standard. Si un fichier est passé en paramètre, alors c'est le contenu de ce fichier qui est affiché.
clear Efface l'écran et réaffiche le prompt.
date Affiche la date courante du système.
echo Active ou désactive l'affichage des caractères frappés.
exit Quitte le shell courant.
help Affiche la liste des commandes internes du shell.
init Affiche le logo de TetanOS.
kill <pid> Tue le processus identifié par pid s'il y en a un.
ls <chemin> Affiche les fichiers et dossiers présents dans le répertoire précisé par <chemin>. Si aucun argument n'est donné, affiche le contenu du dossier racine par défaut.
pinfo Affiche les informations relatives aux files de messages couramment utilisées : numéro de file (fid), nombre de messages, nombre de processus bloqués en attente d'écriture ou nombre de processus bloqués en attente de lecture s'il y en a.
print <chaine> Affiche la chaîne de caractères <chaine> à l'écran.
ps Affiche la liste des processus en cours d'exécution (leur nom, leur état, leur PID et leur PPID).
shell Démarre un nouveau shell.
sleep <num> Endort le processus courant pendant <num> secondes.
testX Lance le test numéro X, X étant compris entre 1 et 22.

Extensions

Gestion d'un Disque Dur ATA

Nous avons réalisé un pilote de disque dur ATA selon le PIO mode. La lecture et l'écriture du disque se font entre le disque et le CPU. Cette méthode est très couteuse pour le CPU mais est relativement simple à mettre en place. Nous avons implémenté le PIO mode avec un adressage sur 48 bits.


Malheureusement, les machines sur lequelles nous avons testé avaient des disques trop récents qui ne géraient plus l'ATA en PIO mode. L'écriture et la lecture sur disque ont pu être testées avec le simulateur Bochs et l'image disque fournie sur le wiki.

Disque dur sur RAM

Tandis que Gautier s'attelait à essayer de récupérer des données depuis un disque dur, Bastien devait commencer à écrire un driver FAT. Pour ce faire, en attendant, il a fallu simuler le driver de disque dur.

Nous avons créé un fichier de 8 Mo contenant un système de fichiers FAT12 (à l'aide de dd, mkfs.vfat, et mount pour créer une arborescence de base dedans). Nous avons ensuite ajouté un fichier assembleur se contentant d'inclure (directive .incbin) ce fichier dans une section particulière, et modifié le script du linker (kernel.lds) pour incorporer ces données dans un endroit connu en mémoire. (Note : attention, un bout de code dans boot/early_mm.c met à zéro une partie de la mémoire noyau, il faut donc incorporer les données au bon endroit.)

Nous avons ensuite écrit un driver pour ces données (appelées la "ramdisk") offrant la même interface qu'un driver de disque dur, capable de lire et d'écrire des secteurs entiers de 512 octets, simplement à coup de memcpy. Cette interface est fournie à l'aide d'une structure contenant des pointeurs vers les fonctions, à-la-Linux. Au moment où le driver de disque dur sera prêt, il suffira d'interchanger les fonctions de création de cette structure.

Système de fichier FAT

Le système de fichiers FAT est relativement simple en soi, mais il faut bien comprendre les calculs à réaliser et il faut se créer une bonne API pour récupérer les secteurs voulus. En effet, toutes les données, fichiers comme répertoires, peuvent être répartis un peu partout sur le disque (dont on récupère l'adresse via la "cluster chain"), et il faut toujours les lire comme si elles étaient séquentielles. Ce qui n'aide pas, c'est que le répertoire racine de FAT12 et FAT16 échappe à la règle et occupe des secteurs contiguës en mémoire, sans passer par la cluster chain.

FAT12, FAT16 et FAT32 peuvent être implémentés en même temps sans trop de problème. Nous n'avons eu le temps de ne tester que FAT12, mais avec un tout petit peu de code en plus nous aurions pu gérer facilement les deux autres.

Nous avons réalisé les appels système "Linux-like" de base : open, close, getdents et read (voir leurs pages de manuel respectives). Nous n'avons pas réalisé write par manque de temps.

Bilan

Ce projet nous a permis d'approfondir toutes les notions acquises durant 2 années d'études, en reprenant des choses déjà faites et en les intégrant toutes ensembles das un système global. C'est un projet très gratifiant car le fruit de nos efforts est directement visible, et le résultat au bout de quelques semaines seulement peut déjà être impressionnant.

Difficultés

La principale difficulté rencontrée lors de ce projet a été le debug. En effet, le debug d'un système d'exploitation est loin d'être trivial, surtout en partie noyau, car une corruption de mémoire n'est pas toujours visible et peut engendre des erreurs bien plus tard.

De plus, lors de l'exécution de code côté utilisateur, gdb ne charge pas la table des symboles, il faut alors la charger à la main et au bon endroit pour pouvoir debug (sauf si on préfère lire des milliers de ligne d'assembleur...).

Le simulateur c'est bien, sauf qu'au passage sur vrai machine des nouveau bugs sont apparus, et sans debugueur, la tâche se complexifie !

Bien que la page de spécifications est plutôt bien fournie, il reste quelques ambiguïtés. Nous avons eu quelques difficultés pour certaines parties , notamment sur les files de messages.

Conseils

  • Il est primordial de maîtriser les outils utilisés (débugger, gestionnaire de version) et de communiquer au sein du groupe (bugs connus, changements réalisés impactant la suite).
  • Les étapes sont séquentielles et doivent être soigneusement testées et validées avant de passer à la suite. Elles sont également incrémentales et nécessitent rapidement une bonne répartition du travail (ce qui est difficile au début).
  • Lisez soigneusement la documentation en faisant attention à chaque petit détail.
  • N'oubliez pas de tester sur machine nue! Le simulateur ne montre pas tous les bugs.

Outils

  • Git : le gestionnaire de version imposé pour le projet. Il est très utile de savoir s'en servir, et notamment de savoir manipuler ses commit.
  • vim : le choix de l'IDE est libre, mais il est préférable que tout le groupe travaille sur le même éditeur et mette en place un fichier de configuration commun à tous. Nous avons choisi vim et utilisé un .vimrc commun tout au long du projet.
  • gdb : Il est primordial de maîtriser les outils de débuggage afin de comprendre ces erreurs et d'assurer un système stable. Gdb est celui que nous avions l'habitude d'utiliser et reste très performant pour le débuggage d'un système. Il s'interface également aisément avec Qemu.
  • Qemu : un simulateur déjà installé sur les machines de l'ENSIMAG qui permet de tester son système.
  • Bochs : un autre simulateur non installé sur les machines de l'ENSIMAG (qui nécessite les droits d'administration pour s'exécuter). Il a été notamment utile pour simuler un disque dur (qemu ne nous laissant pas écrire dessus).
  • Une clef USB : il est fortement recommandé de tester régulièrement son système sur une réelle machine. Son bon fonctionnement sur un émulateur ne prouve pas que le système est fonctionnel. Utilisez Grub sur une clef USB afin de l'essayer.
  • Tom's planner : un outil Web accessible et intuitif pour réaliser des diagrammes de Gantt.

Références

Un certains nombre de sites et documents de références ont été utilisés. Le projet système étant un projet très technique, il est fondamental de savoir lire, comprendre et utiliser la documentation fournie. Voici les sites que nous avons le plus utilisés :