Wikilivres frwikibooks https://fr.wikibooks.org/wiki/Accueil MediaWiki 1.47.0-wmf.16 first-letter Média Spécial Discussion Utilisateur Discussion utilisateur Wikilivres Discussion Wikilivres Fichier Discussion fichier MediaWiki Discussion MediaWiki Modèle Discussion modèle Aide Discussion aide Catégorie Discussion catégorie Transwiki Discussion Transwiki Wikijunior Discussion Wikijunior TimedText TimedText talk Module Discussion module Event Event talk Espéranto 0 15477 771160 771159 2026-08-21T14:09:59Z DavidL 1746 Révocation d’une modification de [[Special:Contributions/~2026-34862-02|~2026-34862-02]] ([[User talk:~2026-34862-02|discussion]]) vers la dernière version de [[User:2603:900A:1C01:6238:4427:D626:EB39:F560|2603:900A:1C01:6238:4427:D626:EB39:F560]] 685227 wikitext text/x-wiki {{Réviser|autre1=fait|autre1texte=Placer les chapitres en sous-pages et leur donner un nom significatif|autre2=tache|autre2texte=Changer les liens des boîtes en bas des leçons pour éviter les redirections|autre3=tache|autre3texte=Améliorer l'aspect visuel des leçons}} ==Sommaire== *[[/Première Leçon/]] : Découverte des mots composés et du suffixe '''-in-''' *[[/Deuxième Leçon/]] : Découverte de la liberté syntaxique ; suffixes '''-ar-''' et '''-id-''' *[[/Troisième Leçon/]] : Suffixes '''-ej-''', '''-il-''' et '''-ist-''' *[[/Quatrième Leçon/]] : Suffixes '''-eg-''', '''-ul-''' et '''-em-''' *[[/Cinquième Leçon/]] : Participes, suffixe '''-et-''' et transformation des couleurs en verbes *[[/Sixième Leçon/]] : Nombres, préfixe '''mal-''' et suffixe '''-ing-''' *[[/Septième Leçon/]] : Diminutifs affectueux et vocabulaire de la famille *[[/Huitième Leçon/]] : Exclamation, suffixes '''-estr-''' et '''-er-''' *[[/Neuvième Leçon/]] : Suffixes '''-ind-''', '''-ebl-''' ; adjectivisation des noms *[[/Dixième Leçon/]] : Préfixes '''ge-''', '''sen-''' et préfixe '''-uj-''' *[[/Onzième Leçon/]] : Découverte de tous les affixes ; adverbes spéciaux *[[/Douzième Leçon/]] : Prépositions *[[/Treizième Leçon/]] : Découverte des corrélatifs *[[/Quatorzième Leçon/]] : Découverte des mots-composés ''(À développer !)'' *[[/Quinzième Leçon/]] : ''Vide'' *[[/Lexique/]] == Alphabet == L'espéranto utilise 28 lettres dont six accentuées. {| class="wikitable altlines1" border="1" |- | Majuscules | '''A''' || '''B''' || '''C''' || '''Ĉ''' || '''D''' || '''E''' || '''F''' || '''G''' || '''Ĝ''' || '''H''' || '''Ĥ''' || '''I''' || '''J''' || '''Ĵ''' || '''K''' || '''L''' || '''M''' || '''N''' || '''O''' || '''P''' || '''R''' || '''S''' || '''Ŝ''' || '''T''' || '''U''' || '''Ŭ''' || '''V''' || '''Z''' |- | Minuscules | '''a''' || '''b''' || '''c''' || '''ĉ''' || '''d''' || '''e''' || '''f''' || '''g''' || '''ĝ''' || '''h''' || '''ĥ''' || '''i''' || '''j''' || '''ĵ''' || '''k''' || '''l''' || '''m''' || '''n''' || '''o''' || '''p''' || '''r''' || '''s''' || '''ŝ''' || '''t''' || '''u''' || '''ŭ''' || '''v''' || '''z''' |} == Prononciation de l'espéranto == En espéranto, l'accent tonique est toujours sur l'avant-dernière syllabe. La prononciation de l'espéranto est relativement facile. De plus, l'orthographe ne pose guère de problème, chaque lettre ne correspondant qu'à un seul phonème et inversement. Les lettres non accentuées de l'alphabet latin, sauf c, se prononcent comme dans l'alphabet phonétique international (A.P.I.) <div align="center"> {| class="altlines1" style="border:1px solid #AAAACC;margin-left:0.5em;margin-bottom:0.5em;text-align:center;" rules="all" cellpadding="3" cellspacing="0" ! '''Majuscule''' ! '''Minuscule''' ! '''Prononciation API''' ! '''Équivalent français''' ! '''Exemples (en français)''' |- | A || a || /a/ || a || '''a'''rbre |- | B || b || /b/ || b || '''b'''allon |- | C || c || /ʦ/ || ts || '''ts'''unami |- | Ĉ || ĉ || /ʧ/ || tch || '''tch'''èque |- | D || d || /d/ || d || '''d'''ire |- | E || e || /e/ || é || '''é'''léphant |- | F || f || /f/ || f || '''f'''amille |- | G || g || /g/ || g || '''g'''are |- | Ĝ || ĝ || /ʤ/ || dj || a'''dj'''udant |- | H || h || /h/ || h || '''h'''ibou |- | Ĥ || ĥ || /x/ || kh || '''Kh'''aled |- | I || i || /i/ || i || '''i'''dée |- | J || j || /j/ || y || '''y'''oga |- | Ĵ || ĵ || /ʒ/ || j || '''j'''eudi |- | K || k || /k/ || k || '''k'''oala |- | L || l || /l/ || l || '''l'''ion |- | M || m || /m/ || m || '''m'''erci |- | N || n || /n/ || n || '''n'''ana |- | O || o || /ɔ/ || o || b'''o'''l |- | P || p || /p/ || p || '''p'''apa |- | R || r || /ɾ/ || r || '''r'''ouge |- | S || s || /s/ || s || '''s'''inge |- | Ŝ || ŝ || /ʃ/ || ch || '''ch'''anter |- | T || t || /t/ || t || '''t'''ête |- | U || u || /u/ || ou || '''ou'''rs |- | Ŭ || ŭ || /w/ || w || '''w'''att |- | V || v || /v/ || v || '''v'''ille |- | Z || z || /z/ || z || '''z'''one |} </div> {{RemarqueIndex| Le nom de chaque voyelle est simplement constitué de la voyelle elle-même : ''a'', ''e'', ''i'', ''o'', ''u''. Le nom de la consonne s'obtient simplement en ajoutant ''o'' (ouvert) à cette consonne : ''bo'', ''co'', ''ĉo'', ''ĝo'', ''ho'', ''ĥo'', ''jo'', ''ĵo'', ''ko'', ''lo'', ''mo'', ''no'', ''po'', ''ro'', ''so'', ''ŝo'', ''to'', ''ŭo'', ''vo'', ''zo''. Le ''ŭ'' est surtout employé dans les groupes ''aŭ'' (comme ''s'''aou'''dite'') et ''eŭ'' (comme ''al'''éou'''tien''). Les lettres ''q'', ''w'', ''x'' et ''y'' ne sont pas utilisées en espéranto, sauf dans les expressions mathématiques. Dans ce cas, leurs noms se prononcent : *Q - ''kuo'' *W - ''duobla vo'' *X - ''ikso'' *Y - ''ipsilono'' Les sons nasonnés français ''am-em-im-om-um'' et ''an-en-in-on-un'' n'existent pas en espéranto et se prononcent comme ''ame-éme-ime-ome-oume'' et ''ane-éne-ine-one-oune''. En espéranto, le groupe de lettres '''sc''' se prononce '''s-ts'''. }} == Principales terminaisons non verbales et verbales == '''Les 4 principales désinences''' ou finales détachables sont celles des mots lexicaux qui sont de très loin les plus nombreux. L'espéranto est la seule langue où la dérivation des 4 catégories de mots lexicaux est régulière, ce qui diminue énormément le temps de mémorisation nécessaire. Les substantifs se terminent en '''-o'''. Les adjectifs se terminent en '''-a'''. Les adverbes dérivés se terminent en '''-e'''. L'infinitif des verbes se termine en '''-i.''' Par ex. le paradigme parol-o, parol-a, parol-e, parol-i, respectivement parole, oral, oralement, parler. '''La conjugaison''' des verbes est régulière et ne comprend que six désinences ou terminaisons détachables, contre plusieurs centaines dans de grandes langues de communication européennes. Il n'y a que trois temps de l'indicatif et trois autres modes ; infinitif, impératif élargi dit volitif et conditionnel. L'existence des pronoms sujet permet de réduire chaque temps à une seule forme, ce qui se produit déjà pour le prétérit anglais. {| class="wikitable altlines1" border="1" ! Temps ! Terminaison ! Exemple |- | Infinitif | '''-i''' | far'''i''' (faire) |- | Présent | '''-as''' | mi far'''as''' (je fais) |- | Passé | '''-is''' | ni far'''is''' (nous faisions/nous fîmes) |- | Futur | '''-os''' | li far'''os''' (il fera) |- | Conditionnel | '''-us''' | vi far'''us''' (vous feriez) |- | Impératif | '''-u''' | Far'''u'''! (Fais !) |- ! colspan=3 | Participes actifs |- ! Temps ! Terminaison ! Exemple |- | Présent | '''-ant-''' | far'''ant'''a : faisant |- | Passé | '''-int-''' | far'''int'''a : ayant fait |- | Futur | '''-ont-''' | far'''ont'''a : sera faisant |- ! colspan=3 | Participes passifs |- ! Temps ! Terminaison ! Exemple |- | Présent | '''-at-''' | far'''at'''a : en train d'être fait |- | Passé | '''-it-''' | far'''it'''a : étant fait |- | Futur | '''-ot-''' | far'''ot'''a : étant à faire |} {{RemarqueIndex| *Le présent utilise la lettre ''a'' ; l'infinitif et le passé utilisent la lettre ''i'' ; le futur utilise la lettre ''o'' ; le conditionnel et l'impératif utilisent la tettre ''u''. *''Esti'' (Être) est le seul verbe auxiliaire. Exemple : ''Mi estas faranta.'' (Je suis en train de faire.) }} == Pronoms personnels et possessifs == {| class="wikitable altlines1" border="1" ! Espéranto ! Français |- | Mi |Je |- | Ci* |Tu |- | Li | Il (pour un être vivant de sexe masculin) |- | Ŝi | Elle (pour un être vivant de sexe féminin) |- | Ĝi | Il/Elle (pour un être vivant de sexe indéterminé ou une chose.) |- | Ni | Nous |- |Vi |Vous |- |Ili |Ils/Elles (Comme ''they'' en anglais.) |- | Oni | On (Uniquement dans le sens d'un ''on'' français indéfini.) |} <nowiki>*</nowiki>Ce pronom est rarement utilisé et est principalement destiné à la traduction à partir de langues à distinction familière/formelle, ou peut être utilisé avec des personnes proches. On utilise généralement "vi" sinon. * Pour les pronoms personnels complément direct, on ajoute la lettre ''-n''. Mi cin amas : je t'aime * Pour les adjectifs possessifs, on ajoute la lettre ''-a''. Ex : mia libro : mon livre * Pour les pronoms possessifs, on fait précéder l'adjectif possessif de l'article défini. Ex : la mia : le mien. == Pluriel == Pour indiquer le pluriel des substantifs, adjectifs et pronoms possessifs, on ajoute '''-j''' au singulier. Exemple : ''ĉevalo'' ''blanka'' (un cheval blanc) devient ''ĉevaloj'' ''blankaj'' (des chevaux blancs) == Accusatif == L'accusatif s'indique en mettant la désinence '''-n''' aux substantifs, adjectifs, pronoms possessifs Ex: ''bela rozo'' (une belle rose); ''mi vidas belan rozon'' (je vois une belle rose) == Article défini == Il n'existe qu'un seul article défini invariable : '''la'''. Il n'existe ni article indéfini ni article partitif. Exemples : * ''la domo'' (la maison) et ''la domoj'' (les maisons) ; domo (une maison) et domoj (des maisons) * ''la kuko'' (le gâteau) et ''la kukoj'' (les gâteaux). == Vocabulaire == *aimer = ami *maison = domo *ami = amiko *légume = legomo *congrès = kongreso *Salut ! = Saluton! *Au revoir ! = Ĝis revido!/Ĝis! *le père = la patro *la mère = la patrino *les parents = la gepatroj == Voir aussi == {{autres projets | v=Département:Espéranto}} * [http://www.kursosaluton.org/ Kurso Saluton!] - Cours audiovisuel international. [[Catégorie:Langues construites]] [[Catégorie:Livres en cours de rédaction]] [[Catégorie:Alphabets]] msczccuqacd9ji8hfrjtm5x7maooi2h Japonais/Méthode/Hiragana 0 28339 771172 683275 2026-08-22T07:35:40Z Filipvansnaeskerke 8657 en utilisation => en usage 771172 wikitext text/x-wiki {{Niveau débutant}} L'apprentissage du syllabaire Hiragana est un passage essentiel dans l'apprentissage du Japonais. Il est donc recommandé de suivre ce cours attentivement, dès le début de votre apprentissage. Néanmoins, une connaissance des Hiragana n'est pas nécessaire dans toutes les leçons proposées dans ce Wikilivre. En effet, une note présente dans le cours vous montrera quelles leçons nécessitent une connaissance des hiraganas. Prendre une feuille de papier et écrire les hiraganas en les prononçant à voix haute est une excellente façon de les apprendre en un temps minimal. Des feuilles quadrillées vous aideront à garder vos formes correctes et constantes. Les leçons sont d'une durée approximative de 30 minutes. Il est recommandé de les suivre dans l'ordre, étant donné qu'elles se basent sur les acquis des leçons précédentes. == Maîtriser le Hiragana == * [[Japonais/Méthode/Hiragana/Introduction|Introduction]] * [[Japonais/Hiragana/Leçon_1|Leçon 1]] : 15 hiraganas. * [[Japonais/Hiragana/Leçon_2|Leçon 2]] : 15 hiraganas. * [[Japonais/Hiragana/Leçon_3|Leçon 3]] : 16 hiraganas. * [[Japonais/Hiragana/Leçon_4|Leçon 4]] : Le signe de voisement. * [[Japonais/Hiragana/Leçon_5|Leçon 5]] : Le signe de dévoisement. * [[Japonais/Hiragana/Leçon_6|Leçon 6]] : Les doubles consonnes. * [[Japonais/Hiragana/Leçon_7|Leçon 7]] : Les voyelles longues. * [[Japonais/Hiragana/Leçon_8|Leçon 8]] : Les semi-voyelles (1/2). * [[Japonais/Hiragana/Leçon_9|Leçon 9]] : Les semi-voyelles (2/2). == Conclusion == Vous devriez maintenant pouvoir lire et écrire les différents hiragana. Afin de vous entrainer, voici un petit poème (Iroha-uta "Le Chant des couleurs", fin du 10e siècle) utilisant chacun des caractères du syllabaire. {{furigana|いろはにほへと|i ro ha ni ho he to}} {{furigana|ちりぬるを|chi ri nu ru wo}} {{furigana|わかよたれそ|wa ka yo ta re so}} {{furigana|つねならむ|tsu ne na ra mu}} {{furigana|うゐのおくやま|u wi no o ku ya ma}} {{furigana|けふこえて|ke fu ko e te}} {{furigana|あさきゆめみし|a sa ki yu me mi shi}} {{furigana|ゑひもせす|we hi mo se su}} ''Le plaisir est enivrant, mais s'évanouit. Ici-bas, personne ne demeure. Aujourd'hui franchissant les cimes de l'illusion, Il n'est plus ni de rêves creux, ni d'ivresse.'' Veuillez noter que cet exercice utilise les hiragana ゑ (we) et ゐ (wi) qui ne sont plus en usage. Il n'est donc pas nécessaire de savoir les reconnaitre. L'hiragana ん est le seul caractère du syllabaire qui n'est pas présent dans ce poème. La raison est que ce caractère a été introduit bien après que ce poème a été écrit. Néanmoins, ん peut être ajouté à la fin du poème pour l'apprentissage de la calligraphie et/ou du syllabaire. [[Catégorie:Japonais (livre)|Méthode]] krt5cciz7gxj6v73qd5dqgne9cnz6lw Fonctionnement d'un ordinateur/L'abstraction mémoire et la mémoire virtuelle 0 65813 771162 765331 2026-08-22T01:05:43Z Mewtow 31375 771162 wikitext text/x-wiki Pour introduire ce chapitre, nous devons faire un rappel sur le concept d{{'}}'''espace d'adressage'''. Pour rappel, un espace d'adressage correspond à l'ensemble des adresses utilisables par le processeur. Par exemple, si je prends un processeur 16 bits, il peut adresser en tout 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. Intuitivement, on s'attend à ce qu'il y ait correspondance avec les adresses de la mémoire RAM. J'entends par là que l'adresse 1209 de l'espace d'adressage correspond à l'adresse 1209 en mémoire RAM. C'est là une hypothèse parfaitement raisonnable et on voit mal comment ce pourrait ne pas être le cas. Mais les processeurs modernes utilisent des techniques d{{'}}'''abstraction mémoire''' qui font que ce n'est pas le cas. Avec ces techniques, l'adresse 1209 de l'espace d'adressage correspond en réalité à l'adresse 9999 en mémoire RAM, voire n'est pas en RAM. L'abstraction mémoire fait que l'espace d'adressage regroupe des adresses fictives, qui doivent être traduites en adresses mémoires réelles pour être utilisées. Les adresses de l'espace d'adressage portent le nom d{{'}}'''adresses logiques''', alors que les adresses de la mémoire RAM sont appelées '''adresses physiques'''. L'intérêt de l'abstraction matérielle n'est pas évident. Aussi, avant de parler de comment l'abstraction mémoire fonctionne, nous allons voir à quoi elle sert. Nous allons voir qu'elle a plusieurs utilisations différentes, qui sont absolument nécessaires sur tous les ordinateurs personnels modernes. Tous les processeurs modernes la prennent en charge, les systèmes d'exploitation coopérent avec le processeur pour l'utiliser au mieux. ==L'abstraction mémoire implémente plusieurs fonctionnalités complémentaires== La fonctionnalité la plus connue est la mémoire virtuelle, dont vous avez peut-être déjà entendu parler, peut-être que le nom vous dit quelque chose. Si ce n'est pas le cas, nous la détailleront dans la suite. Elle permet concrétement à un programme d'utiliser plus de mémoire qu'il n'y a de RAM installé dans un ordinateur, en utilisant le disque dur comme solution de secours. Mais d'autres fonctionnalités moins évidentes sont permises par l'abstraction mémoire. Et la première que nous allons voir est l'abstraction des processus. : En général, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle. ===L'abstraction matérielle des processus=== Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres. Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente. Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y. Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types : * La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique. * La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique. ===Le partage de la mémoire=== L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile. Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon. Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM. [[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]] D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées. Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité. Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire). [[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]] L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux. Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique. ===La mémoire virtuelle=== Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème. Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat. Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur. Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM. [[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]] Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation. Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel. L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''. On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur. Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée. ===L'extension d'adressage=== Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses. Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente. Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter. [[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]] Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage. Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas. ===La protection mémoire=== La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses. La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran. La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées. D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre. ==La MMU== La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé appelé la '''''Memory Management Unit''''' (MMU), qui est souvent intégré directement dans l'interface mémoire. La MMU est souvent associée à une ou plusieurs mémoires caches, qui visent à accélérer la traduction d'adresses logiques en adresses physiques. En effet, nous verrons plus bas que la traduction d'adresse demande d'accéder à des tableaux, gérés par le système d'exploitation, qui sont en mémoire RAM. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu. [[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]] ===Les MMU intégrées au processeur=== D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente. Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation. [[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]] ===Les MMU séparées du processeur, sur la carte mère=== Il a existé des processeurs avec une MMU externe, soudée sur la carte mère. Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451. Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits. Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur. ==La relocation matérielle== Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous. [[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]] Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations. ===La relocation avec la relocation matérielle : le registre de base=== Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme. [[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]] La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation. [[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]] Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage. [[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]] Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable. ===La protection mémoire avec la relocation matérielle : le registre limite=== Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire. Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, qu'il ne déborde pas en-dehors. C'est possible qu'une adresse calculée sorte du segment, à la suite d'un bug ou d'une erreur de programmation, voire pire. Et le processeur doit éviter de tels '''débordements de segments'''. A chaque accès mémoire, le processeur compare l'adresse accédée et vérifie qu'elle est bien dans le segment. Pour cela, il y a deux solutions. La première part du principe que le segment est placé en mémoire entre l'adresse de base et l'adresse limite. Il suffit de mémoriser l'adresse limite, l'adresse physique à ne pas dépasser. Une autre solution mémorise la taille du segment. La table des segments doit donc mémoriser, en plus de l'adresse de base : soit l'adresse maximale du segment, soit la taille du segment. D'autres informations peuvent être ajoutées, comme on le verra plus tard, mais cela complexifie la table des segments. De plus, le processeur se voit ajouter un '''registre limite''', qui mémorise soit la taille du segment, soit l'adresse limite. Les deux registres, base et limite, sont utilisés pour vérifier si un programme qui lit/écrit de la mémoire en-dehors de son segment attitré : au-delà pour le registre limite, en-deça pour le registre de base. Le processeur vérifie pour chaque accès mémoire ne déborde pas au-delà du segment qui lui est allouée, ce qui n'arrive que si l'adresse d'accès dépasse la valeur du registre limite. Pour les accès en-dessous du segment, il suffit de vérifier si l'addition de relocation déborde, tout débordement signifiant erreur de protection mémoire. [[File:Registre limite.png|centre|vignette|upright=2|Registre limite]] Utiliser la taille du segment a de nombreux avantages. L'un d'entre eux se manifeste quand on déplace un segment en mémoire RAM. Le descripteur doit alors être mis à jour, et c'est plus facile quand on utilise la taille du segment. Si on utilise l'adresse limite, il faut mettre à jour à la fois l'adresse de base et l'adresse limite, dans le descripteur. En utilisant la taille, seule l'adresse de base doit être modifiée, vu que le segment n'a pas changé de taille. Un autre avantage est lié aux performances, mais nous devons faire un détour pour le comprendre. La taille du segment est équivalent à l'adresse logique maximale possible. Par exemple, si un segment fait 256 octets, les adresses logiques possibles vont de 0 à 255, 256 est donc à la fois la taille du segment et l'adresse logique à partir de laquelle on déborde du segment. Et cela marche si on remplace 256 par n'importe quelle valeur : vu que le segment commence à l'adresse 0, sa taille en octets indique l'adresse de dépassement. Interpréter la taille du segment comme une adresse logique fait que les tests avec le registre limite sont plus performants, voyons pourquoi. En utilisant l'adresse physique limite, on doit faire la relocation, puis comparer l'adresse calculée avec l'adresse limite. Le calcul d'adresse doit se faire avant la vérification. En utilisant la taille, on doit comparer l'adresse logique avec la taille du segment. On peut alors faire le test de débordement avant ou pendant la relocation. Les deux peuvent être faits en parallèle, dans deux circuits distincts, ce qui améliore un peu le temps d'un accès mémoire. Quelques processeurs en ont profité, mais on verra cela dans la section sur la segmentation. [[File:Comparaison entre adresse limite physique et logique.png|centre|vignette|upright=2|Comparaison entre adresse limite physique et logique]] Les registres de base et limite sont altérés uniquement par le système d'exploitation et ne sont accessibles qu'en espace noyau. Lorsque le système d'exploitation charge un programme, ou reprend son exécution, il charge les adresses de début/fin du segment dans ces registres. D'ailleurs, ces deux registres doivent être sauvegardés et restaurés lors de chaque interruption. Par contre, et c'est assez évident, ils ne le sont pas lors d'un appel de fonction. Cela fait une différence de plus entre interruption et appels de fonctions. : Il faut noter que le registre limite et le registre de base sont parfois fusionnés en un seul registre, qui contient un descripteur de segment tout entier. Pour information, la relocation matérielle avec un registre limite a été implémentée sur plusieurs processeurs assez anciens, notamment sur les anciens supercalculateurs de marque CDC. Un exemple est le fameux CDC 6600, qui implémentait cette technique. ===La mémoire virtuelle avec la relocation matérielle=== Il est possible d'implémenter la mémoire virtuelle avec la relocation matérielle. Pour cela, il faut swapper des segments entiers sur le disque dur. Les segments sont placés en mémoire RAM et leur taille évolue au fur et à mesure que les programmes demandent du rab de mémoire RAM. Lorsque la mémoire est pleine, ou qu'un programme demande plus de mémoire que disponible, des segments entiers sont sauvegardés dans le ''swapfile'', pour faire de la place. Faire ainsi de demande juste de mémoriser si un segment est en mémoire RAM ou non, ainsi que la position des segments swappés dans le ''swapfile''. Pour cela, il faut modifier la table des segments, afin d'ajouter un '''bit de swap''' qui précise si le segment en question est swappé ou non. Lorsque le système d'exploitation veut swapper un segment, il le copie dans le ''swapfile'' et met ce bit à 1. Lorsque l'OS recharge ce segment en RAM, il remet ce bit à 0. La gestion de la position des segments dans le ''swapfile'' est le fait d'une structure de données séparée de la table des segments. L'OS exécute chaque programme l'un après l'autre, à tour de rôle. Lorsque le tour d'un programme arrive, il consulte la table des segments pour récupérer les adresses de base et limite, mais il vérifie aussi le bit de swap. Si le bit de swap est à 0, alors l'OS se contente de charger les adresses de base et limite dans les registres adéquats. Mais sinon, il démarre une routine d'interruption qui charge le segment voulu en RAM, depuis le ''swapfile''. C'est seulement une fois le segment chargé que l'on connait son adresse de base/limite et que le chargement des registres de relocation peut se faire. Un défaut évident de cette méthode est que l'on swappe des programmes entiers, qui sont généralement assez imposants. Les segments font généralement plusieurs centaines de mébioctets, pour ne pas dire plusieurs gibioctets, à l'époque actuelle. Ils étaient plus petits dans l'ancien temps, mais la mémoire était alors plus lente. Toujours est-il que la copie sur le disque dur des segments est donc longue, lente, et pas vraiment compatible avec le fait que les programmes s'exécutent à tour de rôle. Et ca explique pourquoi la relocation matérielle n'est presque jamais utilisée avec de la mémoire virtuelle. ===L'extension d'adressage avec la relocation matérielle=== Passons maintenant à la dernière fonctionnalité implémentable avec la traduction d'adresse : l'extension d'adressage. Elle permet d'utiliser plus de mémoire que ne le permet l'espace d'adressage. Par exemple, utiliser plus de 64 kibioctets de mémoire sur un processeur 16 bits. Pour cela, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. L'extension des adresses se fait assez simplement avec la relocation matérielle : il suffit que le registre de base soit plus long. Prenons l'exemple d'un processeur aux adresses de 16 bits, mais qui est reliée à un bus d'adresse de 24 bits. L'espace d'adressage fait juste 64 kibioctets, mais le bus d'adresse gère 16 mébioctets de RAM. On peut utiliser les 16 mébioctets de RAM à une condition : que le registre de base fasse 24 bits, pas 16. Un défaut de cette approche est qu'un programme ne peut pas utiliser plus de mémoire que ce que permet l'espace d'adressage. Mais par contre, on peut placer chaque programme dans des portions différentes de mémoire. Imaginons par exemple que l'on ait un processeur 16 bits, mais un bus d'adresse de 20 bits. Il est alors possible de découper la mémoire en 16 blocs de 64 kibioctets, chacun attribué à un segment/programme, qu'on sélectionne avec les 4 bits de poids fort de l'adresse. Il suffit de faire démarrer les segments au bon endroit en RAM, et cela demande juste que le registre de base le permette. C'est une sorte d'émulation de la commutation de banques. ==La segmentation en mode réel des processeurs x86== Avant de passer à la suite, nous allons voir la technique de segmentation de l'Intel 8086, un des tout premiers processeurs 16 bits. Il s'agissait d'une forme très simple de segmentation, sans aucune forme de protection mémoire, ni même de mémoire virtuelle, ce qui le place à part des autres formes de segmentation. Il s'agit d'une amélioration de la relocation matérielle, qui avait pour but de permettre d'utiliser plus de 64 kibioctets de mémoire, ce qui était la limite maximale sur les processeurs 16 bits de l'époque. Par la suite, la segmentation s'améliora et ajouta un support complet de la mémoire virtuelle et de la protection mémoire. L'ancienne forme de segmentation fut alors appelé le '''mode réel''', et la nouvelle forme de segmentation fut appelée le '''mode protégé'''. Le mode protégé rajoute la protection mémoire, en ajoutant des registres limite et une gestion des droits d'accès aux segments, absents en mode réel. De plus, il ajoute un support de la mémoire virtuelle grâce à l'utilisation d'une des segments digne de ce nom, table qui est absente en mode réel ! Pour le moment, voyons le mode réel. ===Les segments en mode réel=== [[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]] La segmentation en mode réel sépare la pile, le tas, le code machine et les données constantes dans quatre segments distincts. * Le segment '''''text''''', qui contient le code machine du programme, de taille fixe. * Le segment '''''data''''' contient des données de taille fixe qui occupent de la mémoire de façon permanente, des constantes, des variables globales, etc. * Le segment pour la '''pile''', de taille variable. * le reste est appelé le '''tas''', de taille variable. Un point important est que sur ces processeurs, il n'y a pas de table des segments proprement dit. Chaque programme gére de lui-même les adresses de base des segments qu'il manipule. Il n'est en rien aidé par une table des segments gérée par le système d'exploitation. ===Les registres de segments en mode réel=== Chaque segment subit la relocation indépendamment des autres. Pour cela, le processeur intégre plusieurs registres de base, un par segment. Notons que cette solution ne marche que si le nombre de segments par programme est limité, à une dizaine de segments tout au plus. Les processeurs x86 utilisaient cette méthode, et n'associaient que 4 à 6 registres de segments par programme. Les processeurs 8086 et le 286 avaient quatre registres de segment : un pour le code, un autre pour les données, et un pour la pile, le quatrième étant un registre facultatif laissé à l'appréciation du programmeur. Ils sont nommés CS (''code segment''), DS (''data segment''), SS (''Stack segment''), et ES (''Extra segment''). Le 386 rajouta deux registres, les registres FS et GS, qui sont utilisés pour les segments de données. Les processeurs post-386 ont donc 6 registres de segment. Les registres CS et SS sont adressés implicitement, en fonction de l'instruction exécutée. Les instructions de la pile manipulent le segment associé à la pile, le chargement des instructions se fait dans le segment de code, les instructions arithmétiques et logiques vont chercher leurs opérandes sur le tas, etc. Et donc, toutes les instructions sont chargées depuis le segment pointé par CS, les instructions de gestion de la pile (PUSH et POP) utilisent le segment pointé par SS. Les segments DS et ES sont, eux aussi, adressés implicitement. Pour cela, les instructions LOAD/STORE sont dupliquées : il y a une instruction LOAD pour le segment DS, une autre pour le segment ES. D'autres instructions lisent leurs opérandes dans un segment par défaut, mais on peut changer ce choix par défaut en précisant le segment voulu. Un exemple est celui de l'instruction CMPSB, qui compare deux octets/bytes : le premier est chargé depuis le segment DS, le second depuis le segment ES. Un autre exemple est celui de l'instruction MOV avec un opérande en mémoire. Elle lit l'opérande en mémoire depuis le segment DS par défaut. Il est possible de préciser le segment de destination si celui-ci n'est pas DS. Par exemple, l'instruction MOV [A], AX écrit le contenu du registre AX dans l'adresse A du segment DS. Par contre, l'instruction MOV ES:[A], copie le contenu du registre AX das l'adresse A, mais dans le segment ES. ===La traduction d'adresse en mode réel=== La segmentation en mode réel a pour seul but de permettre à un programme de dépasser la limite des 64 KB autorisée par les adresses de 16 bits. L'idée est que chaque segment a droit à son propre espace de 64 KB. On a ainsi 64 Kb pour le code machine, 64 KB pour la pile, 64 KB pour un segment de données, etc. Les registres de segment mémorisaient la base du segment, les adresses calculées par l'ALU étant des ''offsets''. Ce sont tous des registres de 16 bits, mais ils ne mémorisent pas des adresses physiques de 16 bits, comme nous allons le voir. [[File:Table des segments dans un banc de registres.png|centre|vignette|upright=2|Table des segments dans un banc de registres.]] L'Intel 8086 utilisait des adresses de 20 bits, ce qui permet d'adresser 1 mébioctet de RAM. Vous pouvez vous demander comment on peut obtenir des adresses de 20 bits alors que les registres de segments font tous 16 bits ? Cela tient à la manière dont sont calculées les adresses physiques. Le registre de segment n'est pas additionné tel quel avec le décalage : à la place, le registre de segment est décalé de 4 rangs vers la gauche. Le décalage de 4 rangs vers la gauche fait que chaque segment a une adresse qui est multiple de 16. Le fait que le décalage soit de 16 bits fait que les segments ont une taille de 64 kibioctets. {|class="wikitable" |- | <code>&nbsp; </code><code style="background:#DED">0000 0110 1110 1111</code><code>0000</code> | Registre de segment - | 16 bits, décalé de 4 bits vers la gauche |- | <code>+ &nbsp;&nbsp;&nbsp;&nbsp; </code><code style="background:#DDF">0001 0010 0011 0100</code> | Décalage/''Offset'' | 16 bits |- | colspan="3" | |- | <code>&nbsp; </code><code style="background:#FDF">0000 1000 0001 0010 0100</code> | Adresse finale | 20 bits |} Vous aurez peut-être remarqué que le calcul peut déborder, dépasser 20 bits. Mais nous reviendrons là-dessus plus bas. L'essentiel est que la MMU pour la segmentation en mode réel se résume à quelques registres et des additionneurs/soustracteurs. Un exemple est l'Intel 8086, un des tout premier processeur Intel. Le processeur était découpé en deux portions : l'interface mémoire et le reste du processeur. L'interface mémoire est appelée la '''''Bus Interface Unit''''', et le reste du processeur est appelé l{{'}}'''''Execution Unit'''''. L'interface mémoire contenait les registres de segment, au nombre de 4, ainsi qu'un additionneur utilisé pour traduire les adresses logiques en adresses physiques. Elle contenait aussi une file d'attente où étaient préchargées les instructions. Sur le 8086, la MMU est fusionnée avec les circuits de gestion du ''program counter''. Les registres de segment sont regroupés avec le ''program counter'' dans un même banc de registres, et un additionneur unique est utilisé à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. L'additionneur est donc mutualisé entre segmentation et ''program counter''. En somme, il n'y a pas vraiment de MMU dédiée, mais un super-circuit en charge du Fetch et de la mémoire virtuelle, ainsi que du préchargement des instructions. Nous en reparlerons au chapitre suivant. [[File:80186 arch.png|centre|vignette|upright=2.5|Architecture du 8086, du 80186 et de ses variantes.]] La MMU du 286 était fusionnée avec l'unité de calcul d'adresse. Elle contient les registres de segments, un comparateur pour détecter les accès hors-segment, et plusieurs additionneurs. Il y a un additionneur pour les calculs d'adresse proprement dit, suivi d'un additionneur pour la relocation. [[File:Intel i80286 arch.svg|centre|vignette|upright=3|Intel i80286 arch]] ===La segmentation en mode réel accepte plusieurs segments de code/données=== Les programmes peuvent parfaitement répartir leur code machine dans plusieurs segments de code. La limite de 64 KB par segment est en effet assez limitante, et il n'était pas rare qu'un programme stocke son code dans deux ou trois segments. Il en est de même avec les données, qui peuvent être réparties dans deux ou trois segments séparés. La seule exception est la pile : elle est forcément dans un segment unique et ne peut pas dépasser 64 KB. Pour gérer plusieurs segments de code/donnée, il faut changer de segment à la volée suivant les besoins, en modifiant les registres de segment. Il s'agit de la technique de '''commutation de segment'''. Pour cela, tous les registres de segment, à l'exception de CS, peuvent être altérés par une instruction d'accès mémoire, soit avec une instruction MOV, soit en y copiant le sommet de la pile avec une instruction de dépilage POP. L'absence de sécurité fait que la gestion de ces registres est le fait du programmeur, qui doit redoubler de prudence pour ne pas faire n'importe quoi. Pour le code machine, le répartir dans plusieurs segments posait des problèmes au niveau des branchements. Si la plupart des branchements sautaient vers une instruction dans le même segment, quelques rares branchements sautaient vers du code machine dans un autre segment. Intel avait prévu le coup et disposait de deux instructions de branchement différentes pour ces deux situations : les '''''near jumps''''' et les '''''far jumps'''''. Les premiers sont des branchements normaux, qui précisent juste l'adresse à laquelle brancher, qui correspond à la position de la fonction dans le segment. Les seconds branchent vers une instruction dans un autre segment, et doivent préciser deux choses : l'adresse de base du segment de destination, et la position de la destination dans le segment. Le branchement met à jour le registre CS avec l'adresse de base, avant de faire le branchement. Ces derniers étaient plus lents, car on n'avait pas à changer de segment et mettre à jour l'état du processeur. Il y avait la même pour l'instruction d'appel de fonction, avec deux versions de cette instruction. La première version, le '''''near call''''' est un appel de fonction normal, la fonction appelée est dans le segment en cours. Avec la seconde version, le '''''far call''''', la fonction appelée est dans un segment différent. L'instruction a là aussi besoin de deux opérandes : l'adresse de base du segment de destination, et la position de la fonction dans le segment. Un ''far call'' met à jour le registre CS avec l'adresse de base, ce qui fait que les ''far call'' sont plus lents que les ''near call''. Il existe aussi la même chose, pour les instructions de retour de fonction, avec une instruction de retour de fonction normale et une instruction de retour qui renvoie vers un autre segment, qui sont respectivement appelées '''''near return''''' et '''''far return'''''. Là encore, il faut préciser l'adresse du segment de destination dans le second cas. La même chose est possible pour les segments de données. Sauf que cette fois-ci, ce sont les pointeurs qui sont modifiés. pour rappel, les pointeurs sont, en programmation, des variables qui contiennent des adresses. Lors de la compilation, ces pointeurs sont placés soit dans un registre, soit dans les instructions (adressage absolu), ou autres. Ici, il existe deux types de pointeurs, appelés '''''near pointer''''' et '''''far pointer'''''. Vous l'avez deviné, les premiers sont utilisés pour localiser les données dans le segment en cours d'utilisation, alors que les seconds pointent vers une donnée dans un autre segment. Là encore, la différence est que le premier se contente de donner la position dans le segment, alors que les seconds rajoutent l'adresse de base du segment. Les premiers font 16 bits, alors que les seconds en font 32 : 16 bits pour l'adresse de base et 16 pour l{{'}}''offset''. ===L'occupation de l'espace d'adressage par les segments=== Nous venons de voir qu'un programme pouvait utiliser plus de 4-6 segments, avec la commutation de segment. Mais d'autres programmes faisaient l'inverse, à savoir qu'ils se débrouillaient avec seulement 1 ou 2 segments. Suivant le nombre de segments utilisés, la configuration des registres n'était pas la même. Les configurations possibles sont appelées des ''modèle mémoire'', et il y en a en tout 6. En voici la liste : {| class="wikitable" |- ! Modèle mémoire !! Configuration des segments !! Configuration des registres || Pointeurs utilisés || Branchements utilisés |- | Tiny* || Segment unique pour tout le programme || CS=DS=SS || ''near'' uniquement || ''near'' uniquement |- | Small || Segment de donnée séparé du segment de code, pile dans le segment de données || DS=SS || ''near'' uniquement || ''near'' uniquement |- | Medium || Plusieurs segments de code unique, un seul segment de données || CS, DS et SS sont différents || ''near'' et ''far'' || ''near'' uniquement |- | Compact || Segment de code unique, plusieurs segments de données || CS, DS et SS sont différents || ''near'' uniquement || ''near'' et ''far'' |- | Large || Plusieurs segments de code, plusieurs segments de données || CS, DS et SS sont différents || ''near'' et ''far'' || ''near'' et ''far'' |} Un programme est censé utiliser maximum 4-6 segments de 64 KB, ce qui permet d'adresser maximum 64 * 6 = 384 KB de RAM, soit bien moins que le mébioctet de mémoire théoriquement adressable. Mais ce défaut est en réalité contourné par la commutation de segment, qui permettait d'adresser la totalité de la RAM si besoin. Une second manière de contourner cette limite est que plusieurs processus peuvent s'exécuter sur un seul processeur, si l'OS le permet. Ce n'était pas le cas à l'époque du DOS, qui était un OS mono-programmé, mais c'était en théorie possible. La limite est de 6 segments par programme/processus, en exécuter plusieurs permet d'utiliser toute la mémoire disponible rapidement. [[File:Overlapping realmode segments.svg|vignette|Segments qui se recouvrent en mode réel.]] Vous remarquerez qu'avec des registres de segments de 16 bits, on peut gérer 65536 segments différents, chacun de 64 KB. Et 65 536 segments de 64 kibioctets, ça ne rentre pas dans le mébioctet de mémoire permis avec des adresses de 20 bits. La raison est que plusieurs couples segment+''offset'' pointent vers la même adresse. En tout, chaque adresse peut être adressée par 4096 couples segment+''offset'' différents. L'avantage de cette méthode est que des segments peuvent se recouvrir, à savoir que la fin de l'un se situe dans le début de l'autre, comme illustré ci-contre. Cela permet en théorie de partager de la mémoire entre deux processus. Mais la technique est tout sauf pratique et est donc peu utilisée. Elle demande de placer minutieusement les segments en RAM, et les données à partager dans les segments. En pratique, les programmeurs et OS utilisent des segments qui ne se recouvrent pas et sont disjoints en RAM. Le nombre maximal de segments disjoints se calcule en prenant la taille de la RAM, qu'on divise par la taille d'un segment. Le calcul donne : 1024 kibioctets / 64 kibioctets = 16 segments disjoints. Un autre calcul prend le nombre de segments divisé par le nombre d'adresses aliasées, ce qui donne 65536 / 4096 = 16. Seulement 16 segments, c'est peu. En comptant les segments utilisés par l'OS et ceux utilisés par le programme, la limite est vite atteinte si le programme utilise la commutation de segment. ===Le mode réel sur les 286 et plus : la ligne d'adresse A20=== Pour résumer, le registre de segment contient des adresses de 20 bits, dont les 4 bits de poids faible sont à 0. Et il se voit ajouter un ''offset'' de 16 bits. Intéressons-nous un peu à l'adresse maximale que l'on peut calculer avec ce système. Nous allons l'appeler l{{'}}'''adresse maximale de segmentation'''. Elle vaut : {|class="wikitable" |- | <code>&nbsp; </code><code style="background:#DED">1111 1111 1111 1111</code><code>0000</code> | Registre de segment - | 16 bits, décalé de 4 bits vers la gauche |- | <code>+ &nbsp;&nbsp;&nbsp;&nbsp; </code><code style="background:#DDF">1111 1111 1111 1111</code> | Décalage/''Offset'' | 16 bits |- | colspan="3" | |- | <code>&nbsp; </code><code style="background:#FDF">1 0000 1111 1111 1110 1111</code> | Adresse finale | 20 bits |} Le résultat n'est pas l'adresse maximale codée sur 20 bits, car l'addition déborde. Elle donne un résultat qui dépasse l'adresse maximale permis par les 20 bits, il y a un 21ème bit en plus. De plus, les 20 bits de poids faible ont une valeur bien précise. Ils donnent la différence entre l'adresse maximale permise sur 20 bit, et l'adresse maximale de segmentation. Les bits 1111 1111 1110 1111 traduits en binaire donnent 65 519; auxquels il faut ajouter l'adresse 1 0000 0000 0000 0000. En tout, cela fait 65 520 octets adressables en trop. En clair : on dépasse la limite du mébioctet de 65 520 octets. Le résultat est alors très différent selon que l'on parle des processeurs avant le 286 ou après. Avant le 286, le bus d'adresse faisait exactement 20 bits. Les adresses calculées ne pouvaient pas dépasser 20 bits. L'addition générait donc un débordement d'entier, géré en arithmétique modulaire. En clair, les bits de poids fort au-delà du vingtième sont perdus. Le calcul de l'adresse débordait et retournait au début de la mémoire, sur les 65 520 premiers octets de la mémoire RAM. [[File:IBM PC Memory areas.svg|vignette|IBM PC Memory Map, la ''High memory area'' est en jaune.]] Le 80286 en mode réel gère des adresses de base de 24 bits, soit 4 bits de plus que le 8086. Le résultat est qu'il n'y a pas de débordement. Les bits de poids fort sont conservés, même au-delà du 20ème. En clair, la segmentation permettait de réellement adresser 65 530 octets au-delà de la limite de 1 mébioctet. La portion de mémoire adressable était appelé la '''''High memory area''''', qu'on va abrévier en HMA. {| class="wikitable" |+ Espace d'adressage du 286 |- ! Adresses en héxadécimal !! Zone de mémoire |- | 10 FFF0 à FF FFFF || Mémoire étendue, au-delà du premier mébioctet |- | 10 0000 à 10 FFEF || ''High Memory Area'' |- | 0 à 0F FFFF || Mémoire adressable en mode réel |} En conséquence, les applications peuvent utiliser plus d'un mébioctet de RAM, mais au prix d'une rétrocompatibilité imparfaite. Quelques programmes DOS ne marchaient pus à cause de ça. D'autres fonctionnaient convenablement et pouvaient adresser les 65 520 octets en plus. Pour résoudre ce problème, les carte mères ajoutaient un petit circuit relié au 21ème bit d'adresse, nommé A20 (pas d'erreur, les fils du bus d'adresse sont numérotés à partir de 0). Le circuit en question pouvait mettre à zéro le fil d'adresse, ou au contraire le laisser tranquille. En le forçant à 0, le calcul des adresses déborde comme dans le mode réel des 8086. Mais s'il ne le fait pas, la ''high memory area'' est adressable. Le circuit était une simple porte ET, qui combinait le 21ème bit d'adresse avec un '''signal de commande A20''' provenant d'ailleurs. Le signal de commande A20 était géré par le contrôleur de clavier, qui était soudé à la carte mère. Le contrôleur en question ne gérait pas que le clavier, il pouvait aussi RESET le processeur, alors gérer le signal de commande A20 n'était pas si problématique. Quitte à avoir un microcontrôleur sur la carte mère, autant s'en servir au maximum... La gestion du bus d'adresse étaitdonc gérable au clavier. D'autres carte mères faisaient autrement et préféraient ajouter un interrupteur, pour activer ou non la mise à 0 du 21ème bit d'adresse. : Il faut noter que le signal de commande A20 était mis à 1 en mode protégé, afin que le 21ème bit d'adresse soit activé. Le 386 ajouta deux registres de segment, les registres FS et GS, ainsi que le '''mode ''virtual 8086'''''. Ce dernier permet d’exécuter des programmes en mode réel alors que le système d'exploitation s'exécute en mode protégé. C'est une technique de virtualisation matérielle qui permet d'émuler un 8086 sur un 386. L'avantage est que la compatibilité avec les programmes anciens écrits pour le 8086 est conservée, tout en profitant de la protection mémoire. Tous les processeurs x86 qui ont suivi supportent ce mode virtuel 8086. ==La segmentation avec une table des segments== La '''segmentation avec une table des segments''' est apparue sur des processeurs assez anciens, le tout premier étant le Burrough 5000. Elle a ensuite été utilisée sur les processeurs x86 des PCs, à partir du 286 d'Intel. Elle est aujourd'hui abandonnée sur les jeux d'instruction x86. Tout comme la segmentation en mode réel, la segmentation attribue plusieurs segments par programmes ! Et cela a des répercutions sur la manière dont la traduction d'adresse est effectuée. ===Pourquoi plusieurs segments par programme ?=== L'utilité d'avoir plusieurs segments par programme n'est pas évidente, mais elle le devient quand on se plonge dans le passé. Dans le passé, les programmeurs devaient faire avec une quantité de mémoire limitée et il n'était pas rare que certains programmes utilisent plus de mémoire que disponible sur la machine. Mais les programmeurs concevaient leurs programmes en fonction. [[File:Overlay Programming.svg|vignette|upright=1|Overlay Programming]] L'idée était d'implémenter un système de mémoire virtuelle, mais émulé en logiciel, appelé l{{'}}'''''overlaying'''''. Le programme était découpé en plusieurs morceaux, appelés des ''overlays''. Les ''overlays'' les plus importants étaient en permanence en RAM, mais les autres étaient faisaient un va-et-vient entre RAM et disque dur. Ils étaient chargés en RAM lors de leur utilisation, puis sauvegardés sur le disque dur quand ils étaient inutilisés. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. Le matériel n'intervenait pas, comme c'est le cas avec la mémoire virtuelle. Avec la segmentation, un programme peut utiliser la technique des ''overlays'', mais avec l'aide du matériel. Il suffit de mettre chaque ''overlay'' dans son propre segment, et laisser la segmentation faire. Les segments sont swappés en tout ou rien : on doit swapper tout un segment en entier. L'intérêt est que la gestion du ''swapping'' est grandement facilitée, vu que c'est le système d'exploitation qui s'occupe de swapper les segments sur le disque dur ou de charger des segments en RAM. Pas besoin pour le programmeur de coder quoique ce soit. Par contre, cela demande l'intervention du programmeur, qui doit découper le programme en segments/''overlays'' de lui-même. Sans cela, la segmentation n'est pas très utile. L{{'}}''overlaying'' est une forme de '''segmentation à granularité grossière''', à savoir que le programme est découpé en segments de grande taille. L'usage classique est d'avoir un segment pour la pile, un autre pour le code exécutable, un autre pour le reste. Éventuellement, on peut découper les trois segments précédents en deux ou trois segments, rarement au-delà. Les segments sont alors peu nombreux, guère plus d'une dizaine par programme. D'où le terme de ''granularité grossière''. La '''segmentation à granularité fine''' pousse le concept encore plus loin. Avec elle, il y a idéalement un segment par entité manipulée par le programme, un segment pour chaque structure de donnée et/ou chaque objet. Par exemple, un tableau aura son propre segment, ce qui est idéal pour détecter les accès hors tableau. Pour les listes chainées, chaque élément de la liste aura son propre segment. Et ainsi de suite, chaque variable agrégée (non-primitive), chaque structure de donnée, chaque objet, chaque instance d'une classe, a son propre segment. Diverses fonctionnalités supplémentaires peuvent être ajoutées, ce qui transforme le processeur en véritable processeur orienté objet, mais passons ces détails pour le moment. Vu que les segments correspondent à des objets manipulés par le programme, on peut deviner que leur nombre évolue au cours du temps. En effet, les programmes modernes peuvent demander au système d'exploitation du rab de mémoire pour allouer une nouvelle structure de données. Avec la segmentation à granularité fine, cela demande d'allouer un nouveau segment à chaque nouvelle allocation mémoire, à chaque création d'une nouvelle structure de données ou d'un objet. De plus, les programmes peuvent libérer de la mémoire, en supprimant les structures de données ou objets dont ils n'ont plus besoin. Avec la segmentation à granularité fine, cela revient à détruire le segment alloué pour ces objets/structures de données. Le nombre de segments est donc dynamique, il change au cours de l'exécution du programme. ===Les tables de segments avec la segmentation=== La présence de plusieurs segments par programme a un impact sur la table des segments. Avec la relocation matérielle, elle conte nait un segment par programme. Chaque entrée, chaque ligne de la table des segment, mémorisait l'adresse de base, l'adresse limite, un bit de présence pour la mémoire virtuelle et des autorisations liées à la protection mémoire. Avec la segmentation, les choses sont plus compliquées, car il y a plusieurs segments par programme. Les entrées ne sont pas modifiées, mais elles sont organisées différemment. Avec cette forme de segmentation, la table des segments doit respecter plusieurs contraintes. Premièrement, il y a plusieurs segments par programmes. Deuxièmement, le nombre de segments est variable : certains programmes se contenteront d'un seul segment, d'autres de dizaine, d'autres plusieurs centaines, etc. Il y a typiquement deux manières de faire : soit utiliser une table des segments uniques, utiliser une table des segment par programme. Il est possible d'utiliser une table des segment unique qui mémorise tous les segments de tous les processus, système d'exploitation inclut. On parle alors de '''table des segment globale'''. Mais cette solution n'est pas utilisée avec la segmentation proprement dite. Elle est utilisée sur les architectures à capacité qu'on détaillera vers la fin du chapitre, dans une section dédiée. À la place, la segmentation utilise une table de segment par processus/programme, chacun ayant une '''table des segment locale'''. Dans les faits, les choses sont plus compliquées. Le système d'exploitation doit savoir où se trouvent les tables de segment locale pour chaque programme. Pour cela, il a besoin d'utiliser une table de segment globale, dont chaque entrée pointe non pas vers un segment, mais vers une table de segment locale. Lorsque l'OS effectue une commutation de contexte, il lit la table des segment globale, pour récupérer un pointeur vers celle-ci. Ce pointeur est alors chargé dans un registre du processeur, qui mémorise l'adresse de la table locale, ce qui sert lors des accès mémoire. Une telle organisation fait que les segments d'un processus/programme sont invisibles pour les autres, il y a une certaine forme de sécurité. Un programme ne connait que sa table de segments locale, il n'a pas accès directement à la table des segments globales. Tout accès mémoire se passera à travers la table de segment locale, il ne sait pas où se trouvent les autres tables de segment locales. Les processeurs x86 sont dans ce cas : ils utilisent une table de segment globale couplée à autant de table des segments qu'il y a de processus en cours d'exécution. La table des segments globale s'appelle la '''''Global Descriptor Table''''' et elle peut contenir 8192 segments maximum, ce qui permet le support de 8192 processus différents. Les tables de segments locales sont appelées les '''''Local Descriptor Table''''' et elles font aussi 8192 segments maximum, ce qui fait 8192 segments par programme maximum. Il faut noter que la table de segment globale peut mémoriser des pointeurs vers les routines d'interruption, certaines données partagées (le tampon mémoire pour le clavier) et quelques autres choses, qui n'ont pas leur place dans les tables de segment locales. ===La relocation avec la segmentation=== La table des segments locale mémorise les adresses de base et limite de chaque segment, ainsi que d'autres méta-données. Les informations pour un segment sont regroupés dans un '''descripteur de segment''', qui est codé sur plusieurs octets, et qui regroupe : adresse de base, adresse limite, bit de présence en RAM, méta-données de protection mémoire. La table des segments est un tableau dans lequel les descripteurs de segment sont placés les uns à la suite des autres en mémoire RAM. La table des segments est donc un tableau de segment. Les segments d'un programme sont numérotés, le nombre s'appelant un '''indice de segment''', appelé '''sélecteur de segment''' dans la terminologie Intel. L'indice de segment n'est autre que l'indice du segment dans ce tableau. [[File:Global Descriptor table.png|centre|vignette|upright=2|Table des segments locale.]] Il n'y a pas de registre de segment proprement dit, qui mémoriserait l'adresse de base. À la place, les segments sont adressés de manière indirecte. À la place, les registres de segment mémorisent des sélecteurs de segment. Ils sont utilisés pour lire l'adresse de base/limite dans la table de segment en mémoire RAM. Pour cela, un registre mémorise l'adresse de la table de segment locale, sa position en mémoire RAM. Toute lecture ou écriture se fait en deux temps, en deux accès mémoire, consécutifs. Premièrement, le numéro de segment est utilisé pour adresser la table des segment. La lecture récupère alors un pointeur vers ce segment. Deuxièmement, ce pointeur est utilisé pour faire la lecture ou écriture. Plus précisément, la première lecture récupère un descripteur de segment qui contient l'adresse de base, le pointeur voulu, mais aussi l'adresse limite et d'autres informations. [[File:Segmentation avec table des segments.png|centre|vignette|upright=2|Segmentation avec table des segments]] L'accès à la table des segments se fait automatiquement à chaque accès mémoire. La conséquence est que chaque accès mémoire demande d'en faire deux : un pour lire la table des segments, l'autre pour l'accès lui-même. Il s'agit en quelque sorte d'une forme d'adressage indirect mémoire. Un point important est que si le premier accès ne fait qu'une simple lecture dans un tableau, le second accès implique des calculs d'adresse. En effet, le premier accès récupère l'adresse de base du segment, mais le second accès sélectionne une donnée dans le segment, ce qui demande de calculer son adresse. L'adresse finale se déduit en combinant l'adresse de base avec un décalage (''offset'') qui donne la position de la donnée dans ce segment. L'indice de segment est utilisé pour récupérer l'adresse de base du segment. Une fois cette adresse de base connue, on lui additionne le décalage pour obtenir l'adresse finale. [[File:Table des segments.png|centre|vignette|upright=2|Traduction d'adresse avec une table des segments.]] Pour effectuer automatiquement l'accès à la table des segments, le processeur doit contenir un registre supplémentaire, qui contient l'adresse de la table de segment, afin de la localiser en mémoire RAM. Nous appellerons ce registre le '''pointeur de table'''. Le pointeur de table est combiné avec l'indice de segment pour adresser le descripteur de segment adéquat. [[File:Segment 2.svg|centre|vignette|upright=2|Traduction d'adresse avec une table des segments, ici appelée table globale des de"scripteurs (terminologie des processeurs Intel x86).]] Un point important est que la table des segments n'est pas accessible pour le programme en cours d'exécution. Il ne peut pas lire le contenu de la table des segments, et encore moins la modifier. L'accès se fait seulement de manière indirecte, en faisant usage des indices de segments, mais c'est un adressage indirect. Seul le système d'exploitation peut lire ou écrire la table des segments directement. Plus haut, j'ai dit que tout accès mémoire impliquait deux accès mémoire : un pour charger le descripteur de segment, un autre pour la lecture/écriture proprement dite. Cependant, cela aurait un impact bien trop grand sur les performances. Dans les faits, les processeurs avec segmentations intégraient un '''cache de descripteurs de segments''', pour limiter la casse. Quand un descripteur de segment est lu depuis la RAM, il est copié dans ce cache. Les accès ultérieurs accédent au descripteur dans le cache, pas besoin de passer par la RAM. L'intel 386 avait un cache de ce type. ===La protection mémoire : les accès hors-segments=== Comme avec la relocation matérielle, le processeur détecte les débordements de segment. Pour cela, il compare l'adresse logique accédée avec l'adresse limite, ou compare la taille limite avec le décalage. De nombreux processeurs, comme l'Intel 386, préféraient utiliser la taille du segment, pour une question d'optimisation. En effet, si on compare l'adresse finale avec l'adresse limite, on doit faire la relocation avant de comparer l'adresse relocatée. Mais en utilisant la taille, ce n'est pas le cas : on peut faire la comparaison avant, pendant ou après la relocation. Un détail à prendre en compte est la taille de la donnée accédée. Sans cela, la comparaison serait très simple : on vérifie si ''décalage <= taille du segment'', ou on compare des adresses de la même manière. Mais imaginez qu'on accède à une donnée de 4 octets : il se peut que l'adresse de ces 4 octets rentre dans le segment, mais que quelques octets débordent. Par exemple, les deux premiers octets sont dans le segment, mais pas les deux suivants. La vraie comparaison est alors : ''décalage + 4 octets <= taille du segment''. Mais il est possible de faire le calcul autrement, et quelques processeurs comme l'Intel 386 ne s'en sont pas privé. Il calculait la différence ''taille du segment - décalage'', et vérifiait le résultat. Le processeur gérait des données de 1, 2 et 4 octets, ce qui fait que le résultat devait être entre 0 et 3. Le processeur prenait le résultat de la soustraction, et vérifiait alors que les 30 bits de poids fort valaient bien 0. Il vérifiait aussi que les deux bits de poids faible avaient la bonne valeur. [[File:Vm7.svg|centre|vignette|upright=2|Traduction d'adresse avec vérification des accès hors-segment.]] Une nouveauté fait son apparition avec la segmentation : la '''gestion des droits d'accès'''. Par exemple, il est possible d'interdire d'exécuter le contenu d'un segment, ce qui fournit une protection contre certaines failles de sécurité ou certains virus. Lorsqu'on exécute une opération interdite, le processeur lève une exception matérielle, à charge du système d'exploitation de gérer la situation. Pour cela, chaque segment se voit attribuer un certain nombre d'autorisations d'accès qui indiquent si l'on peut lire ou écrire dedans, si celui-ci contient un programme exécutable, etc. Les autorisations pour chaque segment sont placées dans le descripteur de segment. Elles se résument généralement à quelques bits, qui indiquent si le segment est accesible en lecture/écriture ou exécutable. Le tout est souvent concaténé dans un ou deux '''octets de droits d'accès'''. L'implémentation de la protection mémoire dépend du CPU considéré. Les CPU microcodés peuvent en théorie utiliser le microcode. Lorsqu'une instruction mémoire s'exécute, le microcode effectue trois étapes : lire le descripteur de segment, faire les tests de protection mémoire, exécuter la lecture/écriture ou lever une exception. Létape de test est réalisée avec un ou plusieurs micro-branchements. Par exemple, une écriture va tester le bit R/W du descripteur, qui indique si on peut écrire dans le segment, en utilisant un micro-branchement. Le micro-branchement enverra vers une routine du microcode en cas d'erreur. Les tests de protection mémoire demandent cependant de tester beaucoup de conditions différentes. Par exemple, le CPU Intel 386 testait moins d'une dizaine de conditions pour certaines instructions. Il est cependant possible de faire plusieurs comparaisons en parallèle en rusant un peu. Il suffit de mémoriser les octets de droits d'accès dans un registre interne, de masquer les bits non-pertinents, et de faire une comparaison avec une constante adéquate, qui encode la valeur que doivent avoir ces bits. Une solution alternative utiliser un circuit combinatoire pour faire les tests de protection mémoire. Les tests sont alors faits en parallèles, plutôt qu'un par un par des micro-branchements. Par contre, le cout en matériel est assez important. Il faut ajouter ce circuit combinatoire, ce qui demande pas mal de circuits. ===La mémoire virtuelle avec la segmentation=== La mémoire virtuelle est une fonctionnalité souvent implémentée sur les processeurs qui gèrent la segmentation, alors que les processeurs avec relocation matérielle s'en passaient. Il faut dire que l'implémentation de la mémoire virtuelle est beaucoup plus simple avec la segmentation, comparé à la relocation matérielle. Le remplacement des registres de base par des sélecteurs de segment facilite grandement l'implémentation. Le problème de la mémoire virtuelle est que les segments peuvent être swappés sur le disque dur n'importe quand, sans que le programme soit prévu. Le swapping est réalisé par une interruption de l'OS, qui peut interrompre le programme n'importe quand. Et si un segment est swappé, le registre de base correspondant devient invalide, il point sur une adresse en RAM où le segment était, mais n'est plus. De plus, les segments peuvent être déplacés en mémoire, là encore n'importe quand et d'une manière invisible par le programme, ce qui fait que les registres de base adéquats doivent être modifiés. Si le programme entier est swappé d'un coup, comme avec la relocation matérielle simple, cela ne pose pas de problèmes. Mais dès qu'on utilise plusieurs registres de base par programme, les choses deviennent soudainement plus compliquées. Le problème est qu'il n'y a pas de mécanismes pour choisir et invalider le registre de base adéquat quand un segment est déplacé/swappé. En théorie, on pourrait imaginer des systèmes qui résolvent le problème au niveau de l'OS, mais tous ont des problèmes qui font que l'implémentation est compliquée ou que les performances sont ridicules. L'usage d'une table des segments accédée à chaque accès résout complètement le problème. La table des segments est accédée à chaque accès mémoire, elle sait si le segment est swappé ou non, chaque accès vérifie si le segment est en mémoire et quelle est son adresse de base. On peut changer le segment de place n'importe quand, le prochain accès récupérera des informations à jour dans la table des segments. L'implémentation de la mémoire virtuelle avec la segmentation est simple : il suffit d'ajouter un bit dans les descripteurs de segments, qui indique si le segment est swappé ou non. Tout le reste, la gestion de ce bit, du swap, et tout ce qui est nécessaire, est délégué au système d'exploitation. Lors de chaque accès mémoire, le processeur vérifie ce bit avant de faire la traduction d'adresse, et déclenche une exception matérielle si le bit indique que le segment est swappé. L'exception matérielle est gérée par l'OS. ===Le partage de segments=== Il est possible de partager un segment entre plusieurs applications. Cela peut servir pour partager des données entre deux programmes : un segment de données partagées est alors partagé entre deux programmes. Partager un segment de code est utile pour les bibliothèques partagées : la bibliothèque est placée dans un segment dédié, qui est partagé entre les programmes qui l'utilisent. Partager un segment de code est aussi utile quand plusieurs instances d'une même application sont lancés simultanément : le code n'ayant pas de raison de changer, celui-ci est partagé entre toutes les instances. Mais ce n'est là qu'un exemple. La première solution pour cela est de configurer les tables de segment convenablement. Le même segment peut avoir des droits d'accès différents selon les processus. Les adresses de base/limite sont identiques, mais les tables des segments ont alors des droits d'accès différents. Mais cette méthode de partage des segments a plusieurs défauts. Premièrement, les sélecteurs de segments ne sont pas les mêmes d'un processus à l'autre, pour un même segment. Le segment partagé peut correspondre au segment numéro 80 dans le premier processus, au segment numéro 1092 dans le second processus. Rien n'impose que les sélecteurs de segment soient les mêmes d'un processus à l'autre, pour un segment identique. Deuxièmement, les adresses limite et de base sont dupliquées dans plusieurs tables de segments. En soi, cette redondance est un souci mineur. Mais une autre conséquence est une question de sécurité : que se passe-t-il si jamais un processus a une table des segments corrompue ? Il se peut que pour un segment identique, deux processus n'aient pas la même adresse limite, ce qui peut causer des failles de sécurité. Un processus peut alors subir un débordement de tampon, ou tout autre forme d'attaque. [[File:Vm9.png|centre|vignette|upright=2|Illustration du partage d'un segment entre deux applications.]] Une seconde solution, complémentaire, utilise une table de segment globale, qui mémorise des segments partagés ou accessibles par tous les processus. Les défauts de la méthode précédente disparaissent avec cette technique : un segment est identifié par un sélecteur unique pour tous les processus, il n'y a pas de duplication des descripteurs de segment. Par contre, elle a plusieurs défauts. Le défaut principal est que cette table des segments est accessible par tous les processus, impossible de ne partager ses segments qu'avec certains pas avec les autres. Un autre défaut est que les droits d'accès à un segment partagé sont identiques pour tous les processus. Impossible d'avoir un segment partagé accessible en lecture seule pour un processus, mais accessible en écriture pour un autre. Il est possible de corriger ces défauts, mais nous en parlerons dans la section sur les architectures à capacité. ===L'extension d'adresse avec la segmentation=== L'extension d'adresse est possible avec la segmentation, de la même manière qu'avec la relocation matérielle. Il suffit juste que les adresses de base soient aussi grandes que le bus d'adresse. Mais il y a une différence avec la relocation matérielle : un même programme peut utiliser plus de mémoire qu'il n'y en a dans l'espace d'adressage. La raison est simple : un segment peut prendre tout l'espace d'adressage, et il y a plusieurs segments par programme. Pour donner un exemple, prenons un processeur 16 bits, qui peut adresser 64 kibioctets, associé à une mémoire de 4 mébioctets. Il est possible de placer le code machine dans les premiers 64k de la mémoire, la pile du programme dans les 64k suivants, le tas dans les 64k encore après, et ainsi de suite. Le programme dépasse donc les 64k de mémoire de l'espace d'adressage. Ce genre de chose est impossible avec la relocation, où un programme est limité par l'espace d'adressage. ===Le mode protégé des processeurs x86=== L'Intel 80286, aussi appelé 286, ajouta un mode de segmentation séparé du mode réel, qui ajoute une protection mémoire à la segmentation, ce qui lui vaut le nom de '''mode protégé'''. Dans ce mode, les registres de segment ne contiennent pas des adresses de base, mais des sélecteurs de segments qui sont utilisés pour l'accès à la table des segments en mémoire RAM. Le 286 bootait en mode réel, puis le système d'exploitation devait faire quelques manipulations pour passer en mode protégé. Le 286 était pensé pour être rétrocompatible au maximum avec le 80186. Mais les différences entre le 286 et le 8086 étaient majeures, au point que les applications devaient être réécrites intégralement pour profiter du mode protégé. Un mode de compatibilité permettait cependant aux applications destinées au 8086 de fonctionner, avec même de meilleures performances. Aussi, le mode protégé resta inutilisé sur la plupart des applications exécutées sur le 286. Vint ensuite le processeur 80386, renommé en 386 quelques années plus tard. Sur ce processeur, les modes réel et protégé sont conservés tel quel, à une différence près : toutes les adresses passent à 32 bits, qu'il s'agisse des adresses de base, limite ou des ''offsets''. Le processeur peut donc adresser un grand nombre de segments : 2^32, soit plus de 4 milliards. Les segments grandissent aussi et passent de 64 KB maximum à 4 gibioctets maximum. Mais surtout : le 386 ajouta le support de la pagination en plus de la segmentation. Ces modifications ont été conservées sur les processeurs 32 bits ultérieurs. Les processeurs x86 gèrent deux types de tables des segments : une table locale pour chaque processus, et une table globale partagée entre tous les processus. Il ne peut y avoir qu'une table locale d'active, vu que le processeur ne peut exécuter qu'un seul processus en même temps. Chaque table locale définit 8192 segments, pareil pour la table globale. La table globale est utilisée pour les segments du noyau et la mémoire partagée entre processus. Un défaut est qu'un segment partagé par la table globale est visible par tous les processus, avec les mêmes droits d'accès. Ce qui fait que cette méthode était peu utilisée en pratique. La table globale mémorise aussi des pointeurs vers les tables locales, avec un descripteur de segment par table locale. Sur les processeurs x86 32 bits, un descripteur de segment est organisé comme suit, pour les architectures 32 bits. On y trouve l'adresse de base et la taille limite, ainsi que de nombreux bits de contrôle. Le premier groupe de bits de contrôle est l'octet en bleu à droite. Il contient : * le bit P qui indique que l'entrée contient un descripteur valide, qu'elle n'est pas vide ; * deux bits DPL qui indiquent le niveau de privilège du segment (noyau, utilisateur, les deux intermédiaires spécifiques au x86) ; * un bit S qui précise si le segment est de type système (utiles pour l'OS) ou un segment de code/données. * un champ Type qui contient les bits suivants : ** un bit E qui indique si le segment contient du code exécutable ou non ; ** le bit RW qui indique s'il est en lecture seule ou non ;; ** Un bit A qui indique que le segment a récemment été accédé, information utile pour l'OS; ** un bit DC assez spécifiques. En haut à gauche, en bleu, on trouve deux bits : * Le bit G indique comment interpréter la taille contenue dans le descripteur : 0 si la taille est exprimée en octets, 1 si la taille est un nombre de pages de 4 kibioctets. Ce bit précise si on utilise la segmentation seule, ou combinée avec la pagination. * Le bit DB précise si l'on utilise des segments en mode de compatibilité 16 bits ou des segments 32 bits. [[File:SegmentDescriptor.svg|centre|vignette|upright=3|Segment Descriptor]] Les indices de segment sont appelés des sélecteurs de segment. Ils ont une taille de 16 bits, mais 3 bits sont utilisés pour encoder des méta-données. Le numéro de segment est donc codé sur 13 bits, ce qui permettait de gérer maximum 8192 segments par table de segment (locale ou globale). Les 16 bits sont organisés comme suit : * 13 bits pour le numéro du segment dans la table des segments, l'indice de segment proprement dit ; * un bit qui précise s'il faut accéder à la table des segments globale ou locale ; * deux bits qui indiquent le niveau de privilège de l'accès au segment (les 4 niveaux de protection, dont l'espace noyau et utilisateur). [[File:SegmentSelector.svg|centre|vignette|upright=1.5|Sélecteur de segment 16 bit.]] En tout, l'indice permet de gérer 8192 segments pour la table locale et 8192 segments de la table globale. ====La MMU du 386/486 : cache de segment, protection mémoire==== La MMU du 386 et celle du 486 étaient assez similaires. Elles étaient plus complexes que celle du 186 et du 286. Elle contenait un additionneur pour les calculs d'adresse et un comparateur pour tester si l'accès mémoire déborde d'un segment. Le test de débordement se faisait en parallèle du calcul de l'adresse finale, comme sur le 286. L'additionneur était un additionneur trois-opérandes, qui additionnait l'adresse à lire/écrire, l'adresse de base du segment, et un décalage intégré dans l'instruction. En clair, l'unité de segmentation n'était pas qu'une MMU, elle prenait en charge une partie du calcul d'adresse. L'avantage est que cela permettait de gérer les modes d'adressage "base + décalage" et "base + indice + décalage" très facilement, en utilisant un minimum de circuits. Une conséquence de cette organisation était que l'usage d'un décalage était gratuit. Par contre, dès qu'on utilisait l'adressage "Base + Indice", avec ou sans décalage, l'instruction prenait un cycle de plus à s'exécuter, parce qu'il fallait faire l'addition "Base + Indice" dans l'ALU entière. Le CPU 386 était le premier à implémenter la protection mémoire avec des segments. Pour cela, il intégrait une '''''Protection Test Unit''''', séparée du microcode, qu'on va abrévier en PTU. Précisément, il s'agissait d'un PLA (''Programmable Logic Array''), une sorte d'intermédiaire entre circuit logique fait sur mesure et mémoire ROM, qu'on a déjà abordé dans le chapitre sur les mémoires ROM. Mais cette unité ne faisait pas tout, le microcode était aussi impliqué. La PTU sera détaillée dans la section suivante. Pour améliorer les performances, le 386 et le 486 intégraient un '''cache de descripteurs de segment''', aussi appelé le cache de descripteurs. Lorsqu'un descripteur état chargé pour la première fois, il était copié dans le cache de descripteurs de segment. Les accès mémoire ultérieur lisaient le descripteur de segment depuis ce cache, pas depuis la table des segments en RAM. Le cache de descripteurs gère aussi bien les segments en mode réel qu'en mode protégé. Récupérer l'adresse de base depuis cache se fait un peu différemment en mode réel et protégé, mais le cache gère cela tout seul. Idem pour récupérer la taille/adresse limite. [[File:Microarchitecture du 386, avec focus sur la segmentation.png|centre|vignette|upright=2|Microarchitecture du 386 et du 486, avec focus sur la segmentation.]] En mode réel, la taille des segment est censée être limitée à 64 kibioctets. Mais le processeur ne vérifiait pas si cette limite était dépassée. À la place, il utilisait la limite précisée dans le cache de segments. Pire que ça : le cache de segment n'était pas réinitialisé quand on passe du mode réel au mode protégé, et réciproquement. Et cette propriété a été à l'origine de l''''''unreal mode'''''. Il s'agissait d'un mode réel amélioré, capable d'utiliser des segments de 4 gibioctets et des adresses de 32 bits. Passer en mode ''unreal'' pouvait se faire de deux manières. Il était possible d'altérer le cache de descripteur en utilisant l'instruction non-documentée LOADALL. Elle permettait de charger les descripteurs de segments dans le cache de descripteurs, avec une taille arbitraire. Une autre solution, beaucoup plus complexe sur le 386, demandait d'entrer en mode protégé pour configurer des segments de grande taille, de charger leurs descripteurs dans le cache de descripteur, puis de revenir en mode réel. En mode réel, les descripteurs dans le cache étaient encore disponibles et on pouvait les lire dans le cache. ====L'implémentation de la protection mémoire sur le 386==== La protection mémoire teste la valeur des bits P, S, X, E, R/W. Elle teste aussi les niveaux de privilège, avec deux bits DPL et CPL. En tout, le processeur pouvait tester 148 conditions différentes en parallèle dans la PTU. Cependant, les niveaux de privilèges étaient pré-traités par le microcode. Le microcode vérifiait aussi s'il y avait une erreur en terme d’anneau mémoire, avec par "exemple un segment en mode noyau accédé alors que le CPU est en espace utilisateur. Il fournissait alors un résultat sur deux bits, qui indiquait s'il y avait une erreur ou non, que la PTU utilisait. Mais toutes les conditions n'étaient pas pertinentes à un instant t. Par exemple, il est pertinent de vérifier si le bit R/W était cohérent si l'instruction à exécuter est une écriture. Mais il n'y a pas besoin de tester le bit E qui indique qu'un segment est exécutable ou non, pour une lecture. En tout, le processeur pouvait se retrouver dans 33 situations possibles, chacune demandant de tester un sous-ensemble des 148 conditions. Pour préciser quel sous-ensembles tester, la PTU recevait un code opération, généré par le microcode. Pour faire les tests de protection mémoire, le microcode avait une micro-opération nommée ''protection test operation'', qui envoyait les droits d'accès à la PTU. Lors de l'exécution d'une ''protection test operation'', le PLA recevait un descripteur de segment, lu depuis la mémoire RAM, ainsi qu'un code opération provenant du microcode. {|class="wikitable" |+ Entrée de la ''Protection Test Unit'' |- ! 15 - 14 !! 13 - 12 !! 11 !! 10 !! 9 !! 8 !! 7 !! 6 !! 5-0 |- | P1 , P2 || || P || S || X || E || R/W || A || Code opération |- | Niveaux de privilèges cohérents/erreur || || Segment présent en mémoire ou swappé || S || X || Segment exécutable ou non || Segment accesible en lecture/écriture || Segment récemment accédé || Code opération |} Il fournissait en sortie un bit qui indiquait si une erreur de protection mémoire avait eu lieu ou non. Il fournissait aussi une adresse de 12 bits, utilisée seulement en cas d'erruer. Elle pointait dans le microcode, sur un code levant une exception en cas d'erreur. Enfin, la PTU fournissait 4 bits pouvant être testés par un branchement dans le microcode. L'un d'entre eux demandait de tester s'il y a un accès hors-limite, les autres étaient assez peu reliés à la protection mémoire. Un détail est que le chargement du descripteur de segment est réalisé par une fonction dans le microcode. Elle est appliquée pour toutes les instructions ou situations qui demandent de faire un accès mémoire. Et les tests de protection mémoire sont réalisés dans cette fonction, pas après elle. Vu qu'il s'agit d'une fonction exécutée quelque soit l'instruction, le microcode doit transférer le code opération à cette fonction. Le microcode est pour cela associé à un registre interne, dans lequel le code opération est mémorisé, avant d'appeler la fonction. Le microcode a une micro-opération PTSAV (''Protection Save'') pour mémoriser le code opération dans ce registre. Dans la fonction qui charge le descripteur, une micro-opération PTOVRR (''Protection Override'') lit le code opération dans ce registre, et lance les tests nécessaires. Il faut noter que le PLA était certes plus rapide que de tester les conditions une par une, mais il était assez lent. La PTU mettait environ 3 cycles d'horloges pour rendre son résultat. Le microcode en profitait alors pour exécuter des micro-opérations durant ces 3 cycles d'attente. Par exemple, le microcode pouvait en profiter pour lire l'adresse de base dans le descripteur, si elle n'a pas été chargée avant (les descripteur était chargé en deux fois). Il fallait cependant que les trois micro-opérations soient valides, peu importe qu'il y ait une erreur de protection mémoire ou non. Ou du moins, elles produisaient un résultat qui n'est pas utilisé en cas d'erreur. Si ce n'était pas possible, le microcode ajoutait des NOP pendant ce temps d'attente de 3 cycles. Le bit A du descripteur de segment indique que le segment a récemment été accédé. Il est mis à jour après les tests de protection mémoire, quand ceux-ci indiquent que l'accès mémoire est autorisé. Le bit A est mis à 1 si la PTU l'autorise. Pour cela, la PTU utilise un des 4 bits de sortie mentionnés plus haut : l'un d'entre eux indique que le bit A doit être mis à 1. La mise à jour est ensuite réalisée par le microcode, qui utilise trois micro-opérations pour le mettre à jour. ====Le ''Hardware task switching'' des CPU x86==== Les systèmes d’exploitation modernes peuvent lancer plusieurs logiciels en même temps. Les logiciels sont alors exécutés à tour de rôle. Passer d'un programme à un autre est ce qui s'appelle une commutation de contexte. Lors d'une commutation de contexte, l'état du processeur est sauvegardé, afin que le programme stoppé puisse reprendre là où il était. Il arrivera un moment où le programme stoppé redémarrera et il doit reprendre dans l'état exact où il s'est arrêté. Deuxièmement, le programme à qui c'est le tour restaure son état. Cela lui permet de revenir là où il était avant d'être stoppé. Il y a donc une sauvegarde et une restauration des registres. Divers processeurs incorporent des optimisations matérielles pour rendre la commutation de contexte plus rapide. Ils peuvent sauvegarder et restaurer les registres du processeur automatiquement lors d'une interruption de commutation de contexte. Les registres sont sauvegardés dans des structures de données en mémoire RAM, appelées des '''contextes matériels'''. Sur les processeurs x86, il s'agit de la technique d{{'}}''Hardware Task Switching''. Fait intéressant, le ''Hardware Task Switching'' se base beaucoup sur les segments mémoires. Avec ''Hardware Task Switching'', chaque contexte matériel est mémorisé dans son propre segment mémoire, séparé des autres. Les segments pour les contextes matériels sont appelés des '''''Task State Segment''''' (TSS). Un TSS mémorise tous les registres généraux, le registre d'état, les pointeurs de pile, le ''program counter'' et quelques registres de contrôle du processeur. Par contre, les registres flottants ne sont pas sauvegardés, de même que certaines registres dit SIMD que nous n'avons pas encore abordé. Et c'est un défaut qui fait que le ''Hardware Task Switching'' n'est plus utilisé. Le programme en cours d'exécution connait l'adresse du TSS qui lui est attribué, car elle est mémorisée dans un registre appelé le '''''Task Register'''''. En plus de pointer sur le TSS, ce registre contient aussi les adresses de base et limite du segment en cours. Pour être plus précis, le ''Task Register'' ne mémorise pas vraiment l'adresse du TSS. À la place, elle mémorise le numéro du segment, le numéro du TSS. Le numéro est codé sur 16 bits, ce qui explique que 65 536 segments sont adressables. Les instructions LDR et STR permettent de lire/écrire ce numéro de segment dans le ''Task Register''. Le démarrage d'un programme a lieu automatiquement dans plusieurs circonstances. La première est une instruction de branchement CALL ou JMP adéquate. Le branchement fournit non pas une adresse à laquelle brancher, mais un numéro de segment qui pointe vers un TSS. Cela permet à une routine du système d'exploitation de restaurer les registres et de démarrer le programme en une seule instruction de branchement. Une seconde circonstance est une interruption matérielle ou une exception, mais nous la mettons de côté. Le ''Task Register'' est alors initialisé avec le numéro de segment fournit. S'en suit la procédure suivante : * Le ''Task Register'' est utilisé pour adresser la table des segments, pour récupérer un pointeur vers le TSS associé. * Le pointeur est utilisé pour une seconde lecture, qui adresse le TSS directement. Celle-ci restaure les registres du processeur. En clair, on va lire le ''TSS descriptor'' dans la GDT, puis on l'utilise pour restaurer les registres du processeur. [[File:Hardware Task Switching x86.png|centre|vignette|upright=2|Hardware Task Switching x86]] ===La segmentation sur les processeurs Burrough B5000 et plus=== Le Burrough B5000 est un très vieil ordinateur, commercialisé à partir de l'année 1961. Ses successeurs reprennent globalement la même architecture. C'était une machine à pile, doublé d'une architecture taguée, choses très rare de nos jours. Mais ce qui va nous intéresser dans ce chapitre est que ce processeur incorporait la segmentation, avec cependant une différence de taille : un programme avait accès à un grand nombre de segments. La limite était de 1024 segments par programme ! Il va de soi que des segments plus petits favorise l'implémentation de la mémoire virtuelle, mais complexifie la relocation et le reste, comme nous allons le voir. Le processeur gère deux types de segments : les segments de données et de procédure/fonction. Les premiers mémorisent un bloc de données, dont le contenu est laissé à l'appréciation du programmeur. Les seconds sont des segments qui contiennent chacun une procédure, une fonction. L'usage des segments est donc différent de ce qu'on a sur les processeurs x86, qui n'avaient qu'un segment unique pour l'intégralité du code machine. Un seul segment de code machine x86 est découpé en un grand nombre de segments de code sur les processeurs Burrough. La table des segments contenait 1024 entrées de 48 bits chacune. Fait intéressant, chaque entrée de la table des segments pouvait mémoriser non seulement un descripteur de segment, mais aussi une valeur flottante ou d'autres types de données ! Parler de table des segments est donc quelque peu trompeur, car cette table ne gère pas que des segments, mais aussi des données. La documentation appelaiat cette table la '''''Program Reference Table''''', ou PRT. La raison de ce choix quelque peu bizarre est que les instructions ne gèrent pas d'adresses proprement dit. Tous les accès mémoire à des données en-dehors de la pile passent par la segmentation, ils précisent tous un indice de segment et un ''offset''. Pour éviter d'allouer un segment pour chaque donnée, les concepteurs du processeur ont décidé qu'une entrée pouvait contenir directement la donnée entière à lire/écrire. La PRT supporte trois types de segments/descripteurs : les descripteurs de données, les descripteurs de programme et les descripteurs d'entrées-sorties. Les premiers décrivent des segments de données. Les seconds sont associés aux segments de procédure/fonction et sont utilisés pour les appels de fonction (qui passent, eux aussi, par la segmentation). Le dernier type de descripteurs sert pour les appels systèmes et les communications avec l'OS ou les périphériques. Chaque entrée de la PRT contient un ''tag'', une suite de bit qui indique le type de l'entrée : est-ce qu'elle contient un descripteur de segment, une donnée, autre. Les descripteurs contiennent aussi un ''bit de présence'' qui indique si le segment a été swappé ou non. Car oui, les segments pouvaient être swappés sur ce processeur, ce qui n'est pas étonnant vu que les segments sont plus petits sur cette architecture. Le descripteur contient aussi l'adresse de base du segment ainsi que sa taille, et diverses informations pour le retrouver sur le disque dur s'il est swappé. : L'adresse mémorisée ne faisait que 15 bits, ce qui permettait d'adresse 32 kibi-mots, soit 192 kibioctets de mémoire. Diverses techniques d'extension d'adressage étaient disponibles pour contourner cette limitation. Outre l'usage de l{{'}}''overlay'', le processeur et l'OS géraient aussi des identifiants d'espace d'adressage et en fournissaient plusieurs par processus. Les processeurs Borrough suivants utilisaient des adresses plus grandes, de 20 bits, ce qui tempérait le problème. [[File:B6700Word.jpg|centre|vignette|upright=2|Structure d'un mot mémoire sur le B6700.]] ==Les architectures à capacités== Les architectures à capacité utilisent la segmentation à granularité fine, mais ajoutent des mécanismes de protection mémoire assez particuliers, qui font que les architectures à capacité se démarquent du reste. Les architectures de ce type sont très rares et sont des processeurs assez anciens. Le premier d'entre eux était le Plessey System 250, qui date de 1969. Il fu suivi par le CAP computer, vendu entre les années 70 et 77. En 1978, le System/38 d'IBM a eu un petit succès commercial. En 1980, la Flex machine a aussi été vendue, mais à très peu d'examplaires, comme les autres architectures à capacité. Et enfin, en 1981, l'architecture à capacité la plus connue, l'Intel iAPX 432 a été commercialisée. Depuis, la seule architecture de ce type est en cours de développement. Il s'agit de l'architecture CHERI, dont la mise en projet date de 2014. ===Le partage de la mémoire sur les architectures à capacités=== Le partage de segment est grandement modifié sur les architectures à capacité. Avec la segmentation normale, il y a une table de segment par processus. Les conséquences sont assez nombreuses, mais la principale est que partager un segment entre plusieurs processus est compliqué. Les défauts ont été évoqués plus haut. Les sélecteurs de segments ne sont pas les mêmes d'un processus à l'autre, pour un même segment. De plus, les adresses limite et de base sont dupliquées dans plusieurs tables de segments, et cela peut causer des problèmes de sécurité si une table des segments est modifiée et pas l'autre. Et il y a d'autres problèmes, tout aussi importants. [[File:Partage des segments avec la segmentation.png|centre|vignette|upright=1.5|Partage des segments avec la segmentation]] À l'opposé, les architectures à capacité utilisent une table des segments unique pour tous les processus. La table des segments unique sera appelée dans de ce qui suit la '''table des segments globale''', ou encore la table globale. En conséquence, les adresses de base et limite ne sont présentes qu'en un seul exemplaire par segment, au lieu d'être dupliquées dans autant de processus que nécessaire. De plus, cela garantit que l'indice de segment est le même quel que soit le processus qui l'utilise. Un défaut de cette approche est au niveau des droits d'accès. Avec la segmentation normale, les droits d'accès pour un segment sont censés changer d'un processus à l'autre. Par exemple, tel processus a accès en lecture seule au segment, l'autre seulement en écriture, etc. Mais ici, avec une table des segments uniques, cela ne marche plus : incorporer les droits d'accès dans la table des segments ferait que tous les processus auraient les mêmes droits d'accès au segment. Et il faut trouver une solution. ===Les capacités sont des pointeurs protégés=== Pour éviter cela, les droits d'accès sont combinés avec les sélecteurs de segments. Les sélecteurs des segments sont remplacés par des '''capacités''', des pointeurs particuliers formés en concaténant l'indice de segment avec les droits d'accès à ce segment. Si un programme veut accéder à une adresse, il fournit une capacité de la forme "sélecteur:droits d'accès", et un décalage qui indique la position de l'adresse dans le segment. Il est impossible d'accéder à un segment sans avoir la capacité associée, c'est là une sécurité importante. Un accès mémoire demande que l'on ait la capacité pour sélectionner le bon segment, mais aussi que les droits d'accès en permettent l'accès demandé. Par contre, les capacités peuvent être passées d'un programme à un autre sans problème, les deux programmes pourront accéder à un segment tant qu'ils disposent de la capacité associée. [[File:Comparaison entre capacités et adresses segmentées.png|centre|vignette|upright=2.5|Comparaison entre capacités et adresses segmentées]] Mais cette solution a deux problèmes très liés. Au niveau des sélecteurs de segment, le problème est que les sélecteur ont une portée globale. Avant, l'indice de segment était interne à un programme, un sélecteur ne permettait pas d'accéder au segment d'un autre programme. Sur les architectures à capacité, les sélecteurs ont une portée globale. Si un programme arrive à forger un sélecteur qui pointe vers un segment d'un autre programme, il peut théoriquement y accéder, à condition que les droits d'accès le permettent. Et c'est là qu'intervient le second problème : les droits d'accès ne sont plus protégés par l'espace noyau. Les droits d'accès étaient dans la table de segment, accessible uniquement en espace noyau, ce qui empêchait un processus de les modifier. Avec une capacité, il faut ajouter des mécanismes de protection qui empêchent un programme de modifier les droits d'accès à un segment et de générer un indice de segment non-prévu. La première sécurité est qu'un programme ne peut pas créer une capacité, seul le système d'exploitation le peut. Les capacités sont forgées lors de l'allocation mémoire, ce qui est du ressort de l'OS. Pour rappel, un programme qui veut du rab de mémoire RAM peut demander au système d'exploitation de lui allouer de la mémoire supplémentaire. Le système d'exploitation renvoie alors un pointeurs qui pointe vers un nouveau segment. Le pointeur est une capacité. Il doit être impossible de forger une capacité, en-dehors d'une demande d'allocation mémoire effectuée par l'OS. Typiquement, la forge d'une capacité se fait avec des instructions du processeur, que seul l'OS peut éxecuter (pensez à une instruction qui n'est accessible qu'en espace noyau). La seconde protection est que les capacités ne peuvent pas être modifiées sans raison valable, que ce soit pour l'indice de segment ou les droits d'accès. L'indice de segment ne peut pas être modifié, quelqu'en soit la raison. Pour les droits d'accès, la situation est plus compliquée. Il est possible de modifier ses droits d'accès, mais sous conditions. Réduire les droits d'accès d'une capacité est possible, que ce soit en espace noyau ou utilisateur, pas l'OS ou un programme utilisateur, avec une instruction dédiée. Mais augmenter les droits d'accès, seul l'OS peut le faire avec une instruction précise, souvent exécutable seulement en espace noyau. Les capacités peuvent être copiées, et même transférées d'un processus à un autre. Les capacités peuvent être détruites, ce qui permet de libérer la mémoire utilisée par un segment. La copie d'une capacité est contrôlée par l'OS et ne peut se faire que sous conditions. La destruction d'une capacité est par contre possible par tous les processus. La destruction ne signifie pas que le segment est effacé, il est possible que d'autres processus utilisent encore des copies de la capacité, et donc le segment associé. On verra quand la mémoire est libérée plus bas. Protéger les capacités demande plusieurs conditions. Premièrement, le processeur doit faire la distinction entre une capacité et une donnée. Deuxièmement, les capacités ne peuvent être modifiées que par des instructions spécifiques, dont l'exécution est protégée, réservée au noyau. En clair, il doit y avoir une séparation matérielle des capacités, qui sont placées dans des registres séparés. Pour cela, deux solutions sont possibles : soit les capacités remplacent les adresses et sont dispersées en mémoire, soit elles sont regroupées dans un segment protégé. ====La liste des capacités==== Avec la première solution, on regroupe les capacités dans un segment protégé. Chaque programme a accès à un certain nombre de segments et à autant de capacités. Les capacités d'un programme sont souvent regroupées dans une '''liste de capacités''', appelée la '''''C-list'''''. Elle est généralement placée en mémoire RAM. Elle est ce qu'il reste de la table des segments du processus, sauf que cette table ne contient pas les adresses du segment, qui sont dans la table globale. Tout se passe comme si la table des segments de chaque processus est donc scindée en deux : la table globale partagée entre tous les processus contient les informations sur les limites des segments, la ''C-list'' mémorise les droits d'accès et les sélecteurs pour identifier chaque segment. C'est un niveau d'indirection supplémentaire par rapport à la segmentation usuelle. [[File:Architectures à capacité.png|centre|vignette|upright=2|Architectures à capacité]] La liste de capacité est lisible par le programme, qui peut copier librement les capacités dans les registres. Par contre, la liste des capacités est protégée en écriture. Pour le programme, il est impossible de modifier les capacités dedans, impossible d'en rajouter, d'en forger, d'en retirer. De même, il ne peut pas accéder aux segments des autres programmes : il n'a pas les capacités pour adresser ces segments. Pour protéger la ''C-list'' en écriture, la solution la plus utilisée consiste à placer la ''C-list'' dans un segment dédié. Le processeur gère donc plusieurs types de segments : les segments de capacité pour les ''C-list'', les autres types segments pour le reste. Un défaut de cette approche est que les adresses/capacités sont séparées des données. Or, les programmeurs mixent souvent adresses et données, notamment quand ils doivent manipuler des structures de données comme des listes chainées, des arbres, des graphes, etc. L'usage d'une ''C-list'' permet de se passer de la séparation entre espace noyau et utilisateur ! Les segments de capacité sont eux-mêmes adressés par leur propre capacité, avec une capacité par segment de capacité. Le programme a accès à la liste de capacité, comme l'OS, mais leurs droits d'accès ne sont pas les mêmes. Le programme a une capacité vers la ''C-list'' qui n'autorise pas l'écriture, l'OS a une autre capacité qui accepte l'écriture. Les programmes ne pourront pas forger les capacités permettant de modifier les segments de capacité. Une méthode alternative est de ne permettre l'accès aux segments de capacité qu'en espace noyau, mais elle est redondante avec la méthode précédente et moins puissante. ====Les capacités dispersées, les architectures taguées==== Une solution alternative laisse les capacités dispersées en mémoire. Les capacités remplacent les adresses/pointeurs, et elles se trouvent aux mêmes endroits : sur la pile, dans le tas. Comme c'est le cas dans les programmes modernes, chaque allocation mémoire renvoie une capacité, que le programme gére comme il veut. Il peut les mettre dans des structures de données, les placer sur la pile, dans des variables en mémoire, etc. Mais il faut alors distinguer si un mot mémoire contient une capacité ou une autre donnée, les deux ne devant pas être mixés. Pour cela, chaque mot mémoire se voit attribuer un certain bit qui indique s'il s'agit d'un pointeur/capacité ou d'autre chose. Mais cela demande un support matériel, ce qui fait que le processeur devient ce qu'on appelle une ''architecture à tags'', ou ''tagged architectures''. Ici, elles indiquent si le mot mémoire contient une adresse:capacité ou une donnée. [[File:Architectures à capacité sans liste de capacité.png|centre|vignette|upright=2|Architectures à capacité sans liste de capacité]] L'inconvénient est le cout en matériel de cette solution. Il faut ajouter un bit à chaque case mémoire, le processeur doit vérifier les tags avant chaque opération d'accès mémoire, etc. De plus, tous les mots mémoire ont la même taille, ce qui force les capacités à avoir la même taille qu'un entier. Ce qui est compliqué. ===Les registres de capacité=== Les architectures à capacité disposent de registres spécialisés pour les capacités, séparés pour les entiers. La raison principale est une question de sécurité, mais aussi une solution pragmatique au fait que capacités et entiers n'ont pas la même taille. Les registres dédiés aux capacités ne mémorisent pas toujours des capacités proprement dites. À la place, ils mémorisent des descripteurs de segment, qui contiennent l'adresse de base, limite et les droits d'accès. Ils sont utilisés pour la relocation des accès mémoire ultérieurs. Ils sont en réalité identiques aux registres de relocation, voire aux registres de segments. Leur utilité est d'accélérer la relocation, entre autres. Les processeurs à capacité ne gèrent pas d'adresses proprement dit, comme pour la segmentation avec plusieurs registres de relocation. Les accès mémoire doivent préciser deux choses : à quel segment on veut accéder, à quelle position dans le segment se trouve la donnée accédée. La première information se trouve dans le mal nommé "registre de capacité", la seconde information est fournie par l'instruction d'accès mémoire soit dans un registre (Base+Index), soit en adressage base+''offset''. Les registres de capacités sont accessibles à travers des instructions spécialisées. Le processeur ajoute des instructions LOAD/STORE pour les échanges entre table des segments et registres de capacité. Ces instructions sont disponibles en espace utilisateur, pas seulement en espace noyau. Lors du chargement d'une capacité dans ces registres, le processeur vérifie que la capacité chargée est valide, et que les droits d'accès sont corrects. Puis, il accède à la table des segments, récupère les adresses de base et limite, et les mémorise dans le registre de capacité. Les droits d'accès et d'autres méta-données sont aussi mémorisées dans le registre de capacité. En somme, l'instruction de chargement prend une capacité et charge un descripteur de segment dans le registre. Avec ce genre de mécanismes, il devient difficile d’exécuter certains types d'attaques, ce qui est un gage de sureté de fonctionnement indéniable. Du moins, c'est la théorie, car tout repose sur l'intégrité des listes de capacité. Si on peut modifier celles-ci, alors il devient facile de pouvoir accéder à des objets auxquels on n’aurait pas eu droit. ===Le recyclage de mémoire matériel=== Les architectures à capacité séparent les adresses/capacités des nombres entiers. Et cela facilite grandement l'implémentation de la ''garbage collection'', ou '''recyclage de la mémoire''', à savoir un ensemble de techniques logicielles qui visent à libérer la mémoire inutilisée. Rappelons que les programmes peuvent demander à l'OS un rab de mémoire pour y placer quelque chose, généralement une structure de donnée ou un objet. Mais il arrive un moment où cet objet n'est plus utilisé par le programme. Il peut alors demander à l'OS de libérer la portion de mémoire réservée. Sur les architectures à capacité, cela revient à libérer un segment, devenu inutile. La mémoire utilisée par ce segment est alors considérée comme libre, et peut être utilisée pour autre chose. Mais il arrive que les programmes ne libèrent pas le segment en question. Soit parce que le programmeur a mal codé son programme, soit parce que le compilateur n'a pas fait du bon travail ou pour d'autres raisons. Pour éviter cela, les langages de programmation actuels incorporent des '''''garbage collectors''''', des morceaux de code qui scannent la mémoire et détectent les segments inutiles. Pour cela, ils doivent identifier les adresses manipulées par le programme. Si une adresse pointe vers un objet, alors celui-ci est accessible, il sera potentiellement utilisé dans le futur. Mais si aucune adresse ne pointe vers l'objet, alors il est inaccessible et ne sera plus jamais utilisé dans le futur. On peut libérer les objets inaccessibles. Identifier les adresses est cependant très compliqué sur les architectures normales. Sur les processeurs modernes, les ''garbage collectors'' scannent la pile à la recherche des adresses, et considèrent tout mot mémoire comme une adresse potentielle. Mais les architectures à capacité rendent le recyclage de la mémoire très facile. Un segment est accessible si le programme dispose d'une capacité qui pointe vers ce segment, rien de plus. Et les capacités sont facilement identifiables : soit elles sont dans la liste des capacités, soit on peut les identifier à partir de leur ''tag''. Le recyclage de mémoire était parfois implémenté directement en matériel. En soi, son implémentation est assez simple, et peu être réalisé dans le microcode d'un processeur. Une autre solution consiste à utiliser un second processeur, spécialement dédié au recyclage de mémoire, qui exécute un programme spécialement codé pour. Le programme en question est placé dans une mémoire ROM, reliée directement à ce second processeur. ===L'intel iAPX 432=== Voyons maintenat une architecture à capacité assez connue : l'Intel iAPX 432. Oui, vous avez bien lu : Intel a bel et bien réalisé un processeur orienté objet dans sa jeunesse. La conception du processeur Intel iAPX 432 commença en 1975, afin de créer un successeur digne de ce nom aux processeurs 8008 et 8080. La conception du processeur Intel iAPX 432 commença en 1975, afin de créer un successeur digne de ce nom aux processeurs 8008 et 8080. Ce processeur s'est très faiblement vendu en raison de ses performances assez désastreuses et de défauts techniques certains. Par exemple, ce processeur était une machine à pile à une époque où celles-ci étaient tombées en désuétude, il ne pouvait pas effectuer directement de calculs avec des constantes entières autres que 0 et 1, ses instructions avaient un alignement bizarre (elles étaient bit-alignées). Il avait été conçu pour maximiser la compatibilité avec le langage ADA, un langage assez peu utilisé, sans compter que le compilateur pour ce processeur était mauvais. ====Les segments prédéfinis de l'Intel iAPX 432==== L'Intel iAPX432 gère plusieurs types de segments. Rien d'étonnant à cela, les Burrough géraient eux aussi plusieurs types de segments, à savoir des segments de programmes, des segments de données, et des segments d'I/O. C'est la même chose sur l'Intel iAPX 432, mais en bien pire ! Les segments de données sont des segments génériques, dans lequels on peut mettre ce qu'on veut, suivant les besoins du programmeur. Ils sont tous découpés en deux parties de tailles égales : une partie contenant les données de l'objet et une partie pour les capacités. Les capacités d'un segment pointent vers d'autres segments, ce qui permet de créer des structures de données assez complexes. La ligne de démarcation peut être placée n'importe où dans le segment, les deux portions ne sont pas de taille identique, elles ont des tailles qui varient de segment en segment. Il est même possible de réserver le segment entier à des données sans y mettre de capacités, ou inversement. Les capacités et données sont adressées à partir de la ligne de démarcation, qui sert d'adresse de base du segment. Suivant l'instruction utilisée, le processeur accède à la bonne portion du segment. Le processeur supporte aussi d'autres segments pré-définis, qui sont surtout utilisés par le système d'exploitation : * Des segments d'instructions, qui contiennent du code exécutable, typiquement un programme ou des fonctions, parfois des ''threads''. * Des segments de processus, qui mémorisent des processus entiers. Ces segments contiennent des capacités qui pointent vers d'autres segments, notamment un ou plusieurs segments de code, et des segments de données. * Des segments de domaine, pour les modules ou bibliothèques dynamiques. * Des segments de contexte, utilisés pour mémoriser l'état d'un processus, utilisés par l'OS pour faire de la commutation de contexte. * Des segments de message, utilisés pour la communication entre processus par l'intermédiaire de messages. * Et bien d'autres encores. Sur l'Intel iAPX 432, chaque processus est considéré comme un objet à part entière, qui a son propre segment de processus. De même, l'état du processeur (le programme qu'il est en train d’exécuter, son état, etc.) est stocké en mémoire dans un segment de contexte. Il en est de même pour chaque fonction présente en mémoire : elle était encapsulée dans un segment, sur lequel seules quelques manipulations étaient possibles (l’exécuter, notamment). Et ne parlons pas des appels de fonctions qui stockaient l'état de l'appelé directement dans un objet spécial. Bref, de nombreux objets système sont prédéfinis par le processeur : les objets stockant des fonctions, les objets stockant des processus, etc. L'Intel 432 possédait dans ses circuits un ''garbage collector'' matériel. Pour faciliter son fonctionnement, certains bits de l'objet permettaient de savoir si l'objet en question pouvait être supprimé ou non. ====Le support de la segmentation sur l'Intel iAPX 432==== La table des segments est une table hiérarchique, à deux niveaux. Le premier niveau est une ''Object Table Directory'', qui réside toujours en mémoire RAM. Elle contient des descripteurs qui pointent vers des tables secondaires, appelées des ''Object Table''. Il y a plusieurs ''Object Table'', typiquement une par processus. Plusieurs processus peuvent partager la même ''Object Table''. Les ''Object Table'' peuvent être swappées, mais pas l{{'}}''Object Table Directory''. Une capacité tient compte de l'organisation hiérarchique de la table des segments. Elle contient un indice qui précise quelle ''Object Table'' utiliser, et l'indice du segment dans cette ''Object Table''. Le premier indice adresse l{{'}}''Object Table Directory'' et récupère un descripteur de segment qui pointe sur la bonne ''Object Table''. Le second indice est alors utilisé pour lire l'adresse de base adéquate dans cette ''Object Table''. La capacité contient aussi des droits d'accès en lecture, écriture, suppression et copie. Il y a aussi un champ pour le type, qu'on verra plus bas. Au fait : les capacités étaient appelées des ''Access Descriptors'' dans la documentation officielle. Une capacité fait 32 bits, avec un octet utilisé pour les droits d'accès, laissant 24 bits pour adresser les segments. Le processeur gérait jusqu'à 2^24 segments/objets différents, pouvant mesurer jusqu'à 64 kibioctets chacun, ce qui fait 2^40 adresses différentes, soit 1024 gibioctets. Les 24 bits pour adresser les segments sont partagés moitié-moitié pour l'adressage des tables, ce qui fait 4096 ''Object Table'' différentes dans l{{'}}''Object Table Directory'', et chaque ''Object Table'' contient 4096 segments. ====Le jeu d'instruction de l'Intel iAPX 432==== L'Intel iAPX 432 est une machine à pile. Le jeu d'instruction de l'Intel iAPX 432 gère pas moins de 230 instructions différentes. Il gére deux types d'instructions : les instructions normales, et celles qui manipulent des segments/objets. Les premières permettent de manipuler des nombres entiers, des caractères, des chaînes de caractères, des tableaux, etc. Les secondes sont spécialement dédiées à la manipulation des capacités. Il y a une instruction pour copier une capacité, une autre pour invalider une capacité, une autre pour augmenter ses droits d'accès (instruction sécurisée, exécutable seulement sous certaines conditions), une autre pour restreindre ses droits d'accès. deux autres instructions créent un segment et renvoient la capacité associée, la première créant un segment typé, l'autre non. le processeur gérait aussi des instructions spécialement dédiées à la programmation système et idéales pour programmer des systèmes d'exploitation. De nombreuses instructions permettaient ainsi de commuter des processus, faire des transferts de messages entre processus, etc. Environ 40 % du micro-code était ainsi spécialement dédié à ces instructions spéciales. Les instructions sont de longueur variable et peuvent prendre n'importe quelle taille comprise entre 10 et 300 bits, sans vraiment de restriction de taille. Les bits d'une instruction sont regroupés en 4 grands blocs, 4 champs, qui ont chacun une signification particulière. * Le premier est l'opcode de l'instruction. * Le champ référence, doit être interprété différemment suivant la donnée à manipuler. Si cette donnée est un entier, un caractère ou un flottant, ce champ indique l'emplacement de la donnée en mémoire. Alors que si l'instruction manipule un objet, ce champ spécifie la capacité de l'objet en question. Ce champ est assez complexe et il est sacrément bien organisé. * Le champ format, n'utilise que 4 bits et a pour but de préciser si les données à manipuler sont en mémoire ou sur la pile. * Le champ classe permet de dire combien de données différentes l'instruction va devoir manipuler, et quelles seront leurs tailles. [[File:Encodage des instructions de l'Intel iAPX-432.png|centre|vignette|upright=2|Encodage des instructions de l'Intel iAPX-432.]] ====Le support de l'orienté objet sur l'Intel iAPX 432==== L'Intel 432 permet de définir des objets, qui correspondent aux classes des langages orientés objets. L'Intel 432 permet, à partir de fonctions définies par le programmeur, de créer des '''''domain objects''''', qui correspondent à une classe. Un ''domain object'' est un segment de capacité, dont les capacités pointent vers des fonctions ou un/plusieurs objets. Les fonctions et les objets sont chacun placés dans un segment. Une partie des fonctions/objets sont publics, ce qui signifie qu'ils sont accessibles en lecture par l'extérieur. Les autres sont privées, inaccessibles aussi bien en lecture qu'en écriture. L'exécution d'une fonction demande que le branchement fournisse deux choses : une capacité vers le ''domain object'', et la position de la fonction à exécuter dans le segment. La position permet de localiser la capacité de la fonction à exécuter. En clair, on accède au ''domain object'' d'abord, pour récupérer la capacité qui pointe vers la fonction à exécuter. Il est aussi possible pour le programmeur de définir de nouveaux types non supportés par le processeur, en faisant appel au système d'exploitation de l'ordinateur. Au niveau du processeur, chaque objet est typé au niveau de son object descriptor : celui-ci contient des informations qui permettent de déterminer le type de l'objet. Chaque type se voit attribuer un domain object qui contient toutes les fonctions capables de manipuler les objets de ce type et que l'on appelle le type manager. Lorsque l'on veut manipuler un objet d'un certain type, il suffit d'accéder à une capacité spéciale (le TCO) qui pointera dans ce type manager et qui précisera quel est l'objet à manipuler (en sélectionnant la bonne entrée dans la liste de capacité). Le type d'un objet prédéfini par le processeur est ainsi spécifié par une suite de 8 bits, tandis que le type d'un objet défini par le programmeur est défini par la capacité spéciale pointant vers son type manager. ===Conclusion=== Pour ceux qui veulent en savoir plus, je conseille la lecture de ce livre, disponible gratuitement sur internet (merci à l'auteur pour cette mise à disposition) : * [https://homes.cs.washington.edu/~levy/capabook/ Capability-Based Computer Systems]. Voici un document qui décrit le fonctionnement de l'Intel iAPX432 : * [https://homes.cs.washington.edu/~levy/capabook/Chapter9.pdf The Intel iAPX 432 ] ==La pagination== Avec la pagination, la mémoire est découpée en blocs de taille fixe, appelés des '''pages mémoires'''. La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Mais elles sont de taille fixe : on ne peut pas en changer la taille. C'est la différence avec les segments, qui sont de taille variable. Le contenu d'une page en mémoire fictive est rigoureusement le même que le contenu de la page correspondante en mémoire physique. L'espace d'adressage est découpé en '''pages logiques''', alors que la mémoire physique est découpée en '''pages physique''' de même taille. Les pages logiques correspondent soit à une page physique, soit à une page swappée sur le disque dur. Quand une page logique est associée à une page physique, les deux ont le même contenu, mais pas les mêmes adresses. Les pages logiques sont numérotées, en partant de 0, afin de pouvoir les identifier/sélectionner. Même chose pour les pages physiques, qui sont elles aussi numérotées en partant de 0. [[File:Principe de la pagination.png|centre|vignette|upright=2|Principe de la pagination.]] Pour information, le tout premier processeur avec un système de mémoire virtuelle était le super-ordinateur Atlas. Il utilisait la pagination, et non la segmentation. Mais il fallu du temps avant que la méthode de la pagination prenne son essor dans les processeurs commerciaux x86. Un point important est que la pagination implique une coopération entre OS et hardware, les deux étant fortement mélés. Une partie des informations de cette section auraient tout autant leur place dans le wikilivre sur les systèmes d'exploitation, mais il est plus simple d'en parler ici. ===La mémoire virtuelle : le ''swapping'' et le remplacement des pages mémoires=== Le système d'exploitation mémorise des informations sur toutes les pages existantes dans une '''table des pages'''. C'est un tableau où chaque ligne est associée à une page logique. Une ligne contient un bit ''Valid'' qui indique si la page logique associée est swappée sur le disque dur ou non, et la position de la page physique correspondante en mémoire RAM. Elle peut aussi contenir des bits pour la protection mémoire, et bien d'autres. Les lignes sont aussi appelées des ''entrées de la table des pages'' [[File:Gestionnaire de mémoire virtuelle - Pagination et swapping.png|centre|vignette|upright=2|Table des pages.]] De plus, le système d'exploitation conserve une '''liste des pages vides'''. Le nom est assez clair : c'est une liste de toutes les pages de la mémoire physique qui sont inutilisées, qui ne sont allouées à aucun processus. Ces pages sont de la mémoire libre, utilisable à volonté. La liste des pages vides est mise à jour à chaque fois qu'un programme réserve de la mémoire, des pages sont alors prises dans cette liste et sont allouées au programme demandeur. ====Les défauts de page==== Lorsque l'on veut traduire l'adresse logique d'une page mémoire, le processeur vérifie le bit ''Valid'' et l'adresse physique. Si le bit ''Valid'' est à 1 et que l'adresse physique est présente, la traduction d'adresse s'effectue normalement. Mais si ce n'est pas le cas, l'entrée de la table des pages ne contient pas de quoi faire la traduction d'adresse. Soit parce que la page est swappée sur le disque dur et qu'il faut la copier en RAM, soit parce que les droits d'accès ne le permettent pas, soit parce que la page n'a pas encore été allouée, etc. On fait alors face à un '''défaut de page'''. Un défaut de page a lieu quand la MMU ne peut pas associer l'adresse logique à une adresse physique, quelque qu'en soit la raison. Il existe deux types de défauts de page : mineurs et majeurs. Un '''défaut de page majeur''' a lieu quand on veut accéder à une page déplacée sur le disque dur. Un défaut de page majeur lève une exception matérielle dont la routine rapatriera la page en mémoire RAM. S'il y a de la place en mémoire RAM, il suffit d'allouer une page vide et d'y copier la page chargée depuis le disque dur. Mais si ce n'est par le cas, on va devoir faire de la place en RAM en déplaçant une page mémoire de la RAM vers le disque dur. Dans tous les cas, c'est le système d'exploitation qui s'occupe du chargement de la page, le processeur n'est pas impliqué. Une fois la page chargée, la table des pages est mise à jour et la traduction d'adresse peut recommencer. Si je dis recommencer, c'est car l'accès mémoire initial est rejoué à l'identique, sauf que la traduction d'adresse réussit cette fois-ci. Un '''défaut de page mineur''' a lieu dans des circonstances pas très intuitives : la page est en mémoire physique, mais l'adresse physique de la page n'est pas accessible. Par exemple, il est possible que des sécurités empêchent de faire la traduction d'adresse, pour des raisons de protection mémoire. Une autre raison est la gestion des adresses synonymes, qui surviennent quand on utilise des libraires partagées entre programmes, de la communication inter-processus, des optimisations de type ''copy-on-write'', etc. Enfin, une dernière raison est que la page a été allouée à un programme par le système d'exploitation, mais qu'il n'a pas encore attribué sa position en mémoire. Pour comprendre comment c'est possible, parlons rapidement de l'allocation paresseuse. Imaginons qu'un programme fasse une demande d'allocation mémoire et se voit donc attribuer une ou plusieurs pages logiques. L'OS peut alors réagir de deux manières différentes. La première est d'attribuer une page physique immédiatement, en même temps que la page logique. En faisant ainsi, on ne peut pas avoir de défaut mineur, sauf en cas de problème de protection mémoire. Cette solution est simple, on l'appelle l{{'}}'''allocation immédiate'''. Une autre solution consiste à attribuer une page logique, mais l'allocation de la page physique se fait plus tard. Elle a lieu la première fois que le programme tente d'écrire/lire dans la page physique. Un défaut mineur a lieu, et c'est lui qui force l'OS à attribuer une page physique pour la page logique demandée. On parle alors d{{'}}'''allocation paresseuse'''. L'avantage est que l'on gagne en performance si des pages logiques sont allouées mais utilisées, ce qui peut arriver. Une optimisation permise par l'existence des défauts mineurs est le '''''copy-on-write'''''. Le but est d'optimiser la copie d'une page logique dans une autre. L'idée est que la copie est retardée quand elle est vraiment nécessaire, à savoir quand on écrit dans la copie. Tant que l'on ne modifie pas la copie, les deux pages logiques, originelle et copiée, pointent vers la même page physique. A quoi bon avoir deux copies avec le même contenu ? Par contre, la page physique est marquée en lecture seule. La moindre écriture déclenche une erreur de protection mémoire, et un défaut mineur. Celui-ci est géré par l'OS, qui effectue alors la copie dans une nouvelle page physique. Je viens de dire que le système d'exploitation gère les défauts de page majeurs/mineurs. Un défaut de page déclenche une exception matérielle, qui passe la main au système d'exploitation. Le système d'exploitation doit alors déterminer ce qui a levé l'exception, notamment identifier si c'est un défaut de page mineur ou majeur. Pour cela, le processeur a un ou plusieurs '''registres de statut''' qui indique l'état du processeur, qui sont utiles pour gérer les défauts de page. Ils indiquent quelle est l'adresse fautive, si l'accès était une lecture ou écriture, si l'accès a eu lieu en espace noyau ou utilisateur (les espaces mémoire ne sont pas les mêmes), etc. Les registres en question varient grandement d'une architecture de processeur à l'autre, aussi on ne peut pas dire grand chose de plus sur le sujet. Le reste est de toute façon à voir dans un cours sur les systèmes d'exploitation. ====Le remplacement des pages==== Les pages virtuelles font référence soit à une page en mémoire physique, soit à une page sur le disque dur. Mais l'on ne peut pas lire une page directement depuis le disque dur. Les pages sur le disque dur doivent être chargées en RAM, avant d'être utilisables. Ce n'est possible que si on a une page mémoire vide, libre. Si ce n'est pas le cas, on doit faire de la place en swappant une page sur le disque dur. Les pages font ainsi une sorte de va et vient entre le fichier d'échange et la RAM, suivant les besoins. Tout cela est effectué par une routine d'interruption du système d'exploitation, le processeur n'ayant pas vraiment de rôle là-dedans. Supposons que l'on veuille faire de la place en RAM pour une nouvelle page. Dans une implémentation naïve, on trouve une page à évincer de la mémoire, qui est copiée dans le ''swapfile''. Toutes les pages évincées sont alors copiées sur le disque dur, à chaque remplacement. Néanmoins, cette implémentation naïve peut cependant être améliorée si on tient compte d'un point important : si la page a été modifiée depuis le dernier accès. Si le programme/processeur a écrit dans la page, alors celle-ci a été modifiée et doit être sauvegardée sur le ''swapfile'' si elle est évincée. Par contre, si ce n'est pas le cas, la page est soit initialisée, soit déjà présente à l'identique dans le ''swapfile''. Mais cette optimisation demande de savoir si une écriture a eu lieu dans la page. Pour cela, on ajoute un '''''dirty bit''''' à chaque entrée de la table des pages, juste à côté du bit ''Valid''. Il indique si une écriture a eu lieu dans la page depuis qu'elle a été chargée en RAM. Ce bit est mis à jour par le processeur, automatiquement, lors d'une écriture. Par contre, il est remis à zéro par le système d'exploitation, quand la page est chargée en RAM. Si le programme se voit allouer de la mémoire, il reçoit une page vide, et ce bit est initialisé à 0. Il est mis à 1 si la mémoire est utilisée. Quand la page est ensuite swappée sur le disque dur, ce bit est remis à 0 après la sauvegarde. Sur la majorité des systèmes d'exploitation, il est possible d'interdire le déplacement de certaines pages sur le disque dur. Ces pages restent alors en mémoire RAM durant un temps plus ou moins long, parfois en permanence. Cette possibilité simplifie la vie des programmeurs qui conçoivent des systèmes d'exploitation : essayez d'exécuter l'interruption pour les défauts de page alors que la page contenant le code de l'interruption est placée sur le disque dur ! Là encore, cela demande d'ajouter un bit dans chaque entrée de la table des pages, qui indique si la page est swappable ou non. Le bit en question s'appelle souvent le '''bit ''swappable'''''. ====Les algorithmes de remplacement des pages pris en charge par l'OS==== Le choix de la page doit être fait avec le plus grand soin et il existe différents algorithmes qui permettent de décider quelle page supprimer de la RAM. Leur but est de swapper des pages qui ne seront pas accédées dans le futur, pour éviter d'avoir à faire triop de va-et-vient entre RAM et ''swapfile''. Les données qui sont censées être accédées dans le futur doivent rester en RAM et ne pas être swappées, autant que possible. Les algorithmes les plus simples pour le choix de page à évincer sont les suivants. Le plus simple est un algorithme aléatoire : on choisit la page au hasard. Mine de rien, cet algorithme est très simple à implémenter et très rapide à exécuter. Il ne demande pas de modifier la table des pages, ni même d'accéder à celle-ci pour faire son choix. Ses performances sont surprenamment correctes, bien que largement en-dessous de tous les autres algorithmes. L'algorithme FIFO supprime la donnée qui a été chargée dans la mémoire avant toutes les autres. Cet algorithme fonctionne bien quand un programme manipule des tableaux de grande taille, mais fonctionne assez mal dans le cas général. L'algorithme LRU supprime la donnée qui été lue ou écrite pour la dernière fois avant toutes les autres. C'est théoriquement le plus efficace dans la majorité des situations. Malheureusement, son implémentation est assez complexe et les OS doivent modifier la table des pages pour l'implémenter. L'algorithme le plus utilisé de nos jours est l{{'}}'''algorithme NRU''' (''Not Recently Used''), une simplification drastique du LRU. Il fait la différence entre les pages accédées il y a longtemps et celles accédées récemment, d'une manière très binaire. Les deux types de page sont appelés respectivement les '''pages froides''' et les '''pages chaudes'''. L'OS swappe en priorité les pages froides et ne swappe de page chaude que si aucune page froide n'est présente. L'algorithme est simple : il choisit la page à évincer au hasard parmi une page froide. Si aucune page froide n'est présente, alors il swappe au hasard une page chaude. Pour implémenter l'algorithme NRU, l'OS mémorise, dans chaque entrée de la table des pages, si la page associée est froide ou chaude. Pour cela, il met à 0 ou 1 un bit dédié : le '''bit ''Accessed'''''. La différence avec le bit ''dirty'' est que le bit ''dirty'' est mis à jour uniquement lors des écritures, alors que le bit ''Accessed'' l'est aussi lors d'une lecture. Uen lecture met à 1 le bit ''Accessed'', mais ne touche pas au bit ''dirty''. Les écritures mettent les deux bits à 1. Implémenter l'algorithme NRU demande juste de mettre à jour le bit ''Accessed'' de chaque entrée de la table des pages. Et sur les architectures modernes, le processeur s'en charge automatiquement. A chaque accès mémoire, que ce soit en lecture ou en écriture, le processeur met à 1 ce bit. Par contre, le système d'exploitation le met à 0 à intervalles réguliers. En conséquence, quand un remplacement de page doit avoir lieu, les pages chaudes ont de bonnes chances d'avoir le bit ''Accessed'' à 1, alors que les pages froides l'ont à 0. Ce n'est pas certain, et on peut se trouver dans des cas où ce n'est pas le cas. Par exemple, si un remplacement a lieu juste après la remise à zéro des bits ''Accessed''. Le choix de la page à remplacer est donc imparfait, mais fonctionne bien en pratique. Tous les algorithmes précédents ont chacun deux variantes : une locale, et une globale. Avec la version locale, la page qui va être rapatriée sur le disque dur est une page réservée au programme qui est la cause du page miss. Avec la version globale, le système d'exploitation va choisir la page à virer parmi toutes les pages présentes en mémoire vive. ===La protection mémoire avec la pagination=== Avec la pagination, chaque page a des '''droits d'accès''' précis, qui permettent d'autoriser ou interdire les accès en lecture, écriture, exécution, etc. La table des pages mémorise les autorisations pour chaque page, sous la forme d'une suite de bits où chaque bit autorise/interdit une opération bien précise. En pratique, les tables de pages modernes disposent de trois bits : un qui autorise/interdit les accès en lecture, un qui autorise/interdit les accès en écriture, un qui autorise/interdit l'éxecution du contenu de la page. Le format exact de la suite de bits a cependant changé dans le temps sur les processeurs x86 modernes. Par exemple, avant le passage au 64 bits, les CPU et OS ne pouvaient pas marquer une page mémoire comme non-exécutable. C'est seulement avec le passage au 64 bits qu'a été ajouté un bit pour interdire l'exécution de code depuis une page. Ce bit, nommé '''bit NX''', est à 0 si la page n'est pas exécutable et à 1 sinon. Le processeur vérifie à chaque chargement d'instruction si le bit NX de page lue est à 1. Sinon, il lève une exception matérielle et laisse la main à l'OS. Une amélioration de cette protection est la technique dite du '''''Write XOR Execute''''', abréviée WxX. Elle consiste à interdire les pages d'être à la fois accessibles en écriture et exécutables. Il est possible de changer les autorisations en cours de route, ceci dit. Les premiers IBM 360 disposaient d'un mécanisme de protection mémoire totalement différent, sans registres limite/base. Ce mécanisme de protection attribue à chaque programme une '''clé de protection''', qui consiste en un nombre unique de 4 bits (chaque programme a donc une clé différente de ses collègues). La mémoire est fragmentée en blocs de même taille, de 2 kibioctets. Le processeur mémorise, pour chacun de ses blocs, la clé de protection du programme qui a réservé ce bloc. À chaque accès mémoire, le processeur compare la clé de protection du programme en cours d’exécution et celle du bloc de mémoire de destination. Si les deux clés sont différentes, alors un programme a effectué un accès hors des clous et il se fait sauvagement arrêter. ===La traduction d'adresse avec la pagination=== Comme dit plus haut, les pages sont numérotées, de 0 à une valeur maximale, afin de les identifier. Le numéro en question est appelé le '''numéro de page'''. Il est utilisé pour dire au processeur : je veux lire une donnée dans la page numéro 20, la page numéro 90, etc. Une fois qu'on a le numéro de page, on doit alors préciser la position de la donnée dans la page, appelé le '''décalage''', ou encore l{{'}}''offset''. Le numéro de page et le décalage se déduisent à partir de l'adresse, en divisant l'adresse par la taille de la page. Le quotient obtenu donne le numéro de la page, alors que le reste est le décalage. Les processeurs actuels utilisent tous des pages dont la taille est une puissance de deux, ce qui fait que ce calcul est fortement simplifié. Sous cette condition, le numéro de page correspond aux bits de poids fort de l'adresse, alors que le décalage est dans les bits de poids faible. Le numéro de page existe en deux versions : un numéro de page physique qui identifie une page en mémoire physique, et un numéro de page logique qui identifie une page dans la mémoire virtuelle. Traduire l'adresse logique en adresse physique demande de remplacer le numéro de la page logique en un numéro de page physique. [[File:Phycical address.JPG|centre|vignette|upright=2|Traduction d'adresse avec la pagination.]] ====Les tables des pages simples==== Dans le cas le plus simple, il n'y a qu'une seule table des pages, qui est adressée par les numéros de page logique. La table des pages est un vulgaire tableau d'adresses physiques, placées les unes à la suite des autres. Avec cette méthode, la table des pages a autant d'entrée qu'il y a de pages logiques en mémoire virtuelle. Accéder à la mémoire nécessite donc d’accéder d'abord à la table des pages en mémoire, de calculer l'adresse de l'entrée voulue, et d’y accéder. [[File:Table des pages.png|centre|vignette|upright=2|Table des pages.]] La table des pages est souvent stockée dans la mémoire RAM, son adresse est connue du processeur, mémorisée dans un registre spécialisé du processeur. Le processeur effectue automatiquement le calcul d'adresse à partir de l'adresse de base et du numéro de page logique. [[File:Address translation (32-bit).png|centre|vignette|upright=2|Address translation (32-bit)]] ====Les tables des pages inversées==== Sur certains systèmes, notamment sur les architectures 64 bits ou plus, le nombre de pages est très important. Sur les ordinateurs x86 récents, les adresses sont en pratique de 48 bits, les bits de poids fort étant ignorés en pratique, ce qui fait en tout 68 719 476 736 pages. Chaque entrée de la table des pages fait au minimum 48 bits, mais fait plus en pratique : partons sur 64 bits par entrée, soit 8 octets. Cela fait 549 755 813 888 octets pour la table des pages, soit plusieurs centaines de gibioctets ! Une table des pages normale serait tout simplement impraticable. Pour résoudre ce problème, on a inventé les '''tables des pages inversées'''. L'idée derrière celles-ci est l'inverse de la méthode précédente. La méthode précédente stocke, pour chaque page logique, son numéro de page physique. Les tables des pages inversées font l'inverse : elles stockent, pour chaque numéro de page physique, la page logique qui correspond. Avec cette méthode table des pages contient ainsi autant d'entrées qu'il y a de pages physiques. Elle est donc plus petite qu'avant, vu que la mémoire physique est plus petite que la mémoire virtuelle. Quand le processeur veut convertir une adresse virtuelle en adresse physique, la MMU recherche le numéro de page de l'adresse virtuelle dans la table des pages. Le numéro de l'entrée à laquelle se trouve ce morceau d'adresse virtuelle est le morceau de l'adresse physique. Pour faciliter le processus de recherche dans la page, la table des pages inversée est ce que l'on appelle une table de hachage. C'est cette solution qui est utilisée sur les processeurs Power PC. [[File:Table des pages inversée.jpg|centre|vignette|upright=2|Table des pages inversée.]] ====Les tables des pages multiples par espace d'adressage==== Dans les deux cas précédents, il y a une table des pages unique. Cependant, les concepteurs de processeurs et de systèmes d'exploitation ont remarqué que les adresses les plus hautes et/ou les plus basses sont les plus utilisées, alors que les adresses situées au milieu de l'espace d'adressage sont peu utilisées en raison du fonctionnement de la pile et du tas. Il y a donc une partie de la table des pages qui ne sert à rien et est utilisé pour des adresses inutilisées. C'est une source d'économie d'autant plus importante que les tables des pages sont de plus en plus grosses. Pour profiter de cette observation, les concepteurs d'OS ont décidé de découper l'espace d'adressage en plusieurs sous-espaces d'adressage de taille identique : certains localisés dans les adresses basses, d'autres au milieu, d'autres tout en haut, etc. Et vu que l'espace d'adressage est scindé en plusieurs parties, la table des pages l'est aussi, elle est découpée en plusieurs sous-tables. Si un sous-espace d'adressage n'est pas utilisé, il n'y a pas besoin d'utiliser de la mémoire pour stocker la table des pages associée. On ne stocke que les tables des pages pour les espaces d'adressage utilisés, ceux qui contiennent au moins une donnée. L'utilisation de plusieurs tables des pages ne fonctionne que si le système d'exploitation connaît l'adresse de chaque table des pages (celle de la première entrée). Pour cela, le système d'exploitation utilise une super-table des pages, qui stocke les adresses de début des sous-tables de chaque sous-espace. En clair, la table des pages est organisé en deux niveaux, la super-table étant le premier niveau et les sous-tables étant le second niveau. L'adresse est structurée de manière à tirer profit de cette organisation. Les bits de poids fort de l'adresse sélectionnent quelle table de second niveau utiliser, les bits du milieu de l'adresse sélectionne la page dans la table de second niveau et le reste est interprété comme un ''offset''. Un accès à la table des pages se fait comme suit. Les bits de poids fort de l'adresse sont envoyés à la table de premier niveau, et sont utilisés pour récupérer l'adresse de la table de second niveau adéquate. Les bits au milieu de l'adresse sont envoyés à la table de second niveau, pour récupérer le numéro de page physique. Le tout est combiné avec l{{'}}''offset'' pour obtenir l'adresse physique finale. [[File:Table des pages hiérarchique.png|centre|vignette|upright=2|Table des pages hiérarchique.]] On peut aussi aller plus loin et découper la table des pages de manière hiérarchique, chaque sous-espace d'adressage étant lui aussi découpé en sous-espaces d'adressages. On a alors une table de premier niveau, plusieurs tables de second niveau, encore plus de tables de troisième niveau, et ainsi de suite. Cela peut aller jusqu'à 5 niveaux sur les processeurs x86 64 bits modernes. On parle alors de '''tables des pages emboitées'''. Dans ce cours, la table des pages désigne l'ensemble des différents niveaux de cette organisation, toutes les tables inclus. Seules les tables du dernier niveau mémorisent des numéros de page physiques, les autres tables mémorisant des pointeurs, des adresses vers le début des tables de niveau inférieur. Un exemple sera donné plus bas, dans la section suivante. ====L'exemple des processeurs x86==== Pour rendre les explications précédentes plus concrètes, nous allons prendre l'exemple des processeur x86 anciens, de type 32 bits. Les processeurs de ce type utilisaient deux types de tables des pages : une table des page unique et une table des page hiérarchique. Les deux étaient utilisées dans cas séparés. La table des page unique était utilisée pour les pages larges et encore seulement en l'absence de la technologie ''physical adress extension'', dont on parlera plus bas. Les autres cas utilisaient une table des page hiérarchique, à deux niveaux, trois niveaux, voire plus. Une table des pages unique était utilisée pour les pages larges (de 2 mébioctets et plus). Pour les pages de 4 mébioctets, il y avait une unique table des pages, adressée par les 10 bits de poids fort de l'adresse, les bits restants servant comme ''offset''. La table des pages contenait 1024 entrées de 4 octets chacune, ce qui fait en tout 4 kibioctet pour la table des pages. La table des page était alignée en mémoire sur un bloc de 4 kibioctet (sa taille). [[File:X86 Paging 4M.svg|centre|vignette|upright=2|X86 Paging 4M]] Pour les pages de 4 kibioctets, les processeurs x86-32 bits utilisaient une table des page hiérarchique à deux niveaux. Les 10 bits de poids fort l'adresse adressaient la table des page maitre, appelée le directoire des pages (''page directory''), les 10 bits précédents servaient de numéro de page logique, et les 12 bits restants servaient à indiquer la position de l'octet dans la table des pages. Les entrées de chaque table des pages, mineure ou majeure, faisaient 32 bits, soit 4 octets. Vous remarquerez que la table des page majeure a la même taille que la table des page unique obtenue avec des pages larges (de 4 mébioctets). [[File:X86 Paging 4K.svg|centre|vignette|upright=2|X86 Paging 4K]] La technique du '''''physical adress extension''''' (PAE), utilisée depuis le Pentium Pro, permettait aux processeurs x86 32 bits d'adresser plus de 4 gibioctets de mémoire, en utilisant des adresses physiques de 64 bits. Les adresses virtuelles de 32 bits étaient traduites en adresses physiques de 64 bits grâce à une table des pages adaptée. Cette technologie permettait d'adresser plus de 4 gibioctets de mémoire au total, mais avec quelques limitations. Notamment, chaque programme ne pouvait utiliser que 4 gibioctets de mémoire RAM pour lui seul. Mais en lançant plusieurs programmes, on pouvait dépasser les 4 gibioctets au total. Pour cela, les entrées de la table des pages passaient à 64 bits au lieu de 32 auparavant. La table des pages gardait 2 niveaux pour les pages larges en PAE. [[File:X86 Paging PAE 2M.svg|centre|vignette|upright=2|X86 Paging PAE 2M]] Par contre, pour les pages de 4 kibioctets en PAE, elle était modifiée de manière à ajouter un niveau de hiérarchie, passant de deux niveaux à trois. [[File:X86 Paging PAE 4K.svg|centre|vignette|upright=2|X86 Paging PAE 4K]] En 64 bits, la table des pages est une table des page hiérarchique avec 5 niveaux. Seuls les 48 bits de poids faible des adresses sont utilisés, les 16 restants étant ignorés. [[File:X86 Paging 64bit.svg|centre|vignette|upright=2|X86 Paging 64bit]] ====Les circuits liés à la gestion de la table des pages==== En théorie, la table des pages est censée être accédée à chaque accès mémoire. Mais pour éviter d'avoir à lire la table des pages en mémoire RAM à chaque accès mémoire, les concepteurs de processeurs ont décidé d'implanter un cache dédié, le '''''translation lookaside buffer''''', ou TLB. Le TLB stocke au minimum de quoi faire la traduction entre adresse virtuelle et adresse physique, à savoir une correspondance entre numéro de page logique et numéro de page physique. Pour faire plus général, il stocke des entrées de la table des pages. [[File:MMU principle updated.png|centre|vignette|upright=2.0|MMU avec une TLB.]] Les accès à la table des pages sont gérés de deux façons : soit le processeur gère tout seul la situation, soit il délègue cette tâche au système d’exploitation. Sur les processeurs anciens, le système d'exploitation gère le parcours de la table des pages. Mais cette solution logicielle n'a pas de bonnes performances. D'autres processeurs gèrent eux-mêmes le défaut d'accès à la TLB et vont chercher d'eux-mêmes les informations nécessaires dans la table des pages. Ils disposent de circuits, les '''''page table walkers''''' (PTW), qui s'occupent eux-mêmes du défaut. Les ''page table walkers'' contiennent des registres qui leur permettent de faire leur travail. Le plus important est celui qui mémorise la position de la table des pages en mémoire RAM, dont nous avons parlé plus haut. Les PTW ont besoin, pour faire leur travail, de mémoriser l'adresse physique de la table des pages, ou du moins l'adresse de la table des pages de niveau 1 pour des tables des pages hiérarchiques. Mais d'autres registres existent. Toutes les informations nécessaires pour gérer les défauts de TLB sont stockées dans des registres spécialisés appelés des '''tampons de PTW''' (PTW buffers). ===L'abstraction matérielle des processus : une table des pages par processus=== [[File:Memoire virtuelle.svg|vignette|Mémoire virtuelle]] Il est possible d'implémenter l'abstraction matérielle des processus avec la pagination. En clair, chaque programme lancé sur l'ordinateur dispose de son propre espace d'adressage, ce qui fait que la même adresse logique ne pointera pas sur la même adresse physique dans deux programmes différents. Pour cela, il y a plusieurs méthodes. ====L'usage d'une table des pages unique avec un identifiant de processus dans chaque entrée==== La première solution n'utilise qu'une seule table des pages, mais chaque entrée est associée à un processus. Pour cela, chaque entrée contient un '''identifiant de processus''', un numéro qui précise pour quel processus, pour quel espace d'adressage, la correspondance est valide. La page des tables peut aussi contenir des entrées qui sont valides pour tous les processus en même temps. L'intérêt n'est pas évident, mais il le devient quand on se rappelle que le noyau de l'OS est mappé dans le haut de l'espace d'adressage. Et peu importe l'espace d'adressage, le noyau est toujours mappé de manière identique, les mêmes adresses logiques adressant la même adresse mémoire. En conséquence, les correspondances adresse physique-logique sont les mêmes pour le noyau, peu importe l'espace d'adressage. Dans ce cas, la correspondance est mémorisée dans une entrée, mais sans identifiant de processus. À la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau. Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus. ====L'usage de plusieurs tables des pages==== Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour. Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique. [[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]] ===La taille des pages=== La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant. Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''. Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges. Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses. ===Les entrées de la table des pages=== Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits. * Elle contient d'abord le numéro de page physique. * Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS. * Le bit G est le bit ''global''. * Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges. * Le bit D est le bit ''dirty''. * Le bit A est le bit ''accessed''. * Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM. * Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page). * Le bit U/S précise si la page est accessible en mode noyau ou utilisateur. * Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture. * Le bit P est le bit ''valid''. [[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]] ==Comparaison des différentes techniques d'abstraction mémoire== Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous. {|class="wikitable" |- ! ! colspan="5" | Avec abstraction mémoire ! rowspan="2" | Sans abstraction mémoire |- ! ! Relocation matérielle ! Segmentation en mode réel (x86) ! Segmentation, général ! Architectures à capacités ! Pagination |- ! Abstraction matérielle des processus | colspan="4" | Oui, relocation matérielle | Oui, liée à la traduction d'adresse | Impossible |- ! Mémoire virtuelle | colspan="2" | Non, sauf émulation logicielle | colspan="3" | Oui, gérée par le processeur et l'OS | Non, sauf émulation logicielle |- ! Extension de l'espace d'adressage | colspan="2" | Oui : registre de base élargi | colspan="2" | Oui : adresse de base élargie dans la table des segments | ''Physical Adress Extension'' des processeurs 32 bits | Commutation de banques |- ! Protection mémoire | Registre limite | Aucune | colspan="2" | Registre limite, droits d'accès aux segments | Gestion des droits d'accès aux pages | Possible, méthodes variées |- ! Partage de mémoire | colspan="2" | Non | colspan="2" | Segment partagés | Pages partagées | Possible, méthodes variées |} ===Les différents types de segmentation=== La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues. La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente. La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86. Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique. ===Segmentation versus pagination=== Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe. L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum. Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment. Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=L'espace d'adressage du processeur | prevText=L'espace d'adressage du processeur | next=Les méthodes de synchronisation entre processeur et périphériques | nextText=Les méthodes de synchronisation entre processeur et périphériques }} </noinclude> tt8eram6gpr8jnnundk0o1v8yrzb2xp 771163 771162 2026-08-22T01:05:59Z Mewtow 31375 /* L'abstraction mémoire implémente plusieurs fonctionnalités complémentaires */ 771163 wikitext text/x-wiki Pour introduire ce chapitre, nous devons faire un rappel sur le concept d{{'}}'''espace d'adressage'''. Pour rappel, un espace d'adressage correspond à l'ensemble des adresses utilisables par le processeur. Par exemple, si je prends un processeur 16 bits, il peut adresser en tout 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. Intuitivement, on s'attend à ce qu'il y ait correspondance avec les adresses de la mémoire RAM. J'entends par là que l'adresse 1209 de l'espace d'adressage correspond à l'adresse 1209 en mémoire RAM. C'est là une hypothèse parfaitement raisonnable et on voit mal comment ce pourrait ne pas être le cas. Mais les processeurs modernes utilisent des techniques d{{'}}'''abstraction mémoire''' qui font que ce n'est pas le cas. Avec ces techniques, l'adresse 1209 de l'espace d'adressage correspond en réalité à l'adresse 9999 en mémoire RAM, voire n'est pas en RAM. L'abstraction mémoire fait que l'espace d'adressage regroupe des adresses fictives, qui doivent être traduites en adresses mémoires réelles pour être utilisées. Les adresses de l'espace d'adressage portent le nom d{{'}}'''adresses logiques''', alors que les adresses de la mémoire RAM sont appelées '''adresses physiques'''. L'intérêt de l'abstraction matérielle n'est pas évident. Aussi, avant de parler de comment l'abstraction mémoire fonctionne, nous allons voir à quoi elle sert. Nous allons voir qu'elle a plusieurs utilisations différentes, qui sont absolument nécessaires sur tous les ordinateurs personnels modernes. Tous les processeurs modernes la prennent en charge, les systèmes d'exploitation coopérent avec le processeur pour l'utiliser au mieux. ==L'abstraction mémoire permet plusieurs fonctionnalités complémentaires== La fonctionnalité la plus connue est la mémoire virtuelle, dont vous avez peut-être déjà entendu parler, peut-être que le nom vous dit quelque chose. Si ce n'est pas le cas, nous la détailleront dans la suite. Elle permet concrétement à un programme d'utiliser plus de mémoire qu'il n'y a de RAM installé dans un ordinateur, en utilisant le disque dur comme solution de secours. Mais d'autres fonctionnalités moins évidentes sont permises par l'abstraction mémoire. Et la première que nous allons voir est l'abstraction des processus. : En général, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle. ===L'abstraction matérielle des processus=== Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres. Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente. Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y. Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types : * La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique. * La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique. ===Le partage de la mémoire=== L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile. Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon. Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM. [[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]] D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées. Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité. Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire). [[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]] L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux. Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique. ===La mémoire virtuelle=== Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème. Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat. Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur. Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM. [[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]] Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation. Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel. L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''. On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur. Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée. ===L'extension d'adressage=== Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses. Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente. Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter. [[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]] Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage. Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas. ===La protection mémoire=== La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses. La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran. La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées. D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre. ==La MMU== La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé appelé la '''''Memory Management Unit''''' (MMU), qui est souvent intégré directement dans l'interface mémoire. La MMU est souvent associée à une ou plusieurs mémoires caches, qui visent à accélérer la traduction d'adresses logiques en adresses physiques. En effet, nous verrons plus bas que la traduction d'adresse demande d'accéder à des tableaux, gérés par le système d'exploitation, qui sont en mémoire RAM. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu. [[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]] ===Les MMU intégrées au processeur=== D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente. Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation. [[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]] ===Les MMU séparées du processeur, sur la carte mère=== Il a existé des processeurs avec une MMU externe, soudée sur la carte mère. Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451. Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits. Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur. ==La relocation matérielle== Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous. [[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]] Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations. ===La relocation avec la relocation matérielle : le registre de base=== Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme. [[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]] La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation. [[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]] Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage. [[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]] Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable. ===La protection mémoire avec la relocation matérielle : le registre limite=== Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire. Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, qu'il ne déborde pas en-dehors. C'est possible qu'une adresse calculée sorte du segment, à la suite d'un bug ou d'une erreur de programmation, voire pire. Et le processeur doit éviter de tels '''débordements de segments'''. A chaque accès mémoire, le processeur compare l'adresse accédée et vérifie qu'elle est bien dans le segment. Pour cela, il y a deux solutions. La première part du principe que le segment est placé en mémoire entre l'adresse de base et l'adresse limite. Il suffit de mémoriser l'adresse limite, l'adresse physique à ne pas dépasser. Une autre solution mémorise la taille du segment. La table des segments doit donc mémoriser, en plus de l'adresse de base : soit l'adresse maximale du segment, soit la taille du segment. D'autres informations peuvent être ajoutées, comme on le verra plus tard, mais cela complexifie la table des segments. De plus, le processeur se voit ajouter un '''registre limite''', qui mémorise soit la taille du segment, soit l'adresse limite. Les deux registres, base et limite, sont utilisés pour vérifier si un programme qui lit/écrit de la mémoire en-dehors de son segment attitré : au-delà pour le registre limite, en-deça pour le registre de base. Le processeur vérifie pour chaque accès mémoire ne déborde pas au-delà du segment qui lui est allouée, ce qui n'arrive que si l'adresse d'accès dépasse la valeur du registre limite. Pour les accès en-dessous du segment, il suffit de vérifier si l'addition de relocation déborde, tout débordement signifiant erreur de protection mémoire. [[File:Registre limite.png|centre|vignette|upright=2|Registre limite]] Utiliser la taille du segment a de nombreux avantages. L'un d'entre eux se manifeste quand on déplace un segment en mémoire RAM. Le descripteur doit alors être mis à jour, et c'est plus facile quand on utilise la taille du segment. Si on utilise l'adresse limite, il faut mettre à jour à la fois l'adresse de base et l'adresse limite, dans le descripteur. En utilisant la taille, seule l'adresse de base doit être modifiée, vu que le segment n'a pas changé de taille. Un autre avantage est lié aux performances, mais nous devons faire un détour pour le comprendre. La taille du segment est équivalent à l'adresse logique maximale possible. Par exemple, si un segment fait 256 octets, les adresses logiques possibles vont de 0 à 255, 256 est donc à la fois la taille du segment et l'adresse logique à partir de laquelle on déborde du segment. Et cela marche si on remplace 256 par n'importe quelle valeur : vu que le segment commence à l'adresse 0, sa taille en octets indique l'adresse de dépassement. Interpréter la taille du segment comme une adresse logique fait que les tests avec le registre limite sont plus performants, voyons pourquoi. En utilisant l'adresse physique limite, on doit faire la relocation, puis comparer l'adresse calculée avec l'adresse limite. Le calcul d'adresse doit se faire avant la vérification. En utilisant la taille, on doit comparer l'adresse logique avec la taille du segment. On peut alors faire le test de débordement avant ou pendant la relocation. Les deux peuvent être faits en parallèle, dans deux circuits distincts, ce qui améliore un peu le temps d'un accès mémoire. Quelques processeurs en ont profité, mais on verra cela dans la section sur la segmentation. [[File:Comparaison entre adresse limite physique et logique.png|centre|vignette|upright=2|Comparaison entre adresse limite physique et logique]] Les registres de base et limite sont altérés uniquement par le système d'exploitation et ne sont accessibles qu'en espace noyau. Lorsque le système d'exploitation charge un programme, ou reprend son exécution, il charge les adresses de début/fin du segment dans ces registres. D'ailleurs, ces deux registres doivent être sauvegardés et restaurés lors de chaque interruption. Par contre, et c'est assez évident, ils ne le sont pas lors d'un appel de fonction. Cela fait une différence de plus entre interruption et appels de fonctions. : Il faut noter que le registre limite et le registre de base sont parfois fusionnés en un seul registre, qui contient un descripteur de segment tout entier. Pour information, la relocation matérielle avec un registre limite a été implémentée sur plusieurs processeurs assez anciens, notamment sur les anciens supercalculateurs de marque CDC. Un exemple est le fameux CDC 6600, qui implémentait cette technique. ===La mémoire virtuelle avec la relocation matérielle=== Il est possible d'implémenter la mémoire virtuelle avec la relocation matérielle. Pour cela, il faut swapper des segments entiers sur le disque dur. Les segments sont placés en mémoire RAM et leur taille évolue au fur et à mesure que les programmes demandent du rab de mémoire RAM. Lorsque la mémoire est pleine, ou qu'un programme demande plus de mémoire que disponible, des segments entiers sont sauvegardés dans le ''swapfile'', pour faire de la place. Faire ainsi de demande juste de mémoriser si un segment est en mémoire RAM ou non, ainsi que la position des segments swappés dans le ''swapfile''. Pour cela, il faut modifier la table des segments, afin d'ajouter un '''bit de swap''' qui précise si le segment en question est swappé ou non. Lorsque le système d'exploitation veut swapper un segment, il le copie dans le ''swapfile'' et met ce bit à 1. Lorsque l'OS recharge ce segment en RAM, il remet ce bit à 0. La gestion de la position des segments dans le ''swapfile'' est le fait d'une structure de données séparée de la table des segments. L'OS exécute chaque programme l'un après l'autre, à tour de rôle. Lorsque le tour d'un programme arrive, il consulte la table des segments pour récupérer les adresses de base et limite, mais il vérifie aussi le bit de swap. Si le bit de swap est à 0, alors l'OS se contente de charger les adresses de base et limite dans les registres adéquats. Mais sinon, il démarre une routine d'interruption qui charge le segment voulu en RAM, depuis le ''swapfile''. C'est seulement une fois le segment chargé que l'on connait son adresse de base/limite et que le chargement des registres de relocation peut se faire. Un défaut évident de cette méthode est que l'on swappe des programmes entiers, qui sont généralement assez imposants. Les segments font généralement plusieurs centaines de mébioctets, pour ne pas dire plusieurs gibioctets, à l'époque actuelle. Ils étaient plus petits dans l'ancien temps, mais la mémoire était alors plus lente. Toujours est-il que la copie sur le disque dur des segments est donc longue, lente, et pas vraiment compatible avec le fait que les programmes s'exécutent à tour de rôle. Et ca explique pourquoi la relocation matérielle n'est presque jamais utilisée avec de la mémoire virtuelle. ===L'extension d'adressage avec la relocation matérielle=== Passons maintenant à la dernière fonctionnalité implémentable avec la traduction d'adresse : l'extension d'adressage. Elle permet d'utiliser plus de mémoire que ne le permet l'espace d'adressage. Par exemple, utiliser plus de 64 kibioctets de mémoire sur un processeur 16 bits. Pour cela, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. L'extension des adresses se fait assez simplement avec la relocation matérielle : il suffit que le registre de base soit plus long. Prenons l'exemple d'un processeur aux adresses de 16 bits, mais qui est reliée à un bus d'adresse de 24 bits. L'espace d'adressage fait juste 64 kibioctets, mais le bus d'adresse gère 16 mébioctets de RAM. On peut utiliser les 16 mébioctets de RAM à une condition : que le registre de base fasse 24 bits, pas 16. Un défaut de cette approche est qu'un programme ne peut pas utiliser plus de mémoire que ce que permet l'espace d'adressage. Mais par contre, on peut placer chaque programme dans des portions différentes de mémoire. Imaginons par exemple que l'on ait un processeur 16 bits, mais un bus d'adresse de 20 bits. Il est alors possible de découper la mémoire en 16 blocs de 64 kibioctets, chacun attribué à un segment/programme, qu'on sélectionne avec les 4 bits de poids fort de l'adresse. Il suffit de faire démarrer les segments au bon endroit en RAM, et cela demande juste que le registre de base le permette. C'est une sorte d'émulation de la commutation de banques. ==La segmentation en mode réel des processeurs x86== Avant de passer à la suite, nous allons voir la technique de segmentation de l'Intel 8086, un des tout premiers processeurs 16 bits. Il s'agissait d'une forme très simple de segmentation, sans aucune forme de protection mémoire, ni même de mémoire virtuelle, ce qui le place à part des autres formes de segmentation. Il s'agit d'une amélioration de la relocation matérielle, qui avait pour but de permettre d'utiliser plus de 64 kibioctets de mémoire, ce qui était la limite maximale sur les processeurs 16 bits de l'époque. Par la suite, la segmentation s'améliora et ajouta un support complet de la mémoire virtuelle et de la protection mémoire. L'ancienne forme de segmentation fut alors appelé le '''mode réel''', et la nouvelle forme de segmentation fut appelée le '''mode protégé'''. Le mode protégé rajoute la protection mémoire, en ajoutant des registres limite et une gestion des droits d'accès aux segments, absents en mode réel. De plus, il ajoute un support de la mémoire virtuelle grâce à l'utilisation d'une des segments digne de ce nom, table qui est absente en mode réel ! Pour le moment, voyons le mode réel. ===Les segments en mode réel=== [[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]] La segmentation en mode réel sépare la pile, le tas, le code machine et les données constantes dans quatre segments distincts. * Le segment '''''text''''', qui contient le code machine du programme, de taille fixe. * Le segment '''''data''''' contient des données de taille fixe qui occupent de la mémoire de façon permanente, des constantes, des variables globales, etc. * Le segment pour la '''pile''', de taille variable. * le reste est appelé le '''tas''', de taille variable. Un point important est que sur ces processeurs, il n'y a pas de table des segments proprement dit. Chaque programme gére de lui-même les adresses de base des segments qu'il manipule. Il n'est en rien aidé par une table des segments gérée par le système d'exploitation. ===Les registres de segments en mode réel=== Chaque segment subit la relocation indépendamment des autres. Pour cela, le processeur intégre plusieurs registres de base, un par segment. Notons que cette solution ne marche que si le nombre de segments par programme est limité, à une dizaine de segments tout au plus. Les processeurs x86 utilisaient cette méthode, et n'associaient que 4 à 6 registres de segments par programme. Les processeurs 8086 et le 286 avaient quatre registres de segment : un pour le code, un autre pour les données, et un pour la pile, le quatrième étant un registre facultatif laissé à l'appréciation du programmeur. Ils sont nommés CS (''code segment''), DS (''data segment''), SS (''Stack segment''), et ES (''Extra segment''). Le 386 rajouta deux registres, les registres FS et GS, qui sont utilisés pour les segments de données. Les processeurs post-386 ont donc 6 registres de segment. Les registres CS et SS sont adressés implicitement, en fonction de l'instruction exécutée. Les instructions de la pile manipulent le segment associé à la pile, le chargement des instructions se fait dans le segment de code, les instructions arithmétiques et logiques vont chercher leurs opérandes sur le tas, etc. Et donc, toutes les instructions sont chargées depuis le segment pointé par CS, les instructions de gestion de la pile (PUSH et POP) utilisent le segment pointé par SS. Les segments DS et ES sont, eux aussi, adressés implicitement. Pour cela, les instructions LOAD/STORE sont dupliquées : il y a une instruction LOAD pour le segment DS, une autre pour le segment ES. D'autres instructions lisent leurs opérandes dans un segment par défaut, mais on peut changer ce choix par défaut en précisant le segment voulu. Un exemple est celui de l'instruction CMPSB, qui compare deux octets/bytes : le premier est chargé depuis le segment DS, le second depuis le segment ES. Un autre exemple est celui de l'instruction MOV avec un opérande en mémoire. Elle lit l'opérande en mémoire depuis le segment DS par défaut. Il est possible de préciser le segment de destination si celui-ci n'est pas DS. Par exemple, l'instruction MOV [A], AX écrit le contenu du registre AX dans l'adresse A du segment DS. Par contre, l'instruction MOV ES:[A], copie le contenu du registre AX das l'adresse A, mais dans le segment ES. ===La traduction d'adresse en mode réel=== La segmentation en mode réel a pour seul but de permettre à un programme de dépasser la limite des 64 KB autorisée par les adresses de 16 bits. L'idée est que chaque segment a droit à son propre espace de 64 KB. On a ainsi 64 Kb pour le code machine, 64 KB pour la pile, 64 KB pour un segment de données, etc. Les registres de segment mémorisaient la base du segment, les adresses calculées par l'ALU étant des ''offsets''. Ce sont tous des registres de 16 bits, mais ils ne mémorisent pas des adresses physiques de 16 bits, comme nous allons le voir. [[File:Table des segments dans un banc de registres.png|centre|vignette|upright=2|Table des segments dans un banc de registres.]] L'Intel 8086 utilisait des adresses de 20 bits, ce qui permet d'adresser 1 mébioctet de RAM. Vous pouvez vous demander comment on peut obtenir des adresses de 20 bits alors que les registres de segments font tous 16 bits ? Cela tient à la manière dont sont calculées les adresses physiques. Le registre de segment n'est pas additionné tel quel avec le décalage : à la place, le registre de segment est décalé de 4 rangs vers la gauche. Le décalage de 4 rangs vers la gauche fait que chaque segment a une adresse qui est multiple de 16. Le fait que le décalage soit de 16 bits fait que les segments ont une taille de 64 kibioctets. {|class="wikitable" |- | <code>&nbsp; </code><code style="background:#DED">0000 0110 1110 1111</code><code>0000</code> | Registre de segment - | 16 bits, décalé de 4 bits vers la gauche |- | <code>+ &nbsp;&nbsp;&nbsp;&nbsp; </code><code style="background:#DDF">0001 0010 0011 0100</code> | Décalage/''Offset'' | 16 bits |- | colspan="3" | |- | <code>&nbsp; </code><code style="background:#FDF">0000 1000 0001 0010 0100</code> | Adresse finale | 20 bits |} Vous aurez peut-être remarqué que le calcul peut déborder, dépasser 20 bits. Mais nous reviendrons là-dessus plus bas. L'essentiel est que la MMU pour la segmentation en mode réel se résume à quelques registres et des additionneurs/soustracteurs. Un exemple est l'Intel 8086, un des tout premier processeur Intel. Le processeur était découpé en deux portions : l'interface mémoire et le reste du processeur. L'interface mémoire est appelée la '''''Bus Interface Unit''''', et le reste du processeur est appelé l{{'}}'''''Execution Unit'''''. L'interface mémoire contenait les registres de segment, au nombre de 4, ainsi qu'un additionneur utilisé pour traduire les adresses logiques en adresses physiques. Elle contenait aussi une file d'attente où étaient préchargées les instructions. Sur le 8086, la MMU est fusionnée avec les circuits de gestion du ''program counter''. Les registres de segment sont regroupés avec le ''program counter'' dans un même banc de registres, et un additionneur unique est utilisé à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. L'additionneur est donc mutualisé entre segmentation et ''program counter''. En somme, il n'y a pas vraiment de MMU dédiée, mais un super-circuit en charge du Fetch et de la mémoire virtuelle, ainsi que du préchargement des instructions. Nous en reparlerons au chapitre suivant. [[File:80186 arch.png|centre|vignette|upright=2.5|Architecture du 8086, du 80186 et de ses variantes.]] La MMU du 286 était fusionnée avec l'unité de calcul d'adresse. Elle contient les registres de segments, un comparateur pour détecter les accès hors-segment, et plusieurs additionneurs. Il y a un additionneur pour les calculs d'adresse proprement dit, suivi d'un additionneur pour la relocation. [[File:Intel i80286 arch.svg|centre|vignette|upright=3|Intel i80286 arch]] ===La segmentation en mode réel accepte plusieurs segments de code/données=== Les programmes peuvent parfaitement répartir leur code machine dans plusieurs segments de code. La limite de 64 KB par segment est en effet assez limitante, et il n'était pas rare qu'un programme stocke son code dans deux ou trois segments. Il en est de même avec les données, qui peuvent être réparties dans deux ou trois segments séparés. La seule exception est la pile : elle est forcément dans un segment unique et ne peut pas dépasser 64 KB. Pour gérer plusieurs segments de code/donnée, il faut changer de segment à la volée suivant les besoins, en modifiant les registres de segment. Il s'agit de la technique de '''commutation de segment'''. Pour cela, tous les registres de segment, à l'exception de CS, peuvent être altérés par une instruction d'accès mémoire, soit avec une instruction MOV, soit en y copiant le sommet de la pile avec une instruction de dépilage POP. L'absence de sécurité fait que la gestion de ces registres est le fait du programmeur, qui doit redoubler de prudence pour ne pas faire n'importe quoi. Pour le code machine, le répartir dans plusieurs segments posait des problèmes au niveau des branchements. Si la plupart des branchements sautaient vers une instruction dans le même segment, quelques rares branchements sautaient vers du code machine dans un autre segment. Intel avait prévu le coup et disposait de deux instructions de branchement différentes pour ces deux situations : les '''''near jumps''''' et les '''''far jumps'''''. Les premiers sont des branchements normaux, qui précisent juste l'adresse à laquelle brancher, qui correspond à la position de la fonction dans le segment. Les seconds branchent vers une instruction dans un autre segment, et doivent préciser deux choses : l'adresse de base du segment de destination, et la position de la destination dans le segment. Le branchement met à jour le registre CS avec l'adresse de base, avant de faire le branchement. Ces derniers étaient plus lents, car on n'avait pas à changer de segment et mettre à jour l'état du processeur. Il y avait la même pour l'instruction d'appel de fonction, avec deux versions de cette instruction. La première version, le '''''near call''''' est un appel de fonction normal, la fonction appelée est dans le segment en cours. Avec la seconde version, le '''''far call''''', la fonction appelée est dans un segment différent. L'instruction a là aussi besoin de deux opérandes : l'adresse de base du segment de destination, et la position de la fonction dans le segment. Un ''far call'' met à jour le registre CS avec l'adresse de base, ce qui fait que les ''far call'' sont plus lents que les ''near call''. Il existe aussi la même chose, pour les instructions de retour de fonction, avec une instruction de retour de fonction normale et une instruction de retour qui renvoie vers un autre segment, qui sont respectivement appelées '''''near return''''' et '''''far return'''''. Là encore, il faut préciser l'adresse du segment de destination dans le second cas. La même chose est possible pour les segments de données. Sauf que cette fois-ci, ce sont les pointeurs qui sont modifiés. pour rappel, les pointeurs sont, en programmation, des variables qui contiennent des adresses. Lors de la compilation, ces pointeurs sont placés soit dans un registre, soit dans les instructions (adressage absolu), ou autres. Ici, il existe deux types de pointeurs, appelés '''''near pointer''''' et '''''far pointer'''''. Vous l'avez deviné, les premiers sont utilisés pour localiser les données dans le segment en cours d'utilisation, alors que les seconds pointent vers une donnée dans un autre segment. Là encore, la différence est que le premier se contente de donner la position dans le segment, alors que les seconds rajoutent l'adresse de base du segment. Les premiers font 16 bits, alors que les seconds en font 32 : 16 bits pour l'adresse de base et 16 pour l{{'}}''offset''. ===L'occupation de l'espace d'adressage par les segments=== Nous venons de voir qu'un programme pouvait utiliser plus de 4-6 segments, avec la commutation de segment. Mais d'autres programmes faisaient l'inverse, à savoir qu'ils se débrouillaient avec seulement 1 ou 2 segments. Suivant le nombre de segments utilisés, la configuration des registres n'était pas la même. Les configurations possibles sont appelées des ''modèle mémoire'', et il y en a en tout 6. En voici la liste : {| class="wikitable" |- ! Modèle mémoire !! Configuration des segments !! Configuration des registres || Pointeurs utilisés || Branchements utilisés |- | Tiny* || Segment unique pour tout le programme || CS=DS=SS || ''near'' uniquement || ''near'' uniquement |- | Small || Segment de donnée séparé du segment de code, pile dans le segment de données || DS=SS || ''near'' uniquement || ''near'' uniquement |- | Medium || Plusieurs segments de code unique, un seul segment de données || CS, DS et SS sont différents || ''near'' et ''far'' || ''near'' uniquement |- | Compact || Segment de code unique, plusieurs segments de données || CS, DS et SS sont différents || ''near'' uniquement || ''near'' et ''far'' |- | Large || Plusieurs segments de code, plusieurs segments de données || CS, DS et SS sont différents || ''near'' et ''far'' || ''near'' et ''far'' |} Un programme est censé utiliser maximum 4-6 segments de 64 KB, ce qui permet d'adresser maximum 64 * 6 = 384 KB de RAM, soit bien moins que le mébioctet de mémoire théoriquement adressable. Mais ce défaut est en réalité contourné par la commutation de segment, qui permettait d'adresser la totalité de la RAM si besoin. Une second manière de contourner cette limite est que plusieurs processus peuvent s'exécuter sur un seul processeur, si l'OS le permet. Ce n'était pas le cas à l'époque du DOS, qui était un OS mono-programmé, mais c'était en théorie possible. La limite est de 6 segments par programme/processus, en exécuter plusieurs permet d'utiliser toute la mémoire disponible rapidement. [[File:Overlapping realmode segments.svg|vignette|Segments qui se recouvrent en mode réel.]] Vous remarquerez qu'avec des registres de segments de 16 bits, on peut gérer 65536 segments différents, chacun de 64 KB. Et 65 536 segments de 64 kibioctets, ça ne rentre pas dans le mébioctet de mémoire permis avec des adresses de 20 bits. La raison est que plusieurs couples segment+''offset'' pointent vers la même adresse. En tout, chaque adresse peut être adressée par 4096 couples segment+''offset'' différents. L'avantage de cette méthode est que des segments peuvent se recouvrir, à savoir que la fin de l'un se situe dans le début de l'autre, comme illustré ci-contre. Cela permet en théorie de partager de la mémoire entre deux processus. Mais la technique est tout sauf pratique et est donc peu utilisée. Elle demande de placer minutieusement les segments en RAM, et les données à partager dans les segments. En pratique, les programmeurs et OS utilisent des segments qui ne se recouvrent pas et sont disjoints en RAM. Le nombre maximal de segments disjoints se calcule en prenant la taille de la RAM, qu'on divise par la taille d'un segment. Le calcul donne : 1024 kibioctets / 64 kibioctets = 16 segments disjoints. Un autre calcul prend le nombre de segments divisé par le nombre d'adresses aliasées, ce qui donne 65536 / 4096 = 16. Seulement 16 segments, c'est peu. En comptant les segments utilisés par l'OS et ceux utilisés par le programme, la limite est vite atteinte si le programme utilise la commutation de segment. ===Le mode réel sur les 286 et plus : la ligne d'adresse A20=== Pour résumer, le registre de segment contient des adresses de 20 bits, dont les 4 bits de poids faible sont à 0. Et il se voit ajouter un ''offset'' de 16 bits. Intéressons-nous un peu à l'adresse maximale que l'on peut calculer avec ce système. Nous allons l'appeler l{{'}}'''adresse maximale de segmentation'''. Elle vaut : {|class="wikitable" |- | <code>&nbsp; </code><code style="background:#DED">1111 1111 1111 1111</code><code>0000</code> | Registre de segment - | 16 bits, décalé de 4 bits vers la gauche |- | <code>+ &nbsp;&nbsp;&nbsp;&nbsp; </code><code style="background:#DDF">1111 1111 1111 1111</code> | Décalage/''Offset'' | 16 bits |- | colspan="3" | |- | <code>&nbsp; </code><code style="background:#FDF">1 0000 1111 1111 1110 1111</code> | Adresse finale | 20 bits |} Le résultat n'est pas l'adresse maximale codée sur 20 bits, car l'addition déborde. Elle donne un résultat qui dépasse l'adresse maximale permis par les 20 bits, il y a un 21ème bit en plus. De plus, les 20 bits de poids faible ont une valeur bien précise. Ils donnent la différence entre l'adresse maximale permise sur 20 bit, et l'adresse maximale de segmentation. Les bits 1111 1111 1110 1111 traduits en binaire donnent 65 519; auxquels il faut ajouter l'adresse 1 0000 0000 0000 0000. En tout, cela fait 65 520 octets adressables en trop. En clair : on dépasse la limite du mébioctet de 65 520 octets. Le résultat est alors très différent selon que l'on parle des processeurs avant le 286 ou après. Avant le 286, le bus d'adresse faisait exactement 20 bits. Les adresses calculées ne pouvaient pas dépasser 20 bits. L'addition générait donc un débordement d'entier, géré en arithmétique modulaire. En clair, les bits de poids fort au-delà du vingtième sont perdus. Le calcul de l'adresse débordait et retournait au début de la mémoire, sur les 65 520 premiers octets de la mémoire RAM. [[File:IBM PC Memory areas.svg|vignette|IBM PC Memory Map, la ''High memory area'' est en jaune.]] Le 80286 en mode réel gère des adresses de base de 24 bits, soit 4 bits de plus que le 8086. Le résultat est qu'il n'y a pas de débordement. Les bits de poids fort sont conservés, même au-delà du 20ème. En clair, la segmentation permettait de réellement adresser 65 530 octets au-delà de la limite de 1 mébioctet. La portion de mémoire adressable était appelé la '''''High memory area''''', qu'on va abrévier en HMA. {| class="wikitable" |+ Espace d'adressage du 286 |- ! Adresses en héxadécimal !! Zone de mémoire |- | 10 FFF0 à FF FFFF || Mémoire étendue, au-delà du premier mébioctet |- | 10 0000 à 10 FFEF || ''High Memory Area'' |- | 0 à 0F FFFF || Mémoire adressable en mode réel |} En conséquence, les applications peuvent utiliser plus d'un mébioctet de RAM, mais au prix d'une rétrocompatibilité imparfaite. Quelques programmes DOS ne marchaient pus à cause de ça. D'autres fonctionnaient convenablement et pouvaient adresser les 65 520 octets en plus. Pour résoudre ce problème, les carte mères ajoutaient un petit circuit relié au 21ème bit d'adresse, nommé A20 (pas d'erreur, les fils du bus d'adresse sont numérotés à partir de 0). Le circuit en question pouvait mettre à zéro le fil d'adresse, ou au contraire le laisser tranquille. En le forçant à 0, le calcul des adresses déborde comme dans le mode réel des 8086. Mais s'il ne le fait pas, la ''high memory area'' est adressable. Le circuit était une simple porte ET, qui combinait le 21ème bit d'adresse avec un '''signal de commande A20''' provenant d'ailleurs. Le signal de commande A20 était géré par le contrôleur de clavier, qui était soudé à la carte mère. Le contrôleur en question ne gérait pas que le clavier, il pouvait aussi RESET le processeur, alors gérer le signal de commande A20 n'était pas si problématique. Quitte à avoir un microcontrôleur sur la carte mère, autant s'en servir au maximum... La gestion du bus d'adresse étaitdonc gérable au clavier. D'autres carte mères faisaient autrement et préféraient ajouter un interrupteur, pour activer ou non la mise à 0 du 21ème bit d'adresse. : Il faut noter que le signal de commande A20 était mis à 1 en mode protégé, afin que le 21ème bit d'adresse soit activé. Le 386 ajouta deux registres de segment, les registres FS et GS, ainsi que le '''mode ''virtual 8086'''''. Ce dernier permet d’exécuter des programmes en mode réel alors que le système d'exploitation s'exécute en mode protégé. C'est une technique de virtualisation matérielle qui permet d'émuler un 8086 sur un 386. L'avantage est que la compatibilité avec les programmes anciens écrits pour le 8086 est conservée, tout en profitant de la protection mémoire. Tous les processeurs x86 qui ont suivi supportent ce mode virtuel 8086. ==La segmentation avec une table des segments== La '''segmentation avec une table des segments''' est apparue sur des processeurs assez anciens, le tout premier étant le Burrough 5000. Elle a ensuite été utilisée sur les processeurs x86 des PCs, à partir du 286 d'Intel. Elle est aujourd'hui abandonnée sur les jeux d'instruction x86. Tout comme la segmentation en mode réel, la segmentation attribue plusieurs segments par programmes ! Et cela a des répercutions sur la manière dont la traduction d'adresse est effectuée. ===Pourquoi plusieurs segments par programme ?=== L'utilité d'avoir plusieurs segments par programme n'est pas évidente, mais elle le devient quand on se plonge dans le passé. Dans le passé, les programmeurs devaient faire avec une quantité de mémoire limitée et il n'était pas rare que certains programmes utilisent plus de mémoire que disponible sur la machine. Mais les programmeurs concevaient leurs programmes en fonction. [[File:Overlay Programming.svg|vignette|upright=1|Overlay Programming]] L'idée était d'implémenter un système de mémoire virtuelle, mais émulé en logiciel, appelé l{{'}}'''''overlaying'''''. Le programme était découpé en plusieurs morceaux, appelés des ''overlays''. Les ''overlays'' les plus importants étaient en permanence en RAM, mais les autres étaient faisaient un va-et-vient entre RAM et disque dur. Ils étaient chargés en RAM lors de leur utilisation, puis sauvegardés sur le disque dur quand ils étaient inutilisés. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. Le matériel n'intervenait pas, comme c'est le cas avec la mémoire virtuelle. Avec la segmentation, un programme peut utiliser la technique des ''overlays'', mais avec l'aide du matériel. Il suffit de mettre chaque ''overlay'' dans son propre segment, et laisser la segmentation faire. Les segments sont swappés en tout ou rien : on doit swapper tout un segment en entier. L'intérêt est que la gestion du ''swapping'' est grandement facilitée, vu que c'est le système d'exploitation qui s'occupe de swapper les segments sur le disque dur ou de charger des segments en RAM. Pas besoin pour le programmeur de coder quoique ce soit. Par contre, cela demande l'intervention du programmeur, qui doit découper le programme en segments/''overlays'' de lui-même. Sans cela, la segmentation n'est pas très utile. L{{'}}''overlaying'' est une forme de '''segmentation à granularité grossière''', à savoir que le programme est découpé en segments de grande taille. L'usage classique est d'avoir un segment pour la pile, un autre pour le code exécutable, un autre pour le reste. Éventuellement, on peut découper les trois segments précédents en deux ou trois segments, rarement au-delà. Les segments sont alors peu nombreux, guère plus d'une dizaine par programme. D'où le terme de ''granularité grossière''. La '''segmentation à granularité fine''' pousse le concept encore plus loin. Avec elle, il y a idéalement un segment par entité manipulée par le programme, un segment pour chaque structure de donnée et/ou chaque objet. Par exemple, un tableau aura son propre segment, ce qui est idéal pour détecter les accès hors tableau. Pour les listes chainées, chaque élément de la liste aura son propre segment. Et ainsi de suite, chaque variable agrégée (non-primitive), chaque structure de donnée, chaque objet, chaque instance d'une classe, a son propre segment. Diverses fonctionnalités supplémentaires peuvent être ajoutées, ce qui transforme le processeur en véritable processeur orienté objet, mais passons ces détails pour le moment. Vu que les segments correspondent à des objets manipulés par le programme, on peut deviner que leur nombre évolue au cours du temps. En effet, les programmes modernes peuvent demander au système d'exploitation du rab de mémoire pour allouer une nouvelle structure de données. Avec la segmentation à granularité fine, cela demande d'allouer un nouveau segment à chaque nouvelle allocation mémoire, à chaque création d'une nouvelle structure de données ou d'un objet. De plus, les programmes peuvent libérer de la mémoire, en supprimant les structures de données ou objets dont ils n'ont plus besoin. Avec la segmentation à granularité fine, cela revient à détruire le segment alloué pour ces objets/structures de données. Le nombre de segments est donc dynamique, il change au cours de l'exécution du programme. ===Les tables de segments avec la segmentation=== La présence de plusieurs segments par programme a un impact sur la table des segments. Avec la relocation matérielle, elle conte nait un segment par programme. Chaque entrée, chaque ligne de la table des segment, mémorisait l'adresse de base, l'adresse limite, un bit de présence pour la mémoire virtuelle et des autorisations liées à la protection mémoire. Avec la segmentation, les choses sont plus compliquées, car il y a plusieurs segments par programme. Les entrées ne sont pas modifiées, mais elles sont organisées différemment. Avec cette forme de segmentation, la table des segments doit respecter plusieurs contraintes. Premièrement, il y a plusieurs segments par programmes. Deuxièmement, le nombre de segments est variable : certains programmes se contenteront d'un seul segment, d'autres de dizaine, d'autres plusieurs centaines, etc. Il y a typiquement deux manières de faire : soit utiliser une table des segments uniques, utiliser une table des segment par programme. Il est possible d'utiliser une table des segment unique qui mémorise tous les segments de tous les processus, système d'exploitation inclut. On parle alors de '''table des segment globale'''. Mais cette solution n'est pas utilisée avec la segmentation proprement dite. Elle est utilisée sur les architectures à capacité qu'on détaillera vers la fin du chapitre, dans une section dédiée. À la place, la segmentation utilise une table de segment par processus/programme, chacun ayant une '''table des segment locale'''. Dans les faits, les choses sont plus compliquées. Le système d'exploitation doit savoir où se trouvent les tables de segment locale pour chaque programme. Pour cela, il a besoin d'utiliser une table de segment globale, dont chaque entrée pointe non pas vers un segment, mais vers une table de segment locale. Lorsque l'OS effectue une commutation de contexte, il lit la table des segment globale, pour récupérer un pointeur vers celle-ci. Ce pointeur est alors chargé dans un registre du processeur, qui mémorise l'adresse de la table locale, ce qui sert lors des accès mémoire. Une telle organisation fait que les segments d'un processus/programme sont invisibles pour les autres, il y a une certaine forme de sécurité. Un programme ne connait que sa table de segments locale, il n'a pas accès directement à la table des segments globales. Tout accès mémoire se passera à travers la table de segment locale, il ne sait pas où se trouvent les autres tables de segment locales. Les processeurs x86 sont dans ce cas : ils utilisent une table de segment globale couplée à autant de table des segments qu'il y a de processus en cours d'exécution. La table des segments globale s'appelle la '''''Global Descriptor Table''''' et elle peut contenir 8192 segments maximum, ce qui permet le support de 8192 processus différents. Les tables de segments locales sont appelées les '''''Local Descriptor Table''''' et elles font aussi 8192 segments maximum, ce qui fait 8192 segments par programme maximum. Il faut noter que la table de segment globale peut mémoriser des pointeurs vers les routines d'interruption, certaines données partagées (le tampon mémoire pour le clavier) et quelques autres choses, qui n'ont pas leur place dans les tables de segment locales. ===La relocation avec la segmentation=== La table des segments locale mémorise les adresses de base et limite de chaque segment, ainsi que d'autres méta-données. Les informations pour un segment sont regroupés dans un '''descripteur de segment''', qui est codé sur plusieurs octets, et qui regroupe : adresse de base, adresse limite, bit de présence en RAM, méta-données de protection mémoire. La table des segments est un tableau dans lequel les descripteurs de segment sont placés les uns à la suite des autres en mémoire RAM. La table des segments est donc un tableau de segment. Les segments d'un programme sont numérotés, le nombre s'appelant un '''indice de segment''', appelé '''sélecteur de segment''' dans la terminologie Intel. L'indice de segment n'est autre que l'indice du segment dans ce tableau. [[File:Global Descriptor table.png|centre|vignette|upright=2|Table des segments locale.]] Il n'y a pas de registre de segment proprement dit, qui mémoriserait l'adresse de base. À la place, les segments sont adressés de manière indirecte. À la place, les registres de segment mémorisent des sélecteurs de segment. Ils sont utilisés pour lire l'adresse de base/limite dans la table de segment en mémoire RAM. Pour cela, un registre mémorise l'adresse de la table de segment locale, sa position en mémoire RAM. Toute lecture ou écriture se fait en deux temps, en deux accès mémoire, consécutifs. Premièrement, le numéro de segment est utilisé pour adresser la table des segment. La lecture récupère alors un pointeur vers ce segment. Deuxièmement, ce pointeur est utilisé pour faire la lecture ou écriture. Plus précisément, la première lecture récupère un descripteur de segment qui contient l'adresse de base, le pointeur voulu, mais aussi l'adresse limite et d'autres informations. [[File:Segmentation avec table des segments.png|centre|vignette|upright=2|Segmentation avec table des segments]] L'accès à la table des segments se fait automatiquement à chaque accès mémoire. La conséquence est que chaque accès mémoire demande d'en faire deux : un pour lire la table des segments, l'autre pour l'accès lui-même. Il s'agit en quelque sorte d'une forme d'adressage indirect mémoire. Un point important est que si le premier accès ne fait qu'une simple lecture dans un tableau, le second accès implique des calculs d'adresse. En effet, le premier accès récupère l'adresse de base du segment, mais le second accès sélectionne une donnée dans le segment, ce qui demande de calculer son adresse. L'adresse finale se déduit en combinant l'adresse de base avec un décalage (''offset'') qui donne la position de la donnée dans ce segment. L'indice de segment est utilisé pour récupérer l'adresse de base du segment. Une fois cette adresse de base connue, on lui additionne le décalage pour obtenir l'adresse finale. [[File:Table des segments.png|centre|vignette|upright=2|Traduction d'adresse avec une table des segments.]] Pour effectuer automatiquement l'accès à la table des segments, le processeur doit contenir un registre supplémentaire, qui contient l'adresse de la table de segment, afin de la localiser en mémoire RAM. Nous appellerons ce registre le '''pointeur de table'''. Le pointeur de table est combiné avec l'indice de segment pour adresser le descripteur de segment adéquat. [[File:Segment 2.svg|centre|vignette|upright=2|Traduction d'adresse avec une table des segments, ici appelée table globale des de"scripteurs (terminologie des processeurs Intel x86).]] Un point important est que la table des segments n'est pas accessible pour le programme en cours d'exécution. Il ne peut pas lire le contenu de la table des segments, et encore moins la modifier. L'accès se fait seulement de manière indirecte, en faisant usage des indices de segments, mais c'est un adressage indirect. Seul le système d'exploitation peut lire ou écrire la table des segments directement. Plus haut, j'ai dit que tout accès mémoire impliquait deux accès mémoire : un pour charger le descripteur de segment, un autre pour la lecture/écriture proprement dite. Cependant, cela aurait un impact bien trop grand sur les performances. Dans les faits, les processeurs avec segmentations intégraient un '''cache de descripteurs de segments''', pour limiter la casse. Quand un descripteur de segment est lu depuis la RAM, il est copié dans ce cache. Les accès ultérieurs accédent au descripteur dans le cache, pas besoin de passer par la RAM. L'intel 386 avait un cache de ce type. ===La protection mémoire : les accès hors-segments=== Comme avec la relocation matérielle, le processeur détecte les débordements de segment. Pour cela, il compare l'adresse logique accédée avec l'adresse limite, ou compare la taille limite avec le décalage. De nombreux processeurs, comme l'Intel 386, préféraient utiliser la taille du segment, pour une question d'optimisation. En effet, si on compare l'adresse finale avec l'adresse limite, on doit faire la relocation avant de comparer l'adresse relocatée. Mais en utilisant la taille, ce n'est pas le cas : on peut faire la comparaison avant, pendant ou après la relocation. Un détail à prendre en compte est la taille de la donnée accédée. Sans cela, la comparaison serait très simple : on vérifie si ''décalage <= taille du segment'', ou on compare des adresses de la même manière. Mais imaginez qu'on accède à une donnée de 4 octets : il se peut que l'adresse de ces 4 octets rentre dans le segment, mais que quelques octets débordent. Par exemple, les deux premiers octets sont dans le segment, mais pas les deux suivants. La vraie comparaison est alors : ''décalage + 4 octets <= taille du segment''. Mais il est possible de faire le calcul autrement, et quelques processeurs comme l'Intel 386 ne s'en sont pas privé. Il calculait la différence ''taille du segment - décalage'', et vérifiait le résultat. Le processeur gérait des données de 1, 2 et 4 octets, ce qui fait que le résultat devait être entre 0 et 3. Le processeur prenait le résultat de la soustraction, et vérifiait alors que les 30 bits de poids fort valaient bien 0. Il vérifiait aussi que les deux bits de poids faible avaient la bonne valeur. [[File:Vm7.svg|centre|vignette|upright=2|Traduction d'adresse avec vérification des accès hors-segment.]] Une nouveauté fait son apparition avec la segmentation : la '''gestion des droits d'accès'''. Par exemple, il est possible d'interdire d'exécuter le contenu d'un segment, ce qui fournit une protection contre certaines failles de sécurité ou certains virus. Lorsqu'on exécute une opération interdite, le processeur lève une exception matérielle, à charge du système d'exploitation de gérer la situation. Pour cela, chaque segment se voit attribuer un certain nombre d'autorisations d'accès qui indiquent si l'on peut lire ou écrire dedans, si celui-ci contient un programme exécutable, etc. Les autorisations pour chaque segment sont placées dans le descripteur de segment. Elles se résument généralement à quelques bits, qui indiquent si le segment est accesible en lecture/écriture ou exécutable. Le tout est souvent concaténé dans un ou deux '''octets de droits d'accès'''. L'implémentation de la protection mémoire dépend du CPU considéré. Les CPU microcodés peuvent en théorie utiliser le microcode. Lorsqu'une instruction mémoire s'exécute, le microcode effectue trois étapes : lire le descripteur de segment, faire les tests de protection mémoire, exécuter la lecture/écriture ou lever une exception. Létape de test est réalisée avec un ou plusieurs micro-branchements. Par exemple, une écriture va tester le bit R/W du descripteur, qui indique si on peut écrire dans le segment, en utilisant un micro-branchement. Le micro-branchement enverra vers une routine du microcode en cas d'erreur. Les tests de protection mémoire demandent cependant de tester beaucoup de conditions différentes. Par exemple, le CPU Intel 386 testait moins d'une dizaine de conditions pour certaines instructions. Il est cependant possible de faire plusieurs comparaisons en parallèle en rusant un peu. Il suffit de mémoriser les octets de droits d'accès dans un registre interne, de masquer les bits non-pertinents, et de faire une comparaison avec une constante adéquate, qui encode la valeur que doivent avoir ces bits. Une solution alternative utiliser un circuit combinatoire pour faire les tests de protection mémoire. Les tests sont alors faits en parallèles, plutôt qu'un par un par des micro-branchements. Par contre, le cout en matériel est assez important. Il faut ajouter ce circuit combinatoire, ce qui demande pas mal de circuits. ===La mémoire virtuelle avec la segmentation=== La mémoire virtuelle est une fonctionnalité souvent implémentée sur les processeurs qui gèrent la segmentation, alors que les processeurs avec relocation matérielle s'en passaient. Il faut dire que l'implémentation de la mémoire virtuelle est beaucoup plus simple avec la segmentation, comparé à la relocation matérielle. Le remplacement des registres de base par des sélecteurs de segment facilite grandement l'implémentation. Le problème de la mémoire virtuelle est que les segments peuvent être swappés sur le disque dur n'importe quand, sans que le programme soit prévu. Le swapping est réalisé par une interruption de l'OS, qui peut interrompre le programme n'importe quand. Et si un segment est swappé, le registre de base correspondant devient invalide, il point sur une adresse en RAM où le segment était, mais n'est plus. De plus, les segments peuvent être déplacés en mémoire, là encore n'importe quand et d'une manière invisible par le programme, ce qui fait que les registres de base adéquats doivent être modifiés. Si le programme entier est swappé d'un coup, comme avec la relocation matérielle simple, cela ne pose pas de problèmes. Mais dès qu'on utilise plusieurs registres de base par programme, les choses deviennent soudainement plus compliquées. Le problème est qu'il n'y a pas de mécanismes pour choisir et invalider le registre de base adéquat quand un segment est déplacé/swappé. En théorie, on pourrait imaginer des systèmes qui résolvent le problème au niveau de l'OS, mais tous ont des problèmes qui font que l'implémentation est compliquée ou que les performances sont ridicules. L'usage d'une table des segments accédée à chaque accès résout complètement le problème. La table des segments est accédée à chaque accès mémoire, elle sait si le segment est swappé ou non, chaque accès vérifie si le segment est en mémoire et quelle est son adresse de base. On peut changer le segment de place n'importe quand, le prochain accès récupérera des informations à jour dans la table des segments. L'implémentation de la mémoire virtuelle avec la segmentation est simple : il suffit d'ajouter un bit dans les descripteurs de segments, qui indique si le segment est swappé ou non. Tout le reste, la gestion de ce bit, du swap, et tout ce qui est nécessaire, est délégué au système d'exploitation. Lors de chaque accès mémoire, le processeur vérifie ce bit avant de faire la traduction d'adresse, et déclenche une exception matérielle si le bit indique que le segment est swappé. L'exception matérielle est gérée par l'OS. ===Le partage de segments=== Il est possible de partager un segment entre plusieurs applications. Cela peut servir pour partager des données entre deux programmes : un segment de données partagées est alors partagé entre deux programmes. Partager un segment de code est utile pour les bibliothèques partagées : la bibliothèque est placée dans un segment dédié, qui est partagé entre les programmes qui l'utilisent. Partager un segment de code est aussi utile quand plusieurs instances d'une même application sont lancés simultanément : le code n'ayant pas de raison de changer, celui-ci est partagé entre toutes les instances. Mais ce n'est là qu'un exemple. La première solution pour cela est de configurer les tables de segment convenablement. Le même segment peut avoir des droits d'accès différents selon les processus. Les adresses de base/limite sont identiques, mais les tables des segments ont alors des droits d'accès différents. Mais cette méthode de partage des segments a plusieurs défauts. Premièrement, les sélecteurs de segments ne sont pas les mêmes d'un processus à l'autre, pour un même segment. Le segment partagé peut correspondre au segment numéro 80 dans le premier processus, au segment numéro 1092 dans le second processus. Rien n'impose que les sélecteurs de segment soient les mêmes d'un processus à l'autre, pour un segment identique. Deuxièmement, les adresses limite et de base sont dupliquées dans plusieurs tables de segments. En soi, cette redondance est un souci mineur. Mais une autre conséquence est une question de sécurité : que se passe-t-il si jamais un processus a une table des segments corrompue ? Il se peut que pour un segment identique, deux processus n'aient pas la même adresse limite, ce qui peut causer des failles de sécurité. Un processus peut alors subir un débordement de tampon, ou tout autre forme d'attaque. [[File:Vm9.png|centre|vignette|upright=2|Illustration du partage d'un segment entre deux applications.]] Une seconde solution, complémentaire, utilise une table de segment globale, qui mémorise des segments partagés ou accessibles par tous les processus. Les défauts de la méthode précédente disparaissent avec cette technique : un segment est identifié par un sélecteur unique pour tous les processus, il n'y a pas de duplication des descripteurs de segment. Par contre, elle a plusieurs défauts. Le défaut principal est que cette table des segments est accessible par tous les processus, impossible de ne partager ses segments qu'avec certains pas avec les autres. Un autre défaut est que les droits d'accès à un segment partagé sont identiques pour tous les processus. Impossible d'avoir un segment partagé accessible en lecture seule pour un processus, mais accessible en écriture pour un autre. Il est possible de corriger ces défauts, mais nous en parlerons dans la section sur les architectures à capacité. ===L'extension d'adresse avec la segmentation=== L'extension d'adresse est possible avec la segmentation, de la même manière qu'avec la relocation matérielle. Il suffit juste que les adresses de base soient aussi grandes que le bus d'adresse. Mais il y a une différence avec la relocation matérielle : un même programme peut utiliser plus de mémoire qu'il n'y en a dans l'espace d'adressage. La raison est simple : un segment peut prendre tout l'espace d'adressage, et il y a plusieurs segments par programme. Pour donner un exemple, prenons un processeur 16 bits, qui peut adresser 64 kibioctets, associé à une mémoire de 4 mébioctets. Il est possible de placer le code machine dans les premiers 64k de la mémoire, la pile du programme dans les 64k suivants, le tas dans les 64k encore après, et ainsi de suite. Le programme dépasse donc les 64k de mémoire de l'espace d'adressage. Ce genre de chose est impossible avec la relocation, où un programme est limité par l'espace d'adressage. ===Le mode protégé des processeurs x86=== L'Intel 80286, aussi appelé 286, ajouta un mode de segmentation séparé du mode réel, qui ajoute une protection mémoire à la segmentation, ce qui lui vaut le nom de '''mode protégé'''. Dans ce mode, les registres de segment ne contiennent pas des adresses de base, mais des sélecteurs de segments qui sont utilisés pour l'accès à la table des segments en mémoire RAM. Le 286 bootait en mode réel, puis le système d'exploitation devait faire quelques manipulations pour passer en mode protégé. Le 286 était pensé pour être rétrocompatible au maximum avec le 80186. Mais les différences entre le 286 et le 8086 étaient majeures, au point que les applications devaient être réécrites intégralement pour profiter du mode protégé. Un mode de compatibilité permettait cependant aux applications destinées au 8086 de fonctionner, avec même de meilleures performances. Aussi, le mode protégé resta inutilisé sur la plupart des applications exécutées sur le 286. Vint ensuite le processeur 80386, renommé en 386 quelques années plus tard. Sur ce processeur, les modes réel et protégé sont conservés tel quel, à une différence près : toutes les adresses passent à 32 bits, qu'il s'agisse des adresses de base, limite ou des ''offsets''. Le processeur peut donc adresser un grand nombre de segments : 2^32, soit plus de 4 milliards. Les segments grandissent aussi et passent de 64 KB maximum à 4 gibioctets maximum. Mais surtout : le 386 ajouta le support de la pagination en plus de la segmentation. Ces modifications ont été conservées sur les processeurs 32 bits ultérieurs. Les processeurs x86 gèrent deux types de tables des segments : une table locale pour chaque processus, et une table globale partagée entre tous les processus. Il ne peut y avoir qu'une table locale d'active, vu que le processeur ne peut exécuter qu'un seul processus en même temps. Chaque table locale définit 8192 segments, pareil pour la table globale. La table globale est utilisée pour les segments du noyau et la mémoire partagée entre processus. Un défaut est qu'un segment partagé par la table globale est visible par tous les processus, avec les mêmes droits d'accès. Ce qui fait que cette méthode était peu utilisée en pratique. La table globale mémorise aussi des pointeurs vers les tables locales, avec un descripteur de segment par table locale. Sur les processeurs x86 32 bits, un descripteur de segment est organisé comme suit, pour les architectures 32 bits. On y trouve l'adresse de base et la taille limite, ainsi que de nombreux bits de contrôle. Le premier groupe de bits de contrôle est l'octet en bleu à droite. Il contient : * le bit P qui indique que l'entrée contient un descripteur valide, qu'elle n'est pas vide ; * deux bits DPL qui indiquent le niveau de privilège du segment (noyau, utilisateur, les deux intermédiaires spécifiques au x86) ; * un bit S qui précise si le segment est de type système (utiles pour l'OS) ou un segment de code/données. * un champ Type qui contient les bits suivants : ** un bit E qui indique si le segment contient du code exécutable ou non ; ** le bit RW qui indique s'il est en lecture seule ou non ;; ** Un bit A qui indique que le segment a récemment été accédé, information utile pour l'OS; ** un bit DC assez spécifiques. En haut à gauche, en bleu, on trouve deux bits : * Le bit G indique comment interpréter la taille contenue dans le descripteur : 0 si la taille est exprimée en octets, 1 si la taille est un nombre de pages de 4 kibioctets. Ce bit précise si on utilise la segmentation seule, ou combinée avec la pagination. * Le bit DB précise si l'on utilise des segments en mode de compatibilité 16 bits ou des segments 32 bits. [[File:SegmentDescriptor.svg|centre|vignette|upright=3|Segment Descriptor]] Les indices de segment sont appelés des sélecteurs de segment. Ils ont une taille de 16 bits, mais 3 bits sont utilisés pour encoder des méta-données. Le numéro de segment est donc codé sur 13 bits, ce qui permettait de gérer maximum 8192 segments par table de segment (locale ou globale). Les 16 bits sont organisés comme suit : * 13 bits pour le numéro du segment dans la table des segments, l'indice de segment proprement dit ; * un bit qui précise s'il faut accéder à la table des segments globale ou locale ; * deux bits qui indiquent le niveau de privilège de l'accès au segment (les 4 niveaux de protection, dont l'espace noyau et utilisateur). [[File:SegmentSelector.svg|centre|vignette|upright=1.5|Sélecteur de segment 16 bit.]] En tout, l'indice permet de gérer 8192 segments pour la table locale et 8192 segments de la table globale. ====La MMU du 386/486 : cache de segment, protection mémoire==== La MMU du 386 et celle du 486 étaient assez similaires. Elles étaient plus complexes que celle du 186 et du 286. Elle contenait un additionneur pour les calculs d'adresse et un comparateur pour tester si l'accès mémoire déborde d'un segment. Le test de débordement se faisait en parallèle du calcul de l'adresse finale, comme sur le 286. L'additionneur était un additionneur trois-opérandes, qui additionnait l'adresse à lire/écrire, l'adresse de base du segment, et un décalage intégré dans l'instruction. En clair, l'unité de segmentation n'était pas qu'une MMU, elle prenait en charge une partie du calcul d'adresse. L'avantage est que cela permettait de gérer les modes d'adressage "base + décalage" et "base + indice + décalage" très facilement, en utilisant un minimum de circuits. Une conséquence de cette organisation était que l'usage d'un décalage était gratuit. Par contre, dès qu'on utilisait l'adressage "Base + Indice", avec ou sans décalage, l'instruction prenait un cycle de plus à s'exécuter, parce qu'il fallait faire l'addition "Base + Indice" dans l'ALU entière. Le CPU 386 était le premier à implémenter la protection mémoire avec des segments. Pour cela, il intégrait une '''''Protection Test Unit''''', séparée du microcode, qu'on va abrévier en PTU. Précisément, il s'agissait d'un PLA (''Programmable Logic Array''), une sorte d'intermédiaire entre circuit logique fait sur mesure et mémoire ROM, qu'on a déjà abordé dans le chapitre sur les mémoires ROM. Mais cette unité ne faisait pas tout, le microcode était aussi impliqué. La PTU sera détaillée dans la section suivante. Pour améliorer les performances, le 386 et le 486 intégraient un '''cache de descripteurs de segment''', aussi appelé le cache de descripteurs. Lorsqu'un descripteur état chargé pour la première fois, il était copié dans le cache de descripteurs de segment. Les accès mémoire ultérieur lisaient le descripteur de segment depuis ce cache, pas depuis la table des segments en RAM. Le cache de descripteurs gère aussi bien les segments en mode réel qu'en mode protégé. Récupérer l'adresse de base depuis cache se fait un peu différemment en mode réel et protégé, mais le cache gère cela tout seul. Idem pour récupérer la taille/adresse limite. [[File:Microarchitecture du 386, avec focus sur la segmentation.png|centre|vignette|upright=2|Microarchitecture du 386 et du 486, avec focus sur la segmentation.]] En mode réel, la taille des segment est censée être limitée à 64 kibioctets. Mais le processeur ne vérifiait pas si cette limite était dépassée. À la place, il utilisait la limite précisée dans le cache de segments. Pire que ça : le cache de segment n'était pas réinitialisé quand on passe du mode réel au mode protégé, et réciproquement. Et cette propriété a été à l'origine de l''''''unreal mode'''''. Il s'agissait d'un mode réel amélioré, capable d'utiliser des segments de 4 gibioctets et des adresses de 32 bits. Passer en mode ''unreal'' pouvait se faire de deux manières. Il était possible d'altérer le cache de descripteur en utilisant l'instruction non-documentée LOADALL. Elle permettait de charger les descripteurs de segments dans le cache de descripteurs, avec une taille arbitraire. Une autre solution, beaucoup plus complexe sur le 386, demandait d'entrer en mode protégé pour configurer des segments de grande taille, de charger leurs descripteurs dans le cache de descripteur, puis de revenir en mode réel. En mode réel, les descripteurs dans le cache étaient encore disponibles et on pouvait les lire dans le cache. ====L'implémentation de la protection mémoire sur le 386==== La protection mémoire teste la valeur des bits P, S, X, E, R/W. Elle teste aussi les niveaux de privilège, avec deux bits DPL et CPL. En tout, le processeur pouvait tester 148 conditions différentes en parallèle dans la PTU. Cependant, les niveaux de privilèges étaient pré-traités par le microcode. Le microcode vérifiait aussi s'il y avait une erreur en terme d’anneau mémoire, avec par "exemple un segment en mode noyau accédé alors que le CPU est en espace utilisateur. Il fournissait alors un résultat sur deux bits, qui indiquait s'il y avait une erreur ou non, que la PTU utilisait. Mais toutes les conditions n'étaient pas pertinentes à un instant t. Par exemple, il est pertinent de vérifier si le bit R/W était cohérent si l'instruction à exécuter est une écriture. Mais il n'y a pas besoin de tester le bit E qui indique qu'un segment est exécutable ou non, pour une lecture. En tout, le processeur pouvait se retrouver dans 33 situations possibles, chacune demandant de tester un sous-ensemble des 148 conditions. Pour préciser quel sous-ensembles tester, la PTU recevait un code opération, généré par le microcode. Pour faire les tests de protection mémoire, le microcode avait une micro-opération nommée ''protection test operation'', qui envoyait les droits d'accès à la PTU. Lors de l'exécution d'une ''protection test operation'', le PLA recevait un descripteur de segment, lu depuis la mémoire RAM, ainsi qu'un code opération provenant du microcode. {|class="wikitable" |+ Entrée de la ''Protection Test Unit'' |- ! 15 - 14 !! 13 - 12 !! 11 !! 10 !! 9 !! 8 !! 7 !! 6 !! 5-0 |- | P1 , P2 || || P || S || X || E || R/W || A || Code opération |- | Niveaux de privilèges cohérents/erreur || || Segment présent en mémoire ou swappé || S || X || Segment exécutable ou non || Segment accesible en lecture/écriture || Segment récemment accédé || Code opération |} Il fournissait en sortie un bit qui indiquait si une erreur de protection mémoire avait eu lieu ou non. Il fournissait aussi une adresse de 12 bits, utilisée seulement en cas d'erruer. Elle pointait dans le microcode, sur un code levant une exception en cas d'erreur. Enfin, la PTU fournissait 4 bits pouvant être testés par un branchement dans le microcode. L'un d'entre eux demandait de tester s'il y a un accès hors-limite, les autres étaient assez peu reliés à la protection mémoire. Un détail est que le chargement du descripteur de segment est réalisé par une fonction dans le microcode. Elle est appliquée pour toutes les instructions ou situations qui demandent de faire un accès mémoire. Et les tests de protection mémoire sont réalisés dans cette fonction, pas après elle. Vu qu'il s'agit d'une fonction exécutée quelque soit l'instruction, le microcode doit transférer le code opération à cette fonction. Le microcode est pour cela associé à un registre interne, dans lequel le code opération est mémorisé, avant d'appeler la fonction. Le microcode a une micro-opération PTSAV (''Protection Save'') pour mémoriser le code opération dans ce registre. Dans la fonction qui charge le descripteur, une micro-opération PTOVRR (''Protection Override'') lit le code opération dans ce registre, et lance les tests nécessaires. Il faut noter que le PLA était certes plus rapide que de tester les conditions une par une, mais il était assez lent. La PTU mettait environ 3 cycles d'horloges pour rendre son résultat. Le microcode en profitait alors pour exécuter des micro-opérations durant ces 3 cycles d'attente. Par exemple, le microcode pouvait en profiter pour lire l'adresse de base dans le descripteur, si elle n'a pas été chargée avant (les descripteur était chargé en deux fois). Il fallait cependant que les trois micro-opérations soient valides, peu importe qu'il y ait une erreur de protection mémoire ou non. Ou du moins, elles produisaient un résultat qui n'est pas utilisé en cas d'erreur. Si ce n'était pas possible, le microcode ajoutait des NOP pendant ce temps d'attente de 3 cycles. Le bit A du descripteur de segment indique que le segment a récemment été accédé. Il est mis à jour après les tests de protection mémoire, quand ceux-ci indiquent que l'accès mémoire est autorisé. Le bit A est mis à 1 si la PTU l'autorise. Pour cela, la PTU utilise un des 4 bits de sortie mentionnés plus haut : l'un d'entre eux indique que le bit A doit être mis à 1. La mise à jour est ensuite réalisée par le microcode, qui utilise trois micro-opérations pour le mettre à jour. ====Le ''Hardware task switching'' des CPU x86==== Les systèmes d’exploitation modernes peuvent lancer plusieurs logiciels en même temps. Les logiciels sont alors exécutés à tour de rôle. Passer d'un programme à un autre est ce qui s'appelle une commutation de contexte. Lors d'une commutation de contexte, l'état du processeur est sauvegardé, afin que le programme stoppé puisse reprendre là où il était. Il arrivera un moment où le programme stoppé redémarrera et il doit reprendre dans l'état exact où il s'est arrêté. Deuxièmement, le programme à qui c'est le tour restaure son état. Cela lui permet de revenir là où il était avant d'être stoppé. Il y a donc une sauvegarde et une restauration des registres. Divers processeurs incorporent des optimisations matérielles pour rendre la commutation de contexte plus rapide. Ils peuvent sauvegarder et restaurer les registres du processeur automatiquement lors d'une interruption de commutation de contexte. Les registres sont sauvegardés dans des structures de données en mémoire RAM, appelées des '''contextes matériels'''. Sur les processeurs x86, il s'agit de la technique d{{'}}''Hardware Task Switching''. Fait intéressant, le ''Hardware Task Switching'' se base beaucoup sur les segments mémoires. Avec ''Hardware Task Switching'', chaque contexte matériel est mémorisé dans son propre segment mémoire, séparé des autres. Les segments pour les contextes matériels sont appelés des '''''Task State Segment''''' (TSS). Un TSS mémorise tous les registres généraux, le registre d'état, les pointeurs de pile, le ''program counter'' et quelques registres de contrôle du processeur. Par contre, les registres flottants ne sont pas sauvegardés, de même que certaines registres dit SIMD que nous n'avons pas encore abordé. Et c'est un défaut qui fait que le ''Hardware Task Switching'' n'est plus utilisé. Le programme en cours d'exécution connait l'adresse du TSS qui lui est attribué, car elle est mémorisée dans un registre appelé le '''''Task Register'''''. En plus de pointer sur le TSS, ce registre contient aussi les adresses de base et limite du segment en cours. Pour être plus précis, le ''Task Register'' ne mémorise pas vraiment l'adresse du TSS. À la place, elle mémorise le numéro du segment, le numéro du TSS. Le numéro est codé sur 16 bits, ce qui explique que 65 536 segments sont adressables. Les instructions LDR et STR permettent de lire/écrire ce numéro de segment dans le ''Task Register''. Le démarrage d'un programme a lieu automatiquement dans plusieurs circonstances. La première est une instruction de branchement CALL ou JMP adéquate. Le branchement fournit non pas une adresse à laquelle brancher, mais un numéro de segment qui pointe vers un TSS. Cela permet à une routine du système d'exploitation de restaurer les registres et de démarrer le programme en une seule instruction de branchement. Une seconde circonstance est une interruption matérielle ou une exception, mais nous la mettons de côté. Le ''Task Register'' est alors initialisé avec le numéro de segment fournit. S'en suit la procédure suivante : * Le ''Task Register'' est utilisé pour adresser la table des segments, pour récupérer un pointeur vers le TSS associé. * Le pointeur est utilisé pour une seconde lecture, qui adresse le TSS directement. Celle-ci restaure les registres du processeur. En clair, on va lire le ''TSS descriptor'' dans la GDT, puis on l'utilise pour restaurer les registres du processeur. [[File:Hardware Task Switching x86.png|centre|vignette|upright=2|Hardware Task Switching x86]] ===La segmentation sur les processeurs Burrough B5000 et plus=== Le Burrough B5000 est un très vieil ordinateur, commercialisé à partir de l'année 1961. Ses successeurs reprennent globalement la même architecture. C'était une machine à pile, doublé d'une architecture taguée, choses très rare de nos jours. Mais ce qui va nous intéresser dans ce chapitre est que ce processeur incorporait la segmentation, avec cependant une différence de taille : un programme avait accès à un grand nombre de segments. La limite était de 1024 segments par programme ! Il va de soi que des segments plus petits favorise l'implémentation de la mémoire virtuelle, mais complexifie la relocation et le reste, comme nous allons le voir. Le processeur gère deux types de segments : les segments de données et de procédure/fonction. Les premiers mémorisent un bloc de données, dont le contenu est laissé à l'appréciation du programmeur. Les seconds sont des segments qui contiennent chacun une procédure, une fonction. L'usage des segments est donc différent de ce qu'on a sur les processeurs x86, qui n'avaient qu'un segment unique pour l'intégralité du code machine. Un seul segment de code machine x86 est découpé en un grand nombre de segments de code sur les processeurs Burrough. La table des segments contenait 1024 entrées de 48 bits chacune. Fait intéressant, chaque entrée de la table des segments pouvait mémoriser non seulement un descripteur de segment, mais aussi une valeur flottante ou d'autres types de données ! Parler de table des segments est donc quelque peu trompeur, car cette table ne gère pas que des segments, mais aussi des données. La documentation appelaiat cette table la '''''Program Reference Table''''', ou PRT. La raison de ce choix quelque peu bizarre est que les instructions ne gèrent pas d'adresses proprement dit. Tous les accès mémoire à des données en-dehors de la pile passent par la segmentation, ils précisent tous un indice de segment et un ''offset''. Pour éviter d'allouer un segment pour chaque donnée, les concepteurs du processeur ont décidé qu'une entrée pouvait contenir directement la donnée entière à lire/écrire. La PRT supporte trois types de segments/descripteurs : les descripteurs de données, les descripteurs de programme et les descripteurs d'entrées-sorties. Les premiers décrivent des segments de données. Les seconds sont associés aux segments de procédure/fonction et sont utilisés pour les appels de fonction (qui passent, eux aussi, par la segmentation). Le dernier type de descripteurs sert pour les appels systèmes et les communications avec l'OS ou les périphériques. Chaque entrée de la PRT contient un ''tag'', une suite de bit qui indique le type de l'entrée : est-ce qu'elle contient un descripteur de segment, une donnée, autre. Les descripteurs contiennent aussi un ''bit de présence'' qui indique si le segment a été swappé ou non. Car oui, les segments pouvaient être swappés sur ce processeur, ce qui n'est pas étonnant vu que les segments sont plus petits sur cette architecture. Le descripteur contient aussi l'adresse de base du segment ainsi que sa taille, et diverses informations pour le retrouver sur le disque dur s'il est swappé. : L'adresse mémorisée ne faisait que 15 bits, ce qui permettait d'adresse 32 kibi-mots, soit 192 kibioctets de mémoire. Diverses techniques d'extension d'adressage étaient disponibles pour contourner cette limitation. Outre l'usage de l{{'}}''overlay'', le processeur et l'OS géraient aussi des identifiants d'espace d'adressage et en fournissaient plusieurs par processus. Les processeurs Borrough suivants utilisaient des adresses plus grandes, de 20 bits, ce qui tempérait le problème. [[File:B6700Word.jpg|centre|vignette|upright=2|Structure d'un mot mémoire sur le B6700.]] ==Les architectures à capacités== Les architectures à capacité utilisent la segmentation à granularité fine, mais ajoutent des mécanismes de protection mémoire assez particuliers, qui font que les architectures à capacité se démarquent du reste. Les architectures de ce type sont très rares et sont des processeurs assez anciens. Le premier d'entre eux était le Plessey System 250, qui date de 1969. Il fu suivi par le CAP computer, vendu entre les années 70 et 77. En 1978, le System/38 d'IBM a eu un petit succès commercial. En 1980, la Flex machine a aussi été vendue, mais à très peu d'examplaires, comme les autres architectures à capacité. Et enfin, en 1981, l'architecture à capacité la plus connue, l'Intel iAPX 432 a été commercialisée. Depuis, la seule architecture de ce type est en cours de développement. Il s'agit de l'architecture CHERI, dont la mise en projet date de 2014. ===Le partage de la mémoire sur les architectures à capacités=== Le partage de segment est grandement modifié sur les architectures à capacité. Avec la segmentation normale, il y a une table de segment par processus. Les conséquences sont assez nombreuses, mais la principale est que partager un segment entre plusieurs processus est compliqué. Les défauts ont été évoqués plus haut. Les sélecteurs de segments ne sont pas les mêmes d'un processus à l'autre, pour un même segment. De plus, les adresses limite et de base sont dupliquées dans plusieurs tables de segments, et cela peut causer des problèmes de sécurité si une table des segments est modifiée et pas l'autre. Et il y a d'autres problèmes, tout aussi importants. [[File:Partage des segments avec la segmentation.png|centre|vignette|upright=1.5|Partage des segments avec la segmentation]] À l'opposé, les architectures à capacité utilisent une table des segments unique pour tous les processus. La table des segments unique sera appelée dans de ce qui suit la '''table des segments globale''', ou encore la table globale. En conséquence, les adresses de base et limite ne sont présentes qu'en un seul exemplaire par segment, au lieu d'être dupliquées dans autant de processus que nécessaire. De plus, cela garantit que l'indice de segment est le même quel que soit le processus qui l'utilise. Un défaut de cette approche est au niveau des droits d'accès. Avec la segmentation normale, les droits d'accès pour un segment sont censés changer d'un processus à l'autre. Par exemple, tel processus a accès en lecture seule au segment, l'autre seulement en écriture, etc. Mais ici, avec une table des segments uniques, cela ne marche plus : incorporer les droits d'accès dans la table des segments ferait que tous les processus auraient les mêmes droits d'accès au segment. Et il faut trouver une solution. ===Les capacités sont des pointeurs protégés=== Pour éviter cela, les droits d'accès sont combinés avec les sélecteurs de segments. Les sélecteurs des segments sont remplacés par des '''capacités''', des pointeurs particuliers formés en concaténant l'indice de segment avec les droits d'accès à ce segment. Si un programme veut accéder à une adresse, il fournit une capacité de la forme "sélecteur:droits d'accès", et un décalage qui indique la position de l'adresse dans le segment. Il est impossible d'accéder à un segment sans avoir la capacité associée, c'est là une sécurité importante. Un accès mémoire demande que l'on ait la capacité pour sélectionner le bon segment, mais aussi que les droits d'accès en permettent l'accès demandé. Par contre, les capacités peuvent être passées d'un programme à un autre sans problème, les deux programmes pourront accéder à un segment tant qu'ils disposent de la capacité associée. [[File:Comparaison entre capacités et adresses segmentées.png|centre|vignette|upright=2.5|Comparaison entre capacités et adresses segmentées]] Mais cette solution a deux problèmes très liés. Au niveau des sélecteurs de segment, le problème est que les sélecteur ont une portée globale. Avant, l'indice de segment était interne à un programme, un sélecteur ne permettait pas d'accéder au segment d'un autre programme. Sur les architectures à capacité, les sélecteurs ont une portée globale. Si un programme arrive à forger un sélecteur qui pointe vers un segment d'un autre programme, il peut théoriquement y accéder, à condition que les droits d'accès le permettent. Et c'est là qu'intervient le second problème : les droits d'accès ne sont plus protégés par l'espace noyau. Les droits d'accès étaient dans la table de segment, accessible uniquement en espace noyau, ce qui empêchait un processus de les modifier. Avec une capacité, il faut ajouter des mécanismes de protection qui empêchent un programme de modifier les droits d'accès à un segment et de générer un indice de segment non-prévu. La première sécurité est qu'un programme ne peut pas créer une capacité, seul le système d'exploitation le peut. Les capacités sont forgées lors de l'allocation mémoire, ce qui est du ressort de l'OS. Pour rappel, un programme qui veut du rab de mémoire RAM peut demander au système d'exploitation de lui allouer de la mémoire supplémentaire. Le système d'exploitation renvoie alors un pointeurs qui pointe vers un nouveau segment. Le pointeur est une capacité. Il doit être impossible de forger une capacité, en-dehors d'une demande d'allocation mémoire effectuée par l'OS. Typiquement, la forge d'une capacité se fait avec des instructions du processeur, que seul l'OS peut éxecuter (pensez à une instruction qui n'est accessible qu'en espace noyau). La seconde protection est que les capacités ne peuvent pas être modifiées sans raison valable, que ce soit pour l'indice de segment ou les droits d'accès. L'indice de segment ne peut pas être modifié, quelqu'en soit la raison. Pour les droits d'accès, la situation est plus compliquée. Il est possible de modifier ses droits d'accès, mais sous conditions. Réduire les droits d'accès d'une capacité est possible, que ce soit en espace noyau ou utilisateur, pas l'OS ou un programme utilisateur, avec une instruction dédiée. Mais augmenter les droits d'accès, seul l'OS peut le faire avec une instruction précise, souvent exécutable seulement en espace noyau. Les capacités peuvent être copiées, et même transférées d'un processus à un autre. Les capacités peuvent être détruites, ce qui permet de libérer la mémoire utilisée par un segment. La copie d'une capacité est contrôlée par l'OS et ne peut se faire que sous conditions. La destruction d'une capacité est par contre possible par tous les processus. La destruction ne signifie pas que le segment est effacé, il est possible que d'autres processus utilisent encore des copies de la capacité, et donc le segment associé. On verra quand la mémoire est libérée plus bas. Protéger les capacités demande plusieurs conditions. Premièrement, le processeur doit faire la distinction entre une capacité et une donnée. Deuxièmement, les capacités ne peuvent être modifiées que par des instructions spécifiques, dont l'exécution est protégée, réservée au noyau. En clair, il doit y avoir une séparation matérielle des capacités, qui sont placées dans des registres séparés. Pour cela, deux solutions sont possibles : soit les capacités remplacent les adresses et sont dispersées en mémoire, soit elles sont regroupées dans un segment protégé. ====La liste des capacités==== Avec la première solution, on regroupe les capacités dans un segment protégé. Chaque programme a accès à un certain nombre de segments et à autant de capacités. Les capacités d'un programme sont souvent regroupées dans une '''liste de capacités''', appelée la '''''C-list'''''. Elle est généralement placée en mémoire RAM. Elle est ce qu'il reste de la table des segments du processus, sauf que cette table ne contient pas les adresses du segment, qui sont dans la table globale. Tout se passe comme si la table des segments de chaque processus est donc scindée en deux : la table globale partagée entre tous les processus contient les informations sur les limites des segments, la ''C-list'' mémorise les droits d'accès et les sélecteurs pour identifier chaque segment. C'est un niveau d'indirection supplémentaire par rapport à la segmentation usuelle. [[File:Architectures à capacité.png|centre|vignette|upright=2|Architectures à capacité]] La liste de capacité est lisible par le programme, qui peut copier librement les capacités dans les registres. Par contre, la liste des capacités est protégée en écriture. Pour le programme, il est impossible de modifier les capacités dedans, impossible d'en rajouter, d'en forger, d'en retirer. De même, il ne peut pas accéder aux segments des autres programmes : il n'a pas les capacités pour adresser ces segments. Pour protéger la ''C-list'' en écriture, la solution la plus utilisée consiste à placer la ''C-list'' dans un segment dédié. Le processeur gère donc plusieurs types de segments : les segments de capacité pour les ''C-list'', les autres types segments pour le reste. Un défaut de cette approche est que les adresses/capacités sont séparées des données. Or, les programmeurs mixent souvent adresses et données, notamment quand ils doivent manipuler des structures de données comme des listes chainées, des arbres, des graphes, etc. L'usage d'une ''C-list'' permet de se passer de la séparation entre espace noyau et utilisateur ! Les segments de capacité sont eux-mêmes adressés par leur propre capacité, avec une capacité par segment de capacité. Le programme a accès à la liste de capacité, comme l'OS, mais leurs droits d'accès ne sont pas les mêmes. Le programme a une capacité vers la ''C-list'' qui n'autorise pas l'écriture, l'OS a une autre capacité qui accepte l'écriture. Les programmes ne pourront pas forger les capacités permettant de modifier les segments de capacité. Une méthode alternative est de ne permettre l'accès aux segments de capacité qu'en espace noyau, mais elle est redondante avec la méthode précédente et moins puissante. ====Les capacités dispersées, les architectures taguées==== Une solution alternative laisse les capacités dispersées en mémoire. Les capacités remplacent les adresses/pointeurs, et elles se trouvent aux mêmes endroits : sur la pile, dans le tas. Comme c'est le cas dans les programmes modernes, chaque allocation mémoire renvoie une capacité, que le programme gére comme il veut. Il peut les mettre dans des structures de données, les placer sur la pile, dans des variables en mémoire, etc. Mais il faut alors distinguer si un mot mémoire contient une capacité ou une autre donnée, les deux ne devant pas être mixés. Pour cela, chaque mot mémoire se voit attribuer un certain bit qui indique s'il s'agit d'un pointeur/capacité ou d'autre chose. Mais cela demande un support matériel, ce qui fait que le processeur devient ce qu'on appelle une ''architecture à tags'', ou ''tagged architectures''. Ici, elles indiquent si le mot mémoire contient une adresse:capacité ou une donnée. [[File:Architectures à capacité sans liste de capacité.png|centre|vignette|upright=2|Architectures à capacité sans liste de capacité]] L'inconvénient est le cout en matériel de cette solution. Il faut ajouter un bit à chaque case mémoire, le processeur doit vérifier les tags avant chaque opération d'accès mémoire, etc. De plus, tous les mots mémoire ont la même taille, ce qui force les capacités à avoir la même taille qu'un entier. Ce qui est compliqué. ===Les registres de capacité=== Les architectures à capacité disposent de registres spécialisés pour les capacités, séparés pour les entiers. La raison principale est une question de sécurité, mais aussi une solution pragmatique au fait que capacités et entiers n'ont pas la même taille. Les registres dédiés aux capacités ne mémorisent pas toujours des capacités proprement dites. À la place, ils mémorisent des descripteurs de segment, qui contiennent l'adresse de base, limite et les droits d'accès. Ils sont utilisés pour la relocation des accès mémoire ultérieurs. Ils sont en réalité identiques aux registres de relocation, voire aux registres de segments. Leur utilité est d'accélérer la relocation, entre autres. Les processeurs à capacité ne gèrent pas d'adresses proprement dit, comme pour la segmentation avec plusieurs registres de relocation. Les accès mémoire doivent préciser deux choses : à quel segment on veut accéder, à quelle position dans le segment se trouve la donnée accédée. La première information se trouve dans le mal nommé "registre de capacité", la seconde information est fournie par l'instruction d'accès mémoire soit dans un registre (Base+Index), soit en adressage base+''offset''. Les registres de capacités sont accessibles à travers des instructions spécialisées. Le processeur ajoute des instructions LOAD/STORE pour les échanges entre table des segments et registres de capacité. Ces instructions sont disponibles en espace utilisateur, pas seulement en espace noyau. Lors du chargement d'une capacité dans ces registres, le processeur vérifie que la capacité chargée est valide, et que les droits d'accès sont corrects. Puis, il accède à la table des segments, récupère les adresses de base et limite, et les mémorise dans le registre de capacité. Les droits d'accès et d'autres méta-données sont aussi mémorisées dans le registre de capacité. En somme, l'instruction de chargement prend une capacité et charge un descripteur de segment dans le registre. Avec ce genre de mécanismes, il devient difficile d’exécuter certains types d'attaques, ce qui est un gage de sureté de fonctionnement indéniable. Du moins, c'est la théorie, car tout repose sur l'intégrité des listes de capacité. Si on peut modifier celles-ci, alors il devient facile de pouvoir accéder à des objets auxquels on n’aurait pas eu droit. ===Le recyclage de mémoire matériel=== Les architectures à capacité séparent les adresses/capacités des nombres entiers. Et cela facilite grandement l'implémentation de la ''garbage collection'', ou '''recyclage de la mémoire''', à savoir un ensemble de techniques logicielles qui visent à libérer la mémoire inutilisée. Rappelons que les programmes peuvent demander à l'OS un rab de mémoire pour y placer quelque chose, généralement une structure de donnée ou un objet. Mais il arrive un moment où cet objet n'est plus utilisé par le programme. Il peut alors demander à l'OS de libérer la portion de mémoire réservée. Sur les architectures à capacité, cela revient à libérer un segment, devenu inutile. La mémoire utilisée par ce segment est alors considérée comme libre, et peut être utilisée pour autre chose. Mais il arrive que les programmes ne libèrent pas le segment en question. Soit parce que le programmeur a mal codé son programme, soit parce que le compilateur n'a pas fait du bon travail ou pour d'autres raisons. Pour éviter cela, les langages de programmation actuels incorporent des '''''garbage collectors''''', des morceaux de code qui scannent la mémoire et détectent les segments inutiles. Pour cela, ils doivent identifier les adresses manipulées par le programme. Si une adresse pointe vers un objet, alors celui-ci est accessible, il sera potentiellement utilisé dans le futur. Mais si aucune adresse ne pointe vers l'objet, alors il est inaccessible et ne sera plus jamais utilisé dans le futur. On peut libérer les objets inaccessibles. Identifier les adresses est cependant très compliqué sur les architectures normales. Sur les processeurs modernes, les ''garbage collectors'' scannent la pile à la recherche des adresses, et considèrent tout mot mémoire comme une adresse potentielle. Mais les architectures à capacité rendent le recyclage de la mémoire très facile. Un segment est accessible si le programme dispose d'une capacité qui pointe vers ce segment, rien de plus. Et les capacités sont facilement identifiables : soit elles sont dans la liste des capacités, soit on peut les identifier à partir de leur ''tag''. Le recyclage de mémoire était parfois implémenté directement en matériel. En soi, son implémentation est assez simple, et peu être réalisé dans le microcode d'un processeur. Une autre solution consiste à utiliser un second processeur, spécialement dédié au recyclage de mémoire, qui exécute un programme spécialement codé pour. Le programme en question est placé dans une mémoire ROM, reliée directement à ce second processeur. ===L'intel iAPX 432=== Voyons maintenat une architecture à capacité assez connue : l'Intel iAPX 432. Oui, vous avez bien lu : Intel a bel et bien réalisé un processeur orienté objet dans sa jeunesse. La conception du processeur Intel iAPX 432 commença en 1975, afin de créer un successeur digne de ce nom aux processeurs 8008 et 8080. La conception du processeur Intel iAPX 432 commença en 1975, afin de créer un successeur digne de ce nom aux processeurs 8008 et 8080. Ce processeur s'est très faiblement vendu en raison de ses performances assez désastreuses et de défauts techniques certains. Par exemple, ce processeur était une machine à pile à une époque où celles-ci étaient tombées en désuétude, il ne pouvait pas effectuer directement de calculs avec des constantes entières autres que 0 et 1, ses instructions avaient un alignement bizarre (elles étaient bit-alignées). Il avait été conçu pour maximiser la compatibilité avec le langage ADA, un langage assez peu utilisé, sans compter que le compilateur pour ce processeur était mauvais. ====Les segments prédéfinis de l'Intel iAPX 432==== L'Intel iAPX432 gère plusieurs types de segments. Rien d'étonnant à cela, les Burrough géraient eux aussi plusieurs types de segments, à savoir des segments de programmes, des segments de données, et des segments d'I/O. C'est la même chose sur l'Intel iAPX 432, mais en bien pire ! Les segments de données sont des segments génériques, dans lequels on peut mettre ce qu'on veut, suivant les besoins du programmeur. Ils sont tous découpés en deux parties de tailles égales : une partie contenant les données de l'objet et une partie pour les capacités. Les capacités d'un segment pointent vers d'autres segments, ce qui permet de créer des structures de données assez complexes. La ligne de démarcation peut être placée n'importe où dans le segment, les deux portions ne sont pas de taille identique, elles ont des tailles qui varient de segment en segment. Il est même possible de réserver le segment entier à des données sans y mettre de capacités, ou inversement. Les capacités et données sont adressées à partir de la ligne de démarcation, qui sert d'adresse de base du segment. Suivant l'instruction utilisée, le processeur accède à la bonne portion du segment. Le processeur supporte aussi d'autres segments pré-définis, qui sont surtout utilisés par le système d'exploitation : * Des segments d'instructions, qui contiennent du code exécutable, typiquement un programme ou des fonctions, parfois des ''threads''. * Des segments de processus, qui mémorisent des processus entiers. Ces segments contiennent des capacités qui pointent vers d'autres segments, notamment un ou plusieurs segments de code, et des segments de données. * Des segments de domaine, pour les modules ou bibliothèques dynamiques. * Des segments de contexte, utilisés pour mémoriser l'état d'un processus, utilisés par l'OS pour faire de la commutation de contexte. * Des segments de message, utilisés pour la communication entre processus par l'intermédiaire de messages. * Et bien d'autres encores. Sur l'Intel iAPX 432, chaque processus est considéré comme un objet à part entière, qui a son propre segment de processus. De même, l'état du processeur (le programme qu'il est en train d’exécuter, son état, etc.) est stocké en mémoire dans un segment de contexte. Il en est de même pour chaque fonction présente en mémoire : elle était encapsulée dans un segment, sur lequel seules quelques manipulations étaient possibles (l’exécuter, notamment). Et ne parlons pas des appels de fonctions qui stockaient l'état de l'appelé directement dans un objet spécial. Bref, de nombreux objets système sont prédéfinis par le processeur : les objets stockant des fonctions, les objets stockant des processus, etc. L'Intel 432 possédait dans ses circuits un ''garbage collector'' matériel. Pour faciliter son fonctionnement, certains bits de l'objet permettaient de savoir si l'objet en question pouvait être supprimé ou non. ====Le support de la segmentation sur l'Intel iAPX 432==== La table des segments est une table hiérarchique, à deux niveaux. Le premier niveau est une ''Object Table Directory'', qui réside toujours en mémoire RAM. Elle contient des descripteurs qui pointent vers des tables secondaires, appelées des ''Object Table''. Il y a plusieurs ''Object Table'', typiquement une par processus. Plusieurs processus peuvent partager la même ''Object Table''. Les ''Object Table'' peuvent être swappées, mais pas l{{'}}''Object Table Directory''. Une capacité tient compte de l'organisation hiérarchique de la table des segments. Elle contient un indice qui précise quelle ''Object Table'' utiliser, et l'indice du segment dans cette ''Object Table''. Le premier indice adresse l{{'}}''Object Table Directory'' et récupère un descripteur de segment qui pointe sur la bonne ''Object Table''. Le second indice est alors utilisé pour lire l'adresse de base adéquate dans cette ''Object Table''. La capacité contient aussi des droits d'accès en lecture, écriture, suppression et copie. Il y a aussi un champ pour le type, qu'on verra plus bas. Au fait : les capacités étaient appelées des ''Access Descriptors'' dans la documentation officielle. Une capacité fait 32 bits, avec un octet utilisé pour les droits d'accès, laissant 24 bits pour adresser les segments. Le processeur gérait jusqu'à 2^24 segments/objets différents, pouvant mesurer jusqu'à 64 kibioctets chacun, ce qui fait 2^40 adresses différentes, soit 1024 gibioctets. Les 24 bits pour adresser les segments sont partagés moitié-moitié pour l'adressage des tables, ce qui fait 4096 ''Object Table'' différentes dans l{{'}}''Object Table Directory'', et chaque ''Object Table'' contient 4096 segments. ====Le jeu d'instruction de l'Intel iAPX 432==== L'Intel iAPX 432 est une machine à pile. Le jeu d'instruction de l'Intel iAPX 432 gère pas moins de 230 instructions différentes. Il gére deux types d'instructions : les instructions normales, et celles qui manipulent des segments/objets. Les premières permettent de manipuler des nombres entiers, des caractères, des chaînes de caractères, des tableaux, etc. Les secondes sont spécialement dédiées à la manipulation des capacités. Il y a une instruction pour copier une capacité, une autre pour invalider une capacité, une autre pour augmenter ses droits d'accès (instruction sécurisée, exécutable seulement sous certaines conditions), une autre pour restreindre ses droits d'accès. deux autres instructions créent un segment et renvoient la capacité associée, la première créant un segment typé, l'autre non. le processeur gérait aussi des instructions spécialement dédiées à la programmation système et idéales pour programmer des systèmes d'exploitation. De nombreuses instructions permettaient ainsi de commuter des processus, faire des transferts de messages entre processus, etc. Environ 40 % du micro-code était ainsi spécialement dédié à ces instructions spéciales. Les instructions sont de longueur variable et peuvent prendre n'importe quelle taille comprise entre 10 et 300 bits, sans vraiment de restriction de taille. Les bits d'une instruction sont regroupés en 4 grands blocs, 4 champs, qui ont chacun une signification particulière. * Le premier est l'opcode de l'instruction. * Le champ référence, doit être interprété différemment suivant la donnée à manipuler. Si cette donnée est un entier, un caractère ou un flottant, ce champ indique l'emplacement de la donnée en mémoire. Alors que si l'instruction manipule un objet, ce champ spécifie la capacité de l'objet en question. Ce champ est assez complexe et il est sacrément bien organisé. * Le champ format, n'utilise que 4 bits et a pour but de préciser si les données à manipuler sont en mémoire ou sur la pile. * Le champ classe permet de dire combien de données différentes l'instruction va devoir manipuler, et quelles seront leurs tailles. [[File:Encodage des instructions de l'Intel iAPX-432.png|centre|vignette|upright=2|Encodage des instructions de l'Intel iAPX-432.]] ====Le support de l'orienté objet sur l'Intel iAPX 432==== L'Intel 432 permet de définir des objets, qui correspondent aux classes des langages orientés objets. L'Intel 432 permet, à partir de fonctions définies par le programmeur, de créer des '''''domain objects''''', qui correspondent à une classe. Un ''domain object'' est un segment de capacité, dont les capacités pointent vers des fonctions ou un/plusieurs objets. Les fonctions et les objets sont chacun placés dans un segment. Une partie des fonctions/objets sont publics, ce qui signifie qu'ils sont accessibles en lecture par l'extérieur. Les autres sont privées, inaccessibles aussi bien en lecture qu'en écriture. L'exécution d'une fonction demande que le branchement fournisse deux choses : une capacité vers le ''domain object'', et la position de la fonction à exécuter dans le segment. La position permet de localiser la capacité de la fonction à exécuter. En clair, on accède au ''domain object'' d'abord, pour récupérer la capacité qui pointe vers la fonction à exécuter. Il est aussi possible pour le programmeur de définir de nouveaux types non supportés par le processeur, en faisant appel au système d'exploitation de l'ordinateur. Au niveau du processeur, chaque objet est typé au niveau de son object descriptor : celui-ci contient des informations qui permettent de déterminer le type de l'objet. Chaque type se voit attribuer un domain object qui contient toutes les fonctions capables de manipuler les objets de ce type et que l'on appelle le type manager. Lorsque l'on veut manipuler un objet d'un certain type, il suffit d'accéder à une capacité spéciale (le TCO) qui pointera dans ce type manager et qui précisera quel est l'objet à manipuler (en sélectionnant la bonne entrée dans la liste de capacité). Le type d'un objet prédéfini par le processeur est ainsi spécifié par une suite de 8 bits, tandis que le type d'un objet défini par le programmeur est défini par la capacité spéciale pointant vers son type manager. ===Conclusion=== Pour ceux qui veulent en savoir plus, je conseille la lecture de ce livre, disponible gratuitement sur internet (merci à l'auteur pour cette mise à disposition) : * [https://homes.cs.washington.edu/~levy/capabook/ Capability-Based Computer Systems]. Voici un document qui décrit le fonctionnement de l'Intel iAPX432 : * [https://homes.cs.washington.edu/~levy/capabook/Chapter9.pdf The Intel iAPX 432 ] ==La pagination== Avec la pagination, la mémoire est découpée en blocs de taille fixe, appelés des '''pages mémoires'''. La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Mais elles sont de taille fixe : on ne peut pas en changer la taille. C'est la différence avec les segments, qui sont de taille variable. Le contenu d'une page en mémoire fictive est rigoureusement le même que le contenu de la page correspondante en mémoire physique. L'espace d'adressage est découpé en '''pages logiques''', alors que la mémoire physique est découpée en '''pages physique''' de même taille. Les pages logiques correspondent soit à une page physique, soit à une page swappée sur le disque dur. Quand une page logique est associée à une page physique, les deux ont le même contenu, mais pas les mêmes adresses. Les pages logiques sont numérotées, en partant de 0, afin de pouvoir les identifier/sélectionner. Même chose pour les pages physiques, qui sont elles aussi numérotées en partant de 0. [[File:Principe de la pagination.png|centre|vignette|upright=2|Principe de la pagination.]] Pour information, le tout premier processeur avec un système de mémoire virtuelle était le super-ordinateur Atlas. Il utilisait la pagination, et non la segmentation. Mais il fallu du temps avant que la méthode de la pagination prenne son essor dans les processeurs commerciaux x86. Un point important est que la pagination implique une coopération entre OS et hardware, les deux étant fortement mélés. Une partie des informations de cette section auraient tout autant leur place dans le wikilivre sur les systèmes d'exploitation, mais il est plus simple d'en parler ici. ===La mémoire virtuelle : le ''swapping'' et le remplacement des pages mémoires=== Le système d'exploitation mémorise des informations sur toutes les pages existantes dans une '''table des pages'''. C'est un tableau où chaque ligne est associée à une page logique. Une ligne contient un bit ''Valid'' qui indique si la page logique associée est swappée sur le disque dur ou non, et la position de la page physique correspondante en mémoire RAM. Elle peut aussi contenir des bits pour la protection mémoire, et bien d'autres. Les lignes sont aussi appelées des ''entrées de la table des pages'' [[File:Gestionnaire de mémoire virtuelle - Pagination et swapping.png|centre|vignette|upright=2|Table des pages.]] De plus, le système d'exploitation conserve une '''liste des pages vides'''. Le nom est assez clair : c'est une liste de toutes les pages de la mémoire physique qui sont inutilisées, qui ne sont allouées à aucun processus. Ces pages sont de la mémoire libre, utilisable à volonté. La liste des pages vides est mise à jour à chaque fois qu'un programme réserve de la mémoire, des pages sont alors prises dans cette liste et sont allouées au programme demandeur. ====Les défauts de page==== Lorsque l'on veut traduire l'adresse logique d'une page mémoire, le processeur vérifie le bit ''Valid'' et l'adresse physique. Si le bit ''Valid'' est à 1 et que l'adresse physique est présente, la traduction d'adresse s'effectue normalement. Mais si ce n'est pas le cas, l'entrée de la table des pages ne contient pas de quoi faire la traduction d'adresse. Soit parce que la page est swappée sur le disque dur et qu'il faut la copier en RAM, soit parce que les droits d'accès ne le permettent pas, soit parce que la page n'a pas encore été allouée, etc. On fait alors face à un '''défaut de page'''. Un défaut de page a lieu quand la MMU ne peut pas associer l'adresse logique à une adresse physique, quelque qu'en soit la raison. Il existe deux types de défauts de page : mineurs et majeurs. Un '''défaut de page majeur''' a lieu quand on veut accéder à une page déplacée sur le disque dur. Un défaut de page majeur lève une exception matérielle dont la routine rapatriera la page en mémoire RAM. S'il y a de la place en mémoire RAM, il suffit d'allouer une page vide et d'y copier la page chargée depuis le disque dur. Mais si ce n'est par le cas, on va devoir faire de la place en RAM en déplaçant une page mémoire de la RAM vers le disque dur. Dans tous les cas, c'est le système d'exploitation qui s'occupe du chargement de la page, le processeur n'est pas impliqué. Une fois la page chargée, la table des pages est mise à jour et la traduction d'adresse peut recommencer. Si je dis recommencer, c'est car l'accès mémoire initial est rejoué à l'identique, sauf que la traduction d'adresse réussit cette fois-ci. Un '''défaut de page mineur''' a lieu dans des circonstances pas très intuitives : la page est en mémoire physique, mais l'adresse physique de la page n'est pas accessible. Par exemple, il est possible que des sécurités empêchent de faire la traduction d'adresse, pour des raisons de protection mémoire. Une autre raison est la gestion des adresses synonymes, qui surviennent quand on utilise des libraires partagées entre programmes, de la communication inter-processus, des optimisations de type ''copy-on-write'', etc. Enfin, une dernière raison est que la page a été allouée à un programme par le système d'exploitation, mais qu'il n'a pas encore attribué sa position en mémoire. Pour comprendre comment c'est possible, parlons rapidement de l'allocation paresseuse. Imaginons qu'un programme fasse une demande d'allocation mémoire et se voit donc attribuer une ou plusieurs pages logiques. L'OS peut alors réagir de deux manières différentes. La première est d'attribuer une page physique immédiatement, en même temps que la page logique. En faisant ainsi, on ne peut pas avoir de défaut mineur, sauf en cas de problème de protection mémoire. Cette solution est simple, on l'appelle l{{'}}'''allocation immédiate'''. Une autre solution consiste à attribuer une page logique, mais l'allocation de la page physique se fait plus tard. Elle a lieu la première fois que le programme tente d'écrire/lire dans la page physique. Un défaut mineur a lieu, et c'est lui qui force l'OS à attribuer une page physique pour la page logique demandée. On parle alors d{{'}}'''allocation paresseuse'''. L'avantage est que l'on gagne en performance si des pages logiques sont allouées mais utilisées, ce qui peut arriver. Une optimisation permise par l'existence des défauts mineurs est le '''''copy-on-write'''''. Le but est d'optimiser la copie d'une page logique dans une autre. L'idée est que la copie est retardée quand elle est vraiment nécessaire, à savoir quand on écrit dans la copie. Tant que l'on ne modifie pas la copie, les deux pages logiques, originelle et copiée, pointent vers la même page physique. A quoi bon avoir deux copies avec le même contenu ? Par contre, la page physique est marquée en lecture seule. La moindre écriture déclenche une erreur de protection mémoire, et un défaut mineur. Celui-ci est géré par l'OS, qui effectue alors la copie dans une nouvelle page physique. Je viens de dire que le système d'exploitation gère les défauts de page majeurs/mineurs. Un défaut de page déclenche une exception matérielle, qui passe la main au système d'exploitation. Le système d'exploitation doit alors déterminer ce qui a levé l'exception, notamment identifier si c'est un défaut de page mineur ou majeur. Pour cela, le processeur a un ou plusieurs '''registres de statut''' qui indique l'état du processeur, qui sont utiles pour gérer les défauts de page. Ils indiquent quelle est l'adresse fautive, si l'accès était une lecture ou écriture, si l'accès a eu lieu en espace noyau ou utilisateur (les espaces mémoire ne sont pas les mêmes), etc. Les registres en question varient grandement d'une architecture de processeur à l'autre, aussi on ne peut pas dire grand chose de plus sur le sujet. Le reste est de toute façon à voir dans un cours sur les systèmes d'exploitation. ====Le remplacement des pages==== Les pages virtuelles font référence soit à une page en mémoire physique, soit à une page sur le disque dur. Mais l'on ne peut pas lire une page directement depuis le disque dur. Les pages sur le disque dur doivent être chargées en RAM, avant d'être utilisables. Ce n'est possible que si on a une page mémoire vide, libre. Si ce n'est pas le cas, on doit faire de la place en swappant une page sur le disque dur. Les pages font ainsi une sorte de va et vient entre le fichier d'échange et la RAM, suivant les besoins. Tout cela est effectué par une routine d'interruption du système d'exploitation, le processeur n'ayant pas vraiment de rôle là-dedans. Supposons que l'on veuille faire de la place en RAM pour une nouvelle page. Dans une implémentation naïve, on trouve une page à évincer de la mémoire, qui est copiée dans le ''swapfile''. Toutes les pages évincées sont alors copiées sur le disque dur, à chaque remplacement. Néanmoins, cette implémentation naïve peut cependant être améliorée si on tient compte d'un point important : si la page a été modifiée depuis le dernier accès. Si le programme/processeur a écrit dans la page, alors celle-ci a été modifiée et doit être sauvegardée sur le ''swapfile'' si elle est évincée. Par contre, si ce n'est pas le cas, la page est soit initialisée, soit déjà présente à l'identique dans le ''swapfile''. Mais cette optimisation demande de savoir si une écriture a eu lieu dans la page. Pour cela, on ajoute un '''''dirty bit''''' à chaque entrée de la table des pages, juste à côté du bit ''Valid''. Il indique si une écriture a eu lieu dans la page depuis qu'elle a été chargée en RAM. Ce bit est mis à jour par le processeur, automatiquement, lors d'une écriture. Par contre, il est remis à zéro par le système d'exploitation, quand la page est chargée en RAM. Si le programme se voit allouer de la mémoire, il reçoit une page vide, et ce bit est initialisé à 0. Il est mis à 1 si la mémoire est utilisée. Quand la page est ensuite swappée sur le disque dur, ce bit est remis à 0 après la sauvegarde. Sur la majorité des systèmes d'exploitation, il est possible d'interdire le déplacement de certaines pages sur le disque dur. Ces pages restent alors en mémoire RAM durant un temps plus ou moins long, parfois en permanence. Cette possibilité simplifie la vie des programmeurs qui conçoivent des systèmes d'exploitation : essayez d'exécuter l'interruption pour les défauts de page alors que la page contenant le code de l'interruption est placée sur le disque dur ! Là encore, cela demande d'ajouter un bit dans chaque entrée de la table des pages, qui indique si la page est swappable ou non. Le bit en question s'appelle souvent le '''bit ''swappable'''''. ====Les algorithmes de remplacement des pages pris en charge par l'OS==== Le choix de la page doit être fait avec le plus grand soin et il existe différents algorithmes qui permettent de décider quelle page supprimer de la RAM. Leur but est de swapper des pages qui ne seront pas accédées dans le futur, pour éviter d'avoir à faire triop de va-et-vient entre RAM et ''swapfile''. Les données qui sont censées être accédées dans le futur doivent rester en RAM et ne pas être swappées, autant que possible. Les algorithmes les plus simples pour le choix de page à évincer sont les suivants. Le plus simple est un algorithme aléatoire : on choisit la page au hasard. Mine de rien, cet algorithme est très simple à implémenter et très rapide à exécuter. Il ne demande pas de modifier la table des pages, ni même d'accéder à celle-ci pour faire son choix. Ses performances sont surprenamment correctes, bien que largement en-dessous de tous les autres algorithmes. L'algorithme FIFO supprime la donnée qui a été chargée dans la mémoire avant toutes les autres. Cet algorithme fonctionne bien quand un programme manipule des tableaux de grande taille, mais fonctionne assez mal dans le cas général. L'algorithme LRU supprime la donnée qui été lue ou écrite pour la dernière fois avant toutes les autres. C'est théoriquement le plus efficace dans la majorité des situations. Malheureusement, son implémentation est assez complexe et les OS doivent modifier la table des pages pour l'implémenter. L'algorithme le plus utilisé de nos jours est l{{'}}'''algorithme NRU''' (''Not Recently Used''), une simplification drastique du LRU. Il fait la différence entre les pages accédées il y a longtemps et celles accédées récemment, d'une manière très binaire. Les deux types de page sont appelés respectivement les '''pages froides''' et les '''pages chaudes'''. L'OS swappe en priorité les pages froides et ne swappe de page chaude que si aucune page froide n'est présente. L'algorithme est simple : il choisit la page à évincer au hasard parmi une page froide. Si aucune page froide n'est présente, alors il swappe au hasard une page chaude. Pour implémenter l'algorithme NRU, l'OS mémorise, dans chaque entrée de la table des pages, si la page associée est froide ou chaude. Pour cela, il met à 0 ou 1 un bit dédié : le '''bit ''Accessed'''''. La différence avec le bit ''dirty'' est que le bit ''dirty'' est mis à jour uniquement lors des écritures, alors que le bit ''Accessed'' l'est aussi lors d'une lecture. Uen lecture met à 1 le bit ''Accessed'', mais ne touche pas au bit ''dirty''. Les écritures mettent les deux bits à 1. Implémenter l'algorithme NRU demande juste de mettre à jour le bit ''Accessed'' de chaque entrée de la table des pages. Et sur les architectures modernes, le processeur s'en charge automatiquement. A chaque accès mémoire, que ce soit en lecture ou en écriture, le processeur met à 1 ce bit. Par contre, le système d'exploitation le met à 0 à intervalles réguliers. En conséquence, quand un remplacement de page doit avoir lieu, les pages chaudes ont de bonnes chances d'avoir le bit ''Accessed'' à 1, alors que les pages froides l'ont à 0. Ce n'est pas certain, et on peut se trouver dans des cas où ce n'est pas le cas. Par exemple, si un remplacement a lieu juste après la remise à zéro des bits ''Accessed''. Le choix de la page à remplacer est donc imparfait, mais fonctionne bien en pratique. Tous les algorithmes précédents ont chacun deux variantes : une locale, et une globale. Avec la version locale, la page qui va être rapatriée sur le disque dur est une page réservée au programme qui est la cause du page miss. Avec la version globale, le système d'exploitation va choisir la page à virer parmi toutes les pages présentes en mémoire vive. ===La protection mémoire avec la pagination=== Avec la pagination, chaque page a des '''droits d'accès''' précis, qui permettent d'autoriser ou interdire les accès en lecture, écriture, exécution, etc. La table des pages mémorise les autorisations pour chaque page, sous la forme d'une suite de bits où chaque bit autorise/interdit une opération bien précise. En pratique, les tables de pages modernes disposent de trois bits : un qui autorise/interdit les accès en lecture, un qui autorise/interdit les accès en écriture, un qui autorise/interdit l'éxecution du contenu de la page. Le format exact de la suite de bits a cependant changé dans le temps sur les processeurs x86 modernes. Par exemple, avant le passage au 64 bits, les CPU et OS ne pouvaient pas marquer une page mémoire comme non-exécutable. C'est seulement avec le passage au 64 bits qu'a été ajouté un bit pour interdire l'exécution de code depuis une page. Ce bit, nommé '''bit NX''', est à 0 si la page n'est pas exécutable et à 1 sinon. Le processeur vérifie à chaque chargement d'instruction si le bit NX de page lue est à 1. Sinon, il lève une exception matérielle et laisse la main à l'OS. Une amélioration de cette protection est la technique dite du '''''Write XOR Execute''''', abréviée WxX. Elle consiste à interdire les pages d'être à la fois accessibles en écriture et exécutables. Il est possible de changer les autorisations en cours de route, ceci dit. Les premiers IBM 360 disposaient d'un mécanisme de protection mémoire totalement différent, sans registres limite/base. Ce mécanisme de protection attribue à chaque programme une '''clé de protection''', qui consiste en un nombre unique de 4 bits (chaque programme a donc une clé différente de ses collègues). La mémoire est fragmentée en blocs de même taille, de 2 kibioctets. Le processeur mémorise, pour chacun de ses blocs, la clé de protection du programme qui a réservé ce bloc. À chaque accès mémoire, le processeur compare la clé de protection du programme en cours d’exécution et celle du bloc de mémoire de destination. Si les deux clés sont différentes, alors un programme a effectué un accès hors des clous et il se fait sauvagement arrêter. ===La traduction d'adresse avec la pagination=== Comme dit plus haut, les pages sont numérotées, de 0 à une valeur maximale, afin de les identifier. Le numéro en question est appelé le '''numéro de page'''. Il est utilisé pour dire au processeur : je veux lire une donnée dans la page numéro 20, la page numéro 90, etc. Une fois qu'on a le numéro de page, on doit alors préciser la position de la donnée dans la page, appelé le '''décalage''', ou encore l{{'}}''offset''. Le numéro de page et le décalage se déduisent à partir de l'adresse, en divisant l'adresse par la taille de la page. Le quotient obtenu donne le numéro de la page, alors que le reste est le décalage. Les processeurs actuels utilisent tous des pages dont la taille est une puissance de deux, ce qui fait que ce calcul est fortement simplifié. Sous cette condition, le numéro de page correspond aux bits de poids fort de l'adresse, alors que le décalage est dans les bits de poids faible. Le numéro de page existe en deux versions : un numéro de page physique qui identifie une page en mémoire physique, et un numéro de page logique qui identifie une page dans la mémoire virtuelle. Traduire l'adresse logique en adresse physique demande de remplacer le numéro de la page logique en un numéro de page physique. [[File:Phycical address.JPG|centre|vignette|upright=2|Traduction d'adresse avec la pagination.]] ====Les tables des pages simples==== Dans le cas le plus simple, il n'y a qu'une seule table des pages, qui est adressée par les numéros de page logique. La table des pages est un vulgaire tableau d'adresses physiques, placées les unes à la suite des autres. Avec cette méthode, la table des pages a autant d'entrée qu'il y a de pages logiques en mémoire virtuelle. Accéder à la mémoire nécessite donc d’accéder d'abord à la table des pages en mémoire, de calculer l'adresse de l'entrée voulue, et d’y accéder. [[File:Table des pages.png|centre|vignette|upright=2|Table des pages.]] La table des pages est souvent stockée dans la mémoire RAM, son adresse est connue du processeur, mémorisée dans un registre spécialisé du processeur. Le processeur effectue automatiquement le calcul d'adresse à partir de l'adresse de base et du numéro de page logique. [[File:Address translation (32-bit).png|centre|vignette|upright=2|Address translation (32-bit)]] ====Les tables des pages inversées==== Sur certains systèmes, notamment sur les architectures 64 bits ou plus, le nombre de pages est très important. Sur les ordinateurs x86 récents, les adresses sont en pratique de 48 bits, les bits de poids fort étant ignorés en pratique, ce qui fait en tout 68 719 476 736 pages. Chaque entrée de la table des pages fait au minimum 48 bits, mais fait plus en pratique : partons sur 64 bits par entrée, soit 8 octets. Cela fait 549 755 813 888 octets pour la table des pages, soit plusieurs centaines de gibioctets ! Une table des pages normale serait tout simplement impraticable. Pour résoudre ce problème, on a inventé les '''tables des pages inversées'''. L'idée derrière celles-ci est l'inverse de la méthode précédente. La méthode précédente stocke, pour chaque page logique, son numéro de page physique. Les tables des pages inversées font l'inverse : elles stockent, pour chaque numéro de page physique, la page logique qui correspond. Avec cette méthode table des pages contient ainsi autant d'entrées qu'il y a de pages physiques. Elle est donc plus petite qu'avant, vu que la mémoire physique est plus petite que la mémoire virtuelle. Quand le processeur veut convertir une adresse virtuelle en adresse physique, la MMU recherche le numéro de page de l'adresse virtuelle dans la table des pages. Le numéro de l'entrée à laquelle se trouve ce morceau d'adresse virtuelle est le morceau de l'adresse physique. Pour faciliter le processus de recherche dans la page, la table des pages inversée est ce que l'on appelle une table de hachage. C'est cette solution qui est utilisée sur les processeurs Power PC. [[File:Table des pages inversée.jpg|centre|vignette|upright=2|Table des pages inversée.]] ====Les tables des pages multiples par espace d'adressage==== Dans les deux cas précédents, il y a une table des pages unique. Cependant, les concepteurs de processeurs et de systèmes d'exploitation ont remarqué que les adresses les plus hautes et/ou les plus basses sont les plus utilisées, alors que les adresses situées au milieu de l'espace d'adressage sont peu utilisées en raison du fonctionnement de la pile et du tas. Il y a donc une partie de la table des pages qui ne sert à rien et est utilisé pour des adresses inutilisées. C'est une source d'économie d'autant plus importante que les tables des pages sont de plus en plus grosses. Pour profiter de cette observation, les concepteurs d'OS ont décidé de découper l'espace d'adressage en plusieurs sous-espaces d'adressage de taille identique : certains localisés dans les adresses basses, d'autres au milieu, d'autres tout en haut, etc. Et vu que l'espace d'adressage est scindé en plusieurs parties, la table des pages l'est aussi, elle est découpée en plusieurs sous-tables. Si un sous-espace d'adressage n'est pas utilisé, il n'y a pas besoin d'utiliser de la mémoire pour stocker la table des pages associée. On ne stocke que les tables des pages pour les espaces d'adressage utilisés, ceux qui contiennent au moins une donnée. L'utilisation de plusieurs tables des pages ne fonctionne que si le système d'exploitation connaît l'adresse de chaque table des pages (celle de la première entrée). Pour cela, le système d'exploitation utilise une super-table des pages, qui stocke les adresses de début des sous-tables de chaque sous-espace. En clair, la table des pages est organisé en deux niveaux, la super-table étant le premier niveau et les sous-tables étant le second niveau. L'adresse est structurée de manière à tirer profit de cette organisation. Les bits de poids fort de l'adresse sélectionnent quelle table de second niveau utiliser, les bits du milieu de l'adresse sélectionne la page dans la table de second niveau et le reste est interprété comme un ''offset''. Un accès à la table des pages se fait comme suit. Les bits de poids fort de l'adresse sont envoyés à la table de premier niveau, et sont utilisés pour récupérer l'adresse de la table de second niveau adéquate. Les bits au milieu de l'adresse sont envoyés à la table de second niveau, pour récupérer le numéro de page physique. Le tout est combiné avec l{{'}}''offset'' pour obtenir l'adresse physique finale. [[File:Table des pages hiérarchique.png|centre|vignette|upright=2|Table des pages hiérarchique.]] On peut aussi aller plus loin et découper la table des pages de manière hiérarchique, chaque sous-espace d'adressage étant lui aussi découpé en sous-espaces d'adressages. On a alors une table de premier niveau, plusieurs tables de second niveau, encore plus de tables de troisième niveau, et ainsi de suite. Cela peut aller jusqu'à 5 niveaux sur les processeurs x86 64 bits modernes. On parle alors de '''tables des pages emboitées'''. Dans ce cours, la table des pages désigne l'ensemble des différents niveaux de cette organisation, toutes les tables inclus. Seules les tables du dernier niveau mémorisent des numéros de page physiques, les autres tables mémorisant des pointeurs, des adresses vers le début des tables de niveau inférieur. Un exemple sera donné plus bas, dans la section suivante. ====L'exemple des processeurs x86==== Pour rendre les explications précédentes plus concrètes, nous allons prendre l'exemple des processeur x86 anciens, de type 32 bits. Les processeurs de ce type utilisaient deux types de tables des pages : une table des page unique et une table des page hiérarchique. Les deux étaient utilisées dans cas séparés. La table des page unique était utilisée pour les pages larges et encore seulement en l'absence de la technologie ''physical adress extension'', dont on parlera plus bas. Les autres cas utilisaient une table des page hiérarchique, à deux niveaux, trois niveaux, voire plus. Une table des pages unique était utilisée pour les pages larges (de 2 mébioctets et plus). Pour les pages de 4 mébioctets, il y avait une unique table des pages, adressée par les 10 bits de poids fort de l'adresse, les bits restants servant comme ''offset''. La table des pages contenait 1024 entrées de 4 octets chacune, ce qui fait en tout 4 kibioctet pour la table des pages. La table des page était alignée en mémoire sur un bloc de 4 kibioctet (sa taille). [[File:X86 Paging 4M.svg|centre|vignette|upright=2|X86 Paging 4M]] Pour les pages de 4 kibioctets, les processeurs x86-32 bits utilisaient une table des page hiérarchique à deux niveaux. Les 10 bits de poids fort l'adresse adressaient la table des page maitre, appelée le directoire des pages (''page directory''), les 10 bits précédents servaient de numéro de page logique, et les 12 bits restants servaient à indiquer la position de l'octet dans la table des pages. Les entrées de chaque table des pages, mineure ou majeure, faisaient 32 bits, soit 4 octets. Vous remarquerez que la table des page majeure a la même taille que la table des page unique obtenue avec des pages larges (de 4 mébioctets). [[File:X86 Paging 4K.svg|centre|vignette|upright=2|X86 Paging 4K]] La technique du '''''physical adress extension''''' (PAE), utilisée depuis le Pentium Pro, permettait aux processeurs x86 32 bits d'adresser plus de 4 gibioctets de mémoire, en utilisant des adresses physiques de 64 bits. Les adresses virtuelles de 32 bits étaient traduites en adresses physiques de 64 bits grâce à une table des pages adaptée. Cette technologie permettait d'adresser plus de 4 gibioctets de mémoire au total, mais avec quelques limitations. Notamment, chaque programme ne pouvait utiliser que 4 gibioctets de mémoire RAM pour lui seul. Mais en lançant plusieurs programmes, on pouvait dépasser les 4 gibioctets au total. Pour cela, les entrées de la table des pages passaient à 64 bits au lieu de 32 auparavant. La table des pages gardait 2 niveaux pour les pages larges en PAE. [[File:X86 Paging PAE 2M.svg|centre|vignette|upright=2|X86 Paging PAE 2M]] Par contre, pour les pages de 4 kibioctets en PAE, elle était modifiée de manière à ajouter un niveau de hiérarchie, passant de deux niveaux à trois. [[File:X86 Paging PAE 4K.svg|centre|vignette|upright=2|X86 Paging PAE 4K]] En 64 bits, la table des pages est une table des page hiérarchique avec 5 niveaux. Seuls les 48 bits de poids faible des adresses sont utilisés, les 16 restants étant ignorés. [[File:X86 Paging 64bit.svg|centre|vignette|upright=2|X86 Paging 64bit]] ====Les circuits liés à la gestion de la table des pages==== En théorie, la table des pages est censée être accédée à chaque accès mémoire. Mais pour éviter d'avoir à lire la table des pages en mémoire RAM à chaque accès mémoire, les concepteurs de processeurs ont décidé d'implanter un cache dédié, le '''''translation lookaside buffer''''', ou TLB. Le TLB stocke au minimum de quoi faire la traduction entre adresse virtuelle et adresse physique, à savoir une correspondance entre numéro de page logique et numéro de page physique. Pour faire plus général, il stocke des entrées de la table des pages. [[File:MMU principle updated.png|centre|vignette|upright=2.0|MMU avec une TLB.]] Les accès à la table des pages sont gérés de deux façons : soit le processeur gère tout seul la situation, soit il délègue cette tâche au système d’exploitation. Sur les processeurs anciens, le système d'exploitation gère le parcours de la table des pages. Mais cette solution logicielle n'a pas de bonnes performances. D'autres processeurs gèrent eux-mêmes le défaut d'accès à la TLB et vont chercher d'eux-mêmes les informations nécessaires dans la table des pages. Ils disposent de circuits, les '''''page table walkers''''' (PTW), qui s'occupent eux-mêmes du défaut. Les ''page table walkers'' contiennent des registres qui leur permettent de faire leur travail. Le plus important est celui qui mémorise la position de la table des pages en mémoire RAM, dont nous avons parlé plus haut. Les PTW ont besoin, pour faire leur travail, de mémoriser l'adresse physique de la table des pages, ou du moins l'adresse de la table des pages de niveau 1 pour des tables des pages hiérarchiques. Mais d'autres registres existent. Toutes les informations nécessaires pour gérer les défauts de TLB sont stockées dans des registres spécialisés appelés des '''tampons de PTW''' (PTW buffers). ===L'abstraction matérielle des processus : une table des pages par processus=== [[File:Memoire virtuelle.svg|vignette|Mémoire virtuelle]] Il est possible d'implémenter l'abstraction matérielle des processus avec la pagination. En clair, chaque programme lancé sur l'ordinateur dispose de son propre espace d'adressage, ce qui fait que la même adresse logique ne pointera pas sur la même adresse physique dans deux programmes différents. Pour cela, il y a plusieurs méthodes. ====L'usage d'une table des pages unique avec un identifiant de processus dans chaque entrée==== La première solution n'utilise qu'une seule table des pages, mais chaque entrée est associée à un processus. Pour cela, chaque entrée contient un '''identifiant de processus''', un numéro qui précise pour quel processus, pour quel espace d'adressage, la correspondance est valide. La page des tables peut aussi contenir des entrées qui sont valides pour tous les processus en même temps. L'intérêt n'est pas évident, mais il le devient quand on se rappelle que le noyau de l'OS est mappé dans le haut de l'espace d'adressage. Et peu importe l'espace d'adressage, le noyau est toujours mappé de manière identique, les mêmes adresses logiques adressant la même adresse mémoire. En conséquence, les correspondances adresse physique-logique sont les mêmes pour le noyau, peu importe l'espace d'adressage. Dans ce cas, la correspondance est mémorisée dans une entrée, mais sans identifiant de processus. À la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau. Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus. ====L'usage de plusieurs tables des pages==== Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour. Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique. [[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]] ===La taille des pages=== La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant. Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''. Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges. Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses. ===Les entrées de la table des pages=== Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits. * Elle contient d'abord le numéro de page physique. * Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS. * Le bit G est le bit ''global''. * Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges. * Le bit D est le bit ''dirty''. * Le bit A est le bit ''accessed''. * Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM. * Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page). * Le bit U/S précise si la page est accessible en mode noyau ou utilisateur. * Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture. * Le bit P est le bit ''valid''. [[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]] ==Comparaison des différentes techniques d'abstraction mémoire== Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous. {|class="wikitable" |- ! ! colspan="5" | Avec abstraction mémoire ! rowspan="2" | Sans abstraction mémoire |- ! ! Relocation matérielle ! Segmentation en mode réel (x86) ! Segmentation, général ! Architectures à capacités ! Pagination |- ! Abstraction matérielle des processus | colspan="4" | Oui, relocation matérielle | Oui, liée à la traduction d'adresse | Impossible |- ! Mémoire virtuelle | colspan="2" | Non, sauf émulation logicielle | colspan="3" | Oui, gérée par le processeur et l'OS | Non, sauf émulation logicielle |- ! Extension de l'espace d'adressage | colspan="2" | Oui : registre de base élargi | colspan="2" | Oui : adresse de base élargie dans la table des segments | ''Physical Adress Extension'' des processeurs 32 bits | Commutation de banques |- ! Protection mémoire | Registre limite | Aucune | colspan="2" | Registre limite, droits d'accès aux segments | Gestion des droits d'accès aux pages | Possible, méthodes variées |- ! Partage de mémoire | colspan="2" | Non | colspan="2" | Segment partagés | Pages partagées | Possible, méthodes variées |} ===Les différents types de segmentation=== La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues. La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente. La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86. Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique. ===Segmentation versus pagination=== Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe. L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum. Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment. Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=L'espace d'adressage du processeur | prevText=L'espace d'adressage du processeur | next=Les méthodes de synchronisation entre processeur et périphériques | nextText=Les méthodes de synchronisation entre processeur et périphériques }} </noinclude> 5ilhkijr67gwvmhsnu4ctfzl6u11b8u 771164 771163 2026-08-22T01:10:06Z Mewtow 31375 /* La MMU */ 771164 wikitext text/x-wiki Pour introduire ce chapitre, nous devons faire un rappel sur le concept d{{'}}'''espace d'adressage'''. Pour rappel, un espace d'adressage correspond à l'ensemble des adresses utilisables par le processeur. Par exemple, si je prends un processeur 16 bits, il peut adresser en tout 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. Intuitivement, on s'attend à ce qu'il y ait correspondance avec les adresses de la mémoire RAM. J'entends par là que l'adresse 1209 de l'espace d'adressage correspond à l'adresse 1209 en mémoire RAM. C'est là une hypothèse parfaitement raisonnable et on voit mal comment ce pourrait ne pas être le cas. Mais les processeurs modernes utilisent des techniques d{{'}}'''abstraction mémoire''' qui font que ce n'est pas le cas. Avec ces techniques, l'adresse 1209 de l'espace d'adressage correspond en réalité à l'adresse 9999 en mémoire RAM, voire n'est pas en RAM. L'abstraction mémoire fait que l'espace d'adressage regroupe des adresses fictives, qui doivent être traduites en adresses mémoires réelles pour être utilisées. Les adresses de l'espace d'adressage portent le nom d{{'}}'''adresses logiques''', alors que les adresses de la mémoire RAM sont appelées '''adresses physiques'''. L'intérêt de l'abstraction matérielle n'est pas évident. Aussi, avant de parler de comment l'abstraction mémoire fonctionne, nous allons voir à quoi elle sert. Nous allons voir qu'elle a plusieurs utilisations différentes, qui sont absolument nécessaires sur tous les ordinateurs personnels modernes. Tous les processeurs modernes la prennent en charge, les systèmes d'exploitation coopérent avec le processeur pour l'utiliser au mieux. ==L'abstraction mémoire permet plusieurs fonctionnalités complémentaires== La fonctionnalité la plus connue est la mémoire virtuelle, dont vous avez peut-être déjà entendu parler, peut-être que le nom vous dit quelque chose. Si ce n'est pas le cas, nous la détailleront dans la suite. Elle permet concrétement à un programme d'utiliser plus de mémoire qu'il n'y a de RAM installé dans un ordinateur, en utilisant le disque dur comme solution de secours. Mais d'autres fonctionnalités moins évidentes sont permises par l'abstraction mémoire. Et la première que nous allons voir est l'abstraction des processus. : En général, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle. ===L'abstraction matérielle des processus=== Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres. Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente. Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y. Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types : * La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique. * La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique. ===Le partage de la mémoire=== L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile. Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon. Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM. [[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]] D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées. Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité. Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire). [[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]] L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux. Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique. ===La mémoire virtuelle=== Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème. Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat. Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur. Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM. [[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]] Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation. Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel. L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''. On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur. Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée. ===L'extension d'adressage=== Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses. Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente. Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter. [[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]] Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage. Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas. ===La protection mémoire=== La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses. La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran. La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées. D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre. ==La MMU== La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé, appelé la '''''Memory Management Unit''''' (MMU). De nos jours, elle est intégrée dans le processeur, à la suite de l'unité mémoire. La traduction des adresses logiques en adresses physiques demande d'accéder à des données sont en mémoire RAM, qui sont gérés par le système d'exploitation. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu. [[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]] ===Les MMU intégrées au processeur=== D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente. Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation. [[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]] ===Les MMU séparées du processeur, sur la carte mère=== Il a existé des processeurs avec une MMU externe, soudée sur la carte mère. Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451. Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits. Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur. ==La relocation matérielle== Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous. [[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]] Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations. ===La relocation avec la relocation matérielle : le registre de base=== Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme. [[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]] La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation. [[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]] Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage. [[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]] Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable. ===La protection mémoire avec la relocation matérielle : le registre limite=== Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire. Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, qu'il ne déborde pas en-dehors. C'est possible qu'une adresse calculée sorte du segment, à la suite d'un bug ou d'une erreur de programmation, voire pire. Et le processeur doit éviter de tels '''débordements de segments'''. A chaque accès mémoire, le processeur compare l'adresse accédée et vérifie qu'elle est bien dans le segment. Pour cela, il y a deux solutions. La première part du principe que le segment est placé en mémoire entre l'adresse de base et l'adresse limite. Il suffit de mémoriser l'adresse limite, l'adresse physique à ne pas dépasser. Une autre solution mémorise la taille du segment. La table des segments doit donc mémoriser, en plus de l'adresse de base : soit l'adresse maximale du segment, soit la taille du segment. D'autres informations peuvent être ajoutées, comme on le verra plus tard, mais cela complexifie la table des segments. De plus, le processeur se voit ajouter un '''registre limite''', qui mémorise soit la taille du segment, soit l'adresse limite. Les deux registres, base et limite, sont utilisés pour vérifier si un programme qui lit/écrit de la mémoire en-dehors de son segment attitré : au-delà pour le registre limite, en-deça pour le registre de base. Le processeur vérifie pour chaque accès mémoire ne déborde pas au-delà du segment qui lui est allouée, ce qui n'arrive que si l'adresse d'accès dépasse la valeur du registre limite. Pour les accès en-dessous du segment, il suffit de vérifier si l'addition de relocation déborde, tout débordement signifiant erreur de protection mémoire. [[File:Registre limite.png|centre|vignette|upright=2|Registre limite]] Utiliser la taille du segment a de nombreux avantages. L'un d'entre eux se manifeste quand on déplace un segment en mémoire RAM. Le descripteur doit alors être mis à jour, et c'est plus facile quand on utilise la taille du segment. Si on utilise l'adresse limite, il faut mettre à jour à la fois l'adresse de base et l'adresse limite, dans le descripteur. En utilisant la taille, seule l'adresse de base doit être modifiée, vu que le segment n'a pas changé de taille. Un autre avantage est lié aux performances, mais nous devons faire un détour pour le comprendre. La taille du segment est équivalent à l'adresse logique maximale possible. Par exemple, si un segment fait 256 octets, les adresses logiques possibles vont de 0 à 255, 256 est donc à la fois la taille du segment et l'adresse logique à partir de laquelle on déborde du segment. Et cela marche si on remplace 256 par n'importe quelle valeur : vu que le segment commence à l'adresse 0, sa taille en octets indique l'adresse de dépassement. Interpréter la taille du segment comme une adresse logique fait que les tests avec le registre limite sont plus performants, voyons pourquoi. En utilisant l'adresse physique limite, on doit faire la relocation, puis comparer l'adresse calculée avec l'adresse limite. Le calcul d'adresse doit se faire avant la vérification. En utilisant la taille, on doit comparer l'adresse logique avec la taille du segment. On peut alors faire le test de débordement avant ou pendant la relocation. Les deux peuvent être faits en parallèle, dans deux circuits distincts, ce qui améliore un peu le temps d'un accès mémoire. Quelques processeurs en ont profité, mais on verra cela dans la section sur la segmentation. [[File:Comparaison entre adresse limite physique et logique.png|centre|vignette|upright=2|Comparaison entre adresse limite physique et logique]] Les registres de base et limite sont altérés uniquement par le système d'exploitation et ne sont accessibles qu'en espace noyau. Lorsque le système d'exploitation charge un programme, ou reprend son exécution, il charge les adresses de début/fin du segment dans ces registres. D'ailleurs, ces deux registres doivent être sauvegardés et restaurés lors de chaque interruption. Par contre, et c'est assez évident, ils ne le sont pas lors d'un appel de fonction. Cela fait une différence de plus entre interruption et appels de fonctions. : Il faut noter que le registre limite et le registre de base sont parfois fusionnés en un seul registre, qui contient un descripteur de segment tout entier. Pour information, la relocation matérielle avec un registre limite a été implémentée sur plusieurs processeurs assez anciens, notamment sur les anciens supercalculateurs de marque CDC. Un exemple est le fameux CDC 6600, qui implémentait cette technique. ===La mémoire virtuelle avec la relocation matérielle=== Il est possible d'implémenter la mémoire virtuelle avec la relocation matérielle. Pour cela, il faut swapper des segments entiers sur le disque dur. Les segments sont placés en mémoire RAM et leur taille évolue au fur et à mesure que les programmes demandent du rab de mémoire RAM. Lorsque la mémoire est pleine, ou qu'un programme demande plus de mémoire que disponible, des segments entiers sont sauvegardés dans le ''swapfile'', pour faire de la place. Faire ainsi de demande juste de mémoriser si un segment est en mémoire RAM ou non, ainsi que la position des segments swappés dans le ''swapfile''. Pour cela, il faut modifier la table des segments, afin d'ajouter un '''bit de swap''' qui précise si le segment en question est swappé ou non. Lorsque le système d'exploitation veut swapper un segment, il le copie dans le ''swapfile'' et met ce bit à 1. Lorsque l'OS recharge ce segment en RAM, il remet ce bit à 0. La gestion de la position des segments dans le ''swapfile'' est le fait d'une structure de données séparée de la table des segments. L'OS exécute chaque programme l'un après l'autre, à tour de rôle. Lorsque le tour d'un programme arrive, il consulte la table des segments pour récupérer les adresses de base et limite, mais il vérifie aussi le bit de swap. Si le bit de swap est à 0, alors l'OS se contente de charger les adresses de base et limite dans les registres adéquats. Mais sinon, il démarre une routine d'interruption qui charge le segment voulu en RAM, depuis le ''swapfile''. C'est seulement une fois le segment chargé que l'on connait son adresse de base/limite et que le chargement des registres de relocation peut se faire. Un défaut évident de cette méthode est que l'on swappe des programmes entiers, qui sont généralement assez imposants. Les segments font généralement plusieurs centaines de mébioctets, pour ne pas dire plusieurs gibioctets, à l'époque actuelle. Ils étaient plus petits dans l'ancien temps, mais la mémoire était alors plus lente. Toujours est-il que la copie sur le disque dur des segments est donc longue, lente, et pas vraiment compatible avec le fait que les programmes s'exécutent à tour de rôle. Et ca explique pourquoi la relocation matérielle n'est presque jamais utilisée avec de la mémoire virtuelle. ===L'extension d'adressage avec la relocation matérielle=== Passons maintenant à la dernière fonctionnalité implémentable avec la traduction d'adresse : l'extension d'adressage. Elle permet d'utiliser plus de mémoire que ne le permet l'espace d'adressage. Par exemple, utiliser plus de 64 kibioctets de mémoire sur un processeur 16 bits. Pour cela, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. L'extension des adresses se fait assez simplement avec la relocation matérielle : il suffit que le registre de base soit plus long. Prenons l'exemple d'un processeur aux adresses de 16 bits, mais qui est reliée à un bus d'adresse de 24 bits. L'espace d'adressage fait juste 64 kibioctets, mais le bus d'adresse gère 16 mébioctets de RAM. On peut utiliser les 16 mébioctets de RAM à une condition : que le registre de base fasse 24 bits, pas 16. Un défaut de cette approche est qu'un programme ne peut pas utiliser plus de mémoire que ce que permet l'espace d'adressage. Mais par contre, on peut placer chaque programme dans des portions différentes de mémoire. Imaginons par exemple que l'on ait un processeur 16 bits, mais un bus d'adresse de 20 bits. Il est alors possible de découper la mémoire en 16 blocs de 64 kibioctets, chacun attribué à un segment/programme, qu'on sélectionne avec les 4 bits de poids fort de l'adresse. Il suffit de faire démarrer les segments au bon endroit en RAM, et cela demande juste que le registre de base le permette. C'est une sorte d'émulation de la commutation de banques. ==La segmentation en mode réel des processeurs x86== Avant de passer à la suite, nous allons voir la technique de segmentation de l'Intel 8086, un des tout premiers processeurs 16 bits. Il s'agissait d'une forme très simple de segmentation, sans aucune forme de protection mémoire, ni même de mémoire virtuelle, ce qui le place à part des autres formes de segmentation. Il s'agit d'une amélioration de la relocation matérielle, qui avait pour but de permettre d'utiliser plus de 64 kibioctets de mémoire, ce qui était la limite maximale sur les processeurs 16 bits de l'époque. Par la suite, la segmentation s'améliora et ajouta un support complet de la mémoire virtuelle et de la protection mémoire. L'ancienne forme de segmentation fut alors appelé le '''mode réel''', et la nouvelle forme de segmentation fut appelée le '''mode protégé'''. Le mode protégé rajoute la protection mémoire, en ajoutant des registres limite et une gestion des droits d'accès aux segments, absents en mode réel. De plus, il ajoute un support de la mémoire virtuelle grâce à l'utilisation d'une des segments digne de ce nom, table qui est absente en mode réel ! Pour le moment, voyons le mode réel. ===Les segments en mode réel=== [[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]] La segmentation en mode réel sépare la pile, le tas, le code machine et les données constantes dans quatre segments distincts. * Le segment '''''text''''', qui contient le code machine du programme, de taille fixe. * Le segment '''''data''''' contient des données de taille fixe qui occupent de la mémoire de façon permanente, des constantes, des variables globales, etc. * Le segment pour la '''pile''', de taille variable. * le reste est appelé le '''tas''', de taille variable. Un point important est que sur ces processeurs, il n'y a pas de table des segments proprement dit. Chaque programme gére de lui-même les adresses de base des segments qu'il manipule. Il n'est en rien aidé par une table des segments gérée par le système d'exploitation. ===Les registres de segments en mode réel=== Chaque segment subit la relocation indépendamment des autres. Pour cela, le processeur intégre plusieurs registres de base, un par segment. Notons que cette solution ne marche que si le nombre de segments par programme est limité, à une dizaine de segments tout au plus. Les processeurs x86 utilisaient cette méthode, et n'associaient que 4 à 6 registres de segments par programme. Les processeurs 8086 et le 286 avaient quatre registres de segment : un pour le code, un autre pour les données, et un pour la pile, le quatrième étant un registre facultatif laissé à l'appréciation du programmeur. Ils sont nommés CS (''code segment''), DS (''data segment''), SS (''Stack segment''), et ES (''Extra segment''). Le 386 rajouta deux registres, les registres FS et GS, qui sont utilisés pour les segments de données. Les processeurs post-386 ont donc 6 registres de segment. Les registres CS et SS sont adressés implicitement, en fonction de l'instruction exécutée. Les instructions de la pile manipulent le segment associé à la pile, le chargement des instructions se fait dans le segment de code, les instructions arithmétiques et logiques vont chercher leurs opérandes sur le tas, etc. Et donc, toutes les instructions sont chargées depuis le segment pointé par CS, les instructions de gestion de la pile (PUSH et POP) utilisent le segment pointé par SS. Les segments DS et ES sont, eux aussi, adressés implicitement. Pour cela, les instructions LOAD/STORE sont dupliquées : il y a une instruction LOAD pour le segment DS, une autre pour le segment ES. D'autres instructions lisent leurs opérandes dans un segment par défaut, mais on peut changer ce choix par défaut en précisant le segment voulu. Un exemple est celui de l'instruction CMPSB, qui compare deux octets/bytes : le premier est chargé depuis le segment DS, le second depuis le segment ES. Un autre exemple est celui de l'instruction MOV avec un opérande en mémoire. Elle lit l'opérande en mémoire depuis le segment DS par défaut. Il est possible de préciser le segment de destination si celui-ci n'est pas DS. Par exemple, l'instruction MOV [A], AX écrit le contenu du registre AX dans l'adresse A du segment DS. Par contre, l'instruction MOV ES:[A], copie le contenu du registre AX das l'adresse A, mais dans le segment ES. ===La traduction d'adresse en mode réel=== La segmentation en mode réel a pour seul but de permettre à un programme de dépasser la limite des 64 KB autorisée par les adresses de 16 bits. L'idée est que chaque segment a droit à son propre espace de 64 KB. On a ainsi 64 Kb pour le code machine, 64 KB pour la pile, 64 KB pour un segment de données, etc. Les registres de segment mémorisaient la base du segment, les adresses calculées par l'ALU étant des ''offsets''. Ce sont tous des registres de 16 bits, mais ils ne mémorisent pas des adresses physiques de 16 bits, comme nous allons le voir. [[File:Table des segments dans un banc de registres.png|centre|vignette|upright=2|Table des segments dans un banc de registres.]] L'Intel 8086 utilisait des adresses de 20 bits, ce qui permet d'adresser 1 mébioctet de RAM. Vous pouvez vous demander comment on peut obtenir des adresses de 20 bits alors que les registres de segments font tous 16 bits ? Cela tient à la manière dont sont calculées les adresses physiques. Le registre de segment n'est pas additionné tel quel avec le décalage : à la place, le registre de segment est décalé de 4 rangs vers la gauche. Le décalage de 4 rangs vers la gauche fait que chaque segment a une adresse qui est multiple de 16. Le fait que le décalage soit de 16 bits fait que les segments ont une taille de 64 kibioctets. {|class="wikitable" |- | <code>&nbsp; </code><code style="background:#DED">0000 0110 1110 1111</code><code>0000</code> | Registre de segment - | 16 bits, décalé de 4 bits vers la gauche |- | <code>+ &nbsp;&nbsp;&nbsp;&nbsp; </code><code style="background:#DDF">0001 0010 0011 0100</code> | Décalage/''Offset'' | 16 bits |- | colspan="3" | |- | <code>&nbsp; </code><code style="background:#FDF">0000 1000 0001 0010 0100</code> | Adresse finale | 20 bits |} Vous aurez peut-être remarqué que le calcul peut déborder, dépasser 20 bits. Mais nous reviendrons là-dessus plus bas. L'essentiel est que la MMU pour la segmentation en mode réel se résume à quelques registres et des additionneurs/soustracteurs. Un exemple est l'Intel 8086, un des tout premier processeur Intel. Le processeur était découpé en deux portions : l'interface mémoire et le reste du processeur. L'interface mémoire est appelée la '''''Bus Interface Unit''''', et le reste du processeur est appelé l{{'}}'''''Execution Unit'''''. L'interface mémoire contenait les registres de segment, au nombre de 4, ainsi qu'un additionneur utilisé pour traduire les adresses logiques en adresses physiques. Elle contenait aussi une file d'attente où étaient préchargées les instructions. Sur le 8086, la MMU est fusionnée avec les circuits de gestion du ''program counter''. Les registres de segment sont regroupés avec le ''program counter'' dans un même banc de registres, et un additionneur unique est utilisé à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. L'additionneur est donc mutualisé entre segmentation et ''program counter''. En somme, il n'y a pas vraiment de MMU dédiée, mais un super-circuit en charge du Fetch et de la mémoire virtuelle, ainsi que du préchargement des instructions. Nous en reparlerons au chapitre suivant. [[File:80186 arch.png|centre|vignette|upright=2.5|Architecture du 8086, du 80186 et de ses variantes.]] La MMU du 286 était fusionnée avec l'unité de calcul d'adresse. Elle contient les registres de segments, un comparateur pour détecter les accès hors-segment, et plusieurs additionneurs. Il y a un additionneur pour les calculs d'adresse proprement dit, suivi d'un additionneur pour la relocation. [[File:Intel i80286 arch.svg|centre|vignette|upright=3|Intel i80286 arch]] ===La segmentation en mode réel accepte plusieurs segments de code/données=== Les programmes peuvent parfaitement répartir leur code machine dans plusieurs segments de code. La limite de 64 KB par segment est en effet assez limitante, et il n'était pas rare qu'un programme stocke son code dans deux ou trois segments. Il en est de même avec les données, qui peuvent être réparties dans deux ou trois segments séparés. La seule exception est la pile : elle est forcément dans un segment unique et ne peut pas dépasser 64 KB. Pour gérer plusieurs segments de code/donnée, il faut changer de segment à la volée suivant les besoins, en modifiant les registres de segment. Il s'agit de la technique de '''commutation de segment'''. Pour cela, tous les registres de segment, à l'exception de CS, peuvent être altérés par une instruction d'accès mémoire, soit avec une instruction MOV, soit en y copiant le sommet de la pile avec une instruction de dépilage POP. L'absence de sécurité fait que la gestion de ces registres est le fait du programmeur, qui doit redoubler de prudence pour ne pas faire n'importe quoi. Pour le code machine, le répartir dans plusieurs segments posait des problèmes au niveau des branchements. Si la plupart des branchements sautaient vers une instruction dans le même segment, quelques rares branchements sautaient vers du code machine dans un autre segment. Intel avait prévu le coup et disposait de deux instructions de branchement différentes pour ces deux situations : les '''''near jumps''''' et les '''''far jumps'''''. Les premiers sont des branchements normaux, qui précisent juste l'adresse à laquelle brancher, qui correspond à la position de la fonction dans le segment. Les seconds branchent vers une instruction dans un autre segment, et doivent préciser deux choses : l'adresse de base du segment de destination, et la position de la destination dans le segment. Le branchement met à jour le registre CS avec l'adresse de base, avant de faire le branchement. Ces derniers étaient plus lents, car on n'avait pas à changer de segment et mettre à jour l'état du processeur. Il y avait la même pour l'instruction d'appel de fonction, avec deux versions de cette instruction. La première version, le '''''near call''''' est un appel de fonction normal, la fonction appelée est dans le segment en cours. Avec la seconde version, le '''''far call''''', la fonction appelée est dans un segment différent. L'instruction a là aussi besoin de deux opérandes : l'adresse de base du segment de destination, et la position de la fonction dans le segment. Un ''far call'' met à jour le registre CS avec l'adresse de base, ce qui fait que les ''far call'' sont plus lents que les ''near call''. Il existe aussi la même chose, pour les instructions de retour de fonction, avec une instruction de retour de fonction normale et une instruction de retour qui renvoie vers un autre segment, qui sont respectivement appelées '''''near return''''' et '''''far return'''''. Là encore, il faut préciser l'adresse du segment de destination dans le second cas. La même chose est possible pour les segments de données. Sauf que cette fois-ci, ce sont les pointeurs qui sont modifiés. pour rappel, les pointeurs sont, en programmation, des variables qui contiennent des adresses. Lors de la compilation, ces pointeurs sont placés soit dans un registre, soit dans les instructions (adressage absolu), ou autres. Ici, il existe deux types de pointeurs, appelés '''''near pointer''''' et '''''far pointer'''''. Vous l'avez deviné, les premiers sont utilisés pour localiser les données dans le segment en cours d'utilisation, alors que les seconds pointent vers une donnée dans un autre segment. Là encore, la différence est que le premier se contente de donner la position dans le segment, alors que les seconds rajoutent l'adresse de base du segment. Les premiers font 16 bits, alors que les seconds en font 32 : 16 bits pour l'adresse de base et 16 pour l{{'}}''offset''. ===L'occupation de l'espace d'adressage par les segments=== Nous venons de voir qu'un programme pouvait utiliser plus de 4-6 segments, avec la commutation de segment. Mais d'autres programmes faisaient l'inverse, à savoir qu'ils se débrouillaient avec seulement 1 ou 2 segments. Suivant le nombre de segments utilisés, la configuration des registres n'était pas la même. Les configurations possibles sont appelées des ''modèle mémoire'', et il y en a en tout 6. En voici la liste : {| class="wikitable" |- ! Modèle mémoire !! Configuration des segments !! Configuration des registres || Pointeurs utilisés || Branchements utilisés |- | Tiny* || Segment unique pour tout le programme || CS=DS=SS || ''near'' uniquement || ''near'' uniquement |- | Small || Segment de donnée séparé du segment de code, pile dans le segment de données || DS=SS || ''near'' uniquement || ''near'' uniquement |- | Medium || Plusieurs segments de code unique, un seul segment de données || CS, DS et SS sont différents || ''near'' et ''far'' || ''near'' uniquement |- | Compact || Segment de code unique, plusieurs segments de données || CS, DS et SS sont différents || ''near'' uniquement || ''near'' et ''far'' |- | Large || Plusieurs segments de code, plusieurs segments de données || CS, DS et SS sont différents || ''near'' et ''far'' || ''near'' et ''far'' |} Un programme est censé utiliser maximum 4-6 segments de 64 KB, ce qui permet d'adresser maximum 64 * 6 = 384 KB de RAM, soit bien moins que le mébioctet de mémoire théoriquement adressable. Mais ce défaut est en réalité contourné par la commutation de segment, qui permettait d'adresser la totalité de la RAM si besoin. Une second manière de contourner cette limite est que plusieurs processus peuvent s'exécuter sur un seul processeur, si l'OS le permet. Ce n'était pas le cas à l'époque du DOS, qui était un OS mono-programmé, mais c'était en théorie possible. La limite est de 6 segments par programme/processus, en exécuter plusieurs permet d'utiliser toute la mémoire disponible rapidement. [[File:Overlapping realmode segments.svg|vignette|Segments qui se recouvrent en mode réel.]] Vous remarquerez qu'avec des registres de segments de 16 bits, on peut gérer 65536 segments différents, chacun de 64 KB. Et 65 536 segments de 64 kibioctets, ça ne rentre pas dans le mébioctet de mémoire permis avec des adresses de 20 bits. La raison est que plusieurs couples segment+''offset'' pointent vers la même adresse. En tout, chaque adresse peut être adressée par 4096 couples segment+''offset'' différents. L'avantage de cette méthode est que des segments peuvent se recouvrir, à savoir que la fin de l'un se situe dans le début de l'autre, comme illustré ci-contre. Cela permet en théorie de partager de la mémoire entre deux processus. Mais la technique est tout sauf pratique et est donc peu utilisée. Elle demande de placer minutieusement les segments en RAM, et les données à partager dans les segments. En pratique, les programmeurs et OS utilisent des segments qui ne se recouvrent pas et sont disjoints en RAM. Le nombre maximal de segments disjoints se calcule en prenant la taille de la RAM, qu'on divise par la taille d'un segment. Le calcul donne : 1024 kibioctets / 64 kibioctets = 16 segments disjoints. Un autre calcul prend le nombre de segments divisé par le nombre d'adresses aliasées, ce qui donne 65536 / 4096 = 16. Seulement 16 segments, c'est peu. En comptant les segments utilisés par l'OS et ceux utilisés par le programme, la limite est vite atteinte si le programme utilise la commutation de segment. ===Le mode réel sur les 286 et plus : la ligne d'adresse A20=== Pour résumer, le registre de segment contient des adresses de 20 bits, dont les 4 bits de poids faible sont à 0. Et il se voit ajouter un ''offset'' de 16 bits. Intéressons-nous un peu à l'adresse maximale que l'on peut calculer avec ce système. Nous allons l'appeler l{{'}}'''adresse maximale de segmentation'''. Elle vaut : {|class="wikitable" |- | <code>&nbsp; </code><code style="background:#DED">1111 1111 1111 1111</code><code>0000</code> | Registre de segment - | 16 bits, décalé de 4 bits vers la gauche |- | <code>+ &nbsp;&nbsp;&nbsp;&nbsp; </code><code style="background:#DDF">1111 1111 1111 1111</code> | Décalage/''Offset'' | 16 bits |- | colspan="3" | |- | <code>&nbsp; </code><code style="background:#FDF">1 0000 1111 1111 1110 1111</code> | Adresse finale | 20 bits |} Le résultat n'est pas l'adresse maximale codée sur 20 bits, car l'addition déborde. Elle donne un résultat qui dépasse l'adresse maximale permis par les 20 bits, il y a un 21ème bit en plus. De plus, les 20 bits de poids faible ont une valeur bien précise. Ils donnent la différence entre l'adresse maximale permise sur 20 bit, et l'adresse maximale de segmentation. Les bits 1111 1111 1110 1111 traduits en binaire donnent 65 519; auxquels il faut ajouter l'adresse 1 0000 0000 0000 0000. En tout, cela fait 65 520 octets adressables en trop. En clair : on dépasse la limite du mébioctet de 65 520 octets. Le résultat est alors très différent selon que l'on parle des processeurs avant le 286 ou après. Avant le 286, le bus d'adresse faisait exactement 20 bits. Les adresses calculées ne pouvaient pas dépasser 20 bits. L'addition générait donc un débordement d'entier, géré en arithmétique modulaire. En clair, les bits de poids fort au-delà du vingtième sont perdus. Le calcul de l'adresse débordait et retournait au début de la mémoire, sur les 65 520 premiers octets de la mémoire RAM. [[File:IBM PC Memory areas.svg|vignette|IBM PC Memory Map, la ''High memory area'' est en jaune.]] Le 80286 en mode réel gère des adresses de base de 24 bits, soit 4 bits de plus que le 8086. Le résultat est qu'il n'y a pas de débordement. Les bits de poids fort sont conservés, même au-delà du 20ème. En clair, la segmentation permettait de réellement adresser 65 530 octets au-delà de la limite de 1 mébioctet. La portion de mémoire adressable était appelé la '''''High memory area''''', qu'on va abrévier en HMA. {| class="wikitable" |+ Espace d'adressage du 286 |- ! Adresses en héxadécimal !! Zone de mémoire |- | 10 FFF0 à FF FFFF || Mémoire étendue, au-delà du premier mébioctet |- | 10 0000 à 10 FFEF || ''High Memory Area'' |- | 0 à 0F FFFF || Mémoire adressable en mode réel |} En conséquence, les applications peuvent utiliser plus d'un mébioctet de RAM, mais au prix d'une rétrocompatibilité imparfaite. Quelques programmes DOS ne marchaient pus à cause de ça. D'autres fonctionnaient convenablement et pouvaient adresser les 65 520 octets en plus. Pour résoudre ce problème, les carte mères ajoutaient un petit circuit relié au 21ème bit d'adresse, nommé A20 (pas d'erreur, les fils du bus d'adresse sont numérotés à partir de 0). Le circuit en question pouvait mettre à zéro le fil d'adresse, ou au contraire le laisser tranquille. En le forçant à 0, le calcul des adresses déborde comme dans le mode réel des 8086. Mais s'il ne le fait pas, la ''high memory area'' est adressable. Le circuit était une simple porte ET, qui combinait le 21ème bit d'adresse avec un '''signal de commande A20''' provenant d'ailleurs. Le signal de commande A20 était géré par le contrôleur de clavier, qui était soudé à la carte mère. Le contrôleur en question ne gérait pas que le clavier, il pouvait aussi RESET le processeur, alors gérer le signal de commande A20 n'était pas si problématique. Quitte à avoir un microcontrôleur sur la carte mère, autant s'en servir au maximum... La gestion du bus d'adresse étaitdonc gérable au clavier. D'autres carte mères faisaient autrement et préféraient ajouter un interrupteur, pour activer ou non la mise à 0 du 21ème bit d'adresse. : Il faut noter que le signal de commande A20 était mis à 1 en mode protégé, afin que le 21ème bit d'adresse soit activé. Le 386 ajouta deux registres de segment, les registres FS et GS, ainsi que le '''mode ''virtual 8086'''''. Ce dernier permet d’exécuter des programmes en mode réel alors que le système d'exploitation s'exécute en mode protégé. C'est une technique de virtualisation matérielle qui permet d'émuler un 8086 sur un 386. L'avantage est que la compatibilité avec les programmes anciens écrits pour le 8086 est conservée, tout en profitant de la protection mémoire. Tous les processeurs x86 qui ont suivi supportent ce mode virtuel 8086. ==La segmentation avec une table des segments== La '''segmentation avec une table des segments''' est apparue sur des processeurs assez anciens, le tout premier étant le Burrough 5000. Elle a ensuite été utilisée sur les processeurs x86 des PCs, à partir du 286 d'Intel. Elle est aujourd'hui abandonnée sur les jeux d'instruction x86. Tout comme la segmentation en mode réel, la segmentation attribue plusieurs segments par programmes ! Et cela a des répercutions sur la manière dont la traduction d'adresse est effectuée. ===Pourquoi plusieurs segments par programme ?=== L'utilité d'avoir plusieurs segments par programme n'est pas évidente, mais elle le devient quand on se plonge dans le passé. Dans le passé, les programmeurs devaient faire avec une quantité de mémoire limitée et il n'était pas rare que certains programmes utilisent plus de mémoire que disponible sur la machine. Mais les programmeurs concevaient leurs programmes en fonction. [[File:Overlay Programming.svg|vignette|upright=1|Overlay Programming]] L'idée était d'implémenter un système de mémoire virtuelle, mais émulé en logiciel, appelé l{{'}}'''''overlaying'''''. Le programme était découpé en plusieurs morceaux, appelés des ''overlays''. Les ''overlays'' les plus importants étaient en permanence en RAM, mais les autres étaient faisaient un va-et-vient entre RAM et disque dur. Ils étaient chargés en RAM lors de leur utilisation, puis sauvegardés sur le disque dur quand ils étaient inutilisés. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. Le matériel n'intervenait pas, comme c'est le cas avec la mémoire virtuelle. Avec la segmentation, un programme peut utiliser la technique des ''overlays'', mais avec l'aide du matériel. Il suffit de mettre chaque ''overlay'' dans son propre segment, et laisser la segmentation faire. Les segments sont swappés en tout ou rien : on doit swapper tout un segment en entier. L'intérêt est que la gestion du ''swapping'' est grandement facilitée, vu que c'est le système d'exploitation qui s'occupe de swapper les segments sur le disque dur ou de charger des segments en RAM. Pas besoin pour le programmeur de coder quoique ce soit. Par contre, cela demande l'intervention du programmeur, qui doit découper le programme en segments/''overlays'' de lui-même. Sans cela, la segmentation n'est pas très utile. L{{'}}''overlaying'' est une forme de '''segmentation à granularité grossière''', à savoir que le programme est découpé en segments de grande taille. L'usage classique est d'avoir un segment pour la pile, un autre pour le code exécutable, un autre pour le reste. Éventuellement, on peut découper les trois segments précédents en deux ou trois segments, rarement au-delà. Les segments sont alors peu nombreux, guère plus d'une dizaine par programme. D'où le terme de ''granularité grossière''. La '''segmentation à granularité fine''' pousse le concept encore plus loin. Avec elle, il y a idéalement un segment par entité manipulée par le programme, un segment pour chaque structure de donnée et/ou chaque objet. Par exemple, un tableau aura son propre segment, ce qui est idéal pour détecter les accès hors tableau. Pour les listes chainées, chaque élément de la liste aura son propre segment. Et ainsi de suite, chaque variable agrégée (non-primitive), chaque structure de donnée, chaque objet, chaque instance d'une classe, a son propre segment. Diverses fonctionnalités supplémentaires peuvent être ajoutées, ce qui transforme le processeur en véritable processeur orienté objet, mais passons ces détails pour le moment. Vu que les segments correspondent à des objets manipulés par le programme, on peut deviner que leur nombre évolue au cours du temps. En effet, les programmes modernes peuvent demander au système d'exploitation du rab de mémoire pour allouer une nouvelle structure de données. Avec la segmentation à granularité fine, cela demande d'allouer un nouveau segment à chaque nouvelle allocation mémoire, à chaque création d'une nouvelle structure de données ou d'un objet. De plus, les programmes peuvent libérer de la mémoire, en supprimant les structures de données ou objets dont ils n'ont plus besoin. Avec la segmentation à granularité fine, cela revient à détruire le segment alloué pour ces objets/structures de données. Le nombre de segments est donc dynamique, il change au cours de l'exécution du programme. ===Les tables de segments avec la segmentation=== La présence de plusieurs segments par programme a un impact sur la table des segments. Avec la relocation matérielle, elle conte nait un segment par programme. Chaque entrée, chaque ligne de la table des segment, mémorisait l'adresse de base, l'adresse limite, un bit de présence pour la mémoire virtuelle et des autorisations liées à la protection mémoire. Avec la segmentation, les choses sont plus compliquées, car il y a plusieurs segments par programme. Les entrées ne sont pas modifiées, mais elles sont organisées différemment. Avec cette forme de segmentation, la table des segments doit respecter plusieurs contraintes. Premièrement, il y a plusieurs segments par programmes. Deuxièmement, le nombre de segments est variable : certains programmes se contenteront d'un seul segment, d'autres de dizaine, d'autres plusieurs centaines, etc. Il y a typiquement deux manières de faire : soit utiliser une table des segments uniques, utiliser une table des segment par programme. Il est possible d'utiliser une table des segment unique qui mémorise tous les segments de tous les processus, système d'exploitation inclut. On parle alors de '''table des segment globale'''. Mais cette solution n'est pas utilisée avec la segmentation proprement dite. Elle est utilisée sur les architectures à capacité qu'on détaillera vers la fin du chapitre, dans une section dédiée. À la place, la segmentation utilise une table de segment par processus/programme, chacun ayant une '''table des segment locale'''. Dans les faits, les choses sont plus compliquées. Le système d'exploitation doit savoir où se trouvent les tables de segment locale pour chaque programme. Pour cela, il a besoin d'utiliser une table de segment globale, dont chaque entrée pointe non pas vers un segment, mais vers une table de segment locale. Lorsque l'OS effectue une commutation de contexte, il lit la table des segment globale, pour récupérer un pointeur vers celle-ci. Ce pointeur est alors chargé dans un registre du processeur, qui mémorise l'adresse de la table locale, ce qui sert lors des accès mémoire. Une telle organisation fait que les segments d'un processus/programme sont invisibles pour les autres, il y a une certaine forme de sécurité. Un programme ne connait que sa table de segments locale, il n'a pas accès directement à la table des segments globales. Tout accès mémoire se passera à travers la table de segment locale, il ne sait pas où se trouvent les autres tables de segment locales. Les processeurs x86 sont dans ce cas : ils utilisent une table de segment globale couplée à autant de table des segments qu'il y a de processus en cours d'exécution. La table des segments globale s'appelle la '''''Global Descriptor Table''''' et elle peut contenir 8192 segments maximum, ce qui permet le support de 8192 processus différents. Les tables de segments locales sont appelées les '''''Local Descriptor Table''''' et elles font aussi 8192 segments maximum, ce qui fait 8192 segments par programme maximum. Il faut noter que la table de segment globale peut mémoriser des pointeurs vers les routines d'interruption, certaines données partagées (le tampon mémoire pour le clavier) et quelques autres choses, qui n'ont pas leur place dans les tables de segment locales. ===La relocation avec la segmentation=== La table des segments locale mémorise les adresses de base et limite de chaque segment, ainsi que d'autres méta-données. Les informations pour un segment sont regroupés dans un '''descripteur de segment''', qui est codé sur plusieurs octets, et qui regroupe : adresse de base, adresse limite, bit de présence en RAM, méta-données de protection mémoire. La table des segments est un tableau dans lequel les descripteurs de segment sont placés les uns à la suite des autres en mémoire RAM. La table des segments est donc un tableau de segment. Les segments d'un programme sont numérotés, le nombre s'appelant un '''indice de segment''', appelé '''sélecteur de segment''' dans la terminologie Intel. L'indice de segment n'est autre que l'indice du segment dans ce tableau. [[File:Global Descriptor table.png|centre|vignette|upright=2|Table des segments locale.]] Il n'y a pas de registre de segment proprement dit, qui mémoriserait l'adresse de base. À la place, les segments sont adressés de manière indirecte. À la place, les registres de segment mémorisent des sélecteurs de segment. Ils sont utilisés pour lire l'adresse de base/limite dans la table de segment en mémoire RAM. Pour cela, un registre mémorise l'adresse de la table de segment locale, sa position en mémoire RAM. Toute lecture ou écriture se fait en deux temps, en deux accès mémoire, consécutifs. Premièrement, le numéro de segment est utilisé pour adresser la table des segment. La lecture récupère alors un pointeur vers ce segment. Deuxièmement, ce pointeur est utilisé pour faire la lecture ou écriture. Plus précisément, la première lecture récupère un descripteur de segment qui contient l'adresse de base, le pointeur voulu, mais aussi l'adresse limite et d'autres informations. [[File:Segmentation avec table des segments.png|centre|vignette|upright=2|Segmentation avec table des segments]] L'accès à la table des segments se fait automatiquement à chaque accès mémoire. La conséquence est que chaque accès mémoire demande d'en faire deux : un pour lire la table des segments, l'autre pour l'accès lui-même. Il s'agit en quelque sorte d'une forme d'adressage indirect mémoire. Un point important est que si le premier accès ne fait qu'une simple lecture dans un tableau, le second accès implique des calculs d'adresse. En effet, le premier accès récupère l'adresse de base du segment, mais le second accès sélectionne une donnée dans le segment, ce qui demande de calculer son adresse. L'adresse finale se déduit en combinant l'adresse de base avec un décalage (''offset'') qui donne la position de la donnée dans ce segment. L'indice de segment est utilisé pour récupérer l'adresse de base du segment. Une fois cette adresse de base connue, on lui additionne le décalage pour obtenir l'adresse finale. [[File:Table des segments.png|centre|vignette|upright=2|Traduction d'adresse avec une table des segments.]] Pour effectuer automatiquement l'accès à la table des segments, le processeur doit contenir un registre supplémentaire, qui contient l'adresse de la table de segment, afin de la localiser en mémoire RAM. Nous appellerons ce registre le '''pointeur de table'''. Le pointeur de table est combiné avec l'indice de segment pour adresser le descripteur de segment adéquat. [[File:Segment 2.svg|centre|vignette|upright=2|Traduction d'adresse avec une table des segments, ici appelée table globale des de"scripteurs (terminologie des processeurs Intel x86).]] Un point important est que la table des segments n'est pas accessible pour le programme en cours d'exécution. Il ne peut pas lire le contenu de la table des segments, et encore moins la modifier. L'accès se fait seulement de manière indirecte, en faisant usage des indices de segments, mais c'est un adressage indirect. Seul le système d'exploitation peut lire ou écrire la table des segments directement. Plus haut, j'ai dit que tout accès mémoire impliquait deux accès mémoire : un pour charger le descripteur de segment, un autre pour la lecture/écriture proprement dite. Cependant, cela aurait un impact bien trop grand sur les performances. Dans les faits, les processeurs avec segmentations intégraient un '''cache de descripteurs de segments''', pour limiter la casse. Quand un descripteur de segment est lu depuis la RAM, il est copié dans ce cache. Les accès ultérieurs accédent au descripteur dans le cache, pas besoin de passer par la RAM. L'intel 386 avait un cache de ce type. ===La protection mémoire : les accès hors-segments=== Comme avec la relocation matérielle, le processeur détecte les débordements de segment. Pour cela, il compare l'adresse logique accédée avec l'adresse limite, ou compare la taille limite avec le décalage. De nombreux processeurs, comme l'Intel 386, préféraient utiliser la taille du segment, pour une question d'optimisation. En effet, si on compare l'adresse finale avec l'adresse limite, on doit faire la relocation avant de comparer l'adresse relocatée. Mais en utilisant la taille, ce n'est pas le cas : on peut faire la comparaison avant, pendant ou après la relocation. Un détail à prendre en compte est la taille de la donnée accédée. Sans cela, la comparaison serait très simple : on vérifie si ''décalage <= taille du segment'', ou on compare des adresses de la même manière. Mais imaginez qu'on accède à une donnée de 4 octets : il se peut que l'adresse de ces 4 octets rentre dans le segment, mais que quelques octets débordent. Par exemple, les deux premiers octets sont dans le segment, mais pas les deux suivants. La vraie comparaison est alors : ''décalage + 4 octets <= taille du segment''. Mais il est possible de faire le calcul autrement, et quelques processeurs comme l'Intel 386 ne s'en sont pas privé. Il calculait la différence ''taille du segment - décalage'', et vérifiait le résultat. Le processeur gérait des données de 1, 2 et 4 octets, ce qui fait que le résultat devait être entre 0 et 3. Le processeur prenait le résultat de la soustraction, et vérifiait alors que les 30 bits de poids fort valaient bien 0. Il vérifiait aussi que les deux bits de poids faible avaient la bonne valeur. [[File:Vm7.svg|centre|vignette|upright=2|Traduction d'adresse avec vérification des accès hors-segment.]] Une nouveauté fait son apparition avec la segmentation : la '''gestion des droits d'accès'''. Par exemple, il est possible d'interdire d'exécuter le contenu d'un segment, ce qui fournit une protection contre certaines failles de sécurité ou certains virus. Lorsqu'on exécute une opération interdite, le processeur lève une exception matérielle, à charge du système d'exploitation de gérer la situation. Pour cela, chaque segment se voit attribuer un certain nombre d'autorisations d'accès qui indiquent si l'on peut lire ou écrire dedans, si celui-ci contient un programme exécutable, etc. Les autorisations pour chaque segment sont placées dans le descripteur de segment. Elles se résument généralement à quelques bits, qui indiquent si le segment est accesible en lecture/écriture ou exécutable. Le tout est souvent concaténé dans un ou deux '''octets de droits d'accès'''. L'implémentation de la protection mémoire dépend du CPU considéré. Les CPU microcodés peuvent en théorie utiliser le microcode. Lorsqu'une instruction mémoire s'exécute, le microcode effectue trois étapes : lire le descripteur de segment, faire les tests de protection mémoire, exécuter la lecture/écriture ou lever une exception. Létape de test est réalisée avec un ou plusieurs micro-branchements. Par exemple, une écriture va tester le bit R/W du descripteur, qui indique si on peut écrire dans le segment, en utilisant un micro-branchement. Le micro-branchement enverra vers une routine du microcode en cas d'erreur. Les tests de protection mémoire demandent cependant de tester beaucoup de conditions différentes. Par exemple, le CPU Intel 386 testait moins d'une dizaine de conditions pour certaines instructions. Il est cependant possible de faire plusieurs comparaisons en parallèle en rusant un peu. Il suffit de mémoriser les octets de droits d'accès dans un registre interne, de masquer les bits non-pertinents, et de faire une comparaison avec une constante adéquate, qui encode la valeur que doivent avoir ces bits. Une solution alternative utiliser un circuit combinatoire pour faire les tests de protection mémoire. Les tests sont alors faits en parallèles, plutôt qu'un par un par des micro-branchements. Par contre, le cout en matériel est assez important. Il faut ajouter ce circuit combinatoire, ce qui demande pas mal de circuits. ===La mémoire virtuelle avec la segmentation=== La mémoire virtuelle est une fonctionnalité souvent implémentée sur les processeurs qui gèrent la segmentation, alors que les processeurs avec relocation matérielle s'en passaient. Il faut dire que l'implémentation de la mémoire virtuelle est beaucoup plus simple avec la segmentation, comparé à la relocation matérielle. Le remplacement des registres de base par des sélecteurs de segment facilite grandement l'implémentation. Le problème de la mémoire virtuelle est que les segments peuvent être swappés sur le disque dur n'importe quand, sans que le programme soit prévu. Le swapping est réalisé par une interruption de l'OS, qui peut interrompre le programme n'importe quand. Et si un segment est swappé, le registre de base correspondant devient invalide, il point sur une adresse en RAM où le segment était, mais n'est plus. De plus, les segments peuvent être déplacés en mémoire, là encore n'importe quand et d'une manière invisible par le programme, ce qui fait que les registres de base adéquats doivent être modifiés. Si le programme entier est swappé d'un coup, comme avec la relocation matérielle simple, cela ne pose pas de problèmes. Mais dès qu'on utilise plusieurs registres de base par programme, les choses deviennent soudainement plus compliquées. Le problème est qu'il n'y a pas de mécanismes pour choisir et invalider le registre de base adéquat quand un segment est déplacé/swappé. En théorie, on pourrait imaginer des systèmes qui résolvent le problème au niveau de l'OS, mais tous ont des problèmes qui font que l'implémentation est compliquée ou que les performances sont ridicules. L'usage d'une table des segments accédée à chaque accès résout complètement le problème. La table des segments est accédée à chaque accès mémoire, elle sait si le segment est swappé ou non, chaque accès vérifie si le segment est en mémoire et quelle est son adresse de base. On peut changer le segment de place n'importe quand, le prochain accès récupérera des informations à jour dans la table des segments. L'implémentation de la mémoire virtuelle avec la segmentation est simple : il suffit d'ajouter un bit dans les descripteurs de segments, qui indique si le segment est swappé ou non. Tout le reste, la gestion de ce bit, du swap, et tout ce qui est nécessaire, est délégué au système d'exploitation. Lors de chaque accès mémoire, le processeur vérifie ce bit avant de faire la traduction d'adresse, et déclenche une exception matérielle si le bit indique que le segment est swappé. L'exception matérielle est gérée par l'OS. ===Le partage de segments=== Il est possible de partager un segment entre plusieurs applications. Cela peut servir pour partager des données entre deux programmes : un segment de données partagées est alors partagé entre deux programmes. Partager un segment de code est utile pour les bibliothèques partagées : la bibliothèque est placée dans un segment dédié, qui est partagé entre les programmes qui l'utilisent. Partager un segment de code est aussi utile quand plusieurs instances d'une même application sont lancés simultanément : le code n'ayant pas de raison de changer, celui-ci est partagé entre toutes les instances. Mais ce n'est là qu'un exemple. La première solution pour cela est de configurer les tables de segment convenablement. Le même segment peut avoir des droits d'accès différents selon les processus. Les adresses de base/limite sont identiques, mais les tables des segments ont alors des droits d'accès différents. Mais cette méthode de partage des segments a plusieurs défauts. Premièrement, les sélecteurs de segments ne sont pas les mêmes d'un processus à l'autre, pour un même segment. Le segment partagé peut correspondre au segment numéro 80 dans le premier processus, au segment numéro 1092 dans le second processus. Rien n'impose que les sélecteurs de segment soient les mêmes d'un processus à l'autre, pour un segment identique. Deuxièmement, les adresses limite et de base sont dupliquées dans plusieurs tables de segments. En soi, cette redondance est un souci mineur. Mais une autre conséquence est une question de sécurité : que se passe-t-il si jamais un processus a une table des segments corrompue ? Il se peut que pour un segment identique, deux processus n'aient pas la même adresse limite, ce qui peut causer des failles de sécurité. Un processus peut alors subir un débordement de tampon, ou tout autre forme d'attaque. [[File:Vm9.png|centre|vignette|upright=2|Illustration du partage d'un segment entre deux applications.]] Une seconde solution, complémentaire, utilise une table de segment globale, qui mémorise des segments partagés ou accessibles par tous les processus. Les défauts de la méthode précédente disparaissent avec cette technique : un segment est identifié par un sélecteur unique pour tous les processus, il n'y a pas de duplication des descripteurs de segment. Par contre, elle a plusieurs défauts. Le défaut principal est que cette table des segments est accessible par tous les processus, impossible de ne partager ses segments qu'avec certains pas avec les autres. Un autre défaut est que les droits d'accès à un segment partagé sont identiques pour tous les processus. Impossible d'avoir un segment partagé accessible en lecture seule pour un processus, mais accessible en écriture pour un autre. Il est possible de corriger ces défauts, mais nous en parlerons dans la section sur les architectures à capacité. ===L'extension d'adresse avec la segmentation=== L'extension d'adresse est possible avec la segmentation, de la même manière qu'avec la relocation matérielle. Il suffit juste que les adresses de base soient aussi grandes que le bus d'adresse. Mais il y a une différence avec la relocation matérielle : un même programme peut utiliser plus de mémoire qu'il n'y en a dans l'espace d'adressage. La raison est simple : un segment peut prendre tout l'espace d'adressage, et il y a plusieurs segments par programme. Pour donner un exemple, prenons un processeur 16 bits, qui peut adresser 64 kibioctets, associé à une mémoire de 4 mébioctets. Il est possible de placer le code machine dans les premiers 64k de la mémoire, la pile du programme dans les 64k suivants, le tas dans les 64k encore après, et ainsi de suite. Le programme dépasse donc les 64k de mémoire de l'espace d'adressage. Ce genre de chose est impossible avec la relocation, où un programme est limité par l'espace d'adressage. ===Le mode protégé des processeurs x86=== L'Intel 80286, aussi appelé 286, ajouta un mode de segmentation séparé du mode réel, qui ajoute une protection mémoire à la segmentation, ce qui lui vaut le nom de '''mode protégé'''. Dans ce mode, les registres de segment ne contiennent pas des adresses de base, mais des sélecteurs de segments qui sont utilisés pour l'accès à la table des segments en mémoire RAM. Le 286 bootait en mode réel, puis le système d'exploitation devait faire quelques manipulations pour passer en mode protégé. Le 286 était pensé pour être rétrocompatible au maximum avec le 80186. Mais les différences entre le 286 et le 8086 étaient majeures, au point que les applications devaient être réécrites intégralement pour profiter du mode protégé. Un mode de compatibilité permettait cependant aux applications destinées au 8086 de fonctionner, avec même de meilleures performances. Aussi, le mode protégé resta inutilisé sur la plupart des applications exécutées sur le 286. Vint ensuite le processeur 80386, renommé en 386 quelques années plus tard. Sur ce processeur, les modes réel et protégé sont conservés tel quel, à une différence près : toutes les adresses passent à 32 bits, qu'il s'agisse des adresses de base, limite ou des ''offsets''. Le processeur peut donc adresser un grand nombre de segments : 2^32, soit plus de 4 milliards. Les segments grandissent aussi et passent de 64 KB maximum à 4 gibioctets maximum. Mais surtout : le 386 ajouta le support de la pagination en plus de la segmentation. Ces modifications ont été conservées sur les processeurs 32 bits ultérieurs. Les processeurs x86 gèrent deux types de tables des segments : une table locale pour chaque processus, et une table globale partagée entre tous les processus. Il ne peut y avoir qu'une table locale d'active, vu que le processeur ne peut exécuter qu'un seul processus en même temps. Chaque table locale définit 8192 segments, pareil pour la table globale. La table globale est utilisée pour les segments du noyau et la mémoire partagée entre processus. Un défaut est qu'un segment partagé par la table globale est visible par tous les processus, avec les mêmes droits d'accès. Ce qui fait que cette méthode était peu utilisée en pratique. La table globale mémorise aussi des pointeurs vers les tables locales, avec un descripteur de segment par table locale. Sur les processeurs x86 32 bits, un descripteur de segment est organisé comme suit, pour les architectures 32 bits. On y trouve l'adresse de base et la taille limite, ainsi que de nombreux bits de contrôle. Le premier groupe de bits de contrôle est l'octet en bleu à droite. Il contient : * le bit P qui indique que l'entrée contient un descripteur valide, qu'elle n'est pas vide ; * deux bits DPL qui indiquent le niveau de privilège du segment (noyau, utilisateur, les deux intermédiaires spécifiques au x86) ; * un bit S qui précise si le segment est de type système (utiles pour l'OS) ou un segment de code/données. * un champ Type qui contient les bits suivants : ** un bit E qui indique si le segment contient du code exécutable ou non ; ** le bit RW qui indique s'il est en lecture seule ou non ;; ** Un bit A qui indique que le segment a récemment été accédé, information utile pour l'OS; ** un bit DC assez spécifiques. En haut à gauche, en bleu, on trouve deux bits : * Le bit G indique comment interpréter la taille contenue dans le descripteur : 0 si la taille est exprimée en octets, 1 si la taille est un nombre de pages de 4 kibioctets. Ce bit précise si on utilise la segmentation seule, ou combinée avec la pagination. * Le bit DB précise si l'on utilise des segments en mode de compatibilité 16 bits ou des segments 32 bits. [[File:SegmentDescriptor.svg|centre|vignette|upright=3|Segment Descriptor]] Les indices de segment sont appelés des sélecteurs de segment. Ils ont une taille de 16 bits, mais 3 bits sont utilisés pour encoder des méta-données. Le numéro de segment est donc codé sur 13 bits, ce qui permettait de gérer maximum 8192 segments par table de segment (locale ou globale). Les 16 bits sont organisés comme suit : * 13 bits pour le numéro du segment dans la table des segments, l'indice de segment proprement dit ; * un bit qui précise s'il faut accéder à la table des segments globale ou locale ; * deux bits qui indiquent le niveau de privilège de l'accès au segment (les 4 niveaux de protection, dont l'espace noyau et utilisateur). [[File:SegmentSelector.svg|centre|vignette|upright=1.5|Sélecteur de segment 16 bit.]] En tout, l'indice permet de gérer 8192 segments pour la table locale et 8192 segments de la table globale. ====La MMU du 386/486 : cache de segment, protection mémoire==== La MMU du 386 et celle du 486 étaient assez similaires. Elles étaient plus complexes que celle du 186 et du 286. Elle contenait un additionneur pour les calculs d'adresse et un comparateur pour tester si l'accès mémoire déborde d'un segment. Le test de débordement se faisait en parallèle du calcul de l'adresse finale, comme sur le 286. L'additionneur était un additionneur trois-opérandes, qui additionnait l'adresse à lire/écrire, l'adresse de base du segment, et un décalage intégré dans l'instruction. En clair, l'unité de segmentation n'était pas qu'une MMU, elle prenait en charge une partie du calcul d'adresse. L'avantage est que cela permettait de gérer les modes d'adressage "base + décalage" et "base + indice + décalage" très facilement, en utilisant un minimum de circuits. Une conséquence de cette organisation était que l'usage d'un décalage était gratuit. Par contre, dès qu'on utilisait l'adressage "Base + Indice", avec ou sans décalage, l'instruction prenait un cycle de plus à s'exécuter, parce qu'il fallait faire l'addition "Base + Indice" dans l'ALU entière. Le CPU 386 était le premier à implémenter la protection mémoire avec des segments. Pour cela, il intégrait une '''''Protection Test Unit''''', séparée du microcode, qu'on va abrévier en PTU. Précisément, il s'agissait d'un PLA (''Programmable Logic Array''), une sorte d'intermédiaire entre circuit logique fait sur mesure et mémoire ROM, qu'on a déjà abordé dans le chapitre sur les mémoires ROM. Mais cette unité ne faisait pas tout, le microcode était aussi impliqué. La PTU sera détaillée dans la section suivante. Pour améliorer les performances, le 386 et le 486 intégraient un '''cache de descripteurs de segment''', aussi appelé le cache de descripteurs. Lorsqu'un descripteur état chargé pour la première fois, il était copié dans le cache de descripteurs de segment. Les accès mémoire ultérieur lisaient le descripteur de segment depuis ce cache, pas depuis la table des segments en RAM. Le cache de descripteurs gère aussi bien les segments en mode réel qu'en mode protégé. Récupérer l'adresse de base depuis cache se fait un peu différemment en mode réel et protégé, mais le cache gère cela tout seul. Idem pour récupérer la taille/adresse limite. [[File:Microarchitecture du 386, avec focus sur la segmentation.png|centre|vignette|upright=2|Microarchitecture du 386 et du 486, avec focus sur la segmentation.]] En mode réel, la taille des segment est censée être limitée à 64 kibioctets. Mais le processeur ne vérifiait pas si cette limite était dépassée. À la place, il utilisait la limite précisée dans le cache de segments. Pire que ça : le cache de segment n'était pas réinitialisé quand on passe du mode réel au mode protégé, et réciproquement. Et cette propriété a été à l'origine de l''''''unreal mode'''''. Il s'agissait d'un mode réel amélioré, capable d'utiliser des segments de 4 gibioctets et des adresses de 32 bits. Passer en mode ''unreal'' pouvait se faire de deux manières. Il était possible d'altérer le cache de descripteur en utilisant l'instruction non-documentée LOADALL. Elle permettait de charger les descripteurs de segments dans le cache de descripteurs, avec une taille arbitraire. Une autre solution, beaucoup plus complexe sur le 386, demandait d'entrer en mode protégé pour configurer des segments de grande taille, de charger leurs descripteurs dans le cache de descripteur, puis de revenir en mode réel. En mode réel, les descripteurs dans le cache étaient encore disponibles et on pouvait les lire dans le cache. ====L'implémentation de la protection mémoire sur le 386==== La protection mémoire teste la valeur des bits P, S, X, E, R/W. Elle teste aussi les niveaux de privilège, avec deux bits DPL et CPL. En tout, le processeur pouvait tester 148 conditions différentes en parallèle dans la PTU. Cependant, les niveaux de privilèges étaient pré-traités par le microcode. Le microcode vérifiait aussi s'il y avait une erreur en terme d’anneau mémoire, avec par "exemple un segment en mode noyau accédé alors que le CPU est en espace utilisateur. Il fournissait alors un résultat sur deux bits, qui indiquait s'il y avait une erreur ou non, que la PTU utilisait. Mais toutes les conditions n'étaient pas pertinentes à un instant t. Par exemple, il est pertinent de vérifier si le bit R/W était cohérent si l'instruction à exécuter est une écriture. Mais il n'y a pas besoin de tester le bit E qui indique qu'un segment est exécutable ou non, pour une lecture. En tout, le processeur pouvait se retrouver dans 33 situations possibles, chacune demandant de tester un sous-ensemble des 148 conditions. Pour préciser quel sous-ensembles tester, la PTU recevait un code opération, généré par le microcode. Pour faire les tests de protection mémoire, le microcode avait une micro-opération nommée ''protection test operation'', qui envoyait les droits d'accès à la PTU. Lors de l'exécution d'une ''protection test operation'', le PLA recevait un descripteur de segment, lu depuis la mémoire RAM, ainsi qu'un code opération provenant du microcode. {|class="wikitable" |+ Entrée de la ''Protection Test Unit'' |- ! 15 - 14 !! 13 - 12 !! 11 !! 10 !! 9 !! 8 !! 7 !! 6 !! 5-0 |- | P1 , P2 || || P || S || X || E || R/W || A || Code opération |- | Niveaux de privilèges cohérents/erreur || || Segment présent en mémoire ou swappé || S || X || Segment exécutable ou non || Segment accesible en lecture/écriture || Segment récemment accédé || Code opération |} Il fournissait en sortie un bit qui indiquait si une erreur de protection mémoire avait eu lieu ou non. Il fournissait aussi une adresse de 12 bits, utilisée seulement en cas d'erruer. Elle pointait dans le microcode, sur un code levant une exception en cas d'erreur. Enfin, la PTU fournissait 4 bits pouvant être testés par un branchement dans le microcode. L'un d'entre eux demandait de tester s'il y a un accès hors-limite, les autres étaient assez peu reliés à la protection mémoire. Un détail est que le chargement du descripteur de segment est réalisé par une fonction dans le microcode. Elle est appliquée pour toutes les instructions ou situations qui demandent de faire un accès mémoire. Et les tests de protection mémoire sont réalisés dans cette fonction, pas après elle. Vu qu'il s'agit d'une fonction exécutée quelque soit l'instruction, le microcode doit transférer le code opération à cette fonction. Le microcode est pour cela associé à un registre interne, dans lequel le code opération est mémorisé, avant d'appeler la fonction. Le microcode a une micro-opération PTSAV (''Protection Save'') pour mémoriser le code opération dans ce registre. Dans la fonction qui charge le descripteur, une micro-opération PTOVRR (''Protection Override'') lit le code opération dans ce registre, et lance les tests nécessaires. Il faut noter que le PLA était certes plus rapide que de tester les conditions une par une, mais il était assez lent. La PTU mettait environ 3 cycles d'horloges pour rendre son résultat. Le microcode en profitait alors pour exécuter des micro-opérations durant ces 3 cycles d'attente. Par exemple, le microcode pouvait en profiter pour lire l'adresse de base dans le descripteur, si elle n'a pas été chargée avant (les descripteur était chargé en deux fois). Il fallait cependant que les trois micro-opérations soient valides, peu importe qu'il y ait une erreur de protection mémoire ou non. Ou du moins, elles produisaient un résultat qui n'est pas utilisé en cas d'erreur. Si ce n'était pas possible, le microcode ajoutait des NOP pendant ce temps d'attente de 3 cycles. Le bit A du descripteur de segment indique que le segment a récemment été accédé. Il est mis à jour après les tests de protection mémoire, quand ceux-ci indiquent que l'accès mémoire est autorisé. Le bit A est mis à 1 si la PTU l'autorise. Pour cela, la PTU utilise un des 4 bits de sortie mentionnés plus haut : l'un d'entre eux indique que le bit A doit être mis à 1. La mise à jour est ensuite réalisée par le microcode, qui utilise trois micro-opérations pour le mettre à jour. ====Le ''Hardware task switching'' des CPU x86==== Les systèmes d’exploitation modernes peuvent lancer plusieurs logiciels en même temps. Les logiciels sont alors exécutés à tour de rôle. Passer d'un programme à un autre est ce qui s'appelle une commutation de contexte. Lors d'une commutation de contexte, l'état du processeur est sauvegardé, afin que le programme stoppé puisse reprendre là où il était. Il arrivera un moment où le programme stoppé redémarrera et il doit reprendre dans l'état exact où il s'est arrêté. Deuxièmement, le programme à qui c'est le tour restaure son état. Cela lui permet de revenir là où il était avant d'être stoppé. Il y a donc une sauvegarde et une restauration des registres. Divers processeurs incorporent des optimisations matérielles pour rendre la commutation de contexte plus rapide. Ils peuvent sauvegarder et restaurer les registres du processeur automatiquement lors d'une interruption de commutation de contexte. Les registres sont sauvegardés dans des structures de données en mémoire RAM, appelées des '''contextes matériels'''. Sur les processeurs x86, il s'agit de la technique d{{'}}''Hardware Task Switching''. Fait intéressant, le ''Hardware Task Switching'' se base beaucoup sur les segments mémoires. Avec ''Hardware Task Switching'', chaque contexte matériel est mémorisé dans son propre segment mémoire, séparé des autres. Les segments pour les contextes matériels sont appelés des '''''Task State Segment''''' (TSS). Un TSS mémorise tous les registres généraux, le registre d'état, les pointeurs de pile, le ''program counter'' et quelques registres de contrôle du processeur. Par contre, les registres flottants ne sont pas sauvegardés, de même que certaines registres dit SIMD que nous n'avons pas encore abordé. Et c'est un défaut qui fait que le ''Hardware Task Switching'' n'est plus utilisé. Le programme en cours d'exécution connait l'adresse du TSS qui lui est attribué, car elle est mémorisée dans un registre appelé le '''''Task Register'''''. En plus de pointer sur le TSS, ce registre contient aussi les adresses de base et limite du segment en cours. Pour être plus précis, le ''Task Register'' ne mémorise pas vraiment l'adresse du TSS. À la place, elle mémorise le numéro du segment, le numéro du TSS. Le numéro est codé sur 16 bits, ce qui explique que 65 536 segments sont adressables. Les instructions LDR et STR permettent de lire/écrire ce numéro de segment dans le ''Task Register''. Le démarrage d'un programme a lieu automatiquement dans plusieurs circonstances. La première est une instruction de branchement CALL ou JMP adéquate. Le branchement fournit non pas une adresse à laquelle brancher, mais un numéro de segment qui pointe vers un TSS. Cela permet à une routine du système d'exploitation de restaurer les registres et de démarrer le programme en une seule instruction de branchement. Une seconde circonstance est une interruption matérielle ou une exception, mais nous la mettons de côté. Le ''Task Register'' est alors initialisé avec le numéro de segment fournit. S'en suit la procédure suivante : * Le ''Task Register'' est utilisé pour adresser la table des segments, pour récupérer un pointeur vers le TSS associé. * Le pointeur est utilisé pour une seconde lecture, qui adresse le TSS directement. Celle-ci restaure les registres du processeur. En clair, on va lire le ''TSS descriptor'' dans la GDT, puis on l'utilise pour restaurer les registres du processeur. [[File:Hardware Task Switching x86.png|centre|vignette|upright=2|Hardware Task Switching x86]] ===La segmentation sur les processeurs Burrough B5000 et plus=== Le Burrough B5000 est un très vieil ordinateur, commercialisé à partir de l'année 1961. Ses successeurs reprennent globalement la même architecture. C'était une machine à pile, doublé d'une architecture taguée, choses très rare de nos jours. Mais ce qui va nous intéresser dans ce chapitre est que ce processeur incorporait la segmentation, avec cependant une différence de taille : un programme avait accès à un grand nombre de segments. La limite était de 1024 segments par programme ! Il va de soi que des segments plus petits favorise l'implémentation de la mémoire virtuelle, mais complexifie la relocation et le reste, comme nous allons le voir. Le processeur gère deux types de segments : les segments de données et de procédure/fonction. Les premiers mémorisent un bloc de données, dont le contenu est laissé à l'appréciation du programmeur. Les seconds sont des segments qui contiennent chacun une procédure, une fonction. L'usage des segments est donc différent de ce qu'on a sur les processeurs x86, qui n'avaient qu'un segment unique pour l'intégralité du code machine. Un seul segment de code machine x86 est découpé en un grand nombre de segments de code sur les processeurs Burrough. La table des segments contenait 1024 entrées de 48 bits chacune. Fait intéressant, chaque entrée de la table des segments pouvait mémoriser non seulement un descripteur de segment, mais aussi une valeur flottante ou d'autres types de données ! Parler de table des segments est donc quelque peu trompeur, car cette table ne gère pas que des segments, mais aussi des données. La documentation appelaiat cette table la '''''Program Reference Table''''', ou PRT. La raison de ce choix quelque peu bizarre est que les instructions ne gèrent pas d'adresses proprement dit. Tous les accès mémoire à des données en-dehors de la pile passent par la segmentation, ils précisent tous un indice de segment et un ''offset''. Pour éviter d'allouer un segment pour chaque donnée, les concepteurs du processeur ont décidé qu'une entrée pouvait contenir directement la donnée entière à lire/écrire. La PRT supporte trois types de segments/descripteurs : les descripteurs de données, les descripteurs de programme et les descripteurs d'entrées-sorties. Les premiers décrivent des segments de données. Les seconds sont associés aux segments de procédure/fonction et sont utilisés pour les appels de fonction (qui passent, eux aussi, par la segmentation). Le dernier type de descripteurs sert pour les appels systèmes et les communications avec l'OS ou les périphériques. Chaque entrée de la PRT contient un ''tag'', une suite de bit qui indique le type de l'entrée : est-ce qu'elle contient un descripteur de segment, une donnée, autre. Les descripteurs contiennent aussi un ''bit de présence'' qui indique si le segment a été swappé ou non. Car oui, les segments pouvaient être swappés sur ce processeur, ce qui n'est pas étonnant vu que les segments sont plus petits sur cette architecture. Le descripteur contient aussi l'adresse de base du segment ainsi que sa taille, et diverses informations pour le retrouver sur le disque dur s'il est swappé. : L'adresse mémorisée ne faisait que 15 bits, ce qui permettait d'adresse 32 kibi-mots, soit 192 kibioctets de mémoire. Diverses techniques d'extension d'adressage étaient disponibles pour contourner cette limitation. Outre l'usage de l{{'}}''overlay'', le processeur et l'OS géraient aussi des identifiants d'espace d'adressage et en fournissaient plusieurs par processus. Les processeurs Borrough suivants utilisaient des adresses plus grandes, de 20 bits, ce qui tempérait le problème. [[File:B6700Word.jpg|centre|vignette|upright=2|Structure d'un mot mémoire sur le B6700.]] ==Les architectures à capacités== Les architectures à capacité utilisent la segmentation à granularité fine, mais ajoutent des mécanismes de protection mémoire assez particuliers, qui font que les architectures à capacité se démarquent du reste. Les architectures de ce type sont très rares et sont des processeurs assez anciens. Le premier d'entre eux était le Plessey System 250, qui date de 1969. Il fu suivi par le CAP computer, vendu entre les années 70 et 77. En 1978, le System/38 d'IBM a eu un petit succès commercial. En 1980, la Flex machine a aussi été vendue, mais à très peu d'examplaires, comme les autres architectures à capacité. Et enfin, en 1981, l'architecture à capacité la plus connue, l'Intel iAPX 432 a été commercialisée. Depuis, la seule architecture de ce type est en cours de développement. Il s'agit de l'architecture CHERI, dont la mise en projet date de 2014. ===Le partage de la mémoire sur les architectures à capacités=== Le partage de segment est grandement modifié sur les architectures à capacité. Avec la segmentation normale, il y a une table de segment par processus. Les conséquences sont assez nombreuses, mais la principale est que partager un segment entre plusieurs processus est compliqué. Les défauts ont été évoqués plus haut. Les sélecteurs de segments ne sont pas les mêmes d'un processus à l'autre, pour un même segment. De plus, les adresses limite et de base sont dupliquées dans plusieurs tables de segments, et cela peut causer des problèmes de sécurité si une table des segments est modifiée et pas l'autre. Et il y a d'autres problèmes, tout aussi importants. [[File:Partage des segments avec la segmentation.png|centre|vignette|upright=1.5|Partage des segments avec la segmentation]] À l'opposé, les architectures à capacité utilisent une table des segments unique pour tous les processus. La table des segments unique sera appelée dans de ce qui suit la '''table des segments globale''', ou encore la table globale. En conséquence, les adresses de base et limite ne sont présentes qu'en un seul exemplaire par segment, au lieu d'être dupliquées dans autant de processus que nécessaire. De plus, cela garantit que l'indice de segment est le même quel que soit le processus qui l'utilise. Un défaut de cette approche est au niveau des droits d'accès. Avec la segmentation normale, les droits d'accès pour un segment sont censés changer d'un processus à l'autre. Par exemple, tel processus a accès en lecture seule au segment, l'autre seulement en écriture, etc. Mais ici, avec une table des segments uniques, cela ne marche plus : incorporer les droits d'accès dans la table des segments ferait que tous les processus auraient les mêmes droits d'accès au segment. Et il faut trouver une solution. ===Les capacités sont des pointeurs protégés=== Pour éviter cela, les droits d'accès sont combinés avec les sélecteurs de segments. Les sélecteurs des segments sont remplacés par des '''capacités''', des pointeurs particuliers formés en concaténant l'indice de segment avec les droits d'accès à ce segment. Si un programme veut accéder à une adresse, il fournit une capacité de la forme "sélecteur:droits d'accès", et un décalage qui indique la position de l'adresse dans le segment. Il est impossible d'accéder à un segment sans avoir la capacité associée, c'est là une sécurité importante. Un accès mémoire demande que l'on ait la capacité pour sélectionner le bon segment, mais aussi que les droits d'accès en permettent l'accès demandé. Par contre, les capacités peuvent être passées d'un programme à un autre sans problème, les deux programmes pourront accéder à un segment tant qu'ils disposent de la capacité associée. [[File:Comparaison entre capacités et adresses segmentées.png|centre|vignette|upright=2.5|Comparaison entre capacités et adresses segmentées]] Mais cette solution a deux problèmes très liés. Au niveau des sélecteurs de segment, le problème est que les sélecteur ont une portée globale. Avant, l'indice de segment était interne à un programme, un sélecteur ne permettait pas d'accéder au segment d'un autre programme. Sur les architectures à capacité, les sélecteurs ont une portée globale. Si un programme arrive à forger un sélecteur qui pointe vers un segment d'un autre programme, il peut théoriquement y accéder, à condition que les droits d'accès le permettent. Et c'est là qu'intervient le second problème : les droits d'accès ne sont plus protégés par l'espace noyau. Les droits d'accès étaient dans la table de segment, accessible uniquement en espace noyau, ce qui empêchait un processus de les modifier. Avec une capacité, il faut ajouter des mécanismes de protection qui empêchent un programme de modifier les droits d'accès à un segment et de générer un indice de segment non-prévu. La première sécurité est qu'un programme ne peut pas créer une capacité, seul le système d'exploitation le peut. Les capacités sont forgées lors de l'allocation mémoire, ce qui est du ressort de l'OS. Pour rappel, un programme qui veut du rab de mémoire RAM peut demander au système d'exploitation de lui allouer de la mémoire supplémentaire. Le système d'exploitation renvoie alors un pointeurs qui pointe vers un nouveau segment. Le pointeur est une capacité. Il doit être impossible de forger une capacité, en-dehors d'une demande d'allocation mémoire effectuée par l'OS. Typiquement, la forge d'une capacité se fait avec des instructions du processeur, que seul l'OS peut éxecuter (pensez à une instruction qui n'est accessible qu'en espace noyau). La seconde protection est que les capacités ne peuvent pas être modifiées sans raison valable, que ce soit pour l'indice de segment ou les droits d'accès. L'indice de segment ne peut pas être modifié, quelqu'en soit la raison. Pour les droits d'accès, la situation est plus compliquée. Il est possible de modifier ses droits d'accès, mais sous conditions. Réduire les droits d'accès d'une capacité est possible, que ce soit en espace noyau ou utilisateur, pas l'OS ou un programme utilisateur, avec une instruction dédiée. Mais augmenter les droits d'accès, seul l'OS peut le faire avec une instruction précise, souvent exécutable seulement en espace noyau. Les capacités peuvent être copiées, et même transférées d'un processus à un autre. Les capacités peuvent être détruites, ce qui permet de libérer la mémoire utilisée par un segment. La copie d'une capacité est contrôlée par l'OS et ne peut se faire que sous conditions. La destruction d'une capacité est par contre possible par tous les processus. La destruction ne signifie pas que le segment est effacé, il est possible que d'autres processus utilisent encore des copies de la capacité, et donc le segment associé. On verra quand la mémoire est libérée plus bas. Protéger les capacités demande plusieurs conditions. Premièrement, le processeur doit faire la distinction entre une capacité et une donnée. Deuxièmement, les capacités ne peuvent être modifiées que par des instructions spécifiques, dont l'exécution est protégée, réservée au noyau. En clair, il doit y avoir une séparation matérielle des capacités, qui sont placées dans des registres séparés. Pour cela, deux solutions sont possibles : soit les capacités remplacent les adresses et sont dispersées en mémoire, soit elles sont regroupées dans un segment protégé. ====La liste des capacités==== Avec la première solution, on regroupe les capacités dans un segment protégé. Chaque programme a accès à un certain nombre de segments et à autant de capacités. Les capacités d'un programme sont souvent regroupées dans une '''liste de capacités''', appelée la '''''C-list'''''. Elle est généralement placée en mémoire RAM. Elle est ce qu'il reste de la table des segments du processus, sauf que cette table ne contient pas les adresses du segment, qui sont dans la table globale. Tout se passe comme si la table des segments de chaque processus est donc scindée en deux : la table globale partagée entre tous les processus contient les informations sur les limites des segments, la ''C-list'' mémorise les droits d'accès et les sélecteurs pour identifier chaque segment. C'est un niveau d'indirection supplémentaire par rapport à la segmentation usuelle. [[File:Architectures à capacité.png|centre|vignette|upright=2|Architectures à capacité]] La liste de capacité est lisible par le programme, qui peut copier librement les capacités dans les registres. Par contre, la liste des capacités est protégée en écriture. Pour le programme, il est impossible de modifier les capacités dedans, impossible d'en rajouter, d'en forger, d'en retirer. De même, il ne peut pas accéder aux segments des autres programmes : il n'a pas les capacités pour adresser ces segments. Pour protéger la ''C-list'' en écriture, la solution la plus utilisée consiste à placer la ''C-list'' dans un segment dédié. Le processeur gère donc plusieurs types de segments : les segments de capacité pour les ''C-list'', les autres types segments pour le reste. Un défaut de cette approche est que les adresses/capacités sont séparées des données. Or, les programmeurs mixent souvent adresses et données, notamment quand ils doivent manipuler des structures de données comme des listes chainées, des arbres, des graphes, etc. L'usage d'une ''C-list'' permet de se passer de la séparation entre espace noyau et utilisateur ! Les segments de capacité sont eux-mêmes adressés par leur propre capacité, avec une capacité par segment de capacité. Le programme a accès à la liste de capacité, comme l'OS, mais leurs droits d'accès ne sont pas les mêmes. Le programme a une capacité vers la ''C-list'' qui n'autorise pas l'écriture, l'OS a une autre capacité qui accepte l'écriture. Les programmes ne pourront pas forger les capacités permettant de modifier les segments de capacité. Une méthode alternative est de ne permettre l'accès aux segments de capacité qu'en espace noyau, mais elle est redondante avec la méthode précédente et moins puissante. ====Les capacités dispersées, les architectures taguées==== Une solution alternative laisse les capacités dispersées en mémoire. Les capacités remplacent les adresses/pointeurs, et elles se trouvent aux mêmes endroits : sur la pile, dans le tas. Comme c'est le cas dans les programmes modernes, chaque allocation mémoire renvoie une capacité, que le programme gére comme il veut. Il peut les mettre dans des structures de données, les placer sur la pile, dans des variables en mémoire, etc. Mais il faut alors distinguer si un mot mémoire contient une capacité ou une autre donnée, les deux ne devant pas être mixés. Pour cela, chaque mot mémoire se voit attribuer un certain bit qui indique s'il s'agit d'un pointeur/capacité ou d'autre chose. Mais cela demande un support matériel, ce qui fait que le processeur devient ce qu'on appelle une ''architecture à tags'', ou ''tagged architectures''. Ici, elles indiquent si le mot mémoire contient une adresse:capacité ou une donnée. [[File:Architectures à capacité sans liste de capacité.png|centre|vignette|upright=2|Architectures à capacité sans liste de capacité]] L'inconvénient est le cout en matériel de cette solution. Il faut ajouter un bit à chaque case mémoire, le processeur doit vérifier les tags avant chaque opération d'accès mémoire, etc. De plus, tous les mots mémoire ont la même taille, ce qui force les capacités à avoir la même taille qu'un entier. Ce qui est compliqué. ===Les registres de capacité=== Les architectures à capacité disposent de registres spécialisés pour les capacités, séparés pour les entiers. La raison principale est une question de sécurité, mais aussi une solution pragmatique au fait que capacités et entiers n'ont pas la même taille. Les registres dédiés aux capacités ne mémorisent pas toujours des capacités proprement dites. À la place, ils mémorisent des descripteurs de segment, qui contiennent l'adresse de base, limite et les droits d'accès. Ils sont utilisés pour la relocation des accès mémoire ultérieurs. Ils sont en réalité identiques aux registres de relocation, voire aux registres de segments. Leur utilité est d'accélérer la relocation, entre autres. Les processeurs à capacité ne gèrent pas d'adresses proprement dit, comme pour la segmentation avec plusieurs registres de relocation. Les accès mémoire doivent préciser deux choses : à quel segment on veut accéder, à quelle position dans le segment se trouve la donnée accédée. La première information se trouve dans le mal nommé "registre de capacité", la seconde information est fournie par l'instruction d'accès mémoire soit dans un registre (Base+Index), soit en adressage base+''offset''. Les registres de capacités sont accessibles à travers des instructions spécialisées. Le processeur ajoute des instructions LOAD/STORE pour les échanges entre table des segments et registres de capacité. Ces instructions sont disponibles en espace utilisateur, pas seulement en espace noyau. Lors du chargement d'une capacité dans ces registres, le processeur vérifie que la capacité chargée est valide, et que les droits d'accès sont corrects. Puis, il accède à la table des segments, récupère les adresses de base et limite, et les mémorise dans le registre de capacité. Les droits d'accès et d'autres méta-données sont aussi mémorisées dans le registre de capacité. En somme, l'instruction de chargement prend une capacité et charge un descripteur de segment dans le registre. Avec ce genre de mécanismes, il devient difficile d’exécuter certains types d'attaques, ce qui est un gage de sureté de fonctionnement indéniable. Du moins, c'est la théorie, car tout repose sur l'intégrité des listes de capacité. Si on peut modifier celles-ci, alors il devient facile de pouvoir accéder à des objets auxquels on n’aurait pas eu droit. ===Le recyclage de mémoire matériel=== Les architectures à capacité séparent les adresses/capacités des nombres entiers. Et cela facilite grandement l'implémentation de la ''garbage collection'', ou '''recyclage de la mémoire''', à savoir un ensemble de techniques logicielles qui visent à libérer la mémoire inutilisée. Rappelons que les programmes peuvent demander à l'OS un rab de mémoire pour y placer quelque chose, généralement une structure de donnée ou un objet. Mais il arrive un moment où cet objet n'est plus utilisé par le programme. Il peut alors demander à l'OS de libérer la portion de mémoire réservée. Sur les architectures à capacité, cela revient à libérer un segment, devenu inutile. La mémoire utilisée par ce segment est alors considérée comme libre, et peut être utilisée pour autre chose. Mais il arrive que les programmes ne libèrent pas le segment en question. Soit parce que le programmeur a mal codé son programme, soit parce que le compilateur n'a pas fait du bon travail ou pour d'autres raisons. Pour éviter cela, les langages de programmation actuels incorporent des '''''garbage collectors''''', des morceaux de code qui scannent la mémoire et détectent les segments inutiles. Pour cela, ils doivent identifier les adresses manipulées par le programme. Si une adresse pointe vers un objet, alors celui-ci est accessible, il sera potentiellement utilisé dans le futur. Mais si aucune adresse ne pointe vers l'objet, alors il est inaccessible et ne sera plus jamais utilisé dans le futur. On peut libérer les objets inaccessibles. Identifier les adresses est cependant très compliqué sur les architectures normales. Sur les processeurs modernes, les ''garbage collectors'' scannent la pile à la recherche des adresses, et considèrent tout mot mémoire comme une adresse potentielle. Mais les architectures à capacité rendent le recyclage de la mémoire très facile. Un segment est accessible si le programme dispose d'une capacité qui pointe vers ce segment, rien de plus. Et les capacités sont facilement identifiables : soit elles sont dans la liste des capacités, soit on peut les identifier à partir de leur ''tag''. Le recyclage de mémoire était parfois implémenté directement en matériel. En soi, son implémentation est assez simple, et peu être réalisé dans le microcode d'un processeur. Une autre solution consiste à utiliser un second processeur, spécialement dédié au recyclage de mémoire, qui exécute un programme spécialement codé pour. Le programme en question est placé dans une mémoire ROM, reliée directement à ce second processeur. ===L'intel iAPX 432=== Voyons maintenat une architecture à capacité assez connue : l'Intel iAPX 432. Oui, vous avez bien lu : Intel a bel et bien réalisé un processeur orienté objet dans sa jeunesse. La conception du processeur Intel iAPX 432 commença en 1975, afin de créer un successeur digne de ce nom aux processeurs 8008 et 8080. La conception du processeur Intel iAPX 432 commença en 1975, afin de créer un successeur digne de ce nom aux processeurs 8008 et 8080. Ce processeur s'est très faiblement vendu en raison de ses performances assez désastreuses et de défauts techniques certains. Par exemple, ce processeur était une machine à pile à une époque où celles-ci étaient tombées en désuétude, il ne pouvait pas effectuer directement de calculs avec des constantes entières autres que 0 et 1, ses instructions avaient un alignement bizarre (elles étaient bit-alignées). Il avait été conçu pour maximiser la compatibilité avec le langage ADA, un langage assez peu utilisé, sans compter que le compilateur pour ce processeur était mauvais. ====Les segments prédéfinis de l'Intel iAPX 432==== L'Intel iAPX432 gère plusieurs types de segments. Rien d'étonnant à cela, les Burrough géraient eux aussi plusieurs types de segments, à savoir des segments de programmes, des segments de données, et des segments d'I/O. C'est la même chose sur l'Intel iAPX 432, mais en bien pire ! Les segments de données sont des segments génériques, dans lequels on peut mettre ce qu'on veut, suivant les besoins du programmeur. Ils sont tous découpés en deux parties de tailles égales : une partie contenant les données de l'objet et une partie pour les capacités. Les capacités d'un segment pointent vers d'autres segments, ce qui permet de créer des structures de données assez complexes. La ligne de démarcation peut être placée n'importe où dans le segment, les deux portions ne sont pas de taille identique, elles ont des tailles qui varient de segment en segment. Il est même possible de réserver le segment entier à des données sans y mettre de capacités, ou inversement. Les capacités et données sont adressées à partir de la ligne de démarcation, qui sert d'adresse de base du segment. Suivant l'instruction utilisée, le processeur accède à la bonne portion du segment. Le processeur supporte aussi d'autres segments pré-définis, qui sont surtout utilisés par le système d'exploitation : * Des segments d'instructions, qui contiennent du code exécutable, typiquement un programme ou des fonctions, parfois des ''threads''. * Des segments de processus, qui mémorisent des processus entiers. Ces segments contiennent des capacités qui pointent vers d'autres segments, notamment un ou plusieurs segments de code, et des segments de données. * Des segments de domaine, pour les modules ou bibliothèques dynamiques. * Des segments de contexte, utilisés pour mémoriser l'état d'un processus, utilisés par l'OS pour faire de la commutation de contexte. * Des segments de message, utilisés pour la communication entre processus par l'intermédiaire de messages. * Et bien d'autres encores. Sur l'Intel iAPX 432, chaque processus est considéré comme un objet à part entière, qui a son propre segment de processus. De même, l'état du processeur (le programme qu'il est en train d’exécuter, son état, etc.) est stocké en mémoire dans un segment de contexte. Il en est de même pour chaque fonction présente en mémoire : elle était encapsulée dans un segment, sur lequel seules quelques manipulations étaient possibles (l’exécuter, notamment). Et ne parlons pas des appels de fonctions qui stockaient l'état de l'appelé directement dans un objet spécial. Bref, de nombreux objets système sont prédéfinis par le processeur : les objets stockant des fonctions, les objets stockant des processus, etc. L'Intel 432 possédait dans ses circuits un ''garbage collector'' matériel. Pour faciliter son fonctionnement, certains bits de l'objet permettaient de savoir si l'objet en question pouvait être supprimé ou non. ====Le support de la segmentation sur l'Intel iAPX 432==== La table des segments est une table hiérarchique, à deux niveaux. Le premier niveau est une ''Object Table Directory'', qui réside toujours en mémoire RAM. Elle contient des descripteurs qui pointent vers des tables secondaires, appelées des ''Object Table''. Il y a plusieurs ''Object Table'', typiquement une par processus. Plusieurs processus peuvent partager la même ''Object Table''. Les ''Object Table'' peuvent être swappées, mais pas l{{'}}''Object Table Directory''. Une capacité tient compte de l'organisation hiérarchique de la table des segments. Elle contient un indice qui précise quelle ''Object Table'' utiliser, et l'indice du segment dans cette ''Object Table''. Le premier indice adresse l{{'}}''Object Table Directory'' et récupère un descripteur de segment qui pointe sur la bonne ''Object Table''. Le second indice est alors utilisé pour lire l'adresse de base adéquate dans cette ''Object Table''. La capacité contient aussi des droits d'accès en lecture, écriture, suppression et copie. Il y a aussi un champ pour le type, qu'on verra plus bas. Au fait : les capacités étaient appelées des ''Access Descriptors'' dans la documentation officielle. Une capacité fait 32 bits, avec un octet utilisé pour les droits d'accès, laissant 24 bits pour adresser les segments. Le processeur gérait jusqu'à 2^24 segments/objets différents, pouvant mesurer jusqu'à 64 kibioctets chacun, ce qui fait 2^40 adresses différentes, soit 1024 gibioctets. Les 24 bits pour adresser les segments sont partagés moitié-moitié pour l'adressage des tables, ce qui fait 4096 ''Object Table'' différentes dans l{{'}}''Object Table Directory'', et chaque ''Object Table'' contient 4096 segments. ====Le jeu d'instruction de l'Intel iAPX 432==== L'Intel iAPX 432 est une machine à pile. Le jeu d'instruction de l'Intel iAPX 432 gère pas moins de 230 instructions différentes. Il gére deux types d'instructions : les instructions normales, et celles qui manipulent des segments/objets. Les premières permettent de manipuler des nombres entiers, des caractères, des chaînes de caractères, des tableaux, etc. Les secondes sont spécialement dédiées à la manipulation des capacités. Il y a une instruction pour copier une capacité, une autre pour invalider une capacité, une autre pour augmenter ses droits d'accès (instruction sécurisée, exécutable seulement sous certaines conditions), une autre pour restreindre ses droits d'accès. deux autres instructions créent un segment et renvoient la capacité associée, la première créant un segment typé, l'autre non. le processeur gérait aussi des instructions spécialement dédiées à la programmation système et idéales pour programmer des systèmes d'exploitation. De nombreuses instructions permettaient ainsi de commuter des processus, faire des transferts de messages entre processus, etc. Environ 40 % du micro-code était ainsi spécialement dédié à ces instructions spéciales. Les instructions sont de longueur variable et peuvent prendre n'importe quelle taille comprise entre 10 et 300 bits, sans vraiment de restriction de taille. Les bits d'une instruction sont regroupés en 4 grands blocs, 4 champs, qui ont chacun une signification particulière. * Le premier est l'opcode de l'instruction. * Le champ référence, doit être interprété différemment suivant la donnée à manipuler. Si cette donnée est un entier, un caractère ou un flottant, ce champ indique l'emplacement de la donnée en mémoire. Alors que si l'instruction manipule un objet, ce champ spécifie la capacité de l'objet en question. Ce champ est assez complexe et il est sacrément bien organisé. * Le champ format, n'utilise que 4 bits et a pour but de préciser si les données à manipuler sont en mémoire ou sur la pile. * Le champ classe permet de dire combien de données différentes l'instruction va devoir manipuler, et quelles seront leurs tailles. [[File:Encodage des instructions de l'Intel iAPX-432.png|centre|vignette|upright=2|Encodage des instructions de l'Intel iAPX-432.]] ====Le support de l'orienté objet sur l'Intel iAPX 432==== L'Intel 432 permet de définir des objets, qui correspondent aux classes des langages orientés objets. L'Intel 432 permet, à partir de fonctions définies par le programmeur, de créer des '''''domain objects''''', qui correspondent à une classe. Un ''domain object'' est un segment de capacité, dont les capacités pointent vers des fonctions ou un/plusieurs objets. Les fonctions et les objets sont chacun placés dans un segment. Une partie des fonctions/objets sont publics, ce qui signifie qu'ils sont accessibles en lecture par l'extérieur. Les autres sont privées, inaccessibles aussi bien en lecture qu'en écriture. L'exécution d'une fonction demande que le branchement fournisse deux choses : une capacité vers le ''domain object'', et la position de la fonction à exécuter dans le segment. La position permet de localiser la capacité de la fonction à exécuter. En clair, on accède au ''domain object'' d'abord, pour récupérer la capacité qui pointe vers la fonction à exécuter. Il est aussi possible pour le programmeur de définir de nouveaux types non supportés par le processeur, en faisant appel au système d'exploitation de l'ordinateur. Au niveau du processeur, chaque objet est typé au niveau de son object descriptor : celui-ci contient des informations qui permettent de déterminer le type de l'objet. Chaque type se voit attribuer un domain object qui contient toutes les fonctions capables de manipuler les objets de ce type et que l'on appelle le type manager. Lorsque l'on veut manipuler un objet d'un certain type, il suffit d'accéder à une capacité spéciale (le TCO) qui pointera dans ce type manager et qui précisera quel est l'objet à manipuler (en sélectionnant la bonne entrée dans la liste de capacité). Le type d'un objet prédéfini par le processeur est ainsi spécifié par une suite de 8 bits, tandis que le type d'un objet défini par le programmeur est défini par la capacité spéciale pointant vers son type manager. ===Conclusion=== Pour ceux qui veulent en savoir plus, je conseille la lecture de ce livre, disponible gratuitement sur internet (merci à l'auteur pour cette mise à disposition) : * [https://homes.cs.washington.edu/~levy/capabook/ Capability-Based Computer Systems]. Voici un document qui décrit le fonctionnement de l'Intel iAPX432 : * [https://homes.cs.washington.edu/~levy/capabook/Chapter9.pdf The Intel iAPX 432 ] ==La pagination== Avec la pagination, la mémoire est découpée en blocs de taille fixe, appelés des '''pages mémoires'''. La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Mais elles sont de taille fixe : on ne peut pas en changer la taille. C'est la différence avec les segments, qui sont de taille variable. Le contenu d'une page en mémoire fictive est rigoureusement le même que le contenu de la page correspondante en mémoire physique. L'espace d'adressage est découpé en '''pages logiques''', alors que la mémoire physique est découpée en '''pages physique''' de même taille. Les pages logiques correspondent soit à une page physique, soit à une page swappée sur le disque dur. Quand une page logique est associée à une page physique, les deux ont le même contenu, mais pas les mêmes adresses. Les pages logiques sont numérotées, en partant de 0, afin de pouvoir les identifier/sélectionner. Même chose pour les pages physiques, qui sont elles aussi numérotées en partant de 0. [[File:Principe de la pagination.png|centre|vignette|upright=2|Principe de la pagination.]] Pour information, le tout premier processeur avec un système de mémoire virtuelle était le super-ordinateur Atlas. Il utilisait la pagination, et non la segmentation. Mais il fallu du temps avant que la méthode de la pagination prenne son essor dans les processeurs commerciaux x86. Un point important est que la pagination implique une coopération entre OS et hardware, les deux étant fortement mélés. Une partie des informations de cette section auraient tout autant leur place dans le wikilivre sur les systèmes d'exploitation, mais il est plus simple d'en parler ici. ===La mémoire virtuelle : le ''swapping'' et le remplacement des pages mémoires=== Le système d'exploitation mémorise des informations sur toutes les pages existantes dans une '''table des pages'''. C'est un tableau où chaque ligne est associée à une page logique. Une ligne contient un bit ''Valid'' qui indique si la page logique associée est swappée sur le disque dur ou non, et la position de la page physique correspondante en mémoire RAM. Elle peut aussi contenir des bits pour la protection mémoire, et bien d'autres. Les lignes sont aussi appelées des ''entrées de la table des pages'' [[File:Gestionnaire de mémoire virtuelle - Pagination et swapping.png|centre|vignette|upright=2|Table des pages.]] De plus, le système d'exploitation conserve une '''liste des pages vides'''. Le nom est assez clair : c'est une liste de toutes les pages de la mémoire physique qui sont inutilisées, qui ne sont allouées à aucun processus. Ces pages sont de la mémoire libre, utilisable à volonté. La liste des pages vides est mise à jour à chaque fois qu'un programme réserve de la mémoire, des pages sont alors prises dans cette liste et sont allouées au programme demandeur. ====Les défauts de page==== Lorsque l'on veut traduire l'adresse logique d'une page mémoire, le processeur vérifie le bit ''Valid'' et l'adresse physique. Si le bit ''Valid'' est à 1 et que l'adresse physique est présente, la traduction d'adresse s'effectue normalement. Mais si ce n'est pas le cas, l'entrée de la table des pages ne contient pas de quoi faire la traduction d'adresse. Soit parce que la page est swappée sur le disque dur et qu'il faut la copier en RAM, soit parce que les droits d'accès ne le permettent pas, soit parce que la page n'a pas encore été allouée, etc. On fait alors face à un '''défaut de page'''. Un défaut de page a lieu quand la MMU ne peut pas associer l'adresse logique à une adresse physique, quelque qu'en soit la raison. Il existe deux types de défauts de page : mineurs et majeurs. Un '''défaut de page majeur''' a lieu quand on veut accéder à une page déplacée sur le disque dur. Un défaut de page majeur lève une exception matérielle dont la routine rapatriera la page en mémoire RAM. S'il y a de la place en mémoire RAM, il suffit d'allouer une page vide et d'y copier la page chargée depuis le disque dur. Mais si ce n'est par le cas, on va devoir faire de la place en RAM en déplaçant une page mémoire de la RAM vers le disque dur. Dans tous les cas, c'est le système d'exploitation qui s'occupe du chargement de la page, le processeur n'est pas impliqué. Une fois la page chargée, la table des pages est mise à jour et la traduction d'adresse peut recommencer. Si je dis recommencer, c'est car l'accès mémoire initial est rejoué à l'identique, sauf que la traduction d'adresse réussit cette fois-ci. Un '''défaut de page mineur''' a lieu dans des circonstances pas très intuitives : la page est en mémoire physique, mais l'adresse physique de la page n'est pas accessible. Par exemple, il est possible que des sécurités empêchent de faire la traduction d'adresse, pour des raisons de protection mémoire. Une autre raison est la gestion des adresses synonymes, qui surviennent quand on utilise des libraires partagées entre programmes, de la communication inter-processus, des optimisations de type ''copy-on-write'', etc. Enfin, une dernière raison est que la page a été allouée à un programme par le système d'exploitation, mais qu'il n'a pas encore attribué sa position en mémoire. Pour comprendre comment c'est possible, parlons rapidement de l'allocation paresseuse. Imaginons qu'un programme fasse une demande d'allocation mémoire et se voit donc attribuer une ou plusieurs pages logiques. L'OS peut alors réagir de deux manières différentes. La première est d'attribuer une page physique immédiatement, en même temps que la page logique. En faisant ainsi, on ne peut pas avoir de défaut mineur, sauf en cas de problème de protection mémoire. Cette solution est simple, on l'appelle l{{'}}'''allocation immédiate'''. Une autre solution consiste à attribuer une page logique, mais l'allocation de la page physique se fait plus tard. Elle a lieu la première fois que le programme tente d'écrire/lire dans la page physique. Un défaut mineur a lieu, et c'est lui qui force l'OS à attribuer une page physique pour la page logique demandée. On parle alors d{{'}}'''allocation paresseuse'''. L'avantage est que l'on gagne en performance si des pages logiques sont allouées mais utilisées, ce qui peut arriver. Une optimisation permise par l'existence des défauts mineurs est le '''''copy-on-write'''''. Le but est d'optimiser la copie d'une page logique dans une autre. L'idée est que la copie est retardée quand elle est vraiment nécessaire, à savoir quand on écrit dans la copie. Tant que l'on ne modifie pas la copie, les deux pages logiques, originelle et copiée, pointent vers la même page physique. A quoi bon avoir deux copies avec le même contenu ? Par contre, la page physique est marquée en lecture seule. La moindre écriture déclenche une erreur de protection mémoire, et un défaut mineur. Celui-ci est géré par l'OS, qui effectue alors la copie dans une nouvelle page physique. Je viens de dire que le système d'exploitation gère les défauts de page majeurs/mineurs. Un défaut de page déclenche une exception matérielle, qui passe la main au système d'exploitation. Le système d'exploitation doit alors déterminer ce qui a levé l'exception, notamment identifier si c'est un défaut de page mineur ou majeur. Pour cela, le processeur a un ou plusieurs '''registres de statut''' qui indique l'état du processeur, qui sont utiles pour gérer les défauts de page. Ils indiquent quelle est l'adresse fautive, si l'accès était une lecture ou écriture, si l'accès a eu lieu en espace noyau ou utilisateur (les espaces mémoire ne sont pas les mêmes), etc. Les registres en question varient grandement d'une architecture de processeur à l'autre, aussi on ne peut pas dire grand chose de plus sur le sujet. Le reste est de toute façon à voir dans un cours sur les systèmes d'exploitation. ====Le remplacement des pages==== Les pages virtuelles font référence soit à une page en mémoire physique, soit à une page sur le disque dur. Mais l'on ne peut pas lire une page directement depuis le disque dur. Les pages sur le disque dur doivent être chargées en RAM, avant d'être utilisables. Ce n'est possible que si on a une page mémoire vide, libre. Si ce n'est pas le cas, on doit faire de la place en swappant une page sur le disque dur. Les pages font ainsi une sorte de va et vient entre le fichier d'échange et la RAM, suivant les besoins. Tout cela est effectué par une routine d'interruption du système d'exploitation, le processeur n'ayant pas vraiment de rôle là-dedans. Supposons que l'on veuille faire de la place en RAM pour une nouvelle page. Dans une implémentation naïve, on trouve une page à évincer de la mémoire, qui est copiée dans le ''swapfile''. Toutes les pages évincées sont alors copiées sur le disque dur, à chaque remplacement. Néanmoins, cette implémentation naïve peut cependant être améliorée si on tient compte d'un point important : si la page a été modifiée depuis le dernier accès. Si le programme/processeur a écrit dans la page, alors celle-ci a été modifiée et doit être sauvegardée sur le ''swapfile'' si elle est évincée. Par contre, si ce n'est pas le cas, la page est soit initialisée, soit déjà présente à l'identique dans le ''swapfile''. Mais cette optimisation demande de savoir si une écriture a eu lieu dans la page. Pour cela, on ajoute un '''''dirty bit''''' à chaque entrée de la table des pages, juste à côté du bit ''Valid''. Il indique si une écriture a eu lieu dans la page depuis qu'elle a été chargée en RAM. Ce bit est mis à jour par le processeur, automatiquement, lors d'une écriture. Par contre, il est remis à zéro par le système d'exploitation, quand la page est chargée en RAM. Si le programme se voit allouer de la mémoire, il reçoit une page vide, et ce bit est initialisé à 0. Il est mis à 1 si la mémoire est utilisée. Quand la page est ensuite swappée sur le disque dur, ce bit est remis à 0 après la sauvegarde. Sur la majorité des systèmes d'exploitation, il est possible d'interdire le déplacement de certaines pages sur le disque dur. Ces pages restent alors en mémoire RAM durant un temps plus ou moins long, parfois en permanence. Cette possibilité simplifie la vie des programmeurs qui conçoivent des systèmes d'exploitation : essayez d'exécuter l'interruption pour les défauts de page alors que la page contenant le code de l'interruption est placée sur le disque dur ! Là encore, cela demande d'ajouter un bit dans chaque entrée de la table des pages, qui indique si la page est swappable ou non. Le bit en question s'appelle souvent le '''bit ''swappable'''''. ====Les algorithmes de remplacement des pages pris en charge par l'OS==== Le choix de la page doit être fait avec le plus grand soin et il existe différents algorithmes qui permettent de décider quelle page supprimer de la RAM. Leur but est de swapper des pages qui ne seront pas accédées dans le futur, pour éviter d'avoir à faire triop de va-et-vient entre RAM et ''swapfile''. Les données qui sont censées être accédées dans le futur doivent rester en RAM et ne pas être swappées, autant que possible. Les algorithmes les plus simples pour le choix de page à évincer sont les suivants. Le plus simple est un algorithme aléatoire : on choisit la page au hasard. Mine de rien, cet algorithme est très simple à implémenter et très rapide à exécuter. Il ne demande pas de modifier la table des pages, ni même d'accéder à celle-ci pour faire son choix. Ses performances sont surprenamment correctes, bien que largement en-dessous de tous les autres algorithmes. L'algorithme FIFO supprime la donnée qui a été chargée dans la mémoire avant toutes les autres. Cet algorithme fonctionne bien quand un programme manipule des tableaux de grande taille, mais fonctionne assez mal dans le cas général. L'algorithme LRU supprime la donnée qui été lue ou écrite pour la dernière fois avant toutes les autres. C'est théoriquement le plus efficace dans la majorité des situations. Malheureusement, son implémentation est assez complexe et les OS doivent modifier la table des pages pour l'implémenter. L'algorithme le plus utilisé de nos jours est l{{'}}'''algorithme NRU''' (''Not Recently Used''), une simplification drastique du LRU. Il fait la différence entre les pages accédées il y a longtemps et celles accédées récemment, d'une manière très binaire. Les deux types de page sont appelés respectivement les '''pages froides''' et les '''pages chaudes'''. L'OS swappe en priorité les pages froides et ne swappe de page chaude que si aucune page froide n'est présente. L'algorithme est simple : il choisit la page à évincer au hasard parmi une page froide. Si aucune page froide n'est présente, alors il swappe au hasard une page chaude. Pour implémenter l'algorithme NRU, l'OS mémorise, dans chaque entrée de la table des pages, si la page associée est froide ou chaude. Pour cela, il met à 0 ou 1 un bit dédié : le '''bit ''Accessed'''''. La différence avec le bit ''dirty'' est que le bit ''dirty'' est mis à jour uniquement lors des écritures, alors que le bit ''Accessed'' l'est aussi lors d'une lecture. Uen lecture met à 1 le bit ''Accessed'', mais ne touche pas au bit ''dirty''. Les écritures mettent les deux bits à 1. Implémenter l'algorithme NRU demande juste de mettre à jour le bit ''Accessed'' de chaque entrée de la table des pages. Et sur les architectures modernes, le processeur s'en charge automatiquement. A chaque accès mémoire, que ce soit en lecture ou en écriture, le processeur met à 1 ce bit. Par contre, le système d'exploitation le met à 0 à intervalles réguliers. En conséquence, quand un remplacement de page doit avoir lieu, les pages chaudes ont de bonnes chances d'avoir le bit ''Accessed'' à 1, alors que les pages froides l'ont à 0. Ce n'est pas certain, et on peut se trouver dans des cas où ce n'est pas le cas. Par exemple, si un remplacement a lieu juste après la remise à zéro des bits ''Accessed''. Le choix de la page à remplacer est donc imparfait, mais fonctionne bien en pratique. Tous les algorithmes précédents ont chacun deux variantes : une locale, et une globale. Avec la version locale, la page qui va être rapatriée sur le disque dur est une page réservée au programme qui est la cause du page miss. Avec la version globale, le système d'exploitation va choisir la page à virer parmi toutes les pages présentes en mémoire vive. ===La protection mémoire avec la pagination=== Avec la pagination, chaque page a des '''droits d'accès''' précis, qui permettent d'autoriser ou interdire les accès en lecture, écriture, exécution, etc. La table des pages mémorise les autorisations pour chaque page, sous la forme d'une suite de bits où chaque bit autorise/interdit une opération bien précise. En pratique, les tables de pages modernes disposent de trois bits : un qui autorise/interdit les accès en lecture, un qui autorise/interdit les accès en écriture, un qui autorise/interdit l'éxecution du contenu de la page. Le format exact de la suite de bits a cependant changé dans le temps sur les processeurs x86 modernes. Par exemple, avant le passage au 64 bits, les CPU et OS ne pouvaient pas marquer une page mémoire comme non-exécutable. C'est seulement avec le passage au 64 bits qu'a été ajouté un bit pour interdire l'exécution de code depuis une page. Ce bit, nommé '''bit NX''', est à 0 si la page n'est pas exécutable et à 1 sinon. Le processeur vérifie à chaque chargement d'instruction si le bit NX de page lue est à 1. Sinon, il lève une exception matérielle et laisse la main à l'OS. Une amélioration de cette protection est la technique dite du '''''Write XOR Execute''''', abréviée WxX. Elle consiste à interdire les pages d'être à la fois accessibles en écriture et exécutables. Il est possible de changer les autorisations en cours de route, ceci dit. Les premiers IBM 360 disposaient d'un mécanisme de protection mémoire totalement différent, sans registres limite/base. Ce mécanisme de protection attribue à chaque programme une '''clé de protection''', qui consiste en un nombre unique de 4 bits (chaque programme a donc une clé différente de ses collègues). La mémoire est fragmentée en blocs de même taille, de 2 kibioctets. Le processeur mémorise, pour chacun de ses blocs, la clé de protection du programme qui a réservé ce bloc. À chaque accès mémoire, le processeur compare la clé de protection du programme en cours d’exécution et celle du bloc de mémoire de destination. Si les deux clés sont différentes, alors un programme a effectué un accès hors des clous et il se fait sauvagement arrêter. ===La traduction d'adresse avec la pagination=== Comme dit plus haut, les pages sont numérotées, de 0 à une valeur maximale, afin de les identifier. Le numéro en question est appelé le '''numéro de page'''. Il est utilisé pour dire au processeur : je veux lire une donnée dans la page numéro 20, la page numéro 90, etc. Une fois qu'on a le numéro de page, on doit alors préciser la position de la donnée dans la page, appelé le '''décalage''', ou encore l{{'}}''offset''. Le numéro de page et le décalage se déduisent à partir de l'adresse, en divisant l'adresse par la taille de la page. Le quotient obtenu donne le numéro de la page, alors que le reste est le décalage. Les processeurs actuels utilisent tous des pages dont la taille est une puissance de deux, ce qui fait que ce calcul est fortement simplifié. Sous cette condition, le numéro de page correspond aux bits de poids fort de l'adresse, alors que le décalage est dans les bits de poids faible. Le numéro de page existe en deux versions : un numéro de page physique qui identifie une page en mémoire physique, et un numéro de page logique qui identifie une page dans la mémoire virtuelle. Traduire l'adresse logique en adresse physique demande de remplacer le numéro de la page logique en un numéro de page physique. [[File:Phycical address.JPG|centre|vignette|upright=2|Traduction d'adresse avec la pagination.]] ====Les tables des pages simples==== Dans le cas le plus simple, il n'y a qu'une seule table des pages, qui est adressée par les numéros de page logique. La table des pages est un vulgaire tableau d'adresses physiques, placées les unes à la suite des autres. Avec cette méthode, la table des pages a autant d'entrée qu'il y a de pages logiques en mémoire virtuelle. Accéder à la mémoire nécessite donc d’accéder d'abord à la table des pages en mémoire, de calculer l'adresse de l'entrée voulue, et d’y accéder. [[File:Table des pages.png|centre|vignette|upright=2|Table des pages.]] La table des pages est souvent stockée dans la mémoire RAM, son adresse est connue du processeur, mémorisée dans un registre spécialisé du processeur. Le processeur effectue automatiquement le calcul d'adresse à partir de l'adresse de base et du numéro de page logique. [[File:Address translation (32-bit).png|centre|vignette|upright=2|Address translation (32-bit)]] ====Les tables des pages inversées==== Sur certains systèmes, notamment sur les architectures 64 bits ou plus, le nombre de pages est très important. Sur les ordinateurs x86 récents, les adresses sont en pratique de 48 bits, les bits de poids fort étant ignorés en pratique, ce qui fait en tout 68 719 476 736 pages. Chaque entrée de la table des pages fait au minimum 48 bits, mais fait plus en pratique : partons sur 64 bits par entrée, soit 8 octets. Cela fait 549 755 813 888 octets pour la table des pages, soit plusieurs centaines de gibioctets ! Une table des pages normale serait tout simplement impraticable. Pour résoudre ce problème, on a inventé les '''tables des pages inversées'''. L'idée derrière celles-ci est l'inverse de la méthode précédente. La méthode précédente stocke, pour chaque page logique, son numéro de page physique. Les tables des pages inversées font l'inverse : elles stockent, pour chaque numéro de page physique, la page logique qui correspond. Avec cette méthode table des pages contient ainsi autant d'entrées qu'il y a de pages physiques. Elle est donc plus petite qu'avant, vu que la mémoire physique est plus petite que la mémoire virtuelle. Quand le processeur veut convertir une adresse virtuelle en adresse physique, la MMU recherche le numéro de page de l'adresse virtuelle dans la table des pages. Le numéro de l'entrée à laquelle se trouve ce morceau d'adresse virtuelle est le morceau de l'adresse physique. Pour faciliter le processus de recherche dans la page, la table des pages inversée est ce que l'on appelle une table de hachage. C'est cette solution qui est utilisée sur les processeurs Power PC. [[File:Table des pages inversée.jpg|centre|vignette|upright=2|Table des pages inversée.]] ====Les tables des pages multiples par espace d'adressage==== Dans les deux cas précédents, il y a une table des pages unique. Cependant, les concepteurs de processeurs et de systèmes d'exploitation ont remarqué que les adresses les plus hautes et/ou les plus basses sont les plus utilisées, alors que les adresses situées au milieu de l'espace d'adressage sont peu utilisées en raison du fonctionnement de la pile et du tas. Il y a donc une partie de la table des pages qui ne sert à rien et est utilisé pour des adresses inutilisées. C'est une source d'économie d'autant plus importante que les tables des pages sont de plus en plus grosses. Pour profiter de cette observation, les concepteurs d'OS ont décidé de découper l'espace d'adressage en plusieurs sous-espaces d'adressage de taille identique : certains localisés dans les adresses basses, d'autres au milieu, d'autres tout en haut, etc. Et vu que l'espace d'adressage est scindé en plusieurs parties, la table des pages l'est aussi, elle est découpée en plusieurs sous-tables. Si un sous-espace d'adressage n'est pas utilisé, il n'y a pas besoin d'utiliser de la mémoire pour stocker la table des pages associée. On ne stocke que les tables des pages pour les espaces d'adressage utilisés, ceux qui contiennent au moins une donnée. L'utilisation de plusieurs tables des pages ne fonctionne que si le système d'exploitation connaît l'adresse de chaque table des pages (celle de la première entrée). Pour cela, le système d'exploitation utilise une super-table des pages, qui stocke les adresses de début des sous-tables de chaque sous-espace. En clair, la table des pages est organisé en deux niveaux, la super-table étant le premier niveau et les sous-tables étant le second niveau. L'adresse est structurée de manière à tirer profit de cette organisation. Les bits de poids fort de l'adresse sélectionnent quelle table de second niveau utiliser, les bits du milieu de l'adresse sélectionne la page dans la table de second niveau et le reste est interprété comme un ''offset''. Un accès à la table des pages se fait comme suit. Les bits de poids fort de l'adresse sont envoyés à la table de premier niveau, et sont utilisés pour récupérer l'adresse de la table de second niveau adéquate. Les bits au milieu de l'adresse sont envoyés à la table de second niveau, pour récupérer le numéro de page physique. Le tout est combiné avec l{{'}}''offset'' pour obtenir l'adresse physique finale. [[File:Table des pages hiérarchique.png|centre|vignette|upright=2|Table des pages hiérarchique.]] On peut aussi aller plus loin et découper la table des pages de manière hiérarchique, chaque sous-espace d'adressage étant lui aussi découpé en sous-espaces d'adressages. On a alors une table de premier niveau, plusieurs tables de second niveau, encore plus de tables de troisième niveau, et ainsi de suite. Cela peut aller jusqu'à 5 niveaux sur les processeurs x86 64 bits modernes. On parle alors de '''tables des pages emboitées'''. Dans ce cours, la table des pages désigne l'ensemble des différents niveaux de cette organisation, toutes les tables inclus. Seules les tables du dernier niveau mémorisent des numéros de page physiques, les autres tables mémorisant des pointeurs, des adresses vers le début des tables de niveau inférieur. Un exemple sera donné plus bas, dans la section suivante. ====L'exemple des processeurs x86==== Pour rendre les explications précédentes plus concrètes, nous allons prendre l'exemple des processeur x86 anciens, de type 32 bits. Les processeurs de ce type utilisaient deux types de tables des pages : une table des page unique et une table des page hiérarchique. Les deux étaient utilisées dans cas séparés. La table des page unique était utilisée pour les pages larges et encore seulement en l'absence de la technologie ''physical adress extension'', dont on parlera plus bas. Les autres cas utilisaient une table des page hiérarchique, à deux niveaux, trois niveaux, voire plus. Une table des pages unique était utilisée pour les pages larges (de 2 mébioctets et plus). Pour les pages de 4 mébioctets, il y avait une unique table des pages, adressée par les 10 bits de poids fort de l'adresse, les bits restants servant comme ''offset''. La table des pages contenait 1024 entrées de 4 octets chacune, ce qui fait en tout 4 kibioctet pour la table des pages. La table des page était alignée en mémoire sur un bloc de 4 kibioctet (sa taille). [[File:X86 Paging 4M.svg|centre|vignette|upright=2|X86 Paging 4M]] Pour les pages de 4 kibioctets, les processeurs x86-32 bits utilisaient une table des page hiérarchique à deux niveaux. Les 10 bits de poids fort l'adresse adressaient la table des page maitre, appelée le directoire des pages (''page directory''), les 10 bits précédents servaient de numéro de page logique, et les 12 bits restants servaient à indiquer la position de l'octet dans la table des pages. Les entrées de chaque table des pages, mineure ou majeure, faisaient 32 bits, soit 4 octets. Vous remarquerez que la table des page majeure a la même taille que la table des page unique obtenue avec des pages larges (de 4 mébioctets). [[File:X86 Paging 4K.svg|centre|vignette|upright=2|X86 Paging 4K]] La technique du '''''physical adress extension''''' (PAE), utilisée depuis le Pentium Pro, permettait aux processeurs x86 32 bits d'adresser plus de 4 gibioctets de mémoire, en utilisant des adresses physiques de 64 bits. Les adresses virtuelles de 32 bits étaient traduites en adresses physiques de 64 bits grâce à une table des pages adaptée. Cette technologie permettait d'adresser plus de 4 gibioctets de mémoire au total, mais avec quelques limitations. Notamment, chaque programme ne pouvait utiliser que 4 gibioctets de mémoire RAM pour lui seul. Mais en lançant plusieurs programmes, on pouvait dépasser les 4 gibioctets au total. Pour cela, les entrées de la table des pages passaient à 64 bits au lieu de 32 auparavant. La table des pages gardait 2 niveaux pour les pages larges en PAE. [[File:X86 Paging PAE 2M.svg|centre|vignette|upright=2|X86 Paging PAE 2M]] Par contre, pour les pages de 4 kibioctets en PAE, elle était modifiée de manière à ajouter un niveau de hiérarchie, passant de deux niveaux à trois. [[File:X86 Paging PAE 4K.svg|centre|vignette|upright=2|X86 Paging PAE 4K]] En 64 bits, la table des pages est une table des page hiérarchique avec 5 niveaux. Seuls les 48 bits de poids faible des adresses sont utilisés, les 16 restants étant ignorés. [[File:X86 Paging 64bit.svg|centre|vignette|upright=2|X86 Paging 64bit]] ====Les circuits liés à la gestion de la table des pages==== En théorie, la table des pages est censée être accédée à chaque accès mémoire. Mais pour éviter d'avoir à lire la table des pages en mémoire RAM à chaque accès mémoire, les concepteurs de processeurs ont décidé d'implanter un cache dédié, le '''''translation lookaside buffer''''', ou TLB. Le TLB stocke au minimum de quoi faire la traduction entre adresse virtuelle et adresse physique, à savoir une correspondance entre numéro de page logique et numéro de page physique. Pour faire plus général, il stocke des entrées de la table des pages. [[File:MMU principle updated.png|centre|vignette|upright=2.0|MMU avec une TLB.]] Les accès à la table des pages sont gérés de deux façons : soit le processeur gère tout seul la situation, soit il délègue cette tâche au système d’exploitation. Sur les processeurs anciens, le système d'exploitation gère le parcours de la table des pages. Mais cette solution logicielle n'a pas de bonnes performances. D'autres processeurs gèrent eux-mêmes le défaut d'accès à la TLB et vont chercher d'eux-mêmes les informations nécessaires dans la table des pages. Ils disposent de circuits, les '''''page table walkers''''' (PTW), qui s'occupent eux-mêmes du défaut. Les ''page table walkers'' contiennent des registres qui leur permettent de faire leur travail. Le plus important est celui qui mémorise la position de la table des pages en mémoire RAM, dont nous avons parlé plus haut. Les PTW ont besoin, pour faire leur travail, de mémoriser l'adresse physique de la table des pages, ou du moins l'adresse de la table des pages de niveau 1 pour des tables des pages hiérarchiques. Mais d'autres registres existent. Toutes les informations nécessaires pour gérer les défauts de TLB sont stockées dans des registres spécialisés appelés des '''tampons de PTW''' (PTW buffers). ===L'abstraction matérielle des processus : une table des pages par processus=== [[File:Memoire virtuelle.svg|vignette|Mémoire virtuelle]] Il est possible d'implémenter l'abstraction matérielle des processus avec la pagination. En clair, chaque programme lancé sur l'ordinateur dispose de son propre espace d'adressage, ce qui fait que la même adresse logique ne pointera pas sur la même adresse physique dans deux programmes différents. Pour cela, il y a plusieurs méthodes. ====L'usage d'une table des pages unique avec un identifiant de processus dans chaque entrée==== La première solution n'utilise qu'une seule table des pages, mais chaque entrée est associée à un processus. Pour cela, chaque entrée contient un '''identifiant de processus''', un numéro qui précise pour quel processus, pour quel espace d'adressage, la correspondance est valide. La page des tables peut aussi contenir des entrées qui sont valides pour tous les processus en même temps. L'intérêt n'est pas évident, mais il le devient quand on se rappelle que le noyau de l'OS est mappé dans le haut de l'espace d'adressage. Et peu importe l'espace d'adressage, le noyau est toujours mappé de manière identique, les mêmes adresses logiques adressant la même adresse mémoire. En conséquence, les correspondances adresse physique-logique sont les mêmes pour le noyau, peu importe l'espace d'adressage. Dans ce cas, la correspondance est mémorisée dans une entrée, mais sans identifiant de processus. À la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau. Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus. ====L'usage de plusieurs tables des pages==== Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour. Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique. [[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]] ===La taille des pages=== La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant. Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''. Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges. Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses. ===Les entrées de la table des pages=== Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits. * Elle contient d'abord le numéro de page physique. * Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS. * Le bit G est le bit ''global''. * Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges. * Le bit D est le bit ''dirty''. * Le bit A est le bit ''accessed''. * Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM. * Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page). * Le bit U/S précise si la page est accessible en mode noyau ou utilisateur. * Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture. * Le bit P est le bit ''valid''. [[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]] ==Comparaison des différentes techniques d'abstraction mémoire== Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous. {|class="wikitable" |- ! ! colspan="5" | Avec abstraction mémoire ! rowspan="2" | Sans abstraction mémoire |- ! ! Relocation matérielle ! Segmentation en mode réel (x86) ! Segmentation, général ! Architectures à capacités ! Pagination |- ! Abstraction matérielle des processus | colspan="4" | Oui, relocation matérielle | Oui, liée à la traduction d'adresse | Impossible |- ! Mémoire virtuelle | colspan="2" | Non, sauf émulation logicielle | colspan="3" | Oui, gérée par le processeur et l'OS | Non, sauf émulation logicielle |- ! Extension de l'espace d'adressage | colspan="2" | Oui : registre de base élargi | colspan="2" | Oui : adresse de base élargie dans la table des segments | ''Physical Adress Extension'' des processeurs 32 bits | Commutation de banques |- ! Protection mémoire | Registre limite | Aucune | colspan="2" | Registre limite, droits d'accès aux segments | Gestion des droits d'accès aux pages | Possible, méthodes variées |- ! Partage de mémoire | colspan="2" | Non | colspan="2" | Segment partagés | Pages partagées | Possible, méthodes variées |} ===Les différents types de segmentation=== La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues. La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente. La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86. Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique. ===Segmentation versus pagination=== Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe. L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum. Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment. Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=L'espace d'adressage du processeur | prevText=L'espace d'adressage du processeur | next=Les méthodes de synchronisation entre processeur et périphériques | nextText=Les méthodes de synchronisation entre processeur et périphériques }} </noinclude> 2pg8aq8eaut8lh34iddjsqp6appyu9o 771165 771164 2026-08-22T01:14:50Z Mewtow 31375 /* La MMU */ 771165 wikitext text/x-wiki Pour introduire ce chapitre, nous devons faire un rappel sur le concept d{{'}}'''espace d'adressage'''. Pour rappel, un espace d'adressage correspond à l'ensemble des adresses utilisables par le processeur. Par exemple, si je prends un processeur 16 bits, il peut adresser en tout 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. Intuitivement, on s'attend à ce qu'il y ait correspondance avec les adresses de la mémoire RAM. J'entends par là que l'adresse 1209 de l'espace d'adressage correspond à l'adresse 1209 en mémoire RAM. C'est là une hypothèse parfaitement raisonnable et on voit mal comment ce pourrait ne pas être le cas. Mais les processeurs modernes utilisent des techniques d{{'}}'''abstraction mémoire''' qui font que ce n'est pas le cas. Avec ces techniques, l'adresse 1209 de l'espace d'adressage correspond en réalité à l'adresse 9999 en mémoire RAM, voire n'est pas en RAM. L'abstraction mémoire fait que l'espace d'adressage regroupe des adresses fictives, qui doivent être traduites en adresses mémoires réelles pour être utilisées. Les adresses de l'espace d'adressage portent le nom d{{'}}'''adresses logiques''', alors que les adresses de la mémoire RAM sont appelées '''adresses physiques'''. L'intérêt de l'abstraction matérielle n'est pas évident. Aussi, avant de parler de comment l'abstraction mémoire fonctionne, nous allons voir à quoi elle sert. Nous allons voir qu'elle a plusieurs utilisations différentes, qui sont absolument nécessaires sur tous les ordinateurs personnels modernes. Tous les processeurs modernes la prennent en charge, les systèmes d'exploitation coopérent avec le processeur pour l'utiliser au mieux. ==L'abstraction mémoire permet plusieurs fonctionnalités complémentaires== La fonctionnalité la plus connue est la mémoire virtuelle, dont vous avez peut-être déjà entendu parler, peut-être que le nom vous dit quelque chose. Si ce n'est pas le cas, nous la détailleront dans la suite. Elle permet concrétement à un programme d'utiliser plus de mémoire qu'il n'y a de RAM installé dans un ordinateur, en utilisant le disque dur comme solution de secours. Mais d'autres fonctionnalités moins évidentes sont permises par l'abstraction mémoire. Et la première que nous allons voir est l'abstraction des processus. : En général, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle. ===L'abstraction matérielle des processus=== Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres. Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente. Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y. Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types : * La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique. * La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique. ===Le partage de la mémoire=== L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile. Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon. Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM. [[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]] D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées. Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité. Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire). [[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]] L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux. Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique. ===La mémoire virtuelle=== Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème. Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat. Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur. Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM. [[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]] Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation. Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel. L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''. On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur. Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée. ===L'extension d'adressage=== Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses. Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente. Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter. [[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]] Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage. Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas. ===La protection mémoire=== La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses. La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran. La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées. D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre. ==La MMU== La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé, appelé la '''''Memory Management Unit''''' (MMU). De nos jours, elle est intégrée dans le processeur, à la suite de l'unité mémoire. Mais il a existé des processeurs avec une MMU externe, soudée sur la carte mère. La traduction des adresses logiques en adresses physiques demande d'accéder à des données sont en mémoire RAM, qui sont gérés par le système d'exploitation. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu. [[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]] ===Les MMU intégrées au processeur=== D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente. Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation. [[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]] ===Les MMU séparées du processeur, sur la carte mère=== Avant d'être intégrées au processeur, les MMU étaient des circuits séparés, placés sur la carte mère, entre le processeur et la mémoire. Elle étaient connectées en plein milieu du bus d'adresse. A savoir que le bus d'adresse entrait dans la MMU et en ressortait. Le processeur envoyait des adresses à la MMU, qui renvoyait une adresse physique. La MMU était facultative, à savoir que le système pouvait fonctionner sans MMU d'installée, mais l'abstraction mémoire n’était alors pas disponible. Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451. Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits. Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur. ==La relocation matérielle== Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous. [[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]] Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations. ===La relocation avec la relocation matérielle : le registre de base=== Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme. [[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]] La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation. [[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]] Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage. [[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]] Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable. ===La protection mémoire avec la relocation matérielle : le registre limite=== Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire. Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, qu'il ne déborde pas en-dehors. C'est possible qu'une adresse calculée sorte du segment, à la suite d'un bug ou d'une erreur de programmation, voire pire. Et le processeur doit éviter de tels '''débordements de segments'''. A chaque accès mémoire, le processeur compare l'adresse accédée et vérifie qu'elle est bien dans le segment. Pour cela, il y a deux solutions. La première part du principe que le segment est placé en mémoire entre l'adresse de base et l'adresse limite. Il suffit de mémoriser l'adresse limite, l'adresse physique à ne pas dépasser. Une autre solution mémorise la taille du segment. La table des segments doit donc mémoriser, en plus de l'adresse de base : soit l'adresse maximale du segment, soit la taille du segment. D'autres informations peuvent être ajoutées, comme on le verra plus tard, mais cela complexifie la table des segments. De plus, le processeur se voit ajouter un '''registre limite''', qui mémorise soit la taille du segment, soit l'adresse limite. Les deux registres, base et limite, sont utilisés pour vérifier si un programme qui lit/écrit de la mémoire en-dehors de son segment attitré : au-delà pour le registre limite, en-deça pour le registre de base. Le processeur vérifie pour chaque accès mémoire ne déborde pas au-delà du segment qui lui est allouée, ce qui n'arrive que si l'adresse d'accès dépasse la valeur du registre limite. Pour les accès en-dessous du segment, il suffit de vérifier si l'addition de relocation déborde, tout débordement signifiant erreur de protection mémoire. [[File:Registre limite.png|centre|vignette|upright=2|Registre limite]] Utiliser la taille du segment a de nombreux avantages. L'un d'entre eux se manifeste quand on déplace un segment en mémoire RAM. Le descripteur doit alors être mis à jour, et c'est plus facile quand on utilise la taille du segment. Si on utilise l'adresse limite, il faut mettre à jour à la fois l'adresse de base et l'adresse limite, dans le descripteur. En utilisant la taille, seule l'adresse de base doit être modifiée, vu que le segment n'a pas changé de taille. Un autre avantage est lié aux performances, mais nous devons faire un détour pour le comprendre. La taille du segment est équivalent à l'adresse logique maximale possible. Par exemple, si un segment fait 256 octets, les adresses logiques possibles vont de 0 à 255, 256 est donc à la fois la taille du segment et l'adresse logique à partir de laquelle on déborde du segment. Et cela marche si on remplace 256 par n'importe quelle valeur : vu que le segment commence à l'adresse 0, sa taille en octets indique l'adresse de dépassement. Interpréter la taille du segment comme une adresse logique fait que les tests avec le registre limite sont plus performants, voyons pourquoi. En utilisant l'adresse physique limite, on doit faire la relocation, puis comparer l'adresse calculée avec l'adresse limite. Le calcul d'adresse doit se faire avant la vérification. En utilisant la taille, on doit comparer l'adresse logique avec la taille du segment. On peut alors faire le test de débordement avant ou pendant la relocation. Les deux peuvent être faits en parallèle, dans deux circuits distincts, ce qui améliore un peu le temps d'un accès mémoire. Quelques processeurs en ont profité, mais on verra cela dans la section sur la segmentation. [[File:Comparaison entre adresse limite physique et logique.png|centre|vignette|upright=2|Comparaison entre adresse limite physique et logique]] Les registres de base et limite sont altérés uniquement par le système d'exploitation et ne sont accessibles qu'en espace noyau. Lorsque le système d'exploitation charge un programme, ou reprend son exécution, il charge les adresses de début/fin du segment dans ces registres. D'ailleurs, ces deux registres doivent être sauvegardés et restaurés lors de chaque interruption. Par contre, et c'est assez évident, ils ne le sont pas lors d'un appel de fonction. Cela fait une différence de plus entre interruption et appels de fonctions. : Il faut noter que le registre limite et le registre de base sont parfois fusionnés en un seul registre, qui contient un descripteur de segment tout entier. Pour information, la relocation matérielle avec un registre limite a été implémentée sur plusieurs processeurs assez anciens, notamment sur les anciens supercalculateurs de marque CDC. Un exemple est le fameux CDC 6600, qui implémentait cette technique. ===La mémoire virtuelle avec la relocation matérielle=== Il est possible d'implémenter la mémoire virtuelle avec la relocation matérielle. Pour cela, il faut swapper des segments entiers sur le disque dur. Les segments sont placés en mémoire RAM et leur taille évolue au fur et à mesure que les programmes demandent du rab de mémoire RAM. Lorsque la mémoire est pleine, ou qu'un programme demande plus de mémoire que disponible, des segments entiers sont sauvegardés dans le ''swapfile'', pour faire de la place. Faire ainsi de demande juste de mémoriser si un segment est en mémoire RAM ou non, ainsi que la position des segments swappés dans le ''swapfile''. Pour cela, il faut modifier la table des segments, afin d'ajouter un '''bit de swap''' qui précise si le segment en question est swappé ou non. Lorsque le système d'exploitation veut swapper un segment, il le copie dans le ''swapfile'' et met ce bit à 1. Lorsque l'OS recharge ce segment en RAM, il remet ce bit à 0. La gestion de la position des segments dans le ''swapfile'' est le fait d'une structure de données séparée de la table des segments. L'OS exécute chaque programme l'un après l'autre, à tour de rôle. Lorsque le tour d'un programme arrive, il consulte la table des segments pour récupérer les adresses de base et limite, mais il vérifie aussi le bit de swap. Si le bit de swap est à 0, alors l'OS se contente de charger les adresses de base et limite dans les registres adéquats. Mais sinon, il démarre une routine d'interruption qui charge le segment voulu en RAM, depuis le ''swapfile''. C'est seulement une fois le segment chargé que l'on connait son adresse de base/limite et que le chargement des registres de relocation peut se faire. Un défaut évident de cette méthode est que l'on swappe des programmes entiers, qui sont généralement assez imposants. Les segments font généralement plusieurs centaines de mébioctets, pour ne pas dire plusieurs gibioctets, à l'époque actuelle. Ils étaient plus petits dans l'ancien temps, mais la mémoire était alors plus lente. Toujours est-il que la copie sur le disque dur des segments est donc longue, lente, et pas vraiment compatible avec le fait que les programmes s'exécutent à tour de rôle. Et ca explique pourquoi la relocation matérielle n'est presque jamais utilisée avec de la mémoire virtuelle. ===L'extension d'adressage avec la relocation matérielle=== Passons maintenant à la dernière fonctionnalité implémentable avec la traduction d'adresse : l'extension d'adressage. Elle permet d'utiliser plus de mémoire que ne le permet l'espace d'adressage. Par exemple, utiliser plus de 64 kibioctets de mémoire sur un processeur 16 bits. Pour cela, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. L'extension des adresses se fait assez simplement avec la relocation matérielle : il suffit que le registre de base soit plus long. Prenons l'exemple d'un processeur aux adresses de 16 bits, mais qui est reliée à un bus d'adresse de 24 bits. L'espace d'adressage fait juste 64 kibioctets, mais le bus d'adresse gère 16 mébioctets de RAM. On peut utiliser les 16 mébioctets de RAM à une condition : que le registre de base fasse 24 bits, pas 16. Un défaut de cette approche est qu'un programme ne peut pas utiliser plus de mémoire que ce que permet l'espace d'adressage. Mais par contre, on peut placer chaque programme dans des portions différentes de mémoire. Imaginons par exemple que l'on ait un processeur 16 bits, mais un bus d'adresse de 20 bits. Il est alors possible de découper la mémoire en 16 blocs de 64 kibioctets, chacun attribué à un segment/programme, qu'on sélectionne avec les 4 bits de poids fort de l'adresse. Il suffit de faire démarrer les segments au bon endroit en RAM, et cela demande juste que le registre de base le permette. C'est une sorte d'émulation de la commutation de banques. ==La segmentation en mode réel des processeurs x86== Avant de passer à la suite, nous allons voir la technique de segmentation de l'Intel 8086, un des tout premiers processeurs 16 bits. Il s'agissait d'une forme très simple de segmentation, sans aucune forme de protection mémoire, ni même de mémoire virtuelle, ce qui le place à part des autres formes de segmentation. Il s'agit d'une amélioration de la relocation matérielle, qui avait pour but de permettre d'utiliser plus de 64 kibioctets de mémoire, ce qui était la limite maximale sur les processeurs 16 bits de l'époque. Par la suite, la segmentation s'améliora et ajouta un support complet de la mémoire virtuelle et de la protection mémoire. L'ancienne forme de segmentation fut alors appelé le '''mode réel''', et la nouvelle forme de segmentation fut appelée le '''mode protégé'''. Le mode protégé rajoute la protection mémoire, en ajoutant des registres limite et une gestion des droits d'accès aux segments, absents en mode réel. De plus, il ajoute un support de la mémoire virtuelle grâce à l'utilisation d'une des segments digne de ce nom, table qui est absente en mode réel ! Pour le moment, voyons le mode réel. ===Les segments en mode réel=== [[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]] La segmentation en mode réel sépare la pile, le tas, le code machine et les données constantes dans quatre segments distincts. * Le segment '''''text''''', qui contient le code machine du programme, de taille fixe. * Le segment '''''data''''' contient des données de taille fixe qui occupent de la mémoire de façon permanente, des constantes, des variables globales, etc. * Le segment pour la '''pile''', de taille variable. * le reste est appelé le '''tas''', de taille variable. Un point important est que sur ces processeurs, il n'y a pas de table des segments proprement dit. Chaque programme gére de lui-même les adresses de base des segments qu'il manipule. Il n'est en rien aidé par une table des segments gérée par le système d'exploitation. ===Les registres de segments en mode réel=== Chaque segment subit la relocation indépendamment des autres. Pour cela, le processeur intégre plusieurs registres de base, un par segment. Notons que cette solution ne marche que si le nombre de segments par programme est limité, à une dizaine de segments tout au plus. Les processeurs x86 utilisaient cette méthode, et n'associaient que 4 à 6 registres de segments par programme. Les processeurs 8086 et le 286 avaient quatre registres de segment : un pour le code, un autre pour les données, et un pour la pile, le quatrième étant un registre facultatif laissé à l'appréciation du programmeur. Ils sont nommés CS (''code segment''), DS (''data segment''), SS (''Stack segment''), et ES (''Extra segment''). Le 386 rajouta deux registres, les registres FS et GS, qui sont utilisés pour les segments de données. Les processeurs post-386 ont donc 6 registres de segment. Les registres CS et SS sont adressés implicitement, en fonction de l'instruction exécutée. Les instructions de la pile manipulent le segment associé à la pile, le chargement des instructions se fait dans le segment de code, les instructions arithmétiques et logiques vont chercher leurs opérandes sur le tas, etc. Et donc, toutes les instructions sont chargées depuis le segment pointé par CS, les instructions de gestion de la pile (PUSH et POP) utilisent le segment pointé par SS. Les segments DS et ES sont, eux aussi, adressés implicitement. Pour cela, les instructions LOAD/STORE sont dupliquées : il y a une instruction LOAD pour le segment DS, une autre pour le segment ES. D'autres instructions lisent leurs opérandes dans un segment par défaut, mais on peut changer ce choix par défaut en précisant le segment voulu. Un exemple est celui de l'instruction CMPSB, qui compare deux octets/bytes : le premier est chargé depuis le segment DS, le second depuis le segment ES. Un autre exemple est celui de l'instruction MOV avec un opérande en mémoire. Elle lit l'opérande en mémoire depuis le segment DS par défaut. Il est possible de préciser le segment de destination si celui-ci n'est pas DS. Par exemple, l'instruction MOV [A], AX écrit le contenu du registre AX dans l'adresse A du segment DS. Par contre, l'instruction MOV ES:[A], copie le contenu du registre AX das l'adresse A, mais dans le segment ES. ===La traduction d'adresse en mode réel=== La segmentation en mode réel a pour seul but de permettre à un programme de dépasser la limite des 64 KB autorisée par les adresses de 16 bits. L'idée est que chaque segment a droit à son propre espace de 64 KB. On a ainsi 64 Kb pour le code machine, 64 KB pour la pile, 64 KB pour un segment de données, etc. Les registres de segment mémorisaient la base du segment, les adresses calculées par l'ALU étant des ''offsets''. Ce sont tous des registres de 16 bits, mais ils ne mémorisent pas des adresses physiques de 16 bits, comme nous allons le voir. [[File:Table des segments dans un banc de registres.png|centre|vignette|upright=2|Table des segments dans un banc de registres.]] L'Intel 8086 utilisait des adresses de 20 bits, ce qui permet d'adresser 1 mébioctet de RAM. Vous pouvez vous demander comment on peut obtenir des adresses de 20 bits alors que les registres de segments font tous 16 bits ? Cela tient à la manière dont sont calculées les adresses physiques. Le registre de segment n'est pas additionné tel quel avec le décalage : à la place, le registre de segment est décalé de 4 rangs vers la gauche. Le décalage de 4 rangs vers la gauche fait que chaque segment a une adresse qui est multiple de 16. Le fait que le décalage soit de 16 bits fait que les segments ont une taille de 64 kibioctets. {|class="wikitable" |- | <code>&nbsp; </code><code style="background:#DED">0000 0110 1110 1111</code><code>0000</code> | Registre de segment - | 16 bits, décalé de 4 bits vers la gauche |- | <code>+ &nbsp;&nbsp;&nbsp;&nbsp; </code><code style="background:#DDF">0001 0010 0011 0100</code> | Décalage/''Offset'' | 16 bits |- | colspan="3" | |- | <code>&nbsp; </code><code style="background:#FDF">0000 1000 0001 0010 0100</code> | Adresse finale | 20 bits |} Vous aurez peut-être remarqué que le calcul peut déborder, dépasser 20 bits. Mais nous reviendrons là-dessus plus bas. L'essentiel est que la MMU pour la segmentation en mode réel se résume à quelques registres et des additionneurs/soustracteurs. Un exemple est l'Intel 8086, un des tout premier processeur Intel. Le processeur était découpé en deux portions : l'interface mémoire et le reste du processeur. L'interface mémoire est appelée la '''''Bus Interface Unit''''', et le reste du processeur est appelé l{{'}}'''''Execution Unit'''''. L'interface mémoire contenait les registres de segment, au nombre de 4, ainsi qu'un additionneur utilisé pour traduire les adresses logiques en adresses physiques. Elle contenait aussi une file d'attente où étaient préchargées les instructions. Sur le 8086, la MMU est fusionnée avec les circuits de gestion du ''program counter''. Les registres de segment sont regroupés avec le ''program counter'' dans un même banc de registres, et un additionneur unique est utilisé à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. L'additionneur est donc mutualisé entre segmentation et ''program counter''. En somme, il n'y a pas vraiment de MMU dédiée, mais un super-circuit en charge du Fetch et de la mémoire virtuelle, ainsi que du préchargement des instructions. Nous en reparlerons au chapitre suivant. [[File:80186 arch.png|centre|vignette|upright=2.5|Architecture du 8086, du 80186 et de ses variantes.]] La MMU du 286 était fusionnée avec l'unité de calcul d'adresse. Elle contient les registres de segments, un comparateur pour détecter les accès hors-segment, et plusieurs additionneurs. Il y a un additionneur pour les calculs d'adresse proprement dit, suivi d'un additionneur pour la relocation. [[File:Intel i80286 arch.svg|centre|vignette|upright=3|Intel i80286 arch]] ===La segmentation en mode réel accepte plusieurs segments de code/données=== Les programmes peuvent parfaitement répartir leur code machine dans plusieurs segments de code. La limite de 64 KB par segment est en effet assez limitante, et il n'était pas rare qu'un programme stocke son code dans deux ou trois segments. Il en est de même avec les données, qui peuvent être réparties dans deux ou trois segments séparés. La seule exception est la pile : elle est forcément dans un segment unique et ne peut pas dépasser 64 KB. Pour gérer plusieurs segments de code/donnée, il faut changer de segment à la volée suivant les besoins, en modifiant les registres de segment. Il s'agit de la technique de '''commutation de segment'''. Pour cela, tous les registres de segment, à l'exception de CS, peuvent être altérés par une instruction d'accès mémoire, soit avec une instruction MOV, soit en y copiant le sommet de la pile avec une instruction de dépilage POP. L'absence de sécurité fait que la gestion de ces registres est le fait du programmeur, qui doit redoubler de prudence pour ne pas faire n'importe quoi. Pour le code machine, le répartir dans plusieurs segments posait des problèmes au niveau des branchements. Si la plupart des branchements sautaient vers une instruction dans le même segment, quelques rares branchements sautaient vers du code machine dans un autre segment. Intel avait prévu le coup et disposait de deux instructions de branchement différentes pour ces deux situations : les '''''near jumps''''' et les '''''far jumps'''''. Les premiers sont des branchements normaux, qui précisent juste l'adresse à laquelle brancher, qui correspond à la position de la fonction dans le segment. Les seconds branchent vers une instruction dans un autre segment, et doivent préciser deux choses : l'adresse de base du segment de destination, et la position de la destination dans le segment. Le branchement met à jour le registre CS avec l'adresse de base, avant de faire le branchement. Ces derniers étaient plus lents, car on n'avait pas à changer de segment et mettre à jour l'état du processeur. Il y avait la même pour l'instruction d'appel de fonction, avec deux versions de cette instruction. La première version, le '''''near call''''' est un appel de fonction normal, la fonction appelée est dans le segment en cours. Avec la seconde version, le '''''far call''''', la fonction appelée est dans un segment différent. L'instruction a là aussi besoin de deux opérandes : l'adresse de base du segment de destination, et la position de la fonction dans le segment. Un ''far call'' met à jour le registre CS avec l'adresse de base, ce qui fait que les ''far call'' sont plus lents que les ''near call''. Il existe aussi la même chose, pour les instructions de retour de fonction, avec une instruction de retour de fonction normale et une instruction de retour qui renvoie vers un autre segment, qui sont respectivement appelées '''''near return''''' et '''''far return'''''. Là encore, il faut préciser l'adresse du segment de destination dans le second cas. La même chose est possible pour les segments de données. Sauf que cette fois-ci, ce sont les pointeurs qui sont modifiés. pour rappel, les pointeurs sont, en programmation, des variables qui contiennent des adresses. Lors de la compilation, ces pointeurs sont placés soit dans un registre, soit dans les instructions (adressage absolu), ou autres. Ici, il existe deux types de pointeurs, appelés '''''near pointer''''' et '''''far pointer'''''. Vous l'avez deviné, les premiers sont utilisés pour localiser les données dans le segment en cours d'utilisation, alors que les seconds pointent vers une donnée dans un autre segment. Là encore, la différence est que le premier se contente de donner la position dans le segment, alors que les seconds rajoutent l'adresse de base du segment. Les premiers font 16 bits, alors que les seconds en font 32 : 16 bits pour l'adresse de base et 16 pour l{{'}}''offset''. ===L'occupation de l'espace d'adressage par les segments=== Nous venons de voir qu'un programme pouvait utiliser plus de 4-6 segments, avec la commutation de segment. Mais d'autres programmes faisaient l'inverse, à savoir qu'ils se débrouillaient avec seulement 1 ou 2 segments. Suivant le nombre de segments utilisés, la configuration des registres n'était pas la même. Les configurations possibles sont appelées des ''modèle mémoire'', et il y en a en tout 6. En voici la liste : {| class="wikitable" |- ! Modèle mémoire !! Configuration des segments !! Configuration des registres || Pointeurs utilisés || Branchements utilisés |- | Tiny* || Segment unique pour tout le programme || CS=DS=SS || ''near'' uniquement || ''near'' uniquement |- | Small || Segment de donnée séparé du segment de code, pile dans le segment de données || DS=SS || ''near'' uniquement || ''near'' uniquement |- | Medium || Plusieurs segments de code unique, un seul segment de données || CS, DS et SS sont différents || ''near'' et ''far'' || ''near'' uniquement |- | Compact || Segment de code unique, plusieurs segments de données || CS, DS et SS sont différents || ''near'' uniquement || ''near'' et ''far'' |- | Large || Plusieurs segments de code, plusieurs segments de données || CS, DS et SS sont différents || ''near'' et ''far'' || ''near'' et ''far'' |} Un programme est censé utiliser maximum 4-6 segments de 64 KB, ce qui permet d'adresser maximum 64 * 6 = 384 KB de RAM, soit bien moins que le mébioctet de mémoire théoriquement adressable. Mais ce défaut est en réalité contourné par la commutation de segment, qui permettait d'adresser la totalité de la RAM si besoin. Une second manière de contourner cette limite est que plusieurs processus peuvent s'exécuter sur un seul processeur, si l'OS le permet. Ce n'était pas le cas à l'époque du DOS, qui était un OS mono-programmé, mais c'était en théorie possible. La limite est de 6 segments par programme/processus, en exécuter plusieurs permet d'utiliser toute la mémoire disponible rapidement. [[File:Overlapping realmode segments.svg|vignette|Segments qui se recouvrent en mode réel.]] Vous remarquerez qu'avec des registres de segments de 16 bits, on peut gérer 65536 segments différents, chacun de 64 KB. Et 65 536 segments de 64 kibioctets, ça ne rentre pas dans le mébioctet de mémoire permis avec des adresses de 20 bits. La raison est que plusieurs couples segment+''offset'' pointent vers la même adresse. En tout, chaque adresse peut être adressée par 4096 couples segment+''offset'' différents. L'avantage de cette méthode est que des segments peuvent se recouvrir, à savoir que la fin de l'un se situe dans le début de l'autre, comme illustré ci-contre. Cela permet en théorie de partager de la mémoire entre deux processus. Mais la technique est tout sauf pratique et est donc peu utilisée. Elle demande de placer minutieusement les segments en RAM, et les données à partager dans les segments. En pratique, les programmeurs et OS utilisent des segments qui ne se recouvrent pas et sont disjoints en RAM. Le nombre maximal de segments disjoints se calcule en prenant la taille de la RAM, qu'on divise par la taille d'un segment. Le calcul donne : 1024 kibioctets / 64 kibioctets = 16 segments disjoints. Un autre calcul prend le nombre de segments divisé par le nombre d'adresses aliasées, ce qui donne 65536 / 4096 = 16. Seulement 16 segments, c'est peu. En comptant les segments utilisés par l'OS et ceux utilisés par le programme, la limite est vite atteinte si le programme utilise la commutation de segment. ===Le mode réel sur les 286 et plus : la ligne d'adresse A20=== Pour résumer, le registre de segment contient des adresses de 20 bits, dont les 4 bits de poids faible sont à 0. Et il se voit ajouter un ''offset'' de 16 bits. Intéressons-nous un peu à l'adresse maximale que l'on peut calculer avec ce système. Nous allons l'appeler l{{'}}'''adresse maximale de segmentation'''. Elle vaut : {|class="wikitable" |- | <code>&nbsp; </code><code style="background:#DED">1111 1111 1111 1111</code><code>0000</code> | Registre de segment - | 16 bits, décalé de 4 bits vers la gauche |- | <code>+ &nbsp;&nbsp;&nbsp;&nbsp; </code><code style="background:#DDF">1111 1111 1111 1111</code> | Décalage/''Offset'' | 16 bits |- | colspan="3" | |- | <code>&nbsp; </code><code style="background:#FDF">1 0000 1111 1111 1110 1111</code> | Adresse finale | 20 bits |} Le résultat n'est pas l'adresse maximale codée sur 20 bits, car l'addition déborde. Elle donne un résultat qui dépasse l'adresse maximale permis par les 20 bits, il y a un 21ème bit en plus. De plus, les 20 bits de poids faible ont une valeur bien précise. Ils donnent la différence entre l'adresse maximale permise sur 20 bit, et l'adresse maximale de segmentation. Les bits 1111 1111 1110 1111 traduits en binaire donnent 65 519; auxquels il faut ajouter l'adresse 1 0000 0000 0000 0000. En tout, cela fait 65 520 octets adressables en trop. En clair : on dépasse la limite du mébioctet de 65 520 octets. Le résultat est alors très différent selon que l'on parle des processeurs avant le 286 ou après. Avant le 286, le bus d'adresse faisait exactement 20 bits. Les adresses calculées ne pouvaient pas dépasser 20 bits. L'addition générait donc un débordement d'entier, géré en arithmétique modulaire. En clair, les bits de poids fort au-delà du vingtième sont perdus. Le calcul de l'adresse débordait et retournait au début de la mémoire, sur les 65 520 premiers octets de la mémoire RAM. [[File:IBM PC Memory areas.svg|vignette|IBM PC Memory Map, la ''High memory area'' est en jaune.]] Le 80286 en mode réel gère des adresses de base de 24 bits, soit 4 bits de plus que le 8086. Le résultat est qu'il n'y a pas de débordement. Les bits de poids fort sont conservés, même au-delà du 20ème. En clair, la segmentation permettait de réellement adresser 65 530 octets au-delà de la limite de 1 mébioctet. La portion de mémoire adressable était appelé la '''''High memory area''''', qu'on va abrévier en HMA. {| class="wikitable" |+ Espace d'adressage du 286 |- ! Adresses en héxadécimal !! Zone de mémoire |- | 10 FFF0 à FF FFFF || Mémoire étendue, au-delà du premier mébioctet |- | 10 0000 à 10 FFEF || ''High Memory Area'' |- | 0 à 0F FFFF || Mémoire adressable en mode réel |} En conséquence, les applications peuvent utiliser plus d'un mébioctet de RAM, mais au prix d'une rétrocompatibilité imparfaite. Quelques programmes DOS ne marchaient pus à cause de ça. D'autres fonctionnaient convenablement et pouvaient adresser les 65 520 octets en plus. Pour résoudre ce problème, les carte mères ajoutaient un petit circuit relié au 21ème bit d'adresse, nommé A20 (pas d'erreur, les fils du bus d'adresse sont numérotés à partir de 0). Le circuit en question pouvait mettre à zéro le fil d'adresse, ou au contraire le laisser tranquille. En le forçant à 0, le calcul des adresses déborde comme dans le mode réel des 8086. Mais s'il ne le fait pas, la ''high memory area'' est adressable. Le circuit était une simple porte ET, qui combinait le 21ème bit d'adresse avec un '''signal de commande A20''' provenant d'ailleurs. Le signal de commande A20 était géré par le contrôleur de clavier, qui était soudé à la carte mère. Le contrôleur en question ne gérait pas que le clavier, il pouvait aussi RESET le processeur, alors gérer le signal de commande A20 n'était pas si problématique. Quitte à avoir un microcontrôleur sur la carte mère, autant s'en servir au maximum... La gestion du bus d'adresse étaitdonc gérable au clavier. D'autres carte mères faisaient autrement et préféraient ajouter un interrupteur, pour activer ou non la mise à 0 du 21ème bit d'adresse. : Il faut noter que le signal de commande A20 était mis à 1 en mode protégé, afin que le 21ème bit d'adresse soit activé. Le 386 ajouta deux registres de segment, les registres FS et GS, ainsi que le '''mode ''virtual 8086'''''. Ce dernier permet d’exécuter des programmes en mode réel alors que le système d'exploitation s'exécute en mode protégé. C'est une technique de virtualisation matérielle qui permet d'émuler un 8086 sur un 386. L'avantage est que la compatibilité avec les programmes anciens écrits pour le 8086 est conservée, tout en profitant de la protection mémoire. Tous les processeurs x86 qui ont suivi supportent ce mode virtuel 8086. ==La segmentation avec une table des segments== La '''segmentation avec une table des segments''' est apparue sur des processeurs assez anciens, le tout premier étant le Burrough 5000. Elle a ensuite été utilisée sur les processeurs x86 des PCs, à partir du 286 d'Intel. Elle est aujourd'hui abandonnée sur les jeux d'instruction x86. Tout comme la segmentation en mode réel, la segmentation attribue plusieurs segments par programmes ! Et cela a des répercutions sur la manière dont la traduction d'adresse est effectuée. ===Pourquoi plusieurs segments par programme ?=== L'utilité d'avoir plusieurs segments par programme n'est pas évidente, mais elle le devient quand on se plonge dans le passé. Dans le passé, les programmeurs devaient faire avec une quantité de mémoire limitée et il n'était pas rare que certains programmes utilisent plus de mémoire que disponible sur la machine. Mais les programmeurs concevaient leurs programmes en fonction. [[File:Overlay Programming.svg|vignette|upright=1|Overlay Programming]] L'idée était d'implémenter un système de mémoire virtuelle, mais émulé en logiciel, appelé l{{'}}'''''overlaying'''''. Le programme était découpé en plusieurs morceaux, appelés des ''overlays''. Les ''overlays'' les plus importants étaient en permanence en RAM, mais les autres étaient faisaient un va-et-vient entre RAM et disque dur. Ils étaient chargés en RAM lors de leur utilisation, puis sauvegardés sur le disque dur quand ils étaient inutilisés. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. Le matériel n'intervenait pas, comme c'est le cas avec la mémoire virtuelle. Avec la segmentation, un programme peut utiliser la technique des ''overlays'', mais avec l'aide du matériel. Il suffit de mettre chaque ''overlay'' dans son propre segment, et laisser la segmentation faire. Les segments sont swappés en tout ou rien : on doit swapper tout un segment en entier. L'intérêt est que la gestion du ''swapping'' est grandement facilitée, vu que c'est le système d'exploitation qui s'occupe de swapper les segments sur le disque dur ou de charger des segments en RAM. Pas besoin pour le programmeur de coder quoique ce soit. Par contre, cela demande l'intervention du programmeur, qui doit découper le programme en segments/''overlays'' de lui-même. Sans cela, la segmentation n'est pas très utile. L{{'}}''overlaying'' est une forme de '''segmentation à granularité grossière''', à savoir que le programme est découpé en segments de grande taille. L'usage classique est d'avoir un segment pour la pile, un autre pour le code exécutable, un autre pour le reste. Éventuellement, on peut découper les trois segments précédents en deux ou trois segments, rarement au-delà. Les segments sont alors peu nombreux, guère plus d'une dizaine par programme. D'où le terme de ''granularité grossière''. La '''segmentation à granularité fine''' pousse le concept encore plus loin. Avec elle, il y a idéalement un segment par entité manipulée par le programme, un segment pour chaque structure de donnée et/ou chaque objet. Par exemple, un tableau aura son propre segment, ce qui est idéal pour détecter les accès hors tableau. Pour les listes chainées, chaque élément de la liste aura son propre segment. Et ainsi de suite, chaque variable agrégée (non-primitive), chaque structure de donnée, chaque objet, chaque instance d'une classe, a son propre segment. Diverses fonctionnalités supplémentaires peuvent être ajoutées, ce qui transforme le processeur en véritable processeur orienté objet, mais passons ces détails pour le moment. Vu que les segments correspondent à des objets manipulés par le programme, on peut deviner que leur nombre évolue au cours du temps. En effet, les programmes modernes peuvent demander au système d'exploitation du rab de mémoire pour allouer une nouvelle structure de données. Avec la segmentation à granularité fine, cela demande d'allouer un nouveau segment à chaque nouvelle allocation mémoire, à chaque création d'une nouvelle structure de données ou d'un objet. De plus, les programmes peuvent libérer de la mémoire, en supprimant les structures de données ou objets dont ils n'ont plus besoin. Avec la segmentation à granularité fine, cela revient à détruire le segment alloué pour ces objets/structures de données. Le nombre de segments est donc dynamique, il change au cours de l'exécution du programme. ===Les tables de segments avec la segmentation=== La présence de plusieurs segments par programme a un impact sur la table des segments. Avec la relocation matérielle, elle conte nait un segment par programme. Chaque entrée, chaque ligne de la table des segment, mémorisait l'adresse de base, l'adresse limite, un bit de présence pour la mémoire virtuelle et des autorisations liées à la protection mémoire. Avec la segmentation, les choses sont plus compliquées, car il y a plusieurs segments par programme. Les entrées ne sont pas modifiées, mais elles sont organisées différemment. Avec cette forme de segmentation, la table des segments doit respecter plusieurs contraintes. Premièrement, il y a plusieurs segments par programmes. Deuxièmement, le nombre de segments est variable : certains programmes se contenteront d'un seul segment, d'autres de dizaine, d'autres plusieurs centaines, etc. Il y a typiquement deux manières de faire : soit utiliser une table des segments uniques, utiliser une table des segment par programme. Il est possible d'utiliser une table des segment unique qui mémorise tous les segments de tous les processus, système d'exploitation inclut. On parle alors de '''table des segment globale'''. Mais cette solution n'est pas utilisée avec la segmentation proprement dite. Elle est utilisée sur les architectures à capacité qu'on détaillera vers la fin du chapitre, dans une section dédiée. À la place, la segmentation utilise une table de segment par processus/programme, chacun ayant une '''table des segment locale'''. Dans les faits, les choses sont plus compliquées. Le système d'exploitation doit savoir où se trouvent les tables de segment locale pour chaque programme. Pour cela, il a besoin d'utiliser une table de segment globale, dont chaque entrée pointe non pas vers un segment, mais vers une table de segment locale. Lorsque l'OS effectue une commutation de contexte, il lit la table des segment globale, pour récupérer un pointeur vers celle-ci. Ce pointeur est alors chargé dans un registre du processeur, qui mémorise l'adresse de la table locale, ce qui sert lors des accès mémoire. Une telle organisation fait que les segments d'un processus/programme sont invisibles pour les autres, il y a une certaine forme de sécurité. Un programme ne connait que sa table de segments locale, il n'a pas accès directement à la table des segments globales. Tout accès mémoire se passera à travers la table de segment locale, il ne sait pas où se trouvent les autres tables de segment locales. Les processeurs x86 sont dans ce cas : ils utilisent une table de segment globale couplée à autant de table des segments qu'il y a de processus en cours d'exécution. La table des segments globale s'appelle la '''''Global Descriptor Table''''' et elle peut contenir 8192 segments maximum, ce qui permet le support de 8192 processus différents. Les tables de segments locales sont appelées les '''''Local Descriptor Table''''' et elles font aussi 8192 segments maximum, ce qui fait 8192 segments par programme maximum. Il faut noter que la table de segment globale peut mémoriser des pointeurs vers les routines d'interruption, certaines données partagées (le tampon mémoire pour le clavier) et quelques autres choses, qui n'ont pas leur place dans les tables de segment locales. ===La relocation avec la segmentation=== La table des segments locale mémorise les adresses de base et limite de chaque segment, ainsi que d'autres méta-données. Les informations pour un segment sont regroupés dans un '''descripteur de segment''', qui est codé sur plusieurs octets, et qui regroupe : adresse de base, adresse limite, bit de présence en RAM, méta-données de protection mémoire. La table des segments est un tableau dans lequel les descripteurs de segment sont placés les uns à la suite des autres en mémoire RAM. La table des segments est donc un tableau de segment. Les segments d'un programme sont numérotés, le nombre s'appelant un '''indice de segment''', appelé '''sélecteur de segment''' dans la terminologie Intel. L'indice de segment n'est autre que l'indice du segment dans ce tableau. [[File:Global Descriptor table.png|centre|vignette|upright=2|Table des segments locale.]] Il n'y a pas de registre de segment proprement dit, qui mémoriserait l'adresse de base. À la place, les segments sont adressés de manière indirecte. À la place, les registres de segment mémorisent des sélecteurs de segment. Ils sont utilisés pour lire l'adresse de base/limite dans la table de segment en mémoire RAM. Pour cela, un registre mémorise l'adresse de la table de segment locale, sa position en mémoire RAM. Toute lecture ou écriture se fait en deux temps, en deux accès mémoire, consécutifs. Premièrement, le numéro de segment est utilisé pour adresser la table des segment. La lecture récupère alors un pointeur vers ce segment. Deuxièmement, ce pointeur est utilisé pour faire la lecture ou écriture. Plus précisément, la première lecture récupère un descripteur de segment qui contient l'adresse de base, le pointeur voulu, mais aussi l'adresse limite et d'autres informations. [[File:Segmentation avec table des segments.png|centre|vignette|upright=2|Segmentation avec table des segments]] L'accès à la table des segments se fait automatiquement à chaque accès mémoire. La conséquence est que chaque accès mémoire demande d'en faire deux : un pour lire la table des segments, l'autre pour l'accès lui-même. Il s'agit en quelque sorte d'une forme d'adressage indirect mémoire. Un point important est que si le premier accès ne fait qu'une simple lecture dans un tableau, le second accès implique des calculs d'adresse. En effet, le premier accès récupère l'adresse de base du segment, mais le second accès sélectionne une donnée dans le segment, ce qui demande de calculer son adresse. L'adresse finale se déduit en combinant l'adresse de base avec un décalage (''offset'') qui donne la position de la donnée dans ce segment. L'indice de segment est utilisé pour récupérer l'adresse de base du segment. Une fois cette adresse de base connue, on lui additionne le décalage pour obtenir l'adresse finale. [[File:Table des segments.png|centre|vignette|upright=2|Traduction d'adresse avec une table des segments.]] Pour effectuer automatiquement l'accès à la table des segments, le processeur doit contenir un registre supplémentaire, qui contient l'adresse de la table de segment, afin de la localiser en mémoire RAM. Nous appellerons ce registre le '''pointeur de table'''. Le pointeur de table est combiné avec l'indice de segment pour adresser le descripteur de segment adéquat. [[File:Segment 2.svg|centre|vignette|upright=2|Traduction d'adresse avec une table des segments, ici appelée table globale des de"scripteurs (terminologie des processeurs Intel x86).]] Un point important est que la table des segments n'est pas accessible pour le programme en cours d'exécution. Il ne peut pas lire le contenu de la table des segments, et encore moins la modifier. L'accès se fait seulement de manière indirecte, en faisant usage des indices de segments, mais c'est un adressage indirect. Seul le système d'exploitation peut lire ou écrire la table des segments directement. Plus haut, j'ai dit que tout accès mémoire impliquait deux accès mémoire : un pour charger le descripteur de segment, un autre pour la lecture/écriture proprement dite. Cependant, cela aurait un impact bien trop grand sur les performances. Dans les faits, les processeurs avec segmentations intégraient un '''cache de descripteurs de segments''', pour limiter la casse. Quand un descripteur de segment est lu depuis la RAM, il est copié dans ce cache. Les accès ultérieurs accédent au descripteur dans le cache, pas besoin de passer par la RAM. L'intel 386 avait un cache de ce type. ===La protection mémoire : les accès hors-segments=== Comme avec la relocation matérielle, le processeur détecte les débordements de segment. Pour cela, il compare l'adresse logique accédée avec l'adresse limite, ou compare la taille limite avec le décalage. De nombreux processeurs, comme l'Intel 386, préféraient utiliser la taille du segment, pour une question d'optimisation. En effet, si on compare l'adresse finale avec l'adresse limite, on doit faire la relocation avant de comparer l'adresse relocatée. Mais en utilisant la taille, ce n'est pas le cas : on peut faire la comparaison avant, pendant ou après la relocation. Un détail à prendre en compte est la taille de la donnée accédée. Sans cela, la comparaison serait très simple : on vérifie si ''décalage <= taille du segment'', ou on compare des adresses de la même manière. Mais imaginez qu'on accède à une donnée de 4 octets : il se peut que l'adresse de ces 4 octets rentre dans le segment, mais que quelques octets débordent. Par exemple, les deux premiers octets sont dans le segment, mais pas les deux suivants. La vraie comparaison est alors : ''décalage + 4 octets <= taille du segment''. Mais il est possible de faire le calcul autrement, et quelques processeurs comme l'Intel 386 ne s'en sont pas privé. Il calculait la différence ''taille du segment - décalage'', et vérifiait le résultat. Le processeur gérait des données de 1, 2 et 4 octets, ce qui fait que le résultat devait être entre 0 et 3. Le processeur prenait le résultat de la soustraction, et vérifiait alors que les 30 bits de poids fort valaient bien 0. Il vérifiait aussi que les deux bits de poids faible avaient la bonne valeur. [[File:Vm7.svg|centre|vignette|upright=2|Traduction d'adresse avec vérification des accès hors-segment.]] Une nouveauté fait son apparition avec la segmentation : la '''gestion des droits d'accès'''. Par exemple, il est possible d'interdire d'exécuter le contenu d'un segment, ce qui fournit une protection contre certaines failles de sécurité ou certains virus. Lorsqu'on exécute une opération interdite, le processeur lève une exception matérielle, à charge du système d'exploitation de gérer la situation. Pour cela, chaque segment se voit attribuer un certain nombre d'autorisations d'accès qui indiquent si l'on peut lire ou écrire dedans, si celui-ci contient un programme exécutable, etc. Les autorisations pour chaque segment sont placées dans le descripteur de segment. Elles se résument généralement à quelques bits, qui indiquent si le segment est accesible en lecture/écriture ou exécutable. Le tout est souvent concaténé dans un ou deux '''octets de droits d'accès'''. L'implémentation de la protection mémoire dépend du CPU considéré. Les CPU microcodés peuvent en théorie utiliser le microcode. Lorsqu'une instruction mémoire s'exécute, le microcode effectue trois étapes : lire le descripteur de segment, faire les tests de protection mémoire, exécuter la lecture/écriture ou lever une exception. Létape de test est réalisée avec un ou plusieurs micro-branchements. Par exemple, une écriture va tester le bit R/W du descripteur, qui indique si on peut écrire dans le segment, en utilisant un micro-branchement. Le micro-branchement enverra vers une routine du microcode en cas d'erreur. Les tests de protection mémoire demandent cependant de tester beaucoup de conditions différentes. Par exemple, le CPU Intel 386 testait moins d'une dizaine de conditions pour certaines instructions. Il est cependant possible de faire plusieurs comparaisons en parallèle en rusant un peu. Il suffit de mémoriser les octets de droits d'accès dans un registre interne, de masquer les bits non-pertinents, et de faire une comparaison avec une constante adéquate, qui encode la valeur que doivent avoir ces bits. Une solution alternative utiliser un circuit combinatoire pour faire les tests de protection mémoire. Les tests sont alors faits en parallèles, plutôt qu'un par un par des micro-branchements. Par contre, le cout en matériel est assez important. Il faut ajouter ce circuit combinatoire, ce qui demande pas mal de circuits. ===La mémoire virtuelle avec la segmentation=== La mémoire virtuelle est une fonctionnalité souvent implémentée sur les processeurs qui gèrent la segmentation, alors que les processeurs avec relocation matérielle s'en passaient. Il faut dire que l'implémentation de la mémoire virtuelle est beaucoup plus simple avec la segmentation, comparé à la relocation matérielle. Le remplacement des registres de base par des sélecteurs de segment facilite grandement l'implémentation. Le problème de la mémoire virtuelle est que les segments peuvent être swappés sur le disque dur n'importe quand, sans que le programme soit prévu. Le swapping est réalisé par une interruption de l'OS, qui peut interrompre le programme n'importe quand. Et si un segment est swappé, le registre de base correspondant devient invalide, il point sur une adresse en RAM où le segment était, mais n'est plus. De plus, les segments peuvent être déplacés en mémoire, là encore n'importe quand et d'une manière invisible par le programme, ce qui fait que les registres de base adéquats doivent être modifiés. Si le programme entier est swappé d'un coup, comme avec la relocation matérielle simple, cela ne pose pas de problèmes. Mais dès qu'on utilise plusieurs registres de base par programme, les choses deviennent soudainement plus compliquées. Le problème est qu'il n'y a pas de mécanismes pour choisir et invalider le registre de base adéquat quand un segment est déplacé/swappé. En théorie, on pourrait imaginer des systèmes qui résolvent le problème au niveau de l'OS, mais tous ont des problèmes qui font que l'implémentation est compliquée ou que les performances sont ridicules. L'usage d'une table des segments accédée à chaque accès résout complètement le problème. La table des segments est accédée à chaque accès mémoire, elle sait si le segment est swappé ou non, chaque accès vérifie si le segment est en mémoire et quelle est son adresse de base. On peut changer le segment de place n'importe quand, le prochain accès récupérera des informations à jour dans la table des segments. L'implémentation de la mémoire virtuelle avec la segmentation est simple : il suffit d'ajouter un bit dans les descripteurs de segments, qui indique si le segment est swappé ou non. Tout le reste, la gestion de ce bit, du swap, et tout ce qui est nécessaire, est délégué au système d'exploitation. Lors de chaque accès mémoire, le processeur vérifie ce bit avant de faire la traduction d'adresse, et déclenche une exception matérielle si le bit indique que le segment est swappé. L'exception matérielle est gérée par l'OS. ===Le partage de segments=== Il est possible de partager un segment entre plusieurs applications. Cela peut servir pour partager des données entre deux programmes : un segment de données partagées est alors partagé entre deux programmes. Partager un segment de code est utile pour les bibliothèques partagées : la bibliothèque est placée dans un segment dédié, qui est partagé entre les programmes qui l'utilisent. Partager un segment de code est aussi utile quand plusieurs instances d'une même application sont lancés simultanément : le code n'ayant pas de raison de changer, celui-ci est partagé entre toutes les instances. Mais ce n'est là qu'un exemple. La première solution pour cela est de configurer les tables de segment convenablement. Le même segment peut avoir des droits d'accès différents selon les processus. Les adresses de base/limite sont identiques, mais les tables des segments ont alors des droits d'accès différents. Mais cette méthode de partage des segments a plusieurs défauts. Premièrement, les sélecteurs de segments ne sont pas les mêmes d'un processus à l'autre, pour un même segment. Le segment partagé peut correspondre au segment numéro 80 dans le premier processus, au segment numéro 1092 dans le second processus. Rien n'impose que les sélecteurs de segment soient les mêmes d'un processus à l'autre, pour un segment identique. Deuxièmement, les adresses limite et de base sont dupliquées dans plusieurs tables de segments. En soi, cette redondance est un souci mineur. Mais une autre conséquence est une question de sécurité : que se passe-t-il si jamais un processus a une table des segments corrompue ? Il se peut que pour un segment identique, deux processus n'aient pas la même adresse limite, ce qui peut causer des failles de sécurité. Un processus peut alors subir un débordement de tampon, ou tout autre forme d'attaque. [[File:Vm9.png|centre|vignette|upright=2|Illustration du partage d'un segment entre deux applications.]] Une seconde solution, complémentaire, utilise une table de segment globale, qui mémorise des segments partagés ou accessibles par tous les processus. Les défauts de la méthode précédente disparaissent avec cette technique : un segment est identifié par un sélecteur unique pour tous les processus, il n'y a pas de duplication des descripteurs de segment. Par contre, elle a plusieurs défauts. Le défaut principal est que cette table des segments est accessible par tous les processus, impossible de ne partager ses segments qu'avec certains pas avec les autres. Un autre défaut est que les droits d'accès à un segment partagé sont identiques pour tous les processus. Impossible d'avoir un segment partagé accessible en lecture seule pour un processus, mais accessible en écriture pour un autre. Il est possible de corriger ces défauts, mais nous en parlerons dans la section sur les architectures à capacité. ===L'extension d'adresse avec la segmentation=== L'extension d'adresse est possible avec la segmentation, de la même manière qu'avec la relocation matérielle. Il suffit juste que les adresses de base soient aussi grandes que le bus d'adresse. Mais il y a une différence avec la relocation matérielle : un même programme peut utiliser plus de mémoire qu'il n'y en a dans l'espace d'adressage. La raison est simple : un segment peut prendre tout l'espace d'adressage, et il y a plusieurs segments par programme. Pour donner un exemple, prenons un processeur 16 bits, qui peut adresser 64 kibioctets, associé à une mémoire de 4 mébioctets. Il est possible de placer le code machine dans les premiers 64k de la mémoire, la pile du programme dans les 64k suivants, le tas dans les 64k encore après, et ainsi de suite. Le programme dépasse donc les 64k de mémoire de l'espace d'adressage. Ce genre de chose est impossible avec la relocation, où un programme est limité par l'espace d'adressage. ===Le mode protégé des processeurs x86=== L'Intel 80286, aussi appelé 286, ajouta un mode de segmentation séparé du mode réel, qui ajoute une protection mémoire à la segmentation, ce qui lui vaut le nom de '''mode protégé'''. Dans ce mode, les registres de segment ne contiennent pas des adresses de base, mais des sélecteurs de segments qui sont utilisés pour l'accès à la table des segments en mémoire RAM. Le 286 bootait en mode réel, puis le système d'exploitation devait faire quelques manipulations pour passer en mode protégé. Le 286 était pensé pour être rétrocompatible au maximum avec le 80186. Mais les différences entre le 286 et le 8086 étaient majeures, au point que les applications devaient être réécrites intégralement pour profiter du mode protégé. Un mode de compatibilité permettait cependant aux applications destinées au 8086 de fonctionner, avec même de meilleures performances. Aussi, le mode protégé resta inutilisé sur la plupart des applications exécutées sur le 286. Vint ensuite le processeur 80386, renommé en 386 quelques années plus tard. Sur ce processeur, les modes réel et protégé sont conservés tel quel, à une différence près : toutes les adresses passent à 32 bits, qu'il s'agisse des adresses de base, limite ou des ''offsets''. Le processeur peut donc adresser un grand nombre de segments : 2^32, soit plus de 4 milliards. Les segments grandissent aussi et passent de 64 KB maximum à 4 gibioctets maximum. Mais surtout : le 386 ajouta le support de la pagination en plus de la segmentation. Ces modifications ont été conservées sur les processeurs 32 bits ultérieurs. Les processeurs x86 gèrent deux types de tables des segments : une table locale pour chaque processus, et une table globale partagée entre tous les processus. Il ne peut y avoir qu'une table locale d'active, vu que le processeur ne peut exécuter qu'un seul processus en même temps. Chaque table locale définit 8192 segments, pareil pour la table globale. La table globale est utilisée pour les segments du noyau et la mémoire partagée entre processus. Un défaut est qu'un segment partagé par la table globale est visible par tous les processus, avec les mêmes droits d'accès. Ce qui fait que cette méthode était peu utilisée en pratique. La table globale mémorise aussi des pointeurs vers les tables locales, avec un descripteur de segment par table locale. Sur les processeurs x86 32 bits, un descripteur de segment est organisé comme suit, pour les architectures 32 bits. On y trouve l'adresse de base et la taille limite, ainsi que de nombreux bits de contrôle. Le premier groupe de bits de contrôle est l'octet en bleu à droite. Il contient : * le bit P qui indique que l'entrée contient un descripteur valide, qu'elle n'est pas vide ; * deux bits DPL qui indiquent le niveau de privilège du segment (noyau, utilisateur, les deux intermédiaires spécifiques au x86) ; * un bit S qui précise si le segment est de type système (utiles pour l'OS) ou un segment de code/données. * un champ Type qui contient les bits suivants : ** un bit E qui indique si le segment contient du code exécutable ou non ; ** le bit RW qui indique s'il est en lecture seule ou non ;; ** Un bit A qui indique que le segment a récemment été accédé, information utile pour l'OS; ** un bit DC assez spécifiques. En haut à gauche, en bleu, on trouve deux bits : * Le bit G indique comment interpréter la taille contenue dans le descripteur : 0 si la taille est exprimée en octets, 1 si la taille est un nombre de pages de 4 kibioctets. Ce bit précise si on utilise la segmentation seule, ou combinée avec la pagination. * Le bit DB précise si l'on utilise des segments en mode de compatibilité 16 bits ou des segments 32 bits. [[File:SegmentDescriptor.svg|centre|vignette|upright=3|Segment Descriptor]] Les indices de segment sont appelés des sélecteurs de segment. Ils ont une taille de 16 bits, mais 3 bits sont utilisés pour encoder des méta-données. Le numéro de segment est donc codé sur 13 bits, ce qui permettait de gérer maximum 8192 segments par table de segment (locale ou globale). Les 16 bits sont organisés comme suit : * 13 bits pour le numéro du segment dans la table des segments, l'indice de segment proprement dit ; * un bit qui précise s'il faut accéder à la table des segments globale ou locale ; * deux bits qui indiquent le niveau de privilège de l'accès au segment (les 4 niveaux de protection, dont l'espace noyau et utilisateur). [[File:SegmentSelector.svg|centre|vignette|upright=1.5|Sélecteur de segment 16 bit.]] En tout, l'indice permet de gérer 8192 segments pour la table locale et 8192 segments de la table globale. ====La MMU du 386/486 : cache de segment, protection mémoire==== La MMU du 386 et celle du 486 étaient assez similaires. Elles étaient plus complexes que celle du 186 et du 286. Elle contenait un additionneur pour les calculs d'adresse et un comparateur pour tester si l'accès mémoire déborde d'un segment. Le test de débordement se faisait en parallèle du calcul de l'adresse finale, comme sur le 286. L'additionneur était un additionneur trois-opérandes, qui additionnait l'adresse à lire/écrire, l'adresse de base du segment, et un décalage intégré dans l'instruction. En clair, l'unité de segmentation n'était pas qu'une MMU, elle prenait en charge une partie du calcul d'adresse. L'avantage est que cela permettait de gérer les modes d'adressage "base + décalage" et "base + indice + décalage" très facilement, en utilisant un minimum de circuits. Une conséquence de cette organisation était que l'usage d'un décalage était gratuit. Par contre, dès qu'on utilisait l'adressage "Base + Indice", avec ou sans décalage, l'instruction prenait un cycle de plus à s'exécuter, parce qu'il fallait faire l'addition "Base + Indice" dans l'ALU entière. Le CPU 386 était le premier à implémenter la protection mémoire avec des segments. Pour cela, il intégrait une '''''Protection Test Unit''''', séparée du microcode, qu'on va abrévier en PTU. Précisément, il s'agissait d'un PLA (''Programmable Logic Array''), une sorte d'intermédiaire entre circuit logique fait sur mesure et mémoire ROM, qu'on a déjà abordé dans le chapitre sur les mémoires ROM. Mais cette unité ne faisait pas tout, le microcode était aussi impliqué. La PTU sera détaillée dans la section suivante. Pour améliorer les performances, le 386 et le 486 intégraient un '''cache de descripteurs de segment''', aussi appelé le cache de descripteurs. Lorsqu'un descripteur état chargé pour la première fois, il était copié dans le cache de descripteurs de segment. Les accès mémoire ultérieur lisaient le descripteur de segment depuis ce cache, pas depuis la table des segments en RAM. Le cache de descripteurs gère aussi bien les segments en mode réel qu'en mode protégé. Récupérer l'adresse de base depuis cache se fait un peu différemment en mode réel et protégé, mais le cache gère cela tout seul. Idem pour récupérer la taille/adresse limite. [[File:Microarchitecture du 386, avec focus sur la segmentation.png|centre|vignette|upright=2|Microarchitecture du 386 et du 486, avec focus sur la segmentation.]] En mode réel, la taille des segment est censée être limitée à 64 kibioctets. Mais le processeur ne vérifiait pas si cette limite était dépassée. À la place, il utilisait la limite précisée dans le cache de segments. Pire que ça : le cache de segment n'était pas réinitialisé quand on passe du mode réel au mode protégé, et réciproquement. Et cette propriété a été à l'origine de l''''''unreal mode'''''. Il s'agissait d'un mode réel amélioré, capable d'utiliser des segments de 4 gibioctets et des adresses de 32 bits. Passer en mode ''unreal'' pouvait se faire de deux manières. Il était possible d'altérer le cache de descripteur en utilisant l'instruction non-documentée LOADALL. Elle permettait de charger les descripteurs de segments dans le cache de descripteurs, avec une taille arbitraire. Une autre solution, beaucoup plus complexe sur le 386, demandait d'entrer en mode protégé pour configurer des segments de grande taille, de charger leurs descripteurs dans le cache de descripteur, puis de revenir en mode réel. En mode réel, les descripteurs dans le cache étaient encore disponibles et on pouvait les lire dans le cache. ====L'implémentation de la protection mémoire sur le 386==== La protection mémoire teste la valeur des bits P, S, X, E, R/W. Elle teste aussi les niveaux de privilège, avec deux bits DPL et CPL. En tout, le processeur pouvait tester 148 conditions différentes en parallèle dans la PTU. Cependant, les niveaux de privilèges étaient pré-traités par le microcode. Le microcode vérifiait aussi s'il y avait une erreur en terme d’anneau mémoire, avec par "exemple un segment en mode noyau accédé alors que le CPU est en espace utilisateur. Il fournissait alors un résultat sur deux bits, qui indiquait s'il y avait une erreur ou non, que la PTU utilisait. Mais toutes les conditions n'étaient pas pertinentes à un instant t. Par exemple, il est pertinent de vérifier si le bit R/W était cohérent si l'instruction à exécuter est une écriture. Mais il n'y a pas besoin de tester le bit E qui indique qu'un segment est exécutable ou non, pour une lecture. En tout, le processeur pouvait se retrouver dans 33 situations possibles, chacune demandant de tester un sous-ensemble des 148 conditions. Pour préciser quel sous-ensembles tester, la PTU recevait un code opération, généré par le microcode. Pour faire les tests de protection mémoire, le microcode avait une micro-opération nommée ''protection test operation'', qui envoyait les droits d'accès à la PTU. Lors de l'exécution d'une ''protection test operation'', le PLA recevait un descripteur de segment, lu depuis la mémoire RAM, ainsi qu'un code opération provenant du microcode. {|class="wikitable" |+ Entrée de la ''Protection Test Unit'' |- ! 15 - 14 !! 13 - 12 !! 11 !! 10 !! 9 !! 8 !! 7 !! 6 !! 5-0 |- | P1 , P2 || || P || S || X || E || R/W || A || Code opération |- | Niveaux de privilèges cohérents/erreur || || Segment présent en mémoire ou swappé || S || X || Segment exécutable ou non || Segment accesible en lecture/écriture || Segment récemment accédé || Code opération |} Il fournissait en sortie un bit qui indiquait si une erreur de protection mémoire avait eu lieu ou non. Il fournissait aussi une adresse de 12 bits, utilisée seulement en cas d'erruer. Elle pointait dans le microcode, sur un code levant une exception en cas d'erreur. Enfin, la PTU fournissait 4 bits pouvant être testés par un branchement dans le microcode. L'un d'entre eux demandait de tester s'il y a un accès hors-limite, les autres étaient assez peu reliés à la protection mémoire. Un détail est que le chargement du descripteur de segment est réalisé par une fonction dans le microcode. Elle est appliquée pour toutes les instructions ou situations qui demandent de faire un accès mémoire. Et les tests de protection mémoire sont réalisés dans cette fonction, pas après elle. Vu qu'il s'agit d'une fonction exécutée quelque soit l'instruction, le microcode doit transférer le code opération à cette fonction. Le microcode est pour cela associé à un registre interne, dans lequel le code opération est mémorisé, avant d'appeler la fonction. Le microcode a une micro-opération PTSAV (''Protection Save'') pour mémoriser le code opération dans ce registre. Dans la fonction qui charge le descripteur, une micro-opération PTOVRR (''Protection Override'') lit le code opération dans ce registre, et lance les tests nécessaires. Il faut noter que le PLA était certes plus rapide que de tester les conditions une par une, mais il était assez lent. La PTU mettait environ 3 cycles d'horloges pour rendre son résultat. Le microcode en profitait alors pour exécuter des micro-opérations durant ces 3 cycles d'attente. Par exemple, le microcode pouvait en profiter pour lire l'adresse de base dans le descripteur, si elle n'a pas été chargée avant (les descripteur était chargé en deux fois). Il fallait cependant que les trois micro-opérations soient valides, peu importe qu'il y ait une erreur de protection mémoire ou non. Ou du moins, elles produisaient un résultat qui n'est pas utilisé en cas d'erreur. Si ce n'était pas possible, le microcode ajoutait des NOP pendant ce temps d'attente de 3 cycles. Le bit A du descripteur de segment indique que le segment a récemment été accédé. Il est mis à jour après les tests de protection mémoire, quand ceux-ci indiquent que l'accès mémoire est autorisé. Le bit A est mis à 1 si la PTU l'autorise. Pour cela, la PTU utilise un des 4 bits de sortie mentionnés plus haut : l'un d'entre eux indique que le bit A doit être mis à 1. La mise à jour est ensuite réalisée par le microcode, qui utilise trois micro-opérations pour le mettre à jour. ====Le ''Hardware task switching'' des CPU x86==== Les systèmes d’exploitation modernes peuvent lancer plusieurs logiciels en même temps. Les logiciels sont alors exécutés à tour de rôle. Passer d'un programme à un autre est ce qui s'appelle une commutation de contexte. Lors d'une commutation de contexte, l'état du processeur est sauvegardé, afin que le programme stoppé puisse reprendre là où il était. Il arrivera un moment où le programme stoppé redémarrera et il doit reprendre dans l'état exact où il s'est arrêté. Deuxièmement, le programme à qui c'est le tour restaure son état. Cela lui permet de revenir là où il était avant d'être stoppé. Il y a donc une sauvegarde et une restauration des registres. Divers processeurs incorporent des optimisations matérielles pour rendre la commutation de contexte plus rapide. Ils peuvent sauvegarder et restaurer les registres du processeur automatiquement lors d'une interruption de commutation de contexte. Les registres sont sauvegardés dans des structures de données en mémoire RAM, appelées des '''contextes matériels'''. Sur les processeurs x86, il s'agit de la technique d{{'}}''Hardware Task Switching''. Fait intéressant, le ''Hardware Task Switching'' se base beaucoup sur les segments mémoires. Avec ''Hardware Task Switching'', chaque contexte matériel est mémorisé dans son propre segment mémoire, séparé des autres. Les segments pour les contextes matériels sont appelés des '''''Task State Segment''''' (TSS). Un TSS mémorise tous les registres généraux, le registre d'état, les pointeurs de pile, le ''program counter'' et quelques registres de contrôle du processeur. Par contre, les registres flottants ne sont pas sauvegardés, de même que certaines registres dit SIMD que nous n'avons pas encore abordé. Et c'est un défaut qui fait que le ''Hardware Task Switching'' n'est plus utilisé. Le programme en cours d'exécution connait l'adresse du TSS qui lui est attribué, car elle est mémorisée dans un registre appelé le '''''Task Register'''''. En plus de pointer sur le TSS, ce registre contient aussi les adresses de base et limite du segment en cours. Pour être plus précis, le ''Task Register'' ne mémorise pas vraiment l'adresse du TSS. À la place, elle mémorise le numéro du segment, le numéro du TSS. Le numéro est codé sur 16 bits, ce qui explique que 65 536 segments sont adressables. Les instructions LDR et STR permettent de lire/écrire ce numéro de segment dans le ''Task Register''. Le démarrage d'un programme a lieu automatiquement dans plusieurs circonstances. La première est une instruction de branchement CALL ou JMP adéquate. Le branchement fournit non pas une adresse à laquelle brancher, mais un numéro de segment qui pointe vers un TSS. Cela permet à une routine du système d'exploitation de restaurer les registres et de démarrer le programme en une seule instruction de branchement. Une seconde circonstance est une interruption matérielle ou une exception, mais nous la mettons de côté. Le ''Task Register'' est alors initialisé avec le numéro de segment fournit. S'en suit la procédure suivante : * Le ''Task Register'' est utilisé pour adresser la table des segments, pour récupérer un pointeur vers le TSS associé. * Le pointeur est utilisé pour une seconde lecture, qui adresse le TSS directement. Celle-ci restaure les registres du processeur. En clair, on va lire le ''TSS descriptor'' dans la GDT, puis on l'utilise pour restaurer les registres du processeur. [[File:Hardware Task Switching x86.png|centre|vignette|upright=2|Hardware Task Switching x86]] ===La segmentation sur les processeurs Burrough B5000 et plus=== Le Burrough B5000 est un très vieil ordinateur, commercialisé à partir de l'année 1961. Ses successeurs reprennent globalement la même architecture. C'était une machine à pile, doublé d'une architecture taguée, choses très rare de nos jours. Mais ce qui va nous intéresser dans ce chapitre est que ce processeur incorporait la segmentation, avec cependant une différence de taille : un programme avait accès à un grand nombre de segments. La limite était de 1024 segments par programme ! Il va de soi que des segments plus petits favorise l'implémentation de la mémoire virtuelle, mais complexifie la relocation et le reste, comme nous allons le voir. Le processeur gère deux types de segments : les segments de données et de procédure/fonction. Les premiers mémorisent un bloc de données, dont le contenu est laissé à l'appréciation du programmeur. Les seconds sont des segments qui contiennent chacun une procédure, une fonction. L'usage des segments est donc différent de ce qu'on a sur les processeurs x86, qui n'avaient qu'un segment unique pour l'intégralité du code machine. Un seul segment de code machine x86 est découpé en un grand nombre de segments de code sur les processeurs Burrough. La table des segments contenait 1024 entrées de 48 bits chacune. Fait intéressant, chaque entrée de la table des segments pouvait mémoriser non seulement un descripteur de segment, mais aussi une valeur flottante ou d'autres types de données ! Parler de table des segments est donc quelque peu trompeur, car cette table ne gère pas que des segments, mais aussi des données. La documentation appelaiat cette table la '''''Program Reference Table''''', ou PRT. La raison de ce choix quelque peu bizarre est que les instructions ne gèrent pas d'adresses proprement dit. Tous les accès mémoire à des données en-dehors de la pile passent par la segmentation, ils précisent tous un indice de segment et un ''offset''. Pour éviter d'allouer un segment pour chaque donnée, les concepteurs du processeur ont décidé qu'une entrée pouvait contenir directement la donnée entière à lire/écrire. La PRT supporte trois types de segments/descripteurs : les descripteurs de données, les descripteurs de programme et les descripteurs d'entrées-sorties. Les premiers décrivent des segments de données. Les seconds sont associés aux segments de procédure/fonction et sont utilisés pour les appels de fonction (qui passent, eux aussi, par la segmentation). Le dernier type de descripteurs sert pour les appels systèmes et les communications avec l'OS ou les périphériques. Chaque entrée de la PRT contient un ''tag'', une suite de bit qui indique le type de l'entrée : est-ce qu'elle contient un descripteur de segment, une donnée, autre. Les descripteurs contiennent aussi un ''bit de présence'' qui indique si le segment a été swappé ou non. Car oui, les segments pouvaient être swappés sur ce processeur, ce qui n'est pas étonnant vu que les segments sont plus petits sur cette architecture. Le descripteur contient aussi l'adresse de base du segment ainsi que sa taille, et diverses informations pour le retrouver sur le disque dur s'il est swappé. : L'adresse mémorisée ne faisait que 15 bits, ce qui permettait d'adresse 32 kibi-mots, soit 192 kibioctets de mémoire. Diverses techniques d'extension d'adressage étaient disponibles pour contourner cette limitation. Outre l'usage de l{{'}}''overlay'', le processeur et l'OS géraient aussi des identifiants d'espace d'adressage et en fournissaient plusieurs par processus. Les processeurs Borrough suivants utilisaient des adresses plus grandes, de 20 bits, ce qui tempérait le problème. [[File:B6700Word.jpg|centre|vignette|upright=2|Structure d'un mot mémoire sur le B6700.]] ==Les architectures à capacités== Les architectures à capacité utilisent la segmentation à granularité fine, mais ajoutent des mécanismes de protection mémoire assez particuliers, qui font que les architectures à capacité se démarquent du reste. Les architectures de ce type sont très rares et sont des processeurs assez anciens. Le premier d'entre eux était le Plessey System 250, qui date de 1969. Il fu suivi par le CAP computer, vendu entre les années 70 et 77. En 1978, le System/38 d'IBM a eu un petit succès commercial. En 1980, la Flex machine a aussi été vendue, mais à très peu d'examplaires, comme les autres architectures à capacité. Et enfin, en 1981, l'architecture à capacité la plus connue, l'Intel iAPX 432 a été commercialisée. Depuis, la seule architecture de ce type est en cours de développement. Il s'agit de l'architecture CHERI, dont la mise en projet date de 2014. ===Le partage de la mémoire sur les architectures à capacités=== Le partage de segment est grandement modifié sur les architectures à capacité. Avec la segmentation normale, il y a une table de segment par processus. Les conséquences sont assez nombreuses, mais la principale est que partager un segment entre plusieurs processus est compliqué. Les défauts ont été évoqués plus haut. Les sélecteurs de segments ne sont pas les mêmes d'un processus à l'autre, pour un même segment. De plus, les adresses limite et de base sont dupliquées dans plusieurs tables de segments, et cela peut causer des problèmes de sécurité si une table des segments est modifiée et pas l'autre. Et il y a d'autres problèmes, tout aussi importants. [[File:Partage des segments avec la segmentation.png|centre|vignette|upright=1.5|Partage des segments avec la segmentation]] À l'opposé, les architectures à capacité utilisent une table des segments unique pour tous les processus. La table des segments unique sera appelée dans de ce qui suit la '''table des segments globale''', ou encore la table globale. En conséquence, les adresses de base et limite ne sont présentes qu'en un seul exemplaire par segment, au lieu d'être dupliquées dans autant de processus que nécessaire. De plus, cela garantit que l'indice de segment est le même quel que soit le processus qui l'utilise. Un défaut de cette approche est au niveau des droits d'accès. Avec la segmentation normale, les droits d'accès pour un segment sont censés changer d'un processus à l'autre. Par exemple, tel processus a accès en lecture seule au segment, l'autre seulement en écriture, etc. Mais ici, avec une table des segments uniques, cela ne marche plus : incorporer les droits d'accès dans la table des segments ferait que tous les processus auraient les mêmes droits d'accès au segment. Et il faut trouver une solution. ===Les capacités sont des pointeurs protégés=== Pour éviter cela, les droits d'accès sont combinés avec les sélecteurs de segments. Les sélecteurs des segments sont remplacés par des '''capacités''', des pointeurs particuliers formés en concaténant l'indice de segment avec les droits d'accès à ce segment. Si un programme veut accéder à une adresse, il fournit une capacité de la forme "sélecteur:droits d'accès", et un décalage qui indique la position de l'adresse dans le segment. Il est impossible d'accéder à un segment sans avoir la capacité associée, c'est là une sécurité importante. Un accès mémoire demande que l'on ait la capacité pour sélectionner le bon segment, mais aussi que les droits d'accès en permettent l'accès demandé. Par contre, les capacités peuvent être passées d'un programme à un autre sans problème, les deux programmes pourront accéder à un segment tant qu'ils disposent de la capacité associée. [[File:Comparaison entre capacités et adresses segmentées.png|centre|vignette|upright=2.5|Comparaison entre capacités et adresses segmentées]] Mais cette solution a deux problèmes très liés. Au niveau des sélecteurs de segment, le problème est que les sélecteur ont une portée globale. Avant, l'indice de segment était interne à un programme, un sélecteur ne permettait pas d'accéder au segment d'un autre programme. Sur les architectures à capacité, les sélecteurs ont une portée globale. Si un programme arrive à forger un sélecteur qui pointe vers un segment d'un autre programme, il peut théoriquement y accéder, à condition que les droits d'accès le permettent. Et c'est là qu'intervient le second problème : les droits d'accès ne sont plus protégés par l'espace noyau. Les droits d'accès étaient dans la table de segment, accessible uniquement en espace noyau, ce qui empêchait un processus de les modifier. Avec une capacité, il faut ajouter des mécanismes de protection qui empêchent un programme de modifier les droits d'accès à un segment et de générer un indice de segment non-prévu. La première sécurité est qu'un programme ne peut pas créer une capacité, seul le système d'exploitation le peut. Les capacités sont forgées lors de l'allocation mémoire, ce qui est du ressort de l'OS. Pour rappel, un programme qui veut du rab de mémoire RAM peut demander au système d'exploitation de lui allouer de la mémoire supplémentaire. Le système d'exploitation renvoie alors un pointeurs qui pointe vers un nouveau segment. Le pointeur est une capacité. Il doit être impossible de forger une capacité, en-dehors d'une demande d'allocation mémoire effectuée par l'OS. Typiquement, la forge d'une capacité se fait avec des instructions du processeur, que seul l'OS peut éxecuter (pensez à une instruction qui n'est accessible qu'en espace noyau). La seconde protection est que les capacités ne peuvent pas être modifiées sans raison valable, que ce soit pour l'indice de segment ou les droits d'accès. L'indice de segment ne peut pas être modifié, quelqu'en soit la raison. Pour les droits d'accès, la situation est plus compliquée. Il est possible de modifier ses droits d'accès, mais sous conditions. Réduire les droits d'accès d'une capacité est possible, que ce soit en espace noyau ou utilisateur, pas l'OS ou un programme utilisateur, avec une instruction dédiée. Mais augmenter les droits d'accès, seul l'OS peut le faire avec une instruction précise, souvent exécutable seulement en espace noyau. Les capacités peuvent être copiées, et même transférées d'un processus à un autre. Les capacités peuvent être détruites, ce qui permet de libérer la mémoire utilisée par un segment. La copie d'une capacité est contrôlée par l'OS et ne peut se faire que sous conditions. La destruction d'une capacité est par contre possible par tous les processus. La destruction ne signifie pas que le segment est effacé, il est possible que d'autres processus utilisent encore des copies de la capacité, et donc le segment associé. On verra quand la mémoire est libérée plus bas. Protéger les capacités demande plusieurs conditions. Premièrement, le processeur doit faire la distinction entre une capacité et une donnée. Deuxièmement, les capacités ne peuvent être modifiées que par des instructions spécifiques, dont l'exécution est protégée, réservée au noyau. En clair, il doit y avoir une séparation matérielle des capacités, qui sont placées dans des registres séparés. Pour cela, deux solutions sont possibles : soit les capacités remplacent les adresses et sont dispersées en mémoire, soit elles sont regroupées dans un segment protégé. ====La liste des capacités==== Avec la première solution, on regroupe les capacités dans un segment protégé. Chaque programme a accès à un certain nombre de segments et à autant de capacités. Les capacités d'un programme sont souvent regroupées dans une '''liste de capacités''', appelée la '''''C-list'''''. Elle est généralement placée en mémoire RAM. Elle est ce qu'il reste de la table des segments du processus, sauf que cette table ne contient pas les adresses du segment, qui sont dans la table globale. Tout se passe comme si la table des segments de chaque processus est donc scindée en deux : la table globale partagée entre tous les processus contient les informations sur les limites des segments, la ''C-list'' mémorise les droits d'accès et les sélecteurs pour identifier chaque segment. C'est un niveau d'indirection supplémentaire par rapport à la segmentation usuelle. [[File:Architectures à capacité.png|centre|vignette|upright=2|Architectures à capacité]] La liste de capacité est lisible par le programme, qui peut copier librement les capacités dans les registres. Par contre, la liste des capacités est protégée en écriture. Pour le programme, il est impossible de modifier les capacités dedans, impossible d'en rajouter, d'en forger, d'en retirer. De même, il ne peut pas accéder aux segments des autres programmes : il n'a pas les capacités pour adresser ces segments. Pour protéger la ''C-list'' en écriture, la solution la plus utilisée consiste à placer la ''C-list'' dans un segment dédié. Le processeur gère donc plusieurs types de segments : les segments de capacité pour les ''C-list'', les autres types segments pour le reste. Un défaut de cette approche est que les adresses/capacités sont séparées des données. Or, les programmeurs mixent souvent adresses et données, notamment quand ils doivent manipuler des structures de données comme des listes chainées, des arbres, des graphes, etc. L'usage d'une ''C-list'' permet de se passer de la séparation entre espace noyau et utilisateur ! Les segments de capacité sont eux-mêmes adressés par leur propre capacité, avec une capacité par segment de capacité. Le programme a accès à la liste de capacité, comme l'OS, mais leurs droits d'accès ne sont pas les mêmes. Le programme a une capacité vers la ''C-list'' qui n'autorise pas l'écriture, l'OS a une autre capacité qui accepte l'écriture. Les programmes ne pourront pas forger les capacités permettant de modifier les segments de capacité. Une méthode alternative est de ne permettre l'accès aux segments de capacité qu'en espace noyau, mais elle est redondante avec la méthode précédente et moins puissante. ====Les capacités dispersées, les architectures taguées==== Une solution alternative laisse les capacités dispersées en mémoire. Les capacités remplacent les adresses/pointeurs, et elles se trouvent aux mêmes endroits : sur la pile, dans le tas. Comme c'est le cas dans les programmes modernes, chaque allocation mémoire renvoie une capacité, que le programme gére comme il veut. Il peut les mettre dans des structures de données, les placer sur la pile, dans des variables en mémoire, etc. Mais il faut alors distinguer si un mot mémoire contient une capacité ou une autre donnée, les deux ne devant pas être mixés. Pour cela, chaque mot mémoire se voit attribuer un certain bit qui indique s'il s'agit d'un pointeur/capacité ou d'autre chose. Mais cela demande un support matériel, ce qui fait que le processeur devient ce qu'on appelle une ''architecture à tags'', ou ''tagged architectures''. Ici, elles indiquent si le mot mémoire contient une adresse:capacité ou une donnée. [[File:Architectures à capacité sans liste de capacité.png|centre|vignette|upright=2|Architectures à capacité sans liste de capacité]] L'inconvénient est le cout en matériel de cette solution. Il faut ajouter un bit à chaque case mémoire, le processeur doit vérifier les tags avant chaque opération d'accès mémoire, etc. De plus, tous les mots mémoire ont la même taille, ce qui force les capacités à avoir la même taille qu'un entier. Ce qui est compliqué. ===Les registres de capacité=== Les architectures à capacité disposent de registres spécialisés pour les capacités, séparés pour les entiers. La raison principale est une question de sécurité, mais aussi une solution pragmatique au fait que capacités et entiers n'ont pas la même taille. Les registres dédiés aux capacités ne mémorisent pas toujours des capacités proprement dites. À la place, ils mémorisent des descripteurs de segment, qui contiennent l'adresse de base, limite et les droits d'accès. Ils sont utilisés pour la relocation des accès mémoire ultérieurs. Ils sont en réalité identiques aux registres de relocation, voire aux registres de segments. Leur utilité est d'accélérer la relocation, entre autres. Les processeurs à capacité ne gèrent pas d'adresses proprement dit, comme pour la segmentation avec plusieurs registres de relocation. Les accès mémoire doivent préciser deux choses : à quel segment on veut accéder, à quelle position dans le segment se trouve la donnée accédée. La première information se trouve dans le mal nommé "registre de capacité", la seconde information est fournie par l'instruction d'accès mémoire soit dans un registre (Base+Index), soit en adressage base+''offset''. Les registres de capacités sont accessibles à travers des instructions spécialisées. Le processeur ajoute des instructions LOAD/STORE pour les échanges entre table des segments et registres de capacité. Ces instructions sont disponibles en espace utilisateur, pas seulement en espace noyau. Lors du chargement d'une capacité dans ces registres, le processeur vérifie que la capacité chargée est valide, et que les droits d'accès sont corrects. Puis, il accède à la table des segments, récupère les adresses de base et limite, et les mémorise dans le registre de capacité. Les droits d'accès et d'autres méta-données sont aussi mémorisées dans le registre de capacité. En somme, l'instruction de chargement prend une capacité et charge un descripteur de segment dans le registre. Avec ce genre de mécanismes, il devient difficile d’exécuter certains types d'attaques, ce qui est un gage de sureté de fonctionnement indéniable. Du moins, c'est la théorie, car tout repose sur l'intégrité des listes de capacité. Si on peut modifier celles-ci, alors il devient facile de pouvoir accéder à des objets auxquels on n’aurait pas eu droit. ===Le recyclage de mémoire matériel=== Les architectures à capacité séparent les adresses/capacités des nombres entiers. Et cela facilite grandement l'implémentation de la ''garbage collection'', ou '''recyclage de la mémoire''', à savoir un ensemble de techniques logicielles qui visent à libérer la mémoire inutilisée. Rappelons que les programmes peuvent demander à l'OS un rab de mémoire pour y placer quelque chose, généralement une structure de donnée ou un objet. Mais il arrive un moment où cet objet n'est plus utilisé par le programme. Il peut alors demander à l'OS de libérer la portion de mémoire réservée. Sur les architectures à capacité, cela revient à libérer un segment, devenu inutile. La mémoire utilisée par ce segment est alors considérée comme libre, et peut être utilisée pour autre chose. Mais il arrive que les programmes ne libèrent pas le segment en question. Soit parce que le programmeur a mal codé son programme, soit parce que le compilateur n'a pas fait du bon travail ou pour d'autres raisons. Pour éviter cela, les langages de programmation actuels incorporent des '''''garbage collectors''''', des morceaux de code qui scannent la mémoire et détectent les segments inutiles. Pour cela, ils doivent identifier les adresses manipulées par le programme. Si une adresse pointe vers un objet, alors celui-ci est accessible, il sera potentiellement utilisé dans le futur. Mais si aucune adresse ne pointe vers l'objet, alors il est inaccessible et ne sera plus jamais utilisé dans le futur. On peut libérer les objets inaccessibles. Identifier les adresses est cependant très compliqué sur les architectures normales. Sur les processeurs modernes, les ''garbage collectors'' scannent la pile à la recherche des adresses, et considèrent tout mot mémoire comme une adresse potentielle. Mais les architectures à capacité rendent le recyclage de la mémoire très facile. Un segment est accessible si le programme dispose d'une capacité qui pointe vers ce segment, rien de plus. Et les capacités sont facilement identifiables : soit elles sont dans la liste des capacités, soit on peut les identifier à partir de leur ''tag''. Le recyclage de mémoire était parfois implémenté directement en matériel. En soi, son implémentation est assez simple, et peu être réalisé dans le microcode d'un processeur. Une autre solution consiste à utiliser un second processeur, spécialement dédié au recyclage de mémoire, qui exécute un programme spécialement codé pour. Le programme en question est placé dans une mémoire ROM, reliée directement à ce second processeur. ===L'intel iAPX 432=== Voyons maintenat une architecture à capacité assez connue : l'Intel iAPX 432. Oui, vous avez bien lu : Intel a bel et bien réalisé un processeur orienté objet dans sa jeunesse. La conception du processeur Intel iAPX 432 commença en 1975, afin de créer un successeur digne de ce nom aux processeurs 8008 et 8080. La conception du processeur Intel iAPX 432 commença en 1975, afin de créer un successeur digne de ce nom aux processeurs 8008 et 8080. Ce processeur s'est très faiblement vendu en raison de ses performances assez désastreuses et de défauts techniques certains. Par exemple, ce processeur était une machine à pile à une époque où celles-ci étaient tombées en désuétude, il ne pouvait pas effectuer directement de calculs avec des constantes entières autres que 0 et 1, ses instructions avaient un alignement bizarre (elles étaient bit-alignées). Il avait été conçu pour maximiser la compatibilité avec le langage ADA, un langage assez peu utilisé, sans compter que le compilateur pour ce processeur était mauvais. ====Les segments prédéfinis de l'Intel iAPX 432==== L'Intel iAPX432 gère plusieurs types de segments. Rien d'étonnant à cela, les Burrough géraient eux aussi plusieurs types de segments, à savoir des segments de programmes, des segments de données, et des segments d'I/O. C'est la même chose sur l'Intel iAPX 432, mais en bien pire ! Les segments de données sont des segments génériques, dans lequels on peut mettre ce qu'on veut, suivant les besoins du programmeur. Ils sont tous découpés en deux parties de tailles égales : une partie contenant les données de l'objet et une partie pour les capacités. Les capacités d'un segment pointent vers d'autres segments, ce qui permet de créer des structures de données assez complexes. La ligne de démarcation peut être placée n'importe où dans le segment, les deux portions ne sont pas de taille identique, elles ont des tailles qui varient de segment en segment. Il est même possible de réserver le segment entier à des données sans y mettre de capacités, ou inversement. Les capacités et données sont adressées à partir de la ligne de démarcation, qui sert d'adresse de base du segment. Suivant l'instruction utilisée, le processeur accède à la bonne portion du segment. Le processeur supporte aussi d'autres segments pré-définis, qui sont surtout utilisés par le système d'exploitation : * Des segments d'instructions, qui contiennent du code exécutable, typiquement un programme ou des fonctions, parfois des ''threads''. * Des segments de processus, qui mémorisent des processus entiers. Ces segments contiennent des capacités qui pointent vers d'autres segments, notamment un ou plusieurs segments de code, et des segments de données. * Des segments de domaine, pour les modules ou bibliothèques dynamiques. * Des segments de contexte, utilisés pour mémoriser l'état d'un processus, utilisés par l'OS pour faire de la commutation de contexte. * Des segments de message, utilisés pour la communication entre processus par l'intermédiaire de messages. * Et bien d'autres encores. Sur l'Intel iAPX 432, chaque processus est considéré comme un objet à part entière, qui a son propre segment de processus. De même, l'état du processeur (le programme qu'il est en train d’exécuter, son état, etc.) est stocké en mémoire dans un segment de contexte. Il en est de même pour chaque fonction présente en mémoire : elle était encapsulée dans un segment, sur lequel seules quelques manipulations étaient possibles (l’exécuter, notamment). Et ne parlons pas des appels de fonctions qui stockaient l'état de l'appelé directement dans un objet spécial. Bref, de nombreux objets système sont prédéfinis par le processeur : les objets stockant des fonctions, les objets stockant des processus, etc. L'Intel 432 possédait dans ses circuits un ''garbage collector'' matériel. Pour faciliter son fonctionnement, certains bits de l'objet permettaient de savoir si l'objet en question pouvait être supprimé ou non. ====Le support de la segmentation sur l'Intel iAPX 432==== La table des segments est une table hiérarchique, à deux niveaux. Le premier niveau est une ''Object Table Directory'', qui réside toujours en mémoire RAM. Elle contient des descripteurs qui pointent vers des tables secondaires, appelées des ''Object Table''. Il y a plusieurs ''Object Table'', typiquement une par processus. Plusieurs processus peuvent partager la même ''Object Table''. Les ''Object Table'' peuvent être swappées, mais pas l{{'}}''Object Table Directory''. Une capacité tient compte de l'organisation hiérarchique de la table des segments. Elle contient un indice qui précise quelle ''Object Table'' utiliser, et l'indice du segment dans cette ''Object Table''. Le premier indice adresse l{{'}}''Object Table Directory'' et récupère un descripteur de segment qui pointe sur la bonne ''Object Table''. Le second indice est alors utilisé pour lire l'adresse de base adéquate dans cette ''Object Table''. La capacité contient aussi des droits d'accès en lecture, écriture, suppression et copie. Il y a aussi un champ pour le type, qu'on verra plus bas. Au fait : les capacités étaient appelées des ''Access Descriptors'' dans la documentation officielle. Une capacité fait 32 bits, avec un octet utilisé pour les droits d'accès, laissant 24 bits pour adresser les segments. Le processeur gérait jusqu'à 2^24 segments/objets différents, pouvant mesurer jusqu'à 64 kibioctets chacun, ce qui fait 2^40 adresses différentes, soit 1024 gibioctets. Les 24 bits pour adresser les segments sont partagés moitié-moitié pour l'adressage des tables, ce qui fait 4096 ''Object Table'' différentes dans l{{'}}''Object Table Directory'', et chaque ''Object Table'' contient 4096 segments. ====Le jeu d'instruction de l'Intel iAPX 432==== L'Intel iAPX 432 est une machine à pile. Le jeu d'instruction de l'Intel iAPX 432 gère pas moins de 230 instructions différentes. Il gére deux types d'instructions : les instructions normales, et celles qui manipulent des segments/objets. Les premières permettent de manipuler des nombres entiers, des caractères, des chaînes de caractères, des tableaux, etc. Les secondes sont spécialement dédiées à la manipulation des capacités. Il y a une instruction pour copier une capacité, une autre pour invalider une capacité, une autre pour augmenter ses droits d'accès (instruction sécurisée, exécutable seulement sous certaines conditions), une autre pour restreindre ses droits d'accès. deux autres instructions créent un segment et renvoient la capacité associée, la première créant un segment typé, l'autre non. le processeur gérait aussi des instructions spécialement dédiées à la programmation système et idéales pour programmer des systèmes d'exploitation. De nombreuses instructions permettaient ainsi de commuter des processus, faire des transferts de messages entre processus, etc. Environ 40 % du micro-code était ainsi spécialement dédié à ces instructions spéciales. Les instructions sont de longueur variable et peuvent prendre n'importe quelle taille comprise entre 10 et 300 bits, sans vraiment de restriction de taille. Les bits d'une instruction sont regroupés en 4 grands blocs, 4 champs, qui ont chacun une signification particulière. * Le premier est l'opcode de l'instruction. * Le champ référence, doit être interprété différemment suivant la donnée à manipuler. Si cette donnée est un entier, un caractère ou un flottant, ce champ indique l'emplacement de la donnée en mémoire. Alors que si l'instruction manipule un objet, ce champ spécifie la capacité de l'objet en question. Ce champ est assez complexe et il est sacrément bien organisé. * Le champ format, n'utilise que 4 bits et a pour but de préciser si les données à manipuler sont en mémoire ou sur la pile. * Le champ classe permet de dire combien de données différentes l'instruction va devoir manipuler, et quelles seront leurs tailles. [[File:Encodage des instructions de l'Intel iAPX-432.png|centre|vignette|upright=2|Encodage des instructions de l'Intel iAPX-432.]] ====Le support de l'orienté objet sur l'Intel iAPX 432==== L'Intel 432 permet de définir des objets, qui correspondent aux classes des langages orientés objets. L'Intel 432 permet, à partir de fonctions définies par le programmeur, de créer des '''''domain objects''''', qui correspondent à une classe. Un ''domain object'' est un segment de capacité, dont les capacités pointent vers des fonctions ou un/plusieurs objets. Les fonctions et les objets sont chacun placés dans un segment. Une partie des fonctions/objets sont publics, ce qui signifie qu'ils sont accessibles en lecture par l'extérieur. Les autres sont privées, inaccessibles aussi bien en lecture qu'en écriture. L'exécution d'une fonction demande que le branchement fournisse deux choses : une capacité vers le ''domain object'', et la position de la fonction à exécuter dans le segment. La position permet de localiser la capacité de la fonction à exécuter. En clair, on accède au ''domain object'' d'abord, pour récupérer la capacité qui pointe vers la fonction à exécuter. Il est aussi possible pour le programmeur de définir de nouveaux types non supportés par le processeur, en faisant appel au système d'exploitation de l'ordinateur. Au niveau du processeur, chaque objet est typé au niveau de son object descriptor : celui-ci contient des informations qui permettent de déterminer le type de l'objet. Chaque type se voit attribuer un domain object qui contient toutes les fonctions capables de manipuler les objets de ce type et que l'on appelle le type manager. Lorsque l'on veut manipuler un objet d'un certain type, il suffit d'accéder à une capacité spéciale (le TCO) qui pointera dans ce type manager et qui précisera quel est l'objet à manipuler (en sélectionnant la bonne entrée dans la liste de capacité). Le type d'un objet prédéfini par le processeur est ainsi spécifié par une suite de 8 bits, tandis que le type d'un objet défini par le programmeur est défini par la capacité spéciale pointant vers son type manager. ===Conclusion=== Pour ceux qui veulent en savoir plus, je conseille la lecture de ce livre, disponible gratuitement sur internet (merci à l'auteur pour cette mise à disposition) : * [https://homes.cs.washington.edu/~levy/capabook/ Capability-Based Computer Systems]. Voici un document qui décrit le fonctionnement de l'Intel iAPX432 : * [https://homes.cs.washington.edu/~levy/capabook/Chapter9.pdf The Intel iAPX 432 ] ==La pagination== Avec la pagination, la mémoire est découpée en blocs de taille fixe, appelés des '''pages mémoires'''. La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Mais elles sont de taille fixe : on ne peut pas en changer la taille. C'est la différence avec les segments, qui sont de taille variable. Le contenu d'une page en mémoire fictive est rigoureusement le même que le contenu de la page correspondante en mémoire physique. L'espace d'adressage est découpé en '''pages logiques''', alors que la mémoire physique est découpée en '''pages physique''' de même taille. Les pages logiques correspondent soit à une page physique, soit à une page swappée sur le disque dur. Quand une page logique est associée à une page physique, les deux ont le même contenu, mais pas les mêmes adresses. Les pages logiques sont numérotées, en partant de 0, afin de pouvoir les identifier/sélectionner. Même chose pour les pages physiques, qui sont elles aussi numérotées en partant de 0. [[File:Principe de la pagination.png|centre|vignette|upright=2|Principe de la pagination.]] Pour information, le tout premier processeur avec un système de mémoire virtuelle était le super-ordinateur Atlas. Il utilisait la pagination, et non la segmentation. Mais il fallu du temps avant que la méthode de la pagination prenne son essor dans les processeurs commerciaux x86. Un point important est que la pagination implique une coopération entre OS et hardware, les deux étant fortement mélés. Une partie des informations de cette section auraient tout autant leur place dans le wikilivre sur les systèmes d'exploitation, mais il est plus simple d'en parler ici. ===La mémoire virtuelle : le ''swapping'' et le remplacement des pages mémoires=== Le système d'exploitation mémorise des informations sur toutes les pages existantes dans une '''table des pages'''. C'est un tableau où chaque ligne est associée à une page logique. Une ligne contient un bit ''Valid'' qui indique si la page logique associée est swappée sur le disque dur ou non, et la position de la page physique correspondante en mémoire RAM. Elle peut aussi contenir des bits pour la protection mémoire, et bien d'autres. Les lignes sont aussi appelées des ''entrées de la table des pages'' [[File:Gestionnaire de mémoire virtuelle - Pagination et swapping.png|centre|vignette|upright=2|Table des pages.]] De plus, le système d'exploitation conserve une '''liste des pages vides'''. Le nom est assez clair : c'est une liste de toutes les pages de la mémoire physique qui sont inutilisées, qui ne sont allouées à aucun processus. Ces pages sont de la mémoire libre, utilisable à volonté. La liste des pages vides est mise à jour à chaque fois qu'un programme réserve de la mémoire, des pages sont alors prises dans cette liste et sont allouées au programme demandeur. ====Les défauts de page==== Lorsque l'on veut traduire l'adresse logique d'une page mémoire, le processeur vérifie le bit ''Valid'' et l'adresse physique. Si le bit ''Valid'' est à 1 et que l'adresse physique est présente, la traduction d'adresse s'effectue normalement. Mais si ce n'est pas le cas, l'entrée de la table des pages ne contient pas de quoi faire la traduction d'adresse. Soit parce que la page est swappée sur le disque dur et qu'il faut la copier en RAM, soit parce que les droits d'accès ne le permettent pas, soit parce que la page n'a pas encore été allouée, etc. On fait alors face à un '''défaut de page'''. Un défaut de page a lieu quand la MMU ne peut pas associer l'adresse logique à une adresse physique, quelque qu'en soit la raison. Il existe deux types de défauts de page : mineurs et majeurs. Un '''défaut de page majeur''' a lieu quand on veut accéder à une page déplacée sur le disque dur. Un défaut de page majeur lève une exception matérielle dont la routine rapatriera la page en mémoire RAM. S'il y a de la place en mémoire RAM, il suffit d'allouer une page vide et d'y copier la page chargée depuis le disque dur. Mais si ce n'est par le cas, on va devoir faire de la place en RAM en déplaçant une page mémoire de la RAM vers le disque dur. Dans tous les cas, c'est le système d'exploitation qui s'occupe du chargement de la page, le processeur n'est pas impliqué. Une fois la page chargée, la table des pages est mise à jour et la traduction d'adresse peut recommencer. Si je dis recommencer, c'est car l'accès mémoire initial est rejoué à l'identique, sauf que la traduction d'adresse réussit cette fois-ci. Un '''défaut de page mineur''' a lieu dans des circonstances pas très intuitives : la page est en mémoire physique, mais l'adresse physique de la page n'est pas accessible. Par exemple, il est possible que des sécurités empêchent de faire la traduction d'adresse, pour des raisons de protection mémoire. Une autre raison est la gestion des adresses synonymes, qui surviennent quand on utilise des libraires partagées entre programmes, de la communication inter-processus, des optimisations de type ''copy-on-write'', etc. Enfin, une dernière raison est que la page a été allouée à un programme par le système d'exploitation, mais qu'il n'a pas encore attribué sa position en mémoire. Pour comprendre comment c'est possible, parlons rapidement de l'allocation paresseuse. Imaginons qu'un programme fasse une demande d'allocation mémoire et se voit donc attribuer une ou plusieurs pages logiques. L'OS peut alors réagir de deux manières différentes. La première est d'attribuer une page physique immédiatement, en même temps que la page logique. En faisant ainsi, on ne peut pas avoir de défaut mineur, sauf en cas de problème de protection mémoire. Cette solution est simple, on l'appelle l{{'}}'''allocation immédiate'''. Une autre solution consiste à attribuer une page logique, mais l'allocation de la page physique se fait plus tard. Elle a lieu la première fois que le programme tente d'écrire/lire dans la page physique. Un défaut mineur a lieu, et c'est lui qui force l'OS à attribuer une page physique pour la page logique demandée. On parle alors d{{'}}'''allocation paresseuse'''. L'avantage est que l'on gagne en performance si des pages logiques sont allouées mais utilisées, ce qui peut arriver. Une optimisation permise par l'existence des défauts mineurs est le '''''copy-on-write'''''. Le but est d'optimiser la copie d'une page logique dans une autre. L'idée est que la copie est retardée quand elle est vraiment nécessaire, à savoir quand on écrit dans la copie. Tant que l'on ne modifie pas la copie, les deux pages logiques, originelle et copiée, pointent vers la même page physique. A quoi bon avoir deux copies avec le même contenu ? Par contre, la page physique est marquée en lecture seule. La moindre écriture déclenche une erreur de protection mémoire, et un défaut mineur. Celui-ci est géré par l'OS, qui effectue alors la copie dans une nouvelle page physique. Je viens de dire que le système d'exploitation gère les défauts de page majeurs/mineurs. Un défaut de page déclenche une exception matérielle, qui passe la main au système d'exploitation. Le système d'exploitation doit alors déterminer ce qui a levé l'exception, notamment identifier si c'est un défaut de page mineur ou majeur. Pour cela, le processeur a un ou plusieurs '''registres de statut''' qui indique l'état du processeur, qui sont utiles pour gérer les défauts de page. Ils indiquent quelle est l'adresse fautive, si l'accès était une lecture ou écriture, si l'accès a eu lieu en espace noyau ou utilisateur (les espaces mémoire ne sont pas les mêmes), etc. Les registres en question varient grandement d'une architecture de processeur à l'autre, aussi on ne peut pas dire grand chose de plus sur le sujet. Le reste est de toute façon à voir dans un cours sur les systèmes d'exploitation. ====Le remplacement des pages==== Les pages virtuelles font référence soit à une page en mémoire physique, soit à une page sur le disque dur. Mais l'on ne peut pas lire une page directement depuis le disque dur. Les pages sur le disque dur doivent être chargées en RAM, avant d'être utilisables. Ce n'est possible que si on a une page mémoire vide, libre. Si ce n'est pas le cas, on doit faire de la place en swappant une page sur le disque dur. Les pages font ainsi une sorte de va et vient entre le fichier d'échange et la RAM, suivant les besoins. Tout cela est effectué par une routine d'interruption du système d'exploitation, le processeur n'ayant pas vraiment de rôle là-dedans. Supposons que l'on veuille faire de la place en RAM pour une nouvelle page. Dans une implémentation naïve, on trouve une page à évincer de la mémoire, qui est copiée dans le ''swapfile''. Toutes les pages évincées sont alors copiées sur le disque dur, à chaque remplacement. Néanmoins, cette implémentation naïve peut cependant être améliorée si on tient compte d'un point important : si la page a été modifiée depuis le dernier accès. Si le programme/processeur a écrit dans la page, alors celle-ci a été modifiée et doit être sauvegardée sur le ''swapfile'' si elle est évincée. Par contre, si ce n'est pas le cas, la page est soit initialisée, soit déjà présente à l'identique dans le ''swapfile''. Mais cette optimisation demande de savoir si une écriture a eu lieu dans la page. Pour cela, on ajoute un '''''dirty bit''''' à chaque entrée de la table des pages, juste à côté du bit ''Valid''. Il indique si une écriture a eu lieu dans la page depuis qu'elle a été chargée en RAM. Ce bit est mis à jour par le processeur, automatiquement, lors d'une écriture. Par contre, il est remis à zéro par le système d'exploitation, quand la page est chargée en RAM. Si le programme se voit allouer de la mémoire, il reçoit une page vide, et ce bit est initialisé à 0. Il est mis à 1 si la mémoire est utilisée. Quand la page est ensuite swappée sur le disque dur, ce bit est remis à 0 après la sauvegarde. Sur la majorité des systèmes d'exploitation, il est possible d'interdire le déplacement de certaines pages sur le disque dur. Ces pages restent alors en mémoire RAM durant un temps plus ou moins long, parfois en permanence. Cette possibilité simplifie la vie des programmeurs qui conçoivent des systèmes d'exploitation : essayez d'exécuter l'interruption pour les défauts de page alors que la page contenant le code de l'interruption est placée sur le disque dur ! Là encore, cela demande d'ajouter un bit dans chaque entrée de la table des pages, qui indique si la page est swappable ou non. Le bit en question s'appelle souvent le '''bit ''swappable'''''. ====Les algorithmes de remplacement des pages pris en charge par l'OS==== Le choix de la page doit être fait avec le plus grand soin et il existe différents algorithmes qui permettent de décider quelle page supprimer de la RAM. Leur but est de swapper des pages qui ne seront pas accédées dans le futur, pour éviter d'avoir à faire triop de va-et-vient entre RAM et ''swapfile''. Les données qui sont censées être accédées dans le futur doivent rester en RAM et ne pas être swappées, autant que possible. Les algorithmes les plus simples pour le choix de page à évincer sont les suivants. Le plus simple est un algorithme aléatoire : on choisit la page au hasard. Mine de rien, cet algorithme est très simple à implémenter et très rapide à exécuter. Il ne demande pas de modifier la table des pages, ni même d'accéder à celle-ci pour faire son choix. Ses performances sont surprenamment correctes, bien que largement en-dessous de tous les autres algorithmes. L'algorithme FIFO supprime la donnée qui a été chargée dans la mémoire avant toutes les autres. Cet algorithme fonctionne bien quand un programme manipule des tableaux de grande taille, mais fonctionne assez mal dans le cas général. L'algorithme LRU supprime la donnée qui été lue ou écrite pour la dernière fois avant toutes les autres. C'est théoriquement le plus efficace dans la majorité des situations. Malheureusement, son implémentation est assez complexe et les OS doivent modifier la table des pages pour l'implémenter. L'algorithme le plus utilisé de nos jours est l{{'}}'''algorithme NRU''' (''Not Recently Used''), une simplification drastique du LRU. Il fait la différence entre les pages accédées il y a longtemps et celles accédées récemment, d'une manière très binaire. Les deux types de page sont appelés respectivement les '''pages froides''' et les '''pages chaudes'''. L'OS swappe en priorité les pages froides et ne swappe de page chaude que si aucune page froide n'est présente. L'algorithme est simple : il choisit la page à évincer au hasard parmi une page froide. Si aucune page froide n'est présente, alors il swappe au hasard une page chaude. Pour implémenter l'algorithme NRU, l'OS mémorise, dans chaque entrée de la table des pages, si la page associée est froide ou chaude. Pour cela, il met à 0 ou 1 un bit dédié : le '''bit ''Accessed'''''. La différence avec le bit ''dirty'' est que le bit ''dirty'' est mis à jour uniquement lors des écritures, alors que le bit ''Accessed'' l'est aussi lors d'une lecture. Uen lecture met à 1 le bit ''Accessed'', mais ne touche pas au bit ''dirty''. Les écritures mettent les deux bits à 1. Implémenter l'algorithme NRU demande juste de mettre à jour le bit ''Accessed'' de chaque entrée de la table des pages. Et sur les architectures modernes, le processeur s'en charge automatiquement. A chaque accès mémoire, que ce soit en lecture ou en écriture, le processeur met à 1 ce bit. Par contre, le système d'exploitation le met à 0 à intervalles réguliers. En conséquence, quand un remplacement de page doit avoir lieu, les pages chaudes ont de bonnes chances d'avoir le bit ''Accessed'' à 1, alors que les pages froides l'ont à 0. Ce n'est pas certain, et on peut se trouver dans des cas où ce n'est pas le cas. Par exemple, si un remplacement a lieu juste après la remise à zéro des bits ''Accessed''. Le choix de la page à remplacer est donc imparfait, mais fonctionne bien en pratique. Tous les algorithmes précédents ont chacun deux variantes : une locale, et une globale. Avec la version locale, la page qui va être rapatriée sur le disque dur est une page réservée au programme qui est la cause du page miss. Avec la version globale, le système d'exploitation va choisir la page à virer parmi toutes les pages présentes en mémoire vive. ===La protection mémoire avec la pagination=== Avec la pagination, chaque page a des '''droits d'accès''' précis, qui permettent d'autoriser ou interdire les accès en lecture, écriture, exécution, etc. La table des pages mémorise les autorisations pour chaque page, sous la forme d'une suite de bits où chaque bit autorise/interdit une opération bien précise. En pratique, les tables de pages modernes disposent de trois bits : un qui autorise/interdit les accès en lecture, un qui autorise/interdit les accès en écriture, un qui autorise/interdit l'éxecution du contenu de la page. Le format exact de la suite de bits a cependant changé dans le temps sur les processeurs x86 modernes. Par exemple, avant le passage au 64 bits, les CPU et OS ne pouvaient pas marquer une page mémoire comme non-exécutable. C'est seulement avec le passage au 64 bits qu'a été ajouté un bit pour interdire l'exécution de code depuis une page. Ce bit, nommé '''bit NX''', est à 0 si la page n'est pas exécutable et à 1 sinon. Le processeur vérifie à chaque chargement d'instruction si le bit NX de page lue est à 1. Sinon, il lève une exception matérielle et laisse la main à l'OS. Une amélioration de cette protection est la technique dite du '''''Write XOR Execute''''', abréviée WxX. Elle consiste à interdire les pages d'être à la fois accessibles en écriture et exécutables. Il est possible de changer les autorisations en cours de route, ceci dit. Les premiers IBM 360 disposaient d'un mécanisme de protection mémoire totalement différent, sans registres limite/base. Ce mécanisme de protection attribue à chaque programme une '''clé de protection''', qui consiste en un nombre unique de 4 bits (chaque programme a donc une clé différente de ses collègues). La mémoire est fragmentée en blocs de même taille, de 2 kibioctets. Le processeur mémorise, pour chacun de ses blocs, la clé de protection du programme qui a réservé ce bloc. À chaque accès mémoire, le processeur compare la clé de protection du programme en cours d’exécution et celle du bloc de mémoire de destination. Si les deux clés sont différentes, alors un programme a effectué un accès hors des clous et il se fait sauvagement arrêter. ===La traduction d'adresse avec la pagination=== Comme dit plus haut, les pages sont numérotées, de 0 à une valeur maximale, afin de les identifier. Le numéro en question est appelé le '''numéro de page'''. Il est utilisé pour dire au processeur : je veux lire une donnée dans la page numéro 20, la page numéro 90, etc. Une fois qu'on a le numéro de page, on doit alors préciser la position de la donnée dans la page, appelé le '''décalage''', ou encore l{{'}}''offset''. Le numéro de page et le décalage se déduisent à partir de l'adresse, en divisant l'adresse par la taille de la page. Le quotient obtenu donne le numéro de la page, alors que le reste est le décalage. Les processeurs actuels utilisent tous des pages dont la taille est une puissance de deux, ce qui fait que ce calcul est fortement simplifié. Sous cette condition, le numéro de page correspond aux bits de poids fort de l'adresse, alors que le décalage est dans les bits de poids faible. Le numéro de page existe en deux versions : un numéro de page physique qui identifie une page en mémoire physique, et un numéro de page logique qui identifie une page dans la mémoire virtuelle. Traduire l'adresse logique en adresse physique demande de remplacer le numéro de la page logique en un numéro de page physique. [[File:Phycical address.JPG|centre|vignette|upright=2|Traduction d'adresse avec la pagination.]] ====Les tables des pages simples==== Dans le cas le plus simple, il n'y a qu'une seule table des pages, qui est adressée par les numéros de page logique. La table des pages est un vulgaire tableau d'adresses physiques, placées les unes à la suite des autres. Avec cette méthode, la table des pages a autant d'entrée qu'il y a de pages logiques en mémoire virtuelle. Accéder à la mémoire nécessite donc d’accéder d'abord à la table des pages en mémoire, de calculer l'adresse de l'entrée voulue, et d’y accéder. [[File:Table des pages.png|centre|vignette|upright=2|Table des pages.]] La table des pages est souvent stockée dans la mémoire RAM, son adresse est connue du processeur, mémorisée dans un registre spécialisé du processeur. Le processeur effectue automatiquement le calcul d'adresse à partir de l'adresse de base et du numéro de page logique. [[File:Address translation (32-bit).png|centre|vignette|upright=2|Address translation (32-bit)]] ====Les tables des pages inversées==== Sur certains systèmes, notamment sur les architectures 64 bits ou plus, le nombre de pages est très important. Sur les ordinateurs x86 récents, les adresses sont en pratique de 48 bits, les bits de poids fort étant ignorés en pratique, ce qui fait en tout 68 719 476 736 pages. Chaque entrée de la table des pages fait au minimum 48 bits, mais fait plus en pratique : partons sur 64 bits par entrée, soit 8 octets. Cela fait 549 755 813 888 octets pour la table des pages, soit plusieurs centaines de gibioctets ! Une table des pages normale serait tout simplement impraticable. Pour résoudre ce problème, on a inventé les '''tables des pages inversées'''. L'idée derrière celles-ci est l'inverse de la méthode précédente. La méthode précédente stocke, pour chaque page logique, son numéro de page physique. Les tables des pages inversées font l'inverse : elles stockent, pour chaque numéro de page physique, la page logique qui correspond. Avec cette méthode table des pages contient ainsi autant d'entrées qu'il y a de pages physiques. Elle est donc plus petite qu'avant, vu que la mémoire physique est plus petite que la mémoire virtuelle. Quand le processeur veut convertir une adresse virtuelle en adresse physique, la MMU recherche le numéro de page de l'adresse virtuelle dans la table des pages. Le numéro de l'entrée à laquelle se trouve ce morceau d'adresse virtuelle est le morceau de l'adresse physique. Pour faciliter le processus de recherche dans la page, la table des pages inversée est ce que l'on appelle une table de hachage. C'est cette solution qui est utilisée sur les processeurs Power PC. [[File:Table des pages inversée.jpg|centre|vignette|upright=2|Table des pages inversée.]] ====Les tables des pages multiples par espace d'adressage==== Dans les deux cas précédents, il y a une table des pages unique. Cependant, les concepteurs de processeurs et de systèmes d'exploitation ont remarqué que les adresses les plus hautes et/ou les plus basses sont les plus utilisées, alors que les adresses situées au milieu de l'espace d'adressage sont peu utilisées en raison du fonctionnement de la pile et du tas. Il y a donc une partie de la table des pages qui ne sert à rien et est utilisé pour des adresses inutilisées. C'est une source d'économie d'autant plus importante que les tables des pages sont de plus en plus grosses. Pour profiter de cette observation, les concepteurs d'OS ont décidé de découper l'espace d'adressage en plusieurs sous-espaces d'adressage de taille identique : certains localisés dans les adresses basses, d'autres au milieu, d'autres tout en haut, etc. Et vu que l'espace d'adressage est scindé en plusieurs parties, la table des pages l'est aussi, elle est découpée en plusieurs sous-tables. Si un sous-espace d'adressage n'est pas utilisé, il n'y a pas besoin d'utiliser de la mémoire pour stocker la table des pages associée. On ne stocke que les tables des pages pour les espaces d'adressage utilisés, ceux qui contiennent au moins une donnée. L'utilisation de plusieurs tables des pages ne fonctionne que si le système d'exploitation connaît l'adresse de chaque table des pages (celle de la première entrée). Pour cela, le système d'exploitation utilise une super-table des pages, qui stocke les adresses de début des sous-tables de chaque sous-espace. En clair, la table des pages est organisé en deux niveaux, la super-table étant le premier niveau et les sous-tables étant le second niveau. L'adresse est structurée de manière à tirer profit de cette organisation. Les bits de poids fort de l'adresse sélectionnent quelle table de second niveau utiliser, les bits du milieu de l'adresse sélectionne la page dans la table de second niveau et le reste est interprété comme un ''offset''. Un accès à la table des pages se fait comme suit. Les bits de poids fort de l'adresse sont envoyés à la table de premier niveau, et sont utilisés pour récupérer l'adresse de la table de second niveau adéquate. Les bits au milieu de l'adresse sont envoyés à la table de second niveau, pour récupérer le numéro de page physique. Le tout est combiné avec l{{'}}''offset'' pour obtenir l'adresse physique finale. [[File:Table des pages hiérarchique.png|centre|vignette|upright=2|Table des pages hiérarchique.]] On peut aussi aller plus loin et découper la table des pages de manière hiérarchique, chaque sous-espace d'adressage étant lui aussi découpé en sous-espaces d'adressages. On a alors une table de premier niveau, plusieurs tables de second niveau, encore plus de tables de troisième niveau, et ainsi de suite. Cela peut aller jusqu'à 5 niveaux sur les processeurs x86 64 bits modernes. On parle alors de '''tables des pages emboitées'''. Dans ce cours, la table des pages désigne l'ensemble des différents niveaux de cette organisation, toutes les tables inclus. Seules les tables du dernier niveau mémorisent des numéros de page physiques, les autres tables mémorisant des pointeurs, des adresses vers le début des tables de niveau inférieur. Un exemple sera donné plus bas, dans la section suivante. ====L'exemple des processeurs x86==== Pour rendre les explications précédentes plus concrètes, nous allons prendre l'exemple des processeur x86 anciens, de type 32 bits. Les processeurs de ce type utilisaient deux types de tables des pages : une table des page unique et une table des page hiérarchique. Les deux étaient utilisées dans cas séparés. La table des page unique était utilisée pour les pages larges et encore seulement en l'absence de la technologie ''physical adress extension'', dont on parlera plus bas. Les autres cas utilisaient une table des page hiérarchique, à deux niveaux, trois niveaux, voire plus. Une table des pages unique était utilisée pour les pages larges (de 2 mébioctets et plus). Pour les pages de 4 mébioctets, il y avait une unique table des pages, adressée par les 10 bits de poids fort de l'adresse, les bits restants servant comme ''offset''. La table des pages contenait 1024 entrées de 4 octets chacune, ce qui fait en tout 4 kibioctet pour la table des pages. La table des page était alignée en mémoire sur un bloc de 4 kibioctet (sa taille). [[File:X86 Paging 4M.svg|centre|vignette|upright=2|X86 Paging 4M]] Pour les pages de 4 kibioctets, les processeurs x86-32 bits utilisaient une table des page hiérarchique à deux niveaux. Les 10 bits de poids fort l'adresse adressaient la table des page maitre, appelée le directoire des pages (''page directory''), les 10 bits précédents servaient de numéro de page logique, et les 12 bits restants servaient à indiquer la position de l'octet dans la table des pages. Les entrées de chaque table des pages, mineure ou majeure, faisaient 32 bits, soit 4 octets. Vous remarquerez que la table des page majeure a la même taille que la table des page unique obtenue avec des pages larges (de 4 mébioctets). [[File:X86 Paging 4K.svg|centre|vignette|upright=2|X86 Paging 4K]] La technique du '''''physical adress extension''''' (PAE), utilisée depuis le Pentium Pro, permettait aux processeurs x86 32 bits d'adresser plus de 4 gibioctets de mémoire, en utilisant des adresses physiques de 64 bits. Les adresses virtuelles de 32 bits étaient traduites en adresses physiques de 64 bits grâce à une table des pages adaptée. Cette technologie permettait d'adresser plus de 4 gibioctets de mémoire au total, mais avec quelques limitations. Notamment, chaque programme ne pouvait utiliser que 4 gibioctets de mémoire RAM pour lui seul. Mais en lançant plusieurs programmes, on pouvait dépasser les 4 gibioctets au total. Pour cela, les entrées de la table des pages passaient à 64 bits au lieu de 32 auparavant. La table des pages gardait 2 niveaux pour les pages larges en PAE. [[File:X86 Paging PAE 2M.svg|centre|vignette|upright=2|X86 Paging PAE 2M]] Par contre, pour les pages de 4 kibioctets en PAE, elle était modifiée de manière à ajouter un niveau de hiérarchie, passant de deux niveaux à trois. [[File:X86 Paging PAE 4K.svg|centre|vignette|upright=2|X86 Paging PAE 4K]] En 64 bits, la table des pages est une table des page hiérarchique avec 5 niveaux. Seuls les 48 bits de poids faible des adresses sont utilisés, les 16 restants étant ignorés. [[File:X86 Paging 64bit.svg|centre|vignette|upright=2|X86 Paging 64bit]] ====Les circuits liés à la gestion de la table des pages==== En théorie, la table des pages est censée être accédée à chaque accès mémoire. Mais pour éviter d'avoir à lire la table des pages en mémoire RAM à chaque accès mémoire, les concepteurs de processeurs ont décidé d'implanter un cache dédié, le '''''translation lookaside buffer''''', ou TLB. Le TLB stocke au minimum de quoi faire la traduction entre adresse virtuelle et adresse physique, à savoir une correspondance entre numéro de page logique et numéro de page physique. Pour faire plus général, il stocke des entrées de la table des pages. [[File:MMU principle updated.png|centre|vignette|upright=2.0|MMU avec une TLB.]] Les accès à la table des pages sont gérés de deux façons : soit le processeur gère tout seul la situation, soit il délègue cette tâche au système d’exploitation. Sur les processeurs anciens, le système d'exploitation gère le parcours de la table des pages. Mais cette solution logicielle n'a pas de bonnes performances. D'autres processeurs gèrent eux-mêmes le défaut d'accès à la TLB et vont chercher d'eux-mêmes les informations nécessaires dans la table des pages. Ils disposent de circuits, les '''''page table walkers''''' (PTW), qui s'occupent eux-mêmes du défaut. Les ''page table walkers'' contiennent des registres qui leur permettent de faire leur travail. Le plus important est celui qui mémorise la position de la table des pages en mémoire RAM, dont nous avons parlé plus haut. Les PTW ont besoin, pour faire leur travail, de mémoriser l'adresse physique de la table des pages, ou du moins l'adresse de la table des pages de niveau 1 pour des tables des pages hiérarchiques. Mais d'autres registres existent. Toutes les informations nécessaires pour gérer les défauts de TLB sont stockées dans des registres spécialisés appelés des '''tampons de PTW''' (PTW buffers). ===L'abstraction matérielle des processus : une table des pages par processus=== [[File:Memoire virtuelle.svg|vignette|Mémoire virtuelle]] Il est possible d'implémenter l'abstraction matérielle des processus avec la pagination. En clair, chaque programme lancé sur l'ordinateur dispose de son propre espace d'adressage, ce qui fait que la même adresse logique ne pointera pas sur la même adresse physique dans deux programmes différents. Pour cela, il y a plusieurs méthodes. ====L'usage d'une table des pages unique avec un identifiant de processus dans chaque entrée==== La première solution n'utilise qu'une seule table des pages, mais chaque entrée est associée à un processus. Pour cela, chaque entrée contient un '''identifiant de processus''', un numéro qui précise pour quel processus, pour quel espace d'adressage, la correspondance est valide. La page des tables peut aussi contenir des entrées qui sont valides pour tous les processus en même temps. L'intérêt n'est pas évident, mais il le devient quand on se rappelle que le noyau de l'OS est mappé dans le haut de l'espace d'adressage. Et peu importe l'espace d'adressage, le noyau est toujours mappé de manière identique, les mêmes adresses logiques adressant la même adresse mémoire. En conséquence, les correspondances adresse physique-logique sont les mêmes pour le noyau, peu importe l'espace d'adressage. Dans ce cas, la correspondance est mémorisée dans une entrée, mais sans identifiant de processus. À la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau. Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus. ====L'usage de plusieurs tables des pages==== Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour. Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique. [[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]] ===La taille des pages=== La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant. Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''. Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges. Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses. ===Les entrées de la table des pages=== Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits. * Elle contient d'abord le numéro de page physique. * Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS. * Le bit G est le bit ''global''. * Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges. * Le bit D est le bit ''dirty''. * Le bit A est le bit ''accessed''. * Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM. * Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page). * Le bit U/S précise si la page est accessible en mode noyau ou utilisateur. * Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture. * Le bit P est le bit ''valid''. [[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]] ==Comparaison des différentes techniques d'abstraction mémoire== Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous. {|class="wikitable" |- ! ! colspan="5" | Avec abstraction mémoire ! rowspan="2" | Sans abstraction mémoire |- ! ! Relocation matérielle ! Segmentation en mode réel (x86) ! Segmentation, général ! Architectures à capacités ! Pagination |- ! Abstraction matérielle des processus | colspan="4" | Oui, relocation matérielle | Oui, liée à la traduction d'adresse | Impossible |- ! Mémoire virtuelle | colspan="2" | Non, sauf émulation logicielle | colspan="3" | Oui, gérée par le processeur et l'OS | Non, sauf émulation logicielle |- ! Extension de l'espace d'adressage | colspan="2" | Oui : registre de base élargi | colspan="2" | Oui : adresse de base élargie dans la table des segments | ''Physical Adress Extension'' des processeurs 32 bits | Commutation de banques |- ! Protection mémoire | Registre limite | Aucune | colspan="2" | Registre limite, droits d'accès aux segments | Gestion des droits d'accès aux pages | Possible, méthodes variées |- ! Partage de mémoire | colspan="2" | Non | colspan="2" | Segment partagés | Pages partagées | Possible, méthodes variées |} ===Les différents types de segmentation=== La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues. La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente. La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86. Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique. ===Segmentation versus pagination=== Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe. L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum. Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment. Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=L'espace d'adressage du processeur | prevText=L'espace d'adressage du processeur | next=Les méthodes de synchronisation entre processeur et périphériques | nextText=Les méthodes de synchronisation entre processeur et périphériques }} </noinclude> 54sylw296wr9hjqyiaql7ry1ogclkn5 771166 771165 2026-08-22T01:17:03Z Mewtow 31375 /* Comparaison des différentes techniques d'abstraction mémoire */ 771166 wikitext text/x-wiki Pour introduire ce chapitre, nous devons faire un rappel sur le concept d{{'}}'''espace d'adressage'''. Pour rappel, un espace d'adressage correspond à l'ensemble des adresses utilisables par le processeur. Par exemple, si je prends un processeur 16 bits, il peut adresser en tout 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. Intuitivement, on s'attend à ce qu'il y ait correspondance avec les adresses de la mémoire RAM. J'entends par là que l'adresse 1209 de l'espace d'adressage correspond à l'adresse 1209 en mémoire RAM. C'est là une hypothèse parfaitement raisonnable et on voit mal comment ce pourrait ne pas être le cas. Mais les processeurs modernes utilisent des techniques d{{'}}'''abstraction mémoire''' qui font que ce n'est pas le cas. Avec ces techniques, l'adresse 1209 de l'espace d'adressage correspond en réalité à l'adresse 9999 en mémoire RAM, voire n'est pas en RAM. L'abstraction mémoire fait que l'espace d'adressage regroupe des adresses fictives, qui doivent être traduites en adresses mémoires réelles pour être utilisées. Les adresses de l'espace d'adressage portent le nom d{{'}}'''adresses logiques''', alors que les adresses de la mémoire RAM sont appelées '''adresses physiques'''. L'intérêt de l'abstraction matérielle n'est pas évident. Aussi, avant de parler de comment l'abstraction mémoire fonctionne, nous allons voir à quoi elle sert. Nous allons voir qu'elle a plusieurs utilisations différentes, qui sont absolument nécessaires sur tous les ordinateurs personnels modernes. Tous les processeurs modernes la prennent en charge, les systèmes d'exploitation coopérent avec le processeur pour l'utiliser au mieux. ==L'abstraction mémoire permet plusieurs fonctionnalités complémentaires== La fonctionnalité la plus connue est la mémoire virtuelle, dont vous avez peut-être déjà entendu parler, peut-être que le nom vous dit quelque chose. Si ce n'est pas le cas, nous la détailleront dans la suite. Elle permet concrétement à un programme d'utiliser plus de mémoire qu'il n'y a de RAM installé dans un ordinateur, en utilisant le disque dur comme solution de secours. Mais d'autres fonctionnalités moins évidentes sont permises par l'abstraction mémoire. Et la première que nous allons voir est l'abstraction des processus. : En général, une adresse logique correspond à une seule adresse physique. Mais beaucoup de fonctionnalités avancées ne respectent pas cette règle. ===L'abstraction matérielle des processus=== Les systèmes d'exploitation modernes sont dits multi-tâche, à savoir qu'ils sont capables d'exécuter plusieurs logiciels en même temps. Et ce même si un seul processeur est présent dans l'ordinateur : les logiciels sont alors exécutés à tour de rôle. Toutefois, cela amène un paquet de problèmes qu'il faut résoudre au mieux. Par exemple, les programmes exécutés doivent se partager la mémoire RAM, ce qui ne vient pas sans problèmes. Le problème principal est que les programmes ne doivent pas lire ou écrire dans les données d'un autre, sans quoi on se retrouverait rapidement avec des problèmes. Il faut donc introduire des mécanismes d{{'}}'''isolement des processus''', pour isoler les programmes les uns des autres. Un de ces mécanismes est l{{'}}'''abstraction matérielle des processus''', une technique qui fait que chaque programme a son propre espace d'adressage. Chaque programme a l'impression d'avoir accès à tout l'espace d'adressage, de l'adresse 0 à l'adresse maximale gérée par le processeur. Évidemment, il s'agit d'une illusion maintenue justement grâce à la traduction d'adresse. Les espaces d'adressage contiennent des adresses logiques, les adresses de la RAM sont des adresses physiques, la nécessité de l'abstraction mémoire est évidente. Implémenter l'abstraction mémoire peut se faire de plusieurs manières. Mais dans tous les cas, il faut que la correspondance adresse logique - physique change d'un programme à l'autre. Ce qui est normal, vu que les deux processus sont placés à des endroits différents en RAM physique. La conséquence est qu'avec l'abstraction mémoire, une adresse logique correspond à plusieurs adresses physiques. Une même adresse logique dans deux processus différents correspond à deux adresses phsiques différentes, une par processus. Une adresse logique dans un processus correspondra à l'adresse physique X, la même adresse dans un autre processus correspondra à l'adresse Y. Les adresses physiques qui partagent la même adresse logique sont alors appelées des '''adresses homonymes'''. Le choix de la bonne adresse étant réalisé par un mécanisme matériel et dépend du programme en cours. Le mécanisme pour choisir la bonne adresse dépend du processeur, mais il y en a deux grands types : * La première consiste à utiliser l'identifiant de processus CPU, vu au chapitre précédent. C'est, pour rappel, un numéro attribué à chaque processus par le processeur. L'identifiant du processus en cours d'exécution est mémorisé dans un registre du processeur. La traduction d'adresse utilise cet identifiant, en plus de l'adresse logique, pour déterminer l'adresse physique. * La seconde solution mémorise les correspondances adresses logiques-physique dans des tables en mémoire RAM, qui sont différentes pour chaque programme. Les tables sont accédées à chaque accès mémoire, afin de déterminer l'adresse physique. ===Le partage de la mémoire=== L'isolation des processus est très importante sur les systèmes d'exploitation modernes. Cependant, il existe quelques situations où elle doit être contournée ou du moins mise en pause. Les situations sont multiples : gestion de bibliothèques partagées, communication entre processus, usage de ''threads'', etc. Elles impliquent toutes un '''partage de mémoire''', à savoir qu'une portion de mémoire RAM est partagée entre plusieurs programmes. Le partage de mémoire est une sorte de brèche de l'isolation des processus, mais qui est autorisée car elle est utile. Un cas intéressant est celui des '''bibliothèques partagées'''. Les bibliothèques sont des collections de fonctions regroupées ensemble, dans une seule unité de code. Un programme qui utilise une bibliothèque peut appeler n’importe quelle fonction présente dans la bibliothèque. La bibliothèque peut être simplement inclue dans le programme lui-même, on parle alors de bibliothèques statiques. De telles bibliothèques fonctionnent très bien, mais avec un petit défaut pour les bibliothèques très utilisées : plusieurs programmes qui utilisent la même bibliothèque vont chacun l'inclure dans leur code, ce qui fera doublon. Pour éviter cela, les OS modernes gèrent des bibliothèques partagées, à savoir qu'un seul exemplaire de la bibliothèque est partagé entre plusieurs programmes. Chaque programme peut exécuter une fonction de la bibliothèque quand il le souhaite, en effectuant un branchement adéquat. Mais cela implique que la bibliothèque soit présente dans l'espace d'adressage du programme en question. Une bibliothèque est donc présente dans plusieurs espaces d'adressage, alors qu'il n'y en a qu'un seul exemplaire en mémoire RAM. [[File:Ogg vorbis libs and application dia.svg|centre|vignette|upright=2|Exemple de bibliothèques, avec Ogg vorbis.]] D'autres situations demandent de partager de la mémoire entre deux programmes. Par exemple, les systèmes d'exploitation modernes gèrent nativement des systèmes de '''communication inter-processus''', très utilisés par les programmes modernes pour échanger des données. Et la plupart demandant de partager un bout de mémoire entre processus, même si c'est seulement temporairement. Typiquement, deux processus partagent un intervalle d'adresse où l'un écrit les données à l'autre, l'autre lisant les données envoyées. Une dernière utilisation de la mémoire partagée est l{{'}}'''accès direct au noyau'''. Sur les systèmes d'exploitations moderne, dans l'espace d'adressage de chaque programme, les adresses hautes sont remplies avec une partie du noyau ! Évidemment, ces adresses sont accessibles uniquement en lecture, pas en écriture. Pas question de modifier le noyau de l'OS ! De plus, il s'agit d'une portion du noyau dont on sait que la consultation ne pose pas de problèmes de sécurité. Le programme peut lire des données dans cette portion du noyau, mais aussi exécuter les fonctions du noyau qui sont dedans. L'idée est d'éviter des appels systèmes trop fréquents. Au lieu d'effectuer un véritable appel système, avec une interruption logicielle, le programme peut exécuter des appels systèmes simplifiés, de simples appels de fonctions couplés avec un changement de niveau de privilège (passage en espace noyau nécessaire). [[File:AMD64-canonical--48-bit.png|vignette|Répartition des adresses entre noyau (jaune/orange) et programme (verte), sur les systèmes x86-64 bits, avec des adresses physiques de 48 bits.]] L'espace d'adressage est donc séparé en deux portions : l'OS d'un côté, le programme de l'autre. La répartition des adresses entre noyau et programme varie suivant l'OS ou le processeur utilisé. Sur les PC x86 32 bits, Linux attribuait 3 gigas pour les programmes et 1 giga pour le noyau, Windows attribuait 2 gigas à chacun. Sur les systèmes x86 64 bits, l'espace d'adressage d'un programme est coupé en trois, comme illustré ci-contre : une partie basse de 2^48 octets, une partie haute de même taille, et un bloc d'adresses invalides entre les deux. Les adresses basses sont utilisées pour le programme, les adresses hautes pour le noyau, il n'y a rien entre les deux. Avec le partage de mémoire, plusieurs adresses logiques correspondent à la même adresse physique. Tel processus verra la zone de mémoire partagée à l'adresse X, l'autre la verra à l'adresse Y. Mais il s'agira de la même portion de mémoire physique, avec une seule adresse physique. En clair, lorsque deux processus partagent une même zone de mémoire, la zone sera mappées à des adresses logiques différentes. Les adresses logiques sont alors appelées des '''adresses synonymes''', terme qui trahit le fait qu'elles correspondent à la même adresse physique. ===La mémoire virtuelle=== Toutes les adresses ne sont pas forcément occupées par de la mémoire RAM, s'il n'y a pas assez de RAM installée. Par exemple, un processeur 32 bits peut adresser 4 gibioctets de RAM, même si seulement 3 gibioctets sont installés dans l'ordinateur. L'espace d'adressage contient donc 1 gigas d'adresses inutilisées, et il faut éviter ce surplus d'adresses pose problème. Sans mémoire virtuelle, seule la mémoire réellement installée est utilisable. Si un programme utilise trop de mémoire, il est censé se rendre compte qu'il n'a pas accès à tout l'espace d'adressage. Quand il demandera au système d'exploitation de lui réserver de la mémoire, le système d'exploitation le préviendra qu'il n'y a plus de mémoire libre. Par exemple, si un programme tente d'utiliser 4 gibioctets sur un ordinateur avec 3 gibioctets de mémoire, il ne pourra pas. Pareil s'il veut utiliser 2 gibioctets de mémoire sur un ordinateur avec 4 gibioctets, mais dont 3 gibioctets sont déjà utilisés par d'autres programmes. Dans les deux cas, l'illusion tombe à plat. Les techniques de '''mémoire virtuelle''' font que l'espace d'adressage est utilisable au complet, même s'il n'y a pas assez de mémoire installée dans l'ordinateur ou que d'autres programmes utilisent de la RAM. Par exemple, sur un processeur 32 bits, le programme aura accès à 4 gibioctets de RAM, même si d'autres programmes utilisent la RAM, même s'il n'y a que 2 gibioctets de RAM d'installés dans l'ordinateur. Pour cela, on utilise une partie des mémoires de masse (disques durs) d'un ordinateur en remplacement de la mémoire physique manquante. Le système d'exploitation crée sur le disque dur un fichier, appelé le ''swapfile'' ou '''fichier de ''swap''''', qui est utilisé comme mémoire RAM supplémentaire. Il mémorise le surplus de données et de programmes qui ne peut pas être mis en mémoire RAM. [[File:Vm1.png|centre|vignette|upright=2.0|Mémoire virtuelle et fichier de Swap.]] Une technique naïve de mémoire virtuelle serait la suivante. Avant de l'aborder, précisons qu'il s'agit d'une technique abordée à but pédagogique, mais qui n'est implémentée nulle part tellement elle est lente et inefficace. Un espace d'adressage de 4 gigas ne contient que 3 gigas de RAM, ce qui fait 1 giga d'adresses inutilisées. Les accès mémoire aux 3 gigas de RAM se font normalement, mais l'accès aux adresses inutilisées lève une exception matérielle "Memory Unavailable". La routine d'interruption de cette exception accède alors au ''swapfile'' et récupère les données associées à cette adresse. La mémoire virtuelle est alors émulée par le système d'exploitation. Le défaut de cette méthode est que l'accès au giga manquant est toujours très lent, parce qu'il se fait depuis le disque dur. D'autres techniques de mémoire virtuelle logicielle font beaucoup mieux, mais nous allons les passer sous silence, vu qu'on peut faire mieux, avec l'aide du matériel. L'idée est de charger les données dont le programme a besoin dans la RAM, et de déplacer les autres sur le disque dur. Par exemple, imaginons la situation suivante : un programme a besoin de 4 gigas de mémoire, mais ne dispose que de 2 gigas de mémoire installée. On peut imaginer découper l'espace d'adressage en 2 blocs de 2 gigas, qui sont chargés à la demande. Si le programme accède aux adresses basses, on charge les 2 gigas d'adresse basse en RAM. S'il accède aux adresses hautes, on charge les 2 gigas d'adresse haute dans la RAM après avoir copié les adresses basses sur le ''swapfile''. On perd du temps dans les copies de données entre RAM et ''swapfile'', mais on gagne en performance vu que tous les accès mémoire se font en RAM. Du fait de la localité temporelle, le programme utilise les données chargées depuis le swapfile durant un bon moment avant de passer au bloc suivant. La RAM est alors utilisée comme une sorte de cache alors que les données sont placées dans une mémoire fictive représentée par l'espace d'adressage et qui correspond au disque dur. Mais avec cette technique, la correspondance entre adresses du programme et adresses de la RAM change au cours du temps. Les adresses de la RAM correspondent d'abord aux adresses basses, puis aux adresses hautes, et ainsi de suite. On a donc besoin d'abstraction mémoire. Les correspondances entre adresse logique et physique peuvent varier avec le temps, ce qui permet de déplacer des données de la RAM vers le disque dur ou inversement. Une adresse logique peut correspondre à une adresse physique, ou bien à une donnée swappée sur le disque dur. C'est l'unité de traduction d'adresse qui se charge de faire la différence. Si une correspondance entre adresse logique et physique est trouvée, elle l'utilise pour traduire les adresses. Si aucune correspondance n'est trouvée, alors elle laisse la main au système d'exploitation pour charger la donnée en RAM. Une fois la donnée chargée en RAM, les correspondances entre adresse logique et physiques sont modifiées de manière à ce que l'adresse logique pointe vers la donnée chargée. ===L'extension d'adressage=== Une autre fonctionnalité rendue possible par l'abstraction mémoire est l{{'}}'''extension d'adressage'''. Elle permet d'utiliser plus de mémoire que l'espace d'adressage ne le permet. Par exemple, utiliser 7 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'extension d'adresse est l'exact inverse de la mémoire virtuelle. La mémoire virtuelle sert quand on a moins de mémoire que d'adresses, l'extension d'adresse sert quand on a plus de mémoire que d'adresses. Il y a quelques chapitres, nous avions vu que c'est possible via la commutation de banques. Mais l'abstraction mémoire est une méthode alternative. Que ce soit avec la commutation de banques ou avec l'abstraction mémoire, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. La différence est que l'abstraction mémoire étend les adresses d'une manière différente. Une implémentation possible de l'extension d'adressage fait usage de l'abstraction matérielle des processus. Chaque processus a son propre espace d'adressage, mais ceux-ci sont placés à des endroits différents dans la mémoire physique. Par exemple, sur un ordinateur avec 16 gigas de RAM, mais un espace d'adressage de 2 gigas, on peut remplir la RAM en lançant 8 processus différents et chaque processus aura accès à un bloc de 2 gigas de RAM, pas plus, il ne peut pas dépasser cette limite. Ainsi, chaque processus est limité par son espace d'adressage, mais on remplit la mémoire avec plusieurs processus, ce qui compense. Il s'agit là de l'implémentation la plus simple, qui a en plus l'avantage d'avoir la meilleure compatibilité logicielle. De simples changements dans le système d'exploitation suffisent à l'implémenter. [[File:Extension de l'espace d'adressage.png|centre|vignette|upright=1.5|Extension de l'espace d'adressage]] Un autre implémentation donne plusieurs espaces d'adressage différents à chaque processus, et a donc accès à autant de mémoire que permis par la somme de ces espaces d'adressage. Par exemple, sur un ordinateur avec 16 gigas de RAM et un espace d'adressage de 4 gigas, un programme peut utiliser toute la RAM en utilisant 4 espaces d'adressage distincts. On passe d'un espace d'adressage à l'autre en changeant la correspondance adresse logique-physique. L'inconvénient est que la compatibilité logicielle est assez mauvaise. Modifier l'OS ne suffit pas, les programmeurs doivent impérativement concevoir leurs programmes pour qu'ils utilisent explicitement plusieurs espaces d'adressage. Les deux implémentations font usage des adresses logiques homonymes, mais à l'intérieur d'un même processus. Pour rappel, cela veut dire qu'une adresse logique correspond à des adresses physiques différentes. Rien d'étonnant vu qu'on utilise plusieurs espaces d'adressage, comme pour l'abstraction des processus, sauf que cette fois-ci, on a plusieurs espaces d'adressage par processus. Prenons l'exemple où on a 8 gigas de RAM sur un processeur 32 bits, dont l'espace d'adressage ne gère que 4 gigas. L'idée est qu'une adresse correspondra à une adresse dans les premiers 4 gigas, ou dans les seconds 4 gigas. L'adresse logique X correspondra d'abord à une adresse physique dans les premiers 4 gigas, puis à une adresse physique dans les seconds 4 gigas. ===La protection mémoire=== La '''protection mémoire''' regroupe des techniques très différentes les unes des autres, qui visent à améliorer la sécurité des programmes et des systèmes d'exploitation. Elles visent à empêcher de lire, d'écrire ou d'exécuter certaines portions de mémoire. Sans elle, les programmes peuvent techniquement lire ou écrire les données des autres, ce qui causent des situations non-prévues par le programmeur, avec des conséquences qui vont d'un joli plantage à des failles de sécurité dangereuses. La première technique de protection mémoire est l{{'}}'''isolation des processus''', qu'on a vue plus haut. Elle garantit que chaque programme n'a accès qu'à certaines portions dédiées de la mémoire et rend le reste de la mémoire inaccessible en lecture et en écriture. Le système d'exploitation attribue à chaque programme une ou plusieurs portions de mémoire rien que pour lui, auquel aucun autre programme ne peut accéder. Un tel programme, isolé des autres, s'appelle un '''processus''', d'où le nom de cet objectif. Toute tentative d'accès à une partie de la mémoire non autorisée déclenche une exception matérielle (rappelez-vous le chapitre sur les interruptions) qui est traitée par une routine du système d'exploitation. Généralement, le programme fautif est sauvagement arrêté et un message d'erreur est affiché à l'écran. La '''protection de l'espace exécutable''' empêche d’exécuter quoique ce soit provenant de certaines zones de la mémoire. En effet, certaines portions de la mémoire sont censées contenir uniquement des données, sans aucun programme ou code exécutable. Cependant, des virus informatiques peuvent se cacher dedans et d’exécuter depuis celles-ci. Ou encore, des failles de sécurités peuvent permettre à un attaquant d'injecter du code exécutable malicieux dans des données, ce qui peut lui permettre de lire les données manipulées par un programme, prendre le contrôle de la machine, injecter des virus, ou autre. Pour éviter cela, le système d'exploitation peut marquer certaines zones mémoire comme n'étant pas exécutable. Toute tentative d’exécuter du code localisé dans ces zones entraîne la levée d'une exception ou d'une erreur et le système d'exploitation réagit en conséquence. Là encore, le processeur doit détecter les exécutions non autorisées. D'autres méthodes de protection mémoire visent à limiter des actions dangereuses. Pour cela, le processeur et l'OS gèrent des '''droits d'accès''', qui interdisent certaines actions pour des programmes non-autorisés. Lorsqu'on exécute une opération interdite, le système d’exploitation et/ou le processeur réagissent en conséquence. La première technique de ce genre n'est autre que la séparation entre espace noyau et utilisateur, vue dans le chapitre sur les interruptions. Mais il y en a d'autres, comme nous le verrons dans ce chapitre. ==La MMU== La traduction des adresses logiques en adresses physiques se fait par un circuit spécialisé, appelé la '''''Memory Management Unit''''' (MMU). De nos jours, elle est intégrée dans le processeur, à la suite de l'unité mémoire. Mais il a existé des processeurs avec une MMU externe, soudée sur la carte mère. La traduction des adresses logiques en adresses physiques demande d'accéder à des données sont en mémoire RAM, qui sont gérés par le système d'exploitation. Aussi, les processeurs modernes incorporent des mémoires caches appelées des '''''Translation Lookaside Buffers''''', ou encore TLB. Nous nous pouvons pas parler des TLB pour le moment, car nous n'avons pas encore abordé le chapitre sur les mémoires caches, mais un chapitre entier sera dédié aux TLB d'ici peu. [[File:MMU principle updated.png|centre|vignette|upright=2|MMU.]] ===Les MMU intégrées au processeur=== D'ordinaire, la MMU est intégrée au processeur. Et elle peut l'être de deux manières. La première en fait un circuit séparé, relié au bus d'adresse. La seconde fusionne la MMU avec l'unité de calcul d'adresse. La première solution est surtout utilisée avec une technique d'abstraction mémoire appelée la pagination, alors que l'autre l'est avec une autre méthode appelée la segmentation. La raison est que la traduction d'adresse avec la segmentation est assez simple : elle demande d'additionner le contenu d'un registre avec l'adresse logique, ce qui est le genre de calcul qu'une unité de calcul d'adresse sait déjà faire. La fusion est donc assez évidente. Pour donner un exemple, l'Intel 8086 fusionnait l'unité de calcul d'adresse et la MMU. Précisément, il utilisait un même additionneur pour incrémenter le ''program counter'' et effectuer des calculs d'adresse liés à la segmentation. Il aurait été logique d'ajouter les pointeurs de pile avec, mais ce n'était pas possible. La raison est que le pointeur de pile ne peut pas être envoyé directement sur le bus d'adresse, vu qu'il doit passer par une phase de traduction en adresse physique liée à la segmentation. [[File:80186 arch.png|centre|vignette|upright=2|Intel 8086, microarchitecture.]] ===Les MMU séparées du processeur, sur la carte mère=== Avant d'être intégrées au processeur, les MMU étaient des circuits séparés, placés sur la carte mère, entre le processeur et la mémoire. Elle étaient connectées en plein milieu du bus d'adresse. A savoir que le bus d'adresse entrait dans la MMU et en ressortait. Le processeur envoyait des adresses à la MMU, qui renvoyait une adresse physique. La MMU était facultative, à savoir que le système pouvait fonctionner sans MMU d'installée, mais l'abstraction mémoire n’était alors pas disponible. Par exemple, les processeurs Motorola 68000 et 68010 pouvaient être combinés avec une MMU de type Motorola 68451. Elle supportait des versions simplifiées de la segmentation et de la pagination. Au minimum, elle ajoutait un support de la protection mémoire contre certains accès non-autorisés. La gestion de la mémoire virtuelle proprement dit n'était possible que si le processeur utilisé était un Motorola 68010, en raison de la manière dont le 68000 gérait ses accès mémoire. La MMU 68451 gérait un espace d'adressage de 16 mébioctets, découpé en maximum 32 pages/segments. On pouvait dépasser cette limite de 32 segments/pages en combinant plusieurs 68451. Le Motorola 68851 était une MMU qui était prévue pour fonctionner de paire avec le Motorola 68020. Elle gérait la pagination pour un espace d'adressage de 32 bits. Les processeurs suivants, les 68030, 68040, et 68060, avaient une MMU interne au processeur. ==La relocation matérielle== Pour rappel, les systèmes d'exploitation moderne permettent de lancer plusieurs programmes en même temps et les laissent se partager la mémoire. Dans le cas le plus simple, qui n'est pas celui des OS modernes, le système d'exploitation découpe la mémoire en blocs d'adresses contiguës qui sont appelés des '''segments''', ou encore des ''partitions mémoire''. Les segments correspondent à un bloc de mémoire RAM. C'est-à-dire qu'un segment de 259 mébioctets sera un segment continu de 259 mébioctets dans la mémoire physique comme dans la mémoire logique. Dans ce qui suit, un segment contient un programme en cours d'exécution, comme illustré ci-dessous. [[File:CPT Memory Addressable.svg|centre|vignette|upright=2|Espace d'adressage segmenté.]] Le système d'exploitation mémorise la position de chaque segment en mémoire, ainsi que d'autres informations annexes. Le tout est regroupé dans la '''table de segment''', un tableau dont chaque case est attribuée à un programme/segment. La table des segments est un tableau numéroté, chaque segment ayant un numéro qui précise sa position dans le tableau. Chaque case, chaque entrée, contient un '''descripteur de segment''' qui regroupe plusieurs informations sur le segment : son adresse de base, sa taille, diverses informations. ===La relocation avec la relocation matérielle : le registre de base=== Un segment peut être placé n'importe où en RAM physique et sa position en RAM change à chaque exécution. Le programme est chargé à une adresse, celle du début du segment, qui change à chaque chargement du programme. Et toutes les adresses utilisées par le programme doivent être corrigées lors du chargement du programme, généralement par l'OS. Cette correction s'appelle la '''relocation''', et elle consiste à ajouter l'adresse de début du segment à chaque adresse manipulée par le programme. [[File:Relocation assistée par matériel.png|centre|vignette|upright=2.5|Relocation.]] La relocation matérielle fait que la relocation est faite par le processeur, pas par l'OS. La relocation est intégrée dans le processeur par l'intégration d'un registre : le '''registre de base''', aussi appelé '''registre de relocation'''. Il mémorise l'adresse à laquelle commence le segment, la première adresse du programme. Pour effectuer la relocation, le processeur ajoute automatiquement l'adresse de base à chaque accès mémoire, en allant la chercher dans le registre de relocation. [[File:Registre de base de segment.png|centre|vignette|upright=2|Registre de base de segment.]] Le processeur s'occupe de la relocation des segments et le programme compilé n'en voit rien. Pour le dire autrement, les programmes manipulent des adresses logiques, qui sont traduites par le processeur en adresses physiques. La traduction se fait en ajoutant le contenu du registre de relocation à l'adresse logique. De plus, cette méthode fait que chaque programme a son propre espace d'adressage. [[File:CPU created logical address presentation.png|centre|vignette|upright=2|Traduction d'adresse avec la relocation matérielle.]] Le système d'exploitation mémorise les adresses de base pour chaque programme, dans la table des segments. Le registre de base est mis à jour automatiquement lors de chaque changement de segment. Pour cela, le registre de base est accessible via certaines instructions, accessibles en espace noyau, plus rarement en espace utilisateur. Le registre de segment est censé être adressé implicitement, vu qu'il est unique. Si ce n'est pas le cas, il est possible d'écrire dans ce registre de segment, qui est alors adressable. ===La protection mémoire avec la relocation matérielle : le registre limite=== Sans restrictions supplémentaires, la taille maximale d'un segment est égale à la taille complète de l'espace d'adressage. Sur les processeurs 32 bits, un segment a une taille maximale de 2^32 octets, soit 4 gibioctets. Mais il est possible de limiter la taille du segment à 2 gibioctets, 1 gibioctet, 64 Kibioctets, ou toute autre taille. La limite est définie lors de la création du segment, mais elle peut cependant évoluer au cours de l'exécution du programme, grâce à l'allocation mémoire. Le processeur vérifie à chaque accès mémoire que celui-ci se fait bien dans le segment, qu'il ne déborde pas en-dehors. C'est possible qu'une adresse calculée sorte du segment, à la suite d'un bug ou d'une erreur de programmation, voire pire. Et le processeur doit éviter de tels '''débordements de segments'''. A chaque accès mémoire, le processeur compare l'adresse accédée et vérifie qu'elle est bien dans le segment. Pour cela, il y a deux solutions. La première part du principe que le segment est placé en mémoire entre l'adresse de base et l'adresse limite. Il suffit de mémoriser l'adresse limite, l'adresse physique à ne pas dépasser. Une autre solution mémorise la taille du segment. La table des segments doit donc mémoriser, en plus de l'adresse de base : soit l'adresse maximale du segment, soit la taille du segment. D'autres informations peuvent être ajoutées, comme on le verra plus tard, mais cela complexifie la table des segments. De plus, le processeur se voit ajouter un '''registre limite''', qui mémorise soit la taille du segment, soit l'adresse limite. Les deux registres, base et limite, sont utilisés pour vérifier si un programme qui lit/écrit de la mémoire en-dehors de son segment attitré : au-delà pour le registre limite, en-deça pour le registre de base. Le processeur vérifie pour chaque accès mémoire ne déborde pas au-delà du segment qui lui est allouée, ce qui n'arrive que si l'adresse d'accès dépasse la valeur du registre limite. Pour les accès en-dessous du segment, il suffit de vérifier si l'addition de relocation déborde, tout débordement signifiant erreur de protection mémoire. [[File:Registre limite.png|centre|vignette|upright=2|Registre limite]] Utiliser la taille du segment a de nombreux avantages. L'un d'entre eux se manifeste quand on déplace un segment en mémoire RAM. Le descripteur doit alors être mis à jour, et c'est plus facile quand on utilise la taille du segment. Si on utilise l'adresse limite, il faut mettre à jour à la fois l'adresse de base et l'adresse limite, dans le descripteur. En utilisant la taille, seule l'adresse de base doit être modifiée, vu que le segment n'a pas changé de taille. Un autre avantage est lié aux performances, mais nous devons faire un détour pour le comprendre. La taille du segment est équivalent à l'adresse logique maximale possible. Par exemple, si un segment fait 256 octets, les adresses logiques possibles vont de 0 à 255, 256 est donc à la fois la taille du segment et l'adresse logique à partir de laquelle on déborde du segment. Et cela marche si on remplace 256 par n'importe quelle valeur : vu que le segment commence à l'adresse 0, sa taille en octets indique l'adresse de dépassement. Interpréter la taille du segment comme une adresse logique fait que les tests avec le registre limite sont plus performants, voyons pourquoi. En utilisant l'adresse physique limite, on doit faire la relocation, puis comparer l'adresse calculée avec l'adresse limite. Le calcul d'adresse doit se faire avant la vérification. En utilisant la taille, on doit comparer l'adresse logique avec la taille du segment. On peut alors faire le test de débordement avant ou pendant la relocation. Les deux peuvent être faits en parallèle, dans deux circuits distincts, ce qui améliore un peu le temps d'un accès mémoire. Quelques processeurs en ont profité, mais on verra cela dans la section sur la segmentation. [[File:Comparaison entre adresse limite physique et logique.png|centre|vignette|upright=2|Comparaison entre adresse limite physique et logique]] Les registres de base et limite sont altérés uniquement par le système d'exploitation et ne sont accessibles qu'en espace noyau. Lorsque le système d'exploitation charge un programme, ou reprend son exécution, il charge les adresses de début/fin du segment dans ces registres. D'ailleurs, ces deux registres doivent être sauvegardés et restaurés lors de chaque interruption. Par contre, et c'est assez évident, ils ne le sont pas lors d'un appel de fonction. Cela fait une différence de plus entre interruption et appels de fonctions. : Il faut noter que le registre limite et le registre de base sont parfois fusionnés en un seul registre, qui contient un descripteur de segment tout entier. Pour information, la relocation matérielle avec un registre limite a été implémentée sur plusieurs processeurs assez anciens, notamment sur les anciens supercalculateurs de marque CDC. Un exemple est le fameux CDC 6600, qui implémentait cette technique. ===La mémoire virtuelle avec la relocation matérielle=== Il est possible d'implémenter la mémoire virtuelle avec la relocation matérielle. Pour cela, il faut swapper des segments entiers sur le disque dur. Les segments sont placés en mémoire RAM et leur taille évolue au fur et à mesure que les programmes demandent du rab de mémoire RAM. Lorsque la mémoire est pleine, ou qu'un programme demande plus de mémoire que disponible, des segments entiers sont sauvegardés dans le ''swapfile'', pour faire de la place. Faire ainsi de demande juste de mémoriser si un segment est en mémoire RAM ou non, ainsi que la position des segments swappés dans le ''swapfile''. Pour cela, il faut modifier la table des segments, afin d'ajouter un '''bit de swap''' qui précise si le segment en question est swappé ou non. Lorsque le système d'exploitation veut swapper un segment, il le copie dans le ''swapfile'' et met ce bit à 1. Lorsque l'OS recharge ce segment en RAM, il remet ce bit à 0. La gestion de la position des segments dans le ''swapfile'' est le fait d'une structure de données séparée de la table des segments. L'OS exécute chaque programme l'un après l'autre, à tour de rôle. Lorsque le tour d'un programme arrive, il consulte la table des segments pour récupérer les adresses de base et limite, mais il vérifie aussi le bit de swap. Si le bit de swap est à 0, alors l'OS se contente de charger les adresses de base et limite dans les registres adéquats. Mais sinon, il démarre une routine d'interruption qui charge le segment voulu en RAM, depuis le ''swapfile''. C'est seulement une fois le segment chargé que l'on connait son adresse de base/limite et que le chargement des registres de relocation peut se faire. Un défaut évident de cette méthode est que l'on swappe des programmes entiers, qui sont généralement assez imposants. Les segments font généralement plusieurs centaines de mébioctets, pour ne pas dire plusieurs gibioctets, à l'époque actuelle. Ils étaient plus petits dans l'ancien temps, mais la mémoire était alors plus lente. Toujours est-il que la copie sur le disque dur des segments est donc longue, lente, et pas vraiment compatible avec le fait que les programmes s'exécutent à tour de rôle. Et ca explique pourquoi la relocation matérielle n'est presque jamais utilisée avec de la mémoire virtuelle. ===L'extension d'adressage avec la relocation matérielle=== Passons maintenant à la dernière fonctionnalité implémentable avec la traduction d'adresse : l'extension d'adressage. Elle permet d'utiliser plus de mémoire que ne le permet l'espace d'adressage. Par exemple, utiliser plus de 64 kibioctets de mémoire sur un processeur 16 bits. Pour cela, les adresses envoyées à la mémoire doivent être plus longues que les adresses gérées par le processeur. L'extension des adresses se fait assez simplement avec la relocation matérielle : il suffit que le registre de base soit plus long. Prenons l'exemple d'un processeur aux adresses de 16 bits, mais qui est reliée à un bus d'adresse de 24 bits. L'espace d'adressage fait juste 64 kibioctets, mais le bus d'adresse gère 16 mébioctets de RAM. On peut utiliser les 16 mébioctets de RAM à une condition : que le registre de base fasse 24 bits, pas 16. Un défaut de cette approche est qu'un programme ne peut pas utiliser plus de mémoire que ce que permet l'espace d'adressage. Mais par contre, on peut placer chaque programme dans des portions différentes de mémoire. Imaginons par exemple que l'on ait un processeur 16 bits, mais un bus d'adresse de 20 bits. Il est alors possible de découper la mémoire en 16 blocs de 64 kibioctets, chacun attribué à un segment/programme, qu'on sélectionne avec les 4 bits de poids fort de l'adresse. Il suffit de faire démarrer les segments au bon endroit en RAM, et cela demande juste que le registre de base le permette. C'est une sorte d'émulation de la commutation de banques. ==La segmentation en mode réel des processeurs x86== Avant de passer à la suite, nous allons voir la technique de segmentation de l'Intel 8086, un des tout premiers processeurs 16 bits. Il s'agissait d'une forme très simple de segmentation, sans aucune forme de protection mémoire, ni même de mémoire virtuelle, ce qui le place à part des autres formes de segmentation. Il s'agit d'une amélioration de la relocation matérielle, qui avait pour but de permettre d'utiliser plus de 64 kibioctets de mémoire, ce qui était la limite maximale sur les processeurs 16 bits de l'époque. Par la suite, la segmentation s'améliora et ajouta un support complet de la mémoire virtuelle et de la protection mémoire. L'ancienne forme de segmentation fut alors appelé le '''mode réel''', et la nouvelle forme de segmentation fut appelée le '''mode protégé'''. Le mode protégé rajoute la protection mémoire, en ajoutant des registres limite et une gestion des droits d'accès aux segments, absents en mode réel. De plus, il ajoute un support de la mémoire virtuelle grâce à l'utilisation d'une des segments digne de ce nom, table qui est absente en mode réel ! Pour le moment, voyons le mode réel. ===Les segments en mode réel=== [[File:Typical computer data memory arrangement.png|vignette|upright=0.5|Typical computer data memory arrangement]] La segmentation en mode réel sépare la pile, le tas, le code machine et les données constantes dans quatre segments distincts. * Le segment '''''text''''', qui contient le code machine du programme, de taille fixe. * Le segment '''''data''''' contient des données de taille fixe qui occupent de la mémoire de façon permanente, des constantes, des variables globales, etc. * Le segment pour la '''pile''', de taille variable. * le reste est appelé le '''tas''', de taille variable. Un point important est que sur ces processeurs, il n'y a pas de table des segments proprement dit. Chaque programme gére de lui-même les adresses de base des segments qu'il manipule. Il n'est en rien aidé par une table des segments gérée par le système d'exploitation. ===Les registres de segments en mode réel=== Chaque segment subit la relocation indépendamment des autres. Pour cela, le processeur intégre plusieurs registres de base, un par segment. Notons que cette solution ne marche que si le nombre de segments par programme est limité, à une dizaine de segments tout au plus. Les processeurs x86 utilisaient cette méthode, et n'associaient que 4 à 6 registres de segments par programme. Les processeurs 8086 et le 286 avaient quatre registres de segment : un pour le code, un autre pour les données, et un pour la pile, le quatrième étant un registre facultatif laissé à l'appréciation du programmeur. Ils sont nommés CS (''code segment''), DS (''data segment''), SS (''Stack segment''), et ES (''Extra segment''). Le 386 rajouta deux registres, les registres FS et GS, qui sont utilisés pour les segments de données. Les processeurs post-386 ont donc 6 registres de segment. Les registres CS et SS sont adressés implicitement, en fonction de l'instruction exécutée. Les instructions de la pile manipulent le segment associé à la pile, le chargement des instructions se fait dans le segment de code, les instructions arithmétiques et logiques vont chercher leurs opérandes sur le tas, etc. Et donc, toutes les instructions sont chargées depuis le segment pointé par CS, les instructions de gestion de la pile (PUSH et POP) utilisent le segment pointé par SS. Les segments DS et ES sont, eux aussi, adressés implicitement. Pour cela, les instructions LOAD/STORE sont dupliquées : il y a une instruction LOAD pour le segment DS, une autre pour le segment ES. D'autres instructions lisent leurs opérandes dans un segment par défaut, mais on peut changer ce choix par défaut en précisant le segment voulu. Un exemple est celui de l'instruction CMPSB, qui compare deux octets/bytes : le premier est chargé depuis le segment DS, le second depuis le segment ES. Un autre exemple est celui de l'instruction MOV avec un opérande en mémoire. Elle lit l'opérande en mémoire depuis le segment DS par défaut. Il est possible de préciser le segment de destination si celui-ci n'est pas DS. Par exemple, l'instruction MOV [A], AX écrit le contenu du registre AX dans l'adresse A du segment DS. Par contre, l'instruction MOV ES:[A], copie le contenu du registre AX das l'adresse A, mais dans le segment ES. ===La traduction d'adresse en mode réel=== La segmentation en mode réel a pour seul but de permettre à un programme de dépasser la limite des 64 KB autorisée par les adresses de 16 bits. L'idée est que chaque segment a droit à son propre espace de 64 KB. On a ainsi 64 Kb pour le code machine, 64 KB pour la pile, 64 KB pour un segment de données, etc. Les registres de segment mémorisaient la base du segment, les adresses calculées par l'ALU étant des ''offsets''. Ce sont tous des registres de 16 bits, mais ils ne mémorisent pas des adresses physiques de 16 bits, comme nous allons le voir. [[File:Table des segments dans un banc de registres.png|centre|vignette|upright=2|Table des segments dans un banc de registres.]] L'Intel 8086 utilisait des adresses de 20 bits, ce qui permet d'adresser 1 mébioctet de RAM. Vous pouvez vous demander comment on peut obtenir des adresses de 20 bits alors que les registres de segments font tous 16 bits ? Cela tient à la manière dont sont calculées les adresses physiques. Le registre de segment n'est pas additionné tel quel avec le décalage : à la place, le registre de segment est décalé de 4 rangs vers la gauche. Le décalage de 4 rangs vers la gauche fait que chaque segment a une adresse qui est multiple de 16. Le fait que le décalage soit de 16 bits fait que les segments ont une taille de 64 kibioctets. {|class="wikitable" |- | <code>&nbsp; </code><code style="background:#DED">0000 0110 1110 1111</code><code>0000</code> | Registre de segment - | 16 bits, décalé de 4 bits vers la gauche |- | <code>+ &nbsp;&nbsp;&nbsp;&nbsp; </code><code style="background:#DDF">0001 0010 0011 0100</code> | Décalage/''Offset'' | 16 bits |- | colspan="3" | |- | <code>&nbsp; </code><code style="background:#FDF">0000 1000 0001 0010 0100</code> | Adresse finale | 20 bits |} Vous aurez peut-être remarqué que le calcul peut déborder, dépasser 20 bits. Mais nous reviendrons là-dessus plus bas. L'essentiel est que la MMU pour la segmentation en mode réel se résume à quelques registres et des additionneurs/soustracteurs. Un exemple est l'Intel 8086, un des tout premier processeur Intel. Le processeur était découpé en deux portions : l'interface mémoire et le reste du processeur. L'interface mémoire est appelée la '''''Bus Interface Unit''''', et le reste du processeur est appelé l{{'}}'''''Execution Unit'''''. L'interface mémoire contenait les registres de segment, au nombre de 4, ainsi qu'un additionneur utilisé pour traduire les adresses logiques en adresses physiques. Elle contenait aussi une file d'attente où étaient préchargées les instructions. Sur le 8086, la MMU est fusionnée avec les circuits de gestion du ''program counter''. Les registres de segment sont regroupés avec le ''program counter'' dans un même banc de registres, et un additionneur unique est utilisé à la fois à incrémenter le ''program counter'' et pour gérer la segmentation. L'additionneur est donc mutualisé entre segmentation et ''program counter''. En somme, il n'y a pas vraiment de MMU dédiée, mais un super-circuit en charge du Fetch et de la mémoire virtuelle, ainsi que du préchargement des instructions. Nous en reparlerons au chapitre suivant. [[File:80186 arch.png|centre|vignette|upright=2.5|Architecture du 8086, du 80186 et de ses variantes.]] La MMU du 286 était fusionnée avec l'unité de calcul d'adresse. Elle contient les registres de segments, un comparateur pour détecter les accès hors-segment, et plusieurs additionneurs. Il y a un additionneur pour les calculs d'adresse proprement dit, suivi d'un additionneur pour la relocation. [[File:Intel i80286 arch.svg|centre|vignette|upright=3|Intel i80286 arch]] ===La segmentation en mode réel accepte plusieurs segments de code/données=== Les programmes peuvent parfaitement répartir leur code machine dans plusieurs segments de code. La limite de 64 KB par segment est en effet assez limitante, et il n'était pas rare qu'un programme stocke son code dans deux ou trois segments. Il en est de même avec les données, qui peuvent être réparties dans deux ou trois segments séparés. La seule exception est la pile : elle est forcément dans un segment unique et ne peut pas dépasser 64 KB. Pour gérer plusieurs segments de code/donnée, il faut changer de segment à la volée suivant les besoins, en modifiant les registres de segment. Il s'agit de la technique de '''commutation de segment'''. Pour cela, tous les registres de segment, à l'exception de CS, peuvent être altérés par une instruction d'accès mémoire, soit avec une instruction MOV, soit en y copiant le sommet de la pile avec une instruction de dépilage POP. L'absence de sécurité fait que la gestion de ces registres est le fait du programmeur, qui doit redoubler de prudence pour ne pas faire n'importe quoi. Pour le code machine, le répartir dans plusieurs segments posait des problèmes au niveau des branchements. Si la plupart des branchements sautaient vers une instruction dans le même segment, quelques rares branchements sautaient vers du code machine dans un autre segment. Intel avait prévu le coup et disposait de deux instructions de branchement différentes pour ces deux situations : les '''''near jumps''''' et les '''''far jumps'''''. Les premiers sont des branchements normaux, qui précisent juste l'adresse à laquelle brancher, qui correspond à la position de la fonction dans le segment. Les seconds branchent vers une instruction dans un autre segment, et doivent préciser deux choses : l'adresse de base du segment de destination, et la position de la destination dans le segment. Le branchement met à jour le registre CS avec l'adresse de base, avant de faire le branchement. Ces derniers étaient plus lents, car on n'avait pas à changer de segment et mettre à jour l'état du processeur. Il y avait la même pour l'instruction d'appel de fonction, avec deux versions de cette instruction. La première version, le '''''near call''''' est un appel de fonction normal, la fonction appelée est dans le segment en cours. Avec la seconde version, le '''''far call''''', la fonction appelée est dans un segment différent. L'instruction a là aussi besoin de deux opérandes : l'adresse de base du segment de destination, et la position de la fonction dans le segment. Un ''far call'' met à jour le registre CS avec l'adresse de base, ce qui fait que les ''far call'' sont plus lents que les ''near call''. Il existe aussi la même chose, pour les instructions de retour de fonction, avec une instruction de retour de fonction normale et une instruction de retour qui renvoie vers un autre segment, qui sont respectivement appelées '''''near return''''' et '''''far return'''''. Là encore, il faut préciser l'adresse du segment de destination dans le second cas. La même chose est possible pour les segments de données. Sauf que cette fois-ci, ce sont les pointeurs qui sont modifiés. pour rappel, les pointeurs sont, en programmation, des variables qui contiennent des adresses. Lors de la compilation, ces pointeurs sont placés soit dans un registre, soit dans les instructions (adressage absolu), ou autres. Ici, il existe deux types de pointeurs, appelés '''''near pointer''''' et '''''far pointer'''''. Vous l'avez deviné, les premiers sont utilisés pour localiser les données dans le segment en cours d'utilisation, alors que les seconds pointent vers une donnée dans un autre segment. Là encore, la différence est que le premier se contente de donner la position dans le segment, alors que les seconds rajoutent l'adresse de base du segment. Les premiers font 16 bits, alors que les seconds en font 32 : 16 bits pour l'adresse de base et 16 pour l{{'}}''offset''. ===L'occupation de l'espace d'adressage par les segments=== Nous venons de voir qu'un programme pouvait utiliser plus de 4-6 segments, avec la commutation de segment. Mais d'autres programmes faisaient l'inverse, à savoir qu'ils se débrouillaient avec seulement 1 ou 2 segments. Suivant le nombre de segments utilisés, la configuration des registres n'était pas la même. Les configurations possibles sont appelées des ''modèle mémoire'', et il y en a en tout 6. En voici la liste : {| class="wikitable" |- ! Modèle mémoire !! Configuration des segments !! Configuration des registres || Pointeurs utilisés || Branchements utilisés |- | Tiny* || Segment unique pour tout le programme || CS=DS=SS || ''near'' uniquement || ''near'' uniquement |- | Small || Segment de donnée séparé du segment de code, pile dans le segment de données || DS=SS || ''near'' uniquement || ''near'' uniquement |- | Medium || Plusieurs segments de code unique, un seul segment de données || CS, DS et SS sont différents || ''near'' et ''far'' || ''near'' uniquement |- | Compact || Segment de code unique, plusieurs segments de données || CS, DS et SS sont différents || ''near'' uniquement || ''near'' et ''far'' |- | Large || Plusieurs segments de code, plusieurs segments de données || CS, DS et SS sont différents || ''near'' et ''far'' || ''near'' et ''far'' |} Un programme est censé utiliser maximum 4-6 segments de 64 KB, ce qui permet d'adresser maximum 64 * 6 = 384 KB de RAM, soit bien moins que le mébioctet de mémoire théoriquement adressable. Mais ce défaut est en réalité contourné par la commutation de segment, qui permettait d'adresser la totalité de la RAM si besoin. Une second manière de contourner cette limite est que plusieurs processus peuvent s'exécuter sur un seul processeur, si l'OS le permet. Ce n'était pas le cas à l'époque du DOS, qui était un OS mono-programmé, mais c'était en théorie possible. La limite est de 6 segments par programme/processus, en exécuter plusieurs permet d'utiliser toute la mémoire disponible rapidement. [[File:Overlapping realmode segments.svg|vignette|Segments qui se recouvrent en mode réel.]] Vous remarquerez qu'avec des registres de segments de 16 bits, on peut gérer 65536 segments différents, chacun de 64 KB. Et 65 536 segments de 64 kibioctets, ça ne rentre pas dans le mébioctet de mémoire permis avec des adresses de 20 bits. La raison est que plusieurs couples segment+''offset'' pointent vers la même adresse. En tout, chaque adresse peut être adressée par 4096 couples segment+''offset'' différents. L'avantage de cette méthode est que des segments peuvent se recouvrir, à savoir que la fin de l'un se situe dans le début de l'autre, comme illustré ci-contre. Cela permet en théorie de partager de la mémoire entre deux processus. Mais la technique est tout sauf pratique et est donc peu utilisée. Elle demande de placer minutieusement les segments en RAM, et les données à partager dans les segments. En pratique, les programmeurs et OS utilisent des segments qui ne se recouvrent pas et sont disjoints en RAM. Le nombre maximal de segments disjoints se calcule en prenant la taille de la RAM, qu'on divise par la taille d'un segment. Le calcul donne : 1024 kibioctets / 64 kibioctets = 16 segments disjoints. Un autre calcul prend le nombre de segments divisé par le nombre d'adresses aliasées, ce qui donne 65536 / 4096 = 16. Seulement 16 segments, c'est peu. En comptant les segments utilisés par l'OS et ceux utilisés par le programme, la limite est vite atteinte si le programme utilise la commutation de segment. ===Le mode réel sur les 286 et plus : la ligne d'adresse A20=== Pour résumer, le registre de segment contient des adresses de 20 bits, dont les 4 bits de poids faible sont à 0. Et il se voit ajouter un ''offset'' de 16 bits. Intéressons-nous un peu à l'adresse maximale que l'on peut calculer avec ce système. Nous allons l'appeler l{{'}}'''adresse maximale de segmentation'''. Elle vaut : {|class="wikitable" |- | <code>&nbsp; </code><code style="background:#DED">1111 1111 1111 1111</code><code>0000</code> | Registre de segment - | 16 bits, décalé de 4 bits vers la gauche |- | <code>+ &nbsp;&nbsp;&nbsp;&nbsp; </code><code style="background:#DDF">1111 1111 1111 1111</code> | Décalage/''Offset'' | 16 bits |- | colspan="3" | |- | <code>&nbsp; </code><code style="background:#FDF">1 0000 1111 1111 1110 1111</code> | Adresse finale | 20 bits |} Le résultat n'est pas l'adresse maximale codée sur 20 bits, car l'addition déborde. Elle donne un résultat qui dépasse l'adresse maximale permis par les 20 bits, il y a un 21ème bit en plus. De plus, les 20 bits de poids faible ont une valeur bien précise. Ils donnent la différence entre l'adresse maximale permise sur 20 bit, et l'adresse maximale de segmentation. Les bits 1111 1111 1110 1111 traduits en binaire donnent 65 519; auxquels il faut ajouter l'adresse 1 0000 0000 0000 0000. En tout, cela fait 65 520 octets adressables en trop. En clair : on dépasse la limite du mébioctet de 65 520 octets. Le résultat est alors très différent selon que l'on parle des processeurs avant le 286 ou après. Avant le 286, le bus d'adresse faisait exactement 20 bits. Les adresses calculées ne pouvaient pas dépasser 20 bits. L'addition générait donc un débordement d'entier, géré en arithmétique modulaire. En clair, les bits de poids fort au-delà du vingtième sont perdus. Le calcul de l'adresse débordait et retournait au début de la mémoire, sur les 65 520 premiers octets de la mémoire RAM. [[File:IBM PC Memory areas.svg|vignette|IBM PC Memory Map, la ''High memory area'' est en jaune.]] Le 80286 en mode réel gère des adresses de base de 24 bits, soit 4 bits de plus que le 8086. Le résultat est qu'il n'y a pas de débordement. Les bits de poids fort sont conservés, même au-delà du 20ème. En clair, la segmentation permettait de réellement adresser 65 530 octets au-delà de la limite de 1 mébioctet. La portion de mémoire adressable était appelé la '''''High memory area''''', qu'on va abrévier en HMA. {| class="wikitable" |+ Espace d'adressage du 286 |- ! Adresses en héxadécimal !! Zone de mémoire |- | 10 FFF0 à FF FFFF || Mémoire étendue, au-delà du premier mébioctet |- | 10 0000 à 10 FFEF || ''High Memory Area'' |- | 0 à 0F FFFF || Mémoire adressable en mode réel |} En conséquence, les applications peuvent utiliser plus d'un mébioctet de RAM, mais au prix d'une rétrocompatibilité imparfaite. Quelques programmes DOS ne marchaient pus à cause de ça. D'autres fonctionnaient convenablement et pouvaient adresser les 65 520 octets en plus. Pour résoudre ce problème, les carte mères ajoutaient un petit circuit relié au 21ème bit d'adresse, nommé A20 (pas d'erreur, les fils du bus d'adresse sont numérotés à partir de 0). Le circuit en question pouvait mettre à zéro le fil d'adresse, ou au contraire le laisser tranquille. En le forçant à 0, le calcul des adresses déborde comme dans le mode réel des 8086. Mais s'il ne le fait pas, la ''high memory area'' est adressable. Le circuit était une simple porte ET, qui combinait le 21ème bit d'adresse avec un '''signal de commande A20''' provenant d'ailleurs. Le signal de commande A20 était géré par le contrôleur de clavier, qui était soudé à la carte mère. Le contrôleur en question ne gérait pas que le clavier, il pouvait aussi RESET le processeur, alors gérer le signal de commande A20 n'était pas si problématique. Quitte à avoir un microcontrôleur sur la carte mère, autant s'en servir au maximum... La gestion du bus d'adresse étaitdonc gérable au clavier. D'autres carte mères faisaient autrement et préféraient ajouter un interrupteur, pour activer ou non la mise à 0 du 21ème bit d'adresse. : Il faut noter que le signal de commande A20 était mis à 1 en mode protégé, afin que le 21ème bit d'adresse soit activé. Le 386 ajouta deux registres de segment, les registres FS et GS, ainsi que le '''mode ''virtual 8086'''''. Ce dernier permet d’exécuter des programmes en mode réel alors que le système d'exploitation s'exécute en mode protégé. C'est une technique de virtualisation matérielle qui permet d'émuler un 8086 sur un 386. L'avantage est que la compatibilité avec les programmes anciens écrits pour le 8086 est conservée, tout en profitant de la protection mémoire. Tous les processeurs x86 qui ont suivi supportent ce mode virtuel 8086. ==La segmentation avec une table des segments== La '''segmentation avec une table des segments''' est apparue sur des processeurs assez anciens, le tout premier étant le Burrough 5000. Elle a ensuite été utilisée sur les processeurs x86 des PCs, à partir du 286 d'Intel. Elle est aujourd'hui abandonnée sur les jeux d'instruction x86. Tout comme la segmentation en mode réel, la segmentation attribue plusieurs segments par programmes ! Et cela a des répercutions sur la manière dont la traduction d'adresse est effectuée. ===Pourquoi plusieurs segments par programme ?=== L'utilité d'avoir plusieurs segments par programme n'est pas évidente, mais elle le devient quand on se plonge dans le passé. Dans le passé, les programmeurs devaient faire avec une quantité de mémoire limitée et il n'était pas rare que certains programmes utilisent plus de mémoire que disponible sur la machine. Mais les programmeurs concevaient leurs programmes en fonction. [[File:Overlay Programming.svg|vignette|upright=1|Overlay Programming]] L'idée était d'implémenter un système de mémoire virtuelle, mais émulé en logiciel, appelé l{{'}}'''''overlaying'''''. Le programme était découpé en plusieurs morceaux, appelés des ''overlays''. Les ''overlays'' les plus importants étaient en permanence en RAM, mais les autres étaient faisaient un va-et-vient entre RAM et disque dur. Ils étaient chargés en RAM lors de leur utilisation, puis sauvegardés sur le disque dur quand ils étaient inutilisés. Le va-et-vient des ''overlays'' entre RAM et disque dur était réalisé en logiciel, par le programme lui-même. Le matériel n'intervenait pas, comme c'est le cas avec la mémoire virtuelle. Avec la segmentation, un programme peut utiliser la technique des ''overlays'', mais avec l'aide du matériel. Il suffit de mettre chaque ''overlay'' dans son propre segment, et laisser la segmentation faire. Les segments sont swappés en tout ou rien : on doit swapper tout un segment en entier. L'intérêt est que la gestion du ''swapping'' est grandement facilitée, vu que c'est le système d'exploitation qui s'occupe de swapper les segments sur le disque dur ou de charger des segments en RAM. Pas besoin pour le programmeur de coder quoique ce soit. Par contre, cela demande l'intervention du programmeur, qui doit découper le programme en segments/''overlays'' de lui-même. Sans cela, la segmentation n'est pas très utile. L{{'}}''overlaying'' est une forme de '''segmentation à granularité grossière''', à savoir que le programme est découpé en segments de grande taille. L'usage classique est d'avoir un segment pour la pile, un autre pour le code exécutable, un autre pour le reste. Éventuellement, on peut découper les trois segments précédents en deux ou trois segments, rarement au-delà. Les segments sont alors peu nombreux, guère plus d'une dizaine par programme. D'où le terme de ''granularité grossière''. La '''segmentation à granularité fine''' pousse le concept encore plus loin. Avec elle, il y a idéalement un segment par entité manipulée par le programme, un segment pour chaque structure de donnée et/ou chaque objet. Par exemple, un tableau aura son propre segment, ce qui est idéal pour détecter les accès hors tableau. Pour les listes chainées, chaque élément de la liste aura son propre segment. Et ainsi de suite, chaque variable agrégée (non-primitive), chaque structure de donnée, chaque objet, chaque instance d'une classe, a son propre segment. Diverses fonctionnalités supplémentaires peuvent être ajoutées, ce qui transforme le processeur en véritable processeur orienté objet, mais passons ces détails pour le moment. Vu que les segments correspondent à des objets manipulés par le programme, on peut deviner que leur nombre évolue au cours du temps. En effet, les programmes modernes peuvent demander au système d'exploitation du rab de mémoire pour allouer une nouvelle structure de données. Avec la segmentation à granularité fine, cela demande d'allouer un nouveau segment à chaque nouvelle allocation mémoire, à chaque création d'une nouvelle structure de données ou d'un objet. De plus, les programmes peuvent libérer de la mémoire, en supprimant les structures de données ou objets dont ils n'ont plus besoin. Avec la segmentation à granularité fine, cela revient à détruire le segment alloué pour ces objets/structures de données. Le nombre de segments est donc dynamique, il change au cours de l'exécution du programme. ===Les tables de segments avec la segmentation=== La présence de plusieurs segments par programme a un impact sur la table des segments. Avec la relocation matérielle, elle conte nait un segment par programme. Chaque entrée, chaque ligne de la table des segment, mémorisait l'adresse de base, l'adresse limite, un bit de présence pour la mémoire virtuelle et des autorisations liées à la protection mémoire. Avec la segmentation, les choses sont plus compliquées, car il y a plusieurs segments par programme. Les entrées ne sont pas modifiées, mais elles sont organisées différemment. Avec cette forme de segmentation, la table des segments doit respecter plusieurs contraintes. Premièrement, il y a plusieurs segments par programmes. Deuxièmement, le nombre de segments est variable : certains programmes se contenteront d'un seul segment, d'autres de dizaine, d'autres plusieurs centaines, etc. Il y a typiquement deux manières de faire : soit utiliser une table des segments uniques, utiliser une table des segment par programme. Il est possible d'utiliser une table des segment unique qui mémorise tous les segments de tous les processus, système d'exploitation inclut. On parle alors de '''table des segment globale'''. Mais cette solution n'est pas utilisée avec la segmentation proprement dite. Elle est utilisée sur les architectures à capacité qu'on détaillera vers la fin du chapitre, dans une section dédiée. À la place, la segmentation utilise une table de segment par processus/programme, chacun ayant une '''table des segment locale'''. Dans les faits, les choses sont plus compliquées. Le système d'exploitation doit savoir où se trouvent les tables de segment locale pour chaque programme. Pour cela, il a besoin d'utiliser une table de segment globale, dont chaque entrée pointe non pas vers un segment, mais vers une table de segment locale. Lorsque l'OS effectue une commutation de contexte, il lit la table des segment globale, pour récupérer un pointeur vers celle-ci. Ce pointeur est alors chargé dans un registre du processeur, qui mémorise l'adresse de la table locale, ce qui sert lors des accès mémoire. Une telle organisation fait que les segments d'un processus/programme sont invisibles pour les autres, il y a une certaine forme de sécurité. Un programme ne connait que sa table de segments locale, il n'a pas accès directement à la table des segments globales. Tout accès mémoire se passera à travers la table de segment locale, il ne sait pas où se trouvent les autres tables de segment locales. Les processeurs x86 sont dans ce cas : ils utilisent une table de segment globale couplée à autant de table des segments qu'il y a de processus en cours d'exécution. La table des segments globale s'appelle la '''''Global Descriptor Table''''' et elle peut contenir 8192 segments maximum, ce qui permet le support de 8192 processus différents. Les tables de segments locales sont appelées les '''''Local Descriptor Table''''' et elles font aussi 8192 segments maximum, ce qui fait 8192 segments par programme maximum. Il faut noter que la table de segment globale peut mémoriser des pointeurs vers les routines d'interruption, certaines données partagées (le tampon mémoire pour le clavier) et quelques autres choses, qui n'ont pas leur place dans les tables de segment locales. ===La relocation avec la segmentation=== La table des segments locale mémorise les adresses de base et limite de chaque segment, ainsi que d'autres méta-données. Les informations pour un segment sont regroupés dans un '''descripteur de segment''', qui est codé sur plusieurs octets, et qui regroupe : adresse de base, adresse limite, bit de présence en RAM, méta-données de protection mémoire. La table des segments est un tableau dans lequel les descripteurs de segment sont placés les uns à la suite des autres en mémoire RAM. La table des segments est donc un tableau de segment. Les segments d'un programme sont numérotés, le nombre s'appelant un '''indice de segment''', appelé '''sélecteur de segment''' dans la terminologie Intel. L'indice de segment n'est autre que l'indice du segment dans ce tableau. [[File:Global Descriptor table.png|centre|vignette|upright=2|Table des segments locale.]] Il n'y a pas de registre de segment proprement dit, qui mémoriserait l'adresse de base. À la place, les segments sont adressés de manière indirecte. À la place, les registres de segment mémorisent des sélecteurs de segment. Ils sont utilisés pour lire l'adresse de base/limite dans la table de segment en mémoire RAM. Pour cela, un registre mémorise l'adresse de la table de segment locale, sa position en mémoire RAM. Toute lecture ou écriture se fait en deux temps, en deux accès mémoire, consécutifs. Premièrement, le numéro de segment est utilisé pour adresser la table des segment. La lecture récupère alors un pointeur vers ce segment. Deuxièmement, ce pointeur est utilisé pour faire la lecture ou écriture. Plus précisément, la première lecture récupère un descripteur de segment qui contient l'adresse de base, le pointeur voulu, mais aussi l'adresse limite et d'autres informations. [[File:Segmentation avec table des segments.png|centre|vignette|upright=2|Segmentation avec table des segments]] L'accès à la table des segments se fait automatiquement à chaque accès mémoire. La conséquence est que chaque accès mémoire demande d'en faire deux : un pour lire la table des segments, l'autre pour l'accès lui-même. Il s'agit en quelque sorte d'une forme d'adressage indirect mémoire. Un point important est que si le premier accès ne fait qu'une simple lecture dans un tableau, le second accès implique des calculs d'adresse. En effet, le premier accès récupère l'adresse de base du segment, mais le second accès sélectionne une donnée dans le segment, ce qui demande de calculer son adresse. L'adresse finale se déduit en combinant l'adresse de base avec un décalage (''offset'') qui donne la position de la donnée dans ce segment. L'indice de segment est utilisé pour récupérer l'adresse de base du segment. Une fois cette adresse de base connue, on lui additionne le décalage pour obtenir l'adresse finale. [[File:Table des segments.png|centre|vignette|upright=2|Traduction d'adresse avec une table des segments.]] Pour effectuer automatiquement l'accès à la table des segments, le processeur doit contenir un registre supplémentaire, qui contient l'adresse de la table de segment, afin de la localiser en mémoire RAM. Nous appellerons ce registre le '''pointeur de table'''. Le pointeur de table est combiné avec l'indice de segment pour adresser le descripteur de segment adéquat. [[File:Segment 2.svg|centre|vignette|upright=2|Traduction d'adresse avec une table des segments, ici appelée table globale des de"scripteurs (terminologie des processeurs Intel x86).]] Un point important est que la table des segments n'est pas accessible pour le programme en cours d'exécution. Il ne peut pas lire le contenu de la table des segments, et encore moins la modifier. L'accès se fait seulement de manière indirecte, en faisant usage des indices de segments, mais c'est un adressage indirect. Seul le système d'exploitation peut lire ou écrire la table des segments directement. Plus haut, j'ai dit que tout accès mémoire impliquait deux accès mémoire : un pour charger le descripteur de segment, un autre pour la lecture/écriture proprement dite. Cependant, cela aurait un impact bien trop grand sur les performances. Dans les faits, les processeurs avec segmentations intégraient un '''cache de descripteurs de segments''', pour limiter la casse. Quand un descripteur de segment est lu depuis la RAM, il est copié dans ce cache. Les accès ultérieurs accédent au descripteur dans le cache, pas besoin de passer par la RAM. L'intel 386 avait un cache de ce type. ===La protection mémoire : les accès hors-segments=== Comme avec la relocation matérielle, le processeur détecte les débordements de segment. Pour cela, il compare l'adresse logique accédée avec l'adresse limite, ou compare la taille limite avec le décalage. De nombreux processeurs, comme l'Intel 386, préféraient utiliser la taille du segment, pour une question d'optimisation. En effet, si on compare l'adresse finale avec l'adresse limite, on doit faire la relocation avant de comparer l'adresse relocatée. Mais en utilisant la taille, ce n'est pas le cas : on peut faire la comparaison avant, pendant ou après la relocation. Un détail à prendre en compte est la taille de la donnée accédée. Sans cela, la comparaison serait très simple : on vérifie si ''décalage <= taille du segment'', ou on compare des adresses de la même manière. Mais imaginez qu'on accède à une donnée de 4 octets : il se peut que l'adresse de ces 4 octets rentre dans le segment, mais que quelques octets débordent. Par exemple, les deux premiers octets sont dans le segment, mais pas les deux suivants. La vraie comparaison est alors : ''décalage + 4 octets <= taille du segment''. Mais il est possible de faire le calcul autrement, et quelques processeurs comme l'Intel 386 ne s'en sont pas privé. Il calculait la différence ''taille du segment - décalage'', et vérifiait le résultat. Le processeur gérait des données de 1, 2 et 4 octets, ce qui fait que le résultat devait être entre 0 et 3. Le processeur prenait le résultat de la soustraction, et vérifiait alors que les 30 bits de poids fort valaient bien 0. Il vérifiait aussi que les deux bits de poids faible avaient la bonne valeur. [[File:Vm7.svg|centre|vignette|upright=2|Traduction d'adresse avec vérification des accès hors-segment.]] Une nouveauté fait son apparition avec la segmentation : la '''gestion des droits d'accès'''. Par exemple, il est possible d'interdire d'exécuter le contenu d'un segment, ce qui fournit une protection contre certaines failles de sécurité ou certains virus. Lorsqu'on exécute une opération interdite, le processeur lève une exception matérielle, à charge du système d'exploitation de gérer la situation. Pour cela, chaque segment se voit attribuer un certain nombre d'autorisations d'accès qui indiquent si l'on peut lire ou écrire dedans, si celui-ci contient un programme exécutable, etc. Les autorisations pour chaque segment sont placées dans le descripteur de segment. Elles se résument généralement à quelques bits, qui indiquent si le segment est accesible en lecture/écriture ou exécutable. Le tout est souvent concaténé dans un ou deux '''octets de droits d'accès'''. L'implémentation de la protection mémoire dépend du CPU considéré. Les CPU microcodés peuvent en théorie utiliser le microcode. Lorsqu'une instruction mémoire s'exécute, le microcode effectue trois étapes : lire le descripteur de segment, faire les tests de protection mémoire, exécuter la lecture/écriture ou lever une exception. Létape de test est réalisée avec un ou plusieurs micro-branchements. Par exemple, une écriture va tester le bit R/W du descripteur, qui indique si on peut écrire dans le segment, en utilisant un micro-branchement. Le micro-branchement enverra vers une routine du microcode en cas d'erreur. Les tests de protection mémoire demandent cependant de tester beaucoup de conditions différentes. Par exemple, le CPU Intel 386 testait moins d'une dizaine de conditions pour certaines instructions. Il est cependant possible de faire plusieurs comparaisons en parallèle en rusant un peu. Il suffit de mémoriser les octets de droits d'accès dans un registre interne, de masquer les bits non-pertinents, et de faire une comparaison avec une constante adéquate, qui encode la valeur que doivent avoir ces bits. Une solution alternative utiliser un circuit combinatoire pour faire les tests de protection mémoire. Les tests sont alors faits en parallèles, plutôt qu'un par un par des micro-branchements. Par contre, le cout en matériel est assez important. Il faut ajouter ce circuit combinatoire, ce qui demande pas mal de circuits. ===La mémoire virtuelle avec la segmentation=== La mémoire virtuelle est une fonctionnalité souvent implémentée sur les processeurs qui gèrent la segmentation, alors que les processeurs avec relocation matérielle s'en passaient. Il faut dire que l'implémentation de la mémoire virtuelle est beaucoup plus simple avec la segmentation, comparé à la relocation matérielle. Le remplacement des registres de base par des sélecteurs de segment facilite grandement l'implémentation. Le problème de la mémoire virtuelle est que les segments peuvent être swappés sur le disque dur n'importe quand, sans que le programme soit prévu. Le swapping est réalisé par une interruption de l'OS, qui peut interrompre le programme n'importe quand. Et si un segment est swappé, le registre de base correspondant devient invalide, il point sur une adresse en RAM où le segment était, mais n'est plus. De plus, les segments peuvent être déplacés en mémoire, là encore n'importe quand et d'une manière invisible par le programme, ce qui fait que les registres de base adéquats doivent être modifiés. Si le programme entier est swappé d'un coup, comme avec la relocation matérielle simple, cela ne pose pas de problèmes. Mais dès qu'on utilise plusieurs registres de base par programme, les choses deviennent soudainement plus compliquées. Le problème est qu'il n'y a pas de mécanismes pour choisir et invalider le registre de base adéquat quand un segment est déplacé/swappé. En théorie, on pourrait imaginer des systèmes qui résolvent le problème au niveau de l'OS, mais tous ont des problèmes qui font que l'implémentation est compliquée ou que les performances sont ridicules. L'usage d'une table des segments accédée à chaque accès résout complètement le problème. La table des segments est accédée à chaque accès mémoire, elle sait si le segment est swappé ou non, chaque accès vérifie si le segment est en mémoire et quelle est son adresse de base. On peut changer le segment de place n'importe quand, le prochain accès récupérera des informations à jour dans la table des segments. L'implémentation de la mémoire virtuelle avec la segmentation est simple : il suffit d'ajouter un bit dans les descripteurs de segments, qui indique si le segment est swappé ou non. Tout le reste, la gestion de ce bit, du swap, et tout ce qui est nécessaire, est délégué au système d'exploitation. Lors de chaque accès mémoire, le processeur vérifie ce bit avant de faire la traduction d'adresse, et déclenche une exception matérielle si le bit indique que le segment est swappé. L'exception matérielle est gérée par l'OS. ===Le partage de segments=== Il est possible de partager un segment entre plusieurs applications. Cela peut servir pour partager des données entre deux programmes : un segment de données partagées est alors partagé entre deux programmes. Partager un segment de code est utile pour les bibliothèques partagées : la bibliothèque est placée dans un segment dédié, qui est partagé entre les programmes qui l'utilisent. Partager un segment de code est aussi utile quand plusieurs instances d'une même application sont lancés simultanément : le code n'ayant pas de raison de changer, celui-ci est partagé entre toutes les instances. Mais ce n'est là qu'un exemple. La première solution pour cela est de configurer les tables de segment convenablement. Le même segment peut avoir des droits d'accès différents selon les processus. Les adresses de base/limite sont identiques, mais les tables des segments ont alors des droits d'accès différents. Mais cette méthode de partage des segments a plusieurs défauts. Premièrement, les sélecteurs de segments ne sont pas les mêmes d'un processus à l'autre, pour un même segment. Le segment partagé peut correspondre au segment numéro 80 dans le premier processus, au segment numéro 1092 dans le second processus. Rien n'impose que les sélecteurs de segment soient les mêmes d'un processus à l'autre, pour un segment identique. Deuxièmement, les adresses limite et de base sont dupliquées dans plusieurs tables de segments. En soi, cette redondance est un souci mineur. Mais une autre conséquence est une question de sécurité : que se passe-t-il si jamais un processus a une table des segments corrompue ? Il se peut que pour un segment identique, deux processus n'aient pas la même adresse limite, ce qui peut causer des failles de sécurité. Un processus peut alors subir un débordement de tampon, ou tout autre forme d'attaque. [[File:Vm9.png|centre|vignette|upright=2|Illustration du partage d'un segment entre deux applications.]] Une seconde solution, complémentaire, utilise une table de segment globale, qui mémorise des segments partagés ou accessibles par tous les processus. Les défauts de la méthode précédente disparaissent avec cette technique : un segment est identifié par un sélecteur unique pour tous les processus, il n'y a pas de duplication des descripteurs de segment. Par contre, elle a plusieurs défauts. Le défaut principal est que cette table des segments est accessible par tous les processus, impossible de ne partager ses segments qu'avec certains pas avec les autres. Un autre défaut est que les droits d'accès à un segment partagé sont identiques pour tous les processus. Impossible d'avoir un segment partagé accessible en lecture seule pour un processus, mais accessible en écriture pour un autre. Il est possible de corriger ces défauts, mais nous en parlerons dans la section sur les architectures à capacité. ===L'extension d'adresse avec la segmentation=== L'extension d'adresse est possible avec la segmentation, de la même manière qu'avec la relocation matérielle. Il suffit juste que les adresses de base soient aussi grandes que le bus d'adresse. Mais il y a une différence avec la relocation matérielle : un même programme peut utiliser plus de mémoire qu'il n'y en a dans l'espace d'adressage. La raison est simple : un segment peut prendre tout l'espace d'adressage, et il y a plusieurs segments par programme. Pour donner un exemple, prenons un processeur 16 bits, qui peut adresser 64 kibioctets, associé à une mémoire de 4 mébioctets. Il est possible de placer le code machine dans les premiers 64k de la mémoire, la pile du programme dans les 64k suivants, le tas dans les 64k encore après, et ainsi de suite. Le programme dépasse donc les 64k de mémoire de l'espace d'adressage. Ce genre de chose est impossible avec la relocation, où un programme est limité par l'espace d'adressage. ===Le mode protégé des processeurs x86=== L'Intel 80286, aussi appelé 286, ajouta un mode de segmentation séparé du mode réel, qui ajoute une protection mémoire à la segmentation, ce qui lui vaut le nom de '''mode protégé'''. Dans ce mode, les registres de segment ne contiennent pas des adresses de base, mais des sélecteurs de segments qui sont utilisés pour l'accès à la table des segments en mémoire RAM. Le 286 bootait en mode réel, puis le système d'exploitation devait faire quelques manipulations pour passer en mode protégé. Le 286 était pensé pour être rétrocompatible au maximum avec le 80186. Mais les différences entre le 286 et le 8086 étaient majeures, au point que les applications devaient être réécrites intégralement pour profiter du mode protégé. Un mode de compatibilité permettait cependant aux applications destinées au 8086 de fonctionner, avec même de meilleures performances. Aussi, le mode protégé resta inutilisé sur la plupart des applications exécutées sur le 286. Vint ensuite le processeur 80386, renommé en 386 quelques années plus tard. Sur ce processeur, les modes réel et protégé sont conservés tel quel, à une différence près : toutes les adresses passent à 32 bits, qu'il s'agisse des adresses de base, limite ou des ''offsets''. Le processeur peut donc adresser un grand nombre de segments : 2^32, soit plus de 4 milliards. Les segments grandissent aussi et passent de 64 KB maximum à 4 gibioctets maximum. Mais surtout : le 386 ajouta le support de la pagination en plus de la segmentation. Ces modifications ont été conservées sur les processeurs 32 bits ultérieurs. Les processeurs x86 gèrent deux types de tables des segments : une table locale pour chaque processus, et une table globale partagée entre tous les processus. Il ne peut y avoir qu'une table locale d'active, vu que le processeur ne peut exécuter qu'un seul processus en même temps. Chaque table locale définit 8192 segments, pareil pour la table globale. La table globale est utilisée pour les segments du noyau et la mémoire partagée entre processus. Un défaut est qu'un segment partagé par la table globale est visible par tous les processus, avec les mêmes droits d'accès. Ce qui fait que cette méthode était peu utilisée en pratique. La table globale mémorise aussi des pointeurs vers les tables locales, avec un descripteur de segment par table locale. Sur les processeurs x86 32 bits, un descripteur de segment est organisé comme suit, pour les architectures 32 bits. On y trouve l'adresse de base et la taille limite, ainsi que de nombreux bits de contrôle. Le premier groupe de bits de contrôle est l'octet en bleu à droite. Il contient : * le bit P qui indique que l'entrée contient un descripteur valide, qu'elle n'est pas vide ; * deux bits DPL qui indiquent le niveau de privilège du segment (noyau, utilisateur, les deux intermédiaires spécifiques au x86) ; * un bit S qui précise si le segment est de type système (utiles pour l'OS) ou un segment de code/données. * un champ Type qui contient les bits suivants : ** un bit E qui indique si le segment contient du code exécutable ou non ; ** le bit RW qui indique s'il est en lecture seule ou non ;; ** Un bit A qui indique que le segment a récemment été accédé, information utile pour l'OS; ** un bit DC assez spécifiques. En haut à gauche, en bleu, on trouve deux bits : * Le bit G indique comment interpréter la taille contenue dans le descripteur : 0 si la taille est exprimée en octets, 1 si la taille est un nombre de pages de 4 kibioctets. Ce bit précise si on utilise la segmentation seule, ou combinée avec la pagination. * Le bit DB précise si l'on utilise des segments en mode de compatibilité 16 bits ou des segments 32 bits. [[File:SegmentDescriptor.svg|centre|vignette|upright=3|Segment Descriptor]] Les indices de segment sont appelés des sélecteurs de segment. Ils ont une taille de 16 bits, mais 3 bits sont utilisés pour encoder des méta-données. Le numéro de segment est donc codé sur 13 bits, ce qui permettait de gérer maximum 8192 segments par table de segment (locale ou globale). Les 16 bits sont organisés comme suit : * 13 bits pour le numéro du segment dans la table des segments, l'indice de segment proprement dit ; * un bit qui précise s'il faut accéder à la table des segments globale ou locale ; * deux bits qui indiquent le niveau de privilège de l'accès au segment (les 4 niveaux de protection, dont l'espace noyau et utilisateur). [[File:SegmentSelector.svg|centre|vignette|upright=1.5|Sélecteur de segment 16 bit.]] En tout, l'indice permet de gérer 8192 segments pour la table locale et 8192 segments de la table globale. ====La MMU du 386/486 : cache de segment, protection mémoire==== La MMU du 386 et celle du 486 étaient assez similaires. Elles étaient plus complexes que celle du 186 et du 286. Elle contenait un additionneur pour les calculs d'adresse et un comparateur pour tester si l'accès mémoire déborde d'un segment. Le test de débordement se faisait en parallèle du calcul de l'adresse finale, comme sur le 286. L'additionneur était un additionneur trois-opérandes, qui additionnait l'adresse à lire/écrire, l'adresse de base du segment, et un décalage intégré dans l'instruction. En clair, l'unité de segmentation n'était pas qu'une MMU, elle prenait en charge une partie du calcul d'adresse. L'avantage est que cela permettait de gérer les modes d'adressage "base + décalage" et "base + indice + décalage" très facilement, en utilisant un minimum de circuits. Une conséquence de cette organisation était que l'usage d'un décalage était gratuit. Par contre, dès qu'on utilisait l'adressage "Base + Indice", avec ou sans décalage, l'instruction prenait un cycle de plus à s'exécuter, parce qu'il fallait faire l'addition "Base + Indice" dans l'ALU entière. Le CPU 386 était le premier à implémenter la protection mémoire avec des segments. Pour cela, il intégrait une '''''Protection Test Unit''''', séparée du microcode, qu'on va abrévier en PTU. Précisément, il s'agissait d'un PLA (''Programmable Logic Array''), une sorte d'intermédiaire entre circuit logique fait sur mesure et mémoire ROM, qu'on a déjà abordé dans le chapitre sur les mémoires ROM. Mais cette unité ne faisait pas tout, le microcode était aussi impliqué. La PTU sera détaillée dans la section suivante. Pour améliorer les performances, le 386 et le 486 intégraient un '''cache de descripteurs de segment''', aussi appelé le cache de descripteurs. Lorsqu'un descripteur état chargé pour la première fois, il était copié dans le cache de descripteurs de segment. Les accès mémoire ultérieur lisaient le descripteur de segment depuis ce cache, pas depuis la table des segments en RAM. Le cache de descripteurs gère aussi bien les segments en mode réel qu'en mode protégé. Récupérer l'adresse de base depuis cache se fait un peu différemment en mode réel et protégé, mais le cache gère cela tout seul. Idem pour récupérer la taille/adresse limite. [[File:Microarchitecture du 386, avec focus sur la segmentation.png|centre|vignette|upright=2|Microarchitecture du 386 et du 486, avec focus sur la segmentation.]] En mode réel, la taille des segment est censée être limitée à 64 kibioctets. Mais le processeur ne vérifiait pas si cette limite était dépassée. À la place, il utilisait la limite précisée dans le cache de segments. Pire que ça : le cache de segment n'était pas réinitialisé quand on passe du mode réel au mode protégé, et réciproquement. Et cette propriété a été à l'origine de l''''''unreal mode'''''. Il s'agissait d'un mode réel amélioré, capable d'utiliser des segments de 4 gibioctets et des adresses de 32 bits. Passer en mode ''unreal'' pouvait se faire de deux manières. Il était possible d'altérer le cache de descripteur en utilisant l'instruction non-documentée LOADALL. Elle permettait de charger les descripteurs de segments dans le cache de descripteurs, avec une taille arbitraire. Une autre solution, beaucoup plus complexe sur le 386, demandait d'entrer en mode protégé pour configurer des segments de grande taille, de charger leurs descripteurs dans le cache de descripteur, puis de revenir en mode réel. En mode réel, les descripteurs dans le cache étaient encore disponibles et on pouvait les lire dans le cache. ====L'implémentation de la protection mémoire sur le 386==== La protection mémoire teste la valeur des bits P, S, X, E, R/W. Elle teste aussi les niveaux de privilège, avec deux bits DPL et CPL. En tout, le processeur pouvait tester 148 conditions différentes en parallèle dans la PTU. Cependant, les niveaux de privilèges étaient pré-traités par le microcode. Le microcode vérifiait aussi s'il y avait une erreur en terme d’anneau mémoire, avec par "exemple un segment en mode noyau accédé alors que le CPU est en espace utilisateur. Il fournissait alors un résultat sur deux bits, qui indiquait s'il y avait une erreur ou non, que la PTU utilisait. Mais toutes les conditions n'étaient pas pertinentes à un instant t. Par exemple, il est pertinent de vérifier si le bit R/W était cohérent si l'instruction à exécuter est une écriture. Mais il n'y a pas besoin de tester le bit E qui indique qu'un segment est exécutable ou non, pour une lecture. En tout, le processeur pouvait se retrouver dans 33 situations possibles, chacune demandant de tester un sous-ensemble des 148 conditions. Pour préciser quel sous-ensembles tester, la PTU recevait un code opération, généré par le microcode. Pour faire les tests de protection mémoire, le microcode avait une micro-opération nommée ''protection test operation'', qui envoyait les droits d'accès à la PTU. Lors de l'exécution d'une ''protection test operation'', le PLA recevait un descripteur de segment, lu depuis la mémoire RAM, ainsi qu'un code opération provenant du microcode. {|class="wikitable" |+ Entrée de la ''Protection Test Unit'' |- ! 15 - 14 !! 13 - 12 !! 11 !! 10 !! 9 !! 8 !! 7 !! 6 !! 5-0 |- | P1 , P2 || || P || S || X || E || R/W || A || Code opération |- | Niveaux de privilèges cohérents/erreur || || Segment présent en mémoire ou swappé || S || X || Segment exécutable ou non || Segment accesible en lecture/écriture || Segment récemment accédé || Code opération |} Il fournissait en sortie un bit qui indiquait si une erreur de protection mémoire avait eu lieu ou non. Il fournissait aussi une adresse de 12 bits, utilisée seulement en cas d'erruer. Elle pointait dans le microcode, sur un code levant une exception en cas d'erreur. Enfin, la PTU fournissait 4 bits pouvant être testés par un branchement dans le microcode. L'un d'entre eux demandait de tester s'il y a un accès hors-limite, les autres étaient assez peu reliés à la protection mémoire. Un détail est que le chargement du descripteur de segment est réalisé par une fonction dans le microcode. Elle est appliquée pour toutes les instructions ou situations qui demandent de faire un accès mémoire. Et les tests de protection mémoire sont réalisés dans cette fonction, pas après elle. Vu qu'il s'agit d'une fonction exécutée quelque soit l'instruction, le microcode doit transférer le code opération à cette fonction. Le microcode est pour cela associé à un registre interne, dans lequel le code opération est mémorisé, avant d'appeler la fonction. Le microcode a une micro-opération PTSAV (''Protection Save'') pour mémoriser le code opération dans ce registre. Dans la fonction qui charge le descripteur, une micro-opération PTOVRR (''Protection Override'') lit le code opération dans ce registre, et lance les tests nécessaires. Il faut noter que le PLA était certes plus rapide que de tester les conditions une par une, mais il était assez lent. La PTU mettait environ 3 cycles d'horloges pour rendre son résultat. Le microcode en profitait alors pour exécuter des micro-opérations durant ces 3 cycles d'attente. Par exemple, le microcode pouvait en profiter pour lire l'adresse de base dans le descripteur, si elle n'a pas été chargée avant (les descripteur était chargé en deux fois). Il fallait cependant que les trois micro-opérations soient valides, peu importe qu'il y ait une erreur de protection mémoire ou non. Ou du moins, elles produisaient un résultat qui n'est pas utilisé en cas d'erreur. Si ce n'était pas possible, le microcode ajoutait des NOP pendant ce temps d'attente de 3 cycles. Le bit A du descripteur de segment indique que le segment a récemment été accédé. Il est mis à jour après les tests de protection mémoire, quand ceux-ci indiquent que l'accès mémoire est autorisé. Le bit A est mis à 1 si la PTU l'autorise. Pour cela, la PTU utilise un des 4 bits de sortie mentionnés plus haut : l'un d'entre eux indique que le bit A doit être mis à 1. La mise à jour est ensuite réalisée par le microcode, qui utilise trois micro-opérations pour le mettre à jour. ====Le ''Hardware task switching'' des CPU x86==== Les systèmes d’exploitation modernes peuvent lancer plusieurs logiciels en même temps. Les logiciels sont alors exécutés à tour de rôle. Passer d'un programme à un autre est ce qui s'appelle une commutation de contexte. Lors d'une commutation de contexte, l'état du processeur est sauvegardé, afin que le programme stoppé puisse reprendre là où il était. Il arrivera un moment où le programme stoppé redémarrera et il doit reprendre dans l'état exact où il s'est arrêté. Deuxièmement, le programme à qui c'est le tour restaure son état. Cela lui permet de revenir là où il était avant d'être stoppé. Il y a donc une sauvegarde et une restauration des registres. Divers processeurs incorporent des optimisations matérielles pour rendre la commutation de contexte plus rapide. Ils peuvent sauvegarder et restaurer les registres du processeur automatiquement lors d'une interruption de commutation de contexte. Les registres sont sauvegardés dans des structures de données en mémoire RAM, appelées des '''contextes matériels'''. Sur les processeurs x86, il s'agit de la technique d{{'}}''Hardware Task Switching''. Fait intéressant, le ''Hardware Task Switching'' se base beaucoup sur les segments mémoires. Avec ''Hardware Task Switching'', chaque contexte matériel est mémorisé dans son propre segment mémoire, séparé des autres. Les segments pour les contextes matériels sont appelés des '''''Task State Segment''''' (TSS). Un TSS mémorise tous les registres généraux, le registre d'état, les pointeurs de pile, le ''program counter'' et quelques registres de contrôle du processeur. Par contre, les registres flottants ne sont pas sauvegardés, de même que certaines registres dit SIMD que nous n'avons pas encore abordé. Et c'est un défaut qui fait que le ''Hardware Task Switching'' n'est plus utilisé. Le programme en cours d'exécution connait l'adresse du TSS qui lui est attribué, car elle est mémorisée dans un registre appelé le '''''Task Register'''''. En plus de pointer sur le TSS, ce registre contient aussi les adresses de base et limite du segment en cours. Pour être plus précis, le ''Task Register'' ne mémorise pas vraiment l'adresse du TSS. À la place, elle mémorise le numéro du segment, le numéro du TSS. Le numéro est codé sur 16 bits, ce qui explique que 65 536 segments sont adressables. Les instructions LDR et STR permettent de lire/écrire ce numéro de segment dans le ''Task Register''. Le démarrage d'un programme a lieu automatiquement dans plusieurs circonstances. La première est une instruction de branchement CALL ou JMP adéquate. Le branchement fournit non pas une adresse à laquelle brancher, mais un numéro de segment qui pointe vers un TSS. Cela permet à une routine du système d'exploitation de restaurer les registres et de démarrer le programme en une seule instruction de branchement. Une seconde circonstance est une interruption matérielle ou une exception, mais nous la mettons de côté. Le ''Task Register'' est alors initialisé avec le numéro de segment fournit. S'en suit la procédure suivante : * Le ''Task Register'' est utilisé pour adresser la table des segments, pour récupérer un pointeur vers le TSS associé. * Le pointeur est utilisé pour une seconde lecture, qui adresse le TSS directement. Celle-ci restaure les registres du processeur. En clair, on va lire le ''TSS descriptor'' dans la GDT, puis on l'utilise pour restaurer les registres du processeur. [[File:Hardware Task Switching x86.png|centre|vignette|upright=2|Hardware Task Switching x86]] ===La segmentation sur les processeurs Burrough B5000 et plus=== Le Burrough B5000 est un très vieil ordinateur, commercialisé à partir de l'année 1961. Ses successeurs reprennent globalement la même architecture. C'était une machine à pile, doublé d'une architecture taguée, choses très rare de nos jours. Mais ce qui va nous intéresser dans ce chapitre est que ce processeur incorporait la segmentation, avec cependant une différence de taille : un programme avait accès à un grand nombre de segments. La limite était de 1024 segments par programme ! Il va de soi que des segments plus petits favorise l'implémentation de la mémoire virtuelle, mais complexifie la relocation et le reste, comme nous allons le voir. Le processeur gère deux types de segments : les segments de données et de procédure/fonction. Les premiers mémorisent un bloc de données, dont le contenu est laissé à l'appréciation du programmeur. Les seconds sont des segments qui contiennent chacun une procédure, une fonction. L'usage des segments est donc différent de ce qu'on a sur les processeurs x86, qui n'avaient qu'un segment unique pour l'intégralité du code machine. Un seul segment de code machine x86 est découpé en un grand nombre de segments de code sur les processeurs Burrough. La table des segments contenait 1024 entrées de 48 bits chacune. Fait intéressant, chaque entrée de la table des segments pouvait mémoriser non seulement un descripteur de segment, mais aussi une valeur flottante ou d'autres types de données ! Parler de table des segments est donc quelque peu trompeur, car cette table ne gère pas que des segments, mais aussi des données. La documentation appelaiat cette table la '''''Program Reference Table''''', ou PRT. La raison de ce choix quelque peu bizarre est que les instructions ne gèrent pas d'adresses proprement dit. Tous les accès mémoire à des données en-dehors de la pile passent par la segmentation, ils précisent tous un indice de segment et un ''offset''. Pour éviter d'allouer un segment pour chaque donnée, les concepteurs du processeur ont décidé qu'une entrée pouvait contenir directement la donnée entière à lire/écrire. La PRT supporte trois types de segments/descripteurs : les descripteurs de données, les descripteurs de programme et les descripteurs d'entrées-sorties. Les premiers décrivent des segments de données. Les seconds sont associés aux segments de procédure/fonction et sont utilisés pour les appels de fonction (qui passent, eux aussi, par la segmentation). Le dernier type de descripteurs sert pour les appels systèmes et les communications avec l'OS ou les périphériques. Chaque entrée de la PRT contient un ''tag'', une suite de bit qui indique le type de l'entrée : est-ce qu'elle contient un descripteur de segment, une donnée, autre. Les descripteurs contiennent aussi un ''bit de présence'' qui indique si le segment a été swappé ou non. Car oui, les segments pouvaient être swappés sur ce processeur, ce qui n'est pas étonnant vu que les segments sont plus petits sur cette architecture. Le descripteur contient aussi l'adresse de base du segment ainsi que sa taille, et diverses informations pour le retrouver sur le disque dur s'il est swappé. : L'adresse mémorisée ne faisait que 15 bits, ce qui permettait d'adresse 32 kibi-mots, soit 192 kibioctets de mémoire. Diverses techniques d'extension d'adressage étaient disponibles pour contourner cette limitation. Outre l'usage de l{{'}}''overlay'', le processeur et l'OS géraient aussi des identifiants d'espace d'adressage et en fournissaient plusieurs par processus. Les processeurs Borrough suivants utilisaient des adresses plus grandes, de 20 bits, ce qui tempérait le problème. [[File:B6700Word.jpg|centre|vignette|upright=2|Structure d'un mot mémoire sur le B6700.]] ==Les architectures à capacités== Les architectures à capacité utilisent la segmentation à granularité fine, mais ajoutent des mécanismes de protection mémoire assez particuliers, qui font que les architectures à capacité se démarquent du reste. Les architectures de ce type sont très rares et sont des processeurs assez anciens. Le premier d'entre eux était le Plessey System 250, qui date de 1969. Il fu suivi par le CAP computer, vendu entre les années 70 et 77. En 1978, le System/38 d'IBM a eu un petit succès commercial. En 1980, la Flex machine a aussi été vendue, mais à très peu d'examplaires, comme les autres architectures à capacité. Et enfin, en 1981, l'architecture à capacité la plus connue, l'Intel iAPX 432 a été commercialisée. Depuis, la seule architecture de ce type est en cours de développement. Il s'agit de l'architecture CHERI, dont la mise en projet date de 2014. ===Le partage de la mémoire sur les architectures à capacités=== Le partage de segment est grandement modifié sur les architectures à capacité. Avec la segmentation normale, il y a une table de segment par processus. Les conséquences sont assez nombreuses, mais la principale est que partager un segment entre plusieurs processus est compliqué. Les défauts ont été évoqués plus haut. Les sélecteurs de segments ne sont pas les mêmes d'un processus à l'autre, pour un même segment. De plus, les adresses limite et de base sont dupliquées dans plusieurs tables de segments, et cela peut causer des problèmes de sécurité si une table des segments est modifiée et pas l'autre. Et il y a d'autres problèmes, tout aussi importants. [[File:Partage des segments avec la segmentation.png|centre|vignette|upright=1.5|Partage des segments avec la segmentation]] À l'opposé, les architectures à capacité utilisent une table des segments unique pour tous les processus. La table des segments unique sera appelée dans de ce qui suit la '''table des segments globale''', ou encore la table globale. En conséquence, les adresses de base et limite ne sont présentes qu'en un seul exemplaire par segment, au lieu d'être dupliquées dans autant de processus que nécessaire. De plus, cela garantit que l'indice de segment est le même quel que soit le processus qui l'utilise. Un défaut de cette approche est au niveau des droits d'accès. Avec la segmentation normale, les droits d'accès pour un segment sont censés changer d'un processus à l'autre. Par exemple, tel processus a accès en lecture seule au segment, l'autre seulement en écriture, etc. Mais ici, avec une table des segments uniques, cela ne marche plus : incorporer les droits d'accès dans la table des segments ferait que tous les processus auraient les mêmes droits d'accès au segment. Et il faut trouver une solution. ===Les capacités sont des pointeurs protégés=== Pour éviter cela, les droits d'accès sont combinés avec les sélecteurs de segments. Les sélecteurs des segments sont remplacés par des '''capacités''', des pointeurs particuliers formés en concaténant l'indice de segment avec les droits d'accès à ce segment. Si un programme veut accéder à une adresse, il fournit une capacité de la forme "sélecteur:droits d'accès", et un décalage qui indique la position de l'adresse dans le segment. Il est impossible d'accéder à un segment sans avoir la capacité associée, c'est là une sécurité importante. Un accès mémoire demande que l'on ait la capacité pour sélectionner le bon segment, mais aussi que les droits d'accès en permettent l'accès demandé. Par contre, les capacités peuvent être passées d'un programme à un autre sans problème, les deux programmes pourront accéder à un segment tant qu'ils disposent de la capacité associée. [[File:Comparaison entre capacités et adresses segmentées.png|centre|vignette|upright=2.5|Comparaison entre capacités et adresses segmentées]] Mais cette solution a deux problèmes très liés. Au niveau des sélecteurs de segment, le problème est que les sélecteur ont une portée globale. Avant, l'indice de segment était interne à un programme, un sélecteur ne permettait pas d'accéder au segment d'un autre programme. Sur les architectures à capacité, les sélecteurs ont une portée globale. Si un programme arrive à forger un sélecteur qui pointe vers un segment d'un autre programme, il peut théoriquement y accéder, à condition que les droits d'accès le permettent. Et c'est là qu'intervient le second problème : les droits d'accès ne sont plus protégés par l'espace noyau. Les droits d'accès étaient dans la table de segment, accessible uniquement en espace noyau, ce qui empêchait un processus de les modifier. Avec une capacité, il faut ajouter des mécanismes de protection qui empêchent un programme de modifier les droits d'accès à un segment et de générer un indice de segment non-prévu. La première sécurité est qu'un programme ne peut pas créer une capacité, seul le système d'exploitation le peut. Les capacités sont forgées lors de l'allocation mémoire, ce qui est du ressort de l'OS. Pour rappel, un programme qui veut du rab de mémoire RAM peut demander au système d'exploitation de lui allouer de la mémoire supplémentaire. Le système d'exploitation renvoie alors un pointeurs qui pointe vers un nouveau segment. Le pointeur est une capacité. Il doit être impossible de forger une capacité, en-dehors d'une demande d'allocation mémoire effectuée par l'OS. Typiquement, la forge d'une capacité se fait avec des instructions du processeur, que seul l'OS peut éxecuter (pensez à une instruction qui n'est accessible qu'en espace noyau). La seconde protection est que les capacités ne peuvent pas être modifiées sans raison valable, que ce soit pour l'indice de segment ou les droits d'accès. L'indice de segment ne peut pas être modifié, quelqu'en soit la raison. Pour les droits d'accès, la situation est plus compliquée. Il est possible de modifier ses droits d'accès, mais sous conditions. Réduire les droits d'accès d'une capacité est possible, que ce soit en espace noyau ou utilisateur, pas l'OS ou un programme utilisateur, avec une instruction dédiée. Mais augmenter les droits d'accès, seul l'OS peut le faire avec une instruction précise, souvent exécutable seulement en espace noyau. Les capacités peuvent être copiées, et même transférées d'un processus à un autre. Les capacités peuvent être détruites, ce qui permet de libérer la mémoire utilisée par un segment. La copie d'une capacité est contrôlée par l'OS et ne peut se faire que sous conditions. La destruction d'une capacité est par contre possible par tous les processus. La destruction ne signifie pas que le segment est effacé, il est possible que d'autres processus utilisent encore des copies de la capacité, et donc le segment associé. On verra quand la mémoire est libérée plus bas. Protéger les capacités demande plusieurs conditions. Premièrement, le processeur doit faire la distinction entre une capacité et une donnée. Deuxièmement, les capacités ne peuvent être modifiées que par des instructions spécifiques, dont l'exécution est protégée, réservée au noyau. En clair, il doit y avoir une séparation matérielle des capacités, qui sont placées dans des registres séparés. Pour cela, deux solutions sont possibles : soit les capacités remplacent les adresses et sont dispersées en mémoire, soit elles sont regroupées dans un segment protégé. ====La liste des capacités==== Avec la première solution, on regroupe les capacités dans un segment protégé. Chaque programme a accès à un certain nombre de segments et à autant de capacités. Les capacités d'un programme sont souvent regroupées dans une '''liste de capacités''', appelée la '''''C-list'''''. Elle est généralement placée en mémoire RAM. Elle est ce qu'il reste de la table des segments du processus, sauf que cette table ne contient pas les adresses du segment, qui sont dans la table globale. Tout se passe comme si la table des segments de chaque processus est donc scindée en deux : la table globale partagée entre tous les processus contient les informations sur les limites des segments, la ''C-list'' mémorise les droits d'accès et les sélecteurs pour identifier chaque segment. C'est un niveau d'indirection supplémentaire par rapport à la segmentation usuelle. [[File:Architectures à capacité.png|centre|vignette|upright=2|Architectures à capacité]] La liste de capacité est lisible par le programme, qui peut copier librement les capacités dans les registres. Par contre, la liste des capacités est protégée en écriture. Pour le programme, il est impossible de modifier les capacités dedans, impossible d'en rajouter, d'en forger, d'en retirer. De même, il ne peut pas accéder aux segments des autres programmes : il n'a pas les capacités pour adresser ces segments. Pour protéger la ''C-list'' en écriture, la solution la plus utilisée consiste à placer la ''C-list'' dans un segment dédié. Le processeur gère donc plusieurs types de segments : les segments de capacité pour les ''C-list'', les autres types segments pour le reste. Un défaut de cette approche est que les adresses/capacités sont séparées des données. Or, les programmeurs mixent souvent adresses et données, notamment quand ils doivent manipuler des structures de données comme des listes chainées, des arbres, des graphes, etc. L'usage d'une ''C-list'' permet de se passer de la séparation entre espace noyau et utilisateur ! Les segments de capacité sont eux-mêmes adressés par leur propre capacité, avec une capacité par segment de capacité. Le programme a accès à la liste de capacité, comme l'OS, mais leurs droits d'accès ne sont pas les mêmes. Le programme a une capacité vers la ''C-list'' qui n'autorise pas l'écriture, l'OS a une autre capacité qui accepte l'écriture. Les programmes ne pourront pas forger les capacités permettant de modifier les segments de capacité. Une méthode alternative est de ne permettre l'accès aux segments de capacité qu'en espace noyau, mais elle est redondante avec la méthode précédente et moins puissante. ====Les capacités dispersées, les architectures taguées==== Une solution alternative laisse les capacités dispersées en mémoire. Les capacités remplacent les adresses/pointeurs, et elles se trouvent aux mêmes endroits : sur la pile, dans le tas. Comme c'est le cas dans les programmes modernes, chaque allocation mémoire renvoie une capacité, que le programme gére comme il veut. Il peut les mettre dans des structures de données, les placer sur la pile, dans des variables en mémoire, etc. Mais il faut alors distinguer si un mot mémoire contient une capacité ou une autre donnée, les deux ne devant pas être mixés. Pour cela, chaque mot mémoire se voit attribuer un certain bit qui indique s'il s'agit d'un pointeur/capacité ou d'autre chose. Mais cela demande un support matériel, ce qui fait que le processeur devient ce qu'on appelle une ''architecture à tags'', ou ''tagged architectures''. Ici, elles indiquent si le mot mémoire contient une adresse:capacité ou une donnée. [[File:Architectures à capacité sans liste de capacité.png|centre|vignette|upright=2|Architectures à capacité sans liste de capacité]] L'inconvénient est le cout en matériel de cette solution. Il faut ajouter un bit à chaque case mémoire, le processeur doit vérifier les tags avant chaque opération d'accès mémoire, etc. De plus, tous les mots mémoire ont la même taille, ce qui force les capacités à avoir la même taille qu'un entier. Ce qui est compliqué. ===Les registres de capacité=== Les architectures à capacité disposent de registres spécialisés pour les capacités, séparés pour les entiers. La raison principale est une question de sécurité, mais aussi une solution pragmatique au fait que capacités et entiers n'ont pas la même taille. Les registres dédiés aux capacités ne mémorisent pas toujours des capacités proprement dites. À la place, ils mémorisent des descripteurs de segment, qui contiennent l'adresse de base, limite et les droits d'accès. Ils sont utilisés pour la relocation des accès mémoire ultérieurs. Ils sont en réalité identiques aux registres de relocation, voire aux registres de segments. Leur utilité est d'accélérer la relocation, entre autres. Les processeurs à capacité ne gèrent pas d'adresses proprement dit, comme pour la segmentation avec plusieurs registres de relocation. Les accès mémoire doivent préciser deux choses : à quel segment on veut accéder, à quelle position dans le segment se trouve la donnée accédée. La première information se trouve dans le mal nommé "registre de capacité", la seconde information est fournie par l'instruction d'accès mémoire soit dans un registre (Base+Index), soit en adressage base+''offset''. Les registres de capacités sont accessibles à travers des instructions spécialisées. Le processeur ajoute des instructions LOAD/STORE pour les échanges entre table des segments et registres de capacité. Ces instructions sont disponibles en espace utilisateur, pas seulement en espace noyau. Lors du chargement d'une capacité dans ces registres, le processeur vérifie que la capacité chargée est valide, et que les droits d'accès sont corrects. Puis, il accède à la table des segments, récupère les adresses de base et limite, et les mémorise dans le registre de capacité. Les droits d'accès et d'autres méta-données sont aussi mémorisées dans le registre de capacité. En somme, l'instruction de chargement prend une capacité et charge un descripteur de segment dans le registre. Avec ce genre de mécanismes, il devient difficile d’exécuter certains types d'attaques, ce qui est un gage de sureté de fonctionnement indéniable. Du moins, c'est la théorie, car tout repose sur l'intégrité des listes de capacité. Si on peut modifier celles-ci, alors il devient facile de pouvoir accéder à des objets auxquels on n’aurait pas eu droit. ===Le recyclage de mémoire matériel=== Les architectures à capacité séparent les adresses/capacités des nombres entiers. Et cela facilite grandement l'implémentation de la ''garbage collection'', ou '''recyclage de la mémoire''', à savoir un ensemble de techniques logicielles qui visent à libérer la mémoire inutilisée. Rappelons que les programmes peuvent demander à l'OS un rab de mémoire pour y placer quelque chose, généralement une structure de donnée ou un objet. Mais il arrive un moment où cet objet n'est plus utilisé par le programme. Il peut alors demander à l'OS de libérer la portion de mémoire réservée. Sur les architectures à capacité, cela revient à libérer un segment, devenu inutile. La mémoire utilisée par ce segment est alors considérée comme libre, et peut être utilisée pour autre chose. Mais il arrive que les programmes ne libèrent pas le segment en question. Soit parce que le programmeur a mal codé son programme, soit parce que le compilateur n'a pas fait du bon travail ou pour d'autres raisons. Pour éviter cela, les langages de programmation actuels incorporent des '''''garbage collectors''''', des morceaux de code qui scannent la mémoire et détectent les segments inutiles. Pour cela, ils doivent identifier les adresses manipulées par le programme. Si une adresse pointe vers un objet, alors celui-ci est accessible, il sera potentiellement utilisé dans le futur. Mais si aucune adresse ne pointe vers l'objet, alors il est inaccessible et ne sera plus jamais utilisé dans le futur. On peut libérer les objets inaccessibles. Identifier les adresses est cependant très compliqué sur les architectures normales. Sur les processeurs modernes, les ''garbage collectors'' scannent la pile à la recherche des adresses, et considèrent tout mot mémoire comme une adresse potentielle. Mais les architectures à capacité rendent le recyclage de la mémoire très facile. Un segment est accessible si le programme dispose d'une capacité qui pointe vers ce segment, rien de plus. Et les capacités sont facilement identifiables : soit elles sont dans la liste des capacités, soit on peut les identifier à partir de leur ''tag''. Le recyclage de mémoire était parfois implémenté directement en matériel. En soi, son implémentation est assez simple, et peu être réalisé dans le microcode d'un processeur. Une autre solution consiste à utiliser un second processeur, spécialement dédié au recyclage de mémoire, qui exécute un programme spécialement codé pour. Le programme en question est placé dans une mémoire ROM, reliée directement à ce second processeur. ===L'intel iAPX 432=== Voyons maintenat une architecture à capacité assez connue : l'Intel iAPX 432. Oui, vous avez bien lu : Intel a bel et bien réalisé un processeur orienté objet dans sa jeunesse. La conception du processeur Intel iAPX 432 commença en 1975, afin de créer un successeur digne de ce nom aux processeurs 8008 et 8080. La conception du processeur Intel iAPX 432 commença en 1975, afin de créer un successeur digne de ce nom aux processeurs 8008 et 8080. Ce processeur s'est très faiblement vendu en raison de ses performances assez désastreuses et de défauts techniques certains. Par exemple, ce processeur était une machine à pile à une époque où celles-ci étaient tombées en désuétude, il ne pouvait pas effectuer directement de calculs avec des constantes entières autres que 0 et 1, ses instructions avaient un alignement bizarre (elles étaient bit-alignées). Il avait été conçu pour maximiser la compatibilité avec le langage ADA, un langage assez peu utilisé, sans compter que le compilateur pour ce processeur était mauvais. ====Les segments prédéfinis de l'Intel iAPX 432==== L'Intel iAPX432 gère plusieurs types de segments. Rien d'étonnant à cela, les Burrough géraient eux aussi plusieurs types de segments, à savoir des segments de programmes, des segments de données, et des segments d'I/O. C'est la même chose sur l'Intel iAPX 432, mais en bien pire ! Les segments de données sont des segments génériques, dans lequels on peut mettre ce qu'on veut, suivant les besoins du programmeur. Ils sont tous découpés en deux parties de tailles égales : une partie contenant les données de l'objet et une partie pour les capacités. Les capacités d'un segment pointent vers d'autres segments, ce qui permet de créer des structures de données assez complexes. La ligne de démarcation peut être placée n'importe où dans le segment, les deux portions ne sont pas de taille identique, elles ont des tailles qui varient de segment en segment. Il est même possible de réserver le segment entier à des données sans y mettre de capacités, ou inversement. Les capacités et données sont adressées à partir de la ligne de démarcation, qui sert d'adresse de base du segment. Suivant l'instruction utilisée, le processeur accède à la bonne portion du segment. Le processeur supporte aussi d'autres segments pré-définis, qui sont surtout utilisés par le système d'exploitation : * Des segments d'instructions, qui contiennent du code exécutable, typiquement un programme ou des fonctions, parfois des ''threads''. * Des segments de processus, qui mémorisent des processus entiers. Ces segments contiennent des capacités qui pointent vers d'autres segments, notamment un ou plusieurs segments de code, et des segments de données. * Des segments de domaine, pour les modules ou bibliothèques dynamiques. * Des segments de contexte, utilisés pour mémoriser l'état d'un processus, utilisés par l'OS pour faire de la commutation de contexte. * Des segments de message, utilisés pour la communication entre processus par l'intermédiaire de messages. * Et bien d'autres encores. Sur l'Intel iAPX 432, chaque processus est considéré comme un objet à part entière, qui a son propre segment de processus. De même, l'état du processeur (le programme qu'il est en train d’exécuter, son état, etc.) est stocké en mémoire dans un segment de contexte. Il en est de même pour chaque fonction présente en mémoire : elle était encapsulée dans un segment, sur lequel seules quelques manipulations étaient possibles (l’exécuter, notamment). Et ne parlons pas des appels de fonctions qui stockaient l'état de l'appelé directement dans un objet spécial. Bref, de nombreux objets système sont prédéfinis par le processeur : les objets stockant des fonctions, les objets stockant des processus, etc. L'Intel 432 possédait dans ses circuits un ''garbage collector'' matériel. Pour faciliter son fonctionnement, certains bits de l'objet permettaient de savoir si l'objet en question pouvait être supprimé ou non. ====Le support de la segmentation sur l'Intel iAPX 432==== La table des segments est une table hiérarchique, à deux niveaux. Le premier niveau est une ''Object Table Directory'', qui réside toujours en mémoire RAM. Elle contient des descripteurs qui pointent vers des tables secondaires, appelées des ''Object Table''. Il y a plusieurs ''Object Table'', typiquement une par processus. Plusieurs processus peuvent partager la même ''Object Table''. Les ''Object Table'' peuvent être swappées, mais pas l{{'}}''Object Table Directory''. Une capacité tient compte de l'organisation hiérarchique de la table des segments. Elle contient un indice qui précise quelle ''Object Table'' utiliser, et l'indice du segment dans cette ''Object Table''. Le premier indice adresse l{{'}}''Object Table Directory'' et récupère un descripteur de segment qui pointe sur la bonne ''Object Table''. Le second indice est alors utilisé pour lire l'adresse de base adéquate dans cette ''Object Table''. La capacité contient aussi des droits d'accès en lecture, écriture, suppression et copie. Il y a aussi un champ pour le type, qu'on verra plus bas. Au fait : les capacités étaient appelées des ''Access Descriptors'' dans la documentation officielle. Une capacité fait 32 bits, avec un octet utilisé pour les droits d'accès, laissant 24 bits pour adresser les segments. Le processeur gérait jusqu'à 2^24 segments/objets différents, pouvant mesurer jusqu'à 64 kibioctets chacun, ce qui fait 2^40 adresses différentes, soit 1024 gibioctets. Les 24 bits pour adresser les segments sont partagés moitié-moitié pour l'adressage des tables, ce qui fait 4096 ''Object Table'' différentes dans l{{'}}''Object Table Directory'', et chaque ''Object Table'' contient 4096 segments. ====Le jeu d'instruction de l'Intel iAPX 432==== L'Intel iAPX 432 est une machine à pile. Le jeu d'instruction de l'Intel iAPX 432 gère pas moins de 230 instructions différentes. Il gére deux types d'instructions : les instructions normales, et celles qui manipulent des segments/objets. Les premières permettent de manipuler des nombres entiers, des caractères, des chaînes de caractères, des tableaux, etc. Les secondes sont spécialement dédiées à la manipulation des capacités. Il y a une instruction pour copier une capacité, une autre pour invalider une capacité, une autre pour augmenter ses droits d'accès (instruction sécurisée, exécutable seulement sous certaines conditions), une autre pour restreindre ses droits d'accès. deux autres instructions créent un segment et renvoient la capacité associée, la première créant un segment typé, l'autre non. le processeur gérait aussi des instructions spécialement dédiées à la programmation système et idéales pour programmer des systèmes d'exploitation. De nombreuses instructions permettaient ainsi de commuter des processus, faire des transferts de messages entre processus, etc. Environ 40 % du micro-code était ainsi spécialement dédié à ces instructions spéciales. Les instructions sont de longueur variable et peuvent prendre n'importe quelle taille comprise entre 10 et 300 bits, sans vraiment de restriction de taille. Les bits d'une instruction sont regroupés en 4 grands blocs, 4 champs, qui ont chacun une signification particulière. * Le premier est l'opcode de l'instruction. * Le champ référence, doit être interprété différemment suivant la donnée à manipuler. Si cette donnée est un entier, un caractère ou un flottant, ce champ indique l'emplacement de la donnée en mémoire. Alors que si l'instruction manipule un objet, ce champ spécifie la capacité de l'objet en question. Ce champ est assez complexe et il est sacrément bien organisé. * Le champ format, n'utilise que 4 bits et a pour but de préciser si les données à manipuler sont en mémoire ou sur la pile. * Le champ classe permet de dire combien de données différentes l'instruction va devoir manipuler, et quelles seront leurs tailles. [[File:Encodage des instructions de l'Intel iAPX-432.png|centre|vignette|upright=2|Encodage des instructions de l'Intel iAPX-432.]] ====Le support de l'orienté objet sur l'Intel iAPX 432==== L'Intel 432 permet de définir des objets, qui correspondent aux classes des langages orientés objets. L'Intel 432 permet, à partir de fonctions définies par le programmeur, de créer des '''''domain objects''''', qui correspondent à une classe. Un ''domain object'' est un segment de capacité, dont les capacités pointent vers des fonctions ou un/plusieurs objets. Les fonctions et les objets sont chacun placés dans un segment. Une partie des fonctions/objets sont publics, ce qui signifie qu'ils sont accessibles en lecture par l'extérieur. Les autres sont privées, inaccessibles aussi bien en lecture qu'en écriture. L'exécution d'une fonction demande que le branchement fournisse deux choses : une capacité vers le ''domain object'', et la position de la fonction à exécuter dans le segment. La position permet de localiser la capacité de la fonction à exécuter. En clair, on accède au ''domain object'' d'abord, pour récupérer la capacité qui pointe vers la fonction à exécuter. Il est aussi possible pour le programmeur de définir de nouveaux types non supportés par le processeur, en faisant appel au système d'exploitation de l'ordinateur. Au niveau du processeur, chaque objet est typé au niveau de son object descriptor : celui-ci contient des informations qui permettent de déterminer le type de l'objet. Chaque type se voit attribuer un domain object qui contient toutes les fonctions capables de manipuler les objets de ce type et que l'on appelle le type manager. Lorsque l'on veut manipuler un objet d'un certain type, il suffit d'accéder à une capacité spéciale (le TCO) qui pointera dans ce type manager et qui précisera quel est l'objet à manipuler (en sélectionnant la bonne entrée dans la liste de capacité). Le type d'un objet prédéfini par le processeur est ainsi spécifié par une suite de 8 bits, tandis que le type d'un objet défini par le programmeur est défini par la capacité spéciale pointant vers son type manager. ===Conclusion=== Pour ceux qui veulent en savoir plus, je conseille la lecture de ce livre, disponible gratuitement sur internet (merci à l'auteur pour cette mise à disposition) : * [https://homes.cs.washington.edu/~levy/capabook/ Capability-Based Computer Systems]. Voici un document qui décrit le fonctionnement de l'Intel iAPX432 : * [https://homes.cs.washington.edu/~levy/capabook/Chapter9.pdf The Intel iAPX 432 ] ==La pagination== Avec la pagination, la mémoire est découpée en blocs de taille fixe, appelés des '''pages mémoires'''. La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Mais elles sont de taille fixe : on ne peut pas en changer la taille. C'est la différence avec les segments, qui sont de taille variable. Le contenu d'une page en mémoire fictive est rigoureusement le même que le contenu de la page correspondante en mémoire physique. L'espace d'adressage est découpé en '''pages logiques''', alors que la mémoire physique est découpée en '''pages physique''' de même taille. Les pages logiques correspondent soit à une page physique, soit à une page swappée sur le disque dur. Quand une page logique est associée à une page physique, les deux ont le même contenu, mais pas les mêmes adresses. Les pages logiques sont numérotées, en partant de 0, afin de pouvoir les identifier/sélectionner. Même chose pour les pages physiques, qui sont elles aussi numérotées en partant de 0. [[File:Principe de la pagination.png|centre|vignette|upright=2|Principe de la pagination.]] Pour information, le tout premier processeur avec un système de mémoire virtuelle était le super-ordinateur Atlas. Il utilisait la pagination, et non la segmentation. Mais il fallu du temps avant que la méthode de la pagination prenne son essor dans les processeurs commerciaux x86. Un point important est que la pagination implique une coopération entre OS et hardware, les deux étant fortement mélés. Une partie des informations de cette section auraient tout autant leur place dans le wikilivre sur les systèmes d'exploitation, mais il est plus simple d'en parler ici. ===La mémoire virtuelle : le ''swapping'' et le remplacement des pages mémoires=== Le système d'exploitation mémorise des informations sur toutes les pages existantes dans une '''table des pages'''. C'est un tableau où chaque ligne est associée à une page logique. Une ligne contient un bit ''Valid'' qui indique si la page logique associée est swappée sur le disque dur ou non, et la position de la page physique correspondante en mémoire RAM. Elle peut aussi contenir des bits pour la protection mémoire, et bien d'autres. Les lignes sont aussi appelées des ''entrées de la table des pages'' [[File:Gestionnaire de mémoire virtuelle - Pagination et swapping.png|centre|vignette|upright=2|Table des pages.]] De plus, le système d'exploitation conserve une '''liste des pages vides'''. Le nom est assez clair : c'est une liste de toutes les pages de la mémoire physique qui sont inutilisées, qui ne sont allouées à aucun processus. Ces pages sont de la mémoire libre, utilisable à volonté. La liste des pages vides est mise à jour à chaque fois qu'un programme réserve de la mémoire, des pages sont alors prises dans cette liste et sont allouées au programme demandeur. ====Les défauts de page==== Lorsque l'on veut traduire l'adresse logique d'une page mémoire, le processeur vérifie le bit ''Valid'' et l'adresse physique. Si le bit ''Valid'' est à 1 et que l'adresse physique est présente, la traduction d'adresse s'effectue normalement. Mais si ce n'est pas le cas, l'entrée de la table des pages ne contient pas de quoi faire la traduction d'adresse. Soit parce que la page est swappée sur le disque dur et qu'il faut la copier en RAM, soit parce que les droits d'accès ne le permettent pas, soit parce que la page n'a pas encore été allouée, etc. On fait alors face à un '''défaut de page'''. Un défaut de page a lieu quand la MMU ne peut pas associer l'adresse logique à une adresse physique, quelque qu'en soit la raison. Il existe deux types de défauts de page : mineurs et majeurs. Un '''défaut de page majeur''' a lieu quand on veut accéder à une page déplacée sur le disque dur. Un défaut de page majeur lève une exception matérielle dont la routine rapatriera la page en mémoire RAM. S'il y a de la place en mémoire RAM, il suffit d'allouer une page vide et d'y copier la page chargée depuis le disque dur. Mais si ce n'est par le cas, on va devoir faire de la place en RAM en déplaçant une page mémoire de la RAM vers le disque dur. Dans tous les cas, c'est le système d'exploitation qui s'occupe du chargement de la page, le processeur n'est pas impliqué. Une fois la page chargée, la table des pages est mise à jour et la traduction d'adresse peut recommencer. Si je dis recommencer, c'est car l'accès mémoire initial est rejoué à l'identique, sauf que la traduction d'adresse réussit cette fois-ci. Un '''défaut de page mineur''' a lieu dans des circonstances pas très intuitives : la page est en mémoire physique, mais l'adresse physique de la page n'est pas accessible. Par exemple, il est possible que des sécurités empêchent de faire la traduction d'adresse, pour des raisons de protection mémoire. Une autre raison est la gestion des adresses synonymes, qui surviennent quand on utilise des libraires partagées entre programmes, de la communication inter-processus, des optimisations de type ''copy-on-write'', etc. Enfin, une dernière raison est que la page a été allouée à un programme par le système d'exploitation, mais qu'il n'a pas encore attribué sa position en mémoire. Pour comprendre comment c'est possible, parlons rapidement de l'allocation paresseuse. Imaginons qu'un programme fasse une demande d'allocation mémoire et se voit donc attribuer une ou plusieurs pages logiques. L'OS peut alors réagir de deux manières différentes. La première est d'attribuer une page physique immédiatement, en même temps que la page logique. En faisant ainsi, on ne peut pas avoir de défaut mineur, sauf en cas de problème de protection mémoire. Cette solution est simple, on l'appelle l{{'}}'''allocation immédiate'''. Une autre solution consiste à attribuer une page logique, mais l'allocation de la page physique se fait plus tard. Elle a lieu la première fois que le programme tente d'écrire/lire dans la page physique. Un défaut mineur a lieu, et c'est lui qui force l'OS à attribuer une page physique pour la page logique demandée. On parle alors d{{'}}'''allocation paresseuse'''. L'avantage est que l'on gagne en performance si des pages logiques sont allouées mais utilisées, ce qui peut arriver. Une optimisation permise par l'existence des défauts mineurs est le '''''copy-on-write'''''. Le but est d'optimiser la copie d'une page logique dans une autre. L'idée est que la copie est retardée quand elle est vraiment nécessaire, à savoir quand on écrit dans la copie. Tant que l'on ne modifie pas la copie, les deux pages logiques, originelle et copiée, pointent vers la même page physique. A quoi bon avoir deux copies avec le même contenu ? Par contre, la page physique est marquée en lecture seule. La moindre écriture déclenche une erreur de protection mémoire, et un défaut mineur. Celui-ci est géré par l'OS, qui effectue alors la copie dans une nouvelle page physique. Je viens de dire que le système d'exploitation gère les défauts de page majeurs/mineurs. Un défaut de page déclenche une exception matérielle, qui passe la main au système d'exploitation. Le système d'exploitation doit alors déterminer ce qui a levé l'exception, notamment identifier si c'est un défaut de page mineur ou majeur. Pour cela, le processeur a un ou plusieurs '''registres de statut''' qui indique l'état du processeur, qui sont utiles pour gérer les défauts de page. Ils indiquent quelle est l'adresse fautive, si l'accès était une lecture ou écriture, si l'accès a eu lieu en espace noyau ou utilisateur (les espaces mémoire ne sont pas les mêmes), etc. Les registres en question varient grandement d'une architecture de processeur à l'autre, aussi on ne peut pas dire grand chose de plus sur le sujet. Le reste est de toute façon à voir dans un cours sur les systèmes d'exploitation. ====Le remplacement des pages==== Les pages virtuelles font référence soit à une page en mémoire physique, soit à une page sur le disque dur. Mais l'on ne peut pas lire une page directement depuis le disque dur. Les pages sur le disque dur doivent être chargées en RAM, avant d'être utilisables. Ce n'est possible que si on a une page mémoire vide, libre. Si ce n'est pas le cas, on doit faire de la place en swappant une page sur le disque dur. Les pages font ainsi une sorte de va et vient entre le fichier d'échange et la RAM, suivant les besoins. Tout cela est effectué par une routine d'interruption du système d'exploitation, le processeur n'ayant pas vraiment de rôle là-dedans. Supposons que l'on veuille faire de la place en RAM pour une nouvelle page. Dans une implémentation naïve, on trouve une page à évincer de la mémoire, qui est copiée dans le ''swapfile''. Toutes les pages évincées sont alors copiées sur le disque dur, à chaque remplacement. Néanmoins, cette implémentation naïve peut cependant être améliorée si on tient compte d'un point important : si la page a été modifiée depuis le dernier accès. Si le programme/processeur a écrit dans la page, alors celle-ci a été modifiée et doit être sauvegardée sur le ''swapfile'' si elle est évincée. Par contre, si ce n'est pas le cas, la page est soit initialisée, soit déjà présente à l'identique dans le ''swapfile''. Mais cette optimisation demande de savoir si une écriture a eu lieu dans la page. Pour cela, on ajoute un '''''dirty bit''''' à chaque entrée de la table des pages, juste à côté du bit ''Valid''. Il indique si une écriture a eu lieu dans la page depuis qu'elle a été chargée en RAM. Ce bit est mis à jour par le processeur, automatiquement, lors d'une écriture. Par contre, il est remis à zéro par le système d'exploitation, quand la page est chargée en RAM. Si le programme se voit allouer de la mémoire, il reçoit une page vide, et ce bit est initialisé à 0. Il est mis à 1 si la mémoire est utilisée. Quand la page est ensuite swappée sur le disque dur, ce bit est remis à 0 après la sauvegarde. Sur la majorité des systèmes d'exploitation, il est possible d'interdire le déplacement de certaines pages sur le disque dur. Ces pages restent alors en mémoire RAM durant un temps plus ou moins long, parfois en permanence. Cette possibilité simplifie la vie des programmeurs qui conçoivent des systèmes d'exploitation : essayez d'exécuter l'interruption pour les défauts de page alors que la page contenant le code de l'interruption est placée sur le disque dur ! Là encore, cela demande d'ajouter un bit dans chaque entrée de la table des pages, qui indique si la page est swappable ou non. Le bit en question s'appelle souvent le '''bit ''swappable'''''. ====Les algorithmes de remplacement des pages pris en charge par l'OS==== Le choix de la page doit être fait avec le plus grand soin et il existe différents algorithmes qui permettent de décider quelle page supprimer de la RAM. Leur but est de swapper des pages qui ne seront pas accédées dans le futur, pour éviter d'avoir à faire triop de va-et-vient entre RAM et ''swapfile''. Les données qui sont censées être accédées dans le futur doivent rester en RAM et ne pas être swappées, autant que possible. Les algorithmes les plus simples pour le choix de page à évincer sont les suivants. Le plus simple est un algorithme aléatoire : on choisit la page au hasard. Mine de rien, cet algorithme est très simple à implémenter et très rapide à exécuter. Il ne demande pas de modifier la table des pages, ni même d'accéder à celle-ci pour faire son choix. Ses performances sont surprenamment correctes, bien que largement en-dessous de tous les autres algorithmes. L'algorithme FIFO supprime la donnée qui a été chargée dans la mémoire avant toutes les autres. Cet algorithme fonctionne bien quand un programme manipule des tableaux de grande taille, mais fonctionne assez mal dans le cas général. L'algorithme LRU supprime la donnée qui été lue ou écrite pour la dernière fois avant toutes les autres. C'est théoriquement le plus efficace dans la majorité des situations. Malheureusement, son implémentation est assez complexe et les OS doivent modifier la table des pages pour l'implémenter. L'algorithme le plus utilisé de nos jours est l{{'}}'''algorithme NRU''' (''Not Recently Used''), une simplification drastique du LRU. Il fait la différence entre les pages accédées il y a longtemps et celles accédées récemment, d'une manière très binaire. Les deux types de page sont appelés respectivement les '''pages froides''' et les '''pages chaudes'''. L'OS swappe en priorité les pages froides et ne swappe de page chaude que si aucune page froide n'est présente. L'algorithme est simple : il choisit la page à évincer au hasard parmi une page froide. Si aucune page froide n'est présente, alors il swappe au hasard une page chaude. Pour implémenter l'algorithme NRU, l'OS mémorise, dans chaque entrée de la table des pages, si la page associée est froide ou chaude. Pour cela, il met à 0 ou 1 un bit dédié : le '''bit ''Accessed'''''. La différence avec le bit ''dirty'' est que le bit ''dirty'' est mis à jour uniquement lors des écritures, alors que le bit ''Accessed'' l'est aussi lors d'une lecture. Uen lecture met à 1 le bit ''Accessed'', mais ne touche pas au bit ''dirty''. Les écritures mettent les deux bits à 1. Implémenter l'algorithme NRU demande juste de mettre à jour le bit ''Accessed'' de chaque entrée de la table des pages. Et sur les architectures modernes, le processeur s'en charge automatiquement. A chaque accès mémoire, que ce soit en lecture ou en écriture, le processeur met à 1 ce bit. Par contre, le système d'exploitation le met à 0 à intervalles réguliers. En conséquence, quand un remplacement de page doit avoir lieu, les pages chaudes ont de bonnes chances d'avoir le bit ''Accessed'' à 1, alors que les pages froides l'ont à 0. Ce n'est pas certain, et on peut se trouver dans des cas où ce n'est pas le cas. Par exemple, si un remplacement a lieu juste après la remise à zéro des bits ''Accessed''. Le choix de la page à remplacer est donc imparfait, mais fonctionne bien en pratique. Tous les algorithmes précédents ont chacun deux variantes : une locale, et une globale. Avec la version locale, la page qui va être rapatriée sur le disque dur est une page réservée au programme qui est la cause du page miss. Avec la version globale, le système d'exploitation va choisir la page à virer parmi toutes les pages présentes en mémoire vive. ===La protection mémoire avec la pagination=== Avec la pagination, chaque page a des '''droits d'accès''' précis, qui permettent d'autoriser ou interdire les accès en lecture, écriture, exécution, etc. La table des pages mémorise les autorisations pour chaque page, sous la forme d'une suite de bits où chaque bit autorise/interdit une opération bien précise. En pratique, les tables de pages modernes disposent de trois bits : un qui autorise/interdit les accès en lecture, un qui autorise/interdit les accès en écriture, un qui autorise/interdit l'éxecution du contenu de la page. Le format exact de la suite de bits a cependant changé dans le temps sur les processeurs x86 modernes. Par exemple, avant le passage au 64 bits, les CPU et OS ne pouvaient pas marquer une page mémoire comme non-exécutable. C'est seulement avec le passage au 64 bits qu'a été ajouté un bit pour interdire l'exécution de code depuis une page. Ce bit, nommé '''bit NX''', est à 0 si la page n'est pas exécutable et à 1 sinon. Le processeur vérifie à chaque chargement d'instruction si le bit NX de page lue est à 1. Sinon, il lève une exception matérielle et laisse la main à l'OS. Une amélioration de cette protection est la technique dite du '''''Write XOR Execute''''', abréviée WxX. Elle consiste à interdire les pages d'être à la fois accessibles en écriture et exécutables. Il est possible de changer les autorisations en cours de route, ceci dit. Les premiers IBM 360 disposaient d'un mécanisme de protection mémoire totalement différent, sans registres limite/base. Ce mécanisme de protection attribue à chaque programme une '''clé de protection''', qui consiste en un nombre unique de 4 bits (chaque programme a donc une clé différente de ses collègues). La mémoire est fragmentée en blocs de même taille, de 2 kibioctets. Le processeur mémorise, pour chacun de ses blocs, la clé de protection du programme qui a réservé ce bloc. À chaque accès mémoire, le processeur compare la clé de protection du programme en cours d’exécution et celle du bloc de mémoire de destination. Si les deux clés sont différentes, alors un programme a effectué un accès hors des clous et il se fait sauvagement arrêter. ===La traduction d'adresse avec la pagination=== Comme dit plus haut, les pages sont numérotées, de 0 à une valeur maximale, afin de les identifier. Le numéro en question est appelé le '''numéro de page'''. Il est utilisé pour dire au processeur : je veux lire une donnée dans la page numéro 20, la page numéro 90, etc. Une fois qu'on a le numéro de page, on doit alors préciser la position de la donnée dans la page, appelé le '''décalage''', ou encore l{{'}}''offset''. Le numéro de page et le décalage se déduisent à partir de l'adresse, en divisant l'adresse par la taille de la page. Le quotient obtenu donne le numéro de la page, alors que le reste est le décalage. Les processeurs actuels utilisent tous des pages dont la taille est une puissance de deux, ce qui fait que ce calcul est fortement simplifié. Sous cette condition, le numéro de page correspond aux bits de poids fort de l'adresse, alors que le décalage est dans les bits de poids faible. Le numéro de page existe en deux versions : un numéro de page physique qui identifie une page en mémoire physique, et un numéro de page logique qui identifie une page dans la mémoire virtuelle. Traduire l'adresse logique en adresse physique demande de remplacer le numéro de la page logique en un numéro de page physique. [[File:Phycical address.JPG|centre|vignette|upright=2|Traduction d'adresse avec la pagination.]] ====Les tables des pages simples==== Dans le cas le plus simple, il n'y a qu'une seule table des pages, qui est adressée par les numéros de page logique. La table des pages est un vulgaire tableau d'adresses physiques, placées les unes à la suite des autres. Avec cette méthode, la table des pages a autant d'entrée qu'il y a de pages logiques en mémoire virtuelle. Accéder à la mémoire nécessite donc d’accéder d'abord à la table des pages en mémoire, de calculer l'adresse de l'entrée voulue, et d’y accéder. [[File:Table des pages.png|centre|vignette|upright=2|Table des pages.]] La table des pages est souvent stockée dans la mémoire RAM, son adresse est connue du processeur, mémorisée dans un registre spécialisé du processeur. Le processeur effectue automatiquement le calcul d'adresse à partir de l'adresse de base et du numéro de page logique. [[File:Address translation (32-bit).png|centre|vignette|upright=2|Address translation (32-bit)]] ====Les tables des pages inversées==== Sur certains systèmes, notamment sur les architectures 64 bits ou plus, le nombre de pages est très important. Sur les ordinateurs x86 récents, les adresses sont en pratique de 48 bits, les bits de poids fort étant ignorés en pratique, ce qui fait en tout 68 719 476 736 pages. Chaque entrée de la table des pages fait au minimum 48 bits, mais fait plus en pratique : partons sur 64 bits par entrée, soit 8 octets. Cela fait 549 755 813 888 octets pour la table des pages, soit plusieurs centaines de gibioctets ! Une table des pages normale serait tout simplement impraticable. Pour résoudre ce problème, on a inventé les '''tables des pages inversées'''. L'idée derrière celles-ci est l'inverse de la méthode précédente. La méthode précédente stocke, pour chaque page logique, son numéro de page physique. Les tables des pages inversées font l'inverse : elles stockent, pour chaque numéro de page physique, la page logique qui correspond. Avec cette méthode table des pages contient ainsi autant d'entrées qu'il y a de pages physiques. Elle est donc plus petite qu'avant, vu que la mémoire physique est plus petite que la mémoire virtuelle. Quand le processeur veut convertir une adresse virtuelle en adresse physique, la MMU recherche le numéro de page de l'adresse virtuelle dans la table des pages. Le numéro de l'entrée à laquelle se trouve ce morceau d'adresse virtuelle est le morceau de l'adresse physique. Pour faciliter le processus de recherche dans la page, la table des pages inversée est ce que l'on appelle une table de hachage. C'est cette solution qui est utilisée sur les processeurs Power PC. [[File:Table des pages inversée.jpg|centre|vignette|upright=2|Table des pages inversée.]] ====Les tables des pages multiples par espace d'adressage==== Dans les deux cas précédents, il y a une table des pages unique. Cependant, les concepteurs de processeurs et de systèmes d'exploitation ont remarqué que les adresses les plus hautes et/ou les plus basses sont les plus utilisées, alors que les adresses situées au milieu de l'espace d'adressage sont peu utilisées en raison du fonctionnement de la pile et du tas. Il y a donc une partie de la table des pages qui ne sert à rien et est utilisé pour des adresses inutilisées. C'est une source d'économie d'autant plus importante que les tables des pages sont de plus en plus grosses. Pour profiter de cette observation, les concepteurs d'OS ont décidé de découper l'espace d'adressage en plusieurs sous-espaces d'adressage de taille identique : certains localisés dans les adresses basses, d'autres au milieu, d'autres tout en haut, etc. Et vu que l'espace d'adressage est scindé en plusieurs parties, la table des pages l'est aussi, elle est découpée en plusieurs sous-tables. Si un sous-espace d'adressage n'est pas utilisé, il n'y a pas besoin d'utiliser de la mémoire pour stocker la table des pages associée. On ne stocke que les tables des pages pour les espaces d'adressage utilisés, ceux qui contiennent au moins une donnée. L'utilisation de plusieurs tables des pages ne fonctionne que si le système d'exploitation connaît l'adresse de chaque table des pages (celle de la première entrée). Pour cela, le système d'exploitation utilise une super-table des pages, qui stocke les adresses de début des sous-tables de chaque sous-espace. En clair, la table des pages est organisé en deux niveaux, la super-table étant le premier niveau et les sous-tables étant le second niveau. L'adresse est structurée de manière à tirer profit de cette organisation. Les bits de poids fort de l'adresse sélectionnent quelle table de second niveau utiliser, les bits du milieu de l'adresse sélectionne la page dans la table de second niveau et le reste est interprété comme un ''offset''. Un accès à la table des pages se fait comme suit. Les bits de poids fort de l'adresse sont envoyés à la table de premier niveau, et sont utilisés pour récupérer l'adresse de la table de second niveau adéquate. Les bits au milieu de l'adresse sont envoyés à la table de second niveau, pour récupérer le numéro de page physique. Le tout est combiné avec l{{'}}''offset'' pour obtenir l'adresse physique finale. [[File:Table des pages hiérarchique.png|centre|vignette|upright=2|Table des pages hiérarchique.]] On peut aussi aller plus loin et découper la table des pages de manière hiérarchique, chaque sous-espace d'adressage étant lui aussi découpé en sous-espaces d'adressages. On a alors une table de premier niveau, plusieurs tables de second niveau, encore plus de tables de troisième niveau, et ainsi de suite. Cela peut aller jusqu'à 5 niveaux sur les processeurs x86 64 bits modernes. On parle alors de '''tables des pages emboitées'''. Dans ce cours, la table des pages désigne l'ensemble des différents niveaux de cette organisation, toutes les tables inclus. Seules les tables du dernier niveau mémorisent des numéros de page physiques, les autres tables mémorisant des pointeurs, des adresses vers le début des tables de niveau inférieur. Un exemple sera donné plus bas, dans la section suivante. ====L'exemple des processeurs x86==== Pour rendre les explications précédentes plus concrètes, nous allons prendre l'exemple des processeur x86 anciens, de type 32 bits. Les processeurs de ce type utilisaient deux types de tables des pages : une table des page unique et une table des page hiérarchique. Les deux étaient utilisées dans cas séparés. La table des page unique était utilisée pour les pages larges et encore seulement en l'absence de la technologie ''physical adress extension'', dont on parlera plus bas. Les autres cas utilisaient une table des page hiérarchique, à deux niveaux, trois niveaux, voire plus. Une table des pages unique était utilisée pour les pages larges (de 2 mébioctets et plus). Pour les pages de 4 mébioctets, il y avait une unique table des pages, adressée par les 10 bits de poids fort de l'adresse, les bits restants servant comme ''offset''. La table des pages contenait 1024 entrées de 4 octets chacune, ce qui fait en tout 4 kibioctet pour la table des pages. La table des page était alignée en mémoire sur un bloc de 4 kibioctet (sa taille). [[File:X86 Paging 4M.svg|centre|vignette|upright=2|X86 Paging 4M]] Pour les pages de 4 kibioctets, les processeurs x86-32 bits utilisaient une table des page hiérarchique à deux niveaux. Les 10 bits de poids fort l'adresse adressaient la table des page maitre, appelée le directoire des pages (''page directory''), les 10 bits précédents servaient de numéro de page logique, et les 12 bits restants servaient à indiquer la position de l'octet dans la table des pages. Les entrées de chaque table des pages, mineure ou majeure, faisaient 32 bits, soit 4 octets. Vous remarquerez que la table des page majeure a la même taille que la table des page unique obtenue avec des pages larges (de 4 mébioctets). [[File:X86 Paging 4K.svg|centre|vignette|upright=2|X86 Paging 4K]] La technique du '''''physical adress extension''''' (PAE), utilisée depuis le Pentium Pro, permettait aux processeurs x86 32 bits d'adresser plus de 4 gibioctets de mémoire, en utilisant des adresses physiques de 64 bits. Les adresses virtuelles de 32 bits étaient traduites en adresses physiques de 64 bits grâce à une table des pages adaptée. Cette technologie permettait d'adresser plus de 4 gibioctets de mémoire au total, mais avec quelques limitations. Notamment, chaque programme ne pouvait utiliser que 4 gibioctets de mémoire RAM pour lui seul. Mais en lançant plusieurs programmes, on pouvait dépasser les 4 gibioctets au total. Pour cela, les entrées de la table des pages passaient à 64 bits au lieu de 32 auparavant. La table des pages gardait 2 niveaux pour les pages larges en PAE. [[File:X86 Paging PAE 2M.svg|centre|vignette|upright=2|X86 Paging PAE 2M]] Par contre, pour les pages de 4 kibioctets en PAE, elle était modifiée de manière à ajouter un niveau de hiérarchie, passant de deux niveaux à trois. [[File:X86 Paging PAE 4K.svg|centre|vignette|upright=2|X86 Paging PAE 4K]] En 64 bits, la table des pages est une table des page hiérarchique avec 5 niveaux. Seuls les 48 bits de poids faible des adresses sont utilisés, les 16 restants étant ignorés. [[File:X86 Paging 64bit.svg|centre|vignette|upright=2|X86 Paging 64bit]] ====Les circuits liés à la gestion de la table des pages==== En théorie, la table des pages est censée être accédée à chaque accès mémoire. Mais pour éviter d'avoir à lire la table des pages en mémoire RAM à chaque accès mémoire, les concepteurs de processeurs ont décidé d'implanter un cache dédié, le '''''translation lookaside buffer''''', ou TLB. Le TLB stocke au minimum de quoi faire la traduction entre adresse virtuelle et adresse physique, à savoir une correspondance entre numéro de page logique et numéro de page physique. Pour faire plus général, il stocke des entrées de la table des pages. [[File:MMU principle updated.png|centre|vignette|upright=2.0|MMU avec une TLB.]] Les accès à la table des pages sont gérés de deux façons : soit le processeur gère tout seul la situation, soit il délègue cette tâche au système d’exploitation. Sur les processeurs anciens, le système d'exploitation gère le parcours de la table des pages. Mais cette solution logicielle n'a pas de bonnes performances. D'autres processeurs gèrent eux-mêmes le défaut d'accès à la TLB et vont chercher d'eux-mêmes les informations nécessaires dans la table des pages. Ils disposent de circuits, les '''''page table walkers''''' (PTW), qui s'occupent eux-mêmes du défaut. Les ''page table walkers'' contiennent des registres qui leur permettent de faire leur travail. Le plus important est celui qui mémorise la position de la table des pages en mémoire RAM, dont nous avons parlé plus haut. Les PTW ont besoin, pour faire leur travail, de mémoriser l'adresse physique de la table des pages, ou du moins l'adresse de la table des pages de niveau 1 pour des tables des pages hiérarchiques. Mais d'autres registres existent. Toutes les informations nécessaires pour gérer les défauts de TLB sont stockées dans des registres spécialisés appelés des '''tampons de PTW''' (PTW buffers). ===L'abstraction matérielle des processus : une table des pages par processus=== [[File:Memoire virtuelle.svg|vignette|Mémoire virtuelle]] Il est possible d'implémenter l'abstraction matérielle des processus avec la pagination. En clair, chaque programme lancé sur l'ordinateur dispose de son propre espace d'adressage, ce qui fait que la même adresse logique ne pointera pas sur la même adresse physique dans deux programmes différents. Pour cela, il y a plusieurs méthodes. ====L'usage d'une table des pages unique avec un identifiant de processus dans chaque entrée==== La première solution n'utilise qu'une seule table des pages, mais chaque entrée est associée à un processus. Pour cela, chaque entrée contient un '''identifiant de processus''', un numéro qui précise pour quel processus, pour quel espace d'adressage, la correspondance est valide. La page des tables peut aussi contenir des entrées qui sont valides pour tous les processus en même temps. L'intérêt n'est pas évident, mais il le devient quand on se rappelle que le noyau de l'OS est mappé dans le haut de l'espace d'adressage. Et peu importe l'espace d'adressage, le noyau est toujours mappé de manière identique, les mêmes adresses logiques adressant la même adresse mémoire. En conséquence, les correspondances adresse physique-logique sont les mêmes pour le noyau, peu importe l'espace d'adressage. Dans ce cas, la correspondance est mémorisée dans une entrée, mais sans identifiant de processus. À la place, l'entrée contient un '''bit ''global''''', qui précise que cette correspondance est valide pour tous les processus. Le bit global accélère rapidement la traduction d'adresse pour l'accès au noyau. Un défaut de cette méthode est que le partage d'une page entre plusieurs processus est presque impossible. Impossible de partager une page avec seulement certains processus et pas d'autres : soit on partage une page avec tous les processus, soit on l'alloue avec un seul processus. ====L'usage de plusieurs tables des pages==== Une solution alternative, plus simple, utilise une table des pages par processus lancé sur l'ordinateur, une table des pages unique par espace d'adressage. À chaque changement de processus, le registre qui mémorise la position de la table des pages est modifié pour pointer sur la bonne. C'est le système d'exploitation qui se charge de cette mise à jour. Avec cette méthode, il est possible de partager une ou plusieurs pages entre plusieurs processus, en configurant les tables des pages convenablement. Les pages partagées sont mappées dans l'espace d'adressage de plusieurs processus, mais pas forcément au même endroit, pas forcément dans les mêmes adresses logiques. On peut placer la page partagée à l'adresse logique 0x0FFF pour un processus, à l'adresse logique 0xFF00 pour un autre processus, etc. Par contre, les entrées de la table des pages pour ces adresses pointent vers la même adresse physique. [[File:Vm5.png|centre|vignette|upright=2|Tables des pages de plusieurs processus.]] ===La taille des pages=== La taille des pages varie suivant le processeur et le système d'exploitation et tourne souvent autour de 4 kibioctets. Les processeurs actuels gèrent plusieurs tailles différentes pour les pages : 4 kibioctets par défaut, 2 mébioctets, voire 1 à 4 gibioctets pour les pages les plus larges. Les pages de 4 kibioctets sont les pages par défaut, les autres tailles de page sont appelées des ''pages larges''. La taille optimale pour les pages dépend de nombreux paramètres et il n'y a pas de taille qui convienne à tout le monde. Certaines applications gagnent à utiliser des pages larges, d'autres vont au contraire perdre drastiquement en performance en les utilisant. Le désavantage principal des pages larges est qu'elles favorisent la fragmentation mémoire. Si un programme veut réserver une portion de mémoire, pour une structure de donnée quelconque, il doit réserver une portion dont la taille est multiple de la taille d'une page. Par exemple, un programme ayant besoin de 110 kibioctets allouera 28 pages de 4 kibioctets, soit 120 kibioctets : 2 kibioctets seront perdus. Par contre, avec des pages larges de 2 mébioctets, on aura une perte de 2048 - 110 = 1938 kibioctets. En somme, des morceaux de mémoire seront perdus, car les pages sont trop grandes pour les données qu'on veut y mettre. Le résultat est que le programme qui utilise les pages larges utilisent plus de mémoire et ce d'autant plus qu'il utilise des données de petite taille. Un autre désavantage est qu'elles se marient mal avec certaines techniques d'optimisations de type ''copy-on-write''. Mais l'avantage est que la traduction des adresses est plus performante. Une taille des pages plus élevée signifie moins de pages, donc des tables des pages plus petites. Et des pages des tables plus petites n'ont pas besoin de beaucoup de niveaux de hiérarchie, voire peuvent se limiter à des tables des pages simples, ce qui rend la traduction d'adresse plus simple et plus rapide. De plus, les programmes ont une certaine localité spatiale, qui font qu'ils accèdent souvent à des données proches. La traduction d'adresse peut alors profiter de systèmes de mise en cache dont nous parlerons dans le prochain chapitre, et ces systèmes de cache marchent nettement mieux avec des pages larges. Il faut noter que la taille des pages est presque toujours une puissance de deux. Cela a de nombreux avantages, mais n'est pas une nécessité. Par exemple, le tout premier processeur avec de la pagination, le super-ordinateur Atlas, avait des pages de 3 kibioctets. L'avantage principal est que la traduction de l'adresse physique en adresse logique est trivial avec une puissance de deux. Cela garantit que l'on peut diviser l'adresse en un numéro de page et un ''offset'' : la traduction demande juste de remplacer les bits de poids forts par le numéro de page voulu. Sans cela, la traduction d'adresse implique des divisions et des multiplications, qui sont des opérations assez couteuses. ===Les entrées de la table des pages=== Avant de poursuivre, faisons un rapide rappel sur les entrées de la table des pages. Nous venons de voir que la table des pages contient de nombreuses informations : un bit ''valid'' pour la mémoire virtuelle, des bits ''dirty'' et ''accessed'' utilisés par l'OS, des bits de protection mémoire, un bit ''global'' et un potentiellement un identifiant de processus, etc. Étudions rapidement le format de la table des pages sur un processeur x86 32 bits. * Elle contient d'abord le numéro de page physique. * Les bits AVL sont inutilisés et peuvent être configurés à loisir par l'OS. * Le bit G est le bit ''global''. * Le bit PS vaut 0 pour une page de 4 kibioctets, mais est mis à 1 pour une page de 4 mébioctets dans le cas où le processus utilise des pages larges. * Le bit D est le bit ''dirty''. * Le bit A est le bit ''accessed''. * Le bit PCD indique que la page ne peut pas être cachée, dans le sens où le processeur ne peut copier son contenu dans le cache et doit toujours lire ou écrire cette page directement dans la RAM. * Le bit PWT indique que les écritures doivent mettre à jour le cache et la page en RAM (dans le chapitre sur le cache, on verra qu'il force le cache à se comporter comme un cache ''write-through'' pour cette page). * Le bit U/S précise si la page est accessible en mode noyau ou utilisateur. * Le bit R/W indique si la page est accessible en écriture, toutes les pages sont par défaut accessibles en lecture. * Le bit P est le bit ''valid''. [[File:PDE.png|centre|vignette|upright=2.5|Table des pages des processeurs Intel 32 bits.]] ==Comparaison des différentes techniques d'abstraction mémoire== Pour résumer, l'abstraction mémoire permet de gérer : la relocation, la protection mémoire, l'isolation des processus, la mémoire virtuelle, l'extension de l'espace d'adressage, le partage de mémoire, etc. Elles sont souvent implémentées en même temps. Ce qui fait qu'elles sont souvent confondues, alors que ce sont des concepts sont différents. Ces liens sont résumés dans le tableau ci-dessous. {|class="wikitable" |- ! ! colspan="5" | Avec abstraction mémoire ! rowspan="2" | Sans abstraction mémoire |- ! ! Relocation matérielle ! Segmentation en mode réel (x86) ! Segmentation, général ! Architectures à capacités ! Pagination |- ! Abstraction matérielle des processus | colspan="4" | Oui, relocation matérielle | Oui, liée à la traduction d'adresse | Emulée via relocation logicielle au démarrage du programme. |- ! Mémoire virtuelle | colspan="2" | Non, sauf émulation logicielle | colspan="3" | Oui, gérée par le processeur et l'OS | Non, sauf émulation logicielle |- ! Extension de l'espace d'adressage | colspan="2" | Oui : registre de base élargi | colspan="2" | Oui : adresse de base élargie dans la table des segments | ''Physical Adress Extension'' des processeurs 32 bits | Commutation de banques |- ! Protection mémoire | Registre limite | Aucune | colspan="2" | Registre limite, droits d'accès aux segments | Gestion des droits d'accès aux pages | Possible, méthodes variées |- ! Partage de mémoire | colspan="2" | Non | colspan="2" | Segment partagés | Pages partagées | Possible, méthodes variées |} ===Les différents types de segmentation=== La segmentation regroupe plusieurs techniques franchement différentes, qui auraient gagné à être nommées différemment. La principale différence est l'usage de registres de relocation versus des registres de sélecteurs de segments. L'usage de registres de relocation est le fait de la relocation matérielle, mais aussi de la segmentation en mode réel des CPU x86. Par contre, l'usage de sélecteurs de segments est le fait des autres formes de segmentation, architectures à capacité inclues. La différence entre les deux est le nombre de segments. L'usage de registres de relocation fait que le CPU ne gère qu'un petit nombre de segments de grande taille. La mémoire virtuelle est donc rarement implémentée vu que swapper des segments de grande taille est trop long, l'impact sur les performances est trop important. Sans compter que l'usage de registres de base se marie très mal avec la mémoire virtuelle. Vu qu'un segment peut être swappé ou déplacée n'importe quand, il faut invalider les registres de base au moment du swap/déplacement, ce qui n'est pas chose aisée. Aucun processeur ne gère cela, les méthodes pour n'existent tout simplement pas. L'usage de registres de base implique que la mémoire virtuelle est absente. La protection mémoire est aussi plus limitée avec l'usage de registres de relocation. Elle se limite à des registres limite, mais la gestion des droits d'accès est limitée. En théorie, la segmentation en mode réel pourrait implémenter une version limitée de protection mémoire, avec une protection de l'espace exécutable. Mais ca n'a jamais été fait en pratique sur les processeurs x86. Le partage de la mémoire est aussi difficile sur les architectures avec des registres de base. L'absence de table des segments fait que le partage d'un segment est basiquement impossible sans utiliser des méthodes complétement tordues, qui ne sont jamais implémentées en pratique. ===Segmentation versus pagination=== Par rapport à la pagination, la segmentation a des avantages et des inconvénients. Tous sont liés aux propriétés des segments et pages : les segments sont de grande taille et de taille variable, les pages sont petites et de taille fixe. L'avantage principal de la segmentation est sa rapidité. Le fait que les segments sont de grande taille fait qu'on a pas besoin d'équivalent aux tables des pages inversée ou multiple, juste d'une table des segments toute simple. De plus, les échanges entre table des pages/segments et registres sont plus rares avec la segmentation. Par exemple, si un programme utilise un segment de 2 gigas, tous les accès dans le segment se feront avec une seule consultation de la table des segments. Alors qu'avec la pagination, il faudra une consultation de la table des pages chaque bloc de 4 kibioctet, au minimum. Mais les désavantages sont nombreux. Le système d'exploitation doit agencer les segments en RAM, et c'est une tâche complexe. Le fait que les segments puisse changer de taille rend le tout encore plus complexe. Par exemple, si on colle les segments les uns à la suite des autres, changer la taille d'un segment demande de réorganiser tous les segments en RAM, ce qui demande énormément de copies RAM-RAM. Une autre possibilité est de laisser assez d'espace entre les segments, mais cet espace est alors gâché, dans le sens où on ne peut pas y placer un nouveau segment. Swapper un segment est aussi très long, vu que les segments sont de grande taille, alors que swapper une page est très rapide. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=L'espace d'adressage du processeur | prevText=L'espace d'adressage du processeur | next=Les méthodes de synchronisation entre processeur et périphériques | nextText=Les méthodes de synchronisation entre processeur et périphériques }} </noinclude> n5aqd3guzod00n87z88qbbsz8njt1l2 Fonctionnement d'un ordinateur/Les architectures à parallélisme de données 0 65960 771171 763338 2026-08-22T01:34:25Z Mewtow 31375 /* La performance des processeurs SIMD */ 771171 wikitext text/x-wiki Nous allons maintenant aborder le parallélisme de données, qui consiste à traiter des données différentes en parallèle. De nombreuses situations s'y prêtent relativement bien : traitement d'image, manipulation de sons, vidéo, rendu 3d, etc. Mais pour exploiter ce parallélisme, il a fallu concevoir des processeurs adaptés. Les architectures les plus simples exécutent une instruction sur plusieurs données en parallèle, à l'intérieur d'un processeur. Ces architectures exploitent le parallélisme de données au niveau de l'unité de calcul, celle-ci pouvant exécuter un même calcul sur des données différentes en parallèle. Si on omet quelques exceptions, on peut classer ces architectures dans plusieurs catégories principales : les processeurs à instructions SIMD, les processeurs à instructions SIMD à prédicat, les processeurs SIMT, les processeurs vectoriels. Si les processeurs vectoriels sont assez rares de nos jours, tous les processeurs récents incorporent des instructions SIMD. Quant au SIMT, il est utilisé sur les cartes graphiques modernes. ==Les instructions SIMD : les points communs entre tous les processeurs SIMD== Les processeurs modernes fournissent presque tous des '''instructions SIMD''', qui sont capables de traiter plusieurs éléments en parallèle. Elles travaillent sur des nombres entiers ou flottants regroupés dans ce qu'on appelle un ''vecteur'', qui sont eux-mêmes stockés dans des registres spécialisés. En général, tous les vecteurs ont une taille fixe, peu importe leur contenu. Cela implique que suivant la taille des données à manipuler, on pourra en placer plus ou moins dans un vecteur. Par exemple, un vecteur de 128 bits pourra contenir 4 entiers de 32 bits, 4 flottants 32 bits, ou 8 entiers de 16 bits. [[File:Vector register.png|centre|vignette|upright=2|Contenu d'un vecteur en fonction du type de données utilisé.]] Les vecteurs sont stockés dans des '''registres vectoriels''', aussi appelés '''registres SIMD'''. Un registre vectoriel peut contenir un vecteur complet, pas plus. En conséquence, ils ont une taille assez importante : ils font généralement 128,,256, voire 512 bits, comparé aux 32/64 bits des registres scalaires. Le résultat est que les processeurs SIMD modernes ont des registres SIMD séparés des registres entiers/flottants normaux. Un défaut de cette organisation est que les registres vectoriels sont des registres en plus, qui doivent être sauvegardés lors des commutations de contexte, lors des interruptions, lors des appels systèmes, etc. {| |+ Comparaison entre un processeur sans registres vectoriels, et avec registres vectoriels. |[[File:Non-SIMD cpu diagram1.svg|vignette|upright=1.5|CPU Non-SIMD]] |[[File:SIMD cpu diagram1.svg|vignette|upright=1.5|CPU SIMD]] |} Les instructions SIMD peuvent être rassemblées en deux grands groupes : les horizontales et les verticales. Les '''instructions SIMD verticales''' travaillent en parallèle sur les éléments qui sont "à la même place" dans deux vecteurs. Elles peuvent additionner ou multiplier deux vecteurs, par exemple. Pour prendre l'exemple d'une instruction d'addition vectorielle, celle-ci va additionner ensemble les données qui sont à la même place dans deux vecteurs, et placer le résultat dans un autre vecteur, à la même place. Les '''instructions SIMD horizontales''' partent d'un vecteur et ont pour résultat un simple nombre. Elles peuvent calculer la somme ou le produit des éléments d'un vecteur, renvoyer le nombre d’éléments nuls dans un vecteur, etc. [[File:Instructions SIMD.png|centre|vignette|upright=2|Instructions SIMD]] Les instructions SIMD sont difficilement utilisables dans des langages de haut niveau et c'est donc au compilateur de traduire un programme avec des instructions vectorielles. Les transformations qui permettent de traduire des morceaux de programmes en instructions vectorielles (déroulage de boucles, strip-mining) portent le nom de vectorisation. ===Les instructions SIMD arithmétiques verticales=== Les instructions SIMD sont essentiellement des instructions arithmétiques, comme des additions, des soustractions, des multiplications, éventuellement des opérations bit à bit, et quelques autres. Suivant la taille des données, et le type de celle-si, on devra effecteur des instructions différentes. Par exemple, on devra utiliser deux instructions d'addition différentes suivant qu'on manipule des flottants 64 bits ou des entiers 32 bits. De même, l'instruction pour additionner des vecteurs d'entiers de 16 bits sera différente de celle manipulant des vecteurs d'entiers de 32 bits. L'addition et la multiplication peuvent générer des débordements d'entier. Pour les instructions non-SIMD, le débordement d'une addition est géré par un bit de retenue, stocké dans le registre d'état. Mais pour les instructions SIMD, ce n'est pas une solution très facile à implémenter. À la place, les additions et soustractions utilisent généralement l'arithmétique saturée, à savoir que lorsqu'une addition déborde, le résultat est la valeur maximale représentable dans un entier. Pour la multiplication, les instructions non-SIMD génère un résultat qui est codé sur le double du nombre de bits. Une multiplication de deux nombres 64 bits donnera un résultat sur 128 bits, par exemple. Pour gérer cela, les multiplications non-SIMD stockent ce résultat dans deux registres, ou ne conservent que les 64 bits de poids faible. D'autres solutions sont possibles, mais ces deux là sont les plus utilisées. Pour les instructions SMID, une autre solution est préférée car plus simple pour de telles instructions : fournir deux instructions : une qui calcule les 64 bits de poids faible du résultat, une autre pour les 64 bits de poids fort. Les processeurs SIMD étant utilisés pour du traitement d'image ou du rendu 2D/3D, ils supportent généralement des opérations mathématiques assez complexes sur des nombres flottants. Il n'est pas rare d'avoir des instructions SIMD pour le calcul de l'inverse d'un nombre, de sa racine carrée, l'inverse d'une racine carrée, des fonctions trigonométriques, etc. L'implémentation matérielle de telles instructions est généralement très complexe et gourmande en circuits, ce qui fait que les concepteurs de processeurs rusent. Sauf sur certains processeurs assez peu fréquents, les instructions mathématiques complexes ne calculent pas un résultat exact, mais une approximation du résultat. Utiliser un résultat approximatif ne pose pas de problème pour de telles applications, le rendu d'image ou 3D s'en accommodant parfaitement, contrairement au calcul scientifique. ===Les instructions SIMD de manipulation de données intra-vecteur=== Après avoir vu les instructions SIMD verticales, il est temps de voir les instructions SIMD horizontales. Pour rappel, celles-ci travaillent un vecteur unique, dont elles réarrangent ou modifient les éléments. Tout cela sera plus parlant une fois qu'on aura donné quelques exemples. Mais avant toute chose, nous allons séparer les instructions horizontales en deux sous-types, fondamentalement différents. Le premier sous-type change de place les données dans un vecteur. Le second type est celui des instructions horizontales arithmétiques/logiques habituelles, qu'on verra plus tard. La distinction entre les deux est très importante, comme on le verra plus tard. [[File:SIMD instruction data movement - One operand.png|vignette|upright=1.5|Instructions SIMD de mouvement de données à une opérande.]] Les instructions SIMD horizontales les plus communes bougent des données à l'intérieur des vecteurs, ce qui leur vaut le nom d''''instructions de manipulation de vecteur'''. Les plus simples sont celles qui ont une seule opérande, à savoir qui prennent un vecteur comme opérande et renvoient un vecteur résultat. Les plus intuitives sont les instructions de '''permutation''', dont le nom est assez explicite. Elles changent de place des données dans un vecteur. Dans le cas le plus compliqué, elles prennent deux vecteur : le vecteur dans lequel faire la permutation, et un vecteur qui indique comment faire la permutation. Le vecteur précise, pour chaque élement du vecteur, où il doit aller après la permutation. L'instruction effectue la permutation des entiers/flottants/octets dans le vecteur. Les instructions '''''compress''''' et '''''expand''''' sont elles aussi spécifiques aux vecteurs. L'instruction ''compress'' prend un vecteur en opérande, sélectionne certaines valeurs présentes dedans, et les stocke dans un vecteur de sortie. Elle regroupe les valeurs sélectionnées dans le début du vecteur, et laisse les cases inoccupées à 0. Par exemple, pour un vecteur de 16 entiers, elle permet de sélectionner 5 entiers dedans, et les place dans un vecteur résultat qui ne contient que ces 5 valeurs au début du vecteur, le reste est à 0. L'instruction ''expand'' fait l'inverse : elle prend un vecteur crée par l'instruction ''compress'' (ou du moin qui a le même format), prend toutes les valeurs non-nulles dedans, et les disperse dans un vecteur de sortie, en les mettant à la place désirée. Les autres instructions SIMD horizontales prennent deux opérandes, voire aucune opérande vectorielle (elles prennent un nombre, pas un vecteur). Les instructions '''''shuffle''''' prennent plusieurs vecteurs, sélectionnent les éléments adéquats dans chaque vecteur, et les regroupe dans un vecteur résultat. Par exemple, prenons 2 vecteurs de 16 entiers chacun. Une instruction ''shuffle'' peut prendre 8 entiers dans chaque vecteur et les regrouper dans un vecteur résultat unique de 16 entiers. Ou encore, elle peut prendre trois vecteurs de 16 flottants chacun, prendre 4 flottants dans le premier, 10 dans le second et 2 dans le troisième, et regrouper le tout dans un vecteur résultat de 16 flottants. Il arrive que les deux cas précédents correspondent à des cas séparés, à deux instructions de type ''shuffle'', mais qui fonctionnent différemment. Le premier type prend les N premiers éléments du premier vecteur, puis prend les élements manquant à partir de la fin du vecteur. Le second type prend un élément sur deux dans chaque vecteur, mais avec un décalage d'un rang entre les deux vecteurs. Il y a aussi les instructions dites de '''''broadcast''''', qui copient une valeur unique dans un vecteur entier. Elles sont surtout utilisées pour initialiser des tableaux avec une valeur unique, ou pour remplir un vecteur avec des données prédéterminées pour certains calculs impliquant des constantes. [[File:SIMD instruction exemple.png|centre|vignette|upright=1.5|Instructions SIMD de mouvement de données qui ne sont pas à une opérande.]] ===Les instructions SIMD de réduction=== Il existe des instructions horizontales de type arithmétiques. La plus simple est celle qui additionne les différents entiers/flottants d'un vecteur. Elle est utilisée pour accélérer une opération précise : faire la somme d'un tableau d'entier/flottants. On peut aussi citer les opérations maximum et minimum, qui renvoient le plus grand/petit élément d'un vecteur. Un point important avec ces opérations est qu'elles prennent un vecteur, mais renvoie un résultat unique, un scalaire, un nombre entier/flottant seul. Elles sont parfois appelées des '''instructions de réduction'''. Les instructions de réduction sont généralement la spécificité des processeurs vectoriels, les processeurs SIMD récents n'en ont pas. La raison est que leur implémentation matérielle est généralement assez compliquée. Contrairement aux instructions verticales, elles ont une forme de parallélisme de donnée très limitée. Elles ne travaillent pas exactement sur des données indépendantes, elles font des calculs dont le résultat est utilisé par d'autres, il y a des dépendances de données entre éléments du vecteur. Cependant, il faut savoir que l'on peut émuler une instruction de réduction en utilisant des instructions verticales couplées à des instructions de manipulation de vecteur. Par exemples, prenons le cas d'une instruction de réduction qui additionne entre eux les éléments d'un vecteur. On peut l'émuler en utilisant une addition verticale entre deux vecteurs, et une instruction ''compress''. L'idée est la suivante : on coupe le vecteur initial en deux avec deux instructions ''compress'', ce qui donne deux sous-vecteurs. Puis, on additionne les deux vecteurs résultat entre eux. Puis on répète les deux étapes précédentes jusqu'à obtenir la somme finale. Le fait que l'on puisse émuler les instructions de réduction est la raison principale qui explique que les processeurs SIMD récents n’intègrent pas d'instructions de réduction. Ils se débrouillent avec des instructions SIMD de manipulation de vecteur, et des instructions SIMD verticales. C'est un bon compromis entre cout en circuits et performance. ===Les accès mémoire=== La gestion des accès mémoire est assez hétéroclite, la façon de faire étant différente selon les architectures. Dans le cas le plus simple, les données d'un vecteur sont contigües en mémoire et les instructions ont juste à préciser l'adresse mémoire du début du vecteur, qui est généralement dans un registre d'adresse spécialisé, ou un registre non-SIMD. Avec des vecteurs de 8 octets, toute instruction d'accès mémoire de ce type va lire ou écrire des blocs de 8 octets. L'adresse de départ de ces blocs est soumise à des contraintes d'alignement sur les jeux d'instructions comme le SSE, le MMX, etc. La raison à cela est que gérer des accès non alignés en mémoire rend les circuits de lecture/écriture en mémoire plus complexes. En contrepartie, ces contraintes compliquent l'utilisation des instructions SIMD par le compilateur. D'autres modes d'adressage des vecteurs permettent à une instruction de charger des données dispersées en mémoire pour les rassembler dans un vecteur. On peut notamment citer l'existence d'accès mémoires en stride et en scatter-gather. L''''accès en stride''' regroupe des données séparées par un intervalle régulier d'adresses. Ce mode d'accès a besoin de l'adresse initiale, de celle du premier élément du vecteur, et de la distance entre deux données en mémoire. Il permet aux instructions de mieux gérer les tableaux de structures, ainsi que les tableaux multi-dimensionnels. Lorsqu'on utilise de tels tableaux, il arrive assez souvent que l'on n'accède qu'à des éléments tous séparés par une même distance. Par exemple, si on fait des calculs de géométrie dans l'espace, on peut très bien ne vouloir traiter que les coordonnées sur l'axe des x, sans accès sur l'axe des y ou des z. Les instructions d'accès mémoire en enjambées gèrent de tels cas efficacement. Les processeurs SIMD incorporent aussi les accès en '''''scatter-gather'''''. De tels accès prennent en opérande un vecteur contenant des adresses mémoires, et lit/écrit chacune de ses adresses mémoire indépendamment. Les accès en ''scatter-Gather'' peuvent être vus comme une généralisation de l'adressage indirect à registre aux vecteurs, chaque élément du vecteur étant adressé via adressage indirect. Les accès en ''gather'' sont des lectures : elles prennent un vecteur d'adresse, lisent chaque adresse, et regroupent toutes les données lues dans un vecteur, dans un registre vectoriel. Les accès en ''scatter'' sont l'équivalent pour les écritures. Ils prennent un vecteur d'opérande pour les données à écrire, un vecteur pour les adresses. [[File:Cuda4.png|centre|vignette|upright=2|Adressage en scatter-gather]] Un autre version des accès en ''scatter-gather'' n'utilise pas des adresses mémoire, amis des indices. Le vecteur opérande ne contient pas des adresses, mais des indices qui sont combinés à une adresse de base pour calculer plusieurs adresses. Un tel mode d'adressage permet de faciliter l'implémentation de certaines structures de données, appelées des vecteurs de Liffe. Ils sont très utilisés pour gérer les matrices creuses, des matrices où une grande partie des éléments sont nuls et où, dans un souci d'optimisation, seuls les éléments non nuls de la matrice sont stockés en mémoire. Lors d'une instruction de ''scatter'' ou de ''gather'', tous les accès mémoire n'ont pas lieu en même temps, certains accès mémoire seront terminés avant les autres. Les accès peuvent se faire dans le désordre et de nombreuses optimisations matérielle en profitent pour gagner en performance. Il existe cependant un cas où les accès mémoire doivent se faire dans un ordre bien précis, généralement du premier élément vers le dernier : lorsqu'une instruction ''scatter'' effectue plusieurs écritures à la même adresse. C'est parfaitement possible, et c'est le seul cas où il est intéressant de forcer un ordre pour les écritures. En théorie, une instruction SIMD est un tout, c'est à dire qu'elle ne doit pas modifier les registres SIMD avant que tous les accès mémoire se soient terminés. Cependant, il est possible qu'une partie des accès mémoire déclenchent des défauts de page ou lèvent des exceptions matérielles, alors que les autres se sont terminés. En théorie, le processeur est censé se débarrasser du résultat des accès mémoire terminés. Mais ce serait un gachis de performance, ce qui fait que le processeur viole la règle voulant que les registres SIMD soient mis à jour en une seule fois à la fin de l'instruction de ''scatter-gather''. Dès qu'un accès mémoire se termine, il écrit son résultat dans le registre SIMD, à l'endroit adéquat. Cependant, ce comportement pose un problème. Lorsqu'une exception survient, comme lors d'un défaut de page, le processeur exécute la routine d'interruption associée, puis redémarre l'instruction fautive, le second essai étant le bon. Ici, le processeur ne réexecute pas l'instruction à l'identique, mais seulement les accès non-terminés. Mais comment le processeur sait-il quelles accès mémoire redémarrer ? Il doit pour cela mémoriser quels sont les accès mémoire terminés. Il utilise pour cela un registre architectural appelé le '''masque de complétion'''. Le registre est forcément architectural car il doit être préservé lors d'un changement de contexte, l’exécution de l'exception matérielle forçant un changement de contexte. ===Exemple avec les processeurs x86=== [[File:PD-20060908-SSE3-01.svg|vignette|Chronologie des extensions x86 SIMD.]] Après avoir vu la théorie sur les instructions SIMD, il est temps de voir un exemple concret : celui des instructions SIMD des processeurs x86, présents dans nos PC. Le jeu d'instruction des PC qui fonctionnent sous Windows est appelé le x86. C'est un jeu d'instructions particulièrement ancien, apparu en 1978. Depuis, des '''extensions x86''' ont ajouté des instructions au x86 de base. On peut citer par exemple les extensions MMX, SSE, SSE2, ''3dnow!'', etc. Les premières instructions SIMD furent fournies par une extension x86 du nom de MMX, introduite par Intel en 1996 sur le processeur Pentium MMX. Le MMX a perduré durant quelques années avant d'être remplacé par les extensions SSE. La même année, AMD sorti d'expansion ''3DNow!'', qui ajoutait 21 instructions SIMD similaires à celles du MMX. Celui-ci fût suivi du ''3DNow!+'' quelques années plus tard. Le SSE fût ensuite décliné en plusieurs versions avant d'être "remplacé" par l'AVX. Une grande quantité de ces extensions x86 sont des ajouts d'instructions SIMD. L’'''extension MMX''' ajoutait pas mal d'instructions SIMD assez basiques, essentiellement des instructions arithmétiques : addition, soustraction, opérations logiques, décalages, rotations, mise à zéro d'un registre, etc. La multiplication est aussi supportée, mais avec quelques petites subtilités, via l'instruction PMULLW. Les instructions MMX ne mettent pas le registre d'état à jour et ne préviennent pas en cas d'overflow ou d'underflow si ceux-ci arrivent (pour les instructions qui ne travaillent pas en arithmétique saturée). Le MMX introduisait 8 registres vectoriels, du nom de MM0, MM1, MM2, MM3, MM4, MM5, MM6 et MM7. Ils ne pouvaient contenir que des nombres entiers et faisaient 64 bits. Ils avaient cependant un léger défaut, qui a nui à l'adoption du MMX. Pour rappel, avant le MMX, les flottants étaient gérés par l'extension x87, qui définit 8 registres flottants de 80 bits. Et chaque registre MMX correspondait aux 64 bits de poids faible d'un registre flottant x87 ! Le système d'''alias'' de registres typique des CPU x86 a encore frappé ! En conséquence, il était impossible d'utiliser en même temps l'unité de calcul flottante et l'unité MMX. Par contre, sauvegarder les registres lors d'un changement de contexte, d'une interruption, ou d'un appel de fonction était très simple : la sauvegarde des registres de la FPU x87 suffisait. [[File:MMX-FPU-Register.JPG|centre|vignette|upright=2|Registres MMX et FPU x87.]] [[File:XMM registers.svg|droite|vignette|XMM registers]] Dans les années 1999, une nouvelle extension SIMD fit son apparition sur les processeurs Intel Pentium 3 : le '''Streaming SIMD Extensions''', abrévié SSE. Ce SSE fut ensuite complété, et différentes versions virent le jour : le SSE2, SSE3, SSE4, etc. Cette extension fit apparaitre 8 nouveaux registres, les registres XMM. Sur les processeurs 64 bits, ces registres sont doublés et on en trouve donc 16. En plus de ces registres, on trouve aussi un registre d'état qui permet de contrôler le comportement des instructions SSE : celui contient des bits qui permettront de dire au processeur que les instructions doivent arrondir leurs calculs d'une certaine façon, etc. Ce registre n'est autre que le registre MXCSR. Chose étrange, seuls les 16 premiers bits de ce registre ont une utilité : les concepteurs du SSE ont surement préférés laisser un peu de marge au cas où. La première version du SSE contenait assez peu d'instructions : seulement 70. Le SSE première version ne fournissait que des instructions pouvant manipuler des paquets contenant 4 nombres flottants de 32 bits (simple précision). Je ne vais pas toutes les lister, mais je peux quand-même dire qu'on trouve des instructions arithmétiques de base, avec pas mal d'opérations en plus : permutations, opérations arithmétiques complexes, autres. Petit détail : la multiplication est gérée plus simplement et l'on a pas besoin de s’embêter à faire mumuse avec plusieurs instructions différentes pour faire une simple multiplication comme avec le MMX. On peut quand même signaler une chose : des instructions permettant de contrôler le cache firent leur apparition. On retrouve ainsi des instructions qui permettent d'écrire ou de lire le contenu d'un registre XMM en mémoire sans le copier dans le cache. Ces instructions permettent ainsi de garder le cache propre en évitant de copier inutilement des données dedans. On peut citer par exemple, les instructions MOVNTQ et MOVNTPS du SSE premiére version. On trouve aussi des instructions permettant de charger le contenu d'une portion de mémoire dans le cache, ce qui permet de contrôler son contenu. De telles instructions de prefetch permettent ainsi de charger à l'avance une donnée dont on aura besoin, permettant de supprimer pas mal de cache miss. Le SSE fournissait notamment les instructions PREFETCH0, PREFETCH1, PREFETCH2 et PREFETCHNTA. Autant vous dire qu'utiliser ces instructions peut donner lieu à de sacrés gains si on s'y prend correctement ! Il faut tout de même noter que le SSE n'est pas seul "jeu d'instruction" incorporant des instructions de contrôle du cache : certains jeux d'instruction POWER PC (je pense à l'Altivec) ont aussi cette particularité. Avec le '''SSE2''', de nouvelles instructions furent ajoutés, permettant d'utiliser des nombres de 64, 16 et 8 bits dans chaque vecteur. Le SSE2 incorporait ainsi pas moins de 144 instructions différentes. Ce qui commençait à faire beaucoup. Puis, vient le '''SSE3''', avec ses 13 instructions supplémentaires. Pas grand-chose à signaler, si ce n'est que des instructions permettant d'additionner ou de soustraire tous les éléments d'un paquet SSE ensemble, des instructions pour les nombres complexes, et plus intéressant : les deux instructions MWAIT et MONITOR qui permettent de paralléliser plus facilement des programmes. Le '''SSE4''' fut un peu plus complexe et fut décliné lui-même en 2 versions. Le SSE4.1 introduit ainsi des opérations de calcul de moyenne, de copie conditionnelle de registre (un registre est copié dans un autre si le résultat d'une opération de comparaison précédente est vrai), de calcul de produits scalaire, de calcul du minimum ou du maximum de deux entiers, des calculs d'arrondis, et quelques autres. Avec le SSE4.2, le vice à été poussé jusqu'à incorporer des instructions de traitement de chaines de caractères. [[File:AVX registers.svg|vignette|Registres AVX.]] Avec l''''AVX (Advanced Vector eXtensions)''', on retrouve 16 registres d'une taille de 256 bits, nommés de YMM0 à YMM15 et dédiés aux instructions AVX. Ils sont partagés avec les registres XMM : les 128 bits de poids faible des registres YMM ne sont autres que les registres XMM. L'AVX complète le SSE et ses extensions, en rajoutant quelques instructions, et surtout en permettant de traiter des données de 256 bits. Son principal atout face au SSE est que les instructions AVX permettent de préciser le registre de destination en plus des registres d'opérandes. Avec le SSE et le MMX, le résultat d'une instruction SIMD était écrit dans un des deux registres d'opérande manipulé par l'instruction. Il fallait sauvegarder son contenu si on en avait besoin plus tard, ce qui n'est plus nécessaire avec l'AVX. ==La performance des processeurs SIMD== Il est intéressant de comparer la performance d'un processeur SIMD et celle d'un processeur normal, sans SIMD. Mais cet exercice est compliqué par le fait que les processeurs non-SIMD sont très nombreux : entre les CPU superscalaires, à émission dans l'ordre, exécution dans le désordre, ceux sans rien de tout cela, on a de quoi être perdu. Dans ce qui suit, nous allons comparer deux processeurs basiques sans pipeline : un avec SIMD, un autre sans. Comparé à un processeur sans pipeline, on s'attend à ce que la performance soit augmentée d'un facteur N, avec N le nombre maximal d'entiers/flottants que l'on peut mettre dans un vecteur. Si une architecture SIMD fait N calculs en parallèles, alors les performances sont censées être multipliées par N. Cela laisse penser que plus les vecteurs sont longs, plus le gain en performance est important. Il s'agit là d'un résultat basique, mais la vraie vie est différente. Un premier problème est que ce résultat ne tient que si la mémoire RAM et les caches suivent. En effet, faire N calculs en parallèle demande de lire/écrire N fois plus de données. Les données sont souvent stockées dans des registres vectoriels, ce qui fait que la pression sur le banc de registre est assez importante. Mais cela signifie aussi que les instructions d'accès mémoire doivent lire/écrire N fois plus de données. Il faut alors ajouter des ports sur le cache, élargir le bus mémoire, augmenter la taille des lignes de cache, etc. Le débit binaire des caches et de la mémoire RAM deviennent rapidement des points limitants, qui réduisent le gain en performance des instructions SIMD. Et ne parlons pas de l'interaction avec la mémoire virtuelle : vu qu'on lit/écrit par paquets de données, cela signifie qu'on traverse une page mémoire plus vite, ce qui fait que les défauts de page sont lus fréquents. Un processeur SIMD a intérêt à être combiné à une RAM et des caches solides, très performants. C'est le cas sur les processeurs modernes, mais cela a pose des problèmes pour les processeurs vectoriels, et a mené à leur abandon progressif. Mais passons outre ce problème et regardons la seconde raison qui font que ce résultat est naïf : la loi d'Amdhal ! Toutes les portions d'un programme ne sont pas accélérées par des instructions SIMD, il y a des portions dont les calculs ont des dépendances de données, d'autres qui ne sont pas parallélisables, etc. Une partie du programme est donc impossible à véctoriser (i.e optimiser pour le SIMD), une l'autre l'est. Pour simplifier les explications, on suppose qu'une instruction SIMD prend le même nombre de cycles que son équivalent non-SIMD. Par exemple, une addition SIMD prend le même temps qu'une addition normale. Dans ce cas, la portion non-vectorisable du code reste inchangée, alors que la portion vectorisable est divisée par N, par la largeur du vecteur. On retombe sur une formulation identique à la loi d'Amdhal sur le fond. Une manière de quantifier le tout est d'utiliser la métrique de l'efficience SIMD. Elle est calculée en divisant deux grandeurs. La première est elle-même un rapport : c'est le gain obtenu en terme de nombre d'instructions exécutées. Prenons un programme qui exécute I instructions avant vectorisation, et qui en exécute I/X après. Le gain s'exprime comme suit : : <math>G = \frac{I}{I/X} = X</math> Maintenant, prenons ce gain est divisons-le par la largeur d'un vecteur. On obtient alors l'efficience SIMD : : <math>\text{Efficience SIMD} = \frac{G}{N} = \frac{\frac{I}{I/X}}{N}</math> Notons que le gain et la largeur SIMD ne sont égales que dans un cas bien précis : tout le code est vectorisable. Il s'agit donc d'une reformulation du gain de la loi d'Amdhal, mais dans laquelle le pourcentage de code série est caché. Il est intéressant de regarder l'efficience SIMD quand on augmente la portion du code série, ainsi que la taille des vecteurs. Prenons un cas assez généreux, réaliste pour certaines applications très adaptées au SIMD : 1% du code est non-vectorisable, 99% profite du SIMD. L'efficience SIMD varie alors assez rapidement avec la taille des vecteurs. De 99% pour N=2, elle descend à 98% pour N=16, et tombe sous les 50% pou N=128. La conséquence est que les très grandes tailles de vecteurs ne sont pas vraiment utiles, le rendement est décroissant avec la taille des vecteurs. ==Les processeurs SIMD à registres généraux== Le premier type de processeur SIMD que nous allons voir est celui des processeurs de type '''SWAR''' (''SIMD Within A Register''). Le terme SWAR est un terme polysémique dont le sens a beaucoup changé au fil du temps. Ici, nous allons l'utiliser dans la définition la plus stricte : celle où on effectue du SIMD dans les registres généraux du processeur, sans registres spécialisés. Il s'agit d'une forme de SIMD qui est aujourd'hui peu utilisée, mais qui a été la première forme de SIMD utilisée dans des processeurs grand public commerciaux. L'idée est d'améliorer un petit peu un processeur normal, non-SIMD. Un processeur usuel contient des registres généraux de 32 à 64 bits, qui stockent chacun un opérande de même taille que le registre. D'anciens processeurs avaient des instructions pour effectuer des calculs simples, sur des opérandes plus courts. Par exemple, les processeurs x86 32 bits sont capables de faire des calculs sur 8, 16, ou 32 bits. Mais on ne pouvait placer qu'un seul opérande de 8, 16 ou 32 bits dans un registre général, ce qui est un léger gâchis. Le SWAR est une amélioration de cette technique qui vise à mieux utiliser les registres et l'ALU. L'idée est de regrouper plusieurs opérandes de 8, 16, voire 32 bits, dans un seul registre général. De plus, on ajoute des instructions SIMD d'addition/soustraction/autres, qui lisent des opérandes 8/16/32 bits dans ces registres généraux. Par exemple, sur un processeur 32 bits, on peut ajouter une opération d'addition qui lit deux registres de 32 bits, pour récupérer quatre opérandes de 16 bits (deux par registre) et effectue deux additions 16 bits en même temps dans l'ALU. Et il y a la même chose pour d'autres opérations, comme la soustraction, la comparaison, etc. Les instructions SIMD disponibles sur ces processeurs se limitent généralement à des additions, soustractions, comparaisons, mais guère plus. De plus, il s'agit d'instructions entières, il n'y a pas d'instructions SIMD flottantes. La raison à cela est très simple, mais nous l'expliquerons plus bas, dans la section sur l'implémentation. Disons simplement que de tells architectures visent une économie en circuit maximale. Elles implémentent les instructions SIMD en utilisant le moins de circuits possibles, ce qui fait qu'elles réutilisent les registres généraux et n'ont pas de registres SIMD séparés. ===Les extensions/jeux d'instructions de type SWAR=== Les premiers processeurs à intégrer du SWAR étaient les processeurs DEC Alpha, avec l'extension multimédia '''''Motion Video Extensions'''''. Les processeurs en question étaient les processeurs Alpha 21164PC (PCA56 and PCA57), Alpha 21264 (EV6) and Alpha 21364 (EV7). Les instructions SIMD disponibles étaient très simples et se résumaient à quelques comparaisons : trouver le maximum ou le minimum de deux opérandes de 8/16 bits, quelques instructions de permutation. Les extensions '''''Multimedia Acceleration eXtensions''''' des processeurs Hewlett-Packard PA-RISC sont aussi de ce type, mais disposent de plus d'instructions SIMD. La première version, appelée MAX-1, était disponible sur les processeurs 32 bits de la marque, à partir du processeur PA-7100LC. Les instructions disponibles étaient des additions et soustractions d'opérandes 16 bit. Il y avait en tout trois instructions d'addition et trois pour la soustraction. En tout, il y avait : * une instruction d'addition/soustraction non-signée utilisant l'arithmétique modulaire ; * une instruction d'addition/soustraction signée en arithmétique modulaire ; * une instruction d'addition/soustraction signée en arithmétique saturée. Outre les additions/soustractions, il y avait aussi une instruction pour calculer la moyenne de deux opérandes 16 bits, ainsi qu'une addition fusionnée avec un décalage. La seconde version, appelée MAX-2, ajouta des instructions d'additions fusionnées avec des décalages, mais aussi des instructions de permutation. De plus, cette version était disponible sur les processeurs 64 bits de la marque, ce qui fait que les registres étaient eux aussi de 64 bits. Ils pouvaient contenir deux fois plus d'opérandes 16 bits. Il n'y avait de gestion d'opérandes 32 bits pour les extensions SIMD. ===L'implémentation matérielle=== L'avantage de cette technique est la grande simplicité d'implémentation. On n'ajoute pas de registres SIMD séparés, ce qui a de nombreux avantages : économie de circuits car pas besoin d'un second banc de registre, pas besoin de sauvegarder des registres SIMD en plus lors d'un appel système/interruption, etc. L'implémentation a juste besoin de modifier le décodeur et l'unité de calcul. Le décodeur est modifié pour ajouter des instructions en plus, rien de spécifique au SWAR. Les modifications de l'unité de calcul sont spécifiques au SWAR et modifient la manière dont elle gère les retenues. Une première solution est d'utiliser une ALU bit-slicée, ce qui est l'idéal pour les unités de calcul entières. Par exemple, pour une ALU 32 bits entière, on peut la découper en 4 unités de calcul 8 bits. Pour une opération SIMD avec 4 opérandes 8 bits, les quatre ALU fonctionnent en parallèle, il n'y a pas transmission des retenues. Pour les calculs SIMD avec des opérandes de 16 bits, on regroupe les ALU par paire et on propage les retenues à l'intérieur d'une paire, mais pas entre les paires. Enfin, pour un calcul non-SIMD, les opérandes de 32 bits, on propage les retenues normalement, d'une ALU 8 bits vers la suivante. Une autre solution prend un additionneur 32/64 bits normal, mais mets à 0 les retenues. L'implémentation est très simple avec un additionneur à anticipation de retenues, où les retenues sont calculées en avance, avant de faire l'addition proprement dite, par un circuit d'anticipation de retenue. Il suffit alors d'ajouter un circuit de masquage en sortie du circuit d'anticipation de retenue, qui met à zéro les retenues adéquates. La gestion des débordements demande d'ajouter des circuits, ou du moins de modifier ceux déjà présents dans l'ALU. Typiquement, les circuits pour gérer l'arithmétique saturée sont un peu modifiés pour effectuer la mise à 1111...111 octet par octet et chaque octet est masqué si besoin. [[File:ALU modifiée pour implémenter du SWAR.png|centre|vignette|upright=3|ALU modifiée pour implémenter du SWAR]] Notons que la technique ne s'applique qu'aux additions, et aux opérations dérivées comme la soustraction et les comparaisons (ces dernières sont des soustractions déguisées). Mais les multiplications ou divisions ne peuvent pas s'implémenter simplement en modifiant des ALU existantes, du moins pas avec des modifications simples. Aussi, les processeurs qui utilisent cette technique se bornent aux additions et opérations dérivées, pas plus. La gestion des opérations de permutation est quant à elle très simple : beaucoup de processeurs disposent déjà d'instructions de permutation d'octets, qui sont l'équivalent d'instructions SIMD de permutation pour les registres généraux. Nous en avions déjà parlé dans le chapitre sur le langage machine et l'assembleur, quand nous avions fait la liste des instructions les plus courantes. De telles instructions de permutation d'octet sont utiles pour gérer le boutisme ou effectuer quelques manipulations assez rares. Pas besoin de rajouter une ALU dédiée, celle-ci est déjà présente de base, du moins si les instructions de permutation d'octet sont déjà présentes. ==Les processeurs SIMD purs (''packed SIMD'') et SIMT== Maintenant que nous avons vu les instructions SIMD, passons maintenant aux processeurs eux-même. Tous les processeurs que nous allons voir dans ce qui suit supportent des instructions SIMD. Mais leur implémentation matérielle, leur micro-architecture, n'est pas la même. Ils ont aussi quelques différences en termes de jeu d'instruction, mais qui sont fortement liées à l'implémentation matérielle. Nous allons ici voir les '''processeurs SIMD purs''', aussi dits de type ''Packed SIMD''. Ils implémentent des instructions SIMD sans rien de plus, juste le strict minimum : pas de prédication, pas de vecteurs de taille variable, presque pas d’instructions horizontales, architecture de type LOAD-STORE. De tels processeurs utilisent plusieurs ALU entières/flottantes qui travaillent en parallèle. De plus, ils disposent de registres SIMD séparés des registres généraux. Voyons ces points immédiatement. ===Les instructions SIMD verticales : plusieurs ALU travaillant en parallèle=== [[File:SIMD2.svg|vignette|Parallélisme de données au niveau de l'unité de calcul. Celle-ci contient plusieurs circuits indépendants qui appliquent la même opération sur des données différentes.]] Sur un processeur SIMD pur, les vecteurs sont stockés dans des registres séparés des registres généraux. Les vecteurs sont beaucoup plus longs qu'un entier/flottant normal, ils peuvent en regrouper plusieurs. Par exemple, sur un processeur 32 bits, qui gère donc des entiers de 32 bits, les vecteurs peuvent faire 128 ou 256 bits. Un vecteur contient donc plusieurs entiers ou flottants de taille maximale. On n'est pas dans le cas précédent, où registres généraux et SIMD sont les mêmes, ils sont séparés et n'ont pas la même taille. Les calculs sont effectués en parallèle dans des ALU séparées. Une unité de calcul SIMD contient plusieurs additionneurs/multiplieurs séparés. Par exemple, pour additionner deux vecteurs contenant chacun 16 entiers/flottants, il faut utilise 16 additionneurs entiers et 16 additionneurs flottants. Dans le cas général, une ALU SIMD est composée de plusieurs ALU entières et flottantes regroupées ensemble et avec quelques circuits pour gérer les débordements et d'autres situations. Notons que les ALU travaillent en parallèle, elles font des calculs indépendants. Notons que les additionneurs dans chaque ALU doivent pouvoir être configurés de manière à gérer des tailles de données différentes. Par exemple, si on prend un vecteur simple, qui peut contenir soit 32 entiers de 16 bits, soit 16 entiers 32 bits, soit 8 entiers 64 bits, alors on doit utiliser 8 additionneurs, mais chacun d'entre eux doit pouvoir être reconfiguré de manière à ne pas propager les retenues au-delà des premiers 16 ou 32 bits. Un défaut de cette organisation est que le cout en circuits est loin d'être négligeable. Il faut dupliquer des unités de calcul et les coller en rajoutant des circuits, cela utilise beaucoup de transistors. Le cout en circuit est d'autant plus grand que les vecteurs sont longs et le cout est approximativement proportionnel à la taille des vecteurs. Entre des vecteurs de 128 et 256 bits, l'unité de calcul utilisera globalement deux fois plus de circuits avec 256 bits qu'avec 128. Même chose pour les registres, mais c'est là un cout commun à toutes les architectures. Retenez cependant : l'usage de plusieurs ALU travaillant en parallèle a plusieurs défauts. Premièrement, cela implique des vecteurs de taille fixe, du fait que le nombre d'ALU travaillant en parallèle est fixe. Deuxièmement, des difficultés d'implémentation concernant les instructions de réduction. Détaillons ce second point ! Une autre possibilité est d'utiliser moins d'unités de calcul qu'il n'y a d'éléments dans un vecteur. Par exemple, pour des vecteurs contenant 32 flottants, il se peut qu'il n'y ait que 16 ALU. Les opérations sur les vecteurs sont donc faites en deux fois : une première passe pour les 16 premiers éléments, une seconde passe pour les 16 restants. On économise ainsi pas mal de circuits, mais cela se fait au détriment de la performance globale. L'avantage est que l'ensemble des calculs se fait en une seule instruction machine. L'implémentation est généralement la suivante : le processeur décode une instruction SIMD en deux micro-instructions SIMD plus courtes. Par exemple, pour une instruction SIMD de 32 éléments, elle sera décodée en deux instructions SIMD de 16 éléments, chacune étant exécutée sur l'ALU. Il est possible de réduire fortement l'impact en performance en doublant la fréquence de l'ALU. Par exemple, pour des vecteurs contenant 32 flottants, le processeur incorpore 16 ALU, mais celles-ci fonctionne à une fréquence double de celle du processeur. L'usage d'une ALU à double fréquence est une technique qui a été utilisée sur le Pentium 4 et quelques processeurs, pour des instructions non-SIMD. Mais pour les processeurs SIMD, elle s'applique à la perfection. L'implémentation est la même que précédemment, sauf que la fenêtre d'instruction et la logique d'émission fonctionnent à double fréquence. Pour limiter la casse, il est préférable d'utiliser une fenêtre d'instruction séparée pour les instructions SIMD, qui sera seule à fonctionner à double fréquence. ===Les instructions horizontales : une ALU simple séparée=== Les instructions horizontales posent des difficultés d’implémentation. Elles ne peuvent pas s'implémenter en utilisant plusieurs ALU travaillant en parallèle, contrairement aux instructions verticales, ce qui fait qu'elles utilisent généralement des unités de calcul spécialisées. Et c'est là que la différence entre instructions de manipulation de vecteurs et instructions de réduction vient encore une fois poser problème. Les instructions de manipulation de vecteur sont relativement simples à implémenter : elles ont juste besoin d'une ALU spécialisée, relativement simple, composée de beaucoup de multiplexeurs à configurer convenablement. Mais pour les instructions de réduction, c'est autre chose ! L'implémentation des instructions de réduction est possible mais a un cout en circuits assez prohibitif. Pour additionner N entiers, il faut utiliser un additionneur multi-opérande qui prend beaucoup de circuits et dont le temps de calcul est proche de celui d'une multiplication (très lent). Trouver le maximum et le minimum d'un nombre demandent de faire la même chose avec des comparateurs. Le tout a un cout en circuit non-négligeable, pour accélérer des opérations assez peu fréquentes. Elles sont surtout utilisées pour trouver la somme ou le maximum/minimum d'un tableau, opération séquentielle par nature, avec peu parallélisme de données, peu courante. Une autre solution utilise une unité SIMD normale, mais intègre un système de contournement, de ''bypass'', qui relierait l'entrée d'une ALU à la sortie d'une autre. Mais le cout lié aux interconnexions est très important : on doit relier chaque ALU à toutes les autres dans le pire des cas, on peut optimiser le tout et réduire un peu la quantité d'interconnexions, mais le cout reste important. Au final, on gagne en circuits ce qu'on perd en interconnexions. Le choix entre les deux solutions est loin d'être facile et dépend du processeur, de la technologie utilisée, du budget en transistors disponibles, etc. Les processeurs SIMD purs résolvent ce problème assez simplement : ils n'implémentent pas d'instructions de réduction. Par contre, ils implémentent systématiquement des instructions de manipulation de vecteur, comme des permutations ou autres. La raison est que l'on peut émuler les instructions de réduction en utilisant des instructions SIMD de manipulation de vecteur couplées à des additions/multiplications SIMD verticales. Il s'agit donc d'un compromis entre cout en circuits et performance finale. Niveau circuits, on a une unité SIMD avec plusieurs ALU de calcul en parallèle, une unité de manipulation de vecteur séparée assez simple et au cout en circuits raisonnable. Les performances pour les opérations de réduction sont acceptables, le cout en performance est relativement modéré. ===L'implémentation des LOAD-STORE à des données consécutives=== Les architectures SIMD pures sont des architectures de type LOAD-STORE, ce qui veut dire que les instructions SIMD ne peuvent que lire ou écrire dans les registres vectoriels, pas en mémoire ou ailleurs. Les transferts entre registres SIMD et mémoire sont le fait d'instructions LOAD et STORE spécialisées, qui transfèrent des vecteurs entre RAM et registres vectoriels. Les autres architectures n'ont pas nécessairement ce genre de restrictions, mais laissons cela pour plus tard. Implémenter les instructions LOAD/STORE pour des vecteurs dont les données sont consécutives en mémoire n'est pas très compliqué. Il suffit juste d'élargir le port de lecture/écriture du cache L1 de données, pour pouvoir lire/écrire un vecteur entier. Rien de bien compliqué, il suffit juste d'ajouter des interconnexions et d'ajouter des multiplexeurs. Du moins, c'est le cas tant que les vecteurs sont plus petits qu'une ligne de cache, ce qui est souvent le cas en pratique. Dans le cas très rare où les vecteurs sont plus longs qu'une ligne de cache, les LOAD/STORE doivent se faire en deux accès dans le cache, ce qui demande d'ajouter du matériel pour contrôler la situation. Précisons cependant qu'il s'agit là d'un cas idéal, où les vecteurs sont correctement alignés en mémoire. En effet, il se peut qu'un vecteur soit à cheval sur deux lignes de cache, alors qu'il est plus petit qu'une ligne de cache. Il suffit pour cela qu'il soit placé à une adresse adéquate. Par exemple, prenons un vecteur de 16 octets et des lignes de cache de 32 octets. Si le vecteur démarre à l'adresse 24, alors il sera à cheval sur deux lignes de cache. Il faut donc intégrer des circuits pour gérer cette situation. Pour cela, il suffit d'ajouter un registre pour stocker la première de ligne lue, puis un circuit qui combine les deux lignes de cache pour donner le vecteur final. ===L'implémentation des LOAD-STORE en ''scatter-gather''=== L'implémentation des accès en ''scatter-gather'' est plus compliquée. Un accès en ''scatter-gather'' demande de faire des calculs d'adresse, suivis par un ensemble de lectures/écritures, suivis par un regroupement des données lues dans un vecteur. Le calcul d'adresse peut se faire en parallèle dans une unité de calcul SIMD, dans plusieurs unités de calcul d'adresse en parallèle. Par contre, les lectures/écritures ne le peuvent pas. Le cache n'a pas assez de ports de lecture/écriture pour lire/écrire N données. Il a quelques ports, ce qui lui permet de lire/écrire 3/4 données grand maximum, soit moins que ce qui est nécessaire pour faire un accès en ''scatter-gather'' en une fois. Les lectures/écritures sont donc effectuées en plusieurs fois. Pour une opération de ''Gather'', il faut aussi regrouper les données lues dans un vecteur. Sur les caches des processeurs à haute performance, le calcul d'adresse est intégré dans le décodeur. Ce sont des caches adressés par somme, que nous avions abordé dans le chapitre sur les mémoires caches. Avec de telles caches, on ne peut pas utiliser une unité de calcul d'adresse SIMD, on ne peut calculer qu'autant d'adresse qu'il y a de ports sur le cache. Les accès en ''scatter-gather'' regroupent plusieurs accès mémoire, qui peuvent être effectués dans le désordre. La seule exception est celle où un ''scatter'' effectue plusieurs écritures à la même adresse, où les deux écritures doivent se faire dans l'ordre allant du premier au dernier élément du vecteur d'adresse. Les processeurs SIMD profitent de cette absence d'ordre pour effectuer les accès dans un ordre le plus optimisé possible. Par exemple, imaginons le cas où une instruction ''gather'' lise des éléments placés dans deux lignes de cache uniquement, mais dans le désordre en passant sans cesse d'une ligne de cache à l'autre. Il est alors possible de faire tous les accès dans la première ligne de cache en premier, avant de faire ceux allant dans l'autre. Une telle optimisation marche aussi bien pour les lectures que les écritures, pour les ''gather'' que pour les ''scatter''. Elle peut aussi être adaptée pour plus de deux lignes de cache. Elle porte le nom de '''''memory coalescing'''''. Une autre optimisation possible est de détecter le cas où un accès en ''scatter-gather'' accède à des données consécutives. Il suffit pour cela que les indices/adresses du vecteur opérande soient consécutives, et cela arrive plus souvent qu'on ne le pense. Dans ce cas, l'accès est remplacé par une instruction LOAD/STORE SIMD normale, beaucoup plus simple et plus rapide. Les deux optimisations demandent que les adresses soient calculées avant de faire l'accès mémoire. Les adresses calculées sont alors comparées entre elles, par un réseau de comparateurs assez complexe. Un point important de l'implémentation des instructions ''gather'' est qu'une donnée chargée doit être insérée au bon endroit dans le vecteur destination. Et pareil pour les ''scatter'' : il faut lire la bonne donnée au bon moment. Si on prend une ligne de cache complète, il faut lire plusieurs élèments en même temps et les envoyer au registre de destination au bon endroit. Il faut pour cela tout un réseau de multiplexeurs assez complexe pour faire ce travail. Il y a le même genre de réseau pour les instructions ''scatter'', mais qui va dans l'autre sens : du registre vers la ligne de cache. ==Les processeurs SIMD avec prédication== Un obstacle très gênant à la vectorisation est la présence de branchements conditionnels dans les boucles à vectoriser. Si une boucle contient des branchements conditionnels, elle ne peut pas être vectorisée facilement : il est impossible de zapper certains éléments d'un vecteur suivant une condition. Il n'est pas possible d'effectuer une instruction SIMD seulement sur certains éléments d'un vecteur et d'ignorer les autres, en fonction du résultat d'un branchement conditionnel. Tout le vecteur y passe, ou le vecteur est épargné, mais la condition intermédiaire n'est pas possible avec du SIMD simple. Pour résoudre ce problème, les processeurs SIMD incorporent diverses techniques pour implémenter ou éviter les branchements conditionnels le plus possible. La plus simple est l'incorporation de certaines instructions SIMD simples, comme la valeur absolue, le calcul du minimum/maximum, etc. Ces opérations sont généralement émulées en utilisant des branchements, avec quelques conditions simples. Incorporer ces instructions permet ainsi de faire disparaitre une partie des branchements du code. Elle ne paye pas de mine, mais elle a un résultat pas négligeable pour le rendu graphique, certaines applications de calcul scientifiques, et quelques autres. Son cout en transistors est très variable, il faut rajouter des circuits dans le séquenceur, l'unité de calcul SIMD est assez peu modifiée (il faut juste rajouter des multiplexeurs). Le vrai problème est que cette solution laisse énormément de branchements dans le code. Et qu'il faut donc trouver une solution plus générale, capable de réduire drastiquement les branchements. Pour cela, on réutilise une technique vue dans les chapitre sur l'assembleur et le langage machine : la prédication. ===Les instructions à prédicats=== Pour optimiser le cas général, il est possible d'utiliser des instructions à prédicats adaptées pour fonctionner sur des vecteurs. L'idée est que quand une instruction effectue un calcul sur un ou deux vecteurs, certains éléments du vecteurs sont ignorés. Les éléments à ignorer sont choisis suivant le résultat d'une instruction de comparaison, qui effectue un test : les éléments pour lesquels ce test est respecté sont pris en compte, ceux qui ne passent pas le test sont ignorés. Pour donner un exemple d'utilisation, imaginons que l'on ait un vecteur dans lequel on veut remplacer toutes les valeurs négatives par des 0. Dans ce cas, on utilise : * une instruction de comparaison, qui compare chaque élément du vecteur avec 0 et génère plusieurs bits de résultat ; * suivi d'une instruction à prédicat qui met à zéro les éléments pour lesquels les bits de résultat précédents sont à 1. La prédication peut aussi être utilisée pour simuler des vecteurs de taille variable. Tous les vecteurs ont une taille fixe, mais on peut ne pas faire de calculs sur la fin d'un vecteur, ce qui fait que tout se passe comme si le vecteur était plus petit. Ce n'est pas sa seule utilisation, mais la prédication permet de simuler ce comportement. Néanmoins, cela n'en fait pas un processeur vectoriel, capable de traiter des vecteurs de taille variable : il n'y a pas de moyen de préciser explicitement la taille des vecteurs à traiter. Tout cela demande plusieurs modifications du jeu d'instruction : ajouter des instructions de comparaison SIMD, ajouter de quoi faire la prédication. ===Le calcul des masques et le registre de masque=== La prédication demande que le résultat d'une comparaison soit stocké dans un registre à prédicat ou dans le registre d'état. Pour les processeurs SIMD, il y a cependant une grosse adaptation à faire concernant le fonctionnement des comparaisons et le stockage des résultats. Premièrement, les instructions de comparaison SIMD comparent une paire de deux éléments à la même place dans deux vecteurs et fournissent un résultat d'un bit pour chaque paire. Le résultat est donc un ensemble de bits, qui sont regroupées dans un vecteur spécialisé. Deuxièmement, reste à savoir que faire de ce vecteur. Pour cela, deux solutions sont possibles. Avec la première, le vecteur est stocké dans un registre vectoriel/SIMD comme les autres, il est traité comme n'importe quel autre vecteur. Avec la seconde solution, il reçoit un registre dédié, qui est un mix entre registre d'état et registre à prédicat, appelé un '''registre de masque''' (''Vector Mask Register''). Il stocke un bit pour chaque donnée présente dans le vecteur à traiter, qui indique s'il faut ignorer la donnée ou non. L'instruction SIMD suivante fait usage de ce masque pour savoir s'il faut faire l'opération pour chaque élément du vecteur. [[File:Vector mask register.png|centre|vignette|upright=2|Vector mask register]] Il peut y avoir un ou plusieurs registres de masque. S'il y en a plusieurs, ils sont généralement nommés, sur le modèle des registres à prédicats. L'application d'un masque demande de fournir le nom du registre de masque à utiliser, ils adressés explicitement dans les instructions. S'il n'y en a qu'un seul, il est possible de le pas le nommer et de faire en sorte qu'il soit adressé implicitement par les instructions qui en ont besoin. On parle alors de '''masquage implicite'''. Le masquage implicite est très utilisé sur les cartes graphiques, qui sont actuellement des architectures SIMD, comme nous le verrons plus bas. Un gros problème du masquage implicite est qu'il introduit des dépendances implicites entre instructions, qui ne sont pas explicites quand on regarder uniquement les registres manipulés par une instruction, qui perturbent le renommage de registres. Rien d'insurmontable, mais cela peut poser des problèmes de performance et la résolution de ce problème a un cout en hardware. Mais ce n'est un problème que sur les architectures à exécution dans le désordre. Les architectures à émission dans l'ordre ne sont pas concernées, et c'est notamment le cas sur les GPU et les cartes graphiques modernes. Diverses optimisations sont possible avec un registre de masque. La première est de ne pas exécuter d'instruction si tous les bits du masque sont à 0. Dans ce cas, l'instruction SIMD n'a pas de calculs à faire, vu que tous les résultats sont masqués. Il est alors possible de ne pas exécuter l'instruction SIMD et de passer directement à la suivante. L'implémentation matérielle est très simple : une porte NOR qui détermine si le registre de masque contient la valeur 0. La sortie de cette porte NOR est envoyée au séquenceur d'instruction. Le décodeur est alors conçu pour zapper l'instruction si le registre de masque contient un zéro. ===Les accès mémoire en ''scatter-gather'' avec prédication=== Un problème avec les accès mémoire que certains accès peuvent déclencher des défauts de page ou d'autres formes d'exceptions (erreurs de protection mémoire, autre). Si l'accès mémoire se fait sans prédication, ces exceptions sont gérées dans l'ordre des accès mémoire, en commençant par le début du vecteur, ce qui rend l'implémentation assez facile. Mais avec la prédiction, il se peut qu'un accès masqué et censé être ignoré déclenche une exception matérielle. Et le résultat dépend de l'implémentation. Une première solution est d'effectuer l'accès mémoire, et de masquer ensuite les éléments à ignorer. Mais dans ce cas, les éléments masqués peuvent déclencher des exceptions, qui ne sont pas censées se produire. Une autre solution est de déterminer quelles sont les adresses qu'il faut lire ou écrire et ne pas faire les accès mémoire masqués. Une autre solution effectue tous les accès avant de savoir quels sont ceux à masquer, mais de ne pas déclencher d'exception avant qu'on sache quels sont les accès masqués. Une fois le masquage des accès effectués, le processeur exécute les exceptions adéquates. Il s'agit d'un comportement de masquage d'exception, très utilisé. L'implémentation est assez simple pour les accès mémoire contiguës, mais plus compliqué pour les accès en ''stride'' ou en ''scatter-gather''. Pour les accès contiguës, les seules exceptions pouvant survenir sont celles ayant lieu quand on passe d'une page mémoire à la suivante (au sens pagination, mémoire virtuelle). Et le hardware pour détecter un débordement de page est assez simple. Mais pour les accès en ''stride'' ou en ''scatter-gather'' demandent qu'oin vérifie les exceptions pour chaque élément du vecteur. ===L'implémentation matérielle=== L'implémentation matérielle est assez proche des processeurs SIMD purs. On retrouve plusieurs unités de calcul travaillant en parallèle, avec quelques circuits ajoutés pour gérer la prédication. Le cout supplémentaire en circuits est acceptable, les gains en performance associés sont très intéressants. Les circuits en plus, pour gérer la prédication, sont similaires à ceux utilisés pour la prédication sur les architectures non-SIMD, mais sont dupliqués. La seule innovation architecturale est l'implémentation du registre de masque. Tous les processeurs n'en utilisent pas, mais c'est la solution la plus performante. En effet, ce registre de masque est un peu l'équivalent du registre d'état pour les instructions SIMD, bien qu'il soit techniquement une concaténation de registres à prédicat non-nommés. Ils ont pour avantage de libérer des registres SIMD normaux tout en ayant un cout en matériel très limité. Un autre point est que pour gérer les masques, les instructions SIMD à prédicat doivent lire le masque. Sans registre de masque, en utilisant un registre SIMD normal, il faut rajouter un troisième port de lecture sur le banc de registre pour récupérer le masque, ce qui est couteux en matériel. Pas besoin avec un registre de masque, qui n'est connecté qu'à l'ALU, sur le même modèle que le registre d'état. Plus haut, on a vu que l'implémentation de l'ALU SIMD peut se faire en utilisant des moins d'ALU que prévu. Par exemple, pour des vecteurs de 32 éléments, on peut utiliser 16 ALUs (allant à double fréquence ou non). Une instruction SIMD est alors décodée en plusieurs micro-instructions SIMD consécutives, chacune traitant une partie d'un vecteur. Par exemple, une instruction SIMD sur des vecteurs de 32 éléments est exécutée par deux micro-instructions travaillant sur des vecteurs de 16 éléments. L'avantage est que cela se marie bien avec l'abandon des opérations pour les masques dont tous les bits sont à 0. Par exemple, prenons une instruction travaillant sur 16 flottants, exécutée en deux fois sur 8 flottants. Si le masque dit que les 8 premières opérations ne sont pas à exécuter, alors l'ALU ne fera que le calcul des 8 derniers flottants. Pour cela, le décodeur doit vérifier que les 8 bits de poids faible ainsi que de poids fort du registre de masque ne soient pas individuellement à zéro. Cela se fait en dupliquant la porte NOR évoquer plus haut , l'une pour la partie basse du registre et la seconde pour la partie haute. ==La prédication pour les structures de contrôle imbriquées== Au niveau du jeu d’instruction, les architectures SIMT implémentent de la prédication, sous une forme améliorée. Les processeurs SIMT actuels sont surtout utilisées sur les processeurs intégrés aux cartes graphiques. Et ces derniers gèrent très mal les branchements, et encore : beaucoup de cartes graphiques, même récentes, ne gèrent tout simplement pas les branchements. Elles doivent donc se débrouiller avec uniquement la prédication, là où les processeurs SIMD utilisent des branchements normaux en complément de la prédication. Insistons sur le fait que cet usage exclusif de la prédication n'est présent que sur une sous-partie des architectures SIMT, le seul exemple que l'auteur de ce wikilivre connait étant celui des cartes graphiques. Les architectures SIMT sans branchements doivent donc trouver des solutions pour gérer les structures de contrôle imbriquées, à savoir une boucle placée à l'intérieur d'une autre boucle, un IF...ELSE dans un autre IF...ELSE, etc. Elles utilisent pour cela la prédication, combinée avec des mécanismes annexes. Le premier d'entre eux est l'usage de plusieurs registres de masques organisés d'une manière bien précise, l'autre est l'usage de compteurs d'activité. Voyons ces deux techniques. ===La pile de masques=== La '''pile de masques''' remplace le ou les registres de masque. Sans elle, le processeur SIMD incorpore un registre de masque qui est adressé implicitement ou explicitement. Éventuellement, le processeur peut contenir plusieurs registres de masque séparés adressables via un nom de registre. Avec elle, le processeur SIMD incorpore plusieurs registres de masque organisé en pile. Le registre de masque est donc remplacé par une mémoire LIFO, une pile, dans laquelle plusieurs masques sont empilés. Le tout forme une pile, similaire à la pile d'appel, sauf qu'elle est utilisée pour empiler des masques. Un masque est calculé et empilé à chaque entrée dans une structure de contrôle, puis dépilé une fois la structure de contrôle exécutée. L'empilement et le dépilement des masques est effectué par des instructions PUSH et POP, présentes dans le jeu d'instruction du processeur SIMD. Le calcul des masques doit répondre à plusieurs impératifs. * Premièrement, chaque masque se calcule en faisant un ET entre le masque précédent et le masque calculé par l'instruction de test. Cela permet de ne pas réveiller d’élément au beau milieu d'une structure imbriquée. Si in IF désactive certains éléments du vecteur, une condition imbriquée dans ce IF ne doit pas réveiller cet élément. Le fait de faire un ET entre les masques garantit cela. * Deuxièmement, les masques doivent être empilés et dépilés correctement. Au moment de rentrer dans une structure de contrôle, on effectue une instruction de test associée à la structure de contrôle, qui calcule un masque, et on empile le masque calculé. Au moment de sortir de la structure de contrôle, on dépile le masque en question. L'implémentation demande d'utiliser une mémoire LIFO pour stocker la pile de masques, et quelques circuits annexes. Il faut notamment un circuit relié à l'ALU qui récupère les conditions, les résultats des comparaisons, et qui effectue le ET pour combiner les maques. Pour donner un exemple, prenons le code suivant, qui est volontairement simpliste et ne sert qu'à des fins d'explication : <syntaxhighlight lang="c"> if ( condition 1 ) { if ( condition 2 ) { ... } else { ... } Autres instructions } Instructions après le IF... </syntaxhighlight> Imaginons que l'on traite des vecteurs de 8 éléments. Pour le vecteur considéré, la première condition (a > 0) n'est respectée que par les 4 premiers éléments. L'instruction de condition calcule alors le masque correspondant : 1111 0000. Le masque est alors calculé, puis empilé au somment de la pile. La seconde instruction de test, qui teste la variable b, est maintenant valide pour les 4 bits du milieu du masque. Mais n'allez pas croire que le masque correspondant soit 0011 11100 : il faut tenir compte de la condition précédente, qui a éliminé les 4 derniers éléments. Pour cela, on fait un ET logique entre le masque précédent, et le masque calculé par la condition. Le masque au sommet de la pile est donc lu, combiné avec le masque calculé par l'instruction, ce qui donne le masque final. Le masque final est alors empilé au sommet de la pile. On exécute alors l'instruction du IF, en tenant compte du masque qui est au sommet de la pile. Si le IF était plus compliqué, toutes les instructions suivantes tiendraient compte du masque. En fait, le masque est pris en compte tant qu'il n'est pas dépilé. Une fois que le IF est terminé, le masque est dépilé. On passe alors au ELSE, et rebelotte. Le masque pour le ELSE est calculé en combinant le masque au sommet de la pile avec la condition du ELSE. Le masque au sommet de la pile est celui calculé à l'entrée du premier IF, pas le second qui a été dépilé. Les instructions du ELSE sont alors exécutées en tenant compte de ce masque. Une fois qu'elles sont toutes exécutées, le masque est dépilé. Puis vient l'exécution des instructions après le ELSE. Elles utilisent le masque empilé au sommet de la pile, qui correspond à celui à l'entrée du IF. Puis vient le moment d'exécuter les instructions après le IF : pas de masque, on exécute sur tout le vecteur. ===Les compteurs d'activité=== Une variante de la technique précédente remplace la pile de masques par des '''compteurs d'activité'''. La technique est similaire, si ce n'est qu'elle utilise moins de circuits. Avant , on avait une pile de masques de même taille, dont les bits sont à 0 ou 1 suivant que la condition est remplie. La pile de masque ressemble donc à ceci : {|class="wikitable" |- ! masque 1 | 1 || 1 || 1 || 1 |- ! masque 2 | 0 || 1 || 1 || 1 |- ! masque 3 | 0 || 1 || 1 || 1 |- ! masque 4 | 0 || 0 || 0 || 1 |- ! masque 1 | colspan="4" | vide |} Une manière équivalent de représenter cette pile de masque est de compter combien de bits sont à 0 dans chaque colonne. Attention : j'ai bien dit à 0 ! On obtient alors : {|class="wikitable" |- ! masque 1 | 3 || 1 || 1 || 0 |} Et c'est le principe caché derrière la techniques des compteurs d'activité. Chaque élément dans un vecteur, chaque place, se voit attribuer un compteur. Un compteur non-nul indique qu'il ne faut pas prendre en compte l’élément. Ce n'est qu'une fois que le compteur est nul que l'on effectue des opérations sur l’élément associé du vecteur. À chaque fois qu'on entre dans une structure de contrôle, on teste une condition sur chaque élément. Si la condition est respectée pour un élément, alors le compteur ne change pas. Mais si la condition n'est pas respectée, alors on incrémente le compteur associé. En sortant de la structure de contrôle, on décrémente le compteur associé. Notons que les compteurs qui n'ont pas été incrémenté en entrant dans la structure de contrôle ne sont pas décrémenté en sortant. En clair, là où on empilait/dépilait un masque, on se contente d'incrémenter/décrémenter un compteur. Utiliser un compteur en lieu et place d'une colonne entière dans la pile de masque utilise moins de bits. Et c'est sans doute pour cette raison que certaines cartes graphiques, comme les cartes graphiques intégrées d'Intel depuis 2004, utilisent cette technique. ==Les processeurs vectoriels== Les '''processeurs vectoriels''' sont les ancêtres des processeurs à instruction SIMD. Bien qu'ils soient arrivés en premier, ils incorporent diverses techniques que de simples instructions SIMD n'ont pas forcément. C'est paradoxal, mais c'est ainsi. La première différence se manifeste au niveau du jeu d'instruction. Les processeurs vectoriels sont capables de traiter des vecteurs de taille variable, alors que les instructions SIMD usuelles ne gèrent que des vecteurs de taille fixe. Par taille variable, on veut dire que le processeur gère nativement des vecteurs d'une taille allant de 1 à la taille maximale d'un vecteur. La taille maximale d'un vecteur est celle permise par la taille des registres vectoriels, il s'agit de la taille d'un vecteur SIMD équivalent sur les autres processeurs SIMD. La seconde différence est une différence en termes de micro-architecture. Un processeur SIMD actuel dispose d'une unité de calcul très élaborée, capable de faire plusieurs calculs en parallèle, au moins un pour chaque élément du vecteur. Par exemple, si un vecteur contient 16 entiers, l'ALU SIMD doit contenir au moins 16 additionneurs entiers. Mais sur un processeur vectoriel, ce n'est pas le cas : l'unité de calcul est en réalité une unité de calcul entière/flottante normale, mais qui a la particularité d'être pipelinée. Les calculs sont donc effectués en parallèle, mais d'une manière totalement différente. La plupart de ces différences s'expliquent par le fait que les anciens processeurs vectoriels devaient faire avec des limitations en termes de circuits. Ils avaient peu de transistors à leur disposition, ce qui fait que leur unité de calcul devait être la plus simple possible. D'où l'utilisation d'une unité de calcul simple, mais utilisée de manière pipelinée. De plus, les processeurs vectoriels ne possèdent aucune mémoire cache pour les données et se contentent juste de caches d'instruction. De tels caches sont généralement peu utiles quand on manipule des tableaux, chose quasiment systématique quand on travaille avec du parallélisme de données. ===Une unité de calcul pipelinée=== La différence entre processeur vectoriel et SIMD tient dans la façon dont sont traités les vecteurs : les instructions SIMD traitent chaque élément en parallèle, alors que les processeurs vectoriels pipelinent ces calculs ! Par pipeliner, on veut dire que l’exécution de chaque instruction est découpée en plusieurs étapes indépendantes. Au lieu d'attendre la fin de l’exécution d'une opération avant de passer à la suivante, on peut commencer le traitement d'une nouvelle donnée sans avoir à attendre que l'ancienne soit terminée. [[File:Pipeline.png|centre|vignette|upright=2|Pipeline vectoriel.]] Pour donner un exemple, on peut donner l'exemple d'une multiplication flottante effectuée entre deux registres. Son exécution peut être décomposée en plusieurs étapes. Par exemple, on peut avoir 3 étapes : * une première étape E qui va additionner les exposants et gérer les diverses exceptions ; * une étape M qui va multiplier les mantisses ; * et enfin une étape A qui va arrondir le résultat. L’exécution de notre opération flottante sur un vecteur donnerait donc quelque chose dans le genre, où chaque ligne correspond au traitement d'un nouvel élément dans un vecteur, dans un paquet SIMD. [[File:Multiplication vectorielle - pipeline.png|centre|vignette|upright=2|Multiplication vectorielle - pipeline]] : Certains processeurs vectoriels utilisent plusieurs ALU pipelinées pour accélérer les calculs. On peut les voir comme des intermédiaires entre processeur vectoriels et SIMD SWAR. Avec une unité de calcul pipelinée découpée en N étages, on peut gérer N données simultanées : autant qu'il y a d'étapes différentes. Mais ce nombre maximal de données met un certain temps avant d'être atteint. L'unité de calcul met du temps avant d'arriver à son régime de croisière. Durant ce temps, elle n'a pas commencé à traiter suffisamment d’éléments pour que toutes les étapes soient occupées. Ce temps de démarrage est strictement égal du nombre d'étapes nécessaires pour effectuer une instruction. La même chose arrive vers la fin du vecteur, quand il ne reste plus suffisamment d’éléments à traiter pour remplir toutes les étapes. [[File:Startup and dead time vector pipeline.png|centre|vignette|upright=2|Startup and dead time vector pipeline]] Pour amortir ces temps de démarrage et de fin, certains processeurs démarrent une nouvelle instruction sans attendre la fin de la précédente : deux instructions peuvent se chevaucher pour remplir les vides. Par contre, il peut y avoir des problèmes lorsque deux instructions qui se suivent manipulent le même vecteur. Il faut que la première instruction ait fini de manipuler un élément avant que la suivante ne veuille commencer à le modifier. Pour cela, il faut avoir des vecteurs suffisamment qui contiennent plus d’éléments qu'il n'y a d'étapes pour effectuer notre instruction. ===La technique du ''chaining''=== La technique du pipeline peut encore être améliorée dans certains cas particuliers. L'idée est simplement d'ajouter la technique du contournement (''bypass'') à l'unité de calcul. Pour rappel, le contournement connecte la sortie de l'ALU à une de ses entrées, ce qui permet au résultat d'un calcul d'être réutilisable au cycle d’horloge suivant. L'usage du contournement sur les processeurs vectoriels porte le nom de '''''Vector Chaining'''''. Un processeur implémentant le ''chaining''/''bypass'' a toutes ses unités de calcul reliées entre elles : la sortie d'une unité est reliée aux entrées de toutes les autres. Pour comprendre à quoi peut servir cette technique, on peut citer deux grands exemples principaux. Le premier avantage est que l'implémentation des instructions SIMD horizontales est beaucoup plus simple. C'est pour cela que les processeurs vectoriels intègrent souvent des instructions horizontales, et notamment des instructions arithmétiques horizontales. Le contraste avec les processeurs SIMD récents, qui utilisent plusieurs ALU travaillant en parallèle, est frappant. Ces derniers n’intègrent pas d'instructions horizontales arithmétiques, les seules opérations horizontales supportées sont généralement des permutations ou des instructions ''compress/expand'', guère plus. Maintenant, introduisons une autre possibilité par un exemple. Dans cet exemple, on suppose que le processeur dispose d'une ALU pour l'addition et d'une autre pour la multiplication. Imaginons que l'on ait trois vecteurs nommés A, B et C. Pour chaque énième élément de ces paquets, je souhaite effectuer le calcul <math>A_n + B_n \times C_n</math>. En théorie, il faudrait faire d'abord la multiplication, stocker le résultat temporaire de la multiplication dans un registre vectoriel, puis faire l'addition. Mais en rusant un peu, on peut utiliser le pipeline plus efficacement. Une fois que le premier élément de la multiplication du premier vecteur est connu, pourquoi ne pas démarrer l'addition immédiatement après, et continuer la multiplication en parallèle ? Après tout, les deux calculs ont lieu dans des ALUs séparés. Et le contournement permet d'alimenter l'ALU d'addition avec la sortie du circuit multiplieur. [[File:Vector chaining.png|centre|vignette|upright=2|Vector chaining]] ===La gestion des vecteurs de taille variable=== L'usage d'une unité de calcul pipelinée fait que la taille des vecteurs n'est pas forcément fixe. Autant les processeurs SIMD non-vectoriels disposent d'un nombre fixe d'unités de calcul, et peuvent donc gérer des vecteurs de taille fixe, autant ce n'est pas le cas des processeurs vectoriels. Une unité de calcul pipelinée à 16 étage peut utiliser les trois premiers pour traiter un vecteur de 3 élements, les 5 suivants pour un second vecteur de 5, et le reste pour un troisième vecteur. Reste que la gestion des vecteurs de taille variable doit être gérée au niveau du séquenceur, mais surtout : doit être permise au niveau du jeu d'instruction. Pour cela, il faut ajouter au jeu d'instruction de quoi indiquer la taille du vecteur en cours de traitement. Et c'est le rôle d'un registre spécialisé, le '''Vector Length Register''', que de gérer les vecteurs de taille variable. Le ''Vector Length Register'' est un registre qui indique combien d’éléments on doit traiter dans un vecteur. On peut ainsi dire au processeur : je veux que tu ne traites que les 40 premiers éléments présents d'un paquet. Ils sont utilisés pour gérer des tableaux dont la taille n'est pas un multiple d'un vecteur. Quand on arrive à la fin d'un tableau, il suffit de configurer le ''Vector Length Register'' pour ne traiter que ce qu'il faut. [[File:Vector length register.png|centre|vignette|upright=2|Vector length register.]] Il facilite l'usage du '''déroulage de boucle''', une optimisation qui vise à réduire le nombre d'itérations d'une boucle en dupliquant son corps en plusieurs exemplaires. Le compilateur réplique le corps de la boucle (les instructions à répéter) en plusieurs exemplaires dans cette boucle, avant de corriger le nombre d'itérations de la boucle. Par exemple, prenons cette boucle, écrite dans le langage C : <syntaxhighlight lang="c"> int i; for (i = 0; i < 100; ++i) { a[i] = b[i] * 7 ; } </syntaxhighlight> Celle-ci peut être déroulée comme suit : <syntaxhighlight lang="c"> int i; for (i = 0; i < 100; i+=4) { a[i] = b[i] * 7 ; a[i+1] = b[i+1] * 7 ; a[i+2] = b[i+2] * 7 ; a[i+3] = b[i+3] * 7 ; } </syntaxhighlight> Les instructions vectorielles permettent de traiter plusieurs éléments à la fois, ce qui fait que plusieurs tours de boucles peuvent être rassemblés en une seule instruction SIMD. Le déroulage de boucles permet d'exposer des situations où ce regroupement est possible. Dans notre exemple, si jamais notre processeur dispose d'une instruction de multiplication capable de traiter 4 éléments du tableau a ou b en une seule fois, la boucle déroulée peut être vectorisée assez simplement en utilisant une multiplication vectorielle (que nous noterons vec_mul). <syntaxhighlight lang="c"> int i; for (i = 0; i < 100; i+=4) { vec_a[i] = vec_mul ( vec_b[i] , 7 ) ; } </syntaxhighlight> Le déroulage de boucles n'est toutefois pas une optimisation valable pour toutes les boucles. Reprenons l'exemple de la boucle vue plus haut. Si jamais le tableau à manipuler a un nombre d’éléments qui n'est pas multiple de 4, la boucle ne pourra être vectorisée, vu que la multiplication vectorielle ne peut traiter que 4 éléments à la fois. Pour ce faire, les compilateurs utilisent généralement deux boucles : une qui traite les éléments du tableau avec des instructions SIMD, et une autre qui traite les éléments restants avec des instructions non vectorielles. Cette transformation s'appelle le '''strip-mining'''. Par exemple, si je veux parcourir un tableau de taille fixe contenant 102 éléments, je devrais avoir une boucle comme celle-ci : <syntaxhighlight lang="c"> int i; for (i = 0; i < 100; i+=4) { a[i] = b[i] * 7 ; a[i+1] = b[i+1] * 7 ; a[i+2] = b[i+2] * 7 ; a[i+3] = b[i+3] * 7 ; } for (i = 100; i < 102; ++i) { a[i] = b[i] * 7 ; } </syntaxhighlight> Les processeurs vectoriels utilisent le ''Vector Length Register'' pour éviter d'utiliser le strip-mining. Avec ce registre, il est possible de demander aux instructions vectorielles de ne traiter que les n premiers éléments d'un vecteur : il suffit de placer la valeur n dans le ''Vector Length Register''. Évidemment, n doit être inférieur au nombre d’éléments maximal du vecteur. Avec ce registre, on n'a pas besoin d'une seconde boucle pour traiter les éléments restants, et une simple instruction vectorielle peut suffire. L'avantage principal est que cela réduit la portion de code non-parallélisable, donc augmente l'efficience SIMD. Mais l'intérêt n'est pas qu'une question de code. Il faut aussi prendre en compte que cela permet d'utiliser l'ALU plus efficacement. Dès qu'un vecteur court est terminé, le processeur peut immédiatement démarrer le calcul d'un autre vecteur, le vecteur suivant, l'instruction suivante, sans laisser de cycles inutilisés. Avec un processeur SIMD non-vectoriel, on aurait du utiliser de la prédication, donc utiliser seulement une partie des ALU SIMD, ce qui aurait été un gâchis. ===Les accès mémoire=== Les accès mémoire des processeurs vectoriels sont sensiblement les mêmes que ceux des autres processeurs SIMD. Les instructions d'accès mémoire sont les mêmes, on retrouve les modes d'adressages précédents, dont les accès en ''scatter-gather'' et en ''stride''. Sur certains processeurs vectoriels, les instructions vectorielles lisent et écrivent en mémoire RAM, sans passer par des registres : on parle de '''processeurs vectoriels mémoire-mémoire'''. Elles n'avaient pas d'instructions LOAD et STORE, les instructions arithmétiques géraient directement la lecture des opérandes en mémoire, et incrémentaient automatiquement les adresses à lire/écrire. Elles pouvaient gérer des vecteurs de taille arbitraire, la gestion d'un tableau se faisait parfois en une seule instruction ! Le problème est que l'absence de registres pour stocker les données lues réduisait grandement les performances. [[File:Processeur vectoriel mémoire-mémoire.png|centre|vignette|upright=2|Processeur vectoriel mémoire-mémoire]] Les processeurs vectoriels mémoire-mémoire étaient cependant minoritaires, la grosse majorité des processeurs vectoriels récents possèdent des registres vectoriels dédiés aux vecteurs. La plupart sont des architectures de type LOAD-STORE, mais pas toute. De fait, il n'y a pas de lien strict entre adressage des opérandes et processeurs vectoriel (ou SIMT). Seuls les processeurs SIMD de type SWAR ont une restriction là-dessus et sont systématiquement des architectures LOAD-STORE. [[File:Processeur vectoriel à registres vectoriels.png|centre|vignette|upright=2|Processeur vectoriel à registres vectoriels.]] Il faut noter que l'implémentation des accès mémoire est potentiellement différente de celle des autres processeurs SIMD. En principe, on peut utiliser une implémentation identique entre les deux. Mais le fait que les processeurs vectoriels sont pipelinés fait qu'il est préférable d'utiliser une implémentation différente, qui se base sur une RAM ou des caches pipelinés. Vu que l'ALU reçoit une/deux opérandes par cycle, l'idéal est que la mémoire ou le cache aillent au même rythme et soient donc pipelinés.Bien sur, il est possible d'utiliser une implémentation plus classique, avec des caches multiports, un chargement en plusieurs fois des vecteurs depuis le cache, des circuits pour réorganiser les accès mémoire, etc. En théorie, les architectures vectorielles font peu usage de mémoires cache. Les transferts entre registres vectoriels et mémoire RAM sont directs, sans intermédiaire. La raison est que les applications qui tirent parti du SIMD ont une bonne localité spatiale, mais une mauvaise localité temporelle. Or, les caches de données profitent surtout de la localité temporelle pour donner de bonnes performances. De plus, rappelons que les processeurs vectoriels sont assez anciens et datent d'une époque où les transistors étaient précieux. Il valait mieux utiliser des transistors dans un gros cache d'instruction que pour un cache de donnée peu efficace. ==Les systèmes SIMD à plusieurs processeurs : le SIMT== Enfin, terminons avec le dernier type de SIMD, qui date du tout début de l'informatique : celui où on combine plusieurs processeurs non-SIMD. L'idée est de synchroniser plusieurs processeurs de manière à ce qu'ils exécutent la même instruction en même temps, chacun sur des données différentes. On parle d''''exécution ''lockstep''''' des instructions. Toute la difficulté est de garantir la synchronisation des processeurs, garantir que tous les processeurs exécutent la même instruction. Ce qui pose problème quand des branchements sont impliqués, des processeurs pouvant prendre le branchement et pas les autres. ===Les ''array processors'' des temps anciens=== Les premières architectures matérielles conçues pour le parallélisme de données étaient de ce type. On peut par exemple citer l'exemple des Thinking machines CM-1 et CM-2, qui exécutaient tous la même instruction au même cycle d'horloge sur 64000 processeurs minimalistes. Un autre exemple est celui de l'ILLIAC IV. Historiquement, le terme utilisé pour désigner de telles architectures était '''array processors''', encore que ce terme fût réservé aux systèmes multiprocesseurs. Par la suite, le terme ''Single Instruction Multiple Threads'', ou SIMT, a été utilisé. Mais ce terme a ensuite été repris par le marketing de NVIDIA pour désigner une technique précise de SIMD avec prédication, utilisée sur ses cartes graphiques récentes. Le mauvais usage de la terminologie fait que le terme SIMT est devenu polysémique et quelque peu trompeur. L'ILLIAC 4 était un ordinateur un peu particulier, presque unique en son genre. Son architecture était composé d'un processeur principal couplé à plusieurs processeurs secondaires. Il y avait en tout un processeur principal et 64 processeurs secondaires. Le processeur principal chargeait les instructions depuis la mémoire, les décodaient, puis envoyait les instructions aux processeurs secondaires. Le processeur principal était le seul à avoir un ''program counter'', il était utilisé pour gérer les boucles, ainsi que pour configurer les instructions à prédication. Cependant, le processeur principal n'était pas un simple séquenceur : il pouvait exécuter des instructions, avait un accumulateur et des registres, etc. Il prenait en charge toute instruction non-SIMD, alors que les instructions SIMD étaient envoyées aux processeurs secondaires. Les 64 processeurs secondaires avaient chacun : une unité de calcul, un ''local store'' de 2048 flottants de 64 bits, une unité mémoire. Ils n'ont pas de séquenceur ni de ''program counter'', ni de quoi charger une instruction. L'unité de calcul est appelée un ''processing element'' ou PE. Elle intègre un additionneur flottant, de quoi faire des décalages, et une unité de calcul logique. De plus, un PE intègre des registres accumulateurs et de quoi calculer des adresses : un registre d'indice, un registre d'adresse, un circuit de calcul d'adresse. L'unité mémoire connectait l'ALU entière PE et le ''local store'', et elle permettait aussi d'adresser des entrées-sorties. Pour gérer la prédication, les processeurs secondaires pouvaient être activés ou désactivés suivant le résultat des branchements/conditions. Intuitivement, on peut faire le rapprochement avec un processeur SIMD. Le processeur principal regrouperait la partie non-SIMD d'un processeur SIMD, alors que les processeurs secondaires seraient l'unité de calcul SIMD. Cependant, il y a une différence très importante : il n'y a pas de banc de registre vectoriel. À la place, chaque processeur secondaire a son propre banc de registre, et ce sont des registres scalaires ! Une autre différence est que la mémoire RAM est découpée en plusieurs ''local store'' de 2048 adresses, chacun étant relié à une unité de calcul PE. N'oublions pas le système de calcul d'adresse et l'unité LOAD/STORE qui est fusionnée avec l'unité SIMD, chaque PE ayant ses circuits de calcul d'adresse, sa propre unité mémoire, etc. ===Les anciennes cartes graphiques hybrides SIMD-VLIW=== Une technique de SIMD multi-processeur a été utilisée autrefois sur certaines cartes graphiques AMD à architecture Terascale. La différence est que l'architecture en question était multicœur et non multi-processeurs. Elles contenaient plusieurs cœurs VLIW, qui fonctionnaient en ''lockstep''. Un groupe de 16 processeurs VLIWs exécutaient la même opération VLIW. Du moins, c'est ce que semblent dire les [https://www.techpowerup.com/gpu-specs/docs/amd-gcn1-architecture.pdf white papers de présentation de l'architecture GCN]. L'usage de cet hybride SIMD-VLIW était très adapté au traitement graphique. Les cartes graphiques post-années 2000 sont capables d'exécuter des programmes appelés des '''''shaders''''', qui sont utilisés en rendu 3D, pour calculer certaines scènes 3D, et notamment pour calculer les ombres (d'où leur nom de shader, pour shade - ombre). Or, ces shaders sont justement exécutés par la carte graphique, sur les processeurs de shaders. Un processeur de ''shader'' applique un programme/''shader'' sur chaque ''pixel'' ou triangle d'une scène 3D (en fait des fragments et des vertices, mais faisons cette simplification). Chaque triangle ou pixel d'une scène 3D peut être traité indépendamment des autres, ce qui rend le traitement 3D fortement parallèle. Aussi, l'usage du SIMD est assez naturel : chaque processeur exécute un ''shader'' sur un pixel/triangle, utiliser une architecture SIMD parait naturel. Reste à justifier l'usage de plusieurs CPU VLIW. La raison tient dans la manière dont est traité un pixel par un shader. Un pixel est codé en utilisant quatre informations. Premièrement, une couleur codée avec trois nombres flottants, qui représentent une couleur au format RGB avec un mélange de rouge, vert et bleu, chaque couleur rouge/bleu/vert étant codée avec un flottant de 32 bits. La quatrième information est un nombre qui code la transparence du pixel. Généralement, le shader traite différemment la transparence de la couleur RGB, dans le sens où les instructions utilisées ne sont pas les mêmes. Au lieu de traiter couleur RGB et transparence séparément, elles sont traitées en même temps en parallèle, ce qui demande de pouvoir émettre deux instructions : une pour le calcul de la transparence, une autre pour la couleur RGB. Fait amusant, l'instruction utilisée pour gérer la composante RGB est une instruction vectorielle qui travaille sur des vecteurs de 3 couleurs. Les architectures VLIW sont parfaites pour cela. Les cartes graphiques de l'époque de DirectX 9 utilisaient cette méthode qui mélangeait SIMD et VLIW. Elles disposaient de plusieurs cœurs VLIW qui exécutaient le même shader en exécution ''lockstep''. Chaque cœur traitait au moins un pixel à la fois, ou un triangle à la fois. Cependant, nous devons préciser que les cœurs VLIW partagent la même unité de contrôle, ce sont en réalité des chemins de données plus qu'autre chose. L'architecture était aussi combinée avec du ''Fine Grained Multithreading'' afin de pouvoir gérer les accès mémoire, de telles architectures ne pouvant pas se permettre d'utiliser l'exécution dans le désordre. De telles architectures multicœurs à exécution ''lockstep'' permettent quelques optimisations, comme le fait de partager le cache d'instruction entre les cœurs. ==Résumé des différents types de processeurs SIMD== Dans ce chapitre, nous avons appris qu'il existe plusieurs catégories de processeurs SIMD. Les différences entre ces catégories tiennent à la fois dans le jeu d'instruction, mais aussi dans la microarchitecture des processeurs en question. Au niveau du jeu d'instruction, il y a trois paliers, qui sont résumés dans le tableau ci-dessous. {|class="wikitable" |- ! ! Support de la prédication ! Taille des vecteurs ! Instructions horizontales ! Architecture LOAD-STORE ! Pointeur de pile |- ! Processeurs SIMD purs | Non | rowspan="3" | Fixe | rowspan="3" | Limitées à des échanges de données à l'intérieur d'un vecteur, pas d'instructions de réduction | Oui | rowspan="2" | Unique |- ! Processeurs SIMD avec prédication | Oui, par définition | rowspan="3" | Dépend du processeur |- ! Processeurs SIMT | rowspan="2" | Oui, sauf exceptions | Un pointeur de pile possible par élément du vecteur |- ! Processeurs vectoriels | Variable | Toutes supportées, y compris les instructions arithmétiques horizontales | Unique |} Au niveau de la microarchitecture, il y a trois paliers, qui sont résumés dans le tableau ci-dessous. {|class="wikitable" |- ! ! Unité de calcul ! Banc de registre ! Adressage des opérandes |- ! Processeurs SIMD | rowspan="2" | Plusieurs ALU simples non-pipelinée, plusieurs additionneurs/multiplieurs/autres | Banc de registres vectoriels | Architecture LOAD-STORE |- ! Processeurs SIMT | Plusieurs bancs de registres scalaires (non-vectoriels, registres normaux) | rowspan="2" | Dépend du processeur |- ! Processeurs vectoriels | ALU simple unique, pipelinée | Banc de registres vectoriels |} Les processeurs SIMD purs ont des performances différentes des processeurs vectoriels, vu que les premiers font leurs calculs en parallèle, alors que les CPU vectoriels utilisent un pipeline. Mais dans les grandes lignes, les performances sont similaires. Les différences sont grandement atténuées par l'usage du ''vector chaining'', de la gestion des vecteurs de taille variables, et des autres optimisations spécifiques aux processeurs vectoriels. L'avantage est cependant du côté des processeurs SIMD pur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Architectures multithreadées et Hyperthreading | prevText=Architectures multithreadées et Hyperthreading | next=La cohérence des caches | nextText=La cohérence des caches }} {{autocat}} </noinclude> tnb1xf2gelhrtnx1gfyavw297gthg86 Fonctionnement d'un ordinateur/Architectures multithreadées et Hyperthreading 0 65961 771170 763290 2026-08-22T01:28:14Z Mewtow 31375 /* Le Fine Grained MultiThreading */ 771170 wikitext text/x-wiki Vous pensez surement qu'il faut obligatoirement plusieurs cœurs pour exécuter plusieurs programmes en parallèle, mais sachez que c'est faux ! Les processeurs mono-cœur en sont capables, en alternant entre les programmes à exécuter. Plusieurs programmes s’exécutent donc sur le même processeur, mais chacun à leur tour et non en même temps. D'ordinaire, cette alternance est gérée par le système d'exploitation, mais certains processeurs gèrent cette alternance eux-mêmes, directement au niveau matériel. L'idée est d'exécuter plusieurs programmes en même temps sur le même processeur, le processeur commutant de l'un à l'autre suivant les besoins. Par exemple, si un programme est bloqué par un accès mémoire, le processeur donne la main à un autre programme. Les processeurs en question sont appelés des processeurs multithreadés, ou encore des '''architectures multithreadées''', en référence au terme ''thread'', qui est plus ou moins équivalent à celui de programme dans ce cours. Ils exécutent un ''thread'' à la fois, mais changent de ''thread'' plus ou moins régulièrement. Pour comprendre le pourquoi des architectures multithreadées, il faut rappeler qu'il arrive que l'unité de calcul d'un processeur ne fasse rien, par exemple pendant que le processeur accède à la mémoire. Les cycles d'horloge où l'unité de calcul est inutilisée sont des '''cycles gâchés'''. L’exécution dans le désordre réduit ces cycles gâchés, mais les architectures multithreadées sont une solution alternative et complémentaire. Elles visent à ce que les cycles gâchés d'un ''thread'' soient remplis par les calculs d'autre ''thread''. Il existe trois techniques de ''multithreading'' matériel : le ''Fine Grained Multithreading'', le ''Coarse Grained Multithreading'' et le ''Simultaneous MultiThreading''. Le ''Simultaneous MultiThreading'' est spécifique aux processeurs superscalaires, alors que les autres techniques fonctionnent sur tous les processeurs, qu'ils soient superscalaire ou simple-émission. : Dans ce qui suit, nous utiliserons l'abréviation FGMT pour parler du ''Fine Grained Multithreading'', de CGMT pour parler du ''Coarse Grained Multithreading'' et de SMT pour le ''Simultaneous MultiThreading''. ==Le multithreading temporel== Le FGMT et le CGMT sont regroupés sous le terme de '''multithreading temporel'''. Les deux partagent en effet un même point commun : à chaque cycle, les instructions émises par l'unité d'émission proviennent d'un seul programme. La distinction n'a de sens que sur les processeurs à exécution multiple, ce sera plus clair dans la suite du chapitre quand on comparera ''multithreading'' temporel et SMT. La différence entre FGMT et CGMT est la fréquence des changements de ''thread'' : à chaque cycle ou presque pour le FGMT, lors d'un évènement bien précis pour le CGMT. Le processeur subit des changements pour gérer plusieurs ''threads'' en cours d'exécution. En premier lieu, il y a un ''Program Counter'' par ''thread''. À chaque cycle, un multiplexeur choisit le ''Program Counter'' - le thread - qui a la chance de charger ses instructions. Le choix du ''program counter'' sélectionné est le fait de l''''unité d'ordonnancement''', dont le fonctionnement dépend du processeur, comme nous le verrons plus bas. L'unité d'ordonnancement sait quels ''threads'' sont en cours d'exécution et lesquels sont en pause. Pour cela, elle intègre une petite mémoire qui mémorise l'état de chaque ''thread''. [[File:Architecture d'un processeur multithreadé.png|centre|vignette|upright=2|Architecture d'un processeur multithreadé]] Ensuite, le processeur attribue un numéro à chaque ''thread'', appelé le ''thread ID''. Il y en autant que de ''threads'' exécutables simultanément par le processeur. Par exemple, si le processeur gère au maximum 8 ''threads'' simultanés, l'identifiant de ''thread'' va de 0 à 7 et est codé sur 3 bits. Le ''thread ID'' est propagé dans le pipeline, histoire de savoir à quel ''thread'' appartient l'instruction en cours. Il sert notamment pour adresser le banc de registre, mais aussi pour d'autres choses. Les processeurs à ''multithreading'' temporel sont généralement des processeurs sans exécution dans le désordre, et n'ont donc pas de renommage de registres. Il est donc obligatoire de dupliquer les registres, pour que chaque programme ait ses registres architecturaux rien qu'à lui. Cela demande soit un banc de registre par programme, soit un banc de registre commun géré par fenêtrage de registre (chaque programme ayant sa propre fenêtre de registres). Il faut aussi dupliquer les ''load-store queue'', pour séparer les lectures/écritures en attente de chaque ''thread''. Il est possible d'utiliser une ''load-store queue'' unique dans laquelle chaque lecture/écriture mémorise le ''thread ID'', pour identifier le ''thread'' qui l'a émise. [[File:Aperçu de l'architecture d'un processeur multithreadé.png|centre|vignette|upright=2.5|Aperçu de l'architecture d'un processeur multithreadé]] ===Le ''Coarse Grained Multithreading''=== [[File:Coarse Grained Multithreading.png|vignette|upright=1|Coarse Grained Multithreading.]] Le '''Coarse Grained Multithreading''' change de programme quand un évènement bien précis a lieu. L'évènement en question fait que l'ALU restera inutilisé pendant un moment : accès à la mémoire, branchements, etc. Sur certains processeurs CGMT, il y a une instruction précise pour changer de ''thread''. La commutation de ''thread'' est alors totalement ou partiellement décidée par le logiciel. Mais il s'agit là d'un cas particulier. Le cas le plus fréquent est de changer de ''thread'' lors d'un défaut de cache. Vu que l'accès à la RAM est quelque chose de très lent, il est intéressant d'exécuter des instructions d'un autre ''thread'' pour recouvrir l'accès à la RAM. L'idée est similaire à ce qu'on a avec les lectures non-bloquantes et/ou l'exécution dans le désordre : pendant que l'unité d'accès mémoire gère le défaut de cache, on alimente l'unité de calcul avec des calculs indépendants. Sauf qu'avec le ''multithreading'', les calculs proviennent d'un autre ''thread''. Un point important est que le cache doit être un cache non-bloquant, sans quoi le ''multithreading'' matériel ne fonctionne tout simplement pas. Par exemple, prenons une architecture qui change de ''thread'' à chaque défaut de cache. Si on veut supporter plus de deux ''threads'', il faut que plusieurs ''threads'' subissent un défaut de cache pour que le ''multithreading'' ait de l'intérêt, ce qui implique un cache non-bloquant. [[File:Multithreading et mitigation de la latence mémoire.png|centre|vignette|upright=1.5|Multithreading et mitigation de la latence mémoire.]] Avec le CGMT, on est certain que toutes les instructions en cours d’exécution appartiennent au même ''thread''. Quand il passe d'un ''thread'' à l'autre, le processeur attend naturellement que le pipeline soit complétement vidé avant de charger les instructions du ''thread'' suivant. Le processeur peut donc se débrouiller avec un simple '''registre de ''thread''''' qui mémorise le ''thread ID'' du ''thread'' en cours d'exécution. Le registre de ''thread'' est directement connecté à l'entrée d'adresse du banc de registre, pour gérer le fenêtrage de registres. Il est aussi connecté à la ''load-store queue'', et surtout au multiplexeur de choix de ''thread'', dans l'unité de chargement. [[File:Implementation du CGMT.png|centre|vignette|upright=2|Implémentation du CGMT]] L'unité d'ordonnancement détermine quel ''thread'' charger dans le pipeline, elle sélectionne un ''thread ID''. Pour faciliter son travail, cette unité contient un registre qui mémorise quels sont les ''threads'' actifs. Les ''threads'' bloqués par un défaut de cache sont marqués comme inactifs tant que le défaut de cache n'est pas résolu. Évidemment, dès qu'un défaut de cache est résolu, l'unité d'accès mémoire prévient l'unité de choix de ''thread'' pour que celui-ci marque le ''thread'' adéquat comme de nouveau actif. Le registre est composé de N bits sur un processeur qui gère N threads maximum : chaque bit est associé à un ''thread'' et indique s'il est actif ou non. ===Le ''Fine Grained MultiThreading''=== [[File:Fine Grained Multithreading.png|vignette|upright=1|''Barrel processor''.]] Le '''''Fine Grained Multithreading''''' regroupe deux types de processeurs différents. Le premier type est celui des '''''barrel processors''''', qui changent de programme à chaque cycle d'horloge. Idéalement, chaque étage du pipeline est utilisé par une instruction différente. L'avantage est que, à l'intérieur d'un ''thread'', une nouvelle instruction démarre quand la précédente est terminée. Le résultat d'une instruction est déjà dans les registres quand l'instruction suivante s’exécute. Il n'y a donc pas de dépendances entre instructions successives, les circuits liés aux dépendances de données sont fortement simplifiés, le réseau de contournement de l'ALU disparait, l'unité de prédiction de branchement disparait (car le résultat d'un branchement est connu avant que le même ''thread'' exécute sa prochaine instruction). Mais les accès mémoire sont gérés à part des autres instructions. Lorsque un ''thread'' effectue un accès mémoire, il est mis en pause et d'autres ''threads'' sont lancés à sa place. Prenons l'exemple d'un processeur qui gère 16 ''threads'' maximum : si l'un d'eux est mis en pause, le processeur changera de ''thread'' tous les 15 cycles au lieu de 16. Mais pour que cela fonctionne sans encombre, on est obligé d'avoir un nombre assez élevé de programmes en cours d’exécution. Pour donner un exemple, le processeur MTA était capable d'exécuter un 128 ''threads'' maximum, pour un pipeline de 21 étages. La marge est élevée, mais cela compense le fait que le processeur n'a pas de cache, avec des accès mémoire prenant 150-170 cycles d'horloge ! Le désavantage est que les registres étaient dupliqués 128 fois ! Le processeur avait près d'un millier de registres, ce qui est énorme pour l'époque. [[File:Full multithreading.png|vignette|upright=1|Processeur à ''Fine Grained Multithreading''.]] Utiliser un ''barrel processor'' au mieux demande donc qu'il y ait un grand nombre de ''threads'' en cours d'exécution. Mais quelques processeurs FGMT font autrement, en se rapprochant du CGMT. Ils peuvent exécuter un ''thread'' durant quelques cycles successifs, plus d'une dizaine, voire plus. Cependant, il s'agit de processeurs sans exécution dans le désordre, qui sont bloqués dès qu'ils tombent sur une instruction dépendante. Et justement, ils masquent ces blocages en changeant de thread au lieu d'émettre des bulles de pipeline. La technique est beaucoup utilisée sur les cartes graphiques modernes, et sur certains processeurs spécialisés. Le désavantage est qu'ils doivent intégrer des ''scoreboards'' pour gérer les dépendances de données, sauf sur quelques processeurs laissaient la détection des dépendances au compilateur ! Un exemple historique assez ancien est le processeur Tera MTA (''MultiThreaded Architecture''), qui introduit la technique de l''''anticipation de dépendances explicite''' (''Explicit-Dependence lookahead''). L'idée est que chaque instruction contient quelques bits pour dire au processeur : tu peux lancer 1, 2, 3 instructions à la suite, avant de changer de ''thread''. Qu'il s'agisse des ''barrel processor'' ou des autres processeurs FGMT, tous ont une implémentation similaire. Contrairement aux processeurs CGMT, il n'y a pas de registre de ''thread ID'' unique. En effet, le processeur doit savoir, pour chaque instruction dans le pipeline, à quel ''thread'' elle appartient. Pour cela, les ''thread ID'' sont générés lors du chargement et propagés dans le pipeline en même temps que les instructions, où il est utilisé par le banc de registre, dans les ''load store queues'', etc. Pour résumer : propagation du ''thread ID'' dans le pipeline et disparition du registre de ''thread'' global. Avec le FGMT, le processeur charge et décode les instructions, avant de les placer dans plusieurs files d'instruction. Il y a une file d'instruction par ''thread''. Le choix de la file d'instruction est réalisé par un multiplexeur, commandé par une super-unité d'émission qui décide quel ''thread'' émet ses instructions (''thread issue unit''). [[File:FGMT sur un processeur de shaders.png|centre|vignette|upright=2.5|FGMT sur un processeur.]] L'unité d'émission en question contient un ''scoreboard'' par ''thread'', et combine leurs résultats pour décider quel ''thread'' émet une instruction. Sur les ''barrel processor'', le ''scoreboard'' ne gère que les dépendances liées aux accès mémoire, pour gérer les ''threads'' mis en pause et les re-démarrer quand la lecture est terminée. Sur les autres processeurs, les ''scoreboard'' gèrent les dépendances entre instructions. Les unités de choix de ''thread'' fonctionnent différemment entre les ''barrel processors'' et ceux qui peuvent exécuter plusieurs instructions successives d'un même ''thread''. Pour ces derniers, l'implémentation demande une coopération entre l'unité d'émission et l'unité de chargement. Le processeur profite du fait que le chargement des instructions depuis la mémoire et leur exécution se font en même temps : le processeur charge des instructions pendant qu'il en exécute d'autres. Si un thread émet une instruction, ce même thread charge une instruction au même moment. Sauf si la file d'instruction du thread est déjà pleine, auquel cas un autre thread est choisi. Il est possible de tenir compte du contenu de la fenêtre d'instruction pour décider quels ''threads'' sont éligibles au chargement. Il est possible de mettre en pause un ''thread'' si celui-ci accumule un peu trop d'instructions dans la fenêtre d'instruction. C'est en effet signe que ce ''thread'' est bloqué par un accès mémoire, une instruction multicycle ou des dépendances de données. À l'inverse, il est possible de prioriser les ''threads'' qui n'ont presque aucune instruction dans la fenêtre d'instruction. Car ce sont des ''threads'' qui s'exécutent rapidement au point de vider la fenêtre d'instruction. L'idée est de charger en priorité les instructions du ''thread'' pour lequel la file d'instruction est la moins remplie. L'unité de choix du ''thread'' utilise cette information pour déterminer quel ''thread'' charger au prochain cycle. ==Le ''Simultaneous multithreading'' : une spécificité des processeurs superscalaires== Les deux techniques vues au-dessus peuvent s'adapter sur les processeurs à émission multiple, à savoir qui sont capables d'émettre plusieurs instructions simultanément. La seule contrainte est que l'implémentation du FGMT est compliquée sur les architectures superscalaires, ce qui fait que les ''barrel processors'' sont généralement des processeurs VLIW ou sans émission multiple. Mais il existe une technique de ''multithreading'' matériel spécialement pensée pour les processeurs à émission multiple : le ''Simultaneous multithreading''. La comprendre est assez simple si on la compare au FGMT et au CGMT. Avec le ''multithreading'' temporel, lors d'un cycle d'horloge, les instructions émises appartiennent au même ''thread'' matériel. Il n'y a pas de situation où une instruction du ''thread'' 1 est émise en même temps qu'une instruction du ''thread'' 2. {| |[[File:CGMT sur processeur superscalaire.png|vignette|upright=1.5|CGMT sur processeur superscalaire]] |[[File:FGMT sur processeur superscalaire.png|vignette|upright=1.5|FGMT sur processeur superscalaire]] |} Le '''Simultaneous Multi-Threading''', abrégé en SMT, permet d'émettre simultanément des instructions provenant de ''threads'' séparés. Elle fonctionne sur les processeurs superscalaires, mais n'est pas possible sur les processeurs VLIW. [[File:Simultaneous Multi-Threading.png|centre|vignette|upright=2|Simultaneous Multi-Threading]] ===L'implémentation matérielle du SMT=== Dans les faits, tous les processeurs SMT sont des processeurs à exécution dans le désordre. Non pas que ce soit obligatoire, juste que le cout d'implémentation est bien plus faible sur un processeur à exécution dans le désordre. L'implémentation du SMT prend un processeur à exécution dans le désordre, ajoute plusieurs ''program counter'' et les circuits adéquats. L'unité d'émission choisit quelles instructions envoyer aux ALU, en utilisant l'exécution dans le désordre : tant qu'elles sont indépendantes, elles peuvent s’exécuter en parallèle. Et deux instructions de deux ''threads'' différents sont indépendantes ! Il y a donc un lien étroit entre exécution dans le désordre et SMT ! Ironiquement, avec l'amélioration des techniques d'exécution dans le désordre, le SMT commence à perdre de sa superbe. Plus l'exécution dans le désordre est efficace, plus le SMT est inutile. Si l'exécution dans le désordre est trop efficace, les cycles gâchés sont trop rares pour que le SMT ne servent pas à grande chose. Quand les cycles gâchés représentent grand maximum 10% du temps d’exécution grâce à l'usage de l’exécution dans le désordre, le SMT n'a plus grand-chose à remplir et le second programme s’exécuterait trop lentement pour ça vaille le coup. Le SMT s'implémente généralement avec une seule fenêtre d’instruction et avec le renommage de registres. Le renommage de registres fait qu'on n'a pas à utiliser de fenêtrage de registres, juste à augmenter la taille du banc de registres. L'idée est que l'on concatène le ''thread ID'' au nom de registre avant de faire le renommage. Comme cela, on garantit que deux registres architecturaux identiques mais référencés dans des ''threads'' différents, correspondront à des registres physiques différents. Ainsi, l'unité d'émission a juste à vérifier les dépendances entre registres, pas besoin de gérer les ''thread ID''. En faisant cela, la logique d'émission est beaucoup plus simple. Par contre, les tables pour le renommage de registre sont dupliqués, avec autant de table que de ''thread'', chaque ''thread'' a la sienne. Comme avec le FGMT, en cas des mauvaise prédiction de branchement, seules les instructions du ''thread'' fautif doivent être vidées. Pour cela, les files/fenêtres d'instruction doivent savoir à quel ''thread'' appartient tel ou telle instruction, pareil pour le ''Reorder Buffer''. Pour cela, on ajoute, dans chaque entrée de ces structures, le ''thread ID'' de l'instruction. Avec le SMT, les différents ''threads'' doivent se partager certaines ressources matérielles : la fenêtre d'instruction, le banc de registres, la ''load store queue'', le cache, l'unité de prédiction de branchement ou d'autres structures matérielles. Le partage de ces ressources entre ''threads'' est dynamique, sauf pour quelques exceptions. La conséquence est qu'il peut y avoir une perte de performance si on lance deux ''threads'' et qu'il n'y a pas assez de ressources matérielles pour en profiter. Les ''threads'' entrent en compétition pour y accéder. Parlons un petit peu du partage du cache et de l'unité de prédiction de branchement. ===Le partage des caches=== La quasi-totalité des architectures multithreadées utilisent un cache partagé, et non des caches dédiés, ce qui impose de gérer le partage des caches entre les différents ''threads''. La première idée est de partitionner le cache en plusieurs morceaux, avec un par ''thread''. Le '''partitionnement statique''' donne des portions égales à chaque programme, ce qui n'est pas toujours optimal mais a l'avantage d'être facile à implémenter. À l'opposé, le '''partitionnement dynamique''' partage le cache selon les besoins des ''thread'' en cours d'exécution. Si un programme a besoin de plus de place que l'autre, il peut réserver un peu plus de place que son concurrent. Mais la gestion du partitionnement est alors plus compliquée, et demande de partitionner efficacement le cache, sans quoi les deux programmes exécutés risquent de se marcher dessus et d'entrer en compétition pour le cache. [[File:Partitionnement de l'Instruction Buffer avec l'hyperthreading.png|centre|vignette|upright=2|Partitionnement des caches avec l'hyperthreading.]] Une autre possibilité est de ne pas partitionner et de laisser les différents ''threads'' accéder au cache comme bon leur semble. Si le cache est physiquement tagué, il n'y a aucune modification à faire. Mais si le cache est virtuellement tagué, une même adresse virtuelle peut correspondre à des adresses physiques différentes pour deux ''threads'' différents. Pour éviter cela, il faut ajouter l'identifiant de ''thread'' dans chaque ligne de cache, dans les bits de contrôle. La détection des succès ou défaut de cache tient compte de l'identifiant de ''thread'' lors des comparaisons de tags, afin d'éviter qu'un ''thread'' lise une donnée d'un autre ''thread''. À l'inverse des processeurs mono-cœur, les processeurs multithreadés préfèrent des caches dont le débit binaire est plus important que la latence mémoire. La raison est que deux ''threads'' consomment deux fois plus de données qu'un seul et le débit binaire du cache doit suivre. Par contre, la latence est moins importante car les cycles gâchés qu'elle créé sont remplis par le ''multithreading'' matériel. Si un défaut de cache prend 4 cycles, le ''multithreading'' matériel va rapidement trouver des instructions à exécuter pendant ces cycles. Tout ce qui vient d'être dit s'applique aussi sur la TLB. Il est possible de la partitionner entre plusieurs ''threads''. La plupart du temps, le partitionnement est statique sur les TLB dédiées aux instructions, alors que les TLB pour les données sont partagées dynamiquement. C'est le cas sur les architectures Skylake d'Intel, où les 128 entrées de la TLB d'instruction de niveau 1 ont découpées en deux sections de 64 entrées, une par programme/''thread'', les autres TLB étant partitionnées dynamiquement. Une autre solution est d'ajouter un identifiant de ''thread'' dans chaque entrée de la TLB, sur le même principe que celui vu dans le chapitre sur la TLB. Le gain en performance est bien plus important, pour un cout en hardware limité. ===L'unité de prédiction de branchement=== Un autre cache est lui aussi partitionné ou complété avec des ''thread ID'' : le ''branch target buffer''. Rappelons que c'est un cache spécialisé pour les adresses de branchements. L'unité de prédiction de branchements doit idéalement séparer les branchements entre chaque ''thread'' pour obtenir de bonnes performances, soit en partitionnant le BTB, soit en ajoutant ajouter un ''Thread ID'' dans chaque entrée de ce cache. Mais ce n'est pas obligatoire. Les unités de prédiction de branchement vues dans les chapitres précédents peuvent parfaitement fonctionner telles quelles sur un processeur multithreadé. Le ''branch target buffer'' mémorise alors des branchements de ''threads'' différents, il ne sait pas à quel ''thread'' appartient tel branchement, mais le tout fonctionne. Le problème est que les prédictions pour un branchement sont parasitées par les branchements d'autres ''threads'', surtout pour les unités se basant sur un historique global. Malgré tout, on obtient un résultat tolérable, les performances sont assez bonnes et cela s'explique avec ce qui suit. Déjà, il arrive que les branchements des autres ''threads'' aient un effet positif sur la prédiction pour un ''thread''. De telles interférences positives sont rares, mais possible si les deux ''threads'' travaillent sur des données partagées, et c'est alors les unités de prédiction de branchement normales, sans partitionnement ni ''thread ID'' qui fonctionnent mieux, sous certaines circonstances. De plus, quand une mauvaise prédiction de branchement est détectée, il faut vider le pipeline des instructions chargées à tort. Et les instructions fautives appartiennent toutes à un même ''thread'', les instructions des autres ''threads'' ne sont pas concernées. Le vidage du pipeline est donc sélectif. Là encore, la vidange du pipeline se base sur les ''Thread ID'' propagés dans le pipeline. Vu que le vidage du pipeline est sélectif, partiel, la perte de performance en cas de mauvaise prédiction de branchement est donc plus faible, et cela surcompense le fait que les prédictions de branchement sont parasitées par les branchements d’autres ''threads''. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Architectures multiprocesseurs et multicœurs | prevText=Architectures multiprocesseurs et multicœurs | next=Les architectures à parallélisme de données | nextText=Les architectures à parallélisme de données }} </noinclude> fq1gm8883lrkmfw5nogk65zt54g9kwe Mathc matrices/Sommaire 0 69175 771173 771073 2026-08-22T07:40:35Z Xhungab 23827 771173 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc matrices (livre)]] : * [[Mathc matrices/Introduction|Introduction]] ---- ---- : {{Partie{{{type|}}}| '''Propriétés et Applications'''}} {| class="wikitable" |+ |- | ==Octave== * [[Mathc matrices/a27| Fonctions pour les matrices]] * [[Mathc matrices/04g| Le code de quelques fonctions]] * [[Mathc matrices/03b| Utilitaires graphiques]] * [[Mathc matrices/04m| Exemples d'applications]] * [[Mathc matrices/04l| Ecrire des fonctions mathématiques]] || ==Le langage C== * [[Mathc matrices/a33|'''Utilitaires pour le langage C''']] *. *. *. *. |- | ==Les matrices== : * [https://youtube.com/playlist?list=PLi6peGpf8EPOx2Pm_rspnagtdkBYe1t6J&feature=shared '''Playlist'''] : * [[Mathc matrices/05q|Créer une matrice ]] : * [[Mathc matrices/e12d2|Afficher une matrice ]] : * [[Mathc matrices/a03c|Afficher une matrice dans un fichier]] : * [[Mathc matrices/Fichiers c : mul_tran|Afficher une matrice pour Octave:]] : * [[Mathc matrices/a07|Copier une matrice]] : * [[Mathc matrices/c12eb1|Matrices aléatoires]] : * [[Mathc matrices/01p|Éntrez vos données]] : * [[Mathc matrices/c12bn|Tableaux de matrices]] : * [[Mathc matrices/a94|Afficher des vecteurs d'une colonne]] : : '''Opérations de bases''' : * [[Mathc matrices/c12ea2|Opérations sur les matrices]] : * [[Mathc matrices/c12ea1|La fonction trace_R();]] : * [[Mathc matrices/03o|La fonction transpose_R();]] *. *. *. *. *. *. : : || ==Les matrices spécifiques== : * [https://youtube.com/playlist?list=PLQZh_m-wiVNs&si=kCFfVn3Lbms-d-go '''Playlist'''] : * [[Mathc matrices/c12fn17|Matrice identité]] : * [[Mathc matrices/c12b8|Matrices triangulaires]] : * [[Mathc matrices/e02a|Matrices commutatives]] : * [[Mathc matrices/a90|Matrices semblables]] : * [[Mathc matrices/c12b3|Matrices symétriques]] : * [[Mathc matrices/c12b1|Matrices anti-symétriques]] : * [[Mathc matrices/02h|Matrice centrosymétrique]] : * [[Mathc matrices/02v|Matrices définies positives]] : * [[Mathc matrices/09n|Matrice de Gram]] : * [[Mathc matrices/09o|1/2 int(P1 P2, x= -1..1)]] : * [[Mathc matrices/035|Matrices définies négatives]] : * [[Mathc matrices/008|Matrices de Hankel]] : * [[Mathc matrices/009|Matrices de Toeplitz]] : * [[Mathc matrices/09i|Matrices de Markov]] : * [[Mathc matrices/a76|La fonction exponentielle]] : : '''Application Graphiques''' : * [[Mathc matrices/e02|Matrices de transformation [2x2]]] : * [[Mathc matrices/a133|Matrices de transformation [3x3]]] : : |- | ==Déterminant== : * [https://youtube.com/playlist?list=PLi6peGpf8EPO6GuKOJERdVXKu_zdExd9_&si=1P0vkGzm5Mrf9Vf2 '''Playlist'''] : * [[Mathc matrices/b03|Le déterminant]] : * [[Mathc matrices/Fichiers c : fp_e_mr| Opérations élémentaires]] : * [[Mathc matrices/e12d9|Les fonctions intermédiaires]] : * [[Mathc matrices/c21n|Quelque propriétés ]] : : '''Applications en mathématique ''' : * [[Mathc matrices/149|Matrice Adjointe]] : * [[Mathc matrices/e12d7|L'inverse par le déterminant]] ; * [[Mathc matrices/a235|Produit en croix]] : : || ==Applications== : * [[Mathc matrices/25a|L'équation d'une droite]] : * [[Mathc matrices/25b|L'équation d'un plan]] : * [[Mathc matrices/25c|L'équation d'une parabole]] : * [[Mathc matrices/25d|L'équation d'un cercle]] : * [[Mathc matrices/25e|L'équation d'une sphère]] * . * . * . * . * . : : |- | ==Total Pivoting== : * [https://youtube.com/playlist?list=PLi6peGpf8EPODYQZ0fosFTZZqKB9-CkHi '''Playlist'''] : * [[Mathc matrices/a205|Gauss-Jordan Total Pivoting]] : * [[Mathc matrices/a207|L'inverse]] : : '''Application ''' : * [[Mathc matrices/a209|Analyse d'un '''réseau''']] : * [[Mathc matrices/a216|Analyse d'un circuit '''électrique''']] : : '''Applications en géometrie ''' : * [[Mathc matrices/26a|L'équation d'un '''polynôme''']] : * [[Mathc matrices/26b|L'équation d'un '''conique''']] : * [[Mathc matrices/26c|L'équation d'un '''cercle''']] : : '''Applications en mathématique ''' : * [[Mathc matrices/a208|Systèmes '''non linéaire''' ]] : * [[Mathc matrices/a32|Choisir les solutions d'un système]] : : || ==Partial Pivoting== : * [https://youtube.com/playlist?list=PLCKoQKPJSeZ4&si=xL59cV3PKV8bK34W '''Playlist'''] : * [[Mathc matrices/a203|Gauss-Jordan Partial Pivoting]] : * [[Mathc matrices/a204|Variables libres]] : : '''Application ''' : * [[Mathc matrices/a201|Équation '''chimique''']] : : '''Applications mathématiques ''' : * [[Mathc matrices/c21s|Trouver une base pour ...]] : * [[Mathc matrices/c24f|Matrices de changement de base]] : * [[Mathc matrices/c24k|Matrice d'une application linéaire]] : * [[Mathc matrices/e05b|Projection sur un sous-espace vectoriel]] *. *. *. *. : : |- | ==Produits scalaires== : * [https://youtube.com/playlist?list=PLi6peGpf8EPM0AMbdj2DOnyFYP97-93Ry '''Playlist'''] : * [[Mathc matrices/Fichiers h : a03d0|Produit scalaire]] : * [[Mathc matrices/e05c| Quelques Propriétés]] : * [[Mathc matrices/08o| Calculer les vecteurs orthogonaux]] : : '''Orthogonalisation ''' : * [[Mathc matrices/e05a|Les matrices ortho'''normales''']] : * [[Mathc matrices/c25b|Quelques '''propriétés''']] : : '''La QR Décompositions ''' : * [[Mathc matrices/c12an8|'''La QR décomposition:''']] : : '''Application ''' : * [[Mathc matrices/c22m|Analyse d'un réseau]] : * [[Mathc matrices/c22t|Analyse d'un circuit électrique]] : : '''Applications en mathématique ''' : * [[Mathc matrices/00u|Les coefficients d'un polynôme]] : * [[Mathc matrices/00z|Les coefficients d'un conique]] : * [[Mathc matrices/c23l|Les coefficients d'un cercle]] : * . : * . : * . : || ==Vecteurs propres== : * [https://youtube.com/playlist?list=PLHCbCgJHr54w&si=rZsd2b1gQ1cI8lkv '''Playlist'''] : * [[Mathc matrices/e12d6|Vecteurs et valeurs propres]] : * [[Mathc matrices/a147|Quelques '''propriétés''']] : * [[Mathc matrices/03h|Valeurs propres '''multiples'''.]] : : '''Applications en mathématique ''' : * [[ Mathc matrices/01z|Conditionnement matriciel]] : * [[Mathc matrices/e050c|'''Fonctions matricielles''']] : * [[Mathc matrices/09b|'''Système dynamique linéaire discret''']] : * [[Mathc matrices/a29|La décomposition spectral]] : : '''Applications graphique ''' : * [[Mathc matrices/060|'''Formes quadratiques : 2D''']] ; * [[Mathc matrices/061|'''Formes quadratiques : 3D''']] : * [[Mathc matrices/08p| '''Choisir''' les valeurs propres]] : * [[Mathc matrices/03g|'''Projection du plan'''(1)]] : * [[Mathc matrices/08u|'''Projection du plan'''(2)]] : * [[Mathc matrices/a257|'''Projection de l'espace'''(1)]] : * [[Mathc matrices/090|'''Projection de l'espace'''(2)]] : * [[Mathc matrices/062|''' Projection de l'hyperespace'''(1)]] : * [[Mathc matrices/096|'''Projection de l'hyperespace'''(2)]] : : |- | ==Valeurs singulière== : * [https://youtube.com/playlist?list=PLeCGlgiiTezU&si=FMpg7W-9ggkypVEJ '''Playlist'''] : * [[Mathc matrices/c12an7|Calculer les Valeurs Singulières]] : : '''SVD décomposition''' : * [[Mathc matrices/e12a|Plus de lignes que de colonnes]] : * [[Mathc matrices/02q|Quelques propriétés ]] : * [[Mathc matrices/e12b|Plus de colonnes que de lignes]] *. *. *. *. : : || ==Pseudo inverse== : * [https://youtube.com/playlist?list=PLEJ0dDNSeWu8&si=e2V9XOgjRqFrlFMr '''Playlist'''] : * [[Mathc matrices/26d|'''Pseudo inverse:''']] : : '''Application ''' : * [[Mathc matrices/00t|Analyse d'un réseau]] : * [[Mathc matrices/00i|Analyse d'un circuit électrique]] : : '''Applications en mathématique ''' : * [[Mathc matrices/088|Étude d'un polynôme]] : * [[Mathc matrices/08b|Étude d'un conique]] : * [[Mathc matrices/00k|Étude d'un cercle]] : : |- | ==Espace euclidien== * [https://youtube.com/playlist?list=PLJ8C0Kl0F9vo&si=neCAfxj1GXoGAtBM '''Playlist'''] * [[Mathc matrices/0i9|Produits scalaires standard (2 )]] * [[Mathc matrices/0hx|Produits scalaires standard (3')]] * [[Mathc matrices/0ig|Inégalité Cauchy-Schwarz]] *. * [[Mathc matrices/0b7|Produits scalaires avec poids (D1)]] * [[Mathc matrices/0ge|Produits scalaires avec poids (D2)]] * [[Mathc matrices/0h8|Produits scalaires avec poids (D3')]] * [[Mathc matrices/0in|Inégalité Cauchy-Schwarz]] *. * [[Mathc matrices/0c0|Produits scalaires avec poids (+A1)]] * [[Mathc matrices/0gr|Produits scalaires avec poids (+A2)]] * [[Mathc matrices/0hf|Produits scalaires avec poids (+A3')]] *. * [[Mathc matrices/0d5|Produits scalaires avec poids (AtA1)]] * [[Mathc matrices/0h3|Produits scalaires avec poids (AtA2)]] * [[Mathc matrices/0hl|Produits scalaires avec poids (AtA3')]] *. * [[Mathc matrices/0cf|Produits scalaires avec trace() (1)]] * [[Mathc matrices/0g1|Produits scalaires avec trace() (2)]] * [[Mathc matrices/0hr|Produits scalaires avec trace() (3')]] || * [https://youtube.com/playlist?list=PLN1BB1ITBByg&si=4pXoU0I31HYa0ro- '''Playlist'''] * [[Mathc matrices/0ff|Produits scalaires dans f(x) (1)]] * [[Mathc matrices/0a2|Produits scalaires dans f(x) (2)]] *. * [[Mathc matrices/0aw|Produits scalaires dans f(x) (3)]] * [[Mathc matrices/0fl|Produits scalaires dans f(1) (3')]] * [[Mathc matrices/0iw|Produits scalaires dans f(2) (3')]] * [[Mathc matrices/0j3|Produits scalaires dans f(3) (3')]] *. * [[Mathc matrices/0jh|Prod. S. dans f(x) avec poids (1 ) ]] * [[Mathc matrices/0jo|Prod. S. dans f(1) avec poids (3') ]] * [[Mathc matrices/0jv|Prod. S. dans f(2) avec poids (3') ]] *. * [[Mathc matrices/0dr|Produits scalaires dans p(x) (1) ]] * [[Mathc matrices/0fr|Produits scalaires dans p(x) 1(3')]] * [[Mathc matrices/0k2|Produits scalaires dans p(x) 2(3')]] '''Version 2 ''' * [[Mathc matrices/0k2|Produits scalaires dans f(x)]] *. *. *. *. *. |} : : ---- {{Partie{{{type|}}}| '''La bibliothèque'''}} : {{Partie{{{type|}}}|[[Mathc matrices/c21r| '''La bibliothèque.''']]}} : ---- {{Partie{{{type|}}}| '''Gnuplot.'''}} : * [[Mathc matrices/c21q|Gnuplot:]] {{Lien modifier|Mathc matrices/Sommaire|modifier le sommaire}} {{AutoCat}} hoh8oa6wfgll8qvm54wgbekudc9ri6e 771174 771173 2026-08-22T07:42:13Z Xhungab 23827 771174 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc matrices (livre)]] : * [[Mathc matrices/Introduction|Introduction]] ---- ---- : {{Partie{{{type|}}}| '''Propriétés et Applications'''}} {| class="wikitable" |+ |- | ==Octave== * [[Mathc matrices/a27| Fonctions pour les matrices]] * [[Mathc matrices/04g| Le code de quelques fonctions]] * [[Mathc matrices/03b| Utilitaires graphiques]] * [[Mathc matrices/04m| Exemples d'applications]] * [[Mathc matrices/04l| Ecrire des fonctions mathématiques]] || ==Le langage C== * [[Mathc matrices/a33|'''Utilitaires pour le langage C''']] *. *. *. *. |- | ==Les matrices== : * [https://youtube.com/playlist?list=PLi6peGpf8EPOx2Pm_rspnagtdkBYe1t6J&feature=shared '''Playlist'''] : * [[Mathc matrices/05q|Créer une matrice ]] : * [[Mathc matrices/e12d2|Afficher une matrice ]] : * [[Mathc matrices/a03c|Afficher une matrice dans un fichier]] : * [[Mathc matrices/Fichiers c : mul_tran|Afficher une matrice pour Octave:]] : * [[Mathc matrices/a07|Copier une matrice]] : * [[Mathc matrices/c12eb1|Matrices aléatoires]] : * [[Mathc matrices/01p|Éntrez vos données]] : * [[Mathc matrices/c12bn|Tableaux de matrices]] : * [[Mathc matrices/a94|Afficher des vecteurs d'une colonne]] : : '''Opérations de bases''' : * [[Mathc matrices/c12ea2|Opérations sur les matrices]] : * [[Mathc matrices/c12ea1|La fonction trace_R();]] : * [[Mathc matrices/03o|La fonction transpose_R();]] *. *. *. *. *. *. : : || ==Les matrices spécifiques== : * [https://youtube.com/playlist?list=PLQZh_m-wiVNs&si=kCFfVn3Lbms-d-go '''Playlist'''] : * [[Mathc matrices/c12fn17|Matrice identité]] : * [[Mathc matrices/c12b8|Matrices triangulaires]] : * [[Mathc matrices/e02a|Matrices commutatives]] : * [[Mathc matrices/a90|Matrices semblables]] : * [[Mathc matrices/c12b3|Matrices symétriques]] : * [[Mathc matrices/c12b1|Matrices anti-symétriques]] : * [[Mathc matrices/02h|Matrice centrosymétrique]] : * [[Mathc matrices/02v|Matrices définies positives]] : * [[Mathc matrices/09n|Matrice de Gram]] : * [[Mathc matrices/09o|1/2 int(P1 P2, x= -1..1)]] : * [[Mathc matrices/035|Matrices définies négatives]] : * [[Mathc matrices/008|Matrices de Hankel]] : * [[Mathc matrices/009|Matrices de Toeplitz]] : * [[Mathc matrices/09i|Matrices de Markov]] : * [[Mathc matrices/a76|La fonction exponentielle]] : : '''Application Graphiques''' : * [[Mathc matrices/e02|Matrices de transformation [2x2]]] : * [[Mathc matrices/a133|Matrices de transformation [3x3]]] : : |- | ==Déterminant== : * [https://youtube.com/playlist?list=PLi6peGpf8EPO6GuKOJERdVXKu_zdExd9_&si=1P0vkGzm5Mrf9Vf2 '''Playlist'''] : * [[Mathc matrices/b03|Le déterminant]] : * [[Mathc matrices/Fichiers c : fp_e_mr| Opérations élémentaires]] : * [[Mathc matrices/e12d9|Les fonctions intermédiaires]] : * [[Mathc matrices/c21n|Quelque propriétés ]] : : '''Applications en mathématique ''' : * [[Mathc matrices/149|Matrice Adjointe]] : * [[Mathc matrices/e12d7|L'inverse par le déterminant]] ; * [[Mathc matrices/a235|Produit en croix]] : : || ==Applications== : * [[Mathc matrices/25a|L'équation d'une droite]] : * [[Mathc matrices/25b|L'équation d'un plan]] : * [[Mathc matrices/25c|L'équation d'une parabole]] : * [[Mathc matrices/25d|L'équation d'un cercle]] : * [[Mathc matrices/25e|L'équation d'une sphère]] * . * . * . * . * . : : |- | ==Total Pivoting== : * [https://youtube.com/playlist?list=PLi6peGpf8EPODYQZ0fosFTZZqKB9-CkHi '''Playlist'''] : * [[Mathc matrices/a205|Gauss-Jordan Total Pivoting]] : * [[Mathc matrices/a207|L'inverse]] : : '''Application ''' : * [[Mathc matrices/a209|Analyse d'un '''réseau''']] : * [[Mathc matrices/a216|Analyse d'un circuit '''électrique''']] : : '''Applications en géometrie ''' : * [[Mathc matrices/26a|L'équation d'un '''polynôme''']] : * [[Mathc matrices/26b|L'équation d'un '''conique''']] : * [[Mathc matrices/26c|L'équation d'un '''cercle''']] : : '''Applications en mathématique ''' : * [[Mathc matrices/a208|Systèmes '''non linéaire''' ]] : * [[Mathc matrices/a32|Choisir les solutions d'un système]] : : || ==Partial Pivoting== : * [https://youtube.com/playlist?list=PLCKoQKPJSeZ4&si=xL59cV3PKV8bK34W '''Playlist'''] : * [[Mathc matrices/a203|Gauss-Jordan Partial Pivoting]] : * [[Mathc matrices/a204|Variables libres]] : : '''Application ''' : * [[Mathc matrices/a201|Équation '''chimique''']] : : '''Applications mathématiques ''' : * [[Mathc matrices/c21s|Trouver une base pour ...]] : * [[Mathc matrices/c24f|Matrices de changement de base]] : * [[Mathc matrices/c24k|Matrice d'une application linéaire]] : * [[Mathc matrices/e05b|Projection sur un sous-espace vectoriel]] *. *. *. *. : : |- | ==Produits scalaires== : * [https://youtube.com/playlist?list=PLi6peGpf8EPM0AMbdj2DOnyFYP97-93Ry '''Playlist'''] : * [[Mathc matrices/Fichiers h : a03d0|Produit scalaire]] : * [[Mathc matrices/e05c| Quelques Propriétés]] : * [[Mathc matrices/08o| Calculer les vecteurs orthogonaux]] : : '''Orthogonalisation ''' : * [[Mathc matrices/e05a|Les matrices ortho'''normales''']] : * [[Mathc matrices/c25b|Quelques '''propriétés''']] : : '''La QR Décompositions ''' : * [[Mathc matrices/c12an8|'''La QR décomposition:''']] : : '''Application ''' : * [[Mathc matrices/c22m|Analyse d'un réseau]] : * [[Mathc matrices/c22t|Analyse d'un circuit électrique]] : : '''Applications en mathématique ''' : * [[Mathc matrices/00u|Les coefficients d'un polynôme]] : * [[Mathc matrices/00z|Les coefficients d'un conique]] : * [[Mathc matrices/c23l|Les coefficients d'un cercle]] : * . : * . : * . : || ==Vecteurs propres== : * [https://youtube.com/playlist?list=PLHCbCgJHr54w&si=rZsd2b1gQ1cI8lkv '''Playlist'''] : * [[Mathc matrices/e12d6|Vecteurs et valeurs propres]] : * [[Mathc matrices/a147|Quelques '''propriétés''']] : * [[Mathc matrices/03h|Valeurs propres '''multiples'''.]] : : '''Applications en mathématique ''' : * [[ Mathc matrices/01z|Conditionnement matriciel]] : * [[Mathc matrices/e050c|'''Fonctions matricielles''']] : * [[Mathc matrices/09b|'''Système dynamique linéaire discret''']] : * [[Mathc matrices/a29|La décomposition spectral]] : : '''Applications graphique ''' : * [[Mathc matrices/060|'''Formes quadratiques : 2D''']] ; * [[Mathc matrices/061|'''Formes quadratiques : 3D''']] : * [[Mathc matrices/08p| '''Choisir''' les valeurs propres]] : * [[Mathc matrices/03g|'''Projection du plan'''(1)]] : * [[Mathc matrices/08u|'''Projection du plan'''(2)]] : * [[Mathc matrices/a257|'''Projection de l'espace'''(1)]] : * [[Mathc matrices/090|'''Projection de l'espace'''(2)]] : * [[Mathc matrices/062|''' Projection de l'hyperespace'''(1)]] : * [[Mathc matrices/096|'''Projection de l'hyperespace'''(2)]] : : |- | ==Valeurs singulière== : * [https://youtube.com/playlist?list=PLeCGlgiiTezU&si=FMpg7W-9ggkypVEJ '''Playlist'''] : * [[Mathc matrices/c12an7|Calculer les Valeurs Singulières]] : : '''SVD décomposition''' : * [[Mathc matrices/e12a|Plus de lignes que de colonnes]] : * [[Mathc matrices/02q|Quelques propriétés ]] : * [[Mathc matrices/e12b|Plus de colonnes que de lignes]] *. *. *. *. : : || ==Pseudo inverse== : * [https://youtube.com/playlist?list=PLEJ0dDNSeWu8&si=e2V9XOgjRqFrlFMr '''Playlist'''] : * [[Mathc matrices/26d|'''Pseudo inverse:''']] : : '''Application ''' : * [[Mathc matrices/00t|Analyse d'un réseau]] : * [[Mathc matrices/00i|Analyse d'un circuit électrique]] : : '''Applications en mathématique ''' : * [[Mathc matrices/088|Étude d'un polynôme]] : * [[Mathc matrices/08b|Étude d'un conique]] : * [[Mathc matrices/00k|Étude d'un cercle]] : : |- | ==Espace euclidien== * [https://youtube.com/playlist?list=PLJ8C0Kl0F9vo&si=neCAfxj1GXoGAtBM '''Playlist'''] * [[Mathc matrices/0i9|Produits scalaires standard (2 )]] * [[Mathc matrices/0hx|Produits scalaires standard (3')]] * [[Mathc matrices/0ig|Inégalité Cauchy-Schwarz]] *. * [[Mathc matrices/0b7|Produits scalaires avec poids (D1)]] * [[Mathc matrices/0ge|Produits scalaires avec poids (D2)]] * [[Mathc matrices/0h8|Produits scalaires avec poids (D3')]] * [[Mathc matrices/0in|Inégalité Cauchy-Schwarz]] *. * [[Mathc matrices/0c0|Produits scalaires avec poids (+A1)]] * [[Mathc matrices/0gr|Produits scalaires avec poids (+A2)]] * [[Mathc matrices/0hf|Produits scalaires avec poids (+A3')]] *. * [[Mathc matrices/0d5|Produits scalaires avec poids (AtA1)]] * [[Mathc matrices/0h3|Produits scalaires avec poids (AtA2)]] * [[Mathc matrices/0hl|Produits scalaires avec poids (AtA3')]] *. * [[Mathc matrices/0cf|Produits scalaires avec trace() (1)]] * [[Mathc matrices/0g1|Produits scalaires avec trace() (2)]] * [[Mathc matrices/0hr|Produits scalaires avec trace() (3')]] || * [https://youtube.com/playlist?list=PLN1BB1ITBByg&si=4pXoU0I31HYa0ro- '''Playlist'''] * [[Mathc matrices/0ff|Produits scalaires dans f(x) (1)]] * [[Mathc matrices/0a2|Produits scalaires dans f(x) (2)]] *. * [[Mathc matrices/0aw|Produits scalaires dans f(x) (3)]] * [[Mathc matrices/0fl|Produits scalaires dans f(1) (3')]] * [[Mathc matrices/0iw|Produits scalaires dans f(2) (3')]] * [[Mathc matrices/0j3|Produits scalaires dans f(3) (3')]] *. * [[Mathc matrices/0jh|Prod. S. dans f(x) avec poids (1 ) ]] * [[Mathc matrices/0jo|Prod. S. dans f(1) avec poids (3') ]] * [[Mathc matrices/0jv|Prod. S. dans f(2) avec poids (3') ]] *. * [[Mathc matrices/0dr|Produits scalaires dans p(x) (1) ]] * [[Mathc matrices/0fr|Produits scalaires dans p(x) 1(3')]] * [[Mathc matrices/0k2|Produits scalaires dans p(x) 2(3')]] '''Version 2 ''' * [[Mathc matrices/0k3|Produits scalaires dans f(x)]] *. *. *. *. *. |} : : ---- {{Partie{{{type|}}}| '''La bibliothèque'''}} : {{Partie{{{type|}}}|[[Mathc matrices/c21r| '''La bibliothèque.''']]}} : ---- {{Partie{{{type|}}}| '''Gnuplot.'''}} : * [[Mathc matrices/c21q|Gnuplot:]] {{Lien modifier|Mathc matrices/Sommaire|modifier le sommaire}} {{AutoCat}} atkcpnypxcjjtvfgepxdw4o814p8m3p 771183 771174 2026-08-22T08:06:26Z Xhungab 23827 771183 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc matrices (livre)]] : * [[Mathc matrices/Introduction|Introduction]] ---- ---- : {{Partie{{{type|}}}| '''Propriétés et Applications'''}} {| class="wikitable" |+ |- | ==Octave== * [[Mathc matrices/a27| Fonctions pour les matrices]] * [[Mathc matrices/04g| Le code de quelques fonctions]] * [[Mathc matrices/03b| Utilitaires graphiques]] * [[Mathc matrices/04m| Exemples d'applications]] * [[Mathc matrices/04l| Ecrire des fonctions mathématiques]] || ==Le langage C== : * [https://youtube.com/playlist?list=PLi6peGpf8EPOx2Pm_rspnagtdkBYe1t6J&feature=shared '''Playlist'''] : * [[Mathc matrices/a33|'''Utilitaires pour le langage C''']] *. *. *. |- | ==Les matrices== : * [https://youtube.com/playlist?list=PLi6peGpf8EPOx2Pm_rspnagtdkBYe1t6J&feature=shared '''Playlist'''] : * [[Mathc matrices/05q|Créer une matrice ]] : * [[Mathc matrices/e12d2|Afficher une matrice ]] : * [[Mathc matrices/a03c|Afficher une matrice dans un fichier]] : * [[Mathc matrices/Fichiers c : mul_tran|Afficher une matrice pour Octave:]] : * [[Mathc matrices/a07|Copier une matrice]] : * [[Mathc matrices/c12eb1|Matrices aléatoires]] : * [[Mathc matrices/01p|Éntrez vos données]] : * [[Mathc matrices/c12bn|Tableaux de matrices]] : * [[Mathc matrices/a94|Afficher des vecteurs d'une colonne]] : : '''Opérations de bases''' : * [[Mathc matrices/c12ea2|Opérations sur les matrices]] : * [[Mathc matrices/c12ea1|La fonction trace_R();]] : * [[Mathc matrices/03o|La fonction transpose_R();]] *. *. *. *. *. *. : : || ==Les matrices spécifiques== : * [https://youtube.com/playlist?list=PLQZh_m-wiVNs&si=kCFfVn3Lbms-d-go '''Playlist'''] : * [[Mathc matrices/c12fn17|Matrice identité]] : * [[Mathc matrices/c12b8|Matrices triangulaires]] : * [[Mathc matrices/e02a|Matrices commutatives]] : * [[Mathc matrices/a90|Matrices semblables]] : * [[Mathc matrices/c12b3|Matrices symétriques]] : * [[Mathc matrices/c12b1|Matrices anti-symétriques]] : * [[Mathc matrices/02h|Matrice centrosymétrique]] : * [[Mathc matrices/02v|Matrices définies positives]] : * [[Mathc matrices/09n|Matrice de Gram]] : * [[Mathc matrices/09o|1/2 int(P1 P2, x= -1..1)]] : * [[Mathc matrices/035|Matrices définies négatives]] : * [[Mathc matrices/008|Matrices de Hankel]] : * [[Mathc matrices/009|Matrices de Toeplitz]] : * [[Mathc matrices/09i|Matrices de Markov]] : * [[Mathc matrices/a76|La fonction exponentielle]] : : '''Application Graphiques''' : * [[Mathc matrices/e02|Matrices de transformation [2x2]]] : * [[Mathc matrices/a133|Matrices de transformation [3x3]]] : : |- | ==Déterminant== : * [https://youtube.com/playlist?list=PLi6peGpf8EPO6GuKOJERdVXKu_zdExd9_&si=1P0vkGzm5Mrf9Vf2 '''Playlist'''] : * [[Mathc matrices/b03|Le déterminant]] : * [[Mathc matrices/Fichiers c : fp_e_mr| Opérations élémentaires]] : * [[Mathc matrices/e12d9|Les fonctions intermédiaires]] : * [[Mathc matrices/c21n|Quelque propriétés ]] : : '''Applications en mathématique ''' : * [[Mathc matrices/149|Matrice Adjointe]] : * [[Mathc matrices/e12d7|L'inverse par le déterminant]] ; * [[Mathc matrices/a235|Produit en croix]] : : || ==Applications== : * [[Mathc matrices/25a|L'équation d'une droite]] : * [[Mathc matrices/25b|L'équation d'un plan]] : * [[Mathc matrices/25c|L'équation d'une parabole]] : * [[Mathc matrices/25d|L'équation d'un cercle]] : * [[Mathc matrices/25e|L'équation d'une sphère]] * . * . * . * . * . : : |- | ==Total Pivoting== : * [https://youtube.com/playlist?list=PLi6peGpf8EPODYQZ0fosFTZZqKB9-CkHi '''Playlist'''] : * [[Mathc matrices/a205|Gauss-Jordan Total Pivoting]] : * [[Mathc matrices/a207|L'inverse]] : : '''Application ''' : * [[Mathc matrices/a209|Analyse d'un '''réseau''']] : * [[Mathc matrices/a216|Analyse d'un circuit '''électrique''']] : : '''Applications en géometrie ''' : * [[Mathc matrices/26a|L'équation d'un '''polynôme''']] : * [[Mathc matrices/26b|L'équation d'un '''conique''']] : * [[Mathc matrices/26c|L'équation d'un '''cercle''']] : : '''Applications en mathématique ''' : * [[Mathc matrices/a208|Systèmes '''non linéaire''' ]] : * [[Mathc matrices/a32|Choisir les solutions d'un système]] : : || ==Partial Pivoting== : * [https://youtube.com/playlist?list=PLCKoQKPJSeZ4&si=xL59cV3PKV8bK34W '''Playlist'''] : * [[Mathc matrices/a203|Gauss-Jordan Partial Pivoting]] : * [[Mathc matrices/a204|Variables libres]] : : '''Application ''' : * [[Mathc matrices/a201|Équation '''chimique''']] : : '''Applications mathématiques ''' : * [[Mathc matrices/c21s|Trouver une base pour ...]] : * [[Mathc matrices/c24f|Matrices de changement de base]] : * [[Mathc matrices/c24k|Matrice d'une application linéaire]] : * [[Mathc matrices/e05b|Projection sur un sous-espace vectoriel]] *. *. *. *. : : |- | ==Produits scalaires== : * [https://youtube.com/playlist?list=PLi6peGpf8EPM0AMbdj2DOnyFYP97-93Ry '''Playlist'''] : * [[Mathc matrices/Fichiers h : a03d0|Produit scalaire]] : * [[Mathc matrices/e05c| Quelques Propriétés]] : * [[Mathc matrices/08o| Calculer les vecteurs orthogonaux]] : : '''Orthogonalisation ''' : * [[Mathc matrices/e05a|Les matrices ortho'''normales''']] : * [[Mathc matrices/c25b|Quelques '''propriétés''']] : : '''La QR Décompositions ''' : * [[Mathc matrices/c12an8|'''La QR décomposition:''']] : : '''Application ''' : * [[Mathc matrices/c22m|Analyse d'un réseau]] : * [[Mathc matrices/c22t|Analyse d'un circuit électrique]] : : '''Applications en mathématique ''' : * [[Mathc matrices/00u|Les coefficients d'un polynôme]] : * [[Mathc matrices/00z|Les coefficients d'un conique]] : * [[Mathc matrices/c23l|Les coefficients d'un cercle]] : * . : * . : * . : || ==Vecteurs propres== : * [https://youtube.com/playlist?list=PLHCbCgJHr54w&si=rZsd2b1gQ1cI8lkv '''Playlist'''] : * [[Mathc matrices/e12d6|Vecteurs et valeurs propres]] : * [[Mathc matrices/a147|Quelques '''propriétés''']] : * [[Mathc matrices/03h|Valeurs propres '''multiples'''.]] : : '''Applications en mathématique ''' : * [[ Mathc matrices/01z|Conditionnement matriciel]] : * [[Mathc matrices/e050c|'''Fonctions matricielles''']] : * [[Mathc matrices/09b|'''Système dynamique linéaire discret''']] : * [[Mathc matrices/a29|La décomposition spectral]] : : '''Applications graphique ''' : * [[Mathc matrices/060|'''Formes quadratiques : 2D''']] ; * [[Mathc matrices/061|'''Formes quadratiques : 3D''']] : * [[Mathc matrices/08p| '''Choisir''' les valeurs propres]] : * [[Mathc matrices/03g|'''Projection du plan'''(1)]] : * [[Mathc matrices/08u|'''Projection du plan'''(2)]] : * [[Mathc matrices/a257|'''Projection de l'espace'''(1)]] : * [[Mathc matrices/090|'''Projection de l'espace'''(2)]] : * [[Mathc matrices/062|''' Projection de l'hyperespace'''(1)]] : * [[Mathc matrices/096|'''Projection de l'hyperespace'''(2)]] : : |- | ==Valeurs singulière== : * [https://youtube.com/playlist?list=PLeCGlgiiTezU&si=FMpg7W-9ggkypVEJ '''Playlist'''] : * [[Mathc matrices/c12an7|Calculer les Valeurs Singulières]] : : '''SVD décomposition''' : * [[Mathc matrices/e12a|Plus de lignes que de colonnes]] : * [[Mathc matrices/02q|Quelques propriétés ]] : * [[Mathc matrices/e12b|Plus de colonnes que de lignes]] *. *. *. *. : : || ==Pseudo inverse== : * [https://youtube.com/playlist?list=PLEJ0dDNSeWu8&si=e2V9XOgjRqFrlFMr '''Playlist'''] : * [[Mathc matrices/26d|'''Pseudo inverse:''']] : : '''Application ''' : * [[Mathc matrices/00t|Analyse d'un réseau]] : * [[Mathc matrices/00i|Analyse d'un circuit électrique]] : : '''Applications en mathématique ''' : * [[Mathc matrices/088|Étude d'un polynôme]] : * [[Mathc matrices/08b|Étude d'un conique]] : * [[Mathc matrices/00k|Étude d'un cercle]] : : |- | ==Espace euclidien== * [https://youtube.com/playlist?list=PLJ8C0Kl0F9vo&si=neCAfxj1GXoGAtBM '''Playlist'''] * [[Mathc matrices/0i9|Produits scalaires standard (2 )]] * [[Mathc matrices/0hx|Produits scalaires standard (3')]] * [[Mathc matrices/0ig|Inégalité Cauchy-Schwarz]] *. * [[Mathc matrices/0b7|Produits scalaires avec poids (D1)]] * [[Mathc matrices/0ge|Produits scalaires avec poids (D2)]] * [[Mathc matrices/0h8|Produits scalaires avec poids (D3')]] * [[Mathc matrices/0in|Inégalité Cauchy-Schwarz]] *. * [[Mathc matrices/0c0|Produits scalaires avec poids (+A1)]] * [[Mathc matrices/0gr|Produits scalaires avec poids (+A2)]] * [[Mathc matrices/0hf|Produits scalaires avec poids (+A3')]] *. * [[Mathc matrices/0d5|Produits scalaires avec poids (AtA1)]] * [[Mathc matrices/0h3|Produits scalaires avec poids (AtA2)]] * [[Mathc matrices/0hl|Produits scalaires avec poids (AtA3')]] *. * [[Mathc matrices/0cf|Produits scalaires avec trace() (1)]] * [[Mathc matrices/0g1|Produits scalaires avec trace() (2)]] * [[Mathc matrices/0hr|Produits scalaires avec trace() (3')]] || * [https://youtube.com/playlist?list=PLN1BB1ITBByg&si=4pXoU0I31HYa0ro- '''Playlist'''] * [[Mathc matrices/0ff|Produits scalaires dans f(x) (1)]] * [[Mathc matrices/0a2|Produits scalaires dans f(x) (2)]] *. * [[Mathc matrices/0aw|Produits scalaires dans f(x) (3)]] * [[Mathc matrices/0fl|Produits scalaires dans f(1) (3')]] * [[Mathc matrices/0iw|Produits scalaires dans f(2) (3')]] * [[Mathc matrices/0j3|Produits scalaires dans f(3) (3')]] *. * [[Mathc matrices/0jh|Prod. S. dans f(x) avec poids (1 ) ]] * [[Mathc matrices/0jo|Prod. S. dans f(1) avec poids (3') ]] * [[Mathc matrices/0jv|Prod. S. dans f(2) avec poids (3') ]] *. * [[Mathc matrices/0dr|Produits scalaires dans p(x) (1) ]] * [[Mathc matrices/0fr|Produits scalaires dans p(x) 1(3')]] * [[Mathc matrices/0k2|Produits scalaires dans p(x) 2(3')]] '''Version 2 ''' * [[Mathc matrices/0k3|Produits scalaires dans f(x)]] *. *. *. *. *. |} : : ---- {{Partie{{{type|}}}| '''La bibliothèque'''}} : {{Partie{{{type|}}}|[[Mathc matrices/c21r| '''La bibliothèque.''']]}} : ---- {{Partie{{{type|}}}| '''Gnuplot.'''}} : * [[Mathc matrices/c21q|Gnuplot:]] {{Lien modifier|Mathc matrices/Sommaire|modifier le sommaire}} {{AutoCat}} hnga6twovtujt1xfkme9gnta01c35vr 771185 771183 2026-08-22T08:53:54Z Xhungab 23827 771185 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc matrices (livre)]] : * [[Mathc matrices/Introduction|Introduction]] ---- ---- : {{Partie{{{type|}}}| '''Propriétés et Applications'''}} {| class="wikitable" |+ |- | ==Octave== * [[Mathc matrices/a27| Fonctions pour les matrices]] * [[Mathc matrices/04g| Le code de quelques fonctions]] * [[Mathc matrices/03b| Utilitaires graphiques]] * [[Mathc matrices/04m| Exemples d'applications]] * [[Mathc matrices/04l| Ecrire des fonctions mathématiques]] || ==Le langage C== : * [https://youtube.com/playlist?list=PLi6peGpf8EPOx2Pm_rspnagtdkBYe1t6J&feature=shared '''Playlist'''] : * [[Mathc matrices/a33|'''Utilitaires pour le langage C''']] *. *. *. |- | ==Les matrices== : * [https://youtube.com/playlist?list=PLi6peGpf8EPOx2Pm_rspnagtdkBYe1t6J&feature=shared '''Playlist'''] : * [[Mathc matrices/05q|Créer une matrice ]] : * [[Mathc matrices/e12d2|Afficher une matrice ]] : * [[Mathc matrices/a03c|Afficher une matrice dans un fichier]] : * [[Mathc matrices/Fichiers c : mul_tran|Afficher une matrice pour Octave:]] : * [[Mathc matrices/a07|Copier une matrice]] : * [[Mathc matrices/c12eb1|Matrices aléatoires]] : * [[Mathc matrices/01p|Éntrez vos données]] : * [[Mathc matrices/c12bn|Tableaux de matrices]] : * [[Mathc matrices/a94|Afficher des vecteurs d'une colonne]] : : '''Opérations de bases''' : * [[Mathc matrices/c12ea2|Opérations sur les matrices]] : * [[Mathc matrices/c12ea1|La fonction trace_R();]] : * [[Mathc matrices/03o|La fonction transpose_R();]] *. *. *. *. *. *. : : || ==Les matrices spécifiques== : * [https://youtube.com/playlist?list=PLQZh_m-wiVNs&si=kCFfVn3Lbms-d-go '''Playlist'''] : * [[Mathc matrices/c12fn17|Matrice identité]] : * [[Mathc matrices/c12b8|Matrices triangulaires]] : * [[Mathc matrices/e02a|Matrices commutatives]] : * [[Mathc matrices/a90|Matrices semblables]] : * [[Mathc matrices/c12b3|Matrices symétriques]] : * [[Mathc matrices/c12b1|Matrices anti-symétriques]] : * [[Mathc matrices/02h|Matrice centrosymétrique]] : * [[Mathc matrices/02v|Matrices définies positives]] : * [[Mathc matrices/09n|Matrice de Gram]] : * [[Mathc matrices/09o|1/2 int(P1 P2, x= -1..1)]] : * [[Mathc matrices/035|Matrices définies négatives]] : * [[Mathc matrices/008|Matrices de Hankel]] : * [[Mathc matrices/009|Matrices de Toeplitz]] : * [[Mathc matrices/09i|Matrices de Markov]] : * [[Mathc matrices/a76|La fonction exponentielle]] : : '''Application Graphiques''' : * [[Mathc matrices/e02|Matrices de transformation [2x2]]] : * [[Mathc matrices/a133|Matrices de transformation [3x3]]] : : |- | ==Déterminant== : * [https://youtube.com/playlist?list=PLi6peGpf8EPO6GuKOJERdVXKu_zdExd9_&si=1P0vkGzm5Mrf9Vf2 '''Playlist'''] : * [[Mathc matrices/b03|Le déterminant]] : * [[Mathc matrices/Fichiers c : fp_e_mr| Opérations élémentaires]] : * [[Mathc matrices/e12d9|Les fonctions intermédiaires]] : * [[Mathc matrices/c21n|Quelque propriétés ]] : : '''Applications en mathématique ''' : * [[Mathc matrices/149|Matrice Adjointe]] : * [[Mathc matrices/e12d7|L'inverse par le déterminant]] ; * [[Mathc matrices/a235|Produit en croix]] : : || ==Applications== : * [[Mathc matrices/25a|L'équation d'une droite]] : * [[Mathc matrices/25b|L'équation d'un plan]] : * [[Mathc matrices/25c|L'équation d'une parabole]] : * [[Mathc matrices/25d|L'équation d'un cercle]] : * [[Mathc matrices/25e|L'équation d'une sphère]] * . * . * . * . * . : : |- | ==Total Pivoting== : * [https://youtube.com/playlist?list=PLi6peGpf8EPODYQZ0fosFTZZqKB9-CkHi '''Playlist'''] : * [[Mathc matrices/a205|Gauss-Jordan Total Pivoting]] : * [[Mathc matrices/a207|L'inverse]] : : '''Application ''' : * [[Mathc matrices/a209|Analyse d'un '''réseau''']] : * [[Mathc matrices/a216|Analyse d'un circuit '''électrique''']] : : '''Applications en géometrie ''' : * [[Mathc matrices/26a|L'équation d'un '''polynôme''']] : * [[Mathc matrices/26b|L'équation d'un '''conique''']] : * [[Mathc matrices/26c|L'équation d'un '''cercle''']] : : '''Applications en mathématique ''' : * [[Mathc matrices/a208|Systèmes '''non linéaire''' ]] : * [[Mathc matrices/a32|Choisir les solutions d'un système]] : : || ==Partial Pivoting== : * [https://youtube.com/playlist?list=PLCKoQKPJSeZ4&si=xL59cV3PKV8bK34W '''Playlist'''] : * [[Mathc matrices/a203|Gauss-Jordan Partial Pivoting]] : * [[Mathc matrices/a204|Variables libres]] : : '''Application ''' : * [[Mathc matrices/a201|Équation '''chimique''']] : : '''Applications mathématiques ''' : * [[Mathc matrices/c21s|Trouver une base pour ...]] : * [[Mathc matrices/c24f|Matrices de changement de base]] : * [[Mathc matrices/c24k|Matrice d'une application linéaire]] : * [[Mathc matrices/e05b|Projection sur un sous-espace vectoriel]] *. *. *. *. : : |- | ==Produits scalaires== : * [https://youtube.com/playlist?list=PLi6peGpf8EPM0AMbdj2DOnyFYP97-93Ry '''Playlist'''] : * [[Mathc matrices/Fichiers h : a03d0|Produit scalaire]] : * [[Mathc matrices/e05c| Quelques Propriétés]] : * [[Mathc matrices/08o| Calculer les vecteurs orthogonaux]] : : '''Orthogonalisation ''' : * [[Mathc matrices/e05a|Les matrices ortho'''normales''']] : * [[Mathc matrices/c25b|Quelques '''propriétés''']] : : '''La QR Décompositions ''' : * [[Mathc matrices/c12an8|'''La QR décomposition:''']] : : '''Application ''' : * [[Mathc matrices/c22m|Analyse d'un réseau]] : * [[Mathc matrices/c22t|Analyse d'un circuit électrique]] : : '''Applications en mathématique ''' : * [[Mathc matrices/00u|Les coefficients d'un polynôme]] : * [[Mathc matrices/00z|Les coefficients d'un conique]] : * [[Mathc matrices/c23l|Les coefficients d'un cercle]] : * . : * . : * . : || ==Vecteurs propres== : * [https://youtube.com/playlist?list=PLHCbCgJHr54w&si=rZsd2b1gQ1cI8lkv '''Playlist'''] : * [[Mathc matrices/e12d6|Vecteurs et valeurs propres]] : * [[Mathc matrices/a147|Quelques '''propriétés''']] : * [[Mathc matrices/03h|Valeurs propres '''multiples'''.]] : : '''Applications en mathématique ''' : * [[ Mathc matrices/01z|Conditionnement matriciel]] : * [[Mathc matrices/e050c|'''Fonctions matricielles''']] : * [[Mathc matrices/09b|'''Système dynamique linéaire discret''']] : * [[Mathc matrices/a29|La décomposition spectral]] : : '''Applications graphique ''' : * [[Mathc matrices/060|'''Formes quadratiques : 2D''']] ; * [[Mathc matrices/061|'''Formes quadratiques : 3D''']] : * [[Mathc matrices/08p| '''Choisir''' les valeurs propres]] : * [[Mathc matrices/03g|'''Projection du plan'''(1)]] : * [[Mathc matrices/08u|'''Projection du plan'''(2)]] : * [[Mathc matrices/a257|'''Projection de l'espace'''(1)]] : * [[Mathc matrices/090|'''Projection de l'espace'''(2)]] : * [[Mathc matrices/062|''' Projection de l'hyperespace'''(1)]] : * [[Mathc matrices/096|'''Projection de l'hyperespace'''(2)]] : : |- | ==Valeurs singulière== : * [https://youtube.com/playlist?list=PLeCGlgiiTezU&si=FMpg7W-9ggkypVEJ '''Playlist'''] : * [[Mathc matrices/c12an7|Calculer les Valeurs Singulières]] : : '''SVD décomposition''' : * [[Mathc matrices/e12a|Plus de lignes que de colonnes]] : * [[Mathc matrices/02q|Quelques propriétés ]] : * [[Mathc matrices/e12b|Plus de colonnes que de lignes]] *. *. *. *. : : || ==Pseudo inverse== : * [https://youtube.com/playlist?list=PLEJ0dDNSeWu8&si=e2V9XOgjRqFrlFMr '''Playlist'''] : * [[Mathc matrices/26d|'''Pseudo inverse:''']] : : '''Application ''' : * [[Mathc matrices/00t|Analyse d'un réseau]] : * [[Mathc matrices/00i|Analyse d'un circuit électrique]] : : '''Applications en mathématique ''' : * [[Mathc matrices/088|Étude d'un polynôme]] : * [[Mathc matrices/08b|Étude d'un conique]] : * [[Mathc matrices/00k|Étude d'un cercle]] : : |- | ==Espace euclidien== * [https://youtube.com/playlist?list=PLJ8C0Kl0F9vo&si=neCAfxj1GXoGAtBM '''Playlist'''] * [[Mathc matrices/0i9|Produits scalaires standard (2 )]] * [[Mathc matrices/0hx|Produits scalaires standard (3')]] * [[Mathc matrices/0ig|Inégalité Cauchy-Schwarz]] *. * [[Mathc matrices/0b7|Produits scalaires avec poids (D1)]] * [[Mathc matrices/0ge|Produits scalaires avec poids (D2)]] * [[Mathc matrices/0h8|Produits scalaires avec poids (D3')]] * [[Mathc matrices/0in|Inégalité Cauchy-Schwarz]] *. * [[Mathc matrices/0c0|Produits scalaires avec poids (+A1)]] * [[Mathc matrices/0gr|Produits scalaires avec poids (+A2)]] * [[Mathc matrices/0hf|Produits scalaires avec poids (+A3')]] *. * [[Mathc matrices/0d5|Produits scalaires avec poids (AtA1)]] * [[Mathc matrices/0h3|Produits scalaires avec poids (AtA2)]] * [[Mathc matrices/0hl|Produits scalaires avec poids (AtA3')]] *. * [[Mathc matrices/0cf|Produits scalaires avec trace() (1)]] * [[Mathc matrices/0g1|Produits scalaires avec trace() (2)]] * [[Mathc matrices/0hr|Produits scalaires avec trace() (3')]] || * [https://youtube.com/playlist?list=PLN1BB1ITBByg&si=4pXoU0I31HYa0ro- '''Playlist'''] * [[Mathc matrices/0ff|Produits scalaires dans f(x) (1)]] * [[Mathc matrices/0a2|Produits scalaires dans f(x) (2)]] *. * [[Mathc matrices/0aw|Produits scalaires dans f(x) (3)]] * [[Mathc matrices/0fl|Produits scalaires dans f(1) (3')]] * [[Mathc matrices/0iw|Produits scalaires dans f(2) (3')]] * [[Mathc matrices/0j3|Produits scalaires dans f(3) (3')]] *. * [[Mathc matrices/0jh|Prod. S. dans f(x) avec poids (1 ) ]] * [[Mathc matrices/0jo|Prod. S. dans f(1) avec poids (3') ]] * [[Mathc matrices/0jv|Prod. S. dans f(2) avec poids (3') ]] *. * [[Mathc matrices/0dr|Produits scalaires dans p(x) (1) ]] * [[Mathc matrices/0fr|Produits scalaires dans p(x) 1(3')]] * [[Mathc matrices/0k2|Produits scalaires dans p(x) 2(3')]] '''Version 2 ''' * [[Mathc matrices/0k3|Produits scalaires dans f(x)]] * [[Mathc matrices/0kg|Prod. S. dans f(x) avec poids ]] *. *. *. *. |} : : ---- {{Partie{{{type|}}}| '''La bibliothèque'''}} : {{Partie{{{type|}}}|[[Mathc matrices/c21r| '''La bibliothèque.''']]}} : ---- {{Partie{{{type|}}}| '''Gnuplot.'''}} : * [[Mathc matrices/c21q|Gnuplot:]] {{Lien modifier|Mathc matrices/Sommaire|modifier le sommaire}} {{AutoCat}} jaudi57ffbgzndbjqhljbv3l8t1nvke 771193 771185 2026-08-22T09:10:39Z Xhungab 23827 771193 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc matrices (livre)]] : * [[Mathc matrices/Introduction|Introduction]] ---- ---- : {{Partie{{{type|}}}| '''Propriétés et Applications'''}} {| class="wikitable" |+ |- | ==Octave== * [[Mathc matrices/a27| Fonctions pour les matrices]] * [[Mathc matrices/04g| Le code de quelques fonctions]] * [[Mathc matrices/03b| Utilitaires graphiques]] * [[Mathc matrices/04m| Exemples d'applications]] * [[Mathc matrices/04l| Ecrire des fonctions mathématiques]] || ==Le langage C== : * [https://youtube.com/playlist?list=PLi6peGpf8EPOx2Pm_rspnagtdkBYe1t6J&feature=shared '''Playlist'''] : * [[Mathc matrices/a33|'''Utilitaires pour le langage C''']] *. *. *. |- | ==Les matrices== : * [https://youtube.com/playlist?list=PLi6peGpf8EPOx2Pm_rspnagtdkBYe1t6J&feature=shared '''Playlist'''] : * [[Mathc matrices/05q|Créer une matrice ]] : * [[Mathc matrices/e12d2|Afficher une matrice ]] : * [[Mathc matrices/a03c|Afficher une matrice dans un fichier]] : * [[Mathc matrices/Fichiers c : mul_tran|Afficher une matrice pour Octave:]] : * [[Mathc matrices/a07|Copier une matrice]] : * [[Mathc matrices/c12eb1|Matrices aléatoires]] : * [[Mathc matrices/01p|Éntrez vos données]] : * [[Mathc matrices/c12bn|Tableaux de matrices]] : * [[Mathc matrices/a94|Afficher des vecteurs d'une colonne]] : : '''Opérations de bases''' : * [[Mathc matrices/c12ea2|Opérations sur les matrices]] : * [[Mathc matrices/c12ea1|La fonction trace_R();]] : * [[Mathc matrices/03o|La fonction transpose_R();]] *. *. *. *. *. *. : : || ==Les matrices spécifiques== : * [https://youtube.com/playlist?list=PLQZh_m-wiVNs&si=kCFfVn3Lbms-d-go '''Playlist'''] : * [[Mathc matrices/c12fn17|Matrice identité]] : * [[Mathc matrices/c12b8|Matrices triangulaires]] : * [[Mathc matrices/e02a|Matrices commutatives]] : * [[Mathc matrices/a90|Matrices semblables]] : * [[Mathc matrices/c12b3|Matrices symétriques]] : * [[Mathc matrices/c12b1|Matrices anti-symétriques]] : * [[Mathc matrices/02h|Matrice centrosymétrique]] : * [[Mathc matrices/02v|Matrices définies positives]] : * [[Mathc matrices/09n|Matrice de Gram]] : * [[Mathc matrices/09o|1/2 int(P1 P2, x= -1..1)]] : * [[Mathc matrices/035|Matrices définies négatives]] : * [[Mathc matrices/008|Matrices de Hankel]] : * [[Mathc matrices/009|Matrices de Toeplitz]] : * [[Mathc matrices/09i|Matrices de Markov]] : * [[Mathc matrices/a76|La fonction exponentielle]] : : '''Application Graphiques''' : * [[Mathc matrices/e02|Matrices de transformation [2x2]]] : * [[Mathc matrices/a133|Matrices de transformation [3x3]]] : : |- | ==Déterminant== : * [https://youtube.com/playlist?list=PLi6peGpf8EPO6GuKOJERdVXKu_zdExd9_&si=1P0vkGzm5Mrf9Vf2 '''Playlist'''] : * [[Mathc matrices/b03|Le déterminant]] : * [[Mathc matrices/Fichiers c : fp_e_mr| Opérations élémentaires]] : * [[Mathc matrices/e12d9|Les fonctions intermédiaires]] : * [[Mathc matrices/c21n|Quelque propriétés ]] : : '''Applications en mathématique ''' : * [[Mathc matrices/149|Matrice Adjointe]] : * [[Mathc matrices/e12d7|L'inverse par le déterminant]] ; * [[Mathc matrices/a235|Produit en croix]] : : || ==Applications== : * [[Mathc matrices/25a|L'équation d'une droite]] : * [[Mathc matrices/25b|L'équation d'un plan]] : * [[Mathc matrices/25c|L'équation d'une parabole]] : * [[Mathc matrices/25d|L'équation d'un cercle]] : * [[Mathc matrices/25e|L'équation d'une sphère]] * . * . * . * . * . : : |- | ==Total Pivoting== : * [https://youtube.com/playlist?list=PLi6peGpf8EPODYQZ0fosFTZZqKB9-CkHi '''Playlist'''] : * [[Mathc matrices/a205|Gauss-Jordan Total Pivoting]] : * [[Mathc matrices/a207|L'inverse]] : : '''Application ''' : * [[Mathc matrices/a209|Analyse d'un '''réseau''']] : * [[Mathc matrices/a216|Analyse d'un circuit '''électrique''']] : : '''Applications en géometrie ''' : * [[Mathc matrices/26a|L'équation d'un '''polynôme''']] : * [[Mathc matrices/26b|L'équation d'un '''conique''']] : * [[Mathc matrices/26c|L'équation d'un '''cercle''']] : : '''Applications en mathématique ''' : * [[Mathc matrices/a208|Systèmes '''non linéaire''' ]] : * [[Mathc matrices/a32|Choisir les solutions d'un système]] : : || ==Partial Pivoting== : * [https://youtube.com/playlist?list=PLCKoQKPJSeZ4&si=xL59cV3PKV8bK34W '''Playlist'''] : * [[Mathc matrices/a203|Gauss-Jordan Partial Pivoting]] : * [[Mathc matrices/a204|Variables libres]] : : '''Application ''' : * [[Mathc matrices/a201|Équation '''chimique''']] : : '''Applications mathématiques ''' : * [[Mathc matrices/c21s|Trouver une base pour ...]] : * [[Mathc matrices/c24f|Matrices de changement de base]] : * [[Mathc matrices/c24k|Matrice d'une application linéaire]] : * [[Mathc matrices/e05b|Projection sur un sous-espace vectoriel]] *. *. *. *. : : |- | ==Produits scalaires== : * [https://youtube.com/playlist?list=PLi6peGpf8EPM0AMbdj2DOnyFYP97-93Ry '''Playlist'''] : * [[Mathc matrices/Fichiers h : a03d0|Produit scalaire]] : * [[Mathc matrices/e05c| Quelques Propriétés]] : * [[Mathc matrices/08o| Calculer les vecteurs orthogonaux]] : : '''Orthogonalisation ''' : * [[Mathc matrices/e05a|Les matrices ortho'''normales''']] : * [[Mathc matrices/c25b|Quelques '''propriétés''']] : : '''La QR Décompositions ''' : * [[Mathc matrices/c12an8|'''La QR décomposition:''']] : : '''Application ''' : * [[Mathc matrices/c22m|Analyse d'un réseau]] : * [[Mathc matrices/c22t|Analyse d'un circuit électrique]] : : '''Applications en mathématique ''' : * [[Mathc matrices/00u|Les coefficients d'un polynôme]] : * [[Mathc matrices/00z|Les coefficients d'un conique]] : * [[Mathc matrices/c23l|Les coefficients d'un cercle]] : * . : * . : * . : || ==Vecteurs propres== : * [https://youtube.com/playlist?list=PLHCbCgJHr54w&si=rZsd2b1gQ1cI8lkv '''Playlist'''] : * [[Mathc matrices/e12d6|Vecteurs et valeurs propres]] : * [[Mathc matrices/a147|Quelques '''propriétés''']] : * [[Mathc matrices/03h|Valeurs propres '''multiples'''.]] : : '''Applications en mathématique ''' : * [[ Mathc matrices/01z|Conditionnement matriciel]] : * [[Mathc matrices/e050c|'''Fonctions matricielles''']] : * [[Mathc matrices/09b|'''Système dynamique linéaire discret''']] : * [[Mathc matrices/a29|La décomposition spectral]] : : '''Applications graphique ''' : * [[Mathc matrices/060|'''Formes quadratiques : 2D''']] ; * [[Mathc matrices/061|'''Formes quadratiques : 3D''']] : * [[Mathc matrices/08p| '''Choisir''' les valeurs propres]] : * [[Mathc matrices/03g|'''Projection du plan'''(1)]] : * [[Mathc matrices/08u|'''Projection du plan'''(2)]] : * [[Mathc matrices/a257|'''Projection de l'espace'''(1)]] : * [[Mathc matrices/090|'''Projection de l'espace'''(2)]] : * [[Mathc matrices/062|''' Projection de l'hyperespace'''(1)]] : * [[Mathc matrices/096|'''Projection de l'hyperespace'''(2)]] : : |- | ==Valeurs singulière== : * [https://youtube.com/playlist?list=PLeCGlgiiTezU&si=FMpg7W-9ggkypVEJ '''Playlist'''] : * [[Mathc matrices/c12an7|Calculer les Valeurs Singulières]] : : '''SVD décomposition''' : * [[Mathc matrices/e12a|Plus de lignes que de colonnes]] : * [[Mathc matrices/02q|Quelques propriétés ]] : * [[Mathc matrices/e12b|Plus de colonnes que de lignes]] *. *. *. *. : : || ==Pseudo inverse== : * [https://youtube.com/playlist?list=PLEJ0dDNSeWu8&si=e2V9XOgjRqFrlFMr '''Playlist'''] : * [[Mathc matrices/26d|'''Pseudo inverse:''']] : : '''Application ''' : * [[Mathc matrices/00t|Analyse d'un réseau]] : * [[Mathc matrices/00i|Analyse d'un circuit électrique]] : : '''Applications en mathématique ''' : * [[Mathc matrices/088|Étude d'un polynôme]] : * [[Mathc matrices/08b|Étude d'un conique]] : * [[Mathc matrices/00k|Étude d'un cercle]] : : |- | ==Espace euclidien== * [https://youtube.com/playlist?list=PLJ8C0Kl0F9vo&si=neCAfxj1GXoGAtBM '''Playlist'''] * [[Mathc matrices/0i9|Produits scalaires standard (2 )]] * [[Mathc matrices/0hx|Produits scalaires standard (3')]] * [[Mathc matrices/0ig|Inégalité Cauchy-Schwarz]] *. * [[Mathc matrices/0b7|Produits scalaires avec poids (D1)]] * [[Mathc matrices/0ge|Produits scalaires avec poids (D2)]] * [[Mathc matrices/0h8|Produits scalaires avec poids (D3')]] * [[Mathc matrices/0in|Inégalité Cauchy-Schwarz]] *. * [[Mathc matrices/0c0|Produits scalaires avec poids (+A1)]] * [[Mathc matrices/0gr|Produits scalaires avec poids (+A2)]] * [[Mathc matrices/0hf|Produits scalaires avec poids (+A3')]] *. * [[Mathc matrices/0d5|Produits scalaires avec poids (AtA1)]] * [[Mathc matrices/0h3|Produits scalaires avec poids (AtA2)]] * [[Mathc matrices/0hl|Produits scalaires avec poids (AtA3')]] *. * [[Mathc matrices/0cf|Produits scalaires avec trace() (1)]] * [[Mathc matrices/0g1|Produits scalaires avec trace() (2)]] * [[Mathc matrices/0hr|Produits scalaires avec trace() (3')]] || ==Espace euclidien (Fonctions)== * [https://youtube.com/playlist?list=PLN1BB1ITBByg&si=4pXoU0I31HYa0ro- '''Playlist'''] * [[Mathc matrices/0ff|Produits scalaires dans f(x) (1)]] * [[Mathc matrices/0a2|Produits scalaires dans f(x) (2)]] *. * [[Mathc matrices/0aw|Produits scalaires dans f(x) (3)]] * [[Mathc matrices/0fl|Produits scalaires dans f(1) (3')]] * [[Mathc matrices/0iw|Produits scalaires dans f(2) (3')]] * [[Mathc matrices/0j3|Produits scalaires dans f(3) (3')]] *. * [[Mathc matrices/0jh|Prod. S. dans f(x) avec poids (1 ) ]] * [[Mathc matrices/0jo|Prod. S. dans f(1) avec poids (3') ]] * [[Mathc matrices/0jv|Prod. S. dans f(2) avec poids (3') ]] *. * [[Mathc matrices/0dr|Produits scalaires dans p(x) (1) ]] * [[Mathc matrices/0fr|Produits scalaires dans p(x) 1(3')]] * [[Mathc matrices/0k2|Produits scalaires dans p(x) 2(3')]] '''Version 2 ''' * [[Mathc matrices/0k3|Produits scalaires dans f(x)]] * [[Mathc matrices/0kg|Prod. S. dans f(x) avec poids ]] *. *. |} : : ---- {{Partie{{{type|}}}| '''La bibliothèque'''}} : {{Partie{{{type|}}}|[[Mathc matrices/c21r| '''La bibliothèque.''']]}} : ---- {{Partie{{{type|}}}| '''Gnuplot.'''}} : * [[Mathc matrices/c21q|Gnuplot:]] {{Lien modifier|Mathc matrices/Sommaire|modifier le sommaire}} {{AutoCat}} p9tcosu9pcf3fji7dw4bn6ghc6yxy3d 771194 771193 2026-08-22T09:11:44Z Xhungab 23827 771194 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc matrices (livre)]] : * [[Mathc matrices/Introduction|Introduction]] ---- ---- : {{Partie{{{type|}}}| '''Propriétés et Applications'''}} {| class="wikitable" |+ |- | ==Octave== * [[Mathc matrices/a27| Fonctions pour les matrices]] * [[Mathc matrices/04g| Le code de quelques fonctions]] * [[Mathc matrices/03b| Utilitaires graphiques]] * [[Mathc matrices/04m| Exemples d'applications]] * [[Mathc matrices/04l| Ecrire des fonctions mathématiques]] || ==Le langage C== : * [https://youtube.com/playlist?list=PLi6peGpf8EPOx2Pm_rspnagtdkBYe1t6J&feature=shared '''Playlist'''] : * [[Mathc matrices/a33|'''Utilitaires pour le langage C''']] *. *. *. |- | ==Les matrices== : * [https://youtube.com/playlist?list=PLi6peGpf8EPOx2Pm_rspnagtdkBYe1t6J&feature=shared '''Playlist'''] : * [[Mathc matrices/05q|Créer une matrice ]] : * [[Mathc matrices/e12d2|Afficher une matrice ]] : * [[Mathc matrices/a03c|Afficher une matrice dans un fichier]] : * [[Mathc matrices/Fichiers c : mul_tran|Afficher une matrice pour Octave:]] : * [[Mathc matrices/a07|Copier une matrice]] : * [[Mathc matrices/c12eb1|Matrices aléatoires]] : * [[Mathc matrices/01p|Éntrez vos données]] : * [[Mathc matrices/c12bn|Tableaux de matrices]] : * [[Mathc matrices/a94|Afficher des vecteurs d'une colonne]] : : '''Opérations de bases''' : * [[Mathc matrices/c12ea2|Opérations sur les matrices]] : * [[Mathc matrices/c12ea1|La fonction trace_R();]] : * [[Mathc matrices/03o|La fonction transpose_R();]] *. *. *. *. *. *. : : || ==Les matrices spécifiques== : * [https://youtube.com/playlist?list=PLQZh_m-wiVNs&si=kCFfVn3Lbms-d-go '''Playlist'''] : * [[Mathc matrices/c12fn17|Matrice identité]] : * [[Mathc matrices/c12b8|Matrices triangulaires]] : * [[Mathc matrices/e02a|Matrices commutatives]] : * [[Mathc matrices/a90|Matrices semblables]] : * [[Mathc matrices/c12b3|Matrices symétriques]] : * [[Mathc matrices/c12b1|Matrices anti-symétriques]] : * [[Mathc matrices/02h|Matrice centrosymétrique]] : * [[Mathc matrices/02v|Matrices définies positives]] : * [[Mathc matrices/09n|Matrice de Gram]] : * [[Mathc matrices/09o|1/2 int(P1 P2, x= -1..1)]] : * [[Mathc matrices/035|Matrices définies négatives]] : * [[Mathc matrices/008|Matrices de Hankel]] : * [[Mathc matrices/009|Matrices de Toeplitz]] : * [[Mathc matrices/09i|Matrices de Markov]] : * [[Mathc matrices/a76|La fonction exponentielle]] : : '''Application Graphiques''' : * [[Mathc matrices/e02|Matrices de transformation [2x2]]] : * [[Mathc matrices/a133|Matrices de transformation [3x3]]] : : |- | ==Déterminant== : * [https://youtube.com/playlist?list=PLi6peGpf8EPO6GuKOJERdVXKu_zdExd9_&si=1P0vkGzm5Mrf9Vf2 '''Playlist'''] : * [[Mathc matrices/b03|Le déterminant]] : * [[Mathc matrices/Fichiers c : fp_e_mr| Opérations élémentaires]] : * [[Mathc matrices/e12d9|Les fonctions intermédiaires]] : * [[Mathc matrices/c21n|Quelque propriétés ]] : : '''Applications en mathématique ''' : * [[Mathc matrices/149|Matrice Adjointe]] : * [[Mathc matrices/e12d7|L'inverse par le déterminant]] ; * [[Mathc matrices/a235|Produit en croix]] : : || ==Applications== : * [[Mathc matrices/25a|L'équation d'une droite]] : * [[Mathc matrices/25b|L'équation d'un plan]] : * [[Mathc matrices/25c|L'équation d'une parabole]] : * [[Mathc matrices/25d|L'équation d'un cercle]] : * [[Mathc matrices/25e|L'équation d'une sphère]] * . * . * . * . * . : : |- | ==Total Pivoting== : * [https://youtube.com/playlist?list=PLi6peGpf8EPODYQZ0fosFTZZqKB9-CkHi '''Playlist'''] : * [[Mathc matrices/a205|Gauss-Jordan Total Pivoting]] : * [[Mathc matrices/a207|L'inverse]] : : '''Application ''' : * [[Mathc matrices/a209|Analyse d'un '''réseau''']] : * [[Mathc matrices/a216|Analyse d'un circuit '''électrique''']] : : '''Applications en géometrie ''' : * [[Mathc matrices/26a|L'équation d'un '''polynôme''']] : * [[Mathc matrices/26b|L'équation d'un '''conique''']] : * [[Mathc matrices/26c|L'équation d'un '''cercle''']] : : '''Applications en mathématique ''' : * [[Mathc matrices/a208|Systèmes '''non linéaire''' ]] : * [[Mathc matrices/a32|Choisir les solutions d'un système]] : : || ==Partial Pivoting== : * [https://youtube.com/playlist?list=PLCKoQKPJSeZ4&si=xL59cV3PKV8bK34W '''Playlist'''] : * [[Mathc matrices/a203|Gauss-Jordan Partial Pivoting]] : * [[Mathc matrices/a204|Variables libres]] : : '''Application ''' : * [[Mathc matrices/a201|Équation '''chimique''']] : : '''Applications mathématiques ''' : * [[Mathc matrices/c21s|Trouver une base pour ...]] : * [[Mathc matrices/c24f|Matrices de changement de base]] : * [[Mathc matrices/c24k|Matrice d'une application linéaire]] : * [[Mathc matrices/e05b|Projection sur un sous-espace vectoriel]] *. *. *. *. : : |- | ==Produits scalaires== : * [https://youtube.com/playlist?list=PLi6peGpf8EPM0AMbdj2DOnyFYP97-93Ry '''Playlist'''] : * [[Mathc matrices/Fichiers h : a03d0|Produit scalaire]] : * [[Mathc matrices/e05c| Quelques Propriétés]] : * [[Mathc matrices/08o| Calculer les vecteurs orthogonaux]] : : '''Orthogonalisation ''' : * [[Mathc matrices/e05a|Les matrices ortho'''normales''']] : * [[Mathc matrices/c25b|Quelques '''propriétés''']] : : '''La QR Décompositions ''' : * [[Mathc matrices/c12an8|'''La QR décomposition:''']] : : '''Application ''' : * [[Mathc matrices/c22m|Analyse d'un réseau]] : * [[Mathc matrices/c22t|Analyse d'un circuit électrique]] : : '''Applications en mathématique ''' : * [[Mathc matrices/00u|Les coefficients d'un polynôme]] : * [[Mathc matrices/00z|Les coefficients d'un conique]] : * [[Mathc matrices/c23l|Les coefficients d'un cercle]] : * . : * . : * . : || ==Vecteurs propres== : * [https://youtube.com/playlist?list=PLHCbCgJHr54w&si=rZsd2b1gQ1cI8lkv '''Playlist'''] : * [[Mathc matrices/e12d6|Vecteurs et valeurs propres]] : * [[Mathc matrices/a147|Quelques '''propriétés''']] : * [[Mathc matrices/03h|Valeurs propres '''multiples'''.]] : : '''Applications en mathématique ''' : * [[ Mathc matrices/01z|Conditionnement matriciel]] : * [[Mathc matrices/e050c|'''Fonctions matricielles''']] : * [[Mathc matrices/09b|'''Système dynamique linéaire discret''']] : * [[Mathc matrices/a29|La décomposition spectral]] : : '''Applications graphique ''' : * [[Mathc matrices/060|'''Formes quadratiques : 2D''']] ; * [[Mathc matrices/061|'''Formes quadratiques : 3D''']] : * [[Mathc matrices/08p| '''Choisir''' les valeurs propres]] : * [[Mathc matrices/03g|'''Projection du plan'''(1)]] : * [[Mathc matrices/08u|'''Projection du plan'''(2)]] : * [[Mathc matrices/a257|'''Projection de l'espace'''(1)]] : * [[Mathc matrices/090|'''Projection de l'espace'''(2)]] : * [[Mathc matrices/062|''' Projection de l'hyperespace'''(1)]] : * [[Mathc matrices/096|'''Projection de l'hyperespace'''(2)]] : : |- | ==Valeurs singulière== : * [https://youtube.com/playlist?list=PLeCGlgiiTezU&si=FMpg7W-9ggkypVEJ '''Playlist'''] : * [[Mathc matrices/c12an7|Calculer les Valeurs Singulières]] : : '''SVD décomposition''' : * [[Mathc matrices/e12a|Plus de lignes que de colonnes]] : * [[Mathc matrices/02q|Quelques propriétés ]] : * [[Mathc matrices/e12b|Plus de colonnes que de lignes]] *. *. *. *. : : || ==Pseudo inverse== : * [https://youtube.com/playlist?list=PLEJ0dDNSeWu8&si=e2V9XOgjRqFrlFMr '''Playlist'''] : * [[Mathc matrices/26d|'''Pseudo inverse:''']] : : '''Application ''' : * [[Mathc matrices/00t|Analyse d'un réseau]] : * [[Mathc matrices/00i|Analyse d'un circuit électrique]] : : '''Applications en mathématique ''' : * [[Mathc matrices/088|Étude d'un polynôme]] : * [[Mathc matrices/08b|Étude d'un conique]] : * [[Mathc matrices/00k|Étude d'un cercle]] : : |- | ==Espace euclidien== * [https://youtube.com/playlist?list=PLJ8C0Kl0F9vo&si=neCAfxj1GXoGAtBM '''Playlist'''] * [[Mathc matrices/0i9|Produits scalaires standard (2 )]] * [[Mathc matrices/0hx|Produits scalaires standard (3')]] * [[Mathc matrices/0ig|Inégalité Cauchy-Schwarz]] *. * [[Mathc matrices/0b7|Produits scalaires avec poids (D1)]] * [[Mathc matrices/0ge|Produits scalaires avec poids (D2)]] * [[Mathc matrices/0h8|Produits scalaires avec poids (D3')]] * [[Mathc matrices/0in|Inégalité Cauchy-Schwarz]] *. * [[Mathc matrices/0c0|Produits scalaires avec poids (+A1)]] * [[Mathc matrices/0gr|Produits scalaires avec poids (+A2)]] * [[Mathc matrices/0hf|Produits scalaires avec poids (+A3')]] *. * [[Mathc matrices/0d5|Produits scalaires avec poids (AtA1)]] * [[Mathc matrices/0h3|Produits scalaires avec poids (AtA2)]] * [[Mathc matrices/0hl|Produits scalaires avec poids (AtA3')]] *. * [[Mathc matrices/0cf|Produits scalaires avec trace() (1)]] * [[Mathc matrices/0g1|Produits scalaires avec trace() (2)]] * [[Mathc matrices/0hr|Produits scalaires avec trace() (3')]] || ==Sur les fonctions== * [https://youtube.com/playlist?list=PLN1BB1ITBByg&si=4pXoU0I31HYa0ro- '''Playlist'''] * [[Mathc matrices/0ff|Produits scalaires dans f(x) (1)]] * [[Mathc matrices/0a2|Produits scalaires dans f(x) (2)]] *. * [[Mathc matrices/0aw|Produits scalaires dans f(x) (3)]] * [[Mathc matrices/0fl|Produits scalaires dans f(1) (3')]] * [[Mathc matrices/0iw|Produits scalaires dans f(2) (3')]] * [[Mathc matrices/0j3|Produits scalaires dans f(3) (3')]] *. * [[Mathc matrices/0jh|Prod. S. dans f(x) avec poids (1 ) ]] * [[Mathc matrices/0jo|Prod. S. dans f(1) avec poids (3') ]] * [[Mathc matrices/0jv|Prod. S. dans f(2) avec poids (3') ]] *. * [[Mathc matrices/0dr|Produits scalaires dans p(x) (1) ]] * [[Mathc matrices/0fr|Produits scalaires dans p(x) 1(3')]] * [[Mathc matrices/0k2|Produits scalaires dans p(x) 2(3')]] '''Version 2 ''' * [[Mathc matrices/0k3|Produits scalaires dans f(x)]] * [[Mathc matrices/0kg|Prod. S. dans f(x) avec poids ]] *. *. |} : : ---- {{Partie{{{type|}}}| '''La bibliothèque'''}} : {{Partie{{{type|}}}|[[Mathc matrices/c21r| '''La bibliothèque.''']]}} : ---- {{Partie{{{type|}}}| '''Gnuplot.'''}} : * [[Mathc matrices/c21q|Gnuplot:]] {{Lien modifier|Mathc matrices/Sommaire|modifier le sommaire}} {{AutoCat}} 8wstm6vbo829vasdl66loila0i1cqaw Mathc initiation/c58a3 0 76707 771195 729767 2026-08-22T11:29:05Z Xhungab 23827 771195 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] : . : [[Mathc initiation/Fichiers h : c44a4| Sommaire]] : . : '''L'étude de ce chapitre peut ce faire à l'aide de cette [[https://youtube.com/playlist?list=PLI02BRu_AoGA&si=1ZSqxdlTrTB3XvSl Playlist]]. : . : {{Partie{{{type|}}}|[[Mathc initiation/c57a3|* Dessiner les fonctions vectorielles en 2d]]}} : {{Partie{{{type|}}}|[[Mathc initiation/c57a4|* Dessiner les fonctions vectorielles en 3d]]}} : . : {{Partie{{{type|}}}|[[Mathc initiation/a106|* Dessiner les tangentes en 2d]]}} : {{Partie{{{type|}}}|[[Mathc initiation/a107|* Dessiner les tangentes en 3d]]}} : . : {{Partie{{{type|}}}|[[Mathc initiation/a129|* Dessiner le vecteur vitesse et le vecteur accélération 2d]]}} : {{Partie{{{type|}}}|[[Mathc initiation/a130|* Dessiner le vecteur vitesse et le vecteur accélération 3d]]}} : . : {{Partie{{{type|}}}|[[Mathc initiation/Fichiers h : c54a5|* Intégrer, dériver une fonction vectorielle. Accélération tangentielle et normale.]]}} : . : {{Partie{{{type|}}}|[[Mathc initiation/Fichiers h : c51a4|* Calculer la longueur d'une courbe définie par une fonction vectorielle en 2d]]}} : {{Partie{{{type|}}}|[[Mathc initiation/a137|* Calculer la longueur d'une courbe définie par une fonction vectorielle en 3d]]}} : . : {{Partie{{{type|}}}|[[Mathc initiation/Fichiers h : c40fc|* Calculer la courbure d'une fonction vectorielle en 3d]]}} : {{AutoCat}} t2wum12clitqdg2nuaz1a470rkdaum1 Fonctionnement d'un ordinateur/Les méthodes de synchronisation entre processeur et périphériques 0 78309 771167 768545 2026-08-22T01:21:44Z Mewtow 31375 /* Les coprocesseurs d'entrée-sorties */ 771167 wikitext text/x-wiki Dans ce chapitre, on va voir comment les périphériques communiquent avec le processeur ou la mémoire. On sait déjà que les entrées-sorties (et donc les périphériques) sont reliées au reste de l'ordinateur par un ou plusieurs bus. Pour communiquer avec un périphérique, le processeur a juste besoin de configurer ces bus avec les bonnes valeurs. Dans la façon la plus simple de procéder, le processeur se connecte au bus et reste connecté au bus tant que le périphérique n'a pas traité sa demande, que ce soit une lecture, ou une écriture. Mais les périphériques sont tellement lents que le processeur passe son temps à attendre le périphérique. Aussi, il a fallu trouver une solution pour simplifier la communication avec les périphériques. ==Le contrôleur de périphériques== Pour résoudre ce problème, il suffit d'intercaler un intermédiaire entre le périphérique et le reste de l'ordinateur. Cet intermédiaire s'appelle le '''contrôleur de périphériques'''. Les contrôleurs de périphérique vont du simple circuit de quelques centaines de transistors à un microcontrôleur très puissant. Le contrôleur de périphérique est généralement placé sur la carte mère, mais il peut être intégré directement dans le périphérique, tout dépend de la situation. [[File:CPT-3Box-Model-Peripherals.svg|centre|vignette|upright=1.5|Contrôleur de périphériques.]] Le processeur envoie au contrôleur de périphérique des « commandes », des valeurs numériques auxquelles le périphérique répond en effectuant un ensemble d'actions préprogrammées. Le contrôleur de périphérique reçoit les commandes envoyées par le processeur et pilote le périphérique de façon à faire ce qui est demandé. Le boulot du contrôleur de périphérique est de générer des signaux de commande qui déclencheront une action effectuée par le périphérique. L'analogie avec le séquenceur d'un processeur est possible. [[File:Controleur de périphériques.png|centre|vignette|upright=2.5|Echanges de données entre bus et contrôleur de périphériques.]] ===Les registres d’interfaçage=== Pour faire son travail, le contrôleur de périphérique doit avoir de quoi mémoriser les données à échanger entre processeur et périphérique. Pour cela, il contient des '''registres d'interfaçage''' entre le processeur et les entrées-sorties. Pour simplifier, les registres d’interfaçage sont de trois types : les registres de données, les registres de commande et les registres d'état. * Les ''registres de données'' permettent l'échange de données entre le processeur et les périphériques. On trouve généralement un registre de lecture et un registre d'écriture, mais il se peut que les deux soient fusionnés en un seul registre d’interfaçage de données. * Les ''registres de commande'' sont des registres qui mémorisent les commandes envoyées par le processeur. Quand le processeur veut envoyer une commande au périphérique, il écrit la commande en question dans ce ou ces registres. * Enfin, beaucoup de périphériques ont un ''registre d'état'', lisible par le processeur, qui contient des informations sur l'état du périphérique. Ils servent notamment à indiquer au processeur que le périphérique est disponible, qu'il est en train d’exécuter une commande, qu'il est utilisé par un autre processeur, etc. Ils peuvent parfois signaler des erreurs de configuration ou des pannes touchant un périphérique. [[File:Registres d'interfaçage.png|centre|vignette|upright=2|Registres d'interfaçage.]] Les registres d’interfaçage libèrent le processeur lors de l'accès à un périphérique, mais seulement en partie. Ils sont très utiles pour les transferts du processeur vers les périphériques. Le processeur écrit dans ces registres et fait autre chose en attendant que le périphérique ait terminé : le registre maintient les informations à transmettre tant que le périphérique en a besoin. Une écriture ou en envoi de commande simple demande donc au processeur d'écrire dans les registres d’interfaçage, rien de plus. Mais les transferts dans l'autre sens sont plus problématiques. Par exemple, imaginons que le processeur souhaite lire une donnée depuis le disque dur : le processeur envoie l'ordre de lecture en écrivant dans les registres d’interfaçage, fait autre chose en attendant que la donnée soit lue, puis récupère la donnée quand elle est disponible. Mais comment fait-il pour savoir quand la donnée lue est disponible ? De même, le processeur ne peut pas (sauf cas particuliers) envoyer une autre commande au contrôleur de périphérique tant que la première commande n'est pas traitée, mais comment sait-il quand le périphérique en a terminé avec la première commande ? Pour résoudre ces problèmes, il existe globalement trois méthodes : le ''pooling'', l'usage d'interruptions, et le ''Direct Memory Access''. La solution la plus simple, appelée ''Pooling'', est de vérifier périodiquement si le périphérique a envoyé quelque chose. Par exemple, après avoir envoyé un ordre au contrôleur, le processeur vérifie périodiquement si le contrôleur est prêt pour un nouvel envoi de commandes/données. Sinon le processeur vérifie régulièrement si le périphérique a quelque chose à dire, au cas où le périphérique veut entamer une transmission. Pour faire cette vérification, le processeur a juste à lire le registre d'état du contrôleur : un bit de celui-ci indique si le contrôleur est libre ou occupé. Le ''Pooling'' est une solution logicielle très imparfaite, car ces vérifications périodiques sont du temps de perdu pour le processeur. Aussi, d'autres solutions ont été inventées. ===Les interruptions de type IRQ=== La vérification régulière des registres d’interfaçage prend du temps que le processeur pourrait utiliser pour autre chose. Pour réduire à néant ce temps perdu, certains processeurs supportent les '''interruptions'''. Pour rappel, il s'agit de fonctionnalités du processeur, qui interrompent temporairement l’exécution d'un programme pour réagir à un événement extérieur (matériel, erreur fatale d’exécution d'un programme…). Lors d'une interruption, le processeur suit la procédure suivante : * arrête l'exécution du programme en cours et sauvegarde l'état du processeur (registres et program counter) ; * exécute un petit programme nommé '''routine d'interruption''' ; * restaure l'état du programme sauvegardé afin de reprendre l'exécution de son programme là ou il en était. [[File:Interruption processeur.png|centre|vignette|upright=2|Interruption processeur]] Dans le chapitre sur les fonctions et la pile d'appel, nous avions vu qu'il existait plusieurs types d'interruptions différents. Les interruptions logicielles sont déclenchées par une instruction spéciale et sont des appels de fonctions spécialisés. Les exceptions matérielles se déclenchent quand le processeur rencontre une erreur : division par zéro, problème de segmentation, etc. Les interruptions matérielles, aussi appelées '''IRQ''', sont des interruptions déclenchées par un périphérique et ce sont celles qui vont nous intéresser dans ce qui suit. Les IRQ sont générées par le contrôleur de périphérique quand c'est nécessaire. [[File:Contrôleur de périphérique.png|centre|vignette|upright=2|Contrôleur de périphérique.]] Avec ces IRQ, le processeur n'a pas à vérifier périodiquement si le contrôleur de périphérique a fini son travail. À la place, le contrôleur de périphérique prévient le processeur avec une interruption. Par exemple, quand vous tapez sur votre clavier, celui-ci émet une interruption à chaque appui/relevée de touche. Ainsi, le processeur est prévenu quand une touche est appuyée, le système d'exploitation qu'il doit regarder quelle touche est appuyée, etc. Pas besoin d'utiliser du ''pooling'', pas besoin de vérifier sans cesse si un périphérique a quelque chose à signaler. À la place, le périphérique déclenche une interruption quand il a quelque chose à dire. ===Les FIFO internes au contrôleur de périphérique=== Le contrôleur de périphérique peut contenir des mémoires FIFO, afin de mettre en attente les transmissions CPU<->périphérique/bus. Rappelons que les transferts se sont entre CPU et contrôleur, puis entre contrôleur et périphérique/bus. Vu que les trois composants ne sont pas synchronisés, il est nécessaire de mettre en attente des transmissions dans les registres d'interfaçage. Mais les registres d'interfaçage ne permettent que de mémoriser une seule transmission à la fois, et ce n'est pas l'idéal niveau performance. Par exemple, prenons l'exemple du circuit 8250 de National Semiconductors, utilisé sur les PC 8 bits. Il s'agissait d'un UART, à savoir d'un circuit qui recevait des octets envoyés sur une liaison série, connectées à un modem ou une imprimante. Il disposait d'un registre d'interface d'un octet en émission, et un autre en réception, ce qui permettait d'envoyer ou de recevoir des trames d'un octet chacune. Les bus auxquels il était relié ne gérait pas des trames plus longues, la transmission se faisait octet par octet. Le problème est que le 8250 générait une interruption processeur à chaque octet reçu ! En soi, ce n'était pas un problème si majeur, car les modems et imprimantes de l'époque étaient très lents, et que les liaisons série de l'époque avaient une fréquence minable. Mais rapidement, avec l'introduction de liaisons série plus rapide, l'UART 8250 générait trop d'interruptions et cela avait un cout en performances. Son successeur, le 16 550 UART, a corrigé ce problème en ajoutant une mémoire FIFO en sortie (et une en entrée, qu'on passe sous silence). Les octets reçus sont copiés non pas vers le registre d'interfaçage, mais vers une mémoire FIFO qui le précède. Elle est capable de mémoriser 16 octets reçus, dans leur ordre de réception. Une interruption est envoyée non pas à chaque octet, mais quand la mémoire FIFO est pleine. Utiliser des mémoires FIFO en réception permet d'accumuler plusieurs transmissions reçues, que le processeur lit en bloc quand il est disponible. Cela permet au processeur de lire plusieurs octets d'un coup assez rapidement, plutôt que d'être dérangé pour chaque octet ou chaque trame. Ainsi, on génère moins d'interruptions. La même méthode peut s'appliquer sans interruptions, avec la technique du ''pooling'', avec des avantages similaires. Et la même chose a lieu en émission : le contrôleur peut accepter plusieurs commandes/données consécutives, et les envoyer aux périphériques une par une. L'envoi des commandes consécutives peut se faire en un seul bloc, ou bien une par une, mais avec un rythme différent de celui du périphérique. ===Un contrôleur de périphérique peut gérer plusieurs périphériques=== Les contrôleurs de périphériques les plus simples ne sont connectés qu'à un seul périphérique, via une connexion point à point. Tel est le cas du port série RS-232 ou des différents ports parallèles, autrefois présents à l'arrière des PC. Mais de nombreux contrôleurs de périphériques sont connectés à plusieurs périphériques. Prenez par exemple l'USB : vous avez plusieurs ports USB sur votre ordinateur, mais ceux-ci sont gérés par un seul contrôleur USB. En fait, ces périphériques sont connectés au contrôleur de périphérique par un '''bus secondaire''', et le contrôleur gère ce qui transite sur le bus. On devrait plutôt parler de '''contrôleur de bus''' que de contrôleur de périphérique dans ce cas précis, mais passons. [[File:Controleur de périphérique qui adresse plusieurs périphériques.png|centre|vignette|upright=2|Contrôleur de périphérique qui adresse plusieurs périphériques]] Les périphériques connectés à un même contrôleur peuvent être radicalement différents, même s’ils sont connectés au même bus. C'est notamment le cas pour tout ce qui est des contrôleurs PCI, USB et autres. On peut connecter en USB aussi bien des clés USB, des imprimantes, des scanners, des lecteurs DVD et bien d'autres. Mais leur respect du standard USB les rend compatibles. Au final, le contrôleur USB gère le bus USB mais se fiche de savoir s’il communique avec un disque dur, une imprimante USB ou quoique ce soit d'autre. Toujours est-il que le contrôleur de périphérique doit pouvoir identifier chaque périphérique. Prenons par exemple le cas où une imprimante, une souris et un disque dur sont connectés en USB sur un ordinateur. Si je lance une impression, le contrôleur de périphérique doit envoyer les données à l'imprimante et pas au disque dur. Pour cela, il attribue à chaque périphérique une ou plusieurs adresses, utilisées pour l'identifier et le sélectionner. En général, les périphériques ont plusieurs adresses : une par registre d’interfaçage. L'adresse permet ainsi d'adresser le périphérique, et de préciser quel registre du contrôleur lire ou écrire. L'adresse d'un périphérique peut être fixée une bonne fois pour toutes dès la conception du périphérique, ou se configurer via un registre ou une EEPROM. Comme on l'a vu dans le chapitre sur les bus, la sélection du bon composant se fait de deux manières : soit les périphériques vérifient si la transmission leur est dédiée, soit on utilise du décodage partiel d'adresse. Le décodage d'adresse n'est pas utilisé quand on peut ajouter ou retirer des périphériques à la demande, la première méthode est plus pratique. Le contrôleur attribue alors une adresse à chaque composant quand il est branché, il attribue les adresses à la volée. Les adresses en question sont alors mémorisées dans le périphérique, ainsi que dans le contrôleur de périphérique. [[File:Décodage d'adresse par le contrôleur de périphérique.png|centre|vignette|upright=2|Décodage d'adresse par le contrôleur de périphérique.]] ==Les entrées d'interruption du processeur== Implémenter les interruptions matérielles demande d'ajouter des circuits à la fois sur le processeur et sur la carte mère. La méthode la plus simple demande d'ajouter au processeur une entrée d'interruption, qui est mise à 1 quand une interruption survient. Cependant, la majorité des processeurs utilise deux entrées d'interruption : une pour les interruptions masquables, et une autre pour les interruptions non-masquables. ===L'entrée d'interruption : niveaux logiques ou fronts=== Nous allons d'abord nous intéresser aux cas d'une interruption matérielle unique, c'est à dire au cas avec un seul périphérique. On peut par exemple imaginer le cas d'un thermostat, basé sur un couple processeur/RAM/ROM, relié à un capteur de mouvement, qui commande une alarme. Le processeur n'a pas besoin d'interruptions pour gérer l'alarme, mais le capteur de mouvement fonctionne avec des interruptions. Dans ce cas, on a juste besoin d'ajouter une entrée sur le processeur, appelée l''''entrée d'interruption''', souvent notée INTR ou INT. L'entrée d'interruption peut fonctionner de deux manières différentes, qui portent le nom d''''entrée déclenchée par niveau logique''' et d''''entrée déclenchée par front montant/descendant'''. Les noms sont barbares mais recouvrent des concepts très simples. Le plus simple est le cas de l'''entrée déclenchée par niveau logique'' : la mise à 1 de cette entrée déclenche une interruption au cycle d'horloge suivant. En réalité, la majorité des processeurs préfèrent mettre l'entrée INT à 0 pour déclencher une interruption, mais nous allons considérer l'inverse dans ce qui suit. Le processeur vérifie au début de chaque cycle d'horloge si cette entrée est mise à 0 ou 1 et agit en conséquence. Dans le cas le plus basique, le processeur reste en état d'interruption tant que l'entrée n'est pas remise à 0, généralement quand le processeur prévient le périphérique que la routine d'interruption est terminée. Cette solution est très simple pour détecter les interruptions, mais pose le problème de la remise à zéro de l'entrée, qui demande de communiquer avec le contrôleur de périphérique. Une autre solution consiste à utiliser des signaux d'interruption très brefs, qui mettent l'entrée à 1 durant un cycle d'horloge, avant de revenir à 0 (ou l'inverse). Le signal d'interruption ne dure alors qu'un cycle d'horloge, mais le processeur le mémorise dans une bascule que nous nommerons INT#BIT dans ce qui suit. La bascule INT#BIT permet de savoir si le processeur est en train de traiter une interruption ou non. Elle est mise à 1 quand on présente un 1 sur l'entrée d'interruption, mais elle est remise à 0 par le processeur, quand celui-ci active l'entrée Reset de la bascule à la fin d'une routine d'interruption. À l'opposé, avec une ''entrée déclenchée par front montant/descendant'', on doit envoyer un front montant ou descendant sur l'entrée pour déclencher une interruption. La remise à zéro de l'entrée est plus simple qu'avec les entrées précédentes. Si l'entrée détecte aussi bien les fronts montants que descendants, il n'y a pas besoin de remettre l'entrée à zéro. Le problème de ces entrées est que le signal d'interruption arrive pendant un cycle d'horloge et que le processeur ne peut pas le détecter facilement. Pour cela, il faut ajouter quelques circuits qui détectent si un front a eu lieu pendant un cycle, et indique le résultat au processeur. Ces circuits traduisent l'entrée par front en entrée par niveau logique, si on peut dire. Il s'agit le plus souvent d'une bascule déclenchée sur front montant/descendant, rien de plus. ===Les deux entrées d'interruption : masquable et non-masquable=== Pour rappel, le masquage d'interruption permet de retarder ou d'ignorer les interruption tant que le masquage est actif. Les interruptions ignorées/retardées sont dites, masquées comme le veut la terminologie. Le masquage des IRQ s'implémente au niveau du processeur, au niveau de l'entrée d'interruption. En cas de masquage des interruptions, il ignore simplement ce qu'il y a sur l'entrée d'interruption. Mais il doit exécuter l'interruption une fois le masquage levé, ce qui demande de mémoriser qu'une interruption a eu lieu. Pour cela, l'entrée d'interruption masquable contient une bascule qui est mise à 1 quand l'entrée d'interruption passe à 1. Elle est remise à 0 quand le processeur a pris en compte l'interruption et l'a exécutée. De plus, en sortie de la bascule, le processeur ajoute une porte ET pour combiner le contenu de la bascule avec un signal généré par le séquenceur qui dit s'il faut ou non masquer l'interruption Les '''interruptions non-masquables''' ne doivent pas être masquées, quelle que soit la situation. Les interruptions non-masquables sont généralement générées en cas de défaillances matérielles graves, qui demandent une intervention immédiate du processeur. Par exemple : une surchauffe du processeur, une défaillance de l'alimentation électrique, une erreur de parité mémoire, etc. Le résultat de telles défaillances est que l'ordinateur est arrêté/redémarré de force, ou alors affiche un écran bleu. Elles peuvent être générées par un contrôleur de périphérique, ou par des circuits placés sur la carte mère comme un watchdog timer, des circuits de détection de défaillances matérielles, des circuits de contrôle de parité mémoire, etc. Si on omet les défaillances matérielles, les interruptions non-masquables servent pour la gestion du watchdog timer, vu il y a quelques chapitres. Pour rappel, le watchdog timer est un mécanisme de sécurité qui redémarre l'ordinateur s'ils suspecte que celui-ci a planté. Le watchdog timer est un compteur/décompteur qui redémarre le système s'il déborde. Mais une interruption non-masquable réinitialise le watchdog timer régulièrement, ce qui signifie que le système n'est pas censé redémarrer. Pour gérer les interruptions non-masquables, beaucoup de processeurs ont deux entrées d'interruption séparées : une pour les interruptions masquables, une autre pour les interruptions non-masquables. C'est le cas des premiers processeurs x86 des PCs, qui disposent d'une entrée INTR pour les interruptions masquables, et une entrée NMI pour les interruptions non-masquables. La différence entre les deux est l'usage ou non de la bascule mentionnée plus haut. L'entrée d'interruption normale dispose de cette bascule pour le masquage, alors que l'entrée d'interruption non-masquable ne l'a pas. ==Le contrôleur d'interruption== Précédemment, nous avons vu le cas où nous n'avons qu'un seul contrôleur de périphérique dans l’ordinateur. Mais avec plusieurs périphériques, l'implémentation des interruptions matérielles est plus compliqué. Dans une implémentation simple des IRQ, chaque contrôleur de périphérique envoie ses interruptions au processeur via une entrée dédiée. Mais cela demande de brocher une entrée d'interruption par périphérique, ce qui limite le nombre de périphériques supportés. Et c'est dans le cas où chaque périphérique n'a qu'une seule interruption, mais un périphérique peut très bien utiliser plusieurs interruptions. Par exemple, un disque dur peut utiliser une interruption pour dire qu'une écriture est terminée, une autre pour dire qu'il est occupé et ne peut pas accepter de nouvelles demandes de lecture/écriture, etc. [[File:Entrées d'interruptions séparées pour chaque périphérique.png|centre|vignette|upright=2|Entrées d'interruptions séparées pour chaque périphérique]] Une autre possibilité est de connecter tous les périphériques à l'entrée d'interruption à travers une porte OU ou un OU câblé, mais elle a quelques problèmes. Déjà, cela suppose que l'entrée d'interruption est une entrée déclenchée par niveau logique. Mais surtout, elle ne permet pas de savoir quel périphérique a causé l'interruption, et le processeur ne sait pas quelle routine exécuter. [[File:Entrée d'interruption partagée.png|centre|vignette|upright=2|Entrée d'interruption partagée]] Pour résoudre ce problème, il est possible de modifier la solution précédente en ajoutant un ''numéro d'interruption'' qui précise quel périphérique a envoyé l'interruption, qui permet de savoir quelle routine exécuter. Au lieu d'avoir une entrée par interruption possible, on code l'interruption par un nombre et on passe donc de <math>N</math> entrées à <math>log_2{(N)}</math> entrées. Le processeur récupère ce numéro d'interruption, qui est généré à l'extérieur du processeur. Pour implémenter cette solution, on a inventé le '''contrôleur d'interruptions'''. C'est un circuit qui récupère toutes les interruptions envoyées par les périphériques et qui en déduit : le signal d'interruption et le numéro de l'interruption. Le numéro d'interruption est souvent mémorisé dans un registre interne au contrôleur d'interruption, souvent mappé en mémoire. Il dispose d'une entrée par interruption/périphérique possible et une sortie de 1 bit qui indique si une interruption a lieu. Il a aussi une sortie pour le numéro de l'interruption, qui permet au processeur de lire le numéro d'interruption. [[File:Contrôleur d'interruptions IRQ.png|centre|vignette|upright=3|Contrôleur d'interruptions IRQ]] L'intérieur d'un contrôleur d'interruption n'est en théorie pas très compliqué. Déterminer le signal d'interruption demande de faire un simple OU entre les entrées d'interruptions. Déduire le numéro de l'interruption demande d'utiliser un simple encodeur, de préférence à priorité. Pour gérer le masquage, il suffit d'ajouter un circuit de masquage en amont de l'encodeur, ce qui demande quelques portes logiques ET/NON. ===La contrôleur d'interruption est une entrée-sortie comme une autre=== Pour récupérer le numéro d'interruption, le processeur doit communiquer avec le contrôleur d'interruption. Pour cela, le contrôleur d'interruption est techniquement traité comme n'importe quel périphérique. Le registre pour le numéro d'interruption est simplement mappé en mémoire RAM, à savoir qu'il est lisible à une adresse bien précise. Et on peut aller plus loin : tous les registres du contrôleur d'interruption sont mappés en mémoire, le contrôleur d'interruption est adressable comme tout périphérique mappé en mémoire. Le numéro d'interruption est alors toujours mémorisé dans un registre interne au contrôleur d'interruption, et le processeur lit ce registre en passant par le bus de données. Dans le cas le plus simple, le numéro d'interruption est envoyé au processeur sur un bus dédié. Le défaut de cette technique est qu'elle demande d'ajouter des broches d'entrée sur le processeur. Une autre solution connecte le contrôleur d'interruption sur le bus système. Les deux solutions sont des détails d'implémentation. Mais dans les deux cas, le contrôleur d'interruption doit être connecté sur le processeur, sur son entrée d'interruption au minimum, via des fils dédiés. Le résultat est un mélange entre bus dédié et connexion au bus système/IO. Nous verrons cela avec l'exemple qui va suivre. Pour rentrer dans le détail, étudions le cas du contrôleur d'interruption 8259 couplé à un processeur Intel 486. L'Intel 8259 était un contrôleur d'interruption utilisé dans les premiers PC. Il était présent sur toutes les cartes mères des PC de l'époque, il a vraiment eu son heure de gloire. Il gérait 8 interruptions, grâce à 8 entrées IRQ et une sortie d'interruption. C'est peu, mais laissons cela de côté pour le moment. Il était connecté sur le bus de données du processeur, qui servait de bus système. En plus des 8 entrées IRQ, et des entrées CAS qu'on laisse de côté pour le moment, il disposait de broches spécialisées pour communiquer avec le processeur. * La sortie INTR permettait d'envoyer une interruption au processeur. * L'entrée INTA (''Interrupt Acwnoledge'') permettait au processeur de dire qu'il avait bien pris en compte l'interruption, ce qui permettait au 8259 de remettre sa sortie INTR à zéro. * Les deux signaux RD et WR permettaient au processeur de lire/écrire les registres du 8259. La broche WR était mise à 0 quand le processeur envoyait une commande au 8259, RD était mis à 0 quand il lisait ses registres. * Un bus de 8 bits permettait de connecter le contrôleur d'interruption au bus de données. Le 8259 était traité comme une entrée-sortie tout ce qu'il y a de plus banale. Il était connecté sur le bus système, au même titre que les entrées-sorties, la mémoire, etc. Il n'y avait pas de bus dédié, au-delà des broches INTR, INTA. Il est donc connecté aux circuits qui administrent le bus système, qui est lui-même relié au processeur. Vu que ses registres étaient mappés en mémoire RAM, il était relié au circuit décodeur d'adresse, que nous verrons en détail dans le prochain chapitre. Pour rappel, ce décodeur d'adresse connecte/déconnecte les entrées-sorties du bus, suivant les adresses envoyées dessus. Si l'adresse envoyée est destinée à la RAM ou une ROM, les entrées-sorties sont déconnectées du bus, en mettant à zéro leur signal CS (''Chip Select''). Si l'adresse est destinée à une entrée-sortie, elle est connectée sur le bus, en mettant son signal CS à 1. Le 8258 avait une entrée CS connectée directement à ce décodeur d'adresse, comme toutes les autres entrées-sorties. Il avait aussi une entrée d'adresse de un bit, qui permettait d'adresser les registres internes au 8259. Notons que le bus dédié était un bus de 8 bits, alors que les processeurs Intel de l'époque étaient des processeurs 16 ou 32 bits. Leur bus de données faisait donc 16 ou 32 bits, ce qui ne collait pas avec les 8 bits du 8259. Il y avait donc un circuit pour faire l'interface entre les deux, qui est représenté ci-dessous avec deux circuits : une mémoire tampon de 32 bits et un circuit de commande de ''byte swap''. [[File:Intel486 with 82C59A.png|centre|vignette|upright=2|Intel486 with 82C59A]] ===Les contrôleurs d'interruption en cascade=== Un contrôleur d'interruption permet de gérer un nombre limité d'interruptions. Par exemple, l'ancien Intel 8259 gérait 8 interruptions avec 8 entrées IRQ et une sortie d'interruption. Même pour l'époque, ce n'était pas assez. Un PC contenait plus de 8 périphériques, en tenant compte de la ''real time clock'' et d'autres composants intégrés sur la carte mère. Aussi, il fallait trouver une solution pour gérer plus de 8 interruptions. Et la solution a été d'utiliser plusieurs contrôleurs d'interruption en cascade, à savoir que le second contrôleur d'interruption émettait une interruption vers le premier. C'était le cas avec les premiers processeurs d'Intel dès le 8086, notamment le 486, utilisaient deux contrôleurs Intel 8259 : un contrôleur maitre, un contrôleur esclave. Le contrôleur esclave gérait les interruptions liées au bus ISA, le bus pour les cartes d'extension utilisé à l'époque, alors que le contrôleur maitre gérait le reste. Leur sortie d'interruption était soit connectée au processeur (pour le contrôleur maître), soit au contrôleur maître (pour le contrôleur esclave). Le tout permettait de gérer 15 interruptions : 8 interruptions pour le contrôleur esclave, 7 pour le contrôleur maitre (une des entrées était prise par le contrôleur esclave). Le fonctionnement était le suivant. Si une interruption avait lieu en dehors du bus ISA, le contrôleur maitre gérait l'interruption. Mais si une interruption avait lieu sur le bus ISA, le contrôleur esclave recevait l'interruption, générait un signal transmis au contrôleur maitre sur l'entrée IRQ 2, qui lui-même transmettait le tout au processeur. Le processeur accédait alors au bus qui le reliait aux deux contrôleurs d'interruption, et lisait le registre pour récupérer le numéro de l'interruption. [[File:Intel486 and cascading PIC.png|centre|vignette|upright=2|Contrôleurs d'interruptions IRQ du 486 d'Intel.]] [[File:Intel 8259.svg|vignette|Intel 8259]] En théorie, jusqu'à 8 contrôleurs 8259 peuvent être mis en cascade, ce qui permet de gérer 64 interruptions. Il faut alors disposer de 8 contrôleurs esclaves et d'un contrôleur maitre. La mise en cascade est assez simple sur le principe : il faut juste envoyer la sortie INTR de l'esclave sur une entrée d'IRQ du contrôleur maitre. Il faut cependant que le processeur sache dans quel 8259 récupérer le numéro de l'interruption. Pour cela, l'Intel 8259 disposait de trois entrées/sorties pour permettre la mise en cascade, nommées CAS0, CAS1 et CAS2. Sur ces entrées/sorties, on trouve un identifiant allant de 0 à 7, qui indique quel contrôleur 8259 est le bon. On peut le voir comme si chaque 8259 était identifié par une adresse codée sur 3 bits, ce qui permet d'adresser 8 contrôleurs 8259. Les 8 valeurs permettent d'adresser aussi bien le maitre que l'esclave, sauf dans une configuration : celles où on a 1 maitre et 8 esclaves. Dans ce cas, on considère que le maitre n'est pas adressable et que seuls les esclaves le sont. Cette limitation explique pourquoi on ne peut pas dépasser les 8 contrôleurs en cascade. Les entrées/sorties CAS de tous les contrôleurs 8259 sont connectées entre elles via un bus, le contrôleur maitre étant l'émetteur, les autres 8259 étant des récepteurs. Le contrôleur maitre émet l'identifiant du contrôleur esclave dans lequel récupérer le numéro de l'interruption sur ce bus. Les contrôleurs esclaves réagissent en se connectant ou se déconnectant du bus utilisé pour transmettre le numéro d'interruption. Le contrôleur adéquat, adressé par le maitre, se connecte au bus alors que les autres se déconnectent. Le processeur est donc certain de récupérer le bon numéro d'interruption. Lorsque c'est le maitre qui dispose du bon numéro d'interruption, il se connecte au bus, mais envoie son numéro aux 8259 esclaves pour qu'ils se déconnectent. Le fait de mettre en cascade deux 8259 a fonctionné pour les PC avec un bus ISA. Mais avec l'introduction du bus PCI, les choses sont devenues bien plus complexes. Le nombre de périphériques, et donc d'interruptions, a grimpé en flèche. Le même problème de manque d'interruption s'est fait sentir, et la solution a été similaire : rajouter un contrôleur d'interruption en cascade. Sauf que pour le bus PCI, on n'a pas rajouté un 8259, mais un autre contrôleur d'interruption dédié au bus PCI, appelé le PIR (''Programmable Interrupt Request''). Ce contrôleur d'interruption émettait 4 fils vers le 8259 esclave, connectés aux entrées d'IRQ numéro 5, 9, 10, et 11. ===Les interruptions sur les systèmes multicœurs et multi-processeurs=== L'arrivée des systèmes avec plusieurs processeurs, plusieurs cœurs, a demandé d'adapter les contrôleurs d'interruption. Et en plus d'adapter le tout aux systèmes multicœurs, il a été décidé de simplifier les contrôleurs d'interruption, en réduisant leur mise en cascade. Pour cela, les deux 8259 ont été remplacés par un unique contrôleur d'interruption, appelé l''''APIC''' (''Advanced PIC''). Je dis un contrôleur d'interruption, mais l'APIC est en réalité un standard qui peut être respecté par des contrôleurs d'interruption très différents. Si les plus simples se contentent de remplacer deux 8259 en cascade, d'autres vont plus loin et intègrent carrément un PIC pour le bus PCI ! Les tout premiers APIC avaient 16 entrées d'interruption et se contentaient de remplacer à la lettre les deux 8259. Le PIC pour le bus PCI était toujours présent. Par la suite, d'autres APIC sont apparus, avec 24 ou 32 entrées d'interruption. Typiquement, les 16 premières entrées d'interruptions étaient réservées pour remplacer/émuler les deux 8259, le reste était utilisable avec plus ou moins de flexibilité. Par exemple, un APIC avec 32 entrées d'interruption peut utiliser 16 entrées rien que pour le bus PCI, ce qui permet parfois de se passer de PIC. Un exemple d'APIC était le 82093AA. Il disposait de 24 entrées d'IRQ : 13 pour le bus ISA, 4 pour le bus PCI, une entrée d'interruption pour mettre des contrôleurs d'interruption en cascade, le reste était plus ou moins spécialisé. Le standard impose la présence de plusieurs contrôleurs d'interruptions. Chaque cœur/processeur a son propre contrôleur d'interruption, avec un contrôleur général. Le contrôleur d'interruption général est nommé ''IO-APIC'', les autres sont les APIC locaux (''local-APIC''). L'''IO-APIC'' reçoit les interruptions provenant des périphériques, et les redirige vers les processeurs/cœurs adéquats. Pour cela, sont tous reliés soit par un bus APIC dédié, soit par le bus système ou un bus existant. [[File:Contrôleurs d'interrptions sur systèmes x86 multicoeurs.png|centre|vignette|upright=1.5|Contrôleurs d'interruptions sur systèmes x86 multicœurs.]] Le premier APIC était le 82489DX. Il était prévu pour les systèmes avec deux cœurs, deux processeurs. Il regroupait l'IO-APCI et deux APIC locaux en un seul circuit. Mais cette solution a ensuite été modifiée, les APIC locaux ont été intégrés aux processeurs/cœurs eux-mêmes, ne laissant que l'IO-APCI sur la carte mère, dans son ''chipset''. Le standard APIC a évolué pour donner l'xAPIC et le x2APIC. La différences principales sont que le nombre de processeurs/cœurs supporté est passé respectivement à 256, puis à 2^32. ===Les ''Message Signaled Interrupts''=== Les interruptions précédentes demandent d'ajouter un fil par périphérique, pour que celui-ci signale une interruption au contrôleur d'interruption. Prenons l'exemple d'une carte mère qui dispose de 5 ports ISA, ce qui permet de connecter 5 périphériques ISA sur la carte mère. Chaque port ISA a un fil d'interruption pour signaler que le périphérique veut déclencher une interruption, ce qui demande de relier 5 fils au contrôleur de périphérique. Cela n'a pas l'air grand-chose, mais rappelons qu'une carte mère moderne gère facilement une dizaine de bus différents, donc certains pouvant connecter une demi-dizaine de composants. Le nombre de fils à câbler est alors important. Il existe cependant un type d'interruption qui permet de se passer de ces fils d'interruptions : les '''interruptions signalées par message''', ''Message Signaled Interrupts'' (MSI) en anglais. Elles sont utilisées sur le bus PCI et son successeur, le PCI-Express, deux bus très importants et utilisés pour les cartes d'extension dans presque tous les ordinateurs modernes et anciens. Aussi, nous allons prendre l'exemple du bus PCI Express dans ce qui suit, même si les explications fonctionnent pour d'autres bus. Les interruptions signalées par message sont déclenchées quand le périphérique écrit dans une adresse allouée spécifiquement pour, appelée une ''adresse réservée''. Le contrôleur d'interruption surveille en permanence ce qui est transmis sur le bus PCI Express. Dès qu'il voit passer une écriture à cette adresse réservée, il déclenche une interruption sur le processeur. Le reste du traitement de l'interruption est le même qu'avec les IRQ. Le processeur ayant reçu l'interruption communique alors avec le contrôleur d'interruption pour savoir quel périphérique a déclenché une interruption, récupérer le numéro de l'interruption, etc. : Le processeur a toujours une entrée d'interruption unique reliée, reliée au contrôleur d'interruption, ce sont les fils entre périphérique et contrôleur d'interruption qui disparaissent. Une implémentation possible place les adresses réservées dans le contrôleur d'interruption. Les adresses réservées correspondent à des registres du contrôleur d'interruption, qui sont mappés en mémoire. Toute écriture dans cette adresse réservée modifiera donc un registre du contrôleur d'interruption. La détection de l'écriture dans une adresse réservée est donc particulièrement simple. Il y a une adresse réservée par interruption, ce qui fait que le contrôleur d'interruption détermine le numéro de l'interruption à partir de l'adresse réservée utilisée/écrite. Le périphérique écrit un message dans l'adresse réservée, qui donne des informations sur l'interruption : le numéro d'interruption au minimum, souvent d'autres informations. Un avantage est que cela permet de gérer un grand nombre d'interruptions assez facilement. On n'est plus limité par un nombre de fils, la limite tient dans le nombre d'adresses réservées. Et les controleurs d'interruptions peuvent facilement gérer un grand nombre d'adresse, tant qu'ils ont le hardware pour. L'ancien PCI 2.2 gère jusqu'à 32 adresses réservées, le bus PCI 3.0 MSI-X alloue 2048 adresses réservées. En comparaison le bus PCI gérait maximum 4 IRQ câblées, du fait des limitations en termes de fils (deux fils maximum). Un avantage lié au précédent est que cela simplifie la gestion des interruptions pour le processeur. Les 4 interruptions du bus PCI étaient partagées entre périphériques PCI, ce qui fait que le processeur avait du mal à déterminer quel périphérique avait lancé une interruption. Quand deux périphériques PCI partageaient une interruption, le processeur avait du mal à savoir lequel des deux lançait l'interruption. Mais avec les MSI, on a assez d'interruption pour ne pas avoir à les partager entre périphériques. Chaque périphérique a ses interruptions, ses adresses réservées, il ne marche pas sur les pieds des autres périphériques. Un autre avantage est que les interruptions passent par le bus PCI Express. La conséquence est que sur les systèmes multicoeurs/multi-processeurs, il n'y a pas besoin d'utiliser de contrôleur d'interruption général, d'IO-APIC. La présence d'APIC locaux est toujours nécessaire, mais l'IO-APIC n'est gardé que par souci de compatibilité avec les vieilles interruptions PCI. ===Les généralités sur les interruptions matérielles=== Peu importe que les interruptions soient implémentées avec une entrée d'interruption ou avec une signalisation par messages, certaines fonctionnalités sont souvent implémentées par le contrôleur d'interruption. Par exemple, il est possible de prioriser certaines interruptions sur les autres, ce qui permet de gérer le cas où plusieurs interruptions ont lieu en même temps. De même, il est possible de mettre en attente certaines interruptions, voire de les désactiver. En premier lieu, voyons pourquoi il est pertinent de prioriser certaines interruptions plutôt que d'autres. Quand plusieurs interruptions se déclenchent en même temps, on ne peut en exécuter qu'une seule. Pour gérer des interruptions simultanées, un '''système de priorité d'interruption''' est mis en place, où certaines interruptions sont prioritaires sur les autres. Par exemple, l'interruption qui gère l'horloge système est prioritaire sur les interruptions en provenance de périphériques lents comme le disque dur ou une clé USB. Quand plusieurs interruptions souhaitent s'exécuter en même temps, on exécute d'abord celle qui est la plus prioritaire, les autres sont alors mises en attente. La gestion des priorités est gérée par le contrôleur d'interruption, ou par le processeur si le contrôleur d'interruption est absent. Pour gérer les priorités, l'encodeur présent dans le contrôleur de périphérique doit être un encodeur à priorité, et cela suffit. On peut configurer les priorités de chaque interruption, à condition que l'encodeur à priorité soit configurable et permette de configurer les priorités de chaque entrée. Ensuite, il faut aussi parler du '''masquage des interruptions''', qui pour rappel, permet de désactiver temporairement les interruptions. Si le masquage est temporairement activé, les interruptions sont soit ignorées, soit mise en attente et exécutées une fois le masquage levé. Le masquage peut être total, dans le sens où toutes les interruptions masquables sont masquées en bloc, ce qui revient à désactiver les interruptions (sauf les non-masquables). On parle alors de masquage global. Mais il est aussi possible de ne masquer qu'une partie des interruptions masquables. Par exemple, on peut ignorer l'interruption numéro 5 provenant du disque dur, mais pas l'interruption numéro 0 du ''watchdog timer'', alors que les deux sont masquables. On parle alors de '''masquage d'interruption sélectif'''. L'avantage est que cela permet de simplifier l'implémentation des interruptions masquables et non-masquables. Le contrôleur d'interruption gère de lui-même le masquage des interruptions. Dans le cas le plus simple, où on masque toutes les interruptions en bloc, il suffit d'ajouter un '''bit MASK''' dans le contrôleur d'interruption, qui dit si les interruptions sont masquées ou non. La valeur du bit est combinée avec les signaux d'interruptions pour calculer le signal à envoyer sur l'entrée d'interruption finale. Notons que le contrôleur n'utilise ce bit que pour les interruptions non-masquables. Il dispose de deux sorties : une pour l'entrée d'interruption normale, l'autre pour l'entrée d'interruption masquable. Le bit MASK n'agit que sur la sortie pour les interruptions masquables, pas l'autre. [[File:Masquage global des interruptions dans le controleur d'interruption.png|centre|vignette|upright=2|Masquage global des interruptions dans le controleur d'interruption]] Masquer les interruptions individuellement demande quelques adaptations. Mais l'idée est globalement d'avoir un bit MASK pour chaque interruption, de le dupliquer en autant d'interruption masquables existantes, et de les regrouper dans un registre. Pour cela, le contrôleur d'interruption (ou le processeur) disposent d'un registre appelé le '''registre de masque d'interruption'''. Chaque bit de ce registre est associé à une interruption : le bit numéro 0 est associé à l'interruption numéro 0, le bit 1 à l'interruption 1, etc. Si le bit est mis à 1, l'interruption correspondante est ignorée. Inversement, si le bit est mis à 0 : l'interruption n'est pas masquée. ==Le ''Direct memory access''== Avec les interruptions, seul le processeur gère l'adressage de la mémoire. Impossible à un périphérique d'adresser la mémoire RAM ou un autre périphérique, il doit forcément passer par l'intermédiaire du processeur. Pour éviter cela, on a inventé le ''bus mastering'', qui permet à un périphérique de lire/écrire sur le bus système. C'est suffisant pour leur permettre d'adresser la mémoire directement ou de communiquer avec d’autres périphériques directement, sans passer par le processeur. Le '''Direct Memory Access''', ou DMA, est une technologie de ''bus mastering'' qui permet de copier un bloc de mémoire d'une source vers la destination. La source et la destination peuvent être la mémoire ou un périphérique, ce qui permet des transferts mémoire -> mémoire (des copies de données, donc), mémoire -> périphérique, périphérique -> mémoire, périphérique -> périphérique. Le bloc de mémoire commence à une adresse appelée adresse de départ, et a une certaine longueur. Soit il est copié dans un autre bloc de mémoire qui commence à une adresse de destination bien précise, soit il est envoyé sur le bus à destination du périphérique. Ce dernier le reçoit bloc pièce par pièce, mot mémoire par mot mémoire. Sans DMA, le processeur doit copier le bloc de mémoire mot mémoire par mot mémoire, byte par byte. Avec DMA, la copie se fait encore mot mémoire par mémoire, mais le processeur n'est pas impliqué. ===Le contrôleur DMA=== Avec le DMA, l'échange de données entre le périphérique et la mémoire est intégralement géré par un circuit spécial : le '''contrôleur DMA'''. Il est généralement intégré au contrôleur de périphérique et placé sur la carte mère, parfois intégré au périphérique, parfois intégré au processeur, mais est toujours connecté au bus mémoire. Le contrôleur DMA contient des registres d'interfaçage dans lesquels le processeur écrit pour initialiser un transfert de données. Un transfert DMA s'effectue sans intervention du processeur, sauf au tout début pour initialiser le transfert, et à la fin du transfert. Le processeur se contente de configurer le contrôleur DMA, qui effectue le transfert tout seul. Une fois le transfert terminé, le processeur est prévenu par le contrôleur DMA, qui déclenche une interruption spécifique quand le transfert est fini. Le contrôleur DMA contient généralement plusieurs compteurs : un pour l'adresse de la source, un pour l'adresse de destination, un autre pour le nombre de bytes restants à copier. Les deux compteurs d'adresse sont initialisé avec l'adresse source/destination de départ, à savoir l'adresse du bloc à copier et celle du bloc de destination. Elle est incrémenté ou décrémentée à chaque envoi de données sur le bus. Le compteur de ''byte'' restant est décrémenté à chaque copie d'un mot mémoire sur le bus. Le compteur de bytes restant est purement interne au contrôleur mémoire, alors que le contenu des compteurs d'adresse est envoyé sur le bus d'adresse à chaque copie de mot mémoire. [[File:DMA controller transfer count register.jpg|centre|vignette|upright=2.5|DMA controller transfer count register]] [[File:DMA controller memory address registers.jpg|centre|vignette|upright=2.5|DMA controller memory address registers]] Outre les compteurs, le contrôleur DMA contient aussi des registres de contrôle. Ils mémorisent des informations très variées : avec quel périphérique doit-on échanger des données, les données sont-elles copiées du périphérique vers la RAM ou l'inverse, et bien d’autres choses encore. [[File:Controleur DMA.png|centre|vignette|upright=2.5|Controleur DMA]] Le contrôleur DMA contient aussi des registres internes pour effectuer la copie de la source vers la destination. La copie se fait en effet en deux temps : d'abord on lit le mot mémoire à copier dans l'adresse source, puis on l'écrit dans l'adresse de destination. Entre les deux, le mot mémoire est mémorisé dans un registre temporaire, de même taille que la taille du bus. Mais sur certains bus, il existe un autre mode de transfert, où le contrôleur DMA ne sert pas d'intermédiaire. Le périphérique et la mémoire sont tous deux connectés directement au bus mémoire, la copie est directe, le contrôleur DMA s'occupe juste du bus d'adresse et n'accède pas au bus de données lors des transferts. Les deux modes sont différents, le premier étant plus lent mais beaucoup plus simple à mettre en place. Il est aussi très facile à mettre en place quand le périphérique et la mémoire n'ont pas la même vitesse. ===Les modes de transfert DMA=== Il existe trois façons de transférer des données entre le périphérique et la mémoire : le mode ''block'', le mode ''cycle stealing'', et le mode transparent. Dans le '''mode block''', le contrôleur mémoire se réserve le bus mémoire, et effectue le transfert en une seule fois, sans interruption. Cela a un désavantage : le processeur ne peut pas accéder à la mémoire durant toute la durée du transfert entre le périphérique et la mémoire. Alors certes, ça va plus vite que si on devait utiliser le processeur comme intermédiaire, mais bloquer ainsi le processeur durant le transfert peut diminuer les performances. Dans ce mode, la durée du transfert est la plus faible possible. Il est très utilisé pour charger un programme du disque dur dans la mémoire, par exemple. Eh oui, quand vous démarrez un programme, c'est souvent un contrôleur DMA qui s'en charge ! Dans le '''mode cycle stealing''', on est un peu moins strict : cette fois-ci, le contrôleur ne bloque pas le processeur durant toute la durée du transfert. En cycle stealing, le contrôleur va simplement transférer un mot mémoire (un octet) à la fois, avant de rendre la main au processeur. Puis, le contrôleur récupérera l'accès au bus après un certain temps. En gros, le contrôleur transfère un mot mémoire, fait une pause d'une durée fixe, puis recommence, et ainsi de suite jusqu'à la fin du transfert. Et enfin, on trouve le '''mode transparent''', dans lequel le contrôleur DMA accède au bus mémoire uniquement quand le processeur ne l'utilise pas. ===Les limitations en termes d’adressage=== Les contrôleurs DMA sont parfois limités sur quelques points, ce qui a des répercussions dont il faut parler. Les limitations que nous voir ne sont pas systématiques, mais elles sont fréquentes, aussi il vaut mieux les connaitres. La première limitation est que le contrôleur DMA n'a pas accès à tout l'espace d'adressage du processeur. Par exemple, le contrôleur DMA du bus ISA, que nous étudierons plus bas, avait accès à seulement aux 16 mébioctets au bas de l'espace d'adressage, à savoir les premiers 16 mébioctets qui commencent à l'adresse zéro. Le processeur pouvait adresser bien plus de mémoire. La chose était courante sur les systèmes 32 bits, où les contrôleurs DMA géraient des adresses de 20, 24 bits. Le problème est systématique sur les processeurs 64 bits, où les contrôleurs DMA ne gèrent pas des adresses de 64 bits, mais de bien moins. Typiquement, le contrôleur DMA ne gère que les adresses basses de l'espace d'adressage. Le pilote de périphérique connait les limitations du contrôleur DMA, et prépare les blocs aux bons endroits, dans une section adressable par le contrôleur DMA. Le problème est que les applications qui veulent communiquer avec des périphériques préparent le bloc de données à transférer à des adresses hautes, inaccessibles par le contrôleur DMA. La solution la plus simple consiste à réserver une zone de mémoire juste pour les transferts DMA avec un périphérique, dans les adresses basses accessibles au contrôleur DMA. La zone de mémoire est appelée un '''tampon DMA''' ou encore un ''bounce buffer''. Si une application veut faire un transfert DMA, elle copie les données à transmettre dans le tampon DMA et démarre le transfert DMA par l'intermédiaire du pilote de périphérique. Le transfert en sens inverse se fait de la même manière. Le périphérique copie les données transmises dans le tampon DMA, et son contenu est ensuite copié dans la mémoire de l'application demandeuse. Il peut y avoir des copies supplémentaires vu que le tampon DMA est souvent géré par le pilote de périphérique en espace noyau, alors que l'application est en espace utilisateur. Diverses optimisations visent à réduire le nombre de copies nécessaires, elles sont beaucoup utilisées par le code réseau des systèmes d'exploitation. ===Les limitations en termes d’alignement=== La seconde limitation des contrôleurs DMA est qu'ils ne gèrent que des transferts alignés, c'est-à-dire que les adresses de départ/fin du transfert doivent être des multiples de 4, 8, 16, etc. La raison est que le contrôleur DMA effectue les transferts par blocs de 4, 8, 16 octets. En théorie, les blocs pourraient être placés n'importe où en mémoire, mais il est préférable qu'ils soient alignés pour simplifier le travail du contrôleur DMA et économiser quelques circuits. Les registres qui mémorisent les adresses sont raccourcis, on utilise moins de fils pour le bus mémoire ou la sortie du contrôleur DMA, etc. À noter que cet alignement est l'équivalent pour le contrôleur DMA de l'alignement mémoire du processeur. Mais les deux sont différents : il est possible d'avoir un processeur avec un alignement mémoire de 4 octets, couplé à un contrôleur DMA qui gère des blocs alignés sur 16 octets. Un défaut de cet alignement est que les blocs à transférer via DMA doivent être placés à des adresses bien précises, ce qui n'est pas garanti. Si le pilote de périphérique est responsable des transferts DMA, alors rien de plus simple : il dispose de mécanismes logiciels pour allouer des blocs alignés correctement. Mais si le bloc est fourni par un logiciel (par exemple, un jeu vidéo qui veut copier une texture en mémoire vidéo), les choses sont tout autres. La solution la plus simple est de faire une copie du bloc incorrectement aligné vers un nouveau bloc correctement aligné. Mais les performances sont alors très faibles, surtout pour de grosses données. Une autre solution est de transférer les morceaux non-alignés sans DMA? et de copier le reste avec le DMA. ===Le 8237 et son usage dans les PC=== Le 8237 d'Intel était un contrôleur DMA (''Direct Memory Access'') présents dans les premiers PC. Il a d'abord été utilisé avec les processeurs 8086, puis avec le 8088. Le processeur 8086 était un processeur 16 bits, c'est-à-dire manipulait les données sur 16 bits, mais utilisait des adresses sur 20 bits (adressage segmenté, en utilisant deux registres 16 bits, dont un registre de segment décalé de 4 bits). Le processeur 8088 était identique au 8086 mais utilisait un bus de données 8 bits (les instructions accédant la mémoire ou l'interface d'entrée-sortie sur 16 bits prenant des cycles supplémentaires car exécutées en deux temps : octet de poids faible, puis octet de poids fort), il avait lui aussi un bus d'adresse sur 20 bits, ce qui était incompatible avec les capacités 16 bits du 8237. Cependant, cela n'a pas empêché d'utiliser le 8237 sur les premiers PC, mais avec quelques ruses. ====L'usage du 8237 sur les premiers IBM PC==== Pour utiliser un 8237 avec un adressage de 16 bits sur un bus d'adresse de 20 bits, il faut fournir les 4 bits manquants. Ils sont mémorisés dans un registre de 4 bits, placés dans un circuit 74LS670. Ce registre est configuré par le processeur, mais il n'est pas accessible au contrôleur DMA. Les architectures avec un bus de 24 bits utilisaient la même solution, sauf que le registre de 4 bits est devenu un registre de 8 bits. Cette solution ressemble pas mal à l'utilisation de la commutation de banque (''bank switching''), mais ce n'en est pas vu que le processeur n'est pas concerné et que seul le contrôleur DMA l'est. Le contrôleur DMA ne gère pas des banques proprement dit, car il n'est pas un processeur, mais quelque chose d'équivalent que nous appellerons "pseudo-banque" dans ce qui suit. Les registres de 4/8 bits ajoutés pour choisir la pseudo-banque sont appelés des registres de pseudo-banque DMA ou page DMA. Un défaut de cette solution est que le contrôleur DMA gère 16 pages de 64 Kibioctets, au lieu d'une seul de 1 Mébioctets. S'il démarre une copie, celle-ci ne peut pas passer d'une page à une autre. Par exemple, si on lui demande de copier 12 Kibioctets, tout se passe bien si les 12 Kb sont tout entier dans une page. Mais si jamais les 3 premiers Kb sont dans une page, et le reste dans la suivante, alors le contrôleur DMA ne pourra pas faire la copie. Dans un cas pareil, le compteur d'adresse est remis à 0 une fois qu'il atteint la fin de la page, et la copie reprend au tout début de la page de départ. Ce défaut est resté sur les générations de processeurs suivantes, avant que les faibles performances du 8237 ne forcent à mettre celui-ci au rebut. Le 8237 peut gérer 4 transferts DMA simultanés, 4 copies en même temps. On dit aussi qu'il dispose de 4 '''canaux DMA''', qui sont numérotés de 0 à 3. La présence de plusieurs canaux DMA fait que les registres de pseudo-banque de 4/8 bits doivent être dupliqués : il doit y en avoir un par canal DMA. Le 8237 avait quelques restrictions sur ces canaux. Notamment, seuls les canaux 0 et 1 pouvaient faire des transferts mémoire-mémoire, les autres ne pouvant faire que des transferts mémoire-périphérique. Le canal 0 était utilisé pour le rafraichissement mémoire, plus que pour des transferts de données. ====Les deux 8237 des bus ISA==== Le bus ISA utilise des 8237 pour gérer les transferts DMA. Les registres de pseudo-banques sont élargis pour adresser 16 mébioctets (page sur 8 bits), mais la limitation des pseudo-banques de 64 Kibioctets est encore présente. Les PC avec un bus ISA géraient 7 canaux DMA, qui étaient gérés par deux contrôleurs 8237 mis en cascade. La mise en cascade des deux 8237 se fait en réservant le canal 0 du 8237 maître pour gérer le 8237 esclave. Le canal 0 du maitre n'est plus systématiquement utilisé pour le rafraichissement mémoire, cela libère la possibilité de faire des transferts mémoire-mémoire. Voici l'utilisation normale des 7 canaux DMA : Premier 8237 (esclave) : * Rafraichissement mémoire ; * Hardware utilisateur, typiquement une carte son ; * Lecteur de disquette ; * Disque dur, port parallèle, autres ; Second 8237 (maitre) : * Utilisé pour mise en cascade ; * Disque dur (PS/2), hardware utilisateur ; * Hardware utilisateur ; * Hardware utilisateur. Le bus ISA est un bus de données de 16 bits, alors que le 8237 est un composant 8 bits, ce qui est censé poser un problème. Mais le bus ISA utilisait des transferts spéciaux, où le contrôleur DMA n'était pas utilisé comme intermédiaire. En temps normal, le contrôleur DMA lit le mot mémoire à copier et le mémorise dans un registre interne, puis adresse l'adresse de destination et connecte ce registre au bus de données. Mais avec le bus ISA, le périphérique est connecté directement au bus mémoire et ne passe pas par le contrôleur DMA : la copie est directe, le contrôleur DMA s'occupe juste du bus d'adresse et n'accède pas au bus de données lors des transferts. ===L'usage du DMA pour les transferts mémoire-mémoire=== Le DMA peut être utilisé pour faire des copies dans la mémoire, avec des transferts mémoire-mémoire. Les performances sont correctes tant que le contrôleur DMA est assez rapide, sans compter que cela libère le processeur du transfert. Le processeur peut faire autre chose en attendant, tant qu'il n'accède pas à la mémoire, par exemple en manipulant des données dans ses registres ou dans son cache. La seule limitation est que les blocs à copier ne doivent pas se recouvrir, sans quoi il peut y avoir des problèmes, mais ce n'est pas forcément du ressort du contrôleur DMA. Un exemple de ce type est celui du processeur CELL de la Playstation 2. Le processeur était en réalité un processeur multicœur, qui regroupait plusieurs processeurs sur une même puce. Il regroupait un processeur principal et plusieurs coprocesseurs auxiliaires, le premier étant appelé PPE et les autres SPE. Le processeur principal était un processeur PowerPC, les autres étaient des processeurs en virgule flottante au jeu d'instruction beaucoup plus limité (ils ne géraient que des calculs en virgule flottante). Le processeur principal avait accès à la mémoire RAM, mais les autres non. Les processeurs auxiliaires disposaient chacun d'un ''local store'' dédié, qui est une petite mémoire RAM qui remplace la mémoire cache, et ils ne pouvaient accéder qu'à ce ''local store'', rien d'autre. Les transferts entre PPE et SPE se faisaient via des transferts DMA, qui faisaient des copies de la mémoire RAM principale vers les ''local stores''. Chaque SPE incorporait son propre contrôleur DMA. [[File:Schema Cell.png|centre|vignette|upright=2|Schema du processeur Cell]] ===DMA et cohérence des caches CPU=== Les transferts DMA se font entre la RAM et un périphérique, et les deux sens de transfert sont possibles. Certains périphériques se contentent d'un seul sens de transfert. Par exemple, pour une carte son, les transferts se font de la RAM vers la carte son, l'autre sens se résume à des interruptions. Le transfert DMA envoie les données sonores vers la carte son qui se charge d'envoyer le tout sur les hauts-parleurs ou un casque. Mais d'autres périphériques font des transferts dans l'autre sens, du périphérique vers la RAM. Pensez à une carte réseau : les téléchargements impliquent que les données reçues par la carte réseau sont copiées en RAM, alors que c'est l'inverse pour l'''upload''. Et les transferts DMA dans le sens périphériques -> RAM posent un problème sur les architectures avec une mémoire cache. De tels transferts peuvent modifier des adresses qui sont mises en cache. Or, les changements dans la RAM ne sont pas automatiquement propagés au cache. Le cache contient alors une copie de la donnée obsolète, qui ne correspond plus au contenu écrit dans la RAM par le contrôleur DMA. [[File:Cache incoherence write.svg|centre|vignette|upright=2|Cohérence des caches avec DMA.]] Pour résoudre ce problème, la solution la plus simple interdit de charger dans le cache des données stockées dans les zones de la mémoire attribuées aux transferts DMA, qui peuvent être modifiées par des périphériques ou des contrôleurs DMA. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires caches. L'interdiction est assez facile à mettre en place, vu que le processeur est en charge de la mise en place du DMA. Mais sur les systèmes multicœurs ou multi-processeurs, les choses sont plus compliquées, comme on le verra dans quelques chapitres. Une autre solution est d'invalider le contenu des caches lors d'un transfert DMA. Par invalider, on veut dire que les caches sont remis à zéro, leurs données sont effacées. Cela force le processeur à aller récupérer les données valides en mémoire RAM. L'invalidation des caches est cependant assez couteuse, comme nous le verrons dans le chapitre sur les caches. Elle est plus performante si les accès à la zone de mémoire sont rares, ce qui permet de profiter du cache 90 à 99% du temps, en perdant en performance dans les accès restants. Différentes technologies matérielles ont été inventées pour éliminer le problème à la source. L'une d'entre elles était la technologie ''Data Direct I/O'' d'Intel. Elle permettait à un périphérique d'écrire directement dans le cache du processeur, sans passer par la mémoire. Si elle résout le problème de la cohérence des caches, son but premier était d'améliorer les performances des communications réseaux, lorsqu'une carte réseau veut écrire quelque chose en mémoire. Au lieu d'écrire le message réseau en mémoire, avant de le charger dans le cache, l'idée était de passer outre la RAM et d'écrire directement dans le cache. Cette technologie était activée par défaut sur les plateformes Intel Xeon processor E5 et Intel Xeon processor E7 v2. ===La cohérence des caches périphériques=== Un problème similaire au précédent se manifeste sur les périphériques qui incorporent une mémoire cache. Nous parlons ici de périphériques qui n'ont pas de mémoire RAM interne, et dont le cache contient des copies de données présentes en RAM système, en RAM principale. Nous appellerons de tels caches des '''caches périphériques''', sous entendu intégrés à un périphérique. Il est important de préciser que de tels caches peuvent maintenir des copies de données en RAM système. Les caches ne pouvant pas contenir de données provenant de la RAM système ne sont pas concernés par ce qui suit, ce sont des caches purement internes au périphérique. [[File:Cache périphérique.png|centre|vignette|upright=2|Cache périphérique]] Certaines cartes réseaux incorporent de tels caches, pour améliorer les performances lors des transferts réseaux. Le cache périphérique permet alors soit précharger les données à uploader depuis la RAM, soit en accumulant les données téléchargées avant de tout envoyer d'un seul bloc dans la RAM. Mais précisons que seules les cartes réseaux de haute performance, destinées aux serveurs et ''mainframes'', intègrent des caches périphériques. Les cartes réseaux des PC n'ont pas de cache périphériques de ce type, elles se contentent de mémoire tampon de type FIFO qui ne sont pas des caches. Avec de tels périphériques, il arrive que le processeur modifie des données en RAM, alors qu'une copie est dans le cache périphérique. Il faut alors que l'écriture en RAM soit propagée dans le cache périphérique. Et cela demande là encore d'utiliser des mécanismes de cohérence des caches. La solution la plus simple est d'invalider le cache périphérique avant un transfert DMA, ou avant toute écriture en mémoire RAM qui peut poser problème. Typiquement, c'est le pilote du périphérique qui s'en occupe. Il réserve quelques portions de RAM pour communiquer avec le périphérique, et toute écriture dedans entrainera une invalidation. Le périphérique doit gérer nativement des commandes d'invalidation, qui ordonnent au périphérique d’invalider son cache périphérique. Le pilote du périphérique envoie ces commandes quand la situation l'exige. La technologie CXL (''Compute Express Link'') permet d'aller plus loin, en ajoutant un protocole de cohérence des caches au PCI Express. Le protocole CXL est une surcouche au PCI Express, qui est gérée par le processeur. Plus précisément, CXL réutilise la couche physique du protocole, mais change les couches transport et liaison. Concrètement, cela signifie que les interconnexions série du PCI Express sont réutilisables telles quelles par CXL, ce qui est l'idéal niveau compatibilité matérielle. Elle réutilise des mécanismes de cohérence des caches utilisés sur les processeurs multicœurs, qui seront décrits dans un chapitre ultérieur (un protocole de type MESI, pour rentrer dans les détails). L'intérêt est que les caches ne sont pas invalidés à chaque écriture problématique, la donnée adéquate est simplement mise à jour. De plus, la mise à jour et la détection des écritures fautives ne fait pas intervenir le pilote de périphérique, tout est géré par le processeur lui-même. Mais CXL va au-delà de la simple cohérence des caches périphériques. Il permet aussi au processeur d'adresser de la mémoire RAM installée sur un périphérique, à travers une liaison PCI Express. Toujours est-il que CXL est un regroupement de trois protocoles distincts, nommés CXL.io, CXL.cache, et CXL.memory. CXL.io gère des transferts DMA, la mémoire virtuelle du périphérique, les interruptions, la découverte des périphériques CXL, leur configuration, la gestion des erreurs, etc. CXL.cache gère la cohérence des caches périphériques, il permet à un périphérique d’accéder et de cacher la RAM système. Enfin, CLX.mem permet au processeur d'adresser la mémoire RAM du périphérique. Il faut noter que chaque périphérique implémente CXL a sa sauce. Il doit au minimum implémenter CXL.io, mais fait ce qu'il veut pour CXL.cache et CXL.mem. Et cela permet de classer les périphériques CXL en trois types. * Le type 1 correspond aux périphériques sans RAM intégrée, avec un cache périphérique. Ils peuvent se passer de CXL.mem, mais pas de CXL.cache. * Le type 2 correspond aux GPU et autres cartes accélératrices, qui ont une RAM intégrée qui gagne à être adressée directement par le processeur. Dans ce cas, il faut implémenter CXL.io, CXL.cache, et CXL.memory. * Le type 3 correspond à des ''RAM drive'', des systèmes permettant d’ajouter de la RAM à l'ordinateur sans ajouter de RAM système. La RAM ajoutée est placée sur un périphérique PCI Express, mais sert de complément à la RAM principale. l'idée est que les données sont déplacées entre RAM système et RAM du périphérique selon les besoins, la RAM du périphérique servant de second ''swapfile'' de la mémoire virtuelle. La cohérence des caches n'est alors pas nécessaire, CXL.cache n'est pas utilsié, alors que CXL.mem l'est. ===L'interaction entre DMA et mémoire virtuelle : l'IO-MMU=== Le contrôleur DMA est initialisé par le processeur avec des adresses de départ. Mais que se passe-t-il si le processeur utilise la mémoire virtuelle, l'abstraction mémoire ? Dans ce cas, le processeur initialise le contrôleur DMA avec des adresses virtuelles, ce qui pose problème. Pour éviter cela, les contrôleurs DMA modernes incrémentent des adresses virtuelles nativement, mais les traduisent en adresse physique avant tout accès à la RAM. En clair, les contrôleurs DMA incorporent une '''IO-MMU''', un circuit qui traduit les adresses virtuelles en adresses physiques. Elle est similaire à la MMU du processeur, si ce n'est qu'elle est intégrée dans le contrôleur DMA. La IO-MMU est intégrée dans le contrôleur DMA, qui peut être dans le ''chipset'' de la carte mère ou dans un périphérique. Si elle est dans le ''chipset'', elle est partagée entre plusieurs périphériques. Mais il n'est pas rare que la IO-MMU soit intégré dans un périphérique. L'exemple type est celui des cartes graphiques AGP et PCI-Express, qui utilisaient une IO-MMU standardisée, appelée la ''Graphics address remapping table''. La première carte graphique NVIDIA, la NV1, utilisait un système similaire, comme illustré ci-dessous. [[File:Microarchitecture du GPU NV1 de NVIDIA.png|centre|vignette|upright=2|Microarchitecture de la carte graphique NV1 de NVIDIA]] Évidemment, la IO-MMU a sa propre table des pages, qui est gérée par le pilote de périphérique, qu'on nommera '''table des pages IO'''. La IO-MMU intègre aussi des ''page table walkers'', des TLBs et toutes les optimisations associées. La table des pages de l'IO-MMU est séparée des autres tables des pages, bien que certaines optimisations permettent de fusionner les deux. Placer la table des pages IO en mémoire RAM fonctionne très bien pour les contrôleurs DMA intégré au ''chipset'' de la carte mère, voire ceux dans le processeur lui-même. Mais les périphériques qui incorporent un contrôleur DMA font autrement. Avec une table des pages IO en RAM, le périphérique devrait y accéder à travers le bus. Par exemple, une carte graphique devrait passer par le bus PCI-Express à chaque accès à la table des pages IO. Les performances seraient catastrophiques, même si on peut limiter la casse avec une TLB de grande taille. En pratique, le périphérique incorpore de la mémoire RAM et met la table des pages IO dedans. Par exemple, les cartes graphiques incorporent une mémoire vidéo de grande taille, dont elles peuvent réserver une petite partie pour la table des pages. La carte graphique NV1 de NVIDIA faisait autrement : elle mettait la table des pages dans une mémoire dédiée. [[File:IO-MMU et table des pages sur périphérique avec RAM locale.png|centre|vignette|upright=2|IO-MMU et table des pages sur périphérique avec RAM locale]] L'usage de l'IO-MMU permet de passer outre les limitations d'adressage du contrôleur DMA. Le contrôleur DMA peut gérer des adresses virtuelles très courtes, qui sont traduites en adresses physiques longues. Il s'agit d'un cas particulier d'extension d'espace d'adressage, qui porte le nom de '''''DMA Remapping'''''. Le résultat est que l'on peut se passer de tampons DMA, des ''bounce buffer''. Les données à envoyer au périphérique peuvent être placées n'importe où en mémoire physique, le contrôleur DMA utilisera des adresses physiques assez longues pour les adresser. Un autre avantage est que l'on peut communiquer avec un périphérique DMA sans avoir à allouer des blocs de mémoire contiguë. Avec la pagination, un bloc de mémoire transféré via DMA peut être éclaté sur plusieurs pages séparées et dispersées en mémoire RAM. Le bloc de mémoire est contigu dans l'espace d'adressage, mais éclaté en mémoire physique. Un autre avantage est la protection mémoire. Avec une IO-MMU adéquate, les échanges entre processeur et périphérique sont sécurisés. La mémoire virtuelle des périphériques incorpore des mécanismes de protection mémoire. Grâce à eux, le périphérique ne peut pas accéder à des régions de la mémoire auquel le pilote de périphérique ne lui a pas donné accès. Une erreur de configuration ou de programmation ne permet pas au périphérique d'accéder à des régions mémoire protégées, qui ne lui sont pas allouées. Des tentatives de hacking basée sur des attaques DMA ne marchent plus avec ce type de mémoire virtuelle. Un dernier avantage de l'IO-MMU se manifeste sur les périphériques qui incorporent de la mémoire RAM. Un bon exemple est celui des cartes graphiques dédiées, qui incorporent actuellement plusieurs gibi-octets de mémoire vidéo. Pour éviter toute confusion, nous allons parler de '''mémoire locale''' pour la RAM dans le périphérique, de '''mémoire système''' pour la RAM de l'ordinateur. L'IO-MMU permet d'adresser plus de RAM qu'il n'y a de RAM locale, le surplus d'adresse étant pris dans la RAM système. C'est un peu l'équivalent de la mémoire virtuelle, sauf qu'au lieu d'utiliser un fichier ''pagefile'' sur le disque dur, on utilise de la RAM système. Par exemple, si on prend une carte graphique avec 6 gigas de RAM dédiée, elle pourra gérer jusqu'à 8 gigas de RAM : les 6 en mémoire vidéo, plus 2 gigas fictifs en mémoire système. [[File:Mémoire virtuelle des cartes graphiques dédiées.png|centre|vignette|upright=2|Mémoire virtuelle des cartes graphiques dédiées]] ==Le ''chipset'' de la carte mère== Pour résumer ce chapitre, la communication avec les périphériques demande beaucoup de composants matériels. Un ordinateur moderne contient plusieurs dizaines, si ce n'est des centaines, de contrôleurs de périphériques. Par exemple, un ordinateur de type PC contient des contrôleurs pour le bus USB, un pour la carte son, un autre pour le disque dur, un autre pour les péripéhriques PCI Express, et ainsi de suite. Et il faut aussi rajouter un contrôleur d'interruption, éventuellement un contrôleur DMA, pour que les performances soient dignes de ce nom. Auparavant, le contrôleur d'interruption et le contrôleur DMA étaient des circuits intégrés séparés, soudés sur la carte mère. Mais ce n'est plus le cas de nos jours. ===Les ''chipsets'' sont des regroupements de circuits très divers=== De nos jours, tout est intégré dans un circuit unique, appelé le ''chipset'' de la carte mère. Il intègre un contrôleur DMA, le contrôleur d'interruption, ainsi que de nombreux contrôleurs de périphériques. Par exemple, les contrôleurs USB sont directement intégrés dans le ''chipset'', idem pour les contrôleurs S-ATA/nVME et PCI Express. [[File:Chipset et contrôleurs de périphériques.png|centre|vignette|upright=2|Chipset et contrôleurs de périphériques]] Dire ce que regroupe un ''chipset'' est donc compliqué, car le contenu du ''chipset'' dépend fortement de la carte mère. Le résumer en un simple regroupement de circuits aux fonctions disparates est de loin la meilleure définition possible. Comme le disent si bien les anglais : "A chipset is a set of chips". Le regroupement de tout ces circuits dans un ''chipset'' est une conséquence de la miniaturisation des transistors. Souder une dizaine de contrôleurs de périphériques différents sur la carte mère, ce qui avait un coût en termes de branchements, de coût de production et autres. Mais la miniaturisation a permis de regrouper le tout dans un seul boîtier. Des composants autrefois soudés sur la carte mère, comme des ''timers'', la CMOS RAM ou la ''Real Time Clock'' peuvent parfois être intégrés aux ''chipset''. Et il intègre souvent le microcontrôleur de surveillance matérielle, qui surveille les températures et tensions, pour contrôler les ventilateurs et les régulateurs de voltage. Il faut aussi noter que les ''chipsets'' modernes servent aussi de répartiteurs, qui connectent entre eux CPU, RAM, IO et périphériques. Mais nous ne reparlerons pas de cela ici, c'est quelque chose qui est plus pour le prochain chapitre. Et nous en avions déjà parlé longuement dans le chapitre sur l'architecture de base d'un ordinateur. [[File:IO mappées en mémoire avec séparation des bus.png|centre|vignette|upright=2|IO mappées en mémoire avec séparation des bus, usage d'un répartiteur]] ===Un exemple de ''chipset'' pour le bus ISA=== Pour donner un exemple de ''chipset'' de ce genre, nous allons regarder du côté des anciens PC. Les premiers PC utilisaient un bus système unique, sur lequel tout était connecté. Le bus en question s'appelait le '''bus ISA''', pour ''Industry Standard Architecture''. De nombreux composants présents sur la carte mère étaient connectés sur ce bus ISA. La liste suivante, non-exhaustive, contient les composants principaux : * un contrôleur mémoire pour DRAM ; * le contrôleur de bus 8288 ; * un contrôleur d'interruptions 8259 et un contrôleur DMA 8237 * un contrôleur d'interface parallèle 8255 ; * un générateur d'horloge 8284 ; * une ''Real Time Clock'' pour gérer la date et l'heure ; * le ''Programmable Interval Timer'' (le fameux 8254 qu'on a vu dans le chapitre sur les ''timers'') ; * le contrôleur de clavier XT ; * l'Intel 82091AA, qui était connecté au lecteur de disquette, au port série et au port parallèle (deux connecteurs présents à l'arrière des vieux PC). [[File:Architecture de l'IBM PC compatible.png|centre|vignette|upright=2.5|Architecture de l'IBM PC compatible]] Un des premier ''chipset'' inventé pour les PC a été le 82C100. Il était prévu pour fonctionner avec un processeur Intel x86, un 286 pour être précis. Il combine le contrôleur de clavier avec les composants de la liste précédentes, à l'exception de la ''Real Time Clock''. Son successeur est le 82C206 ''Integrated Peripheral Controller'' (IPC). Les différences avec le précédent sont mineures. La ''Real Time Clock'' et la CMOS RAM sont maintenant intégré dans le ''chipset''. Le contrôleur DMA et le contrôleur d'interruption sont doublés : il y en a deux exemplaires. Par contre, le contrôleurs de clavier et d'interface parallèle 8255 disparaissent. Les deux ''chipsets'' ont été développés par l'entreprise Chips and Technologies. Ils étaient composés de quatre à cinq circuits intégrés distincts, donc 4 boitiers. Sur l'image suivante, vous pouvez voir les quatre ''chips'' sur la carte mère. Ils sont reconnaissables par leur forme carrée, et surtout par le logo de l'entreprise avec un "Chips" assez reconnaissable. [[File:Chicony CH-286N-16.jpg|centre|vignette|upright=2|Carte mère avec un ''chipset'' 82C100.]] ==Les coprocesseurs d'entrée-sorties== Pour finir, nous allons voir un dernier composant qui permet de grandement faciliter la communication avec les entrées-sorties. Il s'agit des '''coprocesseurs d'entrée-sorties''', qui déchargent le processeur principal de tout ce qui a trait aux entrées-sorties. Ils étaient appelés avec des termes très divers : ''Channel I/O'', ''I/O processor'', ''I/O controller'', ''I/O synchronizer'', et autres. Dans ce qui va suivre, nous allons utiliser le terme de '''coprocesseur I/O'''. [[File:Asmp 2.gif|centre|vignette|upright=2|Co-processeur pour l'accès aux entrées-sorties.]] Pour simplifier, les coprocesseurs I/O sont des '''contrôleurs DMA programmables'''. Les plus simples sont simplement des contrôleurs DMA émulés avec un processeur, mais la majorité est capables d'enchainer plusieurs transferts DMA à la suite l'un de l'autre. Et certains d'entre eux savaient se débrouiller avec la mémoire virtuelle, avec des capacités de traduction d'adresse virtuelles en adresses physiques. Les coprocesseurs I/O exécutent un programme qui agit sur les entrées-sorties, souvent appelé un '''programme I/O'''. Les instructions d'un programme I/O sont très variables, mais les plus fréquentes sont les copies DMA, les lectures/écritures de registre d’interfaçage, des opérations bit à bit. Les branchements et opérations arithmétiques sont possibles, mais plus rares. Pour démarrer un programme I/O, le CPU envoie un ordre au coprocesseur I/O, que ce dernier exécute. Quand le programme IO s'est terminé, le coprocesseur I/O envoie une interruption au CPU pour le prévenir qu'il a terminé. Idem en cas d'erreur lors de la transmission. Les co-procdesseurs I/O sont apparus sur les anciens ''mainframes'', notamment sur les ''mainframes'' IBM. À l'époque, communiquer avec des périphérique demandait de faire des transferts DMA, mais les contrôleurs DMA n'existaient pas. Alors les contrôleurs DMA étaient émulés avec des processeurs dédiés, qui s'occupaient des transferts DMA, au jeu d'instruction spécialisé pour gérer des entrées-sorties. Et vu que c'était des processeurs, ils étaient programmables, dans une certaine mesure. C'était l'époque des ''channel I/O'' des vieux ''mainframes''. Pour les ordinateurs personnels, il a existé un coprocesseur I/O vendu par Intel : l'Intel 8089. Mais l'IBM PC originel ne l'ayant pas utilisé, il n'a pas été repris sur les PC suivants, ce qui l'a fait tomber dans l'oubli. les consoles de jeu utilisent souvent des coprocesseurs I/O. Pour être précis, elles utilisent un processeur normal comme coprocesseur d'entrée-sortie. Un exemple est celui de la console de jeu Nintendo 3DS, qui disposait d'un CPU ARM9, d'un coprocesseur pour les divisions qu'on abordera plus bas, et d'un processeur ARM7. L'ARM 7 était utilisé comme coprocesseur d'I/O, ainsi que pour l'émulation de la console GBA. ===Les interconnexions entre coprocesseur IO, CPU et entrée-sorties=== Un coprocesseur I/O peut être relié au reste de l'ordinateur de plusieurs manières. L'essentiel est que le coprocesseur IO soit relié à la mémoire RAM, pour prendre en charge les transferts DMA. Rappelons en effet qu'un coprocesseur I/O est une sorte de contrôleur DMA sous stéroïde, ce qui requiert qu'il soit relié à la fois aux entrées-sorties et à la mémoire RAM. Il y a donc deux solutions : soit il est connecté à un bus système, soit il est utilisé en tant que répartiteur. Utiliser un bus système a de nombreux avantages. Le câblage du système est très simple, l'implémentation du DMA sur le coprocesseur I/O est aisée. Par contre, le processeur et le coprocesseur I/O doivent se synchroniser, histoire de ne pas accéder au bus en même temps, en utilisant les mécanismes d'arbitrage du bus. Utiliser le coprocesseur I/O comme répartiteur fait que celui-ci sert d'intermédiaire entre les entrée-sorties et le reste de l'ordinateur. Il est relié à deux bus : un ''bus processeur'' qui le relie au processeur, et un ''bus IO'' qui le relie aux entrée-sorties. Il reçoit des commandes sur le bus processeur et peut prévenir le processeur quand il a terminé son travail, que ce soit via ''pooling'' ou via des interruptions. [[File:Connexion d'un coprocesseur IO au reste de l'ordinateur.png|centre|vignette|upright=2|Connexion d'un coprocesseur IO au reste de l'ordinateur]] Rappelons que le programme I/O doit bien être mis quelque part. Avec un bus système, il est placé dans la RAM système, partagée entre processeur et coprocesseur IO. Pour initialiser le coprocesseur I/O, le processeur a juste besoin de l fournissant un pointeur qui pointe vers le programme IO (l'adresse du programme). Le défaut à cela est que le CPU et le coprocesseur I/O se marchent sur les pieds pour accéder à la mémoire RAM. Il est possible de résoudre ce problème en dédiant une RAM au coprocesseur, pour le programme IO. Reste à connecter cette RAM dédiée au coprocesseur I/O. Pour cela, il est possible de la placer sur le bus IO, au même titre que n'importe quelle entrée-sortie. C'est la solution la plus économe, mais elle demande d'utiliser le coprocesseur I/O comme un répartiteur, un intermédiaire. Une autre serait de connecter la RAM sur un bus mémoire dédié. Elle a l'avantage de marcher avec un bus système ou une utilisation en répartiteur, mais elle demande que le coprocesseur IO ait des broches dédiées à la mémoire. Dans les deux cas, le programme I/O doit être copié dans cette mémoire séparée, ce qui n'est pas de la tarte. [[File:Coprocesseur IO avec RAM dédiée sur le bus d'IO.png|centre|vignette|upright=2|Coprocesseur IO avec RAM dédiée sur le bus d'IO]] Les ''Channel IO'' des anciens ''mainframes'' commandaient un bus IO séparé. Par contre, l'Intel 8089 pouvait fonctionner dans deux modes : soit avec un bus système, soit pour commander un bus IO. ===Le jeu d'instruction du coprocesseur IO=== En général, le programme I/O émule les transferts DMA. Pour cela, le programme IO copie les mots mémoire un par un avec une boucle. Le coprocesseur IO incorpore pour cela les registres d'un contrôleur DMA : des registres pour l'adresse de la source et de la destination, des registres pour des indices de boucle, des compteurs pour le nombre d'octets à copier, etc. Les registres d'adresse sont généralement incrémentés ou décrémentés automatiquement, suivant que le transfert se fasse par adresses croissantes ou décroissantes. Les compteurs de boucles sont décrémentés à chaque itération et mémorisent à tout instant combien de mots mémoire il reste à copier. Le jeu d'instruction se concentre surtout sur les accès mémoire et les branchements pour les boucles. Pour ce qui est des calculs, il a juste le minimum : addition/soustraction/comparaisons, pas d'instructions de multiplication ou de divisions. Les opérations arithmétiques complexes sont inutiles pour les tâches de gestion des périphériques, elles ne sont donc pas implémentées. Par contre, ils disposent d'instructions de manipulation de bit assez riches et complexes. En effet, les pilotes de périphériques font beaucoup de manipulations de bits, que ce soit pour changer des bits dans un registre de contrôle, extraire les bits pertinents d'un registre d'état, ou bien d'autres manipulations. Pour résumer, leur jeu d’instruction est centré sur : les accès mémoire, les boucles, la manipulation de bit. Il faut noter que le processeur a souvent plusieurs ports, avec un port par entrée-sortie dans le meilleur des cas. Et chaque port dispose de ses propres registres pointeurs/computer de boucle. Par exemple, l'Intel 8089 avait deux ports dédiés aux entrée-sorties, et deux bancs de registres dédiés. Rien de surprenant, il y a l'équivalent sur n'importe quel contrôleur DMA. Chaque canal DMA a ses propres registres. Les coprocesseurs I/O peuvent faire communiquer des composants ayant des largeurs de bus différentes. Par exemple, il est possible de faire communiquer une entrés-sortie 8 bits avec une autre de 16 bits, ou de faire des transferts entre une RAM 32 bits et une entrée-sortie de 16 bits. Pour cela, le coprocesseur I/O fait automatiquement la conversion. Par exemple, pour un transfert d'un composant 16 bits vers un autre de 8 bits, il lit 16 bits depuis la source et découpe le doublet en deux octets, qu'il envoie un par un. Inversement, pour un transfert d'un composant 8 bits vers un composant 16 bits, il lit deux octets à la suite et les combine en un seul doublet, envoyé en une fois. ===L'Intel 8089=== [[File:Intel 8089.svg|vignette|Intel 8089]] L'Intel 8089 est un coprocesseur Intel, prévu pour fonctionner en tandem avec un CPU 8086 d'Intel. Il contient deux canaux DMA programmables, chacun ayant ses propres registres, ses propres interruptions, etc. Par contre, le reste du processeur n'est présent qu'en un seul exemplaire. Ce qui veut dire que ce n'est pas un processeur double cœur, il n'y a qu'une seule unité de contrôle, et une seule unité de calcul. Niveau instruction, on retrouve les instructions classiques, avec quelques ajouts dédiés aux transferts DMA. Les calculs se limitent à l'addition, l'incrémentation, la décrémentation, et les ET/OU/NON bit à bit. Il y a aussi deux instructions pour mettre un bit à 0 ou 1, ainsi que des branchements pour tester la valeur d'un bit dans un registre ou une adresse. Mais les instructions intéressantes sont : * les instructions de copie mémoire-mémoire ; * une instruction pour configurer un transfert DMA ; * une instruction pour démarrer un transfert DMA à la prochaine instruction ; * une instruction pour terminer l'exécution du programme I/O ; * une instruction pour mettre la sortie d’interruption à 1. Le 8089 contient des registres de 20/21 bits et des registres de 16 bits. Les registres de 20/21 bits sont destinés à recevoir des adresses, alors que les registres de 16 bits sont des registres de données. Si je dis 20/21, c'est en raison de l'adressage employé par le 8086, qui utilisait un espace d'adressage séparé pour la mémoire et les entrée-sorties. Les registres du 8089 mémorisent des adresses de 20 bits, et ajoutent un bit IO pour faire la différence entre les deux espaces d'adressage. Les registres d'adresse sont au nombre de quatre et sont nommés GA, GB, GC et TP. Les registres de 16 bits sont eux aussi au nombre de quatre et sont nommés BC, IX, MC et CC. Le registre TP est le ''program counter'' associé à un canal DMA. Les deux canaux DMA exécutent chacun leur propre programme, ce qui fait qu'ils ont chacun leur propre ''program counter''. Mais pour le reste, les registres ne font pas la même chose selon qu'un transfert DMA est en cours ou non. Lors d'un transfert DMA, les registres sont utilisés comme suit : * Le registre GA est utilisé pour adresser la source, le registre GB est utilisé pour adresser la destination. * Le registre GC est facultatif : il pointe vers une table de 256 octets, utilisée pour faire de la traduction d'adresse. * Le registre BC compte le nombre d'octets qu'il reste à transférer. Le registre MC mémorise un masque, qui est utilisé pour appliquer une opération de masquage avant d'écrire l'octet dans la destination. * Le registre CC est utilisé pour configurer certains transferts DMA, notamment quand plusieurs transferts DMA sont enchainés automatiquement par le 8089. * Les autres registres ne sont pas utilisés. En dehors d'un transfert DMA, les registres sont utilisés pour stocker des données, à l'exception de TP et de CC. * Les trois registres d'adresse GA, GB et GC peuvent être utilisés comme registre de donnée, entre deux transferts DMA. Mais ils sont alors utilisés comme des registres de 16 bits, les 4 bits de poids fort étant remplis avec le bit de signe du nombre qu'ils contiennent. * Le registre BC n'a pas de prédisposition en dehors des transferts DMA. * IX est prédisposé à servir de registre d'indice, si le mode d'adressage le demande. * MC est prédisposé à gérer un masque, qui est utilisé avec les instructions JMCE and JMCNE. Initialiser le 8089 demande de lui fournir des informations, regroupées dans deux blocs de mémoire : un ''Channel Control Block'' et un ''Command Parameter Block''. Les deux sont placés en mémoire RAM, le 8089 y accède en lisant dans la mémoire RAM, et le processeur doit fournir l'adresse de ces deux blocs de configuration au 8089 quand il l'initialise. Je ne vais pas donner une explication complète de ces deux blocs, mais je vais quand même donner quelques détails. Le ''Channel Control Block'' contient, pour chaque canal DMA, un octet de configuration. L'octet de configuration contient plusieurs bits de configuration. En premier lieu, on trouve trois bits qui permettent de démarrer, terminer, suspendre ou reprendre l'exécution du programme I/O. En second lieu, on trouve deux bits pour activer/désactiver les interruptions sur chaque canal DMA. Enfin, on a un bit de priorité qui permet de donner la priorité à un canal DMA sur l'autre. Si un canal DMA a ce bit à 1 et l'autre a un bit à 0, celui qui a le bit à 1 a la priorité. Le ''Command Parameter Block'' contient l'adresse du programme I/O, en mémoire RAM. Elle est en réalité composée de deux adresses, parce que le 8089 et le 8086 utilisent la segmentation. Il contient aussi un ''program status word'', qui remplace le registre d'état. En effet, le 8089 n'a pas vraiment de registre d'état. À la place, il utilise un octet en RAM, qui n'est autre que le ''program status word''. Il met à jour le ''program status word'' en mémoire RAM dès que nécessaire. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=L'abstraction mémoire et la mémoire virtuelle | prevText=L'abstraction mémoire et la mémoire virtuelle | next=L'adressage des périphériques | nextText=L'adressage des périphériques }} </noinclude> 053bu74qxzz14zxwc73uzhgsoh31mrg Fonctionnement d'un ordinateur/Les processeurs VLIW et EPIC 0 79441 771168 761246 2026-08-22T01:25:15Z Mewtow 31375 /* Les processeurs VLIW */ 771168 wikitext text/x-wiki Dans les chapitres précédents, nous avons parlé des processeurs qui sont capables d’exécuter des instructions dans le désordre, ou plusieurs instructions en parallèle, en même temps. Mais nous nous sommes concentrés sur les processeurs avec un jeu d'instruction normal, où l’exécution des instructions en parallèle est invisible pour le programmeur ou le compilateur. Les processeurs superscalaires sont de ce type, mais ils permettent non seulement d’exécuter plusieurs instructions en même temps, mais aussi d'émettre plusieurs instructions en même temps sur des unités de calcul différentes. Le parallélisme d'instruction maximal avec ce genre d’architectures est donc un processeur pipeliné, superscalaire, à exécution dans le désordre avec renommage de registre. Mai ces architectures ont un défaut majeur, au-delà de la prédiction des branchements : elles doivent détecter les dépendances et déterminer quelles sont les instructions exécutables en parallèle. Mais il se trouve que ces informations sur les dépendances d'instructions sont connues des compilateurs modernes. Lors de la compilation, le code source est traduit en une représentation intermédiaire où les dépendances de type WAR, WAW et RAR n'existent tout simplement pas. Il faut dire que la représentation intermédiaire a souvent une infinité de registres, voire fonctionne sans registres et avec une infinité de places mémoires. De fait, les processeurs à exécution dans le désordre doivent retrouver des informations qui étaient connues du compilateur mais ont disparu du code source. De cette observation est venue l'idée de modifier le jeu d’instruction pour que ces informations sur les dépendances soient disponibles directement dans le code machine lui-même. Ainsi, le compilateur fait tout le travail de réorganisation des instructions et d’exécution en parallèle, à la place du processeur. De telles architectures s'appellent des '''architectures à parallélisme d'instruction explicite'''. Il existe plusieurs types d'architectures de ce genre, les deux principales étant les architectures VLIW et ''dataflow''. Il faut aussi mentionner les architectures découplées, fort confidentielles et peu nombreuses, que nous verrons à la fin du chapitre. Les architectures ''dataflow'' seront vues dans le chapitre suivant. L'idée derrière ces architectures est d'expliciter les dépendances entre instruction et de les encoder directement dans le code machine. Le processeur peut ainsi déterminer quelle instruction est prête à s’exécuter en fonction de la disponibilité des opérandes. L’exécution dans le désordre est donc réalisée par le compilateur. Dans ce chapitre, nous allons voir des architectures à parallélisme d'instruction qui n'encodent pas les dépendances dans les instructions, ou du moins pas explicitement. Voyons maintenant les architectures VLIW, EPIC et découplées. ==Les processeurs VLIW== Les '''processeurs VLIW''', ou ''very long instruction word'', exécutent plusieurs instructions en parallèle, mais sans forcément avoir de l’exécution dans le désordre. Pour cela, ils regroupent plusieurs instructions indépendantes en super-instructions, appelées des '''faisceaux d’instructions''' (aussi appelés ''bundle''). Le faisceau est chargé en une seule fois et est encodé comme une instruction unique. En clair, les processeurs VLIW chargent "plusieurs instructions à la fois" et les exécutent sur des unités de calcul séparées (les guillemets sont là pour vous faire comprendre que c'est en réalité plus compliqué). Une autre manière de voir les choses est que les faisceaux d'instruction regroupent plusieurs opérations en une seule super-instruction machine. Là où les instructions machines usuelles effectuent une seule opération, les faisceaux d'instruction VLIW exécutent plusieurs opérations indépendantes en même temps, dans des unités de calcul séparées. [[File:Vliwpipeline.svg|centre|vignette|upright=1.5|Pipeline simplifié d'un processeur VLIW. On voit que le faisceau est chargé en un cycle d'horloge, mais que les instructions sont exécutées en même temps dans des unités de calcul séparées.]] ===L'attribution des instructions/opérations aux ALUs=== Sur un processeur VLIW pur, les faisceaux sont de taille fixe. Aussi, le regroupement des instructions est grandement facilité, de même que leur attribution aux unités de calcul. L'attriobution d'une instruction à une unité de calcul utilise l''''encodage par position'''. Avec cette méthode, un faisceau est découpé en créneaux (''slot''), chacun étant attribué à une ALU. La position de l'instruction dans le faisceau détermine l'ALU à utiliser. Les opérations/instructions ne peuvent pas être réparties n'importe comment dans un faisceau. Pour le dire autrement, chaque créneau ne peut contenir que quelques instructions bien précises,compatibles avec l'unité de calcul associée. Par exemple, le premier créneau ne peut accepter que des instructions d'addition, le second que des multiplications, le dernier que des instructions transcendantales, etc. {|class="wikitable" |- ! colspan="3" |Instruction VLIW à 3 slots |- | Slot 1 || Slot 1 || Slot 3 |- | Addition || Multiplication || Décalage à gauche |} En conséquence, il y a de nombreuses contraintes quand au regroupement des opérations/instructions. On ne peut pas regrouper n'importe quelle opération avec n'importe quelle autre, il faut que les unités de calcul permettent le regroupement. Prenons l'exemple d'un processeur VLIW disposant de deux additionneurs et d'un circuit multiplieur : il sera possible de regrouper deux additions avec une multiplication, mais pas deux multiplications ou trois additions. Il y a aussi des contraintes sur les registres : les instructions d'un faisceau ne peuvent pas écrire dans les mêmes registres, il y a des contraintes qui font que si telle opération utilise tel registre, alors certains autres registres seront interdits pour l'autre opération, etc. ===L'exécution ''lockstep'' des opérations/instructions d'un faisceau=== Sur un processeur VLIW idéal, toutes les instructions d'un faisceau s'exécutent en même temps, durant les mêmes cycles d'horloge. La conséquence est que si une instruction/opération bloque le pipeline, toutes les instructions du faisceau doivent l'attendre. Par exemple, si un accès mémoire est dans un faisceau, les autres instructions s’exécutent en parallèle, mais on ne peut pas passer au faisceau suivant tant que l'accès mémoire n'est pas terminé. Un faisceau est un tout, qui s’exécute en bloc, on ne peut pas passer au faisceau suivant tant que tout le faisceau précédent est terminé. On parle parfois d''''exécution ''lockstep''''' d'un faisceau. Un défaut important est la gestion des instructions multi-cycle et notamment des accès mémoire. Lors d'un défaut de cache, les autres unités de calcul sont inutilisées une fois qu'elles ont fait les calculs associés dans le faisceau. Ce ne serait pas le cas avec un processeur à lectures non-bloquantes ou à exécution dans le désordre. Les instructions suivant la lecture pourraient parfaitement alimenter les unités de calcul, à condition d'être indépendants de la lecture. Mais avec un processeur VLIW, impossible de démarrer les opérations du faisceau suivant tant que l'accès mémoire n'est pas terminé. Idem avec les instructions multicycle, pour des instructions flottantes ou les multiplications/divisions. ===Les dépendances de données sont interdites dans un faisceau=== Les instructions/opérations d'un faisceau sont censées être indépendantes, dans le sens où elles n'ont pas de dépendances de données. Aussi, des difficultés arrivent quand des instructions/opérations d'un faisceau manipulent le même registre. Pour les lectures, cela ne pose pas de problème : la donnée lue est envoyée à plusieurs ALU, pas de quoi déclencher des problèmes, il faut juste que la connexion entre registres et unité de calcul le permettent, ce qui est souvent le cas. Les problèmes apparaissent avec des écritures. En théorie, deux instructions/opérations ne sont pas censées écrire dans le même registre. De même, si les instructions sont indépendantes, une instruction ne doit pas lire un registre écrit par une autre instruction. Mais dans les faits, cette situation arrive sur certains processeurs VLIW. Et ils peuvent gérer la situation de trois manières. La toute première est la plus simple : le processeur interdit à deux instructions d'un même faisceau d'avoir une dépendance de ce type. Si une instruction écrit dans un registre, tout autre instruction du faisceau ne peut pas lire ce registre. Une autre solution autorise ce genre de choses, mais avec une subtilité : la lecture du registre renvoie la valeur avant l'écriture. En clair, les deux instructions n'ont pas de dépendances entre elles, c’est juste que l'instruction lectrice lit une donnée qui est écrasée par la seconde au cycle suivant. L'implémentation est assez simple au niveau du matériel, et assez intuitive. Il existe cependant une exception, qui permet à plusieurs instructions d'écrire dans un registre. Il s'agit d'une sous-catégories d'instructions à prédicat sur le processeur Itanium. Les instructions en question effectuent une comparaison, puis font un ET/OU/XOR entre le résultat et un registre à prédicat. Or, l'Itanium peut exécuter plusieurs comparaisons de ce type en parallèle, en mettre plusieurs dans un seul faisceau. Si plusieurs comparaisons utilisent le même registre à prédicat et font un ET avec leur résultat, le résultat sera un ET entre tous les résultats des comparaisons. Idem avec un OU ou un XOR sur un même registre. Par contre, impossible de faire à la fois un OU et un ET sur le même registre. Il s'agit donc d'une exception à la règle : pas d'écritures simultanées dans le même registre. ===La gestion des exceptions matérielles avec des faisceaux d'instructions=== Passons maintenant aux dépendances de contrôle, qui existent encore sur les architectures VLIW. L'exécution en bloc, ''lockstep'', est censée résoudre ces dépendances pour ce qui est des branchements. Mais il ne faut pas oublier les exceptions matérielle. Une instruction/opération peut déclencher une exception, et il faut la traiter avec la granularité d'un faisceau. Un processeur VLIW peut gérer la situation de plusieurs manières différentes, qui dépendent du processeur. La plus simple invalide toutes les instructions du faisceau, l'exception est traitée au niveau du faisceau. Le faisceau est ré-executé intégralement une fois l'exception traitée par la routine adéquate. Une solution complétement à l'opposé exécute toutes les instructions du faisceau, sauf celle qui a levé l'exception. La première fait qu'on doit ré-exécuter toutes les instructions du faisceau, l'autre solution n'en ré-exécute qu'une seule. En terme de consommation énergétique et de performance, les deux sont complétement opposées : ré-exécution maximale couteuse en performance et énergie d'un côté, ré-exécution minimale et peu couteuse en énergie/performance de l'autre. Mais elles ont un point commun : dans les deux cas précédents, les exceptions deviennent alors imprécises. Une autre solution, plus compatible avec le modèle familier aux programmeurs, ajoute un support des exceptions précises. L'idée est d'invalider uniquement les instructions qui suivent l'exception dans l'ordre du programme. Pour cela, le compilateur doit ajouter quelques informations sur l'ordre des instructions dans le faisceau. Une solution intermédiaire invalide seulement les instructions qui ont une dépendance avec celle qui a levé l'exception. On a alors des exceptions semi-précises, mais les performances sont meilleures vu qu'on n'a pas à ré-exécuter beaucoup d'instructions. ===Le compilateur a un rôle primordial sur les architectures VLIW=== Les architectures VLIW ont des avantages certains : hardware très simple, émission multiple supportée nativement, etc. Mais elles ont aussi divers problèmes, comme une faible compatibilité, des performances limitées par les dépendances existantes à la compilation et une densité de code mauvaise. Tous les avantages et inconvénients majeurs ont la même source : c'est le compilateur qui regroupe plusieurs instructions/opérations en un seul faisceau. Le compilateur garantit que les instructions/opérations regroupées sont indépendantes et peuvent s’exécuter en parallèle. Un avantage à cela est que les processeurs VLIW ont un hardware très simple, avec peu de circuits de contrôle. Le compilateur se charge de vérifier que des opérations indépendantes sont regroupées dans une instruction, pas besoin de hardware pour cela, pas besoin d'une unité d'émission complexe, ni d'exécution dans le désordre. Il y a juste besoin d'un ''scoreboard'' pour gérer les instructions multicycle et les accès mémoire. Alors qu'avec un processeur superscalaire, il y a des circuits de détection des dépendances entre instructions assez complexes et couteux en circuits. L'absence de ces circuits fait que les processeurs VLIW étaient utilisés sur les premières cartes graphiques : autant utiliser les transistors pour placer le plus de circuits de calcul possible au lieu d'en dépenser dans des circuits de contrôle. Mais avec l'évolution de la technologie, il est devenu plus rentable d'ajouter de tels circuits pour gagner en performance, ce qui fait que les cartes graphiques incorporent un ''scoreboard'' ou quelque chose de similaire. Mais pour ce qui est des désavantages, les processeurs VLIW ont une performance très dépendante du compilateur. Le compilateur doit analyser le code source pour détecter les dépendances d'instruction, et faire les regroupements. En soi, analyser les dépendances entre instruction est assez simple pour les compilateurs modernes, qui utilisent une représentation SSA. Mais malgré cela, les compilateurs ne font pas du très bon travail. Il arrive régulièrement que le compilateur ne puisse pas remplir tout le faisceau avec des instructions indépendantes. Sur les anciens processeurs VLIW, les instructions VLIW (les faisceaux) étaient de taille fixe, ce qui forçait le compilateur à remplir d'éventuels vides avec des NOP, diminuant la densité de code. La majorité des processeurs VLIW récents utilise des faisceaux de longueur variable, supprimant ces NOP. Mais il reste le fait que ces NOP sont une sous-exploitation des unités de calcul. De plus, certaines dépendances entre instructions ne peuvent être supprimées, ce qu'un processeur à exécution dans le désordre peut faire. Par exemple, le fait que les accès à la mémoire aient des durées variables (suivant que la donnée soit dans le cache ou la RAM, par exemple) joue sur les différentes dépendances. Un compilateur ne peut pas savoir combien de temps va mettre un accès mémoire et il ne peut organiser les instructions d'un programme en conséquence. Par contre, un processeur le peu. Autre exemple : les dépendances d'instructions dues aux branchements, qui ont tendance à limiter fortement les possibilités d'optimisation du compilateur. Autre défaut : les processeurs VLIW n'ont strictement aucune compatibilité, ou alors celle-ci est très limitée. En effet, le format des faisceaux VLIW est spécifique à un processeur. Celui-ci va dire : telle instruction va sur telle ALU, et pas ailleurs. Mais si on rajoute des unités de calcul dans une nouvelle version du processeur, il faudra recompiler notre programme pour que celui-ci puisse l'utiliser, voire simplement faire fonctionner notre programme. Dans des situations dans lesquelles on se moque de la compatibilité, cela ne pose aucun problème : par exemple, on utilise beaucoup les processeurs VLIW dans l'embarqué. Mais pour un ordinateur de bureau, c'est autre chose... ===Un petit historique des processeurs VLIW=== Les processeurs VLIW sont une idée assez ancienne, qui a ses sources dans un algorithme conçu à base pour l'implémentation d'un microcode horizontal ! Dans les années 80, Joseph Fisher travaillait sur l'architecture du CDC-6600, un super-ordinateur dont le processeur avait un microcode horizontal. Et le microcode horizontal a justement des ressemblances avec les processeurs VLIW. Frustré par les difficultés à coder un microcode sur ce genre de machines, il chercha un algorithme qui part d'une séquence de micro-opérations, et qui les regroupe dans des micro-opérations horizontales. L'algorithme qui naquit de ces recherches est appelé l''''algorithme de ''trace scheduling'''''. L'algorithme était capable de regrouper des opérations isolées dans un paquet encodé avec une seule micro-opération. Quelques architectures similaires au VLIW existaient déjà à l'époque, mais étaient prévues pour être codées en assembleur. L'invention de cet algorithme rendit ces architectures bien plus utiles. Le parallélisme exposé dans les micro-instructions horizontales est similaire à celui exposé par les architectures VLIW. Il était alors possible de prendre un compilateur, d'exécuter un algorithme de ''trace scheduling'' sur les instructions, et de se retrouver avec un programme encodant directement les des faisceaux VLIW. Fisher participa alors à un projet de recherche visant à créer un processeur VLIW, nommé le ELI-512 (''Extremely Long Instruction''-512), dont les instructions faisaient 512 bits et encodaient jusqu’à 30 instructions machines. Par la suite, il créa l'entreprise Multiflow, qui batit les premiers processeurs VLIW commerciaux, les bien nommés processeurs Multiflow. L'entreprise Cydrome tenta de leur faire concurrence. Mais les deux entreprises firent faillite au bout de quelques années. Quelques tentatives récentes de faire revenir ces architectures sur le devant de la scène ont été des échecs. Citons le cas de l'architecture Itanium d'Intel, ainsi que les processeurs Crusoe de l'entreprise Transmetta. Les deux visaient à remplacer le jeu d'instruction x86 des PC modernes, objectifs très compliqué et sans doute voué à l'échec du fait des problèmes de compatibilité intrinsèques. Transmetta a pourtant pris le problème à bras le corps, avec des techniques de traduction x86-VLIW très performantes, mais purement logicielles. Mais rien à faire, les processeurs VLIW n'ont percé que dans des domaines où la compatibilité avec le code existant n'est pas nécessaire, l'embarqué étant le meilleur d'entre eux. Les processeurs VLIW ont été autrefois utilisés dans les cartes graphiques AMD et possiblement NVIDIA de l'époque de DirextX 8/9. Mais tout cela est le sujet d'un autre wikilivre... ==Les instructions multicyles et le pipeline sur les CPU VLIW== La discussion précédente partait du principe que le processeur VLIW n'avait pas de pipeline. L'implémentation du VLIW sur un pipeline demande cependant quelques modifications. Le cas le plus simple est celui d'un pipeline de longueur fixe, où toutes les opérations s'exécutent en un seul cycle d'horloge. Sans système de contournement, l'usage du pîpeline fait qu'il y a un délai entre le moment où une opération lit ses opérandes dans les registres et celui où son résultat est enregistré dans les registres. Quelques cycles d'horloges, tout au plus, mais il s'agit d'un délai qui fait que le résultat d'une opération est enregistré en retard. Or, un processeur pipeliné démarre un nouveau faisceau à chaque cycle d'horloge. Ce qui pose des problèmes si deux faisceaux ont des dépendances de données. Si on lance ces deux faisceaux l'un à la suite de l'autre, le second faisceau ne lira pas le résultat calculé par le premier. On retombe sur les problèmes liés à l'émission dans l'ordre/désordre, que les architectures VLIW souhaitaient éviter. ===Les mitigations et solutions=== Il est possible de mitiger le tout dans le cas d'un pipeline de longueur fixe, où toutes les instructions s'exécutent en un cycle d'horloge. Pour cela, il suffit d'utiliser un système de contournement, pour envoyer les résultats calculés par l'ALU sur son entrée, afin de mieux gérer les dépendances de données de type RAW. Sans cela, deux faisceaux avec une dépendance ne peuvent pas être démarrés l'un après l'autre. Mais la solution ne résout le problème que si toutes les instructions/opérations s'exécutent en cycle d'horloge au niveau de l'ALU. Dans les faits, la solution ne marche pas pour les instructions multicycles, dont les accès mémoire. Une solution à cela serait de vérifier les dépendances entre faisceaux avec un ''scoreboard'' ou un circuit similaire. En cas de dépendances entre les faisceaux, l'exécution ''lockstep'' des instructions fait qu'on doit ajouter des bulles de pipeline. Le problème est que l'on ne profite pas du pipeline, sauf à utiliser un système de contournement complexe. De plus, cela demanderait d'améliorer l'unité d'émission et d'ajouter du hardware pour cela, ce qui colle mal à la philosophie du VLIW. Mais c'est l'une des seule solution possible pour gérer les accès mémoire. Une autre solution est de laisser le travail au compilateur, qui gère de lui-même les latences des instructions liées au pipeline. Le compilateur doit donc produire un code machine spécifique à un processeur, qui prend en compte la longueur du pipeline. La solution marche pas trop mal pour les instructions multicycles exécutées dans une ALU, mais pas trop pour les accès mémoire. Pour les instructions multicycles, le compilateur connait la latence de chaque opération et s’arrange pour que le code compilé n'aie pas de problèmes. Par exemple, si une unité de calcul multicycle est utilisée pendant 5 cycles, elle s'arrange pour que les 5 faisceaux suivants ne l'utilisent pas. Le compilateur doit gérer les latences pour chaque opération d'un faisceau, vérifier que des faisceaux consécutifs soient compatibles, et ainsi de suite. ===Les modèles de latence d'instruction=== Peu importe que l'on utilise un ''scoreboard'' ou le compilateur, la latence des instruction a un grand impact dans la manière dont on exécute/compile le code. Le processeur connait la latence maximale d'une instruction multicycle, sauf éventuellement pour les accès mémoire. Idéalement, le compilateur fonctionne mieux avec des latences fixes, mais il peut avoir à se débrouiller avec des latences variables. Les deux cas donnent des résultats très différents pour le compilateur, et ils sont deux modèles différents, qui doivent être pris en compte à la création du processeur. Avec le premier modèle, la latence des instructions est fixe, à savoir qu'elles prennent toujours le même nombre de cycles pour s'exécuter. Si une instruction de multiplication prend 5 cycles, elle prendra toujours 5 cycles, pas un de moins. Avec ce modèle, le compilateur a plus de facilités à gérer les registres. Par contre, la gestion des exceptions précises est particulièrement complexe. Le processeur doit être conçu pour. Il se peut que l'instruction finisse avant dans l'unité de calcul, mais l'écriture dans les registres sera alors retardée pour respecter la latence fixée. Le chemin de données du processeur doit être conçu en conséquence et doit mettre en attente les résultats disponibles à l'avance. Le second modèle permet à une instruction multicycle de finir plus tôt que prévu, leur latence est variable. Par exemple, une instruction de multiplication a une latence maximale de 5 cycles, mais il se peut qu'elle prennent 4/3 cycles si les opérandes sont particulières. Le compilateur a un peu plus de mal avec ce genre de modèle. Mais l'implémentation des exceptions précises est bien plus simple. De plus, cela améliore un petit peu la compatibilité dans certains cas. Si le nouveau modèle du processeur a une latence inférieure pour certaines instructions, le code conçu pour l'ancien processeur' marchera toujours, et ses performances seront améliorées. ==Les processeurs VLIW à instruction de longueur variable== Les processeurs VLIW purs ont de nombreux défauts, mais cela ne signifie pas qu'ils n'ont pas de solutions. Cependant, ces solutions sont un peu opposées à la philosophie de base des processeurs VLIW : réduire au maximum le hardware lié au décodage et aux unités d'émission, tout an gardant l'émission multiple. Les solutions en question sont multiples, mais elle partent du même principe : elle encodent des faisceaux de taille variable. Par taille variable, on veut dire que les faisceaux ont un nombre d'instructions/opérations variables, qui peut varier d'un faisceau à l'autre. Par exemple, prenons un processeur VLIW codant ses faisceaux sur une taille fixe de 512 bits. En passant à des faisceaux de taille variable, les faisceaux peuvent faire entre 64 et 512 bits, par exemple. Pour cela, les NOPs, les instructions qui ne font rien, sont retirées du faisceau ou sont encodées de manière compressée. Et cela implique que le décodage des instructions et leur émission est plus complexe, elle demande plus de circuits. La densité de code est grandement améliorée, de même que la compatibilité. Les faisceaux ont donc une taille variable, comprise entre une taille minimale et une taille maximale. La taille maximale est obtenue quand aucun NOP n'est présent dans le faisceau. Par contre, le processeur doit pouvoir charger un faisceau complet. Par exemple, pour un processeur dont les faisceaux font entre 64 et 512 bits, le processeur charge 512 bits en une fois, en un accès au cache d'instruction. Il faut donc faire la distinction entre les faisceaux et les '''paquets de chargement'''. Un paquet de chargement dans l'exemple précédent fait 512 bits, mais les faisceaux en font entre 64 et 512. L'avantage principal est uen amélioration de la densité de code : pas besoin d'insérer des NOPs à l'intérieur des faisceaux, même si on peut avoir besoin d'insérer des NOPs de bourrage. Avec un faisceau de taille variable, il n'y a pas besoin d'ajouter des NOPs si le faisceau est partiellement remplit, on a juste à raccourcir le faisceau. Le défaut est que le décodage et le chargement des instructions est plus complexe. Les faisceaux n'ayant plus une taille fixe, il faut utiliser les techniques de chargement/décodage des instructions variables vues dans le chapitre sur l'unité de chargement. Cela fait du hardware en plus, un cout en performance et en énergie. Tout ce que la philosophie VLIW voulait éviter. En contrepartie, le gain en densité de code est important et le gain de compatibilité binaire est très important. ===La délimitation des faisceaux=== Un paquet de chargement peut contenir plusieurs faisceaux. Lorsqu'un paquet est chargé, il est découpé en faisceaux au fur et à mesure de l'exécution. Le processeur exécute les faisceaux les uns après les autres. Par exemple, si toutes les instructions d'un paquet doivent s’exécuter en série, il les exécute une après l'autre, et ne charge le paquet suivant qu'une fois que toutes les instructions du paquet sont terminées. Reste que pour cela, il faut trouver un moyen pour découper un paquet de chargement en plusieurs faisceaux. Pour cela, il existe plusieurs techniques, avec d'autres techniques dérivées. La première technique ajoute des '''méta-données''' au début du faisceau, à savoir quelques bits qui fournissent des informations sur le faisceau. Et parmi ces information, on peut intégrer la taille du faisceau, sa longueur en nombre d'octets ou d'instructions. Le processeur a juste à extraire la longueur du faisceau et sait comment découper le paquet de chargement en faisceaux. Les faisceaux sont donc placés à la suite les uns des autres en mémoire, mais sont précédés par un octet de méta-données qui indique la longueur du faisceau, comment sont organisées les instructions/opérations dedans, etc. Un défaut est que la taille d'un faisceau est limitée par le nombre de bits utilisés pour encoder les méta-données. La méthode peut être étendue pour gérer autre chose que la taille des faisceaux, comme on le verra plus bas. En effet, les méta-données ne se bornent pas à donner la longueur du faisceau, mais peuvent indiquer comment sont organisées les instructions dans le faisceau, ou toute autre information utile. A défaut d'intégrer la longueur du faisceau dans l'instruction, on peut intégrer des informations qui permettent de la calculer. Par exemple, nous verrons plus bas la technique du masque de NOPs qui permet de calculer la longueur de l'instruction sur la base d'informations utilisées pour autre chose. Les deux techniques suivantes dispersent des méta-données entre chaque instruction, au lieu de les regrouper dans un octet au début du faisceau. Pour ce faire, elles ajoutent des bits à la fin de chaque instruction, qui précisent s'il s'agit de la dernière instruction d'un faisceau. La solution est plus flexible, car les faisceaux peuvent avoir des tailles arbitraires, en théorie. Avec la première technique, chaque instruction est suivie d'un '''bit d’arrêt''' (stop bits) qui indique la fin d'un faisceau. [[File:Bits d’arrêt.png|centre|vignette|upright=2|Bits d’arrêt.]] Avec la seconde méthode, chaque instruction/opération est suivie d'un un '''bit de parallélisme''' (''parallel bit''), un bit placé à la fin d'une instruction qui dit si elle peut s'effectuer ou non en parallèle de la suivante. [[File:Bit de parallélisme.png|centre|vignette|upright=2|Bit de parallélisme.]] Un exemple est celui des processeurs Texas Instrument de série C6X, notamment le TMS320C62 (C62) et le TMS320C67 (C67). Sur ces processeurs, un paquet de chargement fait 256 bits et les paquets sont eux-même alignés en mémoire sur 256 bits. Les instructions font 32 bits, ce qui fait qu'on peut en mettre 8 par paquet de chargement. Les faisceaux sont indiqués avec des bits de parallélisme. ===L'attribution des instructions/opérations aux ALUs=== Le passage à des faisceaux de taille variable fait que l'on perd la correspondance unique entre une instruction/opération et une unité de calcul. Le fait que les NOPs sont devenus implicites fait que l'on doit répartir les opérations sur les différentes unités de calcul. Il faut déterminer quelle instruction/opération va sur tel unité de calcul. Les solutions pour cela sont assez variables, allant d'un codage des NOPs compressé avec un encodage explicite des NOPs, à des systèmes avec un encodage implicite. Une solution est de se débarrasser de l'encodage par position utilisé plus haut, où chaque instruction était attribuée à une ALU en fonction de sa place dans le faisceau. A la palce, on le remplace par l''''encodage par nommage''', où chaque instruction d'un faisceau précise l'unité de calcul qui doit la prendre en charge. L'instruction contient un numéro qui indique l'unité de calcul à utiliser. Cette technique est déclinée en deux formes : soit on trouve un identifiant d'ALU par instruction, soit on utilise un identifiant pour tout le faisceau, qui permet à lui seul de déterminer l'unité associée à chaque instruction. Une autre solution conserve l'encodage par position vu précédemment, mais représente les NOPS sous forme compressée. Les instructions/opérations sont donc toujours à la bonne place, à la bonne position dans le faisceau, mais les NOPs sont compressés et réduits à une taille plus petite. Le décodage doit alors juste identifier les NOPs et les étendre, de manière à retrouver le faisceau d'instruction non-compressé. Une solution pour cela est d'incorporer, dans le faisceau, un masque qui indique où sont les NOPs dans le faisceau. La technique porte le nom de '''masque de NOP'''. Prenons l'exemple d'un faisceau contenant 8 instructions/opérations maximum. Si j'ai par exemple 6 positions remplies avec deux NOPs, le faisceau contiendra 6 instructions seulement, les NOPs ne seront pas placés dans le faisceau. Par contre, le masque indiquera où sont les NOPs dans le faisceau, où il faut les placer. Le masque contient un bit par instruction, par ''slot'', par position dans le faisceau. Le premier bit est pour la première instruction, le second bit pour la seconde instruction, etc. Le bit est mis à 1 si l'instruction est un NOP, 0 sinon. Les NOPs ne sont donc pas représentés. {|class="wikitable" |- ! colspan="8" | Instruction décompréssée | ADD || MUL || class="f_jaune" | NOP || SUB || class="f_jaune" | NOP || class="f_jaune" | NOP || class="f_jaune" | NOP || LOAD |- ! colspan="8" | Masque de NOP | 0 || 0 || 1 || 0 || 1 || 1 || 1 || 0 |- ! colspan="8" | Instruction compressée | ADD || MUL || SUB || LOAD || 0010 1110 || colspan="3" | |} Le masque est généralement placé au tout début du faisceau, pour faciliter le décodage. Il faut noter que cette technique permet de compresser les faisceaux qui contiennent au moins un NOP. Mais les autres sont allongés d'un octet pour stocker le masque. La technique est donc d'autant plus efficace que le code contient de NOPs, ce qui fait qu'elle est surtout utile sur les CPU VLIW avec beaucoup de ''slots'', qui ont beaucoup d’instructions par faisceau, qui ont plus de difficultés à remplir leurs faisceaux. Il faut préciser qu'avec cette technique, l'encodage de la longueur de l'instruction n'est pas nécessaire. Déterminer la longueur de l'instruction se fait simplement à partir du masque. Il suffit de l'envoyer en opérande d'un encodeur, qui renvoie la longueur du faisceau. La solution a pour avantage de ne pas ajouter d'informations en plus dans le faisceau. Pour implémenter la technique, l'instruction est décompressée lors du chargement de l'instruction depuis le cache L1. Un circuit prend en entrée un paquet de chargement, et l'analyse, puis décompresse le faisceau en ajoutant les NOPs au bon endroit. L'avantage est que les instructions sont compressées dans le cache, ce qui utilise au mieux sa capacité. Une autre solution déplace le circuit de décompression avant le cache. Les faisceaux sont alors décompressés lors de leur chargement dans le cache d’instruction L1. ===L'alignement des faisceaux dans un paquet de chargement=== Un point important est que les faisceaux peuvent ou non être à cheval sur deux paquets de chargement. Certains processeurs ne le permettent pas. C'est le cas sur les processeurs Texas Instrument de série C6X, où un faisceau doit rentrer intégralement dans le paquet. Ainsi, le bit de parallélisme est à 0 toutes les 8 instructions, vu qu'un paquet de chargement fait 8 instructions. Un inconvénient est que cela peut parfois laisser des espaces vides à la fin d'un paquet d'instruction. Le compilateur essaye de faire rentrer le plus de faisceaux possibles dans un paquet de chargement, mais il arrive malgré tout qu'il reste de la place. Par exemple, pour un paquet de 8 instructions, imaginons qu'on ait un faisceau de 4 instructions, un autre de 3, et un autre de deux. Les deux premiers faisceaux rentrent dans le paquet, mais pas le troisième. Il manquera une instruction pour compléter, le vide est comblé par une instruction NOP qui ne fait rien. Le ou les NOP en question, sont appelés des '''NOP de bourrage'''. La compatibilité est très mauvaise avec les bits de bourrage, vu que cela limite la taille du paquet de chargement. De plus, cela réduit les opportunités de compression. On gagne en densité de code si et seulement si on peut mettre plusieurs faisceaux dans un seul paquet de chargement. L'idéal est d'utiliser des paquets de chargement assez grands, pour pouvoir mettre pleins de petits faisceaux dedans. Mais d'autres processeurs autorisent un système différent, où un faisceau peut être à cheval sur un paquet de chargement. Le chargement se fait paquet de chargement par paquet de chargement, avec un découpage des faisceaux lors du décodage. Les techniques utilisées pour cela sont similaires à celles utilisées pour le chargement des instructions de longueur variable. L'avantage est que cela permet de ne pas avoir à ajouter des NOPs de bourrage. Mais le cout en circuits est conséquent. La technique est notamment utilisée sur l'Itanium, comme on le verra plus bas. Les techniques pour charger des faisceaux non-alignées demandent d'accumuler les paquets de chargement dans une mémoire temporaire, et de le insérer au bon endroit, à la suite des faisceaux déjà chargés. Le tout implique des circuits de décalage et autres. Et pour qu'ils fonctionnent, ils doivent connaitre la longueur d'un faisceau, afin de décaler du bon nombre de rangs/instructions. Déterminer la longueur d'un faisceau dépend grandement de la méthode employée pour délimiter les faisceaux. Avec un octet de méta-donnée, la longueur peut être intégrée directement dans les méta-données du faisceau et n'a pas à être déterminée. Avec l'usage de bits d'arrêt ou de parallélisme, on peut la calculer sur la base de ces bits. Il suffit d'envoyer ces bits dans un circuit encodeur qui renvoie la longueur du faisceau, exprimée en nombre d'instructions. Avec un masque pour encoder les NOPs, l'encodage de la longueur se détermine à partir du masque de NOP. Là encore, il suffit de le faire passer dans un circuit qui prend le masque de NOP et détermine la longueur du faisceau. Le circuit n'est ni plus ni moins qu'un circuit de ''population count''. ===L'encodage des faisceaux sur l'Itanium=== L'Itanium utilise un mélange des deux méthodes. Elle regroupe les instructions dans des paquets de trois instructions appelés des ''bundles''. N'allez pas croire que ces ''bundles'' sont des paquets de chargement : les paquets de chargement contiennent 2 ''bundles'', voire plus. Les ''bundles'' ne peuvent pas regrouper n'importe quel triplet d'instructions, mais seulement certains regroupements prévus à l'avance. Encore une fois, il y a des contraintes sur les regroupements d'instructions dans un ''bundle''. Il y a en tout 32 possibilités, qui définissent chacune un mix d'instructions mémoire, entières, flottantes et de branchement. Les trois instructions d'un ''bundle'' sont associées à un '''octet de ''template''''', qui encode les dépendances entre instructions et qui dit comment découper le paquet de chargement en faisceaux. Il regroupe notamment les trois bits d'arrêt des trois instructions, et 5 bits annexes qui encodent la nature des instructions présentes dans le paquet. J'ai dit plus haut qu'il y a 32 possibilités pour les regroupements possibles, et bien on peut coder les 32 possibilités sur 5 bits. Le tout est codé sur 128 bits, avec 40 bits pour chaque instruction, plus l'octet de ''template''. [[File:Encodage des stop bits sur l'Itanium.png|centre|vignette|upright=3|Encodage des stop bits sur l'Itanium]] Un faisceau peut être intégralement contenu dans un ''bundle'' s'il fait trois instructions ou moins. Dans le cas contraire, il est répartit sur plusieurs ''bundles'' et c'est au processeur de reconstituer le faisceau à partir de plusieurs ''bundles''. L'usage de bits d'arrêt permet à un faisceau d'être à cheval sur deux ''bundles'' et éventuellement sur deux paquets de chargement. Les paquets de chargement chargent 2 ''bundles'' à la fois sur l'Itanium 1 et 2. De plus, les paquets de chargement sont accumulées dans un tampon de 8 ''bundles'' (24 instructions). Le tampon s'assure que deux ''bundles'' soient disponibles pour les unités de décodages, vu que le processeur a la capacité d'exécuter 2 ''bundles'' à la fois dans ses unités de calcul. L'unité de décodage se charge de reconstituer des faisceaux d'instruction en utilisant les octets de ''template'' des ''bundles'' chargés, en analysant les bits d'arrêts et les bits qui encodent le regroupement des instructions. Avec la technique utilisée sur l'Itanium, la compatibilité binaire est très bonne. Il est possible de changer les unités de calcul du processeur sans que la compatibilité binaire soit altérée. D'ailleurs, l'Itanium n'a lui-même pas de correspondance entre un ''bundle'' et ses unités de calcul, vu qu'il a 6 unités de calcul/mémoire, pour des ''bundles'' de trois instructions. Il pourrait en rajouter ou en enlever sans que ce soit un problème, il faudrait juste adapter le décodeur. ==Les processeurs hybrides VLIW/RISC== Il existe des processeurs hybrides entre processeurs RISC et VLIW. L'idée est que ces processeurs gérent à la fois des instructions machines isolées, et des faisceaux d'instructions. Il en existe assez peu, mais il est globalement possible de quand même les classer en deux sous-types. Le premier est celui où il est possible de mélanger instructions RISC et VLIW sans véritables contraintes. L'autre est celui où le processeur a deux modes de fonctionnement :un mode RISC, et un mode VLIW, les deux étant totalement séparés et ne pouvant pas être mixés. ===Les CPU RISC/VLIW à instructions mixées=== L'encodage des instructions autorise des instructions isolées, parfois complétées par des faisceaux d'instructions à l'encodage spécifique. Le processeur exécute un programme qui mélange instructions normales et faisceaux VLIW librement. Un exemple est celui des processeurs Xtensa LX2 de Tensilica. Ils appelent cette technique sous le nom de ''Flexible Length Instruction eXtensions'' (FLIX). Le nom trahit bien que les instructions VLIW sont une extension d'un jeu d'instruction normal, au même titre que les extensions MMX/SSE/x87 et autres du jeu d'instruction x86 s'ajoutent aux instructions x86 normales. Les instructions normales font entre 16 et 24 bits, alors que les faisceaux font soit 32, soit 64 bits. Ils peuvent donc regrouper 2 à 4 instructions normales. Les processeurs DSP Infineon Carmel utilise une technique similaire. Ils sont des CPU RISC dont les instructions de base font 48 bits. Ils disposent aussi d'instructions courtes codées sur 24 bits. Et enfin, l'extension ''Configurable Long Instruction Word'' (''CLIW'') permet de gérer des faisceaux VLIW qui regroupent six instructions machines. Vu que le processeur charge 48 bits d'un seul coup, il peut exécuter une à deux instructions courtes d'un seul coup, ce qui en fait une forme basique de VLIW, complémentaire du CLIW. ===Les CPU RISC/VLIW à deux modes de fonctionnement=== Le processeur Intel i860 utilise une méthode différente. Le processeur peut fonctionner en mode RISC normal, ou en mode VLIW. En mode RISC, il peut exécuter des instructions entières ou flottante, les deux étant codées sur 32 bits. En mode VLIW, il exécute des faisceaux VLIW codés sur 64 bits. L'implémentation du VLIW profite de la présence d'une unité FPU séparée de l'ALU entière. En mode VLIW, les faisceaux d'instruction sont composés en concaténant une instruction entière et une instruction flottante, le tout faisant 64 bits. Les faisceaux sont alignées sur 64 bits, la première instruction est forcément entière, la seconde est une instruction flottante. L'instruction flottante peut faire une multiplication et une addition à la suite, vu qu'on a un additionneur flottant relié à un multiplieur flottant. Les deux circuits ont une interconnexion partiellement programmable, afin de faciliter l'implémentation de certaines instructions flottantes. ==Les processeurs EPIC== En 1997, Intel et HP lancèrent un nouveau processeur, l'Itanium, dont l'architecture corrigeait les défauts des autres processeurs VLIW. Dans un but marketing évident, Intel et HP prétendirent que l'architecture de ce processeur était une nouvelle classe d'architecture, appelée ''EPIC'' pour '''''Explicit Parallelism Instruction Computing'''''. Mais il faut avouer que cette architecture était juste du VLIW amélioré. De plus, les améliorations apportées par les processeurs Itanium étaient reprises d'autres architectures, ou ont été appliqués sur d'autres processeurs VLIW ultérieurs. Les processeurs de la société Transmetta sont un exemple. Et nous allons les voir dans ce qui suit. Une architecture EPIC implémente des faisceaux d'instruction de taille variable, mais aussi des techniques de spéculation avancées pour les exceptions matérielles, les branchements, ou les accès mémoire. Vu qu'il s'agit de techniques spéculatives, on s'attend à ce que ce soit le processeur qui soit en charge de ces spéculations, mais la réalité est qu'elles sont une collaboration entre CPU et compilateur ! ===Les exceptions différées=== L'Itanium implémente ce qu'on appelle les '''exceptions différées'''. Avec cette technique, la gestion des exceptions est optimisée avec un système de pseudo-transactions. Le code est exécuté par blocs d'instruction, délimités par des branchements ou tout autre limite/barrière pertinente dans le code. L'idée est qu'un morceau de code s'exécute d'un seul coup, et que la détection des exceptions se fait à la toute fin. Si aucune exception n'a été levée, les résultats du bloc de code sont définitivement acceptés. Mais si une exception est levée, tout est annulé et on ré-éxecute le code d'une manière qui permette de gérer l'exception. La méthode implémente deux types d'instructions : des instructions spéculatives, et des instructions non-spéculatives. Les instructions non-spéculatives lévent des exceptions normalement, si la situation l'exige. Les instructions spéculatives ne font pas cela. A la place, elles mémorisent qu'elles ont levée une exception, mais sans l'exécuter. Tout bloc de code sont donc compilés en deux versions : une version avec uniquement des instructions spéculatives, une avec des instructions normales. La version avec instructions spéculatives s'exécute, et on vérifie si une exception a eu lieu à la fin du bloc de code. Si une exception a eu lieu, on annule tout ce qu'a fait le bloc de code, et on exécute la version non-spéculative du bloc de code. Pour vérifier l’occurrence d'une exception, chaque registre est associé à un bit ''Exception'', qui est mis à 1 si l'instruction qui a écrit dans ce registre aurait dû lever une exception. Si une instruction lève une exception, elle écrira un résultat faux dans un registre et le bit ''Exception'' est mis à 1. Les autres instructions propageront ce bit ''Exception'' dans leurs résultats : si un opérande est ''Exception'', le résultat de l'instruction l'est aussi. Une fois le code fini, il suffit d'utiliser une instruction qui teste l'ensemble des bits « rien du tout » et agit en conséquence. Dans la terminologie des processeurs Itanium, ce bit est appelé « rien du tout » (''not a thing''), et est mis à 1 s'il contient une valeur invalide. Son usage est plus général et il ne sert pas qu'à détecter les exceptions, mais aussi des violations de dépendances mémoire, comme on le verra dans la section suivante. De plus, il n'existe que pour les registres entiers. Pour les registres flottants, une solution alternative est utilisée : ils remplacent la valeur dans le registre flottant par une valeur NaTVal, qui est une sorte de NaN spécialisé dans les exceptions. Pour annuler ce qu'à fait un bloc de code après une exception, le processeur doit mémoriser l'état du processeur avant de démarrer un morceau de code. Pour cela, le processeur sauvegarde les registres du processeur dans des ''shadow registers'', avant d'exécuter un bloc de code, afin que le processeur puisse revenir à l'état de base. Lorsqu'un bloc de code termine son exécution, il exécute une instruction CHECK qui vérifie les bits Exception des registres. Si une exception a eu lieu, l'instruction CHECK branche vers la version non-spéculative du bloc de code. Sinon, elle se comporte comme un simple NOP. ===La spéculation sur les lectures=== L'Itanium fournit une fonctionnalité similaire pour les lectures, qui vise à les déplacer le plus tot possible dans le code. En théorie, le compilateur peut déplacer les lectures, mais il doit respecter les dépendances de données. De nombreuses opportunités sont alors perdues, car le compilateur doit prendre des décisions assez conservatrices. Il ne peut pas déplacer une lecture avant une écriture s'il ne connait pas les adresses à l'avance, quand elles sont calculées à l'exécution. Mais les processeurs EPCI ajoutent de quoi spéculer sur les dépendances mémoire dans le code. L'idée est d'exécuter les lectures en avance, de détecter les violations de dépendances mémoire à l'exécution et de les corriger quand elles surviennent. Pour cela, on réutilise la machinerie des exceptions différées vue juste avant. Le code est compilé dans une version optimisée où les lectures sont déplacées sans tenir compte des dépendances de données, avec un code de secours non-optimisé. Le processeur vérifie si la spéculation s'est bien passée, avant de décider de passer sur le code de secours ou non. Et la vérification est assez simple : elle est considérée comme une exception matérielle différée. Si une lecture viole une dépendance mémoire, le registre de destination contient une donnée invalide, son bit « rien du tout » est mis à 1, etc. Pour détecter les violations de dépendance, le processeur maintient une liste des lectures émises dans un cache : la '''table des adresses lues en avance''', appelée en anglais ''advanced load address table'' (ALAT). Cette ALAT stocke l'adresse, la longueur de la donnée lue, le registre de destination, etc. Toute écriture consulte l'ALAT, pour vérifier si une lecture à la même adresse est dans l'ALAT. Si c'est le cas, une dépendance a été violée, la lecture est retirée de l'ALAT, le bit « rien du tout » du registre de destination est mis à 1. La vérification ne se fait pas de la même façon selon que la lecture ait été déplacée avant un branchement ou avant une écriture. Les deux situations ne sont pas équivalentes. Déplacer une lecture avant une écriture implique une violation de dépendance mémoire. Mais ce n'est pas le cas si on déplace une lecture avant un branchement. Le branchement pourrait en effet brancher vers un morceau de code où cette lecture ne s'exécute pas. Typiquement, le cas est celui où un IF...ELSE a une de ses deux branches qui exécute une lecture et pas l'autre. Dans ce cas, le compilateur déplace la lecture avant le branchement, de manière spéculative. Dans ce cas, on exécute une lecture avant de savoir si elle va réellement être exécutée. Il se peut que la lecture soit exécutée à tord, mais il n'y a pas de violation de dépendance mémoire proprement dit. [[File:Spéculation sur les lectures.png|centre|vignette|upright=2|Spéculation sur les lectures.]] Les deux cas sont traités à part en matériel, l'un impliquant l'ALAT et les mécanismes de désambiguïsation mémoire, l'autre demandant de vérifier si un branchement a pris la bonne voie d'exécution. Les mécanisme matériels pour cela ne sont donc pas les mêmes. * Si on passe une lecture avant un branchement, la lecture et la vérification sont effectuées par les instructions LD.S et CHK.S. Si une dépendance est violée, le processeur lève une exception différée : le bit « rien du tout » du registre contenant la donnée lue est alors mis à 1. CHK.S ne fait rien d'autre que vérifier ce bit. * Si on passe une lecture avant une écriture, la désambiguïsation de la mémoire est gérée par le compilateur. Tout se passe comme avec les branchements, à part que les instructions sont nommées LD.A et CHK.A. ===Les bancs de registres tournants=== Les processeurs EPIC et VLIW utilisent une forme limitée de renommage de registres pour accélérer certaines boucles. Pour l’expliquer, prenons une boucle simple et intéressons-nous au corps de la boucle, à savoir la boucle sans les branchements et instructions de test qui servent à répéter les instructions. La boucle d'exemple se contente d'ajouter 5 à tous les éléments d'un tableau. L'adresse de l’élément du tableau est stockée dans le registre R2. Dans le code qui suivra, les crochets serviront à indiquer l'utilisation du mode d'adressage indirect. Sans optimisations, le corps de la boucle est le suivant : loop : load R5 [R2] / add 4 R2 ; add 5 R5 ; store [R2] R5 ; Les différentes itérations de la boucle peuvent se calculer en parallèle, vu que les éléments du tableau sont manipulés indépendamment. Mais codée comme dessus, ce n'est pas possible car les trois instructions de la boucle utilisent le registre R5 et ont donc des dépendances. Le renommage de registres peut éliminer ces dépendances, mais il n'est pas disponible sur les processeurs VLIW et EPIC. À la place, les concepteurs de processeurs ont inventé les '''bancs de registres tournants''' (''rotating register files''). Avec cette méthode, la correspondance (nom de registre - registre physique) se décale d'un cran à chaque cycle d’horloge. Par exemple, le registre nommé R0 à un instant donné devient le registre R1 au cycle d'après, et idem pour tous les registres. Précisons que sur l'Itanium, cette technique est appliquée non pas à l'ensemble du banc de registre, mais est limitée à un banc de registres spécialisé dans l’exécution des boucles. Évidemment, le code source du programme doit être modifié pour en tenir compte. Ainsi, le code vu précédemment devient celui-ci. loop : load RB5 [R2] / add 4 R2 ; add 5 RB6 ; store [R2] RB7 ; Ainsi, le LOAD d'une itération ne touchera pas le même registre que le LOAD de l'itération suivante, idem pour l'instruction de calcul et le STORE. Le nom de registre sera le même, mais le fait que les noms de registre se décalent à chaque cycle d'horloge fera que ces noms identiques correspondent à des registres différents. Les dépendances sont supprimées, et le pipeline est utilisé à pleine puissance. Cette technique s'implémente avec un simple compteur, incrémenté à chaque cycle d'horloge, qui mémorise le décalage à appliquer aux noms de registre. À chaque utilisation d'un registre, le contenu de ce compteur est ajouté au nom de registre à accéder. ==Les architectures découplées== Les '''architectures découplées''', aussi appelées '''''Decoupled Access/ExecuteComputer Architectures''''', sont totalement différentes des architectures VLIW. Elles sont bien des architectures à parallélisme d'instruction explicites, mais leur rôle est d'implémenter une forme limitée d’exécution dans le désordre, pas d’exécuter des instructions indépendantes en parallèles. Par contre, elle ont pour point commun avec les architectures VLIW : elles n'encodent pas les dépendances entre instructions de manière explicite dans les instructions, ce que font les architectures ''dataflow''. Les architectures découplées séparent les accès mémoires des autres instructions dans deux programmes qui s’exécutent en parallèle, ce qui permet une forme limitée d’exécution dans le désordre. Le découpage du programme en plusieurs flux s'effectue à la compilation. Les deux flux sont chacun exécutés sur un processeur séparé, un le processeur ''access'' en charge des accès mémoire et un processeur ''execute'' pour les calculs et des branchements. Le transfert des opérandes entre processeurs se fait par l'intermédiaire de mémoires tampons de type FIFOs, manipulées par le programmeur. Les transferts se font dans les deux sens : du processeur ''access'' vers le processeur ''execute'' pour les lectures, dans l'autre sens pour les écritures. Les branchements demandent cependant une synchronisation entre les deux flux d'instruction, afin de garantir qu'un branchement s’exécute bien au même moment dans les deux flux. Et toute la difficulté est de synchroniser les deux processeurs. Cette technique n'est pas compatible avec l’exécution dans le désordre telle que vue dans les chapitres précédents, mais elle en reprend certains avantages. L'avantage principal est que la séparation en deux flux permet de profiter d'une forme limitée d’exécution dans le désordre, mais avec beaucoup moins de structures matérielles, moins de registres, moins de circuits. Les deux flux étant séparés, l'un peut continuer à s’exécuter alors que l'autre est en train d'attendre. Par exemple, le flux de calcul peut continuer à faire des calculs pendant que l'autre est coincé dans un accès mémoire de longue durée. Ou alors, le flux d'accès mémoire peut charger des données à l'avance, pendant que le flux de calcul est bloqué dans un calcul long (une division, par exemple). Cette forme de parallélisme est certes plus limitée que celle permise par l’exécution dans le désordre, mais le principe est là. : Dans ce qui suit, le processeur ''access'' sera noté processeur A, et le processeur ''execute'' sera noté processeur X, pour plus de simplicité. Pour en savoir, voici quelques liens utiles. * [https://course.ece.cmu.edu/~ece740/f13/lib/exe/fetch.php?media=p289-smith.pdf ''Decoupled Access/Execute Computer Architectures''] ===Les échanges de données entre processeurs=== Prenons le cas d'une lecture : la lecture est démarrée par le processeur A, puis est transmise au processeur X. La transmission au processeur X se fait par l'intermédiaire d'une mémoire FIFO, la file de lecture de X (''X load queue'', abréviée XLQ). Quand le processeur X a besoin d'un opérande lu depuis la mémoire, il vérifie si elle est présente dans l'XLQ et se met en attente tant que ce n'est pas le cas. Pour les écritures, l'adresse de l'écriture est calculée par le processeur A, tandis que la donnée à écrire est calculée par le processeur X, et les deux ne sont pas forcément disponibles en même temps. Pour cela, les adresses et les données sont mises en attente dans deux mémoires tampons qui coopèrent entre elles. La première est intégrée dans le processeur A et met en attente les adresses calculées : c'est la file d’écriture des adresses (''store address queue'', ou SAQ). La seconde met en attente les données à lire, et relie le processeur X au processeur A : c'est la file d’écriture de X (''X store queue'', ou XSQ). Quand l'adresse et la donnée sont disponibles en même temps, l'écriture est envoyée à la mémoire ou au cache. Les échanges entre processeurs peuvent aussi se faire sans passer par des files de lecture/écriture. Cela peut servir pour diverses scénarios. Par exemple, le calcul d'une adresse sur le processeur A peut utiliser des données calculées par le processeur X. Pour cela, les échanges s'effectuent par copies inter-processeurs de registres. Un registre peut être copié du processeur A vers le processeur X, ou réciproquement. Pour utiliser cette unité de copie, chaque processeur dispose d'une instruction COPY. ===Les branchements=== Les branchements sont présents à des endroits identiques dans les deux flux, ce qui fait que les structures de contrôle présentes dans un programme sont présentes à l'identique dans l'autre, etc. Or, les branchements doivent donner le même résultat dans les deux processeurs. Pour cela, le résultat d'un branchement sur un processeur doit être transmis à l'autre processeur. Pour cela, on trouve deux files d'attente : * la file A vers X (''access-execute queue'', abréviée AEQ), pour les transferts du processeur A vers le processeur X ; * la file X vers A (''execute-access queue'', abréviée EAQ), pour l'autre sens. Il faut noter que le processeur peut se trouver définitivement bloqué si l'AEQ et l'EAQ sont toutes deux totalement remplies ou totalement vide. Pour éviter cette situation, les compilateurs doivent compiler le code source d'une certaine manière. [[File:752a78c7-71e0-4580-afdd-538Résumé d'une architecture découplée de type access-execute.e30998305.png|centre|vignette|upright=2|Résumé d'une architecture découplée de type access-execute.]] <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Exemples de microarchitectures CPU : le cas du x86 | prevText=Exemples de microarchitectures CPU : le cas du x86 | next=Les architectures dataflow | nextText=Les architectures dataflow }} </noinclude> in50tyj1i7y8tcek5y1vzkpod65flxq 771169 771168 2026-08-22T01:25:28Z Mewtow 31375 /* L'attribution des instructions/opérations aux ALUs */ 771169 wikitext text/x-wiki Dans les chapitres précédents, nous avons parlé des processeurs qui sont capables d’exécuter des instructions dans le désordre, ou plusieurs instructions en parallèle, en même temps. Mais nous nous sommes concentrés sur les processeurs avec un jeu d'instruction normal, où l’exécution des instructions en parallèle est invisible pour le programmeur ou le compilateur. Les processeurs superscalaires sont de ce type, mais ils permettent non seulement d’exécuter plusieurs instructions en même temps, mais aussi d'émettre plusieurs instructions en même temps sur des unités de calcul différentes. Le parallélisme d'instruction maximal avec ce genre d’architectures est donc un processeur pipeliné, superscalaire, à exécution dans le désordre avec renommage de registre. Mai ces architectures ont un défaut majeur, au-delà de la prédiction des branchements : elles doivent détecter les dépendances et déterminer quelles sont les instructions exécutables en parallèle. Mais il se trouve que ces informations sur les dépendances d'instructions sont connues des compilateurs modernes. Lors de la compilation, le code source est traduit en une représentation intermédiaire où les dépendances de type WAR, WAW et RAR n'existent tout simplement pas. Il faut dire que la représentation intermédiaire a souvent une infinité de registres, voire fonctionne sans registres et avec une infinité de places mémoires. De fait, les processeurs à exécution dans le désordre doivent retrouver des informations qui étaient connues du compilateur mais ont disparu du code source. De cette observation est venue l'idée de modifier le jeu d’instruction pour que ces informations sur les dépendances soient disponibles directement dans le code machine lui-même. Ainsi, le compilateur fait tout le travail de réorganisation des instructions et d’exécution en parallèle, à la place du processeur. De telles architectures s'appellent des '''architectures à parallélisme d'instruction explicite'''. Il existe plusieurs types d'architectures de ce genre, les deux principales étant les architectures VLIW et ''dataflow''. Il faut aussi mentionner les architectures découplées, fort confidentielles et peu nombreuses, que nous verrons à la fin du chapitre. Les architectures ''dataflow'' seront vues dans le chapitre suivant. L'idée derrière ces architectures est d'expliciter les dépendances entre instruction et de les encoder directement dans le code machine. Le processeur peut ainsi déterminer quelle instruction est prête à s’exécuter en fonction de la disponibilité des opérandes. L’exécution dans le désordre est donc réalisée par le compilateur. Dans ce chapitre, nous allons voir des architectures à parallélisme d'instruction qui n'encodent pas les dépendances dans les instructions, ou du moins pas explicitement. Voyons maintenant les architectures VLIW, EPIC et découplées. ==Les processeurs VLIW== Les '''processeurs VLIW''', ou ''very long instruction word'', exécutent plusieurs instructions en parallèle, mais sans forcément avoir de l’exécution dans le désordre. Pour cela, ils regroupent plusieurs instructions indépendantes en super-instructions, appelées des '''faisceaux d’instructions''' (aussi appelés ''bundle''). Le faisceau est chargé en une seule fois et est encodé comme une instruction unique. En clair, les processeurs VLIW chargent "plusieurs instructions à la fois" et les exécutent sur des unités de calcul séparées (les guillemets sont là pour vous faire comprendre que c'est en réalité plus compliqué). Une autre manière de voir les choses est que les faisceaux d'instruction regroupent plusieurs opérations en une seule super-instruction machine. Là où les instructions machines usuelles effectuent une seule opération, les faisceaux d'instruction VLIW exécutent plusieurs opérations indépendantes en même temps, dans des unités de calcul séparées. [[File:Vliwpipeline.svg|centre|vignette|upright=1.5|Pipeline simplifié d'un processeur VLIW. On voit que le faisceau est chargé en un cycle d'horloge, mais que les instructions sont exécutées en même temps dans des unités de calcul séparées.]] ===L'attribution des instructions/opérations aux ALUs=== Sur un processeur VLIW pur, les faisceaux sont de taille fixe. Aussi, le regroupement des instructions est grandement facilité, de même que leur attribution aux unités de calcul. L'attribution d'une instruction à une unité de calcul utilise l''''encodage par position'''. Avec cette méthode, un faisceau est découpé en créneaux (''slot''), chacun étant attribué à une ALU. La position de l'instruction dans le faisceau détermine l'ALU à utiliser. Les opérations/instructions ne peuvent pas être réparties n'importe comment dans un faisceau. Pour le dire autrement, chaque créneau ne peut contenir que quelques instructions bien précises,compatibles avec l'unité de calcul associée. Par exemple, le premier créneau ne peut accepter que des instructions d'addition, le second que des multiplications, le dernier que des instructions transcendantales, etc. {|class="wikitable" |- ! colspan="3" |Instruction VLIW à 3 slots |- | Slot 1 || Slot 1 || Slot 3 |- | Addition || Multiplication || Décalage à gauche |} En conséquence, il y a de nombreuses contraintes quand au regroupement des opérations/instructions. On ne peut pas regrouper n'importe quelle opération avec n'importe quelle autre, il faut que les unités de calcul permettent le regroupement. Prenons l'exemple d'un processeur VLIW disposant de deux additionneurs et d'un circuit multiplieur : il sera possible de regrouper deux additions avec une multiplication, mais pas deux multiplications ou trois additions. Il y a aussi des contraintes sur les registres : les instructions d'un faisceau ne peuvent pas écrire dans les mêmes registres, il y a des contraintes qui font que si telle opération utilise tel registre, alors certains autres registres seront interdits pour l'autre opération, etc. ===L'exécution ''lockstep'' des opérations/instructions d'un faisceau=== Sur un processeur VLIW idéal, toutes les instructions d'un faisceau s'exécutent en même temps, durant les mêmes cycles d'horloge. La conséquence est que si une instruction/opération bloque le pipeline, toutes les instructions du faisceau doivent l'attendre. Par exemple, si un accès mémoire est dans un faisceau, les autres instructions s’exécutent en parallèle, mais on ne peut pas passer au faisceau suivant tant que l'accès mémoire n'est pas terminé. Un faisceau est un tout, qui s’exécute en bloc, on ne peut pas passer au faisceau suivant tant que tout le faisceau précédent est terminé. On parle parfois d''''exécution ''lockstep''''' d'un faisceau. Un défaut important est la gestion des instructions multi-cycle et notamment des accès mémoire. Lors d'un défaut de cache, les autres unités de calcul sont inutilisées une fois qu'elles ont fait les calculs associés dans le faisceau. Ce ne serait pas le cas avec un processeur à lectures non-bloquantes ou à exécution dans le désordre. Les instructions suivant la lecture pourraient parfaitement alimenter les unités de calcul, à condition d'être indépendants de la lecture. Mais avec un processeur VLIW, impossible de démarrer les opérations du faisceau suivant tant que l'accès mémoire n'est pas terminé. Idem avec les instructions multicycle, pour des instructions flottantes ou les multiplications/divisions. ===Les dépendances de données sont interdites dans un faisceau=== Les instructions/opérations d'un faisceau sont censées être indépendantes, dans le sens où elles n'ont pas de dépendances de données. Aussi, des difficultés arrivent quand des instructions/opérations d'un faisceau manipulent le même registre. Pour les lectures, cela ne pose pas de problème : la donnée lue est envoyée à plusieurs ALU, pas de quoi déclencher des problèmes, il faut juste que la connexion entre registres et unité de calcul le permettent, ce qui est souvent le cas. Les problèmes apparaissent avec des écritures. En théorie, deux instructions/opérations ne sont pas censées écrire dans le même registre. De même, si les instructions sont indépendantes, une instruction ne doit pas lire un registre écrit par une autre instruction. Mais dans les faits, cette situation arrive sur certains processeurs VLIW. Et ils peuvent gérer la situation de trois manières. La toute première est la plus simple : le processeur interdit à deux instructions d'un même faisceau d'avoir une dépendance de ce type. Si une instruction écrit dans un registre, tout autre instruction du faisceau ne peut pas lire ce registre. Une autre solution autorise ce genre de choses, mais avec une subtilité : la lecture du registre renvoie la valeur avant l'écriture. En clair, les deux instructions n'ont pas de dépendances entre elles, c’est juste que l'instruction lectrice lit une donnée qui est écrasée par la seconde au cycle suivant. L'implémentation est assez simple au niveau du matériel, et assez intuitive. Il existe cependant une exception, qui permet à plusieurs instructions d'écrire dans un registre. Il s'agit d'une sous-catégories d'instructions à prédicat sur le processeur Itanium. Les instructions en question effectuent une comparaison, puis font un ET/OU/XOR entre le résultat et un registre à prédicat. Or, l'Itanium peut exécuter plusieurs comparaisons de ce type en parallèle, en mettre plusieurs dans un seul faisceau. Si plusieurs comparaisons utilisent le même registre à prédicat et font un ET avec leur résultat, le résultat sera un ET entre tous les résultats des comparaisons. Idem avec un OU ou un XOR sur un même registre. Par contre, impossible de faire à la fois un OU et un ET sur le même registre. Il s'agit donc d'une exception à la règle : pas d'écritures simultanées dans le même registre. ===La gestion des exceptions matérielles avec des faisceaux d'instructions=== Passons maintenant aux dépendances de contrôle, qui existent encore sur les architectures VLIW. L'exécution en bloc, ''lockstep'', est censée résoudre ces dépendances pour ce qui est des branchements. Mais il ne faut pas oublier les exceptions matérielle. Une instruction/opération peut déclencher une exception, et il faut la traiter avec la granularité d'un faisceau. Un processeur VLIW peut gérer la situation de plusieurs manières différentes, qui dépendent du processeur. La plus simple invalide toutes les instructions du faisceau, l'exception est traitée au niveau du faisceau. Le faisceau est ré-executé intégralement une fois l'exception traitée par la routine adéquate. Une solution complétement à l'opposé exécute toutes les instructions du faisceau, sauf celle qui a levé l'exception. La première fait qu'on doit ré-exécuter toutes les instructions du faisceau, l'autre solution n'en ré-exécute qu'une seule. En terme de consommation énergétique et de performance, les deux sont complétement opposées : ré-exécution maximale couteuse en performance et énergie d'un côté, ré-exécution minimale et peu couteuse en énergie/performance de l'autre. Mais elles ont un point commun : dans les deux cas précédents, les exceptions deviennent alors imprécises. Une autre solution, plus compatible avec le modèle familier aux programmeurs, ajoute un support des exceptions précises. L'idée est d'invalider uniquement les instructions qui suivent l'exception dans l'ordre du programme. Pour cela, le compilateur doit ajouter quelques informations sur l'ordre des instructions dans le faisceau. Une solution intermédiaire invalide seulement les instructions qui ont une dépendance avec celle qui a levé l'exception. On a alors des exceptions semi-précises, mais les performances sont meilleures vu qu'on n'a pas à ré-exécuter beaucoup d'instructions. ===Le compilateur a un rôle primordial sur les architectures VLIW=== Les architectures VLIW ont des avantages certains : hardware très simple, émission multiple supportée nativement, etc. Mais elles ont aussi divers problèmes, comme une faible compatibilité, des performances limitées par les dépendances existantes à la compilation et une densité de code mauvaise. Tous les avantages et inconvénients majeurs ont la même source : c'est le compilateur qui regroupe plusieurs instructions/opérations en un seul faisceau. Le compilateur garantit que les instructions/opérations regroupées sont indépendantes et peuvent s’exécuter en parallèle. Un avantage à cela est que les processeurs VLIW ont un hardware très simple, avec peu de circuits de contrôle. Le compilateur se charge de vérifier que des opérations indépendantes sont regroupées dans une instruction, pas besoin de hardware pour cela, pas besoin d'une unité d'émission complexe, ni d'exécution dans le désordre. Il y a juste besoin d'un ''scoreboard'' pour gérer les instructions multicycle et les accès mémoire. Alors qu'avec un processeur superscalaire, il y a des circuits de détection des dépendances entre instructions assez complexes et couteux en circuits. L'absence de ces circuits fait que les processeurs VLIW étaient utilisés sur les premières cartes graphiques : autant utiliser les transistors pour placer le plus de circuits de calcul possible au lieu d'en dépenser dans des circuits de contrôle. Mais avec l'évolution de la technologie, il est devenu plus rentable d'ajouter de tels circuits pour gagner en performance, ce qui fait que les cartes graphiques incorporent un ''scoreboard'' ou quelque chose de similaire. Mais pour ce qui est des désavantages, les processeurs VLIW ont une performance très dépendante du compilateur. Le compilateur doit analyser le code source pour détecter les dépendances d'instruction, et faire les regroupements. En soi, analyser les dépendances entre instruction est assez simple pour les compilateurs modernes, qui utilisent une représentation SSA. Mais malgré cela, les compilateurs ne font pas du très bon travail. Il arrive régulièrement que le compilateur ne puisse pas remplir tout le faisceau avec des instructions indépendantes. Sur les anciens processeurs VLIW, les instructions VLIW (les faisceaux) étaient de taille fixe, ce qui forçait le compilateur à remplir d'éventuels vides avec des NOP, diminuant la densité de code. La majorité des processeurs VLIW récents utilise des faisceaux de longueur variable, supprimant ces NOP. Mais il reste le fait que ces NOP sont une sous-exploitation des unités de calcul. De plus, certaines dépendances entre instructions ne peuvent être supprimées, ce qu'un processeur à exécution dans le désordre peut faire. Par exemple, le fait que les accès à la mémoire aient des durées variables (suivant que la donnée soit dans le cache ou la RAM, par exemple) joue sur les différentes dépendances. Un compilateur ne peut pas savoir combien de temps va mettre un accès mémoire et il ne peut organiser les instructions d'un programme en conséquence. Par contre, un processeur le peu. Autre exemple : les dépendances d'instructions dues aux branchements, qui ont tendance à limiter fortement les possibilités d'optimisation du compilateur. Autre défaut : les processeurs VLIW n'ont strictement aucune compatibilité, ou alors celle-ci est très limitée. En effet, le format des faisceaux VLIW est spécifique à un processeur. Celui-ci va dire : telle instruction va sur telle ALU, et pas ailleurs. Mais si on rajoute des unités de calcul dans une nouvelle version du processeur, il faudra recompiler notre programme pour que celui-ci puisse l'utiliser, voire simplement faire fonctionner notre programme. Dans des situations dans lesquelles on se moque de la compatibilité, cela ne pose aucun problème : par exemple, on utilise beaucoup les processeurs VLIW dans l'embarqué. Mais pour un ordinateur de bureau, c'est autre chose... ===Un petit historique des processeurs VLIW=== Les processeurs VLIW sont une idée assez ancienne, qui a ses sources dans un algorithme conçu à base pour l'implémentation d'un microcode horizontal ! Dans les années 80, Joseph Fisher travaillait sur l'architecture du CDC-6600, un super-ordinateur dont le processeur avait un microcode horizontal. Et le microcode horizontal a justement des ressemblances avec les processeurs VLIW. Frustré par les difficultés à coder un microcode sur ce genre de machines, il chercha un algorithme qui part d'une séquence de micro-opérations, et qui les regroupe dans des micro-opérations horizontales. L'algorithme qui naquit de ces recherches est appelé l''''algorithme de ''trace scheduling'''''. L'algorithme était capable de regrouper des opérations isolées dans un paquet encodé avec une seule micro-opération. Quelques architectures similaires au VLIW existaient déjà à l'époque, mais étaient prévues pour être codées en assembleur. L'invention de cet algorithme rendit ces architectures bien plus utiles. Le parallélisme exposé dans les micro-instructions horizontales est similaire à celui exposé par les architectures VLIW. Il était alors possible de prendre un compilateur, d'exécuter un algorithme de ''trace scheduling'' sur les instructions, et de se retrouver avec un programme encodant directement les des faisceaux VLIW. Fisher participa alors à un projet de recherche visant à créer un processeur VLIW, nommé le ELI-512 (''Extremely Long Instruction''-512), dont les instructions faisaient 512 bits et encodaient jusqu’à 30 instructions machines. Par la suite, il créa l'entreprise Multiflow, qui batit les premiers processeurs VLIW commerciaux, les bien nommés processeurs Multiflow. L'entreprise Cydrome tenta de leur faire concurrence. Mais les deux entreprises firent faillite au bout de quelques années. Quelques tentatives récentes de faire revenir ces architectures sur le devant de la scène ont été des échecs. Citons le cas de l'architecture Itanium d'Intel, ainsi que les processeurs Crusoe de l'entreprise Transmetta. Les deux visaient à remplacer le jeu d'instruction x86 des PC modernes, objectifs très compliqué et sans doute voué à l'échec du fait des problèmes de compatibilité intrinsèques. Transmetta a pourtant pris le problème à bras le corps, avec des techniques de traduction x86-VLIW très performantes, mais purement logicielles. Mais rien à faire, les processeurs VLIW n'ont percé que dans des domaines où la compatibilité avec le code existant n'est pas nécessaire, l'embarqué étant le meilleur d'entre eux. Les processeurs VLIW ont été autrefois utilisés dans les cartes graphiques AMD et possiblement NVIDIA de l'époque de DirextX 8/9. Mais tout cela est le sujet d'un autre wikilivre... ==Les instructions multicyles et le pipeline sur les CPU VLIW== La discussion précédente partait du principe que le processeur VLIW n'avait pas de pipeline. L'implémentation du VLIW sur un pipeline demande cependant quelques modifications. Le cas le plus simple est celui d'un pipeline de longueur fixe, où toutes les opérations s'exécutent en un seul cycle d'horloge. Sans système de contournement, l'usage du pîpeline fait qu'il y a un délai entre le moment où une opération lit ses opérandes dans les registres et celui où son résultat est enregistré dans les registres. Quelques cycles d'horloges, tout au plus, mais il s'agit d'un délai qui fait que le résultat d'une opération est enregistré en retard. Or, un processeur pipeliné démarre un nouveau faisceau à chaque cycle d'horloge. Ce qui pose des problèmes si deux faisceaux ont des dépendances de données. Si on lance ces deux faisceaux l'un à la suite de l'autre, le second faisceau ne lira pas le résultat calculé par le premier. On retombe sur les problèmes liés à l'émission dans l'ordre/désordre, que les architectures VLIW souhaitaient éviter. ===Les mitigations et solutions=== Il est possible de mitiger le tout dans le cas d'un pipeline de longueur fixe, où toutes les instructions s'exécutent en un cycle d'horloge. Pour cela, il suffit d'utiliser un système de contournement, pour envoyer les résultats calculés par l'ALU sur son entrée, afin de mieux gérer les dépendances de données de type RAW. Sans cela, deux faisceaux avec une dépendance ne peuvent pas être démarrés l'un après l'autre. Mais la solution ne résout le problème que si toutes les instructions/opérations s'exécutent en cycle d'horloge au niveau de l'ALU. Dans les faits, la solution ne marche pas pour les instructions multicycles, dont les accès mémoire. Une solution à cela serait de vérifier les dépendances entre faisceaux avec un ''scoreboard'' ou un circuit similaire. En cas de dépendances entre les faisceaux, l'exécution ''lockstep'' des instructions fait qu'on doit ajouter des bulles de pipeline. Le problème est que l'on ne profite pas du pipeline, sauf à utiliser un système de contournement complexe. De plus, cela demanderait d'améliorer l'unité d'émission et d'ajouter du hardware pour cela, ce qui colle mal à la philosophie du VLIW. Mais c'est l'une des seule solution possible pour gérer les accès mémoire. Une autre solution est de laisser le travail au compilateur, qui gère de lui-même les latences des instructions liées au pipeline. Le compilateur doit donc produire un code machine spécifique à un processeur, qui prend en compte la longueur du pipeline. La solution marche pas trop mal pour les instructions multicycles exécutées dans une ALU, mais pas trop pour les accès mémoire. Pour les instructions multicycles, le compilateur connait la latence de chaque opération et s’arrange pour que le code compilé n'aie pas de problèmes. Par exemple, si une unité de calcul multicycle est utilisée pendant 5 cycles, elle s'arrange pour que les 5 faisceaux suivants ne l'utilisent pas. Le compilateur doit gérer les latences pour chaque opération d'un faisceau, vérifier que des faisceaux consécutifs soient compatibles, et ainsi de suite. ===Les modèles de latence d'instruction=== Peu importe que l'on utilise un ''scoreboard'' ou le compilateur, la latence des instruction a un grand impact dans la manière dont on exécute/compile le code. Le processeur connait la latence maximale d'une instruction multicycle, sauf éventuellement pour les accès mémoire. Idéalement, le compilateur fonctionne mieux avec des latences fixes, mais il peut avoir à se débrouiller avec des latences variables. Les deux cas donnent des résultats très différents pour le compilateur, et ils sont deux modèles différents, qui doivent être pris en compte à la création du processeur. Avec le premier modèle, la latence des instructions est fixe, à savoir qu'elles prennent toujours le même nombre de cycles pour s'exécuter. Si une instruction de multiplication prend 5 cycles, elle prendra toujours 5 cycles, pas un de moins. Avec ce modèle, le compilateur a plus de facilités à gérer les registres. Par contre, la gestion des exceptions précises est particulièrement complexe. Le processeur doit être conçu pour. Il se peut que l'instruction finisse avant dans l'unité de calcul, mais l'écriture dans les registres sera alors retardée pour respecter la latence fixée. Le chemin de données du processeur doit être conçu en conséquence et doit mettre en attente les résultats disponibles à l'avance. Le second modèle permet à une instruction multicycle de finir plus tôt que prévu, leur latence est variable. Par exemple, une instruction de multiplication a une latence maximale de 5 cycles, mais il se peut qu'elle prennent 4/3 cycles si les opérandes sont particulières. Le compilateur a un peu plus de mal avec ce genre de modèle. Mais l'implémentation des exceptions précises est bien plus simple. De plus, cela améliore un petit peu la compatibilité dans certains cas. Si le nouveau modèle du processeur a une latence inférieure pour certaines instructions, le code conçu pour l'ancien processeur' marchera toujours, et ses performances seront améliorées. ==Les processeurs VLIW à instruction de longueur variable== Les processeurs VLIW purs ont de nombreux défauts, mais cela ne signifie pas qu'ils n'ont pas de solutions. Cependant, ces solutions sont un peu opposées à la philosophie de base des processeurs VLIW : réduire au maximum le hardware lié au décodage et aux unités d'émission, tout an gardant l'émission multiple. Les solutions en question sont multiples, mais elle partent du même principe : elle encodent des faisceaux de taille variable. Par taille variable, on veut dire que les faisceaux ont un nombre d'instructions/opérations variables, qui peut varier d'un faisceau à l'autre. Par exemple, prenons un processeur VLIW codant ses faisceaux sur une taille fixe de 512 bits. En passant à des faisceaux de taille variable, les faisceaux peuvent faire entre 64 et 512 bits, par exemple. Pour cela, les NOPs, les instructions qui ne font rien, sont retirées du faisceau ou sont encodées de manière compressée. Et cela implique que le décodage des instructions et leur émission est plus complexe, elle demande plus de circuits. La densité de code est grandement améliorée, de même que la compatibilité. Les faisceaux ont donc une taille variable, comprise entre une taille minimale et une taille maximale. La taille maximale est obtenue quand aucun NOP n'est présent dans le faisceau. Par contre, le processeur doit pouvoir charger un faisceau complet. Par exemple, pour un processeur dont les faisceaux font entre 64 et 512 bits, le processeur charge 512 bits en une fois, en un accès au cache d'instruction. Il faut donc faire la distinction entre les faisceaux et les '''paquets de chargement'''. Un paquet de chargement dans l'exemple précédent fait 512 bits, mais les faisceaux en font entre 64 et 512. L'avantage principal est uen amélioration de la densité de code : pas besoin d'insérer des NOPs à l'intérieur des faisceaux, même si on peut avoir besoin d'insérer des NOPs de bourrage. Avec un faisceau de taille variable, il n'y a pas besoin d'ajouter des NOPs si le faisceau est partiellement remplit, on a juste à raccourcir le faisceau. Le défaut est que le décodage et le chargement des instructions est plus complexe. Les faisceaux n'ayant plus une taille fixe, il faut utiliser les techniques de chargement/décodage des instructions variables vues dans le chapitre sur l'unité de chargement. Cela fait du hardware en plus, un cout en performance et en énergie. Tout ce que la philosophie VLIW voulait éviter. En contrepartie, le gain en densité de code est important et le gain de compatibilité binaire est très important. ===La délimitation des faisceaux=== Un paquet de chargement peut contenir plusieurs faisceaux. Lorsqu'un paquet est chargé, il est découpé en faisceaux au fur et à mesure de l'exécution. Le processeur exécute les faisceaux les uns après les autres. Par exemple, si toutes les instructions d'un paquet doivent s’exécuter en série, il les exécute une après l'autre, et ne charge le paquet suivant qu'une fois que toutes les instructions du paquet sont terminées. Reste que pour cela, il faut trouver un moyen pour découper un paquet de chargement en plusieurs faisceaux. Pour cela, il existe plusieurs techniques, avec d'autres techniques dérivées. La première technique ajoute des '''méta-données''' au début du faisceau, à savoir quelques bits qui fournissent des informations sur le faisceau. Et parmi ces information, on peut intégrer la taille du faisceau, sa longueur en nombre d'octets ou d'instructions. Le processeur a juste à extraire la longueur du faisceau et sait comment découper le paquet de chargement en faisceaux. Les faisceaux sont donc placés à la suite les uns des autres en mémoire, mais sont précédés par un octet de méta-données qui indique la longueur du faisceau, comment sont organisées les instructions/opérations dedans, etc. Un défaut est que la taille d'un faisceau est limitée par le nombre de bits utilisés pour encoder les méta-données. La méthode peut être étendue pour gérer autre chose que la taille des faisceaux, comme on le verra plus bas. En effet, les méta-données ne se bornent pas à donner la longueur du faisceau, mais peuvent indiquer comment sont organisées les instructions dans le faisceau, ou toute autre information utile. A défaut d'intégrer la longueur du faisceau dans l'instruction, on peut intégrer des informations qui permettent de la calculer. Par exemple, nous verrons plus bas la technique du masque de NOPs qui permet de calculer la longueur de l'instruction sur la base d'informations utilisées pour autre chose. Les deux techniques suivantes dispersent des méta-données entre chaque instruction, au lieu de les regrouper dans un octet au début du faisceau. Pour ce faire, elles ajoutent des bits à la fin de chaque instruction, qui précisent s'il s'agit de la dernière instruction d'un faisceau. La solution est plus flexible, car les faisceaux peuvent avoir des tailles arbitraires, en théorie. Avec la première technique, chaque instruction est suivie d'un '''bit d’arrêt''' (stop bits) qui indique la fin d'un faisceau. [[File:Bits d’arrêt.png|centre|vignette|upright=2|Bits d’arrêt.]] Avec la seconde méthode, chaque instruction/opération est suivie d'un un '''bit de parallélisme''' (''parallel bit''), un bit placé à la fin d'une instruction qui dit si elle peut s'effectuer ou non en parallèle de la suivante. [[File:Bit de parallélisme.png|centre|vignette|upright=2|Bit de parallélisme.]] Un exemple est celui des processeurs Texas Instrument de série C6X, notamment le TMS320C62 (C62) et le TMS320C67 (C67). Sur ces processeurs, un paquet de chargement fait 256 bits et les paquets sont eux-même alignés en mémoire sur 256 bits. Les instructions font 32 bits, ce qui fait qu'on peut en mettre 8 par paquet de chargement. Les faisceaux sont indiqués avec des bits de parallélisme. ===L'attribution des instructions/opérations aux ALUs=== Le passage à des faisceaux de taille variable fait que l'on perd la correspondance unique entre une instruction/opération et une unité de calcul. Le fait que les NOPs sont devenus implicites fait que l'on doit répartir les opérations sur les différentes unités de calcul. Il faut déterminer quelle instruction/opération va sur tel unité de calcul. Les solutions pour cela sont assez variables, allant d'un codage des NOPs compressé avec un encodage explicite des NOPs, à des systèmes avec un encodage implicite. Une solution est de se débarrasser de l'encodage par position utilisé plus haut, où chaque instruction était attribuée à une ALU en fonction de sa place dans le faisceau. A la palce, on le remplace par l''''encodage par nommage''', où chaque instruction d'un faisceau précise l'unité de calcul qui doit la prendre en charge. L'instruction contient un numéro qui indique l'unité de calcul à utiliser. Cette technique est déclinée en deux formes : soit on trouve un identifiant d'ALU par instruction, soit on utilise un identifiant pour tout le faisceau, qui permet à lui seul de déterminer l'unité associée à chaque instruction. Une autre solution conserve l'encodage par position vu précédemment, mais représente les NOPS sous forme compressée. Les instructions/opérations sont donc toujours à la bonne place, à la bonne position dans le faisceau, mais les NOPs sont compressés et réduits à une taille plus petite. Le décodage doit alors juste identifier les NOPs et les étendre, de manière à retrouver le faisceau d'instruction non-compressé. Une solution pour cela est d'incorporer, dans le faisceau, un masque qui indique où sont les NOPs dans le faisceau. La technique porte le nom de '''masque de NOP'''. Prenons l'exemple d'un faisceau contenant 8 instructions/opérations maximum. Si j'ai par exemple 6 positions remplies avec deux NOPs, le faisceau contiendra 6 instructions seulement, les NOPs ne seront pas placés dans le faisceau. Par contre, le masque indiquera où sont les NOPs dans le faisceau, où il faut les placer. Le masque contient un bit par instruction, par ''slot'', par position dans le faisceau. Le premier bit est pour la première instruction, le second bit pour la seconde instruction, etc. Le bit est mis à 1 si l'instruction est un NOP, 0 sinon. Les NOPs ne sont donc pas représentés. {|class="wikitable" |- ! colspan="8" | Instruction décompréssée | ADD || MUL || class="f_jaune" | NOP || SUB || class="f_jaune" | NOP || class="f_jaune" | NOP || class="f_jaune" | NOP || LOAD |- ! colspan="8" | Masque de NOP | 0 || 0 || 1 || 0 || 1 || 1 || 1 || 0 |- ! colspan="8" | Instruction compressée | ADD || MUL || SUB || LOAD || 0010 1110 || colspan="3" | |} Le masque est généralement placé au tout début du faisceau, pour faciliter le décodage. Il faut noter que cette technique permet de compresser les faisceaux qui contiennent au moins un NOP. Mais les autres sont allongés d'un octet pour stocker le masque. La technique est donc d'autant plus efficace que le code contient de NOPs, ce qui fait qu'elle est surtout utile sur les CPU VLIW avec beaucoup de ''slots'', qui ont beaucoup d’instructions par faisceau, qui ont plus de difficultés à remplir leurs faisceaux. Il faut préciser qu'avec cette technique, l'encodage de la longueur de l'instruction n'est pas nécessaire. Déterminer la longueur de l'instruction se fait simplement à partir du masque. Il suffit de l'envoyer en opérande d'un encodeur, qui renvoie la longueur du faisceau. La solution a pour avantage de ne pas ajouter d'informations en plus dans le faisceau. Pour implémenter la technique, l'instruction est décompressée lors du chargement de l'instruction depuis le cache L1. Un circuit prend en entrée un paquet de chargement, et l'analyse, puis décompresse le faisceau en ajoutant les NOPs au bon endroit. L'avantage est que les instructions sont compressées dans le cache, ce qui utilise au mieux sa capacité. Une autre solution déplace le circuit de décompression avant le cache. Les faisceaux sont alors décompressés lors de leur chargement dans le cache d’instruction L1. ===L'alignement des faisceaux dans un paquet de chargement=== Un point important est que les faisceaux peuvent ou non être à cheval sur deux paquets de chargement. Certains processeurs ne le permettent pas. C'est le cas sur les processeurs Texas Instrument de série C6X, où un faisceau doit rentrer intégralement dans le paquet. Ainsi, le bit de parallélisme est à 0 toutes les 8 instructions, vu qu'un paquet de chargement fait 8 instructions. Un inconvénient est que cela peut parfois laisser des espaces vides à la fin d'un paquet d'instruction. Le compilateur essaye de faire rentrer le plus de faisceaux possibles dans un paquet de chargement, mais il arrive malgré tout qu'il reste de la place. Par exemple, pour un paquet de 8 instructions, imaginons qu'on ait un faisceau de 4 instructions, un autre de 3, et un autre de deux. Les deux premiers faisceaux rentrent dans le paquet, mais pas le troisième. Il manquera une instruction pour compléter, le vide est comblé par une instruction NOP qui ne fait rien. Le ou les NOP en question, sont appelés des '''NOP de bourrage'''. La compatibilité est très mauvaise avec les bits de bourrage, vu que cela limite la taille du paquet de chargement. De plus, cela réduit les opportunités de compression. On gagne en densité de code si et seulement si on peut mettre plusieurs faisceaux dans un seul paquet de chargement. L'idéal est d'utiliser des paquets de chargement assez grands, pour pouvoir mettre pleins de petits faisceaux dedans. Mais d'autres processeurs autorisent un système différent, où un faisceau peut être à cheval sur un paquet de chargement. Le chargement se fait paquet de chargement par paquet de chargement, avec un découpage des faisceaux lors du décodage. Les techniques utilisées pour cela sont similaires à celles utilisées pour le chargement des instructions de longueur variable. L'avantage est que cela permet de ne pas avoir à ajouter des NOPs de bourrage. Mais le cout en circuits est conséquent. La technique est notamment utilisée sur l'Itanium, comme on le verra plus bas. Les techniques pour charger des faisceaux non-alignées demandent d'accumuler les paquets de chargement dans une mémoire temporaire, et de le insérer au bon endroit, à la suite des faisceaux déjà chargés. Le tout implique des circuits de décalage et autres. Et pour qu'ils fonctionnent, ils doivent connaitre la longueur d'un faisceau, afin de décaler du bon nombre de rangs/instructions. Déterminer la longueur d'un faisceau dépend grandement de la méthode employée pour délimiter les faisceaux. Avec un octet de méta-donnée, la longueur peut être intégrée directement dans les méta-données du faisceau et n'a pas à être déterminée. Avec l'usage de bits d'arrêt ou de parallélisme, on peut la calculer sur la base de ces bits. Il suffit d'envoyer ces bits dans un circuit encodeur qui renvoie la longueur du faisceau, exprimée en nombre d'instructions. Avec un masque pour encoder les NOPs, l'encodage de la longueur se détermine à partir du masque de NOP. Là encore, il suffit de le faire passer dans un circuit qui prend le masque de NOP et détermine la longueur du faisceau. Le circuit n'est ni plus ni moins qu'un circuit de ''population count''. ===L'encodage des faisceaux sur l'Itanium=== L'Itanium utilise un mélange des deux méthodes. Elle regroupe les instructions dans des paquets de trois instructions appelés des ''bundles''. N'allez pas croire que ces ''bundles'' sont des paquets de chargement : les paquets de chargement contiennent 2 ''bundles'', voire plus. Les ''bundles'' ne peuvent pas regrouper n'importe quel triplet d'instructions, mais seulement certains regroupements prévus à l'avance. Encore une fois, il y a des contraintes sur les regroupements d'instructions dans un ''bundle''. Il y a en tout 32 possibilités, qui définissent chacune un mix d'instructions mémoire, entières, flottantes et de branchement. Les trois instructions d'un ''bundle'' sont associées à un '''octet de ''template''''', qui encode les dépendances entre instructions et qui dit comment découper le paquet de chargement en faisceaux. Il regroupe notamment les trois bits d'arrêt des trois instructions, et 5 bits annexes qui encodent la nature des instructions présentes dans le paquet. J'ai dit plus haut qu'il y a 32 possibilités pour les regroupements possibles, et bien on peut coder les 32 possibilités sur 5 bits. Le tout est codé sur 128 bits, avec 40 bits pour chaque instruction, plus l'octet de ''template''. [[File:Encodage des stop bits sur l'Itanium.png|centre|vignette|upright=3|Encodage des stop bits sur l'Itanium]] Un faisceau peut être intégralement contenu dans un ''bundle'' s'il fait trois instructions ou moins. Dans le cas contraire, il est répartit sur plusieurs ''bundles'' et c'est au processeur de reconstituer le faisceau à partir de plusieurs ''bundles''. L'usage de bits d'arrêt permet à un faisceau d'être à cheval sur deux ''bundles'' et éventuellement sur deux paquets de chargement. Les paquets de chargement chargent 2 ''bundles'' à la fois sur l'Itanium 1 et 2. De plus, les paquets de chargement sont accumulées dans un tampon de 8 ''bundles'' (24 instructions). Le tampon s'assure que deux ''bundles'' soient disponibles pour les unités de décodages, vu que le processeur a la capacité d'exécuter 2 ''bundles'' à la fois dans ses unités de calcul. L'unité de décodage se charge de reconstituer des faisceaux d'instruction en utilisant les octets de ''template'' des ''bundles'' chargés, en analysant les bits d'arrêts et les bits qui encodent le regroupement des instructions. Avec la technique utilisée sur l'Itanium, la compatibilité binaire est très bonne. Il est possible de changer les unités de calcul du processeur sans que la compatibilité binaire soit altérée. D'ailleurs, l'Itanium n'a lui-même pas de correspondance entre un ''bundle'' et ses unités de calcul, vu qu'il a 6 unités de calcul/mémoire, pour des ''bundles'' de trois instructions. Il pourrait en rajouter ou en enlever sans que ce soit un problème, il faudrait juste adapter le décodeur. ==Les processeurs hybrides VLIW/RISC== Il existe des processeurs hybrides entre processeurs RISC et VLIW. L'idée est que ces processeurs gérent à la fois des instructions machines isolées, et des faisceaux d'instructions. Il en existe assez peu, mais il est globalement possible de quand même les classer en deux sous-types. Le premier est celui où il est possible de mélanger instructions RISC et VLIW sans véritables contraintes. L'autre est celui où le processeur a deux modes de fonctionnement :un mode RISC, et un mode VLIW, les deux étant totalement séparés et ne pouvant pas être mixés. ===Les CPU RISC/VLIW à instructions mixées=== L'encodage des instructions autorise des instructions isolées, parfois complétées par des faisceaux d'instructions à l'encodage spécifique. Le processeur exécute un programme qui mélange instructions normales et faisceaux VLIW librement. Un exemple est celui des processeurs Xtensa LX2 de Tensilica. Ils appelent cette technique sous le nom de ''Flexible Length Instruction eXtensions'' (FLIX). Le nom trahit bien que les instructions VLIW sont une extension d'un jeu d'instruction normal, au même titre que les extensions MMX/SSE/x87 et autres du jeu d'instruction x86 s'ajoutent aux instructions x86 normales. Les instructions normales font entre 16 et 24 bits, alors que les faisceaux font soit 32, soit 64 bits. Ils peuvent donc regrouper 2 à 4 instructions normales. Les processeurs DSP Infineon Carmel utilise une technique similaire. Ils sont des CPU RISC dont les instructions de base font 48 bits. Ils disposent aussi d'instructions courtes codées sur 24 bits. Et enfin, l'extension ''Configurable Long Instruction Word'' (''CLIW'') permet de gérer des faisceaux VLIW qui regroupent six instructions machines. Vu que le processeur charge 48 bits d'un seul coup, il peut exécuter une à deux instructions courtes d'un seul coup, ce qui en fait une forme basique de VLIW, complémentaire du CLIW. ===Les CPU RISC/VLIW à deux modes de fonctionnement=== Le processeur Intel i860 utilise une méthode différente. Le processeur peut fonctionner en mode RISC normal, ou en mode VLIW. En mode RISC, il peut exécuter des instructions entières ou flottante, les deux étant codées sur 32 bits. En mode VLIW, il exécute des faisceaux VLIW codés sur 64 bits. L'implémentation du VLIW profite de la présence d'une unité FPU séparée de l'ALU entière. En mode VLIW, les faisceaux d'instruction sont composés en concaténant une instruction entière et une instruction flottante, le tout faisant 64 bits. Les faisceaux sont alignées sur 64 bits, la première instruction est forcément entière, la seconde est une instruction flottante. L'instruction flottante peut faire une multiplication et une addition à la suite, vu qu'on a un additionneur flottant relié à un multiplieur flottant. Les deux circuits ont une interconnexion partiellement programmable, afin de faciliter l'implémentation de certaines instructions flottantes. ==Les processeurs EPIC== En 1997, Intel et HP lancèrent un nouveau processeur, l'Itanium, dont l'architecture corrigeait les défauts des autres processeurs VLIW. Dans un but marketing évident, Intel et HP prétendirent que l'architecture de ce processeur était une nouvelle classe d'architecture, appelée ''EPIC'' pour '''''Explicit Parallelism Instruction Computing'''''. Mais il faut avouer que cette architecture était juste du VLIW amélioré. De plus, les améliorations apportées par les processeurs Itanium étaient reprises d'autres architectures, ou ont été appliqués sur d'autres processeurs VLIW ultérieurs. Les processeurs de la société Transmetta sont un exemple. Et nous allons les voir dans ce qui suit. Une architecture EPIC implémente des faisceaux d'instruction de taille variable, mais aussi des techniques de spéculation avancées pour les exceptions matérielles, les branchements, ou les accès mémoire. Vu qu'il s'agit de techniques spéculatives, on s'attend à ce que ce soit le processeur qui soit en charge de ces spéculations, mais la réalité est qu'elles sont une collaboration entre CPU et compilateur ! ===Les exceptions différées=== L'Itanium implémente ce qu'on appelle les '''exceptions différées'''. Avec cette technique, la gestion des exceptions est optimisée avec un système de pseudo-transactions. Le code est exécuté par blocs d'instruction, délimités par des branchements ou tout autre limite/barrière pertinente dans le code. L'idée est qu'un morceau de code s'exécute d'un seul coup, et que la détection des exceptions se fait à la toute fin. Si aucune exception n'a été levée, les résultats du bloc de code sont définitivement acceptés. Mais si une exception est levée, tout est annulé et on ré-éxecute le code d'une manière qui permette de gérer l'exception. La méthode implémente deux types d'instructions : des instructions spéculatives, et des instructions non-spéculatives. Les instructions non-spéculatives lévent des exceptions normalement, si la situation l'exige. Les instructions spéculatives ne font pas cela. A la place, elles mémorisent qu'elles ont levée une exception, mais sans l'exécuter. Tout bloc de code sont donc compilés en deux versions : une version avec uniquement des instructions spéculatives, une avec des instructions normales. La version avec instructions spéculatives s'exécute, et on vérifie si une exception a eu lieu à la fin du bloc de code. Si une exception a eu lieu, on annule tout ce qu'a fait le bloc de code, et on exécute la version non-spéculative du bloc de code. Pour vérifier l’occurrence d'une exception, chaque registre est associé à un bit ''Exception'', qui est mis à 1 si l'instruction qui a écrit dans ce registre aurait dû lever une exception. Si une instruction lève une exception, elle écrira un résultat faux dans un registre et le bit ''Exception'' est mis à 1. Les autres instructions propageront ce bit ''Exception'' dans leurs résultats : si un opérande est ''Exception'', le résultat de l'instruction l'est aussi. Une fois le code fini, il suffit d'utiliser une instruction qui teste l'ensemble des bits « rien du tout » et agit en conséquence. Dans la terminologie des processeurs Itanium, ce bit est appelé « rien du tout » (''not a thing''), et est mis à 1 s'il contient une valeur invalide. Son usage est plus général et il ne sert pas qu'à détecter les exceptions, mais aussi des violations de dépendances mémoire, comme on le verra dans la section suivante. De plus, il n'existe que pour les registres entiers. Pour les registres flottants, une solution alternative est utilisée : ils remplacent la valeur dans le registre flottant par une valeur NaTVal, qui est une sorte de NaN spécialisé dans les exceptions. Pour annuler ce qu'à fait un bloc de code après une exception, le processeur doit mémoriser l'état du processeur avant de démarrer un morceau de code. Pour cela, le processeur sauvegarde les registres du processeur dans des ''shadow registers'', avant d'exécuter un bloc de code, afin que le processeur puisse revenir à l'état de base. Lorsqu'un bloc de code termine son exécution, il exécute une instruction CHECK qui vérifie les bits Exception des registres. Si une exception a eu lieu, l'instruction CHECK branche vers la version non-spéculative du bloc de code. Sinon, elle se comporte comme un simple NOP. ===La spéculation sur les lectures=== L'Itanium fournit une fonctionnalité similaire pour les lectures, qui vise à les déplacer le plus tot possible dans le code. En théorie, le compilateur peut déplacer les lectures, mais il doit respecter les dépendances de données. De nombreuses opportunités sont alors perdues, car le compilateur doit prendre des décisions assez conservatrices. Il ne peut pas déplacer une lecture avant une écriture s'il ne connait pas les adresses à l'avance, quand elles sont calculées à l'exécution. Mais les processeurs EPCI ajoutent de quoi spéculer sur les dépendances mémoire dans le code. L'idée est d'exécuter les lectures en avance, de détecter les violations de dépendances mémoire à l'exécution et de les corriger quand elles surviennent. Pour cela, on réutilise la machinerie des exceptions différées vue juste avant. Le code est compilé dans une version optimisée où les lectures sont déplacées sans tenir compte des dépendances de données, avec un code de secours non-optimisé. Le processeur vérifie si la spéculation s'est bien passée, avant de décider de passer sur le code de secours ou non. Et la vérification est assez simple : elle est considérée comme une exception matérielle différée. Si une lecture viole une dépendance mémoire, le registre de destination contient une donnée invalide, son bit « rien du tout » est mis à 1, etc. Pour détecter les violations de dépendance, le processeur maintient une liste des lectures émises dans un cache : la '''table des adresses lues en avance''', appelée en anglais ''advanced load address table'' (ALAT). Cette ALAT stocke l'adresse, la longueur de la donnée lue, le registre de destination, etc. Toute écriture consulte l'ALAT, pour vérifier si une lecture à la même adresse est dans l'ALAT. Si c'est le cas, une dépendance a été violée, la lecture est retirée de l'ALAT, le bit « rien du tout » du registre de destination est mis à 1. La vérification ne se fait pas de la même façon selon que la lecture ait été déplacée avant un branchement ou avant une écriture. Les deux situations ne sont pas équivalentes. Déplacer une lecture avant une écriture implique une violation de dépendance mémoire. Mais ce n'est pas le cas si on déplace une lecture avant un branchement. Le branchement pourrait en effet brancher vers un morceau de code où cette lecture ne s'exécute pas. Typiquement, le cas est celui où un IF...ELSE a une de ses deux branches qui exécute une lecture et pas l'autre. Dans ce cas, le compilateur déplace la lecture avant le branchement, de manière spéculative. Dans ce cas, on exécute une lecture avant de savoir si elle va réellement être exécutée. Il se peut que la lecture soit exécutée à tord, mais il n'y a pas de violation de dépendance mémoire proprement dit. [[File:Spéculation sur les lectures.png|centre|vignette|upright=2|Spéculation sur les lectures.]] Les deux cas sont traités à part en matériel, l'un impliquant l'ALAT et les mécanismes de désambiguïsation mémoire, l'autre demandant de vérifier si un branchement a pris la bonne voie d'exécution. Les mécanisme matériels pour cela ne sont donc pas les mêmes. * Si on passe une lecture avant un branchement, la lecture et la vérification sont effectuées par les instructions LD.S et CHK.S. Si une dépendance est violée, le processeur lève une exception différée : le bit « rien du tout » du registre contenant la donnée lue est alors mis à 1. CHK.S ne fait rien d'autre que vérifier ce bit. * Si on passe une lecture avant une écriture, la désambiguïsation de la mémoire est gérée par le compilateur. Tout se passe comme avec les branchements, à part que les instructions sont nommées LD.A et CHK.A. ===Les bancs de registres tournants=== Les processeurs EPIC et VLIW utilisent une forme limitée de renommage de registres pour accélérer certaines boucles. Pour l’expliquer, prenons une boucle simple et intéressons-nous au corps de la boucle, à savoir la boucle sans les branchements et instructions de test qui servent à répéter les instructions. La boucle d'exemple se contente d'ajouter 5 à tous les éléments d'un tableau. L'adresse de l’élément du tableau est stockée dans le registre R2. Dans le code qui suivra, les crochets serviront à indiquer l'utilisation du mode d'adressage indirect. Sans optimisations, le corps de la boucle est le suivant : loop : load R5 [R2] / add 4 R2 ; add 5 R5 ; store [R2] R5 ; Les différentes itérations de la boucle peuvent se calculer en parallèle, vu que les éléments du tableau sont manipulés indépendamment. Mais codée comme dessus, ce n'est pas possible car les trois instructions de la boucle utilisent le registre R5 et ont donc des dépendances. Le renommage de registres peut éliminer ces dépendances, mais il n'est pas disponible sur les processeurs VLIW et EPIC. À la place, les concepteurs de processeurs ont inventé les '''bancs de registres tournants''' (''rotating register files''). Avec cette méthode, la correspondance (nom de registre - registre physique) se décale d'un cran à chaque cycle d’horloge. Par exemple, le registre nommé R0 à un instant donné devient le registre R1 au cycle d'après, et idem pour tous les registres. Précisons que sur l'Itanium, cette technique est appliquée non pas à l'ensemble du banc de registre, mais est limitée à un banc de registres spécialisé dans l’exécution des boucles. Évidemment, le code source du programme doit être modifié pour en tenir compte. Ainsi, le code vu précédemment devient celui-ci. loop : load RB5 [R2] / add 4 R2 ; add 5 RB6 ; store [R2] RB7 ; Ainsi, le LOAD d'une itération ne touchera pas le même registre que le LOAD de l'itération suivante, idem pour l'instruction de calcul et le STORE. Le nom de registre sera le même, mais le fait que les noms de registre se décalent à chaque cycle d'horloge fera que ces noms identiques correspondent à des registres différents. Les dépendances sont supprimées, et le pipeline est utilisé à pleine puissance. Cette technique s'implémente avec un simple compteur, incrémenté à chaque cycle d'horloge, qui mémorise le décalage à appliquer aux noms de registre. À chaque utilisation d'un registre, le contenu de ce compteur est ajouté au nom de registre à accéder. ==Les architectures découplées== Les '''architectures découplées''', aussi appelées '''''Decoupled Access/ExecuteComputer Architectures''''', sont totalement différentes des architectures VLIW. Elles sont bien des architectures à parallélisme d'instruction explicites, mais leur rôle est d'implémenter une forme limitée d’exécution dans le désordre, pas d’exécuter des instructions indépendantes en parallèles. Par contre, elle ont pour point commun avec les architectures VLIW : elles n'encodent pas les dépendances entre instructions de manière explicite dans les instructions, ce que font les architectures ''dataflow''. Les architectures découplées séparent les accès mémoires des autres instructions dans deux programmes qui s’exécutent en parallèle, ce qui permet une forme limitée d’exécution dans le désordre. Le découpage du programme en plusieurs flux s'effectue à la compilation. Les deux flux sont chacun exécutés sur un processeur séparé, un le processeur ''access'' en charge des accès mémoire et un processeur ''execute'' pour les calculs et des branchements. Le transfert des opérandes entre processeurs se fait par l'intermédiaire de mémoires tampons de type FIFOs, manipulées par le programmeur. Les transferts se font dans les deux sens : du processeur ''access'' vers le processeur ''execute'' pour les lectures, dans l'autre sens pour les écritures. Les branchements demandent cependant une synchronisation entre les deux flux d'instruction, afin de garantir qu'un branchement s’exécute bien au même moment dans les deux flux. Et toute la difficulté est de synchroniser les deux processeurs. Cette technique n'est pas compatible avec l’exécution dans le désordre telle que vue dans les chapitres précédents, mais elle en reprend certains avantages. L'avantage principal est que la séparation en deux flux permet de profiter d'une forme limitée d’exécution dans le désordre, mais avec beaucoup moins de structures matérielles, moins de registres, moins de circuits. Les deux flux étant séparés, l'un peut continuer à s’exécuter alors que l'autre est en train d'attendre. Par exemple, le flux de calcul peut continuer à faire des calculs pendant que l'autre est coincé dans un accès mémoire de longue durée. Ou alors, le flux d'accès mémoire peut charger des données à l'avance, pendant que le flux de calcul est bloqué dans un calcul long (une division, par exemple). Cette forme de parallélisme est certes plus limitée que celle permise par l’exécution dans le désordre, mais le principe est là. : Dans ce qui suit, le processeur ''access'' sera noté processeur A, et le processeur ''execute'' sera noté processeur X, pour plus de simplicité. Pour en savoir, voici quelques liens utiles. * [https://course.ece.cmu.edu/~ece740/f13/lib/exe/fetch.php?media=p289-smith.pdf ''Decoupled Access/Execute Computer Architectures''] ===Les échanges de données entre processeurs=== Prenons le cas d'une lecture : la lecture est démarrée par le processeur A, puis est transmise au processeur X. La transmission au processeur X se fait par l'intermédiaire d'une mémoire FIFO, la file de lecture de X (''X load queue'', abréviée XLQ). Quand le processeur X a besoin d'un opérande lu depuis la mémoire, il vérifie si elle est présente dans l'XLQ et se met en attente tant que ce n'est pas le cas. Pour les écritures, l'adresse de l'écriture est calculée par le processeur A, tandis que la donnée à écrire est calculée par le processeur X, et les deux ne sont pas forcément disponibles en même temps. Pour cela, les adresses et les données sont mises en attente dans deux mémoires tampons qui coopèrent entre elles. La première est intégrée dans le processeur A et met en attente les adresses calculées : c'est la file d’écriture des adresses (''store address queue'', ou SAQ). La seconde met en attente les données à lire, et relie le processeur X au processeur A : c'est la file d’écriture de X (''X store queue'', ou XSQ). Quand l'adresse et la donnée sont disponibles en même temps, l'écriture est envoyée à la mémoire ou au cache. Les échanges entre processeurs peuvent aussi se faire sans passer par des files de lecture/écriture. Cela peut servir pour diverses scénarios. Par exemple, le calcul d'une adresse sur le processeur A peut utiliser des données calculées par le processeur X. Pour cela, les échanges s'effectuent par copies inter-processeurs de registres. Un registre peut être copié du processeur A vers le processeur X, ou réciproquement. Pour utiliser cette unité de copie, chaque processeur dispose d'une instruction COPY. ===Les branchements=== Les branchements sont présents à des endroits identiques dans les deux flux, ce qui fait que les structures de contrôle présentes dans un programme sont présentes à l'identique dans l'autre, etc. Or, les branchements doivent donner le même résultat dans les deux processeurs. Pour cela, le résultat d'un branchement sur un processeur doit être transmis à l'autre processeur. Pour cela, on trouve deux files d'attente : * la file A vers X (''access-execute queue'', abréviée AEQ), pour les transferts du processeur A vers le processeur X ; * la file X vers A (''execute-access queue'', abréviée EAQ), pour l'autre sens. Il faut noter que le processeur peut se trouver définitivement bloqué si l'AEQ et l'EAQ sont toutes deux totalement remplies ou totalement vide. Pour éviter cette situation, les compilateurs doivent compiler le code source d'une certaine manière. [[File:752a78c7-71e0-4580-afdd-538Résumé d'une architecture découplée de type access-execute.e30998305.png|centre|vignette|upright=2|Résumé d'une architecture découplée de type access-execute.]] <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Exemples de microarchitectures CPU : le cas du x86 | prevText=Exemples de microarchitectures CPU : le cas du x86 | next=Les architectures dataflow | nextText=Les architectures dataflow }} </noinclude> kukzky6yd6pfemiig0j8kj7asxzqdmz Mathc matrices/a33 0 79819 771184 753117 2026-08-22T08:06:51Z Xhungab 23827 771184 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc matrices (livre)]] [[Mathc_matrices/Sommaire| Sommaire]] === Les utilitaires pour le langage C === : ==== Effacer l'écran est arrêter le programme ==== * [[Mathc matrices/01h|clrscrn(); stop();]] : ==== Arrêter le programme dans une boucle ==== * [[Mathc matrices/Fichiers c : test01b|stop_w();]] : ==== Des valeurs entières aléatoires ==== * [[Mathc matrices/Fichiers c : test01c|r_I(); rp_I(); r0_I(); rp0_I();]] : ==== Des valeurs décimales aléatoires ==== * [[Mathc matrices/Fichiers c : test01e|r_E(); rp_E();]] : ==== Calculer les factorielles ==== * [[Mathc matrices/a75|factorial();]] {{AutoCat}} 5s2rknyzkhawqkoy0ibmb14pe1xzfvr Mathc initiation/a334 0 80765 771196 747844 2026-08-22T11:35:56Z Xhungab 23827 771196 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] : . : [[Mathc initiation/Fichiers h : c44a4| Sommaire]] : . : '''L'étude de ce chapitre peut ce faire à l'aide de cette [[https://youtube.com/playlist?list=PLfb8UL9BmkEg&si=OdGpXH_XuCXaIp0T Playlist]]. : . : [[Utilisateur:Mewtow|'''Mewtow''']] : '''[https://w.wiki/9LKG Les suites et séries --- Wikilivres] Les suites et séries sont des outils mathématiques très utilisés dans de nombreux domaines : algèbre, analyse, statistiques et probabilités, et même en physique ou en économie. Une bonne partie des mathématiques est basée sur ces suites et séries. ''' : . : {{Partie{{{type|}}}|'''L'étude des suites : Arithmétiques. Géométriques.'''}} : {{Partie{{{type|}}}|[[Mathc initiation/a156|* Les suites : Arithmétiques.]]}} : {{Partie{{{type|}}}|[[Mathc initiation/a241|* Les suites : Géométriques.]]}} : . : {{Partie{{{type|}}}|'''L'étude des suites :'''}} : {{Partie{{{type|}}}|[[Mathc initiation/a332|* Dessiner une suite]]}} : {{Partie{{{type|}}}|[[Mathc initiation/a333|* Evaluer une suite définie par récurrence]]}} : {{Partie{{{type|}}}|[[Mathc initiation/a391|* Evaluer une suite définie par récurrence du deuxième ordre]]}} : . : {{Partie{{{type|}}}|'''L'étude des series :'''}} : {{Partie{{{type|}}}|[[Mathc initiation/a335|* Le test de l'intégrale]]}} : {{Partie{{{type|}}}|[[Mathc initiation/a389|* Le test de comparaison des limites]]}} : {{Partie{{{type|}}}|[[Mathc initiation/a494|* Le ratio test]]}} : {{Partie{{{type|}}}|[[Mathc initiation/a384|* Le test des racines]]}} : . : {{Partie{{{type|}}}|[[Mathc initiation/a502|* Présentation des séries alternées]]}} : {{Partie{{{type|}}}|[[Mathc initiation/a507|* Le ratio test pour les séries alternées]]}} : . : {{Partie{{{type|}}}|'''L'étude des series de Fourier:'''}} : {{Partie{{{type|}}}|[[Mathc initiation/a594| Se familiariser avec les series de Fourier]]}} : . : {{AutoCat}} 629q4gndr4yupckztsrotjrnnj3o4m8 Mathc initiation/005v 0 83797 771197 763670 2026-08-22T11:43:16Z Xhungab 23827 771197 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] [[Mathc initiation/Fichiers h : c44a4| Sommaire]] '''L'étude de ce chapitre peut ce faire à l'aide de cette [[https://youtube.com/playlist?list=PLA5FsD7FKx90&si=SPshPmK3mtiqEtoH Playlist]]. : . : {{Partie{{{type|}}}|'''Dérivées partielles. Méthode de Newton (en xy)'''}} {{Partie{{{type|}}}|[[Mathc initiation/Fichiers h : c25|* Dérivées partielles (en xy)]]}} : {{Partie{{{type|}}}|[[Mathc initiation/Fichiers h : c24|* Méthode de Newton (en xy)]]}} : {{Partie{{{type|}}}|[[Mathc initiation/Fichiers h : c26|* Dérivées partielles (en xyz)]]}} {{Partie{{{type|}}}|'''Quelques applications'''}} {{Partie{{{type|}}}|[[Mathc initiation/a511|* Matrice hessienne]]}} : {{Partie{{{type|}}}|[[Mathc initiation/Fichiers h : c74a3|* Local minimum , local maximum , point-selle]]}} : {{Partie{{{type|}}}|[[Mathc initiation/Fichiers h : c66a3|* Calculer les local minimum , local maximum , point-selle]]}} : {{Partie{{{type|}}}|[[Mathc initiation/Fichiers h : c37a2|* Dessiner une tangente sur une fonction f(x,y)]]}} {{AutoCat}} aq6yky914oeql1lxxgpoxyablwauxc9 Mathc initiation/005w 0 83798 771198 763663 2026-08-22T11:49:04Z Xhungab 23827 771198 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] [[Mathc initiation/Fichiers h : c44a4| Sommaire]] '''L'étude de ce chapitre peut ce faire à l'aide de cette [[https://youtube.com/playlist?list=PLCgs7iTLbaGM&si=xTJ9LAJ89Ptr9TwV Playlist]]. : . : {{Partie{{{type|}}}|'''Calculer le gradient et quelques applications'''}} {{Partie{{{type|}}}|[[Mathc initiation/Fichiers h : c27|* Le gradient au point p (en xy et xyz)]]}} {{Partie{{{type|}}}|[[Mathc initiation/Fichiers h : c28|* La dérivée directionnelle (en xy et xyz)]]}} {{Partie{{{type|}}}|[[Mathc initiation/Fichiers h : c29|* Plan tangent en P0 pour une fonction f(x,y,z)]]}} {{Partie{{{type|}}}|[[Mathc initiation/Fichiers h : c24a4|* Dessiner le vecteur orthogonal au point P]]}} {{Partie{{{type|}}}|[[Mathc initiation/a60|* Dessiner le plan orthogonal au point P]]}} {{AutoCat}} iuexxb1k5bl5kp0wfejnvfj8664nxyh Japonais/Vocabulaire/Métiers 0 84463 771161 2026-08-21T17:51:00Z IFroooo 124431 V0.1 Test Vocabulaire métiers santé 771161 wikitext text/x-wiki {| class="wikitable" |+Santé !Français ![[Japonais/Kanji|Kanji]] ![[Japonais/Kana|Kana]] ![[Japonais/Romaji|Rōmaji]] |- |Médecin |医者 |いしゃ |isha |- |Médecin (officiel) |医師 |いし |ishi |- |Dentiste |歯医者 |はいしゃ |haisha |- |Dentiste (titre) |歯科医 |しかい |shikai |- |Infirmier |看護師 |かんごし |kangoshi |- |Pharmacien |薬剤師 |やくざいし |yakuzaishi |- |Vétérinaire |獣医 |じゅうい |jūi |- |Psychologue |心理学者 |しんりがくしゃ |shinrigakusha |- |Sage-femme |助産師 |じょさんし |josanshi |- |Kinésithérapeute |理学療法士 |りがくりょうほうし |rigaku ryōhōshi |} 4oswodagajlm75651ntn2np6q1p4s0n Mathc matrices/0k3 0 84464 771175 2026-08-22T07:44:31Z Xhungab 23827 news 771175 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc matrices (livre)]] [[Mathc_matrices/Sommaire#Espace euclidien| Sommaire]] {{Partie{{{type|}}}| Produits scalaires dans f(x) Version (2)}} Un espace euclidien est un espace vectoriel de dimension finie muni d’un produit scalaire. Nous allons ici introduire un produit scalaire sur deux fonctions (vecteurs). f(x) = cos(x) g(x) = x**2 Le produit scalaire choisi sera : (+PI <f(x),g(x)> = int( f(x) g(x) dx (-PI '''Dans la première version, nous avons utilisé la fonction simpson(), qui ne permettait de ne rentrer qu'une seule fonction en argument. Dans cette nouvelle version nous allons réécrire cette fonction pour qu'elle accepte plusieurs fonctions mais aussi, qu'elle fasse tous les calculs intérmédiaires.''' '''Copier la bibliothèque dans votre répertoire de travail :''' * [[Mathc matrices/0k8|hfile.h ............ Déclaration des fichiers h]] * [[Mathc matrices/0ae|hdx.h ............. Intégrale simple]] * [[Mathc matrices/0ce|ffdot.h ............ Les fontions de bases]] * [[Mathc matrices/0k9|ffdot2.h .......... Les fontions de bases]] * [[Mathc matrices/0f2|fa.h ................ Les fonctions f, g]] {{Partie{{{type|}}}| Produit Scalaire :}} '''Nous allons commencer par calculer les produits scalaires, ce qui nous permettra de déterminer les angles qu'ils existent entre ces fonctions (vecteurs).''' * si <f(x),g(x)> = 0 : f(x) et g(x) sont orthogonaux * if <f(x),g(x)> > 0 : f(x) et g(x) forme un angle aigu (< ) * if <f(x),g(x)> < 0 : f(x) et g(x) forme un angle obtu (\\_) * <f(x),g(x)> ......... : [[Mathc matrices/0k4| c0a1.c ]] {{Partie{{{type|}}}| La norme :}} '''Nous allons calculer les normes des fonctions (vecteurs). Cela nous donnera leurs longueurs.''' * ||f(x)|| .................. : [[Mathc matrices/0k5| c1a1.c ]] {{Partie{{{type|}}}| Calculer la distance entre deux vecteurs :}} * d(f(x),g(x)) ......... : [[Mathc matrices/0k6| c3a1.c ]] {{Partie{{{type|}}}| Calculer l'angle entre deux vecteurs :}} * cos(f(x),g(x)) ......... : [[Mathc matrices/0k7| c4a1.c ]] {{AutoCat}} 8lkgo1hlavix467tnsyes8cgfjagq75 771178 771175 2026-08-22T07:48:37Z Xhungab 23827 771178 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc matrices (livre)]] [[Mathc_matrices/Sommaire#Espace euclidien| Sommaire]] {{Partie{{{type|}}}| Produits scalaires dans f(x) Version (2)}} Un espace euclidien est un espace vectoriel de dimension finie muni d’un produit scalaire. Nous allons ici introduire un produit scalaire sur deux fonctions (vecteurs). f(x) = cos(x) g(x) = x**2 Le produit scalaire choisi sera : (+PI <f(x),g(x)> = int( f(x) g(x) dx (-PI '''Dans la première version, nous avons utilisé la fonction simpson(), qui ne permettait de ne rentrer qu'une seule fonction en argument. Dans cette nouvelle version nous allons réécrire cette fonction pour qu'elle accepte plusieurs fonctions mais aussi, qu'elle fasse tous les calculs intérmédiaires.''' '''Copier la bibliothèque dans votre répertoire de travail :''' * [[Mathc matrices/0k8|hfile.h ............ Déclaration des fichiers h]] * [[Mathc matrices/0ae|hdx.h ............. Intégrale simple]] * [[Mathc matrices/0ce|ffdot.h ............ Les fontions de bases]] * [[Mathc matrices/0k9|ffdot2.h .......... Les fontions de bases]] ... Version (2) * [[Mathc matrices/0f2|fa.h ................ Les fonctions f, g]] {{Partie{{{type|}}}| Produit Scalaire :}} '''Nous allons commencer par calculer les produits scalaires, ce qui nous permettra de déterminer les angles qu'ils existent entre ces fonctions (vecteurs).''' * si <f(x),g(x)> = 0 : f(x) et g(x) sont orthogonaux * if <f(x),g(x)> > 0 : f(x) et g(x) forme un angle aigu (< ) * if <f(x),g(x)> < 0 : f(x) et g(x) forme un angle obtu (\\_) * <f(x),g(x)> ......... : [[Mathc matrices/0k4| c0a1.c ]] {{Partie{{{type|}}}| La norme :}} '''Nous allons calculer les normes des fonctions (vecteurs). Cela nous donnera leurs longueurs.''' * ||f(x)|| .................. : [[Mathc matrices/0k5| c1a1.c ]] {{Partie{{{type|}}}| Calculer la distance entre deux vecteurs :}} * d(f(x),g(x)) ......... : [[Mathc matrices/0k6| c3a1.c ]] {{Partie{{{type|}}}| Calculer l'angle entre deux vecteurs :}} * cos(f(x),g(x)) ......... : [[Mathc matrices/0k7| c4a1.c ]] {{AutoCat}} dgzpq838dy25qr83fe5yh5k2ipggtks Mathc matrices/0k8 0 84465 771176 2026-08-22T07:46:22Z Xhungab 23827 news 771176 wikitext text/x-wiki [[Catégorie:Mathc matrices (livre)]] '''Ce fichier est spécifique pour ce travail. Il ne doit pas être conservé avec la bibliothèque.''' Installer ces fichiers dans votre répertoire de travail. {{Fichier|hfile.h|largeur=70%|info=|icon=Crystal Clear mimetype source h.png}} <syntaxhighlight lang="c"> /* ---------------------------------- */ /* save as hfile.h */ /* ---------------------------------- */ #include <stdio.h> #include <stdlib.h> #include <ctype.h> #include <time.h> #include <math.h> #include <string.h> /* ---------------------------------- */ #include "z_s.h" #include "z_d.h" #include "hdx.h" #include "ffdot.h" #include "ffdot2.h" /* ---------------------------------- */ /* ---------------------------------- */ </syntaxhighlight> [[Mathc matrices/c21r|.]] {{AutoCat}} dfjq7t4zz8ut9z8as3z5fnp1b25t9ls Mathc matrices/0k9 0 84466 771177 2026-08-22T07:47:31Z Xhungab 23827 news 771177 wikitext text/x-wiki [[Catégorie:Mathc matrices (livre)]] '''Ce fichier est spécifique pour ce travail. Il ne doit pas être conservé avec la bibliothèque.''' Installer ces fichiers dans votre répertoire de travail. {{Fichier|ffdot2.h|largeur=70%|info=|icon=Crystal Clear mimetype source h.png}} <syntaxhighlight lang="c"> /* ---------------------------------- */ /* save as ffdot2.h */ /* ---------------------------------- */ double fdot2_R( double (*P_f)(double x), double (*P_g)(double x), double a, double b, int n ) { int i = 0; double m = 0.; double M = 0.; double t = 0.; for(i = 0; i <= n; i++) { if(i ==0 || i== n){m = 1.;} else if(fmod(i,2) == 0){m = 2.;} else {m = 4.;} t = a + i*(b-a)/n; M += m * (*P_f)(t) * (*P_g)(t); } return( ((b -a)*M) / (3*n) ); } /* ---------------------------------- */ /* ---------------------------------- */ double fnorm2_R( double (*P_f)(double x), double a, double b, int n ) { int i = 0; double m = 0.; double M = 0.; double t = 0.; for(i = 0; i <= n; i++) { if(i ==0 || i== n){m = 1.;} else if(fmod(i,2) == 0){m = 2.;} else {m = 4.;} t = a + i*(b-a)/n; M += m * (*P_f)(t) * (*P_f)(t); } return( sqrt( ((b -a)*M)/(3*n) ) ); } /* ---------------------------------- */ /* ---------------------------------- */ double fdist2_R( double (*P_f)(double x), double (*P_g)(double x), double a, double b, int n ) { int i = 0; double m = 0.; double M = 0.; double t = 0.; for(i = 0; i <= n; i++) { if(i ==0 || i== n){m = 1.;} else if(fmod(i,2) == 0){m = 2.;} else {m = 4.;} t = a + i*(b-a)/n; M += m * ((*P_f)(t)-(*P_g)(t)) * ((*P_f)(t)-(*P_g)(t)); } return( sqrt(((b -a)*M)/(3*n)) ); } /* ------------------------------------ */ /* cos(alpha) = <p,q> / ||p|| ||q|| */ /* ------------------------------------ */ double fcos2_R( double (*P_f)(double x), double (*P_g)(double x), double a, double b, int n ) { return(( fdot2_R(P_f,P_g,a,b,n) / (fnorm2_R(P_f,a,b,n) * fnorm2_R(P_g,a,b,n)) )); } /* ------------------------------------ */ /* ------------------------------------ */ </syntaxhighlight> [[Mathc matrices/c21r|.]] {{AutoCat}} 5nnv15ef3ofcphnz6kdbt478rtzykqw Mathc matrices/0k4 0 84467 771179 2026-08-22T07:57:23Z Xhungab 23827 news 771179 wikitext text/x-wiki [[Catégorie:Mathc matrices (livre)]] [[Mathc matrices/0k3| '''Application''']] Installer et compiler ces fichiers dans votre répertoire de travail. {{Fichier|c0a1.c|largeur=70%|info=|icon=Crystal128-source-c.svg}} <syntaxhighlight lang="c"> /* ---------------------------------- */ /* save as c0a1.c */ /* ---------------------------------- */ #include "hfile.h" #include "fa.h" /* ---------------------------------- */ int main(void) { int n = 2*500; double a = -PI; double b = +PI; clrscrn(); printf(" Compute the inner product in the space of function.\n\n" " (+PI \n" " <f(x),g(x)> = int( f(x) g(x) dx \n" " (-PI \n\n" " if <f(x),g(x)> = 0 : f(x) and g(x) are orthogonal\n" " if <f(x),g(x)> > 0 : f(x) and g(x) form the shape of an acute angle (< )\n" " if <f(x),g(x)> < 0 : f(x) and g(x) form the shape of an obtuse angle (\\_)\n\n\n"); printf(" With :\n\n" " f(x) = %s\n" " g(x) = %s\n\n" " (%+.3f\n" " int( (%s) dx = %.6f\n" " (%+.3f\n\n", feq,geq, b, fgeq, simpson(fg,a,b,n), a); printf(" fdot2_R(f,g) = %.6f\n\n", fdot2_R(f,g,a,b,n)); stop(); return 0; } /* ---------------------------------- */ /* ---------------------------------- */ </syntaxhighlight> '''Exemple de sortie écran :''' <syntaxhighlight lang="c"> Compute the inner product in the space of function. (+PI <f(x),g(x)> = int( f(x) g(x) dx (-PI if <f(x),g(x)> = 0 : f(x) and g(x) are orthogonal if <f(x),g(x)> > 0 : f(x) and g(x) form the shape of an acute angle (< ) if <f(x),g(x)> < 0 : f(x) and g(x) form the shape of an obtuse angle (\_) With : f(x) = cos(x) g(x) = x**2 (+3.142 int( (f(x) g(x)) dx = -12.566371 (-3.142 fdot2_R(f,g) = -12.566371 Press return to continue. </syntaxhighlight> {{AutoCat}} haxoejuh6wdxe2fikur8cxuopm3710n Mathc matrices/0k5 0 84468 771180 2026-08-22T07:58:49Z Xhungab 23827 news 771180 wikitext text/x-wiki [[Catégorie:Mathc matrices (livre)]] [[Mathc matrices/0k3| '''Application''']] Installer et compiler ces fichiers dans votre répertoire de travail. {{Fichier|c1a1.c|largeur=70%|info=|icon=Crystal128-source-c.svg}} <syntaxhighlight lang="c"> /* ---------------------------------- */ /* save as c1a1.c */ /* ---------------------------------- */ #include "hfile.h" #include "fa.h" /* ---------------------------------- */ int main(void) { int n = 2*500; double a = -PI; double b = +PI; clrscrn(); printf(" Compute the norm in the space of function.\n\n" " (+PI \n" " (<f(x),f(x)>)^(1/2) = sqrt[ int( f(x) f(x) dx ] \n" " (-PI \n\n"); printf(" With :\n\n" " f(x) = %s\n\n" " (%+.3f\n" " ||f(x)|| = sqrt[int( (%s) dx] = %.9f\n" " (%+.3f\n\n\n\n", feq, b, ffeq, sqrt(simpson(ff,a,b,n)), a); printf(" fnorm2_R(f) = %.9f\n\n", fnorm2_R(f,a,b,n)); stop(); return 0; } /* ---------------------------------- */ /* ---------------------------------- */ </syntaxhighlight> '''Exemple de sortie écran :''' <syntaxhighlight lang="c"> Compute the norm in the space of function. (+PI (<f(x),f(x)>)^(1/2) = sqrt[ int( f(x) f(x) dx ] (-PI With : f(x) = cos(x) (+3.142 ||f(x)|| = sqrt[int( (f(x) f(x)) dx] = 1.772453851 (-3.142 fnorm2_R(f) = 1.772453851 Press return to continue. </syntaxhighlight> {{AutoCat}} ol356dgahc995cwycgud85jnm3sy6ed Mathc matrices/0k6 0 84469 771181 2026-08-22T08:00:17Z Xhungab 23827 news 771181 wikitext text/x-wiki [[Catégorie:Mathc matrices (livre)]] [[Mathc matrices/0k3| '''Application''']] Installer et compiler ces fichiers dans votre répertoire de travail. {{Fichier|c3a1.c|largeur=70%|info=|icon=Crystal128-source-c.svg}} <syntaxhighlight lang="c"> /* ---------------------------------- */ /* save as c3a1.c */ /* ---------------------------------- */ #include "hfile.h" #include "fa.h" /* ---------------------------------- */ int main(void) { int n = 2*500; double a = -PI; double b = +PI; clrscrn(); printf(" Compute the distance in the inner product" " in the space of function.\n\n" " (+PI \n" " <f(x),g(x)> = int( f(x) g(x) dx \n" " (-PI \n\n" " dist(f(x),g(x)) = ||f(x)-g(x)|| = <f(x)-g(x),f(x)-g(x)>^1/2\n\n\n"); printf(" With :\n\n" " f(x) = %s\n" " g(x) = %s\n\n" " (%+.3f\n" " sqrt( int( (%s) dx ) = %.6f\n" " (%+.3f\n\n", feq,geq, b, fmnsgeq, sqrt(simpson(fmnsg,a,b,n)), a); printf(" fdist2_R(f,g) = %.6f\n\n", fdist2_R(g,f,a,b,n)); stop(); return 0; } /* ---------------------------------- */ /* ---------------------------------- */ </syntaxhighlight> '''Exemple de sortie écran :''' <syntaxhighlight lang="c"> Compute the distance in the inner product in the space of function. (+PI <f(x),g(x)> = int( f(x) g(x) dx (-PI dist(f(x),g(x)) = ||f(x)-g(x)|| = <f(x)-g(x),f(x)-g(x)>^1/2 With : f(x) = cos(x) g(x) = x**2 (+3.142 sqrt( int( ((f(x)-g(x)) (f(x)-g(x))) dx ) = 12.275268 (-3.142 fdist2_R(f,g) = 12.275268 Press return to continue. </syntaxhighlight> {{AutoCat}} 1p2up3h3baidk898rpw5pkb5le7ru3i Mathc matrices/0k7 0 84470 771182 2026-08-22T08:01:29Z Xhungab 23827 news 771182 wikitext text/x-wiki [[Catégorie:Mathc matrices (livre)]] [[Mathc matrices/0k3| '''Application''']] Installer et compiler ces fichiers dans votre répertoire de travail. {{Fichier|c4a1.c|largeur=70%|info=|icon=Crystal128-source-c.svg}} <syntaxhighlight lang="c"> /* ---------------------------------- */ /* save as c4a1.c */ /* ---------------------------------- */ #include "hfile.h" #include "fa.h" /* ---------------------------------- */ int main(void) { int n = 2*500; double a = -PI; double b = +PI; clrscrn(); printf(" Compute the inner product in the space of function.\n\n" " (+PI \n" " <f(x),g(x)> = int( f(x) g(x) dx \n" " (-PI \n\n" " if <f(x),g(x)> = 0 : f(x) and g(x) are orthogonal (90°)\n" " if <f(x),g(x)> > 0 : f(x) and g(x) form the shape of an acute angle (< )\n" " if <f(x),g(x)> < 0 : f(x) and g(x) form the shape of an obtuse angle (\\_)\n\n\n"); stop(); clrscrn(); printf(" With :\n\n" " f(x) = %s\n" " g(x) = %s\n\n" " (%+.3f\n" " int( (%s) dx = %.6f\n" " (%+.3f\n\n", feq,geq, b, fgeq, simpson(fg,a,b,n), a); printf(" <f,g> \n" " ----------- = %+.4f \n" " ||f|| ||g|| \n\n", fdot_R(fg,a,b,n) / ( fnorm_R(ff,a,b,n) * fnorm_R(gg,a,b,n) )); printf(" <f,g> \n" " cos(alpha) = ----------- = %+.4f arcos(cos(alpha)) = %+.4f°\n" " ||f|| ||g|| \n\n", fcos_R(fg,ff,gg,a,b,n), acos(fcos_R(fg,ff,gg,a,b,n))*180/PI ); printf(" <f,g> \n" " cos2(alpha) = ----------- = %+.4f arcos(cos2(alpha)) = %+.4f°\n" " ||f|| ||g|| \n\n", fcos2_R(f,g,a,b,n), acos(fcos2_R(f,g,a,b,n))*180/PI ); stop(); return 0; } /* ---------------------------------- */ /* ---------------------------------- */ </syntaxhighlight> '''Exemple de sortie écran :''' <syntaxhighlight lang="c"> With : f(x) = cos(x) g(x) = x**2 (+3.142 int( (f(x) g(x)) dx = -12.566371 (-3.142 <f,g> ----------- = -0.6408 ||f|| ||g|| <f,g> cos(alpha) = ----------- = -0.6408 arcos(cos(alpha)) = +129.8524° ||f|| ||g|| <f,g> cos2(alpha) = ----------- = -0.6408 arcos(cos2(alpha)) = +129.8524° ||f|| ||g|| Press return to continue. </syntaxhighlight> {{AutoCat}} noxyetawrmvx5s5am5ewntm8l4y9oo9 Mathc matrices/0kg 0 84471 771186 2026-08-22T08:54:54Z Xhungab 23827 news 771186 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc matrices (livre)]] [[Mathc_matrices/Sommaire#Espace euclidien| Sommaire]] {{Partie{{{type|}}}| Produits scalaires dans f(x) avec poids Version (2)}} Un espace euclidien est un espace vectoriel de dimension finie muni d’un produit scalaire. Nous allons ici introduire un produit scalaire sur deux fonctions (vecteurs) avec poids. f(x) = cos(x) g(x) = x**2 Le produit scalaire choisi sera : (+PI <f(x),g(x)> = int( f(x) g(x) W(x) dx (-PI '''Dans la première version, nous avons utilisé la fonction simpson(), qui ne permettait de ne rentrer qu'une seule fonction en argument. Dans cette nouvelle version nous allons réécrire cette fonction pour qu'elle accepte plusieurs fonctions mais aussi, qu'elle fasse tous les calculs intérmédiaires.''' '''Copier la bibliothèque dans votre répertoire de travail :''' * [[Mathc matrices/0ka|hfile.h ............ Déclaration des fichiers h]] * [[Mathc matrices/0ae|hdx.h ............. Intégrale simple]] * [[Mathc matrices/0ce|ffdot.h ............ Les fontions de bases]] * [[Mathc matrices/0kb|ffdot3.h .......... Les fontions de bases]] ... Version (2) * [[Mathc matrices/0j4|fa.h ................ Les fonctions f, g]] {{Partie{{{type|}}}| Produit Scalaire :}} '''Nous allons commencer par calculer les produits scalaires, ce qui nous permettra de déterminer les angles qu'ils existent entre ces fonctions (vecteurs).''' * si <f(x),g(x)> = 0 : f(x) et g(x) sont orthogonaux * if <f(x),g(x)> > 0 : f(x) et g(x) forme un angle aigu (< ) * if <f(x),g(x)> < 0 : f(x) et g(x) forme un angle obtu (\\_) * <f(x),g(x)> ......... : [[Mathc matrices/0kc| c0a1.c ]] {{Partie{{{type|}}}| La norme :}} '''Nous allons calculer les normes des fonctions (vecteurs). Cela nous donnera leurs longueurs.''' * ||f(x)|| .................. : [[Mathc matrices/0kd| c1a1.c ]] {{Partie{{{type|}}}| Calculer la distance entre deux vecteurs :}} * d(f(x),g(x)) ......... : [[Mathc matrices/0ke| c3a1.c ]] {{Partie{{{type|}}}| Calculer l'angle entre deux vecteurs :}} * cos(f(x),g(x)) ......... : [[Mathc matrices/0kf| c4a1.c ]] {{AutoCat}} hwabkht1hea1tucdo3v0oom89ft2re9 Mathc matrices/0ka 0 84472 771187 2026-08-22T08:56:01Z Xhungab 23827 news 771187 wikitext text/x-wiki [[Catégorie:Mathc matrices (livre)]] '''Ce fichier est spécifique pour ce travail. Il ne doit pas être conservé avec la bibliothèque.''' Installer ces fichiers dans votre répertoire de travail. {{Fichier|hfile.h|largeur=70%|info=|icon=Crystal Clear mimetype source h.png}} <syntaxhighlight lang="c"> /* ---------------------------------- */ /* save as hfile.h */ /* ---------------------------------- */ #include <stdio.h> #include <stdlib.h> #include <ctype.h> #include <time.h> #include <math.h> #include <string.h> /* ---------------------------------- */ #include "z_s.h" #include "z_d.h" #include "hdx.h" #include "ffdot.h" #include "ffdot3.h" /* ---------------------------------- */ /* ---------------------------------- */ </syntaxhighlight> [[Mathc matrices/c21r|.]] {{AutoCat}} qf1sj3qoekkc8ystjh47k1fkrk23ik7 Mathc matrices/0kb 0 84473 771188 2026-08-22T08:57:25Z Xhungab 23827 news 771188 wikitext text/x-wiki [[Catégorie:Mathc matrices (livre)]] '''Ce fichier est spécifique pour ce travail. Il ne doit pas être conservé avec la bibliothèque.''' Installer ces fichiers dans votre répertoire de travail. {{Fichier|ffdot3.h|largeur=70%|info=|icon=Crystal Clear mimetype source h.png}} <syntaxhighlight lang="c"> /* ---------------------------------- */ /* save as ffdot3.h */ /* ---------------------------------- */ double fdot3_R( double (*P_f)(double x), double (*P_g)(double x), double (*P_W)(double x), double a, double b, int n ) { int i = 0; double m = 0.; double M = 0.; double t = 0.; for(i = 0; i <= n; i++) { if(i ==0 || i== n){m = 1.;} else if(fmod(i,2) == 0){m = 2.;} else {m = 4.;} t = a + i*(b-a)/n; M += m * (*P_f)(t) * (*P_g)(t) * (*P_W)(t); } return( ((b -a)*M) / (3*n) ); } /* ---------------------------------- */ /* ---------------------------------- */ double fnorm3_R( double (*P_f)(double x), double (*P_W)(double x), double a, double b, int n ) { int i = 0; double m = 0.; double M = 0.; double t = 0.; for(i = 0; i <= n; i++) { if(i ==0 || i== n){m = 1.;} else if(fmod(i,2) == 0){m = 2.;} else {m = 4.;} t = a + i*(b-a)/n; M += m * (*P_f)(t) * (*P_f)(t) * (*P_W)(t); } return( sqrt( ((b -a)*M)/(3*n) ) ); } /* ---------------------------------- */ /* ---------------------------------- */ double fdist3_R( double (*P_f)(double x), double (*P_g)(double x), double (*P_W)(double x), double a, double b, int n ) { int i = 0; double m = 0.; double M = 0.; double t = 0.; for(i = 0; i <= n; i++) { if(i ==0 || i== n){m = 1.;} else if(fmod(i,2) == 0){m = 2.;} else {m = 4.;} t = a + i*(b-a)/n; M += m * ((*P_f)(t)-(*P_g)(t)) * ((*P_f)(t)-(*P_g)(t)) * (*P_W)(t); } return( sqrt(((b -a)*M)/(3*n)) ); } /* ------------------------------------ */ /* cos(alpha) = <p,q> / ||p|| ||q|| */ /* ------------------------------------ */ double fcos3_R( double (*P_f)(double x), double (*P_g)(double x), double (*P_W)(double x), double a, double b, int n ) { return(( fdot3_R(P_f,P_g,P_W,a,b,n) / (fnorm3_R(P_f,P_W,a,b,n) * fnorm3_R(P_g,P_W,a,b,n)) )); } /* ------------------------------------ */ /* ------------------------------------ */ </syntaxhighlight> [[Mathc matrices/c21r|.]] {{AutoCat}} bnr5hdp10d4t1rid6hllgvkslohgsds Mathc matrices/0kc 0 84474 771189 2026-08-22T09:01:00Z Xhungab 23827 news 771189 wikitext text/x-wiki [[Catégorie:Mathc matrices (livre)]] [[Mathc matrices/0kg| '''Application''']] Installer et compiler ces fichiers dans votre répertoire de travail. {{Fichier|c0a1.c|largeur=70%|info=|icon=Crystal128-source-c.svg}} <syntaxhighlight lang="c"> /* ---------------------------------- */ /* save as c0a1.c */ /* ---------------------------------- */ #include "hfile.h" #include "fa.h" /* ---------------------------------- */ int main(void) { int n = 2*500; double b = +PI; double a = +1; clrscrn(); printf(" Compute the inner product in the space of function.\n\n" " (+PI \n" " <f(x),g(x)> = int( f(x) g(x) w(x) dx \n" " (-PI \n\n" " if <f(x),g(x)> = 0 : f(x) and g(x) are orthogonal\n" " if <f(x),g(x)> > 0 : f(x) and g(x) form the shape of an acute angle (< )\n" " if <f(x),g(x)> < 0 : f(x) and g(x) form the shape of an obtuse angle (\\_)\n\n\n"); printf(" With :\n\n" " W(x) = %s\n" " f(x) = %s\n" " g(x) = %s\n\n" " (%+.3f\n" " int( (%s) dx = %.6f\n" " (%+.3f\n\n", Weq,feq,geq, b, fgeq, simpson(fg,a,b,n), a); printf(" fdot3_R(f,g,W) = %.6f\n\n", fdot3_R(f,g,W,a,b,n)); stop(); return 0; } /* ---------------------------------- */ /* ---------------------------------- */ </syntaxhighlight> '''Exemple de sortie écran :''' <syntaxhighlight lang="c"> Compute the inner product in the space of function. (+PI <f(x),g(x)> = int( f(x) g(x) w(x) dx (-PI if <f(x),g(x)> = 0 : f(x) and g(x) are orthogonal if <f(x),g(x)> > 0 : f(x) and g(x) form the shape of an acute angle (< ) if <f(x),g(x)> < 0 : f(x) and g(x) form the shape of an obtuse angle (\_) With : W(x) = [1/x] > 0 f(x) = cos(x) g(x) = x**2 (+3.142 int( (f(x) g(x) W(x)) dx = -2.381773 (+1.000 fdot3_R(f,g,W) = -2.381773 Press return to continue. </syntaxhighlight> {{AutoCat}} f9feqrztuhfs6818f7duqv0fucplhvh Mathc matrices/0kd 0 84475 771190 2026-08-22T09:02:42Z Xhungab 23827 news 771190 wikitext text/x-wiki [[Catégorie:Mathc matrices (livre)]] [[Mathc matrices/0kg| '''Application''']] Installer et compiler ces fichiers dans votre répertoire de travail. {{Fichier|c1a1.c|largeur=70%|info=|icon=Crystal128-source-c.svg}} <syntaxhighlight lang="c"> /* ---------------------------------- */ /* save as c1a1.c */ /* ---------------------------------- */ #include "hfile.h" #include "fa.h" /* ---------------------------------- */ int main(void) { int n = 2*500; double b = +PI; double a = +1; clrscrn(); printf(" Compute the norm in the space of function.\n\n" " (+PI \n" " (<f(x),f(x)>)^(1/2) = sqrt[ int( f(x) f(x) W(x) dx ] \n" " (-PI \n\n"); printf(" With :\n\n" " W(x) = %s\n\n" " f(x) = %s\n\n" " (%+.3f\n" " ||f(x)|| = sqrt[int( (%s) dx] = %.9f\n" " (%+.3f\n\n\n\n", Weq,feq, b, ffeq, sqrt(simpson(ff,a,b,n)), a); printf(" fnorm3_R(f,W) = %.9f\n\n", fnorm3_R(f,W,a,b,n)); stop(); return 0; } /* ---------------------------------- */ /* ---------------------------------- */ </syntaxhighlight> '''Exemple de sortie écran :''' <syntaxhighlight lang="c"> Compute the norm in the space of function. (+PI (<f(x),f(x)>)^(1/2) = sqrt[ int( f(x) f(x) W(x) dx ] (-PI With : W(x) = [1/x] > 0 f(x) = cos(x) (+3.142 ||f(x)|| = sqrt[int( (f(x) f(x) W(x)) dx] = 0.591264913 (+1.000 fnorm3_R(f,W) = 0.591264913 Press return to continue. </syntaxhighlight> {{AutoCat}} 7hea0wgn5wg4l6d00qa11dmtgcyqwsq Mathc matrices/0ke 0 84476 771191 2026-08-22T09:04:19Z Xhungab 23827 news 771191 wikitext text/x-wiki [[Catégorie:Mathc matrices (livre)]] [[Mathc matrices/0kg| '''Application''']] Installer et compiler ces fichiers dans votre répertoire de travail. {{Fichier|c3a1.c|largeur=70%|info=|icon=Crystal128-source-c.svg}} <syntaxhighlight lang="c"> /* ---------------------------------- */ /* save as c3a1.c */ /* ---------------------------------- */ #include "hfile.h" #include "fa.h" /* ---------------------------------- */ int main(void) { int n = 2*500; double b = +PI; double a = +1; clrscrn(); printf(" Compute the distance in the inner product" " in the space of function.\n\n" " (+PI \n" " <f(x),g(x)> = int( f(x) g(x) W(x) dx \n" " (-PI \n\n" " dist(f(x),g(x)) = ||f(x)-g(x)|| = <f(x)-g(x),f(x)-g(x)>^1/2\n\n\n"); printf(" With :\n\n" " W(x) = %s\n\n" " f(x) = %s\n" " g(x) = %s\n\n" " (%+.3f\n" " sqrt( int( (%s) dx ) = %.6f\n" " (%+.3f\n\n", Weq,feq,geq, b, fmnsgeq, sqrt(simpson(fmnsg,a,b,n)), a); printf(" fdist3_R(f,g,W) = %.6f\n\n", fdist3_R(f,g,W,a,b,n)); stop(); return 0; } /* ---------------------------------- */ /* ---------------------------------- */ </syntaxhighlight> '''Exemple de sortie écran :''' <syntaxhighlight lang="c"> Compute the distance in the inner product in the space of function. (+PI <f(x),g(x)> = int( f(x) g(x) W(x) dx (-PI dist(f(x),g(x)) = ||f(x)-g(x)|| = <f(x)-g(x),f(x)-g(x)>^1/2 With : W(x) = [1/x] > 0 f(x) = cos(x) g(x) = x**2 (+3.142 sqrt( int( ((f(x)-g(x)) (f(x)-g(x) W(x))) dx ) = 5.405128 (+1.000 fdist3_R(f,g,W) = 5.405128 Press return to continue. </syntaxhighlight> {{AutoCat}} 9l7zhaau7fkh8imf42fj8cioe276nmj Mathc matrices/0kf 0 84477 771192 2026-08-22T09:06:10Z Xhungab 23827 news 771192 wikitext text/x-wiki [[Catégorie:Mathc matrices (livre)]] [[Mathc matrices/0kg| '''Application''']] Installer et compiler ces fichiers dans votre répertoire de travail. {{Fichier|c4a1.c|largeur=70%|info=|icon=Crystal128-source-c.svg}} <syntaxhighlight lang="c"> /* ---------------------------------- */ /* save as c4a1.c */ /* ---------------------------------- */ #include "hfile.h" #include "fa.h" /* ---------------------------------- */ int main(void) { int n = 2*500; double b = +PI; double a = +1; clrscrn(); printf(" Compute the inner product in the space of function.\n\n" " (+PI \n" " <f(x),g(x)> = int( f(x) g(x) W(x) dx \n" " (-PI \n\n" " if <f(x),g(x)> = 0 : f(x) and g(x) are orthogonal (90°)\n" " if <f(x),g(x)> > 0 : f(x) and g(x) form the shape of an acute angle (< )\n" " if <f(x),g(x)> < 0 : f(x) and g(x) form the shape of an obtuse angle (\\_)\n\n\n"); stop(); clrscrn(); printf(" With :\n\n" " W(x) = %s\n\n" " f(x) = %s\n" " g(x) = %s\n\n" " (%+.3f\n" " int( (%s) dx = %.6f\n" " (%+.3f\n\n", Weq,feq,geq, b, fgeq, simpson(fg,a,b,n), a); printf(" <f,g> \n" " ----------- = %+.4f \n" " ||f|| ||g|| \n\n", fdot_R(fg,a,b,n) / ( fnorm_R(ff,a,b,n) * fnorm_R(gg,a,b,n) )); printf(" <f,g> \n" " cos3(alpha) = ----------- = %+.4f arcos(cos3(alpha)) = %+.4f°\n" " ||f|| ||g|| \n\n", fcos3_R(f,g,W,a,b,n), acos(fcos3_R(f,g,W,a,b,n))*180/PI ); stop(); return 0; } /* ---------------------------------- */ /* ---------------------------------- */ </syntaxhighlight> '''Exemple de sortie écran :''' <syntaxhighlight lang="c"> Compute the inner product in the space of function. (+PI <f(x),g(x)> = int( f(x) g(x) W(x) dx (-PI if <f(x),g(x)> = 0 : f(x) and g(x) are orthogonal (90°) if <f(x),g(x)> > 0 : f(x) and g(x) form the shape of an acute angle (< ) if <f(x),g(x)> < 0 : f(x) and g(x) form the shape of an obtuse angle (\_) Press return to continue. With : W(x) = [1/x] > 0 f(x) = cos(x) g(x) = x**2 (+3.142 int( (f(x) g(x) W(x)) dx = -2.381773 (+1.000 <f,g> ----------- = -0.8205 ||f|| ||g|| <f,g> cos3(alpha) = ----------- = -0.8205 arcos(cos3(alpha)) = +145.1369° ||f|| ||g|| Press return to continue. </syntaxhighlight> {{AutoCat}} phoh9gc6hhyh8t1j5n4h8j3ff4nmabt