Informations extraites de la thèse de Frédéric Ogel : « Environnements d'exécution dynamiquement adaptables ».
Nous présentons les propriétés nécessaires pour supporter une flexibilité dynamique complète ainsi que leur mise en œuvre pour aboutir à une architecture d'environnement d'exécution dynamiquement adaptable, appelé NEVERMIND (NEVERMIND est un EnVironnement Extensible, Réflexif, MINimal et Dynamique).
Nous commençons par détailler les propriétés de NEVERMIND supportant la flexibilité dynamique, avant de présenter son architecture. Nous décrivons ensuite sa mise en œuvre en tant qu'environnement sur machine nue, puis son intégration à un noyau existant (LINUX) sous la forme d'une extension.
La figure 1 illustre une application et son environnement d'exécution, constitué des composants « système » et des composants « langage ». Le code source de l'application est transformé par les composants langages, suivant une syntaxe, une sémantique et une mise en œuvre particulière (i.e. pragmatique). L'exécution du produit de cette transformation repose sur l'implantation du moteur d'exécution et éventuellement sur des bibliothèques annexes (tout deux servant d'intermédiaires entre l'application et le système). Traditionnellement le développeur n'a de contrôle que sur le code source de son application. Si l'environnement ne correspond pas exactement à la sémantique ou aux besoins de l'application, le développeur ne dispose que de deux alternatives : soit il adapte son application à ce qu'offre l'environnement, soit il se tourne vers un autre environnement, lorsque cela est possible.
Notre objectif est de rendre le contrôle sur les composants système et langage au développeur, par le biais d'un environnement d'exécution dynamiquement adaptable. Afin d'offrir une flexibilité dynamique complète, un tel environnement doit s'appuyer sur trois propriétés essentielles :
Figure 1 – Environnement d'exécution d'une application.
Comme illustré par l'approche Exokernel, plus les primitives d'un système sont de bas niveau, plus ce système est flexible. En d'autres termes, plus un système repose sur un nombre important d'abstractions prédéfinies, moins il sera possible d'adapter ce système à divers domaines applicatifs. Ainsi, pour une flexibilité maximale, un environnement d'exécution ne doit proposer qu'un minimum d'abstractions prédéfinies. L'architecture proposée devra donc s'en affranchir autant que possible.
Le principe de minimalité est assuré à travers un couplage des aspects système et langage au plus bas niveau. L'architecture repose sur une partie langage réflexive définissant une chaîne de compilation dynamique, ainsi que sur une réification « politiquement neutre » des ressources matérielles : aucune abstraction supplémentaire n'est définie et aucune sémantique n'est ajoutée à l'interface de ces ressources. Ainsi un environnement d'exécution minimal est défini comme un compilateur dynamique dénué d'abstractions prédéfinies figées.
La construction d'un environnement d'exécution de plus haut niveau, dédié à tel ou tel domaine applicatif, se fait à partir de l'environnement minimal. Chaque abstraction ou mécanisme système ajouté sera dynamiquement reconfigurable, de par la flexibilité du code dynamiquement généré. De même, la partie « langage » de l'environnement minimal peut être adaptée ou enrichie pour refléter la sémantique du domaine applicatif cible.
Afin d'être dynamiquement adaptable, un environnement doit être extensible dynamiquement et donc permettre l'ajout de code « à la volée ». Pour cela, il est indispensable de disposer d'un mécanisme de chargement dynamique de code, tel que les librairies dynamiques de LINUX ou WINDOWS, ou le chargement dynamique de classes des machines virtuelles JAVA ou SMALLTALK. Le chargement dynamique s'appuie sur une ou plusieurs méthodes d'entrée (clavier, réseau, disque, etc) pour lire du code. Il peut s'agir de code natif (format ELF par exemple), chargé via un composant, préalablement défini, capable de « décoder » le format et d'effectuer la liaison dynamique, ou bien de code abstrait (bytecode, AST) en utilisant un composant, également préalablement défini, capable de la transformation du code abstrait en AST en vue de la compilation en code natif.
Cependant, l'extensibilité dynamique ne sufit pas pour offrir une flexibilité dynamique complète. Tout d'abord le simple ajout de fonctionnalités ne permet pas forcément la modification des parties préexistantes de l'environnement d'exécution. De plus, le chargement dynamique d'une extension correspond à une volonté exprimée statiquement dans le code du programme. Il s'agit en effet d'adapter tout ou partie de l'implantation de l'application à son contexte d'exécution ainsi qu'à l'occurrence éventuelle de certains événements prédéfinis, tels qu'une déconnexion réseau ou la faute d'un composant. Mais cela ne sufit pas pour être en mesure de réagir à une variation imprévue du contexte d'exécution, voire à un quelconque événement qui n'a pas été envisagé lors de la conception du programme.
L'adaptabilité dynamique est le pendant de l'extensibilité dynamique, en cela qu'elle concerne la modification du code préexistant. Pour atteindre cet objectif, l'environnement d'exécution doit offrir un accès complet à une représentation logicielle de son implantation afin de permettre sa manipulation : sa réification est donc nécessaire. Il faut ici distinguer la réflexivité du langage d'implantation de l'application qui réifie le code de celle-ci, permettant ainsi son adaptation dynamique, de la réflexivité du support d'exécution qui permet l'adaptation dynamique des mécanismes et abstractions supportant l'application. Par exemple, JAVA et SMALLTALK sont deux langages réflexifs, offrant donc certaines facilités d'adaptation du code applicatif. Cependant, leurs machines virtuelles respectives restent des environnements d'exécution rigides et fermés, dont les mécanismes internes (interprétation, format de bytecode, gestion mémoire, etc) ne peuvent être adaptés dynamiquement.
L'architecture proposée offre donc une réification complète des mécanismes de base et permet la mise en œuvre de langages réflexifs. Pour cela, les mécanismes de compilation conservent des méta-données sur le code généré, permettant ainsi une manipulation directe de sa définition, par défaut sous forme d'un AST.
La réification des composants de base, ainsi que la compatibilité binaire entre le code compilé statiquement et le code dynamiquement généré permet le remplacement dynamique de toute partie de l'environnement, qu'elle ait été générée dynamiquement ou statiquement.
Figure 2 – Architecture de l'environnement minimal d'exécution.
La spécialisation de notre environnement se fait au moyen de spécification active (ou scripts) décrivant les aspects système et langage spécifiques au domaine cible. L'architecture proposée pour l'environnement minimal est illustrée sur la figure 2. Une partie basse est chargée de la réification des ressources matérielles, sans ajout de sémantique. Elle permet entre autre l'accès aux bus, vecteur d'interruptions, MMU (Memory Management Unit), etc. Le seul service requis par la chaîne de compilation dynamique est un allocateur mémoire (physique ou virtuelle). À ce niveau, aucun modèle de mémoire ou de concurrence n'est imposé. La compilation dynamique est assurée par une chaîne de composants indépendants.
Des composants supplémentaires sont ensuite liés au système (par exemple des pilotes de périphériques tels que clavier, souris, console, carte réseau, etc) statiquement (c'est-à-dire lors de la compilation de l'image amorçable de l'environnement) ou dynamiquement, afin de construire un système dédié. Par exemple, si un script contenant un pilote Ethernet ainsi qu'un micro-protocole de type TFTP (Trivial File Transfert Protocol) est statiquement lié avec l'environnement minimal, un autre script peut alors être chargé lors du démarrage de cet environnement. Ce second script pouvant tout aussi bien contenir une spécification active d'environnement d'exécution particulière qu'un pilote pour le clavier et la carte graphique permettant alors à un utilisateur de charger interactivement des modules pour construire incrémentalement un environnement d'exécution spécialisé (modèle de composant, mécanismes d'invocation distantes via Ethernet, sérialisation, service de courtage, composants graphique, pilotes disques, etc). La figure 2 représente un environnement minimal incluant un script de démarrage mettant en place divers composants de base (dont le mini-TFTP). Les éléments de base de cet environnement sont présentés dans les sections suivantes.
Au cœur de l'architecture se trouve un compilateur dynamique réflexif. Il s'agit d'un ensemble d'interfaces et de composants permettant la construction dynamique par assemblage de chaînes de compilation quelconques. Le rôle de ce compilateur dynamique est avant tout de supporter l'extensibilité, c'est-à-dire de permettre l'ajout dynamique de code dans le système. Pour respecter le principe de minimalité , il doit être placé aussi près du matériel que possible. Idéalement, il ne repose que sur un allocateur mémoire, sans imposer de modèle mémoire particulier. Cette dépendance vient de la nécessité de stocker en mémoire le code produit par la compilation dynamique ainsi que les méta-données associées également issues de la phase de compilation. Aucun modèle de gestion de ressources (mémoire, concurrence, etc) n'est imposé. Enfin, la réflexivité de ce compilateur permet de rendre l'environnement complètement flexible, puisque chaque symbole (lié a un composant, une fonction,etc) « prédéfini » peut être dynamiquement adapté en modifiant sa liaison.
Le compilateur dynamique utilisé est la YNVM. Il est structuré sous la forme d'une collection d'interfaces et de composants basés sur le modèle de référence ODP et développés dans le cadre du projet MVV (Machine Virtuelle Virtuelle) par Ian Piumarta. Le premier objectif de la YNVM est de permettre la construction dynamique d'environnements d'exécution réflexifs et flexibles dynamiquement via la génération dynamique de code. Le second objectif est d'offrir un support pour la reconfiguration dynamique grâce à la réification des méta-données issues de la compilation ainsi qu'à la réification des liaisons entre symboles et valeurs.
Figure 3 – Architecture du compilateur dynamique YNVM.
La génération de code peut aussi bien s'effectuer directement dans la mémoire physique qu'au-dessus d'un modèle de mémoire virtuelle (paginée, distribuée, etc). Les composants internes, représentés sur la figure 3, reposent sur une mémoire objet interne avec ramasse-miettes utilisée pour le stockage des méta-données. Le parseur convertit un texte, reçu depuis le disque, le réseau ou une interface de type console, en un arbre de syntaxe abstraite (AST), stocké dans la mémoire objet de la YNVM. Le compilateur d'arbres syntaxiques convertit ensuite ces objets en instructions pour une machine abstraite à pile dont le modèle d'exécution et la sémantique sont similaires au C. Il maintient également à jour des méta-données sur l'état du code compilé et applique les éventuelles règles de transformations de code fournies par des scripts. Les méta-données sont organisées en espaces de nommages hiérarchiques appelés modules. Enfin, le générateur de code traduit la représentation intermédiaire, à base d'instructions abstraites, en instructions concrètes pour la plateforme locale. Le découplage en deux niveaux d'abstraction (AST et machine à pile) permet deux phases d'optimisation spécifiques et indépendantes de la machine, via deux compilations dynamiques : la compilation d'arbres syntaxiques en instructions abstraites puis la compilation de ces instructions abstraites en instructions concrètes. La génération des instructions concrètes se fait non plus dans la mémoire interne, mais dans la mémoire applicative (en général le tas) par l'intermédiaire d'un assembleur dynamique propre à la plate-forme cible. Si chaque expression est traditionnellement lue, compilée puis exécutée, un mécanisme de macro-expansion, appelé syntaxe, permet de définir du code exécuté pendant la phase de compilation dynamique. Cela permet notamment la mise en œuvre de mécanismes de réécriture et transformation de code.
Par défaut, le langage d'entrée de la chaîne de compilation est une représentation textuelle d'arbre de syntaxe abstraite de type LISP. Les composants internes de la YNVM étant accessibles au niveau applicatif, il est possible de redéfinir dynamiquement tout ou partie de la chaîne de compilation dynamique afin de prendre d'autres langages en entrée ou de changer la sémantique d'un langage.
(module dev.console) (component.define-state fb ;;composant framebuffer x y ;;position du curseur cols rows) ;;dimensions ;; définition d'une interface pour le composant (component.export-methods (putc char) (putcs string) (putxycs int int string)) ;; création d'une instance d'interface (define %default (:component.interface myPutcs myPutxycs)) (define new (lambda(fb nbcols nbrows) (component.new %default fb 0 0 nbcols nbrows))) ;; utilisation du composant (module global) (define fb-ptr (myBindingFactory bind (myTrader lookup "framebuffer"))) (define myConsole (dev.console.new fb-ptr 80 25)) (myConsole putcs "hello world!\n")
Figure 4 – Création d'un composant console.
La figure 4 illustre la définition et l'utilisation d'un composant de type console par un script utilisant le langage par défaut.
Un espace de nommage (dev.console) est défini pour contenir la définition du composant.
Cette définition comprend l'état (c'est-à-dire les attributs) déclaré par define-state
, une interface %default
et un constructeur new
.
L'utilisation de ce composant dépendant d'un composant framebuffer, une liaison est construite via un composant de courtage (myTrader) et une usine à liaisons (myBindingFactory) avant d'instancier le composant.
(require 'infix) (parseur.infix) module(dev.console); component.define-state(fb, //composant framebuffer x, y, //position du curseur cols, rows); //dimensions // définition d'une interface pour le composant component.export-methods(putc (char), putcs (string), putxycs (int, int, string)); // création d'une instance d'interface define %default = component.interface(myPutc, myPutcs, myPutxycs); defun new(fb, nbcols, nbrows) { component.new(%default, fb, 0, 0, nbcols, nbrows); }; // utilisation du composant module(global); define fb_ptr = myBindingFactory->bind(myTrader->lookup("framebuffer")); define myConsole = dev.console.new(fb_ptr, 80, 25); myConsole->putcs("hello world!\n"); parseur->prefix();
Figure 5 – Création d'un composant console avec parseur infixe.
La Figure 5 représente un script fonctionnellement équivalent, mais exploitant la flexibilité de la chaîne de compilation en remplaçant le parseur préfixe par défaut par un parseur infixe, plus proche d'une syntaxe C++, pour définir et activer le composant console. Le parseur par défaut est rétabli en fin de script.
À partir de l'environnement minimal, la définition de services et abstractions supplémentaires permettent de construire dynamiquement un environnement d'exécution spécifique. Par exemple, étant donné un pilote pour la carte réseau, on construit une pile UDP/IP sous la forme d'une chaîne de composants. La mise en place ultérieure de protocoles de communication pourra s'effectuer à n'importe quel niveau de cette pile : appel de méthode à distance au-dessus d'UDP ou directement via Ethernet, mise en place d'un support de la mobilité pour composants, via la génération dynamique de proxies assurant les redirections, etc.
Figure 6 – Construction dynamique d'un environnement dédié.
La figure 6 illustre l'extension dynamique de l'environnement de base pour construire un environnement dédié de type µ-ORB. Les commandes de chargement saisie au clavier sont transmises au compilateur dynamique pour évaluation. Leur exécution provoque l'envoi à travers le protocole « Mini-TFTP » de requêtes vers un entrepôt de modules. Les modules, s'ils sont disponibles, sont alors chargés et transmis au compilateur dynamique pour évaluation. Leur évaluation définit et assemble les éléments constituant l'environnement dédié. Dans cet exemple, le µ-ORB est principalement composé de trois éléments :
Toujours à partir de cet environnement de base, il est également possible de définir des politiques et/ou des méta-politiques d'administration. Par exemple, un protocole d'extension peut être mis en place afin de garantir certaines propriétés d'isolation, de privilèges ou d'utilisation des ressources. Le protocole d'extension de base est le chargement de modules depuis le disque, le réseau ou l'image binaire du noyau suivant les possibilités via une simple liaison entre un symbole load et du code. En compilant une nouvelle fonction de chargement et en établissant une nouvelle liaison entre le symbole load et le code binaire de cette fonction on définit un nouveau protocole de chargement : dès lors, chaque module ou application chargé le sera suivant ce nouveau protocole. Nous avons alors une stratégie qui, si elle est adaptée dynamiquement, n'en reste pas moins unique. Or en utilisant la réflexivité de l'environnement, il est possible de modifier la visibilité des symboles en modifiant la phase de résolution pendant la compilation et l'exécution des commandes. Ainsi nous pouvons définir plusieurs symboles load, et donc plusieurs protocoles de chargements, et les faire cohabiter. Nous obtenons alors un méta-protocole d'extension définissant les règles dictant le choix d'un protocole d'extension ou d'un autre pour les différentes applications.
Ce méta-protocole peut agir d'une part lors de la phase de compilation dynamique en remplaçant les appels à load par des appels vers l'un des protocoles d'extension, via un mécanisme de réécriture. L'utilisation de la réécriture permet la mise en œuvre naturelle de hiérarchie de méta-protocole (ou métaN-protocoles). Chaque méta-protocole remplaçant la liaison précédente par une liaison vers un méta-protocole plus spécifique : ainsi plusieurs niveaux méta sont représentés dans un seul niveau de réification. D'autre part, la liaison vers une instance particulière de protocole d'extension peut également se faire dynamiquement si les méta-protocoles reposent sur un appel de fonction classique, évalué lors de l'exécution et non plus pendant la phase de compilation dynamique.
Si la flexibilité dynamique complète offre de nombreuses possibilités, de l'optimisation des systèmes et des applications à leur évolution « à chaud », elle a également un impact direct sur l'administration de ces systèmes.
Au premier rang des préoccupations se trouve la sécurité : qu'il s'agisse de confidentialité, de sûreté ou de fiabilité, la flexibilité est très souvent mise en opposition avec ces aspects. Il n'est déjà pas forcément simple de garantir certaines propriétés de comportement dans un système pleinement déterminé. Mais dans le cas d'un environnement où de nombreux aspects, paramètres ou propriétés peuvent varier dynamiquement, cette tâche s'avère beaucoup plus complexe.
Ce couplage sécurité/flexibilité est si fort que bon nombre de projets de systèmes extensibles intègrent la gestion d'aspects de sécurité aux mécanismes de flexibilité. Il s'agit alors bien souvent de définir un compromis statique entre flexibilité et sécurité. Ce compromis, généralement exprimé à travers un choix de conception, demeure ensuite figé.
Notre architecture prend le parti de considérer la sécurité comme un aspect système de l'environnement d'exécution. À ce titre, nous pensons qu'il peut être spécialisé, étendu et adapté en fonction du domaine applicatif et de paramètres dynamiques, comme la provenance d'une application, un modèle de niveau de confiance ou les résultats de l'analyse du code applicatif lors de sa compilation dynamique. Nous proposons donc l'utilisation de politique de sécurité flexibles.
Le mécanisme de réécriture de code du compilateur dynamique permet de mettre en œuvre des politiques de sécurité complexes. Tout d'abord, les accès en lecture et écriture vers un symbole peuvent être protégés, temporairement ou non, et sont ainsi simplement détectables lors de la phase de compilation dynamique. Cela peut servir à interdire la lecture de valeurs « cachées », comme des clés ou à vérifier que l'on ne modifie pas certaines primitives jugées « sensibles ». Grâce à la réflexivité de la chaîne de compilation, il est possible d'insérer une phase d'analyse du code avant sa compilation. Là encore, il s'agit d'un mécanisme « à la demande » : il n'intervient que lorsqu'un critère de sécurité (provenance de l'application, identité d'utilisateur, etc) suggère que c'est nécessaire. De plus, le contenu de cette phase d'analyse est entièrement déterminé en fonction du critère ayant motivé son application. Les résultats de cette phase d'analyse peuvent ensuite être utilisés, via le mécanisme de réécriture pour insérer du code supplémentaire dans l'application, toujours avant sa compilation. Ainsi tout ce qui n'a pu être déterminé « statiquement » par analyse pourra être vérifié dynamiquement lors de l'exécution. Non seulement le niveau de sécurité et les politiques associées deviennent flexibles, mais ils sont également appliqués incrémentalement, en fonction des besoins.
La réflexivité de l'environnement permet de mettre en œuvre les mécanismes liés à la manipulation du code et à sa compilation. Ces mécanisme étant eux-mêmes réifiés via le compilateur dynamique, la mise en œuvre des méta-politiques de sécurité chargées de choisir et d'appliquer les mécanismes de sécurité lorsque nécessaire est tout aussi naturelle. Ces politiques étant également réifiées, comme toute politique, il est possible d'automatiser leur activation et leur gestion via d'autre méta-politiques. Cependant, s'il est techniquement possible d'aboutir à un environnement appliquant de lui-même des politiques différenciées pour chaque aspect suivant des méta-politiques de plus haut-niveau, la mise en œuvre de telles hiérarchies de stratégies, qui peuvent s'apparenter à un système de classification à base de règles, est une tâche complexe nécessitant une connaissance précise de l'environnement. Plus généralement la pleine exploitation des possibilités offertes par la flexibilité dynamique implique une connaissance précise de l'environnement ainsi que la maîtrise de la complexité résultant de sa nature dynamique, et donc moins facilement prévisible.
Nous avons réalisé deux types de mises en œuvre de NEVERMIND.
Nous avons mis en œuvre cette architecture en portant la YNVM directement sur le matériel. Pour cela nous avons dans un premier temps utilisé l'OSKIT. L'OSKIT est né d'un constat fait à l'université de Utah : la recherche en système nécessite souvent de construire de nouveaux système d'exploitation (OS), ce qui demande aux équipes de consacrer du temps et de l'énergie à un ensemble de mécanismes dont l'étude ne présente pas de réel intérêt (par exemple, le boot loader). Il s'agit d'un ensemble de bibliothèques de composants écrites en C (34 pour être exact) fort bien documentées. En assemblant ces composants il est possible d'obtenir l'infrastructure d'un noyau (support réseau, mémoire de masse, amorçage, mémoire, etc). Cependant, la faible granularité des composants alliée à de fortes interdépendances limitent sérieusement la minimalité de l'environnement résultant. En effet, l'OSKIT propose un ensemble de macro-composants représentant des services et abstractions système, comme le système de fichiers, la pile réseau ou les processus, extraits des noyaux LINUX et FREE BSD. L'objectif est de permettre la mise en œuvre « rapide » d'un noyau. À titre d'illustration, l'image amorçable d'une YNVM reposant sur l'OSKIT mesurait plusieurs Mo et contenait un modèle de gestion prédéfini pour chaque ressource. La taille et surtout les modèles de gestion prédéfinis sont en opposition avec les spécifications de notre architecture.
Nous avons donc choisi de réutiliser la couche d'abstraction du matériel THINK (THINK Is Not a Kernel), développée par France Télécom R&D dans le cadre du projet RNRT PHÉNIX.
Comme son nom l'indique, THINK n'est pas un noyau. En effet, les noyaux traditionnels peuvent être vus comme des machines virtuelles définissant un ensemble d'abstractions logicielles et des services associés, tel que les systèmes de fichiers, les processus ou la mémoire. À l'inverse, THINK est une librairie d'abstractions matérielles dont l'objectif est l'amorçage de l'environnement et la réification des ressources matérielles, telles que la mémoire physique, la MMU ou les périphériques. Aucune abstraction logique n'est définie en sus. Il s'agit donc d'un ensemble de composants, chacun représentant un élément physique et exportant une ou plusieurs interface(s) correspondants aux fonctionnalités matérielles, sans y ajouter la moindre sémantique. THINK fournit donc un accès direct aux ressources physiques sans aucune gestion ou aucun contrôle : les composants sont politiquement neutres. Tout comme dans le cadre du canevas logiciel Jonathan, les noyaux spécifiques construits à partir de THINK sont appelés des « personnalités ».
Initialement conçue comme une application LINUX, le code de la YNVM a été restructuré afin d'isoler les dépendances extérieures. Nous avons ensuite réécrit cette couche de compatibilité pour s'adapter à une exécution reposant sur THINK.
Cette couche comporte un mécanisme de signaux, proche de celui d'UNIX, construit à partir des primitives de manipulation du vecteur d'interruption, des contextes d'exécutions et du pilote clavier pour le signal SigInt (Ctrl–C).
La primitive dlsym
de la YNVM, utilisée pour la résolution dynamique de symboles, est également réécrite : lors de la compilation de l'image amorçable, une table des symboles est construite et embarquée dans cette image.
La résolution dynamique de symboles correspond alors à une recherche dans cette table.
La couche de compatibilité contient également un ensemble de fonctions issues de la GLIBC, telles que les manipulations de chaîne de caractères (sys_strchr
, sys_strcpy
, strncmp
, etc), les entrées/sorties (sys_getchar
, sys_fflush
, sys_printf
, etc), les sauts (setjmp
, longjmp
, etc) ou la manipulation de mémoire (sys_malloc
, sys_free
, sys_realloc
, etc).
Toutes ces fonctions ont été réécrites en s'appuyant directement sur les pilotes des périphériques concernés.
L'amorçage du système commence donc par la phase d'initialisation du matériel, assurée par THINK, puis un allocateur de mémoire physique est défini. Cet allocateur est simplement un composant permettant d'utiliser la mémoire physique et n'impose aucun modèle mémoire particulier. La YNVM est alors chargée et exécute un script de démarrage, généralement définissant des pilotes ou services de base, comme l'accès au réseau, au disque ou la gestion du clavier et de l'affichage. Ainsi le code statique sur lequel le compilateur dynamique repose ne définit aucune abstraction ni sémantique. Tout ce qui est défini au-dessus de la YNVM héritant de sa flexibilité dynamique, l'ensemble de l'environnement est ainsi dynamiquement adaptable. À titre de comparaison, la version minimale de cet environnement contient quelques composants THINK assurant la première phase de l'amorçage, les composants de la YNVM (parseur, processeur virtuel, etc), et le script de démarrage de la YNVM définissant des pilotes pour la carte réseau, le clavier et l'écran ainsi qu'un micro-protocole réseau de type TFTP, permettant le chargement incrémentale de modules à mesure des besoins. L'image mémoire de cette version minimale mesure 120 ko.
Afin de rendre l'intégration entre la YNVM et THINK encore plus forte, nous avons développé un module définissant au-dessus de la YNVM le modèle de composants présent dans THINK. Ainsi, il est possible de réutiliser de façon transparente des composants THINK depuis la YNVM. À l'inverse, les composants dynamiquement construits en utilisant la YNVM sont pleinement utilisables depuis des composants THINK. La YNVM elle-même peut être encapsulée dans un tel composant puis exportée pour être utilisée comme un service passif au-dessus de THINK depuis une « personnalité » quelconque. Un composant est représenté par une structure contenant ses attributs et un pointeur vers une table de pointeurs de fonction représentant son interface. Les méta-données de définition du composant sont conservées dans la mémoire objet de la YNVM.
Nous avons développé un ensemble de composants système et langage destinés à la construction d'environnements plus sophistiqués. Au niveau système, nous avons mis en œuvre un ensemble de protocoles réseaux, tels que ARP/RARP, IP, UDP, TCP, TFTP ou DNS, plusieurs ordonnanceurs, tels que temps partagé avec et sans priorité ou progressbased, ainsi qu'un intergiciel flexible permettant l'appel de fonction distantes, la migration de code, l'exportation à la demande de composants serveurs ainsi que le déploiement et la reconfiguration dynamique de services. Au niveau langage, nous avons principalement développé un tisseur dynamique d'aspects exploitant la réflexivité et la compilation dynamique pour éviter les surcoûts liés aux indirections traditionnellement nécessaire pour le tissage d'aspect.
Figure 7 – Un environnement d'exécution basé sur la YNVM et THINK.
À titre d'illustration, la figure 7 représente un environnement d'exécution pour composants distribués basé sur l'évaluation distante d'expressions. L'image mémoire de cet environnement mesure 580 ko.
L'architecture de notre environnement a été prévue et prototypée pour être déployée directement au-dessus du matériel, sans système d'exploitation sous-jacent. Elle peut néanmoins être transposée pour s'intégrer au-dessus d'un système existant. Le degré de flexibilité dynamique offert sera alors déterminé par les mécanismes exportés par le noyau sous-jacent.
Afin de reconstruire un environnement d'exécution dynamiquement reconfigurable au-dessus d'un système d'exploitation existant, un certain nombre de mécanismes conditionnent le degré de flexibilité dynamique envisageable.
Tout d'abord, pour supporter l'extensibilité, un mécanisme d'introspection dynamique doit être exporté par le noyau. Le chargement dynamique de code natif, tel que les bibliothèques dynamiques sous LINUX ou WINDOWS, n'est par contre pas indispensable. En effet, alors qu'il est parfaitement envisageable d'utiliser uniquement un mécanisme de chargement de bytecode (voir de fichier texte) lorsque l'environnement est entièrement généré par le compilateur dynamique, dans le cas où le compilateur dynamique, et donc l'environnement applicatif, repose sur un système préexistant, les services systèmes seront vraisemblablement regroupés sous la forme de bibliothèques de code natif. Si le noyau ne supporte pas le chargement dynamique de code natif, alors il faudra statiquement lier le compilateur dynamique aux services système existants et utiliser le compilateur dynamique pour toute extension future, ce qui limitera ces extensions dans la mesure où elles seront nécessairement restreintes à un niveau utilisateur, par opposition à une extension utilisant les mécanismes internes du noyau.
Avec ou sans chargement dynamique de code, il est nécessaire de disposer d'un mécanisme permettant l'introspection dynamique, comme l'appel système dlsym
pour la résolution dynamique de symbole.
Si le chargement dynamique de code natif est accessible, la résolution dynamique de symboles est nécessairement présente.
Dans le cas contraire, il est possible de construire une table de symboles statiquement, lors de la compilation du compilateur dynamique et de sa liaison avec les bibliothèques système.
L'introspection dynamique se résume alors à une recherche dans cette table.
La réflexivité est assurée par les composants de compilation dynamique et se trouve, de ce fait, indépendante de la plateforme sous-jacente.
Le principal obstacle réside finalement dans la mise en œuvre du principe de minimalité. Dans notre architecture, il repose sur le couplage langage/système au plus bas niveau, sous la forme d'un environnement minimal. En effet, si tout ce qui est dynamiquement mis en place via le compilateur dynamique est naturellement dynamiquement reconfigurable, le degré de flexibilité dynamique des services et mécanismes internes du système ne dépend que du noyau sous-jacent. Ainsi, l'intégration du compilateur dynamique dans un exo-noyau ou un micro-noyau réflexif offrira une flexibilité largement supérieure à son utilisation en tant qu'application au-dessus d'un noyau monolithique.
Une première solution à l'intégration de notre architecture dans LINUX consiste simplement à utiliser le compilateur dynamique YNVM comme une application LINUX définissant un environnement de programmation et d'exécution flexible, dans le même esprit qu'une machine virtuelle classique. La compatibilité binaire avec l'ABI (Architecture Binary Interface) de la plateforme permet alors une utilisation directe, depuis cet environnement, de l'API (Application Programming Interface) du système ainsi que des bibliothèques C et C++ présentes.
Cependant, si la flexibilité dynamique offerte par la YNVM s'étend naturellement à l'ensemble du code applicatif, il n'en est pas de même pour les services et abstractions prédéfinis du noyau. Ainsi une application ou une spécification active pourra à loisir spécialiser la chaîne de compilation (nouvelles syntaxes, nouvelles sémantiques, etc), enrichir les aspects langage (tissage dynamique d'aspects, modèle de composition de composants, etc) ou redéfinir partiellement le comportement du système via l'interposition de code encapsulant les appels systèmes, dans la limite de ce qu'une telle approche de type tissage d'aspects autorise. Il ne sera pas possible, par exemple, de modifier globalement la sémantique des appels systèmes ou d'insérer un protocole applicatif dans la pile de démultiplexage réseau située dans le noyau.
Le principe de minimalité n'étant plus assuré, la flexibilité redevient partielle : elle est limitée aux applications reposant sur la YNVM et à la frontière avec le noyau. De ce fait, il apparaît donc nécessaire, d'une part de permettre l'accès aux structures du noyau afin de supporter l'adaptation dynamique des services et abstractions existantes, mais aussi d'autre part d'avoir la possibilité d'étendre le noyau, afin de définir de nouveau services ou comportements, donc de pouvoir générer et exécuter du code dans l'espace noyau. Pour atteindre cet objectif, une partie du compilateur dynamique doit être intégrée dans le noyau. En utilisant le chargement dynamique de modules offert par LINUX, il est possible d'embarquer tout ou partie du compilateur dynamique à l'intérieur du noyau. Pour ce faire, nous encapsulons une YNVM dans un module noyau utilisant un périphérique virtuel (/dev/vvm) comme source de données.
Figure 8 – Intégration du compilateur dynamique dans le noyau Linux.
Comme illustré sur la figure 8, le module utilise alors l'allocateur mémoire du noyau, via les fonctions kmalloc et kfree.
L'accès aux symboles préexistants de l'espace noyau se fait via la liste des modules, dont la première entrée représente le noyau lui-même : chaque module contient une table de ses symboles, permettant ainsi de construire des liaisons dynamiques vers l'ensemble du noyau.
Néanmoins, l'ensemble des symboles du noyau n'étant pas exporté lors de sa compilation, nous reconstruisons une table exhaustive à partir du fichier System.map
lors du chargement du module.
Ainsi les symboles n'ayant pas été exportés deviennent également visibles et accessibles depuis la YNVM.
L'image mémoire de cette version est de 450 ko.
Ce module est accessible depuis n'importe quelle application de l'espace utilisateur : les expressions à évaluer sont transmises en utilisant l'appel système ioctl sur le fichier périphérique associé /dev/vvm.
Afin d'abstraire l'utilisation de ce module depuis une YNVM situé dans l'espace utilisateur, nous définissons un espace de nommage chargé de réifier l'espace noyau au niveau applicatif : le module YNVM kernel.
Ce module permet de définir des symboles dans l'espace noyau : chaque symbole kernel.xxx représente un symbole xxx définit dans l'espace noyau. Pour ce faire, le mécanisme de syntaxe est utilisé afin de réécrire dynamiquement les accès en lecture et écriture à ces symboles en appel vers le périphérique /dev/vvm. Par défaut, les symboles du noyau sont réifiés incrémentalement lors de la compilation d'un accès à un symbole non défini, l'accès est transformé, toujours via le mécanisme de syntaxe, pour être précédé d'une résolution de symbole : si le symbole est présent dans la table des symboles du noyau, alors il est dynamiquement défini dans l'espace de nommage kernel et ainsi lié au symbole correspondant de l'espace noyau. Néanmoins, il est possible de modifier dynamiquement ces mécanismes de réification pour mettre en œuvre une réification « statique » de l'ensemble du noyau ou insérer des contrôles de sécurité sur les accès.
L'interface d'accès au module noyau est donc constituée d'un ensemble de méthodes invoquées via l'appel système ioctl
.
La principale fonction est l'évaluation d'une expression, via la fonction readEvalPrintString(char *string)
de la YNVM : la chaîne string
, représentation textuelle d'un arbre de syntaxe abstraite, est compilée puis évaluée.
Du fait de l'isolation mémoire inhérente à la séparation des espaces d'adressage, une expression essayant de retourner une adresse mémoire de l'espace noyau vers l'espace utilisateur conduira à une violation de segmentation.
Au niveau de l'espace de nommage kernel, les symboles define
et load
, utilisés respectivement pour définir un nouveau symbole et pour charger un fichier (module, application ou bibliothèque), sont redéfinis.
La définition d'un symbole est précédée par la définition de deux syntaxes contrôlant l'accès en lecture et en écriture du symbole.
La définition est ensuite transmise à la YNVM située dans l'espace noyau.
Ainsi tous les accès au symbole seront transformés en lecture ou écriture dans l'espace noyau via la YNVM.
De plus, la réification de l'espace noyau dans le module kernel est paresseuse : lors de la définition d'une expression, une introspection de l'espace noyau permet de créer dynamiquement une liaison pour les symboles indéfinis.
Last modification on: Tuesday April 19 2005 - 18:58:32 +0200