l'assembleur  [livre #4061]

par  pierre maurette



Architecture
système

Notre intention n'est pas, à proprement parler, d’étudier la programmation système active, mais plutôt d’établir un premier contact avec l'architecture à ce niveau, afin d'être plus à l'aise en programmation d'applications, pour ne pas mourir idiot en d'autres termes.

Si votre objectif est de programmer réellement au niveau système, ce qui suit ne peut pas, bien entendu, être suffisant. Considérez plutôt ce chapitre comme une première lecture qui vous permettra peut-être de consulter ensuite plus facilement la documentation. Disons que ce chapitre est une Task Gate.

Nous sommes sur des machines de la génération Pentium, mais pratiquement tout était décidé à partir de 386. Ce que nous allons développer est, à priori, universel, la source de documentation étant essentiellement celle du Pentium 4. Pour information, consultez IA-32 Intel Architecture Software Developer's Manuals Volume 3: System Programming Guide , lien de téléchargement sur le CD-Rom. Tout y est sur l'architecture système, complet mais brutal et en anglais. Si la table des matières de ce fichier  .pdf vous évoque quelque chose, le but de ce chapitre aura été atteint.

3.1 Modes de fonctionnement

À un instant donné, le processeur peut se trouver dans un mode de fonctionnement parmi quatre :

  Le mode Réel  est celui du 8086 tel que nous le connaissons. Le processeur est dans ce mode lors d'un RESET, donc au démarrage. Il n'a alors accès qu'aux mêmes possibilités qu'un 8086, limitation mémoire comprise. Pour en utiliser davantage, il faut employer comme sous DOS un émulateur de gestionnaire EMS, emm386.exe . La seule possibilité supplémentaire, mais importante, est de pouvoir basculer en mode Protégé ou en mode SMM.

  Le mode Protégé  est le mode Normal des programmes utilisateurs dans lequel ils tireront le meilleur du matériel et du jeu d'instructions. Il est possible de distinguer un sous-mode 16 bits et un sous-mode 32 bits, ou plus précisément des modules 16 bits et des modules 32 bits tournant en mode Protégé. Consultez plus loin le paragraphe réservé à ce point important.

  Le mode Virtual-8086  ou V86 . C'est une façon de mettre à la disposition d'une application 16 bits un environnement 8086 complet, comme en mode Réel, tout en bénéficiant des avantages du mode Protégé, protection et multitâche, puisqu'il s'agit d'un sous-mode de ce mode. Sous Windows, chaque session DOS met à la disposition du programmeur un environnement DOS complet, 1 Mo de mémoire, copie des 384 Ko affectés au système, gestionnaire EMS. Et cela est possible en un certain nombre d'exemplaires. L'avantage réside dans le fait que le blocage de cette tâche ne met pas en cause la stabilité du système.

  Le mode de gestion système, ou mode   SMM  (System Management Mode). Ce mode très spécial n'est accessible que via une interruption externe spécifique : SMI. Il est réservé à des tâches particulières, la gestion de l'alimentation par exemple.

Les basculements entre les différents modes ne se font pas sans contrainte, certains sont possibles, d'autres pas, d'autres enfin passent par des interruptions matérielles.

Basculements entre les modes de fonctionnement
figure 3.01 Basculements entre les modes de fonctionnement [the .swf]

Le processeur démarre à froid en mode Réel, et le RESET l'y ramène, quel que soit le mode dans lequel il se trouve à ce moment-là. Oublions, dans un premier temps, le mode SMM. Du mode Réel, nous ne pouvons passer qu'en mode Protégé, en positionnant le flag PE (Protect Enable) du registre de contrôle CR0 à 1. En mode Protégé, le processeur peut rebasculer en mode Réel, en positionnant PE à 0. ttention, toutes ces opérations sont en réalité plus complexes que le simple positionnement d'un bit. Du mode Protégé, le processeur peut également passer en mode V86, ce qui positionne le flag VM (Virtual Mode) de EFLAGS à 1. Depuis ce mode V86, il n'est possible de repasser qu'en mode Protégé, qui joue donc un rôle de chef d'orchestre, ce qui repositionne VM à 0.

Le mode SMM est tout à fait à part, déconnecté des autres modes, asynchrone en quelque sorte. Un programme tournant dans l’un des trois autres modes ne décide jamais du passage en mode SMM. Une interruption matérielle le déclenche et l'instruction RSM fait revenir dans le mode et le contexte du moment de l'interruption.

Historiquement, il y eut trois générations de processeurs, quant aux modes de fonctionnement disponibles.

Processeurs et modes de fonctionnement
figure 3.02 Processeurs et modes de fonctionnement [the .swf]

Il nous reste à clarifier la notion de mode 16 bits et de mode 32 bits.

16 bits – 32 bits

Ce point est important et dépasse nettement le cadre de la programmation système. Les notions de taille d'adresse et de taille d'opérande sont parfois mal comprises à la lecture de la documentation sur le jeu d'instructions. Il faut bien voir que 16 bits n'est pas systématiquement associé à un mode 8086 (Réel ou Virtuel), ni 32 bits au mode Protégé.

En mode Protégé, il existe deux types de modules, selon les tailles par défaut de certains registres et opérandes : les modules 16 bits et les modules 32 bits. Ces deux types définissent, d'une certaine façon, deux sous-modes, mais attention, nous sommes toujours en mode Protégé. En fait, nous verrons que c'est chaque segment qui est de type 16 bits ou 32 bits, selon des attributs dans les descripteurs.

Caractéristiques des types 16 bits et 32 bits

Caractéristique

Module 16 bits

Module 32 bits

Taille d'un segment

0 Ko à 64 Ko

4 Ko à 4 Go

Taille des opérandes

8 bits et 16 bits

8 bits et 32 bits

Taille d'adresse (offset)

16 bits

32 bits

Taille du pointeur de pile

16 bits (SP)

32 bits (ESP)

Les modes Réel, V86 et SMM sont associés au 16 bits. Le mode Protégé, à partir du 386, est typiquement associé au 32 bits. Mais il est également possible de créer des segments 16 bits dans ce mode, pour le sous-mode Protégé 16 bits. Le mode Protégé du 286 était, bien entendu, uniquement 16 bits.

Deux précisions à propos du tableau des caractéristiques :

  Ce n'est qu'en mode Protégé 16 bits que la taille des segments varie de 0 Ko à 64 Ko. En mode Réel, elle est de 64 Ko, rien ne protégeant un segment d'un autre s'ils se recouvrent.

  Il est donné deux tailles d'opérandes. Si nous regardons la documentation, nous trouvons très souvent des définitions du type :

C6 /0 MOV r/m8,imm8   Move imm8  to r/m8
C7 /0 MOV r/m16,imm16 Move imm16 to r/m16
C7 /0 MOV r/m32,imm32 Move imm32 to r/m32

Les deux dernières ont le même opcode. Le choix se fera à l'exécution, en fonction de l'attribut de taille d'opérande, lié au mode 16 ou 32 bits. Il est possible d'inverser ce choix, en préfixant l'instruction par le préfixe de surcharge de taille d'opérande ( 66h ). Vous n'aurez, à priori, pas à vous préoccuper de ce préfixe, l'assembleur le gère pour vous. Voyez par exemple cette séquence, extraite d'un fichier listing MASM , le programme étant en modèle FLAT, donc 32 bits :

00000013  B8 000001A8   mov eax, 1A8h
00000018  66| B8 01A8   mov  ax, 1A8h

Attention, le signe  | suivant 66 est ajouté au listing par MASM pour justement signaler une surcharge de taille.

Exactement de la même façon, l'attribut de taille d'adresse détermine la taille d'adresse par défaut :

FF /4 JMP r/m16      Jump near, absolute indirect, address given in r/m16
FF /4 JMP r/m32      Jump near, absolute indirect, address given in r/m32
EA cd JMP ptr16 :16  Jump far, absolute, address given in operand
EA cp JMP ptr16 :32  Jump far, absolute, address given in operand

Vous pouvez également modifier ce choix par le préfixe de surcharge de taille d'adresse ( 67h ). Cette surcharge est beaucoup moins fréquente que celle de la taille d'opérande. Dans le chapitre sur la pile et les sous-programmes, ce point est repris au sujet des instructions PUSH et POP , qui sont intéressantes en ce sens qu’à la fois la largeur du pointeur de pile et le nombre d'octets, par lequel il est incrémenté et décrémenté, varient en fonction des attributs.

 

3.2 Registres et structures

Vous n’avez pas à lire cette partie en continu, mais elle représente un ensemble de références auxquelles vous devrez vous reporter au fur et à mesure de la lecture. Si le schéma des registres et structures ainsi que le contenu de ces registres ne vous inspirent pour l'instant pas grand-chose, c’est normal. Très peu de cases dans ce schéma sont pour l'instant identifiables.

Registres et structures au niveau système
figure 3.03 Registres et structures au niveau système [the .swf]

Dans ce schéma, les registres du microprocesseur sont représentés en grisé. Donc, le reste est en mémoire, ce point aura son importance. Les registres CS, SS, DS, ES, FS et GS sont affublés d'une partie supplémentaire (cache). C'est une partie cachée, dans les deux sens du terme, de chaque registre. Ces registres contiennent des sélecteurs, qui sont des sortes de pointeurs. C'est la valeur pointée, des descripteurs, qui change relativement peu souvent, qui est ainsi mise en cache directement dans le processeur. Ceci a pour effet d'éviter des accès trop fréquents en mémoire.

Présentons rapidement les registres spécifiques : EFLAGS, CR0 à CR4, GDTR, LDTR, IDTR et le registre de tâche Task Register TR.

Un seul fait partie du modèle du programmeur standard : le registre EFLAGS. Une partie des bits de ce registre est de type système. Voici un extrait de ce que vous trouvez au chapitre sur le jeu d'instructions à propos de EFLAGS.

Le registre EFLAGS
figure 3.04 Le registre EFLAGS [the .swf]

Certains de ces bits sont de type Système, voici leur signification :

Les bits Système de EFLAGS

Symbole

Bit

Nom

Traduction

ID

21

ID Flag

Identification. La possibilité de modifier ce bit indique que l’instruction CPUID est implémentée.

VIP

20

Virtual Interrupt Pending Flag

Interruption(s) en attente. En conjonction avec VIF, indique qu’au moins une interruption est en attente. Écrit par logiciel, lu par le microprocesseur.

VIF

19

Virtual Interrupt Flag

Image virtuelle de IF. Quand VME est activé, image en mode V86 de IF.

AC

18

Alignment Check Flag

Vérification de l’alignement mémoire. Si positionné en même temps que AM de CR0, autorise la vérification de l’alignement mémoire. Dans ce cas, une exception est déclenchée lors de l'accès à des données non alignées sur leur propre taille.

VM

17

Virtual-8086 Mode Flag

Mode virtuel 8086. Indique, démarre ou arrête le mode V86.

RF

16

Resume Flag

Reprise. Contrôle la réponse aux exceptions de débuggage.

NT

14

Nested Task Flag

Tâche chaînée. Indique si la tâche courante est liée à une tâche parent par un CALL ou une interruption.

IOPL

12, 13

I/O Privilege Level

Niveau de privilège E/S. Indique le niveau de privilège E/S de la tâche courante. Codé sur 2 bits (4 valeurs). Doit être inférieur ou égal à IOPL pour que la tâche puisse accéder aux E/S.

IF

9

Interrupt Enable Flag

Autorisation d'interruptions matérielles masquables. Le positionner pour autoriser le traitement de ces interruptions.

TF

8

Trap Flag

Mode Pas à pas (trace). Le positionner pour valider ce mode. Dans ce mode, une exception est levée après chaque instruction.

 

Nous voyons ensuite cinq registres de contrôle CR0 à CR4. Les parties grisées dans la représentation suivante représentent des bits réservés. Leur grand nombre laisse prévoir un bonne évolutivité, à moins que certains d'entre eux ne soient utilisés par le processeur sans que cette utilisation ne soit documentée. Le CR1, par exemple, est entièrement réservé.

Les registres de contrôle
figure 3.05 Les registres de contrôle [the .swf]

Nous ne donnerons sur chaque bit qu'une très brève explication, réduite parfois simplement à son nom. Ceci permettra d'avoir une idée de son rôle. Pour en savoir plus, vous pourrez vous reporter à la documentation.

Le registre CR2 contient les 32 bits de l'adresse linéaire qui a causé une exception #PF (Page Fault). Ce sera utile pour la gestion de la mémoire virtuelle : le programme d'application ne se préoccupe pas de savoir si l'objet auquel il souhaite accéder est bien physiquement en mémoire. Si ce n'est pas le cas, c'est le système, au niveau du gestionnaire de #PF , qui par exemple chargera le segment en question à partir du disque. Ce sont en particulier les fichiers de mémoire virtuelle ou fichiers swap configurés dans les diverses versions de Windows, pagefile.sys ou win386.swp .

Les flags du registre CR0 contrôlent le mode opératoire et l'état du processeur :

PE (Protection Enable) autorise le mode Protégé.

Les fonctionnements de EM (Emulation), MP (Monitor Coprocessor) et TS (Task Switched) sont liés, en rapport avec la FPU ainsi que MMX, SSE et SSE. Derrière ces 3 bits qui doivent être envisagés globalement se cachent deux idées :

  Générer une exception en cas d'utilisation d'une instruction (FPU, MMX, SSE, SSE2) non disponible pour pouvoir la traiter par une émulation logicielle.

  Normalement, l'ancien contexte complet est entièrement sauvegardé et le nouveau restauré à chaque commutation de tâche. Il est clair que, souvent, très peu de tâches emploieront FPU, SSE, etc. Une tâche parmi vingt par exemple. L'idée consiste à ne pas sauvegarder les registres en rapport avec ces jeux d'instructions et à faire en sorte qu'une exception soit levée en cas d'utilisation d'une instruction appartenant à l’un de ces jeux dans la nouvelle tâche. Pour les détails, consultez la documentation.

ET (Extension Type) toujours à 1 sur les processeurs récents. Indique la présence du jeu d'instructions  FPU .

NE (Numeric Error) concerne le type de gestion d'erreur de la  FPU , en gros matériel ou logiciel.

WP (Write Protect) autorise ou interdit aux procédures niveau superviseur l'écriture dans les pages à lecture seule (Read Only) du niveau utilisateur.

AM (Alignment Mask) participe, avec d'autres conditions, à autoriser ou inhiber le contrôle d'alignement.

CD (Cache Disable) et NW (Not Write-through) permettent d'autoriser, d'interdire ou de restreindre l'utilisation de la mémoire cache, interne et externe.

PG (Paging) valide ou inhibe la pagination.

 

Le registre CR3 contient les vingt bits de poids fort de l'adresse physique de la base du Page Directory (les 12 bits de poids faible sont à 0), alors que les flags  PCD (Page-level Cache Disable) et PWT (Page-level Writes Transparent) contrôlent le comportement du cache du Page Directory dans les caches internes du processeur.

 

Le registre CR4 contient un bouquet de flags indiquant des extensions et spécificités du processeur.

VME (Virtual-8086 Mode Extensions) autorise ou interdit les extensions de gestions d'interruptions et d'exceptions. PVI (Protected-Mode Virtual Interrupt) autorise ou interdit le support du flag VIF de EFLAGS. Ces flags s'intègrent au sein de l'usine à gaz qu'est la gestion des interruptions en multitâche, voire multimode : par qui et comment sont traitées les NMI, les interruptions masquables, les instructions INTn , les exceptions ? Nous ferons simplement la remarque suivante : le mode V86 est fait pour simuler un environnement 8086 complet, donc les tâches qui tournent dans ce mode possèdent leur propre table de vecteurs d'interruptions. Parfois, il sera bon que la tâche X86 traite directement l'interruption. Dans d'autres cas, même s'il s'agit d'une interruption logicielle déclenchée par la tâche, il faudra qu'elle soit traitée par le système, pour résoudre les accès aux périphériques par exemple. VME et VPI s'inscrivent dans cette optique.

TSD (Time Stamp Disable) restreint l'utilisation de l'instruction RDTSC (lecture du compteur de cycles TSC) au niveau de privilège 0. Voir également PCE.

DE (Debugging Extensions). Les registres de debug DR4 et DR5 sont réservés. La position du flag DE détermine si une référence à ces registres provoque une exception #UD (opcode invalide), si DE est à 1, ou si cette référence est transformée en référence à DR6 et DR7.

PSE (Page Size Extension) détermine la taille des pages du système de paging, 4 Ko ou 4 Mo.

PAE (Physical Address Extension) autorise ou non le système de pagination à utiliser les 36 bits du bus d'adresses. Dans le cas contraire, seuls les 32 bits de poids faible sont utilisés.

MCE (Machine-Check Enable) autorise ou interdit l'exception machine-check .

PGE (Page Global Enable) autorise ou interdit la possibilité de marquer comme globales les pages d'accès fréquent ou partagées.

PCE (Performance-Monitoring Counter Enable) autorise l'utilisation de l'instruction RDPMC (lecture du compteur PMC) à tous les niveaux de privilège. À comparer avec TSD, l'une autorise, l'autre restreint. Ce point est évoqué au chapitre consacré à l'optimisation.

OSFXSR (Operating System Support for FXSAVE and FXRSTOR instructions) détermine le comportement du processeur face aux instructions FXSAVE et FXRSTOR . En particulier si le contenu des registres XMM et MXCSR est sauvé en même temps que celui des registres FPU et MMX.

OSXMMEXCPT (Operating System Support for Unmasked SIMD Floating-Point Exceptions), à propos des instructions FP des jeux SSE et SSE2, dans le cas d'un processeur ne les implémentant pas, si l'OS veut traiter spécifiquement ce cas, il allumera ce flag. Le processeur lèvera alors dans cette circonstance une exception spécifique #XF . Si le flag est à 0, c'est l'exception #UD (opcode invalide) qui sera générée.

3.3 Sélecteurs de segments

Dans les lignes suivantes, le fonctionnement du 8086 est supposé connu, particulièrement en termes d'adressage. Quand nous parlerons de changement, c'est par rapport à celui-ci qu'il faudra l'entendre.

Plutôt que de plonger directement au cœur de la bête, partons de quelque chose que nous connaissons bien : l'adressage normal d'une case mémoire, code ou données. Sauf indication contraire, nous nous situons dans une application normale, tournant en mode Protégé. Nous avons déjà abordé le sujet à propos du 286.

Malheureusement, tout se mord un peu la queue, et même en nous situant dans un contexte simplifié, nous verrons apparaître des objets concernant des notions plus complexes présentées plus loin, comme la protection ou le multitâche. Une seule lecture linéaire ne sera donc pas suffisante.

Le but est d'atteindre une adresse dans un espace de mémoire physique. Les 386 et 486 disposaient pour accéder à cette mémoire de 32 bits, les modèles actuels en ont 36. Pour fabriquer cette adresse, nous tirons toujours nos informations de deux sources :

  Un offset, sur 16 ou 32 bits selon le mode de fonctionnement et la valeur d'un attribut. De ce côté-là, rien n'a vraiment changé : pour l'accès aux données, l'offset est donné par l'opérande de l'instruction, pour le code c'est le compteur programme (E)IP. Sa signification n'a pas non plus été modifiée, il s'agit d'un déplacement par rapport au début d'une zone appelée segment. Donc, l'offset ne va pas spécialement nous préoccuper, il finira additionné à une adresse de segment, toute la difficulté étant d'obtenir cette dernière.

  La seconde source ne semble pas non plus avoir changé : ce sont les mêmes registres de segments, toujours sur 16 bits et choisis de la même façon en fonction de l'instruction. Alors rien n'a changé ? Que nenni. En effet ces registres n'ont pas changé de nom, mais leur contenu n'est plus le même : ce n'est plus l'adresse (divisée par 16) d'un début de segment, mais un objet particulier, le sélecteur de segment, premier jalon sur le chemin qui va nous conduire à notre zone de mémoire physique. Et nous ne sommes pas arrivés. À ce propos, précisons que la complexité du dispositif peut laisser une impression de lenteur. Il n'en est rien, c'est de l'électronique câblée rapide qui n'engendre pas de retard significatif aidée ,il est vrai, par des méthodes d'anticipation.

Donc, les registres de segments contiennent des sélecteurs. Dans certains cas très particuliers, en mode Réel (et non pas en V86), le sélecteur pointe vers un début de segment, à une multiplication par 16 près. Ce point est nécessaire, puisque nous devons nous souvenir qu'à l'instant 0 de la mise sous tension, le processeur doit parfaitement simuler un 8086. Dans tous les autres cas, le sélecteur a la structure suivante.

Un sélecteur de segment
figure 3.06 Un sélecteur de segment [the .swf]

Index  : codé sur 13 bits, donc 8 192 valeurs possibles. Fait irrésistiblement penser à un index dans une table contenant des adresses de segment. Gagné, c'est à peu près cela, nous allons voir qu'il s'agit en fait d'une table de descripteurs contenant, outre l'adresse, diverses propriétés du segment.

TI (Table Indicator) : il est possible d'utiliser une table parmi deux. Si TI est à 0, c'est la GDT (Global Descriptor Table) qui sera utilisée. S'il est à 1, c'est la LDT (Local Descriptor Table).

RPL (Requested Privilege Level) : codé sur deux bits, peut donc prendre quatre valeurs, de 0 à 3. Nous y reviendrons, mais pouvons penser qu'il s'agit de gérer des droits d'accès.

Nous commençons à voir à peu près comment nous allons obtenir une adresse de segment à partir du sélecteur. En réalité, nous ne sommes pas encore au bout du chemin.

Adresses logiques, linéaires et physiques
figure 3.07 Adresses logiques, linéaires et physiques [the .swf]

L'adresse au contact avec le programme, constituée d'un offset et d'un segment, que ce soit sur un 8086 ou sur un Pentium, est appelée adresse logique . À l'aide des sélecteurs et descripteurs, nous allons élaborer une adresse non segmentée, sur 32 bits, que nous appellerons adresse linéaire . Est-ce cette adresse qui va sortir de la puce pour attaquer les circuits de mémoire ? Dans certains cas, oui, mais il est possible d'utiliser un deuxième niveau de translation d'adresse, nommé paging ou pagination . Donc, avec ou sans paging, nous obtenons finalement l' adresse physique .

Une bonne nouvelle : les deux opérations, décodage du sélecteur et paging, sont indépendantes, chose logique puisque le paging est optionnel. Nous pourrons donc raisonner dans un premier temps uniquement sur la partie sélecteur-descripteur, en confondant adresse linéaire et adresse physique.

Oublions donc le paging et reprenons nos sélecteurs de segments. Une précision : dans la très grande majorité des situations, les sélecteurs et surtout les descripteurs de segments sont fournis par le lieur puis le loader de Windows, et le programme n'a pas à s'en préoccuper. Il n'en a d'ailleurs généralement ni les éléments (quelle valeur d'index dans le sélecteur ?), ni le droit.

3.4 Descripteurs de segments

Le sélecteur nous permet d'accéder à un descripteur de segment. Précisons que le descripteur est un élément de base de l'architecture et qu'il existe sur la même idée d'autres descripteurs système.

À quoi ressemble un descripteur de segment ? Il s’agit d’une structure de 64 bits, soit 8 octets, dont la représentation en mémoire peut changer selon la façon de considérer cette mémoire, par mots de 8, 16, 32 ou même 64 bits (voir au besoin les explications sur little endian/big endian). A partir de ces considérations, l'impression qu'il y a des bits dans tous les sens devient peut-être trompeuse. Ceci dit, il y a quand même des bits dans tous les sens.

Un descripteur de segment
figure 3.08 Un descripteur de segment [the .swf]

L' adresse de base est l'adresse sur 32 bits du premier octet du segment. Il est conseillé d'aligner cette valeur sur un multiple de 16, pour des adresses alignées sur des blocs de 16 octets, 128 bits.

Cette adresse de base est le seul élément réellement indispensable. Le reste, optionnel, concerne la gestion d'un certain nombre de protections, présentées dans un paragraphe particulier à la suite de celui-ci. La connaissance de ces mécanismes de protection est nécessaire à la compréhension des autres éléments du descripteur de segment.

La limite du segment s'exprime selon la valeur du bit G (comme granularity) :

  Si G = 0, la limite s'exprime en octets et est donc limitée à 1 Mo.

  Si G = 1, la limite s'exprime en blocs de 4 Ko et donc court de 4 Ko à 4 Go.

Si l'offset sort de la plage autorisée, une exception est levée. Cette notion de limite n'est pas totalement simple, il faut, pour l'appréhender, voir à quoi correspondent les notions de types de segments expand-up  et expand-down .

Quand un programme manipule des données créées dynamiquement, il peut arriver que le segment réservé à ces données se révèle trop petit. Il en est de même pour une pile. Le cas du code ne nous intéresse pas sur ce plan. Il sera intéressant de pouvoir réallouer dynamiquement de la mémoire, à la suite de celle déjà utilisée, idéalement par simple modification de la limite. Cette opération est possible. Trouver de la mémoire disponible au bon endroit correspond au travail (certainement complexe) du gestionnaire de mémoire, aidé en cela par l'ensemble des mécanismes que nous sommes en train de décrire, y compris la pagination. Rappelons-nous qu'un segment de données se remplit vers les adresses mémoire croissantes, alors qu'une pile pousse vers le bas, vers les adresses décroissantes. Il a donc été prévu deux définitions de la limite, chacune adaptée à un cas de figure.

Segments expand-up et expand-down
figure 3.09 Segments expand-up et expand-down [the .swf]

Dans le cas d'un segment normal (expand-up n'est pas utilisé, c'est le comportement par défaut), il est clair qu'une exception #GP sera levée quand l'offset sera supérieur à limite .

Dans le cas d'un segment expand-down, l'exception se produira pour un offset inférieur à limite . Donc, les valeurs de l'offset pourront évoluer de limite à FFFFFFFFh (ou FFFFh ). Cette dernière valeur dépend du bit B et est bien entendu en rapport avec le mode 16 ou 32 bits. Notez bien que, généralement, ce type de segment sera affecté à une pile, que l'offset sera contenu dans ESP (ou SP) et sera initialisé à FFFFFFFFh (ou FFFFh ). L'exception sera alors une #SF (Stack Fault).

Le bit S indique le type de descripteur, à 1 c'est un descripteur de segment, à 0 un descripteur système. Un descripteur système décrit une zone de mémoire qui n'est pas un segment. Nous y reviendrons.

Le champ Type est composé de 4 bits.

Le bit A , comme Accessed, est allumé par le processeur à chaque accès au segment. Il est éteint par logiciel. Usage en contrôle de la mémoire virtuelle ou en débogage.

Le bit 11 à 1 indique un segment de code, à 0 un segment de données, ce qui modifie la signification des bits 9 et 10.

Pour un segment de données, le bit W , 9 (Write Enable) indique s'il est à 1 qu'il est possible d'écrire sur le segment. Le bit E , 10 (Expansion-direction) indique s'il est à 1 que le segment est de type expand-down. Les segments de pile sont des segments de données dont le bit W est à 1 et le bit E souvent à 1, bien que ce ne soit pas obligatoire.

Pour un segment de code, le bit R , 9 (Read Enable) autorise, quand il est à 1, le segment à être lu, au sens de lu comme une donnée. Il n'est jamais possible d'écrire dans un segment de code en mode Protégé. Il n'a donc pas toujours été un segment de code. Le bit C , 10 (conforming) indique s'il est à 1 que le segment est de type conforming. Nous avons déjà entrevu qu'il existe quatre niveaux de privilège et intuitivement nous voyons à peu près à quoi cela correspond. Il existe des procédures très spéciales pour transférer l'exécution entre des segments de niveaux de privilège différents, nous les verrons un peu plus loin. Le transfert entre segments par de simples JMP ou CALL ( FAR ) n'est possible que s'ils ont le même niveau de privilège. Sauf si le segment d'arrivée est de type conforming : l'exécution peut s'y transférer, mais en conservant le niveau de privilège du segment d'origine. Nous pouvons voir là une bonne méthode pour qu'un système d'exploitation mette à la disposition des applications une partie de ses procédures, sans pour autant lui donner tous les droits.

La signification de  D/B dépend du type du segment :

  Dans un segment de code, le bit est appelé le bit D , il indique la taille par défaut des adresses et des opérandes référencés par les instructions du code du segment : si D = 1, adresses sur 32 bits et opérandes sur 32 ou 8 bits ; si D = 0, adresses sur 16 bits et opérandes sur 16 ou 8 bits.

  Dans un segment de pile (pointé par le registre SS), c'est le bit B . Il indique la taille du pointeur de pile, 32 bits dans ESP si B = 1, 16 bits dans SP si B = 0. Si, de plus, le segment est expand-down, B indique la limite supérieure du segment (voir ligne suivante).

  Dans un segment de données de type expand-down, c'est également le bit B . À 1, il indique une limite supérieure de segment de FFFFFFFFh , à 0 une limite de FFFFh .

Les deux bits de  DPL (Descriptor Privilege Level) servent à déterminer le niveau de privilège, parmi 4, du segment. Ces deux bits sont, bien entendu, à mettre en rapport avec le RPL du sélecteur. Nous y revenons un peu plus loin

Le bit P (Segment-Present) indique que le segment est réellement présent en mémoire. Il s'agit essentiellement de gérer de la mémoire virtuelle. Quand le segment n'est pas en mémoire, une exception est levée cNP (segment not present). Cette exception peut alors être mise à profit par le gestionnaire de mémoire pour, par exemple, charger le segment à partir du disque. Tant que le segment n'est pas présent, les 56 bits de l'adresse de base et de la limite sont à la disposition du système. Il est possible d'imaginer qu'il puisse y déposer les éléments pour retrouver l'image du segment sur le disque.

AVL est un bit à la disposition des programmes système.

Nous en avons terminé avec le contenu d'un descripteur de segment. Mais où sont-ils donc, ces descripteurs ? Très souvent, ils sont dans le microprocesseur. Pour chaque registre de segment, les 16 bits de la partie visible contiennent un sélecteur, nous le savons. Il existe également une partie invisible, dans laquelle est caché le descripteur pointé par le sélecteur. Comme le descripteur est en mémoire, il suffira d'aller le chercher uniquement à chaque changement de sélecteur, ou quand il peut avoir changé. Mais si ce cache améliore les performances, cela ne résout pas le problème : où aller chercher les descripteurs ? Nous avons vu que le bit TI du sélecteur de segment indiquait une table, GDT ou LDT. Donc, à table...

3.5 Tables de descripteurs GDT, LDT et IDT

Comme nous pouvons le voir sur le schéma général en début de chapitre, il existe trois tables de descripteurs : GDT, LDT et IDT. Ces tables sont en mémoire et pour y accéder, le processeur utilise trois registres système dans la puce du processeur, respectivement GDTR, LDTR et IDTR.

Il faut distinguer d'une part GDT et IDT, et d'autre part LDT.

GDT et IDT sont chacune une table unique de descripteurs. Elles sont vues l'une et l'autre par toutes les tâches. D'une certaine façon, ce sont des segments un peu particuliers, et leurs attributs sont connus par défaut, il est inutile de les mémoriser. Donc, GDTR et IDTR sont des descripteurs de segments simplifiés, ne contenant que l'adresse de base et la limite de la table correspondante.

Les registres GDTR et IDTR
figure 3.10 Les registres GDTR et IDTR [the .swf]

Pour accéder au descripteur dans GDT, il suffit d'ajouter à l'adresse de base l'index issu du sélecteur multiplié par les 8 octets de la taille de chaque descripteur. Il en est de même pour IDT, à partir du vecteur d'interruption. GDT peut contenir 8 192 sélecteurs, le sélecteur d'index 0 ou sélecteur null étant réservé à un usage particulier vu par ailleurs. IDT peut contenir 256 descripteurs (appelés gates), correspondant au nombre de vecteurs d'interruption.

LDT peut contenir, tout comme GDT, 8 192 descripteurs. Mais elle est locale à chaque tâche, c’est-à-dire qu'il y a une LDT par tâche. Chaque LDT possède son descripteur dans la GDT. Donc, chaque LDT est un segment. À chaque commutation de tâche, le système accède à un sélecteur propre à la tâche qui permet d'accéder au descripteur de la LDT dans la GDT. Immédiatement, sélecteur et descripteur sont transférés dans le registre LDTR qui, comme un registre de segment, montre le sélecteur et cache le descripteur.

Les registres LDTR et Task Register
figure 3.11 Les registres LDTR et Task Register [the .swf]

Nous avons représenté également le registre de tâche, qui possède la même structure et se charge de la même façon.

3.6 Protections

Les mécanismes de protection sous-tendent l'ensemble de l'architecture et en expliquent partiellement la complexité. Il existe quatre domaines de protection :

  Le type de segment.

  Les limites de segment.

  Les niveaux de privilège.

  Les instructions protégées ou privilégiées.

3.6.1 Type et limites de segment

Nous avons vu comment, grâce au paramètre limite du descripteur de segment, le système contrôlait l'accès aux segments, déclenchant une exception si la limite était franchie, pouvant utiliser cette exception pour résoudre le problème en réallouant de la mémoire.

Les vérifications sur les attributs (le type) d'un segment découlent en grande partie du bon sens. Le processeur n'autorise pas les actions illogiques. Sinon, pourquoi gérer des attributs ? Voici quelques règles :

Au chargement d'un sélecteur dans un registre de segment :

  Uniquement des sélecteurs pointant vers des segments exécutables dans le CS.

  Les sélecteurs de segments de code interdits en lecture ne peuvent se charger dans DS, ES, FS ni GS.

  Seuls des sélecteurs de segments autorisés en écriture peuvent déposés dans le registre SS.

À l'exécution de certaines instructions :

  Pas d'écriture dans un segment de code.

  Pas de lecture d'un segment de code interdit en lecture.

  Pas d'écriture dans un segment de code à lecture seule.

La violation d'une de ces règles entraîne une exception #GP (General Protection Fault).

Le premier segment de la table GDT, d'index 0, n'est pas utilisable par les applications. Il s'agit d'un dispositif particulier de protection : Le sélecteur null . Un sélecteur où tous les bits sont à 0 représente l'index 0 dans la table GDT (celui qui est inutilisable) et un RPL de 0. Nous pourrions utiliser éventuellement la valeur 3 ( 0000000000000011b ), qui est la même chose avec un RPL de 3. Il est possible de charger ce sélecteur dans les registres de segments DS, ES, FS et GS. Ce n'est qu'en cas d'utilisation de l’un de ces registres qu'une exception sera levée. Initialiser à null les registres de segments non utilisés est une bonne précaution, la certitude de déclencher une exception en cas d'utilisation erronée est une bonne chose. Cette façon de faire fait irrésistiblement penser aux pointeurs initialisés à NULL quand la mémoire pointée n'est pas encore allouée ou libérée. Ceci s'adresse plus au programmeur système, c'est une donnée brute du microprocesseur, les contraintes d'écriture dans les segments, particulièrement DS, pouvant être différentes sous un OS particulier.

Dans CS et SS, le simple chargement du registre par le sélecteur null provoque l'exception, ce qui est normal puisque ces deux registres sont toujours indispensables et qu'ils doivent donc contenir une valeur correcte.

3.6.2 Niveaux de privilège

Les anneaux de privilège sont le concept fondateur de l'architecture IA depuis les 286 et 386.

Nous avons déjà entrevu les niveaux de privilège, sous la forme du RPL (Requested Privilege Level) dans les sélecteurs de segments, et du DPL (Descriptor Privilege Level) dans... les descripteurs.

Ajoutons-y le CPL (Current Privilege Level) qui est celui de la tâche ou programme courant, en cours d'exécution. Il est indiqué par les bits 0 et 1 des registres CS et SS. C'est le plus souvent le niveau de privilège du segment de code dans lequel sont les instructions, mais le système peut modifier cette valeur.

Il s'agit toujours d'une valeur sur deux bits, donc quatre niveaux sont définis, de 0 à 4. Au niveau le plus faible, 0, correspond le maximum de privilèges. Il est courant de parler d'anneaux de privilège , et le mot anglais « ring » est souvent conservé : le noyau du système d'exploitation travaille en ring 0 . Il est plus souvent fait référence aux niveaux extrêmes ring 3 pour les applications et ring 0 pour le système. Sous Windows, les niveaux intermédiaires ring 1 et ring 2 sont occupés par les services du service d'exploitation. Vu de l'application, tout ce qui n'est pas ring 3 est privilégié. Les deux niveaux intermédiaires servent à protéger le cœur du système en ring 0 de ses propres services.

Les anneaux de privilège
figure 3.12 Les anneaux de privilège [the .swf]

Il existe d'autres localisations pour trouver, sous la forme de deux bits, un niveau de privilège, par exemple IOPL dans EFLAGS, et d'autres dans des structures que nous n'avons pas encore abordées.

Le code est considéré comme de moins en moins fiable au fur et à mesure que le niveau augmente, donc que les privilèges diminuent :

  Ring 3, niveau 3, applications, code non testé supposé à risque.

  Ring 0, niveau 0, noyau système, code certifié comme très fiable.

Si une tâche à un niveau de privilège donné, déterminé par CPL, tente d'accéder à un segment de données (ou de code, si la lecture est autorisée) de privilège DPL, en chargeant un de ses registres de segments d'un descripteur de privilège RPL, la condition suivante doit être respectée :

DPL >= CPL et DPL >= RPL

Dans le cas d'une application normale, les trois niveaux seront égaux à 3. Mais ce dispositif permet aux tâches plus privilégiées, particulièrement celles du système d'exploitation, d'accéder aux segments du niveau application, ce qui est normal.

Il faut bien voir que le système peut très bien proposer, à deux processus, deux sélecteurs qui ne différeront que par le RPL, qui sera généralement mis en accord avec le CPL.

Le cas de la pile est particulier : pour charger un descripteur dans SS, il faut que :

RPL = CPL = DPL

Voyons en nous aidant d'une illustration le cas de quatre procédures de niveaux de privilège différents cherchant à accéder à un même segment de données.

Accès à un segment de données
figure 3.13 Accès à un segment de données [the .swf]

Le cas des segments de code A et B accédant au segment de données D n'appelle pas de commentaire particulier, puisque leurs niveaux CPL sont plus petits (privilèges supérieurs) ou égaux au DPL du segment D, et que les procédures de ces segments de code passent par des sélecteurs de même RPL que leurs CPL respectifs.

Le code tournant dans le segment C n'a aucun espoir d'accéder au segment D, puisque le CPL du segment comme le RPL du sélecteur utilisé sont supérieurs au DPL du segment D.

Le code tournant dans le segment de code E devrait pouvoir accéder au segment D, puisque tournant en ring 0, il a le niveau de privilège maximal (CPL = 0). Malheureusement, il a chargé dans son registre DS (par exemple, ou ES, FS, GS) le sélecteur S3, doté d'un RPL de 3, qui lui interdit l'accès à D, de DPL 2.

Nous avons donc une idée des possibilités d'accès aux segments entre les différents niveaux de privilège. Mais qu'en est-il de l'exécution, du flux de programme ? En d'autres termes et très simplement, mon application tournant en ring 3 peut-elle appeler, par un CALL , une fonction de l'OS installée en ring 0 ?

Nous allons voir un peu plus loin que les transferts d'exécution en mode Protégé se font souvent de façon différente que par un simple  JMP , CALL , INTx , RET , IRET , en version FAR dans le cas qui nous occupe. En fait, nous verrons que ces instructions sont toujours employées, mais qu'elles ne pointent pas directement sur le code visé. Outre le saut direct, elles peuvent diriger le flux de programme vers une structure appelée gate (porte), voire une commutation de tâche.

Dans le cadre des appels normaux, et même deux fois normaux puisque non-conforming, pas de complication : le privilège courant CPL de l'appelant doit être strictement égal au niveau DPL du segment de code de la cible. Point.

Toujours dans le cadre d'un appel direct, mais dans le cas où le segment cible serait de type conforming , les choses se compliquent un peu. Le segment cible (de type conforming, rappelons-le, ce qui pourrait se traduire par bonne pâte ) possède, dans son descripteur, un certain niveau de privilège, DPL. Peu importe, il prend le CPL de l'appelant. Nous l'avons dit, bonne pâte. Ainsi le processus appelant, qui ne s’est pas vu accorder trop de droits, sans doute avec raison, ne va pas en profiter pour faire faire des bêtises à l'appelé.

Reste un détail : dans la philosophie des niveaux de privilège, il est admissible qu'un processus dégrade ses droits, mais plus gênant qu'il se promotionne, qu'il s'accorde des droits supérieurs. Ce serait remettre en cause la belle logique de l'ensemble. Donc, le DPL du segment cible représente la valeur minimale (le maximum de privilèges) que peut avoir le CPL du segment d'origine. DPL <= CPL. Pour faire simple, un programme ne peut appeler de procédure dans un segment conforming que si celui-ci est plus privilégié que lui, ou à l’identique.

3.6.3 Instructions privilégiées

Certaines instructions, travaillant généralement au niveau de la couche système, voient leur utilisation réservée au ring 0. C’est-à-dire que si CPL est différent de 0, ces instructions provoqueront une exception #GP (General-Protection exception). Ces instructions, appelées instructions privilégiées, sont (en prenant pour source les processeurs Intel les plus récents en mai 2003) :

  LGDT (Load GDT register).

  LLDT (Load LDT register).

  LTR (Load task register).

  LIDT (Load IDT register).

  MOV [registres de contrôle].

  MOV [registres de debug].

  LMSW (Load machine status word).

  CLTS (Clear task-switched flag in register CR0).

  INVD (Invalidate cache, without writeback).

  WBINVD (Invalidate cache, with writeback).

  INVLPG (Invalidate TLB entry).

  HLT (Halt processor).

  RDMSR (Read Model-Specific Registers).

  WRMSR (Write Model-Specific Registers).

  RDPMC (Read Performance-Monitoring Counter).

  RDTSC (Read Time-Stamp Counter).

Les deux dernières sont privilégiées ou autorisées pour tout CPL selon l'état d'un bit (PCE et TSD dans CR4). L'état normal de RDPMC est d'être privilégiée, celui de RSTSC est d'être autorisée à toutes les applications.

D'autres instructions sont partiellement privilégiées, ou plutôt ce sont les registres sur lesquels elles interviennent qui le sont (voir un peu plus loin POPFD ). En cas de souci, reportez-vous à la documentation la plus spécifique à votre processeur et cette fois-ci, lisez toutes les lignes, particulièrement les rubriques consacrées aux exceptions dans les différents modes de fonctionnement.

Il est bien évident que, par les mécanismes de protection, en premier rang desquelles les instructions privilégiées, le logiciel système, travaillant en ring 0, rendra inaccessible aux applications tournant en ring 3 la modification barbare des éléments de la protection elle-même. Si une application en ring 3 pouvait forcer son IOPL à 0, tout cela n'aurait aucun sens. Pour ce faire, nous pourrions coder :

pushfd
pop eax
and eax, 11111111111111111100111111111111b
push eax
popfd

11111111111111111100111111111111b (ou FFFFCFFFh , soit 4294955007 ) est le masque de forçage à 0 des bits 12 et 13 de EFALGS, soit IOPL.

Ce code tourne, sans protection fault. Mais si nous lisons la documentation de POPFD (qui dépile dans EFLAGS), nous avons toutes les réponses que nous souhaitons. C'est assez compliqué, les flags sont modifiés ou préservés selon le niveau absolu du CPL et son niveau par rapport à IOPL, et en aucun cas sauf en ring 0, IOPL ne sera modifié. Donc, notre code ne fait rien du tout. Il ne sera pas plus possible de modifier le RPL d'un sélecteur de segment.

Tout le reste est du même tonneau, le système de protection est réellement efficace, s'il faut passer par l'OS pour accéder à une ressource, il sera difficile de passer outre. C'est possible et prévu, il faut passer par l'écriture de pilotes de périphériques. Ce sont ces composants qui permettent le fonctionnement de débogueurs niveau kernel (noyau), comme par exemple SoftIce, de NuMega.

Il est impressionnant d'imaginer la complexité de l'ensemble du fonctionnement de cette usine. Rappelons-nous que si le système est optimisé pour gérer le mutitâche, il est monoprocesseur, donc au niveau le plus bas monofil : une seule instruction s'exécute à un instant donné, puis la suivante, et ainsi de suite. Il ne faut surtout pas que le système se retrouve bloqué en ring 3, puisque, à ce niveau de privilège, il ne dispose d'aucun moyen de débloquer la situation en repassant en ring 0. Or, monoprocesseur monofil, donc aucun code ne tourne en ring 0 pendant que se déroule une tâche en ring 3.

3.7 Transferts de code entre niveaux de privilège

Il est possible, dans certains cas précis de segment cible de type conforming, d'utiliser de simples JMP ou CALL . Mais généralement, c'est une porte d'appel, que nous appellerons à l'américaine Call Gate, qui sera employée. Il est également possible, pour des niveaux de privilège particuliers, de faire appel au couple d'instructions SYSENTER et SYSEXIT . Ce sont ces deux techniques que nous allons maintenant découvrir.

3.7.1 Les Call Gates

Cette technique des Call Gates est utilisée pour gérer les passages autrement interdits entre niveaux de privilège différents. Bien entendu, les transferts à même niveau de privilège sont également possibles. Bâties sur le même principe existent les Task Gates, Interrupt Gates et Trap Gates, que nous ne détaillerons pas faute de place et parce qu'effectivement le principe est le même.

Au travers d'un Call Gate, nous transférons le contrôle de l'exécution à un autre segment. À la lumière de ce qui précède, nous avons compris qu'un segment est bien autre chose qu'une zone de mémoire : c'est un ensemble d'attributs, mieux, c'est un contexte, particulièrement un niveau de privilège. Un Call Gate sera également utilisé pour transférer le flux d'un programme entre des segments 16 et 32 bits.

Fonctionnellement, le Call Gate est très comparable à un super pointeur vers une procédure, celle-ci possédant la particularité de pouvoir indifféremment se situer n'importe où, à divers niveaux de privilège, et en mode 16 ou 32 bits.

Physiquement, un Call Gate est en tout point semblable à un descripteur de segment. C'est d'ailleurs un descripteur de segment, mais dont le bit S (bit 12) est à 0 pour indiquer un descripteur système. Il serait plus judicieux d'écrire descripteur de type Call Gate , plutôt que descripteur de Call Gate , le Call Gate étant le descripteur. Donc, un Call Gate résidera dans GDT ou une LDT et sera activé par l'intermédiaire d'un sélecteur de segment.

Pour appeler un Call Gate, les instructions CALL et JMP , FAR bien entendu, seront utilisées. Le sélecteur correspondant au Call Gate sera dans le registre de segment utilisé, et l'offset sera quelconque, puisqu'il n'en sera pas tenu compte.

Un descripteur de Call Gate
figure 3.14 Un descripteur de Call Gate [the .swf]

Le bit 12 (ou bit S) à 0 est le bit qui identifie le descripteur en tant que descripteur système. C'est par sa lecture que tout va commencer pour le processeur. Il va conclure de la lecture des bits de Type qu'il s'agit d'un Call Gate et en déduire la signification des autres champs du descripteur.

Un Call Gate a plusieurs fonctions, utilisées ou non selon le contexte. Il spécifie :

  Le segment de code à atteindre.

  Le point d'entrée d'une procédure dans ce segment de code.

  Le niveau de privilège requis pour accéder à la procédure.

  S'il y en a, le nombre de paramètres à transférer dans la nouvelle pile.

  La taille de ces paramètres.

  La validité du descripteur.

Le Call Gate décrit l'accès à une procédure située dans un segment. Ce segment est décrit par ailleurs par un descripteur dans la GDT ou la LDT, le sélecteur de segment du Call Gate permet d'y accéder.

L' offset dans le segment indique le point d'entrée dans le segment, généralement la première instruction d'une procédure.

Mécanisme d'appel par un Call Gate
figure 3.15 Mécanisme d'appel par un Call Gate [the .swf]

Le bit P indique si le descripteur lui-même est valide ou pas, à ne pas confondre avec la présence ou l'absence du segment de code, qui est indiquée par le bit P de son propre descripteur. Ce bit ne semble pas servir à grand-chose. À 0, l'accès au descripteur déclenche une exception #NP (Not Present). La documentation en propose un usage amusant : gérer l'exception #NP pour compter les appels au Call Gate.

La gestion des piles entre les diverses tâches est complexe (au point où nous en sommes...). De façon très schématique, il est exclu pour des raisons de protection de partager une pile entre deux niveaux de privilège, et ce de façon absolue. Chaque tâche d'application dispose pour sa vie entière de quatre piles : sa pile courante en ring 3 et des moignons de pile pour chacun des trois autres niveaux. En fait de moignon, il s'agit d'un sélecteur de segment de pile et d'un pointeur à charger dans ESP, le tout dans le TSS de la tâche. Que nous faut-il en retenir ? Que, à chaque fois qu'un appel à un Call Gate entraîne un changement de niveau de privilège, la pile va être commutée vers celle de niveau adéquat. Dans la nouvelle pile seront immédiatement empilés de façon automatique des paramètres de retour (SS, ESP puis CS, EIP). Entre les deux paires seront copiés depuis l'ancienne pile vers la nouvelle les paramètres transmis à la procédure.

Commutation de piles
figure 3.16 Commutation de piles [the .swf]

Le nombre de paramètres à empiler, qui peut très bien être nul, est la valeur nombre de paramètres passés ( param count ) du descripteur de Call Gate.

Comme pour tout descripteur, les deux bits de DPL identifient un niveau de privilège.

Dans la procédure appelante, nous avons un CPL, un RPL dans le sélecteur et nous trouvons un CPLcg dans le descripteur du Call Gate. De plus, le descripteur du segment de code de destination aura son DPLcs . Les conditions à respecter sont les suivantes, pour un appel par un CALL  :

  CPL =< DPLcg

  RPL =< DPLcg

  DPLcs =< CPL

Pour un appel par un JMP , c'est la même chose, sauf dans le cas d'un segment de code de destination non conforming, où il est imposé que : DPLcs = CPL .

Ce qu'il faut retenir dans le cas général, c'est que le DPL du Call Gate représente le maximum du CPL du processus appelant, donc les droits minimaux. Un Call Gate ayant un DPL à 2 pourra être appelé depuis un CPL de 0, 1 ou 2 mais pas 3.

Nous en savons suffisamment sur les Call Gates, disons plutôt suffisamment pour nous plonger sereinement dans la documentation. Mieux, nous verrons avec plaisir que les Interrupt Gates et les Trap Gates ont exactement la même structure, et que celle des Task Gates gérant la commutation des tâches en est proche.

3.7.2 SYSENTER et SYSEXIT

Ces deux instructions ont été proposées à partir du Pentium 2 en réponse à une demande précise mais fréquente des systèmes d'exploitation : l'appel par une application en ring 3 d'une procédure système en ring 0, et l'opération inverse.

Ces opérations sont du ressort d'un Call Gate, mais dans les situations où SYSENTER  et SYSEXIT  sont utilisables, elles sont optimisées et donc plus rapides.

SYSENTER ne peut être utilisée qu'à partir d'un niveau de privilège 3, 2 ou 1. Elle envoie le flux programme impérativement en niveau 0.

SYSEXIT ne peut être utilisée qu'en niveau de privilège 0 et renvoie le flux programme en niveau de privilège 3, 2 ou 1.

SYSENTER et SYSEXIT sont indubitablement liées dans leur esprit, mais il n'y a pas de couplage automatique comme pour le couple CALL/RET . L'adresse de retour devra être gérée par le programmeur, ce qui n'est pas compliqué (pour une fois...).

Ces deux instructions ne sont suivies d'aucun opérande. Elles en ont pourtant besoin, il faut au minimum transmettre l'adresse de la procédure cible. Un certain nombre de registres MSR (Model-Specific Register) sont utilisés pour cette tâche. Ce sont pour SYSENTER  :

  SYSENTER_CS_MSR indique le segment cible.

  SYSENTER_EIP_MSR indique l'offset du point d'entrée de la procédure cible.

  SYSENTER_ESP_MSR indique la valeur de ESP dans la procédure cible.

  La valeur de SS dans la procédure cible est toujours obtenue en ajoutant 8 au contenu de SYSENTER_CS_MSR .

Et pour SYSEXIT  :

  Le segment cible, qui est ici le segment de retour, s'obtient en ajoutant 16 au contenu de SYSENTER_CS_MSR .

  L'offset de retour (valeur de EIP une fois dans le segment cible) est dans EDX.

  La valeur de SS dans le segment cible s'obtient en ajoutant 24 au contenu de SYSENTER_CS_MSR

  La valeur de ESP est dans ECX.

Les instructions elles-mêmes sont plus rapides qu'un CALL par un Call Gate, les vérifications de protection étant en quelque sorte préfabriquées. De plus, les procédures d'appel sont plus simples, certains registres n'ayant pas besoin d'être chargés à chaque appel.

 

Si nous comparons ce que nous savons maintenant et la documentation citée en début de chapitre, il est clair que nous ne savons rien, ou si peu. Ce n'est pas si grave. Pourquoi ? Parce que les notions que nous avons assimilées sont fondamentales et déclinées sous de multiples formes. Par notions, entendons les sélecteurs et descripteurs, les tables et la façon d'accéder à des données ou à du code au travers du triplet sélecteur-table-descripteur. Nous savons également qu'il est fréquent qu'un seul étage ne suffise pas et que plusieurs tables, plusieurs indirections sont utilisées pour atteindre le but. Là réside une des difficultés : il peut arriver que nous ne nous souvenions plus du point de départ une fois arrivé.

C'est parce que nous maîtrisons ces principes de base que nous allons maintenant survoler quelques points importants, les interruptions et la commutation de tâche. Pour les premières, nous en savons suffisamment, à partir de ce chapitre mais également des deux précédents, pour imaginer avec peu d'erreurs le principe général de leur traitement.

3.8 Interruptions et exceptions

Interruptions et exceptions sont des événements qui peuvent survenir à n'importe quel moment, même s'il est possible de les provoquer par programmes, donc à un instant précis : ce sont les interruptions logicielles. Les exceptions ne surviennent pas non plus tout à fait n'importe quand, c'est une instruction précise qui en provoque une.

Les interruptions matérielles, qu'elles proviennent de l'extérieur ou du microprocesseur lui-même, surviennent réellement à n'importe quel moment. Nous n'avons pas encore vu précisément ce qu'est une tâche, mais nous avons évoqué le sujet, qui est de plus assez intuitif. Sur une machine connectée à internet par l'intermédiaire d'un modem connecté à la prise série, de nombreuses tâches tournent. Une d'entre elles gère la liaison série à bas niveau (certainement un pilote de périphérique). Elle occupe plusieurs niveaux de privilège, au moins une partie en ring 0, mais peu importe. Quand survient l'interruption indiquant que le tampon de réception est plein, n'importe quelle tâche peut être active, mais ce n'est pas grave puisque c'est au niveau système que doivent tout d'abord être reçues la plupart des interruptions.

Le cas peut-être le plus embêtant est celui d'un programme DOS tournant en mode V86. Ce type de programme comporte souvent ses propres routines de traitement d'interruption. Il faudra bien que le système finisse par donner le contrôle à l’une de ces procédures. Ainsi s'expliquent par exemple les flags VIF et VIP de EFLAGS. Nous n'avons malheureusement pas la place de détailler ce sujet. Tout est dans la documentation citée en début de chapitre, paragraphe 16.1.4 pour le mode Réel et 16.3 pour le mode V86.

De la même façon, vous vous reporterez aux mêmes sources si le sujet machine-check architecture vous intéresse. Ces dispositifs déclenchent des exceptions en cas de souci avec le hardware. Elles ressemblent à des interruptions qui, générées par des problèmes internes du processeur, ne passent pas par une ficelle à l'extérieur.

Ne nous voilons pas la face, le sujet est composé d'un certain nombre de sous-sujets qui chacun ferait sans problème l'objet d'un copieux chapitre. En revanche, la mécanique générale de traitement, sur le schéma événement-vecteur–vecteur + IDTR-IDT–Interrupt Gate–traitement, est constante.

Rien n'a vraiment changé depuis les premières générations, chaque interruption ou exception est associée à un identificateur, une valeur numérique entre 0 et 255, appelée vecteur d'interruption. Les 32 premières valeurs, de 0 à 31, sont définies par le processeur, de façon immuable. Les vecteurs réservés ne sont pas disponibles et ne doivent pas être utilisés. Voici la définition simplifiée de ces 32 vecteurs, telle que donnée par Intel :

0  #DE Divide Error.
1  #DB Debug.
2  NMI Non-maskable external interrupt.
3  #BP Breakpoint INT 3.
4  #OF Overflow.
5  #BR BOUND Range Exceeded.
6  #UD Invalid Opcode (UnDefined Opcode).
7  #NM Device Not Available (No Math Coprocessor.
8  #DF Double Fault
9  CoProcessor Segment Overrun (reserved)
10 #TS Invalid TSS.
11 #NP Segment Not Present.
12 #SS Stack Segment Fault.
13 #GP General Protection.
14 #PF Page Fault.
15 (Intel reserved. Do not use.)
16 #MF Floating-Point Error (Math Fault).
17 #AC Alignment Check.
18 #MC Machine Check Error.
19 #XF Streaming SIMD Extensions.
20-31 (Intel reserved. Do not use.)

Ces vecteurs sont ainsi définis par le hardware et sont donc indépendants du système d'exploitation. Il n'en va pas de même pour ceux allant de 32 à 255, définissables par l'utilisateur. Attention, le premier utilisateur dans le cas qui nous préoccupe, c'est Windows. Dans cette zone, il définit les interruptions dont il a besoin, en particulier celles qui nous posent des problèmes quand nous installons une nouvelle carte périphérique.

Quelles familles de sources d'interruptions/exceptions ? Nous venons de voir que les exceptions sont générées par le processeur lui-même. En oubliant les exceptions de type machine-check , elles sont toutes provoquées par une erreur du programme (ou par  INT n ou  BOUND , voir cette instruction au chapitre sur le jeu d'instructions). Attention, une erreur du programme ne signifie pas nécessairement une erreur du programmeur. Le mot aléa serait plus explicite que le terme erreur. Nous avons vu dans ce chapitre au sujet de quelques exceptions qu'elles peuvent très bien être utilisées volontairement en programmation. Le principe consiste à remplacer une batterie de tests ayant pour but d'éviter l'exception par un traitement efficace de celle-ci. Les programmeurs en C comprendront facilement. Un exemple des plus simples : une exception sur une instruction n'existant pas, alors la routine de traitement regarde quelle était cette instruction et si elle identifie par exemple du FPU ou MMX, elle peut renvoyer le programme vers une routine d'émulation.

Les exceptions sont réparties en trois familles :

  FAULTS  : une erreur, mais qui ne remet pas généralement en cause la possibilité de poursuite du programme. L'adresse de retour ( CS :EIP dans la pile) pointe sur l'instruction fautive, qui sera exécutée après le traitement.

  TRAP  : une erreur le plus souvent volontaire, typiquement c'est l'exception générée après chaque instruction en mode Pas à pas. Le contrôle est rendu à l'instruction suivant l'instruction ayant provoqué le trap.

  ABORTS  : une erreur très sévère, qui ne permet pas de récupérer la tâche. L'adresse de retour n'est pas toujours une adresse valide.

 

Observons ensuite les interruptions matérielles. Spécifique à la famille Pentium, il est possible de mettre en branle l'APIC (Advanced Programmable Interrupt Controler). Ce système met en œuvre une table locale de vecteurs (encore une...), mais peu importe, nous en arriverons toujours à un vecteur.

Si l'APIC n'est pas utilisé, nous nous retrouvons dans le cas de figure qui nous intéresse sur nos compatibles PC (rappelons quand même que IA32 et PC sont des choses liées, mais différentes). Une patte d'interruption non masquable NMI, qui fournit le vecteur 2, et une patte INTR (Interruption Request).

Tout cela, nous connaissons : sur le PC, un circuit compatible 8259A fournit pour chaque source extérieure d'interruption directement un vecteur.

Il est, bien entendu, possible de déclencher directement par programme les interruptions, par l'instruction INT n , n allant de 0 à 255. Ces instructions lancent le même traitement que les INTR de même vecteur, mais ne subissent pas l'effet du masquage par IF.

Une fois que nous avons le vecteur d'interruption, nous connaissons la suite. Ce vecteur se comporte comme un sélecteur, en ce sens qu'il envoie, dans une table de descripteurs, IDT. Cette table est globale, puisque nous avons vu que les interruptions se traitent au niveau système, une IDT locale n'aurait aucun sens.

Interrupt Gate, Trap Gate et Task Gate
figure 3.17 Interrupt Gate, Trap Gate et Task Gate [the .swf]

Les Interrupt Gates et Trap Gates sont pratiquement identiques, la seule différence étant le bit qui les identifie comme un Trap ou Interrupt et permet un traitement du saut dans la procédure de traitement légèrement différent. Les éléments qui restent utiles sont les mêmes que pour les Call Gates et ne doivent pas poser de problème particulier. Le DPL est toujours utile, au moins parce qu'il est possible d'appeler ces portes par logiciel, par l'instruction INT n par exemple.

Les Task Gates ont une structure plus originale, nous allons voir de façon très générale comment se passe la commutation des tâches.

3.9 Commutation des tâches

Une tâche est une unité d'exécution que le processeur peut lancer, puis suspendre et relancer. Cette unité d'exécution est utilisée pour tout ce qui est au sens large un programme, c’est-à-dire les applications, processus indépendants, services résidents du système d'exploitation, routine de traitement d'interruption ou d'exception, bref en mode Protégé, tout ce qui tourne le fait au sein d'une tâche.

Dès qu'il y a plusieurs tâches, et sous Windows c'est largement le cas dès que le système est lancé, le noyau va lancer les tâches puis les interrompre pour les valider une par une, plus ou moins à tour de rôle.

Le challenge pour un système d'exploitation multitâche est de pouvoir lancer les tâches le plus souvent possible, avec la limite de ne pas passer plus de temps à commuter qu'à laisser travailler les tâches (consultez la partie consacrée au multitâche au chapitre précédent). Le goulet d'étranglement en termes de performance se situe dans la sauvegarde et la restauration du contexte de fonctionnement, pour que chacune des tâches puisse reprendre son travail comme si rien ne s'était passé.

Il est clair que chaque tâche possède son ou ses segments de code, pile et données, et qu'entre deux tranches de fonctionnement, rien ne va changer. Mais une partie importante du contexte se trouve dans le processeur, et il va falloir optimiser la sauvegarde et la restauration de ces informations.

Pour cela, chaque tâche va être dotée d'un (petit) segment particulier, le TSS (Task State Segment), destiné à accueillir ces éléments. Il ne faut surtout pas sauvegarder/restaurer tous les registres du processeur, puisque certains doivent impérativement conserver leurs informations de tâche en tâche.

Listons tous les éléments dont la sauvegarde est nécessaire :

  Tous les registres de segments qui contiennent les sélecteurs définissant l'espace de travail.

  Le contenu de tous les registres généraux.

  Le contenu du registre de flags EFLAGS.

  Le contenu du compteur programme EIP.

  Le contenu du registre de contrôle CR3.

  Le contenu du registre de tâche (nous allons y revenir).

  Le contenu du pointeur LDTR.

Les éléments suivants sont et restent dans le TSS :

  Les tables d'I/O.

  Les ESP des piles ring 0, ring 1 et ring 2.

  Un lien vers la tâche précédente.

Savoir si les registres cachés correspondant aux registres sauvegardés (LDTR) le sont également nous importe peu. Ils ne le sont, semble-t-il, pas. Ainsi seront-ils rafraîchis, si par hasard ils ont changé pendant l'interruption.

La structure exacte de cette TSS n'est pas importante, elle se trouve facilement dans la documentation. C'est une table de 25 mots de 32 bits (100 octets) dans sa version actuelle. Le fait qu'il s'agisse d'un segment associé, nous allons le voir, à un descripteur rend cette structure facilement évolutive. Elle contient tous les éléments que nous avons listés, à l'exception du contenu du registre de tâche.

Ce registre de tâche est un simple sélecteur (associé au cache du descripteur), nous l'avions déjà représenté avec le LDTR, auquel il est strictement identique. Ce sélecteur nous amène au descripteur de TSS, dans la GDT (bien entendu, puisque c'est dans TSS que se trouve le LDTR permettant d'accéder à LDT). Observez un descripteur classique.

Le descripteur de TSS
figure 3.18 Le descripteur de TSS [the .swf]

Le bit B, Busy flag, indique que la tâche est active. Par active, il faut entendre qu'elle a été lancée et n'est pas terminée, et non pas active au sens de la commutation des tâches.

Nous devrions maintenant pouvoir reconstituer le processus d'activation d'une tâche, au travers d'une Task Gate. Le sélecteur de la Task Gate désigne le descripteur dans GDT, qui désigne la page de segment. À partir de là, le contexte de la tâche active est régénéré, alors que celui de la tâche précédente est archivé dans son propre TSS. Enfin, le programme reprend dans la tâche à l'EIP qui avait été sauvegardé.

Quatre méthodes permettent de commuter vers une tâche :

  Un JMP ou un CALL par la tâche (programme, procédure...) courante vers le descripteur de TSS dans la GDT.

  Un JMP ou un CALL par la tâche (programme, procédure...) courante vers le descripteur de Task Gate dans la GDT ou la LDT courante.

  Un vecteur d'interruption ou d'exception qui pointe vers un descripteur de Task Gate dans l'IDT.

  La tâche courante exécute un IRET (retour d'interruption) alors que le flag NT (Nested Task, tâche imbriquée, est allumé).

Vous vous demandez certainement comment le système d'exploitation gère tout cela. Disons clairement que nous pensons à Windows. Windows, par sa couche kernel (noyau), traite toutes les interruptions. À chaque interruption, il reprend donc la main. Nous verrons au chapitre sur la programmation Windows que souvent à une interruption (ou une série d'interruptions) correspond la nécessité d'envoyer un message à une ou plusieurs applications. De plus, il est permis de penser que le scheduler (la couche qui s'occupe de la commutation des tâches) ne lance pas une tâche pour son temps de travail sans lancer un timer (hardware) qui redonnera la main au kernel, si rien d'autre ne l'a fait d'ici là.

Windows, dans ses versions les plus courantes, n'utilise pas nécessairement toutes les ressources du processeur, devant rester compatible avec toute la gamme.

3.10 La pagination ou paging

Nous ne faisons que présenter succinctement l'unité de pagination. Nous avons vu que, même quand elle est présente, son utilisation n'a rien d'obligatoire et dépend du bit PG de CR0.

Le but de la pagination est de projeter un espace linéaire de 4 Go vu par le programmeur vers un espace de mémoire physique ou même d'espace disque, qui peut être plus petit ou plus grand. Le nom de mémoire virtuelle est souvent employé, dans des sens un peu différents parfois. La mémoire virtuelle de Windows désigne ainsi le plus souvent un espace disque, et elle ne nécessite pas nécessairement l'unité de pagination du processeur.

Ce processus est totalement transparent pour l'utilisateur et même pour le programmeur. La seule chose qu'il peut être amené à faire est de configurer cette unité de pagination, et encore est-ce généralement les couches de plus bas niveau du système d'exploitation qui le gèrent, si la pagination est utilisée, répétons-le, ce qui est loin d'être le cas général.

Quand la pagination est activée, l'espace linéaire obtenu par la segmentation, tel que nous venons de l'étudier, est divisé en blocs de taille fixe. Cette taille, sur les processeurs les plus récents, peut être de 4 Ko, 2 Mo ou 4 Mo, mais elle est fixe à un instant donné pour l'ensemble de la mémoire.

Chacun de ces blocs est projeté individuellement vers un bloc de la même taille de la mémoire physique. Il n'y a aucune contrainte dans cette affectation, il est ainsi possible, à partir d'un espace physique totalement fragmenté, d'obtenir des blocs bien propres de mémoire linéaires, ceux qui sont seuls vus par le programme. La gestion de la mémoire s'en trouvera largement accélérée, puisque par exemple une défragmentation se fera de façon pratiquement transparente.

Il peut être intéressant ici de relire si nécessaire ce qui a été écrit sur les extensions de mémoire EMS au chapitre précédent. Nous pourrions imaginer que l'adresse linéaire (32 bits, rappelons-le) attaque une table qui va, bloc par bloc, modifier cette adresse, à l'aide d'une électronique configurable, où les quelques registres, déjà nombreux, de la carte EMS seraient remplacés par de vastes plans de mémoire. Dans le cas de blocs de 4 Ko (il y en a 1 048 576 dans les 4 Go), seuls 12 bits passent tout droit . Si nous nous amusons à réfléchir à la structure de la table de translation nécessaire, nous arrivons vite à des chiffres dissuasifs. C'est ainsi qu'une structure en plusieurs répertoires a été décidée.

Un exemple de pagination (blocs de 4 Ko)
figure 3.19 Un exemple de pagination (blocs de 4 Ko) [the .swf]

Il ne s'agit là que d'un exemple, blocs de 4 Ko, bus d'adresses physiques en 32 bits. Dans le schéma, nous avons renoncé à tenter une traduction. Le seul effet aurait été d'ailleurs de vous embrouiller quand vous lirez la documentation sur le sujet, qui sera certainement en anglais.

Nous n'avons pas représenté le contenu de CR3, car il l'est partiellement auparavant dans le chapitre. Disons qu'il dépend de la taille des blocs et que, pour des blocs de 4 Ko, il est constitué, outre des attributs, de 20 bits pour l'adresse de base du Page Directory. Le principe devrait maintenant être clair : les 12 bits de poids faible passent tout droit. Les 10 bits de poids fort sélectionnent une entrée (parmi 1 024), dans la table Page Directory (celle pointée par CR3), qui contient des pointeurs vers des tables. Dans la table Page Table ainsi sélectionnée, les 10 bits intermédiaires permettent de choisir une entrée (encore parmi 1 024). Cette entrée contient les 20 bits de poids fort de l'adresse physique, que nous pouvons considérer (comme pour la mémoire EMS) comme une fenêtre dans la mémoire, un Page Frame .

Pour apprécier cette structure, par rapport à une énorme table linéaire, il faut tenir compte du fait que tout n'a pas besoin d'être en mémoire au même moment. Il existe d'ailleurs un système de cache évolué, qui fait que les pages actives sont très souvent dans le processeur, c’est-à-dire d'un accès immédiat. Le nombre de niveaux de paginations, donc la taille (ici très raisonnable) des Page Directories et de Page Tables, est un compromis entre le temps de chargement des pages (leur taille) et la fréquence de ces chargements (leur nombre, donc leur taille).

Les versions récentes du Pentium peuvent adresser 16 fois plus de mémoire physique (bus externe de 36 bits), soit 64 Go, toujours à partir d'un espace linéaire sur 32 bits. Nous nous épargnerons le schéma d'accès (et translation) à ces 64 Mo par blocs de 4 Ko. Ce n'est tout compte fait pas beaucoup plus compliqué : il suffit de rajouter... une table, pour laquelle il devient compliqué de trouver un nom : Page Directory Pointer Table. Les éléments de cette table pointent chacun vers une table de pointeurs pointant chacun vers une table d'adresses de base de pages. Ouf. La structure de CR3 est modifiée en conséquence.

Cette présentation est tellement simplifiée qu'elle en est presque fausse. Le fonctionnement en profondeur des divers modes de pagination 16 et 32 bits est un véritable casse-tête.

 

Envoyer un message à l'auteur

se connecter pour pouvoir écrire

Ce site est créé et maintenu (laborieusement) par Pierre Maurette
hébergé par 1 & 1