Cette planche de TP s'inspire librement de l'assignement 2 de Matt Welsh.

1. Introduction

Le système OS/161 a un support minimal pour exécuter des programmes mais rien qui ne puisse être considéré comme un vrai processus. Dans ce devoir, vous allez mettre en oeuvre une implémentation minimale des appels système et des processus.

Pour aborder ce devoir dans de bonnes conditions, il est important d'avoir compris le chapitre de cours sur les processus ainsi que le TP préliminaire sur OS/161. Dans la partie programmation, il est important de bien organiser votre code, de le commenter et de produire quelque chose de lisible -- dans un style et des conventions proches du code que vous enrichissez.

Configuration de l'ASST2

OS/161 met à votre disposition un peu de code source pour faciliter la réalisation de ce TP à travers un pilote (kern/asst2) et des entrées du menu au boot. Pour activer ce code, il faut reconfigurer votre noyau, comme vous l'avez fait au TP préliminaire mais en utilisant le fichier de configuration ASST2 au lieu de ASST0 puis de le compiler en allant dans le répertoire compile/ASST2.

2. Lecture de code

Notez les réponses aux questions suivantes dans un fichier texte en identifiant bien les différentes questions auxquelles vous répondez. Ce fichier est à rendre avec le devoir.

Exécution de programmes en mode utilisateur

kern/userprog
Ce répertoire contient les fichiers responsables du chargement et de l'exécution de programmes au niveau utilisateur. Pour le moment, ce dossier ne contient que les trois fichiers loadelf.c, runprogram.c et uio.c; cependant vous aurez peut être besoin de rajouter des fichiers que vous aurez écrits durant ce devoir. La compréhension de ces fichiers est la clé pour démarrer avec l'implémentation de la multiprogrammation. Notez que pour répondre à certaines questions vous aurez besoin d'aller regarder des fichiers en dehors de ce répertoire.

loadelf.c
Ce fichier contient le code permettant le chargement en mémoire virtuelle d'exécutables au format ELF depuis le système de fichiers. (ELF est le nom du format des fichiers exécutables produits par cs161-gcc.)
runprogram.c
Ce fichier ne contient qu'une seule fonction runprogram() qui permet de lancer un programme utilisateur depuis le menu. C'est une bonne base pour l'écriture de execv(). De plus, lorsque vous aurez écrit votre système de processus, vous devrez modifier runprogram() pour que les programmes soient lancés proprement dans ce système; par exemple avec les descripteurs de fichiers standards disponibles à l'exécution.

uio.c
Ce fichier contient les fonctions permettant de faire transiter des données entre espace noyau et espace utilisateur. Savoir quand et comment passer cette barrière est un point critique pour l'implémentation correcte des programmes utilisateur, ainsi c'est un fichier à lire attentivement. Vous pouvez aussi regarder lib/copyinout.c.
  1. Quel est l'identifiant (magic number) du format ELF ?
  2. Quelle est la différence entre UIO_USERISPACE et UIO_USERSPACE ? Quand doit-on utiliser UIO_SYSSPACE à la place ?
  3. Pourquoi la struct uio qui est utilisée pour lire dans un segment peut être allouée sur la pile dans load_segment()? (i.e., où va réellement la mémoire?)
  4. Dans runprogram(), pourquoi est-il important d'appeler vfs_close() avant de passer en mode utilisateur ?
  5. Quelle fonction force le processeur à passer en mode utilisateur ? Est-ce que cette fonction dépend de l'architecture ?
  6. Dans quel fichier sont définies les fonctions copyin et copyout ? et memmove? Pourquoi les deux premières ne peuvent-elles pas être implémentées de la même manière que memmove?
  7. Quel est (brièvement) le rôle de userptr_t ?

kern/arch/mips/mips: Pièges et appels système

Les exceptions sont un élément clé des systèmes d'exploitation; ce sont des mécanismes qui permettent au système de regagner le contrôle de l'exécution et ainsi de faire son travail. Vous pouvez voir les exceptions comme l'interface entre le processeur et le système d'exploitation. Quand le système démarre, il installe un "gestionnaire d'exceptions" (un code en assembleur) à une adresse spécifique en mémoire. Quand le processeur lève une exception ce code est invoqué et appelle le système d'exploitation. Comme "exception" est un terme surchargé en informatique, en systèmes on appelle ça un piège, lorsque l'OS piège l'exécution. Les interruptions sont des exceptions, ainsi que les appels système. Plus spécifiquement, syscall.c gère les pièges qui sont des appels système. La compréhension d'au moins le code C dans ce répertoire est un point clé pour devenir un gourou des systèmes d'exploitation donc il est vivement conseillé d'aller y jeter un oeil attentif.

trap.c
mips_trap() est la fonction qui permet de rendre le contrôle au système d'exploitation. C'est la fonction C qui est appelée par le gestionnaire d'exceptions (en assembleur). md_usermode() est la fonction qui permet de rendre le contrôle aux programmes utilisateur. kill_curthread() est la fonction pour gérer les mauvais programmes utilisateur; quand le processeur est en mode utilisateur et rencontre quelque chose qu'il ne sait pas gérer (par exemple une instruction invalide), il lève une exception. C'est un point de non retour, ainsi le système se doit de tuer le processus. Une partie de ce devoir sera d'écrire une version utilise de cette fonction.

syscall.c
mips_syscall() est la fonction que délègue le travail de traitement d'un appel système à la fonction noyau qui l'implémente. Notez que reboot() est, pour l'instant, le seul cas géré; cependant vous allez enrichir cette fonction tout au cours de ce devoir. Vous trouverez aussi une fonction md_forkentry() qui est un prototype où vous devrez placer votre code pour implémenter l'appel système fork(). Il devra être appelé depuis mips_syscall().
  1. Quelle est la valeur numérique du code d'exception pour un appel système MIPS?
  2. Pourquoi est-ce que mips_trap() met curspl à SPL_HIGH "manuellement" au lieu d'utiliser splhigh()?
  3. Quelle est la taille (en octets) d'une instruction MIPS? (Regardez attentivement mips_syscall() et pas ailleurs.)
  4. Pourquoi est-ce que vous "voudrez certainement changer" l'implémentation de kill_curthread()?
  5. Que serait-il nécessaire de faire pour implémenter un appel système qui prendrait plus de 4 arguments?
lib/crt0
Ceci est le code de démarrage d'un programme utilisateur. Il n'y a qu'un seul fichier ici, mips-crt0.S, qui contient le code assembleur MIPS qui est exécuté lorsqu'un programme est lancé. Il appelle la fonction main() du programme. C'est le code avec lequel votre execv() devra s'interfacer; vérifiez bien quelles valeurs il attend dans quels registres, etc.

lib/libc
C'est la librairie C en espace utilisateur. Évidemment, il y a un paquet de code ici. Nous n'attendons pas que vous lisiez tout même s'il serait instructif sur le long terme de le faire. Pour ce devoir vous avez seulement besoin de regarder le code qui implémente la partie utilisateur des appels système que nous détaillons plus bas.

errno.c
C'est ici que la variable globale errno est définie.

syscalls-mips.S
Ce fichier contient le code dépendant de l'architecture pour la partie utilisateur des appels système MIPS.

syscalls.S
Ce fichier est créé à partir de syscalls-mips.S à la compilation et est celui qui est réellement assemblé dans la librairie C. Les noms des appels système sont placés dans ce fichier à l'aide d'un script callno-parse.sh qui les lit depuis les headers du noyau. Ceci permet d'éviter d'avoir à faire une deuxième liste des appels système. Dans un vrai système, chaque appel et placé dans son propre fichier pour permettre des les inclure sélectivement. Dans OS/161, ils sont tous dans le même fichier pour simplifier les makefiles.
  1. Quelle est la fonction de la macro SYSCALL?
  2. Quelle est l'instruction MIPS qui génère un appel système? (Lisez le code dans ce répertoire et pas ailleurs.)

3. Implémentation

Appels système et exceptions

Vous allez implémenter la gestion des appels système et des exceptions. La totalité des appels système importants est listée dans kern/include/kern/callno.h.

Dans ce devoir nous vous demandons d'écrire les appels système suivants:

Il est primordial que vos appels système gèrent les erreurs correctement (i.e., sans planter le système). Vous pouvez lire les pages de man inclues dans OS/161 pour mieux comprendre les appels que vous devez écrire. De plus, vous devez renvoyer des valeurs correctes (en cas de succès) ou le code d'erreur (en cas de problème) comme décrit dans ces pages de man; la conformance à la spécification est aussi importante que la validité de l'implémentation.

Le fichier include/unistd.h contient les définitions des interfaces au niveau utilisateur des appels système que vous devez écrire. Cette interface est différente de celle des fonctions noyau que vous allez définir pour implémenter ces appels. Vous devez écrire cette interface et la mettre dans kern/include/syscall.h. Comme vous le savez déjà, les codes pour les appels sont définis dans kern/include/kern/callno.h. Vous devrez réfléchir sur nombre de problèmes associés avec l'implémentation des appels système. Peut-être le plus évident est celui-ci: est-ce que deux processus utilisateur peuvent exécuter un appel système en même temps?

Exemple: implémentons _getcwd()

Pour vous aider pour la suite, nous vous donnons la solution pour un appel système de base: _getcwd().

3.0.1. Rajout du code dans syscall.c

Comme vu plus haut, le fichier kern/arch/mips/mips/syscall.c contient la routine de gestion des appels système. Nous rajoutons à la position ad-hoc le code suivant:

case SYS___getcwd:
    err=sys__getcwd(&retval,tf->tf_a0,tf->tf_a1);
    break;

3.0.2. Rajout du prototype

Le fichier include/syscall.h contient les prototypes des fonctions noyau pour les appels système; rajoutons notre prototype:

int sys__getcwd(int*,char*,size_t);

3.0.3. Le code

Créons un répertoire kern/fs/ul et le fichier kern/fs/ul/sc.c pour y mettre le code de nos appels système relatifs aux systèmes de fichier. Ce fichier ne sera pas compilé par défaut; rajoutons la ligne suivante dans kern/conf/conf.kern:

file            fs/ul/sc.c

Il faut reconfigurer le noyau pour que les changements soient pris en compte (à l'aide de la commande ./config ASST2).

Maintenant nous pouvons nous concentrer sur le code proprement dit.

int sys__getcwd(int* r,char* buf,size_t size){
        struct uio u;
        int ret;
        u.uio_iovec.iov_un.un_ubase = buf;
        u.uio_iovec.iov_len         = size;
        u.uio_offset                = 0;
        u.uio_resid                 = size;
        u.uio_segflg                = UIO_USERSPACE;
        u.uio_space                 = curthread->t_vmspace;
        u.uio_rw                    = UIO_READ;
        ret                         = vfs_getcwd(&u);
        if(ret)*r=-1;
        else *r=u.uio_offset;
        return ret;
}

Et voilà! Votre appel système _getcwd() est terminé. Il ne vous reste plus qu'à écrire les votres maintenant.

open(), read(), write(), lseek(), close(), dup2() et chdir()

Même si ces appels semblent être fortement liés aux systèmes de fichiers (que vous n'avez pas écrits et n'aurez pas à écrire), en réalité ils ne consistent qu'à manipuler des descripteurs de fichiers ou des états du système de fichier propres à chaque processus. Une part importante de cette partie est l'implémentation d'un système pour suivre cet état. Une partie de cette information (comme le répertoire courant) est spécifique seulement au processus et d'autres (tels que les offsets des fichiers) sont spécifiques au processus et au descripteur de fichiers.

Pour chaque processus, les premiers descripteurs de fichier (0, 1 et 2) sont, respectivement, l'entrée standard (stdin), la sortie standard (stdout) et la sortie d'erreur (stderr). Ces descripteurs de fichier devront être attachés, au début, à la console (con:) mais votre implémentation se doit d'autoriser les programmes à utiliser dup2() pour les changer.

getpid()

Un PID est un numéro unique qui permet d'identifier un processus. L'implémentation de getpid() n'est pas très maline mais l'allocation et la désallocation des PID sont des concepts importants que vous devez aussi implémenter. Ce n'est pas une bonne idée que votre système plante au bout d'un certain temps parce que vous avez utilisé tous les PID.

fork(), execv(), waitpid(), _exit()

Ces appels sont certainement la partie la plus difficile de ce devoir, de fait la plus récompensée aussi. Ils permettent la multiprogrammation et font d'OS/161 un système d'exploitation bien plus utile.

fork() est le mécanisme de création de nouveaux processus. Il doit faire une copie du processus courant et s'assurer que les processus père et le fils reçoivent chacun la bonne valeur de retour (0 pour le fils et le PID du fils pour le père).

execv() est le coeur de ce devoir. Il permet de faire exécuter à des processus quelque chose de plus utile que le code de son père après un fork par exemple. Il doit remplacer l'espace d'adressage existant par un autre tout nouveau pour l'exécutable (créé en appelant as_create dans le présent système dumbvm) et de l'exécuter. Même si cela ressemble au lancement d'un processus depuis le noyau (comme runprogram() le fait), ce n'est pas si simple. Rappelez-vous que cet appel vient depuis l'espace utilisateur dans le noyau et doit ensuite retourner en espace utilisateur. Vous devez gérer la mémoire qui passe ces frontières avec beaucoup de prudence. Remarquez aussi que runprogram() ne prend pas un vecteur d'arguments mais ceci doit aussi être géré correctement par execv().

Même si cela peut paraître simple à première vue, waitpid() nécessite de réfléchir un peu. Lisez attentivement la documentation pour en comprendre la sémantique et servez-vous en comme modèle de votre implémentation. Vous pouvez aussi regarder la page de manuel UNIX mais il n'est pas demandé d'implémenter tout ce que le waitpid() UNIX supporte.

L'implémentation d'_exit() est intimement liée à celle de waitpid(). Ce sont deux moitié du même mécanisme. Le plus souvent le code pour _exit() sera simple et celui de waitpid() plus complexe.

Une note sur les erreurs et la gestion d'erreur des appels système

Les pages de manuel d'OS/161 contiennent une description des valeurs de retour que vous devez renvoyer. S'il y a des possibilités qui peuvent arriver et qui ne sont pas listées dans ces pages, renvoyez le code d'erreur qui vous semble le plus approprié (cf kern/errno.h). Si vraiment aucun ne semble convenir, vous pouvez en ajouter un nouveau. Si vous rajoutez un code pour un cas pour lequel UNIX a un code d'erreur standard, utilisez le même symbole si possible. Sinon, trouvez-lui un nom mais gardez en tête que les codes d'erreur doivent commencer par E, ne doivent pas être EOF, etc. Consultez les pages de manuel pour en apprendre plus sur les codes d'erreur UNIX (man errno).

Notez aussi que si vous rajoutez un code dans src/kern/include/kern/errno.h, vous devez aussi rajouter le message d'erreur correspondant dans le fichier src/lib/libc/strerror.c.

4. Tester et utiliser le shell

Dans bin/sh vous trouverez un simple shell qui vous permet de tester vos appels système. Lorsqu'il est exécuté, le shell affiche une invite de commande et vous permet d'en taper pour lancer d'autres programmes. Chaque commande et sa liste d'arguments est passée à execv() après un appel à fork() pour avoir un nouveau processus pour cette exécution. Le shell supporte aussi le lancement de programmes en arrière plan avec &. Vous pouvez quitter le shell en tapant exit.

Sous OS/161, une fois que vous avez écrit les appels système de ce devoir vous devriez être capables d'utiliser le shell pour exécuter les programmes suivants présents dans le répertoire bin: cat, cp, false, pwd et true. Les programmes présents dans le répertoire testbin peuvent aussi vous être utile.