Projet système PC : 2010 - Benjamin PETIT et Thierry SABRAN

De Ensiwiki
Aller à : navigation, rechercher


En construction

Shell petitbe.png

Dans le cadre du projet de spécialité de deuxième année, le projet système propose de réaliser un noyau de système d'exploitation (définition sur wikipedia).

Le noyau développé doit respecter les spécifications minimales du projet.

Nous étions libres de faire les extensions que nous jugions intéressantes à programmer (mode VGA, lecture du disque dur, etc.). Nous les détaillons dans la partie extensions.

Pour celles et ceux qui seraient intéressés pour faire ce projet l'année prochaine, je vous invite à lire le petit bilan.

Enfin, vous pouvez tester notre noyau !

Equipe

L'équipe était composée de :

  • Benjamin Petit (ISI)
  • Thierry Sabran (ISI)

Tuteur : Grégory Mounier

Gestion de projet

Charte de travail

Même si nous n'étions qu'en binôme, nous avons réfléchi à une mini charte de travail, précisant les jours et horaires de travail, ainsi que les conventions utilisés en programmation, pour garder un code clair et agréable à lire.

Étapes intermédiaires

Le sujet est très structuré, avec des étapes intermédiaires et des buts clairs et précis. De ce fait, le projet paraît très guidé. Cependant, chaque étape demande une grande partie d'analyse, pour comprendre les enjeux et les méthodes à mettre en place pour valider une étape.

Outils utilisés

Nous avons utilisé Subversion comme gestionnaire de version (imposé par le projet). Après s'être servi de git pour le projet GL, on a l'impression de regresser... mais il est important de connaître les deux.

Comme compilateur, nous avons utilisé GCC (version 4.4).

Enfin, comme éditeur, nous avons utilisé le meilleur éditeur au monde ;)

Réalisation

Spécifications requises

Notre noyau respecte les spécifications minimales du projet, que vous trouverez dans la page Projet système : spécification

Nous avons néanmoins pris la liberté d'ajouter certaines caractéristiques à certaines primitives systèmes spécifiées :

waitpid
int waitpid(int pid, int *retval);

Cette fonction devait attendre la fin du processus fils pid si pid est positif, ou d'un fils quelconque si pid négatif. Nous avons ajouté la possibilité de rendre cet appel non bloquant pour facilement vérifier si un fils était mort mais sans attendre dans le cas contraire.

  • si pid == -2, appel non bloquant, renvoie le pid d'un fils zombie s'il y en a un, -2 sinon
  • si pid == -1, appel bloquant, renvoie le pid d'un fils zombie, attend s'il n'y en a pas
  • si pid >= 0, attend la fin du processus fils pid
preceive / psend

Lors de l'implémentation des redirections d'entrées / sorties grâce aux files de messages, les processus ont eu besoin de savoir lorsque leur entrée ou sortie standard était fermée. Sinon ils ne pouvaient jamais se terminer. La file ne pouvait pas être détruite puisque rien n'empêchait un processus plus prioritaire d'en recréer une sous le même numéro de fid.

La solution que nous avons adopté a été de rajouter un état closed aux files de message. En temps normal, ce booléen est à faux et la file se comporte comme dans les spécifications. Mais lorsqu'on passe son état à vrai, son comportement est défini comme suit :

  • preceive renvoie les messages présents dans la file tant qu'il y en a, puis renvoie -1 lorsqu'elle est vide (permet à un processus lisant dans la file de savoir qu'il ne recevra plus d'autres messages)
  • psend renvoie -1 systématiquement (permet à un processus écrivant dans la file de savoir que plus personne ne lira jamais ses messages)


D'autres primitives systèmes complémentaires ont pu être rajoutées mais seront alors spécifiées dans le détail de l'extension les concernant.

Extensions

Voici les différentes extensions que nous avons réalisés au cours du projet :

Extensions majeures

Drivers IDE

Il existe plusieurs méthodes pour lire/écrire sur un disque dur. La plus simple (et la plus lente, mais suffisante dans notre cas) est d'utiliser le mode PIO

Notre code est basé sur le code de [[1]], légerement modifié.

Les primitives accessibles en kernel sont :

 /* Lit sur le disque drive count octet à partir de offset dans buf */
 int disk_read(int drive, int offset, char *buf, int count);
 /* Lit sur le disque drive count blocs de 512 octets depuis le bloc offset dans buf */
 int bl_read(int drive, int numblock, int count, char *buf);
 /* Lit sur le disque drive count blocs de 512 octets depuis le bloc offset dans buf */
 int bl_write(int drive, int numblock, int count, char *buf);
Lecture de la MBR
Affichage de la table des partitions

Dans la MBR se trouve la table des partitions, ce qui nous sert pour détecter une partition en FAT32.

 /* Lit la MBR du disque device */
 struct mbr *read_mbr(int device);
 /* Affiche la table des partitions */
 void display_mbr(struct mbr *m);

Les pages sur la MBR et sur la table de partition du wiki OSdev sont très claires et détaillées.

Driver FAT32
Opérations sur les fichiers

Les systèmes de fichier FATx sont réputés pour être facile à mettre en oeuvre. Nous avons implémenté un driver capable de lire une partition FAT32.

Nous n'avons pas eu le temps d'implémenter l'écriture, mais cela ne semble pas trop dur à faire à priori.

Depuis notre shell vous pouvez utiliser ces commandes pour interagir avec le système de fichier :

  • mount pour monter automatiquement la première partition FAT32 trouvée (attention si pas de disque IDE la commande se bloquera)
  • ls pour lister le répertoire racine
  • ls num pour lister le répertoire numéro 'num'
  • read num pour lire le contenu du fichier 'num'

Les numéros de fichier et de répertoire sont en faites les clusters où commencent les fichiers. Si ce numéro vaut 0, c'est que le fichier est vide.

ELF loader

Le chargement dynamique de fichiers binaires au format ELF est une extension que nous n'avions pas envisagé au début du projet, mais qui s'est révélée fort intéressante pour rentabiliser l'extension du système de fichiers FAT et la relier au shell.

De plus l'allocation en mémoire d'une zone programme spécifique à chaque processus évite le partage de mémoire souvent non souhaité, ce qui facilite significativement le développement des applications utilisateur.

Une des difficultés envisagées fut la non gestion de la pagination dans notre système. En effet, le traitement classique d'un executable ELF consiste à charger les segments de code binaire à l'adresse virtielle 0. Or ce type de chargement est impossible sans pagination. Mais le format ELF n'est pas utilisé pour coder uniquement des executables, mais aussi des fichiers relocalisables (fichiers objets). Et ces fichiers relocalisables ont la particularité de permettre le déplacement de chaque section du programme à l'adresse souhaitée (utilisé en temps normal par l'éditeur de liens pour justement générer l'exécutable).

Notre module ELF_loader consiste donc dans un premier temps à allouer en mémoire utilisateur chacune des sections de code du fichier relocalisable (fichier objet). Puis il parcourt les tables de symboles et de relocalisation du format ELF pour corriger toutes les références à la fois aux symboles internes (situés dans d'autres sections du programme) et aux symboles externes (définis dans les librairies utilisées à la compilation). Il prend soin également de repérer l'adresse du symbole main, qui constituera le point d'entrée du programme alloué.

Remarque : Le elf_loader recherche en fait un symbole nommé soit 'main' soit 'main_' pour faciliter la compilation. En effet, gcc refuse de compiler des programme avec un main(void *args), puisque le premier argument (argc) devrait être un int. Or la spécification de la fonction start définit un unique argument de type (void *), les applications peuvent définir leur fonction d'entrée avec int main_(void *arg).

Le module elf_loader fournit la primitive suivante, qui :

  • alloue en mémoire utilisateur les segments de code exécutable du fichier objet fourni en paramètre
  • ajoute à la liste alloc_list l'ensemble des segments alloués avec leur taille (struct allo_entry spécifiée dans elf_loader.h), pour permettre leur libération à la fin du processus
  • renvoie l'adresse du point d'entrée du programme à lancer
void *elf_loader(char *file, link *alloc_list);


La documentation de référence utilisée : http://www.skyfree.org/linux/references/ELF_Format.pdf

Fichier d'en-tête utilisé spécifiant les structures des en-têtes du format Elf: <elf.h> (GNU CLibrary)

Redirection des entrées sorties
Redirections des I/O avec les pipes

Faire tourner un processus sur un TTY particulier consiste à associer ses entrées et sorties à ce tty. Mais rediriger la sortie d'un processus vers l'entree d'un processus consiste aussi à associer les entrées et sorties des processus à une interface. Nous avons utilisé à cet effet les files de message.

Nous avons donc ajouter la possibilité de spécifier l'entrée standard et la sortie standard des processus, grâce aux appels systèmes suivants :

int get_stdin();
int get_stdout();
void set_stdin(int stdin);
void set_stdout(int stdout);

Les valeurs possibles par ces variables stdin et stdout sont définies de la façon suivante :

  • si x < 0, alors l'entrée/sortie standard est redirigée vers le TTY n° (-x), entre 1 et NB_TTY
  • si x >= 0, alors l'entrée/sortie standard est associé à une file de message, de fid = x

De plus, chaque nouveau processus hérite à sa création des entrées et sorties de son père. On peut alors aisément lancer un processus P0 avec les entrées / sorties souhaitées grace à la procédure suivante :

stdin_save = get_stdin();      //Sauvegarde du contexte
stdin_save = get_stdin();
set_stdin(stdin_P0);           //Changement des entrées sorties pour P0
set_stdout(stdout_P0);
start(P0, ...);                //Lancement de P0
set_stdin(stdin_save);
set_stdout(stdout_save);       //Restauration du contexte

Avec ce mécanisme, les processus peuvent écrire et lire avec cons_read et cons_write, indépendament de si leur entrée ou sortie standard est de type tty ou file de message. Néanmoins nous avons du ajouter un moyen de notifier les applications lorsque leur entrée ou sortie standard était fermée.

Pour le tty, la sortie standard n'est à priori jamais fermée, et l'entrée standard devrait pouvoir être fermée en utilisant les signaux de contrôle (non encore utilisés). Pour les files de messages nous avons du ajouter un état particulier d'une file : l'état fermé. En effet, on ne peut pas juste supprimer la file puisque le shell pourrait alors immédiatement la réallouer sans que le processus cible ne s'en rende compte. Ainsi, il suffit d'utiliser la primitive pclose sur une file pour la mettre dans l'état fermé en attendant que tous les processus l'utilisant soit terminés, puis de la supprimer après. Le comportement d'une file fermé est détaillé dans la section Spécifications requises.

Extensions mineures

Signaux asynchrones

Nous avons implémenté un système de signaux asynchrones "à la UNIX" [2]

Les appels systèmes associés sont :

 /* Envoie le signal num au processus pid */
 int send_signal(int pid, int num);
 /* Change la fonction qui intercepte les signaux (par défaut exit) */
 void set_signal_handler(int (*ptfunc)(int));
 /* Renvoie le pointeur vers la fonction qui intercepte les signaux */
 int get_signal_handler();

À la différence des signaux UNIX, on peut tous les intercepter, et une seule fonction a le rôle "d'intercepteur".

Par défaut, si aucun handler n'a été défini, le processus quitte à la reception d'un signal. Il n'est pas nécessaire de réarmer le handler.

En théorie, l'implémentation des signaux asynchrones n'est pas difficile ; il suffit de bien modifier les piles du processus fils... ce qui en pratique n'est pas si facile que ça. Peu de code a écrire, mais des maux de tête assurés !

Gestion des jobs, redirections, séquences d'instruction et variables dans le shell

Le shell inclu à notre OS, conçu à l'origine pour simplement lancer les commandes sans arguments, l'une après l'autre, a été étendu pour supporter divers mécanismes inspirés des shells usuels. Ces extensions avaient notament pour objectif de mieux utiliser les facultés de notre noyau, telles que les files de message, et de rendre les applications clientes plus faciles à utiliser.

L'élément clé de cette extension du shell est en fait le parser de commandes. En effet, c'est lui qui va séparer les différents mots de la commande, en détectant les variables et autres caractères spéciaux. C'est aussi lui qui va regrouper ces mots en une séquence de commandes, redirigées en entrée (<) et/ ou en sortie (>), éventuellement séparés par des opérateurs tels que &&, ||, ;. Pour finir, c'est aussi lui qui découpe une commande en une suite d'exécutables "pipés" entre eux (|), avec chacun sa liste d'arguments,

Différents modules du shell permettent alors de traiter ces résultats.

Le module process a pour but de conserver en mémoire l'ensemble des processus actifs du shell. En réalisant un wait_pid non bloquant, il peut détecter les processus fils zombies pour les libérer eux ainsi qu'éventuellement les files de messages utilisées pour rediriger ses entrées et/ou sorties sur un autre processus.

Le module jobs consiste lui à conserver en mémoire l'ensemble des jobs pour pouvoir d'une part lister des jobs en cours d'execution et voir leur état, et d'autre part attendre la fin (mettre en "foreground") un job lancé en tâche de fond (ou "background"). A terme il est aussi prévu d'utiliser les signaux implémentés pour permettre la suspension et la mise en background d'un job.

Un module variable qui sert juste de base de donnée définissant l'environnement de variables du shell.

Remarque : Les entrées et sorties redirigées dans des fichiers, bien que détectés, n'ont pas encore été reliées avec le système de fichier, et ne sont donc pas encore disponibles.


Gestion des séquences d'échappement lors de l'affichage

Les séquences de contrôle des consoles constituent une technique élégante pour transmettre diverses commandes au gestionnaire d'affichage du texte, sans recourir à des appels systèmes spécifiques supplémentaires. Ces commandes servent par exemple à déplacer le curseur sur l'écran, ou à changer la couleur d'affichage des caractères.

Certaines de ces commandes utilisent simplement des caractères de contrôle définis dans le code ASCII :

  • \a : BEL emet un bip sur le speaker
  • \b : BS revient en arrière d'une colonne en effaçant le caractère
  • \t : HT avance le curseur jusqu'à la prochaine tabulation
  • \r : CR déclenche un retour chariot
  • \n : LF effectue un saut de ligne

Toutefois il est possible sous un terminal Unix de transmettre des commandes plus complexes, échappées par la touche ESC. C'est ce comportement que nous avons cherché à reproduire. Voir man console_codes pour plus de détails sur ces séquences de contrôle.

En particulier, nous avons implémenté les séquences d'échapement CSI, qui sont constituées des caractères ESC (code octal \033) et '[', suivis d'une série de paramètres numériques séparés par des points virgules, et terminés par un unique caractère non numérique. Un paramètre vide ou absent est considéré comme nul. Ce dernier caractère non numérique spécifie alors l'action à réaliser.

Les caractères gérés sont notamment :

  • A, B, C, D, E, F, G, H, a, d, e, f pour manipuler la position du curseur,
  • @, J, K, pour effacer des sections de l'écran
  • m pour changer les attributs (la couleur) du curseur

Exemple :

printf("\033[10;10H");   //place le curseur à la position (10,10)
printf("\033[34;1m");    //séléctionne la couleur rouge, et met en gras
printf("Hello World");
printf("\033[m);         //remet les attributs d'affichage par défaut

Le détail de ces commandes est disponible sur le manuel 'console_codes' : http://unixhelp.ed.ac.uk/CGI/man-cgi?console_codes+4

Gestion des TTY

Le changement de tty peut se décomposer en deux parties :

  • Affichage du nouveau tty à l'écran
  • Association des interuptions clavier avec les processus du nouveau tty

En effet, chaque tty se doit de pouvoir recevoir et traiter indentiquement les écritures de ses processus, indépendament de s'il est actuellement affiché ou non. En pratique, le noyau dispose d'un buffer de sauvegarde pour chaque tty, identique à celui de la mémoire vidéo. Le choix du buffer d'écriture est complètement transparent pour l'appel système 'cons_write'.

De plus, les touches entrées au clavier doivent être associé au tty affiché. En particulier, même si on change de tty en cours d'édition d'une ligne, chaque caractère sera transmis au tty affiché au moment de la frappe de sa touche, et non forcément à celui affiché au moment du retour chariot. Ici aussi le noyau conserve donc un buffer clavier spécifique, ainsi qu'une liste des processus en attente d'IO, pour chaque tty.

RQ : Depuis l'ajout de la gestion du mode VGA 13h (320x200x256), nous avons choisi de permettre à chaque tty de basculer entre les modes VGA text 03h et VGA graphic 13h séparément. Pour cela, le même mécanisme a été adopté pour la mémoire graphique : un buffer graphique de sauvegarde pour chaque tty, l'écriture dans ce buffer lorsque le tty est masqué ou le mode graphique désactivé.

Ainsi, toutes les modifications effectuées (écritures / dump mémoire video) sur un tty ou un mode VGA masqué sont traitées sur le buffer de sauvegarde, et pourront donc être visibles lors de la restauration du tty ou du mode en question, comme s'il n'avait jamais été masqué.

Le changement de tty actif peut se faire grâce aux touches F1, F2, ..., ou par l'appel système :

int switch_tty(int new_tty);

Le changement du mode VGA du tty d'un processus peut se faire par l'appel système :

void switch_vga_mode();	//bascule le mode du tty entre 03h et 13h
Gestion du mode VGA 13h (320x200x256)
L'application bubble

Remarque préalable : Cette extension n'a pas pour objet de remplacer le mode texte, utilisé par le reste du noyau et du shell. Elle consiste uniquement à offrir aux programmes clients la possibilité de changer leur mode d'affichage pour un mode graphique et d'y afficher ce qu'ils souhaitent. Cette technique permet ainsi la réalisation d'outils usuels tels qu'une visionneuse d'images, ou un jeu video (en faible résolution).


VGA est un standard d'affichage supporté par la plupart des cartes vidéos usuelles. Il spécifie notament plusieurs modes d'affichages, parmi lesquels les plus courants :

  • mode texte (80 x 25 caractères), ou mode 03h
  • mode graphique 320x200x256, ou mode 13h

Il permet en fait d'obtenir des résolutions suppérieures en utilisant jusqu'à 4 plans vidéos, mais nous ne nous en soucieront pas dans cette extension.

Le mode texte est bien connu puisque c'est celui utilisé par défaut par l'ordinateur à son démarrage, ainsi que par notre OS pour ses sorties texte. Il permet de modifier l'affichage en écrivant les caractères dans une matrice de 80x25 short (1 octet pour le caractère + 1 octet pour sa couleur). Cette matrice est directement accessible puisque mappée en mémoire principale à l'adresse 0xB8000.

Le mode graphique 13h est tout aussi simple, puisqu'il suffit de modifier la valeur de chaque pixel (1 octet décrivant la couleur du pixel parmi 256) dans une matrice de 320x200 octets. Et comme pour le mode texte, cette matrice est directement accessible puisque mappée en mémoire principale à l'adresse 0xA0000.

Seule difficulté rencontrée : le changement de mode VGA se fait facilement par un simple appel BIOS, mais à priori non utilisable en mode protégé. Le module fourni par nos tuteurs, permettant justement d'effectuer les appels BIOS en repassant temporairement en mode réel, a donc été bien utile.


Notre noyau fournit donc les appels systèmes suivants :

// bascule le mode vga du tty du process appelant entre les modes 03h (texte) et 13h (graphique)
void switch_vga_mode();

// copie le contenu du buffer en argument vers la vraie mémoire vidéo graphique (0xA0000, non accessible depuis l'espace utilisateur)
void dump_vga_buffer(char *userspace_buffer);

Une application de test, bubble, a été réalisée pour utiliser ces appels systèmes, et consiste à afficher des bulles en déplacement à l'écran.



Apports et difficultés

Ce sujet est probablement l'un des plus techniques des sujets de spécialités, et c'est probablement la seule occasion que nous avons de développer un OS de A à Z. Il nous a permis de bien comprendre le fonctionnement d'un kernel.

Ce projet n'est pas sans difficultés ; même si la roadmap fournie par les profs est claire et précise, il faut beaucoup de reflexion personnelle pour résoudre les divers problèmes que l'on rencontre au cours du projet.

De même, il vaut mieux bien maîtriser le C (et un peu l'assembleur, mais on n'en fait pas tant que ça) pour ce projet !

Le debuggage, malgré la très bonne interface fournie par les profs n'est pas aisé : gdb n'est pas capable de suivre partout (et nous ne maîtrisons pas assez gdb, c'est vrai aussi). Les erreurs "silencieuses" sont difficiles à détecter et à corriger ! Ici pas de segfault (ou peu), et pas de valgrind pour vérifier vos pointeurs !

Pour nous, c'est clairement le projet le plus intéressant qui nous a été donné de faire au cours de notre scolarité à l'Ensimag. On regrette presque que le projet se finisse, pour continuer les extensions !

Sources et liens externes

  • TutoOS Wiki très bien fait (en français !) sur l'écriture d'OS. Nous nous sommes basés sur son code pour le disque dur, malgré qu'il ne soit pas documenté
  • OSDev Wiki en anglais sur le développement d'OS. Votre bible pour ce projet
  • Understanding FAT32 Filesystems Le lien le plus clair pour comprendre comment marche FAT32 (la documentation de OSDev et microsoft est assez indigeste)

Noyau binaire

Vous voulez tester notre OS ? Rien de plus simple, il suffit de télécharger notre noyau, et de configurer VirtualBox comme indiqué sur cette page.

Pour tester un systeme FAT32, il faut attacher un disque dur IDE sur la première nappe en Master qui possède au moins une partition FAT32.

Télécharger le kernel cosmOS