Inside the Hurd Operating System Copyright 2003 Matthieu Lemerre

A l'interieur du Hurd
*********************

Ce document devrait permettre une compréhension globale du Hurd. Pour toute remarque et suggestion, mailez-moi: racin at free dot fr

Presentation générale sur les OS
********************************

Attention, certains de ces principes ne s'appliquent qu'aux ``véritables'' systèmes d'exploitations, qui différencient plusieurs utilisateurs et qui peuvent exécuter plusieurs programmes en même temps.

Dans ces systèmes, un utilisateur n'est pas libre de faire tout ce qu'il veut, et cela constitue une protection. Par exemple, un processus ne peut pas écrire dans l'espace d'adressage de son voisin, et si il le fait il plante et émet un SEGFAULT (sous les systèmes compatibles POSIX). Cela protège l'ensemble du système.

En particulier, pour un processus donné, les autres processus, la mémoire qui n'appartient pas à ce processus, les entrées sorties sont protégés. Aucun programme ne peut y accéder directement, il doit demander au noyau de le faire à sa place.  Pour cela, Un processeur possède plusieurs modes d'exécutions (ring), dont uniquement 2 sont utilisés sous les systèmes POSIX: le mode utilisateur, et le mode noyau (aussi appelé mode superviseur).  En mode noyau, un certain nombre d'instructions supplémentaires sont disponibles, comme les instructions d'entrée sortie.

Si il existait une instruction permettant à n'importe quel processus utilisateur de s'exécuter en mode noyau, cela poserait évidemment un gros problème de sécurité, puisque qu'un utilisateur malicieux pourrait outrepasser les mesures de sécurité mises en places par le système.

Un programme utilisateur a pourtant besoin d'effectuer des opérations qui ne sont permises que par le noyau, comme par exemple l'affichage d'un texte à l'écran (qui constitue une entrée sortie).

(On comprend bien que seul le noyau puisse faire cela, sinon pendant que notre programme s'exécute, un autre programme pourrait afficher plein de choses que nous ne voudrions pas).

Comment alors contrôler ce passage en mode noyau? La solution mise en place s'appelle l'appel système: le noyau fournit un certain nombres de fonctions que tout utilisateur peut appeler. On n'appelle pas ces fonctions de manière classique , mais par une instruction ``spéciale'', qui indique quel est le numéro de cet appel. Les paramètres sont le plus souvent transmis par les registres (ce qui est plus rapide).  L'un des rôles de la librairie C est de fournir une interface standard pour effectuer ces appels systèmes.  Ainsi sous GNU/Linux, un programme effectuant un appel à fork() est lié a la glibc, qui remplira les registres et paramètres nécessaires à l'appel système Linux sys_fork().

Ces appels systèmes sont divers et variés. Sur un noyau UNIX standard, il y en a un certain nombre. Par exemple, Linux en fournit plus de 200. Minix en fournit 40.

Un noyau monolithique comme Linux fournit un grand nombre d'appels systèmes pour rendre les différents services aux programmes. Par exemple, à chaque modification du système de fichier (chdir, chown, etc) correspond un appel système.

Il existe une autre possibilité: c'est d'implémenter ces fonctions dans des processus systèmes tournant en mode utilisateur et appelés par le noyau. Le noyau joue ainsi le rôle de messager entre ces processus et les processus de l'utilisateur. Les appels systèmes du noyau se réduisent donc qu'à un ensemble de primitives de transmission de message. Un tel noyau s'appelle alors un micronoyau. L'exécution de ces fonctionnalités n'est plus faite au sein du processus utilisateur, mais du processus système appelé, qui peut même être situe sur une autre machine. C'est pour cela qu'un appel de ce type s'appelle un RPC (Remote Procedure Call).

Pour résumer, un noyau monolithique fournit beaucoup d'appels systèmes, des serveurs au dessus d'un autre noyau fournissent des RPC.

Les avantages de construction d'un système par micronoyau sont les suivants:

  * Le design en couche est mieux respecté

  * Les processus systèmes sont indépendants (ils peuvent planter sans s'affecter les uns les autres)

  * Ils sont dynamiquement remplaçable

  * Le noyau est plus facilement préemptif et multithreadé

Le gros désavantage est que la transmission d'un message demande bien plus de temps qu'un appel système classique.

Le problème est que le design du Hurd fait qu'il ne se sert pas de toutes les fonctionnalités de Mach, comme le serveur de nom. Le Hurd a ``juste'' besoin de passages de paramètres extrêmement rapide. L'objectif des micronoyaux actuel est de réduire ce temps de transmissions d'un message. Pistachio est particulièrement bon dans ce domaine, c'est pourquoi le portage du Hurd sur l4 est si prometteur.

Pour le moment le Hurd tourne sur Mach, et c'est largement suffisant pour être heureux de développer dessus!

Le Hurd constitue donc un ``troupeau'' de processus systèmes s'exécutant en mode noyau, et qui fournit un certains nombres de fonctions le rendant compatible POSIX. Mais le Hurd est bien plus que cela. Chaque utilisateur est libre de remplacer la quasi-totalité de ces processus pour le remplacer par le sien. Chaque utilisateur peut ainsi littéralement remplacer une partie du noyau pour qu'elle lui convienne, et ce sans gêner les autres utilisateurs qui continueront à utiliser le processus système qu'il y a par défaut! Ceci permet d'étendre considérablement la liberté de l'utilisateur, qui peut ainsi par exemple ``monter'' (c'est-à-dire, attacher la racine d'un système de fichier à un répertoire, mais nous verrons que ce concept est bien étendu avec le Hurd) tout système de fichier qu'il souhaite en préservant la sécurité de chacun. C'est un défi particulièrement difficile à relever, et c'est pour cela que le Hurd semble si complexe.

Toutefois, la modularité du Hurd permet de comprendre certaines parties indépendemment des autres. C'est ce que je vais faire: vous expliquer chaque partie que vous allez comprendre indépendemment, et a la fin de votre lecture, vous aurez une vue d'ensemble du Hurd. Au début, certaines choses que nous verrons vous paraîtront même sans rapport (par exemple, entre l'implémentation d'un translator avec trivfs et l'envoi de 2 messages sous Mach.) N'hésitez pas a relire les parties plusieurs, fois, ainsi vous verrez mieux les liens entre elles.

Mach en détail
**************

Qu'est ce que Mach?
===================

Mach est un micronoyau de première génération, développé à l'Université Cargenie-Mellon. Il fournit une base intéressante à d'autres OS. Le Hurd, Darwin (la base de Mac OS X) sont basés dessus.

Mach étant pour l'instant le seul micronoyau sur lequel tourne le Hurd, vous allez devoir apprendre quelques concepts de Mach pour comprendre le Hurd. Pour autant, il vaudra mieux par la suite utiliser des librairies de plus haut niveau pour développer des translators, pour les rendre plus portables. Mais il est important de comprendre les concepts de bas niveau.

Les IPC sous Mach
=================

La fonction principale de Mach sous le Hurd étant la transmission de messages, intéressons-nous aux IPC sous Mach.

IPC peut être considéré comme un abus de langage, puisque les ressources d'exécution sous Mach sont appelées des tâches.  Le système d'IPC (inter-process communication) permet donc aux différentes tâches de communiquer entre elles.

Les IPC sous Mach reposent sur trois concepts: celui de message, celui de port, et celui de droit sur un port (port right, ou port capability).

Les ports sont les moyens de communication entre les différentes tâches. Les port rights sont les moyens d'accéder à ces ports. Il y en a 3 sortes: les droits de réception (receive right), les droits d'envoi (send rights) et les droits d'envoi unique (send once right). Pour manipuler ces port rights, on dispose de port name (type mach_port_t).  Lorsqu'une tâche crée un port, elle obtient ainsi un port name sur un receive right vers ce port. Ce receive right est unique: une seule tâche a la fois peut recevoir des données en provenance d'un port.  Les tâches peuvent se fabriquer, copier ou transférer des port rights par les messages. Le receive right peut être transféré, mais pas copié ni fabriqué.

Il existe aussi la notion de port set, qui permet à une tâche d'écouter plusieurs ports en même temps.

Les ports rights sont gérés par le noyau et sont entièrement sécurisés: aucune tâche ne peut se rajouter des droits. Les messages sont assurés de parvenir à leur destinataires, et dans l'ordre dans lequel ils ont été envoyés.

Pour plus d'info consulter le manuel info de GNU Mach, et le wiki http://hurd.gnufans.org.

Exemple: envoi de messages avec Mach
====================================

Pour illustrer ces propos je vais vous montrer comment envoyer des messages avec Mach. Si nous voulions écrire 2 programmes séparés, un client et un serveur, qui communiquent entre eux, il serait difficile pour chacun de ces programmes de trouver le port de l'autre, et donc de communiquer. Pour simplifier, nous allons donc écrire un programme qui va tout d'abord allouer un port puis se scinder en deux threads, ainsi  chacun de ces threads pourra communiquer avec l'autre. Nous utiliserons les pthread plutôt que les cthreads pour faire cela, puisque les pthreads sont destinés à devenir la bibliothèque de référence en threading sous le Hurd (comme sous tout UNIX).

     /* machipc.c Communication entre deux threads par envoi de messages     *
      *  mach.                                                               *
      * Copyright 2003-2004  Matthieu Lemerre                                *
      * Distributed under the terms of the GNU General Public License.       *
      * This is distributed as is". No warranty is provided at all.          */

     #define _GNU_SOURCE 1

     #include <stdio.h>
     #include <string.h>		/*memcpy */
     #include <unistd.h>		/*sleep */
     #include <pthread.h>
     #include <error.h>
     #include <mach/message.h>
     #include <mach/port.h>
     #include <hurd/hurd_types.h>

     #define HEADER_LEN sizeof(mach_msg_header_t)
     #define TYPE_LEN  sizeof(mach_msg_type_t)
     #define BUFF_LEN  200  /*Ce que l'on veut vraiment envoyer*/
     #define MSG_LEN HEADER_LEN+TYPE_LEN+BUFF_LEN

     mach_port_t port;

     void *
     fn_client (void *arg)
     {
       char nom[] = "thread client";
       mach_msg_return_t msg_err;
       mach_msg_header_t header = {
         MACH_MSGH_BITS (MACH_MSG_TYPE_MAKE_SEND_ONCE, MACH_MSG_TYPE_MAKE_SEND_ONCE),	/*mach_msg_bits */
         BUFF_LEN,			/*msg_size */
         port,			/*Le port ou envoyer */
         0,			        /*Le port local ou l'autre va renvoyer(non indispensable) */
         0,				/*Le numero du message. Ne sert pas quand on envoie un message */
         0                           /*Un champ qui ne sert a rien */
       };

       mach_msg_type_t type = {
         MACH_MSG_TYPE_STRING,	/*type */
         8,				/*Nombre de bits de chaque datum. */
         sizeof (nom) + 1,		/*Nombre de datums */
         TRUE,			/*On envoie un message in-line */
         0,				/*C'est un message pas "long" */
         0,				/*2 champs qui servent qu'aux out-line */
         0
       };

       //faire un mmap plutot
       mach_msg_header_t *buffer = malloc (MSG_LEN);
       char *ptr=buffer;
       memcpy (buffer, &header, HEADER_LEN);
       ptr+=HEADER_LEN;
       memcpy (ptr, &type,TYPE_LEN);
       ptr+=TYPE_LEN;
       strcpy (ptr, nom);
       msg_err = mach_msg (buffer, MACH_SEND_MSG, buffer->msgh_size, 0, MACH_PORT_NULL, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);	/*Envoi du message */
       free(buffer);
       pthread_exit(0);
     }

     int
     main (int argc, char **argv)
     {
       pthread_t client;
       mach_port_t current_task;
       void *resultat_client;
       kern_return_t err;
       const char nomclient[] = "nom_du_client";
       mach_msg_header_t *buffer = malloc (BUFF_LEN);
       char *dup;
       mach_msg_return_t msg_err;

       mach_msg_header_t server_header = {
         0,				/*mach_msg_bits */
         BUFF_LEN,			/*msg_size */
         0,				/*Le port ou envoyer */
         0,				/*Le port local ou l'autre va renvoyer */
         0,				/*Le numero du message. Ne sert pas quand on envoie un message */
         0                           /*Un champ qui ne sert a rien */
       };
       /*Recuperation de la tâche courante*/
       current_task = mach_task_self ();
       /*Creation d'un nouveau port dans port, avec un droit de reception pour la tâche courante*/
       err = mach_port_allocate (current_task, MACH_PORT_RIGHT_RECEIVE, &port);
       /*On se rajoute le droit d'envoyer des messages vers ce port*/
       err = mach_port_insert_right (current_task, port, port,MACH_MSG_TYPE_MAKE_SEND);

       /*Creation d'un client*/
       client = pthread_create (&client, NULL, fn_client ,"hello");

       /*Preparation de la reception*/
       memcpy (buffer, &server_header, HEADER_LEN);

       /*Cet appel est bloquant*/
       msg_err = mach_msg (buffer, MACH_RCV_MSG, 0, buffer->msgh_size, port, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);	/*Reception du message */
       printf ("Le client m'a envoye son nom. C'est %s\n", (char *) buffer + HEADER_LEN + TYPE_LEN);
       pthread_join(client,&resultat_client);
       return (0);
     }

Le fonctionnement global du programme est le suivant: nous initialisons le port dans port, puis le programme se scinde en 2 threads. L'un écrit dans ce port et l'autre lit.

L'appel mach_task_self() permet de récupérer la tâche du programme. Nous en avons besoins pour indiquer au noyau dans quelle tâche nous allons créer ce port (mach_port_allocate()). Le port étant créé, port est un port name, un moyen d'accéder à un port en réception. Pour pouvoir envoyer des messages dans ce port, il faut insérer un droit d'envoi dedans (mach_port_insert_right()).

Enfin on se scinde (pthread_create()), puis on prépare la réception. La transmission d'un message sous Mach se passe par l'intermédiaire de la fonction mach_msg(). Un message a la structure suivante:

     +-----------------------------------------
     |Header|Type1|Contenu1|Type2|Contenu2|...|
     +-----------------------------------------

Le client doit donc initialiser un buffer avec une telle structure. Si le contenu est trop large, le message doit être passé outline, par transmission de pointeurs. La tâche réceptionnant le message doit aussi initialiser une telle structure.

Enfin, le thread serveur attend le client (pthread_join()) et le programme quitte.

Dans un vrai programme, les droits d'envois sont transmis par les messages.

Pour plus d'info voir ftp://ftp.cs.cmu.edu/afs/cs/project/mach/public/doc/osf

Envoi de RPC en utilisant Mig
=============================

Comme vous venez de le voir, faire passer des messages n'est pas une chose triviale. Dans le Hurd, nous utilisons les messages pour envoyer nos RPC. Pour nous faciliter la vie, il existe un outil appelle Mig, le Mach Interface Generator. Son seul et unique but est de simplifier l'écriture d'interfaces de serveur Mach et de rendre les choses bien plus lisibles.

Voila la manière de définir une interface:

     /* hello.defs Definition de l'interface d'un serveur de bonjours        *
      * Copyright 2003-2004  Matthieu Lemerre                                *
      * Distributed under the terms of the GNU General Public License.       *
      * This is distributed äs is". No warranty is provided at all.          */

     #include <mach/std_types.defs>

     type string_t = c_string[1024]; /*un type Hurd standard*/
     import "type.h";

     subsystem hello 53400; /*534 is arbitrary*/
     serverprefix S_;  /*These two are necessary because we use the 2 functions in the same program*/

     serverdemux demux; /*Pour que Mig genere la fonction de demultiplexage du message*/

     /*Le premier argument doit etre un "request port"*/
     /*Il est utilise lors du transfert des messages, mais aussi communique au serveur*/

     routine hello_hello
     (
     	server: mach_port_t;
     	name: string_t;
     	out greeting: string_t
     );

Une fois ce fichier créé, on le passe à la moulinette avec Mig: mig hello.defs

Cela produit les fichiers helloServer.c, helloUser.c et hello.h.

Mig est le Mach interface Generator, c'est a dire le programme qui permet de générer du code pour que la communication par RPC soit un appel de fonction pour le programmeur.

Dans cet exemple, hello est une routine, c'est à dire un RPC synchrone, un message aller-retour entre un client et un serveur.

Lorsque le client a connaissance du port du serveur sur lequel envoyer sa RPC, il suffit d'écrire: hello_hello(port,"racin",greeting) avec port le port du serveur et greeting un buffer alloue dans lequel on va recevoir la réponse, pour envoyer un message au serveur sans passer nous-même par la puissante mais complexe commande mach_msg directement. hello_hello est une fonction générée par Mig et placée dans helloUser.c, donc lors de la compilation il suffit de lier notre client au fichier objet et de faire un #include "hello.h" pour pouvoir utiliser cette fonction. hello.h contient la définition de hello_hello.

Essayons de comprendre un peu chaque ligne: (pour plus de détails sur Mig voir le server writer's guide, chapitre 3

type string_t = c_string[1024]; Définit le type string_t

subsystem hello 53400;  Définit deux choses: le nom associé au server, qui sert de préfixe au noms des fichiers crées; et un message-base-id, qui permet de renseigner le champ msgh_id lors de l'envoi de messages. Ce champ permet de savoir quelle est l'interface demandée au serveur. (En effet, la plupart des serveurs peuvent répondre a plusieurs RPC, il faut bien savoir les distinguer). C'est pour cela qu'il existe une directive skip pour les interfaces dans lesquelles on ne définit pas toutes les routines (dans le Hurd, beaucoup d'interfaces sont séparées en interfaces write et read)

serverprefix S_;

Lorsqu'on veut faire communiquer des programmes par RPC avec Mig, les serveurs doivent présenter une fonction pour renvoyer les données. serverprefix est le préfixe rajoute au nom de la routine pour déterminer le nom de la fonction. Dans notre exemple le prototype de cette fonction est : kern_return_t S_hello_hello(mach_port_t port, string_t name, string_t greeting) Mig s'occupe d'appeler cette fonction lors de la réception d'un message. Il existe aussi une commande userprefix, et par défaut le userprefix et le serverprefix sont des chaînes vides.

Dans le Hurd on utilise S_ comme serverprefix en général, et les routines ont des noms composés selon le schéma nomsubsystem_nomroutine

serverdemux hello_server;

Cette directive demande a Mig d'appeler la fonction qui demultiplexe les messages vers le server server_demux. nomsubsystem_server est le nom par défaut. Cette fonction décompose les RPC vers le serveur grâce aux messages ids, et appelle la bonne fonction.

/*Le premier argument doit être un "request port"*/ /*Il est utilisé lors du transfert des messages, mais aussi communique au serveur*/

     routine hello
     (
     	server: mach_port_t;
     	name: string_t;
     	out greeting: string_t
     );

Ceci définit une routine, c'est-à-dire une RPC synchrone.  Le client envoie un message qui est traité par le serveur. Ce dernier renvoie à son tour un message par un port alloué automatiquement par la fonction créée par Mig. Pour les RPC asynchrones, on utilise simpleroutine.  Le premier argument d'une RPC est toujours un port name, correspondant à un port sur lequel écoute le serveur. (Pour savoir comment le client peut obtenir ce port, voir le chapitre suivant) La syntaxe de passage des arguments est la suivante:

specification nomvar: type [IPC flags];

specification est souvent soit in (implicite quand specification n'est pas définit) soit out, soit inout. in désigne un paramètre que le client fournit au serveur, et out l'inverse, et inout pour les deux.

Pour s'échanger des informations, le client alloue une zone mémoire qu'il ``donne'' au serveur. Ce dernier peut modifier cette zone mémoire et ainsi retourner une réponse au client. Si la taille de cette zone mémoire n'est pas assez importante, le serveur peut allouer une nouvelle zone pour y mettre les informations qu'il souhaite. Ces pages sont ensuite réattachées à l'espace d'adressage du client par l'envoi du message de retour. (C'est ce qu'on appelle des messages out-of-line).

Il existe divers flags. Le flag deallocate sert à retirer de la tâche appelante le port right qu'elle avait ou à déallouer la zone mémoire transférée dans le cas des messages out-of-line.

Voila ce que devient notre application hello lorsqu'on utilise Mig:

     //Nécessaire pour compiler
     #define _GNU_SOURCE 1

     #include <pthread.h>
     #include <stdio.h> //printf
     #include <stdlib.h> //exit
     #include <unistd.h> //sleep
     #include <string.h>

     #include <mach.h>
     #include <mach/message.h>
     #include <mach/notify.h>
     #include <mach_error.h>

     #include "hello.h"

     static mach_port_t port;

     #define WELCOME_MESSAGE "Bonjour a toi, "
     #define MAX_MSG_SIZE 5120

     boolean_t hello_server(mach_msg_header_t *InHeadP, mach_msg_header_t *OutHeadP);

     void* client(void *param) {//l'un des threads appelle cette routine
       char *greeting;
       greeting = malloc(100);
       hello_hello(port,"Matthieu",greeting);
       printf("%s\n",greeting);
       pthread_exit(0);
     }

     int main() {

       pthread_t client_thread;
       void *resultat_client;

       //On alloue le port pour le serveur
       mach_port_allocate(mach_task_self(),MACH_PORT_RIGHT_RECEIVE,&port);
       //On s'auto-ajoute le droit de s'envoyer des messages
       mach_port_insert_right(mach_task_self(),port,port,MACH_MSG_TYPE_MAKE_SEND);//normalement c'est pas la methode pour recevoir des send_rights.

       //Creation du thread client
       pthread_create (&client_thread, NULL, client ,"hello");

       //le thread principal est utilise comme serveur
       mach_msg_server(demux,MAX_MSG_SIZE,port);

       //On attend que le client s'arrete nous rende un resultat
       pthread_join(client_thread,&resultat_client);
       exit(0);

     }

     kern_return_t S_hello_hello(mach_port_t port, string_t name, string_t greeting)
     {
       //greeting est fournit. Normalement le client devrait fournir la longueur de son buffer.
       printf("Le nom du client est: %s\n",name);
       strcpy(greeting,WELCOME_MESSAGE);
       strcat(greeting,name);
       return KERN_SUCCESS;
     }

Il suffit de compiler et tout devrait marcher: gcc server.c helloServer.c helloUser.c -D_GNU_SOURCE -lthreads  -Wall

La grande différence entre les deux exemples est donc que celui-ci utilise deux ports pour l'envoi et la réception de messages, tandis que le précédent utilisait le même port (pour simplifier et ne pas trop augmenter la taille du code).

Vision objet du Hurd.
=====================

Si nous nous adoptons une vue plus globale, nous pouvons assimiler les serveurs à des objets, les interfaces étant la déclaration des méthodes, les RPC un passage de message d'un objet à un autre, et les ports étant des références vers un autre objet. Cette vision est importante, et nous aidera à comprendre le fonctionnement de L4/Hurd.

Désormais vous devriez être capable de lire toutes les différentes interfaces du Hurd. Celles-ci sont définies dans l'arbre des sources du Hurd dans le sous répertoire hurd.

Connaître les ports d'un serveur
================================

Dans tous nos exemples, les tâches étaient constituées de 2 threads qui communiquait par un port créé spécifiquement pour ça. Dans le cas général, on ne peut pas procéder comme ça. Quelles sont les méthodes possibles pour obtenir un port d'un serveur? Ce qui est généralement fait sous Mach est d'utiliser un serveur pour cela appelé serveur de nom, et lorsqu'on cherche un port vers un serveur on le demande au serveur de nom. Ce n'est pas la solution retenue par le Hurd. Sous le Hurd, on utilise le système de fichier comme moyen de trouver les ports. Un serveur doit donc s'attacher à un fichier, et lorsqu'on veut communiquer avec il suffit de demander le fichier en question au serveur de système de fichier parent pour obtenir un port vers lui. C'est ce qu'on appelle un translator.

Les translators
***************

Un translator est donc un serveur qui s'attache à un fichier. Lorsqu'un tâche est lancée sous Mach, elle a accès à plusieurs ports, parmi ceux-ci un bootstrap port. Ce port est utilisé pour communiquer avec la tâche appelante. Le protocole de création des translators est assez complexe et sera abordé plus tard.

Comme l'a dit Thomas Bushnell: ``If you want to be part of the filesystem, you must implement the fsys_* calls.  You must also implement the file_* and io_* calls for the node you are inserting into the filesystem.''

Allez voir le Hurd hacking guide pour un exemple d'utilisation de trivfs.

Voila un exemple d'utilisation de netfs: un Fibonnacci translator